@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.
- package/README.md +6 -1
- package/package.json +1 -1
- 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
|
|
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
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.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
|
|
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
|
}
|
|
@@ -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,
|