superpowers-manager 0.4.0 → 0.5.1
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 +76 -517
- package/dist/artifact-tree.js +111 -0
- package/dist/atomic.js +103 -7
- package/dist/cli-arguments.js +37 -0
- package/dist/cli.js +49 -12
- package/dist/commands/install.js +116 -17
- package/dist/commands/mutation.js +71 -0
- package/dist/commands/prepare.js +23 -17
- package/dist/commands/probe.js +164 -14
- package/dist/commands/uninstall.js +9 -5
- package/dist/commands/update.js +6 -2
- package/dist/domain/refs.js +4 -0
- package/dist/harness-compatibility.js +13 -0
- package/dist/{adapter.js → harnesses/codex/adapter.js} +17 -13
- package/dist/harnesses/codex/compatibility.js +126 -0
- package/dist/harnesses/codex/conflicts.js +99 -0
- package/dist/{generated-plugin.js → harnesses/codex/generated-plugin.js} +7 -73
- package/dist/{codex-harness.js → harnesses/codex/harness.js} +55 -5
- package/dist/{hooks.js → harnesses/codex/hooks.js} +6 -6
- package/dist/{codex-json.js → harnesses/codex/json.js} +9 -2
- package/dist/{lifecycle.js → harnesses/codex/lifecycle.js} +2 -2
- package/dist/{manifest-overlay.js → harnesses/codex/manifest-overlay.js} +5 -5
- package/dist/{codex-prepare.js → harnesses/codex/prepare.js} +72 -16
- package/dist/{codex-presentation.js → harnesses/codex/presentation.js} +27 -4
- package/dist/{codex-state.js → harnesses/codex/state.js} +3 -3
- package/dist/harnesses/pi/compatibility.js +73 -0
- package/dist/harnesses/pi/harness.js +63 -0
- package/dist/harnesses/pi/install.js +495 -0
- package/dist/harnesses/pi/native.js +81 -0
- package/dist/harnesses/pi/package.js +153 -0
- package/dist/harnesses/pi/paths.js +30 -0
- package/dist/harnesses/pi/prepare.js +111 -0
- package/dist/harnesses/pi/presentation.js +67 -0
- package/dist/harnesses/pi/settings.js +171 -0
- package/dist/harnesses/pi/shared-skills.js +413 -0
- package/dist/harnesses/pi/state.js +311 -0
- package/dist/python-text.js +16 -0
- package/dist/resource-lock.js +265 -0
- package/dist/safe-path.js +21 -5
- package/dist/skill-validation.js +52 -0
- package/dist/upstream-workspace.js +8 -0
- package/dist/validate-generated-plugin-cli.js +1 -1
- package/dist/validator.js +2 -1
- package/package.json +12 -8
|
@@ -0,0 +1,111 @@
|
|
|
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 } from "./python-text.js";
|
|
8
|
+
import { DEFAULT_FS_DEPS, validateSkillFrontmatter, } from "./skill-validation.js";
|
|
9
|
+
export const ARTIFACT_RECEIPT = ".superpowers-manager.json";
|
|
10
|
+
export const ARTIFACT_DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
11
|
+
const DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
12
|
+
export function addArtifactHashField(hash, bytes) {
|
|
13
|
+
const length = Buffer.alloc(8);
|
|
14
|
+
length.writeBigUInt64BE(BigInt(bytes.byteLength));
|
|
15
|
+
hash.update(length);
|
|
16
|
+
hash.update(bytes);
|
|
17
|
+
}
|
|
18
|
+
export async function readArtifactFile(root, path, limit = 1024 * 1024) {
|
|
19
|
+
try {
|
|
20
|
+
await assertExistingContained(root, path);
|
|
21
|
+
await assertNoFollowType(path, ["regular-file"]);
|
|
22
|
+
if ((await lstat(path)).size > limit)
|
|
23
|
+
throw new Error("size");
|
|
24
|
+
const bytes = await readFile(path);
|
|
25
|
+
if (bytes.length > limit)
|
|
26
|
+
throw new Error("size");
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
throw new SafetyError("artifact", `cannot read bounded artifact file: ${path}`, { cause });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function readArtifactObject(root, path) {
|
|
34
|
+
try {
|
|
35
|
+
const value = parseStrictJson(await readArtifactFile(root, path), {
|
|
36
|
+
duplicateKeys: "reject",
|
|
37
|
+
nonStandardConstants: "reject",
|
|
38
|
+
maxDepth: 64,
|
|
39
|
+
});
|
|
40
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
41
|
+
throw new Error("object");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new SafetyError("artifact", `invalid artifact JSON object: ${path}`, {
|
|
46
|
+
cause,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function validateNativeSkill(root) {
|
|
51
|
+
const path = join(root, "skills", "using-superpowers", "SKILL.md");
|
|
52
|
+
await readArtifactFile(root, path);
|
|
53
|
+
const errors = [];
|
|
54
|
+
await validateSkillFrontmatter(path, "using-superpowers", errors, DEFAULT_FS_DEPS);
|
|
55
|
+
if (errors.length)
|
|
56
|
+
throw new SafetyError("artifact", `invalid native bootstrap skill: ${path}`);
|
|
57
|
+
}
|
|
58
|
+
export async function digestArtifactTree(root) {
|
|
59
|
+
try {
|
|
60
|
+
await assertNoFollowType(root, ["directory"]);
|
|
61
|
+
const paths = [];
|
|
62
|
+
async function visit(relative) {
|
|
63
|
+
for (const bytes of await readdir(join(root, relative), {
|
|
64
|
+
encoding: "buffer",
|
|
65
|
+
})) {
|
|
66
|
+
const name = DECODER.decode(bytes);
|
|
67
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
68
|
+
if (path === ARTIFACT_RECEIPT)
|
|
69
|
+
continue;
|
|
70
|
+
paths.push(path);
|
|
71
|
+
if ((await lstat(join(root, path))).isDirectory())
|
|
72
|
+
await visit(path);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await visit("");
|
|
76
|
+
const hash = createHash("sha256");
|
|
77
|
+
for (const path of paths.sort(compareByCodePoint)) {
|
|
78
|
+
const absolute = join(root, path), info = await lstat(absolute);
|
|
79
|
+
let kind, contents;
|
|
80
|
+
if (info.isDirectory()) {
|
|
81
|
+
kind = "directory";
|
|
82
|
+
contents = Buffer.alloc(0);
|
|
83
|
+
}
|
|
84
|
+
else if (info.isFile()) {
|
|
85
|
+
kind = "file";
|
|
86
|
+
contents = await readFile(absolute);
|
|
87
|
+
}
|
|
88
|
+
else if (info.isSymbolicLink()) {
|
|
89
|
+
await assertSymlinkTargetContained(root, absolute);
|
|
90
|
+
kind = "link";
|
|
91
|
+
contents = await readlink(absolute, { encoding: "buffer" });
|
|
92
|
+
DECODER.decode(contents);
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
throw new Error("entry type");
|
|
96
|
+
for (const bytes of [
|
|
97
|
+
Buffer.from(kind),
|
|
98
|
+
Buffer.from(path),
|
|
99
|
+
Buffer.from(String(info.isFile() ? info.mode & 0o111 : 0)),
|
|
100
|
+
contents,
|
|
101
|
+
])
|
|
102
|
+
addArtifactHashField(hash, bytes);
|
|
103
|
+
}
|
|
104
|
+
return hash.digest("hex");
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
throw new SafetyError("artifact", `cannot digest artifact tree: ${root}`, {
|
|
108
|
+
cause,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
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 = {};
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { oneLine } from "./cli-arguments.js";
|
|
5
|
-
import { codexHarness } from "./codex
|
|
4
|
+
import { extractHarnessOptions, oneLine, UsageError } from "./cli-arguments.js";
|
|
5
|
+
import { codexHarness } from "./harnesses/codex/harness.js";
|
|
6
|
+
import { piHarness } from "./harnesses/pi/harness.js";
|
|
7
|
+
import { createResourceCoordinator } from "./resource-lock.js";
|
|
6
8
|
import { runInstall } from "./commands/install.js";
|
|
7
9
|
import { runPin } from "./commands/pin.js";
|
|
8
10
|
import { runPrepare } from "./commands/prepare.js";
|
|
@@ -85,14 +87,28 @@ function isMain(moduleFilename, argvPath) {
|
|
|
85
87
|
function parseArgs(argv) {
|
|
86
88
|
const first = argv[0];
|
|
87
89
|
if (argv.length === 0)
|
|
88
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
kind: "run",
|
|
92
|
+
cmd: "update",
|
|
93
|
+
args: [],
|
|
94
|
+
options: { harness: "codex", allowExperimental: false },
|
|
95
|
+
};
|
|
89
96
|
if (first === "--help" || first === "-h")
|
|
90
97
|
return { kind: "help" };
|
|
91
98
|
if (first === "--version")
|
|
92
99
|
return { kind: "version" };
|
|
93
100
|
if (first && SUBCOMMANDS.includes(first)) {
|
|
94
101
|
const command = first;
|
|
95
|
-
|
|
102
|
+
let extracted;
|
|
103
|
+
try {
|
|
104
|
+
extracted = extractHarnessOptions(command, argv.slice(1));
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
if (cause instanceof UsageError)
|
|
108
|
+
return { kind: "usage-error", message: cause.message };
|
|
109
|
+
throw cause;
|
|
110
|
+
}
|
|
111
|
+
const { args, options } = extracted;
|
|
96
112
|
if (command === "pin" && args.length !== 1) {
|
|
97
113
|
return {
|
|
98
114
|
kind: "usage-error",
|
|
@@ -128,7 +144,7 @@ function parseArgs(argv) {
|
|
|
128
144
|
message: "usage: superpowers-manager probe [--porcelain]",
|
|
129
145
|
};
|
|
130
146
|
}
|
|
131
|
-
return { kind: "run", cmd: command, args };
|
|
147
|
+
return { kind: "run", cmd: command, args, options };
|
|
132
148
|
}
|
|
133
149
|
return { kind: "usage-error", message: `unknown subcommand: ${first}` };
|
|
134
150
|
}
|
|
@@ -232,6 +248,14 @@ function usage() {
|
|
|
232
248
|
" update probe, then prepare/install only if needed (default when no subcommand)",
|
|
233
249
|
" uninstall remove the manager plugin and marketplace from Codex",
|
|
234
250
|
"",
|
|
251
|
+
"Target one invocation (default: codex; selection commands are shared):",
|
|
252
|
+
" probe --harness pi [--porcelain]",
|
|
253
|
+
" prepare --harness pi",
|
|
254
|
+
" install --harness pi [--allow-experimental]",
|
|
255
|
+
" update --harness pi [--allow-experimental]",
|
|
256
|
+
" uninstall --harness pi",
|
|
257
|
+
" --harness=codex and --harness=pi are also accepted after the command.",
|
|
258
|
+
"",
|
|
235
259
|
"Environment overrides (used by in-process commands): SUPERPOWERS_REF,",
|
|
236
260
|
"SUPERPOWERS_UPSTREAM_URL, SUPERPOWERS_CODEX, SUPERPOWERS_CACHE_DIR,",
|
|
237
261
|
"SUPERPOWERS_CONFIG_DIR, XDG_CONFIG_HOME,",
|
|
@@ -264,11 +288,22 @@ async function main() {
|
|
|
264
288
|
console.error(usage());
|
|
265
289
|
process.exit(2);
|
|
266
290
|
}
|
|
267
|
-
const
|
|
291
|
+
const status = parsed.options.harness === "pi"
|
|
292
|
+
? await dispatch(piHarness, parsed)
|
|
293
|
+
: await dispatch(codexHarness, parsed);
|
|
294
|
+
process.exit(status);
|
|
295
|
+
}
|
|
296
|
+
async function dispatch(adapter, parsed) {
|
|
297
|
+
const root = resolvePackageRoot(import.meta.filename);
|
|
298
|
+
if (!root) {
|
|
299
|
+
console.error("error: cannot resolve the superpowers-manager package root");
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
const pf = preflightFor(parsed.cmd, process.env, process.platform, adapter);
|
|
268
303
|
if (!pf.ok) {
|
|
269
304
|
for (const e of pf.errors)
|
|
270
305
|
console.error(`error: ${e}`);
|
|
271
|
-
|
|
306
|
+
return 1;
|
|
272
307
|
}
|
|
273
308
|
// Dispatch is no longer a branch: slice 4b flipped the last spawned command,
|
|
274
309
|
// so every subcommand runs here, and slice 6 deleted the DISPATCH table whose
|
|
@@ -281,15 +316,17 @@ async function main() {
|
|
|
281
316
|
const handler = IN_PROCESS_HANDLERS[parsed.cmd];
|
|
282
317
|
if (!handler) {
|
|
283
318
|
console.error(`error: no in-process handler registered for: ${parsed.cmd}`);
|
|
284
|
-
|
|
319
|
+
return 1;
|
|
285
320
|
}
|
|
286
321
|
const ctx = {
|
|
287
322
|
root,
|
|
288
323
|
env: process.env,
|
|
289
324
|
stdout: process.stdout,
|
|
290
325
|
stderr: process.stderr,
|
|
326
|
+
options: parsed.options,
|
|
327
|
+
coordination: createResourceCoordinator(),
|
|
291
328
|
// The ONLY production binding of a concrete harness implementation.
|
|
292
|
-
adapter
|
|
329
|
+
adapter,
|
|
293
330
|
};
|
|
294
331
|
let status;
|
|
295
332
|
try {
|
|
@@ -300,11 +337,11 @@ async function main() {
|
|
|
300
337
|
// failures and returns a status code (see src/commands/unpin.ts). This
|
|
301
338
|
// re-emits a subordinate module's own diagnostic if one somehow escapes.
|
|
302
339
|
console.error(`error: ${oneLine(cause)}`);
|
|
303
|
-
|
|
340
|
+
return 1;
|
|
304
341
|
}
|
|
305
|
-
|
|
342
|
+
return status;
|
|
306
343
|
}
|
|
307
|
-
export { resolvePackageRoot, isMain, parseArgs, findTool, commandRequirements, commandRequirementsFor, preflight, preflightFor, usage, main, };
|
|
344
|
+
export { resolvePackageRoot, isMain, parseArgs, findTool, commandRequirements, commandRequirementsFor, preflight, preflightFor, usage, dispatch, main, };
|
|
308
345
|
if (isMain(import.meta.filename, process.argv[1])) {
|
|
309
346
|
main().catch((cause) => {
|
|
310
347
|
console.error(`error: ${oneLine(cause)}`);
|
package/dist/commands/install.js
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
// Ports scripts/install. The shell sourced common.sh, provenance.sh,
|
|
2
2
|
// status.sh, lifecycle.sh and adapter.sh; the predicates now live in
|
|
3
|
-
// src/lifecycle.ts, the generated-metadata read lives in src/provenance.ts,
|
|
3
|
+
// src/harnesses/codex/lifecycle.ts, the generated-metadata read lives in src/provenance.ts,
|
|
4
4
|
// and the adapter arrives through ctx.adapter.
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { oneLine } from "../cli-arguments.js";
|
|
7
7
|
import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
|
|
8
|
-
import { gatherProbe, replayOutcome } from "./probe.js";
|
|
8
|
+
import { gatherProbe, replayOutcome, validInstalled, validOutput, } from "./probe.js";
|
|
9
9
|
import { runPrepare } from "./prepare.js";
|
|
10
|
+
import { runWithMutation } from "./mutation.js";
|
|
11
|
+
import { activationBlock } from "../harness-compatibility.js";
|
|
10
12
|
function writeOutput(output, ctx) {
|
|
11
13
|
for (const line of output.stdout)
|
|
12
14
|
ctx.stdout.write(`${line}\n`);
|
|
13
15
|
for (const line of output.stderr)
|
|
14
16
|
ctx.stderr.write(`${line}\n`);
|
|
15
17
|
}
|
|
16
|
-
async function invoke(call, failure, outcomes) {
|
|
18
|
+
async function invoke(call, failure, outcomes, valid = () => true) {
|
|
17
19
|
let result;
|
|
18
20
|
try {
|
|
19
21
|
result = await call();
|
|
22
|
+
if (!result ||
|
|
23
|
+
!Number.isInteger(result.status) ||
|
|
24
|
+
!result.outcome ||
|
|
25
|
+
typeof result.outcome.ok !== "boolean" ||
|
|
26
|
+
typeof result.outcome.operation !== "string" ||
|
|
27
|
+
!Array.isArray(result.outcome.messages) ||
|
|
28
|
+
!result.outcome.messages.every((message) => message &&
|
|
29
|
+
(message.channel === "stdout" || message.channel === "stderr") &&
|
|
30
|
+
typeof message.text === "string") ||
|
|
31
|
+
(result.outcome.ok
|
|
32
|
+
? result.outcome.error !== null || !valid(result.outcome.result)
|
|
33
|
+
: !result.outcome.error ||
|
|
34
|
+
typeof result.outcome.error.code !== "string" ||
|
|
35
|
+
typeof result.outcome.error.message !== "string" ||
|
|
36
|
+
!Array.isArray(result.outcome.error.hints) ||
|
|
37
|
+
!result.outcome.error.hints.every((hint) => typeof hint === "string"))) {
|
|
38
|
+
return { ok: false, message: failure.invalidStatus, result: null };
|
|
39
|
+
}
|
|
20
40
|
}
|
|
21
41
|
catch {
|
|
22
42
|
return { ok: false, message: failure.unexpected, result: null };
|
|
@@ -35,6 +55,12 @@ async function invoke(call, failure, outcomes) {
|
|
|
35
55
|
result: { status: result.status, outcome },
|
|
36
56
|
};
|
|
37
57
|
}
|
|
58
|
+
function validReceipt(value) {
|
|
59
|
+
return (value !== null &&
|
|
60
|
+
typeof value === "object" &&
|
|
61
|
+
validOutput(value.missingVerificationOutput) &&
|
|
62
|
+
validOutput(value.mismatchVerificationOutput));
|
|
63
|
+
}
|
|
38
64
|
// withWorkspace can throw AFTER its callback has already returned a fully
|
|
39
65
|
// computed StageOutcome: a post-success cleanup failure discards that return
|
|
40
66
|
// value entirely and rejects instead, UNLESS an `onCleanupFailure` reporter is
|
|
@@ -103,10 +129,30 @@ async function gatherInstallStages(ctx, selection, artifact) {
|
|
|
103
129
|
}
|
|
104
130
|
// Stage 3: the mutation itself. Nothing above may have issued this --
|
|
105
131
|
// that is the whole point of stages 1 and 2 running first.
|
|
132
|
+
const activationFailure = activationBlock(artifact.compatibility, ctx.options.allowExperimental);
|
|
133
|
+
if (activationFailure !== null)
|
|
134
|
+
return failed(activationFailure);
|
|
106
135
|
const installFailure = ctx.adapter.presentation.callFailure("install", adapterContext);
|
|
107
|
-
const install = await invoke(() => ctx.adapter.install(artifact, adapterContext), installFailure, outcomes);
|
|
136
|
+
const install = await invoke(() => ctx.adapter.install(artifact, adapterContext), installFailure, outcomes, validReceipt);
|
|
108
137
|
if (!install.ok)
|
|
109
138
|
return failed(install.message);
|
|
139
|
+
let transaction;
|
|
140
|
+
try {
|
|
141
|
+
const candidate = install.result.outcome.result.transaction;
|
|
142
|
+
if (candidate !== undefined) {
|
|
143
|
+
if (candidate === null || typeof candidate !== "object")
|
|
144
|
+
throw new Error("invalid transaction");
|
|
145
|
+
const finalize = candidate.finalize.bind(candidate);
|
|
146
|
+
const rollback = candidate.rollback.bind(candidate);
|
|
147
|
+
if (typeof finalize !== "function" ||
|
|
148
|
+
typeof rollback !== "function")
|
|
149
|
+
throw new Error("invalid transaction");
|
|
150
|
+
transaction = { finalize, rollback };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return failed("invalid installation transaction receipt; preserve recovery material for manual resolution");
|
|
155
|
+
}
|
|
110
156
|
// Stage 4: inspect fingerprint, to verify the mutation actually took.
|
|
111
157
|
//
|
|
112
158
|
// Deliberately NOT short-circuited on `!inspected.ok`, unlike stages
|
|
@@ -117,27 +163,71 @@ async function gatherInstallStages(ctx, selection, artifact) {
|
|
|
117
163
|
// is what turns a failed inspection into "error: installed manager
|
|
118
164
|
// fingerprint inspection failed after install."
|
|
119
165
|
// renderInstallVerification's failed-inspection arm
|
|
120
|
-
// (`src/codex
|
|
166
|
+
// (`src/harnesses/codex/presentation.ts:329::if (inspection.status !== 0 || !inspection.outcome.ok) {`)
|
|
121
167
|
// exists for this result-bearing path; the lifecycle compatibility
|
|
122
168
|
// export delegates through the same arm. Returning
|
|
123
169
|
// failed() instead reported the adapter's own generic diagnostic and
|
|
124
170
|
// dropped the post-install verification claim -- a mutation had
|
|
125
171
|
// already been issued at stage 3,
|
|
126
172
|
// so "the install could not be verified" is the contract, not "an
|
|
127
|
-
// adapter call failed". A ctx.adapter THROW
|
|
128
|
-
//
|
|
173
|
+
// adapter call failed". A ctx.adapter THROW has no result to render.
|
|
174
|
+
// A pending transaction still rolls back before reporting that failure.
|
|
129
175
|
const inspectionFailure = ctx.adapter.presentation.callFailure("post-install", adapterContext);
|
|
130
|
-
const inspected = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes);
|
|
131
|
-
if (!inspected.ok && inspected.result === null) {
|
|
132
|
-
return failed(inspected.message);
|
|
133
|
-
}
|
|
176
|
+
const inspected = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
|
|
134
177
|
const inspection = inspected.result;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const output = ctx.adapter.presentation.renderInstallVerification(selection.desiredCommit, install.result, inspection);
|
|
138
|
-
const verified = inspection.status === 0 &&
|
|
178
|
+
const verified = inspection !== null &&
|
|
179
|
+
inspection.status === 0 &&
|
|
139
180
|
inspection.outcome.ok &&
|
|
140
181
|
inspection.outcome.result.kind === "current";
|
|
182
|
+
if (transaction !== undefined) {
|
|
183
|
+
const operation = verified ? "finalize" : "rollback";
|
|
184
|
+
const settlement = await invoke(() => (verified ? transaction.finalize() : transaction.rollback()), {
|
|
185
|
+
unexpected: `installation ${operation} did not complete; preserve recovery material for manual resolution`,
|
|
186
|
+
invalidStatus: `installation ${operation} returned an invalid result; preserve recovery material for manual resolution`,
|
|
187
|
+
}, outcomes, (value) => value === null);
|
|
188
|
+
if (!verified) {
|
|
189
|
+
const restored = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
|
|
190
|
+
const restoration = restored.ok
|
|
191
|
+
? `installed state after rollback: ${restored.result.outcome.result.kind}; identity=${restored.result.outcome.result.observedIdentity}`
|
|
192
|
+
: "installed state after rollback could not be inspected";
|
|
193
|
+
const message = !settlement.ok
|
|
194
|
+
? settlement.message
|
|
195
|
+
: !inspected.ok
|
|
196
|
+
? inspected.message
|
|
197
|
+
: "installation could not be verified";
|
|
198
|
+
return {
|
|
199
|
+
kind: "verified",
|
|
200
|
+
outcomes,
|
|
201
|
+
status: 1,
|
|
202
|
+
stdout: [],
|
|
203
|
+
stderr: [
|
|
204
|
+
...(message === null ? [] : [`error: ${message}`]),
|
|
205
|
+
restoration,
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!settlement.ok) {
|
|
210
|
+
const actual = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
|
|
211
|
+
return {
|
|
212
|
+
kind: "verified",
|
|
213
|
+
outcomes,
|
|
214
|
+
status: 1,
|
|
215
|
+
stdout: [],
|
|
216
|
+
stderr: [
|
|
217
|
+
"error: installation was verified, but transaction cleanup did not complete; preserve recovery material for manual resolution",
|
|
218
|
+
actual.ok
|
|
219
|
+
? `installed state after failed finalization: ${actual.result.outcome.result.kind}; identity=${actual.result.outcome.result.observedIdentity}`
|
|
220
|
+
: "installed state after failed finalization could not be inspected",
|
|
221
|
+
...(settlement.message === null
|
|
222
|
+
? []
|
|
223
|
+
: [`error: ${settlement.message}`]),
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (inspection === null)
|
|
229
|
+
return failed(inspected.ok ? null : inspected.message);
|
|
230
|
+
const output = ctx.adapter.presentation.renderInstallVerification(selection.desiredCommit, install.result, inspection);
|
|
141
231
|
return {
|
|
142
232
|
kind: "verified",
|
|
143
233
|
outcomes,
|
|
@@ -164,10 +254,14 @@ async function gatherInstallStages(ctx, selection, artifact) {
|
|
|
164
254
|
}
|
|
165
255
|
}
|
|
166
256
|
export async function runInstall(argv, ctx) {
|
|
257
|
+
if (ctx.adapter.presentation.installNotice !== "")
|
|
258
|
+
ctx.stdout.write(`${ctx.adapter.presentation.installNotice}\n`);
|
|
259
|
+
return await runWithMutation("install", ctx, async (scoped) => performInstall(argv, scoped));
|
|
260
|
+
}
|
|
261
|
+
async function performInstall(argv, ctx) {
|
|
167
262
|
// scripts/install never reads "$@", so extra arguments are silently
|
|
168
263
|
// ignored -- the same asymmetry runPrepare and runUninstall document.
|
|
169
264
|
void argv;
|
|
170
|
-
ctx.stdout.write(`${ctx.adapter.presentation.installNotice}\n`);
|
|
171
265
|
let probe;
|
|
172
266
|
try {
|
|
173
267
|
probe = await gatherProbe(ctx);
|
|
@@ -179,7 +273,7 @@ export async function runInstall(argv, ctx) {
|
|
|
179
273
|
// runs only after this try/catch has resolved.
|
|
180
274
|
//
|
|
181
275
|
// This is a SECOND consumer of gatherProbe's throw channel --
|
|
182
|
-
// `src/commands/probe.ts:
|
|
276
|
+
// `src/commands/probe.ts:469-527::THREE exceptions, all inherited and none a regression:`'s
|
|
183
277
|
// runProbe catch is the first. Because both consumers wrap the identical
|
|
184
278
|
// function, its long comment there enumerates exactly what can reach THIS
|
|
185
279
|
// stream too, including the three foreign-text exceptions at :251-296:
|
|
@@ -215,6 +309,11 @@ export async function runInstall(argv, ctx) {
|
|
|
215
309
|
writeOutput(facts.ownership.installEligibility.output, ctx);
|
|
216
310
|
return 1;
|
|
217
311
|
}
|
|
312
|
+
if (facts.resourceState === "recovery-required" &&
|
|
313
|
+
facts.control.probeEligibility.kind === "blocked") {
|
|
314
|
+
writeOutput(facts.control.probeEligibility.output, ctx);
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
218
317
|
switch (facts.status) {
|
|
219
318
|
case "needs prepare": {
|
|
220
319
|
const prepareStatus = await runPrepare([], ctx);
|