@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.
Files changed (3) hide show
  1. package/README.md +48 -1
  2. package/package.json +1 -1
  3. 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 deploy [path] [--slug X] [--follow]` | Tar the directory and upload it. `--follow` streams the build log and exits with the build's success code. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Canner deploy CLI — git push from any terminal, no GitHub required",
5
5
  "type": "module",
6
6
  "bin": {
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.1.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
- process.stderr.write(`${red('error:')} ${msg}\n`);
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
- const plan = await apiJSON(creds, 'GET', '/me/plan');
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('Commands:')}
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 ship it.
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 ${dim('# build to live, streaming logs')}
563
- canner deploy ./web --slug my-app ${dim('# deploy a sibling directory')}
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) {