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