@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.
- 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 +298 -119
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +73 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +39 -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
|
@@ -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
|
-
}
|
package/lib/cache/store.mjs
DELETED
|
@@ -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
|
-
}
|