opencode-ship 0.2.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 +57 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/assets/agents/delivery-reviewer.md +80 -0
- package/assets/agents/delivery-verifier.md +61 -0
- package/assets/skills/delivery-workflow/SKILL.md +62 -0
- package/assets/skills/planning-research-checkpoint/SKILL.md +37 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2484 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +1851 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +15060 -0
- package/docs/adr/0001-opencode-ship-redesign.md +168 -0
- package/package.json +62 -0
- package/schema/project-adapter.example.json +50 -0
- package/schema/project-adapter.schema.json +132 -0
- package/schema/project-opencode-shim.json +42 -0
- package/schema/ship-config.schema.json +136 -0
- package/schema/ship-lock.schema.json +112 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// opencode-ship CLI v0.2.0
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
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
|
+
// src/installer/json-pointer.js
|
|
117
|
+
var json_pointer_exports = {};
|
|
118
|
+
__export(json_pointer_exports, {
|
|
119
|
+
getPointer: () => getPointer,
|
|
120
|
+
parsePointer: () => parsePointer,
|
|
121
|
+
pointerPath: () => pointerPath,
|
|
122
|
+
removePointer: () => removePointer,
|
|
123
|
+
setPointer: () => setPointer,
|
|
124
|
+
stableStringify: () => stableStringify
|
|
125
|
+
});
|
|
126
|
+
function unescape(token) {
|
|
127
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
128
|
+
}
|
|
129
|
+
function escape(token) {
|
|
130
|
+
return token.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
131
|
+
}
|
|
132
|
+
function* parsePointer(pointer) {
|
|
133
|
+
if (typeof pointer !== "string" || pointer === "") return;
|
|
134
|
+
if (pointer[0] !== "/") {
|
|
135
|
+
yield "";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const trimmed = pointer.slice(1);
|
|
139
|
+
if (trimmed === "") return;
|
|
140
|
+
for (const token of trimmed.split("/")) yield unescape(token);
|
|
141
|
+
}
|
|
142
|
+
function getPointer(doc, pointer) {
|
|
143
|
+
if (pointer === "" || pointer === "/") return void 0;
|
|
144
|
+
let current = doc;
|
|
145
|
+
for (const token of parsePointer(pointer)) {
|
|
146
|
+
if (current === null || current === void 0) return void 0;
|
|
147
|
+
if (typeof token === "string" && token.includes("=")) return void 0;
|
|
148
|
+
current = current?.[token];
|
|
149
|
+
}
|
|
150
|
+
return current;
|
|
151
|
+
}
|
|
152
|
+
function setPointer(doc, pointer, value) {
|
|
153
|
+
const tokens = [...parsePointer(pointer)];
|
|
154
|
+
if (tokens.length === 0) return value;
|
|
155
|
+
if (!isObject(doc)) return value;
|
|
156
|
+
const root = { ...doc };
|
|
157
|
+
let cursor = root;
|
|
158
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
159
|
+
const key = tokens[i];
|
|
160
|
+
const next = cursor[key];
|
|
161
|
+
const copy = isObject(next) ? { ...next } : Array.isArray(next) ? [...next] : {};
|
|
162
|
+
cursor[key] = copy;
|
|
163
|
+
cursor = copy;
|
|
164
|
+
}
|
|
165
|
+
cursor[tokens[tokens.length - 1]] = value;
|
|
166
|
+
return root;
|
|
167
|
+
}
|
|
168
|
+
function removePointer(doc, pointer) {
|
|
169
|
+
const tokens = [...parsePointer(pointer)];
|
|
170
|
+
if (tokens.length === 0) return doc;
|
|
171
|
+
if (!isObject(doc)) return doc;
|
|
172
|
+
const root = { ...doc };
|
|
173
|
+
let cursor = root;
|
|
174
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
175
|
+
const key = tokens[i];
|
|
176
|
+
const copy = isObject(cursor[key]) ? { ...cursor[key] } : { ...cursor[key] };
|
|
177
|
+
cursor[key] = copy;
|
|
178
|
+
cursor = copy;
|
|
179
|
+
}
|
|
180
|
+
delete cursor[tokens[tokens.length - 1]];
|
|
181
|
+
return root;
|
|
182
|
+
}
|
|
183
|
+
function isObject(v) {
|
|
184
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
185
|
+
}
|
|
186
|
+
function stableStringify(value) {
|
|
187
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
188
|
+
if (isObject(value)) {
|
|
189
|
+
const keys = Object.keys(value).sort();
|
|
190
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
191
|
+
}
|
|
192
|
+
return JSON.stringify(value);
|
|
193
|
+
}
|
|
194
|
+
function pointerPath(segments) {
|
|
195
|
+
return "/" + segments.map((s) => escape(String(s))).join("/");
|
|
196
|
+
}
|
|
197
|
+
var init_json_pointer = __esm({
|
|
198
|
+
"src/installer/json-pointer.js"() {
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// src/installer/hash.js
|
|
203
|
+
import { createHash } from "node:crypto";
|
|
204
|
+
function bytesHash(buffer) {
|
|
205
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
206
|
+
}
|
|
207
|
+
function bytesHashString(text) {
|
|
208
|
+
return bytesHash(Buffer.from(text, "utf8"));
|
|
209
|
+
}
|
|
210
|
+
var init_hash = __esm({
|
|
211
|
+
"src/installer/hash.js"() {
|
|
212
|
+
init_json_pointer();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// src/installer/root-config.js
|
|
217
|
+
var root_config_exports = {};
|
|
218
|
+
__export(root_config_exports, {
|
|
219
|
+
applyOwnedPointers: () => applyOwnedPointers,
|
|
220
|
+
defaultRootConfigPath: () => defaultRootConfigPath,
|
|
221
|
+
findRootConfig: () => findRootConfig,
|
|
222
|
+
formatRootConfig: () => formatRootConfig,
|
|
223
|
+
formatRootConfigPreserving: () => formatRootConfigPreserving,
|
|
224
|
+
parseRootConfigPreservingOrder: () => parseRootConfigPreservingOrder,
|
|
225
|
+
readRootConfig: () => readRootConfig,
|
|
226
|
+
synthesizeDefaultRootConfig: () => synthesizeDefaultRootConfig
|
|
227
|
+
});
|
|
228
|
+
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
229
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
230
|
+
import { resolve as resolve3 } from "node:path";
|
|
231
|
+
function findRootConfig(repoRoot) {
|
|
232
|
+
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" };
|
|
235
|
+
}
|
|
236
|
+
return { path: null, relative: ROOT_PATH_CANDIDATES[0], format: "json" };
|
|
237
|
+
}
|
|
238
|
+
function defaultRootConfigPath(repoRoot) {
|
|
239
|
+
return resolve3(repoRoot, ROOT_PATH_CANDIDATES[0]);
|
|
240
|
+
}
|
|
241
|
+
function readRootConfig(absPath) {
|
|
242
|
+
if (!existsSync2(absPath)) {
|
|
243
|
+
return { ok: false, error: { kind: "missing", path: absPath } };
|
|
244
|
+
}
|
|
245
|
+
const raw = readFileSync(absPath, "utf8");
|
|
246
|
+
const stripped = stripJsonc(raw);
|
|
247
|
+
try {
|
|
248
|
+
const value = JSON.parse(stripped);
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
path: absPath,
|
|
252
|
+
raw,
|
|
253
|
+
sha256: bytesHashString(raw),
|
|
254
|
+
value,
|
|
255
|
+
before: snapshotValues(value),
|
|
256
|
+
format: absPath.endsWith(".jsonc") ? "jsonc" : "json"
|
|
257
|
+
};
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return { ok: false, error: { kind: "parse", path: absPath, message: e.message } };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function snapshotValues(doc) {
|
|
263
|
+
const out = {};
|
|
264
|
+
for (const entry of POINTER_ENTRIES) {
|
|
265
|
+
out[entry.pointer] = getPointer(doc, entry.pointer);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
function stripJsonc(text) {
|
|
270
|
+
let stripped = "";
|
|
271
|
+
let i = 0;
|
|
272
|
+
let inString = false;
|
|
273
|
+
let escape2 = false;
|
|
274
|
+
while (i < text.length) {
|
|
275
|
+
const ch = text[i];
|
|
276
|
+
if (inString) {
|
|
277
|
+
stripped += ch;
|
|
278
|
+
if (escape2) {
|
|
279
|
+
escape2 = false;
|
|
280
|
+
} else if (ch === "\\") {
|
|
281
|
+
escape2 = true;
|
|
282
|
+
} else if (ch === '"') {
|
|
283
|
+
inString = false;
|
|
284
|
+
}
|
|
285
|
+
i += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ch === '"') {
|
|
289
|
+
inString = true;
|
|
290
|
+
stripped += ch;
|
|
291
|
+
i += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
295
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
299
|
+
i += 2;
|
|
300
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
|
301
|
+
i += 2;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
stripped += ch;
|
|
305
|
+
i += 1;
|
|
306
|
+
}
|
|
307
|
+
return stripped.replace(/,\s*([}\]])/g, "$1");
|
|
308
|
+
}
|
|
309
|
+
function applyOwnedPointers(rootDoc, { pointerEntries = POINTER_ENTRIES, allowEqualValues = true } = {}) {
|
|
310
|
+
const result = { doc: rootDoc, applied: [], skipped: [] };
|
|
311
|
+
let doc = rootDoc;
|
|
312
|
+
for (const entry of pointerEntries) {
|
|
313
|
+
const existing = getPointer(doc, entry.pointer);
|
|
314
|
+
if (existing === void 0) {
|
|
315
|
+
doc = setPointer(doc, entry.pointer, entry.value);
|
|
316
|
+
result.applied.push({ pointer: entry.pointer, value: entry.value });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (existing === entry.value) {
|
|
320
|
+
if (allowEqualValues) {
|
|
321
|
+
result.skipped.push({ pointer: entry.pointer, reason: "already equal" });
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
result.skipped.push({
|
|
326
|
+
pointer: entry.pointer,
|
|
327
|
+
reason: "different existing value",
|
|
328
|
+
existing,
|
|
329
|
+
desired: entry.value
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
function synthesizeDefaultRootConfig() {
|
|
335
|
+
return {
|
|
336
|
+
$schema: "https://opencode.ai/config.json",
|
|
337
|
+
agent: {
|
|
338
|
+
build: {
|
|
339
|
+
permission: {
|
|
340
|
+
delivery_inspect: "allow",
|
|
341
|
+
delivery_issue: "allow",
|
|
342
|
+
delivery_worktree: "allow",
|
|
343
|
+
delivery_verify: "deny",
|
|
344
|
+
delivery_review: "deny",
|
|
345
|
+
delivery_pr: "allow",
|
|
346
|
+
delivery_ready: "allow",
|
|
347
|
+
delivery_merge: "ask",
|
|
348
|
+
delivery_cleanup: "allow",
|
|
349
|
+
task: {
|
|
350
|
+
"delivery-reviewer": "allow",
|
|
351
|
+
"delivery-verifier": "allow"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function formatRootConfig(value) {
|
|
359
|
+
return JSON.stringify(stripSourceOrder(value), null, 2) + "\n";
|
|
360
|
+
}
|
|
361
|
+
function stripSourceOrder(value) {
|
|
362
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
363
|
+
const out = Array.isArray(value) ? [] : {};
|
|
364
|
+
const order = Array.isArray(value.__sourceOrder__) ? value.__sourceOrder__ : null;
|
|
365
|
+
const seen = /* @__PURE__ */ new Set();
|
|
366
|
+
if (order) {
|
|
367
|
+
for (const k of order) {
|
|
368
|
+
if (k === "__sourceOrder__") continue;
|
|
369
|
+
if (!(k in value)) continue;
|
|
370
|
+
seen.add(k);
|
|
371
|
+
out[k] = stripSourceOrder(value[k]);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const k of Object.keys(value)) {
|
|
375
|
+
if (k === "__sourceOrder__") continue;
|
|
376
|
+
if (seen.has(k)) continue;
|
|
377
|
+
out[k] = stripSourceOrder(value[k]);
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
function formatRootConfigPreserving(value) {
|
|
382
|
+
return formatRootConfig(value);
|
|
383
|
+
}
|
|
384
|
+
function parseRootConfigPreservingOrder(text) {
|
|
385
|
+
const parser = new RootConfigParser(text);
|
|
386
|
+
const value = parser.parseValue(
|
|
387
|
+
0,
|
|
388
|
+
/*atTop*/
|
|
389
|
+
true
|
|
390
|
+
);
|
|
391
|
+
const isJsonc = text.includes("//") || text.includes("/*");
|
|
392
|
+
return { value, format: isJsonc ? "jsonc" : "json" };
|
|
393
|
+
}
|
|
394
|
+
var ROOT_PATH_CANDIDATES, RootConfigParser;
|
|
395
|
+
var init_root_config = __esm({
|
|
396
|
+
"src/installer/root-config.js"() {
|
|
397
|
+
init_json_pointer();
|
|
398
|
+
init_hash();
|
|
399
|
+
init_catalog();
|
|
400
|
+
ROOT_PATH_CANDIDATES = ["opencode.json", "opencode.jsonc"];
|
|
401
|
+
RootConfigParser = class {
|
|
402
|
+
constructor(text) {
|
|
403
|
+
this.text = text;
|
|
404
|
+
this.pos = 0;
|
|
405
|
+
}
|
|
406
|
+
skipWS() {
|
|
407
|
+
while (this.pos < this.text.length) {
|
|
408
|
+
const ch = this.text[this.pos];
|
|
409
|
+
if (ch === " " || ch === "\n" || ch === " " || ch === "\r") {
|
|
410
|
+
this.pos += 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === "/" && this.text[this.pos + 1] === "/") {
|
|
414
|
+
while (this.pos < this.text.length && this.text[this.pos] !== "\n") this.pos += 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (ch === "/" && this.text[this.pos + 1] === "*") {
|
|
418
|
+
this.pos += 2;
|
|
419
|
+
while (this.pos < this.text.length && !(this.text[this.pos] === "*" && this.text[this.pos + 1] === "/")) this.pos += 1;
|
|
420
|
+
this.pos += 2;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
parseValue(depth, atTop) {
|
|
427
|
+
this.skipWS();
|
|
428
|
+
const ch = this.text[this.pos];
|
|
429
|
+
if (ch === "{") return this.parseObject(depth, atTop);
|
|
430
|
+
if (ch === "[") return this.parseArray(depth);
|
|
431
|
+
if (ch === '"') return this.parseString();
|
|
432
|
+
if (ch === "-" || ch >= "0" && ch <= "9") return this.parseNumber();
|
|
433
|
+
if (this.text.startsWith("true", this.pos)) {
|
|
434
|
+
this.pos += 4;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (this.text.startsWith("false", this.pos)) {
|
|
438
|
+
this.pos += 5;
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
if (this.text.startsWith("null", this.pos)) {
|
|
442
|
+
this.pos += 4;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`unexpected token at ${this.pos}: ${this.text.slice(this.pos, this.pos + 8)}`);
|
|
446
|
+
}
|
|
447
|
+
parseObject(depth, atTop) {
|
|
448
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
449
|
+
out.__sourceOrder__ = [];
|
|
450
|
+
this.pos += 1;
|
|
451
|
+
while (this.pos < this.text.length) {
|
|
452
|
+
this.skipWS();
|
|
453
|
+
if (this.text[this.pos] === "}") {
|
|
454
|
+
this.pos += 1;
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
const key = this.parseString();
|
|
458
|
+
out.__sourceOrder__.push(key);
|
|
459
|
+
this.skipWS();
|
|
460
|
+
if (this.text[this.pos] !== ":") throw new Error(`expected : at ${this.pos}`);
|
|
461
|
+
this.pos += 1;
|
|
462
|
+
out[key] = this.parseValue(depth + 1, false);
|
|
463
|
+
this.skipWS();
|
|
464
|
+
if (this.text[this.pos] === ",") {
|
|
465
|
+
this.pos += 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (this.text[this.pos] === "}") {
|
|
469
|
+
this.pos += 1;
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
throw new Error(`expected , or } at ${this.pos}`);
|
|
473
|
+
}
|
|
474
|
+
throw new Error("unterminated object");
|
|
475
|
+
}
|
|
476
|
+
parseArray(depth) {
|
|
477
|
+
const out = [];
|
|
478
|
+
this.pos += 1;
|
|
479
|
+
while (this.pos < this.text.length) {
|
|
480
|
+
this.skipWS();
|
|
481
|
+
if (this.text[this.pos] === "]") {
|
|
482
|
+
this.pos += 1;
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
out.push(this.parseValue(depth + 1, false));
|
|
486
|
+
this.skipWS();
|
|
487
|
+
if (this.text[this.pos] === ",") {
|
|
488
|
+
this.pos += 1;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (this.text[this.pos] === "]") {
|
|
492
|
+
this.pos += 1;
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
throw new Error(`expected , or ] at ${this.pos}`);
|
|
496
|
+
}
|
|
497
|
+
throw new Error("unterminated array");
|
|
498
|
+
}
|
|
499
|
+
parseString() {
|
|
500
|
+
if (this.text[this.pos] !== '"') throw new Error(`expected " at ${this.pos}`);
|
|
501
|
+
this.pos += 1;
|
|
502
|
+
let out = "";
|
|
503
|
+
while (this.pos < this.text.length) {
|
|
504
|
+
const ch = this.text[this.pos];
|
|
505
|
+
if (ch === "\\") {
|
|
506
|
+
const next = this.text[this.pos + 1];
|
|
507
|
+
out += ch + next;
|
|
508
|
+
this.pos += 2;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (ch === '"') {
|
|
512
|
+
this.pos += 1;
|
|
513
|
+
return JSON.parse('"' + out + '"');
|
|
514
|
+
}
|
|
515
|
+
out += ch;
|
|
516
|
+
this.pos += 1;
|
|
517
|
+
}
|
|
518
|
+
throw new Error("unterminated string");
|
|
519
|
+
}
|
|
520
|
+
parseNumber() {
|
|
521
|
+
const start = this.pos;
|
|
522
|
+
if (this.text[this.pos] === "-") this.pos += 1;
|
|
523
|
+
while (this.pos < this.text.length && /[0-9.eE+\-]/.test(this.text[this.pos])) this.pos += 1;
|
|
524
|
+
return Number(this.text.slice(start, this.pos));
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
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
|
+
// src/installer/cli-args.js
|
|
608
|
+
var USAGE = `opencode-ship <command> [options]
|
|
609
|
+
|
|
610
|
+
Commands:
|
|
611
|
+
init Install or update managed files in this project.
|
|
612
|
+
diff Show what would change without writing.
|
|
613
|
+
update Apply pending updates after recovering the journal.
|
|
614
|
+
doctor Validate environment, lock, and references.
|
|
615
|
+
uninstall Remove managed files that still match the lock.
|
|
616
|
+
--version Print the version and exit.
|
|
617
|
+
--help Show this usage and exit.
|
|
618
|
+
|
|
619
|
+
Options:
|
|
620
|
+
--root <path> Project root (defaults to cwd).
|
|
621
|
+
--config <path> Override the config file location (init only).
|
|
622
|
+
--force-config Rewrite the user config from detection (init only).
|
|
623
|
+
--force-root-config Create opencode.json when absent (init only).
|
|
624
|
+
--strict-doctor Fail init when doctor reports unhealthy checks.
|
|
625
|
+
--replace-managed Replace locally-modified managed files (update only).
|
|
626
|
+
--purge-config Remove ship.config.json when uninstalling.
|
|
627
|
+
--json Emit a JSON envelope instead of human output.
|
|
628
|
+
`;
|
|
629
|
+
function parseFlags(argv) {
|
|
630
|
+
const options = {
|
|
631
|
+
rootPath: null,
|
|
632
|
+
configPath: null,
|
|
633
|
+
json: false,
|
|
634
|
+
replaceManaged: false,
|
|
635
|
+
purgeConfig: false,
|
|
636
|
+
forceConfig: false,
|
|
637
|
+
forceRootConfig: false,
|
|
638
|
+
strictDoctor: false
|
|
639
|
+
};
|
|
640
|
+
for (let i = 0; i < argv.length; i++) {
|
|
641
|
+
const arg = argv[i];
|
|
642
|
+
if (arg === "--json") options.json = true;
|
|
643
|
+
else if (arg === "--replace-managed") options.replaceManaged = true;
|
|
644
|
+
else if (arg === "--purge-config") options.purgeConfig = true;
|
|
645
|
+
else if (arg === "--force-config") options.forceConfig = true;
|
|
646
|
+
else if (arg === "--force-root-config") options.forceRootConfig = true;
|
|
647
|
+
else if (arg === "--strict-doctor") options.strictDoctor = true;
|
|
648
|
+
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 };
|
|
651
|
+
else if (arg === "-v" || arg === "--version") return { version: true };
|
|
652
|
+
else return { error: `unknown flag ${arg}` };
|
|
653
|
+
}
|
|
654
|
+
return options;
|
|
655
|
+
}
|
|
656
|
+
function parseCommand(argv) {
|
|
657
|
+
for (const arg of argv) {
|
|
658
|
+
if (arg === "--help" || arg === "-h") return { command: "help" };
|
|
659
|
+
if (arg === "--version" || arg === "-v") return { command: "version" };
|
|
660
|
+
}
|
|
661
|
+
const [cmd, ...rest] = argv;
|
|
662
|
+
if (!cmd) return { command: "help" };
|
|
663
|
+
const flags = parseFlags(rest);
|
|
664
|
+
if ("help" in flags) return { command: "help" };
|
|
665
|
+
if ("version" in flags) return { command: "version" };
|
|
666
|
+
if ("error" in flags) return { error: flags.error };
|
|
667
|
+
switch (cmd) {
|
|
668
|
+
case "init":
|
|
669
|
+
case "diff":
|
|
670
|
+
case "update":
|
|
671
|
+
case "doctor":
|
|
672
|
+
case "uninstall":
|
|
673
|
+
return { command: cmd, options: flags };
|
|
674
|
+
default:
|
|
675
|
+
return { error: `unknown command ${cmd}` };
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function helpText() {
|
|
679
|
+
return USAGE;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/installer/executor.js
|
|
683
|
+
init_catalog();
|
|
684
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
685
|
+
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";
|
|
687
|
+
|
|
688
|
+
// src/installer/planner.js
|
|
689
|
+
init_catalog();
|
|
690
|
+
init_hash();
|
|
691
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
692
|
+
import { readFile as readFile3, stat } from "node:fs/promises";
|
|
693
|
+
|
|
694
|
+
// src/installer/config.js
|
|
695
|
+
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";
|
|
698
|
+
|
|
699
|
+
// schema/ship-config.schema.json
|
|
700
|
+
var ship_config_schema_default = {
|
|
701
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
702
|
+
$id: "https://github.com/Viktorxyz/opencode-delivery/schema/ship-config.schema.json",
|
|
703
|
+
title: "opencode-ship user config",
|
|
704
|
+
type: "object",
|
|
705
|
+
required: ["schemaVersion"],
|
|
706
|
+
additionalProperties: false,
|
|
707
|
+
properties: {
|
|
708
|
+
schemaVersion: { const: 1 },
|
|
709
|
+
owner: {
|
|
710
|
+
type: "string",
|
|
711
|
+
description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
|
|
712
|
+
},
|
|
713
|
+
project: {
|
|
714
|
+
type: "object",
|
|
715
|
+
additionalProperties: false,
|
|
716
|
+
properties: {
|
|
717
|
+
remote: { type: "string", minLength: 1 },
|
|
718
|
+
repository: { type: "string", pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
|
|
719
|
+
defaultBranch: { type: "string", minLength: 1 },
|
|
720
|
+
packageManager: { enum: ["npm", "pnpm", "yarn", "bun"] },
|
|
721
|
+
detectOverrides: { type: "boolean", description: "Permit detection to refresh previously persisted values." }
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
delivery: {
|
|
725
|
+
type: "object",
|
|
726
|
+
additionalProperties: false,
|
|
727
|
+
properties: {
|
|
728
|
+
worktree: {
|
|
729
|
+
type: "object",
|
|
730
|
+
additionalProperties: false,
|
|
731
|
+
properties: {
|
|
732
|
+
root: { type: "string", minLength: 1 },
|
|
733
|
+
branchTemplate: { type: "string", minLength: 1 },
|
|
734
|
+
bootstrap: {
|
|
735
|
+
type: "array",
|
|
736
|
+
items: {
|
|
737
|
+
type: "array",
|
|
738
|
+
items: { type: "string", minLength: 1 },
|
|
739
|
+
minItems: 1
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
verification: {
|
|
745
|
+
type: "object",
|
|
746
|
+
additionalProperties: false,
|
|
747
|
+
properties: {
|
|
748
|
+
commands: {
|
|
749
|
+
type: "array",
|
|
750
|
+
minItems: 1,
|
|
751
|
+
items: {
|
|
752
|
+
type: "object",
|
|
753
|
+
required: ["id", "argv"],
|
|
754
|
+
additionalProperties: false,
|
|
755
|
+
properties: {
|
|
756
|
+
id: { type: "string", minLength: 1 },
|
|
757
|
+
argv: {
|
|
758
|
+
type: "array",
|
|
759
|
+
items: { type: "string", minLength: 1 },
|
|
760
|
+
minItems: 1
|
|
761
|
+
},
|
|
762
|
+
timeoutMs: { type: "integer", minimum: 1 }
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
requireCleanDiffAfter: { type: "boolean" },
|
|
767
|
+
invalidateOnHeadChange: { type: "boolean" }
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
review: {
|
|
771
|
+
type: "object",
|
|
772
|
+
additionalProperties: false,
|
|
773
|
+
properties: {
|
|
774
|
+
agent: { type: "string", minLength: 1 },
|
|
775
|
+
required: { type: "boolean" },
|
|
776
|
+
invalidateOnHeadChange: { type: "boolean" }
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
ci: {
|
|
780
|
+
type: "object",
|
|
781
|
+
additionalProperties: false,
|
|
782
|
+
properties: {
|
|
783
|
+
driver: { const: "github-status-checks" },
|
|
784
|
+
requiredChecks: {
|
|
785
|
+
type: "array",
|
|
786
|
+
items: { type: "string", minLength: 1 },
|
|
787
|
+
uniqueItems: true
|
|
788
|
+
},
|
|
789
|
+
wait: { type: "boolean" },
|
|
790
|
+
flakyRetry: { type: "integer", enum: [0, 1] }
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
ready: {
|
|
794
|
+
type: "object",
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
properties: {
|
|
797
|
+
requires: {
|
|
798
|
+
type: "array",
|
|
799
|
+
items: { enum: ["review", "local-verification", "remote-ci"] },
|
|
800
|
+
uniqueItems: true
|
|
801
|
+
},
|
|
802
|
+
stopAfterReady: { type: "boolean" }
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
merge: {
|
|
806
|
+
type: "object",
|
|
807
|
+
additionalProperties: false,
|
|
808
|
+
properties: {
|
|
809
|
+
strategy: { const: "squash" },
|
|
810
|
+
policy: { const: "explicit-user-request-only" },
|
|
811
|
+
requireFreshGates: { type: "boolean" }
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
cleanup: {
|
|
815
|
+
type: "object",
|
|
816
|
+
additionalProperties: false,
|
|
817
|
+
properties: {
|
|
818
|
+
when: { const: "next-task" },
|
|
819
|
+
requireUnpublishedGuard: { type: "boolean" }
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
tasks: {
|
|
825
|
+
type: "object",
|
|
826
|
+
description: "Optional override of the managed-file paths. Use only to relocate a target.",
|
|
827
|
+
additionalProperties: false,
|
|
828
|
+
properties: {
|
|
829
|
+
pluginPath: { type: "string", pattern: "^\\.opencode/.+\\.js$" },
|
|
830
|
+
agentsDir: { type: "string", pattern: "^\\.opencode/agents/?$" },
|
|
831
|
+
skillsDir: { type: "string", pattern: "^\\.opencode/skills/?$" }
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// src/installer/validation.js
|
|
838
|
+
var FORMAT_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
|
|
839
|
+
function isObject2(v) {
|
|
840
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
841
|
+
}
|
|
842
|
+
function validate(value, schema, pointer, issues) {
|
|
843
|
+
if (!isObject2(schema)) return;
|
|
844
|
+
if (schema.const !== void 0 && value !== schema.const) {
|
|
845
|
+
issues.push(`${pointer}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (schema.enum !== void 0 && !schema.enum.includes(value)) {
|
|
849
|
+
issues.push(`${pointer}: expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const type = schema.type;
|
|
853
|
+
if (type !== void 0) {
|
|
854
|
+
const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
855
|
+
if (type !== actual) {
|
|
856
|
+
if (!(type === "integer" && typeof value === "number" && Number.isInteger(value))) {
|
|
857
|
+
issues.push(`${pointer}: expected ${type}, got ${actual}`);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (type === "string") {
|
|
863
|
+
if (schema.minLength !== void 0 && value.length < schema.minLength) {
|
|
864
|
+
issues.push(`${pointer}: shorter than minLength ${schema.minLength}`);
|
|
865
|
+
}
|
|
866
|
+
if (schema.pattern !== void 0) {
|
|
867
|
+
const re = new RegExp(schema.pattern);
|
|
868
|
+
if (!re.test(value)) issues.push(`${pointer}: does not match pattern ${schema.pattern}`);
|
|
869
|
+
}
|
|
870
|
+
if (schema.format === "date-time" && !FORMAT_DATE_TIME.test(value)) {
|
|
871
|
+
issues.push(`${pointer}: not a date-time string`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (type === "integer" || type === "number") {
|
|
875
|
+
if (schema.minimum !== void 0 && value < schema.minimum) {
|
|
876
|
+
issues.push(`${pointer}: less than minimum ${schema.minimum}`);
|
|
877
|
+
}
|
|
878
|
+
if (schema.maximum !== void 0 && value > schema.maximum) {
|
|
879
|
+
issues.push(`${pointer}: greater than maximum ${schema.maximum}`);
|
|
880
|
+
}
|
|
881
|
+
if (schema.enum !== void 0) {
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (type === "array") {
|
|
885
|
+
if (schema.minItems !== void 0 && value.length < schema.minItems) {
|
|
886
|
+
issues.push(`${pointer}: fewer items than minItems ${schema.minItems}`);
|
|
887
|
+
}
|
|
888
|
+
if (Array.isArray(schema.items)) {
|
|
889
|
+
value.forEach((entry, i) => validate(entry, schema.items[i] ?? {}, `${pointer}/${i}`, issues));
|
|
890
|
+
} else if (schema.items) {
|
|
891
|
+
if (schema.uniqueItems) {
|
|
892
|
+
const seen = /* @__PURE__ */ new Set();
|
|
893
|
+
value.forEach((entry, i) => {
|
|
894
|
+
const key = JSON.stringify(entry);
|
|
895
|
+
if (seen.has(key)) issues.push(`${pointer}/${i}: duplicate unique item`);
|
|
896
|
+
seen.add(key);
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
value.forEach((entry, i) => validate(entry, schema.items, `${pointer}/${i}`, issues));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (type === "object") {
|
|
903
|
+
if (Array.isArray(schema.required)) {
|
|
904
|
+
for (const key of schema.required) {
|
|
905
|
+
if (!(key in value)) issues.push(`${pointer}: missing required field ${key}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (schema.additionalProperties === false && isObject2(schema.properties)) {
|
|
909
|
+
for (const key of Object.keys(value)) {
|
|
910
|
+
if (!(key in schema.properties)) issues.push(`${pointer}: unknown field ${key}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (isObject2(schema.properties)) {
|
|
914
|
+
for (const key of Object.keys(schema.properties)) {
|
|
915
|
+
if (key in value) validate(value[key], schema.properties[key], `${pointer}/${key}`, issues);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function validateSchema(value, schema) {
|
|
921
|
+
const issues = [];
|
|
922
|
+
validate(value, schema, "#", issues);
|
|
923
|
+
return { ok: issues.length === 0, issues };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/installer/config.js
|
|
927
|
+
init_json_pointer();
|
|
928
|
+
init_hash();
|
|
929
|
+
function configPath(repoRoot) {
|
|
930
|
+
return resolve2(repoRoot, ".opencode", "ship.config.json");
|
|
931
|
+
}
|
|
932
|
+
async function loadConfig(repoRoot) {
|
|
933
|
+
const path = configPath(repoRoot);
|
|
934
|
+
if (!existsSync(path)) return null;
|
|
935
|
+
const raw = await readFile(path, "utf8");
|
|
936
|
+
let parsed;
|
|
937
|
+
try {
|
|
938
|
+
parsed = JSON.parse(raw);
|
|
939
|
+
} catch (e) {
|
|
940
|
+
return { ok: false, error: { kind: "parse", path, message: e.message } };
|
|
941
|
+
}
|
|
942
|
+
const validation = validateSchema(parsed, ship_config_schema_default);
|
|
943
|
+
if (!validation.ok) {
|
|
944
|
+
return { ok: false, error: { kind: "contract", path, issues: validation.issues } };
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
ok: true,
|
|
948
|
+
path,
|
|
949
|
+
raw,
|
|
950
|
+
sha256: bytesHashString(raw),
|
|
951
|
+
canonicalSha256: bytesHashString(stableStringify(parsed)),
|
|
952
|
+
value: parsed
|
|
953
|
+
};
|
|
954
|
+
}
|
|
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
|
+
function renderDefaultConfig(detection, overrides = {}) {
|
|
965
|
+
const pm = detection?.packageManager ?? "npm";
|
|
966
|
+
const safeBootstrap = Array.isArray(detection?.worktreeBootstrap) && detection.worktreeBootstrap.length ? detection.worktreeBootstrap : [["npm", "install"]];
|
|
967
|
+
const safeVerification = Array.isArray(detection?.verificationPlan) && detection.verificationPlan.length ? detection.verificationPlan.map((step) => ({ id: step.id, argv: step.argv })) : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }];
|
|
968
|
+
const repo = detection?.repository ?? overrides.repository ?? "owner/repo";
|
|
969
|
+
return {
|
|
970
|
+
schemaVersion: 1,
|
|
971
|
+
project: {
|
|
972
|
+
remote: detection?.remote ?? "origin",
|
|
973
|
+
repository: repo,
|
|
974
|
+
defaultBranch: detection?.defaultBranch ?? "main",
|
|
975
|
+
packageManager: pm,
|
|
976
|
+
detectOverrides: false
|
|
977
|
+
},
|
|
978
|
+
delivery: {
|
|
979
|
+
worktree: {
|
|
980
|
+
root: detection?.worktreeRoot ?? ".worktrees",
|
|
981
|
+
branchTemplate: "{actor}/{slug}",
|
|
982
|
+
bootstrap: safeBootstrap
|
|
983
|
+
},
|
|
984
|
+
verification: {
|
|
985
|
+
commands: safeVerification,
|
|
986
|
+
requireCleanDiffAfter: true,
|
|
987
|
+
invalidateOnHeadChange: true
|
|
988
|
+
},
|
|
989
|
+
review: { agent: "delivery-reviewer", required: true, invalidateOnHeadChange: true },
|
|
990
|
+
ci: {
|
|
991
|
+
driver: "github-status-checks",
|
|
992
|
+
requiredChecks: ["delivery-verify"],
|
|
993
|
+
wait: true,
|
|
994
|
+
flakyRetry: 1
|
|
995
|
+
},
|
|
996
|
+
ready: { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
|
|
997
|
+
merge: { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
|
|
998
|
+
cleanup: { when: "next-task", requireUnpublishedGuard: true }
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/installer/planner.js
|
|
1004
|
+
init_json_pointer();
|
|
1005
|
+
init_catalog();
|
|
1006
|
+
init_root_config();
|
|
1007
|
+
init_root_config();
|
|
1008
|
+
async function readBytes(path) {
|
|
1009
|
+
if (!existsSync3(path)) return null;
|
|
1010
|
+
const buf = await readFile3(path);
|
|
1011
|
+
const fileStat = await stat(path);
|
|
1012
|
+
return { bytes: buf, hash: bytesHashString(buf.toString("utf8")), mode: fileStat.mode & 511 };
|
|
1013
|
+
}
|
|
1014
|
+
async function readDesiredBytes(source) {
|
|
1015
|
+
if (!source || !existsSync3(source)) return null;
|
|
1016
|
+
const buf = await readFile3(source);
|
|
1017
|
+
return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
|
|
1018
|
+
}
|
|
1019
|
+
function lookupLockedFile(lock, targetPath) {
|
|
1020
|
+
if (!lock?.files) return null;
|
|
1021
|
+
return lock.files.find((entry) => entry.path === targetPath) ?? null;
|
|
1022
|
+
}
|
|
1023
|
+
async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
|
|
1024
|
+
const targetPath = `${repoRoot}/${entry.path}`;
|
|
1025
|
+
const locked = lookupLockedFile(lock, entry.path);
|
|
1026
|
+
const current = await readBytes(targetPath);
|
|
1027
|
+
const desired = await readDesiredBytes(entry.source);
|
|
1028
|
+
if (!current) {
|
|
1029
|
+
return {
|
|
1030
|
+
kind: "create",
|
|
1031
|
+
op: "file",
|
|
1032
|
+
target: targetPath,
|
|
1033
|
+
relPath: entry.path,
|
|
1034
|
+
kindOf: entry.kind,
|
|
1035
|
+
bytes: desired?.bytes ?? Buffer.alloc(0),
|
|
1036
|
+
sha256: desired?.hash,
|
|
1037
|
+
mode: 420,
|
|
1038
|
+
reason: "managed file missing"
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
if (desired.hash === current.hash) {
|
|
1042
|
+
if (locked?.sha256 === current.hash) {
|
|
1043
|
+
return { kind: "noop", op: "file", target: targetPath, relPath: entry.path };
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
kind: "converge",
|
|
1047
|
+
op: "file",
|
|
1048
|
+
target: targetPath,
|
|
1049
|
+
relPath: entry.path,
|
|
1050
|
+
reason: "current bytes already equal desired; refresh lock only"
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
if (locked?.sha256 === current.hash) {
|
|
1054
|
+
return {
|
|
1055
|
+
kind: "update",
|
|
1056
|
+
op: "file",
|
|
1057
|
+
target: targetPath,
|
|
1058
|
+
relPath: entry.path,
|
|
1059
|
+
bytes: desired?.bytes ?? Buffer.alloc(0),
|
|
1060
|
+
sha256: desired?.hash,
|
|
1061
|
+
mode: 420,
|
|
1062
|
+
reason: "safe update: previous lock matches current bytes"
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
if (locked?.sha256 && locked.sha256 !== current.hash && allowUnowned) {
|
|
1066
|
+
return {
|
|
1067
|
+
kind: "update",
|
|
1068
|
+
op: "file",
|
|
1069
|
+
target: targetPath,
|
|
1070
|
+
relPath: entry.path,
|
|
1071
|
+
bytes: desired?.bytes ?? Buffer.alloc(0),
|
|
1072
|
+
sha256: desired?.hash,
|
|
1073
|
+
mode: 420,
|
|
1074
|
+
reason: "force update: lock present, current bytes modified"
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
kind: "conflict",
|
|
1079
|
+
op: "file",
|
|
1080
|
+
target: targetPath,
|
|
1081
|
+
relPath: entry.path,
|
|
1082
|
+
currentSha: current.hash,
|
|
1083
|
+
previousSha: locked?.sha256 ?? null,
|
|
1084
|
+
desiredSha: desired?.hash,
|
|
1085
|
+
reason: locked?.sha256 == null ? "managed file already exists; bytes differ from upstream" : "managed file is locally modified"
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
async function planFileInstall({ repoRoot, lock, allowUnowned = false }) {
|
|
1089
|
+
const plan = [];
|
|
1090
|
+
for (const entry of CATALOG) {
|
|
1091
|
+
plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned }));
|
|
1092
|
+
}
|
|
1093
|
+
return plan;
|
|
1094
|
+
}
|
|
1095
|
+
async function planUninstall({ repoRoot, lock }) {
|
|
1096
|
+
if (!lock) return [];
|
|
1097
|
+
const plan = [];
|
|
1098
|
+
for (const entry of lock.files ?? []) {
|
|
1099
|
+
const targetPath = `${repoRoot}/${entry.path}`;
|
|
1100
|
+
const current = await readBytes(targetPath);
|
|
1101
|
+
if (!current) continue;
|
|
1102
|
+
if (current.hash !== entry.sha256) {
|
|
1103
|
+
plan.push({
|
|
1104
|
+
kind: "conflict",
|
|
1105
|
+
op: "file",
|
|
1106
|
+
target: targetPath,
|
|
1107
|
+
relPath: entry.path,
|
|
1108
|
+
reason: "managed file is locally modified; refusing to delete"
|
|
1109
|
+
});
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
plan.push({ kind: "delete", op: "file", target: targetPath, relPath: entry.path });
|
|
1113
|
+
}
|
|
1114
|
+
return plan;
|
|
1115
|
+
}
|
|
1116
|
+
async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite }) {
|
|
1117
|
+
const existing = await loadConfig(repoRoot);
|
|
1118
|
+
if (existing?.ok && !forceOverwrite) {
|
|
1119
|
+
return {
|
|
1120
|
+
kind: "noop",
|
|
1121
|
+
op: "config",
|
|
1122
|
+
relPath: ".opencode/ship.config.json",
|
|
1123
|
+
target: existing.path,
|
|
1124
|
+
currentSha: existing.sha256,
|
|
1125
|
+
desiredSha: existing.sha256,
|
|
1126
|
+
configValue: existing.value,
|
|
1127
|
+
reason: "user config already present"
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const desiredValue = renderDefaultConfig(detection);
|
|
1131
|
+
const desiredJson = JSON.stringify(desiredValue, null, 2) + "\n";
|
|
1132
|
+
const desiredSha = bytesHashString(desiredJson);
|
|
1133
|
+
const kind = existing?.ok && forceOverwrite ? "update" : "create";
|
|
1134
|
+
return {
|
|
1135
|
+
kind,
|
|
1136
|
+
op: "config",
|
|
1137
|
+
relPath: ".opencode/ship.config.json",
|
|
1138
|
+
target: `${repoRoot}/.opencode/ship.config.json`,
|
|
1139
|
+
currentSha: existing?.ok ? existing.sha256 : null,
|
|
1140
|
+
desiredSha,
|
|
1141
|
+
bytes: Buffer.from(desiredJson, "utf8"),
|
|
1142
|
+
configValue: desiredValue,
|
|
1143
|
+
reason: existing?.ok ? "user config overwritten via --force-config" : "synthesising a default config from detection"
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
async function planRootConfigApply({ repoRoot, lock, forceRepair }) {
|
|
1147
|
+
const detected = findRootConfig(repoRoot);
|
|
1148
|
+
const target = detected.path ?? defaultRootConfigPath(repoRoot);
|
|
1149
|
+
const previous = lock?.manager?.rootDocuments?.find((d) => d.path === detected.relative);
|
|
1150
|
+
const fileMissing = !existsSync3(target);
|
|
1151
|
+
if (fileMissing && !forceRepair) {
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "noop",
|
|
1154
|
+
op: "root-config",
|
|
1155
|
+
target,
|
|
1156
|
+
relPath: detected.relative,
|
|
1157
|
+
reason: "no root opencode.json present",
|
|
1158
|
+
edits: []
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
if (fileMissing && forceRepair) {
|
|
1162
|
+
const { synthesizeDefaultRootConfig: synthesizeDefaultRootConfig2, formatRootConfig: formatRootConfig3 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
|
|
1163
|
+
const doc = synthesizeDefaultRootConfig2();
|
|
1164
|
+
const bytes2 = Buffer.from(formatRootConfig3(doc), "utf8");
|
|
1165
|
+
const newSha2 = bytesHashString(stableStringify(doc));
|
|
1166
|
+
const installedPointers2 = [];
|
|
1167
|
+
return {
|
|
1168
|
+
kind: "create",
|
|
1169
|
+
op: "root-config",
|
|
1170
|
+
target,
|
|
1171
|
+
relPath: detected.relative,
|
|
1172
|
+
bytes: bytes2,
|
|
1173
|
+
desiredSha: newSha2,
|
|
1174
|
+
currentSha: null,
|
|
1175
|
+
edits: [{ kind: "create", pointer: "(file)", value: "(created)" }],
|
|
1176
|
+
pointerRecords: installedPointers2,
|
|
1177
|
+
format: "json",
|
|
1178
|
+
document: doc,
|
|
1179
|
+
reason: "creating root opencode.json with installer-owned Build permissions"
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const docResult = readRootConfig(target);
|
|
1183
|
+
if (!docResult.ok) {
|
|
1184
|
+
return {
|
|
1185
|
+
kind: "noop",
|
|
1186
|
+
op: "root-config",
|
|
1187
|
+
target,
|
|
1188
|
+
relPath: detected.relative,
|
|
1189
|
+
reason: `root config ${docResult.error.kind}`,
|
|
1190
|
+
edits: []
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const previousPointers = previous?.pointers ?? [];
|
|
1194
|
+
const result = applyOwnedPointers(docResult.value, { allowEqualValues: true });
|
|
1195
|
+
const edits = result.applied.map((a) => ({
|
|
1196
|
+
kind: "create",
|
|
1197
|
+
pointer: a.pointer,
|
|
1198
|
+
value: a.value
|
|
1199
|
+
}));
|
|
1200
|
+
for (const s of result.skipped) {
|
|
1201
|
+
if (s.reason === "already equal") continue;
|
|
1202
|
+
edits.push({ kind: "conflict", pointer: s.pointer, reason: s.reason, existing: s.existing, desired: s.desired });
|
|
1203
|
+
}
|
|
1204
|
+
const { formatRootConfigPreserving: formatRootConfigPreserving2, formatRootConfig: formatRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
|
|
1205
|
+
const { parseRootConfigPreservingOrder: parseRootConfigPreservingOrder2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
|
|
1206
|
+
const { setPointer: setPointer2 } = await Promise.resolve().then(() => (init_json_pointer(), json_pointer_exports));
|
|
1207
|
+
let bytes;
|
|
1208
|
+
let docForWrite = result.doc;
|
|
1209
|
+
try {
|
|
1210
|
+
const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw);
|
|
1211
|
+
docForWrite = sourceValue;
|
|
1212
|
+
for (const entry of result.applied) {
|
|
1213
|
+
docForWrite = setPointer2(docForWrite, entry.pointer, entry.value);
|
|
1214
|
+
}
|
|
1215
|
+
bytes = Buffer.from(formatRootConfigPreserving2(docForWrite), "utf8");
|
|
1216
|
+
void sourceFormat;
|
|
1217
|
+
} catch {
|
|
1218
|
+
bytes = Buffer.from(formatRootConfig2(result.doc), "utf8");
|
|
1219
|
+
}
|
|
1220
|
+
const newSha = bytesHashString(stableStringify(docForWrite));
|
|
1221
|
+
const installedPointers = [...previousPointers];
|
|
1222
|
+
for (const e of result.applied) {
|
|
1223
|
+
const idx = installedPointers.findIndex((p) => p.pointer === e.pointer);
|
|
1224
|
+
const previousEntry = docResult.before?.[e.pointer];
|
|
1225
|
+
const next = {
|
|
1226
|
+
pointer: e.pointer,
|
|
1227
|
+
strategy: "value",
|
|
1228
|
+
installedSha256: bytesHashString(stableStringify(e.value)),
|
|
1229
|
+
previous: previousEntry == null ? { existed: false } : { existed: true, value: previousEntry }
|
|
1230
|
+
};
|
|
1231
|
+
if (idx >= 0) installedPointers[idx] = next;
|
|
1232
|
+
else installedPointers.push(next);
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.length ? "update" : "noop",
|
|
1236
|
+
op: "root-config",
|
|
1237
|
+
target,
|
|
1238
|
+
relPath: detected.relative,
|
|
1239
|
+
bytes,
|
|
1240
|
+
desiredSha: newSha,
|
|
1241
|
+
currentSha: docResult.sha256 ?? null,
|
|
1242
|
+
edits,
|
|
1243
|
+
pointerRecords: installedPointers,
|
|
1244
|
+
format: docResult.format,
|
|
1245
|
+
document: docForWrite,
|
|
1246
|
+
reason: edits.length === 0 ? "no installer-owned entries missing" : `apply ${result.applied.length} / skip ${result.skipped.length}`
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/installer/executor.js
|
|
1251
|
+
init_lock();
|
|
1252
|
+
init_hash();
|
|
1253
|
+
init_json_pointer();
|
|
1254
|
+
|
|
1255
|
+
// src/installer/detection/project.js
|
|
1256
|
+
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";
|
|
1259
|
+
function runGit(cwd, args) {
|
|
1260
|
+
const r = spawnSync("git", ["-C", cwd, ...args], {
|
|
1261
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1262
|
+
encoding: "utf8"
|
|
1263
|
+
});
|
|
1264
|
+
return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
1265
|
+
}
|
|
1266
|
+
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";
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
function readPackageJson(repoRoot) {
|
|
1274
|
+
const path = join(repoRoot, "package.json");
|
|
1275
|
+
if (!existsSync5(path)) return null;
|
|
1276
|
+
try {
|
|
1277
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
1278
|
+
} catch {
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
function planFromScripts(pkg, packageManager) {
|
|
1283
|
+
const scripts = pkg?.scripts ?? {};
|
|
1284
|
+
const runner = packageManager === "npm" ? "npm" : packageManager || "npm";
|
|
1285
|
+
const candidate = (name) => typeof scripts[name] === "string" ? scripts[name].trim() : null;
|
|
1286
|
+
if (candidate("verify") || candidate("verify:workspace")) {
|
|
1287
|
+
const name = candidate("verify:workspace") ? "verify:workspace" : "verify";
|
|
1288
|
+
const cmd = candidate(name);
|
|
1289
|
+
return [{ id: "canonical", argv: [runner, "run", name], inferredFrom: "verify", command: cmd }];
|
|
1290
|
+
}
|
|
1291
|
+
const steps = [];
|
|
1292
|
+
if (candidate("typecheck")) steps.push({ id: "typecheck", argv: [runner, "run", "typecheck"], script: "typecheck" });
|
|
1293
|
+
if (candidate("lint")) steps.push({ id: "lint", argv: [runner, "run", "lint"], script: "lint" });
|
|
1294
|
+
if (candidate("test")) steps.push({ id: "test", argv: [runner, "run", "test"], script: "test" });
|
|
1295
|
+
return steps;
|
|
1296
|
+
}
|
|
1297
|
+
function bootstrapFor(packageManager) {
|
|
1298
|
+
if (packageManager === "pnpm") return [["pnpm", "install", "--frozen-lockfile"]];
|
|
1299
|
+
if (packageManager === "yarn") return [["yarn", "install", "--frozen-lockfile"]];
|
|
1300
|
+
if (packageManager === "bun") return [["bun", "install", "--frozen-lockfile"]];
|
|
1301
|
+
if (packageManager === "npm") return [["npm", "ci"]];
|
|
1302
|
+
return [];
|
|
1303
|
+
}
|
|
1304
|
+
function parseRepoSlugFromRemote(url) {
|
|
1305
|
+
if (!url) return null;
|
|
1306
|
+
const ssh = url.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
1307
|
+
if (ssh) return ssh[1].replace(/^\/+|\/+$/g, "");
|
|
1308
|
+
const https = url.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
1309
|
+
if (https) return https[1].replace(/^\/+|\/+$/g, "");
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
function detectRemote(repoRoot) {
|
|
1313
|
+
const remotes = runGit(repoRoot, ["remote", "-v"]);
|
|
1314
|
+
if (remotes.status !== 0) return { candidates: [], primary: null };
|
|
1315
|
+
const lines = remotes.stdout.split("\n").filter(Boolean);
|
|
1316
|
+
const map = /* @__PURE__ */ new Map();
|
|
1317
|
+
for (const line of lines) {
|
|
1318
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
|
|
1319
|
+
if (!match) continue;
|
|
1320
|
+
const name = match[1];
|
|
1321
|
+
const url = match[2];
|
|
1322
|
+
if (!map.has(name)) map.set(name, { name, url });
|
|
1323
|
+
}
|
|
1324
|
+
const list = Array.from(map.values());
|
|
1325
|
+
const origin = list.find((r) => r.name === "origin") ?? list[0] ?? null;
|
|
1326
|
+
return { candidates: list, primary: origin };
|
|
1327
|
+
}
|
|
1328
|
+
function detectDefaultBranch(repoRoot, remoteName) {
|
|
1329
|
+
const head = runGit(repoRoot, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]);
|
|
1330
|
+
if (head.status === 0) {
|
|
1331
|
+
const ref = head.stdout.trim();
|
|
1332
|
+
const match = ref.match(/^refs\/remotes\/[^/]+\/(.+)$/);
|
|
1333
|
+
if (match) return match[1];
|
|
1334
|
+
}
|
|
1335
|
+
const local = runGit(repoRoot, ["remote", "show", remoteName]);
|
|
1336
|
+
if (local.status === 0) {
|
|
1337
|
+
const match = local.stdout.match(/HEAD branch:\s*(\S+)/);
|
|
1338
|
+
if (match) return match[1];
|
|
1339
|
+
}
|
|
1340
|
+
const branch = runGit(repoRoot, ["branch", "--list"]);
|
|
1341
|
+
if (branch.status === 0 && /\*\s*main\b/.test(branch.stdout)) return "main";
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
function detectOwner(repoRoot) {
|
|
1345
|
+
const user = runGit(repoRoot, ["config", "--get", "user.name"]);
|
|
1346
|
+
if (user.status === 0 && user.stdout.trim()) return user.stdout.trim();
|
|
1347
|
+
const fallback = process.env.USER ?? process.env.USERNAME ?? "opencode-ship";
|
|
1348
|
+
return fallback;
|
|
1349
|
+
}
|
|
1350
|
+
function detectProject(repoRoot = process.cwd()) {
|
|
1351
|
+
const errors = [];
|
|
1352
|
+
const cwd = resolve5(repoRoot);
|
|
1353
|
+
const inside = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1354
|
+
if (inside.status !== 0) {
|
|
1355
|
+
errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
|
|
1356
|
+
return { repoRoot: cwd, errors };
|
|
1357
|
+
}
|
|
1358
|
+
const repoRootActual = inside.stdout.trim();
|
|
1359
|
+
const headBranch = runGit(repoRootActual, ["symbolic-ref", "--short", "HEAD"]);
|
|
1360
|
+
if (headBranch.status !== 0 || headBranch.stdout.trim().length === 0) {
|
|
1361
|
+
errors.push({ kind: "detached-head", path: repoRootActual, detail: headBranch.stderr.trim() });
|
|
1362
|
+
}
|
|
1363
|
+
const remote = detectRemote(repoRootActual);
|
|
1364
|
+
let repository = null;
|
|
1365
|
+
let defaultBranch = null;
|
|
1366
|
+
if (remote.primary) {
|
|
1367
|
+
repository = parseRepoSlugFromRemote(remote.primary.url);
|
|
1368
|
+
defaultBranch = detectDefaultBranch(repoRootActual, remote.primary.name);
|
|
1369
|
+
}
|
|
1370
|
+
if (!repository) {
|
|
1371
|
+
errors.push({ kind: "no-remote", path: repoRootActual, detail: "no usable remote for github detection" });
|
|
1372
|
+
}
|
|
1373
|
+
if (!defaultBranch) {
|
|
1374
|
+
defaultBranch = "main";
|
|
1375
|
+
}
|
|
1376
|
+
const packageJson = readPackageJson(repoRootActual);
|
|
1377
|
+
const packageManager = detectPackageManager(repoRootActual);
|
|
1378
|
+
const verificationPlan = planFromScripts(packageJson, packageManager);
|
|
1379
|
+
const worktreeBootstrap = bootstrapFor(packageManager);
|
|
1380
|
+
const owner = detectOwner(repoRootActual);
|
|
1381
|
+
return {
|
|
1382
|
+
repoRoot: repoRootActual,
|
|
1383
|
+
repository,
|
|
1384
|
+
defaultBranch,
|
|
1385
|
+
remote: remote.primary?.name ?? null,
|
|
1386
|
+
remoteCandidates: remote.candidates,
|
|
1387
|
+
packageManager,
|
|
1388
|
+
packageJson,
|
|
1389
|
+
verificationPlan,
|
|
1390
|
+
worktreeBootstrap,
|
|
1391
|
+
worktreeRoot: ".worktrees",
|
|
1392
|
+
owner,
|
|
1393
|
+
headBranch: headBranch.stdout.trim() || null,
|
|
1394
|
+
errors
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// src/installer/executor.js
|
|
1399
|
+
init_root_config();
|
|
1400
|
+
init_lock();
|
|
1401
|
+
|
|
1402
|
+
// src/installer/transaction.js
|
|
1403
|
+
import {
|
|
1404
|
+
writeFile as writeFile3,
|
|
1405
|
+
rename as rename3,
|
|
1406
|
+
readFile as readFile5,
|
|
1407
|
+
unlink,
|
|
1408
|
+
mkdir as mkdir3,
|
|
1409
|
+
stat as stat2,
|
|
1410
|
+
open
|
|
1411
|
+
} from "node:fs/promises";
|
|
1412
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1413
|
+
import { dirname as dirname4, resolve as resolve6 } from "node:path";
|
|
1414
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1415
|
+
function gitDir(repoRoot) {
|
|
1416
|
+
const r = spawnSync2("git", ["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-dir"], {
|
|
1417
|
+
encoding: "utf8"
|
|
1418
|
+
});
|
|
1419
|
+
if (r.status !== 0) return null;
|
|
1420
|
+
const trimmed = r.stdout.trim();
|
|
1421
|
+
return trimmed ? resolve6(repoRoot, trimmed) : null;
|
|
1422
|
+
}
|
|
1423
|
+
function lockDir(repoRoot) {
|
|
1424
|
+
return resolve6(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
|
|
1425
|
+
}
|
|
1426
|
+
function transactionLockPath(repoRoot) {
|
|
1427
|
+
return resolve6(lockDir(repoRoot), ".txn.lock");
|
|
1428
|
+
}
|
|
1429
|
+
function journalPath(repoRoot, txnId) {
|
|
1430
|
+
return resolve6(lockDir(repoRoot), `.txn-${txnId}.journal`);
|
|
1431
|
+
}
|
|
1432
|
+
async function acquireLock(repoRoot, txnId) {
|
|
1433
|
+
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
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
async function releaseLock(repoRoot) {
|
|
1442
|
+
try {
|
|
1443
|
+
await unlink(transactionLockPath(repoRoot));
|
|
1444
|
+
} catch {
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
async function fsyncDir(path) {
|
|
1448
|
+
try {
|
|
1449
|
+
const handle = await open(path, "r");
|
|
1450
|
+
await handle.sync();
|
|
1451
|
+
await handle.close();
|
|
1452
|
+
} catch {
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function randomToken() {
|
|
1456
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1457
|
+
}
|
|
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
|
+
function asMode(mode) {
|
|
1469
|
+
return typeof mode === "number" ? mode : 420;
|
|
1470
|
+
}
|
|
1471
|
+
async function writeJournal(repoRoot, txnId, journal) {
|
|
1472
|
+
await writeFile3(journalPath(repoRoot, txnId), JSON.stringify(journal, null, 2), "utf8");
|
|
1473
|
+
}
|
|
1474
|
+
async function clearJournal(repoRoot, txnId) {
|
|
1475
|
+
if (!txnId) return;
|
|
1476
|
+
try {
|
|
1477
|
+
await unlink(journalPath(repoRoot, txnId));
|
|
1478
|
+
} catch {
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async function recover(repoRoot) {
|
|
1482
|
+
const dir = lockDir(repoRoot);
|
|
1483
|
+
if (!existsSync6(dir)) return { recovered: false };
|
|
1484
|
+
const { readdir } = await import("node:fs/promises");
|
|
1485
|
+
const names = await readdir(dir).catch(() => []);
|
|
1486
|
+
const journals = names.filter((n) => n.startsWith(".txn-") && n.endsWith(".journal"));
|
|
1487
|
+
if (!journals.length) return { recovered: false };
|
|
1488
|
+
let totalRecovered = 0;
|
|
1489
|
+
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;
|
|
1519
|
+
}
|
|
1520
|
+
await releaseLock(repoRoot);
|
|
1521
|
+
return { recovered: totalRecovered > 0, recoveredCount: totalRecovered };
|
|
1522
|
+
}
|
|
1523
|
+
async function executePlan({ repoRoot, plan, newLockBuilder }) {
|
|
1524
|
+
const recovered = await recover(repoRoot);
|
|
1525
|
+
const txnId = `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1526
|
+
try {
|
|
1527
|
+
await acquireLock(repoRoot, txnId);
|
|
1528
|
+
} catch (e) {
|
|
1529
|
+
return { ok: false, error: { kind: "lock-held", message: e?.message ?? String(e) } };
|
|
1530
|
+
}
|
|
1531
|
+
const journal = { repoRoot, txnId, startedAt: (/* @__PURE__ */ new Date()).toISOString(), entries: [] };
|
|
1532
|
+
await writeJournal(repoRoot, txnId, journal);
|
|
1533
|
+
try {
|
|
1534
|
+
for (const op of plan) {
|
|
1535
|
+
if (op.op !== "file") continue;
|
|
1536
|
+
if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
|
|
1537
|
+
const original = await captureOriginal(op.target);
|
|
1538
|
+
if (op.kind === "delete") {
|
|
1539
|
+
if (!existsSync6(op.target)) continue;
|
|
1540
|
+
const backup = `${op.target}.txn-${randomToken()}-backup`;
|
|
1541
|
+
await rename3(op.target, backup);
|
|
1542
|
+
journal.entries.push({
|
|
1543
|
+
op: "promote",
|
|
1544
|
+
target: op.target,
|
|
1545
|
+
staged: backup,
|
|
1546
|
+
original,
|
|
1547
|
+
kind: "delete"
|
|
1548
|
+
});
|
|
1549
|
+
} 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);
|
|
1557
|
+
journal.entries.push({
|
|
1558
|
+
op: "promote",
|
|
1559
|
+
target: op.target,
|
|
1560
|
+
staged: tempPath,
|
|
1561
|
+
original,
|
|
1562
|
+
kind: op.kind
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
await fsyncDir(dirname4(op.target));
|
|
1566
|
+
await writeJournal(repoRoot, txnId, journal);
|
|
1567
|
+
}
|
|
1568
|
+
if (newLockBuilder) {
|
|
1569
|
+
const lockValue = await newLockBuilder();
|
|
1570
|
+
const { writeLock: writer } = await Promise.resolve().then(() => (init_lock(), lock_exports));
|
|
1571
|
+
const lockPathTarget = `${repoRoot}/.opencode/ship.lock.json`;
|
|
1572
|
+
journal.entries.push({
|
|
1573
|
+
op: "promote",
|
|
1574
|
+
target: lockPathTarget,
|
|
1575
|
+
staged: lockPathTarget,
|
|
1576
|
+
original: await captureOriginal(lockPathTarget),
|
|
1577
|
+
kind: "lock"
|
|
1578
|
+
});
|
|
1579
|
+
await writer(repoRoot, lockValue);
|
|
1580
|
+
await writeJournal(repoRoot, txnId, journal);
|
|
1581
|
+
}
|
|
1582
|
+
await clearJournal(repoRoot, txnId);
|
|
1583
|
+
return { ok: true, txnId, recovered: recovered.recovered ?? false, recoveredCount: recovered.recoveredCount ?? 0 };
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
await rollback(repoRoot, journal);
|
|
1586
|
+
return { ok: false, error: { kind: "transaction-failed", message: e?.message ?? String(e) } };
|
|
1587
|
+
} finally {
|
|
1588
|
+
await releaseLock(repoRoot);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
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
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (journal.txnId) await clearJournal(repoRoot, journal.txnId);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// src/installer/migration.js
|
|
1625
|
+
init_lock();
|
|
1626
|
+
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
|
|
1634
|
+
function legacyAdapterPath(repoRoot) {
|
|
1635
|
+
return resolve8(repoRoot, ".opencode", "delivery.json");
|
|
1636
|
+
}
|
|
1637
|
+
function legacyLockPath(repoRoot) {
|
|
1638
|
+
return resolve8(repoRoot, ".opencode", "delivery.lock.json");
|
|
1639
|
+
}
|
|
1640
|
+
function legacyPluginPath(repoRoot) {
|
|
1641
|
+
return resolve8(repoRoot, ".opencode", "plugin", "delivery.ts");
|
|
1642
|
+
}
|
|
1643
|
+
async function detectLegacyShapes(repoRoot) {
|
|
1644
|
+
const out = {
|
|
1645
|
+
adapter: false,
|
|
1646
|
+
legacyLock: false,
|
|
1647
|
+
plugin: false,
|
|
1648
|
+
reviewer: false,
|
|
1649
|
+
verifier: false
|
|
1650
|
+
};
|
|
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;
|
|
1656
|
+
return out;
|
|
1657
|
+
}
|
|
1658
|
+
async function readLegacyAdapter(repoRoot) {
|
|
1659
|
+
const path = legacyAdapterPath(repoRoot);
|
|
1660
|
+
if (!existsSync7(path)) return null;
|
|
1661
|
+
try {
|
|
1662
|
+
const raw = await readFile6(path, "utf8");
|
|
1663
|
+
return { path, raw, value: JSON.parse(raw) };
|
|
1664
|
+
} catch {
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async function migration({ repoRoot, lock, forceRepair }) {
|
|
1669
|
+
const shapes = await detectLegacyShapes(repoRoot);
|
|
1670
|
+
const legacy = await readLegacyAdapter(repoRoot);
|
|
1671
|
+
const config = await loadConfig(repoRoot);
|
|
1672
|
+
const actions = [];
|
|
1673
|
+
if (legacy && !config?.ok) {
|
|
1674
|
+
await writeConfig(repoRoot, legacyToShipConfig(legacy.value));
|
|
1675
|
+
actions.push({ kind: "seeded-config", from: legacy.path });
|
|
1676
|
+
}
|
|
1677
|
+
if (legacy && shapes.legacyLock && !lock?.manager) {
|
|
1678
|
+
actions.push({ kind: "kept-legacy-lock", path: legacyLockPath(repoRoot) });
|
|
1679
|
+
}
|
|
1680
|
+
if (shapes.plugin && existsSync7(resolve8(repoRoot, ".opencode/plugin/opencode-ship.js"))) {
|
|
1681
|
+
if (!forceRepair) {
|
|
1682
|
+
actions.push({ kind: "candidate-remove-legacy-plugin", path: legacyPluginPath(repoRoot) });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return { shapes, actions, legacyPresent: Boolean(legacy) };
|
|
1686
|
+
}
|
|
1687
|
+
function legacyToShipConfig(legacy) {
|
|
1688
|
+
if (!legacy || typeof legacy !== "object") return renderDefaultConfig({});
|
|
1689
|
+
return {
|
|
1690
|
+
schemaVersion: 1,
|
|
1691
|
+
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",
|
|
1696
|
+
detectOverrides: false
|
|
1697
|
+
},
|
|
1698
|
+
delivery: {
|
|
1699
|
+
worktree: {
|
|
1700
|
+
root: legacy.worktree?.root ?? ".worktrees",
|
|
1701
|
+
branchTemplate: legacy.worktree?.branchTemplate ?? "{actor}/{slug}",
|
|
1702
|
+
bootstrap: Array.isArray(legacy.worktree?.bootstrap) ? legacy.worktree.bootstrap : [["npm", "install"]]
|
|
1703
|
+
},
|
|
1704
|
+
verification: {
|
|
1705
|
+
commands: legacy.verification?.commands?.length ? legacy.verification.commands : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }],
|
|
1706
|
+
requireCleanDiffAfter: legacy.verification?.requireCleanDiffAfter ?? true,
|
|
1707
|
+
invalidateOnHeadChange: legacy.verification?.invalidateOnHeadChange ?? true
|
|
1708
|
+
},
|
|
1709
|
+
review: legacy.review ?? { agent: "delivery-reviewer", required: true, invalidateOnHeadChange: true },
|
|
1710
|
+
ci: legacy.ci ?? {
|
|
1711
|
+
driver: "github-status-checks",
|
|
1712
|
+
requiredChecks: ["delivery-verify"],
|
|
1713
|
+
wait: true,
|
|
1714
|
+
flakyRetry: 1
|
|
1715
|
+
},
|
|
1716
|
+
ready: legacy.ready ?? { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
|
|
1717
|
+
merge: legacy.merge ?? { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
|
|
1718
|
+
cleanup: legacy.cleanup ?? { when: "next-task", requireUnpublishedGuard: true }
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// src/installer/executor.js
|
|
1724
|
+
async function readCurrentBytes(targetPath) {
|
|
1725
|
+
if (!existsSync8(targetPath)) return null;
|
|
1726
|
+
const buf = await readFile7(targetPath);
|
|
1727
|
+
return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
|
|
1728
|
+
}
|
|
1729
|
+
async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRootConfig }) {
|
|
1730
|
+
const detection = detectProject(rootPath ?? process.cwd());
|
|
1731
|
+
if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
|
|
1732
|
+
return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
|
|
1733
|
+
}
|
|
1734
|
+
const repoRoot = detection.repoRoot;
|
|
1735
|
+
const lock = await readLock(repoRoot);
|
|
1736
|
+
const migrationReport = await migration({ repoRoot, lock, forceRepair: false });
|
|
1737
|
+
const configPlan = await planConfigSynthesis({
|
|
1738
|
+
repoRoot,
|
|
1739
|
+
detection,
|
|
1740
|
+
lock,
|
|
1741
|
+
forceOverwrite: Boolean(forceConfig)
|
|
1742
|
+
});
|
|
1743
|
+
const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged) });
|
|
1744
|
+
const rootPlan = await planRootConfigApply({ repoRoot, lock, forceRepair: Boolean(forceRootConfig) });
|
|
1745
|
+
const plan = [...filePlan ?? [], configPlan, rootPlan];
|
|
1746
|
+
const conflicts = plan.filter((p) => p && p.kind === "conflict");
|
|
1747
|
+
const summary = summarise(plan);
|
|
1748
|
+
return { ok: true, repoRoot, detection, lock, plan, conflicts, summary, migrationReport };
|
|
1749
|
+
}
|
|
1750
|
+
async function previewUninstall({ rootPath }) {
|
|
1751
|
+
const detection = detectProject(rootPath ?? process.cwd());
|
|
1752
|
+
if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
|
|
1753
|
+
return { ok: false, error: { kind: "invalid-project" } };
|
|
1754
|
+
}
|
|
1755
|
+
const repoRoot = detection.repoRoot;
|
|
1756
|
+
const lock = await readLock(repoRoot);
|
|
1757
|
+
if (!lock) {
|
|
1758
|
+
return { ok: true, repoRoot, lock, plan: [], conflicts: [], summary: summarise([]) };
|
|
1759
|
+
}
|
|
1760
|
+
const plan = await planUninstall({ repoRoot, lock });
|
|
1761
|
+
const conflicts = plan.filter((p) => p.kind === "conflict");
|
|
1762
|
+
return { ok: true, repoRoot, lock, plan, conflicts, summary: summarise(plan) };
|
|
1763
|
+
}
|
|
1764
|
+
function summarise(plan) {
|
|
1765
|
+
const counts = { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0, lock: 0, config: 0, rootConfig: 0 };
|
|
1766
|
+
for (const op of plan) {
|
|
1767
|
+
if (!op) continue;
|
|
1768
|
+
if (op.op === "lock") counts.lock += 1;
|
|
1769
|
+
else if (op.op === "config") counts.config += 1;
|
|
1770
|
+
else if (op.op === "root-config") counts.rootConfig += 1;
|
|
1771
|
+
else if (counts[op.kind] !== void 0) counts[op.kind] += 1;
|
|
1772
|
+
}
|
|
1773
|
+
return counts;
|
|
1774
|
+
}
|
|
1775
|
+
async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
|
|
1776
|
+
const files = [];
|
|
1777
|
+
const remain = lock?.files?.filter((f) => !plan.some((op) => op?.relPath === f.path)) ?? [];
|
|
1778
|
+
for (const op of plan) {
|
|
1779
|
+
if (!op || op.op !== "file") continue;
|
|
1780
|
+
if (op.kind === "delete" || op.kind === "conflict") continue;
|
|
1781
|
+
const entry = CATALOG.find((c) => c.path === op.relPath);
|
|
1782
|
+
if (!entry) continue;
|
|
1783
|
+
let hash = op.sha256;
|
|
1784
|
+
if (!hash && op.target) {
|
|
1785
|
+
const cur = await readCurrentBytes(op.target);
|
|
1786
|
+
if (cur) hash = cur.hash;
|
|
1787
|
+
}
|
|
1788
|
+
files.push({
|
|
1789
|
+
path: op.relPath,
|
|
1790
|
+
sha256: hash ?? null,
|
|
1791
|
+
mode: 420,
|
|
1792
|
+
template: relativeTemplate(entry.source),
|
|
1793
|
+
kind: entry.kind
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
for (const f of remain) {
|
|
1797
|
+
files.push({ ...f });
|
|
1798
|
+
}
|
|
1799
|
+
const configSha = configPlan?.kind === "create" || configPlan?.kind === "update" ? configPlan.desiredSha : configPlan?.kind === "noop" ? configPlan.currentSha : null;
|
|
1800
|
+
const rootPointers = rootPlan?.pointerRecords ?? lock?.manager?.rootDocuments?.[0]?.pointers ?? [];
|
|
1801
|
+
return {
|
|
1802
|
+
contractVersion: 1,
|
|
1803
|
+
manager: {
|
|
1804
|
+
schemaVersion: 1,
|
|
1805
|
+
name: "opencode-ship",
|
|
1806
|
+
version: "0.2.0",
|
|
1807
|
+
templateSet: getTemplateSet(),
|
|
1808
|
+
appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1809
|
+
config: {
|
|
1810
|
+
path: ".opencode/ship.config.json",
|
|
1811
|
+
sha256: configSha ?? lock?.manager?.config?.sha256 ?? "",
|
|
1812
|
+
existed: Boolean(lock?.manager?.config?.existed)
|
|
1813
|
+
},
|
|
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 ?? []
|
|
1819
|
+
},
|
|
1820
|
+
files,
|
|
1821
|
+
cleanupPending: lock?.cleanupPending ?? []
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
async function commitInstall(preview, { json, command }) {
|
|
1825
|
+
if (!preview.ok) {
|
|
1826
|
+
return {
|
|
1827
|
+
ok: false,
|
|
1828
|
+
command,
|
|
1829
|
+
plan: [],
|
|
1830
|
+
conflicts: [],
|
|
1831
|
+
summary: summarise([]),
|
|
1832
|
+
diagnostics: [preview.error?.kind ?? "invalid-project"],
|
|
1833
|
+
/** @type {any} */
|
|
1834
|
+
extra: { exitCode: 2, repoRoot: null, migrationReport: null }
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
const { repoRoot, plan, conflicts, migrationReport } = preview;
|
|
1838
|
+
const filePlans = plan.filter((op) => op.op === "file");
|
|
1839
|
+
const configPlan = plan.find((op) => op.op === "config");
|
|
1840
|
+
const rootPlan = plan.find((op) => op.op === "root-config");
|
|
1841
|
+
const fileOnly = filePlans;
|
|
1842
|
+
if (conflicts.length > 0) {
|
|
1843
|
+
return {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
command,
|
|
1846
|
+
plan,
|
|
1847
|
+
conflicts,
|
|
1848
|
+
summary: summarise(plan),
|
|
1849
|
+
diagnostics: ["hash conflict; refuse to overwrite"],
|
|
1850
|
+
/** @type {any} */
|
|
1851
|
+
extra: { exitCode: 3, repoRoot, migrationReport }
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
const newLockObject = await assembleLock({
|
|
1855
|
+
repoRoot,
|
|
1856
|
+
plan: fileOnly,
|
|
1857
|
+
lock: preview.lock,
|
|
1858
|
+
configPlan,
|
|
1859
|
+
rootPlan
|
|
1860
|
+
});
|
|
1861
|
+
const txPlan = await stageFiles(fileOnly, repoRoot);
|
|
1862
|
+
if (configPlan && (configPlan.kind === "create" || configPlan.kind === "update")) {
|
|
1863
|
+
txPlan.push({
|
|
1864
|
+
op: "file",
|
|
1865
|
+
kind: configPlan.kind === "create" ? "create" : "update",
|
|
1866
|
+
target: configPlan.target,
|
|
1867
|
+
bytes: configPlan.bytes,
|
|
1868
|
+
mode: 420,
|
|
1869
|
+
relPath: configPlan.relPath
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
if (rootPlan && (rootPlan.kind === "create" || rootPlan.kind === "update")) {
|
|
1873
|
+
txPlan.push({
|
|
1874
|
+
op: "file",
|
|
1875
|
+
kind: rootPlan.kind,
|
|
1876
|
+
target: rootPlan.target,
|
|
1877
|
+
bytes: rootPlan.bytes,
|
|
1878
|
+
mode: 420,
|
|
1879
|
+
relPath: rootPlan.relPath
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
const tx = await executePlan({
|
|
1883
|
+
repoRoot,
|
|
1884
|
+
plan: txPlan,
|
|
1885
|
+
newLockBuilder: async () => newLockObject
|
|
1886
|
+
});
|
|
1887
|
+
if (!tx.ok) {
|
|
1888
|
+
return {
|
|
1889
|
+
ok: false,
|
|
1890
|
+
command,
|
|
1891
|
+
plan,
|
|
1892
|
+
conflicts: [],
|
|
1893
|
+
summary: summarise(plan),
|
|
1894
|
+
diagnostics: [tx.error?.message ?? "transaction failure"],
|
|
1895
|
+
extra: { exitCode: 4, repoRoot, migrationReport, recovered: false }
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
return {
|
|
1899
|
+
ok: true,
|
|
1900
|
+
command,
|
|
1901
|
+
plan,
|
|
1902
|
+
conflicts: [],
|
|
1903
|
+
summary: summarise(plan),
|
|
1904
|
+
diagnostics: [],
|
|
1905
|
+
extra: { exitCode: 0, repoRoot, migrationReport, recovered: tx.recovered }
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
async function stageFiles(filePlan, repoRoot) {
|
|
1909
|
+
const out = [];
|
|
1910
|
+
for (const op of filePlan) {
|
|
1911
|
+
if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge" || op.kind === "delete") continue;
|
|
1912
|
+
out.push({
|
|
1913
|
+
op: "file",
|
|
1914
|
+
kind: op.kind,
|
|
1915
|
+
target: op.target,
|
|
1916
|
+
bytes: op.bytes ?? Buffer.alloc(0),
|
|
1917
|
+
mode: op.mode ?? 420
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
return out;
|
|
1921
|
+
}
|
|
1922
|
+
function serializePlan(plan) {
|
|
1923
|
+
return plan.filter(Boolean).map((op) => {
|
|
1924
|
+
if (!op) return null;
|
|
1925
|
+
const { bytes, ...rest } = op;
|
|
1926
|
+
if (bytes && Buffer.isBuffer(bytes)) {
|
|
1927
|
+
return { ...rest, bytesLength: bytes.length };
|
|
1928
|
+
}
|
|
1929
|
+
return rest;
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
function relativeTemplate(source) {
|
|
1933
|
+
if (typeof source !== "string") return source;
|
|
1934
|
+
const prefix = `${process.cwd()}/`;
|
|
1935
|
+
if (source.startsWith(prefix)) return source.slice(prefix.length);
|
|
1936
|
+
return source;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// src/installer/commands/doctor.js
|
|
1940
|
+
init_lock();
|
|
1941
|
+
import { existsSync as existsSync9, readFileSync as readFileSync3 } from "node:fs";
|
|
1942
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
1943
|
+
init_hash();
|
|
1944
|
+
init_root_config();
|
|
1945
|
+
import { resolve as resolve10 } from "node:path";
|
|
1946
|
+
|
|
1947
|
+
// src/installer/report.js
|
|
1948
|
+
var REPORT_VERSION = 1;
|
|
1949
|
+
function pad(p) {
|
|
1950
|
+
return p.replace(/^/, " ");
|
|
1951
|
+
}
|
|
1952
|
+
function summarise2(plan) {
|
|
1953
|
+
const counts = { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 };
|
|
1954
|
+
for (const op of plan) {
|
|
1955
|
+
if (counts[op.kind] !== void 0) counts[op.kind] += 1;
|
|
1956
|
+
}
|
|
1957
|
+
return counts;
|
|
1958
|
+
}
|
|
1959
|
+
function renderHuman({ command, plan, conflicts, summary, diagnostics = [] }) {
|
|
1960
|
+
const head = `# opencode-ship ${command}`;
|
|
1961
|
+
if (!plan.length && !conflicts.length) {
|
|
1962
|
+
return [head, "", "No changes.", ...diagnostics.map((d) => pad(`- ${d}`))].join("\n");
|
|
1963
|
+
}
|
|
1964
|
+
const sections = [head, "", `## Plan`];
|
|
1965
|
+
for (const op of plan) {
|
|
1966
|
+
sections.push(pad(`- ${op.kind.padEnd(9)} ${op.relPath ?? op.target}${op.reason ? ` \u2014 ${op.reason}` : ""}`));
|
|
1967
|
+
}
|
|
1968
|
+
if (conflicts.length) {
|
|
1969
|
+
sections.push("", `## Conflicts (${conflicts.length})`);
|
|
1970
|
+
for (const c of conflicts) {
|
|
1971
|
+
sections.push(pad(`- ${c.relPath ?? c.target}: ${c.reason}`));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (diagnostics.length) {
|
|
1975
|
+
sections.push("", "## Diagnostics");
|
|
1976
|
+
for (const d of diagnostics) sections.push(pad(`- ${d}`));
|
|
1977
|
+
}
|
|
1978
|
+
sections.push("", `Summary: ${JSON.stringify(summary)}`);
|
|
1979
|
+
return sections.join("\n");
|
|
1980
|
+
}
|
|
1981
|
+
function renderJson({ command, plan, conflicts, summary, diagnostics = [], exitCode }) {
|
|
1982
|
+
return JSON.stringify({
|
|
1983
|
+
reportVersion: REPORT_VERSION,
|
|
1984
|
+
command,
|
|
1985
|
+
status: conflicts.length > 0 ? "conflict" : "ok",
|
|
1986
|
+
plan,
|
|
1987
|
+
conflicts,
|
|
1988
|
+
summary,
|
|
1989
|
+
diagnostics,
|
|
1990
|
+
exitCode
|
|
1991
|
+
}, null, 2);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// src/installer/commands/doctor.js
|
|
1995
|
+
function checkNode() {
|
|
1996
|
+
return { name: "node>=22.6.0", ok: /^v2[2-9]/.test(process.version), detail: process.version };
|
|
1997
|
+
}
|
|
1998
|
+
function checkGit() {
|
|
1999
|
+
const r = spawnSync3("git", ["--version"], { encoding: "utf8" });
|
|
2000
|
+
return { name: "git installed", ok: r.status === 0, detail: r.status === 0 ? r.stdout.trim() : "git not on PATH" };
|
|
2001
|
+
}
|
|
2002
|
+
function checkGh() {
|
|
2003
|
+
const r = spawnSync3("gh", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
2004
|
+
return { name: "gh installed", ok: r.status === 0, detail: r.status === 0 ? r.stdout.trim() : "gh CLI not on PATH" };
|
|
2005
|
+
}
|
|
2006
|
+
function checkGhAuth() {
|
|
2007
|
+
const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
2008
|
+
if (!envToken) {
|
|
2009
|
+
return { name: "gh auth status", ok: false, detail: "no GH_TOKEN / GITHUB_TOKEN in environment; gh auth skipped" };
|
|
2010
|
+
}
|
|
2011
|
+
const r = spawnSync3("gh", ["auth", "status"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
2012
|
+
return {
|
|
2013
|
+
name: "gh auth status",
|
|
2014
|
+
ok: r.status === 0,
|
|
2015
|
+
detail: r.status === 0 ? "authenticated (token)" : (r.stderr || r.stdout || "").trim() || "no session"
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
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);
|
|
2022
|
+
return {
|
|
2023
|
+
name: "lock present",
|
|
2024
|
+
ok: integrityOk,
|
|
2025
|
+
detail: integrityOk ? `v0.2 manager@${lock.manager?.version ?? "?"}` : "integrity mismatch"
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
async function checkConfig(repoRoot) {
|
|
2029
|
+
const r = await loadConfig(repoRoot);
|
|
2030
|
+
return {
|
|
2031
|
+
name: "ship.config.json valid",
|
|
2032
|
+
ok: Boolean(r?.ok),
|
|
2033
|
+
detail: r?.ok ? "loaded" : r?.error?.kind ?? "missing"
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
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 };
|
|
2044
|
+
}
|
|
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
|
+
const drift = [];
|
|
2068
|
+
for (const entry of lock.files ?? []) {
|
|
2069
|
+
const p = resolve10(repoRoot, entry.path);
|
|
2070
|
+
if (!existsSync9(p)) {
|
|
2071
|
+
drift.push(`missing:${entry.path}`);
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
const buf = readFileSync3(p, "utf8");
|
|
2075
|
+
const actual = bytesHashString(buf);
|
|
2076
|
+
if (actual !== entry.sha256) drift.push(`drift:${entry.path}`);
|
|
2077
|
+
}
|
|
2078
|
+
return { name: "managed hashes", ok: drift.length === 0, detail: drift.length ? drift.join(",") : "match" };
|
|
2079
|
+
}
|
|
2080
|
+
async function checkRootConfig(repoRoot) {
|
|
2081
|
+
const { findRootConfig: findRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
|
|
2082
|
+
const candidate = findRootConfig2(repoRoot);
|
|
2083
|
+
if (!candidate.path) return { name: "root config owned entries", ok: true, detail: "absent (no work)" };
|
|
2084
|
+
const result = readRootConfig(candidate.path);
|
|
2085
|
+
if (!result.ok) return { name: "root config owned entries", ok: false, detail: `root config ${result.error.kind}` };
|
|
2086
|
+
const r = applyOwnedPointers(result.value);
|
|
2087
|
+
const conflict = r.skipped.find((s) => s.reason === "different existing value");
|
|
2088
|
+
return {
|
|
2089
|
+
name: "root config owned entries",
|
|
2090
|
+
ok: !conflict,
|
|
2091
|
+
detail: conflict ? `conflict on ${conflict.pointer}` : `applied=${r.applied.length}, skipped=${r.skipped.length}`
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
function writeEnvelope({ command, plan, summary, diagnostics, json, exitCode }) {
|
|
2095
|
+
const conflicts = plan.filter((p) => p.kind === "conflict");
|
|
2096
|
+
if (json) {
|
|
2097
|
+
process.stdout.write(renderJson({ command, plan, conflicts, summary, diagnostics, exitCode }) + "\n");
|
|
2098
|
+
} else {
|
|
2099
|
+
process.stdout.write(renderHuman({ command, plan, conflicts, summary, diagnostics }) + "\n");
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
async function runDoctor({ rootPath, json, writeOutput = true }) {
|
|
2103
|
+
const detection = detectProject(rootPath ?? process.cwd());
|
|
2104
|
+
if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
|
|
2105
|
+
const issues2 = ["not in a git repository"];
|
|
2106
|
+
const checks2 = [];
|
|
2107
|
+
const plan2 = checks2.map((c) => ({
|
|
2108
|
+
kind: c.ok ? "noop" : "conflict",
|
|
2109
|
+
op: "check",
|
|
2110
|
+
target: c.name,
|
|
2111
|
+
relPath: c.name,
|
|
2112
|
+
reason: c.detail
|
|
2113
|
+
}));
|
|
2114
|
+
const summary2 = summarise2(plan2);
|
|
2115
|
+
if (writeOutput) writeEnvelope({ command: "doctor", plan: plan2, summary: summary2, diagnostics: issues2, json, exitCode: 2 });
|
|
2116
|
+
process.exitCode = 2;
|
|
2117
|
+
return { issues: issues2, exitCode: 2, plan: plan2, checks: checks2 };
|
|
2118
|
+
}
|
|
2119
|
+
const repoRoot = detection.repoRoot;
|
|
2120
|
+
const checks = [
|
|
2121
|
+
checkNode(),
|
|
2122
|
+
checkGit(),
|
|
2123
|
+
checkGh(),
|
|
2124
|
+
checkGhAuth(),
|
|
2125
|
+
await checkLock(repoRoot),
|
|
2126
|
+
await checkConfig(repoRoot),
|
|
2127
|
+
checkPlugin(repoRoot),
|
|
2128
|
+
checkAgents(repoRoot),
|
|
2129
|
+
checkSkills(repoRoot),
|
|
2130
|
+
await checkManagedHashes(repoRoot),
|
|
2131
|
+
await checkRootConfig(repoRoot)
|
|
2132
|
+
];
|
|
2133
|
+
const issues = checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
|
|
2134
|
+
const plan = checks.map((c) => ({
|
|
2135
|
+
kind: c.ok ? "noop" : "conflict",
|
|
2136
|
+
op: "check",
|
|
2137
|
+
target: c.name,
|
|
2138
|
+
relPath: c.name,
|
|
2139
|
+
reason: c.detail
|
|
2140
|
+
}));
|
|
2141
|
+
const summary = summarise2(plan);
|
|
2142
|
+
const exitCode = issues.length === 0 ? 0 : 1;
|
|
2143
|
+
if (writeOutput) writeEnvelope({ command: "doctor", plan, summary, diagnostics: issues, json, exitCode });
|
|
2144
|
+
process.exitCode = exitCode;
|
|
2145
|
+
return { issues, exitCode, plan, checks };
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// src/installer/commands/init.js
|
|
2149
|
+
async function runInit(options) {
|
|
2150
|
+
const preview = await previewInstall({
|
|
2151
|
+
rootPath: options.rootPath ?? null,
|
|
2152
|
+
replaceManaged: false,
|
|
2153
|
+
forceConfig: Boolean(options.forceConfig),
|
|
2154
|
+
forceRootConfig: Boolean(options.forceRootConfig)
|
|
2155
|
+
});
|
|
2156
|
+
if (!preview.ok) {
|
|
2157
|
+
return emitFailure(2, preview.error?.kind ?? "invalid-project", options.json, "init");
|
|
2158
|
+
}
|
|
2159
|
+
const committed = await commitInstall(preview, { json: options.json, command: "init" });
|
|
2160
|
+
let exitCode = committed.extra?.exitCode ?? 0;
|
|
2161
|
+
if (!committed || exitCode !== 0) {
|
|
2162
|
+
if (exitCode === 2) return emitFailure(2, committed?.diagnostics?.[0] ?? "invalid project", options.json, "init");
|
|
2163
|
+
if (exitCode === 3) return emitFailure(3, committed?.diagnostics?.[0] ?? "conflict", options.json, "init");
|
|
2164
|
+
if (exitCode === 4) return emitFailure(4, committed?.diagnostics?.[0] ?? "transaction failure", options.json, "init");
|
|
2165
|
+
return emitFailure(exitCode, committed?.diagnostics?.[0] ?? "unknown", options.json, "init");
|
|
2166
|
+
}
|
|
2167
|
+
const doctor = await runDoctor({
|
|
2168
|
+
rootPath: options.rootPath ?? null,
|
|
2169
|
+
json: Boolean(options.json),
|
|
2170
|
+
writeOutput: false
|
|
2171
|
+
});
|
|
2172
|
+
committed.extra = { ...committed.extra ?? {}, doctor: { issues: doctor.issues, checks: doctor.checks, exitCode: doctor.exitCode } };
|
|
2173
|
+
committed.diagnostics = [...committed.diagnostics ?? [], ...doctor.issues ?? []];
|
|
2174
|
+
if (doctor.issues && doctor.issues.length > 0) {
|
|
2175
|
+
committed.diagnostics = [`doctor: ${doctor.issues.length} check(s) unhealthy`, ...committed.diagnostics];
|
|
2176
|
+
if (options.strictDoctor) {
|
|
2177
|
+
exitCode = 1;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (options.json) {
|
|
2181
|
+
const envelope = {
|
|
2182
|
+
reportVersion: 1,
|
|
2183
|
+
command: "init",
|
|
2184
|
+
status: exitCode === 0 ? "ok" : "warning",
|
|
2185
|
+
plan: serializePlan(committed.plan ?? []),
|
|
2186
|
+
conflicts: committed.conflicts ?? [],
|
|
2187
|
+
summary: committed.summary ?? {},
|
|
2188
|
+
diagnostics: committed.diagnostics ?? [],
|
|
2189
|
+
doctor: doctor.issues ?? [],
|
|
2190
|
+
doctorChecks: doctor.checks ?? [],
|
|
2191
|
+
exitCode
|
|
2192
|
+
};
|
|
2193
|
+
Object.assign(envelope, committed.extra ?? {}, { doctor: doctor.issues ?? [] });
|
|
2194
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
2195
|
+
} else if (exitCode !== 0) {
|
|
2196
|
+
process.stdout.write(`opencode-ship: doctor reported ${doctor.issues.length} unhealthy check(s)
|
|
2197
|
+
`);
|
|
2198
|
+
} else {
|
|
2199
|
+
process.stdout.write(`opencode-ship: installed; doctor OK
|
|
2200
|
+
`);
|
|
2201
|
+
}
|
|
2202
|
+
process.exitCode = exitCode;
|
|
2203
|
+
return { ok: exitCode === 0, exitCode };
|
|
2204
|
+
}
|
|
2205
|
+
function emitFailure(code, message, json, command) {
|
|
2206
|
+
if (json) {
|
|
2207
|
+
process.stdout.write(JSON.stringify({
|
|
2208
|
+
reportVersion: 1,
|
|
2209
|
+
command,
|
|
2210
|
+
status: "error",
|
|
2211
|
+
plan: [],
|
|
2212
|
+
conflicts: [],
|
|
2213
|
+
summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
|
|
2214
|
+
diagnostics: [message],
|
|
2215
|
+
exitCode: code
|
|
2216
|
+
}, null, 2) + "\n");
|
|
2217
|
+
} else {
|
|
2218
|
+
process.stdout.write(`opencode-ship: ${message}
|
|
2219
|
+
`);
|
|
2220
|
+
}
|
|
2221
|
+
process.exitCode = code;
|
|
2222
|
+
return { ok: false, exitCode: code };
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/installer/commands/diff.js
|
|
2226
|
+
function summarise3(plan) {
|
|
2227
|
+
const counts = { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0, lock: 0, config: 0, rootConfig: 0 };
|
|
2228
|
+
for (const op of plan) {
|
|
2229
|
+
if (!op) continue;
|
|
2230
|
+
if (op.op === "lock") counts.lock += 1;
|
|
2231
|
+
else if (op.op === "config") counts.config += 1;
|
|
2232
|
+
else if (op.op === "root-config") counts.rootConfig += 1;
|
|
2233
|
+
else if (counts[op.kind] !== void 0) counts[op.kind] += 1;
|
|
2234
|
+
}
|
|
2235
|
+
return counts;
|
|
2236
|
+
}
|
|
2237
|
+
function serializePlan2(plan) {
|
|
2238
|
+
return plan.filter(Boolean).map((op) => {
|
|
2239
|
+
if (!op) return null;
|
|
2240
|
+
const { bytes, ...rest } = op;
|
|
2241
|
+
if (bytes && Buffer.isBuffer(bytes)) {
|
|
2242
|
+
return { ...rest, bytesLength: bytes.length };
|
|
2243
|
+
}
|
|
2244
|
+
return rest;
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
async function runDiff(options) {
|
|
2248
|
+
const preview = await previewInstall({
|
|
2249
|
+
rootPath: options.rootPath ?? null,
|
|
2250
|
+
replaceManaged: false,
|
|
2251
|
+
forceConfig: false,
|
|
2252
|
+
forceRootConfig: false
|
|
2253
|
+
});
|
|
2254
|
+
if (!preview.ok) {
|
|
2255
|
+
if (options.json) {
|
|
2256
|
+
process.stdout.write(JSON.stringify({
|
|
2257
|
+
reportVersion: 1,
|
|
2258
|
+
command: "diff",
|
|
2259
|
+
status: "error",
|
|
2260
|
+
plan: [],
|
|
2261
|
+
conflicts: [],
|
|
2262
|
+
summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
|
|
2263
|
+
diagnostics: [preview.error?.kind ?? "invalid-project"],
|
|
2264
|
+
exitCode: 2
|
|
2265
|
+
}, null, 2) + "\n");
|
|
2266
|
+
} else {
|
|
2267
|
+
process.stdout.write(`opencode-ship: ${preview.error?.kind ?? "invalid-project"}
|
|
2268
|
+
`);
|
|
2269
|
+
}
|
|
2270
|
+
process.exitCode = 2;
|
|
2271
|
+
return { ok: false, exitCode: 2 };
|
|
2272
|
+
}
|
|
2273
|
+
const { plan, conflicts, migrationReport } = preview;
|
|
2274
|
+
const summary = summarise3(plan);
|
|
2275
|
+
const changes = summary.create + summary.update + summary.delete + summary.conflict;
|
|
2276
|
+
if (options.json) {
|
|
2277
|
+
process.stdout.write(JSON.stringify({
|
|
2278
|
+
reportVersion: 1,
|
|
2279
|
+
command: "diff",
|
|
2280
|
+
status: conflicts.length > 0 ? "conflict" : changes ? "changed" : "noop",
|
|
2281
|
+
plan: serializePlan2(plan),
|
|
2282
|
+
conflicts,
|
|
2283
|
+
summary,
|
|
2284
|
+
diagnostics: [],
|
|
2285
|
+
migrationReport,
|
|
2286
|
+
exitCode: changes ? 1 : 0
|
|
2287
|
+
}, null, 2) + "\n");
|
|
2288
|
+
} else {
|
|
2289
|
+
const head = "# opencode-ship diff";
|
|
2290
|
+
const lines = [head, "", "## Plan"];
|
|
2291
|
+
for (const op of plan.filter(Boolean)) {
|
|
2292
|
+
const bytesHint = op.bytes ? `${op.bytes.length ?? 0}b` : "";
|
|
2293
|
+
lines.push(` - ${op.kind.padEnd(9)} ${op.op} ${op.relPath ?? op.target}${bytesHint ? ` (${bytesHint})` : ""}${op.reason ? ` \u2014 ${op.reason}` : ""}`);
|
|
2294
|
+
}
|
|
2295
|
+
if (conflicts.length) {
|
|
2296
|
+
lines.push("", `## Conflicts (${conflicts.length})`);
|
|
2297
|
+
for (const c of conflicts) lines.push(` - ${c.relPath ?? c.target}: ${c.reason}`);
|
|
2298
|
+
}
|
|
2299
|
+
lines.push("", `Summary: ${JSON.stringify(summary)}`);
|
|
2300
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2301
|
+
}
|
|
2302
|
+
process.exitCode = changes ? 1 : 0;
|
|
2303
|
+
return { ok: true, exitCode: changes ? 1 : 0 };
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/installer/commands/update.js
|
|
2307
|
+
async function runUpdate(options) {
|
|
2308
|
+
const preview = await previewInstall({
|
|
2309
|
+
rootPath: options.rootPath,
|
|
2310
|
+
replaceManaged: options.replaceManaged,
|
|
2311
|
+
forceConfig: options.forceConfig,
|
|
2312
|
+
forceRootConfig: options.forceRootConfig
|
|
2313
|
+
});
|
|
2314
|
+
if (!preview.ok) {
|
|
2315
|
+
return emitFailure2(2, preview.error?.kind ?? "invalid-project", options.json, "update");
|
|
2316
|
+
}
|
|
2317
|
+
if (preview.conflicts.length > 0 && !options.replaceManaged) {
|
|
2318
|
+
return emitFailure2(3, "modified managed files; rerun with --replace-managed", options.json, "update");
|
|
2319
|
+
}
|
|
2320
|
+
const committed = await commitInstall(preview, { json: options.json, command: "update" });
|
|
2321
|
+
if (options.json) {
|
|
2322
|
+
process.stdout.write(JSON.stringify({
|
|
2323
|
+
reportVersion: 1,
|
|
2324
|
+
command: "update",
|
|
2325
|
+
status: committed.extra?.exitCode === 0 ? "ok" : "error",
|
|
2326
|
+
plan: serializePlan(committed.plan ?? []),
|
|
2327
|
+
conflicts: committed.conflicts ?? [],
|
|
2328
|
+
summary: committed.summary ?? {},
|
|
2329
|
+
diagnostics: committed.diagnostics ?? [],
|
|
2330
|
+
exitCode: committed.extra?.exitCode ?? 0,
|
|
2331
|
+
...committed.extra ?? {}
|
|
2332
|
+
}, null, 2) + "\n");
|
|
2333
|
+
} else if (committed.extra?.exitCode === 0) {
|
|
2334
|
+
process.stdout.write(`opencode-ship: update OK
|
|
2335
|
+
`);
|
|
2336
|
+
}
|
|
2337
|
+
process.exitCode = committed.extra?.exitCode ?? 0;
|
|
2338
|
+
return committed;
|
|
2339
|
+
}
|
|
2340
|
+
function emitFailure2(code, message, json, command) {
|
|
2341
|
+
if (json) {
|
|
2342
|
+
process.stdout.write(JSON.stringify({
|
|
2343
|
+
reportVersion: 1,
|
|
2344
|
+
command,
|
|
2345
|
+
status: "error",
|
|
2346
|
+
plan: [],
|
|
2347
|
+
conflicts: [],
|
|
2348
|
+
summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
|
|
2349
|
+
diagnostics: [message],
|
|
2350
|
+
exitCode: code
|
|
2351
|
+
}, null, 2) + "\n");
|
|
2352
|
+
} else {
|
|
2353
|
+
process.stdout.write(`opencode-ship: ${message}
|
|
2354
|
+
`);
|
|
2355
|
+
}
|
|
2356
|
+
process.exitCode = code;
|
|
2357
|
+
return { ok: false, exitCode: code };
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// src/installer/commands/uninstall.js
|
|
2361
|
+
import { unlink as unlink3 } from "node:fs/promises";
|
|
2362
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
2363
|
+
import { resolve as resolve11 } from "node:path";
|
|
2364
|
+
async function runUninstall(options) {
|
|
2365
|
+
const preview = await previewUninstall({ rootPath: options.rootPath });
|
|
2366
|
+
if (!preview.ok) {
|
|
2367
|
+
return emitFailure3(2, preview.error?.kind ?? "invalid-project", options.json);
|
|
2368
|
+
}
|
|
2369
|
+
const { repoRoot, plan, conflicts, summary } = preview;
|
|
2370
|
+
if (conflicts.length > 0) {
|
|
2371
|
+
return emitReport(plan, conflicts, summary, options.json, 3, ["modified managed files; refusing to delete"]);
|
|
2372
|
+
}
|
|
2373
|
+
const tx = await executePlan({ repoRoot, plan, newLockBuilder: null });
|
|
2374
|
+
if (!tx.ok) {
|
|
2375
|
+
return emitFailure3(4, tx.error?.message ?? "transaction failure", options.json);
|
|
2376
|
+
}
|
|
2377
|
+
const lockPath2 = resolve11(repoRoot, ".opencode", "ship.lock.json");
|
|
2378
|
+
if (existsSync10(lockPath2)) await unlink3(lockPath2).catch(() => null);
|
|
2379
|
+
if (options.purgeConfig) {
|
|
2380
|
+
await unlink3(configPath(repoRoot)).catch(() => null);
|
|
2381
|
+
}
|
|
2382
|
+
return emitReport(plan, [], summary, options.json, 0, [tx.recovered ? "journal recovered before uninstall" : ""].filter(Boolean));
|
|
2383
|
+
}
|
|
2384
|
+
function emitFailure3(code, message, json) {
|
|
2385
|
+
if (json) {
|
|
2386
|
+
process.stdout.write(JSON.stringify({
|
|
2387
|
+
reportVersion: 1,
|
|
2388
|
+
command: "uninstall",
|
|
2389
|
+
status: "error",
|
|
2390
|
+
plan: [],
|
|
2391
|
+
conflicts: [],
|
|
2392
|
+
summary: { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0 },
|
|
2393
|
+
diagnostics: [message],
|
|
2394
|
+
exitCode: code
|
|
2395
|
+
}, null, 2) + "\n");
|
|
2396
|
+
} else {
|
|
2397
|
+
process.stdout.write(`opencode-ship: ${message}
|
|
2398
|
+
`);
|
|
2399
|
+
}
|
|
2400
|
+
process.exitCode = code;
|
|
2401
|
+
return { ok: false, exitCode: code };
|
|
2402
|
+
}
|
|
2403
|
+
function emitReport(plan, conflicts, summary, json, exitCode, diagnostics) {
|
|
2404
|
+
const safePlan = plan.map((op) => {
|
|
2405
|
+
const { bytes, ...rest } = op ?? {};
|
|
2406
|
+
return bytes && Buffer.isBuffer(bytes) ? { ...rest, bytesLength: bytes.length } : rest;
|
|
2407
|
+
});
|
|
2408
|
+
if (json) {
|
|
2409
|
+
process.stdout.write(JSON.stringify({
|
|
2410
|
+
reportVersion: 1,
|
|
2411
|
+
command: "uninstall",
|
|
2412
|
+
status: conflicts.length > 0 ? "conflict" : exitCode === 0 ? "ok" : "error",
|
|
2413
|
+
plan: safePlan,
|
|
2414
|
+
conflicts,
|
|
2415
|
+
summary,
|
|
2416
|
+
diagnostics,
|
|
2417
|
+
exitCode
|
|
2418
|
+
}, null, 2) + "\n");
|
|
2419
|
+
} else {
|
|
2420
|
+
const head = "# opencode-ship uninstall";
|
|
2421
|
+
const lines = [head, "", "## Plan"];
|
|
2422
|
+
for (const op of plan) lines.push(` - ${op.kind.padEnd(9)} ${op.relPath ?? op.target}${op.reason ? ` \u2014 ${op.reason}` : ""}`);
|
|
2423
|
+
if (conflicts.length) {
|
|
2424
|
+
lines.push("", `## Conflicts (${conflicts.length})`);
|
|
2425
|
+
for (const c of conflicts) lines.push(` - ${c.relPath ?? c.target}: ${c.reason}`);
|
|
2426
|
+
}
|
|
2427
|
+
lines.push("", `Summary: ${JSON.stringify(summary)}`);
|
|
2428
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2429
|
+
}
|
|
2430
|
+
process.exitCode = exitCode;
|
|
2431
|
+
return { ok: true, exitCode };
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// src/cli.js
|
|
2435
|
+
var VERSION = "0.2.0";
|
|
2436
|
+
async function main() {
|
|
2437
|
+
const parsed = parseCommand(process.argv.slice(2));
|
|
2438
|
+
if (parsed.command === "help") {
|
|
2439
|
+
process.stdout.write(helpText() + "\n");
|
|
2440
|
+
process.exitCode = 0;
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
if (parsed.command === "version") {
|
|
2444
|
+
process.stdout.write(`opencode-ship ${VERSION}
|
|
2445
|
+
`);
|
|
2446
|
+
process.exitCode = 0;
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
if ("error" in parsed) {
|
|
2450
|
+
process.stdout.write(`opencode-ship: ${parsed.error}
|
|
2451
|
+
|
|
2452
|
+
${helpText()}`);
|
|
2453
|
+
process.exitCode = 2;
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
const options = parsed.options ?? {};
|
|
2457
|
+
const opts = options;
|
|
2458
|
+
switch (parsed.command) {
|
|
2459
|
+
case "init":
|
|
2460
|
+
await runInit({ json: !!opts.json, rootPath: opts.rootPath, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
|
|
2461
|
+
return;
|
|
2462
|
+
case "diff":
|
|
2463
|
+
await runDiff({ json: !!opts.json, rootPath: opts.rootPath });
|
|
2464
|
+
return;
|
|
2465
|
+
case "update":
|
|
2466
|
+
await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
|
|
2467
|
+
return;
|
|
2468
|
+
case "doctor":
|
|
2469
|
+
await runDoctor({ json: !!opts.json, rootPath: opts.rootPath });
|
|
2470
|
+
return;
|
|
2471
|
+
case "uninstall":
|
|
2472
|
+
await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, purgeConfig: !!opts.purgeConfig });
|
|
2473
|
+
return;
|
|
2474
|
+
default:
|
|
2475
|
+
process.stdout.write(helpText() + "\n");
|
|
2476
|
+
process.exitCode = 2;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
main().catch((e) => {
|
|
2480
|
+
process.stderr.write(`opencode-ship: internal failure: ${e?.message ?? String(e)}
|
|
2481
|
+
`);
|
|
2482
|
+
if (e?.stack) process.stderr.write(e.stack + "\n");
|
|
2483
|
+
process.exitCode = 4;
|
|
2484
|
+
});
|