superpowers-manager 0.2.0 → 0.4.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/README.md +340 -66
- package/dist/adapter-result.js +204 -0
- package/dist/adapter.js +690 -0
- package/dist/atomic.js +142 -0
- package/dist/cli-arguments.js +32 -0
- package/dist/cli.js +313 -0
- package/dist/codex-harness.js +271 -0
- package/dist/codex-json.js +89 -0
- package/dist/codex-prepare.js +199 -0
- package/dist/codex-presentation.js +259 -0
- package/dist/codex-state.js +78 -0
- package/dist/commands/context.js +1 -0
- package/dist/commands/install.js +291 -0
- package/dist/commands/pin.js +79 -0
- package/dist/commands/prepare.js +420 -0
- package/dist/commands/probe.js +257 -0
- package/dist/commands/track-latest.js +44 -0
- package/dist/commands/uninstall.js +217 -0
- package/dist/commands/unpin.js +95 -0
- package/dist/commands/update.js +79 -0
- package/dist/domain/fingerprint.js +4 -0
- package/dist/domain/refs.js +37 -0
- package/dist/effective-selection.js +123 -0
- package/dist/generated-plugin.js +748 -0
- package/dist/git.js +38 -0
- package/dist/harness.js +1 -0
- package/dist/hooks.js +290 -0
- package/dist/lifecycle.js +135 -0
- package/dist/manifest-overlay.js +125 -0
- package/dist/provenance.js +111 -0
- package/dist/python-json-format.js +83 -0
- package/dist/python-json.js +29 -0
- package/dist/python-text.js +74 -0
- package/dist/safe-path.js +137 -0
- package/dist/safety-error.js +10 -0
- package/dist/selection-state-cli.js +85 -0
- package/dist/selection-store.js +172 -0
- package/dist/selection.js +200 -0
- package/dist/status.js +24 -0
- package/dist/strict-json.js +248 -0
- package/dist/upstream-cli.js +110 -0
- package/dist/upstream-version.js +40 -0
- package/dist/upstream.js +253 -0
- package/dist/validate-generated-plugin-cli.js +118 -0
- package/dist/validator.js +317 -0
- package/dist/workspace.js +139 -0
- package/package.json +25 -4
- package/bin/superpowers-manager.js +0 -290
- package/scripts/adapters/codex/adapter +0 -603
- package/scripts/adapters/codex/lib.sh +0 -449
- package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
- package/scripts/core/adapter.sh +0 -103
- package/scripts/core/common.sh +0 -59
- package/scripts/core/lifecycle.sh +0 -141
- package/scripts/core/provenance.sh +0 -135
- package/scripts/core/selection-state.py +0 -414
- package/scripts/core/selection.sh +0 -168
- package/scripts/core/status.sh +0 -27
- package/scripts/core/upstream.sh +0 -280
- package/scripts/core/validate-adapter-response.py +0 -284
- package/scripts/install +0 -59
- package/scripts/pin +0 -51
- package/scripts/prepare +0 -117
- package/scripts/probe +0 -82
- package/scripts/track-latest +0 -30
- package/scripts/uninstall +0 -35
- package/scripts/unpin +0 -41
- package/scripts/update +0 -34
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { assertFailureWritable, writeAdapterFailure, } from "../adapter-result.js";
|
|
2
|
+
import { oneLine } from "../cli-arguments.js";
|
|
3
|
+
import { computeEffectiveSelection } from "../effective-selection.js";
|
|
4
|
+
export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain]\n";
|
|
5
|
+
// Ports scripts/core/validate-adapter-response.py's replay (:235-238) and its
|
|
6
|
+
// error/hint block (:269-272). The shell ran that validator on EVERY adapter
|
|
7
|
+
// response, so adapter messages reached the operator on their declared streams
|
|
8
|
+
// in array order, and a controlled failure printed `error:` plus one `hint:`
|
|
9
|
+
// per hint. DIAG-ADAPTER-01 retains that contract
|
|
10
|
+
// (docs/baseline/protocol-disposition.md); dropping it here would be a
|
|
11
|
+
// silent diagnostics regression, not a simplification.
|
|
12
|
+
//
|
|
13
|
+
// Interpolating error.message and each hint is the sanctioned form (AGENTS.md):
|
|
14
|
+
// src/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
|
|
15
|
+
// message, so the callee owns every failure reachable on this path. Those two
|
|
16
|
+
// writes now live in writeAdapterFailure (src/adapter-result.ts), which
|
|
17
|
+
// validates all three strings before the first of them reaches the stream.
|
|
18
|
+
//
|
|
19
|
+
// This writes to ctx, so it MUST NOT be called from inside gatherProbe's try
|
|
20
|
+
// -- see the ProbeOutcome note below.
|
|
21
|
+
export function replayOutcome(outcome, ctx) {
|
|
22
|
+
// Hoisted ABOVE the message loop, and that ordering is the point: a failure
|
|
23
|
+
// whose code, message, or a hint carries a terminal control character must
|
|
24
|
+
// leave both streams untouched, not the context lines followed by nothing.
|
|
25
|
+
// Returns immediately on a succeeding outcome.
|
|
26
|
+
assertFailureWritable(outcome);
|
|
27
|
+
for (const message of outcome.messages) {
|
|
28
|
+
const stream = message.channel === "stdout" ? ctx.stdout : ctx.stderr;
|
|
29
|
+
stream.write(`${message.text}\n`);
|
|
30
|
+
}
|
|
31
|
+
// The message loop stays unguarded on purpose: D8b scopes the check to code,
|
|
32
|
+
// message, and hints, and AdapterMessageLog escapes message text before it is
|
|
33
|
+
// stored, so scanning it here would re-check a population that cannot fail.
|
|
34
|
+
writeAdapterFailure(ctx, outcome);
|
|
35
|
+
}
|
|
36
|
+
// `runAdapter` reports a CONTROLLED failure by RETURN VALUE, not by throwing
|
|
37
|
+
// (`src/adapter-result.ts:32-35::export interface AdapterResult`). The shell got
|
|
38
|
+
// fail-closed behaviour for free: spw_invoke_adapter returned 1 and
|
|
39
|
+
// scripts/probe ran under `set -eu`.
|
|
40
|
+
// Omitting the status check here would read a failed inspection as absent
|
|
41
|
+
// evidence and report it as success.
|
|
42
|
+
//
|
|
43
|
+
// It does still THROW for a non-AdapterFailure cause (runAdapter's closing
|
|
44
|
+
// `throw cause`, src/adapter.ts). That is caught here rather than in runProbe's
|
|
45
|
+
// outer catch, because the two need different diagnostics -- see spec §3.3a.
|
|
46
|
+
//
|
|
47
|
+
// Reached through ctx.adapter, not a direct module-level dependency on the
|
|
48
|
+
// adapter module: src/commands/prepare.ts and this module are the two the
|
|
49
|
+
// injected double must observe, because install reaches the adapter through
|
|
50
|
+
// gatherProbe and runPrepare. Spec §4.5.
|
|
51
|
+
async function inspect(call, unexpected, invalidStatus) {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await call();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, result: null, message: unexpected };
|
|
58
|
+
}
|
|
59
|
+
const outcome = result.outcome;
|
|
60
|
+
if (result.status !== 0 || !outcome.ok) {
|
|
61
|
+
// The `outcome.ok && status !== 0` combination cannot arise from
|
|
62
|
+
// successResult/failureResult, so it gets its own hand-written message
|
|
63
|
+
// rather than falling through to a replay that would print nothing.
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
result,
|
|
67
|
+
message: outcome.ok ? invalidStatus : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, result: { status: result.status, outcome } };
|
|
71
|
+
}
|
|
72
|
+
// Runs every step that can throw or fail closed, returning the outcome as
|
|
73
|
+
// data. It performs no writes of its own, so nothing inside runProbe's try can
|
|
74
|
+
// raise EPIPE — the same shape as src/commands/unpin.ts's attemptUnpin.
|
|
75
|
+
//
|
|
76
|
+
// The outcomes are CARRIED OUT rather than replayed in place, for that same
|
|
77
|
+
// reason: replayOutcome writes to ctx, and a write inside this try could
|
|
78
|
+
// raise EPIPE and be caught and relabelled as a selection failure. runProbe
|
|
79
|
+
// replays them, in collection order, after the try/catch has resolved. The
|
|
80
|
+
// operator-visible result is identical -- probe emits nothing else until the
|
|
81
|
+
// very end -- and the EPIPE hazard never exists.
|
|
82
|
+
// Exported for src/commands/install.ts and src/commands/update.ts, which need
|
|
83
|
+
// probe's FACTS rather than its rendering. The historical
|
|
84
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:39-41::spw_probe_field`
|
|
85
|
+
// implementation awk-parsed the porcelain back into fields; that round trip is
|
|
86
|
+
// gone.
|
|
87
|
+
export async function gatherProbe(ctx) {
|
|
88
|
+
// Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
|
|
89
|
+
const selection = await computeEffectiveSelection(ctx.root, ctx.env);
|
|
90
|
+
const outcomes = [];
|
|
91
|
+
const adapterContext = { root: ctx.root, env: ctx.env };
|
|
92
|
+
const collect = async (call, unexpected, invalidStatus) => {
|
|
93
|
+
const result = await inspect(call, unexpected, invalidStatus);
|
|
94
|
+
if (result.result !== null)
|
|
95
|
+
outcomes.push(result.result.outcome);
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection");
|
|
99
|
+
if (!prepared.ok) {
|
|
100
|
+
return { status: 1, outcomes, message: prepared.message };
|
|
101
|
+
}
|
|
102
|
+
const failure = (site) => ctx.adapter.presentation.callFailure(site, adapterContext);
|
|
103
|
+
const installedFailure = failure("probe-installed");
|
|
104
|
+
const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus);
|
|
105
|
+
if (!installed.ok) {
|
|
106
|
+
return { status: 1, outcomes, message: installed.message };
|
|
107
|
+
}
|
|
108
|
+
const ownershipFailure = failure("probe-ownership");
|
|
109
|
+
const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus);
|
|
110
|
+
if (!ownership.ok) {
|
|
111
|
+
return { status: 1, outcomes, message: ownership.message };
|
|
112
|
+
}
|
|
113
|
+
const controlFailure = failure("probe-control");
|
|
114
|
+
const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus);
|
|
115
|
+
if (!control.ok) {
|
|
116
|
+
return { status: 1, outcomes, message: control.message };
|
|
117
|
+
}
|
|
118
|
+
const preparedState = prepared.result.outcome.result;
|
|
119
|
+
const installedState = installed.result.outcome.result;
|
|
120
|
+
return {
|
|
121
|
+
status: 0,
|
|
122
|
+
outcomes,
|
|
123
|
+
facts: {
|
|
124
|
+
selection,
|
|
125
|
+
prepared: preparedState,
|
|
126
|
+
installed: installedState,
|
|
127
|
+
ownership: ownership.result.outcome.result,
|
|
128
|
+
control: control.result.outcome.result,
|
|
129
|
+
status: preparedState.kind !== "current"
|
|
130
|
+
? "needs prepare"
|
|
131
|
+
: installedState.kind !== "current"
|
|
132
|
+
? "needs install"
|
|
133
|
+
: "current",
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export async function runProbe(argv, ctx) {
|
|
138
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:42::porcelain` tested only `[ "${1:-}" = "--porcelain" ]`, so a typo'd
|
|
139
|
+
// flag silently produced human output. Rejecting it is a deliberate
|
|
140
|
+
// narrowing, recorded as a port-only entry in
|
|
141
|
+
// tests/migration-inventory/probe.md.
|
|
142
|
+
//
|
|
143
|
+
// This guard is NOT the production path. src/cli.ts's parseArgs rejects the
|
|
144
|
+
// same inputs first, before preflight, with the usage block the CLI's other
|
|
145
|
+
// usage errors carry — exactly the arrangement track-latest and unpin have,
|
|
146
|
+
// where the in-module check is an unreachable-from-CLI duplicate. It stays
|
|
147
|
+
// because runProbe is also called directly by tests and by any future
|
|
148
|
+
// in-process caller that has not been through parseArgs.
|
|
149
|
+
const porcelain = argv.length === 1 && argv[0] === "--porcelain";
|
|
150
|
+
if (!porcelain && argv.length !== 0) {
|
|
151
|
+
ctx.stderr.write(PROBE_USAGE);
|
|
152
|
+
return 2;
|
|
153
|
+
}
|
|
154
|
+
let outcome;
|
|
155
|
+
try {
|
|
156
|
+
outcome = await gatherProbe(ctx);
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
// Hand-written messages, per AGENTS.md's reader-diagnostics rule. Reachable
|
|
160
|
+
// here: the selectionErrors validateSource raises
|
|
161
|
+
// (`src/selection.ts:137::malformed`, :140, requireSingleLineString at :77),
|
|
162
|
+
// reached from
|
|
163
|
+
// computeEffectiveSelection's validateSource call
|
|
164
|
+
// (src/effective-selection.ts); the ones validateRecord raises
|
|
165
|
+
// (`src/selection.ts:198::integer`, :219, requireObject at :47, requireExactKeys at
|
|
166
|
+
// :64, validatePinnedRecord at :170-183), reached from
|
|
167
|
+
// `src/selection-store.ts:103::return validateRecord` on the read path; the
|
|
168
|
+
// selectionErrors requireAbsolute and selectionConfigDir
|
|
169
|
+
// (src/effective-selection.ts) raise
|
|
170
|
+
// for a non-absolute or missing config directory; readConfigRef's `cannot
|
|
171
|
+
// read packaged upstream ref <path>` (src/upstream.ts); and resolveRef's own
|
|
172
|
+
// two no-match diagnostics, `no stable semver tag found for latest-release`
|
|
173
|
+
// and `cannot resolve upstream ref: <ref>` (src/upstream.ts). normalizeSaved
|
|
174
|
+
// (`src/selection.ts:222-241::normalizeSaved`) throws nothing at all.
|
|
175
|
+
//
|
|
176
|
+
// THREE exceptions, all inherited and none a regression:
|
|
177
|
+
// 1. resolveRef splices git's combined stdout+stderr into its own text
|
|
178
|
+
// (its three `cannot query upstream ...` throws, src/upstream.ts),
|
|
179
|
+
// reached via computeEffectiveSelection's resolveRef call
|
|
180
|
+
// (src/effective-selection.ts). The packaged ref is `latest-release`,
|
|
181
|
+
// which resolveRef answers with an ls-remote, so an ordinary unpinned
|
|
182
|
+
// run reaches this rather than only an exotic corner -- though not
|
|
183
|
+
// every unpinned run does: resolveRef's COMMIT_INPUT_RE branch returns
|
|
184
|
+
// before any runGit call, so a 40-hex requested ref splices nothing.
|
|
185
|
+
// Pinned by
|
|
186
|
+
// `tests/unit/upstream.test.ts:453::void test("resolveRef reports a query failure for latest`,
|
|
187
|
+
// :471, and :483.
|
|
188
|
+
// 2. The
|
|
189
|
+
// `src/selection-store.ts:120-124::cause.module === "selection") {`
|
|
190
|
+
// site (same shape at :49, :86, :98) is the module AGENTS.md's
|
|
191
|
+
// `src/selection-store.ts` bullet grandfathers:
|
|
192
|
+
// it interpolates the caught error's own message, so Node errno
|
|
193
|
+
// prose can reach this stream. Reached on the READ path only, via
|
|
194
|
+
// loadSavedSelection (src/effective-selection.ts) ->
|
|
195
|
+
// readSelectionState
|
|
196
|
+
// (`src/selection-store.ts:149::export async function readSelectionState`).
|
|
197
|
+
// This module's four write-only interpolating sites -- ensureStateDirectory
|
|
198
|
+
// (:172), finalStateDiagnostic (:197), and the two in
|
|
199
|
+
// writeSelectionState's own catch (:225, :231) -- are all
|
|
200
|
+
// unreachable from probe, which never writes.
|
|
201
|
+
// 3. Every runGit call site inside resolveRef (src/upstream.ts) can
|
|
202
|
+
// reject instead of resolving. On the non-ENOENT arm of
|
|
203
|
+
// `src/git.ts:47-52::if (typeof`, runGit builds the message
|
|
204
|
+
// "cannot run git: " followed by the Node spawn error's own message
|
|
205
|
+
// (:51) and rejects with a SafetyError carrying it (:52), so that
|
|
206
|
+
// Node spawn-level text reaches ctx.stderr through this catch. The
|
|
207
|
+
// ENOENT arm (:50) is hand-written and carries nothing. A non-zero
|
|
208
|
+
// *exit status* is handled by exception 1 above; this is the
|
|
209
|
+
// *spawn-level* case, where runGit throws rather than returning a
|
|
210
|
+
// status.
|
|
211
|
+
//
|
|
212
|
+
// oneLine() at this catch collapses each of exceptions 1 and 3 -- the two
|
|
213
|
+
// carrying git-derived text -- to a single line. It collapses CR/LF only,
|
|
214
|
+
// so it bounds how much of that text lands, not what it may contain.
|
|
215
|
+
//
|
|
216
|
+
// fetchExactCommit is deliberately NOT in this list, unlike prepare's
|
|
217
|
+
// fetchExactCommit exception, which runPrepare's catch block documents.
|
|
218
|
+
// Its only callers are `src/upstream-cli.ts:83::await fetchExactCommit` and
|
|
219
|
+
// gatherPrepare's own call in src/commands/prepare.ts (git grep -n
|
|
220
|
+
// fetchExactCommit -- src/ is the check that keeps "only" true), so probe
|
|
221
|
+
// never reaches it and
|
|
222
|
+
// its splice sites cannot appear on this stream. Do not add it back by
|
|
223
|
+
// symmetry with prepare.
|
|
224
|
+
//
|
|
225
|
+
// generatedCommitOrEmpty is not in this list either: it cannot throw at
|
|
226
|
+
// all. It (src/provenance.ts) delegates to readGeneratedCommitLenient,
|
|
227
|
+
// which catches every failure and returns "".
|
|
228
|
+
//
|
|
229
|
+
// A non-AdapterFailure re-thrown by runAdapter's closing `throw cause`
|
|
230
|
+
// (src/adapter.ts) does NOT reach here: inspect() catches it and converts
|
|
231
|
+
// it to a hand-written message per AGENTS.md's reader-diagnostics rule -- a
|
|
232
|
+
// rethrown cause is exactly the failure src/adapter.ts declined to own, so
|
|
233
|
+
// its text must never reach this stream. See §3.3a.
|
|
234
|
+
//
|
|
235
|
+
// gatherProbe performs no writes of its own, so this catch cannot also be
|
|
236
|
+
// reached by an EPIPE from probe's own output — every write below runs
|
|
237
|
+
// only after this try/catch has resolved.
|
|
238
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
// Replay first, on both paths: the shell validator replayed every response's
|
|
242
|
+
// messages whether or not that response was a failure at
|
|
243
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`.
|
|
244
|
+
for (const each of outcome.outcomes)
|
|
245
|
+
replayOutcome(each, ctx);
|
|
246
|
+
if (outcome.status === 1) {
|
|
247
|
+
// null means replayOutcome already emitted the adapter's own `error:`
|
|
248
|
+
// and `hint:` lines for the failing outcome.
|
|
249
|
+
if (outcome.message !== null) {
|
|
250
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
251
|
+
}
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
const rendered = ctx.adapter.presentation.renderProbe(outcome.facts);
|
|
255
|
+
ctx.stdout.write(porcelain ? rendered.porcelain : rendered.human);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { oneLine } from "../cli-arguments.js";
|
|
2
|
+
import { selectionStatePath, loadSavedSelection, UPSTREAM_URL_DEFAULT, } from "../effective-selection.js";
|
|
3
|
+
import { validateSource } from "../selection.js";
|
|
4
|
+
import { writeSelectionState } from "../selection-store.js";
|
|
5
|
+
export async function runTrackLatest(argv, ctx) {
|
|
6
|
+
if (argv.length !== 0) {
|
|
7
|
+
ctx.stderr.write("error: usage: superpowers-manager track-latest\n");
|
|
8
|
+
return 2;
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
// Read first. This is a deliberate redundant boundary check, not the
|
|
12
|
+
// enforcing guard: writeSelectionState below already refuses to overwrite
|
|
13
|
+
// a corrupt existing record on its own (src/selection-store.ts's
|
|
14
|
+
// writeSelectionState calls readSelectionState on the same path before
|
|
15
|
+
// writing, under "Invalid existing state must block overwrite" —
|
|
16
|
+
// readSelectionState's own readOpenedRecord/parseRecordBytes is what
|
|
17
|
+
// validates an existing record, via validateRecord at
|
|
18
|
+
// `src/selection-store.ts:103::return validateRecord`). Calling loadSavedSelection here preserves
|
|
19
|
+
// `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/track-latest:20-21::read`'s read-then-write shape, so this command
|
|
20
|
+
// stays fail-closed on its own terms rather than solely by depending on
|
|
21
|
+
// the store's internals.
|
|
22
|
+
await loadSavedSelection(ctx.env);
|
|
23
|
+
const source = ctx.env.SUPERPOWERS_UPSTREAM_URL || UPSTREAM_URL_DEFAULT;
|
|
24
|
+
validateSource(source);
|
|
25
|
+
await writeSelectionState(selectionStatePath(ctx.env), {
|
|
26
|
+
schema_version: 1,
|
|
27
|
+
mode: "track-latest",
|
|
28
|
+
source,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (cause) {
|
|
32
|
+
// Every throw reachable here is a hand-written SafetyError from
|
|
33
|
+
// loadSavedSelection, validateSource, selectionStatePath, or
|
|
34
|
+
// writeSelectionState — re-emitting a subordinate module's own
|
|
35
|
+
// diagnostic is the sanctioned form of interpolation (see AGENTS.md's
|
|
36
|
+
// diagnostics convention). The try ends here, before the stdout write
|
|
37
|
+
// below: an EPIPE from that write must never be caught and relabelled
|
|
38
|
+
// as one of those modules' own diagnostics.
|
|
39
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
ctx.stdout.write("saved upstream selection: latest stable release\n");
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Ports scripts/uninstall. The shell sourced common.sh, provenance.sh,
|
|
2
|
+
// lifecycle.sh and adapter.sh; the predicates now live in src/lifecycle.ts
|
|
3
|
+
// and the adapter arrives through ctx.adapter.
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { oneLine } from "../cli-arguments.js";
|
|
6
|
+
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
7
|
+
import { replayOutcome } from "./probe.js";
|
|
8
|
+
async function invoke(call, failure, outcomes) {
|
|
9
|
+
let result;
|
|
10
|
+
try {
|
|
11
|
+
result = await call();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { ok: false, message: failure.unexpected };
|
|
15
|
+
}
|
|
16
|
+
outcomes.push(result.outcome);
|
|
17
|
+
const outcome = result.outcome;
|
|
18
|
+
if (result.status !== 0 || !outcome.ok) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: outcome.ok ? failure.invalidStatus : null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
result: { status: result.status, outcome },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// withWorkspace throws for mkdtemp failure before the callback ever runs
|
|
30
|
+
// ("cannot create workspace",
|
|
31
|
+
// `src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`).
|
|
32
|
+
// A bare re-throw would silently drop every outcome collected before that
|
|
33
|
+
// point -- a narrow
|
|
34
|
+
// DIAG-ADAPTER-01 regression the shell never had, since it replayed each
|
|
35
|
+
// adapter response as it went rather than batching replay to the end. This
|
|
36
|
+
// carries the outcomes collected so far alongside the original cause, so
|
|
37
|
+
// runUninstall's catch can still replay them before reporting the cause.
|
|
38
|
+
//
|
|
39
|
+
// The post-success cleanup failure no longer reaches here: onCleanupFailure
|
|
40
|
+
// below suppresses withWorkspace's throw for that case and records the
|
|
41
|
+
// warning as data, so the computed UninstallOutcome survives it. The
|
|
42
|
+
// outcome-carrying is still load-bearing for mkdtemp, and the shape stays
|
|
43
|
+
// identical to src/commands/install.ts's GatherFailure.
|
|
44
|
+
class GatherFailure extends Error {
|
|
45
|
+
inner;
|
|
46
|
+
outcomes;
|
|
47
|
+
constructor(inner, outcomes) {
|
|
48
|
+
super("uninstall gather failed");
|
|
49
|
+
this.inner = inner;
|
|
50
|
+
this.outcomes = outcomes;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Every step that can throw or fail closed, returning the outcome as data and
|
|
54
|
+
// performing NO writes. Same shape as gatherProbe
|
|
55
|
+
// (`src/commands/probe.ts:114-117::readonly facts: ProbeSnapshot<R>;`) and for the same
|
|
56
|
+
// reason: a write inside this try could raise EPIPE, be caught here, and be
|
|
57
|
+
// relabelled as a domain failure.
|
|
58
|
+
async function gatherUninstall(ctx) {
|
|
59
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:20-21::TMPDIR=` exported TMPDIR="$uninstall_workspace" so every
|
|
60
|
+
// child confined its temporary files to the tree the workspace trap
|
|
61
|
+
// removed. The AdapterContext passed to ctx.adapter below does the same.
|
|
62
|
+
const parent = ctx.env.TMPDIR ?? tmpdir();
|
|
63
|
+
// Declared OUTSIDE the withWorkspace callback (rather than inside, as an
|
|
64
|
+
// earlier draft had it) so the catch below -- which wraps the ENTIRE
|
|
65
|
+
// withWorkspace call, not just the callback -- can still see whatever was
|
|
66
|
+
// collected before a workspace throw. Both `mkdtemp` failure (nothing
|
|
67
|
+
// collected yet) and a post-success cleanup failure (everything the
|
|
68
|
+
// callback collected) reach this same array.
|
|
69
|
+
const outcomes = [];
|
|
70
|
+
let cleanupWarning = null;
|
|
71
|
+
try {
|
|
72
|
+
const outcome = await withWorkspace(parent, "superpowers-manager.uninstall.", async (workspace) => {
|
|
73
|
+
const env = { ...ctx.env, TMPDIR: workspace };
|
|
74
|
+
const failed = (message) => ({
|
|
75
|
+
status: 1,
|
|
76
|
+
outcomes,
|
|
77
|
+
message,
|
|
78
|
+
output: null,
|
|
79
|
+
});
|
|
80
|
+
const adapterContext = { root: ctx.root, env };
|
|
81
|
+
// Stage 1: inspect ownership, before removal.
|
|
82
|
+
const beforeFailure = ctx.adapter.presentation.callFailure("remove-ownership", adapterContext);
|
|
83
|
+
const before = await invoke(() => ctx.adapter.inspectOwnership(adapterContext), beforeFailure, outcomes);
|
|
84
|
+
if (!before.ok)
|
|
85
|
+
return failed(before.message);
|
|
86
|
+
// Stage 2: remove, passing the private input through untouched.
|
|
87
|
+
const removalInput = before.result.outcome.result.removalInput;
|
|
88
|
+
const removeFailure = ctx.adapter.presentation.callFailure("remove", adapterContext, removalInput);
|
|
89
|
+
const removed = await invoke(() => ctx.adapter.remove(removalInput, adapterContext), removeFailure, outcomes);
|
|
90
|
+
if (!removed.ok)
|
|
91
|
+
return failed(removed.message);
|
|
92
|
+
// Stage 3: inspect ownership AGAIN. This overwrites the first
|
|
93
|
+
// inspection (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:29-30::spw_verify_uninstalled_resources`), so everything below reads the
|
|
94
|
+
// POST-uninstall state, not the pre-uninstall one read above.
|
|
95
|
+
const afterFailure = ctx.adapter.presentation.callFailure("post-remove", adapterContext);
|
|
96
|
+
const after = await invoke(() => ctx.adapter.inspectOwnership(adapterContext), afterFailure, outcomes);
|
|
97
|
+
if (!after.ok)
|
|
98
|
+
return failed(after.message);
|
|
99
|
+
const ownership = after.result.outcome.result;
|
|
100
|
+
if (ownership.removalVerification.kind === "blocked") {
|
|
101
|
+
return {
|
|
102
|
+
status: 1,
|
|
103
|
+
outcomes,
|
|
104
|
+
message: null,
|
|
105
|
+
output: ownership.removalVerification.output,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
status: 0,
|
|
110
|
+
outcomes,
|
|
111
|
+
output: ctx.adapter.presentation.renderRemovalCompletion(ownership),
|
|
112
|
+
};
|
|
113
|
+
}, {
|
|
114
|
+
// Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
|
|
115
|
+
// so the UninstallOutcome the callback already computed still comes
|
|
116
|
+
// back as `outcome` instead of being discarded. Runs synchronously,
|
|
117
|
+
// as the option requires (src/workspace.ts).
|
|
118
|
+
onCleanupFailure: (path) => {
|
|
119
|
+
cleanupWarning = workspaceRemovalFailure(path);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return { outcome, cleanupWarning };
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
// Reachable only for mkdtemp failure, with nothing collected yet: this
|
|
126
|
+
// callback never throws -- every ctx.adapter throw is already caught
|
|
127
|
+
// inside invoke(), and presenceFlag/verifyUninstalledResources/
|
|
128
|
+
// reportLegacyState are pure (src/lifecycle.ts's header comment) -- so a
|
|
129
|
+
// post-success cleanup failure is handled by onCleanupFailure above and
|
|
130
|
+
// cannot reach here. Wrapping with `outcomes` anyway keeps the class
|
|
131
|
+
// total over its declared contract rather than assuming the callback's
|
|
132
|
+
// purity at the throw site.
|
|
133
|
+
throw new GatherFailure(cause, outcomes);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function runUninstall(argv, ctx) {
|
|
137
|
+
// scripts/uninstall never reads "$@", so extra arguments are silently
|
|
138
|
+
// ignored -- the same asymmetry runPrepare documents at its own
|
|
139
|
+
// `void argv;`.
|
|
140
|
+
void argv;
|
|
141
|
+
let run;
|
|
142
|
+
try {
|
|
143
|
+
run = await gatherUninstall(ctx);
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
// gatherUninstall throws exactly one shape: GatherFailure, wrapping
|
|
147
|
+
// withWorkspace's "cannot create workspace" SafetyError
|
|
148
|
+
// (`src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`),
|
|
149
|
+
// alongside whatever outcomes were collected before that throw -- none,
|
|
150
|
+
// for that cause. Replaying first, before
|
|
151
|
+
// reporting the cause, keeps the arm honest for any outcome-bearing
|
|
152
|
+
// throw the class is declared to carry (DIAG-ADAPTER-01). The
|
|
153
|
+
// post-success cleanup failure no longer arrives here: gatherUninstall's
|
|
154
|
+
// onCleanupFailure records it as `cleanupWarning` and the computed
|
|
155
|
+
// outcome survives, which is what `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete` did. The
|
|
156
|
+
// `instanceof` guard is defensive rather than load-bearing --
|
|
157
|
+
// gatherUninstall's own catch is the only thing that can throw here, and
|
|
158
|
+
// it always wraps -- but this catch does not assume that invariant
|
|
159
|
+
// blindly.
|
|
160
|
+
//
|
|
161
|
+
// A cause outside ctx.adapter's AdapterFailure guard
|
|
162
|
+
// (`src/adapter.ts:973-999::async function runCodexOperation(`) does NOT
|
|
163
|
+
// reach here: invoke() catches it inside gatherUninstall and converts it
|
|
164
|
+
// to a hand-written message carried as UninstallOutcome data, exactly as
|
|
165
|
+
// src/commands/probe.ts's inspect() does for the same cause.
|
|
166
|
+
//
|
|
167
|
+
// gatherUninstall performs no writes of its own, so this catch cannot
|
|
168
|
+
// also be reached by an EPIPE from uninstall's own output -- every write
|
|
169
|
+
// below runs only after this try/catch has resolved.
|
|
170
|
+
const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
|
|
171
|
+
for (const outcome of outcomes)
|
|
172
|
+
replayOutcome(outcome, ctx);
|
|
173
|
+
const inner = cause instanceof GatherFailure ? cause.inner : cause;
|
|
174
|
+
ctx.stderr.write(`error: ${oneLine(inner)}\n`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
const { outcome, cleanupWarning } = run;
|
|
178
|
+
// Replay first, on both paths: the shell validator replayed every
|
|
179
|
+
// response's messages whether or not that response was a failure
|
|
180
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`).
|
|
181
|
+
for (const each of outcome.outcomes)
|
|
182
|
+
replayOutcome(each, ctx);
|
|
183
|
+
let status;
|
|
184
|
+
if (outcome.status === 1) {
|
|
185
|
+
// null means replayOutcome already emitted the adapter's own error:
|
|
186
|
+
// and hint: lines for the failing outcome.
|
|
187
|
+
if (outcome.message !== null) {
|
|
188
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
189
|
+
}
|
|
190
|
+
if (outcome.output !== null) {
|
|
191
|
+
for (const line of outcome.output.stdout)
|
|
192
|
+
ctx.stdout.write(`${line}\n`);
|
|
193
|
+
for (const line of outcome.output.stderr)
|
|
194
|
+
ctx.stderr.write(`${line}\n`);
|
|
195
|
+
}
|
|
196
|
+
status = 1;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
for (const line of outcome.output.stdout)
|
|
200
|
+
ctx.stdout.write(`${line}\n`);
|
|
201
|
+
for (const line of outcome.output.stderr)
|
|
202
|
+
ctx.stderr.write(`${line}\n`);
|
|
203
|
+
status = 0;
|
|
204
|
+
}
|
|
205
|
+
if (cleanupWarning !== null) {
|
|
206
|
+
// Mirrors src/commands/install.ts's closing arm. A leaked workspace is
|
|
207
|
+
// reported even when the domain outcome above was a success: the
|
|
208
|
+
// uninstall and its verification already completed against the adapter
|
|
209
|
+
// before cleanup ran, so it is not being reported as unverified -- but
|
|
210
|
+
// something did still go wrong, and AGENTS.md's fail-closed rule extends
|
|
211
|
+
// to it. The operator keeps "uninstall complete", which is the one line
|
|
212
|
+
// telling them whether the removal they asked for happened.
|
|
213
|
+
ctx.stderr.write(`error: ${cleanupWarning}\n`);
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
return status;
|
|
217
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { lstat, unlink } from "node:fs/promises";
|
|
2
|
+
import { oneLine } from "../cli-arguments.js";
|
|
3
|
+
import { selectionStatePath } from "../effective-selection.js";
|
|
4
|
+
import { selectionError } from "../selection.js";
|
|
5
|
+
import { readConfigRef } from "../upstream.js";
|
|
6
|
+
const NOT_REGULAR = "selection state path is not a regular file; remove it manually after inspecting";
|
|
7
|
+
// Runs every step that can throw or fail closed, returning the outcome as
|
|
8
|
+
// data instead of writing it — see UnpinOutcome above for why.
|
|
9
|
+
async function attemptUnpin(ctx) {
|
|
10
|
+
const statePath = selectionStatePath(ctx.env);
|
|
11
|
+
// The packaged fallback ignores an active SUPERPOWERS_REF: `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/unpin:12::spw_config_ref`
|
|
12
|
+
// cleared it deliberately so the reported value is the packaged one.
|
|
13
|
+
const fallback = await readConfigRef(ctx.root, {
|
|
14
|
+
...ctx.env,
|
|
15
|
+
SUPERPOWERS_REF: "",
|
|
16
|
+
});
|
|
17
|
+
// Only a validated ENOENT means "absent". Treating every errno as absence
|
|
18
|
+
// turns EACCES or ENOTDIR into `no saved upstream selection` and exit 0 —
|
|
19
|
+
// reporting unverifiable state as success, which the fail-closed rule forbids.
|
|
20
|
+
// The shell could not draw this distinction ([ -e ] cannot), so this is a
|
|
21
|
+
// deliberate narrowing beyond parity, recorded as a port-only inventory entry.
|
|
22
|
+
const inspect = async (path) => {
|
|
23
|
+
try {
|
|
24
|
+
return await lstat(path);
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
if (cause.code === "ENOENT")
|
|
28
|
+
return null;
|
|
29
|
+
throw selectionError(`cannot inspect selection state: ${path}`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
let removed = false;
|
|
33
|
+
const info = await inspect(statePath);
|
|
34
|
+
if (info !== null) {
|
|
35
|
+
if (!info.isFile()) {
|
|
36
|
+
return { status: 1, message: `${NOT_REGULAR}: ${statePath}` };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await unlink(statePath);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {
|
|
43
|
+
status: 1,
|
|
44
|
+
message: `cannot remove selection state: ${statePath}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
removed = true;
|
|
48
|
+
}
|
|
49
|
+
// Same classification on the verification read: an EACCES here must not be
|
|
50
|
+
// read as "successfully absent".
|
|
51
|
+
if ((await inspect(statePath)) !== null) {
|
|
52
|
+
return {
|
|
53
|
+
status: 1,
|
|
54
|
+
message: `selection state remains after removal attempt: ${statePath}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { status: 0, removed, fallback };
|
|
58
|
+
}
|
|
59
|
+
export async function runUnpin(argv, ctx) {
|
|
60
|
+
if (argv.length !== 0) {
|
|
61
|
+
ctx.stderr.write("error: usage: superpowers-manager unpin\n");
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
let outcome;
|
|
65
|
+
try {
|
|
66
|
+
outcome = await attemptUnpin(ctx);
|
|
67
|
+
}
|
|
68
|
+
catch (cause) {
|
|
69
|
+
// Every throw reachable here is a hand-written SafetyError from
|
|
70
|
+
// selectionStatePath, readConfigRef, or inspect() inside attemptUnpin —
|
|
71
|
+
// re-emitting a subordinate module's own diagnostic is the sanctioned
|
|
72
|
+
// form of interpolation (see AGENTS.md's diagnostics convention).
|
|
73
|
+
// attemptUnpin performs no writes of its own (see UnpinOutcome above),
|
|
74
|
+
// so this catch cannot also be reached by an EPIPE from a write this
|
|
75
|
+
// function made itself — the write below runs only after this try/catch
|
|
76
|
+
// has already resolved.
|
|
77
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
if (outcome.status === 1) {
|
|
81
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const { removed, fallback } = outcome;
|
|
85
|
+
ctx.stdout.write(removed
|
|
86
|
+
? `removed saved upstream selection; packaged fallback is ${fallback}\n`
|
|
87
|
+
: `no saved upstream selection; packaged fallback is ${fallback}\n`);
|
|
88
|
+
if (ctx.env.SUPERPOWERS_REF) {
|
|
89
|
+
ctx.stdout.write("note: active SUPERPOWERS_REF override remains effective\n");
|
|
90
|
+
}
|
|
91
|
+
if (ctx.env.SUPERPOWERS_UPSTREAM_URL) {
|
|
92
|
+
ctx.stdout.write("note: active SUPERPOWERS_UPSTREAM_URL override remains effective\n");
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|