@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.
- package/README.md +78 -3
- package/package.json +1 -1
- 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
|
-
-
|
|
125
|
-
- **
|
|
126
|
-
- **
|
|
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
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.
|
|
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
|
-
|
|
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
|
-
|
|
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}.
|
|
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}.
|
|
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}.
|
|
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}.
|
|
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
|
|
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}.
|
|
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) {
|