superpowers-manager 0.4.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 +62 -533
- package/dist/adapter.js +8 -4
- package/dist/artifact-tree.js +110 -0
- package/dist/atomic.js +103 -7
- package/dist/cli-arguments.js +37 -0
- package/dist/cli.js +48 -11
- package/dist/codex-compatibility.js +126 -0
- package/dist/codex-conflicts.js +99 -0
- package/dist/codex-harness.js +51 -1
- package/dist/codex-json.js +7 -0
- package/dist/codex-prepare.js +69 -13
- package/dist/codex-presentation.js +24 -1
- package/dist/commands/install.js +115 -16
- package/dist/commands/mutation.js +71 -0
- package/dist/commands/prepare.js +19 -13
- package/dist/commands/probe.js +160 -10
- package/dist/commands/uninstall.js +6 -2
- package/dist/commands/update.js +5 -1
- package/dist/generated-plugin.js +3 -3
- package/dist/harness-compatibility.js +13 -0
- package/dist/manifest-overlay.js +1 -1
- 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/upstream-workspace.js +8 -0
- package/dist/validator.js +2 -1
- package/package.json +11 -8
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { codexInstalledPluginsFromJson, } from "./codex-json.js";
|
|
4
|
+
export const CODEX_MANAGER_PLUGIN_ID = "superpowers@superpowers-manager";
|
|
5
|
+
export const CODEX_LEGACY_PLUGIN_ID = "superpowers@superpowers-wrapper";
|
|
6
|
+
const SUPERPOWERS_PLUGIN_PREFIX = "superpowers@";
|
|
7
|
+
const DISPLAYABLE_PLUGIN_ID = /^superpowers@[A-Za-z0-9][A-Za-z0-9._-]*/;
|
|
8
|
+
const NATIVE_ROUTE = ".agents/skills/superpowers";
|
|
9
|
+
const NATIVE_ROUTE_CONFLICT = `native Codex skills route ~/${NATIVE_ROUTE} has indeterminate activity`;
|
|
10
|
+
function isMissingPath(cause) {
|
|
11
|
+
if (cause === null || typeof cause !== "object" || !("code" in cause)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return cause.code === "ENOENT" || cause.code === "ENOTDIR";
|
|
15
|
+
}
|
|
16
|
+
function isUnmanagedPluginId(pluginId) {
|
|
17
|
+
if (pluginId === CODEX_MANAGER_PLUGIN_ID ||
|
|
18
|
+
pluginId === CODEX_LEGACY_PLUGIN_ID) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return (pluginId.startsWith(SUPERPOWERS_PLUGIN_PREFIX) &&
|
|
22
|
+
pluginId.length > SUPERPOWERS_PLUGIN_PREFIX.length);
|
|
23
|
+
}
|
|
24
|
+
function displayablePluginId(pluginId) {
|
|
25
|
+
return pluginId.length <= 256 &&
|
|
26
|
+
DISPLAYABLE_PLUGIN_ID.exec(pluginId)?.[0] === pluginId
|
|
27
|
+
? pluginId
|
|
28
|
+
: "";
|
|
29
|
+
}
|
|
30
|
+
function unmanagedPluginConflict(plugin) {
|
|
31
|
+
if (!isUnmanagedPluginId(plugin.pluginId))
|
|
32
|
+
return "";
|
|
33
|
+
const displayId = displayablePluginId(plugin.pluginId);
|
|
34
|
+
const subject = displayId.length > 0
|
|
35
|
+
? `Codex plugin ${displayId}`
|
|
36
|
+
: "Codex plugin with a non-displayable Superpowers identity";
|
|
37
|
+
if (plugin.installed === true && plugin.enabled === true) {
|
|
38
|
+
return `active ${subject}`;
|
|
39
|
+
}
|
|
40
|
+
if ((plugin.installed === true && plugin.enabled === false) ||
|
|
41
|
+
(plugin.installed === false && plugin.enabled === false)) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return `${subject} has indeterminate activity`;
|
|
45
|
+
}
|
|
46
|
+
async function nativeRouteConflict(ctx) {
|
|
47
|
+
const home = ctx.env?.HOME;
|
|
48
|
+
if (home === undefined || home.length === 0)
|
|
49
|
+
return NATIVE_ROUTE_CONFLICT;
|
|
50
|
+
const route = join(home, ".agents", "skills", "superpowers");
|
|
51
|
+
const asset = join(route, "using-superpowers", "SKILL.md");
|
|
52
|
+
let routeIsSymlink;
|
|
53
|
+
try {
|
|
54
|
+
routeIsSymlink = (await lstat(route)).isSymbolicLink();
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
|
|
58
|
+
}
|
|
59
|
+
if (!routeIsSymlink) {
|
|
60
|
+
try {
|
|
61
|
+
await lstat(asset);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const resolvedAsset = await realpath(asset);
|
|
69
|
+
if (!(await stat(resolvedAsset)).isFile())
|
|
70
|
+
return NATIVE_ROUTE_CONFLICT;
|
|
71
|
+
const contents = await readFile(resolvedAsset, "utf8");
|
|
72
|
+
const lines = contents.split(/\r?\n/u);
|
|
73
|
+
if (lines[0] !== "---")
|
|
74
|
+
return "";
|
|
75
|
+
const end = lines.indexOf("---", 1);
|
|
76
|
+
if (end < 0 ||
|
|
77
|
+
!lines
|
|
78
|
+
.slice(1, end)
|
|
79
|
+
.some((line) => /^name:[ \t]+using-superpowers[ \t]*$/u.test(line))) {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return NATIVE_ROUTE_CONFLICT;
|
|
85
|
+
}
|
|
86
|
+
return NATIVE_ROUTE_CONFLICT;
|
|
87
|
+
}
|
|
88
|
+
export async function inspectCodexConflicts(ctx, installedListing) {
|
|
89
|
+
const conflicts = new Set();
|
|
90
|
+
for (const plugin of codexInstalledPluginsFromJson(installedListing)) {
|
|
91
|
+
const conflict = unmanagedPluginConflict(plugin);
|
|
92
|
+
if (conflict.length > 0)
|
|
93
|
+
conflicts.add(conflict);
|
|
94
|
+
}
|
|
95
|
+
const nativeConflict = await nativeRouteConflict(ctx);
|
|
96
|
+
if (nativeConflict.length > 0)
|
|
97
|
+
conflicts.add(nativeConflict);
|
|
98
|
+
return [...conflicts];
|
|
99
|
+
}
|
package/dist/codex-harness.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
1
2
|
import { failureResult, hasTerminalControl, successResult, } from "./adapter-result.js";
|
|
2
3
|
import { codexInspect, codexInstall, codexRemove, } from "./adapter.js";
|
|
3
4
|
import { codexPreparationLocation, inspectCodexPrepared, prepareCodexCandidate, readCodexPrepared, validateCodexPreparationBeforeFetch, } from "./codex-prepare.js";
|
|
4
5
|
import { codexInstallReceipt, codexPresentation, } from "./codex-presentation.js";
|
|
5
6
|
import { reportLegacyState, requireManagedUpdateControl, requireNoLegacyState, verifyUninstalledResources, } from "./lifecycle.js";
|
|
7
|
+
import { SafetyError } from "./safety-error.js";
|
|
6
8
|
import { commitMatches } from "./status.js";
|
|
7
9
|
function preserveFailure(result) {
|
|
8
10
|
if (result.outcome.ok) {
|
|
@@ -40,6 +42,23 @@ function resourceFlag(result, key) {
|
|
|
40
42
|
const value = bag[key];
|
|
41
43
|
return typeof value === "boolean" ? value : null;
|
|
42
44
|
}
|
|
45
|
+
function conflictDescriptions(result) {
|
|
46
|
+
const raw = result.conflicts;
|
|
47
|
+
if (raw === null || raw === undefined)
|
|
48
|
+
return [];
|
|
49
|
+
if (!Array.isArray(raw))
|
|
50
|
+
return null;
|
|
51
|
+
const descriptions = [];
|
|
52
|
+
for (const item of raw) {
|
|
53
|
+
if (typeof item !== "string" ||
|
|
54
|
+
item.length === 0 ||
|
|
55
|
+
hasTerminalControl(item)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
descriptions.push(item);
|
|
59
|
+
}
|
|
60
|
+
return descriptions;
|
|
61
|
+
}
|
|
43
62
|
function installDecision(legacy) {
|
|
44
63
|
switch (legacy.kind) {
|
|
45
64
|
case "ok":
|
|
@@ -87,7 +106,11 @@ export function normalizeCodexOwnership(result) {
|
|
|
87
106
|
return malformed(result, "adapter returned a non-string identity_state for inspect --view ownership");
|
|
88
107
|
}
|
|
89
108
|
const identityState = identity.value;
|
|
90
|
-
const
|
|
109
|
+
const conflicts = conflictDescriptions(record);
|
|
110
|
+
if (conflicts === null) {
|
|
111
|
+
return malformed(result, "expected an array of strings at conflicts");
|
|
112
|
+
}
|
|
113
|
+
const legacyEligibility = identityState.length === 0
|
|
91
114
|
? {
|
|
92
115
|
kind: "blocked",
|
|
93
116
|
output: {
|
|
@@ -96,6 +119,19 @@ export function normalizeCodexOwnership(result) {
|
|
|
96
119
|
},
|
|
97
120
|
}
|
|
98
121
|
: installDecision(requireNoLegacyState(identityState));
|
|
122
|
+
const installEligibility = legacyEligibility.kind === "blocked" || conflicts.length === 0
|
|
123
|
+
? legacyEligibility
|
|
124
|
+
: {
|
|
125
|
+
kind: "blocked",
|
|
126
|
+
output: {
|
|
127
|
+
stdout: [],
|
|
128
|
+
stderr: [
|
|
129
|
+
"Conflicting unmanaged Superpowers Codex resources require manual resolution:",
|
|
130
|
+
...conflicts.map((conflict) => `- ${conflict}`),
|
|
131
|
+
"Remove or disable each resource manually, then retry.",
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
};
|
|
99
135
|
const verification = verifyUninstalledResources(result);
|
|
100
136
|
const legacyReport = reportLegacyState(identityState);
|
|
101
137
|
let removalVerification;
|
|
@@ -132,6 +168,7 @@ export function normalizeCodexOwnership(result) {
|
|
|
132
168
|
removalVerification,
|
|
133
169
|
postRemovalOutput,
|
|
134
170
|
presentationValue: identityState,
|
|
171
|
+
presentationConflicts: conflicts,
|
|
135
172
|
}, result.outcome.messages);
|
|
136
173
|
}
|
|
137
174
|
export function normalizeCodexControl(result) {
|
|
@@ -243,8 +280,21 @@ function requirements(command, env) {
|
|
|
243
280
|
},
|
|
244
281
|
];
|
|
245
282
|
}
|
|
283
|
+
async function mutationRoots(ctx) {
|
|
284
|
+
const env = ctx.env ?? {};
|
|
285
|
+
const configured = env.CODEX_HOME;
|
|
286
|
+
if (configured !== undefined && configured.length > 0) {
|
|
287
|
+
return [resolve(configured)];
|
|
288
|
+
}
|
|
289
|
+
const home = env.HOME;
|
|
290
|
+
if (home === undefined || home.length === 0) {
|
|
291
|
+
throw new SafetyError("codex-harness", "cannot determine Codex state root without HOME");
|
|
292
|
+
}
|
|
293
|
+
return [join(resolve(home), ".codex")];
|
|
294
|
+
}
|
|
246
295
|
export const codexHarness = {
|
|
247
296
|
preparationLocation: codexPreparationLocation,
|
|
297
|
+
mutationRoots,
|
|
248
298
|
validatePreparationBeforeFetch: validateCodexPreparationBeforeFetch,
|
|
249
299
|
prepareCandidate: prepareCodexCandidate,
|
|
250
300
|
inspectPrepared: inspectCodexPrepared,
|
package/dist/codex-json.js
CHANGED
|
@@ -59,6 +59,13 @@ function hasTerminalControl(value) {
|
|
|
59
59
|
export function installedListingHas(raw, arrayKey, field, value) {
|
|
60
60
|
return checkedItems(raw, ACCEPT_CONSTANTS, arrayKey, field).some((item) => item[field] === value);
|
|
61
61
|
}
|
|
62
|
+
export function codexInstalledPluginsFromJson(raw) {
|
|
63
|
+
return checkedItems(raw, ACCEPT_CONSTANTS, "installed", "pluginId").map((item) => ({
|
|
64
|
+
pluginId: item.pluginId,
|
|
65
|
+
installed: typeof item.installed === "boolean" ? item.installed : null,
|
|
66
|
+
enabled: typeof item.enabled === "boolean" ? item.enabled : null,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
62
69
|
export function marketplaceRootFromJson(raw, marketplaceName) {
|
|
63
70
|
const items = checkedItems(raw, ACCEPT_CONSTANTS, "marketplaces", "name");
|
|
64
71
|
const match = items.find((item) => item.name === marketplaceName);
|
package/dist/codex-prepare.js
CHANGED
|
@@ -2,9 +2,11 @@ import { cp, mkdir, rm, stat } from "node:fs/promises";
|
|
|
2
2
|
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { failureResult, successResult, } from "./adapter-result.js";
|
|
4
4
|
import { codexBuild } from "./adapter.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ARTIFACT_RECEIPT } from "./artifact-tree.js";
|
|
6
|
+
import { assessCodexCompatibility, readCodexAssessment, writeCodexAssessment, } from "./codex-compatibility.js";
|
|
6
7
|
import { readManifest } from "./hooks.js";
|
|
7
|
-
import {
|
|
8
|
+
import { readGeneratedCommitLenient, readStrictProvenanceField, writeProvenance, } from "./provenance.js";
|
|
9
|
+
import { classifyPathNoFollow } from "./safe-path.js";
|
|
8
10
|
import { SafetyError } from "./safety-error.js";
|
|
9
11
|
import { manifestVersionForRef } from "./upstream-version.js";
|
|
10
12
|
// Order is inherited from the original prepare command; the first miss wins.
|
|
@@ -27,6 +29,12 @@ const RESOLUTION_KINDS = [
|
|
|
27
29
|
"ref",
|
|
28
30
|
"raw-commit",
|
|
29
31
|
];
|
|
32
|
+
function unassessedCompatibility() {
|
|
33
|
+
return {
|
|
34
|
+
kind: "unknown",
|
|
35
|
+
reason: "Codex compatibility assessment is not available",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
30
38
|
function prepareError(message, cause) {
|
|
31
39
|
return new SafetyError("prepare", message, { cause });
|
|
32
40
|
}
|
|
@@ -166,26 +174,66 @@ export async function prepareCodexCandidate(input, ctx) {
|
|
|
166
174
|
if (built.status !== 0) {
|
|
167
175
|
return failureResult("build", "invalid-status", "adapter reported failure without an error outcome", [], built.outcome.messages);
|
|
168
176
|
}
|
|
169
|
-
|
|
177
|
+
const compatibility = await assessCodexCompatibility(input.upstreamRoot, input.selection);
|
|
178
|
+
if (compatibility.kind === "supported") {
|
|
179
|
+
try {
|
|
180
|
+
await writeCodexAssessment(input.candidateRoot, input.selection, (await regularFileExists(join(input.upstreamRoot, ".codex-plugin/plugin.json")))
|
|
181
|
+
? "upstream"
|
|
182
|
+
: "fallback");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return failureResult("prepare", "assessment-failed", `cannot record Codex compatibility assessment: ${input.candidateRoot}`, [], built.outcome.messages);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return successResult(built.outcome.operation, {
|
|
189
|
+
root: input.candidateRoot,
|
|
190
|
+
commit: input.selection.desiredCommit,
|
|
191
|
+
compatibility,
|
|
192
|
+
identity: input.selection.desiredCommit,
|
|
193
|
+
}, built.outcome.messages);
|
|
170
194
|
}
|
|
171
195
|
export async function inspectCodexPrepared(selection, ctx) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
const root = codexPreparationLocation(ctx).destinationRoot;
|
|
197
|
+
const observedIdentity = await readGeneratedCommitLenient(join(root, ".superpowers-upstream.json"));
|
|
198
|
+
const compatibility = unassessedCompatibility();
|
|
199
|
+
try {
|
|
200
|
+
if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) === "missing")
|
|
201
|
+
return successResult("inspect-prepared", {
|
|
202
|
+
kind: "needs-prepare",
|
|
203
|
+
observedIdentity,
|
|
204
|
+
compatibility,
|
|
205
|
+
}, []);
|
|
206
|
+
const artifact = await readCodexAssessment(root);
|
|
207
|
+
const source = await readStrictProvenanceField(join(artifact.root, ".superpowers-upstream.json"), "source");
|
|
208
|
+
if (artifact.commit === selection.desiredCommit &&
|
|
209
|
+
source === selection.effectiveSource) {
|
|
210
|
+
if (artifact.compatibility.kind === "supported")
|
|
211
|
+
return successResult("inspect-prepared", {
|
|
212
|
+
kind: "current",
|
|
213
|
+
artifact,
|
|
214
|
+
observedIdentity,
|
|
215
|
+
compatibility: artifact.compatibility,
|
|
216
|
+
}, []);
|
|
217
|
+
return successResult("inspect-prepared", {
|
|
218
|
+
kind: "needs-prepare",
|
|
219
|
+
observedIdentity,
|
|
220
|
+
compatibility: artifact.compatibility,
|
|
221
|
+
}, []);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return failureResult("inspect-prepared", "invalid-assessment", `cannot inspect Codex prepared artifact: ${root}`, [], []);
|
|
175
226
|
}
|
|
176
227
|
return successResult("inspect-prepared", {
|
|
177
|
-
kind: "
|
|
178
|
-
artifact: {
|
|
179
|
-
root: join(ctx.root, "plugins", "superpowers"),
|
|
180
|
-
commit: observedIdentity,
|
|
181
|
-
},
|
|
228
|
+
kind: "needs-prepare",
|
|
182
229
|
observedIdentity,
|
|
230
|
+
compatibility,
|
|
183
231
|
}, []);
|
|
184
232
|
}
|
|
185
233
|
export async function readCodexPrepared(ctx) {
|
|
186
234
|
let commit = "";
|
|
187
235
|
try {
|
|
188
|
-
const value = await readStrictProvenanceField(
|
|
236
|
+
const value = await readStrictProvenanceField(join(codexPreparationLocation(ctx).destinationRoot, ".superpowers-upstream.json"), "commit");
|
|
189
237
|
if (typeof value === "string")
|
|
190
238
|
commit = value;
|
|
191
239
|
}
|
|
@@ -195,5 +243,13 @@ export async function readCodexPrepared(ctx) {
|
|
|
195
243
|
if (commit.length === 0) {
|
|
196
244
|
return failureResult("read-prepared", "missing-commit", "generated metadata missing desired commit after prepare", [], []);
|
|
197
245
|
}
|
|
198
|
-
|
|
246
|
+
try {
|
|
247
|
+
const artifact = await readCodexAssessment(codexPreparationLocation(ctx).destinationRoot);
|
|
248
|
+
if (artifact.compatibility.kind !== "supported")
|
|
249
|
+
throw new Error("unsupported profile");
|
|
250
|
+
return successResult("read-prepared", artifact, []);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return failureResult("read-prepared", "invalid-assessment", "generated Codex compatibility assessment is missing or invalid", [], []);
|
|
254
|
+
}
|
|
199
255
|
}
|
|
@@ -4,6 +4,7 @@ import { displaySource } from "./selection.js";
|
|
|
4
4
|
// porcelain field order exposed through the harness presentation.
|
|
5
5
|
export function fields(f) {
|
|
6
6
|
return [
|
|
7
|
+
{ key: "harness", label: "harness", value: f.harness },
|
|
7
8
|
{ key: "requested_ref", label: "requested ref", value: f.requestedRef },
|
|
8
9
|
{ key: "resolved_ref", label: "resolved ref", value: f.resolvedRef },
|
|
9
10
|
{ key: "desired_commit", label: "desired commit", value: f.desiredCommit },
|
|
@@ -58,6 +59,7 @@ export function fields(f) {
|
|
|
58
59
|
];
|
|
59
60
|
}
|
|
60
61
|
const NO_FACTS = {
|
|
62
|
+
harness: "codex",
|
|
61
63
|
requestedRef: "",
|
|
62
64
|
resolvedRef: "",
|
|
63
65
|
desiredCommit: "",
|
|
@@ -129,6 +131,7 @@ function probeFacts(facts) {
|
|
|
129
131
|
const selection = facts.selection;
|
|
130
132
|
const saved = selection.saved;
|
|
131
133
|
return {
|
|
134
|
+
harness: "codex",
|
|
132
135
|
requestedRef: selection.requestedRef,
|
|
133
136
|
resolvedRef: selection.resolvedRef,
|
|
134
137
|
desiredCommit: selection.desiredCommit,
|
|
@@ -205,7 +208,27 @@ export const codexPresentation = {
|
|
|
205
208
|
currentNotice: "manager is current",
|
|
206
209
|
renderProbe(snapshot) {
|
|
207
210
|
const facts = probeFacts(snapshot);
|
|
208
|
-
|
|
211
|
+
const conflicts = snapshot.ownership.presentationConflicts ?? [];
|
|
212
|
+
const additional = [
|
|
213
|
+
["installation_state", snapshot.installed.kind],
|
|
214
|
+
["resource_state", snapshot.resourceState ?? "idle"],
|
|
215
|
+
["compatibility", snapshot.compatibility.kind],
|
|
216
|
+
["compatibility_reason", snapshot.compatibility.reason],
|
|
217
|
+
];
|
|
218
|
+
return {
|
|
219
|
+
human: formatHuman(facts) +
|
|
220
|
+
additional
|
|
221
|
+
.map(([key, value]) => `${key.replaceAll("_", " ")}: ${value}\n`)
|
|
222
|
+
.join("") +
|
|
223
|
+
conflicts
|
|
224
|
+
.map((conflict) => `ownership conflict: ${conflict}\n`)
|
|
225
|
+
.join(""),
|
|
226
|
+
porcelain: formatPorcelain(facts) +
|
|
227
|
+
additional.map(([key, value]) => `${key}=${value}\n`).join("") +
|
|
228
|
+
conflicts
|
|
229
|
+
.map((conflict) => `ownership_conflict=${conflict}\n`)
|
|
230
|
+
.join(""),
|
|
231
|
+
};
|
|
209
232
|
},
|
|
210
233
|
renderInstallVerification(desiredCommit, receipt, inspection) {
|
|
211
234
|
if (inspection.status !== 0 || !inspection.outcome.ok) {
|
package/dist/commands/install.js
CHANGED
|
@@ -5,18 +5,38 @@
|
|
|
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-presentation.ts:
|
|
166
|
+
// (`src/codex-presentation.ts:332::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);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { computeEffectiveSelection } from "../effective-selection.js";
|
|
2
|
+
import { upstreamCacheRoot } from "../upstream-workspace.js";
|
|
3
|
+
import { SafetyError } from "../safety-error.js";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
import { oneLine } from "../cli-arguments.js";
|
|
6
|
+
export async function withMutation(command, ctx, action) {
|
|
7
|
+
// Selection validation precedes every adapter call and lock creation. A
|
|
8
|
+
// nested mutation receives the already resolved value and cannot observe a
|
|
9
|
+
// saved-selection change midway through one invocation.
|
|
10
|
+
const selection = command === "uninstall"
|
|
11
|
+
? ctx.selection
|
|
12
|
+
: (ctx.selection ?? (await computeEffectiveSelection(ctx.root, ctx.env)));
|
|
13
|
+
const scoped = selection === undefined ? ctx : { ...ctx, selection };
|
|
14
|
+
const adapterContext = { root: ctx.root, env: ctx.env };
|
|
15
|
+
let location;
|
|
16
|
+
try {
|
|
17
|
+
location = ctx.adapter.preparationLocation(adapterContext);
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
throw new SafetyError("mutation", "cannot determine preparation location", {
|
|
21
|
+
cause,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (!isAbsolute(location.destinationRoot))
|
|
25
|
+
throw new SafetyError("mutation", "adapter returned a non-absolute preparation destination");
|
|
26
|
+
if (!location.stagingLeaf ||
|
|
27
|
+
location.stagingLeaf === "." ||
|
|
28
|
+
location.stagingLeaf === ".." ||
|
|
29
|
+
location.stagingLeaf.includes("/") ||
|
|
30
|
+
location.stagingLeaf.includes("\\"))
|
|
31
|
+
throw new SafetyError("mutation", "adapter returned an invalid preparation staging leaf");
|
|
32
|
+
let mutationRoots;
|
|
33
|
+
try {
|
|
34
|
+
mutationRoots = await ctx.adapter.mutationRoots(adapterContext);
|
|
35
|
+
if (!Array.isArray(mutationRoots) ||
|
|
36
|
+
mutationRoots.some((root) => typeof root !== "string" || !isAbsolute(root)))
|
|
37
|
+
throw new Error("invalid roots");
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
throw new SafetyError("mutation", "cannot determine harness mutation resources", { cause });
|
|
41
|
+
}
|
|
42
|
+
const resources = [location.destinationRoot, ...mutationRoots];
|
|
43
|
+
if (command === "prepare") {
|
|
44
|
+
resources.push(upstreamCacheRoot(ctx.root, ctx.env, process.cwd()));
|
|
45
|
+
}
|
|
46
|
+
// The coordinator sorts each acquisition and refuses conflicts immediately.
|
|
47
|
+
// A nested prepare may add a cache lock that sorts before an outer target;
|
|
48
|
+
// it cannot deadlock because no acquisition waits, while already-held roots
|
|
49
|
+
// are reentrant for this invocation.
|
|
50
|
+
return await ctx.coordination.withResources(resources, async () => action(scoped));
|
|
51
|
+
}
|
|
52
|
+
export async function runWithMutation(command, ctx, action) {
|
|
53
|
+
let actionThrew = false;
|
|
54
|
+
try {
|
|
55
|
+
return await withMutation(command, ctx, async (scoped) => {
|
|
56
|
+
try {
|
|
57
|
+
return await action(scoped);
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
actionThrew = true;
|
|
61
|
+
throw cause;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (cause) {
|
|
66
|
+
if (actionThrew)
|
|
67
|
+
throw cause;
|
|
68
|
+
ctx.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|