@elmundi/ship-cli 0.15.4 → 0.16.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.
package/bin/shipctl.mjs CHANGED
@@ -81,6 +81,18 @@ try {
81
81
  process.exit(0);
82
82
  }
83
83
 
84
+ if (cmd === "inbox") {
85
+ const { inboxCommand } = await import("../lib/commands/inbox.mjs");
86
+ await inboxCommand(ctx, rest);
87
+ process.exit(0);
88
+ }
89
+
90
+ if (cmd === "project") {
91
+ const { projectCommand } = await import("../lib/commands/project.mjs");
92
+ await projectCommand(ctx, rest);
93
+ process.exit(0);
94
+ }
95
+
84
96
  if (cmd === "run") {
85
97
  const { runCommand } = await import("../lib/commands/run.mjs");
86
98
  await runCommand(ctx, rest);
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shared client helpers for ``shipctl`` commands that hit the
3
+ * agent-runs API (inbox / project / etc).
4
+ *
5
+ * Reuses the same auth model the existing ``shipctl knowledge`` flow
6
+ * established: bearer ``SHIP_API_TOKEN`` PAT, base URL resolved via
7
+ * the same precedence chain (flag → ``SHIP_WORKSPACE_API_BASE`` →
8
+ * ``SHIP_API_BASE`` → prod default), workspace id resolved via flag /
9
+ * ``SHIP_WORKSPACE_ID`` / sole-workspace lookup.
10
+ *
11
+ * Exit codes are uniform across commands so wrapper scripts can
12
+ * branch on them: 0 ok, 1 arg/config error, 2 auth error (401),
13
+ * 3 network/5xx.
14
+ */
15
+
16
+ const PROD_BASE = "https://api.ship.elmundi.com";
17
+
18
+
19
+ /**
20
+ * @param {string|null|undefined} explicit
21
+ * @returns {string}
22
+ */
23
+ export function resolveBaseUrl(explicit) {
24
+ if (explicit) return explicit.replace(/\/+$/, "");
25
+ const envWorkspace = process.env.SHIP_WORKSPACE_API_BASE;
26
+ if (envWorkspace) return envWorkspace.replace(/\/+$/, "");
27
+ const envGeneric = process.env.SHIP_API_BASE;
28
+ if (envGeneric) return envGeneric.replace(/\/+$/, "");
29
+ return PROD_BASE;
30
+ }
31
+
32
+
33
+ export function requireToken() {
34
+ const token = process.env.SHIP_API_TOKEN || "";
35
+ if (!token) {
36
+ console.error(
37
+ "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
38
+ );
39
+ process.exit(1);
40
+ }
41
+ return token;
42
+ }
43
+
44
+
45
+ /**
46
+ * @param {string} baseUrl
47
+ * @param {string} token
48
+ * @returns {Promise<string>}
49
+ */
50
+ export async function resolveSoleWorkspace(baseUrl, token) {
51
+ const rows = await apiRequest(baseUrl, "/v1/workspaces", "GET", token, null);
52
+ if (!Array.isArray(rows) || rows.length === 0) {
53
+ console.error("No workspaces visible to this token.");
54
+ process.exit(1);
55
+ }
56
+ if (rows.length > 1) {
57
+ const ids = rows.map((r) => `${r.id} (${r.name ?? "?"})`).join("\n ");
58
+ console.error(
59
+ `Token has access to more than one workspace; pass --workspace <id>.\n ${ids}`,
60
+ );
61
+ process.exit(1);
62
+ }
63
+ return String(rows[0].id);
64
+ }
65
+
66
+
67
+ /**
68
+ * @param {{ workspace?: string|null, baseUrl?: string|null }} opts
69
+ * @param {{ baseUrl?: string|null, baseUrlSource?: string|null }} ctx
70
+ */
71
+ export async function resolveContext(opts, ctx) {
72
+ const baseUrl = resolveBaseUrl(
73
+ opts.baseUrl || (ctx?.baseUrlSource === "flag" ? ctx?.baseUrl : null),
74
+ );
75
+ const token = requireToken();
76
+ let workspaceId =
77
+ opts.workspace || (process.env.SHIP_WORKSPACE_ID || "").trim() || "";
78
+ if (!workspaceId) {
79
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
80
+ }
81
+ return { baseUrl, token, workspaceId };
82
+ }
83
+
84
+
85
+ /**
86
+ * @param {string} baseUrl
87
+ * @param {string} path
88
+ * @param {"GET"|"POST"|"DELETE"} method
89
+ * @param {string} token
90
+ * @param {Record<string, unknown>|null} body
91
+ */
92
+ export async function apiRequest(baseUrl, path, method, token, body) {
93
+ const url = `${baseUrl}${path}`;
94
+ let res;
95
+ try {
96
+ res = await fetch(url, {
97
+ method,
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ Accept: "application/json",
101
+ Authorization: `Bearer ${token}`,
102
+ },
103
+ body: body === null ? undefined : JSON.stringify(body),
104
+ });
105
+ } catch (err) {
106
+ console.error(
107
+ `Network error calling ${url}: ${err instanceof Error ? err.message : err}`,
108
+ );
109
+ process.exit(3);
110
+ }
111
+ const text = await res.text();
112
+ let data = null;
113
+ try {
114
+ data = text ? JSON.parse(text) : null;
115
+ } catch {
116
+ data = text;
117
+ }
118
+ if (res.ok) return data;
119
+ if (res.status === 401) {
120
+ console.error(
121
+ `HTTP 401 on ${method} ${url} — SHIP_API_TOKEN is missing, expired, or lacks workspace access.`,
122
+ );
123
+ process.exit(2);
124
+ }
125
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
126
+ console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
127
+ process.exit(res.status >= 500 ? 3 : 1);
128
+ }
129
+
130
+
131
+ /**
132
+ * Read a value from a flag (`--name`/`--name=value`). Returns true if
133
+ * consumed; mutates ``copy`` in place.
134
+ *
135
+ * @param {string[]} copy
136
+ * @param {string} flag
137
+ * @param {Record<string, string|null>} out
138
+ * @param {string} key
139
+ */
140
+ export function consumeStringFlag(copy, flag, out, key) {
141
+ if (copy[0] === flag && copy[1] !== undefined) {
142
+ copy.shift();
143
+ out[key] = String(copy.shift());
144
+ return true;
145
+ }
146
+ const p = `${flag}=`;
147
+ if (copy[0] && copy[0].startsWith(p)) {
148
+ out[key] = copy[0].slice(p.length);
149
+ copy.shift();
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+
156
+ /**
157
+ * Read a body / file argument. ``--body <inline>`` takes the next arg
158
+ * as the literal body; ``--body-file <path>`` reads the file content.
159
+ * Mutually exclusive — the second wins if both are given but we warn.
160
+ *
161
+ * @param {string[]} copy
162
+ * @param {Record<string, string|null>} out
163
+ */
164
+ export function consumeBodyFlags(copy, out) {
165
+ if (consumeStringFlag(copy, "--body", out, "body")) return true;
166
+ if (consumeStringFlag(copy, "--body-file", out, "bodyFile")) return true;
167
+ return false;
168
+ }
169
+
170
+
171
+ /**
172
+ * Resolve ``--body`` vs ``--body-file`` into a single string. Reads
173
+ * stdin when ``--body-file=-`` so callers can pipe markdown.
174
+ *
175
+ * @param {{ body?: string|null, bodyFile?: string|null }} out
176
+ */
177
+ export async function readBodyFromOpts(out) {
178
+ if (out.body && out.bodyFile) {
179
+ console.error("Pass --body OR --body-file, not both.");
180
+ process.exit(1);
181
+ }
182
+ if (out.bodyFile) {
183
+ const fs = await import("node:fs/promises");
184
+ if (out.bodyFile === "-") {
185
+ const chunks = [];
186
+ for await (const chunk of process.stdin) chunks.push(chunk);
187
+ return Buffer.concat(chunks).toString("utf8");
188
+ }
189
+ return await fs.readFile(out.bodyFile, "utf8");
190
+ }
191
+ return out.body || "";
192
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `shipctl inbox` — write into the operator's mailbox from a routine.
3
+ *
4
+ * Routines like daily-retro / learning-capture / process-reviewer file
5
+ * a single ``type=report`` letter per run; the operator opens it in the
6
+ * mailbox preview and hits Acknowledge. SDLC pipelines occasionally
7
+ * file ``approval`` / ``improvement`` items.
8
+ *
9
+ * Usage:
10
+ *
11
+ * shipctl inbox create
12
+ * --type <report|improvement|approval|exception>
13
+ * --title "Daily retro 2026-05-07"
14
+ * --body "<markdown>" | --body-file path/to/digest.md | --body-file -
15
+ * [--summary "<short list-row blurb>"]
16
+ * [--workspace <id>]
17
+ * [--ticket-ref <id>]
18
+ * [--json]
19
+ *
20
+ * Auth: bearer ``SHIP_API_TOKEN`` (mint at /settings).
21
+ *
22
+ * Exit codes:
23
+ * 0 inbox item created
24
+ * 1 arg / config / 4xx error
25
+ * 2 auth error (401)
26
+ * 3 network / 5xx error
27
+ */
28
+
29
+ import {
30
+ apiRequest,
31
+ consumeBodyFlags,
32
+ consumeStringFlag,
33
+ readBodyFromOpts,
34
+ resolveContext,
35
+ } from "../agent_api.mjs";
36
+
37
+
38
+ const VALID_TYPES = new Set([
39
+ "report",
40
+ "improvement",
41
+ "approval",
42
+ "exception",
43
+ ]);
44
+
45
+
46
+ export async function inboxCommand(ctx, rest) {
47
+ const [sub, ...args] = rest;
48
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
49
+ printInboxHelp();
50
+ return;
51
+ }
52
+ if (sub === "create") {
53
+ await inboxCreateCommand(ctx, args);
54
+ return;
55
+ }
56
+ console.error(
57
+ `Unknown 'shipctl inbox' subcommand: ${sub}\nRun: shipctl inbox --help`,
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+
63
+ function printInboxHelp() {
64
+ console.log(`shipctl inbox — file letters in the operator's mailbox
65
+
66
+ SUBCOMMANDS
67
+ shipctl inbox create --type <kind> --title <s> --body <md>
68
+ [--summary <s>] [--ticket-ref <id>]
69
+ [--body-file <path|->] [--workspace <id>] [--json]
70
+
71
+ TYPES
72
+ report Read-only digest (daily retro, learning capture,
73
+ process review). Operator hits Acknowledge.
74
+ improvement Actionable suggestion. Operator accepts / dismisses.
75
+ approval Gated decision. Operator approves / rejects / dismisses.
76
+ exception Policy edge case. Operator acknowledges / dismisses.
77
+
78
+ ENV
79
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
80
+ SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
81
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
82
+
83
+ EXIT
84
+ 0 letter filed
85
+ 1 arg / config / 4xx
86
+ 2 auth (401)
87
+ 3 network / 5xx
88
+ `);
89
+ }
90
+
91
+
92
+ async function inboxCreateCommand(ctx, args) {
93
+ const opts = parseCreateArgs(args);
94
+ const body = (await readBodyFromOpts(opts)).trim();
95
+ if (!body) {
96
+ console.error("Pass --body <markdown> or --body-file <path|->");
97
+ process.exit(1);
98
+ }
99
+ const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
100
+
101
+ const result = await apiRequest(
102
+ baseUrl,
103
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/inbox/items`,
104
+ "POST",
105
+ token,
106
+ {
107
+ type: opts.type,
108
+ title: opts.title,
109
+ body,
110
+ summary: opts.summary || null,
111
+ ticket_ref: opts.ticketRef || null,
112
+ },
113
+ );
114
+
115
+ if (ctx?.json || opts.json) {
116
+ console.log(JSON.stringify(result, null, 2));
117
+ return;
118
+ }
119
+ console.log(`Letter filed (${opts.type}): ${opts.title}`);
120
+ }
121
+
122
+
123
+ function parseCreateArgs(args) {
124
+ const out = {
125
+ type: null,
126
+ title: null,
127
+ body: null,
128
+ bodyFile: null,
129
+ summary: null,
130
+ ticketRef: null,
131
+ workspace: null,
132
+ baseUrl: null,
133
+ json: false,
134
+ };
135
+ const copy = [...args];
136
+ while (copy.length) {
137
+ if (
138
+ consumeStringFlag(copy, "--type", out, "type") ||
139
+ consumeStringFlag(copy, "--title", out, "title") ||
140
+ consumeStringFlag(copy, "--summary", out, "summary") ||
141
+ consumeStringFlag(copy, "--ticket-ref", out, "ticketRef") ||
142
+ consumeStringFlag(copy, "--workspace", out, "workspace") ||
143
+ consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
144
+ consumeBodyFlags(copy, out)
145
+ ) {
146
+ continue;
147
+ }
148
+ if (copy[0] === "--json") {
149
+ out.json = true;
150
+ copy.shift();
151
+ continue;
152
+ }
153
+ if (copy[0] === "--help" || copy[0] === "-h") {
154
+ printInboxHelp();
155
+ process.exit(0);
156
+ }
157
+ console.error(`Unknown flag: ${copy[0]}`);
158
+ process.exit(1);
159
+ }
160
+ if (!out.type || !VALID_TYPES.has(out.type)) {
161
+ console.error(
162
+ `--type must be one of ${[...VALID_TYPES].join(" | ")}`,
163
+ );
164
+ process.exit(1);
165
+ }
166
+ if (!out.title || !out.title.trim()) {
167
+ console.error("--title is required");
168
+ process.exit(1);
169
+ }
170
+ return out;
171
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `shipctl project` — manage tracker projects from a routine.
3
+ *
4
+ * Today the only subcommand is ``find-or-create``: the reviewer
5
+ * routines (tech-reviewer, qa-reviewer, security-officer) call this
6
+ * once per run to resolve their dedicated holding-pen project ("Tech
7
+ * Debt", "QA Debt", "Security") on the workspace's bound tracker.
8
+ * First run creates; every subsequent run short-circuits on the
9
+ * case-insensitive name match — no list-then-create race.
10
+ *
11
+ * Usage:
12
+ *
13
+ * shipctl project find-or-create
14
+ * --name "Tech Debt"
15
+ * --body "<markdown body for the new project — only used on create>"
16
+ * [--description "<one-liner; ≤240 chars; only used on create>"]
17
+ * [--workspace <id>]
18
+ * [--json]
19
+ *
20
+ * Output (default): ``project_id\tname\tcreated`` on a single line so
21
+ * shell scripts can parse it. ``--json`` returns the full server
22
+ * response.
23
+ *
24
+ * Auth: bearer ``SHIP_API_TOKEN``.
25
+ *
26
+ * Exit codes:
27
+ * 0 project resolved (found or created)
28
+ * 1 arg / config / 4xx error
29
+ * 2 auth error (401)
30
+ * 3 network / 5xx error
31
+ */
32
+
33
+ import {
34
+ apiRequest,
35
+ consumeBodyFlags,
36
+ consumeStringFlag,
37
+ readBodyFromOpts,
38
+ resolveContext,
39
+ } from "../agent_api.mjs";
40
+
41
+
42
+ export async function projectCommand(ctx, rest) {
43
+ const [sub, ...args] = rest;
44
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
45
+ printProjectHelp();
46
+ return;
47
+ }
48
+ if (sub === "find-or-create") {
49
+ await projectFindOrCreateCommand(ctx, args);
50
+ return;
51
+ }
52
+ console.error(
53
+ `Unknown 'shipctl project' subcommand: ${sub}\nRun: shipctl project --help`,
54
+ );
55
+ process.exit(1);
56
+ }
57
+
58
+
59
+ function printProjectHelp() {
60
+ console.log(`shipctl project — manage tracker projects
61
+
62
+ SUBCOMMANDS
63
+ shipctl project find-or-create --name <s> --body <md>
64
+ [--description <s>] [--body-file <path|->]
65
+ [--workspace <id>] [--json]
66
+
67
+ ENV
68
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
69
+ SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
70
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
71
+
72
+ EXIT
73
+ 0 project resolved
74
+ 1 arg / config / 4xx
75
+ 2 auth (401)
76
+ 3 network / 5xx
77
+ `);
78
+ }
79
+
80
+
81
+ async function projectFindOrCreateCommand(ctx, args) {
82
+ const opts = parseFindOrCreateArgs(args);
83
+ const body = (await readBodyFromOpts(opts)).trim();
84
+ if (!body) {
85
+ console.error("Pass --body <markdown> or --body-file <path|->");
86
+ process.exit(1);
87
+ }
88
+ const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
89
+
90
+ const result = await apiRequest(
91
+ baseUrl,
92
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/projects/find-or-create`,
93
+ "POST",
94
+ token,
95
+ {
96
+ name: opts.name,
97
+ body,
98
+ description: opts.description || null,
99
+ },
100
+ );
101
+
102
+ if (ctx?.json || opts.json) {
103
+ console.log(JSON.stringify(result, null, 2));
104
+ return;
105
+ }
106
+ const project = result?.project ?? {};
107
+ const created = result?.created ? "created" : "existing";
108
+ console.log(
109
+ `${project.id ?? ""}\t${project.name ?? opts.name}\t${created}`,
110
+ );
111
+ }
112
+
113
+
114
+ function parseFindOrCreateArgs(args) {
115
+ const out = {
116
+ name: null,
117
+ body: null,
118
+ bodyFile: null,
119
+ description: null,
120
+ workspace: null,
121
+ baseUrl: null,
122
+ json: false,
123
+ };
124
+ const copy = [...args];
125
+ while (copy.length) {
126
+ if (
127
+ consumeStringFlag(copy, "--name", out, "name") ||
128
+ consumeStringFlag(copy, "--description", out, "description") ||
129
+ consumeStringFlag(copy, "--workspace", out, "workspace") ||
130
+ consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
131
+ consumeBodyFlags(copy, out)
132
+ ) {
133
+ continue;
134
+ }
135
+ if (copy[0] === "--json") {
136
+ out.json = true;
137
+ copy.shift();
138
+ continue;
139
+ }
140
+ if (copy[0] === "--help" || copy[0] === "-h") {
141
+ printProjectHelp();
142
+ process.exit(0);
143
+ }
144
+ console.error(`Unknown flag: ${copy[0]}`);
145
+ process.exit(1);
146
+ }
147
+ if (!out.name || !out.name.trim()) {
148
+ console.error("--name is required");
149
+ process.exit(1);
150
+ }
151
+ return out;
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elmundi/ship-cli",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Ship CLI: bootstrap a repo, sync the catalog, run process routines, report outcomes.",
6
6
  "license": "Apache-2.0",