@elmundi/ship-cli 0.8.1 → 0.12.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/README.md +651 -25
- package/bin/shipctl.mjs +168 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +422 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +742 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +159 -24
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +562 -0
- package/lib/commands/lanes.mjs +527 -0
- package/lib/commands/manifest-catalog.mjs +106 -42
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +14 -48
- package/lib/commands/run.mjs +857 -0
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +824 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/trigger.mjs +196 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +223 -0
- package/lib/config/schema.mjs +901 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- package/bin/ship.mjs +0 -68
|
@@ -0,0 +1,422 @@
|
|
|
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
|
+
}
|