@elmundi/ship-cli 0.14.2 → 0.15.3
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/README.md +17 -16
- package/bin/shipctl.mjs +4 -80
- package/lib/commands/feedback.mjs +1 -1
- package/lib/commands/help.mjs +47 -131
- package/lib/commands/init.mjs +17 -250
- package/lib/commands/knowledge.mjs +25 -328
- package/lib/commands/preflight.mjs +213 -0
- package/lib/commands/run.mjs +266 -116
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +68 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +34 -0
- package/lib/templates.mjs +2 -2
- package/lib/verify/checks/agents-on-disk.mjs +5 -28
- package/lib/verify/registry.mjs +7 -8
- package/package.json +1 -1
- package/lib/artifacts/fs-index.mjs +0 -230
- package/lib/cache/store.mjs +0 -422
- package/lib/commands/bootstrap.mjs +0 -4
- package/lib/commands/callback.mjs +0 -742
- package/lib/commands/docs.mjs +0 -90
- package/lib/commands/kickoff.mjs +0 -192
- package/lib/commands/lanes.mjs +0 -566
- package/lib/commands/manifest-catalog.mjs +0 -251
- package/lib/commands/migrate.mjs +0 -204
- package/lib/commands/new.mjs +0 -452
- package/lib/commands/patterns.mjs +0 -160
- package/lib/commands/process.mjs +0 -388
- package/lib/commands/search.mjs +0 -43
- package/lib/commands/sync.mjs +0 -824
- package/lib/config/migrate.mjs +0 -223
- package/lib/find-ship-root.mjs +0 -75
- package/lib/process/specialist-prompt-contract.mjs +0 -171
- package/lib/state/lockfile.mjs +0 -180
- package/lib/vendor/run-agent.workflow.yml +0 -254
- package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
- package/lib/verify/checks/cache-integrity.mjs +0 -51
- package/lib/verify/checks/gitignore-cache.mjs +0 -51
- package/lib/verify/checks/rules-markers.mjs +0 -135
package/lib/commands/sync.mjs
DELETED
|
@@ -1,824 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
readConfig,
|
|
6
|
-
readState,
|
|
7
|
-
writeState,
|
|
8
|
-
findShipRoot,
|
|
9
|
-
} from "../config/io.mjs";
|
|
10
|
-
import { validateConfig, lanePatterns as lanePatternList } from "../config/schema.mjs";
|
|
11
|
-
import { fetchManifest, fetchArtifact } from "../http.mjs";
|
|
12
|
-
import {
|
|
13
|
-
readCached,
|
|
14
|
-
writeCached,
|
|
15
|
-
listCached,
|
|
16
|
-
cachePath,
|
|
17
|
-
verifyCachedOnDisk,
|
|
18
|
-
} from "../cache/store.mjs";
|
|
19
|
-
import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
|
|
20
|
-
import { readArtifactFile, scanArtifacts } from "../artifacts/fs-index.mjs";
|
|
21
|
-
import {
|
|
22
|
-
writeLockfile,
|
|
23
|
-
entryFromBody,
|
|
24
|
-
lockKey,
|
|
25
|
-
LOCKFILE_SCHEMA_VERSION,
|
|
26
|
-
} from "../state/lockfile.mjs";
|
|
27
|
-
import { getCliVersion } from "../version.mjs";
|
|
28
|
-
|
|
29
|
-
function printSyncHelp() {
|
|
30
|
-
console.log(`shipctl sync — fetch the catalog into .ship/cache (and optionally lock).
|
|
31
|
-
|
|
32
|
-
WHAT THIS COMMAND DOES
|
|
33
|
-
Pulls the artifacts your repo declares — pins, the active preset,
|
|
34
|
-
per-agent rule collections, and any pattern referenced by your lanes
|
|
35
|
-
(Automations in the operator console) — from the methodology API
|
|
36
|
-
into .ship/cache/<kind>/<id>@<version>/. Verifies content_sha256,
|
|
37
|
-
writes meta, optionally produces a lockfile so 'shipctl run --offline'
|
|
38
|
-
is reproducible.
|
|
39
|
-
|
|
40
|
-
USAGE
|
|
41
|
-
shipctl sync [--check-only] [--only <kind:id>]... [--channel <c>]
|
|
42
|
-
[--force-unpin] [--dry-run] [--lock] [--json] [--cwd <dir>]
|
|
43
|
-
|
|
44
|
-
FLAGS
|
|
45
|
-
--check-only Report what would change; do not write to disk.
|
|
46
|
-
--only <kind:id> Restrict to one or more artifacts (repeatable).
|
|
47
|
-
Example: --only pattern:role-developer --only collection:preset-web-app
|
|
48
|
-
--channel <c> Override config.api.channel for this invocation
|
|
49
|
-
(stable|edge).
|
|
50
|
-
--force-unpin Ignore artifacts.pins[] and pull the manifest
|
|
51
|
-
version. Use when intentionally bumping a pin.
|
|
52
|
-
--dry-run Print the resolution plan; do not write or fetch.
|
|
53
|
-
--lock After sync, materialise every pattern referenced
|
|
54
|
-
by the declared routines and write
|
|
55
|
-
.ship/shipctl.lock.json (used by
|
|
56
|
-
'shipctl run --offline').
|
|
57
|
-
--json Emit a structured JSON summary on stdout.
|
|
58
|
-
--cwd <dir> Repo root. Defaults to the current directory;
|
|
59
|
-
searches upward for .ship/.
|
|
60
|
-
--help, -h Show this help.
|
|
61
|
-
|
|
62
|
-
EXAMPLES
|
|
63
|
-
shipctl sync # baseline pull
|
|
64
|
-
shipctl sync --check-only --json # CI guard
|
|
65
|
-
shipctl sync --only pattern:role-developer --only tool:methodology-api
|
|
66
|
-
shipctl sync --lock # produce a reproducible lockfile
|
|
67
|
-
|
|
68
|
-
EXIT CODE
|
|
69
|
-
0 when everything resolved.
|
|
70
|
-
20 when at least one artifact failed to fetch (or --lock left
|
|
71
|
-
unresolved entries).
|
|
72
|
-
`);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function parseSyncArgs(rest) {
|
|
76
|
-
const out = {
|
|
77
|
-
cwd: process.cwd(),
|
|
78
|
-
checkOnly: false,
|
|
79
|
-
dryRun: false,
|
|
80
|
-
forceUnpin: false,
|
|
81
|
-
channel: null,
|
|
82
|
-
only: [],
|
|
83
|
-
lock: false,
|
|
84
|
-
json: false,
|
|
85
|
-
help: false,
|
|
86
|
-
unknown: [],
|
|
87
|
-
};
|
|
88
|
-
const copy = [...rest];
|
|
89
|
-
while (copy.length) {
|
|
90
|
-
const a = copy[0];
|
|
91
|
-
if (a === "--help" || a === "-h") {
|
|
92
|
-
out.help = true;
|
|
93
|
-
copy.shift();
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (a === "--check-only") {
|
|
97
|
-
out.checkOnly = true;
|
|
98
|
-
copy.shift();
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (a === "--dry-run") {
|
|
102
|
-
out.dryRun = true;
|
|
103
|
-
copy.shift();
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
if (a === "--force-unpin") {
|
|
107
|
-
out.forceUnpin = true;
|
|
108
|
-
copy.shift();
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (a === "--lock") {
|
|
112
|
-
out.lock = true;
|
|
113
|
-
copy.shift();
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
if (a === "--json") {
|
|
117
|
-
out.json = true;
|
|
118
|
-
copy.shift();
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
if (a === "--channel" && copy[1]) {
|
|
122
|
-
copy.shift();
|
|
123
|
-
out.channel = copy.shift();
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (a.startsWith("--channel=")) {
|
|
127
|
-
out.channel = a.slice("--channel=".length);
|
|
128
|
-
copy.shift();
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (a === "--only" && copy[1]) {
|
|
132
|
-
copy.shift();
|
|
133
|
-
out.only.push(copy.shift());
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (a.startsWith("--only=")) {
|
|
137
|
-
out.only.push(a.slice("--only=".length));
|
|
138
|
-
copy.shift();
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (a === "--cwd" && copy[1]) {
|
|
142
|
-
copy.shift();
|
|
143
|
-
out.cwd = copy.shift();
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (a.startsWith("--cwd=")) {
|
|
147
|
-
out.cwd = a.slice("--cwd=".length);
|
|
148
|
-
copy.shift();
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
/* Previously we silently dropped unrecognised tokens here. That
|
|
152
|
-
* hid bashisms like a misspelt `--cheek-only`, so we now collect
|
|
153
|
-
* them and warn from `syncCommand` once parsing is complete.
|
|
154
|
-
* Stays non-fatal because existing CI scripts may rely on the old
|
|
155
|
-
* permissive behaviour. */
|
|
156
|
-
out.unknown.push(a);
|
|
157
|
-
copy.shift();
|
|
158
|
-
}
|
|
159
|
-
return out;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function parseOnlySpec(spec) {
|
|
163
|
-
const idx = spec.indexOf(":");
|
|
164
|
-
if (idx <= 0) return null;
|
|
165
|
-
return { kind: spec.slice(0, idx), id: spec.slice(idx + 1) };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function pinSatisfies(pin, version) {
|
|
169
|
-
if (!pin) return true;
|
|
170
|
-
const p = pin.trim();
|
|
171
|
-
if (p === version) return true;
|
|
172
|
-
if (/^\d+(\.\d+){0,2}$/.test(p)) return version.startsWith(p);
|
|
173
|
-
// Caret / tilde / comparators: conservative match on major.minor for ranges.
|
|
174
|
-
const m = p.match(/^(\^|~)(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
175
|
-
if (m) {
|
|
176
|
-
const [, op, maj, min] = m;
|
|
177
|
-
const parts = version.split(".");
|
|
178
|
-
if (parts[0] !== maj) return false;
|
|
179
|
-
if (op === "~" && min !== undefined && parts[1] !== min) return false;
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function hoursSince(iso) {
|
|
186
|
-
if (!iso) return Infinity;
|
|
187
|
-
const ts = Date.parse(iso);
|
|
188
|
-
if (Number.isNaN(ts)) return Infinity;
|
|
189
|
-
return (Date.now() - ts) / (1000 * 60 * 60);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function manifestHash(entries) {
|
|
193
|
-
const canonical = JSON.stringify(
|
|
194
|
-
entries.map((e) => ({
|
|
195
|
-
kind: e.kind,
|
|
196
|
-
id: e.id,
|
|
197
|
-
version: e.version,
|
|
198
|
-
content_sha256: e.content_sha256,
|
|
199
|
-
})),
|
|
200
|
-
);
|
|
201
|
-
return crypto.createHash("sha256").update(canonical).digest("hex");
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function appendTelemetryEvent(shipRoot, config, event) {
|
|
205
|
-
if (!config.telemetry || config.telemetry.share !== true) return;
|
|
206
|
-
const file = path.join(shipRoot, ".ship", "telemetry-outbox.jsonl");
|
|
207
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
208
|
-
const envelope = {
|
|
209
|
-
event: event.event,
|
|
210
|
-
ts: new Date().toISOString(),
|
|
211
|
-
anonymous_id: config.telemetry.anonymous_id,
|
|
212
|
-
shipctl_version: "0.9.0",
|
|
213
|
-
stack_preset: config.stack?.preset || null,
|
|
214
|
-
payload: event.payload,
|
|
215
|
-
};
|
|
216
|
-
fs.appendFileSync(file, `${JSON.stringify(envelope)}\n`, "utf8");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Build the set of desired {kind,id} targets given config + cache + manifest.
|
|
221
|
-
*/
|
|
222
|
-
function computeDesired(config, manifestEntries, cached, onlySpecs) {
|
|
223
|
-
if (onlySpecs.length > 0) {
|
|
224
|
-
const specs = onlySpecs.map(parseOnlySpec).filter(Boolean);
|
|
225
|
-
return manifestEntries.filter((e) =>
|
|
226
|
-
specs.some((s) => s.kind === e.kind && s.id === e.id),
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const wanted = new Map();
|
|
231
|
-
const add = (e) => {
|
|
232
|
-
const key = `${e.kind}:${e.id}`;
|
|
233
|
-
if (!wanted.has(key)) wanted.set(key, e);
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const pins = config.artifacts?.pins || {};
|
|
237
|
-
for (const pinKey of Object.keys(pins)) {
|
|
238
|
-
const slash = pinKey.indexOf("/");
|
|
239
|
-
if (slash < 0) continue;
|
|
240
|
-
const kind = pinKey.slice(0, slash);
|
|
241
|
-
const id = pinKey.slice(slash + 1);
|
|
242
|
-
const entry = manifestEntries.find((e) => e.kind === kind && e.id === id);
|
|
243
|
-
if (entry) add(entry);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
for (const c of cached) {
|
|
247
|
-
const entry = manifestEntries.find((e) => e.kind === c.kind && e.id === c.id);
|
|
248
|
-
if (entry) add(entry);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const preset = config.stack?.preset;
|
|
252
|
-
if (preset) {
|
|
253
|
-
const presetCollectionId = `preset-${preset}`;
|
|
254
|
-
const e = manifestEntries.find(
|
|
255
|
-
(m) => m.kind === "collection" && m.id === presetCollectionId,
|
|
256
|
-
);
|
|
257
|
-
if (e) add(e);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
for (const agent of config.stack?.agents || []) {
|
|
261
|
-
const e = manifestEntries.find(
|
|
262
|
-
(m) => m.kind === "collection" && m.id === `agent-rules/${agent}`,
|
|
263
|
-
);
|
|
264
|
-
if (e) add(e);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return [...wanted.values()];
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Reusable sync implementation, suitable for embedding from other CLI
|
|
272
|
-
* commands (notably `shipctl init`). Returns a structured summary instead
|
|
273
|
-
* of calling `process.exit`.
|
|
274
|
-
*
|
|
275
|
-
* @typedef {Object} SyncOptions
|
|
276
|
-
* @property {string} [cwd]
|
|
277
|
-
* @property {string} [baseUrl]
|
|
278
|
-
* @property {string} [channel]
|
|
279
|
-
* @property {boolean} [dryRun]
|
|
280
|
-
* @property {boolean} [checkOnly]
|
|
281
|
-
* @property {boolean} [forceUnpin]
|
|
282
|
-
* @property {string[]} [only] "kind:id" specs; overrides config-derived desired set
|
|
283
|
-
* @property {Array<string|{kind:string,id:string}>} [include]
|
|
284
|
-
* Additional specs merged with `only` (kept separate so callers
|
|
285
|
-
* can reason about them independently).
|
|
286
|
-
* @property {string[]} [onlyKinds] Optional post-filter on manifest entries (kind whitelist)
|
|
287
|
-
* @property {boolean} [verbose] When true, write human progress to stdout (default: CLI only)
|
|
288
|
-
*
|
|
289
|
-
* @returns {Promise<{
|
|
290
|
-
* up_to_date:number, updated:number, skipped_pin:number,
|
|
291
|
-
* deprecated:number, yanked:number, failed:number,
|
|
292
|
-
* notes:string[], entries:Array<{kind:string,id:string,version:string,action:string}>
|
|
293
|
-
* }>}
|
|
294
|
-
*/
|
|
295
|
-
export async function syncArtifacts(options = {}) {
|
|
296
|
-
const {
|
|
297
|
-
cwd = process.cwd(),
|
|
298
|
-
baseUrl: baseUrlOpt,
|
|
299
|
-
channel: channelOpt,
|
|
300
|
-
dryRun = false,
|
|
301
|
-
checkOnly = false,
|
|
302
|
-
forceUnpin = false,
|
|
303
|
-
only = [],
|
|
304
|
-
include = [],
|
|
305
|
-
onlyKinds = null,
|
|
306
|
-
verbose = false,
|
|
307
|
-
} = options;
|
|
308
|
-
|
|
309
|
-
const root = findShipRoot(cwd);
|
|
310
|
-
if (!root) {
|
|
311
|
-
const err = new Error(".ship/ not found. Run 'shipctl config init' first.");
|
|
312
|
-
err.exitCode = 10;
|
|
313
|
-
throw err;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const { config } = readConfig(root);
|
|
317
|
-
const valid = validateConfig(config);
|
|
318
|
-
if (!valid.ok) {
|
|
319
|
-
const err = new Error(valid.errors.join("\n"));
|
|
320
|
-
err.exitCode = 10;
|
|
321
|
-
throw err;
|
|
322
|
-
}
|
|
323
|
-
if (verbose) for (const w of valid.warnings) console.error(`warn: ${w}`);
|
|
324
|
-
|
|
325
|
-
const baseUrl = (
|
|
326
|
-
baseUrlOpt ||
|
|
327
|
-
process.env.SHIP_API_BASE ||
|
|
328
|
-
config.api?.base_url ||
|
|
329
|
-
"https://ship.elmundi.com"
|
|
330
|
-
).replace(/\/$/, "");
|
|
331
|
-
const channel = channelOpt || process.env.SHIP_CHANNEL || config.api?.channel || "stable";
|
|
332
|
-
const ttlHours =
|
|
333
|
-
typeof config.api?.ttl_hours === "number" ? config.api.ttl_hours : 24;
|
|
334
|
-
|
|
335
|
-
if (dryRun && verbose) {
|
|
336
|
-
console.log(
|
|
337
|
-
`plan: GET ${baseUrl}/{patterns,tools,collections} (channel=${channel})`,
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
let manifestEntries;
|
|
342
|
-
try {
|
|
343
|
-
manifestEntries = await fetchManifest(baseUrl, { channel });
|
|
344
|
-
} catch (e) {
|
|
345
|
-
const err = new Error(e.message);
|
|
346
|
-
err.exitCode = 20;
|
|
347
|
-
throw err;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (Array.isArray(onlyKinds) && onlyKinds.length) {
|
|
351
|
-
manifestEntries = manifestEntries.filter((m) => onlyKinds.includes(m.kind));
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const { state } = readState(root);
|
|
355
|
-
const cached = listCached(root);
|
|
356
|
-
|
|
357
|
-
// Normalise "include" into "kind:id" strings, merged with `only`.
|
|
358
|
-
const mergedOnly = [
|
|
359
|
-
...only,
|
|
360
|
-
...include
|
|
361
|
-
.map((e) => (typeof e === "string" ? e : e && e.kind && e.id ? `${e.kind}:${e.id}` : null))
|
|
362
|
-
.filter(Boolean),
|
|
363
|
-
];
|
|
364
|
-
|
|
365
|
-
const desired = computeDesired(config, manifestEntries, cached, mergedOnly);
|
|
366
|
-
|
|
367
|
-
const summary = {
|
|
368
|
-
up_to_date: 0,
|
|
369
|
-
updated: 0,
|
|
370
|
-
skipped_pin: 0,
|
|
371
|
-
deprecated: 0,
|
|
372
|
-
yanked: 0,
|
|
373
|
-
failed: 0,
|
|
374
|
-
};
|
|
375
|
-
/** @type {string[]} */
|
|
376
|
-
const notes = [];
|
|
377
|
-
/** @type {Array<{kind:string,id:string,version:string,action:string}>} */
|
|
378
|
-
const entries = [];
|
|
379
|
-
const pins = config.artifacts?.pins || {};
|
|
380
|
-
|
|
381
|
-
for (const entry of desired) {
|
|
382
|
-
const key = `${entry.kind}/${entry.id}`;
|
|
383
|
-
if (entry.yanked === true) {
|
|
384
|
-
summary.yanked += 1;
|
|
385
|
-
notes.push(`yanked: ${key}@${entry.version}`);
|
|
386
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "yanked" });
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
if (entry.deprecated === true) {
|
|
390
|
-
const isPinned = Object.prototype.hasOwnProperty.call(pins, key);
|
|
391
|
-
summary.deprecated += 1;
|
|
392
|
-
notes.push(
|
|
393
|
-
`deprecated: ${key}@${entry.version}${entry.replaced_by ? ` → ${entry.replaced_by}` : ""}`,
|
|
394
|
-
);
|
|
395
|
-
if (!isPinned) {
|
|
396
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "deprecated" });
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const pin = pins[key];
|
|
402
|
-
if (pin && !pinSatisfies(pin, entry.version) && !forceUnpin) {
|
|
403
|
-
summary.skipped_pin += 1;
|
|
404
|
-
notes.push(`skipped_pin: ${key} pinned=${pin} upstream=${entry.version}`);
|
|
405
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "skipped_pin" });
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const localAll = cached.filter((c) => c.kind === entry.kind && c.id === entry.id);
|
|
410
|
-
const sameVersion = localAll.find((c) => c.version === entry.version);
|
|
411
|
-
if (sameVersion) {
|
|
412
|
-
const existingHash = sameVersion.sha256 === entry.content_sha256;
|
|
413
|
-
const age = hoursSince(sameVersion.fetched_at);
|
|
414
|
-
if (existingHash && age < ttlHours) {
|
|
415
|
-
// Physical-presence & integrity guard: meta.json alone is not enough
|
|
416
|
-
// — the rendered body may have been deleted or edited in place, in
|
|
417
|
-
// which case we must force a re-fetch so the cache matches disk.
|
|
418
|
-
const onDisk = verifyCachedOnDisk(root, entry.kind, entry.id, entry.version);
|
|
419
|
-
if (onDisk.ok) {
|
|
420
|
-
summary.up_to_date += 1;
|
|
421
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "up_to_date" });
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
notes.push(
|
|
425
|
-
`refetch: ${key}@${entry.version} (${onDisk.reason === "missing_body" ? "missing" : onDisk.reason === "drift" ? "drifted" : onDisk.reason || "invalid"})`,
|
|
426
|
-
);
|
|
427
|
-
// Fall through to the fetch branch below (respect checkOnly/dryRun).
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (checkOnly || dryRun) {
|
|
432
|
-
summary.updated += 1;
|
|
433
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "would_update" });
|
|
434
|
-
if (dryRun && verbose) {
|
|
435
|
-
console.log(`plan: POST ${baseUrl}/fetch ${JSON.stringify({ kind: entry.kind, id: entry.id, version: entry.version })}`);
|
|
436
|
-
}
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
try {
|
|
441
|
-
const { content, meta } = await fetchArtifact(baseUrl, entry.kind, entry.id, entry.version);
|
|
442
|
-
if (entry.content_sha256 && meta.content_sha256 !== entry.content_sha256) {
|
|
443
|
-
summary.failed += 1;
|
|
444
|
-
notes.push(
|
|
445
|
-
`failed: ${key}@${entry.version} content_sha256 mismatch (manifest=${entry.content_sha256} got=${meta.content_sha256})`,
|
|
446
|
-
);
|
|
447
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "failed" });
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
writeCached(root, entry.kind, entry.id, entry.version, content, {
|
|
451
|
-
...meta,
|
|
452
|
-
updated_at: entry.updated_at || meta.updated_at,
|
|
453
|
-
channel: entry.channel || meta.channel,
|
|
454
|
-
});
|
|
455
|
-
summary.updated += 1;
|
|
456
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "updated" });
|
|
457
|
-
} catch (e) {
|
|
458
|
-
summary.failed += 1;
|
|
459
|
-
notes.push(`failed: ${key}@${entry.version}: ${e.message}`);
|
|
460
|
-
entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "failed" });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (!checkOnly && !dryRun) {
|
|
465
|
-
const newState = {
|
|
466
|
-
...state,
|
|
467
|
-
last_sync_at: new Date().toISOString(),
|
|
468
|
-
last_manifest_hash: manifestHash(manifestEntries),
|
|
469
|
-
};
|
|
470
|
-
writeState(root, newState);
|
|
471
|
-
|
|
472
|
-
appendTelemetryEvent(root, config, {
|
|
473
|
-
event: "artifact.sync",
|
|
474
|
-
payload: {
|
|
475
|
-
categories: [...new Set(desired.map((e) => e.kind))].sort(),
|
|
476
|
-
updates_count: summary.updated,
|
|
477
|
-
failures_count: summary.failed,
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return { ...summary, notes, entries };
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Produce a lockfile covering every pattern the config's lanes depend on,
|
|
487
|
-
* plus any pattern the config pins explicitly. Other artifact kinds are
|
|
488
|
-
* out of scope today — lanes only reference patterns, and pins for tools
|
|
489
|
-
* or collections don't need reproducibility guarantees at run-time (yet).
|
|
490
|
-
*
|
|
491
|
-
* Resolution order per pattern:
|
|
492
|
-
* 1. `.ship/cache/pattern/<id>@<v>/ARTIFACT.md` (materialised by sync).
|
|
493
|
-
* 2. Ship monorepo fallback (`artifacts/patterns/<id>/ARTIFACT.md`).
|
|
494
|
-
* 3. One-shot POST /fetch to the methodology API.
|
|
495
|
-
*
|
|
496
|
-
* Returns a structured report instead of writing to disk directly so the
|
|
497
|
-
* caller can roll it into the overall sync summary and fail the job on
|
|
498
|
-
* unresolved patterns.
|
|
499
|
-
*
|
|
500
|
-
* @param {Object} opts
|
|
501
|
-
* @param {string} opts.shipRoot
|
|
502
|
-
* @param {Object} opts.config
|
|
503
|
-
* @param {string} opts.baseUrl
|
|
504
|
-
* @param {string} opts.channel
|
|
505
|
-
* @param {boolean} [opts.verbose]
|
|
506
|
-
* @returns {Promise<{ lockfile:object, resolved:Array<object>, unresolved:Array<object>, notes:string[] }>}
|
|
507
|
-
*/
|
|
508
|
-
export async function buildLockfile({ shipRoot, config, baseUrl, channel, verbose = false }) {
|
|
509
|
-
/** @type {Record<string, object>} */
|
|
510
|
-
const artifacts = {};
|
|
511
|
-
/** @type {Array<{kind:string,id:string,version:string,source:string,pinned:boolean}>} */
|
|
512
|
-
const resolved = [];
|
|
513
|
-
/** @type {Array<{kind:string,id:string,reason:string}>} */
|
|
514
|
-
const unresolved = [];
|
|
515
|
-
/** @type {string[]} */
|
|
516
|
-
const notes = [];
|
|
517
|
-
|
|
518
|
-
const pins = config.artifacts?.pins || {};
|
|
519
|
-
/* Flatten each lane into one (laneId, patternId) row per pattern so
|
|
520
|
-
* lanes that declare ``patterns: [a, b]`` (RFC-0008 C3.1) feed both
|
|
521
|
-
* into the sync/lockfile pipeline. Legacy ``pattern: <id>`` lanes
|
|
522
|
-
* normalise to a single-element list via lanePatternList(). */
|
|
523
|
-
const laneRows = [];
|
|
524
|
-
for (const [laneId, lane] of Object.entries(config.lanes || {})) {
|
|
525
|
-
for (const pid of lanePatternList(lane)) {
|
|
526
|
-
laneRows.push({ laneId, patternId: pid });
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
const pinRows = Object.keys(pins)
|
|
530
|
-
.filter((k) => k.startsWith("pattern/"))
|
|
531
|
-
.map((k) => ({ laneId: null, patternId: k.slice("pattern/".length) }));
|
|
532
|
-
|
|
533
|
-
/* De-duplicate on pattern id while preserving lane provenance (useful
|
|
534
|
-
* for the `notes` field — operators want to know which lane pinned a
|
|
535
|
-
* given pattern when they read the diff). */
|
|
536
|
-
const seen = new Map();
|
|
537
|
-
for (const row of [...laneRows, ...pinRows]) {
|
|
538
|
-
const pid = row.patternId;
|
|
539
|
-
if (!seen.has(pid)) seen.set(pid, { id: pid, by: [] });
|
|
540
|
-
seen.get(pid).by.push(row.laneId || "config.artifacts.pins");
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const shipRepo = resolveShipRepoRootForCatalog();
|
|
544
|
-
const cached = listCached(shipRoot);
|
|
545
|
-
|
|
546
|
-
for (const [patternId, ctx] of seen) {
|
|
547
|
-
const pinKey = `pattern/${patternId}`;
|
|
548
|
-
const isPinned = Object.prototype.hasOwnProperty.call(pins, pinKey);
|
|
549
|
-
|
|
550
|
-
/* 1) Look for an already-cached copy. */
|
|
551
|
-
const localAll = cached.filter((c) => c.kind === "pattern" && c.id === patternId);
|
|
552
|
-
if (localAll.length) {
|
|
553
|
-
localAll.sort((a, b) => cmpSemver(b.version, a.version));
|
|
554
|
-
const latest = localAll[0];
|
|
555
|
-
const body = readCached(shipRoot, "pattern", patternId, latest.version);
|
|
556
|
-
if (body && body.content) {
|
|
557
|
-
artifacts[lockKey("pattern", patternId)] = entryFromBody({
|
|
558
|
-
body: body.content,
|
|
559
|
-
version: latest.version,
|
|
560
|
-
cachedPath: path.relative(
|
|
561
|
-
shipRoot,
|
|
562
|
-
cachePath(shipRoot, "pattern", patternId, latest.version),
|
|
563
|
-
),
|
|
564
|
-
source: "http",
|
|
565
|
-
pinned: isPinned,
|
|
566
|
-
channel: body.meta?.channel || channel,
|
|
567
|
-
});
|
|
568
|
-
resolved.push({
|
|
569
|
-
kind: "pattern",
|
|
570
|
-
id: patternId,
|
|
571
|
-
version: latest.version,
|
|
572
|
-
source: "cache",
|
|
573
|
-
pinned: isPinned,
|
|
574
|
-
lanes: ctx.by,
|
|
575
|
-
});
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/* 2) Running inside the Ship monorepo — read from artifacts/ and
|
|
581
|
-
* materialise the body into the customer's local cache so the
|
|
582
|
-
* lockfile's `cached_path` is always inside ship_root. This keeps
|
|
583
|
-
* `shipctl run --offline` working without SHIP_REPO set at run
|
|
584
|
-
* time (important for `act`-style local CI reproductions and
|
|
585
|
-
* enterprise forks where the monorepo isn't on the runner). */
|
|
586
|
-
if (shipRepo) {
|
|
587
|
-
const file = readArtifactFile(shipRepo, "pattern", patternId);
|
|
588
|
-
if (file) {
|
|
589
|
-
const version = parseVersionFromFrontmatter(file.content) || "0.0.0-monorepo";
|
|
590
|
-
writeCached(shipRoot, "pattern", patternId, version, file.content, {
|
|
591
|
-
kind: "pattern",
|
|
592
|
-
id: patternId,
|
|
593
|
-
version,
|
|
594
|
-
channel,
|
|
595
|
-
});
|
|
596
|
-
const cachedAbs = cachePath(shipRoot, "pattern", patternId, version);
|
|
597
|
-
artifacts[lockKey("pattern", patternId)] = entryFromBody({
|
|
598
|
-
body: file.content,
|
|
599
|
-
version,
|
|
600
|
-
cachedPath: path.relative(shipRoot, cachedAbs).replace(/\\/g, "/"),
|
|
601
|
-
source: "monorepo",
|
|
602
|
-
pinned: isPinned,
|
|
603
|
-
channel,
|
|
604
|
-
});
|
|
605
|
-
resolved.push({
|
|
606
|
-
kind: "pattern",
|
|
607
|
-
id: patternId,
|
|
608
|
-
version,
|
|
609
|
-
source: "monorepo",
|
|
610
|
-
pinned: isPinned,
|
|
611
|
-
lanes: ctx.by,
|
|
612
|
-
});
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/* 3) Last resort — fetch fresh from the API. We can't cache-write
|
|
618
|
-
* without a known version, so only the sha256 + body go into the
|
|
619
|
-
* lockfile; subsequent `shipctl sync --lock` runs will promote it
|
|
620
|
-
* into the cache on the normal sync pass. */
|
|
621
|
-
try {
|
|
622
|
-
const pin = pins[pinKey];
|
|
623
|
-
const { content, meta } = await fetchArtifact(baseUrl, "pattern", patternId, pin || undefined);
|
|
624
|
-
const version = meta.version || "0.0.0";
|
|
625
|
-
// Promote into cache immediately so subsequent --offline runs find it.
|
|
626
|
-
writeCached(shipRoot, "pattern", patternId, version, content, {
|
|
627
|
-
...meta,
|
|
628
|
-
channel: meta.channel || channel,
|
|
629
|
-
});
|
|
630
|
-
artifacts[lockKey("pattern", patternId)] = entryFromBody({
|
|
631
|
-
body: content,
|
|
632
|
-
version,
|
|
633
|
-
cachedPath: path.relative(
|
|
634
|
-
shipRoot,
|
|
635
|
-
cachePath(shipRoot, "pattern", patternId, version),
|
|
636
|
-
),
|
|
637
|
-
source: "http",
|
|
638
|
-
pinned: isPinned,
|
|
639
|
-
channel: meta.channel || channel,
|
|
640
|
-
});
|
|
641
|
-
resolved.push({
|
|
642
|
-
kind: "pattern",
|
|
643
|
-
id: patternId,
|
|
644
|
-
version,
|
|
645
|
-
source: "http",
|
|
646
|
-
pinned: isPinned,
|
|
647
|
-
lanes: ctx.by,
|
|
648
|
-
});
|
|
649
|
-
} catch (err) {
|
|
650
|
-
unresolved.push({
|
|
651
|
-
kind: "pattern",
|
|
652
|
-
id: patternId,
|
|
653
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
654
|
-
});
|
|
655
|
-
notes.push(`unresolved: pattern/${patternId}: ${err instanceof Error ? err.message : err}`);
|
|
656
|
-
if (verbose) {
|
|
657
|
-
console.error(
|
|
658
|
-
`warn: lock: could not resolve pattern/${patternId}: ${err instanceof Error ? err.message : err}`,
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
const lockfile = {
|
|
665
|
-
version: LOCKFILE_SCHEMA_VERSION,
|
|
666
|
-
generated_at: new Date().toISOString(),
|
|
667
|
-
shipctl_version: getCliVersion(),
|
|
668
|
-
source: { base_url: baseUrl, channel },
|
|
669
|
-
artifacts,
|
|
670
|
-
notes: notes.slice(),
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
return { lockfile, resolved, unresolved, notes };
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function parseVersionFromFrontmatter(content) {
|
|
677
|
-
if (!content.startsWith("---")) return null;
|
|
678
|
-
const end = content.indexOf("\n---", 3);
|
|
679
|
-
if (end < 0) return null;
|
|
680
|
-
const header = content.slice(3, end);
|
|
681
|
-
const m = header.match(/^version:\s*['"]?([^'"\n]+)['"]?/m);
|
|
682
|
-
return m ? m[1].trim() : null;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function cmpSemver(a, b) {
|
|
686
|
-
const parts = (s) =>
|
|
687
|
-
String(s)
|
|
688
|
-
.split(/[.-]/)
|
|
689
|
-
.map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
|
|
690
|
-
const pa = parts(a);
|
|
691
|
-
const pb = parts(b);
|
|
692
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
693
|
-
const xa = pa[i];
|
|
694
|
-
const xb = pb[i];
|
|
695
|
-
if (xa === undefined) return -1;
|
|
696
|
-
if (xb === undefined) return 1;
|
|
697
|
-
if (xa === xb) continue;
|
|
698
|
-
if (typeof xa === typeof xb) return xa < xb ? -1 : 1;
|
|
699
|
-
return typeof xa === "number" ? -1 : 1;
|
|
700
|
-
}
|
|
701
|
-
return 0;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
export async function syncCommand(ctx, rest) {
|
|
705
|
-
const args = parseSyncArgs(rest);
|
|
706
|
-
if (args.help) {
|
|
707
|
-
printSyncHelp();
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
if (ctx?.dryRun) args.dryRun = true;
|
|
711
|
-
if (ctx?.json) args.json = true;
|
|
712
|
-
for (const tok of args.unknown) {
|
|
713
|
-
console.warn(
|
|
714
|
-
`warn: shipctl sync: ignoring unknown argument '${tok}'. Run 'shipctl sync --help'.`,
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
let result;
|
|
719
|
-
try {
|
|
720
|
-
result = await syncArtifacts({
|
|
721
|
-
cwd: args.cwd,
|
|
722
|
-
baseUrl: ctx?.baseUrl,
|
|
723
|
-
channel: args.channel,
|
|
724
|
-
dryRun: args.dryRun,
|
|
725
|
-
checkOnly: args.checkOnly,
|
|
726
|
-
forceUnpin: args.forceUnpin,
|
|
727
|
-
only: args.only,
|
|
728
|
-
verbose: !args.json,
|
|
729
|
-
});
|
|
730
|
-
} catch (e) {
|
|
731
|
-
/* When `--lock` is requested we treat manifest failures as soft:
|
|
732
|
-
* the lockfile build has its own resolution chain (cache → monorepo
|
|
733
|
-
* → HTTP) and will report its own unresolved entries. This keeps
|
|
734
|
-
* `shipctl sync --lock` useful for customers who only run Ship-
|
|
735
|
-
* locally (e.g. internal forks) or are offline with a mirrored
|
|
736
|
-
* monorepo on SHIP_REPO. */
|
|
737
|
-
if (!args.lock) {
|
|
738
|
-
const code = typeof e.exitCode === "number" ? e.exitCode : 1;
|
|
739
|
-
console.error(e.message);
|
|
740
|
-
process.exit(code);
|
|
741
|
-
}
|
|
742
|
-
if (!args.json) console.error(`warn: manifest sync skipped (${e.message || e})`);
|
|
743
|
-
result = {
|
|
744
|
-
up_to_date: 0,
|
|
745
|
-
updated: 0,
|
|
746
|
-
skipped_pin: 0,
|
|
747
|
-
deprecated: 0,
|
|
748
|
-
yanked: 0,
|
|
749
|
-
failed: 0,
|
|
750
|
-
notes: [`manifest sync skipped (${e.message || e})`],
|
|
751
|
-
entries: [],
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/* --lock: walk the lane patterns, make sure every body is materialised
|
|
756
|
-
* under .ship/cache, and dump a lockfile so `shipctl run --offline` has
|
|
757
|
-
* a content-sha to compare against. Only runs after a successful-ish
|
|
758
|
-
* normal sync (we don't care if individual artifacts failed upstream —
|
|
759
|
-
* lockfile generation has its own fallback chain). */
|
|
760
|
-
let lockResult = null;
|
|
761
|
-
if (args.lock && !args.dryRun && !args.checkOnly) {
|
|
762
|
-
const shipRoot = findShipRoot(args.cwd);
|
|
763
|
-
if (!shipRoot) {
|
|
764
|
-
console.error("--lock: .ship/ not found; run 'shipctl init' first.");
|
|
765
|
-
process.exit(10);
|
|
766
|
-
}
|
|
767
|
-
const { config } = readConfig(shipRoot);
|
|
768
|
-
const baseUrl = (
|
|
769
|
-
ctx?.baseUrl ||
|
|
770
|
-
process.env.SHIP_API_BASE ||
|
|
771
|
-
config.api?.base_url ||
|
|
772
|
-
"https://ship.elmundi.com"
|
|
773
|
-
).replace(/\/$/, "");
|
|
774
|
-
const channel = args.channel || process.env.SHIP_CHANNEL || config.api?.channel || "stable";
|
|
775
|
-
try {
|
|
776
|
-
lockResult = await buildLockfile({
|
|
777
|
-
shipRoot,
|
|
778
|
-
config,
|
|
779
|
-
baseUrl,
|
|
780
|
-
channel,
|
|
781
|
-
verbose: !args.json,
|
|
782
|
-
});
|
|
783
|
-
writeLockfile(shipRoot, lockResult.lockfile);
|
|
784
|
-
} catch (err) {
|
|
785
|
-
console.error(`--lock: ${err instanceof Error ? err.message : err}`);
|
|
786
|
-
process.exit(20);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if (args.json) {
|
|
791
|
-
const payload = { ...result };
|
|
792
|
-
if (lockResult) {
|
|
793
|
-
payload.lock = {
|
|
794
|
-
path: path.join(".ship", "shipctl.lock.json"),
|
|
795
|
-
entries: Object.keys(lockResult.lockfile.artifacts).length,
|
|
796
|
-
resolved: lockResult.resolved,
|
|
797
|
-
unresolved: lockResult.unresolved,
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
801
|
-
} else {
|
|
802
|
-
const lines = [
|
|
803
|
-
`up_to_date: ${result.up_to_date}`,
|
|
804
|
-
`updated: ${result.updated}`,
|
|
805
|
-
`skipped_pin:${result.skipped_pin}`,
|
|
806
|
-
`deprecated: ${result.deprecated}${result.deprecated ? " (…)" : ""}`,
|
|
807
|
-
`yanked: ${result.yanked}`,
|
|
808
|
-
`failed: ${result.failed}`,
|
|
809
|
-
];
|
|
810
|
-
for (const l of lines) console.log(l);
|
|
811
|
-
for (const n of result.notes) console.log(` - ${n}`);
|
|
812
|
-
|
|
813
|
-
if (lockResult) {
|
|
814
|
-
const entryCount = Object.keys(lockResult.lockfile.artifacts).length;
|
|
815
|
-
console.log(
|
|
816
|
-
`lock: wrote .ship/shipctl.lock.json (${entryCount} entries, ${lockResult.unresolved.length} unresolved)`,
|
|
817
|
-
);
|
|
818
|
-
for (const n of lockResult.notes) console.log(` - ${n}`);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
if (result.failed > 0) process.exit(20);
|
|
823
|
-
if (lockResult && lockResult.unresolved.length > 0) process.exit(20);
|
|
824
|
-
}
|