@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.
- package/README.md +2 -1
- package/package.json +1 -1
- 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
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.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] [--
|
|
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
|
|
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,
|