diff --git a/README.md b/README.md index 2b38545..56ed8fe 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ npm run build node dist/bin/agents-repo.js ``` -Commands (`install`, `search`, and more) land in later milestones. The `init` -command is available to bootstrap `agents.json`. See -[docs/commands/init.md](docs/commands/init.md). +Commands (`search`, `list`, and more) land in later milestones. The `init` and +`install` commands are available today. See +[docs/commands/init.md](docs/commands/init.md) and +[docs/commands/install.md](docs/commands/install.md). ## Stack @@ -52,6 +53,7 @@ npm run env:check && npm run lint:all && npm run typecheck && npm test && npm ru | Command | Documentation | | --- | --- | | `init` | [docs/commands/init.md](docs/commands/init.md) | +| `install` / `i` | [docs/commands/install.md](docs/commands/install.md) | ## IDE Agent Instructions diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8beaf12..f1f9018 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,8 +49,8 @@ application and infrastructure APIs consumed by commands. The config module implements schema-gated `agents.json` resolution, merge semantics, conflict detection, environment overrides, and `agents-lock.json` I/O per [`specs/config-schema.md`](../specs/config-schema.md) and -[`specs/lock-schema.md`](../specs/lock-schema.md). The `init` command (#7) -consumes these APIs; `install` follows in issue #8. +[`specs/lock-schema.md`](../specs/lock-schema.md). The `init` command (#7) and +`install` command (#8) consume these APIs. ### Delivered in issue #5 @@ -77,8 +77,25 @@ consumes these APIs; `install` follows in issue #8. Product documentation: [commands/init.md](commands/init.md). -Install commands (#8) pass `ResolvedAgentsConfig.registry` to -`getRegistrySourceConfig()` from the registry module (replacing the deferred +### Delivered in issue #8 (`install`) + +| Area | CLI path | Notes | +| --- | --- | --- | +| Install orchestration | `install/application/installService.ts` | Full pipeline per protocol | +| Version pick | `install/application/resolveInstallVersion.ts` | Semver range + ad-hoc pick | +| Target validation | `install/application/validateInstallTarget.ts` | Index, metadata, manifest | +| Lock ref resolution | `install/application/resolveLockRef.ts` | Concrete ref for lock writes | +| Extract scope | `install/application/installScope.ts` | Project vs global paths | +| Persistence | `install/application/installPersistence.ts` | Config + lock updates | +| Download / verify / scan / extract | `install/infrastructure/` | ZIP pipeline + path remap | +| Version metadata fetch | `registry/infrastructure/registryRepository.ts` | `loadPackageMetadata` | +| `install` command | `cli/presentation/installCommand.ts` | Commander wiring | +| Global `--dry-run` / `--no-save` | `cli/presentation/createCliProgram.ts` | Root install flags | + +Product documentation: [commands/install.md](commands/install.md). + +Install commands pass `ResolvedAgentsConfig.registry` to +`resolveRegistryFetchSourceConfig()` from the registry module (replacing the deferred `registrySourceSettings.ts` webapp port). ## Registry module — webapp parity (issue #4) @@ -142,23 +159,26 @@ for `init` suggestions per [`specs/target-detection.md`](../specs/target-detecti are skipped silently; a `none` result may therefore hide present-but-inaccessible markers—`init` adds marker detail in verbose mode when detection is ambiguous. -## Install module — webapp URL logic + CLI extensions +## Install module — download, verify, extract (issue #8) The webapp does not HTTP-download ZIP artifacts. It builds download URLs via `getPackageDownloadTargets` in `homePageCatalogState.ts` and `buildRegistryArtifactUrl` in `registrySourceUrl.ts`. The CLI reuses those -registry helpers (see `application/resolveArtifact.ts`) and will add download, +registry helpers (see `application/resolveArtifact.ts`) and adds download, verification, and extraction per [`specs/cli-protocol.md`](../specs/cli-protocol.md). | Concern | Webapp reference | CLI responsibility | | --- | --- | --- | | Artifact URL resolution | `homePageCatalogState.ts` | `registry/application/resolveArtifact.ts` | -| Manifest fetch + validation | — | `registry/infrastructure/registryRepository.ts` (issue #4) | -| Semver version pick | — | `install/application/` (issue #8) | -| SHA-256 verify | — | `install/infrastructure/` (protocol step 9) | -| ZIP security scan | — | `install/infrastructure/` (protocol step 10) | -| Extract to target paths | — | `install/infrastructure/` per [registry install-targets](https://github.com/agents-repo/registry/blob/main/specs/install-targets.md) | -| Config and lock writes | — | Delegates to `config/` (protocol step 12) | +| Manifest + metadata fetch | — | `registry/infrastructure/registryRepository.ts` | +| Semver version pick | — | `install/application/resolveInstallVersion.ts` | +| SHA-256 verify | — | `install/infrastructure/sha256Verifier.ts` | +| ZIP security scan | — | `install/infrastructure/zipSecurityScanner.ts` | +| Extract to target paths | — | `install/infrastructure/packageExtractor.ts` | +| Config and lock writes | — | `install/application/installPersistence.ts` | + +**Dependencies:** `adm-zip` (extract), `gray-matter` (agent frontmatter scan), `semver` +(version pick and registry ref resolution). ### Install pipeline overview @@ -168,13 +188,13 @@ verification, and extraction per [`specs/cli-protocol.md`](../specs/cli-protocol 3. Fetch packages/index.json -> registry/ 4. Resolve package id -> registry/ 5. Fetch versions/manifest.json -> registry/ (issue #4) -6. Pick version (semver) -> install/application/ (issue #8) +6. Pick version (semver) -> install/application/ 7. Pick artifact for install target -> registry/application/ + install/ 8. Download ZIP -> install/infrastructure/ 9. Verify SHA-256 -> install/infrastructure/ 10. ZIP security scan -> install/infrastructure/ 11. Extract package -> install/infrastructure/ -12. Update agents.json + lock -> config/ +12. Update agents.json + lock -> config/ + install/application/ ``` See [`specs/cli-protocol.md`](../specs/cli-protocol.md) for normative step @@ -191,5 +211,6 @@ details. ## Related docs - [commands/init.md](commands/init.md) — `init` command usage +- [commands/install.md](commands/install.md) — `install` command usage - [architecture/ddd-decision.md](architecture/ddd-decision.md) - [development.md](development.md) diff --git a/docs/architecture/ddd-decision.md b/docs/architecture/ddd-decision.md index 10d7966..13f9c6f 100644 --- a/docs/architecture/ddd-decision.md +++ b/docs/architecture/ddd-decision.md @@ -20,8 +20,8 @@ registration, and global option hooks. - `src/modules/registry/` — Registry index, manifest, and artifact URL resolution; copy-adapted from the webapp registry module (implemented in issue #4). -- `src/modules/install/` — Planned download, SHA-256 verification, ZIP security - scan, and extract packages per install target. +- `src/modules/install/` — Download, SHA-256 verification, ZIP security scan, and + extract packages per install target (implemented in issue #8). - `src/modules/target/` — Detection of IDE/project install targets from filesystem markers (implemented in issue #6; consumed by `init` in issue #7). @@ -70,12 +70,9 @@ See [ARCHITECTURE.md](../ARCHITECTURE.md) for the webapp-to-CLI file mapping. ## Global Flags (M0) Issue #3 registers root-level `--json` and `--verbose`. Issue #7 adds `--yes` / -`-y` for non-interactive conflict handling on `init` (and future `install`). - -Deferred to later command issues: - -- `--dry-run` — install resolve-only mode -- `--no-save` — skip config and lock writes +`-y` for non-interactive conflict handling on `init` and `install`. Issue #8 +adds `--dry-run` and `--no-save` for install resolve-only mode and skipped +config/lock writes. `DEBUG` env override for verbose logging is documented in `specs/command-contracts.md` and will be wired when logging is implemented. @@ -85,8 +82,9 @@ Deferred to later command issues: Module directories and the Commander root program are scaffolded in issue #3. The registry module (issue #4) and config module (issue #5) are implemented. Install target detection (issue #6) is implemented in `target/`. The `init` -command (issue #7) wires config and target modules through `InitService`. -Install pipeline and remaining command wiring are tracked in downstream issues. +command (issue #7) wires config and target modules through `InitService`. The +`install` command (issue #8) wires config, registry, and install modules through +`InstallService`. Remaining command wiring is tracked in downstream issues. ## Why This Decision Exists diff --git a/docs/commands/install.md b/docs/commands/install.md new file mode 100644 index 0000000..22891d8 --- /dev/null +++ b/docs/commands/install.md @@ -0,0 +1,151 @@ +# `install` command + +Install a single package from the registry: resolve version and artifact for the +active install target, download the ZIP, verify integrity, scan for unsafe paths, +extract into the project (or global directory), and update `agents.json` and +`agents-lock.json` unless disabled. + +Bulk `install` (sync all `packages` entries) is tracked in issue #9. + +## Usage + +```bash +agents-repo install [options] +agents-repo i [options] +``` + +`` is a qualified id (for example `agents-repo/sample-agent`) or an +index alias defined in `packages/index.json`. + +## Flags + +| Flag | Scope | Description | +| --- | --- | --- | +| `--target ` | install | Override install target for this invocation | +| `--global` / `-g` | install | Global extract; single-package skips config/lock writes | +| `--yes` / `-y` | install / global | Waive dual-definition mismatches with warnings | +| `--dry-run` | global | Resolve through artifact selection; no download, extract, or save | +| `--no-save` | global | Skip `agents.json` and lock writes after a successful extract | +| `--json` | global | Machine-readable success and error output | +| `--verbose` | global | Detailed logging | + +`--dry-run` and `--no-save` are root-level flags (`agents-repo --dry-run install …`). + +## Behavior + +### Prerequisites + +- An install target must be available from `agents.json` or `--target`. Without + either, the command exits `3`. +- Run `agents-repo init --target ` first when starting from an empty project. + +### Version selection + +| Condition | Version pick | +| --- | --- | +| Existing `packages[]` range in config | Highest matching manifest version (no prereleases) | +| Ad-hoc install (no `packages` entry) | Highest stable manifest version (no prereleases) | + +### Registry resolution + +Install loads the catalog, package manifest, and version-scoped `metadata.json`. +Target support is validated against index `installTargets` (when present), +metadata `compatibility.targets`, and manifest artifacts. + +### Extract scope + +| Scope | Extract root | Config / lock writes | +| --- | --- | --- | +| Project (default) | Project cwd | Updates config/lock unless `--no-save` / `--dry-run` | +| Global (`-g`) | Global config dir | Skips project config on single-package install | + +Ad-hoc project installs add `packages[] = ^` unless +`--no-save` or `--dry-run`. + +### Install pipeline + +The command follows [`specs/cli-protocol.md`](../../specs/cli-protocol.md): + +1. Load config (with env overrides) +2. Resolve registry ref (including major-line aliases such as `v2.x`) +3. Fetch catalog, manifest, and metadata +4. Pick version and artifact for the install target +5. Download ZIP, verify SHA-256, run ZIP security scan +6. Extract using registry install-target path rules +7. Persist config and lock when allowed + +`--dry-run` stops after step 4 and prints the resolved install plan. + +### Environment overrides + +| Variable | Effect | +| --- | --- | +| `AGENTS_REPO_CONFIG` | Absolute path to `agents.json` | +| `AGENTS_REPO_REGISTRY_URL` | Overrides `registry.url` after file resolution | + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success (or successful `--dry-run`) | +| `1` | Runtime failure (network, I/O, ZIP security, checksum mismatch) | +| `2` | Usage error | +| `3` | Validation failure (missing target, unsupported target, package not found, schema errors) | +| `4` | Dual-definition conflict in config (without `--yes`) | + +## Examples + +Install into a configured project: + +```bash +agents-repo init --target cursor +agents-repo install agents-repo/sample-agent +# Installed agents-repo/sample-agent@1.0.0 for target cursor into /path/to/project +``` + +Resolve only: + +```bash +agents-repo --dry-run install agents-repo/sample-agent +# Would install agents-repo/sample-agent@1.0.0 for target cursor into /path/to/project +``` + +Override target for one invocation: + +```bash +agents-repo install agents-repo/sample-agent --target github-copilot +``` + +Extract globally without updating project config: + +```bash +agents-repo install -g agents-repo/sample-agent --target cursor +``` + +### JSON output + +With `--json`, successful installs print one JSON object on stdout: + +```json +{ + "packageId": "agents-repo/sample-agent", + "version": "1.0.0", + "target": "cursor", + "extractRoot": "/path/to/project", + "artifactUrl": "https://registry.example/.../1.0.0-cursor.zip", + "saved": true, + "dryRun": false, + "global": false, + "noSave": false, + "warnings": [] +} +``` + +Errors use a single JSON object on stderr: `{"error":{"code":"...","message":"..."}}`. + +## Related specs + +- [cli-protocol.md](../../specs/cli-protocol.md) — install pipeline steps +- [command-contracts.md](../../specs/command-contracts.md) — flags and exit codes +- [config-schema.md](../../specs/config-schema.md) — `agents.json` merge rules +- [lock-schema.md](../../specs/lock-schema.md) — lockfile integrity fields diff --git a/docs/development.md b/docs/development.md index 252151f..8558997 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,6 +39,10 @@ npm install-scripts approve @ Commit the resulting `allowScripts` update with your dependency change. +Install support adds runtime dependencies `adm-zip` and `gray-matter` (plus +`@types/adm-zip` for development). Neither package requires install-script +approval in the current npm 12 lockfile (`allowScripts` remains empty). + ## Local Validation Run these checks before marking the pull request ready for review: @@ -69,7 +73,7 @@ src/ modules/ # DDD modules (cli, config, registry, install, target) specs/ # Normative CLI contracts test/ # node:test tooling script tests -tests/ # Vitest application tests +tests/ # Vitest tests (async spawn when subprocess tests use local HTTP) scripts/ # Validation and sync scripts docs/ # Contributor and architecture docs ``` diff --git a/package-lock.json b/package-lock.json index 7171bd1..d03d84e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "agents-repo", "version": "0.0.0", "dependencies": { + "adm-zip": "^0.6.0", "commander": "^15.0.0", + "gray-matter": "^4.0.3", "semver": "^7.8.5" }, "bin": { @@ -20,6 +22,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", + "@types/adm-zip": "^0.5.8", "@types/node": "^24.12.3", "@types/semver": "^7.7.1", "conventional-changelog-conventionalcommits": "^8.0.0", @@ -1166,6 +1169,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1626,6 +1639,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, "node_modules/agent-base": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", @@ -2904,6 +2926,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3014,6 +3049,18 @@ "node": ">=12.0.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3350,6 +3397,43 @@ "dev": true, "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -3630,6 +3714,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3936,6 +4029,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8023,6 +8125,19 @@ "node": "^14.0.0 || >=16.0.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semantic-release": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.7.tgz", @@ -8444,6 +8559,12 @@ "through2": "~2.0.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8522,6 +8643,15 @@ "node": ">=4" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", diff --git a/package.json b/package.json index eaa333b..589cf77 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "prepare": "husky" }, "dependencies": { + "adm-zip": "^0.6.0", "commander": "^15.0.0", + "gray-matter": "^4.0.3", "semver": "^7.8.5" }, "devDependencies": { @@ -38,6 +40,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", + "@types/adm-zip": "^0.5.8", "@types/node": "^24.12.3", "@types/semver": "^7.7.1", "conventional-changelog-conventionalcommits": "^8.0.0", diff --git a/src/bin/agents-repo.ts b/src/bin/agents-repo.ts index e51c312..9cd90f2 100644 --- a/src/bin/agents-repo.ts +++ b/src/bin/agents-repo.ts @@ -3,6 +3,7 @@ import { runCli } from '../modules/cli/presentation/runCli.js'; try { await runCli(process.argv); + process.exit(0); } catch (error) { if (error instanceof Error) { console.error(error.message); diff --git a/src/modules/cli/application/cliGlobals.ts b/src/modules/cli/application/cliGlobals.ts index 33a119f..cc94ca6 100644 --- a/src/modules/cli/application/cliGlobals.ts +++ b/src/modules/cli/application/cliGlobals.ts @@ -2,12 +2,16 @@ export interface CliGlobals { json: boolean; verbose: boolean; yes: boolean; + dryRun: boolean; + noSave: boolean; } const defaultGlobals: CliGlobals = { json: false, verbose: false, yes: false, + dryRun: false, + noSave: false, }; let currentGlobals: CliGlobals = { ...defaultGlobals }; diff --git a/src/modules/cli/presentation/cliErrorHandling.ts b/src/modules/cli/presentation/cliErrorHandling.ts index 13bd83a..cf4d4aa 100644 --- a/src/modules/cli/presentation/cliErrorHandling.ts +++ b/src/modules/cli/presentation/cliErrorHandling.ts @@ -4,7 +4,10 @@ import { ConfigParseError, ConfigValidationError, } from '../../config/domain/configErrors.js'; +import { RegistryError, RegistryFetchError } from '../../registry/domain/errors.js'; import { TargetDetectionError } from '../../target/domain/targetDetectionErrors.js'; +import { InstallRuntimeError, InstallZipSecurityError } from '../../install/domain/installErrors.js'; +import { getCliGlobals } from '../application/cliGlobals.js'; const formatConflictWarnings = (error: ConfigConflictError): string => { return error.conflicts.map((conflict) => conflict.message).join('\n'); @@ -32,7 +35,32 @@ const formatUnknownThrowable = (error: unknown): string => { } }; +const getErrorCode = (error: unknown): string | undefined => { + if (error instanceof ConfigError) { + return error.code; + } + + if (error instanceof InstallZipSecurityError || error instanceof InstallRuntimeError) { + return error.code; + } + + if (error instanceof RegistryError) { + return error.code; + } + + return undefined; +}; + export const writeCliError = (error: unknown): void => { + const globals = getCliGlobals(); + const code = getErrorCode(error); + + if (globals.json) { + const message = error instanceof Error ? error.message : formatUnknownThrowable(error); + process.stderr.write(`${JSON.stringify({ error: { code: code ?? 'runtime_error', message } })}\n`); + return; + } + if (error instanceof ConfigConflictError) { process.stderr.write(`${error.message}\n`); if (error.conflicts.length > 0) { @@ -42,7 +70,8 @@ export const writeCliError = (error: unknown): void => { } if (error instanceof Error) { - process.stderr.write(`${error.message}\n`); + const prefix = code ? `[${code}] ` : ''; + process.stderr.write(`${prefix}${error.message}\n`); return; } @@ -56,6 +85,18 @@ export const getCliExitCode = (error: unknown): number => { return error.exitCode; } + if (error instanceof RegistryFetchError) { + return 1; + } + + if (error instanceof RegistryError) { + return 3; + } + + if (error instanceof InstallZipSecurityError || error instanceof InstallRuntimeError) { + return error.exitCode; + } + return 1; }; diff --git a/src/modules/cli/presentation/createCliProgram.ts b/src/modules/cli/presentation/createCliProgram.ts index 4390f30..c710909 100644 --- a/src/modules/cli/presentation/createCliProgram.ts +++ b/src/modules/cli/presentation/createCliProgram.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { setCliGlobals } from '../application/cliGlobals.js'; import { registerInitCommand } from './initCommand.js'; +import { registerInstallCommand } from './installCommand.js'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); @@ -37,13 +38,21 @@ const registerPlaceholderCommand = ( }; const syncGlobalsFromCommand = (command: Command): void => { - const { json = false, verbose = false, yes = false } = command.optsWithGlobals<{ + const { + json = false, + verbose = false, + yes = false, + dryRun = false, + save, + } = command.optsWithGlobals<{ json?: boolean; verbose?: boolean; yes?: boolean; + dryRun?: boolean; + save?: boolean; }>(); - setCliGlobals({ json, verbose, yes }); + setCliGlobals({ json, verbose, yes, dryRun, noSave: save === false }); }; export const createCliProgram = (): Command => { @@ -56,6 +65,8 @@ export const createCliProgram = (): Command => { .option('--json', 'Machine-readable output') .option('--verbose', 'Detailed logging') .option('-y, --yes', 'Waive dual-definition mismatches with warnings') + .option('--dry-run', 'Resolve install through artifact selection without download or save') + .option('--no-save', 'Skip agents.json and lock writes') .showHelpAfterError() .hook('preAction', (thisCommand) => { syncGlobalsFromCommand(thisCommand); @@ -63,7 +74,7 @@ export const createCliProgram = (): Command => { .exitOverride(); registerInitCommand(program); - registerPlaceholderCommand(program, 'install', 'Install packages from the registry', 8, ['i']); + registerInstallCommand(program); registerPlaceholderCommand(program, 'search', 'Search the registry catalog', 10, ['find']); registerPlaceholderCommand(program, 'list', 'List installed packages', 11, ['ls']); diff --git a/src/modules/cli/presentation/installCommand.ts b/src/modules/cli/presentation/installCommand.ts new file mode 100644 index 0000000..97dd0fd --- /dev/null +++ b/src/modules/cli/presentation/installCommand.ts @@ -0,0 +1,84 @@ +import type { Command } from 'commander'; + +import { getCliGlobals } from '../application/cliGlobals.js'; +import { InstallService } from '../../install/application/installService.js'; +import type { InstallResult } from '../../install/domain/installResult.js'; +import { handleCliError } from './cliErrorHandling.js'; + +export interface InstallCommandOptions { + readonly global?: boolean; + readonly target?: string; + readonly yes?: boolean; +} + +const formatInstallSuccess = (result: InstallResult): string => { + const action = result.dryRun ? 'Would install' : 'Installed'; + let saveSuffix = ''; + if (!result.saved && !result.dryRun && result.noSave) { + saveSuffix = ' (not saved)'; + } + return `${action} ${result.packageId}@${result.version} for target ${result.target} into ${result.extractRoot}${saveSuffix}`; +}; + +const writeInstallWarnings = (warnings: InstallResult['warnings'], json: boolean): void => { + if (json) { + return; + } + + for (const warning of warnings) { + process.stderr.write(`warning: ${warning}\n`); + } +}; + +const writeInstallSuccess = (result: InstallResult, json: boolean): void => { + if (json) { + process.stdout.write( + `${JSON.stringify({ + packageId: result.packageId, + version: result.version, + target: result.target, + extractRoot: result.extractRoot, + artifactUrl: result.artifactUrl, + saved: result.saved, + dryRun: result.dryRun, + global: result.global, + noSave: result.noSave, + warnings: result.warnings, + })}\n`, + ); + return; + } + + process.stdout.write(`${formatInstallSuccess(result)}\n`); +}; + +export const registerInstallCommand = (program: Command): void => { + program + .command('install ') + .alias('i') + .description('Install a package from the registry') + .option('-g, --global', 'Install to global directory without updating project config') + .option('--target ', 'Override install target for this invocation') + .option('-y, --yes', 'Waive dual-definition mismatches with warnings') + .action(async function installAction(this: Command, packageId: string, options: InstallCommandOptions) { + const globals = getCliGlobals(); + const rootOpts = this.optsWithGlobals<{ yes?: boolean }>(); + + try { + const service = new InstallService(); + const result = await service.run({ + packageId, + target: options.target, + global: options.global ?? false, + yes: options.yes ?? rootOpts.yes ?? globals.yes ?? false, + dryRun: globals.dryRun, + noSave: globals.noSave, + }); + + writeInstallWarnings(result.warnings, globals.json); + writeInstallSuccess(result, globals.json); + } catch (error) { + handleCliError(error); + } + }); +}; diff --git a/src/modules/install/application/installPersistence.ts b/src/modules/install/application/installPersistence.ts new file mode 100644 index 0000000..9d46c3d --- /dev/null +++ b/src/modules/install/application/installPersistence.ts @@ -0,0 +1,74 @@ +import { LOCKFILE_VERSION } from '../../config/domain/configConstants.js' +import type { ResolvedAgentsConfig } from '../../config/domain/agentsConfig.js' +import type { AgentsLockDocument, PackageLockEntry } from '../../config/domain/agentsLock.js' +import { ConfigMerger } from '../../config/application/configMerger.js' +import { LockFileService } from '../../config/application/lockFileService.js' +import { AgentsJsonRepository } from '../../config/infrastructure/agentsJsonRepository.js' +import type { InstallTargetId } from '../../registry/domain/package.js' +import type { ManifestArtifact } from '../../registry/domain/manifest.js' +import { assertResolvableLockRef } from './resolveLockRef.js' + +export interface InstallPersistenceInput { + readonly resolved: ResolvedAgentsConfig + readonly packageId: string + readonly version: string + readonly target: InstallTargetId + readonly artifact: ManifestArtifact + readonly resolvedRef: string + readonly adHocInstall: boolean +} + +export class InstallPersistence { + private readonly configMerger = new ConfigMerger() + private readonly agentsJsonRepository = new AgentsJsonRepository() + private readonly lockFileService = new LockFileService() + + async save(input: InstallPersistenceInput): Promise { + assertResolvableLockRef(input.resolvedRef) + + const patch: { + target?: InstallTargetId + registry?: ResolvedAgentsConfig['registry'] + packages?: Record + } = {} + + if (input.adHocInstall) { + patch.packages = { [input.packageId]: `^${input.version}` } + } + + if (input.resolved.rawDocument === null) { + patch.registry = input.resolved.registry + patch.target = input.target + if (patch.packages === undefined) { + patch.packages = { [input.packageId]: `^${input.version}` } + } + } else if (input.resolved.target === undefined) { + patch.target = input.target + } + + const merged = this.configMerger.merge(input.resolved.rawDocument, patch, { + gateMode: input.resolved.gateMode, + force: true, + }) + + const existingLock = await this.lockFileService.read(input.resolved.lockPath) + const lockEntry: PackageLockEntry = { + version: input.version, + target: input.target, + integrity: this.lockFileService.formatIntegrity(input.artifact.sha256), + artifact: input.artifact.file, + } + + const lockDocument: AgentsLockDocument = { + lockfileVersion: LOCKFILE_VERSION, + resolvedRef: input.resolvedRef, + packages: { + ...(existingLock?.packages ?? {}), + [input.packageId]: lockEntry, + }, + } + + await this.agentsJsonRepository.write(input.resolved.configPath, merged) + await this.lockFileService.write(input.resolved.lockPath, lockDocument) + } +} diff --git a/src/modules/install/application/installScope.ts b/src/modules/install/application/installScope.ts new file mode 100644 index 0000000..9aa752f --- /dev/null +++ b/src/modules/install/application/installScope.ts @@ -0,0 +1,30 @@ +import os from 'node:os' +import path from 'node:path' + +export interface InstallScope { + readonly global: boolean + readonly extractRoot: string + readonly mutateProjectConfig: boolean +} + +export const resolveInstallScope = (options: { + readonly cwd: string + readonly env?: NodeJS.ProcessEnv + readonly globalFlag?: boolean + readonly configGlobal?: boolean +}): InstallScope => { + const global = options.globalFlag === true || options.configGlobal === true + const homedir = + options.env?.HOME?.trim() || + options.env?.USERPROFILE?.trim() || + os.homedir() + const extractRoot = global + ? path.join(homedir, '.config', 'agents-repo') + : options.cwd + + return { + global, + extractRoot, + mutateProjectConfig: !global, + } +} diff --git a/src/modules/install/application/installService.ts b/src/modules/install/application/installService.ts new file mode 100644 index 0000000..a104072 --- /dev/null +++ b/src/modules/install/application/installService.ts @@ -0,0 +1,157 @@ +import { ConfigResolver } from '../../config/application/configResolver.js' +import { ConfigValidationError } from '../../config/domain/configErrors.js' +import { isValidInstallTargetId } from '../../config/domain/validators.js' +import { + buildManifestArtifactUrl, + findManifestArtifact, +} from '../../registry/application/resolveArtifact.js' +import { evaluatePackageStatusPolicy } from '../../registry/application/packageStatusPolicy.js' +import { resolvePackageInCatalog } from '../../registry/application/resolvePackageInCatalog.js' +import { + loadPackageManifest, + loadPackageMetadata, + loadRegistryCatalog, +} from '../../registry/infrastructure/registryRepository.js' +import { resolveInstallVersion } from './resolveInstallVersion.js' +import { resolveInstallScope } from './installScope.js' +import { assertInstallTargetSupported } from './validateInstallTarget.js' +import { resolveLockRef } from './resolveLockRef.js' +import { InstallPersistence } from './installPersistence.js' +import { downloadArtifact } from '../infrastructure/artifactDownloader.js' +import { verifySha256 } from '../infrastructure/sha256Verifier.js' +import { + extractPackageArtifact, + rollbackExtractedPaths, +} from '../infrastructure/packageExtractor.js' +import type { InstallResult } from '../domain/installResult.js' + +export interface InstallServiceOptions { + readonly cwd?: string + readonly env?: NodeJS.ProcessEnv + readonly packageId: string + readonly target?: string + readonly global?: boolean + readonly yes?: boolean + readonly dryRun?: boolean + readonly noSave?: boolean +} + +export class InstallService { + private readonly configResolver = new ConfigResolver() + private readonly installPersistence = new InstallPersistence() + + async run(options: InstallServiceOptions): Promise { + const cwd = options.cwd ?? process.cwd() + const env = options.env ?? process.env + const warnings: string[] = [] + + const resolved = await this.configResolver.resolve({ + cwd, + env, + waiveConflicts: options.yes ?? false, + }) + + warnings.push(...resolved.warnings.map((warning) => warning.message)) + + const effectiveTargetInput = options.target ?? resolved.target + if (effectiveTargetInput === undefined) { + throw new ConfigValidationError('Install target is required but missing from config', 'missing_target') + } + + if (!isValidInstallTargetId(effectiveTargetInput)) { + throw new ConfigValidationError( + `Invalid install target id: ${effectiveTargetInput}`, + 'invalid_enum', + ) + } + + const target = effectiveTargetInput + const scope = resolveInstallScope({ + cwd, + env, + globalFlag: options.global, + configGlobal: resolved.global, + }) + + const catalogResult = await loadRegistryCatalog(resolved.registry) + warnings.push(...catalogResult.warnings) + + const pkg = resolvePackageInCatalog(catalogResult.catalog, options.packageId) + const statusPolicy = evaluatePackageStatusPolicy(pkg.status, pkg.id) + warnings.push(...statusPolicy.warnings) + + const manifest = await loadPackageManifest( + catalogResult.registryBaseUrl, + pkg.namespace, + pkg.package, + ) + + const existingRange = resolved.packages[pkg.id] + const adHocInstall = !Object.hasOwn(resolved.packages, pkg.id) + const version = resolveInstallVersion(manifest, pkg.id, existingRange) + + const metadata = await loadPackageMetadata( + catalogResult.registryBaseUrl, + pkg.namespace, + pkg.package, + version, + ) + + assertInstallTargetSupported(pkg, metadata, manifest, version, target) + + const artifact = findManifestArtifact(manifest, version, target) + const artifactUrl = buildManifestArtifactUrl( + catalogResult.registryBaseUrl, + pkg.namespace, + pkg.package, + version, + artifact.file, + ) + + const noSave = options.noSave === true + + const resultBase: InstallResult = { + packageId: pkg.id, + version, + target, + extractRoot: scope.extractRoot, + artifactUrl, + saved: false, + dryRun: options.dryRun ?? false, + global: scope.global, + noSave, + warnings, + } + + if (options.dryRun === true) { + return resultBase + } + + const zipBytes = await downloadArtifact(artifactUrl) + verifySha256(zipBytes, artifact.sha256) + const extractedPaths = await extractPackageArtifact(zipBytes, target, version, scope.extractRoot) + + if (!noSave && scope.mutateProjectConfig) { + try { + const resolvedRef = resolveLockRef(resolved, catalogResult) + await this.installPersistence.save({ + resolved: { ...resolved, target }, + packageId: pkg.id, + version, + target, + artifact, + resolvedRef, + adHocInstall, + }) + } catch (error) { + await rollbackExtractedPaths(extractedPaths) + throw error + } + } + + return { + ...resultBase, + saved: !noSave && scope.mutateProjectConfig, + } + } +} diff --git a/src/modules/install/application/resolveInstallVersion.ts b/src/modules/install/application/resolveInstallVersion.ts new file mode 100644 index 0000000..c3ac030 --- /dev/null +++ b/src/modules/install/application/resolveInstallVersion.ts @@ -0,0 +1,32 @@ +import semver from 'semver' + +import type { PackageManifest } from '../../registry/domain/manifest.js' +import { NoMatchingVersionError } from '../../registry/domain/errors.js' + +export const resolveInstallVersion = ( + manifest: PackageManifest, + packageId: string, + semverRange?: string, +): string => { + const versions = manifest.versions.map((entry) => entry.version) + + if (semverRange !== undefined) { + const match = semver.maxSatisfying(versions, semverRange, { includePrerelease: false }) + if (match === null) { + throw new NoMatchingVersionError(packageId, semverRange) + } + return match + } + + const sorted = versions + .filter((version) => semver.prerelease(version) === null) + .map((version) => ({ version, parsed: semver.parse(version) })) + .filter((entry): entry is { version: string; parsed: semver.SemVer } => entry.parsed !== null) + .sort((left, right) => semver.rcompare(left.parsed, right.parsed)) + + if (sorted.length === 0) { + throw new NoMatchingVersionError(packageId) + } + + return sorted[0].version +} diff --git a/src/modules/install/application/resolveLockRef.ts b/src/modules/install/application/resolveLockRef.ts new file mode 100644 index 0000000..e49c1b4 --- /dev/null +++ b/src/modules/install/application/resolveLockRef.ts @@ -0,0 +1,33 @@ +import type { ResolvedAgentsConfig } from '../../config/domain/agentsConfig.js' +import { ConfigValidationError } from '../../config/domain/configErrors.js' +import { isConcreteRegistryRef } from '../../config/domain/validators.js' +import type { RegistryCatalogLoadResult } from '../../registry/infrastructure/registryRepository.js' +import { RegistryRefResolutionError } from '../../registry/domain/errors.js' + +export const resolveLockRef = ( + config: ResolvedAgentsConfig, + catalogResult: RegistryCatalogLoadResult, +): string => { + const fromResolution = catalogResult.baseUrlRefResolution?.resolvedRef + if (fromResolution !== undefined) { + return fromResolution + } + + const configuredRef = config.registry.ref + if (isConcreteRegistryRef(configuredRef)) { + return configuredRef + } + + throw new RegistryRefResolutionError( + 'Could not determine concrete registry ref for agents-lock.json resolvedRef', + ) +} + +export const assertResolvableLockRef = (ref: string): void => { + if (!isConcreteRegistryRef(ref)) { + throw new ConfigValidationError( + 'Registry ref must resolve to a concrete git ref before lock write', + 'type_mismatch', + ) + } +} diff --git a/src/modules/install/application/validateInstallTarget.ts b/src/modules/install/application/validateInstallTarget.ts new file mode 100644 index 0000000..4af3282 --- /dev/null +++ b/src/modules/install/application/validateInstallTarget.ts @@ -0,0 +1,63 @@ +import type { InstallTargetId, RegistryPackage } from '../../registry/domain/package.js' +import { findManifestArtifact } from '../../registry/application/resolveArtifact.js' +import { InstallTargetUnsupportedError } from '../../registry/domain/errors.js' +import type { PackageManifest } from '../../registry/domain/manifest.js' +import type { PackageMetadata } from '../../registry/domain/packageMetadata.js' +const isActiveIndexTarget = ( + installTargets: RegistryPackage['installTargets'], + targetId: InstallTargetId, +): boolean => { + if (installTargets === undefined) { + return true + } + + const entry = installTargets.find((target) => target.id === targetId) + if (entry === undefined) { + return false + } + + return entry.status === 'supported' || entry.status === 'experimental' +} + +const isActiveMetadataTarget = ( + metadata: PackageMetadata, + targetId: InstallTargetId, +): boolean => { + if (metadata.compatibility === undefined) { + return false + } + + const entry = metadata.compatibility.targets.find((target) => target.id === targetId) + + if (entry === undefined) { + return false + } + + return entry.status === 'supported' || entry.status === 'experimental' +} + +export const assertInstallTargetSupported = ( + pkg: RegistryPackage, + metadata: PackageMetadata, + manifest: PackageManifest, + version: string, + targetId: InstallTargetId, +): void => { + if (!isActiveIndexTarget(pkg.installTargets, targetId)) { + throw new InstallTargetUnsupportedError( + pkg.id, + targetId, + 'target is not listed as supported or experimental in the registry index', + ) + } + + if (!isActiveMetadataTarget(metadata, targetId)) { + throw new InstallTargetUnsupportedError( + pkg.id, + targetId, + 'target is not supported in version metadata compatibility.targets', + ) + } + + findManifestArtifact(manifest, version, targetId) +} diff --git a/src/modules/install/domain/installErrors.ts b/src/modules/install/domain/installErrors.ts new file mode 100644 index 0000000..9ba9414 --- /dev/null +++ b/src/modules/install/domain/installErrors.ts @@ -0,0 +1,21 @@ +export class InstallZipSecurityError extends Error { + readonly code: string + readonly exitCode = 1 as const + + constructor(code: string, message: string) { + super(message) + this.name = 'InstallZipSecurityError' + this.code = code + } +} + +export class InstallRuntimeError extends Error { + readonly code: string + readonly exitCode = 1 as const + + constructor(code: string, message: string) { + super(message) + this.name = 'InstallRuntimeError' + this.code = code + } +} diff --git a/src/modules/install/domain/installResult.ts b/src/modules/install/domain/installResult.ts new file mode 100644 index 0000000..ce827af --- /dev/null +++ b/src/modules/install/domain/installResult.ts @@ -0,0 +1,14 @@ +import type { InstallTargetId } from '../../registry/domain/package.js' + +export interface InstallResult { + readonly packageId: string + readonly version: string + readonly target: InstallTargetId + readonly extractRoot: string + readonly artifactUrl: string + readonly saved: boolean + readonly dryRun: boolean + readonly global: boolean + readonly noSave: boolean + readonly warnings: readonly string[] +} diff --git a/src/modules/install/infrastructure/artifactDownloader.ts b/src/modules/install/infrastructure/artifactDownloader.ts new file mode 100644 index 0000000..153b804 --- /dev/null +++ b/src/modules/install/infrastructure/artifactDownloader.ts @@ -0,0 +1,29 @@ +import { RegistryFetchError } from '../../registry/domain/errors.js' + +export const downloadArtifact = async ( + artifactUrl: string, + options: { signal?: AbortSignal } = {}, +): Promise => { + let response: Response + + try { + response = await fetch(artifactUrl, { + signal: options.signal, + cache: 'no-store', + }) + } catch (error) { + throw new RegistryFetchError( + error instanceof Error ? error.message : 'Unknown artifact download error', + ) + } + + if (!response.ok) { + throw new RegistryFetchError( + `Artifact download failed (${response.status} ${response.statusText})`, + response.status, + ) + } + + const arrayBuffer = await response.arrayBuffer() + return Buffer.from(arrayBuffer) +} diff --git a/src/modules/install/infrastructure/packageExtractor.ts b/src/modules/install/infrastructure/packageExtractor.ts new file mode 100644 index 0000000..513192c --- /dev/null +++ b/src/modules/install/infrastructure/packageExtractor.ts @@ -0,0 +1,138 @@ +import { lstat, mkdir, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import AdmZip from 'adm-zip' + +import type { InstallTargetId } from '../../registry/domain/package.js' +import { InstallRuntimeError, InstallZipSecurityError } from '../domain/installErrors.js' +import { + assertZipEntryPathSafe, + mapZipEntryToExtractPath, + resolveContainedExtractPath, +} from './targetExtractPaths.js' +import { scanTargetArtifactZipBuffer } from './zipSecurityScanner.js' + +const isEnoentError = (error: unknown): error is NodeJS.ErrnoException => { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +const isExistError = (error: unknown): error is NodeJS.ErrnoException => { + return error instanceof Error && 'code' in error && error.code === 'EEXIST' +} + +const assertNoSymlinksAlongPath = async ( + resolvedRoot: string, + destination: string, +): Promise => { + const relativeParts = path.relative(resolvedRoot, path.resolve(destination)).split(path.sep).filter(Boolean) + let current = resolvedRoot + + for (const part of relativeParts) { + current = path.join(current, part) + try { + const stats = await lstat(current) + if (stats.isSymbolicLink()) { + throw new InstallRuntimeError('path_traversal', `Refusing to extract through symlink: ${current}`) + } + } catch (error) { + if (isEnoentError(error)) { + return + } + + throw error + } + } +} + +const assertDestinationWithinRoot = (resolvedRoot: string, destination: string): void => { + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}` + if (destination !== resolvedRoot && !destination.startsWith(rootPrefix)) { + throw new InstallRuntimeError('path_traversal', `Refusing to extract outside root: ${destination}`) + } +} + +export const rollbackExtractedPaths = async (paths: readonly string[]): Promise => { + for (const filePath of [...paths].reverse()) { + try { + await rm(filePath, { force: true }) + } catch { + // Best-effort rollback when persistence fails after extract. + } + } +} + +export const extractPackageArtifact = async ( + zipBytes: Buffer, + targetId: InstallTargetId, + version: string, + extractRoot: string, +): Promise => { + const issues = scanTargetArtifactZipBuffer(zipBytes, targetId, version) + const blocking = issues.find((issue) => issue.severity === 'error') + if (blocking !== undefined) { + throw new InstallZipSecurityError(blocking.code, blocking.message) + } + + let zip: AdmZip + try { + zip = new AdmZip(zipBytes) + } catch (error) { + throw new InstallZipSecurityError( + 'ERR_ZIP_MALFORMED_ENTRY', + `Cannot open ZIP artifact: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + const resolvedRoot = path.resolve(extractRoot) + const writtenPaths: string[] = [] + + try { + for (const entry of zip.getEntries()) { + const entryName = entry.entryName + if (entryName.endsWith('/')) { + continue + } + + if (entryName.indexOf('..') !== -1) { + throw new InstallRuntimeError( + 'path_traversal', + `Refusing to extract unsafe archive entry: ${entryName}`, + ) + } + + assertZipEntryPathSafe(entryName) + + const mappedName = mapZipEntryToExtractPath(targetId, entryName) + if (mappedName.indexOf('..') !== -1) { + throw new InstallRuntimeError( + 'path_traversal', + `Refusing to extract unsafe mapped path: ${mappedName}`, + ) + } + + const destination = resolveContainedExtractPath(resolvedRoot, mappedName) + assertDestinationWithinRoot(resolvedRoot, destination) + + await assertNoSymlinksAlongPath(resolvedRoot, destination) + await mkdir(path.dirname(destination), { recursive: true }) + try { + await writeFile(destination, entry.getData(), { flag: 'wx' }) + } catch (error) { + if (isExistError(error)) { + throw new InstallRuntimeError( + 'extract_conflict', + `Refusing to overwrite existing file: ${destination}`, + ) + } + + throw error + } + writtenPaths.push(destination) + } + } catch (error) { + await rollbackExtractedPaths(writtenPaths) + throw error + } + + return writtenPaths +} diff --git a/src/modules/install/infrastructure/sha256Verifier.ts b/src/modules/install/infrastructure/sha256Verifier.ts new file mode 100644 index 0000000..35f5fea --- /dev/null +++ b/src/modules/install/infrastructure/sha256Verifier.ts @@ -0,0 +1,13 @@ +import { createHash } from 'node:crypto' + +import { InstallRuntimeError } from '../domain/installErrors.js' + +export const verifySha256 = (bytes: Buffer, expectedHex: string): void => { + const digest = createHash('sha256').update(bytes).digest('hex') + if (digest !== expectedHex) { + throw new InstallRuntimeError( + 'integrity_mismatch', + `SHA-256 mismatch: expected ${expectedHex}, got ${digest}`, + ) + } +} diff --git a/src/modules/install/infrastructure/targetExtractPaths.ts b/src/modules/install/infrastructure/targetExtractPaths.ts new file mode 100644 index 0000000..caf5bee --- /dev/null +++ b/src/modules/install/infrastructure/targetExtractPaths.ts @@ -0,0 +1,50 @@ +import path from 'node:path' + +import type { InstallTargetId } from '../../registry/domain/package.js' + +const AGENTS_DIR = 'agents' +const GITHUB_AGENTS_DIR = '.github/agents' + +export const mapZipEntryToExtractPath = ( + targetId: InstallTargetId, + zipEntryName: string, +): string => { + if (targetId === 'github-copilot' && zipEntryName.startsWith(`${AGENTS_DIR}/`)) { + return pathJoin(GITHUB_AGENTS_DIR, zipEntryName.slice(AGENTS_DIR.length + 1)) + } + + return zipEntryName +} + +const pathJoin = (...segments: string[]): string => segments.join('/') + +export const hasTraversalPattern = (name: string): boolean => { + if ( + name.includes('\0') || + name.startsWith('/') || + name.includes('\\') || + /^[A-Za-z]:/.test(name) + ) { + return true + } + + return name.split('/').includes('..') +} + +export const assertZipEntryPathSafe = (name: string): void => { + if (hasTraversalPattern(name)) { + throw new Error(`Unsafe archive entry path: ${name}`) + } +} + +export const resolveContainedExtractPath = (extractRoot: string, relativePath: string): string => { + assertZipEntryPathSafe(relativePath) + + const resolvedRoot = path.resolve(extractRoot) + const destination = path.resolve(resolvedRoot, relativePath) + if (destination !== resolvedRoot && !destination.startsWith(resolvedRoot + path.sep)) { + throw new Error(`Archive entry escapes extract root: ${relativePath}`) + } + + return destination +} diff --git a/src/modules/install/infrastructure/zipSecurityScanner.ts b/src/modules/install/infrastructure/zipSecurityScanner.ts new file mode 100644 index 0000000..83b1d5d --- /dev/null +++ b/src/modules/install/infrastructure/zipSecurityScanner.ts @@ -0,0 +1,390 @@ +import AdmZip from 'adm-zip' +import matter from 'gray-matter' + +import type { InstallTargetId } from '../../registry/domain/package.js' + +const ZIP_MAX_ENTRY_NAME_LENGTH = 4096 +const ZIP_UNIX_MODE_MASK = 0xffff +const ZIP_UNIX_TYPE_MASK = 0xf000 +const ZIP_SYMLINK_TYPE = 0xa000 + +const DEPLOYMENT_ZIP_ENTRY_PATTERN = /^agents\/[a-z0-9]+(?:-[a-z0-9]+)*\.agent\.md$/ +const CLAUDE_AGENT_ENTRY_PATTERN = /^\.claude\/agents\/[a-z0-9]+(?:-[a-z0-9]+)*\.md$/ +const SKILL_ENTRY_PATTERN = + /^(?:\.cursor\/skills|\.agents\/skills)\/[a-z0-9]+(?:-[a-z0-9]+)*\/SKILL\.md$/ +const DEPLOYMENT_ALLOWED_EXTENSION = '.agent.md' + +export interface ZipValidationIssue { + readonly code: string + readonly message: string + readonly severity: 'error' +} + +const err = (code: string, message: string): ZipValidationIssue => ({ + code, + message, + severity: 'error', +}) + +const parseFrontmatter = (content: string): Record => { + try { + const parsed = matter(content) + const data: unknown = parsed.data + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return {} + } + + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string') { + result[key] = value + } else if (typeof value === 'number' || typeof value === 'boolean') { + result[key] = String(value) + } + } + return result + } catch { + return {} + } +} + +const parseFrontmatterData = (content: string): Record => { + try { + const parsed = matter(content) + const data: unknown = parsed.data + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + return data as Record + } + } catch { + return {} + } + + return {} +} + +const hasTraversalPattern = (name: string): boolean => { + if ( + name.includes('\0') || + name.startsWith('/') || + name.includes('\\') || + /^[A-Za-z]:/.test(name) + ) { + return true + } + + return name.split('/').includes('..') +} + +const validateEntryPath = (name: string, issues: ZipValidationIssue[]): boolean => { + if (name.length === 0 || name.length > ZIP_MAX_ENTRY_NAME_LENGTH) { + issues.push(err('ERR_ZIP_MALFORMED_ENTRY', `Malformed ZIP entry name length: "${name}"`)) + return false + } + + if (hasTraversalPattern(name)) { + issues.push(err('ERR_ZIP_TRAVERSAL', `Path traversal detected in ZIP entry: "${name}"`)) + return false + } + + return true +} + +const hasDeploymentAllowedExtension = (name: string): boolean => { + return name.endsWith(DEPLOYMENT_ALLOWED_EXTENSION) +} + +const validateDisallowedPayload = (name: string, issues: ZipValidationIssue[]): boolean => { + const lowerName = name.toLowerCase() + + if (lowerName.startsWith('agents/') && !hasDeploymentAllowedExtension(name)) { + issues.push( + err( + 'ERR_ZIP_DISALLOWED_PAYLOAD', + `Disallowed file type in deployment ZIP: "${name}" — entries under agents/ must end with ${DEPLOYMENT_ALLOWED_EXTENSION}`, + ), + ) + return false + } + + if ( + (lowerName.startsWith('.cursor/skills/') || lowerName.startsWith('.agents/skills/')) && + !name.endsWith('/SKILL.md') + ) { + issues.push( + err( + 'ERR_ZIP_DISALLOWED_PAYLOAD', + `Disallowed file type in skill ZIP: "${name}" — entries under skill directories must end with /SKILL.md`, + ), + ) + return false + } + + if (lowerName.startsWith('.claude/agents/') && !name.endsWith('.md')) { + issues.push( + err( + 'ERR_ZIP_DISALLOWED_PAYLOAD', + `Disallowed file type in Claude target ZIP: "${name}" — entries under .claude/agents/ must end with .md`, + ), + ) + return false + } + + return true +} + +const validateNotSymlink = ( + entry: AdmZip.IZipEntry, + name: string, + issues: ZipValidationIssue[], +): boolean => { + const unixMode = (entry.attr >>> 16) & ZIP_UNIX_MODE_MASK + if (unixMode !== 0 && (unixMode & ZIP_UNIX_TYPE_MASK) === ZIP_SYMLINK_TYPE) { + issues.push(err('ERR_ZIP_SYMLINK', `Symlink entry detected in ZIP: "${name}"`)) + return false + } + + return true +} + +const trackEntryCollisions = ( + name: string, + issues: ZipValidationIssue[], + seenExact: Set, + seenLower: Map, +): void => { + if (seenExact.has(name)) { + issues.push(err('ERR_ZIP_COLLISION', `Duplicate ZIP entry: "${name}"`)) + } else { + seenExact.add(name) + } + + const lower = name.toLowerCase() + const firstSeen = seenLower.get(lower) + if (firstSeen !== undefined && firstSeen !== name) { + issues.push( + err( + 'ERR_ZIP_COLLISION', + `Case-collision ZIP entry: "${name}" collides with "${firstSeen}"`, + ), + ) + return + } + + if (firstSeen === undefined) { + seenLower.set(lower, name) + } +} + +const formatFrontmatterScopeLabel = ( + scope: 'deployment' | 'source' | 'claude', + capitalize: boolean, +): string => { + if (scope === 'claude') { + return 'Claude target' + } + + if (scope === 'deployment') { + return capitalize ? 'Deployment' : 'deployment' + } + + return capitalize ? 'Source' : 'source' +} + +const validateFrontmatterVersion = ( + entry: AdmZip.IZipEntry, + name: string, + expectedVersion: string, + issues: ZipValidationIssue[], + scope: 'deployment' | 'source' | 'claude', +): void => { + try { + const content = entry.getData().toString('utf-8') + const frontmatter = parseFrontmatter(content) + if (frontmatter.version === expectedVersion) { + return + } + + const frontmatterVersion = Object.hasOwn(frontmatter, 'version') + ? frontmatter.version + : undefined + const frontmatterVersionDisplay = + frontmatterVersion === undefined ? '(missing)' : JSON.stringify(frontmatterVersion) + + const prefix = formatFrontmatterScopeLabel(scope, true) + issues.push( + err( + 'ERR_FRONTMATTER_VERSION_MISMATCH', + `${prefix} ZIP entry "${name}": frontmatter version ${frontmatterVersionDisplay} must be "${expectedVersion}"`, + ), + ) + } catch { + const prefix = formatFrontmatterScopeLabel(scope, false) + issues.push( + err('ERR_ZIP_MALFORMED_ENTRY', `Cannot read content of ${prefix} ZIP entry: "${name}"`), + ) + } +} + +const validateDeploymentEntry = ( + entry: AdmZip.IZipEntry, + name: string, + expectedVersion: string, + issues: ZipValidationIssue[], +): void => { + if (!DEPLOYMENT_ZIP_ENTRY_PATTERN.test(name)) { + issues.push( + err( + 'ERR_ZIP_UNEXPECTED_ENTRY', + `Unexpected entry in deployment ZIP: "${name}" — only agents/.agent.md is allowed`, + ), + ) + return + } + + validateFrontmatterVersion(entry, name, expectedVersion, issues, 'deployment') +} + +const validateSkillEntry = ( + entry: AdmZip.IZipEntry, + name: string, + issues: ZipValidationIssue[], +): void => { + if (!SKILL_ENTRY_PATTERN.test(name)) { + issues.push( + err('ERR_ZIP_UNEXPECTED_ENTRY', `Unexpected entry in skill target ZIP: "${name}"`), + ) + return + } + + try { + const content = entry.getData().toString('utf-8') + const frontmatter = parseFrontmatterData(content) + if (typeof frontmatter.name !== 'string' || frontmatter.name.trim().length === 0) { + issues.push( + err('ERR_ZIP_MALFORMED_ENTRY', `Skill ZIP entry "${name}" must include frontmatter name`), + ) + } + if (typeof frontmatter.description !== 'string' || frontmatter.description.trim().length === 0) { + issues.push( + err( + 'ERR_ZIP_MALFORMED_ENTRY', + `Skill ZIP entry "${name}" must include frontmatter description`, + ), + ) + } + } catch { + issues.push(err('ERR_ZIP_MALFORMED_ENTRY', `Cannot read content of skill ZIP entry: "${name}"`)) + } +} + +const validateClaudeEntry = ( + entry: AdmZip.IZipEntry, + name: string, + expectedVersion: string, + issues: ZipValidationIssue[], +): void => { + if (!CLAUDE_AGENT_ENTRY_PATTERN.test(name)) { + issues.push( + err('ERR_ZIP_UNEXPECTED_ENTRY', `Unexpected entry in Claude target ZIP: "${name}"`), + ) + return + } + + validateFrontmatterVersion(entry, name, expectedVersion, issues, 'claude') +} + +const scanSnapshotZipBuffer = ( + zipBytes: Buffer, + opts: { type: 'deployment' | 'source'; expectedVersion: string }, +): ZipValidationIssue[] => { + const issues: ZipValidationIssue[] = [] + + let zip: AdmZip + try { + zip = new AdmZip(zipBytes) + } catch (error) { + return [err('ERR_ZIP_MALFORMED_ENTRY', `Cannot open ZIP — ${String(error)}`)] + } + + const seenExact = new Set() + const seenLower = new Map() + + for (const entry of zip.getEntries()) { + const name = entry.entryName + + if (name.endsWith('/')) { + continue + } + + if (!validateEntryPath(name, issues)) { + continue + } + + if (!validateNotSymlink(entry, name, issues)) { + continue + } + + trackEntryCollisions(name, issues, seenExact, seenLower) + + if (!validateDisallowedPayload(name, issues)) { + continue + } + + if (opts.type === 'deployment') { + validateDeploymentEntry(entry, name, opts.expectedVersion, issues) + } + } + + return issues +} + +export const scanTargetArtifactZipBuffer = ( + zipBytes: Buffer, + targetId: InstallTargetId, + expectedVersion: string, +): ZipValidationIssue[] => { + if (targetId === 'github-copilot') { + return scanSnapshotZipBuffer(zipBytes, { type: 'deployment', expectedVersion }) + } + + const issues: ZipValidationIssue[] = [] + let zip: AdmZip + try { + zip = new AdmZip(zipBytes) + } catch (error) { + return [err('ERR_ZIP_MALFORMED_ENTRY', `Cannot open ZIP — ${String(error)}`)] + } + + const seenExact = new Set() + const seenLower = new Map() + + for (const entry of zip.getEntries()) { + const name = entry.entryName + if (name.endsWith('/')) { + continue + } + + if (!validateEntryPath(name, issues)) { + continue + } + + if (!validateNotSymlink(entry, name, issues)) { + continue + } + + trackEntryCollisions(name, issues, seenExact, seenLower) + + if (!validateDisallowedPayload(name, issues)) { + continue + } + + if (targetId === 'claude-code') { + validateClaudeEntry(entry, name, expectedVersion, issues) + continue + } + + validateSkillEntry(entry, name, issues) + } + + return issues +} diff --git a/src/modules/registry/domain/errors.ts b/src/modules/registry/domain/errors.ts index 181bf84..3d2144a 100644 --- a/src/modules/registry/domain/errors.ts +++ b/src/modules/registry/domain/errors.ts @@ -1,4 +1,6 @@ export class RegistryError extends Error { + readonly code: string = 'registry_error' + constructor(message: string) { super(message) this.name = 'RegistryError' @@ -6,6 +8,7 @@ export class RegistryError extends Error { } export class RegistryFetchError extends RegistryError { + readonly code = 'registry_fetch_error' readonly statusCode?: number constructor(message: string, statusCode?: number) { @@ -16,6 +19,8 @@ export class RegistryFetchError extends RegistryError { } export class RegistryCatalogValidationError extends RegistryError { + readonly code = 'registry_catalog_validation_error' + constructor(message: string) { super(message) this.name = 'RegistryCatalogValidationError' @@ -23,6 +28,8 @@ export class RegistryCatalogValidationError extends RegistryError { } export class RegistryRefResolutionError extends RegistryError { + readonly code = 'registry_ref_resolution_error' + constructor(message: string) { super(message) this.name = 'RegistryRefResolutionError' @@ -30,6 +37,7 @@ export class RegistryRefResolutionError extends RegistryError { } export class IndexSchemaError extends RegistryError { + readonly code = 'index_schema_error' readonly schemaVersion: string constructor(message: string, schemaVersion: string) { @@ -40,6 +48,7 @@ export class IndexSchemaError extends RegistryError { } export class ManifestSchemaError extends RegistryError { + readonly code = 'manifest_schema_error' readonly schemaVersion?: string constructor(message: string, schemaVersion?: string) { @@ -50,6 +59,7 @@ export class ManifestSchemaError extends RegistryError { } export class PackageNotFoundError extends RegistryError { + readonly code = 'package_not_found' readonly packageId: string constructor(packageId: string) { @@ -60,6 +70,7 @@ export class PackageNotFoundError extends RegistryError { } export class PackageYankedError extends RegistryError { + readonly code = 'package_yanked' readonly packageId: string constructor(packageId: string) { @@ -70,6 +81,7 @@ export class PackageYankedError extends RegistryError { } export class ManifestVersionNotFoundError extends RegistryError { + readonly code = 'manifest_version_not_found' readonly version: string readonly packageId?: string @@ -86,6 +98,7 @@ export class ManifestVersionNotFoundError extends RegistryError { } export class ManifestArtifactNotFoundError extends RegistryError { + readonly code = 'manifest_artifact_not_found' readonly targetId: string readonly version: string @@ -96,3 +109,40 @@ export class ManifestArtifactNotFoundError extends RegistryError { this.version = version } } + +export class MetadataSchemaError extends RegistryError { + readonly code = 'metadata_schema_error' + + constructor(message: string) { + super(message) + this.name = 'MetadataSchemaError' + } +} + +export class InstallTargetUnsupportedError extends RegistryError { + readonly code = 'unsupported_install_target' + readonly targetId: string + readonly packageId: string + + constructor(packageId: string, targetId: string, reason: string) { + super(`Install target "${targetId}" is not supported for ${packageId}: ${reason}`) + this.name = 'InstallTargetUnsupportedError' + this.packageId = packageId + this.targetId = targetId + } +} + +export class NoMatchingVersionError extends RegistryError { + readonly code = 'no_matching_version' + readonly packageId: string + + constructor(packageId: string, range?: string) { + super( + range + ? `No manifest version satisfies range "${range}" for ${packageId}` + : `No installable manifest version found for ${packageId}`, + ) + this.name = 'NoMatchingVersionError' + this.packageId = packageId + } +} diff --git a/src/modules/registry/domain/packageMetadata.ts b/src/modules/registry/domain/packageMetadata.ts new file mode 100644 index 0000000..a9ef756 --- /dev/null +++ b/src/modules/registry/domain/packageMetadata.ts @@ -0,0 +1,24 @@ +import type { InstallTargetId } from './package.js' + +export const METADATA_INSTALL_TARGET_STATUSES = ['supported', 'experimental', 'planned'] as const +export type MetadataInstallTargetStatus = (typeof METADATA_INSTALL_TARGET_STATUSES)[number] + +export interface MetadataCompatibilityTarget { + readonly id: InstallTargetId + readonly status: MetadataInstallTargetStatus +} + +export interface PackageCompatibility { + readonly canonicalFormat: string + readonly targets: readonly MetadataCompatibilityTarget[] +} + +export interface PackageMetadata { + readonly schemaVersion: string + readonly name: string + readonly description: string + readonly owner: string + readonly license: string + readonly version: string + readonly compatibility?: PackageCompatibility +} diff --git a/src/modules/registry/infrastructure/registryMetadataValidation.ts b/src/modules/registry/infrastructure/registryMetadataValidation.ts new file mode 100644 index 0000000..b452971 --- /dev/null +++ b/src/modules/registry/infrastructure/registryMetadataValidation.ts @@ -0,0 +1,131 @@ +import { + INSTALL_TARGET_IDS, + type InstallTargetId, +} from '../domain/package.js' +import { + METADATA_INSTALL_TARGET_STATUSES, + type MetadataCompatibilityTarget, + type PackageCompatibility, + type PackageMetadata, +} from '../domain/packageMetadata.js' +import { MetadataSchemaError } from '../domain/errors.js' + +const isPlainObject = (value: unknown): value is Record => { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +const DEFAULT_CANONICAL_FORMAT = 'agents-repo.agent-instruction@1.0.0' + +const DEFAULT_TARGETS: MetadataCompatibilityTarget[] = INSTALL_TARGET_IDS.map((id) => ({ + id, + status: 'supported', +})) + +const isInstallTargetId = (value: unknown): value is InstallTargetId => { + return typeof value === 'string' && (INSTALL_TARGET_IDS as readonly string[]).includes(value) +} + +const isMetadataInstallTargetStatus = ( + value: unknown, +): value is MetadataCompatibilityTarget['status'] => { + return ( + typeof value === 'string' && + (METADATA_INSTALL_TARGET_STATUSES as readonly string[]).includes(value) + ) +} + +const parseCompatibilityObject = (value: unknown): PackageCompatibility => { + if (!isPlainObject(value)) { + throw new MetadataSchemaError('metadata.json compatibility must be an object when provided') + } + + const canonicalFormatValue = value.canonicalFormat + const canonicalFormat = + typeof canonicalFormatValue === 'string' && canonicalFormatValue.trim().length > 0 + ? canonicalFormatValue + : DEFAULT_CANONICAL_FORMAT + + const rawTargets = value.targets + if (!Array.isArray(rawTargets) || rawTargets.length === 0) { + throw new MetadataSchemaError('metadata.json compatibility.targets must be a non-empty array') + } + + const targets: MetadataCompatibilityTarget[] = [] + const seen = new Set() + + for (const entry of rawTargets) { + if (!isPlainObject(entry)) { + throw new MetadataSchemaError('metadata.json compatibility.targets entries must be objects') + } + + const id = entry.id + const status = entry.status + + if (!isInstallTargetId(id)) { + throw new MetadataSchemaError( + `metadata.json compatibility.targets id must be one of: ${INSTALL_TARGET_IDS.join(', ')}`, + ) + } + + if (!isMetadataInstallTargetStatus(status)) { + throw new MetadataSchemaError( + 'metadata.json compatibility.targets status must be supported, experimental, or planned', + ) + } + + if (seen.has(id)) { + throw new MetadataSchemaError(`metadata.json compatibility.targets contains duplicate id: ${id}`) + } + + seen.add(id) + targets.push({ id, status }) + } + + return { canonicalFormat, targets } +} + +export const resolvePackageCompatibility = (metadata: PackageMetadata): PackageCompatibility => { + if (metadata.compatibility === undefined) { + return { + canonicalFormat: DEFAULT_CANONICAL_FORMAT, + targets: DEFAULT_TARGETS, + } + } + + return metadata.compatibility +} + +export const isPackageMetadata = (value: unknown): value is PackageMetadata => { + if (!isPlainObject(value)) { + return false + } + + return ( + typeof value.schemaVersion === 'string' && + typeof value.name === 'string' && + typeof value.description === 'string' && + typeof value.owner === 'string' && + typeof value.license === 'string' && + typeof value.version === 'string' && + (value.compatibility === undefined || isPlainObject(value.compatibility)) + ) +} + +export const parsePackageMetadata = (value: unknown): PackageMetadata => { + if (!isPackageMetadata(value)) { + throw new MetadataSchemaError('Metadata payload does not match expected schema') + } + + const compatibility = + value.compatibility === undefined ? undefined : parseCompatibilityObject(value.compatibility) + + return { + schemaVersion: value.schemaVersion, + name: value.name, + description: value.description, + owner: value.owner, + license: value.license, + version: value.version, + compatibility, + } +} diff --git a/src/modules/registry/infrastructure/registryRepository.ts b/src/modules/registry/infrastructure/registryRepository.ts index 020c5e6..5ee1050 100644 --- a/src/modules/registry/infrastructure/registryRepository.ts +++ b/src/modules/registry/infrastructure/registryRepository.ts @@ -1,11 +1,14 @@ import type { RegistryCatalog } from '../domain/package.js' import { ManifestSchemaError, RegistryCatalogValidationError, RegistryFetchError } from '../domain/errors.js' import type { PackageManifest } from '../domain/manifest.js' +import type { PackageMetadata } from '../domain/packageMetadata.js' +import { parsePackageMetadata } from './registryMetadataValidation.js' import { isRegistryCatalog } from './registryCatalogValidation.js' import { isPackageManifest } from './registryManifestValidation.js' import { assertIndexSchemaVersion, assertManifestSchemaVersion } from './registrySchemaGate.js' import { buildRegistryManifestUrl, + buildRegistryVersionMetadataUrl, getRegistryBaseUrlFromIndexUrl, } from './registrySourceUrl.js' import { @@ -112,3 +115,29 @@ export const loadPackageManifest = async ( return payload } + +export const loadPackageMetadata = async ( + registryBaseUrl: string, + namespace: string, + packageId: string, + version: string, + options: { signal?: AbortSignal } = {}, +): Promise => { + const metadataUrl = buildRegistryVersionMetadataUrl(registryBaseUrl, namespace, packageId, version) + + let payload: unknown + + try { + payload = await fetchJson(metadataUrl, options.signal) + } catch (error) { + if (error instanceof RegistryFetchError) { + throw error + } + + throw new RegistryFetchError( + error instanceof Error ? error.message : 'Unknown metadata loading error', + ) + } + + return parsePackageMetadata(payload) +} diff --git a/src/modules/registry/infrastructure/registrySourceUrl.ts b/src/modules/registry/infrastructure/registrySourceUrl.ts index 781e8b3..2110145 100644 --- a/src/modules/registry/infrastructure/registrySourceUrl.ts +++ b/src/modules/registry/infrastructure/registrySourceUrl.ts @@ -5,6 +5,7 @@ export const DEFAULT_REGISTRY_GITHUB_REPOSITORY_URL = `https://github.com/agents-repo/registry/tree/${DEFAULT_REGISTRY_REF}` export const DEFAULT_REGISTRY_INDEX_PATH = 'packages/index.json' export const DEFAULT_REGISTRY_MANIFEST_SEGMENT = 'versions/manifest.json' +export const DEFAULT_REGISTRY_METADATA_FILENAME = 'metadata.json' const GITHUB_HOSTNAME = 'github.com' const GITHUB_WWW_HOSTNAME = 'www.github.com' @@ -177,6 +178,31 @@ export const buildRegistryManifestUrl = ( packageId: string, ): string => buildRegistryIndexUrl(baseUrl, buildRegistryManifestPath(namespace, packageId)) +export const buildRegistryVersionMetadataPath = ( + namespace: string, + packageId: string, + version: string, +): string => { + const encodedVersion = encodePathSegment(version) + + return [ + 'packages', + encodePathSegment(namespace), + encodePathSegment(packageId), + 'versions', + encodedVersion, + DEFAULT_REGISTRY_METADATA_FILENAME, + ].join('/') +} + +export const buildRegistryVersionMetadataUrl = ( + baseUrl: string, + namespace: string, + packageId: string, + version: string, +): string => + buildRegistryIndexUrl(baseUrl, buildRegistryVersionMetadataPath(namespace, packageId, version)) + export const buildRegistryArtifactPath = ( namespace: string, packageId: string, diff --git a/tests/bin/agents-repo.test.ts b/tests/bin/agents-repo.test.ts index ef5db3b..e38004c 100644 --- a/tests/bin/agents-repo.test.ts +++ b/tests/bin/agents-repo.test.ts @@ -68,11 +68,19 @@ describe('agents-repo bin', () => { } }); - it('runs install alias i as placeholder', () => { - const result = spawnSync(nodeExecutable, [binPath, 'i'], { encoding: 'utf8' }); + it('runs install alias i', () => { + const cwd = mkdtempSync(join(os.tmpdir(), 'agents-install-alias-')); - expect(result.status).toBe(1); - expect(result.stderr).toContain('install is not implemented yet'); - expect(result.stderr).toContain('issue #8'); + try { + const result = spawnSync(nodeExecutable, [binPath, 'i', 'agents-repo/sample-agent'], { + cwd, + encoding: 'utf8', + }); + + expect(result.status).toBe(3); + expect(result.stderr).toContain('Install target is required'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/tests/fixtures/installFixtures.ts b/tests/fixtures/installFixtures.ts new file mode 100644 index 0000000..c17bd42 --- /dev/null +++ b/tests/fixtures/installFixtures.ts @@ -0,0 +1,129 @@ +import AdmZip from 'adm-zip' + +import type { PackageManifest } from '../../src/modules/registry/domain/manifest.js' +import type { PackageMetadata } from '../../src/modules/registry/domain/packageMetadata.js' +import type { RegistryCatalog } from '../../src/modules/registry/domain/package.js' + +export const INSTALL_TEST_SHA256 = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + +export const makeInstallTestCatalog = ( + options: { readonly status?: 'active' | 'deprecated' | 'yanked' } = {}, +): RegistryCatalog => ({ + schemaVersion: '1.3.0', + updatedAt: '2026-01-01T00:00:00.000Z', + packages: [ + { + id: 'agents-repo/sample-agent', + namespace: 'agents-repo', + package: 'sample-agent', + name: 'sample-agent', + description: 'Sample package for install tests.', + owner: 'agents-repo', + latest: '1.0.0', + tags: ['sample'], + status: options.status ?? 'active', + category: 'agent', + estimateOverallCost: { band: 'low' }, + installTargets: [{ id: 'cursor', status: 'supported' }], + }, + ], +}) + +export const makeInstallTestManifest = (): PackageManifest => ({ + schemaVersion: '1.1.0', + name: 'sample-agent', + latest: '1.0.0', + versions: [ + { + version: '1.0.0', + artifacts: [ + { + target: 'cursor', + file: '1.0.0-cursor.zip', + sha256: INSTALL_TEST_SHA256, + }, + ], + srcArtifact: '1.0.0-src.zip', + srcSha256: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], +}) + +export const makeInstallTestMetadata = (): PackageMetadata => ({ + schemaVersion: '1.0.0', + name: 'sample-agent', + description: 'Sample package for install tests.', + owner: 'agents-repo', + license: 'MIT', + version: '1.0.0', + compatibility: { + canonicalFormat: 'agents-repo.agent-instruction@1.0.0', + targets: [{ id: 'cursor', status: 'supported' }], + }, +}) + +export const withInstallTestArtifactSha256 = ( + manifest: PackageManifest, + sha256: string, +): PackageManifest => { + if (manifest.versions.length === 0) { + return manifest + } + + const firstVersion = manifest.versions[0] + + if (firstVersion.artifacts.length === 0) { + return manifest + } + + const [firstArtifact, ...restArtifacts] = firstVersion.artifacts + + return { + ...manifest, + versions: [ + { + ...firstVersion, + artifacts: [{ ...firstArtifact, sha256 }, ...restArtifacts], + }, + ...manifest.versions.slice(1), + ], + } +} + +export const buildCursorSkillZip = (): Buffer => { + const zip = new AdmZip() + zip.addFile( + '.cursor/skills/sample/SKILL.md', + Buffer.from(`--- +name: sample +description: Sample skill for install tests. +version: 1.0.0 +--- +Body +`), + ) + return zip.toBuffer() +} + +export const buildGithubCopilotZip = (): Buffer => { + const zip = new AdmZip() + zip.addFile( + 'agents/sample.agent.md', + Buffer.from(`--- +name: sample +description: Sample agent for install tests. +version: 1.0.0 +--- +Body +`), + ) + return zip.toBuffer() +} + +export const buildTraversalZip = (): Buffer => { + const zip = new AdmZip() + zip.addFile('../evil.agent.md', Buffer.from('bad')) + return zip.toBuffer() +} diff --git a/tests/modules/cli/cliErrorHandling.test.ts b/tests/modules/cli/cliErrorHandling.test.ts index d037896..11ebd3c 100644 --- a/tests/modules/cli/cliErrorHandling.test.ts +++ b/tests/modules/cli/cliErrorHandling.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCliExitCode, writeCliError } from '../../../src/modules/cli/presentation/cliErrorHandling.js'; import { ConfigValidationError, LockValidationError } from '../../../src/modules/config/domain/configErrors.js'; +import { NoMatchingVersionError, RegistryFetchError } from '../../../src/modules/registry/domain/errors.js'; +import { resetCliGlobals, setCliGlobals } from '../../../src/modules/cli/application/cliGlobals.js'; describe('cliErrorHandling', () => { + afterEach(() => { + resetCliGlobals(); + }); it('writes a fallback message for non-Error throwables', () => { const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); @@ -47,4 +52,61 @@ describe('cliErrorHandling', () => { it('maps unknown throwables to exit code 1', () => { expect(getCliExitCode('boom')).toBe(1); }); + + it('maps registry fetch errors to exit code 1', () => { + const error = new RegistryFetchError('Registry request failed with status 503', 503); + + expect(getCliExitCode(error)).toBe(1); + }); + + it('maps other registry errors to exit code 3', () => { + expect(getCliExitCode(new NoMatchingVersionError('agents-repo/demo', '^9.0.0'))).toBe(3); + }); + + it('writes stable registry error codes in json mode', () => { + setCliGlobals({ json: true, verbose: false, yes: false, dryRun: false, noSave: false }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + writeCliError(new NoMatchingVersionError('agents-repo/demo', '^9.0.0')); + + expect(stderr).toHaveBeenCalledWith( + `${JSON.stringify({ + error: { + code: 'no_matching_version', + message: 'No manifest version satisfies range "^9.0.0" for agents-repo/demo', + }, + })}\n`, + ); + + stderr.mockRestore(); + }); + + it('writes stable registry fetch error codes in json mode', () => { + setCliGlobals({ json: true, verbose: false, yes: false, dryRun: false, noSave: false }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + writeCliError(new RegistryFetchError('Registry request failed with status 503', 503)); + + expect(stderr).toHaveBeenCalledWith( + `${JSON.stringify({ + error: { + code: 'registry_fetch_error', + message: 'Registry request failed with status 503', + }, + })}\n`, + ); + + stderr.mockRestore(); + }); + + it('prefixes registry fetch errors with stable codes in text mode', () => { + setCliGlobals({ json: false, verbose: false, yes: false, dryRun: false, noSave: false }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + writeCliError(new RegistryFetchError('Registry request failed with status 503', 503)); + + expect(stderr).toHaveBeenCalledWith('[registry_fetch_error] Registry request failed with status 503\n'); + + stderr.mockRestore(); + }); }); diff --git a/tests/modules/cli/cliGlobals.test.ts b/tests/modules/cli/cliGlobals.test.ts index f197676..2b8d990 100644 --- a/tests/modules/cli/cliGlobals.test.ts +++ b/tests/modules/cli/cliGlobals.test.ts @@ -52,6 +52,8 @@ describe('cli global options', () => { json: true, verbose: true, yes: false, + dryRun: false, + noSave: false, }); }); }); @@ -66,6 +68,8 @@ describe('cli global options', () => { json: false, verbose: false, yes: true, + dryRun: false, + noSave: false, }); }); }); @@ -80,6 +84,26 @@ describe('cli global options', () => { json: false, verbose: false, yes: false, + dryRun: false, + noSave: false, + }); + }); + }); + + it('sets dry-run and no-save globals from root options', async () => { + await withConfigOverride(async () => { + const program = createCliProgram(); + + await program.parseAsync(['--dry-run', '--no-save', 'init', '--target', 'cursor'], { + from: 'user', + }); + + expect(getCliGlobals()).toEqual({ + json: false, + verbose: false, + yes: false, + dryRun: true, + noSave: true, }); }); }); diff --git a/tests/modules/cli/installCommand.test.ts b/tests/modules/cli/installCommand.test.ts new file mode 100644 index 0000000..ef273ce --- /dev/null +++ b/tests/modules/cli/installCommand.test.ts @@ -0,0 +1,321 @@ +import { createHash } from 'node:crypto'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import type { PackageManifest } from '../../../src/modules/registry/domain/manifest.js'; +import { + buildCursorSkillZip, + makeInstallTestCatalog, + makeInstallTestManifest, + makeInstallTestMetadata, + withInstallTestArtifactSha256, +} from '../../fixtures/installFixtures.js'; +import { conflictingTopLevelConfig } from '../../fixtures/agentsJson/index.js'; + +const nodeExecutable = process.execPath; +const binPath = path.resolve(process.cwd(), 'dist/bin/agents-repo.js'); + +const zipBytes = buildCursorSkillZip(); +const sha256 = createHash('sha256').update(zipBytes).digest('hex'); +const mockManifest: PackageManifest = withInstallTestArtifactSha256(makeInstallTestManifest(), sha256); + +interface CliRunResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +const runCliSubprocess = async ( + args: readonly string[], + options: { readonly cwd: string; readonly env?: NodeJS.ProcessEnv }, +): Promise => { + return new Promise((resolve, reject) => { + const child: ChildProcess = spawn(nodeExecutable, [binPath, ...args], { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on('error', reject); + + child.on('close', (status) => { + resolve({ + status: status ?? 1, + stdout, + stderr, + }); + }); + }); +}; + +const writeInstallConfig = (cwd: string, baseUrl: string): void => { + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { url: baseUrl, ref: 'v2.0.0' }, + target: 'cursor', + packages: {}, + }), + ); +}; + +describe('install command subprocess', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('exits 3 when install target is missing', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-missing-target-')); + tempDirs.push(cwd); + + const result = await runCliSubprocess(['install', 'agents-repo/sample-agent'], { cwd }); + + expect(result.status).toBe(3); + expect(result.stderr).toContain('Install target is required'); + }); + + it('exits 2 when package id is missing', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-missing-package-')); + tempDirs.push(cwd); + + const result = await runCliSubprocess(['install'], { cwd }); + + expect(result.status).toBe(2); + expect(`${result.stdout}${result.stderr}`).toMatch(/missing required argument|not enough arguments/i); + }); + + it('exits 4 for dual-definition conflicts without --yes', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-conflict-')); + tempDirs.push(cwd); + + writeFileSync(path.join(cwd, 'agents.json'), JSON.stringify(conflictingTopLevelConfig)); + + const result = await runCliSubprocess( + ['install', 'agents-repo/sample-agent', '--target', 'cursor'], + { cwd }, + ); + + expect(result.status).toBe(4); + expect(result.stderr).toContain('dual definition'); + }); +}); + +describe('install command subprocess with mock registry', () => { + const tempDirs: string[] = []; + let mockServer: Server; + let mockBaseUrl: string; + + beforeAll(async () => { + const server = createServer((request, response) => { + const url = request.url ?? '/'; + + if (url.includes('/packages/index.json')) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(makeInstallTestCatalog())); + return; + } + + if (url.includes('/versions/manifest.json')) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(mockManifest)); + return; + } + + if (url.includes('/metadata.json')) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(makeInstallTestMetadata())); + return; + } + + if (url.includes('1.0.0-cursor.zip')) { + response.writeHead(200); + response.end(zipBytes); + return; + } + + response.writeHead(404); + response.end('not found'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(); + }); + }); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Failed to bind mock registry server'); + } + + mockServer = server; + mockBaseUrl = `http://127.0.0.1:${address.port}/?ref=v2.0.0`; + }); + + afterAll(async () => { + mockServer.closeAllConnections?.(); + + await new Promise((resolve, reject) => { + mockServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('supports dry-run without writing lock files', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-dry-run-')); + tempDirs.push(cwd); + writeInstallConfig(cwd, mockBaseUrl); + + const result = await runCliSubprocess(['--dry-run', 'install', 'agents-repo/sample-agent'], { + cwd, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Would install'); + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow(); + }); + + it('emits JSON output on success when --json is set', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-json-dry-run-')); + tempDirs.push(cwd); + writeInstallConfig(cwd, mockBaseUrl); + + const result = await runCliSubprocess( + ['--json', '--dry-run', 'install', 'agents-repo/sample-agent'], + { cwd }, + ); + + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout.trim()) as { + packageId: string; + dryRun: boolean; + saved: boolean; + warnings: string[]; + }; + expect(payload.packageId).toBe('agents-repo/sample-agent'); + expect(payload.dryRun).toBe(true); + expect(payload.saved).toBe(false); + expect(payload.warnings).toEqual([]); + expect(result.stderr).not.toMatch(/^warning:/m); + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow(); + }); + + it('installs into a project and updates config and lock files', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-project-')); + tempDirs.push(cwd); + writeInstallConfig(cwd, mockBaseUrl); + + const result = await runCliSubprocess( + ['install', 'agents-repo/sample-agent', '--target', 'cursor'], + { cwd }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Installed agents-repo/sample-agent@1.0.0'); + expect(readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toContain( + 'name: sample', + ); + + const config = JSON.parse(readFileSync(path.join(cwd, 'agents.json'), 'utf8')) as { + packages: Record + }; + expect(config.packages['agents-repo/sample-agent']).toBe('^1.0.0'); + + const lock = JSON.parse(readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')) as { + packages: Record + }; + expect(lock.packages['agents-repo/sample-agent'].version).toBe('1.0.0'); + }); + + it('extracts without saving when --no-save is set', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-no-save-')); + tempDirs.push(cwd); + writeInstallConfig(cwd, mockBaseUrl); + + const result = await runCliSubprocess( + ['--no-save', 'install', 'agents-repo/sample-agent', '--target', 'cursor'], + { cwd }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('(not saved)'); + expect(readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toContain( + 'name: sample', + ); + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow(); + expect( + JSON.parse(readFileSync(path.join(cwd, 'agents.json'), 'utf8')) as { packages: Record }, + ).toEqual({ + schemaVersion: '1.0.0', + registry: { url: mockBaseUrl, ref: 'v2.0.0' }, + target: 'cursor', + packages: {}, + }); + }); + + it('installs globally without writing project config or lock files', async () => { + const homeDir = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-home-')); + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-cli-global-')); + tempDirs.push(cwd); + tempDirs.push(homeDir); + + const configPath = path.join(cwd, 'agents.json'); + writeInstallConfig(cwd, mockBaseUrl); + + const result = await runCliSubprocess( + ['install', '-g', 'agents-repo/sample-agent', '--target', 'cursor'], + { + cwd, + env: { + ...process.env, + HOME: homeDir, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Installed agents-repo/sample-agent@1.0.0'); + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow(); + expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual({ + schemaVersion: '1.0.0', + registry: { url: mockBaseUrl, ref: 'v2.0.0' }, + target: 'cursor', + packages: {}, + }); + expect( + readFileSync(path.join(homeDir, '.config/agents-repo/.cursor/skills/sample/SKILL.md'), 'utf8'), + ).toContain('name: sample'); + }); +}); diff --git a/tests/modules/install/installPersistence.test.ts b/tests/modules/install/installPersistence.test.ts new file mode 100644 index 0000000..f79ce73 --- /dev/null +++ b/tests/modules/install/installPersistence.test.ts @@ -0,0 +1,287 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { InstallPersistence } from '../../../src/modules/install/application/installPersistence.js' +import { resolveLockRef } from '../../../src/modules/install/application/resolveLockRef.js' +import type { ResolvedAgentsConfig } from '../../../src/modules/config/domain/agentsConfig.js' +import { DEFAULT_REGISTRY_CONFIG } from '../../../src/modules/registry/infrastructure/registrySourceConfig.js' + +describe('resolveLockRef', () => { + it('uses alias resolution when available', () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), 'agents-lock-ref-')) + const ref = resolveLockRef( + { + gateMode: 'greenfield', + configPath: path.join(configDir, 'agents.json'), + lockPath: path.join(configDir, 'agents-lock.json'), + registry: DEFAULT_REGISTRY_CONFIG, + packages: {}, + global: false, + warnings: [], + rawDocument: null, + }, + { + catalog: { schemaVersion: '1.3.0', updatedAt: '', packages: [] }, + indexUrl: 'https://example.test/packages/index.json?ref=v2.0.0', + registryBaseUrl: 'https://example.test/?ref=v2.0.0', + baseUrlRefResolution: { alias: 'v2.x', resolvedRef: 'v2.0.0' }, + warnings: [], + }, + ) + + expect(ref).toBe('v2.0.0') + }) + + it('falls back to a concrete configured ref', () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), 'agents-lock-ref-concrete-')) + const ref = resolveLockRef( + { + gateMode: 'greenfield', + configPath: path.join(configDir, 'agents.json'), + lockPath: path.join(configDir, 'agents-lock.json'), + registry: { url: 'https://example.test', ref: 'v2.3.1' }, + packages: {}, + global: false, + warnings: [], + rawDocument: null, + }, + { + catalog: { schemaVersion: '1.3.0', updatedAt: '', packages: [] }, + indexUrl: 'https://example.test/packages/index.json?ref=v2.3.1', + registryBaseUrl: 'https://example.test/?ref=v2.3.1', + baseUrlRefResolution: null, + warnings: [], + }, + ) + + expect(ref).toBe('v2.3.1') + }) +}) + +describe('InstallPersistence', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('creates agents.json and lock entries on greenfield save', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-persist-')) + tempDirs.push(cwd) + const configPath = path.join(cwd, 'agents.json') + const lockPath = path.join(cwd, 'agents-lock.json') + + const resolved: ResolvedAgentsConfig = { + gateMode: 'greenfield', + configPath, + lockPath, + registry: DEFAULT_REGISTRY_CONFIG, + packages: {}, + global: false, + warnings: [], + rawDocument: null, + } + + const persistence = new InstallPersistence() + await persistence.save({ + resolved, + packageId: 'agents-repo/sample-agent', + version: '1.0.0', + target: 'cursor', + artifact: { + target: 'cursor', + file: '1.0.0-cursor.zip', + sha256: 'a'.repeat(64), + }, + resolvedRef: 'v2.0.0', + adHocInstall: true, + }) + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as Record + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as { + resolvedRef: string + packages: Record + } + + expect(config.target).toBe('cursor') + expect(config.packages).toEqual({ 'agents-repo/sample-agent': '^1.0.0' }) + expect(lock.resolvedRef).toBe('v2.0.0') + expect(lock.packages['agents-repo/sample-agent'].version).toBe('1.0.0') + }) + + it('updates lock for an existing configured package without changing its range', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-persist-update-')) + tempDirs.push(cwd) + const configPath = path.join(cwd, 'agents.json') + const lockPath = path.join(cwd, 'agents-lock.json') + + writeFileSync( + configPath, + JSON.stringify({ + schemaVersion: '1.0.0', + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: { 'agents-repo/sample-agent': '^1.0.0' }, + }), + ) + + const resolved: ResolvedAgentsConfig = { + gateMode: 'top-level-ours', + configPath, + lockPath, + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: { 'agents-repo/sample-agent': '^1.0.0' }, + global: false, + warnings: [], + rawDocument: JSON.parse(readFileSync(configPath, 'utf8')) as Record, + } + + const persistence = new InstallPersistence() + await persistence.save({ + resolved, + packageId: 'agents-repo/sample-agent', + version: '1.1.0', + target: 'cursor', + artifact: { + target: 'cursor', + file: '1.1.0-cursor.zip', + sha256: 'b'.repeat(64), + }, + resolvedRef: 'v2.0.0', + adHocInstall: false, + }) + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + packages: Record + } + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as { + packages: Record + } + + expect(config.packages['agents-repo/sample-agent']).toBe('^1.0.0') + expect(lock.packages['agents-repo/sample-agent'].version).toBe('1.1.0') + }) + + it('preserves unrelated lock packages when saving a new install', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-persist-lock-merge-')) + tempDirs.push(cwd) + const configPath = path.join(cwd, 'agents.json') + const lockPath = path.join(cwd, 'agents-lock.json') + + writeFileSync( + configPath, + JSON.stringify({ + schemaVersion: '1.0.0', + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: {}, + }), + ) + + writeFileSync( + lockPath, + JSON.stringify({ + lockfileVersion: 1, + resolvedRef: 'v2.0.0', + packages: { + 'agents-repo/other-agent': { + version: '2.0.0', + target: 'cursor', + integrity: `sha256-${'c'.repeat(64)}`, + artifact: '2.0.0-cursor.zip', + }, + }, + }), + ) + + const resolved: ResolvedAgentsConfig = { + gateMode: 'top-level-ours', + configPath, + lockPath, + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: {}, + global: false, + warnings: [], + rawDocument: JSON.parse(readFileSync(configPath, 'utf8')) as Record, + } + + const persistence = new InstallPersistence() + await persistence.save({ + resolved, + packageId: 'agents-repo/sample-agent', + version: '1.0.0', + target: 'cursor', + artifact: { + target: 'cursor', + file: '1.0.0-cursor.zip', + sha256: 'a'.repeat(64), + }, + resolvedRef: 'v2.0.0', + adHocInstall: true, + }) + + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as { + packages: Record + } + + expect(lock.packages['agents-repo/other-agent'].version).toBe('2.0.0') + expect(lock.packages['agents-repo/sample-agent'].version).toBe('1.0.0') + }) + + it('does not write agents.json when the existing lock file is invalid', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-persist-lock-invalid-')) + tempDirs.push(cwd) + const configPath = path.join(cwd, 'agents.json') + const lockPath = path.join(cwd, 'agents-lock.json') + + writeFileSync( + configPath, + JSON.stringify({ + schemaVersion: '1.0.0', + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: {}, + }), + ) + writeFileSync(lockPath, '{ invalid json') + + const resolved: ResolvedAgentsConfig = { + gateMode: 'top-level-ours', + configPath, + lockPath, + registry: DEFAULT_REGISTRY_CONFIG, + target: 'cursor', + packages: {}, + global: false, + warnings: [], + rawDocument: JSON.parse(readFileSync(configPath, 'utf8')) as Record, + } + + const persistence = new InstallPersistence() + const originalConfig = readFileSync(configPath, 'utf8') + + await expect( + persistence.save({ + resolved, + packageId: 'agents-repo/sample-agent', + version: '1.0.0', + target: 'cursor', + artifact: { + target: 'cursor', + file: '1.0.0-cursor.zip', + sha256: 'a'.repeat(64), + }, + resolvedRef: 'v2.0.0', + adHocInstall: true, + }), + ).rejects.toThrow() + + expect(readFileSync(configPath, 'utf8')).toBe(originalConfig) + }) +}) diff --git a/tests/modules/install/installPersistenceRollback.test.ts b/tests/modules/install/installPersistenceRollback.test.ts new file mode 100644 index 0000000..c8b9bee --- /dev/null +++ b/tests/modules/install/installPersistenceRollback.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { InstallService } from '../../../src/modules/install/application/installService.js' +import { InstallPersistence } from '../../../src/modules/install/application/installPersistence.js' +import * as registrySourceConfig from '../../../src/modules/registry/infrastructure/registrySourceConfig.js' +import { + buildCursorSkillZip, + makeInstallTestCatalog, + makeInstallTestManifest, + makeInstallTestMetadata, + withInstallTestArtifactSha256, +} from '../../fixtures/installFixtures.js' + +describe('InstallService persistence rollback', () => { + const tempDirs: string[] = [] + + afterEach(() => { + vi.restoreAllMocks() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rolls back extracted files when config persistence fails', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-rollback-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const zipBytes = buildCursorSkillZip() + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const manifest = withInstallTestArtifactSha256(makeInstallTestManifest(), sha256) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + let url: string + if (typeof input === 'string') { + url = input + } else if (input instanceof URL) { + url = input.toString() + } else { + url = input.url + } + + if (url.includes('packages/index.json')) { + return Promise.resolve( + new Response(JSON.stringify(makeInstallTestCatalog()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (url.includes('versions/manifest.json')) { + return Promise.resolve( + new Response(JSON.stringify(manifest), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (url.includes('metadata.json')) { + return Promise.resolve( + new Response(JSON.stringify(makeInstallTestMetadata()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (url.includes('1.0.0-cursor.zip')) { + return Promise.resolve(new Response(zipBytes, { status: 200 })) + } + + return Promise.resolve(new Response('not found', { status: 404 })) + }) + + vi.spyOn(InstallPersistence.prototype, 'save').mockRejectedValue(new Error('disk full')) + + const service = new InstallService() + + await expect( + service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + }), + ).rejects.toThrow('disk full') + + expect(() => readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toThrow() + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow() + }) +}) diff --git a/tests/modules/install/installScope.test.ts b/tests/modules/install/installScope.test.ts new file mode 100644 index 0000000..0c4bce0 --- /dev/null +++ b/tests/modules/install/installScope.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { resolveInstallScope } from '../../../src/modules/install/application/installScope.js' + +describe('resolveInstallScope', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uses the project cwd by default', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-scope-project-')) + tempDirs.push(cwd) + + const scope = resolveInstallScope({ cwd, globalFlag: false, configGlobal: false }) + + expect(scope.global).toBe(false) + expect(scope.extractRoot).toBe(cwd) + expect(scope.mutateProjectConfig).toBe(true) + }) + + it('forces global scope when -g is set', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-scope-global-flag-')) + tempDirs.push(cwd) + + const scope = resolveInstallScope({ cwd, globalFlag: true, configGlobal: false }) + + expect(scope.global).toBe(true) + expect(scope.extractRoot).toContain('.config/agents-repo') + expect(scope.mutateProjectConfig).toBe(false) + }) + + it('uses global scope from config when globalFlag is unset', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-scope-config-global-')) + tempDirs.push(cwd) + + const scope = resolveInstallScope({ cwd, configGlobal: true }) + + expect(scope.global).toBe(true) + expect(scope.mutateProjectConfig).toBe(false) + }) + + it('respects HOME from env when resolving global extract root', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-scope-home-env-')) + const homeDir = mkdtempSync(path.join(os.tmpdir(), 'agents-install-scope-home-dir-')) + tempDirs.push(cwd) + tempDirs.push(homeDir) + + const scope = resolveInstallScope({ + cwd, + env: { HOME: homeDir }, + globalFlag: true, + }) + + expect(scope.extractRoot).toBe(path.join(homeDir, '.config', 'agents-repo')) + }) +}) diff --git a/tests/modules/install/installService.test.ts b/tests/modules/install/installService.test.ts new file mode 100644 index 0000000..758b3c9 --- /dev/null +++ b/tests/modules/install/installService.test.ts @@ -0,0 +1,379 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { InstallRuntimeError } from '../../../src/modules/install/domain/installErrors.js' +import { InstallService } from '../../../src/modules/install/application/installService.js' +import { PackageYankedError } from '../../../src/modules/registry/domain/errors.js' +import * as registrySourceConfig from '../../../src/modules/registry/infrastructure/registrySourceConfig.js' +import { + buildCursorSkillZip, + makeInstallTestCatalog, + makeInstallTestManifest, + makeInstallTestMetadata, + withInstallTestArtifactSha256, +} from '../../fixtures/installFixtures.js' + +const toFetchUrl = (input: Parameters[0]): string => { + if (typeof input === 'string') { + return input + } + + if (input instanceof URL) { + return input.toString() + } + + return input.url +} + +const mockRegistryFetch = ( + manifest: ReturnType, + options: { + readonly zipBytes?: Buffer + readonly catalog?: ReturnType + } = {}, +) => { + const catalog = options.catalog ?? makeInstallTestCatalog() + + return vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + const url = toFetchUrl(input) + + if (url.includes('packages/index.json')) { + return Promise.resolve( + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (url.includes('versions/manifest.json')) { + return Promise.resolve( + new Response(JSON.stringify(manifest), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (url.includes('metadata.json')) { + return Promise.resolve( + new Response(JSON.stringify(makeInstallTestMetadata()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + } + + if (options.zipBytes !== undefined && url.includes('1.0.0-cursor.zip')) { + return Promise.resolve(new Response(options.zipBytes, { status: 200 })) + } + + return Promise.resolve(new Response('not found', { status: 404 })) + }) +} + +describe('InstallService', () => { + const tempDirs: string[] = [] + + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('installs a package into a temp project with mocked registry HTTP', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-service-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const zipBytes = buildCursorSkillZip() + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const manifest = withInstallTestArtifactSha256(makeInstallTestManifest(), sha256) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: { alias: 'v2.x', resolvedRef: 'v2.0.0' }, + }) + + mockRegistryFetch(manifest, { zipBytes }) + + const service = new InstallService() + const result = await service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + }) + + expect(result.saved).toBe(true) + expect(readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toContain( + 'name: sample', + ) + + const config = JSON.parse(readFileSync(path.join(cwd, 'agents.json'), 'utf8')) as { + packages: Record + } + expect(config.packages['agents-repo/sample-agent']).toBe('^1.0.0') + + const lock = JSON.parse(readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')) as { + resolvedRef: string + packages: Record + } + expect(lock.resolvedRef).toBe('v2.0.0') + expect(lock.packages['agents-repo/sample-agent'].integrity).toBe(`sha256-${sha256}`) + }) + + it('stops before download on dry-run', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-dry-run-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const fetchSpy = mockRegistryFetch(makeInstallTestManifest()) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + const service = new InstallService() + const result = await service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + dryRun: true, + }) + + expect(result.dryRun).toBe(true) + expect(result.artifactUrl).toContain('1.0.0-cursor.zip') + expect(fetchSpy.mock.calls.some(([url]) => toFetchUrl(url).endsWith('.zip'))).toBe(false) + }) + + it('rejects yanked packages before download', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-yanked-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + mockRegistryFetch(makeInstallTestManifest(), { + catalog: makeInstallTestCatalog({ status: 'yanked' }), + }) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + const service = new InstallService() + + await expect( + service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + }), + ).rejects.toBeInstanceOf(PackageYankedError) + }) + + it('warns for deprecated packages', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-deprecated-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const zipBytes = buildCursorSkillZip() + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const manifest = withInstallTestArtifactSha256(makeInstallTestManifest(), sha256) + + mockRegistryFetch(manifest, { + zipBytes, + catalog: makeInstallTestCatalog({ status: 'deprecated' }), + }) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + const service = new InstallService() + const result = await service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + dryRun: true, + }) + + expect(result.warnings.some((warning) => warning.includes('deprecated'))).toBe(true) + }) + + it('extracts without updating config or lock when --no-save is set', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-no-save-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const zipBytes = buildCursorSkillZip() + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const manifest = withInstallTestArtifactSha256(makeInstallTestManifest(), sha256) + + mockRegistryFetch(manifest, { zipBytes }) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + const service = new InstallService() + const result = await service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + noSave: true, + }) + + expect(result.saved).toBe(false) + expect(result.noSave).toBe(true) + expect(readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toContain( + 'name: sample', + ) + expect(() => readFileSync(path.join(cwd, 'agents-lock.json'), 'utf8')).toThrow() + expect( + JSON.parse(readFileSync(path.join(cwd, 'agents.json'), 'utf8')) as { packages: Record }, + ).toEqual({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }) + }) + + it('rejects checksum mismatches before extract', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-checksum-')) + tempDirs.push(cwd) + + writeFileSync( + path.join(cwd, 'agents.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + registry: { + url: 'https://registry-proxy.example.workers.dev', + ref: 'v2.0.0', + }, + target: 'cursor', + packages: {}, + }), + ) + + const zipBytes = buildCursorSkillZip() + const manifest = withInstallTestArtifactSha256(makeInstallTestManifest(), 'f'.repeat(64)) + + mockRegistryFetch(manifest, { zipBytes }) + + vi.spyOn(registrySourceConfig, 'resolveRegistryFetchSourceConfig').mockResolvedValue({ + sourceUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + configuredBaseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + baseUrl: 'https://registry-proxy.example.workers.dev/?ref=v2.0.0', + indexPath: 'packages/index.json', + indexUrl: 'https://registry-proxy.example.workers.dev/packages/index.json?ref=v2.0.0', + configuredGithubRepositoryUrl: 'https://github.com/agents-repo/registry/tree/v2.0.0', + baseUrlRefResolution: null, + }) + + const service = new InstallService() + + await expect( + service.run({ + cwd, + packageId: 'agents-repo/sample-agent', + }), + ).rejects.toBeInstanceOf(InstallRuntimeError) + + expect(() => readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8')).toThrow() + }) +}) diff --git a/tests/modules/install/packageExtractor.test.ts b/tests/modules/install/packageExtractor.test.ts new file mode 100644 index 0000000..628e2f3 --- /dev/null +++ b/tests/modules/install/packageExtractor.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { extractPackageArtifact } from '../../../src/modules/install/infrastructure/packageExtractor.js' +import { + assertZipEntryPathSafe, + mapZipEntryToExtractPath, + resolveContainedExtractPath, +} from '../../../src/modules/install/infrastructure/targetExtractPaths.js' +import { + buildCursorSkillZip, + buildGithubCopilotZip, + buildTraversalZip, +} from '../../fixtures/installFixtures.js' + +describe('targetExtractPaths', () => { + it('remaps github-copilot agents paths under .github/agents', () => { + expect(mapZipEntryToExtractPath('github-copilot', 'agents/sample.agent.md')).toBe( + '.github/agents/sample.agent.md', + ) + }) + + it('keeps cursor paths unchanged', () => { + expect(mapZipEntryToExtractPath('cursor', '.cursor/skills/sample/SKILL.md')).toBe( + '.cursor/skills/sample/SKILL.md', + ) + }) + + it('rejects traversal segments in archive paths', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agents-extract-safe-root-')) + try { + expect(() => assertZipEntryPathSafe('../evil.txt')).toThrow(/Unsafe archive entry path/) + expect(() => assertZipEntryPathSafe('safe/foo..bar/file.txt')).not.toThrow() + expect(resolveContainedExtractPath(root, 'safe/nested/file.txt')).toBe( + path.resolve(root, 'safe/nested/file.txt'), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + +describe('packageExtractor', () => { + it('extracts cursor skill layout into the project root', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-cursor-')) + + try { + await extractPackageArtifact(buildCursorSkillZip(), 'cursor', '1.0.0', cwd) + const content = readFileSync(path.join(cwd, '.cursor/skills/sample/SKILL.md'), 'utf8') + expect(content).toContain('name: sample') + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + + it('extracts github-copilot artifacts under .github/agents', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-copilot-')) + + try { + await extractPackageArtifact(buildGithubCopilotZip(), 'github-copilot', '1.0.0', cwd) + const content = readFileSync(path.join(cwd, '.github/agents/sample.agent.md'), 'utf8') + expect(content).toContain('name: sample') + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + + it('rejects traversal entries before writing files', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-traversal-')) + + try { + await expect( + extractPackageArtifact(buildTraversalZip(), 'cursor', '1.0.0', cwd), + ).rejects.toThrow() + expect(() => readFileSync(path.join(cwd, 'evil.agent.md'), 'utf8')).toThrow() + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + + it('rejects extraction through existing symlinks under the extract root', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-symlink-root-')) + const outside = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-symlink-outside-')) + + try { + mkdirSync(path.join(root, '.cursor'), { recursive: true }) + symlinkSync(outside, path.join(root, '.cursor', 'skills')) + + await expect( + extractPackageArtifact(buildCursorSkillZip(), 'cursor', '1.0.0', root), + ).rejects.toMatchObject({ code: 'path_traversal' }) + expect(() => readFileSync(path.join(outside, 'sample', 'SKILL.md'), 'utf8')).toThrow() + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('rejects extraction when a destination file already exists', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'agents-install-extract-existing-')) + + try { + await extractPackageArtifact(buildCursorSkillZip(), 'cursor', '1.0.0', cwd) + await expect( + extractPackageArtifact(buildCursorSkillZip(), 'cursor', '1.0.0', cwd), + ).rejects.toMatchObject({ code: 'extract_conflict' }) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/modules/install/resolveInstallVersion.test.ts b/tests/modules/install/resolveInstallVersion.test.ts new file mode 100644 index 0000000..de977c2 --- /dev/null +++ b/tests/modules/install/resolveInstallVersion.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { resolveInstallVersion } from '../../../src/modules/install/application/resolveInstallVersion.js' +import type { PackageManifest } from '../../../src/modules/registry/domain/manifest.js' +import { NoMatchingVersionError } from '../../../src/modules/registry/domain/errors.js' + +const manifest: PackageManifest = { + schemaVersion: '1.1.0', + name: 'demo', + latest: '1.0.0', + versions: [ + { + version: '1.0.0', + artifacts: [], + srcArtifact: '1.0.0-src.zip', + srcSha256: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + version: '1.1.0', + artifacts: [], + srcArtifact: '1.1.0-src.zip', + srcSha256: 'b'.repeat(64), + createdAt: '2026-02-01T00:00:00.000Z', + }, + { + version: '2.0.0-beta.1', + artifacts: [], + srcArtifact: '2.0.0-beta.1-src.zip', + srcSha256: 'c'.repeat(64), + createdAt: '2026-03-01T00:00:00.000Z', + }, + ], +} + +describe('resolveInstallVersion', () => { + it('picks the highest version satisfying a semver range', () => { + expect(resolveInstallVersion(manifest, 'agents-repo/demo', '^1.0.0')).toBe('1.1.0') + }) + + it('picks the highest version for ad-hoc installs', () => { + expect(resolveInstallVersion(manifest, 'agents-repo/demo')).toBe('1.1.0') + }) + + it('excludes prereleases unless the range includes them', () => { + expect(() => resolveInstallVersion(manifest, 'agents-repo/demo', '2.0.0')).toThrow( + NoMatchingVersionError, + ) + }) +}) diff --git a/tests/modules/install/validateInstallTarget.test.ts b/tests/modules/install/validateInstallTarget.test.ts new file mode 100644 index 0000000..364fdad --- /dev/null +++ b/tests/modules/install/validateInstallTarget.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' + +import { assertInstallTargetSupported } from '../../../src/modules/install/application/validateInstallTarget.js' +import { InstallTargetUnsupportedError } from '../../../src/modules/registry/domain/errors.js' +import type { RegistryPackage } from '../../../src/modules/registry/domain/package.js' +import { + makeInstallTestManifest, + makeInstallTestMetadata, +} from '../../fixtures/installFixtures.js' + +const basePackage: RegistryPackage = { + id: 'agents-repo/sample-agent', + namespace: 'agents-repo', + package: 'sample-agent', + name: 'sample-agent', + description: 'Sample', + owner: 'agents-repo', + latest: '1.0.0', + tags: [], + status: 'active', + category: 'agent', + estimateOverallCost: { band: 'low' }, +} + +describe('assertInstallTargetSupported', () => { + it('accepts supported targets from index and metadata', () => { + expect(() => + assertInstallTargetSupported( + { + ...basePackage, + installTargets: [{ id: 'cursor', status: 'supported' }], + }, + makeInstallTestMetadata(), + makeInstallTestManifest(), + '1.0.0', + 'cursor', + ), + ).not.toThrow() + }) + + it('allows metadata validation when index installTargets is absent', () => { + expect(() => + assertInstallTargetSupported( + basePackage, + makeInstallTestMetadata(), + makeInstallTestManifest(), + '1.0.0', + 'cursor', + ), + ).not.toThrow() + }) + + it('rejects install when metadata compatibility is absent', () => { + const withoutCompatibility = { ...makeInstallTestMetadata() } + delete (withoutCompatibility as { compatibility?: unknown }).compatibility + + expect(() => + assertInstallTargetSupported( + basePackage, + withoutCompatibility, + makeInstallTestManifest(), + '1.0.0', + 'cursor', + ), + ).toThrow(InstallTargetUnsupportedError) + }) + + it('rejects when target is absent from index installTargets', () => { + expect(() => + assertInstallTargetSupported( + { + ...basePackage, + installTargets: [{ id: 'github-copilot', status: 'supported' }], + }, + makeInstallTestMetadata(), + makeInstallTestManifest(), + '1.0.0', + 'cursor', + ), + ).toThrow(InstallTargetUnsupportedError) + }) + + it('rejects planned metadata targets', () => { + const metadata = makeInstallTestMetadata() + expect(() => + assertInstallTargetSupported( + { + ...basePackage, + installTargets: [{ id: 'cursor', status: 'supported' }], + }, + { + ...metadata, + compatibility: { + canonicalFormat: 'agents-repo.agent-instruction@1.0.0', + targets: [{ id: 'cursor', status: 'planned' }], + }, + }, + makeInstallTestManifest(), + '1.0.0', + 'cursor', + ), + ).toThrow(InstallTargetUnsupportedError) + }) +}) diff --git a/tests/modules/install/zipSecurityScanner.test.ts b/tests/modules/install/zipSecurityScanner.test.ts new file mode 100644 index 0000000..9bc116c --- /dev/null +++ b/tests/modules/install/zipSecurityScanner.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { scanTargetArtifactZipBuffer } from '../../../src/modules/install/infrastructure/zipSecurityScanner.js' +import { + buildCursorSkillZip, + buildGithubCopilotZip, + buildTraversalZip, +} from '../../fixtures/installFixtures.js' + +describe('zipSecurityScanner', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it('accepts a valid cursor skill ZIP', () => { + const issues = scanTargetArtifactZipBuffer(buildCursorSkillZip(), 'cursor', '1.0.0') + expect(issues).toEqual([]) + }) + + it('accepts a valid github-copilot deployment ZIP', () => { + const issues = scanTargetArtifactZipBuffer(buildGithubCopilotZip(), 'github-copilot', '1.0.0') + expect(issues).toEqual([]) + }) + + it('rejects unsafe entries', () => { + const issues = scanTargetArtifactZipBuffer(buildTraversalZip(), 'cursor', '1.0.0') + expect(issues.length).toBeGreaterThan(0) + expect( + issues.some( + (issue) => issue.code === 'ERR_ZIP_TRAVERSAL' || issue.code === 'ERR_ZIP_UNEXPECTED_ENTRY', + ), + ).toBe(true) + }) + + it('rejects disallowed payloads under constrained skill paths', async () => { + vi.resetModules() + vi.doMock('adm-zip', () => ({ + default: class MockAdmZip { + getEntries() { + return [ + { + entryName: '.cursor/skills/sample/evil.exe', + attr: 0, + getData: () => Buffer.from('bad'), + }, + ] + } + }, + })) + + const { scanTargetArtifactZipBuffer: scanWithMockedZip } = await import( + '../../../src/modules/install/infrastructure/zipSecurityScanner.js' + ) + + const issues = scanWithMockedZip(Buffer.from('zip'), 'cursor', '1.0.0') + expect(issues.some((issue) => issue.code === 'ERR_ZIP_DISALLOWED_PAYLOAD')).toBe(true) + + vi.doUnmock('adm-zip') + vi.resetModules() + }) + + it('rejects symlink entries', async () => { + vi.resetModules() + vi.doMock('adm-zip', () => ({ + default: class MockAdmZip { + getEntries() { + return [ + { + entryName: '.cursor/skills/sample/SKILL.md', + attr: 0o120000 << 16, + getData: () => Buffer.from('unused'), + }, + ] + } + }, + })) + + const { scanTargetArtifactZipBuffer: scanWithMockedZip } = await import( + '../../../src/modules/install/infrastructure/zipSecurityScanner.js' + ) + + const issues = scanWithMockedZip(Buffer.from('zip'), 'cursor', '1.0.0') + expect(issues.some((issue) => issue.code === 'ERR_ZIP_SYMLINK')).toBe(true) + + vi.doUnmock('adm-zip') + vi.resetModules() + }) +})