@prave/cli 1.4.6 → 1.4.7

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/dist/index.js CHANGED
@@ -15,7 +15,6 @@ import { mcpInstallCommand } from './commands/mcp-install.js';
15
15
  import { mcpServerCommand } from './commands/mcp-server.js';
16
16
  import { optimizeCommand } from './commands/optimize.js';
17
17
  import { overviewCommand } from './commands/overview.js';
18
- import { runDeployCommand, runListCommand, runLogsCommand, runTriggerCommand, } from './commands/run.js';
19
18
  import { searchCommand } from './commands/search.js';
20
19
  import { settingsCommand } from './commands/settings.js';
21
20
  import { syncCommand } from './commands/sync.js';
@@ -40,7 +39,6 @@ const program = new Command()
40
39
  ' prave login # device-code auth in your browser',
41
40
  ' prave search <q> # discover community skills',
42
41
  ' prave install <slug> # pull into ~/.claude/skills/ (multi-agent prompt included)',
43
- ' prave run deploy # bundle cwd, schedule on Prave\'s sandbox',
44
42
  ' prave usage hook install # real-time invocation tracking',
45
43
  '',
46
44
  ' Docs: https://prave.app/docs',
@@ -151,30 +149,6 @@ program
151
149
  .command('docs [slug]')
152
150
  .description('Open the docs in your browser. Bare `prave docs` lands on the home page; `prave docs cli/run` or `prave docs web/runs` jumps straight to a section.')
153
151
  .action((slug) => docsCommand(slug));
154
- // ─── prave run — scheduled server-side executions (Runs) ──────────
155
- const run = program
156
- .command('run')
157
- .description('Schedule a skill to fire on a cron, executed by your chosen AI agent on Prave\'s sandbox. Bring the whole project — SKILL.md, scripts, .env.');
158
- run
159
- .command('deploy [path]')
160
- .description('Bundle the current directory (or `path`), upload it to Prave, and open the browser wizard to pick the schedule + agent.')
161
- .action((path) => runDeployCommand(path));
162
- run
163
- .command('update <slug> [path]')
164
- .description("Re-upload the bundle for an existing run. Same flow as `deploy`, but the run's schedule, agent and env vars stay intact — only the project files swap.")
165
- .action((slug, path) => runDeployCommand(path, { updateRunSlug: slug }));
166
- run
167
- .command('list')
168
- .description('List your scheduled runs with next-fire time + last status.')
169
- .action(runListCommand);
170
- run
171
- .command('logs <slug>')
172
- .description('Print the latest execution log for a scheduled run.')
173
- .action(runLogsCommand);
174
- run
175
- .command('trigger <slug>')
176
- .description('Fire a one-shot execution outside the cron schedule. Refused with 409 if another execution is already in flight. Tail with `prave run logs <slug>` afterwards.')
177
- .action(runTriggerCommand);
178
152
  program
179
153
  .command('mcp install')
180
154
  .alias('mcp-install')
@@ -219,16 +193,9 @@ program
219
193
  'Settings',
220
194
  ' prave settings # configure agents + paths',
221
195
  '',
222
- 'Runs (scheduled cron on Prave)',
223
- ' prave run deploy # bundle cwd, upload, open wizard',
224
- ' prave run update <slug> # re-upload bundle for an existing run',
225
- ' prave run trigger <slug> # fire one execution outside the cron',
226
- ' prave run list # your scheduled runs',
227
- ' prave run logs <slug> # tail latest execution log',
228
- '',
229
196
  'Docs',
230
197
  ' prave docs # open the docs in your browser',
231
- ' prave docs <slug> # jump to a section, e.g. cli/run',
198
+ ' prave docs <slug> # jump to a section',
232
199
  '',
233
200
  'Telemetry',
234
201
  ' PRAVE_TELEMETRY=0 # opt out of CLI usage analytics',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "Prave CLI — discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
54
54
  "ora": "^8.0.1",
55
55
  "tar": "^7.4.3",
56
56
  "undici": "^6.18.0",
57
- "@prave/shared": "1.4.6"
57
+ "@prave/shared": "1.4.7"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",
@@ -1,481 +0,0 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
2
- import { resolve, relative, basename, sep } from 'node:path';
3
- import { createInterface } from 'node:readline/promises';
4
- import { Buffer } from 'node:buffer';
5
- import chalk from 'chalk';
6
- import open from 'open';
7
- import ora from 'ora';
8
- import * as tar from 'tar';
9
- import { request } from 'undici';
10
- import { isLikelyTextPath, scanForSecrets, } from '@prave/shared';
11
- import { api, ApiError } from '../lib/api.js';
12
- import { CONFIG } from '../lib/config.js';
13
- import { loadCredentials, requireAuth } from '../lib/credentials.js';
14
- import { log } from '../utils/logger.js';
15
- /**
16
- * `prave run` — scheduled server-side skill executions on Prave's
17
- * infrastructure.
18
- *
19
- * prave run deploy [path] # bundle dir → upload → open wizard
20
- * prave run list # list my deployed runs
21
- * prave run logs <slug> # tail the most recent execution logs
22
- *
23
- * `deploy` is the headline action — the rest just expose the same data
24
- * the dashboard already shows. The wizard handles schedule + agent
25
- * selection in the browser because picking from an agent dropdown
26
- * filtered to "which API keys do I have" is way clearer in a UI than
27
- * an interactive prompt.
28
- */
29
- // Sane-default ignore list for `tar.create` — none of these belong in
30
- // a deployable bundle and dragging them up adds noise to the scan +
31
- // bloats storage.
32
- const TAR_IGNORE = new Set([
33
- '.git',
34
- '.github',
35
- '.next',
36
- '.turbo',
37
- '.svelte-kit',
38
- '.cache',
39
- 'node_modules',
40
- 'dist',
41
- 'build',
42
- 'coverage',
43
- '.venv',
44
- 'venv',
45
- '__pycache__',
46
- '.DS_Store',
47
- ]);
48
- const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; // matches API + Supabase Storage cap
49
- const MAX_FILES = 200;
50
- export async function runDeployCommand(pathArg, options = {}) {
51
- const isUpdate = Boolean(options.updateRunSlug);
52
- const root = resolve(pathArg ?? process.cwd());
53
- const rootStat = await stat(root).catch(() => null);
54
- if (!rootStat?.isDirectory()) {
55
- log.error(`Not a directory: ${root}`);
56
- process.exit(1);
57
- }
58
- // Sanity check — the dir should *look* like a skill project. We don't
59
- // hard-reject; we just warn if there's no SKILL.md anywhere.
60
- const skillMd = await findSkillMd(root);
61
- if (!skillMd) {
62
- log.warn('No SKILL.md found in this directory. Deploys still work without it, but the runner will not have skill-shaped context for the agent.');
63
- }
64
- const creds0 = await requireAuth('prave run');
65
- if (!creds0)
66
- return;
67
- // 1a. Look for .env / .env.production / .env.local etc. The scanner
68
- // would otherwise hard-reject them on the upload. Offer to lift the
69
- // values out of the bundle and pass them to the wizard so they end
70
- // up encrypted on the run row instead of in the tarball.
71
- const envFiles = await findEnvFiles(root);
72
- let envVars = {};
73
- const stripPaths = new Set();
74
- if (envFiles.length > 0) {
75
- const totalKeys = envFiles.reduce((n, f) => n + Object.keys(f.values).length, 0);
76
- console.log();
77
- console.log(chalk.bold(`Found ${envFiles.length} env file${envFiles.length === 1 ? '' : 's'} with ${totalKeys} variable${totalKeys === 1 ? '' : 's'}:`));
78
- for (const f of envFiles) {
79
- console.log(` ${chalk.cyan('•')} ${f.path} ${chalk.dim(`(${Object.keys(f.values).length} keys)`)}`);
80
- }
81
- console.log(chalk.dim('\nIf you continue, Prave strips these from the upload and passes the\n' +
82
- 'values to the browser wizard. They get AES-256-GCM-encrypted onto\n' +
83
- 'the run, decrypted only inside the sandbox at run-time.\n' +
84
- 'Decline to abort so you can clean up first.'));
85
- const yes = await confirmYesNo('Strip & pass env vars to the wizard?');
86
- if (!yes) {
87
- log.error('Aborted. Remove the .env file(s) (or rename to .env.example) and re-run.');
88
- process.exit(1);
89
- }
90
- for (const f of envFiles) {
91
- stripPaths.add(f.path);
92
- Object.assign(envVars, f.values);
93
- }
94
- }
95
- // 1b. Local secret-scan BEFORE we ship anything. Cheap defence in
96
- // depth — even though the API scans again, surfacing the finding
97
- // pre-upload saves the user a round-trip and avoids briefly storing
98
- // a secret-bearing tarball in our bucket. The env files we just
99
- // accepted to lift out are excluded from the scan.
100
- const localFindings = await preflightScan(root, stripPaths);
101
- if (localFindings.length > 0) {
102
- log.error('Bundle contains files that look like secrets:');
103
- for (const f of localFindings.slice(0, 10)) {
104
- console.error(` ${chalk.red('•')} ${f.rule} ${chalk.dim(f.path)}${f.line ? `:${f.line}` : ''}`);
105
- }
106
- console.error(chalk.dim('\nRemove these files (or scrub the values) and re-run.\n' +
107
- 'For env vars, ship a .env.example template instead — Prave\n' +
108
- 'will prompt you for the real values during the wizard.'));
109
- process.exit(1);
110
- }
111
- // 2. Mint the deploy session. Update flows tag the session with
112
- // the existing run's slug so the upload handler swaps that run's
113
- // bundle pointer instead of creating a fresh row.
114
- const initSpinner = ora(isUpdate ? `Opening update session for ${options.updateRunSlug}…` : 'Opening deploy session…').start();
115
- let session;
116
- try {
117
- const initPath = isUpdate
118
- ? `/api/v1/deploy/init?run_slug=${encodeURIComponent(options.updateRunSlug)}`
119
- : '/api/v1/deploy/init';
120
- const { data } = await api.post(initPath, {}, true);
121
- session = data.session;
122
- initSpinner.succeed('Session opened');
123
- }
124
- catch (err) {
125
- initSpinner.fail(`Could not open deploy session: ${err.message}`);
126
- process.exit(1);
127
- }
128
- // 2b. Ship env vars to the wizard before the upload so they're
129
- // waiting when the browser opens. The wizard reads them once via
130
- // /deploy/status and pre-fills its env-vars step.
131
- if (Object.keys(envVars).length > 0) {
132
- const envSpinner = ora('Sending env vars to the wizard…').start();
133
- try {
134
- await api.post(`/api/v1/deploy/env?session=${encodeURIComponent(session.session_id)}`, { env_vars: envVars }, true);
135
- envSpinner.succeed(`Passed ${Object.keys(envVars).length} env var(s)`);
136
- }
137
- catch (err) {
138
- envSpinner.fail(`Could not ship env vars: ${err.message}`);
139
- process.exit(1);
140
- }
141
- }
142
- // 3. Pack the directory into a gzipped tar in memory. tar.create's
143
- // `cwd` option is critical — paths inside the archive must be
144
- // RELATIVE to the project root, not absolute. Stripped paths are
145
- // env files we already shipped via /deploy/env above.
146
- const packSpinner = ora('Bundling project…').start();
147
- let tarball;
148
- try {
149
- tarball = await packDirectory(root, stripPaths);
150
- packSpinner.succeed(`Bundled ${formatBytes(tarball.length)}`);
151
- }
152
- catch (err) {
153
- packSpinner.fail(`Bundle failed: ${err.message}`);
154
- process.exit(1);
155
- }
156
- if (tarball.length > MAX_BUNDLE_BYTES) {
157
- log.error(`Bundle is ${formatBytes(tarball.length)}, cap is ${formatBytes(MAX_BUNDLE_BYTES)}. ` +
158
- 'Add large files to .gitignore-style noise (or place them in node_modules / .git which we already skip).');
159
- process.exit(1);
160
- }
161
- // 4. Stream the tarball to /deploy/upload. We use undici directly
162
- // because api.ts only does JSON.
163
- const uploadSpinner = ora('Uploading to Prave…').start();
164
- const creds = await loadCredentials();
165
- if (!creds) {
166
- uploadSpinner.fail('Credentials expired mid-flight — please re-login.');
167
- process.exit(1);
168
- }
169
- const uploadUrl = `${CONFIG.apiUrl}/api/v1/deploy/upload?session=${encodeURIComponent(session.session_id)}`;
170
- try {
171
- const { statusCode, body } = await request(uploadUrl, {
172
- method: 'POST',
173
- headers: {
174
- 'Content-Type': 'application/gzip',
175
- Authorization: `Bearer ${creds.access_token}`,
176
- },
177
- body: tarball,
178
- });
179
- const text = await body.text();
180
- if (statusCode >= 400) {
181
- const parsed = safeJson(text);
182
- const msg = parsed?.error ?? `HTTP ${statusCode}`;
183
- uploadSpinner.fail(`Upload rejected: ${msg}`);
184
- process.exit(1);
185
- }
186
- uploadSpinner.succeed('Upload complete');
187
- // Update flow: the server already swapped the run's bundle
188
- // pointer. No wizard, no browser open — just confirm + exit.
189
- const parsed = safeJson(text);
190
- if (isUpdate || parsed?.updated_run_slug) {
191
- console.log();
192
- console.log(chalk.bold(`Updated ${parsed?.updated_run_slug ?? options.updateRunSlug}.`));
193
- console.log(chalk.dim(' Next scheduled fire will use the freshly-uploaded bundle.\n' +
194
- ' Open the dashboard:'));
195
- console.log(chalk.cyan(' ' + session.wizard_url));
196
- return;
197
- }
198
- }
199
- catch (err) {
200
- uploadSpinner.fail(`Upload failed: ${err.message}`);
201
- process.exit(1);
202
- }
203
- // 5. New-run flow: open the browser at the wizard.
204
- console.log();
205
- console.log(chalk.bold('Finish in the browser:'));
206
- console.log(chalk.cyan(' ' + session.wizard_url));
207
- try {
208
- await open(session.wizard_url);
209
- }
210
- catch {
211
- /* user can copy the URL manually */
212
- }
213
- }
214
- export async function runListCommand() {
215
- if (!(await requireAuth('prave run list')))
216
- return;
217
- const spinner = ora('Fetching runs…').start();
218
- try {
219
- const { data } = await api.get('/api/v1/runs', true);
220
- spinner.stop();
221
- if (!data.runs.length) {
222
- console.log(chalk.dim('No runs yet. `prave run deploy` to schedule your first one.'));
223
- return;
224
- }
225
- for (const r of data.runs) {
226
- const nextRun = r.next_run_at
227
- ? new Date(r.next_run_at).toLocaleString()
228
- : chalk.dim('paused');
229
- const status = r.status === 'active'
230
- ? chalk.green('active')
231
- : r.status === 'paused'
232
- ? chalk.yellow('paused')
233
- : chalk.red(r.status);
234
- console.log(` ${chalk.bold(r.name)} ${chalk.dim(r.slug)}\n` +
235
- ` ${chalk.dim(`agent=${r.agent} schedule=${r.schedule_kind} status=${status} next=${nextRun}`)}`);
236
- }
237
- }
238
- catch (err) {
239
- spinner.fail(err.message);
240
- process.exit(err instanceof ApiError ? 1 : 1);
241
- }
242
- }
243
- export async function runLogsCommand(slug) {
244
- if (!(await requireAuth('prave run logs')))
245
- return;
246
- const spinner = ora(`Fetching logs for ${slug}…`).start();
247
- try {
248
- const { data } = await api.get(`/api/v1/runs/${encodeURIComponent(slug)}/executions?limit=10`, true);
249
- spinner.stop();
250
- if (!data.executions.length) {
251
- console.log(chalk.dim('No executions yet. The first one will appear after the next scheduled fire.'));
252
- return;
253
- }
254
- const latest = data.executions[0];
255
- const status = latest.status === 'success'
256
- ? chalk.green(latest.status)
257
- : latest.status === 'running'
258
- ? chalk.cyan(latest.status)
259
- : chalk.red(latest.status);
260
- console.log(`${chalk.bold(slug)} ${chalk.dim(latest.started_at)} ${status}` +
261
- (latest.duration_ms !== null ? chalk.dim(` (${latest.duration_ms}ms)`) : ''));
262
- console.log();
263
- console.log(latest.log_text ?? chalk.dim('(no log captured)'));
264
- if (latest.error_message) {
265
- console.log();
266
- console.log(chalk.red('Error: ') + latest.error_message);
267
- }
268
- }
269
- catch (err) {
270
- spinner.fail(err.message);
271
- process.exit(1);
272
- }
273
- }
274
- // ── helpers ──────────────────────────────────────────────────────────
275
- async function findSkillMd(root) {
276
- // Top-level only — most skill projects ship SKILL.md at the root.
277
- // Deeper search would be wasted work for the warning we'd print.
278
- try {
279
- const entries = await readdir(root);
280
- return entries.find((n) => n.toLowerCase() === 'skill.md') ?? null;
281
- }
282
- catch {
283
- return null;
284
- }
285
- }
286
- async function preflightScan(root, stripPaths = new Set()) {
287
- const inputs = [];
288
- let files = 0;
289
- let bytes = 0;
290
- const visit = async (dir) => {
291
- if (files >= MAX_FILES)
292
- return;
293
- if (bytes >= MAX_BUNDLE_BYTES)
294
- return;
295
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
296
- for (const entry of entries) {
297
- if (TAR_IGNORE.has(entry.name))
298
- continue;
299
- if (entry.name.startsWith('._'))
300
- continue;
301
- const abs = `${dir}${sep}${entry.name}`;
302
- if (entry.isDirectory()) {
303
- await visit(abs);
304
- continue;
305
- }
306
- if (!entry.isFile())
307
- continue;
308
- const rel = relative(root, abs).split(sep).join('/');
309
- // The user already accepted to lift this env file out of the
310
- // bundle in the previous step. Don't scan it (we know it has
311
- // secrets — that's why we're lifting it).
312
- if (stripPaths.has(rel))
313
- continue;
314
- files++;
315
- if (!isLikelyTextPath(rel)) {
316
- inputs.push({ path: rel });
317
- continue;
318
- }
319
- try {
320
- const content = await readFile(abs, 'utf8');
321
- bytes += content.length;
322
- inputs.push({ path: rel, content });
323
- }
324
- catch {
325
- inputs.push({ path: rel });
326
- }
327
- }
328
- };
329
- await visit(root);
330
- return scanForSecrets(inputs).findings;
331
- }
332
- async function packDirectory(root, stripPaths = new Set()) {
333
- // Top-level entries only — tar.create resolves them against `cwd`.
334
- const entries = (await readdir(root)).filter((n) => !TAR_IGNORE.has(n));
335
- if (entries.length === 0) {
336
- throw new Error('Project directory is empty.');
337
- }
338
- // Normalise the strip-set against tar.create's relative-path format
339
- // (POSIX forward slashes, no leading "./"). Paths sit one level
340
- // beneath the archive's basename prefix, so we compare on the
341
- // input-side relative path tar.create hands us.
342
- const stripNormalised = new Set([...stripPaths].map((p) => p.replace(/\\/g, '/').replace(/^\.\//, '')));
343
- const stream = tar.create({
344
- gzip: true,
345
- cwd: root,
346
- portable: true,
347
- // Prefix all paths with the project's basename so the runner
348
- // gets a `<project>/SKILL.md` shape, not a flat dump at the
349
- // archive root.
350
- prefix: basename(root),
351
- filter: (path) => {
352
- const norm = path.replace(/\\/g, '/').replace(/^\.\//, '');
353
- return !stripNormalised.has(norm);
354
- },
355
- }, entries);
356
- const chunks = [];
357
- for await (const chunk of stream) {
358
- chunks.push(Buffer.from(chunk));
359
- }
360
- return Buffer.concat(chunks);
361
- }
362
- /**
363
- * Look for common `.env*` files at the project root. We don't scan
364
- * deeper than top-level — a nested `.env` is almost certainly a
365
- * shipping example, not the real secrets file. Returns an empty list
366
- * when nothing's found.
367
- */
368
- async function findEnvFiles(root) {
369
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
370
- const findings = [];
371
- for (const entry of entries) {
372
- if (!entry.isFile())
373
- continue;
374
- const name = entry.name;
375
- if (!isRealEnvFile(name))
376
- continue;
377
- try {
378
- const raw = await readFile(`${root}${sep}${name}`, 'utf8');
379
- const values = parseDotenv(raw);
380
- if (Object.keys(values).length === 0)
381
- continue;
382
- findings.push({ path: name, values });
383
- }
384
- catch {
385
- /* unreadable — skip */
386
- }
387
- }
388
- return findings;
389
- }
390
- /**
391
- * `.env`, `.env.local`, `.env.production`, `.env.development`, `.envrc`
392
- * count as "real env files". Templates (`.env.example`, `.env.sample`,
393
- * `.env.template`) are explicitly NOT lifted — those are meant to be
394
- * shipped.
395
- */
396
- function isRealEnvFile(name) {
397
- if (name === '.env' || name === '.envrc')
398
- return true;
399
- if (!name.startsWith('.env.'))
400
- return false;
401
- const suffix = name.slice('.env.'.length);
402
- if (/^(example|sample|template)$/i.test(suffix))
403
- return false;
404
- return true;
405
- }
406
- function parseDotenv(text) {
407
- const out = {};
408
- for (const raw of text.split('\n')) {
409
- const line = raw.trim();
410
- if (!line || line.startsWith('#'))
411
- continue;
412
- const eq = line.indexOf('=');
413
- if (eq <= 0)
414
- continue;
415
- const key = line.slice(0, eq).trim();
416
- let value = line.slice(eq + 1).trim();
417
- if ((value.startsWith('"') && value.endsWith('"')) ||
418
- (value.startsWith("'") && value.endsWith("'"))) {
419
- value = value.slice(1, -1);
420
- }
421
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
422
- out[key] = value;
423
- }
424
- return out;
425
- }
426
- async function confirmYesNo(question) {
427
- // Non-interactive shells (pipes, CI) can't prompt — default to "no"
428
- // so we never silently ship secrets in a script.
429
- if (!process.stdout.isTTY || !process.stdin.isTTY)
430
- return false;
431
- const rl = createInterface({ input: process.stdin, output: process.stdout });
432
- try {
433
- const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
434
- return answer === '' || answer === 'y' || answer === 'yes';
435
- }
436
- finally {
437
- rl.close();
438
- }
439
- }
440
- function formatBytes(n) {
441
- if (n < 1024)
442
- return `${n}B`;
443
- if (n < 1024 * 1024)
444
- return `${(n / 1024).toFixed(1)}KB`;
445
- return `${(n / 1024 / 1024).toFixed(2)}MB`;
446
- }
447
- function safeJson(text) {
448
- try {
449
- return JSON.parse(text);
450
- }
451
- catch {
452
- return null;
453
- }
454
- }
455
- /**
456
- * `prave run trigger <slug>` — fire a single ad-hoc execution outside
457
- * the cron schedule. Refused with 409 when another execution is in
458
- * flight (same single-flight guard the dashboard's "Run now" button
459
- * uses). Returns immediately after enqueueing — tail with
460
- * `prave run logs <slug>` after a few seconds.
461
- */
462
- export async function runTriggerCommand(slug) {
463
- if (!(await requireAuth('prave run trigger')))
464
- return;
465
- const spinner = ora(`Triggering ${slug}…`).start();
466
- try {
467
- await api.post(`/api/v1/runs/${encodeURIComponent(slug)}/trigger`, undefined, true);
468
- spinner.succeed(`Triggered ${chalk.bold(slug)} — log will appear in a few seconds.`);
469
- console.log(chalk.dim(` Tail it with `) +
470
- chalk.cyan(`prave run logs ${slug}`) +
471
- chalk.dim(` (or watch the dashboard).`));
472
- }
473
- catch (err) {
474
- if (err instanceof ApiError && err.status === 409) {
475
- spinner.warn(`An execution is already in flight for ${slug} — wait for it to finish, then re-run.`);
476
- return;
477
- }
478
- spinner.fail(err.message);
479
- process.exit(1);
480
- }
481
- }