@elmundi/ship-cli 0.14.2 → 0.15.4

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.
Files changed (39) hide show
  1. package/README.md +17 -16
  2. package/bin/shipctl.mjs +4 -80
  3. package/lib/commands/feedback.mjs +1 -1
  4. package/lib/commands/help.mjs +47 -131
  5. package/lib/commands/init.mjs +17 -250
  6. package/lib/commands/knowledge.mjs +25 -328
  7. package/lib/commands/preflight.mjs +213 -0
  8. package/lib/commands/run.mjs +298 -119
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +73 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +39 -0
  13. package/lib/templates.mjs +2 -2
  14. package/lib/verify/checks/agents-on-disk.mjs +5 -28
  15. package/lib/verify/registry.mjs +7 -8
  16. package/package.json +1 -1
  17. package/lib/artifacts/fs-index.mjs +0 -230
  18. package/lib/cache/store.mjs +0 -422
  19. package/lib/commands/bootstrap.mjs +0 -4
  20. package/lib/commands/callback.mjs +0 -742
  21. package/lib/commands/docs.mjs +0 -90
  22. package/lib/commands/kickoff.mjs +0 -192
  23. package/lib/commands/lanes.mjs +0 -566
  24. package/lib/commands/manifest-catalog.mjs +0 -251
  25. package/lib/commands/migrate.mjs +0 -204
  26. package/lib/commands/new.mjs +0 -452
  27. package/lib/commands/patterns.mjs +0 -160
  28. package/lib/commands/process.mjs +0 -388
  29. package/lib/commands/search.mjs +0 -43
  30. package/lib/commands/sync.mjs +0 -824
  31. package/lib/config/migrate.mjs +0 -223
  32. package/lib/find-ship-root.mjs +0 -75
  33. package/lib/process/specialist-prompt-contract.mjs +0 -171
  34. package/lib/state/lockfile.mjs +0 -180
  35. package/lib/vendor/run-agent.workflow.yml +0 -254
  36. package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
  37. package/lib/verify/checks/cache-integrity.mjs +0 -51
  38. package/lib/verify/checks/gitignore-cache.mjs +0 -51
  39. package/lib/verify/checks/rules-markers.mjs +0 -135
@@ -1,230 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- /**
5
- * Shared filesystem index for v2 artifact trees: walks
6
- * `<repoRoot>/artifacts/<plural>/<id>/ARTIFACT.md` and parses just enough YAML
7
- * front-matter to reconstruct the same entry shape we used to read out of the
8
- * legacy `<plural>/manifest.json` files.
9
- *
10
- * Zero dependencies — only node builtins. Anything we cannot parse falls
11
- * through (the entry still gets emitted with whatever fields we recovered).
12
- */
13
-
14
- const KIND_TO_PLURAL = {
15
- pattern: "patterns",
16
- tool: "tools",
17
- collection: "collections",
18
- };
19
-
20
- /**
21
- * @param {"pattern"|"tool"|"collection"} kind
22
- */
23
- export function pluralFor(kind) {
24
- return KIND_TO_PLURAL[kind] || `${kind}s`;
25
- }
26
-
27
- /**
28
- * Walk `artifacts/<plural>/*` and return the parsed entries (same shape as the
29
- * legacy manifest).
30
- *
31
- * @param {string} repoRoot
32
- * @param {"pattern"|"tool"|"collection"} kind
33
- * @returns {Array<Record<string, any>>}
34
- */
35
- export function scanArtifacts(repoRoot, kind) {
36
- const plural = pluralFor(kind);
37
- const dir = path.join(repoRoot, "artifacts", plural);
38
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
39
-
40
- const ids = fs.readdirSync(dir, { withFileTypes: true })
41
- .filter((e) => e.isDirectory())
42
- .map((e) => e.name)
43
- .sort();
44
-
45
- /** @type {Array<Record<string, any>>} */
46
- const out = [];
47
- for (const id of ids) {
48
- const file = path.join(dir, id, "ARTIFACT.md");
49
- if (!fs.existsSync(file)) continue;
50
- let raw;
51
- try {
52
- raw = fs.readFileSync(file, "utf8");
53
- } catch {
54
- continue;
55
- }
56
- const { fm } = parseFrontMatter(raw);
57
- const entry = entryFromFrontmatter(fm, kind, id);
58
- out.push(entry);
59
- }
60
- return out;
61
- }
62
-
63
- /**
64
- * Read the full ARTIFACT.md (frontmatter + body) for a specific id. Returns
65
- * null when the file is absent so callers can emit the same "Unknown id"
66
- * messages they did before.
67
- *
68
- * @param {string} repoRoot
69
- * @param {"pattern"|"tool"|"collection"} kind
70
- * @param {string} id
71
- */
72
- export function readArtifactFile(repoRoot, kind, id) {
73
- const plural = pluralFor(kind);
74
- const file = path.join(repoRoot, "artifacts", plural, id, "ARTIFACT.md");
75
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return null;
76
- return { absPath: file, content: fs.readFileSync(file, "utf8") };
77
- }
78
-
79
- function entryFromFrontmatter(fm, kind, id) {
80
- const plural = pluralFor(kind);
81
- const description = typeof fm.description === "string" ? fm.description : "";
82
- const summary = description ? firstSentence(description) : "";
83
- return {
84
- id: typeof fm.id === "string" && fm.id ? fm.id : id,
85
- title: typeof fm.name === "string" ? fm.name : id,
86
- summary,
87
- path: `artifacts/${plural}/${id}/ARTIFACT.md`,
88
- tags: Array.isArray(fm.tags) ? fm.tags : [],
89
- group: typeof fm.group === "string" ? fm.group : null,
90
- version: typeof fm.version === "string" ? fm.version : null,
91
- content_sha256: typeof fm.content_sha256 === "string" ? fm.content_sha256 : null,
92
- updated_at: typeof fm.updated_at === "string" ? fm.updated_at : null,
93
- channel: typeof fm.channel === "string" ? fm.channel : null,
94
- min_shipctl: typeof fm.min_shipctl === "string" ? fm.min_shipctl : null,
95
- deprecated: fm.deprecated === true || fm.deprecated === "true",
96
- replaced_by: fm.replaced_by ?? null,
97
- yanked: fm.yanked === true || fm.yanked === "true",
98
- };
99
- }
100
-
101
- function firstSentence(text) {
102
- const trimmed = text.trim();
103
- if (!trimmed) return "";
104
- const m = /[.!?](\s|$)/.exec(trimmed);
105
- if (!m) return trimmed;
106
- return trimmed.slice(0, m.index + 1).trim();
107
- }
108
-
109
- /**
110
- * Tiny YAML front-matter parser tailored for v2 ARTIFACT.md files.
111
- *
112
- * Supports:
113
- * - simple `key: value`
114
- * - inline lists `key: [a, b]`
115
- * - folded scalars `key: >` / `key: >-` with indented continuation lines
116
- * - quoted strings (single or double)
117
- * - one level of nested mapping (used by `spec:`)
118
- * - comments (`# …`)
119
- *
120
- * Anything else is best-effort: the value is captured as the trimmed string.
121
- *
122
- * @param {string} source
123
- * @returns {{fm: Record<string, any>, body: string}}
124
- */
125
- export function parseFrontMatter(source) {
126
- if (typeof source !== "string") return { fm: {}, body: "" };
127
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
128
- if (!match) return { fm: {}, body: source };
129
- const block = match[1];
130
- const body = source.slice(match[0].length);
131
- /** @type {Record<string, any>} */
132
- const fm = {};
133
- const lines = block.split(/\r?\n/);
134
- let i = 0;
135
- while (i < lines.length) {
136
- const rawLine = lines[i];
137
- const line = rawLine.replace(/\s+$/, "");
138
- if (!line || /^\s*#/.test(line)) {
139
- i += 1;
140
- continue;
141
- }
142
- const top = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(line);
143
- if (!top) {
144
- i += 1;
145
- continue;
146
- }
147
- const key = top[1];
148
- const value = top[2];
149
-
150
- if (value === ">" || value === ">-") {
151
- const folded = [];
152
- i += 1;
153
- while (i < lines.length) {
154
- const cont = lines[i];
155
- if (cont === "" || cont === "\r") {
156
- // Preserve paragraph breaks as a single space in folded scalars.
157
- folded.push("");
158
- i += 1;
159
- continue;
160
- }
161
- const m = /^(\s+)(.*)$/.exec(cont);
162
- if (!m) break;
163
- folded.push(m[2]);
164
- i += 1;
165
- }
166
- let joined = folded.join(" ").replace(/\s+/g, " ").trim();
167
- if (value === ">-") joined = joined.replace(/\s+$/, "");
168
- fm[key] = joined;
169
- continue;
170
- }
171
-
172
- if (/^\[.*\]$/.test(value.trim())) {
173
- const inner = value.trim().slice(1, -1).trim();
174
- fm[key] = inner.length ? inner.split(/\s*,\s*/).map(unquote) : [];
175
- i += 1;
176
- continue;
177
- }
178
-
179
- if (value === "") {
180
- // Possible nested mapping or empty scalar. Peek ahead.
181
- const child = {};
182
- let saw = false;
183
- let j = i + 1;
184
- while (j < lines.length) {
185
- const cont = lines[j];
186
- if (!cont.trim()) { j += 1; continue; }
187
- const indented = /^(\s{2,})([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(cont);
188
- if (!indented) break;
189
- const [, , subKey, subVal] = indented;
190
- if (/^\[.*\]$/.test(subVal.trim())) {
191
- const inner = subVal.trim().slice(1, -1).trim();
192
- child[subKey] = inner.length ? inner.split(/\s*,\s*/).map(unquote) : [];
193
- } else {
194
- child[subKey] = coerceScalar(subVal);
195
- }
196
- saw = true;
197
- j += 1;
198
- }
199
- if (saw) {
200
- fm[key] = child;
201
- i = j;
202
- continue;
203
- }
204
- fm[key] = "";
205
- i += 1;
206
- continue;
207
- }
208
-
209
- fm[key] = coerceScalar(value);
210
- i += 1;
211
- }
212
- return { fm, body };
213
- }
214
-
215
- function unquote(value) {
216
- if (typeof value !== "string") return value;
217
- const v = value.trim();
218
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
219
- return v.slice(1, -1);
220
- }
221
- return v;
222
- }
223
-
224
- function coerceScalar(rawValue) {
225
- const v = unquote(String(rawValue).trim());
226
- if (v === "true") return true;
227
- if (v === "false") return false;
228
- if (v === "null" || v === "~") return null;
229
- return v;
230
- }
@@ -1,422 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import crypto from "node:crypto";
4
-
5
- const CACHE_REL = path.join(".ship", "cache");
6
- const KIND_ROOTS = ["pattern", "tool", "collection", "doc"];
7
-
8
- function sanitize(id) {
9
- return String(id).replace(/\//g, "__");
10
- }
11
-
12
- function kindDir(shipRoot, kind) {
13
- return path.join(shipRoot, CACHE_REL, kind);
14
- }
15
-
16
- /**
17
- * Folder that holds the artifact body + sidecar meta. New v2 cache layout:
18
- * .ship/cache/<kind>/<sanitize(id)>@<version>/ARTIFACT.md
19
- * .ship/cache/<kind>/<sanitize(id)>@<version>/.meta.json
20
- */
21
- export function cacheFolder(shipRoot, kind, id, version) {
22
- return path.join(kindDir(shipRoot, kind), `${sanitize(id)}@${version}`);
23
- }
24
-
25
- /**
26
- * @param {string} shipRoot
27
- * @param {string} kind
28
- * @param {string} id
29
- * @param {string} version
30
- * @param {string} [extension] Reserved for back-compat; ignored under the
31
- * new folder layout (always returns ARTIFACT.md).
32
- */
33
- export function cachePath(shipRoot, kind, id, version, extension = ".md") {
34
- // Keep the trailing-extension parameter so callers that opted into the old
35
- // ".meta.json" trick still work (they used to call `cachePath(..., ".meta.json")`
36
- // — those callers should now use `metaPath` instead, but stay defensive).
37
- if (extension === ".meta.json") {
38
- return path.join(cacheFolder(shipRoot, kind, id, version), ".meta.json");
39
- }
40
- return path.join(cacheFolder(shipRoot, kind, id, version), "ARTIFACT.md");
41
- }
42
-
43
- export function metaPath(shipRoot, kind, id, version) {
44
- return path.join(cacheFolder(shipRoot, kind, id, version), ".meta.json");
45
- }
46
-
47
- export function sha256Hex(buf) {
48
- return crypto.createHash("sha256").update(buf).digest("hex");
49
- }
50
-
51
- /**
52
- * RFC-0005 hashing convention: `content_sha256` is computed over the
53
- * artifact bytes with the `content_sha256:` value cleared (the line stays,
54
- * but the hex value is replaced by empty). This avoids the chicken-and-egg
55
- * of hashing a file whose own hash lives inside it. Both the server-side
56
- * stamp and any client-side verification must apply the same normalization.
57
- *
58
- * @param {string} content
59
- * @returns {string}
60
- */
61
- export function normalizeForArtifactSha(content) {
62
- return String(content).replace(
63
- /^(content_sha256:\s*)[A-Fa-f0-9]+\s*$/m,
64
- "$1",
65
- );
66
- }
67
-
68
- /**
69
- * Hash an artifact body the way the server does — with the sha line cleared.
70
- *
71
- * @param {string|Buffer} content
72
- * @returns {string}
73
- */
74
- export function artifactSha256(content) {
75
- const text = Buffer.isBuffer(content) ? content.toString("utf8") : String(content);
76
- return sha256Hex(Buffer.from(normalizeForArtifactSha(text), "utf8"));
77
- }
78
-
79
- /**
80
- * @returns {{content:string, meta:object}|null}
81
- */
82
- export function readCached(shipRoot, kind, id, version) {
83
- const body = cachePath(shipRoot, kind, id, version);
84
- const meta = metaPath(shipRoot, kind, id, version);
85
- if (!fs.existsSync(body) || !fs.existsSync(meta)) return null;
86
- try {
87
- const content = fs.readFileSync(body, "utf8");
88
- const metaObj = JSON.parse(fs.readFileSync(meta, "utf8"));
89
- return { content, meta: metaObj };
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- /**
96
- * @param {string} shipRoot
97
- * @param {string} kind
98
- * @param {string} id
99
- * @param {string} version
100
- * @param {string} content
101
- * @param {object} [meta]
102
- */
103
- export function writeCached(shipRoot, kind, id, version, content, meta = {}) {
104
- const folder = cacheFolder(shipRoot, kind, id, version);
105
- const body = cachePath(shipRoot, kind, id, version);
106
- const metaFile = metaPath(shipRoot, kind, id, version);
107
- fs.mkdirSync(folder, { recursive: true });
108
- fs.writeFileSync(body, content, "utf8");
109
- // Trust the server-stamped sha256 when present; otherwise hash the body
110
- // we just wrote using the RFC-0005 normalized form (sha line cleared) so
111
- // future verifies match. The artifact folder currently holds only
112
- // ARTIFACT.md + .meta.json, so a body-only hash is equivalent to a
113
- // folder-walk hash once .meta.json is excluded.
114
- const computed = artifactSha256(content);
115
- const fullMeta = {
116
- kind,
117
- id,
118
- version,
119
- content_sha256: meta.content_sha256 || computed,
120
- updated_at: meta.updated_at || null,
121
- source_url: meta.source_url || null,
122
- fetched_at: meta.fetched_at || new Date().toISOString(),
123
- ...meta,
124
- };
125
- fs.writeFileSync(metaFile, `${JSON.stringify(fullMeta, null, 2)}\n`, "utf8");
126
- return { bodyPath: body, metaPath: metaFile, meta: fullMeta };
127
- }
128
-
129
- /**
130
- * @returns {Array<{kind:string,id:string,version:string,sha256:string,fetched_at:string|null,source_url:string|null}>}
131
- */
132
- export function listCached(shipRoot) {
133
- const out = [];
134
- for (const kind of KIND_ROOTS) {
135
- const dir = kindDir(shipRoot, kind);
136
- if (!fs.existsSync(dir)) continue;
137
- let entries;
138
- try {
139
- entries = fs.readdirSync(dir, { withFileTypes: true });
140
- } catch {
141
- continue;
142
- }
143
- for (const entry of entries) {
144
- if (!entry.isDirectory()) continue;
145
- const fullMeta = path.join(dir, entry.name, ".meta.json");
146
- if (!fs.existsSync(fullMeta)) continue;
147
- let metaObj;
148
- try {
149
- metaObj = JSON.parse(fs.readFileSync(fullMeta, "utf8"));
150
- } catch {
151
- continue;
152
- }
153
- out.push({
154
- kind: metaObj.kind || kind,
155
- id: metaObj.id,
156
- version: metaObj.version,
157
- sha256: metaObj.content_sha256,
158
- fetched_at: metaObj.fetched_at || null,
159
- source_url: metaObj.source_url || null,
160
- });
161
- }
162
- }
163
- return out;
164
- }
165
-
166
- export function removeCached(shipRoot, kind, id, version) {
167
- const folder = cacheFolder(shipRoot, kind, id, version);
168
- if (!fs.existsSync(folder)) return 0;
169
- // Count what we are about to remove so callers (and tests) can assert how
170
- // many "things" disappeared. Historically removeCached returned 2 (body +
171
- // meta), so cap the count to match for the common case.
172
- let removed = 0;
173
- for (const f of ["ARTIFACT.md", ".meta.json"]) {
174
- if (fs.existsSync(path.join(folder, f))) removed += 1;
175
- }
176
- fs.rmSync(folder, { recursive: true, force: true });
177
- return removed;
178
- }
179
-
180
- /**
181
- * @returns {{ok:boolean, expected:string|null, actual:string|null, reason?:string}}
182
- */
183
- export function verifyCached(shipRoot, kind, id, version) {
184
- const body = cachePath(shipRoot, kind, id, version);
185
- const meta = metaPath(shipRoot, kind, id, version);
186
- if (!fs.existsSync(body) || !fs.existsSync(meta)) {
187
- return { ok: false, expected: null, actual: null, reason: "missing body or meta" };
188
- }
189
- let metaObj;
190
- try {
191
- metaObj = JSON.parse(fs.readFileSync(meta, "utf8"));
192
- } catch (e) {
193
- return { ok: false, expected: null, actual: null, reason: `meta parse error: ${e.message}` };
194
- }
195
- const expected = metaObj.content_sha256 || null;
196
- const actual = artifactSha256(fs.readFileSync(body));
197
- return { ok: expected === actual, expected, actual };
198
- }
199
-
200
- /**
201
- * Returns whether the on-disk cached body still exists and its sha matches the
202
- * `content_sha256` recorded in the sidecar `.meta.json`. Distinct from
203
- * `verifyCached` in that the caller needs a specific reason code (missing_body /
204
- * missing_meta / drift) so `sync` can decide to re-fetch.
205
- *
206
- * @param {string} shipRoot
207
- * @param {string} kind
208
- * @param {string} id
209
- * @param {string} version
210
- * @returns {{ok:boolean, reason?:string, expected_sha?:string|null, actual_sha?:string|null}}
211
- */
212
- export function verifyCachedOnDisk(shipRoot, kind, id, version) {
213
- const body = cachePath(shipRoot, kind, id, version);
214
- const meta = metaPath(shipRoot, kind, id, version);
215
- if (!fs.existsSync(meta)) {
216
- return { ok: false, reason: "missing_meta" };
217
- }
218
- if (!fs.existsSync(body)) {
219
- return { ok: false, reason: "missing_body" };
220
- }
221
- let metaObj;
222
- try {
223
- metaObj = JSON.parse(fs.readFileSync(meta, "utf8"));
224
- } catch (e) {
225
- return { ok: false, reason: `meta_parse_error: ${e.message}` };
226
- }
227
- const expected = metaObj.content_sha256 || null;
228
- const actual = artifactSha256(fs.readFileSync(body));
229
- if (expected && actual !== expected) {
230
- return { ok: false, reason: "drift", expected_sha: expected, actual_sha: actual };
231
- }
232
- return { ok: true, expected_sha: expected, actual_sha: actual };
233
- }
234
-
235
- /**
236
- * Minimal YAML front-matter parser. Handles the `---\n<keys>\n---\n` prelude
237
- * we use for documentation artifacts. Only scalar keys are supported (strings
238
- * and numbers); quotes and inline arrays are stripped conservatively.
239
- * @param {string} source
240
- * @returns {{fm: Record<string, string>, body: string}}
241
- */
242
- function parseFrontMatter(source) {
243
- if (typeof source !== "string") return { fm: {}, body: "" };
244
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
245
- if (!match) return { fm: {}, body: source };
246
- const block = match[1];
247
- const body = source.slice(match[0].length);
248
- /** @type {Record<string, string>} */
249
- const fm = {};
250
- for (const rawLine of block.split(/\r?\n/)) {
251
- const line = rawLine.replace(/\s+$/, "");
252
- if (!line || /^\s*#/.test(line)) continue;
253
- const kv = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(line);
254
- if (!kv) continue;
255
- let value = kv[2].trim();
256
- if (
257
- (value.startsWith('"') && value.endsWith('"')) ||
258
- (value.startsWith("'") && value.endsWith("'"))
259
- ) {
260
- value = value.slice(1, -1);
261
- }
262
- fm[kv[1]] = value;
263
- }
264
- return { fm, body };
265
- }
266
-
267
- /**
268
- * Slightly richer parser used by `readCachedArtifact`. Recognises the v2
269
- * `spec:` block (one level of nested `key: value` indented by 2 spaces) and
270
- * inline list / quoted scalar conventions. Anything we cannot parse falls
271
- * through with a best-effort string value (callers degrade gracefully).
272
- *
273
- * @param {string} source
274
- * @returns {{fm: Record<string, any>, body: string, spec: Record<string, any>}}
275
- */
276
- function parseFrontMatterV2(source) {
277
- if (typeof source !== "string") return { fm: {}, body: "", spec: {} };
278
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
279
- if (!match) return { fm: {}, body: source, spec: {} };
280
- const block = match[1];
281
- const body = source.slice(match[0].length);
282
- /** @type {Record<string, any>} */
283
- const fm = {};
284
- /** @type {Record<string, any>} */
285
- const spec = {};
286
-
287
- const lines = block.split(/\r?\n/);
288
- let i = 0;
289
- while (i < lines.length) {
290
- const rawLine = lines[i];
291
- const line = rawLine.replace(/\s+$/, "");
292
- if (!line || /^\s*#/.test(line)) {
293
- i += 1;
294
- continue;
295
- }
296
-
297
- // Top-level key: scalar | list | folded.
298
- const top = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(line);
299
- if (!top) {
300
- i += 1;
301
- continue;
302
- }
303
- const key = top[1];
304
- let value = top[2];
305
-
306
- // Folded scalars: `>` or `>-` then indented continuation lines.
307
- if (value === ">" || value === ">-") {
308
- const folded = [];
309
- i += 1;
310
- while (i < lines.length) {
311
- const cont = lines[i];
312
- const m = /^(\s+)(.*)$/.exec(cont);
313
- if (!m) break;
314
- folded.push(m[2]);
315
- i += 1;
316
- }
317
- fm[key] = folded.join(" ").trim();
318
- continue;
319
- }
320
-
321
- // Inline list: `[a, b, c]`.
322
- if (/^\[.*\]$/.test(value.trim())) {
323
- const inner = value.trim().slice(1, -1).trim();
324
- fm[key] = inner.length
325
- ? inner.split(/\s*,\s*/).map((v) => unquote(v))
326
- : [];
327
- i += 1;
328
- continue;
329
- }
330
-
331
- // Nested block (currently only `spec:` is recognised).
332
- if (value === "" && key === "spec") {
333
- i += 1;
334
- while (i < lines.length) {
335
- const cont = lines[i];
336
- if (!cont.trim()) {
337
- i += 1;
338
- continue;
339
- }
340
- const indented = /^(\s{2,})([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(cont);
341
- if (!indented) break;
342
- const [, , subKey, subVal] = indented;
343
- if (/^\[.*\]$/.test(subVal.trim())) {
344
- const inner = subVal.trim().slice(1, -1).trim();
345
- spec[subKey] = inner.length
346
- ? inner.split(/\s*,\s*/).map((v) => unquote(v))
347
- : [];
348
- } else {
349
- spec[subKey] = unquote(subVal.trim());
350
- }
351
- i += 1;
352
- }
353
- fm.spec = spec;
354
- continue;
355
- }
356
-
357
- fm[key] = unquote(value.trim());
358
- i += 1;
359
- }
360
-
361
- return { fm, body, spec };
362
- }
363
-
364
- function unquote(value) {
365
- if (typeof value !== "string") return value;
366
- const v = value.trim();
367
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
368
- return v.slice(1, -1);
369
- }
370
- return v;
371
- }
372
-
373
- /**
374
- * Read the cached artifact body and parse its YAML front-matter. If
375
- * `version` is omitted, the highest-version cached entry for (kind,id) is
376
- * used. Returns `null` when nothing is cached (caller chooses how to
377
- * degrade).
378
- *
379
- * @param {string} shipRoot
380
- * @param {string} kind
381
- * @param {string} id
382
- * @param {string} [version]
383
- * @returns {{fm: Record<string, string>, body: string, version: string, meta: object}|null}
384
- */
385
- export function readCachedFrontMatter(shipRoot, kind, id, version) {
386
- let resolvedVersion = version;
387
- if (!resolvedVersion) {
388
- const candidates = listCached(shipRoot).filter((e) => e.kind === kind && e.id === id);
389
- if (!candidates.length) return null;
390
- candidates.sort((a, b) => String(b.version).localeCompare(String(a.version)));
391
- resolvedVersion = candidates[0].version;
392
- }
393
- const cached = readCached(shipRoot, kind, id, resolvedVersion);
394
- if (!cached) return null;
395
- const { fm, body } = parseFrontMatter(cached.content);
396
- return { fm, body, version: resolvedVersion, meta: cached.meta };
397
- }
398
-
399
- /**
400
- * v2-aware variant of `readCachedFrontMatter`. Uses the richer parser so
401
- * callers can read `spec.install_target` (or other nested keys) without
402
- * pulling in a YAML dep.
403
- *
404
- * @param {string} shipRoot
405
- * @param {string} kind
406
- * @param {string} id
407
- * @param {string} [version]
408
- * @returns {{fm:Record<string,any>, body:string, version:string, meta:object, spec:Record<string,any>}|null}
409
- */
410
- export function readCachedArtifact(shipRoot, kind, id, version) {
411
- let resolvedVersion = version;
412
- if (!resolvedVersion) {
413
- const candidates = listCached(shipRoot).filter((e) => e.kind === kind && e.id === id);
414
- if (!candidates.length) return null;
415
- candidates.sort((a, b) => String(b.version).localeCompare(String(a.version)));
416
- resolvedVersion = candidates[0].version;
417
- }
418
- const cached = readCached(shipRoot, kind, id, resolvedVersion);
419
- if (!cached) return null;
420
- const { fm, body, spec } = parseFrontMatterV2(cached.content);
421
- return { fm, body, version: resolvedVersion, meta: cached.meta, spec };
422
- }
@@ -1,4 +0,0 @@
1
- export async function bootstrapCommand() {
2
- console.error("shipctl bootstrap: coming in a later epic — see documentation/protocol/rfc-0002-shipctl-config.md");
3
- process.exit(2);
4
- }