@canner-ca/cli 0.2.0 → 0.4.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 +78 -3
  2. package/package.json +1 -1
  3. package/src/cli.mjs +352 -16
package/README.md CHANGED
@@ -38,6 +38,7 @@ canner deploy --follow
38
38
  | Command | What it does |
39
39
  |---|---|
40
40
  | `canner login` | Prompt for an API token, store it at `~/.canner/credentials` (mode 0600). |
41
+ | `canner logout` | Delete `~/.canner/credentials` from this machine. |
41
42
  | `canner whoami` | Show the email + plan this CLI is signed in as. |
42
43
 
43
44
  ### Projects
@@ -60,6 +61,33 @@ canner deploy --follow
60
61
  | `canner cancel [<id>] [--slug X]` | Abort the in-flight build (or a specific one by id). |
61
62
  | `canner logs [--slug X] [--lines N] [--follow]` | Stream the running tenant's runtime logs (last 200 lines by default). |
62
63
 
64
+ ### Resources
65
+
66
+ | Command | What it does |
67
+ |---|---|
68
+ | `canner token list` | List your `cnr_` API tokens. |
69
+ | `canner token create <name>` | Mint a new token. The secret is printed once — copy it immediately. |
70
+ | `canner token revoke <id\|name>` | Revoke a token. |
71
+ | `canner env [list] [--slug X]` | List a project's environment variables. |
72
+ | `canner env set KEY=VALUE [--env production\|preview] [--sensitive] [--slug X]` | Add an environment variable. |
73
+ | `canner env rm <KEY\|id> [--env ...] [--slug X]` | Delete an environment variable. |
74
+ | `canner domains [list] [--slug X]` | List a project's custom domains. |
75
+ | `canner domains add <hostname> [--slug X]` | Attach a custom domain (starts `pending`). |
76
+ | `canner domains verify <hostname\|id> [--slug X]` | Re-check DNS and provision TLS. |
77
+ | `canner domains rm <hostname\|id> [--slug X]` | Detach a custom domain. |
78
+
79
+ ## JSON output
80
+
81
+ 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.
82
+
83
+ ```bash
84
+ canner projects --json | jq -r '.[].slug'
85
+ canner deploy --json # → {"deployment_id":"…","status":"queued",…}
86
+ canner env list --slug my-app --json
87
+ ```
88
+
89
+ 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>).
90
+
63
91
  ## What gets uploaded
64
92
 
65
93
  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`.
@@ -121,12 +149,59 @@ canner deploy --follow
121
149
 
122
150
  The CLI uploads under the same per-tier compressed-archive cap as the dashboard:
123
151
 
124
- - **Starter** — 50 MB
125
- - **Basic** — 200 MB
126
- - **Pro** — 500 MB
152
+ <!-- BEGIN:GENERATED:cli-caps -->
153
+ - **Starter** — 100 MB
154
+ - **Live** — 500 MB
155
+ - **Dedicated** — 1 GB
156
+ <!-- END:GENERATED:cli-caps -->
127
157
 
128
158
  If your project is bigger than that compressed, drop heavier directories into `.cannerignore` or upgrade your plan. `node_modules` is already excluded; the typical thing that pushes a project over the cap is a `public/` folder full of media.
129
159
 
160
+ ## What this CLI stores and sends
161
+
162
+ **Written to your machine — two files, both plain text you can read:**
163
+
164
+ | Path | Contents | Written by |
165
+ |---|---|---|
166
+ | `~/.canner/credentials` | `{ api_base, token }`, mode `0600` | `canner login` |
167
+ | `./canner.json` | Your project slug, in the project directory | `canner init` |
168
+
169
+ Nothing else is written. No system settings, shell profiles, PATH entries, or
170
+ startup items are modified.
171
+
172
+ **Sent over the network:** requests go only to `https://api.canner.ca` (or
173
+ whatever `CANNER_API_BASE` is set to). `canner deploy` uploads a tar of your
174
+ project directory — minus `node_modules`, `.git`, build output, and anything in
175
+ your `.cannerignore` — so that we can build and host it.
176
+
177
+ **Telemetry: none.** There is no usage tracking, analytics, crash reporting, or
178
+ phone-home of any kind. The CLI makes no network request you didn't ask for by
179
+ running a command.
180
+
181
+ ## Removing the CLI
182
+
183
+ ```bash
184
+ canner logout # delete ~/.canner/credentials
185
+ npm uninstall -g @canner-ca/cli # remove the package
186
+ ```
187
+
188
+ `canner logout` removes the stored credential from **this machine only**. The
189
+ token itself stays valid — the same token may be in use in CI or on another
190
+ machine, so logging out on your laptop deliberately doesn't kill it. To revoke
191
+ it everywhere, run `canner token revoke <name>` before logging out, or revoke it
192
+ from your dashboard's API tokens page.
193
+
194
+ If you've already uninstalled the package and want to clear the leftover
195
+ credential, delete `~/.canner/credentials` by hand. Removing it affects nothing
196
+ else; `canner.json` in a project directory is just a slug and is safe to keep or
197
+ delete.
198
+
199
+ ## Terms
200
+
201
+ Use of the CLI and the Canner service is governed by the
202
+ [Terms of Service](https://canner.ca/terms) and the
203
+ [Privacy Policy](https://canner.ca/privacy).
204
+
130
205
  ## License
131
206
 
132
207
  MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // Commands:
11
11
  // canner login store an API token
12
+ // canner logout remove the stored token from this machine
12
13
  // canner whoami show the account this CLI is signed in as
13
14
  // canner init [--slug X] create a project, write canner.json
14
15
  // canner deploy [path] [--slug X] [--follow]
@@ -27,7 +28,7 @@ import { randomUUID } from 'node:crypto';
27
28
  import { stdin as input, stdout as output } from 'node:process';
28
29
  import { create as tarCreate } from 'tar';
29
30
 
30
- const VERSION = '0.2.0';
31
+ const VERSION = '0.4.0';
31
32
  const DEFAULT_API_BASE = 'https://api.canner.ca';
32
33
  const CONFIG_DIR = join(homedir(), '.canner');
33
34
  const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
@@ -61,16 +62,33 @@ const green = c('32');
61
62
  const yellow = c('33');
62
63
  const cyan = c('36');
63
64
 
65
+ // Machine-readable output mode (`--json`). Set once in main from argv. In JSON
66
+ // mode every command prints a single JSON value to stdout and nothing else, so
67
+ // the output pipes straight into `jq` or an agent; errors become
68
+ // {"error":"..."} on stderr with a non-zero exit. Human-facing helpers
69
+ // (info/muted) go silent so they can't corrupt the JSON stream.
70
+ let JSON_MODE = false;
71
+
72
+ function emitJSON(value) {
73
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
74
+ }
75
+
64
76
  function die(msg, code = 1) {
65
- process.stderr.write(`${red('error:')} ${msg}\n`);
77
+ if (JSON_MODE) {
78
+ process.stderr.write(JSON.stringify({ error: String(msg) }) + '\n');
79
+ } else {
80
+ process.stderr.write(`${red('error:')} ${msg}\n`);
81
+ }
66
82
  process.exit(code);
67
83
  }
68
84
 
69
85
  function info(msg) {
86
+ if (JSON_MODE) return;
70
87
  process.stdout.write(`${msg}\n`);
71
88
  }
72
89
 
73
90
  function muted(msg) {
91
+ if (JSON_MODE) return;
74
92
  process.stdout.write(`${dim(msg)}\n`);
75
93
  }
76
94
 
@@ -275,19 +293,70 @@ async function cmdWhoami() {
275
293
  if (!me?.account) {
276
294
  die('authenticated but /auth/me returned no account.');
277
295
  }
278
- info(`${bold(me.account.email)}`);
279
- muted(`api: ${creds.apiBase}`);
280
296
  // Projects count from /me/plan (cheap call we already have).
297
+ let plan = null;
281
298
  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
- }
299
+ plan = await apiJSON(creds, 'GET', '/me/plan');
288
300
  } catch {
289
301
  // /me/plan is optional context; not fatal if it fails.
290
302
  }
303
+ if (JSON_MODE) {
304
+ return emitJSON({ account: me.account, api_base: creds.apiBase, plan: plan ?? null });
305
+ }
306
+ info(`${bold(me.account.email)}`);
307
+ muted(`api: ${creds.apiBase}`);
308
+ if (plan?.plan) {
309
+ muted(
310
+ `plan: ${plan.plan.display_name} (${plan.project_count}/${plan.project_count_limit} projects)`
311
+ );
312
+ }
313
+ }
314
+
315
+ // Remove the stored credential from this machine.
316
+ //
317
+ // `npm uninstall -g @canner-ca/cli` leaves ~/.canner/credentials behind — a
318
+ // live bearer token sitting in the user's home directory with no obvious way
319
+ // to clear it. That's the gap this closes: uninstalling should not leave a
320
+ // working credential on disk.
321
+ //
322
+ // Deliberately local-only. We delete the file we wrote; we do NOT revoke the
323
+ // token server-side, because the same token may be in use by CI, the MCP
324
+ // server, or another machine, and silently killing those from a `logout` on
325
+ // one laptop would be a surprising side effect. The output says so plainly and
326
+ // points at `canner token revoke` for people who do want it gone everywhere.
327
+ async function cmdLogout() {
328
+ const creds = await loadCredentials();
329
+ let removed = false;
330
+ try {
331
+ await unlink(CREDENTIALS_PATH);
332
+ removed = true;
333
+ } catch (err) {
334
+ // ENOENT just means there was nothing stored — that's a success, not an
335
+ // error. Anything else (EACCES, EPERM) the user needs to know about.
336
+ if (err?.code !== 'ENOENT') {
337
+ die(`could not remove ${CREDENTIALS_PATH}: ${err.message}`);
338
+ }
339
+ }
340
+ if (JSON_MODE) {
341
+ return emitJSON({ removed, path: CREDENTIALS_PATH });
342
+ }
343
+ if (!removed) {
344
+ info('Nothing to remove — no stored credential on this machine.');
345
+ // A token in the environment survives logout, and a user who doesn't know
346
+ // that will think they've signed out when they haven't.
347
+ if (process.env.CANNER_TOKEN) {
348
+ muted('Note: CANNER_TOKEN is set in your environment and still applies.');
349
+ }
350
+ return;
351
+ }
352
+ info(`${green('Signed out.')} Removed ${CREDENTIALS_PATH}`);
353
+ if (creds.token) {
354
+ muted('The token itself is still valid. To revoke it everywhere, run');
355
+ muted(` canner token revoke <name> ${dim('(or use the dashboard)')}`);
356
+ }
357
+ if (process.env.CANNER_TOKEN) {
358
+ muted('Note: CANNER_TOKEN is set in your environment and still applies.');
359
+ }
291
360
  }
292
361
 
293
362
  async function cmdInit(args) {
@@ -321,7 +390,7 @@ async function cmdInit(args) {
321
390
  if (!body) die('project creation returned no body.');
322
391
  await writeProjectConfig(cwd, { slug });
323
392
  info(`${green('Created')} ${bold(slug)}`);
324
- muted(`Will be served at https://${slug}.app.canner.ca after the first deploy.`);
393
+ muted(`Will be served at https://${slug}.canner.app after the first deploy.`);
325
394
  muted(`Wrote ${PROJECT_CONFIG_FILE}. Run \`canner deploy\` to ship.`);
326
395
  }
327
396
 
@@ -372,13 +441,14 @@ async function cmdProjects() {
372
441
  if (!creds.token) die("not signed in. Run `canner login`.");
373
442
  const body = await apiJSON(creds, 'GET', '/projects');
374
443
  const rows = body?.projects ?? body ?? [];
444
+ if (JSON_MODE) return emitJSON(rows);
375
445
  if (!Array.isArray(rows) || rows.length === 0) {
376
446
  muted('no projects yet. Run `canner init` to create one.');
377
447
  return;
378
448
  }
379
449
  info(`${pad(bold('SLUG'), 24)}${pad(bold('STATUS'), 14)}${pad(bold('URL'), 40)}${bold('LAST REQUEST')}`);
380
450
  for (const p of rows) {
381
- const url = `https://${p.slug}.app.canner.ca`;
451
+ const url = `https://${p.slug}.canner.app`;
382
452
  const lastReq = relTime(p.last_request_at);
383
453
  info(`${pad(p.slug, 24)}${pad(colorStatus(p.status), 14)}${pad(dim(url), 40)}${dim(lastReq)}`);
384
454
  }
@@ -394,9 +464,10 @@ async function cmdStatus(args) {
394
464
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}`);
395
465
  const p = body?.project ?? body;
396
466
  if (!p?.slug) die('unexpected response from /projects/:slug');
467
+ if (JSON_MODE) return emitJSON(p);
397
468
  info(`${bold(p.slug)} ${dim(`(${p.name ?? ''})`)}`);
398
469
  info(` status: ${colorStatus(p.status)}${p.is_cold ? dim(' (cold)') : ''}`);
399
- info(` url: ${cyan(`https://${p.slug}.app.canner.ca`)}`);
470
+ info(` url: ${cyan(`https://${p.slug}.canner.app`)}`);
400
471
  info(` source: ${p.is_connected ? `${dim('github')} ${p.repo_url ?? ''} (branch ${p.repo_branch})` : dim('upload-only')}`);
401
472
  info(` memory: ${p.memory_limit_mb}M${p.always_warm ? ` ${dim('(always-warm)')}` : ''}`);
402
473
  info(` last request: ${relTime(p.last_request_at)}`);
@@ -415,6 +486,7 @@ async function cmdDeployments(args) {
415
486
  if (id) return viewDeployment(creds, slug, id, args.follow === true);
416
487
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
417
488
  const rows = body?.deployments ?? [];
489
+ if (JSON_MODE) return emitJSON(rows);
418
490
  if (rows.length === 0) {
419
491
  muted('no deployments yet.');
420
492
  return;
@@ -442,6 +514,7 @@ async function viewDeployment(creds, slug, idPrefix, follow) {
442
514
  const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments/${fullId}`);
443
515
  const d = body?.deployment;
444
516
  if (!d) die('deployment not found');
517
+ if (JSON_MODE) return emitJSON(d);
445
518
  info(`${bold(d.id.slice(0, 8))}${dim(d.id.slice(8))} ${colorStatus(d.status)}`);
446
519
  if (d.commit_sha) info(` ${dim('commit')} ${d.commit_sha.slice(0, 12)} ${dim((d.commit_message || '').split('\n')[0])}`);
447
520
  if (d.commit_author) info(` ${dim('author')} ${d.commit_author}`);
@@ -541,9 +614,233 @@ async function cmdCancel(args) {
541
614
  id = matches[0].id;
542
615
  }
543
616
  await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deployments/${id}/cancel`);
617
+ if (JSON_MODE) return emitJSON({ ok: true, cancelled: id });
544
618
  info(`${green('cancelled')} ${dim(id.slice(0, 8))}`);
545
619
  }
546
620
 
621
+ // ---------------------------------------------------------------------------
622
+ // canner token — list API tokens
623
+ // canner token create <name> — mint a token (plaintext printed once)
624
+ // canner token revoke <id|prefix> — revoke a token
625
+ // ---------------------------------------------------------------------------
626
+ async function cmdToken(args) {
627
+ const creds = await loadCredentials();
628
+ if (!creds.token) die("not signed in. Run `canner login`.");
629
+ const sub = args._[0] ?? 'list';
630
+
631
+ if (sub === 'list') {
632
+ const body = await apiJSON(creds, 'GET', '/tokens');
633
+ const rows = body?.tokens ?? [];
634
+ if (JSON_MODE) return emitJSON(rows);
635
+ if (rows.length === 0) { muted('no API tokens. Create one with `canner token create <name>`.'); return; }
636
+ info(`${pad(bold('ID'), 12)}${pad(bold('NAME'), 28)}${pad(bold('PREFIX'), 18)}${bold('LAST USED')}`);
637
+ for (const t of rows) {
638
+ 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))}`);
639
+ }
640
+ return;
641
+ }
642
+
643
+ if (sub === 'create') {
644
+ const name = args._[1] || (typeof args.name === 'string' ? args.name : null);
645
+ if (!name) die('usage: canner token create <name>', 2);
646
+ const tok = await apiJSON(creds, 'POST', '/tokens', { name });
647
+ if (!tok?.token) die('token creation returned no secret.');
648
+ if (JSON_MODE) return emitJSON(tok);
649
+ info(`${green('created')} ${bold(tok.name)}`);
650
+ info(tok.token);
651
+ muted('Copy this now — the secret is not shown again.');
652
+ return;
653
+ }
654
+
655
+ if (sub === 'revoke') {
656
+ const ref = args._[1];
657
+ if (!ref) die('usage: canner token revoke <id|prefix|name>', 2);
658
+ const id = await resolveTokenId(creds, ref);
659
+ await apiJSON(creds, 'DELETE', `/tokens/${encodeURIComponent(id)}`);
660
+ if (JSON_MODE) return emitJSON({ ok: true, revoked: id });
661
+ info(`${green('revoked')} ${dim(id.slice(0, 8))}`);
662
+ return;
663
+ }
664
+
665
+ die(`unknown token subcommand "${sub}". Use: list | create | revoke.`, 2);
666
+ }
667
+
668
+ async function resolveTokenId(creds, ref) {
669
+ if (ref.length >= 36) return ref; // full UUID
670
+ const body = await apiJSON(creds, 'GET', '/tokens');
671
+ const rows = body?.tokens ?? [];
672
+ const matches = rows.filter(
673
+ (t) => t.id.startsWith(ref) || t.name === ref || (t.prefix && t.prefix.startsWith(ref))
674
+ );
675
+ if (matches.length === 0) die(`no token matching "${ref}"`);
676
+ if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} tokens`);
677
+ return matches[0].id;
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // canner env [--slug X] — list env vars
682
+ // canner env set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
683
+ // canner env rm <KEY|id> [--env ...] [--slug X] — delete an env var
684
+ // ---------------------------------------------------------------------------
685
+ async function cmdEnv(args) {
686
+ const creds = await loadCredentials();
687
+ if (!creds.token) die("not signed in. Run `canner login`.");
688
+ const slug = await resolveSlug(args);
689
+ const sub = args._[0] ?? 'list';
690
+ const base = `/projects/${encodeURIComponent(slug)}/env-vars`;
691
+
692
+ if (sub === 'list') {
693
+ const body = await apiJSON(creds, 'GET', base);
694
+ const rows = body?.env_vars ?? [];
695
+ if (JSON_MODE) return emitJSON(rows);
696
+ if (rows.length === 0) { muted('no environment variables.'); return; }
697
+ info(`${pad(bold('KEY'), 28)}${pad(bold('ENV'), 14)}${pad(bold('SENSITIVE'), 12)}${bold('VALUE')}`);
698
+ for (const v of rows) {
699
+ const val = v.is_sensitive ? dim('••••••') : (v.value ?? '').split('\n')[0].slice(0, 48);
700
+ info(`${pad(v.key, 28)}${pad(dim(v.environment ?? 'all'), 14)}${pad(v.is_sensitive ? yellow('yes') : dim('no'), 12)}${dim(val)}`);
701
+ }
702
+ return;
703
+ }
704
+
705
+ if (sub === 'set') {
706
+ const kv = args._[1];
707
+ if (!kv || !kv.includes('=')) die('usage: canner env set KEY=VALUE', 2);
708
+ const eq = kv.indexOf('=');
709
+ const key = kv.slice(0, eq);
710
+ const value = kv.slice(eq + 1);
711
+ const environment = normalizeEnvScope(args.env);
712
+ const isSensitive = args.sensitive === true;
713
+ const result = await apiJSON(creds, 'POST', base, {
714
+ key, value, environment, is_sensitive: isSensitive,
715
+ });
716
+ if (JSON_MODE) return emitJSON(result);
717
+ if ((result?.created ?? []).length > 0) {
718
+ info(`${green('set')} ${bold(key)}${environment ? dim(` (${environment})`) : ''}`);
719
+ } else {
720
+ // A duplicate (same key+scope) is skipped, not overwritten — surface that
721
+ // so the user isn't surprised the value didn't change.
722
+ die(`${key} already exists for that environment. Delete it first (canner env rm ${key}), then set again.`);
723
+ }
724
+ return;
725
+ }
726
+
727
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
728
+ const ref = args._[1];
729
+ if (!ref) die('usage: canner env rm <KEY|id>', 2);
730
+ const id = await resolveEnvVarId(creds, base, ref, normalizeEnvScope(args.env));
731
+ await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
732
+ if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
733
+ info(`${green('deleted')} ${bold(ref)}`);
734
+ return;
735
+ }
736
+
737
+ die(`unknown env subcommand "${sub}". Use: list | set | rm.`, 2);
738
+ }
739
+
740
+ // Map a --env flag to the API's environment field. Accepts production/preview;
741
+ // anything else (or absent) means "all environments" (null).
742
+ function normalizeEnvScope(flag) {
743
+ if (flag === 'production' || flag === 'preview') return flag;
744
+ return null;
745
+ }
746
+
747
+ async function resolveEnvVarId(creds, base, ref, environment) {
748
+ if (ref.length >= 36 && ref.includes('-')) return ref; // looks like a UUID
749
+ const body = await apiJSON(creds, 'GET', base);
750
+ const rows = body?.env_vars ?? [];
751
+ const matches = rows.filter((v) => {
752
+ if (v.id.startsWith(ref)) return true;
753
+ if (v.key !== ref) return false;
754
+ // If a scope was given, narrow to it; otherwise match any scope.
755
+ return environment === null ? true : v.environment === environment;
756
+ });
757
+ if (matches.length === 0) die(`no env var matching "${ref}"`);
758
+ if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} vars (pass --env to disambiguate)`);
759
+ return matches[0].id;
760
+ }
761
+
762
+ // ---------------------------------------------------------------------------
763
+ // canner domains [--slug X] — list custom domains
764
+ // canner domains add <hostname> [--slug X] — attach a domain (pending)
765
+ // canner domains verify <host|id> [--slug X] — re-check DNS + provision TLS
766
+ // canner domains rm <host|id> [--slug X] — detach a domain
767
+ // ---------------------------------------------------------------------------
768
+ async function cmdDomains(args) {
769
+ const creds = await loadCredentials();
770
+ if (!creds.token) die("not signed in. Run `canner login`.");
771
+ const slug = await resolveSlug(args);
772
+ const sub = args._[0] ?? 'list';
773
+ const base = `/projects/${encodeURIComponent(slug)}/domains`;
774
+
775
+ if (sub === 'list') {
776
+ const body = await apiJSON(creds, 'GET', base);
777
+ const rows = body?.domains ?? [];
778
+ if (JSON_MODE) return emitJSON(rows);
779
+ if (rows.length === 0) {
780
+ muted(`no custom domains. Add one with \`canner domains add <hostname>\`.`);
781
+ if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify\`.`);
782
+ return;
783
+ }
784
+ info(`${pad(bold('HOSTNAME'), 36)}${pad(bold('STATUS'), 16)}${bold('VERIFIED')}`);
785
+ for (const d of rows) {
786
+ info(`${pad(d.hostname, 36)}${pad(colorDomainStatus(d.status), 16)}${dim(relTime(d.verified_at))}`);
787
+ }
788
+ if (body?.platform_ip) muted(`A record target: ${body.platform_ip}`);
789
+ return;
790
+ }
791
+
792
+ if (sub === 'add') {
793
+ const hostname = args._[1];
794
+ if (!hostname) die('usage: canner domains add <hostname>', 2);
795
+ const body = await apiJSON(creds, 'POST', base, { hostname });
796
+ if (JSON_MODE) return emitJSON(body);
797
+ const d = body?.domain ?? {};
798
+ info(`${green('added')} ${bold(d.hostname ?? hostname)} ${dim(`(${d.status ?? 'pending'})`)}`);
799
+ if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify ${hostname}\`.`);
800
+ return;
801
+ }
802
+
803
+ if (sub === 'verify') {
804
+ const ref = args._[1];
805
+ if (!ref) die('usage: canner domains verify <hostname|id>', 2);
806
+ const id = await resolveDomainId(creds, base, ref);
807
+ const body = await apiJSON(creds, 'POST', `${base}/${encodeURIComponent(id)}/verify`);
808
+ if (JSON_MODE) return emitJSON(body);
809
+ const d = body?.domain ?? {};
810
+ info(`${green('verified')} ${bold(d.hostname ?? ref)} ${dim(`(${d.status ?? 'verified'})`)}`);
811
+ return;
812
+ }
813
+
814
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
815
+ const ref = args._[1];
816
+ if (!ref) die('usage: canner domains rm <hostname|id>', 2);
817
+ const id = await resolveDomainId(creds, base, ref);
818
+ await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
819
+ if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
820
+ info(`${green('removed')} ${bold(ref)}`);
821
+ return;
822
+ }
823
+
824
+ die(`unknown domains subcommand "${sub}". Use: list | add | verify | rm.`, 2);
825
+ }
826
+
827
+ function colorDomainStatus(s) {
828
+ if (s === 'verified') return green(s);
829
+ if (s === 'failed') return red(s);
830
+ if (s === 'pending') return yellow(s);
831
+ return dim(s);
832
+ }
833
+
834
+ async function resolveDomainId(creds, base, ref) {
835
+ if (ref.length >= 36 && ref.includes('-')) return ref; // UUID
836
+ const body = await apiJSON(creds, 'GET', base);
837
+ const rows = body?.domains ?? [];
838
+ const matches = rows.filter((d) => d.id.startsWith(ref) || d.hostname === ref);
839
+ if (matches.length === 0) die(`no domain matching "${ref}"`);
840
+ if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} domains`);
841
+ return matches[0].id;
842
+ }
843
+
547
844
  // ---------------------------------------------------------------------------
548
845
  // canner open [--slug X] [--dashboard]
549
846
  // Open the tenant URL (default) or the dashboard project page in the
@@ -560,7 +857,7 @@ async function cmdOpen(args) {
560
857
  const dashboardOrigin = (creds.apiBase || DEFAULT_API_BASE).replace(/^https?:\/\/api\./, 'https://');
561
858
  const url = args.dashboard
562
859
  ? `${dashboardOrigin}/dashboard/projects/${slug}`
563
- : `https://${slug}.app.canner.ca`;
860
+ : `https://${slug}.canner.app`;
564
861
  info(`opening ${dim(url)}`);
565
862
  const { spawn } = await import('node:child_process');
566
863
  const platform = process.platform;
@@ -587,6 +884,7 @@ async function cmdRedeploy(args) {
587
884
  const body = await res.json();
588
885
  const deploymentId = body.deployment_id;
589
886
  if (!deploymentId) die('redeploy returned no deployment_id.');
887
+ if (JSON_MODE) return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug });
590
888
  info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))} ${dim('(rebuild from GitHub HEAD)')}`);
591
889
  if (!args.follow) {
592
890
  muted(`Tail with: canner deployments ${deploymentId.slice(0, 8)} --follow`);
@@ -663,7 +961,7 @@ async function cmdDeploy(args) {
663
961
  info(`${dim('packaged')} ${fileCount} files (${sizeMb} MB compressed)`);
664
962
 
665
963
  // Upload. fs.openAsBlob() backs the Blob by the on-disk file lazily —
666
- // bytes only flow when fetch consumes them, so a 500 MB Pro upload
964
+ // bytes only flow when fetch consumes them, so a 500 MB Live upload
667
965
  // doesn't buffer into RAM on the user's machine. Node fetch has no
668
966
  // upload-progress hook as of 2026, so we just print the size we're
669
967
  // sending; the dashboard shows real-time progress for browser uploads.
@@ -684,6 +982,12 @@ async function cmdDeploy(args) {
684
982
  if (!deploymentId) {
685
983
  die('upload succeeded but the response had no deployment_id.');
686
984
  }
985
+ if (JSON_MODE) {
986
+ // JSON mode never streams the SSE build log (it would corrupt the
987
+ // single-value contract). Emit the queued result; the caller can poll
988
+ // `canner deployments <id> --json` for terminal status.
989
+ return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug, files: fileCount, size_mb: Number(sizeMb) });
990
+ }
687
991
  info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))}`);
688
992
 
689
993
  if (!args.follow) {
@@ -755,7 +1059,7 @@ async function tailDeploymentLogs(creds, slug, deploymentId) {
755
1059
  }
756
1060
 
757
1061
  if (lastStatus === 'live') {
758
- info(`\n${green('live')} at https://${slug}.app.canner.ca`);
1062
+ info(`\n${green('live')} at https://${slug}.canner.app`);
759
1063
  return 0;
760
1064
  }
761
1065
  if (lastStatus === 'failed') {
@@ -822,6 +1126,7 @@ ${bold('Usage:')}
822
1126
 
823
1127
  ${bold('Setup:')}
824
1128
  ${bold('login')} Store your Canner API token (one-time).
1129
+ ${bold('logout')} Remove the stored token from this machine.
825
1130
  ${bold('whoami')} Show the account this CLI is signed in as.
826
1131
 
827
1132
  ${bold('Projects:')}
@@ -849,6 +1154,24 @@ ${bold('Deploys:')}
849
1154
  Stream the running tenant's runtime logs (last 200
850
1155
  lines by default, --follow to keep streaming).
851
1156
 
1157
+ ${bold('Resources:')}
1158
+ ${bold('token')} list | create <name> | revoke <id|name>
1159
+ Manage the cnr_ API tokens that authenticate this CLI.
1160
+ ${bold('env')} [list] [--slug X]
1161
+ ${bold('env')} set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
1162
+ ${bold('env')} rm <KEY|id> [--env ...] [--slug X]
1163
+ Manage a project's environment variables.
1164
+ ${bold('domains')} [list] [--slug X]
1165
+ ${bold('domains')} add <hostname> [--slug X]
1166
+ ${bold('domains')} verify <hostname|id> [--slug X]
1167
+ ${bold('domains')} rm <hostname|id> [--slug X]
1168
+ Manage a project's custom domains.
1169
+
1170
+ ${bold('Global:')}
1171
+ ${bold('--json')} Emit machine-readable JSON (for scripts, CI, agents).
1172
+ Suppresses tables + progress; --follow log streaming is
1173
+ skipped. Errors go to stderr as {"error":"..."}.
1174
+
852
1175
  ${bold('Examples:')}
853
1176
  canner login
854
1177
  cd my-site && canner init
@@ -859,6 +1182,10 @@ ${bold('Examples:')}
859
1182
  canner logs --follow ${dim('# tail tenant runtime logs')}
860
1183
  canner cancel ${dim('# abort the current build')}
861
1184
  canner open --dashboard ${dim('# open dashboard project page')}
1185
+ canner token create ci ${dim('# mint a token named "ci"')}
1186
+ canner env set API_KEY=secret --sensitive ${dim('# add a hidden env var')}
1187
+ canner domains add www.example.com ${dim('# attach a custom domain')}
1188
+ canner projects --json | jq '.[].slug' ${dim('# machine-readable output')}
862
1189
 
863
1190
  ${bold('Environment:')}
864
1191
  CANNER_TOKEN Override stored token (handy for CI).
@@ -882,6 +1209,10 @@ Version: ${VERSION}
882
1209
  const args = parseArgs(process.argv.slice(2));
883
1210
  const command = args._.shift();
884
1211
 
1212
+ // Global --json: machine-readable output for scripts, CI, and agents. Set
1213
+ // before dispatch so every helper (info/muted/die) honors it.
1214
+ JSON_MODE = args.json === true;
1215
+
885
1216
  if (args.version || args.v || command === 'version') {
886
1217
  process.stdout.write(`canner ${VERSION}\n`);
887
1218
  process.exit(0);
@@ -893,6 +1224,7 @@ if (!command || args.help || args.h || command === 'help') {
893
1224
 
894
1225
  const dispatch = {
895
1226
  login: cmdLogin,
1227
+ logout: cmdLogout,
896
1228
  whoami: cmdWhoami,
897
1229
  init: cmdInit,
898
1230
  deploy: cmdDeploy,
@@ -903,6 +1235,10 @@ const dispatch = {
903
1235
  deployments: cmdDeployments,
904
1236
  cancel: cmdCancel,
905
1237
  open: cmdOpen,
1238
+ token: cmdToken,
1239
+ tokens: cmdToken,
1240
+ env: cmdEnv,
1241
+ domains: cmdDomains,
906
1242
  };
907
1243
  const handler = dispatch[command];
908
1244
  if (!handler) {