diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb93c68..ea64684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,14 +7,22 @@ on: jobs: build: + name: Build and Test (Java ${{ matrix.java }}) runs-on: ubuntu-latest + strategy: + matrix: + java: ['11', '17'] + steps: - - uses: actions/checkout@v3 - - name: Set up JDK - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: ${{ matrix.java }} distribution: 'temurin' + cache: 'maven' + - name: Build and test run: mvn clean verify diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a7a9538 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance for Claude Code when working in this repository. + +## Repository overview + +This is the **install-skill CLI** — a Java command-line tool (PicoCLI + embedded Maven) for installing AI assistant skills deployed with the skills-jar-maven-plugin. Skills are resolved from the [skills registry](https://github.com/webliteca/skills-registry) or by Maven coordinates. + +## Key files + +- `src/main/java/ca/weblite/installskill/InstallSkillCommand.java` — Main CLI entry point and command implementation. Handles single-skill install and batch install from `.skills-versions`. +- `src/main/java/ca/weblite/installskill/SkillCoordinates.java` — Immutable value class for resolved Maven coordinates (name, groupId, artifactId, version). +- `src/main/java/ca/weblite/installskill/SkillVersionsFile.java` — Parser for `.skills-versions` files. +- `src/main/java/ca/weblite/installskill/SkillLockFile.java` — Read/write for `.skills-versions.lock` (JSON). Includes resolution plan computation (comparing desired vs locked state). +- `pom.xml` — Maven build configuration. Java 11 target, PicoCLI + Maven Embedder dependencies. +- `package.json` — npm/jDeploy configuration for distribution as a native CLI. + +## Architecture + +### Two execution modes + +1. **Single-skill mode** (`install-skill `): resolves one skill, creates a temp Maven project, runs `skills-jar-plugin:install`, copies result to target directory. No interaction with `.skills-versions` or lock files. + +2. **Batch mode** (`install-skill` with no arguments): reads `.skills-versions` from the working directory, uses `.skills-versions.lock` for reproducible resolution, installs all listed skills sequentially. + +### Key method flow in `InstallSkillCommand` + +- `call()` — dispatcher: delegates to `installSingleSkill()` or `installFromVersionsFile()` +- `resolveSkillCoordinates(String)` — parses raw input (registry name, `name@version`, or Maven coords) into `SkillCoordinates` +- `resolveRegistryName(String, String)` — looks up a skill name in the XML registry +- `installResolved(String, String, String)` — creates temp Maven project and installs a single resolved skill +- `installFromVersionsFile()` — batch flow: parse versions file, compute resolution plan against lock, resolve new entries, install all, write lock + +### Lock file resolution plan + +`SkillLockFile.computeResolutionPlan()` compares `.skills-versions` entries against `.skills-versions.lock`: +- **Reusable**: entry exists in lock and `requestedVersion` matches — skip resolution +- **To resolve**: new entry or `requestedVersion` changed — needs fresh resolution +- **Removed**: in lock but not in `.skills-versions` — dropped from updated lock + +## `.skills-versions` format + +``` +# Comment +skill-name@0.1.0 +skill-name +com.example:my-lib@1.0 +``` + +## `.skills-versions.lock` format (JSON) + +```json +{ + "lockVersion": 1, + "skills": { + "skill-name": { + "name": "skill-name", + "groupId": "com.example", + "artifactId": "my-lib", + "version": "0.1.0", + "requestedVersion": "0.1.0" + } + } +} +``` + +## Build and test commands + +```bash +# Compile +mvn compile + +# Run unit tests +mvn test + +# Run integration tests (requires Maven on PATH) +mvn verify + +# Package as shaded JAR +mvn package +``` + +## Testing patterns + +- Unit tests (`*Test.java`): test parsing and logic in isolation using `@TempDir`. +- Integration tests (`*IT.java`): install a fixture skills JAR to the local Maven repo in `@BeforeAll`, then exercise the CLI via `new CommandLine(new InstallSkillCommand()).execute(...)`. +- For batch-mode tests, set `cmd.workingDirectory` to control where `.skills-versions` is looked up. +- For registry tests, set `System.setProperty("skills.registry.url", ...)` to a local file URI. + +## Distribution + +The CLI is distributed via npm/jDeploy as the `install-skill` package with native bundles for macOS, Windows, and Linux. diff --git a/README.md b/README.md index 85d8cc4..88d71be 100644 --- a/README.md +++ b/README.md @@ -1 +1,144 @@ -# install-skill-cli \ No newline at end of file +# install-skill-cli + +A CLI tool for installing skills deployed with the [skills-jar-maven-plugin](https://github.com/webliteca/skills-jar-maven-plugin). Skills are AI assistant guidance bundles published as Maven artifacts. + +## Installation + +```bash +npm install -g install-skill +``` + +## Usage + +### Install a single skill + +By registry name: + +```bash +install-skill teavm-lambda +``` + +By registry name with a specific version: + +```bash +install-skill teavm-lambda@0.1.2 +``` + +By Maven coordinates: + +```bash +install-skill ca.weblite:teavm-lambda-parent:0.1.2 +``` + +### Install from a `.skills-versions` file + +When run with no arguments, `install-skill` reads a `.skills-versions` file from the current directory and installs all listed skills: + +```bash +install-skill +``` + +This is the recommended way to manage skills for a project. Add `.skills-versions` to version control so all contributors share the same skill set. + +## `.skills-versions` file + +A plain text file listing skills to install, one per line: + +``` +# Skills for this project +teavm-lambda@0.1.2 +my-other-skill@0.3.1 +some-skill +``` + +Format rules: +- One entry per line: `name@version` or just `name` (latest version) +- Names can be registry skill names or Maven coordinates (`groupId:artifactId`) +- Lines starting with `#` are comments +- Blank lines are ignored + +Examples of valid entries: + +``` +# Registry skill name with pinned version +teavm-lambda@0.1.2 + +# Registry skill name, latest version +teavm-lambda + +# Maven coordinates with version +ca.weblite:teavm-lambda-parent@0.1.2 + +# Maven coordinates, latest version +ca.weblite:teavm-lambda-parent +``` + +## `.skills-versions.lock` file + +After installing from `.skills-versions`, a `.skills-versions.lock` file is created. This JSON file records the resolved Maven coordinates for each skill, enabling reproducible installs across machines and CI. + +The lock file behaves similarly to `composer.lock`: + +- **First install**: resolves all versions from `.skills-versions` and creates the lock file. +- **Subsequent installs**: reuses locked versions for unchanged entries. Only new or changed entries are re-resolved. +- **Version changes**: if you modify a version in `.skills-versions`, that entry is re-resolved on the next install. +- **Force re-resolution**: use `--update` to ignore the lock file and re-resolve everything. + +Add `.skills-versions.lock` to version control to ensure all contributors install the exact same resolved versions. + +## Options + +| Option | Description | +|--------|-------------| +| `-d ` | Skills installation directory (overrides `--global`) | +| `-g, --global` | Install globally to `~/.claude/skills` (default is local: `./.claude/skills`) | +| `-r ` | Repository URL with optional credentials: `[user:pass@]repositoryUrl` | +| `-u, --update` | Force re-resolution of all skill versions, ignoring the lock file | +| `-h, --help` | Show help message | +| `-V, --version` | Show version | + +## Examples + +Install all skills from `.skills-versions` to the default directory: + +```bash +install-skill +``` + +Install all skills to a custom directory: + +```bash +install-skill -d ./my-skills +``` + +Install all skills globally: + +```bash +install-skill --global +``` + +Force re-resolution of all versions (like `composer update`): + +```bash +install-skill --update +``` + +Install a single skill from a private repository: + +```bash +install-skill my-skill@1.0.0 -r user:pass@https://maven.example.com/releases +``` + +## How it works + +1. **Single-skill mode** (`install-skill `): resolves the skill from the [skills registry](https://github.com/webliteca/skills-registry) or by Maven coordinates, creates a temporary Maven project, runs the `skills-jar-plugin:install` goal, and copies the result to the target directory. + +2. **Batch mode** (`install-skill` with no arguments): reads `.skills-versions`, checks `.skills-versions.lock` for previously resolved versions, resolves any new or changed entries, installs each skill, and updates the lock file. + +## Skills registry + +Skills are looked up by name in the [skills registry](https://github.com/webliteca/skills-registry). The registry maps human-readable skill names to Maven coordinates. To register a new skill, open a PR against that repository. + +## License + +Apache License 2.0 diff --git a/src/main/java/ca/weblite/installskill/InstallSkillCommand.java b/src/main/java/ca/weblite/installskill/InstallSkillCommand.java index daaf890..d5fa91a 100644 --- a/src/main/java/ca/weblite/installskill/InstallSkillCommand.java +++ b/src/main/java/ca/weblite/installskill/InstallSkillCommand.java @@ -6,10 +6,8 @@ import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; -import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; import java.net.URL; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -19,8 +17,11 @@ import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.stream.Stream; @@ -37,6 +38,10 @@ *

Sets up a temporary Maven project with the specified skill artifact as a dependency, * runs the skills-jar plugin install goal to resolve and unpack skill bundles, * then copies the installed skills to the target directory.

+ * + *

When invoked with no arguments, reads from a {@code .skills-versions} file in the + * current directory and installs all listed skills. A {@code .skills-versions.lock} file + * records the resolved versions for reproducible installs.

*/ @Command(name = "install-skill", mixinStandardHelpOptions = true, @@ -52,7 +57,9 @@ public class InstallSkillCommand implements Callable { "https://raw.githubusercontent.com/webliteca/skills-registry/main/skills.xml"; @Parameters(index = "0", + arity = "0..1", description = "Skill name or Maven coordinates (groupId:artifactId[:version]). " + + "If omitted, reads from .skills-versions file in the current directory. " + "If no ':' is present, the skill is looked up by name in the skills registry. " + "Use name@version to override the registry version.", paramLabel = "") @@ -72,6 +79,13 @@ public class InstallSkillCommand implements Callable { paramLabel = "") private String skillsDir; + @Option(names = {"-u", "--update"}, + description = "Force re-resolution of all skill versions, ignoring the lock file.") + private boolean update; + + /** Working directory for locating .skills-versions. Package-private for testability. */ + Path workingDirectory; + public static void main(String[] args) { String mode = System.getProperty("jdeploy.mode", null); if ("gui".equals(mode)) { @@ -92,26 +106,80 @@ public static void main(String[] args) { @Override public Integer call() throws Exception { - // 1. Parse coordinates — either Maven format or skill name from registry + if (coordinates != null) { + return installSingleSkill(coordinates); + } else { + return installFromVersionsFile(); + } + } + + // ---- Single-skill install (existing behavior) ---- + + /** + * Installs a single skill given a raw coordinate string. + * This preserves the original CLI behavior for explicit skill arguments. + */ + private Integer installSingleSkill(String rawCoordinates) throws Exception { + SkillCoordinates coords = resolveSkillCoordinates(rawCoordinates); + if (coords == null) { + return 1; + } + return installResolved(coords.getGroupId(), coords.getArtifactId(), coords.getVersion()); + } + + /** + * Resolves a raw skill specifier to concrete Maven coordinates. + * Handles both Maven coordinate format and registry name lookup. + * + * @return resolved coordinates, or null on error (after printing error message) + */ + SkillCoordinates resolveSkillCoordinates(String rawCoordinates) { String groupId; String artifactId; String version; + String name; - if (coordinates.contains(":")) { + if (rawCoordinates.contains(":")) { // Maven coordinates: groupId:artifactId[:version] - String[] parts = coordinates.split(":", -1); - if (parts.length < 2 || parts.length > 3) { - System.err.println("Error: Invalid coordinates format. Expected: groupId:artifactId[:version]"); - return 1; + // But first check if the colon is part of a name@version pattern + // e.g., "com.example:my-lib@1.0" — the @ splits name from version + String coordinatesPart = rawCoordinates; + String versionOverride = null; + int atIdx = rawCoordinates.lastIndexOf('@'); + if (atIdx >= 0) { + versionOverride = rawCoordinates.substring(atIdx + 1).trim(); + coordinatesPart = rawCoordinates.substring(0, atIdx).trim(); + if (versionOverride.isEmpty()) { + System.err.println("Error: Version must not be empty in name@version format."); + return null; + } + } + + if (coordinatesPart.contains(":")) { + // Pure Maven coordinates (possibly with @version override) + String[] parts = coordinatesPart.split(":", -1); + if (parts.length < 2 || parts.length > 3) { + System.err.println("Error: Invalid coordinates format. Expected: groupId:artifactId[:version]"); + return null; + } + groupId = parts[0].trim(); + artifactId = parts[1].trim(); + if (versionOverride != null) { + version = versionOverride; + } else if (parts.length == 3 && !parts[2].trim().isEmpty()) { + version = parts[2].trim(); + } else { + version = DEFAULT_VERSION; + } + name = groupId + ":" + artifactId; + } else { + // The colon was actually inside a @version part somehow — treat as registry name + // This shouldn't normally happen, but handle gracefully + return resolveRegistryName(coordinatesPart, versionOverride); } - groupId = parts[0].trim(); - artifactId = parts[1].trim(); - version = (parts.length == 3 && !parts[2].trim().isEmpty()) - ? parts[2].trim() : DEFAULT_VERSION; } else { // Skill name — look up in registry - // Support name@version syntax to override registry version - String skillName = coordinates.trim(); + String skillName = rawCoordinates.trim(); String versionOverride = null; int atIdx = skillName.indexOf('@'); if (atIdx >= 0) { @@ -119,35 +187,52 @@ public Integer call() throws Exception { skillName = skillName.substring(0, atIdx).trim(); if (versionOverride.isEmpty()) { System.err.println("Error: Version must not be empty in name@version format."); - return 1; + return null; } } - if (skillName.isEmpty()) { - System.err.println("Error: Skill name must not be empty."); - return 1; - } - System.out.println("Looking up skill '" + skillName + "' in registry..."); - String[] resolved = resolveFromRegistry(skillName); - if (resolved == null) { - System.err.println("Error: Skill '" + skillName + "' not found in the skills registry."); - return 1; - } - groupId = resolved[0]; - artifactId = resolved[1]; - if (versionOverride != null) { - version = versionOverride; - } else { - version = resolved[2] != null ? resolved[2] : DEFAULT_VERSION; - } - System.out.println("Resolved to " + groupId + ":" + artifactId + ":" + version); + return resolveRegistryName(skillName, versionOverride); } if (groupId.isEmpty() || artifactId.isEmpty()) { System.err.println("Error: groupId and artifactId must not be empty."); - return 1; + return null; + } + + return new SkillCoordinates(name, groupId, artifactId, version); + } + + private SkillCoordinates resolveRegistryName(String skillName, String versionOverride) { + if (skillName.isEmpty()) { + System.err.println("Error: Skill name must not be empty."); + return null; + } + System.out.println("Looking up skill '" + skillName + "' in registry..."); + String[] resolved = resolveFromRegistry(skillName); + if (resolved == null) { + System.err.println("Error: Skill '" + skillName + "' not found in the skills registry."); + return null; + } + String groupId = resolved[0]; + String artifactId = resolved[1]; + String version; + if (versionOverride != null) { + version = versionOverride; + } else { + version = resolved[2] != null ? resolved[2] : DEFAULT_VERSION; } + System.out.println("Resolved to " + groupId + ":" + artifactId + ":" + version); + return new SkillCoordinates(skillName, groupId, artifactId, version); + } - // 2. Parse repository option + /** + * Installs a single skill given resolved Maven coordinates. + * Creates a temp Maven project, resolves, and copies the skill to the target directory. + * + * @return 0 on success, non-zero on failure + */ + private int installResolved(String groupId, String artifactId, String version) + throws Exception { + // Parse repository option String repoUrl = null; String repoUser = null; String repoPass = null; @@ -158,22 +243,22 @@ public Integer call() throws Exception { repoPass = repoParts[2]; } - // 3. Create temp directory + // Create temp directory Path tempDir = Files.createTempDirectory("install-skill-"); System.out.println("Setting up temporary Maven project..."); try { - // 4. Generate pom.xml in the temp project + // Generate pom.xml in the temp project generatePom(tempDir, groupId, artifactId, version, repoUrl); - // 5. Generate settings.xml if credentials are provided + // Generate settings.xml if credentials are provided boolean hasSettings = false; if (repoUser != null) { generateSettings(tempDir, repoUser, repoPass); hasSettings = true; } - // 6. Run embedded Maven to install skills + // Run embedded Maven to install skills System.out.println("Resolving skill " + groupId + ":" + artifactId + ":" + version + "..."); int exitCode = runMaven(tempDir, hasSettings); if (exitCode != 0) { @@ -181,7 +266,7 @@ public Integer call() throws Exception { return exitCode; } - // 8. Copy installed skills to target directory + // Copy installed skills to target directory Path installedSkillsDir = tempDir.resolve(".claude").resolve("skills"); String resolvedDir = skillsDir != null ? skillsDir : global ? Paths.get(System.getProperty("user.home"), ".claude", "skills").toString() @@ -204,6 +289,133 @@ public Integer call() throws Exception { } } + // ---- Batch install from .skills-versions ---- + + /** + * Installs skills from a {@code .skills-versions} file in the working directory. + * Uses the lock file for reproducible resolution when available. + */ + private Integer installFromVersionsFile() throws Exception { + Path cwd = getWorkingDirectory(); + Path versionsPath = SkillVersionsFile.pathIn(cwd); + + if (!SkillVersionsFile.exists(cwd)) { + System.err.println("Error: No argument provided and no .skills-versions file " + + "found in " + cwd); + return 1; + } + + // 1. Parse .skills-versions + List entries; + try { + entries = SkillVersionsFile.parse(versionsPath); + } catch (IOException e) { + System.err.println("Error: Failed to parse .skills-versions: " + e.getMessage()); + return 1; + } + + if (entries.isEmpty()) { + System.out.println(".skills-versions is empty. Nothing to install."); + return 0; + } + + System.out.println("Found " + entries.size() + " skill(s) in .skills-versions"); + + // 2. Read existing lock file (if any) + Path lockPath = SkillLockFile.pathIn(cwd); + Map locked = SkillLockFile.read(lockPath); + + // 3. Compute resolution plan + SkillLockFile.ResolutionPlan plan; + if (update || locked.isEmpty()) { + // --update or no lock file: resolve everything fresh + plan = new SkillLockFile.ResolutionPlan( + Collections.emptyList(), entries, Collections.emptyList()); + } else { + plan = SkillLockFile.computeResolutionPlan(entries, locked); + } + + if (!plan.getReusable().isEmpty()) { + System.out.println("Using locked versions for " + plan.getReusable().size() + " skill(s)"); + } + if (!plan.getToResolve().isEmpty()) { + System.out.println("Resolving " + plan.getToResolve().size() + " skill(s)..."); + } + if (!plan.getRemoved().isEmpty()) { + System.out.println("Removing " + plan.getRemoved().size() + + " skill(s) no longer in .skills-versions"); + } + + // 4. Build final coordinates map (preserving .skills-versions order) + Map newLock = new LinkedHashMap<>(); + + // First, resolve all new/changed entries + Map resolvedNew = new LinkedHashMap<>(); + for (SkillVersionsFile.Entry entry : plan.getToResolve()) { + String rawCoord = entry.getVersion() != null + ? entry.getName() + "@" + entry.getVersion() + : entry.getName(); + SkillCoordinates coords = resolveSkillCoordinates(rawCoord); + if (coords == null) { + System.err.println("Error: Failed to resolve skill '" + entry.getName() + "'."); + return 1; + } + resolvedNew.put(entry.getName(), new SkillLockFile.LockedSkill( + entry.getName(), coords.getGroupId(), coords.getArtifactId(), + coords.getVersion(), entry.getVersion())); + } + + // Build lock map in .skills-versions order + Map reusableMap = new LinkedHashMap<>(); + for (SkillLockFile.LockedSkill ls : plan.getReusable()) { + reusableMap.put(ls.getName(), ls); + } + for (SkillVersionsFile.Entry entry : entries) { + SkillLockFile.LockedSkill skill = reusableMap.get(entry.getName()); + if (skill == null) { + skill = resolvedNew.get(entry.getName()); + } + if (skill != null) { + newLock.put(entry.getName(), skill); + } + } + + // 5. Install each skill sequentially + int failCount = 0; + for (SkillLockFile.LockedSkill skill : newLock.values()) { + System.out.println("\n--- Installing " + skill.getName() + + " (" + skill.getGroupId() + ":" + skill.getArtifactId() + + ":" + skill.getVersion() + ") ---"); + int result = installResolved( + skill.getGroupId(), skill.getArtifactId(), skill.getVersion()); + if (result != 0) { + System.err.println("Error: Failed to install skill '" + skill.getName() + "'."); + failCount++; + } + } + + // 6. Write updated lock file + SkillLockFile.write(lockPath, newLock); + System.out.println("\nLock file updated: " + lockPath); + + if (failCount > 0) { + System.err.println(failCount + " skill(s) failed to install."); + return 1; + } + + System.out.println("All " + newLock.size() + " skill(s) installed successfully."); + return 0; + } + + private Path getWorkingDirectory() { + if (workingDirectory != null) { + return workingDirectory; + } + return Path.of("").toAbsolutePath(); + } + + // ---- Registry resolution ---- + /** * Resolves a skill name to Maven coordinates via the skills registry. * @@ -247,6 +459,8 @@ private static String getElementText(Element parent, String tagName) { return null; } + // ---- Repository credential parsing ---- + /** * Parses a repository string that may contain credentials. * Format: [user:pass@]repositoryUrl @@ -272,6 +486,8 @@ static String[] parseRepository(String repo) { return new String[]{repo, null, null}; } + // ---- Maven execution ---- + /** * Runs the Maven skills-jar:install goal using the embedded Maven runtime. */ @@ -292,6 +508,8 @@ private int runMaven(Path projectDir, boolean hasSettings) { projectDir.toString(), System.out, System.err); } + // ---- POM and settings generation ---- + /** * Generates a temporary pom.xml with the skill artifact as a dependency. */ @@ -386,6 +604,8 @@ private void generateSettings(Path projectDir, String user, String password) thr Files.writeString(projectDir.resolve("settings.xml"), settings.toString()); } + // ---- File system utilities ---- + /** * Recursively copies a directory tree from source to target. */ diff --git a/src/main/java/ca/weblite/installskill/SkillCoordinates.java b/src/main/java/ca/weblite/installskill/SkillCoordinates.java new file mode 100644 index 0000000..6dac43d --- /dev/null +++ b/src/main/java/ca/weblite/installskill/SkillCoordinates.java @@ -0,0 +1,58 @@ +package ca.weblite.installskill; + +import java.util.Objects; + +/** + * Immutable value class holding the resolved Maven coordinates for a skill. + */ +public final class SkillCoordinates { + + private final String name; + private final String groupId; + private final String artifactId; + private final String version; + + public SkillCoordinates(String name, String groupId, String artifactId, String version) { + this.name = Objects.requireNonNull(name); + this.groupId = Objects.requireNonNull(groupId); + this.artifactId = Objects.requireNonNull(artifactId); + this.version = Objects.requireNonNull(version); + } + + public String getName() { + return name; + } + + public String getGroupId() { + return groupId; + } + + public String getArtifactId() { + return artifactId; + } + + public String getVersion() { + return version; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SkillCoordinates)) return false; + SkillCoordinates that = (SkillCoordinates) o; + return name.equals(that.name) + && groupId.equals(that.groupId) + && artifactId.equals(that.artifactId) + && version.equals(that.version); + } + + @Override + public int hashCode() { + return Objects.hash(name, groupId, artifactId, version); + } + + @Override + public String toString() { + return groupId + ":" + artifactId + ":" + version; + } +} diff --git a/src/main/java/ca/weblite/installskill/SkillLockFile.java b/src/main/java/ca/weblite/installskill/SkillLockFile.java new file mode 100644 index 0000000..9819c80 --- /dev/null +++ b/src/main/java/ca/weblite/installskill/SkillLockFile.java @@ -0,0 +1,331 @@ +package ca.weblite.installskill; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Reads and writes {@code .skills-versions.lock} files. + * + *

The lock file captures the resolved Maven coordinates for each skill + * listed in {@code .skills-versions}, enabling reproducible installs. + * Format is JSON with a {@code lockVersion} field for forward compatibility.

+ */ +public final class SkillLockFile { + + private static final String FILE_NAME = ".skills-versions.lock"; + private static final int LOCK_VERSION = 1; + + private SkillLockFile() { + } + + /** + * A locked skill entry recording the resolved coordinates. + */ + public static final class LockedSkill { + private final String name; + private final String groupId; + private final String artifactId; + private final String version; + private final String requestedVersion; // nullable — null means latest/RELEASE was requested + + public LockedSkill(String name, String groupId, String artifactId, + String version, String requestedVersion) { + this.name = Objects.requireNonNull(name); + this.groupId = Objects.requireNonNull(groupId); + this.artifactId = Objects.requireNonNull(artifactId); + this.version = Objects.requireNonNull(version); + this.requestedVersion = requestedVersion; + } + + public String getName() { return name; } + public String getGroupId() { return groupId; } + public String getArtifactId() { return artifactId; } + public String getVersion() { return version; } + public String getRequestedVersion() { return requestedVersion; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LockedSkill)) return false; + LockedSkill that = (LockedSkill) o; + return name.equals(that.name) + && groupId.equals(that.groupId) + && artifactId.equals(that.artifactId) + && version.equals(that.version) + && Objects.equals(requestedVersion, that.requestedVersion); + } + + @Override + public int hashCode() { + return Objects.hash(name, groupId, artifactId, version, requestedVersion); + } + } + + /** + * Result of comparing {@code .skills-versions} against {@code .skills-versions.lock}. + */ + public static final class ResolutionPlan { + private final List reusable; + private final List toResolve; + private final List removed; + + public ResolutionPlan(List reusable, + List toResolve, + List removed) { + this.reusable = Objects.requireNonNull(reusable); + this.toResolve = Objects.requireNonNull(toResolve); + this.removed = Objects.requireNonNull(removed); + } + + /** Entries whose locked version can be reused as-is. */ + public List getReusable() { return reusable; } + + /** Entries that need fresh resolution (new or changed). */ + public List getToResolve() { return toResolve; } + + /** Skill names that were in the lock but are no longer in {@code .skills-versions}. */ + public List getRemoved() { return removed; } + } + + /** + * Reads a lock file. Returns an empty map if the file doesn't exist. + * Prints a warning and returns empty on parse errors. + */ + public static Map read(Path lockFilePath) throws IOException { + if (!Files.isRegularFile(lockFilePath)) { + return new LinkedHashMap<>(); + } + + String content = Files.readString(lockFilePath); + try { + return parseJson(content); + } catch (Exception e) { + System.err.println("Warning: Failed to parse lock file (" + + e.getMessage() + "). Re-resolving all skills."); + return new LinkedHashMap<>(); + } + } + + /** + * Writes lock file entries as formatted JSON. + */ + public static void write(Path lockFilePath, Map entries) + throws IOException { + Files.writeString(lockFilePath, toJson(entries)); + } + + /** + * Returns the path to {@code .skills-versions.lock} in the given directory. + */ + public static Path pathIn(Path directory) { + return directory.resolve(FILE_NAME); + } + + /** + * Determines which entries need re-resolution vs. which can use locked versions. + */ + public static ResolutionPlan computeResolutionPlan( + List requested, + Map locked) { + + List reusable = new ArrayList<>(); + List toResolve = new ArrayList<>(); + List removed = new ArrayList<>(); + + // Check each requested entry against the lock + for (SkillVersionsFile.Entry entry : requested) { + LockedSkill lockedEntry = locked.get(entry.getName()); + if (lockedEntry != null + && Objects.equals(entry.getVersion(), lockedEntry.getRequestedVersion())) { + reusable.add(lockedEntry); + } else { + toResolve.add(entry); + } + } + + // Find entries that were in the lock but are no longer requested + for (String lockedName : locked.keySet()) { + boolean stillRequested = requested.stream() + .anyMatch(e -> e.getName().equals(lockedName)); + if (!stillRequested) { + removed.add(lockedName); + } + } + + return new ResolutionPlan(reusable, toResolve, removed); + } + + // ---- JSON serialization (hand-rolled, no external dependency) ---- + + static String toJson(Map entries) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"lockVersion\": ").append(LOCK_VERSION).append(",\n"); + sb.append(" \"skills\": {"); + + boolean first = true; + for (Map.Entry e : entries.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\n"); + LockedSkill s = e.getValue(); + sb.append(" ").append(jsonString(s.getName())).append(": {\n"); + sb.append(" \"name\": ").append(jsonString(s.getName())).append(",\n"); + sb.append(" \"groupId\": ").append(jsonString(s.getGroupId())).append(",\n"); + sb.append(" \"artifactId\": ").append(jsonString(s.getArtifactId())).append(",\n"); + sb.append(" \"version\": ").append(jsonString(s.getVersion())).append(",\n"); + sb.append(" \"requestedVersion\": ") + .append(s.getRequestedVersion() != null + ? jsonString(s.getRequestedVersion()) : "null") + .append("\n"); + sb.append(" }"); + } + + if (!entries.isEmpty()) { + sb.append("\n "); + } + sb.append("}\n"); + sb.append("}\n"); + return sb.toString(); + } + + static Map parseJson(String json) { + Map result = new LinkedHashMap<>(); + + // Find "skills" object + int skillsIdx = json.indexOf("\"skills\""); + if (skillsIdx < 0) { + return result; + } + + // Find the opening brace of the skills object + int braceStart = json.indexOf('{', skillsIdx); + if (braceStart < 0) { + return result; + } + + // Find matching closing brace + int braceEnd = findMatchingBrace(json, braceStart); + if (braceEnd < 0) { + return result; + } + + String skillsBlock = json.substring(braceStart + 1, braceEnd); + + // Parse each skill entry by finding inner objects + int pos = 0; + while (pos < skillsBlock.length()) { + // Find key (skill name) + int keyStart = skillsBlock.indexOf('"', pos); + if (keyStart < 0) break; + int keyEnd = skillsBlock.indexOf('"', keyStart + 1); + if (keyEnd < 0) break; + + // Find opening brace of this skill's object + int objStart = skillsBlock.indexOf('{', keyEnd); + if (objStart < 0) break; + int objEnd = findMatchingBrace(skillsBlock, objStart); + if (objEnd < 0) break; + + String objContent = skillsBlock.substring(objStart + 1, objEnd); + String name = extractJsonStringValue(objContent, "name"); + String groupId = extractJsonStringValue(objContent, "groupId"); + String artifactId = extractJsonStringValue(objContent, "artifactId"); + String version = extractJsonStringValue(objContent, "version"); + String requestedVersion = extractJsonStringValue(objContent, "requestedVersion"); + + if (name != null && groupId != null && artifactId != null && version != null) { + result.put(name, new LockedSkill(name, groupId, artifactId, + version, requestedVersion)); + } + + pos = objEnd + 1; + } + + return result; + } + + private static int findMatchingBrace(String s, int openPos) { + int depth = 0; + boolean inString = false; + for (int i = openPos; i < s.length(); i++) { + char c = s.charAt(i); + if (inString) { + if (c == '\\') { + i++; // skip escaped character + } else if (c == '"') { + inString = false; + } + } else { + if (c == '"') { + inString = true; + } else if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return i; + } + } + } + } + return -1; + } + + private static String extractJsonStringValue(String obj, String key) { + String search = "\"" + key + "\""; + int keyIdx = obj.indexOf(search); + if (keyIdx < 0) return null; + + int colonIdx = obj.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) return null; + + // Skip whitespace after colon + int valueStart = colonIdx + 1; + while (valueStart < obj.length() && Character.isWhitespace(obj.charAt(valueStart))) { + valueStart++; + } + + if (valueStart >= obj.length()) return null; + + // Check for null + if (obj.startsWith("null", valueStart)) { + return null; + } + + // Expect a quoted string + if (obj.charAt(valueStart) != '"') return null; + int strEnd = obj.indexOf('"', valueStart + 1); + if (strEnd < 0) return null; + + return unescapeJson(obj.substring(valueStart + 1, strEnd)); + } + + private static String jsonString(String value) { + return "\"" + escapeJson(value) + "\""; + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String unescapeJson(String s) { + return s.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } +} diff --git a/src/main/java/ca/weblite/installskill/SkillVersionsFile.java b/src/main/java/ca/weblite/installskill/SkillVersionsFile.java new file mode 100644 index 0000000..9423e09 --- /dev/null +++ b/src/main/java/ca/weblite/installskill/SkillVersionsFile.java @@ -0,0 +1,120 @@ +package ca.weblite.installskill; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Parses {@code .skills-versions} files. + * + *

File format: one entry per line as {@code name[@version]}. + * Blank lines and lines starting with {@code #} are ignored. + * Names can be registry skill names or Maven coordinates ({@code groupId:artifactId}).

+ */ +public final class SkillVersionsFile { + + private static final String FILE_NAME = ".skills-versions"; + + private SkillVersionsFile() { + } + + /** + * An entry from a {@code .skills-versions} file. + */ + public static final class Entry { + private final String name; + private final String version; + + public Entry(String name, String version) { + this.name = Objects.requireNonNull(name); + this.version = version; // nullable — null means latest/RELEASE + } + + public String getName() { + return name; + } + + /** Returns the requested version, or {@code null} if latest/RELEASE is desired. */ + public String getVersion() { + return version; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Entry)) return false; + Entry entry = (Entry) o; + return name.equals(entry.name) && Objects.equals(version, entry.version); + } + + @Override + public int hashCode() { + return Objects.hash(name, version); + } + + @Override + public String toString() { + return version != null ? name + "@" + version : name; + } + } + + /** + * Parses a {@code .skills-versions} file. + * + * @param path path to the file + * @return ordered list of entries (preserves file order) + * @throws IOException if the file cannot be read or contains malformed entries + */ + public static List parse(Path path) throws IOException { + List lines = Files.readAllLines(path); + List entries = new ArrayList<>(); + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + int lineNumber = i + 1; + int atIdx = line.lastIndexOf('@'); + if (atIdx < 0) { + // No version specified + if (line.isEmpty()) { + throw new IOException("Line " + lineNumber + ": skill name must not be empty"); + } + entries.add(new Entry(line, null)); + } else { + String name = line.substring(0, atIdx).trim(); + String version = line.substring(atIdx + 1).trim(); + if (name.isEmpty()) { + throw new IOException("Line " + lineNumber + ": skill name must not be empty"); + } + if (version.isEmpty()) { + throw new IOException("Line " + lineNumber + + ": version must not be empty when '@' is present (use '" + + name + "' without '@' for latest)"); + } + entries.add(new Entry(name, version)); + } + } + + return entries; + } + + /** + * Checks if a {@code .skills-versions} file exists in the given directory. + */ + public static boolean exists(Path directory) { + return Files.isRegularFile(directory.resolve(FILE_NAME)); + } + + /** + * Returns the path to {@code .skills-versions} in the given directory. + */ + public static Path pathIn(Path directory) { + return directory.resolve(FILE_NAME); + } +} diff --git a/src/test/java/ca/weblite/installskill/InstallSkillIT.java b/src/test/java/ca/weblite/installskill/InstallSkillIT.java index fa724d8..2cb9b0a 100644 --- a/src/test/java/ca/weblite/installskill/InstallSkillIT.java +++ b/src/test/java/ca/weblite/installskill/InstallSkillIT.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.util.Map; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; import java.util.stream.Stream; @@ -351,4 +352,216 @@ void installSkillByRegistryNameNotFound() throws IOException { Files.deleteIfExists(registryFile); } } + + // ---- .skills-versions batch install tests ---- + + private Path createRegistryFile() throws IOException { + Path registryFile = Files.createTempFile("skills-registry-", ".xml"); + String registryXml = + "\n" + + "\n" + + " \n" + + " teavm-lambda\n" + + " " + TEAVM_LAMBDA_GROUP + "\n" + + " " + TEAVM_LAMBDA_ARTIFACT + "\n" + + " " + TEAVM_LAMBDA_VERSION + "\n" + + " Test skill\n" + + " \n" + + "\n"; + Files.writeString(registryFile, registryXml); + return registryFile; + } + + private InstallSkillCommand createCommandWithWorkingDir(Path workDir) { + InstallSkillCommand cmd = new InstallSkillCommand(); + cmd.workingDirectory = workDir; + return cmd; + } + + @Test + void installFromVersionsFile() throws IOException { + // Create .skills-versions file in skillsDir (acting as working dir) + Path versionsFile = skillsDir.resolve(".skills-versions"); + Files.writeString(versionsFile, + "teavm-lambda@" + TEAVM_LAMBDA_VERSION + "\n"); + + Path registryFile = createRegistryFile(); + try { + System.setProperty("skills.registry.url", registryFile.toUri().toString()); + try { + // Create a subdirectory for installed skills output + Path installTarget = skillsDir.resolve("output"); + Files.createDirectories(installTarget); + + InstallSkillCommand cmd = createCommandWithWorkingDir(skillsDir); + int exitCode = new CommandLine(cmd).execute( + "-d", installTarget.toString() + ); + + assertEquals(0, exitCode, "install from .skills-versions should exit successfully"); + + // Verify the skill was installed + Path skillDir = installTarget.resolve(TEAVM_LAMBDA_ARTIFACT); + assertTrue(Files.isDirectory(skillDir), + "Skill directory should exist after batch install"); + + Path skillMd = skillDir.resolve("SKILL.md"); + assertTrue(Files.isRegularFile(skillMd), + "SKILL.md should exist after batch install"); + + // Verify lock file was created + Path lockFile = skillsDir.resolve(".skills-versions.lock"); + assertTrue(Files.isRegularFile(lockFile), + ".skills-versions.lock should be created after batch install"); + + String lockContent = Files.readString(lockFile); + assertTrue(lockContent.contains("\"teavm-lambda\""), + "Lock file should contain the skill name"); + assertTrue(lockContent.contains(TEAVM_LAMBDA_GROUP), + "Lock file should contain the groupId"); + assertTrue(lockContent.contains(TEAVM_LAMBDA_ARTIFACT), + "Lock file should contain the artifactId"); + assertTrue(lockContent.contains(TEAVM_LAMBDA_VERSION), + "Lock file should contain the resolved version"); + } finally { + System.clearProperty("skills.registry.url"); + } + } finally { + Files.deleteIfExists(registryFile); + } + } + + @Test + void installFromVersionsFileLockReuse() throws IOException { + // Create .skills-versions file + Path versionsFile = skillsDir.resolve(".skills-versions"); + Files.writeString(versionsFile, + "teavm-lambda@" + TEAVM_LAMBDA_VERSION + "\n"); + + Path registryFile = createRegistryFile(); + try { + System.setProperty("skills.registry.url", registryFile.toUri().toString()); + try { + Path installTarget = skillsDir.resolve("output"); + Files.createDirectories(installTarget); + + // First install — creates lock file + InstallSkillCommand cmd1 = createCommandWithWorkingDir(skillsDir); + int exitCode1 = new CommandLine(cmd1).execute( + "-d", installTarget.toString() + ); + assertEquals(0, exitCode1, "First install should succeed"); + + Path lockFile = skillsDir.resolve(".skills-versions.lock"); + assertTrue(Files.isRegularFile(lockFile), + "Lock file should exist after first install"); + + // Read lock file and verify it can be parsed + Map locked = SkillLockFile.read(lockFile); + assertEquals(1, locked.size(), "Lock file should have 1 entry"); + assertTrue(locked.containsKey("teavm-lambda"), + "Lock file should contain teavm-lambda"); + + // Second install — should reuse locked versions + InstallSkillCommand cmd2 = createCommandWithWorkingDir(skillsDir); + int exitCode2 = new CommandLine(cmd2).execute( + "-d", installTarget.toString() + ); + assertEquals(0, exitCode2, "Second install (from lock) should succeed"); + + // Lock file should still exist and be unchanged + Map lockedAfter = SkillLockFile.read(lockFile); + assertEquals(locked.size(), lockedAfter.size(), + "Lock file should have same entries after second install"); + } finally { + System.clearProperty("skills.registry.url"); + } + } finally { + Files.deleteIfExists(registryFile); + } + } + + @Test + void installFromVersionsFileWithUpdate() throws IOException { + // Create .skills-versions file + Path versionsFile = skillsDir.resolve(".skills-versions"); + Files.writeString(versionsFile, + "teavm-lambda@" + TEAVM_LAMBDA_VERSION + "\n"); + + Path registryFile = createRegistryFile(); + try { + System.setProperty("skills.registry.url", registryFile.toUri().toString()); + try { + Path installTarget = skillsDir.resolve("output"); + Files.createDirectories(installTarget); + + // First install — creates lock file + InstallSkillCommand cmd1 = createCommandWithWorkingDir(skillsDir); + int exitCode1 = new CommandLine(cmd1).execute( + "-d", installTarget.toString() + ); + assertEquals(0, exitCode1, "First install should succeed"); + + // Install with --update — should re-resolve even though lock exists + InstallSkillCommand cmd2 = createCommandWithWorkingDir(skillsDir); + int exitCode2 = new CommandLine(cmd2).execute( + "-u", "-d", installTarget.toString() + ); + assertEquals(0, exitCode2, "Install with --update should succeed"); + + // Verify lock file still exists with correct content + Path lockFile = skillsDir.resolve(".skills-versions.lock"); + Map locked = SkillLockFile.read(lockFile); + assertEquals(1, locked.size(), + "Lock file should have 1 entry after --update"); + assertEquals(TEAVM_LAMBDA_VERSION, locked.get("teavm-lambda").getVersion(), + "Version should match after --update"); + } finally { + System.clearProperty("skills.registry.url"); + } + } finally { + Files.deleteIfExists(registryFile); + } + } + + @Test + void installWithoutArgAndWithoutVersionsFileFails() { + // Use skillsDir as working directory — no .skills-versions present + InstallSkillCommand cmd = createCommandWithWorkingDir(skillsDir); + int exitCode = new CommandLine(cmd).execute( + "-d", skillsDir.toString() + ); + + assertEquals(1, exitCode, + "install-skill with no argument and no .skills-versions should fail"); + } + + @Test + void installFromVersionsFileUsingMavenCoordinates() throws IOException { + // Test .skills-versions with Maven coordinates format (groupId:artifactId@version) + Path versionsFile = skillsDir.resolve(".skills-versions"); + Files.writeString(versionsFile, + TEAVM_LAMBDA_GROUP + ":" + TEAVM_LAMBDA_ARTIFACT + + "@" + TEAVM_LAMBDA_VERSION + "\n"); + + Path installTarget = skillsDir.resolve("output"); + Files.createDirectories(installTarget); + + InstallSkillCommand cmd = createCommandWithWorkingDir(skillsDir); + int exitCode = new CommandLine(cmd).execute( + "-d", installTarget.toString() + ); + + assertEquals(0, exitCode, + "install from .skills-versions with Maven coordinates should succeed"); + + Path skillDir = installTarget.resolve(TEAVM_LAMBDA_ARTIFACT); + assertTrue(Files.isDirectory(skillDir), + "Skill directory should exist after batch install with Maven coordinates"); + + // Verify lock file + Path lockFile = skillsDir.resolve(".skills-versions.lock"); + assertTrue(Files.isRegularFile(lockFile), + ".skills-versions.lock should be created"); + } } diff --git a/src/test/java/ca/weblite/installskill/SkillLockFileTest.java b/src/test/java/ca/weblite/installskill/SkillLockFileTest.java new file mode 100644 index 0000000..58931cf --- /dev/null +++ b/src/test/java/ca/weblite/installskill/SkillLockFileTest.java @@ -0,0 +1,185 @@ +package ca.weblite.installskill; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class SkillLockFileTest { + + @TempDir + Path tempDir; + + private static SkillLockFile.LockedSkill locked( + String name, String groupId, String artifactId, + String version, String requestedVersion) { + return new SkillLockFile.LockedSkill(name, groupId, artifactId, + version, requestedVersion); + } + + @Test + void writeAndReadRoundTrip() throws IOException { + Path lockFile = tempDir.resolve(".skills-versions.lock"); + + Map entries = new LinkedHashMap<>(); + entries.put("my-skill", locked("my-skill", "com.example", + "my-skill-lib", "0.1.0", "0.1.0")); + entries.put("other-skill", locked("other-skill", "org.example", + "other-skill", "2.3.1", null)); + + SkillLockFile.write(lockFile, entries); + Map read = SkillLockFile.read(lockFile); + + assertEquals(2, read.size()); + + SkillLockFile.LockedSkill mySkill = read.get("my-skill"); + assertNotNull(mySkill); + assertEquals("my-skill", mySkill.getName()); + assertEquals("com.example", mySkill.getGroupId()); + assertEquals("my-skill-lib", mySkill.getArtifactId()); + assertEquals("0.1.0", mySkill.getVersion()); + assertEquals("0.1.0", mySkill.getRequestedVersion()); + + SkillLockFile.LockedSkill otherSkill = read.get("other-skill"); + assertNotNull(otherSkill); + assertEquals("other-skill", otherSkill.getName()); + assertEquals("org.example", otherSkill.getGroupId()); + assertEquals("other-skill", otherSkill.getArtifactId()); + assertEquals("2.3.1", otherSkill.getVersion()); + assertNull(otherSkill.getRequestedVersion()); + } + + @Test + void readNonExistentFileReturnsEmpty() throws IOException { + Path lockFile = tempDir.resolve("nonexistent.lock"); + + Map result = SkillLockFile.read(lockFile); + + assertTrue(result.isEmpty()); + } + + @Test + void computeResolutionPlanAllNew() { + List requested = Arrays.asList( + new SkillVersionsFile.Entry("skill-a", "1.0"), + new SkillVersionsFile.Entry("skill-b", null) + ); + Map locked = Collections.emptyMap(); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertTrue(plan.getReusable().isEmpty()); + assertEquals(2, plan.getToResolve().size()); + assertTrue(plan.getRemoved().isEmpty()); + } + + @Test + void computeResolutionPlanAllLocked() { + List requested = Arrays.asList( + new SkillVersionsFile.Entry("skill-a", "1.0"), + new SkillVersionsFile.Entry("skill-b", null) + ); + Map locked = new LinkedHashMap<>(); + locked.put("skill-a", locked("skill-a", "com.ex", "a", "1.0", "1.0")); + locked.put("skill-b", locked("skill-b", "com.ex", "b", "2.0", null)); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertEquals(2, plan.getReusable().size()); + assertTrue(plan.getToResolve().isEmpty()); + assertTrue(plan.getRemoved().isEmpty()); + } + + @Test + void computeResolutionPlanMixed() { + List requested = Arrays.asList( + new SkillVersionsFile.Entry("existing", "1.0"), + new SkillVersionsFile.Entry("new-skill", "2.0") + ); + Map locked = new LinkedHashMap<>(); + locked.put("existing", locked("existing", "com.ex", "ex", "1.0", "1.0")); + locked.put("removed-skill", locked("removed-skill", "com.ex", "rm", "3.0", "3.0")); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertEquals(1, plan.getReusable().size()); + assertEquals("existing", plan.getReusable().get(0).getName()); + assertEquals(1, plan.getToResolve().size()); + assertEquals("new-skill", plan.getToResolve().get(0).getName()); + assertEquals(1, plan.getRemoved().size()); + assertEquals("removed-skill", plan.getRemoved().get(0)); + } + + @Test + void computeResolutionPlanVersionChanged() { + List requested = Collections.singletonList( + new SkillVersionsFile.Entry("skill-a", "2.0") + ); + Map locked = new LinkedHashMap<>(); + locked.put("skill-a", locked("skill-a", "com.ex", "a", "1.0", "1.0")); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertTrue(plan.getReusable().isEmpty()); + assertEquals(1, plan.getToResolve().size()); + assertEquals("skill-a", plan.getToResolve().get(0).getName()); + } + + @Test + void computeResolutionPlanNewEntry() { + List requested = Collections.singletonList( + new SkillVersionsFile.Entry("brand-new", "1.0") + ); + Map locked = new LinkedHashMap<>(); + locked.put("other", locked("other", "com.ex", "o", "1.0", "1.0")); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertTrue(plan.getReusable().isEmpty()); + assertEquals(1, plan.getToResolve().size()); + assertEquals("brand-new", plan.getToResolve().get(0).getName()); + } + + @Test + void computeResolutionPlanRemovedEntry() { + List requested = Collections.emptyList(); + Map locked = new LinkedHashMap<>(); + locked.put("gone-skill", locked("gone-skill", "com.ex", "g", "1.0", "1.0")); + + SkillLockFile.ResolutionPlan plan = + SkillLockFile.computeResolutionPlan(requested, locked); + + assertTrue(plan.getReusable().isEmpty()); + assertTrue(plan.getToResolve().isEmpty()); + assertEquals(1, plan.getRemoved().size()); + assertEquals("gone-skill", plan.getRemoved().get(0)); + } + + @Test + void jsonRoundTripWithSpecialCharacters() throws IOException { + Path lockFile = tempDir.resolve(".skills-versions.lock"); + + Map entries = new LinkedHashMap<>(); + entries.put("my-skill", locked("my-skill", "com.example", + "my-skill", "1.0.0-beta.1", "1.0.0-beta.1")); + + SkillLockFile.write(lockFile, entries); + Map read = SkillLockFile.read(lockFile); + + assertEquals(1, read.size()); + assertEquals("1.0.0-beta.1", read.get("my-skill").getVersion()); + } +} diff --git a/src/test/java/ca/weblite/installskill/SkillVersionsFileTest.java b/src/test/java/ca/weblite/installskill/SkillVersionsFileTest.java new file mode 100644 index 0000000..2a868f0 --- /dev/null +++ b/src/test/java/ca/weblite/installskill/SkillVersionsFileTest.java @@ -0,0 +1,141 @@ +package ca.weblite.installskill; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SkillVersionsFileTest { + + @TempDir + Path tempDir; + + private Path writeVersionsFile(String content) throws IOException { + Path file = tempDir.resolve(".skills-versions"); + Files.writeString(file, content); + return file; + } + + @Test + void parseBasicEntries() throws IOException { + Path file = writeVersionsFile( + "my-skill@0.1.0\n" + + "other-skill@1.2.3\n" + + "third-skill\n" + ); + + List entries = SkillVersionsFile.parse(file); + + assertEquals(3, entries.size()); + assertEquals("my-skill", entries.get(0).getName()); + assertEquals("0.1.0", entries.get(0).getVersion()); + assertEquals("other-skill", entries.get(1).getName()); + assertEquals("1.2.3", entries.get(1).getVersion()); + assertEquals("third-skill", entries.get(2).getName()); + assertNull(entries.get(2).getVersion()); + } + + @Test + void parseSkipsBlankLinesAndComments() throws IOException { + Path file = writeVersionsFile( + "# This is a comment\n" + + "\n" + + "my-skill@0.1.0\n" + + " \n" + + "# Another comment\n" + + "other-skill\n" + ); + + List entries = SkillVersionsFile.parse(file); + + assertEquals(2, entries.size()); + assertEquals("my-skill", entries.get(0).getName()); + assertEquals("other-skill", entries.get(1).getName()); + } + + @Test + void parseEmptyFile() throws IOException { + Path file = writeVersionsFile(""); + + List entries = SkillVersionsFile.parse(file); + + assertTrue(entries.isEmpty()); + } + + @Test + void parseVersionlessEntry() throws IOException { + Path file = writeVersionsFile("some-skill\n"); + + List entries = SkillVersionsFile.parse(file); + + assertEquals(1, entries.size()); + assertEquals("some-skill", entries.get(0).getName()); + assertNull(entries.get(0).getVersion()); + } + + @Test + void parseRejectsEmptyVersion() throws IOException { + Path file = writeVersionsFile("skill@\n"); + + IOException ex = assertThrows(IOException.class, + () -> SkillVersionsFile.parse(file)); + assertTrue(ex.getMessage().contains("Line 1")); + assertTrue(ex.getMessage().contains("version must not be empty")); + } + + @Test + void parseRejectsEmptyName() throws IOException { + Path file = writeVersionsFile("@1.0\n"); + + IOException ex = assertThrows(IOException.class, + () -> SkillVersionsFile.parse(file)); + assertTrue(ex.getMessage().contains("Line 1")); + assertTrue(ex.getMessage().contains("name must not be empty")); + } + + @Test + void parseMavenCoordinatesFormat() throws IOException { + Path file = writeVersionsFile("com.example:my-lib@1.0.0\n"); + + List entries = SkillVersionsFile.parse(file); + + assertEquals(1, entries.size()); + assertEquals("com.example:my-lib", entries.get(0).getName()); + assertEquals("1.0.0", entries.get(0).getVersion()); + } + + @Test + void existsReturnsTrueWhenFilePresent() throws IOException { + writeVersionsFile("skill@1.0\n"); + + assertTrue(SkillVersionsFile.exists(tempDir)); + } + + @Test + void existsReturnsFalseWhenFileMissing() { + assertFalse(SkillVersionsFile.exists(tempDir)); + } + + @Test + void pathInReturnsCorrectPath() { + Path expected = tempDir.resolve(".skills-versions"); + assertEquals(expected, SkillVersionsFile.pathIn(tempDir)); + } + + @Test + void entryToStringWithVersion() { + SkillVersionsFile.Entry entry = new SkillVersionsFile.Entry("my-skill", "0.1.0"); + assertEquals("my-skill@0.1.0", entry.toString()); + } + + @Test + void entryToStringWithoutVersion() { + SkillVersionsFile.Entry entry = new SkillVersionsFile.Entry("my-skill", null); + assertEquals("my-skill", entry.toString()); + } +}