@elmundi/ship-cli 0.14.2 → 0.15.4
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 +17 -16
- package/bin/shipctl.mjs +4 -80
- package/lib/commands/feedback.mjs +1 -1
- package/lib/commands/help.mjs +47 -131
- package/lib/commands/init.mjs +17 -250
- package/lib/commands/knowledge.mjs +25 -328
- package/lib/commands/preflight.mjs +213 -0
- package/lib/commands/run.mjs +298 -119
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +73 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +39 -0
- package/lib/templates.mjs +2 -2
- package/lib/verify/checks/agents-on-disk.mjs +5 -28
- package/lib/verify/registry.mjs +7 -8
- package/package.json +1 -1
- package/lib/artifacts/fs-index.mjs +0 -230
- package/lib/cache/store.mjs +0 -422
- package/lib/commands/bootstrap.mjs +0 -4
- package/lib/commands/callback.mjs +0 -742
- package/lib/commands/docs.mjs +0 -90
- package/lib/commands/kickoff.mjs +0 -192
- package/lib/commands/lanes.mjs +0 -566
- package/lib/commands/manifest-catalog.mjs +0 -251
- package/lib/commands/migrate.mjs +0 -204
- package/lib/commands/new.mjs +0 -452
- package/lib/commands/patterns.mjs +0 -160
- package/lib/commands/process.mjs +0 -388
- package/lib/commands/search.mjs +0 -43
- package/lib/commands/sync.mjs +0 -824
- package/lib/config/migrate.mjs +0 -223
- package/lib/find-ship-root.mjs +0 -75
- package/lib/process/specialist-prompt-contract.mjs +0 -171
- package/lib/state/lockfile.mjs +0 -180
- package/lib/vendor/run-agent.workflow.yml +0 -254
- package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
- package/lib/verify/checks/cache-integrity.mjs +0 -51
- package/lib/verify/checks/gitignore-cache.mjs +0 -51
- package/lib/verify/checks/rules-markers.mjs +0 -135
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `shipctl knowledge` —
|
|
2
|
+
* `shipctl knowledge` — read-only access to workspace knowledge buckets.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* starter PRs, while ``bootstrap`` is the GitHub Actions entry point
|
|
9
|
-
* that opens the generated knowledge PR after wizard seed merge.
|
|
4
|
+
* The agent calls this during a routine run to pull bucket articles
|
|
5
|
+
* into context (the same surface the Navigator chat reads). Bucket
|
|
6
|
+
* authoring, ingestion, and intel harvest live server-side now;
|
|
7
|
+
* starter docs are written by the wizard at workspace seed time.
|
|
10
8
|
*
|
|
11
9
|
* Usage:
|
|
12
10
|
*
|
|
13
|
-
* shipctl knowledge fetch
|
|
14
|
-
* shipctl knowledge bootstrap --workspace <id> --repo <id|owner/name>
|
|
15
|
-
* shipctl knowledge refresh-intel --workspace <id> --repo <id|owner/name>
|
|
11
|
+
* shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]
|
|
16
12
|
*
|
|
17
13
|
* Auth: bearer token from ``SHIP_API_TOKEN`` (the same env var the
|
|
18
14
|
* console docs describe for CLI sessions minted under Settings →
|
|
@@ -28,28 +24,15 @@
|
|
|
28
24
|
* 4. ``https://api.ship.elmundi.com`` as the canonical production
|
|
29
25
|
* workspace API.
|
|
30
26
|
*
|
|
31
|
-
* Workspace
|
|
27
|
+
* Workspace resolution:
|
|
32
28
|
*
|
|
33
|
-
* - ``--workspace`` pins a workspace id;
|
|
29
|
+
* - ``--workspace`` pins a workspace id; ``SHIP_WORKSPACE_ID`` env
|
|
30
|
+
* var serves as a fallback. Without either we fetch
|
|
34
31
|
* ``GET /v1/workspaces`` and pick the only row. If there are
|
|
35
|
-
* multiple rows we abort with a helpful message
|
|
36
|
-
* either supplies ``--workspace`` or narrows their PAT.
|
|
37
|
-
* - ``--repo`` pins a repo id (uuid) or a full_name like
|
|
38
|
-
* ``owner/name``; otherwise we fetch
|
|
39
|
-
* ``GET /v1/workspaces/{ws}/repos`` and pick the most-recently
|
|
40
|
-
* activated row — the same heuristic the wizard uses, so
|
|
41
|
-
* ``shipctl knowledge init`` on a freshly-onboarded workspace
|
|
42
|
-
* seeds the repo the user just activated.
|
|
32
|
+
* multiple rows we abort with a helpful message.
|
|
43
33
|
*/
|
|
44
34
|
|
|
45
|
-
const VERSION = "
|
|
46
|
-
|
|
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/";
|
|
35
|
+
const VERSION = "v2";
|
|
53
36
|
|
|
54
37
|
/**
|
|
55
38
|
* @param {{baseUrl?: string, json?: boolean}} ctx
|
|
@@ -61,22 +44,10 @@ export async function knowledgeCommand(ctx, rest) {
|
|
|
61
44
|
printKnowledgeHelp();
|
|
62
45
|
return;
|
|
63
46
|
}
|
|
64
|
-
if (sub === "init") {
|
|
65
|
-
await knowledgeInitCommand(ctx, args);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
47
|
if (sub === "fetch") {
|
|
69
48
|
await knowledgeFetchCommand(ctx, args);
|
|
70
49
|
return;
|
|
71
50
|
}
|
|
72
|
-
if (sub === "bootstrap") {
|
|
73
|
-
await knowledgeBootstrapCommand(ctx, args);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
if (sub === "refresh-intel" || sub === "refresh-context") {
|
|
77
|
-
await knowledgeRefreshIntelCommand(ctx, args);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
51
|
console.error(
|
|
81
52
|
`Unknown 'shipctl knowledge' subcommand: ${sub}\nRun: shipctl knowledge --help`,
|
|
82
53
|
);
|
|
@@ -84,111 +55,31 @@ export async function knowledgeCommand(ctx, rest) {
|
|
|
84
55
|
}
|
|
85
56
|
|
|
86
57
|
function printKnowledgeHelp() {
|
|
87
|
-
console.log(`shipctl knowledge —
|
|
58
|
+
console.log(`shipctl knowledge — read workspace knowledge buckets (${VERSION})
|
|
88
59
|
|
|
89
60
|
SUBCOMMANDS
|
|
90
|
-
shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>]
|
|
91
|
-
[--only <csv>] [--json]
|
|
92
61
|
shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]
|
|
93
|
-
shipctl knowledge bootstrap [--workspace <id>] [--repo <id|owner/name>]
|
|
94
|
-
[--json]
|
|
95
|
-
shipctl knowledge refresh-intel [--workspace <id>] [--repo <id|owner/name>]
|
|
96
|
-
[--json]
|
|
97
|
-
|
|
98
|
-
INIT FLAGS
|
|
99
|
-
--workspace <id> Workspace UUID. Defaults to the only workspace
|
|
100
|
-
the caller's PAT can see.
|
|
101
|
-
--repo <ref> Workspace repo UUID, or GitHub 'owner/name'.
|
|
102
|
-
Defaults to the most-recently activated repo in
|
|
103
|
-
the resolved workspace.
|
|
104
|
-
--only <csv> Comma-separated starter slugs. Defaults to the
|
|
105
|
-
full backend catalog, including static starters
|
|
106
|
-
(${STATIC_KNOWLEDGE_SLUGS.join(", ")}) and generated
|
|
107
|
-
recipe starters under ${RECIPE_KNOWLEDGE_PREFIX}<pattern-id>.
|
|
108
|
-
--base-url URL Workspace control-plane API. See env fallbacks.
|
|
109
|
-
--json Emit a machine-readable JSON summary.
|
|
110
62
|
|
|
111
63
|
ENV
|
|
112
64
|
SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
|
|
65
|
+
SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
|
|
113
66
|
SHIP_WORKSPACE_API_BASE Optional override for the control plane.
|
|
114
67
|
SHIP_API_BASE Fallback only (co-located proxies).
|
|
115
68
|
|
|
116
69
|
EXIT
|
|
117
|
-
0
|
|
70
|
+
0 bucket fetched
|
|
118
71
|
1 arg / config error
|
|
119
72
|
2 auth error (401)
|
|
120
73
|
3 network / HTTP 5xx
|
|
121
74
|
`);
|
|
122
75
|
}
|
|
123
76
|
|
|
124
|
-
/**
|
|
125
|
-
* @param {{baseUrl?: string, json?: boolean}} ctx
|
|
126
|
-
* @param {string[]} args
|
|
127
|
-
*/
|
|
128
|
-
async function knowledgeInitCommand(ctx, args) {
|
|
129
|
-
const opts = parseInitArgs(args);
|
|
130
|
-
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
131
|
-
const token = process.env.SHIP_API_TOKEN || "";
|
|
132
|
-
if (!token) {
|
|
133
|
-
console.error(
|
|
134
|
-
"SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
|
|
135
|
-
);
|
|
136
|
-
process.exit(1);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const selection = opts.only;
|
|
140
|
-
if (selection !== null) {
|
|
141
|
-
const unknown = selection.filter((s) => !isKnownKnowledgeStarterSlug(s));
|
|
142
|
-
if (unknown.length) {
|
|
143
|
-
console.error(
|
|
144
|
-
`Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown static slugs: ${STATIC_KNOWLEDGE_SLUGS.join(", ")}; recipe slugs must start with ${RECIPE_KNOWLEDGE_PREFIX}`,
|
|
145
|
-
);
|
|
146
|
-
process.exit(1);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let workspaceId = opts.workspace;
|
|
151
|
-
if (!workspaceId) {
|
|
152
|
-
workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
|
|
156
|
-
|
|
157
|
-
const body = selection === null ? {} : { selection };
|
|
158
|
-
const result = await apiPostJson(
|
|
159
|
-
baseUrl,
|
|
160
|
-
`/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge_seed`,
|
|
161
|
-
body,
|
|
162
|
-
token,
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
if (ctx.json || opts.json) {
|
|
166
|
-
console.log(JSON.stringify(result, null, 2));
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
const files = Array.isArray(result.files) ? result.files : [];
|
|
170
|
-
console.log(
|
|
171
|
-
`Seeded compatibility knowledge files for workspace ${workspaceId} / repo ${repoId}:\n` +
|
|
172
|
-
` PR #${result.pr_number}: ${result.pr_url}\n` +
|
|
173
|
-
` Branch: ${result.branch}\n` +
|
|
174
|
-
` Files: ${files.join(", ") || "(none)"}\n` +
|
|
175
|
-
`\nShip-owned repository context is refreshed separately with:\n` +
|
|
176
|
-
` shipctl knowledge refresh-intel --workspace ${workspaceId} --repo ${repoId}`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function isKnownKnowledgeStarterSlug(slug) {
|
|
181
|
-
return (
|
|
182
|
-
STATIC_KNOWLEDGE_SLUGS.includes(slug) ||
|
|
183
|
-
slug.startsWith(RECIPE_KNOWLEDGE_PREFIX)
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
77
|
async function knowledgeFetchCommand(ctx, args) {
|
|
188
78
|
const opts = parseFetchArgs(args);
|
|
189
79
|
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
190
80
|
const token = requireToken();
|
|
191
|
-
let workspaceId =
|
|
81
|
+
let workspaceId =
|
|
82
|
+
opts.workspace || (process.env.SHIP_WORKSPACE_ID || "").trim() || "";
|
|
192
83
|
if (!workspaceId) {
|
|
193
84
|
workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
194
85
|
}
|
|
@@ -227,87 +118,14 @@ async function knowledgeFetchCommand(ctx, args) {
|
|
|
227
118
|
}
|
|
228
119
|
}
|
|
229
120
|
|
|
230
|
-
async function knowledgeRefreshIntelCommand(ctx, args) {
|
|
231
|
-
const opts = parseRefreshArgs(args);
|
|
232
|
-
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
233
|
-
const token = requireToken();
|
|
234
|
-
let workspaceId = opts.workspace;
|
|
235
|
-
if (!workspaceId) {
|
|
236
|
-
workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
237
|
-
}
|
|
238
|
-
const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
|
|
239
|
-
const result = await apiPostJson(
|
|
240
|
-
baseUrl,
|
|
241
|
-
`/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/intel/harvest`,
|
|
242
|
-
{},
|
|
243
|
-
token,
|
|
244
|
-
);
|
|
245
|
-
if (ctx.json || opts.json) {
|
|
246
|
-
console.log(JSON.stringify(result, null, 2));
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const where = result.enqueued
|
|
250
|
-
? `queued as job ${result.job_id || "(unknown)"}`
|
|
251
|
-
: `completed inline, intel_id=${result.intel_id || "(none)"}`;
|
|
252
|
-
console.log(
|
|
253
|
-
`Repository context refresh for workspace ${workspaceId} / repo ${repoId}: ${where}\n` +
|
|
254
|
-
`Fetch it with: shipctl knowledge fetch repository-context --workspace ${workspaceId}`,
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function knowledgeBootstrapCommand(ctx, args) {
|
|
259
|
-
const opts = parseBootstrapArgs(args);
|
|
260
|
-
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
261
|
-
const token = requireToken();
|
|
262
|
-
let workspaceId = opts.workspace;
|
|
263
|
-
if (!workspaceId) {
|
|
264
|
-
workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
265
|
-
}
|
|
266
|
-
const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
|
|
267
|
-
const result = await apiPostJson(
|
|
268
|
-
baseUrl,
|
|
269
|
-
`/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge/bootstrap`,
|
|
270
|
-
{},
|
|
271
|
-
token,
|
|
272
|
-
);
|
|
273
|
-
if (ctx.json || opts.json) {
|
|
274
|
-
console.log(JSON.stringify(result, null, 2));
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
const files = Array.isArray(result.files) ? result.files : [];
|
|
278
|
-
if (result.status === "already_done") {
|
|
279
|
-
console.log(
|
|
280
|
-
`Knowledge bootstrap already completed for workspace ${workspaceId} / repo ${repoId}:\n` +
|
|
281
|
-
` PR #${result.pr_number || "?"}: ${result.pr_url || "(unknown)"}`,
|
|
282
|
-
);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
console.log(
|
|
286
|
-
`Knowledge bootstrap opened PR #${result.pr_number} for workspace ${workspaceId} / repo ${repoId}:\n` +
|
|
287
|
-
` ${result.pr_url}\n` +
|
|
288
|
-
` Files: ${files.join(", ") || "(none)"}`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
121
|
function explicitGlobalBaseUrl(ctx) {
|
|
293
122
|
return ctx?.baseUrlSource === "flag" ? ctx.baseUrl : null;
|
|
294
123
|
}
|
|
295
124
|
|
|
296
|
-
|
|
297
|
-
* @param {string[]} args
|
|
298
|
-
* @returns {{
|
|
299
|
-
* workspace: string|null,
|
|
300
|
-
* repo: string|null,
|
|
301
|
-
* only: string[]|null,
|
|
302
|
-
* baseUrl: string|null,
|
|
303
|
-
* json: boolean,
|
|
304
|
-
* }}
|
|
305
|
-
*/
|
|
306
|
-
function parseInitArgs(args) {
|
|
125
|
+
function parseFetchArgs(args) {
|
|
307
126
|
const out = {
|
|
127
|
+
slug: null,
|
|
308
128
|
workspace: null,
|
|
309
|
-
repo: null,
|
|
310
|
-
only: null,
|
|
311
129
|
baseUrl: null,
|
|
312
130
|
json: false,
|
|
313
131
|
};
|
|
@@ -329,8 +147,6 @@ function parseInitArgs(args) {
|
|
|
329
147
|
while (copy.length) {
|
|
330
148
|
if (
|
|
331
149
|
consume("--workspace", "workspace") ||
|
|
332
|
-
consume("--repo", "repo") ||
|
|
333
|
-
consume("--only", "only") ||
|
|
334
150
|
consume("--base-url", "baseUrl")
|
|
335
151
|
) {
|
|
336
152
|
continue;
|
|
@@ -344,81 +160,19 @@ function parseInitArgs(args) {
|
|
|
344
160
|
printKnowledgeHelp();
|
|
345
161
|
process.exit(0);
|
|
346
162
|
}
|
|
347
|
-
|
|
348
|
-
process.exit(1);
|
|
349
|
-
}
|
|
350
|
-
if (out.only !== null) {
|
|
351
|
-
out.only = String(out.only)
|
|
352
|
-
.split(",")
|
|
353
|
-
.map((s) => s.trim())
|
|
354
|
-
.filter(Boolean);
|
|
355
|
-
}
|
|
356
|
-
return out;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function parseFetchArgs(args) {
|
|
360
|
-
const out = parseCommonArgs(args, { slug: null });
|
|
361
|
-
if (!out.slug) {
|
|
362
|
-
console.error("Usage: shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]");
|
|
363
|
-
process.exit(1);
|
|
364
|
-
}
|
|
365
|
-
return out;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function parseRefreshArgs(args) {
|
|
369
|
-
return parseCommonArgs(args, { repo: null });
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function parseBootstrapArgs(args) {
|
|
373
|
-
return parseCommonArgs(args, { repo: null });
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function parseCommonArgs(args, extra) {
|
|
377
|
-
const out = {
|
|
378
|
-
workspace: null,
|
|
379
|
-
baseUrl: null,
|
|
380
|
-
json: false,
|
|
381
|
-
...extra,
|
|
382
|
-
};
|
|
383
|
-
const copy = [...args];
|
|
384
|
-
const consume = (flag, key) => {
|
|
385
|
-
if (copy[0] === flag && copy[1] !== undefined) {
|
|
386
|
-
copy.shift();
|
|
387
|
-
out[key] = String(copy.shift());
|
|
388
|
-
return true;
|
|
389
|
-
}
|
|
390
|
-
const p = `${flag}=`;
|
|
391
|
-
if (copy[0] && copy[0].startsWith(p)) {
|
|
392
|
-
out[key] = copy[0].slice(p.length);
|
|
393
|
-
copy.shift();
|
|
394
|
-
return true;
|
|
395
|
-
}
|
|
396
|
-
return false;
|
|
397
|
-
};
|
|
398
|
-
while (copy.length) {
|
|
399
|
-
if (
|
|
400
|
-
consume("--workspace", "workspace") ||
|
|
401
|
-
consume("--repo", "repo") ||
|
|
402
|
-
consume("--base-url", "baseUrl")
|
|
403
|
-
) {
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (copy[0] === "--json") {
|
|
407
|
-
out.json = true;
|
|
408
|
-
copy.shift();
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (!String(copy[0]).startsWith("-") && "slug" in out && out.slug === null) {
|
|
163
|
+
if (!String(copy[0]).startsWith("-") && out.slug === null) {
|
|
412
164
|
out.slug = String(copy.shift());
|
|
413
165
|
continue;
|
|
414
166
|
}
|
|
415
|
-
if (copy[0] === "--help" || copy[0] === "-h") {
|
|
416
|
-
printKnowledgeHelp();
|
|
417
|
-
process.exit(0);
|
|
418
|
-
}
|
|
419
167
|
console.error(`Unknown flag: ${copy[0]}`);
|
|
420
168
|
process.exit(1);
|
|
421
169
|
}
|
|
170
|
+
if (!out.slug) {
|
|
171
|
+
console.error(
|
|
172
|
+
"Usage: shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]",
|
|
173
|
+
);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
422
176
|
return out;
|
|
423
177
|
}
|
|
424
178
|
|
|
@@ -467,53 +221,6 @@ async function resolveSoleWorkspace(baseUrl, token) {
|
|
|
467
221
|
return String(rows[0].id);
|
|
468
222
|
}
|
|
469
223
|
|
|
470
|
-
/**
|
|
471
|
-
* @param {string} baseUrl
|
|
472
|
-
* @param {string} token
|
|
473
|
-
* @param {string} workspaceId
|
|
474
|
-
* @param {string|null} hint
|
|
475
|
-
* @returns {Promise<string>}
|
|
476
|
-
*/
|
|
477
|
-
async function resolveRepoId(baseUrl, token, workspaceId, hint) {
|
|
478
|
-
// Direct UUID? Accept it verbatim — avoids a list call.
|
|
479
|
-
if (hint && /^[0-9a-fA-F-]{32,36}$/.test(hint) && hint.includes("-")) {
|
|
480
|
-
return hint;
|
|
481
|
-
}
|
|
482
|
-
const rows = await apiGetJson(
|
|
483
|
-
baseUrl,
|
|
484
|
-
`/v1/workspaces/${encodeURIComponent(workspaceId)}/repos`,
|
|
485
|
-
token,
|
|
486
|
-
);
|
|
487
|
-
if (!Array.isArray(rows) || rows.length === 0) {
|
|
488
|
-
console.error(
|
|
489
|
-
`Workspace ${workspaceId} has no activated repos. Activate one in the console first.`,
|
|
490
|
-
);
|
|
491
|
-
process.exit(1);
|
|
492
|
-
}
|
|
493
|
-
if (hint) {
|
|
494
|
-
const match = rows.find(
|
|
495
|
-
(r) =>
|
|
496
|
-
r.full_name === hint ||
|
|
497
|
-
`${r.owner ?? ""}/${r.name ?? ""}` === hint ||
|
|
498
|
-
r.id === hint,
|
|
499
|
-
);
|
|
500
|
-
if (!match) {
|
|
501
|
-
const known = rows.map((r) => r.full_name ?? r.id).join(", ");
|
|
502
|
-
console.error(
|
|
503
|
-
`--repo ${hint} doesn't match any activated repo in workspace ${workspaceId}.\nKnown: ${known}`,
|
|
504
|
-
);
|
|
505
|
-
process.exit(1);
|
|
506
|
-
}
|
|
507
|
-
return String(match.id);
|
|
508
|
-
}
|
|
509
|
-
const sorted = [...rows].sort((a, b) => {
|
|
510
|
-
const ax = a.activated_at ? Date.parse(a.activated_at) : 0;
|
|
511
|
-
const bx = b.activated_at ? Date.parse(b.activated_at) : 0;
|
|
512
|
-
return bx - ax;
|
|
513
|
-
});
|
|
514
|
-
return String(sorted[0].id);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
224
|
/**
|
|
518
225
|
* @param {string} baseUrl
|
|
519
226
|
* @param {string} path
|
|
@@ -523,16 +230,6 @@ async function apiGetJson(baseUrl, path, token) {
|
|
|
523
230
|
return apiRequest(baseUrl, path, "GET", token, null);
|
|
524
231
|
}
|
|
525
232
|
|
|
526
|
-
/**
|
|
527
|
-
* @param {string} baseUrl
|
|
528
|
-
* @param {string} path
|
|
529
|
-
* @param {Record<string, unknown>} body
|
|
530
|
-
* @param {string} token
|
|
531
|
-
*/
|
|
532
|
-
async function apiPostJson(baseUrl, path, body, token) {
|
|
533
|
-
return apiRequest(baseUrl, path, "POST", token, body);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
233
|
/**
|
|
537
234
|
* @param {string} baseUrl
|
|
538
235
|
* @param {string} path
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl preflight` — Phase 4 lifecycle gate.
|
|
3
|
+
*
|
|
4
|
+
* The trigger workflow runs this *before* `shipctl run` so a missing
|
|
5
|
+
* secret or an unauthorised role denial surfaces as a structured
|
|
6
|
+
* inbox-shaped result instead of a half-spawned agent that crashes
|
|
7
|
+
* mid-prompt. Two outputs depending on the result:
|
|
8
|
+
*
|
|
9
|
+
* ready=true → exit 0, JSON body shows the resolved deny list
|
|
10
|
+
* + the env contract that passed.
|
|
11
|
+
* ready=false → exit 0 but `ready: false` and a list of
|
|
12
|
+
* ``missing_secrets`` and/or ``denied_role`` reasons.
|
|
13
|
+
* The workflow's case statement uses this to skip
|
|
14
|
+
* the run cleanly without consuming a Cursor seat.
|
|
15
|
+
*
|
|
16
|
+
* Phase 4 MVP scope (ship narrow, expand on real need):
|
|
17
|
+
* - Secrets check: SHIP_API_TOKEN, SHIP_WORKSPACE_ID,
|
|
18
|
+
* SHIP_API_BASE (or SHIP_WORKSPACE_API_BASE), and CURSOR_API_KEY
|
|
19
|
+
* when provider is cursor (the default).
|
|
20
|
+
* - Role denial surfacing: read the resolved role's `denied_tools`
|
|
21
|
+
* so the workflow can decide whether the role is even runnable
|
|
22
|
+
* in this environment (e.g. a future check that flags "reviewer
|
|
23
|
+
* needs gh auth, not git push" rejections at the runner side).
|
|
24
|
+
* - No tool detection (gh, jq, gitleaks, etc) — those are workflow
|
|
25
|
+
* concerns, and the runner itself complains loudly. We do NOT
|
|
26
|
+
* want preflight to grow into a general 'verify' clone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
import { findShipRoot, readConfig } from "../config/io.mjs";
|
|
32
|
+
import { resolveProvider } from "../agents/index.mjs";
|
|
33
|
+
|
|
34
|
+
const EXIT_OK = 0;
|
|
35
|
+
const EXIT_USAGE = 2;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
export async function preflightCommand(ctx, rest) {
|
|
39
|
+
const args = parseArgs(rest);
|
|
40
|
+
if (args.help) {
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(EXIT_OK);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cwd = args.cwd || process.cwd();
|
|
46
|
+
const root = findShipRoot(cwd);
|
|
47
|
+
// Without a Ship root we can still preflight the env — a missing
|
|
48
|
+
// ``.ship/config.yml`` is itself a missing-tool reason.
|
|
49
|
+
const config = root ? readConfig(cwd).config : null;
|
|
50
|
+
|
|
51
|
+
const env = readEnv();
|
|
52
|
+
const missingSecrets = [];
|
|
53
|
+
if (!env.apiToken) missingSecrets.push("SHIP_API_TOKEN");
|
|
54
|
+
if (!env.workspaceId) missingSecrets.push("SHIP_WORKSPACE_ID");
|
|
55
|
+
if (!env.apiBase) missingSecrets.push("SHIP_API_BASE");
|
|
56
|
+
|
|
57
|
+
// Provider-specific secrets only when we know which provider the
|
|
58
|
+
// workflow will resolve to. ``args.routine`` / ``args.specialist``
|
|
59
|
+
// is optional — when absent we report the workspace-default
|
|
60
|
+
// provider (typically ``cursor``).
|
|
61
|
+
const provider = config
|
|
62
|
+
? resolveProvider(config, args.routine || args.specialist)
|
|
63
|
+
: "cursor";
|
|
64
|
+
if (provider === "cursor" && !env.cursorKey) {
|
|
65
|
+
missingSecrets.push("CURSOR_API_KEY");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Role-side denial surfacing. We don't *enforce* the deny list
|
|
69
|
+
// here — that's the runner's job once an agent runtime ships
|
|
70
|
+
// tool-execution metadata Ship can intercept. Preflight just
|
|
71
|
+
// reports the list so the workflow can pre-empt unsupported
|
|
72
|
+
// combinations (and so `verify` can audit drift between the
|
|
73
|
+
// Ship default and the workspace override).
|
|
74
|
+
let deniedTools = [];
|
|
75
|
+
if (env.apiToken && env.apiBase && env.workspaceId && (args.routine || args.specialist)) {
|
|
76
|
+
const slug = args.specialist || resolveSpecialistFromRoutine(config, args.routine);
|
|
77
|
+
if (slug) {
|
|
78
|
+
try {
|
|
79
|
+
const role = await fetchResolvedRole({
|
|
80
|
+
apiBase: env.apiBase,
|
|
81
|
+
apiToken: env.apiToken,
|
|
82
|
+
workspaceId: env.workspaceId,
|
|
83
|
+
slug,
|
|
84
|
+
});
|
|
85
|
+
deniedTools = Array.isArray(role?.denied_tools) ? role.denied_tools : [];
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Role-resolve failures degrade — preflight stays useful for
|
|
88
|
+
// the secret-check half even when the API is unreachable.
|
|
89
|
+
if (!ctx.json && !args.json) {
|
|
90
|
+
console.error(
|
|
91
|
+
`warn: agent-role resolve failed (${err instanceof Error ? err.message : err}); deny-list unverified.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ready = missingSecrets.length === 0;
|
|
99
|
+
const result = {
|
|
100
|
+
ready,
|
|
101
|
+
provider,
|
|
102
|
+
missing_secrets: missingSecrets,
|
|
103
|
+
denied_tools: deniedTools,
|
|
104
|
+
routine: args.routine || null,
|
|
105
|
+
specialist: args.specialist || null,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (ctx.json || args.json) {
|
|
109
|
+
console.log(JSON.stringify(result, null, 2));
|
|
110
|
+
process.exit(EXIT_OK);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!ready) {
|
|
114
|
+
console.error(`Ship preflight: NOT READY — missing ${missingSecrets.join(", ")}`);
|
|
115
|
+
} else {
|
|
116
|
+
console.log(`Ship preflight: ready (${provider}${deniedTools.length ? `, deny ${deniedTools.length}` : ""})`);
|
|
117
|
+
}
|
|
118
|
+
process.exit(EXIT_OK);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
function parseArgs(rest) {
|
|
123
|
+
const out = {
|
|
124
|
+
routine: null,
|
|
125
|
+
specialist: null,
|
|
126
|
+
cwd: null,
|
|
127
|
+
json: false,
|
|
128
|
+
help: false,
|
|
129
|
+
};
|
|
130
|
+
const copy = [...rest];
|
|
131
|
+
while (copy.length) {
|
|
132
|
+
const a = copy[0];
|
|
133
|
+
if (a === "--help" || a === "-h") { out.help = true; copy.shift(); continue; }
|
|
134
|
+
if (a === "--json") { out.json = true; copy.shift(); continue; }
|
|
135
|
+
if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
|
|
136
|
+
if (a === "--specialist" && copy[1] !== undefined) { out.specialist = copy[1]; copy.splice(0, 2); continue; }
|
|
137
|
+
if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
|
|
138
|
+
console.error(`unknown argument: ${a}`);
|
|
139
|
+
process.exit(EXIT_USAGE);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
function printHelp() {
|
|
146
|
+
console.log(`shipctl preflight — verify the env + role contract before launching the runner.
|
|
147
|
+
|
|
148
|
+
USAGE
|
|
149
|
+
shipctl preflight [--routine <id> | --specialist <slug>] [--json] [--cwd <dir>]
|
|
150
|
+
|
|
151
|
+
OUTPUT
|
|
152
|
+
JSON shape:
|
|
153
|
+
{
|
|
154
|
+
"ready": true|false,
|
|
155
|
+
"provider": "cursor"|...,
|
|
156
|
+
"missing_secrets": ["SHIP_API_TOKEN", ...],
|
|
157
|
+
"denied_tools": ["git_commit", ...], // resolved role's deny list
|
|
158
|
+
"routine": "...",
|
|
159
|
+
"specialist": "..."
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
EXIT
|
|
163
|
+
0 always (so the workflow's case statement can branch on the JSON);
|
|
164
|
+
the workflow checks 'ready' to decide whether to skip the run.
|
|
165
|
+
`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
function readEnv() {
|
|
170
|
+
return {
|
|
171
|
+
apiBase: stripSlash(
|
|
172
|
+
process.env.SHIP_API_BASE || process.env.SHIP_WORKSPACE_API_BASE || "",
|
|
173
|
+
),
|
|
174
|
+
apiToken: process.env.SHIP_API_TOKEN || "",
|
|
175
|
+
workspaceId: process.env.SHIP_WORKSPACE_ID || "",
|
|
176
|
+
cursorKey: process.env.CURSOR_API_KEY || "",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
function stripSlash(s) {
|
|
182
|
+
return s.replace(/\/+$/, "");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
function resolveSpecialistFromRoutine(config, routineId) {
|
|
187
|
+
if (!routineId || !config) return null;
|
|
188
|
+
const routine = config?.process?.routines?.[routineId] || config?.routines?.[routineId];
|
|
189
|
+
if (!routine || typeof routine !== "object") return null;
|
|
190
|
+
const direct = typeof routine.specialist === "string" ? routine.specialist : null;
|
|
191
|
+
if (direct) return direct;
|
|
192
|
+
const nested = typeof routine.specialist?.id === "string" ? routine.specialist.id : null;
|
|
193
|
+
if (nested) return nested;
|
|
194
|
+
// Legacy ``pattern: role-X`` carries the slug as ``X``.
|
|
195
|
+
const pattern = typeof routine.pattern === "string" ? routine.pattern : null;
|
|
196
|
+
if (pattern && pattern.startsWith("role-")) return pattern.slice("role-".length);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async function fetchResolvedRole({ apiBase, apiToken, workspaceId, slug }) {
|
|
202
|
+
const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent-roles/${encodeURIComponent(slug)}/resolve`;
|
|
203
|
+
const res = await fetch(url, {
|
|
204
|
+
headers: {
|
|
205
|
+
Accept: "application/json",
|
|
206
|
+
Authorization: `Bearer ${apiToken}`,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
throw new Error(`agent-roles resolve ${res.status}`);
|
|
211
|
+
}
|
|
212
|
+
return res.json();
|
|
213
|
+
}
|