@canner-ca/cli 0.5.4 → 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 +7 -2
- package/package.json +1 -1
- package/src/cli.mjs +265 -20
package/README.md
CHANGED
|
@@ -69,19 +69,24 @@ 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
|
|
|
81
85
|
| Command | What it does |
|
|
82
86
|
|---|---|
|
|
83
87
|
| `canner db [list] [--slug X]` | List the project's databases (the primary is injected as `DATABASE_URL`). |
|
|
84
|
-
| `canner db init [--pg 17\|18]` | Provision the project's primary database. |
|
|
88
|
+
| `canner db init [--pg 17\|18] [--dedicated [--pitr-days N] [--yes]]` | Provision the project's primary database. `--dedicated` puts it on its own instance with point-in-time recovery; if the project has no dedicated-instance unit, that adds one to your plan, so the CLI shows the price and needs `--yes`. |
|
|
89
|
+
| `canner billing addons` / `canner billing preview <addon> <quantity>` | Read-only: the add-ons your organization owns and where they are assigned, and the monthly change a quantity would cause. Nothing is charged. |
|
|
85
90
|
| `canner db create <name> [--pg 17\|18]` | Add another database (paid plans). Injected as `DATABASE_URL_<NAME>`. |
|
|
86
91
|
| `canner db info [<name>] [--reveal]` / `canner db url [<name>]` | Connection details. The password is hidden unless `--reveal` (and is `null` in `--json`). |
|
|
87
92
|
| `canner db export [<name>] [--format custom\|plain] [--out FILE]` | Dump a database and download it. `custom` restores with `pg_restore`, `plain` is gzipped SQL. Every plan. |
|
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
|
}
|
|
@@ -1120,7 +1128,7 @@ function sluggify(s) {
|
|
|
1120
1128
|
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
|
-
'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict',
|
|
1131
|
+
'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict', 'dedicated',
|
|
1124
1132
|
]);
|
|
1125
1133
|
|
|
1126
1134
|
const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
|
|
@@ -1166,7 +1174,8 @@ function parseArgs(argv) {
|
|
|
1166
1174
|
// canner db … — project databases
|
|
1167
1175
|
//
|
|
1168
1176
|
// canner db [list] [--slug X] list the project's databases
|
|
1169
|
-
// canner db init [--pg 17|18] [--
|
|
1177
|
+
// canner db init [--pg 17|18] [--dedicated [--pitr-days N] [--yes]] [--slug X]
|
|
1178
|
+
// provision the primary database (--dedicated: on its own instance)
|
|
1170
1179
|
// canner db create <name> [--pg 17|18] add another database (paid plans)
|
|
1171
1180
|
// canner db info [<name>] [--reveal] connection details (password hidden by default)
|
|
1172
1181
|
// canner db url [<name>] print just the connection string
|
|
@@ -1223,6 +1232,12 @@ async function readSecretArg(args, urlFlag, envFlag, what) {
|
|
|
1223
1232
|
die(`give the ${what} with --${urlFlag} <url>, --${envFlag} <VAR>, or --${urlFlag} - to type it.`, 2);
|
|
1224
1233
|
}
|
|
1225
1234
|
|
|
1235
|
+
// Cents to "$9" / "$9.50". No currency marker: prices are shown as bare dollars everywhere but invoices.
|
|
1236
|
+
function fmtDollars(cents) {
|
|
1237
|
+
const n = (Number(cents) || 0) / 100;
|
|
1238
|
+
return `$${Number.isInteger(n) ? n : n.toFixed(2)}`;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1226
1241
|
function requireYes(args, message) {
|
|
1227
1242
|
if (args.yes === true || args.y === true) return;
|
|
1228
1243
|
die(`${message} Re-run with --yes to confirm.`, 2);
|
|
@@ -1291,6 +1306,203 @@ async function startAndWait(creds, base, args, method, path, body, verb) {
|
|
|
1291
1306
|
return waitForOperation(creds, base, op.id, args);
|
|
1292
1307
|
}
|
|
1293
1308
|
|
|
1309
|
+
// ---------------------------------------------------------------------------
|
|
1310
|
+
// canner billing ... - add-ons, read-only
|
|
1311
|
+
//
|
|
1312
|
+
// canner billing addons what the organization owns, what is assigned where
|
|
1313
|
+
// canner billing preview <addon> <quantity> the monthly change a quantity would cause (no charge)
|
|
1314
|
+
//
|
|
1315
|
+
// Nothing here buys or changes anything. Buying is `canner db init --dedicated --yes`
|
|
1316
|
+
// (dedicated instance) or the dashboard's Billing page.
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
async function cmdBilling(args) {
|
|
1319
|
+
const creds = await loadCredentials();
|
|
1320
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
1321
|
+
const sub = args._[0] ?? 'addons';
|
|
1322
|
+
|
|
1323
|
+
if (sub === 'addons' || sub === 'addon') {
|
|
1324
|
+
const body = await apiJSON(creds, 'GET', '/billing/addons');
|
|
1325
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1326
|
+
info(`${pad(bold('ID'), 20)}${pad(bold('ADD-ON'), 46)}${pad(bold('PRICE'), 10)}${pad(bold('OWNED'), 7)}${bold('ASSIGNED')}`);
|
|
1327
|
+
for (const a of body?.catalog ?? []) {
|
|
1328
|
+
const assigned = a.assigned == null ? dim('pooled') : `${a.assigned} of ${a.owned}${a.unassigned > 0 ? yellow(` (${a.unassigned} unassigned)`) : ''}`;
|
|
1329
|
+
info(`${pad(dim(a.id), 20)}${pad(a.display_name, 46)}${pad(`${fmtDollars(a.price_monthly_cents)}/mo`, 10)}${pad(String(a.owned), 7)}${a.owned > 0 ? assigned : dim('-')}`);
|
|
1330
|
+
}
|
|
1331
|
+
const held = (body?.projects ?? []).filter((p) => Object.keys(p.allocations ?? {}).length > 0);
|
|
1332
|
+
if (held.length > 0) {
|
|
1333
|
+
info('');
|
|
1334
|
+
info(bold('Assigned to projects'));
|
|
1335
|
+
for (const p of held) {
|
|
1336
|
+
info(` ${pad(p.slug, 30)}${Object.entries(p.allocations).map(([id, q]) => `${id} x${q}`).join(', ')}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
info('');
|
|
1340
|
+
muted(`Add-ons cost ${fmtDollars(body?.addons_monthly_cents)}/month in total. Preview a change with: canner billing preview <addon> <quantity>`);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (sub === 'preview') {
|
|
1345
|
+
const addon = args._[1];
|
|
1346
|
+
const qty = Number(args._[2]);
|
|
1347
|
+
if (!addon || !Number.isInteger(qty) || qty < 0) die('usage: canner billing preview <addon> <quantity> (the quantity you would own in total, e.g. dedicated_instance 3)', 2);
|
|
1348
|
+
const body = await apiJSON(creds, 'POST', '/billing/addons/preview', { addon_id: addon, quantity: qty });
|
|
1349
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1350
|
+
const p = body?.preview;
|
|
1351
|
+
if (!p) die('the platform did not return a preview.');
|
|
1352
|
+
info(`${bold(p.display_name)}: ${p.current_quantity} owned -> ${p.new_quantity}`);
|
|
1353
|
+
const delta = p.monthly_delta_cents;
|
|
1354
|
+
info(` monthly change ${delta === 0 ? 'none' : `${delta > 0 ? '+' : '-'}${fmtDollars(Math.abs(delta))}`} ${dim(`(${fmtDollars(p.unit_price_cents)} each; add-ons would total ${fmtDollars(p.new_addons_monthly_cents)}/month)`)}`);
|
|
1355
|
+
if (p.blocked) info(` ${red('blocked')} ${p.blocked.message}`);
|
|
1356
|
+
if (p.needs_payment_method) info(` ${yellow('needs a card')} add a payment method before buying`);
|
|
1357
|
+
if (p.needs_subscription) info(` ${yellow('needs a plan')} a paid plan is required first`);
|
|
1358
|
+
muted('Nothing was charged or changed.');
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
die('usage: canner billing addons | preview <addon> <quantity>', 2);
|
|
1363
|
+
}
|
|
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
|
+
|
|
1294
1506
|
async function cmdDb(args) {
|
|
1295
1507
|
const creds = await loadCredentials();
|
|
1296
1508
|
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
@@ -1315,10 +1527,28 @@ async function cmdDb(args) {
|
|
|
1315
1527
|
}
|
|
1316
1528
|
|
|
1317
1529
|
if (sub === 'init' || sub === 'provision') {
|
|
1318
|
-
const
|
|
1530
|
+
const req = args.pg ? { pg_version: String(args.pg) } : {};
|
|
1531
|
+
if (args.dedicated === true) {
|
|
1532
|
+
// A dedicated instance is a paid add-on. If the project has no unit assigned, creating one buys one,
|
|
1533
|
+
// so say the price and require --yes before anything is charged.
|
|
1534
|
+
const inst = await apiJSON(creds, 'GET', `${base}/instance`);
|
|
1535
|
+
if (!inst?.offered) die('a dedicated instance is not available on this plan.');
|
|
1536
|
+
const dedicated = { pitr: true };
|
|
1537
|
+
if (args['pitr-days'] !== undefined) {
|
|
1538
|
+
const days = Number(args['pitr-days']);
|
|
1539
|
+
if (!Number.isInteger(days) || days < 1 || days > 35) die('--pitr-days must be a whole number from 1 to 35.', 2);
|
|
1540
|
+
dedicated.window_days = days;
|
|
1541
|
+
}
|
|
1542
|
+
if (!inst.allowed) {
|
|
1543
|
+
requireYes(args, `This project has no dedicated-instance add-on assigned. Creating the database on a dedicated instance adds one to your plan at ${fmtDollars(inst.price_monthly_cents)}/month, billed to your organization.`);
|
|
1544
|
+
dedicated.confirm_addon_purchase = true;
|
|
1545
|
+
}
|
|
1546
|
+
req.dedicated = dedicated;
|
|
1547
|
+
}
|
|
1548
|
+
const body = await apiJSON(creds, 'POST', `${base}/database`, req);
|
|
1319
1549
|
if (JSON_MODE) return emitJSON(body);
|
|
1320
1550
|
const d = body?.database;
|
|
1321
|
-
info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
|
|
1551
|
+
info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version}${args.dedicated === true ? ', dedicated instance' : ''})`)}`);
|
|
1322
1552
|
muted('The connection string is injected as DATABASE_URL on your next deploy.');
|
|
1323
1553
|
return;
|
|
1324
1554
|
}
|
|
@@ -2587,6 +2817,18 @@ ${bold('Databases:')}
|
|
|
2587
2817
|
${bold('db')} [list] [--slug X] List the project's databases.
|
|
2588
2818
|
${bold('db')} init | create <name> [--pg 17|18]
|
|
2589
2819
|
Provision the primary database, or add another (paid).
|
|
2820
|
+
${bold('db')} init --dedicated [--pitr-days N] [--yes]
|
|
2821
|
+
Create the primary on its own dedicated instance with
|
|
2822
|
+
point-in-time recovery. If the project has no unit, this
|
|
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.
|
|
2829
|
+
${bold('billing')} addons | preview <addon> <quantity>
|
|
2830
|
+
See the add-ons you own and where they are assigned, and
|
|
2831
|
+
preview a quantity change. Read-only; nothing is charged.
|
|
2590
2832
|
${bold('db')} info [<name>] [--reveal] | url [<name>]
|
|
2591
2833
|
Connection details (password hidden unless --reveal).
|
|
2592
2834
|
${bold('db')} export [<name>] [--format custom|plain] [--out FILE]
|
|
@@ -2766,6 +3008,9 @@ const dispatch = {
|
|
|
2766
3008
|
tokens: cmdToken,
|
|
2767
3009
|
env: cmdEnv,
|
|
2768
3010
|
domains: cmdDomains,
|
|
3011
|
+
billing: cmdBilling,
|
|
3012
|
+
jobs: cmdJobs,
|
|
3013
|
+
job: cmdJobs,
|
|
2769
3014
|
db: cmdDb,
|
|
2770
3015
|
database: cmdDb,
|
|
2771
3016
|
databases: cmdDb,
|