@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.
- package/README.md +27 -0
- package/package.json +1 -1
- 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
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) {
|
|
@@ -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) {
|