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