@canner-ca/cli 0.5.5 → 0.5.7

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 +6 -1
  2. package/package.json +1 -1
  3. package/src/cli.mjs +232 -16
package/README.md CHANGED
@@ -69,12 +69,17 @@ 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 (in Git Bash on Windows write `--path //api/cron`, or Git Bash rewrites the leading slash) (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 edit <id> [--name N] [--schedule "CRON"] [--description D] [--pause \| --resume] [--path /p [--branch B \| --production] \| --url U] [--header-env name=VARIABLE] [--bearer-env VARIABLE] [--header name=value] [--remove-header name] [--clear-headers]` | Change a job in place; only the options you give change, and its run history stays. A header given by name replaces the one with that name and the others are kept. |
81
+ | `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. |
82
+ | `canner jobs delete <id> --yes` | Delete a job and its run history. |
78
83
 
79
84
  ### Databases
80
85
 
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.7",
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.7';
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
  }
@@ -1121,6 +1129,7 @@ const BOOLEAN_FLAGS = new Set([
1121
1129
  'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
1122
1130
  'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
1123
1131
  'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict', 'dedicated',
1132
+ 'pause', 'resume', 'production', 'clear-headers',
1124
1133
  ]);
1125
1134
 
1126
1135
  const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
@@ -1354,6 +1363,204 @@ async function cmdBilling(args) {
1354
1363
  die('usage: canner billing addons | preview <addon> <quantity>', 2);
1355
1364
  }
1356
1365
 
1366
+ // ---------------------------------------------------------------------------
1367
+ // canner jobs ... - scheduled jobs (webhook cron)
1368
+ //
1369
+ // canner jobs [list] [--slug X] the jobs (all of them, or one project's)
1370
+ // canner jobs create <name> (--path /api/cron [--branch B] | --url U) --schedule "*/15 * * * *" [--slug X] [--description D]
1371
+ // [--header "name=value" ...] [--header-env name=VARIABLE ...] [--bearer-env VARIABLE]
1372
+ // (--path calls a path in the project itself: its address is built from the project's own host each time, in
1373
+ // production or, with --branch, on that preview; --url is for anything else)
1374
+ // canner jobs edit <id> [--name N] [--schedule "CRON"] [--description D] [--pause | --resume]
1375
+ // [--path /p [--branch B | --production] | --url U]
1376
+ // [--header-env name=VARIABLE ...] [--bearer-env VARIABLE] [--header name=value ...]
1377
+ // [--remove-header name ...] [--clear-headers]
1378
+ // (edit changes only what is given. A header given by name replaces the one with that name; the others stay.)
1379
+ // canner jobs run <id> run it now and show what came back
1380
+ // canner jobs runs <id> recent runs, with what to check on a failure
1381
+ // canner jobs delete <id> --yes
1382
+ //
1383
+ // A header's value can read a project environment variable when the job fires, so the secret is never
1384
+ // copied: --header-env x-cron-key=CRON_KEY sends x-cron-key with the value of CRON_KEY; --bearer-env
1385
+ // CRON_SECRET sends Authorization: Bearer <CRON_SECRET>. (Same as --header 'x-cron-key=${CRON_KEY}', without
1386
+ // the shell-quoting.) With a CRON_SECRET variable and no Authorization header, Canner sends it automatically.
1387
+ // ---------------------------------------------------------------------------
1388
+ const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
1389
+
1390
+ // A path argument, as the API wants it (leading slash). Git Bash on Windows rewrites an argument that starts with
1391
+ // "/" into a Windows path (C:/Program Files/Git/api/cron) before any program sees it; say so instead of sending that.
1392
+ function pathArg(raw) {
1393
+ const p = String(raw);
1394
+ if (/^[A-Za-z]:[\\/]/.test(p)) {
1395
+ die(`"${p}" is not a path in your project. Git Bash on Windows rewrites arguments that start with "/": write it with a doubled slash (--path //api/cron), or run the command with MSYS_NO_PATHCONV=1.`, 2);
1396
+ }
1397
+ return p.startsWith('//') && !p.startsWith('///') ? p.slice(1) : p.startsWith('/') ? p : `/${p}`;
1398
+ }
1399
+
1400
+ function jobHeadersFromArgs(args) {
1401
+ const headers = {};
1402
+ const put = (name, value) => {
1403
+ if (!name || value === undefined || value === '') die(`bad header: "${name}" needs a name and a value.`, 2);
1404
+ headers[name] = value;
1405
+ };
1406
+ for (const h of asList(args.header)) {
1407
+ const eq = String(h).indexOf('=');
1408
+ if (eq < 1) die(`bad --header "${h}": use name=value.`, 2);
1409
+ put(String(h).slice(0, eq), String(h).slice(eq + 1));
1410
+ }
1411
+ for (const h of asList(args['header-env'])) {
1412
+ const eq = String(h).indexOf('=');
1413
+ if (eq < 1) die(`bad --header-env "${h}": use header-name=VARIABLE.`, 2);
1414
+ put(String(h).slice(0, eq), `\${${String(h).slice(eq + 1)}}`);
1415
+ }
1416
+ for (const v of asList(args['bearer-env'])) put('Authorization', `Bearer \${${v}}`);
1417
+ return headers;
1418
+ }
1419
+
1420
+ const describeJobHeaders = (headers) => {
1421
+ const entries = Object.entries(headers ?? {});
1422
+ if (entries.length === 0) return dim('none');
1423
+ return entries.map(([n, v]) => (/\$\{/.test(v) ? `${n} ${dim(`(${v})`)}` : `${n} ${dim('(hidden)')}`)).join(', ');
1424
+ };
1425
+
1426
+ async function findJob(creds, ref) {
1427
+ const body = await apiJSON(creds, 'GET', '/jobs');
1428
+ const hits = (body?.jobs ?? []).filter((j) => j.id === ref || j.id.startsWith(ref));
1429
+ if (hits.length === 0) die(`no job with id "${ref}". List them with: canner jobs`);
1430
+ if (hits.length > 1) die(`"${ref}" matches more than one job; use more of the id.`);
1431
+ return hits[0];
1432
+ }
1433
+
1434
+ function printRun(r) {
1435
+ const color = r.status === 'succeeded' ? green : r.status === 'running' ? yellow : red;
1436
+ 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` : ''}`);
1437
+ if (r.error_message) info(` ${dim(String(r.error_message).slice(0, 200))}`);
1438
+ if (r.hint) info(` ${yellow('check')} ${r.hint}`);
1439
+ }
1440
+
1441
+ async function cmdJobs(args) {
1442
+ const creds = await loadCredentials();
1443
+ if (!creds.token) die("not signed in. Run `canner login`.");
1444
+ const sub = args._[0] ?? 'list';
1445
+
1446
+ if (sub === 'list' || sub === 'ls') {
1447
+ const body = await apiJSON(creds, 'GET', args.slug ? `/projects/${encodeURIComponent(args.slug)}/jobs` : '/jobs');
1448
+ if (JSON_MODE) return emitJSON(body);
1449
+ const jobs = body?.jobs ?? [];
1450
+ if (jobs.length === 0) { muted('no scheduled jobs. Create one with `canner jobs create`.'); return; }
1451
+ info(`${pad(bold('ID'), 10)}${pad(bold('NAME'), 26)}${pad(bold('PROJECT'), 20)}${pad(bold('SCHEDULE'), 16)}${bold('HEADERS')}`);
1452
+ for (const j of jobs) {
1453
+ 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)}`);
1454
+ }
1455
+ return;
1456
+ }
1457
+
1458
+ if (sub === 'create' || sub === 'add') {
1459
+ const name = args._[1];
1460
+ if (!name || !args.schedule || Boolean(args.url) === Boolean(args.path)) {
1461
+ 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);
1462
+ }
1463
+ if (args.branch && !args.path) die('--branch goes with --path.', 2);
1464
+ const slug = await resolveSlug(args);
1465
+ const headers = jobHeadersFromArgs(args);
1466
+ const body = await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/jobs`, {
1467
+ name, schedule: String(args.schedule),
1468
+ ...(args.path ? { path: pathArg(args.path), ...(args.branch ? { branch: String(args.branch) } : {}) } : { url: String(args.url) }),
1469
+ ...(args.description ? { description: String(args.description) } : {}),
1470
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
1471
+ });
1472
+ if (JSON_MODE) return emitJSON(body);
1473
+ info(`${green('created')} ${bold(name)} ${dim(body.job.id)}`);
1474
+ for (const w of body.warnings ?? []) info(`${yellow('warning')} ${w.message}`);
1475
+ return;
1476
+ }
1477
+
1478
+ if (sub === 'run' || sub === 'trigger') {
1479
+ if (!args._[1]) die('usage: canner jobs run <id>', 2);
1480
+ const job = await findJob(creds, args._[1]);
1481
+ await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/trigger`);
1482
+ let run = null;
1483
+ for (let i = 0; i < 20; i++) {
1484
+ await new Promise((r) => setTimeout(r, 750));
1485
+ const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/runs?limit=1`);
1486
+ run = body?.runs?.[0] ?? null;
1487
+ if (run && run.status !== 'running') break;
1488
+ }
1489
+ if (JSON_MODE) return emitJSON(run);
1490
+ if (!run) { muted('started; check `canner jobs runs ' + job.id.slice(0, 8) + '` in a moment.'); return; }
1491
+ info(`${bold(job.name)}`);
1492
+ printRun(run);
1493
+ if (run.status !== 'succeeded' && run.response_body) info(` ${dim(String(run.response_body).slice(0, 300))}`);
1494
+ if (run.status !== 'succeeded') process.exitCode = 1;
1495
+ return;
1496
+ }
1497
+
1498
+ if (sub === 'runs' || sub === 'history') {
1499
+ if (!args._[1]) die('usage: canner jobs runs <id>', 2);
1500
+ const job = await findJob(creds, args._[1]);
1501
+ const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}/runs?limit=10`);
1502
+ if (JSON_MODE) return emitJSON(body);
1503
+ info(`${bold(job.name)} ${dim(`${job.project_slug} · ${job.schedule}`)} headers: ${describeJobHeaders(job.headers)}`);
1504
+ if ((body?.runs ?? []).length === 0) { muted('no runs yet.'); return; }
1505
+ for (const r of body.runs) printRun(r);
1506
+ return;
1507
+ }
1508
+
1509
+ if (sub === 'edit' || sub === 'update') {
1510
+ if (!args._[1]) die('usage: canner jobs edit <id> [--schedule "CRON"] [--path /p [--branch B | --production] | --url U] [--header-env name=VARIABLE] [--bearer-env VARIABLE] [--remove-header name] [--clear-headers] [--pause | --resume]', 2);
1511
+ if (args.url && args.path) die('give either --url or --path, not both.', 2);
1512
+ if (args.branch && args.production) die('give either --branch or --production, not both.', 2);
1513
+ if (args.pause && args.resume) die('give either --pause or --resume, not both.', 2);
1514
+ if ((args.branch || args.production) && args.url) die('--branch / --production go with --path.', 2);
1515
+ const job = await findJob(creds, args._[1]);
1516
+
1517
+ const patchBody = {};
1518
+ if (args.name) patchBody.name = String(args.name);
1519
+ if (args.schedule) patchBody.schedule = String(args.schedule);
1520
+ if (args.description !== undefined && args.description !== true) patchBody.description = String(args.description);
1521
+ if (args.pause) patchBody.is_active = false;
1522
+ if (args.resume) patchBody.is_active = true;
1523
+ if (args.path) patchBody.path = pathArg(args.path);
1524
+ if (args.url) patchBody.url = String(args.url);
1525
+ if (args.branch) patchBody.branch = String(args.branch);
1526
+ if (args.production) patchBody.branch = null;
1527
+
1528
+ // Headers: start from what the job has (references as written, literal values hidden — the server keeps
1529
+ // a hidden value as is), drop what was asked to go, and let a header given by name replace its namesake.
1530
+ const added = jobHeadersFromArgs(args);
1531
+ const removed = asList(args['remove-header']).map((n) => String(n).toLowerCase());
1532
+ if (Object.keys(added).length > 0 || removed.length > 0 || args['clear-headers']) {
1533
+ const lower = (n) => n.toLowerCase();
1534
+ const replaced = new Set(Object.keys(added).map(lower));
1535
+ const kept = args['clear-headers']
1536
+ ? {}
1537
+ : Object.fromEntries(Object.entries(job.headers ?? {}).filter(([n]) => !replaced.has(lower(n)) && !removed.includes(lower(n))));
1538
+ patchBody.headers = { ...kept, ...added };
1539
+ }
1540
+ if (Object.keys(patchBody).length === 0) die('nothing to change. Give at least one option (canner jobs edit <id> --help-style usage above).', 2);
1541
+
1542
+ const body = await apiJSON(creds, 'PATCH', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}`, patchBody);
1543
+ if (JSON_MODE) return emitJSON(body);
1544
+ info(`${green('updated')} ${bold(body.job?.name ?? job.name)}`);
1545
+ info(` calls ${body.job?.url ?? job.url}`);
1546
+ info(` headers ${describeJobHeaders(body.job?.headers)}`);
1547
+ for (const w of body.warnings ?? []) info(`${yellow('warning')} ${w.message}`);
1548
+ return;
1549
+ }
1550
+
1551
+ if (sub === 'delete' || sub === 'rm') {
1552
+ if (!args._[1]) die('usage: canner jobs delete <id> --yes', 2);
1553
+ const job = await findJob(creds, args._[1]);
1554
+ requireYes(args, `This deletes the job "${job.name}" and its run history.`);
1555
+ await apiJSON(creds, 'DELETE', `/projects/${encodeURIComponent(job.project_slug)}/jobs/${job.id}`);
1556
+ if (JSON_MODE) return emitJSON({ ok: true });
1557
+ info(`${green('deleted')} ${bold(job.name)}`);
1558
+ return;
1559
+ }
1560
+
1561
+ die('usage: canner jobs [list] | create <name> ... | edit <id> ... | run <id> | runs <id> | delete <id> --yes', 2);
1562
+ }
1563
+
1357
1564
  async function cmdDb(args) {
1358
1565
  const creds = await loadCredentials();
1359
1566
  if (!creds.token) die("not signed in. Run `canner login`.");
@@ -2672,6 +2879,13 @@ ${bold('Databases:')}
2672
2879
  Create the primary on its own dedicated instance with
2673
2880
  point-in-time recovery. If the project has no unit, this
2674
2881
  buys one: it shows the price and needs --yes.
2882
+ ${bold('jobs')} [list] | create <name> (--path /p [--branch B] | --url U) --schedule "CRON" | edit <id> | run <id> | runs <id> | delete <id> --yes
2883
+ Scheduled jobs. --path calls a path in the project itself
2884
+ (production, or a preview with --branch). A header can read a
2885
+ project variable: --header-env x-cron-key=CRON_KEY or
2886
+ --bearer-env CRON_SECRET. "jobs edit <id>" changes only the
2887
+ options you give (--schedule, --path, --header-env,
2888
+ --remove-header, --pause / --resume ...).
2675
2889
  ${bold('billing')} addons | preview <addon> <quantity>
2676
2890
  See the add-ons you own and where they are assigned, and
2677
2891
  preview a quantity change. Read-only; nothing is charged.
@@ -2855,6 +3069,8 @@ const dispatch = {
2855
3069
  env: cmdEnv,
2856
3070
  domains: cmdDomains,
2857
3071
  billing: cmdBilling,
3072
+ jobs: cmdJobs,
3073
+ job: cmdJobs,
2858
3074
  db: cmdDb,
2859
3075
  database: cmdDb,
2860
3076
  databases: cmdDb,