@canner-ca/cli 0.1.0 → 0.3.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/README.md +48 -1
- package/package.json +1 -1
- package/src/cli.mjs +605 -14
package/README.md
CHANGED
|
@@ -33,12 +33,59 @@ canner deploy --follow
|
|
|
33
33
|
|
|
34
34
|
## Commands
|
|
35
35
|
|
|
36
|
+
### Setup
|
|
37
|
+
|
|
36
38
|
| Command | What it does |
|
|
37
39
|
|---|---|
|
|
38
40
|
| `canner login` | Prompt for an API token, store it at `~/.canner/credentials` (mode 0600). |
|
|
39
41
|
| `canner whoami` | Show the email + plan this CLI is signed in as. |
|
|
42
|
+
|
|
43
|
+
### Projects
|
|
44
|
+
|
|
45
|
+
| Command | What it does |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `canner projects` | List your projects. |
|
|
40
48
|
| `canner init [--slug X]` | Create a new Canner project and write `canner.json` in the current directory. |
|
|
41
|
-
| `canner
|
|
49
|
+
| `canner status [--slug X]` | Show one project's state — URL, status, source, last request. |
|
|
50
|
+
| `canner open [--slug X] [--dashboard]` | Open the tenant URL (or the dashboard project page) in your browser. |
|
|
51
|
+
|
|
52
|
+
### Deploys
|
|
53
|
+
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `canner deploy [path] [--slug X] [--follow]` | Tar the directory and upload. Works for any project — GitHub-connected or upload-only. `--follow` streams the build log and exits with the build's success code. |
|
|
57
|
+
| `canner redeploy [--slug X] [--follow]` | Trigger a rebuild from the GitHub repo's `HEAD` without uploading. GitHub-connected projects only. |
|
|
58
|
+
| `canner deployments [--slug X]` | List the project's recent deployments. |
|
|
59
|
+
| `canner deployments <id> [--slug X] [--follow]` | Show one deployment's build log; `--follow` streams if it's still in progress. |
|
|
60
|
+
| `canner cancel [<id>] [--slug X]` | Abort the in-flight build (or a specific one by id). |
|
|
61
|
+
| `canner logs [--slug X] [--lines N] [--follow]` | Stream the running tenant's runtime logs (last 200 lines by default). |
|
|
62
|
+
|
|
63
|
+
### Resources
|
|
64
|
+
|
|
65
|
+
| Command | What it does |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `canner token list` | List your `cnr_` API tokens. |
|
|
68
|
+
| `canner token create <name>` | Mint a new token. The secret is printed once — copy it immediately. |
|
|
69
|
+
| `canner token revoke <id\|name>` | Revoke a token. |
|
|
70
|
+
| `canner env [list] [--slug X]` | List a project's environment variables. |
|
|
71
|
+
| `canner env set KEY=VALUE [--env production\|preview] [--sensitive] [--slug X]` | Add an environment variable. |
|
|
72
|
+
| `canner env rm <KEY\|id> [--env ...] [--slug X]` | Delete an environment variable. |
|
|
73
|
+
| `canner domains [list] [--slug X]` | List a project's custom domains. |
|
|
74
|
+
| `canner domains add <hostname> [--slug X]` | Attach a custom domain (starts `pending`). |
|
|
75
|
+
| `canner domains verify <hostname\|id> [--slug X]` | Re-check DNS and provision TLS. |
|
|
76
|
+
| `canner domains rm <hostname\|id> [--slug X]` | Detach a custom domain. |
|
|
77
|
+
|
|
78
|
+
## JSON output
|
|
79
|
+
|
|
80
|
+
Add `--json` to any command for machine-readable output — tables and progress lines are suppressed and a single JSON value is printed to stdout, so the result pipes straight into `jq` or an agent. Errors are written to stderr as `{"error":"..."}` with a non-zero exit code. With `--json`, `deploy --follow` does **not** stream the build log (it would corrupt the JSON); poll `canner deployments <id> --json` for the terminal status instead.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
canner projects --json | jq -r '.[].slug'
|
|
84
|
+
canner deploy --json # → {"deployment_id":"…","status":"queued",…}
|
|
85
|
+
canner env list --slug my-app --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This pairs with the REST API directly: every command wraps an endpoint documented in the [API reference](https://canner.ca/developers) (interactive explorer at <https://api.canner.ca/docs>, OpenAPI spec at <https://api.canner.ca/openapi.json>).
|
|
42
89
|
|
|
43
90
|
## What gets uploaded
|
|
44
91
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
27
27
|
import { stdin as input, stdout as output } from 'node:process';
|
|
28
28
|
import { create as tarCreate } from 'tar';
|
|
29
29
|
|
|
30
|
-
const VERSION = '0.
|
|
30
|
+
const VERSION = '0.3.0';
|
|
31
31
|
const DEFAULT_API_BASE = 'https://api.canner.ca';
|
|
32
32
|
const CONFIG_DIR = join(homedir(), '.canner');
|
|
33
33
|
const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
|
|
@@ -61,16 +61,33 @@ const green = c('32');
|
|
|
61
61
|
const yellow = c('33');
|
|
62
62
|
const cyan = c('36');
|
|
63
63
|
|
|
64
|
+
// Machine-readable output mode (`--json`). Set once in main from argv. In JSON
|
|
65
|
+
// mode every command prints a single JSON value to stdout and nothing else, so
|
|
66
|
+
// the output pipes straight into `jq` or an agent; errors become
|
|
67
|
+
// {"error":"..."} on stderr with a non-zero exit. Human-facing helpers
|
|
68
|
+
// (info/muted) go silent so they can't corrupt the JSON stream.
|
|
69
|
+
let JSON_MODE = false;
|
|
70
|
+
|
|
71
|
+
function emitJSON(value) {
|
|
72
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
function die(msg, code = 1) {
|
|
65
|
-
|
|
76
|
+
if (JSON_MODE) {
|
|
77
|
+
process.stderr.write(JSON.stringify({ error: String(msg) }) + '\n');
|
|
78
|
+
} else {
|
|
79
|
+
process.stderr.write(`${red('error:')} ${msg}\n`);
|
|
80
|
+
}
|
|
66
81
|
process.exit(code);
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
function info(msg) {
|
|
85
|
+
if (JSON_MODE) return;
|
|
70
86
|
process.stdout.write(`${msg}\n`);
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
function muted(msg) {
|
|
90
|
+
if (JSON_MODE) return;
|
|
74
91
|
process.stdout.write(`${dim(msg)}\n`);
|
|
75
92
|
}
|
|
76
93
|
|
|
@@ -275,19 +292,23 @@ async function cmdWhoami() {
|
|
|
275
292
|
if (!me?.account) {
|
|
276
293
|
die('authenticated but /auth/me returned no account.');
|
|
277
294
|
}
|
|
278
|
-
info(`${bold(me.account.email)}`);
|
|
279
|
-
muted(`api: ${creds.apiBase}`);
|
|
280
295
|
// Projects count from /me/plan (cheap call we already have).
|
|
296
|
+
let plan = null;
|
|
281
297
|
try {
|
|
282
|
-
|
|
283
|
-
if (plan?.plan) {
|
|
284
|
-
muted(
|
|
285
|
-
`plan: ${plan.plan.display_name} (${plan.project_count}/${plan.project_count_limit} projects)`
|
|
286
|
-
);
|
|
287
|
-
}
|
|
298
|
+
plan = await apiJSON(creds, 'GET', '/me/plan');
|
|
288
299
|
} catch {
|
|
289
300
|
// /me/plan is optional context; not fatal if it fails.
|
|
290
301
|
}
|
|
302
|
+
if (JSON_MODE) {
|
|
303
|
+
return emitJSON({ account: me.account, api_base: creds.apiBase, plan: plan ?? null });
|
|
304
|
+
}
|
|
305
|
+
info(`${bold(me.account.email)}`);
|
|
306
|
+
muted(`api: ${creds.apiBase}`);
|
|
307
|
+
if (plan?.plan) {
|
|
308
|
+
muted(
|
|
309
|
+
`plan: ${plan.plan.display_name} (${plan.project_count}/${plan.project_count_limit} projects)`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
async function cmdInit(args) {
|
|
@@ -325,6 +346,506 @@ async function cmdInit(args) {
|
|
|
325
346
|
muted(`Wrote ${PROJECT_CONFIG_FILE}. Run \`canner deploy\` to ship.`);
|
|
326
347
|
}
|
|
327
348
|
|
|
349
|
+
// Resolve a project slug from --slug flag → canner.json → error. Used by
|
|
350
|
+
// every command that needs one. Returns the slug string (never null).
|
|
351
|
+
async function resolveSlug(args) {
|
|
352
|
+
if (args.slug) return args.slug;
|
|
353
|
+
const cfg = await loadProjectConfig(process.cwd());
|
|
354
|
+
if (cfg?.slug) return cfg.slug;
|
|
355
|
+
die(`no project slug. Pass --slug=X or run \`canner init\` to create ${PROJECT_CONFIG_FILE}.`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Format an ISO timestamp as a short relative-time string (e.g. "2m ago",
|
|
359
|
+
// "3h ago", "5d ago"). Falls back to absolute YYYY-MM-DD for dates > 30d.
|
|
360
|
+
function relTime(iso) {
|
|
361
|
+
if (!iso) return dim('—');
|
|
362
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
363
|
+
const s = Math.floor(ms / 1000);
|
|
364
|
+
if (s < 60) return `${s}s ago`;
|
|
365
|
+
const m = Math.floor(s / 60);
|
|
366
|
+
if (m < 60) return `${m}m ago`;
|
|
367
|
+
const h = Math.floor(m / 60);
|
|
368
|
+
if (h < 24) return `${h}h ago`;
|
|
369
|
+
const d = Math.floor(h / 24);
|
|
370
|
+
if (d < 30) return `${d}d ago`;
|
|
371
|
+
return iso.slice(0, 10);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Right-pad a string to width N, ANSI-stripped for length math.
|
|
375
|
+
function pad(s, n) {
|
|
376
|
+
const visible = String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
377
|
+
return String(s) + ' '.repeat(Math.max(0, n - visible.length));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Color a deployment status string for table output.
|
|
381
|
+
function colorStatus(s) {
|
|
382
|
+
if (s === 'live') return green(s);
|
|
383
|
+
if (s === 'failed') return red(s);
|
|
384
|
+
if (s === 'queued' || s === 'building' || s === 'deploying') return yellow(s);
|
|
385
|
+
return dim(s);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// canner projects — list owned + team projects
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
async function cmdProjects() {
|
|
392
|
+
const creds = await loadCredentials();
|
|
393
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
394
|
+
const body = await apiJSON(creds, 'GET', '/projects');
|
|
395
|
+
const rows = body?.projects ?? body ?? [];
|
|
396
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
397
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
398
|
+
muted('no projects yet. Run `canner init` to create one.');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
info(`${pad(bold('SLUG'), 24)}${pad(bold('STATUS'), 14)}${pad(bold('URL'), 40)}${bold('LAST REQUEST')}`);
|
|
402
|
+
for (const p of rows) {
|
|
403
|
+
const url = `https://${p.slug}.app.canner.ca`;
|
|
404
|
+
const lastReq = relTime(p.last_request_at);
|
|
405
|
+
info(`${pad(p.slug, 24)}${pad(colorStatus(p.status), 14)}${pad(dim(url), 40)}${dim(lastReq)}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// canner status [--slug X] — single project detail
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
async function cmdStatus(args) {
|
|
413
|
+
const creds = await loadCredentials();
|
|
414
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
415
|
+
const slug = await resolveSlug(args);
|
|
416
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}`);
|
|
417
|
+
const p = body?.project ?? body;
|
|
418
|
+
if (!p?.slug) die('unexpected response from /projects/:slug');
|
|
419
|
+
if (JSON_MODE) return emitJSON(p);
|
|
420
|
+
info(`${bold(p.slug)} ${dim(`(${p.name ?? ''})`)}`);
|
|
421
|
+
info(` status: ${colorStatus(p.status)}${p.is_cold ? dim(' (cold)') : ''}`);
|
|
422
|
+
info(` url: ${cyan(`https://${p.slug}.app.canner.ca`)}`);
|
|
423
|
+
info(` source: ${p.is_connected ? `${dim('github')} ${p.repo_url ?? ''} (branch ${p.repo_branch})` : dim('upload-only')}`);
|
|
424
|
+
info(` memory: ${p.memory_limit_mb}M${p.always_warm ? ` ${dim('(always-warm)')}` : ''}`);
|
|
425
|
+
info(` last request: ${relTime(p.last_request_at)}`);
|
|
426
|
+
info(` updated: ${relTime(p.updated_at)}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// canner deployments [--slug X] — list
|
|
431
|
+
// canner deployments <id> [--slug X] [--follow] — view one (optionally tail)
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
async function cmdDeployments(args) {
|
|
434
|
+
const creds = await loadCredentials();
|
|
435
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
436
|
+
const slug = await resolveSlug(args);
|
|
437
|
+
const id = args._[0];
|
|
438
|
+
if (id) return viewDeployment(creds, slug, id, args.follow === true);
|
|
439
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
440
|
+
const rows = body?.deployments ?? [];
|
|
441
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
442
|
+
if (rows.length === 0) {
|
|
443
|
+
muted('no deployments yet.');
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('STATUS'), 14)}${pad(bold('WHEN'), 14)}${pad(bold('DURATION'), 12)}${bold('COMMIT')}`);
|
|
447
|
+
for (const d of rows.slice(0, 20)) {
|
|
448
|
+
const sha = (d.commit_sha || '').slice(0, 7) || dim('—');
|
|
449
|
+
const msg = (d.commit_message || '').split('\n')[0].slice(0, 60);
|
|
450
|
+
const dur = d.build_duration_ms != null ? `${Math.round(d.build_duration_ms / 1000)}s` : dim('—');
|
|
451
|
+
const star = d.is_current ? green('●') : ' ';
|
|
452
|
+
info(`${star} ${pad(dim(d.id.slice(0, 8)), 10)}${pad(colorStatus(d.status), 14)}${pad(dim(relTime(d.created_at)), 14)}${pad(dur, 12)}${sha} ${dim(msg)}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function viewDeployment(creds, slug, idPrefix, follow) {
|
|
457
|
+
// Allow the user to pass an 8-char prefix — disambiguate against the list.
|
|
458
|
+
let fullId = idPrefix;
|
|
459
|
+
if (idPrefix.length < 36) {
|
|
460
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
461
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(idPrefix));
|
|
462
|
+
if (matches.length === 0) die(`no deployment matching "${idPrefix}"`);
|
|
463
|
+
if (matches.length > 1) die(`ambiguous "${idPrefix}" — matches ${matches.length} deployments`);
|
|
464
|
+
fullId = matches[0].id;
|
|
465
|
+
}
|
|
466
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments/${fullId}`);
|
|
467
|
+
const d = body?.deployment;
|
|
468
|
+
if (!d) die('deployment not found');
|
|
469
|
+
if (JSON_MODE) return emitJSON(d);
|
|
470
|
+
info(`${bold(d.id.slice(0, 8))}${dim(d.id.slice(8))} ${colorStatus(d.status)}`);
|
|
471
|
+
if (d.commit_sha) info(` ${dim('commit')} ${d.commit_sha.slice(0, 12)} ${dim((d.commit_message || '').split('\n')[0])}`);
|
|
472
|
+
if (d.commit_author) info(` ${dim('author')} ${d.commit_author}`);
|
|
473
|
+
info(` ${dim('queued')} ${relTime(d.created_at)}`);
|
|
474
|
+
if (d.deployed_at) info(` ${dim('done')} ${relTime(d.deployed_at)}`);
|
|
475
|
+
if (d.error_message) info(` ${dim('error')} ${red(d.error_message)}`);
|
|
476
|
+
if (d.build_log) {
|
|
477
|
+
info(dim('─── build log ───────────────────────────────────'));
|
|
478
|
+
process.stdout.write(d.build_log);
|
|
479
|
+
if (!d.build_log.endsWith('\n')) process.stdout.write('\n');
|
|
480
|
+
}
|
|
481
|
+
if (follow && !['live', 'failed', 'superseded'].includes(d.status)) {
|
|
482
|
+
info(dim('─── streaming (still in progress) ────────────────'));
|
|
483
|
+
const code = await tailDeploymentLogs(creds, slug, fullId);
|
|
484
|
+
process.exit(code);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// canner logs [--slug X] [--lines N] [--follow]
|
|
490
|
+
// Stream the live tenant runtime logs via SSE. Without --follow, exits
|
|
491
|
+
// after the initial history burst settles (~1s quiet window).
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
async function cmdLogs(args) {
|
|
494
|
+
const creds = await loadCredentials();
|
|
495
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
496
|
+
const slug = await resolveSlug(args);
|
|
497
|
+
const lines = Number(args.lines) || 200;
|
|
498
|
+
const url = `${creds.apiBase}/projects/${encodeURIComponent(slug)}/runtime-logs?lines=${lines}`;
|
|
499
|
+
const res = await fetch(url, {
|
|
500
|
+
headers: { Authorization: `Bearer ${creds.token}`, Accept: 'text/event-stream' },
|
|
501
|
+
}).catch((err) => die(`could not open log stream: ${err.message}`));
|
|
502
|
+
if (!res.ok || !res.body) die(`log stream returned ${res.status} ${res.statusText}`);
|
|
503
|
+
|
|
504
|
+
const decoder = new TextDecoder('utf-8');
|
|
505
|
+
let buffer = '';
|
|
506
|
+
let quietTimer = null;
|
|
507
|
+
const armQuiet = () => {
|
|
508
|
+
if (!args.follow) {
|
|
509
|
+
clearTimeout(quietTimer);
|
|
510
|
+
// 1.5s with no new data after the history burst → assume we're caught up.
|
|
511
|
+
quietTimer = setTimeout(() => process.exit(0), 1500);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
armQuiet();
|
|
515
|
+
|
|
516
|
+
for await (const chunk of res.body) {
|
|
517
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
518
|
+
let idx;
|
|
519
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
520
|
+
const raw = buffer.slice(0, idx);
|
|
521
|
+
buffer = buffer.slice(idx + 2);
|
|
522
|
+
if (raw.startsWith(':')) continue; // heartbeat
|
|
523
|
+
let event = 'message';
|
|
524
|
+
let data = '';
|
|
525
|
+
for (const line of raw.split('\n')) {
|
|
526
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
527
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
528
|
+
}
|
|
529
|
+
if (!data) continue;
|
|
530
|
+
let parsed;
|
|
531
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
532
|
+
if (event === 'log') {
|
|
533
|
+
// runtime-logs uses {line:...}, build-logs uses {log:...} — handle both.
|
|
534
|
+
const text = parsed.line ?? parsed.log;
|
|
535
|
+
if (text) process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
|
|
536
|
+
armQuiet();
|
|
537
|
+
} else if (event === 'done') {
|
|
538
|
+
process.exit(0);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// canner cancel [<id>] [--slug X]
|
|
546
|
+
// Cancel an in-flight build. With no id, finds the latest non-terminal
|
|
547
|
+
// deployment for the project and cancels it.
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
async function cmdCancel(args) {
|
|
550
|
+
const creds = await loadCredentials();
|
|
551
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
552
|
+
const slug = await resolveSlug(args);
|
|
553
|
+
let id = args._[0];
|
|
554
|
+
if (!id) {
|
|
555
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
556
|
+
const inflight = (body?.deployments ?? []).find((d) =>
|
|
557
|
+
['queued', 'building', 'deploying'].includes(d.status)
|
|
558
|
+
);
|
|
559
|
+
if (!inflight) die('no in-flight deployment to cancel. Pass an id to cancel a specific one.');
|
|
560
|
+
id = inflight.id;
|
|
561
|
+
} else if (id.length < 36) {
|
|
562
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
563
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(id));
|
|
564
|
+
if (matches.length === 0) die(`no deployment matching "${id}"`);
|
|
565
|
+
if (matches.length > 1) die(`ambiguous "${id}" — matches ${matches.length} deployments`);
|
|
566
|
+
id = matches[0].id;
|
|
567
|
+
}
|
|
568
|
+
await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deployments/${id}/cancel`);
|
|
569
|
+
if (JSON_MODE) return emitJSON({ ok: true, cancelled: id });
|
|
570
|
+
info(`${green('cancelled')} ${dim(id.slice(0, 8))}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// canner token — list API tokens
|
|
575
|
+
// canner token create <name> — mint a token (plaintext printed once)
|
|
576
|
+
// canner token revoke <id|prefix> — revoke a token
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
async function cmdToken(args) {
|
|
579
|
+
const creds = await loadCredentials();
|
|
580
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
581
|
+
const sub = args._[0] ?? 'list';
|
|
582
|
+
|
|
583
|
+
if (sub === 'list') {
|
|
584
|
+
const body = await apiJSON(creds, 'GET', '/tokens');
|
|
585
|
+
const rows = body?.tokens ?? [];
|
|
586
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
587
|
+
if (rows.length === 0) { muted('no API tokens. Create one with `canner token create <name>`.'); return; }
|
|
588
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('NAME'), 28)}${pad(bold('PREFIX'), 18)}${bold('LAST USED')}`);
|
|
589
|
+
for (const t of rows) {
|
|
590
|
+
info(`${pad(dim(t.id.slice(0, 8)), 12)}${pad(t.name ?? dim('—'), 28)}${pad(dim(`${t.prefix}…`), 18)}${dim(relTime(t.last_used_at))}`);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (sub === 'create') {
|
|
596
|
+
const name = args._[1] || (typeof args.name === 'string' ? args.name : null);
|
|
597
|
+
if (!name) die('usage: canner token create <name>', 2);
|
|
598
|
+
const tok = await apiJSON(creds, 'POST', '/tokens', { name });
|
|
599
|
+
if (!tok?.token) die('token creation returned no secret.');
|
|
600
|
+
if (JSON_MODE) return emitJSON(tok);
|
|
601
|
+
info(`${green('created')} ${bold(tok.name)}`);
|
|
602
|
+
info(tok.token);
|
|
603
|
+
muted('Copy this now — the secret is not shown again.');
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (sub === 'revoke') {
|
|
608
|
+
const ref = args._[1];
|
|
609
|
+
if (!ref) die('usage: canner token revoke <id|prefix|name>', 2);
|
|
610
|
+
const id = await resolveTokenId(creds, ref);
|
|
611
|
+
await apiJSON(creds, 'DELETE', `/tokens/${encodeURIComponent(id)}`);
|
|
612
|
+
if (JSON_MODE) return emitJSON({ ok: true, revoked: id });
|
|
613
|
+
info(`${green('revoked')} ${dim(id.slice(0, 8))}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
die(`unknown token subcommand "${sub}". Use: list | create | revoke.`, 2);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function resolveTokenId(creds, ref) {
|
|
621
|
+
if (ref.length >= 36) return ref; // full UUID
|
|
622
|
+
const body = await apiJSON(creds, 'GET', '/tokens');
|
|
623
|
+
const rows = body?.tokens ?? [];
|
|
624
|
+
const matches = rows.filter(
|
|
625
|
+
(t) => t.id.startsWith(ref) || t.name === ref || (t.prefix && t.prefix.startsWith(ref))
|
|
626
|
+
);
|
|
627
|
+
if (matches.length === 0) die(`no token matching "${ref}"`);
|
|
628
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} tokens`);
|
|
629
|
+
return matches[0].id;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// canner env [--slug X] — list env vars
|
|
634
|
+
// canner env set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
|
|
635
|
+
// canner env rm <KEY|id> [--env ...] [--slug X] — delete an env var
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
async function cmdEnv(args) {
|
|
638
|
+
const creds = await loadCredentials();
|
|
639
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
640
|
+
const slug = await resolveSlug(args);
|
|
641
|
+
const sub = args._[0] ?? 'list';
|
|
642
|
+
const base = `/projects/${encodeURIComponent(slug)}/env-vars`;
|
|
643
|
+
|
|
644
|
+
if (sub === 'list') {
|
|
645
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
646
|
+
const rows = body?.env_vars ?? [];
|
|
647
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
648
|
+
if (rows.length === 0) { muted('no environment variables.'); return; }
|
|
649
|
+
info(`${pad(bold('KEY'), 28)}${pad(bold('ENV'), 14)}${pad(bold('SENSITIVE'), 12)}${bold('VALUE')}`);
|
|
650
|
+
for (const v of rows) {
|
|
651
|
+
const val = v.is_sensitive ? dim('••••••') : (v.value ?? '').split('\n')[0].slice(0, 48);
|
|
652
|
+
info(`${pad(v.key, 28)}${pad(dim(v.environment ?? 'all'), 14)}${pad(v.is_sensitive ? yellow('yes') : dim('no'), 12)}${dim(val)}`);
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (sub === 'set') {
|
|
658
|
+
const kv = args._[1];
|
|
659
|
+
if (!kv || !kv.includes('=')) die('usage: canner env set KEY=VALUE', 2);
|
|
660
|
+
const eq = kv.indexOf('=');
|
|
661
|
+
const key = kv.slice(0, eq);
|
|
662
|
+
const value = kv.slice(eq + 1);
|
|
663
|
+
const environment = normalizeEnvScope(args.env);
|
|
664
|
+
const isSensitive = args.sensitive === true;
|
|
665
|
+
const result = await apiJSON(creds, 'POST', base, {
|
|
666
|
+
key, value, environment, is_sensitive: isSensitive,
|
|
667
|
+
});
|
|
668
|
+
if (JSON_MODE) return emitJSON(result);
|
|
669
|
+
if ((result?.created ?? []).length > 0) {
|
|
670
|
+
info(`${green('set')} ${bold(key)}${environment ? dim(` (${environment})`) : ''}`);
|
|
671
|
+
} else {
|
|
672
|
+
// A duplicate (same key+scope) is skipped, not overwritten — surface that
|
|
673
|
+
// so the user isn't surprised the value didn't change.
|
|
674
|
+
die(`${key} already exists for that environment. Delete it first (canner env rm ${key}), then set again.`);
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
680
|
+
const ref = args._[1];
|
|
681
|
+
if (!ref) die('usage: canner env rm <KEY|id>', 2);
|
|
682
|
+
const id = await resolveEnvVarId(creds, base, ref, normalizeEnvScope(args.env));
|
|
683
|
+
await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
|
|
684
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
|
|
685
|
+
info(`${green('deleted')} ${bold(ref)}`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
die(`unknown env subcommand "${sub}". Use: list | set | rm.`, 2);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Map a --env flag to the API's environment field. Accepts production/preview;
|
|
693
|
+
// anything else (or absent) means "all environments" (null).
|
|
694
|
+
function normalizeEnvScope(flag) {
|
|
695
|
+
if (flag === 'production' || flag === 'preview') return flag;
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function resolveEnvVarId(creds, base, ref, environment) {
|
|
700
|
+
if (ref.length >= 36 && ref.includes('-')) return ref; // looks like a UUID
|
|
701
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
702
|
+
const rows = body?.env_vars ?? [];
|
|
703
|
+
const matches = rows.filter((v) => {
|
|
704
|
+
if (v.id.startsWith(ref)) return true;
|
|
705
|
+
if (v.key !== ref) return false;
|
|
706
|
+
// If a scope was given, narrow to it; otherwise match any scope.
|
|
707
|
+
return environment === null ? true : v.environment === environment;
|
|
708
|
+
});
|
|
709
|
+
if (matches.length === 0) die(`no env var matching "${ref}"`);
|
|
710
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} vars (pass --env to disambiguate)`);
|
|
711
|
+
return matches[0].id;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// canner domains [--slug X] — list custom domains
|
|
716
|
+
// canner domains add <hostname> [--slug X] — attach a domain (pending)
|
|
717
|
+
// canner domains verify <host|id> [--slug X] — re-check DNS + provision TLS
|
|
718
|
+
// canner domains rm <host|id> [--slug X] — detach a domain
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
async function cmdDomains(args) {
|
|
721
|
+
const creds = await loadCredentials();
|
|
722
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
723
|
+
const slug = await resolveSlug(args);
|
|
724
|
+
const sub = args._[0] ?? 'list';
|
|
725
|
+
const base = `/projects/${encodeURIComponent(slug)}/domains`;
|
|
726
|
+
|
|
727
|
+
if (sub === 'list') {
|
|
728
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
729
|
+
const rows = body?.domains ?? [];
|
|
730
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
731
|
+
if (rows.length === 0) {
|
|
732
|
+
muted(`no custom domains. Add one with \`canner domains add <hostname>\`.`);
|
|
733
|
+
if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify\`.`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
info(`${pad(bold('HOSTNAME'), 36)}${pad(bold('STATUS'), 16)}${bold('VERIFIED')}`);
|
|
737
|
+
for (const d of rows) {
|
|
738
|
+
info(`${pad(d.hostname, 36)}${pad(colorDomainStatus(d.status), 16)}${dim(relTime(d.verified_at))}`);
|
|
739
|
+
}
|
|
740
|
+
if (body?.platform_ip) muted(`A record target: ${body.platform_ip}`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (sub === 'add') {
|
|
745
|
+
const hostname = args._[1];
|
|
746
|
+
if (!hostname) die('usage: canner domains add <hostname>', 2);
|
|
747
|
+
const body = await apiJSON(creds, 'POST', base, { hostname });
|
|
748
|
+
if (JSON_MODE) return emitJSON(body);
|
|
749
|
+
const d = body?.domain ?? {};
|
|
750
|
+
info(`${green('added')} ${bold(d.hostname ?? hostname)} ${dim(`(${d.status ?? 'pending'})`)}`);
|
|
751
|
+
if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify ${hostname}\`.`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (sub === 'verify') {
|
|
756
|
+
const ref = args._[1];
|
|
757
|
+
if (!ref) die('usage: canner domains verify <hostname|id>', 2);
|
|
758
|
+
const id = await resolveDomainId(creds, base, ref);
|
|
759
|
+
const body = await apiJSON(creds, 'POST', `${base}/${encodeURIComponent(id)}/verify`);
|
|
760
|
+
if (JSON_MODE) return emitJSON(body);
|
|
761
|
+
const d = body?.domain ?? {};
|
|
762
|
+
info(`${green('verified')} ${bold(d.hostname ?? ref)} ${dim(`(${d.status ?? 'verified'})`)}`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
767
|
+
const ref = args._[1];
|
|
768
|
+
if (!ref) die('usage: canner domains rm <hostname|id>', 2);
|
|
769
|
+
const id = await resolveDomainId(creds, base, ref);
|
|
770
|
+
await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
|
|
771
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
|
|
772
|
+
info(`${green('removed')} ${bold(ref)}`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
die(`unknown domains subcommand "${sub}". Use: list | add | verify | rm.`, 2);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function colorDomainStatus(s) {
|
|
780
|
+
if (s === 'verified') return green(s);
|
|
781
|
+
if (s === 'failed') return red(s);
|
|
782
|
+
if (s === 'pending') return yellow(s);
|
|
783
|
+
return dim(s);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function resolveDomainId(creds, base, ref) {
|
|
787
|
+
if (ref.length >= 36 && ref.includes('-')) return ref; // UUID
|
|
788
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
789
|
+
const rows = body?.domains ?? [];
|
|
790
|
+
const matches = rows.filter((d) => d.id.startsWith(ref) || d.hostname === ref);
|
|
791
|
+
if (matches.length === 0) die(`no domain matching "${ref}"`);
|
|
792
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} domains`);
|
|
793
|
+
return matches[0].id;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
// canner open [--slug X] [--dashboard]
|
|
798
|
+
// Open the tenant URL (default) or the dashboard project page in the
|
|
799
|
+
// user's default browser.
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
async function cmdOpen(args) {
|
|
802
|
+
const slug = await resolveSlug(args);
|
|
803
|
+
// Strict slug validation — never trust user input as part of a shell arg
|
|
804
|
+
// even though the runner below uses argv (not bash -c). Belt and braces.
|
|
805
|
+
if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) {
|
|
806
|
+
die(`invalid slug "${slug}"`);
|
|
807
|
+
}
|
|
808
|
+
const creds = await loadCredentials();
|
|
809
|
+
const dashboardOrigin = (creds.apiBase || DEFAULT_API_BASE).replace(/^https?:\/\/api\./, 'https://');
|
|
810
|
+
const url = args.dashboard
|
|
811
|
+
? `${dashboardOrigin}/dashboard/projects/${slug}`
|
|
812
|
+
: `https://${slug}.app.canner.ca`;
|
|
813
|
+
info(`opening ${dim(url)}`);
|
|
814
|
+
const { spawn } = await import('node:child_process');
|
|
815
|
+
const platform = process.platform;
|
|
816
|
+
let cmd, cmdArgs;
|
|
817
|
+
if (platform === 'win32') { cmd = 'cmd'; cmdArgs = ['/c', 'start', '""', url]; }
|
|
818
|
+
else if (platform === 'darwin') { cmd = 'open'; cmdArgs = [url]; }
|
|
819
|
+
else { cmd = 'xdg-open'; cmdArgs = [url]; }
|
|
820
|
+
const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
|
|
821
|
+
child.on('error', () => die(`could not launch browser (${cmd} not found)`));
|
|
822
|
+
child.unref();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// canner redeploy [--slug X] [--follow]
|
|
827
|
+
// Trigger a rebuild from the GitHub repo's current HEAD — no local upload.
|
|
828
|
+
// Only works for projects that are GitHub-connected via the Canner Deploy
|
|
829
|
+
// GitHub App. For upload-only projects, use `canner deploy` instead.
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
async function cmdRedeploy(args) {
|
|
832
|
+
const creds = await loadCredentials();
|
|
833
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
834
|
+
const slug = await resolveSlug(args);
|
|
835
|
+
const res = await apiRequest(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deploy`);
|
|
836
|
+
const body = await res.json();
|
|
837
|
+
const deploymentId = body.deployment_id;
|
|
838
|
+
if (!deploymentId) die('redeploy returned no deployment_id.');
|
|
839
|
+
if (JSON_MODE) return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug });
|
|
840
|
+
info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))} ${dim('(rebuild from GitHub HEAD)')}`);
|
|
841
|
+
if (!args.follow) {
|
|
842
|
+
muted(`Tail with: canner deployments ${deploymentId.slice(0, 8)} --follow`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const exitCode = await tailDeploymentLogs(creds, slug, deploymentId);
|
|
846
|
+
process.exit(exitCode);
|
|
847
|
+
}
|
|
848
|
+
|
|
328
849
|
async function cmdDeploy(args) {
|
|
329
850
|
const projectPath = resolve(args._[0] || '.');
|
|
330
851
|
if (!existsSync(projectPath)) {
|
|
@@ -413,6 +934,12 @@ async function cmdDeploy(args) {
|
|
|
413
934
|
if (!deploymentId) {
|
|
414
935
|
die('upload succeeded but the response had no deployment_id.');
|
|
415
936
|
}
|
|
937
|
+
if (JSON_MODE) {
|
|
938
|
+
// JSON mode never streams the SSE build log (it would corrupt the
|
|
939
|
+
// single-value contract). Emit the queued result; the caller can poll
|
|
940
|
+
// `canner deployments <id> --json` for terminal status.
|
|
941
|
+
return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug, files: fileCount, size_mb: Number(sizeMb) });
|
|
942
|
+
}
|
|
416
943
|
info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))}`);
|
|
417
944
|
|
|
418
945
|
if (!args.follow) {
|
|
@@ -549,18 +1076,67 @@ function printHelp() {
|
|
|
549
1076
|
${bold('Usage:')}
|
|
550
1077
|
canner <command> [options]
|
|
551
1078
|
|
|
552
|
-
${bold('
|
|
1079
|
+
${bold('Setup:')}
|
|
553
1080
|
${bold('login')} Store your Canner API token (one-time).
|
|
554
1081
|
${bold('whoami')} Show the account this CLI is signed in as.
|
|
1082
|
+
|
|
1083
|
+
${bold('Projects:')}
|
|
1084
|
+
${bold('projects')} List your projects.
|
|
555
1085
|
${bold('init')} [--slug X] Create a project and write ${cyan(PROJECT_CONFIG_FILE)}.
|
|
1086
|
+
${bold('status')} [--slug X] Show project state (URL, status, source, last request).
|
|
1087
|
+
${bold('open')} [--slug X] [--dashboard]
|
|
1088
|
+
Open the tenant URL (or dashboard page) in your browser.
|
|
1089
|
+
|
|
1090
|
+
${bold('Deploys:')}
|
|
556
1091
|
${bold('deploy')} [path] [--slug X] [--follow]
|
|
557
|
-
Tar the current directory and
|
|
1092
|
+
Tar the current directory and upload. Works for any
|
|
1093
|
+
project (GitHub-connected or upload-only).
|
|
1094
|
+
${bold('redeploy')} [--slug X] [--follow]
|
|
1095
|
+
Trigger a rebuild from the GitHub repo's HEAD without
|
|
1096
|
+
uploading. GitHub-connected projects only.
|
|
1097
|
+
${bold('deployments')} [--slug X]
|
|
1098
|
+
List the project's recent deployments.
|
|
1099
|
+
${bold('deployments')} <id> [--slug X] [--follow]
|
|
1100
|
+
Show one deployment's build log (--follow streams if
|
|
1101
|
+
it's still in progress).
|
|
1102
|
+
${bold('cancel')} [<id>] [--slug X]
|
|
1103
|
+
Abort the in-flight build (or a specific one by id).
|
|
1104
|
+
${bold('logs')} [--slug X] [--lines N] [--follow]
|
|
1105
|
+
Stream the running tenant's runtime logs (last 200
|
|
1106
|
+
lines by default, --follow to keep streaming).
|
|
1107
|
+
|
|
1108
|
+
${bold('Resources:')}
|
|
1109
|
+
${bold('token')} list | create <name> | revoke <id|name>
|
|
1110
|
+
Manage the cnr_ API tokens that authenticate this CLI.
|
|
1111
|
+
${bold('env')} [list] [--slug X]
|
|
1112
|
+
${bold('env')} set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
|
|
1113
|
+
${bold('env')} rm <KEY|id> [--env ...] [--slug X]
|
|
1114
|
+
Manage a project's environment variables.
|
|
1115
|
+
${bold('domains')} [list] [--slug X]
|
|
1116
|
+
${bold('domains')} add <hostname> [--slug X]
|
|
1117
|
+
${bold('domains')} verify <hostname|id> [--slug X]
|
|
1118
|
+
${bold('domains')} rm <hostname|id> [--slug X]
|
|
1119
|
+
Manage a project's custom domains.
|
|
1120
|
+
|
|
1121
|
+
${bold('Global:')}
|
|
1122
|
+
${bold('--json')} Emit machine-readable JSON (for scripts, CI, agents).
|
|
1123
|
+
Suppresses tables + progress; --follow log streaming is
|
|
1124
|
+
skipped. Errors go to stderr as {"error":"..."}.
|
|
558
1125
|
|
|
559
1126
|
${bold('Examples:')}
|
|
560
1127
|
canner login
|
|
561
1128
|
cd my-site && canner init
|
|
562
|
-
canner deploy --follow
|
|
563
|
-
canner
|
|
1129
|
+
canner deploy --follow ${dim('# build to live, streaming logs')}
|
|
1130
|
+
canner redeploy --follow ${dim('# rebuild from GitHub HEAD')}
|
|
1131
|
+
canner deployments ${dim('# list recent deploys')}
|
|
1132
|
+
canner deployments 7bf4862e --follow ${dim('# inspect a specific one')}
|
|
1133
|
+
canner logs --follow ${dim('# tail tenant runtime logs')}
|
|
1134
|
+
canner cancel ${dim('# abort the current build')}
|
|
1135
|
+
canner open --dashboard ${dim('# open dashboard project page')}
|
|
1136
|
+
canner token create ci ${dim('# mint a token named "ci"')}
|
|
1137
|
+
canner env set API_KEY=secret --sensitive ${dim('# add a hidden env var')}
|
|
1138
|
+
canner domains add www.example.com ${dim('# attach a custom domain')}
|
|
1139
|
+
canner projects --json | jq '.[].slug' ${dim('# machine-readable output')}
|
|
564
1140
|
|
|
565
1141
|
${bold('Environment:')}
|
|
566
1142
|
CANNER_TOKEN Override stored token (handy for CI).
|
|
@@ -584,6 +1160,10 @@ Version: ${VERSION}
|
|
|
584
1160
|
const args = parseArgs(process.argv.slice(2));
|
|
585
1161
|
const command = args._.shift();
|
|
586
1162
|
|
|
1163
|
+
// Global --json: machine-readable output for scripts, CI, and agents. Set
|
|
1164
|
+
// before dispatch so every helper (info/muted/die) honors it.
|
|
1165
|
+
JSON_MODE = args.json === true;
|
|
1166
|
+
|
|
587
1167
|
if (args.version || args.v || command === 'version') {
|
|
588
1168
|
process.stdout.write(`canner ${VERSION}\n`);
|
|
589
1169
|
process.exit(0);
|
|
@@ -598,6 +1178,17 @@ const dispatch = {
|
|
|
598
1178
|
whoami: cmdWhoami,
|
|
599
1179
|
init: cmdInit,
|
|
600
1180
|
deploy: cmdDeploy,
|
|
1181
|
+
redeploy: cmdRedeploy,
|
|
1182
|
+
projects: cmdProjects,
|
|
1183
|
+
status: cmdStatus,
|
|
1184
|
+
logs: cmdLogs,
|
|
1185
|
+
deployments: cmdDeployments,
|
|
1186
|
+
cancel: cmdCancel,
|
|
1187
|
+
open: cmdOpen,
|
|
1188
|
+
token: cmdToken,
|
|
1189
|
+
tokens: cmdToken,
|
|
1190
|
+
env: cmdEnv,
|
|
1191
|
+
domains: cmdDomains,
|
|
601
1192
|
};
|
|
602
1193
|
const handler = dispatch[command];
|
|
603
1194
|
if (!handler) {
|