From 77ff826be86d5bd691083cea0643af20606610e2 Mon Sep 17 00:00:00 2001 From: dd3ok <15044917+dd3ok@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:09:43 +0900 Subject: [PATCH] Add safe managed skill uninstall --- CHANGELOG.md | 2 + README.ko.md | 4 +- README.md | 4 +- SECURITY.md | 3 + adapters/antigravity/cli/scripts/stash.mjs | 408 ++++++++++++- .../cli/skills/references/CLI-CONTRACT.md | 11 + adapters/antigravity/cli/skills/stash.md | 6 +- .../antigravity/ide/skills/stash/SKILL.md | 6 +- .../skills/stash/references/CLI-CONTRACT.md | 11 + .../ide/skills/stash/scripts/stash.mjs | 408 ++++++++++++- adapters/claude-code/skills/stash/SKILL.md | 6 +- .../skills/stash/references/CLI-CONTRACT.md | 11 + .../skills/stash/scripts/stash.mjs | 408 ++++++++++++- adapters/codex/skills/stash/SKILL.md | 6 +- .../skills/stash/references/CLI-CONTRACT.md | 11 + adapters/codex/skills/stash/scripts/stash.mjs | 408 ++++++++++++- docs/architecture.md | 9 +- research/2026-08-28-stash-uninstall-design.md | 161 ++++++ skills/stash/SKILL.md | 6 +- skills/stash/references/CLI-CONTRACT.md | 11 + skills/stash/scripts/stash.mjs | 408 ++++++++++++- src/cli.ts | 33 ++ src/index.ts | 1 + src/stash-lifecycle.ts | 547 +++++++++++++++++- src/types.ts | 8 + tests-dist/cli.test.mjs | 40 ++ tests/stash-lifecycle.test.ts | 390 +++++++++++++ 27 files changed, 3265 insertions(+), 62 deletions(-) create mode 100644 research/2026-08-28-stash-uninstall-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c56357a..f7cd943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Add a separate `StashLifecycle` Module with a local managed inactive store. - Add explicit local `install`/`archive`/`activate`/`deactivate`/`status` commands with provenance, tree hashes, tracked deployments, and drift guards. +- Add `uninstall` for verified managed skills with zero tracked deployments, + using recoverable tree and record tombstones without touching sources or hosts. - Add guarded local `update` with tree/revision compare-and-swap, source identity checks, no-copy metadata advances, recoverable replacement journals, and explicit outdated-deployment reporting without automatic deployment mutation. diff --git a/README.ko.md b/README.ko.md index d3fd023..28a9ef6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -65,12 +65,14 @@ stash install /path/to/rare-skill stash status rare-skill stash activate rare-skill --host codex stash deactivate rare-skill --host codex +stash uninstall rare-skill ``` `archive`는 사용자가 정확히 고른 독립 호스트 스킬을 보관한 뒤 원본을 제거하는 파괴적 변형입니다. `update`는 기존 관리형 사본만 교체하며 배포본을 자동으로 덮어쓰지 않습니다. 원격 저장소 내용은 CLI에 전달하기 전에 로컬 -임시 경로에 준비해 검토해야 합니다. +임시 경로에 준비해 검토해야 합니다. `uninstall`은 검증된 비활성 관리형 +사본만 제거하며, 기록된 배포는 먼저 `deactivate`해야 합니다. 변경 전제조건, provenance, 결과 상태, 일괄 업데이트, 지원 대상은 [CLI 계약](skills/stash/references/CLI-CONTRACT.md)이 기준입니다. 명령 문법은 diff --git a/README.md b/README.md index ab03888..51774d6 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,14 @@ stash install /path/to/rare-skill stash status rare-skill stash activate rare-skill --host codex stash deactivate rare-skill --host codex +stash uninstall rare-skill ``` `archive` is the destructive variant for one explicitly selected standalone host skill. `update` replaces only an existing managed copy and does not rewrite deployed copies. Remote repository content must be staged and reviewed locally -before the CLI sees it. +before the CLI sees it. `uninstall` removes only an inactive, verified managed +copy; tracked deployments must be deactivated first. The [CLI contract](skills/stash/references/CLI-CONTRACT.md) is the authority for mutation preconditions, provenance, result states, bulk updates, and supported diff --git a/SECURITY.md b/SECURITY.md index 5597c88..731118e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,9 @@ Stash discovers, reads, and explicitly stores local Agent Skills. A skill can co - Managed update recovery rechecks record/tree state at commit time, preserves a drifted previous tree during rollback, and recursively removes only an exact operation-owned path after journal authorization. +- Managed uninstall requires zero deployment records, rechecks a present tree + after its same-root rename, and removes only that verified managed tree and + lifecycle record. Interrupted cleanup remains journaled. - Remote managed provenance uses a canonical repository URL, caller-resolved immutable revision, exact case-sensitive repository-relative skill path, and exact `HEAD`, `refs/heads/...`, or `refs/tags/...` tracking ref. These four diff --git a/adapters/antigravity/cli/scripts/stash.mjs b/adapters/antigravity/cli/scripts/stash.mjs index 7962134..4396e60 100644 --- a/adapters/antigravity/cli/scripts/stash.mjs +++ b/adapters/antigravity/cli/scripts/stash.mjs @@ -9932,6 +9932,15 @@ function samePath(left, right) { function targetIdentity(target) { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value; + return record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === name && /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && Boolean(record.source) && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path8.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.updatedAt === void 0 || typeof record.source.updatedAt === "string") && validStoredRemoteProvenance(record.source) && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => typeof deployment.deploymentId === "string" && deployment.skillId === record.skillId && typeof deployment.targetId === "string" && deployment.targetId === targetIdentity(deployment) && deployment.ownership === "stash" && samePath(deployment.path, path8.join(deployment.root, record.name)) + ); +} async function isPluginContained(source) { let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -10443,6 +10452,203 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); return "rolled-back"; } + #validateUninstallJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized" + ]); + if (journal.schemaVersion !== 1 || journal.kind !== "managed-uninstall" || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || typeof journal.skillId !== "string" || journal.skillId.length === 0 || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || typeof journal.managedExisted !== "boolean" || typeof journal.createdAt !== "string" || !path8.isAbsolute(journal.managedPath) || !path8.isAbsolute(journal.recordPath) || !path8.isAbsolute(journal.treeTombstone) || !path8.isAbsolute(journal.recordTombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + if (!samePath( + journal.managedPath, + path8.join(this.#managedRoot, journal.name) + ) || !samePath(journal.recordPath, this.#recordPath(journal.name)) || !samePath( + journal.treeTombstone, + path8.join(stagingRoot, `uninstall-${journal.operationId}-tree`) + ) || !samePath( + journal.recordTombstone, + path8.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json` + ) + )) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + } + async #journalRecordHash(journal, target, label) { + const type = await pathType(target); + if (type === "missing") { + return void 0; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4 + ); + } + try { + const source = await readFile6(target, "utf8"); + const parsed = JSON.parse(source); + if (!validManagedSkillRecord(parsed, journal.name) || parsed.skillId !== journal.skillId || parsed.treeHash !== journal.treeHash || parsed.deployments.length !== 0 || sha256(source) !== journal.recordHash) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4 + ); + } + } + async #moveVerifiedUninstallRecord(journal, source, destination, label) { + if (await pathType(destination) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4 + ); + } + await this.#journalRecordHash(journal, source, label); + await rename2(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + async #removeAuthorizedUninstallPath(journal, target, kind) { + const expected = kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5 + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink2(target); + } + async #recoverUninstallJournal(journal, journalPath) { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if (await pathType(journal.managedPath) !== "missing" || await pathType(journal.recordPath) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4 + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree" + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record" + ); + await unlink2(journalPath); + return "committed"; + } + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target" + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record" + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (managedHash !== void 0 && managedHash !== journal.treeHash || treeTombstoneHash !== void 0 && treeTombstoneHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4 + ); + } + if (managedHash !== void 0 && treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash !== void 0 && recordTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0 && recordTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4 + ); + } + if (journal.managedExisted) { + if (managedHash === void 0 && treeTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4 + ); + } + if (managedHash === void 0) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone" + ); + } + } else if (managedHash !== void 0 || treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone" + ); + } + await unlink2(journalPath); + return "rolled-back"; + } async #removeIncompleteManaged(journal) { if (journal.managedExistedBefore) { return; @@ -10590,6 +10796,9 @@ var StashLifecycleImplementation = class { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ("kind" in journal && journal.kind === "managed-uninstall") { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -10781,9 +10990,7 @@ var StashLifecycleImplementation = class { throw new Error("lifecycle record is not a real file"); } const parsed = JSON.parse(await readFile6(recordPath, "utf8")); - if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || !parsed.source || parsed.source.kind !== "local-import" && parsed.source.kind !== "standalone-archive" || typeof parsed.source.location !== "string" || !path8.isAbsolute(parsed.source.location) || typeof parsed.source.importedAt !== "string" || parsed.source.updatedAt !== void 0 && typeof parsed.source.updatedAt !== "string" || !validStoredRemoteProvenance(parsed.source) || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) - )) { + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -11719,6 +11926,172 @@ var StashLifecycleImplementation = class { return this.#deactivateDeployment(record, managedPath, target); }); } + async uninstall(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3 + ); + } + const managedPath = path8.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3 + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3 + ); + } + } + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile6(recordPath, "utf8"); + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + const journal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-tree` + ), + recordTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-record.json` + ), + createdAt: new Date(this.#now()).toISOString() + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if (!commitRecord || !isDeepStrictEqual(commitRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + await rename2(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3 + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3 + ); + } + const finalRecord = await this.#readRecord(record.name); + if (!finalRecord || !isDeepStrictEqual(finalRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3 + ); + } + if (await pathType(managedPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await rename2(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (await pathType(recordPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + let warning = managedExisted ? void 0 : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...warning ? { warning } : {} + }; + }); + } async status(request = {}) { const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; @@ -11911,6 +12284,16 @@ function numberFlag(args, name) { } return parsed; } +function rejectUnknownFlags(args, allowed) { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2 + ); + } +} function createOptions(args) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -12059,6 +12442,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -12341,6 +12725,24 @@ async function main() { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + /* @__PURE__ */ new Set(["config", "managed-root", "json", "help"]) + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md b/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md index 1d1db54..2ac7f29 100644 --- a/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md +++ b/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md @@ -118,6 +118,17 @@ Each record commits independently, so report successes, skips, and failures. owned by the host. - Honor `reloadRequired` and `warning` after a discovery-path change. +### Uninstall + +`uninstall ` removes only the verified Stash-managed canonical copy and +its lifecycle record. It never removes an external catalog source, host +deployment, plugin, or host setting. Every recorded deployment must first be +removed explicitly with `deactivate`, including a deployment already reported +missing. If the managed directory is already missing, a valid zero-deployment +record is removed with a warning. Hash drift, links, files, special paths, and +invalid metadata fail closed and are preserved for diagnosis. There is no +`--force`, automatic deactivation, trash store, or restore command. + ### Status `status [name] --json` reports store presence, tree integrity, deployment diff --git a/adapters/antigravity/cli/skills/stash.md b/adapters/antigravity/cli/skills/stash.md index b502917..82ca75d 100644 --- a/adapters/antigravity/cli/skills/stash.md +++ b/adapters/antigravity/cli/skills/stash.md @@ -1,6 +1,6 @@ --- name: stash -description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `/stash` to find, read, list, install, update, archive, activate, deactivate, or inspect a skill. +description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `/stash` to find, read, list, install, update, archive, activate, deactivate, uninstall, or inspect a skill. --- # Stash @@ -25,7 +25,7 @@ Classify the text after `/stash`: | exact skill name, optionally followed by a task | `exact`, then `read` | | `find ...` or a task/topic without an exact name | `search`, then `read` when one skill is selected | | `status [name]` | lifecycle `status` | -| `install`, `update`, `archive`, `activate`, or `deactivate` | [Lifecycle requests](#lifecycle-requests) | +| `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall` | [Lifecycle requests](#lifecycle-requests) | Treat an author, repository, or source ID named by the user as `--source`. Keep an explicitly scoped request inside that source. Treat a slug-like skill name @@ -81,7 +81,7 @@ a script does not authorize executing it. ## Lifecycle requests -Before `install`, `update`, `archive`, `activate`, or `deactivate`, read +Before `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall`, read [CLI-CONTRACT.md](references/CLI-CONTRACT.md) completely and follow its Lifecycle contract. It owns the mutation preconditions, remote provenance rules, bulk-update workflow, result meanings, and supported targets. diff --git a/adapters/antigravity/ide/skills/stash/SKILL.md b/adapters/antigravity/ide/skills/stash/SKILL.md index bf373ec..7227408 100644 --- a/adapters/antigravity/ide/skills/stash/SKILL.md +++ b/adapters/antigravity/ide/skills/stash/SKILL.md @@ -1,6 +1,6 @@ --- name: stash -description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `stash` to find, read, list, install, update, archive, activate, deactivate, or inspect a skill. +description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `stash` to find, read, list, install, update, archive, activate, deactivate, uninstall, or inspect a skill. --- # Stash @@ -25,7 +25,7 @@ Classify the text after `stash`: | exact skill name, optionally followed by a task | `exact`, then `read` | | `find ...` or a task/topic without an exact name | `search`, then `read` when one skill is selected | | `status [name]` | lifecycle `status` | -| `install`, `update`, `archive`, `activate`, or `deactivate` | [Lifecycle requests](#lifecycle-requests) | +| `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall` | [Lifecycle requests](#lifecycle-requests) | Treat an author, repository, or source ID named by the user as `--source`. Keep an explicitly scoped request inside that source. Treat a slug-like skill name @@ -81,7 +81,7 @@ a script does not authorize executing it. ## Lifecycle requests -Before `install`, `update`, `archive`, `activate`, or `deactivate`, read +Before `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall`, read [CLI-CONTRACT.md](references/CLI-CONTRACT.md) completely and follow its Lifecycle contract. It owns the mutation preconditions, remote provenance rules, bulk-update workflow, result meanings, and supported targets. diff --git a/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md b/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md index 1d1db54..2ac7f29 100644 --- a/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md @@ -118,6 +118,17 @@ Each record commits independently, so report successes, skips, and failures. owned by the host. - Honor `reloadRequired` and `warning` after a discovery-path change. +### Uninstall + +`uninstall ` removes only the verified Stash-managed canonical copy and +its lifecycle record. It never removes an external catalog source, host +deployment, plugin, or host setting. Every recorded deployment must first be +removed explicitly with `deactivate`, including a deployment already reported +missing. If the managed directory is already missing, a valid zero-deployment +record is removed with a warning. Hash drift, links, files, special paths, and +invalid metadata fail closed and are preserved for diagnosis. There is no +`--force`, automatic deactivation, trash store, or restore command. + ### Status `status [name] --json` reports store presence, tree integrity, deployment diff --git a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs index 7962134..4396e60 100644 --- a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs +++ b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs @@ -9932,6 +9932,15 @@ function samePath(left, right) { function targetIdentity(target) { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value; + return record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === name && /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && Boolean(record.source) && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path8.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.updatedAt === void 0 || typeof record.source.updatedAt === "string") && validStoredRemoteProvenance(record.source) && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => typeof deployment.deploymentId === "string" && deployment.skillId === record.skillId && typeof deployment.targetId === "string" && deployment.targetId === targetIdentity(deployment) && deployment.ownership === "stash" && samePath(deployment.path, path8.join(deployment.root, record.name)) + ); +} async function isPluginContained(source) { let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -10443,6 +10452,203 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); return "rolled-back"; } + #validateUninstallJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized" + ]); + if (journal.schemaVersion !== 1 || journal.kind !== "managed-uninstall" || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || typeof journal.skillId !== "string" || journal.skillId.length === 0 || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || typeof journal.managedExisted !== "boolean" || typeof journal.createdAt !== "string" || !path8.isAbsolute(journal.managedPath) || !path8.isAbsolute(journal.recordPath) || !path8.isAbsolute(journal.treeTombstone) || !path8.isAbsolute(journal.recordTombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + if (!samePath( + journal.managedPath, + path8.join(this.#managedRoot, journal.name) + ) || !samePath(journal.recordPath, this.#recordPath(journal.name)) || !samePath( + journal.treeTombstone, + path8.join(stagingRoot, `uninstall-${journal.operationId}-tree`) + ) || !samePath( + journal.recordTombstone, + path8.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json` + ) + )) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + } + async #journalRecordHash(journal, target, label) { + const type = await pathType(target); + if (type === "missing") { + return void 0; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4 + ); + } + try { + const source = await readFile6(target, "utf8"); + const parsed = JSON.parse(source); + if (!validManagedSkillRecord(parsed, journal.name) || parsed.skillId !== journal.skillId || parsed.treeHash !== journal.treeHash || parsed.deployments.length !== 0 || sha256(source) !== journal.recordHash) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4 + ); + } + } + async #moveVerifiedUninstallRecord(journal, source, destination, label) { + if (await pathType(destination) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4 + ); + } + await this.#journalRecordHash(journal, source, label); + await rename2(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + async #removeAuthorizedUninstallPath(journal, target, kind) { + const expected = kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5 + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink2(target); + } + async #recoverUninstallJournal(journal, journalPath) { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if (await pathType(journal.managedPath) !== "missing" || await pathType(journal.recordPath) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4 + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree" + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record" + ); + await unlink2(journalPath); + return "committed"; + } + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target" + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record" + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (managedHash !== void 0 && managedHash !== journal.treeHash || treeTombstoneHash !== void 0 && treeTombstoneHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4 + ); + } + if (managedHash !== void 0 && treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash !== void 0 && recordTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0 && recordTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4 + ); + } + if (journal.managedExisted) { + if (managedHash === void 0 && treeTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4 + ); + } + if (managedHash === void 0) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone" + ); + } + } else if (managedHash !== void 0 || treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone" + ); + } + await unlink2(journalPath); + return "rolled-back"; + } async #removeIncompleteManaged(journal) { if (journal.managedExistedBefore) { return; @@ -10590,6 +10796,9 @@ var StashLifecycleImplementation = class { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ("kind" in journal && journal.kind === "managed-uninstall") { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -10781,9 +10990,7 @@ var StashLifecycleImplementation = class { throw new Error("lifecycle record is not a real file"); } const parsed = JSON.parse(await readFile6(recordPath, "utf8")); - if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || !parsed.source || parsed.source.kind !== "local-import" && parsed.source.kind !== "standalone-archive" || typeof parsed.source.location !== "string" || !path8.isAbsolute(parsed.source.location) || typeof parsed.source.importedAt !== "string" || parsed.source.updatedAt !== void 0 && typeof parsed.source.updatedAt !== "string" || !validStoredRemoteProvenance(parsed.source) || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) - )) { + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -11719,6 +11926,172 @@ var StashLifecycleImplementation = class { return this.#deactivateDeployment(record, managedPath, target); }); } + async uninstall(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3 + ); + } + const managedPath = path8.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3 + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3 + ); + } + } + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile6(recordPath, "utf8"); + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + const journal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-tree` + ), + recordTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-record.json` + ), + createdAt: new Date(this.#now()).toISOString() + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if (!commitRecord || !isDeepStrictEqual(commitRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + await rename2(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3 + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3 + ); + } + const finalRecord = await this.#readRecord(record.name); + if (!finalRecord || !isDeepStrictEqual(finalRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3 + ); + } + if (await pathType(managedPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await rename2(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (await pathType(recordPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + let warning = managedExisted ? void 0 : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...warning ? { warning } : {} + }; + }); + } async status(request = {}) { const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; @@ -11911,6 +12284,16 @@ function numberFlag(args, name) { } return parsed; } +function rejectUnknownFlags(args, allowed) { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2 + ); + } +} function createOptions(args) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -12059,6 +12442,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -12341,6 +12725,24 @@ async function main() { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + /* @__PURE__ */ new Set(["config", "managed-root", "json", "help"]) + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/adapters/claude-code/skills/stash/SKILL.md b/adapters/claude-code/skills/stash/SKILL.md index 69668b6..1d9850b 100644 --- a/adapters/claude-code/skills/stash/SKILL.md +++ b/adapters/claude-code/skills/stash/SKILL.md @@ -1,6 +1,6 @@ --- name: stash -description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `/stash:stash` to find, read, list, install, update, archive, activate, deactivate, or inspect a skill. +description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `/stash:stash` to find, read, list, install, update, archive, activate, deactivate, uninstall, or inspect a skill. disable-model-invocation: true --- @@ -26,7 +26,7 @@ Classify the text after `/stash:stash`: | exact skill name, optionally followed by a task | `exact`, then `read` | | `find ...` or a task/topic without an exact name | `search`, then `read` when one skill is selected | | `status [name]` | lifecycle `status` | -| `install`, `update`, `archive`, `activate`, or `deactivate` | [Lifecycle requests](#lifecycle-requests) | +| `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall` | [Lifecycle requests](#lifecycle-requests) | Treat an author, repository, or source ID named by the user as `--source`. Keep an explicitly scoped request inside that source. Treat a slug-like skill name @@ -82,7 +82,7 @@ a script does not authorize executing it. ## Lifecycle requests -Before `install`, `update`, `archive`, `activate`, or `deactivate`, read +Before `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall`, read [CLI-CONTRACT.md](references/CLI-CONTRACT.md) completely and follow its Lifecycle contract. It owns the mutation preconditions, remote provenance rules, bulk-update workflow, result meanings, and supported targets. diff --git a/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md b/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md index 1d1db54..2ac7f29 100644 --- a/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md @@ -118,6 +118,17 @@ Each record commits independently, so report successes, skips, and failures. owned by the host. - Honor `reloadRequired` and `warning` after a discovery-path change. +### Uninstall + +`uninstall ` removes only the verified Stash-managed canonical copy and +its lifecycle record. It never removes an external catalog source, host +deployment, plugin, or host setting. Every recorded deployment must first be +removed explicitly with `deactivate`, including a deployment already reported +missing. If the managed directory is already missing, a valid zero-deployment +record is removed with a warning. Hash drift, links, files, special paths, and +invalid metadata fail closed and are preserved for diagnosis. There is no +`--force`, automatic deactivation, trash store, or restore command. + ### Status `status [name] --json` reports store presence, tree integrity, deployment diff --git a/adapters/claude-code/skills/stash/scripts/stash.mjs b/adapters/claude-code/skills/stash/scripts/stash.mjs index 7962134..4396e60 100644 --- a/adapters/claude-code/skills/stash/scripts/stash.mjs +++ b/adapters/claude-code/skills/stash/scripts/stash.mjs @@ -9932,6 +9932,15 @@ function samePath(left, right) { function targetIdentity(target) { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value; + return record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === name && /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && Boolean(record.source) && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path8.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.updatedAt === void 0 || typeof record.source.updatedAt === "string") && validStoredRemoteProvenance(record.source) && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => typeof deployment.deploymentId === "string" && deployment.skillId === record.skillId && typeof deployment.targetId === "string" && deployment.targetId === targetIdentity(deployment) && deployment.ownership === "stash" && samePath(deployment.path, path8.join(deployment.root, record.name)) + ); +} async function isPluginContained(source) { let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -10443,6 +10452,203 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); return "rolled-back"; } + #validateUninstallJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized" + ]); + if (journal.schemaVersion !== 1 || journal.kind !== "managed-uninstall" || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || typeof journal.skillId !== "string" || journal.skillId.length === 0 || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || typeof journal.managedExisted !== "boolean" || typeof journal.createdAt !== "string" || !path8.isAbsolute(journal.managedPath) || !path8.isAbsolute(journal.recordPath) || !path8.isAbsolute(journal.treeTombstone) || !path8.isAbsolute(journal.recordTombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + if (!samePath( + journal.managedPath, + path8.join(this.#managedRoot, journal.name) + ) || !samePath(journal.recordPath, this.#recordPath(journal.name)) || !samePath( + journal.treeTombstone, + path8.join(stagingRoot, `uninstall-${journal.operationId}-tree`) + ) || !samePath( + journal.recordTombstone, + path8.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json` + ) + )) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + } + async #journalRecordHash(journal, target, label) { + const type = await pathType(target); + if (type === "missing") { + return void 0; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4 + ); + } + try { + const source = await readFile6(target, "utf8"); + const parsed = JSON.parse(source); + if (!validManagedSkillRecord(parsed, journal.name) || parsed.skillId !== journal.skillId || parsed.treeHash !== journal.treeHash || parsed.deployments.length !== 0 || sha256(source) !== journal.recordHash) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4 + ); + } + } + async #moveVerifiedUninstallRecord(journal, source, destination, label) { + if (await pathType(destination) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4 + ); + } + await this.#journalRecordHash(journal, source, label); + await rename2(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + async #removeAuthorizedUninstallPath(journal, target, kind) { + const expected = kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5 + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink2(target); + } + async #recoverUninstallJournal(journal, journalPath) { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if (await pathType(journal.managedPath) !== "missing" || await pathType(journal.recordPath) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4 + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree" + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record" + ); + await unlink2(journalPath); + return "committed"; + } + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target" + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record" + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (managedHash !== void 0 && managedHash !== journal.treeHash || treeTombstoneHash !== void 0 && treeTombstoneHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4 + ); + } + if (managedHash !== void 0 && treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash !== void 0 && recordTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0 && recordTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4 + ); + } + if (journal.managedExisted) { + if (managedHash === void 0 && treeTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4 + ); + } + if (managedHash === void 0) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone" + ); + } + } else if (managedHash !== void 0 || treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone" + ); + } + await unlink2(journalPath); + return "rolled-back"; + } async #removeIncompleteManaged(journal) { if (journal.managedExistedBefore) { return; @@ -10590,6 +10796,9 @@ var StashLifecycleImplementation = class { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ("kind" in journal && journal.kind === "managed-uninstall") { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -10781,9 +10990,7 @@ var StashLifecycleImplementation = class { throw new Error("lifecycle record is not a real file"); } const parsed = JSON.parse(await readFile6(recordPath, "utf8")); - if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || !parsed.source || parsed.source.kind !== "local-import" && parsed.source.kind !== "standalone-archive" || typeof parsed.source.location !== "string" || !path8.isAbsolute(parsed.source.location) || typeof parsed.source.importedAt !== "string" || parsed.source.updatedAt !== void 0 && typeof parsed.source.updatedAt !== "string" || !validStoredRemoteProvenance(parsed.source) || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) - )) { + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -11719,6 +11926,172 @@ var StashLifecycleImplementation = class { return this.#deactivateDeployment(record, managedPath, target); }); } + async uninstall(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3 + ); + } + const managedPath = path8.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3 + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3 + ); + } + } + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile6(recordPath, "utf8"); + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + const journal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-tree` + ), + recordTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-record.json` + ), + createdAt: new Date(this.#now()).toISOString() + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if (!commitRecord || !isDeepStrictEqual(commitRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + await rename2(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3 + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3 + ); + } + const finalRecord = await this.#readRecord(record.name); + if (!finalRecord || !isDeepStrictEqual(finalRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3 + ); + } + if (await pathType(managedPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await rename2(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (await pathType(recordPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + let warning = managedExisted ? void 0 : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...warning ? { warning } : {} + }; + }); + } async status(request = {}) { const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; @@ -11911,6 +12284,16 @@ function numberFlag(args, name) { } return parsed; } +function rejectUnknownFlags(args, allowed) { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2 + ); + } +} function createOptions(args) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -12059,6 +12442,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -12341,6 +12725,24 @@ async function main() { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + /* @__PURE__ */ new Set(["config", "managed-root", "json", "help"]) + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/adapters/codex/skills/stash/SKILL.md b/adapters/codex/skills/stash/SKILL.md index a4d1daa..4bebd40 100644 --- a/adapters/codex/skills/stash/SKILL.md +++ b/adapters/codex/skills/stash/SKILL.md @@ -1,6 +1,6 @@ --- name: stash -description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `$stash` to find, read, list, install, update, archive, activate, deactivate, or inspect a skill. +description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `$stash` to find, read, list, install, update, archive, activate, deactivate, uninstall, or inspect a skill. --- # Stash @@ -25,7 +25,7 @@ Classify the text after `$stash`: | exact skill name, optionally followed by a task | `exact`, then `read` | | `find ...` or a task/topic without an exact name | `search`, then `read` when one skill is selected | | `status [name]` | lifecycle `status` | -| `install`, `update`, `archive`, `activate`, or `deactivate` | [Lifecycle requests](#lifecycle-requests) | +| `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall` | [Lifecycle requests](#lifecycle-requests) | Treat an author, repository, or source ID named by the user as `--source`. Keep an explicitly scoped request inside that source. Treat a slug-like skill name @@ -81,7 +81,7 @@ a script does not authorize executing it. ## Lifecycle requests -Before `install`, `update`, `archive`, `activate`, or `deactivate`, read +Before `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall`, read [CLI-CONTRACT.md](references/CLI-CONTRACT.md) completely and follow its Lifecycle contract. It owns the mutation preconditions, remote provenance rules, bulk-update workflow, result meanings, and supported targets. diff --git a/adapters/codex/skills/stash/references/CLI-CONTRACT.md b/adapters/codex/skills/stash/references/CLI-CONTRACT.md index 1d1db54..2ac7f29 100644 --- a/adapters/codex/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/codex/skills/stash/references/CLI-CONTRACT.md @@ -118,6 +118,17 @@ Each record commits independently, so report successes, skips, and failures. owned by the host. - Honor `reloadRequired` and `warning` after a discovery-path change. +### Uninstall + +`uninstall ` removes only the verified Stash-managed canonical copy and +its lifecycle record. It never removes an external catalog source, host +deployment, plugin, or host setting. Every recorded deployment must first be +removed explicitly with `deactivate`, including a deployment already reported +missing. If the managed directory is already missing, a valid zero-deployment +record is removed with a warning. Hash drift, links, files, special paths, and +invalid metadata fail closed and are preserved for diagnosis. There is no +`--force`, automatic deactivation, trash store, or restore command. + ### Status `status [name] --json` reports store presence, tree integrity, deployment diff --git a/adapters/codex/skills/stash/scripts/stash.mjs b/adapters/codex/skills/stash/scripts/stash.mjs index 7962134..4396e60 100644 --- a/adapters/codex/skills/stash/scripts/stash.mjs +++ b/adapters/codex/skills/stash/scripts/stash.mjs @@ -9932,6 +9932,15 @@ function samePath(left, right) { function targetIdentity(target) { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value; + return record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === name && /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && Boolean(record.source) && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path8.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.updatedAt === void 0 || typeof record.source.updatedAt === "string") && validStoredRemoteProvenance(record.source) && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => typeof deployment.deploymentId === "string" && deployment.skillId === record.skillId && typeof deployment.targetId === "string" && deployment.targetId === targetIdentity(deployment) && deployment.ownership === "stash" && samePath(deployment.path, path8.join(deployment.root, record.name)) + ); +} async function isPluginContained(source) { let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -10443,6 +10452,203 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); return "rolled-back"; } + #validateUninstallJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized" + ]); + if (journal.schemaVersion !== 1 || journal.kind !== "managed-uninstall" || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || typeof journal.skillId !== "string" || journal.skillId.length === 0 || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || typeof journal.managedExisted !== "boolean" || typeof journal.createdAt !== "string" || !path8.isAbsolute(journal.managedPath) || !path8.isAbsolute(journal.recordPath) || !path8.isAbsolute(journal.treeTombstone) || !path8.isAbsolute(journal.recordTombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + if (!samePath( + journal.managedPath, + path8.join(this.#managedRoot, journal.name) + ) || !samePath(journal.recordPath, this.#recordPath(journal.name)) || !samePath( + journal.treeTombstone, + path8.join(stagingRoot, `uninstall-${journal.operationId}-tree`) + ) || !samePath( + journal.recordTombstone, + path8.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json` + ) + )) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + } + async #journalRecordHash(journal, target, label) { + const type = await pathType(target); + if (type === "missing") { + return void 0; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4 + ); + } + try { + const source = await readFile6(target, "utf8"); + const parsed = JSON.parse(source); + if (!validManagedSkillRecord(parsed, journal.name) || parsed.skillId !== journal.skillId || parsed.treeHash !== journal.treeHash || parsed.deployments.length !== 0 || sha256(source) !== journal.recordHash) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4 + ); + } + } + async #moveVerifiedUninstallRecord(journal, source, destination, label) { + if (await pathType(destination) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4 + ); + } + await this.#journalRecordHash(journal, source, label); + await rename2(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + async #removeAuthorizedUninstallPath(journal, target, kind) { + const expected = kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5 + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink2(target); + } + async #recoverUninstallJournal(journal, journalPath) { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if (await pathType(journal.managedPath) !== "missing" || await pathType(journal.recordPath) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4 + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree" + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record" + ); + await unlink2(journalPath); + return "committed"; + } + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target" + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record" + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (managedHash !== void 0 && managedHash !== journal.treeHash || treeTombstoneHash !== void 0 && treeTombstoneHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4 + ); + } + if (managedHash !== void 0 && treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash !== void 0 && recordTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0 && recordTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4 + ); + } + if (journal.managedExisted) { + if (managedHash === void 0 && treeTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4 + ); + } + if (managedHash === void 0) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone" + ); + } + } else if (managedHash !== void 0 || treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone" + ); + } + await unlink2(journalPath); + return "rolled-back"; + } async #removeIncompleteManaged(journal) { if (journal.managedExistedBefore) { return; @@ -10590,6 +10796,9 @@ var StashLifecycleImplementation = class { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ("kind" in journal && journal.kind === "managed-uninstall") { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -10781,9 +10990,7 @@ var StashLifecycleImplementation = class { throw new Error("lifecycle record is not a real file"); } const parsed = JSON.parse(await readFile6(recordPath, "utf8")); - if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || !parsed.source || parsed.source.kind !== "local-import" && parsed.source.kind !== "standalone-archive" || typeof parsed.source.location !== "string" || !path8.isAbsolute(parsed.source.location) || typeof parsed.source.importedAt !== "string" || parsed.source.updatedAt !== void 0 && typeof parsed.source.updatedAt !== "string" || !validStoredRemoteProvenance(parsed.source) || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) - )) { + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -11719,6 +11926,172 @@ var StashLifecycleImplementation = class { return this.#deactivateDeployment(record, managedPath, target); }); } + async uninstall(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3 + ); + } + const managedPath = path8.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3 + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3 + ); + } + } + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile6(recordPath, "utf8"); + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + const journal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-tree` + ), + recordTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-record.json` + ), + createdAt: new Date(this.#now()).toISOString() + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if (!commitRecord || !isDeepStrictEqual(commitRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + await rename2(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3 + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3 + ); + } + const finalRecord = await this.#readRecord(record.name); + if (!finalRecord || !isDeepStrictEqual(finalRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3 + ); + } + if (await pathType(managedPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await rename2(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (await pathType(recordPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + let warning = managedExisted ? void 0 : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...warning ? { warning } : {} + }; + }); + } async status(request = {}) { const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; @@ -11911,6 +12284,16 @@ function numberFlag(args, name) { } return parsed; } +function rejectUnknownFlags(args, allowed) { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2 + ); + } +} function createOptions(args) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -12059,6 +12442,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -12341,6 +12725,24 @@ async function main() { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + /* @__PURE__ */ new Set(["config", "managed-root", "json", "help"]) + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/docs/architecture.md b/docs/architecture.md index 5895bb3..a9f0c1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,7 @@ interface StashLifecycle { archive(request: LifecycleArchiveRequest): Promise; activate(request: LifecycleActivateRequest): Promise; deactivate(request: LifecycleDeactivateRequest): Promise; + uninstall(request: LifecycleUninstallRequest): Promise; status(request?: LifecycleStatusRequest): Promise; } ``` @@ -95,7 +96,7 @@ Responsibilities: - `util.ts`: hashing, cursor integrity, path containment, tokenization, platform locations. - `stash-catalog.ts`: orchestrate the Interface and normalize errors/results. - `stash-lifecycle.ts`: validate portable skill trees, serialize mutations, - stage atomic copies, maintain archive and managed-update recovery journals, + stage atomic copies, maintain archive, update, and uninstall recovery journals, record stable skill and deployment identities, detect drift, and enforce standalone-only destructive boundaries. @@ -208,6 +209,12 @@ fails closed and is never removed based on age alone. Archive journals use schema 2. Other archive journal versions are unsupported and fail closed; recovery does not attempt an automatic migration. +Uninstall accepts only a managed skill with zero deployment records. It moves +the verified tree and record to operation-owned tombstones, authorizes cleanup +as the logical commit, then removes those exact paths. Recovery restores both +before commit or resumes even partially completed recursive cleanup afterward; +external sources and host roots are never in scope. + The managed root, `.stash`, records, staging, and journal roots must all be real directories whose resolved paths remain inside the managed root. Journals and records must be real files. Recovery is designed for interrupted processes and diff --git a/research/2026-08-28-stash-uninstall-design.md b/research/2026-08-28-stash-uninstall-design.md new file mode 100644 index 0000000..5f735fe --- /dev/null +++ b/research/2026-08-28-stash-uninstall-design.md @@ -0,0 +1,161 @@ +# Stash managed skill uninstall design + +Date: 2026-08-28 + +## Decision + +Add one command and no destructive aliases or convenience flags: + +```text +stash uninstall [--json] +``` + +It removes only the Stash-managed tree and its lifecycle record. It never +touches external catalogs or host discovery roots. If any deployment record +remains, or if a present managed tree no longer matches its recorded hash, it +makes no change and fails with a useful error. If the tree is already missing, +it removes the valid zero-deployment record and reports a warning. + +This matches the established package-manager shape: npm uses +`npm uninstall ` to remove what npm installed and update its own recorded +state, while Cargo describes `cargo uninstall ` as removing a package +previously installed by Cargo. Neither requires a second `purge` command for +the normal case. ([npm uninstall](https://docs.npmjs.com/cli/v11/commands/npm-uninstall/), +[Cargo uninstall](https://doc.rust-lang.org/nightly/cargo/commands/cargo-uninstall.html)) + +## Why this fits Stash + +The existing [`StashLifecycle`](../src/types.ts) is the only write interface, +and [`stash-lifecycle.ts`](../src/stash-lifecycle.ts) already owns the managed +root, lifecycle lock, tree hashing, atomic record replacement, tombstones, and +crash-recovery journals. `uninstall` should be one more method on that interface, +not a new subsystem. + +A valid managed record is the ownership proof for the canonical managed path; +no new `ownership` boolean is needed. A directory without its valid Stash +record is untracked and must not be adopted or deleted. A record with a +non-directory, linked, or hash-mismatched tree is drifted and must be preserved +for diagnosis. A missing tree has no remaining user content to protect, so +uninstall may safely reconcile the record; this mirrors the existing +missing-deployment behavior in `deactivate`. + +Git provides the useful safety model: verify the current value against the +expected value before changing it, acquire exclusive locks before commit, and +abort a transaction that cannot prepare all updates. Its lockfile API uses +exclusive creation followed by rename for mutual exclusion and atomic file +replacement. ([git-update-ref](https://git-scm.com/docs/git-update-ref), +[Git lockfile API](https://git-scm.com/docs/api-lockfile.html)) + +## Minimal contract + +Add: + +```ts +interface LifecycleUninstallRequest { + name: string; +} + +interface StashLifecycle { + uninstall(request: LifecycleUninstallRequest): Promise; +} +``` + +Reuse `LifecycleMutationResult`, adding only `"uninstalled"` to its `status` +union. Successful JSON therefore keeps the existing lifecycle result shape: +`status`, `name`, `skillId`, `managedPath`, and `treeHash`. Human output can +continue through the existing lifecycle printer. + +Preconditions, checked under the existing lifecycle lock: + +1. A valid record exists for exactly ``. +2. `record.deployments.length === 0`. Even a recorded-but-missing deployment + must first go through `deactivate`, which already reconciles that state. +3. The canonical managed path is missing or a real directory inside the managed + root; links, files, and special paths are rejected. +4. When present, a fresh snapshot hash equals `record.treeHash`. +5. The record and present tree still match immediately before mutation. + +Recommended stable failures are `managed-skill-not-found`, +`active-deployments`, and the existing managed-layout/drift errors. A missing +record is still an error; a valid record with an already-missing tree is a +metadata-only uninstall success with a warning. ([Cargo exit status](https://doc.rust-lang.org/nightly/cargo/commands/cargo-uninstall.html#exit-status)) + +## Transaction and recovery + +Use one new uninstall journal kind inside the existing journal directory. Keep +both tombstones inside the managed root so their renames stay on one filesystem. + +```text +lock + recover old journals + -> validate record, zero deployments, path, and tree hash + -> write prepared journal + -> rename managed tree to journal-owned tree tombstone; re-hash + -> rename the still-verified record to its journal-owned tombstone + -> mark cleanup authorized # point of no return + -> remove both authorized tombstones + -> remove journal and unlock +``` + +Before cleanup authorization, recovery restores the verified record and tree +tombstones to their original unoccupied paths. After authorization, recovery +removes only the exact operation-owned tombstone paths without requiring their +original hashes, so partially completed recursive cleanup can resume. If an +original path is occupied or a pre-commit tombstone does not match its recorded +identity, recovery fails closed. A missing managed tree skips the tree rename +and commits by tombstoning only the verified record. + +Node documents `rename` as the filesystem move primitive and `rm` as recursive +removal. It also warns that promise/callback filesystem calls have no guaranteed +ordering unless each operation is awaited, so every phase must be sequential. +([Node.js filesystem API](https://nodejs.org/api/fs.html#fspromisesrenameoldpath-newpath), +[filesystem operation ordering](https://nodejs.org/api/fs.html#ordering-of-callback-and-promise-based-operations)) + +Describe this as **serialized and crash-recoverable**, not fully atomic or +power-loss durable. Git's lock documentation makes atomic visibility conditional +on filesystem rename behavior, and Stash already documents that it has no fsync +protocol. ([Git lockfile assumptions](https://git-scm.com/docs/api-lockfile.html), +[`docs/architecture.md`](../docs/architecture.md#lifecycle-data-flow)) + +The repository has no operating-system Recycle Bin abstraction; its current +verified tombstones are transaction mechanics and are permanently cleaned with +Node `rm`. Do not add an app-specific `.trash` store or Windows-only shell +integration for this command. That would create a second retention lifecycle +without improving correctness. After a successful uninstall there is no +Stash-provided restore; recoverability applies only to interrupted work. +([Node.js `rm`](https://nodejs.org/api/fs.html#fspromisesrmpath-options), +[`stash-lifecycle.ts`](../src/stash-lifecycle.ts)) + +## Intentionally excluded + +- `--force`: must never bypass ownership, deployment, or drift checks. +- `--deactivate-all`: deployment removal stays explicit per host and scope. +- `--expected-tree-hash`: uninstall verifies the record and tree itself while + holding the lifecycle lock; unlike update, it does not consume an externally + staged replacement. +- `--purge` or `--trash`: there is only one managed-copy removal meaning. +- confirmation prompts: the command itself is explicit and must remain usable + with `--json` in automation. +- uninstall aliases: one documented verb is sufficient. + +## Implementation and test checklist + +Implementation should touch only the existing lifecycle seam, CLI dispatch and +help, public types/exports, canonical skill contract, and generated adapters. +The focused behavior tests should prove: + +- success removes the managed tree and record and leaves external sources alone; +- every recorded deployment blocks uninstall without mutation; +- a missing managed tree removes only its valid record with a warning; +- linked, non-directory, and drifted managed trees are preserved; +- mutation between the first hash and tombstone verification is rejected while + preserving the tombstone and journal for diagnosis; +- interruption before commit restores the verified record and tree; +- interruption after commit finishes only authorized cleanup; +- partial committed cleanup is idempotently resumed without a full-tree hash; +- an externally missing record before commit preserves the tree tombstone and + fails closed; +- occupied paths, malformed journals, and mismatched tombstones fail closed; +- plain and `--json` CLI output use the existing lifecycle result contract. + +After implementation, run `npm run test:all` and the skill validator required +by [`docs/maintenance.md`](../docs/maintenance.md). diff --git a/skills/stash/SKILL.md b/skills/stash/SKILL.md index a4d1daa..4bebd40 100644 --- a/skills/stash/SKILL.md +++ b/skills/stash/SKILL.md @@ -1,6 +1,6 @@ --- name: stash -description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `$stash` to find, read, list, install, update, archive, activate, deactivate, or inspect a skill. +description: Explicitly search a local Agent Skills library or manage Stash-owned inactive skills. Use only when the user invokes `$stash` to find, read, list, install, update, archive, activate, deactivate, uninstall, or inspect a skill. --- # Stash @@ -25,7 +25,7 @@ Classify the text after `$stash`: | exact skill name, optionally followed by a task | `exact`, then `read` | | `find ...` or a task/topic without an exact name | `search`, then `read` when one skill is selected | | `status [name]` | lifecycle `status` | -| `install`, `update`, `archive`, `activate`, or `deactivate` | [Lifecycle requests](#lifecycle-requests) | +| `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall` | [Lifecycle requests](#lifecycle-requests) | Treat an author, repository, or source ID named by the user as `--source`. Keep an explicitly scoped request inside that source. Treat a slug-like skill name @@ -81,7 +81,7 @@ a script does not authorize executing it. ## Lifecycle requests -Before `install`, `update`, `archive`, `activate`, or `deactivate`, read +Before `install`, `update`, `archive`, `activate`, `deactivate`, or `uninstall`, read [CLI-CONTRACT.md](references/CLI-CONTRACT.md) completely and follow its Lifecycle contract. It owns the mutation preconditions, remote provenance rules, bulk-update workflow, result meanings, and supported targets. diff --git a/skills/stash/references/CLI-CONTRACT.md b/skills/stash/references/CLI-CONTRACT.md index 1d1db54..2ac7f29 100644 --- a/skills/stash/references/CLI-CONTRACT.md +++ b/skills/stash/references/CLI-CONTRACT.md @@ -118,6 +118,17 @@ Each record commits independently, so report successes, skips, and failures. owned by the host. - Honor `reloadRequired` and `warning` after a discovery-path change. +### Uninstall + +`uninstall ` removes only the verified Stash-managed canonical copy and +its lifecycle record. It never removes an external catalog source, host +deployment, plugin, or host setting. Every recorded deployment must first be +removed explicitly with `deactivate`, including a deployment already reported +missing. If the managed directory is already missing, a valid zero-deployment +record is removed with a warning. Hash drift, links, files, special paths, and +invalid metadata fail closed and are preserved for diagnosis. There is no +`--force`, automatic deactivation, trash store, or restore command. + ### Status `status [name] --json` reports store presence, tree integrity, deployment diff --git a/skills/stash/scripts/stash.mjs b/skills/stash/scripts/stash.mjs index 7962134..4396e60 100644 --- a/skills/stash/scripts/stash.mjs +++ b/skills/stash/scripts/stash.mjs @@ -9932,6 +9932,15 @@ function samePath(left, right) { function targetIdentity(target) { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value; + return record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === name && /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && Boolean(record.source) && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path8.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.updatedAt === void 0 || typeof record.source.updatedAt === "string") && validStoredRemoteProvenance(record.source) && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => typeof deployment.deploymentId === "string" && deployment.skillId === record.skillId && typeof deployment.targetId === "string" && deployment.targetId === targetIdentity(deployment) && deployment.ownership === "stash" && samePath(deployment.path, path8.join(deployment.root, record.name)) + ); +} async function isPluginContained(source) { let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -10443,6 +10452,203 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); return "rolled-back"; } + #validateUninstallJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized" + ]); + if (journal.schemaVersion !== 1 || journal.kind !== "managed-uninstall" || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || typeof journal.skillId !== "string" || journal.skillId.length === 0 || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || typeof journal.managedExisted !== "boolean" || typeof journal.createdAt !== "string" || !path8.isAbsolute(journal.managedPath) || !path8.isAbsolute(journal.recordPath) || !path8.isAbsolute(journal.treeTombstone) || !path8.isAbsolute(journal.recordTombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + if (!samePath( + journal.managedPath, + path8.join(this.#managedRoot, journal.name) + ) || !samePath(journal.recordPath, this.#recordPath(journal.name)) || !samePath( + journal.treeTombstone, + path8.join(stagingRoot, `uninstall-${journal.operationId}-tree`) + ) || !samePath( + journal.recordTombstone, + path8.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json` + ) + )) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5 + ); + } + } + async #journalRecordHash(journal, target, label) { + const type = await pathType(target); + if (type === "missing") { + return void 0; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4 + ); + } + try { + const source = await readFile6(target, "utf8"); + const parsed = JSON.parse(source); + if (!validManagedSkillRecord(parsed, journal.name) || parsed.skillId !== journal.skillId || parsed.treeHash !== journal.treeHash || parsed.deployments.length !== 0 || sha256(source) !== journal.recordHash) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4 + ); + } + } + async #moveVerifiedUninstallRecord(journal, source, destination, label) { + if (await pathType(destination) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4 + ); + } + await this.#journalRecordHash(journal, source, label); + await rename2(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + async #removeAuthorizedUninstallPath(journal, target, kind) { + const expected = kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5 + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink2(target); + } + async #recoverUninstallJournal(journal, journalPath) { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if (await pathType(journal.managedPath) !== "missing" || await pathType(journal.recordPath) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4 + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree" + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record" + ); + await unlink2(journalPath); + return "committed"; + } + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target" + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record" + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (managedHash !== void 0 && managedHash !== journal.treeHash || treeTombstoneHash !== void 0 && treeTombstoneHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4 + ); + } + if (managedHash !== void 0 && treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash !== void 0 && recordTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0 && recordTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4 + ); + } + if (journal.managedExisted) { + if (managedHash === void 0 && treeTombstoneHash === void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4 + ); + } + if (managedHash === void 0) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone" + ); + } + } else if (managedHash !== void 0 || treeTombstoneHash !== void 0) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4 + ); + } + if (recordHash === void 0) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone" + ); + } + await unlink2(journalPath); + return "rolled-back"; + } async #removeIncompleteManaged(journal) { if (journal.managedExistedBefore) { return; @@ -10590,6 +10796,9 @@ var StashLifecycleImplementation = class { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ("kind" in journal && journal.kind === "managed-uninstall") { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -10781,9 +10990,7 @@ var StashLifecycleImplementation = class { throw new Error("lifecycle record is not a real file"); } const parsed = JSON.parse(await readFile6(recordPath, "utf8")); - if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || !parsed.source || parsed.source.kind !== "local-import" && parsed.source.kind !== "standalone-archive" || typeof parsed.source.location !== "string" || !path8.isAbsolute(parsed.source.location) || typeof parsed.source.importedAt !== "string" || parsed.source.updatedAt !== void 0 && typeof parsed.source.updatedAt !== "string" || !validStoredRemoteProvenance(parsed.source) || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) - )) { + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -11719,6 +11926,172 @@ var StashLifecycleImplementation = class { return this.#deactivateDeployment(record, managedPath, target); }); } + async uninstall(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3 + ); + } + const managedPath = path8.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3 + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3 + ); + } + } + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile6(recordPath, "utf8"); + const stagingRoot = path8.join(this.#metadataRoot(), "staging"); + const journal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-tree` + ), + recordTombstone: path8.join( + stagingRoot, + `uninstall-${operationId}-record.json` + ), + createdAt: new Date(this.#now()).toISOString() + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if (!commitRecord || !isDeepStrictEqual(commitRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3 + ); + } + await rename2(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone" + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3 + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3 + ); + } + const finalRecord = await this.#readRecord(record.name); + if (!finalRecord || !isDeepStrictEqual(finalRecord, record) || sha256(await readFile6(recordPath, "utf8")) !== journal.recordHash) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3 + ); + } + if (await pathType(managedPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await rename2(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone" + ); + if (await pathType(recordPath) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3 + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + let warning = managedExisted ? void 0 : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...warning ? { warning } : {} + }; + }); + } async status(request = {}) { const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; @@ -11911,6 +12284,16 @@ function numberFlag(args, name) { } return parsed; } +function rejectUnknownFlags(args, allowed) { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2 + ); + } +} function createOptions(args) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -12059,6 +12442,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -12341,6 +12725,24 @@ async function main() { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + /* @__PURE__ */ new Set(["config", "managed-root", "json", "help"]) + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/src/cli.ts b/src/cli.ts index d24dfa6..1aac8b2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -77,6 +77,20 @@ function numberFlag(args: ParsedArguments, name: string): number | undefined { return parsed; } +function rejectUnknownFlags( + args: ParsedArguments, + allowed: ReadonlySet, +): void { + const unknown = [...args.flags.keys()].filter((name) => !allowed.has(name)); + if (unknown.length > 0) { + throw new StashError( + "invalid-argument", + `Unknown option(s) for ${args.command}: ${unknown.map((name) => `--${name}`).join(", ")}.`, + 2, + ); + } +} + function createOptions(args: ParsedArguments) { const root = flag(args, "root"); const catalogId = flag(args, "root-id") ?? "default"; @@ -239,6 +253,7 @@ Usage: stash archive --host [--scope user] [--source-url ] [--revision ] [--repository-path ] [--tracking-ref ] [--json] stash activate --host [--scope user] [--json] stash deactivate --host [--scope user] [--json] + stash uninstall [--json] stash status [name] [--json] Configuration: @@ -531,6 +546,24 @@ async function main(): Promise { json ? printJson(result) : printLifecycle(result); return; } + case "uninstall": { + rejectUnknownFlags( + args, + new Set(["config", "managed-root", "json", "help"]), + ); + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + "uninstall requires a managed skill name.", + 2, + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.uninstall({ name }); + json ? printJson(result) : printLifecycle(result); + return; + } case "status": { const name = args.positionals.join(" ").trim(); const lifecycle = await createStashLifecycle(createOptions(args)); diff --git a/src/index.ts b/src/index.ts index f653d4d..8d7fd44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export type { LifecycleHost, LifecycleHostTarget, LifecycleInstallRequest, + LifecycleUninstallRequest, LifecycleUpdateRequest, LifecycleMutationResult, LifecycleScope, diff --git a/src/stash-lifecycle.ts b/src/stash-lifecycle.ts index af64052..3e329f0 100644 --- a/src/stash-lifecycle.ts +++ b/src/stash-lifecycle.ts @@ -27,6 +27,7 @@ import type { LifecycleSkillStatus, LifecycleStatusRequest, LifecycleStatusResult, + LifecycleUninstallRequest, LifecycleUpdateRequest, ManagedSkillRecord, StashLifecycle, @@ -119,7 +120,31 @@ interface ManagedUpdateJournal { createdAt: string; } -type LifecycleJournal = ArchiveJournal | ManagedUpdateJournal; +interface ManagedUninstallJournal { + schemaVersion: 1; + kind: "managed-uninstall"; + operationId: string; + stage: + | "started" + | "tree-tombstoned" + | "record-tombstoned" + | "cleanup-authorized"; + name: string; + skillId: string; + treeHash: string; + recordHash: string; + managedExisted: boolean; + managedPath: string; + recordPath: string; + treeTombstone: string; + recordTombstone: string; + createdAt: string; +} + +type LifecycleJournal = + | ArchiveJournal + | ManagedUpdateJournal + | ManagedUninstallJournal; interface LifecycleLockOwner { schemaVersion: 1; @@ -312,6 +337,42 @@ function targetIdentity(target: { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } +function validManagedSkillRecord( + value: unknown, + name: string, +): value is ManagedSkillRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as ManagedSkillRecord; + return ( + record.schemaVersion === STORE_SCHEMA_VERSION && + typeof record.skillId === "string" && + record.skillId.length > 0 && + record.name === name && + /^sha256:[0-9a-f]{64}$/iu.test(record.treeHash) && + Boolean(record.source) && + (record.source.kind === "local-import" || + record.source.kind === "standalone-archive") && + typeof record.source.location === "string" && + path.isAbsolute(record.source.location) && + typeof record.source.importedAt === "string" && + (record.source.updatedAt === undefined || + typeof record.source.updatedAt === "string") && + validStoredRemoteProvenance(record.source) && + Array.isArray(record.deployments) && + record.deployments.every( + (deployment) => + typeof deployment.deploymentId === "string" && + deployment.skillId === record.skillId && + typeof deployment.targetId === "string" && + deployment.targetId === targetIdentity(deployment) && + deployment.ownership === "stash" && + samePath(deployment.path, path.join(deployment.root, record.name)), + ) + ); +} + async function isPluginContained(source: string): Promise { let current = path.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -921,6 +982,272 @@ class StashLifecycleImplementation implements StashLifecycle { return "rolled-back"; } + #validateUninstallJournal( + journal: ManagedUninstallJournal, + journalPath: string, + ): void { + const stages = new Set([ + "started", + "tree-tombstoned", + "record-tombstoned", + "cleanup-authorized", + ]); + if ( + journal.schemaVersion !== 1 || + journal.kind !== "managed-uninstall" || + !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || + !stages.has(journal.stage) || + !NAME_PATTERN.test(journal.name) || + typeof journal.skillId !== "string" || + journal.skillId.length === 0 || + !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || + !/^sha256:[0-9a-f]{64}$/iu.test(journal.recordHash) || + typeof journal.managedExisted !== "boolean" || + typeof journal.createdAt !== "string" || + !path.isAbsolute(journal.managedPath) || + !path.isAbsolute(journal.recordPath) || + !path.isAbsolute(journal.treeTombstone) || + !path.isAbsolute(journal.recordTombstone) + ) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5, + ); + } + const stagingRoot = path.join(this.#metadataRoot(), "staging"); + if ( + !samePath( + journal.managedPath, + path.join(this.#managedRoot, journal.name), + ) || + !samePath(journal.recordPath, this.#recordPath(journal.name)) || + !samePath( + journal.treeTombstone, + path.join(stagingRoot, `uninstall-${journal.operationId}-tree`), + ) || + !samePath( + journal.recordTombstone, + path.join( + stagingRoot, + `uninstall-${journal.operationId}-record.json`, + ), + ) + ) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe managed uninstall journal "${journalPath}".`, + 5, + ); + } + } + + async #journalRecordHash( + journal: ManagedUninstallJournal, + target: string, + label: string, + ): Promise { + const type = await pathType(target); + if (type === "missing") { + return undefined; + } + if (type !== "file") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} is not a real file: "${target}".`, + 4, + ); + } + try { + const source = await readFile(target, "utf8"); + const parsed = JSON.parse(source) as unknown; + if ( + !validManagedSkillRecord(parsed, journal.name) || + parsed.skillId !== journal.skillId || + parsed.treeHash !== journal.treeHash || + parsed.deployments.length !== 0 || + sha256(source) !== journal.recordHash + ) { + throw new Error("record identity or content changed"); + } + return journal.recordHash; + } catch (error) { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} drifted at "${target}": ${String(error)}`, + 4, + ); + } + } + + async #moveVerifiedUninstallRecord( + journal: ManagedUninstallJournal, + source: string, + destination: string, + label: string, + ): Promise { + if ((await pathType(destination)) !== "missing") { + throw new StashError( + "lifecycle-recovery-conflict", + `${label} destination is occupied at "${destination}".`, + 4, + ); + } + await this.#journalRecordHash(journal, source, label); + await rename(source, destination); + await this.#journalRecordHash(journal, destination, label); + } + + async #removeAuthorizedUninstallPath( + journal: ManagedUninstallJournal, + target: string, + kind: "tree" | "record", + ): Promise { + const expected = + kind === "tree" ? journal.treeTombstone : journal.recordTombstone; + if (!samePath(target, expected)) { + throw new StashError( + "invalid-lifecycle-journal", + `Managed uninstall cleanup path is not operation-owned: "${target}".`, + 5, + ); + } + await this.#assertManagedLayout(); + const type = await pathType(target); + if (type === "missing") { + return; + } + if (type === "directory") { + await rm(target, { recursive: true, force: true }); + return; + } + await unlink(target); + } + + async #recoverUninstallJournal( + journal: ManagedUninstallJournal, + journalPath: string, + ): Promise<"committed" | "rolled-back"> { + await this.#assertManagedLayout(); + if (journal.stage === "cleanup-authorized") { + if ( + (await pathType(journal.managedPath)) !== "missing" || + (await pathType(journal.recordPath)) !== "missing" + ) { + throw new StashError( + "lifecycle-recovery-conflict", + `Committed uninstall paths were repopulated for "${journal.name}".`, + 4, + ); + } + await this.#removeAuthorizedUninstallPath( + journal, + journal.treeTombstone, + "tree", + ); + await this.#removeAuthorizedUninstallPath( + journal, + journal.recordTombstone, + "record", + ); + await unlink(journalPath); + return "committed"; + } + + const managedHash = await this.#journalTreeHash( + journal.managedPath, + "Managed uninstall target", + ); + const treeTombstoneHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone", + ); + const recordHash = await this.#journalRecordHash( + journal, + journal.recordPath, + "Managed uninstall record", + ); + const recordTombstoneHash = await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone", + ); + + if ( + (managedHash !== undefined && managedHash !== journal.treeHash) || + (treeTombstoneHash !== undefined && + treeTombstoneHash !== journal.treeHash) + ) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall tree drifted for "${journal.name}".`, + 4, + ); + } + + if ( + managedHash !== undefined && + treeTombstoneHash !== undefined + ) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical tree and tombstone for "${journal.name}".`, + 4, + ); + } + if (recordHash !== undefined && recordTombstoneHash !== undefined) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall has both a canonical record and tombstone for "${journal.name}".`, + 4, + ); + } + if (recordHash === undefined && recordTombstoneHash === undefined) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its lifecycle record before commit for "${journal.name}".`, + 4, + ); + } + + if (journal.managedExisted) { + if (managedHash === undefined && treeTombstoneHash === undefined) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed uninstall lost its canonical tree for "${journal.name}".`, + 4, + ); + } + if (managedHash === undefined) { + await this.#moveVerifiedJournalTree( + journal.treeTombstone, + journal.managedPath, + journal.treeHash, + "Managed uninstall tree tombstone", + ); + } + } else if ( + managedHash !== undefined || + treeTombstoneHash !== undefined + ) { + throw new StashError( + "lifecycle-recovery-conflict", + `Metadata-only uninstall path changed for "${journal.name}".`, + 4, + ); + } + if (recordHash === undefined) { + await this.#moveVerifiedUninstallRecord( + journal, + journal.recordTombstone, + journal.recordPath, + "Managed uninstall record tombstone", + ); + } + await unlink(journalPath); + return "rolled-back"; + } + async #removeIncompleteManaged(journal: ArchiveJournal): Promise { if (journal.managedExistedBefore) { return; @@ -1101,6 +1428,12 @@ class StashLifecycleImplementation implements StashLifecycle { if ("kind" in journal && journal.kind === "managed-update") { this.#validateUpdateJournal(journal, journalPath); await this.#recoverUpdateJournal(journal, journalPath); + } else if ( + "kind" in journal && + journal.kind === "managed-uninstall" + ) { + this.#validateUninstallJournal(journal, journalPath); + await this.#recoverUninstallJournal(journal, journalPath); } else { const archiveJournal = journal as ArchiveJournal; this.#validateArchiveJournal(archiveJournal, journalPath); @@ -1313,34 +1646,8 @@ class StashLifecycleImplementation implements StashLifecycle { if (type !== "file") { throw new Error("lifecycle record is not a real file"); } - const parsed = JSON.parse(await readFile(recordPath, "utf8")) as ManagedSkillRecord; - if ( - parsed.schemaVersion !== STORE_SCHEMA_VERSION || - typeof parsed.skillId !== "string" || - parsed.skillId.length === 0 || - parsed.name !== name || - !/^sha256:[0-9a-f]{64}$/iu.test(parsed.treeHash) || - !parsed.source || - (parsed.source.kind !== "local-import" && - parsed.source.kind !== "standalone-archive") || - typeof parsed.source.location !== "string" || - !path.isAbsolute(parsed.source.location) || - typeof parsed.source.importedAt !== "string" || - (parsed.source.updatedAt !== undefined && - typeof parsed.source.updatedAt !== "string") || - !validStoredRemoteProvenance(parsed.source) || - !Array.isArray(parsed.deployments) || - parsed.deployments.some( - (deployment) => - typeof deployment.deploymentId !== "string" || - deployment.skillId !== parsed.skillId || - typeof deployment.targetId !== "string" || - deployment.targetId !== - targetIdentity(deployment) || - deployment.ownership !== "stash" || - !samePath(deployment.path, path.join(deployment.root, parsed.name)), - ) - ) { + const parsed = JSON.parse(await readFile(recordPath, "utf8")) as unknown; + if (!validManagedSkillRecord(parsed, name)) { throw new Error("invalid lifecycle record shape"); } return parsed; @@ -2455,6 +2762,188 @@ class StashLifecycleImplementation implements StashLifecycle { }); } + async uninstall( + request: LifecycleUninstallRequest, + ): Promise { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4, + ); + } + if (record.deployments.length > 0) { + throw new StashError( + "active-deployments", + `Managed skill "${record.name}" still has ${record.deployments.length} tracked deployment(s); deactivate each host target first. If deactivation reports drift, reconcile that host copy before retrying deactivation.`, + 3, + ); + } + const managedPath = path.join(this.#managedRoot, record.name); + const managedType = await pathType(managedPath); + if (managedType !== "missing" && managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" is not a real directory; refusing to uninstall it.`, + 3, + ); + } + const managedExisted = managedType === "directory"; + if (managedExisted) { + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" no longer matches its recorded hash.`, + 3, + ); + } + } + + const operationId = randomUUID(); + const recordPath = this.#recordPath(record.name); + const recordSource = await readFile(recordPath, "utf8"); + const stagingRoot = path.join(this.#metadataRoot(), "staging"); + const journal: ManagedUninstallJournal = { + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "started", + name: record.name, + skillId: record.skillId, + treeHash: record.treeHash, + recordHash: sha256(recordSource), + managedExisted, + managedPath, + recordPath, + treeTombstone: path.join( + stagingRoot, + `uninstall-${operationId}-tree`, + ), + recordTombstone: path.join( + stagingRoot, + `uninstall-${operationId}-record.json`, + ), + createdAt: new Date(this.#now()).toISOString(), + }; + const journalPath = this.#journalPath(operationId); + await this.#writeJournal(journal); + let committed = false; + try { + const commitRecord = await this.#readRecord(record.name); + if ( + !commitRecord || + !isDeepStrictEqual(commitRecord, record) || + sha256(await readFile(recordPath, "utf8")) !== journal.recordHash + ) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3, + ); + } + const commitManagedType = await pathType(managedPath); + if (managedExisted) { + if (commitManagedType !== "directory") { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3, + ); + } + const commitSnapshot = await snapshotTree(managedPath); + if (commitSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-version-conflict", + `Managed skill "${record.name}" changed before uninstall could commit.`, + 3, + ); + } + await rename(managedPath, journal.treeTombstone); + await this.#advanceJournal(journal, "tree-tombstoned"); + const movedTreeHash = await this.#journalTreeHash( + journal.treeTombstone, + "Managed uninstall tree tombstone", + ); + if (movedTreeHash !== journal.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${record.name}" changed during uninstall; its tombstone and recovery journal were preserved.`, + 3, + ); + } + } else if (commitManagedType !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" appeared before uninstall could commit.`, + 3, + ); + } + + const finalRecord = await this.#readRecord(record.name); + if ( + !finalRecord || + !isDeepStrictEqual(finalRecord, record) || + sha256(await readFile(recordPath, "utf8")) !== journal.recordHash + ) { + throw new StashError( + "managed-version-conflict", + `Managed metadata for "${record.name}" changed during uninstall.`, + 3, + ); + } + if ((await pathType(managedPath)) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed path for "${record.name}" was repopulated during uninstall.`, + 3, + ); + } + await rename(recordPath, journal.recordTombstone); + await this.#advanceJournal(journal, "record-tombstoned"); + await this.#journalRecordHash( + journal, + journal.recordTombstone, + "Managed uninstall record tombstone", + ); + if ((await pathType(recordPath)) !== "missing") { + throw new StashError( + "managed-version-conflict", + `Managed record for "${record.name}" was repopulated during uninstall.`, + 3, + ); + } + await this.#advanceJournal(journal, "cleanup-authorized"); + committed = true; + } catch (error) { + if (!committed) { + await this.#recoverUninstallJournal(journal, journalPath); + } + throw error; + } + + let warning = managedExisted + ? undefined + : "The managed copy was already missing; its lifecycle record was removed."; + try { + await this.#recoverUninstallJournal(journal, journalPath); + } catch (error) { + const cleanupWarning = `Uninstall committed, but verified cleanup remains for recovery: ${String(error)}`; + warning = warning ? `${warning} ${cleanupWarning}` : cleanupWarning; + } + return { + status: "uninstalled", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + ...(warning ? { warning } : {}), + }; + }); + } + async status( request: LifecycleStatusRequest = {}, ): Promise { diff --git a/src/types.ts b/src/types.ts index c076896..44a8111 100644 --- a/src/types.ts +++ b/src/types.ts @@ -342,6 +342,10 @@ export interface LifecycleDeactivateRequest { target: LifecycleHostTarget; } +export interface LifecycleUninstallRequest { + name: string; +} + export interface LifecycleStatusRequest { name?: string; } @@ -351,6 +355,7 @@ export interface LifecycleMutationResult { | "stored" | "deployed" | "deactivated" + | "uninstalled" | "already-stored" | "already-deployed" | "updated" @@ -411,6 +416,9 @@ export interface StashLifecycle { deactivate( request: LifecycleDeactivateRequest, ): Promise; + uninstall( + request: LifecycleUninstallRequest, + ): Promise; status(request?: LifecycleStatusRequest): Promise; } diff --git a/tests-dist/cli.test.mjs b/tests-dist/cli.test.mjs index 94e1e0f..e514c95 100644 --- a/tests-dist/cli.test.mjs +++ b/tests-dist/cli.test.mjs @@ -193,6 +193,45 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s ); assert.equal(deactivated.status, "deactivated"); await assert.rejects(access(path.join(hostRoot, "rare-skill"))); + + const uninstalled = JSON.parse( + ( + await execFileAsync(process.execPath, [ + bundledCli, + "uninstall", + "rare-skill", + ...common, + ], { env: cliEnvironment }) + ).stdout, + ); + assert.equal(uninstalled.status, "uninstalled"); + await assert.rejects(access(path.join(managedRoot, "rare-skill"))); + + await execFileAsync(process.execPath, [ + bundledCli, + "install", + source, + ...common, + ], { env: cliEnvironment }); + const { stdout: humanUninstall } = await execFileAsync(process.execPath, [ + bundledCli, + "uninstall", + "rare-skill", + "--managed-root", + managedRoot, + ], { env: cliEnvironment }); + assert.match(humanUninstall, /rare-skill: uninstalled/u); + + await assert.rejects( + execFileAsync(process.execPath, [ + bundledCli, + "uninstall", + "rare-skill", + "--force", + ...common, + ], { env: cliEnvironment }), + (error) => error.code === 2 && /Unknown option.*--force/u.test(error.stderr), + ); }); test("human output preserves URL-only source attribution", async () => { @@ -301,6 +340,7 @@ test("npm package entrypoints match the compiled layout", async () => { assert.match(stdout, /stash exact /u); assert.match(stdout, /stash install /u); assert.match(stdout, /stash update /u); + assert.match(stdout, /stash uninstall /u); assert.match(stdout, /--source /u); const { stdout: flagHelp } = await execFileAsync(process.execPath, [ diff --git a/tests/stash-lifecycle.test.ts b/tests/stash-lifecycle.test.ts index 408bbe5..926c9b8 100644 --- a/tests/stash-lifecycle.test.ts +++ b/tests/stash-lifecycle.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync, writeFileSync } from "node:fs"; import { access, + cp, mkdtemp, mkdir, readdir, @@ -9,6 +10,7 @@ import { rename, rm, symlink, + unlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -23,6 +25,7 @@ import { import { createStashLifecycle as createStashLifecycleForCurrentHome, } from "../src/stash-lifecycle.js"; +import { sha256 } from "../src/internal/util.js"; import type { CreateStashLifecycleOptions, ManagedSkillRecord, @@ -136,6 +139,393 @@ test("install creates a searchable inactive canonical copy without changing sour assert.equal(repeated.status, "already-stored"); }); +test("uninstall removes only a verified inactive managed copy", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + + const uninstalled = await lifecycle.uninstall({ name: "rare-skill" }); + + assert.equal(uninstalled.status, "uninstalled"); + assert.equal(uninstalled.skillId, installed.skillId); + assert.equal(uninstalled.treeHash, installed.treeHash); + await assert.rejects(access(installed.managedPath)); + await assert.rejects( + access( + path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ), + ), + ); + await access(path.join(fixture.sourceRoot, "SKILL.md")); + assert.equal( + (await lifecycle.status({ name: "rare-skill" })).status, + "not-found", + ); +}); + +test("uninstall refuses tracked deployments and managed drift without mutation", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const target = { host: "codex" as const, scope: "user" as const }; + await lifecycle.activate({ name: "rare-skill", target }); + + await assert.rejects( + lifecycle.uninstall({ name: "rare-skill" }), + (error: unknown) => + error instanceof StashError && error.code === "active-deployments", + ); + await access(installed.managedPath); + + await lifecycle.deactivate({ name: "rare-skill", target }); + await writeFile( + path.join(installed.managedPath, "references", "guide.md"), + "user changed managed content\n", + "utf8", + ); + await assert.rejects( + lifecycle.uninstall({ name: "rare-skill" }), + (error: unknown) => + error instanceof StashError && error.code === "managed-drift", + ); + await access(installed.managedPath); + await access( + path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ), + ); +}); + +test("uninstall removes stale metadata when the managed copy is already missing", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const recordPath = path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ); + await rm(installed.managedPath, { recursive: true, force: false }); + + const uninstalled = await lifecycle.uninstall({ name: "rare-skill" }); + + assert.equal(uninstalled.status, "uninstalled"); + assert.match(uninstalled.warning ?? "", /already missing/u); + await assert.rejects(access(recordPath)); + await access(path.join(fixture.sourceRoot, "SKILL.md")); +}); + +test("uninstall preserves linked and non-directory managed paths", async () => { + for (const replacement of ["file", "link"] as const) { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const preserved = path.join(fixture.base, `preserved-${replacement}`); + await rename(installed.managedPath, preserved); + if (replacement === "file") { + await writeFile(installed.managedPath, "do not delete\n", "utf8"); + } else { + await symlink( + preserved, + installed.managedPath, + process.platform === "win32" ? "junction" : "dir", + ); + } + + await assert.rejects( + lifecycle.uninstall({ name: "rare-skill" }), + (error: unknown) => + error instanceof StashError && error.code === "managed-drift", + ); + await access(installed.managedPath); + await access( + path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ), + ); + } +}); + +test("uninstall recovery restores before commit and finishes after commit", async () => { + for (const stage of ["record-tombstoned", "cleanup-authorized"] as const) { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const operationId = + stage === "cleanup-authorized" + ? "22222222-2222-4222-8222-222222222222" + : "11111111-1111-4111-8111-111111111111"; + const stagingRoot = path.join(fixture.managedRoot, ".stash", "staging"); + const journalPath = path.join( + fixture.managedRoot, + ".stash", + "journal", + `${operationId}.json`, + ); + const recordPath = path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ); + const treeTombstone = path.join( + stagingRoot, + `uninstall-${operationId}-tree`, + ); + const recordTombstone = path.join( + stagingRoot, + `uninstall-${operationId}-record.json`, + ); + const recordSource = await readFile(recordPath, "utf8"); + await rename(installed.managedPath, treeTombstone); + await rename(recordPath, recordTombstone); + if (stage === "cleanup-authorized") { + await rm(path.join(treeTombstone, "references", "guide.md"), { + force: false, + }); + } + await writeFile( + journalPath, + `${JSON.stringify({ + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage, + name: "rare-skill", + skillId: installed.skillId, + treeHash: installed.treeHash, + recordHash: sha256(recordSource), + managedExisted: true, + managedPath: installed.managedPath, + recordPath, + treeTombstone, + recordTombstone, + createdAt: "2026-08-28T00:00:00.000Z", + })}\n`, + "utf8", + ); + const otherSource = await createStandaloneSkill( + path.join(fixture.base, "other-source"), + "other-skill", + ); + + await lifecycle.install({ source: otherSource }); + + await assert.rejects(access(journalPath)); + await assert.rejects(access(treeTombstone)); + await assert.rejects(access(recordTombstone)); + if (stage === "cleanup-authorized") { + await assert.rejects(access(installed.managedPath)); + await assert.rejects(access(recordPath)); + } else { + await access(installed.managedPath); + await access(recordPath); + } + } +}); + +test("uninstall recovery preserves a tombstone when the record disappears before commit", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const operationId = "33333333-3333-4333-8333-333333333333"; + const recordPath = path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ); + const journalPath = path.join( + fixture.managedRoot, + ".stash", + "journal", + `${operationId}.json`, + ); + const treeTombstone = path.join( + fixture.managedRoot, + ".stash", + "staging", + `uninstall-${operationId}-tree`, + ); + const recordTombstone = path.join( + fixture.managedRoot, + ".stash", + "staging", + `uninstall-${operationId}-record.json`, + ); + const recordSource = await readFile(recordPath, "utf8"); + await rename(installed.managedPath, treeTombstone); + await unlink(recordPath); + await writeFile( + journalPath, + `${JSON.stringify({ + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "tree-tombstoned", + name: "rare-skill", + skillId: installed.skillId, + treeHash: installed.treeHash, + recordHash: sha256(recordSource), + managedExisted: true, + managedPath: installed.managedPath, + recordPath, + treeTombstone, + recordTombstone, + createdAt: "2026-08-28T00:00:00.000Z", + })}\n`, + "utf8", + ); + const otherSource = await createStandaloneSkill( + path.join(fixture.base, "other-source"), + "other-skill", + ); + + await assert.rejects( + lifecycle.install({ source: otherSource }), + (error: unknown) => + error instanceof StashError && + error.code === "lifecycle-recovery-conflict", + ); + await access(journalPath); + await access(treeTombstone); + await assert.rejects(access(installed.managedPath)); +}); + +test("uninstall recovery fails closed for mismatched, occupied, and malformed state", async () => { + for (const [scenario, operationId] of [ + ["mismatched", "44444444-4444-4444-8444-444444444444"], + ["occupied", "55555555-5555-4555-8555-555555555555"], + ["malformed", "66666666-6666-4666-8666-666666666666"], + ] as const) { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const recordPath = path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ); + const journalPath = path.join( + fixture.managedRoot, + ".stash", + "journal", + `${operationId}.json`, + ); + const expectedTreeTombstone = path.join( + fixture.managedRoot, + ".stash", + "staging", + `uninstall-${operationId}-tree`, + ); + const recordTombstone = path.join( + fixture.managedRoot, + ".stash", + "staging", + `uninstall-${operationId}-record.json`, + ); + const recordSource = await readFile(recordPath, "utf8"); + if (scenario === "mismatched") { + await rename(installed.managedPath, expectedTreeTombstone); + await writeFile( + path.join(expectedTreeTombstone, "references", "guide.md"), + "changed after tombstoning\n", + "utf8", + ); + } else if (scenario === "occupied") { + await cp(installed.managedPath, expectedTreeTombstone, { + recursive: true, + }); + } + const treeTombstone = + scenario === "malformed" + ? path.join(fixture.base, "not-operation-owned") + : expectedTreeTombstone; + await writeFile( + journalPath, + `${JSON.stringify({ + schemaVersion: 1, + kind: "managed-uninstall", + operationId, + stage: "tree-tombstoned", + name: "rare-skill", + skillId: installed.skillId, + treeHash: installed.treeHash, + recordHash: sha256(recordSource), + managedExisted: true, + managedPath: installed.managedPath, + recordPath, + treeTombstone, + recordTombstone, + createdAt: "2026-08-28T00:00:00.000Z", + })}\n`, + "utf8", + ); + const otherSource = await createStandaloneSkill( + path.join(fixture.base, "other-source"), + "other-skill", + ); + const expectedCode = + scenario === "malformed" + ? "invalid-lifecycle-journal" + : "lifecycle-recovery-conflict"; + + await assert.rejects( + lifecycle.install({ source: otherSource }), + (error: unknown) => + error instanceof StashError && error.code === expectedCode, + ); + await access(journalPath); + await access(recordPath); + if (scenario !== "malformed") { + await access(expectedTreeTombstone); + } + if (scenario !== "mismatched") { + await access(installed.managedPath); + } + } +}); + test("update transactionally replaces a managed tree while preserving its identity", async () => { const fixture = await lifecycleFixture(); const sourceUrl = "https://github.com/example/rare-skills";