superpowers-manager 0.1.6 → 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 +184 -64
- package/bin/superpowers-manager.js +9 -237
- 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 -598
- package/scripts/adapters/codex/lib.sh +0 -391
- package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
- package/scripts/core/adapter.sh +0 -92
- package/scripts/core/common.sh +0 -35
- package/scripts/core/lifecycle.sh +0 -127
- package/scripts/core/provenance.sh +0 -135
- package/scripts/core/status.sh +0 -27
- package/scripts/core/upstream.sh +0 -146
- package/scripts/core/validate-adapter-response.py +0 -245
- package/scripts/install +0 -60
- package/scripts/prepare +0 -126
- package/scripts/probe +0 -61
- package/scripts/uninstall +0 -44
- package/scripts/update +0 -31
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { cp, mkdir, rm, 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 { readManifest } from "../hooks.js";
|
|
8
|
+
import { writeProvenance } from "../provenance.js";
|
|
9
|
+
import { SafetyError } from "../safety-error.js";
|
|
10
|
+
import { manifestVersionForRef } from "../upstream-version.js";
|
|
11
|
+
import { fetchExactCommit, gitSafeSource } from "../upstream.js";
|
|
12
|
+
import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
|
|
13
|
+
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
14
|
+
import { replayOutcome } from "./probe.js";
|
|
15
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:64-67::spw_require_upstream_path "$cache/skills`, via spw_require_upstream_path. Order is the shell's;
|
|
16
|
+
// the first miss wins.
|
|
17
|
+
const REQUIRED_UPSTREAM = [
|
|
18
|
+
{ path: "skills", label: "skills/" },
|
|
19
|
+
{ path: "LICENSE", label: "LICENSE" },
|
|
20
|
+
{ path: "README.md", label: "README.md" },
|
|
21
|
+
{ path: "CODE_OF_CONDUCT.md", label: "CODE_OF_CONDUCT.md" },
|
|
22
|
+
];
|
|
23
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:73-77::spw_copy_path_if_present "$cache/skills`. Same names on both sides.
|
|
24
|
+
const COPY_PATHS = [
|
|
25
|
+
"skills",
|
|
26
|
+
"assets",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md",
|
|
29
|
+
"CODE_OF_CONDUCT.md",
|
|
30
|
+
];
|
|
31
|
+
const RESOLUTION_KINDS = [
|
|
32
|
+
"latest-release",
|
|
33
|
+
"tag",
|
|
34
|
+
"ref",
|
|
35
|
+
"raw-commit",
|
|
36
|
+
];
|
|
37
|
+
// Every message this module writes is hand-written here. The cause is attached
|
|
38
|
+
// for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
|
|
39
|
+
// reads .message only. Same arrangement as hookError
|
|
40
|
+
// (`src/hooks.ts:44::function hookError`).
|
|
41
|
+
function prepareError(message, cause) {
|
|
42
|
+
return new SafetyError("prepare", message, { cause });
|
|
43
|
+
}
|
|
44
|
+
// `[ -e ]` — follows symlinks, so a dangling link is absent to the shell too.
|
|
45
|
+
// Two call sites, each mirroring a distinct `-e` in the shell: the
|
|
46
|
+
// REQUIRED_UPSTREAM loop
|
|
47
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:53-59::spw_require_upstream_path`
|
|
48
|
+
// — e.g. skills/ is a directory, not a regular file) and copyPathIfPresent's
|
|
49
|
+
// guard (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`). The `.git`
|
|
50
|
+
// check below is NOT a third site: it needs `-d` (F1), not `-e`, and uses
|
|
51
|
+
// directoryExists instead.
|
|
52
|
+
async function pathExists(path) {
|
|
53
|
+
try {
|
|
54
|
+
await stat(path);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing`, :80, and :108 all use -f, and
|
|
62
|
+
// tests/baseline/cli-parity.test.js's "CLI-ENV-MANIFEST-TEMPLATE-01 fallback
|
|
63
|
+
// template bytes and non-file rejection" asserts a DIRECTORY passed as
|
|
64
|
+
// SUPERPOWERS_MANIFEST_TEMPLATE is rejected before any adapter build. A
|
|
65
|
+
// stat-only predicate would accept it.
|
|
66
|
+
async function regularFileExists(path) {
|
|
67
|
+
try {
|
|
68
|
+
return (await stat(path)).isFile();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
|
|
75
|
+
// worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
|
|
76
|
+
// fetch branch and let git follow its `gitdir:` pointer, where the shell took
|
|
77
|
+
// the clone branch. `src/upstream.ts:332::if (!(await isDirectory` makes the
|
|
78
|
+
// same distinction.
|
|
79
|
+
async function directoryExists(path) {
|
|
80
|
+
try {
|
|
81
|
+
return (await stat(path)).isDirectory();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// mkdir, cp, and rm throw raw ErrnoExceptions. Every prepare-owned call goes
|
|
88
|
+
// through here so the message on the stream is this module's, not Node's.
|
|
89
|
+
async function owned(message, fn) {
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
throw prepareError(message, cause);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`.
|
|
98
|
+
// `cp -R` copies symlinks AS symlinks;
|
|
99
|
+
// fs.cp's default rewrites relative link targets against the destination,
|
|
100
|
+
// which would change what the adapter's containment checks see.
|
|
101
|
+
async function copyPathIfPresent(source, destination) {
|
|
102
|
+
if (!(await pathExists(source)))
|
|
103
|
+
return;
|
|
104
|
+
await owned(`cannot clear candidate path: ${destination}`, () => rm(destination, { recursive: true, force: true }));
|
|
105
|
+
await owned(`cannot copy upstream path into candidate: ${source}`, () => cp(source, destination, { recursive: true, verbatimSymlinks: true }));
|
|
106
|
+
}
|
|
107
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:17-24::case "$cache_parent`. Only cache_parent and plugin_root are resolved against
|
|
108
|
+
// the invocation cwd; manifest_template (:14) is not.
|
|
109
|
+
function resolveFromCwd(value, cwd) {
|
|
110
|
+
return isAbsolute(value) ? value : resolve(cwd, value);
|
|
111
|
+
}
|
|
112
|
+
// readManifest (`src/hooks.ts:113::readManifest`) owns the read and the parse completely: byte
|
|
113
|
+
// read so invalid UTF-8 is rejected rather than replaced, cause dropped, three
|
|
114
|
+
// hand-written messages naming the path, object check included. Its
|
|
115
|
+
// diagnostics are pinned by
|
|
116
|
+
// `tests/unit/hooks.test.js:108::void test("readManifest diagnostics`. This wrapper adds only
|
|
117
|
+
// the `version` type check.
|
|
118
|
+
export async function readUpstreamManifestVersion(path) {
|
|
119
|
+
const manifest = await readManifest(path);
|
|
120
|
+
const value = manifest.version;
|
|
121
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:56-62::for part in dotted_key.split`
|
|
122
|
+
// — absent key and JSON null both yield "".
|
|
123
|
+
if (value === undefined || value === null)
|
|
124
|
+
return "";
|
|
125
|
+
if (typeof value !== "string") {
|
|
126
|
+
// The shell stringified any other type through Python's print()
|
|
127
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`), so `"version": 6` became "6" and flowed into both the
|
|
128
|
+
// provenance record and --upstream-manifest-version. Fail closed instead.
|
|
129
|
+
// Spec divergence 7.
|
|
130
|
+
throw prepareError(`upstream manifest version is not a string: ${path}`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
function asResolutionKind(value) {
|
|
135
|
+
for (const kind of RESOLUTION_KINDS) {
|
|
136
|
+
if (kind === value)
|
|
137
|
+
return kind;
|
|
138
|
+
}
|
|
139
|
+
throw prepareError(`unknown upstream resolution kind: ${value}`);
|
|
140
|
+
}
|
|
141
|
+
// Each stream's marker goes to that stream's own destination, so a reader of either
|
|
142
|
+
// sees its own truncation and neither is silently short.
|
|
143
|
+
function withTruncationMarker(captured, stream) {
|
|
144
|
+
if (captured.droppedBytes === 0)
|
|
145
|
+
return captured.text;
|
|
146
|
+
return `${captured.text}\n[superpowers-manager: validator ${stream} truncated, ${captured.droppedBytes} bytes dropped]\n`;
|
|
147
|
+
}
|
|
148
|
+
// D8's disclosure. The variable is ambient process env and nothing filters it, so a
|
|
149
|
+
// consumer's CI can set it; naming what actually ran is what makes a supply-chain
|
|
150
|
+
// surprise visible. Paths go through displayPath, so a control character in either
|
|
151
|
+
// one cannot reach the terminal raw.
|
|
152
|
+
function disclosureLine(resolution) {
|
|
153
|
+
const configured = displayPath(resolution.configured);
|
|
154
|
+
if (resolution.resolved === null) {
|
|
155
|
+
// The manager does not know the final target and does not pretend to. But a
|
|
156
|
+
// dangling symlink is a KNOWN fact even when resolution fails, and saying "the
|
|
157
|
+
// OS selects it" about a path-like value would be wrong.
|
|
158
|
+
if (resolution.isSymlink) {
|
|
159
|
+
return `[superpowers-manager: running external validator ${configured} (a symlink whose target could not be resolved)]\n`;
|
|
160
|
+
}
|
|
161
|
+
if (configured.includes(sep)) {
|
|
162
|
+
return `[superpowers-manager: running external validator ${configured} (unresolved)]\n`;
|
|
163
|
+
}
|
|
164
|
+
return `[superpowers-manager: running external validator ${configured} (a bare name; PATH selects the file, and the manager does not guess which)]\n`;
|
|
165
|
+
}
|
|
166
|
+
const via = resolution.isSymlink ? " via symlink" : "";
|
|
167
|
+
return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
|
|
168
|
+
}
|
|
169
|
+
const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
|
|
170
|
+
async function gatherPrepare(ctx) {
|
|
171
|
+
const env = ctx.env;
|
|
172
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
|
|
173
|
+
// getcwd(3) returns the physical path, so this matches `pwd -P` without a
|
|
174
|
+
// realpath call.
|
|
175
|
+
const cwd = process.cwd();
|
|
176
|
+
const cacheParent = resolveFromCwd(env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream"), cwd);
|
|
177
|
+
const pluginRoot = resolveFromCwd(env.SUPERPOWERS_PLUGIN_ROOT || join(ctx.root, "plugins", "superpowers"), cwd);
|
|
178
|
+
const manifestTemplate = env.SUPERPOWERS_MANIFEST_TEMPLATE ||
|
|
179
|
+
join(ctx.root, "plugins", "superpowers", ".codex-plugin", "plugin.template.json");
|
|
180
|
+
const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
|
|
181
|
+
const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
|
|
182
|
+
const cache = join(cacheParent, "superpowers");
|
|
183
|
+
const tmpParent = dirname(pluginRoot);
|
|
184
|
+
await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
|
|
185
|
+
let cleanupWarning = null;
|
|
186
|
+
const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
|
|
187
|
+
const failed = (message) => ({
|
|
188
|
+
kind: "failed",
|
|
189
|
+
outcomes: [],
|
|
190
|
+
validator: NO_VALIDATOR_OUTPUT,
|
|
191
|
+
message,
|
|
192
|
+
});
|
|
193
|
+
const candidate = join(workspace, "superpowers");
|
|
194
|
+
const selection = await computeEffectiveSelection(ctx.root, env);
|
|
195
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing` — `[ -f ]`.
|
|
196
|
+
if (!(await regularFileExists(manifestTemplate))) {
|
|
197
|
+
return failed(`missing fallback manifest template: ${manifestTemplate}`);
|
|
198
|
+
}
|
|
199
|
+
await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
|
|
200
|
+
if (selection.selectionMode === "pinned") {
|
|
201
|
+
await fetchExactCommit(selection.effectiveSource, selection.desiredCommit, cache, workspace);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const source = gitSafeSource(selection.effectiveSource);
|
|
205
|
+
if (await directoryExists(join(cache, ".git"))) {
|
|
206
|
+
const fetched = await runGit([
|
|
207
|
+
"-C",
|
|
208
|
+
cache,
|
|
209
|
+
"fetch",
|
|
210
|
+
"--tags",
|
|
211
|
+
"--prune",
|
|
212
|
+
"--",
|
|
213
|
+
source,
|
|
214
|
+
]);
|
|
215
|
+
// runGit returns its status rather than throwing, so no git output is
|
|
216
|
+
// in scope. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:52::spw_die "cannot fetch` names the source and nothing else, and
|
|
217
|
+
// computeEffectiveSelection ran validateSource first, so a
|
|
218
|
+
// credential-bearing source never reaches here.
|
|
219
|
+
if (fetched.status !== 0) {
|
|
220
|
+
return failed(`cannot fetch upstream repo: ${selection.effectiveSource}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const cloned = await runGit(["clone", "--", source, cache]);
|
|
225
|
+
if (cloned.status !== 0) {
|
|
226
|
+
return failed(`cannot clone upstream repo: ${selection.effectiveSource}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const checkedOut = await runGit([
|
|
231
|
+
"-C",
|
|
232
|
+
cache,
|
|
233
|
+
"checkout",
|
|
234
|
+
"--detach",
|
|
235
|
+
selection.desiredCommit,
|
|
236
|
+
]);
|
|
237
|
+
if (checkedOut.status !== 0) {
|
|
238
|
+
return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
|
|
239
|
+
}
|
|
240
|
+
for (const required of REQUIRED_UPSTREAM) {
|
|
241
|
+
// spw_require_upstream_path is `[ -e ]`, not `[ -f ]`: skills/ is a
|
|
242
|
+
// directory.
|
|
243
|
+
if (!(await pathExists(join(cache, required.path)))) {
|
|
244
|
+
return failed(`required upstream path missing: ${required.label}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await owned(`cannot clear candidate root: ${candidate}`, () => rm(candidate, { recursive: true, force: true }));
|
|
248
|
+
await owned(`cannot create candidate root: ${candidate}`, () => mkdir(join(candidate, ".codex-plugin"), { recursive: true }));
|
|
249
|
+
for (const name of COPY_PATHS) {
|
|
250
|
+
await copyPathIfPresent(join(cache, name), join(candidate, name));
|
|
251
|
+
}
|
|
252
|
+
const upstreamManifest = join(cache, ".codex-plugin", "plugin.json");
|
|
253
|
+
let upstreamManifestVersion = "";
|
|
254
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:80::if [ -f` — `[ -f ]`.
|
|
255
|
+
if (await regularFileExists(upstreamManifest)) {
|
|
256
|
+
upstreamManifestVersion =
|
|
257
|
+
await readUpstreamManifestVersion(upstreamManifest);
|
|
258
|
+
}
|
|
259
|
+
await writeProvenance(join(candidate, ".superpowers-upstream.json"), {
|
|
260
|
+
source: selection.effectiveSource,
|
|
261
|
+
requested_ref: selection.requestedRef,
|
|
262
|
+
resolved_ref: selection.resolvedRef,
|
|
263
|
+
commit: selection.desiredCommit,
|
|
264
|
+
upstream_manifest_version: upstreamManifestVersion,
|
|
265
|
+
});
|
|
266
|
+
const managerVersion = manifestVersionForRef({
|
|
267
|
+
requestedRef: selection.requestedRef,
|
|
268
|
+
resolutionKind: asResolutionKind(selection.resolutionKind),
|
|
269
|
+
resolvedRef: selection.resolvedRef,
|
|
270
|
+
commit: selection.desiredCommit,
|
|
271
|
+
});
|
|
272
|
+
let built;
|
|
273
|
+
try {
|
|
274
|
+
built = await ctx.adapter([
|
|
275
|
+
"build",
|
|
276
|
+
"--upstream-root",
|
|
277
|
+
cache,
|
|
278
|
+
"--candidate-root",
|
|
279
|
+
candidate,
|
|
280
|
+
"--requested-ref",
|
|
281
|
+
selection.requestedRef,
|
|
282
|
+
"--resolved-ref",
|
|
283
|
+
selection.resolvedRef,
|
|
284
|
+
"--commit",
|
|
285
|
+
selection.desiredCommit,
|
|
286
|
+
"--manager-version",
|
|
287
|
+
managerVersion,
|
|
288
|
+
"--upstream-manifest-version",
|
|
289
|
+
upstreamManifestVersion,
|
|
290
|
+
"--fallback-manifest",
|
|
291
|
+
manifestTemplate,
|
|
292
|
+
], { root: ctx.root, env });
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// ctx.adapter reports CONTROLLED failures by return value
|
|
296
|
+
// (`src/adapter-result.ts:32-35::export interface AdapterResult`) but still THROWS for a
|
|
297
|
+
// non-AdapterFailure cause (runAdapter's closing `throw cause`,
|
|
298
|
+
// src/adapter.ts). That cause is by construction the one failure
|
|
299
|
+
// src/adapter.ts declined to own, so its text must never reach
|
|
300
|
+
// ctx.stderr. Caught here rather than in runPrepare's outer catch,
|
|
301
|
+
// the same treatment
|
|
302
|
+
// `src/commands/probe.ts:205-228::It does still THROW for a non-AdapterFailure cause`
|
|
303
|
+
// gives it.
|
|
304
|
+
return failed("cannot build the generated plugin candidate");
|
|
305
|
+
}
|
|
306
|
+
const outcomes = [built.outcome];
|
|
307
|
+
if (built.status !== 0 || !built.outcome.ok) {
|
|
308
|
+
return {
|
|
309
|
+
kind: "failed",
|
|
310
|
+
outcomes,
|
|
311
|
+
validator: NO_VALIDATOR_OUTPUT,
|
|
312
|
+
// replayOutcome emits the adapter's own error and hints; a second
|
|
313
|
+
// line here would duplicate them. The `ok && status !== 0`
|
|
314
|
+
// combination cannot arise from successResult/failureResult, so it
|
|
315
|
+
// gets its own hand-written message rather than a silent replay.
|
|
316
|
+
message: built.outcome.ok
|
|
317
|
+
? "adapter reported failure without an error outcome"
|
|
318
|
+
: null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
let validator = NO_VALIDATOR_OUTPUT;
|
|
322
|
+
if (additionalValidator.length > 0) {
|
|
323
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` — `[ -f ]`.
|
|
324
|
+
if (!(await regularFileExists(additionalValidator))) {
|
|
325
|
+
return {
|
|
326
|
+
kind: "failed",
|
|
327
|
+
outcomes,
|
|
328
|
+
validator,
|
|
329
|
+
message: `additional plugin validator not found: ${additionalValidator}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const ran = await runValidator(["python3", additionalValidator, candidate], UNBOUNDED_LEGACY, env, workspace);
|
|
333
|
+
if (ran.kind === "launchFailed") {
|
|
334
|
+
// PARITY, and it must stay a THROW. The legacy path rejects the workspace
|
|
335
|
+
// callback on a spawn failure; withWorkspace returns the callback error
|
|
336
|
+
// and the `outcomes` collected above are LOST. That loss is a recorded,
|
|
337
|
+
// deliberately unassigned defect. Returning a failed outcome here would
|
|
338
|
+
// replay those outcomes instead -- observably different control flow for
|
|
339
|
+
// CLI-ENV-VALIDATOR-01, which this PR is not authorized to change.
|
|
340
|
+
throw prepareError(`cannot execute additional plugin validator: ${additionalValidator}`, ran.cause);
|
|
341
|
+
}
|
|
342
|
+
validator = { stdout: ran.stdout.text, stderr: ran.stderr.text };
|
|
343
|
+
if (ran.kind !== "exited" || ran.code !== 0) {
|
|
344
|
+
return {
|
|
345
|
+
kind: "failed",
|
|
346
|
+
outcomes,
|
|
347
|
+
validator,
|
|
348
|
+
message: "additional plugin validation failed",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (executableValidator.length > 0) {
|
|
353
|
+
const resolution = await resolveValidator(executableValidator);
|
|
354
|
+
const ran = await runValidator([executableValidator, candidate], BOUNDED_EXECUTABLE, env, workspace);
|
|
355
|
+
// The disclosure lands BEFORE any early return, so a launch failure still
|
|
356
|
+
// reports what the manager tried to run and what it resolved to — exactly
|
|
357
|
+
// the path where an operator most needs it. It is assigned twice rather
|
|
358
|
+
// than once because `launchFailed` carries no captured streams: there was
|
|
359
|
+
// no process to capture from.
|
|
360
|
+
validator = { stdout: disclosureLine(resolution), stderr: "" };
|
|
361
|
+
if (ran.kind === "launchFailed") {
|
|
362
|
+
return {
|
|
363
|
+
kind: "failed",
|
|
364
|
+
outcomes,
|
|
365
|
+
validator,
|
|
366
|
+
message: launchFailureMessage(ran.errno, resolution),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
validator = {
|
|
370
|
+
stdout: disclosureLine(resolution) +
|
|
371
|
+
withTruncationMarker(ran.stdout, "stdout"),
|
|
372
|
+
stderr: withTruncationMarker(ran.stderr, "stderr"),
|
|
373
|
+
};
|
|
374
|
+
if (ran.kind === "timedOut") {
|
|
375
|
+
return {
|
|
376
|
+
kind: "failed",
|
|
377
|
+
outcomes,
|
|
378
|
+
validator,
|
|
379
|
+
message: `external plugin validation timed out after ${Math.round(ran.afterMs / 1000)}s`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (ran.code !== 0) {
|
|
383
|
+
return {
|
|
384
|
+
kind: "failed",
|
|
385
|
+
outcomes,
|
|
386
|
+
validator,
|
|
387
|
+
message: "external plugin validation failed",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// The swap must run inside the workspace callback: withWorkspace removes
|
|
392
|
+
// the workspace on return, and the candidate lives in it.
|
|
393
|
+
//
|
|
394
|
+
// atomicReplaceDir's outer catch (`src/atomic.ts:208-215::if (cause`) wraps every
|
|
395
|
+
// non-SafetyError into a SafetyError, so the callee owns every failure on
|
|
396
|
+
// this path and re-emitting its own diagnostic is the sanctioned form of
|
|
397
|
+
// interpolation. The hand-written prefix carries the live root, which the
|
|
398
|
+
// callee's message does not.
|
|
399
|
+
try {
|
|
400
|
+
await atomicReplaceDir(candidate, pluginRoot);
|
|
401
|
+
}
|
|
402
|
+
catch (cause) {
|
|
403
|
+
return {
|
|
404
|
+
kind: "failed",
|
|
405
|
+
outcomes,
|
|
406
|
+
validator,
|
|
407
|
+
message: `cannot install generated tree into ${pluginRoot}: ${oneLine(cause)}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
kind: "ok",
|
|
412
|
+
outcomes,
|
|
413
|
+
validator,
|
|
414
|
+
resolvedRef: selection.resolvedRef,
|
|
415
|
+
commit: selection.desiredCommit,
|
|
416
|
+
};
|
|
417
|
+
}, {
|
|
418
|
+
// Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure, so
|
|
419
|
+
// the PrepareOutcome the callback already computed still reaches
|
|
420
|
+
// runPrepare instead of being discarded. The reporter runs synchronously,
|
|
421
|
+
// as the option requires (`src/workspace.ts:103-106::Must`).
|
|
422
|
+
onCleanupFailure: (path) => {
|
|
423
|
+
cleanupWarning = workspaceRemovalFailure(path);
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
return { outcome, cleanupWarning };
|
|
427
|
+
}
|
|
428
|
+
export async function runPrepare(argv, ctx) {
|
|
429
|
+
// scripts/prepare never reads "$@", so extra arguments are ignored. This is a
|
|
430
|
+
// deliberate asymmetry with probe, whose shell original rejected unknown
|
|
431
|
+
// arguments and whose arity therefore moved into parseArgs in slice 2.
|
|
432
|
+
void argv;
|
|
433
|
+
let run;
|
|
434
|
+
try {
|
|
435
|
+
run = await gatherPrepare(ctx);
|
|
436
|
+
}
|
|
437
|
+
catch (cause) {
|
|
438
|
+
// Hand-written messages, per AGENTS.md's reader-diagnostics rule.
|
|
439
|
+
// Reachable here: prepareError(), from this module's owned() wrappers,
|
|
440
|
+
// its two manifest-version checks, asResolutionKind, and the
|
|
441
|
+
// additional-validator branch's own throw on a legacy-validator launch
|
|
442
|
+
// failure (its cause is the shared runner's (src/validator.ts) captured
|
|
443
|
+
// spawn error, which oneLine never reads -- it takes .message only);
|
|
444
|
+
// readManifest's three hookError messages
|
|
445
|
+
// (`src/hooks.ts:113-138::readManifest`), pinned by
|
|
446
|
+
// `tests/unit/hooks.test.js:108::void test("readManifest diagnostics` as carrying no reader vocabulary or
|
|
447
|
+
// errno; and SafetyErrors from gitSafeSource, writeProvenance, and
|
|
448
|
+
// withWorkspace.
|
|
449
|
+
//
|
|
450
|
+
// FOUR exceptions, all inherited and none a regression:
|
|
451
|
+
// 1. resolveRef splices git's combined stdout+stderr into its own text
|
|
452
|
+
// on the NON-PINNED path (src/upstream.ts), reached via
|
|
453
|
+
// computeEffectiveSelection (src/effective-selection.ts).
|
|
454
|
+
// This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
|
|
455
|
+
// and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
|
|
456
|
+
// by
|
|
457
|
+
// `tests/unit/upstream.test.js:460-469::void test("resolveRef reports a query failure for latest`,
|
|
458
|
+
// :471-481, and :483-501.
|
|
459
|
+
// 2. fetchExactCommit splices the same combined stdout+stderr into its
|
|
460
|
+
// own text on the PINNED path (both of its own splice sites in
|
|
461
|
+
// src/upstream.ts, and proveCommit's, which it calls). This is the
|
|
462
|
+
// rarer of the two raw-git-output paths, not the only one.
|
|
463
|
+
// 3. `src/selection-store.ts:120-124::cause.module === "selection") {`
|
|
464
|
+
// (same shape at :49, :86, :98) is the
|
|
465
|
+
// module AGENTS.md's `src/selection-store.ts` bullet grandfathers:
|
|
466
|
+
// it interpolates the caught error's own message, so Node errno
|
|
467
|
+
// prose can reach this stream -- sanctioned, nothing here needs
|
|
468
|
+
// fixing.
|
|
469
|
+
// 4. Every runGit call site in this module (fetch, clone, checkout) can
|
|
470
|
+
// reject instead of resolving: `src/git.ts:47-52::reject(new SafetyError` wraps every string
|
|
471
|
+
// errno other than ENOENT in
|
|
472
|
+
// `new SafetyError("git", \`cannot run git: ${failure.message}\`)`,
|
|
473
|
+
// and that Node spawn-level message reaches ctx.stderr through this
|
|
474
|
+
// catch. So the claim that the non-pinned clone/fetch/checkout
|
|
475
|
+
// branch names only the source is true for a non-zero *exit status*
|
|
476
|
+
// (handled explicitly below, in gatherPrepare) but false for a
|
|
477
|
+
// *spawn-level* failure -- runGit throws rather than returning a
|
|
478
|
+
// status in that case, and this outer catch is what stands between
|
|
479
|
+
// it and the stream.
|
|
480
|
+
//
|
|
481
|
+
// oneLine() at this catch collapses each of exceptions 1, 2, and 4 -- the
|
|
482
|
+
// three carrying git-derived text -- to a single line. It collapses CR/LF
|
|
483
|
+
// only, so it bounds how much of that text lands, not what it may
|
|
484
|
+
// contain.
|
|
485
|
+
//
|
|
486
|
+
// runAdapter's closing `throw cause` (src/adapter.ts) does NOT arrive
|
|
487
|
+
// here -- the call site catches it and converts it to a hand-written
|
|
488
|
+
// message per AGENTS.md's reader-diagnostics rule.
|
|
489
|
+
//
|
|
490
|
+
// gatherPrepare performs no writes of its own, so this catch cannot also be
|
|
491
|
+
// reached by an EPIPE from prepare's own output: every write below runs
|
|
492
|
+
// only after this try/catch has resolved.
|
|
493
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
const { outcome, cleanupWarning } = run;
|
|
497
|
+
for (const each of outcome.outcomes)
|
|
498
|
+
replayOutcome(each, ctx);
|
|
499
|
+
if (outcome.validator.stdout.length > 0) {
|
|
500
|
+
ctx.stdout.write(outcome.validator.stdout);
|
|
501
|
+
}
|
|
502
|
+
if (outcome.validator.stderr.length > 0) {
|
|
503
|
+
ctx.stderr.write(outcome.validator.stderr);
|
|
504
|
+
}
|
|
505
|
+
let status;
|
|
506
|
+
if (outcome.kind === "failed") {
|
|
507
|
+
if (outcome.message !== null) {
|
|
508
|
+
ctx.stderr.write(`error: ${outcome.message}\n`);
|
|
509
|
+
}
|
|
510
|
+
status = 1;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
ctx.stdout.write(`prepared ${outcome.resolvedRef} at ${outcome.commit}\n`);
|
|
514
|
+
status = 0;
|
|
515
|
+
}
|
|
516
|
+
if (cleanupWarning !== null) {
|
|
517
|
+
// A leaked workspace is reported even when the outcome above was itself a
|
|
518
|
+
// success: the generated-tree replacement that produced it already
|
|
519
|
+
// completed before cleanup ran, so it is not being reported as unverified
|
|
520
|
+
// -- but something did still go wrong, and AGENTS.md's fail-closed rule
|
|
521
|
+
// extends to it. Mirrors
|
|
522
|
+
// `src/commands/install.ts:503-510::if (cleanupWarning`.
|
|
523
|
+
ctx.stderr.write(`error: ${cleanupWarning}\n`);
|
|
524
|
+
return 1;
|
|
525
|
+
}
|
|
526
|
+
return status;
|
|
527
|
+
}
|