@canner-ca/cli 0.5.3 → 0.5.5

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 +2 -1
  2. package/package.json +1 -1
  3. package/src/cli.mjs +100 -10
package/README.md CHANGED
@@ -81,7 +81,8 @@ canner deploy --follow
81
81
  | Command | What it does |
82
82
  |---|---|
83
83
  | `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. |
84
+ | `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`. |
85
+ | `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
86
  | `canner db create <name> [--pg 17\|18]` | Add another database (paid plans). Injected as `DATABASE_URL_<NAME>`. |
86
87
  | `canner db info [<name>] [--reveal]` / `canner db url [<name>]` | Connection details. The password is hidden unless `--reveal` (and is `null` in `--json`). |
87
88
  | `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.3",
3
+ "version": "0.5.5",
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.3';
31
+ const VERSION = '0.5.5';
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');
@@ -692,7 +692,7 @@ async function resolveTokenId(creds, ref) {
692
692
  // ---------------------------------------------------------------------------
693
693
  // canner env [--reveal] [--slug X] — list env vars
694
694
  // (sensitive values redacted by default, in text AND --json; --reveal opts out)
695
- // canner env set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
695
+ // canner env set KEY=VALUE [--env production|preview|runtime] [--sensitive] [--slug X]
696
696
  // canner env rm <KEY|id> [--env ...] [--slug X] — delete an env var
697
697
  // ---------------------------------------------------------------------------
698
698
  async function cmdEnv(args) {
@@ -760,10 +760,11 @@ async function cmdEnv(args) {
760
760
  die(`unknown env subcommand "${sub}". Use: list | set | rm.`, 2);
761
761
  }
762
762
 
763
- // Map a --env flag to the API's environment field. Accepts production/preview;
763
+ // Map a --env flag to the API's environment field. Accepts production / preview / runtime
764
+ // (runtime = the running app on production deploys only: never builds, the release command or previews);
764
765
  // anything else (or absent) means "all environments" (null).
765
766
  function normalizeEnvScope(flag) {
766
- if (flag === 'production' || flag === 'preview') return flag;
767
+ if (flag === 'production' || flag === 'preview' || flag === 'runtime') return flag;
767
768
  return null;
768
769
  }
769
770
 
@@ -1119,7 +1120,7 @@ function sluggify(s) {
1119
1120
  const BOOLEAN_FLAGS = new Set([
1120
1121
  'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
1121
1122
  'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
1122
- 'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict',
1123
+ 'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict', 'dedicated',
1123
1124
  ]);
1124
1125
 
1125
1126
  const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
@@ -1165,7 +1166,8 @@ function parseArgs(argv) {
1165
1166
  // canner db … — project databases
1166
1167
  //
1167
1168
  // canner db [list] [--slug X] list the project's databases
1168
- // canner db init [--pg 17|18] [--slug X] provision the primary database
1169
+ // canner db init [--pg 17|18] [--dedicated [--pitr-days N] [--yes]] [--slug X]
1170
+ // provision the primary database (--dedicated: on its own instance)
1169
1171
  // canner db create <name> [--pg 17|18] add another database (paid plans)
1170
1172
  // canner db info [<name>] [--reveal] connection details (password hidden by default)
1171
1173
  // canner db url [<name>] print just the connection string
@@ -1222,6 +1224,12 @@ async function readSecretArg(args, urlFlag, envFlag, what) {
1222
1224
  die(`give the ${what} with --${urlFlag} <url>, --${envFlag} <VAR>, or --${urlFlag} - to type it.`, 2);
1223
1225
  }
1224
1226
 
1227
+ // Cents to "$9" / "$9.50". No currency marker: prices are shown as bare dollars everywhere but invoices.
1228
+ function fmtDollars(cents) {
1229
+ const n = (Number(cents) || 0) / 100;
1230
+ return `$${Number.isInteger(n) ? n : n.toFixed(2)}`;
1231
+ }
1232
+
1225
1233
  function requireYes(args, message) {
1226
1234
  if (args.yes === true || args.y === true) return;
1227
1235
  die(`${message} Re-run with --yes to confirm.`, 2);
@@ -1290,6 +1298,62 @@ async function startAndWait(creds, base, args, method, path, body, verb) {
1290
1298
  return waitForOperation(creds, base, op.id, args);
1291
1299
  }
1292
1300
 
1301
+ // ---------------------------------------------------------------------------
1302
+ // canner billing ... - add-ons, read-only
1303
+ //
1304
+ // canner billing addons what the organization owns, what is assigned where
1305
+ // canner billing preview <addon> <quantity> the monthly change a quantity would cause (no charge)
1306
+ //
1307
+ // Nothing here buys or changes anything. Buying is `canner db init --dedicated --yes`
1308
+ // (dedicated instance) or the dashboard's Billing page.
1309
+ // ---------------------------------------------------------------------------
1310
+ async function cmdBilling(args) {
1311
+ const creds = await loadCredentials();
1312
+ if (!creds.token) die("not signed in. Run `canner login`.");
1313
+ const sub = args._[0] ?? 'addons';
1314
+
1315
+ if (sub === 'addons' || sub === 'addon') {
1316
+ const body = await apiJSON(creds, 'GET', '/billing/addons');
1317
+ if (JSON_MODE) return emitJSON(body);
1318
+ info(`${pad(bold('ID'), 20)}${pad(bold('ADD-ON'), 46)}${pad(bold('PRICE'), 10)}${pad(bold('OWNED'), 7)}${bold('ASSIGNED')}`);
1319
+ for (const a of body?.catalog ?? []) {
1320
+ const assigned = a.assigned == null ? dim('pooled') : `${a.assigned} of ${a.owned}${a.unassigned > 0 ? yellow(` (${a.unassigned} unassigned)`) : ''}`;
1321
+ 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('-')}`);
1322
+ }
1323
+ const held = (body?.projects ?? []).filter((p) => Object.keys(p.allocations ?? {}).length > 0);
1324
+ if (held.length > 0) {
1325
+ info('');
1326
+ info(bold('Assigned to projects'));
1327
+ for (const p of held) {
1328
+ info(` ${pad(p.slug, 30)}${Object.entries(p.allocations).map(([id, q]) => `${id} x${q}`).join(', ')}`);
1329
+ }
1330
+ }
1331
+ info('');
1332
+ muted(`Add-ons cost ${fmtDollars(body?.addons_monthly_cents)}/month in total. Preview a change with: canner billing preview <addon> <quantity>`);
1333
+ return;
1334
+ }
1335
+
1336
+ if (sub === 'preview') {
1337
+ const addon = args._[1];
1338
+ const qty = Number(args._[2]);
1339
+ 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);
1340
+ const body = await apiJSON(creds, 'POST', '/billing/addons/preview', { addon_id: addon, quantity: qty });
1341
+ if (JSON_MODE) return emitJSON(body);
1342
+ const p = body?.preview;
1343
+ if (!p) die('the platform did not return a preview.');
1344
+ info(`${bold(p.display_name)}: ${p.current_quantity} owned -> ${p.new_quantity}`);
1345
+ const delta = p.monthly_delta_cents;
1346
+ 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)`)}`);
1347
+ if (p.blocked) info(` ${red('blocked')} ${p.blocked.message}`);
1348
+ if (p.needs_payment_method) info(` ${yellow('needs a card')} add a payment method before buying`);
1349
+ if (p.needs_subscription) info(` ${yellow('needs a plan')} a paid plan is required first`);
1350
+ muted('Nothing was charged or changed.');
1351
+ return;
1352
+ }
1353
+
1354
+ die('usage: canner billing addons | preview <addon> <quantity>', 2);
1355
+ }
1356
+
1293
1357
  async function cmdDb(args) {
1294
1358
  const creds = await loadCredentials();
1295
1359
  if (!creds.token) die("not signed in. Run `canner login`.");
@@ -1314,10 +1378,28 @@ async function cmdDb(args) {
1314
1378
  }
1315
1379
 
1316
1380
  if (sub === 'init' || sub === 'provision') {
1317
- const body = await apiJSON(creds, 'POST', `${base}/database`, args.pg ? { pg_version: String(args.pg) } : {});
1381
+ const req = args.pg ? { pg_version: String(args.pg) } : {};
1382
+ if (args.dedicated === true) {
1383
+ // A dedicated instance is a paid add-on. If the project has no unit assigned, creating one buys one,
1384
+ // so say the price and require --yes before anything is charged.
1385
+ const inst = await apiJSON(creds, 'GET', `${base}/instance`);
1386
+ if (!inst?.offered) die('a dedicated instance is not available on this plan.');
1387
+ const dedicated = { pitr: true };
1388
+ if (args['pitr-days'] !== undefined) {
1389
+ const days = Number(args['pitr-days']);
1390
+ if (!Number.isInteger(days) || days < 1 || days > 35) die('--pitr-days must be a whole number from 1 to 35.', 2);
1391
+ dedicated.window_days = days;
1392
+ }
1393
+ if (!inst.allowed) {
1394
+ 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.`);
1395
+ dedicated.confirm_addon_purchase = true;
1396
+ }
1397
+ req.dedicated = dedicated;
1398
+ }
1399
+ const body = await apiJSON(creds, 'POST', `${base}/database`, req);
1318
1400
  if (JSON_MODE) return emitJSON(body);
1319
1401
  const d = body?.database;
1320
- info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
1402
+ info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version}${args.dedicated === true ? ', dedicated instance' : ''})`)}`);
1321
1403
  muted('The connection string is injected as DATABASE_URL on your next deploy.');
1322
1404
  return;
1323
1405
  }
@@ -2075,9 +2157,9 @@ async function cmdDb(args) {
2075
2157
 
2076
2158
  if (sub === 'least-privilege') {
2077
2159
  // One step to the usual safe arrangement: the app logs in as a limited role, the owner (which can change the schema) is
2078
- // given to the BUILD only, as DATABASE_URL_MIGRATE, and every migration ledger stays read-only for the app's role.
2160
+ // given to the release command only, as DATABASE_URL_MIGRATE, and every migration ledger stays read-only for the app's role.
2079
2161
  const label = args._[1] ?? 'primary';
2080
- requireYes(args, 'The app will run as a limited role (it can read and write data but cannot change the schema) from its next deploy, and the database owner will be given to the build only, as DATABASE_URL_MIGRATE: run your migrations there.');
2162
+ requireYes(args, 'The app will run as a limited role (it can read and write data but cannot change the schema) from its next deploy, and the database owner will be given to the release command only, as DATABASE_URL_MIGRATE: run your migrations there.');
2081
2163
  const r = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/least-privilege`, {
2082
2164
  confirm: true,
2083
2165
  ...(typeof args.label === 'string' ? { label: args.label } : {}),
@@ -2586,6 +2668,13 @@ ${bold('Databases:')}
2586
2668
  ${bold('db')} [list] [--slug X] List the project's databases.
2587
2669
  ${bold('db')} init | create <name> [--pg 17|18]
2588
2670
  Provision the primary database, or add another (paid).
2671
+ ${bold('db')} init --dedicated [--pitr-days N] [--yes]
2672
+ Create the primary on its own dedicated instance with
2673
+ point-in-time recovery. If the project has no unit, this
2674
+ buys one: it shows the price and needs --yes.
2675
+ ${bold('billing')} addons | preview <addon> <quantity>
2676
+ See the add-ons you own and where they are assigned, and
2677
+ preview a quantity change. Read-only; nothing is charged.
2589
2678
  ${bold('db')} info [<name>] [--reveal] | url [<name>]
2590
2679
  Connection details (password hidden unless --reveal).
2591
2680
  ${bold('db')} export [<name>] [--format custom|plain] [--out FILE]
@@ -2765,6 +2854,7 @@ const dispatch = {
2765
2854
  tokens: cmdToken,
2766
2855
  env: cmdEnv,
2767
2856
  domains: cmdDomains,
2857
+ billing: cmdBilling,
2768
2858
  db: cmdDb,
2769
2859
  database: cmdDb,
2770
2860
  databases: cmdDb,