@canner-ca/cli 0.4.1 → 0.5.1

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 +25 -1
  2. package/package.json +3 -2
  3. package/src/cli.mjs +1305 -7
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.4.0';
31
+ const VERSION = '0.5.1';
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');
@@ -151,6 +151,8 @@ async function apiRequest(creds, method, path, options = {}) {
151
151
  // fetch level. Per-call options.signal can still abort.
152
152
  };
153
153
  if (options.signal) init.signal = options.signal;
154
+ // A streamed request body (database imports) needs duplex: 'half' in Node's fetch.
155
+ if (options.duplex) init.duplex = options.duplex;
154
156
  const res = await fetch(url, init).catch((err) => {
155
157
  die(`network: ${err.message}`);
156
158
  });
@@ -161,7 +163,17 @@ async function apiRequest(creds, method, path, options = {}) {
161
163
  die("authentication failed. Your token may be revoked. Run `canner login` again.");
162
164
  }
163
165
  if (res.status === 403) {
164
- die("forbidden. The API token doesn't have access to this resource.");
166
+ // Surface the server's specific reason: a token missing a scope
167
+ // (insufficient_scope) or the member's role/permission in the org
168
+ // (insufficient_permission) both send a human `message`.
169
+ const detail =
170
+ body?.message ||
171
+ (body?.error === 'insufficient_scope'
172
+ ? 'the API token is missing a required scope'
173
+ : body?.error === 'insufficient_permission'
174
+ ? "your role in this organization doesn't permit this action"
175
+ : "the API token doesn't have access to this resource");
176
+ die(`forbidden: ${detail}`);
165
177
  }
166
178
  const msg = body?.message || body?.error || `${res.status} ${res.statusText}`;
167
179
  die(`${method} ${path}: ${msg}`);
@@ -268,7 +280,7 @@ async function cmdLogin(_args) {
268
280
  const existing = await loadCredentials();
269
281
  const apiBase = process.env.CANNER_API_BASE || existing.apiBase || DEFAULT_API_BASE;
270
282
  info(bold('Sign in to Canner'));
271
- info(`Create an API token at ${cyan(apiBase.replace('api.', '') + '/dashboard/account')}`);
283
+ info(`Create an API token at ${cyan(apiBase.replace('api.', '') + '/dashboard/identity/api-keys')} ${dim('(Identity → API keys)')}`);
272
284
  info(`Then paste it below. It starts with ${dim('cnr_')}`);
273
285
  const token = (await rl.question('token: ')).trim();
274
286
  rl.close();
@@ -1102,6 +1114,23 @@ function sluggify(s) {
1102
1114
  // canner cmd --key=value → { key: 'value' }
1103
1115
  // canner cmd -v → { v: true }
1104
1116
  // ---------------------------------------------------------------------------
1117
+ // Flags that never take a value, so `canner db import --replace dump.dump` keeps
1118
+ // dump.dump as the positional argument instead of reading it as --replace's value.
1119
+ const BOOLEAN_FLAGS = new Set([
1120
+ 'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
1121
+ 'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
1122
+ ]);
1123
+
1124
+ // A flag repeated on the command line (e.g. `--allow a --allow b`) accumulates
1125
+ // into an array instead of the last one silently winning.
1126
+ function setArg(out, key, value) {
1127
+ if (key in out) {
1128
+ out[key] = Array.isArray(out[key]) ? [...out[key], value] : [out[key], value];
1129
+ } else {
1130
+ out[key] = value;
1131
+ }
1132
+ }
1133
+
1105
1134
  function parseArgs(argv) {
1106
1135
  const out = { _: [] };
1107
1136
  for (let i = 0; i < argv.length; i++) {
@@ -1109,15 +1138,15 @@ function parseArgs(argv) {
1109
1138
  if (a.startsWith('--')) {
1110
1139
  const eq = a.indexOf('=');
1111
1140
  if (eq !== -1) {
1112
- out[a.slice(2, eq)] = a.slice(eq + 1);
1141
+ setArg(out, a.slice(2, eq), a.slice(eq + 1));
1113
1142
  } else {
1114
1143
  const key = a.slice(2);
1115
1144
  const next = argv[i + 1];
1116
- if (next && !next.startsWith('-')) {
1117
- out[key] = next;
1145
+ if (next && !next.startsWith('-') && !BOOLEAN_FLAGS.has(key)) {
1146
+ setArg(out, key, next);
1118
1147
  i++;
1119
1148
  } else {
1120
- out[key] = true;
1149
+ setArg(out, key, true);
1121
1150
  }
1122
1151
  }
1123
1152
  } else if (a.startsWith('-') && a.length === 2) {
@@ -1129,6 +1158,1178 @@ function parseArgs(argv) {
1129
1158
  return out;
1130
1159
  }
1131
1160
 
1161
+ // ---------------------------------------------------------------------------
1162
+ // canner db … — project databases
1163
+ //
1164
+ // canner db [list] [--slug X] list the project's databases
1165
+ // canner db init [--pg 17|18] [--slug X] provision the primary database
1166
+ // canner db create <name> [--pg 17|18] add another database (paid plans)
1167
+ // canner db info [<name>] [--reveal] connection details (password hidden by default)
1168
+ // canner db url [<name>] print just the connection string
1169
+ // canner db rm <name> --yes delete an additional database
1170
+ // canner db pg-version <name> <17|18> --yes move a database to another Postgres version
1171
+ // canner db export [<name>] [--format custom|plain] [--out FILE]
1172
+ // canner db import <file> [--to <name>] [--replace] restore a pg_dump custom-format file
1173
+ // canner db migrate-in [--to <name>] --from <url>|--from-env VAR|--from - [--replace]
1174
+ // canner db migrate-out [--from <name>] --to-url <url>|--to-env VAR|--to-url - --yes
1175
+ // canner db copy <from> <to> [--to-project SLUG] [--replace]
1176
+ // canner db ops [<id>] recent operations / one operation
1177
+ //
1178
+ // Long operations (export, import, migrate, copy) run on the platform: the CLI
1179
+ // starts one, then waits and shows progress. Pass --no-wait to return the
1180
+ // operation id immediately (poll with `canner db ops <id>`).
1181
+ // ---------------------------------------------------------------------------
1182
+ const DB_OP_POLL_MS = 2000;
1183
+
1184
+ function dbBase(slug) {
1185
+ return `/projects/${encodeURIComponent(slug)}`;
1186
+ }
1187
+
1188
+ function fmtBytes(n) {
1189
+ if (n == null) return '—';
1190
+ const u = ['B', 'KB', 'MB', 'GB'];
1191
+ let i = 0;
1192
+ let v = Number(n);
1193
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
1194
+ return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
1195
+ }
1196
+
1197
+ // A connection URL is a secret: never take it from a positional argument that
1198
+ // ends up in shell history unless the user insists. --from/--to-url take a URL,
1199
+ // --from-env/--to-env read an environment variable, and "-" reads one line from stdin.
1200
+ async function readSecretArg(args, urlFlag, envFlag, what) {
1201
+ if (args[envFlag]) {
1202
+ const v = process.env[String(args[envFlag])];
1203
+ if (!v) die(`environment variable ${args[envFlag]} is not set.`, 2);
1204
+ return v.trim();
1205
+ }
1206
+ const v = args[urlFlag];
1207
+ if (v === '-' || v === true) {
1208
+ const rl = createInterface({ input, output: process.stderr, terminal: false });
1209
+ const line = await rl.question(`${what} (postgres://user:password@host:port/db): `);
1210
+ rl.close();
1211
+ if (!line.trim()) die(`no ${what} given.`, 2);
1212
+ return line.trim();
1213
+ }
1214
+ if (typeof v === 'string' && v) return v;
1215
+ die(`give the ${what} with --${urlFlag} <url>, --${envFlag} <VAR>, or --${urlFlag} - to type it.`, 2);
1216
+ }
1217
+
1218
+ function requireYes(args, message) {
1219
+ if (args.yes === true || args.y === true) return;
1220
+ die(`${message} Re-run with --yes to confirm.`, 2);
1221
+ }
1222
+
1223
+ async function waitForOperation(creds, base, id, args) {
1224
+ if (args['no-wait'] === true) return null;
1225
+ const path = `${base}/database-operations/${encodeURIComponent(id)}`;
1226
+ let last = '';
1227
+ for (;;) {
1228
+ const body = await apiJSON(creds, 'GET', path);
1229
+ const op = body?.operation;
1230
+ if (!op) die('lost track of the operation.');
1231
+ if (op.state === 'succeeded') {
1232
+ if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
1233
+ return op;
1234
+ }
1235
+ if (op.state === 'failed' || op.state === 'cancelled') {
1236
+ if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
1237
+ die(op.error || `the operation ${op.state}.`);
1238
+ }
1239
+ if (!JSON_MODE) {
1240
+ const p = op.progress ?? {};
1241
+ const bit = p.bytes != null ? ` ${fmtBytes(p.bytes)}` : p.size_mb != null ? ` ${p.size_mb} MB` : '';
1242
+ last = ` ${op.state}…${bit}`;
1243
+ process.stderr.write('\r' + last);
1244
+ }
1245
+ await new Promise((r) => setTimeout(r, DB_OP_POLL_MS));
1246
+ }
1247
+ }
1248
+
1249
+ async function waitForBackup(creds, base, label, id, args) {
1250
+ if (args['no-wait'] === true) return null;
1251
+ const path = `${base}/databases/${encodeURIComponent(label)}/backups/${encodeURIComponent(id)}`;
1252
+ let last = '';
1253
+ for (;;) {
1254
+ const body = await apiJSON(creds, 'GET', path);
1255
+ const b = body?.backup;
1256
+ if (!b) die('lost track of the backup.');
1257
+ if (b.state === 'succeeded') {
1258
+ if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
1259
+ return b;
1260
+ }
1261
+ if (b.state === 'failed') {
1262
+ if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
1263
+ die(b.error || 'the backup failed.');
1264
+ }
1265
+ if (!JSON_MODE) {
1266
+ last = ' running…';
1267
+ process.stderr.write('\r' + last);
1268
+ }
1269
+ await new Promise((r) => setTimeout(r, DB_OP_POLL_MS));
1270
+ }
1271
+ }
1272
+
1273
+ async function startAndWait(creds, base, args, method, path, body, verb) {
1274
+ const started = await apiJSON(creds, method, path, body);
1275
+ const op = started?.operation;
1276
+ if (!op) die('the platform did not return an operation.');
1277
+ if (args['no-wait'] === true) {
1278
+ if (JSON_MODE) emitJSON(op);
1279
+ else { info(`${green('started')} ${verb} ${dim(op.id)}`); muted(`Check it with: canner db ops ${op.id}`); }
1280
+ return null;
1281
+ }
1282
+ if (!JSON_MODE) info(`${verb}…`);
1283
+ return waitForOperation(creds, base, op.id, args);
1284
+ }
1285
+
1286
+ async function cmdDb(args) {
1287
+ const creds = await loadCredentials();
1288
+ if (!creds.token) die("not signed in. Run `canner login`.");
1289
+ const slug = await resolveSlug(args);
1290
+ const base = dbBase(slug);
1291
+ const sub = args._[0] ?? 'list';
1292
+ const name = (i = 1) => args._[i] ?? 'primary';
1293
+
1294
+ if (sub === 'list' || sub === 'ls') {
1295
+ const body = await apiJSON(creds, 'GET', `${base}/databases`);
1296
+ if (JSON_MODE) return emitJSON(body);
1297
+ const rows = body?.databases ?? [];
1298
+ if (rows.length === 0) { muted('no database yet. Create one with `canner db init`.'); return; }
1299
+ info(`${pad(bold('NAME'), 22)}${pad(bold('POSTGRES'), 11)}${pad(bold('SIZE'), 11)}${bold('STATUS')}`);
1300
+ for (const d of rows) {
1301
+ 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}`);
1303
+ }
1304
+ const l = body?.limits;
1305
+ if (l && !l.advanced_enabled) muted('Additional databases, import and migration are available on paid plans.');
1306
+ return;
1307
+ }
1308
+
1309
+ if (sub === 'init' || sub === 'provision') {
1310
+ const body = await apiJSON(creds, 'POST', `${base}/database`, args.pg ? { pg_version: String(args.pg) } : {});
1311
+ if (JSON_MODE) return emitJSON(body);
1312
+ const d = body?.database;
1313
+ info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
1314
+ muted('The connection string is injected as DATABASE_URL on your next deploy.');
1315
+ return;
1316
+ }
1317
+
1318
+ if (sub === 'create' || sub === 'add') {
1319
+ const label = args._[1];
1320
+ if (!label) die('usage: canner db create <name> [--pg 17|18]', 2);
1321
+ const body = await apiJSON(creds, 'POST', `${base}/databases`, {
1322
+ name: label,
1323
+ ...(args.pg ? { pg_version: String(args.pg) } : {}),
1324
+ ...(args.instance === true ? { instance: true } : {}),
1325
+ });
1326
+ if (JSON_MODE) return emitJSON(body);
1327
+ const d = body?.database;
1328
+ info(`${green('created')} ${bold(label)} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
1329
+ muted(`Injected into your app as DATABASE_URL_${label.toUpperCase()} on your next deploy.`);
1330
+ return;
1331
+ }
1332
+
1333
+ if (sub === 'info' || sub === 'show') {
1334
+ const body = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(name())}`);
1335
+ const d = body?.database;
1336
+ if (JSON_MODE) return emitJSON(args.reveal === true ? d : { ...d, password: null, connection_string: null });
1337
+ info(`${bold(d.name)} ${dim(`Postgres ${d.pg_version_full ?? d.pg_version} · ${d.size_mb} MB`)}`);
1338
+ info(` user ${d.db_user}`);
1339
+ info(` database ${d.db_name}`);
1340
+ info(` password ${args.reveal === true ? d.password : dim('•••••• (--reveal to show)')}`);
1341
+ if (args.reveal === true) info(` url ${d.connection_string}`);
1342
+ return;
1343
+ }
1344
+
1345
+ if (sub === 'url') {
1346
+ const body = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(name())}`);
1347
+ const d = body?.database;
1348
+ if (JSON_MODE) return emitJSON({ url: d.connection_string });
1349
+ process.stdout.write(`${d.connection_string}\n`);
1350
+ return;
1351
+ }
1352
+
1353
+ if (sub === 'rm' || sub === 'delete') {
1354
+ const label = args._[1];
1355
+ if (!label) die('usage: canner db rm <name> --yes', 2);
1356
+ requireYes(args, `This permanently deletes the database "${label}" and everything in it.`);
1357
+ await apiJSON(creds, 'DELETE', `${base}/databases/${encodeURIComponent(label)}`);
1358
+ if (JSON_MODE) return emitJSON({ ok: true, deleted: label });
1359
+ info(`${green('deleted')} ${bold(label)}`);
1360
+ return;
1361
+ }
1362
+
1363
+ if (sub === 'pg-version') {
1364
+ const label = args._[1];
1365
+ const target = args._[2];
1366
+ if (!label || !target) die('usage: canner db pg-version <name> <17|18> --yes', 2);
1367
+ requireYes(args, 'Changing the Postgres version dumps and restores the database, with downtime while it runs.');
1368
+ const body = await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/pg-version`, { target_version: String(target), confirm: true });
1369
+ if (JSON_MODE) return emitJSON(body);
1370
+ info(`${green('moved')} ${bold(label)} from Postgres ${body.from_version} to ${body.to_version}. The connection string is unchanged.`);
1371
+ return;
1372
+ }
1373
+
1374
+ if (sub === 'export') {
1375
+ const label = name();
1376
+ const format = args.format === 'plain' ? 'plain' : 'custom';
1377
+ const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(label)}/exports`, { format }, 'exporting');
1378
+ if (!done) return;
1379
+ const out = resolve(typeof args.out === 'string' ? args.out : done.result.file_name);
1380
+ const res = await apiRequest(creds, 'GET', `${base}/database-operations/${encodeURIComponent(done.id)}/download`);
1381
+ const { Readable } = await import('node:stream');
1382
+ const { pipeline } = await import('node:stream/promises');
1383
+ const { createWriteStream } = await import('node:fs');
1384
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(out, { mode: 0o600 }));
1385
+ if (JSON_MODE) return emitJSON({ file: out, bytes: done.result.bytes, sha256: done.result.sha256, format });
1386
+ info(`${green('exported')} ${bold(label)} → ${cyan(out)} ${dim(`(${fmtBytes(done.result.bytes)}, sha256 ${done.result.sha256.slice(0, 12)}…)`)}`);
1387
+ muted(`Restore with: ${done.result.restore_hint}`);
1388
+ return;
1389
+ }
1390
+
1391
+ if (sub === 'import') {
1392
+ const file = args._[1];
1393
+ if (!file) die('usage: canner db import <file.dump> [--to <name>] [--replace]', 2);
1394
+ const to = typeof args.to === 'string' ? args.to : 'primary';
1395
+ const size = (await stat(resolve(file))).size;
1396
+ const { createReadStream } = await import('node:fs');
1397
+ if (!JSON_MODE) info(`uploading ${basename(file)} (${fmtBytes(size)})…`);
1398
+ const res = await apiRequest(creds, 'POST',
1399
+ `${base}/databases/${encodeURIComponent(to)}/imports${args.replace === true ? '?replace=true' : ''}`,
1400
+ { headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size) }, body: createReadStream(resolve(file)), duplex: 'half' });
1401
+ const started = await res.json();
1402
+ const opId = started?.operation?.id;
1403
+ if (args['no-wait'] === true) {
1404
+ if (JSON_MODE) return emitJSON(started.operation);
1405
+ info(`${green('started')} import ${dim(opId)}`);
1406
+ return;
1407
+ }
1408
+ if (!JSON_MODE) info('restoring…');
1409
+ const done = await waitForOperation(creds, base, opId, args);
1410
+ if (JSON_MODE) return emitJSON(done);
1411
+ info(`${green('imported')} into ${bold(to)}`);
1412
+ if (done.result?.snapshot) muted(`A snapshot of the previous contents was kept for 7 days (${fmtBytes(done.result.snapshot.bytes)}).`);
1413
+ return;
1414
+ }
1415
+
1416
+ if (sub === 'migrate-in') {
1417
+ const to = typeof args.to === 'string' ? args.to : 'primary';
1418
+ const url = await readSecretArg(args, 'from', 'from-env', 'source connection URL');
1419
+ const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(to)}/migrate-in`,
1420
+ { url, replace: args.replace === true }, 'migrating in');
1421
+ if (!done) return;
1422
+ if (JSON_MODE) return emitJSON(done);
1423
+ info(`${green('migrated')} ${dim(done.result?.source_host ?? '')} → ${bold(to)}`);
1424
+ return;
1425
+ }
1426
+
1427
+ if (sub === 'migrate-out') {
1428
+ const from = typeof args.from === 'string' ? args.from : 'primary';
1429
+ requireYes(args, 'This writes this database into the server you name.');
1430
+ const url = await readSecretArg(args, 'to-url', 'to-env', 'target connection URL');
1431
+ const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(from)}/migrate-out`,
1432
+ { url, replace: args.replace === true, confirm: true }, 'migrating out');
1433
+ if (!done) return;
1434
+ if (JSON_MODE) return emitJSON(done);
1435
+ info(`${green('migrated')} ${bold(from)} → ${dim(done.result?.target_host ?? '')}`);
1436
+ return;
1437
+ }
1438
+
1439
+ if (sub === 'copy') {
1440
+ const from = args._[1];
1441
+ const to = args._[2];
1442
+ if (!from || !to) die('usage: canner db copy <from> <to> [--to-project SLUG] [--replace]', 2);
1443
+ const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(from)}/copy`,
1444
+ { target_name: to, ...(args['to-project'] ? { target_project: String(args['to-project']) } : {}), replace: args.replace === true }, 'copying');
1445
+ if (!done) return;
1446
+ if (JSON_MODE) return emitJSON(done);
1447
+ info(`${green('copied')} ${bold(from)} → ${bold(to)}`);
1448
+ return;
1449
+ }
1450
+
1451
+ if (sub === 'ops' || sub === 'operations') {
1452
+ const id = args._[1];
1453
+ if (id) {
1454
+ const body = await apiJSON(creds, 'GET', `${base}/database-operations/${encodeURIComponent(id)}`);
1455
+ if (JSON_MODE) return emitJSON(body.operation);
1456
+ const o = body.operation;
1457
+ info(`${bold(o.kind)} ${dim(o.id)} ${o.state === 'succeeded' ? green(o.state) : o.state === 'failed' ? red(o.state) : yellow(o.state)}`);
1458
+ if (o.error) info(` ${red(o.error)}`);
1459
+ if (o.progress?.bytes != null) info(` ${fmtBytes(o.progress.bytes)} written`);
1460
+ if (o.progress?.size_mb != null) info(` target is ${o.progress.size_mb} MB`);
1461
+ return;
1462
+ }
1463
+ const body = await apiJSON(creds, 'GET', `${base}/database-operations`);
1464
+ const rows = body?.operations ?? [];
1465
+ if (JSON_MODE) return emitJSON(rows);
1466
+ if (rows.length === 0) { muted('no database operations yet.'); return; }
1467
+ info(`${pad(bold('ID'), 12)}${pad(bold('KIND'), 14)}${pad(bold('STATE'), 12)}${bold('WHEN')}`);
1468
+ for (const o of rows) {
1469
+ info(`${pad(o.id.slice(0, 8), 12)}${pad(o.kind, 14)}${pad(o.state === 'succeeded' ? green(o.state) : o.state === 'failed' ? red(o.state) : yellow(o.state), 12)}${dim(relTime(o.created_at))}`);
1470
+ }
1471
+ return;
1472
+ }
1473
+
1474
+ if (sub === 'external' || sub === 'external-access') {
1475
+ const label = args._[1] ?? 'primary';
1476
+ const path = `${base}/databases/${encodeURIComponent(label)}/external-access`;
1477
+
1478
+ if (args.disable === true) {
1479
+ const body = await apiJSON(creds, 'PATCH', path, { enabled: false });
1480
+ if (JSON_MODE) return emitJSON(body.external_access);
1481
+ info(`${green('disabled')} external access on ${bold(label)}`);
1482
+ return;
1483
+ }
1484
+ if (args.enable === true || args.allow !== undefined || args['allow-all'] === true) {
1485
+ const allowlist = args.allow === undefined ? [] : (Array.isArray(args.allow) ? args.allow : [args.allow]).map(String);
1486
+ if (args['allow-all'] === true) {
1487
+ requireYes(args, 'This allows connections to this database from anywhere on the internet.');
1488
+ allowlist.push('0.0.0.0/0', '::/0');
1489
+ }
1490
+ if (allowlist.length === 0) die('give at least one --allow <cidr>, or --allow-all --yes.', 2);
1491
+ const body = await apiJSON(creds, 'PATCH', path, { enabled: true, allowlist, allow_open: args['allow-all'] === true });
1492
+ if (JSON_MODE) return emitJSON(body.external_access);
1493
+ const ea = body.external_access;
1494
+ info(`${green('enabled')} external access on ${bold(label)}`);
1495
+ info(` ${ea.host}:${ea.port}`);
1496
+ info(` allowed: ${ea.allowlist.join(', ')}`);
1497
+ return;
1498
+ }
1499
+ // No mutating flag: show current status.
1500
+ const body = await apiJSON(creds, 'GET', path);
1501
+ const ea = body.external_access;
1502
+ if (JSON_MODE) return emitJSON(ea);
1503
+ info(`${bold(label)} ${ea.enabled ? green('enabled') : dim('disabled')}`);
1504
+ info(` ${ea.host}:${ea.port}${!ea.configured ? dim(' (not available on this platform instance yet)') : ''}`);
1505
+ if (ea.enabled) info(` allowed: ${ea.allowlist.length ? ea.allowlist.join(', ') : dim('(none)')}`);
1506
+ return;
1507
+ }
1508
+
1509
+ if (sub === 'rotate' || sub === 'rotate-credentials') {
1510
+ const label = args._[1] ?? 'primary';
1511
+ requireYes(args, 'This changes the password — your app needs the new DATABASE_URL from the next deploy.');
1512
+ const body = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/rotate-credentials`);
1513
+ if (JSON_MODE) return emitJSON(body.database);
1514
+ info(`${green('rotated')} the password for ${bold(label)}.`);
1515
+ muted('Redeploy to pick up the new DATABASE_URL.');
1516
+ return;
1517
+ }
1518
+
1519
+ if (sub === 'backup-policy') {
1520
+ const label = args._[1] ?? 'primary';
1521
+ const path = `${base}/databases/${encodeURIComponent(label)}/backup-policy`;
1522
+
1523
+ if (args.disable === true) {
1524
+ const body = await apiJSON(creds, 'PATCH', path, { enabled: false });
1525
+ if (JSON_MODE) return emitJSON(body.policy);
1526
+ info(`${green('disabled')} scheduled backups on ${bold(label)}`);
1527
+ return;
1528
+ }
1529
+ const setting = args.enable === true || args.every !== undefined || args.keep !== undefined || args['keep-count'] !== undefined || args.offsite !== undefined;
1530
+ if (setting) {
1531
+ const patch = {};
1532
+ if (args.enable === true) patch.enabled = true;
1533
+ if (args.every !== undefined) patch.interval_hours = Number(args.every);
1534
+ if (args.keep !== undefined) patch.retention_days = Number(args.keep);
1535
+ if (args['keep-count'] !== undefined) patch.retention_count = args['keep-count'] === 'none' ? null : Number(args['keep-count']);
1536
+ if (args.offsite !== undefined) {
1537
+ if (args.offsite === 'off' || args.offsite === 'none') patch.offsite_destination_id = null;
1538
+ else if (typeof args.offsite === 'string' && args.offsite !== 'on') {
1539
+ const all = (await apiJSON(creds, 'GET', `${base}/backup-destinations`)).destinations;
1540
+ const hit = all.find((d) => d.id === args.offsite || d.name.toLowerCase() === args.offsite.toLowerCase());
1541
+ if (!hit) die(`no destination "${args.offsite}". List them with: canner db offsite`, 1);
1542
+ patch.offsite_destination_id = hit.id;
1543
+ } else die('--offsite takes a destination name (see: canner db offsite) or off.', 2);
1544
+ }
1545
+ const body = await apiJSON(creds, 'PATCH', path, patch);
1546
+ if (JSON_MODE) return emitJSON(body.policy);
1547
+ const p = body.policy;
1548
+ info(`${green('set')} backup policy for ${bold(label)}: every ${p.interval_hours}h, keep ${p.retention_days}d${p.retention_count ? ` (max ${p.retention_count})` : ''}`);
1549
+ return;
1550
+ }
1551
+ const body = await apiJSON(creds, 'GET', path);
1552
+ const p = body.policy;
1553
+ if (JSON_MODE) return emitJSON(p);
1554
+ info(`${bold(label)} ${p.enabled ? green('enabled') : dim('disabled')}`);
1555
+ info(` every ${p.interval_hours}h, keep ${p.retention_days}d${p.retention_count ? `, max ${p.retention_count} backups` : ''}`);
1556
+ info(` off-site copy ${p.offsite ? green('on') : dim('off')}`);
1557
+ if (p.enabled && p.next_run_at) info(` next run ${dim(relTime(p.next_run_at))}`);
1558
+ return;
1559
+ }
1560
+
1561
+ if (sub === 'backup') {
1562
+ const label = name();
1563
+ const started = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/backups`);
1564
+ const b = started?.backup;
1565
+ if (!b) die('the platform did not return a backup.');
1566
+ if (args['no-wait'] === true) {
1567
+ if (JSON_MODE) return emitJSON(b);
1568
+ info(`${green('started')} backup ${dim(b.id)}`);
1569
+ muted(`Check it with: canner db backups ${label} ${b.id}`);
1570
+ return;
1571
+ }
1572
+ if (!JSON_MODE) info('backing up…');
1573
+ const done = await waitForBackup(creds, base, label, b.id, args);
1574
+ if (JSON_MODE) return emitJSON(done);
1575
+ info(`${green('backed up')} ${bold(label)} ${dim(`(${fmtBytes(done.bytes)}, sha256 ${done.sha256.slice(0, 12)}…)`)}`);
1576
+ return;
1577
+ }
1578
+
1579
+ if (sub === 'backups') {
1580
+ const label = args._[1] ?? 'primary';
1581
+ const id = args._[2];
1582
+ const listPath = `${base}/databases/${encodeURIComponent(label)}/backups`;
1583
+
1584
+ if (!id) {
1585
+ const body = await apiJSON(creds, 'GET', listPath);
1586
+ const rows = body?.backups ?? [];
1587
+ if (JSON_MODE) return emitJSON(rows);
1588
+ if (rows.length === 0) { muted(`no backups yet for ${label}. Create one with \`canner db backup ${label}\`.`); return; }
1589
+ info(`${pad(bold('ID'), 12)}${pad(bold('KIND'), 11)}${pad(bold('STATE'), 12)}${pad(bold('SIZE'), 11)}${bold('WHEN')}`);
1590
+ for (const b of rows) {
1591
+ const state = b.state === 'succeeded' ? green(b.state) : b.state === 'failed' ? red(b.state) : yellow(b.state);
1592
+ info(`${pad(b.id.slice(0, 8), 12)}${pad(b.kind, 11)}${pad(state, 12)}${pad(b.bytes != null ? fmtBytes(b.bytes) : '—', 11)}${dim(relTime(b.created_at))}`);
1593
+ }
1594
+ return;
1595
+ }
1596
+
1597
+ const itemPath = `${listPath}/${encodeURIComponent(id)}`;
1598
+
1599
+ if (args.rm === true || args.delete === true) {
1600
+ requireYes(args, 'This permanently deletes the backup — its encryption key is destroyed immediately, so this cannot be undone.');
1601
+ await apiJSON(creds, 'DELETE', itemPath);
1602
+ if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
1603
+ info(`${green('deleted')} backup ${dim(id.slice(0, 8))}`);
1604
+ return;
1605
+ }
1606
+
1607
+ if (args.restore === true) {
1608
+ const to = typeof args.to === 'string' ? args.to : undefined;
1609
+ if (to) requireYes(args, `This replaces the contents of "${to}" with this backup (Canner takes a snapshot first).`);
1610
+ const inspect = args.inspect === true;
1611
+ if (inspect && to) die('--inspect restores into a new temporary database; it cannot be combined with --to.', 2);
1612
+ const body = await apiJSON(creds, 'POST', `${itemPath}/restore`, to ? { target_name: to, confirm: true } : inspect ? { inspect: true } : {});
1613
+ const opId = body?.operation_id;
1614
+ if (args['no-wait'] === true) {
1615
+ if (JSON_MODE) return emitJSON(body);
1616
+ info(`${green('started')} restore ${dim(opId)} → ${bold(body.database.name)}`);
1617
+ muted(`Check it with: canner db ops ${opId}`);
1618
+ return;
1619
+ }
1620
+ if (!JSON_MODE) info('restoring…');
1621
+ const done = await waitForOperation(creds, base, opId, args);
1622
+ if (JSON_MODE) return emitJSON({ ...done, database: body.database });
1623
+ info(`${green('restored')} into ${bold(body.database.name)}`);
1624
+ if (inspect) muted(`Temporary inspect database (deleted ${dim(relTime(body.inspect_expires_at))}). Compare it: canner db diff ${body.database.name}. Keep it: canner db inspect ${body.database.name} --keep`);
1625
+ return;
1626
+ }
1627
+
1628
+ if (args.offsite !== undefined) {
1629
+ const body = {};
1630
+ if (typeof args.offsite === 'string') {
1631
+ const all = (await apiJSON(creds, 'GET', `${base}/backup-destinations`)).destinations;
1632
+ const hit = all.find((d) => d.id === args.offsite || d.name.toLowerCase() === args.offsite.toLowerCase());
1633
+ if (!hit) die(`no destination "${args.offsite}". List them with: canner db offsite`, 1);
1634
+ body.destination_id = hit.id;
1635
+ }
1636
+ await apiJSON(creds, 'POST', `${itemPath}/offsite`, body);
1637
+ if (JSON_MODE) return emitJSON({ ok: true });
1638
+ info(`${green('copied')} backup ${dim(id.slice(0, 8))} to your off-site bucket`);
1639
+ return;
1640
+ }
1641
+
1642
+ if (args.verify === true) {
1643
+ const body = await apiJSON(creds, 'POST', `${itemPath}/verify`);
1644
+ const opId = body?.operation_id;
1645
+ if (args['no-wait'] === true) {
1646
+ if (JSON_MODE) return emitJSON(body);
1647
+ info(`${green('started')} test restore ${dim(opId)}`);
1648
+ return;
1649
+ }
1650
+ if (!JSON_MODE) info('test-restoring…');
1651
+ const done = await waitForOperation(creds, base, opId, args);
1652
+ if (JSON_MODE) return emitJSON(done);
1653
+ info(`${green('verified')} backup ${dim(id.slice(0, 8))}: ${done?.result?.tables ?? '?'} tables restored and readable`);
1654
+ return;
1655
+ }
1656
+
1657
+ if (args.download === true) {
1658
+ const out = resolve(typeof args.out === 'string' ? args.out : `${label}-${id.slice(0, 8)}.dump`);
1659
+ const res = await apiRequest(creds, 'GET', `${itemPath}/download`);
1660
+ const { Readable } = await import('node:stream');
1661
+ const { pipeline } = await import('node:stream/promises');
1662
+ const { createWriteStream } = await import('node:fs');
1663
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(out, { mode: 0o600 }));
1664
+ if (JSON_MODE) return emitJSON({ file: out });
1665
+ info(`${green('downloaded')} → ${cyan(out)}`);
1666
+ return;
1667
+ }
1668
+
1669
+ const body = await apiJSON(creds, 'GET', itemPath);
1670
+ const b = body?.backup;
1671
+ if (JSON_MODE) return emitJSON(b);
1672
+ info(`${bold(b.id)} ${dim(b.kind)} ${b.state === 'succeeded' ? green(b.state) : b.state === 'failed' ? red(b.state) : yellow(b.state)}`);
1673
+ if (b.error) info(` ${red(b.error)}`);
1674
+ if (b.bytes != null) info(` ${fmtBytes(b.bytes)}`);
1675
+ if (b.expires_at) info(` expires ${dim(relTime(b.expires_at))}`);
1676
+ return;
1677
+ }
1678
+
1679
+ if (sub === 'diff') {
1680
+ const label = args._[1];
1681
+ if (!label) die('usage: canner db diff <name> [--against <name>]', 2);
1682
+ const q = typeof args.against === 'string' ? `?against=${encodeURIComponent(args.against)}` : '';
1683
+ const d = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/diff${q}`);
1684
+ if (JSON_MODE) return emitJSON(d);
1685
+ info(`${bold(d.to)} compared with ${bold(d.from)}: ${d.summary.added} only in ${d.to}, ${d.summary.removed} missing, ${d.summary.changed} different, ${d.summary.same} same`);
1686
+ for (const t of d.tables.filter((x) => x.status !== 'same')) {
1687
+ const tag = t.status === 'added' ? green('added ') : t.status === 'removed' ? red('missing') : yellow('changed');
1688
+ info(` ${tag} ${pad(t.table, 32)} ${dim(`${t.rows_from ?? '?'} → ${t.rows_to ?? '?'} rows`)}`);
1689
+ }
1690
+ return;
1691
+ }
1692
+
1693
+ if (sub === 'inspect') {
1694
+ const label = args._[1];
1695
+ if (!label) die('usage: canner db inspect <name> --keep | --extend HOURS', 2);
1696
+ const body = args.keep === true ? { keep: true } : args.extend !== undefined ? { hours: Number(args.extend) } : null;
1697
+ if (!body) die('use --keep to make it permanent, or --extend HOURS to push the expiry out.', 2);
1698
+ await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/inspect`, body);
1699
+ if (JSON_MODE) return emitJSON({ ok: true });
1700
+ info(`${green('updated')} ${bold(label)}`);
1701
+ return;
1702
+ }
1703
+
1704
+ if (sub === 'resources') {
1705
+ const path = `${base}/instance/resources`;
1706
+ const action = args._[1];
1707
+ const mb = (n) => (n >= 1024 ? `${(n / 1024).toFixed(n % 1024 === 0 ? 0 : 1)} GB` : `${n} MB`);
1708
+ if (action === 'apply') {
1709
+ requireYes(args, 'Applying restarts your dedicated instance; connections drop for a few seconds.');
1710
+ const r = await apiJSON(creds, 'POST', `${path}/apply`, { confirm: true });
1711
+ if (JSON_MODE) return emitJSON(r);
1712
+ info(`${green('applied')} the memory budget${r.limits_applied ? '' : yellow(' (hard limit pending on the host)')}`);
1713
+ if (r.redeploy_recommended) muted('Your app memory limit was lowered to its new ceiling. Redeploy the app to use it.');
1714
+ return;
1715
+ }
1716
+ if (action === 'set') {
1717
+ const patch = {};
1718
+ if (args.share !== undefined) patch.pg_share_pct = Number(args.share);
1719
+ if (args['app-reserved'] !== undefined) {
1720
+ const cur = (await apiJSON(creds, 'GET', path)).resources.budget;
1721
+ patch.reserved_to_pg_mb = cur.reserved_purchased_mb - Math.round(Number(args['app-reserved']) * 1024);
1722
+ }
1723
+ if (Object.keys(patch).length === 0) die('nothing to change. Use --share PCT (10-90) or --app-reserved GB.', 2);
1724
+ const r = await apiJSON(creds, 'PATCH', path, patch);
1725
+ if (JSON_MODE) return emitJSON(r.resources);
1726
+ info(`${green('saved')} the split. It takes effect with: canner db resources apply --yes`);
1727
+ return;
1728
+ }
1729
+ const r = (await apiJSON(creds, 'GET', path)).resources;
1730
+ if (JSON_MODE) return emitJSON(r);
1731
+ const b = r.budget;
1732
+ info(`${bold('postgres')} ${mb(b.pg.total_mb)} (${mb(b.pg.reserved_mb)} guaranteed, ${mb(b.pg.pooled_mb)} shared)`);
1733
+ info(`${bold('app')} ${mb(b.app.ceiling_mb)} (${mb(b.app.reserved_mb)} guaranteed, ${mb(b.app.pooled_mb)} shared)`);
1734
+ info(` Postgres takes ${b.pg_share_pct}% of the shared memory; reserved RAM bought: ${mb(b.reserved_purchased_mb)} (+ ${mb(b.reserved_included_mb)} included)`);
1735
+ info(` ${r.applied ? `in force: ${mb(r.applied.max_mb)} limit, ${dim(relTime(r.applied.at))}` : 'not applied yet'}${r.pending ? yellow(' (changes pending: canner db resources apply --yes)') : ''}`);
1736
+ if (r.applied?.error) info(` ${yellow(r.applied.error)}`);
1737
+ return;
1738
+ }
1739
+
1740
+ if (sub === 'extensions') {
1741
+ const label = args._[1] ?? 'primary';
1742
+ const action = args._[2];
1743
+ const path = `${base}/databases/${encodeURIComponent(label)}/extensions`;
1744
+ if (action === 'install') {
1745
+ const name = args._[3];
1746
+ if (!name) die('usage: canner db extensions <db> install <extension> [--window] [--yes]', 2);
1747
+ const needsRestart = name === 'pg_cron';
1748
+ if (needsRestart) requireYes(args, `${name} loads a library at startup: installing it restarts your instance${args.window === true ? ' in the next maintenance window' : ' now; connections drop for a few seconds'}.`);
1749
+ const r = await apiJSON(creds, 'POST', path, { extension: name, when: args.window === true ? 'window' : 'now', confirm: needsRestart ? true : undefined });
1750
+ if (JSON_MODE) return emitJSON(r);
1751
+ if (r.restart && !r.restart.restarted) info(`${yellow('scheduled')} ${bold(name)}: the instance restarts at the next maintenance window (${r.restart.scheduled_for ?? 'unknown'}) and installs it then.`);
1752
+ else info(`${green('installed')} ${bold(name)}`);
1753
+ return;
1754
+ }
1755
+ if (action === 'update') {
1756
+ const name = args._[3];
1757
+ if (!name) die('usage: canner db extensions <db> update <extension>', 2);
1758
+ const r = await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/update`);
1759
+ if (JSON_MODE) return emitJSON(r);
1760
+ info(`${green('updated')} ${bold(name)}`);
1761
+ return;
1762
+ }
1763
+ if (action === 'rm') {
1764
+ const name = args._[3];
1765
+ if (!name) die('usage: canner db extensions <db> rm <extension> --yes', 2);
1766
+ requireYes(args, `This removes ${name} from the database. Nothing is removed if objects still use it.`);
1767
+ const r = await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
1768
+ if (JSON_MODE) return emitJSON(r);
1769
+ info(`${green('removed')} ${bold(name)}${r.restart_pending ? dim(' (the library stops loading at the next restart)') : ''}`);
1770
+ return;
1771
+ }
1772
+ const r = await apiJSON(creds, 'GET', path);
1773
+ if (JSON_MODE) return emitJSON(r);
1774
+ for (const e of r.extensions) {
1775
+ const state = e.installed_version ? green(`installed ${e.installed_version}`) : e.available ? dim('available') : red('unavailable');
1776
+ info(`${pad(bold(e.id), 24)}${pad(state, 24)}${e.update_available ? yellow(`update to ${e.default_version} `) : ''}${dim(e.description)}${e.preload ? dim(' (restarts the instance)') : ''}`);
1777
+ }
1778
+ if (r.pg_cron_database) muted(`pg_cron runs in the database "${r.pg_cron_database}".`);
1779
+ return;
1780
+ }
1781
+
1782
+ if (sub === 'tuning') {
1783
+ const path = `${base}/instance/config`;
1784
+ const patch = {};
1785
+ if (typeof args.preset === 'string') patch.preset = args.preset.replace(/-/g, '_');
1786
+ const ov = {};
1787
+ if (args['statement-timeout'] !== undefined) ov.statement_timeout_ms = Math.round(Number(args['statement-timeout']) * 1000);
1788
+ if (args['idle-timeout'] !== undefined) ov.idle_in_transaction_timeout_ms = Math.round(Number(args['idle-timeout']) * 1000);
1789
+ if (args['lock-timeout'] !== undefined) ov.lock_timeout_ms = Math.round(Number(args['lock-timeout']) * 1000);
1790
+ if (Object.keys(ov).length > 0) {
1791
+ const cur = (await apiJSON(creds, 'GET', path)).config.overrides;
1792
+ patch.overrides = { ...cur, ...ov };
1793
+ }
1794
+ if (args['window-day'] !== undefined || args['window-hour'] !== undefined || args['window-tz'] !== undefined) {
1795
+ const cur = (await apiJSON(creds, 'GET', path)).config.window;
1796
+ patch.window = {
1797
+ day: args['window-day'] === undefined ? cur.day : args['window-day'] === 'all' ? null : Number(args['window-day']),
1798
+ hour: args['window-hour'] === undefined ? cur.hour : Number(args['window-hour']),
1799
+ tz: typeof args['window-tz'] === 'string' ? args['window-tz'] : cur.tz,
1800
+ };
1801
+ }
1802
+ const r = Object.keys(patch).length > 0 ? await apiJSON(creds, 'PATCH', path, patch) : await apiJSON(creds, 'GET', path);
1803
+ if (JSON_MODE) return emitJSON(r.config);
1804
+ const c = r.config;
1805
+ if (Object.keys(patch).length > 0) info(`${green('saved')}. No restart needed.`);
1806
+ const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
1807
+ const secs = (ms) => (ms ? `${ms / 1000} s` : 'none');
1808
+ info(`${bold('preset')} ${c.preset} ${dim(`(${c.presets.join(' | ')})`)}`);
1809
+ info(`${bold('timeouts')} statement ${secs(c.overrides.statement_timeout_ms)}, idle in transaction ${secs(c.overrides.idle_in_transaction_timeout_ms)}, lock wait ${secs(c.overrides.lock_timeout_ms)}`);
1810
+ info(`${bold('window')} ${c.window.day === null ? 'every day' : days[c.window.day]} ${String(c.window.hour).padStart(2, '0')}:00 for two hours, ${c.window.tz}${c.window_open ? green(' (open now)') : c.next_window ? dim(` (next ${relTime(c.next_window)})`) : ''}`);
1811
+ if (c.restart.waiting_for_window) info(yellow(` a restart is waiting for the window; do it now with: canner db restart --yes`));
1812
+ if (c.restart.settings.length > 0) info(yellow(` waiting for a restart: ${c.restart.settings.join(', ')}`));
1813
+ return;
1814
+ }
1815
+
1816
+ if (sub === 'restart') {
1817
+ requireYes(args, 'Restarting your dedicated instance drops connections for a few seconds.');
1818
+ const r = await apiJSON(creds, 'POST', `${base}/instance/restart`, { confirm: true });
1819
+ if (JSON_MODE) return emitJSON(r);
1820
+ info(`${green('restarted')} the instance${r.failed_actions ? yellow(` (${r.failed_actions} queued action(s) failed)`) : ''}`);
1821
+ return;
1822
+ }
1823
+
1824
+ if (sub === 'replication') {
1825
+ const label = args._[1] ?? 'primary';
1826
+ const action = args._[2];
1827
+ const path = `${base}/databases/${encodeURIComponent(label)}/replication`;
1828
+ const inst = `${base}/instance/replication`;
1829
+ if (action === 'on') {
1830
+ requireYes(args, `Turning logical replication on restarts your instance${args.window === true ? ' in the next maintenance window' : ' now; connections drop for a few seconds'}.`);
1831
+ const body = { enabled: true, when: args.window === true ? 'window' : 'now', confirm: true };
1832
+ if (args.cap !== undefined) body.cap_mb = Number(args.cap);
1833
+ const r = await apiJSON(creds, 'POST', inst, body);
1834
+ if (JSON_MODE) return emitJSON(r);
1835
+ info(r.restart && !r.restart.restarted ? `${yellow('scheduled')}: the instance restarts at the next maintenance window (${r.restart.scheduled_for ?? 'unknown'}).` : `${green('on')} logical replication`);
1836
+ muted(`Next: create a login with \`canner db roles ${label} create <name> --replication\` and a publication with \`canner db replication ${label} pub <name> --all\`.`);
1837
+ return;
1838
+ }
1839
+ if (action === 'off') {
1840
+ requireYes(args, 'Turning logical replication off restarts your instance for a few seconds. Remove every slot and replication role first.');
1841
+ const r = await apiJSON(creds, 'POST', inst, { enabled: false, when: args.window === true ? 'window' : 'now', confirm: true });
1842
+ if (JSON_MODE) return emitJSON(r);
1843
+ info(`${green('off')} logical replication`);
1844
+ return;
1845
+ }
1846
+ if (action === 'cap') {
1847
+ const mb = Number(args._[3]);
1848
+ if (!Number.isFinite(mb)) die('usage: canner db replication <db> cap <MB>', 2);
1849
+ const r = await apiJSON(creds, 'PATCH', inst, { cap_mb: mb });
1850
+ if (JSON_MODE) return emitJSON(r);
1851
+ info(`${green('set')} the limit on WAL held back per slot to ${mb} MB (no restart)`);
1852
+ return;
1853
+ }
1854
+ if (action === 'pub') {
1855
+ const name = args._[3];
1856
+ if (!name) die('usage: canner db replication <db> pub <name> --all | --tables schema.table,schema.table', 2);
1857
+ const body = args.all === true ? { name, all_tables: true } : { name, tables: String(args.tables ?? '').split(',').map((x) => x.trim()).filter(Boolean) };
1858
+ if (args.all !== true && body.tables.length === 0) die('give --all or --tables schema.table,schema.table.', 2);
1859
+ const r = await apiJSON(creds, 'POST', `${path}/publications`, body);
1860
+ if (JSON_MODE) return emitJSON(r.replication);
1861
+ info(`${green('created')} publication ${bold(name)}`);
1862
+ return;
1863
+ }
1864
+ if (action === 'unpub' || action === 'drop-slot') {
1865
+ const name = args._[3];
1866
+ if (!name) die(`usage: canner db replication <db> ${action} <name> --yes`, 2);
1867
+ requireYes(args, action === 'unpub' ? `This drops the publication "${name}"; subscribers that use it stop receiving changes.` : `This drops the slot "${name}" and disconnects its subscriber, which cannot resume.`);
1868
+ const r = await apiJSON(creds, 'DELETE', `${path}/${action === 'unpub' ? 'publications' : 'slots'}/${encodeURIComponent(name)}`);
1869
+ if (JSON_MODE) return emitJSON(r.replication);
1870
+ info(`${green('dropped')} ${action === 'unpub' ? 'publication' : 'slot'} ${bold(name)}`);
1871
+ return;
1872
+ }
1873
+ const r = (await apiJSON(creds, 'GET', path)).replication;
1874
+ if (JSON_MODE) return emitJSON(r);
1875
+ info(`logical replication ${r.active ? green('on') : r.enabled ? yellow('saved, waiting for a restart') : dim('off')} ${dim(`(limit ${r.cap_mb} MB of WAL per slot)`)}`);
1876
+ for (const p of r.publications) info(` publication ${bold(p.name)} ${dim(p.all_tables ? 'all tables' : p.tables.join(', '))}`);
1877
+ for (const x of r.slots) {
1878
+ const tone = x.health === 'ok' ? green : x.health === 'lost' || x.health === 'critical' ? red : yellow;
1879
+ info(` slot ${bold(x.slot_name)} ${tone(x.health)} ${dim(`${x.retained_mb} MB held back (${x.percent_of_cap}% of the limit)`)}`);
1880
+ }
1881
+ return;
1882
+ }
1883
+
1884
+ if (sub === 'subscriptions') {
1885
+ const label = args._[1] ?? 'primary';
1886
+ const action = args._[2];
1887
+ const path = `${base}/databases/${encodeURIComponent(label)}/subscriptions`;
1888
+ if (action === 'add') {
1889
+ const name = args._[3];
1890
+ if (!name || typeof args.publication !== 'string') die('usage: canner db subscriptions <db> add <name> --from <url>|--from-env VAR|--from - --publication p1,p2 [--no-copy]', 2);
1891
+ const url = await readSecretArg(args, 'from', 'from-env', 'connection URL of the server to follow');
1892
+ const body = { name, url, publications: args.publication.split(',').map((x) => x.trim()).filter(Boolean), copy_data: args['no-copy'] !== true };
1893
+ const r = await apiJSON(creds, 'POST', path, body);
1894
+ if (JSON_MODE) return emitJSON(r.subscription);
1895
+ info(`${green('following')} ${bold(r.subscription.publications.join(', '))} on ${r.subscription.host}:${r.subscription.port}/${r.subscription.remote_db} as ${bold(name)}`);
1896
+ muted(`The existing rows are copied first. Watch it with: canner db subscriptions ${label}`);
1897
+ return;
1898
+ }
1899
+ if (action === 'refresh' || action === 'pause' || action === 'resume') {
1900
+ const name = args._[3];
1901
+ if (!name) die(`usage: canner db subscriptions <db> ${action} <name>`, 2);
1902
+ const r = action === 'refresh'
1903
+ ? await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/refresh`)
1904
+ : await apiJSON(creds, 'PATCH', `${path}/${encodeURIComponent(name)}`, { enabled: action === 'resume' });
1905
+ if (JSON_MODE) return emitJSON(r.subscriptions);
1906
+ info(`${green(action === 'refresh' ? 'refreshed' : action === 'pause' ? 'paused' : 'resumed')} ${bold(name)}`);
1907
+ return;
1908
+ }
1909
+ if (action === 'rm') {
1910
+ const name = args._[3];
1911
+ if (!name) die('usage: canner db subscriptions <db> rm <name> [--force] --yes', 2);
1912
+ requireYes(args, `This stops following with "${name}" and removes its replication slot on the other server. Rows already copied stay.`);
1913
+ await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}${args.force === true ? '?force=true' : ''}`);
1914
+ if (JSON_MODE) return emitJSON({ ok: true, removed: name });
1915
+ info(`${green('stopped')} following with ${bold(name)}`);
1916
+ return;
1917
+ }
1918
+ const r = await apiJSON(creds, 'GET', path);
1919
+ if (JSON_MODE) return emitJSON(r);
1920
+ if (r.subscriptions.length === 0) { muted(`not following any server. Start with: canner db subscriptions ${label} add <name> --from-env URL_VAR --publication <name>`); return; }
1921
+ for (const x of r.subscriptions) {
1922
+ const tone = x.health === 'streaming' ? green : x.health === 'error' ? red : x.health === 'disabled' ? yellow : cyan;
1923
+ info(`${pad(bold(x.name), 22)}${tone(x.health)} ${dim(`${x.remote_user}@${x.host}:${x.port}/${x.remote_db} ${x.publications.join(',')}`)}`);
1924
+ info(` ${x.tables_ready}/${x.tables_total} tables in step${x.last_message_at ? `, last change ${relTime(x.last_message_at)}` : ''}${x.apply_errors ? red(`, ${x.apply_errors} error(s)`) : ''}`);
1925
+ }
1926
+ return;
1927
+ }
1928
+
1929
+ if (sub === 'metrics') {
1930
+ const label = args._[1] ?? 'primary';
1931
+ const range = typeof args.range === 'string' ? args.range : '24h';
1932
+ const m = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/metrics?range=${encodeURIComponent(range)}`);
1933
+ if (JSON_MODE) return emitJSON(m);
1934
+ const pts = m.database.points;
1935
+ if (m.database.samples === 0) { muted('no samples yet: metrics appear a few minutes after a database is created.'); return; }
1936
+ const tx = pts.reduce((n, p) => n + p.transactions, 0);
1937
+ const conns = pts.map((p) => p.connections).filter((v) => v !== null);
1938
+ const hits = pts.map((p) => p.cache_hit).filter((v) => v !== null);
1939
+ info(`${bold(label)} last ${range}`);
1940
+ info(` transactions ${tx.toLocaleString()} (${pts.reduce((n, p) => n + p.rollbacks, 0).toLocaleString()} rolled back)`);
1941
+ info(` connections peak ${conns.length ? Math.max(...conns) : '—'}`);
1942
+ info(` cache hit ${hits.length ? (100 * hits.reduce((a, b) => a + b, 0) / hits.length).toFixed(1) + '%' : '—'}`);
1943
+ const last = [...pts].reverse().find((p) => p.size_bytes !== null);
1944
+ if (last) info(` size ${fmtBytes(last.size_bytes)}`);
1945
+ if (m.instance) {
1946
+ const ip = m.instance.points;
1947
+ const mem = ip.map((p) => p.mem_current).filter((v) => v !== null);
1948
+ const cpu = ip.map((p) => p.cpu_cores).filter((v) => v !== null);
1949
+ info(` instance memory peak ${mem.length ? fmtBytes(Math.max(...mem)) : '—'}, cpu peak ${cpu.length ? Math.max(...cpu).toFixed(2) + ' cores' : '—'}`);
1950
+ }
1951
+ return;
1952
+ }
1953
+
1954
+ if (sub === 'monitoring') {
1955
+ const path = `${base}/instance/monitoring`;
1956
+ const action = args._[1];
1957
+ if (action === 'on' || action === 'off') {
1958
+ const on = action === 'on';
1959
+ requireYes(args, on ? 'Turning monitoring on restarts your instance; connections drop for a few seconds.' : 'Turning monitoring off removes query statistics and logs and restarts your instance for a few seconds.');
1960
+ const body = { enabled: on, confirm: true };
1961
+ if (args['slow-ms'] !== undefined) body.slow_ms = Number(args['slow-ms']);
1962
+ const r = await apiJSON(creds, 'POST', path, body);
1963
+ if (JSON_MODE) return emitJSON(r.monitoring);
1964
+ info(`${green(on ? 'on' : 'off')} query insights and logs${on ? `, logging statements slower than ${r.monitoring.slow_ms} ms` : ''}`);
1965
+ return;
1966
+ }
1967
+ if (args['slow-ms'] !== undefined) {
1968
+ const r = await apiJSON(creds, 'POST', path, { enabled: true, slow_ms: Number(args['slow-ms']) });
1969
+ if (JSON_MODE) return emitJSON(r.monitoring);
1970
+ info(`${green('set')} slow-query threshold to ${r.monitoring.slow_ms} ms`);
1971
+ return;
1972
+ }
1973
+ const r = await apiJSON(creds, 'GET', path);
1974
+ if (JSON_MODE) return emitJSON(r.monitoring);
1975
+ info(`query insights and logs ${r.monitoring.enabled ? green('on') : dim('off')} (slow threshold ${r.monitoring.slow_ms} ms)`);
1976
+ return;
1977
+ }
1978
+
1979
+ if (sub === 'insights') {
1980
+ const label = args._[1] ?? 'primary';
1981
+ const path = `${base}/databases/${encodeURIComponent(label)}/insights`;
1982
+ if (args.reset === true) {
1983
+ requireYes(args, 'This clears the collected query statistics for this database.');
1984
+ await apiJSON(creds, 'POST', `${path}/reset`);
1985
+ if (JSON_MODE) return emitJSON({ ok: true });
1986
+ info(`${green('reset')} query statistics for ${bold(label)}`);
1987
+ return;
1988
+ }
1989
+ const sort = typeof args.sort === 'string' ? args.sort : 'total';
1990
+ const r = await apiJSON(creds, 'GET', `${path}?sort=${encodeURIComponent(sort)}&limit=${Number(args.limit ?? 10)}`);
1991
+ if (JSON_MODE) return emitJSON(r.statements);
1992
+ if (r.statements.length === 0) { muted('no statements yet.'); return; }
1993
+ for (const st of r.statements) {
1994
+ info(`${dim(`${st.calls.toLocaleString()}x avg ${st.mean_ms} ms total ${Math.round(st.total_ms).toLocaleString()} ms max ${st.max_ms} ms`)}`);
1995
+ info(` ${st.query.replace(/\s+/g, ' ').slice(0, 200)}`);
1996
+ }
1997
+ return;
1998
+ }
1999
+
2000
+ if (sub === 'logs') {
2001
+ const label = args._[1] ?? 'primary';
2002
+ const q = new URLSearchParams();
2003
+ if (typeof args.level === 'string') q.set('level', args.level);
2004
+ if (args['min-ms'] !== undefined) q.set('min_ms', String(args['min-ms']));
2005
+ if (typeof args.search === 'string') q.set('q', args.search);
2006
+ q.set('limit', String(Number(args.limit ?? 50)));
2007
+ const r = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/logs?${q.toString()}`);
2008
+ if (JSON_MODE) return emitJSON(r.entries);
2009
+ if (r.entries.length === 0) { muted('no matching log lines.'); return; }
2010
+ for (const e of r.entries) {
2011
+ const lvl = e.level === 'ERROR' || e.level === 'FATAL' || e.level === 'PANIC' ? red(e.level) : e.level === 'WARNING' ? yellow(e.level) : dim(e.level);
2012
+ info(`${dim(e.ts)} ${lvl} ${e.message.replace(/\s+/g, ' ')}`);
2013
+ if (e.detail) info(` ${dim(e.detail.replace(/\s+/g, ' '))}`);
2014
+ }
2015
+ return;
2016
+ }
2017
+
2018
+ if (sub === 'roles') {
2019
+ const label = args._[1] ?? 'primary';
2020
+ const action = args._[2];
2021
+ const path = `${base}/databases/${encodeURIComponent(label)}/roles`;
2022
+ if (action === 'create') {
2023
+ 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('-', '_') });
2028
+ 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)}`);
2030
+ info(` ${cyan(r.role.connection_string)}`);
2031
+ if (r.role.write_excluded_tables?.length) {
2032
+ info(` ${dim('kept SELECT-only:')} ${r.role.write_excluded_tables.join(', ')}`);
2033
+ }
2034
+ // A recommendation, not applied — surfaced once, here, right when it's actionable. The exact
2035
+ // command is printed so acting on it is a copy-paste, not a lookup.
2036
+ if (r.write_exclusion_suggestions?.length) {
2037
+ info('');
2038
+ info(`${yellow('recommendation:')} these table${r.write_exclusion_suggestions.length === 1 ? '' : 's'} look like a migration or audit ledger by name — if so, keep ${r.write_exclusion_suggestions.length === 1 ? 'it' : 'them'} read-only too:`);
2039
+ for (const t of r.write_exclusion_suggestions) {
2040
+ info(` ${dim('canner db roles')} ${label} ${dim('write-exclusions')} ${bold(name)} ${dim(t)}`);
2041
+ }
2042
+ }
2043
+ return;
2044
+ }
2045
+ if (action === 'rotate' || action === 'rm') {
2046
+ const name = args._[3];
2047
+ 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.`);
2049
+ if (action === 'rm') {
2050
+ await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
2051
+ if (JSON_MODE) return emitJSON({ ok: true, deleted: name });
2052
+ info(`${green('deleted')} role ${bold(name)}`);
2053
+ } else {
2054
+ const r = await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/rotate`);
2055
+ if (JSON_MODE) return emitJSON(r.role);
2056
+ info(`${green('rotated')} ${bold(name)}: ${cyan(r.role.connection_string)}`);
2057
+ }
2058
+ return;
2059
+ }
2060
+ if (action === 'write-exclusions') {
2061
+ const name = args._[3];
2062
+ const table = args._[4];
2063
+ if (!name || !table) die('usage: canner db roles <db> write-exclusions <role> <table>', 2);
2064
+ const r = await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/write-exclusions`, { table });
2065
+ if (JSON_MODE) return emitJSON(r.role);
2066
+ info(`${green('kept SELECT-only')} on ${bold(name)}: ${r.role.write_excluded_tables.join(', ')}`);
2067
+ return;
2068
+ }
2069
+ const r = await apiJSON(creds, 'GET', path);
2070
+ 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; }
2072
+ 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))}`);
2074
+ info(` ${cyan(x.connection_string)}`);
2075
+ if (x.external_connection_string) info(` ${cyan(x.external_connection_string)}`);
2076
+ if (x.write_excluded_tables?.length) info(` ${dim('kept SELECT-only:')} ${x.write_excluded_tables.join(', ')}`);
2077
+ }
2078
+ return;
2079
+ }
2080
+
2081
+ if (sub === 'offsite') {
2082
+ const action = args._[1];
2083
+ const path = `${base}/backup-destinations`;
2084
+ const listAll = async () => (await apiJSON(creds, 'GET', path)).destinations;
2085
+ const find = async (ref) => {
2086
+ const all = await listAll();
2087
+ const hit = all.find((d) => d.id === ref || d.name.toLowerCase() === String(ref).toLowerCase());
2088
+ if (!hit) die(`no destination "${ref}". List them with: canner db offsite`, 1);
2089
+ return hit;
2090
+ };
2091
+ const fields = (body) => {
2092
+ if (typeof args.endpoint === 'string') body.endpoint = args.endpoint;
2093
+ if (typeof args.bucket === 'string') body.bucket = args.bucket;
2094
+ if (typeof args.region === 'string') body.region = args.region;
2095
+ if (typeof args.folder === 'string') body.prefix = args.folder;
2096
+ if (typeof args['access-key'] === 'string') body.access_key = args['access-key'];
2097
+ if (typeof args['secret-key'] === 'string') body.secret_key = args['secret-key'];
2098
+ return body;
2099
+ };
2100
+ if (action === 'add') {
2101
+ const name = args._[2];
2102
+ const body = fields({ name });
2103
+ if (!name || !body.endpoint || !body.bucket || !body.access_key || !body.secret_key) {
2104
+ die('usage: canner db offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]', 2);
2105
+ }
2106
+ const r = await apiJSON(creds, 'POST', path, body);
2107
+ if (JSON_MODE) return emitJSON(r.destination);
2108
+ info(`${green('added')} off-site destination ${bold(r.destination.name)} (${r.destination.bucket} at ${r.destination.endpoint}). Test it: canner db offsite test ${r.destination.name}`);
2109
+ return;
2110
+ }
2111
+ if (action === 'edit') {
2112
+ if (!args._[2]) die('usage: canner db offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]', 2);
2113
+ const cur = await find(args._[2]);
2114
+ const body = fields({ name: typeof args['new-name'] === 'string' ? args['new-name'] : cur.name });
2115
+ body.endpoint ??= cur.endpoint; body.bucket ??= cur.bucket; body.access_key ??= cur.access_key; body.region ??= cur.region; body.prefix ??= cur.prefix;
2116
+ const r = await apiJSON(creds, 'PUT', `${path}/${cur.id}`, body);
2117
+ if (JSON_MODE) return emitJSON(r.destination);
2118
+ info(`${green('saved')} ${bold(r.destination.name)}. Test it: canner db offsite test ${r.destination.name}`);
2119
+ return;
2120
+ }
2121
+ if (action === 'test') {
2122
+ if (!args._[2]) die('usage: canner db offsite test <name>', 2);
2123
+ const cur = await find(args._[2]);
2124
+ const r = await apiJSON(creds, 'POST', `${path}/${cur.id}/test`);
2125
+ if (JSON_MODE) return emitJSON(r);
2126
+ if (r.ok) info(green(`${cur.name}: the bucket accepted a test file.`));
2127
+ else die(`${cur.name}: the test failed: ${r.error}`);
2128
+ return;
2129
+ }
2130
+ if (action === 'rm') {
2131
+ if (!args._[2]) die('usage: canner db offsite rm <name> --yes', 2);
2132
+ const cur = await find(args._[2]);
2133
+ requireYes(args, `This removes "${cur.name}"${cur.databases ? `; ${cur.databases} database(s) stop copying backups off-site` : ''}. Files already in your bucket stay there but can no longer be used to restore.`);
2134
+ await apiJSON(creds, 'DELETE', `${path}/${cur.id}`);
2135
+ if (JSON_MODE) return emitJSON({ ok: true });
2136
+ info(`${green('removed')} ${bold(cur.name)}`);
2137
+ return;
2138
+ }
2139
+ const all = await listAll();
2140
+ if (JSON_MODE) return emitJSON(all);
2141
+ if (all.length === 0) { muted('no off-site destinations. Add one with `canner db offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET`.'); return; }
2142
+ for (const d of all) {
2143
+ info(`${bold(d.name)} ${d.bucket} at ${cyan(d.endpoint)} region ${d.region} folder ${d.prefix || dim('(root)')}`);
2144
+ info(` access key ${d.access_key} ${d.last_test_at ? (d.last_test_ok ? green('test ok') : red('test failed')) + ' ' + dim(relTime(d.last_test_at)) : dim('not tested')} ${d.databases ? dim(`${d.databases} database(s)`) : ''}`);
2145
+ }
2146
+ return;
2147
+ }
2148
+
2149
+ if (sub === 'alerts') {
2150
+ const action = args._[1];
2151
+ const path = `${base}/database-alerts`;
2152
+ if (action === 'test') {
2153
+ const r = await apiJSON(creds, 'POST', `${path}/test`);
2154
+ if (JSON_MODE) return emitJSON(r);
2155
+ info(`${green('sent')} ${r.emails} email${r.emails === 1 ? '' : 's'}${r.webhook === 'sent' ? ' and the webhook' : r.webhook === 'failed' ? `; the webhook ${red('failed')}: ${r.error}` : ''}`);
2156
+ return;
2157
+ }
2158
+ if (action === 'set') {
2159
+ const patch = {};
2160
+ const onOff = (v, name) => { if (v !== 'on' && v !== 'off') die(`--${name} takes on or off.`, 2); return v === 'on'; };
2161
+ if (args.email !== undefined) patch.email_enabled = onOff(args.email, 'email');
2162
+ if (args.verify !== undefined) patch.verify_backups = onOff(args.verify, 'verify');
2163
+ if (args.recipients !== undefined) patch.recipients = args.recipients === 'all' ? [] : String(args.recipients).split(',').map((x) => x.trim()).filter(Boolean);
2164
+ if (args.webhook !== undefined) patch.webhook_url = args.webhook === 'none' ? null : String(args.webhook);
2165
+ if (args['rotate-secret'] === true) patch.rotate_webhook_secret = true;
2166
+ if (Object.keys(patch).length === 0) die('nothing to change. Use --email, --recipients, --webhook, --rotate-secret or --verify.', 2);
2167
+ const r = await apiJSON(creds, 'PATCH', path, patch);
2168
+ if (JSON_MODE) return emitJSON(r);
2169
+ info(`${green('saved')} alert settings`);
2170
+ if (r.webhook_secret) info(` webhook signing secret (shown once): ${cyan(r.webhook_secret)}`);
2171
+ return;
2172
+ }
2173
+ const r = await apiJSON(creds, 'GET', path);
2174
+ if (JSON_MODE) return emitJSON(r);
2175
+ const c = r.settings;
2176
+ info(`${bold('email')} ${c.email_enabled ? green('on') : dim('off')} → ${r.effective_recipients.join(', ') || dim('(nobody)')}${c.recipients.length ? '' : dim(' (all org admins)')}`);
2177
+ info(`${bold('webhook')} ${c.webhook_url ? cyan(c.webhook_url) : dim('none')}`);
2178
+ info(`${bold('verify')} ${c.verify_backups ? green('on') : dim('off')} ${dim('(test-restore backups overnight)')}`);
2179
+ const open = r.incidents.filter((i) => i.state === 'open');
2180
+ if (open.length === 0) muted('no open problems.');
2181
+ for (const i of open) info(` ${i.severity === 'critical' ? red(i.kind) : yellow(i.kind)} ${i.database ?? ''} ${dim(relTime(i.first_seen_at))}${i.detail?.error ? ' ' + dim(i.detail.error) : ''}`);
2182
+ return;
2183
+ }
2184
+
2185
+ if (sub === 'instance') {
2186
+ const action = args._[1];
2187
+ const path = `${base}/instance`;
2188
+
2189
+ if (!action || action === 'show') {
2190
+ const body = await apiJSON(creds, 'GET', path);
2191
+ if (JSON_MODE) return emitJSON(body);
2192
+ const i = body?.instance;
2193
+ if (!i) {
2194
+ muted(body?.allowed ? 'no dedicated instance yet. Create one with `canner db instance create`.' : 'A dedicated Postgres instance is a paid add-on for this project.');
2195
+ return;
2196
+ }
2197
+ info(`${bold('dedicated instance')} ${i.state === 'running' ? green(i.state) : yellow(i.state)} ${dim(`Postgres ${i.pg_version} · ${i.databases} database${i.databases === 1 ? '' : 's'}`)}`);
2198
+ info(` point-in-time recovery ${i.pitr_enabled ? green(`on (${i.pitr_window_days}-day window)`) : dim('off')}${i.pitr_enabled ? dim(` archive ${i.archive_mb} MB`) : ''}`);
2199
+ if (i.error) info(` ${red(i.error)}`);
2200
+ return;
2201
+ }
2202
+
2203
+ if (action === 'create') {
2204
+ const body = await apiJSON(creds, 'POST', path, {
2205
+ ...(args.pg ? { pg_version: String(args.pg) } : {}),
2206
+ ...(args.pitr === true ? { pitr: true } : {}),
2207
+ ...(args.window ? { window_days: Number(args.window) } : {}),
2208
+ });
2209
+ if (JSON_MODE) return emitJSON(body);
2210
+ const i = body?.instance;
2211
+ info(`${green('created')} a dedicated Postgres ${i.pg_version} instance${i.pitr_enabled ? ` with point-in-time recovery (${i.pitr_window_days}-day window)` : ''}`);
2212
+ muted('Add databases to it with `canner db create <name> --instance`, or move one with `canner db move <name> --to instance --yes`.');
2213
+ return;
2214
+ }
2215
+
2216
+ if (action === 'stop' || action === 'start') {
2217
+ if (action === 'stop') requireYes(args, 'Stopping takes every database on the instance offline (data is kept).');
2218
+ const body = await apiJSON(creds, 'POST', `${path}/power`, { action, ...(action === 'stop' ? { confirm: true } : {}) });
2219
+ if (JSON_MODE) return emitJSON(body);
2220
+ info(`${green(action === 'stop' ? 'stopped' : 'started')} the dedicated instance`);
2221
+ return;
2222
+ }
2223
+
2224
+ if (action === 'destroy') {
2225
+ requireYes(args, 'This permanently destroys the instance and its point-in-time-recovery archive. Every database on it must be moved or deleted first.');
2226
+ await apiJSON(creds, 'DELETE', `${path}?confirm=true`);
2227
+ if (JSON_MODE) return emitJSON({ ok: true });
2228
+ info(`${green('destroyed')} the dedicated instance`);
2229
+ return;
2230
+ }
2231
+
2232
+ die('usage: canner db instance [show] | create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | destroy --yes', 2);
2233
+ }
2234
+
2235
+ if (sub === 'pitr') {
2236
+ const path = `${base}/instance/pitr`;
2237
+ if (args.now === true) {
2238
+ const started = await apiJSON(creds, 'POST', `${base}/instance/base-backups`);
2239
+ const op = started?.operation;
2240
+ if (!op) die('the platform did not return an operation.');
2241
+ if (args['no-wait'] === true) { if (JSON_MODE) return emitJSON(op); info(`${green('started')} base backup ${dim(op.id)}`); return; }
2242
+ if (!JSON_MODE) info('taking a base backup…');
2243
+ const done = await waitForOperation(creds, base, op.id, args);
2244
+ if (JSON_MODE) return emitJSON(done);
2245
+ info(`${green('done')} ${done.result?.bytes != null ? dim(`(${fmtBytes(done.result.bytes)})`) : ''}`);
2246
+ return;
2247
+ }
2248
+ if (args.offsite !== undefined) {
2249
+ // --offsite <destination name|id> | --offsite none
2250
+ let destination_id = null;
2251
+ if (args.offsite !== 'none') {
2252
+ const dl = await apiJSON(creds, 'GET', `${base}/backup-destinations`);
2253
+ const want = String(args.offsite);
2254
+ const hit = dl.destinations.find((d) => d.id === want || d.name.toLowerCase() === want.toLowerCase());
2255
+ if (!hit) die(`no off-site destination named "${want}". Add one first: canner db offsite add ...`, 2);
2256
+ destination_id = hit.id;
2257
+ }
2258
+ const body = await apiJSON(creds, 'PATCH', `${base}/instance/archive-offsite`, { destination_id });
2259
+ if (JSON_MODE) return emitJSON(body);
2260
+ const o = body.archive_offsite;
2261
+ info(o.destination_id ? `${green('set')} the recovery archive is copied off-site to ${bold(o.destination_name)}` : `${green('set')} the recovery archive is no longer copied off-site`);
2262
+ return;
2263
+ }
2264
+ const setting = args.enable === true || args.disable === true || args.window !== undefined || args.every !== undefined;
2265
+ if (setting) {
2266
+ if (args.disable === true) requireYes(args, 'Turning off point-in-time recovery permanently deletes the recovery archive.');
2267
+ const patch = {};
2268
+ if (args.enable === true) patch.enabled = true;
2269
+ if (args.disable === true) { patch.enabled = false; patch.confirm = true; }
2270
+ if (args.window !== undefined) patch.window_days = Number(args.window);
2271
+ if (args.every !== undefined) patch.base_backup_interval_hours = Number(args.every);
2272
+ const body = await apiJSON(creds, 'PATCH', path, patch);
2273
+ if (JSON_MODE) return emitJSON(body);
2274
+ const i = body.instance;
2275
+ info(`${green('set')} point-in-time recovery: ${i.pitr_enabled ? `on, ${i.pitr_window_days}-day window, base backup every ${i.base_backup_interval_hours}h` : 'off'}`);
2276
+ return;
2277
+ }
2278
+ const body = await apiJSON(creds, 'GET', path);
2279
+ if (JSON_MODE) return emitJSON(body);
2280
+ const p = body.pitr;
2281
+ info(`${bold('point-in-time recovery')} ${p.enabled ? green('on') : dim('off')}`);
2282
+ if (p.enabled) {
2283
+ info(` restorable from ${p.earliest ? cyan(p.earliest) : dim('(first base backup pending)')} to ${p.latest_archived ? cyan(p.latest_archived) : dim('now')}`);
2284
+ info(` ${p.window_days}-day window · base backup every ${p.base_backup_interval_hours}h · ${p.base_backups} base backup${p.base_backups === 1 ? '' : 's'} · archive ${p.archive_mb} MB`);
2285
+ if (p.archive_failures > 0) info(` ${red(`${p.archive_failures} WAL archive failure(s) — contact support`)}`);
2286
+ const o = body.archive_offsite;
2287
+ if (o?.destination_id) {
2288
+ info(` off-site copy: ${bold(o.destination_name)} · ${o.files} file${o.files === 1 ? '' : 's'} (${fmtBytes(o.bytes)})${o.pending ? ` · ${o.pending} waiting` : ''}${o.last_at ? ` · last ${relTime(o.last_at)}` : ''}`);
2289
+ if (o.error) info(` ${red(`last copy failed: ${o.error}`)}`);
2290
+ } else if (p.archive_encrypted) {
2291
+ info(` off-site copy: ${dim('off (canner db pitr --offsite <destination>)')}`);
2292
+ }
2293
+ }
2294
+ return;
2295
+ }
2296
+
2297
+ if (sub === 'pitr-restore') {
2298
+ const label = args._[1];
2299
+ const to = typeof args.to === 'string' ? args.to : null;
2300
+ if (!label || !to) die('usage: canner db pitr-restore <name> --to <ISO-8601 time | latest>', 2);
2301
+ const started = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/pitr-restore`, { target: to });
2302
+ const op = started?.operation;
2303
+ const into = started?.database?.name;
2304
+ if (!op) die('the platform did not return an operation.');
2305
+ if (args['no-wait'] === true) {
2306
+ if (JSON_MODE) return emitJSON(started);
2307
+ info(`${green('started')} recovery ${dim(op.id)} → ${bold(into)}`);
2308
+ return;
2309
+ }
2310
+ if (!JSON_MODE) info(`recovering ${label} as of ${to} into ${into}…`);
2311
+ const done = await waitForOperation(creds, base, op.id, args);
2312
+ if (JSON_MODE) return emitJSON({ ...done, database: started.database });
2313
+ info(`${green('recovered')} ${bold(label)} as of ${to} → new database ${bold(into)}`);
2314
+ muted(`Nothing existing was touched. To swap it in: canner db copy ${into} ${label} --replace`);
2315
+ return;
2316
+ }
2317
+
2318
+ if (sub === 'move') {
2319
+ const label = args._[1];
2320
+ const to = typeof args.to === 'string' ? args.to : null;
2321
+ if (!label || (to !== 'instance' && to !== 'shared')) die('usage: canner db move <name> --to instance|shared --yes', 2);
2322
+ requireYes(args, 'Moving a database has real downtime while it runs (the connection string does not change; a failed move leaves it untouched).');
2323
+ const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(label)}/move`, { to, confirm: true }, 'moving');
2324
+ if (!done) return;
2325
+ if (JSON_MODE) return emitJSON(done);
2326
+ info(`${green('moved')} ${bold(label)} to ${to === 'instance' ? 'the dedicated instance' : 'shared hosting'}`);
2327
+ return;
2328
+ }
2329
+
2330
+ 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);
2331
+ }
2332
+
1132
2333
  function printHelp() {
1133
2334
  process.stdout.write(`${bold('canner')} ${dim('— deploy to Canner from any terminal')}
1134
2335
 
@@ -1180,6 +2381,98 @@ ${bold('Resources:')}
1180
2381
  ${bold('domains')} rm <hostname|id> [--slug X]
1181
2382
  Manage a project's custom domains.
1182
2383
 
2384
+ ${bold('Databases:')}
2385
+ ${bold('db')} [list] [--slug X] List the project's databases.
2386
+ ${bold('db')} init | create <name> [--pg 17|18]
2387
+ Provision the primary database, or add another (paid).
2388
+ ${bold('db')} info [<name>] [--reveal] | url [<name>]
2389
+ Connection details (password hidden unless --reveal).
2390
+ ${bold('db')} export [<name>] [--format custom|plain] [--out FILE]
2391
+ ${bold('db')} import <file> [--to <name>] [--replace]
2392
+ ${bold('db')} migrate-in --from <url>|--from-env VAR [--to <name>] [--replace]
2393
+ ${bold('db')} migrate-out --to-url <url>|--to-env VAR [--from <name>] --yes
2394
+ ${bold('db')} copy <from> <to> [--to-project SLUG] [--replace]
2395
+ ${bold('db')} rm <name> --yes | pg-version <name> <17|18> --yes | ops [<id>]
2396
+ Move data in and out, copy between databases, and
2397
+ manage them. Long operations run on Canner; the CLI
2398
+ waits (or use --no-wait).
2399
+ ${bold('db')} external [<name>] Show external-access status.
2400
+ ${bold('db')} external <name> --enable --allow CIDR [--allow CIDR ...]
2401
+ Turn on public access, allowed from only these
2402
+ addresses (repeat --allow; or --allow-all --yes for
2403
+ 0.0.0.0/0 — not recommended).
2404
+ ${bold('db')} external <name> --disable Turn public access off.
2405
+ ${bold('db')} rotate <name> --yes Rotate the database password.
2406
+ ${bold('db')} backup-policy [<name>] Show a database's backup schedule.
2407
+ ${bold('db')} backup-policy <name> --enable --every HOURS --keep DAYS [--keep-count N|none]
2408
+ ${bold('db')} backup-policy <name> --disable
2409
+ Turn scheduled backups on/off and set how often and how
2410
+ long they're kept (paid plans).
2411
+ ${bold('db')} backup [<name>] Take a manual backup now (waits by
2412
+ default, or use --no-wait).
2413
+ ${bold('db')} backups [<name>] List backups for a database.
2414
+ ${bold('db')} backups <name> <id> Show one backup.
2415
+ ${bold('db')} backups <name> <id> --download [--out FILE]
2416
+ Download the decrypted dump.
2417
+ ${bold('db')} backups <name> <id> --restore --inspect
2418
+ Restore into a temporary inspect database (own
2419
+ connection string, never injected, expires).
2420
+ ${bold('db')} backups <name> <id> --verify
2421
+ Test-restore the backup into a scratch database.
2422
+ ${bold('db')} diff <name> [--against <name>]
2423
+ Compare tables, columns and row counts (default: the
2424
+ database an inspect database was restored from).
2425
+ ${bold('db')} inspect <name> --keep | --extend HOURS
2426
+ ${bold('db')} resources Show the memory budget shared by the app and the dedicated instance.
2427
+ ${bold('db')} resources set [--share PCT] [--app-reserved GB] Postgres's share of the shared memory (10-90); reserved RAM kept for the app.
2428
+ ${bold('db')} resources apply --yes Size Postgres for it and set its memory limit (restarts the instance).
2429
+ ${bold('db')} extensions [<name>] [install|update|rm <ext>] [--window] [--yes] Extensions on the dedicated instance (pgvector, PostGIS, pg_trgm, pg_cron, ...).
2430
+ ${bold('db')} tuning [--preset balanced|read-heavy|write-heavy|analytics] [--statement-timeout S] [--idle-timeout S] [--lock-timeout S]
2431
+ [--window-day 0-6|all] [--window-hour 0-23] [--window-tz ZONE] Workload preset, timeouts and the maintenance window.
2432
+ ${bold('db')} restart --yes Restart the dedicated instance now (also applies settings waiting for the window).
2433
+ ${bold('db')} replication [<name>] [on [--cap MB] [--window] --yes | off --yes | cap MB | pub <n> --all|--tables a.b,c.d | unpub <n> --yes | drop-slot <n> --yes]
2434
+ Logical replication: turn on (restarts), the WAL cap per slot, publications, slots. Subscribers sign in with \`db roles <name> create <n> --replication\`.
2435
+ ${bold('db')} subscriptions [<name>] [add <n> --from <url>|--from-env VAR --publication p1,p2 [--no-copy] | refresh|pause|resume <n> | rm <n> [--force] --yes]
2436
+ Follow publications on an external server (live copy / no-downtime migration).
2437
+ ${bold('db')} metrics [<name>] [--range 1h|24h|7d|30d] Transactions, connections, cache hit, size (and instance memory/CPU).
2438
+ ${bold('db')} monitoring [on|off --yes] [--slow-ms N] Query insights + logs on the dedicated instance (restarts it).
2439
+ ${bold('db')} insights [<name>] [--sort total|mean|calls|max] [--limit N] | --reset --yes
2440
+ ${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).
2442
+ ${bold('db')} roles <name> create <role> --read-only | --read-write
2443
+ A read-write role keeps a migration ledger (schema_migrations and similar)
2444
+ SELECT-only automatically; the response may suggest other candidates by name.
2445
+ ${bold('db')} roles <name> rotate <role> --yes
2446
+ ${bold('db')} roles <name> rm <role> --yes
2447
+ ${bold('db')} roles <name> write-exclusions <role> <table>
2448
+ Keep one more table SELECT-only for a read-write role, live — no recreation.
2449
+ ${bold('db')} offsite List the off-site backup destinations (S3-compatible buckets).
2450
+ ${bold('db')} offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]
2451
+ ${bold('db')} offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]
2452
+ ${bold('db')} offsite test <name> | rm <name> --yes
2453
+ ${bold('db')} backup-policy <name> --offsite <destination>|off Copy this database's backups off-site to that destination.
2454
+ ${bold('db')} backups <name> <id> --offsite [destination] Copy one backup off-site now (default: the policy's destination).
2455
+ ${bold('db')} alerts Show alert settings and open problems.
2456
+ ${bold('db')} alerts set [--email on|off] [--recipients a@x.com,b@y.com|all]
2457
+ [--webhook URL|none] [--rotate-secret] [--verify on|off]
2458
+ ${bold('db')} alerts test Send a test alert.
2459
+ ${bold('db')} backups <name> <id> --restore [--no-wait]
2460
+ Restore into a brand-new database (safe default).
2461
+ ${bold('db')} backups <name> <id> --restore --to <name> --yes
2462
+ Restore IN PLACE into an existing database, replacing
2463
+ its contents (Canner snapshots it first).
2464
+ ${bold('db')} backups <name> <id> --rm --yes
2465
+ Delete a backup (destroys its key immediately).
2466
+ ${bold('db')} instance [create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | destroy --yes]
2467
+ A dedicated Postgres server for this project (paid add-on).
2468
+ ${bold('db')} create <name> --instance Create a database on the dedicated instance.
2469
+ ${bold('db')} move <name> --to instance|shared --yes
2470
+ Move a database onto/off the instance (same connection string).
2471
+ ${bold('db')} pitr [--enable | --disable --yes] [--window DAYS] [--every HOURS] [--now] [--offsite <destination>|none]
2472
+ Point-in-time recovery: status, settings, or a base backup now.
2473
+ ${bold('db')} pitr-restore <name> --to <ISO time | latest>
2474
+ Recover a database as of a moment into a NEW database.
2475
+
1183
2476
  ${bold('Global:')}
1184
2477
  ${bold('--json')} Emit machine-readable JSON (for scripts, CI, agents).
1185
2478
  Suppresses tables + progress; --follow log streaming is
@@ -1199,6 +2492,8 @@ ${bold('Examples:')}
1199
2492
  canner env set API_KEY=secret --sensitive ${dim('# add a hidden env var')}
1200
2493
  canner env list --reveal ${dim('# show sensitive values in cleartext')}
1201
2494
  canner domains add www.example.com ${dim('# attach a custom domain')}
2495
+ canner db export --out backup.dump ${dim('# download a pg_dump of the primary database')}
2496
+ canner db import backup.dump --to analytics ${dim('# restore a dump into another database')}
1202
2497
  canner projects --json | jq '.[].slug' ${dim('# machine-readable output')}
1203
2498
 
1204
2499
  ${bold('Environment:')}
@@ -1253,6 +2548,9 @@ const dispatch = {
1253
2548
  tokens: cmdToken,
1254
2549
  env: cmdEnv,
1255
2550
  domains: cmdDomains,
2551
+ db: cmdDb,
2552
+ database: cmdDb,
2553
+ databases: cmdDb,
1256
2554
  };
1257
2555
  const handler = dispatch[command];
1258
2556
  if (!handler) {