@canner-ca/cli 0.5.5 → 0.5.6

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 +5 -1
  2. package/package.json +1 -1
  3. package/src/cli.mjs +172 -16
package/README.md CHANGED
@@ -69,12 +69,16 @@ canner deploy --follow
69
69
  | `canner token create <name>` | Mint a new token. The secret is printed once — copy it immediately. |
70
70
  | `canner token revoke <id\|name>` | Revoke a token. |
71
71
  | `canner env [list] [--reveal] [--slug X]` | List a project's environment variables. Values marked sensitive are redacted (`••••••`, or `null` in `--json`) unless `--reveal` is passed. |
72
- | `canner env set KEY=VALUE [--env production\|preview] [--sensitive] [--slug X]` | Add an environment variable. |
72
+ | `canner env set KEY=VALUE [KEY=VALUE ...] [--env production\|preview] [--sensitive] [--slug X]` | Add one or more environment variables. |
73
73
  | `canner env rm <KEY\|id> [--env ...] [--slug X]` | Delete an environment variable. |
74
74
  | `canner domains [list] [--slug X]` | List a project's custom domains. |
75
75
  | `canner domains add <hostname> [--slug X]` | Attach a custom domain (starts `pending`). |
76
76
  | `canner domains verify <hostname\|id> [--slug X]` | Re-check DNS and provision TLS. |
77
77
  | `canner domains rm <hostname\|id> [--slug X]` | Detach a custom domain. |
78
+ | `canner jobs [list] [--slug X]` | List scheduled jobs, with the header names each sends (never a value). |
79
+ | `canner jobs create <name> (--path /api/cron [--branch BRANCH] \| --url URL) --schedule "CRON" [--header name=value] [--header-env name=VARIABLE] [--bearer-env VARIABLE] [--slug X]` | Create a job. `--path` calls a path in the project itself (its address is built from the project's own host each time, in production or, with `--branch`, on that preview); `--url` is for anything else. `--header-env x-cron-key=CRON_KEY` sends the project variable's value, read each time the job runs, so the secret is never copied. `--bearer-env CRON_SECRET` sends `Authorization: Bearer …`. With a `CRON_SECRET` variable and no Authorization header, Canner sends it automatically. |
80
+ | `canner jobs run <id>` / `canner jobs runs <id>` | Run a job now and show the result, or list recent runs with what to check on a failure. |
81
+ | `canner jobs delete <id> --yes` | Delete a job and its run history. |
78
82
 
79
83
  ### Databases
80
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
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
@@ -28,7 +28,7 @@ import { randomUUID } from 'node:crypto';
28
28
  import { stdin as input, stdout as output } from 'node:process';
29
29
  import { create as tarCreate } from 'tar';
30
30
 
31
- const VERSION = '0.5.5';
31
+ const VERSION = '0.5.6';
32
32
  const DEFAULT_API_BASE = 'https://api.canner.ca';
33
33
  const CONFIG_DIR = join(homedir(), '.canner');
34
34
  const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
@@ -726,23 +726,31 @@ async function cmdEnv(args) {
726
726
  }
727
727
 
728
728
  if (sub === 'set') {
729
- const kv = args._[1];
730
- if (!kv || !kv.includes('=')) die('usage: canner env set KEY=VALUE', 2);
731
- const eq = kv.indexOf('=');
732
- const key = kv.slice(0, eq);
733
- const value = kv.slice(eq + 1);
729
+ const pairs = args._.slice(1);
730
+ if (pairs.length === 0 || pairs.some((kv) => !kv.includes('='))) die('usage: canner env set KEY=VALUE [KEY=VALUE ...]', 2);
734
731
  const environment = normalizeEnvScope(args.env);
735
732
  const isSensitive = args.sensitive === true;
736
- const result = await apiJSON(creds, 'POST', base, {
737
- key, value, environment, is_sensitive: isSensitive,
738
- });
739
- if (JSON_MODE) return emitJSON(result);
740
- if ((result?.created ?? []).length > 0) {
741
- info(`${green('set')} ${bold(key)}${environment ? dim(` (${environment})`) : ''}`);
742
- } else {
743
- // A duplicate (same key+scope) is skipped, not overwritten — surface that
744
- // so the user isn't surprised the value didn't change.
745
- die(`${key} already exists for that environment. Delete it first (canner env rm ${key}), then set again.`);
733
+ const results = [];
734
+ const skipped = [];
735
+ for (const kv of pairs) {
736
+ const eq = kv.indexOf('=');
737
+ const key = kv.slice(0, eq);
738
+ const value = kv.slice(eq + 1);
739
+ const result = await apiJSON(creds, 'POST', base, {
740
+ key, value, environment, is_sensitive: isSensitive,
741
+ });
742
+ results.push(result);
743
+ if ((result?.created ?? []).length > 0) {
744
+ if (!JSON_MODE) info(`${green('set')} ${bold(key)}${environment ? dim(` (${environment})`) : ''}`);
745
+ } else {
746
+ // A duplicate (same key+scope) is skipped, not overwritten — surface that
747
+ // so the user isn't surprised the value didn't change.
748
+ skipped.push(key);
749
+ }
750
+ }
751
+ if (JSON_MODE) return emitJSON(results.length === 1 ? results[0] : results);
752
+ if (skipped.length > 0) {
753
+ die(`${skipped.join(', ')} already exist${skipped.length === 1 ? 's' : ''} for that environment. Delete ${skipped.length === 1 ? 'it' : 'them'} first (canner env rm ${skipped[0]}), then set again.`);
746
754
  }
747
755
  return;
748
756
  }
@@ -1354,6 +1362,147 @@ async function cmdBilling(args) {
1354
1362
  die('usage: canner billing addons | preview <addon> <quantity>', 2);
1355
1363
  }
1356
1364
 
1365
+ // ---------------------------------------------------------------------------
1366
+ // canner jobs ... - scheduled jobs (webhook cron)
1367
+ //
1368
+ // canner jobs [list] [--slug X] the jobs (all of them, or one project's)
1369
+ // canner jobs create <name> (--path /api/cron [--branch B] | --url U) --schedule "*/15 * * * *" [--slug X] [--description D]
1370
+ // [--header "name=value" ...] [--header-env name=VARIABLE ...] [--bearer-env VARIABLE]
1371
+ // (--path calls a path in the project itself: its address is built from the project's own host each time, in
1372
+ // production or, with --branch, on that preview; --url is for anything else)
1373
+ // canner jobs run <id> run it now and show what came back
1374
+ // canner jobs runs <id> recent runs, with what to check on a failure
1375
+ // canner jobs delete <id> --yes
1376
+ //
1377
+ // A header's value can read a project environment variable when the job fires, so the secret is never
1378
+ // copied: --header-env x-cron-key=CRON_KEY sends x-cron-key with the value of CRON_KEY; --bearer-env
1379
+ // CRON_SECRET sends Authorization: Bearer <CRON_SECRET>. (Same as --header 'x-cron-key=${CRON_KEY}', without
1380
+ // the shell-quoting.) With a CRON_SECRET variable and no Authorization header, Canner sends it automatically.
1381
+ // ---------------------------------------------------------------------------
1382
+ const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
1383
+
1384
+ function jobHeadersFromArgs(args) {
1385
+ const headers = {};
1386
+ const put = (name, value) => {
1387
+ if (!name || value === undefined || value === '') die(`bad header: "${name}" needs a name and a value.`, 2);
1388
+ headers[name] = value;
1389
+ };
1390
+ for (const h of asList(args.header)) {
1391
+ const eq = String(h).indexOf('=');
1392
+ if (eq < 1) die(`bad --header "${h}": use name=value.`, 2);
1393
+ put(String(h).slice(0, eq), String(h).slice(eq + 1));
1394
+ }
1395
+ for (const h of asList(args['header-env'])) {
1396
+ const eq = String(h).indexOf('=');
1397
+ if (eq < 1) die(`bad --header-env "${h}": use header-name=VARIABLE.`, 2);
1398
+ put(String(h).slice(0, eq), `\${${String(h).slice(eq + 1)}}`);
1399
+ }
1400
+ for (const v of asList(args['bearer-env'])) put('Authorization', `Bearer \${${v}}`);
1401
+ return headers;
1402
+ }
1403
+
1404
+ const describeJobHeaders = (headers) => {
1405
+ const entries = Object.entries(headers ?? {});
1406
+ if (entries.length === 0) return dim('none');
1407
+ return entries.map(([n, v]) => (/\$\{/.test(v) ? `${n} ${dim(`(${v})`)}` : `${n} ${dim('(hidden)')}`)).join(', ');
1408
+ };
1409
+
1410
+ async function findJob(creds, ref) {
1411
+ const body = await apiJSON(creds, 'GET', '/jobs');
1412
+ const hits = (body?.jobs ?? []).filter((j) => j.id === ref || j.id.startsWith(ref));
1413
+ if (hits.length === 0) die(`no job with id "${ref}". List them with: canner jobs`);
1414
+ if (hits.length > 1) die(`"${ref}" matches more than one job; use more of the id.`);
1415
+ return hits[0];
1416
+ }
1417
+
1418
+ function printRun(r) {
1419
+ const color = r.status === 'succeeded' ? green : r.status === 'running' ? yellow : red;
1420
+ info(` ${pad(r.started_at.replace('T', ' ').slice(0, 19), 21)}${pad(color(r.status), 20)}${pad(String(r.http_status ?? '-'), 6)}${r.duration_ms != null ? `${r.duration_ms} ms` : ''}`);
1421
+ if (r.error_message) info(` ${dim(String(r.error_message).slice(0, 200))}`);
1422
+ if (r.hint) info(` ${yellow('check')} ${r.hint}`);
1423
+ }
1424
+
1425
+ async function cmdJobs(args) {
1426
+ const creds = await loadCredentials();
1427
+ if (!creds.token) die("not signed in. Run `canner login`.");
1428
+ const sub = args._[0] ?? 'list';
1429
+
1430
+ if (sub === 'list' || sub === 'ls') {
1431
+ const body = await apiJSON(creds, 'GET', args.slug ? `/projects/${encodeURIComponent(args.slug)}/jobs` : '/jobs');
1432
+ if (JSON_MODE) return emitJSON(body);
1433
+ const jobs = body?.jobs ?? [];
1434
+ if (jobs.length === 0) { muted('no scheduled jobs. Create one with `canner jobs create`.'); return; }
1435
+ info(`${pad(bold('ID'), 10)}${pad(bold('NAME'), 26)}${pad(bold('PROJECT'), 20)}${pad(bold('SCHEDULE'), 16)}${bold('HEADERS')}`);
1436
+ for (const j of jobs) {
1437
+ info(`${pad(dim(j.id.slice(0, 8)), 10)}${pad(j.is_active ? j.name : `${j.name} ${dim('(paused)')}`, 26)}${pad(j.project_slug, 20)}${pad(j.schedule, 16)}${describeJobHeaders(j.headers)}`);
1438
+ }
1439
+ return;
1440
+ }
1441
+
1442
+ if (sub === 'create' || sub === 'add') {
1443
+ const name = args._[1];
1444
+ if (!name || !args.schedule || Boolean(args.url) === Boolean(args.path)) {
1445
+ die('usage: canner jobs create <name> (--path /api/cron [--branch BRANCH] | --url URL) --schedule "CRON" [--header name=value] [--header-env name=VARIABLE] [--bearer-env VARIABLE]', 2);
1446
+ }
1447
+ if (args.branch && !args.path) die('--branch goes with --path.', 2);
1448
+ const slug = await resolveSlug(args);
1449
+ const headers = jobHeadersFromArgs(args);
1450
+ const body = await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/jobs`, {
1451
+ name, schedule: String(args.schedule),
1452
+ ...(args.path ? { path: String(args.path), ...(args.branch ? { branch: String(args.branch) } : {}) } : { url: String(args.url) }),
1453
+ ...(args.description ? { description: String(args.description) } : {}),
1454
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
1455
+ });
1456
+ if (JSON_MODE) return emitJSON(body);
1457
+ info(`${green('created')} ${bold(name)} ${dim(body.job.id)}`);
1458
+ for (const w of body.warnings ?? []) info(`${yellow('warning')} ${w.message}`);
1459
+ return;
1460
+ }
1461
+
1462
+ if (sub === 'run' || sub === 'trigger') {
1463
+ if (!args._[1]) die('usage: canner jobs run <id>', 2);
1464
+ const job = await findJob(creds, args._[1]);
1465
+ await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/trigger`);
1466
+ let run = null;
1467
+ for (let i = 0; i < 20; i++) {
1468
+ await new Promise((r) => setTimeout(r, 750));
1469
+ const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/runs?limit=1`);
1470
+ run = body?.runs?.[0] ?? null;
1471
+ if (run && run.status !== 'running') break;
1472
+ }
1473
+ if (JSON_MODE) return emitJSON(run);
1474
+ if (!run) { muted('started; check `canner jobs runs ' + job.id.slice(0, 8) + '` in a moment.'); return; }
1475
+ info(`${bold(job.name)}`);
1476
+ printRun(run);
1477
+ if (run.status !== 'succeeded' && run.response_body) info(` ${dim(String(run.response_body).slice(0, 300))}`);
1478
+ if (run.status !== 'succeeded') process.exitCode = 1;
1479
+ return;
1480
+ }
1481
+
1482
+ if (sub === 'runs' || sub === 'history') {
1483
+ if (!args._[1]) die('usage: canner jobs runs <id>', 2);
1484
+ const job = await findJob(creds, args._[1]);
1485
+ const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/runs?limit=10`);
1486
+ if (JSON_MODE) return emitJSON(body);
1487
+ info(`${bold(job.name)} ${dim(`${job.project_slug} · ${job.schedule}`)} headers: ${describeJobHeaders(job.headers)}`);
1488
+ if ((body?.runs ?? []).length === 0) { muted('no runs yet.'); return; }
1489
+ for (const r of body.runs) printRun(r);
1490
+ return;
1491
+ }
1492
+
1493
+ if (sub === 'delete' || sub === 'rm') {
1494
+ if (!args._[1]) die('usage: canner jobs delete <id> --yes', 2);
1495
+ const job = await findJob(creds, args._[1]);
1496
+ requireYes(args, `This deletes the job "${job.name}" and its run history.`);
1497
+ await apiJSON(creds, 'DELETE', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}`);
1498
+ if (JSON_MODE) return emitJSON({ ok: true });
1499
+ info(`${green('deleted')} ${bold(job.name)}`);
1500
+ return;
1501
+ }
1502
+
1503
+ die('usage: canner jobs [list] | create <name> ... | run <id> | runs <id> | delete <id> --yes', 2);
1504
+ }
1505
+
1357
1506
  async function cmdDb(args) {
1358
1507
  const creds = await loadCredentials();
1359
1508
  if (!creds.token) die("not signed in. Run `canner login`.");
@@ -2672,6 +2821,11 @@ ${bold('Databases:')}
2672
2821
  Create the primary on its own dedicated instance with
2673
2822
  point-in-time recovery. If the project has no unit, this
2674
2823
  buys one: it shows the price and needs --yes.
2824
+ ${bold('jobs')} [list] | create <name> (--path /p [--branch B] | --url U) --schedule "CRON" | run <id> | runs <id> | delete <id> --yes
2825
+ Scheduled jobs. --path calls a path in the project itself
2826
+ (production, or a preview with --branch). A header can read a
2827
+ project variable: --header-env x-cron-key=CRON_KEY or
2828
+ --bearer-env CRON_SECRET.
2675
2829
  ${bold('billing')} addons | preview <addon> <quantity>
2676
2830
  See the add-ons you own and where they are assigned, and
2677
2831
  preview a quantity change. Read-only; nothing is charged.
@@ -2855,6 +3009,8 @@ const dispatch = {
2855
3009
  env: cmdEnv,
2856
3010
  domains: cmdDomains,
2857
3011
  billing: cmdBilling,
3012
+ jobs: cmdJobs,
3013
+ job: cmdJobs,
2858
3014
  db: cmdDb,
2859
3015
  database: cmdDb,
2860
3016
  databases: cmdDb,