@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.
Files changed (78) hide show
  1. package/README.md +651 -25
  2. package/bin/shipctl.mjs +168 -0
  3. package/lib/adapters/_fs.mjs +165 -0
  4. package/lib/adapters/agents/index.mjs +26 -0
  5. package/lib/adapters/ci/azure-pipelines.mjs +23 -0
  6. package/lib/adapters/ci/buildkite.mjs +24 -0
  7. package/lib/adapters/ci/circleci.mjs +23 -0
  8. package/lib/adapters/ci/gh-actions.mjs +29 -0
  9. package/lib/adapters/ci/gitlab-ci.mjs +23 -0
  10. package/lib/adapters/ci/jenkins.mjs +23 -0
  11. package/lib/adapters/ci/manual.mjs +18 -0
  12. package/lib/adapters/index.mjs +122 -0
  13. package/lib/adapters/language/dart.mjs +23 -0
  14. package/lib/adapters/language/go.mjs +23 -0
  15. package/lib/adapters/language/java.mjs +27 -0
  16. package/lib/adapters/language/js.mjs +32 -0
  17. package/lib/adapters/language/kotlin.mjs +48 -0
  18. package/lib/adapters/language/py.mjs +34 -0
  19. package/lib/adapters/language/rust.mjs +23 -0
  20. package/lib/adapters/language/swift.mjs +37 -0
  21. package/lib/adapters/language/ts.mjs +35 -0
  22. package/lib/adapters/trackers/azure-boards.mjs +49 -0
  23. package/lib/adapters/trackers/clickup.mjs +43 -0
  24. package/lib/adapters/trackers/github-issues.mjs +52 -0
  25. package/lib/adapters/trackers/jira.mjs +72 -0
  26. package/lib/adapters/trackers/linear.mjs +62 -0
  27. package/lib/adapters/trackers/none.mjs +18 -0
  28. package/lib/adapters/trackers/spreadsheet.mjs +28 -0
  29. package/lib/artifacts/fs-index.mjs +230 -0
  30. package/lib/bootstrap/render.mjs +422 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +742 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +4 -4
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +159 -24
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +562 -0
  42. package/lib/commands/lanes.mjs +527 -0
  43. package/lib/commands/manifest-catalog.mjs +106 -42
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +14 -48
  47. package/lib/commands/run.mjs +857 -0
  48. package/lib/commands/search.mjs +2 -2
  49. package/lib/commands/sync.mjs +824 -0
  50. package/lib/commands/telemetry.mjs +390 -0
  51. package/lib/commands/trigger.mjs +196 -0
  52. package/lib/commands/verify.mjs +187 -0
  53. package/lib/config/io.mjs +232 -0
  54. package/lib/config/migrate.mjs +223 -0
  55. package/lib/config/schema.mjs +901 -0
  56. package/lib/detect.mjs +162 -19
  57. package/lib/feedback/drafts.mjs +129 -0
  58. package/lib/find-ship-root.mjs +16 -10
  59. package/lib/http.mjs +237 -11
  60. package/lib/state/idempotency.mjs +183 -0
  61. package/lib/state/lockfile.mjs +180 -0
  62. package/lib/telemetry/outbox.mjs +224 -0
  63. package/lib/templates.mjs +53 -65
  64. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  65. package/lib/verify/checks/api-reachable.mjs +39 -0
  66. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  67. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  68. package/lib/verify/checks/cache-integrity.mjs +51 -0
  69. package/lib/verify/checks/ci-secrets.mjs +86 -0
  70. package/lib/verify/checks/config-present.mjs +39 -0
  71. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  72. package/lib/verify/checks/rules-markers.mjs +135 -0
  73. package/lib/verify/checks/stack-enums.mjs +33 -0
  74. package/lib/verify/checks/tracker-labels.mjs +91 -0
  75. package/lib/verify/registry.mjs +120 -0
  76. package/lib/version.mjs +34 -0
  77. package/package.json +10 -3
  78. 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
+ }
@@ -0,0 +1,4 @@
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
+ }