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,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
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { atomicReplaceDir } from "../atomic.js";
|
|
4
|
+
import { oneLine } from "../cli-arguments.js";
|
|
5
|
+
import { computeEffectiveSelection } from "../effective-selection.js";
|
|
6
|
+
import { runGit } from "../git.js";
|
|
7
|
+
import { SafetyError } from "../safety-error.js";
|
|
8
|
+
import { fetchExactCommit, gitSafeSource } from "../upstream.js";
|
|
9
|
+
import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
|
|
10
|
+
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
11
|
+
import { replayOutcome } from "./probe.js";
|
|
12
|
+
// Every message this module writes is hand-written here. The cause is attached
|
|
13
|
+
// for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
|
|
14
|
+
// reads .message only. Same arrangement as hookError
|
|
15
|
+
// (`src/hooks.ts:44::function hookError`).
|
|
16
|
+
function prepareError(message, cause) {
|
|
17
|
+
return new SafetyError("prepare", message, { cause });
|
|
18
|
+
}
|
|
19
|
+
// `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` uses -f, and
|
|
20
|
+
// tests/baseline/cli-parity.test.js's "CLI-ENV-MANIFEST-TEMPLATE-01 fallback
|
|
21
|
+
// template bytes and non-file rejection" also covers the separately extracted
|
|
22
|
+
// fallback check.
|
|
23
|
+
async function regularFileExists(path) {
|
|
24
|
+
try {
|
|
25
|
+
return (await stat(path)).isFile();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
|
|
32
|
+
// worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
|
|
33
|
+
// fetch branch and let git follow its `gitdir:` pointer, where the shell took
|
|
34
|
+
// the clone branch. `src/upstream.ts:332::if (!(await isDirectory` makes the
|
|
35
|
+
// same distinction.
|
|
36
|
+
async function directoryExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
return (await stat(path)).isDirectory();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// mkdir throws raw ErrnoExceptions. Every command-owned call goes through here
|
|
45
|
+
// so the message on the stream is this module's, not Node's.
|
|
46
|
+
async function owned(message, fn) {
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
throw prepareError(message, cause);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Each stream's marker goes to that stream's own destination, so a reader of either
|
|
55
|
+
// sees its own truncation and neither is silently short.
|
|
56
|
+
function withTruncationMarker(captured, stream) {
|
|
57
|
+
if (captured.droppedBytes === 0)
|
|
58
|
+
return captured.text;
|
|
59
|
+
return `${captured.text}\n[superpowers-manager: validator ${stream} truncated, ${captured.droppedBytes} bytes dropped]\n`;
|
|
60
|
+
}
|
|
61
|
+
// D8's disclosure. The variable is ambient process env and nothing filters it, so a
|
|
62
|
+
// consumer's CI can set it; naming what actually ran is what makes a supply-chain
|
|
63
|
+
// surprise visible. Paths go through displayPath, so a control character in either
|
|
64
|
+
// one cannot reach the terminal raw.
|
|
65
|
+
function disclosureLine(resolution) {
|
|
66
|
+
const configured = displayPath(resolution.configured);
|
|
67
|
+
if (resolution.resolved === null) {
|
|
68
|
+
// The manager does not know the final target and does not pretend to. But a
|
|
69
|
+
// dangling symlink is a KNOWN fact even when resolution fails, and saying "the
|
|
70
|
+
// OS selects it" about a path-like value would be wrong.
|
|
71
|
+
if (resolution.isSymlink) {
|
|
72
|
+
return `[superpowers-manager: running external validator ${configured} (a symlink whose target could not be resolved)]\n`;
|
|
73
|
+
}
|
|
74
|
+
if (configured.includes(sep)) {
|
|
75
|
+
return `[superpowers-manager: running external validator ${configured} (unresolved)]\n`;
|
|
76
|
+
}
|
|
77
|
+
return `[superpowers-manager: running external validator ${configured} (a bare name; PATH selects the file, and the manager does not guess which)]\n`;
|
|
78
|
+
}
|
|
79
|
+
const via = resolution.isSymlink ? " via symlink" : "";
|
|
80
|
+
return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
|
|
81
|
+
}
|
|
82
|
+
const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
|
|
83
|
+
function validStagingLeaf(value) {
|
|
84
|
+
return (value.length > 0 &&
|
|
85
|
+
value !== "." &&
|
|
86
|
+
value !== ".." &&
|
|
87
|
+
!value.includes("/") &&
|
|
88
|
+
!value.includes("\\"));
|
|
89
|
+
}
|
|
90
|
+
async function gatherPrepare(ctx) {
|
|
91
|
+
const env = ctx.env;
|
|
92
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
|
|
93
|
+
// getcwd(3) returns the physical path, so this matches `pwd -P` without a
|
|
94
|
+
// realpath call.
|
|
95
|
+
const cwd = process.cwd();
|
|
96
|
+
const configuredCache = env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream");
|
|
97
|
+
const cacheParent = isAbsolute(configuredCache)
|
|
98
|
+
? configuredCache
|
|
99
|
+
: resolve(cwd, configuredCache);
|
|
100
|
+
const adapterContext = { root: ctx.root, env };
|
|
101
|
+
let location;
|
|
102
|
+
try {
|
|
103
|
+
location = ctx.adapter.preparationLocation(adapterContext);
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
throw prepareError("cannot determine preparation location", cause);
|
|
107
|
+
}
|
|
108
|
+
if (!isAbsolute(location.destinationRoot)) {
|
|
109
|
+
throw prepareError("adapter returned a non-absolute preparation destination");
|
|
110
|
+
}
|
|
111
|
+
if (!validStagingLeaf(location.stagingLeaf)) {
|
|
112
|
+
throw prepareError("adapter returned an invalid preparation staging leaf");
|
|
113
|
+
}
|
|
114
|
+
const pluginRoot = location.destinationRoot;
|
|
115
|
+
const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
|
|
116
|
+
const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
|
|
117
|
+
const cache = join(cacheParent, "superpowers");
|
|
118
|
+
const tmpParent = dirname(pluginRoot);
|
|
119
|
+
await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
|
|
120
|
+
let cleanupWarning = null;
|
|
121
|
+
const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
|
|
122
|
+
const outcomes = [];
|
|
123
|
+
const failed = (message) => ({
|
|
124
|
+
kind: "failed",
|
|
125
|
+
outcomes,
|
|
126
|
+
validator: NO_VALIDATOR_OUTPUT,
|
|
127
|
+
message,
|
|
128
|
+
});
|
|
129
|
+
const candidate = join(workspace, location.stagingLeaf);
|
|
130
|
+
const selection = await computeEffectiveSelection(ctx.root, env);
|
|
131
|
+
let prefetch;
|
|
132
|
+
try {
|
|
133
|
+
prefetch =
|
|
134
|
+
await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
|
|
138
|
+
.unexpected);
|
|
139
|
+
}
|
|
140
|
+
outcomes.push(prefetch.outcome);
|
|
141
|
+
if (!prefetch.outcome.ok)
|
|
142
|
+
return failed(null);
|
|
143
|
+
if (prefetch.status !== 0) {
|
|
144
|
+
return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
|
|
145
|
+
.invalidStatus);
|
|
146
|
+
}
|
|
147
|
+
await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
|
|
148
|
+
if (selection.selectionMode === "pinned") {
|
|
149
|
+
await fetchExactCommit(selection.effectiveSource, selection.desiredCommit, cache, workspace);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const source = gitSafeSource(selection.effectiveSource);
|
|
153
|
+
if (await directoryExists(join(cache, ".git"))) {
|
|
154
|
+
const fetched = await runGit([
|
|
155
|
+
"-C",
|
|
156
|
+
cache,
|
|
157
|
+
"fetch",
|
|
158
|
+
"--tags",
|
|
159
|
+
"--prune",
|
|
160
|
+
"--",
|
|
161
|
+
source,
|
|
162
|
+
]);
|
|
163
|
+
// runGit returns its status rather than throwing, so no git output is
|
|
164
|
+
// in scope. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:52::spw_die "cannot fetch` names the source and nothing else, and
|
|
165
|
+
// computeEffectiveSelection ran validateSource first, so a
|
|
166
|
+
// credential-bearing source never reaches here.
|
|
167
|
+
if (fetched.status !== 0) {
|
|
168
|
+
return failed(`cannot fetch upstream repo: ${selection.effectiveSource}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const cloned = await runGit(["clone", "--", source, cache]);
|
|
173
|
+
if (cloned.status !== 0) {
|
|
174
|
+
return failed(`cannot clone upstream repo: ${selection.effectiveSource}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const checkedOut = await runGit([
|
|
179
|
+
"-C",
|
|
180
|
+
cache,
|
|
181
|
+
"checkout",
|
|
182
|
+
"--detach",
|
|
183
|
+
selection.desiredCommit,
|
|
184
|
+
]);
|
|
185
|
+
if (checkedOut.status !== 0) {
|
|
186
|
+
return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
|
|
187
|
+
}
|
|
188
|
+
let prepared;
|
|
189
|
+
try {
|
|
190
|
+
prepared = await ctx.adapter.prepareCandidate({
|
|
191
|
+
upstreamRoot: cache,
|
|
192
|
+
workspaceRoot: workspace,
|
|
193
|
+
candidateRoot: candidate,
|
|
194
|
+
selection,
|
|
195
|
+
}, adapterContext);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
|
|
199
|
+
.unexpected);
|
|
200
|
+
}
|
|
201
|
+
outcomes.push(prepared.outcome);
|
|
202
|
+
if (prepared.status !== 0 || !prepared.outcome.ok) {
|
|
203
|
+
return failed(prepared.outcome.ok
|
|
204
|
+
? ctx.adapter.presentation.callFailure("prepare", adapterContext)
|
|
205
|
+
.invalidStatus
|
|
206
|
+
: null);
|
|
207
|
+
}
|
|
208
|
+
if (prepared.outcome.result.root !== candidate) {
|
|
209
|
+
return failed("adapter returned an unexpected preparation root");
|
|
210
|
+
}
|
|
211
|
+
if (prepared.outcome.result.commit !== selection.desiredCommit) {
|
|
212
|
+
return failed("adapter returned an unexpected preparation commit");
|
|
213
|
+
}
|
|
214
|
+
let validator = NO_VALIDATOR_OUTPUT;
|
|
215
|
+
if (additionalValidator.length > 0) {
|
|
216
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` — `[ -f ]`.
|
|
217
|
+
if (!(await regularFileExists(additionalValidator))) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "failed",
|
|
220
|
+
outcomes,
|
|
221
|
+
validator,
|
|
222
|
+
message: `additional plugin validator not found: ${additionalValidator}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const ran = await runValidator(["python3", additionalValidator, candidate], UNBOUNDED_LEGACY, env, workspace);
|
|
226
|
+
if (ran.kind === "launchFailed") {
|
|
227
|
+
// PARITY, and it must stay a THROW. The legacy path rejects the workspace
|
|
228
|
+
// callback on a spawn failure; withWorkspace returns the callback error
|
|
229
|
+
// and the `outcomes` collected above are LOST. That loss is a recorded,
|
|
230
|
+
// deliberately unassigned defect. Returning a failed outcome here would
|
|
231
|
+
// replay those outcomes instead -- observably different control flow for
|
|
232
|
+
// CLI-ENV-VALIDATOR-01, which this PR is not authorized to change.
|
|
233
|
+
throw prepareError(`cannot execute additional plugin validator: ${additionalValidator}`, ran.cause);
|
|
234
|
+
}
|
|
235
|
+
validator = { stdout: ran.stdout.text, stderr: ran.stderr.text };
|
|
236
|
+
if (ran.kind !== "exited" || ran.code !== 0) {
|
|
237
|
+
return {
|
|
238
|
+
kind: "failed",
|
|
239
|
+
outcomes,
|
|
240
|
+
validator,
|
|
241
|
+
message: "additional plugin validation failed",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (executableValidator.length > 0) {
|
|
246
|
+
const resolution = await resolveValidator(executableValidator);
|
|
247
|
+
const ran = await runValidator([executableValidator, candidate], BOUNDED_EXECUTABLE, env, workspace);
|
|
248
|
+
// The disclosure lands BEFORE any early return, so a launch failure still
|
|
249
|
+
// reports what the manager tried to run and what it resolved to — exactly
|
|
250
|
+
// the path where an operator most needs it. It is assigned twice rather
|
|
251
|
+
// than once because `launchFailed` carries no captured streams: there was
|
|
252
|
+
// no process to capture from.
|
|
253
|
+
validator = { stdout: disclosureLine(resolution), stderr: "" };
|
|
254
|
+
if (ran.kind === "launchFailed") {
|
|
255
|
+
return {
|
|
256
|
+
kind: "failed",
|
|
257
|
+
outcomes,
|
|
258
|
+
validator,
|
|
259
|
+
message: launchFailureMessage(ran.errno, resolution),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
validator = {
|
|
263
|
+
stdout: disclosureLine(resolution) +
|
|
264
|
+
withTruncationMarker(ran.stdout, "stdout"),
|
|
265
|
+
stderr: withTruncationMarker(ran.stderr, "stderr"),
|
|
266
|
+
};
|
|
267
|
+
if (ran.kind === "timedOut") {
|
|
268
|
+
return {
|
|
269
|
+
kind: "failed",
|
|
270
|
+
outcomes,
|
|
271
|
+
validator,
|
|
272
|
+
message: `external plugin validation timed out after ${Math.round(ran.afterMs / 1000)}s`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (ran.code !== 0) {
|
|
276
|
+
return {
|
|
277
|
+
kind: "failed",
|
|
278
|
+
outcomes,
|
|
279
|
+
validator,
|
|
280
|
+
message: "external plugin validation failed",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// The swap must run inside the workspace callback: withWorkspace removes
|
|
285
|
+
// the workspace on return, and the candidate lives in it.
|
|
286
|
+
//
|
|
287
|
+
// atomicReplaceDir's outer catch (`src/atomic.ts:208-215::if (cause`) wraps every
|
|
288
|
+
// non-SafetyError into a SafetyError, so the callee owns every failure on
|
|
289
|
+
// this path and re-emitting its own diagnostic is the sanctioned form of
|
|
290
|
+
// interpolation. The hand-written prefix carries the live root, which the
|
|
291
|
+
// callee's message does not.
|
|
292
|
+
try {
|
|
293
|
+
await atomicReplaceDir(candidate, pluginRoot);
|
|
294
|
+
}
|
|
295
|
+
catch (cause) {
|
|
296
|
+
return {
|
|
297
|
+
kind: "failed",
|
|
298
|
+
outcomes,
|
|
299
|
+
validator,
|
|
300
|
+
message: `cannot install generated tree into ${pluginRoot}: ${oneLine(cause)}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
kind: "ok",
|
|
305
|
+
outcomes,
|
|
306
|
+
validator,
|
|
307
|
+
resolvedRef: selection.resolvedRef,
|
|
308
|
+
commit: selection.desiredCommit,
|
|
309
|
+
};
|
|
310
|
+
}, {
|
|
311
|
+
// Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure, so
|
|
312
|
+
// the PrepareOutcome the callback already computed still reaches
|
|
313
|
+
// runPrepare instead of being discarded. The reporter runs synchronously,
|
|
314
|
+
// as the option requires (`src/workspace.ts:103-106::Must`).
|
|
315
|
+
onCleanupFailure: (path) => {
|
|
316
|
+
cleanupWarning = workspaceRemovalFailure(path);
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return { outcome, cleanupWarning };
|
|
320
|
+
}
|
|
321
|
+
export async function runPrepare(argv, ctx) {
|
|
322
|
+
// scripts/prepare never reads "$@", so extra arguments are ignored. This is a
|
|
323
|
+
// deliberate asymmetry with probe, whose shell original rejected unknown
|
|
324
|
+
// arguments and whose arity therefore moved into parseArgs in slice 2.
|
|
325
|
+
void argv;
|
|
326
|
+
let run;
|
|
327
|
+
try {
|
|
328
|
+
run = await gatherPrepare(ctx);
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
// Hand-written messages, per AGENTS.md's reader-diagnostics rule.
|
|
332
|
+
// Reachable here: prepareError(), from this module's owned() wrappers,
|
|
333
|
+
// its two manifest-version checks, asResolutionKind, and the
|
|
334
|
+
// additional-validator branch's own throw on a legacy-validator launch
|
|
335
|
+
// failure (its cause is the shared runner's (src/validator.ts) captured
|
|
336
|
+
// spawn error, which oneLine never reads -- it takes .message only);
|
|
337
|
+
// readManifest's three hookError messages
|
|
338
|
+
// (`src/hooks.ts:113-138::readManifest`), pinned by
|
|
339
|
+
// `tests/unit/hooks.test.ts:95::void test("readManifest diagnostics` as carrying no reader vocabulary or
|
|
340
|
+
// errno; and SafetyErrors from gitSafeSource, writeProvenance, and
|
|
341
|
+
// withWorkspace.
|
|
342
|
+
//
|
|
343
|
+
// FOUR exceptions, all inherited and none a regression:
|
|
344
|
+
// 1. resolveRef splices git's combined stdout+stderr into its own text
|
|
345
|
+
// on the NON-PINNED path (src/upstream.ts), reached via
|
|
346
|
+
// computeEffectiveSelection (src/effective-selection.ts).
|
|
347
|
+
// This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
|
|
348
|
+
// and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
|
|
349
|
+
// by
|
|
350
|
+
// `tests/unit/upstream.test.ts:453-462::void test("resolveRef reports a query failure for latest`,
|
|
351
|
+
// :471-481, and :483-501.
|
|
352
|
+
// 2. fetchExactCommit splices the same combined stdout+stderr into its
|
|
353
|
+
// own text on the PINNED path (both of its own splice sites in
|
|
354
|
+
// src/upstream.ts, and proveCommit's, which it calls). This is the
|
|
355
|
+
// rarer of the two raw-git-output paths, not the only one.
|
|
356
|
+
// 3. `src/selection-store.ts:120-124::cause.module === "selection") {`
|
|
357
|
+
// (same shape at :49, :86, :98) is the
|
|
358
|
+
// module AGENTS.md's `src/selection-store.ts` bullet grandfathers:
|
|
359
|
+
// it interpolates the caught error's own message, so Node errno
|
|
360
|
+
// prose can reach this stream -- sanctioned, nothing here needs
|
|
361
|
+
// fixing.
|
|
362
|
+
// 4. Every runGit call site in this module (fetch, clone, checkout) can
|
|
363
|
+
// reject instead of resolving: `src/git.ts:47-52::reject(new SafetyError` wraps every string
|
|
364
|
+
// errno other than ENOENT in
|
|
365
|
+
// `new SafetyError("git", \`cannot run git: ${failure.message}\`)`,
|
|
366
|
+
// and that Node spawn-level message reaches ctx.stderr through this
|
|
367
|
+
// catch. So the claim that the non-pinned clone/fetch/checkout
|
|
368
|
+
// branch names only the source is true for a non-zero *exit status*
|
|
369
|
+
// (handled explicitly below, in gatherPrepare) but false for a
|
|
370
|
+
// *spawn-level* failure -- runGit throws rather than returning a
|
|
371
|
+
// status in that case, and this outer catch is what stands between
|
|
372
|
+
// it and the stream.
|
|
373
|
+
//
|
|
374
|
+
// oneLine() at this catch collapses each of exceptions 1, 2, and 4 -- the
|
|
375
|
+
// three carrying git-derived text -- to a single line. It collapses CR/LF
|
|
376
|
+
// only, so it bounds how much of that text lands, not what it may
|
|
377
|
+
// contain.
|
|
378
|
+
//
|
|
379
|
+
// runAdapter's closing `throw cause` (src/adapter.ts) does NOT arrive
|
|
380
|
+
// here -- the call site catches it and converts it to a hand-written
|
|
381
|
+
// message per AGENTS.md's reader-diagnostics rule.
|
|
382
|
+
//
|
|
383
|
+
// gatherPrepare performs no writes of its own, so this catch cannot also be
|
|
384
|
+
// reached by an EPIPE from prepare's own output: every write below runs
|
|
385
|
+
// only after this try/catch has resolved.
|
|
386
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
const { outcome, cleanupWarning } = run;
|
|
390
|
+
for (const each of outcome.outcomes)
|
|
391
|
+
replayOutcome(each, ctx);
|
|
392
|
+
if (outcome.validator.stdout.length > 0) {
|
|
393
|
+
ctx.stdout.write(outcome.validator.stdout);
|
|
394
|
+
}
|
|
395
|
+
if (outcome.validator.stderr.length > 0) {
|
|
396
|
+
ctx.stderr.write(outcome.validator.stderr);
|
|
397
|
+
}
|
|
398
|
+
let status;
|
|
399
|
+
if (outcome.kind === "failed") {
|
|
400
|
+
if (outcome.message !== null) {
|
|
401
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
402
|
+
}
|
|
403
|
+
status = 1;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
ctx.stdout.write(`prepared ${outcome.resolvedRef} at ${outcome.commit}\n`);
|
|
407
|
+
status = 0;
|
|
408
|
+
}
|
|
409
|
+
if (cleanupWarning !== null) {
|
|
410
|
+
// A leaked workspace is reported even when the outcome above was itself a
|
|
411
|
+
// success: the generated-tree replacement that produced it already
|
|
412
|
+
// completed before cleanup ran, so it is not being reported as unverified
|
|
413
|
+
// -- but something did still go wrong, and AGENTS.md's fail-closed rule
|
|
414
|
+
// extends to it. Mirrors
|
|
415
|
+
// `src/commands/install.ts:395-403::if (cleanupWarning`.
|
|
416
|
+
ctx.stderr.write(`error: ${cleanupWarning}\n`);
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
return status;
|
|
420
|
+
}
|