@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.
Files changed (3) hide show
  1. package/README.md +7 -2
  2. package/package.json +1 -1
  3. 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 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
 
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.5.4",
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.4';
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
  }
@@ -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] [--slug X] provision the primary database
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 body = await apiJSON(creds, 'POST', `${base}/database`, args.pg ? { pg_version: String(args.pg) } : {});
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,