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,317 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { sep } from "node:path";
|
|
4
|
+
import { hasTerminalControl, pythonUnicodeEscapeBytes, } from "./adapter-result.js";
|
|
5
|
+
// The legacy SUPERPOWERS_VALIDATOR path. Its behaviour is frozen by parity, so it
|
|
6
|
+
// carries no limits at all; see the spec's D2. Bounding it is PR 12's row.
|
|
7
|
+
export const UNBOUNDED_LEGACY = { kind: "unbounded" };
|
|
8
|
+
// SUPERPOWERS_VALIDATOR_EXECUTABLE. 30s is ~270x a measured realistic validator run
|
|
9
|
+
// (0.11s, dominated by interpreter startup); 64 KiB per stream clears a
|
|
10
|
+
// one-diagnostic-line-per-file report (30 KB on a real tree) with headroom.
|
|
11
|
+
export const BOUNDED_EXECUTABLE = {
|
|
12
|
+
kind: "bounded",
|
|
13
|
+
timeoutMs: 30_000,
|
|
14
|
+
graceMs: 2_000,
|
|
15
|
+
drainMs: 200,
|
|
16
|
+
maxBytesPerStream: 64 * 1024,
|
|
17
|
+
};
|
|
18
|
+
// Retains the first `limit` bytes and counts the rest. A null limit retains
|
|
19
|
+
// everything, which is the unbounded policy's contract. push() always consumes the
|
|
20
|
+
// whole chunk: ceasing to consume would block the child on a full pipe.
|
|
21
|
+
class Sink {
|
|
22
|
+
limit;
|
|
23
|
+
chunks = [];
|
|
24
|
+
kept = 0;
|
|
25
|
+
dropped = 0;
|
|
26
|
+
constructor(limit) {
|
|
27
|
+
this.limit = limit;
|
|
28
|
+
}
|
|
29
|
+
push(chunk) {
|
|
30
|
+
if (this.limit === null) {
|
|
31
|
+
this.chunks.push(chunk);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const room = this.limit - this.kept;
|
|
35
|
+
if (room <= 0) {
|
|
36
|
+
this.dropped += chunk.length;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const take = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
40
|
+
this.chunks.push(take);
|
|
41
|
+
this.kept += take.length;
|
|
42
|
+
this.dropped += chunk.length - take.length;
|
|
43
|
+
}
|
|
44
|
+
done() {
|
|
45
|
+
// Buffer.toString, NOT TextDecoder: TextDecoder strips a leading BOM and the
|
|
46
|
+
// legacy path's current string accumulator preserves it. Measured:
|
|
47
|
+
// decode(<BOM>hi) is "hi" but toString("utf8") is "\ufeffhi". The legacy path
|
|
48
|
+
// must be byte-identical, so BOTH paths use toString.
|
|
49
|
+
return {
|
|
50
|
+
text: Buffer.concat(this.chunks).toString("utf8"),
|
|
51
|
+
droppedBytes: this.dropped,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Signal the child's whole process group. Only meaningful for a child spawned
|
|
56
|
+
// under the BOUNDED policy: that spawn passes `detached: true`, so the child
|
|
57
|
+
// leads a group of its own and -pid reaches its descendants. The LEGACY path
|
|
58
|
+
// spawns without `detached`, so its child does not lead a group and this
|
|
59
|
+
// function is not for it. Wrapped because the group is already gone in the
|
|
60
|
+
// ordinary race between SIGTERM and SIGKILL.
|
|
61
|
+
function signalGroup(child, signal) {
|
|
62
|
+
if (child.pid === undefined)
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
process.kill(-child.pid, signal);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* the group exited between the timer firing and this call */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function runValidator(argv, policy, env, workspace) {
|
|
72
|
+
return new Promise((resolveRun) => {
|
|
73
|
+
const [command, ...args] = argv;
|
|
74
|
+
const limit = policy.kind === "bounded" ? policy.maxBytesPerStream : null;
|
|
75
|
+
const out = new Sink(limit);
|
|
76
|
+
const err = new Sink(limit);
|
|
77
|
+
let settled = false;
|
|
78
|
+
// Settlement and termination are independent BY CONSTRUCTION, which is why
|
|
79
|
+
// there is no grace-timer handle to clear here: the timedOut settle is nested
|
|
80
|
+
// inside the SIGKILL callback, so on the timeout path the promise cannot
|
|
81
|
+
// resolve before the escalation has run. That nesting is the fix for an
|
|
82
|
+
// earlier revision which settled first and left the SIGKILL on a cancellable
|
|
83
|
+
// timer -- the historical shape the `survivor.sh` case in the unit suite
|
|
84
|
+
// exists to catch. Only the timeout timer is cleared here, and that clear is
|
|
85
|
+
// load-bearing rather than belt-and-braces: on a clean exit the `exit` handler
|
|
86
|
+
// has already disarmed it, and on the timeout path it has already fired -- but
|
|
87
|
+
// on the BOUNDED LAUNCH-FAILURE path neither happens. Node emits no `exit`
|
|
88
|
+
// event for an asynchronously failing spawn (an ENOENT spawn yields `error`
|
|
89
|
+
// then `close` with -2 and nothing else), so this is the only thing that
|
|
90
|
+
// disarms the timer for it.
|
|
91
|
+
const settle = (run) => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
if (timeoutTimer !== undefined)
|
|
96
|
+
clearTimeout(timeoutTimer);
|
|
97
|
+
resolveRun(run);
|
|
98
|
+
};
|
|
99
|
+
let timeoutTimer;
|
|
100
|
+
let timedOut = false;
|
|
101
|
+
let lastCode = null;
|
|
102
|
+
// No timer is unref'ed. The escalation must actually run, and an unref'ed timer
|
|
103
|
+
// does not keep the event loop alive.
|
|
104
|
+
// On darwin, ENOEXEC is delivered as a SYNCHRONOUS throw here rather than on
|
|
105
|
+
// the `error` event, so a handler-only implementation would miss it. On Linux
|
|
106
|
+
// this branch is not reached for ENOEXEC at all: POSIX execvp falls back to
|
|
107
|
+
// /bin/sh on ENOEXEC, so the spawn itself succeeds. Because the throw happens
|
|
108
|
+
// inside this Promise executor, it rejects runValidator's returned promise --
|
|
109
|
+
// not an unhandled rejection -- which is what this try/catch prevents.
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = spawn(command, args, {
|
|
113
|
+
env: { ...process.env, ...env, TMPDIR: workspace },
|
|
114
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
115
|
+
// D4: under the BOUNDED policy only, the child leads its own process group
|
|
116
|
+
// so a timeout can signal the GROUP -- signalling the process alone leaves
|
|
117
|
+
// descendants holding the inherited pipes, and `close` then waits for them
|
|
118
|
+
// (measured at 5279 ms against a 300 ms timeout). The LEGACY path must not
|
|
119
|
+
// acquire group leadership: that changes process and session semantics for
|
|
120
|
+
// a path whose whole contract is that it is unchanged.
|
|
121
|
+
detached: policy.kind === "bounded",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
settle({
|
|
126
|
+
kind: "launchFailed",
|
|
127
|
+
errno: cause.code ?? "UNKNOWN",
|
|
128
|
+
cause,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// No setEncoding: the cap counts BYTES, and a string accumulator would count
|
|
133
|
+
// UTF-16 code units instead.
|
|
134
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
135
|
+
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
136
|
+
// Both error and close fire on a launch failure, so settle() is idempotent.
|
|
137
|
+
child.on("error", (cause) => {
|
|
138
|
+
settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN", cause });
|
|
139
|
+
});
|
|
140
|
+
child.on("close", (code) => {
|
|
141
|
+
// On the timeout path, settling here would resolve BEFORE SIGKILL and the
|
|
142
|
+
// escalation would never complete. That path settles itself, after the kill.
|
|
143
|
+
if (timedOut)
|
|
144
|
+
return;
|
|
145
|
+
settle({ kind: "exited", code, stdout: out.done(), stderr: err.done() });
|
|
146
|
+
});
|
|
147
|
+
// Settling on `close` alone is not a wall-clock bound: `close` fires only when
|
|
148
|
+
// every writer has released the pipes, and a descendant that outlives the child
|
|
149
|
+
// holds them. Settle on whichever comes first -- `close`, or the child's `exit`
|
|
150
|
+
// plus a bounded drain for trailing output.
|
|
151
|
+
child.on("exit", (code) => {
|
|
152
|
+
// `exit` carries the status and fires as soon as the CHILD is gone; `close`
|
|
153
|
+
// may lag it indefinitely. Capture the code here or a successful validator
|
|
154
|
+
// whose descendant lingers would settle with a null code and be rejected.
|
|
155
|
+
lastCode = code;
|
|
156
|
+
// BOUNDED ONLY. The legacy path settles on `close`, exactly as it does today:
|
|
157
|
+
// a validator that backgrounds `(sleep 0.6; echo late) &` has "late" captured
|
|
158
|
+
// under `close` and lost under a 200 ms drain, and that output is captured
|
|
159
|
+
// today.
|
|
160
|
+
if (policy.kind !== "bounded")
|
|
161
|
+
return;
|
|
162
|
+
// The TIMEOUT path owns its own settlement, after the escalation has run.
|
|
163
|
+
if (timedOut)
|
|
164
|
+
return;
|
|
165
|
+
// The drain settle below is now the one that will resolve this run, so the
|
|
166
|
+
// timeout must not fire behind it. A child exiting inside
|
|
167
|
+
// [timeoutMs - drainMs, timeoutMs) already has its drain settle queued when
|
|
168
|
+
// the timeout comes due; without this the timeout would SIGTERM the group of
|
|
169
|
+
// a validator that had ALREADY exited cleanly, then leave the SIGKILL
|
|
170
|
+
// escalation pending behind a settle that reports `exited`. Race-free rather
|
|
171
|
+
// than merely unlikely: either the timeout has fired, in which case
|
|
172
|
+
// `timedOut` is true and we returned above, or it has not, and now it never
|
|
173
|
+
// will. The bound is unchanged -- settlement is exitTime + drainMs and
|
|
174
|
+
// exitTime < timeoutMs. Deliberately NOT paired with a `timedOut` re-check
|
|
175
|
+
// inside the drain callback: with the timer cleared that check is
|
|
176
|
+
// unreachable, and unfalsifiable defensive code is what this module keeps
|
|
177
|
+
// being bitten by.
|
|
178
|
+
clearTimeout(timeoutTimer);
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
settle({
|
|
181
|
+
kind: "exited",
|
|
182
|
+
code: lastCode,
|
|
183
|
+
stdout: out.done(),
|
|
184
|
+
stderr: err.done(),
|
|
185
|
+
});
|
|
186
|
+
}, policy.drainMs);
|
|
187
|
+
});
|
|
188
|
+
if (policy.kind === "bounded") {
|
|
189
|
+
timeoutTimer = setTimeout(() => {
|
|
190
|
+
timedOut = true;
|
|
191
|
+
// Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
|
|
192
|
+
// first, because the contract promises stderr carries the reason, and
|
|
193
|
+
// reading continues through the grace window so the child can print it.
|
|
194
|
+
signalGroup(child, "SIGTERM");
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
signalGroup(child, "SIGKILL");
|
|
197
|
+
// Settle ONLY after SIGKILL has been sent. An earlier revision settled
|
|
198
|
+
// first and left the escalation on an unref'ed timer, which never fires
|
|
199
|
+
// because the CLI exits as soon as its handler returns -- measured, the
|
|
200
|
+
// manager exited at 146 ms and the descendant survived. The wall-clock
|
|
201
|
+
// bound is therefore timeout + grace + drain, which is still a bound.
|
|
202
|
+
setTimeout(() => {
|
|
203
|
+
settle({
|
|
204
|
+
kind: "timedOut",
|
|
205
|
+
afterMs: policy.timeoutMs,
|
|
206
|
+
stdout: out.done(),
|
|
207
|
+
stderr: err.done(),
|
|
208
|
+
});
|
|
209
|
+
}, policy.drainMs);
|
|
210
|
+
}, policy.graceMs);
|
|
211
|
+
}, policy.timeoutMs);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// Every probe below is best-effort BY DESIGN: spawn is the authority on whether the
|
|
216
|
+
// path is runnable, and this result exists only for disclosure and for
|
|
217
|
+
// disambiguating EACCES between "a directory" and "not executable". A failed probe
|
|
218
|
+
// therefore degrades the MESSAGE, never the verdict — no probe result can admit a
|
|
219
|
+
// validator that spawn would have rejected.
|
|
220
|
+
export async function resolveValidator(configured) {
|
|
221
|
+
// Every probe is skipped for a bare name. lstat and stat are CWD-relative, so on
|
|
222
|
+
// a bare name they would describe a file in the current directory while spawn
|
|
223
|
+
// runs whatever PATH selects -- the same divergence that makes realpath unsafe
|
|
224
|
+
// here.
|
|
225
|
+
const pathLike = configured.includes(sep);
|
|
226
|
+
let isSymlink = false;
|
|
227
|
+
if (pathLike) {
|
|
228
|
+
try {
|
|
229
|
+
isSymlink = (await lstat(configured)).isSymbolicLink();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* spawn reports the real reason */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Resolve ONLY when the configured value contains a path separator -- which is
|
|
236
|
+
// exactly when spawn bypasses PATH and the two agree about the target. For a
|
|
237
|
+
// bare name, realpath resolves relative to the CWD while spawn searches PATH:
|
|
238
|
+
// measured from a directory containing `node`, realpath("node") gave one
|
|
239
|
+
// interpreter and spawn("node") ran another. Resolving a bare name would let the
|
|
240
|
+
// manager confidently disclose a file it is not about to run.
|
|
241
|
+
let resolved = null;
|
|
242
|
+
if (configured.includes(sep)) {
|
|
243
|
+
try {
|
|
244
|
+
resolved = await realpath(configured);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* unresolvable; stays null and is never claimed */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let isDirectory = false;
|
|
251
|
+
let exists = false;
|
|
252
|
+
if (pathLike) {
|
|
253
|
+
try {
|
|
254
|
+
const s = await stat(configured);
|
|
255
|
+
isDirectory = s.isDirectory();
|
|
256
|
+
exists = true;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* spawn reports the real reason */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { configured, resolved, isSymlink, isDirectory, exists };
|
|
263
|
+
}
|
|
264
|
+
// Escape only when there is something to escape. pythonUnicodeEscape retains only
|
|
265
|
+
// printable ASCII, so escaping unconditionally would render a legitimate non-ASCII
|
|
266
|
+
// path unrecognisable to the operator who configured it. This is a legibility
|
|
267
|
+
// measure, not a security control.
|
|
268
|
+
export function displayPath(value) {
|
|
269
|
+
return hasTerminalControl(value)
|
|
270
|
+
? pythonUnicodeEscapeBytes(Buffer.from(value, "utf8"))
|
|
271
|
+
: value;
|
|
272
|
+
}
|
|
273
|
+
export function launchFailureMessage(errno, resolution) {
|
|
274
|
+
const shown = displayPath(resolution.configured);
|
|
275
|
+
if (errno === "ENOENT" && resolution.exists) {
|
|
276
|
+
// The validator IS there; spawn's ENOENT came from its interpreter line.
|
|
277
|
+
// Measured: an executable with `#!/definitely/missing` raises ENOENT, and
|
|
278
|
+
// reporting "validator not found" would send the operator looking for a file
|
|
279
|
+
// that is present.
|
|
280
|
+
return `external plugin validator is present but its interpreter is missing: ${shown}`;
|
|
281
|
+
}
|
|
282
|
+
if (errno === "ENOENT")
|
|
283
|
+
return `external plugin validator not found: ${shown}`;
|
|
284
|
+
if (errno === "EACCES" && resolution.isDirectory)
|
|
285
|
+
return `external plugin validator is a directory: ${shown}`;
|
|
286
|
+
if (errno === "EACCES")
|
|
287
|
+
return `external plugin validator is not executable: ${shown}`;
|
|
288
|
+
if (errno === "ENOEXEC")
|
|
289
|
+
return `external plugin validator is not a runnable program: ${shown}`;
|
|
290
|
+
return `cannot execute external plugin validator: ${shown} (${errno})`;
|
|
291
|
+
}
|
|
292
|
+
// Only the commands that would actually run a validator. probe stays read-only, and
|
|
293
|
+
// a validator misconfiguration is none of its business.
|
|
294
|
+
const VALIDATOR_COMMANDS = new Set([
|
|
295
|
+
"prepare",
|
|
296
|
+
"install",
|
|
297
|
+
"update",
|
|
298
|
+
]);
|
|
299
|
+
// Legacy emptiness semantics exactly: empty string means unset, whitespace-only
|
|
300
|
+
// counts as set. No trimming — a trim would silently change legacy behaviour.
|
|
301
|
+
export function bothConfigured(env) {
|
|
302
|
+
return ((env.SUPERPOWERS_VALIDATOR || "").length > 0 &&
|
|
303
|
+
(env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "").length > 0);
|
|
304
|
+
}
|
|
305
|
+
// The second exported accessor preflight derives its enforcement from. It is an
|
|
306
|
+
// accessor rather than an inline check because an accessor that under-reports what
|
|
307
|
+
// preflight enforces is the blind spot slice 2 closed.
|
|
308
|
+
export function configurationErrors(cmd, env) {
|
|
309
|
+
if (!VALIDATOR_COMMANDS.has(cmd))
|
|
310
|
+
return [];
|
|
311
|
+
if (!bothConfigured(env))
|
|
312
|
+
return [];
|
|
313
|
+
return [
|
|
314
|
+
"SUPERPOWERS_VALIDATOR and SUPERPOWERS_VALIDATOR_EXECUTABLE are both set — " +
|
|
315
|
+
"unset one; the manager never chooses between them silently",
|
|
316
|
+
];
|
|
317
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SafetyError } from "./safety-error.js";
|
|
5
|
+
const MANAGED_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
6
|
+
// Each active workspace carries its caller's failure reporter. The signal
|
|
7
|
+
// path still invokes it, for parity with the normal path, but a signal death
|
|
8
|
+
// never builds a result outcome (see src/adapter-result.ts), so a report
|
|
9
|
+
// that only lands in the adapter's buffered log would never become
|
|
10
|
+
// observable — the signal path therefore also writes the hand-written
|
|
11
|
+
// diagnostic straight to process.stderr, unconditionally; that raw write is
|
|
12
|
+
// the only thing actually visible to a caller. Previously the signal path
|
|
13
|
+
// called the module-private cleanup and swallowed the rejection in
|
|
14
|
+
// Promise.allSettled, so a signal-time failure was silent entirely — this is
|
|
15
|
+
// the state PR 11.4 removed from the normal path.
|
|
16
|
+
const active = new Map();
|
|
17
|
+
let exiting = false;
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
// One phrasing for one condition. The adapter re-emits this for a suppressed
|
|
20
|
+
// failure, so a change here must not leave two wordings for the same event.
|
|
21
|
+
export function workspaceRemovalFailure(path) {
|
|
22
|
+
return `cannot remove workspace ${path}`;
|
|
23
|
+
}
|
|
24
|
+
async function cleanup(path) {
|
|
25
|
+
try {
|
|
26
|
+
await rm(path, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
throw new SafetyError("workspace", workspaceRemovalFailure(path), {
|
|
30
|
+
cause,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Synchronous by contract. An `await` here would yield to the event loop with
|
|
35
|
+
// the listeners still registered, and every signal arriving during that window
|
|
36
|
+
// would be consumed and discarded — an uninterruptible process, which is worse
|
|
37
|
+
// than the leak the handler exists to prevent. Being synchronous means the
|
|
38
|
+
// handler cannot be re-entered, so the window does not exist.
|
|
39
|
+
function cleanupForSignal(signal) {
|
|
40
|
+
if (exiting)
|
|
41
|
+
return;
|
|
42
|
+
exiting = true;
|
|
43
|
+
for (const [path, report] of active) {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(path, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// The reporter is invoked for parity with the normal path, but nothing
|
|
49
|
+
// ever reads it back here — no outcome is built before the process
|
|
50
|
+
// dies — so the diagnostic is also written straight to stderr,
|
|
51
|
+
// unconditionally; that write is the only thing actually observable.
|
|
52
|
+
// The diagnostic is hand-written and names the workspace; the caught
|
|
53
|
+
// cause is not interpolated.
|
|
54
|
+
//
|
|
55
|
+
// Reporting is guarded: if either call throws — plausibly EPIPE after
|
|
56
|
+
// SIGHUP, when the process on the other end of the pipe is already
|
|
57
|
+
// gone — the exception must not escape this loop, or the remaining
|
|
58
|
+
// workspaces' cleanup, deregistration, and the re-raise below never run.
|
|
59
|
+
try {
|
|
60
|
+
if (report)
|
|
61
|
+
report(path);
|
|
62
|
+
// Node documents pipe writes as asynchronous on some platforms
|
|
63
|
+
// (notably macOS); a saturated pipe could drop this write. Small
|
|
64
|
+
// buffers like this one are written inline, which is why the
|
|
65
|
+
// covering test observes it reliably.
|
|
66
|
+
process.stderr.write(`${workspaceRemovalFailure(path)}\n`);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// See above: a reporting failure must not block cleanup/re-raise.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
active.clear();
|
|
74
|
+
// Remove only the listeners this coordinator registered, then re-raise so the
|
|
75
|
+
// default disposition terminates the process by the signal.
|
|
76
|
+
for (const [managed, handler] of handlers)
|
|
77
|
+
process.off(managed, handler);
|
|
78
|
+
handlers.clear();
|
|
79
|
+
process.kill(process.pid, signal);
|
|
80
|
+
}
|
|
81
|
+
function registerCoordinator() {
|
|
82
|
+
if (handlers.size > 0)
|
|
83
|
+
return;
|
|
84
|
+
for (const signal of MANAGED_SIGNALS) {
|
|
85
|
+
const handler = () => {
|
|
86
|
+
cleanupForSignal(signal);
|
|
87
|
+
};
|
|
88
|
+
handlers.set(signal, handler);
|
|
89
|
+
process.on(signal, handler);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function deregisterCoordinator() {
|
|
93
|
+
if (active.size > 0)
|
|
94
|
+
return;
|
|
95
|
+
for (const [signal, handler] of handlers)
|
|
96
|
+
process.off(signal, handler);
|
|
97
|
+
handlers.clear();
|
|
98
|
+
}
|
|
99
|
+
export async function withWorkspace(parent, prefix, fn, options = {}) {
|
|
100
|
+
const remove = options.cleanup ?? cleanup;
|
|
101
|
+
let workspace;
|
|
102
|
+
try {
|
|
103
|
+
workspace = await mkdtemp(join(parent, prefix));
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
throw new SafetyError("workspace", "cannot create workspace", { cause });
|
|
107
|
+
}
|
|
108
|
+
active.set(workspace, options.onCleanupFailure);
|
|
109
|
+
registerCoordinator();
|
|
110
|
+
try {
|
|
111
|
+
let result;
|
|
112
|
+
let failed = false;
|
|
113
|
+
let callbackError;
|
|
114
|
+
try {
|
|
115
|
+
result = await fn(workspace);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
failed = true;
|
|
119
|
+
callbackError = error;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await remove(workspace);
|
|
123
|
+
}
|
|
124
|
+
catch (cleanupError) {
|
|
125
|
+
if (failed)
|
|
126
|
+
throw callbackError;
|
|
127
|
+
if (options.onCleanupFailure === undefined)
|
|
128
|
+
throw cleanupError;
|
|
129
|
+
options.onCleanupFailure(workspace);
|
|
130
|
+
}
|
|
131
|
+
if (failed)
|
|
132
|
+
throw callbackError;
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
active.delete(workspace);
|
|
137
|
+
deregisterCoordinator();
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superpowers-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Install and update the latest stable obra/superpowers release directly from upstream, without waiting for agent marketplaces to catch up. Codex supported today.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"superpowers",
|
|
@@ -18,6 +18,28 @@
|
|
|
18
18
|
],
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"type": "module",
|
|
21
|
+
"packageManager": "pnpm@11.22.0",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"prebuild": "node --input-type=module -e \"import{rmSync}from'node:fs';rmSync('dist',{recursive:true,force:true})\"",
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"postbuild": "node tests/write-build-id.js",
|
|
26
|
+
"prepack": "node --input-type=module -e \"import{existsSync}from'node:fs';if(!existsSync('dist/cli.js')){console.error('error: dist/cli.js is missing — run pnpm install --frozen-lockfile && pnpm run build');process.exit(1)}\"",
|
|
27
|
+
"lint": "oxlint src/ bin/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/run-node-suites.js tests/build-id.js tests/write-build-id.js tests/assert-matcher-gate.js",
|
|
28
|
+
"format": "prettier --write src/ bin/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/run-node-suites.js tests/build-id.js tests/write-build-id.js tests/assert-matcher-gate.js",
|
|
29
|
+
"format:check": "prettier --check src/ bin/ tests/bin/ tests/unit/ tests/baseline/ tests/lib/ tests/tools/ tests/run-node-suites.js tests/build-id.js tests/write-build-id.js tests/assert-matcher-gate.js",
|
|
30
|
+
"typecheck:js": "tsc -p tests/tsconfig.json",
|
|
31
|
+
"test": "node tests/run-node-suites.js",
|
|
32
|
+
"check:static": "pnpm run format:check && pnpm run lint && pnpm run build && pnpm run typecheck:js",
|
|
33
|
+
"check": "pnpm run check:static && pnpm run test"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.13.3",
|
|
37
|
+
"oxlint": "^1.75.0",
|
|
38
|
+
"oxlint-tsgolint": "7.0.2001",
|
|
39
|
+
"prettier": "^3.9.6",
|
|
40
|
+
"typescript": "~7.0.2",
|
|
41
|
+
"yaml": "^2.9.0"
|
|
42
|
+
},
|
|
21
43
|
"repository": {
|
|
22
44
|
"type": "git",
|
|
23
45
|
"url": "git+https://github.com/j7an/superpowers-manager.git"
|
|
@@ -34,7 +56,8 @@
|
|
|
34
56
|
},
|
|
35
57
|
"files": [
|
|
36
58
|
"bin/",
|
|
37
|
-
"
|
|
59
|
+
"dist/",
|
|
60
|
+
"!dist/.build-id",
|
|
38
61
|
"config/upstream-ref",
|
|
39
62
|
".agents/plugins/marketplace.json",
|
|
40
63
|
"plugins/superpowers/.codex-plugin/plugin.template.json",
|