@elmundi/ship-cli 0.8.1 → 0.11.2

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 (76) hide show
  1. package/README.md +415 -22
  2. package/bin/shipctl.mjs +165 -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 +373 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +302 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +1 -1
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +96 -21
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +368 -0
  42. package/lib/commands/lanes.mjs +502 -0
  43. package/lib/commands/manifest-catalog.mjs +102 -38
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +9 -43
  47. package/lib/commands/run.mjs +617 -0
  48. package/lib/commands/sync.mjs +749 -0
  49. package/lib/commands/telemetry.mjs +390 -0
  50. package/lib/commands/verify.mjs +187 -0
  51. package/lib/config/io.mjs +232 -0
  52. package/lib/config/migrate.mjs +215 -0
  53. package/lib/config/schema.mjs +650 -0
  54. package/lib/detect.mjs +162 -19
  55. package/lib/feedback/drafts.mjs +129 -0
  56. package/lib/find-ship-root.mjs +16 -10
  57. package/lib/http.mjs +237 -11
  58. package/lib/state/idempotency.mjs +183 -0
  59. package/lib/state/lockfile.mjs +180 -0
  60. package/lib/telemetry/outbox.mjs +224 -0
  61. package/lib/templates.mjs +53 -65
  62. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  63. package/lib/verify/checks/api-reachable.mjs +39 -0
  64. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  65. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  66. package/lib/verify/checks/cache-integrity.mjs +51 -0
  67. package/lib/verify/checks/ci-secrets.mjs +86 -0
  68. package/lib/verify/checks/config-present.mjs +39 -0
  69. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  70. package/lib/verify/checks/rules-markers.mjs +135 -0
  71. package/lib/verify/checks/stack-enums.mjs +33 -0
  72. package/lib/verify/checks/tracker-labels.mjs +91 -0
  73. package/lib/verify/registry.mjs +120 -0
  74. package/lib/version.mjs +34 -0
  75. package/package.json +10 -3
  76. package/bin/ship.mjs +0 -68
@@ -0,0 +1,650 @@
1
+ import { KNOWN_AGENTS } from "../detect.mjs";
2
+
3
+ /* Historical schema used by every released shipctl through 0.11.x. We
4
+ * keep validating it in parallel with v2 so customers who haven't run
5
+ * `shipctl migrate` see clear warnings instead of silent failures. */
6
+ export const LEGACY_CONFIG_SCHEMA_VERSION = 1;
7
+
8
+ /* RFC-0007 lanes-as-config. Introduced alongside `shipctl run`. Clients
9
+ * that understand only v1 will refuse to read v2 and print a shipctl
10
+ * upgrade hint; clients that understand v2 accept v1 with a deprecation
11
+ * warning and suggest `shipctl migrate`. */
12
+ export const CONFIG_SCHEMA_VERSION = 2;
13
+
14
+ export const SUPPORTED_CONFIG_VERSIONS = Object.freeze([
15
+ LEGACY_CONFIG_SCHEMA_VERSION,
16
+ CONFIG_SCHEMA_VERSION,
17
+ ]);
18
+
19
+ /* Lane kinds accepted in v2. `once` ships today end-to-end; `event` and
20
+ * `schedule` are parsed + validated but `shipctl run` emits a "not-yet
21
+ * implemented" exit-0 no-op for them until Phase 3 wires the reusable
22
+ * workflow. Keep this union tight — any new kind requires an RFC
23
+ * amendment. */
24
+ export const LANE_KINDS = Object.freeze(["once", "event", "schedule"]);
25
+
26
+ export const LANE_EVENT_TYPES = Object.freeze([
27
+ "pull_request",
28
+ "push",
29
+ "workflow_run",
30
+ "deployment_status",
31
+ ]);
32
+
33
+ export const LANE_IDEMPOTENCY_STORES = Object.freeze(["file", "backend"]);
34
+ export const LANE_IDEMPOTENCY_RESET_ON = Object.freeze(["version-change", "manual"]);
35
+
36
+ /* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
37
+ * file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
38
+ * restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
39
+ export const LANE_ID_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
40
+ export const IDEMPOTENCY_KEY_REGEX = /^[a-z0-9][a-z0-9_.-]{0,127}$/;
41
+ /* Coarse sanity check for 5-field crons: we're not a cron parser, but
42
+ * anything that isn't whitespace-separated 5 tokens is almost certainly
43
+ * a typo and worth rejecting up front. */
44
+ const CRON_5_FIELD_REGEX = /^\s*(\S+\s+){4}\S+\s*$/;
45
+
46
+ export const TRACKERS = Object.freeze([
47
+ "linear",
48
+ "jira",
49
+ "github-issues",
50
+ "azure-boards",
51
+ "clickup",
52
+ "spreadsheet",
53
+ "none",
54
+ ]);
55
+
56
+ export const CIS = Object.freeze([
57
+ "gh-actions",
58
+ "gitlab-ci",
59
+ "buildkite",
60
+ "circleci",
61
+ "azure-pipelines",
62
+ "jenkins",
63
+ "manual",
64
+ ]);
65
+
66
+ export const LANGUAGES = Object.freeze([
67
+ "ts",
68
+ "js",
69
+ "py",
70
+ "go",
71
+ "rust",
72
+ "java",
73
+ "kotlin",
74
+ "swift",
75
+ "dart",
76
+ "multi",
77
+ ]);
78
+
79
+ export const PRESETS = Object.freeze([
80
+ "web-app",
81
+ "api-backend",
82
+ "mobile-app",
83
+ "cli",
84
+ "monorepo",
85
+ "adoption-minimum",
86
+ ]);
87
+
88
+ export const CHANNELS = Object.freeze(["stable", "edge"]);
89
+
90
+ export const KINDS = Object.freeze(["pattern", "tool", "collection", "doc"]);
91
+
92
+ export const AGENT_IDS = Object.freeze(Object.keys(KNOWN_AGENTS));
93
+
94
+ export const PIN_KEY_REGEX = /^(pattern|tool|collection|doc)\/[a-zA-Z0-9_\-\.\/]+$/;
95
+
96
+ export const UUID_V4_REGEX =
97
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
98
+
99
+ const SEMVER_OR_RANGE_REGEX =
100
+ /^(\^|~|>=|<=|>|<|=)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
101
+
102
+ /**
103
+ * Produce a fresh, independent default v1 config (all nested objects are new).
104
+ *
105
+ * Kept for the legacy `shipctl config init` path and any callers that
106
+ * still dogfood v1. New installs go through DEFAULT_CONFIG_V2 below.
107
+ */
108
+ export function DEFAULT_CONFIG_V1() {
109
+ return {
110
+ version: LEGACY_CONFIG_SCHEMA_VERSION,
111
+ shipctl_min: "0.11.2",
112
+ api: {
113
+ base_url: "https://ship.elmundi.com",
114
+ channel: "stable",
115
+ ttl_hours: 24,
116
+ offline_ok: true,
117
+ },
118
+ stack: {
119
+ tracker: "none",
120
+ ci: "manual",
121
+ agents: [],
122
+ language: "multi",
123
+ preset: "adoption-minimum",
124
+ },
125
+ artifacts: {
126
+ pins: {},
127
+ auto_update: true,
128
+ },
129
+ cache: {
130
+ vcs_tracked: false,
131
+ },
132
+ telemetry: {
133
+ share: false,
134
+ anonymous_id: null,
135
+ scope: {
136
+ artifact_usage: true,
137
+ improvement_drafts: true,
138
+ errors: false,
139
+ },
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Produce a fresh, independent default v2 config. `lanes` is empty — the
146
+ * install flow (`shipctl init`, presets) seeds it with per-preset lanes.
147
+ */
148
+ export function DEFAULT_CONFIG_V2() {
149
+ const v1 = DEFAULT_CONFIG_V1();
150
+ return {
151
+ version: CONFIG_SCHEMA_VERSION,
152
+ shipctl_min: "0.12.0",
153
+ api: v1.api,
154
+ stack: v1.stack,
155
+ agent: {
156
+ default: { provider: null },
157
+ overrides: {},
158
+ },
159
+ lanes: {},
160
+ artifacts: v1.artifacts,
161
+ cache: v1.cache,
162
+ telemetry: v1.telemetry,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
168
+ * return the current schema's shape; default to v2 so new installs
169
+ * benefit from lanes.
170
+ */
171
+ export function DEFAULT_CONFIG() {
172
+ return DEFAULT_CONFIG_V2();
173
+ }
174
+
175
+ /** @typedef {{ok:true,config:object,warnings:string[]}|{ok:false,errors:string[],warnings:string[]}} ValidationResult */
176
+
177
+ const KNOWN_TOP_LEVEL_V1 = new Set([
178
+ "version",
179
+ "shipctl_min",
180
+ "api",
181
+ "stack",
182
+ "artifacts",
183
+ "cache",
184
+ "telemetry",
185
+ ]);
186
+
187
+ const KNOWN_TOP_LEVEL_V2 = new Set([
188
+ "version",
189
+ "shipctl_min",
190
+ "api",
191
+ "stack",
192
+ "agent",
193
+ "lanes",
194
+ "artifacts",
195
+ "cache",
196
+ "telemetry",
197
+ ]);
198
+
199
+ /* Back-compat export — keep the old symbol alive so external importers
200
+ * (if any) don't break silently. Point it at the v2 set since new
201
+ * callers should assume v2. */
202
+ const KNOWN_TOP_LEVEL = KNOWN_TOP_LEVEL_V2;
203
+
204
+ const KNOWN_API = new Set(["base_url", "channel", "ttl_hours", "offline_ok"]);
205
+ const KNOWN_STACK = new Set(["tracker", "ci", "agents", "agent", "language", "preset"]);
206
+ const KNOWN_STACK_AGENT = new Set(["provider"]);
207
+ const KNOWN_ARTIFACTS = new Set(["pins", "auto_update"]);
208
+ const KNOWN_CACHE = new Set(["vcs_tracked"]);
209
+ const KNOWN_TELEMETRY = new Set(["share", "anonymous_id", "scope"]);
210
+ const KNOWN_TELEMETRY_SCOPE = new Set(["artifact_usage", "improvement_drafts", "errors"]);
211
+
212
+ function isPlainObject(v) {
213
+ return v !== null && typeof v === "object" && !Array.isArray(v);
214
+ }
215
+
216
+ function pushUnknownKeyWarnings(obj, allowed, prefix, warnings) {
217
+ if (!isPlainObject(obj)) return;
218
+ for (const k of Object.keys(obj)) {
219
+ if (!allowed.has(k)) {
220
+ warnings.push(`${prefix}.${k}: unknown key (ignored, preserved on write)`);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Validate the common shared portion of v1 and v2. Mutates `errors` and
227
+ * `warnings` in place. The per-version wrappers below layer on
228
+ * schema-specific validators on top of this foundation.
229
+ *
230
+ * @param {object} obj
231
+ * @param {string[]} errors
232
+ * @param {string[]} warnings
233
+ */
234
+ function validateSharedSections(obj, errors, warnings) {
235
+ const api = obj.api;
236
+ if (!isPlainObject(api)) {
237
+ errors.push("api: must be an object");
238
+ } else {
239
+ pushUnknownKeyWarnings(api, KNOWN_API, "api", warnings);
240
+ if (typeof api.base_url !== "string") {
241
+ errors.push("api.base_url: must be a string URL");
242
+ } else {
243
+ try {
244
+ new URL(api.base_url);
245
+ } catch {
246
+ errors.push(`api.base_url: not a valid URL (${api.base_url})`);
247
+ }
248
+ }
249
+ if (api.channel !== undefined && !CHANNELS.includes(api.channel)) {
250
+ errors.push(
251
+ `api.channel: ${JSON.stringify(api.channel)} is not valid. Expected one of: ${CHANNELS.join(", ")}`,
252
+ );
253
+ }
254
+ if (api.ttl_hours !== undefined) {
255
+ if (typeof api.ttl_hours !== "number" || !Number.isFinite(api.ttl_hours) || api.ttl_hours < 0) {
256
+ errors.push("api.ttl_hours: must be a number ≥ 0");
257
+ }
258
+ }
259
+ if (api.offline_ok !== undefined && typeof api.offline_ok !== "boolean") {
260
+ errors.push("api.offline_ok: must be boolean");
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * v2-specific validator for the `agent` and `lanes` blocks.
267
+ *
268
+ * @param {object} obj
269
+ * @param {string[]} errors
270
+ * @param {string[]} warnings
271
+ */
272
+ function validateV2Lanes(obj, errors, warnings) {
273
+ const agent = obj.agent;
274
+ if (agent !== undefined) {
275
+ if (!isPlainObject(agent)) {
276
+ errors.push("agent: must be an object");
277
+ } else {
278
+ pushUnknownKeyWarnings(agent, new Set(["default", "overrides"]), "agent", warnings);
279
+ if (agent.default !== undefined) {
280
+ if (!isPlainObject(agent.default)) {
281
+ errors.push("agent.default: must be an object");
282
+ } else {
283
+ pushUnknownKeyWarnings(agent.default, new Set(["provider"]), "agent.default", warnings);
284
+ const p = agent.default.provider;
285
+ if (p !== undefined && p !== null) {
286
+ if (typeof p !== "string" || p.length < 1 || p.length > 64) {
287
+ errors.push("agent.default.provider: must be a non-empty string (≤64 chars)");
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if (agent.overrides !== undefined) {
293
+ if (!isPlainObject(agent.overrides)) {
294
+ errors.push("agent.overrides: must be a map");
295
+ } else {
296
+ for (const [laneId, override] of Object.entries(agent.overrides)) {
297
+ if (!LANE_ID_REGEX.test(laneId)) {
298
+ errors.push(`agent.overrides[${JSON.stringify(laneId)}]: invalid lane id`);
299
+ continue;
300
+ }
301
+ if (!isPlainObject(override)) {
302
+ errors.push(`agent.overrides.${laneId}: must be an object`);
303
+ continue;
304
+ }
305
+ const p = override.provider;
306
+ if (p !== undefined && p !== null && (typeof p !== "string" || p.length < 1 || p.length > 64)) {
307
+ errors.push(`agent.overrides.${laneId}.provider: must be a non-empty string (≤64 chars)`);
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ const lanes = obj.lanes;
316
+ if (lanes === undefined) {
317
+ /* An empty lanes map is legal — a fresh repo that has only gone
318
+ * through `shipctl migrate` has no automation wired yet, and that's
319
+ * the right default for onboarding. */
320
+ return;
321
+ }
322
+ if (!isPlainObject(lanes)) {
323
+ errors.push("lanes: must be a map of lane-id → lane");
324
+ return;
325
+ }
326
+ for (const [laneId, lane] of Object.entries(lanes)) {
327
+ validateLane(laneId, lane, errors, warnings);
328
+ }
329
+ }
330
+
331
+ const KNOWN_LANE_COMMON = new Set([
332
+ "kind",
333
+ "pattern",
334
+ "pattern_version",
335
+ "permissions",
336
+ "runner",
337
+ "timeout_minutes",
338
+ "concurrency",
339
+ ]);
340
+ const KNOWN_LANE_ONCE = new Set([...KNOWN_LANE_COMMON, "idempotency"]);
341
+ const KNOWN_LANE_EVENT = new Set([...KNOWN_LANE_COMMON, "on", "when"]);
342
+ const KNOWN_LANE_SCHEDULE = new Set([...KNOWN_LANE_COMMON, "cron", "cron_tz"]);
343
+
344
+ /**
345
+ * @param {string} laneId
346
+ * @param {any} lane
347
+ * @param {string[]} errors
348
+ * @param {string[]} warnings
349
+ */
350
+ function validateLane(laneId, lane, errors, warnings) {
351
+ if (!LANE_ID_REGEX.test(laneId)) {
352
+ errors.push(
353
+ `lanes[${JSON.stringify(laneId)}]: invalid id; expected /^[a-z0-9][a-z0-9_-]{0,63}$/`,
354
+ );
355
+ return;
356
+ }
357
+ if (!isPlainObject(lane)) {
358
+ errors.push(`lanes.${laneId}: must be an object`);
359
+ return;
360
+ }
361
+
362
+ const prefix = `lanes.${laneId}`;
363
+
364
+ if (typeof lane.kind !== "string" || !LANE_KINDS.includes(lane.kind)) {
365
+ errors.push(
366
+ `${prefix}.kind: must be one of ${LANE_KINDS.join("|")}; got ${JSON.stringify(lane.kind)}`,
367
+ );
368
+ }
369
+ if (typeof lane.pattern !== "string" || !lane.pattern.trim()) {
370
+ errors.push(`${prefix}.pattern: must be a non-empty pattern id`);
371
+ }
372
+ if (
373
+ lane.pattern_version !== undefined &&
374
+ (typeof lane.pattern_version !== "string" || !lane.pattern_version.trim())
375
+ ) {
376
+ errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
377
+ }
378
+ if (lane.permissions !== undefined && !isPlainObject(lane.permissions)) {
379
+ errors.push(`${prefix}.permissions: must be an object when set`);
380
+ }
381
+ if (lane.runner !== undefined && (typeof lane.runner !== "string" || !lane.runner.trim())) {
382
+ errors.push(`${prefix}.runner: must be a non-empty string when set`);
383
+ }
384
+ if (lane.timeout_minutes !== undefined) {
385
+ const n = lane.timeout_minutes;
386
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1 || n > 6 * 60) {
387
+ errors.push(`${prefix}.timeout_minutes: must be an integer between 1 and 360`);
388
+ }
389
+ }
390
+ if (lane.concurrency !== undefined) {
391
+ if (!isPlainObject(lane.concurrency)) {
392
+ errors.push(`${prefix}.concurrency: must be an object`);
393
+ } else {
394
+ pushUnknownKeyWarnings(
395
+ lane.concurrency,
396
+ new Set(["group", "cancel_in_progress"]),
397
+ `${prefix}.concurrency`,
398
+ warnings,
399
+ );
400
+ if (typeof lane.concurrency.group !== "string" || !lane.concurrency.group.trim()) {
401
+ errors.push(`${prefix}.concurrency.group: must be a non-empty string`);
402
+ }
403
+ if (
404
+ lane.concurrency.cancel_in_progress !== undefined &&
405
+ typeof lane.concurrency.cancel_in_progress !== "boolean"
406
+ ) {
407
+ errors.push(`${prefix}.concurrency.cancel_in_progress: must be boolean`);
408
+ }
409
+ }
410
+ }
411
+
412
+ switch (lane.kind) {
413
+ case "once":
414
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_ONCE, prefix, warnings);
415
+ validateLaneIdempotency(lane, prefix, errors, warnings);
416
+ break;
417
+ case "event":
418
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_EVENT, prefix, warnings);
419
+ if (typeof lane.on !== "string" || !LANE_EVENT_TYPES.includes(lane.on)) {
420
+ errors.push(
421
+ `${prefix}.on: must be one of ${LANE_EVENT_TYPES.join("|")}; got ${JSON.stringify(lane.on)}`,
422
+ );
423
+ }
424
+ if (lane.when !== undefined && !isPlainObject(lane.when)) {
425
+ errors.push(`${prefix}.when: must be an object when set`);
426
+ }
427
+ break;
428
+ case "schedule":
429
+ pushUnknownKeyWarnings(lane, KNOWN_LANE_SCHEDULE, prefix, warnings);
430
+ if (typeof lane.cron !== "string" || !CRON_5_FIELD_REGEX.test(lane.cron)) {
431
+ errors.push(
432
+ `${prefix}.cron: must be a 5-field cron expression; got ${JSON.stringify(lane.cron)}`,
433
+ );
434
+ }
435
+ if (lane.cron_tz !== undefined && (typeof lane.cron_tz !== "string" || !lane.cron_tz.trim())) {
436
+ errors.push(`${prefix}.cron_tz: must be a non-empty IANA tz string when set`);
437
+ }
438
+ break;
439
+ }
440
+ }
441
+
442
+ function validateLaneIdempotency(lane, prefix, errors, warnings) {
443
+ const idem = lane.idempotency;
444
+ if (!isPlainObject(idem)) {
445
+ errors.push(`${prefix}.idempotency: must be an object (kind=once requires it)`);
446
+ return;
447
+ }
448
+ pushUnknownKeyWarnings(
449
+ idem,
450
+ new Set(["key", "store", "reset_on"]),
451
+ `${prefix}.idempotency`,
452
+ warnings,
453
+ );
454
+ if (typeof idem.key !== "string" || !IDEMPOTENCY_KEY_REGEX.test(idem.key)) {
455
+ errors.push(
456
+ `${prefix}.idempotency.key: must match /^[a-z0-9][a-z0-9_.-]{0,127}$/; got ${JSON.stringify(idem.key)}`,
457
+ );
458
+ }
459
+ if (idem.store !== undefined && !LANE_IDEMPOTENCY_STORES.includes(idem.store)) {
460
+ errors.push(
461
+ `${prefix}.idempotency.store: must be one of ${LANE_IDEMPOTENCY_STORES.join("|")}`,
462
+ );
463
+ }
464
+ if (idem.reset_on !== undefined && !LANE_IDEMPOTENCY_RESET_ON.includes(idem.reset_on)) {
465
+ errors.push(
466
+ `${prefix}.idempotency.reset_on: must be one of ${LANE_IDEMPOTENCY_RESET_ON.join("|")}`,
467
+ );
468
+ }
469
+ }
470
+
471
+ /**
472
+ * @param {any} obj
473
+ * @returns {ValidationResult}
474
+ */
475
+ export function validateConfig(obj) {
476
+ const errors = [];
477
+ const warnings = [];
478
+
479
+ if (!isPlainObject(obj)) {
480
+ return { ok: false, errors: ["config must be a YAML mapping"], warnings };
481
+ }
482
+
483
+ if (!SUPPORTED_CONFIG_VERSIONS.includes(obj.version)) {
484
+ errors.push(
485
+ `version: unsupported; expected one of ${SUPPORTED_CONFIG_VERSIONS.join(", ")}, got ${JSON.stringify(obj.version)}`,
486
+ );
487
+ return { ok: false, errors, warnings };
488
+ }
489
+
490
+ const isV2 = obj.version === CONFIG_SCHEMA_VERSION;
491
+ const topLevel = isV2 ? KNOWN_TOP_LEVEL_V2 : KNOWN_TOP_LEVEL_V1;
492
+ pushUnknownKeyWarnings(obj, topLevel, "", warnings);
493
+
494
+ if (!isV2) {
495
+ warnings.push(
496
+ `version: config is at v${obj.version}; run \`shipctl migrate\` to upgrade to v${CONFIG_SCHEMA_VERSION}`,
497
+ );
498
+ }
499
+
500
+ validateSharedSections(obj, errors, warnings);
501
+ if (isV2) {
502
+ validateV2Lanes(obj, errors, warnings);
503
+ }
504
+
505
+ /* stack / artifacts / telemetry / cache share the same shape between
506
+ * v1 and v2; api is already covered by validateSharedSections above. */
507
+
508
+ const stack = obj.stack;
509
+ if (!isPlainObject(stack)) {
510
+ errors.push("stack: must be an object");
511
+ } else {
512
+ pushUnknownKeyWarnings(stack, KNOWN_STACK, "stack", warnings);
513
+ if (stack.tracker !== undefined && !TRACKERS.includes(stack.tracker)) {
514
+ errors.push(
515
+ `stack.tracker: ${JSON.stringify(stack.tracker)} is not valid. Expected one of: ${TRACKERS.join(", ")}`,
516
+ );
517
+ }
518
+ if (stack.ci !== undefined && !CIS.includes(stack.ci)) {
519
+ errors.push(
520
+ `stack.ci: ${JSON.stringify(stack.ci)} is not valid. Expected one of: ${CIS.join(", ")}`,
521
+ );
522
+ }
523
+ if (stack.language !== undefined && !LANGUAGES.includes(stack.language)) {
524
+ errors.push(
525
+ `stack.language: ${JSON.stringify(stack.language)} is not valid. Expected one of: ${LANGUAGES.join(", ")}`,
526
+ );
527
+ }
528
+ if (stack.preset !== undefined && !PRESETS.includes(stack.preset)) {
529
+ errors.push(
530
+ `stack.preset: ${JSON.stringify(stack.preset)} is not valid. Expected one of: ${PRESETS.join(", ")}`,
531
+ );
532
+ }
533
+ if (stack.agents !== undefined) {
534
+ if (!Array.isArray(stack.agents)) {
535
+ errors.push("stack.agents: must be an array");
536
+ } else {
537
+ for (const a of stack.agents) {
538
+ if (typeof a !== "string" || !AGENT_IDS.includes(a)) {
539
+ errors.push(
540
+ `stack.agents: ${JSON.stringify(a)} is not valid. Expected one of: ${AGENT_IDS.join(", ")}`,
541
+ );
542
+ }
543
+ }
544
+ }
545
+ }
546
+ if (stack.agent !== undefined) {
547
+ if (!isPlainObject(stack.agent)) {
548
+ errors.push("stack.agent: must be an object");
549
+ } else {
550
+ pushUnknownKeyWarnings(stack.agent, KNOWN_STACK_AGENT, "stack.agent", warnings);
551
+ const p = stack.agent.provider;
552
+ if (p !== undefined && p !== null) {
553
+ if (typeof p !== "string" || p.length < 1 || p.length > 64) {
554
+ errors.push(
555
+ "stack.agent.provider: must be a non-empty string (≤64 chars), e.g. claude-code or cursor-cloud",
556
+ );
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ const artifacts = obj.artifacts;
564
+ if (artifacts !== undefined) {
565
+ if (!isPlainObject(artifacts)) {
566
+ errors.push("artifacts: must be an object");
567
+ } else {
568
+ pushUnknownKeyWarnings(artifacts, KNOWN_ARTIFACTS, "artifacts", warnings);
569
+ if (artifacts.pins !== undefined) {
570
+ if (!isPlainObject(artifacts.pins)) {
571
+ errors.push("artifacts.pins: must be a map");
572
+ } else {
573
+ for (const [k, v] of Object.entries(artifacts.pins)) {
574
+ if (!PIN_KEY_REGEX.test(k)) {
575
+ errors.push(
576
+ `artifacts.pins[${JSON.stringify(k)}]: invalid key; expected <kind>/<id> where kind∈{pattern,tool,collection,doc}`,
577
+ );
578
+ }
579
+ if (typeof v !== "string" || !SEMVER_OR_RANGE_REGEX.test(v.trim())) {
580
+ errors.push(
581
+ `artifacts.pins[${JSON.stringify(k)}]: value must be a semver or range (got ${JSON.stringify(v)})`,
582
+ );
583
+ }
584
+ }
585
+ }
586
+ }
587
+ if (artifacts.auto_update !== undefined && typeof artifacts.auto_update !== "boolean") {
588
+ errors.push("artifacts.auto_update: must be boolean");
589
+ }
590
+ }
591
+ }
592
+
593
+ const cache = obj.cache;
594
+ if (cache !== undefined) {
595
+ if (!isPlainObject(cache)) {
596
+ errors.push("cache: must be an object");
597
+ } else {
598
+ pushUnknownKeyWarnings(cache, KNOWN_CACHE, "cache", warnings);
599
+ if (cache.vcs_tracked !== undefined && typeof cache.vcs_tracked !== "boolean") {
600
+ errors.push("cache.vcs_tracked: must be boolean");
601
+ }
602
+ }
603
+ }
604
+
605
+ const telemetry = obj.telemetry;
606
+ if (telemetry !== undefined) {
607
+ if (!isPlainObject(telemetry)) {
608
+ errors.push("telemetry: must be an object");
609
+ } else {
610
+ pushUnknownKeyWarnings(telemetry, KNOWN_TELEMETRY, "telemetry", warnings);
611
+ if (telemetry.share !== undefined && typeof telemetry.share !== "boolean") {
612
+ errors.push("telemetry.share: must be boolean");
613
+ }
614
+ if (telemetry.share === true) {
615
+ if (typeof telemetry.anonymous_id !== "string" || !UUID_V4_REGEX.test(telemetry.anonymous_id)) {
616
+ errors.push(
617
+ "telemetry.anonymous_id: required UUID v4 when telemetry.share=true",
618
+ );
619
+ }
620
+ } else if (
621
+ telemetry.anonymous_id !== undefined &&
622
+ telemetry.anonymous_id !== null &&
623
+ (typeof telemetry.anonymous_id !== "string" || !UUID_V4_REGEX.test(telemetry.anonymous_id))
624
+ ) {
625
+ errors.push(
626
+ `telemetry.anonymous_id: ${JSON.stringify(telemetry.anonymous_id)} is not a valid UUID v4`,
627
+ );
628
+ }
629
+ if (telemetry.scope !== undefined) {
630
+ if (!isPlainObject(telemetry.scope)) {
631
+ errors.push("telemetry.scope: must be an object");
632
+ } else {
633
+ pushUnknownKeyWarnings(telemetry.scope, KNOWN_TELEMETRY_SCOPE, "telemetry.scope", warnings);
634
+ for (const k of KNOWN_TELEMETRY_SCOPE) {
635
+ if (telemetry.scope[k] !== undefined && typeof telemetry.scope[k] !== "boolean") {
636
+ errors.push(`telemetry.scope.${k}: must be boolean`);
637
+ }
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+ if (typeof obj.shipctl_min !== "undefined" && typeof obj.shipctl_min !== "string") {
645
+ errors.push("shipctl_min: must be a semver string");
646
+ }
647
+
648
+ if (errors.length) return { ok: false, errors, warnings };
649
+ return { ok: true, config: obj, warnings };
650
+ }