superpowers-manager 0.2.0 → 0.3.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 +112 -47
- package/bin/superpowers-manager.js +9 -283
- package/dist/adapter-result.js +204 -0
- package/dist/adapter.js +649 -0
- package/dist/atomic.js +142 -0
- package/dist/cli-arguments.js +32 -0
- package/dist/cli.js +293 -0
- package/dist/codex-json.js +89 -0
- package/dist/codex-state.js +78 -0
- package/dist/commands/context.js +1 -0
- package/dist/commands/install.js +376 -0
- package/dist/commands/pin.js +79 -0
- package/dist/commands/prepare.js +527 -0
- package/dist/commands/probe.js +392 -0
- package/dist/commands/track-latest.js +44 -0
- package/dist/commands/uninstall.js +308 -0
- package/dist/commands/unpin.js +95 -0
- package/dist/commands/update.js +138 -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/hooks.js +290 -0
- package/dist/lifecycle.js +220 -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 -2
- 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,308 @@
|
|
|
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 { reportLegacyState, verifyUninstalledResources } from "../lifecycle.js";
|
|
7
|
+
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
8
|
+
import { replayOutcome } from "./probe.js";
|
|
9
|
+
function presenceFlag(result, key) {
|
|
10
|
+
const outcome = result.outcome;
|
|
11
|
+
if (result.status !== 0 || !outcome.ok) {
|
|
12
|
+
return { kind: "call-failed" };
|
|
13
|
+
}
|
|
14
|
+
const value = outcome.result;
|
|
15
|
+
// Mirrors src/lifecycle.ts's verifyUninstalledResources: a missing or
|
|
16
|
+
// non-object `resources` falls THROUGH to the Boolean check rather than
|
|
17
|
+
// getting its own message, for parity with
|
|
18
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:70::expected`'s
|
|
19
|
+
// single "expected Boolean..." text on {}.
|
|
20
|
+
const resources = typeof value === "object" && value !== null && !Array.isArray(value)
|
|
21
|
+
? value.resources
|
|
22
|
+
: undefined;
|
|
23
|
+
const bag = typeof resources === "object" &&
|
|
24
|
+
resources !== null &&
|
|
25
|
+
!Array.isArray(resources)
|
|
26
|
+
? resources
|
|
27
|
+
: {};
|
|
28
|
+
const flag = bag[key];
|
|
29
|
+
if (typeof flag !== "boolean") {
|
|
30
|
+
return { kind: "malformed", key };
|
|
31
|
+
}
|
|
32
|
+
return { kind: "ok", value: flag };
|
|
33
|
+
}
|
|
34
|
+
async function invoke(ctx, env, argv, outcomes) {
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await ctx.adapter(argv, { root: ctx.root, env });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
message: `cannot invoke Codex adapter for ${argv.join(" ")}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Clause 1: replay every outcome, on both the success and the failure
|
|
46
|
+
// path, before any decision -- collected here and replayed by the caller
|
|
47
|
+
// once gatherUninstall's try/catch has resolved, for the same EPIPE reason
|
|
48
|
+
// gatherProbe carries its outcomes out rather than writing in place
|
|
49
|
+
// (`src/commands/probe.ts:289::The outcomes are CARRIED OUT rather than replayed in place`).
|
|
50
|
+
outcomes.push(result.outcome);
|
|
51
|
+
const outcome = result.outcome;
|
|
52
|
+
if (result.status !== 0 || !outcome.ok) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
message: outcome.ok
|
|
56
|
+
? `adapter reported a failure status for ${argv.join(" ")}`
|
|
57
|
+
: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, result };
|
|
61
|
+
}
|
|
62
|
+
// withWorkspace throws for mkdtemp failure before the callback ever runs
|
|
63
|
+
// ("cannot create workspace",
|
|
64
|
+
// `src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`).
|
|
65
|
+
// A bare re-throw would silently drop every outcome collected before that
|
|
66
|
+
// point -- a narrow
|
|
67
|
+
// DIAG-ADAPTER-01 regression the shell never had, since it replayed each
|
|
68
|
+
// adapter response as it went rather than batching replay to the end. This
|
|
69
|
+
// carries the outcomes collected so far alongside the original cause, so
|
|
70
|
+
// runUninstall's catch can still replay them before reporting the cause.
|
|
71
|
+
//
|
|
72
|
+
// The post-success cleanup failure no longer reaches here: onCleanupFailure
|
|
73
|
+
// below suppresses withWorkspace's throw for that case and records the
|
|
74
|
+
// warning as data, so the computed UninstallOutcome survives it. The
|
|
75
|
+
// outcome-carrying is still load-bearing for mkdtemp, and the shape stays
|
|
76
|
+
// identical to src/commands/install.ts's GatherFailure.
|
|
77
|
+
class GatherFailure extends Error {
|
|
78
|
+
inner;
|
|
79
|
+
outcomes;
|
|
80
|
+
constructor(inner, outcomes) {
|
|
81
|
+
super("uninstall gather failed");
|
|
82
|
+
this.inner = inner;
|
|
83
|
+
this.outcomes = outcomes;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Every step that can throw or fail closed, returning the outcome as data and
|
|
87
|
+
// performing NO writes. Same shape as gatherProbe
|
|
88
|
+
// (`src/commands/probe.ts:280-281::readonly status: 0;`) and for the same
|
|
89
|
+
// reason: a write inside this try could raise EPIPE, be caught here, and be
|
|
90
|
+
// relabelled as a domain failure.
|
|
91
|
+
async function gatherUninstall(ctx) {
|
|
92
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:20-21::TMPDIR=` exported TMPDIR="$uninstall_workspace" so every
|
|
93
|
+
// child confined its temporary files to the tree the workspace trap
|
|
94
|
+
// removed. The AdapterContext passed to ctx.adapter below does the same.
|
|
95
|
+
const parent = ctx.env.TMPDIR ?? tmpdir();
|
|
96
|
+
// Declared OUTSIDE the withWorkspace callback (rather than inside, as an
|
|
97
|
+
// earlier draft had it) so the catch below -- which wraps the ENTIRE
|
|
98
|
+
// withWorkspace call, not just the callback -- can still see whatever was
|
|
99
|
+
// collected before a workspace throw. Both `mkdtemp` failure (nothing
|
|
100
|
+
// collected yet) and a post-success cleanup failure (everything the
|
|
101
|
+
// callback collected) reach this same array.
|
|
102
|
+
const outcomes = [];
|
|
103
|
+
let cleanupWarning = null;
|
|
104
|
+
try {
|
|
105
|
+
const outcome = await withWorkspace(parent, "superpowers-manager.uninstall.", async (workspace) => {
|
|
106
|
+
const env = { ...ctx.env, TMPDIR: workspace };
|
|
107
|
+
const failed = (message) => ({
|
|
108
|
+
status: 1,
|
|
109
|
+
outcomes,
|
|
110
|
+
message,
|
|
111
|
+
});
|
|
112
|
+
// Stage 1: inspect ownership, before removal.
|
|
113
|
+
const first = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
|
|
114
|
+
if (!first.ok)
|
|
115
|
+
return failed(first.message);
|
|
116
|
+
const pluginFlag = presenceFlag(first.result, "plugin");
|
|
117
|
+
if (pluginFlag.kind === "malformed") {
|
|
118
|
+
return failed(`expected a Boolean adapter result at resources.${pluginFlag.key}`);
|
|
119
|
+
}
|
|
120
|
+
if (pluginFlag.kind === "call-failed") {
|
|
121
|
+
// Unreachable via this call path: `first.ok` above already proved
|
|
122
|
+
// status === 0 && outcome.ok, the only inputs presenceFlag reads
|
|
123
|
+
// to decide this arm. Handled anyway so presenceFlag stays total
|
|
124
|
+
// and this switch stays exhaustive rather than assuming its caller
|
|
125
|
+
// always gates first.
|
|
126
|
+
return failed(null);
|
|
127
|
+
}
|
|
128
|
+
const marketplaceFlag = presenceFlag(first.result, "marketplace");
|
|
129
|
+
if (marketplaceFlag.kind === "malformed") {
|
|
130
|
+
return failed(`expected a Boolean adapter result at resources.${marketplaceFlag.key}`);
|
|
131
|
+
}
|
|
132
|
+
if (marketplaceFlag.kind === "call-failed") {
|
|
133
|
+
return failed(null);
|
|
134
|
+
}
|
|
135
|
+
// Stage 2: uninstall, with the two presence Booleans read above.
|
|
136
|
+
const uninstallStage = await invoke(ctx, env, [
|
|
137
|
+
"uninstall",
|
|
138
|
+
"--plugin-present",
|
|
139
|
+
String(pluginFlag.value),
|
|
140
|
+
"--marketplace-present",
|
|
141
|
+
String(marketplaceFlag.value),
|
|
142
|
+
], outcomes);
|
|
143
|
+
if (!uninstallStage.ok)
|
|
144
|
+
return failed(uninstallStage.message);
|
|
145
|
+
// Stage 3: inspect ownership AGAIN. This overwrites the first
|
|
146
|
+
// inspection (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:29-30::spw_verify_uninstalled_resources`), so everything below reads the
|
|
147
|
+
// POST-uninstall state, not the pre-uninstall one read above.
|
|
148
|
+
const second = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
|
|
149
|
+
if (!second.ok)
|
|
150
|
+
return failed(second.message);
|
|
151
|
+
const verify = verifyUninstalledResources(second.result);
|
|
152
|
+
if (!verify.ok)
|
|
153
|
+
return failed(verify.message);
|
|
154
|
+
// identity_state comes from this SAME second inspection, matching
|
|
155
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:31::spw_adapter_result_get`'s spw_adapter_result_get read of the
|
|
156
|
+
// overwritten inspect_result. `second.ok` already proved this call
|
|
157
|
+
// succeeded, so outcome.ok is true here; the explicit check below is
|
|
158
|
+
// for TypeScript's narrowing (a local variable, not a re-derivation
|
|
159
|
+
// of that fact) rather than a live branch.
|
|
160
|
+
const secondOutcome = second.result.outcome;
|
|
161
|
+
if (!secondOutcome.ok)
|
|
162
|
+
return failed(null);
|
|
163
|
+
// Mirrors the inspect null/non-string branches
|
|
164
|
+
// (`src/commands/probe.ts:256-263::if (value === null || value === undefined) {`)
|
|
165
|
+
// and the fingerprint read
|
|
166
|
+
// (`src/lifecycle.ts:181-194::const raw = inspected.value.fingerprint;`):
|
|
167
|
+
// a JSON null or a missing key defaults to "" (the Python reader's own
|
|
168
|
+
// convention for a JSON null, scripts/core/provenance.sh's
|
|
169
|
+
// spw_json_get), but a present, non-null, NON-STRING value is a
|
|
170
|
+
// distinct, fail-closed "malformed" case with its own text -- never
|
|
171
|
+
// silently stringified.
|
|
172
|
+
// (A previous draft of this comment claimed parity with the shell's
|
|
173
|
+
// stringify-and-compare behaviour at
|
|
174
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`;
|
|
175
|
+
// that was wrong on inspection, and it cited these same two call sites
|
|
176
|
+
// as support even though both of them fail closed on a non-string value
|
|
177
|
+
// rather than stringifying it. AGENTS.md's
|
|
178
|
+
// fail-closed rule wins over shell parity here.)
|
|
179
|
+
const parsed = secondOutcome.result;
|
|
180
|
+
const identityRaw = parsed?.identity_state;
|
|
181
|
+
let identityState;
|
|
182
|
+
if (identityRaw === null || identityRaw === undefined) {
|
|
183
|
+
identityState = "";
|
|
184
|
+
}
|
|
185
|
+
else if (typeof identityRaw === "string") {
|
|
186
|
+
identityState = identityRaw;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
return failed("adapter returned a non-string identity_state for inspect --view ownership");
|
|
190
|
+
}
|
|
191
|
+
const verdict = reportLegacyState(identityState);
|
|
192
|
+
if (verdict.kind === "unknown")
|
|
193
|
+
return failed(verdict.message);
|
|
194
|
+
const lines = [];
|
|
195
|
+
// verdict.kind is "ok" or "report" here (reportLegacyState never
|
|
196
|
+
// returns "blocked" -- that arm belongs to requireNoLegacyState --
|
|
197
|
+
// but LegacyVerdict is one shared union, so this narrows rather than
|
|
198
|
+
// assumes).
|
|
199
|
+
if (verdict.kind !== "ok") {
|
|
200
|
+
lines.push(...verdict.lines);
|
|
201
|
+
}
|
|
202
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete`. The first line ports verbatim; the second
|
|
203
|
+
// changes scripts/prepare to npx superpowers-manager prepare (spec
|
|
204
|
+
// §3.6) -- a deliberate, observable divergence, recorded as a
|
|
205
|
+
// port-only entry in tests/migration-inventory/uninstall-commands.md.
|
|
206
|
+
lines.push("uninstall complete");
|
|
207
|
+
lines.push("note: local generated artifacts under plugins/superpowers/ and " +
|
|
208
|
+
".cache/upstream/ were left in place; remove them manually or " +
|
|
209
|
+
"regenerate with npx superpowers-manager prepare.");
|
|
210
|
+
return { status: 0, outcomes, lines };
|
|
211
|
+
}, {
|
|
212
|
+
// Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
|
|
213
|
+
// so the UninstallOutcome the callback already computed still comes
|
|
214
|
+
// back as `outcome` instead of being discarded. Runs synchronously,
|
|
215
|
+
// as the option requires (src/workspace.ts).
|
|
216
|
+
onCleanupFailure: (path) => {
|
|
217
|
+
cleanupWarning = workspaceRemovalFailure(path);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return { outcome, cleanupWarning };
|
|
221
|
+
}
|
|
222
|
+
catch (cause) {
|
|
223
|
+
// Reachable only for mkdtemp failure, with nothing collected yet: this
|
|
224
|
+
// callback never throws -- every ctx.adapter throw is already caught
|
|
225
|
+
// inside invoke(), and presenceFlag/verifyUninstalledResources/
|
|
226
|
+
// reportLegacyState are pure (src/lifecycle.ts's header comment) -- so a
|
|
227
|
+
// post-success cleanup failure is handled by onCleanupFailure above and
|
|
228
|
+
// cannot reach here. Wrapping with `outcomes` anyway keeps the class
|
|
229
|
+
// total over its declared contract rather than assuming the callback's
|
|
230
|
+
// purity at the throw site.
|
|
231
|
+
throw new GatherFailure(cause, outcomes);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export async function runUninstall(argv, ctx) {
|
|
235
|
+
// scripts/uninstall never reads "$@", so extra arguments are silently
|
|
236
|
+
// ignored -- the same asymmetry runPrepare documents at its own
|
|
237
|
+
// `void argv;`.
|
|
238
|
+
void argv;
|
|
239
|
+
let run;
|
|
240
|
+
try {
|
|
241
|
+
run = await gatherUninstall(ctx);
|
|
242
|
+
}
|
|
243
|
+
catch (cause) {
|
|
244
|
+
// gatherUninstall throws exactly one shape: GatherFailure, wrapping
|
|
245
|
+
// withWorkspace's "cannot create workspace" SafetyError
|
|
246
|
+
// (`src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`),
|
|
247
|
+
// alongside whatever outcomes were collected before that throw -- none,
|
|
248
|
+
// for that cause. Replaying first, before
|
|
249
|
+
// reporting the cause, keeps the arm honest for any outcome-bearing
|
|
250
|
+
// throw the class is declared to carry (DIAG-ADAPTER-01). The
|
|
251
|
+
// post-success cleanup failure no longer arrives here: gatherUninstall's
|
|
252
|
+
// onCleanupFailure records it as `cleanupWarning` and the computed
|
|
253
|
+
// outcome survives, which is what `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete` did. The
|
|
254
|
+
// `instanceof` guard is defensive rather than load-bearing --
|
|
255
|
+
// gatherUninstall's own catch is the only thing that can throw here, and
|
|
256
|
+
// it always wraps -- but this catch does not assume that invariant
|
|
257
|
+
// blindly.
|
|
258
|
+
//
|
|
259
|
+
// A cause outside ctx.adapter's AdapterFailure guard
|
|
260
|
+
// (`src/adapter.ts:1001::if (cause instanceof AdapterFailure) {`) does NOT
|
|
261
|
+
// reach here: invoke() catches it inside gatherUninstall and converts it
|
|
262
|
+
// to a hand-written message carried as UninstallOutcome data, exactly as
|
|
263
|
+
// src/commands/probe.ts's inspect() does for the same cause.
|
|
264
|
+
//
|
|
265
|
+
// gatherUninstall performs no writes of its own, so this catch cannot
|
|
266
|
+
// also be reached by an EPIPE from uninstall's own output -- every write
|
|
267
|
+
// below runs only after this try/catch has resolved.
|
|
268
|
+
const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
|
|
269
|
+
for (const outcome of outcomes)
|
|
270
|
+
replayOutcome(outcome, ctx);
|
|
271
|
+
const inner = cause instanceof GatherFailure ? cause.inner : cause;
|
|
272
|
+
ctx.stderr.write(`error: ${oneLine(inner)}\n`);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const { outcome, cleanupWarning } = run;
|
|
276
|
+
// Replay first, on both paths: the shell validator replayed every
|
|
277
|
+
// response's messages whether or not that response was a failure
|
|
278
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`).
|
|
279
|
+
for (const each of outcome.outcomes)
|
|
280
|
+
replayOutcome(each, ctx);
|
|
281
|
+
let status;
|
|
282
|
+
if (outcome.status === 1) {
|
|
283
|
+
// null means replayOutcome already emitted the adapter's own error:
|
|
284
|
+
// and hint: lines for the failing outcome.
|
|
285
|
+
if (outcome.message !== null) {
|
|
286
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
287
|
+
}
|
|
288
|
+
status = 1;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
for (const line of outcome.lines) {
|
|
292
|
+
ctx.stdout.write(`${line}\n`);
|
|
293
|
+
}
|
|
294
|
+
status = 0;
|
|
295
|
+
}
|
|
296
|
+
if (cleanupWarning !== null) {
|
|
297
|
+
// Mirrors src/commands/install.ts's closing arm. A leaked workspace is
|
|
298
|
+
// reported even when the domain outcome above was a success: the
|
|
299
|
+
// uninstall and its verification already completed against the adapter
|
|
300
|
+
// before cleanup ran, so it is not being reported as unverified -- but
|
|
301
|
+
// something did still go wrong, and AGENTS.md's fail-closed rule extends
|
|
302
|
+
// to it. The operator keeps "uninstall complete", which is the one line
|
|
303
|
+
// telling them whether the removal they asked for happened.
|
|
304
|
+
ctx.stderr.write(`error: ${cleanupWarning}\n`);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
return status;
|
|
308
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Ports scripts/update. The shell sourced common.sh and lifecycle.sh; the
|
|
2
|
+
// predicates now live in src/lifecycle.ts. Unlike install and uninstall,
|
|
3
|
+
// update issues no ctx.adapter call of its own: every adapter interaction
|
|
4
|
+
// this module needs already happens inside gatherProbe, runPrepare or
|
|
5
|
+
// runInstall, each of which owns its own §4.2a-conformant invoke() gate. That
|
|
6
|
+
// is also why update opens no temporary workspace of its own -- there is no
|
|
7
|
+
// mutation here for one to protect.
|
|
8
|
+
import { oneLine } from "../cli-arguments.js";
|
|
9
|
+
import { requireManagedUpdateControl, requireNoLegacyState, } from "../lifecycle.js";
|
|
10
|
+
import { formatPorcelain, gatherProbe, replayOutcome, } from "./probe.js";
|
|
11
|
+
import { runInstall } from "./install.js";
|
|
12
|
+
import { runPrepare } from "./prepare.js";
|
|
13
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:29-32::echo "$probe_output`'s `case ... *)` wildcard. Same unreachability shape as
|
|
14
|
+
// src/commands/install.ts's renderUnknownProbeStatus: statusForCommits
|
|
15
|
+
// (src/status.ts) can only ever return "needs prepare", "needs
|
|
16
|
+
// install" or "current", so this branch is NOT reachable through runUpdate's
|
|
17
|
+
// own call to gatherProbe today. ProbeFacts.status is typed `string`, not
|
|
18
|
+
// that three-literal union, so a future caller that builds facts by hand (as
|
|
19
|
+
// this module's own unit test does, directly) must still see it fail closed
|
|
20
|
+
// rather than silently proceed.
|
|
21
|
+
//
|
|
22
|
+
// Exported specifically so a direct test can reach it -- not part of the
|
|
23
|
+
// interface Task 5 or Task 8 consume. A test against this export proves the
|
|
24
|
+
// two writes below are correct for a given `facts`; it does NOT prove that
|
|
25
|
+
// runUpdate's own final `else` branch actually reaches and calls this
|
|
26
|
+
// function, or that runUpdate returns 1 afterward -- those are properties of
|
|
27
|
+
// the call site, not of this function, and need their own coverage there.
|
|
28
|
+
export function renderUnknownProbeStatus(facts, ctx) {
|
|
29
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:30-31::echo "$probe_output` -- `echo "$probe_output"` (stdout), then the error
|
|
30
|
+
// to stderr via an explicit `>&2`. This is a DIFFERENT mechanism from
|
|
31
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:30-31::printf`'s `spw_die`, even though both end up writing the
|
|
32
|
+
// porcelain to stdout and an `error: `-prefixed line to stderr: install's
|
|
33
|
+
// error text flows through spw_die's own `error: $*` formatting, while
|
|
34
|
+
// update's is spelled out inline in the shell literal already. The two
|
|
35
|
+
// command modules therefore each need their own case pinning this arm --
|
|
36
|
+
// one must not be inferred from the other.
|
|
37
|
+
ctx.stdout.write(formatPorcelain(facts));
|
|
38
|
+
ctx.stderr.write(`error: unknown probe status: ${facts.status}\n`);
|
|
39
|
+
}
|
|
40
|
+
export async function runUpdate(argv, ctx) {
|
|
41
|
+
// scripts/update never reads "$@", so extra arguments are silently
|
|
42
|
+
// ignored -- the same asymmetry runPrepare and runInstall document.
|
|
43
|
+
void argv;
|
|
44
|
+
let probe;
|
|
45
|
+
try {
|
|
46
|
+
probe = await gatherProbe(ctx);
|
|
47
|
+
}
|
|
48
|
+
catch (cause) {
|
|
49
|
+
// gatherProbe performs no writes of its own (src/commands/probe.ts), so
|
|
50
|
+
// this catch cannot also be reached by an EPIPE from update's own
|
|
51
|
+
// output -- update writes nothing before this call resolves, unlike
|
|
52
|
+
// install's NOTE line.
|
|
53
|
+
//
|
|
54
|
+
// This is a THIRD consumer of gatherProbe's throw channel --
|
|
55
|
+
// `src/commands/probe.ts:381-433::THREE exceptions, all inherited and none a regression:`'s
|
|
56
|
+
// runProbe catch is the first and src/commands/install.ts's runInstall catch
|
|
57
|
+
// is the second. Because all three wrap the identical function, runProbe's
|
|
58
|
+
// long comment there enumerates exactly what can reach this stream too; not
|
|
59
|
+
// repeated here.
|
|
60
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
// Replay first, on both paths, before any decision -- clause 1.
|
|
64
|
+
for (const outcome of probe.outcomes)
|
|
65
|
+
replayOutcome(outcome, ctx);
|
|
66
|
+
if (probe.status === 1) {
|
|
67
|
+
if (probe.message !== null) {
|
|
68
|
+
ctx.stderr.write(`error: ${probe.message}\n`);
|
|
69
|
+
}
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
const facts = probe.facts;
|
|
73
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:9-10::identity_state=`. Guards the PROBE-derived value only, matching
|
|
74
|
+
// src/commands/install.ts's identical guard at `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:19-20::identity_state=$(spw_probe_field`: a
|
|
75
|
+
// probe that reports no identity state at all is a different failure from
|
|
76
|
+
// one that reports an unrecognised one, and requireNoLegacyState("") would
|
|
77
|
+
// otherwise reach its "unknown" arm and print a symptom rather than the
|
|
78
|
+
// actual cause.
|
|
79
|
+
if (facts.identityState.length === 0) {
|
|
80
|
+
ctx.stderr.write("error: probe did not report adapter identity state\n");
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:11::spw_require_no_legacy_state`.
|
|
84
|
+
const legacy = requireNoLegacyState(facts.identityState);
|
|
85
|
+
if (legacy.kind === "blocked") {
|
|
86
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:50-53::'Legacy superpowers-wrapper Codex state is`
|
|
87
|
+
// is a single printf writing three bare lines to stderr, no `error: `
|
|
88
|
+
// prefix; :54 is the `return 1` that follows it, reached without spw_die.
|
|
89
|
+
for (const line of legacy.lines)
|
|
90
|
+
ctx.stderr.write(`${line}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
if (legacy.kind === "unknown") {
|
|
94
|
+
ctx.stderr.write(`error: ${legacy.message}\n`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:13-14::update_control=`. install checks only identity_state
|
|
98
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:20::report`); update checks update_control too -- the first of
|
|
99
|
+
// §4.4's two corrections. Both emptiness checks run before the switch, not
|
|
100
|
+
// just one.
|
|
101
|
+
if (facts.updateControl.length === 0) {
|
|
102
|
+
ctx.stderr.write("error: probe did not report adapter update-control capability\n");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:16-34::case`, the four-way switch.
|
|
106
|
+
if (facts.status === "current") {
|
|
107
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:18::spw_require_managed_update_control` -- gated BEFORE printing anything. §4.4's second
|
|
108
|
+
// correction: an update that printed "manager is current" under an
|
|
109
|
+
// unsupported adapter would be asserting managed control it had not
|
|
110
|
+
// verified.
|
|
111
|
+
const managed = requireManagedUpdateControl(facts.updateControl);
|
|
112
|
+
if (!managed.ok) {
|
|
113
|
+
ctx.stderr.write(`error: ${managed.message}\n`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:19-20::printf`. Unlike install, which never lets the porcelain
|
|
117
|
+
// reach the terminal on a successful run (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:18::porcelain`'s
|
|
118
|
+
// probe_output=$(...) capture), update prints it here.
|
|
119
|
+
ctx.stdout.write(formatPorcelain(facts));
|
|
120
|
+
ctx.stdout.write("manager is current\n");
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (facts.status === "needs prepare") {
|
|
124
|
+
// Called as a FUNCTION: a failure propagates as a status, never through
|
|
125
|
+
// `set -eu`. runPrepare has already replayed its own outcomes and
|
|
126
|
+
// written its own diagnostics by the time it returns, so nothing further
|
|
127
|
+
// is written here on that path.
|
|
128
|
+
const prepareStatus = await runPrepare([], ctx);
|
|
129
|
+
if (prepareStatus !== 0)
|
|
130
|
+
return prepareStatus;
|
|
131
|
+
return await runInstall([], ctx);
|
|
132
|
+
}
|
|
133
|
+
if (facts.status === "needs install") {
|
|
134
|
+
return await runInstall([], ctx);
|
|
135
|
+
}
|
|
136
|
+
renderUnknownProbeStatus(facts, ctx);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SEMVER_BASE_SOURCE = "(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)" +
|
|
2
|
+
"(?:-(?:(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +
|
|
3
|
+
"(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?";
|
|
4
|
+
export const SEMVER_BASE_RE = new RegExp(`^${SEMVER_BASE_SOURCE}$`);
|
|
5
|
+
export const TAG_RE = new RegExp(`^v${SEMVER_BASE_SOURCE}$`);
|
|
6
|
+
export const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
7
|
+
export const COMMIT_INPUT_RE = /^[0-9A-Fa-f]{40}$/;
|
|
8
|
+
export function isTagRef(value) {
|
|
9
|
+
return TAG_RE.test(value);
|
|
10
|
+
}
|
|
11
|
+
export function isCommit(value) {
|
|
12
|
+
return COMMIT_RE.test(value);
|
|
13
|
+
}
|
|
14
|
+
export function normalizeCommitInput(value) {
|
|
15
|
+
return COMMIT_INPUT_RE.test(value) ? value.toLowerCase() : null;
|
|
16
|
+
}
|
|
17
|
+
export function parseStableTag(tag) {
|
|
18
|
+
if (!TAG_RE.test(tag))
|
|
19
|
+
return null;
|
|
20
|
+
const core = tag.slice(1);
|
|
21
|
+
// Prerelease filter. Sound only because TAG_RE already matched: the numeric
|
|
22
|
+
// components cannot contain "-", so the sole way one reaches `core` is a
|
|
23
|
+
// prerelease suffix. Stable-tag selection must exclude those.
|
|
24
|
+
if (core.includes("-"))
|
|
25
|
+
return null;
|
|
26
|
+
const [major, minor, patch] = core.split(".");
|
|
27
|
+
return { major: BigInt(major), minor: BigInt(minor), patch: BigInt(patch) };
|
|
28
|
+
}
|
|
29
|
+
export function compareStable(a, b) {
|
|
30
|
+
if (a.major !== b.major)
|
|
31
|
+
return a.major < b.major ? -1 : 1;
|
|
32
|
+
if (a.minor !== b.minor)
|
|
33
|
+
return a.minor < b.minor ? -1 : 1;
|
|
34
|
+
if (a.patch !== b.patch)
|
|
35
|
+
return a.patch < b.patch ? -1 : 1;
|
|
36
|
+
return 0;
|
|
37
|
+
}
|