WsScaffoldInitMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.bootstrap.SubprojectInitializer;
import network.ike.plugin.ws.bootstrap.WorkspaceBootstrap;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.MavenVersion;
import network.ike.workspace.SubprojectName;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.support.ConsoleIkePrompter;
import network.ike.plugin.support.IkePrompter;
import org.apache.maven.api.Session;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Bootstrap a workspace: create it from scratch when there is no
* {@code workspace.yaml}, otherwise clone any declared-but-missing
* subprojects.
*
* <p>This goal subsumes the retired {@code ws:create} and
* {@code ws:init} goals (IKE-Network/ike-issues#393) into a single
* idempotent entry point that "does the right thing" regardless of
* the current state of the directory:
*
* <ul>
* <li><b>No {@code workspace.yaml} in CWD</b> — bootstrap mode. Generates
* {@code pom.xml}, {@code workspace.yaml}, {@code .gitignore},
* {@code .mvn/maven.config}, {@code .mvn/jvm.config},
* {@code README.adoc}, and installs the Maven wrapper. Optionally
* initializes git. Required: {@code -Dgroup=<groupId>}.</li>
* <li><b>{@code workspace.yaml} present</b> — init mode. Walks every
* declared subproject and ensures it is cloned (or initialized in
* place from a Syncthing-synced working tree, or fetched + rebased
* if already a healthy clone). Generates workspace-level docs
* ({@code GOALS.md}, {@code WS-REFERENCE.md},
* {@code CLAUDE.md}).</li>
* <li><b>Both states satisfied</b> — every declared subproject already
* a clean clone, every wrapper at the right version — the goal
* no-ops with a single "all good" line.</li>
* </ul>
*
* <p>Unlike most {@code ws:*} goals this mojo {@code implements Mojo}
* directly rather than extending {@link AbstractWorkspaceMojo}, because
* the bootstrap branch must run <em>before</em> a manifest exists. The
* init branch reads {@code workspace.yaml} inline.
*
* <pre>{@code
* # Bootstrap a new workspace:
* mkdir my-ws && cd my-ws
* mvn ws:scaffold-init -Dname=my-ws -Dgroup=org.example
*
* # Inside an existing workspace, clone any missing subprojects:
* mvn ws:scaffold-init
* }</pre>
*
* @see WorkspaceBootstrap for the create-from-scratch helper
* @see SubprojectInitializer for the clone-missing-subprojects helper
*/
@org.apache.maven.api.plugin.annotations.Mojo(
name = "scaffold-init",
projectRequired = false,
aggregator = true)
public class WsScaffoldInitMojo implements Mojo {
/** Maven logger, injected by the Maven 4 DI container. */
@Inject
private Log log;
/** Maven session — consulted for interactive mode (#385). */
@Inject
private Session session;
/** Interactive prompter, lazily built (IKE-Network/ike-issues#385). */
private IkePrompter prompter;
/**
* Workspace name. Used as the directory name (bootstrap mode), Maven
* artifactId, and in generated documentation. Prompted if omitted
* in bootstrap mode. Ignored in init mode.
*/
@Parameter(property = "name")
String name;
/**
* Short description of the workspace purpose. Defaults to the
* workspace name, which is also used as the Maven POM {@code <name>}
* element (shown in Maven build output). Bootstrap mode only.
*/
@Parameter(property = "description")
String description;
/**
* GitHub organization or user for the remote URL. If set, the
* remote is configured as
* {@code https://github.com/<org>/<name>.git}. Bootstrap mode only.
*/
@Parameter(property = "org")
String org;
/**
* Maven groupId for the workspace root POM. Required in bootstrap
* mode (no default — placeholder GAVs are not supported, see
* ike-issues#183). Persisted to {@code workspace-root.groupId} in
* workspace.yaml.
*
* <p>Convention: {@code network.ike.workspace} for IKE-managed
* workspaces; downstream consumers pick a groupId that fits their
* organization namespace.
*/
@Parameter(property = "group")
String group;
/**
* Initial workspace root version. Single-segment monotonic (per
* {@code feedback_no_semver_assumption}); defaults to
* {@code 1-SNAPSHOT}. The version increments after each release of
* the workspace root, NOT per software-meaningful change. Bootstrap
* mode only.
*/
@Parameter(property = "version", defaultValue = "1-SNAPSHOT")
String version;
/**
* Maven artifactId for the workspace root POM. Defaults to
* {@link #name} when omitted (ike-issues#183). Bootstrap mode only.
*/
@Parameter(property = "artifactId")
String artifactId;
/**
* Default Maven version for subprojects. Written to
* {@code defaults.maven-version} in workspace.yaml. Bootstrap mode
* only.
*/
@Parameter(property = "mavenVersion", defaultValue = "4.0.0-rc-5")
String mavenVersion;
/**
* Default branch for subprojects. Written to
* {@code defaults.branch} in workspace.yaml. Bootstrap mode only.
*/
@Parameter(property = "branch", defaultValue = "main")
String defaultBranch;
/**
* Skip git init and remote setup in bootstrap mode.
*/
@Parameter(property = "skipGit", defaultValue = "false")
boolean skipGit;
/**
* Path to workspace.yaml override (init mode only — bypasses the
* upward-from-CWD search).
*/
@Parameter(property = "workspace.manifest")
File manifest;
/** Creates this goal instance. */
public WsScaffoldInitMojo() {}
@Override
public void execute() throws MojoException {
Path here = Path.of(System.getProperty("user.dir"));
Path manifestPath = (manifest != null && manifest.exists())
? manifest.toPath()
: here.resolve("workspace.yaml");
if (!Files.exists(manifestPath)) {
// No workspace.yaml here — bootstrap a new workspace.
createWorkspace(here);
} else {
// workspace.yaml exists — clone declared-but-missing subprojects
// (and refresh wrappers / docs / CLAUDE.md).
initSubprojects(manifestPath);
}
}
// ── Bootstrap branch (was ws:create) ────────────────────────
private void createWorkspace(Path here) throws MojoException {
if (name == null || name.isBlank()) {
name = promptParam("name", "Workspace name");
}
// -Dgroup is required for real coordinates (ike-issues#183).
// The placeholder local.aggregate:<name>:1.0.0-SNAPSHOT path
// is gone — no known workspace still uses it.
if (group == null || group.isBlank()) {
throw new MojoException(
"ws:scaffold-init in bootstrap mode requires -Dgroup=<groupId> "
+ "(e.g. network.ike.workspace). The workspace root "
+ "needs real Maven coordinates so ws:release-publish, "
+ "ws:align-publish, and site deploy can address it. "
+ "See ike-issues#183.");
}
if (artifactId == null || artifactId.isBlank()) {
artifactId = name;
}
// Validate name + artifactId via SubprojectName and version
// via MavenVersion (#295) — single typed boundary for the
// string parameters that flow into the generated POM. The
// mojo doesn't extend AbstractWorkspaceMojo, so the helpers
// can't be reused; inline the IAE → MojoException conversion.
try {
SubprojectName.of(name);
SubprojectName.of(artifactId);
MavenVersion.of(version);
} catch (IllegalArgumentException e) {
throw new MojoException(e.getMessage());
}
if (description == null || description.isBlank()) {
description = name;
}
Path wsDir = here.resolve(name);
// Fail if workspace files already exist (prevent silent overwrite)
if (Files.exists(wsDir.resolve("pom.xml"))
|| Files.exists(wsDir.resolve("workspace.yaml"))) {
throw new MojoException(
"Workspace already exists at " + wsDir
+ " (pom.xml or workspace.yaml found). "
+ "Remove the directory first or choose a different name.");
}
log.info("");
log.info(name + " — Scaffold-Init (bootstrap)");
log.info("══════════════════════════════════════════════════════════════");
log.info(" Name: " + name);
log.info(" GAV: " + group + ":" + artifactId + ":" + version);
log.info(" Directory: " + wsDir);
if (org != null && !org.isBlank()) {
log.info(" Remote: https://github.com/" + org + "/" + name + ".git");
}
log.info("");
WorkspaceBootstrap.Params params = new WorkspaceBootstrap.Params(
name, description, org, group, artifactId, version,
mavenVersion, defaultBranch, skipGit,
loadBuildProperty("ike-platform.version"));
WorkspaceBootstrap bootstrap = new WorkspaceBootstrap(params, log);
bootstrap.createAt(wsDir);
log.info("");
log.info(Ansi.green(" ✓ ") + "Workspace created: " + wsDir);
log.info("");
log.info(Ansi.yellow(" ⚠ You must change into the workspace directory before running ws: goals:"));
log.info("");
log.info(" " + Ansi.cyan("cd " + name));
log.info(" mvn ws:add -Drepo=<git-url> # add components");
log.info(" mvn ws:scaffold-init # clone components");
log.info("");
WorkspaceReport.write(wsDir, WsGoal.SCAFFOLD_INIT.qualified(),
"Created workspace **" + name + "**\n\nDirectory: `" + wsDir + "`\n",
log);
}
// ── Init branch (was ws:init) ───────────────────────────────
private void initSubprojects(Path manifestPath) throws MojoException {
log.debug("Reading manifest: " + manifestPath);
Manifest m;
try {
m = ManifestReader.read(manifestPath);
} catch (ManifestException e) {
throw new MojoException(
"Failed to read workspace manifest: " + e.getMessage(), e);
}
WorkspaceGraph graph = new WorkspaceGraph(m);
File root = manifestPath.getParent().toFile();
String wsName = resolveWorkspaceName(root);
SubprojectInitializer initializer =
new SubprojectInitializer(graph, root, wsName, log);
SubprojectInitializer.Result result = initializer.run();
if (result.nothingChanged()) {
log.info(Ansi.green(" ✓ ") + "All " + result.alreadyClean()
+ " declared subproject(s) already initialized.");
}
}
/**
* Read the workspace name from the root POM's artifactId. Mirrors
* {@code AbstractWorkspaceMojo#workspaceName()} but is duplicated
* here so this mojo can remain independent of that base class.
*/
private static String resolveWorkspaceName(File root) {
try {
File rootPom = new File(root, "pom.xml");
if (rootPom.exists()) {
return ReleaseSupport.readPomArtifactId(rootPom);
}
} catch (Exception e) {
// Fall through
}
return "Workspace";
}
// ── Helpers ──────────────────────────────────────────────────
/**
* Load a build-time property from {@code ws-plugin.properties}
* (resolved by Maven resource filtering during the build). Used to
* pin the ike-parent version stamped into the generated POM.
*/
private String loadBuildProperty(String key) {
try (var is = getClass().getResourceAsStream("ws-plugin.properties")) {
if (is != null) {
var props = new java.util.Properties();
props.load(is);
String value = props.getProperty(key);
if (value != null && !value.isBlank() && !value.startsWith("${")) {
return value;
}
}
} catch (IOException e) {
// Fall through to fallback
}
// Fallback: use JAR manifest version
String jarVersion = getClass().getPackage().getImplementationVersion();
return jarVersion != null ? jarVersion : "66";
}
private String promptParam(String propertyName, String label)
throws MojoException {
if (prompter == null) {
// No session (unit tests) or batch mode -> not interactive,
// so the prompter declines rather than blocking on stdin.
boolean interactive = session != null
&& session.getSettings().isInteractiveMode();
prompter = new ConsoleIkePrompter(log, interactive);
}
if (prompter.isInteractive()) {
String input = prompter.prompt(label + ": ");
if (input != null && !input.isBlank()) {
return input.trim();
}
}
throw new MojoException(
propertyName + " is required. Specify -D" + propertyName
+ "=<value> or run interactively.");
}
}