@canner-ca/cli 0.5.2 → 0.5.4
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/package.json +1 -1
- package/src/cli.mjs +100 -6
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.4';
|
|
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
|
|
|
@@ -1179,6 +1180,8 @@ function parseArgs(argv) {
|
|
|
1179
1180
|
// canner db ops [<id>] recent operations / one operation
|
|
1180
1181
|
// canner db roles [<name>] [create|member-of|audit|rotate|rm|write-exclusions …] roles, groups and logins
|
|
1181
1182
|
// canner db inject [<name>] [<login>|owner|off|on] which credential the app receives
|
|
1183
|
+
// canner db least-privilege [<name>] [--label app] --yes run the app as a limited role; the owner goes to the build only
|
|
1184
|
+
// canner db check [<name>] [--category security|performance] [--strict] [--json] security and performance check
|
|
1182
1185
|
//
|
|
1183
1186
|
// Long operations (export, import, migrate, copy) run on the platform: the CLI
|
|
1184
1187
|
// starts one, then waits and shows progress. Pass --no-wait to return the
|
|
@@ -2020,6 +2023,34 @@ async function cmdDb(args) {
|
|
|
2020
2023
|
return;
|
|
2021
2024
|
}
|
|
2022
2025
|
|
|
2026
|
+
if (sub === 'check') {
|
|
2027
|
+
// A read-only security and performance check of one database (catalog only, never data). Works on every plan.
|
|
2028
|
+
const label = args._[1] ?? 'primary';
|
|
2029
|
+
if (args.category !== undefined && args.category !== 'security' && args.category !== 'performance') die('--category must be security or performance.', 2);
|
|
2030
|
+
const r = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/security-check`);
|
|
2031
|
+
const findings = typeof args.category === 'string' ? r.check.findings.filter((f) => f.category === args.category) : r.check.findings;
|
|
2032
|
+
const warn = findings.filter((f) => f.severity === 'warn').length;
|
|
2033
|
+
const note = findings.length - warn;
|
|
2034
|
+
if (JSON_MODE) emitJSON({ ...r.check, findings, summary: { warn, info: note } });
|
|
2035
|
+
else {
|
|
2036
|
+
info(`${bold(label)} ${warn > 0 ? yellow(`${warn} warning${warn === 1 ? '' : 's'}`) : green('no warnings')} ${dim(`· ${note} note${note === 1 ? '' : 's'}`)}`);
|
|
2037
|
+
for (const category of ['security', 'performance']) {
|
|
2038
|
+
const group = findings.filter((f) => f.category === category);
|
|
2039
|
+
if (group.length === 0) continue;
|
|
2040
|
+
info('');
|
|
2041
|
+
info(bold(category.toUpperCase()));
|
|
2042
|
+
for (const f of group) {
|
|
2043
|
+
const objs = f.objects.length > 0 ? ` ${dim(`[${f.objects.join(', ')}${f.more ? ', …' : ''}]`)}` : '';
|
|
2044
|
+
info(` ${f.severity === 'warn' ? yellow('warn') : dim('info')} ${f.message}${objs}`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (findings.length === 0) info(green('nothing to report'));
|
|
2048
|
+
}
|
|
2049
|
+
// For CI: --strict fails the run when anything is a warning.
|
|
2050
|
+
if (args.strict === true && warn > 0) process.exitCode = 1;
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2023
2054
|
if (sub === 'inject') {
|
|
2024
2055
|
// Which credential the app receives as DATABASE_URL (or DATABASE_URL_<NAME>): the owner's (the default) or one of your logins.
|
|
2025
2056
|
const label = args._[1] ?? 'primary';
|
|
@@ -2043,6 +2074,26 @@ async function cmdDb(args) {
|
|
|
2043
2074
|
return;
|
|
2044
2075
|
}
|
|
2045
2076
|
|
|
2077
|
+
if (sub === 'least-privilege') {
|
|
2078
|
+
// One step to the usual safe arrangement: the app logs in as a limited role, the owner (which can change the schema) is
|
|
2079
|
+
// given to the release command only, as DATABASE_URL_MIGRATE, and every migration ledger stays read-only for the app's role.
|
|
2080
|
+
const label = args._[1] ?? 'primary';
|
|
2081
|
+
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.');
|
|
2082
|
+
const r = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/least-privilege`, {
|
|
2083
|
+
confirm: true,
|
|
2084
|
+
...(typeof args.label === 'string' ? { label: args.label } : {}),
|
|
2085
|
+
});
|
|
2086
|
+
if (JSON_MODE) return emitJSON(r.least_privilege);
|
|
2087
|
+
const lp = r.least_privilege;
|
|
2088
|
+
const envVar = label === 'primary' ? 'DATABASE_URL' : `DATABASE_URL_${label.toUpperCase()}`;
|
|
2089
|
+
info(`${green(lp.role.created ? 'created' : 'using')} the role ${bold(lp.role.label)}; ${bold(envVar)} will carry it from the next deploy`);
|
|
2090
|
+
if (lp.protected_tables.length) info(` kept read-only for it: ${lp.protected_tables.join(', ')}`);
|
|
2091
|
+
for (const t of lp.suggestions ?? []) info(` ${yellow('look at')} ${t} ${dim(`(looks like a ledger; keep it read-only with: canner db roles ${label} write-exclusions ${lp.role.label} ${t})`)}`);
|
|
2092
|
+
info(` ${bold(lp.migration_var)} is available to the build only: run your migrations with it (for example ${dim(`DATABASE_URL=$${lp.migration_var} npx prisma migrate deploy`)}).`);
|
|
2093
|
+
muted('Run it again after your first migration: a ledger created later is also kept read-only for the app, within minutes, without this.');
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2046
2097
|
if (sub === 'roles') {
|
|
2047
2098
|
const label = args._[1] ?? 'primary';
|
|
2048
2099
|
const action = args._[2];
|
|
@@ -2341,7 +2392,46 @@ async function cmdDb(args) {
|
|
|
2341
2392
|
return;
|
|
2342
2393
|
}
|
|
2343
2394
|
|
|
2344
|
-
|
|
2395
|
+
if (action === 'upgrade') {
|
|
2396
|
+
const what = args._[2];
|
|
2397
|
+
const up = `${path}/upgrade`;
|
|
2398
|
+
if (!what || what === 'check') {
|
|
2399
|
+
const body = await apiJSON(creds, 'GET', up);
|
|
2400
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2401
|
+
info(`${bold('Postgres')} ${body.current}${body.targets.length ? dim(` can be upgraded to ${body.targets.join(', ')}`) : dim(' (already on the newest version offered)')}`);
|
|
2402
|
+
for (const b of body.blockers ?? []) info(` ${yellow('blocked')} ${b.message}`);
|
|
2403
|
+
if (body.targets.length && !(body.blockers ?? []).length) muted('Upgrade with `canner db instance upgrade run --yes` (every database on the instance goes offline for a few minutes).');
|
|
2404
|
+
if (body.rollback) info(` ${dim(`the previous version (Postgres ${body.rollback.to_version}) is kept until ${body.rollback.until}: \`canner db instance upgrade rollback --yes\` goes back, \`... finish --yes\` discards it`)}`);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (what === 'run') {
|
|
2408
|
+
requireYes(args, 'Upgrading restarts every database on the instance on a newer Postgres: they are offline for a few minutes. Roles, grants, policies and extensions carry over and the connection strings do not change. Point-in-time recovery starts a fresh window. If it fails, the instance is left as it was.');
|
|
2409
|
+
const done = await startAndWait(creds, base, args, 'POST', up, { ...(args.pg ? { target: String(args.pg) } : {}), confirm: true }, 'upgrading');
|
|
2410
|
+
if (!done) return;
|
|
2411
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2412
|
+
info(`${green('upgraded')} the instance from Postgres ${done.result?.from_pg_version} to ${done.result?.to_pg_version}`);
|
|
2413
|
+
muted('The previous version is kept for 7 days in case you need to roll back: `canner db instance upgrade rollback --yes`.');
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
if (what === 'rollback') {
|
|
2417
|
+
requireYes(args, 'A rollback goes back to the version before the upgrade and LOSES everything written since. Point-in-time recovery starts a fresh window.');
|
|
2418
|
+
const done = await startAndWait(creds, base, args, 'POST', `${up}/rollback`, { confirm: true }, 'rolling back');
|
|
2419
|
+
if (!done) return;
|
|
2420
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2421
|
+
info(`${green('rolled back')} the instance to Postgres ${done.result?.to_pg_version}`);
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (what === 'finish') {
|
|
2425
|
+
requireYes(args, 'This discards the previous version, so a rollback is no longer possible.');
|
|
2426
|
+
const body = await apiJSON(creds, 'POST', `${up}/finish`, { confirm: true });
|
|
2427
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2428
|
+
info(`${green('done')}: the previous version was removed`);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
die('usage: canner db instance upgrade [check] | run [--pg 18] --yes | rollback --yes | finish --yes', 2);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
die('usage: canner db instance [show] | create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | upgrade [check|run|rollback|finish] | destroy --yes', 2);
|
|
2345
2435
|
}
|
|
2346
2436
|
|
|
2347
2437
|
if (sub === 'pitr') {
|
|
@@ -2439,7 +2529,7 @@ async function cmdDb(args) {
|
|
|
2439
2529
|
return;
|
|
2440
2530
|
}
|
|
2441
2531
|
|
|
2442
|
-
die(`unknown db subcommand "${sub}". Use: list | init | create | info | url | rm | pg-version | export | import | migrate-in | migrate-out | copy | ops | external | rotate | backup-policy | backup | backups | diff | inspect | alerts | roles | offsite | metrics | monitoring | insights | logs | resources | extensions | tuning | restart | replication | subscriptions | instance | pitr | pitr-restore | move.`, 2);
|
|
2532
|
+
die(`unknown db subcommand "${sub}". Use: list | init | create | info | url | rm | pg-version | export | import | migrate-in | migrate-out | copy | ops | external | rotate | backup-policy | backup | backups | diff | inspect | alerts | roles | least-privilege | offsite | metrics | monitoring | insights | logs | resources | extensions | tuning | restart | replication | subscriptions | instance | pitr | pitr-restore | move.`, 2);
|
|
2443
2533
|
}
|
|
2444
2534
|
|
|
2445
2535
|
function printHelp() {
|
|
@@ -2567,6 +2657,10 @@ ${bold('Databases:')}
|
|
|
2567
2657
|
${bold('db')} roles <name> rm <role> --yes
|
|
2568
2658
|
${bold('db')} roles <name> write-exclusions <role> <table>
|
|
2569
2659
|
Keep one more table SELECT-only for a read-write role, live — no recreation.
|
|
2660
|
+
${bold('db')} check [<name>] [--category security|performance] [--strict] [--json]
|
|
2661
|
+
A read-only security and performance check of a database (every plan): policies that allow every
|
|
2662
|
+
row, functions anyone can run as their owner, views that bypass row-level security, missing primary
|
|
2663
|
+
keys and indexes, duplicate indexes. --strict fails the run on any warning, for CI.
|
|
2570
2664
|
${bold('db')} inject [<name>] [<login> | owner | off | on]
|
|
2571
2665
|
Which credential the app receives as DATABASE_URL: the owner's (default) or one of your
|
|
2572
2666
|
logins. Takes effect on the next deploy.
|