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.
- package/README.md +63 -325
- package/dist/adapter.js +91 -46
- package/dist/artifact-tree.js +110 -0
- package/dist/atomic.js +103 -7
- package/dist/cli-arguments.js +37 -0
- package/dist/cli.js +97 -40
- package/dist/codex-compatibility.js +126 -0
- package/dist/codex-conflicts.js +99 -0
- package/dist/codex-harness.js +321 -0
- package/dist/codex-json.js +7 -0
- package/dist/codex-prepare.js +255 -0
- package/dist/codex-presentation.js +282 -0
- package/dist/commands/install.js +187 -173
- package/dist/commands/mutation.js +71 -0
- package/dist/commands/prepare.js +87 -188
- package/dist/commands/probe.js +191 -176
- package/dist/commands/uninstall.js +52 -139
- package/dist/commands/update.js +33 -88
- package/dist/generated-plugin.js +3 -3
- package/dist/harness-compatibility.js +13 -0
- package/dist/harness.js +1 -0
- package/dist/lifecycle.js +1 -86
- package/dist/manifest-overlay.js +2 -2
- package/dist/pi-compatibility.js +73 -0
- package/dist/pi-harness.js +63 -0
- package/dist/pi-install.js +495 -0
- package/dist/pi-native.js +81 -0
- package/dist/pi-package.js +153 -0
- package/dist/pi-paths.js +30 -0
- package/dist/pi-prepare.js +111 -0
- package/dist/pi-presentation.js +67 -0
- package/dist/pi-settings.js +171 -0
- package/dist/pi-shared-skills.js +413 -0
- package/dist/pi-state.js +311 -0
- package/dist/resource-lock.js +265 -0
- package/dist/safe-path.js +21 -5
- package/dist/strict-json.js +1 -1
- package/dist/upstream-workspace.js +8 -0
- package/dist/validator.js +3 -2
- package/package.json +19 -18
- package/bin/superpowers-manager.js +0 -16
package/dist/adapter.js
CHANGED
|
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { delimiter, isAbsolute, join } from "node:path";
|
|
7
7
|
import { AdapterMessageLog, failureResult, successResult, } from "./adapter-result.js";
|
|
8
8
|
import { activePluginVersionFromJson, installedListingHas, marketplaceRootFromJson, } from "./codex-json.js";
|
|
9
|
+
import { CODEX_LEGACY_PLUGIN_ID, CODEX_MANAGER_PLUGIN_ID, inspectCodexConflicts, } from "./codex-conflicts.js";
|
|
9
10
|
import { oneLine } from "./cli-arguments.js";
|
|
10
11
|
import { installedCommitFromRoot, installedRootForVersion, pathsEqual, } from "./codex-state.js";
|
|
11
12
|
import { validateGeneratedPlugin } from "./generated-plugin.js";
|
|
@@ -14,9 +15,9 @@ import { applyManifestOverlay } from "./manifest-overlay.js";
|
|
|
14
15
|
import { readCodexBuildSource } from "./provenance.js";
|
|
15
16
|
import { isAcceptedSplitValue } from "./validate-generated-plugin-cli.js";
|
|
16
17
|
import { withWorkspace, workspaceRemovalFailure } from "./workspace.js";
|
|
17
|
-
const PLUGIN_ID =
|
|
18
|
+
const PLUGIN_ID = CODEX_MANAGER_PLUGIN_ID;
|
|
18
19
|
const MARKETPLACE_NAME = "superpowers-manager";
|
|
19
|
-
const LEGACY_PLUGIN_ID =
|
|
20
|
+
const LEGACY_PLUGIN_ID = CODEX_LEGACY_PLUGIN_ID;
|
|
20
21
|
const LEGACY_MARKETPLACE_NAME = "superpowers-wrapper";
|
|
21
22
|
class AdapterFailure extends Error {
|
|
22
23
|
code;
|
|
@@ -197,11 +198,8 @@ async function fileExists(path) {
|
|
|
197
198
|
return false;
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
|
-
async function runBuild(
|
|
201
|
-
const
|
|
202
|
-
const upstreamRoot = flags["--upstream-root"];
|
|
203
|
-
const candidateRoot = flags["--candidate-root"];
|
|
204
|
-
const fallbackManifest = flags["--fallback-manifest"];
|
|
201
|
+
async function runBuild(input, env, log) {
|
|
202
|
+
const { upstreamRoot, candidateRoot, fallbackManifest } = input;
|
|
205
203
|
if (!isAbsolute(upstreamRoot)) {
|
|
206
204
|
fail("invalid-arguments", "--upstream-root must be an absolute path");
|
|
207
205
|
}
|
|
@@ -276,7 +274,7 @@ async function runBuild(argv, root, env, log) {
|
|
|
276
274
|
}
|
|
277
275
|
let overlaid;
|
|
278
276
|
try {
|
|
279
|
-
overlaid = applyManifestOverlay(source,
|
|
277
|
+
overlaid = applyManifestOverlay(source, input.managerVersion, candidateManifest);
|
|
280
278
|
}
|
|
281
279
|
catch (cause) {
|
|
282
280
|
// applyManifestOverlay's own messages already name the manifest
|
|
@@ -321,13 +319,13 @@ async function runBuild(argv, root, env, log) {
|
|
|
321
319
|
// on the adapter's surface.
|
|
322
320
|
const splitValues = [
|
|
323
321
|
{ value: candidateRoot, name: "--plugin-root" },
|
|
324
|
-
{ value:
|
|
325
|
-
{ value:
|
|
326
|
-
{ value:
|
|
327
|
-
{ value:
|
|
322
|
+
{ value: input.requestedRef, name: "--requested-ref" },
|
|
323
|
+
{ value: input.resolvedRef, name: "--resolved-ref" },
|
|
324
|
+
{ value: input.commit, name: "--commit" },
|
|
325
|
+
{ value: input.managerVersion, name: "--manager-version" },
|
|
328
326
|
{ value: manifestSource, name: "--manifest-source" },
|
|
329
327
|
{
|
|
330
|
-
value:
|
|
328
|
+
value: input.upstreamManifestVersion,
|
|
331
329
|
name: "--upstream-manifest-version",
|
|
332
330
|
},
|
|
333
331
|
];
|
|
@@ -347,12 +345,12 @@ async function runBuild(argv, root, env, log) {
|
|
|
347
345
|
errors = await validateGeneratedPlugin({
|
|
348
346
|
pluginRoot: candidateRoot,
|
|
349
347
|
source: upstreamSource,
|
|
350
|
-
requestedRef:
|
|
351
|
-
resolvedRef:
|
|
352
|
-
commit:
|
|
353
|
-
manifestVersion:
|
|
348
|
+
requestedRef: input.requestedRef,
|
|
349
|
+
resolvedRef: input.resolvedRef,
|
|
350
|
+
commit: input.commit,
|
|
351
|
+
manifestVersion: input.managerVersion,
|
|
354
352
|
manifestSource,
|
|
355
|
-
upstreamManifestVersion:
|
|
353
|
+
upstreamManifestVersion: input.upstreamManifestVersion,
|
|
356
354
|
});
|
|
357
355
|
}
|
|
358
356
|
catch {
|
|
@@ -377,9 +375,7 @@ async function runBuild(argv, root, env, log) {
|
|
|
377
375
|
throw cause;
|
|
378
376
|
}
|
|
379
377
|
}
|
|
380
|
-
async function runInstall(
|
|
381
|
-
const flags = parseFlags(argv, ["--package-root"]);
|
|
382
|
-
const packageRoot = flags["--package-root"];
|
|
378
|
+
async function runInstall(packageRoot, env, log) {
|
|
383
379
|
if (!isAbsolute(packageRoot)) {
|
|
384
380
|
fail("invalid-arguments", "--package-root must be an absolute path");
|
|
385
381
|
}
|
|
@@ -453,23 +449,15 @@ async function runInstall(argv, env, log) {
|
|
|
453
449
|
throw cause;
|
|
454
450
|
}
|
|
455
451
|
}
|
|
456
|
-
async function runUninstall(
|
|
457
|
-
const
|
|
458
|
-
const pluginPresent = flags["--plugin-present"];
|
|
459
|
-
const marketplacePresent = flags["--marketplace-present"];
|
|
460
|
-
if (pluginPresent !== "true" && pluginPresent !== "false") {
|
|
461
|
-
fail("invalid-arguments", "--plugin-present must be true or false");
|
|
462
|
-
}
|
|
463
|
-
if (marketplacePresent !== "true" && marketplacePresent !== "false") {
|
|
464
|
-
fail("invalid-arguments", "--marketplace-present must be true or false");
|
|
465
|
-
}
|
|
452
|
+
async function runUninstall(input, env, log) {
|
|
453
|
+
const { pluginPresent, marketplacePresent } = input;
|
|
466
454
|
const codexBin = env.SUPERPOWERS_CODEX || "codex";
|
|
467
455
|
await requireCodex(codexBin, env);
|
|
468
456
|
let entered = false;
|
|
469
457
|
try {
|
|
470
458
|
return await withWorkspace(tmpdir(), "superpowers-manager.adapter-uninstall.", async () => {
|
|
471
459
|
entered = true;
|
|
472
|
-
if (pluginPresent
|
|
460
|
+
if (pluginPresent) {
|
|
473
461
|
const result = await mutationCommand(log, codexBin, ["plugin", "remove", PLUGIN_ID], env);
|
|
474
462
|
if (commandFailed(result)) {
|
|
475
463
|
fail("uninstall-failed", `codex plugin remove failed for ${PLUGIN_ID}`);
|
|
@@ -479,7 +467,7 @@ async function runUninstall(argv, env, log) {
|
|
|
479
467
|
else {
|
|
480
468
|
log.appendText("stdout", "plugin not installed; skipping");
|
|
481
469
|
}
|
|
482
|
-
if (marketplacePresent
|
|
470
|
+
if (marketplacePresent) {
|
|
483
471
|
const result = await mutationCommand(log, codexBin, ["plugin", "marketplace", "remove", MARKETPLACE_NAME], env);
|
|
484
472
|
if (commandFailed(result)) {
|
|
485
473
|
fail("uninstall-failed", `codex plugin marketplace remove failed for ${MARKETPLACE_NAME}`);
|
|
@@ -499,9 +487,7 @@ async function runUninstall(argv, env, log) {
|
|
|
499
487
|
throw cause;
|
|
500
488
|
}
|
|
501
489
|
}
|
|
502
|
-
async function runInspect(
|
|
503
|
-
const flags = parseFlags(argv, ["--view"]);
|
|
504
|
-
const view = flags["--view"];
|
|
490
|
+
async function runInspect(view, context, env, log) {
|
|
505
491
|
if (view === "update-control") {
|
|
506
492
|
return { view: "update-control", update_control: "managed" };
|
|
507
493
|
}
|
|
@@ -567,9 +553,11 @@ async function runInspect(argv, env, log) {
|
|
|
567
553
|
let legacyPlugin;
|
|
568
554
|
let managerMarketplace;
|
|
569
555
|
let legacyMarketplace;
|
|
556
|
+
let conflicts;
|
|
570
557
|
try {
|
|
571
558
|
managerPlugin = installedListingHas(plugins.stdout, "installed", "pluginId", PLUGIN_ID);
|
|
572
559
|
legacyPlugin = installedListingHas(plugins.stdout, "installed", "pluginId", LEGACY_PLUGIN_ID);
|
|
560
|
+
conflicts = await inspectCodexConflicts({ root: context.root, env }, plugins.stdout.toString("utf8"));
|
|
573
561
|
}
|
|
574
562
|
catch {
|
|
575
563
|
fail("inspect-failed", `cannot parse output of '${codexBin} plugin list --json'`);
|
|
@@ -600,6 +588,7 @@ async function runInspect(argv, env, log) {
|
|
|
600
588
|
: legacyPresent
|
|
601
589
|
? "legacy"
|
|
602
590
|
: "neither",
|
|
591
|
+
conflicts: [...conflicts],
|
|
603
592
|
};
|
|
604
593
|
}, { onCleanupFailure: reportOrphanedWorkspace(log) });
|
|
605
594
|
}
|
|
@@ -610,39 +599,95 @@ async function runInspect(argv, env, log) {
|
|
|
610
599
|
throw cause;
|
|
611
600
|
}
|
|
612
601
|
}
|
|
613
|
-
|
|
602
|
+
const unsupportedView = view;
|
|
603
|
+
fail("invalid-arguments", `unsupported inspect view: ${unsupportedView}`);
|
|
604
|
+
}
|
|
605
|
+
async function runCodexOperation(operation, context, execute) {
|
|
606
|
+
const env = { ...process.env, ...context.env };
|
|
607
|
+
const log = new AdapterMessageLog();
|
|
608
|
+
try {
|
|
609
|
+
const result = await execute(env, log);
|
|
610
|
+
return successResult(operation, result, log.snapshot());
|
|
611
|
+
}
|
|
612
|
+
catch (cause) {
|
|
613
|
+
if (cause instanceof AdapterFailure) {
|
|
614
|
+
return failureResult(operation, cause.code, cause.message, cause.hints, log.snapshot());
|
|
615
|
+
}
|
|
616
|
+
throw cause;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
export function codexBuild(input, context) {
|
|
620
|
+
return runCodexOperation("build", context, (env, log) => runBuild(input, env, log));
|
|
621
|
+
}
|
|
622
|
+
export function codexInstall(packageRoot, context) {
|
|
623
|
+
return runCodexOperation("install", context, (env, log) => runInstall(packageRoot, env, log));
|
|
624
|
+
}
|
|
625
|
+
export function codexRemove(input, context) {
|
|
626
|
+
return runCodexOperation("uninstall", context, (env, log) => runUninstall(input, env, log));
|
|
627
|
+
}
|
|
628
|
+
export function codexInspect(view, context) {
|
|
629
|
+
return runCodexOperation("inspect", context, (env, log) => runInspect(view, context, env, log));
|
|
614
630
|
}
|
|
615
631
|
export async function runAdapter(argv, context) {
|
|
616
632
|
const rawOperation = argv[0];
|
|
617
633
|
const operation = rawOperation || "adapter";
|
|
618
634
|
const args = argv.slice(1);
|
|
619
|
-
const env = { ...process.env, ...context.env };
|
|
620
|
-
const log = new AdapterMessageLog();
|
|
621
635
|
try {
|
|
622
|
-
let result;
|
|
623
636
|
if (rawOperation === undefined || rawOperation.length === 0) {
|
|
624
637
|
fail("invalid-arguments", "missing adapter operation");
|
|
625
638
|
}
|
|
626
639
|
else if (operation === "build") {
|
|
627
|
-
|
|
640
|
+
const flags = parseFlags(args, BUILD_FLAGS);
|
|
641
|
+
return codexBuild({
|
|
642
|
+
upstreamRoot: flags["--upstream-root"],
|
|
643
|
+
candidateRoot: flags["--candidate-root"],
|
|
644
|
+
requestedRef: flags["--requested-ref"],
|
|
645
|
+
resolvedRef: flags["--resolved-ref"],
|
|
646
|
+
commit: flags["--commit"],
|
|
647
|
+
managerVersion: flags["--manager-version"],
|
|
648
|
+
upstreamManifestVersion: flags["--upstream-manifest-version"],
|
|
649
|
+
fallbackManifest: flags["--fallback-manifest"],
|
|
650
|
+
}, context);
|
|
628
651
|
}
|
|
629
652
|
else if (operation === "install") {
|
|
630
|
-
|
|
653
|
+
const flags = parseFlags(args, ["--package-root"]);
|
|
654
|
+
return codexInstall(flags["--package-root"], context);
|
|
631
655
|
}
|
|
632
656
|
else if (operation === "uninstall") {
|
|
633
|
-
|
|
657
|
+
const flags = parseFlags(args, [
|
|
658
|
+
"--plugin-present",
|
|
659
|
+
"--marketplace-present",
|
|
660
|
+
]);
|
|
661
|
+
const pluginPresent = flags["--plugin-present"];
|
|
662
|
+
const marketplacePresent = flags["--marketplace-present"];
|
|
663
|
+
if (pluginPresent !== "true" && pluginPresent !== "false") {
|
|
664
|
+
fail("invalid-arguments", "--plugin-present must be true or false");
|
|
665
|
+
}
|
|
666
|
+
if (marketplacePresent !== "true" && marketplacePresent !== "false") {
|
|
667
|
+
fail("invalid-arguments", "--marketplace-present must be true or false");
|
|
668
|
+
}
|
|
669
|
+
return codexRemove({
|
|
670
|
+
pluginPresent: pluginPresent === "true",
|
|
671
|
+
marketplacePresent: marketplacePresent === "true",
|
|
672
|
+
}, context);
|
|
634
673
|
}
|
|
635
674
|
else if (operation === "inspect") {
|
|
636
|
-
|
|
675
|
+
const flags = parseFlags(args, ["--view"]);
|
|
676
|
+
const view = flags["--view"];
|
|
677
|
+
if (view !== "ownership" &&
|
|
678
|
+
view !== "update-control" &&
|
|
679
|
+
view !== "fingerprint") {
|
|
680
|
+
fail("invalid-arguments", `unsupported inspect view: ${view}`);
|
|
681
|
+
}
|
|
682
|
+
return codexInspect(view, context);
|
|
637
683
|
}
|
|
638
684
|
else {
|
|
639
685
|
fail("unsupported-operation", `unsupported adapter operation: ${operation}`);
|
|
640
686
|
}
|
|
641
|
-
return successResult(operation, result, log.snapshot());
|
|
642
687
|
}
|
|
643
688
|
catch (cause) {
|
|
644
689
|
if (cause instanceof AdapterFailure) {
|
|
645
|
-
return failureResult(operation, cause.code, cause.message, cause.hints,
|
|
690
|
+
return failureResult(operation, cause.code, cause.message, cause.hints, []);
|
|
646
691
|
}
|
|
647
692
|
throw cause;
|
|
648
693
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, readFile, readlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertExistingContained, assertNoFollowType, assertSymlinkTargetContained, } from "./safe-path.js";
|
|
5
|
+
import { SafetyError } from "./safety-error.js";
|
|
6
|
+
import { parseStrictJson } from "./strict-json.js";
|
|
7
|
+
import { compareByCodePoint, DEFAULT_FS_DEPS, validateSkillFrontmatter, } from "./generated-plugin.js";
|
|
8
|
+
export const ARTIFACT_RECEIPT = ".superpowers-manager.json";
|
|
9
|
+
export const ARTIFACT_DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
10
|
+
const DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
11
|
+
export function addArtifactHashField(hash, bytes) {
|
|
12
|
+
const length = Buffer.alloc(8);
|
|
13
|
+
length.writeBigUInt64BE(BigInt(bytes.byteLength));
|
|
14
|
+
hash.update(length);
|
|
15
|
+
hash.update(bytes);
|
|
16
|
+
}
|
|
17
|
+
export async function readArtifactFile(root, path, limit = 1024 * 1024) {
|
|
18
|
+
try {
|
|
19
|
+
await assertExistingContained(root, path);
|
|
20
|
+
await assertNoFollowType(path, ["regular-file"]);
|
|
21
|
+
if ((await lstat(path)).size > limit)
|
|
22
|
+
throw new Error("size");
|
|
23
|
+
const bytes = await readFile(path);
|
|
24
|
+
if (bytes.length > limit)
|
|
25
|
+
throw new Error("size");
|
|
26
|
+
return bytes;
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
throw new SafetyError("artifact", `cannot read bounded artifact file: ${path}`, { cause });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function readArtifactObject(root, path) {
|
|
33
|
+
try {
|
|
34
|
+
const value = parseStrictJson(await readArtifactFile(root, path), {
|
|
35
|
+
duplicateKeys: "reject",
|
|
36
|
+
nonStandardConstants: "reject",
|
|
37
|
+
maxDepth: 64,
|
|
38
|
+
});
|
|
39
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
40
|
+
throw new Error("object");
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
throw new SafetyError("artifact", `invalid artifact JSON object: ${path}`, {
|
|
45
|
+
cause,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function validateNativeSkill(root) {
|
|
50
|
+
const path = join(root, "skills", "using-superpowers", "SKILL.md");
|
|
51
|
+
await readArtifactFile(root, path);
|
|
52
|
+
const errors = [];
|
|
53
|
+
await validateSkillFrontmatter(path, "using-superpowers", errors, DEFAULT_FS_DEPS);
|
|
54
|
+
if (errors.length)
|
|
55
|
+
throw new SafetyError("artifact", `invalid native bootstrap skill: ${path}`);
|
|
56
|
+
}
|
|
57
|
+
export async function digestArtifactTree(root) {
|
|
58
|
+
try {
|
|
59
|
+
await assertNoFollowType(root, ["directory"]);
|
|
60
|
+
const paths = [];
|
|
61
|
+
async function visit(relative) {
|
|
62
|
+
for (const bytes of await readdir(join(root, relative), {
|
|
63
|
+
encoding: "buffer",
|
|
64
|
+
})) {
|
|
65
|
+
const name = DECODER.decode(bytes);
|
|
66
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
67
|
+
if (path === ARTIFACT_RECEIPT)
|
|
68
|
+
continue;
|
|
69
|
+
paths.push(path);
|
|
70
|
+
if ((await lstat(join(root, path))).isDirectory())
|
|
71
|
+
await visit(path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await visit("");
|
|
75
|
+
const hash = createHash("sha256");
|
|
76
|
+
for (const path of paths.sort(compareByCodePoint)) {
|
|
77
|
+
const absolute = join(root, path), info = await lstat(absolute);
|
|
78
|
+
let kind, contents;
|
|
79
|
+
if (info.isDirectory()) {
|
|
80
|
+
kind = "directory";
|
|
81
|
+
contents = Buffer.alloc(0);
|
|
82
|
+
}
|
|
83
|
+
else if (info.isFile()) {
|
|
84
|
+
kind = "file";
|
|
85
|
+
contents = await readFile(absolute);
|
|
86
|
+
}
|
|
87
|
+
else if (info.isSymbolicLink()) {
|
|
88
|
+
await assertSymlinkTargetContained(root, absolute);
|
|
89
|
+
kind = "link";
|
|
90
|
+
contents = await readlink(absolute, { encoding: "buffer" });
|
|
91
|
+
DECODER.decode(contents);
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
throw new Error("entry type");
|
|
95
|
+
for (const bytes of [
|
|
96
|
+
Buffer.from(kind),
|
|
97
|
+
Buffer.from(path),
|
|
98
|
+
Buffer.from(String(info.isFile() ? info.mode & 0o111 : 0)),
|
|
99
|
+
contents,
|
|
100
|
+
])
|
|
101
|
+
addArtifactHashField(hash, bytes);
|
|
102
|
+
}
|
|
103
|
+
return hash.digest("hex");
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
throw new SafetyError("artifact", `cannot digest artifact tree: ${root}`, {
|
|
107
|
+
cause,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/atomic.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { constants, lstat, open, readFile, rename, rm, unlink, } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { SafetyError } from "./safety-error.js";
|
|
5
5
|
export async function atomicWriteFile(path, bytes, options) {
|
|
6
6
|
const renamePath = options.hooks?.rename ?? rename;
|
|
@@ -92,18 +92,52 @@ async function chooseBackup(live) {
|
|
|
92
92
|
}
|
|
93
93
|
throw new SafetyError("atomic", "cannot choose unique backup path");
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
async function validateRequestedBackup(backup, candidate, live) {
|
|
96
|
+
const resolvedBackup = resolve(backup);
|
|
97
|
+
const resolvedCandidate = resolve(candidate);
|
|
98
|
+
const resolvedLive = resolve(live);
|
|
99
|
+
const prefix = `.${basename(resolvedLive)}.bak.`;
|
|
100
|
+
if (dirname(resolvedBackup) !== dirname(resolvedLive) ||
|
|
101
|
+
!basename(resolvedBackup).startsWith(prefix) ||
|
|
102
|
+
basename(resolvedBackup).length === prefix.length ||
|
|
103
|
+
resolvedBackup === resolvedCandidate ||
|
|
104
|
+
resolvedBackup === resolvedLive ||
|
|
105
|
+
(await exists(backup))) {
|
|
106
|
+
throw new Error("invalid directory backup path");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function directoryIdentity(path) {
|
|
110
|
+
const details = await lstat(path);
|
|
111
|
+
return { dev: details.dev, ino: details.ino };
|
|
112
|
+
}
|
|
113
|
+
function sameIdentity(left, right) {
|
|
114
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
115
|
+
}
|
|
116
|
+
function publicationFailure(message, cause) {
|
|
117
|
+
return new SafetyError("atomic", message, {
|
|
118
|
+
cause,
|
|
119
|
+
details: { phase: "post-replacement" },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
export async function beginDirectoryPublication(candidate, live, options = {}) {
|
|
96
123
|
const renamePath = options.hooks?.rename ?? rename;
|
|
97
124
|
const removePath = options.hooks?.rm ?? rm;
|
|
98
125
|
let backupCreated = false;
|
|
126
|
+
let backupIdentity = null;
|
|
99
127
|
let phase = "pre-replacement";
|
|
100
128
|
try {
|
|
101
|
-
const backup = await chooseBackup(live);
|
|
129
|
+
const backup = options.backupPath ?? (await chooseBackup(live));
|
|
130
|
+
if (options.backupPath !== undefined) {
|
|
131
|
+
await validateRequestedBackup(backup, candidate, live);
|
|
132
|
+
}
|
|
102
133
|
if (await exists(live)) {
|
|
134
|
+
backupIdentity = await directoryIdentity(live);
|
|
103
135
|
await renamePath(live, backup);
|
|
104
136
|
backupCreated = true;
|
|
105
137
|
}
|
|
138
|
+
let publishedIdentity;
|
|
106
139
|
try {
|
|
140
|
+
publishedIdentity = await directoryIdentity(candidate);
|
|
107
141
|
await renamePath(candidate, live);
|
|
108
142
|
phase = "post-replacement";
|
|
109
143
|
}
|
|
@@ -125,14 +159,72 @@ export async function atomicReplaceDir(candidate, live, options = {}) {
|
|
|
125
159
|
await removePath(candidate, { recursive: true, force: true });
|
|
126
160
|
throw new SafetyError("atomic", "directory activation failed; previous tree restored", { cause, details: { phase: "pre-replacement" } });
|
|
127
161
|
}
|
|
128
|
-
|
|
162
|
+
const retainedBackup = backupCreated ? backup : null;
|
|
163
|
+
const retainedBackupIdentity = backupCreated ? backupIdentity : null;
|
|
164
|
+
let settled = false;
|
|
165
|
+
const claimSettlement = () => {
|
|
166
|
+
if (settled) {
|
|
167
|
+
throw publicationFailure("directory publication already settled", new Error("publication already settled"));
|
|
168
|
+
}
|
|
169
|
+
settled = true;
|
|
170
|
+
};
|
|
171
|
+
const requireRetainedBackup = async (action) => {
|
|
172
|
+
if (retainedBackup === null)
|
|
173
|
+
return;
|
|
174
|
+
let currentIdentity;
|
|
129
175
|
try {
|
|
130
|
-
await
|
|
176
|
+
currentIdentity = await directoryIdentity(retainedBackup);
|
|
131
177
|
}
|
|
132
178
|
catch (cause) {
|
|
133
|
-
throw
|
|
179
|
+
throw publicationFailure(`directory ${action} refused because backup changed unexpectedly at ${retainedBackup}`, cause);
|
|
134
180
|
}
|
|
135
|
-
|
|
181
|
+
if (retainedBackupIdentity === null ||
|
|
182
|
+
!sameIdentity(currentIdentity, retainedBackupIdentity)) {
|
|
183
|
+
throw publicationFailure(`directory ${action} refused because backup changed unexpectedly at ${retainedBackup}`, new Error("retained backup directory identity changed"));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
live,
|
|
188
|
+
backup: retainedBackup,
|
|
189
|
+
async finalize() {
|
|
190
|
+
claimSettlement();
|
|
191
|
+
if (retainedBackup === null)
|
|
192
|
+
return;
|
|
193
|
+
await requireRetainedBackup("finalization");
|
|
194
|
+
try {
|
|
195
|
+
await removePath(retainedBackup, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
catch (cause) {
|
|
198
|
+
throw publicationFailure(`directory replacement succeeded but backup cleanup failed at ${retainedBackup}`, cause);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
async rollback() {
|
|
202
|
+
claimSettlement();
|
|
203
|
+
await requireRetainedBackup("rollback");
|
|
204
|
+
let currentIdentity;
|
|
205
|
+
try {
|
|
206
|
+
currentIdentity = await directoryIdentity(live);
|
|
207
|
+
}
|
|
208
|
+
catch (cause) {
|
|
209
|
+
throw publicationFailure(`directory rollback refused because live tree changed unexpectedly at ${live}`, cause);
|
|
210
|
+
}
|
|
211
|
+
if (!sameIdentity(currentIdentity, publishedIdentity)) {
|
|
212
|
+
throw publicationFailure(`directory rollback refused because live tree changed unexpectedly at ${live}`, new Error("published directory identity changed"));
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
await removePath(live, { recursive: true, force: true });
|
|
216
|
+
if (retainedBackup !== null) {
|
|
217
|
+
await renamePath(retainedBackup, live);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (cause) {
|
|
221
|
+
const message = retainedBackup === null
|
|
222
|
+
? "directory rollback failed with no prior tree"
|
|
223
|
+
: `directory rollback failed; backup preserved at ${retainedBackup}`;
|
|
224
|
+
throw publicationFailure(message, cause);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
136
228
|
}
|
|
137
229
|
catch (cause) {
|
|
138
230
|
if (cause instanceof SafetyError)
|
|
@@ -140,3 +232,7 @@ export async function atomicReplaceDir(candidate, live, options = {}) {
|
|
|
140
232
|
throw new SafetyError("atomic", "directory replacement failed", { cause, details: { phase } });
|
|
141
233
|
}
|
|
142
234
|
}
|
|
235
|
+
export async function atomicReplaceDir(candidate, live, options = {}) {
|
|
236
|
+
const pending = await beginDirectoryPublication(candidate, live, options);
|
|
237
|
+
await pending.finalize();
|
|
238
|
+
}
|
package/dist/cli-arguments.js
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
export class UsageError extends Error {
|
|
2
2
|
}
|
|
3
|
+
export function extractHarnessOptions(command, argv) {
|
|
4
|
+
let harness = "codex";
|
|
5
|
+
let targeted = false;
|
|
6
|
+
let allowExperimental = false;
|
|
7
|
+
const args = [];
|
|
8
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
9
|
+
const token = argv[index];
|
|
10
|
+
if (token === "--harness" || token.startsWith("--harness=")) {
|
|
11
|
+
if (command === "pin" ||
|
|
12
|
+
command === "unpin" ||
|
|
13
|
+
command === "track-latest")
|
|
14
|
+
throw new UsageError("harness targeting is not available for shared selection commands");
|
|
15
|
+
if (targeted)
|
|
16
|
+
throw new UsageError("duplicate option: --harness");
|
|
17
|
+
targeted = true;
|
|
18
|
+
const value = token === "--harness"
|
|
19
|
+
? argv[++index]
|
|
20
|
+
: token.slice("--harness=".length);
|
|
21
|
+
if (!value || value.startsWith("--"))
|
|
22
|
+
throw new UsageError("option --harness requires a value");
|
|
23
|
+
if (value !== "codex" && value !== "pi")
|
|
24
|
+
throw new UsageError(`unknown harness: ${value}`);
|
|
25
|
+
harness = value;
|
|
26
|
+
}
|
|
27
|
+
else if (token === "--allow-experimental") {
|
|
28
|
+
if (command !== "install" && command !== "update")
|
|
29
|
+
throw new UsageError("--allow-experimental is only available for install or update");
|
|
30
|
+
if (allowExperimental)
|
|
31
|
+
throw new UsageError("duplicate option: --allow-experimental");
|
|
32
|
+
allowExperimental = true;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
args.push(token);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { options: { harness, allowExperimental }, args };
|
|
39
|
+
}
|
|
3
40
|
export function parseFlags(argv, names) {
|
|
4
41
|
const allowed = new Set(names);
|
|
5
42
|
const result = {};
|