@shipfoundry/cli 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/shipfoundry.mjs +5 -0
- package/package.json +28 -0
- package/src/commands.mjs +730 -0
- package/src/config.mjs +102 -0
- package/src/http.mjs +143 -0
- package/src/main.mjs +236 -0
- package/src/output.mjs +140 -0
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { isLoopbackOrigin } from "./config.mjs";
|
|
4
|
+
import { apiFetch, newIdempotencyKey } from "./http.mjs";
|
|
5
|
+
|
|
6
|
+
const TERMINAL_OPERATION_STATUSES = new Set([
|
|
7
|
+
"completed",
|
|
8
|
+
"failed",
|
|
9
|
+
"canceled",
|
|
10
|
+
"dead",
|
|
11
|
+
"skipped"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function requireKey(context) {
|
|
15
|
+
if (!context.key) {
|
|
16
|
+
const error = new Error(
|
|
17
|
+
"No API key configured. Set SHIPFOUNDRY_API_KEY (or use --api-key). Keys are issued from the dashboard; the CLI never mints them."
|
|
18
|
+
);
|
|
19
|
+
error.code = "missing_key";
|
|
20
|
+
error.status = 401;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const commandSpecs = [
|
|
26
|
+
{
|
|
27
|
+
name: "doctor",
|
|
28
|
+
usage: "doctor [--json]",
|
|
29
|
+
summary:
|
|
30
|
+
"Show resolved host, credential status, and version compatibility without secrets.",
|
|
31
|
+
run: doctor
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "auth status",
|
|
35
|
+
usage: "auth status [--json]",
|
|
36
|
+
summary: "Validate the configured credential and show its grant.",
|
|
37
|
+
run: (context) => apiGet(context, "/me")
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "auth login",
|
|
41
|
+
usage: "auth login",
|
|
42
|
+
summary: "Explain the dashboard bootstrap path for issuing a key.",
|
|
43
|
+
run: authLogin
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "projects list",
|
|
47
|
+
usage: "projects list [--json]",
|
|
48
|
+
summary: "List accessible projects.",
|
|
49
|
+
run: (context) => apiGet(context, "/projects")
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "projects show",
|
|
53
|
+
usage: "projects show <project-id> [--json]",
|
|
54
|
+
summary: "Show one project.",
|
|
55
|
+
run: (context, [id]) =>
|
|
56
|
+
apiGet(context, `/projects/${need(id, "project-id")}`)
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "projects create",
|
|
60
|
+
usage:
|
|
61
|
+
"projects create --name <name> [--description ...] [--domain ...] [--repo-url ...] [--idempotency-key ...] [--yes] [--json]",
|
|
62
|
+
summary: "Create a project (unrestricted credentials only).",
|
|
63
|
+
write: true,
|
|
64
|
+
run: (context, args, flags) => {
|
|
65
|
+
needYes(flags, "Creating a project");
|
|
66
|
+
return apiSend(context, "POST", "/projects", {
|
|
67
|
+
body: {
|
|
68
|
+
description: flags.description,
|
|
69
|
+
domain: flags.domain,
|
|
70
|
+
name: need(flags.name, "--name"),
|
|
71
|
+
repoUrl: flags["repo-url"]
|
|
72
|
+
},
|
|
73
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "projects update",
|
|
79
|
+
usage:
|
|
80
|
+
"projects update <project-id> [--name ...] [--summary ...] [--idempotency-key ...] [--yes] [--json]",
|
|
81
|
+
summary: "Rename or re-summarize a project.",
|
|
82
|
+
write: true,
|
|
83
|
+
run: (context, [id], flags) => {
|
|
84
|
+
needYes(flags, "Updating a project");
|
|
85
|
+
return apiSend(context, "PATCH", `/projects/${need(id, "project-id")}`, {
|
|
86
|
+
body: { name: flags.name, summary: flags.summary },
|
|
87
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "projects archive",
|
|
93
|
+
usage: "projects archive <project-id> [--restore] [--yes] [--json]",
|
|
94
|
+
summary: "Archive (or restore) a project with explicit confirmation.",
|
|
95
|
+
write: true,
|
|
96
|
+
run: (context, [id], flags) => {
|
|
97
|
+
needYes(flags, "Archiving a project");
|
|
98
|
+
return apiSend(
|
|
99
|
+
context,
|
|
100
|
+
"POST",
|
|
101
|
+
`/projects/${need(id, "project-id")}/archive`,
|
|
102
|
+
{
|
|
103
|
+
body: {
|
|
104
|
+
action: flags.restore ? "restore" : "archive",
|
|
105
|
+
confirmed: true
|
|
106
|
+
},
|
|
107
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "projects status",
|
|
114
|
+
usage: "projects status <project-id> [--json]",
|
|
115
|
+
summary: "Read setup readiness without triggering work.",
|
|
116
|
+
run: (context, [id]) =>
|
|
117
|
+
apiGet(context, `/projects/${need(id, "project-id")}/status`)
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "projects context",
|
|
121
|
+
usage:
|
|
122
|
+
"projects context <project-id> [--file context.json --yes] [--idempotency-key <key>] [--json]",
|
|
123
|
+
summary:
|
|
124
|
+
"Read project context or submit evidence-based context from a JSON file.",
|
|
125
|
+
run: (context, [id], flags) => {
|
|
126
|
+
const path = `/projects/${need(id, "project-id")}/context`;
|
|
127
|
+
if (!flags.file) return apiGet(context, path);
|
|
128
|
+
needYes(flags, "Updating project context");
|
|
129
|
+
return apiSend(context, "PATCH", path, {
|
|
130
|
+
body: JSON.parse(readFileSync(flags.file, "utf8")),
|
|
131
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "projects activate",
|
|
137
|
+
usage: "projects activate <project-id> [--retry] [--yes] [--json]",
|
|
138
|
+
summary:
|
|
139
|
+
"Confirm the watchlist and start the first review. Use --retry for a failed review (may consume allowance).",
|
|
140
|
+
write: true,
|
|
141
|
+
run: (context, [id], flags) => {
|
|
142
|
+
needYes(flags, "Starting the first review");
|
|
143
|
+
return apiSend(
|
|
144
|
+
context,
|
|
145
|
+
"POST",
|
|
146
|
+
`/projects/${need(id, "project-id")}/activation`,
|
|
147
|
+
{
|
|
148
|
+
body: {
|
|
149
|
+
confirmed: true,
|
|
150
|
+
retryFailed: flags.retry === true ? true : undefined
|
|
151
|
+
},
|
|
152
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "reviews status",
|
|
159
|
+
usage: "reviews status <project-id> [--json]",
|
|
160
|
+
summary: "Read processing status.",
|
|
161
|
+
run: (context, [id]) =>
|
|
162
|
+
apiGet(context, `/projects/${need(id, "project-id")}/processing-status`)
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "updates list",
|
|
166
|
+
usage:
|
|
167
|
+
"updates list --project <project-id> [--workspace active|ready|needs_input|checking|done|all] [--status ...] [--priority ...] [--query ...] [--limit ...] [--cursor ...] [--json]",
|
|
168
|
+
summary: "List recommendations with filters and pagination.",
|
|
169
|
+
run: (context, args, flags) =>
|
|
170
|
+
apiGet(context, `/projects/${need(flags.project, "--project")}/updates`, {
|
|
171
|
+
cursor: flags.cursor,
|
|
172
|
+
limit: flags.limit,
|
|
173
|
+
priority: flags.priority,
|
|
174
|
+
query: flags.query,
|
|
175
|
+
status: flags.status,
|
|
176
|
+
workspace: flags.workspace
|
|
177
|
+
})
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "updates show",
|
|
181
|
+
usage: "updates show <update-id> [--json]",
|
|
182
|
+
summary: "Show one recommendation with verdict and evidence identity.",
|
|
183
|
+
run: (context, [id]) => apiGet(context, `/updates/${need(id, "update-id")}`)
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "updates answer",
|
|
187
|
+
usage:
|
|
188
|
+
"updates answer <update-id> --question-hash <hash> --answer <text> [--yes] [--json]",
|
|
189
|
+
summary: "Answer the current version-bound question.",
|
|
190
|
+
write: true,
|
|
191
|
+
run: (context, [id], flags) => {
|
|
192
|
+
needYes(flags, "Answering a customer question");
|
|
193
|
+
return apiSend(
|
|
194
|
+
context,
|
|
195
|
+
"POST",
|
|
196
|
+
`/updates/${need(id, "update-id")}/answers`,
|
|
197
|
+
{
|
|
198
|
+
body: {
|
|
199
|
+
answer: need(flags.answer, "--answer"),
|
|
200
|
+
questionHash: need(flags["question-hash"], "--question-hash")
|
|
201
|
+
},
|
|
202
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "updates decide",
|
|
209
|
+
usage:
|
|
210
|
+
"updates decide <update-id> --decision keep_open|useful|not_useful|dismiss|reopen [--dismiss-reason ...] [--reason ...] [--yes] [--json]",
|
|
211
|
+
summary: "Record Keep Open, Useful, Not Useful, Dismiss, or Reopen.",
|
|
212
|
+
write: true,
|
|
213
|
+
run: (context, [id], flags) => {
|
|
214
|
+
needYes(flags, "Recording a decision");
|
|
215
|
+
return apiSend(
|
|
216
|
+
context,
|
|
217
|
+
"POST",
|
|
218
|
+
`/updates/${need(id, "update-id")}/decisions`,
|
|
219
|
+
{
|
|
220
|
+
body: {
|
|
221
|
+
decision: need(flags.decision, "--decision"),
|
|
222
|
+
dismissReason: flags["dismiss-reason"],
|
|
223
|
+
reason: flags.reason
|
|
224
|
+
},
|
|
225
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "briefs show",
|
|
232
|
+
usage: "briefs show <update-id> [--json]",
|
|
233
|
+
summary: "Read the stored brief. Never generates.",
|
|
234
|
+
run: (context, [id]) =>
|
|
235
|
+
apiGet(context, `/updates/${need(id, "update-id")}/brief`)
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "briefs request",
|
|
239
|
+
usage:
|
|
240
|
+
"briefs request <update-id> [--mode if_missing_or_stale|regenerate] [--reason ...] [--idempotency-key ...] [--wait] [--timeout ...] [--yes] [--json]",
|
|
241
|
+
summary: "Explicitly request brief generation, optionally waiting.",
|
|
242
|
+
write: true,
|
|
243
|
+
run: requestBrief
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "briefs mark-used",
|
|
247
|
+
usage:
|
|
248
|
+
"briefs mark-used <update-id> --brief-id <brief-id> [--yes] [--json]",
|
|
249
|
+
summary: "Record brief use without changing the outcome.",
|
|
250
|
+
write: true,
|
|
251
|
+
run: (context, [id], flags) => {
|
|
252
|
+
needYes(flags, "Marking a brief used");
|
|
253
|
+
return apiSend(
|
|
254
|
+
context,
|
|
255
|
+
"POST",
|
|
256
|
+
`/updates/${need(id, "update-id")}/brief-uses`,
|
|
257
|
+
{
|
|
258
|
+
body: { briefId: need(flags["brief-id"], "--brief-id") },
|
|
259
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "operations show",
|
|
266
|
+
usage: "operations show <operation-id> [--json]",
|
|
267
|
+
summary: "Read durable operation status.",
|
|
268
|
+
run: (context, [id]) =>
|
|
269
|
+
apiGet(context, `/operations/${need(id, "operation-id")}`)
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "operations wait",
|
|
273
|
+
usage: "operations wait <operation-id> [--timeout ...] [--json]",
|
|
274
|
+
summary: "Poll until terminal or timeout; always reports the operation ID.",
|
|
275
|
+
run: waitOperation
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "operations cancel",
|
|
279
|
+
usage: "operations cancel <operation-id> [--yes] [--json]",
|
|
280
|
+
summary: "Cancel a pending operation.",
|
|
281
|
+
write: true,
|
|
282
|
+
run: (context, [id], flags) => {
|
|
283
|
+
needYes(flags, "Canceling an operation");
|
|
284
|
+
return apiSend(
|
|
285
|
+
context,
|
|
286
|
+
"POST",
|
|
287
|
+
`/operations/${need(id, "operation-id")}/cancel`,
|
|
288
|
+
{
|
|
289
|
+
body: { confirmed: true },
|
|
290
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "handoff show",
|
|
297
|
+
usage:
|
|
298
|
+
"handoff show <update-id> [--format json|markdown] [--mode compact|full]",
|
|
299
|
+
summary: "Fetch the canonical execution packet.",
|
|
300
|
+
run: showHandoff
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "outcomes record",
|
|
304
|
+
usage:
|
|
305
|
+
"outcomes record <update-id> --outcome <value> [--file outcome.json] [--summary ...] [--reason ...] [--idempotency-key ...] [--yes] [--json]",
|
|
306
|
+
summary: "Record evidence-bearing work outcomes.",
|
|
307
|
+
write: true,
|
|
308
|
+
run: recordOutcome
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "watchlist list",
|
|
312
|
+
usage: "watchlist list --project <project-id> [--json]",
|
|
313
|
+
summary: "List tracked selections and suggestions.",
|
|
314
|
+
run: (context, args, flags) =>
|
|
315
|
+
apiGet(context, `/projects/${need(flags.project, "--project")}/watchlist`)
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "watchlist add",
|
|
319
|
+
usage:
|
|
320
|
+
"watchlist add --project <project-id> --recommendation-ids <a,b> [--yes] [--json]",
|
|
321
|
+
summary: "Accept suggestions onto the watchlist.",
|
|
322
|
+
write: true,
|
|
323
|
+
run: (context, args, flags) => {
|
|
324
|
+
needYes(flags, "Adding to the watchlist");
|
|
325
|
+
return apiSend(
|
|
326
|
+
context,
|
|
327
|
+
"POST",
|
|
328
|
+
`/projects/${need(flags.project, "--project")}/watchlist`,
|
|
329
|
+
{
|
|
330
|
+
body: {
|
|
331
|
+
action: "add",
|
|
332
|
+
recommendationIds: splitIds(
|
|
333
|
+
need(flags["recommendation-ids"], "--recommendation-ids")
|
|
334
|
+
)
|
|
335
|
+
},
|
|
336
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "watchlist remove",
|
|
343
|
+
usage:
|
|
344
|
+
"watchlist remove --project <project-id> --selection-ids <a,b> [--yes] [--json]",
|
|
345
|
+
summary: "Stop tracking selections.",
|
|
346
|
+
write: true,
|
|
347
|
+
run: (context, args, flags) => {
|
|
348
|
+
needYes(flags, "Removing from the watchlist");
|
|
349
|
+
return apiSend(
|
|
350
|
+
context,
|
|
351
|
+
"DELETE",
|
|
352
|
+
`/projects/${need(flags.project, "--project")}/watchlist`,
|
|
353
|
+
{
|
|
354
|
+
body: {
|
|
355
|
+
selectionIds: splitIds(
|
|
356
|
+
need(flags["selection-ids"], "--selection-ids")
|
|
357
|
+
)
|
|
358
|
+
},
|
|
359
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "stack request",
|
|
366
|
+
usage:
|
|
367
|
+
"stack request --project <project-id> --name <tool> [--note ...] [--idempotency-key ...] --yes [--json]",
|
|
368
|
+
summary:
|
|
369
|
+
"Request missing coverage without enabling monitoring or paid work.",
|
|
370
|
+
write: true,
|
|
371
|
+
run: (context, args, flags) => {
|
|
372
|
+
needYes(flags, "Requesting stack coverage");
|
|
373
|
+
return apiSend(
|
|
374
|
+
context,
|
|
375
|
+
"POST",
|
|
376
|
+
`/projects/${need(flags.project, "--project")}/tracking-requests`,
|
|
377
|
+
{
|
|
378
|
+
body: {
|
|
379
|
+
name: need(flags.name, "--name"),
|
|
380
|
+
note: flags.note,
|
|
381
|
+
confirmed: true
|
|
382
|
+
},
|
|
383
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "stack resolve",
|
|
390
|
+
usage: "stack resolve --names <a,b> [--json]",
|
|
391
|
+
summary: "Resolve stack names to catalog entities.",
|
|
392
|
+
run: (context, args, flags) =>
|
|
393
|
+
apiSend(context, "POST", "/stack:resolve", {
|
|
394
|
+
body: { names: splitIds(need(flags.names, "--names")) }
|
|
395
|
+
})
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: "repositories list",
|
|
399
|
+
usage: "repositories list --project <project-id> [--json]",
|
|
400
|
+
summary:
|
|
401
|
+
"List authorized repositories, connection state and the browser setup link.",
|
|
402
|
+
run: (context, args, flags) =>
|
|
403
|
+
apiGet(
|
|
404
|
+
context,
|
|
405
|
+
`/projects/${need(flags.project, "--project")}/repository`
|
|
406
|
+
)
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "repositories select",
|
|
410
|
+
usage:
|
|
411
|
+
"repositories select <repo-id> --project <project-id> [--idempotency-key <key>] --yes [--json]",
|
|
412
|
+
summary: "Select one already-authorized repository for the project.",
|
|
413
|
+
write: true,
|
|
414
|
+
run: (context, [id], flags) => {
|
|
415
|
+
needYes(flags, "Selecting a repository");
|
|
416
|
+
return apiSend(
|
|
417
|
+
context,
|
|
418
|
+
"POST",
|
|
419
|
+
`/projects/${need(flags.project, "--project")}/repository`,
|
|
420
|
+
{
|
|
421
|
+
body: { repoId: need(id, "repo-id") },
|
|
422
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "repositories disconnect",
|
|
429
|
+
usage:
|
|
430
|
+
"repositories disconnect <project-repository-id> --project <project-id> [--idempotency-key <key>] --yes [--json]",
|
|
431
|
+
summary: "Disconnect a project repository link without deleting history.",
|
|
432
|
+
write: true,
|
|
433
|
+
run: (context, [id], flags) => {
|
|
434
|
+
needYes(flags, "Disconnecting a repository");
|
|
435
|
+
return apiSend(
|
|
436
|
+
context,
|
|
437
|
+
"DELETE",
|
|
438
|
+
`/projects/${need(flags.project, "--project")}/repository`,
|
|
439
|
+
{
|
|
440
|
+
body: {
|
|
441
|
+
projectRepositoryId: need(id, "project-repository-id"),
|
|
442
|
+
confirmed: true
|
|
443
|
+
},
|
|
444
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
445
|
+
}
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "connections status",
|
|
451
|
+
usage: "connections status [--json]",
|
|
452
|
+
summary: "Read GitHub and Linear connection state.",
|
|
453
|
+
run: (context) => apiGet(context, "/connections")
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "linear status",
|
|
457
|
+
usage: "linear status [--project <project-id>] [--json]",
|
|
458
|
+
summary: "Read Linear export target configuration.",
|
|
459
|
+
run: (context, args, flags) =>
|
|
460
|
+
apiGet(context, "/linear/status", { projectId: flags.project })
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: "linear preview",
|
|
464
|
+
usage: "linear preview --match <update-id> [--json]",
|
|
465
|
+
summary: "Preview the exact Linear payload with dedupe identity.",
|
|
466
|
+
run: (context, args, flags) =>
|
|
467
|
+
apiSend(context, "POST", "/linear/preview", {
|
|
468
|
+
body: { matchId: need(flags.match, "--match") }
|
|
469
|
+
})
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "linear export",
|
|
473
|
+
usage: "linear export --match <update-id> [--force-new] [--yes] [--json]",
|
|
474
|
+
summary: "Create a Linear issue from the current brief.",
|
|
475
|
+
write: true,
|
|
476
|
+
run: (context, args, flags) => {
|
|
477
|
+
needYes(flags, "Creating a Linear issue");
|
|
478
|
+
return apiSend(context, "POST", "/linear/export", {
|
|
479
|
+
body: {
|
|
480
|
+
confirmed: true,
|
|
481
|
+
forceNew: flags["force-new"] === true,
|
|
482
|
+
matchId: need(flags.match, "--match")
|
|
483
|
+
},
|
|
484
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: "account usage",
|
|
490
|
+
usage: "account usage [--json]",
|
|
491
|
+
summary: "Read plan, usage, and limits.",
|
|
492
|
+
run: (context) => apiGet(context, "/usage")
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "account settings",
|
|
496
|
+
usage: "account settings [--json]",
|
|
497
|
+
summary: "Read supported account settings.",
|
|
498
|
+
run: (context) => apiGet(context, "/settings")
|
|
499
|
+
}
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
async function doctor(context) {
|
|
503
|
+
const probe = await apiGet(
|
|
504
|
+
{ ...context, key: context.key ?? null },
|
|
505
|
+
"/me"
|
|
506
|
+
).catch((error) => ({
|
|
507
|
+
authorized: false,
|
|
508
|
+
code: error.code ?? "unknown",
|
|
509
|
+
status: error.status ?? 0
|
|
510
|
+
}));
|
|
511
|
+
const contract = await apiFetch({
|
|
512
|
+
baseUrl: context.baseUrl,
|
|
513
|
+
key: null,
|
|
514
|
+
path: "/openapi"
|
|
515
|
+
})
|
|
516
|
+
.then((response) => response.data?.info?.version ?? null)
|
|
517
|
+
.catch(() => null);
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
baseUrl: context.baseUrl,
|
|
521
|
+
credential: context.key
|
|
522
|
+
? { configured: true, source: context.keySource }
|
|
523
|
+
: { configured: false, source: "none" },
|
|
524
|
+
identity: probe && probe.data ? { ok: true } : { ok: false },
|
|
525
|
+
local: isLoopbackOrigin(context.baseUrl),
|
|
526
|
+
openapi: contract
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function authLogin(context) {
|
|
531
|
+
return {
|
|
532
|
+
bootstrap:
|
|
533
|
+
"Open /dashboard/settings/mcp on your ShipFoundry instance. Under Developer Keys, create a key with the projects and permissions you need, then export SHIPFOUNDRY_API_KEY and set SHIPFOUNDRY_BASE_URL to that instance origin.",
|
|
534
|
+
keyConfigured: Boolean(context.key),
|
|
535
|
+
note: "The CLI never mints keys and never persists secrets. Keys are account-scoped bearer credentials, not a development bypass."
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function apiGet(context, path, query) {
|
|
540
|
+
requireKey(context);
|
|
541
|
+
const response = await apiFetch({
|
|
542
|
+
baseUrl: context.baseUrl,
|
|
543
|
+
key: context.key,
|
|
544
|
+
method: "GET",
|
|
545
|
+
path,
|
|
546
|
+
query,
|
|
547
|
+
timeoutMs: context.timeoutMs
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
return response.data;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function apiSend(context, method, path, { body, idempotencyKey } = {}) {
|
|
554
|
+
requireKey(context);
|
|
555
|
+
const response = await apiFetch({
|
|
556
|
+
baseUrl: context.baseUrl,
|
|
557
|
+
body,
|
|
558
|
+
idempotencyKey,
|
|
559
|
+
key: context.key,
|
|
560
|
+
method,
|
|
561
|
+
path,
|
|
562
|
+
timeoutMs: context.timeoutMs
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
return response.data;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function requestBrief(context, [id], flags) {
|
|
569
|
+
needYes(flags, "Requesting paid brief generation");
|
|
570
|
+
const updateId = need(id, "update-id");
|
|
571
|
+
const result = await apiSend(
|
|
572
|
+
context,
|
|
573
|
+
"POST",
|
|
574
|
+
`/updates/${updateId}/brief-requests`,
|
|
575
|
+
{
|
|
576
|
+
body: {
|
|
577
|
+
confirmed: true,
|
|
578
|
+
mode: flags.mode ?? "if_missing_or_stale",
|
|
579
|
+
reason: flags.reason
|
|
580
|
+
},
|
|
581
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
582
|
+
}
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
if (!flags.wait) {
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const jobId = result?.data?.job?.id ?? result?.data?.brief?.id ?? null;
|
|
590
|
+
|
|
591
|
+
if (
|
|
592
|
+
!jobId ||
|
|
593
|
+
result?.data?.status === "already_current" ||
|
|
594
|
+
result?.data?.status === "blocked"
|
|
595
|
+
) {
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return waitOperation(context, [jobId], flags);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function waitOperation(context, [id], flags) {
|
|
603
|
+
const operationId = need(id, "operation-id");
|
|
604
|
+
const timeoutMs = Number(flags.timeout ?? 120) * 1000;
|
|
605
|
+
const deadline = Date.now() + timeoutMs;
|
|
606
|
+
let last = null;
|
|
607
|
+
|
|
608
|
+
while (true) {
|
|
609
|
+
last = await apiGet(context, `/operations/${operationId}`);
|
|
610
|
+
|
|
611
|
+
if (TERMINAL_OPERATION_STATUSES.has(last?.data?.status)) {
|
|
612
|
+
return last;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (Date.now() >= deadline) {
|
|
616
|
+
const error = new Error(
|
|
617
|
+
`Timed out waiting for operation ${operationId}. It may still be running; poll operations show ${operationId} to resume.`
|
|
618
|
+
);
|
|
619
|
+
error.code = "wait_timeout";
|
|
620
|
+
error.status = 0;
|
|
621
|
+
error.operationId = operationId;
|
|
622
|
+
error.last = last;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function showHandoff(context, [id], flags) {
|
|
631
|
+
const updateId = need(id, "update-id");
|
|
632
|
+
const result = await apiGet(context, `/updates/${updateId}/packet`, {
|
|
633
|
+
mode: flags.mode ?? "compact"
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
if ((flags.format ?? "json") === "markdown") {
|
|
637
|
+
const markdown = extractMarkdown(result);
|
|
638
|
+
|
|
639
|
+
if (markdown) {
|
|
640
|
+
return { markdown };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function markdownField(value) {
|
|
648
|
+
if (value === null || value === undefined || Object(value) === value) {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const text = String(value);
|
|
653
|
+
return text.trim() ? text : null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function extractMarkdown(result) {
|
|
657
|
+
if (!result || Object(result) !== result) {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const data = Object(result.data) === result.data ? result.data : null;
|
|
662
|
+
|
|
663
|
+
if (!data) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const packet = Object(data.packet) === data.packet ? data.packet : null;
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
markdownField(packet?.markdown) ??
|
|
671
|
+
markdownField(data.markdown) ??
|
|
672
|
+
markdownField(data.prompt) ??
|
|
673
|
+
markdownField(packet?.prompt)
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function recordOutcome(context, [id], flags) {
|
|
678
|
+
needYes(flags, "Recording a work outcome");
|
|
679
|
+
const updateId = need(id, "update-id");
|
|
680
|
+
let fileEvidence = {};
|
|
681
|
+
|
|
682
|
+
if (flags.file) {
|
|
683
|
+
fileEvidence = JSON.parse(readFileSync(flags.file, "utf8"));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return apiSend(context, "POST", `/updates/${updateId}/outcomes`, {
|
|
687
|
+
body: {
|
|
688
|
+
commitUrl: fileEvidence.commitUrl,
|
|
689
|
+
filesChanged: fileEvidence.filesChanged ?? [],
|
|
690
|
+
filesInspected: fileEvidence.filesInspected ?? [],
|
|
691
|
+
outcome: need(flags.outcome ?? fileEvidence.outcome, "--outcome"),
|
|
692
|
+
prUrl: fileEvidence.prUrl,
|
|
693
|
+
reason: flags.reason ?? fileEvidence.reason,
|
|
694
|
+
summary: flags.summary ?? fileEvidence.summary,
|
|
695
|
+
validationNotes: fileEvidence.validationNotes ?? []
|
|
696
|
+
},
|
|
697
|
+
idempotencyKey: flags["idempotency-key"] ?? newIdempotencyKey()
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function need(value, label) {
|
|
702
|
+
if (value === undefined || value === null || value === "") {
|
|
703
|
+
const error = new Error(`Missing required ${label}.`);
|
|
704
|
+
error.code = "usage";
|
|
705
|
+
error.status = 400;
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return value;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function needYes(flags, action) {
|
|
713
|
+
if (flags.yes === true) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const error = new Error(
|
|
718
|
+
`${action} requires explicit intent. Re-run with --yes to confirm this write.`
|
|
719
|
+
);
|
|
720
|
+
error.code = "confirmation_required";
|
|
721
|
+
error.status = 400;
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function splitIds(raw) {
|
|
726
|
+
return String(raw)
|
|
727
|
+
.split(",")
|
|
728
|
+
.map((item) => item.trim())
|
|
729
|
+
.filter(Boolean);
|
|
730
|
+
}
|