WorkspaceBootstrap.java
package network.ike.plugin.ws.bootstrap;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.Ansi;
import network.ike.plugin.ws.MavenWrapper;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
/**
* Generates the on-disk scaffold for a brand-new IKE workspace.
*
* <p>Subsumes the file-generation half of the retired
* {@code WsCreateMojo} (folded into {@code ws:scaffold-init} per
* IKE-Network/ike-issues#393): given a target directory and a small
* parameter bag, writes the standard workspace files
* ({@code pom.xml}, {@code workspace.yaml}, {@code .gitignore},
* {@code .mvn/maven.config}, {@code .mvn/jvm.config},
* {@code README.adoc}) and installs the Maven wrapper via
* {@link MavenWrapper#writeMissingFiles}.
*
* <p>The generated files follow current IKE conventions:
* <ul>
* <li>POM uses Maven 4.1.0 model with {@code root="true"}</li>
* <li>.gitignore uses whitelist strategy (ignore everything,
* whitelist workspace-owned files)</li>
* <li>workspace.yaml has schema-version 1.1 with a typed
* {@code workspace-root:} block holding the workspace's GAV
* (ike-issues#183) and an empty {@code subprojects:} list</li>
* <li>.mvn/maven.config sets {@code -T 1C}</li>
* </ul>
*
* <p>This class is a pure scaffold writer — it does not consult
* {@code workspace.yaml} (none exists yet), does not iterate
* subprojects, and never extends {@code AbstractWorkspaceMojo}.
*
* @see SubprojectInitializer for the "workspace.yaml already exists"
* half of {@code ws:scaffold-init}
*/
public final class WorkspaceBootstrap {
/** Parameters captured from the user-facing mojo. */
public record Params(String name,
String description,
String org,
String group,
String artifactId,
String version,
String mavenVersion,
String defaultBranch,
boolean skipGit,
String parentVersion) {}
private final Params params;
private final Log log;
/**
* Create a workspace bootstrapper bound to a parameter bag and a
* logger. Construction is cheap; the actual filesystem writes
* happen in {@link #createAt(Path)}.
*
* @param params the resolved create-time parameters
* @param log the mojo logger
*/
public WorkspaceBootstrap(Params params, Log log) {
this.params = params;
this.log = log;
}
/**
* Write the full workspace scaffold into {@code wsDir} and
* optionally initialize git. The directory must not already
* contain {@code pom.xml} or {@code workspace.yaml}; the caller
* is responsible for that pre-check.
*
* @param wsDir the workspace directory to populate
* @throws MojoException on file or git failures
*/
public void createAt(Path wsDir) throws MojoException {
try {
Files.createDirectories(wsDir);
Files.createDirectories(wsDir.resolve(".mvn"));
writeFile(wsDir.resolve("pom.xml"), generatePom());
writeFile(wsDir.resolve("workspace.yaml"), generateManifest());
writeFile(wsDir.resolve(".gitignore"), generateGitignore());
writeFile(wsDir.resolve(".mvn/maven.config"), "-T 1C\n");
// .mvn/jvm.config is parsed as raw JVM args, one token per line,
// with NO comment syntax. A `#` at column 0 is passed to the JVM
// as a main-class name and fails with ClassNotFoundException: #.
// Seed a single standard flag so downstream hand-edits start
// from a correct baseline. The flag suppresses sun.misc.Unsafe
// deprecation warnings from JRuby/AsciidoctorJ on JDK 24+.
writeFile(wsDir.resolve(".mvn/jvm.config"),
"--sun-misc-unsafe-memory-access=allow\n");
writeFile(wsDir.resolve("README.adoc"), generateReadme());
MavenWrapper.writeMissingFiles(wsDir, params.mavenVersion());
log.info(Ansi.green(" ✓ ") + "pom.xml");
log.info(Ansi.green(" ✓ ") + "workspace.yaml");
log.info(Ansi.green(" ✓ ") + ".gitignore");
log.info(Ansi.green(" ✓ ") + ".mvn/maven.config");
log.info(Ansi.green(" ✓ ") + ".mvn/jvm.config");
log.info(Ansi.green(" ✓ ") + "README.adoc");
log.info(Ansi.green(" ✓ ") + "mvnw (Maven " + params.mavenVersion() + ")");
} catch (IOException e) {
throw new MojoException(
"Failed to create workspace files: " + e.getMessage(), e);
}
if (!params.skipGit()) {
try {
initGit(wsDir);
} catch (Exception e) {
log.warn(" Git init failed (non-fatal): " + e.getMessage());
log.warn(" Initialize git manually in " + wsDir);
}
}
}
// ── File generators (pure, testable) ─────────────────────────
/**
* Generate the workspace root POM content. Package-private so
* tests can invoke it directly.
*
* @return the {@code pom.xml} text
*/
String generatePom() {
// ike-parent version is the same as ike-platform version (reactor sibling).
String parentVersion = params.parentVersion();
StringBuilder xml = new StringBuilder(2048);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<!--\n");
xml.append(" ").append(params.description()).append("\n");
xml.append("\n");
xml.append(" Inherits from ike-parent to get plugin version management for\n");
xml.append(" ike-maven-plugin and ike-workspace-maven-plugin automatically.\n");
xml.append("\n");
xml.append(" Every subproject is inside a file-activated profile so the reactor\n");
xml.append(" automatically includes only the repos that are physically cloned.\n");
xml.append(" Clone more repos and they join the reactor on the next build.\n");
xml.append("\n");
xml.append(" Usage:\n");
xml.append(" mvn clean install # All cloned repos\n");
xml.append(" mvn ws:scaffold-init # Clone all repos\n");
xml.append(" mvn ws:overview # Workspace overview\n");
xml.append("-->\n");
xml.append("<project xmlns=\"http://maven.apache.org/POM/4.1.0\"\n");
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.append(" xsi:schemaLocation=\"http://maven.apache.org/POM/4.1.0\n");
xml.append(" https://maven.apache.org/xsd/maven-4.1.0.xsd\"\n");
xml.append(" root=\"true\">\n");
xml.append(" <modelVersion>4.1.0</modelVersion>\n\n");
xml.append(" <parent>\n");
xml.append(" <groupId>network.ike.platform</groupId>\n");
xml.append(" <artifactId>ike-parent</artifactId>\n");
xml.append(" <version>").append(parentVersion).append("</version>\n");
xml.append(" <relativePath/>\n");
xml.append(" </parent>\n\n");
xml.append(" <groupId>").append(params.group()).append("</groupId>\n");
xml.append(" <artifactId>").append(params.artifactId()).append("</artifactId>\n");
xml.append(" <version>").append(params.version()).append("</version>\n");
xml.append(" <packaging>pom</packaging>\n\n");
xml.append(" <name>").append(params.description()).append("</name>\n\n");
xml.append(" <build>\n");
xml.append(" <plugins>\n");
xml.append(" <plugin>\n");
xml.append(" <groupId>network.ike.tooling</groupId>\n");
xml.append(" <artifactId>ike-maven-plugin</artifactId>\n");
xml.append(" <!-- version from ike-parent pluginManagement -->\n");
xml.append(" </plugin>\n");
xml.append(" <plugin>\n");
xml.append(" <groupId>network.ike.platform</groupId>\n");
xml.append(" <artifactId>ike-workspace-maven-plugin</artifactId>\n");
xml.append(" <!-- version from ike-parent pluginManagement -->\n");
xml.append(" </plugin>\n");
xml.append(" </plugins>\n");
xml.append(" </build>\n\n");
xml.append(" <!-- Profiles are added by ws:add -->\n");
xml.append(" <profiles>\n");
xml.append(" </profiles>\n\n");
xml.append("</project>\n");
return xml.toString();
}
/**
* Generate the {@code workspace.yaml} manifest content. Package-private
* for tests.
*
* @return the YAML text
*/
String generateManifest() {
String today = LocalDate.now().toString();
String orgName = params.org() != null ? params.org() : "<org>";
StringBuilder yaml = new StringBuilder(1024);
yaml.append("# workspace.yaml — ").append(params.name()).append("\n");
yaml.append("# ").append("═".repeat(params.name().length() + 22)).append("\n");
yaml.append("#\n");
yaml.append("# ").append(params.description()).append("\n");
yaml.append("#\n");
yaml.append("# Bootstrap:\n");
yaml.append("# git clone https://github.com/").append(orgName).append("/").append(params.name()).append(".git\n");
yaml.append("# cd ").append(params.name()).append("\n");
yaml.append("# mvn ws:scaffold-init\n");
yaml.append("# mvn clean install\n\n");
yaml.append("schema-version: \"1.1\"\n");
yaml.append("generated: ").append(today).append("\n\n");
// Workspace root coordinates (#183) — real GAV for ws:release-publish,
// ws:align-publish, and site deploy to address. Single-segment monotonic
// version (not semver — per feedback_no_semver_assumption).
yaml.append("workspace-root:\n");
yaml.append(" groupId: ").append(params.group()).append("\n");
yaml.append(" artifactId: ").append(params.artifactId()).append("\n");
yaml.append(" version: ").append(params.version()).append("\n\n");
yaml.append("defaults:\n");
yaml.append(" branch: ").append(params.defaultBranch()).append("\n");
yaml.append(" maven-version: \"").append(params.mavenVersion()).append("\"\n\n");
yaml.append("subprojects:\n");
yaml.append(" # Add subprojects with: mvn ws:add -Drepo=<git-url>\n\n");
yaml.append("# Optional: IntelliJ project settings shared across collaborators.\n");
yaml.append("# Uncomment and set to have `ws:scaffold-publish` enforce these values in\n");
yaml.append("# .idea/misc.xml on every run. Useful when the project uses\n");
yaml.append("# --enable-preview (set language-level to JDK_NN_PREVIEW).\n");
yaml.append("# ide:\n");
yaml.append("# language-level: JDK_25_PREVIEW\n");
yaml.append("# jdk-name: \"25\"\n");
return yaml.toString();
}
/**
* Generate the workspace {@code .gitignore} using the whitelist
* strategy: ignore everything by default, then allowlist only
* workspace-owned files. Subproject repos are independent git
* repos cloned by {@code ws:scaffold-init}, so they must stay
* ignored at the workspace level.
*
* <p>The generated file includes a curated {@code .idea/} slice so
* that fresh checkouts land at the correct IntelliJ project
* settings (JDK, language level including preview mode, encoding,
* Maven repositories) without per-collaborator manual setup.
* {@code compiler.xml} and {@code vcs.xml} are intentionally not
* allowlisted — they regenerate on every Maven reload or per
* workspace membership and would cause constant diff churn.
* User-specific state ({@code workspace.xml}, {@code shelf/},
* {@code httpRequests/}) is excluded by IntelliJ's own
* {@code .idea/.gitignore}.
*
* @return the {@code .gitignore} content
*/
String generateGitignore() {
StringBuilder gi = new StringBuilder(768);
gi.append("# ").append(params.name()).append(" .gitignore\n");
gi.append("# ").append("═".repeat(params.name().length() + 11)).append("\n");
gi.append("#\n");
gi.append("# Ignore everything, whitelist only workspace-owned files.\n");
gi.append("# Subproject repos are independent git repos cloned by ws:scaffold-init.\n\n");
gi.append("# ── Ignore everything by default ─────────────────────────────────\n");
gi.append("*\n\n");
gi.append("# ── Whitelist workspace-level files ──────────────────────────────\n");
gi.append("!.gitignore\n");
gi.append("!pom.xml\n");
gi.append("!workspace.yaml\n");
gi.append("!README.adoc\n");
gi.append("!GOALS.md\n");
gi.append("!WS-REFERENCE.md\n");
gi.append("!mvnw\n");
gi.append("!mvnw.cmd\n\n");
gi.append("# ── Whitelist workspace-owned directories ────────────────────────\n");
gi.append("!.mvn/\n");
gi.append("!.mvn/**\n");
gi.append("!checkpoints/\n");
gi.append("!checkpoints/**\n");
gi.append("!.run/\n");
gi.append("!.run/**\n\n");
gi.append("# ── IntelliJ project config (curated slice) ──────────────────────\n");
gi.append("# Small, stable project-wide settings shared across collaborators.\n");
gi.append("# compiler.xml and vcs.xml are excluded — they regenerate per\n");
gi.append("# Maven reload or per workspace membership.\n");
gi.append("!.idea/\n");
gi.append("!.idea/.gitignore\n");
gi.append("!.idea/misc.xml\n");
gi.append("!.idea/kotlinc.xml\n");
gi.append("!.idea/encodings.xml\n");
gi.append("!.idea/jarRepositories.xml\n");
return gi.toString();
}
/**
* Generate the boilerplate {@code README.adoc}.
*
* @return the AsciiDoc content
*/
String generateReadme() {
String orgName = params.org() != null ? params.org() : "<org>";
StringBuilder adoc = new StringBuilder(1024);
adoc.append("= ").append(params.description()).append("\n");
adoc.append(":toc:\n");
adoc.append(":toc-placement!:\n\n");
adoc.append(params.description()).append("\n\n");
adoc.append("toc::[]\n\n");
adoc.append("== Bootstrap\n\n");
adoc.append("[source,bash]\n");
adoc.append("----\n");
adoc.append("git clone https://github.com/").append(orgName).append("/").append(params.name()).append(".git\n");
adoc.append("cd ").append(params.name()).append("\n");
adoc.append("mvn ws:scaffold-init # <1>\n");
adoc.append("mvn clean install # <2>\n");
adoc.append("----\n");
adoc.append("<1> Clones all subproject repos in dependency order; installs Maven\n");
adoc.append(" wrapper and JVM config per subproject.\n");
adoc.append("<2> Builds the full stack.\n\n");
adoc.append("== Workspace Commands\n\n");
adoc.append("All `ws:` goals appear in the IntelliJ Maven tool window\n");
adoc.append("(under _Plugins > ws_). Double-click any goal to run it.\n\n");
adoc.append("[source,bash]\n");
adoc.append("----\n");
adoc.append("mvn ws:overview # Workspace overview\n");
adoc.append("mvn ws:add -Drepo= # Add a subproject repo\n");
adoc.append("mvn ws:scaffold-draft # Preview scaffold and reconciliation drift\n");
adoc.append("----\n");
return adoc.toString();
}
// ── Git init ────────────────────────────────────────────────
private void initGit(Path wsDir) throws MojoException {
ReleaseSupport.exec(wsDir.toFile(), log, "git", "init");
log.info(Ansi.green(" ✓ ") + "git init");
if (params.org() != null && !params.org().isBlank()) {
String remoteUrl = "https://github.com/" + params.org() + "/"
+ params.name() + ".git";
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "remote", "add", "origin", remoteUrl);
log.info(Ansi.green(" ✓ ") + "remote: " + remoteUrl);
}
// Auto-commit scaffold so ws:add and ws:feature-start have a
// baseline commit to work from.
try {
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "add", ".");
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "commit", "-m", "workspace: initial scaffold");
log.info(Ansi.green(" ✓ ") + "initial commit");
} catch (MojoException e) {
log.warn(" Auto-commit failed (set git user.email/user.name): "
+ e.getMessage());
}
}
private static void writeFile(Path path, String content) throws IOException {
Files.writeString(path, content, StandardCharsets.UTF_8);
}
}