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,376 @@
|
|
|
1
|
+
// Ports scripts/install. The shell sourced common.sh, provenance.sh,
|
|
2
|
+
// status.sh, lifecycle.sh and adapter.sh; the predicates now live in
|
|
3
|
+
// src/lifecycle.ts, the generated-metadata read lives in src/provenance.ts,
|
|
4
|
+
// and the adapter arrives through ctx.adapter.
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { oneLine } from "../cli-arguments.js";
|
|
7
|
+
import { requireManagedUpdateControl, requireNoLegacyState, verifyInstalledFingerprint, } from "../lifecycle.js";
|
|
8
|
+
import { generatedMetadataPath, readStrictProvenanceField, } from "../provenance.js";
|
|
9
|
+
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
10
|
+
import { formatPorcelain, gatherProbe, replayOutcome, } from "./probe.js";
|
|
11
|
+
import { runPrepare } from "./prepare.js";
|
|
12
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:13::conflicting`, verbatim, and always first: the shell echoed it before
|
|
13
|
+
// even invoking probe.
|
|
14
|
+
const NOTE = "Note: remove or disable conflicting Superpowers providers yourself before" +
|
|
15
|
+
" relying on manager skills.\n";
|
|
16
|
+
function readStringField(result, key, label) {
|
|
17
|
+
// Callers only reach this after invoke() has already proven
|
|
18
|
+
// `result.status === 0 && result.outcome.ok`; the guard below is for
|
|
19
|
+
// TypeScript's narrowing of `outcome.result`, not a live branch.
|
|
20
|
+
const outcome = result.outcome;
|
|
21
|
+
const parsed = outcome.ok
|
|
22
|
+
? outcome.result
|
|
23
|
+
: null;
|
|
24
|
+
const raw = parsed?.[key];
|
|
25
|
+
if (raw === null || raw === undefined)
|
|
26
|
+
return { kind: "ok", value: "" };
|
|
27
|
+
if (typeof raw === "string")
|
|
28
|
+
return { kind: "ok", value: raw };
|
|
29
|
+
return {
|
|
30
|
+
kind: "malformed",
|
|
31
|
+
message: `adapter returned a non-string ${key} for ${label}`,
|
|
32
|
+
};
|
|
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
|
+
result: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Clause 1: replay every outcome, on both the success and the failure
|
|
47
|
+
// path, before any decision -- collected here and replayed by runInstall
|
|
48
|
+
// once gatherInstallStages's try/catch has resolved, for the same EPIPE
|
|
49
|
+
// reason gatherProbe carries its outcomes out rather than writing in place
|
|
50
|
+
// (src/commands/probe.ts).
|
|
51
|
+
outcomes.push(result.outcome);
|
|
52
|
+
const outcome = result.outcome;
|
|
53
|
+
if (result.status !== 0 || !outcome.ok) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
message: outcome.ok
|
|
57
|
+
? `adapter reported a failure status for ${argv.join(" ")}`
|
|
58
|
+
: null,
|
|
59
|
+
result,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, result };
|
|
63
|
+
}
|
|
64
|
+
// withWorkspace can throw AFTER its callback has already returned a fully
|
|
65
|
+
// computed StageOutcome: a post-success cleanup failure discards that return
|
|
66
|
+
// value entirely and rejects instead, UNLESS an `onCleanupFailure` reporter is
|
|
67
|
+
// supplied -- which gatherInstallStages below does, precisely so this class
|
|
68
|
+
// stays reserved for mkdtemp failure (nothing collected yet) and the
|
|
69
|
+
// callback's own throw (never reachable here; see gatherInstallStages).
|
|
70
|
+
// Carries the outcomes collected so far so runInstall's catch can still
|
|
71
|
+
// replay them instead of discarding them with a bare re-throw. Same shape as
|
|
72
|
+
// src/commands/uninstall.ts's GatherFailure, duplicated for the same reason
|
|
73
|
+
// invoke() is: no shared dependency between the two modules.
|
|
74
|
+
class GatherFailure extends Error {
|
|
75
|
+
inner;
|
|
76
|
+
outcomes;
|
|
77
|
+
constructor(inner, outcomes) {
|
|
78
|
+
super("install gather failed");
|
|
79
|
+
this.inner = inner;
|
|
80
|
+
this.outcomes = outcomes;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:44-58::TMPDIR=`, wrapped in the temporary workspace scripts/install
|
|
84
|
+
// created via spw_make_workspace + spw_install_workspace_trap. Performs no
|
|
85
|
+
// writes of its own -- same EPIPE-avoidance shape as gatherProbe and
|
|
86
|
+
// src/commands/uninstall.ts's gatherUninstall.
|
|
87
|
+
async function gatherInstallStages(ctx, desiredCommit) {
|
|
88
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:38::tmp_parent=` -- ${TMPDIR:-/tmp}. Matches
|
|
89
|
+
// src/commands/uninstall.ts's gatherUninstall.
|
|
90
|
+
const parent = ctx.env.TMPDIR ?? tmpdir();
|
|
91
|
+
const outcomes = [];
|
|
92
|
+
let cleanupWarning = null;
|
|
93
|
+
try {
|
|
94
|
+
const outcome = await withWorkspace(parent, "superpowers-manager.install.", async (workspace) => {
|
|
95
|
+
const env = { ...ctx.env, TMPDIR: workspace };
|
|
96
|
+
const failed = (message) => ({
|
|
97
|
+
kind: "failed",
|
|
98
|
+
outcomes,
|
|
99
|
+
message,
|
|
100
|
+
});
|
|
101
|
+
// Stage 1: inspect ownership, re-checked even though gatherProbe just
|
|
102
|
+
// reported it. Mutation authority requires CURRENT, VALIDATED
|
|
103
|
+
// evidence -- a probe's answer is neither by the time this runs.
|
|
104
|
+
const ownership = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
|
|
105
|
+
if (!ownership.ok)
|
|
106
|
+
return failed(ownership.message);
|
|
107
|
+
const identity = readStringField(ownership.result, "identity_state", "inspect --view ownership");
|
|
108
|
+
if (identity.kind === "malformed")
|
|
109
|
+
return failed(identity.message);
|
|
110
|
+
const legacy = requireNoLegacyState(identity.value);
|
|
111
|
+
if (legacy.kind === "blocked") {
|
|
112
|
+
return { kind: "blocked", outcomes, lines: legacy.lines };
|
|
113
|
+
}
|
|
114
|
+
if (legacy.kind === "unknown")
|
|
115
|
+
return failed(legacy.message);
|
|
116
|
+
// Stage 2: inspect update-control, re-checked for the same reason.
|
|
117
|
+
const updateControl = await invoke(ctx, env, ["inspect", "--view", "update-control"], outcomes);
|
|
118
|
+
if (!updateControl.ok)
|
|
119
|
+
return failed(updateControl.message);
|
|
120
|
+
const control = readStringField(updateControl.result, "update_control", "inspect --view update-control");
|
|
121
|
+
if (control.kind === "malformed")
|
|
122
|
+
return failed(control.message);
|
|
123
|
+
const managed = requireManagedUpdateControl(control.value);
|
|
124
|
+
if (!managed.ok)
|
|
125
|
+
return failed(managed.message);
|
|
126
|
+
// Stage 3: the mutation itself. Nothing above may have issued this --
|
|
127
|
+
// that is the whole point of stages 1 and 2 running first.
|
|
128
|
+
const install = await invoke(ctx, env, ["install", "--package-root", ctx.root], outcomes);
|
|
129
|
+
if (!install.ok)
|
|
130
|
+
return failed(install.message);
|
|
131
|
+
// Stage 4: inspect fingerprint, to verify the mutation actually took.
|
|
132
|
+
//
|
|
133
|
+
// Deliberately NOT short-circuited on `!inspected.ok`, unlike stages
|
|
134
|
+
// 1-3. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:57::spw_verify_installed_fingerprint` handed the inspect result to
|
|
135
|
+
// spw_verify_installed_fingerprint whatever it contained, and that
|
|
136
|
+
// function's first guard, from
|
|
137
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:91-94::spw_inspect_fingerprint`,
|
|
138
|
+
// is what turns a failed inspection into "error: installed manager
|
|
139
|
+
// fingerprint inspection failed after install."
|
|
140
|
+
// verifyInstalledFingerprint's "call-failed" arm
|
|
141
|
+
// (`src/lifecycle.ts:158::if (inspected.kind === "call-failed") {`)
|
|
142
|
+
// exists for exactly this and is reachable only from here. Returning
|
|
143
|
+
// failed() instead reported the adapter's own generic diagnostic and
|
|
144
|
+
// dropped the post-install verification claim -- a mutation had
|
|
145
|
+
// already been issued at stage 3,
|
|
146
|
+
// so "the install could not be verified" is the contract, not "an
|
|
147
|
+
// adapter call failed". A ctx.adapter THROW is the one case with no
|
|
148
|
+
// result to verify against, and keeps the short-circuit.
|
|
149
|
+
const inspected = await invoke(ctx, env, ["inspect", "--view", "fingerprint"], outcomes);
|
|
150
|
+
let inspectResult;
|
|
151
|
+
if (inspected.ok) {
|
|
152
|
+
inspectResult = inspected.result;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
if (inspected.result === null)
|
|
156
|
+
return failed(inspected.message);
|
|
157
|
+
inspectResult = inspected.result;
|
|
158
|
+
}
|
|
159
|
+
const verdict = verifyInstalledFingerprint(desiredCommit, install.result, inspectResult);
|
|
160
|
+
return {
|
|
161
|
+
kind: "verified",
|
|
162
|
+
outcomes,
|
|
163
|
+
status: verdict.ok ? 0 : 1,
|
|
164
|
+
stdout: verdict.stdout,
|
|
165
|
+
stderr: verdict.stderr,
|
|
166
|
+
};
|
|
167
|
+
}, {
|
|
168
|
+
// Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
|
|
169
|
+
// so the StageOutcome the callback already computed still comes back
|
|
170
|
+
// as `outcome` below instead of being discarded. `report` runs
|
|
171
|
+
// synchronously, as the option requires (src/workspace.ts).
|
|
172
|
+
onCleanupFailure: (path) => {
|
|
173
|
+
cleanupWarning = workspaceRemovalFailure(path);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return { outcome, cleanupWarning };
|
|
177
|
+
}
|
|
178
|
+
catch (cause) {
|
|
179
|
+
// Reachable only for mkdtemp failure (nothing collected yet) -- the
|
|
180
|
+
// callback above never throws, so a post-success cleanup failure is
|
|
181
|
+
// already handled by onCleanupFailure and cannot reach here.
|
|
182
|
+
throw new GatherFailure(cause, outcomes);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:29-32::printf` kept its shell `case ... *)` wildcard even though the
|
|
186
|
+
// three inputs the shell's own probe could ever emit were bounded by the same
|
|
187
|
+
// three-way status.sh logic this port's statusForCommits (src/status.ts) now
|
|
188
|
+
// owns exactly. statusForCommits can only ever return "needs prepare",
|
|
189
|
+
// "needs install" or "current" -- so this branch is NOT reachable through
|
|
190
|
+
// runInstall's own call to gatherProbe today. It stays anyway, because
|
|
191
|
+
// ProbeFacts.status is typed `string`, not that three-literal union, and a
|
|
192
|
+
// future caller that builds facts by hand (as this module's own unit test
|
|
193
|
+
// does, directly) must still see it fail closed rather than silently
|
|
194
|
+
// proceed.
|
|
195
|
+
//
|
|
196
|
+
// Exported specifically so a direct test can reach it. That is ONE STEP
|
|
197
|
+
// FURTHER than
|
|
198
|
+
// `src/commands/probe.ts:367-372::This guard is NOT the production path.`'s own
|
|
199
|
+
// precedent: that comment licenses RETAINING an unreachable branch inside an
|
|
200
|
+
// already-public function (runProbe was public before that comment existed),
|
|
201
|
+
// not EXPORTING a new one. This module does the latter, deliberately, because
|
|
202
|
+
// runInstall
|
|
203
|
+
// itself has no way to construct the unreachable input. Not part of the
|
|
204
|
+
// interface Task 5 or Task 8 consume.
|
|
205
|
+
//
|
|
206
|
+
// A test against this export proves the two writes below are correct for a
|
|
207
|
+
// given `facts`. It does NOT prove that runInstall's own `else if` branch
|
|
208
|
+
// actually reaches and calls this function, or that runInstall returns 1
|
|
209
|
+
// afterward -- those are properties of the call site, not of this function,
|
|
210
|
+
// and need their own coverage there.
|
|
211
|
+
export function renderUnknownProbeStatus(facts, ctx) {
|
|
212
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:30-31::printf` -- the porcelain reaches the terminal ONLY here.
|
|
213
|
+
// Every other path swallows it, matching `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:18::porcelain`'s
|
|
214
|
+
// probe_output=$(...) capture.
|
|
215
|
+
ctx.stdout.write(formatPorcelain(facts));
|
|
216
|
+
ctx.stderr.write(`error: unknown probe status: ${facts.status}\n`);
|
|
217
|
+
}
|
|
218
|
+
export async function runInstall(argv, ctx) {
|
|
219
|
+
// scripts/install never reads "$@", so extra arguments are silently
|
|
220
|
+
// ignored -- the same asymmetry runPrepare and runUninstall document.
|
|
221
|
+
void argv;
|
|
222
|
+
ctx.stdout.write(NOTE);
|
|
223
|
+
let probe;
|
|
224
|
+
try {
|
|
225
|
+
probe = await gatherProbe(ctx);
|
|
226
|
+
}
|
|
227
|
+
catch (cause) {
|
|
228
|
+
// gatherProbe performs no writes of its own (src/commands/probe.ts), so
|
|
229
|
+
// this catch cannot also be reached by an EPIPE from install's own
|
|
230
|
+
// output: the NOTE line above already left the try, and everything below
|
|
231
|
+
// runs only after this try/catch has resolved.
|
|
232
|
+
//
|
|
233
|
+
// This is a SECOND consumer of gatherProbe's throw channel --
|
|
234
|
+
// `src/commands/probe.ts:399-433::THREE exceptions, all inherited and none a regression:`'s
|
|
235
|
+
// runProbe catch is the first. Because both consumers wrap the identical
|
|
236
|
+
// function, its long comment there enumerates exactly what can reach THIS
|
|
237
|
+
// stream too, including the three foreign-text exceptions at :399-433:
|
|
238
|
+
// 1. :399-410 -- resolveRef splices git's own combined stdout+stderr
|
|
239
|
+
// into its text. Reached on probe's DEFAULT path, which that comment
|
|
240
|
+
// defines as every invocation NOT resolving a saved pin: a 40-hex
|
|
241
|
+
// ref returns a "raw-commit" resolution at
|
|
242
|
+
// `src/upstream.ts:162-164::return { kind: "raw-commit"`
|
|
243
|
+
// before any git call, so it reaches no splice at all.
|
|
244
|
+
// 2. :411-423 -- src/selection-store.ts's read path interpolates the
|
|
245
|
+
// caught error's own message, so Node errno prose can appear.
|
|
246
|
+
// AGENTS.md grandfathers that module's wording.
|
|
247
|
+
// 3. :424-433 -- a SPAWN-level git failure, a different channel from
|
|
248
|
+
// exception 1's exit-status one: on the non-ENOENT arm of
|
|
249
|
+
// `src/git.ts:47-52::if (typeof failure.code === "string") {`, runGit
|
|
250
|
+
// rejects with "cannot run git: " followed by the Node spawn error's
|
|
251
|
+
// own message.
|
|
252
|
+
// Not repeated in full here; read it there.
|
|
253
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
// Replay first, on both paths, before any decision -- clause 1.
|
|
257
|
+
for (const outcome of probe.outcomes)
|
|
258
|
+
replayOutcome(outcome, ctx);
|
|
259
|
+
if (probe.status === 1) {
|
|
260
|
+
if (probe.message !== null) {
|
|
261
|
+
ctx.stderr.write(`error: ${probe.message}\n`);
|
|
262
|
+
}
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
const facts = probe.facts;
|
|
266
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:19-20::identity_state=$(spw_probe_field`. Guards the PROBE-derived value only -- the
|
|
267
|
+
// re-inspection at :198 reads through readStringField, which already has
|
|
268
|
+
// its own "malformed" arm for a non-string value, and an empty string
|
|
269
|
+
// there is legitimately absent (the JSON-null convention), not an error.
|
|
270
|
+
// This check exists because a probe that reports no identity state at all
|
|
271
|
+
// is a different failure than one that reports an unrecognised one:
|
|
272
|
+
// requireNoLegacyState("") would otherwise reach its "unknown" arm and
|
|
273
|
+
// print "unknown adapter identity state: " (empty-suffixed), which names a
|
|
274
|
+
// symptom rather than the actual cause. Do NOT add a second copy of this
|
|
275
|
+
// check at :198 -- it guards a different value with a different failure
|
|
276
|
+
// mode.
|
|
277
|
+
if (facts.identityState.length === 0) {
|
|
278
|
+
ctx.stderr.write("error: probe did not report adapter identity state\n");
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:20-21::report`, run BEFORE the workspace is ever created and before
|
|
282
|
+
// any further adapter call -- a legacy identity is fatal on sight, not
|
|
283
|
+
// something worth spending a mutation attempt on.
|
|
284
|
+
const legacy = requireNoLegacyState(facts.identityState);
|
|
285
|
+
if (legacy.kind === "blocked") {
|
|
286
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:50-53::'Legacy superpowers-wrapper Codex state is`
|
|
287
|
+
// is a single printf writing three bare lines to stderr, no `error: `
|
|
288
|
+
// prefix; :54 is the `return 1` that follows it, reached without spw_die.
|
|
289
|
+
for (const line of legacy.lines)
|
|
290
|
+
ctx.stderr.write(`${line}\n`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
if (legacy.kind === "unknown") {
|
|
294
|
+
ctx.stderr.write(`error: ${legacy.message}\n`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:22-33::status=`.
|
|
298
|
+
if (facts.status === "needs prepare") {
|
|
299
|
+
// Called as a FUNCTION: a failure propagates as a status, never through
|
|
300
|
+
// `set -eu`. runPrepare has already replayed its own outcomes and
|
|
301
|
+
// written its own diagnostics by the time it returns, so nothing further
|
|
302
|
+
// is written here on that path.
|
|
303
|
+
const prepareStatus = await runPrepare([], ctx);
|
|
304
|
+
if (prepareStatus !== 0)
|
|
305
|
+
return prepareStatus;
|
|
306
|
+
}
|
|
307
|
+
else if (facts.status !== "needs install" && facts.status !== "current") {
|
|
308
|
+
renderUnknownProbeStatus(facts, ctx);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:35-36::spw_metadata_commit_or_empty`. The STRICT reader, never the lenient
|
|
312
|
+
// generatedCommitOrEmpty gatherProbe already used for facts.generatedCommit:
|
|
313
|
+
// a throw, an absent key, or a non-string value are all treated as absent
|
|
314
|
+
// here, matching spw_metadata_commit_or_empty's `|| true` plus the
|
|
315
|
+
// following `[ -n ]` check.
|
|
316
|
+
let desiredCommit = "";
|
|
317
|
+
try {
|
|
318
|
+
const value = await readStrictProvenanceField(generatedMetadataPath(ctx.root), "commit");
|
|
319
|
+
if (typeof value === "string")
|
|
320
|
+
desiredCommit = value;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Absent, same as an undefined or non-string read.
|
|
324
|
+
}
|
|
325
|
+
if (desiredCommit.length === 0) {
|
|
326
|
+
ctx.stderr.write("error: generated metadata missing desired commit after prepare\n");
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
let stage;
|
|
330
|
+
try {
|
|
331
|
+
stage = await gatherInstallStages(ctx, desiredCommit);
|
|
332
|
+
}
|
|
333
|
+
catch (cause) {
|
|
334
|
+
const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
|
|
335
|
+
for (const outcome of outcomes)
|
|
336
|
+
replayOutcome(outcome, ctx);
|
|
337
|
+
const inner = cause instanceof GatherFailure ? cause.inner : cause;
|
|
338
|
+
ctx.stderr.write(`error: ${oneLine(inner)}\n`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
const { outcome, cleanupWarning } = stage;
|
|
342
|
+
for (const each of outcome.outcomes)
|
|
343
|
+
replayOutcome(each, ctx);
|
|
344
|
+
let status;
|
|
345
|
+
if (outcome.kind === "blocked") {
|
|
346
|
+
for (const line of outcome.lines)
|
|
347
|
+
ctx.stderr.write(`${line}\n`);
|
|
348
|
+
status = 1;
|
|
349
|
+
}
|
|
350
|
+
else if (outcome.kind === "failed") {
|
|
351
|
+
if (outcome.message !== null) {
|
|
352
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
353
|
+
}
|
|
354
|
+
status = 1;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// :99-100 printed both lines BEFORE deciding, on every path that got this
|
|
358
|
+
// far; verifyInstalledFingerprint already encodes that, so both arrays
|
|
359
|
+
// are written in order regardless of verdict.ok.
|
|
360
|
+
for (const line of outcome.stdout)
|
|
361
|
+
ctx.stdout.write(`${line}\n`);
|
|
362
|
+
for (const line of outcome.stderr)
|
|
363
|
+
ctx.stderr.write(`${line}\n`);
|
|
364
|
+
status = outcome.status;
|
|
365
|
+
}
|
|
366
|
+
if (cleanupWarning !== null) {
|
|
367
|
+
// A leaked workspace is reported even when the domain outcome above was
|
|
368
|
+
// itself a success: the fingerprint verification that produced that
|
|
369
|
+
// success already completed against the adapter before cleanup ran, so
|
|
370
|
+
// it is not being reported as unverified -- but something did still go
|
|
371
|
+
// wrong, and AGENTS.md's fail-closed rule extends to it.
|
|
372
|
+
ctx.stderr.write(`error: ${cleanupWarning}\n`);
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
return status;
|
|
376
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { oneLine } from "../cli-arguments.js";
|
|
3
|
+
import { isTagRef, normalizeCommitInput } from "../domain/refs.js";
|
|
4
|
+
import { loadSavedSelection, selectionStatePath, UPSTREAM_URL_DEFAULT, } from "../effective-selection.js";
|
|
5
|
+
import { normalizePinnedArguments, validateSource } from "../selection.js";
|
|
6
|
+
import { writeSelectionState } from "../selection-store.js";
|
|
7
|
+
import { resolveExactTag, verifyRawCommit } from "../upstream.js";
|
|
8
|
+
// Runs every step that can throw, and performs no writes of its own: that is
|
|
9
|
+
// the point of this shape (mirrors src/commands/unpin.ts's attemptUnpin) —
|
|
10
|
+
// runPin's try (below) can therefore never itself raise EPIPE, since nothing
|
|
11
|
+
// left inside it writes to a stream.
|
|
12
|
+
async function attemptPin(requested, ctx) {
|
|
13
|
+
// Read first. This is a deliberate redundant boundary check, not the
|
|
14
|
+
// enforcing guard: writeSelectionState below already refuses to overwrite
|
|
15
|
+
// a corrupt existing record on its own (src/selection-store.ts's
|
|
16
|
+
// writeSelectionState calls readSelectionState on the same path before
|
|
17
|
+
// writing, under "Invalid existing state must block overwrite" —
|
|
18
|
+
// readSelectionState's own readOpenedRecord/parseRecordBytes is what
|
|
19
|
+
// validates an existing record, via validateRecord at
|
|
20
|
+
// `src/selection-store.ts:103::return validateRecord`). Calling loadSavedSelection here preserves
|
|
21
|
+
// `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/pin:26-27::read`'s read-then-resolve-then-write shape, so this command
|
|
22
|
+
// stays fail-closed on its own terms rather than solely by depending on the
|
|
23
|
+
// store's internals.
|
|
24
|
+
await loadSavedSelection(ctx.env);
|
|
25
|
+
const source = ctx.env.SUPERPOWERS_UPSTREAM_URL || UPSTREAM_URL_DEFAULT;
|
|
26
|
+
validateSource(source);
|
|
27
|
+
let resolvedRef;
|
|
28
|
+
let commit;
|
|
29
|
+
if (isTagRef(requested)) {
|
|
30
|
+
resolvedRef = requested;
|
|
31
|
+
commit = await resolveExactTag(source, requested);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/pin:39::upper` lowercases a raw-commit request before use; both
|
|
35
|
+
// requested_ref and resolved_ref end up equal to the normalized commit.
|
|
36
|
+
resolvedRef = normalizeCommitInput(requested);
|
|
37
|
+
commit = await verifyRawCommit(source, resolvedRef, tmpdir());
|
|
38
|
+
}
|
|
39
|
+
await writeSelectionState(selectionStatePath(ctx.env), normalizePinnedArguments({
|
|
40
|
+
source,
|
|
41
|
+
requestedRef: resolvedRef,
|
|
42
|
+
resolvedRef,
|
|
43
|
+
commit,
|
|
44
|
+
}));
|
|
45
|
+
return { resolvedRef, commit };
|
|
46
|
+
}
|
|
47
|
+
export async function runPin(argv, ctx) {
|
|
48
|
+
// Arity and ref syntax are already decided in src/cli.ts's parseArgs (the
|
|
49
|
+
// TAG_RE / COMMIT_INPUT_RE check ahead of dispatch) and must not be
|
|
50
|
+
// duplicated here — one source, checked before any tool lookup.
|
|
51
|
+
const requested = argv[0];
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await attemptPin(requested, ctx);
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
// Every throw reachable here is a hand-written SafetyError from
|
|
58
|
+
// loadSavedSelection, validateSource, resolveExactTag, verifyRawCommit,
|
|
59
|
+
// or writeSelectionState — re-emitting a subordinate module's own
|
|
60
|
+
// diagnostic is the sanctioned form of interpolation (see AGENTS.md's
|
|
61
|
+
// diagnostics convention). That does not make every such message clean:
|
|
62
|
+
// resolveExactTag's and verifyRawCommit's SafetyErrors splice git's own
|
|
63
|
+
// combined stdout+stderr into their text (src/upstream.ts's `combined()`,
|
|
64
|
+
// used at :219 for `ls-remote` and :262 for `init`), so raw git output can
|
|
65
|
+
// reach ctx.stderr through this catch. Not a regression — scripts/pin
|
|
66
|
+
// piped git's output into its own error text the same way — but `pin` is
|
|
67
|
+
// the first in-process command able to surface it. oneLine() collapses
|
|
68
|
+
// that spliced output to one line, containing the harm to one line of
|
|
69
|
+
// git text rather than the arbitrarily many the shell original allowed.
|
|
70
|
+
// attemptPin performs no writes of its own (see above), so this catch
|
|
71
|
+
// cannot also be reached by an EPIPE from a write this function made
|
|
72
|
+
// itself — the write below runs only after this try/catch has already
|
|
73
|
+
// resolved.
|
|
74
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
ctx.stdout.write(`pinned upstream selection to ${result.resolvedRef} at ${result.commit}\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|