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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { failureResult, successResult } from "./adapter-result.js";
|
|
2
|
+
import { classifyPathNoFollow } from "./safe-path.js";
|
|
3
|
+
import { installPi, removePi } from "./pi-install.js";
|
|
4
|
+
import { piPaths } from "./pi-paths.js";
|
|
5
|
+
import { inspectPiPrepared, piPreparationLocation, preparePiCandidate, readPiPrepared, } from "./pi-prepare.js";
|
|
6
|
+
import { piPresentation } from "./pi-presentation.js";
|
|
7
|
+
import { inspectPiControl, inspectPiInstalled, inspectPiOwnership, } from "./pi-state.js";
|
|
8
|
+
export const piHarness = {
|
|
9
|
+
preparationLocation: piPreparationLocation,
|
|
10
|
+
mutationRoots: async (ctx) => [
|
|
11
|
+
piPaths(ctx.env ?? {}, process.cwd()).agentDir,
|
|
12
|
+
],
|
|
13
|
+
validatePreparationBeforeFetch: async () => successResult("validate-pi-preparation", null, []),
|
|
14
|
+
prepareCandidate: preparePiCandidate,
|
|
15
|
+
inspectPrepared: inspectPiPrepared,
|
|
16
|
+
readPrepared: readPiPrepared,
|
|
17
|
+
inspectOwnership: inspectPiOwnership,
|
|
18
|
+
async inspectUpdateControl(ctx) {
|
|
19
|
+
const paths = piPaths(ctx.env ?? {}, process.cwd());
|
|
20
|
+
try {
|
|
21
|
+
if ((await classifyPathNoFollow(paths.recoveryRoot)) !== "missing") {
|
|
22
|
+
const decision = {
|
|
23
|
+
kind: "blocked",
|
|
24
|
+
output: {
|
|
25
|
+
stdout: [],
|
|
26
|
+
stderr: [
|
|
27
|
+
`error: Pi recovery required; preserve material at ${paths.recoveryRoot} for manual resolution`,
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return successResult("inspect-pi-control", {
|
|
32
|
+
probeEligibility: decision,
|
|
33
|
+
mutationEligibility: decision,
|
|
34
|
+
presentationValue: `recovery required at ${paths.recoveryRoot}`,
|
|
35
|
+
recoveryState: "required",
|
|
36
|
+
}, []);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return failureResult("inspect-pi-control", "invalid-state", `cannot inspect Pi recovery state at ${paths.recoveryRoot}`, [], []);
|
|
41
|
+
}
|
|
42
|
+
return await inspectPiControl(ctx);
|
|
43
|
+
},
|
|
44
|
+
inspectInstalled: inspectPiInstalled,
|
|
45
|
+
install: installPi,
|
|
46
|
+
remove: removePi,
|
|
47
|
+
requirements(command, env) {
|
|
48
|
+
if (command !== "install" &&
|
|
49
|
+
command !== "update" &&
|
|
50
|
+
command !== "uninstall")
|
|
51
|
+
return [];
|
|
52
|
+
const executable = env.SUPERPOWERS_PI || "pi";
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
name: "pi",
|
|
56
|
+
executable,
|
|
57
|
+
lookup: "explicit-path-or-path",
|
|
58
|
+
missingMessage: `required command not found: ${executable} — install the Pi CLI or set SUPERPOWERS_PI`,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
presentation: piPresentation,
|
|
63
|
+
};
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, unlink, } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { failureResult, successResult, } from "./adapter-result.js";
|
|
5
|
+
import { atomicWriteFile, beginDirectoryPublication, } from "./atomic.js";
|
|
6
|
+
import { normalizePiRuntimeVersion, runPi } from "./pi-native.js";
|
|
7
|
+
import { digestPiTree, readPiPackageAssessment, readPiReceipt, } from "./pi-package.js";
|
|
8
|
+
import { piPaths } from "./pi-paths.js";
|
|
9
|
+
import { readPiSettings, resolveCanonicalPiLocalSource, } from "./pi-settings.js";
|
|
10
|
+
import { inspectPiControl, inspectPiOwnership, } from "./pi-state.js";
|
|
11
|
+
import { assertNoFollowType, canonicalizeProspectivePath, classifyPathNoFollow, } from "./safe-path.js";
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
run: runPi,
|
|
14
|
+
beginPublication: beginDirectoryPublication,
|
|
15
|
+
readSettings: readPiSettings,
|
|
16
|
+
};
|
|
17
|
+
function fail(code, message) {
|
|
18
|
+
return failureResult("pi-lifecycle", code, message, [], []);
|
|
19
|
+
}
|
|
20
|
+
function accepted(result) {
|
|
21
|
+
if (result.status !== 0 || !result.outcome.ok)
|
|
22
|
+
throw new Error("Pi operation did not succeed");
|
|
23
|
+
return result.outcome.result;
|
|
24
|
+
}
|
|
25
|
+
function sameIdentity(a, b) {
|
|
26
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
27
|
+
}
|
|
28
|
+
async function identity(path) {
|
|
29
|
+
await assertNoFollowType(path, ["directory"]);
|
|
30
|
+
return await lstat(path);
|
|
31
|
+
}
|
|
32
|
+
async function requireIdentity(path, expected) {
|
|
33
|
+
if (!sameIdentity(await identity(path), expected))
|
|
34
|
+
throw new Error("Pi directory changed");
|
|
35
|
+
}
|
|
36
|
+
function journalPath(p) {
|
|
37
|
+
return join(p.paths.recoveryRoot, "transaction.json");
|
|
38
|
+
}
|
|
39
|
+
function journalBytes(journal) {
|
|
40
|
+
return Buffer.from(JSON.stringify(journal) + "\n");
|
|
41
|
+
}
|
|
42
|
+
async function validatePaths(paths) {
|
|
43
|
+
await assertNoFollowType(paths.agentDir, ["directory", "missing"]);
|
|
44
|
+
await assertNoFollowType(paths.managerRoot, ["directory", "missing"]);
|
|
45
|
+
await assertNoFollowType(paths.installedRoot, ["directory", "missing"]);
|
|
46
|
+
return await canonicalizeProspectivePath(paths.installedRoot);
|
|
47
|
+
}
|
|
48
|
+
async function requireNoRecovery(paths) {
|
|
49
|
+
await assertNoFollowType(paths.recoveryRoot, ["missing"]);
|
|
50
|
+
}
|
|
51
|
+
async function syncDirectoryStrict(path) {
|
|
52
|
+
const directory = await open(path, "r");
|
|
53
|
+
try {
|
|
54
|
+
await directory.sync();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await directory.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function hasRecovery(paths) {
|
|
61
|
+
try {
|
|
62
|
+
return (await classifyPathNoFollow(paths.recoveryRoot)) !== "missing";
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function registration(paths, deps) {
|
|
69
|
+
const settings = await deps.readSettings(paths.settingsFile, paths.homeDir);
|
|
70
|
+
const canonicalRoot = await canonicalizeProspectivePath(paths.installedRoot);
|
|
71
|
+
const matches = [];
|
|
72
|
+
for (const entry of settings.packages) {
|
|
73
|
+
if ((await resolveCanonicalPiLocalSource(entry.source, paths.agentDir, paths.homeDir)) === canonicalRoot) {
|
|
74
|
+
matches.push(entry);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (matches.length > 1)
|
|
78
|
+
throw new Error("duplicate Pi registration");
|
|
79
|
+
return matches[0] ?? null;
|
|
80
|
+
}
|
|
81
|
+
function sameRegistration(a, b) {
|
|
82
|
+
return a === null
|
|
83
|
+
? b === null
|
|
84
|
+
: b !== null &&
|
|
85
|
+
a.source === b.source &&
|
|
86
|
+
a.resourceState === b.resourceState;
|
|
87
|
+
}
|
|
88
|
+
async function snapshot(root) {
|
|
89
|
+
if ((await assertNoFollowType(root, ["directory", "missing"])) === "missing")
|
|
90
|
+
return null;
|
|
91
|
+
const receipt = await readPiReceipt(root);
|
|
92
|
+
if (receipt.digest !== (await digestPiTree(root)))
|
|
93
|
+
throw new Error("Pi snapshot changed");
|
|
94
|
+
return receipt;
|
|
95
|
+
}
|
|
96
|
+
function sameArtifact(a, b) {
|
|
97
|
+
return a === null
|
|
98
|
+
? b === null
|
|
99
|
+
: b !== null &&
|
|
100
|
+
a.binding === b.binding &&
|
|
101
|
+
a.digest === b.digest &&
|
|
102
|
+
a.commit === b.commit &&
|
|
103
|
+
a.source === b.source;
|
|
104
|
+
}
|
|
105
|
+
async function requireActivationEligibility(ctx) {
|
|
106
|
+
const ownership = accepted(await inspectPiOwnership(ctx));
|
|
107
|
+
const control = accepted(await inspectPiControl(ctx));
|
|
108
|
+
if (ownership.installEligibility.kind !== "allowed" ||
|
|
109
|
+
control.mutationEligibility.kind !== "allowed")
|
|
110
|
+
throw new Error("Pi ownership or control blocked");
|
|
111
|
+
}
|
|
112
|
+
async function requireSnapshot(root, expected) {
|
|
113
|
+
if (!sameArtifact(await snapshot(root), expected))
|
|
114
|
+
throw new Error("Pi artifact changed");
|
|
115
|
+
}
|
|
116
|
+
async function requireJournal(p) {
|
|
117
|
+
if ((await validatePaths(p.paths)) !== p.canonicalRoot ||
|
|
118
|
+
p.journal.installedRoot !== p.canonicalRoot ||
|
|
119
|
+
!/^[a-f0-9]{32}$/u.test(p.journal.token))
|
|
120
|
+
throw new Error("Pi recovery root changed");
|
|
121
|
+
await requireIdentity(p.paths.recoveryRoot, p.recoveryIdentity);
|
|
122
|
+
const path = journalPath(p);
|
|
123
|
+
await assertNoFollowType(path, ["regular-file"]);
|
|
124
|
+
const stat = await lstat(path);
|
|
125
|
+
if (!sameIdentity(stat, p.journalIdentity) ||
|
|
126
|
+
stat.size > 64 * 1024 ||
|
|
127
|
+
!(await readFile(path)).equals(journalBytes(p.journal)))
|
|
128
|
+
throw new Error("Pi recovery journal changed");
|
|
129
|
+
if (p.backup !==
|
|
130
|
+
join(dirname(p.canonicalRoot), `.${basename(p.canonicalRoot)}.bak.${p.journal.token}`) ||
|
|
131
|
+
p.stage !==
|
|
132
|
+
join(dirname(p.canonicalRoot), `.${basename(p.canonicalRoot)}.stage.${p.journal.token}`))
|
|
133
|
+
throw new Error("Pi recovery paths changed");
|
|
134
|
+
}
|
|
135
|
+
async function phase(p, next, changes = {}) {
|
|
136
|
+
await requireJournal(p);
|
|
137
|
+
const journal = { ...p.journal, ...changes, phase: next };
|
|
138
|
+
const bytes = journalBytes(journal);
|
|
139
|
+
await atomicWriteFile(journalPath(p), bytes, {
|
|
140
|
+
validate: async (temporary) => {
|
|
141
|
+
if (!(await readFile(temporary)).equals(bytes))
|
|
142
|
+
throw new Error("Pi journal write changed");
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await syncDirectoryStrict(p.paths.recoveryRoot);
|
|
146
|
+
p.journal = journal;
|
|
147
|
+
p.journalIdentity = await lstat(journalPath(p));
|
|
148
|
+
}
|
|
149
|
+
async function beginJournal(paths, oldArtifact, newArtifact, priorRegistration, ctx, deps) {
|
|
150
|
+
const canonicalRoot = await validatePaths(paths);
|
|
151
|
+
await mkdir(paths.managerRoot, { recursive: true });
|
|
152
|
+
await requireNoRecovery(paths);
|
|
153
|
+
await mkdir(paths.recoveryRoot, { mode: 0o700 });
|
|
154
|
+
const recoveryIdentity = await identity(paths.recoveryRoot);
|
|
155
|
+
const token = randomBytes(16).toString("hex");
|
|
156
|
+
const journal = {
|
|
157
|
+
schema: 1,
|
|
158
|
+
token,
|
|
159
|
+
installedRoot: canonicalRoot,
|
|
160
|
+
oldArtifact,
|
|
161
|
+
newArtifact,
|
|
162
|
+
priorRegistration,
|
|
163
|
+
priorCanonicalRegistration: priorRegistration === null ? null : canonicalRoot,
|
|
164
|
+
createdRegistration: null,
|
|
165
|
+
phase: newArtifact === null ? "removing" : "staging",
|
|
166
|
+
};
|
|
167
|
+
const path = join(paths.recoveryRoot, "transaction.json");
|
|
168
|
+
const handle = await open(path, "wx", 0o600);
|
|
169
|
+
try {
|
|
170
|
+
await handle.writeFile(journalBytes(journal));
|
|
171
|
+
await handle.sync();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
// The first journal is durable before either a staging tree or backup exists.
|
|
177
|
+
await syncDirectoryStrict(paths.recoveryRoot);
|
|
178
|
+
return {
|
|
179
|
+
paths,
|
|
180
|
+
canonicalRoot,
|
|
181
|
+
recoveryIdentity,
|
|
182
|
+
backup: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.bak.${token}`),
|
|
183
|
+
stage: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.stage.${token}`),
|
|
184
|
+
ctx,
|
|
185
|
+
deps,
|
|
186
|
+
journal,
|
|
187
|
+
journalIdentity: await lstat(path),
|
|
188
|
+
settled: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function retireJournal(p) {
|
|
192
|
+
await requireJournal(p);
|
|
193
|
+
if ((await readdir(p.paths.recoveryRoot)).some((name) => name !== "transaction.json"))
|
|
194
|
+
throw new Error("unknown Pi recovery material");
|
|
195
|
+
await unlink(journalPath(p));
|
|
196
|
+
await requireIdentity(p.paths.recoveryRoot, p.recoveryIdentity);
|
|
197
|
+
await rmdir(p.paths.recoveryRoot);
|
|
198
|
+
}
|
|
199
|
+
async function journalRetirementGuidance(p) {
|
|
200
|
+
try {
|
|
201
|
+
const kind = await classifyPathNoFollow(p.paths.recoveryRoot);
|
|
202
|
+
if (kind === "missing")
|
|
203
|
+
return `recovery directory ${p.paths.recoveryRoot} is absent; verify the installed state because no recovery journal remains`;
|
|
204
|
+
if (kind !== "directory")
|
|
205
|
+
return `recovery path ${p.paths.recoveryRoot} is no longer a directory; preserve it unchanged and inspect it manually`;
|
|
206
|
+
let entries;
|
|
207
|
+
try {
|
|
208
|
+
await requireIdentity(p.paths.recoveryRoot, p.recoveryIdentity);
|
|
209
|
+
entries = await readdir(p.paths.recoveryRoot);
|
|
210
|
+
await requireIdentity(p.paths.recoveryRoot, p.recoveryIdentity);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return `recovery directory identity at ${p.paths.recoveryRoot} changed or could not be verified; preserve it unchanged and inspect it manually`;
|
|
214
|
+
}
|
|
215
|
+
if (entries.some((name) => name !== "transaction.json"))
|
|
216
|
+
return `recovery directory ${p.paths.recoveryRoot} contains unexpected material; preserve it unchanged and inspect its contents manually`;
|
|
217
|
+
if (entries.includes("transaction.json"))
|
|
218
|
+
return `the Manager recovery journal remains at ${journalPath(p)} and no other recovery material was observed; verify the installed state and confirm the recovery directory still contains only that journal before retiring it`;
|
|
219
|
+
return `the recovery journal is absent and ${p.paths.recoveryRoot} was observed as an empty directory; verify the installed state and confirm the directory is still empty before removing it`;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return `recovery state at ${p.paths.recoveryRoot} could not be inspected; preserve that path unchanged and inspect it manually`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function requirePublication(p) {
|
|
226
|
+
await requireJournal(p);
|
|
227
|
+
if (p.publishedIdentity === undefined)
|
|
228
|
+
throw new Error("Pi publication missing");
|
|
229
|
+
await requireIdentity(p.canonicalRoot, p.publishedIdentity);
|
|
230
|
+
await requireSnapshot(p.canonicalRoot, p.journal.newArtifact);
|
|
231
|
+
if (p.journal.oldArtifact !== null)
|
|
232
|
+
await requireSnapshot(p.backup, p.journal.oldArtifact);
|
|
233
|
+
else
|
|
234
|
+
await assertNoFollowType(p.backup, ["missing"]);
|
|
235
|
+
}
|
|
236
|
+
async function finalizePiPublication(p, publication) {
|
|
237
|
+
if (p.settled)
|
|
238
|
+
return fail("already-settled", "Pi installation transaction has already been settled");
|
|
239
|
+
p.settled = true;
|
|
240
|
+
let verified = false;
|
|
241
|
+
let publicationCleanupCompleted = false;
|
|
242
|
+
try {
|
|
243
|
+
await requirePublication(p);
|
|
244
|
+
const current = await registration(p.paths, p.deps);
|
|
245
|
+
const expectedRegistration = p.journal.priorRegistration ?? p.journal.createdRegistration;
|
|
246
|
+
if (expectedRegistration === null ||
|
|
247
|
+
!sameRegistration(current, expectedRegistration) ||
|
|
248
|
+
current?.resourceState !== "enabled")
|
|
249
|
+
throw new Error("Pi registration changed");
|
|
250
|
+
const assessed = await readPiPackageAssessment(p.canonicalRoot);
|
|
251
|
+
if (assessed.compatibility.kind !== "supported" &&
|
|
252
|
+
assessed.compatibility.kind !== "experimental")
|
|
253
|
+
throw new Error("Pi compatibility changed");
|
|
254
|
+
await phase(p, "finalizing");
|
|
255
|
+
verified = true;
|
|
256
|
+
await publication.finalize();
|
|
257
|
+
publicationCleanupCompleted = true;
|
|
258
|
+
await retireJournal(p);
|
|
259
|
+
return successResult("finalize-pi", null, []);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
if (publicationCleanupCompleted)
|
|
263
|
+
return fail("recovery-required", `Pi activation was verified and backup cleanup completed, but journal retirement failed; ${await journalRetirementGuidance(p)}`);
|
|
264
|
+
return fail("recovery-required", `${verified ? "Pi activation was verified, but backup or journal cleanup failed" : "Pi activation could not be verified"}; preserve recovery material at ${p.paths.recoveryRoot} and ${p.backup} for manual resolution`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function rollbackPiPublication(p, publication) {
|
|
268
|
+
if (p.settled)
|
|
269
|
+
return fail("already-settled", "Pi installation transaction has already been settled");
|
|
270
|
+
p.settled = true;
|
|
271
|
+
let retirementStarted = false;
|
|
272
|
+
try {
|
|
273
|
+
await requirePublication(p);
|
|
274
|
+
const current = await registration(p.paths, p.deps);
|
|
275
|
+
if (p.journal.priorRegistration !== null) {
|
|
276
|
+
if (!sameRegistration(current, p.journal.priorRegistration))
|
|
277
|
+
throw new Error("prior Pi registration changed");
|
|
278
|
+
}
|
|
279
|
+
else if (!sameRegistration(current, p.journal.createdRegistration) ||
|
|
280
|
+
(current !== null && current.resourceState !== "enabled")) {
|
|
281
|
+
throw new Error("new Pi registration changed");
|
|
282
|
+
}
|
|
283
|
+
await phase(p, "rolling-back");
|
|
284
|
+
if (p.journal.priorRegistration === null &&
|
|
285
|
+
p.journal.createdRegistration !== null) {
|
|
286
|
+
await p.deps
|
|
287
|
+
.run(["remove", p.canonicalRoot, "--no-approve"], p.paths, p.ctx)
|
|
288
|
+
.catch(() => undefined);
|
|
289
|
+
if ((await registration(p.paths, p.deps)) !== null)
|
|
290
|
+
throw new Error("Pi deregistration unverified");
|
|
291
|
+
}
|
|
292
|
+
await requirePublication(p);
|
|
293
|
+
await publication.rollback();
|
|
294
|
+
await requireSnapshot(p.canonicalRoot, p.journal.oldArtifact);
|
|
295
|
+
if (!sameRegistration(await registration(p.paths, p.deps), p.journal.priorRegistration))
|
|
296
|
+
throw new Error("Pi registration restoration unverified");
|
|
297
|
+
await phase(p, "restored");
|
|
298
|
+
retirementStarted = true;
|
|
299
|
+
await retireJournal(p);
|
|
300
|
+
return successResult("rollback-pi", null, []);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
if (retirementStarted)
|
|
304
|
+
return fail("recovery-required", `Pi restoration of the previous snapshot and registration was verified, but journal retirement failed; ${await journalRetirementGuidance(p)}`);
|
|
305
|
+
return fail("recovery-required", `Pi restoration could not be verified; preserve the installed snapshot and recovery material at ${p.paths.recoveryRoot} and ${p.backup} for manual resolution`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function recoverBeforePublication(p) {
|
|
309
|
+
try {
|
|
310
|
+
await requireJournal(p);
|
|
311
|
+
await requireSnapshot(p.canonicalRoot, p.journal.oldArtifact);
|
|
312
|
+
await assertNoFollowType(p.backup, ["missing"]);
|
|
313
|
+
if (!sameRegistration(await registration(p.paths, p.deps), p.journal.priorRegistration))
|
|
314
|
+
return false;
|
|
315
|
+
if ((await assertNoFollowType(p.stage, ["directory", "missing"])) !==
|
|
316
|
+
"missing") {
|
|
317
|
+
if (p.stageIdentity === undefined)
|
|
318
|
+
return false;
|
|
319
|
+
await requireIdentity(p.stage, p.stageIdentity);
|
|
320
|
+
await phase(p, "restored");
|
|
321
|
+
await rm(p.stage, { recursive: true });
|
|
322
|
+
}
|
|
323
|
+
await retireJournal(p);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export async function installPi(artifact, ctx, deps = DEFAULTS) {
|
|
331
|
+
const paths = piPaths(ctx.env ?? {}, process.cwd());
|
|
332
|
+
let pending;
|
|
333
|
+
let publication;
|
|
334
|
+
try {
|
|
335
|
+
await validatePaths(paths);
|
|
336
|
+
await requireNoRecovery(paths);
|
|
337
|
+
if (artifact.root !== paths.preparedRoot)
|
|
338
|
+
throw new Error("unexpected Pi prepared root");
|
|
339
|
+
const assessment = await readPiPackageAssessment(artifact.root);
|
|
340
|
+
if (assessment.receipt.digest !== artifact.identity ||
|
|
341
|
+
assessment.receipt.commit !== artifact.commit ||
|
|
342
|
+
(assessment.compatibility.kind !== "supported" &&
|
|
343
|
+
assessment.compatibility.kind !== "experimental"))
|
|
344
|
+
throw new Error("Pi prepared artifact changed");
|
|
345
|
+
await requireActivationEligibility(ctx);
|
|
346
|
+
const previous = await snapshot(paths.installedRoot);
|
|
347
|
+
const priorRegistration = await registration(paths, deps);
|
|
348
|
+
if (previous === null && priorRegistration !== null)
|
|
349
|
+
throw new Error("unowned Pi registration");
|
|
350
|
+
accepted(normalizePiRuntimeVersion(await deps.run(["--version"], paths, ctx)));
|
|
351
|
+
// Recheck after the native preflight, before claiming mutation ownership.
|
|
352
|
+
await requireSnapshot(paths.installedRoot, previous);
|
|
353
|
+
if (!sameRegistration(await registration(paths, deps), priorRegistration))
|
|
354
|
+
throw new Error("Pi registration changed");
|
|
355
|
+
pending = await beginJournal(paths, previous, assessment.receipt, priorRegistration, ctx, deps);
|
|
356
|
+
await mkdir(pending.stage);
|
|
357
|
+
pending.stageIdentity = await identity(pending.stage);
|
|
358
|
+
await cp(artifact.root, pending.stage, {
|
|
359
|
+
recursive: true,
|
|
360
|
+
verbatimSymlinks: true,
|
|
361
|
+
force: false,
|
|
362
|
+
});
|
|
363
|
+
const staged = await readPiPackageAssessment(pending.stage);
|
|
364
|
+
if (!sameArtifact(staged.receipt, assessment.receipt) ||
|
|
365
|
+
(staged.compatibility.kind !== "supported" &&
|
|
366
|
+
staged.compatibility.kind !== "experimental"))
|
|
367
|
+
throw new Error("Pi staged artifact changed");
|
|
368
|
+
await phase(pending, "publishing");
|
|
369
|
+
await requireIdentity(pending.stage, pending.stageIdentity);
|
|
370
|
+
await requireActivationEligibility(ctx);
|
|
371
|
+
await requireSnapshot(paths.installedRoot, previous);
|
|
372
|
+
if (!sameRegistration(await registration(paths, deps), priorRegistration))
|
|
373
|
+
throw new Error("Pi registration changed");
|
|
374
|
+
publication = await deps.beginPublication(pending.stage, pending.canonicalRoot, { backupPath: pending.backup });
|
|
375
|
+
pending.publishedIdentity = pending.stageIdentity;
|
|
376
|
+
await phase(pending, "published");
|
|
377
|
+
if (priorRegistration === null) {
|
|
378
|
+
await phase(pending, "registering");
|
|
379
|
+
if ((await registration(paths, deps)) !== null)
|
|
380
|
+
throw new Error("Pi registration changed");
|
|
381
|
+
const result = await deps
|
|
382
|
+
.run(["install", pending.canonicalRoot, "--no-approve"], paths, ctx)
|
|
383
|
+
.catch(() => fail("native-failed", "Pi native installation failed"));
|
|
384
|
+
// Even an unsuccessful command may have changed settings. Read them first.
|
|
385
|
+
const registered = await registration(paths, deps);
|
|
386
|
+
if (registered === null || registered.resourceState !== "enabled")
|
|
387
|
+
throw new Error("Pi activation unverified");
|
|
388
|
+
await phase(pending, "registered", {
|
|
389
|
+
createdRegistration: registered,
|
|
390
|
+
});
|
|
391
|
+
accepted(result);
|
|
392
|
+
}
|
|
393
|
+
await requirePublication(pending);
|
|
394
|
+
const registered = await registration(paths, deps);
|
|
395
|
+
if (registered === null || registered.resourceState !== "enabled")
|
|
396
|
+
throw new Error("Pi activation unverified");
|
|
397
|
+
await phase(pending, "ready");
|
|
398
|
+
const owned = pending;
|
|
399
|
+
const published = publication;
|
|
400
|
+
return successResult("install-pi", {
|
|
401
|
+
missingVerificationOutput: {
|
|
402
|
+
stdout: [],
|
|
403
|
+
stderr: [
|
|
404
|
+
"error: installed Pi snapshot is not detectable after activation",
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
mismatchVerificationOutput: {
|
|
408
|
+
stdout: [],
|
|
409
|
+
stderr: [
|
|
410
|
+
"error: installed Pi snapshot or registration does not match the prepared artifact",
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
transaction: {
|
|
414
|
+
finalize: () => finalizePiPublication(owned, published),
|
|
415
|
+
rollback: () => rollbackPiPublication(owned, published),
|
|
416
|
+
},
|
|
417
|
+
}, []);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
if (pending !== undefined) {
|
|
421
|
+
const restored = publication === undefined
|
|
422
|
+
? await recoverBeforePublication(pending)
|
|
423
|
+
: (await rollbackPiPublication(pending, publication)).outcome.ok;
|
|
424
|
+
if (restored)
|
|
425
|
+
return fail("activation-failed", "Pi activation failed; the previous snapshot and registration were restored");
|
|
426
|
+
}
|
|
427
|
+
if ((await hasRecovery(paths)) || pending !== undefined)
|
|
428
|
+
return fail("recovery-required", `Pi mutation requires manual recovery; preserve material at ${paths.recoveryRoot} and any installed snapshot or sibling backup`);
|
|
429
|
+
return fail("activation-refused", `cannot activate Pi artifact ${artifact.root}; verify prepared content, runtime, ownership, and settings ${paths.settingsFile}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
export async function removePi(input, ctx, deps = DEFAULTS) {
|
|
433
|
+
const paths = piPaths(ctx.env ?? {}, process.cwd());
|
|
434
|
+
let pending;
|
|
435
|
+
let deregistered = false;
|
|
436
|
+
try {
|
|
437
|
+
await validatePaths(paths);
|
|
438
|
+
await requireNoRecovery(paths);
|
|
439
|
+
const current = accepted(await inspectPiOwnership(ctx)).removalInput;
|
|
440
|
+
if (input.installedRoot !== paths.installedRoot ||
|
|
441
|
+
input.registrationIdentity !== current.registrationIdentity ||
|
|
442
|
+
input.receiptDigest !== current.receiptDigest)
|
|
443
|
+
throw new Error("stale Pi removal input");
|
|
444
|
+
const previous = await snapshot(paths.installedRoot);
|
|
445
|
+
const registered = await registration(paths, deps);
|
|
446
|
+
if (previous === null) {
|
|
447
|
+
if (registered !== null)
|
|
448
|
+
throw new Error("unowned Pi registration");
|
|
449
|
+
return successResult("remove-pi", null, []);
|
|
450
|
+
}
|
|
451
|
+
if (registered !== null)
|
|
452
|
+
accepted(normalizePiRuntimeVersion(await deps.run(["--version"], paths, ctx)));
|
|
453
|
+
await requireSnapshot(paths.installedRoot, previous);
|
|
454
|
+
if (!sameRegistration(await registration(paths, deps), registered))
|
|
455
|
+
throw new Error("Pi registration changed");
|
|
456
|
+
pending = await beginJournal(paths, previous, null, registered, ctx, deps);
|
|
457
|
+
const installedIdentity = await identity(paths.installedRoot);
|
|
458
|
+
if (registered !== null) {
|
|
459
|
+
const result = await deps
|
|
460
|
+
.run(["remove", pending.canonicalRoot, "--no-approve"], paths, ctx)
|
|
461
|
+
.catch(() => fail("native-failed", "Pi native removal failed"));
|
|
462
|
+
const after = await registration(paths, deps);
|
|
463
|
+
if (after !== null) {
|
|
464
|
+
if (sameRegistration(after, registered)) {
|
|
465
|
+
await requireSnapshot(paths.installedRoot, previous);
|
|
466
|
+
await retireJournal(pending);
|
|
467
|
+
pending = undefined;
|
|
468
|
+
}
|
|
469
|
+
throw new Error("Pi deregistration unverified");
|
|
470
|
+
}
|
|
471
|
+
// Removal is established by settings, even if Pi reported a nonzero exit.
|
|
472
|
+
deregistered = true;
|
|
473
|
+
if (result.status !== 0 || !result.outcome.ok) {
|
|
474
|
+
await phase(pending, "deregistered");
|
|
475
|
+
return fail("cleanup-required", `Pi registration was removed but its command failed; snapshot and recovery material remain at ${paths.installedRoot} and ${paths.recoveryRoot}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
await phase(pending, "deregistered");
|
|
479
|
+
await requireIdentity(paths.installedRoot, installedIdentity);
|
|
480
|
+
await requireSnapshot(paths.installedRoot, previous);
|
|
481
|
+
if ((await registration(paths, deps)) !== null)
|
|
482
|
+
throw new Error("Pi registration returned");
|
|
483
|
+
await rm(paths.installedRoot, { recursive: true });
|
|
484
|
+
await requireSnapshot(paths.installedRoot, null);
|
|
485
|
+
await retireJournal(pending);
|
|
486
|
+
return successResult("remove-pi", null, []);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return fail(pending !== undefined || (await hasRecovery(paths))
|
|
490
|
+
? "recovery-required"
|
|
491
|
+
: "removal-refused", deregistered
|
|
492
|
+
? `Pi registration was removed but snapshot cleanup failed; preserve recovery material at ${paths.recoveryRoot}`
|
|
493
|
+
: `cannot verify Pi removal at ${paths.installedRoot}; preserve the snapshot and any recovery material at ${paths.recoveryRoot}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
3
|
+
import { AdapterMessageLog, failureResult, successResult, } from "./adapter-result.js";
|
|
4
|
+
import { SEMVER_RE } from "./generated-plugin.js";
|
|
5
|
+
import { BOUNDED_EXECUTABLE, runValidator as runBoundedCommand, } from "./validator.js";
|
|
6
|
+
import { withWorkspace } from "./workspace.js";
|
|
7
|
+
function selectedExecutable(env, invocationCwd) {
|
|
8
|
+
const configured = env.SUPERPOWERS_PI || "pi";
|
|
9
|
+
return configured.includes(sep) && !isAbsolute(configured)
|
|
10
|
+
? resolve(invocationCwd, configured)
|
|
11
|
+
: configured;
|
|
12
|
+
}
|
|
13
|
+
function appendCaptured(log, channel, captured) {
|
|
14
|
+
log.appendBytes(channel, Buffer.from(captured.text));
|
|
15
|
+
if (captured.droppedBytes > 0) {
|
|
16
|
+
log.appendText(channel, `Pi ${channel} omitted ${captured.droppedBytes} bytes at the capture limit`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function runPi(args, paths, ctx, execute = runBoundedCommand) {
|
|
20
|
+
const operation = "pi-command";
|
|
21
|
+
const env = ctx.env ?? {};
|
|
22
|
+
const invocationCwd = process.cwd();
|
|
23
|
+
const executable = selectedExecutable(env, invocationCwd);
|
|
24
|
+
let entered = false;
|
|
25
|
+
try {
|
|
26
|
+
return await withWorkspace(env.TMPDIR ?? tmpdir(), "superpowers-manager.pi.", async (workspace) => {
|
|
27
|
+
entered = true;
|
|
28
|
+
const result = await execute([executable, ...args], BOUNDED_EXECUTABLE, {
|
|
29
|
+
...env,
|
|
30
|
+
HOME: paths.homeDir,
|
|
31
|
+
PI_CODING_AGENT_DIR: paths.agentDir,
|
|
32
|
+
PI_OFFLINE: "1",
|
|
33
|
+
PI_SKIP_VERSION_CHECK: "1",
|
|
34
|
+
}, workspace, workspace);
|
|
35
|
+
if (result.kind === "launchFailed") {
|
|
36
|
+
const errno = /^[A-Z][A-Z0-9_]*$/u.test(result.errno)
|
|
37
|
+
? result.errno
|
|
38
|
+
: "UNKNOWN";
|
|
39
|
+
return failureResult(operation, "launch-failed", `cannot launch configured Pi executable (${errno})`, [], []);
|
|
40
|
+
}
|
|
41
|
+
const log = new AdapterMessageLog();
|
|
42
|
+
if (result.kind === "timedOut") {
|
|
43
|
+
appendCaptured(log, "stdout", result.stdout);
|
|
44
|
+
appendCaptured(log, "stderr", result.stderr);
|
|
45
|
+
return failureResult(operation, "timeout", `Pi command timed out after ${result.afterMs} ms`, [], log.snapshot());
|
|
46
|
+
}
|
|
47
|
+
if (result.code !== 0) {
|
|
48
|
+
appendCaptured(log, "stdout", result.stdout);
|
|
49
|
+
appendCaptured(log, "stderr", result.stderr);
|
|
50
|
+
return failureResult(operation, "nonzero-exit", result.code === null
|
|
51
|
+
? "Pi command exited without a status"
|
|
52
|
+
: `Pi command exited with status ${result.code}`, [], log.snapshot());
|
|
53
|
+
}
|
|
54
|
+
if (result.stdout.droppedBytes > 0 || result.stderr.droppedBytes > 0) {
|
|
55
|
+
appendCaptured(log, "stdout", result.stdout);
|
|
56
|
+
appendCaptured(log, "stderr", result.stderr);
|
|
57
|
+
return failureResult(operation, "output-limit", "Pi command output exceeded the capture limit", [], log.snapshot());
|
|
58
|
+
}
|
|
59
|
+
appendCaptured(log, "stderr", result.stderr);
|
|
60
|
+
return successResult(operation, { stdout: result.stdout.text }, log.snapshot());
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return failureResult(operation, "workspace-failed", entered
|
|
65
|
+
? "cannot complete Pi command in its isolated workspace"
|
|
66
|
+
: "cannot create an isolated Pi command workspace", [], []);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function normalizePiRuntimeVersion(result) {
|
|
70
|
+
if (!result.outcome.ok) {
|
|
71
|
+
return { status: result.status, outcome: result.outcome };
|
|
72
|
+
}
|
|
73
|
+
if (result.status !== 0) {
|
|
74
|
+
return failureResult("pi-runtime", "invalid-status", "Pi runtime inspection returned a nonzero status", [], result.outcome.messages);
|
|
75
|
+
}
|
|
76
|
+
const version = result.outcome.result.stdout.trim();
|
|
77
|
+
if (!SEMVER_RE.test(version)) {
|
|
78
|
+
return failureResult("pi-runtime", "invalid-version", "Pi runtime inspection returned an invalid version response", [], result.outcome.messages);
|
|
79
|
+
}
|
|
80
|
+
return successResult("pi-runtime", version, result.outcome.messages);
|
|
81
|
+
}
|