diff --git a/README.md b/README.md index 03853ad..50b3299 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,23 @@ nsolid-plugin setup --harness nsolid-plugin install --harness ``` +### Check and update + +The updater plans changes before executing them and never starts OAuth. Version reporting is read-only: + +```bash +nsolid-plugin version +nsolid-plugin --version +nsolid-plugin update --check +nsolid-plugin update --all --check --json +``` + +The default `update` scope is the globally installed CLI. Use `--harness claude|codex|opencode|antigravity|pi` for one harness or `--all` for the CLI plus every detected installation. Mutating updates require an interactive confirmation or `--yes`; non-interactive automation should use `--yes --json`. + +Ownership remains with each tool. Claude uses its detected plugin ID and scope, Codex refreshes the detected marketplace and transactionally removes/adds that same ID, and Antigravity backs up its staged root and matching import manifest before reinstalling the fixed GitHub source. Pi runs its native unpinned package update once for the detected user/project scopes. OpenCode and tracked fallback installs use the exact published package's internal refresh entrypoint; they do not invoke `opencode plugin`. + +The updater preserves credentials, user-owned configuration, unrelated MCP entries, and native marketplace identities. It refuses ambiguous or unsupported sources, never silently downgrades a CLI newer than the registry, and rolls back owned fallback/Antigravity/Codex state when validation fails. The public `nsolid-plugin install` command and programmatic `install()` behavior remain unchanged. + Without a global install, use `npx -y nsolid-plugin setup --harness ` and `npx -y nsolid-plugin install --harness `. Use direct CLI install as the primary install path for OpenCode. For Claude Code, Codex CLI, and Antigravity CLI, prefer the native plugin commands below and keep `nsolid-plugin install` for fallback or repair. For Pi Agent, skills come from `nsolid-pi-plugin`; the CLI writes Pi MCP config only. @@ -214,10 +231,17 @@ pnpm plugin:sync # Regenerate manifests/conf pnpm plugin:materialize # Copy root skills into the Pi package for pack/release pnpm plugin:root # Refresh root marketplace/plugin manifests from bundle.json pnpm plugin:root:check # Fail if committed root manifests drift from bundle.json +pnpm release:prepare -- patch # Prepare the next release version atomically +pnpm release:check # Check synchronized versions and generated payload +pnpm release:check --release # Also require payload changes to bump the release version ``` Run `pnpm plugin:check` in CI and before release. The source tree keeps one canonical skill copy under root `skills/`; package-local `skills/` directories are materialized only for npm package release and cleaned afterward by package sync scripts. +### Release order + +`release:prepare` only updates and validates local files; it never publishes or creates Git state. After review, run `pnpm release:check`, publish `nsolid-plugin@` first, publish the same-version `nsolid-pi-plugin` second, then commit and push the generated root manifests and the matching semantic Git tag. Run `pnpm plugin:clean` after any interrupted package materialization. The first release that introduces this updater must be bootstrapped manually because an older CLI cannot update itself before the new package and native marketplace payload are published. + ## Troubleshooting ### Run the doctor command diff --git a/openspec/changes/add-update-flow/implementation.md b/openspec/changes/add-update-flow/implementation.md new file mode 100644 index 0000000..1a89102 --- /dev/null +++ b/openspec/changes/add-update-flow/implementation.md @@ -0,0 +1,173 @@ +# Implementation record + +This branch implements the approved `add-update-flow` change from +`cesar/update-flow-spec@5812b0f`. The proposal, design, tasks, and both +normative specs are unchanged. The implementation is recorded here against +the complete branch diff, not only the last corrective pass. + +## Scope and invariants + +- The updater remains a minor, additive feature. Existing installation APIs + and the public `nsolid-plugin install` workflow are preserved. +- Every mutating plan carries an immutable artifact identity: npm registry, + exact version, tarball and integrity, or Git repository, full commit and + content digest. A mutable ref, an ambient registry re-resolution, or an + unsupported harness source produces a non-mutating result. +- Native and fallback installations remain separate plan items. Ownership is + tracked per installation, skill/link path, tracking field, and MCP field; + unrelated user-owned state is never included in a mutation or rollback. +- Fallback recovery is parent-owned and durable. A child process cannot be + the only source of rollback truth. +- `--check` is read-only, JSON stdout contains one valid document, and the + summary exposes the approved exit-code contract (`0`, `1`, `2`). + +## Implemented areas + +### Update domain, sources, and command execution + +- Added `packages/core/src/update/types.ts` with the approved contracts for + versions, targets, ownership, installations, artifact identities, plans, + execute/rollback steps, sanitized errors, results, summaries, commands, + confirmations, context, and strategies. +- Added strict stable-semver parsing/comparison in + `packages/core/src/update/version.ts`. +- Added shell-free, argument-array command execution with bounded output, + executable lookup, timeouts, and sanitized diagnostics in + `packages/core/src/update/command-runner.ts`. +- Added registry, npm tarball/integrity, Git commit/content, and local-source + resolution in `packages/core/src/update/version-source.ts`. Resolution, + execution, and post-update verification use the same frozen identity. +- Added package-manager detection and positive realpath ownership checks in + `packages/core/src/update/package-manager.ts`; unsupported workspace, + `npx`, Volta, Yarn, Bun, mismatched-root, and ambiguous launches do not + mutate. + +### Inventory and existing ownership state + +- Added complete installation discovery, source evidence, deterministic + ordering, target/scope filters, and empty-inventory handling in + `packages/core/src/update/inventory.ts`. +- Extended `packages/core/src/skills/skill-tracker.ts` and + `packages/core/src/skills/skill-linker.ts` with per-installation paths, + ownership, bundle-version compatibility evidence, and safe reconciliation. +- Extended `packages/core/src/mcp/mcp-tracker.ts` with field-level ownership + and digest evidence. +- Updated `packages/core/src/harnesses/pi-plugin-detector.ts` so Pi project + roots, effective settings, source identity, scope, and cache roots can be + captured and revalidated. +- Kept native and fallback records distinct and preserved legacy tracking + without requiring a new public installer contract. + +### Strategies and transactional mutations + +- CLI package updates: `strategies/cli-package.ts` plans exact-version npm or + pnpm operations, uses the positively identified package-manager executable, + verifies the installed package/version, and reports exact rollback guidance. +- Claude native updates: `strategies/claude.ts` uses the detected plugin ID + and installation scope only. +- Codex native updates: `strategies/codex.ts` and + `codex-transaction.ts` refresh the exact detected marketplace/plugin, + snapshot registration, enablement, user fields, and cache, then validate or + restore the transaction without touching neighboring plugins. +- Pi package-owned updates: `strategies/pi.ts` coalesces only matching user + and project scopes, chooses the approved approval mode, sets the captured + project root, and revalidates settings, source, directory identity, and + caches immediately before mutation. +- Antigravity native updates: `strategies/antigravity.ts` and + `antigravity-transaction.ts` operate on one supported staged-root/manifest + pair, use the pinned Git identity, validate both content and registration, + and restore unrelated imports on failure. +- OpenCode/fallback updates: `strategies/fallback.ts`, + `fallback-transaction.ts`, `fallback-journal.ts`, and + `refresh-owned-cli.ts` implement exact-package execution from a verified + tarball, a restrictive transaction manifest, atomic durable `prepared`, + `mutating`, and `committed` journal phases, field-level MCP checks, parent + rollback/recovery, timeout/crash handling, and stale-journal recovery on the + next mutable invocation. +- The private refresh binary is invoked only with `--transaction `; + executable harness-only ownership rediscovery was not introduced. + +### Coordinator, API, and CLI + +- Added `packages/core/src/update/coordinator.ts` for deterministic inventory, + scope validation, one plan item per installation, explicit absent-harness + `none` items, complete execute/rollback plans before confirmation, + check-only short-circuiting, sequential execution, independent failure + isolation, aggregation, and the approved exit-code precedence. +- Added `packages/core/src/update/index.ts` and exports in + `packages/core/src/index.ts` for `getVersionInfo()`, `checkUpdates()`, + `planUpdates()`, and `update()`. +- Extended `packages/core/src/cli.ts` with `version`, bare `--version`, + `update`, `--check`, `--all`, target/ownership validation, confirmation and + non-interactive approval, human-readable output, JSON-only stdout, sanitized + errors, recovery reporting, and manual remove/add guidance. + +### Release, packaging, and documentation + +- Added atomic `scripts/prepare-release.mjs` and read-only + `scripts/check-release-version.mjs`. +- Added the `release:prepare` and `release:check` package scripts in the root, + and registered the private `nsolid-plugin-refresh-owned` binary in + `packages/core/package.json`, while leaving the private root package version + untouched. +- Release checks cover generated artifacts, source/package equality, the full + published payload (including `packages/core/src/**` and + `packages/pi-plugin/index.js`), committed/staged/unstaged/untracked + changes, semantic `X.Y.Z`/`vX.Y.Z` tags, peeled annotated tags, ancestry, + duplicate versions, missing tags, and shallow history. +- Updated `README.md`, `packages/core/README.md`, and + `packages/pi-plugin/README.md` with user, maintainer, automation, rollback, + unsupported-wrapper, scope/trust, publication, and first-release guidance. + +### Branch file inventory + +The branch changes are distributed across the following implementation +surfaces: + +- Update runtime: `packages/core/src/update/{types,version,command-runner, + version-source,package-manager,inventory,coordinator,index, + refresh-owned-cli,fallback-journal,fallback-transaction, + codex-transaction,antigravity-transaction}.ts` and + `packages/core/src/update/strategies/{common,cli-package,claude,codex,pi, + antigravity,fallback}.ts`. +- Existing integration points: `packages/core/src/cli.ts`, + `packages/core/src/index.ts`, `packages/core/src/harnesses/pi-plugin-detector.ts`, + `packages/core/src/mcp/mcp-tracker.ts`, + `packages/core/src/skills/skill-linker.ts`, and + `packages/core/src/skills/skill-tracker.ts`. +- Regression coverage: `packages/core/test/integration/update-flow.test.ts` + and the update unit suites for the command runner, version/source logic, + package-manager ownership, inventory, CLI strategy, Codex, Antigravity, + fallback, and semver behavior. +- Release and package surfaces: `scripts/prepare-release.mjs`, + `scripts/check-release-version.mjs`, root `package.json`, and + `packages/core/package.json`. +- User/maintainer documentation: `README.md`, `packages/core/README.md`, + and `packages/pi-plugin/README.md`. + +## Tests and verification + +The implementation branch was verified with the following successful gates: + +- `openspec validate add-update-flow --strict` +- `pnpm lint` +- `pnpm build` +- `pnpm test:unit` (41/41) +- `pnpm test:integration` (125 tests, 26 suites) +- `pnpm test:marketplace` (all checks) +- `pnpm test` (539 tests, 104 suites) +- `pnpm plugin:check` +- `pnpm release:check` +- package dry-runs for both publishable packages +- `pnpm plugin:sync` cleanup and `git diff --check` + +The resulting commit is `3f7efff` (`feat(update): implement approved update +flow`). The worktree is clean and the approved OpenSpec documents remain +unchanged. + +`pnpm release:check --release` correctly reports that the current payload has +changed since `v1.0.1` without an update-visible version. This is the expected +release gate until `release:prepare` is run for a future release; it is not an +implementation failure. A live upgrade against a newly published candidate is +also intentionally not claimed here because no candidate is currently +available. diff --git a/package.json b/package.json index 47ce2b7..bff3e97 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "plugin:materialize": "node scripts/sync-plugin-assets.mjs --materialize-skills", "plugin:clean": "node scripts/sync-plugin-assets.mjs", "plugin:root": "node scripts/materialize-github-marketplace.mjs", - "plugin:root:check": "node scripts/materialize-github-marketplace.mjs --check" + "plugin:root:check": "node scripts/materialize-github-marketplace.mjs --check", + "release:prepare": "node scripts/prepare-release.mjs", + "release:check": "node scripts/check-release-version.mjs" }, "engines": { "node": ">=22.3.0" diff --git a/packages/core/README.md b/packages/core/README.md index 4f0ba56..b39d8a6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -73,6 +73,17 @@ nsolid-plugin restore --harness claude --list nsolid-plugin restore --harness claude --backup ~/.agents/.config-backup/claude/1234567890.json ``` +Update and version commands are additive to the installer API: + +```bash +nsolid-plugin version +nsolid-plugin update --check +nsolid-plugin update --harness opencode --yes +nsolid-plugin update --all --check --json +``` + +`getVersionInfo()` is synchronous and read-only. `checkUpdates()` performs discovery only; `update()` plans first, asks for confirmation unless `yes: true`, and executes each owned target sequentially. Native harnesses keep their own ownership and source identity. Direct fallback updates use the package-internal refresh binary and path-level tracking; the public `install()` function keeps its existing idempotent behavior. + Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer native GitHub plugin install from the repository root; `install --harness` is a fallback direct installer only. For Pi, install `nsolid-pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for auth followed by `install --harness opencode` to copy user-level skills and write MCP config. ## Config backups diff --git a/packages/core/package.json b/packages/core/package.json index 51d09e0..b023da1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,8 @@ "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "bin": { - "nsolid-plugin": "./dist/src/cli.js" + "nsolid-plugin": "./dist/src/cli.js", + "nsolid-plugin-refresh-owned": "./dist/src/update/refresh-owned-cli.js" }, "exports": { ".": { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 4c6ca0e..fdd4b97 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -5,22 +5,24 @@ import { createInterface } from 'node:readline/promises' import path from 'node:path' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { install, setup, uninstall, logout, doctor, restore } from './index.js' +import { install, setup, uninstall, logout, doctor, restore, executeUpdatePlan, getVersionInfo, planUpdates } from './index.js' import type { AuthConfirmation, HarnessType } from './types.js' import { HARNESS_VALUES } from './types.js' +import type { UpdateConfirmationContext, UpdatePlan, UpdatePlanItem, UpdateSummary } from './update/types.js' import { formatPluginError } from './errors.js' import { listConfigBackups } from './utils/backup.js' import { C, supportsColor } from './utils/format.js' import { createConsoleProgress, silentProgress } from './utils/progress.js' +import { resolvePackageRoot } from './update/version.js' const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) const HARNESS_SPECIFIC_SKILL_HARNESSES = new Set(['opencode']) const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// At runtime the bin is dist/src/cli.js, so __dirname is /dist/src. -// bundle.json and skills/ ship at the package root (per package.json "files"), -// not under dist/ — resolve up two levels to reach the package root. -const CORE_PKG_ROOT = path.resolve(__dirname, '..', '..') +// At runtime the bin is dist/src/cli.js and source execution is src/cli.ts. +// Resolve the nearest directory containing the package and bundle manifests so +// both layouts use the same package root. +const CORE_PKG_ROOT = resolvePackageRoot(__dirname) const REPO_ROOT = path.resolve(CORE_PKG_ROOT, '..', '..') const DEFAULT_SOURCE_ROOT = existsSync(path.join(REPO_ROOT, 'bundle.json')) && existsSync(path.join(REPO_ROOT, 'skills')) ? REPO_ROOT @@ -61,6 +63,8 @@ Commands: logout Forget your stored NodeSource login (removes credentials only) doctor Check installation health for a harness restore Restore a harness MCP config from the latest backup + version Report the CLI and bundled plugin versions + update Check or update the CLI and detected harness installations Options: --harness Target harness (required in non-interactive mode): ${HARNESS_VALUES.join(', ')} @@ -74,6 +78,9 @@ Options: --no-color Disable colored output --quiet Suppress step-by-step progress output (install only) --yes Skip interactive confirmation prompts + --check Report update status without mutating anything + --all Include every detected installation (cannot combine with --harness) + --version Print the CLI and bundled plugin versions (alias for version) --accounts-url Explicit origin-only accounts URL override for setup --help Show this help message @@ -84,6 +91,91 @@ Distribution notes: Auth: only setup/login may open a browser.`) } +function printVersion (json: boolean): void { + const info = getVersionInfo(CORE_PKG_ROOT) + if (json) { + console.log(JSON.stringify(info)) + return + } + console.log(`nsolid-plugin CLI ${info.cliVersion}`) + console.log(`bundled plugin ${info.bundleVersion}`) +} + +async function confirmUpdatePlan (_context: UpdateConfirmationContext, _color: boolean): Promise { + const rl = createPrompt() + try { + const answer = (await rl.question('Apply this update plan? [y/N]: ')).trim().toLowerCase() + return answer === 'y' || answer === 'yes' + } finally { + rl.close() + } +} + +function printUpdatePlan (plan: UpdatePlan, color: boolean): void { + if (plan.items.length === 0) { + process.stderr.write('No installations detected.\n') + return + } + process.stderr.write(plan.checkOnly ? 'Update check:\n' : 'Update plan:\n') + for (const item of plan.items) printUpdatePlanItem(item, color, process.stderr) +} + +function printUpdatePlanItem (item: UpdatePlanItem, color: boolean, output: NodeJS.WritableStream): void { + const paint = (value: string) => color ? C.dim(value) : value + output.write(` ${item.installationId} — ${item.ownership} — ${item.version.status}`) + if (item.version.current && item.version.latest) output.write(` (${item.version.current} → ${item.version.latest})`) + else if (item.version.current) output.write(` (${item.version.current})`) + else if (item.version.latest) output.write(` (latest: ${item.version.latest})`) + output.write(`\n source: ${sourceLabel(item)}\n`) + for (const command of item.manualCommands ?? []) output.write(` ${paint('manual:')} ${command}\n`) + if (item.planningError) { + output.write(` error: ${item.planningError.message}\n`) + return + } + for (const step of item.steps) { + if (step.kind === 'command') output.write(` ${paint('run:')} ${formatCommand(step.command.executable, step.command.args)}\n`) + if (step.kind === 'filesystem') output.write(` ${paint(`${step.operation}:`)} ${step.paths.join(', ')}\n`) + if (step.kind === 'validation') output.write(` ${paint('check:')} ${step.checks.join('; ')}\n`) + } + if (item.rollbackSteps.length > 0) { + output.write(` ${paint('rollback:')}\n`) + for (const step of item.rollbackSteps) { + if (step.kind === 'command') output.write(` ${paint('run:')} ${formatCommand(step.command.executable, step.command.args)}\n`) + if (step.kind === 'filesystem') output.write(` ${paint(`${step.operation}:`)} ${step.paths.join(', ')}\n`) + if (step.kind === 'validation') output.write(` ${paint('check:')} ${step.checks.join('; ')}\n`) + } + } +} + +function printUpdateSummary (summary: UpdateSummary, color: boolean): void { + for (const result of summary.results) { + const version = result.resultingVersion ?? result.latestVersion ?? result.currentVersion + const suffix = version ? ` (${version})` : '' + const error = result.error ? ` — ${result.error.message}` : '' + console.log(`${result.installationId}: ${result.status}${suffix}${error}`) + if (result.rollbackCommand && result.status === 'failed') console.log(` restore: ${result.rollbackCommand}`) + if (result.restartHint && result.status === 'updated') console.log(` ${result.restartHint}`) + } + const counts = Object.entries(summary.counts).filter(([, count]) => count > 0).map(([status, count]) => `${status}=${count}`).join(', ') + console.log(`${color ? C.dim('Summary:') : 'Summary:'} ${counts || 'none'}`) +} + +function sourceLabel (item: UpdatePlanItem): string { + const source = item.source + if (source.kind === 'none') return 'none' + if (source.kind === 'unsupported') return `${source.reason} (${source.source})` + if (source.kind === 'global-package') return `${source.packageManager}: ${source.packageName}` + if (source.kind === 'claude-marketplace') return `${source.pluginId} @ ${source.marketplace} (${source.scope})` + if (source.kind === 'codex-marketplace') return `${source.pluginId} @ ${source.marketplace}` + if (source.kind === 'pi-package') return `${source.spec} (${source.scopes.join(',')})` + if (source.kind === 'antigravity-git') return `${source.url} (${source.layout.kind})` + return `fallback${source.executor ? ` (${source.executor})` : ''}` +} + +function formatCommand (executable: string, args: readonly string[]): string { + return [executable, ...args].map((value) => /[\s"']/.test(value) ? JSON.stringify(value) : value).join(' ') +} + function isInteractive (): boolean { return process.stdin.isTTY === true && process.stderr.isTTY === true } @@ -241,10 +333,18 @@ async function main (): Promise { 'keep-credentials': { type: 'boolean' }, quiet: { type: 'boolean' }, yes: { type: 'boolean' }, + check: { type: 'boolean' }, + all: { type: 'boolean' }, + version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'H' }, }, }) + if (values.version === true) { + printVersion(values.json === true) + return + } + if (values.help || positionals.length === 0) { printUsage() process.exit(values.help ? 0 : 1) @@ -253,6 +353,11 @@ async function main (): Promise { const command = positionals[0] const harness = values.harness as HarnessType | undefined + if (values.all === true && harness) { + console.error('Error: --all cannot be combined with --harness') + process.exit(1) + } + const resolveHarnesses = async (multiple: boolean): Promise => { if (harness && HARNESS_VALUES.includes(harness)) return [harness] if (!harness && isInteractive() && values.yes !== true) return promptForHarnesses(command, multiple) @@ -280,6 +385,36 @@ async function main (): Promise { } switch (command) { + case 'version': { + printVersion(values.json === true) + break + } + case 'update': { + if (harness && !HARNESS_VALUES.includes(harness)) { + console.error(`Error: --harness must be one of: ${HARNESS_VALUES.join(', ')}`) + process.exit(1) + } + const updateOptions = { + harness, + all: values.all === true, + check: values.check === true, + yes: values.yes === true, + json: values.json === true, + verbose: values.verbose === true, + noColor: values['no-color'] === true, + packageRoot: CORE_PKG_ROOT, + confirm: isInteractive() && values.yes !== true + ? (context: UpdateConfirmationContext) => confirmUpdatePlan(context, values['no-color'] !== true && supportsColor(process.stderr)) + : undefined, + } + const plan = await planUpdates(updateOptions) + printUpdatePlan(plan, color) + const summary = await executeUpdatePlan(plan, updateOptions) + if (values.json === true) console.log(JSON.stringify(summary)) + else printUpdateSummary(summary, color) + process.exitCode = summary.exitCode + break + } case 'setup': { if (values['accounts-url']) { process.env.NSOLID_ACCOUNTS_URL = values['accounts-url'] diff --git a/packages/core/src/harnesses/pi-plugin-detector.ts b/packages/core/src/harnesses/pi-plugin-detector.ts index 11d3429..45960ea 100644 --- a/packages/core/src/harnesses/pi-plugin-detector.ts +++ b/packages/core/src/harnesses/pi-plugin-detector.ts @@ -31,7 +31,7 @@ function readPiPackageSourceEntries (settingsPath: string): string[] { .filter((source): source is string => typeof source === 'string' && source.length > 0) } -function packageNameFromNpmSource (source: string): string | null { +export function packageNameFromNpmSource (source: string): string | null { if (!source.startsWith('npm:')) return null const spec = source.slice('npm:'.length) if (spec.startsWith('@')) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c288ade..2425ec9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ import { readTrackingFile, addTrackedSkills, removeTrackedSkills, + setTrackingBundleVersion, } from './skills/skill-tracker.js' import { writeMcpConfig, @@ -357,6 +358,17 @@ export async function install (options: InstallOptions): Promise } result.success = result.errors.length === 0 + if (result.success) { + try { + // Version evidence is meaningful only after every owned install step has + // succeeded. A partial install must remain repairable on the next run. + await setTrackingBundleVersion(bundle.version, logger, options.harness) + } catch (err) { + const pluginErr = toPluginError(err, 'TRACKING_UPDATE_FAILED', { harness: options.harness }) + result.errors.push(`Tracking version update failed: ${pluginErr.message}`) + result.success = false + } + } if (result.success) { if (options.packageOwnedSkills === true) { const mcpCount = result.mcpServersConfigured.length @@ -752,3 +764,26 @@ export type { HarnessType, InstallOptions, InstallResult, DoctorReport, BundleDe export type { LinkResult, LinkStatus } from './skills/skill-linker.js' export type { SkillTrackingEntry, McpTrackingEntry, TrackingData } from './skills/skill-tracker.js' export type { BackupEntry } from './utils/backup.js' +export { checkUpdates, executeUpdatePlan, planUpdates, update } from './update/coordinator.js' +export { readRunningVersionInfo as getVersionInfo } from './update/version.js' +export type { + CommandResult, + CommandRunner, + CommandSpec, + RunningVersionInfo, + UpdateConfirmation, + UpdateError, + UpdateInstallation, + UpdateOptions, + UpdatePlan, + UpdatePlanItem, + UpdateResult, + UpdateSource, + UpdateStatus, + UpdateSummary, + NpmArtifactIdentity, + GitArtifactIdentity, + LocalArtifactIdentity, + ResolvedArtifactIdentity, + FallbackTransactionIdentity, +} from './update/types.js' diff --git a/packages/core/src/mcp/mcp-tracker.ts b/packages/core/src/mcp/mcp-tracker.ts index 5c30067..d29e7e3 100644 --- a/packages/core/src/mcp/mcp-tracker.ts +++ b/packages/core/src/mcp/mcp-tracker.ts @@ -1,9 +1,11 @@ import path from 'node:path' import { existsSync, unlinkSync } from 'node:fs' +import { createHash } from 'node:crypto' import type { HarnessType, Logger } from '../types.js' import type { McpTrackingEntry, TrackingData } from '../skills/skill-tracker.js' import { readTrackingFile, writeTrackingFile } from '../skills/skill-tracker.js' import { getTrackingFilePath, resolveHome } from '../utils/path.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' export type { McpTrackingEntry } from '../skills/skill-tracker.js' @@ -33,12 +35,14 @@ export async function addTrackedMcps ( if (existing) { existing.configPath = path.resolve(resolveHome(entry.configPath)) existing.configuredAt = now + existing.fields = readOwnedFieldDigests(existing.configPath, existing.name) } else { tracking.mcpServers.push({ name: entry.name, configPath: path.resolve(resolveHome(entry.configPath)), harness, configuredAt: now, + fields: readOwnedFieldDigests(path.resolve(resolveHome(entry.configPath)), entry.name), }) } } @@ -46,6 +50,34 @@ export async function addTrackedMcps ( await writeTrackingFile(tracking, logger) } +function readOwnedFieldDigests (configPath: string, name: string): Record | undefined { + try { + const raw = configPath.endsWith('.toml') + ? readTomlFile>(configPath) + : configPath.endsWith('.jsonc') + ? readJsoncFile>(configPath) + : readJsonFile>(configPath) + if (!raw) return undefined + const servers = (raw.mcpServers ?? raw.mcp_servers ?? raw.mcp) as unknown + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return undefined + const server = (servers as Record)[name] + if (!server || typeof server !== 'object' || Array.isArray(server)) return undefined + return Object.fromEntries(Object.entries(server as Record).map(([field, value]) => [field, digest(value)])) + } catch { return undefined } +} + +function digest (value: unknown): string { + return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') +} + +function stableValue (value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) + } + return value +} + export async function removeTrackedMcps ( serverNames: string[], harness?: HarnessType, diff --git a/packages/core/src/skills/skill-linker.ts b/packages/core/src/skills/skill-linker.ts index 18715eb..d0ca274 100644 --- a/packages/core/src/skills/skill-linker.ts +++ b/packages/core/src/skills/skill-linker.ts @@ -1,4 +1,4 @@ -import { symlink, readlink, lstat, rm, rename, cp, access } from 'node:fs/promises' +import { symlink, readlink, lstat, rm, rename, cp } from 'node:fs/promises' import path from 'node:path' import type { HarnessType, Logger, SkillRef } from '../types.js' import { getSkillsDir } from '../utils/path.js' @@ -60,7 +60,10 @@ export async function unlinkSkillsFromHarness ( const safeName = assertSafeSkillName(skill.name) const target = path.join(harnessDir, safeName) try { - await access(target) + // lstat also finds dangling symlinks. access() follows the link and + // treated a removed shared skill as missing, leaving stale harness links + // behind after a fallback refresh. + await lstat(target) logger?.debug('skills.unlink', { harness, skill: skill.name, target }) await rm(target, { recursive: true, force: true }) } catch (err) { diff --git a/packages/core/src/skills/skill-tracker.ts b/packages/core/src/skills/skill-tracker.ts index d111f8c..38230db 100644 --- a/packages/core/src/skills/skill-tracker.ts +++ b/packages/core/src/skills/skill-tracker.ts @@ -19,19 +19,30 @@ export interface McpTrackingEntry { configPath: string; harness: string; configuredAt: string; + /** SHA-256 evidence for each NodeSource-owned field in the server object. */ + fields?: Record; } export interface TrackingData { version: string; installedAt: string; harness: string; + /** Version of the bundle used by the last owned refresh, when known. */ + bundleVersion?: string; + /** Version evidence keyed by the fallback harness that was refreshed. */ + bundleVersions?: Partial>; skills: SkillTrackingEntry[]; mcpServers: McpTrackingEntry[]; } export async function readTrackingFile (logger?: Logger): Promise { try { - return readJsonFile(getTrackingFilePath()) + const value = readJsonFile(getTrackingFilePath()) + if (!isValidTrackingData(value)) { + logger?.warn('tracking.read.invalid', { path: getTrackingFilePath() }) + return null + } + return value } catch (err) { logger?.warn('tracking.read.failed', { error: (err as Error).message }) return null @@ -50,6 +61,25 @@ export async function writeTrackingFile (data: TrackingData, logger?: Logger): P } } +export async function setTrackingBundleVersion (bundleVersion: string, logger?: Logger, harness?: HarnessType): Promise { + const tracking = await readTrackingFile(logger) + if (!tracking) return + tracking.bundleVersion = bundleVersion + if (harness) tracking.bundleVersions = { ...(tracking.bundleVersions ?? {}), [harness]: bundleVersion } + await writeTrackingFile(tracking, logger) +} + +export function isValidTrackingData (value: unknown): value is TrackingData { + if (!isRecord(value) || typeof value.version !== 'string' || typeof value.installedAt !== 'string' || typeof value.harness !== 'string') return false + if (!Array.isArray(value.skills) || !Array.isArray(value.mcpServers)) return false + if (value.bundleVersion !== undefined && typeof value.bundleVersion !== 'string') return false + if (value.bundleVersions !== undefined) { + if (!isRecord(value.bundleVersions) || Object.values(value.bundleVersions).some((version) => typeof version !== 'string')) return false + } + if (value.skills.some((entry) => !isValidSkillTrackingEntry(entry))) return false + return !value.mcpServers.some((entry) => !isValidMcpTrackingEntry(entry)) +} + export async function addTrackedSkills ( skills: SkillRef[], harness: HarnessType, @@ -145,3 +175,18 @@ function createEmptyTracking (harness: HarnessType): TrackingData { mcpServers: [], } } + +function isValidSkillTrackingEntry (value: unknown): value is SkillTrackingEntry { + if (!isRecord(value) || typeof value.name !== 'string' || typeof value.path !== 'string' || typeof value.installedAt !== 'string' || !Array.isArray(value.harnesses)) return false + if (value.harnesses.some((harness) => typeof harness !== 'string')) return false + if (value.paths !== undefined && (!isRecord(value.paths) || Object.values(value.paths).some((entry) => typeof entry !== 'string'))) return false + return true +} + +function isValidMcpTrackingEntry (value: unknown): value is McpTrackingEntry { + return isRecord(value) && typeof value.name === 'string' && typeof value.configPath === 'string' && typeof value.harness === 'string' && typeof value.configuredAt === 'string' && (value.fields === undefined || (isRecord(value.fields) && Object.values(value.fields).every((field) => typeof field === 'string'))) +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/core/src/update/antigravity-transaction.ts b/packages/core/src/update/antigravity-transaction.ts new file mode 100644 index 0000000..a0d05bf --- /dev/null +++ b/packages/core/src/update/antigravity-transaction.ts @@ -0,0 +1,170 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { resolveHome } from '../utils/path.js' +import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' +import { isCommandSuccessful } from './command-runner.js' +import { isStableVersion } from './version.js' +import { createHash } from 'node:crypto' + +export interface AntigravityTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +export async function executeAntigravityTransaction ( + item: UpdatePlanItem, + commandRunner: CommandRunner +): Promise { + if (item.source.kind !== 'antigravity-git') { + return { success: false, rollbackAttempted: false, error: { code: 'INVALID_ANTIGRAVITY_SOURCE', message: 'Antigravity source is not the fixed GitHub root' } } + } + const pluginRoot = resolveHome(item.source.layout.pluginRoot) + const manifestPath = resolveHome(item.source.layout.manifestPath) + const backupDir = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-agy-')) + const rootBackup = path.join(backupDir, 'plugin') + const manifestBackup = path.join(backupDir, 'manifest.json') + const rootExisted = existsSync(pluginRoot) + const manifestExisted = existsSync(manifestPath) + let rootBackupComplete = !rootExisted + let manifestBackupComplete = !manifestExisted + let backupsComplete = false + let mutationStarted = false + let rollbackAttempted = false + + try { + // Do not enter rollback handling until every original asset has a complete + // backup. A failed recursive copy may leave rootBackup present but + // incomplete; treating mere existence as proof would destroy the live AGY + // plugin while restoring a partial tree. + try { + if (rootExisted) { + await cp(pluginRoot, rootBackup, { recursive: true, force: true }) + rootBackupComplete = true + } + if (manifestExisted) { + await writeFile(manifestBackup, await readFile(manifestPath), { mode: 0o600 }) + manifestBackupComplete = true + } + backupsComplete = rootBackupComplete && manifestBackupComplete + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'ANTIGRAVITY_BACKUP_FAILED', message: 'Antigravity plugin or import manifest backup could not be completed' }, + } + } + + mutationStarted = true + for (const step of item.steps) { + if (step.kind !== 'command') continue + const result = await commandRunner.run(step.command) + if (!isCommandSuccessful(result)) { + rollbackAttempted = true + const rollbackSucceeded = await restore(rootBackup, manifestBackup, pluginRoot, manifestPath, rootExisted, manifestExisted, rootBackupComplete, manifestBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: result.spawnErrorCode === 'ENOENT' + ? { code: 'MISSING_EXECUTABLE', message: 'agy executable was not found on PATH' } + : { code: result.timedOut ? 'ANTIGRAVITY_COMMAND_TIMEOUT' : 'ANTIGRAVITY_COMMAND_FAILED', message: 'Antigravity plugin replacement command failed' }, + } + } + } + + if (!validateStagedPlugin(pluginRoot, manifestPath, item.version.latest, item.artifact?.kind === 'git' ? item.artifact.contentDigest : undefined)) { + rollbackAttempted = true + const rollbackSucceeded = await restore(rootBackup, manifestBackup, pluginRoot, manifestPath, rootExisted, manifestExisted, rootBackupComplete, manifestBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'ANTIGRAVITY_VALIDATION_FAILED', message: 'Antigravity staged plugin or import manifest did not validate' }, + } + } + return { success: true, rollbackAttempted: false } + } catch { + rollbackAttempted = backupsComplete && mutationStarted + const rollbackSucceeded = rollbackAttempted + ? await restore(rootBackup, manifestBackup, pluginRoot, manifestPath, rootExisted, manifestExisted, rootBackupComplete, manifestBackupComplete) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: rollbackAttempted ? 'ANTIGRAVITY_TRANSACTION_FAILED' : 'ANTIGRAVITY_BACKUP_FAILED', + message: rollbackAttempted ? 'Antigravity replacement transaction failed' : 'Antigravity backup phase did not complete', + }, + } + } finally { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } +} + +export function validateStagedPlugin (pluginRoot: string, manifestPath: string, expectedVersion?: string, expectedDigest?: string): boolean { + if (!existsSync(path.join(pluginRoot, 'plugin.json'))) return false + if (!existsSync(path.join(pluginRoot, 'bundle.json'))) return false + if (!existsSync(path.join(pluginRoot, 'skills'))) return false + try { + const plugin = JSON.parse(readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8')) as unknown + if (!plugin || typeof plugin !== 'object') return false + const bundle = JSON.parse(readFileSync(path.join(pluginRoot, 'bundle.json'), 'utf8')) as { version?: unknown; skills?: Array<{ name?: unknown; path?: unknown }> } + if (expectedVersion !== undefined && (!isStableVersion(bundle.version) || bundle.version !== expectedVersion)) return false + if (expectedDigest && createHash('sha256').update(readFileSync(path.join(pluginRoot, 'bundle.json'))).digest('hex') !== expectedDigest) return false + if (!Array.isArray(bundle.skills) || bundle.skills.length === 0) return false + for (const skill of bundle.skills) { + if (typeof skill.name !== 'string' || typeof skill.path !== 'string') return false + if (path.isAbsolute(skill.path) || skill.path.split(/[\\/]+/).includes('..')) return false + if (!existsSync(path.join(pluginRoot, skill.path, 'SKILL.md'))) return false + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { imports?: unknown } + if (Array.isArray(manifest.imports)) return manifest.imports.some((entry) => isPluginImport(entry)) + if (manifest.imports && typeof manifest.imports === 'object') { + return Object.entries(manifest.imports as Record).some(([key, value]) => + key.includes('nsolid-plugin') || isPluginImport(value)) + } + return false + } catch { + return false + } +} + +function isPluginImport (entry: unknown): boolean { + if (!entry || typeof entry !== 'object') return false + const value = entry as { name?: unknown; plugin?: unknown } + return value.name === 'nsolid-plugin' || value.plugin === 'nsolid-plugin' +} + +async function restore ( + rootBackup: string, + manifestBackup: string, + pluginRoot: string, + manifestPath: string, + rootExisted: boolean, + manifestExisted: boolean, + rootBackupComplete: boolean, + manifestBackupComplete: boolean +): Promise { + try { + if (!rootBackupComplete || !manifestBackupComplete) return false + if (rootExisted) { + await rm(pluginRoot, { recursive: true, force: true }) + await cp(rootBackup, pluginRoot, { recursive: true, force: true }) + } else { + await rm(pluginRoot, { recursive: true, force: true }) + } + if (manifestExisted) await writeFile(manifestPath, await readFile(manifestBackup), { mode: 0o600 }) + else await rm(manifestPath, { force: true }) + const rootRestored = rootExisted ? existsSync(pluginRoot) : !existsSync(pluginRoot) + const manifestRestored = manifestExisted ? existsSync(manifestPath) : !existsSync(manifestPath) + if (!rootRestored || !manifestRestored) return false + return rootExisted && manifestExisted ? validateStagedPlugin(pluginRoot, manifestPath) : true + } catch { + return false + } +} diff --git a/packages/core/src/update/codex-transaction.ts b/packages/core/src/update/codex-transaction.ts new file mode 100644 index 0000000..d451cf4 --- /dev/null +++ b/packages/core/src/update/codex-transaction.ts @@ -0,0 +1,478 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { createHash } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { CommandRunner, UpdateError, UpdatePlanItem } from './types.js' +import { isCommandSuccessful } from './command-runner.js' +import { resolveHome } from '../utils/path.js' +import { readTomlFile, writeTomlFileSync } from '../utils/config.js' +import { compareVersions, isStableVersion } from './version.js' + +export interface CodexTransactionResult { + success: boolean + rollbackAttempted: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +export async function executeCodexTransaction ( + item: UpdatePlanItem, + commandRunner: CommandRunner +): Promise { + const configPath = path.resolve(item.metadata?.configPath ?? item.metadata?.trackedMcpConfigPath ?? resolveHome('~/.codex/config.toml')) + const pluginId = item.source.kind === 'codex-marketplace' ? item.source.pluginId : undefined + const cachePath = pluginId + ? resolveCodexPluginCachePath( + configPath, + pluginId, + item.source.kind === 'codex-marketplace' ? item.source.marketplace : undefined, + item.metadata?.packageRoot + ) + : item.metadata?.packageRoot + if (!cachePath) { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_CACHE_NOT_FOUND', message: 'The exact Codex plugin cache directory could not be identified safely' }, + } + } + + const backupDir = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-codex-')) + const backupPath = path.join(backupDir, 'config.toml') + const cacheBackup = path.join(backupDir, 'cache') + const originalPlugin = pluginId ? readCodexPlugin(configPath, pluginId) : undefined + const configExisted = existsSync(configPath) + const cacheExisted = existsSync(cachePath) + let configBackupComplete = !configExisted + let cacheBackupComplete = !cacheExisted + let backupsComplete = false + let mutationStarted = false + let rollbackAttempted = false + let removalCompleted = false + + try { + // Backup is a separate phase. If a recursive copy fails after creating a + // partial tree, that tree is not a valid rollback source and must never be + // used to replace the untouched live cache. + try { + if (configExisted) { + await writeFile(backupPath, await readFile(configPath), { mode: 0o600 }) + configBackupComplete = true + } + if (cacheExisted) { + await cp(cachePath, cacheBackup, { recursive: true, force: true }) + cacheBackupComplete = true + } + backupsComplete = configBackupComplete && cacheBackupComplete + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'CODEX_BACKUP_FAILED', message: 'Codex configuration or plugin cache backup could not be completed' }, + } + } + + mutationStarted = true + for (const step of item.steps) { + if (step.kind !== 'command') continue + const result = await commandRunner.run(step.command) + if (!isCommandSuccessful(result)) { + rollbackAttempted = removalCompleted || step.command.args.includes('remove') + const rollbackSucceeded = rollbackAttempted + ? await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: result.spawnErrorCode === 'ENOENT' ? 'MISSING_EXECUTABLE' : result.timedOut ? 'CODEX_COMMAND_TIMEOUT' : 'CODEX_COMMAND_FAILED', + message: result.spawnErrorCode === 'ENOENT' ? 'codex executable was not found on PATH' : `Codex command ${step.command.args[0] ?? 'operation'} failed`, + }, + } + } + if (step.command.args.includes('remove')) removalCompleted = true + } + + const refreshedPlugin = pluginId ? readCodexPlugin(configPath, pluginId) : undefined + if (pluginId && !refreshedPlugin) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex did not recreate the selected plugin registration' }, + } + } + + if (pluginId && item.version.latest && refreshedPlugin) { + // Codex's normal registration contains enablement/source fields, not a + // version. Validate the payload selected by the recreated registration, + // rather than accepting the expected version elsewhere in the cache. + const selectedPayload = resolveRegisteredPayloadPath(configPath, refreshedPlugin) + const cachedVersion = selectedPayload + ? readDirectCodexPayloadVersion(selectedPayload, pluginId) + : readCodexPayloadVersion(cachePath, pluginId) + if (cachedVersion !== item.version.latest) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_VERSION_MISMATCH', message: 'Reinstalled Codex cached payload did not match the refreshed marketplace version' }, + } + } + if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot')) { + const versionSource = item.source.kind === 'codex-marketplace' ? item.source.versionSource : undefined + const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined + const digest = selectedPayload ? payloadDigest(selectedPayload, manifestPath) : undefined + if (!selectedPayload || !digest || digest !== item.artifact.contentDigest) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_CONTENT_MISMATCH', message: 'Reinstalled Codex payload did not match the planned content identity' }, + } + } + } + } + + if (pluginId) { + const restoredUserFields = originalPlugin + ? restoreUserOwnedFields(configPath, pluginId, originalPlugin) + : true + const restoredPlugin = readCodexPlugin(configPath, pluginId) + if (!restoredPlugin || (originalPlugin !== undefined && !userOwnedFieldsMatch(restoredPlugin, originalPlugin))) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex did not recreate the selected plugin registration and its preserved fields' }, + } + } + if (!restoredUserFields) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_REGISTRATION_MISSING', message: 'Codex plugin registration could not preserve its user-owned fields' }, + } + } + } + + const validation = item.steps.find((step) => step.kind === 'validation') + if (validation && (!existsSync(configPath) || (pluginId !== undefined && !readCodexPlugin(configPath, pluginId)))) { + rollbackAttempted = true + const rollbackSucceeded = await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { code: 'CODEX_VALIDATION_FAILED', message: 'Codex configuration was not present after reinstall' }, + } + } + return { success: true, rollbackAttempted: false } + } catch { + rollbackAttempted = mutationStarted && backupsComplete + const rollbackSucceeded = rollbackAttempted + ? await restoreFiles(backupPath, cacheBackup, configPath, cachePath, configExisted, cacheExisted, configBackupComplete, cacheBackupComplete) + : undefined + return { + success: false, + rollbackAttempted, + rollbackSucceeded, + error: { + code: rollbackAttempted ? 'CODEX_TRANSACTION_FAILED' : 'CODEX_BACKUP_FAILED', + message: rollbackAttempted ? 'Codex replacement transaction failed' : 'Codex backup phase did not complete', + }, + } + } finally { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } +} + +/** Read version evidence from the refreshed Codex cache, never from config.toml. */ +export function readCodexPayloadVersion (cachePath: string, pluginId: string): string | undefined { + return readCodexPayloadVersions(cachePath, pluginId).sort(compareVersions).at(-1) +} + +export function readCodexPayloadVersions (cachePath: string, pluginId: string): string[] { + const pluginName = pluginId.split('@', 1)[0] + const candidates: string[] = [] + collectPayloadManifests(cachePath, 0, candidates) + return versionsFromManifests(candidates, pluginName) +} + +function readDirectCodexPayloadVersion (cachePath: string, pluginId: string): string | undefined { + return versionsFromManifests(directPayloadManifests(cachePath), pluginId.split('@', 1)[0]).sort(compareVersions).at(-1) +} + +function versionsFromManifests (candidates: string[], pluginName: string): string[] { + const versions: string[] = [] + + for (const filePath of candidates) { + let value: unknown + try { value = JSON.parse(readFileSync(filePath, 'utf8')) as unknown } catch { continue } + if (!isPayloadForPlugin(value, pluginName)) continue + const object = value as Record + const metadata = object.metadata + const nestedPlugin = object.plugin + const version = [ + object.version, + object.pluginVersion, + object.bundleVersion, + isRecord(metadata) ? metadata.version : undefined, + isRecord(nestedPlugin) ? nestedPlugin.version : undefined, + ].find(isStableVersion) + if (version && !versions.includes(version)) versions.push(version) + } + return versions +} + +function collectPayloadManifests (root: string, depth: number, output: string[]): void { + if (depth > 4 || output.length >= 256) return + let entries + try { entries = readdirSync(root, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + if (output.length >= 256) return + const filePath = path.join(root, entry.name) + if (entry.isDirectory()) collectPayloadManifests(filePath, depth + 1, output) + else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) output.push(filePath) + } +} + +function directPayloadManifests (root: string): string[] { + if (existsSync(root) && !isDirectory(root)) return [root] + return ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'] + .map((name) => path.join(root, name)) + .filter(existsSync) +} + +function isPayloadForPlugin (value: unknown, pluginName: string): boolean { + if (!isRecord(value)) return false + const identityValues = [value.name, value.id, value.pluginId, value.packageName] + const nested = value.plugin + if (isRecord(nested)) identityValues.push(nested.name, nested.id) + return identityValues.some((identity) => typeof identity === 'string' && (identity === pluginName || identity.startsWith(`${pluginName}@`))) +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function readCodexPlugin (configPath: string, pluginId: string): Record | undefined { + try { + const data = readTomlFile>(configPath) + const plugins = data?.plugins + const plugin = plugins && typeof plugins === 'object' && !Array.isArray(plugins) + ? (plugins as Record)[pluginId] + : undefined + return plugin && typeof plugin === 'object' && !Array.isArray(plugin) ? { ...(plugin as Record) } : undefined + } catch { + return undefined + } +} + +function restoreUserOwnedFields (configPath: string, pluginId: string, original: Record): boolean { + try { + const data = readTomlFile>(configPath) + if (!data) return false + const plugins = data.plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false + const current = (plugins as Record)[pluginId] + if (!current || typeof current !== 'object' || Array.isArray(current)) return false + if (userOwnedFieldsMatch(current as Record, original)) return true + const preserved = { ...(current as Record) } + for (const [key, value] of Object.entries(original)) { + if (!['version', 'path', 'installPath', 'cachePath'].includes(key)) preserved[key] = value + } + ;(plugins as Record)[pluginId] = preserved + writeTomlFileSync(configPath, data) + return true + } catch { + return false + } +} + +function resolveRegisteredPayloadPath ( + configPath: string, + plugin: Record +): string | undefined { + const cacheBase = path.resolve(path.dirname(configPath), 'plugins', 'cache') + for (const key of ['path', 'installPath', 'cachePath']) { + const configured = plugin[key] + if (typeof configured !== 'string' || configured.length === 0) continue + const candidates = path.isAbsolute(configured) + ? [path.resolve(configured)] + : [path.resolve(cacheBase, configured), path.resolve(path.dirname(configPath), configured)] + const selected = candidates.find((candidate) => isSameOrContained(candidate, cacheBase) && existsSync(candidate)) + if (selected) return selected + return undefined + } + return undefined +} + +function isDirectory (filePath: string): boolean { + try { return readdirSync(filePath).length >= 0 } catch { return false } +} + +async function restoreFiles ( + backupPath: string, + cacheBackup: string, + targetPath: string, + cachePath: string, + configExisted: boolean, + cacheExisted: boolean, + configBackupComplete: boolean, + cacheBackupComplete: boolean +): Promise { + try { + if (configExisted && !configBackupComplete) return false + if (cacheExisted && !cacheBackupComplete) return false + if (configBackupComplete && configExisted) await writeFile(targetPath, await readFile(backupPath), { mode: 0o600 }) + else if (!configExisted) await rm(targetPath, { force: true }) + if (cacheBackupComplete && cacheExisted) { + await rm(cachePath, { recursive: true, force: true }) + await cp(cacheBackup, cachePath, { recursive: true, force: true }) + } else if (!cacheExisted) { + await rm(cachePath, { recursive: true, force: true }) + } + const configRestored = configExisted ? configBackupComplete && existsSync(backupPath) && existsSync(targetPath) : !existsSync(targetPath) + const cacheRestored = cacheExisted ? cacheBackupComplete && existsSync(cacheBackup) && existsSync(cachePath) : !existsSync(cachePath) + return configRestored && cacheRestored + } catch { + return false + } +} + +export function resolveCodexPluginCachePath ( + configPath: string, + pluginId: string, + marketplace: string | undefined, + hintedPath: string | undefined +): string | undefined { + const cacheBase = path.resolve(path.dirname(configPath), 'plugins', 'cache') + const pluginName = pluginId.split('@', 1)[0].toLowerCase() + if (hintedPath) { + const candidate = path.resolve(hintedPath) + if (isSameOrContained(candidate, cacheBase) && candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase) && isPluginCacheCandidate(candidate, pluginName)) { + return candidate + } + } + + const directories: string[] = [] + collectDirectories(cacheBase, 0, directories) + const marketplaceKeys = new Set([ + ...(marketplace ? marketplace.split('/').map((part) => part.replace(/\.git$/, '').toLowerCase()) : []), + pluginId.split('@')[1]?.toLowerCase(), + ].filter((value): value is string => typeof value === 'string' && value.length > 0)) + const candidates = directories + .filter((candidate) => candidate !== cacheBase && !isBroadCachePath(candidate, cacheBase)) + .filter((candidate) => isPluginCacheCandidate(candidate, pluginName)) + .map((candidate) => ({ candidate, score: pluginCacheScore(candidate, pluginName, marketplaceKeys) })) + .sort((left, right) => right.score - left.score || pathDepth(left.candidate) - pathDepth(right.candidate)) + const best = candidates[0] + const tied = candidates[1] && candidates[1].score === best?.score && pathDepth(candidates[1].candidate) === pathDepth(best.candidate) + return best && !tied ? best.candidate : undefined +} + +function collectDirectories (root: string, depth: number, output: string[]): void { + if (depth > 5) return + let entries + try { entries = readdirSync(root, { withFileTypes: true }) } catch { return } + output.push(root) + for (const entry of entries) { + if (entry.isDirectory()) collectDirectories(path.join(root, entry.name), depth + 1, output) + } +} + +function isPluginCacheCandidate (candidate: string, pluginName: string): boolean { + const segments = candidate.toLowerCase().split(path.sep) + const basename = path.basename(candidate).toLowerCase() + return basename === pluginName || basename.startsWith(`${pluginName}@`) || segments.includes(pluginName) || readCodexPayloadVersions(candidate, pluginName).length > 0 +} + +function pluginCacheScore (candidate: string, pluginName: string, marketplaceKeys: ReadonlySet): number { + const basename = path.basename(candidate).toLowerCase() + const segments = candidate.toLowerCase().split(path.sep) + const parent = path.basename(path.dirname(candidate)).toLowerCase() + const marketplaceMatched = marketplaceKeys.has(parent) + if (basename === pluginName) return marketplaceMatched ? 220 : 100 + if (basename.startsWith(`${pluginName}@`)) return marketplaceMatched ? 215 : 95 + if (segments.includes(pluginName)) return marketplaceMatched ? 180 : 90 + return 50 +} + +function isBroadCachePath (candidate: string, cacheBase: string): boolean { + const basename = path.basename(candidate).toLowerCase() + return candidate === cacheBase || basename === 'cache' || basename === 'plugins' +} + +function pathDepth (filePath: string): number { + return filePath.split(path.sep).length +} + +function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function userOwnedFieldsMatch (current: Record, original: Record): boolean { + for (const [key, value] of Object.entries(original)) { + if (['version', 'path', 'installPath', 'cachePath'].includes(key)) continue + if (!sameValue(current[key], value)) return false + } + return true +} + +function sameValue (left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue(value, right[index])) + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key])) + } + return false +} + +function payloadDigest (root: string, manifestPath?: string): string | undefined { + try { + if (manifestPath) { + const base = path.resolve(root) + const manifest = path.resolve(base, manifestPath) + if (manifest.startsWith(`${base}${path.sep}`) && existsSync(manifest)) return createHash('sha256').update(readFileSync(manifest)).digest('hex') + } + const directBundle = path.join(root, 'bundle.json') + if (existsSync(directBundle)) return createHash('sha256').update(readFileSync(directBundle)).digest('hex') + const files: string[] = [] + collectDigestFiles(root, 0, files) + if (files.length === 0) return undefined + const hash = createHash('sha256') + for (const file of files.sort()) hash.update(file).update(readFileSync(file)) + return hash.digest('hex') + } catch { return undefined } +} + +function collectDigestFiles (root: string, depth: number, output: string[]): void { + if (depth > 4 || output.length > 256) return + try { + const stats = readdirSync(root, { withFileTypes: true }) + for (const entry of stats) { + const file = path.join(root, entry.name) + if (entry.isDirectory()) collectDigestFiles(file, depth + 1, output) + else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) output.push(file) + } + } catch { + if (existsSync(root)) output.push(root) + } +} diff --git a/packages/core/src/update/command-runner.ts b/packages/core/src/update/command-runner.ts new file mode 100644 index 0000000..684dcdf --- /dev/null +++ b/packages/core/src/update/command-runner.ts @@ -0,0 +1,122 @@ +import { spawn } from 'node:child_process' +import { accessSync, constants } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { CommandResult, CommandRunner, CommandSpec } from './types.js' + +export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000 +export const MAX_COMMAND_OUTPUT = 64 * 1024 + +const SECRET_PATTERNS = [ + /Bearer\s+[A-Za-z0-9._~+/=-]+/gi, + /(["']?(?:authorization|token|password|secret|api[-_]?key)["']?\s*[:=]\s*)[^\s,;"']+/gi, + /https?:\/\/[^\s/@]+:[^\s/@]+@/gi, + /((?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key)\s*[=:]\s*)["']?[^\s,"']+/gi, + /(?:[A-Za-z]:[\\/]|\/)[^\s"']*(?:\.nodesource-auth|credentials?|\.npmrc|token)[^\s"']*/gi, +] + +export function sanitizeOutput (value: string): string { + let result = value.slice(0, MAX_COMMAND_OUTPUT) + result = result.replace(/((["']?(?:authorization|token|password|secret|api[-_]?key)["']?)\s*[:=]\s*)(["'])[^"']*\3/gi, '$1$3[REDACTED]$3') + for (const pattern of SECRET_PATTERNS) { + result = result.replace(pattern, (_match, prefix?: unknown) => typeof prefix === 'string' ? `${prefix}[REDACTED]` : '[REDACTED]') + } + return result +} + +export function createCommandRunner (): CommandRunner { + return { run: runCommand } +} + +export async function runCommand (spec: CommandSpec): Promise { + const timeoutMs = Number.isFinite(spec.timeoutMs) && spec.timeoutMs > 0 + ? spec.timeoutMs + : DEFAULT_COMMAND_TIMEOUT_MS + + return await new Promise((resolve) => { + let stdout = '' + let stderr = '' + let timedOut = false + let settled = false + + const child = spawn(spec.executable, [...spec.args], { + cwd: spec.cwd, + env: { ...process.env, ...(spec.env ?? {}) }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const append = (target: 'stdout' | 'stderr', chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8') + if (target === 'stdout') stdout += text + else stderr += text + if (stdout.length > MAX_COMMAND_OUTPUT) stdout = stdout.slice(0, MAX_COMMAND_OUTPUT) + if (stderr.length > MAX_COMMAND_OUTPUT) stderr = stderr.slice(0, MAX_COMMAND_OUTPUT) + } + + child.stdout?.on('data', (chunk: Buffer | string) => append('stdout', chunk)) + child.stderr?.on('data', (chunk: Buffer | string) => append('stderr', chunk)) + + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + setTimeout(() => { + if (!settled) child.kill('SIGKILL') + }, Math.min(1_000, timeoutMs)) + }, timeoutMs) + + const finish = (exitCode: number | null, signal?: NodeJS.Signals, spawnErrorCode?: string) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ + exitCode, + signal, + spawnErrorCode, + stdout: sanitizeOutput(stdout), + stderr: sanitizeOutput(stderr), + timedOut, + }) + } + + child.once('error', (error: NodeJS.ErrnoException) => { + const message = error.code === 'ENOENT' ? 'executable not found' : 'command could not be started' + stderr += message + finish(null, undefined, error.code) + }) + child.once('exit', (code, signal) => finish(code, signal ?? undefined)) + }) +} + +export function isCommandSuccessful (result: CommandResult): boolean { + return !result.timedOut && result.exitCode === 0 +} + +export function findExecutable (executable: string, env = process.env): string | undefined { + if (!executable || /[\\/]/.test(executable)) { + return isExecutable(executable) ? executable : undefined + } + + const pathValue = env.PATH ?? '' + const candidates = pathValue.split(path.delimiter).filter(Boolean) + const extensions = process.platform === 'win32' + ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') + : [''] + + for (const directory of candidates) { + for (const extension of extensions) { + const candidate = path.join(directory, executable + extension) + if (isExecutable(candidate)) return candidate + } + } + return undefined +} + +function isExecutable (filePath: string): boolean { + try { + accessSync(filePath, os.platform() === 'win32' ? constants.F_OK : constants.X_OK) + return true + } catch { + return false + } +} diff --git a/packages/core/src/update/coordinator.ts b/packages/core/src/update/coordinator.ts new file mode 100644 index 0000000..2da37ae --- /dev/null +++ b/packages/core/src/update/coordinator.ts @@ -0,0 +1,344 @@ +import type { HarnessType } from '../types.js' +import { rm } from 'node:fs/promises' +import path from 'node:path' +import { createCommandRunner } from './command-runner.js' +import { detectCliInstallation, detectInstallations } from './inventory.js' +import { cleanupNpmArtifact, downloadNpmArtifact, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryVersion } from './version-source.js' +import { classifyVersionSet, classifyVersions } from './version.js' +import type { + UpdateContext, + UpdateInstallation, + UpdateOptions, + UpdatePlan, + UpdatePlanItem, + UpdateResult, + UpdateStatus, + UpdateStrategy, + UpdateSummary, + VersionLookupResult, +} from './types.js' +import { planItem, resultFromPlan } from './strategies/common.js' +import { cliPackageStrategy } from './strategies/cli-package.js' +import { claudeStrategy } from './strategies/claude.js' +import { codexStrategy } from './strategies/codex.js' +import { antigravityStrategy } from './strategies/antigravity.js' +import { piStrategy } from './strategies/pi.js' +import { fallbackStrategy } from './strategies/fallback.js' +import { recoverFallbackJournal } from './fallback-journal.js' +import { getTrackingFilePath } from '../utils/path.js' + +const STATUSES: readonly UpdateStatus[] = [ + 'current', + 'update-available', + 'newer-than-registry', + 'updated', + 'skipped', + 'not-installed', + 'unsupported', + 'unknown', + 'failed', +] + +export async function planUpdates (options: UpdateOptions = {}): Promise { + validateScope(options) + const pendingRecovery = await recoverFallbackJournal(getTrackingFilePath(), options.check !== true) + const commandRunner = options.commandRunner ?? createCommandRunner() + const context: UpdateContext = { options, commandRunner } + const detected = await detectInstallations({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + includeCli: options.harness === undefined, + readOnly: options.check === true, + deferCliOwnership: options.check !== true, + }) + const selected = selectInstallations(detected, options) + const withSynthetic = options.harness && selected.length === 0 + ? [syntheticInstallation(options.harness)] + : selected + const items: UpdatePlanItem[] = [] + + if (pendingRecovery.pending && !pendingRecovery.recovered) { + items.push({ + ...planItem({ + installationId: 'fallback:recovery', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback' }, + version: { status: 'unknown' }, + }), + planningError: { code: options.check === true ? 'FALLBACK_RECOVERY_PENDING' : 'FALLBACK_RECOVERY_FAILED', message: options.check === true ? 'A pending fallback transaction requires recovery before the next mutable update' : 'A pending fallback transaction could not be recovered' }, + }) + } + + for (let installation of withSynthetic) { + if (installation.source.kind === 'none') { + items.push({ ...planItem(installation), manualCommands: installationGuidance(installation.target) }) + continue + } + + const lookup = await resolveLatestVersion(installation, options) + const plannedStatus = lookup.version + ? classifyVersions(installation.version.current, lookup.version).status + : installation.version.status + if (options.check !== true && lookup.artifact?.kind === 'npm' && (plannedStatus === 'update-available' || plannedStatus === 'unknown') && !lookup.artifact.tarballPath) { + try { + lookup.artifact = await downloadNpmArtifact(lookup.artifact, { fetchImpl: options.fetchImpl }) + } catch { + items.push(planItem(installation, [], [], undefined, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'Planned registry artifact could not be downloaded or verified' })) + continue + } + } + if ( + installation.target === 'cli' && + options.check !== true && + installation.source.kind === 'unsupported' && + (plannedStatus === 'update-available' || (plannedStatus === 'unknown' && installation.version.status === 'unknown')) + ) { + // Ownership probing is deferred until a mutation is actually possible. + // This keeps current/newer-than-registry paths free of package-manager + // subprocesses while still requiring positive ownership evidence before + // an update command is planned. + installation = await detectCliInstallation({ + commandRunner, + cwd: options.cwd, + packageRoot: options.packageRoot, + includeCli: true, + readOnly: false, + }) + } + const resolved = lookup.version + ? { ...installation, version: classifyInstallationVersion(installation, lookup.version), artifact: lookup.artifact ?? installation.artifact } + : installation + if (lookup.error) { + if (options.check !== true && isMutationUnavailableLookup(lookup.error.code)) { + const unsupported = { + ...resolved, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:immutable-source`, + reason: 'git' as const, + }, + } + items.push({ ...planItem(unsupported), manualCommands: installationGuidance(installation.target) }) + continue + } + items.push(planItem(resolved, [], [], undefined, lookup.error)) + continue + } + // Checks are inventory/version reports. They must not ask a mutation + // strategy to discover an executor, construct commands, or inspect + // writable transaction state. + if (options.check === true) { + items.push(planItem(resolved)) + continue + } + const strategy = strategyFor(resolved) + try { + items.push(await strategy.plan(resolved, context)) + } catch { + items.push(planItem(resolved, [], [], undefined, { code: 'UPDATE_PLAN_FAILED', message: 'Target update plan could not be created' })) + } + } + + return { checkOnly: options.check === true, items } +} + +export async function checkUpdates (options: UpdateOptions = {}): Promise { + const plan = await planUpdates({ ...options, check: true }) + return summarizePlan(plan) +} + +export async function update (options: UpdateOptions = {}): Promise { + const plan = await planUpdates({ ...options, check: false }) + return executeUpdatePlan(plan, options) +} + +export async function executeUpdatePlan (plan: UpdatePlan, options: UpdateOptions = {}): Promise { + if (plan.checkOnly || options.check === true) return summarizePlan(plan) + const commandRunner = options.commandRunner ?? createCommandRunner() + + const planningResults = plan.items.map((item) => item.planningError ? resultFromPlan(item, 'failed', { error: item.planningError }) : undefined) + const mutableItems = plan.items.filter((item) => item.requiresConfirmation) + let approved = options.yes === true + + if (mutableItems.length > 0 && !approved) { + if (!options.confirm) { + const results = plan.items.map((item, index) => planningResults[index] ?? ( + item.requiresConfirmation + ? resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'Pass --yes in non-interactive mode to approve this update' } }) + : resultFromPlan(item, statusForPlan(item, false)) + )) + await Promise.all(plan.items.map(cleanupPlanState)) + return summarizeResults(false, results) + } + approved = await options.confirm({ items: mutableItems }) + } + + const results: UpdateResult[] = [] + for (const item of plan.items) { + if (item.planningError) { + results.push(resultFromPlan(item, 'failed', { error: item.planningError })) + continue + } + if (item.requiresConfirmation && !approved) { + await cleanupPlanState(item) + results.push(resultFromPlan(item, 'skipped', { error: { code: 'CONFIRMATION_REQUIRED', message: 'Update was not approved' } })) + continue + } + if (!item.requiresConfirmation) { + results.push(resultFromPlan(item, statusForPlan(item, false))) + continue + } + try { + results.push(await strategyForPlan(item).execute(item, { options, commandRunner })) + } catch { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + results.push(resultFromPlan(item, 'failed', { error: { code: 'UPDATE_EXECUTION_FAILED', message: 'Update strategy failed' } })) + } + await cleanupPlanState(item) + } + return summarizeResults(false, results) +} + +async function cleanupPlanState (item: UpdatePlanItem): Promise { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + const command = item.steps.find((step) => step.kind === 'command') + const transactionIndex = command?.kind === 'command' ? command.command.args.indexOf('--transaction') : -1 + const manifestPath = transactionIndex >= 0 && command?.kind === 'command' ? command.command.args[transactionIndex + 1] : undefined + if (manifestPath) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) +} + +export function summarizePlan (plan: UpdatePlan): UpdateSummary { + return summarizeResults(plan.checkOnly, plan.items.map((item) => item.planningError + ? resultFromPlan(item, 'failed', { error: item.planningError }) + : resultFromPlan(item, statusForPlan(item, plan.checkOnly)))) +} + +export function summarizeResults (checkOnly: boolean, results: UpdateResult[]): UpdateSummary { + const counts = Object.fromEntries(STATUSES.map((status) => [status, 0])) as Record + for (const result of results) counts[result.status]++ + return { + checkOnly, + results, + counts, + exitCode: checkOnly + ? counts.failed > 0 ? 1 : 0 + : counts.failed > 0 ? 1 : (counts.unsupported > 0 || counts['not-installed'] > 0 || counts.unknown > 0 || results.some((result) => result.error?.code === 'CONFIRMATION_REQUIRED')) ? 2 : 0, + success: (checkOnly ? counts.failed === 0 : counts.failed === 0 && counts.unsupported === 0 && counts['not-installed'] === 0 && counts.unknown === 0 && !results.some((result) => result.error?.code === 'CONFIRMATION_REQUIRED')), + } +} + +function statusForPlan (item: UpdatePlanItem, checkOnly: boolean): UpdateStatus { + if (checkOnly) { + if (!item.installed || item.source.kind === 'none') return 'not-installed' + if (item.source.kind === 'unsupported' && item.target !== 'cli') return 'unsupported' + switch (item.version.status) { + case 'current': return 'current' + case 'update-available': return 'update-available' + case 'newer-than-registry': return 'newer-than-registry' + default: return 'unknown' + } + } + if (item.source.kind === 'unsupported') { + // A CLI that is already current, or newer than the registry, has no + // mutation to authorize and therefore does not need ownership probing. + if (item.target === 'cli' && item.version.status === 'current') return 'current' + if (item.target === 'cli' && item.version.status === 'newer-than-registry') return 'newer-than-registry' + return 'unsupported' + } + if (!item.installed || item.ownership === 'none' || item.source.kind === 'none') return 'not-installed' + switch (item.version.status) { + case 'current': return 'current' + case 'newer-than-registry': return 'newer-than-registry' + case 'update-available': return 'update-available' + default: return 'unknown' + } +} + +function classifyInstallationVersion (installation: UpdateInstallation, latest: string) { + const currents = installation.version.currentVersions + return currents + ? classifyVersionSet(currents, latest) + : classifyVersions(installation.version.current, latest) +} + +function strategyForPlan (item: UpdatePlanItem): UpdateStrategy { + return strategyFor({ target: item.target, ownership: item.ownership, source: item.source }) +} + +function strategyFor (installation: Pick): UpdateStrategy { + if (installation.target === 'cli') return cliPackageStrategy + if (installation.ownership === 'fallback') return fallbackStrategy + switch (installation.target) { + case 'claude': return claudeStrategy + case 'codex': return codexStrategy + case 'antigravity': return antigravityStrategy + case 'pi': return piStrategy + default: return fallbackStrategy + } +} + +async function resolveLatestVersion (installation: UpdateInstallation, options: UpdateOptions): Promise { + const sourceOptions = { + fetchImpl: options.fetchImpl, + registry: process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY, + downloadArtifact: false, + requireImmutable: options.check !== true, + } + switch (installation.source.kind) { + case 'global-package': + return resolveRegistryVersion('nsolid-plugin', sourceOptions) + case 'pi-package': + return resolveRegistryVersion('nsolid-pi-plugin', sourceOptions) + case 'fallback': + return resolveRegistryVersion('nsolid-plugin', sourceOptions) + case 'claude-marketplace': + case 'codex-marketplace': + return resolveMarketplaceVersion(installation.source.versionSource, sourceOptions) + case 'antigravity-git': + return resolveFixedGitBundleVersion(sourceOptions) + case 'unsupported': + if (installation.target === 'cli') return resolveRegistryVersion('nsolid-plugin', sourceOptions) + return {} + case 'none': + return {} + } +} + +function selectInstallations (detected: UpdateInstallation[], options: UpdateOptions): UpdateInstallation[] { + if (options.harness) return detected.filter((installation) => installation.target === options.harness) + if (options.all) return detected + return detected.filter((installation) => installation.target === 'cli') +} + +function syntheticInstallation (harness: HarnessType): UpdateInstallation { + return { + installationId: `${harness}:none`, + target: harness, + ownership: 'none', + installed: false, + source: { kind: 'none' }, + version: { status: 'unknown' }, + } +} + +function installationGuidance (target: UpdateInstallation['target']): readonly string[] { + switch (target) { + case 'claude': return ['claude plugin marketplace add NodeSource/nsolid-plugin', 'claude plugin install nsolid-plugin@nodesource'] + case 'codex': return ['codex plugin marketplace add NodeSource/nsolid-plugin', 'codex plugin add nsolid-plugin@nodesource'] + case 'antigravity': return ['agy plugin install https://github.com/NodeSource/nsolid-plugin.git'] + case 'opencode': return ['nsolid-plugin setup --harness opencode', 'nsolid-plugin install --harness opencode'] + case 'pi': return ['pi install npm:nsolid-pi-plugin', 'nsolid-plugin setup --harness pi'] + case 'cli': return ['npm install --global nsolid-plugin'] + } +} + +function validateScope (options: UpdateOptions): void { + if (options.all && options.harness) throw new Error('Cannot combine --all with --harness') +} + +function isMutationUnavailableLookup (code: string): boolean { + return code === 'IMMUTABLE_SOURCE_UNAVAILABLE' || code === 'SOURCE_CONTENT_MISMATCH' || code === 'INVALID_MARKETPLACE_SOURCE' +} diff --git a/packages/core/src/update/fallback-journal.ts b/packages/core/src/update/fallback-journal.ts new file mode 100644 index 0000000..296dcb6 --- /dev/null +++ b/packages/core/src/update/fallback-journal.ts @@ -0,0 +1,210 @@ +import { createHash } from 'node:crypto' +import { cp, lstat, mkdtemp, open, readFile, readlink, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { FallbackTransactionIdentity } from './types.js' + +export type FallbackJournalPhase = 'prepared' | 'mutating' | 'committed' + +export interface FallbackJournal { + version: 1 + phase: FallbackJournalPhase + manifest: FallbackTransactionIdentity + journalPath: string + snapshotDirectory: string + entries: readonly { path: string; backup: string; existed: boolean; digest?: string }[] +} + +export interface FallbackJournalResult { + journal: FallbackJournal + rollbackSucceeded?: boolean +} + +export function trackingDigest (trackingPath: string): string | undefined { + try { return createHash('sha256').update(readFileSync(trackingPath)).digest('hex') } catch { return undefined } +} + +export function valueDigest (value: unknown): string { + return createHash('sha256').update(JSON.stringify(stableValue(value)) ?? 'undefined').digest('hex') +} + +export function fallbackJournalPath (trackingPath: string): string { + return `${path.resolve(trackingPath)}.update-journal.json` +} + +export async function beginFallbackJournal (manifest: FallbackTransactionIdentity): Promise { + const trackingPath = path.resolve(manifest.trackingPath) + const currentTrackingDigest = trackingDigest(trackingPath) + if (!currentTrackingDigest || currentTrackingDigest !== manifest.trackingDigest) { + throw new Error('FALLBACK_TRACKING_DRIFT') + } + const journalPath = fallbackJournalPath(trackingPath) + const snapshotDirectory = await mkdtemp(path.join(path.dirname(trackingPath), '.nsolid-plugin-update-')) + const paths = [...new Set([ + trackingPath, + ...manifest.ownedSkillPaths, + ...manifest.ownedLinkPaths, + ...manifest.ownedMcpFields.map((field) => field.configPath), + ].map((value) => path.resolve(value)))] + const entries: Array<{ path: string; backup: string; existed: boolean; digest?: string }> = [] + try { + for (const [index, target] of paths.entries()) { + const existed = existsSync(target) + const backup = path.join(snapshotDirectory, String(index)) + const digest = existed ? await pathDigest(target) : undefined + if (existed && !digest) throw new Error(`cannot digest ${target}`) + if (existed) await cp(target, backup, { recursive: true, force: true }) + entries.push({ path: target, backup, existed, digest }) + } + const journal: FallbackJournal = { version: 1, phase: 'prepared', manifest, journalPath, snapshotDirectory, entries } + await writeDurable(journalPath, journal) + return { journal } + } catch (error) { + await rm(snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + throw new Error('FALLBACK_BACKUP_FAILED', { cause: error }) + } +} + +export async function markFallbackJournalMutating (journal: FallbackJournal): Promise { + const updated = { ...journal, phase: 'mutating' as const } + await writeDurable(journal.journalPath, updated) + return updated +} + +export async function commitFallbackJournal (journal: FallbackJournal): Promise { + if (!isSafeJournal(journal)) throw new Error('Invalid fallback journal') + await writeDurable(journal.journalPath, { ...journal, phase: 'committed' }) + await rm(journal.journalPath, { force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }) +} + +export async function restoreFallbackJournal (journal: FallbackJournal): Promise { + if (!isSafeJournal(journal)) return false + try { + for (const entry of journal.entries) { + if (entry.existed) { + await rm(entry.path, { recursive: true, force: true }) + await cp(entry.backup, entry.path, { recursive: true, force: true }) + } else { + await rm(entry.path, { recursive: true, force: true }) + } + } + const valid = await Promise.all(journal.entries.map(async (entry) => { + if (!entry.existed) return !existsSync(entry.path) + if (!existsSync(entry.path) || !entry.digest) return false + return await pathDigest(entry.path) === entry.digest + })).then((values) => values.every(Boolean)) + if (valid) { + await rm(journal.journalPath, { force: true }) + await rm(journal.snapshotDirectory, { recursive: true, force: true }) + } + return valid + } catch { + return false + } +} + +export async function recoverFallbackJournal (trackingPath: string, mutate: boolean): Promise<{ pending: boolean; recovered: boolean }> { + const journalPath = fallbackJournalPath(trackingPath) + if (!existsSync(journalPath)) return { pending: false, recovered: true } + let journal: FallbackJournal + try { journal = JSON.parse(await readFile(journalPath, 'utf8')) as FallbackJournal } catch { return { pending: true, recovered: false } } + if (!isSafeJournal(journal) || journal.journalPath !== journalPath) return { pending: true, recovered: false } + if (!mutate) return { pending: true, recovered: false } + if (journal.phase === 'committed') { + await rm(journal.journalPath, { force: true }).catch(() => {}) + await rm(journal.snapshotDirectory, { recursive: true, force: true }).catch(() => {}) + return { pending: false, recovered: true } + } + const recovered = await restoreFallbackJournal(journal) + return { pending: true, recovered } +} + +async function writeDurable (filePath: string, value: unknown): Promise { + const temporary = `${filePath}.${process.pid}.tmp` + await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) + const handle = await open(temporary, 'r+') + try { await handle.sync() } finally { await handle.close() } + await rename(temporary, filePath) + try { + const directory = await open(path.dirname(filePath), 'r') + await directory.sync() + await directory.close() + } catch { /* directory fsync is unavailable on some platforms */ } +} + +async function pathDigest (target: string): Promise { + try { + const stat = await lstat(target) + const hash = createHash('sha256') + if (stat.isSymbolicLink()) { + hash.update('symlink\0').update(await readlink(target)) + return hash.digest('hex') + } + if (stat.isFile()) { + hash.update('file\0').update(await readFile(target)) + return hash.digest('hex') + } + if (stat.isDirectory()) { + hash.update('directory\0') + const entries = await readdir(target, { withFileTypes: true }) + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const child = path.join(target, entry.name) + hash.update(entry.name).update('\0') + const childDigest = await pathDigest(child) + if (!childDigest) return undefined + hash.update(childDigest) + } + return hash.digest('hex') + } + return undefined + } catch { + return undefined + } +} + +function isSafeJournal (journal: FallbackJournal): boolean { + if (!journal || journal.version !== 1 || !['prepared', 'mutating', 'committed'].includes(journal.phase) || !journal.manifest || !Array.isArray(journal.entries)) return false + if (!Array.isArray(journal.manifest.ownedSkillPaths) || !Array.isArray(journal.manifest.ownedLinkPaths) || !Array.isArray(journal.manifest.ownedMcpFields)) return false + if (journal.manifest.ownedMcpFields.some((field) => !field || typeof field.configPath !== 'string' || typeof field.server !== 'string' || typeof field.field !== 'string' || typeof field.expectedDigest !== 'string')) return false + if (journal.manifest.ownedSkillPaths.some((value) => typeof value !== 'string') || journal.manifest.ownedLinkPaths.some((value) => typeof value !== 'string')) return false + if (typeof journal.manifest.trackingPath !== 'string' || typeof journal.manifest.harness !== 'string' || typeof journal.manifest.installationId !== 'string') return false + if (typeof journal.journalPath !== 'string' || typeof journal.snapshotDirectory !== 'string') return false + const trackingPath = path.resolve(journal.manifest.trackingPath) + if (journal.journalPath !== fallbackJournalPath(trackingPath)) return false + if (!isSameOrContained(path.resolve(journal.snapshotDirectory), path.dirname(trackingPath))) return false + if (!journal.manifest.installationId || journal.manifest.installationId !== `${journal.manifest.harness}:fallback`) return false + const expectedPaths = new Set([ + trackingPath, + ...journal.manifest.ownedSkillPaths, + ...journal.manifest.ownedLinkPaths, + ...journal.manifest.ownedMcpFields.map((field) => field.configPath), + ].map((value) => path.resolve(value))) + if ([...expectedPaths].some((value) => !isCanonicalPath(value))) return false + const entries = new Set() + for (const entry of journal.entries) { + if (!entry || typeof entry.path !== 'string' || typeof entry.backup !== 'string' || typeof entry.existed !== 'boolean') return false + const target = path.resolve(entry.path) + if (!isCanonicalPath(target) || !expectedPaths.has(target) || entries.has(target)) return false + if (!isSameOrContained(path.resolve(entry.backup), path.resolve(journal.snapshotDirectory))) return false + entries.add(target) + } + return entries.size === expectedPaths.size && [...expectedPaths].every((target) => entries.has(target)) +} + +function isCanonicalPath (value: string): boolean { + return path.isAbsolute(value) && !value.split(path.sep).includes('..') && path.resolve(value) === value +} + +function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function stableValue (value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stableValue(child)])) + } + return value +} diff --git a/packages/core/src/update/fallback-transaction.ts b/packages/core/src/update/fallback-transaction.ts new file mode 100644 index 0000000..d74128e --- /dev/null +++ b/packages/core/src/update/fallback-transaction.ts @@ -0,0 +1,432 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync, lstatSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { BundleDescriptor, Credentials, HarnessType } from '../types.js' +import { validateBundle } from '../validate.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../utils/config.js' +import { resolveHome, getSkillsDir, getAuthFilePath } from '../utils/path.js' +import { deriveMcpUrlFromConsoleUrl } from '../auth/mcp-url.js' +import { removeMcpConfig, writeMcpConfig } from '../mcp/mcp-config-writer.js' +import { readTrackingFile, writeTrackingFile, type SkillTrackingEntry, type TrackingData } from '../skills/skill-tracker.js' +import { installSkillsToDirectory } from '../skills/skill-copier.js' +import { getHarnessSkillsPath, linkSkillsToHarness, unlinkSkillsFromHarness } from '../skills/skill-linker.js' +import { assertSafeSkillName } from '../utils/skill-name.js' +import { getAdapter } from '../harnesses/index.js' +import type { FallbackTransactionIdentity, UpdateError } from './types.js' +import { trackingDigest, valueDigest } from './fallback-journal.js' +import { readPackageVersion } from './package-manager.js' +import { isStableVersion } from './version.js' + +export interface FallbackRefreshOptions { + harness: HarnessType + bundlePath: string + skillsSource: string + transaction?: FallbackTransactionIdentity +} + +export interface FallbackRefreshResult { + success: boolean + rollbackAttempted?: boolean + rollbackSucceeded?: boolean + error?: UpdateError +} + +export async function refreshOwnedInstallation (options: FallbackRefreshOptions): Promise { + if (options.transaction) { + const validation = validateTransactionIdentity(options.transaction) + if (validation) return failure(validation.code, validation.message) + } + const tracking = await readTrackingFile() + if (!tracking) return failure('UNTRACKED_INSTALLATION', 'No NodeSource tracking record exists') + if (options.transaction && !matchesTrackedOwnership(tracking, options.transaction)) { + return failure('FALLBACK_OWNERSHIP_DRIFT', 'Fallback ownership no longer matches the approved transaction manifest') + } + const previousSkills = tracking.skills.filter((entry) => entry.harnesses.includes(options.harness)) + const previousMcps = tracking.mcpServers.filter((entry) => entry.harness === options.harness) + if (previousSkills.length === 0 && previousMcps.length === 0) return failure('UNTRACKED_INSTALLATION', 'The requested harness has no tracked NodeSource ownership') + + let bundle: BundleDescriptor + try { + const raw = readJsonFile(options.bundlePath) + if (!raw) return failure('BUNDLE_NOT_FOUND', 'Update bundle is not available') + bundle = validateBundle(raw) + } catch { + return failure('BUNDLE_INVALID', 'Update bundle is invalid') + } + const packageVersion = readPackageVersion(options.skillsSource) + const hasPackageManifest = existsSync(path.join(options.skillsSource, 'package.json')) + if (!isStableVersion(bundle.version) || (hasPackageManifest && packageVersion !== bundle.version)) { + return failure('FALLBACK_BUNDLE_VERSION_MISMATCH', 'Update bundle version does not match the executing package version') + } + + const destination = options.harness === 'opencode' + ? path.resolve(process.env.NSOLID_OPENCODE_SKILLS_DIR ?? resolveHome('~/.config/opencode/skills')) + : getSkillsDir() + const linkSkills = options.harness !== 'opencode' + const linkDir = linkSkills ? getHarnessSkillsPath(options.harness) : undefined + const oldPaths = previousSkills.map((entry) => entry.paths?.[options.harness] ?? entry.path) + if (oldPaths.some((value) => typeof value !== 'string' || !path.isAbsolute(value))) { + return failure('UNTRACKED_INSTALLATION', 'Tracked skill ownership does not contain safe absolute paths') + } + try { + for (const skill of bundle.skills) assertSafeSkillName(skill.name) + } catch { + return failure('BUNDLE_INVALID', 'Update bundle contains an unsafe skill destination') + } + const oldPathSet = new Set(oldPaths.map((value) => path.resolve(value))) + const newPaths = bundle.skills.map((skill) => path.join(destination, skill.name)) + const trackedPathSet = new Set( + tracking.skills.flatMap((entry) => [entry.path, ...Object.values(entry.paths ?? {})] + .filter((value): value is string => typeof value === 'string')) + .map((value) => path.resolve(value)) + ) + + for (const target of newPaths) { + if (pathExists(target) && !oldPathSet.has(path.resolve(target)) && !trackedPathSet.has(path.resolve(target))) { + return failure('UNTRACKED_DESTINATION', `Owned refresh would overwrite an untracked destination: ${path.basename(target)}`) + } + } + + const backupDir = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-fallback-')) + const trackingBackup = path.join(backupDir, 'tracking.json') + const configPath = previousMcps[0]?.configPath ?? getAdapter(options.harness).getMcpConfigPath() + const configExisted = configPath ? existsSync(configPath) : false + const configBackup = configPath ? path.join(backupDir, 'mcp-config') : undefined + const skillsBackup = path.join(backupDir, 'skills') + const linkPaths = linkDir ? [...new Set([...previousSkills, ...bundle.skills].map((skill) => path.join(linkDir, skill.name)))] : [] + const linksBackup = path.join(backupDir, 'links') + const sharedNewPaths = newPaths.filter((value) => existsSync(value) && trackedPathSet.has(path.resolve(value))) + const backupPaths = [...new Set([...oldPaths, ...sharedNewPaths])] + const previousSkillNames = new Set(previousSkills.map((entry) => entry.name)) + + // linkSkillsToHarness historically renamed any regular destination to a + // timestamped .bak before linking. A new bundle skill has no such ownership + // evidence, so reject that collision before the transaction can rename a + // user's directory or file. + if (linkDir) { + for (const skill of bundle.skills) { + const linkPath = path.join(linkDir, skill.name) + if (!previousSkillNames.has(skill.name) && pathExists(linkPath) && !trackedPathSet.has(path.resolve(linkPath))) { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + return failure('UNTRACKED_DESTINATION', `Owned refresh would overwrite an untracked harness link destination: ${skill.name}`) + } + } + } + + let backupsComplete = false + let mutationStarted = false + try { + // Keep backup creation outside the mutation catch. A partial backup is + // never safe input to rollback: deleting the live paths and restoring the + // partial tree can destroy the only intact copy of a user's installation. + try { + await writeFile(trackingBackup, JSON.stringify(tracking, null, 2) + '\n', { mode: 0o600 }) + await mkdir(skillsBackup, { recursive: true, mode: 0o700 }) + for (const oldPath of backupPaths) { + if (pathExists(oldPath)) { + const target = path.join(skillsBackup, encodeURIComponent(oldPath)) + await cp(oldPath, target, { recursive: true, force: true }) + } + } + if (linkPaths.length > 0) { + await mkdir(linksBackup, { recursive: true, mode: 0o700 }) + for (const linkPath of linkPaths) { + if (pathExists(linkPath)) await cp(linkPath, path.join(linksBackup, encodeURIComponent(linkPath)), { recursive: true, force: true }) + } + } + if (configPath && configBackup && existsSync(configPath)) await writeFile(configBackup, await readFile(configPath), { mode: 0o600 }) + backupsComplete = true + } catch { + return { + success: false, + rollbackAttempted: false, + error: { code: 'FALLBACK_BACKUP_FAILED', message: 'Owned fallback backup could not be completed' }, + } + } + + try { + mutationStarted = true + const newNames = new Set(bundle.skills.map((skill) => skill.name)) + const pathsToReplace = previousSkills + .filter((entry) => newNames.has(entry.name)) + .map((entry) => entry.paths?.[options.harness] ?? entry.path) + const pathsToRemove = previousSkills + .filter((entry) => !newNames.has(entry.name) && canRemoveOwnedPath(entry, options.harness)) + .map((entry) => entry.paths?.[options.harness] ?? entry.path) + for (const ownedPath of [...pathsToReplace, ...pathsToRemove, ...sharedNewPaths]) { + await rm(ownedPath, { recursive: true, force: true }) + } + + await installSkillsToDirectory(bundle.skills, options.skillsSource, destination) + for (const oldEntry of previousSkills) { + if (!newNames.has(oldEntry.name)) { + if (linkSkills) await unlinkSkillsFromHarness(options.harness, [{ name: oldEntry.name, path: oldEntry.name, description: '' }]) + } + } + if (linkSkills) await linkSkillsToHarness(options.harness, bundle.skills) + + const credentials = readValidCredentials() + const canReconcileMcp = credentials !== null + const previousMcpNames = previousMcps.map((entry) => entry.name) + const desiredMcpNames = bundle.mcpServers.map((server) => server.name) + if (!canReconcileMcp && !sameNameSet(previousMcpNames, desiredMcpNames)) { + throw new FallbackTransactionError('MCP_RECONCILIATION_REQUIRED', 'Fallback MCP state changed but valid credentials are unavailable') + } + const newMcpNames = canReconcileMcp + ? new Set(desiredMcpNames) + : new Set(previousMcpNames) + const staleMcpNames = previousMcps + .filter((entry) => !newMcpNames.has(entry.name)) + .filter((entry) => !tracking.mcpServers.some((other) => other !== entry && other.name === entry.name && path.resolve(other.configPath) === path.resolve(configPath))) + .map((entry) => entry.name) + if (configPath && staleMcpNames.length > 0) { + await removeMcpConfig(options.harness, [...new Set(staleMcpNames)], { configPath }) + } + const configuredMcpServers = canReconcileMcp ? bundle.mcpServers : [] + if (credentials && bundle.mcpServers.length > 0) { + const variables = await mcpVariables(credentials) + await writeMcpConfig(options.harness, bundle.mcpServers, variables, { configPath }) + } + + const updated = reconcileTracking(tracking, options.harness, destination, bundle.skills, configPath, configuredMcpServers, staleMcpNames) + updated.bundleVersion = bundle.version + updated.bundleVersions = { ...(updated.bundleVersions ?? {}), [options.harness]: bundle.version } + await writeTrackingFile(updated) + return { success: true } + } catch (error) { + const rollback = backupsComplete && mutationStarted + ? await rollbackFallback({ trackingBackup, configBackup, configPath, configExisted, skillsBackup, backupPaths, newPaths, linksBackup, linkPaths }) + : false + if (error instanceof FallbackTransactionError && rollback) { + return failure(error.code, error.message, { attempted: true, succeeded: true }) + } + return rollback + ? failure('FALLBACK_REFRESH_FAILED', 'Owned fallback refresh failed and was rolled back', { attempted: true, succeeded: true }) + : failure('FALLBACK_ROLLBACK_FAILED', 'Owned fallback refresh failed and rollback was incomplete', { attempted: true, succeeded: false }) + } + } finally { + await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } +} + +function validateTransactionIdentity (identity: FallbackTransactionIdentity): UpdateError | undefined { + if (!identity.installationId || identity.installationId !== `${identity.harness}:fallback`) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction manifest has an invalid installation identity' } + if (!path.isAbsolute(identity.trackingPath) || !trackingDigest(identity.trackingPath)) return { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file is absent or cannot be hashed' } + if (trackingDigest(identity.trackingPath) !== identity.trackingDigest) return { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file changed after planning' } + if (identity.ownedSkillPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe skill path' } + if (identity.ownedLinkPaths.some((value) => !isCanonicalPath(value))) return { code: 'INVALID_TRANSACTION_MANIFEST', message: 'Fallback transaction contains an unsafe link path' } + for (const field of identity.ownedMcpFields) { + if (!isCanonicalPath(field.configPath) || !existsSync(field.configPath)) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP configuration changed after planning' } + const current = readMcpField(field.configPath, field.server, field.field) + if (field.expectedDigest && valueDigest(current) !== field.expectedDigest) return { code: 'FALLBACK_MCP_DRIFT', message: 'Owned MCP field changed after planning' } + } + return undefined +} + +function matchesTrackedOwnership (tracking: TrackingData, identity: FallbackTransactionIdentity): boolean { + const linkRoot = path.resolve(getHarnessSkillsPath(identity.harness)) + if (identity.ownedLinkPaths.some((value) => !isSameOrContained(path.resolve(value), linkRoot))) return false + const scopedSkills = tracking.skills.filter((entry) => entry.harnesses.includes(identity.harness)) + const trackedPaths = new Set(scopedSkills + .map((entry) => entry.paths?.[identity.harness] ?? entry.path) + .filter((value): value is string => typeof value === 'string') + .map((value) => path.resolve(value))) + const ownedSkillPaths = new Set(identity.ownedSkillPaths.map((value) => path.resolve(value))) + if (ownedSkillPaths.size !== trackedPaths.size || ![...ownedSkillPaths].every((value) => trackedPaths.has(value))) return false + const trackedNames = new Set(scopedSkills.map((entry) => entry.name)) + const ownedLinkNames = new Set(identity.ownedLinkPaths.map((value) => path.basename(value))) + if (ownedLinkNames.size !== trackedNames.size || ![...ownedLinkNames].every((value) => trackedNames.has(value))) return false + const expectedMcpFields = tracking.mcpServers + .filter((entry) => entry.harness === identity.harness && entry.fields) + .flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => `${path.resolve(entry.configPath)}\0${entry.name}\0${field}\0${expectedDigest}`)) + const ownedMcpFields = new Set(identity.ownedMcpFields.map((field) => `${path.resolve(field.configPath)}\0${field.server}\0${field.field}\0${field.expectedDigest}`)) + if (expectedMcpFields.length > 0 && (ownedMcpFields.size !== expectedMcpFields.length || !expectedMcpFields.every((value) => ownedMcpFields.has(value)))) return false + return identity.ownedMcpFields.every((field) => tracking.mcpServers.some((entry) => { + if (entry.harness !== identity.harness || entry.name !== field.server || path.resolve(entry.configPath) !== path.resolve(field.configPath)) return false + return entry.fields?.[field.field] === field.expectedDigest + })) +} + +function isSameOrContained (candidate: string, parent: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function readMcpField (configPath: string, server: string, field: string): unknown { + try { + const parsed = readMcpConfig(configPath) + const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[server] : undefined + return record && typeof record === 'object' && !Array.isArray(record) ? (record as Record)[field] : undefined + } catch { return undefined } +} + +function readMcpConfig (configPath: string): Record | null { + if (configPath.endsWith('.toml')) return readTomlFile>(configPath) + if (configPath.endsWith('.jsonc')) return readJsoncFile>(configPath) + return readJsonFile>(configPath) +} + +function isCanonicalPath (value: string): boolean { + if (!path.isAbsolute(value) || value.split(path.sep).includes('..')) return false + return path.resolve(value) === value +} + +function readValidCredentials (): Credentials | null { + try { + const credentials = readJsonFile(getAuthFilePath()) + if (!credentials || typeof credentials.expiresAt !== 'string') return null + return Date.parse(credentials.expiresAt) > Date.now() ? credentials : null + } catch { return null } +} + +async function mcpVariables (credentials: Credentials): Promise> { + const mcpUrl = credentials.mcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) + if (!mcpUrl) throw new Error('MCP URL could not be derived') + return { AUTH_TOKEN: credentials.serviceToken, AUTH_ORG_ID: credentials.organizationId, MCP_URL: mcpUrl } +} + +async function rollbackFallback (options: { + trackingBackup: string + configBackup?: string + configPath?: string + configExisted: boolean + skillsBackup: string + backupPaths: string[] + newPaths: string[] + linksBackup: string + linkPaths: string[] +}): Promise { + try { + for (const newPath of options.newPaths) await rm(newPath, { recursive: true, force: true }) + for (const oldPath of options.backupPaths) { + const backup = path.join(options.skillsBackup, encodeURIComponent(oldPath)) + if (existsSync(backup)) await cp(backup, oldPath, { recursive: true, force: true }) + } + for (const linkPath of options.linkPaths) await rm(linkPath, { recursive: true, force: true }) + for (const linkPath of options.linkPaths) { + const backup = path.join(options.linksBackup, encodeURIComponent(linkPath)) + if (existsSync(backup)) await cp(backup, linkPath, { recursive: true, force: true }) + } + if (options.configPath && options.configBackup && existsSync(options.configBackup)) { + await writeFile(options.configPath, await readFile(options.configBackup), { mode: 0o600 }) + } else if (options.configPath && !options.configExisted) { + await rm(options.configPath, { force: true }) + } + const tracking = JSON.parse(await readFile(options.trackingBackup, 'utf8')) as TrackingData + await writeTrackingFile(tracking) + return true + } catch { + return false + } +} + +function canRemoveOwnedPath (entry: SkillTrackingEntry, harness: HarnessType): boolean { + const ownedPath = entry.paths?.[harness] ?? entry.path + const remainingHarnesses = entry.harnesses.filter((value) => value !== harness) + if (remainingHarnesses.length === 0) return true + const remainingPaths = remainingHarnesses.map((value) => entry.paths?.[value]).filter((value): value is string => typeof value === 'string') + // Legacy entries may not have per-harness paths. Keep the physical path when + // another owner remains and the old record cannot prove it is unshared. + if (remainingPaths.length === 0) return false + return !remainingPaths.some((value) => path.resolve(value) === path.resolve(ownedPath)) +} + +function reconcileTracking ( + original: TrackingData, + harness: HarnessType, + destination: string, + skills: BundleDescriptor['skills'], + configPath: string | undefined, + mcpServers: BundleDescriptor['mcpServers'], + staleMcpNames: string[] +): TrackingData { + const tracking = JSON.parse(JSON.stringify(original)) as TrackingData + const newNames = new Set(skills.map((skill) => skill.name)) + + for (const entry of tracking.skills) { + if (!entry.harnesses.includes(harness)) continue + if (newNames.has(entry.name)) { + entry.paths = { ...(entry.paths ?? {}), [harness]: path.resolve(destination, entry.name) } + continue + } + entry.harnesses = entry.harnesses.filter((value) => value !== harness) + if (entry.paths) delete entry.paths[harness] + if (entry.harnesses.length > 0) { + const remainingPath = entry.paths?.[entry.harnesses[0]] + if (remainingPath) entry.path = remainingPath + } + } + + tracking.skills = tracking.skills.filter((entry) => entry.harnesses.length > 0) + for (const skill of skills) { + const normalizedPath = path.resolve(destination, skill.name) + const existing = tracking.skills.find((entry) => entry.name === skill.name) + if (existing) { + if (!existing.harnesses.includes(harness)) existing.harnesses.push(harness) + existing.paths = { ...(existing.paths ?? {}), [harness]: normalizedPath } + if (existing.harnesses.length === 1) existing.path = normalizedPath + } else { + tracking.skills.push({ + name: skill.name, + path: normalizedPath, + paths: { [harness]: normalizedPath }, + installedAt: new Date().toISOString(), + harnesses: [harness], + }) + } + } + + const stale = new Set(staleMcpNames) + tracking.mcpServers = tracking.mcpServers.filter((entry) => !(entry.harness === harness && stale.has(entry.name))) + if (configPath) { + const now = new Date().toISOString() + for (const server of mcpServers) { + const existing = tracking.mcpServers.find((entry) => entry.harness === harness && entry.name === server.name) + if (existing) { + existing.configPath = path.resolve(configPath) + existing.configuredAt = now + existing.fields = readMcpRecord(configPath, server.name) + } else { + tracking.mcpServers.push({ name: server.name, configPath: path.resolve(configPath), harness, configuredAt: now, fields: readMcpRecord(configPath, server.name) }) + } + } + } + return tracking +} + +function readMcpRecord (configPath: string, name: string): Record | undefined { + try { + const parsed = readMcpConfig(configPath) + const servers = parsed?.mcpServers ?? parsed?.mcp_servers ?? parsed?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined + if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined + return Object.fromEntries(Object.entries(record as Record).map(([field, value]) => [field, valueDigest(value)])) + } catch { return undefined } +} + +function failure (code: string, message: string, rollback?: { attempted: boolean; succeeded: boolean }): FallbackRefreshResult { + return { success: false, rollbackAttempted: rollback?.attempted, rollbackSucceeded: rollback?.succeeded, error: { code, message } } +} + +class FallbackTransactionError extends Error { + constructor (public readonly code: string, message: string) { + super(message) + } +} + +function sameNameSet (left: readonly string[], right: readonly string[]): boolean { + const leftSet = new Set(left) + const rightSet = new Set(right) + return leftSet.size === rightSet.size && [...leftSet].every((name) => rightSet.has(name)) +} + +function pathExists (filePath: string): boolean { + try { + lstatSync(filePath) + return true + } catch { + return false + } +} diff --git a/packages/core/src/update/index.ts b/packages/core/src/update/index.ts new file mode 100644 index 0000000..e0e6ddb --- /dev/null +++ b/packages/core/src/update/index.ts @@ -0,0 +1,44 @@ +export { checkUpdates, executeUpdatePlan, planUpdates, summarizePlan, summarizeResults, update } from './coordinator.js' +export { createCommandRunner, findExecutable, isCommandSuccessful, runCommand, sanitizeOutput } from './command-runner.js' +export { detectAntigravityLayout, detectInstallations, detectCliInstallation } from './inventory.js' +export { beginFallbackJournal, commitFallbackJournal, fallbackJournalPath, markFallbackJournalMutating, recoverFallbackJournal, restoreFallbackJournal, trackingDigest, valueDigest } from './fallback-journal.js' +export { refreshOwnedInstallation } from './fallback-transaction.js' +export type { FallbackJournal, FallbackJournalPhase } from './fallback-journal.js' +export { compareVersions, classifyVersionSet, classifyVersions, isStableVersion, parseStableVersion, readPackageVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' +export { cleanupNpmArtifact, downloadNpmArtifact, resolveFixedGitBundleVersion, resolveMarketplaceVersion, resolveRegistryVersion, sanitizeRepository } from './version-source.js' +export type { + AntigravityLayout, + ClaudePluginScope, + CommandResult, + CommandRunner, + CommandSpec, + FallbackPackageExecutor, + MarketplaceVersionSource, + NpmArtifactIdentity, + GitArtifactIdentity, + LocalArtifactIdentity, + ResolvedArtifactIdentity, + FallbackTransactionIdentity, + PiPackageLocation, + RunningVersionInfo, + UpdateConfirmation, + UpdateConfirmationContext, + UpdateContext, + UpdateError, + UpdateInstallation, + UpdateInstallationMetadata, + UpdateOptions, + UpdateOwnership, + UpdatePlan, + UpdatePlanItem, + UpdatePlanStep, + UpdateResult, + UpdateSource, + UpdateStatus, + UpdateStrategy, + UpdateSummary, + UpdateTarget, + VersionInfo, + VersionLookupResult, + VersionStatus, +} from './types.js' diff --git a/packages/core/src/update/inventory.ts b/packages/core/src/update/inventory.ts new file mode 100644 index 0000000..e36fe70 --- /dev/null +++ b/packages/core/src/update/inventory.ts @@ -0,0 +1,634 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { readJsonFile, readTomlFile } from '../utils/config.js' +import { getTrackingFilePath, resolveHome } from '../utils/path.js' +import { isValidTrackingData } from '../skills/skill-tracker.js' +import { packageNameFromNpmSource, PI_PLUGIN_PACKAGE_NAME } from '../harnesses/pi-plugin-detector.js' +import { isNsolidPluginId } from '../harnesses/plugin-name.js' +import type { HarnessType } from '../types.js' +import type { + AntigravityLayout, + ClaudePluginScope, + MarketplaceVersionSource, + UpdateInstallation, + UpdateInstallationMetadata, + UpdateSource, +} from './types.js' +import type { CommandRunner } from './types.js' +import { detectGlobalPackageOwnership, readPackageVersion as readNamedPackageVersion } from './package-manager.js' +import { classifyVersionSet, classifyVersions, isStableVersion, readRunningVersionInfo, resolvePackageRoot } from './version.js' + +export interface InventoryOptions { + commandRunner: CommandRunner + cwd?: string + packageRoot?: string + includeCli?: boolean + readOnly?: boolean + deferCliOwnership?: boolean +} + +const HARNESS_ORDER: HarnessType[] = ['claude', 'codex', 'antigravity', 'opencode', 'pi'] +const PLUGIN_ID = /^nsolid-plugin@([A-Za-z0-9][A-Za-z0-9._-]*)$/ +const SCOPES = new Set(['user', 'project', 'local', 'managed']) + +export async function detectInstallations (options: InventoryOptions): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const installations: UpdateInstallation[] = [] + const includeCli = options.includeCli !== false + + if (includeCli) installations.push(await detectCliInstallation(options, options.deferCliOwnership !== true)) + + const fallback = await detectFallbackInstallations() + for (const harness of HARNESS_ORDER) { + const native = harness === 'claude' + ? detectClaudeInstallations() + : harness === 'codex' + ? detectCodexInstallations() + : harness === 'antigravity' + ? detectAntigravityInstallations() + : harness === 'pi' + ? detectPiInstallations(cwd) + : [] + installations.push(...native) + const fallbackInstallation = fallback.find((item) => item.target === harness) + if (fallbackInstallation) installations.push(fallbackInstallation) + } + + return installations.sort(compareInstallations) +} + +export async function detectCliInstallation (options: InventoryOptions, probeOwnership = true): Promise { + const packageRoot = path.resolve(options.packageRoot ?? defaultPackageRoot()) + const running = safeRunningVersion(packageRoot) + const ownership = probeOwnership + ? await detectGlobalPackageOwnership({ + commandRunner: options.commandRunner, + packageRoot, + executablePath: process.argv[1], + readOnly: options.readOnly, + }) + : undefined + + const source: UpdateSource = ownership?.ownership + ? { + kind: 'global-package', + packageManager: ownership.ownership.manager, + packageName: 'nsolid-plugin', + } + : { + kind: 'unsupported', + source: process.argv[1] || 'unknown', + reason: 'unsupported-manager', + } + + const metadata: UpdateInstallationMetadata = ownership?.ownership + ? { + packageRoot: ownership.ownership.packageRoot, + packagePath: ownership.ownership.packagePath, + previousVersion: running?.cliVersion, + rollbackCommand: ownership.ownership.rollbackCommand, + packageManagerExecutable: ownership.ownership.executable, + } + : { packageRoot } + + return { + installationId: 'cli:global', + target: 'cli', + ownership: ownership?.ownership ? 'global-package' : 'none', + installed: true, + source, + version: classifyVersions(running?.cliVersion, undefined), + metadata, + } +} + +function detectClaudeInstallations (): UpdateInstallation[] { + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + const data = safeReadJson(installedPath) + const knownMarketplaces = safeReadJson(resolveHome('~/.claude/plugins/known_marketplaces.json')) ?? {} + const records = extractPluginRecords(data) + const output: UpdateInstallation[] = [] + + for (const { id, record } of records) { + if (!isNsolidPluginId(id)) continue + const parsed = PLUGIN_ID.exec(id) + const scope = readScope(record) + const metadata = recordMetadata(record) + const marketplaceRecord = parsed && isRecord(knownMarketplaces[parsed[1]]) ? knownMarketplaces[parsed[1]] as Record : {} + const enrichedRecord = { ...marketplaceRecord, ...record } + const source = parsed && scope + ? makeClaudeSource(id, parsed[1], enrichedRecord, metadata) + : makeUnsupportedSource(id, !parsed ? 'ambiguous' : 'ambiguous') + const version = readRecordVersion(enrichedRecord, metadata?.packageRoot) + output.push({ + installationId: `claude:native:${id}:${scope ?? 'unknown'}`, + target: 'claude', + ownership: 'native-plugin', + installed: true, + source, + version: classifyVersions(version, undefined), + metadata, + }) + } + return output +} + +function detectCodexInstallations (): UpdateInstallation[] { + const configPath = path.resolve(process.env.CODEX_CONFIG_PATH ?? resolveHome('~/.codex/config.toml')) + const data = safeReadToml(configPath) + const plugins = data?.plugins + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return [] + const output: UpdateInstallation[] = [] + + for (const [id, value] of Object.entries(plugins as Record)) { + if (!isNsolidPluginId(id)) continue + const parsed = PLUGIN_ID.exec(id) + const marketplaceRecord = isRecord(data.marketplaces) && isRecord((data.marketplaces as Record)[parsed?.[1] ?? '']) + ? (data.marketplaces as Record)[parsed![1]] as Record + : {} + const record = { ...marketplaceRecord, ...(isRecord(value) ? value : {}) } + const source = parsed + ? makeCodexSource(id, parsed[1], record) + : makeUnsupportedSource(id, 'ambiguous') + const metadata = { ...(recordMetadata(record) ?? {}), configPath } + const version = readRecordVersion(record, metadata?.packageRoot) + output.push({ + installationId: `codex:native:${id}`, + target: 'codex', + ownership: 'native-plugin', + installed: true, + source, + version: classifyVersions(version, undefined), + metadata, + }) + } + return output +} + +function detectAntigravityInstallations (): UpdateInstallation[] { + const detected = detectAntigravityLayout() + if (!detected.layout) { + if (!detected.reason) return [] + return [{ + installationId: 'antigravity:native:unsupported-layout', + target: 'antigravity', + ownership: 'native-plugin', + installed: true, + source: makeUnsupportedSource(detected.reason, 'ambiguous'), + version: { status: 'unknown' }, + metadata: { pluginRoot: detected.pluginRoot, manifestPath: detected.manifestPath }, + }] + } + + const pluginRoot = resolveHome(detected.layout.pluginRoot) + const bundleVersion = safeReadVersion(path.join(pluginRoot, 'bundle.json')) + return [{ + installationId: `antigravity:native:${detected.layout.kind}`, + target: 'antigravity', + ownership: 'native-plugin', + installed: true, + source: { + kind: 'antigravity-git', + url: 'https://github.com/NodeSource/nsolid-plugin.git', + layout: detected.layout, + }, + version: classifyVersions(bundleVersion, undefined), + metadata: { pluginRoot, manifestPath: resolveHome(detected.layout.manifestPath) }, + }] +} + +function detectPiInstallations (cwd: string): UpdateInstallation[] { + const userSettings = resolveHome('~/.pi/agent/settings.json') + const projectSettings = path.join(cwd, '.pi', 'settings.json') + const userEntries = readPiEntries(userSettings) + const projectEntries = existsSync(projectSettings) ? readPiEntries(projectSettings) : [] + const allEntries = [...userEntries, ...projectEntries] + const matching = allEntries.filter((entry) => isPiPluginName(entry.source)) + const packageRoots: string[] = [] + const hasUserCanonical = matching.some((entry) => entry.scope === 'user' && entry.canonical) + const hasProjectCanonical = matching.some((entry) => entry.scope === 'project' && entry.canonical) + const invalid = matching.find((entry) => !entry.canonical) + // A cache directory is not source evidence. Only an explicit canonical + // settings entry makes a Pi package installation updateable. + if (!hasUserCanonical && !hasProjectCanonical && matching.length === 0) return [] + + if (invalid) { + return [{ + installationId: 'pi:package:unsupported', + target: 'pi', + ownership: 'package-owned', + installed: true, + source: makeUnsupportedSource(invalid.source, invalid.reason), + version: { status: 'unknown' }, + }] + } + + const scopes: Array<'user' | 'project'> = [] + if (hasUserCanonical) scopes.push('user') + if (hasProjectCanonical) scopes.push('project') + if (scopes.includes('user')) packageRoots.push(resolveHome(`~/.pi/agent/npm/node_modules/${PI_PLUGIN_PACKAGE_NAME}`)) + if (scopes.includes('project')) { + packageRoots.push(path.join(cwd, '.pi', 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME)) + } + const uniqueRoots = [...new Set(packageRoots)] + const version = classifyVersionSet(uniqueRoots.map(safePackageVersion), undefined) + const location = scopes.length === 2 + ? { scopes: ['user', 'project'] as const, projectRoot: cwd } + : scopes[0] === 'project' + ? { scopes: ['project'] as const, projectRoot: cwd } + : { scopes: ['user'] as const } + + return [{ + installationId: `pi:package:${scopes.join('+')}`, + target: 'pi', + ownership: 'package-owned', + installed: true, + source: { kind: 'pi-package', spec: 'npm:nsolid-pi-plugin', ...location }, + version, + metadata: { + packageRoots, + packageRootIdentities: packageRoots.map(safeRealpath), + projectRoot: scopes.includes('project') ? cwd : undefined, + projectRootIdentity: scopes.includes('project') ? safeRealpath(cwd) : undefined, + settingsPaths: [userSettings, ...(existsSync(projectSettings) ? [projectSettings] : [])], + settingsDigests: [userSettings, ...(existsSync(projectSettings) ? [projectSettings] : [])].map(fileDigest), + sourceEntries: matching.map((entry) => entry.source), + cacheDigests: uniqueRoots.map((root) => fileDigest(path.join(root, 'package.json'))), + }, + }] +} + +async function detectFallbackInstallations (): Promise { + const trackingPath = getTrackingFilePath() + if (!existsSync(trackingPath)) return [] + let rawTracking: unknown + try { + rawTracking = readJsonFile(trackingPath) + } catch { + return [unsupportedFallbackInstallation('tracking file could not be read')] + } + if (!isValidTrackingData(rawTracking)) return [unsupportedFallbackInstallation('tracking file has an invalid shape', trackingHarness(rawTracking))] + const tracking = rawTracking + const output: UpdateInstallation[] = [] + for (const harness of HARNESS_ORDER) { + const rawTrackedSkills = tracking.skills + .filter((entry) => entry.harnesses.includes(harness)) + .map((entry) => ({ + name: entry.name, + path: entry.paths?.[harness] ?? entry.path, + })) + const trackedSkills = rawTrackedSkills.filter((entry): entry is { name: string; path: string } => typeof entry.path === 'string') + const trackedMcps = tracking.mcpServers.filter((entry) => entry.harness === harness) + // Pi owns its skills through nsolid-pi-plugin. Its normal setup may still + // leave MCP entries in the shared tracking file, but those entries do not + // constitute a fallback installation and must not create a duplicate + // unsupported target beside the package-owned Pi target. + if (harness === 'pi' && trackedSkills.length === 0) continue + if (trackedSkills.length === 0 && trackedMcps.length === 0) continue + const scopedVersion = tracking.bundleVersions?.[harness] + const legacyVersion = tracking.bundleVersions === undefined && tracking.harness === harness ? tracking.bundleVersion : undefined + const bundleVersion = isStableVersion(scopedVersion) + ? scopedVersion + : isStableVersion(legacyVersion) ? legacyVersion : undefined + const ownershipProven = rawTrackedSkills.length === trackedSkills.length && trackedSkills.every((entry) => path.isAbsolute(entry.path)) + const source: UpdateSource = ownershipProven && trackedSkills.length > 0 + ? { kind: 'fallback', bundleVersion } + : { kind: 'unsupported', source: `${harness}:tracking`, reason: 'untracked' } + output.push({ + installationId: `${harness}:fallback`, + target: harness, + ownership: 'fallback', + installed: true, + source, + version: classifyVersions(bundleVersion, undefined), + metadata: { + trackedSkills, + trackedMcpConfigPath: trackedMcps[0]?.configPath, + trackedMcpNames: trackedMcps.map((entry) => entry.name), + trackedMcpFields: trackedMcps.flatMap((entry) => Object.entries(entry.fields ?? {}).map(([field, expectedDigest]) => ({ + configPath: path.resolve(entry.configPath), + server: entry.name, + field, + expectedDigest, + }))), + trackedMcpOwnershipComplete: trackedMcps.every((entry) => entry.fields !== undefined), + }, + }) + } + return output +} + +function unsupportedFallbackInstallation (reason: string, target: HarnessType = 'opencode'): UpdateInstallation { + return { + installationId: `${target}:fallback`, + target, + ownership: 'fallback', + installed: true, + source: makeUnsupportedSource(`${target}:tracking (${reason})`, 'untracked'), + version: { status: 'unknown' }, + metadata: { trackedSkills: [] }, + } +} + +function trackingHarness (value: unknown): HarnessType { + if (isRecord(value) && typeof value.harness === 'string' && HARNESS_ORDER.includes(value.harness as HarnessType)) return value.harness as HarnessType + return 'opencode' +} + +function makeClaudeSource ( + id: string, + marketplace: string, + record: Record, + metadata?: UpdateInstallationMetadata +): UpdateSource { + const scope = readScope(record) + if (!scope) return makeUnsupportedSource(id, 'ambiguous') + const versionSource = sourceFromRecord(record, metadata) + if (versionSource.kind === 'unknown') { + return makeUnsupportedSource(id, versionSource.reason === 'ambiguous' ? 'ambiguous' : 'untracked') + } + return { + kind: 'claude-marketplace', + pluginId: id, + marketplace, + scope, + versionSource, + } +} + +function makeCodexSource (id: string, marketplace: string, record: Record): UpdateSource { + const versionSource = sourceFromRecord(record, recordMetadata(record)) + if (versionSource.kind === 'unknown') { + return makeUnsupportedSource(id, versionSource.reason === 'ambiguous' ? 'ambiguous' : 'untracked') + } + return { + kind: 'codex-marketplace', + pluginId: id, + marketplace, + versionSource, + } +} + +function sourceFromRecord (record: Record, metadata?: UpdateInstallationMetadata): MarketplaceVersionSource { + const source = isRecord(record.source) ? record.source : record + const repositoryCandidate = firstString(source.repository, source.repo, source.url, record.repository, record.repo) + const sourceValue = typeof source.source === 'string' ? source.source : undefined + const repository = repositoryCandidate ?? (sourceValue && (sourceValue.includes('/') || /^https?:\/\//.test(sourceValue)) ? sourceValue : undefined) + const manifestPath = firstString( + source.manifestPath, + source.relativeManifestPath, + source.relativePath, + source.manifest, + source.manifestFile, + record.manifestPath, + record.relativeManifestPath, + record.relativePath, + record.manifest + ) + const revision = firstString(source.revision, source.ref, source.commit, record.revision, record.ref) + const effectiveManifestPath = manifestPath ?? (repository ? 'bundle.json' : undefined) + if (repository && effectiveManifestPath && isSafeManifestPath(effectiveManifestPath)) { + const safeRepository = sanitizeRepository(repository) + if (!safeRepository) return { kind: 'unknown', reason: 'unsupported' } + const commit = firstString(source.commit, record.commit) + const contentDigest = firstString(source.contentDigest, record.contentDigest) + return { kind: 'git', repository: safeRepository, revision, commit, contentDigest, manifestPath: effectiveManifestPath } + } + + const root = metadata?.packageRoot ?? firstString( + record.installPath, + record.installLocation, + record.pluginRoot, + record.path, + source.path + ) + if (root && isSafeSnapshotRoot(root)) { + const inferredManifest = effectiveManifestPath ?? ( + existsSync(path.join(root, 'plugin.json')) + ? 'plugin.json' + : existsSync(path.join(root, 'bundle.json')) ? 'bundle.json' : undefined + ) + if (inferredManifest && isSafeManifestPath(inferredManifest)) { + const freshnessValue = firstString(record.freshness, source.freshness) + const freshness = freshnessValue === 'verified' || freshnessValue === 'stale' || freshnessValue === 'unknown' + ? freshnessValue + : 'unknown' + return { + kind: 'local-snapshot', + root, + manifestPath: inferredManifest, + freshness, + contentDigest: contentDigestForSnapshot(root, inferredManifest), + } + } + } + return { kind: 'unknown', reason: repository || root ? 'unsupported' : 'missing-metadata' } +} + +function recordMetadata (record: Record): UpdateInstallationMetadata | undefined { + const packageRoot = firstString(record.installPath, record.installLocation, record.pluginRoot, record.path) + return packageRoot ? { packageRoot } : undefined +} + +function extractPluginRecords (data: unknown): Array<{ id: string; record: Record }> { + if (!data || typeof data !== 'object') return [] + const output: Array<{ id: string; record: Record }> = [] + const object = data as Record + const plugins = object.plugins + if (plugins && typeof plugins === 'object' && !Array.isArray(plugins)) { + for (const [id, value] of Object.entries(plugins as Record)) { + if (Array.isArray(value)) { + for (const record of value) output.push({ id, record: isRecord(record) ? record : {} }) + } else output.push({ id, record: isRecord(value) ? value : {} }) + } + } else if (Array.isArray(plugins)) { + for (const value of plugins) { + if (typeof value === 'string') output.push({ id: value, record: {} }) + else if (isRecord(value) && typeof value.id === 'string') output.push({ id: value.id, record: value }) + } + } + return output +} + +function readPiEntries (settingsPath: string): Array<{ source: string; scope: 'user' | 'project'; canonical: boolean; reason: 'local' | 'git' | 'pinned' | 'conflicting' | 'ambiguous' }> { + const scope = settingsPath.includes(`${path.sep}.pi${path.sep}agent${path.sep}`) ? 'user' : 'project' + const settings = safeReadJson(settingsPath) + const packages = settings?.packages + if (!Array.isArray(packages)) return [] + return packages + .map((entry) => typeof entry === 'string' ? entry : isRecord(entry) ? entry.source : undefined) + .filter((source): source is string => typeof source === 'string') + .filter((source) => source.includes('nsolid-pi-plugin')) + .map((source) => { + if (source === 'npm:nsolid-pi-plugin') return { source, scope, canonical: true, reason: 'ambiguous' as const } + if (source.startsWith('npm:')) return { source, scope, canonical: false, reason: 'pinned' as const } + if (/^(git:|https?:|ssh:)/.test(source)) return { source, scope, canonical: false, reason: 'git' as const } + return { source, scope, canonical: false, reason: 'local' as const } + }) +} + +function readScope (record: Record): ClaudePluginScope | undefined { + const scope = firstString(record.scope, record.installationScope, isRecord(record.metadata) ? record.metadata.scope : undefined) + return scope && SCOPES.has(scope as ClaudePluginScope) ? scope as ClaudePluginScope : undefined +} + +function readRecordVersion (record: Record, root?: string): string | undefined { + const candidate = firstString(record.version, record.pluginVersion, record.bundleVersion) + if (isStableVersion(candidate)) return candidate + return root ? safeReadVersion(path.join(root, 'bundle.json')) ?? safeReadVersion(path.join(root, 'plugin.json')) : undefined +} + +function safeRunningVersion (packageRoot: string) { + try { return readRunningVersionInfo(packageRoot) } catch { return undefined } +} + +function safeReadJson (filePath: string): Record | null { + try { + const value = readJsonFile(filePath) + return isRecord(value) ? value : null + } catch { return null } +} + +function safeReadToml (filePath: string): Record | null { + try { + const value = readTomlFile(filePath) + return isRecord(value) ? value : null + } catch { return null } +} + +function safeReadVersion (filePath: string): string | undefined { + const data = safeReadJson(filePath) + const version = data?.version + return isStableVersion(version) ? version : undefined +} + +function safePackageVersion (root: string): string | undefined { + return readNamedPackageVersion(root, PI_PLUGIN_PACKAGE_NAME) +} + +function fileDigest (filePath: string): string { + try { return createHash('sha256').update(readFileSync(filePath)).digest('hex') } catch { return '' } +} + +function safeRealpath (filePath: string): string { + try { return realpathSync(filePath) } catch { return path.resolve(filePath) } +} + +function contentDigestForSnapshot (root: string, manifestPath: string): string | undefined { + try { return createHash('sha256').update(readFileSync(path.resolve(root, manifestPath))).digest('hex') } catch { return undefined } +} + +function compareInstallations (a: UpdateInstallation, b: UpdateInstallation): number { + const targetOrder = (target: string) => target === 'cli' ? -1 : HARNESS_ORDER.indexOf(target as HarnessType) + const targetDifference = targetOrder(a.target) - targetOrder(b.target) + if (targetDifference !== 0) return targetDifference + const ownershipOrder: Record = { 'global-package': 0, 'native-plugin': 1, 'package-owned': 1, fallback: 2, none: 3 } + return (ownershipOrder[a.ownership] ?? 9) - (ownershipOrder[b.ownership] ?? 9) || a.installationId.localeCompare(b.installationId) +} + +function makeUnsupportedSource (source: string, reason: 'local' | 'git' | 'pinned' | 'ambiguous' | 'conflicting' | 'untracked' | 'unsupported-manager'): UpdateSource { + return { kind: 'unsupported', source: sanitizeUnsupportedSource(source), reason } +} + +function sanitizeUnsupportedSource (source: string): string { + const safeControls = [...source.trim()].map((character) => { + const code = character.charCodeAt(0) + return code < 0x20 || code === 0x7f ? '?' : character + }).join('') + const redacted = safeControls.replace(/((?:https?|ssh):\/\/)[^/\s@]+@/gi, '$1[REDACTED]@') + try { + const url = new URL(redacted) + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '').slice(0, 120) + } catch { + return redacted.slice(0, 120) + } +} + +function firstString (...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === 'string' && value.length > 0) +} + +function isPiPluginName (source: string): boolean { + if (source.startsWith('npm:')) return packageNameFromNpmSource(source) === PI_PLUGIN_PACKAGE_NAME + + const withoutFragment = source.trim().split(/[\s?#]/, 1)[0].replace(/[\\/]+$/, '').replace(/\.git$/, '') + const basename = withoutFragment.split(/[\\/:]/).at(-1) + return basename === PI_PLUGIN_PACKAGE_NAME +} + +function isRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isSafeManifestPath (value: string): boolean { + return value.length > 0 && !path.isAbsolute(value) && !value.split(/[\\/]+/).includes('..') && !value.includes('\\') +} + +function isSafeSnapshotRoot (value: string): boolean { + return path.isAbsolute(value) && !value.split(path.sep).includes('..') +} + +function sanitizeRepository (value: string): string | undefined { + const githubShorthand = value.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?$/) + if (githubShorthand) return `https://github.com/${githubShorthand[1]}/${githubShorthand[2]}.git` + try { + const url = new URL(value) + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') + } catch { return undefined } +} + +export function detectAntigravityLayout (): { + layout?: AntigravityLayout + reason?: string + pluginRoot?: string + manifestPath?: string +} { + const candidates: Array<{ layout: AntigravityLayout; pluginRoot: string; manifestPath: string }> = [ + { + layout: { kind: 'shared', pluginRoot: '~/.gemini/config/plugins/nsolid-plugin', manifestPath: '~/.gemini/config/import_manifest.json' }, + pluginRoot: resolveHome('~/.gemini/config/plugins/nsolid-plugin'), + manifestPath: resolveHome('~/.gemini/config/import_manifest.json'), + }, + { + layout: { kind: 'agy-cli', pluginRoot: '~/.gemini/antigravity-cli/plugins/nsolid-plugin', manifestPath: '~/.gemini/antigravity-cli/import_manifest.json' }, + pluginRoot: resolveHome('~/.gemini/antigravity-cli/plugins/nsolid-plugin'), + manifestPath: resolveHome('~/.gemini/antigravity-cli/import_manifest.json'), + }, + ] + const valid = candidates.filter((candidate) => + existsSync(candidate.pluginRoot) && existsSync(candidate.manifestPath) && manifestContainsPlugin(candidate.manifestPath)) + // A generic import manifest belongs to the layout only when it contains + // NodeSource evidence. Merely having both product manifests on disk is + // common and must not be reported as an ambiguous N|Solid installation. + const present = candidates.filter((candidate) => + existsSync(candidate.pluginRoot) || (existsSync(candidate.manifestPath) && manifestContainsPlugin(candidate.manifestPath))) + if (present.length > 1) return { reason: 'multiple Antigravity plugin layouts are present' } + if (valid.length === 1) return valid[0] + const touched = present[0] + if (touched) return { reason: 'Antigravity plugin root and matching import manifest are incomplete', pluginRoot: touched.pluginRoot, manifestPath: touched.manifestPath } + return {} +} + +function manifestContainsPlugin (manifestPath: string): boolean { + const data = safeReadJson(manifestPath) + const imports = data?.imports + if (Array.isArray(imports)) return imports.some((entry) => isRecord(entry) && (entry.name === 'nsolid-plugin' || entry.plugin === 'nsolid-plugin')) + if (isRecord(imports)) { + return Object.entries(imports).some(([key, value]) => key === 'nsolid-plugin' || key.includes('nsolid-plugin') || (isRecord(value) && (value.name === 'nsolid-plugin' || value.plugin === 'nsolid-plugin'))) + } + return false +} + +function defaultPackageRoot (): string { + return resolvePackageRoot(path.dirname(fileURLToPath(new URL('.', import.meta.url)))) +} diff --git a/packages/core/src/update/package-manager.ts b/packages/core/src/update/package-manager.ts new file mode 100644 index 0000000..dc5e6a8 --- /dev/null +++ b/packages/core/src/update/package-manager.ts @@ -0,0 +1,189 @@ +import path from 'node:path' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createHash } from 'node:crypto' +import type { CommandRunner, CommandSpec, NpmArtifactIdentity, UpdateError } from './types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, findExecutable, isCommandSuccessful } from './command-runner.js' +import { isStableVersion } from './version.js' + +export interface GlobalPackageOwnership { + manager: 'npm' | 'pnpm' + packageRoot: string + packagePath: string + executable: string + rollbackCommand: string +} + +export interface PackageManagerDetectionOptions { + commandRunner: CommandRunner + packageRoot: string + executablePath?: string + env?: NodeJS.ProcessEnv + /** Do not invoke npm/pnpm when collecting a read-only update report. */ + readOnly?: boolean +} + +export interface PackageManagerDetection { + ownership?: GlobalPackageOwnership + unsupported?: UpdateError +} + +export async function detectGlobalPackageOwnership ( + options: PackageManagerDetectionOptions +): Promise { + const env = options.env ?? process.env + const executablePath = options.executablePath ?? process.argv[1] ?? '' + const source = `${executablePath} ${env.npm_execpath ?? ''} ${env.npm_config_user_agent ?? ''}`.toLowerCase() + + // Installation roots and the resolved executable are stronger evidence than + // ambient variables. Volta/Bun/Yarn commonly export their home variables in + // ordinary npm/pnpm shells, so those variables alone must not reject a + // positively identified global package. + const wrapperEnvironment = env.npm_command === 'exec' + const npxCache = /[\\/]\.npm[\\/]_npx[\\/]/.test(executablePath) + if (wrapperEnvironment || npxCache || /(^|[\\/])(?:npx|volta|yarn|bun)(?:\.exe)?(?:\s|$)/.test(source) || source.includes('node_modules/.bin')) { + return { unsupported: unsupported('unsupported-manager', 'CLI was launched through an unsupported wrapper') } + } + + // A check must not even query a package manager. It can still report the + // running version; ownership is intentionally left unsupported until a + // mutating plan is requested and the manager can be positively verified. + if (options.readOnly) { + return { unsupported: unsupported('unsupported-manager', 'CLI ownership was not probed during a read-only check') } + } + + const candidates: Array<'npm' | 'pnpm'> = [] + if (source.includes('pnpm')) candidates.push('pnpm') + if (source.includes('npm')) candidates.push('npm') + for (const manager of ['npm', 'pnpm'] as const) { + if (!candidates.includes(manager)) candidates.push(manager) + } + + const matches: GlobalPackageOwnership[] = [] + for (const manager of candidates) { + const executable = findExecutable(manager, env) + if (!executable) continue + let rootResult + try { + rootResult = await options.commandRunner.run({ + executable, + args: ['root', '--global'], + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }) + } catch { + continue + } + if (!isCommandSuccessful(rootResult)) continue + const globalRoot = rootResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) + if (!globalRoot) continue + + // Keep the manager-reported link as the package identity used for reads + // after an update. pnpm repoints this link to a new store directory; a + // realpath captured before `pnpm add --global` would keep verification + // pinned to the old versioned store entry. + const packagePath = path.resolve(globalRoot, 'nsolid-plugin') + const resolvedPackagePath = realpathOrAbsolute(packagePath) + const resolvedPackageRoot = realpathOrAbsolute(options.packageRoot) + if (!isSameOrContained(resolvedPackageRoot, resolvedPackagePath)) continue + const packageVersion = readPackageVersion(packagePath) + if (!packageVersion) continue + if (!isSameOrContained(executablePath, resolvedPackagePath) && executablePath) { + // The entrypoint may be a symlink. Resolve it when possible, but reject a + // launcher that is unrelated to the positively identified package root. + const resolvedEntry = safeRealpath(executablePath) + if (!resolvedEntry || !isSameOrContained(resolvedEntry, resolvedPackagePath)) continue + } + + matches.push({ + manager, + packageRoot: path.resolve(globalRoot), + packagePath, + executable, + rollbackCommand: formatRollbackCommand(manager, packageVersion), + }) + } + + if (matches.length === 1) return { ownership: matches[0] } + if (matches.length > 1) return { unsupported: unsupported('unsupported-manager', 'CLI ownership is ambiguous between multiple package managers') } + + return { unsupported: unsupported('unsupported-manager', 'CLI installation is not proven npm or pnpm global-owned') } +} + +export function buildGlobalUpdateCommand (ownership: GlobalPackageOwnership, version: string, artifact?: NpmArtifactIdentity): CommandSpec { + const packageSpec = artifact?.tarballPath ?? `nsolid-plugin@${version}` + return { + executable: ownership.executable, + args: ownership.manager === 'npm' + ? ['install', '--global', packageSpec] + : ['add', '--global', packageSpec], + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + } +} + +export function formatRollbackCommand (manager: 'npm' | 'pnpm', version?: string): string { + if (!version) return `${manager} ${manager === 'npm' ? 'install' : 'add'} --global nsolid-plugin@` + return manager === 'npm' + ? `npm install --global nsolid-plugin@${version}` + : `pnpm add --global nsolid-plugin@${version}` +} + +export function readPackageVersion (packagePath: string, expectedName = 'nsolid-plugin'): string | undefined { + try { + const parsed = JSON.parse(readFileSync(path.join(packagePath, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown } + return parsed.name === expectedName && isStableVersion(parsed.version) ? parsed.version : undefined + } catch { + return undefined + } +} + +export function verifyGlobalPackage (ownership: GlobalPackageOwnership, expectedVersion: string, artifact?: NpmArtifactIdentity): boolean { + if (readPackageVersion(ownership.packagePath) !== expectedVersion) return false + if (!artifact) return true + try { + const packageJson = JSON.parse(readFileSync(path.join(ownership.packagePath, 'package.json'), 'utf8')) as Record + const resolved = packageJson._resolved ?? packageJson.resolved ?? packageJson.tarball + const integrity = packageJson._integrity ?? packageJson.integrity + // npm/pnpm do not expose provenance in every global package manifest. If + // they do, it must agree with the planned immutable artifact rather than + // silently pointing at a different registry or tarball. + if (typeof resolved === 'string' && resolved !== artifact.tarball) return false + if (typeof integrity === 'string' && integrity !== artifact.integrity) return false + const contentDigest = packageJson.contentDigest ?? packageJson._contentDigest + if (typeof contentDigest === 'string' && artifact.contentDigest && contentDigest !== artifact.contentDigest) return false + return packageJson.name === artifact.packageName && packageJson.version === artifact.version + } catch { + return false + } +} + +export function verifyLocalArtifact (artifact: NpmArtifactIdentity): boolean { + if (!artifact.tarballPath) return false + try { + const match = artifact.integrity.match(/^sha(256|384|512)-([A-Za-z0-9+/=_-]+)$/i) + if (!match) return false + const actual = createHash(`sha${match[1]}` as 'sha256' | 'sha384' | 'sha512').update(readFileSync(artifact.tarballPath)).digest('base64') + return actual === match[2].replace(/-/g, '+').replace(/_/g, '/') + } catch { return false } +} + +function unsupported (reason: 'unsupported-manager', message: string): UpdateError { + return { code: 'UNSUPPORTED_CLI_SOURCE', message: `${message}. Use an exact-version npm or pnpm command manually.` } +} + +function isSameOrContained (candidate: string, parent: string): boolean { + if (!candidate || !parent) return false + const relative = path.relative(path.resolve(parent), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function safeRealpath (filePath: string): string | undefined { + try { + const resolved = path.resolve(filePath) + return existsSync(resolved) ? realpathSync(resolved) : undefined + } catch { + return undefined + } +} + +function realpathOrAbsolute (filePath: string): string { + return safeRealpath(filePath) ?? path.resolve(filePath) +} diff --git a/packages/core/src/update/refresh-owned-cli.ts b/packages/core/src/update/refresh-owned-cli.ts new file mode 100644 index 0000000..6cb5696 --- /dev/null +++ b/packages/core/src/update/refresh-owned-cli.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { refreshOwnedInstallation } from './fallback-transaction.js' +import { resolvePackageRoot } from './version.js' +import type { FallbackTransactionIdentity } from './types.js' +import { HARNESS_VALUES } from '../types.js' + +const args = process.argv.slice(2) +const transactionIndex = args.indexOf('--transaction') +const transactionPath = transactionIndex >= 0 ? args[transactionIndex + 1] : undefined +if (!transactionPath) { + console.error('nsolid-plugin-refresh-owned requires --transaction ') + process.exit(2) +} + +let transaction: FallbackTransactionIdentity +try { + transaction = JSON.parse(await (await import('node:fs/promises')).readFile(transactionPath, 'utf8')) as FallbackTransactionIdentity +} catch { + console.error('Fallback transaction manifest could not be read') + process.exit(2) +} +const harness = transaction.harness +if (!HARNESS_VALUES.includes(harness)) { + console.error('Fallback transaction manifest has an unsupported harness') + process.exit(2) +} + +const sourceRoot = resolvePackageRoot(path.dirname(fileURLToPath(import.meta.url))) +const result = await refreshOwnedInstallation({ + harness, + bundlePath: path.join(sourceRoot, 'bundle.json'), + skillsSource: sourceRoot, + transaction, +}) +if (!result.success) { + console.error(result.error?.message ?? 'Owned refresh failed') + if (result.rollbackAttempted) console.error(`rollback: ${result.rollbackSucceeded ? 'succeeded' : 'failed'}`) + else console.error('rollback: not-attempted') + process.exit(result.rollbackAttempted && result.rollbackSucceeded === false ? 2 : 1) +} diff --git a/packages/core/src/update/strategies/antigravity.ts b/packages/core/src/update/strategies/antigravity.ts new file mode 100644 index 0000000..6fad5af --- /dev/null +++ b/packages/core/src/update/strategies/antigravity.ts @@ -0,0 +1,44 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS } from '../command-runner.js' +import { findExecutable } from '../command-runner.js' +import { executeAntigravityTransaction } from '../antigravity-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' + +export const antigravityStrategy: UpdateStrategy = { + target: 'antigravity', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'antigravity-git' || !isMutableVersion(installation)) return planItem(installation) + const paths = [path.resolve(installation.metadata?.pluginRoot ?? source.layout.pluginRoot), path.resolve(installation.metadata?.manifestPath ?? source.layout.manifestPath)] + const agy = findExecutable('agy') ?? 'agy' + const pinnedSource = installation.artifact?.kind === 'git' && installation.artifact.commit + ? `${source.url}#${installation.artifact.commit}` + : source.url + return { + ...planItem( + installation, + [ + { kind: 'filesystem', description: 'Back up the staged plugin and matching import manifest', operation: 'backup', paths }, + { kind: 'command', description: 'Uninstall the existing Antigravity N|Solid plugin', command: { executable: agy, args: ['plugin', 'uninstall', 'nsolid-plugin'], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } }, + { kind: 'command', description: 'Install the fixed NodeSource GitHub plugin root at the planned commit', command: { executable: agy, args: ['plugin', 'install', pinnedSource], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } }, + { kind: 'validation', description: 'Validate the staged plugin and matching import manifest', checks: ['plugin.json', 'bundle.json', 'canonical skills', 'nsolid-plugin import entry'] }, + { kind: 'filesystem', description: 'Remove the successful Antigravity backup', operation: 'cleanup', paths }, + ], + [{ kind: 'filesystem', description: 'Restore the staged plugin and matching import manifest', operation: 'restore', paths }], + 'Restart Antigravity to load the updated plugin' + ), + manualCommands: ['agy plugin uninstall nsolid-plugin', `agy plugin install ${pinnedSource}`], + } + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const transaction = await executeAntigravityTransaction(item, context.commandRunner) + if (!transaction.success) return failedResult(item, transaction.error ?? { code: 'ANTIGRAVITY_TRANSACTION_FAILED', message: 'Antigravity replacement failed' }, { attempted: transaction.rollbackAttempted, succeeded: transaction.rollbackSucceeded }) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} diff --git a/packages/core/src/update/strategies/claude.ts b/packages/core/src/update/strategies/claude.ts new file mode 100644 index 0000000..516e31e --- /dev/null +++ b/packages/core/src/update/strategies/claude.ts @@ -0,0 +1,74 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, findExecutable, isCommandSuccessful } from '../command-runner.js' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' + +export const claudeStrategy: UpdateStrategy = { + target: 'claude', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'claude-marketplace' || !isMutableVersion(installation)) return planItem(installation) + if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Claude plugin identity is ambiguous' }) + } + return planItem( + installation, + [{ + kind: 'command', + description: `Update ${source.pluginId} in its detected ${source.scope} scope`, + command: { + executable: findExecutable('claude') ?? 'claude', + args: ['plugin', 'update', source.pluginId, '--scope', source.scope], + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }, + }], + [], + '/reload-plugins or restart Claude Code' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const step = item.steps[0] + if (step.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'Claude update plan has no command' }) + const result = await context.commandRunner.run(step.command) + if (!isCommandSuccessful(result)) return failedResult(item, commandFailure(step.command.executable, result.timedOut, result.spawnErrorCode)) + if (item.artifact && (item.artifact.kind === 'git' || item.artifact.kind === 'local-snapshot') && item.metadata?.packageRoot) { + const versionSource = item.source.kind === 'claude-marketplace' ? item.source.versionSource : undefined + const manifestPath = versionSource && versionSource.kind !== 'unknown' ? versionSource.manifestPath : undefined + const digest = payloadDigest(item.metadata.packageRoot, manifestPath) + if (digest && digest !== item.artifact.contentDigest) return failedResult(item, { code: 'CLAUDE_CONTENT_MISMATCH', message: 'Claude installed payload did not match the planned source identity' }) + } + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} + +function payloadDigest (root: string, manifestPath?: string): string | undefined { + try { + if (manifestPath) { + const manifest = path.resolve(root, manifestPath) + if (manifest.startsWith(`${path.resolve(root)}${path.sep}`) && existsSync(manifest)) return createHash('sha256').update(readFileSync(manifest)).digest('hex') + } + const directBundle = path.join(root, 'bundle.json') + if (existsSync(directBundle)) return createHash('sha256').update(readFileSync(directBundle)).digest('hex') + const files: string[] = [] + const walk = (directory: string, depth: number) => { + if (depth > 4) return + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name) + if (entry.isDirectory()) walk(file, depth + 1) + else if (entry.isFile() && ['bundle.json', 'plugin.json', 'package.json', 'manifest.json'].includes(entry.name)) files.push(file) + } + } + walk(root, 0) + if (files.length === 0) return undefined + const hash = createHash('sha256') + for (const file of files.sort()) hash.update(file).update(readFileSync(file)) + return hash.digest('hex') + } catch { return undefined } +} diff --git a/packages/core/src/update/strategies/cli-package.ts b/packages/core/src/update/strategies/cli-package.ts new file mode 100644 index 0000000..f5f9b64 --- /dev/null +++ b/packages/core/src/update/strategies/cli-package.ts @@ -0,0 +1,95 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { buildGlobalUpdateCommand, formatRollbackCommand, verifyGlobalPackage, verifyLocalArtifact } from '../package-manager.js' +import { isCommandSuccessful } from '../command-runner.js' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { cleanupNpmArtifact } from '../version-source.js' + +export const cliPackageStrategy: UpdateStrategy = { + target: 'cli', + ownership: 'global-package', + + async plan (installation: UpdateInstallation): Promise { + if (installation.source.kind !== 'global-package' || !installation.metadata?.packagePath) { + const item = planItem(installation) + const version = installation.version.latest ?? '' + return { + ...item, + manualCommands: [ + `npm install --global nsolid-plugin@${version}`, + `pnpm add --global nsolid-plugin@${version}`, + `npx -y nsolid-plugin@${version} `, + ], + } + } + if (!isMutableVersion(installation)) return planItem(installation) + if (installation.version.latest && (installation.artifact?.kind !== 'npm' || !installation.artifact.tarballPath)) { + return planItem(installation, [], [], undefined, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'CLI update requires a verified registry tarball identity' }) + } + const ownership = { + manager: installation.source.packageManager, + packageRoot: installation.metadata.packageRoot ?? '', + packagePath: installation.metadata.packagePath, + executable: installation.metadata.packageManagerExecutable ?? installation.source.packageManager, + rollbackCommand: installation.metadata.rollbackCommand ?? formatRollbackCommand(installation.source.packageManager, installation.version.current), + } + const command = buildGlobalUpdateCommand(ownership, installation.version.latest!, installation.artifact?.kind === 'npm' ? installation.artifact : undefined) + return planItem( + installation, + [ + { kind: 'command', description: 'Update the globally owned CLI package at the resolved version', command }, + { + kind: 'validation', + description: 'Verify the positively identified global package root', + checks: [`${installation.metadata.packagePath}/package.json has name nsolid-plugin and version ${installation.version.latest}`], + }, + ], + [{ + kind: 'command', + description: 'Restore the previously installed CLI version', + command: { + executable: installation.metadata.packageManagerExecutable ?? installation.source.packageManager, + args: installation.source.packageManager === 'npm' + ? ['install', '--global', `nsolid-plugin@${installation.version.current}`] + : ['add', '--global', `nsolid-plugin@${installation.version.current}`], + timeoutMs: 120_000, + }, + }], + 'Invoke nsolid-plugin again (or start a new shell) to use the new CLI code' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) { + if (item.source.kind === 'unsupported') return resultFromPlan(item, 'unsupported') + return resultFromPlan(item, noMutationStatus(item.version)) + } + const commandStep = item.steps.find((step) => step.kind === 'command') + if (!commandStep || commandStep.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'CLI update plan has no command' }) + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) { + await cleanupNpmArtifact(item.artifact) + return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned CLI tarball no longer matches its registry integrity' }) + } + const result = await context.commandRunner.run(commandStep.command) + if (!isCommandSuccessful(result)) { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return failedResult(item, commandFailure(commandStep.command.executable, result.timedOut, result.spawnErrorCode)) + } + const packagePath = item.metadata?.packagePath + if (!packagePath || !item.version.latest || !verifyGlobalPackage({ + manager: item.source.kind === 'global-package' ? item.source.packageManager : 'npm', + packageRoot: item.metadata?.packageRoot ?? '', + packagePath, + executable: commandStep.command.executable, + rollbackCommand: '', + }, item.version.latest, item.artifact?.kind === 'npm' ? item.artifact : undefined)) { + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return failedResult(item, { + code: 'CLI_VERSION_MISMATCH', + message: 'Package manager completed but the identified global package has the wrong version', + }) + } + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest }) + }, +} diff --git a/packages/core/src/update/strategies/codex.ts b/packages/core/src/update/strategies/codex.ts new file mode 100644 index 0000000..c3742be --- /dev/null +++ b/packages/core/src/update/strategies/codex.ts @@ -0,0 +1,75 @@ +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, findExecutable } from '../command-runner.js' +import { executeCodexTransaction, resolveCodexPluginCachePath } from '../codex-transaction.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { resolveHome } from '../../utils/path.js' + +export const codexStrategy: UpdateStrategy = { + target: 'codex', + ownership: 'native-plugin', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'codex-marketplace' || !isMutableVersion(installation)) return planItem(installation) + if (!/^nsolid-plugin@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.pluginId)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_PLUGIN_ID', message: 'Detected Codex plugin identity is ambiguous' }) + } + if (!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/.test(source.marketplace)) { + return planItem(installation, [], [], undefined, { code: 'INVALID_MARKETPLACE_ID', message: 'Detected Codex marketplace identity is ambiguous' }) + } + const configPath = path.resolve(installation.metadata?.configPath ?? process.env.CODEX_CONFIG_PATH ?? resolveHome('~/.codex/config.toml')) + const plannedInstallation = { + ...installation, + metadata: { ...(installation.metadata ?? {}), configPath }, + } + const cachePath = resolveCodexPluginCachePath(configPath, source.pluginId, source.marketplace, installation.metadata?.packageRoot) ?? + '' + return { + ...planItem( + plannedInstallation, + [ + { + kind: 'command', + description: `Refresh the detected Codex marketplace ${source.marketplace}`, + command: { executable: findExecutable('codex') ?? 'codex', args: ['plugin', 'marketplace', 'upgrade', source.marketplace], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { kind: 'filesystem', description: 'Back up the exact Codex plugin registration and cached payload', operation: 'backup', paths: [configPath, cachePath] }, + { + kind: 'command', + description: `Remove the detected plugin ${source.pluginId} before reinstalling the refreshed snapshot`, + command: { executable: findExecutable('codex') ?? 'codex', args: ['plugin', 'remove', source.pluginId], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { + kind: 'command', + description: `Reinstall the detected plugin ${source.pluginId} from the refreshed marketplace`, + command: { executable: findExecutable('codex') ?? 'codex', args: ['plugin', 'add', source.pluginId], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS }, + }, + { kind: 'validation', description: 'Validate the reinstalled local Codex plugin version and preserved configuration', checks: [`${source.pluginId} matches refreshed version ${installation.version.latest}`, 'unrelated Codex configuration remains unchanged'] }, + { kind: 'filesystem', description: 'Remove the successful Codex transaction backup', operation: 'cleanup', paths: [configPath] }, + ], + [ + { kind: 'filesystem', description: 'Restore the prior Codex plugin registration and cached payload', operation: 'restore', paths: [configPath, cachePath] }, + ], + 'Start a new Codex session to load the updated plugin' + ), + manualCommands: [ + `codex plugin remove ${source.pluginId}`, + `codex plugin add ${source.pluginId}`, + ], + } + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const transaction = await executeCodexTransaction(item, context.commandRunner) + if (!transaction.success) { + return failedResult(item, transaction.error ?? { code: 'CODEX_TRANSACTION_FAILED', message: 'Codex replacement failed' }, { + attempted: transaction.rollbackAttempted, + succeeded: transaction.rollbackSucceeded, + }) + } + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) + }, +} diff --git a/packages/core/src/update/strategies/common.ts b/packages/core/src/update/strategies/common.ts new file mode 100644 index 0000000..150a009 --- /dev/null +++ b/packages/core/src/update/strategies/common.ts @@ -0,0 +1,88 @@ +import type { + UpdateError, + UpdateInstallation, + UpdatePlanItem, + UpdatePlanStep, + UpdateResult, + UpdateStatus, + VersionInfo, +} from '../types.js' + +export function planItem ( + installation: UpdateInstallation, + steps: readonly UpdatePlanStep[] = [], + rollbackSteps: readonly UpdatePlanStep[] = [], + restartHint?: string, + planningError?: UpdateError +): UpdatePlanItem { + return { + installationId: installation.installationId, + target: installation.target, + ownership: installation.ownership, + installed: installation.installed, + source: installation.source, + version: installation.version, + steps, + rollbackSteps, + planningError, + requiresConfirmation: steps.length > 0 && !planningError, + restartHint, + metadata: installation.metadata, + artifact: installation.artifact, + fallbackTransaction: installation.fallbackTransaction, + } +} + +export function resultFromPlan (item: UpdatePlanItem, status: UpdateStatus, extra: Partial = {}): UpdateResult { + return { + installationId: item.installationId, + target: item.target, + ownership: item.ownership, + status, + currentVersion: item.version.current, + latestVersion: item.version.latest, + changed: status === 'updated', + restartHint: item.restartHint, + manualCommands: item.manualCommands, + rollbackCommand: item.metadata?.rollbackCommand, + ...extra, + } +} + +export function noMutationStatus (version: VersionInfo): UpdateStatus { + switch (version.status) { + case 'current': return 'current' + case 'newer-than-registry': return 'newer-than-registry' + default: return 'unknown' + } +} + +export function failedResult (item: UpdatePlanItem, error: UpdateError, rollback?: UpdateResult['rollback']): UpdateResult { + return resultFromPlan(item, 'failed', { changed: false, error, rollback }) +} + +export function commandFailure (executable: string, timedOut = false, spawnErrorCode?: string): UpdateError { + if (spawnErrorCode === 'ENOENT') { + return { code: 'MISSING_EXECUTABLE', message: `${executable} executable was not found on PATH` } + } + return { + code: timedOut ? 'COMMAND_TIMEOUT' : 'COMMAND_FAILED', + message: timedOut ? `${executable} timed out` : `${executable} exited unsuccessfully`, + } +} + +export function isMutableVersion (item: UpdateInstallation): boolean { + if (item.version.status === 'update-available') return true + + // Native registrations commonly omit the installed version entirely. Once + // the source and ownership are positively identified, an exact refresh is + // still safe and is preferable to silently treating the target as current. + // The same rule repairs a tracked/package-owned cache whose manifest is + // missing, but never enables mutation for unsupported or uninstalled data. + return item.version.status === 'unknown' && + typeof item.version.latest === 'string' && + item.installed && + item.ownership !== 'none' && + item.source.kind !== 'none' && + item.source.kind !== 'unsupported' +} diff --git a/packages/core/src/update/strategies/fallback.ts b/packages/core/src/update/strategies/fallback.ts new file mode 100644 index 0000000..d5c8021 --- /dev/null +++ b/packages/core/src/update/strategies/fallback.ts @@ -0,0 +1,234 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import type { HarnessType } from '../../types.js' +import { DEFAULT_COMMAND_TIMEOUT_MS, findExecutable, isCommandSuccessful } from '../command-runner.js' +import { failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { getTrackingFilePath } from '../../utils/path.js' +import { getHarnessSkillsPath } from '../../skills/skill-linker.js' +import { beginFallbackJournal, commitFallbackJournal, markFallbackJournalMutating, recoverFallbackJournal, trackingDigest, valueDigest, restoreFallbackJournal, type FallbackJournal } from '../fallback-journal.js' +import { cleanupNpmArtifact } from '../version-source.js' +import { verifyLocalArtifact } from '../package-manager.js' +import { readTrackingFile } from '../../skills/skill-tracker.js' +import { readJsonFile, readJsoncFile, readTomlFile } from '../../utils/config.js' + +export const fallbackStrategy: UpdateStrategy = { + target: 'opencode', + ownership: 'fallback', + + async plan (installation: UpdateInstallation): Promise { + if (installation.source.kind !== 'fallback') { + return { + ...planItem(installation), + manualCommands: [`nsolid-plugin install --harness ${installation.target}`], + } + } + if (!isMutableVersion(installation)) return planItem(installation) + const executor = installation.source.executor ?? detectExecutor() + if (!executor) { + const unsupportedInstallation = { + ...installation, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:fallback executor`, + reason: 'unsupported-manager' as const, + }, + } + return { + ...planItem(unsupportedInstallation), + manualCommands: [ + `npm exec --yes --package=nsolid-plugin@${installation.version.latest ?? ''} -- nsolid-plugin-refresh-owned --harness ${installation.target}`, + `pnpm --package=nsolid-plugin@${installation.version.latest ?? ''} dlx nsolid-plugin-refresh-owned --harness ${installation.target}`, + ], + } + } + if (installation.artifact?.kind !== 'npm' || !installation.artifact.tarballPath) { + return planItem(installation, [], [], undefined, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'Fallback update requires a verified registry tarball identity' }) + } + const executable = findExecutable(executor === 'npm-exec' ? 'npm' : 'pnpm') ?? (executor === 'npm-exec' ? 'npm' : 'pnpm') + const identity = createFallbackIdentity(installation) + if (!identity) { + const unsupportedInstallation = { + ...installation, + source: { + kind: 'unsupported' as const, + source: `${installation.target}:tracking`, + reason: 'untracked' as const, + }, + } + return { + ...planItem(unsupportedInstallation), + manualCommands: [ + `nsolid-plugin install --harness ${installation.target}`, + `nsolid-plugin update --harness ${installation.target} --check`, + ], + } + } + const manifestPath = await createManifest(identity) + const version = installation.version.latest! + const command = executor === 'npm-exec' + ? { executable, args: ['exec', '--yes', `--package=${installation.artifact?.kind === 'npm' && installation.artifact.tarballPath ? installation.artifact.tarballPath : `nsolid-plugin@${version}`}`, '--', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } + : { executable, args: [`--package=${installation.artifact?.kind === 'npm' && installation.artifact.tarballPath ? installation.artifact.tarballPath : `nsolid-plugin@${version}`}`, 'dlx', 'nsolid-plugin-refresh-owned', '--transaction', manifestPath], timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS } + const paths = installation.metadata?.trackedSkills?.map((skill) => skill.path) ?? [] + if (installation.metadata?.trackedMcpConfigPath) paths.push(installation.metadata.trackedMcpConfigPath) + return planItem( + { ...installation, source: { ...installation.source, executor }, fallbackTransaction: identity }, + [ + { kind: 'filesystem', description: 'Back up tracked NodeSource-owned fallback assets', operation: 'backup', paths }, + { kind: 'command', description: `Refresh the owned ${installation.target} bundle at exact version ${version}`, command }, + { kind: 'validation', description: 'Validate skills, MCP ownership, tracking paths, and per-harness bundle version evidence', checks: ['tracked skills match new bundle', 'unrelated MCP entries are preserved', `${installation.target} bundleVersions entry is ${version}`] }, + { kind: 'filesystem', description: 'Remove the successful fallback backup', operation: 'cleanup', paths }, + ], + [{ kind: 'filesystem', description: 'Restore tracked fallback assets and tracking state', operation: 'restore', paths }], + installation.target === 'opencode' ? 'Restart OpenCode to load refreshed skills' : undefined + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const step = item.steps.find((entry) => entry.kind === 'command') + if (!step || step.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'Fallback update plan has no command' }) + const workspace = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-update-')) + let journal: FallbackJournal | undefined + try { + await chmod(workspace, 0o700) + // Anchor npm/pnpm's project discovery inside the private directory so + // parent-level /tmp/package.json, .npmrc, or node_modules/.bin entries + // cannot influence exact-package execution. + await writeFile(path.join(workspace, 'package.json'), '{"private":true}\n', { mode: 0o600 }) + await writeFile(path.join(workspace, '.npmrc'), '', { mode: 0o600 }) + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) { + return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned fallback tarball no longer matches its registry integrity' }) + } + if (item.fallbackTransaction) { + const recovery = await recoverFallbackJournal(item.fallbackTransaction.trackingPath, true) + if (!recovery.recovered) return failedResult(item, { code: 'FALLBACK_RECOVERY_FAILED', message: 'A previous fallback transaction could not be recovered' }, { attempted: true, succeeded: false }) + try { + journal = (await beginFallbackJournal(item.fallbackTransaction)).journal + journal = await markFallbackJournalMutating(journal) + } catch (error) { + if (error instanceof Error && error.message === 'FALLBACK_TRACKING_DRIFT') { + return failedResult(item, { code: 'FALLBACK_TRACKING_DRIFT', message: 'Fallback tracking file changed after planning' }, { attempted: false }) + } + return failedResult(item, { code: 'FALLBACK_BACKUP_FAILED', message: 'Fallback parent snapshot could not be completed' }, { attempted: false }) + } + } + const result = await context.commandRunner.run({ + ...step.command, + cwd: workspace, + env: { + ...step.command.env, + NPM_CONFIG_USERCONFIG: path.join(workspace, '.npmrc'), + npm_config_userconfig: path.join(workspace, '.npmrc'), + }, + }) + if (!isCommandSuccessful(result)) { + const rollback = parseRollbackState(`${result.stdout}\n${result.stderr}`) + const parentRecovered = journal ? await restoreFallbackJournal(journal) : undefined + return failedResult( + item, + { + code: result.spawnErrorCode === 'ENOENT' + ? 'MISSING_EXECUTABLE' + : result.timedOut + ? 'FALLBACK_COMMAND_TIMEOUT' + : rollback?.attempted && rollback.succeeded === false ? 'FALLBACK_ROLLBACK_FAILED' : 'FALLBACK_COMMAND_FAILED', + message: result.spawnErrorCode === 'ENOENT' + ? `${step.command.executable} executable was not found on PATH` + : rollback?.attempted && rollback.succeeded === false + ? 'Fallback refresh command failed and its rollback was incomplete' + : 'Fallback refresh command failed', + }, + parentRecovered === false ? { attempted: true, succeeded: false } : rollback ?? (journal ? { attempted: true, succeeded: parentRecovered === true } : { attempted: false }) + ) + } + if (journal) { + const tracking = await readTrackingFile() + const bundleEvidence = tracking?.bundleVersions?.[item.target as keyof typeof tracking.bundleVersions] ?? tracking?.bundleVersion + if (!tracking || bundleEvidence !== item.version.latest || !validateFallbackPostconditions(tracking, item.target)) { + const recovered = await restoreFallbackJournal(journal) + return failedResult(item, { code: recovered ? 'FALLBACK_VALIDATION_FAILED' : 'FALLBACK_ROLLBACK_FAILED', message: recovered ? 'Fallback child completed without the planned bundle evidence' : 'Fallback validation failed and parent recovery was incomplete' }, { attempted: true, succeeded: recovered }) + } + } + if (journal) await commitFallbackJournal(journal) + await cleanupNpmArtifact(item.artifact?.kind === 'npm' ? item.artifact : undefined) + return resultFromPlan(item, 'updated', { resultingVersion: item.version.latest, rollback: { attempted: false } }) + } finally { + await rm(workspace, { recursive: true, force: true }).catch(() => {}) + const transactionIndex = step.command.args.indexOf('--transaction') + const manifestPath = transactionIndex >= 0 ? step.command.args[transactionIndex + 1] : undefined + if (manifestPath) await rm(path.dirname(manifestPath), { recursive: true, force: true }).catch(() => {}) + } + }, +} + +function createFallbackIdentity (installation: UpdateInstallation) { + const trackingPath = getTrackingFilePath() + const digest = trackingDigest(trackingPath) + if (!digest) return undefined + const skills = installation.metadata?.trackedSkills ?? [] + const configPath = installation.metadata?.trackedMcpConfigPath + const names = installation.metadata?.trackedMcpNames ?? [] + const trackedFields = installation.metadata?.trackedMcpFields ?? [] + if (names.length > 0 && installation.metadata?.trackedMcpOwnershipComplete === false) return undefined + return { + installationId: installation.installationId, + harness: installation.target as HarnessType, + trackingPath, + trackingDigest: digest, + ownedSkillPaths: skills.map((skill) => path.resolve(skill.path)), + ownedLinkPaths: skills.map((skill) => path.join(getHarnessSkillsPath(installation.target as HarnessType), skill.name)), + ownedMcpFields: trackedFields.length > 0 + ? trackedFields.map((field) => ({ ...field, configPath: path.resolve(field.configPath) })) + : configPath + ? names.flatMap((name) => Object.entries(readMcpRecord(configPath, name) ?? {}).map(([field, value]) => ({ configPath: path.resolve(configPath), server: name, field, expectedDigest: valueDigest(value) }))) + : [], + } as const +} + +async function createManifest (identity: NonNullable>): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'nsolid-plugin-manifest-')) + const manifestPath = path.join(directory, 'transaction.json') + await writeFile(manifestPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 }) + return manifestPath +} + +function readMcpRecord (configPath: string, name: string): Record | undefined { + try { + const value = configPath.endsWith('.toml') + ? readTomlFile>(configPath) + : configPath.endsWith('.jsonc') + ? readJsoncFile>(configPath) + : readJsonFile>(configPath) + const servers = value?.mcpServers ?? value?.mcp_servers ?? value?.mcp + const record = servers && typeof servers === 'object' ? (servers as Record)[name] : undefined + return record && typeof record === 'object' && !Array.isArray(record) ? record as Record : undefined + } catch { return undefined } +} + +function parseRollbackState (output: string): UpdateResult['rollback'] | undefined { + const match = output.match(/(?:^|\n)rollback:\s*(succeeded|failed|not-attempted)\s*(?:\n|$)/i) + if (!match) return undefined + if (match[1].toLowerCase() === 'not-attempted') return { attempted: false } + return { attempted: true, succeeded: match[1].toLowerCase() === 'succeeded' } +} + +function detectExecutor (): 'npm-exec' | 'pnpm-dlx' | undefined { + if (findExecutable('npm')) return 'npm-exec' + if (findExecutable('pnpm')) return 'pnpm-dlx' + return undefined +} + +function validateFallbackPostconditions (tracking: Awaited>, harness: UpdatePlanItem['target']): boolean { + if (!tracking || harness === 'cli') return false + const scopedSkills = tracking.skills.filter((entry) => entry.harnesses.includes(harness)) + if (scopedSkills.some((entry) => { + const skillPath = entry.paths?.[harness] ?? entry.path + return !path.isAbsolute(skillPath) || !existsSync(skillPath) + })) return false + const scopedMcp = tracking.mcpServers.filter((entry) => entry.harness === harness) + return scopedMcp.every((entry) => path.isAbsolute(entry.configPath) && existsSync(entry.configPath)) +} diff --git a/packages/core/src/update/strategies/pi.ts b/packages/core/src/update/strategies/pi.ts new file mode 100644 index 0000000..9bfeeb1 --- /dev/null +++ b/packages/core/src/update/strategies/pi.ts @@ -0,0 +1,99 @@ +import type { UpdateContext, UpdateInstallation, UpdatePlanItem, UpdateResult, UpdateStrategy } from '../types.js' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync } from 'node:fs' +import { DEFAULT_COMMAND_TIMEOUT_MS, findExecutable, isCommandSuccessful } from '../command-runner.js' +import { compareVersions, isStableVersion } from '../version.js' +import { commandFailure, failedResult, isMutableVersion, noMutationStatus, planItem, resultFromPlan } from './common.js' +import { readPackageVersion, verifyLocalArtifact } from '../package-manager.js' + +export const piStrategy: UpdateStrategy = { + target: 'pi', + ownership: 'package-owned', + + async plan (installation: UpdateInstallation): Promise { + const source = installation.source + if (source.kind !== 'pi-package' || !isMutableVersion(installation)) return planItem(installation) + const approve = (source.scopes as readonly string[]).includes('project') + const projectRoot = 'projectRoot' in source ? source.projectRoot : undefined + return planItem( + installation, + [{ + kind: 'command', + description: `Update Pi package caches (${source.scopes.join(' and ')})${projectRoot ? ` at ${projectRoot}` : ''}`, + command: { + executable: findExecutable('pi') ?? 'pi', + args: ['update', 'npm:nsolid-pi-plugin', approve ? '--approve' : '--no-approve'], + cwd: projectRoot, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }, + }, + { kind: 'validation', description: 'Verify every affected Pi package cache is at least the planned version', checks: ['nsolid-pi-plugin package name', `version >= ${installation.version.latest}`] }], + [], + '/reload or restart Pi' + ) + }, + + async execute (item: UpdatePlanItem, context: UpdateContext): Promise { + if (item.planningError) return failedResult(item, item.planningError) + if (item.steps.length === 0) return resultFromPlan(item, item.source.kind === 'unsupported' ? 'unsupported' : noMutationStatus(item.version)) + const step = item.steps.find((entry) => entry.kind === 'command') + if (!step || step.kind !== 'command') return failedResult(item, { code: 'INVALID_PLAN', message: 'Pi update plan has no command' }) + if (item.version.latest && (item.artifact?.kind !== 'npm' || !item.artifact.integrity)) { + return failedResult(item, { code: 'ARTIFACT_IDENTITY_REQUIRED', message: 'Pi update could not prove the planned registry artifact identity' }) + } + if (item.artifact?.kind === 'npm' && !verifyLocalArtifact(item.artifact)) return failedResult(item, { code: 'ARTIFACT_INTEGRITY_FAILED', message: 'The planned Pi package artifact no longer matches its registry integrity' }) + const drift = revalidatePiPlan(item) + if (drift) return failedResult(item, drift) + const result = await context.commandRunner.run(step.command) + if (!isCommandSuccessful(result)) { + const error = commandFailure(step.command.executable, result.timedOut, result.spawnErrorCode) + return failedResult(item, error.code === 'COMMAND_FAILED' ? { code: 'PI_COMMAND_FAILED', message: 'Pi package update failed' } : error) + } + + const roots = item.metadata?.packageRoots ?? [] + const versions = roots.map((root) => readPackageVersion(root, 'nsolid-pi-plugin')).filter((version): version is string => isStableVersion(version)) + if (roots.length > 0 && versions.length !== roots.length) { + return failedResult(item, { code: 'PI_PACKAGE_MISSING', message: 'An affected Pi package cache is missing after update' }) + } + if (item.version.latest && versions.length > 0 && versions.some((version) => compareVersions(version, item.version.latest!) < 0)) { + return failedResult(item, { code: 'PI_VERSION_MISMATCH', message: 'One affected Pi package cache is older than the planned version' }) + } + return resultFromPlan(item, 'updated', { resultingVersion: versions.sort((a, b) => compareVersions(b, a))[0] ?? item.version.latest }) + }, +} + +function revalidatePiPlan (item: UpdatePlanItem) { + const metadata = item.metadata + if (!metadata) return undefined + if (metadata.projectRoot && metadata.projectRootIdentity && safeRealpath(metadata.projectRoot) !== metadata.projectRootIdentity) { + return { code: 'PI_SCOPE_DRIFT', message: 'Pi project root changed after planning' } + } + const paths = metadata.settingsPaths ?? [] + const expected = metadata.settingsDigests ?? [] + if (paths.length !== expected.length || paths.some((filePath, index) => digest(filePath) !== expected[index])) { + return { code: 'PI_SETTINGS_DRIFT', message: 'Pi settings changed after planning' } + } + const roots = metadata.packageRoots ?? [] + const rootIdentities = metadata.packageRootIdentities ?? [] + const cacheDigests = metadata.cacheDigests ?? [] + if (roots.length !== rootIdentities.length || roots.some((root, index) => safeRealpath(root) !== rootIdentities[index])) { + return { code: 'PI_CACHE_DRIFT', message: 'Pi package cache roots changed after planning' } + } + if (roots.length !== cacheDigests.length || roots.some((root, index) => digest(path.join(root, 'package.json')) !== cacheDigests[index])) { + return { code: 'PI_CACHE_DRIFT', message: 'Pi package cache contents changed after planning' } + } + const sources = metadata.sourceEntries ?? [] + if (sources.some((source) => source !== 'npm:nsolid-pi-plugin')) { + return { code: 'PI_SOURCE_DRIFT', message: 'Pi package source changed after planning' } + } + return undefined +} + +function digest (filePath: string): string { + try { return createHash('sha256').update(readFileSync(filePath)).digest('hex') } catch { return '' } +} + +function safeRealpath (filePath: string): string { + try { return realpathSync(filePath) } catch { return path.resolve(filePath) } +} diff --git a/packages/core/src/update/types.ts b/packages/core/src/update/types.ts new file mode 100644 index 0000000..e09724d --- /dev/null +++ b/packages/core/src/update/types.ts @@ -0,0 +1,333 @@ +import type { HarnessType } from '../types.js' + +export type UpdateTarget = 'cli' | HarnessType + +export type UpdateOwnership = + | 'global-package' + | 'native-plugin' + | 'package-owned' + | 'fallback' + | 'none' + +export type VersionStatus = + | 'current' + | 'update-available' + | 'newer-than-registry' + | 'unknown' + +export type UpdateStatus = + | 'current' + | 'update-available' + | 'newer-than-registry' + | 'updated' + | 'skipped' + | 'not-installed' + | 'unsupported' + | 'unknown' + | 'failed' + +export interface VersionInfo { + current?: string + latest?: string + status: VersionStatus + /** All detected copies when one logical target spans multiple caches/scopes. */ + currentVersions?: readonly (string | undefined)[] +} + +export interface RunningVersionInfo { + cliVersion: string + bundleVersion: string +} + +export type ClaudePluginScope = 'user' | 'project' | 'local' | 'managed' + +export type MarketplaceVersionSource = + | { + kind: 'git' + repository: string + revision?: string + commit?: string + contentDigest?: string + manifestPath: string + } + | { + kind: 'local-snapshot' + root: string + manifestPath: string + freshness: 'verified' | 'stale' | 'unknown' + contentDigest?: string + } + | { + kind: 'unknown' + reason: 'missing-metadata' | 'ambiguous' | 'unsupported' + } + +export type PiPackageLocation = + | { scopes: readonly ['user'] } + | { scopes: readonly ['project']; projectRoot: string } + | { scopes: readonly ['user', 'project']; projectRoot: string } + +export type FallbackPackageExecutor = 'npm-exec' | 'pnpm-dlx' + +export interface NpmArtifactIdentity { + kind: 'npm' + packageName: 'nsolid-plugin' | 'nsolid-pi-plugin' + version: string + registry: string + tarball: string + integrity: string + /** Planner-only local path; never render this in public output. */ + tarballPath?: string + tempDirectory?: string + contentDigest?: string +} + +export interface GitArtifactIdentity { + kind: 'git' + repository: string + commit: string + contentDigest: string +} + +export interface LocalArtifactIdentity { + kind: 'local-snapshot' + root: string + contentDigest: string +} + +export type ResolvedArtifactIdentity = NpmArtifactIdentity | GitArtifactIdentity | LocalArtifactIdentity + +export interface FallbackTransactionIdentity { + installationId: string + harness: HarnessType + trackingPath: string + trackingDigest: string + ownedSkillPaths: readonly string[] + ownedLinkPaths: readonly string[] + ownedMcpFields: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] +} + +export type AntigravityLayout = + | { + kind: 'shared' + pluginRoot: '~/.gemini/config/plugins/nsolid-plugin' + manifestPath: '~/.gemini/config/import_manifest.json' + } + | { + kind: 'agy-cli' + pluginRoot: '~/.gemini/antigravity-cli/plugins/nsolid-plugin' + manifestPath: '~/.gemini/antigravity-cli/import_manifest.json' + } + +export type UpdateSource = + | { kind: 'none' } + | { kind: 'global-package'; packageManager: 'npm' | 'pnpm'; packageName: 'nsolid-plugin' } + | { + kind: 'claude-marketplace' + pluginId: string + marketplace: string + scope: ClaudePluginScope + versionSource: MarketplaceVersionSource + } + | { + kind: 'codex-marketplace' + pluginId: string + marketplace: string + versionSource: MarketplaceVersionSource + } + | ({ kind: 'pi-package'; spec: 'npm:nsolid-pi-plugin' } & PiPackageLocation) + | { + kind: 'unsupported' + source: string + reason: 'local' | 'git' | 'pinned' | 'ambiguous' | 'conflicting' | 'untracked' | 'unsupported-manager' + } + | { + kind: 'antigravity-git' + url: 'https://github.com/NodeSource/nsolid-plugin.git' + layout: AntigravityLayout + } + | { kind: 'fallback'; bundleVersion?: string; executor?: FallbackPackageExecutor } + +/** Additional read-only evidence used by strategies. It never reaches CLI output verbatim. */ +export interface UpdateInstallationMetadata { + /** Exact native configuration path approved during planning. */ + configPath?: string + packageRoot?: string + packagePath?: string + previousVersion?: string + rollbackCommand?: string + trackedSkills?: readonly { name: string; path: string }[] + trackedMcpConfigPath?: string + trackedMcpNames?: readonly string[] + trackedMcpFields?: readonly { + configPath: string + server: string + field: string + expectedDigest: string + }[] + trackedMcpOwnershipComplete?: boolean + projectRoot?: string + packageRoots?: readonly string[] + packageRootIdentities?: readonly string[] + pluginRoot?: string + manifestPath?: string + packageManagerExecutable?: string + projectRootIdentity?: string + settingsPaths?: readonly string[] + settingsDigests?: readonly string[] + sourceEntries?: readonly string[] + cacheDigests?: readonly string[] +} + +export interface UpdateInstallation { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo + metadata?: UpdateInstallationMetadata + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity +} + +export interface UpdateOptions { + harness?: HarnessType + all?: boolean + check?: boolean + yes?: boolean + json?: boolean + verbose?: boolean + noColor?: boolean + cwd?: string + packageRoot?: string + fetchImpl?: typeof fetch + commandRunner?: CommandRunner + confirm?: UpdateConfirmation +} + +export interface CommandSpec { + executable: string + args: readonly string[] + cwd?: string + env?: Readonly> + timeoutMs: number +} + +export interface CommandResult { + exitCode: number | null + signal?: NodeJS.Signals + /** OS error raised before the child process started, for example ENOENT. */ + spawnErrorCode?: string + stdout: string + stderr: string + timedOut: boolean +} + +export interface CommandRunner { + run(spec: CommandSpec): Promise +} + +export type UpdatePlanStep = + | { + kind: 'command' + description: string + command: CommandSpec + } + | { + kind: 'filesystem' + description: string + operation: 'backup' | 'replace' | 'reconcile' | 'restore' | 'cleanup' + paths: readonly string[] + } + | { + kind: 'validation' + description: string + checks: readonly string[] + } + +export interface UpdateError { + code: string + message: string +} + +export interface UpdatePlanItem { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + installed: boolean + source: UpdateSource + version: VersionInfo + steps: readonly UpdatePlanStep[] + rollbackSteps: readonly UpdatePlanStep[] + planningError?: UpdateError + requiresConfirmation: boolean + restartHint?: string + manualCommands?: readonly string[] + metadata?: UpdateInstallationMetadata + artifact?: ResolvedArtifactIdentity + fallbackTransaction?: FallbackTransactionIdentity +} + +export interface UpdatePlan { + checkOnly: boolean + items: readonly UpdatePlanItem[] +} + +export interface UpdateConfirmationContext { + items: readonly UpdatePlanItem[] +} + +export type UpdateConfirmation = ( + context: UpdateConfirmationContext +) => boolean | Promise + +export interface UpdateResult { + installationId: string + target: UpdateTarget + ownership: UpdateOwnership + status: UpdateStatus + currentVersion?: string + latestVersion?: string + resultingVersion?: string + changed: boolean + restartHint?: string + rollbackCommand?: string + manualCommands?: readonly string[] + rollback?: { + attempted: boolean + succeeded?: boolean + } + error?: UpdateError +} + +export interface UpdateSummary { + checkOnly: boolean + results: UpdateResult[] + counts: Record + success: boolean + exitCode: 0 | 1 | 2 +} + +export interface UpdateContext { + options: Readonly + commandRunner: CommandRunner +} + +export interface UpdateStrategy { + readonly target: UpdateTarget + readonly ownership: UpdateOwnership + plan(installation: UpdateInstallation, context: UpdateContext): Promise + execute(item: UpdatePlanItem, context: UpdateContext): Promise +} + +export interface VersionLookupResult { + version?: string + error?: UpdateError + artifact?: ResolvedArtifactIdentity +} diff --git a/packages/core/src/update/version-source.ts b/packages/core/src/update/version-source.ts new file mode 100644 index 0000000..2b1b070 --- /dev/null +++ b/packages/core/src/update/version-source.ts @@ -0,0 +1,335 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { MarketplaceVersionSource, NpmArtifactIdentity, UpdateError, VersionLookupResult } from './types.js' +import { isStableVersion } from './version.js' + +export interface VersionSourceOptions { + fetchImpl?: typeof fetch + /** Effective npm registry captured for this lookup (defaults to npmjs). */ + registry?: string + timeoutMs?: number + /** Download and verify the immutable npm artifact for a mutating plan. */ + downloadArtifact?: boolean + /** Reject mutable Git refs that could not be resolved to a commit. */ + requireImmutable?: boolean +} + +const DEFAULT_TIMEOUT_MS = 15_000 +const SAFE_RELATIVE_PATH = /^(?![\\/])(?!(?:.*[\\/])?\.\.(?:[\\/]|$))[A-Za-z0-9._/-]+$/ + +export async function resolveRegistryVersion ( + packageName: string, + options: VersionSourceOptions = {} +): Promise { + if (!/^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(packageName)) { + return { error: lookupError('INVALID_PACKAGE', 'Package name is invalid') } + } + + try { + const registry = normalizeRegistryUrl(options.registry) + const data = await fetchJson(`${registry}${encodeURIComponent(packageName)}`, options) + const metadata = data as { 'dist-tags'?: { latest?: unknown }; dist?: { tarball?: unknown; integrity?: unknown }; registry?: unknown } + const latest = metadata['dist-tags']?.latest + if (!isStableVersion(latest)) { + return { error: lookupError('INVALID_REGISTRY_VERSION', 'Registry latest version is invalid') } + } + const tarball = typeof metadata.dist?.tarball === 'string' ? metadata.dist.tarball : undefined + const integrity = typeof metadata.dist?.integrity === 'string' ? metadata.dist.integrity : undefined + // Keep version-only lookup compatibility for registries/proxies that omit + // dist metadata. Mutation strategies that require byte identity reject the + // resulting lookup before constructing an executable plan. + if (!tarball || !integrity) return { version: latest } + let parsedTarball: URL + try { + parsedTarball = new URL(tarball, registry) + if (!['https:', 'http:'].includes(parsedTarball.protocol)) throw new Error('unsupported tarball protocol') + } catch { + return { error: lookupError('INVALID_REGISTRY_ARTIFACT', 'Registry tarball URL is invalid') } + } + const artifact: NpmArtifactIdentity = { + kind: 'npm', + packageName: packageName as NpmArtifactIdentity['packageName'], + version: latest, + registry: typeof metadata.registry === 'string' ? normalizeRegistryUrl(metadata.registry).replace(/\/$/, '') : registry.replace(/\/$/, ''), + tarball: parsedTarball.toString(), + integrity, + } + if (options.downloadArtifact) { + try { + const downloaded = await downloadAndVerifyTarball(tarball, integrity, options) + artifact.tarballPath = downloaded.path + artifact.tempDirectory = downloaded.directory + artifact.contentDigest = downloaded.contentDigest + } catch (error) { + return { error: lookupError('ARTIFACT_INTEGRITY_FAILED', sanitizeLookupMessage(error)) } + } + } + return { version: latest, artifact } + } catch (error) { + return { error: lookupError('REGISTRY_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export async function resolveMarketplaceVersion ( + source: MarketplaceVersionSource, + options: VersionSourceOptions = {} +): Promise { + if (source.kind === 'unknown') { + return {} + } + + if (!isSafeManifestPath(source.manifestPath)) { + return {} + } + + if (source.kind === 'local-snapshot') { + if (source.freshness !== 'verified') { + return {} + } + const manifestPath = path.resolve(source.root, source.manifestPath) + const result = await readManifestVersion(manifestPath) + if (result.version) { + const contentDigest = await digestFile(manifestPath) + if (source.contentDigest && contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace snapshot content changed after discovery') } + if (contentDigest) result.artifact = { kind: 'local-snapshot', root: path.resolve(source.root), contentDigest } + } + return result + } + + const repository = sanitizeRepository(source.repository) + if (!repository) return { error: lookupError('INVALID_MARKETPLACE_SOURCE', 'Marketplace repository is invalid') } + let revision = isFullCommit(source.commit) ? source.commit : source.commit ?? source.revision ?? 'HEAD' + if (options.requireImmutable && !isFullCommit(revision)) { + const resolvedCommit = await resolveGitCommit(repository, revision, options) + if (!resolvedCommit) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace ref could not be resolved to an immutable commit') } + revision = resolvedCommit + } + if (!isSafeRevision(revision)) return {} + const rawUrl = toRawManifestUrl(repository, revision, source.manifestPath) + try { + const response = await fetchWithTimeout(rawUrl, options) + if (!response.ok) throw new Error(`marketplace returned ${response.status}`) + const body = await response.text() + const parsed = parseJsonResponse(body, 'marketplace response was not valid JSON') + const version = extractVersion(parsed) + if (!isStableVersion(version)) throw new Error('marketplace manifest version is invalid') + const responseCommit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? undefined + const commit = isFullCommit(responseCommit) ? responseCommit : isFullCommit(source.commit) ? source.commit : isFullCommit(revision) ? revision : undefined + if (options.requireImmutable && !commit) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Marketplace response did not identify an immutable commit') } + const contentDigest = sha256(body) + if (source.contentDigest && source.contentDigest !== contentDigest) return { error: lookupError('SOURCE_CONTENT_MISMATCH', 'Marketplace content changed after discovery') } + return commit + ? { version, artifact: { kind: 'git', repository, commit, contentDigest } } + : { version } + } catch (error) { + return { error: lookupError('MARKETPLACE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export async function resolveFixedGitBundleVersion ( + options: VersionSourceOptions = {} +): Promise { + try { + const repository = 'https://github.com/NodeSource/nsolid-plugin.git' + const revision = options.requireImmutable + ? await resolveGitCommit(repository, 'main', options) + : 'main' + if (options.requireImmutable && !revision) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source could not be resolved to an immutable commit') } + const effectiveRevision = revision ?? 'main' + const response = await fetchWithTimeout( + `https://raw.githubusercontent.com/NodeSource/nsolid-plugin/${effectiveRevision}/bundle.json`, + options + ) + if (!response.ok) throw new Error(`fixed source returned ${response.status}`) + const body = await response.text() + const data = parseJsonResponse(body, 'fixed source response was not valid JSON') + const version = extractVersion(data) + if (!isStableVersion(version)) throw new Error('fixed source version is invalid') + const commit = response.headers.get('x-commit-sha') ?? response.headers.get('x-git-commit') ?? effectiveRevision + if (options.requireImmutable && !isFullCommit(commit)) return { error: lookupError('IMMUTABLE_SOURCE_UNAVAILABLE', 'Fixed source response did not identify an immutable commit') } + return isFullCommit(commit) + ? { version, artifact: { kind: 'git', repository, commit, contentDigest: sha256(body) } } + : { version } + } catch (error) { + return { error: lookupError('FIXED_SOURCE_LOOKUP_FAILED', sanitizeLookupMessage(error)) } + } +} + +export function sanitizeRepository (repository: string): string | undefined { + const githubShorthand = repository.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?$/) + if (githubShorthand) return `https://github.com/${githubShorthand[1]}/${githubShorthand[2]}.git` + try { + const parsed = new URL(repository) + if (!['https:', 'http:', 'ssh:'].includes(parsed.protocol)) return undefined + parsed.username = '' + parsed.password = '' + parsed.hash = '' + parsed.search = '' + return parsed.toString().replace(/\/$/, '') + } catch { + return undefined + } +} + +export function isSafeManifestPath (manifestPath: string): boolean { + return SAFE_RELATIVE_PATH.test(manifestPath) && !manifestPath.includes('\\') +} + +function isSafeRevision (revision: string): boolean { + return revision.length > 0 && !revision.startsWith('/') && !revision.includes('\\') && !revision.split('/').includes('..') && !/[\s?#]/.test(revision) +} + +function toRawManifestUrl (repository: string, revision: string, manifestPath: string): string { + const parsed = new URL(repository) + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length < 2) throw new Error('marketplace repository has no owner/name') + const host = parsed.hostname.toLowerCase() + if (host === 'github.com') { + const encodedRevision = revision.split('/').map((segment) => encodeURIComponent(segment)).join('/') + return `https://raw.githubusercontent.com/${segments.join('/')}/${encodedRevision}/${manifestPath}` + } + const encodedRevision = revision.split('/').map((segment) => encodeURIComponent(segment)).join('/') + return `${repository}/raw/${encodedRevision}/${manifestPath}` +} + +async function readManifestVersion (filePath: string): Promise { + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown + const version = extractVersion(parsed) + return isStableVersion(version) + ? { version } + : { error: lookupError('INVALID_MARKETPLACE_VERSION', 'Marketplace manifest version is invalid') } + } catch { + return { error: lookupError('MARKETPLACE_LOOKUP_FAILED', 'Marketplace snapshot could not be read') } + } +} + +async function fetchJson (url: string, options: VersionSourceOptions): Promise { + const response = await fetchWithTimeout(url, options) + if (!response.ok) throw new Error(`registry returned ${response.status}`) + return parseJsonResponse(await response.text(), 'registry response was not valid JSON') +} + +function parseJsonResponse (body: string, failureMessage: string): unknown { + try { + return JSON.parse(body) as unknown + } catch { + throw new Error(failureMessage) + } +} + +async function fetchWithTimeout (url: string, options: VersionSourceOptions): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + try { + return await fetchImpl(url, { signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +function extractVersion (value: unknown): unknown { + if (!value || typeof value !== 'object') return undefined + const object = value as Record + if (typeof object.version === 'string') return object.version + const plugin = object.plugin + if (plugin && typeof plugin === 'object' && typeof (plugin as { version?: unknown }).version === 'string') { + return (plugin as { version: string }).version + } + return undefined +} + +function lookupError (code: string, message: string): UpdateError { + return { code, message: message.replace(/[\r\n]/g, ' ').slice(0, 240) } +} + +function sanitizeLookupMessage (error: unknown): string { + const message = error instanceof Error ? error.message : 'version lookup failed' + return message + .replace(/https?:\/\/[^\s/@]+:[^\s/@]+@/gi, 'https://[REDACTED]@') + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]') + .replace(/((?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/(?:[A-Za-z]:[\\/]|\/)[^\s]*(?:\.nodesource-auth|credentials?|\.npmrc)[^\s]*/gi, '[REDACTED_PATH]') + .replace(/[\r\n]/g, ' ') + .slice(0, 240) +} + +function normalizeRegistryUrl (value?: string): string { + const candidate = value || process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org/' + try { + const url = new URL(candidate) + if (!['https:', 'http:'].includes(url.protocol)) throw new Error('unsupported registry protocol') + url.username = '' + url.password = '' + url.search = '' + url.hash = '' + url.pathname = url.pathname.replace(/\/+$/, '') + '/' + return url.toString() + } catch { + return 'https://registry.npmjs.org/' + } +} + +function isFullCommit (value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value) +} + +async function resolveGitCommit (repository: string, revision: string, options: VersionSourceOptions): Promise { + try { + const parsed = new URL(repository) + if (parsed.hostname.toLowerCase() !== 'github.com') return undefined + const segments = parsed.pathname.replace(/\.git$/, '').split('/').filter(Boolean) + if (segments.length !== 2) return undefined + const url = `https://api.github.com/repos/${segments[0]}/${segments[1]}/commits/${revision.split('/').map(encodeURIComponent).join('/')}` + const response = await fetchWithTimeout(url, options) + if (!response.ok) return undefined + const body = JSON.parse(await response.text()) as { sha?: unknown; object?: { sha?: unknown }; commit?: { sha?: unknown } } + const commit = body.sha ?? body.object?.sha ?? body.commit?.sha + return isFullCommit(commit) ? commit : undefined + } catch { return undefined } +} + +function sha256 (value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +async function digestFile (filePath: string): Promise { + try { return sha256(await readFile(filePath)) } catch { return undefined } +} + +async function downloadAndVerifyTarball ( + url: string, + integrity: string, + options: VersionSourceOptions +): Promise<{ path: string; directory: string; contentDigest: string }> { + const response = await fetchWithTimeout(url, options) + if (!response.ok) throw new Error(`registry tarball returned ${response.status}`) + const bytes = new Uint8Array(await response.arrayBuffer()) + const match = integrity.match(/^sha(256|384|512)-([A-Za-z0-9+/=_-]+)$/i) + if (!match) throw new Error('registry integrity is invalid') + const algorithm = `sha${match[1]}` as 'sha256' | 'sha384' | 'sha512' + const actual = createHash(algorithm).update(bytes).digest('base64') + const expected = match[2].replace(/-/g, '+').replace(/_/g, '/') + if (actual !== expected) throw new Error('registry tarball integrity mismatch') + const directory = await mkdtemp(path.join(os.tmpdir(), 'nsolid-plugin-artifact-')) + const tarballPath = path.join(directory, 'package.tgz') + await writeFile(tarballPath, bytes, { mode: 0o600 }) + return { path: tarballPath, directory, contentDigest: sha256(bytes) } +} + +export async function cleanupNpmArtifact (artifact: NpmArtifactIdentity | undefined): Promise { + if (!artifact?.tempDirectory) return + await rm(artifact.tempDirectory, { recursive: true, force: true }).catch(() => {}) +} + +export async function downloadNpmArtifact ( + artifact: NpmArtifactIdentity, + options: VersionSourceOptions = {} +): Promise { + if (artifact.tarballPath) return artifact + const downloaded = await downloadAndVerifyTarball(artifact.tarball, artifact.integrity, options) + return { ...artifact, tarballPath: downloaded.path, tempDirectory: downloaded.directory, contentDigest: downloaded.contentDigest } +} diff --git a/packages/core/src/update/version.ts b/packages/core/src/update/version.ts new file mode 100644 index 0000000..f10a3a5 --- /dev/null +++ b/packages/core/src/update/version.ts @@ -0,0 +1,119 @@ +import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import type { RunningVersionInfo, VersionInfo, VersionStatus } from './types.js' + +export interface ParsedVersion { + major: number + minor: number + patch: number +} + +const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function parseStableVersion (value: unknown): ParsedVersion | null { + if (typeof value !== 'string') return null + const match = value.match(STABLE_VERSION) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } +} + +export function isStableVersion (value: unknown): value is string { + return parseStableVersion(value) !== null +} + +export function compareVersions (left: string, right: string): number { + const a = parseStableVersion(left) + const b = parseStableVersion(right) + if (!a || !b) throw new Error('Only stable semantic versions can be compared') + if (a.major !== b.major) return a.major - b.major + if (a.minor !== b.minor) return a.minor - b.minor + return a.patch - b.patch +} + +export function classifyVersions (current: unknown, latest: unknown): VersionInfo { + const currentVersion = isStableVersion(current) ? current : undefined + const latestVersion = isStableVersion(latest) ? latest : undefined + let status: VersionStatus = 'unknown' + + if (currentVersion && latestVersion) { + const comparison = compareVersions(currentVersion, latestVersion) + status = comparison === 0 + ? 'current' + : comparison < 0 + ? 'update-available' + : 'newer-than-registry' + } + + return { current: currentVersion, latest: latestVersion, status } +} + +/** + * Classify every physical copy behind one logical installation. A missing or + * malformed copy is actionable when a newer package is known: the update can + * repair that cache even though no version can be read from it. + */ +export function classifyVersionSet (currents: readonly unknown[], latest: unknown): VersionInfo { + const latestVersion = isStableVersion(latest) ? latest : undefined + const currentVersions = currents.map((value) => isStableVersion(value) ? value : undefined) + const stableVersions = currentVersions.filter((value): value is string => value !== undefined) + let status: VersionStatus = 'unknown' + + if (latestVersion && currentVersions.length > 0) { + const hasMissing = stableVersions.length !== currentVersions.length + const hasOlder = stableVersions.some((value) => compareVersions(value, latestVersion) < 0) + const allPresent = stableVersions.length === currentVersions.length && stableVersions.length > 0 + const hasNewer = stableVersions.some((value) => compareVersions(value, latestVersion) > 0) + if (hasMissing || hasOlder) status = 'update-available' + else if (allPresent && hasNewer) status = 'newer-than-registry' + else if (allPresent) status = 'current' + } else if (stableVersions.length === currentVersions.length && stableVersions.length > 0) { + status = 'unknown' + } + + const current = stableVersions.length > 0 + ? [...stableVersions].sort(compareVersions)[0] + : undefined + return { current, latest: latestVersion, status, currentVersions } +} + +export function readRunningVersionInfo (packageRoot = defaultPackageRoot()): RunningVersionInfo { + const packageJson = readJson(path.join(packageRoot, 'package.json')) as { version?: unknown } + const bundle = readJson(path.join(packageRoot, 'bundle.json')) as { version?: unknown } + if (!isStableVersion(packageJson.version)) throw new Error('Package version is missing or invalid') + if (!isStableVersion(bundle.version)) throw new Error('Bundle version is missing or invalid') + return { cliVersion: packageJson.version, bundleVersion: bundle.version } +} + +/** Find the nearest package root containing both runtime manifests. */ +export function resolvePackageRoot (startDir = path.dirname(fileURLToPath(import.meta.url))): string { + let candidate = path.resolve(startDir) + while (true) { + if (existsSync(path.join(candidate, 'package.json')) && existsSync(path.join(candidate, 'bundle.json'))) return candidate + const parent = path.dirname(candidate) + if (parent === candidate) break + candidate = parent + } + return path.resolve(startDir, '..', '..') +} + +export function readPackageVersion (packageRoot: string): string | undefined { + try { + const value = (readJson(path.join(packageRoot, 'package.json')) as { version?: unknown }).version + return isStableVersion(value) ? value : undefined + } catch { + return undefined + } +} + +function readJson (filePath: string): unknown { + return JSON.parse(readFileSync(filePath, 'utf8')) as unknown +} + +function defaultPackageRoot (): string { + return resolvePackageRoot() +} diff --git a/packages/core/test/integration/update-flow.test.ts b/packages/core/test/integration/update-flow.test.ts new file mode 100644 index 0000000..e81dd8e --- /dev/null +++ b/packages/core/test/integration/update-flow.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { checkUpdates, executeUpdatePlan, planUpdates } from '../../src/update/index.js' + +function registryFetch (version: string): typeof fetch { + return async () => new Response(JSON.stringify({ 'dist-tags': { latest: version } }), { status: 200 }) +} + +describe('update flow coordinator', () => { + it('checks the CLI without probing a package manager', async () => { + let calls = 0 + const commandRunner = { + run: async () => { + calls++ + throw new Error('read-only check must not probe npm or pnpm') + }, + } + const summary = await checkUpdates({ + packageRoot: path.resolve('packages/core'), + fetchImpl: registryFetch('999.0.0'), + commandRunner, + }) + + const cli = summary.results.find((result) => result.installationId === 'cli:global') + assert.equal(cli?.status, 'update-available') + assert.equal(calls, 0) + }) + + it('does not probe a package manager for a CLI newer than the registry', async () => { + let calls = 0 + const commandRunner = { + run: async () => { + calls++ + throw new Error('newer-than-registry must not probe npm or pnpm') + }, + } + const plan = await planUpdates({ + packageRoot: path.resolve('packages/core'), + fetchImpl: registryFetch('0.0.1'), + commandRunner, + }) + + const cli = plan.items.find((item) => item.installationId === 'cli:global') + assert.equal(cli?.version.status, 'newer-than-registry') + assert.equal(cli?.requiresConfirmation, false) + assert.equal(calls, 0) + }) + + it('emits a non-mutating not-installed item for an absent requested harness', async () => { + const calls: string[] = [] + const commandRunner = { + run: async (spec: { executable: string }) => { + calls.push(spec.executable) + return { exitCode: 1, stdout: '', stderr: '', timedOut: false } + }, + } + const summary = await checkUpdates({ + harness: 'pi', + fetchImpl: registryFetch('1.0.2'), + commandRunner, + }) + + assert.equal(summary.checkOnly, true) + assert.equal(summary.results[0]?.status, 'not-installed') + assert.equal(summary.success, true) + assert.ok(!calls.includes('pi')) + }) + + it('keeps update plans immutable and does not execute check plans', async () => { + const commandRunner = { + run: async () => { + throw new Error('check mode must not execute') + }, + } + const plan = await planUpdates({ + harness: 'pi', + check: true, + fetchImpl: registryFetch('1.0.2'), + commandRunner, + }) + const summary = await executeUpdatePlan(plan, { check: true, commandRunner }) + assert.equal(summary.checkOnly, true) + assert.equal(plan.items[0]?.steps.length, 0) + }) +}) diff --git a/packages/core/test/unit/update/antigravity-transaction.test.ts b/packages/core/test/unit/update/antigravity-transaction.test.ts new file mode 100644 index 0000000..62ddd81 --- /dev/null +++ b/packages/core/test/unit/update/antigravity-transaction.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { validateStagedPlugin } from '../../../src/update/antigravity-transaction.js' + +describe('Antigravity staged plugin validation', () => { + it('requires the staged bundle version to match the planned version', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-agy-validation-')) + try { + mkdirSync(path.join(root, 'skills', 'example'), { recursive: true }) + writeFileSync(path.join(root, 'plugin.json'), JSON.stringify({ name: 'nsolid-plugin' })) + writeFileSync(path.join(root, 'bundle.json'), JSON.stringify({ version: '1.0.1', skills: [{ name: 'example', path: 'skills/example' }] })) + writeFileSync(path.join(root, 'skills', 'example', 'SKILL.md'), '# example') + const manifest = path.join(root, 'import_manifest.json') + writeFileSync(manifest, JSON.stringify({ imports: [{ name: 'nsolid-plugin' }] })) + + assert.equal(validateStagedPlugin(root, manifest, '1.0.0'), false) + assert.equal(validateStagedPlugin(root, manifest, '1.0.1'), true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/update/cli-package-strategy.test.ts b/packages/core/test/unit/update/cli-package-strategy.test.ts new file mode 100644 index 0000000..49b5d98 --- /dev/null +++ b/packages/core/test/unit/update/cli-package-strategy.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { cliPackageStrategy } from '../../../src/update/strategies/cli-package.js' + +describe('CLI package update strategy', () => { + it('uses the resolved version in unsupported-source manual commands', async () => { + const item = await cliPackageStrategy.plan({ + installationId: 'cli:global', + target: 'cli', + ownership: 'none', + installed: true, + source: { kind: 'unsupported', source: '/workspace/cli.ts', reason: 'unsupported-manager' }, + version: { current: '1.0.0', latest: '1.2.3', status: 'update-available' }, + }, { + options: {}, + commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) }, + }) + + assert.deepEqual(item.manualCommands, [ + 'npm install --global nsolid-plugin@1.2.3', + 'pnpm add --global nsolid-plugin@1.2.3', + 'npx -y nsolid-plugin@1.2.3 ', + ]) + }) +}) diff --git a/packages/core/test/unit/update/codex-transaction.test.ts b/packages/core/test/unit/update/codex-transaction.test.ts new file mode 100644 index 0000000..c37e890 --- /dev/null +++ b/packages/core/test/unit/update/codex-transaction.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { writeTomlFileSync } from '../../../src/utils/config.js' +import { executeCodexTransaction, readCodexPayloadVersion } from '../../../src/update/codex-transaction.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-codex-transaction-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function item (cachePath?: string): UpdatePlanItem { + return { + installationId: 'codex:native:nsolid-plugin@nodesource', + target: 'codex', + ownership: 'native-plugin', + installed: true, + source: { + kind: 'codex-marketplace', + pluginId: 'nsolid-plugin@nodesource', + marketplace: 'NodeSource/nsolid-plugin', + versionSource: { kind: 'git', repository: 'https://github.com/NodeSource/nsolid-plugin.git', manifestPath: 'bundle.json' }, + }, + version: { current: undefined, latest: '1.0.1', status: 'update-available' }, + metadata: { ...(cachePath ? { packageRoot: cachePath } : {}), trackedMcpConfigPath: path.join(home, '.codex', 'config.toml') }, + steps: [ + { kind: 'command', description: 'upgrade', command: { executable: 'codex', args: ['plugin', 'marketplace', 'upgrade', 'NodeSource/nsolid-plugin'], timeoutMs: 1000 } }, + { kind: 'command', description: 'remove', command: { executable: 'codex', args: ['plugin', 'remove', 'nsolid-plugin@nodesource'], timeoutMs: 1000 } }, + { kind: 'command', description: 'add', command: { executable: 'codex', args: ['plugin', 'add', 'nsolid-plugin@nodesource'], timeoutMs: 1000 } }, + { kind: 'validation', description: 'payload', checks: [] }, + ], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +describe('Codex update transaction', () => { + it('validates the refreshed cached payload rather than a versionless registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + assert.equal(readCodexPayloadVersion(cachePath, 'nsolid-plugin@nodesource'), '1.0.1') + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /enabled = true/) + }) + + it('snapshots only the selected plugin cache when metadata has no package root', async () => { + const cacheBase = path.join(home, '.codex', 'plugins', 'cache') + const selectedCache = path.join(cacheBase, 'NodeSource', 'nsolid-plugin') + const unrelatedCache = path.join(cacheBase, 'other-marketplace', 'other-plugin') + mkdirSync(selectedCache, { recursive: true }) + mkdirSync(unrelatedCache, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(selectedCache, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(unrelatedCache, 'bundle.json'), JSON.stringify({ name: 'other-plugin', version: '2.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(), { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(path.join(selectedCache, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '0.9.0', skills: [] })) + writeFileSync(path.join(unrelatedCache, 'bundle.json'), JSON.stringify({ name: 'other-plugin', version: '9.9.9', skills: [] })) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_VERSION_MISMATCH') + assert.equal(readFileSync(path.join(unrelatedCache, 'bundle.json'), 'utf8').includes('9.9.9'), true) + assert.equal(readFileSync(path.join(selectedCache, 'bundle.json'), 'utf8').includes('1.0.0'), true) + }) + + it('fails when Codex add does not recreate the exact registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + mkdirSync(path.dirname(path.join(home, '.codex', 'config.toml')), { recursive: true }) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: { 'nsolid-plugin@nodesource': { enabled: true } } }) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) { + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + writeTomlFileSync(path.join(home, '.codex', 'config.toml'), { plugins: {} }) + } + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_REGISTRATION_MISSING') + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /nsolid-plugin@nodesource/) + assert.match(readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'), /enabled = true/) + }) + + it('validates the payload selected by the recreated registration', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + const oldPayload = path.join(cachePath, '1.0.0') + const latestPayload = path.join(cachePath, '1.0.1') + mkdirSync(oldPayload, { recursive: true }) + mkdirSync(latestPayload, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeTomlFileSync(configPath, { plugins: { 'nsolid-plugin@nodesource': { enabled: true, cachePath: oldPayload } } }) + writeFileSync(path.join(oldPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + writeFileSync(path.join(latestPayload, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'CODEX_VERSION_MISMATCH') + }) + + it('does not rewrite config TOML when preserved fields already match', async () => { + const cachePath = path.join(home, '.codex', 'plugins', 'cache', 'nsolid-plugin') + mkdirSync(cachePath, { recursive: true }) + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '# user comment must survive', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.0', skills: [] })) + + const result = await executeCodexTransaction(item(cachePath), { + run: async (command) => { + if (command.args.includes('add')) writeFileSync(path.join(cachePath, 'bundle.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1', skills: [] })) + return { exitCode: 0, stdout: '', stderr: '', timedOut: false } + }, + }) + + assert.equal(result.success, true) + assert.match(readFileSync(configPath, 'utf8'), /# user comment must survive/) + }) +}) diff --git a/packages/core/test/unit/update/command-runner.test.ts b/packages/core/test/unit/update/command-runner.test.ts new file mode 100644 index 0000000..b898e41 --- /dev/null +++ b/packages/core/test/unit/update/command-runner.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { runCommand } from '../../../src/update/command-runner.js' + +describe('update command runner', () => { + it('preserves ENOENT as a structured missing-executable error', async () => { + const result = await runCommand({ + executable: 'nsolid-plugin-command-that-does-not-exist', + args: [], + timeoutMs: 1_000, + }) + + assert.equal(result.exitCode, null) + assert.equal(result.spawnErrorCode, 'ENOENT') + }) +}) diff --git a/packages/core/test/unit/update/fallback-strategy.test.ts b/packages/core/test/unit/update/fallback-strategy.test.ts new file mode 100644 index 0000000..1d1a968 --- /dev/null +++ b/packages/core/test/unit/update/fallback-strategy.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fallbackStrategy } from '../../../src/update/strategies/fallback.js' +import type { UpdatePlanItem } from '../../../src/update/types.js' + +function item (): UpdatePlanItem { + return { + installationId: 'opencode:fallback', + target: 'opencode', + ownership: 'fallback', + installed: true, + source: { kind: 'fallback', bundleVersion: '1.0.0', executor: 'npm-exec' }, + version: { current: '1.0.0', latest: '1.0.1', status: 'update-available' }, + steps: [{ kind: 'command', description: 'refresh', command: { executable: 'npm', args: ['exec'], cwd: tmpdir(), timeoutMs: 1000 } }], + rollbackSteps: [], + requiresConfirmation: true, + } +} + +describe('fallback update strategy', () => { + it('uses a private temporary cwd and propagates the child rollback result', async () => { + let observedCwd = '' + const result = await fallbackStrategy.execute(item(), { + options: {}, + commandRunner: { + run: async (command) => { + observedCwd = command.cwd ?? '' + assert.notEqual(observedCwd, tmpdir()) + // POSIX exposes the restrictive mode bits that the implementation + // applies. Windows filesystems do not expose chmod(0700) through + // stat(), so verify the private temp location there instead. + if (process.platform !== 'win32') { + assert.equal(statSync(observedCwd).mode & 0o777, 0o700) + } else { + assert.equal(path.dirname(observedCwd), path.resolve(tmpdir())) + } + return { exitCode: 1, stdout: '', stderr: 'refresh failed\nrollback: succeeded\n', timedOut: false } + }, + }, + }) + + assert.equal(result.status, 'failed') + assert.deepEqual(result.rollback, { attempted: true, succeeded: true }) + assert.equal(existsSync(path.resolve(observedCwd)), false) + }) + + it('reports a missing package executor as unsupported instead of failed planning', async () => { + const previousPath = process.env.PATH + process.env.PATH = '' + try { + const planned = await fallbackStrategy.plan({ + ...item(), + source: { kind: 'fallback', bundleVersion: '1.0.0' }, + }, { options: {}, commandRunner: { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) } }) + assert.equal(planned.planningError, undefined) + assert.equal(planned.source.kind, 'unsupported') + assert.equal(planned.manualCommands?.length, 2) + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + } + }) +}) diff --git a/packages/core/test/unit/update/fallback-transaction.test.ts b/packages/core/test/unit/update/fallback-transaction.test.ts new file mode 100644 index 0000000..e8c121c --- /dev/null +++ b/packages/core/test/unit/update/fallback-transaction.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { refreshOwnedInstallation } from '../../../src/update/fallback-transaction.js' +import { readTrackingFile } from '../../../src/skills/skill-tracker.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-transaction-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile +}) + +function writeJson (filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) +} + +describe('fallback refresh transaction', () => { + it('replaces owned directories, reconciles shared ownership, and recreates harness links', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const retainedDir = path.join(sharedDir, 'retained') + const removedDir = path.join(sharedDir, 'removed') + mkdirSync(retainedDir, { recursive: true }) + mkdirSync(removedDir, { recursive: true }) + writeFileSync(path.join(retainedDir, 'SKILL.md'), 'old retained') + writeFileSync(path.join(retainedDir, 'obsolete.txt'), 'must disappear') + writeFileSync(path.join(removedDir, 'SKILL.md'), 'shared with Codex') + + const claudeSkills = path.join(home, '.claude', 'skills') + mkdirSync(claudeSkills, { recursive: true }) + symlinkSync(removedDir, path.join(claudeSkills, 'removed'), 'dir') + symlinkSync(retainedDir, path.join(claudeSkills, 'retained'), 'dir') + + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const retainedSource = path.join(sourceRoot, 'skills', 'retained') + const addedSource = path.join(sourceRoot, 'skills', 'added') + mkdirSync(retainedSource, { recursive: true }) + mkdirSync(addedSource, { recursive: true }) + writeFileSync(path.join(retainedSource, 'SKILL.md'), 'new retained') + writeFileSync(path.join(addedSource, 'SKILL.md'), 'new skill') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'retained', path: 'skills/retained', description: 'retained' }, + { name: 'added', path: 'skills/added', description: 'added' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + skills: [ + { name: 'retained', path: retainedDir, paths: { claude: retainedDir }, installedAt: new Date().toISOString(), harnesses: ['claude'] }, + { name: 'removed', path: removedDir, paths: { claude: removedDir, codex: removedDir }, installedAt: new Date().toISOString(), harnesses: ['claude', 'codex'] }, + ], + mcpServers: [], + }) + + try { + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + assert.equal(result.success, true) + assert.equal(readFileSync(path.join(retainedDir, 'SKILL.md'), 'utf8'), 'new retained') + assert.equal(existsSync(path.join(retainedDir, 'obsolete.txt')), false) + assert.equal(existsSync(removedDir), true) + assert.equal(existsSync(path.join(claudeSkills, 'removed')), false) + assert.equal(existsSync(path.join(claudeSkills, 'retained')), true) + assert.equal(existsSync(path.join(claudeSkills, 'added')), true) + + const tracking = await readTrackingFile() + const removed = tracking?.skills.find((entry) => entry.name === 'removed') + assert.deepEqual(removed?.harnesses, ['codex']) + assert.equal(tracking?.bundleVersions?.claude, '1.0.1') + } finally { + rmSync(sourceRoot, { recursive: true, force: true }) + } + }) + + it('rejects a new harness link when its destination is untracked', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const trackedSource = path.join(sourceRoot, 'skills', 'tracked') + const addedSource = path.join(sourceRoot, 'skills', 'added') + mkdirSync(trackedSource, { recursive: true }) + mkdirSync(addedSource, { recursive: true }) + writeFileSync(path.join(trackedSource, 'SKILL.md'), 'tracked') + writeFileSync(path.join(addedSource, 'SKILL.md'), 'added') + mkdirSync(path.join(sharedDir, 'tracked'), { recursive: true }) + writeFileSync(path.join(sharedDir, 'tracked', 'SKILL.md'), 'old tracked') + const harnessDir = path.join(home, '.claude', 'skills') + mkdirSync(path.join(harnessDir, 'added'), { recursive: true }) + writeFileSync(path.join(harnessDir, 'added', 'user-owned.txt'), 'keep me') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [ + { name: 'tracked', path: 'skills/tracked', description: 'tracked' }, + { name: 'added', path: 'skills/added', description: 'added' }, + ], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersions: { claude: '1.0.0' }, + skills: [{ name: 'tracked', path: path.join(sharedDir, 'tracked'), paths: { claude: path.join(sharedDir, 'tracked') }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'UNTRACKED_DESTINATION') + assert.equal(existsSync(path.join(harnessDir, 'added', 'user-owned.txt')), true) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('does not roll back or delete owned state when backup creation fails', async () => { + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + const skillSource = path.join(sourceRoot, 'skills', 'tracked') + mkdirSync(skillSource, { recursive: true }) + writeFileSync(path.join(skillSource, 'SKILL.md'), 'new') + const longPath = path.join(home, ...Array.from({ length: 4 }, () => 'a'.repeat(70)), 'tracked') + mkdirSync(path.dirname(longPath), { recursive: true }) + writeFileSync(longPath, 'original') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + skills: [{ name: 'tracked', path: longPath, paths: { opencode: longPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_BACKUP_FAILED') + assert.equal(result.rollbackAttempted, false) + assert.equal(readFileSync(longPath, 'utf8'), 'original') + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('does not advance fallback evidence when MCP reconciliation is skipped', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(sharedDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'new-server', url: 'https://example.com/mcp', headers: {} }], + }) + const configPath = path.join(home, '.claude.json') + writeJson(configPath, { mcpServers: { 'old-server': { type: 'http', url: 'https://old.example/mcp' } } }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersion: '1.0.0', + bundleVersions: { claude: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { claude: skillPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }], + mcpServers: [{ name: 'old-server', configPath, harness: 'claude', configuredAt: new Date().toISOString() }], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'MCP_RECONCILIATION_REQUIRED') + const tracking = await readTrackingFile() + assert.equal(tracking?.bundleVersions?.claude, '1.0.0') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('repoints the legacy path when the referenced harness drops a shared skill', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const claudeDroppedPath = path.join(sharedDir, 'dropped') + const codexRemainingPath = path.join(home, 'codex-owned', 'dropped') + const retainedPath = path.join(sharedDir, 'retained') + for (const skillPath of [claudeDroppedPath, codexRemainingPath, retainedPath]) { + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + } + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'retained'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'retained', 'SKILL.md'), 'new') + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'retained', path: 'skills/retained', description: 'retained' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-auth.json'), { + serviceToken: 'token', + organizationId: 'org', + saasToken: 'saas', + consoleUrl: 'https://console.example.com', + mcpUrl: 'https://example.com/mcp', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'claude', + bundleVersions: { claude: '1.0.0', codex: '1.0.0' }, + skills: [ + { name: 'dropped', path: claudeDroppedPath, paths: { claude: claudeDroppedPath, codex: codexRemainingPath }, installedAt: new Date().toISOString(), harnesses: ['claude', 'codex'] }, + { name: 'retained', path: retainedPath, paths: { claude: retainedPath }, installedAt: new Date().toISOString(), harnesses: ['claude'] }, + ], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'claude', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, true, JSON.stringify(result)) + const tracking = await readTrackingFile() + const dropped = tracking?.skills.find((entry) => entry.name === 'dropped') + assert.equal(dropped?.path, codexRemainingPath) + assert.deepEqual(dropped?.harnesses, ['codex']) + rmSync(sourceRoot, { recursive: true, force: true }) + }) + + it('rejects a bundle whose version does not match its package manifest', async () => { + const sharedDir = path.join(home, '.agents', 'skills') + const skillPath = path.join(sharedDir, 'tracked') + mkdirSync(skillPath, { recursive: true }) + writeFileSync(path.join(skillPath, 'SKILL.md'), 'old') + const sourceRoot = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-fallback-source-')) + mkdirSync(path.join(sourceRoot, 'skills', 'tracked'), { recursive: true }) + writeFileSync(path.join(sourceRoot, 'skills', 'tracked', 'SKILL.md'), 'new') + writeJson(path.join(sourceRoot, 'package.json'), { name: 'nsolid-plugin', version: '1.0.2' }) + const bundlePath = path.join(sourceRoot, 'bundle.json') + writeJson(bundlePath, { + name: 'nsolid-plugin', + version: '1.0.1', + skills: [{ name: 'tracked', path: 'skills/tracked', description: 'tracked' }], + mcpServers: [{ name: 'nsolid-console', url: 'https://example.com/mcp', headers: {} }], + }) + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + + const result = await refreshOwnedInstallation({ harness: 'opencode', bundlePath, skillsSource: sourceRoot }) + + assert.equal(result.success, false) + assert.equal(result.error?.code, 'FALLBACK_BUNDLE_VERSION_MISMATCH') + assert.equal(readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8'), 'old') + rmSync(sourceRoot, { recursive: true, force: true }) + }) +}) diff --git a/packages/core/test/unit/update/inventory.test.ts b/packages/core/test/unit/update/inventory.test.ts new file mode 100644 index 0000000..61b9978 --- /dev/null +++ b/packages/core/test/unit/update/inventory.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { detectInstallations } from '../../../src/update/inventory.js' +import { checkUpdates, planUpdates, update } from '../../../src/update/coordinator.js' + +let home: string +let previousHome: string | undefined +let previousUserProfile: string | undefined +let previousCodexConfigPath: string | undefined + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-inventory-')) + previousHome = process.env.HOME + previousUserProfile = process.env.USERPROFILE + previousCodexConfigPath = process.env.CODEX_CONFIG_PATH + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previousUserProfile + if (previousCodexConfigPath === undefined) delete process.env.CODEX_CONFIG_PATH + else process.env.CODEX_CONFIG_PATH = previousCodexConfigPath +}) + +function writeJson (filePath: string, value: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(value, null, 2)) +} + +function packageRoot (root: string, version: string): string { + writeJson(path.join(root, 'package.json'), { name: 'nsolid-pi-plugin', version }) + return root +} + +function runner () { + return { run: async () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }) } +} + +function registryFetch (version: string): typeof fetch { + return async () => new Response(JSON.stringify({ 'dist-tags': { latest: version } }), { status: 200 }) +} + +describe('update installation inventory', () => { + it('evaluates user and project Pi caches instead of selecting the first valid one', async () => { + const project = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pi-project-')) + try { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + writeJson(path.join(project, '.pi', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.2') + packageRoot(path.join(project, '.pi', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + + const detected = await detectInstallations({ includeCli: false, cwd: project, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.version.current, '1.0.0') + assert.deepEqual(pi?.version.currentVersions, ['1.0.2', '1.0.0']) + + const plan = await planUpdates({ + harness: 'pi', + cwd: project, + fetchImpl: registryFetch('1.0.2'), + commandRunner: runner(), + }) + assert.equal(plan.items[0]?.version.status, 'update-available') + assert.equal(plan.items[0]?.steps.some((step) => step.kind === 'command'), true) + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + + it('does not infer a Pi source from a leftover package cache', async () => { + const root = path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin') + packageRoot(root, '1.0.0') + assert.equal(existsSync(root), true) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + assert.equal(detected.some((installation) => installation.target === 'pi'), false) + }) + + it('ignores unrelated Pi npm package names', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { + packages: ['npm:nsolid-pi-plugin-helper', { source: 'npm:another-pi-plugin' }], + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + assert.equal(detected.some((installation) => installation.target === 'pi'), false) + }) + + it('does not accept a different package name as Pi cache version evidence', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + const root = path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin') + writeJson(path.join(root, 'package.json'), { name: 'different-package', version: '1.0.1' }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.version.current, undefined) + + const plan = await planUpdates({ + harness: 'pi', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(plan.items[0]?.version.status, 'update-available') + }) + + it('carries the approved custom Codex config path into inventory metadata', async () => { + const configPath = path.join(home, 'custom-codex', 'config.toml') + process.env.CODEX_CONFIG_PATH = configPath + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[marketplaces.nodesource]', + 'source = "https://github.com/NodeSource/nsolid-plugin.git"', + '', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.metadata?.configPath, configPath) + }) + + it('classifies a Claude registration without marketplace metadata as unsupported', async () => { + writeJson(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), { + plugins: { 'nsolid-plugin@nodesource': [{ scope: 'user' }] }, + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const claude = detected.find((installation) => installation.target === 'claude') + assert.equal(claude?.source.kind, 'unsupported') + + const check = await checkUpdates({ + harness: 'claude', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(check.results[0]?.status, 'unsupported') + + const result = await update({ + harness: 'claude', + cwd: home, + yes: true, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(result.results[0]?.status, 'unsupported') + assert.equal(result.success, false) + }) + + it('classifies a Codex registration without marketplace metadata as unsupported', async () => { + const configPath = path.join(home, '.codex', 'config.toml') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + process.env.CODEX_CONFIG_PATH = configPath + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const codex = detected.find((installation) => installation.target === 'codex') + assert.equal(codex?.source.kind, 'unsupported') + + const check = await checkUpdates({ + harness: 'codex', + cwd: home, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(check.results[0]?.status, 'unsupported') + + const result = await update({ + harness: 'codex', + cwd: home, + yes: true, + fetchImpl: registryFetch('1.0.1'), + commandRunner: runner(), + }) + assert.equal(result.results[0]?.status, 'unsupported') + assert.equal(result.success, false) + }) + + it('sanitizes unsupported Pi sources before they enter the update plan', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { + packages: ['https://user:token@host/nsolid-pi-plugin\nmalicious'], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const pi = detected.find((installation) => installation.target === 'pi') + assert.equal(pi?.source.kind, 'unsupported') + if (pi?.source.kind === 'unsupported') { + assert.equal(pi.source.source.includes('token'), false) + assert.equal(pi.source.source.includes('\n'), false) + assert.equal(pi.source.source.includes('https://host/'), true) + } + }) + + it('does not let one fallback harness reuse another harness version evidence', async () => { + const sharedSkill = path.join(home, '.agents', 'skills', 'shared') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'shared', path: sharedSkill, paths: { claude: sharedSkill, opencode: sharedSkill }, installedAt: new Date().toISOString(), harnesses: ['claude', 'opencode'] }], + mcpServers: [], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const claude = detected.find((installation) => installation.installationId === 'claude:fallback') + const opencode = detected.find((installation) => installation.installationId === 'opencode:fallback') + assert.equal(claude?.version.current, undefined) + assert.equal(opencode?.version.current, '1.0.0') + }) + + it('keeps malformed tracking isolated from native inventory discovery', async () => { + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + skills: {}, + mcpServers: [], + }) + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const fallback = detected.find((installation) => installation.installationId === 'opencode:fallback') + assert.equal(fallback?.source.kind, 'unsupported') + }) + + it('does not require npm or pnpm to report a fallback update in check mode', async () => { + const skillPath = path.join(home, '.agents', 'skills', 'tracked') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'opencode', + bundleVersion: '1.0.0', + bundleVersions: { opencode: '1.0.0' }, + skills: [{ name: 'tracked', path: skillPath, paths: { opencode: skillPath }, installedAt: new Date().toISOString(), harnesses: ['opencode'] }], + mcpServers: [], + }) + const summary = await checkUpdates({ + harness: 'opencode', + fetchImpl: registryFetch('1.0.1'), + commandRunner: { run: async () => { throw new Error('check must not probe an executor') } }, + }) + assert.equal(summary.results[0]?.status, 'update-available') + assert.equal(summary.success, true) + }) + + it('does not emit a Pi fallback target for MCP-only tracking', async () => { + writeJson(path.join(home, '.pi', 'agent', 'settings.json'), { packages: ['npm:nsolid-pi-plugin'] }) + packageRoot(path.join(home, '.pi', 'agent', 'npm', 'node_modules', 'nsolid-pi-plugin'), '1.0.0') + writeJson(path.join(home, '.agents', '.nodesource-installed.json'), { + version: '1.0.0', + installedAt: new Date().toISOString(), + harness: 'pi', + skills: [], + mcpServers: [{ name: 'nsolid-console', configPath: path.join(home, '.pi', 'settings.json'), harness: 'pi', configuredAt: new Date().toISOString() }], + }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + + assert.equal(detected.some((installation) => installation.installationId === 'pi:fallback'), false) + assert.equal(detected.some((installation) => installation.installationId === 'pi:package:user'), true) + }) + + it('ignores an unrelated Antigravity import manifest when checking ambiguity', async () => { + const pluginRoot = path.join(home, '.gemini', 'config', 'plugins', 'nsolid-plugin') + mkdirSync(pluginRoot, { recursive: true }) + writeJson(path.join(pluginRoot, 'bundle.json'), { version: '1.0.0' }) + writeJson(path.join(home, '.gemini', 'config', 'import_manifest.json'), { imports: [{ name: 'nsolid-plugin' }] }) + writeJson(path.join(home, '.gemini', 'antigravity-cli', 'import_manifest.json'), { imports: [{ name: 'unrelated-plugin' }] }) + + const detected = await detectInstallations({ includeCli: false, cwd: home, commandRunner: runner() }) + const antigravity = detected.find((installation) => installation.target === 'antigravity') + + assert.equal(antigravity?.source.kind, 'antigravity-git') + }) +}) diff --git a/packages/core/test/unit/update/package-manager.test.ts b/packages/core/test/unit/update/package-manager.test.ts new file mode 100644 index 0000000..d14e2d4 --- /dev/null +++ b/packages/core/test/unit/update/package-manager.test.ts @@ -0,0 +1,169 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { detectGlobalPackageOwnership, verifyGlobalPackage } from '../../../src/update/package-manager.js' + +function fixture (managers: readonly string[] = ['npm']) { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-manager-')) + const packagePath = path.join(root, 'lib', 'node_modules', 'nsolid-plugin') + const executablePath = path.join(packagePath, 'dist', 'src', 'cli.js') + const binPath = path.join(root, 'bin') + mkdirSync(path.dirname(executablePath), { recursive: true }) + mkdirSync(binPath, { recursive: true }) + writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(executablePath, '#!/usr/bin/env node\n') + for (const manager of managers) { + // Windows resolves package-manager commands through PATHEXT. Keep the + // fixture equivalent to npm.cmd/pnpm.cmd there instead of relying on an + // extensionless file that `findExecutable()` must not treat as runnable. + const managerPath = path.join(binPath, process.platform === 'win32' ? `${manager}.CMD` : manager) + writeFileSync(managerPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + chmodSync(managerPath, 0o755) + } + return { + root, + packagePath, + executablePath, + env: { + PATH: binPath, + ...(process.platform === 'win32' ? { PATHEXT: '.CMD' } : {}), + }, + } +} + +describe('global CLI package ownership', () => { + it('accepts a package contained by the npm-reported global root', async () => { + const paths = fixture() + const calls: string[] = [] + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async (spec) => { + calls.push(spec.executable) + return { exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false } + }, + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + + assert.equal(result.ownership?.manager, 'npm') + assert.deepEqual(calls, [path.join(paths.env.PATH, process.platform === 'win32' ? 'npm.CMD' : 'npm')]) + assert.equal(result.ownership?.rollbackCommand, 'npm install --global nsolid-plugin@1.0.1') + }) + + it('normalizes pnpm symlinked package roots before proving ownership', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'nsolid-plugin-pnpm-')) + const globalRoot = path.join(root, 'global', 'node_modules') + const storePackage = path.join(root, 'store', 'nsolid-plugin') + const executablePath = path.join(storePackage, 'dist', 'src', 'cli.js') + const binPath = path.join(root, 'bin') + mkdirSync(path.dirname(executablePath), { recursive: true }) + mkdirSync(globalRoot, { recursive: true }) + mkdirSync(binPath, { recursive: true }) + writeFileSync(path.join(storePackage, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.1' })) + writeFileSync(executablePath, '#!/usr/bin/env node\n') + chmodSync(executablePath, 0o755) + const pnpmExecutable = path.join(binPath, process.platform === 'win32' ? 'pnpm.CMD' : 'pnpm') + writeFileSync(pnpmExecutable, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + chmodSync(pnpmExecutable, 0o755) + const symlinkedPackage = path.join(globalRoot, 'nsolid-plugin') + symlinkSync(storePackage, symlinkedPackage, 'dir') + + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${globalRoot}\n`, stderr: '', timedOut: false }), + }, + packageRoot: storePackage, + executablePath, + env: { PATH: binPath }, + }) + + assert.equal(result.ownership?.manager, 'pnpm') + assert.equal(result.ownership?.packagePath, symlinkedPackage) + + const nextStorePackage = path.join(root, 'store', 'nsolid-plugin-next') + mkdirSync(nextStorePackage, { recursive: true }) + writeFileSync(path.join(nextStorePackage, 'package.json'), JSON.stringify({ name: 'nsolid-plugin', version: '1.0.2' })) + rmSync(symlinkedPackage, { force: true }) + symlinkSync(nextStorePackage, symlinkedPackage, 'dir') + assert.equal(verifyGlobalPackage(result.ownership!, '1.0.2'), true) + }) + + it('does not reject a normal package because wrapper home variables are ambient', async () => { + const paths = fixture() + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: { ...paths.env, VOLTA_HOME: '/tmp/volta', BUN_INSTALL: '/tmp/bun', YARN_VERSION: '1' }, + }) + + assert.equal(result.ownership?.manager, 'npm') + }) + + it('rejects a broken entrypoint and a manager root mismatch', async () => { + const paths = fixture() + const broken = path.join(paths.root, 'missing', 'nsolid-plugin') + const mismatch = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'other', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + const brokenResult = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: broken, + env: paths.env, + }) + + assert.equal(mismatch.ownership, undefined) + assert.equal(brokenResult.ownership, undefined) + assert.equal(mismatch.unsupported?.code, 'UNSUPPORTED_CLI_SOURCE') + }) + + it('does not invoke a package manager in read-only mode', async () => { + const paths = fixture() + let calls = 0 + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => { + calls++ + throw new Error('must not run') + }, + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + readOnly: true, + }) + + assert.equal(calls, 0) + assert.equal(result.ownership, undefined) + assert.equal(result.unsupported?.code, 'UNSUPPORTED_CLI_SOURCE') + }) + + it('rejects ambiguous ownership when npm and pnpm both claim the package', async () => { + const paths = fixture(['npm', 'pnpm']) + const result = await detectGlobalPackageOwnership({ + commandRunner: { + run: async () => ({ exitCode: 0, stdout: `${path.join(paths.root, 'lib', 'node_modules')}\n`, stderr: '', timedOut: false }), + }, + packageRoot: paths.packagePath, + executablePath: paths.executablePath, + env: paths.env, + }) + + assert.equal(result.ownership, undefined) + assert.match(result.unsupported?.message ?? '', /ambiguous/i) + }) +}) diff --git a/packages/core/test/unit/update/version-source.test.ts b/packages/core/test/unit/update/version-source.test.ts new file mode 100644 index 0000000..a3dc28d --- /dev/null +++ b/packages/core/test/unit/update/version-source.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isSafeManifestPath, resolveMarketplaceVersion, resolveRegistryVersion, sanitizeRepository } from '../../../src/update/version-source.js' + +describe('update version sources', () => { + it('redacts repository credentials and rejects traversal paths', () => { + assert.equal(sanitizeRepository('https://user:secret@example.com/org/repo.git'), 'https://example.com/org/repo.git') + assert.equal(sanitizeRepository('NodeSource/nsolid-plugin'), 'https://github.com/NodeSource/nsolid-plugin.git') + assert.equal(isSafeManifestPath('bundle.json'), true) + assert.equal(isSafeManifestPath('../bundle.json'), false) + assert.equal(isSafeManifestPath('/tmp/bundle.json'), false) + }) + + it('does not substitute a canonical source for stale local snapshots', async () => { + const result = await resolveMarketplaceVersion({ + kind: 'local-snapshot', + root: '/does-not-exist', + manifestPath: 'bundle.json', + freshness: 'stale', + }) + assert.deepEqual(result, {}) + }) + + it('resolves GitHub shorthand repositories using the carried revision', async () => { + let requested = '' + const result = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + revision: 'feature/update-flow', + manifestPath: 'bundle.json', + }, { + fetchImpl: async (url) => { + requested = String(url) + return new Response(JSON.stringify({ version: '1.0.2' }), { status: 200 }) + }, + }) + + assert.deepEqual(result, { version: '1.0.2' }) + assert.equal(requested, 'https://raw.githubusercontent.com/NodeSource/nsolid-plugin/feature/update-flow/bundle.json') + }) + + it('does not expose malformed response bodies in lookup errors', async () => { + const secretBody = 'PRIVATE_RESPONSE_BODY_DO_NOT_PRINT' + const registry = await resolveRegistryVersion('nsolid-plugin', { + fetchImpl: async () => new Response(secretBody, { status: 200 }), + }) + const marketplace = await resolveMarketplaceVersion({ + kind: 'git', + repository: 'NodeSource/nsolid-plugin', + manifestPath: 'bundle.json', + }, { + fetchImpl: async () => new Response(secretBody, { status: 200 }), + }) + + assert.doesNotMatch(registry.error?.message ?? '', /do-not-print|privateToken/) + assert.doesNotMatch(marketplace.error?.message ?? '', /do-not-print|privateToken/) + }) +}) diff --git a/packages/core/test/unit/update/version.test.ts b/packages/core/test/unit/update/version.test.ts new file mode 100644 index 0000000..d4a1332 --- /dev/null +++ b/packages/core/test/unit/update/version.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { classifyVersionSet, classifyVersions, compareVersions, isStableVersion, parseStableVersion } from '../../../src/update/version.js' + +describe('update semantic versions', () => { + it('parses only stable semantic versions', () => { + assert.deepEqual(parseStableVersion('1.2.3'), { major: 1, minor: 2, patch: 3 }) + assert.equal(parseStableVersion('1.2'), null) + assert.equal(parseStableVersion('1.2.3-beta.1'), null) + assert.equal(parseStableVersion(' 1.2.3 '), null) + assert.equal(isStableVersion('0.0.0'), true) + }) + + it('compares versions without a runtime semver dependency', () => { + assert.equal(compareVersions('1.2.3', '1.2.3'), 0) + assert.ok(compareVersions('1.2.4', '1.2.3') > 0) + assert.ok(compareVersions('2.0.0', '10.0.0') < 0) + }) + + it('distinguishes current, update, newer, and unknown states', () => { + assert.equal(classifyVersions('1.0.0', '1.0.0').status, 'current') + assert.equal(classifyVersions('1.0.0', '1.0.1').status, 'update-available') + assert.equal(classifyVersions('1.0.2', '1.0.1').status, 'newer-than-registry') + assert.equal(classifyVersions(undefined, '1.0.1').status, 'unknown') + }) + + it('marks a multi-cache target updateable when any affected cache is stale or missing', () => { + const result = classifyVersionSet(['1.0.2', '1.0.0'], '1.0.2') + assert.equal(result.status, 'update-available') + assert.equal(result.current, '1.0.0') + assert.deepEqual(result.currentVersions, ['1.0.2', '1.0.0']) + assert.equal(classifyVersionSet(['1.0.2', undefined], '1.0.2').status, 'update-available') + }) + + it('does not update a multi-cache target when every copy is at least latest', () => { + const result = classifyVersionSet(['1.0.1', '1.0.2'], '1.0.1') + assert.equal(result.status, 'newer-than-registry') + assert.equal(result.current, '1.0.1') + }) +}) diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 38ccb74..5875baa 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -20,6 +20,15 @@ pi install npm:pi-mcp-adapter /reload ``` +To update the package-owned skills through Pi, use the N|Solid updater after the canonical unpinned package is installed: + +```bash +nsolid-plugin update --harness pi +nsolid-plugin update --harness pi --check --json +``` + +The updater coalesces matching user and project entries into one `pi update npm:nsolid-pi-plugin` operation. User-only updates use `--no-approve`; a detected project scope is disclosed and uses `--approve` after confirmation. Source entries, filters, trust settings, MCP configuration, and credentials are left to Pi/the user and are not rewritten by the updater. + After local packaging tests, run `pnpm plugin:clean` to remove materialized skills from the source tree. Then verify: diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..a3f8afd --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const releaseMode = process.argv.includes('--release') +const versionFiles = ['bundle.json', 'packages/core/package.json', 'packages/pi-plugin/package.json'] +const payload = [ + 'skills/**', + 'packages/core/src/**', + 'packages/pi-plugin/index.js', + 'bundle.json', + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.agents/plugins/marketplace.json', + '.codex-plugin/plugin.json', + '.claude-mcp.json', + '.mcp.json', + 'plugin.json', + 'mcp_config.json', + 'scripts/mcp-wrapper.js', +] +const errors = [] + +const versions = versionFiles.map((rel) => ({ rel, version: readJson(rel)?.version })) +const canonical = versions[0].version +if (!isStable(canonical)) errors.push(`bundle.json has invalid version ${String(canonical)}`) +for (const entry of versions.slice(1)) { + if (entry.version !== canonical) errors.push(`${entry.rel}: expected ${canonical}, found ${String(entry.version)}`) +} + +checkCommand('packages/core/scripts/check-bundle-sync.mjs', '--check') +checkCommand('scripts/materialize-github-marketplace.mjs', '--check') +checkGeneratedVersions(canonical) + +if (releaseMode) checkPayloadVersion(canonical) + +if (errors.length > 0) { + console.error('release:check failed') + for (const error of errors) console.error(` ${error}`) + process.exitCode = 1 +} else { + console.log(`release:check OK (${canonical})`) +} + +function checkCommand (script, argument) { + try { + execFileSync(process.execPath, [path.join(root, script), argument], { cwd: root, stdio: 'pipe' }) + } catch (error) { + // Some constrained runners report EPERM after a successful child with a + // zero exit status when its stdio pipe is closed by the supervisor. + if (error?.status === 0) return + const output = Buffer.isBuffer(error?.stderr) ? error.stderr.toString().trim() : '' + errors.push(`${script}: ${output || 'generated files are out of sync'}`) + } +} + +function checkGeneratedVersions (expected) { + for (const rel of ['.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json']) { + if (!existsSync(path.join(root, rel))) { + errors.push(`${rel}: file is missing`) + continue + } + const values = findVersions(readJson(rel)) + if (values.some((value) => value !== expected)) errors.push(`${rel}: generated version does not match ${expected}`) + } +} + +function checkPayloadVersion (expected) { + const tag = latestSemanticTag() + if (!tag) { + if (!errors.some((error) => error.includes('semantic-version Git tag') || error.includes('semantic release tag') || error.includes('shallow'))) errors.push('release mode requires an eligible semantic-version Git tag') + return + } + const tagVersion = tag.version + if (expected !== tagVersion) return + const untrackedPayload = untrackedAllowlistedPayload() + if (untrackedPayload.length > 0) { + errors.push(`untracked plugin payload changed since ${tag.name}: ${untrackedPayload.join(', ')}`) + return + } + // Compare the release tag with the complete current worktree. Release + // checks are normally run before commit/tag publication, so HEAD-only + // comparison would miss staged or unstaged payload changes. + const unchanged = runGitQuiet(['diff', '--quiet', tag.name, '--', ...payload]) + if (unchanged === false) { + errors.push(`plugin payload changed since ${tag.name} without an update-visible version; run release:prepare`) + } else if (unchanged === undefined) { + errors.push(`could not compare plugin payload with ${tag.name}`) + } +} + +function latestSemanticTag () { + const output = runGitOutput(['tag', '--list']) + if (output === undefined) return undefined + const names = output + .split(/\r?\n/) + .map((tag) => tag.trim()) + .filter(Boolean) + const semanticNames = names.filter((name) => /^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(name)) + const candidates = names + .map((name) => { + const match = name.match(/^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/) + if (!match) return undefined + const commit = runGitOutput(['rev-parse', `${name}^{commit}`])?.trim() + if (!commit) return undefined + const ancestry = runGitQuiet(['merge-base', '--is-ancestor', commit, 'HEAD']) + if (ancestry !== true) return undefined + return { name, version: match[1], commit } + }) + .filter(Boolean) + if (candidates.length === 0) { + const shallow = runGitOutput(['rev-parse', '--is-shallow-repository'])?.trim() === 'true' + if (shallow) errors.push('release mode cannot prove an eligible tag because repository history is shallow') + else if (!output.trim()) errors.push('release mode found no local semantic-version Git tag; remote tags are not considered until fetched locally') + else if (semanticNames.length === 0) errors.push('release mode found only malformed or non-semantic Git tags') + else if (semanticNames.length > 0) errors.push('release mode found no semantic release tag that is an ancestor of HEAD') + return undefined + } + candidates.sort((left, right) => compareStable(right.version, left.version)) + const selected = candidates[0] + const duplicates = candidates.filter((entry) => entry.version === selected.version && entry.commit !== selected.commit) + if (duplicates.length > 0) { + errors.push(`release mode found ambiguous duplicate tags for version ${selected.version}: ${[selected, ...duplicates].map((entry) => entry.name).join(', ')}`) + return undefined + } + return selected +} + +function runGitOutput (args) { + try { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }) + } catch (error) { + // Some constrained runners throw after a successful child process has + // already populated stdout. Preserve that output just as checkCommand() + // preserves a status-zero result. + if (error?.status !== 0) return undefined + if (typeof error?.stdout === 'string') return error.stdout + if (Buffer.isBuffer(error?.stdout)) return error.stdout.toString('utf8') + return undefined + } +} + +function runGitQuiet (args) { + try { + execFileSync('git', args, { cwd: root, stdio: 'pipe' }) + return true + } catch (error) { + if (error?.status === 0) return true + if (error?.status === 1) return false + return undefined + } +} + +function untrackedAllowlistedPayload () { + const output = runGitOutput(['status', '--porcelain=v1', '--untracked-files=all', '-z']) + if (output === undefined) { + errors.push('could not inspect untracked plugin payload') + return [] + } + return output + .split('\0') + .filter((entry) => entry.startsWith('?? ')) + .map((entry) => entry.slice(3)) + .filter(isPayloadPath) +} + +function isPayloadPath (relativePath) { + const normalized = relativePath.replaceAll('\\', '/') + return normalized === 'bundle.json' || normalized.startsWith('skills/') || normalized.startsWith('packages/core/src/') || normalized === 'packages/pi-plugin/index.js' || payload.includes(normalized) +} + +function findVersions (value) { + if (!value || typeof value !== 'object') return [] + const output = [] + if (typeof value.version === 'string') output.push(value.version) + for (const child of Object.values(value)) output.push(...findVersions(child)) + return output +} + +function readJson (relative) { + try { return JSON.parse(readFileSync(path.join(root, relative), 'utf8')) } catch { return null } +} + +function isStable (value) { return typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) } + +function compareStable (left, right) { + const a = left.split('.').map(Number) + const b = right.split('.').map(Number) + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] +} diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..86cc2b6 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const request = process.argv.slice(2).find((arg) => !arg.startsWith('-')) +const sourceFiles = ['bundle.json', 'packages/core/package.json', 'packages/pi-plugin/package.json'] +const generatedFiles = [ + 'packages/core/bundle.json', + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.agents/plugins/marketplace.json', + '.codex-plugin/plugin.json', + '.claude-mcp.json', + '.mcp.json', + 'plugin.json', + 'mcp_config.json', + 'scripts/mcp-wrapper.js', +] +const materializedDirs = ['packages/core/skills', 'packages/pi-plugin/skills'] +const snapshots = new Map() +const directorySnapshots = new Map() + +try { + if (!request) throw new Error('Usage: pnpm release:prepare -- patch|minor|major|') + const currentBundle = readJson('bundle.json') + const current = currentBundle.version + if (!isStable(current)) throw new Error(`Current bundle version is invalid: ${current}`) + const next = nextVersion(current, request) + if (!next) throw new Error(`Requested release version is invalid or not greater than ${current}: ${request}`) + + for (const rel of [...sourceFiles, ...generatedFiles]) snapshot(rel) + for (const rel of materializedDirs) snapshotDirectory(rel) + for (const rel of sourceFiles) { + const value = readJson(rel) + value.version = next + writeJson(rel, value) + } + + run('packages/core/scripts/check-bundle-sync.mjs') + run('scripts/materialize-github-marketplace.mjs') + run('scripts/sync-plugin-assets.mjs') + validate(next) + + console.log(`Prepared release ${next}`) + for (const rel of changedFiles()) console.log(` ${rel}`) +} catch (error) { + restore() + console.error(`release:prepare failed: ${error instanceof Error ? error.message : 'unknown error'}`) + process.exitCode = 1 +} + +function nextVersion (current, requestValue) { + if (requestValue === 'patch' || requestValue === 'minor' || requestValue === 'major') { + const [major, minor, patch] = current.split('.').map(Number) + if (requestValue === 'major') return `${major + 1}.0.0` + if (requestValue === 'minor') return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` + } + if (!isStable(requestValue) || compare(requestValue, current) <= 0) return null + return requestValue +} + +function validate (version) { + for (const rel of sourceFiles) { + if (readJson(rel).version !== version) throw new Error(`${rel} did not receive ${version}`) + } + if (readFile('bundle.json') !== readFile('packages/core/bundle.json')) throw new Error('packages/core/bundle.json is not synchronized') + for (const rel of ['.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json']) { + const value = readJson(rel) + const versions = findVersions(value) + if (versions.some((value) => value !== version)) throw new Error(`${rel} contains a stale version`) + } +} + +function findVersions (value) { + if (!value || typeof value !== 'object') return [] + const output = [] + if (typeof value.version === 'string') output.push(value.version) + for (const child of Object.values(value)) output.push(...findVersions(child)) + return output +} + +function run (relativeScript) { + execFileSync(process.execPath, [path.join(root, relativeScript)], { cwd: root, stdio: 'inherit' }) +} + +function snapshot (relative) { + const file = path.join(root, relative) + snapshots.set(relative, existsSync(file) ? readFile(relative) : null) +} + +function restore () { + for (const [relative, content] of snapshots) { + const file = path.join(root, relative) + if (content === null) { + if (existsSync(file)) rmSync(file, { recursive: true, force: true }) + } else { + writeFileSync(file, content) + } + } + for (const [relative, files] of directorySnapshots) { + const directory = path.join(root, relative) + rmSync(directory, { recursive: true, force: true }) + if (!files) continue + for (const [file, content] of files) { + const target = path.join(root, file) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + } + } +} + +function snapshotDirectory (relative) { + const directory = path.join(root, relative) + if (!existsSync(directory)) { + directorySnapshots.set(relative, null) + return + } + const files = new Map() + collectDirectoryFiles(directory, relative, files) + directorySnapshots.set(relative, files) +} + +function collectDirectoryFiles (directory, relative, files) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const child = path.join(directory, entry.name) + const childRelative = path.join(relative, entry.name) + if (entry.isDirectory()) collectDirectoryFiles(child, childRelative, files) + else if (entry.isFile()) files.set(childRelative, readFileSync(child)) + } +} + +function changedFiles () { + try { + return execFileSync('git', ['diff', '--name-only', '--', ...sourceFiles, ...generatedFiles], { cwd: root, encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean) + } catch { return [] } +} + +function readJson (relative) { return JSON.parse(readFile(relative)) } +function readFile (relative) { return readFileSync(path.join(root, relative), 'utf8') } +function writeJson (relative, value) { writeFileSync(path.join(root, relative), JSON.stringify(value, null, 2) + '\n') } +function isStable (value) { return typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) } +function compare (a, b) { return a.split('.').map(Number).reduce((result, part, index) => result || part - Number(b.split('.')[index]), 0) }