@elmundi/ship-cli 0.12.0 → 0.12.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.
package/README.md CHANGED
@@ -445,8 +445,8 @@ shipctl lanes install --yes
445
445
  shipctl lanes install --only pr-self-review,release-cut
446
446
 
447
447
  # wire to a specific shipctl version pin
448
- shipctl lanes install --shipctl-version 0.12.0 \
449
- --owner elmundi --repo ship --ref v0.12.0
448
+ shipctl lanes install --shipctl-version 0.12.1 \
449
+ --owner elmundi --repo ship --ref v0.12.1
450
450
 
451
451
  # inspect what's on disk vs config.yml
452
452
  shipctl lanes list --json
package/bin/shipctl.mjs CHANGED
@@ -24,6 +24,7 @@ if (raw[0] === "--version" || raw[0] === "-v" || raw[0] === "version") {
24
24
  const { _, ...g } = extractGlobalArgv(raw);
25
25
  const ctx = {
26
26
  baseUrl: g.baseUrl,
27
+ baseUrlSource: g.baseUrlSource,
27
28
  json: g.json,
28
29
  yes: g.yes,
29
30
  force: g.force,
@@ -139,6 +140,12 @@ try {
139
140
  process.exit(0);
140
141
  }
141
142
 
143
+ if (cmd === "process") {
144
+ const { processCommand } = await import("../lib/commands/process.mjs");
145
+ await processCommand(ctx, rest);
146
+ process.exit(0);
147
+ }
148
+
142
149
  if (cmd === "migrate") {
143
150
  const { migrateCommand } = await import("../lib/commands/migrate.mjs");
144
151
  await migrateCommand(ctx, rest);
@@ -1,20 +1,20 @@
1
1
  export function printHelp() {
2
- console.log(`shipctl — adopt Ship in a repo, sync the catalog, run lanes, report Runs.
2
+ console.log(`shipctl — adopt Ship in a repo, sync the catalog, run routines, report outcomes.
3
3
 
4
4
  Bootstrap a new or existing repo (init / new / doctor), pull the
5
- methodology catalog into .ship/cache (sync), execute one-shot lanes or
6
- emit prompts for the workspace runner (run / lanes / kickoff /
5
+ methodology catalog into .ship/cache (sync), execute one-shot routines or
6
+ emit prompts for the workspace runner (trigger / run / kickoff /
7
7
  callback). Talks to the methodology + orchestration APIs over HTTPS.
8
8
 
9
9
  VOCABULARY
10
- lanes: (.ship/config.yml) operator console: Automations
11
- pattern: (artifact kind) operator console: Plays
12
- pipeline_runs (DB / API) operator console: Runs
13
- attention surface → operator console: Inbox
10
+ process.routines: (.ship/config.yml) repo-local scheduled/manual work
11
+ pattern: (artifact kind) cached role/playbook prompt body
12
+ routine claim: (Ship API) idempotent schedule-window claim
13
+ attention surface → operator console: Inbox
14
14
 
15
- The protocol-stable terms (lanes:, pattern:, pipeline_runs) stay
16
- literal in YAML, CLI flags, and HTTP. Operator-facing prose uses the
17
- console nouns.
15
+ Legacy 'lanes:' configs and '--lane' flags are still accepted as aliases
16
+ so already-seeded repositories keep working while new repos use
17
+ 'process.routines'.
18
18
 
19
19
  GLOBAL FLAGS
20
20
  --base-url URL Methodology API (default: SHIP_API_BASE or
@@ -66,22 +66,18 @@ COMMANDS
66
66
  [--force-unpin] [--dry-run] [--lock] [--json] [--cwd <dir>]
67
67
  — fetch artifacts into .ship/cache. With --lock,
68
68
  also writes .ship/shipctl.lock.json covering
69
- every pattern the declared lanes depend on.
69
+ every pattern the declared routines depend on.
70
70
 
71
71
  Run
72
72
  shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
73
- ask Ship which configured lanes are
74
- due for the current GitHub trigger.
75
- shipctl run --lane <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
73
+ compute due routines locally, then claim
74
+ the schedule window in Ship.
75
+ shipctl run --routine <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
76
76
  [--trigger event|schedule|manual|once]
77
77
  [--dry-run] [--offline] [--json] [--cwd <dir>]
78
78
  [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
79
- — one-shot dispatch entry point. 'kind: once'
80
- lanes execute fully here; 'kind: lane / event /
81
- schedule' lanes are queued for the workspace
82
- runner via .github/workflows/run-agent.yml.
83
- Reports its terminal status via the callback URL
84
- Ship injected into the workflow.
79
+ — one-shot routine dispatch entry point.
80
+ Use --lane as a legacy alias.
85
81
  shipctl lanes install [--only <csv>] [--ref <git-ref>] [--owner <gh>] [--repo <name>]
86
82
  [--shipctl-version <v>] [--dry-run] [--force] [--json] [--cwd <dir>]
87
83
  shipctl lanes list [--json] [--cwd <dir>]
@@ -99,6 +95,17 @@ COMMANDS
99
95
  RunSummary outcome) back to Ship so it can
100
96
  render the outcome row and route any
101
97
  escalations into the Inbox.
98
+ shipctl process prompt --state <id> [--ticket-json <json>] [--policies-file <path>]
99
+ [--cwd <dir>] [--json]
100
+ — assemble a Process/FSM specialist prompt
101
+ bundle with ticket context, allowed
102
+ transitions, policies, and mandatory
103
+ knowledge-first guardrails.
104
+ shipctl process tickets --workspace <id> [--query <text>] [--tracker <kind>] [--json]
105
+ — read-only tracker picker for selecting
106
+ ticket context before building a process
107
+ prompt. Does not create, comment, or
108
+ transition tickets.
102
109
 
103
110
  Knowledge
104
111
  shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>] [--only <csv>] [--json]
@@ -44,10 +44,12 @@
44
44
 
45
45
  const VERSION = "v1";
46
46
 
47
- /** Ship ships two starter buckets today — keep in lockstep with
48
- * ``backend.app.services.catalog.KNOWLEDGE_STARTERS`` and
49
- * ``console/src/lib/api/client.ts#KNOWLEDGE_STARTERS``. */
50
- const KNOWN_SLUGS = ["code-style", "ui-runbook"];
47
+ /** Static starters with source markdown under ``artifacts/knowledge-starters``.
48
+ * Procedural catalog recipes are exposed by the backend under the
49
+ * ``ship-recipes/<pattern-id>`` prefix because that list is generated from
50
+ * on-disk pattern artifacts at runtime. */
51
+ export const STATIC_KNOWLEDGE_SLUGS = ["code-style", "ui-runbook"];
52
+ export const RECIPE_KNOWLEDGE_PREFIX = "ship-recipes/";
51
53
 
52
54
  /**
53
55
  * @param {{baseUrl?: string, json?: boolean}} ctx
@@ -100,7 +102,9 @@ INIT FLAGS
100
102
  Defaults to the most-recently activated repo in
101
103
  the resolved workspace.
102
104
  --only <csv> Comma-separated starter slugs. Defaults to the
103
- full catalog (${KNOWN_SLUGS.join(", ")}).
105
+ full backend catalog, including static starters
106
+ (${STATIC_KNOWLEDGE_SLUGS.join(", ")}) and generated
107
+ recipe starters under ${RECIPE_KNOWLEDGE_PREFIX}<pattern-id>.
104
108
  --base-url URL Workspace control-plane API. See env fallbacks.
105
109
  --json Emit a machine-readable JSON summary.
106
110
 
@@ -123,7 +127,7 @@ EXIT
123
127
  */
124
128
  async function knowledgeInitCommand(ctx, args) {
125
129
  const opts = parseInitArgs(args);
126
- const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
130
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
127
131
  const token = process.env.SHIP_API_TOKEN || "";
128
132
  if (!token) {
129
133
  console.error(
@@ -134,10 +138,10 @@ async function knowledgeInitCommand(ctx, args) {
134
138
 
135
139
  const selection = opts.only;
136
140
  if (selection !== null) {
137
- const unknown = selection.filter((s) => !KNOWN_SLUGS.includes(s));
141
+ const unknown = selection.filter((s) => !isKnownKnowledgeStarterSlug(s));
138
142
  if (unknown.length) {
139
143
  console.error(
140
- `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown: ${KNOWN_SLUGS.join(", ")}`,
144
+ `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown static slugs: ${STATIC_KNOWLEDGE_SLUGS.join(", ")}; recipe slugs must start with ${RECIPE_KNOWLEDGE_PREFIX}`,
141
145
  );
142
146
  process.exit(1);
143
147
  }
@@ -173,9 +177,16 @@ async function knowledgeInitCommand(ctx, args) {
173
177
  );
174
178
  }
175
179
 
180
+ export function isKnownKnowledgeStarterSlug(slug) {
181
+ return (
182
+ STATIC_KNOWLEDGE_SLUGS.includes(slug) ||
183
+ slug.startsWith(RECIPE_KNOWLEDGE_PREFIX)
184
+ );
185
+ }
186
+
176
187
  async function knowledgeFetchCommand(ctx, args) {
177
188
  const opts = parseFetchArgs(args);
178
- const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
189
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
179
190
  const token = requireToken();
180
191
  let workspaceId = opts.workspace;
181
192
  if (!workspaceId) {
@@ -218,7 +229,7 @@ async function knowledgeFetchCommand(ctx, args) {
218
229
 
219
230
  async function knowledgeRefreshIntelCommand(ctx, args) {
220
231
  const opts = parseRefreshArgs(args);
221
- const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
232
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
222
233
  const token = requireToken();
223
234
  let workspaceId = opts.workspace;
224
235
  if (!workspaceId) {
@@ -246,7 +257,7 @@ async function knowledgeRefreshIntelCommand(ctx, args) {
246
257
 
247
258
  async function knowledgeBootstrapCommand(ctx, args) {
248
259
  const opts = parseBootstrapArgs(args);
249
- const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
260
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
250
261
  const token = requireToken();
251
262
  let workspaceId = opts.workspace;
252
263
  if (!workspaceId) {
@@ -278,6 +289,10 @@ async function knowledgeBootstrapCommand(ctx, args) {
278
289
  );
279
290
  }
280
291
 
292
+ function explicitGlobalBaseUrl(ctx) {
293
+ return ctx?.baseUrlSource === "flag" ? ctx.baseUrl : null;
294
+ }
295
+
281
296
  /**
282
297
  * @param {string[]} args
283
298
  * @returns {{
@@ -0,0 +1,388 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { readConfig } from "../config/io.mjs";
5
+ import { CONFIG_SCHEMA_VERSION, validateConfig } from "../config/schema.mjs";
6
+ import { apiGet } from "../http.mjs";
7
+ import {
8
+ buildSpecialistPromptBundle,
9
+ renderSpecialistPromptBundleMarkdown,
10
+ } from "../process/specialist-prompt-contract.mjs";
11
+
12
+ export async function processCommand(ctx, rest) {
13
+ const [sub, ...args] = rest;
14
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
15
+ printProcessHelp();
16
+ return;
17
+ }
18
+ if (sub === "prompt" || sub === "bundle") {
19
+ await processPromptCommand(ctx, args);
20
+ return;
21
+ }
22
+ if (sub === "tickets") {
23
+ await processTicketsCommand(ctx, args);
24
+ return;
25
+ }
26
+ console.error(
27
+ `Unknown 'shipctl process' subcommand: ${sub}\nRun: shipctl process --help`,
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ function printProcessHelp() {
33
+ console.log(`shipctl process — assemble Process/FSM specialist context
34
+
35
+ USAGE
36
+ shipctl process prompt --state <state-id> [--ticket-json <json>] [--ticket-file <path>]
37
+ [--ticket-id <id>] [--ticket-title <title>] [--ticket-url <url>]
38
+ [--ticket-description <text>] [--policies-file <path>]
39
+ [--cwd <dir>] [--json]
40
+ shipctl process prompt --specialist <specialist-id> [...]
41
+ shipctl process tickets --workspace <id> [--process <id>] [--tracker <kind>]
42
+ [--query <text>] [--state open|closed|all] [--limit <n>]
43
+ [--project-hint <hint>] [--assignee-me] [--assignee <user>]
44
+ [--json]
45
+
46
+ WHAT IT EMITS
47
+ A specialist prompt bundle assembled from .ship/config.yml:
48
+ - process and state identity
49
+ - specialist template fields from the selected state
50
+ - ticket context supplied by the caller / tracker picker
51
+ - allowed outgoing FSM transitions from process.transitions
52
+ - workspace policies supplied by --policies-file
53
+ - mandatory knowledge-first and Ship-boundary guardrails
54
+
55
+ The command does not mutate tracker tickets, execute transitions, or write to
56
+ the repository. It prepares context for an agent runtime; Ship remains
57
+ responsible for side effects, FSM validation, audit logging, and PR-only writes.`);
58
+ }
59
+
60
+ async function processTicketsCommand(ctx, args) {
61
+ const opts = parseTicketsArgs(args);
62
+ const baseUrl = resolveWorkspaceBaseUrl(opts.baseUrl || ctx.baseUrl);
63
+ const token = process.env.SHIP_API_TOKEN || "";
64
+ if (!token) {
65
+ console.error("SHIP_API_TOKEN is required for shipctl process tickets.");
66
+ process.exit(1);
67
+ }
68
+ const processId = opts.processId || "development";
69
+ const params = new URLSearchParams();
70
+ for (const [key, value] of Object.entries({
71
+ tracker: opts.tracker,
72
+ project_hint: opts.projectHint,
73
+ state: opts.state,
74
+ query: opts.query,
75
+ assignee: opts.assignee,
76
+ limit: opts.limit,
77
+ })) {
78
+ if (value !== null && value !== undefined && value !== "") {
79
+ params.set(key, String(value));
80
+ }
81
+ }
82
+ if (opts.assigneeMe) params.set("assignee_me", "true");
83
+ const suffix = params.toString() ? `?${params}` : "";
84
+ const data = await apiGet(
85
+ baseUrl,
86
+ `/v1/workspaces/${encodeURIComponent(opts.workspace)}/processes/${encodeURIComponent(processId)}/tickets${suffix}`,
87
+ );
88
+ if (ctx.json || opts.json) {
89
+ console.log(JSON.stringify(data, null, 2));
90
+ return;
91
+ }
92
+ const tickets = Array.isArray(data?.tickets) ? data.tickets : [];
93
+ console.log(`Tracker: ${data?.tracker || opts.tracker || "(auto)"}`);
94
+ if (!tickets.length) {
95
+ console.log("No tickets matched.");
96
+ return;
97
+ }
98
+ for (const ticket of tickets) {
99
+ const id = ticket.key || ticket.display_id || ticket.id || "(no id)";
100
+ const title = ticket.title || "(untitled)";
101
+ const status = ticket.status ? ` [${ticket.status}]` : "";
102
+ const url = ticket.url ? `\n ${ticket.url}` : "";
103
+ console.log(`- ${id}${status}: ${title}${url}`);
104
+ }
105
+ }
106
+
107
+ async function processPromptCommand(ctx, args) {
108
+ const opts = parsePromptArgs(args);
109
+ const cwd = opts.cwd || process.cwd();
110
+ let config;
111
+ try {
112
+ config = readConfig(cwd).config;
113
+ } catch (err) {
114
+ die(err instanceof Error ? err.message : String(err));
115
+ }
116
+ if (config.version !== CONFIG_SCHEMA_VERSION) {
117
+ die(
118
+ `.ship/config.yml is at v${config.version}; shipctl process prompt requires v${CONFIG_SCHEMA_VERSION}.\nRun 'shipctl migrate' to upgrade.`,
119
+ );
120
+ }
121
+ const validation = validateConfig(config);
122
+ if (!validation.ok) {
123
+ die(["config is invalid:", ...validation.errors.map((e) => ` - ${e}`)].join("\n"));
124
+ }
125
+ const processConfig = config.process;
126
+ if (!processConfig || typeof processConfig !== "object") {
127
+ die("process: missing from .ship/config.yml");
128
+ }
129
+
130
+ const state = selectState(processConfig, opts);
131
+ const allowedTransitions = Array.isArray(processConfig.transitions)
132
+ ? processConfig.transitions.filter((transition) => transition.from === state.id)
133
+ : [];
134
+ const bundle = buildSpecialistPromptBundle({
135
+ process: processConfig,
136
+ state,
137
+ allowedTransitions,
138
+ ticket: resolveTicket(opts),
139
+ policies: readOptionalFile(opts.policiesFile, "policies"),
140
+ });
141
+
142
+ if (ctx.json || opts.json) {
143
+ console.log(JSON.stringify(bundle, null, 2));
144
+ return;
145
+ }
146
+ process.stdout.write(renderSpecialistPromptBundleMarkdown(bundle));
147
+ }
148
+
149
+ function parsePromptArgs(args) {
150
+ const out = {
151
+ state: null,
152
+ specialist: null,
153
+ cwd: null,
154
+ json: false,
155
+ ticketJson: null,
156
+ ticketFile: null,
157
+ ticket: {},
158
+ policiesFile: null,
159
+ };
160
+ const copy = [...args];
161
+ const readValue = (flag) => {
162
+ const current = copy.shift();
163
+ if (current === flag) {
164
+ if (copy.length === 0) die(`${flag} requires a value`);
165
+ return String(copy.shift());
166
+ }
167
+ const prefix = `${flag}=`;
168
+ if (current && current.startsWith(prefix)) {
169
+ return current.slice(prefix.length);
170
+ }
171
+ copy.unshift(current);
172
+ return null;
173
+ };
174
+
175
+ while (copy.length) {
176
+ const arg = copy[0];
177
+ if (arg === "--help" || arg === "-h") {
178
+ printProcessHelp();
179
+ process.exit(0);
180
+ }
181
+ if (arg === "--json") {
182
+ copy.shift();
183
+ out.json = true;
184
+ continue;
185
+ }
186
+ const state = readValue("--state");
187
+ if (state !== null) {
188
+ out.state = state;
189
+ continue;
190
+ }
191
+ const specialist = readValue("--specialist");
192
+ if (specialist !== null) {
193
+ out.specialist = specialist;
194
+ continue;
195
+ }
196
+ const cwd = readValue("--cwd");
197
+ if (cwd !== null) {
198
+ out.cwd = path.resolve(cwd);
199
+ continue;
200
+ }
201
+ const ticketJson = readValue("--ticket-json");
202
+ if (ticketJson !== null) {
203
+ out.ticketJson = ticketJson;
204
+ continue;
205
+ }
206
+ const ticketFile = readValue("--ticket-file");
207
+ if (ticketFile !== null) {
208
+ out.ticketFile = path.resolve(ticketFile);
209
+ continue;
210
+ }
211
+ const policiesFile = readValue("--policies-file");
212
+ if (policiesFile !== null) {
213
+ out.policiesFile = path.resolve(policiesFile);
214
+ continue;
215
+ }
216
+ for (const [flag, key] of [
217
+ ["--ticket-id", "id"],
218
+ ["--ticket-key", "key"],
219
+ ["--ticket-title", "title"],
220
+ ["--ticket-url", "url"],
221
+ ["--ticket-status", "status"],
222
+ ["--ticket-description", "description"],
223
+ ]) {
224
+ const value = readValue(flag);
225
+ if (value !== null) {
226
+ out.ticket[key] = value;
227
+ break;
228
+ }
229
+ }
230
+ if (copy[0] === arg) {
231
+ die(`unknown argument: ${arg}\nRun: shipctl process prompt --help`);
232
+ }
233
+ }
234
+ if (!out.state && !out.specialist) {
235
+ die("either --state <state-id> or --specialist <specialist-id> is required");
236
+ }
237
+ if (out.state && out.specialist) {
238
+ die("use either --state or --specialist, not both");
239
+ }
240
+ return out;
241
+ }
242
+
243
+ function parseTicketsArgs(args) {
244
+ const out = {
245
+ workspace: null,
246
+ processId: "development",
247
+ tracker: null,
248
+ projectHint: null,
249
+ query: null,
250
+ state: "open",
251
+ limit: 10,
252
+ assigneeMe: false,
253
+ assignee: null,
254
+ baseUrl: null,
255
+ json: false,
256
+ };
257
+ const copy = [...args];
258
+ const readValue = (flag) => {
259
+ const current = copy.shift();
260
+ if (current === flag) {
261
+ if (copy.length === 0) die(`${flag} requires a value`);
262
+ return String(copy.shift());
263
+ }
264
+ const prefix = `${flag}=`;
265
+ if (current && current.startsWith(prefix)) {
266
+ return current.slice(prefix.length);
267
+ }
268
+ copy.unshift(current);
269
+ return null;
270
+ };
271
+ while (copy.length) {
272
+ const arg = copy[0];
273
+ if (arg === "--help" || arg === "-h") {
274
+ printProcessHelp();
275
+ process.exit(0);
276
+ }
277
+ if (arg === "--json") {
278
+ copy.shift();
279
+ out.json = true;
280
+ continue;
281
+ }
282
+ if (arg === "--assignee-me") {
283
+ copy.shift();
284
+ out.assigneeMe = true;
285
+ continue;
286
+ }
287
+ for (const [flag, key] of [
288
+ ["--workspace", "workspace"],
289
+ ["--process", "processId"],
290
+ ["--tracker", "tracker"],
291
+ ["--project-hint", "projectHint"],
292
+ ["--query", "query"],
293
+ ["--state", "state"],
294
+ ["--limit", "limit"],
295
+ ["--assignee", "assignee"],
296
+ ["--base-url", "baseUrl"],
297
+ ]) {
298
+ const value = readValue(flag);
299
+ if (value !== null) {
300
+ out[key] = key === "limit" ? Number(value) : value;
301
+ break;
302
+ }
303
+ }
304
+ if (copy[0] === arg) {
305
+ die(`unknown argument: ${arg}\nRun: shipctl process tickets --help`);
306
+ }
307
+ }
308
+ if (!out.workspace) die("--workspace <id> is required");
309
+ if (!["open", "closed", "all"].includes(out.state)) {
310
+ die("--state must be one of open|closed|all");
311
+ }
312
+ if (!Number.isInteger(out.limit) || out.limit < 1 || out.limit > 25) {
313
+ die("--limit must be an integer between 1 and 25");
314
+ }
315
+ return out;
316
+ }
317
+
318
+ function selectState(processConfig, opts) {
319
+ const states = Array.isArray(processConfig.states) ? processConfig.states : [];
320
+ if (opts.state) {
321
+ const state = states.find((item) => item.id === opts.state);
322
+ if (!state) die(`unknown process state ${JSON.stringify(opts.state)}`);
323
+ return state;
324
+ }
325
+ const state = states.find((item) => {
326
+ const specialist = item.specialist;
327
+ if (typeof specialist === "string") return specialist === opts.specialist;
328
+ if (specialist && typeof specialist === "object") {
329
+ return specialist.id === opts.specialist || specialist.name === opts.specialist;
330
+ }
331
+ return false;
332
+ });
333
+ if (!state) die(`unknown process specialist ${JSON.stringify(opts.specialist)}`);
334
+ return state;
335
+ }
336
+
337
+ function resolveTicket(opts) {
338
+ const parts = [];
339
+ if (opts.ticketFile) {
340
+ const body = readOptionalFile(opts.ticketFile, "ticket");
341
+ if (body) {
342
+ parts.push(parseTicketJson(body, `ticket file ${opts.ticketFile}`));
343
+ }
344
+ }
345
+ if (opts.ticketJson) {
346
+ parts.push(parseTicketJson(opts.ticketJson, "--ticket-json"));
347
+ }
348
+ if (Object.keys(opts.ticket).length) {
349
+ parts.push(opts.ticket);
350
+ }
351
+ if (!parts.length) return null;
352
+ return Object.assign({}, ...parts);
353
+ }
354
+
355
+ function parseTicketJson(value, label) {
356
+ try {
357
+ const parsed = JSON.parse(value);
358
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
359
+ die(`${label}: must be a JSON object`);
360
+ }
361
+ return parsed;
362
+ } catch (err) {
363
+ die(`${label}: failed to parse JSON (${err instanceof Error ? err.message : err})`);
364
+ }
365
+ }
366
+
367
+ function readOptionalFile(filePath, label) {
368
+ if (!filePath) return null;
369
+ try {
370
+ return fs.readFileSync(filePath, "utf8");
371
+ } catch (err) {
372
+ die(`failed to read ${label} file ${filePath}: ${err instanceof Error ? err.message : err}`);
373
+ }
374
+ }
375
+
376
+ function resolveWorkspaceBaseUrl(explicit) {
377
+ return (
378
+ explicit ||
379
+ process.env.SHIP_WORKSPACE_API_BASE ||
380
+ process.env.SHIP_API_BASE ||
381
+ "https://api.ship.elmundi.com"
382
+ );
383
+ }
384
+
385
+ function die(message) {
386
+ console.error(message);
387
+ process.exit(1);
388
+ }