opencode-ship 0.2.1 → 0.4.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.4.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,82 +484,45 @@ 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;
487
+ // src/profile.js
488
+ var PROFILES = Object.freeze(["core", "engineering"]);
489
+ var DEFAULT_PROFILE = "core";
490
+ function isValidProfile(name) {
491
+ return typeof name === "string" && PROFILES.includes(name);
492
+ }
493
+ function normalizeProfile(name) {
494
+ if (name === void 0 || name === null) return DEFAULT_PROFILE;
495
+ if (!isValidProfile(name)) return null;
496
+ return name;
497
+ }
498
+ function resolveProfile({ cli = null, config = null, lock = null } = {}) {
499
+ if (cli !== null && cli !== void 0) {
500
+ const v = normalizeProfile(cli);
501
+ if (v === null) {
502
+ throw new Error(`unknown CLI profile '${cli}' (expected one of: ${PROFILES.join(", ")})`);
503
+ }
504
+ return { profile: v, source: "cli" };
505
+ }
506
+ if (config && typeof config === "object" && config.profile !== void 0 && config.profile !== null) {
507
+ const v = normalizeProfile(config.profile);
508
+ if (v === null) {
509
+ throw new Error(
510
+ `unknown ship.config.json profile '${config.profile}' (expected one of: ${PROFILES.join(", ")})`
511
+ );
512
+ }
513
+ return { profile: v, source: "config" };
514
+ }
515
+ if (lock && typeof lock === "object" && lock.manager && lock.manager.profile !== void 0) {
516
+ const v = normalizeProfile(lock.manager.profile);
517
+ if (v === null) {
518
+ throw new Error(
519
+ `unknown lock manager.profile '${lock.manager.profile}' (expected one of: ${PROFILES.join(", ")})`
520
+ );
521
+ }
522
+ return { profile: v, source: "lock" };
596
523
  }
524
+ return { profile: DEFAULT_PROFILE, source: "default" };
597
525
  }
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
526
 
607
527
  // src/installer/cli-args.js
608
528
  var USAGE = `opencode-ship <command> [options]
@@ -618,7 +538,7 @@ Commands:
618
538
 
619
539
  Options:
620
540
  --root <path> Project root (defaults to cwd).
621
- --config <path> Override the config file location (init only).
541
+ --profile <name> Override active profile: ${PROFILES.join(", ")}.
622
542
  --force-config Rewrite the user config from detection (init only).
623
543
  --force-root-config Create opencode.json when absent (init only).
624
544
  --strict-doctor Fail init when doctor reports unhealthy checks.
@@ -629,7 +549,7 @@ Options:
629
549
  function parseFlags(argv) {
630
550
  const options = {
631
551
  rootPath: null,
632
- configPath: null,
552
+ profile: null,
633
553
  json: false,
634
554
  replaceManaged: false,
635
555
  purgeConfig: false,
@@ -646,8 +566,16 @@ function parseFlags(argv) {
646
566
  else if (arg === "--force-root-config") options.forceRootConfig = true;
647
567
  else if (arg === "--strict-doctor") options.strictDoctor = true;
648
568
  else if (arg === "--root") options.rootPath = argv[++i];
649
- else if (arg === "--config") options.configPath = argv[++i];
650
- else if (arg === "-h" || arg === "--help") return { help: true };
569
+ else if (arg === "--profile") {
570
+ const value = argv[++i];
571
+ if (value === void 0) {
572
+ return { error: "--profile requires a value" };
573
+ }
574
+ if (!isValidProfile(value)) {
575
+ return { error: `unknown profile '${value}' (expected one of: ${PROFILES.join(", ")})` };
576
+ }
577
+ options.profile = value;
578
+ } else if (arg === "-h" || arg === "--help") return { help: true };
651
579
  else if (arg === "-v" || arg === "--version") return { version: true };
652
580
  else return { error: `unknown flag ${arg}` };
653
581
  }
@@ -680,32 +608,194 @@ function helpText() {
680
608
  }
681
609
 
682
610
  // src/installer/executor.js
683
- init_catalog();
684
- import { existsSync as existsSync8 } from "node:fs";
611
+ import { existsSync as existsSync11 } from "node:fs";
685
612
  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";
613
+ import { dirname as dirname6, resolve as resolve10 } from "node:path";
614
+
615
+ // src/installer/catalog.js
616
+ import { resolve as resolve3, relative, sep } from "node:path";
617
+ import { existsSync as existsSync3, statSync } from "node:fs";
618
+
619
+ // src/installer/package-root.js
620
+ import { existsSync, readFileSync } from "node:fs";
621
+ import { fileURLToPath } from "node:url";
622
+ import { dirname, resolve } from "node:path";
623
+ var PACKAGE_NAME = "opencode-ship";
624
+ function resolvePackageRoot(startUrl) {
625
+ let candidate = dirname(fileURLToPath(startUrl ?? import.meta.url));
626
+ while (candidate && candidate !== "/") {
627
+ const pkgPath = resolve(candidate, "package.json");
628
+ if (existsSync(pkgPath)) {
629
+ try {
630
+ const raw = readFileSync(pkgPath, "utf8");
631
+ const pkg = JSON.parse(raw);
632
+ if (pkg && pkg.name === PACKAGE_NAME) return candidate;
633
+ } catch {
634
+ }
635
+ }
636
+ candidate = dirname(candidate);
637
+ }
638
+ throw new Error(`opencode-ship package root not found from ${startUrl ?? import.meta.url}`);
639
+ }
640
+
641
+ // src/version.js
642
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
643
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
644
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
645
+ var PACKAGE_VERSION = "0.4.0";
646
+ var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
647
+
648
+ // src/installer/catalog.js
649
+ var TEMPLATE_SET_ID = TEMPLATE_SET;
650
+ var packageRoot = resolvePackageRoot(import.meta.url);
651
+ var CATALOG = [
652
+ {
653
+ id: "plugin:opencode-ship",
654
+ kind: "plugin",
655
+ path: ".opencode/plugins/opencode-ship.js",
656
+ source: resolve3(packageRoot, "dist/plugin.js"),
657
+ mode: 420,
658
+ profiles: ["core", "engineering"]
659
+ },
660
+ {
661
+ id: "agent:delivery-reviewer",
662
+ kind: "agent",
663
+ path: ".opencode/agents/delivery-reviewer.md",
664
+ source: resolve3(packageRoot, "assets/agents/delivery-reviewer.md"),
665
+ mode: 420,
666
+ profiles: ["core", "engineering"]
667
+ },
668
+ {
669
+ id: "agent:delivery-verifier",
670
+ kind: "agent",
671
+ path: ".opencode/agents/delivery-verifier.md",
672
+ source: resolve3(packageRoot, "assets/agents/delivery-verifier.md"),
673
+ mode: 420,
674
+ profiles: ["core", "engineering"]
675
+ },
676
+ {
677
+ id: "skill:delivery-workflow",
678
+ kind: "skill",
679
+ path: ".opencode/skills/delivery-workflow/SKILL.md",
680
+ source: resolve3(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
681
+ mode: 420,
682
+ profiles: ["core", "engineering"]
683
+ },
684
+ {
685
+ id: "skill:planning-research-checkpoint",
686
+ kind: "skill",
687
+ path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
688
+ source: resolve3(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
689
+ mode: 420,
690
+ profiles: ["core", "engineering"]
691
+ }
692
+ ];
693
+ function filterCatalogByProfile(catalog, profile) {
694
+ const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : profile;
695
+ if (!isValidProfile(effective)) {
696
+ throw new Error(
697
+ `filterCatalogByProfile: unknown profile '${profile}' (expected one of: ${PROFILES.join(", ")})`
698
+ );
699
+ }
700
+ return catalog.filter((entry) => Array.isArray(entry.profiles) && entry.profiles.includes(effective));
701
+ }
702
+ var ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
703
+ function validateCatalog({ catalog = CATALOG } = {}) {
704
+ const seenIds = /* @__PURE__ */ new Set();
705
+ const seenPaths = /* @__PURE__ */ new Set();
706
+ const issues = [];
707
+ for (const entry of catalog) {
708
+ if (!entry || typeof entry !== "object") {
709
+ issues.push({ id: null, kind: "shape", message: "catalog entry is not an object" });
710
+ continue;
711
+ }
712
+ const { id, kind, path, source, mode } = entry;
713
+ if (typeof id !== "string" || id.length === 0) {
714
+ issues.push({ id: null, kind: "id", message: `entry id missing: ${JSON.stringify(entry)}` });
715
+ } else if (seenIds.has(id)) {
716
+ issues.push({ id, kind: "duplicate-id", message: `duplicate catalog id: ${id}` });
717
+ } else {
718
+ seenIds.add(id);
719
+ }
720
+ if (typeof path !== "string" || !path.startsWith(".opencode" + sep)) {
721
+ issues.push({ id, kind: "path", message: `path must be rooted under .opencode/: ${path}` });
722
+ }
723
+ if (seenPaths.has(path)) {
724
+ issues.push({ id, kind: "duplicate-path", message: `duplicate target path: ${path}` });
725
+ } else {
726
+ seenPaths.add(path);
727
+ }
728
+ if (!ALLOWED_KINDS.has(kind)) {
729
+ issues.push({ id, kind: "kind", message: `unsupported entry kind: ${kind}` });
730
+ }
731
+ if (typeof source !== "string" || source.length === 0) {
732
+ issues.push({ id, kind: "source", message: `source path missing: ${id}` });
733
+ } else if (!existsSync3(source)) {
734
+ issues.push({ id, kind: "source-missing", message: `source file not found: ${source}` });
735
+ } else {
736
+ try {
737
+ const stats = statSync(source);
738
+ if (!stats.isFile()) {
739
+ issues.push({ id, kind: "source-not-file", message: `source is not a regular file: ${source}` });
740
+ } else if (stats.size === 0) {
741
+ issues.push({ id, kind: "source-empty", message: `source file is empty: ${source}` });
742
+ }
743
+ } catch (e) {
744
+ issues.push({ id, kind: "source-stat", message: `unable to stat source: ${e?.message ?? e}` });
745
+ }
746
+ const rel = relative(packageRoot, source);
747
+ if (rel.startsWith("..")) {
748
+ issues.push({ id, kind: "source-out-of-package", message: `source escapes package root: ${source}` });
749
+ }
750
+ }
751
+ if (mode !== 420) {
752
+ issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
753
+ }
754
+ if (!Array.isArray(entry.profiles) || entry.profiles.length === 0) {
755
+ issues.push({ id, kind: "profiles", message: `profiles must be a non-empty array: ${id}` });
756
+ } else {
757
+ for (const p of entry.profiles) {
758
+ if (!isValidProfile(p)) {
759
+ issues.push({ id, kind: "profiles", message: `unknown profile in profiles[${entry.profiles.indexOf(p)}]: ${p} (expected one of: ${PROFILES.join(", ")})` });
760
+ }
761
+ }
762
+ }
763
+ }
764
+ if (issues.length > 0) {
765
+ const summary = issues.map((i) => i.message).join("; ");
766
+ const err = new Error(`opencode-ship catalog validation failed: ${summary}`);
767
+ err.issues = issues;
768
+ err.catalogValidation = true;
769
+ throw err;
770
+ }
771
+ return catalog;
772
+ }
687
773
 
688
774
  // src/installer/planner.js
689
- init_catalog();
690
- init_hash();
691
- import { existsSync as existsSync3 } from "node:fs";
775
+ import { existsSync as existsSync6 } from "node:fs";
692
776
  import { readFile as readFile3, stat } from "node:fs/promises";
777
+ init_hash();
693
778
 
694
779
  // src/installer/config.js
695
780
  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";
781
+ import { existsSync as existsSync4 } from "node:fs";
782
+ import { dirname as dirname3, resolve as resolve4 } from "node:path";
698
783
 
699
784
  // schema/ship-config.schema.json
700
785
  var ship_config_schema_default = {
701
786
  $schema: "https://json-schema.org/draft/2020-12/schema",
702
- $id: "https://github.com/Viktorxyz/opencode-delivery/schema/ship-config.schema.json",
787
+ $id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
703
788
  title: "opencode-ship user config",
704
789
  type: "object",
705
790
  required: ["schemaVersion"],
706
791
  additionalProperties: false,
707
792
  properties: {
708
793
  schemaVersion: { const: 1 },
794
+ profile: {
795
+ type: "string",
796
+ enum: ["core", "engineering"],
797
+ description: "Active profile (precedence layer 2: ship.config > lock > core default)."
798
+ },
709
799
  owner: {
710
800
  type: "string",
711
801
  description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
@@ -927,11 +1017,11 @@ function validateSchema(value, schema) {
927
1017
  init_json_pointer();
928
1018
  init_hash();
929
1019
  function configPath(repoRoot) {
930
- return resolve2(repoRoot, ".opencode", "ship.config.json");
1020
+ return resolve4(repoRoot, ".opencode", "ship.config.json");
931
1021
  }
932
1022
  async function loadConfig(repoRoot) {
933
1023
  const path = configPath(repoRoot);
934
- if (!existsSync(path)) return null;
1024
+ if (!existsSync4(path)) return null;
935
1025
  const raw = await readFile(path, "utf8");
936
1026
  let parsed;
937
1027
  try {
@@ -952,15 +1042,6 @@ async function loadConfig(repoRoot) {
952
1042
  value: parsed
953
1043
  };
954
1044
  }
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
1045
  function renderDefaultConfig(detection, overrides = {}) {
965
1046
  const pm = detection?.packageManager ?? "npm";
966
1047
  const safeBootstrap = Array.isArray(detection?.worktreeBootstrap) && detection.worktreeBootstrap.length ? detection.worktreeBootstrap : [["npm", "install"]];
@@ -1002,17 +1083,16 @@ function renderDefaultConfig(detection, overrides = {}) {
1002
1083
 
1003
1084
  // src/installer/planner.js
1004
1085
  init_json_pointer();
1005
- init_catalog();
1006
1086
  init_root_config();
1007
1087
  init_root_config();
1008
1088
  async function readBytes(path) {
1009
- if (!existsSync3(path)) return null;
1089
+ if (!existsSync6(path)) return null;
1010
1090
  const buf = await readFile3(path);
1011
1091
  const fileStat = await stat(path);
1012
1092
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")), mode: fileStat.mode & 511 };
1013
1093
  }
1014
1094
  async function readDesiredBytes(source) {
1015
- if (!source || !existsSync3(source)) return null;
1095
+ if (!source || !existsSync6(source)) return null;
1016
1096
  const buf = await readFile3(source);
1017
1097
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
1018
1098
  }
@@ -1085,13 +1165,40 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1085
1165
  reason: locked?.sha256 == null ? "managed file already exists; bytes differ from upstream" : "managed file is locally modified"
1086
1166
  };
1087
1167
  }
1088
- async function planFileInstall({ repoRoot, lock, allowUnowned = false }) {
1168
+ async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog = CATALOG }) {
1089
1169
  const plan = [];
1090
- for (const entry of CATALOG) {
1170
+ for (const entry of catalog) {
1091
1171
  plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned }));
1092
1172
  }
1093
1173
  return plan;
1094
1174
  }
1175
+ async function planMigrationCleanup({ repoRoot, lock, migrationReport, allowUnowned = false }) {
1176
+ const action = migrationReport?.actions?.find((entry) => entry.kind === "candidate-remove-legacy-plugin-path");
1177
+ if (!action) return [];
1178
+ const relPath = ".opencode/plugin/opencode-ship.js";
1179
+ const target = `${repoRoot}/${relPath}`;
1180
+ const current = await readBytes(target);
1181
+ if (!current) return [];
1182
+ const locked = lookupLockedFile(lock, relPath);
1183
+ if (locked?.sha256 === current.hash || allowUnowned) {
1184
+ return [{
1185
+ kind: "delete",
1186
+ op: "file",
1187
+ target,
1188
+ relPath,
1189
+ reason: "remove the lock-owned v0.2 singular plugin path"
1190
+ }];
1191
+ }
1192
+ return [{
1193
+ kind: "conflict",
1194
+ op: "file",
1195
+ target,
1196
+ relPath,
1197
+ currentSha: current.hash,
1198
+ previousSha: locked?.sha256 ?? null,
1199
+ reason: "legacy singular plugin is unowned or locally modified"
1200
+ }];
1201
+ }
1095
1202
  async function planUninstall({ repoRoot, lock }) {
1096
1203
  if (!lock) return [];
1097
1204
  const plan = [];
@@ -1113,7 +1220,7 @@ async function planUninstall({ repoRoot, lock }) {
1113
1220
  }
1114
1221
  return plan;
1115
1222
  }
1116
- async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }) {
1223
+ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite, migrationSeed = null }) {
1117
1224
  const existing = await loadConfig(repoRoot);
1118
1225
  if (existing?.ok && !forceOverwrite) {
1119
1226
  return {
@@ -1127,10 +1234,11 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }
1127
1234
  reason: "user config already present"
1128
1235
  };
1129
1236
  }
1130
- const desiredValue = renderDefaultConfig(detection);
1237
+ const desiredValue = migrationSeed ?? renderDefaultConfig(detection);
1131
1238
  const desiredJson = JSON.stringify(desiredValue, null, 2) + "\n";
1132
1239
  const desiredSha = bytesHashString(desiredJson);
1133
1240
  const kind = existing?.ok && forceOverwrite ? "update" : "create";
1241
+ 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
1242
  return {
1135
1243
  kind,
1136
1244
  op: "config",
@@ -1140,22 +1248,29 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }
1140
1248
  desiredSha,
1141
1249
  bytes: Buffer.from(desiredJson, "utf8"),
1142
1250
  configValue: desiredValue,
1143
- reason: existing?.ok ? "user config overwritten via --force-config" : "synthesising a default config from detection"
1251
+ reason
1144
1252
  };
1145
1253
  }
1146
1254
  async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1147
1255
  const detected = findRootConfig(repoRoot);
1148
1256
  const target = detected.path ?? defaultRootConfigPath(repoRoot);
1149
1257
  const previous = lock?.manager?.rootDocuments?.find((d) => d.path === detected.relative);
1150
- const fileMissing = !existsSync3(target);
1258
+ const fileMissing = !existsSync6(target);
1151
1259
  if (fileMissing && !forceRepair) {
1260
+ const seededRecords = previous?.pointers && previous.pointers.length > 0 ? previous.pointers : POINTER_ENTRIES.map((entry) => ({
1261
+ pointer: entry.pointer,
1262
+ strategy: entry.strategy,
1263
+ installedSha256: bytesHashString(stableStringify(entry.value)),
1264
+ previous: { existed: false }
1265
+ }));
1152
1266
  return {
1153
1267
  kind: "noop",
1154
1268
  op: "root-config",
1155
1269
  target,
1156
1270
  relPath: detected.relative,
1157
1271
  reason: "no root opencode.json present",
1158
- edits: []
1272
+ edits: [],
1273
+ pointerRecords: seededRecords
1159
1274
  };
1160
1275
  }
1161
1276
  if (fileMissing && forceRepair) {
@@ -1163,7 +1278,12 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1163
1278
  const doc = synthesizeDefaultRootConfig2();
1164
1279
  const bytes2 = Buffer.from(formatRootConfig3(doc), "utf8");
1165
1280
  const newSha2 = bytesHashString(stableStringify(doc));
1166
- const installedPointers2 = [];
1281
+ const installedPointers2 = POINTER_ENTRIES.map((entry) => ({
1282
+ pointer: entry.pointer,
1283
+ strategy: entry.strategy,
1284
+ installedSha256: bytesHashString(stableStringify(entry.value)),
1285
+ previous: { existed: false }
1286
+ }));
1167
1287
  return {
1168
1288
  kind: "create",
1169
1289
  op: "root-config",
@@ -1172,7 +1292,7 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1172
1292
  bytes: bytes2,
1173
1293
  desiredSha: newSha2,
1174
1294
  currentSha: null,
1175
- edits: [{ kind: "create", pointer: "(file)", value: "(created)" }],
1295
+ edits: installedPointers2.map((p) => ({ kind: "create", pointer: p.pointer, value: "(synthesized)" })),
1176
1296
  pointerRecords: installedPointers2,
1177
1297
  format: "json",
1178
1298
  document: doc,
@@ -1187,11 +1307,36 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1187
1307
  target,
1188
1308
  relPath: detected.relative,
1189
1309
  reason: `root config ${docResult.error.kind}`,
1190
- edits: []
1310
+ edits: [],
1311
+ pointerRecords: previous?.pointers ?? []
1191
1312
  };
1192
1313
  }
1193
1314
  const previousPointers = previous?.pointers ?? [];
1194
1315
  const result = applyOwnedPointers(docResult.value, { allowEqualValues: true });
1316
+ if (result.applied.length === 0 && result.skipped.every((s) => s.reason === "already equal")) {
1317
+ const ownedPointers = previousPointers.length > 0 ? previousPointers : POINTER_ENTRIES.map((entry) => {
1318
+ const existing = getPointer(docResult.value, entry.pointer);
1319
+ return {
1320
+ pointer: entry.pointer,
1321
+ strategy: entry.strategy,
1322
+ installedSha256: bytesHashString(stableStringify(existing ?? entry.value)),
1323
+ previous: existing === void 0 ? { existed: false } : { existed: true, value: existing }
1324
+ };
1325
+ });
1326
+ return {
1327
+ kind: "noop",
1328
+ op: "root-config",
1329
+ target,
1330
+ relPath: detected.relative,
1331
+ reason: "no installer-owned entries missing",
1332
+ edits: [],
1333
+ pointerRecords: ownedPointers,
1334
+ format: docResult.format,
1335
+ document: docResult.value,
1336
+ currentSha: docResult.sha256 ?? null,
1337
+ desiredSha: docResult.sha256 ?? null
1338
+ };
1339
+ }
1195
1340
  const edits = result.applied.map((a) => ({
1196
1341
  kind: "create",
1197
1342
  pointer: a.pointer,
@@ -1207,8 +1352,8 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1207
1352
  let bytes;
1208
1353
  let docForWrite = result.doc;
1209
1354
  try {
1210
- const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw);
1211
- docForWrite = sourceValue;
1355
+ const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw ?? "");
1356
+ docForWrite = sourceValue && typeof sourceValue === "object" ? sourceValue : result.doc;
1212
1357
  for (const entry of result.applied) {
1213
1358
  docForWrite = setPointer2(docForWrite, entry.pointer, entry.value);
1214
1359
  }
@@ -1231,6 +1376,17 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1231
1376
  if (idx >= 0) installedPointers[idx] = next;
1232
1377
  else installedPointers.push(next);
1233
1378
  }
1379
+ for (const e of result.skipped) {
1380
+ if (e.reason !== "already equal") continue;
1381
+ const idx = installedPointers.findIndex((p) => p.pointer === e.pointer);
1382
+ if (idx >= 0) continue;
1383
+ installedPointers.push({
1384
+ pointer: e.pointer,
1385
+ strategy: "value",
1386
+ installedSha256: bytesHashString(stableStringify(e.existing ?? null)),
1387
+ previous: { existed: true, value: e.existing }
1388
+ });
1389
+ }
1234
1390
  return {
1235
1391
  kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.length ? "update" : "noop",
1236
1392
  op: "root-config",
@@ -1247,15 +1403,97 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
1247
1403
  };
1248
1404
  }
1249
1405
 
1406
+ // src/installer/lock.js
1407
+ init_hash();
1408
+ init_json_pointer();
1409
+ import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
1410
+ import { existsSync as existsSync7 } from "node:fs";
1411
+ import { dirname as dirname4, resolve as resolve6 } from "node:path";
1412
+ var CURRENT_LOCK_SCHEMA = 2;
1413
+ function lockPath(repoRoot) {
1414
+ return resolve6(repoRoot, ".opencode", "ship.lock.json");
1415
+ }
1416
+ function computeIntegrity(lock) {
1417
+ const { integrity: _ignored, ...without } = lock ?? {};
1418
+ void _ignored;
1419
+ return {
1420
+ lockSha256: bytesHashString(stableStringify(without))
1421
+ };
1422
+ }
1423
+ function validateLock(rawLock) {
1424
+ if (rawLock === null || rawLock === void 0) {
1425
+ return { ok: true, kind: "missing", issues: [] };
1426
+ }
1427
+ if (typeof rawLock !== "object" || Array.isArray(rawLock)) {
1428
+ return { ok: false, kind: "shape", issues: ["lock root must be an object"] };
1429
+ }
1430
+ const issues = [];
1431
+ let kind = "ok";
1432
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
1433
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1434
+ kind = "schema";
1435
+ }
1436
+ const manager = rawLock.manager;
1437
+ if (manager === void 0) {
1438
+ issues.push("manager section missing");
1439
+ kind = kind === "ok" ? "shape" : kind;
1440
+ } else if (typeof manager !== "object" || manager === null) {
1441
+ issues.push("manager section must be an object");
1442
+ kind = kind === "ok" ? "shape" : kind;
1443
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
1444
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1445
+ kind = "schema";
1446
+ } else if (manager.name !== "opencode-ship") {
1447
+ issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
1448
+ kind = "shape";
1449
+ } else if (rawLock.contractVersion === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
1450
+ issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
1451
+ kind = "shape";
1452
+ }
1453
+ if (!rawLock.files || !Array.isArray(rawLock.files)) {
1454
+ issues.push("files must be an array");
1455
+ kind = kind === "ok" ? "shape" : kind;
1456
+ }
1457
+ if (!rawLock.integrity || typeof rawLock.integrity !== "object") {
1458
+ issues.push("integrity section missing");
1459
+ kind = kind === "ok" ? "shape" : kind;
1460
+ } else {
1461
+ const expected = computeIntegrity(rawLock).lockSha256;
1462
+ if (expected !== rawLock.integrity.lockSha256) {
1463
+ issues.push(`integrity mismatch: stored ${rawLock.integrity.lockSha256} != computed ${expected}`);
1464
+ kind = "integrity";
1465
+ }
1466
+ }
1467
+ return { ok: issues.length === 0, kind, issues };
1468
+ }
1469
+ async function readValidatedLock(repoRoot) {
1470
+ const path = lockPath(repoRoot);
1471
+ if (!existsSync7(path)) {
1472
+ return { kind: "missing", lock: null, issues: [] };
1473
+ }
1474
+ let raw;
1475
+ try {
1476
+ const text = await readFile4(path, "utf8");
1477
+ raw = JSON.parse(text);
1478
+ } catch (e) {
1479
+ return {
1480
+ kind: "integrity",
1481
+ lock: null,
1482
+ issues: [`unable to parse lock JSON: ${e?.message ?? String(e)}`]
1483
+ };
1484
+ }
1485
+ const validation = validateLock(raw);
1486
+ return { kind: validation.kind, lock: validation.ok ? raw : null, issues: validation.issues };
1487
+ }
1488
+
1250
1489
  // src/installer/executor.js
1251
- init_lock();
1252
1490
  init_hash();
1253
1491
  init_json_pointer();
1254
1492
 
1255
1493
  // src/installer/detection/project.js
1256
1494
  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";
1495
+ import { existsSync as existsSync8, readFileSync as readFileSync4 } from "node:fs";
1496
+ import { resolve as resolve7, join } from "node:path";
1259
1497
  function runGit(cwd, args) {
1260
1498
  const r = spawnSync("git", ["-C", cwd, ...args], {
1261
1499
  stdio: ["ignore", "pipe", "pipe"],
@@ -1264,17 +1502,17 @@ function runGit(cwd, args) {
1264
1502
  return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
1265
1503
  }
1266
1504
  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";
1505
+ if (existsSync8(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
1506
+ if (existsSync8(join(repoRoot, "yarn.lock"))) return "yarn";
1507
+ if (existsSync8(join(repoRoot, "bun.lockb"))) return "bun";
1508
+ if (existsSync8(join(repoRoot, "package-lock.json"))) return "npm";
1271
1509
  return null;
1272
1510
  }
1273
1511
  function readPackageJson(repoRoot) {
1274
1512
  const path = join(repoRoot, "package.json");
1275
- if (!existsSync5(path)) return null;
1513
+ if (!existsSync8(path)) return null;
1276
1514
  try {
1277
- return JSON.parse(readFileSync2(path, "utf8"));
1515
+ return JSON.parse(readFileSync4(path, "utf8"));
1278
1516
  } catch {
1279
1517
  return null;
1280
1518
  }
@@ -1349,7 +1587,7 @@ function detectOwner(repoRoot) {
1349
1587
  }
1350
1588
  function detectProject(repoRoot = process.cwd()) {
1351
1589
  const errors = [];
1352
- const cwd = resolve5(repoRoot);
1590
+ const cwd = resolve7(repoRoot);
1353
1591
  const inside = runGit(cwd, ["rev-parse", "--show-toplevel"]);
1354
1592
  if (inside.status !== 0) {
1355
1593
  errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
@@ -1397,7 +1635,6 @@ function detectProject(repoRoot = process.cwd()) {
1397
1635
 
1398
1636
  // src/installer/executor.js
1399
1637
  init_root_config();
1400
- init_lock();
1401
1638
 
1402
1639
  // src/installer/transaction.js
1403
1640
  import {
@@ -1409,34 +1646,62 @@ import {
1409
1646
  stat as stat2,
1410
1647
  open
1411
1648
  } from "node:fs/promises";
1412
- import { existsSync as existsSync6 } from "node:fs";
1413
- import { dirname as dirname4, resolve as resolve6 } from "node:path";
1649
+ import { existsSync as existsSync9 } from "node:fs";
1650
+ import { dirname as dirname5, resolve as resolve8 } from "node:path";
1414
1651
  import { spawnSync as spawnSync2 } from "node:child_process";
1652
+ init_hash();
1415
1653
  function gitDir(repoRoot) {
1416
1654
  const r = spawnSync2("git", ["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-dir"], {
1417
1655
  encoding: "utf8"
1418
1656
  });
1419
1657
  if (r.status !== 0) return null;
1420
1658
  const trimmed = r.stdout.trim();
1421
- return trimmed ? resolve6(repoRoot, trimmed) : null;
1659
+ return trimmed ? resolve8(repoRoot, trimmed) : null;
1422
1660
  }
1423
1661
  function lockDir(repoRoot) {
1424
- return resolve6(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
1662
+ return resolve8(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
1425
1663
  }
1426
1664
  function transactionLockPath(repoRoot) {
1427
- return resolve6(lockDir(repoRoot), ".txn.lock");
1665
+ return resolve8(lockDir(repoRoot), ".txn.lock");
1428
1666
  }
1429
1667
  function journalPath(repoRoot, txnId) {
1430
- return resolve6(lockDir(repoRoot), `.txn-${txnId}.journal`);
1668
+ return resolve8(lockDir(repoRoot), `.txn-${txnId}.journal`);
1669
+ }
1670
+ function backupPath(target, token) {
1671
+ return `${target}.txn-${token}-backup`;
1672
+ }
1673
+ function stagedPath(target, token) {
1674
+ return `${target}.txn-${token}-staged`;
1675
+ }
1676
+ async function mkdirp(path) {
1677
+ await mkdir3(path, { recursive: true });
1431
1678
  }
1432
1679
  async function acquireLock(repoRoot, txnId) {
1433
1680
  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
- );
1681
+ const path = transactionLockPath(repoRoot);
1682
+ await mkdirp(dir);
1683
+ let created = false;
1684
+ try {
1685
+ const handle = await open(path, "wx", 384);
1686
+ created = true;
1687
+ try {
1688
+ await handle.writeFile(JSON.stringify({
1689
+ pid: process.pid,
1690
+ txnId,
1691
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1692
+ }));
1693
+ await handle.sync();
1694
+ } finally {
1695
+ await handle.close();
1696
+ }
1697
+ await fsyncDir(dir);
1698
+ } catch (error) {
1699
+ if (created) {
1700
+ await unlink(path).catch(() => null);
1701
+ await fsyncDir(dir);
1702
+ }
1703
+ throw error;
1704
+ }
1440
1705
  }
1441
1706
  async function releaseLock(repoRoot) {
1442
1707
  try {
@@ -1444,6 +1709,18 @@ async function releaseLock(repoRoot) {
1444
1709
  } catch {
1445
1710
  }
1446
1711
  }
1712
+ async function liveLockOwner(repoRoot) {
1713
+ const path = transactionLockPath(repoRoot);
1714
+ if (!existsSync9(path)) return false;
1715
+ try {
1716
+ const lock = JSON.parse(await readFile5(path, "utf8"));
1717
+ if (!Number.isInteger(lock?.pid) || lock.pid <= 0) return false;
1718
+ process.kill(lock.pid, 0);
1719
+ return true;
1720
+ } catch (error) {
1721
+ return error?.code === "EPERM";
1722
+ }
1723
+ }
1447
1724
  async function fsyncDir(path) {
1448
1725
  try {
1449
1726
  const handle = await open(path, "r");
@@ -1455,21 +1732,32 @@ async function fsyncDir(path) {
1455
1732
  function randomToken() {
1456
1733
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1457
1734
  }
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
1735
  function asMode(mode) {
1469
1736
  return typeof mode === "number" ? mode : 420;
1470
1737
  }
1471
1738
  async function writeJournal(repoRoot, txnId, journal) {
1472
- await writeFile3(journalPath(repoRoot, txnId), JSON.stringify(journal, null, 2), "utf8");
1739
+ const { entries, ...header } = journal;
1740
+ const json = { ...header, ledger: entries.map((entry) => ({
1741
+ op: entry.op,
1742
+ target: entry.target,
1743
+ backup: entry.backup ?? null,
1744
+ staged: entry.staged ?? null,
1745
+ hadOriginal: entry.hadOriginal ?? null,
1746
+ commitMarker: entry.commitMarker ?? false,
1747
+ installedSha256: entry.installedSha256 ?? null,
1748
+ mode: entry.mode ?? null
1749
+ })) };
1750
+ const target = journalPath(repoRoot, txnId);
1751
+ const staged = `${target}.tmp`;
1752
+ const handle = await open(staged, "w", 384);
1753
+ try {
1754
+ await handle.writeFile(JSON.stringify(json, null, 2), "utf8");
1755
+ await handle.sync();
1756
+ } finally {
1757
+ await handle.close();
1758
+ }
1759
+ await rename3(staged, target);
1760
+ await fsyncDir(dirname5(target));
1473
1761
  }
1474
1762
  async function clearJournal(repoRoot, txnId) {
1475
1763
  if (!txnId) return;
@@ -1478,50 +1766,104 @@ async function clearJournal(repoRoot, txnId) {
1478
1766
  } catch {
1479
1767
  }
1480
1768
  }
1769
+ async function readJournal(repoRoot, name) {
1770
+ const path = resolve8(lockDir(repoRoot), name);
1771
+ try {
1772
+ const raw = await readFile5(path, "utf8");
1773
+ return JSON.parse(raw);
1774
+ } catch {
1775
+ return null;
1776
+ }
1777
+ }
1778
+ async function isCommitted(journal) {
1779
+ if (journal.committed) return true;
1780
+ const marker = journal.ledger?.find((entry) => entry.commitMarker);
1781
+ if (!marker?.target || !marker.installedSha256 || !existsSync9(marker.target)) return false;
1782
+ try {
1783
+ return bytesHashString(await readFile5(marker.target, "utf8")) === marker.installedSha256;
1784
+ } catch {
1785
+ return false;
1786
+ }
1787
+ }
1788
+ async function restoreEntry(entry) {
1789
+ if (entry.op === "write") {
1790
+ if (entry.backup && existsSync9(entry.backup)) {
1791
+ await rename3(entry.backup, entry.target);
1792
+ } else if (entry.hadOriginal === false && existsSync9(entry.target)) {
1793
+ await unlink(entry.target);
1794
+ }
1795
+ if (entry.staged && existsSync9(entry.staged)) await unlink(entry.staged);
1796
+ } else if (entry.op === "delete" && entry.backup && existsSync9(entry.backup)) {
1797
+ await rename3(entry.backup, entry.target);
1798
+ }
1799
+ await fsyncDir(dirname5(entry.target));
1800
+ }
1801
+ async function recoverJournal(repoRoot, dir, name) {
1802
+ const journal = await readJournal(repoRoot, name);
1803
+ if (!journal) {
1804
+ await unlink(resolve8(dir, name)).catch(() => null);
1805
+ return true;
1806
+ }
1807
+ const committed = await isCommitted(journal);
1808
+ const entries = committed ? journal.ledger ?? [] : [...journal.ledger ?? []].reverse();
1809
+ let complete = true;
1810
+ for (const entry of entries) {
1811
+ try {
1812
+ if (committed) await commitEntry(entry);
1813
+ else await restoreEntry(entry);
1814
+ } catch {
1815
+ complete = false;
1816
+ }
1817
+ }
1818
+ if (complete) await unlink(resolve8(dir, name)).catch(() => null);
1819
+ return complete;
1820
+ }
1481
1821
  async function recover(repoRoot) {
1482
1822
  const dir = lockDir(repoRoot);
1483
- if (!existsSync6(dir)) return { recovered: false };
1823
+ if (!existsSync9(dir)) return { recovered: false, recoveredCount: 0 };
1824
+ if (await liveLockOwner(repoRoot)) {
1825
+ return { recovered: false, recoveredCount: 0, blocked: true };
1826
+ }
1484
1827
  const { readdir } = await import("node:fs/promises");
1485
1828
  const names = await readdir(dir).catch(() => []);
1486
1829
  const journals = names.filter((n) => n.startsWith(".txn-") && n.endsWith(".journal"));
1487
- if (!journals.length) return { recovered: false };
1830
+ if (!journals.length) {
1831
+ await releaseLock(repoRoot);
1832
+ return { recovered: false, recoveredCount: 0 };
1833
+ }
1488
1834
  let totalRecovered = 0;
1835
+ let recoveryFailed = false;
1489
1836
  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;
1837
+ if (await recoverJournal(repoRoot, dir, name)) totalRecovered += 1;
1838
+ else recoveryFailed = true;
1519
1839
  }
1520
1840
  await releaseLock(repoRoot);
1521
- return { recovered: totalRecovered > 0, recoveredCount: totalRecovered };
1841
+ return {
1842
+ recovered: totalRecovered > 0,
1843
+ recoveredCount: totalRecovered,
1844
+ blocked: recoveryFailed,
1845
+ reason: recoveryFailed ? "recovery-failed" : null
1846
+ };
1847
+ }
1848
+ async function commitEntry(entry) {
1849
+ let changed = false;
1850
+ if (entry.backup && existsSync9(entry.backup)) {
1851
+ await unlink(entry.backup);
1852
+ changed = true;
1853
+ }
1854
+ if (entry.staged && existsSync9(entry.staged)) {
1855
+ await unlink(entry.staged);
1856
+ changed = true;
1857
+ }
1858
+ if (changed) await fsyncDir(dirname5(entry.target));
1522
1859
  }
1523
1860
  async function executePlan({ repoRoot, plan, newLockBuilder }) {
1524
1861
  const recovered = await recover(repoRoot);
1862
+ if (recovered.blocked) {
1863
+ const kind = recovered.reason ?? "lock-held";
1864
+ const message = kind === "lock-held" ? "another opencode-ship transaction is active" : "a previous opencode-ship transaction could not be recovered";
1865
+ return { ok: false, error: { kind, message } };
1866
+ }
1525
1867
  const txnId = `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
1526
1868
  try {
1527
1869
  await acquireLock(repoRoot, txnId);
@@ -1529,58 +1871,94 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1529
1871
  return { ok: false, error: { kind: "lock-held", message: e?.message ?? String(e) } };
1530
1872
  }
1531
1873
  const journal = { repoRoot, txnId, startedAt: (/* @__PURE__ */ new Date()).toISOString(), entries: [] };
1532
- await writeJournal(repoRoot, txnId, journal);
1533
1874
  try {
1875
+ await writeJournal(repoRoot, txnId, journal);
1534
1876
  for (const op of plan) {
1535
1877
  if (op.op !== "file") continue;
1536
1878
  if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
1537
- const original = await captureOriginal(op.target);
1879
+ const token = randomToken();
1880
+ const backup = backupPath(op.target, token);
1881
+ const staged = stagedPath(op.target, token);
1538
1882
  if (op.kind === "delete") {
1539
- if (!existsSync6(op.target)) continue;
1540
- const backup = `${op.target}.txn-${randomToken()}-backup`;
1541
- await rename3(op.target, backup);
1883
+ if (!existsSync9(op.target)) continue;
1542
1884
  journal.entries.push({
1543
- op: "promote",
1885
+ op: "delete",
1544
1886
  target: op.target,
1545
- staged: backup,
1546
- original,
1547
- kind: "delete"
1887
+ backup,
1888
+ staged: null,
1889
+ hadOriginal: true,
1890
+ mode: asMode(op.mode)
1548
1891
  });
1892
+ await writeJournal(repoRoot, txnId, journal);
1893
+ await rename3(op.target, backup);
1549
1894
  } 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);
1895
+ await mkdirp(dirname5(op.target));
1896
+ const hadOriginal = existsSync9(op.target);
1557
1897
  journal.entries.push({
1558
- op: "promote",
1898
+ op: "write",
1559
1899
  target: op.target,
1560
- staged: tempPath,
1561
- original,
1562
- kind: op.kind
1900
+ backup: hadOriginal ? backup : null,
1901
+ staged,
1902
+ hadOriginal,
1903
+ mode: asMode(op.mode)
1563
1904
  });
1905
+ await writeJournal(repoRoot, txnId, journal);
1906
+ await writeFile3(staged, op.bytes ?? Buffer.alloc(0), { mode: asMode(op.mode) });
1907
+ const h = await open(staged, "r+");
1908
+ await h.sync();
1909
+ await h.close();
1910
+ if (hadOriginal) await rename3(op.target, backup);
1911
+ await rename3(staged, op.target);
1564
1912
  }
1565
- await fsyncDir(dirname4(op.target));
1566
- await writeJournal(repoRoot, txnId, journal);
1913
+ await fsyncDir(dirname5(op.target));
1567
1914
  }
1568
1915
  if (newLockBuilder) {
1569
1916
  const lockValue = await newLockBuilder();
1570
- const { writeLock: writer } = await Promise.resolve().then(() => (init_lock(), lock_exports));
1571
1917
  const lockPathTarget = `${repoRoot}/.opencode/ship.lock.json`;
1918
+ await mkdirp(dirname5(lockPathTarget));
1919
+ const token = randomToken();
1920
+ const backup = backupPath(lockPathTarget, token);
1921
+ const staged = stagedPath(lockPathTarget, token);
1922
+ const hadOriginal = existsSync9(lockPathTarget);
1923
+ const finalLock = { ...lockValue, integrity: computeIntegrity(lockValue) };
1924
+ const lockBytes = JSON.stringify(finalLock, null, 2) + "\n";
1572
1925
  journal.entries.push({
1573
- op: "promote",
1926
+ op: "write",
1574
1927
  target: lockPathTarget,
1575
- staged: lockPathTarget,
1576
- original: await captureOriginal(lockPathTarget),
1577
- kind: "lock"
1928
+ backup: hadOriginal ? backup : null,
1929
+ staged,
1930
+ hadOriginal,
1931
+ commitMarker: true,
1932
+ installedSha256: bytesHashString(lockBytes),
1933
+ mode: 420
1578
1934
  });
1579
- await writer(repoRoot, lockValue);
1580
1935
  await writeJournal(repoRoot, txnId, journal);
1936
+ await writeFile3(staged, lockBytes, { mode: 420 });
1937
+ const handle = await open(staged, "r+");
1938
+ await handle.sync();
1939
+ await handle.close();
1940
+ if (hadOriginal) await rename3(lockPathTarget, backup);
1941
+ await rename3(staged, lockPathTarget);
1942
+ await fsyncDir(dirname5(lockPathTarget));
1943
+ }
1944
+ journal.committed = true;
1945
+ await writeJournal(repoRoot, txnId, journal);
1946
+ let cleanupComplete = true;
1947
+ for (const entry of journal.entries) {
1948
+ try {
1949
+ await commitEntry(entry);
1950
+ } catch {
1951
+ cleanupComplete = false;
1952
+ }
1581
1953
  }
1582
- await clearJournal(repoRoot, txnId);
1583
- return { ok: true, txnId, recovered: recovered.recovered ?? false, recoveredCount: recovered.recoveredCount ?? 0 };
1954
+ if (cleanupComplete) await clearJournal(repoRoot, txnId);
1955
+ return {
1956
+ ok: true,
1957
+ txnId,
1958
+ recovered: recovered.recovered ?? false,
1959
+ recoveredCount: recovered.recoveredCount ?? 0,
1960
+ cleanupPending: !cleanupComplete
1961
+ };
1584
1962
  } catch (e) {
1585
1963
  await rollback(repoRoot, journal);
1586
1964
  return { ok: false, error: { kind: "transaction-failed", message: e?.message ?? String(e) } };
@@ -1589,75 +1967,51 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1589
1967
  }
1590
1968
  }
1591
1969
  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
- }
1970
+ const entries = journal.entries ?? journal.ledger ?? [];
1971
+ let complete = true;
1972
+ for (const entry of [...entries].reverse()) {
1973
+ try {
1974
+ await restoreEntry(entry);
1975
+ } catch {
1976
+ complete = false;
1619
1977
  }
1620
1978
  }
1621
- if (journal.txnId) await clearJournal(repoRoot, journal.txnId);
1979
+ if (complete && journal.txnId) await clearJournal(repoRoot, journal.txnId);
1622
1980
  }
1623
1981
 
1624
1982
  // src/installer/migration.js
1625
- init_lock();
1626
1983
  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
1984
+ import { existsSync as existsSync10 } from "node:fs";
1985
+ import { resolve as resolve9 } from "node:path";
1634
1986
  function legacyAdapterPath(repoRoot) {
1635
- return resolve8(repoRoot, ".opencode", "delivery.json");
1987
+ return resolve9(repoRoot, ".opencode", "delivery.json");
1636
1988
  }
1637
1989
  function legacyLockPath(repoRoot) {
1638
- return resolve8(repoRoot, ".opencode", "delivery.lock.json");
1990
+ return resolve9(repoRoot, ".opencode", "delivery.lock.json");
1639
1991
  }
1640
1992
  function legacyPluginPath(repoRoot) {
1641
- return resolve8(repoRoot, ".opencode", "plugin", "delivery.ts");
1993
+ return resolve9(repoRoot, ".opencode", "plugin", "delivery.ts");
1642
1994
  }
1643
1995
  async function detectLegacyShapes(repoRoot) {
1644
1996
  const out = {
1645
1997
  adapter: false,
1646
1998
  legacyLock: false,
1647
1999
  plugin: false,
2000
+ pluginOld: false,
1648
2001
  reviewer: false,
1649
2002
  verifier: false
1650
2003
  };
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;
2004
+ if (existsSync10(legacyAdapterPath(repoRoot))) out.adapter = true;
2005
+ if (existsSync10(legacyLockPath(repoRoot))) out.legacyLock = true;
2006
+ if (existsSync10(legacyPluginPath(repoRoot))) out.plugin = true;
2007
+ if (existsSync10(resolve9(repoRoot, ".opencode/plugin/opencode-ship.js"))) out.pluginOld = true;
2008
+ if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-reviewer.md"))) out.reviewer = true;
2009
+ if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-verifier.md"))) out.verifier = true;
1656
2010
  return out;
1657
2011
  }
1658
2012
  async function readLegacyAdapter(repoRoot) {
1659
2013
  const path = legacyAdapterPath(repoRoot);
1660
- if (!existsSync7(path)) return null;
2014
+ if (!existsSync10(path)) return null;
1661
2015
  try {
1662
2016
  const raw = await readFile6(path, "utf8");
1663
2017
  return { path, raw, value: JSON.parse(raw) };
@@ -1665,44 +2019,49 @@ async function readLegacyAdapter(repoRoot) {
1665
2019
  return null;
1666
2020
  }
1667
2021
  }
1668
- async function migration({ repoRoot, lock, forceRepair }) {
2022
+ async function migration({ repoRoot, lock, forceRepair, detection = null }) {
1669
2023
  const shapes = await detectLegacyShapes(repoRoot);
1670
2024
  const legacy = await readLegacyAdapter(repoRoot);
1671
2025
  const config = await loadConfig(repoRoot);
1672
2026
  const actions = [];
2027
+ let proposedConfigSeed = null;
1673
2028
  if (legacy && !config?.ok) {
1674
- await writeConfig(repoRoot, legacyToShipConfig(legacy.value));
1675
- actions.push({ kind: "seeded-config", from: legacy.path });
2029
+ proposedConfigSeed = legacyToShipConfig(legacy.value, detection);
2030
+ actions.push({ kind: "candidate-seed-config", from: legacy.path });
1676
2031
  }
1677
2032
  if (legacy && shapes.legacyLock && !lock?.manager) {
1678
2033
  actions.push({ kind: "kept-legacy-lock", path: legacyLockPath(repoRoot) });
1679
2034
  }
1680
- if (shapes.plugin && existsSync7(resolve8(repoRoot, ".opencode/plugin/opencode-ship.js"))) {
2035
+ if (shapes.plugin && existsSync10(resolve9(repoRoot, ".opencode/plugins/opencode-ship.js"))) {
1681
2036
  if (!forceRepair) {
1682
2037
  actions.push({ kind: "candidate-remove-legacy-plugin", path: legacyPluginPath(repoRoot) });
1683
2038
  }
1684
2039
  }
1685
- return { shapes, actions, legacyPresent: Boolean(legacy) };
2040
+ if (shapes.pluginOld) {
2041
+ actions.push({ kind: "candidate-remove-legacy-plugin-path", path: resolve9(repoRoot, ".opencode/plugin/opencode-ship.js") });
2042
+ }
2043
+ return { shapes, actions, legacyPresent: Boolean(legacy), proposedConfigSeed };
1686
2044
  }
1687
- function legacyToShipConfig(legacy) {
1688
- if (!legacy || typeof legacy !== "object") return renderDefaultConfig({});
2045
+ function legacyToShipConfig(legacy, detection = null) {
2046
+ if (!legacy || typeof legacy !== "object") return renderDefaultConfig(detection ?? {});
2047
+ const repoSlug = typeof legacy.repository?.repoSlug === "string" ? legacy.repository.repoSlug : detection?.repository ?? "owner/repo";
1689
2048
  return {
1690
2049
  schemaVersion: 1,
1691
2050
  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",
2051
+ remote: legacy.repository?.remote ?? detection?.remote ?? "origin",
2052
+ repository: repoSlug,
2053
+ defaultBranch: legacy.repository?.defaultBranch?.name ?? detection?.defaultBranch ?? "main",
2054
+ packageManager: detection?.packageManager ?? "pnpm",
1696
2055
  detectOverrides: false
1697
2056
  },
1698
2057
  delivery: {
1699
2058
  worktree: {
1700
2059
  root: legacy.worktree?.root ?? ".worktrees",
1701
2060
  branchTemplate: legacy.worktree?.branchTemplate ?? "{actor}/{slug}",
1702
- bootstrap: Array.isArray(legacy.worktree?.bootstrap) ? legacy.worktree.bootstrap : [["npm", "install"]]
2061
+ bootstrap: Array.isArray(legacy.worktree?.bootstrap) && legacy.worktree.bootstrap.length ? legacy.worktree.bootstrap : [["pnpm", "install", "--frozen-lockfile"]]
1703
2062
  },
1704
2063
  verification: {
1705
- commands: legacy.verification?.commands?.length ? legacy.verification.commands : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }],
2064
+ commands: Array.isArray(legacy.verification?.commands) && legacy.verification.commands.length ? legacy.verification.commands : [{ id: "canonical", argv: ["pnpm", "verify:workspace"], timeoutMs: 18e5 }],
1706
2065
  requireCleanDiffAfter: legacy.verification?.requireCleanDiffAfter ?? true,
1707
2066
  invalidateOnHeadChange: legacy.verification?.invalidateOnHeadChange ?? true
1708
2067
  },
@@ -1715,37 +2074,69 @@ function legacyToShipConfig(legacy) {
1715
2074
  },
1716
2075
  ready: legacy.ready ?? { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
1717
2076
  merge: legacy.merge ?? { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
1718
- cleanup: legacy.cleanup ?? { when: "next-task", requireUnpublishedGuard: true }
2077
+ cleanup: legacy.cleanup && typeof legacy.cleanup === "object" && "when" in legacy.cleanup ? legacy.cleanup : { when: "next-task", requireUnpublishedGuard: true }
1719
2078
  }
1720
2079
  };
1721
2080
  }
1722
2081
 
1723
2082
  // src/installer/executor.js
1724
2083
  async function readCurrentBytes(targetPath) {
1725
- if (!existsSync8(targetPath)) return null;
2084
+ if (!existsSync11(targetPath)) return null;
1726
2085
  const buf = await readFile7(targetPath);
1727
2086
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
1728
2087
  }
1729
- async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRootConfig }) {
2088
+ async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig }) {
1730
2089
  const detection = detectProject(rootPath ?? process.cwd());
1731
2090
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
1732
2091
  return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
1733
2092
  }
1734
2093
  const repoRoot = detection.repoRoot;
1735
- const lock = await readLock(repoRoot);
1736
- const migrationReport = await migration({ repoRoot, lock, forceRepair: false });
2094
+ const validatedLock = await readValidatedLock(repoRoot);
2095
+ if (validatedLock.kind === "schema") {
2096
+ return { ok: false, error: { kind: "unsupported-lock-schema", issues: validatedLock.issues } };
2097
+ }
2098
+ if (validatedLock.kind === "integrity" || validatedLock.kind === "shape") {
2099
+ return { ok: false, error: { kind: "lock-invalid", issues: validatedLock.issues } };
2100
+ }
2101
+ const lock = validatedLock.lock;
2102
+ const migrationReport = await migration({ repoRoot, lock, forceRepair: false, detection });
2103
+ const configResult = await loadConfig(repoRoot);
2104
+ const configValue = configResult?.ok ? configResult.value : null;
2105
+ const resolved = resolveProfile({
2106
+ cli: profile,
2107
+ config: configValue,
2108
+ lock
2109
+ });
2110
+ const activeCatalog = filterCatalogByProfile(CATALOG, resolved.profile);
1737
2111
  const configPlan = await planConfigSynthesis({
1738
2112
  repoRoot,
1739
2113
  detection,
1740
2114
  lock,
1741
- forceOverwrite: Boolean(forceConfig)
2115
+ forceOverwrite: Boolean(forceConfig),
2116
+ migrationSeed: migrationReport?.proposedConfigSeed ?? null
2117
+ });
2118
+ const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged), catalog: activeCatalog });
2119
+ const migrationPlan = await planMigrationCleanup({
2120
+ repoRoot,
2121
+ lock,
2122
+ migrationReport,
2123
+ allowUnowned: Boolean(replaceManaged)
1742
2124
  });
1743
- const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged) });
1744
2125
  const rootPlan = await planRootConfigApply({ repoRoot, lock, forceRepair: Boolean(forceRootConfig) });
1745
- const plan = [...filePlan ?? [], configPlan, rootPlan];
2126
+ const plan = [...filePlan ?? [], ...migrationPlan, configPlan, rootPlan];
1746
2127
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
1747
2128
  const summary = summarise(plan);
1748
- return { ok: true, repoRoot, detection, lock, plan, conflicts, summary, migrationReport };
2129
+ return {
2130
+ ok: true,
2131
+ repoRoot,
2132
+ detection,
2133
+ lock,
2134
+ profile: resolved,
2135
+ plan,
2136
+ conflicts,
2137
+ summary,
2138
+ migrationReport
2139
+ };
1749
2140
  }
1750
2141
  async function previewUninstall({ rootPath }) {
1751
2142
  const detection = detectProject(rootPath ?? process.cwd());
@@ -1753,9 +2144,16 @@ async function previewUninstall({ rootPath }) {
1753
2144
  return { ok: false, error: { kind: "invalid-project" } };
1754
2145
  }
1755
2146
  const repoRoot = detection.repoRoot;
1756
- const lock = await readLock(repoRoot);
2147
+ const validatedLock = await readValidatedLock(repoRoot);
2148
+ if (validatedLock.kind === "schema") {
2149
+ return { ok: false, error: { kind: "unsupported-lock-schema", issues: validatedLock.issues } };
2150
+ }
2151
+ if (validatedLock.kind === "integrity" || validatedLock.kind === "shape") {
2152
+ return { ok: false, error: { kind: "lock-invalid", issues: validatedLock.issues } };
2153
+ }
2154
+ const lock = validatedLock.lock;
1757
2155
  if (!lock) {
1758
- return { ok: true, repoRoot, lock, plan: [], conflicts: [], summary: summarise([]) };
2156
+ return { ok: true, repoRoot, lock: null, plan: [], conflicts: [], summary: summarise([]) };
1759
2157
  }
1760
2158
  const plan = await planUninstall({ repoRoot, lock });
1761
2159
  const conflicts = plan.filter((p) => p.kind === "conflict");
@@ -1772,7 +2170,7 @@ function summarise(plan) {
1772
2170
  }
1773
2171
  return counts;
1774
2172
  }
1775
- async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
2173
+ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profile = null }) {
1776
2174
  const files = [];
1777
2175
  const remain = lock?.files?.filter((f) => !plan.some((op) => op?.relPath === f.path)) ?? [];
1778
2176
  for (const op of plan) {
@@ -1798,24 +2196,30 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
1798
2196
  }
1799
2197
  const configSha = configPlan?.kind === "create" || configPlan?.kind === "update" ? configPlan.desiredSha : configPlan?.kind === "noop" ? configPlan.currentSha : null;
1800
2198
  const rootPointers = rootPlan?.pointerRecords ?? lock?.manager?.rootDocuments?.[0]?.pointers ?? [];
2199
+ const hasRootPlan = Boolean(rootPlan?.target || rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0);
2200
+ const hasRootDocuments = rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 || lock?.manager?.rootDocuments && lock.manager.rootDocuments.length > 0;
1801
2201
  return {
1802
- contractVersion: 1,
2202
+ contractVersion: CURRENT_LOCK_SCHEMA,
1803
2203
  manager: {
1804
- schemaVersion: 1,
2204
+ schemaVersion: CURRENT_LOCK_SCHEMA,
1805
2205
  name: "opencode-ship",
1806
- version: "0.2.1",
1807
- templateSet: getTemplateSet(),
2206
+ version: "0.4.0",
2207
+ templateSet: TEMPLATE_SET_ID,
2208
+ // Newly written locks always carry the resolved profile so
2209
+ // future CLI invocations without --profile still resolve to
2210
+ // the same choice through the lock-precedence layer.
2211
+ profile: profile ?? lock?.manager?.profile,
1808
2212
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
1809
2213
  config: {
1810
2214
  path: ".opencode/ship.config.json",
1811
2215
  sha256: configSha ?? lock?.manager?.config?.sha256 ?? "",
1812
2216
  existed: Boolean(lock?.manager?.config?.existed)
1813
2217
  },
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 ?? []
2218
+ rootDocuments: hasRootDocuments && (hasRootPlan || (lock?.manager?.rootDocuments?.length ?? 0) > 0) ? [{
2219
+ path: rootPlan?.relPath ?? lock?.manager?.rootDocuments?.[0]?.path ?? "opencode.json",
2220
+ format: rootPlan?.format ?? lock?.manager?.rootDocuments?.[0]?.format ?? "json",
2221
+ pointers: rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 ? rootPlan.pointerRecords : lock?.manager?.rootDocuments?.[0]?.pointers ?? []
2222
+ }] : []
1819
2223
  },
1820
2224
  files,
1821
2225
  cleanupPending: lock?.cleanupPending ?? []
@@ -1856,7 +2260,8 @@ async function commitInstall(preview, { json, command }) {
1856
2260
  plan: fileOnly,
1857
2261
  lock: preview.lock,
1858
2262
  configPlan,
1859
- rootPlan
2263
+ rootPlan,
2264
+ profile: preview.profile?.profile
1860
2265
  });
1861
2266
  const txPlan = await stageFiles(fileOnly, repoRoot);
1862
2267
  if (configPlan && (configPlan.kind === "create" || configPlan.kind === "update")) {
@@ -1908,7 +2313,16 @@ async function commitInstall(preview, { json, command }) {
1908
2313
  async function stageFiles(filePlan, repoRoot) {
1909
2314
  const out = [];
1910
2315
  for (const op of filePlan) {
1911
- if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge" || op.kind === "delete") continue;
2316
+ if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
2317
+ if (op.kind === "delete") {
2318
+ out.push({
2319
+ op: "file",
2320
+ kind: "delete",
2321
+ target: op.target,
2322
+ relPath: op.relPath
2323
+ });
2324
+ continue;
2325
+ }
1912
2326
  out.push({
1913
2327
  op: "file",
1914
2328
  kind: op.kind,
@@ -1937,12 +2351,11 @@ function relativeTemplate(source) {
1937
2351
  }
1938
2352
 
1939
2353
  // src/installer/commands/doctor.js
1940
- init_lock();
1941
- import { existsSync as existsSync9, readFileSync as readFileSync3 } from "node:fs";
2354
+ import { existsSync as existsSync12, readFileSync as readFileSync5 } from "node:fs";
1942
2355
  import { spawnSync as spawnSync3 } from "node:child_process";
1943
2356
  init_hash();
2357
+ import { resolve as resolve11 } from "node:path";
1944
2358
  init_root_config();
1945
- import { resolve as resolve10 } from "node:path";
1946
2359
 
1947
2360
  // src/installer/report.js
1948
2361
  var REPORT_VERSION = 1;
@@ -2015,14 +2428,77 @@ function checkGhAuth() {
2015
2428
  detail: r.status === 0 ? "authenticated (token)" : (r.stderr || r.stdout || "").trim() || "no session"
2016
2429
  };
2017
2430
  }
2431
+ function checkPackageIntegrity() {
2432
+ try {
2433
+ validateCatalog();
2434
+ return { name: "package integrity", ok: true, detail: `${CATALOG.length} catalog entries` };
2435
+ } catch (e) {
2436
+ return {
2437
+ name: "package integrity",
2438
+ ok: false,
2439
+ detail: `${e?.message ?? e}: ${(e?.issues ?? []).map((i) => i.message).join("; ")}`
2440
+ };
2441
+ }
2442
+ }
2443
+ function buildSourceHashIndex() {
2444
+ const idx = /* @__PURE__ */ new Map();
2445
+ for (const entry of CATALOG) {
2446
+ if (!existsSync12(entry.source)) continue;
2447
+ try {
2448
+ const buf = readFileSync5(entry.source, "utf8");
2449
+ idx.set(entry.source, bytesHashString(buf));
2450
+ } catch {
2451
+ }
2452
+ }
2453
+ return idx;
2454
+ }
2455
+ function checkCatalogInstall(repoRoot, sourceHashes) {
2456
+ const rows = [];
2457
+ for (const entry of CATALOG) {
2458
+ const target = resolve11(repoRoot, entry.path);
2459
+ if (!existsSync12(target)) {
2460
+ rows.push(`${entry.id}: missing`);
2461
+ continue;
2462
+ }
2463
+ try {
2464
+ const buf = readFileSync5(target, "utf8");
2465
+ const actual = bytesHashString(buf);
2466
+ const expected = sourceHashes.get(entry.source);
2467
+ if (expected && expected !== actual) {
2468
+ rows.push(`${entry.id}: drift`);
2469
+ } else {
2470
+ rows.push(`${entry.id}: ok`);
2471
+ }
2472
+ } catch (e) {
2473
+ rows.push(`${entry.id}: ${e?.message ?? e}`);
2474
+ }
2475
+ }
2476
+ const allOk = rows.length === 0 || rows.every((r) => r.endsWith("ok"));
2477
+ return {
2478
+ name: "catalog assets present",
2479
+ ok: allOk,
2480
+ detail: rows.join(",")
2481
+ };
2482
+ }
2018
2483
  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);
2484
+ const result = await readValidatedLock(repoRoot);
2485
+ if (result.kind === "missing") {
2486
+ return { name: "lock present", ok: false, detail: "no lock" };
2487
+ }
2488
+ if (result.kind === "schema") {
2489
+ return { name: "lock present", ok: false, detail: `unsupported schema: ${result.issues.join("; ")}` };
2490
+ }
2491
+ if (result.kind === "integrity") {
2492
+ return { name: "lock present", ok: false, detail: `integrity: ${result.issues.join("; ")}` };
2493
+ }
2494
+ if (result.kind === "shape") {
2495
+ return { name: "lock present", ok: false, detail: `malformed: ${result.issues.join("; ")}` };
2496
+ }
2497
+ const lock = result.lock;
2022
2498
  return {
2023
2499
  name: "lock present",
2024
- ok: integrityOk,
2025
- detail: integrityOk ? `v0.2 manager@${lock.manager?.version ?? "?"}` : "integrity mismatch"
2500
+ ok: true,
2501
+ detail: `manager@${lock.manager?.version ?? "?"} schema=${lock.manager?.schemaVersion ?? "?"}`
2026
2502
  };
2027
2503
  }
2028
2504
  async function checkConfig(repoRoot) {
@@ -2033,50 +2509,41 @@ async function checkConfig(repoRoot) {
2033
2509
  detail: r?.ok ? "loaded" : r?.error?.kind ?? "missing"
2034
2510
  };
2035
2511
  }
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 };
2512
+ async function checkManagedHashes(repoRoot, validatedLock) {
2513
+ if (validatedLock.kind !== "ok" || !validatedLock.lock) {
2514
+ return { name: "managed hashes", ok: false, detail: "no usable lock" };
2044
2515
  }
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
2516
  const drift = [];
2068
- for (const entry of lock.files ?? []) {
2069
- const p = resolve10(repoRoot, entry.path);
2070
- if (!existsSync9(p)) {
2517
+ for (const entry of validatedLock.lock.files ?? []) {
2518
+ const p = resolve11(repoRoot, entry.path);
2519
+ if (!existsSync12(p)) {
2071
2520
  drift.push(`missing:${entry.path}`);
2072
2521
  continue;
2073
2522
  }
2074
- const buf = readFileSync3(p, "utf8");
2523
+ const buf = readFileSync5(p, "utf8");
2075
2524
  const actual = bytesHashString(buf);
2076
2525
  if (actual !== entry.sha256) drift.push(`drift:${entry.path}`);
2077
2526
  }
2078
2527
  return { name: "managed hashes", ok: drift.length === 0, detail: drift.length ? drift.join(",") : "match" };
2079
2528
  }
2529
+ async function checkActiveProfileFootprint(repoRoot, validatedLock, profile) {
2530
+ if (validatedLock.kind !== "ok" || !validatedLock.lock) {
2531
+ return { name: "profile footprint", ok: true, detail: "no lock; n/a" };
2532
+ }
2533
+ if (!profile) {
2534
+ return { name: "profile footprint", ok: true, detail: "no profile; n/a" };
2535
+ }
2536
+ const expectedPaths = new Set(
2537
+ filterCatalogByProfile(CATALOG, profile).map((e) => e.path)
2538
+ );
2539
+ const present = (validatedLock.lock.files ?? []).filter((f) => expectedPaths.has(f.path)).map((f) => f.path);
2540
+ const missing = [...expectedPaths].filter((p) => !present.includes(p));
2541
+ return {
2542
+ name: "profile footprint",
2543
+ ok: missing.length === 0,
2544
+ detail: missing.length ? `missing profile assets: ${missing.join(",")}` : `${present.length}/${expectedPaths.size} present`
2545
+ };
2546
+ }
2080
2547
  async function checkRootConfig(repoRoot) {
2081
2548
  const { findRootConfig: findRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
2082
2549
  const candidate = findRootConfig2(repoRoot);
@@ -2099,7 +2566,7 @@ function writeEnvelope({ command, plan, summary, diagnostics, json, exitCode })
2099
2566
  process.stdout.write(renderHuman({ command, plan, conflicts, summary, diagnostics }) + "\n");
2100
2567
  }
2101
2568
  }
2102
- async function runDoctor({ rootPath, json, writeOutput = true }) {
2569
+ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
2103
2570
  const detection = detectProject(rootPath ?? process.cwd());
2104
2571
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2105
2572
  const issues2 = ["not in a git repository"];
@@ -2117,17 +2584,27 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2117
2584
  return { issues: issues2, exitCode: 2, plan: plan2, checks: checks2 };
2118
2585
  }
2119
2586
  const repoRoot = detection.repoRoot;
2587
+ const sourceHashes = buildSourceHashIndex();
2588
+ const validatedLock = await readValidatedLock(repoRoot);
2589
+ const packageIntegrity = checkPackageIntegrity();
2590
+ const configResult = await loadConfig(repoRoot);
2591
+ const configValue = configResult?.ok ? configResult.value : null;
2592
+ const resolved = resolveProfile({
2593
+ cli: profile,
2594
+ config: configValue,
2595
+ lock: validatedLock.lock
2596
+ });
2120
2597
  const checks = [
2121
2598
  checkNode(),
2122
2599
  checkGit(),
2123
2600
  checkGh(),
2124
2601
  checkGhAuth(),
2602
+ packageIntegrity,
2603
+ checkCatalogInstall(repoRoot, sourceHashes),
2125
2604
  await checkLock(repoRoot),
2126
2605
  await checkConfig(repoRoot),
2127
- checkPlugin(repoRoot),
2128
- checkAgents(repoRoot),
2129
- checkSkills(repoRoot),
2130
- await checkManagedHashes(repoRoot),
2606
+ await checkManagedHashes(repoRoot, validatedLock),
2607
+ await checkActiveProfileFootprint(repoRoot, validatedLock, resolved.profile),
2131
2608
  await checkRootConfig(repoRoot)
2132
2609
  ];
2133
2610
  const issues = checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
@@ -2139,21 +2616,39 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2139
2616
  reason: c.detail
2140
2617
  }));
2141
2618
  const summary = summarise2(plan);
2142
- const exitCode = issues.length === 0 ? 0 : 1;
2619
+ let exitCode = 1;
2620
+ if (issues.length === 0) exitCode = 0;
2621
+ if (!packageIntegrity.ok) exitCode = 4;
2622
+ if (validatedLock.kind === "schema") exitCode = 5;
2143
2623
  if (writeOutput) writeEnvelope({ command: "doctor", plan, summary, diagnostics: issues, json, exitCode });
2144
2624
  process.exitCode = exitCode;
2145
- return { issues, exitCode, plan, checks };
2625
+ return { issues, exitCode, plan, checks, profile: resolved };
2146
2626
  }
2147
2627
 
2148
2628
  // src/installer/commands/init.js
2149
2629
  async function runInit(options) {
2630
+ try {
2631
+ validateCatalog();
2632
+ } catch (e) {
2633
+ if (e?.catalogValidation) {
2634
+ return emitFailure(4, `catalog validation failed: ${e.message}`, options.json, "init");
2635
+ }
2636
+ throw e;
2637
+ }
2150
2638
  const preview = await previewInstall({
2151
2639
  rootPath: options.rootPath ?? null,
2640
+ profile: options.profile ?? null,
2152
2641
  replaceManaged: false,
2153
2642
  forceConfig: Boolean(options.forceConfig),
2154
2643
  forceRootConfig: Boolean(options.forceRootConfig)
2155
2644
  });
2156
2645
  if (!preview.ok) {
2646
+ if (preview.error?.kind === "unsupported-lock-schema") {
2647
+ return emitFailure(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2648
+ }
2649
+ if (preview.error?.kind === "lock-invalid") {
2650
+ return emitFailure(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2651
+ }
2157
2652
  return emitFailure(2, preview.error?.kind ?? "invalid-project", options.json, "init");
2158
2653
  }
2159
2654
  const committed = await commitInstall(preview, { json: options.json, command: "init" });
@@ -2166,6 +2661,7 @@ async function runInit(options) {
2166
2661
  }
2167
2662
  const doctor = await runDoctor({
2168
2663
  rootPath: options.rootPath ?? null,
2664
+ profile: options.profile ?? null,
2169
2665
  json: Boolean(options.json),
2170
2666
  writeOutput: false
2171
2667
  });
@@ -2245,13 +2741,40 @@ function serializePlan2(plan) {
2245
2741
  });
2246
2742
  }
2247
2743
  async function runDiff(options) {
2744
+ try {
2745
+ validateCatalog();
2746
+ } catch (e) {
2747
+ if (e?.catalogValidation) {
2748
+ if (options.json) {
2749
+ process.stdout.write(JSON.stringify({
2750
+ reportVersion: 1,
2751
+ command: "diff",
2752
+ status: "error",
2753
+ plan: [],
2754
+ conflicts: [],
2755
+ summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
2756
+ diagnostics: [`catalog validation failed: ${e.message}`],
2757
+ exitCode: 4
2758
+ }, null, 2) + "\n");
2759
+ } else {
2760
+ process.stdout.write(`opencode-ship: catalog validation failed: ${e.message}
2761
+ `);
2762
+ }
2763
+ process.exitCode = 4;
2764
+ return { ok: false, exitCode: 4 };
2765
+ }
2766
+ throw e;
2767
+ }
2248
2768
  const preview = await previewInstall({
2249
2769
  rootPath: options.rootPath ?? null,
2770
+ profile: options.profile ?? null,
2250
2771
  replaceManaged: false,
2251
2772
  forceConfig: false,
2252
2773
  forceRootConfig: false
2253
2774
  });
2254
2775
  if (!preview.ok) {
2776
+ const exitCode = preview.error?.kind === "unsupported-lock-schema" ? 5 : preview.error?.kind === "lock-invalid" ? 3 : 2;
2777
+ 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
2778
  if (options.json) {
2256
2779
  process.stdout.write(JSON.stringify({
2257
2780
  reportVersion: 1,
@@ -2260,15 +2783,15 @@ async function runDiff(options) {
2260
2783
  plan: [],
2261
2784
  conflicts: [],
2262
2785
  summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
2263
- diagnostics: [preview.error?.kind ?? "invalid-project"],
2264
- exitCode: 2
2786
+ diagnostics: [message],
2787
+ exitCode
2265
2788
  }, null, 2) + "\n");
2266
2789
  } else {
2267
- process.stdout.write(`opencode-ship: ${preview.error?.kind ?? "invalid-project"}
2790
+ process.stdout.write(`opencode-ship: ${message}
2268
2791
  `);
2269
2792
  }
2270
- process.exitCode = 2;
2271
- return { ok: false, exitCode: 2 };
2793
+ process.exitCode = exitCode;
2794
+ return { ok: false, exitCode };
2272
2795
  }
2273
2796
  const { plan, conflicts, migrationReport } = preview;
2274
2797
  const summary = summarise3(plan);
@@ -2305,13 +2828,28 @@ async function runDiff(options) {
2305
2828
 
2306
2829
  // src/installer/commands/update.js
2307
2830
  async function runUpdate(options) {
2831
+ try {
2832
+ validateCatalog();
2833
+ } catch (e) {
2834
+ if (e?.catalogValidation) {
2835
+ return emitFailure2(4, `catalog validation failed: ${e.message}`, options.json, "update");
2836
+ }
2837
+ throw e;
2838
+ }
2308
2839
  const preview = await previewInstall({
2309
2840
  rootPath: options.rootPath,
2841
+ profile: options.profile ?? null,
2310
2842
  replaceManaged: options.replaceManaged,
2311
2843
  forceConfig: options.forceConfig,
2312
2844
  forceRootConfig: options.forceRootConfig
2313
2845
  });
2314
2846
  if (!preview.ok) {
2847
+ if (preview.error?.kind === "unsupported-lock-schema") {
2848
+ return emitFailure2(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json, "update");
2849
+ }
2850
+ if (preview.error?.kind === "lock-invalid") {
2851
+ return emitFailure2(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json, "update");
2852
+ }
2315
2853
  return emitFailure2(2, preview.error?.kind ?? "invalid-project", options.json, "update");
2316
2854
  }
2317
2855
  if (preview.conflicts.length > 0 && !options.replaceManaged) {
@@ -2359,11 +2897,17 @@ function emitFailure2(code, message, json, command) {
2359
2897
 
2360
2898
  // src/installer/commands/uninstall.js
2361
2899
  import { unlink as unlink3 } from "node:fs/promises";
2362
- import { existsSync as existsSync10 } from "node:fs";
2363
- import { resolve as resolve11 } from "node:path";
2900
+ import { existsSync as existsSync13 } from "node:fs";
2901
+ import { resolve as resolve12 } from "node:path";
2364
2902
  async function runUninstall(options) {
2365
- const preview = await previewUninstall({ rootPath: options.rootPath });
2903
+ const preview = await previewUninstall({ rootPath: options.rootPath, profile: options.profile ?? null });
2366
2904
  if (!preview.ok) {
2905
+ if (preview.error?.kind === "unsupported-lock-schema") {
2906
+ return emitFailure3(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json);
2907
+ }
2908
+ if (preview.error?.kind === "lock-invalid") {
2909
+ return emitFailure3(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json);
2910
+ }
2367
2911
  return emitFailure3(2, preview.error?.kind ?? "invalid-project", options.json);
2368
2912
  }
2369
2913
  const { repoRoot, plan, conflicts, summary } = preview;
@@ -2374,8 +2918,8 @@ async function runUninstall(options) {
2374
2918
  if (!tx.ok) {
2375
2919
  return emitFailure3(4, tx.error?.message ?? "transaction failure", options.json);
2376
2920
  }
2377
- const lockPath2 = resolve11(repoRoot, ".opencode", "ship.lock.json");
2378
- if (existsSync10(lockPath2)) await unlink3(lockPath2).catch(() => null);
2921
+ const lockPath2 = resolve12(repoRoot, ".opencode", "ship.lock.json");
2922
+ if (existsSync13(lockPath2)) await unlink3(lockPath2).catch(() => null);
2379
2923
  if (options.purgeConfig) {
2380
2924
  await unlink3(configPath(repoRoot)).catch(() => null);
2381
2925
  }
@@ -2432,7 +2976,7 @@ function emitReport(plan, conflicts, summary, json, exitCode, diagnostics) {
2432
2976
  }
2433
2977
 
2434
2978
  // src/cli.js
2435
- var VERSION = "0.2.1";
2979
+ var VERSION = PACKAGE_VERSION;
2436
2980
  async function main() {
2437
2981
  const parsed = parseCommand(process.argv.slice(2));
2438
2982
  if (parsed.command === "help") {
@@ -2447,7 +2991,7 @@ async function main() {
2447
2991
  return;
2448
2992
  }
2449
2993
  if ("error" in parsed) {
2450
- process.stdout.write(`opencode-ship: ${parsed.error}
2994
+ process.stderr.write(`opencode-ship: ${parsed.error}
2451
2995
 
2452
2996
  ${helpText()}`);
2453
2997
  process.exitCode = 2;
@@ -2455,21 +2999,22 @@ ${helpText()}`);
2455
2999
  }
2456
3000
  const options = parsed.options ?? {};
2457
3001
  const opts = options;
3002
+ const profile = opts.profile ?? null;
2458
3003
  switch (parsed.command) {
2459
3004
  case "init":
2460
- await runInit({ json: !!opts.json, rootPath: opts.rootPath, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
3005
+ await runInit({ json: !!opts.json, rootPath: opts.rootPath, profile, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
2461
3006
  return;
2462
3007
  case "diff":
2463
- await runDiff({ json: !!opts.json, rootPath: opts.rootPath });
3008
+ await runDiff({ json: !!opts.json, rootPath: opts.rootPath, profile });
2464
3009
  return;
2465
3010
  case "update":
2466
- await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
3011
+ await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, profile, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
2467
3012
  return;
2468
3013
  case "doctor":
2469
- await runDoctor({ json: !!opts.json, rootPath: opts.rootPath });
3014
+ await runDoctor({ json: !!opts.json, rootPath: opts.rootPath, profile });
2470
3015
  return;
2471
3016
  case "uninstall":
2472
- await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, purgeConfig: !!opts.purgeConfig });
3017
+ await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, profile, purgeConfig: !!opts.purgeConfig });
2473
3018
  return;
2474
3019
  default:
2475
3020
  process.stdout.write(helpText() + "\n");