@canner-ca/cli 0.1.0 → 0.2.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 +21 -1
- package/package.json +1 -1
- package/src/cli.mjs +310 -5
package/README.md
CHANGED
|
@@ -33,12 +33,32 @@ canner deploy --follow
|
|
|
33
33
|
|
|
34
34
|
## Commands
|
|
35
35
|
|
|
36
|
+
### Setup
|
|
37
|
+
|
|
36
38
|
| Command | What it does |
|
|
37
39
|
|---|---|
|
|
38
40
|
| `canner login` | Prompt for an API token, store it at `~/.canner/credentials` (mode 0600). |
|
|
39
41
|
| `canner whoami` | Show the email + plan this CLI is signed in as. |
|
|
42
|
+
|
|
43
|
+
### Projects
|
|
44
|
+
|
|
45
|
+
| Command | What it does |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `canner projects` | List your projects. |
|
|
40
48
|
| `canner init [--slug X]` | Create a new Canner project and write `canner.json` in the current directory. |
|
|
41
|
-
| `canner
|
|
49
|
+
| `canner status [--slug X]` | Show one project's state — URL, status, source, last request. |
|
|
50
|
+
| `canner open [--slug X] [--dashboard]` | Open the tenant URL (or the dashboard project page) in your browser. |
|
|
51
|
+
|
|
52
|
+
### Deploys
|
|
53
|
+
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `canner deploy [path] [--slug X] [--follow]` | Tar the directory and upload. Works for any project — GitHub-connected or upload-only. `--follow` streams the build log and exits with the build's success code. |
|
|
57
|
+
| `canner redeploy [--slug X] [--follow]` | Trigger a rebuild from the GitHub repo's `HEAD` without uploading. GitHub-connected projects only. |
|
|
58
|
+
| `canner deployments [--slug X]` | List the project's recent deployments. |
|
|
59
|
+
| `canner deployments <id> [--slug X] [--follow]` | Show one deployment's build log; `--follow` streams if it's still in progress. |
|
|
60
|
+
| `canner cancel [<id>] [--slug X]` | Abort the in-flight build (or a specific one by id). |
|
|
61
|
+
| `canner logs [--slug X] [--lines N] [--follow]` | Stream the running tenant's runtime logs (last 200 lines by default). |
|
|
42
62
|
|
|
43
63
|
## What gets uploaded
|
|
44
64
|
|
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.2.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');
|
|
@@ -325,6 +325,277 @@ async function cmdInit(args) {
|
|
|
325
325
|
muted(`Wrote ${PROJECT_CONFIG_FILE}. Run \`canner deploy\` to ship.`);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// Resolve a project slug from --slug flag → canner.json → error. Used by
|
|
329
|
+
// every command that needs one. Returns the slug string (never null).
|
|
330
|
+
async function resolveSlug(args) {
|
|
331
|
+
if (args.slug) return args.slug;
|
|
332
|
+
const cfg = await loadProjectConfig(process.cwd());
|
|
333
|
+
if (cfg?.slug) return cfg.slug;
|
|
334
|
+
die(`no project slug. Pass --slug=X or run \`canner init\` to create ${PROJECT_CONFIG_FILE}.`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Format an ISO timestamp as a short relative-time string (e.g. "2m ago",
|
|
338
|
+
// "3h ago", "5d ago"). Falls back to absolute YYYY-MM-DD for dates > 30d.
|
|
339
|
+
function relTime(iso) {
|
|
340
|
+
if (!iso) return dim('—');
|
|
341
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
342
|
+
const s = Math.floor(ms / 1000);
|
|
343
|
+
if (s < 60) return `${s}s ago`;
|
|
344
|
+
const m = Math.floor(s / 60);
|
|
345
|
+
if (m < 60) return `${m}m ago`;
|
|
346
|
+
const h = Math.floor(m / 60);
|
|
347
|
+
if (h < 24) return `${h}h ago`;
|
|
348
|
+
const d = Math.floor(h / 24);
|
|
349
|
+
if (d < 30) return `${d}d ago`;
|
|
350
|
+
return iso.slice(0, 10);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Right-pad a string to width N, ANSI-stripped for length math.
|
|
354
|
+
function pad(s, n) {
|
|
355
|
+
const visible = String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
356
|
+
return String(s) + ' '.repeat(Math.max(0, n - visible.length));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Color a deployment status string for table output.
|
|
360
|
+
function colorStatus(s) {
|
|
361
|
+
if (s === 'live') return green(s);
|
|
362
|
+
if (s === 'failed') return red(s);
|
|
363
|
+
if (s === 'queued' || s === 'building' || s === 'deploying') return yellow(s);
|
|
364
|
+
return dim(s);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// canner projects — list owned + team projects
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
async function cmdProjects() {
|
|
371
|
+
const creds = await loadCredentials();
|
|
372
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
373
|
+
const body = await apiJSON(creds, 'GET', '/projects');
|
|
374
|
+
const rows = body?.projects ?? body ?? [];
|
|
375
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
376
|
+
muted('no projects yet. Run `canner init` to create one.');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
info(`${pad(bold('SLUG'), 24)}${pad(bold('STATUS'), 14)}${pad(bold('URL'), 40)}${bold('LAST REQUEST')}`);
|
|
380
|
+
for (const p of rows) {
|
|
381
|
+
const url = `https://${p.slug}.app.canner.ca`;
|
|
382
|
+
const lastReq = relTime(p.last_request_at);
|
|
383
|
+
info(`${pad(p.slug, 24)}${pad(colorStatus(p.status), 14)}${pad(dim(url), 40)}${dim(lastReq)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// canner status [--slug X] — single project detail
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
async function cmdStatus(args) {
|
|
391
|
+
const creds = await loadCredentials();
|
|
392
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
393
|
+
const slug = await resolveSlug(args);
|
|
394
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}`);
|
|
395
|
+
const p = body?.project ?? body;
|
|
396
|
+
if (!p?.slug) die('unexpected response from /projects/:slug');
|
|
397
|
+
info(`${bold(p.slug)} ${dim(`(${p.name ?? ''})`)}`);
|
|
398
|
+
info(` status: ${colorStatus(p.status)}${p.is_cold ? dim(' (cold)') : ''}`);
|
|
399
|
+
info(` url: ${cyan(`https://${p.slug}.app.canner.ca`)}`);
|
|
400
|
+
info(` source: ${p.is_connected ? `${dim('github')} ${p.repo_url ?? ''} (branch ${p.repo_branch})` : dim('upload-only')}`);
|
|
401
|
+
info(` memory: ${p.memory_limit_mb}M${p.always_warm ? ` ${dim('(always-warm)')}` : ''}`);
|
|
402
|
+
info(` last request: ${relTime(p.last_request_at)}`);
|
|
403
|
+
info(` updated: ${relTime(p.updated_at)}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// canner deployments [--slug X] — list
|
|
408
|
+
// canner deployments <id> [--slug X] [--follow] — view one (optionally tail)
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
async function cmdDeployments(args) {
|
|
411
|
+
const creds = await loadCredentials();
|
|
412
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
413
|
+
const slug = await resolveSlug(args);
|
|
414
|
+
const id = args._[0];
|
|
415
|
+
if (id) return viewDeployment(creds, slug, id, args.follow === true);
|
|
416
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
417
|
+
const rows = body?.deployments ?? [];
|
|
418
|
+
if (rows.length === 0) {
|
|
419
|
+
muted('no deployments yet.');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('STATUS'), 14)}${pad(bold('WHEN'), 14)}${pad(bold('DURATION'), 12)}${bold('COMMIT')}`);
|
|
423
|
+
for (const d of rows.slice(0, 20)) {
|
|
424
|
+
const sha = (d.commit_sha || '').slice(0, 7) || dim('—');
|
|
425
|
+
const msg = (d.commit_message || '').split('\n')[0].slice(0, 60);
|
|
426
|
+
const dur = d.build_duration_ms != null ? `${Math.round(d.build_duration_ms / 1000)}s` : dim('—');
|
|
427
|
+
const star = d.is_current ? green('●') : ' ';
|
|
428
|
+
info(`${star} ${pad(dim(d.id.slice(0, 8)), 10)}${pad(colorStatus(d.status), 14)}${pad(dim(relTime(d.created_at)), 14)}${pad(dur, 12)}${sha} ${dim(msg)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function viewDeployment(creds, slug, idPrefix, follow) {
|
|
433
|
+
// Allow the user to pass an 8-char prefix — disambiguate against the list.
|
|
434
|
+
let fullId = idPrefix;
|
|
435
|
+
if (idPrefix.length < 36) {
|
|
436
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
437
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(idPrefix));
|
|
438
|
+
if (matches.length === 0) die(`no deployment matching "${idPrefix}"`);
|
|
439
|
+
if (matches.length > 1) die(`ambiguous "${idPrefix}" — matches ${matches.length} deployments`);
|
|
440
|
+
fullId = matches[0].id;
|
|
441
|
+
}
|
|
442
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments/${fullId}`);
|
|
443
|
+
const d = body?.deployment;
|
|
444
|
+
if (!d) die('deployment not found');
|
|
445
|
+
info(`${bold(d.id.slice(0, 8))}${dim(d.id.slice(8))} ${colorStatus(d.status)}`);
|
|
446
|
+
if (d.commit_sha) info(` ${dim('commit')} ${d.commit_sha.slice(0, 12)} ${dim((d.commit_message || '').split('\n')[0])}`);
|
|
447
|
+
if (d.commit_author) info(` ${dim('author')} ${d.commit_author}`);
|
|
448
|
+
info(` ${dim('queued')} ${relTime(d.created_at)}`);
|
|
449
|
+
if (d.deployed_at) info(` ${dim('done')} ${relTime(d.deployed_at)}`);
|
|
450
|
+
if (d.error_message) info(` ${dim('error')} ${red(d.error_message)}`);
|
|
451
|
+
if (d.build_log) {
|
|
452
|
+
info(dim('─── build log ───────────────────────────────────'));
|
|
453
|
+
process.stdout.write(d.build_log);
|
|
454
|
+
if (!d.build_log.endsWith('\n')) process.stdout.write('\n');
|
|
455
|
+
}
|
|
456
|
+
if (follow && !['live', 'failed', 'superseded'].includes(d.status)) {
|
|
457
|
+
info(dim('─── streaming (still in progress) ────────────────'));
|
|
458
|
+
const code = await tailDeploymentLogs(creds, slug, fullId);
|
|
459
|
+
process.exit(code);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// canner logs [--slug X] [--lines N] [--follow]
|
|
465
|
+
// Stream the live tenant runtime logs via SSE. Without --follow, exits
|
|
466
|
+
// after the initial history burst settles (~1s quiet window).
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
async function cmdLogs(args) {
|
|
469
|
+
const creds = await loadCredentials();
|
|
470
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
471
|
+
const slug = await resolveSlug(args);
|
|
472
|
+
const lines = Number(args.lines) || 200;
|
|
473
|
+
const url = `${creds.apiBase}/projects/${encodeURIComponent(slug)}/runtime-logs?lines=${lines}`;
|
|
474
|
+
const res = await fetch(url, {
|
|
475
|
+
headers: { Authorization: `Bearer ${creds.token}`, Accept: 'text/event-stream' },
|
|
476
|
+
}).catch((err) => die(`could not open log stream: ${err.message}`));
|
|
477
|
+
if (!res.ok || !res.body) die(`log stream returned ${res.status} ${res.statusText}`);
|
|
478
|
+
|
|
479
|
+
const decoder = new TextDecoder('utf-8');
|
|
480
|
+
let buffer = '';
|
|
481
|
+
let quietTimer = null;
|
|
482
|
+
const armQuiet = () => {
|
|
483
|
+
if (!args.follow) {
|
|
484
|
+
clearTimeout(quietTimer);
|
|
485
|
+
// 1.5s with no new data after the history burst → assume we're caught up.
|
|
486
|
+
quietTimer = setTimeout(() => process.exit(0), 1500);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
armQuiet();
|
|
490
|
+
|
|
491
|
+
for await (const chunk of res.body) {
|
|
492
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
493
|
+
let idx;
|
|
494
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
495
|
+
const raw = buffer.slice(0, idx);
|
|
496
|
+
buffer = buffer.slice(idx + 2);
|
|
497
|
+
if (raw.startsWith(':')) continue; // heartbeat
|
|
498
|
+
let event = 'message';
|
|
499
|
+
let data = '';
|
|
500
|
+
for (const line of raw.split('\n')) {
|
|
501
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
502
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
503
|
+
}
|
|
504
|
+
if (!data) continue;
|
|
505
|
+
let parsed;
|
|
506
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
507
|
+
if (event === 'log') {
|
|
508
|
+
// runtime-logs uses {line:...}, build-logs uses {log:...} — handle both.
|
|
509
|
+
const text = parsed.line ?? parsed.log;
|
|
510
|
+
if (text) process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
|
|
511
|
+
armQuiet();
|
|
512
|
+
} else if (event === 'done') {
|
|
513
|
+
process.exit(0);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// canner cancel [<id>] [--slug X]
|
|
521
|
+
// Cancel an in-flight build. With no id, finds the latest non-terminal
|
|
522
|
+
// deployment for the project and cancels it.
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
async function cmdCancel(args) {
|
|
525
|
+
const creds = await loadCredentials();
|
|
526
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
527
|
+
const slug = await resolveSlug(args);
|
|
528
|
+
let id = args._[0];
|
|
529
|
+
if (!id) {
|
|
530
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
531
|
+
const inflight = (body?.deployments ?? []).find((d) =>
|
|
532
|
+
['queued', 'building', 'deploying'].includes(d.status)
|
|
533
|
+
);
|
|
534
|
+
if (!inflight) die('no in-flight deployment to cancel. Pass an id to cancel a specific one.');
|
|
535
|
+
id = inflight.id;
|
|
536
|
+
} else if (id.length < 36) {
|
|
537
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
538
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(id));
|
|
539
|
+
if (matches.length === 0) die(`no deployment matching "${id}"`);
|
|
540
|
+
if (matches.length > 1) die(`ambiguous "${id}" — matches ${matches.length} deployments`);
|
|
541
|
+
id = matches[0].id;
|
|
542
|
+
}
|
|
543
|
+
await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deployments/${id}/cancel`);
|
|
544
|
+
info(`${green('cancelled')} ${dim(id.slice(0, 8))}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// canner open [--slug X] [--dashboard]
|
|
549
|
+
// Open the tenant URL (default) or the dashboard project page in the
|
|
550
|
+
// user's default browser.
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
async function cmdOpen(args) {
|
|
553
|
+
const slug = await resolveSlug(args);
|
|
554
|
+
// Strict slug validation — never trust user input as part of a shell arg
|
|
555
|
+
// even though the runner below uses argv (not bash -c). Belt and braces.
|
|
556
|
+
if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) {
|
|
557
|
+
die(`invalid slug "${slug}"`);
|
|
558
|
+
}
|
|
559
|
+
const creds = await loadCredentials();
|
|
560
|
+
const dashboardOrigin = (creds.apiBase || DEFAULT_API_BASE).replace(/^https?:\/\/api\./, 'https://');
|
|
561
|
+
const url = args.dashboard
|
|
562
|
+
? `${dashboardOrigin}/dashboard/projects/${slug}`
|
|
563
|
+
: `https://${slug}.app.canner.ca`;
|
|
564
|
+
info(`opening ${dim(url)}`);
|
|
565
|
+
const { spawn } = await import('node:child_process');
|
|
566
|
+
const platform = process.platform;
|
|
567
|
+
let cmd, cmdArgs;
|
|
568
|
+
if (platform === 'win32') { cmd = 'cmd'; cmdArgs = ['/c', 'start', '""', url]; }
|
|
569
|
+
else if (platform === 'darwin') { cmd = 'open'; cmdArgs = [url]; }
|
|
570
|
+
else { cmd = 'xdg-open'; cmdArgs = [url]; }
|
|
571
|
+
const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
|
|
572
|
+
child.on('error', () => die(`could not launch browser (${cmd} not found)`));
|
|
573
|
+
child.unref();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// canner redeploy [--slug X] [--follow]
|
|
578
|
+
// Trigger a rebuild from the GitHub repo's current HEAD — no local upload.
|
|
579
|
+
// Only works for projects that are GitHub-connected via the Canner Deploy
|
|
580
|
+
// GitHub App. For upload-only projects, use `canner deploy` instead.
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
async function cmdRedeploy(args) {
|
|
583
|
+
const creds = await loadCredentials();
|
|
584
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
585
|
+
const slug = await resolveSlug(args);
|
|
586
|
+
const res = await apiRequest(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deploy`);
|
|
587
|
+
const body = await res.json();
|
|
588
|
+
const deploymentId = body.deployment_id;
|
|
589
|
+
if (!deploymentId) die('redeploy returned no deployment_id.');
|
|
590
|
+
info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))} ${dim('(rebuild from GitHub HEAD)')}`);
|
|
591
|
+
if (!args.follow) {
|
|
592
|
+
muted(`Tail with: canner deployments ${deploymentId.slice(0, 8)} --follow`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const exitCode = await tailDeploymentLogs(creds, slug, deploymentId);
|
|
596
|
+
process.exit(exitCode);
|
|
597
|
+
}
|
|
598
|
+
|
|
328
599
|
async function cmdDeploy(args) {
|
|
329
600
|
const projectPath = resolve(args._[0] || '.');
|
|
330
601
|
if (!existsSync(projectPath)) {
|
|
@@ -549,18 +820,45 @@ function printHelp() {
|
|
|
549
820
|
${bold('Usage:')}
|
|
550
821
|
canner <command> [options]
|
|
551
822
|
|
|
552
|
-
${bold('
|
|
823
|
+
${bold('Setup:')}
|
|
553
824
|
${bold('login')} Store your Canner API token (one-time).
|
|
554
825
|
${bold('whoami')} Show the account this CLI is signed in as.
|
|
826
|
+
|
|
827
|
+
${bold('Projects:')}
|
|
828
|
+
${bold('projects')} List your projects.
|
|
555
829
|
${bold('init')} [--slug X] Create a project and write ${cyan(PROJECT_CONFIG_FILE)}.
|
|
830
|
+
${bold('status')} [--slug X] Show project state (URL, status, source, last request).
|
|
831
|
+
${bold('open')} [--slug X] [--dashboard]
|
|
832
|
+
Open the tenant URL (or dashboard page) in your browser.
|
|
833
|
+
|
|
834
|
+
${bold('Deploys:')}
|
|
556
835
|
${bold('deploy')} [path] [--slug X] [--follow]
|
|
557
|
-
Tar the current directory and
|
|
836
|
+
Tar the current directory and upload. Works for any
|
|
837
|
+
project (GitHub-connected or upload-only).
|
|
838
|
+
${bold('redeploy')} [--slug X] [--follow]
|
|
839
|
+
Trigger a rebuild from the GitHub repo's HEAD without
|
|
840
|
+
uploading. GitHub-connected projects only.
|
|
841
|
+
${bold('deployments')} [--slug X]
|
|
842
|
+
List the project's recent deployments.
|
|
843
|
+
${bold('deployments')} <id> [--slug X] [--follow]
|
|
844
|
+
Show one deployment's build log (--follow streams if
|
|
845
|
+
it's still in progress).
|
|
846
|
+
${bold('cancel')} [<id>] [--slug X]
|
|
847
|
+
Abort the in-flight build (or a specific one by id).
|
|
848
|
+
${bold('logs')} [--slug X] [--lines N] [--follow]
|
|
849
|
+
Stream the running tenant's runtime logs (last 200
|
|
850
|
+
lines by default, --follow to keep streaming).
|
|
558
851
|
|
|
559
852
|
${bold('Examples:')}
|
|
560
853
|
canner login
|
|
561
854
|
cd my-site && canner init
|
|
562
|
-
canner deploy --follow
|
|
563
|
-
canner
|
|
855
|
+
canner deploy --follow ${dim('# build to live, streaming logs')}
|
|
856
|
+
canner redeploy --follow ${dim('# rebuild from GitHub HEAD')}
|
|
857
|
+
canner deployments ${dim('# list recent deploys')}
|
|
858
|
+
canner deployments 7bf4862e --follow ${dim('# inspect a specific one')}
|
|
859
|
+
canner logs --follow ${dim('# tail tenant runtime logs')}
|
|
860
|
+
canner cancel ${dim('# abort the current build')}
|
|
861
|
+
canner open --dashboard ${dim('# open dashboard project page')}
|
|
564
862
|
|
|
565
863
|
${bold('Environment:')}
|
|
566
864
|
CANNER_TOKEN Override stored token (handy for CI).
|
|
@@ -598,6 +896,13 @@ const dispatch = {
|
|
|
598
896
|
whoami: cmdWhoami,
|
|
599
897
|
init: cmdInit,
|
|
600
898
|
deploy: cmdDeploy,
|
|
899
|
+
redeploy: cmdRedeploy,
|
|
900
|
+
projects: cmdProjects,
|
|
901
|
+
status: cmdStatus,
|
|
902
|
+
logs: cmdLogs,
|
|
903
|
+
deployments: cmdDeployments,
|
|
904
|
+
cancel: cmdCancel,
|
|
905
|
+
open: cmdOpen,
|
|
601
906
|
};
|
|
602
907
|
const handler = dispatch[command];
|
|
603
908
|
if (!handler) {
|