@rebasepro/cli 0.20.1-canary.g4d882ca → 0.21.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
@@ -18,14 +18,15 @@ import { execa, execaCommandSync } from "execa";
18
18
  import { cp } from "fs/promises";
19
19
  import { fileURLToPath, pathToFileURL } from "url";
20
20
  import crypto from "crypto";
21
- import { spawn, spawnSync } from "child_process";
21
+ import { execFileSync, spawn, spawnSync } from "child_process";
22
22
  import os from "os";
23
23
  import { createRebaseClient } from "@rebasepro/client";
24
24
  import dotenv from "dotenv";
25
- import { BUNDLE_FORMAT_VERSION, DEFAULT_DATA_SOURCE_KEY, DEFAULT_RESOURCE_KEY, RUNTIME_CONTRACT_VERSION, buildResourceGraph, computeSchemaVersion, declareFunction, declaredQueueConsumers, declaredResources, declaredSubscriptions, deserializeCollections, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getDataSourceCapabilities, isResourceHandle, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveResourceRefs, resourceEnvSuffix, resourceId, resourceKeyOf, resourceToDataSource } from "@rebasepro/types";
25
+ import { BUNDLE_FORMAT_VERSION, DEFAULT_DATA_SOURCE_KEY, DEFAULT_RESOURCE_KEY, DEFAULT_STORAGE_SOURCE_KEY, RUNTIME_CONTRACT_VERSION, buildResourceGraph, computeSchemaVersion, declareFunction, declaredQueueConsumers, declaredResources, declaredSubscriptions, deserializeCollections, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getDataSourceCapabilities, isResourceHandle, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveResourceRefs, resourceEnvSuffix, resourceId, resourceKeyOf, resourceToDataSource } from "@rebasepro/types";
26
26
  import { CodegenError, generateSDK, toSafeIdentifier } from "@rebasepro/codegen";
27
27
  import { createRequire } from "module";
28
28
  import { randomBytes as randomBytes$1 } from "node:crypto";
29
+ import net$1 from "node:net";
29
30
  //#region src/utils/version.ts
30
31
  /**
31
32
  * This CLI's own version, and the User-Agent built from it.
@@ -337,6 +338,27 @@ function credentialsPath() {
337
338
  return path.join(os.homedir(), ".rebase", "credentials.json");
338
339
  }
339
340
  /** Project-local link file: <project>/.rebase/cloud.json */
341
+ /**
342
+ * The billing account an organization row points at, whichever key it arrived under.
343
+ *
344
+ * REST serves every column under its own property name, so the relation comes
345
+ * back as `billingAccountId`. Both readers in this CLI checked
346
+ * `billing_account_id` and `billingAccount` — neither is what arrives — so
347
+ * `rebase cloud billing` answered "no billing account" for every organization,
348
+ * and the deploy pre-check never saw an internal plan. The other two spellings
349
+ * are still accepted: older servers and fixtures send them.
350
+ */
351
+ function billingAccountIdOf(org) {
352
+ if (typeof org !== "object" || org === null) return void 0;
353
+ for (const key of [
354
+ "billingAccountId",
355
+ "billing_account_id",
356
+ "billingAccount"
357
+ ]) {
358
+ const value = org[key];
359
+ if (typeof value === "string" || typeof value === "number") return value;
360
+ }
361
+ }
340
362
  function projectLinkPath(cwd = process.cwd()) {
341
363
  const root = findProjectRoot(cwd) || cwd;
342
364
  return path.join(root, ".rebase", "cloud.json");
@@ -1175,6 +1197,45 @@ function openUrl(target, label = "Opening") {
1175
1197
  child.unref();
1176
1198
  } catch {}
1177
1199
  }
1200
+ /**
1201
+ * Read the control plane's rows as a subcommand's row shape.
1202
+ *
1203
+ * `client.data.collection(...)` is typed against the *generated* schema of the
1204
+ * project the SDK is pointed at, and the control-plane collections this CLI
1205
+ * reads are not in one — so every row arrives as an open
1206
+ * `Record<string, unknown>`. A declared `interface` gets no implicit index
1207
+ * signature, so it does not overlap that, and a direct `as` is refused: which
1208
+ * is how ten call sites came to write `as unknown as XRow[]`, an assertion
1209
+ * about a wire payload with nothing checking it in either direction.
1210
+ *
1211
+ * There is exactly one invariant to check and this checks it. Everything else
1212
+ * the shapes declare is optional and already read as such, so there is nothing
1213
+ * further to verify — but a row with no usable `id` is not a row any of these
1214
+ * commands can act on, and passing it through was how a listing came to print
1215
+ * `[undefined]` and a lookup came to compare against the string `"undefined"`.
1216
+ */
1217
+ function cloudRows(rows) {
1218
+ return (rows ?? []).filter((row) => typeof row?.id === "string" || typeof row?.id === "number");
1219
+ }
1220
+ /** {@link cloudRows} for an endpoint that returns a single row. */
1221
+ function cloudRow(row) {
1222
+ return cloudRows(row ? [row] : [])[0];
1223
+ }
1224
+ /**
1225
+ * {@link cloudRows} for a write that must have produced a row.
1226
+ *
1227
+ * `create()` returning something with no usable `id` means the control plane
1228
+ * accepted the write and then described it in a way this CLI cannot act on.
1229
+ * The callers all go straight on to use that id — `setContextOrg(url,
1230
+ * String(created.id))` — so asserting the shape, which is what stood here,
1231
+ * turned a control-plane fault into an organization whose active id is the
1232
+ * seven-letter string `"undefined"`, stored in the user's config file.
1233
+ */
1234
+ function requireCloudRow(row, what) {
1235
+ const parsed = cloudRow(row);
1236
+ if (!parsed) throw new Error(`The control plane accepted the ${what} but returned no id for it.`);
1237
+ return parsed;
1238
+ }
1178
1239
  /** Duration in coarse bands — enough to see "slow", not enough to fingerprint. */
1179
1240
  function durationBucket(ms) {
1180
1241
  if (!Number.isFinite(ms) || ms < 0) return "unknown";
@@ -1184,6 +1245,24 @@ function durationBucket(ms) {
1184
1245
  return "120s+";
1185
1246
  }
1186
1247
  /**
1248
+ * An error reduced to something safe to transmit.
1249
+ *
1250
+ * The message and the stack are discarded, always. What survives is the
1251
+ * constructor name and, for the errors that carry one, a `code` — both of which
1252
+ * come from the program rather than from anything the user typed or named.
1253
+ * `EACCES` is useful and safe; "cannot write /Users/francesco/clients/acme" is
1254
+ * neither.
1255
+ */
1256
+ function errorClass(error) {
1257
+ if (error && typeof error === "object") {
1258
+ const code = error.code;
1259
+ if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,31}$/.test(code)) return code;
1260
+ const name = error.name;
1261
+ if (typeof name === "string" && /^[A-Za-z][A-Za-z0-9]{0,31}$/.test(name)) return name;
1262
+ }
1263
+ return "Unknown";
1264
+ }
1265
+ /**
1187
1266
  * Drop anything that is not a permitted value type, and clamp strings.
1188
1267
  *
1189
1268
  * The last line of defence rather than the first. Every call site is supposed
@@ -1623,11 +1702,6 @@ var PRESET_CHOICES = [
1623
1702
  short: "Blank"
1624
1703
  }
1625
1704
  ];
1626
- /**
1627
- * Builds the interactive prompt questions for `rebase init`.
1628
- * Exported for testability — all prompt `type` values must match
1629
- * types registered by the installed version of inquirer.
1630
- */
1631
1705
  function buildInitQuestions(params) {
1632
1706
  const { nameArg, templateArg, headlessArg, hasGitFlag, hasInstallFlag, pm } = params;
1633
1707
  const questions = [];
@@ -4441,8 +4515,25 @@ function isRecord(value) {
4441
4515
  * A manifest is committed and reviewed, so this is not a security boundary so
4442
4516
  * much as a guard against `../../` typos that would otherwise have `rebase build`
4443
4517
  * writing outside the project.
4444
- */
4445
- function checkRelativePath(value, fieldPath, issues, { required }) {
4518
+ *
4519
+ * `mayEscape` is for `context`, and only for `context`. Every other path here
4520
+ * names something Rebase *reads* — collections, functions, the generated
4521
+ * schema, the built assets to serve — and those have to be inside the project
4522
+ * or the bundle cannot carry them. A Docker build context is the one field that
4523
+ * names something Rebase never opens: it is handed to `docker build`, and in
4524
+ * any workspace repository the thing it has to name is the workspace root,
4525
+ * above the app. That is not exotic. It is what pnpm, turbo and nx layouts all
4526
+ * look like, and it is what this repository's own reference project needs — its
4527
+ * Dockerfile's first instruction copies `pnpm-lock.yaml`, which does not exist
4528
+ * beside `rebase.json` and never will.
4529
+ *
4530
+ * Refusing it did not prevent the escape; it only stopped anyone declaring it.
4531
+ * `app/rebase.json` built from the monorepo root the whole time — via
4532
+ * `infra/cloudbuild.yaml`, which says `-f app/backend/Dockerfile .` — while the
4533
+ * manifest said the context was `app/` and `rebase build` printed a command
4534
+ * that dies on its first `COPY`.
4535
+ */
4536
+ function checkRelativePath(value, fieldPath, issues, { required, mayEscape = false }) {
4446
4537
  if (value === void 0) {
4447
4538
  if (required) issues.push({
4448
4539
  path: fieldPath,
@@ -4465,7 +4556,7 @@ function checkRelativePath(value, fieldPath, issues, { required }) {
4465
4556
  return;
4466
4557
  }
4467
4558
  const normalized = path.normalize(value);
4468
- if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
4559
+ if (!mayEscape && (normalized === ".." || normalized.startsWith(`..${path.sep}`))) {
4469
4560
  issues.push({
4470
4561
  path: fieldPath,
4471
4562
  message: "must stay inside the project directory"
@@ -4600,12 +4691,26 @@ function validateApp(name, raw, issues) {
4600
4691
  message: "only applies to a custom runtime — set \"runtime\": \"custom\" to build your own image"
4601
4692
  });
4602
4693
  checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
4603
- checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
4694
+ checkRelativePath(raw.context, `${base}.context`, issues, {
4695
+ required: false,
4696
+ mayEscape: true
4697
+ });
4604
4698
  if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
4605
4699
  path: `${base}.port`,
4606
4700
  message: "must be an integer"
4607
4701
  });
4608
- return raw;
4702
+ return {
4703
+ type: "backend",
4704
+ runtime: custom ? "custom" : "managed",
4705
+ config: raw.config,
4706
+ functions: raw.functions,
4707
+ crons: raw.crons,
4708
+ schema: raw.schema,
4709
+ usersCollection: raw.usersCollection,
4710
+ dockerfile: raw.dockerfile,
4711
+ context: raw.context,
4712
+ port: raw.port
4713
+ };
4609
4714
  }
4610
4715
  case "static": {
4611
4716
  checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
@@ -4620,7 +4725,15 @@ function validateApp(name, raw, issues) {
4620
4725
  });
4621
4726
  const appPath = checkAppPath(raw.path, `${base}.path`, issues);
4622
4727
  checkCmsPath(raw.cms, appPath ?? "/", `${base}.cms`, issues);
4623
- return raw;
4728
+ return {
4729
+ type: "static",
4730
+ root: raw.root,
4731
+ build: raw.build,
4732
+ output: raw.output,
4733
+ path: appPath,
4734
+ spa: raw.spa,
4735
+ cms: raw.cms
4736
+ };
4624
4737
  }
4625
4738
  default: return;
4626
4739
  }
@@ -8696,6 +8809,42 @@ ${chalk.bold("Examples")}
8696
8809
  rebase build web Build only the "web" static app
8697
8810
  `.trim());
8698
8811
  }
8812
+ /**
8813
+ * The two commands that turn a custom-runtime app into an image.
8814
+ *
8815
+ * Exported for the test, because the failure this replaced was in the printed
8816
+ * text and nowhere else. It said:
8817
+ *
8818
+ * docker build -f backend/Dockerfile .
8819
+ *
8820
+ * for every custom backend, and the `.` was a guess — `context` was validated,
8821
+ * stored on the config, and read by nothing. For the reference project that
8822
+ * guess is wrong: `app/backend/Dockerfile` opens by copying `pnpm-lock.yaml`
8823
+ * and `pnpm-workspace.yaml`, which live at the monorepo root, so the command
8824
+ * `rebase build` handed you died on its first instruction. The deploy that
8825
+ * actually works, `infra/cloudbuild.yaml`, has always said
8826
+ * `-f app/backend/Dockerfile .` from the root.
8827
+ *
8828
+ * `dockerfile` is relative to `rebase.json`; `context` is too, and may point
8829
+ * above it. Docker resolves `-f` against the working directory, not the
8830
+ * context, so the path has to be re-expressed against wherever the command
8831
+ * runs — which is what the old line never did and is the whole reason it
8832
+ * could not be right for both.
8833
+ */
8834
+ function dockerBuildHint(projectRoot, name, app) {
8835
+ const dockerfile = app.dockerfile ?? "Dockerfile";
8836
+ const context = app.context ?? ".";
8837
+ const contextDir = path.resolve(projectRoot, context);
8838
+ const fromContext = path.relative(contextDir, path.resolve(projectRoot, dockerfile));
8839
+ const build = `${chalk.cyan(`npm run build --workspace ${name}`)}`;
8840
+ if (fromContext.startsWith("..")) return [
8841
+ chalk.dim(` ${build}`),
8842
+ chalk.yellow(` ⚠ ${dockerfile} is outside the build context (${context}) — nothing it COPYs is reachable.`),
8843
+ chalk.dim(` Widen "context" in rebase.json, or move the Dockerfile inside it.`)
8844
+ ];
8845
+ const docker = chalk.cyan(`docker build -f ${fromContext} .`);
8846
+ return context === "." ? [chalk.dim(` ${build} then ${docker}`)] : [chalk.dim(` ${build}`), chalk.dim(` ${chalk.cyan(`cd ${context}`)} && ${docker}`)];
8847
+ }
8699
8848
  async function buildCommand(rawArgs = []) {
8700
8849
  if (wantsHelp(rawArgs)) {
8701
8850
  printHelp$5();
@@ -8759,7 +8908,7 @@ async function buildCommand(rawArgs = []) {
8759
8908
  console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
8760
8909
  if (app.type === "backend" && app.runtime === "custom") {
8761
8910
  console.log(chalk.dim(" custom runtime — this project builds its own image, not a bundle"));
8762
- console.log(chalk.dim(` ${chalk.cyan(`npm run build --workspace ${name}`)} then ${chalk.cyan(`docker build -f ${app.dockerfile ?? "Dockerfile"} .`)}`));
8911
+ for (const line of dockerBuildHint(projectRoot, name, app)) console.log(line);
8763
8912
  console.log("");
8764
8913
  continue;
8765
8914
  }
@@ -8951,8 +9100,19 @@ function findCliRoot(from) {
8951
9100
  }
8952
9101
  return null;
8953
9102
  }
8954
- /** The block names the payload may switch on. A typo has to be an error. */
9103
+ /**
9104
+ * The block names the payload may switch on. A typo has to be an error.
9105
+ *
9106
+ * Typed as keys of {@link ProjectShape}, which is what makes that true: `as
9107
+ * const` alone gave these two string literals and tied them to nothing, so a
9108
+ * flag renamed on the interface left this list naming a block that no longer
9109
+ * exists, and the template kept switching on it.
9110
+ */
8955
9111
  var SHAPE_FLAGS = ["collections", "frontend"];
9112
+ /** Narrow a marker name to a flag, having checked it is one. */
9113
+ function isShapeFlag(name) {
9114
+ return SHAPE_FLAGS.includes(name);
9115
+ }
8956
9116
  /**
8957
9117
  * Render one payload file for this project.
8958
9118
  *
@@ -8976,7 +9136,6 @@ var SHAPE_FLAGS = ["collections", "frontend"];
8976
9136
  * typechecker would see.
8977
9137
  */
8978
9138
  function renderPayload(contents, shape, projectName) {
8979
- const flags = shape;
8980
9139
  const out = [];
8981
9140
  let open = null;
8982
9141
  for (const line of contents.split("\n")) {
@@ -8992,10 +9151,10 @@ function renderPayload(contents, shape, projectName) {
8992
9151
  continue;
8993
9152
  }
8994
9153
  if (open) throw new Error(`Eject template: {{${kind}${name}}} inside an open ${open.name} block.`);
8995
- if (!SHAPE_FLAGS.includes(name)) throw new Error(`Eject template: unknown block {{${kind}${name}}}.`);
9154
+ if (!isShapeFlag(name)) throw new Error(`Eject template: unknown block {{${kind}${name}}}.`);
8996
9155
  open = {
8997
9156
  name,
8998
- keep: kind === "#" ? flags[name] === true : flags[name] !== true
9157
+ keep: kind === "#" ? shape[name] === true : shape[name] !== true
8999
9158
  };
9000
9159
  }
9001
9160
  if (open) throw new Error(`Eject template: {{#${open.name}}} was never closed.`);
@@ -12111,10 +12270,10 @@ async function linkCommand(rawArgs) {
12111
12270
  } else {
12112
12271
  requireInteractive("a project to link", "--project <slug>");
12113
12272
  const org = getContextOrg(url);
12114
- const projects = (await client.data.collection("projects").find({
12273
+ const projects = cloudRows((await client.data.collection("projects").find({
12115
12274
  where: org ? { organization: ["==", org] } : void 0,
12116
12275
  limit: 100
12117
- })).data;
12276
+ })).data);
12118
12277
  if (projects.length === 0) fail("No projects found for your account.", `Create one with ${chalk.bold("rebase cloud projects create")}.`, "no_projects");
12119
12278
  const { picked } = await inquirer.prompt([{
12120
12279
  type: "select",
@@ -12578,14 +12737,14 @@ async function webhooksCommand(subcommand, rawArgs) {
12578
12737
  const table = args["--table"] || fail("--table is required.", void 0, "usage");
12579
12738
  const url = args["--endpoint"] || fail("--endpoint is required.", "Where the POST goes.", "usage");
12580
12739
  const events = (args["--events"] || "insert,update,delete").split(",").map((s) => s.trim());
12581
- const created = await client.data.collection("webhooks").create({
12740
+ const created = requireCloudRow(await client.data.collection("webhooks").create({
12582
12741
  project: projectId,
12583
12742
  name,
12584
12743
  table,
12585
12744
  url,
12586
12745
  events,
12587
12746
  enabled: true
12588
- });
12747
+ }), "webhook");
12589
12748
  success(`Created webhook ${chalk.bold(name)} [${created.id}]`);
12590
12749
  emit(() => {}, {
12591
12750
  success: true,
@@ -12678,15 +12837,22 @@ async function storageCommand(action, rawArgs) {
12678
12837
  return;
12679
12838
  }
12680
12839
  for (const s of stores) {
12681
- console.log(` ${chalk.bold(s.bucketName ?? s.type ?? "bucket")} ${chalk.gray(`[${s.id}]`)} ${colorStatus(s.status)}`);
12682
- keyValues([["Provider", s.provider], ["Type", s.type]]);
12840
+ const name = s.bucketName || s.s3Bucket || "(no bucket)";
12841
+ const key = s.sourceKey || DEFAULT_STORAGE_SOURCE_KEY;
12842
+ console.log(` ${chalk.bold(name)} ${chalk.gray(`[${s.id}]`)} ${colorStatus(s.status)}`);
12843
+ keyValues([
12844
+ ["Source", key],
12845
+ ["Provider", s.provider],
12846
+ ["Type", s.type]
12847
+ ]);
12683
12848
  }
12684
12849
  console.log("");
12685
12850
  }, {
12686
12851
  projectId,
12687
12852
  stores: stores.map((s) => ({
12688
12853
  id: String(s.id),
12689
- bucketName: s.bucketName ?? null,
12854
+ sourceKey: s.sourceKey || DEFAULT_STORAGE_SOURCE_KEY,
12855
+ bucketName: s.bucketName || s.s3Bucket || null,
12690
12856
  type: s.type ?? null,
12691
12857
  provider: s.provider ?? null,
12692
12858
  status: s.status ?? null
@@ -12718,7 +12884,8 @@ function printStorageHelp() {
12718
12884
  ["--secret-access-key <s>", "Secret access key. Required"],
12719
12885
  ["--endpoint <url>", "S3 endpoint. Omit for AWS"],
12720
12886
  ["--region <region>", "Region"],
12721
- ["--force-path-style", "Required by MinIO and some gateways"]
12887
+ ["--force-path-style", "Required by MinIO and some gateways"],
12888
+ ["--source <key>", "Which declared bucket. Default: the default one"]
12722
12889
  ]
12723
12890
  }
12724
12891
  ],
@@ -12743,11 +12910,10 @@ async function storageCreateCommand(rawArgs) {
12743
12910
  try {
12744
12911
  noteBlank();
12745
12912
  note(chalk.gray("Provisioning managed storage — this creates a bucket and its credentials..."));
12746
- const res = await client.functions.invoke("storage-provision", void 0, {
12913
+ const info = (await client.functions.invoke("storage-provision", void 0, {
12747
12914
  method: "POST",
12748
12915
  path: projectId
12749
- });
12750
- const info = res.data ?? res.data;
12916
+ })).data;
12751
12917
  success(`Managed storage provisioned for ${displayProjectRef(rawArgs)}.`);
12752
12918
  emit(() => {
12753
12919
  keyValues([
@@ -12782,7 +12948,8 @@ async function storageAttachCommand(rawArgs) {
12782
12948
  "--secret-access-key": String,
12783
12949
  "--endpoint": String,
12784
12950
  "--region": String,
12785
- "--force-path-style": Boolean
12951
+ "--force-path-style": Boolean,
12952
+ "--source": String
12786
12953
  },
12787
12954
  rawArgs,
12788
12955
  commandWords: 3,
@@ -12800,13 +12967,18 @@ async function storageAttachCommand(rawArgs) {
12800
12967
  if (missing.length > 0) fail(`Missing ${missing.join(", ")}.`, "A bucket without credentials cannot be used, and would be stored as though it could. Run `rebase cloud storage --help` for the full list.", "usage");
12801
12968
  const { client } = await requireClient(rawArgs);
12802
12969
  const projectId = await requireProject(rawArgs, client);
12970
+ const sourceKey = (parsed["--source"] ?? "").trim() || DEFAULT_STORAGE_SOURCE_KEY;
12803
12971
  try {
12804
12972
  const existing = (await client.data.collection("storages").find({
12805
12973
  where: { project: ["==", projectId] },
12806
- limit: 1
12807
- })).data[0];
12974
+ limit: 50
12975
+ })).data.find((r) => {
12976
+ const key = r.sourceKey;
12977
+ return (typeof key === "string" && key ? key : DEFAULT_STORAGE_SOURCE_KEY) === sourceKey;
12978
+ });
12808
12979
  const row = {
12809
12980
  project: projectId,
12981
+ sourceKey,
12810
12982
  type: "byos",
12811
12983
  status: "active",
12812
12984
  s3Bucket: bucket,
@@ -13137,8 +13309,7 @@ async function billingCommand(rawArgs) {
13137
13309
  }
13138
13310
  if (!org) fail("No active organization.", "Run `rebase cloud use` first.", "no_org");
13139
13311
  try {
13140
- const orgRow = await client.data.collection("organizations").findById(org);
13141
- const billingId = orgRow?.billing_account_id ?? orgRow?.billingAccount;
13312
+ const billingId = billingAccountIdOf(await client.data.collection("organizations").findById(org));
13142
13313
  if (!billingId) {
13143
13314
  emit(() => {
13144
13315
  console.log("");
@@ -13226,7 +13397,6 @@ var DIAL_FLAGS = {
13226
13397
  "--replicas": "replicaCount",
13227
13398
  "--spot": "preemptible",
13228
13399
  "--scale-to-zero": "scaleToZero",
13229
- "--db-mode": "databaseMode",
13230
13400
  "--db-instances": "databaseInstances",
13231
13401
  "--db-cpu": "databaseCpu",
13232
13402
  "--db-memory": "databaseMemory",
@@ -13318,7 +13488,6 @@ async function computeCommand(action, rawArgs) {
13318
13488
  true: "scale to zero",
13319
13489
  false: "stay warm"
13320
13490
  })],
13321
- ["Database", dialLine(project.databaseMode)],
13322
13491
  ["Database instances", dialLine(project.databaseInstances)],
13323
13492
  ["Database CPU", dialLine(project.databaseCpu)],
13324
13493
  ["Database memory", dialLine(project.databaseMemory)],
@@ -13345,7 +13514,6 @@ async function computeCommand(action, rawArgs) {
13345
13514
  replicaCount: project.replicaCount ?? null,
13346
13515
  preemptible: project.preemptible ?? null,
13347
13516
  scaleToZero: project.scaleToZero ?? null,
13348
- databaseMode: project.databaseMode ?? null,
13349
13517
  databaseInstances: project.databaseInstances ?? null,
13350
13518
  databaseCpu: project.databaseCpu ?? null,
13351
13519
  databaseMemory: project.databaseMemory ?? null,
@@ -13427,6 +13595,227 @@ function buildDialPatch(rawArgs, opts) {
13427
13595
  return { patch };
13428
13596
  }
13429
13597
  //#endregion
13598
+ //#region src/commands/cloud/db-connect.ts
13599
+ /**
13600
+ * `rebase cloud db connect` — a local port that is your cloud database.
13601
+ *
13602
+ * ## What this replaces
13603
+ *
13604
+ * A managed database lives in a namespace of the platform's cluster, and its
13605
+ * address (`postgres-rw.rebase-tenant-….svc.cluster.local`) resolves to nothing
13606
+ * on a developer's machine. The console used to bridge that gap by printing
13607
+ *
13608
+ * kubectl port-forward svc/postgres-rw -n rebase-tenant-… 5432:5432
13609
+ *
13610
+ * which nobody outside the platform can run: a tenant of Rebase Cloud has no
13611
+ * kubeconfig for our cluster, and there is no product that sells one. So the
13612
+ * platform reaches into the cluster instead, and this command is the local end
13613
+ * of that reach.
13614
+ *
13615
+ * ## How it works
13616
+ *
13617
+ * A listener on 127.0.0.1. Every TCP connection it accepts opens its own
13618
+ * WebSocket to the control plane, authenticates in-band with the console session
13619
+ * this CLI already holds, and from `ready` onwards the two are a byte pipe. The
13620
+ * database still asks for a password — the tunnel is a network path, not a
13621
+ * credential — so `psql` behaves exactly as it would against a local Postgres.
13622
+ *
13623
+ * One WebSocket per connection rather than one multiplexed socket: `psql` is one
13624
+ * connection and a pool is a handful, and per-connection sockets keep the
13625
+ * framing at "these bytes are those bytes" with no stream ids to get wrong.
13626
+ *
13627
+ * Node's global `WebSocket` is used rather than `ws`, which is why the token
13628
+ * goes in the first frame instead of an `Authorization` header — the WHATWG
13629
+ * client cannot set request headers, and a header-only endpoint would be
13630
+ * unreachable from a browser too.
13631
+ */
13632
+ /** Documented in `action-help.ts`, and paired with it by `action-help.test.ts`. */
13633
+ var DB_CONNECT_FLAGS = {
13634
+ "--port": Number,
13635
+ "--reveal": Boolean
13636
+ };
13637
+ /** The default, because it is what every Postgres client assumes. */
13638
+ var DEFAULT_LOCAL_PORT = 5432;
13639
+ /** `https://app.rebase.pro` → `wss://app.rebase.pro/api/db-tunnel/p1`. */
13640
+ function tunnelUrl(cloudUrl, projectId) {
13641
+ const url = new URL(cloudUrl);
13642
+ url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
13643
+ url.pathname = `/api/db-tunnel/${encodeURIComponent(projectId)}`;
13644
+ url.search = "";
13645
+ return url.toString();
13646
+ }
13647
+ /**
13648
+ * A local DSN for the tunnel, with the password only if the caller asked.
13649
+ *
13650
+ * Built here and nowhere else. The server reports the *cluster's* URI, which is
13651
+ * the one thing that must not be printed as the way to connect — it is exactly
13652
+ * the address that does not work from here.
13653
+ */
13654
+ function localDsn(opts) {
13655
+ const user = opts.username ? encodeURIComponent(opts.username) : "postgres";
13656
+ const auth = opts.password ? `${user}:${encodeURIComponent(opts.password)}` : user;
13657
+ const database = opts.database ?? "rebase";
13658
+ return `postgresql://${auth}@127.0.0.1:${opts.port}/${database}`;
13659
+ }
13660
+ async function dbConnect(rawArgs) {
13661
+ const { flags: args } = parseCloudArgs({
13662
+ spec: DB_CONNECT_FLAGS,
13663
+ rawArgs,
13664
+ commandWords: 3,
13665
+ command: "cloud db connect",
13666
+ maxPositionals: 0
13667
+ });
13668
+ const { client, url } = await requireClient(rawArgs);
13669
+ const projectId = await requireProject(rawArgs, client);
13670
+ const projectRef = displayProjectRef(rawArgs);
13671
+ let info;
13672
+ try {
13673
+ info = await client.functions.invoke("db-info", void 0, {
13674
+ method: "GET",
13675
+ path: projectId
13676
+ });
13677
+ } catch (e) {
13678
+ return reportError(e, "Failed to load database info");
13679
+ }
13680
+ if (info.type === "byodb") fail("This project uses your own database.", "Connect to it directly — there is nothing for the platform to tunnel.", "byodb");
13681
+ if (!info.directAccess) fail("The platform could not resolve this project's database, so it cannot open a path to it.", info.unavailableReason ?? "A managed database is provisioned at the project's first deploy; before then there is nothing to connect to.", "db_unavailable");
13682
+ let password;
13683
+ if (args["--reveal"]) {
13684
+ if (!info.passwordAvailable) fail("No password is available to reveal for this database.", info.unavailableReason ?? void 0, "password_unavailable");
13685
+ try {
13686
+ password = (await client.functions.invoke("db-info", { projectId }, { path: "reveal" })).password;
13687
+ } catch (e) {
13688
+ return reportError(e, "Failed to reveal the database password");
13689
+ }
13690
+ }
13691
+ const requested = args["--port"] ?? DEFAULT_LOCAL_PORT;
13692
+ if (!Number.isInteger(requested) || requested < 0 || requested > 65535) fail(`--port must be a port number, not ${requested}.`, void 0, "bad_port");
13693
+ const endpoint = tunnelUrl(url, projectId);
13694
+ const server = net$1.createServer((socket) => {
13695
+ pipeThroughTunnel(socket, endpoint, client.auth.getSession()?.accessToken ?? "");
13696
+ });
13697
+ server.on("error", (err) => {
13698
+ if (err.code === "EADDRINUSE") fail(`Port ${requested} on 127.0.0.1 is already in use.`, "Pass --port to choose another, or stop whatever is listening there.", "port_in_use");
13699
+ fail(`Could not open a local listener: ${err.message}`, void 0, "listen_failed");
13700
+ });
13701
+ await new Promise((resolve) => {
13702
+ server.listen(requested, "127.0.0.1", () => resolve());
13703
+ });
13704
+ const address = server.address();
13705
+ const port = address && typeof address !== "string" ? address.port : requested;
13706
+ const dsn = localDsn({
13707
+ port,
13708
+ username: info.username,
13709
+ database: info.database,
13710
+ password
13711
+ });
13712
+ emit(() => {
13713
+ console.log("");
13714
+ console.log(chalk.bold(` 🔌 Tunnel open — project ${projectRef}`));
13715
+ console.log("");
13716
+ console.log(` ${chalk.green(dsn)}`);
13717
+ console.log("");
13718
+ if (!password) {
13719
+ console.log(chalk.gray(" The password is hidden. Re-run with --reveal to print it in the URL,"));
13720
+ console.log(chalk.gray(" or read it with `rebase cloud db info --reveal`."));
13721
+ console.log("");
13722
+ }
13723
+ console.log(chalk.gray(" Leave this running, and point any Postgres client at it."));
13724
+ console.log(chalk.gray(" Ctrl-C closes the tunnel."));
13725
+ console.log("");
13726
+ }, {
13727
+ projectId,
13728
+ host: "127.0.0.1",
13729
+ port,
13730
+ database: info.database,
13731
+ username: info.username,
13732
+ connectionString: dsn,
13733
+ ...password ? { password } : {}
13734
+ });
13735
+ await new Promise((resolve) => {
13736
+ process.once("SIGINT", () => {
13737
+ noteBlank();
13738
+ note(chalk.gray("Tunnel closed."));
13739
+ server.close();
13740
+ resolve();
13741
+ });
13742
+ });
13743
+ }
13744
+ /**
13745
+ * One accepted connection, carried over one WebSocket.
13746
+ *
13747
+ * The local socket is paused until the tunnel says `ready`, so a client that
13748
+ * sends its startup packet the instant it connects — which every Postgres
13749
+ * client does — cannot have those bytes arrive before there is a database to
13750
+ * send them to.
13751
+ *
13752
+ * Exported for `db-connect.pipe.test.ts`, which drives it with a real socket
13753
+ * against a real WebSocket: this is the half a developer's client actually
13754
+ * talks to, and a pipe is the one component whose bugs are silent — a dropped
13755
+ * or reordered chunk does not throw, it corrupts a Postgres message and
13756
+ * surfaces as a protocol error nowhere near here.
13757
+ */
13758
+ function pipeThroughTunnel(socket, endpoint, token) {
13759
+ socket.pause();
13760
+ socket.setNoDelay(true);
13761
+ const ws = new WebSocket(endpoint);
13762
+ ws.binaryType = "arraybuffer";
13763
+ let ready = false;
13764
+ const closeBoth = (reason) => {
13765
+ if (reason && !ready) note(chalk.red(`✗ ${reason}`));
13766
+ try {
13767
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
13768
+ } catch {}
13769
+ socket.destroy();
13770
+ };
13771
+ ws.onopen = () => {
13772
+ ws.send(JSON.stringify({
13773
+ type: "authenticate",
13774
+ token
13775
+ }));
13776
+ };
13777
+ ws.onmessage = (event) => {
13778
+ if (typeof event.data === "string") {
13779
+ let frame = null;
13780
+ try {
13781
+ frame = JSON.parse(event.data);
13782
+ } catch {
13783
+ return closeBoth(`the tunnel endpoint answered with ${event.data.slice(0, 80)}`);
13784
+ }
13785
+ if (frame.type === "ready") {
13786
+ ready = true;
13787
+ socket.resume();
13788
+ return;
13789
+ }
13790
+ const refusal = frame.type === "error" ? frame : null;
13791
+ return closeBoth(refusal?.message ?? `the tunnel was refused (${refusal?.code ?? "unknown"})`);
13792
+ }
13793
+ socket.write(Buffer.from(event.data));
13794
+ };
13795
+ ws.onerror = () => {
13796
+ closeBoth("could not reach the control plane's tunnel endpoint");
13797
+ };
13798
+ ws.onclose = () => {
13799
+ socket.destroy();
13800
+ };
13801
+ socket.on("data", (chunk) => {
13802
+ if (ws.readyState !== WebSocket.OPEN) return;
13803
+ ws.send(new Uint8Array(chunk));
13804
+ if (ws.bufferedAmount > 1024 * 1024) {
13805
+ socket.pause();
13806
+ const drain = setInterval(() => {
13807
+ if (ws.readyState !== WebSocket.OPEN) return clearInterval(drain);
13808
+ if (ws.bufferedAmount < 256 * 1024) {
13809
+ clearInterval(drain);
13810
+ socket.resume();
13811
+ }
13812
+ }, 5);
13813
+ }
13814
+ });
13815
+ socket.on("close", () => closeBoth());
13816
+ socket.on("error", () => closeBoth());
13817
+ }
13818
+ //#endregion
13430
13819
  //#region src/commands/cloud/databases.ts
13431
13820
  async function dbCommand(subcommand, rawArgs) {
13432
13821
  switch (subcommand) {
@@ -13440,6 +13829,9 @@ async function dbCommand(subcommand, rawArgs) {
13440
13829
  case "info":
13441
13830
  await dbInfo(rawArgs);
13442
13831
  break;
13832
+ case "connect":
13833
+ await dbConnect(rawArgs);
13834
+ break;
13443
13835
  case "test":
13444
13836
  await testDatabase(rawArgs);
13445
13837
  break;
@@ -13467,10 +13859,10 @@ async function listDatabases(rawArgs) {
13467
13859
  const projectId = await requireProject(rawArgs, client);
13468
13860
  const projectRef = displayProjectRef(rawArgs);
13469
13861
  try {
13470
- const dbs = (await client.data.collection("databases").find({
13862
+ const dbs = cloudRows((await client.data.collection("databases").find({
13471
13863
  where: { project: ["==", projectId] },
13472
13864
  limit: 50
13473
- })).data;
13865
+ })).data);
13474
13866
  emit(() => {
13475
13867
  console.log("");
13476
13868
  console.log(chalk.bold(` 🗄 Databases — project ${projectRef}`));
@@ -13507,10 +13899,10 @@ async function listDatabases(rawArgs) {
13507
13899
  * point, since the answer decides which row a deploy will actually use.
13508
13900
  */
13509
13901
  async function firstAttachedDatabase(client, projectId) {
13510
- return (await client.data.collection("databases").find({
13902
+ return cloudRows((await client.data.collection("databases").find({
13511
13903
  where: { project: ["==", projectId] },
13512
13904
  limit: 1
13513
- })).data[0];
13905
+ })).data)[0];
13514
13906
  }
13515
13907
  /**
13516
13908
  * Attach a database row to a project.
@@ -13521,12 +13913,12 @@ async function firstAttachedDatabase(client, projectId) {
13521
13913
  * afterwards".
13522
13914
  */
13523
13915
  async function attachDatabaseRow(client, input) {
13524
- return await client.data.collection("databases").create({
13916
+ return requireCloudRow(await client.data.collection("databases").create({
13525
13917
  project: input.projectId,
13526
13918
  type: input.type,
13527
13919
  connectionString: input.type === "byodb" ? input.connectionString : void 0,
13528
13920
  connectionStatus: "untested"
13529
- });
13921
+ }), "database");
13530
13922
  }
13531
13923
  /** What `rebase cloud db create` parses. Exported so its help page cannot drift. */
13532
13924
  var CREATE_DATABASE_FLAGS = {
@@ -13713,10 +14105,19 @@ async function dbInfo(rawArgs) {
13713
14105
  ["Connection", connectionString]
13714
14106
  ]);
13715
14107
  if (info.unavailableReason) console.log(chalk.gray(` ${info.unavailableReason}`));
13716
- if (info.portForward) {
13717
- const pf = info.portForward;
14108
+ if (info.directAccess) {
14109
+ console.log("");
14110
+ console.log(chalk.gray(" The host above is inside the platform's cluster: it is the address your"));
14111
+ console.log(chalk.gray(" deployed backend uses, and it resolves to nothing from here. To reach this"));
14112
+ console.log(chalk.gray(" database from this machine, open a tunnel:"));
13718
14113
  console.log("");
13719
- console.log(chalk.gray(` Port-forward: kubectl -n ${pf.namespace} port-forward svc/${pf.service} ${pf.localPort}:${pf.remotePort}`));
14114
+ console.log(` ${chalk.cyan("rebase cloud db connect")}`);
14115
+ const pf = info.directAccess.kubectl;
14116
+ if (pf) {
14117
+ console.log("");
14118
+ console.log(chalk.gray(" Or, on your own cluster:"));
14119
+ console.log(chalk.gray(` kubectl -n ${pf.namespace} port-forward svc/${pf.service} ${pf.localPort}:${pf.remotePort}`));
14120
+ }
13720
14121
  }
13721
14122
  console.log("");
13722
14123
  }, {
@@ -13727,7 +14128,7 @@ async function dbInfo(rawArgs) {
13727
14128
  database: info.database,
13728
14129
  username: info.username,
13729
14130
  passwordAvailable: info.passwordAvailable,
13730
- portForward: info.portForward,
14131
+ directAccess: info.directAccess,
13731
14132
  unavailableReason: info.unavailableReason,
13732
14133
  ...args["--reveal"] ? {
13733
14134
  password,
@@ -14013,7 +14414,13 @@ function printDbHelp() {
14013
14414
  action: "info",
14014
14415
  section: "Database",
14015
14416
  description: "Connection details",
14016
- flags: [["--reveal", "Include the password. Without it, the value is masked"]]
14417
+ flags: [["--reveal", "Include the password (owner or admin). Without it, the value is masked"]]
14418
+ },
14419
+ {
14420
+ action: "connect",
14421
+ section: "Database",
14422
+ description: "Open a local port that IS the project's database",
14423
+ flags: [["--port <n>", "Local port to listen on. Default: 5432"], ["--reveal", "Print the password in the connection URL"]]
14017
14424
  },
14018
14425
  {
14019
14426
  action: "test",
@@ -14073,7 +14480,9 @@ function printDbHelp() {
14073
14480
  "A project has exactly one database: `create` refuses rather than attaching a second, because",
14074
14481
  "which of two rows a deploy uses is undefined.",
14075
14482
  "A managed database is provisioned at the project's FIRST DEPLOY, so `test` failing before then",
14076
- "is not a fault."
14483
+ "is not a fault.",
14484
+ "The host `info` reports is inside the platform's cluster — your backend's address for it, not",
14485
+ "one your laptop can resolve. `connect` is what makes it reachable from here."
14077
14486
  ]
14078
14487
  });
14079
14488
  }
@@ -14097,7 +14506,7 @@ async function listProjects(rawArgs) {
14097
14506
  where: org ? { organization: ["==", org] } : void 0,
14098
14507
  orderBy: ["name", "asc"],
14099
14508
  limit: 100
14100
- }).then((res) => res.data), fetchTenantBaseDomain(client, url)]);
14509
+ }).then((res) => cloudRows(res.data)), fetchTenantBaseDomain(client, url)]);
14101
14510
  const linkedId = readLink()?.projectId;
14102
14511
  emit(() => {
14103
14512
  console.log("");
@@ -14216,8 +14625,8 @@ var CREATE_PROJECT_FLAGS = {
14216
14625
  * means the two-command sequence that every project needs is one command,
14217
14626
  * and `--db none` is there for the case that genuinely wants to decide later.
14218
14627
  *
14219
- * Distinct from `--db-mode`/`--db-cpu` next to it, which are resource dials
14220
- * on a database that exists. This is whether there is one.
14628
+ * Distinct from `--db-cpu`/`--db-instances` next to it, which are resource
14629
+ * dials on a database that exists. This is whether there is one.
14221
14630
  */
14222
14631
  "--db": String,
14223
14632
  /** For `--db byodb`. Same spelling as `rebase cloud db create` uses. */
@@ -14228,7 +14637,6 @@ var CREATE_PROJECT_FLAGS = {
14228
14637
  "--replicas": String,
14229
14638
  "--spot": String,
14230
14639
  "--scale-to-zero": String,
14231
- "--db-mode": String,
14232
14640
  "--db-instances": String,
14233
14641
  "--db-cpu": String,
14234
14642
  "--db-memory": String,
@@ -14282,7 +14690,7 @@ async function createProject(rawArgs) {
14282
14690
  try {
14283
14691
  const user = await client.auth.getUser();
14284
14692
  if (!user) fail("Session is no longer valid.", "Run `rebase cloud login` again.", "session_invalid");
14285
- const created = await client.data.collection("projects").create({
14693
+ const created = requireCloudRow(await client.data.collection("projects").create({
14286
14694
  name,
14287
14695
  subdomain,
14288
14696
  gitRepoUrl,
@@ -14293,7 +14701,7 @@ async function createProject(rawArgs) {
14293
14701
  organization: org,
14294
14702
  createdById: user.uid,
14295
14703
  status: "provisioning"
14296
- });
14704
+ }), "project");
14297
14705
  const host = projectHost(created, await fetchTenantBaseDomain(client, url));
14298
14706
  const linked = Boolean(args["--link"]);
14299
14707
  if (linked) writeLink({
@@ -14603,12 +15011,63 @@ function packBundle(bundleDir, outPath) {
14603
15011
  });
14604
15012
  }
14605
15013
  /**
14606
- * Assemble the deploy-trigger body for a bundle deploy.
15014
+ * The commit HEAD is on, read here because here is the only place it exists.
15015
+ *
15016
+ * A bundle deploy has no repository anywhere near the control plane: the CLI
15017
+ * builds a tarball and uploads it, so the three paths `deploy.ts` documents for
15018
+ * learning a commit — clone, `ls-remote`, or "there is no repo at all" — all
15019
+ * resolve to the third. Every bundle deployment therefore recorded an empty
15020
+ * hash, which is 291 of the 305 rows in production: a Deployments list where
15021
+ * almost nothing says what it shipped.
15022
+ *
15023
+ * But the CLI is standing IN the repository. `git -C <dir> log -1` answers
15024
+ * exactly, message included — the one thing even the git-build path cannot get
15025
+ * from `ls-remote`.
14607
15026
  *
14608
- * The manifest travels with the trigger so the control plane can validate intake
14609
- * without unpacking the uploaded archive first a rejection (native deps, no
14610
- * matching runtime) is then a fast, cheap answer.
15027
+ * Returns null rather than guessing, for every reason it can fail: no git, not a
15028
+ * repository, no commits yet. The server records what it is given and nothing
15029
+ * more, so null here stays `UNKNOWN_COMMIT_HASH` there.
15030
+ *
15031
+ * A dirty tree is NOT reported as a different commit. The bundle may contain
15032
+ * uncommitted work, and the honest statement about that is "built from a tree at
15033
+ * <hash>", not a fabricated identifier — the same rule the rest of this file
15034
+ * follows about inventing values.
14611
15035
  */
15036
+ function bundleCommit(cwd, run = gitIn(cwd)) {
15037
+ try {
15038
+ const hash = run([
15039
+ "rev-parse",
15040
+ "--short=7",
15041
+ "HEAD"
15042
+ ]).trim();
15043
+ if (!/^[0-9a-f]{7,40}$/.test(hash)) return null;
15044
+ return {
15045
+ hash,
15046
+ message: run([
15047
+ "log",
15048
+ "-1",
15049
+ "--pretty=%s"
15050
+ ]).trim()
15051
+ };
15052
+ } catch {
15053
+ return null;
15054
+ }
15055
+ }
15056
+ /** `git -C <cwd> …`, as a function, so `bundleCommit` is testable without a repo. */
15057
+ function gitIn(cwd) {
15058
+ return (args) => execFileSync("git", [
15059
+ "-C",
15060
+ cwd,
15061
+ ...args
15062
+ ], {
15063
+ encoding: "utf8",
15064
+ stdio: [
15065
+ "ignore",
15066
+ "pipe",
15067
+ "ignore"
15068
+ ]
15069
+ });
15070
+ }
14612
15071
  function bundleDeployBody(input) {
14613
15072
  return {
14614
15073
  projectId: input.projectId,
@@ -14618,7 +15077,11 @@ function bundleDeployBody(input) {
14618
15077
  client: "cli",
14619
15078
  frameworkVersion: input.manifest.runtime?.builtAgainst,
14620
15079
  ...input.declaredApps?.length ? { declaredApps: input.declaredApps } : {},
14621
- ...input.message ? { message: input.message } : {}
15080
+ ...input.message ? { message: input.message } : {},
15081
+ ...input.commit ? {
15082
+ gitCommitHash: input.commit.hash,
15083
+ gitCommitMessage: input.commit.message
15084
+ } : {}
14622
15085
  };
14623
15086
  }
14624
15087
  /**
@@ -14885,7 +15348,8 @@ async function uploadAndTrigger(opts) {
14885
15348
  manifest,
14886
15349
  app: opts.appName,
14887
15350
  message: opts.message,
14888
- declaredApps
15351
+ declaredApps,
15352
+ commit: bundleCommit(process.cwd())
14889
15353
  });
14890
15354
  let deploymentId;
14891
15355
  let managed;
@@ -15130,8 +15594,7 @@ async function readBillingState(client, projectId) {
15130
15594
  if (!card) return unknown;
15131
15595
  let plan = null;
15132
15596
  try {
15133
- const orgRow = await client.data.collection("organizations").findById(org);
15134
- const billingId = orgRow?.billing_account_id ?? orgRow?.billingAccount;
15597
+ const billingId = billingAccountIdOf(await client.data.collection("organizations").findById(org));
15135
15598
  if (billingId != null) {
15136
15599
  const acct = await client.data.collection("billing-accounts").findById(billingId);
15137
15600
  plan = typeof acct?.plan === "string" ? acct.plan : null;
@@ -15572,7 +16035,7 @@ async function listOrgs(rawArgs) {
15572
16035
  });
15573
16036
  const { client, url } = await requireClient(rawArgs);
15574
16037
  try {
15575
- const orgs = (await client.data.collection("organizations").find({ limit: 100 })).data;
16038
+ const orgs = cloudRows((await client.data.collection("organizations").find({ limit: 100 })).data);
15576
16039
  const active = getContextOrg(url);
15577
16040
  emit(() => {
15578
16041
  console.log("");
@@ -15632,11 +16095,11 @@ async function createOrg(rawArgs) {
15632
16095
  if (!name) fail("Organization name is required.", "Pass `--name <name>`.", "input_required");
15633
16096
  const slug = (args["--slug"] || slugify(name)).trim();
15634
16097
  try {
15635
- const created = await client.data.collection("organizations").create({
16098
+ const created = requireCloudRow(await client.data.collection("organizations").create({
15636
16099
  name,
15637
16100
  slug,
15638
16101
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
15639
- });
16102
+ }), "organization");
15640
16103
  setContextOrg(url, String(created.id));
15641
16104
  success(`Created organization ${chalk.bold(name)} and set it active`);
15642
16105
  emit(() => {}, {
@@ -16817,11 +17280,11 @@ function cancelView(res) {
16817
17280
  };
16818
17281
  }
16819
17282
  async function fetchDeployments(client, projectId, limit = 100) {
16820
- return (await client.data.collection("deployments").find({
17283
+ return cloudRows((await client.data.collection("deployments").find({
16821
17284
  where: { project: ["==", projectId] },
16822
17285
  orderBy: ["createdAt", "desc"],
16823
17286
  limit
16824
- })).data;
17287
+ })).data);
16825
17288
  }
16826
17289
  /** Hard ceiling on `--limit`, matching the backend's own page size. */
16827
17290
  var MAX_DEPLOYMENTS_LIMIT = 100;
@@ -17666,9 +18129,10 @@ async function dbDebugCommand(rawArgs) {
17666
18129
  } catch (e) {
17667
18130
  reportError(e, "Failed to read database connection info");
17668
18131
  }
17669
- const pf = info.portForward;
17670
- const forwardCmd = pf ? `kubectl port-forward -n ${pf.namespace} svc/${pf.service} ${pf.localPort}:${pf.remotePort}` : null;
17671
- const psqlCmd = pf && info.username && info.database ? `psql -h 127.0.0.1 -p ${pf.localPort} -U ${info.username} -d ${info.database}` : null;
18132
+ const access = info.directAccess;
18133
+ const connectCmd = access ? "rebase cloud db connect" : null;
18134
+ const psqlCmd = access && info.username && info.database ? `psql -h 127.0.0.1 -p 5432 -U ${info.username} -d ${info.database}` : null;
18135
+ const kubectlCmd = access?.kubectl ? `kubectl port-forward -n ${access.kubectl.namespace} svc/${access.kubectl.service} ${access.kubectl.localPort}:${access.kubectl.remotePort}` : null;
17672
18136
  emit(() => {
17673
18137
  console.log("");
17674
18138
  console.log(chalk.bold(` 🐘 Database — ${displayProjectRef(rawArgs)}`));
@@ -17687,12 +18151,18 @@ async function dbDebugCommand(rawArgs) {
17687
18151
  ["Password", info.passwordAvailable ? chalk.gray("stored — not shown here") : chalk.yellow("none stored")]
17688
18152
  ]);
17689
18153
  console.log("");
17690
- if (forwardCmd) {
17691
- console.log(chalk.gray(" A managed database is only reachable inside its cluster. To connect:"));
18154
+ if (connectCmd) {
18155
+ console.log(chalk.gray(" That host is inside the platform's cluster and does not resolve here."));
18156
+ console.log(chalk.gray(" To reach the database from this machine:"));
17692
18157
  console.log("");
17693
- console.log(` ${forwardCmd}`);
18158
+ console.log(` ${connectCmd}`);
17694
18159
  if (psqlCmd) console.log(` ${psqlCmd}`);
17695
18160
  console.log("");
18161
+ if (kubectlCmd) {
18162
+ console.log(chalk.gray(" Or, on your own cluster:"));
18163
+ console.log(` ${kubectlCmd}`);
18164
+ console.log("");
18165
+ }
17696
18166
  if (info.passwordAvailable) {
17697
18167
  console.log(chalk.gray(" Get the password with: ") + chalk.bold("rebase cloud db info --reveal"));
17698
18168
  console.log("");
@@ -17705,7 +18175,8 @@ async function dbDebugCommand(rawArgs) {
17705
18175
  database: info.database ?? null,
17706
18176
  username: info.username ?? null,
17707
18177
  passwordAvailable: Boolean(info.passwordAvailable),
17708
- portForwardCommand: forwardCmd,
18178
+ connectCommand: connectCmd,
18179
+ kubectlCommand: kubectlCmd,
17709
18180
  psqlCommand: psqlCmd
17710
18181
  });
17711
18182
  }
@@ -18004,7 +18475,6 @@ var ACTION_HELP = {
18004
18475
  ["--replicas <n>", "Instance count"],
18005
18476
  ["--spot <true|false>", "Run on preemptible capacity"],
18006
18477
  ["--scale-to-zero <true|false>", "Stop the instances when idle"],
18007
- ["--db-mode <mode>", "Database topology dial"],
18008
18478
  ["--db-instances <n>", "Database instance count"],
18009
18479
  ["--db-cpu <n>", "vCPU per database instance"],
18010
18480
  ["--db-memory <size>", "Memory per database instance"],
@@ -18033,11 +18503,28 @@ var ACTION_HELP = {
18033
18503
  ],
18034
18504
  examples: ["rebase cloud db create --type managed", "rebase cloud db create --type byodb --connection-string \"$DATABASE_URL\" --wait"],
18035
18505
  notes: [
18036
- "A managed database is CloudNativePG in a shared in-cluster pool, and it is created at the project's first deploy. There is nothing to poll before then, so --wait says so and returns rather than looping.",
18506
+ "A managed database is a CloudNativePG cluster of the project's own, in the project's own namespace and backed up on its own schedule. It is created at the project's first deploy: there is nothing to poll before then, so --wait says so and returns rather than looping.",
18037
18507
  "`rebase cloud db test` legitimately fails before the first deploy.",
18038
18508
  "A project has exactly one database — attaching a second is refused, because the platform reads one row and it becomes undefined which it deploys against."
18039
18509
  ]
18040
18510
  },
18511
+ "db connect": {
18512
+ command: "cloud db connect",
18513
+ usage: "cloud db connect [--port <n>] [--reveal]",
18514
+ summary: "Open a local port that is the project's managed database, and hold it open until Ctrl-C. A managed database lives inside the platform's cluster, so the host `db info` reports is your backend's address for it and resolves to nothing on your machine; this is what makes it reachable from here. Point psql, TablePlus, Drizzle Studio or pg_dump at the URL it prints. The database still asks for its password — the tunnel is a network path, not a credential.",
18515
+ flags: [["--port <n>", "Local port to listen on. Default: 5432"], ["--reveal", "Print the password inside the connection URL"]],
18516
+ examples: [
18517
+ "rebase cloud db connect",
18518
+ "rebase cloud db connect --port 6543",
18519
+ "rebase cloud db connect --reveal --project shop"
18520
+ ],
18521
+ notes: [
18522
+ "Requires the organization's owner or admin role — the same gate as the console's SQL console, because it is the same capability.",
18523
+ "Piped or with --json it prints one object — host, port, database, username, connectionString — and keeps serving, so a script can read the URL and connect.",
18524
+ "Connections go through the control plane, so they count against the project's own database connection limit like any other client.",
18525
+ "Bring-your-own databases are refused: that host is already yours to reach, and there is nothing for the platform to tunnel."
18526
+ ]
18527
+ },
18041
18528
  deploy: {
18042
18529
  command: "cloud deploy",
18043
18530
  usage: "cloud deploy [app] [options]",
@@ -18139,7 +18626,7 @@ var ACTION_HELP = {
18139
18626
  "rebase cloud db backup restore base-20260831 --yes",
18140
18627
  "rebase cloud db backup download base-20260831"
18141
18628
  ],
18142
- notes: ["`restore` replaces the live database. Nothing about it is undoable from here — `--yes`, the global flag listed below, is what skips that confirmation.", "A managed database on the shared pool is backed up with the pool, not per project."]
18629
+ notes: ["`restore` replaces the live database. Nothing about it is undoable from here — `--yes`, the global flag listed below, is what skips that confirmation.", "Backups are the project's own: a base backup on a schedule, plus continuous WAL archiving for point-in-time recovery."]
18143
18630
  },
18144
18631
  "db pitr": {
18145
18632
  command: "cloud db pitr",
@@ -18214,7 +18701,7 @@ var ACTION_HELP = {
18214
18701
  },
18215
18702
  "storage attach": {
18216
18703
  command: "cloud storage attach",
18217
- usage: "cloud storage attach --bucket <name> --access-key-id <id> --secret-access-key <secret> [--endpoint <url>] [--region <region>] [--force-path-style]",
18704
+ usage: "cloud storage attach --bucket <name> --access-key-id <id> --secret-access-key <secret> [--endpoint <url>] [--region <region>] [--force-path-style] [--source <key>]",
18218
18705
  summary: "Point the project at storage you already own — S3, R2, MinIO, any S3-compatible bucket. The alternative to `storage create`, for a bucket the platform does not manage.",
18219
18706
  flags: [
18220
18707
  ["--bucket <name>", "The bucket name. Required"],
@@ -18222,9 +18709,14 @@ var ACTION_HELP = {
18222
18709
  ["--secret-access-key <secret>", "Required. Stored encrypted and never returned"],
18223
18710
  ["--endpoint <url>", "For anything that is not AWS S3 — R2, MinIO, Backblaze"],
18224
18711
  ["--region <region>", "Bucket region. Default: the provider's own default"],
18225
- ["--force-path-style", "Address as endpoint/bucket rather than bucket.endpoint. Needed by MinIO"]
18712
+ ["--force-path-style", "Address as endpoint/bucket rather than bucket.endpoint. Needed by MinIO"],
18713
+ ["--source <key>", "Which declared bucket to configure. Default: the project's default bucket"]
18714
+ ],
18715
+ examples: [
18716
+ "rebase cloud storage attach --bucket assets --access-key-id … --secret-access-key …",
18717
+ "rebase cloud storage attach --source media --bucket media-eu --access-key-id … --secret-access-key …",
18718
+ "rebase cloud storage attach --bucket assets --access-key-id … --secret-access-key … \\\n --endpoint https://<account>.r2.cloudflarestorage.com --region auto"
18226
18719
  ],
18227
- examples: ["rebase cloud storage attach --bucket assets --access-key-id AKIA… --secret-access-key …", "rebase cloud storage attach --bucket assets --access-key-id … --secret-access-key … \\\n --endpoint https://<account>.r2.cloudflarestorage.com --region auto"],
18228
18720
  notes: ["All three of --bucket, --access-key-id and --secret-access-key, or none: a bucket with no credentials reads as configured and fails on the first upload.", "Redeploy for the tenant to pick the credentials up."]
18229
18721
  },
18230
18722
  "webhooks create": {
@@ -18311,7 +18803,6 @@ var ACTION_HELP = {
18311
18803
  ["--replicas <n>", "Instances that always exist — the autoscaler's floor, and what is billed at rest"],
18312
18804
  ["--spot <true|false>", "Preemptible capacity: cheaper, and restarted without notice"],
18313
18805
  ["--scale-to-zero <true|false>", "Request-billed compute that stops when idle, at the cost of a cold start"],
18314
- ["--db-mode <shared|dedicated>", "Pooled cluster, or one of this project's own"],
18315
18806
  ["--db-instances <n>", "1–3. 1 is a single instance with no failover; 2 adds an automatic standby"],
18316
18807
  ["--db-cpu <n>", "Database CPU request per instance. Default: 500m"],
18317
18808
  ["--db-memory <size>", "Database memory request per instance. Default: 2Gi"],
@@ -18323,7 +18814,7 @@ var ACTION_HELP = {
18323
18814
  examples: [
18324
18815
  "rebase cloud compute set --cpu 500m --memory 2Gi",
18325
18816
  "rebase cloud compute set --replicas 2 --autoscale-max 6",
18326
- "rebase cloud compute set --db-mode dedicated --db-instances 2"
18817
+ "rebase cloud compute set --db-instances 2 --db-memory 4Gi"
18327
18818
  ],
18328
18819
  notes: [
18329
18820
  "Run `rebase cloud compute` first — it prints the current dials and the €/month this project is quoted.",
@@ -18965,7 +19456,7 @@ async function appsCommand(subcommand, rawArgs = []) {
18965
19456
  }
18966
19457
  function describeApp(app) {
18967
19458
  switch (app.type) {
18968
- case "backend": return app.runtime === "custom" ? `custom runtime — ${app.dockerfile ?? "Dockerfile"}` : `managed runtime, config: ${app.config ?? "config"}`;
19459
+ case "backend": return app.runtime === "custom" ? `custom runtime — ${app.dockerfile ?? "Dockerfile"}` + (app.context && app.context !== "." ? ` (context: ${app.context})` : "") : `managed runtime, config: ${app.config ?? "config"}`;
18969
19460
  case "static": return `${app.root} → ${app.output} @ ${app.path ?? "/"}` + (app.cms ? ` ${chalk.magenta(`CMS at ${app.cms}`)}` : "");
18970
19461
  default: return "";
18971
19462
  }
@@ -19180,7 +19671,7 @@ async function entry(args) {
19180
19671
  await recordEvent("cli.error", {
19181
19672
  command: command ?? "none",
19182
19673
  subcommand: effectiveSubcommand ?? "none",
19183
- error_type: error instanceof Error ? error.constructor.name : "unknown",
19674
+ error_type: errorClass(error),
19184
19675
  usage: Boolean(error && typeof error === "object" && error.isUsageError)
19185
19676
  }, { projectRoot: process.cwd() });
19186
19677
  throw error;