superpowers-manager 0.5.2 → 0.7.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 +44 -26
- package/dist/adapter-result.js +5 -9
- package/dist/atomic.js +4 -14
- package/dist/cli-arguments.js +1 -27
- package/dist/cli.js +32 -52
- package/dist/commands/adapter-call.js +28 -0
- package/dist/commands/install.js +26 -107
- package/dist/commands/mutation.js +7 -0
- package/dist/commands/pin.js +1 -1
- package/dist/commands/prepare.js +39 -129
- package/dist/commands/probe.js +35 -264
- package/dist/commands/track-latest.js +1 -1
- package/dist/commands/uninstall.js +16 -92
- package/dist/commands/update.js +5 -25
- package/dist/effective-selection.js +7 -9
- package/dist/git-tree.js +85 -0
- package/dist/harness-command-result.js +47 -0
- package/dist/harnesses/codex/adapter.js +231 -510
- package/dist/harnesses/codex/compatibility.js +2 -17
- package/dist/harnesses/codex/conflicts.js +2 -3
- package/dist/harnesses/codex/harness.js +18 -263
- package/dist/harnesses/codex/hooks.js +1 -1
- package/dist/harnesses/codex/json.js +1 -11
- package/dist/harnesses/codex/lifecycle.js +80 -72
- package/dist/harnesses/codex/manifest-overlay.js +23 -18
- package/dist/harnesses/codex/paths.js +14 -29
- package/dist/harnesses/codex/presentation.js +8 -37
- package/dist/harnesses/codex/publication.js +13 -37
- package/dist/harnesses/codex/recovery.js +5 -5
- package/dist/harnesses/codex/stored-state.js +1 -1
- package/dist/harnesses/opencode/config.js +256 -0
- package/dist/harnesses/opencode/discovery.js +587 -0
- package/dist/harnesses/opencode/harness.js +37 -0
- package/dist/harnesses/opencode/install.js +601 -0
- package/dist/harnesses/opencode/native.js +57 -0
- package/dist/harnesses/opencode/package.js +89 -0
- package/dist/harnesses/opencode/paths.js +38 -0
- package/dist/harnesses/opencode/prepare.js +30 -0
- package/dist/harnesses/opencode/presentation.js +28 -0
- package/dist/harnesses/opencode/state.js +174 -0
- package/dist/harnesses/pi/compatibility.js +5 -37
- package/dist/harnesses/pi/install.js +7 -5
- package/dist/harnesses/pi/native.js +3 -47
- package/dist/harnesses/pi/package.js +5 -107
- package/dist/harnesses/pi/prepare.js +13 -104
- package/dist/harnesses/pi/presentation.js +23 -66
- package/dist/harnesses/pi/shared-skills.js +28 -39
- package/dist/harnesses/pi/state.js +7 -27
- package/dist/provenance.js +2 -2
- package/dist/python-json-format.js +0 -69
- package/dist/python-json.js +2 -27
- package/dist/python-text.js +9 -69
- package/dist/safe-path.js +2 -2
- package/dist/selection-store.js +2 -22
- package/dist/snapshot-package.js +78 -0
- package/dist/snapshot-prepare.js +99 -0
- package/dist/snapshot-presentation.js +44 -0
- package/dist/validator.js +39 -73
- package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
- package/node_modules/jsonc-parser/LICENSE.md +21 -0
- package/node_modules/jsonc-parser/README.md +364 -0
- package/node_modules/jsonc-parser/SECURITY.md +41 -0
- package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
- package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
- package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
- package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
- package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
- package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
- package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
- package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
- package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
- package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
- package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
- package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
- package/node_modules/jsonc-parser/package.json +37 -0
- package/package.json +8 -4
- package/dist/domain/fingerprint.js +0 -4
- package/dist/selection-state-cli.js +0 -85
- package/dist/status.js +0 -24
- package/dist/upstream-cli.js +0 -110
- package/dist/validate-generated-plugin-cli.js +0 -118
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { assertNoFollowType, canonicalizeProspectivePath, } from "../../safe-path.js";
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { assertNoFollowType, canonicalizeProspectivePath, isContained, } from "../../safe-path.js";
|
|
3
3
|
import { SafetyError } from "../../safety-error.js";
|
|
4
4
|
export function codexHome(env, cwd) {
|
|
5
5
|
const configured = env.CODEX_HOME;
|
|
@@ -26,15 +26,18 @@ export function codexPaths(env, cwd) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
function overlaps(a, b) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
return isContained(a, b) || isContained(b, a);
|
|
30
|
+
}
|
|
31
|
+
class CodexPathError extends SafetyError {
|
|
32
|
+
constructor(message, details, cause) {
|
|
33
|
+
super("codex-paths", message, {
|
|
34
|
+
details,
|
|
35
|
+
...(cause === undefined ? {} : { cause }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
function separationError() {
|
|
37
|
-
return new
|
|
40
|
+
return new CodexPathError("preparation overlaps Codex published or recovery storage", { kind: "overlap" });
|
|
38
41
|
}
|
|
39
42
|
async function inspectRoot(path, root) {
|
|
40
43
|
try {
|
|
@@ -42,29 +45,11 @@ async function inspectRoot(path, root) {
|
|
|
42
45
|
return await canonicalizeProspectivePath(path);
|
|
43
46
|
}
|
|
44
47
|
catch (cause) {
|
|
45
|
-
throw new
|
|
48
|
+
throw new CodexPathError(`cannot inspect ${root} root: ${path}`, { kind: "inspection", root, path }, cause);
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
export function codexPathFailureDetails(cause) {
|
|
49
|
-
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
const details = cause.details;
|
|
53
|
-
if (details === null || typeof details !== "object" || !("kind" in details)) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
if (details.kind === "overlap")
|
|
57
|
-
return { kind: "overlap" };
|
|
58
|
-
if (details.kind !== "inspection" ||
|
|
59
|
-
!("root" in details) ||
|
|
60
|
-
!("path" in details) ||
|
|
61
|
-
(details.root !== "preparation" &&
|
|
62
|
-
details.root !== "marketplace" &&
|
|
63
|
-
details.root !== "recovery") ||
|
|
64
|
-
typeof details.path !== "string") {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
return { kind: "inspection", root: details.root, path: details.path };
|
|
52
|
+
return cause instanceof CodexPathError ? (cause.details ?? null) : null;
|
|
68
53
|
}
|
|
69
54
|
export async function assertCodexPreparationSeparate(preparedRoot, paths) {
|
|
70
55
|
const recovery = await inspectRoot(paths.recoveryRoot, "recovery");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { normalizeCodexInstall, normalizeCodexInstalled } from "./harness.js";
|
|
2
1
|
import { displaySource } from "../../selection.js";
|
|
2
|
+
import { hasTerminalControl } from "../../adapter-result.js";
|
|
3
3
|
// One ordered table drives both formats, preserving the public human and
|
|
4
4
|
// porcelain field order exposed through the harness presentation.
|
|
5
5
|
function fields(f) {
|
|
@@ -58,33 +58,12 @@ function fields(f) {
|
|
|
58
58
|
{ key: "update_control", label: "update control", value: f.updateControl },
|
|
59
59
|
];
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
harness: "codex",
|
|
63
|
-
requestedRef: "",
|
|
64
|
-
resolvedRef: "",
|
|
65
|
-
desiredCommit: "",
|
|
66
|
-
generatedCommit: "",
|
|
67
|
-
installedCommit: "",
|
|
68
|
-
identityState: "",
|
|
69
|
-
status: "",
|
|
70
|
-
selectionOrigin: "",
|
|
71
|
-
selectionMode: "",
|
|
72
|
-
upstreamSourceOrigin: "",
|
|
73
|
-
effectiveSource: "",
|
|
74
|
-
savedMode: "",
|
|
75
|
-
savedSource: "",
|
|
76
|
-
savedRequestedRef: "",
|
|
77
|
-
savedResolvedRef: "",
|
|
78
|
-
savedCommit: "",
|
|
79
|
-
updateControl: "",
|
|
80
|
-
};
|
|
81
|
-
export const PROBE_PORCELAIN_KEYS = fields(NO_FACTS).map((field) => field.key);
|
|
82
|
-
export function formatPorcelain(f) {
|
|
61
|
+
function formatPorcelain(f) {
|
|
83
62
|
return fields(f)
|
|
84
63
|
.map((field) => `${field.key}=${field.value}\n`)
|
|
85
64
|
.join("");
|
|
86
65
|
}
|
|
87
|
-
|
|
66
|
+
function formatHuman(f) {
|
|
88
67
|
let text = fields(f)
|
|
89
68
|
.map((field) => {
|
|
90
69
|
const shown = field.value.length === 0 && field.absent !== undefined
|
|
@@ -110,23 +89,15 @@ function verificationOutput(kind, hint) {
|
|
|
110
89
|
stderr.push(`hint: ${hint}`);
|
|
111
90
|
return { stdout: [], stderr };
|
|
112
91
|
}
|
|
92
|
+
function safeHint(value) {
|
|
93
|
+
return typeof value === "string" && !hasTerminalControl(value) ? value : "";
|
|
94
|
+
}
|
|
113
95
|
export function codexInstallReceipt(missingHint, mismatchHint) {
|
|
114
96
|
return {
|
|
115
|
-
missingVerificationOutput: verificationOutput("missing", missingHint),
|
|
116
|
-
mismatchVerificationOutput: verificationOutput("mismatch", mismatchHint),
|
|
97
|
+
missingVerificationOutput: verificationOutput("missing", safeHint(missingHint)),
|
|
98
|
+
mismatchVerificationOutput: verificationOutput("mismatch", safeHint(mismatchHint)),
|
|
117
99
|
};
|
|
118
100
|
}
|
|
119
|
-
// Compatibility export target for src/harnesses/codex/lifecycle.ts. Delegate to the production
|
|
120
|
-
// normalizers and renderer so the retained verdict cannot drift from commands.
|
|
121
|
-
export function verifyInstalledFingerprint(desiredCommit, installResult, inspectResult) {
|
|
122
|
-
const receipt = normalizeCodexInstall(installResult);
|
|
123
|
-
const inspection = normalizeCodexInstalled(inspectResult, desiredCommit);
|
|
124
|
-
const output = codexPresentation.renderInstallVerification(desiredCommit, receipt, inspection);
|
|
125
|
-
const ok = inspection.status === 0 &&
|
|
126
|
-
inspection.outcome.ok &&
|
|
127
|
-
inspection.outcome.result.kind === "current";
|
|
128
|
-
return { ok, stdout: output.stdout, stderr: output.stderr };
|
|
129
|
-
}
|
|
130
101
|
function probeFacts(facts) {
|
|
131
102
|
const selection = facts.selection;
|
|
132
103
|
const saved = selection.saved;
|
|
@@ -3,9 +3,8 @@ import { failureResult, successResult, } from "../../adapter-result.js";
|
|
|
3
3
|
import { beginDirectoryPublication, } from "../../atomic.js";
|
|
4
4
|
import { digestArtifactTree } from "../../artifact-tree.js";
|
|
5
5
|
import { assertNoFollowType, classifyPathNoFollow } from "../../safe-path.js";
|
|
6
|
-
import {
|
|
6
|
+
import { codexInspectControl, codexInspectOwnership, codexInstallRefreshMode, codexRemove, codexReadNativeState, } from "./adapter.js";
|
|
7
7
|
import { readCodexAssessment } from "./compatibility.js";
|
|
8
|
-
import { requireManagedUpdateControl, requireNoLegacyState, } from "./lifecycle.js";
|
|
9
8
|
import { readCodexMarketplace, stageCodexMarketplace, } from "./marketplace.js";
|
|
10
9
|
import { assertCodexPreparationSeparate, codexPathFailureDetails, codexPaths, } from "./paths.js";
|
|
11
10
|
import { codexPreparationLocation } from "./prepare.js";
|
|
@@ -13,7 +12,8 @@ import { beginCodexRecovery, finishCodexRecovery, readCodexRecovery, verifyCodex
|
|
|
13
12
|
import { hasFilesystemAccessFailure, pathsEqual } from "./state.js";
|
|
14
13
|
const DEFAULTS = {
|
|
15
14
|
readNative: codexReadNativeState,
|
|
16
|
-
|
|
15
|
+
inspectOwnership: codexInspectOwnership,
|
|
16
|
+
inspectControl: codexInspectControl,
|
|
17
17
|
beginPublication: beginDirectoryPublication,
|
|
18
18
|
};
|
|
19
19
|
const REMOVAL_DEFAULTS = {
|
|
@@ -26,16 +26,6 @@ function fail(code, message, messages) {
|
|
|
26
26
|
function removalFailure(code, message, messages) {
|
|
27
27
|
return failureResult("uninstall-codex", code, message, [], messages);
|
|
28
28
|
}
|
|
29
|
-
function objectResult(result) {
|
|
30
|
-
if (result.status !== 0 ||
|
|
31
|
-
!result.outcome.ok ||
|
|
32
|
-
result.outcome.result === null ||
|
|
33
|
-
typeof result.outcome.result !== "object" ||
|
|
34
|
-
Array.isArray(result.outcome.result)) {
|
|
35
|
-
throw new Error("Codex inspection failed");
|
|
36
|
-
}
|
|
37
|
-
return result.outcome.result;
|
|
38
|
-
}
|
|
39
29
|
function acceptedNative(result) {
|
|
40
30
|
if (result.status !== 0 || !result.outcome.ok) {
|
|
41
31
|
throw new Error("Codex native observation failed");
|
|
@@ -54,26 +44,18 @@ function acceptedNative(result) {
|
|
|
54
44
|
return value;
|
|
55
45
|
}
|
|
56
46
|
function requireOwnership(result) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
requireNoLegacyState(value.identity_state).kind !== "ok" ||
|
|
60
|
-
!Array.isArray(value.conflicts) ||
|
|
61
|
-
value.conflicts.length !== 0) {
|
|
62
|
-
throw new Error("Codex ownership blocks publication");
|
|
47
|
+
if (result.status !== 0 || !result.outcome.ok) {
|
|
48
|
+
throw new Error("Codex inspection failed");
|
|
63
49
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
resources === null ||
|
|
67
|
-
Array.isArray(resources) ||
|
|
68
|
-
typeof resources.plugin !== "boolean" ||
|
|
69
|
-
typeof resources.marketplace !== "boolean") {
|
|
70
|
-
throw new Error("Codex ownership inspection is malformed");
|
|
50
|
+
if (result.outcome.result?.installEligibility?.kind !== "allowed") {
|
|
51
|
+
throw new Error("Codex ownership blocks publication");
|
|
71
52
|
}
|
|
72
53
|
}
|
|
73
54
|
function requireControl(result) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
if (result.status !== 0 || !result.outcome.ok) {
|
|
56
|
+
throw new Error("Codex inspection failed");
|
|
57
|
+
}
|
|
58
|
+
if (result.outcome.result?.mutationEligibility?.kind !== "allowed") {
|
|
77
59
|
throw new Error("Codex update control blocks publication");
|
|
78
60
|
}
|
|
79
61
|
}
|
|
@@ -262,10 +244,10 @@ async function observeNative(ctx, dependencies, messages) {
|
|
|
262
244
|
return acceptedNative(result);
|
|
263
245
|
}
|
|
264
246
|
async function requireEligibility(ctx, dependencies, messages) {
|
|
265
|
-
const ownership = await dependencies.
|
|
247
|
+
const ownership = await dependencies.inspectOwnership(ctx);
|
|
266
248
|
messages.push(...ownership.outcome.messages);
|
|
267
249
|
requireOwnership(ownership);
|
|
268
|
-
const control = await dependencies.
|
|
250
|
+
const control = await dependencies.inspectControl(ctx);
|
|
269
251
|
messages.push(...control.outcome.messages);
|
|
270
252
|
requireControl(control);
|
|
271
253
|
}
|
|
@@ -316,9 +298,6 @@ async function nativeUnchangedAfterAttempt(prior, priorActiveDigest, current) {
|
|
|
316
298
|
return (await activeDigest(current)) === priorActiveDigest;
|
|
317
299
|
}
|
|
318
300
|
async function rollbackPublication(pending, publication, previous, priorNative, priorActiveDigest, activationAttempted, ctx, dependencies) {
|
|
319
|
-
if (pending.settled) {
|
|
320
|
-
return fail("already-settled", "Codex installation transaction has already been settled", []);
|
|
321
|
-
}
|
|
322
301
|
const messages = [];
|
|
323
302
|
try {
|
|
324
303
|
const currentNative = await observeNative(ctx, dependencies, messages);
|
|
@@ -376,9 +355,6 @@ async function verifyActivated(pending, dependencies, ctx, messages) {
|
|
|
376
355
|
}
|
|
377
356
|
}
|
|
378
357
|
async function finalizePublication(pending, publication, dependencies, ctx) {
|
|
379
|
-
if (pending.settled) {
|
|
380
|
-
return fail("already-settled", "Codex installation transaction has already been settled", []);
|
|
381
|
-
}
|
|
382
358
|
const messages = [];
|
|
383
359
|
let verified = false;
|
|
384
360
|
let backupRemoved = false;
|
|
@@ -114,7 +114,7 @@ function validateEvidenceShape(record) {
|
|
|
114
114
|
throw new Error("incomplete old recovery evidence");
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
function decodeRecord(bytes, paths) {
|
|
118
118
|
const parsed = object(parseStrictJson(bytes, PROFILE));
|
|
119
119
|
if (parsed === null)
|
|
120
120
|
throw new Error("recovery journal is not an object");
|
|
@@ -182,7 +182,7 @@ async function readOwnedRecord(paths) {
|
|
|
182
182
|
}
|
|
183
183
|
const path = recordPath(paths);
|
|
184
184
|
await assertNoFollowType(path, ["regular-file"]);
|
|
185
|
-
return
|
|
185
|
+
return decodeRecord(await readFile(path), paths);
|
|
186
186
|
}
|
|
187
187
|
export async function readCodexRecovery(paths) {
|
|
188
188
|
try {
|
|
@@ -247,11 +247,12 @@ export async function beginCodexRecovery(paths, input) {
|
|
|
247
247
|
oldDigest: input.oldDigest,
|
|
248
248
|
oldIdentity: input.oldIdentity,
|
|
249
249
|
};
|
|
250
|
-
|
|
250
|
+
const bytes = recordBytes(record);
|
|
251
|
+
decodeRecord(bytes, paths);
|
|
251
252
|
const path = recordPath(paths);
|
|
252
253
|
const handle = await open(path, "wx", 0o600);
|
|
253
254
|
try {
|
|
254
|
-
await handle.writeFile(
|
|
255
|
+
await handle.writeFile(bytes);
|
|
255
256
|
await handle.sync();
|
|
256
257
|
}
|
|
257
258
|
finally {
|
|
@@ -311,7 +312,6 @@ async function requirePending(pending) {
|
|
|
311
312
|
!(await readFile(path)).equals(recordBytes(pending.record))) {
|
|
312
313
|
throw new Error("Codex recovery journal changed");
|
|
313
314
|
}
|
|
314
|
-
await decodeRecord(recordBytes(pending.record), pending.paths);
|
|
315
315
|
}
|
|
316
316
|
export async function finishCodexRecovery(pending) {
|
|
317
317
|
try {
|
|
@@ -244,6 +244,6 @@ export async function readCodexStoredState(env, cwd) {
|
|
|
244
244
|
managerPluginEnabled: manager?.enabled === true,
|
|
245
245
|
legacyPluginPresent: legacy?.installed === true,
|
|
246
246
|
legacyPluginEnabled: legacy?.enabled === true,
|
|
247
|
-
|
|
247
|
+
installedPlugins: installed,
|
|
248
248
|
};
|
|
249
249
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, open } from "node:fs/promises";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { applyEdits, createScanner, findNodeAtLocation, getNodeValue, parseTree, } from "jsonc-parser";
|
|
6
|
+
import * as jsoncParserRuntime from "jsonc-parser";
|
|
7
|
+
import { atomicWriteFile } from "../../atomic.js";
|
|
8
|
+
import { SafetyError } from "../../safety-error.js";
|
|
9
|
+
import { isErrno } from "../../safe-path.js";
|
|
10
|
+
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_CONFIG_DEPTH = 256;
|
|
12
|
+
const UTF8_DECODER = new TextDecoder("utf-8", {
|
|
13
|
+
fatal: true,
|
|
14
|
+
ignoreBOM: true,
|
|
15
|
+
});
|
|
16
|
+
const SYNTAX_KIND = jsoncParserRuntime.SyntaxKind;
|
|
17
|
+
async function readBoundedConfigBytes(handle) {
|
|
18
|
+
const budget = Buffer.allocUnsafe(MAX_CONFIG_BYTES + 1);
|
|
19
|
+
let length = 0;
|
|
20
|
+
while (length < budget.length) {
|
|
21
|
+
const { bytesRead } = await handle.read(budget, length, budget.length - length, null);
|
|
22
|
+
if (bytesRead === 0)
|
|
23
|
+
break;
|
|
24
|
+
length += bytesRead;
|
|
25
|
+
}
|
|
26
|
+
if (length > MAX_CONFIG_BYTES) {
|
|
27
|
+
throw new Error("configuration grew past the byte limit");
|
|
28
|
+
}
|
|
29
|
+
return Buffer.from(budget.subarray(0, length));
|
|
30
|
+
}
|
|
31
|
+
function invalidConfig(path, cause) {
|
|
32
|
+
return new SafetyError("opencode-config", `invalid OpenCode configuration: ${path}`, cause === undefined ? {} : { cause });
|
|
33
|
+
}
|
|
34
|
+
function cannotRead(path, cause) {
|
|
35
|
+
return new SafetyError("opencode-config", `cannot read OpenCode configuration: ${path}`, cause === undefined ? {} : { cause });
|
|
36
|
+
}
|
|
37
|
+
function cannotRemove(path, cause) {
|
|
38
|
+
return new SafetyError("opencode-config", `cannot remove owned OpenCode registration: ${path}`, cause === undefined ? {} : { cause });
|
|
39
|
+
}
|
|
40
|
+
function propertyParts(property) {
|
|
41
|
+
const [key, value] = property.children ?? [];
|
|
42
|
+
if (property.type !== "property" ||
|
|
43
|
+
key?.type !== "string" ||
|
|
44
|
+
typeof key.value !== "string" ||
|
|
45
|
+
value === undefined) {
|
|
46
|
+
throw new Error("malformed property tree");
|
|
47
|
+
}
|
|
48
|
+
return [key, value];
|
|
49
|
+
}
|
|
50
|
+
function validateTree(node, depth) {
|
|
51
|
+
if (node.type !== "object" && node.type !== "array")
|
|
52
|
+
return;
|
|
53
|
+
if (depth > MAX_CONFIG_DEPTH)
|
|
54
|
+
throw new Error("configuration is too deep");
|
|
55
|
+
if (node.type === "object") {
|
|
56
|
+
const names = new Set();
|
|
57
|
+
for (const property of node.children ?? []) {
|
|
58
|
+
const [key, value] = propertyParts(property);
|
|
59
|
+
if (names.has(key.value))
|
|
60
|
+
throw new Error("duplicate key");
|
|
61
|
+
names.add(key.value);
|
|
62
|
+
validateTree(value, depth + 1);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const child of node.children ?? [])
|
|
67
|
+
validateTree(child, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
function configEntries(root) {
|
|
70
|
+
const plugin = findNodeAtLocation(root, ["plugin"]);
|
|
71
|
+
if (plugin === undefined)
|
|
72
|
+
return [];
|
|
73
|
+
if (plugin.type !== "array")
|
|
74
|
+
throw new Error("plugin must be an array");
|
|
75
|
+
return (plugin.children ?? []).map((entry, index) => {
|
|
76
|
+
if (entry.type === "string" && typeof entry.value === "string") {
|
|
77
|
+
return { index, spec: entry.value, options: null };
|
|
78
|
+
}
|
|
79
|
+
const tuple = entry.children ?? [];
|
|
80
|
+
if (entry.type !== "array" ||
|
|
81
|
+
tuple.length !== 2 ||
|
|
82
|
+
tuple[0]?.type !== "string" ||
|
|
83
|
+
typeof tuple[0].value !== "string" ||
|
|
84
|
+
tuple[1]?.type !== "object") {
|
|
85
|
+
throw new Error("unsupported plugin entry");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
index,
|
|
89
|
+
spec: tuple[0].value,
|
|
90
|
+
options: structuredClone(getNodeValue(tuple[1])),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function parseOpenCodeConfig(text, path) {
|
|
95
|
+
try {
|
|
96
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CONFIG_BYTES) {
|
|
97
|
+
throw new Error("configuration is too large");
|
|
98
|
+
}
|
|
99
|
+
const errors = [];
|
|
100
|
+
const root = parseTree(text, errors, { allowTrailingComma: true });
|
|
101
|
+
if (root === undefined || errors.length !== 0 || root.type !== "object") {
|
|
102
|
+
throw new Error("configuration must be one valid object");
|
|
103
|
+
}
|
|
104
|
+
validateTree(root, 1);
|
|
105
|
+
return { path, text, root, entries: configEntries(root) };
|
|
106
|
+
}
|
|
107
|
+
catch (cause) {
|
|
108
|
+
if (cause instanceof SafetyError)
|
|
109
|
+
throw cause;
|
|
110
|
+
throw invalidConfig(path, cause);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function sameEntries(left, right) {
|
|
114
|
+
return (left.length === right.length &&
|
|
115
|
+
left.every((entry, index) => entry.spec === right[index]?.spec &&
|
|
116
|
+
isDeepStrictEqual(entry.options, right[index]?.options)));
|
|
117
|
+
}
|
|
118
|
+
export function removeOpenCodeEntry(document, index) {
|
|
119
|
+
try {
|
|
120
|
+
const array = findNodeAtLocation(document.root, ["plugin"]);
|
|
121
|
+
const elements = array?.children ?? [];
|
|
122
|
+
const element = elements[index];
|
|
123
|
+
if (array?.type !== "array" ||
|
|
124
|
+
!Number.isInteger(index) ||
|
|
125
|
+
index < 0 ||
|
|
126
|
+
!element) {
|
|
127
|
+
throw new Error("invalid removal index");
|
|
128
|
+
}
|
|
129
|
+
const previous = elements[index - 1];
|
|
130
|
+
const next = elements[index + 1];
|
|
131
|
+
const gapStart = next || !previous
|
|
132
|
+
? element.offset + element.length
|
|
133
|
+
: previous.offset + previous.length;
|
|
134
|
+
const gapEnd = next
|
|
135
|
+
? next.offset
|
|
136
|
+
: previous
|
|
137
|
+
? element.offset
|
|
138
|
+
: array.offset + array.length - 1;
|
|
139
|
+
const scanner = createScanner(document.text, true);
|
|
140
|
+
scanner.setPosition(gapStart);
|
|
141
|
+
let commaOffset;
|
|
142
|
+
while (scanner.getPosition() < gapEnd) {
|
|
143
|
+
const token = scanner.scan();
|
|
144
|
+
if (token === SYNTAX_KIND.EOF || scanner.getTokenOffset() >= gapEnd)
|
|
145
|
+
break;
|
|
146
|
+
if (token !== SYNTAX_KIND.CommaToken || commaOffset !== undefined) {
|
|
147
|
+
throw new Error("unexpected separator");
|
|
148
|
+
}
|
|
149
|
+
commaOffset = scanner.getTokenOffset();
|
|
150
|
+
}
|
|
151
|
+
if ((previous || next) && commaOffset === undefined) {
|
|
152
|
+
throw new Error("missing separator");
|
|
153
|
+
}
|
|
154
|
+
const edits = [
|
|
155
|
+
{ offset: element.offset, length: element.length, content: "" },
|
|
156
|
+
];
|
|
157
|
+
if (commaOffset !== undefined) {
|
|
158
|
+
edits.push({ offset: commaOffset, length: 1, content: "" });
|
|
159
|
+
}
|
|
160
|
+
const output = applyEdits(document.text, edits);
|
|
161
|
+
const after = parseOpenCodeConfig(output, document.path);
|
|
162
|
+
const expected = document.entries.filter((entry) => entry.index !== index);
|
|
163
|
+
if (!sameEntries(after.entries, expected)) {
|
|
164
|
+
throw new Error("removal changed surviving registrations");
|
|
165
|
+
}
|
|
166
|
+
return output;
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
throw cannotRemove(document.path, cause);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function readConfigBytes(path) {
|
|
173
|
+
let handle;
|
|
174
|
+
try {
|
|
175
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
176
|
+
const details = await handle.stat();
|
|
177
|
+
if (!details.isFile() || details.size > MAX_CONFIG_BYTES) {
|
|
178
|
+
throw new Error("configuration is not a bounded regular file");
|
|
179
|
+
}
|
|
180
|
+
const bytes = await readBoundedConfigBytes(handle);
|
|
181
|
+
return {
|
|
182
|
+
bytes,
|
|
183
|
+
identity: {
|
|
184
|
+
dev: details.dev,
|
|
185
|
+
ino: details.ino,
|
|
186
|
+
mode: details.mode,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (cause) {
|
|
191
|
+
if (isErrno(cause, "ENOENT"))
|
|
192
|
+
return null;
|
|
193
|
+
throw cannotRead(path, cause);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await handle?.close().catch(() => { });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function decodeConfig(bytes, path) {
|
|
200
|
+
try {
|
|
201
|
+
return UTF8_DECODER.decode(bytes);
|
|
202
|
+
}
|
|
203
|
+
catch (cause) {
|
|
204
|
+
throw cannotRead(path, cause);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function readOpenCodeConfig(path) {
|
|
208
|
+
const observed = await readConfigBytes(path);
|
|
209
|
+
if (observed === null)
|
|
210
|
+
return null;
|
|
211
|
+
const text = decodeConfig(observed.bytes, path);
|
|
212
|
+
try {
|
|
213
|
+
return {
|
|
214
|
+
document: parseOpenCodeConfig(text, path),
|
|
215
|
+
bytes: observed.bytes,
|
|
216
|
+
identity: observed.identity,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (cause) {
|
|
220
|
+
throw cannotRead(path, cause);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function sameObservedState(left, right) {
|
|
224
|
+
return (left.dev === right.dev && left.ino === right.ino && left.mode === right.mode);
|
|
225
|
+
}
|
|
226
|
+
export async function removeObservedOpenCodeEntry(observation, index) {
|
|
227
|
+
const path = observation.document.path;
|
|
228
|
+
try {
|
|
229
|
+
const originalText = decodeConfig(observation.bytes, path);
|
|
230
|
+
const original = parseOpenCodeConfig(originalText, path);
|
|
231
|
+
const output = removeOpenCodeEntry(original, index);
|
|
232
|
+
const outputBytes = Buffer.from(output, "utf8");
|
|
233
|
+
const expected = original.entries.filter((entry) => entry.index !== index);
|
|
234
|
+
await atomicWriteFile(path, outputBytes, {
|
|
235
|
+
validate: async (temporary) => {
|
|
236
|
+
await chmod(temporary, observation.identity.mode & 0o7777);
|
|
237
|
+
const current = await readConfigBytes(path);
|
|
238
|
+
if (current === null ||
|
|
239
|
+
!sameObservedState(current.identity, observation.identity) ||
|
|
240
|
+
!current.bytes.equals(observation.bytes)) {
|
|
241
|
+
throw new Error("OpenCode configuration changed concurrently");
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const after = await readOpenCodeConfig(path);
|
|
246
|
+
if (after === null ||
|
|
247
|
+
!after.bytes.equals(outputBytes) ||
|
|
248
|
+
after.identity.mode !== observation.identity.mode ||
|
|
249
|
+
!sameEntries(after.document.entries, expected)) {
|
|
250
|
+
throw new Error("published OpenCode configuration could not be verified");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (cause) {
|
|
254
|
+
throw cannotRemove(path, cause);
|
|
255
|
+
}
|
|
256
|
+
}
|