@elmundi/ship-cli 0.11.2 → 0.12.1

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.
@@ -7,7 +7,7 @@ import {
7
7
  writeState,
8
8
  findShipRoot,
9
9
  } from "../config/io.mjs";
10
- import { validateConfig } from "../config/schema.mjs";
10
+ import { validateConfig, lanePatterns as lanePatternList } from "../config/schema.mjs";
11
11
  import { fetchManifest, fetchArtifact } from "../http.mjs";
12
12
  import {
13
13
  readCached,
@@ -26,6 +26,52 @@ import {
26
26
  } from "../state/lockfile.mjs";
27
27
  import { getCliVersion } from "../version.mjs";
28
28
 
29
+ function printSyncHelp() {
30
+ console.log(`shipctl sync — fetch the catalog into .ship/cache (and optionally lock).
31
+
32
+ WHAT THIS COMMAND DOES
33
+ Pulls the artifacts your repo declares — pins, the active preset,
34
+ per-agent rule collections, and any pattern referenced by your lanes
35
+ (Automations in the operator console) — from the methodology API
36
+ into .ship/cache/<kind>/<id>@<version>/. Verifies content_sha256,
37
+ writes meta, optionally produces a lockfile so 'shipctl run --offline'
38
+ is reproducible.
39
+
40
+ USAGE
41
+ shipctl sync [--check-only] [--only <kind:id>]... [--channel <c>]
42
+ [--force-unpin] [--dry-run] [--lock] [--json] [--cwd <dir>]
43
+
44
+ FLAGS
45
+ --check-only Report what would change; do not write to disk.
46
+ --only <kind:id> Restrict to one or more artifacts (repeatable).
47
+ Example: --only pattern:role-developer --only collection:preset-web-app
48
+ --channel <c> Override config.api.channel for this invocation
49
+ (stable|edge).
50
+ --force-unpin Ignore artifacts.pins[] and pull the manifest
51
+ version. Use when intentionally bumping a pin.
52
+ --dry-run Print the resolution plan; do not write or fetch.
53
+ --lock After sync, materialise every pattern referenced
54
+ by the declared lanes and write
55
+ .ship/shipctl.lock.json (used by
56
+ 'shipctl run --offline').
57
+ --json Emit a structured JSON summary on stdout.
58
+ --cwd <dir> Repo root. Defaults to the current directory;
59
+ searches upward for .ship/.
60
+ --help, -h Show this help.
61
+
62
+ EXAMPLES
63
+ shipctl sync # baseline pull
64
+ shipctl sync --check-only --json # CI guard
65
+ shipctl sync --only pattern:role-developer --only tool:methodology-api
66
+ shipctl sync --lock # produce a reproducible lockfile
67
+
68
+ EXIT CODE
69
+ 0 when everything resolved.
70
+ 20 when at least one artifact failed to fetch (or --lock left
71
+ unresolved entries).
72
+ `);
73
+ }
74
+
29
75
  function parseSyncArgs(rest) {
30
76
  const out = {
31
77
  cwd: process.cwd(),
@@ -36,10 +82,17 @@ function parseSyncArgs(rest) {
36
82
  only: [],
37
83
  lock: false,
38
84
  json: false,
85
+ help: false,
86
+ unknown: [],
39
87
  };
40
88
  const copy = [...rest];
41
89
  while (copy.length) {
42
90
  const a = copy[0];
91
+ if (a === "--help" || a === "-h") {
92
+ out.help = true;
93
+ copy.shift();
94
+ continue;
95
+ }
43
96
  if (a === "--check-only") {
44
97
  out.checkOnly = true;
45
98
  copy.shift();
@@ -95,6 +148,12 @@ function parseSyncArgs(rest) {
95
148
  copy.shift();
96
149
  continue;
97
150
  }
151
+ /* Previously we silently dropped unrecognised tokens here. That
152
+ * hid bashisms like a misspelt `--cheek-only`, so we now collect
153
+ * them and warn from `syncCommand` once parsing is complete.
154
+ * Stays non-fatal because existing CI scripts may rely on the old
155
+ * permissive behaviour. */
156
+ out.unknown.push(a);
98
157
  copy.shift();
99
158
  }
100
159
  return out;
@@ -457,19 +516,26 @@ export async function buildLockfile({ shipRoot, config, baseUrl, channel, verbos
457
516
  const notes = [];
458
517
 
459
518
  const pins = config.artifacts?.pins || {};
460
- const lanePatterns = Object.entries(config.lanes || {})
461
- .map(([laneId, lane]) => ({ laneId, lane }))
462
- .filter((r) => r.lane && typeof r.lane.pattern === "string");
463
- const pinPatterns = Object.keys(pins)
519
+ /* Flatten each lane into one (laneId, patternId) row per pattern so
520
+ * lanes that declare ``patterns: [a, b]`` (RFC-0008 C3.1) feed both
521
+ * into the sync/lockfile pipeline. Legacy ``pattern: <id>`` lanes
522
+ * normalise to a single-element list via lanePatternList(). */
523
+ const laneRows = [];
524
+ for (const [laneId, lane] of Object.entries(config.lanes || {})) {
525
+ for (const pid of lanePatternList(lane)) {
526
+ laneRows.push({ laneId, patternId: pid });
527
+ }
528
+ }
529
+ const pinRows = Object.keys(pins)
464
530
  .filter((k) => k.startsWith("pattern/"))
465
- .map((k) => ({ laneId: null, lane: { pattern: k.slice("pattern/".length) } }));
531
+ .map((k) => ({ laneId: null, patternId: k.slice("pattern/".length) }));
466
532
 
467
533
  /* De-duplicate on pattern id while preserving lane provenance (useful
468
534
  * for the `notes` field — operators want to know which lane pinned a
469
535
  * given pattern when they read the diff). */
470
536
  const seen = new Map();
471
- for (const row of [...lanePatterns, ...pinPatterns]) {
472
- const pid = row.lane.pattern;
537
+ for (const row of [...laneRows, ...pinRows]) {
538
+ const pid = row.patternId;
473
539
  if (!seen.has(pid)) seen.set(pid, { id: pid, by: [] });
474
540
  seen.get(pid).by.push(row.laneId || "config.artifacts.pins");
475
541
  }
@@ -637,8 +703,17 @@ function cmpSemver(a, b) {
637
703
 
638
704
  export async function syncCommand(ctx, rest) {
639
705
  const args = parseSyncArgs(rest);
706
+ if (args.help) {
707
+ printSyncHelp();
708
+ return;
709
+ }
640
710
  if (ctx?.dryRun) args.dryRun = true;
641
711
  if (ctx?.json) args.json = true;
712
+ for (const tok of args.unknown) {
713
+ console.warn(
714
+ `warn: shipctl sync: ignoring unknown argument '${tok}'. Run 'shipctl sync --help'.`,
715
+ );
716
+ }
642
717
 
643
718
  let result;
644
719
  try {
@@ -0,0 +1,200 @@
1
+ import { readConfig } from "../config/io.mjs";
2
+
3
+ const VERSION = "v1";
4
+
5
+ export async function triggerCommand(ctx, rest) {
6
+ const opts = parseArgs(rest);
7
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
8
+ const token = requireToken();
9
+ let workspaceId = opts.workspace;
10
+ if (!workspaceId) workspaceId = await resolveSoleWorkspace(baseUrl, token);
11
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
12
+ const { config } = readConfig(opts.cwd || process.cwd());
13
+
14
+ const result = await apiPostJson(
15
+ baseUrl,
16
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/trigger`,
17
+ {
18
+ event: opts.event,
19
+ config,
20
+ github: {
21
+ event_name: process.env.SHIP_EVENT_NAME || process.env.GITHUB_EVENT_NAME || "",
22
+ ref: process.env.SHIP_REF || process.env.GITHUB_REF || "",
23
+ sha: process.env.SHIP_SHA || process.env.GITHUB_SHA || "",
24
+ run_id: process.env.GITHUB_RUN_ID || "",
25
+ },
26
+ },
27
+ token,
28
+ );
29
+
30
+ if (ctx.json || opts.json) {
31
+ console.log(JSON.stringify(result, null, 2));
32
+ return;
33
+ }
34
+ const due = Array.isArray(result.due_lanes) ? result.due_lanes : [];
35
+ if (!due.length) {
36
+ console.log(`Ship trigger ${opts.event}: no lanes due.`);
37
+ return;
38
+ }
39
+ console.log(`Ship trigger ${opts.event}: ${due.length} lane(s) due`);
40
+ for (const lane of due) console.log(` - ${lane.lane_id}`);
41
+ }
42
+
43
+ function explicitGlobalBaseUrl(ctx) {
44
+ return ctx?.baseUrlSource === "flag" ? ctx.baseUrl : null;
45
+ }
46
+
47
+ function printHelp() {
48
+ console.log(`shipctl trigger — ask Ship which lanes are due (${VERSION})
49
+
50
+ USAGE
51
+ shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
52
+
53
+ ENV
54
+ SHIP_API_TOKEN Required.
55
+ SHIP_WORKSPACE_API_BASE Optional API base override.
56
+ SHIP_API_BASE Fallback API base override.
57
+ `);
58
+ }
59
+
60
+ function parseArgs(args) {
61
+ const out = {
62
+ event: null,
63
+ workspace: null,
64
+ repo: null,
65
+ baseUrl: null,
66
+ cwd: null,
67
+ json: false,
68
+ };
69
+ const copy = [...args];
70
+ const consume = (flag, key) => {
71
+ if (copy[0] === flag && copy[1] !== undefined) {
72
+ copy.shift();
73
+ out[key] = String(copy.shift());
74
+ return true;
75
+ }
76
+ const p = `${flag}=`;
77
+ if (copy[0] && copy[0].startsWith(p)) {
78
+ out[key] = copy[0].slice(p.length);
79
+ copy.shift();
80
+ return true;
81
+ }
82
+ return false;
83
+ };
84
+ while (copy.length) {
85
+ if (
86
+ consume("--event", "event") ||
87
+ consume("--workspace", "workspace") ||
88
+ consume("--repo", "repo") ||
89
+ consume("--base-url", "baseUrl") ||
90
+ consume("--cwd", "cwd")
91
+ ) {
92
+ continue;
93
+ }
94
+ if (copy[0] === "--json") {
95
+ out.json = true;
96
+ copy.shift();
97
+ continue;
98
+ }
99
+ if (copy[0] === "--help" || copy[0] === "-h") {
100
+ printHelp();
101
+ process.exit(0);
102
+ }
103
+ console.error(`Unknown flag: ${copy[0]}`);
104
+ process.exit(1);
105
+ }
106
+ if (!out.event) {
107
+ console.error("Usage: shipctl trigger --event <schedule> --repo <id|owner/name>");
108
+ process.exit(1);
109
+ }
110
+ if (!["schedule", "manual", "pull_request", "push"].includes(out.event)) {
111
+ console.error("--event must be one of: schedule, manual, pull_request, push");
112
+ process.exit(1);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function requireToken() {
118
+ const token = process.env.SHIP_API_TOKEN || "";
119
+ if (!token) {
120
+ console.error("SHIP_API_TOKEN is required.");
121
+ process.exit(1);
122
+ }
123
+ return token;
124
+ }
125
+
126
+ function resolveBaseUrl(explicit) {
127
+ if (explicit) return explicit.replace(/\/+$/, "");
128
+ if (process.env.SHIP_WORKSPACE_API_BASE) return process.env.SHIP_WORKSPACE_API_BASE.replace(/\/+$/, "");
129
+ if (process.env.SHIP_API_BASE) return process.env.SHIP_API_BASE.replace(/\/+$/, "");
130
+ return "https://api.ship.elmundi.com";
131
+ }
132
+
133
+ async function resolveSoleWorkspace(baseUrl, token) {
134
+ const rows = await apiGetJson(baseUrl, "/v1/workspaces", token);
135
+ if (!Array.isArray(rows) || rows.length === 0) {
136
+ console.error("No workspaces visible to this token.");
137
+ process.exit(1);
138
+ }
139
+ if (rows.length > 1) {
140
+ console.error("Token has access to more than one workspace; pass --workspace <id>.");
141
+ process.exit(1);
142
+ }
143
+ return String(rows[0].id);
144
+ }
145
+
146
+ async function resolveRepoId(baseUrl, token, workspaceId, hint) {
147
+ if (hint && /^[0-9a-fA-F-]{32,36}$/.test(hint) && hint.includes("-")) return hint;
148
+ const rows = await apiGetJson(baseUrl, `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos`, token);
149
+ if (!Array.isArray(rows) || rows.length === 0) {
150
+ console.error(`Workspace ${workspaceId} has no activated repos.`);
151
+ process.exit(1);
152
+ }
153
+ if (hint) {
154
+ const match = rows.find((r) => r.full_name === hint || `${r.owner ?? ""}/${r.name ?? ""}` === hint || r.id === hint);
155
+ if (!match) {
156
+ console.error(`--repo ${hint} doesn't match any activated repo in workspace ${workspaceId}.`);
157
+ process.exit(1);
158
+ }
159
+ return String(match.id);
160
+ }
161
+ return String(rows[0].id);
162
+ }
163
+
164
+ async function apiGetJson(baseUrl, path, token) {
165
+ return apiRequest(baseUrl, path, "GET", token, null);
166
+ }
167
+
168
+ async function apiPostJson(baseUrl, path, body, token) {
169
+ return apiRequest(baseUrl, path, "POST", token, body);
170
+ }
171
+
172
+ async function apiRequest(baseUrl, path, method, token, body) {
173
+ const url = `${baseUrl}${path}`;
174
+ let res;
175
+ try {
176
+ res = await fetch(url, {
177
+ method,
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ Accept: "application/json",
181
+ Authorization: `Bearer ${token}`,
182
+ },
183
+ body: body === null ? undefined : JSON.stringify(body),
184
+ });
185
+ } catch (err) {
186
+ console.error(`Network error calling ${url}: ${err instanceof Error ? err.message : err}`);
187
+ process.exit(3);
188
+ }
189
+ const text = await res.text();
190
+ let data = null;
191
+ try {
192
+ data = text ? JSON.parse(text) : null;
193
+ } catch {
194
+ data = text;
195
+ }
196
+ if (res.ok) return data;
197
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
198
+ console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
199
+ process.exit(res.status >= 500 ? 3 : 1);
200
+ }
@@ -19,7 +19,11 @@
19
19
  * the input untouched.
20
20
  */
21
21
 
22
- import { CONFIG_SCHEMA_VERSION, LEGACY_CONFIG_SCHEMA_VERSION } from "./schema.mjs";
22
+ import {
23
+ CONFIG_SCHEMA_VERSION,
24
+ DEFAULT_PROCESS_CONFIG,
25
+ LEGACY_CONFIG_SCHEMA_VERSION,
26
+ } from "./schema.mjs";
23
27
 
24
28
  /**
25
29
  * Default lane translations for the well-known v1 `lanes:` list entries.
@@ -34,23 +38,23 @@ import { CONFIG_SCHEMA_VERSION, LEGACY_CONFIG_SCHEMA_VERSION } from "./schema.mj
34
38
  const V1_LANE_DEFAULTS = Object.freeze({
35
39
  pr_review: {
36
40
  kind: "event",
37
- pattern: "catalog-a5-pr-self-review",
41
+ pattern: "flow-pr-self-review",
38
42
  on: "pull_request",
39
43
  permissions: { contents: "read", "pull-requests": "write" },
40
44
  },
41
45
  daily_standup: {
42
46
  kind: "schedule",
43
- pattern: "catalog-a13-daily-retro",
47
+ pattern: "flow-daily-retro",
44
48
  cron: "0 9 * * 1-5",
45
49
  },
46
50
  tech_debt: {
47
51
  kind: "schedule",
48
- pattern: "catalog-a12-learning",
52
+ pattern: "flow-learning-capture",
49
53
  cron: "0 10 * * 1",
50
54
  },
51
55
  self_heal: {
52
56
  kind: "event",
53
- pattern: "cloud-workflow-self-heal",
57
+ pattern: "op-workflow-self-heal",
54
58
  on: "workflow_run",
55
59
  when: { conclusion: "failure" },
56
60
  permissions: { contents: "read", actions: "read", "pull-requests": "write" },
@@ -132,6 +136,10 @@ export function migrateV1ToV2(input) {
132
136
  out.agent.default.provider = liftedProvider;
133
137
  }
134
138
 
139
+ if (!out.process || typeof out.process !== "object" || Array.isArray(out.process)) {
140
+ out.process = cloneDefault(DEFAULT_PROCESS_CONFIG());
141
+ }
142
+
135
143
  /* lanes: translate from the legacy list-of-strings shape. */
136
144
  out.lanes = {};
137
145
  const srcLanes = src.lanes;