@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.
Files changed (3) hide show
  1. package/README.md +21 -1
  2. package/package.json +1 -1
  3. 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 deploy [path] [--slug X] [--follow]` | Tar the directory and upload it. `--follow` streams the build log and exits with the build's success code. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Canner deploy CLI — git push from any terminal, no GitHub required",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto';
27
27
  import { stdin as input, stdout as output } from 'node:process';
28
28
  import { create as tarCreate } from 'tar';
29
29
 
30
- const VERSION = '0.1.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('Commands:')}
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 ship it.
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 ${dim('# build to live, streaming logs')}
563
- canner deploy ./web --slug my-app ${dim('# deploy a sibling directory')}
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) {