@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.
- package/README.md +5 -1
- package/package.json +1 -1
- 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
|
|
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
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.
|
|
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
|
|
730
|
-
if (
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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,
|