@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +31 -3
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1157 -84
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +31 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +1866 -2434
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +341 -49
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +157 -88
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +129 -26
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +129 -13
- package/gsd-core/bin/lib/config-loader.cjs +20 -4
- package/gsd-core/bin/lib/config.cjs +81 -18
- package/gsd-core/bin/lib/core-utils.cjs +14 -3
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +138 -60
- package/gsd-core/bin/lib/install-engine.cjs +301 -25
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +463 -43
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +636 -72
- package/gsd-core/bin/lib/plan-scan.cjs +73 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
- package/gsd-core/bin/lib/roadmap.cjs +113 -52
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +1 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
- package/gsd-core/bin/lib/smart-entry.cjs +73 -7
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +122 -46
- package/gsd-core/bin/lib/state.cjs +456 -137
- package/gsd-core/bin/lib/surface.cjs +53 -11
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +474 -13
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +224 -25
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +13 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +4 -2
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +27 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +30 -37
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +33 -5
- package/gsd-core/workflows/quick.md +21 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +123 -68
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +4 -1
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +252 -17
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +252 -17
- package/package.json +8 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +31 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* capability-command-router.cjs — ADR-2346 P2 (#2368).
|
|
4
|
+
*
|
|
5
|
+
* Behavior-preserving relocation of the former `case 'capability':` arm from
|
|
6
|
+
* gsd-tools.cjs (gsd-tools.cjs:1693..2399, pre-cutover). Owns the capability
|
|
7
|
+
* lifecycle CLI (state/list/install/upgrade/remove/consent/trust) and wires the
|
|
8
|
+
* capability-lifecycle / -trust / -consent / -ledger / -loader modules.
|
|
9
|
+
*
|
|
10
|
+
* Dispatched via HOST_COMMAND_ROUTERS.capability in runCommand's default case
|
|
11
|
+
* (host dispatch table, ADR-2346 Layer 2). Hand-authored CJS (sibling of
|
|
12
|
+
* ensure-runtime-build.cjs) — not a generated .cts, so it is committed directly.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: the require() paths below are sibling-relative (./X.cjs), correct for
|
|
15
|
+
* this file's home in bin/lib/ — rewritten from the arm's original ./lib/X.cjs
|
|
16
|
+
* (which resolved relative to bin/gsd-tools.cjs).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const io = require('./io.cjs');
|
|
22
|
+
const { output, error, ERROR_REASON } = io;
|
|
23
|
+
const { ExitError } = require('./cli-exit.cjs');
|
|
24
|
+
const capabilityState = require('./capability-state.cjs');
|
|
25
|
+
const capabilityWriter = require('./capability-writer.cjs');
|
|
26
|
+
|
|
27
|
+
async function routeCapabilityCommand({ args, cwd, raw }) {
|
|
28
|
+
// capability state [--config-dir <path>]
|
|
29
|
+
// Root resolution: 'capability' is NOT in SKIP_ROOT_RESOLUTION for the
|
|
30
|
+
// same reason 'loop' is not: both are registry/config queries that need
|
|
31
|
+
// the project root (cwd) for .planning/config.json activation resolution.
|
|
32
|
+
// If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
|
|
33
|
+
// be added at the same time to keep them consistent.
|
|
34
|
+
const capSubcommand = args[1];
|
|
35
|
+
// --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
|
|
36
|
+
// Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
|
|
37
|
+
// lazy-required inside each mutating branch so the common state/set paths never load them. ---
|
|
38
|
+
const capFlagValue = (name) => {
|
|
39
|
+
const i = args.indexOf(name);
|
|
40
|
+
if (i === -1) return undefined;
|
|
41
|
+
const v = args[i + 1];
|
|
42
|
+
if (!v || v.startsWith('--')) {
|
|
43
|
+
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
44
|
+
}
|
|
45
|
+
return v;
|
|
46
|
+
};
|
|
47
|
+
const capHasFlag = (name) => args.includes(name);
|
|
48
|
+
const capRepeatedFlag = (name) => {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (let i = 0; i < args.length; i++) {
|
|
51
|
+
if (args[i] === name) {
|
|
52
|
+
const v = args[i + 1];
|
|
53
|
+
if (!v || v.startsWith('--')) {
|
|
54
|
+
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
55
|
+
}
|
|
56
|
+
out.push(v);
|
|
57
|
+
i++; // skip the consumed value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
// Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
|
|
63
|
+
// .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
|
|
64
|
+
// read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
|
|
65
|
+
// project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
|
|
66
|
+
// via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
|
|
67
|
+
// Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
|
|
68
|
+
// regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
|
|
69
|
+
const capResolveScope = (scope) => {
|
|
70
|
+
const s = scope || 'global';
|
|
71
|
+
if (s !== 'global' && s !== 'project') {
|
|
72
|
+
error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
73
|
+
}
|
|
74
|
+
if (s === 'project') return { scope: 'project', runtimeDir: cwd };
|
|
75
|
+
const os = require('node:os');
|
|
76
|
+
return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
|
|
77
|
+
};
|
|
78
|
+
// capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
|
|
79
|
+
// loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
|
|
80
|
+
// undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
|
|
81
|
+
// malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
|
|
82
|
+
// silently downgraded to permissive here.
|
|
83
|
+
const capReadStrict = () => {
|
|
84
|
+
let cfgPath;
|
|
85
|
+
try {
|
|
86
|
+
const { planningDir } = require('./planning-workspace.cjs');
|
|
87
|
+
cfgPath = path.join(planningDir(cwd), 'config.json');
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined; // cannot even resolve the project config dir — permissive default
|
|
90
|
+
}
|
|
91
|
+
if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
|
|
92
|
+
let cfg;
|
|
93
|
+
try {
|
|
94
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
95
|
+
} catch {
|
|
96
|
+
// Config is PRESENT but unreadable/unparseable: a security policy must not silently
|
|
97
|
+
// downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
|
|
98
|
+
// still allowed) until the config is fixed.
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
|
|
102
|
+
return cfg.capabilities.strict_known_registries;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
};
|
|
106
|
+
// Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
|
|
107
|
+
// #1920: prefer the authoritative gsd-core/VERSION the installer writes for EVERY runtime
|
|
108
|
+
// (gsd-core/bin/ -> ../VERSION), so installed layouts report the true version even when the
|
|
109
|
+
// walked-up ../../package.json is the versionless CommonJS marker or the user's own project.
|
|
110
|
+
// Fall back to the runtime-root package.json (dev/source tree), then fail-closed. Mirrors
|
|
111
|
+
// readHostVersion() in capability-loader.cts.
|
|
112
|
+
const capHostVersion = () => {
|
|
113
|
+
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
|
|
114
|
+
try {
|
|
115
|
+
const v = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
|
|
116
|
+
if (SEMVER_PREFIX.test(v)) return v;
|
|
117
|
+
} catch { /* not an installed tree (no gsd-core/VERSION) */ }
|
|
118
|
+
try {
|
|
119
|
+
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json')); // gsd-core/bin/lib/ -> repo root is three up
|
|
120
|
+
if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
|
|
121
|
+
} catch { /* runtime root has no package.json */ }
|
|
122
|
+
return '0.0.0';
|
|
123
|
+
};
|
|
124
|
+
// #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
|
|
125
|
+
// live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
|
|
126
|
+
// written here is the record the loader checks.
|
|
127
|
+
const capConsentHome = () => {
|
|
128
|
+
const osMod = require('node:os');
|
|
129
|
+
return process.env.GSD_HOME || osMod.homedir();
|
|
130
|
+
};
|
|
131
|
+
// #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
|
|
132
|
+
// record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
|
|
133
|
+
// path cannot be realpath'd (e.g. it does not exist yet).
|
|
134
|
+
const capProjectRoot = () => {
|
|
135
|
+
try { return fs.realpathSync(cwd); } catch { return cwd; }
|
|
136
|
+
};
|
|
137
|
+
// UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
|
|
138
|
+
// (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
|
|
139
|
+
// bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
|
|
140
|
+
// reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
|
|
141
|
+
// follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
|
|
142
|
+
// #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
|
|
143
|
+
// half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
|
|
144
|
+
// record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
|
|
145
|
+
// reconcile revokes nothing.
|
|
146
|
+
const capRunReconcile = (runtimeDir, lifecycle, scope) => {
|
|
147
|
+
try {
|
|
148
|
+
const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
|
|
149
|
+
if (report && Array.isArray(report.warnings)) {
|
|
150
|
+
for (const w of report.warnings) {
|
|
151
|
+
try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
|
|
155
|
+
};
|
|
156
|
+
if (capSubcommand === 'state') {
|
|
157
|
+
const configDirIdx = args.indexOf('--config-dir');
|
|
158
|
+
let configDir = null;
|
|
159
|
+
if (configDirIdx !== -1) {
|
|
160
|
+
const configDirVal = args[configDirIdx + 1];
|
|
161
|
+
// Validate that --config-dir has a following non-flag value.
|
|
162
|
+
if (!configDirVal || configDirVal.startsWith('--')) {
|
|
163
|
+
error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
164
|
+
}
|
|
165
|
+
configDir = configDirVal;
|
|
166
|
+
}
|
|
167
|
+
const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
|
|
168
|
+
// --runtime <r> (#2003): explicit runtime override so the config-dir
|
|
169
|
+
// resolution bypasses the persisted-runtime fallback. Dual-form like
|
|
170
|
+
// --config-dir (--runtime X / --runtime=X).
|
|
171
|
+
let stateRuntime = undefined;
|
|
172
|
+
const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime='));
|
|
173
|
+
const stateRuntimeIdx = args.indexOf('--runtime');
|
|
174
|
+
if (stateRuntimeEqArg) {
|
|
175
|
+
const value = stateRuntimeEqArg.slice('--runtime='.length).trim();
|
|
176
|
+
if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
177
|
+
stateRuntime = value;
|
|
178
|
+
} else if (stateRuntimeIdx !== -1) {
|
|
179
|
+
const value = args[stateRuntimeIdx + 1];
|
|
180
|
+
if (!value || value.startsWith('--')) {
|
|
181
|
+
error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
182
|
+
}
|
|
183
|
+
stateRuntime = value;
|
|
184
|
+
}
|
|
185
|
+
capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime });
|
|
186
|
+
} else if (capSubcommand === 'set') {
|
|
187
|
+
// capability set <id> [--on|--off|--enable|--disable] [--gate <key>=<bool>]... [--config-dir <dir>] [--runtime <r>] [--scope <s>]
|
|
188
|
+
const capId = args[2];
|
|
189
|
+
if (!capId || capId.startsWith('--')) {
|
|
190
|
+
error('Missing capability id for: capability set <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
191
|
+
}
|
|
192
|
+
// Parse --config-dir
|
|
193
|
+
const setConfigDirIdx = args.indexOf('--config-dir');
|
|
194
|
+
let setConfigDir = null;
|
|
195
|
+
if (setConfigDirIdx !== -1) {
|
|
196
|
+
const setConfigDirVal = args[setConfigDirIdx + 1];
|
|
197
|
+
if (!setConfigDirVal || setConfigDirVal.startsWith('--')) {
|
|
198
|
+
error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
199
|
+
}
|
|
200
|
+
setConfigDir = setConfigDirVal;
|
|
201
|
+
}
|
|
202
|
+
const resolvedSetConfigDir = setConfigDir ? path.resolve(setConfigDir) : null;
|
|
203
|
+
// Parse --on/--enable and --off/--disable (mutually exclusive)
|
|
204
|
+
const hasOn = args.includes('--on') || args.includes('--enable');
|
|
205
|
+
const hasOff = args.includes('--off') || args.includes('--disable');
|
|
206
|
+
if (hasOn && hasOff) {
|
|
207
|
+
error('Conflicting flags: --on/--enable and --off/--disable cannot both be present', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
208
|
+
}
|
|
209
|
+
let setEnabled;
|
|
210
|
+
if (hasOn) {
|
|
211
|
+
setEnabled = true;
|
|
212
|
+
} else if (hasOff) {
|
|
213
|
+
setEnabled = false;
|
|
214
|
+
}
|
|
215
|
+
// Parse --gate <key>=<bool> (repeatable)
|
|
216
|
+
const setGates = {};
|
|
217
|
+
for (let gi = 0; gi < args.length; gi++) {
|
|
218
|
+
if (args[gi] === '--gate') {
|
|
219
|
+
const gateVal = args[gi + 1];
|
|
220
|
+
if (!gateVal || gateVal.startsWith('--')) {
|
|
221
|
+
error('Missing value for --gate (expected <key>=<true|false>)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
222
|
+
}
|
|
223
|
+
const eqIdx = gateVal.indexOf('=');
|
|
224
|
+
if (eqIdx === -1) {
|
|
225
|
+
error(`Malformed --gate value "${gateVal}": expected <key>=<true|false>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
226
|
+
}
|
|
227
|
+
const gateKey = gateVal.slice(0, eqIdx);
|
|
228
|
+
const gateBoolStr = gateVal.slice(eqIdx + 1);
|
|
229
|
+
if (gateBoolStr !== 'true' && gateBoolStr !== 'false') {
|
|
230
|
+
error(`Malformed --gate value "${gateVal}": bool must be true or false`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
231
|
+
}
|
|
232
|
+
setGates[gateKey] = gateBoolStr === 'true';
|
|
233
|
+
gi++; // skip consumed value
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Parse --runtime and --scope (validate that values are present and not flags)
|
|
237
|
+
const runtimeIdx = args.indexOf('--runtime');
|
|
238
|
+
let setRuntime;
|
|
239
|
+
if (runtimeIdx !== -1) {
|
|
240
|
+
const runtimeVal = args[runtimeIdx + 1];
|
|
241
|
+
if (!runtimeVal || runtimeVal.startsWith('--')) {
|
|
242
|
+
error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
243
|
+
}
|
|
244
|
+
setRuntime = runtimeVal;
|
|
245
|
+
}
|
|
246
|
+
const scopeIdx = args.indexOf('--scope');
|
|
247
|
+
let setScope;
|
|
248
|
+
if (scopeIdx !== -1) {
|
|
249
|
+
const scopeVal = args[scopeIdx + 1];
|
|
250
|
+
if (!scopeVal || scopeVal.startsWith('--')) {
|
|
251
|
+
error('Missing value for --scope', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
252
|
+
}
|
|
253
|
+
setScope = scopeVal;
|
|
254
|
+
}
|
|
255
|
+
capabilityWriter.cmdCapabilitySet(
|
|
256
|
+
cwd,
|
|
257
|
+
resolvedSetConfigDir,
|
|
258
|
+
capId,
|
|
259
|
+
{ enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
|
|
260
|
+
raw,
|
|
261
|
+
);
|
|
262
|
+
} else if (capSubcommand === 'install') {
|
|
263
|
+
// capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
|
|
264
|
+
const spec = args[2];
|
|
265
|
+
if (!spec || spec.startsWith('--')) {
|
|
266
|
+
error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
267
|
+
}
|
|
268
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
269
|
+
const lifecycle = require('./capability-lifecycle.cjs');
|
|
270
|
+
const trust = require('./capability-trust.cjs');
|
|
271
|
+
// Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
|
|
272
|
+
// staging, or any shared-config write — so an over-cap install fails fast with a clear count
|
|
273
|
+
// error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
|
|
274
|
+
// depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
|
|
275
|
+
const installSharedFiles = capRepeatedFlag('--shared-file');
|
|
276
|
+
const ledgerModInstall = require('./capability-ledger.cjs');
|
|
277
|
+
if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
|
|
278
|
+
error(
|
|
279
|
+
`capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
|
|
280
|
+
`exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
|
|
281
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
285
|
+
const res = await lifecycle.installCapability(spec, {
|
|
286
|
+
runtimeDir,
|
|
287
|
+
hostVersion: capHostVersion(),
|
|
288
|
+
consentGranted: capHasFlag('--yes'),
|
|
289
|
+
integrity: capFlagValue('--integrity'),
|
|
290
|
+
sharedFiles: installSharedFiles,
|
|
291
|
+
strictKnownRegistries: capReadStrict(),
|
|
292
|
+
// #1459: bind a user consent record for a CONSENTED project install (under the user-owned
|
|
293
|
+
// consent home, NOT in the repo). The lifecycle records nothing for global scope.
|
|
294
|
+
scope,
|
|
295
|
+
consentStoreDir: capConsentHome(),
|
|
296
|
+
});
|
|
297
|
+
if (res.status === 'installed') {
|
|
298
|
+
output({
|
|
299
|
+
status: 'installed',
|
|
300
|
+
id: res.id,
|
|
301
|
+
version: res.version,
|
|
302
|
+
scope,
|
|
303
|
+
disclosure: trust.summarizeDisclosure(res.disclosure || {}),
|
|
304
|
+
}, raw);
|
|
305
|
+
} else if (res.status === 'aborted') {
|
|
306
|
+
// 'aborted' always means "executable surface needs consent" in the lifecycle contract —
|
|
307
|
+
// match it regardless of the requiresConsent flag so a future aborted path can't fall
|
|
308
|
+
// through to the generic "blocked: unknown reason" arm with a misleading message.
|
|
309
|
+
const disclosure = trust.summarizeDisclosure(res.disclosure || {});
|
|
310
|
+
// UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
|
|
311
|
+
// can detect the consent requirement programmatically. We throw ExitError (not error(),
|
|
312
|
+
// which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
|
|
313
|
+
// is flushed before exit; the human-readable guidance still lands on stderr.
|
|
314
|
+
output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
|
|
315
|
+
throw new ExitError(
|
|
316
|
+
1,
|
|
317
|
+
['Error: This capability declares executable surfaces and needs your consent before install:']
|
|
318
|
+
.concat(disclosure.map((l) => ' ' + l))
|
|
319
|
+
.concat(['Re-run with --yes to grant consent and install.'])
|
|
320
|
+
.join('\n'),
|
|
321
|
+
);
|
|
322
|
+
} else {
|
|
323
|
+
error(
|
|
324
|
+
`capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
|
|
325
|
+
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
} else if (capSubcommand === 'update') {
|
|
329
|
+
// capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
|
|
330
|
+
const all = capHasFlag('--all');
|
|
331
|
+
const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
|
|
332
|
+
if (!all && !id) {
|
|
333
|
+
error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
334
|
+
}
|
|
335
|
+
if (all && id) {
|
|
336
|
+
error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
337
|
+
}
|
|
338
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
339
|
+
const lifecycle = require('./capability-lifecycle.cjs');
|
|
340
|
+
const ledgerMod = require('./capability-ledger.cjs');
|
|
341
|
+
const trust = require('./capability-trust.cjs');
|
|
342
|
+
// Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
|
|
343
|
+
// the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
|
|
344
|
+
// re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
|
|
345
|
+
// a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
|
|
346
|
+
const updateSharedFiles = capRepeatedFlag('--shared-file');
|
|
347
|
+
if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
|
|
348
|
+
error(
|
|
349
|
+
`capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
|
|
350
|
+
`exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
|
|
351
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
355
|
+
// readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
|
|
356
|
+
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
|
|
357
|
+
// corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
|
|
358
|
+
// or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
|
|
359
|
+
let ledger;
|
|
360
|
+
try {
|
|
361
|
+
ledger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
364
|
+
}
|
|
365
|
+
const entries = (ledger && ledger.entries) || {};
|
|
366
|
+
const upgradeOne = async (capId) => {
|
|
367
|
+
const entry = entries[capId];
|
|
368
|
+
if (!entry) return { id: capId, status: 'not_installed' };
|
|
369
|
+
// expectedId pins the op to the requested id: a retargeted/edited source that now resolves
|
|
370
|
+
// to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
|
|
371
|
+
const r = await lifecycle.upgradeCapability(entry.source, {
|
|
372
|
+
runtimeDir,
|
|
373
|
+
hostVersion: capHostVersion(),
|
|
374
|
+
consentGranted: capHasFlag('--yes'),
|
|
375
|
+
sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
|
|
376
|
+
strictKnownRegistries: capReadStrict(),
|
|
377
|
+
expectedId: capId,
|
|
378
|
+
// #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
|
|
379
|
+
scope,
|
|
380
|
+
consentStoreDir: capConsentHome(),
|
|
381
|
+
});
|
|
382
|
+
// UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
|
|
383
|
+
// them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
|
|
384
|
+
// stable per-entry shape for `--all` consumers.
|
|
385
|
+
return {
|
|
386
|
+
id: capId,
|
|
387
|
+
status: r.status,
|
|
388
|
+
fromVersion: r.fromVersion ?? null,
|
|
389
|
+
toVersion: r.toVersion ?? null,
|
|
390
|
+
requiresConsent: r.requiresConsent ?? null,
|
|
391
|
+
blockReasons: r.blockReasons ?? null,
|
|
392
|
+
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
if (all) {
|
|
396
|
+
// Sequential by design: each upgrade takes the per-scope capability lock; parallel
|
|
397
|
+
// runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
|
|
398
|
+
const results = [];
|
|
399
|
+
for (const capId of Object.keys(entries)) {
|
|
400
|
+
results.push(await upgradeOne(capId));
|
|
401
|
+
}
|
|
402
|
+
const failed = results.filter((x) => x.status !== 'upgraded');
|
|
403
|
+
if (failed.length > 0) {
|
|
404
|
+
// UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
|
|
405
|
+
// alike), then set a non-zero exit. Previously the results JSON was embedded inside the
|
|
406
|
+
// error STRING on stderr, so automation could not parse a partial-failure run as
|
|
407
|
+
// structured data. We throw ExitError (not error(), which calls process.exit and would
|
|
408
|
+
// bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
|
|
409
|
+
// concise reason still lands on stderr.
|
|
410
|
+
output({ scope, updated: results }, raw);
|
|
411
|
+
throw new ExitError(
|
|
412
|
+
1,
|
|
413
|
+
`Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
|
|
414
|
+
`(see the JSON result on stdout for per-capability status).`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
output({ scope, updated: results }, raw);
|
|
418
|
+
} else {
|
|
419
|
+
const r = await upgradeOne(id);
|
|
420
|
+
if (r.status === 'upgraded') {
|
|
421
|
+
output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
|
|
422
|
+
} else if (r.status === 'not_installed') {
|
|
423
|
+
error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
424
|
+
} else if (r.status === 'aborted') {
|
|
425
|
+
// 'aborted' always means "needs consent" (see install) — handle it independently of the
|
|
426
|
+
// requiresConsent flag so it never falls through to the generic blocked arm.
|
|
427
|
+
error(
|
|
428
|
+
[`capability update for "${id}" changes its executable surface and needs your consent:`]
|
|
429
|
+
.concat((r.disclosure || []).map((l) => ' ' + l))
|
|
430
|
+
.concat(['Re-run with --yes to grant consent and update.'])
|
|
431
|
+
.join('\n'),
|
|
432
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
433
|
+
);
|
|
434
|
+
} else {
|
|
435
|
+
error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else if (capSubcommand === 'remove') {
|
|
439
|
+
// capability remove <id> [--purge-data] [--scope global|project]
|
|
440
|
+
const id = args[2];
|
|
441
|
+
if (!id || id.startsWith('--')) {
|
|
442
|
+
error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
443
|
+
}
|
|
444
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
445
|
+
const lifecycle = require('./capability-lifecycle.cjs');
|
|
446
|
+
const ledgerMod = require('./capability-ledger.cjs');
|
|
447
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
448
|
+
// Ledger first: an installed overlay is removable even if its id shadows a first-party name.
|
|
449
|
+
// Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
|
|
450
|
+
// Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
|
|
451
|
+
// silently reporting "first-party cannot be removed" for any id (finding 7).
|
|
452
|
+
let removeLedger;
|
|
453
|
+
try {
|
|
454
|
+
removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
457
|
+
}
|
|
458
|
+
const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
|
|
459
|
+
if (!inLedger) {
|
|
460
|
+
const base = require('./capability-loader.cjs').loadRegistry();
|
|
461
|
+
if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
|
|
462
|
+
error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const res = lifecycle.removeCapability(id, {
|
|
466
|
+
runtimeDir,
|
|
467
|
+
removeData: capHasFlag('--purge-data'),
|
|
468
|
+
// #1459: a project-scope removal revokes the user consent record so a later repo-dropped
|
|
469
|
+
// bundle of the same id cannot silently re-activate against a stale consent.
|
|
470
|
+
scope,
|
|
471
|
+
consentStoreDir: capConsentHome(),
|
|
472
|
+
});
|
|
473
|
+
if (res.status === 'removed') {
|
|
474
|
+
// #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
|
|
475
|
+
// could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
|
|
476
|
+
// record remains. Surface it on stderr + in the JSON so the user knows to clear it.
|
|
477
|
+
if (res.consentRevokeFailed) {
|
|
478
|
+
process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
|
|
479
|
+
}
|
|
480
|
+
output({
|
|
481
|
+
status: 'removed',
|
|
482
|
+
id,
|
|
483
|
+
scope,
|
|
484
|
+
removedFiles: res.removedFiles,
|
|
485
|
+
strippedEdits: res.strippedEdits,
|
|
486
|
+
dataPreserved: res.dataPreserved,
|
|
487
|
+
consentRevokeFailed: res.consentRevokeFailed || undefined,
|
|
488
|
+
consentRevokeWarning: res.consentRevokeWarning || undefined,
|
|
489
|
+
}, raw);
|
|
490
|
+
} else if (res.status === 'not_installed') {
|
|
491
|
+
error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
492
|
+
} else {
|
|
493
|
+
error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
494
|
+
}
|
|
495
|
+
} else if (capSubcommand === 'list') {
|
|
496
|
+
// capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
|
|
497
|
+
// When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
|
|
498
|
+
// corrupt unrelated ledger in another scope does not block a scoped list).
|
|
499
|
+
const loader = require('./capability-loader.cjs');
|
|
500
|
+
const ledgerMod = require('./capability-ledger.cjs');
|
|
501
|
+
const semver = require('./semver-compare.cjs');
|
|
502
|
+
const host = capHostVersion();
|
|
503
|
+
const rows = [];
|
|
504
|
+
const listScopeArg = capFlagValue('--scope');
|
|
505
|
+
// Validate --scope if provided.
|
|
506
|
+
if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
|
|
507
|
+
error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
508
|
+
}
|
|
509
|
+
// First-party capabilities are always included (they have no scope concept).
|
|
510
|
+
const base = loader.loadRegistry();
|
|
511
|
+
const fp = (base && base.capabilities) || {};
|
|
512
|
+
// #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
|
|
513
|
+
// (a bundle whose project ledger looks committed but has no user consent record on THIS
|
|
514
|
+
// machine) is marked status:'inactive' with a reason, instead of silently appearing active.
|
|
515
|
+
// loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
|
|
516
|
+
const inactiveById = {};
|
|
517
|
+
try {
|
|
518
|
+
const composed = loader.loadRegistry({ includeInstalled: true, cwd });
|
|
519
|
+
const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
|
|
520
|
+
for (const w of overlayWarnings) {
|
|
521
|
+
// #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
|
|
522
|
+
// human-readable reason prose (which is free to change without breaking this filter).
|
|
523
|
+
if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
|
|
524
|
+
inactiveById[`${w.scope} ${w.id}`] = w.reason;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
} catch { /* best-effort — list still works without the inactive annotation */ }
|
|
528
|
+
// Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the
|
|
529
|
+
// SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so
|
|
530
|
+
// `list` and `state` stop disagreeing. `list` previously derived `status`
|
|
531
|
+
// purely from ledger-entry existence — an installed-but-not-surfaced cap
|
|
532
|
+
// reported active in `list` and absent in `state`. Surfaced is evaluated at
|
|
533
|
+
// the default runtime config dir (the resolver resolves it when undefined),
|
|
534
|
+
// matching `capability state <id>` with no --config-dir. Best-effort: a
|
|
535
|
+
// resolver failure leaves surfacedById empty (rows report surfaced:null).
|
|
536
|
+
const surfacedById = {};
|
|
537
|
+
// surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface
|
|
538
|
+
// state is single-source — one runtime config dir → one .gsd-surface.json
|
|
539
|
+
// → one surfaced truth per capId — and the loader dedupes overlay caps to
|
|
540
|
+
// one registry entry per id (first-party-wins). So a cap installed in both
|
|
541
|
+
// scopes correctly shares one surfaced value across its list rows.
|
|
542
|
+
try {
|
|
543
|
+
const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined);
|
|
544
|
+
for (const cap of (surfaceState && surfaceState.capabilities) || []) {
|
|
545
|
+
if (cap && typeof cap.id === 'string') {
|
|
546
|
+
surfacedById[cap.id] = cap.surfaced === true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch { /* best-effort — list still works without the surfaced annotation */ }
|
|
550
|
+
for (const capId of Object.keys(fp)) {
|
|
551
|
+
const cap = fp[capId] || {};
|
|
552
|
+
rows.push({
|
|
553
|
+
id: capId,
|
|
554
|
+
role: cap.role || null,
|
|
555
|
+
version: cap.version || null,
|
|
556
|
+
tier: cap.tier || null,
|
|
557
|
+
source: 'first-party',
|
|
558
|
+
scope: 'first-party',
|
|
559
|
+
status: 'active',
|
|
560
|
+
surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
|
|
561
|
+
title: cap.title || null,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// Overlay scopes: honor --scope to read only the requested scope (finding 8).
|
|
565
|
+
const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
|
|
566
|
+
for (const sc of overlayScopes) {
|
|
567
|
+
const { runtimeDir } = capResolveScope(sc);
|
|
568
|
+
// readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
|
|
569
|
+
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
|
|
570
|
+
// corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
|
|
571
|
+
// dropping overlay entries and returning a first-party-only list (site A fix, #1462).
|
|
572
|
+
let ledger;
|
|
573
|
+
try {
|
|
574
|
+
ledger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
// UX-3: name the offending scope so the user knows WHICH ledger to fix.
|
|
577
|
+
error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
578
|
+
}
|
|
579
|
+
if (!ledger || !ledger.entries) continue;
|
|
580
|
+
for (const capId of Object.keys(ledger.entries)) {
|
|
581
|
+
const entry = ledger.entries[capId];
|
|
582
|
+
let manifest = {};
|
|
583
|
+
try {
|
|
584
|
+
// #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
|
|
585
|
+
// bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
|
|
586
|
+
// a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
|
|
587
|
+
// an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
|
|
588
|
+
// declarative capability.json. A null (genuinely missing) or a bounded-reader throw
|
|
589
|
+
// (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
|
|
590
|
+
// (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
|
|
591
|
+
const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
|
|
592
|
+
manifest = raw === null ? {} : JSON.parse(raw);
|
|
593
|
+
} catch { manifest = {}; }
|
|
594
|
+
let status = 'active';
|
|
595
|
+
let reason = null;
|
|
596
|
+
const range = manifest.engines && manifest.engines.gsd;
|
|
597
|
+
if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
|
|
598
|
+
// #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
|
|
599
|
+
const inactiveReason = inactiveById[`${sc} ${capId}`];
|
|
600
|
+
if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
|
|
601
|
+
rows.push({
|
|
602
|
+
id: capId,
|
|
603
|
+
role: manifest.role || null,
|
|
604
|
+
version: entry.version || null,
|
|
605
|
+
tier: manifest.tier || null,
|
|
606
|
+
source: entry.source || null,
|
|
607
|
+
scope: sc,
|
|
608
|
+
status,
|
|
609
|
+
reason,
|
|
610
|
+
// Issue #2045 (DEFECT 3): surfaced reflects surface composition, so
|
|
611
|
+
// list and state agree. An inactive (unconsented/incompatible) cap is
|
|
612
|
+
// surfaced:false by definition; otherwise defer to the resolver.
|
|
613
|
+
surfaced: status === 'active'
|
|
614
|
+
? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null)
|
|
615
|
+
: false,
|
|
616
|
+
title: manifest.title || null,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
output(rows, raw || capHasFlag('--json'));
|
|
621
|
+
} else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
|
|
622
|
+
// capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
|
|
623
|
+
const id = args[2];
|
|
624
|
+
if (!id || id.startsWith('--')) {
|
|
625
|
+
error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
626
|
+
}
|
|
627
|
+
const dCfg = capFlagValue('--config-dir');
|
|
628
|
+
capabilityWriter.cmdCapabilitySet(
|
|
629
|
+
cwd,
|
|
630
|
+
dCfg ? path.resolve(dCfg) : null,
|
|
631
|
+
id,
|
|
632
|
+
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
|
|
633
|
+
raw,
|
|
634
|
+
);
|
|
635
|
+
} else if (capSubcommand === 'outdated') {
|
|
636
|
+
// capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
|
|
637
|
+
// For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
|
|
638
|
+
// latest available version and report whether a newer one exists. This never re-clones/re-packs;
|
|
639
|
+
// a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
|
|
640
|
+
const lifecycle = require('./capability-lifecycle.cjs');
|
|
641
|
+
const outdatedScopeArg = capFlagValue('--scope');
|
|
642
|
+
if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
|
|
643
|
+
error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
644
|
+
}
|
|
645
|
+
// Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
|
|
646
|
+
const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
|
|
647
|
+
const records = [];
|
|
648
|
+
for (const sc of outdatedScopes) {
|
|
649
|
+
const { runtimeDir } = capResolveScope(sc);
|
|
650
|
+
// outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
|
|
651
|
+
const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
|
|
652
|
+
for (const r of scRecords) records.push({ ...r, scope: sc });
|
|
653
|
+
}
|
|
654
|
+
const asJson = raw || capHasFlag('--json');
|
|
655
|
+
if (asJson) {
|
|
656
|
+
output(records, false); // machine output: the records array (JSON).
|
|
657
|
+
} else {
|
|
658
|
+
// Human-readable table: ID | Source | Current | Latest | Status.
|
|
659
|
+
const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
|
|
660
|
+
const cell = (v) => (v === null || v === undefined ? '-' : String(v));
|
|
661
|
+
const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
|
|
662
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
|
|
663
|
+
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
|
|
664
|
+
const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
|
|
665
|
+
for (const row of tableRows) lines.push(fmt(row));
|
|
666
|
+
if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
|
|
667
|
+
output(records, true, lines.join('\n') + '\n');
|
|
668
|
+
}
|
|
669
|
+
} else if (capSubcommand === 'trust') {
|
|
670
|
+
// capability trust list [--scope project] [--json]
|
|
671
|
+
// capability trust revoke <id> [--project <path>]
|
|
672
|
+
// The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
|
|
673
|
+
const consentMod = require('./capability-consent.cjs');
|
|
674
|
+
const trustSub = args[2];
|
|
675
|
+
if (trustSub === 'list') {
|
|
676
|
+
// --scope is accepted for symmetry; only 'project' records exist today.
|
|
677
|
+
const listScope = capFlagValue('--scope');
|
|
678
|
+
if (listScope && listScope !== 'project') {
|
|
679
|
+
error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
680
|
+
}
|
|
681
|
+
const store = consentMod.readConsentStore(capConsentHome());
|
|
682
|
+
const rows = Object.keys(store.records).map((k) => {
|
|
683
|
+
const r = store.records[k];
|
|
684
|
+
// #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
|
|
685
|
+
// binding against the current bundle (e.g. `gsd capability list` showing inactive after a
|
|
686
|
+
// tamper) and understand why a consented cap deactivated. The contentHash is THE security
|
|
687
|
+
// binding the loader checks; disclosureSignature is the executable-surface re-consent key.
|
|
688
|
+
return {
|
|
689
|
+
id: r.id, scope: r.scope, projectRoot: r.projectRoot,
|
|
690
|
+
integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
|
|
691
|
+
consentedAt: r.consentedAt,
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
output(rows, raw || capHasFlag('--json'));
|
|
695
|
+
} else if (trustSub === 'revoke') {
|
|
696
|
+
const id = args[3];
|
|
697
|
+
if (!id || id.startsWith('--')) {
|
|
698
|
+
error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
699
|
+
}
|
|
700
|
+
// --project pins the project root whose consent is revoked; defaults to realpath(cwd).
|
|
701
|
+
const projFlag = capFlagValue('--project');
|
|
702
|
+
let projectRoot;
|
|
703
|
+
try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
|
|
704
|
+
catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
|
|
705
|
+
// #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
|
|
706
|
+
// (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
|
|
707
|
+
// error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
|
|
708
|
+
// consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
|
|
709
|
+
try {
|
|
710
|
+
consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
|
|
711
|
+
} catch (err) {
|
|
712
|
+
error(
|
|
713
|
+
`capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
|
|
714
|
+
`(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
|
|
715
|
+
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
|
|
719
|
+
} else {
|
|
720
|
+
error(
|
|
721
|
+
`Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
|
|
722
|
+
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
} else {
|
|
726
|
+
error(
|
|
727
|
+
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
|
|
728
|
+
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
module.exports = { routeCapabilityCommand };
|