@rebasepro/cli 0.15.0 → 0.16.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/dist/index.es.js CHANGED
@@ -2423,7 +2423,7 @@ async function configureEnvFile(targetDirectory, databaseUrl) {
2423
2423
  envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${pinnedUrl}\nDATABASE_PASSWORD=${dbPassword}`);
2424
2424
  } else {
2425
2425
  const dbPort = await findAvailablePort(5432);
2426
- envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=postgresql://rebase_app:${dbPassword}@127.0.0.1:${dbPort}/rebase?options=-c%20search_path=public&sslmode=disable\nDATABASE_PASSWORD=${dbPassword}`);
2426
+ envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=postgresql://rebase_app:${dbPassword}@127.0.0.1:${dbPort}/rebase?options=-c%20search_path%3Dpublic&sslmode=disable\nDATABASE_PASSWORD=${dbPassword}`);
2427
2427
  const dockerComposePath = path.join(targetDirectory, "docker-compose.yml");
2428
2428
  if (fs.existsSync(dockerComposePath)) {
2429
2429
  let dockerComposeContent = fs.readFileSync(dockerComposePath, "utf-8");
@@ -2827,6 +2827,75 @@ ${chalk.green.bold("introspect Options")}
2827
2827
  /**
2828
2828
  * CLI command: rebase db <action>
2829
2829
  */
2830
+ /**
2831
+ * A destination that names a remote store rather than a local path.
2832
+ *
2833
+ * Matched as "scheme://" generally, not as a list of the schemes we support:
2834
+ * an unknown scheme is still not something to join onto a filesystem path, and
2835
+ * a Windows drive letter ("C:\backups") has no "//" so it stays a path.
2836
+ */
2837
+ var REMOTE_DESTINATION_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
2838
+ /**
2839
+ * Rewrite local path arguments so they mean what the user typed.
2840
+ *
2841
+ * The plugin CLI is spawned with `cwd: backendDir` — it has to be, because that
2842
+ * is where the plugin and its dependencies resolve from. But the developer runs
2843
+ * `rebase db` from the project root, so a relative `--out ./backups` was being
2844
+ * resolved against `backend/` and landed in `backend/backups`, while the
2845
+ * success line echoed the path as typed. The file was real and the reported
2846
+ * location was wrong, which is the worst way for a backup command to behave.
2847
+ * `rebase db --help` documents exactly this invocation.
2848
+ *
2849
+ * Absolutising here rather than inside the plugin keeps the fix where the cwd
2850
+ * is actually changed, and leaves the plugin usable on its own terms.
2851
+ */
2852
+ function absolutizeLocalPathArgs(args, cwd) {
2853
+ const takesPath = (flag) => flag === "--out" || flag === "-o";
2854
+ /**
2855
+ * Flags whose next token is a value, not a positional.
2856
+ *
2857
+ * Mirrors the `arg` specs in `@rebasepro/server-postgres`'s backup CLI.
2858
+ * Needed only to find the `db restore` dump argument: without it,
2859
+ * `restore --target-db app_restored ./x.dump` treats `app_restored` as the
2860
+ * positional — it is the first token not starting with `-` — and turns a
2861
+ * database name into a path while leaving the real dump path unresolved.
2862
+ */
2863
+ const VALUE_FLAGS = /* @__PURE__ */ new Set([
2864
+ "--out",
2865
+ "-o",
2866
+ "--target-db",
2867
+ "--exclude-schema",
2868
+ "--row-security-role"
2869
+ ]);
2870
+ const out = [...args];
2871
+ for (let i = 0; i < out.length; i++) {
2872
+ const arg = out[i];
2873
+ const eq = arg.indexOf("=");
2874
+ if (eq > 0 && takesPath(arg.slice(0, eq))) {
2875
+ const value = arg.slice(eq + 1);
2876
+ if (value && !REMOTE_DESTINATION_RE.test(value)) out[i] = `${arg.slice(0, eq)}=${path.resolve(cwd, value)}`;
2877
+ continue;
2878
+ }
2879
+ if (takesPath(arg)) {
2880
+ const value = out[i + 1];
2881
+ if (value && !value.startsWith("-") && !REMOTE_DESTINATION_RE.test(value)) {
2882
+ out[i + 1] = path.resolve(cwd, value);
2883
+ i++;
2884
+ }
2885
+ }
2886
+ }
2887
+ const restoreAt = out.indexOf("restore");
2888
+ if (restoreAt !== -1) for (let i = restoreAt + 1; i < out.length; i++) {
2889
+ const arg = out[i];
2890
+ if (arg.startsWith("-")) {
2891
+ if (!arg.includes("=") && VALUE_FLAGS.has(arg)) i++;
2892
+ continue;
2893
+ }
2894
+ if (!REMOTE_DESTINATION_RE.test(arg)) out[i] = path.resolve(cwd, arg);
2895
+ break;
2896
+ }
2897
+ return out;
2898
+ }
2830
2899
  async function dbCommand(subcommand, rawArgs) {
2831
2900
  if (!subcommand || subcommand === "--help") {
2832
2901
  printDbHelp$1();
@@ -2849,6 +2918,7 @@ async function dbCommand(subcommand, rawArgs) {
2849
2918
  const envFile = findEnvFile(projectRoot);
2850
2919
  const env = { ...process.env };
2851
2920
  if (envFile) env.DOTENV_CONFIG_PATH = envFile;
2921
+ const childArgs = absolutizeLocalPathArgs(rawArgs.slice(2), process.cwd());
2852
2922
  try {
2853
2923
  if (pluginCli.endsWith(".ts")) {
2854
2924
  const tsxBin = resolveTsx(projectRoot);
@@ -2856,12 +2926,12 @@ async function dbCommand(subcommand, rawArgs) {
2856
2926
  console.error(chalk.red("✗ Could not find tsx binary."));
2857
2927
  process.exit(1);
2858
2928
  }
2859
- await execa(tsxBin, [pluginCli, ...rawArgs.slice(2)], {
2929
+ await execa(tsxBin, [pluginCli, ...childArgs], {
2860
2930
  cwd: backendDir,
2861
2931
  stdio: "inherit",
2862
2932
  env
2863
2933
  });
2864
- } else await execa("node", [pluginCli, ...rawArgs.slice(2)], {
2934
+ } else await execa("node", [pluginCli, ...childArgs], {
2865
2935
  cwd: backendDir,
2866
2936
  stdio: "inherit",
2867
2937
  env
@@ -4963,10 +5033,165 @@ async function buildBundle(options) {
4963
5033
  type: "module",
4964
5034
  dependencies: declared
4965
5035
  }, null, 2)}\n`, "utf8");
5036
+ const vendor = vendorDependencies({
5037
+ outDir,
5038
+ declared,
5039
+ nativeModules,
5040
+ required: [getActiveBackendPlugin(path.join(projectRoot, "backend"))].filter((name) => Boolean(name)),
5041
+ requested: options.vendor
5042
+ });
5043
+ if (vendor.vendored) {
5044
+ manifest.deps.vendored = true;
5045
+ manifest.deps.vendorTarget = vendor.target;
5046
+ fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
5047
+ }
4966
5048
  return {
4967
5049
  outDir,
4968
5050
  manifest,
4969
- collectionCount: collections.length
5051
+ collectionCount: collections.length,
5052
+ vendor
5053
+ };
5054
+ }
5055
+ /** Default install target: what the published runtime image runs. */
5056
+ var VENDOR_TARGET_OS = "linux";
5057
+ var VENDOR_TARGET_CPU = "x64";
5058
+ /**
5059
+ * Where a vendored bundle starts being too big to upload.
5060
+ *
5061
+ * The control plane refuses a bundle over 100 MB, and that ceiling is not
5062
+ * arbitrary or easily raised: its pod has a 512Mi memory limit and the upload
5063
+ * route holds the body while it writes it, so the cap protects the process that
5064
+ * also serves the console, deploys and billing. Vendoring is the one change that
5065
+ * can push a bundle near it.
5066
+ *
5067
+ * So the warning is here, at build time, where the remedy is one flag away —
5068
+ * rather than at deploy time as a 413 nobody can act on without rebuilding. The
5069
+ * threshold sits below the real cap because this measures the tree on disk and
5070
+ * the upload is compressed: crossing it means "getting close", not "will fail".
5071
+ */
5072
+ var VENDOR_SIZE_WARN_BYTES = 150 * 1024 * 1024;
5073
+ /** Bytes on disk under `dir`. Bounded so a pathological tree cannot hang a build. */
5074
+ function directorySize(dir, budget = 2e5) {
5075
+ let total = 0;
5076
+ let visited = 0;
5077
+ const stack = [dir];
5078
+ while (stack.length > 0 && visited < budget) {
5079
+ const current = stack.pop();
5080
+ let entries;
5081
+ try {
5082
+ entries = fs.readdirSync(current, { withFileTypes: true });
5083
+ } catch {
5084
+ continue;
5085
+ }
5086
+ for (const entry of entries) {
5087
+ visited++;
5088
+ const full = path.join(current, entry.name);
5089
+ if (entry.isDirectory()) stack.push(full);
5090
+ else if (entry.isFile()) try {
5091
+ total += fs.statSync(full).size;
5092
+ } catch {}
5093
+ }
5094
+ }
5095
+ return total;
5096
+ }
5097
+ /**
5098
+ * Install the bundle's declared dependencies into the bundle itself.
5099
+ *
5100
+ * ## What this buys
5101
+ *
5102
+ * A managed pod's bundle lives on an `emptyDir`, so it is re-fetched and
5103
+ * re-installed on **every** start — an eviction, a node failure, an OOM, a
5104
+ * runtime rollout. The install is 35–55 seconds of a 40–60 second cold start,
5105
+ * which makes it the price of every unplanned restart a tenant suffers. Doing it
5106
+ * once at build time instead of every time at boot takes that to roughly the
5107
+ * cost of untarring.
5108
+ *
5109
+ * The pod side needs no change to benefit: the init container already skips
5110
+ * installing when `node_modules` is present, a guard that existed for
5111
+ * pre-baked images and turns out to be exactly the hook this needs.
5112
+ *
5113
+ * ## Why it refuses to vendor native code
5114
+ *
5115
+ * A compiled binary is valid only for the platform it was built for, and a
5116
+ * developer's machine is rarely the deployment's. The managed runtime already
5117
+ * refuses bundles containing native modules for the same reason, so this refusal
5118
+ * costs nothing there — but a self-hosted project may legitimately use them, and
5119
+ * for those the honest answer is to install in the container, where the platform
5120
+ * is known.
5121
+ *
5122
+ * ## Why `--os` and `--cpu` are not optional
5123
+ *
5124
+ * The dangerous case is not native code, which is detectable. It is a pure-JS
5125
+ * package whose real work lives in a **platform-specific optional dependency** —
5126
+ * `esbuild` being the one everybody meets. Installing on an Apple Silicon Mac
5127
+ * resolves `@esbuild/darwin-arm64`, produces a tree that looks complete, and
5128
+ * fails at import inside a linux/amd64 pod. npm resolves optional dependencies
5129
+ * for the declared target rather than the host when told to, so it is told to.
5130
+ */
5131
+ function vendorDependencies(options) {
5132
+ if (options.requested === false) return {
5133
+ vendored: false,
5134
+ skipped: "disabled with --no-vendor"
5135
+ };
5136
+ if (Object.keys(options.declared).length === 0) return {
5137
+ vendored: false,
5138
+ skipped: "the bundle declares no dependencies"
5139
+ };
5140
+ if (options.nativeModules.length > 0) return {
5141
+ vendored: false,
5142
+ skipped: `the dependency closure contains native code (${options.nativeModules.map((m) => m.name).join(", ")}), which is only valid on the platform it was compiled for`
5143
+ };
5144
+ const target = {
5145
+ os: VENDOR_TARGET_OS,
5146
+ cpu: "x64",
5147
+ node: process.versions.node.split(".")[0]
5148
+ };
5149
+ const run = options.run ?? ((cmd, args, cwd) => {
5150
+ const result = spawnSync(cmd, args, {
5151
+ cwd,
5152
+ stdio: "pipe",
5153
+ encoding: "utf8"
5154
+ });
5155
+ if (result.error) throw result.error;
5156
+ if (result.status !== 0) throw new Error(`${cmd} ${args.join(" ")} exited ${result.status}\n${result.stderr || result.stdout || ""}`.trim());
5157
+ });
5158
+ try {
5159
+ run("npm", [
5160
+ "install",
5161
+ "--omit=dev",
5162
+ "--ignore-scripts",
5163
+ "--no-audit",
5164
+ "--no-fund",
5165
+ `--os=${target.os}`,
5166
+ `--cpu=${target.cpu}`
5167
+ ], options.outDir);
5168
+ } catch (error) {
5169
+ return {
5170
+ vendored: false,
5171
+ skipped: `npm install failed: ${error instanceof Error ? error.message : String(error)}`
5172
+ };
5173
+ }
5174
+ const installed = path.join(options.outDir, "node_modules");
5175
+ if (!fs.existsSync(installed)) return {
5176
+ vendored: false,
5177
+ skipped: "npm install produced no node_modules"
5178
+ };
5179
+ const missing = (options.required ?? []).filter((name) => !fs.existsSync(path.join(installed, ...name.split("/"), "package.json")));
5180
+ if (missing.length > 0) {
5181
+ fs.rmSync(installed, {
5182
+ recursive: true,
5183
+ force: true
5184
+ });
5185
+ fs.rmSync(path.join(options.outDir, "package-lock.json"), { force: true });
5186
+ return {
5187
+ vendored: false,
5188
+ skipped: `the installed tree is missing ${missing.join(", ")}, which the runtime resolves from the bundle — the bundle declares it at a version no registry can serve (a workspace link), so nothing was vendored rather than shipping a tree that boots without a driver`
5189
+ };
5190
+ }
5191
+ return {
5192
+ vendored: true,
5193
+ target,
5194
+ bytes: directorySize(installed)
4970
5195
  };
4971
5196
  }
4972
5197
  /**
@@ -5374,6 +5599,8 @@ ${chalk.bold("Options")}
5374
5599
  --out <dir> Bundle output directory (default: ${DEFAULT_BUNDLE_DIR})
5375
5600
  --skip-type-check Compile without type checking (faster; use for iteration only)
5376
5601
  --skip-schema Do not regenerate the database schema from collections
5602
+ --no-vendor Do not install dependencies into the bundle (they
5603
+ install on every pod start instead, ~40-60s slower)
5377
5604
  --legacy Run every workspace's own build script instead
5378
5605
  -h, --help Show this help
5379
5606
 
@@ -5393,6 +5620,7 @@ async function buildCommand(rawArgs = []) {
5393
5620
  "--output": String,
5394
5621
  "--out": "--output",
5395
5622
  "--skip-type-check": Boolean,
5623
+ "--no-vendor": Boolean,
5396
5624
  "--skip-schema": Boolean,
5397
5625
  "--no-static": Boolean,
5398
5626
  "--skip-static-build": Boolean,
@@ -5457,11 +5685,24 @@ async function buildCommand(rawArgs = []) {
5457
5685
  runtimeRange: manifest.rebase,
5458
5686
  storage: manifest.storage,
5459
5687
  skipTypeCheck: args["--skip-type-check"],
5460
- skipSchema: args["--skip-schema"]
5688
+ skipSchema: args["--skip-schema"],
5689
+ vendor: args["--no-vendor"] ? false : void 0
5461
5690
  });
5462
5691
  const rel = path.relative(projectRoot, result.outDir);
5463
5692
  console.log(chalk.green(` ✓ bundle → ${rel}/`));
5464
5693
  console.log(chalk.dim(` ${result.collectionCount} collection(s), schema ${result.manifest.schemaVersion}`));
5694
+ if (result.vendor.vendored) {
5695
+ console.log(chalk.dim(` dependencies installed into the bundle (${result.vendor.target?.os}/${result.vendor.target?.cpu})`));
5696
+ if ((result.vendor.bytes ?? 0) > 157286400) {
5697
+ const mb = Math.round((result.vendor.bytes ?? 0) / (1024 * 1024));
5698
+ console.log(chalk.yellow(` ⚠ the installed tree is ${mb} MB — close to the 100 MB upload limit`));
5699
+ console.log(chalk.dim(" Rebuild with --no-vendor if the deploy is rejected as too large."));
5700
+ }
5701
+ } else if (Object.keys(result.manifest.deps.declared).length === 0) console.log(chalk.dim(" no dependencies to install — nothing to bundle"));
5702
+ else {
5703
+ console.log(chalk.yellow(` ⚠ dependencies not bundled: ${result.vendor.skipped}`));
5704
+ console.log(chalk.dim(" They install on every pod start instead, which is ~40-60s of cold start."));
5705
+ }
5465
5706
  if (result.manifest.hooks.native) {
5466
5707
  const names = (result.manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
5467
5708
  console.log(chalk.yellow(` ⚠ native dependencies detected: ${names}`));
@@ -6312,6 +6553,92 @@ ${chalk.green.bold("Examples")}
6312
6553
  `);
6313
6554
  }
6314
6555
  //#endregion
6556
+ //#region src/utils/libpq-url.ts
6557
+ /** Postgres URI schemes. A key/value DSN ("host=… dbname=…") is not one. */
6558
+ var POSTGRES_URI_RE = /^postgres(ql)?:\/\//i;
6559
+ /**
6560
+ * Return the query parameters libpq would reject, or an empty array.
6561
+ *
6562
+ * Deliberately not built on `new URL()`: that parser is happy to accept the
6563
+ * broken form (it splits on the first `=` and keeps the rest as the value),
6564
+ * so it cannot see the defect at all. The rule being checked is libpq's, and
6565
+ * it is about the raw text.
6566
+ */
6567
+ function findUnparseableParams(connectionString) {
6568
+ if (!POSTGRES_URI_RE.test(connectionString)) return [];
6569
+ const q = connectionString.indexOf("?");
6570
+ if (q === -1) return [];
6571
+ const query = connectionString.slice(q + 1).split("#")[0];
6572
+ if (!query) return [];
6573
+ const found = [];
6574
+ for (const part of query.split("&")) {
6575
+ if (!part) continue;
6576
+ if (part.split("=").length > 2) found.push({
6577
+ name: part.slice(0, part.indexOf("=")),
6578
+ raw: part
6579
+ });
6580
+ }
6581
+ return found;
6582
+ }
6583
+ /**
6584
+ * Percent-encode the offending `=` characters, leaving everything else byte for
6585
+ * byte as it was.
6586
+ *
6587
+ * Only the second and later `=` in a parameter are rewritten — the first is the
6588
+ * real separator. Nothing else is touched: re-serialising through `URL` would
6589
+ * also reorder parameters and turn spaces into `+`, which libpq does not decode,
6590
+ * so a "tidy up" would trade one unparseable string for another.
6591
+ */
6592
+ function encodeExtraEquals(connectionString) {
6593
+ if (findUnparseableParams(connectionString).length === 0) return connectionString;
6594
+ const q = connectionString.indexOf("?");
6595
+ const head = connectionString.slice(0, q + 1);
6596
+ const rest = connectionString.slice(q + 1);
6597
+ const hash = rest.indexOf("#");
6598
+ const query = hash === -1 ? rest : rest.slice(0, hash);
6599
+ const fragment = hash === -1 ? "" : rest.slice(hash);
6600
+ return `${head}${query.split("&").map((part) => {
6601
+ const first = part.indexOf("=");
6602
+ if (first === -1) return part;
6603
+ return `${part.slice(0, first)}=${part.slice(first + 1).replace(/=/g, "%3D")}`;
6604
+ }).join("&")}${fragment}`;
6605
+ }
6606
+ /** Variables whose value is a Postgres connection string. */
6607
+ var CONNECTION_VARIABLES = ["DATABASE_URL", "ADMIN_CONNECTION_STRING"];
6608
+ /**
6609
+ * Scan one file's text for connection strings libpq would reject.
6610
+ *
6611
+ * Handles both shapes a scaffolded project uses, because both shipped with the
6612
+ * defect and a deployed stack is broken by the compose one alone:
6613
+ *
6614
+ * .env DATABASE_URL=postgresql://…
6615
+ * docker-compose.yml DATABASE_URL: postgresql://…
6616
+ */
6617
+ function scanTextForLibpqUrls(file, text) {
6618
+ const findings = [];
6619
+ for (const line of text.split("\n")) {
6620
+ const trimmed = line.trim();
6621
+ if (trimmed.startsWith("#")) continue;
6622
+ for (const variable of CONNECTION_VARIABLES) {
6623
+ const match = trimmed.match(new RegExp(`^(?:export\\s+)?${variable}\\s*[:=]\\s*(.+)$`));
6624
+ if (!match) continue;
6625
+ let value = match[1].trim();
6626
+ const quoted = value.match(/^(['"])(.*)\1$/);
6627
+ if (quoted) value = quoted[2];
6628
+ if (!value) continue;
6629
+ const params = findUnparseableParams(value);
6630
+ if (params.length === 0) continue;
6631
+ findings.push({
6632
+ file,
6633
+ variable,
6634
+ params: params.map((p) => p.name),
6635
+ suggested: encodeExtraEquals(value)
6636
+ });
6637
+ }
6638
+ }
6639
+ return findings;
6640
+ }
6641
+ //#endregion
6315
6642
  //#region src/commands/doctor.ts
6316
6643
  /**
6317
6644
  * CLI command: rebase doctor
@@ -6320,6 +6647,76 @@ ${chalk.green.bold("Examples")}
6320
6647
  * the generated Drizzle schema, and the live PostgreSQL database.
6321
6648
  */
6322
6649
  /**
6650
+ * Files that can hold a connection string the project actually runs on.
6651
+ *
6652
+ * `.env` is what local commands read; the compose files are what a self-hosted
6653
+ * stack and its scheduled backup cron read. Both shipped with the defect, and a
6654
+ * deployed stack is broken by the compose one even when `.env` has been fixed.
6655
+ * `.env.example` is deliberately absent: nothing runs on it.
6656
+ */
6657
+ var CONNECTION_FILES = [
6658
+ ".env",
6659
+ ".env.local",
6660
+ "docker-compose.yml",
6661
+ "docker-compose.yaml",
6662
+ "docker-compose.custom.yml"
6663
+ ];
6664
+ /**
6665
+ * Find connection strings libpq cannot parse, anywhere in the project.
6666
+ *
6667
+ * Exported for the tests; `envFile` is passed separately because a project may
6668
+ * keep its `.env` outside the root (see `findEnvFile`).
6669
+ */
6670
+ function findLibpqUrlProblems(projectRoot, envFile) {
6671
+ const candidates = CONNECTION_FILES.map((f) => path.join(projectRoot, f));
6672
+ if (envFile) candidates.push(envFile);
6673
+ const findings = [];
6674
+ const seen = /* @__PURE__ */ new Set();
6675
+ for (const file of candidates) {
6676
+ if (seen.has(file)) continue;
6677
+ seen.add(file);
6678
+ let text;
6679
+ try {
6680
+ if (!fs.existsSync(file)) continue;
6681
+ text = fs.readFileSync(file, "utf-8");
6682
+ } catch {
6683
+ continue;
6684
+ }
6685
+ findings.push(...scanTextForLibpqUrls(path.relative(projectRoot, file) || path.basename(file), text));
6686
+ }
6687
+ return findings;
6688
+ }
6689
+ /**
6690
+ * Report unparseable connection strings, if any.
6691
+ *
6692
+ * Runs before the plugin's drift check and never blocks it. The plugin connects
6693
+ * through node-postgres, which parses these URLs happily — so it cannot see this
6694
+ * defect, and a project with it will otherwise get a clean bill of health while
6695
+ * `rebase db backup` fails.
6696
+ */
6697
+ function reportLibpqUrlProblems(findings) {
6698
+ if (findings.length === 0) return;
6699
+ console.log("");
6700
+ console.log(chalk.red.bold(" ✗ Connection string that PostgreSQL's own tools cannot parse"));
6701
+ console.log("");
6702
+ for (const f of findings) {
6703
+ console.log(` ${chalk.bold(f.file)} → ${chalk.bold(f.variable)}`);
6704
+ console.log(chalk.gray(` the "${f.params.join("\", \"")}" parameter contains an unencoded "=".`));
6705
+ console.log(chalk.gray(" Replace the value with:"));
6706
+ console.log(` ${chalk.cyan(f.suggested)}`);
6707
+ console.log("");
6708
+ }
6709
+ console.log(chalk.gray(" libpq splits a query parameter on the first \"=\" and rejects any further"));
6710
+ console.log(chalk.gray(" one, so this fails:"));
6711
+ console.log(chalk.gray(" extra key/value separator \"=\" in URI query parameter"));
6712
+ console.log("");
6713
+ console.log(chalk.gray(" Affects rebase db backup / restore, scheduled backups, and psql."));
6714
+ console.log(chalk.gray(" NOT rebase dev or db push — those use a driver that accepts it, which"));
6715
+ console.log(chalk.gray(" is why a project can look healthy and still have no working backups."));
6716
+ console.log(chalk.gray(" Projects scaffolded before 2026-08-18 all carry it."));
6717
+ console.log("");
6718
+ }
6719
+ /**
6323
6720
  * `--help` is answered before the project guard, not after.
6324
6721
  *
6325
6722
  * `doctor` declared no `--help` at all, so the flag fell through to the command
@@ -6330,7 +6727,7 @@ ${chalk.green.bold("Examples")}
6330
6727
  */
6331
6728
  function printDoctorHelp() {
6332
6729
  console.log(`
6333
- ${chalk.bold("rebase doctor")} — Detect drift between collections, schema and database
6730
+ ${chalk.bold("rebase doctor")} — Check a project for drift and misconfiguration
6334
6731
 
6335
6732
  ${chalk.green.bold("Usage")}
6336
6733
  rebase doctor
@@ -6338,6 +6735,10 @@ ${chalk.green.bold("Usage")}
6338
6735
  Compares the collections you declare, the generated Drizzle schema, and the
6339
6736
  tables that actually exist, then reports what disagrees and how to reconcile it.
6340
6737
 
6738
+ Also checks the connection strings in .env and the compose files for the
6739
+ unencoded "=" that makes PostgreSQL's own tools refuse to parse them — which
6740
+ breaks backups and psql while leaving the app itself working.
6741
+
6341
6742
  Run from inside a Rebase project — it reads the project's collections and
6342
6743
  connects to its database.
6343
6744
  `);
@@ -6363,6 +6764,7 @@ async function doctorCommand(rawArgs) {
6363
6764
  const envFile = findEnvFile(projectRoot);
6364
6765
  const env = { ...process.env };
6365
6766
  if (envFile) env.DOTENV_CONFIG_PATH = envFile;
6767
+ reportLibpqUrlProblems(findLibpqUrlProblems(projectRoot, envFile));
6366
6768
  try {
6367
6769
  if (pluginCli.endsWith(".ts")) {
6368
6770
  const tsxBin = resolveTsx(projectRoot);
@@ -11521,7 +11923,115 @@ async function storageAttachCommand(rawArgs) {
11521
11923
  reportError(e, "Failed to attach storage");
11522
11924
  }
11523
11925
  }
11524
- async function clustersCommand(rawArgs) {
11926
+ /**
11927
+ * `rebase cloud clusters` — list, register and verify the clusters tenants run on.
11928
+ *
11929
+ * Registration is deliberately an operator action: the `clusters` collection is
11930
+ * admin-only, and a cluster record carries a credential with enough power to
11931
+ * create namespaces and read every secret in them. A self-serve "bring your own
11932
+ * cluster" flow is a different feature with a different threat model.
11933
+ */
11934
+ async function clustersCommand(action, rawArgs) {
11935
+ if (action === "verify") return clustersVerifyCommand(rawArgs);
11936
+ if (action === "add") return clustersAddCommand(rawArgs);
11937
+ return clustersListCommand(rawArgs);
11938
+ }
11939
+ /**
11940
+ * Ask a registered cluster whether it can actually host a tenant.
11941
+ *
11942
+ * The question this exists to answer early is the one that otherwise gets
11943
+ * answered by a customer's first deploy failing halfway through provisioning,
11944
+ * with an error they cannot act on and half a tenant already created.
11945
+ */
11946
+ async function clustersVerifyCommand(rawArgs) {
11947
+ const { client } = await requireClient(rawArgs);
11948
+ const id = rawArgs.find((a) => !a.startsWith("--") && a !== "clusters" && a !== "verify");
11949
+ if (!id) fail("Usage: rebase cloud clusters verify <cluster-id> [--baseline]", void 0, "bad_request");
11950
+ const withBaseline = rawArgs.includes("--baseline");
11951
+ let report;
11952
+ try {
11953
+ report = await client.functions.invoke("cluster-baseline", void 0, {
11954
+ method: "GET",
11955
+ path: `verify/${id}${withBaseline ? "?baseline=1" : ""}`
11956
+ });
11957
+ } catch (error) {
11958
+ reportError(error, "Could not verify the cluster");
11959
+ return;
11960
+ }
11961
+ emit(() => {
11962
+ console.log("");
11963
+ const tone = report.verdict === "ready" ? chalk.green : report.verdict === "degraded" ? chalk.yellow : chalk.red;
11964
+ console.log(` ${tone(chalk.bold(report.verdict.toUpperCase()))} ${chalk.gray(String(id))}`);
11965
+ console.log("");
11966
+ keyValues([["Reachable", report.reachable ? "yes" : chalk.red("no")], ["Permissions", `${report.permissions.allowed.length} allowed, ${report.permissions.denied.length} denied`]]);
11967
+ if (report.blockers.length > 0) {
11968
+ console.log("");
11969
+ for (const b of report.blockers) console.log(` ${chalk.red("✗")} ${b}`);
11970
+ }
11971
+ console.log("");
11972
+ if (!withBaseline && report.verdict !== "unusable") {
11973
+ note("Add --baseline to also check ingress-nginx, cert-manager and CloudNativePG.");
11974
+ console.log("");
11975
+ }
11976
+ }, () => report);
11977
+ if (report.verdict === "unusable") process.exitCode = 1;
11978
+ }
11979
+ /**
11980
+ * Register a cluster from a kubeconfig file.
11981
+ *
11982
+ * Verifies immediately rather than reporting a successful insert: a row that
11983
+ * names an unreachable cluster is worse than no row, because a project pointed
11984
+ * at it fails at deploy instead of at registration.
11985
+ */
11986
+ async function clustersAddCommand(rawArgs) {
11987
+ const { client } = await requireClient(rawArgs);
11988
+ const flag = (name) => {
11989
+ const i = rawArgs.indexOf(name);
11990
+ return i === -1 ? void 0 : rawArgs[i + 1];
11991
+ };
11992
+ const name = flag("--name");
11993
+ const provider = flag("--provider");
11994
+ const region = flag("--region");
11995
+ const kubeconfigPath = flag("--kubeconfig");
11996
+ if (!name || !provider || !region || !kubeconfigPath) fail("Usage: rebase cloud clusters add --name <n> --provider <gcp|aws|hetzner> --region <r> --kubeconfig <path>", void 0, "bad_request");
11997
+ if (![
11998
+ "gcp",
11999
+ "aws",
12000
+ "hetzner"
12001
+ ].includes(provider)) fail(`--provider must be gcp, aws or hetzner (got "${provider}")`, void 0, "bad_request");
12002
+ let kubeConfigData;
12003
+ try {
12004
+ kubeConfigData = fs.readFileSync(kubeconfigPath, "utf8");
12005
+ } catch {
12006
+ fail(`Could not read ${kubeconfigPath}`, void 0, "bad_request");
12007
+ return;
12008
+ }
12009
+ let created;
12010
+ try {
12011
+ created = await client.data.collection("clusters").create({
12012
+ name,
12013
+ provider,
12014
+ region,
12015
+ authType: "kubeconfig",
12016
+ kubeConfigData
12017
+ });
12018
+ } catch (error) {
12019
+ reportError(error, "Could not register the cluster");
12020
+ return;
12021
+ }
12022
+ emit(() => {
12023
+ success(`Registered ${name} (${created.id}).`);
12024
+ noteBlank();
12025
+ note("Verifying it can host tenants:");
12026
+ note(chalk.cyan(` rebase cloud clusters verify ${created.id} --baseline`));
12027
+ }, () => ({
12028
+ id: created.id,
12029
+ name,
12030
+ provider,
12031
+ region
12032
+ }));
12033
+ }
12034
+ async function clustersListCommand(rawArgs) {
11525
12035
  const { client } = await requireClient(rawArgs);
11526
12036
  try {
11527
12037
  const clusters = (await client.data.collection("clusters").find({ limit: 100 })).data;
@@ -11677,6 +12187,122 @@ async function billingCommand(rawArgs) {
11677
12187
  reportError(e, "Failed to load billing");
11678
12188
  }
11679
12189
  }
12190
+ /** The dials, and the flag that sets each. */
12191
+ var DIAL_FLAGS = {
12192
+ "--cpu": "cpu",
12193
+ "--memory": "memory",
12194
+ "--db-mode": "databaseMode",
12195
+ "--db-instances": "databaseInstances",
12196
+ "--db-cpu": "databaseCpu",
12197
+ "--db-memory": "databaseMemory"
12198
+ };
12199
+ /**
12200
+ * `rebase cloud resources` — show what a project is given, and change it.
12201
+ *
12202
+ * ## Why nothing is validated here
12203
+ *
12204
+ * The rules are the *target cluster's*, not the CLI's: GKE Autopilot bills a
12205
+ * 250m/512Mi floor and rewrites anything outside a 1:1–6.5:1 memory:CPU band,
12206
+ * while a Hetzner or EKS node has neither constraint. A CLI that carried those
12207
+ * numbers would be wrong for two of the three providers the moment it shipped,
12208
+ * and would drift from the control plane the first time either changed.
12209
+ *
12210
+ * So the control plane validates and this reports what it said. The same
12211
+ * boundary refuses a raw PATCH and a console save, which is the property worth
12212
+ * having — a check in a client only covers the clients that run it.
12213
+ */
12214
+ async function resourcesCommand(action, rawArgs) {
12215
+ const { client } = await requireClient(rawArgs);
12216
+ const projectId = await requireProject(rawArgs, client);
12217
+ const project = await client.data.collection("projects").findById(projectId);
12218
+ if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found.`, void 0, "not_found");
12219
+ if (action !== "set") {
12220
+ emit(() => {
12221
+ console.log("");
12222
+ console.log(` ${chalk.bold(String(project.name ?? ""))} ${chalk.gray(`[${String(project.subdomain ?? "")}]`)}`);
12223
+ console.log("");
12224
+ keyValues([
12225
+ ["Plan", String(project.plan ?? "legacy (no plan)")],
12226
+ ["App CPU", dialLine(project.cpu)],
12227
+ ["App memory", dialLine(project.memory)],
12228
+ ["App replicas", String(project.replicaCount ?? 1)],
12229
+ ["Database", dialLine(project.databaseMode)],
12230
+ ["Database instances", dialLine(project.databaseInstances)],
12231
+ ["Database CPU", dialLine(project.databaseCpu)],
12232
+ ["Database memory", dialLine(project.databaseMemory)]
12233
+ ]);
12234
+ console.log("");
12235
+ noteBlank();
12236
+ note("Empty means the plan's default. Change one with:");
12237
+ note(chalk.cyan(" rebase cloud resources set --cpu 500m --memory 2Gi"));
12238
+ console.log("");
12239
+ }, () => ({
12240
+ plan: project.plan ?? null,
12241
+ cpu: project.cpu ?? null,
12242
+ memory: project.memory ?? null,
12243
+ replicaCount: project.replicaCount ?? 1,
12244
+ databaseMode: project.databaseMode ?? null,
12245
+ databaseInstances: project.databaseInstances ?? null,
12246
+ databaseCpu: project.databaseCpu ?? null,
12247
+ databaseMemory: project.databaseMemory ?? null
12248
+ }));
12249
+ return;
12250
+ }
12251
+ const built = buildDialPatch(rawArgs);
12252
+ if (built.error) fail(built.error, void 0, "bad_request");
12253
+ const patch = built.patch;
12254
+ try {
12255
+ await client.data.collection("projects").update(projectId, patch);
12256
+ } catch (error) {
12257
+ reportError(error, "Could not change resources");
12258
+ return;
12259
+ }
12260
+ emit(() => {
12261
+ success("Resources updated.");
12262
+ noteBlank();
12263
+ for (const [field, value] of Object.entries(patch)) note(` ${field} → ${String(value)}`);
12264
+ noteBlank();
12265
+ note("Applied on the next deploy, or by the hourly reconcile — whichever is first.");
12266
+ note("A change that restarts the database waits for a maintenance window.");
12267
+ }, () => ({ updated: patch }));
12268
+ }
12269
+ /** A dial's value, or a marker that the plan decides it. */
12270
+ function dialLine(value) {
12271
+ if (value === null || value === void 0 || value === "") return chalk.gray("plan default");
12272
+ return String(value);
12273
+ }
12274
+ /**
12275
+ * Turn `--cpu 500m --db-instances 2` into the patch to send.
12276
+ *
12277
+ * Pure, and exported, so the flag handling is testable without a control plane —
12278
+ * the same shape `buildSettingsPatch` uses. Returns an error string rather than
12279
+ * throwing, because the caller owns how a refusal is printed in JSON mode.
12280
+ */
12281
+ function buildDialPatch(rawArgs) {
12282
+ const patch = {};
12283
+ for (const [flag, field] of Object.entries(DIAL_FLAGS)) {
12284
+ const idx = rawArgs.indexOf(flag);
12285
+ if (idx === -1) continue;
12286
+ const value = rawArgs[idx + 1];
12287
+ if (value === void 0 || value.startsWith("--")) return {
12288
+ patch: {},
12289
+ error: `${flag} needs a value.`
12290
+ };
12291
+ if (field === "databaseInstances") {
12292
+ const n = Number(value);
12293
+ if (!Number.isInteger(n)) return {
12294
+ patch: {},
12295
+ error: `${flag} takes a whole number of instances, not "${value}".`
12296
+ };
12297
+ patch[field] = n;
12298
+ } else patch[field] = value;
12299
+ }
12300
+ if (Object.keys(patch).length === 0) return {
12301
+ patch: {},
12302
+ error: `Nothing to set. Pass one of: ${Object.keys(DIAL_FLAGS).join(", ")}.`
12303
+ };
12304
+ return { patch };
12305
+ }
11680
12306
  //#endregion
11681
12307
  //#region src/commands/cloud/index.ts
11682
12308
  /**
@@ -11839,8 +12465,11 @@ async function cloudCommand(subcommand, rawArgs) {
11839
12465
  case "storage":
11840
12466
  await storageCommand(action, rawArgs);
11841
12467
  break;
12468
+ case "resources":
12469
+ await resourcesCommand(action, rawArgs);
12470
+ break;
11842
12471
  case "clusters":
11843
- await clustersCommand(rawArgs);
12472
+ await clustersCommand(action, rawArgs);
11844
12473
  break;
11845
12474
  case "billing":
11846
12475
  await billingCommand(rawArgs);
@@ -11972,6 +12601,11 @@ ${chalk.green.bold("Databases")}
11972
12601
 
11973
12602
  ${chalk.green.bold("Other resources")}
11974
12603
  ${chalk.blue.bold("webhooks list|create|delete")}
12604
+ ${chalk.blue.bold("clusters")} List the clusters tenants run on
12605
+ ${chalk.blue.bold("clusters add")} Register a cluster from a kubeconfig
12606
+ ${chalk.blue.bold("clusters verify")} Ask a cluster whether it can host tenants
12607
+ ${chalk.blue.bold("resources")} Show the CPU, memory and database this project is given
12608
+ ${chalk.blue.bold("resources set")} Change them (--cpu, --memory, --db-mode, --db-instances, --db-cpu, --db-memory)
11975
12609
  ${chalk.blue.bold("storage")} List storage buckets
11976
12610
  ${chalk.blue.bold("storage create")} Provision platform-managed storage
11977
12611
  ${chalk.blue.bold("storage attach")} Attach your own S3-compatible bucket
@@ -12400,6 +13034,6 @@ function telemetryNotice() {
12400
13034
  return chalk.gray(`Usage sharing: ${sharing ? "on" : "off"} — ${chalk.cyan("rebase telemetry")} to inspect or change\n`);
12401
13035
  }
12402
13036
  //#endregion
12403
- export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, DEV_FLAGS, DEV_PORT_FILENAME, INIT_FLAGS, MANIFEST_FILENAME, ManifestError, RESET_PASSWORD_FLAGS, TEMPLATE_PLACEHOLDER_FILES, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectFrameworkDepDrift, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, ejectCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, getProjectPort, isIdentifierLike, isPnpmAvailable, isPortAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, positionals, printInitHelp, readEnvFile, renderPayload, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveCliVersion, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveResetPasswordArgs, resolveRuntimeImageTag, resolveStartPort, resolveTsx, schemaCommand, selectUserForEmail, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
13037
+ export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, DEV_FLAGS, DEV_PORT_FILENAME, INIT_FLAGS, MANIFEST_FILENAME, ManifestError, RESET_PASSWORD_FLAGS, TEMPLATE_PLACEHOLDER_FILES, VENDOR_SIZE_WARN_BYTES, VENDOR_TARGET_CPU, VENDOR_TARGET_OS, absolutizeLocalPathArgs, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectFrameworkDepDrift, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, ejectCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findLibpqUrlProblems, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, getProjectPort, isIdentifierLike, isPnpmAvailable, isPortAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, positionals, printInitHelp, readEnvFile, renderPayload, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveCliVersion, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveResetPasswordArgs, resolveRuntimeImageTag, resolveStartPort, resolveTsx, schemaCommand, selectUserForEmail, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, vendorDependencies, writeManifest };
12404
13038
 
12405
13039
  //# sourceMappingURL=index.es.js.map