@rebasepro/cli 0.21.2-canary.g1ea48be → 0.23.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
@@ -4,7 +4,7 @@ import { i as probeTcp, r as ensureDevDatabase, t as composeDatabaseUrl } from "
4
4
  import { r as readActiveBranch, t as branchUrl } from "./branch-pointer-dBiClJ0p.js";
5
5
  import { t as MANAGED_LIMITATIONS } from "./constraints-DKGbNfUW.js";
6
6
  import { i as managedUrl, n as ensureManagedDatabase, r as findRunningDaemon } from "./daemon-DR9Tk0La.js";
7
- import { n as resolveDevDatabase, t as describeDevDatabase } from "./resolve-Y56osuQH.js";
7
+ import { n as resolveDevDatabase, t as describeDevDatabase } from "./resolve-BAeRUBhg.js";
8
8
  import chalk from "chalk";
9
9
  import arg from "arg";
10
10
  import inquirer from "inquirer";
@@ -459,16 +459,47 @@ function setCurrentContext(url) {
459
459
  writeCredentials(creds);
460
460
  }
461
461
  function resolveCloudUrl(rawArgs) {
462
- const explicit = arg({ "--url": String }, {
462
+ return resolveCloudTarget(rawArgs).url;
463
+ }
464
+ /** The control-plane URL, and which rung of the ladder above supplied it. */
465
+ function resolveCloudTarget(rawArgs) {
466
+ const parsed = arg({ "--url": String }, {
463
467
  argv: rawArgs.slice(2),
464
468
  permissive: true
465
- })["--url"] || process.env.REBASE_CLOUD_URL;
466
- if (explicit) return normalizeUrl(explicit);
469
+ });
470
+ if (parsed["--url"]) return {
471
+ url: normalizeUrl(parsed["--url"]),
472
+ source: "flag"
473
+ };
474
+ if (process.env.REBASE_CLOUD_URL) return {
475
+ url: normalizeUrl(process.env.REBASE_CLOUD_URL),
476
+ source: "env"
477
+ };
467
478
  const link = readLink();
468
- if (link?.url && link.mode !== "direct") return normalizeUrl(link.url);
479
+ if (link?.url && link.mode !== "direct") return {
480
+ url: normalizeUrl(link.url),
481
+ source: "link"
482
+ };
469
483
  const current = currentContextUrl();
470
- if (current) return normalizeUrl(current);
471
- return DEFAULT_CLOUD_URL;
484
+ if (current) return {
485
+ url: normalizeUrl(current),
486
+ source: "context"
487
+ };
488
+ return {
489
+ url: DEFAULT_CLOUD_URL,
490
+ source: "default"
491
+ };
492
+ }
493
+ /**
494
+ * Whether a control-plane host is one the user chose: the platform's own, or a
495
+ * host already in their credentials file because they signed in to it.
496
+ *
497
+ * A link file is part of the repository, and a cloned repository is not the
498
+ * user's to trust with their password. `login` asks before sending one to a
499
+ * linked host that is neither.
500
+ */
501
+ function isKnownControlPlane(url) {
502
+ return url === DEFAULT_CLOUD_URL || Object.hasOwn(readCredentials().contexts, url);
472
503
  }
473
504
  /**
474
505
  * Refuse to run a control-plane command in a directory linked straight at a
@@ -522,6 +553,22 @@ function createCloudClient(url) {
522
553
  /** Two minutes of head-room before a token is treated as expired. */
523
554
  var EXPIRY_BUFFER_MS = 12e4;
524
555
  /**
556
+ * An access token with time left on it, refreshed first if it is close to
557
+ * expiry. Rejects when there is no session, or it cannot be refreshed.
558
+ *
559
+ * For the requests the SDK does not make itself. Its own calls refresh and
560
+ * retry on a 401; a raw `fetch` or a tunnel handshake presents whatever token
561
+ * it was handed, and `createCloudClient` never refreshes in the background. A
562
+ * token read once at the start of a command that runs for longer than it
563
+ * lives — a deploy's build, a `db connect` left open — is refused by the time
564
+ * it is used, so it is read here at the moment it is sent.
565
+ */
566
+ async function freshAccessToken(client) {
567
+ const session = client.auth.getSession();
568
+ if (session?.accessToken && session.expiresAt > Date.now() + EXPIRY_BUFFER_MS) return session.accessToken;
569
+ return (await client.auth.refreshSession()).accessToken;
570
+ }
571
+ /**
525
572
  * Return an authenticated client for the resolved host, refreshing the access
526
573
  * token if it is close to expiry. Exits with a helpful message when there is no
527
574
  * usable session (never logged in, or the refresh token was revoked).
@@ -2537,7 +2584,7 @@ async function configureEnvFile(targetDirectory, databaseUrl) {
2537
2584
  if (/[\r\n]/.test(databaseUrl)) throw new Error("Invalid DATABASE_URL: multiline values are not allowed.");
2538
2585
  const { pinSearchPath } = await import("@rebasepro/server-postgres");
2539
2586
  const pinnedUrl = pinSearchPath(databaseUrl);
2540
- envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${pinnedUrl}\nDATABASE_PASSWORD=${dbPassword}`);
2587
+ envContent = envContent.replace(/^DATABASE_URL=.*$/m, () => `DATABASE_URL=${pinnedUrl}\nDATABASE_PASSWORD=${dbPassword}`);
2541
2588
  } else {
2542
2589
  const dbPort = await findAvailablePort(5432);
2543
2590
  envContent = envContent.replace(/^DATABASE_URL=.*$/m, [
@@ -3637,7 +3684,7 @@ async function runDriverDbCommand(rawArgs, options = {}) {
3637
3684
  const env = { ...process.env };
3638
3685
  if (envFile) env.DOTENV_CONFIG_PATH = envFile;
3639
3686
  env[DEV_DATABASE_KIND_ENV] = devDatabaseKind(projectRoot) ?? "";
3640
- const { resolveDevDatabase } = await import("./resolve-Y56osuQH.js").then((n) => n.r);
3687
+ const { resolveDevDatabase } = await import("./resolve-BAeRUBhg.js").then((n) => n.r);
3641
3688
  const { readEnvFile } = await import("./project-3WCbrrfW.js").then((n) => n.u);
3642
3689
  refuseBranchOnManagedDatabase(rawArgs, resolveDevDatabase({
3643
3690
  flagUrl: readFlagValue(rawArgs, "--database-url"),
@@ -3807,7 +3854,7 @@ async function manageLocalDatabase(subcommand, projectRoot, rawArgs) {
3807
3854
  * and the wrong one here means overwriting production with a laptop.
3808
3855
  */
3809
3856
  async function pullIntoLocal(projectRoot, rawArgs) {
3810
- const { anonymizeStatements, describeTarget, dumpArgs, findPgDump, restoreArgs } = await import("./pull-Bj0XnH5s.js");
3857
+ const { anonymizeStatements, describeTarget, dumpArgs, findPgTool, restoreArgs, restoreOutcome } = await import("./pull-Dywm5FWP.js");
3811
3858
  const { prepareDatabaseEnv } = await Promise.resolve().then(() => prepare_exports);
3812
3859
  const { readEnvFile } = await import("./project-3WCbrrfW.js").then((n) => n.u);
3813
3860
  const source = readFlagValue(rawArgs, "--from");
@@ -3818,8 +3865,8 @@ async function pullIntoLocal(projectRoot, rawArgs) {
3818
3865
  }
3819
3866
  const schemas = rawArgs.flatMap((arg, index) => arg === "--schema" && rawArgs[index + 1] ? [rawArgs[index + 1]] : []);
3820
3867
  const anonymize = rawArgs.includes("--anonymize");
3821
- if (!await findPgDump()) {
3822
- console.error(chalk.red("✗ `pg_dump` is not on PATH, and this command cannot run without it."));
3868
+ for (const tool of ["pg_dump", "pg_restore"]) if (!await findPgTool(tool)) {
3869
+ console.error(chalk.red(`✗ \`${tool}\` is not on PATH, and this command cannot run without it.`));
3823
3870
  console.error(chalk.gray(" Install the PostgreSQL client tools, e.g. `brew install libpq`."));
3824
3871
  process.exit(1);
3825
3872
  }
@@ -3876,6 +3923,7 @@ async function pullIntoLocal(projectRoot, rawArgs) {
3876
3923
  }
3877
3924
  }
3878
3925
  const dumpFile = path.join(os.tmpdir(), `rebase-pull-${process.pid}.dump`);
3926
+ let failure = null;
3879
3927
  try {
3880
3928
  console.log(chalk.gray(" Dumping…"));
3881
3929
  await execa("pg_dump", [
@@ -3888,12 +3936,14 @@ async function pullIntoLocal(projectRoot, rawArgs) {
3888
3936
  "inherit"
3889
3937
  ] });
3890
3938
  console.log(chalk.gray(" Restoring…"));
3891
- await execa("pg_restore", restoreArgs(plan, dumpFile), { stdio: [
3892
- "ignore",
3893
- "inherit",
3894
- "inherit"
3895
- ] }).catch(() => console.log(chalk.gray(" (pg_restore reported non-fatal diagnostics)")));
3896
- if (anonymize) {
3939
+ const restored = restoreOutcome(await execa("pg_restore", restoreArgs(plan, dumpFile), {
3940
+ stdin: "ignore",
3941
+ stdout: "inherit",
3942
+ stderr: ["pipe", "inherit"],
3943
+ reject: false
3944
+ }));
3945
+ failure = restored.failure;
3946
+ if (anonymize && restored.started) {
3897
3947
  const { Client } = await import("pg");
3898
3948
  const client = new Client({ connectionString: target });
3899
3949
  await client.connect();
@@ -3910,11 +3960,17 @@ async function pullIntoLocal(projectRoot, rawArgs) {
3910
3960
  await client.end();
3911
3961
  }
3912
3962
  }
3913
- await restoreAppRole(target);
3914
- console.log(chalk.green("✓ Local database now holds a copy of " + describeTarget(source)));
3963
+ if (!failure) await restoreAppRole(target);
3915
3964
  } finally {
3916
3965
  fs.rmSync(dumpFile, { force: true });
3917
3966
  }
3967
+ if (failure) {
3968
+ console.error(chalk.red(`✗ ${failure}`));
3969
+ console.error(chalk.yellow(` The local database holds an incomplete copy of ${describeTarget(source)}.`));
3970
+ console.error(chalk.gray(" Fix what the errors above name — often an extension or a role the source has — and pull again."));
3971
+ process.exit(1);
3972
+ }
3973
+ console.log(chalk.green("✓ Local database now holds a copy of " + describeTarget(source)));
3918
3974
  }
3919
3975
  /**
3920
3976
  * Give the restored copy back the privileges `pg_dump` stripped.
@@ -3938,7 +3994,7 @@ async function pullIntoLocal(projectRoot, rawArgs) {
3938
3994
  async function restoreAppRole(target) {
3939
3995
  const { Client } = await import("pg");
3940
3996
  const { detectConnectionPosture, ensureAppRole, REBASE_USER_ROLE } = await import("@rebasepro/server-postgres");
3941
- const { provisionableSchemas } = await import("./pull-Bj0XnH5s.js");
3997
+ const { provisionableSchemas } = await import("./pull-Dywm5FWP.js");
3942
3998
  const client = new Client({ connectionString: target });
3943
3999
  await client.connect();
3944
4000
  try {
@@ -4044,7 +4100,7 @@ async function listFromParentDatabase(projectRoot, rawArgs) {
4044
4100
  const active = readActiveBranch(projectRoot);
4045
4101
  if (!active) return rawArgs;
4046
4102
  const { readEnvFile } = await import("./project-3WCbrrfW.js").then((n) => n.u);
4047
- const { resolveDevDatabase } = await import("./resolve-Y56osuQH.js").then((n) => n.r);
4103
+ const { resolveDevDatabase } = await import("./resolve-BAeRUBhg.js").then((n) => n.r);
4048
4104
  const { resolveComposeUrl } = await Promise.resolve().then(() => prepare_exports);
4049
4105
  const envFile = readEnvFile(projectRoot);
4050
4106
  const parent = resolveDevDatabase({
@@ -4090,7 +4146,7 @@ async function listFromParentDatabase(projectRoot, rawArgs) {
4090
4146
  async function switchBranch(projectRoot, rawArgs) {
4091
4147
  const { branchDatabaseName, clearActiveBranch, databaseNameOf, readActiveBranch, writeActiveBranch } = await import("./branch-pointer-dBiClJ0p.js").then((n) => n.n);
4092
4148
  const { readEnvFile } = await import("./project-3WCbrrfW.js").then((n) => n.u);
4093
- const { resolveDevDatabase } = await import("./resolve-Y56osuQH.js").then((n) => n.r);
4149
+ const { resolveDevDatabase } = await import("./resolve-BAeRUBhg.js").then((n) => n.r);
4094
4150
  const { resolveComposeUrl } = await Promise.resolve().then(() => prepare_exports);
4095
4151
  const goingOff = rawArgs.includes("--off") || rawArgs.includes("--main");
4096
4152
  const name = commandWords(rawArgs, "db")[3];
@@ -4249,6 +4305,8 @@ var DB_ACTION_HELP = {
4249
4305
  notes: ["Destructive, and the data exists only on this machine."]
4250
4306
  }
4251
4307
  };
4308
+ /** Every subcommand the family dispatches — the help table's keys. */
4309
+ var DB_SUBCOMMANDS = Object.keys(DB_ACTION_HELP);
4252
4310
  function printDbHelp$1(action) {
4253
4311
  const entry = action ? DB_ACTION_HELP[action] : void 0;
4254
4312
  if (entry) {
@@ -4385,6 +4443,8 @@ var SCHEMA_ACTION_HELP = {
4385
4443
  notes: ["--fix regenerates them instead of only reporting."]
4386
4444
  }
4387
4445
  };
4446
+ /** Every subcommand the family dispatches — the help table's keys. */
4447
+ var SCHEMA_SUBCOMMANDS = Object.keys(SCHEMA_ACTION_HELP);
4388
4448
  function printSchemaHelp(action) {
4389
4449
  const entry = action ? SCHEMA_ACTION_HELP[action] : void 0;
4390
4450
  if (entry) {
@@ -6421,7 +6481,7 @@ ${chalk.green.bold("Which database")}
6421
6481
  2. ${chalk.gray("DATABASE_URL")} in the shell environment
6422
6482
  3. the branch this checkout is switched to ${chalk.gray("(rebase db branch switch)")}
6423
6483
  4. ${chalk.gray("DATABASE_URL")} in the project's .env
6424
- 5. ${chalk.blue("--docker")}, or ${chalk.gray("devDatabase: \"docker\"")} in rebase.json
6484
+ 5. ${chalk.blue("--docker")} on this command line
6425
6485
  6. the managed development database ${chalk.gray("(PGlite, data in .rebase/)")}
6426
6486
 
6427
6487
  A scaffolded project sets none of 1–5, so it lands on (6): no Docker,
@@ -6470,6 +6530,25 @@ ${chalk.green.bold("Description")}
6470
6530
  `);
6471
6531
  }
6472
6532
  //#endregion
6533
+ //#region src/utils/tool-stdio.ts
6534
+ /**
6535
+ * The stdio a build tool the CLI runs in the foreground gets: a static app's
6536
+ * build command, `tsc`, `tar`.
6537
+ *
6538
+ * Inherited, so its output streams as it happens. With `quietStdout` its stdout
6539
+ * goes to stderr instead, for a command whose own stdout carries a result:
6540
+ * `rebase cloud deploy` in JSON mode — every piped run — owes stdout exactly one
6541
+ * JSON value, and a `vite v6 building…` line in front of it is a result no
6542
+ * parser can read. The tool's output still reaches the person watching.
6543
+ */
6544
+ function toolStdio(quietStdout) {
6545
+ return quietStdout ? [
6546
+ "ignore",
6547
+ 2,
6548
+ "inherit"
6549
+ ] : "inherit";
6550
+ }
6551
+ //#endregion
6473
6552
  //#region src/function-portability.ts
6474
6553
  /**
6475
6554
  * What each of a project's custom functions depends on the *host* for.
@@ -6780,6 +6859,15 @@ function log(options, message) {
6780
6859
  (options.log ?? ((m) => console.log(m)))(message);
6781
6860
  }
6782
6861
  /**
6862
+ * A line the build says to the person running it — a warning, or what it left
6863
+ * out — which progress callbacks may drop and this does not: stdout, or stderr
6864
+ * when stdout carries the caller's result.
6865
+ */
6866
+ function say(options, line) {
6867
+ if (options.quietStdout) console.error(line);
6868
+ else console.log(line);
6869
+ }
6870
+ /**
6783
6871
  * Every `node_modules/@types` directory the project can see.
6784
6872
  *
6785
6873
  * Type roots normally resolve by walking up from the tsconfig's own directory,
@@ -6873,7 +6961,8 @@ function filterProjectPaths(baseDir, projectRoot, paths, baseUrl) {
6873
6961
  * `baseUrl: "../../"` into a config in a different directory silently repoints
6874
6962
  * it at the wrong place.
6875
6963
  */
6876
- async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck) {
6964
+ async function writeBundleTsconfig(projectRoot, outDir, includes, options) {
6965
+ const skipTypeCheck = options.skipTypeCheck === true;
6877
6966
  const tsconfigDir = path.join(projectRoot, ".rebase");
6878
6967
  const fromTsconfig = (target) => {
6879
6968
  return path.relative(tsconfigDir, path.resolve(projectRoot, target)).split(path.sep).join("/");
@@ -6890,7 +6979,7 @@ async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck)
6890
6979
  baseUrl: fromTsconfig("."),
6891
6980
  paths: kept
6892
6981
  };
6893
- if (dropped.length > 0) console.log(chalk.dim(` ignoring ${dropped.length} path alias(es) pointing outside the project (${dropped.join(", ")}) — resolving those from node_modules instead`));
6982
+ if (dropped.length > 0) say(options, chalk.dim(` ignoring ${dropped.length} path alias(es) pointing outside the project (${dropped.join(", ")}) — resolving those from node_modules instead`));
6894
6983
  }
6895
6984
  const compilerOptions = {
6896
6985
  target: "ES2022",
@@ -7668,8 +7757,20 @@ function composeBundleManifest(input) {
7668
7757
  build: input.build
7669
7758
  };
7670
7759
  }
7760
+ /**
7761
+ * What a build refusing on {@link detectDeclaredDepConflicts} says: every name,
7762
+ * both ranges and where each is declared.
7763
+ */
7764
+ function describeDepConflicts(conflicts) {
7765
+ const lines = [`${conflicts.length} dependency range(s) no single version satisfies:`];
7766
+ for (const { name, declarations: [first, second] } of conflicts) lines.push(` ${name}`, ` ${first.range} (${first.file})`, ` ${second.range} (${second.file})`);
7767
+ lines.push(" The bundle installs one range; the other half of the project was built against the other.", " Declare one range, in the app that uses it.");
7768
+ return lines.join("\n");
7769
+ }
7671
7770
  async function buildBundle(options) {
7672
7771
  const { projectRoot, app, appName } = options;
7772
+ const conflicts = detectDeclaredDepConflicts(projectRoot);
7773
+ if (conflicts.length > 0) throw new Error(describeDepConflicts(conflicts));
7673
7774
  const paths = resolveBackendPaths(app, projectRoot);
7674
7775
  const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
7675
7776
  const includes = [];
@@ -7690,22 +7791,22 @@ async function buildBundle(options) {
7690
7791
  "the schema"
7691
7792
  ];
7692
7793
  const compiled = `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
7693
- console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
7694
- console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
7695
- console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
7696
- console.log(chalk.dim(" or run `rebase eject`, which writes an entrypoint of its own and owns"));
7697
- console.log(chalk.dim(" the image — it does not adopt this file, and will not replace it"));
7698
- console.log(chalk.dim(" without --force."));
7794
+ say(options, chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
7795
+ say(options, chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
7796
+ say(options, chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
7797
+ say(options, chalk.dim(" or run `rebase eject`, which writes an entrypoint of its own and owns"));
7798
+ say(options, chalk.dim(" the image — it does not adopt this file, and will not replace it"));
7799
+ say(options, chalk.dim(" without --force."));
7699
7800
  }
7700
7801
  log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
7701
7802
  cleanOutDir(projectRoot, outDir);
7702
- const tsconfigPath = await writeBundleTsconfig(projectRoot, outDir, includes, options.skipTypeCheck === true);
7803
+ const tsconfigPath = await writeBundleTsconfig(projectRoot, outDir, includes, options);
7703
7804
  const tsc = resolveLocalBin(projectRoot, "tsc");
7704
7805
  if (!tsc) throw new Error("TypeScript is not installed in this project. Run your package manager's install first.");
7705
7806
  try {
7706
7807
  await execa(tsc, ["-p", tsconfigPath], {
7707
7808
  cwd: projectRoot,
7708
- stdio: "inherit"
7809
+ stdio: toolStdio(options.quietStdout)
7709
7810
  });
7710
7811
  } catch {
7711
7812
  throw new Error("TypeScript compilation failed — the bundle was not written.");
@@ -7713,9 +7814,9 @@ async function buildBundle(options) {
7713
7814
  const normalized = normalizeEsmSpecifiers(outDir);
7714
7815
  if (normalized.rewritten > 0) log(options, chalk.dim(` resolved ${normalized.rewritten} relative import(s) for Node ESM`));
7715
7816
  if (normalized.unresolved.length > 0) {
7716
- console.log(chalk.yellow(` ⚠ ${normalized.unresolved.length} import(s) could not be resolved to a file:`));
7717
- for (const item of normalized.unresolved.slice(0, 5)) console.log(chalk.dim(` ${item}`));
7718
- if (normalized.unresolved.length > 5) console.log(chalk.dim(` … and ${normalized.unresolved.length - 5} more`));
7817
+ say(options, chalk.yellow(` ⚠ ${normalized.unresolved.length} import(s) could not be resolved to a file:`));
7818
+ for (const item of normalized.unresolved.slice(0, 5)) say(options, chalk.dim(` ${item}`));
7819
+ if (normalized.unresolved.length > 5) say(options, chalk.dim(` … and ${normalized.unresolved.length - 5} more`));
7719
7820
  }
7720
7821
  const compiledConfigDir = path.join(outDir, paths.config);
7721
7822
  const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
@@ -7751,7 +7852,7 @@ async function buildBundle(options) {
7751
7852
  ...requires.length > 0 ? { requires } : {}
7752
7853
  };
7753
7854
  });
7754
- for (const line of summarisePortability(functionReports)) console.log(line.trimStart().startsWith("⚠") ? chalk.yellow(line) : chalk.dim(line));
7855
+ for (const line of summarisePortability(functionReports)) say(options, line.trimStart().startsWith("⚠") ? chalk.yellow(line) : chalk.dim(line));
7755
7856
  const manifest = composeBundleManifest({
7756
7857
  runtimeRange: options.runtimeRange,
7757
7858
  builtAgainst: resolveServerVersion(projectRoot),
@@ -8322,7 +8423,7 @@ async function foldFrontendIntoBundle(options) {
8322
8423
  for (const app of apps) {
8323
8424
  if (app.build && !skipBuild) await execa(app.build, {
8324
8425
  cwd: projectRoot,
8325
- stdio: "inherit",
8426
+ stdio: toolStdio(options.quietStdout),
8326
8427
  shell: true,
8327
8428
  env: staticBuildEnv(app.path, app.name)
8328
8429
  });
@@ -8942,6 +9043,9 @@ async function buildCommand(rawArgs = []) {
8942
9043
  await runWorkspaceBuilds(projectRoot);
8943
9044
  return;
8944
9045
  }
9046
+ const output = args["--output"] ? path.resolve(process.cwd(), args["--output"]) : void 0;
9047
+ const bundled = targets.filter(({ app }) => app.type === "backend" && app.runtime !== "custom" || app.type === "static" && Boolean(app.build) && Boolean(app.output));
9048
+ if (output && bundled.length > 1) throw new UsageError(`--output names one bundle directory, and this build produces ${bundled.length}: ${bundled.map((t) => t.name).join(", ")}. Build one app into it: rebase build ${bundled[0].name} --output ${args["--output"]}`);
8945
9049
  console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
8946
9050
  for (const { name, app } of targets) {
8947
9051
  console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
@@ -8962,25 +9066,11 @@ async function buildCommand(rawArgs = []) {
8962
9066
  }
8963
9067
  writeResourceGraphFile(projectRoot, resourceGraph);
8964
9068
  const drift = detectFrameworkDepDrift(projectRoot, cliVersion$1());
8965
- if (drift.conflicting.length > 0) {
8966
- console.error(chalk.red(`\n ✗ ${drift.conflicting.length} dependency range(s) no single version satisfies:\n`));
8967
- for (const conflict of drift.conflicting) {
8968
- const [first, second] = conflict.declarations;
8969
- console.error(` ${chalk.bold(conflict.name)}`);
8970
- console.error(chalk.dim(` ${first.range} (${first.file})`));
8971
- console.error(chalk.dim(` ${second.range} (${second.file})`));
8972
- }
8973
- console.error("");
8974
- console.error(chalk.dim(" The bundle installs one range; the other half of the project was built"));
8975
- console.error(chalk.dim(" against the other. Declare one range, in the app that uses it.\n"));
8976
- process.exitCode = 1;
8977
- return;
8978
- }
8979
9069
  const result = await buildBundle({
8980
9070
  projectRoot,
8981
9071
  appName: name,
8982
9072
  app,
8983
- outDir: args["--output"],
9073
+ outDir: output,
8984
9074
  runtimeRange: manifest.rebase,
8985
9075
  resources: resourceGraph,
8986
9076
  skipTypeCheck: args["--skip-type-check"],
@@ -9032,7 +9122,10 @@ async function buildCommand(rawArgs = []) {
9032
9122
  });
9033
9123
  for (const outcome of folded ?? []) console.log(chalk.green(` ✓ ${outcome.appName} folded in`) + chalk.dim(` (${outcome.fileCount} file(s) → served at ${outcome.path})`));
9034
9124
  }
9035
- } else if (app.type === "static") await buildAssetApp(projectRoot, name, app, manifest.rebase, args["--output"]);
9125
+ } else if (app.type === "static") await buildAssetApp(projectRoot, name, app, manifest.rebase, output).catch((err) => {
9126
+ console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
9127
+ process.exit(1);
9128
+ });
9036
9129
  console.log("");
9037
9130
  }
9038
9131
  console.log(chalk.green("✓ Build complete."));
@@ -9044,41 +9137,40 @@ async function buildCommand(rawArgs = []) {
9044
9137
  * packages that output into a `static`-kind bundle — the same deployable shape as
9045
9138
  * a backend bundle, so a frontend or admin app deploys through the identical
9046
9139
  * path and runs on the identical image, just serving files instead of an API.
9140
+ *
9141
+ * Throws when the build fails or produces nothing usable, so the caller answers
9142
+ * in its own way: `rebase build` with a red line, `rebase cloud deploy --json`
9143
+ * with its JSON error.
9047
9144
  */
9048
- async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
9145
+ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride, options = {}) {
9049
9146
  const asset = app;
9050
9147
  const basePath = asset.path ?? "/";
9148
+ const say = (line) => {
9149
+ if (options.quietStdout) console.error(line);
9150
+ else console.log(line);
9151
+ };
9051
9152
  if (!asset.build) {
9052
- console.log(chalk.dim(" no build command declared — skipping"));
9153
+ say(chalk.dim(" no build command declared — skipping"));
9053
9154
  return;
9054
9155
  }
9055
9156
  try {
9056
9157
  await execa(asset.build, {
9057
9158
  cwd: projectRoot,
9058
- stdio: "inherit",
9159
+ stdio: toolStdio(options.quietStdout),
9059
9160
  shell: true,
9060
9161
  env: staticBuildEnv(basePath, name)
9061
9162
  });
9062
9163
  } catch {
9063
- console.error(chalk.red(` ✗ build command failed for "${name}"`));
9064
- process.exit(1);
9164
+ throw new Error(`build command failed for "${name}"`);
9065
9165
  }
9066
9166
  if (!asset.output) {
9067
- console.log(chalk.yellow(" no output directory declared — built, but nothing to bundle"));
9167
+ say(chalk.yellow(" no output directory declared — built, but nothing to bundle"));
9068
9168
  return;
9069
9169
  }
9070
9170
  const outputPath = path.join(projectRoot, asset.output);
9071
- if (!fs.existsSync(outputPath)) {
9072
- console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
9073
- process.exit(1);
9074
- }
9171
+ if (!fs.existsSync(outputPath)) throw new Error(`declared output "${asset.output}" does not exist after building`);
9075
9172
  const indexHtml = path.join(outputPath, "index.html");
9076
- if (fs.existsSync(indexHtml)) try {
9077
- assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), basePath, name);
9078
- } catch (err) {
9079
- console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
9080
- process.exit(1);
9081
- }
9173
+ if (fs.existsSync(indexHtml)) assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), basePath, name);
9082
9174
  const result = buildStaticBundle({
9083
9175
  projectRoot,
9084
9176
  appName: name,
@@ -9090,7 +9182,7 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
9090
9182
  cms: asset.cms
9091
9183
  });
9092
9184
  const rel = path.relative(projectRoot, result.outDir);
9093
- console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s) → served at ${basePath})`));
9185
+ say(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s) → served at ${basePath})`));
9094
9186
  return result.outDir;
9095
9187
  }
9096
9188
  /**
@@ -9210,7 +9302,7 @@ function renderPayload(contents, shape, projectName) {
9210
9302
  };
9211
9303
  }
9212
9304
  if (open) throw new Error(`Eject template: {{#${open.name}}} was never closed.`);
9213
- return out.join("\n").replace(/\{\{PROJECT_NAME\}\}/g, projectName);
9305
+ return out.join("\n").replace(/\{\{PROJECT_NAME\}\}/g, () => projectName);
9214
9306
  }
9215
9307
  /** Files the eject payload contributes, as `<source> → <destination>`. */
9216
9308
  var PAYLOAD = [
@@ -9781,7 +9873,59 @@ async function resetPassword(rawArgs) {
9781
9873
  env.REBASE_RESET_EMAIL = email;
9782
9874
  env.REBASE_RESET_PASSWORD = newPassword;
9783
9875
  env.REBASE_ENV_FILE_PATH = envFile || path.join(projectRoot, ".env");
9784
- const scriptContent = `
9876
+ const { prepareDatabaseEnv } = await Promise.resolve().then(() => prepare_exports);
9877
+ const prepared = await prepareDatabaseEnv(projectRoot, { onProgress: (message) => console.log(chalk.gray(` ${message}`)) });
9878
+ Object.assign(env, prepared.env);
9879
+ const scriptContent = resetPasswordScript(wasGenerated);
9880
+ const tmpScriptPath = path.join(backendDir, ".tmp-reset-password.ts");
9881
+ fs.writeFileSync(tmpScriptPath, scriptContent, "utf-8");
9882
+ console.log("");
9883
+ console.log(chalk.bold(" 🔑 Rebase Auth — Reset Password (Direct DB Fallback)"));
9884
+ console.log("");
9885
+ console.log(` ${chalk.gray("Email:")} ${email}`);
9886
+ console.log(` ${chalk.gray("Database:")} ${prepared.description}`);
9887
+ if (!wasGenerated) console.log(` ${chalk.gray("Password:")} ${"*".repeat(newPassword.length)}`);
9888
+ console.log("");
9889
+ const child = spawn(tsxBin, [tmpScriptPath], {
9890
+ cwd: backendDir,
9891
+ stdio: "inherit",
9892
+ env
9893
+ });
9894
+ const cleanup = () => {
9895
+ try {
9896
+ fs.unlinkSync(tmpScriptPath);
9897
+ } catch {}
9898
+ };
9899
+ return new Promise((resolve) => {
9900
+ child.on("error", (err) => {
9901
+ cleanup();
9902
+ console.error(chalk.red("✗ Could not run the reset script."));
9903
+ console.error(chalk.gray(` ${err.message}`));
9904
+ process.exit(1);
9905
+ });
9906
+ child.on("close", (code) => {
9907
+ cleanup();
9908
+ if (code !== 0) process.exit(code ?? 1);
9909
+ resolve();
9910
+ });
9911
+ });
9912
+ } catch (err) {
9913
+ console.error(chalk.red("✗ Direct database update failed."));
9914
+ console.error(err instanceof Error ? err.message : String(err));
9915
+ process.exit(1);
9916
+ }
9917
+ }
9918
+ /**
9919
+ * The script the direct-database fallback runs under the project's own tsx, so
9920
+ * it resolves the project's `@rebasepro/server-postgres` and schema.
9921
+ *
9922
+ * It exits 0 only when a row was updated: a missing user and a thrown error are
9923
+ * both exit 1. `echoPassword` prints the new password, for a generated one.
9924
+ *
9925
+ * Exported so its tests can run it.
9926
+ */
9927
+ function resetPasswordScript(echoPassword) {
9928
+ return `
9785
9929
  import { createPostgresDatabaseConnection } from "@rebasepro/server-postgres";
9786
9930
  import { hashPassword } from "@rebasepro/server";
9787
9931
  import { eq } from "drizzle-orm";
@@ -9826,7 +9970,7 @@ async function resetPassword() {
9826
9970
 
9827
9971
  if (result.length > 0) {
9828
9972
  console.log("✅ Password reset for: " + result[0].email);
9829
- ${wasGenerated ? "console.log(\" New password: \" + newPassword);" : ""}
9973
+ ${echoPassword ? "console.log(\" New password: \" + newPassword);" : ""}
9830
9974
  process.exit(0);
9831
9975
  }
9832
9976
  // Nothing was updated, so nothing was reset. Exiting 0 here reported
@@ -9835,44 +9979,13 @@ async function resetPassword() {
9835
9979
  process.exit(1);
9836
9980
  }
9837
9981
 
9838
- resetPassword().catch(console.error);
9982
+ // A thrown error is a failed reset, and exits like one — a refused connection
9983
+ // that exits 0 reads as a reset to anything checking the exit code.
9984
+ resetPassword().catch((error) => {
9985
+ console.error(error);
9986
+ process.exit(1);
9987
+ });
9839
9988
  `;
9840
- const tmpScriptPath = path.join(backendDir, ".tmp-reset-password.ts");
9841
- fs.writeFileSync(tmpScriptPath, scriptContent, "utf-8");
9842
- console.log("");
9843
- console.log(chalk.bold(" 🔑 Rebase Auth — Reset Password (Direct DB Fallback)"));
9844
- console.log("");
9845
- console.log(` ${chalk.gray("Email:")} ${email}`);
9846
- if (!wasGenerated) console.log(` ${chalk.gray("Password:")} ${"*".repeat(newPassword.length)}`);
9847
- console.log("");
9848
- const child = spawn(tsxBin, [tmpScriptPath], {
9849
- cwd: backendDir,
9850
- stdio: "inherit",
9851
- env
9852
- });
9853
- const cleanup = () => {
9854
- try {
9855
- fs.unlinkSync(tmpScriptPath);
9856
- } catch {}
9857
- };
9858
- return new Promise((resolve) => {
9859
- child.on("error", (err) => {
9860
- cleanup();
9861
- console.error(chalk.red("✗ Could not run the reset script."));
9862
- console.error(chalk.gray(` ${err.message}`));
9863
- process.exit(1);
9864
- });
9865
- child.on("close", (code) => {
9866
- cleanup();
9867
- if (code !== 0) process.exit(code ?? 1);
9868
- resolve();
9869
- });
9870
- });
9871
- } catch (err) {
9872
- console.error(chalk.red("✗ Direct database update failed."));
9873
- console.error(err instanceof Error ? err.message : String(err));
9874
- process.exit(1);
9875
- }
9876
9989
  }
9877
9990
  function printAuthHelp() {
9878
9991
  console.log(`
@@ -10259,7 +10372,8 @@ function findLibpqUrlProblems(projectRoot, envFile) {
10259
10372
  return findings;
10260
10373
  }
10261
10374
  /**
10262
- * Report unparseable connection strings, if any.
10375
+ * Report unparseable connection strings, if any. Returns true when there were,
10376
+ * so the finding reaches the exit code like the environment errors do.
10263
10377
  *
10264
10378
  * Runs before the plugin's drift check and never blocks it. The plugin connects
10265
10379
  * through node-postgres, which parses these URLs happily — so it cannot see this
@@ -10267,7 +10381,7 @@ function findLibpqUrlProblems(projectRoot, envFile) {
10267
10381
  * `rebase db backup` fails.
10268
10382
  */
10269
10383
  function reportLibpqUrlProblems(findings) {
10270
- if (findings.length === 0) return;
10384
+ if (findings.length === 0) return false;
10271
10385
  console.log("");
10272
10386
  console.log(chalk.red.bold(" ✗ Connection string that PostgreSQL's own tools cannot parse"));
10273
10387
  console.log("");
@@ -10287,6 +10401,7 @@ function reportLibpqUrlProblems(findings) {
10287
10401
  console.log(chalk.gray(" is why a project can look healthy and still have no working backups."));
10288
10402
  console.log(chalk.gray(" Projects scaffolded before 2026-08-18 all carry it."));
10289
10403
  console.log("");
10404
+ return true;
10290
10405
  }
10291
10406
  /**
10292
10407
  * `--help` is answered before the project guard, not after.
@@ -10592,9 +10707,10 @@ async function doctorCommand(rawArgs) {
10592
10707
  const env = { ...process.env };
10593
10708
  if (envFile) env.DOTENV_CONFIG_PATH = envFile;
10594
10709
  env[DEV_DATABASE_KIND_ENV] = devDatabaseKind(projectRoot) ?? "";
10595
- reportLibpqUrlProblems(findLibpqUrlProblems(projectRoot, envFile));
10710
+ const connectionFailed = reportLibpqUrlProblems(findLibpqUrlProblems(projectRoot, envFile));
10596
10711
  reportFunctionPortability(projectRoot);
10597
10712
  const environmentFailed = reportEnvironmentFindings(collectEnvironmentFindings(projectRoot, envFile));
10713
+ const failed = connectionFailed || environmentFailed;
10598
10714
  try {
10599
10715
  const prepared = await prepareDatabaseEnv(projectRoot, { onProgress: (message) => console.log(chalk.gray(` ${message}`)) });
10600
10716
  Object.assign(env, prepared.env);
@@ -10607,7 +10723,7 @@ async function doctorCommand(rawArgs) {
10607
10723
  if (!hasDeclaredCollections(projectRoot)) {
10608
10724
  console.log("");
10609
10725
  console.log(chalk.gray(" ○ Schema drift not checked: this project derives its API from the database — run `rebase schema introspect` first."));
10610
- if (environmentFailed) process.exit(1);
10726
+ if (failed) process.exit(1);
10611
10727
  return;
10612
10728
  }
10613
10729
  try {
@@ -10628,7 +10744,7 @@ async function doctorCommand(rawArgs) {
10628
10744
  reportSpawnFailure(error);
10629
10745
  process.exit(1);
10630
10746
  }
10631
- if (environmentFailed) process.exit(1);
10747
+ if (failed) process.exit(1);
10632
10748
  }
10633
10749
  //#endregion
10634
10750
  //#region src/commands/resources.ts
@@ -12108,7 +12224,8 @@ async function loginCommand(rawArgs) {
12108
12224
  maxPositionals: 0
12109
12225
  });
12110
12226
  refuseDirectLink(rawArgs);
12111
- const url = resolveCloudUrl(rawArgs);
12227
+ const { url, source } = resolveCloudTarget(rawArgs);
12228
+ if (source === "link" && !isKnownControlPlane(url)) await confirmLinkedControlPlane(url);
12112
12229
  noteBlank();
12113
12230
  note(`Signing in to ${chalk.cyan(url)}`);
12114
12231
  noteBlank();
@@ -12161,6 +12278,31 @@ async function loginCommand(rawArgs) {
12161
12278
  reportError(e, "Login failed");
12162
12279
  }
12163
12280
  }
12281
+ /**
12282
+ * Ask before sending a password to a control plane a repository named.
12283
+ *
12284
+ * `.rebase/cloud.json` is committed with the code, so a cloned repository can
12285
+ * point its cloud commands at any host — and "Not logged in to <host>" invites
12286
+ * the login that would hand that host the user's email and password. A host
12287
+ * the user has signed in to before, or the platform's own, is theirs; any other
12288
+ * one is named and confirmed at a terminal, and refused without one: `--url`
12289
+ * is how a script says it means that host.
12290
+ */
12291
+ async function confirmLinkedControlPlane(url) {
12292
+ const message = `This directory's .rebase/cloud.json names the control plane ${url}, which you have not signed in to before.`;
12293
+ if (isJsonMode() || process.stdin.isTTY !== true) fail(message, `Your email and password would be sent there. To sign in to it on purpose, pass --url ${url}.`, "unknown_control_plane");
12294
+ warn(message, "Your Rebase Cloud email and password will be sent to it.");
12295
+ const { confirmed } = await inquirer.prompt([{
12296
+ type: "confirm",
12297
+ name: "confirmed",
12298
+ default: false,
12299
+ message: `Sign in to ${url}?`
12300
+ }]);
12301
+ if (!confirmed) {
12302
+ console.error(chalk.gray(" Aborted."));
12303
+ process.exit(0);
12304
+ }
12305
+ }
12164
12306
  async function logoutCommand(rawArgs) {
12165
12307
  parseCloudArgs({
12166
12308
  spec: {},
@@ -13742,10 +13884,7 @@ async function dbConnect(rawArgs) {
13742
13884
  }
13743
13885
  const requested = args["--port"] ?? DEFAULT_LOCAL_PORT;
13744
13886
  if (!Number.isInteger(requested) || requested < 0 || requested > 65535) fail(`--port must be a port number, not ${requested}.`, void 0, "bad_port");
13745
- const endpoint = tunnelUrl(url, projectId);
13746
- const server = net$1.createServer((socket) => {
13747
- pipeThroughTunnel(socket, endpoint, client.auth.getSession()?.accessToken ?? "");
13748
- });
13887
+ const server = tunnelListener(client, tunnelUrl(url, projectId));
13749
13888
  server.on("error", (err) => {
13750
13889
  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");
13751
13890
  fail(`Could not open a local listener: ${err.message}`, void 0, "listen_failed");
@@ -13794,6 +13933,30 @@ async function dbConnect(rawArgs) {
13794
13933
  });
13795
13934
  }
13796
13935
  /**
13936
+ * The local listener: each connection it accepts gets its own tunnel, signed in
13937
+ * with a token that is current when the connection arrives.
13938
+ *
13939
+ * The command is left running for as long as the developer works, and the
13940
+ * access token it started with lapses within the hour. Every connection is
13941
+ * authenticated on its own, so each one reads the token then, refreshing it
13942
+ * first when it is close to expiry — rather than the one read at startup, which
13943
+ * the control plane refuses for every connection opened after it expires.
13944
+ *
13945
+ * The socket is paused while the token is read, so a client's first bytes wait
13946
+ * for the tunnel exactly as they do once it is being established.
13947
+ */
13948
+ function tunnelListener(client, endpoint) {
13949
+ return net$1.createServer((socket) => {
13950
+ socket.pause();
13951
+ freshAccessToken(client).then((token) => {
13952
+ if (!socket.destroyed) pipeThroughTunnel(socket, endpoint, token);
13953
+ }, () => {
13954
+ warn("A connection was closed: the session this tunnel signs in with has expired and could not be refreshed.", "Run `rebase cloud login`, then connect again.");
13955
+ socket.destroy();
13956
+ });
13957
+ });
13958
+ }
13959
+ /**
13797
13960
  * One accepted connection, carried over one WebSocket.
13798
13961
  *
13799
13962
  * The local socket is paused until the tunnel says `ready`, so a client that
@@ -13814,7 +13977,7 @@ function pipeThroughTunnel(socket, endpoint, token) {
13814
13977
  ws.binaryType = "arraybuffer";
13815
13978
  let ready = false;
13816
13979
  const closeBoth = (reason) => {
13817
- if (reason && !ready) note(chalk.red(`✗ ${reason}`));
13980
+ if (reason && !ready) warn(`A connection was refused: ${reason}`);
13818
13981
  try {
13819
13982
  if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
13820
13983
  } catch {}
@@ -13869,6 +14032,15 @@ function pipeThroughTunnel(socket, endpoint, token) {
13869
14032
  }
13870
14033
  //#endregion
13871
14034
  //#region src/commands/cloud/databases.ts
14035
+ /** The filename a backup is restored and downloaded by. */
14036
+ function backupName(row) {
14037
+ return row?.name ?? row?.filename;
14038
+ }
14039
+ /** A backup's size for a person: the control plane's own string, or bytes as MB. */
14040
+ function backupSize(row) {
14041
+ if (typeof row.size === "string") return row.size;
14042
+ if (typeof row.size === "number" && Number.isFinite(row.size)) return `${(row.size / 1024 / 1024).toFixed(1)} MB`;
14043
+ }
13872
14044
  async function dbCommand(subcommand, rawArgs) {
13873
14045
  switch (subcommand) {
13874
14046
  case "list":
@@ -14260,7 +14432,7 @@ async function backupCommand(rawArgs) {
14260
14432
  type: "manual"
14261
14433
  }, { path: "create" });
14262
14434
  if (!res.success) fail(res.error || "Backup failed.");
14263
- emit(() => success(`Backup created: ${res.backup?.filename ?? "(unknown)"}`), {
14435
+ emit(() => success(`Backup created: ${backupName(res.backup) ?? "(unnamed)"}`), {
14264
14436
  success: true,
14265
14437
  backup: res.backup ?? null
14266
14438
  });
@@ -14339,8 +14511,12 @@ async function backupCommand(rawArgs) {
14339
14511
  return;
14340
14512
  }
14341
14513
  for (const b of res.backups) {
14342
- const size = b.size !== void 0 ? `${(b.size / 1024 / 1024).toFixed(1)} MB` : "";
14343
- console.log(` ${chalk.bold(b.filename)} ${chalk.gray(`${b.type ?? ""} ${size}`.trim())}`);
14514
+ const detail = [
14515
+ b.type,
14516
+ backupSize(b),
14517
+ b.date
14518
+ ].filter(Boolean).join(" ");
14519
+ console.log(` ${chalk.bold(backupName(b) ?? "(unnamed)")} ${chalk.gray(detail)}`);
14344
14520
  }
14345
14521
  console.log("");
14346
14522
  }, {
@@ -15033,26 +15209,29 @@ function readBundleManifest(bundleDir) {
15033
15209
  return manifest;
15034
15210
  }
15035
15211
  /**
15212
+ * The control plane's cap on an uploaded bundle: its `MAX_BUNDLE_BYTES`, which
15213
+ * it checks before reading the body.
15214
+ */
15215
+ var MAX_BUNDLE_UPLOAD_BYTES = 100 * 1024 * 1024;
15216
+ /**
15036
15217
  * Tar a built bundle into a gzipped archive.
15037
15218
  *
15038
- * `node_modules` is excluded on purpose: the bundle ships a `package.json`, and
15039
- * the managed runtime installs the declared dependencies at boot. Uploading an
15040
- * installed `node_modules` would bloat the archive and could carry a
15041
- * platform-specific build that will not run on the runtime image.
15219
+ * `node_modules` goes in only with `withModules`: a tree the build vendored for
15220
+ * the runtime's platform is the point of vendoring, while one installed into a
15221
+ * prebuilt bundle by hand was installed for whatever machine ran it.
15042
15222
  */
15043
- function packBundle(bundleDir, outPath) {
15223
+ function packBundle(bundleDir, outPath, options = {}) {
15044
15224
  return new Promise((resolve, reject) => {
15045
15225
  const child = spawn("tar", [
15046
15226
  "-czf",
15047
15227
  outPath,
15048
15228
  "--no-xattrs",
15049
- "--exclude",
15050
- "node_modules",
15229
+ ...options.withModules ? [] : ["--exclude", "node_modules"],
15051
15230
  "-C",
15052
15231
  bundleDir,
15053
15232
  "."
15054
15233
  ], {
15055
- stdio: "inherit",
15234
+ stdio: toolStdio(true),
15056
15235
  env: {
15057
15236
  ...process.env,
15058
15237
  COPYFILE_DISABLE: "1"
@@ -15063,6 +15242,35 @@ function packBundle(bundleDir, outPath) {
15063
15242
  });
15064
15243
  }
15065
15244
  /**
15245
+ * Pack a bundle for upload — without `node_modules`, even a vendored tree.
15246
+ *
15247
+ * Vendoring exists so a pod untars and boots rather than spending 35-55s of
15248
+ * every start in `npm install`, and a deploy did carry the tree for one night
15249
+ * (c36ea4637). It is left out again because of what the control plane does
15250
+ * with the archive: `GET /bundle/:projectId/:bundleId` reads the whole object
15251
+ * into memory on every pod start, and a runtime rollout restarts every tenant's
15252
+ * pods at once. Bundles it was sized against are a few hundred kB; a vendored
15253
+ * one is tens of MB, up to the 100 MB cap — N pods starting together would hold
15254
+ * N of them. A slower cold start per pod is the status quo every deploy has
15255
+ * shipped with; a control plane out of memory is every tenant's outage. The
15256
+ * platform's own rebuilds build with `--no-vendor` for the same shape of
15257
+ * reason. When the control plane streams bundles, this is the one switch to
15258
+ * flip, and `rebase cloud deploy` builds with `vendor: false` until then.
15259
+ *
15260
+ * `modulesLeftOut` says a tree the build DID vendor was not uploaded — a
15261
+ * prebuilt `--bundle-dir` from a plain `rebase build`, which vendors by default.
15262
+ */
15263
+ async function packBundleForUpload(bundleDir, outPath, manifest, maxBytes = MAX_BUNDLE_UPLOAD_BYTES) {
15264
+ const vendored = manifest.deps?.vendored === true;
15265
+ await packBundle(bundleDir, outPath, { withModules: false });
15266
+ const bytes = fs.statSync(outPath).size;
15267
+ if (bytes > maxBytes) throw new Error(`The bundle is ${(bytes / 1024 / 1024).toFixed(1)} MB without its dependencies, over the ${Math.round(maxBytes / 1024 / 1024)} MB upload limit.`);
15268
+ return {
15269
+ bytes,
15270
+ modulesLeftOut: vendored
15271
+ };
15272
+ }
15273
+ /**
15066
15274
  * The commit HEAD is on, read here because here is the only place it exists.
15067
15275
  *
15068
15276
  * A bundle deploy has no repository anywhere near the control plane: the CLI
@@ -15198,7 +15406,8 @@ async function uploadBundle(url, token, projectId, tarPath) {
15198
15406
  * the developer considers source: `.gitignore` honoured the way git honours
15199
15407
  * it, and a sibling package the project links (`link:../../packages/editor`)
15200
15408
  * included, because it is outside the project directory but inside the
15201
- * repository.
15409
+ * repository. Outside a repository — or inside one that ignores the project —
15410
+ * the project is walked, and its `.gitignore` files still decide.
15202
15411
  * - **Secrets never leave the machine, whatever git says.** A committed `.env`
15203
15412
  * is still a `.env`. {@link neverUploaded} is applied after git, not instead
15204
15413
  * of it.
@@ -15227,12 +15436,18 @@ var ENV_TEMPLATES = /* @__PURE__ */ new Set([
15227
15436
  * bundles are never source. Every `.env` and `.env.*` is excluded except the
15228
15437
  * three template names, and so is direnv's `.envrc`, which is the same thing
15229
15438
  * under another name.
15439
+ *
15440
+ * So is what the CLI keeps on this machine: `.rebase/`, which holds the
15441
+ * development database, and the `.rebase-dev-*` files beside it, one of which
15442
+ * is the development signing secrets. And so is a `*.dump`, which is what
15443
+ * `rebase db backup` writes: every row of the database, `rebase.users`
15444
+ * password hashes included.
15230
15445
  */
15231
15446
  function neverUploaded(relativePath) {
15232
15447
  const segments = relativePath.split("/");
15233
- if (segments.some((segment) => segment === "node_modules" || segment === ".git" || segment.startsWith("dist-bundle"))) return true;
15448
+ if (segments.some((segment) => segment === "node_modules" || segment === ".git" || segment === ".rebase" || segment.startsWith("dist-bundle"))) return true;
15234
15449
  const base = segments[segments.length - 1];
15235
- if (base === ".envrc") return true;
15450
+ if (base === ".envrc" || base.startsWith(".rebase-dev-") || base.endsWith(".dump")) return true;
15236
15451
  if ((base === ".env" || base.startsWith(".env.")) && !ENV_TEMPLATES.has(base)) return true;
15237
15452
  return false;
15238
15453
  }
@@ -15245,6 +15460,10 @@ var WALK_SKIP = /* @__PURE__ */ new Set([
15245
15460
  ".next",
15246
15461
  "coverage"
15247
15462
  ]);
15463
+ /** The same directories as `ls-files` excludes, plus every `dist*` build directory. */
15464
+ var WALK_EXCLUDES = [...[...WALK_SKIP].map((name) => `--exclude=${name}/`), "--exclude=dist*/"];
15465
+ /** The file that says what a directory's source is not, which a walk has to honour. */
15466
+ var IGNORE_FILE = ".gitignore";
15248
15467
  var runGit = (cwd, args) => execFileSync("git", [
15249
15468
  "-C",
15250
15469
  cwd,
@@ -15269,29 +15488,106 @@ function isRegularFile(absolute) {
15269
15488
  return false;
15270
15489
  }
15271
15490
  }
15491
+ /** Split `-z` output into its entries. */
15492
+ function nulSeparated(output) {
15493
+ return [...new Set(output.split("\0").filter(Boolean))];
15494
+ }
15495
+ /**
15496
+ * Run `fn` with the git directory of a throwaway, empty repository.
15497
+ *
15498
+ * `git ls-files` reads `.gitignore` files only inside a repository. Pointed at
15499
+ * an empty one with `--git-dir`, and at a directory with `--work-tree`, it lists
15500
+ * that directory's files the way git would — nested ignore files, negations and
15501
+ * the user's global excludes included — without the directory being a
15502
+ * repository, or `git init` touching it.
15503
+ */
15504
+ function withScratchRepository(git, fn) {
15505
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-ignore-"));
15506
+ try {
15507
+ git(scratch, ["init", "-q"]);
15508
+ return fn(path.join(scratch, ".git"));
15509
+ } finally {
15510
+ fs.rmSync(scratch, {
15511
+ recursive: true,
15512
+ force: true
15513
+ });
15514
+ }
15515
+ }
15272
15516
  /**
15273
15517
  * The files of the repository `dir` belongs to — tracked and unignored, the way
15274
- * git sees them — or, outside any repository, `dir` walked with fixed excludes.
15518
+ * git sees them — or, when no repository holds it, `dir` walked.
15519
+ *
15520
+ * A repository that lists nothing under `dir` does not hold it: a project in a
15521
+ * directory the surrounding repository ignores (`tmp/my-app` under a repository
15522
+ * ignoring `tmp/`) has no repository of its own. Uploading that repository sent
15523
+ * everything except the project, so the project is walked instead.
15275
15524
  */
15276
15525
  function listUnit(dir, git) {
15277
15526
  try {
15278
15527
  const toplevel = git(dir, ["rev-parse", "--show-toplevel"]).trim();
15279
15528
  if (toplevel) {
15280
15529
  const root = fs.realpathSync(toplevel);
15281
- const listed = git(root, [
15530
+ const files = nulSeparated(git(root, [
15282
15531
  "ls-files",
15283
15532
  "-z",
15284
15533
  "--cached",
15285
15534
  "--others",
15286
15535
  "--exclude-standard"
15287
- ]);
15288
- return {
15536
+ ])).filter((relative) => !neverUploaded(relative) && isRegularFile(path.join(root, relative)));
15537
+ const inside = toPosix(path.relative(root, dir));
15538
+ if (inside === "" || files.some((file) => file.startsWith(`${inside}/`))) return {
15289
15539
  root,
15290
- files: [...new Set(listed.split("\0").filter(Boolean))].filter((relative) => !neverUploaded(relative) && isRegularFile(path.join(root, relative))),
15540
+ files,
15291
15541
  fromGit: true
15292
15542
  };
15293
15543
  }
15294
15544
  } catch {}
15545
+ return {
15546
+ root: dir,
15547
+ files: walkFiles(dir, git),
15548
+ fromGit: false
15549
+ };
15550
+ }
15551
+ /**
15552
+ * `dir`'s files, walked, with its `.gitignore` files honoured.
15553
+ *
15554
+ * A scaffold made without `git init` still carries the stock `.gitignore`, and
15555
+ * it is the only statement of what in the directory is not source: `backups/`
15556
+ * with its database dumps, `uploads/` with the users' files. Git reads it,
15557
+ * through a throwaway repository (see {@link withScratchRepository}); a
15558
+ * repository nested in `dir` lists as one directory entry, and is listed as the
15559
+ * repository it is.
15560
+ *
15561
+ * Without git the walk has fixed excludes alone, and those cannot read an
15562
+ * ignore file. So a directory holding one is refused rather than walked past
15563
+ * it — the developer wrote down what must stay on this machine.
15564
+ */
15565
+ function walkFiles(dir, git) {
15566
+ let listed;
15567
+ try {
15568
+ listed = withScratchRepository(git, (gitDir) => nulSeparated(git(dir, [
15569
+ `--git-dir=${gitDir}`,
15570
+ `--work-tree=${dir}`,
15571
+ "ls-files",
15572
+ "-z",
15573
+ "--others",
15574
+ "--exclude-standard",
15575
+ ...WALK_EXCLUDES
15576
+ ])));
15577
+ } catch {
15578
+ return walkWithoutGit(dir);
15579
+ }
15580
+ const files = [];
15581
+ for (const relative of listed) if (relative.endsWith("/")) {
15582
+ const nested = listUnit(path.join(dir, relative), git);
15583
+ const prefix = toPosix(path.relative(dir, nested.root));
15584
+ if (prefix.startsWith("..") || path.isAbsolute(prefix)) continue;
15585
+ for (const file of nested.files) files.push(prefix ? `${prefix}/${file}` : file);
15586
+ } else if (!neverUploaded(relative) && isRegularFile(path.join(dir, relative))) files.push(relative);
15587
+ return files;
15588
+ }
15589
+ /** The walk with fixed excludes, for a machine with no git. Refuses a tree with an ignore file in it. */
15590
+ function walkWithoutGit(dir) {
15295
15591
  const files = [];
15296
15592
  const walk = (current, prefix) => {
15297
15593
  let entries;
@@ -15305,15 +15601,12 @@ function listUnit(dir, git) {
15305
15601
  if (entry.isDirectory()) {
15306
15602
  if (WALK_SKIP.has(entry.name) || entry.name.startsWith("dist")) continue;
15307
15603
  walk(path.join(current, entry.name), relative);
15308
- } else if (entry.isFile() && !neverUploaded(relative)) files.push(relative);
15604
+ } else if (entry.isFile() && entry.name === IGNORE_FILE) throw new Error(`${relative} says what is not source, and reading it takes git, which is not available here`);
15605
+ else if (entry.isFile() && !neverUploaded(relative)) files.push(relative);
15309
15606
  }
15310
15607
  };
15311
15608
  walk(dir, "");
15312
- return {
15313
- root: dir,
15314
- files,
15315
- fromGit: false
15316
- };
15609
+ return files;
15317
15610
  }
15318
15611
  /** Whether `candidate` is `dir` or inside it. */
15319
15612
  function isWithin(dir, candidate) {
@@ -15408,6 +15701,42 @@ function listSourceFiles(projectRoot, git = runGit) {
15408
15701
  fromGit: units.every((u) => u.fromGit)
15409
15702
  };
15410
15703
  }
15704
+ /** The per-directory file of patterns a `--source` build context leaves out, beside `.gitignore`. */
15705
+ var CONTEXT_IGNORE_FILE = ".rebaseignore";
15706
+ /**
15707
+ * The files of a `--source` build context: `dir` and what is under it, relative
15708
+ * to it, POSIX, sorted.
15709
+ *
15710
+ * The same rules as the rebuild source — git decides, and {@link neverUploaded}
15711
+ * after it — restricted to `dir`, which is the context's root, so a Dockerfile
15712
+ * in it still reads its paths from there. A `dir` that is a subfolder of a
15713
+ * repository is held to that repository's `.gitignore`, which is where a
15714
+ * monorepo keeps it.
15715
+ *
15716
+ * `.rebaseignore` in `dir` leaves out more, read with `.gitignore` syntax and
15717
+ * anchored at `dir`. It needs git to be read, like `.gitignore` outside a
15718
+ * repository; without git, a context that has one is refused.
15719
+ */
15720
+ function listContextFiles(dir, git = runGit) {
15721
+ const root = fs.realpathSync(dir);
15722
+ const unit = listUnit(root, git);
15723
+ const prefix = toPosix(path.relative(unit.root, root));
15724
+ let files = prefix === "" ? unit.files : unit.files.filter((file) => file.startsWith(`${prefix}/`)).map((file) => file.slice(prefix.length + 1));
15725
+ const contextIgnore = path.join(root, CONTEXT_IGNORE_FILE);
15726
+ if (fs.existsSync(contextIgnore)) {
15727
+ const ignored = new Set(withScratchRepository(git, (gitDir) => nulSeparated(git(root, [
15728
+ `--git-dir=${gitDir}`,
15729
+ `--work-tree=${root}`,
15730
+ "ls-files",
15731
+ "-z",
15732
+ "--others",
15733
+ "--ignored",
15734
+ `--exclude-from=${contextIgnore}`
15735
+ ]))));
15736
+ files = files.filter((file) => !ignored.has(file));
15737
+ }
15738
+ return [...files].sort();
15739
+ }
15411
15740
  /**
15412
15741
  * Pack a listing into a gzipped tarball at `outPath`.
15413
15742
  *
@@ -15552,7 +15881,7 @@ async function prepareRebuildSource(opts) {
15552
15881
  }
15553
15882
  opts.progress(` Uploading source (${listing.files.length} files, ${mb(size)} MB)...`);
15554
15883
  return {
15555
- sourceId: await steps.upload(opts.url, opts.token, opts.projectId, tarPath),
15884
+ sourceId: await steps.upload(opts.url, await opts.token(), opts.projectId, tarPath),
15556
15885
  projectPath: listing.projectPath,
15557
15886
  buildEnv: collectBuildEnv(opts.projectRoot, staticAppRoots(opts.manifest))
15558
15887
  };
@@ -15567,48 +15896,31 @@ async function prepareRebuildSource(opts) {
15567
15896
  //#region src/commands/cloud/deploy.ts
15568
15897
  var POLL_INTERVAL_MS = 1500;
15569
15898
  var POLL_TIMEOUT_MS = 900 * 1e3;
15899
+ /** How long a follow keeps retrying a status it cannot read before it says so. */
15900
+ var UNREADABLE_STATUS_MS = 60 * 1e3;
15570
15901
  function sleep(ms) {
15571
15902
  return new Promise((r) => setTimeout(r, ms));
15572
15903
  }
15573
- function run(cmd, cmdArgs, cwd, env) {
15574
- return new Promise((resolve, reject) => {
15575
- const child = spawn(cmd, cmdArgs, {
15576
- cwd,
15577
- env: env ? {
15578
- ...process.env,
15579
- ...env
15580
- } : void 0,
15581
- stdio: [
15582
- "ignore",
15583
- "ignore",
15584
- "pipe"
15585
- ]
15586
- });
15587
- let stderr = "";
15588
- child.stderr.on("data", (d) => stderr += d.toString());
15589
- child.on("error", reject);
15590
- child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr || `${cmd} exited ${code}`)));
15591
- });
15592
- }
15593
15904
  /**
15594
- * Package `sourceDir` into a gzipped tarball, honoring `.gitignore`/`.rebaseignore`
15595
- * and always excluding `.git` and `node_modules`. Returns the temp archive path.
15905
+ * Package `sourceDir` into a gzipped tarball; returns the temp archive path.
15906
+ *
15907
+ * What goes in is {@link listContextFiles}: `.gitignore` read the way git reads
15908
+ * it, `.rebaseignore` beside it, and never an env file, a database dump or the
15909
+ * CLI's local state, whatever either says. The control plane keeps the archive
15910
+ * as the project's source, so this is the last point anything can be kept back.
15596
15911
  */
15597
15912
  async function createSourceTarball(sourceDir) {
15598
15913
  const dir = path.resolve(sourceDir);
15599
15914
  if (!fs.existsSync(dir)) fail(`Source directory not found: ${dir}`);
15600
15915
  const tarPath = path.join(os.tmpdir(), `rebase-src-${Date.now()}.tar.gz`);
15601
- const tarArgs = [
15602
- "-czf",
15603
- tarPath,
15604
- "--exclude=.git",
15605
- "--exclude=node_modules"
15606
- ];
15607
- for (const ignore of [".gitignore", ".rebaseignore"]) if (fs.existsSync(path.join(dir, ignore))) tarArgs.push(`--exclude-from=${ignore}`);
15608
- tarArgs.push(".");
15609
15916
  try {
15610
- await run("tar", tarArgs, dir, { COPYFILE_DISABLE: "1" });
15917
+ const files = listContextFiles(dir);
15918
+ await packSource({
15919
+ root: fs.realpathSync(dir),
15920
+ files
15921
+ }, tarPath);
15611
15922
  } catch (e) {
15923
+ fs.rmSync(tarPath, { force: true });
15612
15924
  fail(`Failed to package source: ${e instanceof Error ? e.message : String(e)}`);
15613
15925
  }
15614
15926
  return tarPath;
@@ -15654,6 +15966,22 @@ function resolveFrameworkVersion(sourceDir) {
15654
15966
  function progress(line) {
15655
15967
  if (!isJsonMode()) console.log(line);
15656
15968
  }
15969
+ /**
15970
+ * The token for one of the raw uploads below, read as it is sent.
15971
+ *
15972
+ * They are plain `fetch` calls, which never refresh a session the way the
15973
+ * SDK's own requests do, and each is sent after work that can outlive a token:
15974
+ * a type check and a build, or packing a large directory. Read once at the
15975
+ * start, the token had lapsed by then, the upload was refused with a 401, and
15976
+ * the build was thrown away.
15977
+ */
15978
+ async function uploadToken(client, url) {
15979
+ try {
15980
+ return await freshAccessToken(client);
15981
+ } catch {
15982
+ fail(`Your session for ${url} has expired.`, "Run `rebase cloud login` to sign in again, then deploy again.", "session_expired");
15983
+ }
15984
+ }
15657
15985
  /** Upload a build-context tarball; returns the opaque `source` ref for deploy. */
15658
15986
  async function uploadSource(url, token, projectId, tarPath) {
15659
15987
  const bytes = fs.readFileSync(tarPath);
@@ -15680,14 +16008,20 @@ async function uploadSource(url, token, projectId, tarPath) {
15680
16008
  * Build, upload and deploy a project as a managed bundle.
15681
16009
  *
15682
16010
  * Builds the backend app into `dist-bundle` (unless one is pointed at with
15683
- * `--bundle-dir`), packs it without `node_modules`, uploads it, and triggers a
15684
- * deploy carrying the manifest so the control plane can validate intake fast.
16011
+ * `--bundle-dir`), packs it with the dependency tree the build vendored into it,
16012
+ * uploads it, and triggers a deploy carrying the manifest so the control plane
16013
+ * can validate intake fast.
15685
16014
  */
15686
16015
  async function deployBundle(opts) {
15687
16016
  const projectRoot = requireProjectRoot();
15688
16017
  let bundleDir = opts.bundleDir ? path.resolve(process.cwd(), opts.bundleDir) : path.join(projectRoot, "dist-bundle");
15689
16018
  if (!opts.bundleDir) {
15690
- const loaded = loadManifest(projectRoot);
16019
+ let loaded;
16020
+ try {
16021
+ loaded = loadManifest(projectRoot);
16022
+ } catch (err) {
16023
+ fail(err instanceof Error ? err.message : String(err), void 0, "invalid_manifest");
16024
+ }
15691
16025
  let target;
15692
16026
  try {
15693
16027
  target = selectDeployApp(loaded.manifest, opts.appName);
@@ -15695,9 +16029,15 @@ async function deployBundle(opts) {
15695
16029
  fail(err instanceof Error ? err.message : String(err));
15696
16030
  return;
15697
16031
  }
16032
+ if (target.app.type === "static" && opts.noStatic) fail(`--no-static leaves a backend's frontend out of its bundle, and "${target.name}" is a static app: its bundle is nothing but its frontend.`, "Deploy it without --no-static.", "usage");
15698
16033
  if (target.app.type === "static") {
15699
16034
  progress(chalk.gray(` Building static app "${target.name}"...`));
15700
- const staticDir = await buildAssetApp(projectRoot, target.name, target.app, loaded.manifest.rebase);
16035
+ let staticDir;
16036
+ try {
16037
+ staticDir = await buildAssetApp(projectRoot, target.name, target.app, loaded.manifest.rebase, void 0, { quietStdout: isJsonMode() });
16038
+ } catch (err) {
16039
+ fail(err instanceof Error ? err.message : String(err));
16040
+ }
15701
16041
  if (!staticDir) {
15702
16042
  fail(`App "${target.name}" produced no bundle.`, "A static app needs both a `build` command and an `output` directory in rebase.json.");
15703
16043
  return;
@@ -15717,22 +16057,32 @@ async function deployBundle(opts) {
15717
16057
  app: target.app
15718
16058
  };
15719
16059
  const { graph: resourceGraph, issues: resourceIssues } = await deriveResourceGraph(deriveOptionsFor(projectRoot, backend.app));
15720
- if (resourceIssues.length > 0) throw new Error(`${resourceIssues.length} problem(s) in the declared resources:\n` + resourceIssues.map((i) => ` ${i.path} ${i.message}`).join("\n"));
15721
- bundleDir = (await buildBundle({
15722
- projectRoot,
15723
- appName: backend.name,
15724
- app: backend.app,
15725
- runtimeRange: loaded.manifest.rebase,
15726
- resources: resourceGraph,
15727
- skipTypeCheck: opts.skipTypeCheck,
15728
- log: (m) => progress(chalk.gray(m))
15729
- })).outDir;
16060
+ if (resourceIssues.length > 0) fail(`${resourceIssues.length} problem(s) in the declared resources:\n` + resourceIssues.map((i) => ` ${i.path} ${i.message}`).join("\n"), void 0, "build_failed");
16061
+ let result;
15730
16062
  try {
16063
+ result = await buildBundle({
16064
+ projectRoot,
16065
+ appName: backend.name,
16066
+ app: backend.app,
16067
+ runtimeRange: loaded.manifest.rebase,
16068
+ resources: resourceGraph,
16069
+ skipTypeCheck: opts.skipTypeCheck,
16070
+ skipSchema: opts.skipSchema,
16071
+ vendor: false,
16072
+ log: (m) => progress(chalk.gray(m)),
16073
+ quietStdout: isJsonMode()
16074
+ });
16075
+ } catch (err) {
16076
+ fail(err instanceof Error ? err.message : String(err), void 0, "build_failed");
16077
+ }
16078
+ bundleDir = result.outDir;
16079
+ if (!opts.noStatic) try {
15731
16080
  const folded = await foldFrontendIntoBundle({
15732
16081
  projectRoot,
15733
16082
  manifest: loaded.manifest,
15734
16083
  bundleDir,
15735
- log: (m) => progress(m)
16084
+ log: (m) => progress(m),
16085
+ quietStdout: isJsonMode()
15736
16086
  });
15737
16087
  for (const outcome of folded) progress(chalk.gray(` folded ${outcome.appName} in (${outcome.fileCount} file(s), served at ${outcome.path})`));
15738
16088
  } catch (err) {
@@ -15757,20 +16107,32 @@ async function deployBundle(opts) {
15757
16107
  */
15758
16108
  async function uploadAndTrigger(opts) {
15759
16109
  const { client, url, projectId, projectRef, projectRoot, bundleDir } = opts;
15760
- const manifest = readBundleManifest(bundleDir);
16110
+ let manifest;
16111
+ try {
16112
+ manifest = readBundleManifest(bundleDir);
16113
+ } catch (err) {
16114
+ fail(err instanceof Error ? err.message : String(err), void 0, "invalid_bundle");
16115
+ }
16116
+ const record = (followed, status) => recordDeploy({
16117
+ followed,
16118
+ status,
16119
+ deduplicated: false,
16120
+ frameworkVersion: manifest.runtime?.builtAgainst,
16121
+ startedAt: opts.startedAt,
16122
+ projectRoot
16123
+ });
15761
16124
  if (manifest.hooks?.native) {
15762
16125
  const names = (manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
15763
16126
  fail(`This bundle depends on native modules${names ? ` (${names})` : ""}, which the managed runtime cannot run.`, "Remove the native dependency, or deploy on the custom runtime.");
15764
16127
  }
15765
16128
  const tarPath = path.join(os.tmpdir(), `rebase-bundle-${Date.now()}.tar.gz`);
15766
- const token = client.auth.getSession()?.accessToken;
15767
- if (!token) fail("Not authenticated.", "Run `rebase cloud login`.");
15768
16129
  let bundleId;
15769
16130
  try {
15770
- await packBundle(bundleDir, tarPath);
15771
- const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(1);
16131
+ const packed = await packBundleForUpload(bundleDir, tarPath, manifest);
16132
+ if (packed.modulesLeftOut) warn("This bundle's installed dependencies are not uploaded: a managed pod installs them when it starts.", "Build it with `rebase build --no-vendor` to skip installing a tree the upload leaves out.");
16133
+ const sizeMb = (packed.bytes / 1024 / 1024).toFixed(1);
15772
16134
  progress(chalk.gray(` Uploading bundle (${sizeMb} MB)...`));
15773
- bundleId = await uploadBundle(url, token, projectId, tarPath);
16135
+ bundleId = await uploadBundle(url, await uploadToken(client, url), projectId, tarPath);
15774
16136
  } catch (e) {
15775
16137
  fail(e instanceof Error ? e.message : String(e));
15776
16138
  return;
@@ -15791,7 +16153,7 @@ async function uploadAndTrigger(opts) {
15791
16153
  else if (opts.uploadSource && manifest.kind !== "static") rebuildSource = await prepareRebuildSource({
15792
16154
  projectRoot,
15793
16155
  url,
15794
- token,
16156
+ token: () => freshAccessToken(client),
15795
16157
  projectId,
15796
16158
  manifest: projectManifest,
15797
16159
  progress: (line) => progress(chalk.gray(line)),
@@ -15831,6 +16193,7 @@ async function uploadAndTrigger(opts) {
15831
16193
  sourceUploaded: rebuildSource !== null,
15832
16194
  following: false
15833
16195
  });
16196
+ await record(false, "not_followed");
15834
16197
  return;
15835
16198
  }
15836
16199
  if (!isJsonMode()) {
@@ -15838,11 +16201,12 @@ async function uploadAndTrigger(opts) {
15838
16201
  console.log(chalk.gray(" Streaming build logs (Ctrl-C to stop watching — the build keeps running):"));
15839
16202
  console.log("");
15840
16203
  }
15841
- const status = await streamBuildLogs(client, deploymentId, {
16204
+ const { status } = await streamBuildLogs(client, deploymentId, {
15842
16205
  quiet: isJsonMode(),
15843
16206
  timeoutMs: opts.timeoutMs,
15844
16207
  projectId,
15845
- url
16208
+ url,
16209
+ onTerminal: (ended) => record(true, ended)
15846
16210
  });
15847
16211
  emit(() => {}, {
15848
16212
  success: true,
@@ -15885,10 +16249,42 @@ function isManagedProject(project, latest) {
15885
16249
  if (pick(project, "runtimeMode", "runtime_mode") === "managed") return true;
15886
16250
  return latest?.status === "success" && pick(latest, "bundleId", "bundle_id") !== void 0;
15887
16251
  }
15888
- /** What a `deploy` with nothing attached will build, in the words to print. */
15889
- function planBareDeploy(project, latest, now) {
16252
+ /** A row's own `createdAt`, in ms; 0 when it has none that parses. */
16253
+ function createdAtMs(row) {
16254
+ const raw = row.createdAt ?? row.created_at;
16255
+ const at = raw instanceof Date ? raw.getTime() : typeof raw === "string" ? new Date(raw).getTime() : NaN;
16256
+ return Number.isNaN(at) ? 0 : at;
16257
+ }
16258
+ /**
16259
+ * The deployment whose uploaded archive a bare deploy rebuilds, if any.
16260
+ *
16261
+ * The control plane's own rule (`reusableSourceRef`, over the newest 25 rows):
16262
+ * the newest row, by its own `createdAt`, carrying a build-context ref. Not the
16263
+ * newest row — a rollback or a bundle deploy carries none, and the upload
16264
+ * rebuilt then is the one before it.
16265
+ */
16266
+ function snapshotDeployment(recent) {
16267
+ let best;
16268
+ for (const row of recent) {
16269
+ const ref = pick(row, "sourceRef", "source_ref");
16270
+ if (!ref || ref.includes("..") || !ref.endsWith(".tar.gz")) continue;
16271
+ const at = createdAtMs(row);
16272
+ if (!best || at > best.at) best = {
16273
+ row,
16274
+ at
16275
+ };
16276
+ }
16277
+ return best?.row;
16278
+ }
16279
+ /**
16280
+ * What a `deploy` with nothing attached will build, in the words to print.
16281
+ *
16282
+ * `recent` is the project's newest deployments, newest first — the same page
16283
+ * the control plane reads to choose an archive.
16284
+ */
16285
+ function planBareDeploy(project, recent, now) {
15890
16286
  const projectRow = project;
15891
- const deploymentRow = latest;
16287
+ const latest = recent[0];
15892
16288
  const managed = isManagedProject(project, latest);
15893
16289
  const repo = pick(projectRow, "gitRepoUrl", "git_repo_url");
15894
16290
  if (repo) {
@@ -15899,12 +16295,16 @@ function planBareDeploy(project, latest, now) {
15899
16295
  lines: [`Building from git: ${repo}${branch ? ` (${branch})` : ""}.`]
15900
16296
  };
15901
16297
  }
15902
- if (pick(deploymentRow, "sourceRef", "source_ref")) {
15903
- const age = timeAgo(latest?.createdAt ?? latest?.created_at, now);
16298
+ const snapshot = snapshotDeployment(recent);
16299
+ if (snapshot) {
16300
+ const age = timeAgo(snapshot.createdAt ?? snapshot.created_at, now);
16301
+ const lines = [`Rebuilding the stored source archive${snapshot.id !== void 0 ? ` from deployment ${snapshot.id}` : ""}${age ? `, uploaded ${age}` : ""}.`];
16302
+ if (latest && snapshot !== latest && latest.id !== void 0) lines.push(`Deployment ${latest.id}, the newest, carried no archive of its own, so this is not what it deployed.`);
16303
+ lines.push("This directory is NOT uploaded — pass `--source .` to build what is on disk.");
15904
16304
  return {
15905
16305
  managed,
15906
16306
  source: "snapshot",
15907
- lines: [`Rebuilding the stored source archive${latest?.id !== void 0 ? ` from deployment ${latest.id}` : ""}${age ? `, uploaded ${age}` : ""}.`, "This directory is NOT uploaded — pass `--source .` to build what is on disk."]
16307
+ lines
15908
16308
  };
15909
16309
  }
15910
16310
  return {
@@ -15992,22 +16392,28 @@ function warningPayload(warnings) {
15992
16392
  };
15993
16393
  }
15994
16394
  /**
15995
- * Read the two rows the preflight needs.
16395
+ * Read what the preflight needs: the project row and its newest deployments.
15996
16396
  *
15997
- * Best effort by construction: a preflight that cannot read is a preflight that
15998
- * says nothing, never a deploy that fails. The managed refusal rides on the same
15999
- * read, so an unreadable project falls through to the old behaviour rather than
16000
- * blocking a deploy on a lookup.
16397
+ * `readable: false` when either read failed. The plan lines are best effort and
16398
+ * say nothing then; the managed refusal is not — see the caller.
16001
16399
  */
16002
16400
  async function readDeployContext(client, projectId) {
16003
16401
  try {
16004
- const [project, latest] = await Promise.all([client.data.collection("projects").findById(projectId), latestDeployment(client, projectId)]);
16402
+ const [project, recent] = await Promise.all([client.data.collection("projects").findById(projectId), client.data.collection("deployments").find({
16403
+ where: { project: ["==", projectId] },
16404
+ orderBy: ["createdAt", "desc"],
16405
+ limit: 25
16406
+ })]);
16005
16407
  return {
16006
16408
  project,
16007
- latest
16409
+ recent: recent.data,
16410
+ readable: true
16008
16411
  };
16009
16412
  } catch {
16010
- return {};
16413
+ return {
16414
+ recent: [],
16415
+ readable: false
16416
+ };
16011
16417
  }
16012
16418
  }
16013
16419
  /**
@@ -16117,6 +16523,8 @@ var DEPLOY_FLAGS = {
16117
16523
  "--bundle": Boolean,
16118
16524
  "--bundle-dir": String,
16119
16525
  "--skip-type-check": Boolean,
16526
+ "--no-static": Boolean,
16527
+ "--skip-schema": Boolean,
16120
16528
  "--eject": Boolean,
16121
16529
  "--no-source": Boolean,
16122
16530
  "--allow-downgrade": Boolean,
@@ -16178,7 +16586,7 @@ async function recordDeploy(o) {
16178
16586
  deduplicated: o.deduplicated,
16179
16587
  framework_version: o.frameworkVersion ?? null,
16180
16588
  duration: durationBucket(Date.now() - o.startedAt)
16181
- }, { projectRoot: process.cwd() });
16589
+ }, { projectRoot: o.projectRoot ?? process.cwd() });
16182
16590
  }
16183
16591
  async function deployCommand(rawArgs, projectRef) {
16184
16592
  const startedAt = Date.now();
@@ -16201,15 +16609,19 @@ async function deployCommand(rawArgs, projectRef) {
16201
16609
  message: args["--message"],
16202
16610
  appName,
16203
16611
  skipTypeCheck: args["--skip-type-check"] === true,
16612
+ skipSchema: args["--skip-schema"] === true,
16613
+ noStatic: args["--no-static"] === true,
16204
16614
  follow: args["--no-follow"] !== true,
16205
16615
  timeoutMs: resolveDeployTimeout(args["--timeout"]),
16206
16616
  uploadSource: args["--no-source"] !== true,
16207
- allowDowngrade: args["--allow-downgrade"] === true
16617
+ allowDowngrade: args["--allow-downgrade"] === true,
16618
+ startedAt
16208
16619
  });
16209
16620
  return;
16210
16621
  }
16211
- const { project, latest } = await readDeployContext(client, projectId);
16212
- const plan = planBareDeploy(project, latest, /* @__PURE__ */ new Date());
16622
+ const { project, recent, readable } = await readDeployContext(client, projectId);
16623
+ const plan = planBareDeploy(project, recent, /* @__PURE__ */ new Date());
16624
+ if (!readable && args["--eject"] !== true) fail(`Could not read ${projectRef}, so this deploy cannot tell whether it would eject the project from the managed runtime.`, "Try again in a moment. If leaving the managed runtime is intended, pass `--eject`.", "preflight_unreadable");
16213
16625
  const eject = {
16214
16626
  managed: plan.managed,
16215
16627
  source: Boolean(args["--source"]),
@@ -16227,9 +16639,7 @@ async function deployCommand(rawArgs, projectRef) {
16227
16639
  if (args["--source"]) {
16228
16640
  const tarPath = await createSourceTarball(args["--source"]);
16229
16641
  try {
16230
- const token = client.auth.getSession()?.accessToken;
16231
- if (!token) fail("Not authenticated.", "Run `rebase cloud login`.");
16232
- source = await uploadSource(url, token, projectId, tarPath);
16642
+ source = await uploadSource(url, await uploadToken(client, url), projectId, tarPath);
16233
16643
  } finally {
16234
16644
  fs.rmSync(tarPath, { force: true });
16235
16645
  }
@@ -16253,7 +16663,7 @@ async function deployCommand(rawArgs, projectRef) {
16253
16663
  deduplicated: res.deduplicated === true
16254
16664
  };
16255
16665
  } catch (e) {
16256
- triggered = resolveTriggerFailure(e);
16666
+ resolveTriggerFailure(e);
16257
16667
  }
16258
16668
  const { deploymentId, deduplicated } = triggered;
16259
16669
  if (!isJsonMode()) console.log(chalk.gray(deduplicated ? ` Deployment ${deploymentId} is already running — following it.` : ` Deployment ${deploymentId} created.${frameworkVersion ? ` (@rebasepro/* ${frameworkVersion})` : ""}`));
@@ -16281,18 +16691,18 @@ async function deployCommand(rawArgs, projectRef) {
16281
16691
  console.log(chalk.gray(" Streaming build logs (Ctrl-C to stop watching — the build keeps running):"));
16282
16692
  console.log("");
16283
16693
  }
16284
- const status = await streamBuildLogs(client, deploymentId, {
16694
+ const { status } = await streamBuildLogs(client, deploymentId, {
16285
16695
  quiet: isJsonMode(),
16286
16696
  timeoutMs: resolveDeployTimeout(args["--timeout"]),
16287
16697
  projectId,
16288
- url
16289
- });
16290
- await recordDeploy({
16291
- followed: true,
16292
- status,
16293
- deduplicated,
16294
- frameworkVersion,
16295
- startedAt
16698
+ url,
16699
+ onTerminal: (ended) => recordDeploy({
16700
+ followed: true,
16701
+ status: ended,
16702
+ deduplicated,
16703
+ frameworkVersion,
16704
+ startedAt
16705
+ })
16296
16706
  });
16297
16707
  emit(() => {}, {
16298
16708
  deploymentId,
@@ -16311,29 +16721,26 @@ function resolveDeployTimeout(value) {
16311
16721
  });
16312
16722
  }
16313
16723
  /**
16314
- * Turn a failed trigger into either a deployment to follow, or an exit.
16724
+ * Turn a failed trigger into an exit that says why.
16315
16725
  *
16316
- * The 409 is the interesting one. A deploy trigger can reach the control plane
16317
- * twice without anybody asking twice — the SDK transport replays a request once
16318
- * after refreshing an expired token, and any lost response has the same effect
16319
- * — so "a deployment is already in progress" was routinely describing the
16320
- * deployment this very command had just created. With no id in the message the
16321
- * only available reading was "someone else is deploying, back off", and the
16322
- * build stream was lost either way.
16726
+ * A 409 is a refusal, whoever holds the lock. The control plane answers a
16727
+ * replay of this very trigger — the same user sending the same uploaded
16728
+ * archive — with a 200 carrying `deduplicated: true`, which the success path
16729
+ * follows. So the deployment a 409 names is always a different one, and
16730
+ * `mine` means only that the same user started it: an earlier deploy, building
16731
+ * earlier code. Following it reported that deploy's verdict as this one's, and
16732
+ * the code just uploaded was never built.
16323
16733
  *
16324
- * So: if the control plane says the blocking deployment is ours, we attach to
16325
- * it. If it is not ours, we still name it, because "which one, since when, from
16326
- * where" is the difference between an actionable refusal and a dead end.
16734
+ * The refusal names the blocker, because "which one, since when, from where"
16735
+ * is the difference between an actionable refusal and a dead end.
16327
16736
  */
16328
16737
  function resolveTriggerFailure(e) {
16329
16738
  const err = e;
16330
16739
  if (err?.status === 409) {
16331
16740
  const blocking = err.details?.deployment;
16332
- if (blocking?.id && blocking.mine) return {
16333
- deploymentId: String(blocking.id),
16334
- deduplicated: true
16335
- };
16336
- fail(blocking?.id ? `Deployment ${blocking.id} is already in progress for this project${blocking.triggerSource && blocking.triggerSource !== "unknown" ? `, triggered from the ${blocking.triggerSource}` : ""}${blocking.createdAt ? ` at ${fmtDate(blocking.createdAt)}` : ""}.` : "A deployment is already in progress for this project.", blocking?.id ? `Follow it with \`rebase cloud logs -f\`, or stop it with \`rebase cloud cancel ${blocking.id}\`.` : "Follow it with `rebase cloud logs -f`.", "deploy_in_progress");
16741
+ const where = blocking?.triggerSource && blocking.triggerSource !== "unknown" ? ` from the ${blocking.triggerSource}` : "";
16742
+ const who = blocking?.mine ? ", triggered by you" : where ? ", triggered" : "";
16743
+ fail(blocking?.id ? `Deployment ${blocking.id} is already in progress for this project${who}${where}${blocking.createdAt ? ` at ${fmtDate(blocking.createdAt)}` : ""}.` : "A deployment is already in progress for this project.", blocking?.id ? `Nothing was deployed. Wait for it (\`rebase cloud logs -f\`) or stop it with \`rebase cloud cancel ${blocking.id}\`, then deploy again.` : "Nothing was deployed. Wait for it (`rebase cloud logs -f`), then deploy again.", "deploy_in_progress");
16337
16744
  }
16338
16745
  if (err?.status === 402) fail(err.message || "Payment required before deploying.", "Attach a card once with `rebase cloud billing setup`, then deploy again.", "payment_required");
16339
16746
  const refusal = intakeRefusal(e);
@@ -16416,23 +16823,95 @@ async function deployedUrl(client, opts) {
16416
16823
  return;
16417
16824
  }
16418
16825
  }
16826
+ /**
16827
+ * The line an in-progress build keeps rewriting at the end of its log, to prove
16828
+ * to the control plane that it is still alive.
16829
+ *
16830
+ * The control plane's own format — `HEARTBEAT_MARKER` and `heartbeatSuffix` in
16831
+ * its `functions/deploy.ts` — which appends `\n<marker><ISO time>\n` to the log
16832
+ * on every write and every 30 seconds, and drops it on the terminal write.
16833
+ */
16834
+ var BUILD_HEARTBEAT_MARKER = "⏳ Build in progress — control-plane heartbeat ";
16835
+ /**
16836
+ * A deployment's log without its trailing heartbeat line: the build log itself.
16837
+ *
16838
+ * Only a heartbeat that is the last line is one. The same text anywhere else is
16839
+ * the build quoting it.
16840
+ */
16841
+ function withoutHeartbeat(logs) {
16842
+ const at = logs.lastIndexOf(`\n${BUILD_HEARTBEAT_MARKER}`);
16843
+ if (at < 0) return logs;
16844
+ const newline = logs.indexOf("\n", at + 1);
16845
+ return newline === -1 || newline === logs.length - 1 ? logs.slice(0, at) : logs;
16846
+ }
16847
+ /**
16848
+ * What to print of a deployment's log, given everything printed so far.
16849
+ *
16850
+ * The `logs` column is not append-only. Its heartbeat line is rewritten at the
16851
+ * end on every write, and a failed static deploy replaces its whole log with
16852
+ * one line. Diffing the raw column by length therefore cut the start off every
16853
+ * new chunk — as many characters as the heartbeat that had been there — and
16854
+ * printed heartbeat fragments in their place: a failed managed deploy lost the
16855
+ * first half of its reason.
16856
+ *
16857
+ * So the heartbeat is stripped first, and the log printed so far is compared
16858
+ * with the log now. When it has only grown, the new text is printed. When it
16859
+ * was rewritten, printing resumes from the start of the first line that
16860
+ * differs, on a line of its own.
16861
+ */
16862
+ function nextLogChunk(printed, rowLogs) {
16863
+ const logs = withoutHeartbeat(rowLogs);
16864
+ if (logs.startsWith(printed)) return {
16865
+ chunk: logs.slice(printed.length),
16866
+ printed: logs
16867
+ };
16868
+ if (printed.startsWith(logs)) return {
16869
+ chunk: "",
16870
+ printed
16871
+ };
16872
+ let same = 0;
16873
+ while (same < logs.length && logs[same] === printed[same]) same++;
16874
+ const lineStart = logs.lastIndexOf("\n", same - 1) + 1;
16875
+ return {
16876
+ chunk: `${printed.endsWith("\n") ? "" : "\n"}${logs.slice(lineStart)}`,
16877
+ printed: logs
16878
+ };
16879
+ }
16419
16880
  async function streamBuildLogs(client, deploymentId, opts = {}) {
16420
16881
  const quiet = opts.quiet === true;
16421
16882
  const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
16422
- let printed = 0;
16883
+ let printed = "";
16423
16884
  const started = Date.now();
16885
+ /** When the current run of failed status reads began. */
16886
+ let unreadableSince;
16887
+ const stopIfTimedOut = async () => {
16888
+ if (Date.now() - started <= timeoutMs) return;
16889
+ await opts.onTerminal?.("timeout");
16890
+ if (!quiet) console.log("");
16891
+ fail(`Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for the build to finish.`, "The deployment may still be running — check `rebase cloud logs`.", "timeout");
16892
+ };
16424
16893
  for (;;) {
16425
16894
  let dep;
16426
16895
  try {
16427
16896
  dep = await client.data.collection("deployments").findById(deploymentId);
16897
+ unreadableSince = void 0;
16428
16898
  } catch (e) {
16429
- reportError(e, "Failed to read deployment status");
16899
+ if (!transientReadFailure(e)) reportError(e, "Failed to read deployment status");
16900
+ unreadableSince ??= Date.now();
16901
+ if (Date.now() - unreadableSince > UNREADABLE_STATUS_MS) {
16902
+ await opts.onTerminal?.("status_unreadable");
16903
+ fail(`Could not read deployment ${deploymentId}'s status for over a minute: ${e instanceof Error ? e.message : String(e)}`, "The deployment may still be running — check `rebase cloud logs`.", "status_unreadable");
16904
+ }
16905
+ await stopIfTimedOut();
16906
+ await sleep(POLL_INTERVAL_MS);
16907
+ continue;
16430
16908
  }
16431
16909
  if (!dep) fail(`Deployment ${deploymentId} disappeared.`, void 0, "not_found");
16432
- const logs = dep.logs ?? "";
16433
- if (!quiet && logs.length > printed) process.stdout.write(logs.slice(printed));
16434
- printed = logs.length;
16910
+ const next = nextLogChunk(printed, dep.logs ?? "");
16911
+ if (!quiet && next.chunk !== "") process.stdout.write(next.chunk);
16912
+ printed = next.printed;
16435
16913
  if (dep.status && dep.status !== "deploying") {
16914
+ await opts.onTerminal?.(dep.status);
16436
16915
  if (dep.status !== "success") {
16437
16916
  if (quiet) {
16438
16917
  printJson({ error: {
@@ -16440,7 +16919,7 @@ async function streamBuildLogs(client, deploymentId, opts = {}) {
16440
16919
  code: "deploy_failed",
16441
16920
  status: null,
16442
16921
  deploymentId,
16443
- logs
16922
+ logs: withoutHeartbeat(dep.logs ?? "")
16444
16923
  } });
16445
16924
  process.exit(1);
16446
16925
  }
@@ -16456,15 +16935,24 @@ async function streamBuildLogs(client, deploymentId, opts = {}) {
16456
16935
  if (url) console.log(` ${chalk.cyan(`https://${url}`)}`);
16457
16936
  console.log("");
16458
16937
  }
16459
- return dep.status;
16460
- }
16461
- if (Date.now() - started > timeoutMs) {
16462
- if (!quiet) console.log("");
16463
- fail(`Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for the build to finish.`, "The deployment may still be running — check `rebase cloud logs`.", "timeout");
16938
+ return {
16939
+ status: dep.status,
16940
+ logs: withoutHeartbeat(dep.logs ?? "")
16941
+ };
16464
16942
  }
16943
+ await stopIfTimedOut();
16465
16944
  await sleep(POLL_INTERVAL_MS);
16466
16945
  }
16467
16946
  }
16947
+ /**
16948
+ * Whether a failed status read is worth reading again: no answer at all (the
16949
+ * connection failed), or one saying the control plane could not answer now.
16950
+ * Anything else — a 401, a 403, a 404 — will say the same next time.
16951
+ */
16952
+ function transientReadFailure(e) {
16953
+ const status = typeof e === "object" && e !== null && "status" in e && typeof e.status === "number" ? e.status : void 0;
16954
+ return status === void 0 || status === 408 || status === 429 || status >= 500;
16955
+ }
16468
16956
  /** What `rebase cloud logs` parses. Its page pairs against this. */
16469
16957
  var LOGS_FLAGS = {
16470
16958
  "--runtime": Boolean,
@@ -16487,11 +16975,16 @@ async function logsCommand(rawArgs, projectRef) {
16487
16975
  method: "GET",
16488
16976
  path: projectId
16489
16977
  });
16490
- console.log("");
16491
- console.log(chalk.bold(` 📄 Runtime logs — project ${projectRef}`));
16492
- console.log("");
16493
- console.log(res.logs ?? chalk.gray(" (no logs)"));
16494
- console.log("");
16978
+ emit(() => {
16979
+ console.log("");
16980
+ console.log(chalk.bold(` 📄 Runtime logs — project ${projectRef}`));
16981
+ console.log("");
16982
+ console.log(res.logs ?? chalk.gray(" (no logs)"));
16983
+ console.log("");
16984
+ }, {
16985
+ runtime: true,
16986
+ logs: res.logs ?? null
16987
+ });
16495
16988
  } catch (e) {
16496
16989
  reportError(e, "Failed to fetch runtime logs");
16497
16990
  }
@@ -16500,22 +16993,48 @@ async function logsCommand(rawArgs, projectRef) {
16500
16993
  try {
16501
16994
  const dep = await latestDeployment(client, projectId);
16502
16995
  if (!dep) {
16503
- console.log("");
16504
- console.log(chalk.gray(" No deployments yet for this project."));
16505
- console.log("");
16996
+ emit(() => {
16997
+ console.log("");
16998
+ console.log(chalk.gray(" No deployments yet for this project."));
16999
+ console.log("");
17000
+ }, {
17001
+ deploymentId: null,
17002
+ status: null,
17003
+ logs: null
17004
+ });
16506
17005
  return;
16507
17006
  }
16508
- console.log("");
16509
- console.log(chalk.bold(` 📄 Build logs — deployment ${dep.id}`) + ` ${colorStatus(dep.status)}`);
16510
- console.log("");
16511
- if (args["--follow"] && dep.status === "deploying") await streamBuildLogs(client, String(dep.id), {
16512
- projectId,
16513
- url
16514
- });
16515
- else {
16516
- console.log(dep.logs ?? chalk.gray(" (no logs)"));
16517
- console.log("");
17007
+ const deploymentId = String(dep.id);
17008
+ if (args["--follow"] && dep.status === "deploying") {
17009
+ if (!isJsonMode()) {
17010
+ console.log("");
17011
+ console.log(chalk.bold(` 📄 Build logs — deployment ${dep.id}`) + ` ${colorStatus(dep.status)}`);
17012
+ console.log("");
17013
+ }
17014
+ const { status, logs } = await streamBuildLogs(client, deploymentId, {
17015
+ quiet: isJsonMode(),
17016
+ projectId,
17017
+ url
17018
+ });
17019
+ emit(() => {}, {
17020
+ deploymentId,
17021
+ status,
17022
+ logs
17023
+ });
17024
+ return;
16518
17025
  }
17026
+ const logs = dep.logs == null ? null : withoutHeartbeat(dep.logs);
17027
+ emit(() => {
17028
+ console.log("");
17029
+ console.log(chalk.bold(` 📄 Build logs — deployment ${dep.id}`) + ` ${colorStatus(dep.status)}`);
17030
+ console.log("");
17031
+ console.log(logs ?? chalk.gray(" (no logs)"));
17032
+ console.log("");
17033
+ }, {
17034
+ deploymentId,
17035
+ status: dep.status ?? null,
17036
+ logs
17037
+ });
16519
17038
  } catch (e) {
16520
17039
  reportError(e, "Failed to fetch build logs");
16521
17040
  }
@@ -16884,8 +17403,7 @@ async function setEnv(rawArgs) {
16884
17403
  const projectId = await requireProject(rawArgs, client);
16885
17404
  displayProjectRef(rawArgs);
16886
17405
  if (!parsed || !parsed.key) fail("Usage: rebase cloud env set KEY=VALUE [--secret]", void 0, "usage");
16887
- const buildTimePrefix = buildTimeEnvPrefix(parsed.key);
16888
- if (buildTimePrefix && !args["--force"]) fail(`${parsed.key} is read by your bundler at BUILD time, and project variables are applied at rollout — after the image is built. Setting it here would not reach the bundle.`, `Put ${buildTimePrefix}* variables in the source you deploy (a committed .env, or your build config), then \`rebase cloud deploy\`. Pass --force if your build genuinely reads this at run time.`, "build_time_variable");
17406
+ if (buildTimeEnvPrefix(parsed.key) && !args["--force"]) fail(`${parsed.key} is read by your bundler at BUILD time, and project variables are applied at rollout — after the image is built. Setting it here would not reach the bundle.`, `A managed deploy builds the frontend on this machine: set it in your shell when you run \`rebase cloud deploy\`${parsed.key === "VITE_API_URL" ? "" : ", or in the app's .env.production"}. A --source build runs on the platform and never receives .env files: set it in the build itself — an ENV line in your Dockerfile, or \`define\` in your bundler config. Pass --force if your build genuinely reads this at run time.`, "build_time_variable");
16889
17407
  const body = {
16890
17408
  key: parsed.key,
16891
17409
  value: parsed.value
@@ -16983,6 +17501,28 @@ async function revealEnv(rawArgs) {
16983
17501
  reportError(e, "Failed to reveal environment variable");
16984
17502
  }
16985
17503
  }
17504
+ /**
17505
+ * One `KEY=value` line that dotenv reads back as exactly `value`, or null when
17506
+ * no quoting can carry it.
17507
+ *
17508
+ * dotenv's quoting is not JSON's, and `JSON.stringify` was what this used: a
17509
+ * double-quoted value has only `\n` and `\r` unescaped, so every other
17510
+ * backslash stayed and `{"a":1}` came back as `{\"a\":1}`. Single quotes and
17511
+ * backticks are literal — nothing inside is unescaped, and either may span
17512
+ * lines — so they carry any value without their own quote character. Double
17513
+ * quotes come last: they cannot hold a `"` or a literal backslash-n, and they
17514
+ * are the only way to carry a carriage return, which dotenv folds into a
17515
+ * newline everywhere else.
17516
+ */
17517
+ function dotenvLine(key, value) {
17518
+ if (!/[\s#'"`\\]/.test(value)) return `${key}=${value}`;
17519
+ if (!value.includes("\r")) {
17520
+ if (!value.includes("'")) return `${key}='${value}'`;
17521
+ if (!value.includes("`")) return `${key}=\`${value}\``;
17522
+ }
17523
+ if (!value.includes("\"") && !/\\[nr]/.test(value)) return `${key}="${value.replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`;
17524
+ return null;
17525
+ }
16986
17526
  async function pullEnv(rawArgs) {
16987
17527
  const { flags: args } = parseCloudArgs({
16988
17528
  spec: {
@@ -17024,15 +17564,22 @@ async function pullEnv(rawArgs) {
17024
17564
  projectId,
17025
17565
  key: v.key
17026
17566
  }, { path: "reveal" });
17027
- const needsQuote = /[\s#'"]/.test(revealed.value);
17028
- lines.push(`${v.key}=${needsQuote ? JSON.stringify(revealed.value) : revealed.value}`);
17567
+ const line = dotenvLine(v.key, revealed.value);
17568
+ if (line === null) {
17569
+ skipped.push({
17570
+ key: v.key,
17571
+ reason: "no .env quoting carries this value unchanged"
17572
+ });
17573
+ continue;
17574
+ }
17575
+ lines.push(line);
17029
17576
  written.push(v.key);
17030
17577
  }
17031
17578
  fs.writeFileSync(outPath, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
17032
17579
  emit(() => {
17033
17580
  success(`Wrote ${written.length} variable${written.length === 1 ? "" : "s"} to ${outPath}`);
17034
17581
  if (skipped.length) {
17035
- console.log(chalk.gray(` Skipped ${skipped.length} secret variable(s): ${skipped.map((s) => s.key).join(", ")}`));
17582
+ for (const s of skipped) console.log(chalk.gray(` Skipped ${s.key}: ${s.reason}`));
17036
17583
  console.log("");
17037
17584
  }
17038
17585
  }, {
@@ -17086,27 +17633,129 @@ function printEnvHelp() {
17086
17633
  //#endregion
17087
17634
  //#region src/commands/cloud/domains.ts
17088
17635
  /**
17089
- * `rebase cloud domains` — a project's custom domain.
17090
- *
17091
- * domains list Current domain + the DNS records it needs
17092
- * domains add <domain> Register a domain (starts, does not finish, setup)
17093
- * domains verify Check the published DNS now; live only if it passes
17094
- * domains remove Detach the custom domain
17095
- *
17096
- * The DNS record set comes from the server (`verify-domain`), never composed
17097
- * here: whether to publish an A or a CNAME depends on apex-vs-subdomain and on
17098
- * the ingress address behind the tenant host, which the CLI cannot know. Adding
17099
- * a domain only registers it — it is unverified until the records are published
17100
- * and `verify` passes.
17636
+ * `rebase cloud domains` — the hostnames a project is served on.
17637
+ *
17638
+ * domains list Every host, its status, and the DNS records it needs
17639
+ * domains add <domain> Register a host (starts, does not finish, setup)
17640
+ * domains verify [domain] Check the host's published DNS now; live only if it passes
17641
+ * domains remove <domain> Stop serving a host
17642
+ *
17643
+ * A project's hosts are rows of the control plane's `project_domains` table,
17644
+ * managed through its `domains` function — the same one the console uses. The
17645
+ * Ingress is built from those rows, one verified host at a time, so every
17646
+ * action here addresses a row. The single `projects.customDomain` column is a
17647
+ * projection the control plane keeps in step with them; writing it directly
17648
+ * changes nothing that is served.
17649
+ *
17650
+ * Two cases still go through that column, because the control plane still
17651
+ * serves them from it: a project with no rows at all, whose one domain lives
17652
+ * only in the column, and a control plane that has no `domains` function.
17653
+ *
17654
+ * The DNS record set comes from the server, never composed here: whether to
17655
+ * publish an A or a CNAME depends on apex-vs-subdomain and on the ingress
17656
+ * address behind the tenant host, which the CLI cannot know. Adding a host only
17657
+ * registers it — it is unverified until the records are published and `verify`
17658
+ * passes.
17659
+ */
17660
+ /** The column's one domain as an entry, or none. */
17661
+ function columnEntry(setup) {
17662
+ if (!setup.domain) return [];
17663
+ return [{
17664
+ id: null,
17665
+ host: setup.domain,
17666
+ primary: true,
17667
+ status: setup.status === "verified" ? "verified" : "pending",
17668
+ verifiedAt: setup.verifiedAt ?? null,
17669
+ isApex: setup.isApex,
17670
+ tenantHost: setup.tenantHost,
17671
+ instructions: setup.instructions
17672
+ }];
17673
+ }
17674
+ /**
17675
+ * A row, or null for one with no id or host — it cannot be verified or removed,
17676
+ * so it is not offered as a domain. Anything but `verified` is pending: an
17677
+ * unreadable status must not read as live.
17678
+ */
17679
+ function rowEntry(row) {
17680
+ if (typeof row?.id !== "string" || typeof row.host !== "string") return null;
17681
+ return {
17682
+ id: row.id,
17683
+ host: row.host,
17684
+ primary: row.primary === true,
17685
+ status: row.status === "verified" ? "verified" : "pending",
17686
+ verifiedAt: row.verifiedAt ?? null,
17687
+ isApex: row.isApex,
17688
+ tenantHost: row.tenantHost,
17689
+ instructions: row.instructions
17690
+ };
17691
+ }
17692
+ /**
17693
+ * Whether a failed `domains` call means the control plane has no such function
17694
+ * — as opposed to refusing this project, which the function answers `not_found`.
17101
17695
  */
17102
- async function fetchDomainSetup(client, projectId) {
17696
+ function noDomainsFunction(e) {
17697
+ if (typeof e !== "object" || e === null) return false;
17698
+ const status = "status" in e ? e.status : void 0;
17699
+ const code = "code" in e ? e.code : void 0;
17700
+ return status === 404 && code !== "not_found";
17701
+ }
17702
+ async function fetchColumnSetup(client, projectId) {
17103
17703
  return client.functions.invoke("verify-domain", void 0, {
17104
17704
  method: "GET",
17105
17705
  path: projectId
17106
17706
  });
17107
17707
  }
17108
- function printRecords(setup) {
17109
- const recs = [setup.instructions?.pointing, setup.instructions?.ownership].filter(Boolean);
17708
+ /** Every host this project is served on, or may be once verified. */
17709
+ async function loadDomains(client, projectId) {
17710
+ let listed;
17711
+ try {
17712
+ listed = await client.functions.invoke("domains", void 0, {
17713
+ method: "GET",
17714
+ path: projectId
17715
+ });
17716
+ } catch (e) {
17717
+ if (!noDomainsFunction(e)) throw e;
17718
+ const setup = await fetchColumnSetup(client, projectId);
17719
+ return {
17720
+ model: "column",
17721
+ tenantHost: setup.tenantHost,
17722
+ domains: columnEntry(setup)
17723
+ };
17724
+ }
17725
+ const domains = (listed.domains ?? []).map(rowEntry).filter((d) => d !== null);
17726
+ if (domains.length > 0) return {
17727
+ model: "rows",
17728
+ tenantHost: listed.tenantHost,
17729
+ domains
17730
+ };
17731
+ const setup = await fetchColumnSetup(client, projectId);
17732
+ return {
17733
+ model: "rows",
17734
+ tenantHost: listed.tenantHost ?? setup.tenantHost,
17735
+ domains: columnEntry(setup)
17736
+ };
17737
+ }
17738
+ /**
17739
+ * The host an action names, or the only one there is to name.
17740
+ *
17741
+ * Refuses to guess among several: removing or verifying the wrong host of a
17742
+ * live site is not a mistake worth saving a word over.
17743
+ */
17744
+ function pickDomain(set, named, action) {
17745
+ if (named) {
17746
+ const wanted = named.trim().toLowerCase().replace(/\.$/, "");
17747
+ const found = set.domains.find((d) => d.host.toLowerCase() === wanted);
17748
+ if (!found) fail(`This project has no domain ${named}.`, set.domains.length ? `Its domains: ${set.domains.map((d) => d.host).join(", ")}.` : "It has no custom domains. Add one with `rebase cloud domains add <domain>`.", "not_found");
17749
+ return found;
17750
+ }
17751
+ if (set.domains.length === 1) return set.domains[0];
17752
+ if (set.domains.length === 0) fail("This project has no custom domains.", "Add one with `rebase cloud domains add <domain>`.", "not_found");
17753
+ const pending = set.domains.filter((d) => d.status !== "verified");
17754
+ if (action === "verify" && pending.length === 1) return pending[0];
17755
+ fail(`This project has ${set.domains.length} domains; name the one to ${action}.`, `Run \`rebase cloud domains ${action} <domain>\` with one of: ${set.domains.map((d) => d.host).join(", ")}.`, "usage");
17756
+ }
17757
+ function printRecords(instructions) {
17758
+ const recs = [instructions?.pointing, instructions?.ownership].filter(Boolean);
17110
17759
  if (!recs.length) return;
17111
17760
  console.log(chalk.bold(" DNS records to publish:"));
17112
17761
  for (const r of recs) console.log(` ${chalk.cyan(r.type)} ${r.name} → ${r.values.join(", ")}`);
@@ -17124,7 +17773,7 @@ async function domainsCommand(action, rawArgs) {
17124
17773
  await addDomain(rawArgs);
17125
17774
  break;
17126
17775
  case "verify":
17127
- await verifyDomains(rawArgs);
17776
+ await verifyDomain(rawArgs);
17128
17777
  break;
17129
17778
  case "remove":
17130
17779
  case "rm":
@@ -17149,47 +17798,50 @@ async function listDomains(rawArgs) {
17149
17798
  const projectId = await requireProject(rawArgs, client);
17150
17799
  const projectRef = displayProjectRef(rawArgs);
17151
17800
  try {
17152
- const setup = await fetchDomainSetup(client, projectId);
17801
+ const set = await loadDomains(client, projectId);
17153
17802
  emit(() => {
17154
17803
  console.log("");
17155
- console.log(chalk.bold(` 🌐 Custom domain — project ${projectRef}`));
17804
+ console.log(chalk.bold(` 🌐 Custom domains — project ${projectRef}`));
17156
17805
  console.log("");
17157
- if (!setup.domain) {
17158
- console.log(chalk.gray(" No custom domain. Add one with `rebase cloud domains add <domain>`."));
17806
+ if (!set.domains.length) {
17807
+ console.log(chalk.gray(" No custom domains. Add one with `rebase cloud domains add <domain>`."));
17159
17808
  console.log("");
17160
17809
  return;
17161
17810
  }
17162
- keyValues([
17163
- ["Domain", setup.domain],
17164
- ["Status", setup.status === "verified" ? chalk.green(setup.status) : chalk.yellow(setup.status)],
17165
- ["Apex", setup.isApex === void 0 ? void 0 : setup.isApex ? "yes" : "no"],
17166
- ["Tenant host", setup.tenantHost],
17167
- ["Verified at", setup.verifiedAt ?? void 0]
17168
- ]);
17811
+ if (set.tenantHost) {
17812
+ keyValues([["Tenant host", set.tenantHost]]);
17813
+ console.log("");
17814
+ }
17815
+ for (const d of set.domains) {
17816
+ const status = d.status === "verified" ? chalk.green(d.status) : chalk.yellow(d.status);
17817
+ console.log(` ${chalk.bold(d.host)} ${status}${d.primary && set.domains.length > 1 ? chalk.gray(" primary") : ""}`);
17818
+ if (d.verifiedAt) console.log(chalk.gray(` verified ${d.verifiedAt}`));
17819
+ if (d.status !== "verified") printRecords(d.instructions);
17820
+ }
17169
17821
  console.log("");
17170
- if (setup.status !== "verified") printRecords(setup);
17171
17822
  }, {
17172
17823
  projectId,
17173
- ...setup
17824
+ tenantHost: set.tenantHost ?? null,
17825
+ domains: set.domains
17174
17826
  });
17175
17827
  } catch (e) {
17176
- reportError(e, "Failed to load custom domain");
17828
+ reportError(e, "Failed to load custom domains");
17177
17829
  }
17178
17830
  }
17179
17831
  /**
17180
- * The domain `domains add` was asked to register.
17832
+ * The domain an action was given, or undefined.
17181
17833
  *
17182
17834
  * Exported so its tests drive the real parser. Under the old operand filter
17183
17835
  * `rebase cloud domains add -p acme` registered a domain called "acme" — the
17184
17836
  * project slug, read out of `--project`'s own value — and a registered domain
17185
17837
  * is a project-record write, not a no-op.
17186
17838
  */
17187
- function resolveDomainArg(rawArgs) {
17839
+ function resolveDomainArg(rawArgs, action = "add") {
17188
17840
  return parseCloudArgs({
17189
17841
  spec: {},
17190
17842
  rawArgs,
17191
17843
  commandWords: 3,
17192
- command: "cloud domains add",
17844
+ command: `cloud domains ${action}`,
17193
17845
  maxPositionals: 1
17194
17846
  }).positionals[0];
17195
17847
  }
@@ -17198,41 +17850,51 @@ async function addDomain(rawArgs) {
17198
17850
  if (!domain) fail("Usage: rebase cloud domains add <domain>", void 0, "usage");
17199
17851
  const { client } = await requireClient(rawArgs);
17200
17852
  const projectId = await requireProject(rawArgs, client);
17853
+ let added;
17201
17854
  try {
17202
- await client.data.collection("projects").update(projectId, { customDomain: domain });
17203
- const setup = await fetchDomainSetup(client, projectId);
17204
- emit(() => {
17205
- success(`Registered ${chalk.bold(domain)} — not yet verified`);
17206
- printRecords(setup);
17207
- console.log(chalk.gray(" Publish the records above, then run `rebase cloud domains verify`."));
17208
- console.log("");
17209
- }, {
17210
- success: true,
17211
- projectId,
17212
- ...setup
17213
- });
17855
+ const set = await loadDomains(client, projectId);
17856
+ if (set.model === "column") {
17857
+ await client.data.collection("projects").update(projectId, { customDomain: domain });
17858
+ added = columnEntry(await fetchColumnSetup(client, projectId))[0];
17859
+ } else {
17860
+ const legacy = set.domains.find((d) => d.id === null);
17861
+ if (legacy && legacy.host.toLowerCase() !== domain.trim().toLowerCase()) warn(`${legacy.host} is held outside this project's host list, and stops being served once another host is verified.`, `Keep it by adding it too: \`rebase cloud domains add ${legacy.host}\`, then \`rebase cloud domains verify ${legacy.host}\`.`);
17862
+ added = rowEntry(await client.functions.invoke("domains", { host: domain }, { path: projectId })) ?? void 0;
17863
+ }
17214
17864
  } catch (e) {
17215
17865
  reportError(e, "Failed to register domain");
17216
17866
  }
17217
- }
17218
- async function verifyDomains(rawArgs) {
17219
- parseCloudArgs({
17220
- spec: {},
17221
- rawArgs,
17222
- commandWords: 3,
17223
- command: "cloud domains",
17224
- maxPositionals: 0
17867
+ if (!added) fail(`The control plane did not return ${domain}, so whether it was registered is unknown.`, "Run `rebase cloud domains list`.");
17868
+ const registered = added;
17869
+ emit(() => {
17870
+ success(`Registered ${chalk.bold(registered.host)} — not yet verified`);
17871
+ printRecords(registered.instructions);
17872
+ console.log(chalk.gray(` Publish the records above, then run \`rebase cloud domains verify ${registered.host}\`.`));
17873
+ console.log("");
17874
+ }, {
17875
+ success: true,
17876
+ projectId,
17877
+ ...registered
17225
17878
  });
17879
+ }
17880
+ async function verifyDomain(rawArgs) {
17881
+ const named = resolveDomainArg(rawArgs, "verify");
17226
17882
  const { client } = await requireClient(rawArgs);
17227
17883
  const projectId = await requireProject(rawArgs, client);
17228
- displayProjectRef(rawArgs);
17884
+ let set;
17229
17885
  try {
17230
- const res = await client.functions.invoke("verify-domain", {}, { path: projectId });
17886
+ set = await loadDomains(client, projectId);
17887
+ } catch (e) {
17888
+ reportError(e, "Failed to load custom domains");
17889
+ }
17890
+ const target = pickDomain(set, named, "verify");
17891
+ try {
17892
+ const res = verifyOutcome(target.id === null ? await client.functions.invoke("verify-domain", {}, { path: projectId }) : await client.functions.invoke("domains", {}, { path: `${projectId}/${target.id}/verify` }), target);
17231
17893
  emit(() => {
17232
17894
  console.log("");
17233
- if (res.verified) success(`${res.domain} is verified and live`);
17895
+ if (res.verified) success(`${target.host} is verified and live`);
17234
17896
  else {
17235
- console.log(chalk.yellow(` ⚠ ${res.domain ?? "domain"} is not verified yet`));
17897
+ console.log(chalk.yellow(` ⚠ ${target.host} is not verified yet`));
17236
17898
  console.log("");
17237
17899
  const rows = [["Ownership", res.checks.ownership], ["Pointing", res.checks.pointing]];
17238
17900
  for (const [label, check] of rows) {
@@ -17243,41 +17905,78 @@ async function verifyDomains(rawArgs) {
17243
17905
  if (check.error) console.log(chalk.gray(` error: ${check.error}`));
17244
17906
  }
17245
17907
  console.log("");
17246
- printRecords(res);
17908
+ printRecords(res.entry.instructions);
17247
17909
  }
17248
17910
  }, {
17249
17911
  projectId,
17912
+ id: res.entry.id,
17913
+ host: res.entry.host,
17250
17914
  verified: res.verified,
17251
- status: res.status,
17252
- domain: res.domain,
17915
+ status: res.entry.status,
17253
17916
  checks: res.checks,
17254
- instructions: res.instructions
17917
+ instructions: res.entry.instructions ?? null
17255
17918
  });
17256
17919
  if (!res.verified) process.exit(1);
17257
17920
  } catch (e) {
17258
17921
  reportError(e, "Failed to verify domain");
17259
17922
  }
17260
17923
  }
17924
+ /** A verify answer from either door, as one shape. */
17925
+ function verifyOutcome(res, target) {
17926
+ const verified = res.verified === true;
17927
+ const unchecked = {
17928
+ ok: false,
17929
+ expected: [],
17930
+ observed: []
17931
+ };
17932
+ return {
17933
+ verified,
17934
+ checks: res.checks ?? {
17935
+ ownership: unchecked,
17936
+ pointing: unchecked
17937
+ },
17938
+ entry: {
17939
+ ...target,
17940
+ status: verified || res.status === "verified" ? "verified" : "pending",
17941
+ verifiedAt: res.verifiedAt ?? target.verifiedAt,
17942
+ instructions: res.instructions ?? target.instructions
17943
+ }
17944
+ };
17945
+ }
17261
17946
  async function removeDomain(rawArgs) {
17262
- const { flags: args } = parseCloudArgs({
17947
+ const { flags: args, positionals } = parseCloudArgs({
17263
17948
  spec: {},
17264
17949
  rawArgs,
17265
17950
  commandWords: 3,
17266
17951
  command: "cloud domains remove",
17267
- maxPositionals: 0
17952
+ maxPositionals: 1
17268
17953
  });
17269
17954
  const { client } = await requireClient(rawArgs);
17270
17955
  const projectId = await requireProject(rawArgs, client);
17271
17956
  const projectRef = displayProjectRef(rawArgs);
17957
+ let set;
17958
+ try {
17959
+ set = await loadDomains(client, projectId);
17960
+ } catch (e) {
17961
+ reportError(e, "Failed to load custom domains");
17962
+ }
17963
+ const target = pickDomain(set, positionals[0], "remove");
17272
17964
  await confirmDestructive({
17273
17965
  yes: Boolean(args["--yes"]),
17274
- prompt: `Remove the custom domain from project ${projectRef}?`
17966
+ prompt: `Stop serving ${target.host} on project ${projectRef}?`
17275
17967
  });
17276
17968
  try {
17277
- await client.data.collection("projects").update(projectId, { customDomain: "" });
17278
- emit(() => success(`Removed the custom domain from project ${projectRef}`), {
17969
+ let removed = true;
17970
+ if (target.id === null) await client.data.collection("projects").update(projectId, { customDomain: "" });
17971
+ else removed = (await client.functions.invoke("domains", void 0, {
17972
+ method: "DELETE",
17973
+ path: `${projectId}/${target.id}`
17974
+ }))?.removed !== false;
17975
+ emit(() => success(removed ? `${target.host} is no longer served by project ${projectRef}` : `${target.host} was already gone from project ${projectRef}`), {
17279
17976
  success: true,
17280
- projectId
17977
+ projectId,
17978
+ host: target.host,
17979
+ removed
17281
17980
  });
17282
17981
  } catch (e) {
17283
17982
  reportError(e, "Failed to remove domain");
@@ -17286,27 +17985,33 @@ async function removeDomain(rawArgs) {
17286
17985
  function printDomainsHelp() {
17287
17986
  printGroupHelp({
17288
17987
  command: "cloud domains",
17289
- title: "Custom domain",
17988
+ title: "Custom domains",
17290
17989
  actions: [
17291
17990
  {
17292
17991
  action: "list",
17293
- description: "Show the domain and the DNS records it needs"
17992
+ description: "Every host, its status, and the DNS records it needs"
17294
17993
  },
17295
17994
  {
17296
17995
  action: "add",
17297
17996
  args: "<domain>",
17298
- description: "Register a custom domain"
17997
+ description: "Register a host"
17299
17998
  },
17300
17999
  {
17301
18000
  action: "verify",
17302
- description: "Check DNS and go live"
18001
+ args: "[domain]",
18002
+ description: "Check a host's DNS and put it live"
17303
18003
  },
17304
18004
  {
17305
18005
  action: "remove",
17306
- description: "Detach the custom domain"
18006
+ args: "<domain>",
18007
+ description: "Stop serving a host"
17307
18008
  }
17308
18009
  ],
17309
- notes: ["A CNAME is invalid at a zone apex: use an A record there and a CNAME below it.", "`list` prints the exact records to add — copy them rather than deriving them."]
18010
+ notes: [
18011
+ "A project can be served on several hosts; each is verified on its own.",
18012
+ "A CNAME is invalid at a zone apex: use an A record there and a CNAME below it.",
18013
+ "`list` prints the exact records to add — copy them rather than deriving them."
18014
+ ]
17310
18015
  });
17311
18016
  }
17312
18017
  //#endregion
@@ -17904,6 +18609,25 @@ function resolveDeploymentIdArg(rawArgs, command) {
17904
18609
  id: positionals[0]
17905
18610
  };
17906
18611
  }
18612
+ /**
18613
+ * One deployment by id, when it belongs to `projectId`; undefined otherwise.
18614
+ *
18615
+ * A read that fails is "not found" too: the caller's refusal says so, and the
18616
+ * control plane checks the same ownership again before rolling back.
18617
+ */
18618
+ async function deploymentOfProject(client, deploymentId, projectId) {
18619
+ let row;
18620
+ try {
18621
+ row = await client.data.collection("deployments").findById(deploymentId);
18622
+ } catch {
18623
+ return;
18624
+ }
18625
+ if (!row) return void 0;
18626
+ const owner = row.project ?? row.projectId ?? row.project_id;
18627
+ const ownerId = typeof owner === "object" && owner !== null && "id" in owner ? owner.id : owner;
18628
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== projectId) return void 0;
18629
+ return cloudRow(row);
18630
+ }
17907
18631
  async function rollbackCommand(rawArgs) {
17908
18632
  const { flags: args, id: explicitId } = resolveDeploymentIdArg(rawArgs, "cloud rollback");
17909
18633
  const { client } = await requireClient(rawArgs);
@@ -17918,7 +18642,7 @@ async function rollbackCommand(rawArgs) {
17918
18642
  if (!rows.length) fail("No deployments to roll back to.", void 0, "no_deployments");
17919
18643
  let target;
17920
18644
  if (explicitId) {
17921
- target = rows.find((d) => String(d.id) === explicitId);
18645
+ target = rows.find((d) => String(d.id) === explicitId) ?? await deploymentOfProject(client, explicitId, projectId);
17922
18646
  if (!target) fail(`Deployment ${explicitId} not found for project ${projectRef}.`, void 0, "not_found");
17923
18647
  if (!isRollbackable(target)) fail(`Deployment ${explicitId} is not rollbackable (${ROLLBACKABLE_RULE}).`, "List candidates with `rebase cloud deployments list`.", "deploy_not_rollbackable");
17924
18648
  } else {
@@ -19075,6 +19799,8 @@ var ACTION_HELP = {
19075
19799
  ["--bundle-dir <path>", "Deploy a bundle that is already built"],
19076
19800
  ["--source <path>", "Upload this directory and build a container image from it"],
19077
19801
  ["--skip-type-check", "Compile without type checking, as `rebase build` does"],
19802
+ ["--skip-schema", "Keep the committed database schema instead of regenerating it"],
19803
+ ["--no-static", "Deploy the backend without folding the static apps into its bundle"],
19078
19804
  ["--no-source", "Do not upload the project's source beside a backend bundle"],
19079
19805
  ["--allow-downgrade", "Deploy a bundle built on an older framework release than the project runs"],
19080
19806
  ["--eject", "Leave the managed runtime on purpose (ejects to a container image)"]
@@ -19199,7 +19925,7 @@ var ACTION_HELP = {
19199
19925
  "domains add": {
19200
19926
  command: "cloud domains add",
19201
19927
  usage: "cloud domains add <domain>",
19202
- summary: "Register a custom domain on this project and print the DNS records to publish. Registering is not verifying — run `domains verify` once the records are live.",
19928
+ summary: "Register a custom domain on this project, beside any it already has, and print the DNS records to publish. Registering is not verifying — run `domains verify <domain>` once the records are live.",
19203
19929
  flags: [],
19204
19930
  examples: ["rebase cloud domains add app.example.com"],
19205
19931
  notes: ["At a zone apex use the A record this prints, not a CNAME: a CNAME is invalid at an apex.", "Verification is a separate command because DNS propagation is not instant."]
@@ -19207,7 +19933,7 @@ var ACTION_HELP = {
19207
19933
  "domains remove": {
19208
19934
  command: "cloud domains remove",
19209
19935
  usage: "cloud domains remove <domain>",
19210
- summary: "Unregister a custom domain. The project keeps serving on its <slug>.rebase.website host. Aliases: `rm`, `delete`.",
19936
+ summary: "Stop serving one custom domain. The project keeps its other domains and its <slug>.rebase.website host. The domain may be left out when the project has only one. Aliases: `rm`, `delete`.",
19211
19937
  flags: [],
19212
19938
  examples: ["rebase cloud domains remove app.example.com"]
19213
19939
  },
@@ -19215,7 +19941,7 @@ var ACTION_HELP = {
19215
19941
  command: "cloud env set",
19216
19942
  usage: "cloud env set KEY=VALUE | KEY VALUE [--secret] [--force]",
19217
19943
  summary: "Set one environment variable on the project. Takes effect on the next deploy — a running instance does not pick it up.",
19218
- flags: [["--secret", "Store it encrypted and never return it in a listing"], ["--force", "Overwrite a variable that is already set"]],
19944
+ flags: [["--secret", "Store it encrypted and never return it in a listing"], ["--force", "Set a build-time key (VITE_*, NEXT_PUBLIC_*, …) anyway, for a build that reads it at run time"]],
19219
19945
  examples: [
19220
19946
  "rebase cloud env set STRIPE_KEY=sk_live_… --secret",
19221
19947
  "rebase cloud env set LOG_LEVEL debug",
@@ -19376,7 +20102,11 @@ var ACTION_HELP = {
19376
20102
  "rebase cloud login --email me@example.com",
19377
20103
  "rebase cloud login --url https://cloud.example.com"
19378
20104
  ],
19379
- notes: ["--password warns, once, before the request: by the time a login succeeds the password is already in the history file.", "There is no machine token yet, so CI genuinely needs a human's credentials. REBASE_CLOUD_EMAIL and REBASE_CLOUD_PASSWORD are how a secret store hands them over without them appearing on a command line."]
20105
+ notes: [
20106
+ "--password warns, once, before the request: by the time a login succeeds the password is already in the history file.",
20107
+ "There is no machine token yet, so CI genuinely needs a human's credentials. REBASE_CLOUD_EMAIL and REBASE_CLOUD_PASSWORD are how a secret store hands them over without them appearing on a command line.",
20108
+ "In a directory whose .rebase/cloud.json names a control plane you have not signed in to before, login names it and asks first, and refuses without a terminal: a cloned repository does not choose where your password goes. Pass --url <that host> to sign in there on purpose."
20109
+ ]
19380
20110
  },
19381
20111
  logout: {
19382
20112
  command: "cloud logout",
@@ -19868,7 +20598,7 @@ var CLOUD_GROUPS = [
19868
20598
  {
19869
20599
  action: "domains",
19870
20600
  section: "Config",
19871
- description: "Custom domain, its DNS records, and verification"
20601
+ description: "Custom domains, their DNS records, and verification"
19872
20602
  },
19873
20603
  {
19874
20604
  action: "extensions",
@@ -19921,7 +20651,8 @@ var CLOUD_GROUPS = [
19921
20651
  description: "The organization's billing account and card on file"
19922
20652
  }
19923
20653
  ];
19924
- CLOUD_GROUPS.map((g) => g.action);
20654
+ /** The dispatch words alone — what `--help` routing and the tests iterate. */
20655
+ var CLOUD_GROUP_NAMES = CLOUD_GROUPS.map((g) => g.action);
19925
20656
  function printCloudHelp() {
19926
20657
  printGroupHelp({
19927
20658
  command: "cloud",
@@ -20210,11 +20941,22 @@ function classifySpec(spec) {
20210
20941
  kind: "local",
20211
20942
  protocol
20212
20943
  };
20944
+ const catalog = /^catalog:(.*)$/.exec(spec.trim());
20945
+ if (catalog) return {
20946
+ kind: "catalog",
20947
+ catalog: catalog[1].trim() || "default"
20948
+ };
20213
20949
  return {
20214
20950
  kind: "other",
20215
20951
  reason: otherReason(spec)
20216
20952
  };
20217
20953
  }
20954
+ /** Why a spec that does not move is left alone, as the report says it. */
20955
+ function leftAloneReason(cls) {
20956
+ if (cls.kind === "local") return `a local ${cls.protocol} path, which points at a checkout on this machine rather than a release`;
20957
+ if (cls.kind === "catalog") return "a catalog: reference inside a catalog, which pnpm does not resolve";
20958
+ return cls.reason;
20959
+ }
20218
20960
  function otherReason(spec) {
20219
20961
  const trimmed = spec.trim();
20220
20962
  if (trimmed.startsWith("workspace:")) return "a workspace: link, resolved inside this repository rather than from the registry";
@@ -20274,6 +21016,9 @@ function discoverProjectFiles(projectRoot) {
20274
21016
  walk(projectRoot);
20275
21017
  return found;
20276
21018
  }
21019
+ function catalogKey(catalog, pkg) {
21020
+ return `${catalog}\u0000${pkg}`;
21021
+ }
20277
21022
  /** Project-relative, POSIX. What every file path in the output looks like. */
20278
21023
  function relativeTo(projectRoot, absolute) {
20279
21024
  return path.relative(projectRoot, absolute).split(path.sep).join("/");
@@ -20291,13 +21036,18 @@ function planUpgrade(projectRoot, target, options = {}) {
20291
21036
  skipped: [],
20292
21037
  overrides: [],
20293
21038
  unreadable: [],
20294
- writes: []
21039
+ writes: [],
21040
+ unchanged: 0
21041
+ };
21042
+ const ledger = {
21043
+ refs: [],
21044
+ defined: /* @__PURE__ */ new Set()
20295
21045
  };
20296
21046
  const files = discoverProjectFiles(projectRoot);
20297
21047
  for (const absolute of files.packageJsons) {
20298
21048
  const file = relativeTo(projectRoot, absolute);
20299
21049
  const original = fs.readFileSync(absolute, "utf8");
20300
- const content = planPackageJson(file, original, target, options, plan);
21050
+ const content = planPackageJson(file, original, target, options, plan, ledger);
20301
21051
  if (content !== null && content !== original) plan.writes.push({
20302
21052
  file,
20303
21053
  absolute,
@@ -20307,13 +21057,23 @@ function planUpgrade(projectRoot, target, options = {}) {
20307
21057
  for (const absolute of files.workspaceYamls) {
20308
21058
  const file = relativeTo(projectRoot, absolute);
20309
21059
  const original = fs.readFileSync(absolute, "utf8");
20310
- const content = planWorkspaceYaml(file, original, target, options, plan);
21060
+ const content = planWorkspaceYaml(file, original, target, options, plan, ledger);
20311
21061
  if (content !== original) plan.writes.push({
20312
21062
  file,
20313
21063
  absolute,
20314
21064
  content
20315
21065
  });
20316
21066
  }
21067
+ for (const ref of ledger.refs) {
21068
+ if (ledger.defined.has(catalogKey(ref.catalog, ref.pkg))) continue;
21069
+ plan.skipped.push({
21070
+ file: ref.file,
21071
+ name: ref.name,
21072
+ field: ref.field,
21073
+ spec: ref.spec,
21074
+ reason: `a pnpm catalog: reference, and no catalog under this project defines it — move the version where the "${ref.catalog}" catalog is declared`
21075
+ });
21076
+ }
20317
21077
  return plan;
20318
21078
  }
20319
21079
  /** Write what the plan changes. Nothing else on disk is touched. */
@@ -20326,7 +21086,7 @@ var JSON_OVERRIDE_BLOCKS = [
20326
21086
  ["overrides"],
20327
21087
  ["resolutions"]
20328
21088
  ];
20329
- function planPackageJson(file, original, target, options, plan) {
21089
+ function planPackageJson(file, original, target, options, plan, ledger) {
20330
21090
  const bom = original.startsWith("") ? "" : "";
20331
21091
  const text = original.slice(bom.length);
20332
21092
  let parsed;
@@ -20371,7 +21131,10 @@ function planPackageJson(file, original, target, options, plan) {
20371
21131
  const cls = classifySpec(spec);
20372
21132
  if (cls.kind === "movable") {
20373
21133
  const to = `${cls.prefix}${target}`;
20374
- if (to === spec) continue;
21134
+ if (to === spec) {
21135
+ plan.unchanged++;
21136
+ continue;
21137
+ }
20375
21138
  edits.push({
20376
21139
  start: member.value.start,
20377
21140
  end: member.value.end,
@@ -20385,16 +21148,21 @@ function planPackageJson(file, original, target, options, plan) {
20385
21148
  from: spec,
20386
21149
  to
20387
21150
  });
20388
- } else {
20389
- const reason = cls.kind === "local" ? `a local ${cls.protocol} path, which points at a checkout on this machine rather than a release` : cls.reason;
20390
- plan.skipped.push({
20391
- file,
20392
- name: member.key,
20393
- field,
20394
- spec,
20395
- reason
20396
- });
20397
- }
21151
+ } else if (cls.kind === "catalog") ledger.refs.push({
21152
+ file,
21153
+ name: member.key,
21154
+ field,
21155
+ spec,
21156
+ catalog: cls.catalog,
21157
+ pkg: member.key
21158
+ });
21159
+ else plan.skipped.push({
21160
+ file,
21161
+ name: member.key,
21162
+ field,
21163
+ spec,
21164
+ reason: leftAloneReason(cls)
21165
+ });
20398
21166
  }
20399
21167
  }
20400
21168
  for (const blockPath of JSON_OVERRIDE_BLOCKS) {
@@ -20415,9 +21183,13 @@ function planPackageJson(file, original, target, options, plan) {
20415
21183
  }
20416
21184
  const spec = member.value.value;
20417
21185
  const cls = classifySpec(spec);
21186
+ const pkg = overrideTarget(member.key);
20418
21187
  if (cls.kind === "movable") {
20419
21188
  const to = `${cls.prefix}${target}`;
20420
- if (to === spec) continue;
21189
+ if (to === spec) {
21190
+ plan.unchanged++;
21191
+ continue;
21192
+ }
20421
21193
  edits.push({
20422
21194
  start: member.value.start,
20423
21195
  end: member.value.end,
@@ -20431,7 +21203,15 @@ function planPackageJson(file, original, target, options, plan) {
20431
21203
  action: "bumped",
20432
21204
  to
20433
21205
  });
20434
- } else if (cls.kind === "local") if (options.dropLocalOverrides) {
21206
+ } else if (cls.kind === "catalog" && pkg) ledger.refs.push({
21207
+ file,
21208
+ name: member.key,
21209
+ field,
21210
+ spec,
21211
+ catalog: cls.catalog,
21212
+ pkg
21213
+ });
21214
+ else if (cls.kind === "local") if (options.dropLocalOverrides) {
20435
21215
  removals.push([blockPath, member.key]);
20436
21216
  deletePath(expected, [...blockPath, member.key]);
20437
21217
  plan.overrides.push({
@@ -20451,7 +21231,7 @@ function planPackageJson(file, original, target, options, plan) {
20451
21231
  name: member.key,
20452
21232
  field,
20453
21233
  spec,
20454
- reason: cls.reason
21234
+ reason: leftAloneReason(cls)
20455
21235
  });
20456
21236
  }
20457
21237
  }
@@ -20694,7 +21474,8 @@ function readYamlScalar(rest) {
20694
21474
  };
20695
21475
  }
20696
21476
  /**
20697
- * The top-level `overrides:` block of a `pnpm-workspace.yaml`, read line by line.
21477
+ * A top-level block mapping of a `pnpm-workspace.yaml` — `overrides:`,
21478
+ * `catalog:` — read line by line.
20698
21479
  *
20699
21480
  * Deliberately narrow. The file is YAML, and the CLI carries no YAML parser; the
20700
21481
  * shape pnpm documents and every project writes is a block mapping of one
@@ -20702,8 +21483,8 @@ function readYamlScalar(rest) {
20702
21483
  * block is counted rather than guessed at, and an inline `overrides: { … }` is
20703
21484
  * reported as not handled.
20704
21485
  */
20705
- function readYamlOverrides(lines) {
20706
- const keyLine = lines.findIndex((line) => /^overrides:\s*(#.*)?$/.test(line));
21486
+ function readYamlBlock(lines, key) {
21487
+ const keyLine = lines.findIndex((line) => new RegExp(`^${key}:\\s*(#.*)?$`).test(line));
20707
21488
  if (keyLine === -1) return null;
20708
21489
  const block = {
20709
21490
  keyLine,
@@ -20716,29 +21497,142 @@ function readYamlOverrides(lines) {
20716
21497
  if (line.trim() !== "" && !/^\s/.test(line) && !line.startsWith("#")) break;
20717
21498
  block.lines.push(index);
20718
21499
  if (line.trim() === "" || /^\s*#/.test(line)) continue;
20719
- const entry = /^(\s+)("[^"]*"|'[^']*'|[^\s"'#][^:#]*?)\s*:(\s+|$)/.exec(line);
20720
- const valueOffset = entry ? entry[0].length : -1;
20721
- const scalar = entry ? readYamlScalar(line.slice(valueOffset)) : null;
20722
- if (!entry || !scalar) {
20723
- block.otherContent++;
21500
+ const entry = readYamlEntry(line, index);
21501
+ if (entry) block.entries.push(entry);
21502
+ else block.otherContent++;
21503
+ }
21504
+ return block;
21505
+ }
21506
+ /** One `key: scalar` line of a block mapping, or null when it is anything else. */
21507
+ function readYamlEntry(line, index) {
21508
+ const entry = /^(\s+)("[^"]*"|'[^']*'|[^\s"'#][^:#]*?)\s*:(\s+|$)/.exec(line);
21509
+ const valueOffset = entry ? entry[0].length : -1;
21510
+ const scalar = entry ? readYamlScalar(line.slice(valueOffset)) : null;
21511
+ if (!entry || !scalar) return null;
21512
+ const rawKey = entry[2];
21513
+ return {
21514
+ line: index,
21515
+ key: /^["']/.test(rawKey) ? rawKey.slice(1, -1) : rawKey,
21516
+ value: scalar.value,
21517
+ valueStart: valueOffset + scalar.start,
21518
+ valueEnd: valueOffset + scalar.end
21519
+ };
21520
+ }
21521
+ /**
21522
+ * Every catalog a `pnpm-workspace.yaml` declares: the top-level `catalog:`
21523
+ * block, and each named block under `catalogs:`.
21524
+ *
21525
+ * `catalogs:` is one level deeper — a header line per catalog, its entries
21526
+ * indented under it — and is read with the same one-scalar-per-line rule.
21527
+ */
21528
+ function readYamlCatalogs(lines) {
21529
+ const catalogs = [];
21530
+ const single = readYamlBlock(lines, "catalog");
21531
+ if (single) catalogs.push({
21532
+ name: "default",
21533
+ field: "catalog",
21534
+ entries: single.entries
21535
+ });
21536
+ const keyLine = lines.findIndex((line) => /^catalogs:\s*(#.*)?$/.test(line));
21537
+ if (keyLine === -1) return catalogs;
21538
+ let current = null;
21539
+ let headerIndent = -1;
21540
+ for (let index = keyLine + 1; index < lines.length; index++) {
21541
+ const line = lines[index];
21542
+ if (line.trim() !== "" && !/^\s/.test(line) && !line.startsWith("#")) break;
21543
+ if (line.trim() === "" || /^\s*#/.test(line)) continue;
21544
+ const indent = line.length - line.trimStart().length;
21545
+ const header = /^\s+("[^"]*"|'[^']*'|[^\s"'#][^:#]*?)\s*:\s*(#.*)?$/.exec(line);
21546
+ if (header && (headerIndent === -1 || indent === headerIndent)) {
21547
+ headerIndent = indent;
21548
+ const name = /^["']/.test(header[1]) ? header[1].slice(1, -1) : header[1];
21549
+ current = {
21550
+ name,
21551
+ field: `catalogs.${name}`,
21552
+ entries: []
21553
+ };
21554
+ catalogs.push(current);
20724
21555
  continue;
20725
21556
  }
20726
- const rawKey = entry[2];
20727
- const key = /^["']/.test(rawKey) ? rawKey.slice(1, -1) : rawKey;
20728
- block.entries.push({
20729
- line: index,
20730
- key,
20731
- value: scalar.value,
20732
- valueStart: valueOffset + scalar.start,
20733
- valueEnd: valueOffset + scalar.end
21557
+ if (!current || indent <= headerIndent) continue;
21558
+ const entry = readYamlEntry(line, index);
21559
+ if (entry) current.entries.push(entry);
21560
+ }
21561
+ return catalogs;
21562
+ }
21563
+ /** A `pnpm-workspace.yaml`, with its overrides and then its catalogs moved. */
21564
+ function planWorkspaceYaml(file, original, target, options, plan, ledger) {
21565
+ return planYamlCatalogs(file, planYamlOverrides(file, original, target, options, plan, ledger), target, plan, ledger);
21566
+ }
21567
+ /**
21568
+ * Move the framework entries of every catalog. Entries are rewritten in place
21569
+ * — a catalog loses no line — and the result is read back and held to what was
21570
+ * meant, like every other rewrite here.
21571
+ */
21572
+ function planYamlCatalogs(file, original, target, plan, ledger) {
21573
+ const lines = splitLines(original);
21574
+ const catalogs = readYamlCatalogs(lines);
21575
+ for (const key of ["catalog", "catalogs"]) {
21576
+ const inline = lines.find((line) => new RegExp(`^${key}:\\s*[^\\s#]`).test(line));
21577
+ if (inline && inline.includes("@rebasepro/")) plan.skipped.push({
21578
+ file,
21579
+ name: key,
21580
+ field: key,
21581
+ spec: inline.slice(key.length + 1).trim(),
21582
+ reason: `an inline ${key} map, which this does not rewrite; write it as a block, or edit it by hand`
20734
21583
  });
20735
21584
  }
20736
- return block;
21585
+ if (catalogs.length === 0) return original;
21586
+ const next = [...lines];
21587
+ const expected = [];
21588
+ for (const catalog of catalogs) for (const entry of catalog.entries) {
21589
+ const meant = [
21590
+ catalog.field,
21591
+ entry.key,
21592
+ entry.value
21593
+ ];
21594
+ expected.push(meant);
21595
+ if (!entry.key.startsWith("@rebasepro/")) continue;
21596
+ ledger.defined.add(catalogKey(catalog.name, entry.key));
21597
+ const cls = classifySpec(entry.value);
21598
+ if (cls.kind !== "movable") {
21599
+ plan.skipped.push({
21600
+ file,
21601
+ name: entry.key,
21602
+ field: catalog.field,
21603
+ spec: entry.value,
21604
+ reason: leftAloneReason(cls)
21605
+ });
21606
+ continue;
21607
+ }
21608
+ const to = `${cls.prefix}${target}`;
21609
+ if (to === entry.value) {
21610
+ plan.unchanged++;
21611
+ continue;
21612
+ }
21613
+ meant[2] = to;
21614
+ const line = next[entry.line];
21615
+ next[entry.line] = line.slice(0, entry.valueStart) + to + line.slice(entry.valueEnd);
21616
+ plan.changed.push({
21617
+ file,
21618
+ name: entry.key,
21619
+ field: catalog.field,
21620
+ from: entry.value,
21621
+ to
21622
+ });
21623
+ }
21624
+ const content = next.join(original.includes("\r\n") ? "\r\n" : "\n");
21625
+ if (!isDeepStrictEqual(readYamlCatalogs(splitLines(content)).flatMap((catalog) => catalog.entries.map((e) => [
21626
+ catalog.field,
21627
+ e.key,
21628
+ e.value
21629
+ ])), expected)) throw new UpgradeError(`Could not rewrite ${file} without disturbing it; nothing was written.`, "rewrite_failed", "Move its @rebasepro catalog entries by hand, then run `rebase upgrade` again.");
21630
+ return content;
20737
21631
  }
20738
- function planWorkspaceYaml(file, original, target, options, plan) {
21632
+ function planYamlOverrides(file, original, target, options, plan, ledger) {
20739
21633
  const lines = splitLines(original);
20740
21634
  const crlf = original.includes("\r\n");
20741
- const block = readYamlOverrides(lines);
21635
+ const block = readYamlBlock(lines, "overrides");
20742
21636
  if (!block) {
20743
21637
  const inline = lines.find((line) => /^overrides:\s*[^\s#]/.test(line));
20744
21638
  if (inline && inline.includes("@rebasepro/")) plan.skipped.push({
@@ -20759,10 +21653,14 @@ function planWorkspaceYaml(file, original, target, options, plan) {
20759
21653
  continue;
20760
21654
  }
20761
21655
  const cls = classifySpec(entry.value);
21656
+ const pkg = overrideTarget(entry.key);
20762
21657
  if (cls.kind === "movable") {
20763
21658
  const to = `${cls.prefix}${target}`;
20764
21659
  expected.set(entry.key, to);
20765
- if (to === entry.value) continue;
21660
+ if (to === entry.value) {
21661
+ plan.unchanged++;
21662
+ continue;
21663
+ }
20766
21664
  const line = lines[entry.line];
20767
21665
  next[entry.line] = line.slice(0, entry.valueStart) + to + line.slice(entry.valueEnd);
20768
21666
  plan.overrides.push({
@@ -20772,6 +21670,16 @@ function planWorkspaceYaml(file, original, target, options, plan) {
20772
21670
  action: "bumped",
20773
21671
  to
20774
21672
  });
21673
+ } else if (cls.kind === "catalog" && pkg) {
21674
+ expected.set(entry.key, entry.value);
21675
+ ledger.refs.push({
21676
+ file,
21677
+ name: entry.key,
21678
+ field: "overrides",
21679
+ spec: entry.value,
21680
+ catalog: cls.catalog,
21681
+ pkg
21682
+ });
20775
21683
  } else if (cls.kind === "local") if (options.dropLocalOverrides) {
20776
21684
  removed.add(entry.line);
20777
21685
  plan.overrides.push({
@@ -20796,14 +21704,14 @@ function planWorkspaceYaml(file, original, target, options, plan) {
20796
21704
  name: entry.key,
20797
21705
  field: "overrides",
20798
21706
  spec: entry.value,
20799
- reason: cls.reason
21707
+ reason: leftAloneReason(cls)
20800
21708
  });
20801
21709
  }
20802
21710
  }
20803
21711
  const survivors = block.entries.filter((entry) => !removed.has(entry.line)).length + block.otherContent;
20804
21712
  if (removed.size > 0 && survivors === 0) removed.add(block.keyLine);
20805
21713
  const content = next.filter((_, index) => !removed.has(index)).join(crlf ? "\r\n" : "\n");
20806
- const after = readYamlOverrides(splitLines(content));
21714
+ const after = readYamlBlock(splitLines(content), "overrides");
20807
21715
  if (!isDeepStrictEqual(after ? after.entries.map((e) => [e.key, e.value]) : [], [...expected.entries()])) throw new UpgradeError(`Could not rewrite ${file} without disturbing it; nothing was written.`, "rewrite_failed", "Move its @rebasepro overrides by hand, then run `rebase upgrade` again.");
20808
21716
  return content;
20809
21717
  }
@@ -20947,7 +21855,9 @@ ${chalk.bold("What it changes")}
20947
21855
  in every package.json under the project (node_modules, dist* and hidden
20948
21856
  directories excepted). ^ and ~ are kept. peerDependencies are left alone, and so
20949
21857
  are workspace:, link:, file:, git and tag specs, which are listed with the reason.
20950
- Overrides in pnpm-workspace.yaml and package.json are bumped the same way.
21858
+ Overrides in pnpm-workspace.yaml and package.json are bumped the same way, and so
21859
+ are the entries of pnpm-workspace.yaml's catalog: and catalogs: blocks, which is
21860
+ where a catalog: pin's version lives.
20951
21861
 
20952
21862
  ${chalk.bold("Examples")}
20953
21863
  rebase upgrade Move to the latest release and install
@@ -21111,6 +22021,15 @@ function printSummary(plan, dryRun) {
21111
22021
  }
21112
22022
  function printOutcome(plan, outcome) {
21113
22023
  const moved = plan.changed.length + plan.overrides.filter((o) => o.action !== "kept-local").length;
22024
+ const leftAlone = plan.skipped.length + plan.overrides.filter((o) => o.action === "kept-local").length;
22025
+ const entries = (n) => `${n} entr${n === 1 ? "y" : "ies"}`;
22026
+ if (moved === 0 && leftAlone > 0) {
22027
+ if (plan.unchanged > 0) console.log(chalk.green(`✓ Every pin this can move is already on ${plan.target}; ${entries(leftAlone)} ${leftAlone === 1 ? "was" : "were"} left alone, above.`));
22028
+ else if (outcome.dryRun) console.log(chalk.yellow(` Nothing to move to ${plan.target}: ${entries(leftAlone)} ${leftAlone === 1 ? "is" : "are"} left alone, above.`));
22029
+ else console.log(chalk.yellow(`⚠ Nothing moved to ${plan.target}: ${entries(leftAlone)} ${leftAlone === 1 ? "was" : "were"} left alone, above.`));
22030
+ console.log("");
22031
+ return;
22032
+ }
21114
22033
  if (outcome.dryRun) {
21115
22034
  console.log(chalk.gray(moved === 0 ? ` Nothing to change: this project is already on ${plan.target}.` : " Dry run — nothing was written. Run it without --dry-run to apply."));
21116
22035
  console.log("");
@@ -21166,6 +22085,85 @@ var ROOT_FLAGS = {
21166
22085
  "-v": "--version",
21167
22086
  "-h": "--help"
21168
22087
  };
22088
+ /**
22089
+ * Every command the dispatch below answers and the help lists. Declared as a
22090
+ * literal under this name: `help-coverage.test.ts`, `printed-commands.test.ts`
22091
+ * and the docs verifier read it out of this file's source.
22092
+ */
22093
+ var namespacedCommands = [
22094
+ "init",
22095
+ "schema",
22096
+ "db",
22097
+ "dev",
22098
+ "build",
22099
+ "start",
22100
+ "auth",
22101
+ "doctor",
22102
+ "skills",
22103
+ "api-keys",
22104
+ "cloud",
22105
+ "apps",
22106
+ "eject",
22107
+ "generate-sdk",
22108
+ "telemetry",
22109
+ "resources",
22110
+ "status",
22111
+ "upgrade"
22112
+ ];
22113
+ /**
22114
+ * The subcommands each command group dispatches, from the lists those groups
22115
+ * dispatch on. A command absent from here takes no subcommand.
22116
+ */
22117
+ function knownSubcommands(command) {
22118
+ switch (command) {
22119
+ case "schema": return SCHEMA_SUBCOMMANDS;
22120
+ case "db": return DB_SUBCOMMANDS;
22121
+ case "auth": return AUTH_SUBCOMMANDS;
22122
+ case "skills": return SKILLS_SUBCOMMANDS;
22123
+ case "api-keys": return API_KEYS_SUBCOMMANDS;
22124
+ case "apps": return APPS_SUBCOMMANDS;
22125
+ case "telemetry": return TELEMETRY_SUBCOMMANDS;
22126
+ case "cloud": return CLOUD_GROUP_NAMES;
22127
+ default: return [];
22128
+ }
22129
+ }
22130
+ /**
22131
+ * The command and subcommand words as `cli.error` may send them: each one is a
22132
+ * word this CLI dispatches, `"none"` when nothing was typed, or `"other"`.
22133
+ *
22134
+ * The words come from what was typed, and what is typed after a command is
22135
+ * only sometimes a subcommand. `rebase init acme-payroll-internal`, `rebase
22136
+ * cloud --project acme-prod deploy` and `rebase db psuh` put a project name, a
22137
+ * slug and a typo in the subcommand's position — the consent screen promises
22138
+ * "never project names", and `sanitize` cannot tell one of those from a
22139
+ * subcommand because neither has a separator in it. So the words are held
22140
+ * against the vocabulary here, where it is known, and nothing else survives.
22141
+ *
22142
+ * Exported for `cli.test.ts`.
22143
+ */
22144
+ function telemetryCommandWords(command, subcommand) {
22145
+ if (!command) return {
22146
+ command: "none",
22147
+ subcommand: "none"
22148
+ };
22149
+ if (!(namespacedCommands.includes(command) || command === "normalize-imports" || command === "__dev-db-daemon")) return {
22150
+ command: "other",
22151
+ subcommand: subcommand ? "other" : "none"
22152
+ };
22153
+ if (!subcommand) return {
22154
+ command,
22155
+ subcommand: "none"
22156
+ };
22157
+ if (subcommand === "--help") return {
22158
+ command,
22159
+ subcommand
22160
+ };
22161
+ const canonical = command === "cloud" ? GROUP_ALIASES[subcommand] ?? subcommand : subcommand;
22162
+ return {
22163
+ command,
22164
+ subcommand: knownSubcommands(command).includes(canonical) ? canonical : "other"
22165
+ };
22166
+ }
21169
22167
  async function entry(args) {
21170
22168
  silenceDotenvBanner();
21171
22169
  const parsedArgs = arg({ ...ROOT_FLAGS }, {
@@ -21179,26 +22177,6 @@ async function entry(args) {
21179
22177
  const words = parsedArgs._.filter((a) => !a.startsWith("-"));
21180
22178
  const command = words[0];
21181
22179
  const subcommand = words[1];
21182
- const namespacedCommands = [
21183
- "init",
21184
- "schema",
21185
- "db",
21186
- "dev",
21187
- "build",
21188
- "start",
21189
- "auth",
21190
- "doctor",
21191
- "skills",
21192
- "api-keys",
21193
- "cloud",
21194
- "apps",
21195
- "eject",
21196
- "generate-sdk",
21197
- "telemetry",
21198
- "resources",
21199
- "status",
21200
- "upgrade"
21201
- ];
21202
22180
  if (!command || parsedArgs["--help"] && !namespacedCommands.includes(command)) {
21203
22181
  printHelp();
21204
22182
  return;
@@ -21208,8 +22186,7 @@ async function entry(args) {
21208
22186
  await dispatch(command, effectiveSubcommand, args, parsedArgs, namespacedCommands);
21209
22187
  } catch (error) {
21210
22188
  await recordEvent("cli.error", {
21211
- command: command ?? "none",
21212
- subcommand: effectiveSubcommand ?? "none",
22189
+ ...telemetryCommandWords(command, effectiveSubcommand),
21213
22190
  error_type: errorClass(error),
21214
22191
  usage: Boolean(error && typeof error === "object" && error.isUsageError)
21215
22192
  }, { projectRoot: process.cwd() });
@@ -21403,6 +22380,6 @@ function telemetryNotice() {
21403
22380
  return chalk.gray(`Usage sharing: ${sharing ? "on" : "off"} — ${chalk.cyan("rebase telemetry")} to inspect or change\n`);
21404
22381
  }
21405
22382
  //#endregion
21406
- export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, ManifestError, VENDOR_SIZE_MAX_BYTES, VENDOR_SIZE_WARN_BYTES, VENDOR_TARGET_CPU, VENDOR_TARGET_OS, assessManagedCompatibility, buildBundle, buildStaticBundle, buildableApps, cmsMountOf, collectDeclaredDependencies, commentSpans, composeBundleManifest, detectDeclaredDepConflicts, detectFrameworkDepDrift, detectNativeDependencies, detectStorageAuthorize, entry, findBackendApp, findUnusedServerEntry, foldStaticIntoBundle, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, resolveBackendPaths, selectDeployApp, synthesizeManifest, validateManifest, vendorDependencies, writeManifest };
22383
+ export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, ManifestError, VENDOR_SIZE_MAX_BYTES, VENDOR_SIZE_WARN_BYTES, VENDOR_TARGET_CPU, VENDOR_TARGET_OS, assessManagedCompatibility, buildBundle, buildStaticBundle, buildableApps, cmsMountOf, collectDeclaredDependencies, commentSpans, composeBundleManifest, describeDepConflicts, detectDeclaredDepConflicts, detectFrameworkDepDrift, detectNativeDependencies, detectStorageAuthorize, entry, findBackendApp, findUnusedServerEntry, foldStaticIntoBundle, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, resolveBackendPaths, selectDeployApp, synthesizeManifest, validateManifest, vendorDependencies, writeManifest };
21407
22384
 
21408
22385
  //# sourceMappingURL=index.es.js.map