@canner-ca/cli 0.5.1 → 0.5.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +135 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canner-ca/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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.1';
31
+ const VERSION = '0.5.2';
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');
@@ -1119,8 +1119,11 @@ function sluggify(s) {
1119
1119
  const BOOLEAN_FLAGS = new Set([
1120
1120
  'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
1121
1121
  'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
1122
+ 'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict',
1122
1123
  ]);
1123
1124
 
1125
+ const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
1126
+
1124
1127
  // A flag repeated on the command line (e.g. `--allow a --allow b`) accumulates
1125
1128
  // into an array instead of the last one silently winning.
1126
1129
  function setArg(out, key, value) {
@@ -1174,6 +1177,8 @@ function parseArgs(argv) {
1174
1177
  // canner db migrate-out [--from <name>] --to-url <url>|--to-env VAR|--to-url - --yes
1175
1178
  // canner db copy <from> <to> [--to-project SLUG] [--replace]
1176
1179
  // canner db ops [<id>] recent operations / one operation
1180
+ // canner db roles [<name>] [create|member-of|audit|rotate|rm|write-exclusions …] roles, groups and logins
1181
+ // canner db inject [<name>] [<login>|owner|off|on] which credential the app receives
1177
1182
  //
1178
1183
  // Long operations (export, import, migrate, copy) run on the platform: the CLI
1179
1184
  // starts one, then waits and shows progress. Pass --no-wait to return the
@@ -1299,7 +1304,7 @@ async function cmdDb(args) {
1299
1304
  info(`${pad(bold('NAME'), 22)}${pad(bold('POSTGRES'), 11)}${pad(bold('SIZE'), 11)}${bold('STATUS')}`);
1300
1305
  for (const d of rows) {
1301
1306
  const status = d.over_quota_at ? red('write-locked (over quota)') : green('ok');
1302
- info(`${pad(d.is_primary ? `${d.name} ${dim('(DATABASE_URL)')}` : d.name, 22)}${pad(d.pg_version, 11)}${pad(`${d.size_mb} MB`, 11)}${status}`);
1307
+ info(`${pad(d.is_primary ? `${d.name} ${dim(`(DATABASE_URL${d.inject_role ? ` as ${d.inject_role}` : ''})`)}` : d.name, 22)}${pad(d.pg_version, 11)}${pad(`${d.size_mb} MB`, 11)}${status}`);
1303
1308
  }
1304
1309
  const l = body?.limits;
1305
1310
  if (l && !l.advanced_enabled) muted('Additional databases, import and migration are available on paid plans.');
@@ -2015,19 +2020,62 @@ async function cmdDb(args) {
2015
2020
  return;
2016
2021
  }
2017
2022
 
2023
+ if (sub === 'inject') {
2024
+ // Which credential the app receives as DATABASE_URL (or DATABASE_URL_<NAME>): the owner's (the default) or one of your logins.
2025
+ const label = args._[1] ?? 'primary';
2026
+ const what = args._[2];
2027
+ const envVar = label === 'primary' ? 'DATABASE_URL' : `DATABASE_URL_${label.toUpperCase()}`;
2028
+ if (what === undefined) {
2029
+ const body = await apiJSON(creds, 'GET', `${base}/databases`);
2030
+ const d = (body?.databases ?? []).find((x) => x.name === label);
2031
+ if (!d) die(`no database named "${label}".`);
2032
+ if (JSON_MODE) return emitJSON({ name: d.name, auto_inject: d.auto_inject, inject_role: d.inject_role ?? null });
2033
+ info(`${bold(envVar)} ${!d.auto_inject ? yellow('not injected') : d.inject_role ? `the login ${green(d.inject_role)}` : 'the database owner'}`);
2034
+ muted('Change it: canner db inject <db> <login> | owner | off | on');
2035
+ return;
2036
+ }
2037
+ const patch = what === 'on' ? { auto_inject: true } : what === 'off' ? { auto_inject: false } : what === 'owner' ? { inject_role: null } : { inject_role: what };
2038
+ const r = await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/settings`, patch);
2039
+ if (JSON_MODE) return emitJSON(r.settings);
2040
+ const st = r.settings;
2041
+ info(`${green('saved')} ${bold(envVar)} ${!st.auto_inject ? yellow('not injected') : st.inject_role ? `the login ${green(st.inject_role)}` : 'the database owner'}`);
2042
+ muted('Takes effect on the next deploy.');
2043
+ return;
2044
+ }
2045
+
2018
2046
  if (sub === 'roles') {
2019
2047
  const label = args._[1] ?? 'primary';
2020
2048
  const action = args._[2];
2021
2049
  const path = `${base}/databases/${encodeURIComponent(label)}/roles`;
2022
2050
  if (action === 'create') {
2023
2051
  const name = args._[3];
2024
- if (!name) die('usage: canner db roles <db> create <name> --read-only | --read-write | --replication', 2);
2025
- const picked = ['read-only', 'read-write', 'replication'].filter((k) => args[k] === true);
2026
- if (picked.length !== 1) die('choose exactly one of --read-only, --read-write or --replication.', 2);
2027
- const r = await apiJSON(creds, 'POST', path, { label: name, kind: picked[0].replace('-', '_') });
2052
+ if (!name) die('usage: canner db roles <db> create <name> --read-only | --read-write | --replication | --group | --login [--member-of g1,g2] [--adopt]', 2);
2053
+ const picked = ['read-only', 'read-write', 'replication', 'group', 'login'].filter((k) => args[k] === true);
2054
+ if (picked.length !== 1) die('choose exactly one of --read-only, --read-write, --replication, --group or --login.', 2);
2055
+ const kind = picked[0].replace('-', '_');
2056
+ const body = { label: name, kind };
2057
+ if (args['member-of'] !== undefined) {
2058
+ if (kind !== 'login') die('--member-of only applies to --login.', 2);
2059
+ body.member_of = splitList(args['member-of']);
2060
+ }
2061
+ if (args.adopt === true) {
2062
+ if (kind !== 'group') die('--adopt only applies to --group.', 2);
2063
+ body.adopt = true;
2064
+ }
2065
+ const r = await apiJSON(creds, 'POST', path, body);
2028
2066
  if (JSON_MODE) return emitJSON({ ...r.role, write_exclusion_suggestions: r.write_exclusion_suggestions });
2029
- info(`${green('created')} ${r.role.kind === 'read_write' ? 'read-write' : r.role.kind === 'replication' ? 'replication' : 'read-only'} role ${bold(r.role.label)}`);
2067
+ const kindWord = { read_write: 'read-write', replication: 'replication', read_only: 'read-only', group: 'group', login: 'login' }[r.role.kind];
2068
+ info(`${green(args.adopt === true ? 'adopted' : 'created')} ${kindWord} ${kind === 'group' || kind === 'login' ? '' : 'role '}${bold(r.role.label)}`);
2069
+ if (r.role.kind === 'group') {
2070
+ muted('Nothing logs in as a group. Grant it privileges (and attach row-level security policies) in your migrations, then give an app login membership:');
2071
+ info(` ${dim('canner db roles')} ${label} ${dim('create <name> --login --member-of')} ${bold(name)}`);
2072
+ return;
2073
+ }
2030
2074
  info(` ${cyan(r.role.connection_string)}`);
2075
+ if (r.role.kind === 'login') {
2076
+ info(` ${dim('member of:')} ${r.role.member_of?.length ? r.role.member_of.join(', ') : dim('no groups yet: it can connect and nothing else')}`);
2077
+ muted(`To run the app as this login: canner db inject ${label} ${name}`);
2078
+ }
2031
2079
  if (r.role.write_excluded_tables?.length) {
2032
2080
  info(` ${dim('kept SELECT-only:')} ${r.role.write_excluded_tables.join(', ')}`);
2033
2081
  }
@@ -2045,7 +2093,14 @@ async function cmdDb(args) {
2045
2093
  if (action === 'rotate' || action === 'rm') {
2046
2094
  const name = args._[3];
2047
2095
  if (!name) die(`usage: canner db roles <db> ${action} <name> --yes`, 2);
2048
- requireYes(args, action === 'rm' ? `This drops the role "${name}"; anything using its connection string stops working.` : `This gives "${name}" a new password; anything using the old one stops working.`);
2096
+ let rmMessage = `This drops the role "${name}"; anything using its connection string stops working.`;
2097
+ if (action === 'rm') {
2098
+ const cur = (await apiJSON(creds, 'GET', path)).roles?.find((x) => x.label === name);
2099
+ if (cur?.kind === 'group') {
2100
+ rmMessage = `This deletes the group "${name}", the grants you made to it in this database, and any row-level security policy that applies only to it. Logins that were members lose what it gave them.`;
2101
+ }
2102
+ }
2103
+ requireYes(args, action === 'rm' ? rmMessage : `This gives "${name}" a new password; anything using the old one stops working.`);
2049
2104
  if (action === 'rm') {
2050
2105
  await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
2051
2106
  if (JSON_MODE) return emitJSON({ ok: true, deleted: name });
@@ -2057,6 +2112,60 @@ async function cmdDb(args) {
2057
2112
  }
2058
2113
  return;
2059
2114
  }
2115
+ if (action === 'member-of') {
2116
+ const name = args._[3];
2117
+ const list = args._[4];
2118
+ if (!name || list === undefined) die('usage: canner db roles <db> member-of <login> <group>[,<group>...] (use "none" to remove every membership)', 2);
2119
+ const groups = list === 'none' ? [] : splitList(list);
2120
+ const r = await apiJSON(creds, 'PATCH', `${path}/${encodeURIComponent(name)}`, { member_of: groups });
2121
+ if (JSON_MODE) return emitJSON(r.role);
2122
+ info(`${green('updated')} ${bold(name)} ${dim('member of:')} ${r.role.member_of?.length ? r.role.member_of.join(', ') : dim('no groups')}`);
2123
+ return;
2124
+ }
2125
+ if (action === 'audit') {
2126
+ const r = await apiJSON(creds, 'GET', `${path}/audit`);
2127
+ const au = r.audit;
2128
+ if (JSON_MODE) emitJSON(au);
2129
+ else {
2130
+ info(`${bold('the app is given')} ${au.injected === 'owner' ? yellow('the owner') : green(au.injected)}${au.injected === 'owner' ? dim(' (change it with `canner db inject`)') : ''}`);
2131
+ info('');
2132
+ info(bold('roles'));
2133
+ for (const x of au.roles) {
2134
+ const flags = [
2135
+ x.can_login ? 'can log in' : 'no login', x.reads_all_data && 'reads all data', x.writes_all_data && 'writes all data',
2136
+ x.bypass_rls && 'bypasses RLS', x.superuser && 'superuser', x.create_role && 'creates roles', x.create_db && 'creates databases',
2137
+ ].filter(Boolean);
2138
+ info(` ${pad(bold(x.label), 24)}${pad(x.kind, 13)}${dim(flags.join(', '))}${x.member_of?.length ? ` ${dim('member of')} ${x.member_of.join(', ')}` : ''}`);
2139
+ }
2140
+ if (au.tables.length > 0) {
2141
+ info('');
2142
+ info(bold('tables'));
2143
+ info(dim(' S select I insert U update D delete'));
2144
+ for (const tb of au.tables) {
2145
+ const rls = tb.rls_enabled ? (tb.rls_forced ? 'RLS on, forced' : 'RLS on') : 'RLS off';
2146
+ const pol = tb.policies.length ? ` · ${tb.policies.length} polic${tb.policies.length === 1 ? 'y' : 'ies'}` : '';
2147
+ info(` ${bold(tb.schema === 'public' ? tb.table : `${tb.schema}.${tb.table}`)} ${dim(`owner ${tb.owner} · ${rls}${pol}`)}`);
2148
+ for (const x of au.roles) {
2149
+ const pr = tb.privileges[x.label];
2150
+ if (!pr) continue;
2151
+ const letters = `${pr.select ? 'S' : '-'}${pr.insert ? 'I' : '-'}${pr.update ? 'U' : '-'}${pr.delete ? 'D' : '-'}`;
2152
+ info(` ${pad(x.label, 24)}${letters === '----' ? dim(letters) : letters}`);
2153
+ }
2154
+ }
2155
+ }
2156
+ info('');
2157
+ if (au.findings.length === 0) info(green('no findings'));
2158
+ else {
2159
+ info(bold('findings'));
2160
+ for (const f of au.findings) {
2161
+ info(` ${f.severity === 'warn' ? yellow('warn') : dim('info')} ${f.message}${f.tables?.length ? ` ${dim(`[${f.tables.join(', ')}]`)}` : ''}${f.roles?.length ? ` ${dim(`(${f.roles.join(', ')})`)}` : ''}`);
2162
+ }
2163
+ }
2164
+ }
2165
+ // For CI: --strict fails the run when anything is a warning.
2166
+ if (args.strict === true && au.findings.some((f) => f.severity === 'warn')) process.exitCode = 1;
2167
+ return;
2168
+ }
2060
2169
  if (action === 'write-exclusions') {
2061
2170
  const name = args._[3];
2062
2171
  const table = args._[4];
@@ -2068,9 +2177,12 @@ async function cmdDb(args) {
2068
2177
  }
2069
2178
  const r = await apiJSON(creds, 'GET', path);
2070
2179
  if (JSON_MODE) return emitJSON(r.roles);
2071
- if (r.roles.length === 0) { muted(`no roles on ${label}. Create one with \`canner db roles ${label} create <name> --read-only\`.`); return; }
2180
+ if (r.roles.length === 0) { muted(`no roles on ${label}. Create one with \`canner db roles ${label} create <name> --read-only\`, or manage your own privileges with --group and --login.`); return; }
2072
2181
  for (const x of r.roles) {
2073
- info(`${pad(bold(x.label), 22)}${x.kind === 'read_write' ? yellow('read-write') : x.kind === 'replication' ? cyan('replication') : green('read-only')} ${dim(relTime(x.created_at))}`);
2182
+ const kindLabel = x.kind === 'read_write' ? yellow('read-write') : x.kind === 'replication' ? cyan('replication') : x.kind === 'group' ? bold('group') : x.kind === 'login' ? green('login') : green('read-only');
2183
+ info(`${pad(bold(x.label), 22)}${kindLabel} ${dim(relTime(x.created_at))}`);
2184
+ if (x.kind === 'group') { info(` ${dim('no login: holds the grants and policies you give it')}`); continue; }
2185
+ if (x.kind === 'login') info(` ${dim('member of:')} ${x.member_of?.length ? x.member_of.join(', ') : dim('no groups yet')}`);
2074
2186
  info(` ${cyan(x.connection_string)}`);
2075
2187
  if (x.external_connection_string) info(` ${cyan(x.external_connection_string)}`);
2076
2188
  if (x.write_excluded_tables?.length) info(` ${dim('kept SELECT-only:')} ${x.write_excluded_tables.join(', ')}`);
@@ -2438,14 +2550,26 @@ ${bold('Databases:')}
2438
2550
  ${bold('db')} monitoring [on|off --yes] [--slow-ms N] Query insights + logs on the dedicated instance (restarts it).
2439
2551
  ${bold('db')} insights [<name>] [--sort total|mean|calls|max] [--limit N] | --reset --yes
2440
2552
  ${bold('db')} logs [<name>] [--level ERROR,WARNING] [--min-ms N] [--search TEXT] [--limit N]
2441
- ${bold('db')} roles [<name>] List a database's roles (read-only / read-write logins).
2553
+ ${bold('db')} roles [<name>] List a database's roles (presets, groups and logins).
2442
2554
  ${bold('db')} roles <name> create <role> --read-only | --read-write
2443
2555
  A read-write role keeps a migration ledger (schema_migrations and similar)
2444
2556
  SELECT-only automatically; the response may suggest other candidates by name.
2557
+ ${bold('db')} roles <name> create <role> --group [--adopt]
2558
+ A role nobody logs in as, named by you: your migrations grant to it and attach row-level
2559
+ security policies to it. --adopt takes over an existing NOLOGIN role of that name.
2560
+ ${bold('db')} roles <name> create <role> --login [--member-of g1,g2]
2561
+ A login that can connect and nothing else: it gets what its groups hold (no blanket read).
2562
+ ${bold('db')} roles <name> member-of <login> <g1,g2|none> Set the groups a login belongs to, live.
2563
+ ${bold('db')} roles <name> audit [--strict] [--json]
2564
+ What every role can actually do here (tables, row-level security, policies) and what looks
2565
+ wrong; --strict fails the run on any warning, for CI.
2445
2566
  ${bold('db')} roles <name> rotate <role> --yes
2446
2567
  ${bold('db')} roles <name> rm <role> --yes
2447
2568
  ${bold('db')} roles <name> write-exclusions <role> <table>
2448
2569
  Keep one more table SELECT-only for a read-write role, live — no recreation.
2570
+ ${bold('db')} inject [<name>] [<login> | owner | off | on]
2571
+ Which credential the app receives as DATABASE_URL: the owner's (default) or one of your
2572
+ logins. Takes effect on the next deploy.
2449
2573
  ${bold('db')} offsite List the off-site backup destinations (S3-compatible buckets).
2450
2574
  ${bold('db')} offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]
2451
2575
  ${bold('db')} offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]