@canner-ca/cli 0.2.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 +27 -0
  2. package/package.json +1 -1
  3. package/src/cli.mjs +296 -10
package/README.md CHANGED
@@ -60,6 +60,33 @@ canner deploy --follow
60
60
  | `canner cancel [<id>] [--slug X]` | Abort the in-flight build (or a specific one by id). |
61
61
  | `canner logs [--slug X] [--lines N] [--follow]` | Stream the running tenant's runtime logs (last 200 lines by default). |
62
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>).
89
+
63
90
  ## What gets uploaded
64
91
 
65
92
  The CLI tars the current directory into a `.tar.gz`, excludes the "build noise" defaults, and streams it to the same upload endpoint the dashboard's drag-drop uses. The server-side build pipeline detects the framework and runs install / build / deploy just like a `git push`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.2.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.2.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) {
@@ -372,6 +393,7 @@ async function cmdProjects() {
372
393
  if (!creds.token) die("not signed in. Run `canner login`.");
373
394
  const body = await apiJSON(creds, 'GET', '/projects');
374
395
  const rows = body?.projects ?? body ?? [];
396
+ if (JSON_MODE) return emitJSON(rows);
375
397
  if (!Array.isArray(rows) || rows.length === 0) {
376
398
  muted('no projects yet. Run `canner init` to create one.');
377
399
  return;
@@ -394,6 +416,7 @@ async function cmdStatus(args) {
394
416
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}`);
395
417
  const p = body?.project ?? body;
396
418
  if (!p?.slug) die('unexpected response from /projects/:slug');
419
+ if (JSON_MODE) return emitJSON(p);
397
420
  info(`${bold(p.slug)} ${dim(`(${p.name ?? ''})`)}`);
398
421
  info(` status: ${colorStatus(p.status)}${p.is_cold ? dim(' (cold)') : ''}`);
399
422
  info(` url: ${cyan(`https://${p.slug}.app.canner.ca`)}`);
@@ -415,6 +438,7 @@ async function cmdDeployments(args) {
415
438
  if (id) return viewDeployment(creds, slug, id, args.follow === true);
416
439
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
417
440
  const rows = body?.deployments ?? [];
441
+ if (JSON_MODE) return emitJSON(rows);
418
442
  if (rows.length === 0) {
419
443
  muted('no deployments yet.');
420
444
  return;
@@ -442,6 +466,7 @@ async function viewDeployment(creds, slug, idPrefix, follow) {
442
466
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments/${fullId}`);
443
467
  const d = body?.deployment;
444
468
  if (!d) die('deployment not found');
469
+ if (JSON_MODE) return emitJSON(d);
445
470
  info(`${bold(d.id.slice(0, 8))}${dim(d.id.slice(8))} ${colorStatus(d.status)}`);
446
471
  if (d.commit_sha) info(` ${dim('commit')} ${d.commit_sha.slice(0, 12)} ${dim((d.commit_message || '').split('\n')[0])}`);
447
472
  if (d.commit_author) info(` ${dim('author')} ${d.commit_author}`);
@@ -541,9 +566,233 @@ async function cmdCancel(args) {
541
566
  id = matches[0].id;
542
567
  }
543
568
  await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deployments/${id}/cancel`);
569
+ if (JSON_MODE) return emitJSON({ ok: true, cancelled: id });
544
570
  info(`${green('cancelled')} ${dim(id.slice(0, 8))}`);
545
571
  }
546
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
+
547
796
  // ---------------------------------------------------------------------------
548
797
  // canner open [--slug X] [--dashboard]
549
798
  // Open the tenant URL (default) or the dashboard project page in the
@@ -587,6 +836,7 @@ async function cmdRedeploy(args) {
587
836
  const body = await res.json();
588
837
  const deploymentId = body.deployment_id;
589
838
  if (!deploymentId) die('redeploy returned no deployment_id.');
839
+ if (JSON_MODE) return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug });
590
840
  info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))} ${dim('(rebuild from GitHub HEAD)')}`);
591
841
  if (!args.follow) {
592
842
  muted(`Tail with: canner deployments ${deploymentId.slice(0, 8)} --follow`);
@@ -684,6 +934,12 @@ async function cmdDeploy(args) {
684
934
  if (!deploymentId) {
685
935
  die('upload succeeded but the response had no deployment_id.');
686
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
+ }
687
943
  info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))}`);
688
944
 
689
945
  if (!args.follow) {
@@ -849,6 +1105,24 @@ ${bold('Deploys:')}
849
1105
  Stream the running tenant's runtime logs (last 200
850
1106
  lines by default, --follow to keep streaming).
851
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":"..."}.
1125
+
852
1126
  ${bold('Examples:')}
853
1127
  canner login
854
1128
  cd my-site && canner init
@@ -859,6 +1133,10 @@ ${bold('Examples:')}
859
1133
  canner logs --follow ${dim('# tail tenant runtime logs')}
860
1134
  canner cancel ${dim('# abort the current build')}
861
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')}
862
1140
 
863
1141
  ${bold('Environment:')}
864
1142
  CANNER_TOKEN Override stored token (handy for CI).
@@ -882,6 +1160,10 @@ Version: ${VERSION}
882
1160
  const args = parseArgs(process.argv.slice(2));
883
1161
  const command = args._.shift();
884
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
+
885
1167
  if (args.version || args.v || command === 'version') {
886
1168
  process.stdout.write(`canner ${VERSION}\n`);
887
1169
  process.exit(0);
@@ -903,6 +1185,10 @@ const dispatch = {
903
1185
  deployments: cmdDeployments,
904
1186
  cancel: cmdCancel,
905
1187
  open: cmdOpen,
1188
+ token: cmdToken,
1189
+ tokens: cmdToken,
1190
+ env: cmdEnv,
1191
+ domains: cmdDomains,
906
1192
  };
907
1193
  const handler = dispatch[command];
908
1194
  if (!handler) {