@canner-ca/cli 0.5.4 → 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 +94 -5
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.4",
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.4';
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');
@@ -1120,7 +1120,7 @@ function sluggify(s) {
1120
1120
  const BOOLEAN_FLAGS = new Set([
1121
1121
  'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
1122
1122
  'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
1123
- 'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict',
1123
+ 'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict', 'dedicated',
1124
1124
  ]);
1125
1125
 
1126
1126
  const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
@@ -1166,7 +1166,8 @@ function parseArgs(argv) {
1166
1166
  // canner db … — project databases
1167
1167
  //
1168
1168
  // canner db [list] [--slug X] list the project's databases
1169
- // 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)
1170
1171
  // canner db create <name> [--pg 17|18] add another database (paid plans)
1171
1172
  // canner db info [<name>] [--reveal] connection details (password hidden by default)
1172
1173
  // canner db url [<name>] print just the connection string
@@ -1223,6 +1224,12 @@ async function readSecretArg(args, urlFlag, envFlag, what) {
1223
1224
  die(`give the ${what} with --${urlFlag} <url>, --${envFlag} <VAR>, or --${urlFlag} - to type it.`, 2);
1224
1225
  }
1225
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
+
1226
1233
  function requireYes(args, message) {
1227
1234
  if (args.yes === true || args.y === true) return;
1228
1235
  die(`${message} Re-run with --yes to confirm.`, 2);
@@ -1291,6 +1298,62 @@ async function startAndWait(creds, base, args, method, path, body, verb) {
1291
1298
  return waitForOperation(creds, base, op.id, args);
1292
1299
  }
1293
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
+
1294
1357
  async function cmdDb(args) {
1295
1358
  const creds = await loadCredentials();
1296
1359
  if (!creds.token) die("not signed in. Run `canner login`.");
@@ -1315,10 +1378,28 @@ async function cmdDb(args) {
1315
1378
  }
1316
1379
 
1317
1380
  if (sub === 'init' || sub === 'provision') {
1318
- 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);
1319
1400
  if (JSON_MODE) return emitJSON(body);
1320
1401
  const d = body?.database;
1321
- 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' : ''})`)}`);
1322
1403
  muted('The connection string is injected as DATABASE_URL on your next deploy.');
1323
1404
  return;
1324
1405
  }
@@ -2587,6 +2668,13 @@ ${bold('Databases:')}
2587
2668
  ${bold('db')} [list] [--slug X] List the project's databases.
2588
2669
  ${bold('db')} init | create <name> [--pg 17|18]
2589
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.
2590
2678
  ${bold('db')} info [<name>] [--reveal] | url [<name>]
2591
2679
  Connection details (password hidden unless --reveal).
2592
2680
  ${bold('db')} export [<name>] [--format custom|plain] [--out FILE]
@@ -2766,6 +2854,7 @@ const dispatch = {
2766
2854
  tokens: cmdToken,
2767
2855
  env: cmdEnv,
2768
2856
  domains: cmdDomains,
2857
+ billing: cmdBilling,
2769
2858
  db: cmdDb,
2770
2859
  database: cmdDb,
2771
2860
  databases: cmdDb,