superpowers-manager 0.3.0 → 0.5.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.
Files changed (41) hide show
  1. package/README.md +63 -325
  2. package/dist/adapter.js +91 -46
  3. package/dist/artifact-tree.js +110 -0
  4. package/dist/atomic.js +103 -7
  5. package/dist/cli-arguments.js +37 -0
  6. package/dist/cli.js +97 -40
  7. package/dist/codex-compatibility.js +126 -0
  8. package/dist/codex-conflicts.js +99 -0
  9. package/dist/codex-harness.js +321 -0
  10. package/dist/codex-json.js +7 -0
  11. package/dist/codex-prepare.js +255 -0
  12. package/dist/codex-presentation.js +282 -0
  13. package/dist/commands/install.js +187 -173
  14. package/dist/commands/mutation.js +71 -0
  15. package/dist/commands/prepare.js +87 -188
  16. package/dist/commands/probe.js +191 -176
  17. package/dist/commands/uninstall.js +52 -139
  18. package/dist/commands/update.js +33 -88
  19. package/dist/generated-plugin.js +3 -3
  20. package/dist/harness-compatibility.js +13 -0
  21. package/dist/harness.js +1 -0
  22. package/dist/lifecycle.js +1 -86
  23. package/dist/manifest-overlay.js +2 -2
  24. package/dist/pi-compatibility.js +73 -0
  25. package/dist/pi-harness.js +63 -0
  26. package/dist/pi-install.js +495 -0
  27. package/dist/pi-native.js +81 -0
  28. package/dist/pi-package.js +153 -0
  29. package/dist/pi-paths.js +30 -0
  30. package/dist/pi-prepare.js +111 -0
  31. package/dist/pi-presentation.js +67 -0
  32. package/dist/pi-settings.js +171 -0
  33. package/dist/pi-shared-skills.js +413 -0
  34. package/dist/pi-state.js +311 -0
  35. package/dist/resource-lock.js +265 -0
  36. package/dist/safe-path.js +21 -5
  37. package/dist/strict-json.js +1 -1
  38. package/dist/upstream-workspace.js +8 -0
  39. package/dist/validator.js +3 -2
  40. package/package.json +19 -18
  41. package/bin/superpowers-manager.js +0 -16
@@ -1,39 +1,16 @@
1
- import { cp, mkdir, rm, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, sep } from "node:path";
3
3
  import { atomicReplaceDir } from "../atomic.js";
4
4
  import { oneLine } from "../cli-arguments.js";
5
5
  import { computeEffectiveSelection } from "../effective-selection.js";
6
6
  import { runGit } from "../git.js";
7
- import { readManifest } from "../hooks.js";
8
- import { writeProvenance } from "../provenance.js";
9
7
  import { SafetyError } from "../safety-error.js";
10
- import { manifestVersionForRef } from "../upstream-version.js";
11
8
  import { fetchExactCommit, gitSafeSource } from "../upstream.js";
9
+ import { upstreamCacheRoot } from "../upstream-workspace.js";
12
10
  import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
13
11
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
14
12
  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
- ];
13
+ import { runWithMutation } from "./mutation.js";
37
14
  // Every message this module writes is hand-written here. The cause is attached
38
15
  // for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
39
16
  // reads .message only. Same arrangement as hookError
@@ -41,28 +18,10 @@ const RESOLUTION_KINDS = [
41
18
  function prepareError(message, cause) {
42
19
  return new SafetyError("prepare", message, { cause });
43
20
  }
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
21
+ // `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` uses -f, and
62
22
  // 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.
23
+ // template bytes and non-file rejection" also covers the separately extracted
24
+ // fallback check.
66
25
  async function regularFileExists(path) {
67
26
  try {
68
27
  return (await stat(path)).isFile();
@@ -84,8 +43,8 @@ async function directoryExists(path) {
84
43
  return false;
85
44
  }
86
45
  }
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.
46
+ // mkdir throws raw ErrnoExceptions. Every command-owned call goes through here
47
+ // so the message on the stream is this module's, not Node's.
89
48
  async function owned(message, fn) {
90
49
  try {
91
50
  return await fn();
@@ -94,50 +53,6 @@ async function owned(message, fn) {
94
53
  throw prepareError(message, cause);
95
54
  }
96
55
  }
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
56
  // Each stream's marker goes to that stream's own destination, so a reader of either
142
57
  // sees its own truncation and neither is silently short.
143
58
  function withTruncationMarker(captured, stream) {
@@ -167,34 +82,66 @@ function disclosureLine(resolution) {
167
82
  return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
168
83
  }
169
84
  const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
85
+ function validStagingLeaf(value) {
86
+ return (value.length > 0 &&
87
+ value !== "." &&
88
+ value !== ".." &&
89
+ !value.includes("/") &&
90
+ !value.includes("\\"));
91
+ }
170
92
  async function gatherPrepare(ctx) {
171
93
  const env = ctx.env;
172
94
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
173
95
  // getcwd(3) returns the physical path, so this matches `pwd -P` without a
174
96
  // realpath call.
175
97
  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");
98
+ const cache = upstreamCacheRoot(ctx.root, env, cwd);
99
+ const cacheParent = dirname(cache);
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;
180
115
  const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
181
116
  const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
182
- const cache = join(cacheParent, "superpowers");
183
117
  const tmpParent = dirname(pluginRoot);
184
118
  await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
185
119
  let cleanupWarning = null;
186
120
  const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
121
+ const outcomes = [];
187
122
  const failed = (message) => ({
188
123
  kind: "failed",
189
- outcomes: [],
124
+ outcomes,
190
125
  validator: NO_VALIDATOR_OUTPUT,
191
126
  message,
192
127
  });
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}`);
128
+ const candidate = join(workspace, location.stagingLeaf);
129
+ const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, env));
130
+ let prefetch;
131
+ try {
132
+ prefetch =
133
+ await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
134
+ }
135
+ catch {
136
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
137
+ .unexpected);
138
+ }
139
+ outcomes.push(prefetch.outcome);
140
+ if (!prefetch.outcome.ok)
141
+ return failed(null);
142
+ if (prefetch.status !== 0) {
143
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
144
+ .invalidStatus);
198
145
  }
199
146
  await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
200
147
  if (selection.selectionMode === "pinned") {
@@ -237,86 +184,34 @@ async function gatherPrepare(ctx) {
237
184
  if (checkedOut.status !== 0) {
238
185
  return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
239
186
  }
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
- }
187
+ let prepared;
188
+ try {
189
+ prepared = await ctx.adapter.prepareCandidate({
190
+ upstreamRoot: cache,
191
+ workspaceRoot: workspace,
192
+ candidateRoot: candidate,
193
+ selection,
194
+ }, adapterContext);
246
195
  }
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));
196
+ catch {
197
+ return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
198
+ .unexpected);
251
199
  }
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);
200
+ outcomes.push(prepared.outcome);
201
+ if (prepared.status !== 0 || !prepared.outcome.ok) {
202
+ return failed(prepared.outcome.ok
203
+ ? ctx.adapter.presentation.callFailure("prepare", adapterContext)
204
+ .invalidStatus
205
+ : null);
258
206
  }
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 });
207
+ if (prepared.outcome.result.root !== candidate) {
208
+ return failed("adapter returned an unexpected preparation root");
293
209
  }
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");
210
+ if (prepared.outcome.result.commit !== selection.desiredCommit) {
211
+ return failed("adapter returned an unexpected preparation commit");
305
212
  }
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
- };
213
+ if (prepared.outcome.result.compatibility.kind === "unsupported") {
214
+ return failed(prepared.outcome.result.compatibility.reason);
320
215
  }
321
216
  let validator = NO_VALIDATOR_OUTPUT;
322
217
  if (additionalValidator.length > 0) {
@@ -391,11 +286,12 @@ async function gatherPrepare(ctx) {
391
286
  // The swap must run inside the workspace callback: withWorkspace removes
392
287
  // the workspace on return, and the candidate lives in it.
393
288
  //
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.
289
+ // atomicReplaceDir delegates to beginDirectoryPublication, whose outer
290
+ // catch (`src/atomic.ts:341-348::if (cause`) wraps every non-SafetyError
291
+ // into a SafetyError, so the callee owns every failure on this path and
292
+ // re-emitting its own diagnostic is the sanctioned form of interpolation.
293
+ // The hand-written prefix carries the live root, which the callee's message
294
+ // does not.
399
295
  try {
400
296
  await atomicReplaceDir(candidate, pluginRoot);
401
297
  }
@@ -426,6 +322,9 @@ async function gatherPrepare(ctx) {
426
322
  return { outcome, cleanupWarning };
427
323
  }
428
324
  export async function runPrepare(argv, ctx) {
325
+ return await runWithMutation("prepare", ctx, async (scoped) => performPrepare(argv, scoped));
326
+ }
327
+ async function performPrepare(argv, ctx) {
429
328
  // scripts/prepare never reads "$@", so extra arguments are ignored. This is a
430
329
  // deliberate asymmetry with probe, whose shell original rejected unknown
431
330
  // arguments and whose arity therefore moved into parseArgs in slice 2.
@@ -443,7 +342,7 @@ export async function runPrepare(argv, ctx) {
443
342
  // spawn error, which oneLine never reads -- it takes .message only);
444
343
  // readManifest's three hookError messages
445
344
  // (`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
345
+ // `tests/unit/hooks.test.ts:95::void test("readManifest diagnostics` as carrying no reader vocabulary or
447
346
  // errno; and SafetyErrors from gitSafeSource, writeProvenance, and
448
347
  // withWorkspace.
449
348
  //
@@ -454,7 +353,7 @@ export async function runPrepare(argv, ctx) {
454
353
  // This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
455
354
  // and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
456
355
  // by
457
- // `tests/unit/upstream.test.js:460-469::void test("resolveRef reports a query failure for latest`,
356
+ // `tests/unit/upstream.test.ts:453-462::void test("resolveRef reports a query failure for latest`,
458
357
  // :471-481, and :483-501.
459
358
  // 2. fetchExactCommit splices the same combined stdout+stderr into its
460
359
  // own text on the PINNED path (both of its own splice sites in
@@ -519,7 +418,7 @@ export async function runPrepare(argv, ctx) {
519
418
  // completed before cleanup ran, so it is not being reported as unverified
520
419
  // -- but something did still go wrong, and AGENTS.md's fail-closed rule
521
420
  // extends to it. Mirrors
522
- // `src/commands/install.ts:503-510::if (cleanupWarning`.
421
+ // `src/commands/install.ts:546-554::if (cleanupWarning`.
523
422
  ctx.stderr.write(`error: ${cleanupWarning}\n`);
524
423
  return 1;
525
424
  }