opencode-ship 0.2.1 → 0.3.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/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // opencode-ship CLI v0.2.1
2
+ // opencode-ship CLI v0.3.0
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __esm = (fn, res) => function __init() {
@@ -10,109 +10,6 @@ var __export = (target, all) => {
10
10
  __defProp(target, name, { get: all[name], enumerable: true });
11
11
  };
12
12
 
13
- // src/installer/catalog.js
14
- import { fileURLToPath } from "node:url";
15
- import { dirname, resolve } from "node:path";
16
- function getTemplateSet() {
17
- return TEMPLATE_SET;
18
- }
19
- var packageRoot, TEMPLATE_SET, CATALOG, POINTER_ENTRIES;
20
- var init_catalog = __esm({
21
- "src/installer/catalog.js"() {
22
- packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
23
- TEMPLATE_SET = "v0.2.0";
24
- CATALOG = [
25
- {
26
- kind: "plugin",
27
- path: ".opencode/plugin/opencode-ship.js",
28
- source: resolve(packageRoot, "dist/plugin.js"),
29
- mode: 420
30
- },
31
- {
32
- kind: "agent",
33
- path: ".opencode/agents/delivery-reviewer.md",
34
- source: resolve(packageRoot, "assets/agents/delivery-reviewer.md"),
35
- mode: 420
36
- },
37
- {
38
- kind: "agent",
39
- path: ".opencode/agents/delivery-verifier.md",
40
- source: resolve(packageRoot, "assets/agents/delivery-verifier.md"),
41
- mode: 420
42
- },
43
- {
44
- kind: "skill",
45
- path: ".opencode/skills/delivery-workflow/SKILL.md",
46
- source: resolve(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
47
- mode: 420
48
- },
49
- {
50
- kind: "skill",
51
- path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
52
- source: resolve(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
53
- mode: 420
54
- }
55
- ];
56
- POINTER_ENTRIES = [
57
- {
58
- pointer: "/agent/build/permission/delivery_inspect",
59
- strategy: "value",
60
- value: "allow"
61
- },
62
- {
63
- pointer: "/agent/build/permission/delivery_issue",
64
- strategy: "value",
65
- value: "allow"
66
- },
67
- {
68
- pointer: "/agent/build/permission/delivery_worktree",
69
- strategy: "value",
70
- value: "allow"
71
- },
72
- {
73
- pointer: "/agent/build/permission/delivery_verify",
74
- strategy: "value",
75
- value: "deny"
76
- },
77
- {
78
- pointer: "/agent/build/permission/delivery_review",
79
- strategy: "value",
80
- value: "deny"
81
- },
82
- {
83
- pointer: "/agent/build/permission/delivery_pr",
84
- strategy: "value",
85
- value: "allow"
86
- },
87
- {
88
- pointer: "/agent/build/permission/delivery_ready",
89
- strategy: "value",
90
- value: "allow"
91
- },
92
- {
93
- pointer: "/agent/build/permission/delivery_merge",
94
- strategy: "value",
95
- value: "ask"
96
- },
97
- {
98
- pointer: "/agent/build/permission/delivery_cleanup",
99
- strategy: "value",
100
- value: "allow"
101
- },
102
- {
103
- pointer: "/agent/build/permission/task/delivery-reviewer",
104
- strategy: "value",
105
- value: "allow"
106
- },
107
- {
108
- pointer: "/agent/build/permission/task/delivery-verifier",
109
- strategy: "value",
110
- value: "allow"
111
- }
112
- ];
113
- }
114
- });
115
-
116
13
  // src/installer/json-pointer.js
117
14
  var json_pointer_exports = {};
118
15
  __export(json_pointer_exports, {
@@ -216,6 +113,7 @@ var init_hash = __esm({
216
113
  // src/installer/root-config.js
217
114
  var root_config_exports = {};
218
115
  __export(root_config_exports, {
116
+ POINTER_ENTRIES: () => POINTER_ENTRIES,
219
117
  applyOwnedPointers: () => applyOwnedPointers,
220
118
  defaultRootConfigPath: () => defaultRootConfigPath,
221
119
  findRootConfig: () => findRootConfig,
@@ -225,24 +123,24 @@ __export(root_config_exports, {
225
123
  readRootConfig: () => readRootConfig,
226
124
  synthesizeDefaultRootConfig: () => synthesizeDefaultRootConfig
227
125
  });
228
- import { existsSync as existsSync2, readFileSync } from "node:fs";
126
+ import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
229
127
  import { readFile as readFile2 } from "node:fs/promises";
230
- import { resolve as resolve3 } from "node:path";
128
+ import { resolve as resolve5 } from "node:path";
231
129
  function findRootConfig(repoRoot) {
232
130
  for (const rel of ROOT_PATH_CANDIDATES) {
233
- const abs = resolve3(repoRoot, rel);
234
- if (existsSync2(abs)) return { path: abs, relative: rel, format: rel.endsWith(".jsonc") ? "jsonc" : "json" };
131
+ const abs = resolve5(repoRoot, rel);
132
+ if (existsSync5(abs)) return { path: abs, relative: rel, format: rel.endsWith(".jsonc") ? "jsonc" : "json" };
235
133
  }
236
134
  return { path: null, relative: ROOT_PATH_CANDIDATES[0], format: "json" };
237
135
  }
238
136
  function defaultRootConfigPath(repoRoot) {
239
- return resolve3(repoRoot, ROOT_PATH_CANDIDATES[0]);
137
+ return resolve5(repoRoot, ROOT_PATH_CANDIDATES[0]);
240
138
  }
241
139
  function readRootConfig(absPath) {
242
- if (!existsSync2(absPath)) {
140
+ if (!existsSync5(absPath)) {
243
141
  return { ok: false, error: { kind: "missing", path: absPath } };
244
142
  }
245
- const raw = readFileSync(absPath, "utf8");
143
+ const raw = readFileSync3(absPath, "utf8");
246
144
  const stripped = stripJsonc(raw);
247
145
  try {
248
146
  const value = JSON.parse(stripped);
@@ -382,6 +280,9 @@ function formatRootConfigPreserving(value) {
382
280
  return formatRootConfig(value);
383
281
  }
384
282
  function parseRootConfigPreservingOrder(text) {
283
+ if (typeof text !== "string" || text.length === 0) {
284
+ return { value: {}, format: "json" };
285
+ }
385
286
  const parser = new RootConfigParser(text);
386
287
  const value = parser.parseValue(
387
288
  0,
@@ -391,12 +292,68 @@ function parseRootConfigPreservingOrder(text) {
391
292
  const isJsonc = text.includes("//") || text.includes("/*");
392
293
  return { value, format: isJsonc ? "jsonc" : "json" };
393
294
  }
394
- var ROOT_PATH_CANDIDATES, RootConfigParser;
295
+ var POINTER_ENTRIES, ROOT_PATH_CANDIDATES, RootConfigParser;
395
296
  var init_root_config = __esm({
396
297
  "src/installer/root-config.js"() {
397
298
  init_json_pointer();
398
299
  init_hash();
399
- init_catalog();
300
+ POINTER_ENTRIES = [
301
+ {
302
+ pointer: "/agent/build/permission/delivery_inspect",
303
+ strategy: "value",
304
+ value: "allow"
305
+ },
306
+ {
307
+ pointer: "/agent/build/permission/delivery_issue",
308
+ strategy: "value",
309
+ value: "allow"
310
+ },
311
+ {
312
+ pointer: "/agent/build/permission/delivery_worktree",
313
+ strategy: "value",
314
+ value: "allow"
315
+ },
316
+ {
317
+ pointer: "/agent/build/permission/delivery_verify",
318
+ strategy: "value",
319
+ value: "deny"
320
+ },
321
+ {
322
+ pointer: "/agent/build/permission/delivery_review",
323
+ strategy: "value",
324
+ value: "deny"
325
+ },
326
+ {
327
+ pointer: "/agent/build/permission/delivery_pr",
328
+ strategy: "value",
329
+ value: "allow"
330
+ },
331
+ {
332
+ pointer: "/agent/build/permission/delivery_ready",
333
+ strategy: "value",
334
+ value: "allow"
335
+ },
336
+ {
337
+ pointer: "/agent/build/permission/delivery_merge",
338
+ strategy: "value",
339
+ value: "ask"
340
+ },
341
+ {
342
+ pointer: "/agent/build/permission/delivery_cleanup",
343
+ strategy: "value",
344
+ value: "allow"
345
+ },
346
+ {
347
+ pointer: "/agent/build/permission/task/delivery-reviewer",
348
+ strategy: "value",
349
+ value: "allow"
350
+ },
351
+ {
352
+ pointer: "/agent/build/permission/task/delivery-verifier",
353
+ strategy: "value",
354
+ value: "allow"
355
+ }
356
+ ];
400
357
  ROOT_PATH_CANDIDATES = ["opencode.json", "opencode.jsonc"];
401
358
  RootConfigParser = class {
402
359
  constructor(text) {
@@ -527,83 +484,6 @@ var init_root_config = __esm({
527
484
  }
528
485
  });
529
486
 
530
- // src/installer/lock.js
531
- var lock_exports = {};
532
- __export(lock_exports, {
533
- CURRENT_LOCK_SCHEMA: () => CURRENT_LOCK_SCHEMA,
534
- computeIntegrity: () => computeIntegrity,
535
- lockPath: () => lockPath,
536
- migrateLegacyLock: () => migrateLegacyLock,
537
- readLock: () => readLock,
538
- validateIntegrity: () => validateIntegrity,
539
- writeLock: () => writeLock
540
- });
541
- import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
542
- import { existsSync as existsSync4 } from "node:fs";
543
- import { dirname as dirname3, resolve as resolve4 } from "node:path";
544
- function lockPath(repoRoot) {
545
- return resolve4(repoRoot, ".opencode", "ship.lock.json");
546
- }
547
- async function readLock(repoRoot) {
548
- const path = lockPath(repoRoot);
549
- if (!existsSync4(path)) return null;
550
- try {
551
- const raw = await readFile4(path, "utf8");
552
- const parsed = JSON.parse(raw);
553
- return parsed;
554
- } catch {
555
- return null;
556
- }
557
- }
558
- async function writeLock(repoRoot, lock) {
559
- const path = lockPath(repoRoot);
560
- await mkdir2(dirname3(path), { recursive: true });
561
- const integrity = computeIntegrity(lock);
562
- const finalLock = { ...lock, integrity };
563
- const raw = JSON.stringify(finalLock, null, 2) + "\n";
564
- const tmp = `${path}.tmp`;
565
- await writeFile2(tmp, raw, "utf8");
566
- await rename2(tmp, path);
567
- return path;
568
- }
569
- function computeIntegrity(lock) {
570
- const { integrity: _ignored, ...without } = lock ?? {};
571
- void _ignored;
572
- return {
573
- lockSha256: bytesHashString(stableStringify(without))
574
- };
575
- }
576
- async function validateIntegrity(lock) {
577
- if (!lock?.integrity?.lockSha256) return false;
578
- const expected = computeIntegrity(lock).lockSha256;
579
- return expected === lock.integrity.lockSha256;
580
- }
581
- async function migrateLegacyLock(repoRoot) {
582
- const legacy = resolve4(repoRoot, ".opencode", "delivery.lock.json");
583
- if (!existsSync4(legacy)) return null;
584
- try {
585
- const raw = await readFile4(legacy, "utf8");
586
- const parsed = JSON.parse(raw);
587
- if (parsed.contractVersion !== 1 || typeof parsed.adapterSha256 !== "string") return null;
588
- return {
589
- kind: "legacy-lock",
590
- sourcePath: legacy,
591
- payload: { contractVersion: 1, adapterSha256: parsed.adapterSha256, writtenAt: parsed.writtenAt ?? null },
592
- sha256: bytesHashString(raw)
593
- };
594
- } catch {
595
- return null;
596
- }
597
- }
598
- var CURRENT_LOCK_SCHEMA;
599
- var init_lock = __esm({
600
- "src/installer/lock.js"() {
601
- init_hash();
602
- init_json_pointer();
603
- CURRENT_LOCK_SCHEMA = 1;
604
- }
605
- });
606
-
607
487
  // src/installer/cli-args.js
608
488
  var USAGE = `opencode-ship <command> [options]
609
489
 
@@ -618,7 +498,6 @@ Commands:
618
498
 
619
499
  Options:
620
500
  --root <path> Project root (defaults to cwd).
621
- --config <path> Override the config file location (init only).
622
501
  --force-config Rewrite the user config from detection (init only).
623
502
  --force-root-config Create opencode.json when absent (init only).
624
503
  --strict-doctor Fail init when doctor reports unhealthy checks.
@@ -629,7 +508,6 @@ Options:
629
508
  function parseFlags(argv) {
630
509
  const options = {
631
510
  rootPath: null,
632
- configPath: null,
633
511
  json: false,
634
512
  replaceManaged: false,
635
513
  purgeConfig: false,
@@ -646,7 +524,6 @@ function parseFlags(argv) {
646
524
  else if (arg === "--force-root-config") options.forceRootConfig = true;
647
525
  else if (arg === "--strict-doctor") options.strictDoctor = true;
648
526
  else if (arg === "--root") options.rootPath = argv[++i];
649
- else if (arg === "--config") options.configPath = argv[++i];
650
527
  else if (arg === "-h" || arg === "--help") return { help: true };
651
528
  else if (arg === "-v" || arg === "--version") return { version: true };
652
529
  else return { error: `unknown flag ${arg}` };
@@ -680,26 +557,160 @@ function helpText() {
680
557
  }
681
558
 
682
559
  // src/installer/executor.js
683
- init_catalog();
684
- import { existsSync as existsSync8 } from "node:fs";
560
+ import { existsSync as existsSync11 } from "node:fs";
685
561
  import { mkdir as mkdir4, readFile as readFile7, rename as rename4, unlink as unlink2, writeFile as writeFile4 } from "node:fs/promises";
686
- import { dirname as dirname5, resolve as resolve9 } from "node:path";
562
+ import { dirname as dirname6, resolve as resolve10 } from "node:path";
563
+
564
+ // src/installer/catalog.js
565
+ import { resolve as resolve3, relative, sep } from "node:path";
566
+ import { existsSync as existsSync3, statSync } from "node:fs";
567
+
568
+ // src/installer/package-root.js
569
+ import { existsSync, readFileSync } from "node:fs";
570
+ import { fileURLToPath } from "node:url";
571
+ import { dirname, resolve } from "node:path";
572
+ var PACKAGE_NAME = "opencode-ship";
573
+ function resolvePackageRoot(startUrl) {
574
+ let candidate = dirname(fileURLToPath(startUrl ?? import.meta.url));
575
+ while (candidate && candidate !== "/") {
576
+ const pkgPath = resolve(candidate, "package.json");
577
+ if (existsSync(pkgPath)) {
578
+ try {
579
+ const raw = readFileSync(pkgPath, "utf8");
580
+ const pkg = JSON.parse(raw);
581
+ if (pkg && pkg.name === PACKAGE_NAME) return candidate;
582
+ } catch {
583
+ }
584
+ }
585
+ candidate = dirname(candidate);
586
+ }
587
+ throw new Error(`opencode-ship package root not found from ${startUrl ?? import.meta.url}`);
588
+ }
589
+
590
+ // src/version.js
591
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
592
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
593
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
594
+ var PACKAGE_VERSION = "0.3.0";
595
+ var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
596
+
597
+ // src/installer/catalog.js
598
+ var TEMPLATE_SET_ID = TEMPLATE_SET;
599
+ var packageRoot = resolvePackageRoot(import.meta.url);
600
+ var CATALOG = [
601
+ {
602
+ id: "plugin:opencode-ship",
603
+ kind: "plugin",
604
+ path: ".opencode/plugins/opencode-ship.js",
605
+ source: resolve3(packageRoot, "dist/plugin.js"),
606
+ mode: 420
607
+ },
608
+ {
609
+ id: "agent:delivery-reviewer",
610
+ kind: "agent",
611
+ path: ".opencode/agents/delivery-reviewer.md",
612
+ source: resolve3(packageRoot, "assets/agents/delivery-reviewer.md"),
613
+ mode: 420
614
+ },
615
+ {
616
+ id: "agent:delivery-verifier",
617
+ kind: "agent",
618
+ path: ".opencode/agents/delivery-verifier.md",
619
+ source: resolve3(packageRoot, "assets/agents/delivery-verifier.md"),
620
+ mode: 420
621
+ },
622
+ {
623
+ id: "skill:delivery-workflow",
624
+ kind: "skill",
625
+ path: ".opencode/skills/delivery-workflow/SKILL.md",
626
+ source: resolve3(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
627
+ mode: 420
628
+ },
629
+ {
630
+ id: "skill:planning-research-checkpoint",
631
+ kind: "skill",
632
+ path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
633
+ source: resolve3(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
634
+ mode: 420
635
+ }
636
+ ];
637
+ var ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
638
+ function validateCatalog({ catalog = CATALOG } = {}) {
639
+ const seenIds = /* @__PURE__ */ new Set();
640
+ const seenPaths = /* @__PURE__ */ new Set();
641
+ const issues = [];
642
+ for (const entry of catalog) {
643
+ if (!entry || typeof entry !== "object") {
644
+ issues.push({ id: null, kind: "shape", message: "catalog entry is not an object" });
645
+ continue;
646
+ }
647
+ const { id, kind, path, source, mode } = entry;
648
+ if (typeof id !== "string" || id.length === 0) {
649
+ issues.push({ id: null, kind: "id", message: `entry id missing: ${JSON.stringify(entry)}` });
650
+ } else if (seenIds.has(id)) {
651
+ issues.push({ id, kind: "duplicate-id", message: `duplicate catalog id: ${id}` });
652
+ } else {
653
+ seenIds.add(id);
654
+ }
655
+ if (typeof path !== "string" || !path.startsWith(".opencode" + sep)) {
656
+ issues.push({ id, kind: "path", message: `path must be rooted under .opencode/: ${path}` });
657
+ }
658
+ if (seenPaths.has(path)) {
659
+ issues.push({ id, kind: "duplicate-path", message: `duplicate target path: ${path}` });
660
+ } else {
661
+ seenPaths.add(path);
662
+ }
663
+ if (!ALLOWED_KINDS.has(kind)) {
664
+ issues.push({ id, kind: "kind", message: `unsupported entry kind: ${kind}` });
665
+ }
666
+ if (typeof source !== "string" || source.length === 0) {
667
+ issues.push({ id, kind: "source", message: `source path missing: ${id}` });
668
+ } else if (!existsSync3(source)) {
669
+ issues.push({ id, kind: "source-missing", message: `source file not found: ${source}` });
670
+ } else {
671
+ try {
672
+ const stats = statSync(source);
673
+ if (!stats.isFile()) {
674
+ issues.push({ id, kind: "source-not-file", message: `source is not a regular file: ${source}` });
675
+ } else if (stats.size === 0) {
676
+ issues.push({ id, kind: "source-empty", message: `source file is empty: ${source}` });
677
+ }
678
+ } catch (e) {
679
+ issues.push({ id, kind: "source-stat", message: `unable to stat source: ${e?.message ?? e}` });
680
+ }
681
+ const rel = relative(packageRoot, source);
682
+ if (rel.startsWith("..")) {
683
+ issues.push({ id, kind: "source-out-of-package", message: `source escapes package root: ${source}` });
684
+ }
685
+ }
686
+ if (mode !== 420) {
687
+ issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
688
+ }
689
+ }
690
+ if (issues.length > 0) {
691
+ const summary = issues.map((i) => i.message).join("; ");
692
+ const err = new Error(`opencode-ship catalog validation failed: ${summary}`);
693
+ err.issues = issues;
694
+ err.catalogValidation = true;
695
+ throw err;
696
+ }
697
+ return catalog;
698
+ }
687
699
 
688
700
  // src/installer/planner.js
689
- init_catalog();
690
- init_hash();
691
- import { existsSync as existsSync3 } from "node:fs";
701
+ import { existsSync as existsSync6 } from "node:fs";
692
702
  import { readFile as readFile3, stat } from "node:fs/promises";
703
+ init_hash();
693
704
 
694
705
  // src/installer/config.js
695
706
  import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
696
- import { existsSync } from "node:fs";
697
- import { dirname as dirname2, resolve as resolve2 } from "node:path";
707
+ import { existsSync as existsSync4 } from "node:fs";
708
+ import { dirname as dirname3, resolve as resolve4 } from "node:path";
698
709
 
699
710
  // schema/ship-config.schema.json
700
711
  var ship_config_schema_default = {
701
712
  $schema: "https://json-schema.org/draft/2020-12/schema",
702
- $id: "https://github.com/Viktorxyz/opencode-delivery/schema/ship-config.schema.json",
713
+ $id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
703
714
  title: "opencode-ship user config",
704
715
  type: "object",
705
716
  required: ["schemaVersion"],
@@ -927,11 +938,11 @@ function validateSchema(value, schema) {
927
938
  init_json_pointer();
928
939
  init_hash();
929
940
  function configPath(repoRoot) {
930
- return resolve2(repoRoot, ".opencode", "ship.config.json");
941
+ return resolve4(repoRoot, ".opencode", "ship.config.json");
931
942
  }
932
943
  async function loadConfig(repoRoot) {
933
944
  const path = configPath(repoRoot);
934
- if (!existsSync(path)) return null;
945
+ if (!existsSync4(path)) return null;
935
946
  const raw = await readFile(path, "utf8");
936
947
  let parsed;
937
948
  try {
@@ -952,15 +963,6 @@ async function loadConfig(repoRoot) {
952
963
  value: parsed
953
964
  };
954
965
  }
955
- async function writeConfig(repoRoot, value) {
956
- const path = configPath(repoRoot);
957
- await mkdir(dirname2(path), { recursive: true });
958
- const raw = JSON.stringify(value, null, 2) + "\n";
959
- const tmp = `${path}.tmp`;
960
- await writeFile(tmp, raw, "utf8");
961
- await rename(tmp, path);
962
- return { path, raw, sha256: bytesHashString(raw) };
963
- }
964
966
  function renderDefaultConfig(detection, overrides = {}) {
965
967
  const pm = detection?.packageManager ?? "npm";
966
968
  const safeBootstrap = Array.isArray(detection?.worktreeBootstrap) && detection.worktreeBootstrap.length ? detection.worktreeBootstrap : [["npm", "install"]];
@@ -1002,17 +1004,16 @@ function renderDefaultConfig(detection, overrides = {}) {
1002
1004
 
1003
1005
  // src/installer/planner.js
1004
1006
  init_json_pointer();
1005
- init_catalog();
1006
1007
  init_root_config();
1007
1008
  init_root_config();
1008
1009
  async function readBytes(path) {
1009
- if (!existsSync3(path)) return null;
1010
+ if (!existsSync6(path)) return null;
1010
1011
  const buf = await readFile3(path);
1011
1012
  const fileStat = await stat(path);
1012
1013
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")), mode: fileStat.mode & 511 };
1013
1014
  }
1014
1015
  async function readDesiredBytes(source) {
1015
- if (!source || !existsSync3(source)) return null;
1016
+ if (!source || !existsSync6(source)) return null;
1016
1017
  const buf = await readFile3(source);
1017
1018
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
1018
1019
  }
@@ -1092,6 +1093,33 @@ async function planFileInstall({ repoRoot, lock, allowUnowned = false }) {
1092
1093
  }
1093
1094
  return plan;
1094
1095
  }
1096
+ async function planMigrationCleanup({ repoRoot, lock, migrationReport, allowUnowned = false }) {
1097
+ const action = migrationReport?.actions?.find((entry) => entry.kind === "candidate-remove-legacy-plugin-path");
1098
+ if (!action) return [];
1099
+ const relPath = ".opencode/plugin/opencode-ship.js";
1100
+ const target = `${repoRoot}/${relPath}`;
1101
+ const current = await readBytes(target);
1102
+ if (!current) return [];
1103
+ const locked = lookupLockedFile(lock, relPath);
1104
+ if (locked?.sha256 === current.hash || allowUnowned) {
1105
+ return [{
1106
+ kind: "delete",
1107
+ op: "file",
1108
+ target,
1109
+ relPath,
1110
+ reason: "remove the lock-owned v0.2 singular plugin path"
1111
+ }];
1112
+ }
1113
+ return [{
1114
+ kind: "conflict",
1115
+ op: "file",
1116
+ target,
1117
+ relPath,
1118
+ currentSha: current.hash,
1119
+ previousSha: locked?.sha256 ?? null,
1120
+ reason: "legacy singular plugin is unowned or locally modified"
1121
+ }];
1122
+ }
1095
1123
  async function planUninstall({ repoRoot, lock }) {
1096
1124
  if (!lock) return [];
1097
1125
  const plan = [];
@@ -1113,7 +1141,7 @@ async function planUninstall({ repoRoot, lock }) {
1113
1141
  }
1114
1142
  return plan;
1115
1143
  }
1116
- async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }) {
1144
+ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite, migrationSeed = null }) {
1117
1145
  const existing = await loadConfig(repoRoot);
1118
1146
  if (existing?.ok && !forceOverwrite) {
1119
1147
  return {
@@ -1127,10 +1155,11 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }
1127
1155
  reason: "user config already present"
1128
1156
  };
1129
1157
  }
1130
- const desiredValue = renderDefaultConfig(detection);
1158
+ const desiredValue = migrationSeed ?? renderDefaultConfig(detection);
1131
1159
  const desiredJson = JSON.stringify(desiredValue, null, 2) + "\n";
1132
1160
  const desiredSha = bytesHashString(desiredJson);
1133
1161
  const kind = existing?.ok && forceOverwrite ? "update" : "create";
1162
+ const reason = existing?.ok ? "user config overwritten via --force-config" : migrationSeed ? "synthesising a default config from legacy adapter migration" : "synthesising a default config from detection";
1134
1163
  return {
1135
1164
  kind,
1136
1165
  op: "config",
@@ -1140,22 +1169,29 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }
1140
1169
  desiredSha,
1141
1170
  bytes: Buffer.from(desiredJson, "utf8"),
1142
1171
  configValue: desiredValue,
1143
- reason: existing?.ok ? "user config overwritten via --force-config" : "synthesising a default config from detection"
1172
+ reason
1144
1173
  };
1145
1174
  }
1146
1175
  async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1147
1176
  const detected = findRootConfig(repoRoot);
1148
1177
  const target = detected.path ?? defaultRootConfigPath(repoRoot);
1149
1178
  const previous = lock?.manager?.rootDocuments?.find((d) => d.path === detected.relative);
1150
- const fileMissing = !existsSync3(target);
1179
+ const fileMissing = !existsSync6(target);
1151
1180
  if (fileMissing && !forceRepair) {
1181
+ const seededRecords = previous?.pointers && previous.pointers.length > 0 ? previous.pointers : POINTER_ENTRIES.map((entry) => ({
1182
+ pointer: entry.pointer,
1183
+ strategy: entry.strategy,
1184
+ installedSha256: bytesHashString(stableStringify(entry.value)),
1185
+ previous: { existed: false }
1186
+ }));
1152
1187
  return {
1153
1188
  kind: "noop",
1154
1189
  op: "root-config",
1155
1190
  target,
1156
1191
  relPath: detected.relative,
1157
1192
  reason: "no root opencode.json present",
1158
- edits: []
1193
+ edits: [],
1194
+ pointerRecords: seededRecords
1159
1195
  };
1160
1196
  }
1161
1197
  if (fileMissing && forceRepair) {
@@ -1163,7 +1199,12 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1163
1199
  const doc = synthesizeDefaultRootConfig2();
1164
1200
  const bytes2 = Buffer.from(formatRootConfig3(doc), "utf8");
1165
1201
  const newSha2 = bytesHashString(stableStringify(doc));
1166
- const installedPointers2 = [];
1202
+ const installedPointers2 = POINTER_ENTRIES.map((entry) => ({
1203
+ pointer: entry.pointer,
1204
+ strategy: entry.strategy,
1205
+ installedSha256: bytesHashString(stableStringify(entry.value)),
1206
+ previous: { existed: false }
1207
+ }));
1167
1208
  return {
1168
1209
  kind: "create",
1169
1210
  op: "root-config",
@@ -1172,7 +1213,7 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1172
1213
  bytes: bytes2,
1173
1214
  desiredSha: newSha2,
1174
1215
  currentSha: null,
1175
- edits: [{ kind: "create", pointer: "(file)", value: "(created)" }],
1216
+ edits: installedPointers2.map((p) => ({ kind: "create", pointer: p.pointer, value: "(synthesized)" })),
1176
1217
  pointerRecords: installedPointers2,
1177
1218
  format: "json",
1178
1219
  document: doc,
@@ -1187,11 +1228,36 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1187
1228
  target,
1188
1229
  relPath: detected.relative,
1189
1230
  reason: `root config ${docResult.error.kind}`,
1190
- edits: []
1231
+ edits: [],
1232
+ pointerRecords: previous?.pointers ?? []
1191
1233
  };
1192
1234
  }
1193
1235
  const previousPointers = previous?.pointers ?? [];
1194
1236
  const result = applyOwnedPointers(docResult.value, { allowEqualValues: true });
1237
+ if (result.applied.length === 0 && result.skipped.every((s) => s.reason === "already equal")) {
1238
+ const ownedPointers = previousPointers.length > 0 ? previousPointers : POINTER_ENTRIES.map((entry) => {
1239
+ const existing = getPointer(docResult.value, entry.pointer);
1240
+ return {
1241
+ pointer: entry.pointer,
1242
+ strategy: entry.strategy,
1243
+ installedSha256: bytesHashString(stableStringify(existing ?? entry.value)),
1244
+ previous: existing === void 0 ? { existed: false } : { existed: true, value: existing }
1245
+ };
1246
+ });
1247
+ return {
1248
+ kind: "noop",
1249
+ op: "root-config",
1250
+ target,
1251
+ relPath: detected.relative,
1252
+ reason: "no installer-owned entries missing",
1253
+ edits: [],
1254
+ pointerRecords: ownedPointers,
1255
+ format: docResult.format,
1256
+ document: docResult.value,
1257
+ currentSha: docResult.sha256 ?? null,
1258
+ desiredSha: docResult.sha256 ?? null
1259
+ };
1260
+ }
1195
1261
  const edits = result.applied.map((a) => ({
1196
1262
  kind: "create",
1197
1263
  pointer: a.pointer,
@@ -1207,8 +1273,8 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1207
1273
  let bytes;
1208
1274
  let docForWrite = result.doc;
1209
1275
  try {
1210
- const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw);
1211
- docForWrite = sourceValue;
1276
+ const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw ?? "");
1277
+ docForWrite = sourceValue && typeof sourceValue === "object" ? sourceValue : result.doc;
1212
1278
  for (const entry of result.applied) {
1213
1279
  docForWrite = setPointer2(docForWrite, entry.pointer, entry.value);
1214
1280
  }
@@ -1231,6 +1297,17 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1231
1297
  if (idx >= 0) installedPointers[idx] = next;
1232
1298
  else installedPointers.push(next);
1233
1299
  }
1300
+ for (const e of result.skipped) {
1301
+ if (e.reason !== "already equal") continue;
1302
+ const idx = installedPointers.findIndex((p) => p.pointer === e.pointer);
1303
+ if (idx >= 0) continue;
1304
+ installedPointers.push({
1305
+ pointer: e.pointer,
1306
+ strategy: "value",
1307
+ installedSha256: bytesHashString(stableStringify(e.existing ?? null)),
1308
+ previous: { existed: true, value: e.existing }
1309
+ });
1310
+ }
1234
1311
  return {
1235
1312
  kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.length ? "update" : "noop",
1236
1313
  op: "root-config",
@@ -1247,15 +1324,94 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1247
1324
  };
1248
1325
  }
1249
1326
 
1327
+ // src/installer/lock.js
1328
+ init_hash();
1329
+ init_json_pointer();
1330
+ import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
1331
+ import { existsSync as existsSync7 } from "node:fs";
1332
+ import { dirname as dirname4, resolve as resolve6 } from "node:path";
1333
+ var CURRENT_LOCK_SCHEMA = 1;
1334
+ function lockPath(repoRoot) {
1335
+ return resolve6(repoRoot, ".opencode", "ship.lock.json");
1336
+ }
1337
+ function computeIntegrity(lock) {
1338
+ const { integrity: _ignored, ...without } = lock ?? {};
1339
+ void _ignored;
1340
+ return {
1341
+ lockSha256: bytesHashString(stableStringify(without))
1342
+ };
1343
+ }
1344
+ function validateLock(rawLock) {
1345
+ if (rawLock === null || rawLock === void 0) {
1346
+ return { ok: true, kind: "missing", issues: [] };
1347
+ }
1348
+ if (typeof rawLock !== "object" || Array.isArray(rawLock)) {
1349
+ return { ok: false, kind: "shape", issues: ["lock root must be an object"] };
1350
+ }
1351
+ const issues = [];
1352
+ let kind = "ok";
1353
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA) {
1354
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
1355
+ kind = "schema";
1356
+ }
1357
+ const manager = rawLock.manager;
1358
+ if (manager === void 0) {
1359
+ issues.push("manager section missing");
1360
+ kind = kind === "ok" ? "shape" : kind;
1361
+ } else if (typeof manager !== "object" || manager === null) {
1362
+ issues.push("manager section must be an object");
1363
+ kind = kind === "ok" ? "shape" : kind;
1364
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA) {
1365
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
1366
+ kind = "schema";
1367
+ } else if (manager.name !== "opencode-ship") {
1368
+ issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
1369
+ kind = "shape";
1370
+ }
1371
+ if (!rawLock.files || !Array.isArray(rawLock.files)) {
1372
+ issues.push("files must be an array");
1373
+ kind = kind === "ok" ? "shape" : kind;
1374
+ }
1375
+ if (!rawLock.integrity || typeof rawLock.integrity !== "object") {
1376
+ issues.push("integrity section missing");
1377
+ kind = kind === "ok" ? "shape" : kind;
1378
+ } else {
1379
+ const expected = computeIntegrity(rawLock).lockSha256;
1380
+ if (expected !== rawLock.integrity.lockSha256) {
1381
+ issues.push(`integrity mismatch: stored ${rawLock.integrity.lockSha256} != computed ${expected}`);
1382
+ kind = "integrity";
1383
+ }
1384
+ }
1385
+ return { ok: issues.length === 0, kind, issues };
1386
+ }
1387
+ async function readValidatedLock(repoRoot) {
1388
+ const path = lockPath(repoRoot);
1389
+ if (!existsSync7(path)) {
1390
+ return { kind: "missing", lock: null, issues: [] };
1391
+ }
1392
+ let raw;
1393
+ try {
1394
+ const text = await readFile4(path, "utf8");
1395
+ raw = JSON.parse(text);
1396
+ } catch (e) {
1397
+ return {
1398
+ kind: "integrity",
1399
+ lock: null,
1400
+ issues: [`unable to parse lock JSON: ${e?.message ?? String(e)}`]
1401
+ };
1402
+ }
1403
+ const validation = validateLock(raw);
1404
+ return { kind: validation.kind, lock: validation.ok ? raw : null, issues: validation.issues };
1405
+ }
1406
+
1250
1407
  // src/installer/executor.js
1251
- init_lock();
1252
1408
  init_hash();
1253
1409
  init_json_pointer();
1254
1410
 
1255
1411
  // src/installer/detection/project.js
1256
1412
  import { spawnSync } from "node:child_process";
1257
- import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
1258
- import { resolve as resolve5, join } from "node:path";
1413
+ import { existsSync as existsSync8, readFileSync as readFileSync4 } from "node:fs";
1414
+ import { resolve as resolve7, join } from "node:path";
1259
1415
  function runGit(cwd, args) {
1260
1416
  const r = spawnSync("git", ["-C", cwd, ...args], {
1261
1417
  stdio: ["ignore", "pipe", "pipe"],
@@ -1264,17 +1420,17 @@ function runGit(cwd, args) {
1264
1420
  return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
1265
1421
  }
1266
1422
  function detectPackageManager(repoRoot) {
1267
- if (existsSync5(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
1268
- if (existsSync5(join(repoRoot, "yarn.lock"))) return "yarn";
1269
- if (existsSync5(join(repoRoot, "bun.lockb"))) return "bun";
1270
- if (existsSync5(join(repoRoot, "package-lock.json"))) return "npm";
1423
+ if (existsSync8(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
1424
+ if (existsSync8(join(repoRoot, "yarn.lock"))) return "yarn";
1425
+ if (existsSync8(join(repoRoot, "bun.lockb"))) return "bun";
1426
+ if (existsSync8(join(repoRoot, "package-lock.json"))) return "npm";
1271
1427
  return null;
1272
1428
  }
1273
1429
  function readPackageJson(repoRoot) {
1274
1430
  const path = join(repoRoot, "package.json");
1275
- if (!existsSync5(path)) return null;
1431
+ if (!existsSync8(path)) return null;
1276
1432
  try {
1277
- return JSON.parse(readFileSync2(path, "utf8"));
1433
+ return JSON.parse(readFileSync4(path, "utf8"));
1278
1434
  } catch {
1279
1435
  return null;
1280
1436
  }
@@ -1349,7 +1505,7 @@ function detectOwner(repoRoot) {
1349
1505
  }
1350
1506
  function detectProject(repoRoot = process.cwd()) {
1351
1507
  const errors = [];
1352
- const cwd = resolve5(repoRoot);
1508
+ const cwd = resolve7(repoRoot);
1353
1509
  const inside = runGit(cwd, ["rev-parse", "--show-toplevel"]);
1354
1510
  if (inside.status !== 0) {
1355
1511
  errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
@@ -1397,7 +1553,6 @@ function detectProject(repoRoot = process.cwd()) {
1397
1553
 
1398
1554
  // src/installer/executor.js
1399
1555
  init_root_config();
1400
- init_lock();
1401
1556
 
1402
1557
  // src/installer/transaction.js
1403
1558
  import {
@@ -1409,34 +1564,62 @@ import {
1409
1564
  stat as stat2,
1410
1565
  open
1411
1566
  } from "node:fs/promises";
1412
- import { existsSync as existsSync6 } from "node:fs";
1413
- import { dirname as dirname4, resolve as resolve6 } from "node:path";
1567
+ import { existsSync as existsSync9 } from "node:fs";
1568
+ import { dirname as dirname5, resolve as resolve8 } from "node:path";
1414
1569
  import { spawnSync as spawnSync2 } from "node:child_process";
1570
+ init_hash();
1415
1571
  function gitDir(repoRoot) {
1416
1572
  const r = spawnSync2("git", ["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-dir"], {
1417
1573
  encoding: "utf8"
1418
1574
  });
1419
1575
  if (r.status !== 0) return null;
1420
1576
  const trimmed = r.stdout.trim();
1421
- return trimmed ? resolve6(repoRoot, trimmed) : null;
1577
+ return trimmed ? resolve8(repoRoot, trimmed) : null;
1422
1578
  }
1423
1579
  function lockDir(repoRoot) {
1424
- return resolve6(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
1580
+ return resolve8(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
1425
1581
  }
1426
1582
  function transactionLockPath(repoRoot) {
1427
- return resolve6(lockDir(repoRoot), ".txn.lock");
1583
+ return resolve8(lockDir(repoRoot), ".txn.lock");
1428
1584
  }
1429
1585
  function journalPath(repoRoot, txnId) {
1430
- return resolve6(lockDir(repoRoot), `.txn-${txnId}.journal`);
1586
+ return resolve8(lockDir(repoRoot), `.txn-${txnId}.journal`);
1587
+ }
1588
+ function backupPath(target, token) {
1589
+ return `${target}.txn-${token}-backup`;
1590
+ }
1591
+ function stagedPath(target, token) {
1592
+ return `${target}.txn-${token}-staged`;
1593
+ }
1594
+ async function mkdirp(path) {
1595
+ await mkdir3(path, { recursive: true });
1431
1596
  }
1432
1597
  async function acquireLock(repoRoot, txnId) {
1433
1598
  const dir = lockDir(repoRoot);
1434
- await mkdir3(dir, { recursive: true });
1435
- await writeFile3(
1436
- transactionLockPath(repoRoot),
1437
- JSON.stringify({ pid: process.pid, txnId, startedAt: (/* @__PURE__ */ new Date()).toISOString() }),
1438
- { flag: "wx" }
1439
- );
1599
+ const path = transactionLockPath(repoRoot);
1600
+ await mkdirp(dir);
1601
+ let created = false;
1602
+ try {
1603
+ const handle = await open(path, "wx", 384);
1604
+ created = true;
1605
+ try {
1606
+ await handle.writeFile(JSON.stringify({
1607
+ pid: process.pid,
1608
+ txnId,
1609
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1610
+ }));
1611
+ await handle.sync();
1612
+ } finally {
1613
+ await handle.close();
1614
+ }
1615
+ await fsyncDir(dir);
1616
+ } catch (error) {
1617
+ if (created) {
1618
+ await unlink(path).catch(() => null);
1619
+ await fsyncDir(dir);
1620
+ }
1621
+ throw error;
1622
+ }
1440
1623
  }
1441
1624
  async function releaseLock(repoRoot) {
1442
1625
  try {
@@ -1444,6 +1627,18 @@ async function releaseLock(repoRoot) {
1444
1627
  } catch {
1445
1628
  }
1446
1629
  }
1630
+ async function liveLockOwner(repoRoot) {
1631
+ const path = transactionLockPath(repoRoot);
1632
+ if (!existsSync9(path)) return false;
1633
+ try {
1634
+ const lock = JSON.parse(await readFile5(path, "utf8"));
1635
+ if (!Number.isInteger(lock?.pid) || lock.pid <= 0) return false;
1636
+ process.kill(lock.pid, 0);
1637
+ return true;
1638
+ } catch (error) {
1639
+ return error?.code === "EPERM";
1640
+ }
1641
+ }
1447
1642
  async function fsyncDir(path) {
1448
1643
  try {
1449
1644
  const handle = await open(path, "r");
@@ -1455,21 +1650,32 @@ async function fsyncDir(path) {
1455
1650
  function randomToken() {
1456
1651
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1457
1652
  }
1458
- async function captureOriginal(target) {
1459
- if (!existsSync6(target)) return null;
1460
- try {
1461
- const bytes = await readFile5(target);
1462
- const fileStat = await stat2(target);
1463
- return { bytes, mode: fileStat.mode & 511 };
1464
- } catch {
1465
- return null;
1466
- }
1467
- }
1468
1653
  function asMode(mode) {
1469
1654
  return typeof mode === "number" ? mode : 420;
1470
1655
  }
1471
1656
  async function writeJournal(repoRoot, txnId, journal) {
1472
- await writeFile3(journalPath(repoRoot, txnId), JSON.stringify(journal, null, 2), "utf8");
1657
+ const { entries, ...header } = journal;
1658
+ const json = { ...header, ledger: entries.map((entry) => ({
1659
+ op: entry.op,
1660
+ target: entry.target,
1661
+ backup: entry.backup ?? null,
1662
+ staged: entry.staged ?? null,
1663
+ hadOriginal: entry.hadOriginal ?? null,
1664
+ commitMarker: entry.commitMarker ?? false,
1665
+ installedSha256: entry.installedSha256 ?? null,
1666
+ mode: entry.mode ?? null
1667
+ })) };
1668
+ const target = journalPath(repoRoot, txnId);
1669
+ const staged = `${target}.tmp`;
1670
+ const handle = await open(staged, "w", 384);
1671
+ try {
1672
+ await handle.writeFile(JSON.stringify(json, null, 2), "utf8");
1673
+ await handle.sync();
1674
+ } finally {
1675
+ await handle.close();
1676
+ }
1677
+ await rename3(staged, target);
1678
+ await fsyncDir(dirname5(target));
1473
1679
  }
1474
1680
  async function clearJournal(repoRoot, txnId) {
1475
1681
  if (!txnId) return;
@@ -1478,50 +1684,104 @@ async function clearJournal(repoRoot, txnId) {
1478
1684
  } catch {
1479
1685
  }
1480
1686
  }
1687
+ async function readJournal(repoRoot, name) {
1688
+ const path = resolve8(lockDir(repoRoot), name);
1689
+ try {
1690
+ const raw = await readFile5(path, "utf8");
1691
+ return JSON.parse(raw);
1692
+ } catch {
1693
+ return null;
1694
+ }
1695
+ }
1696
+ async function isCommitted(journal) {
1697
+ if (journal.committed) return true;
1698
+ const marker = journal.ledger?.find((entry) => entry.commitMarker);
1699
+ if (!marker?.target || !marker.installedSha256 || !existsSync9(marker.target)) return false;
1700
+ try {
1701
+ return bytesHashString(await readFile5(marker.target, "utf8")) === marker.installedSha256;
1702
+ } catch {
1703
+ return false;
1704
+ }
1705
+ }
1706
+ async function restoreEntry(entry) {
1707
+ if (entry.op === "write") {
1708
+ if (entry.backup && existsSync9(entry.backup)) {
1709
+ await rename3(entry.backup, entry.target);
1710
+ } else if (entry.hadOriginal === false && existsSync9(entry.target)) {
1711
+ await unlink(entry.target);
1712
+ }
1713
+ if (entry.staged && existsSync9(entry.staged)) await unlink(entry.staged);
1714
+ } else if (entry.op === "delete" && entry.backup && existsSync9(entry.backup)) {
1715
+ await rename3(entry.backup, entry.target);
1716
+ }
1717
+ await fsyncDir(dirname5(entry.target));
1718
+ }
1719
+ async function recoverJournal(repoRoot, dir, name) {
1720
+ const journal = await readJournal(repoRoot, name);
1721
+ if (!journal) {
1722
+ await unlink(resolve8(dir, name)).catch(() => null);
1723
+ return true;
1724
+ }
1725
+ const committed = await isCommitted(journal);
1726
+ const entries = committed ? journal.ledger ?? [] : [...journal.ledger ?? []].reverse();
1727
+ let complete = true;
1728
+ for (const entry of entries) {
1729
+ try {
1730
+ if (committed) await commitEntry(entry);
1731
+ else await restoreEntry(entry);
1732
+ } catch {
1733
+ complete = false;
1734
+ }
1735
+ }
1736
+ if (complete) await unlink(resolve8(dir, name)).catch(() => null);
1737
+ return complete;
1738
+ }
1481
1739
  async function recover(repoRoot) {
1482
1740
  const dir = lockDir(repoRoot);
1483
- if (!existsSync6(dir)) return { recovered: false };
1741
+ if (!existsSync9(dir)) return { recovered: false, recoveredCount: 0 };
1742
+ if (await liveLockOwner(repoRoot)) {
1743
+ return { recovered: false, recoveredCount: 0, blocked: true };
1744
+ }
1484
1745
  const { readdir } = await import("node:fs/promises");
1485
1746
  const names = await readdir(dir).catch(() => []);
1486
1747
  const journals = names.filter((n) => n.startsWith(".txn-") && n.endsWith(".journal"));
1487
- if (!journals.length) return { recovered: false };
1748
+ if (!journals.length) {
1749
+ await releaseLock(repoRoot);
1750
+ return { recovered: false, recoveredCount: 0 };
1751
+ }
1488
1752
  let totalRecovered = 0;
1753
+ let recoveryFailed = false;
1489
1754
  for (const name of journals) {
1490
- let payload;
1491
- try {
1492
- const raw = await readFile5(resolve6(dir, name), "utf8");
1493
- payload = JSON.parse(raw);
1494
- } catch {
1495
- await unlink(resolve6(dir, name)).catch(() => null);
1496
- continue;
1497
- }
1498
- const journal = payload;
1499
- for (const entry of [...journal.entries ?? []].reverse()) {
1500
- if (entry.op !== "promote") continue;
1501
- if (entry.original) {
1502
- try {
1503
- await writeFile3(entry.target, entry.original.bytes, { mode: entry.original.mode ?? 420 });
1504
- } catch {
1505
- }
1506
- } else if (entry.staged) {
1507
- try {
1508
- await rename3(entry.staged, entry.target);
1509
- } catch {
1510
- try {
1511
- await unlink(entry.staged);
1512
- } catch {
1513
- }
1514
- }
1515
- }
1516
- }
1517
- await unlink(resolve6(dir, name)).catch(() => null);
1518
- totalRecovered += 1;
1755
+ if (await recoverJournal(repoRoot, dir, name)) totalRecovered += 1;
1756
+ else recoveryFailed = true;
1519
1757
  }
1520
1758
  await releaseLock(repoRoot);
1521
- return { recovered: totalRecovered > 0, recoveredCount: totalRecovered };
1759
+ return {
1760
+ recovered: totalRecovered > 0,
1761
+ recoveredCount: totalRecovered,
1762
+ blocked: recoveryFailed,
1763
+ reason: recoveryFailed ? "recovery-failed" : null
1764
+ };
1765
+ }
1766
+ async function commitEntry(entry) {
1767
+ let changed = false;
1768
+ if (entry.backup && existsSync9(entry.backup)) {
1769
+ await unlink(entry.backup);
1770
+ changed = true;
1771
+ }
1772
+ if (entry.staged && existsSync9(entry.staged)) {
1773
+ await unlink(entry.staged);
1774
+ changed = true;
1775
+ }
1776
+ if (changed) await fsyncDir(dirname5(entry.target));
1522
1777
  }
1523
1778
  async function executePlan({ repoRoot, plan, newLockBuilder }) {
1524
1779
  const recovered = await recover(repoRoot);
1780
+ if (recovered.blocked) {
1781
+ const kind = recovered.reason ?? "lock-held";
1782
+ const message = kind === "lock-held" ? "another opencode-ship transaction is active" : "a previous opencode-ship transaction could not be recovered";
1783
+ return { ok: false, error: { kind, message } };
1784
+ }
1525
1785
  const txnId = `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
1526
1786
  try {
1527
1787
  await acquireLock(repoRoot, txnId);
@@ -1529,58 +1789,94 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1529
1789
  return { ok: false, error: { kind: "lock-held", message: e?.message ?? String(e) } };
1530
1790
  }
1531
1791
  const journal = { repoRoot, txnId, startedAt: (/* @__PURE__ */ new Date()).toISOString(), entries: [] };
1532
- await writeJournal(repoRoot, txnId, journal);
1533
1792
  try {
1793
+ await writeJournal(repoRoot, txnId, journal);
1534
1794
  for (const op of plan) {
1535
1795
  if (op.op !== "file") continue;
1536
1796
  if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
1537
- const original = await captureOriginal(op.target);
1797
+ const token = randomToken();
1798
+ const backup = backupPath(op.target, token);
1799
+ const staged = stagedPath(op.target, token);
1538
1800
  if (op.kind === "delete") {
1539
- if (!existsSync6(op.target)) continue;
1540
- const backup = `${op.target}.txn-${randomToken()}-backup`;
1541
- await rename3(op.target, backup);
1801
+ if (!existsSync9(op.target)) continue;
1542
1802
  journal.entries.push({
1543
- op: "promote",
1803
+ op: "delete",
1544
1804
  target: op.target,
1545
- staged: backup,
1546
- original,
1547
- kind: "delete"
1805
+ backup,
1806
+ staged: null,
1807
+ hadOriginal: true,
1808
+ mode: asMode(op.mode)
1548
1809
  });
1810
+ await writeJournal(repoRoot, txnId, journal);
1811
+ await rename3(op.target, backup);
1549
1812
  } else {
1550
- const tempPath = `${op.target}.txn-${randomToken()}`;
1551
- await mkdir3(dirname4(op.target), { recursive: true });
1552
- await writeFile3(tempPath, op.bytes ?? Buffer.alloc(0), { mode: asMode(op.mode) });
1553
- const h = await open(tempPath, "r+");
1554
- await h.sync();
1555
- await h.close();
1556
- await rename3(tempPath, op.target);
1813
+ await mkdirp(dirname5(op.target));
1814
+ const hadOriginal = existsSync9(op.target);
1557
1815
  journal.entries.push({
1558
- op: "promote",
1816
+ op: "write",
1559
1817
  target: op.target,
1560
- staged: tempPath,
1561
- original,
1562
- kind: op.kind
1818
+ backup: hadOriginal ? backup : null,
1819
+ staged,
1820
+ hadOriginal,
1821
+ mode: asMode(op.mode)
1563
1822
  });
1823
+ await writeJournal(repoRoot, txnId, journal);
1824
+ await writeFile3(staged, op.bytes ?? Buffer.alloc(0), { mode: asMode(op.mode) });
1825
+ const h = await open(staged, "r+");
1826
+ await h.sync();
1827
+ await h.close();
1828
+ if (hadOriginal) await rename3(op.target, backup);
1829
+ await rename3(staged, op.target);
1564
1830
  }
1565
- await fsyncDir(dirname4(op.target));
1566
- await writeJournal(repoRoot, txnId, journal);
1831
+ await fsyncDir(dirname5(op.target));
1567
1832
  }
1568
1833
  if (newLockBuilder) {
1569
1834
  const lockValue = await newLockBuilder();
1570
- const { writeLock: writer } = await Promise.resolve().then(() => (init_lock(), lock_exports));
1571
1835
  const lockPathTarget = `${repoRoot}/.opencode/ship.lock.json`;
1836
+ await mkdirp(dirname5(lockPathTarget));
1837
+ const token = randomToken();
1838
+ const backup = backupPath(lockPathTarget, token);
1839
+ const staged = stagedPath(lockPathTarget, token);
1840
+ const hadOriginal = existsSync9(lockPathTarget);
1841
+ const finalLock = { ...lockValue, integrity: computeIntegrity(lockValue) };
1842
+ const lockBytes = JSON.stringify(finalLock, null, 2) + "\n";
1572
1843
  journal.entries.push({
1573
- op: "promote",
1844
+ op: "write",
1574
1845
  target: lockPathTarget,
1575
- staged: lockPathTarget,
1576
- original: await captureOriginal(lockPathTarget),
1577
- kind: "lock"
1846
+ backup: hadOriginal ? backup : null,
1847
+ staged,
1848
+ hadOriginal,
1849
+ commitMarker: true,
1850
+ installedSha256: bytesHashString(lockBytes),
1851
+ mode: 420
1578
1852
  });
1579
- await writer(repoRoot, lockValue);
1580
1853
  await writeJournal(repoRoot, txnId, journal);
1854
+ await writeFile3(staged, lockBytes, { mode: 420 });
1855
+ const handle = await open(staged, "r+");
1856
+ await handle.sync();
1857
+ await handle.close();
1858
+ if (hadOriginal) await rename3(lockPathTarget, backup);
1859
+ await rename3(staged, lockPathTarget);
1860
+ await fsyncDir(dirname5(lockPathTarget));
1861
+ }
1862
+ journal.committed = true;
1863
+ await writeJournal(repoRoot, txnId, journal);
1864
+ let cleanupComplete = true;
1865
+ for (const entry of journal.entries) {
1866
+ try {
1867
+ await commitEntry(entry);
1868
+ } catch {
1869
+ cleanupComplete = false;
1870
+ }
1581
1871
  }
1582
- await clearJournal(repoRoot, txnId);
1583
- return { ok: true, txnId, recovered: recovered.recovered ?? false, recoveredCount: recovered.recoveredCount ?? 0 };
1872
+ if (cleanupComplete) await clearJournal(repoRoot, txnId);
1873
+ return {
1874
+ ok: true,
1875
+ txnId,
1876
+ recovered: recovered.recovered ?? false,
1877
+ recoveredCount: recovered.recoveredCount ?? 0,
1878
+ cleanupPending: !cleanupComplete
1879
+ };
1584
1880
  } catch (e) {
1585
1881
  await rollback(repoRoot, journal);
1586
1882
  return { ok: false, error: { kind: "transaction-failed", message: e?.message ?? String(e) } };
@@ -1589,75 +1885,51 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1589
1885
  }
1590
1886
  }
1591
1887
  async function rollback(repoRoot, journal) {
1592
- for (const entry of [...journal.entries ?? []].reverse()) {
1593
- if (entry.op !== "promote") continue;
1594
- if (entry.original) {
1595
- try {
1596
- await writeFile3(entry.target, entry.original.bytes, { mode: entry.original.mode ?? 420 });
1597
- } catch {
1598
- }
1599
- } else if (entry.staged) {
1600
- try {
1601
- await rename3(entry.staged, entry.target);
1602
- } catch {
1603
- try {
1604
- await unlink(entry.staged);
1605
- } catch {
1606
- }
1607
- }
1608
- } else {
1609
- try {
1610
- await unlink(entry.target);
1611
- } catch {
1612
- }
1613
- }
1614
- if (entry.staged && entry.staged !== entry.target && existsSync6(entry.staged)) {
1615
- try {
1616
- await unlink(entry.staged);
1617
- } catch {
1618
- }
1888
+ const entries = journal.entries ?? journal.ledger ?? [];
1889
+ let complete = true;
1890
+ for (const entry of [...entries].reverse()) {
1891
+ try {
1892
+ await restoreEntry(entry);
1893
+ } catch {
1894
+ complete = false;
1619
1895
  }
1620
1896
  }
1621
- if (journal.txnId) await clearJournal(repoRoot, journal.txnId);
1897
+ if (complete && journal.txnId) await clearJournal(repoRoot, journal.txnId);
1622
1898
  }
1623
1899
 
1624
1900
  // src/installer/migration.js
1625
- init_lock();
1626
1901
  import { readFile as readFile6 } from "node:fs/promises";
1627
- import { existsSync as existsSync7 } from "node:fs";
1628
- import { resolve as resolve8 } from "node:path";
1629
-
1630
- // src/installer/ship-adapter.js
1631
- import { resolve as resolve7 } from "node:path";
1632
-
1633
- // src/installer/migration.js
1902
+ import { existsSync as existsSync10 } from "node:fs";
1903
+ import { resolve as resolve9 } from "node:path";
1634
1904
  function legacyAdapterPath(repoRoot) {
1635
- return resolve8(repoRoot, ".opencode", "delivery.json");
1905
+ return resolve9(repoRoot, ".opencode", "delivery.json");
1636
1906
  }
1637
1907
  function legacyLockPath(repoRoot) {
1638
- return resolve8(repoRoot, ".opencode", "delivery.lock.json");
1908
+ return resolve9(repoRoot, ".opencode", "delivery.lock.json");
1639
1909
  }
1640
1910
  function legacyPluginPath(repoRoot) {
1641
- return resolve8(repoRoot, ".opencode", "plugin", "delivery.ts");
1911
+ return resolve9(repoRoot, ".opencode", "plugin", "delivery.ts");
1642
1912
  }
1643
1913
  async function detectLegacyShapes(repoRoot) {
1644
1914
  const out = {
1645
1915
  adapter: false,
1646
1916
  legacyLock: false,
1647
1917
  plugin: false,
1918
+ pluginOld: false,
1648
1919
  reviewer: false,
1649
1920
  verifier: false
1650
1921
  };
1651
- if (existsSync7(legacyAdapterPath(repoRoot))) out.adapter = true;
1652
- if (existsSync7(legacyLockPath(repoRoot))) out.legacyLock = true;
1653
- if (existsSync7(legacyPluginPath(repoRoot))) out.plugin = true;
1654
- if (existsSync7(resolve8(repoRoot, ".opencode/agents/delivery-reviewer.md"))) out.reviewer = true;
1655
- if (existsSync7(resolve8(repoRoot, ".opencode/agents/delivery-verifier.md"))) out.verifier = true;
1922
+ if (existsSync10(legacyAdapterPath(repoRoot))) out.adapter = true;
1923
+ if (existsSync10(legacyLockPath(repoRoot))) out.legacyLock = true;
1924
+ if (existsSync10(legacyPluginPath(repoRoot))) out.plugin = true;
1925
+ if (existsSync10(resolve9(repoRoot, ".opencode/plugin/opencode-ship.js"))) out.pluginOld = true;
1926
+ if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-reviewer.md"))) out.reviewer = true;
1927
+ if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-verifier.md"))) out.verifier = true;
1656
1928
  return out;
1657
1929
  }
1658
1930
  async function readLegacyAdapter(repoRoot) {
1659
1931
  const path = legacyAdapterPath(repoRoot);
1660
- if (!existsSync7(path)) return null;
1932
+ if (!existsSync10(path)) return null;
1661
1933
  try {
1662
1934
  const raw = await readFile6(path, "utf8");
1663
1935
  return { path, raw, value: JSON.parse(raw) };
@@ -1665,44 +1937,49 @@ async function readLegacyAdapter(repoRoot) {
1665
1937
  return null;
1666
1938
  }
1667
1939
  }
1668
- async function migration({ repoRoot, lock, forceRepair }) {
1940
+ async function migration({ repoRoot, lock, forceRepair, detection = null }) {
1669
1941
  const shapes = await detectLegacyShapes(repoRoot);
1670
1942
  const legacy = await readLegacyAdapter(repoRoot);
1671
1943
  const config = await loadConfig(repoRoot);
1672
1944
  const actions = [];
1945
+ let proposedConfigSeed = null;
1673
1946
  if (legacy && !config?.ok) {
1674
- await writeConfig(repoRoot, legacyToShipConfig(legacy.value));
1675
- actions.push({ kind: "seeded-config", from: legacy.path });
1947
+ proposedConfigSeed = legacyToShipConfig(legacy.value, detection);
1948
+ actions.push({ kind: "candidate-seed-config", from: legacy.path });
1676
1949
  }
1677
1950
  if (legacy && shapes.legacyLock && !lock?.manager) {
1678
1951
  actions.push({ kind: "kept-legacy-lock", path: legacyLockPath(repoRoot) });
1679
1952
  }
1680
- if (shapes.plugin && existsSync7(resolve8(repoRoot, ".opencode/plugin/opencode-ship.js"))) {
1953
+ if (shapes.plugin && existsSync10(resolve9(repoRoot, ".opencode/plugins/opencode-ship.js"))) {
1681
1954
  if (!forceRepair) {
1682
1955
  actions.push({ kind: "candidate-remove-legacy-plugin", path: legacyPluginPath(repoRoot) });
1683
1956
  }
1684
1957
  }
1685
- return { shapes, actions, legacyPresent: Boolean(legacy) };
1958
+ if (shapes.pluginOld) {
1959
+ actions.push({ kind: "candidate-remove-legacy-plugin-path", path: resolve9(repoRoot, ".opencode/plugin/opencode-ship.js") });
1960
+ }
1961
+ return { shapes, actions, legacyPresent: Boolean(legacy), proposedConfigSeed };
1686
1962
  }
1687
- function legacyToShipConfig(legacy) {
1688
- if (!legacy || typeof legacy !== "object") return renderDefaultConfig({});
1963
+ function legacyToShipConfig(legacy, detection = null) {
1964
+ if (!legacy || typeof legacy !== "object") return renderDefaultConfig(detection ?? {});
1965
+ const repoSlug = typeof legacy.repository?.repoSlug === "string" ? legacy.repository.repoSlug : detection?.repository ?? "owner/repo";
1689
1966
  return {
1690
1967
  schemaVersion: 1,
1691
1968
  project: {
1692
- remote: legacy.repository?.remote ?? "origin",
1693
- repository: legacy.repository?.defaultBranch?.name ?? legacy.repository?.remote ?? "owner/repo",
1694
- defaultBranch: legacy.repository?.defaultBranch?.name ?? "main",
1695
- packageManager: "npm",
1969
+ remote: legacy.repository?.remote ?? detection?.remote ?? "origin",
1970
+ repository: repoSlug,
1971
+ defaultBranch: legacy.repository?.defaultBranch?.name ?? detection?.defaultBranch ?? "main",
1972
+ packageManager: detection?.packageManager ?? "pnpm",
1696
1973
  detectOverrides: false
1697
1974
  },
1698
1975
  delivery: {
1699
1976
  worktree: {
1700
1977
  root: legacy.worktree?.root ?? ".worktrees",
1701
1978
  branchTemplate: legacy.worktree?.branchTemplate ?? "{actor}/{slug}",
1702
- bootstrap: Array.isArray(legacy.worktree?.bootstrap) ? legacy.worktree.bootstrap : [["npm", "install"]]
1979
+ bootstrap: Array.isArray(legacy.worktree?.bootstrap) && legacy.worktree.bootstrap.length ? legacy.worktree.bootstrap : [["pnpm", "install", "--frozen-lockfile"]]
1703
1980
  },
1704
1981
  verification: {
1705
- commands: legacy.verification?.commands?.length ? legacy.verification.commands : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }],
1982
+ commands: Array.isArray(legacy.verification?.commands) && legacy.verification.commands.length ? legacy.verification.commands : [{ id: "canonical", argv: ["pnpm", "verify:workspace"], timeoutMs: 18e5 }],
1706
1983
  requireCleanDiffAfter: legacy.verification?.requireCleanDiffAfter ?? true,
1707
1984
  invalidateOnHeadChange: legacy.verification?.invalidateOnHeadChange ?? true
1708
1985
  },
@@ -1715,14 +1992,14 @@ function legacyToShipConfig(legacy) {
1715
1992
  },
1716
1993
  ready: legacy.ready ?? { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
1717
1994
  merge: legacy.merge ?? { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
1718
- cleanup: legacy.cleanup ?? { when: "next-task", requireUnpublishedGuard: true }
1995
+ cleanup: legacy.cleanup && typeof legacy.cleanup === "object" && "when" in legacy.cleanup ? legacy.cleanup : { when: "next-task", requireUnpublishedGuard: true }
1719
1996
  }
1720
1997
  };
1721
1998
  }
1722
1999
 
1723
2000
  // src/installer/executor.js
1724
2001
  async function readCurrentBytes(targetPath) {
1725
- if (!existsSync8(targetPath)) return null;
2002
+ if (!existsSync11(targetPath)) return null;
1726
2003
  const buf = await readFile7(targetPath);
1727
2004
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
1728
2005
  }
@@ -1732,17 +2009,31 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
1732
2009
  return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
1733
2010
  }
1734
2011
  const repoRoot = detection.repoRoot;
1735
- const lock = await readLock(repoRoot);
1736
- const migrationReport = await migration({ repoRoot, lock, forceRepair: false });
2012
+ const validatedLock = await readValidatedLock(repoRoot);
2013
+ if (validatedLock.kind === "schema") {
2014
+ return { ok: false, error: { kind: "unsupported-lock-schema", issues: validatedLock.issues } };
2015
+ }
2016
+ if (validatedLock.kind === "integrity" || validatedLock.kind === "shape") {
2017
+ return { ok: false, error: { kind: "lock-invalid", issues: validatedLock.issues } };
2018
+ }
2019
+ const lock = validatedLock.lock;
2020
+ const migrationReport = await migration({ repoRoot, lock, forceRepair: false, detection });
1737
2021
  const configPlan = await planConfigSynthesis({
1738
2022
  repoRoot,
1739
2023
  detection,
1740
2024
  lock,
1741
- forceOverwrite: Boolean(forceConfig)
2025
+ forceOverwrite: Boolean(forceConfig),
2026
+ migrationSeed: migrationReport?.proposedConfigSeed ?? null
1742
2027
  });
1743
2028
  const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged) });
2029
+ const migrationPlan = await planMigrationCleanup({
2030
+ repoRoot,
2031
+ lock,
2032
+ migrationReport,
2033
+ allowUnowned: Boolean(replaceManaged)
2034
+ });
1744
2035
  const rootPlan = await planRootConfigApply({ repoRoot, lock, forceRepair: Boolean(forceRootConfig) });
1745
- const plan = [...filePlan ?? [], configPlan, rootPlan];
2036
+ const plan = [...filePlan ?? [], ...migrationPlan, configPlan, rootPlan];
1746
2037
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
1747
2038
  const summary = summarise(plan);
1748
2039
  return { ok: true, repoRoot, detection, lock, plan, conflicts, summary, migrationReport };
@@ -1753,9 +2044,16 @@ async function previewUninstall({ rootPath }) {
1753
2044
  return { ok: false, error: { kind: "invalid-project" } };
1754
2045
  }
1755
2046
  const repoRoot = detection.repoRoot;
1756
- const lock = await readLock(repoRoot);
2047
+ const validatedLock = await readValidatedLock(repoRoot);
2048
+ if (validatedLock.kind === "schema") {
2049
+ return { ok: false, error: { kind: "unsupported-lock-schema", issues: validatedLock.issues } };
2050
+ }
2051
+ if (validatedLock.kind === "integrity" || validatedLock.kind === "shape") {
2052
+ return { ok: false, error: { kind: "lock-invalid", issues: validatedLock.issues } };
2053
+ }
2054
+ const lock = validatedLock.lock;
1757
2055
  if (!lock) {
1758
- return { ok: true, repoRoot, lock, plan: [], conflicts: [], summary: summarise([]) };
2056
+ return { ok: true, repoRoot, lock: null, plan: [], conflicts: [], summary: summarise([]) };
1759
2057
  }
1760
2058
  const plan = await planUninstall({ repoRoot, lock });
1761
2059
  const conflicts = plan.filter((p) => p.kind === "conflict");
@@ -1798,24 +2096,26 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
1798
2096
  }
1799
2097
  const configSha = configPlan?.kind === "create" || configPlan?.kind === "update" ? configPlan.desiredSha : configPlan?.kind === "noop" ? configPlan.currentSha : null;
1800
2098
  const rootPointers = rootPlan?.pointerRecords ?? lock?.manager?.rootDocuments?.[0]?.pointers ?? [];
2099
+ const hasRootPlan = Boolean(rootPlan?.target || rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0);
2100
+ const hasRootDocuments = rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 || lock?.manager?.rootDocuments && lock.manager.rootDocuments.length > 0;
1801
2101
  return {
1802
2102
  contractVersion: 1,
1803
2103
  manager: {
1804
2104
  schemaVersion: 1,
1805
2105
  name: "opencode-ship",
1806
- version: "0.2.1",
1807
- templateSet: getTemplateSet(),
2106
+ version: "0.3.0",
2107
+ templateSet: TEMPLATE_SET_ID,
1808
2108
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
1809
2109
  config: {
1810
2110
  path: ".opencode/ship.config.json",
1811
2111
  sha256: configSha ?? lock?.manager?.config?.sha256 ?? "",
1812
2112
  existed: Boolean(lock?.manager?.config?.existed)
1813
2113
  },
1814
- rootDocuments: rootPlan?.kind && rootPlan.kind !== "noop" && rootPlan?.target ? [{
1815
- path: rootPlan.relPath,
1816
- format: rootPlan.format ?? "json",
1817
- pointers: rootPointers
1818
- }] : lock?.manager?.rootDocuments ?? []
2114
+ rootDocuments: hasRootDocuments && (hasRootPlan || (lock?.manager?.rootDocuments?.length ?? 0) > 0) ? [{
2115
+ path: rootPlan?.relPath ?? lock?.manager?.rootDocuments?.[0]?.path ?? "opencode.json",
2116
+ format: rootPlan?.format ?? lock?.manager?.rootDocuments?.[0]?.format ?? "json",
2117
+ pointers: rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 ? rootPlan.pointerRecords : lock?.manager?.rootDocuments?.[0]?.pointers ?? []
2118
+ }] : []
1819
2119
  },
1820
2120
  files,
1821
2121
  cleanupPending: lock?.cleanupPending ?? []
@@ -1908,7 +2208,16 @@ async function commitInstall(preview, { json, command }) {
1908
2208
  async function stageFiles(filePlan, repoRoot) {
1909
2209
  const out = [];
1910
2210
  for (const op of filePlan) {
1911
- if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge" || op.kind === "delete") continue;
2211
+ if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
2212
+ if (op.kind === "delete") {
2213
+ out.push({
2214
+ op: "file",
2215
+ kind: "delete",
2216
+ target: op.target,
2217
+ relPath: op.relPath
2218
+ });
2219
+ continue;
2220
+ }
1912
2221
  out.push({
1913
2222
  op: "file",
1914
2223
  kind: op.kind,
@@ -1937,12 +2246,11 @@ function relativeTemplate(source) {
1937
2246
  }
1938
2247
 
1939
2248
  // src/installer/commands/doctor.js
1940
- init_lock();
1941
- import { existsSync as existsSync9, readFileSync as readFileSync3 } from "node:fs";
2249
+ import { existsSync as existsSync12, readFileSync as readFileSync5 } from "node:fs";
1942
2250
  import { spawnSync as spawnSync3 } from "node:child_process";
1943
2251
  init_hash();
2252
+ import { resolve as resolve11 } from "node:path";
1944
2253
  init_root_config();
1945
- import { resolve as resolve10 } from "node:path";
1946
2254
 
1947
2255
  // src/installer/report.js
1948
2256
  var REPORT_VERSION = 1;
@@ -2015,14 +2323,77 @@ function checkGhAuth() {
2015
2323
  detail: r.status === 0 ? "authenticated (token)" : (r.stderr || r.stdout || "").trim() || "no session"
2016
2324
  };
2017
2325
  }
2326
+ function checkPackageIntegrity() {
2327
+ try {
2328
+ validateCatalog();
2329
+ return { name: "package integrity", ok: true, detail: `${CATALOG.length} catalog entries` };
2330
+ } catch (e) {
2331
+ return {
2332
+ name: "package integrity",
2333
+ ok: false,
2334
+ detail: `${e?.message ?? e}: ${(e?.issues ?? []).map((i) => i.message).join("; ")}`
2335
+ };
2336
+ }
2337
+ }
2338
+ function buildSourceHashIndex() {
2339
+ const idx = /* @__PURE__ */ new Map();
2340
+ for (const entry of CATALOG) {
2341
+ if (!existsSync12(entry.source)) continue;
2342
+ try {
2343
+ const buf = readFileSync5(entry.source, "utf8");
2344
+ idx.set(entry.source, bytesHashString(buf));
2345
+ } catch {
2346
+ }
2347
+ }
2348
+ return idx;
2349
+ }
2350
+ function checkCatalogInstall(repoRoot, sourceHashes) {
2351
+ const rows = [];
2352
+ for (const entry of CATALOG) {
2353
+ const target = resolve11(repoRoot, entry.path);
2354
+ if (!existsSync12(target)) {
2355
+ rows.push(`${entry.id}: missing`);
2356
+ continue;
2357
+ }
2358
+ try {
2359
+ const buf = readFileSync5(target, "utf8");
2360
+ const actual = bytesHashString(buf);
2361
+ const expected = sourceHashes.get(entry.source);
2362
+ if (expected && expected !== actual) {
2363
+ rows.push(`${entry.id}: drift`);
2364
+ } else {
2365
+ rows.push(`${entry.id}: ok`);
2366
+ }
2367
+ } catch (e) {
2368
+ rows.push(`${entry.id}: ${e?.message ?? e}`);
2369
+ }
2370
+ }
2371
+ const allOk = rows.length === 0 || rows.every((r) => r.endsWith("ok"));
2372
+ return {
2373
+ name: "catalog assets present",
2374
+ ok: allOk,
2375
+ detail: rows.join(",")
2376
+ };
2377
+ }
2018
2378
  async function checkLock(repoRoot) {
2019
- const lock = await readLock(repoRoot);
2020
- if (!lock) return { name: "lock present", ok: false, detail: "missing" };
2021
- const integrityOk = await validateIntegrity(lock);
2379
+ const result = await readValidatedLock(repoRoot);
2380
+ if (result.kind === "missing") {
2381
+ return { name: "lock present", ok: false, detail: "no lock" };
2382
+ }
2383
+ if (result.kind === "schema") {
2384
+ return { name: "lock present", ok: false, detail: `unsupported schema: ${result.issues.join("; ")}` };
2385
+ }
2386
+ if (result.kind === "integrity") {
2387
+ return { name: "lock present", ok: false, detail: `integrity: ${result.issues.join("; ")}` };
2388
+ }
2389
+ if (result.kind === "shape") {
2390
+ return { name: "lock present", ok: false, detail: `malformed: ${result.issues.join("; ")}` };
2391
+ }
2392
+ const lock = result.lock;
2022
2393
  return {
2023
2394
  name: "lock present",
2024
- ok: integrityOk,
2025
- detail: integrityOk ? `v0.2 manager@${lock.manager?.version ?? "?"}` : "integrity mismatch"
2395
+ ok: true,
2396
+ detail: `manager@${lock.manager?.version ?? "?"} schema=${lock.manager?.schemaVersion ?? "?"}`
2026
2397
  };
2027
2398
  }
2028
2399
  async function checkConfig(repoRoot) {
@@ -2033,45 +2404,18 @@ async function checkConfig(repoRoot) {
2033
2404
  detail: r?.ok ? "loaded" : r?.error?.kind ?? "missing"
2034
2405
  };
2035
2406
  }
2036
- function checkPlugin(repoRoot) {
2037
- const path = resolve10(repoRoot, ".opencode/plugin/opencode-ship.js");
2038
- if (!existsSync9(path)) return { name: "plugin", ok: false, detail: "missing" };
2039
- try {
2040
- const buf = readFileSync3(path, "utf8");
2041
- return { name: "plugin", ok: buf.includes("opencode-ship"), detail: "loaded" };
2042
- } catch (e) {
2043
- return { name: "plugin", ok: false, detail: e.message };
2407
+ async function checkManagedHashes(repoRoot, validatedLock) {
2408
+ if (validatedLock.kind !== "ok" || !validatedLock.lock) {
2409
+ return { name: "managed hashes", ok: false, detail: "no usable lock" };
2044
2410
  }
2045
- }
2046
- function checkAgents(repoRoot) {
2047
- const reviewer = resolve10(repoRoot, ".opencode/agents/delivery-reviewer.md");
2048
- const verifier = resolve10(repoRoot, ".opencode/agents/delivery-verifier.md");
2049
- return {
2050
- name: "canonical agents",
2051
- ok: existsSync9(reviewer) && existsSync9(verifier),
2052
- detail: existsSync9(reviewer) && existsSync9(verifier) ? "loaded" : "missing"
2053
- };
2054
- }
2055
- function checkSkills(repoRoot) {
2056
- const a = resolve10(repoRoot, ".opencode/skills/delivery-workflow/SKILL.md");
2057
- const b = resolve10(repoRoot, ".opencode/skills/planning-research-checkpoint/SKILL.md");
2058
- return {
2059
- name: "canonical skills",
2060
- ok: existsSync9(a) && existsSync9(b),
2061
- detail: existsSync9(a) && existsSync9(b) ? "loaded" : "missing"
2062
- };
2063
- }
2064
- async function checkManagedHashes(repoRoot) {
2065
- const lock = await readLock(repoRoot);
2066
- if (!lock) return { name: "managed hashes", ok: false, detail: "no lock" };
2067
2411
  const drift = [];
2068
- for (const entry of lock.files ?? []) {
2069
- const p = resolve10(repoRoot, entry.path);
2070
- if (!existsSync9(p)) {
2412
+ for (const entry of validatedLock.lock.files ?? []) {
2413
+ const p = resolve11(repoRoot, entry.path);
2414
+ if (!existsSync12(p)) {
2071
2415
  drift.push(`missing:${entry.path}`);
2072
2416
  continue;
2073
2417
  }
2074
- const buf = readFileSync3(p, "utf8");
2418
+ const buf = readFileSync5(p, "utf8");
2075
2419
  const actual = bytesHashString(buf);
2076
2420
  if (actual !== entry.sha256) drift.push(`drift:${entry.path}`);
2077
2421
  }
@@ -2117,17 +2461,19 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2117
2461
  return { issues: issues2, exitCode: 2, plan: plan2, checks: checks2 };
2118
2462
  }
2119
2463
  const repoRoot = detection.repoRoot;
2464
+ const sourceHashes = buildSourceHashIndex();
2465
+ const validatedLock = await readValidatedLock(repoRoot);
2466
+ const packageIntegrity = checkPackageIntegrity();
2120
2467
  const checks = [
2121
2468
  checkNode(),
2122
2469
  checkGit(),
2123
2470
  checkGh(),
2124
2471
  checkGhAuth(),
2472
+ packageIntegrity,
2473
+ checkCatalogInstall(repoRoot, sourceHashes),
2125
2474
  await checkLock(repoRoot),
2126
2475
  await checkConfig(repoRoot),
2127
- checkPlugin(repoRoot),
2128
- checkAgents(repoRoot),
2129
- checkSkills(repoRoot),
2130
- await checkManagedHashes(repoRoot),
2476
+ await checkManagedHashes(repoRoot, validatedLock),
2131
2477
  await checkRootConfig(repoRoot)
2132
2478
  ];
2133
2479
  const issues = checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
@@ -2139,7 +2485,10 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2139
2485
  reason: c.detail
2140
2486
  }));
2141
2487
  const summary = summarise2(plan);
2142
- const exitCode = issues.length === 0 ? 0 : 1;
2488
+ let exitCode = 1;
2489
+ if (issues.length === 0) exitCode = 0;
2490
+ if (!packageIntegrity.ok) exitCode = 4;
2491
+ if (validatedLock.kind === "schema") exitCode = 5;
2143
2492
  if (writeOutput) writeEnvelope({ command: "doctor", plan, summary, diagnostics: issues, json, exitCode });
2144
2493
  process.exitCode = exitCode;
2145
2494
  return { issues, exitCode, plan, checks };
@@ -2147,6 +2496,14 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2147
2496
 
2148
2497
  // src/installer/commands/init.js
2149
2498
  async function runInit(options) {
2499
+ try {
2500
+ validateCatalog();
2501
+ } catch (e) {
2502
+ if (e?.catalogValidation) {
2503
+ return emitFailure(4, `catalog validation failed: ${e.message}`, options.json, "init");
2504
+ }
2505
+ throw e;
2506
+ }
2150
2507
  const preview = await previewInstall({
2151
2508
  rootPath: options.rootPath ?? null,
2152
2509
  replaceManaged: false,
@@ -2154,6 +2511,12 @@ async function runInit(options) {
2154
2511
  forceRootConfig: Boolean(options.forceRootConfig)
2155
2512
  });
2156
2513
  if (!preview.ok) {
2514
+ if (preview.error?.kind === "unsupported-lock-schema") {
2515
+ return emitFailure(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2516
+ }
2517
+ if (preview.error?.kind === "lock-invalid") {
2518
+ return emitFailure(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2519
+ }
2157
2520
  return emitFailure(2, preview.error?.kind ?? "invalid-project", options.json, "init");
2158
2521
  }
2159
2522
  const committed = await commitInstall(preview, { json: options.json, command: "init" });
@@ -2245,6 +2608,30 @@ function serializePlan2(plan) {
2245
2608
  });
2246
2609
  }
2247
2610
  async function runDiff(options) {
2611
+ try {
2612
+ validateCatalog();
2613
+ } catch (e) {
2614
+ if (e?.catalogValidation) {
2615
+ if (options.json) {
2616
+ process.stdout.write(JSON.stringify({
2617
+ reportVersion: 1,
2618
+ command: "diff",
2619
+ status: "error",
2620
+ plan: [],
2621
+ conflicts: [],
2622
+ summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
2623
+ diagnostics: [`catalog validation failed: ${e.message}`],
2624
+ exitCode: 4
2625
+ }, null, 2) + "\n");
2626
+ } else {
2627
+ process.stdout.write(`opencode-ship: catalog validation failed: ${e.message}
2628
+ `);
2629
+ }
2630
+ process.exitCode = 4;
2631
+ return { ok: false, exitCode: 4 };
2632
+ }
2633
+ throw e;
2634
+ }
2248
2635
  const preview = await previewInstall({
2249
2636
  rootPath: options.rootPath ?? null,
2250
2637
  replaceManaged: false,
@@ -2252,6 +2639,8 @@ async function runDiff(options) {
2252
2639
  forceRootConfig: false
2253
2640
  });
2254
2641
  if (!preview.ok) {
2642
+ const exitCode = preview.error?.kind === "unsupported-lock-schema" ? 5 : preview.error?.kind === "lock-invalid" ? 3 : 2;
2643
+ const message = preview.error?.kind === "lock-invalid" ? `lock invalid: ${(preview.error.issues ?? []).join("; ")}` : preview.error?.kind === "unsupported-lock-schema" ? `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}` : preview.error?.kind ?? "invalid-project";
2255
2644
  if (options.json) {
2256
2645
  process.stdout.write(JSON.stringify({
2257
2646
  reportVersion: 1,
@@ -2260,15 +2649,15 @@ async function runDiff(options) {
2260
2649
  plan: [],
2261
2650
  conflicts: [],
2262
2651
  summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
2263
- diagnostics: [preview.error?.kind ?? "invalid-project"],
2264
- exitCode: 2
2652
+ diagnostics: [message],
2653
+ exitCode
2265
2654
  }, null, 2) + "\n");
2266
2655
  } else {
2267
- process.stdout.write(`opencode-ship: ${preview.error?.kind ?? "invalid-project"}
2656
+ process.stdout.write(`opencode-ship: ${message}
2268
2657
  `);
2269
2658
  }
2270
- process.exitCode = 2;
2271
- return { ok: false, exitCode: 2 };
2659
+ process.exitCode = exitCode;
2660
+ return { ok: false, exitCode };
2272
2661
  }
2273
2662
  const { plan, conflicts, migrationReport } = preview;
2274
2663
  const summary = summarise3(plan);
@@ -2305,6 +2694,14 @@ async function runDiff(options) {
2305
2694
 
2306
2695
  // src/installer/commands/update.js
2307
2696
  async function runUpdate(options) {
2697
+ try {
2698
+ validateCatalog();
2699
+ } catch (e) {
2700
+ if (e?.catalogValidation) {
2701
+ return emitFailure2(4, `catalog validation failed: ${e.message}`, options.json, "update");
2702
+ }
2703
+ throw e;
2704
+ }
2308
2705
  const preview = await previewInstall({
2309
2706
  rootPath: options.rootPath,
2310
2707
  replaceManaged: options.replaceManaged,
@@ -2312,6 +2709,12 @@ async function runUpdate(options) {
2312
2709
  forceRootConfig: options.forceRootConfig
2313
2710
  });
2314
2711
  if (!preview.ok) {
2712
+ if (preview.error?.kind === "unsupported-lock-schema") {
2713
+ return emitFailure2(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json, "update");
2714
+ }
2715
+ if (preview.error?.kind === "lock-invalid") {
2716
+ return emitFailure2(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json, "update");
2717
+ }
2315
2718
  return emitFailure2(2, preview.error?.kind ?? "invalid-project", options.json, "update");
2316
2719
  }
2317
2720
  if (preview.conflicts.length > 0 && !options.replaceManaged) {
@@ -2359,11 +2762,17 @@ function emitFailure2(code, message, json, command) {
2359
2762
 
2360
2763
  // src/installer/commands/uninstall.js
2361
2764
  import { unlink as unlink3 } from "node:fs/promises";
2362
- import { existsSync as existsSync10 } from "node:fs";
2363
- import { resolve as resolve11 } from "node:path";
2765
+ import { existsSync as existsSync13 } from "node:fs";
2766
+ import { resolve as resolve12 } from "node:path";
2364
2767
  async function runUninstall(options) {
2365
2768
  const preview = await previewUninstall({ rootPath: options.rootPath });
2366
2769
  if (!preview.ok) {
2770
+ if (preview.error?.kind === "unsupported-lock-schema") {
2771
+ return emitFailure3(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json);
2772
+ }
2773
+ if (preview.error?.kind === "lock-invalid") {
2774
+ return emitFailure3(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json);
2775
+ }
2367
2776
  return emitFailure3(2, preview.error?.kind ?? "invalid-project", options.json);
2368
2777
  }
2369
2778
  const { repoRoot, plan, conflicts, summary } = preview;
@@ -2374,8 +2783,8 @@ async function runUninstall(options) {
2374
2783
  if (!tx.ok) {
2375
2784
  return emitFailure3(4, tx.error?.message ?? "transaction failure", options.json);
2376
2785
  }
2377
- const lockPath2 = resolve11(repoRoot, ".opencode", "ship.lock.json");
2378
- if (existsSync10(lockPath2)) await unlink3(lockPath2).catch(() => null);
2786
+ const lockPath2 = resolve12(repoRoot, ".opencode", "ship.lock.json");
2787
+ if (existsSync13(lockPath2)) await unlink3(lockPath2).catch(() => null);
2379
2788
  if (options.purgeConfig) {
2380
2789
  await unlink3(configPath(repoRoot)).catch(() => null);
2381
2790
  }
@@ -2432,7 +2841,7 @@ function emitReport(plan, conflicts, summary, json, exitCode, diagnostics) {
2432
2841
  }
2433
2842
 
2434
2843
  // src/cli.js
2435
- var VERSION = "0.2.1";
2844
+ var VERSION = PACKAGE_VERSION;
2436
2845
  async function main() {
2437
2846
  const parsed = parseCommand(process.argv.slice(2));
2438
2847
  if (parsed.command === "help") {