superpowers-manager 0.7.3 → 0.9.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.
Files changed (67) hide show
  1. package/README.md +36 -16
  2. package/dist/atomic.js +1 -3
  3. package/dist/cli-arguments.js +4 -1
  4. package/dist/cli.js +14 -43
  5. package/dist/commands/adapter-call.js +24 -0
  6. package/dist/commands/install.js +16 -63
  7. package/dist/commands/mutation.js +5 -5
  8. package/dist/commands/pin.js +10 -5
  9. package/dist/commands/prepare.js +42 -108
  10. package/dist/commands/probe.js +33 -19
  11. package/dist/commands/uninstall.js +18 -46
  12. package/dist/commands/unpin.js +2 -1
  13. package/dist/commands/update.js +8 -29
  14. package/dist/domain/refs.js +0 -3
  15. package/dist/effective-selection.js +11 -15
  16. package/dist/git-tree.js +4 -6
  17. package/dist/harness-command-result.js +36 -5
  18. package/dist/harnesses/claude-code/harness.js +39 -0
  19. package/dist/harnesses/claude-code/install.js +380 -0
  20. package/dist/harnesses/claude-code/native.js +79 -0
  21. package/dist/harnesses/claude-code/paths.js +35 -0
  22. package/dist/harnesses/claude-code/prepare.js +117 -0
  23. package/dist/harnesses/claude-code/presentation.js +25 -0
  24. package/dist/harnesses/claude-code/state.js +253 -0
  25. package/dist/harnesses/codex/adapter.js +27 -68
  26. package/dist/harnesses/codex/conflicts.js +3 -8
  27. package/dist/harnesses/codex/generated-plugin.js +4 -11
  28. package/dist/harnesses/codex/harness.js +4 -26
  29. package/dist/harnesses/codex/hooks.js +11 -27
  30. package/dist/harnesses/codex/lifecycle.js +22 -107
  31. package/dist/harnesses/codex/manifest-overlay.js +9 -27
  32. package/dist/harnesses/codex/paths.js +29 -0
  33. package/dist/harnesses/codex/prepare.js +9 -43
  34. package/dist/harnesses/codex/presentation.js +1 -1
  35. package/dist/harnesses/codex/publication.js +5 -40
  36. package/dist/harnesses/codex/state.js +1 -6
  37. package/dist/harnesses/codex/stored-state.js +1 -6
  38. package/dist/harnesses/opencode/config.js +105 -50
  39. package/dist/harnesses/opencode/discovery.js +11 -15
  40. package/dist/harnesses/opencode/install.js +57 -102
  41. package/dist/harnesses/opencode/native.js +28 -49
  42. package/dist/harnesses/opencode/package.js +45 -67
  43. package/dist/harnesses/opencode/prepare.js +2 -7
  44. package/dist/harnesses/opencode/presentation.js +3 -7
  45. package/dist/harnesses/opencode/state.js +1 -0
  46. package/dist/harnesses/pi/install.js +31 -120
  47. package/dist/harnesses/pi/native.js +9 -34
  48. package/dist/harnesses/pi/package.js +9 -50
  49. package/dist/harnesses/pi/prepare.js +3 -8
  50. package/dist/harnesses/pi/presentation.js +2 -7
  51. package/dist/harnesses/pi/settings.js +5 -9
  52. package/dist/harnesses/pi/shared-skills.js +23 -53
  53. package/dist/harnesses/pi/state.js +3 -10
  54. package/dist/provenance.js +3 -19
  55. package/dist/resource-lock.js +11 -16
  56. package/dist/safe-path.js +28 -1
  57. package/dist/selection.js +15 -19
  58. package/dist/snapshot-journal.js +101 -0
  59. package/dist/snapshot-package.js +62 -1
  60. package/dist/snapshot-prepare.js +9 -2
  61. package/dist/snapshot-presentation.js +9 -0
  62. package/dist/upstream.js +8 -12
  63. package/dist/validator.js +26 -23
  64. package/dist/workspace.js +29 -35
  65. package/package.json +10 -9
  66. package/dist/python-json.js +0 -4
  67. package/dist/upstream-workspace.js +0 -8
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, open } from "node:fs/promises";
4
4
  import { isDeepStrictEqual } from "node:util";
5
- import { applyEdits, createScanner, findNodeAtLocation, getNodeValue, parseTree, } from "jsonc-parser";
5
+ import { applyEdits, createScanner, findNodeAtLocation, getNodeValue, modify, parseTree, } from "jsonc-parser";
6
6
  import * as jsoncParserRuntime from "jsonc-parser";
7
7
  import { atomicWriteFile } from "../../atomic.js";
8
8
  import { SafetyError } from "../../safety-error.js";
@@ -14,6 +14,7 @@ const UTF8_DECODER = new TextDecoder("utf-8", {
14
14
  ignoreBOM: true,
15
15
  });
16
16
  const SYNTAX_KIND = jsoncParserRuntime.SyntaxKind;
17
+ const CONFIG_KEYS = ["plugin", "plugins"];
17
18
  async function readBoundedConfigBytes(handle) {
18
19
  const budget = Buffer.allocUnsafe(MAX_CONFIG_BYTES + 1);
19
20
  let length = 0;
@@ -37,6 +38,9 @@ function cannotRead(path, cause) {
37
38
  function cannotRemove(path, cause) {
38
39
  return new SafetyError("opencode-config", `cannot remove owned OpenCode registration: ${path}`, cause === undefined ? {} : { cause });
39
40
  }
41
+ function cannotRegister(path, cause) {
42
+ return new SafetyError("opencode-config", `cannot register owned OpenCode registration: ${path}`, cause === undefined ? {} : { cause });
43
+ }
40
44
  function propertyParts(property) {
41
45
  const [key, value] = property.children ?? [];
42
46
  if (property.type !== "property" ||
@@ -67,28 +71,31 @@ function validateTree(node, depth) {
67
71
  validateTree(child, depth + 1);
68
72
  }
69
73
  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
- };
74
+ return CONFIG_KEYS.flatMap((key) => {
75
+ const array = findNodeAtLocation(root, [key]);
76
+ if (array === undefined)
77
+ return [];
78
+ if (array.type !== "array")
79
+ throw new Error(`${key} must be an array`);
80
+ return (array.children ?? []).map((entry, index) => {
81
+ if (entry.type === "string" && typeof entry.value === "string") {
82
+ return { key, index, spec: entry.value, options: null };
83
+ }
84
+ const tuple = entry.children ?? [];
85
+ if (entry.type !== "array" ||
86
+ tuple.length !== 2 ||
87
+ tuple[0]?.type !== "string" ||
88
+ typeof tuple[0].value !== "string" ||
89
+ tuple[1]?.type !== "object") {
90
+ throw new Error("unsupported plugin entry");
91
+ }
92
+ return {
93
+ key,
94
+ index,
95
+ spec: tuple[0].value,
96
+ options: structuredClone(getNodeValue(tuple[1])),
97
+ };
98
+ });
92
99
  });
93
100
  }
94
101
  export function parseOpenCodeConfig(text, path) {
@@ -112,12 +119,13 @@ export function parseOpenCodeConfig(text, path) {
112
119
  }
113
120
  function sameEntries(left, right) {
114
121
  return (left.length === right.length &&
115
- left.every((entry, index) => entry.spec === right[index]?.spec &&
122
+ left.every((entry, index) => entry.key === right[index]?.key &&
123
+ entry.spec === right[index]?.spec &&
116
124
  isDeepStrictEqual(entry.options, right[index]?.options)));
117
125
  }
118
- export function removeOpenCodeEntry(document, index) {
126
+ export function removeOpenCodeEntry(document, key, index) {
119
127
  try {
120
- const array = findNodeAtLocation(document.root, ["plugin"]);
128
+ const array = findNodeAtLocation(document.root, [key]);
121
129
  const elements = array?.children ?? [];
122
130
  const element = elements[index];
123
131
  if (array?.type !== "array" ||
@@ -159,7 +167,7 @@ export function removeOpenCodeEntry(document, index) {
159
167
  }
160
168
  const output = applyEdits(document.text, edits);
161
169
  const after = parseOpenCodeConfig(output, document.path);
162
- const expected = document.entries.filter((entry) => entry.index !== index);
170
+ const expected = document.entries.filter((entry) => entry.key !== key || entry.index !== index);
163
171
  if (!sameEntries(after.entries, expected)) {
164
172
  throw new Error("removal changed surviving registrations");
165
173
  }
@@ -169,6 +177,37 @@ export function removeOpenCodeEntry(document, index) {
169
177
  throw cannotRemove(document.path, cause);
170
178
  }
171
179
  }
180
+ export function addOpenCodeEntry(document, key, spec) {
181
+ try {
182
+ if (spec.trim().length === 0)
183
+ throw new Error("empty registration spec");
184
+ const array = findNodeAtLocation(document.root, [key]);
185
+ if (array !== undefined && array.type !== "array") {
186
+ throw new Error(`${key} must be an array`);
187
+ }
188
+ const position = array?.children?.length ?? 0;
189
+ const edits = modify(document.text, [key, position], spec, {
190
+ isArrayInsertion: true,
191
+ });
192
+ const output = applyEdits(document.text, edits);
193
+ const after = parseOpenCodeConfig(output, document.path);
194
+ const expected = [
195
+ ...document.entries,
196
+ { key, index: position, spec, options: null },
197
+ ].sort((left, right) => left.key === right.key
198
+ ? left.index - right.index
199
+ : left.key < right.key
200
+ ? -1
201
+ : 1);
202
+ if (!sameEntries(after.entries, expected)) {
203
+ throw new Error("registration changed surviving registrations");
204
+ }
205
+ return output;
206
+ }
207
+ catch (cause) {
208
+ throw cannotRegister(document.path, cause);
209
+ }
210
+ }
172
211
  async function readConfigBytes(path) {
173
212
  let handle;
174
213
  try {
@@ -223,34 +262,50 @@ export async function readOpenCodeConfig(path) {
223
262
  function sameObservedState(left, right) {
224
263
  return (left.dev === right.dev && left.ino === right.ino && left.mode === right.mode);
225
264
  }
226
- export async function removeObservedOpenCodeEntry(observation, index) {
265
+ async function writeObservedOpenCodeConfig(observation, outputBytes, expected) {
266
+ const path = observation.document.path;
267
+ await atomicWriteFile(path, outputBytes, {
268
+ validate: async (temporary) => {
269
+ await chmod(temporary, observation.identity.mode & 0o7777);
270
+ const current = await readConfigBytes(path);
271
+ if (current === null ||
272
+ !sameObservedState(current.identity, observation.identity) ||
273
+ !current.bytes.equals(observation.bytes)) {
274
+ throw new Error("OpenCode configuration changed concurrently");
275
+ }
276
+ },
277
+ });
278
+ const after = await readOpenCodeConfig(path);
279
+ if (after === null ||
280
+ !after.bytes.equals(outputBytes) ||
281
+ after.identity.mode !== observation.identity.mode ||
282
+ !sameEntries(after.document.entries, expected)) {
283
+ throw new Error("published OpenCode configuration could not be verified");
284
+ }
285
+ }
286
+ export async function removeObservedOpenCodeEntry(observation, key, index) {
227
287
  const path = observation.document.path;
228
288
  try {
229
- const originalText = decodeConfig(observation.bytes, path);
230
- const original = parseOpenCodeConfig(originalText, path);
231
- const output = removeOpenCodeEntry(original, index);
289
+ const original = observation.document;
290
+ const output = removeOpenCodeEntry(original, key, index);
232
291
  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
- }
292
+ const expected = original.entries.filter((entry) => entry.key !== key || entry.index !== index);
293
+ await writeObservedOpenCodeConfig(observation, outputBytes, expected);
252
294
  }
253
295
  catch (cause) {
254
296
  throw cannotRemove(path, cause);
255
297
  }
256
298
  }
299
+ export async function addObservedOpenCodeEntry(observation, key, spec) {
300
+ const path = observation.document.path;
301
+ try {
302
+ const original = observation.document;
303
+ const output = addOpenCodeEntry(original, key, spec);
304
+ const outputBytes = Buffer.from(output, "utf8");
305
+ const expected = parseOpenCodeConfig(output, path).entries;
306
+ await writeObservedOpenCodeConfig(observation, outputBytes, expected);
307
+ }
308
+ catch (cause) {
309
+ throw cannotRegister(path, cause);
310
+ }
311
+ }
@@ -151,11 +151,11 @@ function isKnownPluginFile(path) {
151
151
  }
152
152
  async function inspectPluginEntry(observation, entry, state, allowManaged = true, active = true) {
153
153
  if (entry.spec.trim().length === 0) {
154
- addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
154
+ addBlocked(state, `${observation.document.path} ${entry.key}[${entry.index}]`);
155
155
  return;
156
156
  }
157
157
  if (containsSubstitution(entry.spec)) {
158
- addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
158
+ addBlocked(state, `${observation.document.path} ${entry.key}[${entry.index}]`);
159
159
  return;
160
160
  }
161
161
  const local = localPluginPath(entry.spec, observation.document.path, state);
@@ -163,11 +163,7 @@ async function inspectPluginEntry(observation, entry, state, allowManaged = true
163
163
  const canonical = await canonicalizeProspectivePath(local);
164
164
  if (canonical === state.installedRoot) {
165
165
  if (allowManaged)
166
- state.managedEntries.push({
167
- observation,
168
- entry,
169
- canonicalRoot: canonical,
170
- });
166
+ state.managedEntries.push({ observation, entry });
171
167
  else
172
168
  addBlocked(state, `${OPEN_CODE_UNOWNED_MANAGER_INPUT}: ${observation.document.path}`);
173
169
  return;
@@ -175,14 +171,14 @@ async function inspectPluginEntry(observation, entry, state, allowManaged = true
175
171
  }
176
172
  if (!active)
177
173
  return;
178
- if (inspectUpstreamSpec(entry.spec, `${observation.document.path} plugin[${entry.index}]`, state))
174
+ if (inspectUpstreamSpec(entry.spec, `${observation.document.path} ${entry.key}[${entry.index}]`, state))
179
175
  return;
180
176
  if (local === null)
181
177
  return;
182
178
  const kind = await classifyPathNoFollow(local);
183
179
  if (kind === "missing") {
184
180
  if (/^superpowers(?:\.(?:js|ts))?$/u.test(basename(local)))
185
- addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
181
+ addBlocked(state, `${observation.document.path} ${entry.key}[${entry.index}]`);
186
182
  return;
187
183
  }
188
184
  if (isKnownPluginFile(local)) {
@@ -190,7 +186,7 @@ async function inspectPluginEntry(observation, entry, state, allowManaged = true
190
186
  return;
191
187
  }
192
188
  if (kind !== "directory" && kind !== "symlink") {
193
- addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
189
+ addBlocked(state, `${observation.document.path} ${entry.key}[${entry.index}]`);
194
190
  return;
195
191
  }
196
192
  try {
@@ -200,7 +196,7 @@ async function inspectPluginEntry(observation, entry, state, allowManaged = true
200
196
  }
201
197
  catch {
202
198
  if (basename(local) === "superpowers")
203
- addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
199
+ addBlocked(state, `${observation.document.path} ${entry.key}[${entry.index}]`);
204
200
  }
205
201
  }
206
202
  async function inspectSkillFile(path, label, state, requireKnownIdentity = false) {
@@ -268,7 +264,7 @@ async function inspectDocument(observation, state) {
268
264
  await inspectConfigFields(effective.root, source, state);
269
265
  const active = !truthy(state.env.OPENCODE_PURE);
270
266
  for (const entry of effective.entries) {
271
- const original = observation.document.entries[entry.index];
267
+ const original = observation.document.entries.find((candidate) => candidate.key === entry.key && candidate.index === entry.index);
272
268
  const stable = original !== undefined && original.spec === entry.spec;
273
269
  await inspectPluginEntry(observation, stable ? original : entry, state, stable && isNativeWriterOrigin(observation, state), active);
274
270
  }
@@ -565,8 +561,8 @@ export async function inspectOpenCodeDiscovery(paths, env, cwd) {
565
561
  for (const entry of inline.entries) {
566
562
  if (entry.spec.trim().length === 0 ||
567
563
  containsSubstitution(entry.spec))
568
- addBlocked(state, `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`);
569
- else if (inspectUpstreamSpec(entry.spec, `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`, state)) {
564
+ addBlocked(state, `OPENCODE_CONFIG_CONTENT ${entry.key}[${entry.index}]`);
565
+ else if (inspectUpstreamSpec(entry.spec, `OPENCODE_CONFIG_CONTENT ${entry.key}[${entry.index}]`, state)) {
570
566
  continue;
571
567
  }
572
568
  else {
@@ -575,7 +571,7 @@ export async function inspectOpenCodeDiscovery(paths, env, cwd) {
575
571
  const canonical = await canonicalizeProspectivePath(local);
576
572
  addBlocked(state, canonical === state.installedRoot
577
573
  ? `${OPEN_CODE_UNOWNED_MANAGER_INPUT}: OPENCODE_CONFIG_CONTENT`
578
- : `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`);
574
+ : `OPENCODE_CONFIG_CONTENT ${entry.key}[${entry.index}]`);
579
575
  }
580
576
  }
581
577
  }
@@ -1,12 +1,12 @@
1
1
  import { createHash, 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";
2
+ import { cp, mkdir, readdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
4
  import { findNodeAtLocation } from "jsonc-parser";
5
5
  import { failureResult, successResult, } from "../../adapter-result.js";
6
6
  import { digestArtifactTree } from "../../artifact-tree.js";
7
7
  import { atomicWriteFile, beginDirectoryPublication, } from "../../atomic.js";
8
8
  import { assertNoFollowType, canonicalizeProspectivePath, classifyPathNoFollow, } from "../../safe-path.js";
9
- import { removeObservedOpenCodeEntry } from "./config.js";
9
+ import { addObservedOpenCodeEntry, removeObservedOpenCodeEntry, } from "./config.js";
10
10
  import { inspectOpenCodeDiscovery, OPEN_CODE_PURE_MODE_INPUT, } from "./discovery.js";
11
11
  import { normalizeSnapshotRuntimeVersion } from "../../harness-command-result.js";
12
12
  import { runOpenCode } from "./native.js";
@@ -14,10 +14,13 @@ import { readOpenCodePackageAssessment, readOpenCodeReceipt, } from "./package.j
14
14
  import { assertOpenCodePreparationSeparate, openCodePaths, } from "./paths.js";
15
15
  import { inspectOpenCodeControl, inspectOpenCodeOwnership, } from "./state.js";
16
16
  import { displayPath } from "../../validator.js";
17
+ import { createJournal, createSnapshotJournal, hasRecovery, identity, journalPath, publicationPaths, requireNoRecovery, } from "../../snapshot-journal.js";
17
18
  const DEFAULTS = {
18
19
  run: runOpenCode,
19
20
  beginPublication: beginDirectoryPublication,
20
21
  };
22
+ // V1 prints a bare semver ("1.18.31"); V2 prints "opencode v2.0.10".
23
+ const OPEN_CODE_VERSION_PREFIX = /^opencode\s+v/i;
21
24
  // The journal can contain data from two independently bounded 1 MiB receipts
22
25
  // plus one bounded 1 MiB config. Six bytes of JSON output per input byte covers
23
26
  // control escaping; counting the registration spec independently brings that
@@ -33,20 +36,6 @@ function accepted(result) {
33
36
  throw new Error("OpenCode operation did not succeed");
34
37
  return result.outcome.result;
35
38
  }
36
- function sameIdentity(left, right) {
37
- return left.dev === right.dev && left.ino === right.ino;
38
- }
39
- async function identity(path) {
40
- await assertNoFollowType(path, ["directory"]);
41
- return await lstat(path);
42
- }
43
- async function requireIdentity(path, expected) {
44
- if (!sameIdentity(await identity(path), expected))
45
- throw new Error("OpenCode directory changed");
46
- }
47
- function journalPath(pending) {
48
- return join(pending.paths.recoveryRoot, "transaction.json");
49
- }
50
39
  function journalBytes(journal) {
51
40
  const bytes = Buffer.from(`${JSON.stringify(journal)}\n`);
52
41
  if (bytes.length > MAX_JOURNAL_BYTES)
@@ -55,31 +44,14 @@ function journalBytes(journal) {
55
44
  }
56
45
  async function validatePaths(paths) {
57
46
  await assertOpenCodePreparationSeparate(paths);
58
- await assertNoFollowType(paths.configRoot, ["directory", "missing"]);
59
- await assertNoFollowType(paths.managerRoot, ["directory", "missing"]);
60
- await assertNoFollowType(paths.installedRoot, ["directory", "missing"]);
61
47
  return await canonicalizeProspectivePath(paths.installedRoot);
62
48
  }
63
- async function requireNoRecovery(paths) {
64
- await assertNoFollowType(paths.recoveryRoot, ["missing"]);
65
- }
66
- async function hasRecovery(paths) {
67
- try {
68
- return (await classifyPathNoFollow(paths.recoveryRoot)) !== "missing";
69
- }
70
- catch {
71
- return true;
72
- }
73
- }
74
- async function syncDirectoryStrict(path) {
75
- const directory = await open(path, "r");
76
- try {
77
- await directory.sync();
78
- }
79
- finally {
80
- await directory.close();
81
- }
82
- }
49
+ const { requireIdentity, requireJournal, phase, retireJournal } = createSnapshotJournal({
50
+ label: "OpenCode",
51
+ validatePaths,
52
+ serialize: journalBytes,
53
+ readCap: MAX_JOURNAL_BYTES,
54
+ });
83
55
  function artifactSame(left, right) {
84
56
  return left === null
85
57
  ? right === null
@@ -122,13 +94,14 @@ function registrationRecord(registration) {
122
94
  return null;
123
95
  const observation = registration.observation;
124
96
  const node = findNodeAtLocation(observation.document.root, [
125
- "plugin",
97
+ registration.entry.key,
126
98
  registration.entry.index,
127
99
  ]);
128
100
  if (node === undefined)
129
101
  throw new Error("OpenCode registration syntax changed");
130
102
  return {
131
103
  configPath: observation.document.path,
104
+ configKey: registration.entry.key,
132
105
  entryIndex: registration.entry.index,
133
106
  spec: registration.entry.spec,
134
107
  optionsDigest: createHash("sha256")
@@ -148,6 +121,7 @@ function sameRegistration(registration, expected) {
148
121
  ? registration === undefined
149
122
  : registration !== undefined &&
150
123
  registration.observation.document.path === expected.configPath &&
124
+ registration.entry.key === expected.configKey &&
151
125
  registration.entry.spec === expected.spec &&
152
126
  createHash("sha256")
153
127
  .update(JSON.stringify(registration.entry.options))
@@ -157,6 +131,7 @@ function registrationInputSame(input, current) {
157
131
  return input === null
158
132
  ? current === null
159
133
  : current !== null &&
134
+ input.key === current.key &&
160
135
  input.entryIndex === current.entryIndex &&
161
136
  input.spec === current.spec &&
162
137
  input.observation.document.path === current.observation.document.path &&
@@ -195,39 +170,6 @@ async function requireActivationEligibility(ctx) {
195
170
  control.mutationEligibility.kind !== "allowed")
196
171
  throw new Error("OpenCode ownership or control blocked");
197
172
  }
198
- async function requireJournal(pending) {
199
- if ((await validatePaths(pending.paths)) !== pending.canonicalRoot ||
200
- pending.journal.installedRoot !== pending.canonicalRoot ||
201
- !/^[a-f0-9]{32}$/u.test(pending.journal.token))
202
- throw new Error("OpenCode recovery root changed");
203
- await requireIdentity(pending.paths.recoveryRoot, pending.recoveryIdentity);
204
- const path = journalPath(pending);
205
- await assertNoFollowType(path, ["regular-file"]);
206
- const observed = await lstat(path);
207
- if (!sameIdentity(observed, pending.journalIdentity) ||
208
- observed.size > MAX_JOURNAL_BYTES ||
209
- !(await readFile(path)).equals(journalBytes(pending.journal)))
210
- throw new Error("OpenCode recovery journal changed");
211
- if (pending.backup !==
212
- join(dirname(pending.canonicalRoot), `.${basename(pending.canonicalRoot)}.bak.${pending.journal.token}`) ||
213
- pending.stage !==
214
- join(dirname(pending.canonicalRoot), `.${basename(pending.canonicalRoot)}.stage.${pending.journal.token}`))
215
- throw new Error("OpenCode recovery paths changed");
216
- }
217
- async function phase(pending, next, changes = {}) {
218
- await requireJournal(pending);
219
- const journal = { ...pending.journal, ...changes, phase: next };
220
- const bytes = journalBytes(journal);
221
- await atomicWriteFile(journalPath(pending), bytes, {
222
- validate: async (temporary) => {
223
- if (!(await readFile(temporary)).equals(bytes))
224
- throw new Error("OpenCode journal write changed");
225
- },
226
- });
227
- await syncDirectoryStrict(pending.paths.recoveryRoot);
228
- pending.journal = journal;
229
- pending.journalIdentity = await lstat(journalPath(pending));
230
- }
231
173
  async function beginJournal(paths, oldArtifact, newArtifact, priorRegistration, ctx) {
232
174
  const canonicalRoot = await validatePaths(paths);
233
175
  await requireNoRecovery(paths);
@@ -248,36 +190,18 @@ async function beginJournal(paths, oldArtifact, newArtifact, priorRegistration,
248
190
  await mkdir(paths.managerRoot, { recursive: true });
249
191
  await mkdir(paths.recoveryRoot, { mode: 0o700 });
250
192
  const recoveryIdentity = await identity(paths.recoveryRoot);
251
- const path = join(paths.recoveryRoot, "transaction.json");
252
- const handle = await open(path, "wx", 0o600);
253
- try {
254
- await handle.writeFile(bytes);
255
- await handle.sync();
256
- }
257
- finally {
258
- await handle.close();
259
- }
260
- await syncDirectoryStrict(paths.recoveryRoot);
193
+ const journalIdentity = await createJournal(paths.recoveryRoot, bytes);
261
194
  return {
262
195
  paths,
263
196
  canonicalRoot,
264
197
  recoveryIdentity,
265
- backup: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.bak.${token}`),
266
- stage: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.stage.${token}`),
198
+ ...publicationPaths(canonicalRoot, token),
267
199
  ctx,
268
200
  journal,
269
- journalIdentity: await lstat(path),
201
+ journalIdentity,
270
202
  settled: false,
271
203
  };
272
204
  }
273
- async function retireJournal(pending) {
274
- await requireJournal(pending);
275
- if ((await readdir(pending.paths.recoveryRoot)).some((name) => name !== "transaction.json"))
276
- throw new Error("unknown OpenCode recovery material");
277
- await unlink(journalPath(pending));
278
- await requireIdentity(pending.paths.recoveryRoot, pending.recoveryIdentity);
279
- await rmdir(pending.paths.recoveryRoot);
280
- }
281
205
  async function recoveryGuidance(pending) {
282
206
  try {
283
207
  const kind = await classifyPathNoFollow(pending.paths.recoveryRoot);
@@ -348,7 +272,7 @@ async function rollbackOpenCodePublication(pending, publication) {
348
272
  const current = observed.managedEntries[0];
349
273
  if (current === undefined)
350
274
  throw new Error("OpenCode created registration missing");
351
- await removeObservedOpenCodeEntry(current.observation, current.entry.index);
275
+ await removeObservedOpenCodeEntry(current.observation, current.entry.key, current.entry.index);
352
276
  observed = await discovery(pending.paths, pending.ctx);
353
277
  requireNoOwnedActivation(observed);
354
278
  }
@@ -416,7 +340,13 @@ export async function installOpenCode(artifact, ctx, deps = DEFAULTS) {
416
340
  let priorRegistration = registrationRecord(observed.managedEntries[0]);
417
341
  if (previous === null && priorRegistration !== null)
418
342
  throw new Error("unowned OpenCode registration");
419
- accepted(normalizeSnapshotRuntimeVersion("OpenCode", await deps.run(["--version"], paths, ctx)));
343
+ const runtimeVersion = accepted(normalizeSnapshotRuntimeVersion("OpenCode", await deps.run(["--version"], paths, ctx), OPEN_CODE_VERSION_PREFIX));
344
+ const major = Number.parseInt(runtimeVersion, 10);
345
+ if (major !== 1 && major !== 2)
346
+ return fail("unsupported-runtime", `unsupported OpenCode major version ${major}`);
347
+ if (major === 2 &&
348
+ assessment.compatibility.generation !== "opencode-native-bootstrap-v2")
349
+ return fail("unsupported-artifact", "the selected upstream ref does not support OpenCode 2; select a ref that ships the V2 entrypoint");
420
350
  await requireSnapshot(paths.installedRoot, previous);
421
351
  observed = await requireStableActivationRegistration(paths, ctx, priorRegistration);
422
352
  priorRegistration = registrationRecord(observed.managedEntries[0]);
@@ -443,16 +373,40 @@ export async function installOpenCode(artifact, ctx, deps = DEFAULTS) {
443
373
  if (priorRegistration === null) {
444
374
  await phase(pending, "registering");
445
375
  observed = await requireStableActivationRegistration(paths, ctx, null);
446
- const native = await deps
447
- .run(["plugin", pending.canonicalRoot, "--global"], paths, ctx)
448
- .catch(() => fail("native-failed", "OpenCode native installation failed"));
376
+ let native = null;
377
+ if (major === 1) {
378
+ native = await deps
379
+ .run(["plugin", pending.canonicalRoot, "--global"], paths, ctx)
380
+ .catch(() => fail("native-failed", "OpenCode native installation failed"));
381
+ }
382
+ else {
383
+ const jsonc = join(paths.configRoot, "opencode.jsonc");
384
+ const json = join(paths.configRoot, "opencode.json");
385
+ let target = observed.documents.find((document) => document.document.path === jsonc) ??
386
+ observed.documents.find((document) => document.document.path === json);
387
+ if (target === undefined) {
388
+ await atomicWriteFile(json, Buffer.from("{}\n"), {
389
+ validate: async () => {
390
+ await assertNoFollowType(paths.configRoot, ["directory"]);
391
+ await assertNoFollowType(jsonc, ["missing"]);
392
+ await assertNoFollowType(json, ["missing"]);
393
+ },
394
+ });
395
+ observed = await requireStableActivationRegistration(paths, ctx, null);
396
+ target = observed.documents.find((document) => document.document.path === json);
397
+ }
398
+ if (target === undefined)
399
+ throw new Error("no OpenCode configuration to register in");
400
+ await addObservedOpenCodeEntry(target, "plugins", pending.canonicalRoot);
401
+ }
449
402
  observed = await discovery(paths, ctx);
450
403
  requireSafeActivationDiscovery(observed);
451
404
  const created = registrationRecord(observed.managedEntries[0]);
452
405
  if (created === null || created.spec !== pending.canonicalRoot)
453
406
  throw new Error("OpenCode activation unverified");
454
407
  await phase(pending, "registered", { createdRegistration: created });
455
- accepted(native);
408
+ if (native !== null)
409
+ accepted(native);
456
410
  }
457
411
  await requirePublication(pending);
458
412
  observed = await discovery(paths, ctx);
@@ -524,6 +478,7 @@ export async function removeOpenCode(input, ctx, deps = DEFAULTS) {
524
478
  ? null
525
479
  : {
526
480
  observation: registered.observation,
481
+ key: registered.entry.key,
527
482
  entryIndex: registered.entry.index,
528
483
  spec: registered.entry.spec,
529
484
  }))
@@ -546,7 +501,7 @@ export async function removeOpenCode(input, ctx, deps = DEFAULTS) {
546
501
  if (!sameRegistration(observed.managedEntries[0], pending.journal.priorRegistration))
547
502
  throw new Error("OpenCode registration changed");
548
503
  if (registered !== undefined) {
549
- await removeObservedOpenCodeEntry(registered.observation, registered.entry.index);
504
+ await removeObservedOpenCodeEntry(registered.observation, registered.entry.key, registered.entry.index);
550
505
  observed = await discovery(paths, ctx);
551
506
  requireNoOwnedActivation(observed);
552
507
  deregistered = true;