backupdrill 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,13 +150,15 @@ Backups record your installed Postgres extensions in the manifest, and the drill
150
150
 
151
151
  ### Run your own checks against the restored copy
152
152
 
153
- The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — RLS behavior and business workflows only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
153
+ The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — business invariants only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
154
154
 
155
155
  ```bash
156
156
  backupdrill drill --check-cmd "npm run smoke" # BACKUPDRILL_SANDBOX_URL points at the restored copy
157
157
  ```
158
158
 
159
- Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_URL` set to the sandbox's connection string. Exit 0 means pass; the result shows up in the report as an `app checks` line, and a non-zero exit fails the drill (the report keeps structural and app-level failures clearly separate). Entirely optional — without the flag, nothing changes and the report has no `app checks` line. There's a 10-minute timeout, and `--keep` leaves the sandbox container running when a drill fails so you can inspect it (`docker rm -f <id>` when done).
159
+ Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_URL` set to the sandbox's connection string. Exit 0 means pass; the result shows up in the report as an `app checks` line, and a non-zero exit fails the drill (the report keeps structural and app-level failures clearly separate). Entirely optional — without the flag, nothing changes and the report has no `app checks` line. There's a 10-minute timeout (the whole process tree is killed), and `--keep` leaves the sandbox container running when a drill fails so you can inspect it — the report JSON then includes a `keptSandbox` object with the container id and connection string (`docker rm -f <id>` when done).
160
+
161
+ **What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security — plus policies referencing Supabase-managed roles (`authenticated`, `anon`) are skipped during restore, since those roles don't exist outside the platform. So use `--check-cmd` for data and business invariants ("orders reference existing users", "the row count I care about is sane"). It is **not** an RLS behavior test — verifying RLS takes an authenticated client stack with real JWTs, which no restore sandbox can fake generically.
160
162
 
161
163
  When you actually need to recover, `restore` puts the database back into a target you name and pulls the Storage files down to a local folder:
162
164
 
@@ -196,6 +198,23 @@ Before switching to a **daily** schedule, read the egress-cost section above —
196
198
 
197
199
  For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
198
200
 
201
+ ## Versioning & stability
202
+
203
+ `backupdrill` follows [semantic versioning](https://semver.org/). From **1.0.0** onwards the
204
+ following is the stable public surface — breaking it requires a major version bump:
205
+
206
+ - the commands (`backup`, `drill`, `restore`, `estimate`) and their flags
207
+ - the `BACKUPDRILL_*` environment variables and the config-file keys
208
+ - the layout written to your bucket, and the shape of `manifest.json`
209
+
210
+ `manifest.json` carries a `schemaVersion` so the format can evolve safely. A snapshot written by
211
+ a newer release is **refused with a clear message** rather than parsed on a best-effort basis —
212
+ for a backup tool, silently misreading an archive is the worst possible failure. Snapshots taken
213
+ before 1.0 have no `schemaVersion` and are treated as version 1, so older backups stay readable.
214
+
215
+ The package's **JavaScript exports are internal** to the hosted BackupDrill product and are not
216
+ covered by this promise; they may change in any release. Use the CLI if you need a stable contract.
217
+
199
218
  ## License
200
219
 
201
220
  MIT
package/dist/backup.js CHANGED
@@ -5,9 +5,11 @@ import { promisify } from "node:util";
5
5
  import { Client } from "pg";
6
6
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { Upload } from "@aws-sdk/lib-storage";
8
+ import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
8
9
  import { syncStorage } from "./storage.js";
9
10
  import { log } from "./log.js";
10
11
  import { TOOL_VERSION } from "./version.js";
12
+ import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
11
13
  const execFileAsync = promisify(execFile);
12
14
  function majorOf(serverVersionNum) {
13
15
  // server_version_num: 150004 → 15, 170002 → 17
@@ -45,7 +47,9 @@ async function pgDumpVersion(bin) {
45
47
  }
46
48
  }
47
49
  async function inspectDatabase(databaseUrl, schemas) {
48
- const client = new Client({ connectionString: databaseUrl });
50
+ // Supabase 主机 → 用打包的根 CA 做 verify-full(pooler 证书自签、不在系统信任库);
51
+ // 非 Supabase(本 CLI 不限制目标)→ 保持默认,别把开源用户挡在门外
52
+ const client = new Client(pgConnectOptions(databaseUrl));
49
53
  await client.connect();
50
54
  try {
51
55
  const version = await client.query("show server_version");
@@ -97,13 +101,15 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
97
101
  },
98
102
  });
99
103
  const schemaArgs = config.schemas.map((s) => `--schema=${s}`);
104
+ // pg_dump(libpq):Supabase 主机才改写成 sslmode=verify-full + sslrootcert=<打包CA临时文件>
105
+ const dumpDbUrl = dumpUrlFor(config.databaseUrl);
100
106
  const dump = spawn(pgDumpBin, [
101
107
  "--format=custom",
102
108
  "--no-owner",
103
109
  "--no-privileges",
104
110
  ...schemaArgs,
105
111
  "--dbname",
106
- config.databaseUrl,
112
+ dumpDbUrl,
107
113
  ], { stdio: ["ignore", "pipe", "pipe"] });
108
114
  let stderr = "";
109
115
  dump.stderr.on("data", (d) => {
@@ -189,6 +195,7 @@ export async function runBackup(config) {
189
195
  `${(synced.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
190
196
  }
191
197
  const manifest = {
198
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
192
199
  tool: "backupdrill-cli",
193
200
  toolVersion: TOOL_VERSION,
194
201
  createdAt: new Date().toISOString(),
package/dist/drill.d.ts CHANGED
@@ -20,6 +20,11 @@ export interface DrillReport {
20
20
  restoreSeconds: number;
21
21
  restoredTableCount: number;
22
22
  restoredRowTotal: number;
23
+ /** --keep 且演练失败时:被保留的沙箱(供排查/脚本消费);其余情况不存在 */
24
+ keptSandbox?: {
25
+ containerId: string;
26
+ connString: string;
27
+ };
23
28
  }
24
29
  /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
25
30
  interface PostDataResult {
@@ -36,8 +41,9 @@ export declare function postDataResult(code: number | null, stderr: string): Pos
36
41
  export declare const APP_CHECK_TIMEOUT_MS: number;
37
42
  /**
38
43
  * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
39
- * 交给用户命令执行(RLS 行为、业务流可用性——判据只存在于应用侧,引擎不解释
40
- * 命令输出,只裁决退出码:0 = pass)。
44
+ * 交给用户命令执行,校验数据/业务不变量(判据只存在于应用侧,引擎不解释命令输出,
45
+ * 只裁决退出码:0 = pass)。注意这不是 RLS 行为测试:连接身份是沙箱超级用户
46
+ * (绕过 RLS),且引用 Supabase 托管角色的策略在恢复时已被归类跳过。
41
47
  * 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
42
48
  * 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
43
49
  * 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
package/dist/drill.js CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { Client } from "pg";
7
+ import { parseManifest } from "./manifest.js";
7
8
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
9
  import { resolvePgRestoreBin } from "./pgbin.js";
9
10
  import { log } from "./log.js";
@@ -313,8 +314,9 @@ async function verifyStorage(s3, config, manifest, verifyAll) {
313
314
  export const APP_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
314
315
  /**
315
316
  * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
316
- * 交给用户命令执行(RLS 行为、业务流可用性——判据只存在于应用侧,引擎不解释
317
- * 命令输出,只裁决退出码:0 = pass)。
317
+ * 交给用户命令执行,校验数据/业务不变量(判据只存在于应用侧,引擎不解释命令输出,
318
+ * 只裁决退出码:0 = pass)。注意这不是 RLS 行为测试:连接身份是沙箱超级用户
319
+ * (绕过 RLS),且引用 Supabase 托管角色的策略在恢复时已被归类跳过。
318
320
  * 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
319
321
  * 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
320
322
  * 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
@@ -326,10 +328,22 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
326
328
  shell: true,
327
329
  stdio: ["ignore", 2, 2],
328
330
  env: { ...process.env, BACKUPDRILL_SANDBOX_URL: connString },
329
- timeout: timeoutMs,
330
- killSignal: "SIGKILL",
331
+ // 独立进程组:超时必须杀整棵进程树。只杀 shell 的话,npm/测试运行器这类
332
+ // 孙进程会在沙箱销毁后继续跑(xreview 抓出的资源泄漏)。
333
+ detached: true,
331
334
  });
335
+ let timedOut = false;
336
+ const timer = setTimeout(() => {
337
+ timedOut = true;
338
+ try {
339
+ process.kill(-proc.pid, "SIGKILL"); // 负 pid = 杀整个进程组
340
+ }
341
+ catch {
342
+ proc.kill("SIGKILL");
343
+ }
344
+ }, timeoutMs);
332
345
  proc.on("error", (error) => {
346
+ clearTimeout(timer);
333
347
  resolve({
334
348
  name: "app checks",
335
349
  pass: false,
@@ -337,12 +351,21 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
337
351
  });
338
352
  });
339
353
  proc.on("close", (code, signal) => {
354
+ clearTimeout(timer);
340
355
  const seconds = Math.round((Date.now() - started) / 100) / 10;
356
+ if (timedOut) {
357
+ resolve({
358
+ name: "app checks",
359
+ pass: false,
360
+ detail: `timed out after ${timeoutMs / 1000}s — process tree killed`,
361
+ });
362
+ return;
363
+ }
341
364
  if (signal) {
342
365
  resolve({
343
366
  name: "app checks",
344
367
  pass: false,
345
- detail: `terminated by ${signal} after ${seconds}s (timeout ${timeoutMs / 1000}s)`,
368
+ detail: `terminated by ${signal} after ${seconds}s`,
346
369
  });
347
370
  return;
348
371
  }
@@ -362,6 +385,12 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
362
385
  * 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
363
386
  */
364
387
  export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
388
+ // 空/纯空白命令 = 危险的假配置(CI 里变量没展开的典型形态):跑了会以 exit 0
389
+ // 假通过,忽略会让用户以为检查在跑。拒绝,且要在起沙箱之前拒绝。
390
+ const appCheckCommand = opts.appCheckCommand?.trim();
391
+ if (opts.appCheckCommand !== undefined && !appCheckCommand) {
392
+ throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to report a check that would never run");
393
+ }
365
394
  const checks = [...preChecks];
366
395
  const pg = await startEphemeralPostgres(sandboxImage(manifest));
367
396
  let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
@@ -414,12 +443,12 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
414
443
  checks.push(...verify.checks);
415
444
  // 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
416
445
  // 一次失败报成两层失败,误导排查方向。没配置则不跑、报告里不出现该行。
417
- if (opts.appCheckCommand && checks.every((c) => c.pass)) {
446
+ if (appCheckCommand && checks.every((c) => c.pass)) {
418
447
  log.step("Running app checks…");
419
- checks.push(await runAppCheck(opts.appCheckCommand, pg.connString));
448
+ checks.push(await runAppCheck(appCheckCommand, pg.connString));
420
449
  }
421
450
  pass = checks.every((c) => c.pass);
422
- return {
451
+ const report = {
423
452
  snapshot,
424
453
  pass,
425
454
  checks,
@@ -427,6 +456,20 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
427
456
  restoredTableCount: verify.tableCount,
428
457
  restoredRowTotal: verify.rowTotal,
429
458
  };
459
+ if (opts.keepSandboxOnFailure && !pass) {
460
+ // 写进报告让 --keep 可被脚本消费(拿连接串排查),不只靠人眼看日志
461
+ report.keptSandbox = { containerId: pg.containerId, connString: pg.connString };
462
+ }
463
+ return report;
464
+ }
465
+ catch (error) {
466
+ // 异常路径(恢复/校验抛出)没有报告对象可挂 keptSandbox;把沙箱坐标并入
467
+ // 错误信息,让脚本和人都拿得到被保留容器的清理线索
468
+ if (opts.keepSandboxOnFailure) {
469
+ error.message +=
470
+ ` [sandbox kept: ${pg.connString} — remove with: docker rm -f ${pg.containerId.slice(0, 12)}]`;
471
+ }
472
+ throw error;
430
473
  }
431
474
  finally {
432
475
  if (opts.keepSandboxOnFailure && !pass) {
@@ -446,7 +489,7 @@ export async function runDrill(config, opts = {}) {
446
489
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
447
490
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
448
491
  log.step(`Drilling snapshot ${snapshot}`);
449
- const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
492
+ const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
450
493
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
451
494
  const dumpPath = join(workdir, "dump.pgcustom");
452
495
  try {
package/dist/estimate.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Client } from "pg";
2
2
  import { measureStorage } from "./storage.js";
3
3
  import { log } from "./log.js";
4
+ import { pgConnectOptions } from "./supabase-ca.js";
4
5
  // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
5
6
  const SUBSCRIPTION_FLOOR_USD = 19;
6
7
  const GB = 1024 * 1024 * 1024;
7
8
  async function measureDbBytes(databaseUrl, schemas) {
8
- const client = new Client({ connectionString: databaseUrl });
9
+ const client = new Client(pgConnectOptions(databaseUrl));
9
10
  await client.connect();
10
11
  try {
11
12
  // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
package/dist/index.js CHANGED
@@ -76,10 +76,16 @@ program
76
76
  .option("--project-name <name>", "project name used in the object key")
77
77
  .option("--verify-all-files", "checksum every Storage file, not just a sample")
78
78
  .option("--check-cmd <command>", "after structural checks pass, run your own smoke test against the restored sandbox " +
79
- "(connection string in BACKUPDRILL_SANDBOX_URL; exit 0 = pass, reported as 'app checks')")
79
+ "(connection string in BACKUPDRILL_SANDBOX_URL; exit 0 = pass, reported as 'app checks'). " +
80
+ "Note: connects as the sandbox superuser, so RLS policies are NOT enforced — " +
81
+ "verify data and business invariants here, not RLS behavior")
80
82
  .option("--keep", "if the drill fails, keep the sandbox container running for inspection")
81
83
  .action(async (opts) => {
82
84
  try {
85
+ if (opts.checkCmd !== undefined && !String(opts.checkCmd).trim()) {
86
+ throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to run a drill " +
87
+ "that would report a check that never executes");
88
+ }
83
89
  const config = await loadConfig({
84
90
  configPath: opts.config,
85
91
  overrides: {
package/dist/lib.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { runBackup } from "./backup.js";
2
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
2
3
  export { runDrill, drillDump } from "./drill.js";
3
4
  export { runRestore } from "./restore.js";
4
5
  export { runEstimate, project } from "./estimate.js";
package/dist/lib.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // 库入口:把引擎函数暴露给下游复用(产品 worker 直接 import 这些,而不是重写)。
2
2
  // CLI(bin)是这套引擎的薄封装;worker 在其外加调度、凭据托管、告警、报告。
3
3
  export { runBackup } from "./backup.js";
4
+ // verify-full:只导出**原子** API。刻意不导出裸 SUPABASE_SSL —— 它一旦和 connectionString
5
+ // 搭配使用,URL 里的 sslmode=disable 又会把它覆盖掉,等于把这次修掉的绕过再放回去。
6
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
4
7
  export { runDrill, drillDump } from "./drill.js";
5
8
  export { runRestore } from "./restore.js";
6
9
  export { runEstimate, project } from "./estimate.js";
@@ -19,7 +19,18 @@ export interface ExtensionInfo {
19
19
  version: string;
20
20
  schema: string;
21
21
  }
22
+ /**
23
+ * manifest.json 的格式版本。**这是留在客户桶里、可能躺很多年的持久产物**——将来改结构时,
24
+ * 读取端要靠它分支判断,而不是去猜 toolVersion。
25
+ *
26
+ * 约定:
27
+ * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
28
+ * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
29
+ * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
30
+ */
31
+ export declare const MANIFEST_SCHEMA_VERSION = 1;
22
32
  export interface Manifest {
33
+ schemaVersion?: number;
23
34
  tool: "backupdrill-cli";
24
35
  toolVersion: string;
25
36
  createdAt: string;
@@ -51,3 +62,8 @@ export interface StorageFile {
51
62
  bytes: number;
52
63
  sha256: string;
53
64
  }
65
+ /**
66
+ * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
67
+ * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
68
+ */
69
+ export declare function parseManifest(json: string): Manifest;
package/dist/manifest.js CHANGED
@@ -1 +1,30 @@
1
- export {};
1
+ /**
2
+ * manifest.json 的格式版本。**这是留在客户桶里、可能躺很多年的持久产物**——将来改结构时,
3
+ * 读取端要靠它分支判断,而不是去猜 toolVersion。
4
+ *
5
+ * 约定:
6
+ * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
7
+ * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
8
+ * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
9
+ */
10
+ export const MANIFEST_SCHEMA_VERSION = 1;
11
+ /**
12
+ * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
13
+ * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
14
+ */
15
+ export function parseManifest(json) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(json);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`manifest.json is not valid JSON: ${error.message}`);
22
+ }
23
+ const version = parsed.schemaVersion ?? 1; // 缺失 = 1.0 之前的旧快照
24
+ if (!Number.isFinite(version) || version > MANIFEST_SCHEMA_VERSION) {
25
+ throw new Error(`This snapshot's manifest uses schema version ${String(parsed.schemaVersion)}, but this ` +
26
+ `BackupDrill understands up to ${MANIFEST_SCHEMA_VERSION}. Upgrade the CLI ` +
27
+ `(npm i -g backupdrill@latest) to read it.`);
28
+ }
29
+ return parsed;
30
+ }
package/dist/restore.js CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join, dirname } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { GetObjectCommand } from "@aws-sdk/client-s3";
8
+ import { parseManifest } from "./manifest.js";
8
9
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
9
10
  import { resolvePgRestoreBin } from "./pgbin.js";
10
11
  import { log } from "./log.js";
@@ -28,7 +29,7 @@ export async function runRestore(config, opts) {
28
29
  const s3 = targetClient(config);
29
30
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
30
31
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
31
- const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
32
+ const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
32
33
  const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
33
34
  const result = {
34
35
  snapshot,
@@ -0,0 +1,32 @@
1
+ export declare const SUPABASE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
2
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
3
+ export declare const SUPABASE_SSL: {
4
+ readonly ca: "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
5
+ readonly rejectUnauthorized: true;
6
+ };
7
+ /**
8
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
9
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
10
+ */
11
+ export declare function supabaseCaFile(): string;
12
+ /**
13
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
14
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
15
+ */
16
+ export declare function forceVerifyFull(databaseUrl: string, caPath: string): string;
17
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
18
+ export declare function dumpUrlFor(databaseUrl: string): string;
19
+ /**
20
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
21
+ *
22
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
23
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
24
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
25
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
26
+ *
27
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
28
+ */
29
+ export declare function pgConnectOptions(databaseUrl: string): {
30
+ connectionString: string;
31
+ ssl?: typeof SUPABASE_SSL;
32
+ };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Supabase Root 2021 CA —— 打包进引擎,用于对用户 Supabase 源库连接做 verify-full。
3
+ *
4
+ * 为什么必须自带:Supabase pooler(Supavisor)的证书由 Supabase 自签 CA 签发
5
+ * (链:*.pooler.supabase.com ← Supabase Intermediate 2021 CA ← Supabase Root 2021 CA),
6
+ * **不在系统 CA 信任库里**——直接 rejectUnauthorized:true 会报 SELF_SIGNED_CERT_IN_CHAIN
7
+ * (2026-07-20 真机实测)。把这个自签根作为 ca 传入,node-pg / libpq(pg_dump)即可完成
8
+ * verify-full(验签 + 验通配主机名 *.pooler.supabase.com)。所有 Supabase 项目共用这一张根,
9
+ * 有效期至 2031-04(过期前需更新)。
10
+ *
11
+ * 只对**用户 Supabase 源库**连接使用(backup 探查 / pg_dump / estimate / 预检);演练用的
12
+ * 本地 Docker 沙箱、restore 的任意目标库不适用,勿套用。
13
+ */
14
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ export const SUPABASE_ROOT_CA = `-----BEGIN CERTIFICATE-----
18
+ MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
19
+ BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
20
+ dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
21
+ c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
22
+ azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
23
+ YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
24
+ Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
25
+ QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
26
+ DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
27
+ GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
28
+ cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
29
+ O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
30
+ NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
31
+ uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
32
+ aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
33
+ tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
34
+ VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
35
+ jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
36
+ Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
37
+ CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
38
+ o/bKiIz+Fq8=
39
+ -----END CERTIFICATE-----
40
+ `;
41
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
42
+ export const SUPABASE_SSL = { ca: SUPABASE_ROOT_CA, rejectUnauthorized: true };
43
+ let caFilePath = null;
44
+ /**
45
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
46
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
47
+ */
48
+ export function supabaseCaFile() {
49
+ // 缓存的文件可能被系统 tmp 清理删掉;长驻 worker 若一直返回失效路径,之后**每次**
50
+ // pg_dump 都会失败直到重启。发现不存在就重建。
51
+ if (caFilePath && !existsSync(caFilePath))
52
+ caFilePath = null;
53
+ if (!caFilePath) {
54
+ // 私有随机目录(mkdtemp 建的是 0700、属主为本进程):固定的 /tmp 路径在多用户机器上
55
+ // 可被他人预置符号链接,或**替换成伪造 CA** —— 那我们的 pg_dump 就会去信任攻击者的
56
+ // 证书,等于自己开了 MITM 的门。
57
+ const dir = mkdtempSync(join(tmpdir(), "backupdrill-ca-"));
58
+ const p = join(dir, "supabase-root-ca.pem");
59
+ writeFileSync(p, SUPABASE_ROOT_CA, { mode: 0o600 });
60
+ caFilePath = p;
61
+ // 长驻进程(worker)整个生命周期都要用它,退出时再尽力清掉,避免反复起停堆积
62
+ process.once("exit", () => {
63
+ try {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ catch {
67
+ /* 清理失败无所谓,内容是公开 CA */
68
+ }
69
+ });
70
+ }
71
+ return caFilePath;
72
+ }
73
+ /** 所有会影响 TLS 行为的连接串参数——必须全部剥掉,否则会盖过我们显式传的 ssl 配置。 */
74
+ const SSL_QUERY_PARAMS = [
75
+ "sslmode",
76
+ "ssl",
77
+ "requiressl", // 老式开关
78
+ "sslrootcert",
79
+ "sslcert",
80
+ "sslkey",
81
+ "sslcrl",
82
+ "sslcrldir",
83
+ "sslpassword",
84
+ "sslcertmode",
85
+ "sslcompression",
86
+ "sslsni",
87
+ "sslnegotiation",
88
+ "ssl_min_protocol_version",
89
+ "ssl_max_protocol_version",
90
+ // PG18:把 TLS 会话密钥写到文件 —— 留着它,"强制加密"就是句空话(流量可被解密)
91
+ "sslkeylogfile",
92
+ "uselibpqcompat",
93
+ // GSS 会在 TLS 之前协商,带 GSS 的 libpq 构建可能先走 GSS 再谈 TLS;下面统一置 disable
94
+ "gssencmode",
95
+ ];
96
+ /**
97
+ * 手工摘除查询参数——**不能**用 URLSearchParams:一旦碰它,URL 会按 form-urlencoded
98
+ * 重新序列化整个查询串,把 `%20` 写成 `+`(2026-07-22 实测)。而 libpq 把 `+` 当字面量,
99
+ * 于是合法的 `?options=-c%20statement_timeout%3D0` 会被改成 `-c+statement_timeout=0`
100
+ * 让 pg_dump 直接失败。这里逐段过滤、原样保留其余片段的编码。
101
+ */
102
+ function stripSslParams(databaseUrl) {
103
+ const q = databaseUrl.indexOf("?");
104
+ if (q === -1)
105
+ return databaseUrl;
106
+ const base = databaseUrl.slice(0, q);
107
+ const pairs = databaseUrl
108
+ .slice(q + 1)
109
+ .split("&")
110
+ .filter((pair) => pair !== "");
111
+ const keyOf = (pair) => {
112
+ const raw = pair.split("=")[0] ?? "";
113
+ try {
114
+ return decodeURIComponent(raw).toLowerCase();
115
+ }
116
+ catch {
117
+ return raw.toLowerCase(); // 编码坏了就按原文比对
118
+ }
119
+ };
120
+ const valueOf = (pair) => pair.slice((pair.split("=")[0] ?? "").length + 1);
121
+ // 两端都是"最后一个 host 生效"。若最后一个是**空值**,实际目标回落到 authority ——
122
+ // 这时必须删掉**所有** host 条目:只删空的那条,会把前面的 host=other 留给 libpq,
123
+ // 造成探查连 authority、pg_dump 连 other 的分裂。
124
+ const hostValues = pairs.filter((p) => keyOf(p) === "host").map(valueOf);
125
+ const dropAllHosts = hostValues.length > 0 && hostValues[hostValues.length - 1] === "";
126
+ const kept = pairs.filter((pair) => {
127
+ const key = keyOf(pair);
128
+ if (SSL_QUERY_PARAMS.includes(key))
129
+ return false;
130
+ if (key === "host" && dropAllHosts)
131
+ return false;
132
+ return true;
133
+ });
134
+ return kept.length ? `${base}?${kept.join("&")}` : base;
135
+ }
136
+ /**
137
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
138
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
139
+ */
140
+ export function forceVerifyFull(databaseUrl, caPath) {
141
+ const stripped = stripSslParams(databaseUrl);
142
+ const sep = stripped.includes("?") ? "&" : "?";
143
+ // gssencmode=disable:带 GSS 的 libpq 构建会在 TLS 之前尝试 GSS 协商,可能在用上我们的
144
+ // CA 之前就失败;我们从不用 Kerberos,显式关掉让行为跨构建确定。
145
+ return `${stripped}${sep}sslmode=verify-full&sslrootcert=${encodeURIComponent(caPath)}&gssencmode=disable`;
146
+ }
147
+ /**
148
+ * 按主机名决定是否启用 Supabase verify-full。
149
+ *
150
+ * 关键:本 CLI 是公开工具,**不限制**连接串必须是 Supabase(那道校验只在产品侧的
151
+ * shared/endpoints.ts)。若无条件套 Supabase CA,指向自建 Supabase / 其它 Postgres 的
152
+ * 用户会直接 TLS 失败——对开源工具是回归。因此只在目标确实是 Supabase 主机时启用;
153
+ * 其余情形返回 undefined,保持 pg 的默认行为(由连接串自身的 sslmode 决定)。
154
+ */
155
+ const SUPABASE_HOST = /\.supabase\.(co|com|net)$/;
156
+ /**
157
+ * 真正会被连接的主机。两个坑:
158
+ * - libpq/pg 允许 `?host=` 覆盖 authority 里的主机 → 必须按实际目标判定,否则会出现
159
+ * "给 A 套了 B 的 CA"(证书必然不匹配)或"真连 Supabase 却没套 CA"。
160
+ * - DNS 末尾根点(`...supabase.com.`)是合法写法,不归一化会把合法 Supabase 主机漏判成外部库。
161
+ */
162
+ const EMPTY_AUTHORITY_HOST = "backupdrill-empty-authority.invalid";
163
+ /**
164
+ * 按 node-pg 的宽容度解析连接串。WHATWG URL **拒绝空 authority**
165
+ * (`postgresql://u:p@/db?host=…`),但 node-pg 接受并连到 host= 指定的主机
166
+ * ——直接 try/catch 判 null 会把这种串当成外部库放行,于是 `sslmode=disable`
167
+ * 原样保留、真连 Supabase 却走明文(2026-07-22 审查抓到)。
168
+ * 这里给空 authority 塞个占位主机让它可解析,并记住 authority 实为空。
169
+ */
170
+ function parseLikePg(databaseUrl) {
171
+ try {
172
+ const url = new URL(databaseUrl);
173
+ return { url, authorityHost: url.hostname };
174
+ }
175
+ catch {
176
+ /* 可能正是 node-pg 允许、WHATWG 拒绝的空 authority */
177
+ }
178
+ const m = databaseUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)([\s\S]*)$/);
179
+ if (!m)
180
+ return null;
181
+ const [, scheme, authority, rest] = m;
182
+ // 只处理"空 authority"或"有用户名但无主机(以 @ 结尾)"这两种
183
+ if (authority !== "" && !authority.endsWith("@"))
184
+ return null;
185
+ try {
186
+ return { url: new URL(`${scheme}${authority}${EMPTY_AUTHORITY_HOST}${rest}`), authorityHost: "" };
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ function effectiveHost(databaseUrl) {
193
+ const parsed = parseLikePg(databaseUrl);
194
+ if (!parsed)
195
+ return null; // 实在解析不了就别自作主张套 CA
196
+ const { url, authorityHost } = parsed;
197
+ // 必须**与 node-pg 的解析语义逐字对齐**(2026-07-22 实测):
198
+ // - 重复的 host= 取**最后一个**(URLSearchParams.get 取第一个,会判错)
199
+ // - host= 为**空值**时回落到 authority 主机(当成"没写")
200
+ // 判错的后果不是小事:真连 Supabase 却被当成外部库 → 不剥 sslmode=disable → 明文。
201
+ // query 里的 host= 已被 URLSearchParams 解码;authority 主机名 WHATWG URL **不解码**,
202
+ // 而 node-pg 会解码(实测 `supabase%2Ecom` → `supabase.com` 并真的连上去)。不解码就会
203
+ // 把合法 Supabase 主机判成外部库 → 保留 sslmode=disable → 明文。坏转义按原文处理。
204
+ let decodedAuthority = authorityHost;
205
+ try {
206
+ decodedAuthority = decodeURIComponent(authorityHost);
207
+ }
208
+ catch {
209
+ /* 转义损坏:按原文比对,宁可判成外部库也不误套 CA */
210
+ }
211
+ const all = url.searchParams.getAll("host");
212
+ const last = all.length ? all[all.length - 1] : "";
213
+ const host = (last !== "" ? last : decodedAuthority).toLowerCase().replace(/\.$/, "");
214
+ return host || null;
215
+ }
216
+ function isSupabaseHost(databaseUrl) {
217
+ const host = effectiveHost(databaseUrl);
218
+ return host !== null && SUPABASE_HOST.test(host);
219
+ }
220
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
221
+ export function dumpUrlFor(databaseUrl) {
222
+ return isSupabaseHost(databaseUrl)
223
+ ? forceVerifyFull(databaseUrl, supabaseCaFile())
224
+ : databaseUrl;
225
+ }
226
+ /**
227
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
228
+ *
229
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
230
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
231
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
232
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
233
+ *
234
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
235
+ */
236
+ export function pgConnectOptions(databaseUrl) {
237
+ if (!isSupabaseHost(databaseUrl))
238
+ return { connectionString: databaseUrl };
239
+ return { connectionString: stripSslParams(databaseUrl), ssl: SUPABASE_SSL };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {