@rebasepro/cli 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -13,7 +13,7 @@ import path from "path";
13
13
  import * as fs$1 from "fs";
14
14
  import fs from "fs";
15
15
  import net from "net";
16
- import { promisify } from "util";
16
+ import { isDeepStrictEqual, promisify } from "util";
17
17
  import { execa, execaCommandSync } from "execa";
18
18
  import { cp } from "fs/promises";
19
19
  import { fileURLToPath, pathToFileURL } from "url";
@@ -22,7 +22,7 @@ import { execFileSync, spawn, spawnSync } from "child_process";
22
22
  import os from "os";
23
23
  import { createRebaseClient } from "@rebasepro/client";
24
24
  import dotenv from "dotenv";
25
- import { BUNDLE_FORMAT_VERSION, DEFAULT_DATA_SOURCE_KEY, DEFAULT_RESOURCE_KEY, DEFAULT_STORAGE_SOURCE_KEY, RUNTIME_CONTRACT_VERSION, buildResourceGraph, computeSchemaVersion, declareFunction, declaredQueueConsumers, declaredResources, declaredSubscriptions, deserializeCollections, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getDataSourceCapabilities, isResourceHandle, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveResourceRefs, resourceEnvSuffix, resourceId, resourceKeyOf, resourceToDataSource } from "@rebasepro/types";
25
+ import { BUNDLE_FORMAT_VERSION, DEFAULT_DATA_SOURCE_KEY, DEFAULT_RESOURCE_KEY, DEFAULT_STORAGE_SOURCE_KEY, RUNTIME_CONTRACT_VERSION, buildResourceGraph, computeSchemaVersion, declareFunction, declaredQueueConsumers, declaredResources, declaredSubscriptions, deserializeCollections, envBasesForResource, findEnvSuffixCollision, findStorageSuffixCollision, getDataSourceCapabilities, isResourceHandle, parseEnvBoolean, reservedPrefixFor, resetDeclaredQueueConsumers, resetDeclaredResources, resetDeclaredSubscriptions, resolveResourceRefs, resourceEnvSuffix, resourceId, resourceKeyOf, resourceToDataSource } from "@rebasepro/types";
26
26
  import { CodegenError, generateSDK, toSafeIdentifier } from "@rebasepro/codegen";
27
27
  import { createRequire } from "module";
28
28
  import { randomBytes as randomBytes$1 } from "node:crypto";
@@ -186,6 +186,29 @@ function parseCommandArgs({ spec, rawArgs, commandWords, command, maxPositionals
186
186
  //#endregion
187
187
  //#region src/commands/cloud/errors.ts
188
188
  /**
189
+ * Turning a control-plane failure into something the caller can act on.
190
+ *
191
+ * The control plane talks to Kubernetes, and when Kubernetes refuses it, the
192
+ * refusal travels back verbatim: a whole `Status` object, the request headers,
193
+ * an `audit-id`, an `x-kubernetes-pf-flowschema-uid`. That reached the user's
194
+ * terminal unedited. Two things are wrong with it beyond the noise.
195
+ *
196
+ * The first is that the one sentence that matters is buried in the middle of a
197
+ * JSON blob, so the remedy — if there is one — is the hardest part to find.
198
+ *
199
+ * The second is worse, and is the reason this file exists rather than a
200
+ * `slice(0, 200)`. A `403` naming a `system:serviceaccount:` is a statement
201
+ * about the PLATFORM's own credentials: some role the control plane runs as is
202
+ * missing a grant. Nothing in the user's project can change that — not the
203
+ * collections, not `rebase.json`, not the deploy flags — and the error, printed
204
+ * raw in the middle of `rebase cloud deploy`, reads exactly like a project
205
+ * fault. Someone acting on that reading deletes working code looking for the
206
+ * cause. (That is not hypothetical: three cron jobs were removed from a project
207
+ * to test whether they caused a `cronjobs.batch` 403. They did not.)
208
+ *
209
+ * So this classifies before it summarises, and says whose problem it is.
210
+ */
211
+ /**
189
212
  * The service accounts a Kubernetes 403 can name, and what each means.
190
213
  *
191
214
  * `system:serviceaccount:` is the platform's own identity — the control plane's
@@ -308,9 +331,14 @@ function truncate(text) {
308
331
  * `--debug` is already what `bin/rebase.js` prints after every failure as the
309
332
  * thing to add, so the raw payload hangs off the flag people are told to reach
310
333
  * for rather than off one invented here.
334
+ *
335
+ * The one reader of `REBASE_DEBUG` in the commands, spelled as the bin spells
336
+ * it. It used to be `=== "1"` here while the quote fallbacks in `resources.ts`
337
+ * tested the raw string for truthiness, so `=true` hid the body and `=0`
338
+ * printed the fallback.
311
339
  */
312
340
  function wantsRawError(argv = process.argv) {
313
- return argv.includes("--debug") || process.env.REBASE_DEBUG === "1";
341
+ return argv.includes("--debug") || parseEnvBoolean(process.env.REBASE_DEBUG) === true;
314
342
  }
315
343
  //#endregion
316
344
  //#region src/commands/cloud/context.ts
@@ -714,8 +742,7 @@ function initOutputMode(rawArgs) {
714
742
  argv: rawArgs.slice(2),
715
743
  permissive: true
716
744
  })["--json"]) JSON_MODE = true;
717
- else if (process.env.REBASE_JSON === "0") JSON_MODE = false;
718
- else JSON_MODE = process.env.REBASE_JSON === "1" || process.stdout.isTTY !== true;
745
+ else JSON_MODE = parseEnvBoolean(process.env.REBASE_JSON) ?? process.stdout.isTTY !== true;
719
746
  return JSON_MODE;
720
747
  }
721
748
  /** Whether the current invocation is emitting machine-readable JSON. */
@@ -1446,6 +1473,18 @@ function endpoint() {
1446
1473
  return process.env.REBASE_TELEMETRY_ENDPOINT?.trim() || "https://app.rebase.pro/api/functions/telemetry";
1447
1474
  }
1448
1475
  /**
1476
+ * Set to anything but a spelled no.
1477
+ *
1478
+ * The conventions behind these variables make presence the signal —
1479
+ * `CI=woodpecker` is a CI run, `DO_NOT_TRACK=please` is an answer — so a value
1480
+ * the parser does not recognise still counts, and the refusal holds. Only the
1481
+ * platform's spellings of no turn one off. It was `!== "0"` for two of them and
1482
+ * `!== "false"` for `CI`, so `CI=0` read as a runner.
1483
+ */
1484
+ function setAndNotNo(value) {
1485
+ return value !== void 0 && value.trim() !== "" && parseEnvBoolean(value) !== false;
1486
+ }
1487
+ /**
1449
1488
  * The one function that decides whether anything leaves the machine.
1450
1489
  *
1451
1490
  * Every path is a refusal except the last, which is the point: consent is
@@ -1465,9 +1504,9 @@ function endpoint() {
1465
1504
  * project.ts on why the reverse is refused.
1466
1505
  */
1467
1506
  function suppressionReason(env = process.env, cwd = process.cwd()) {
1468
- if (env.DO_NOT_TRACK && env.DO_NOT_TRACK !== "0") return "do_not_track";
1469
- if (env.REBASE_TELEMETRY_DISABLED && env.REBASE_TELEMETRY_DISABLED !== "0") return "rebase_telemetry_disabled";
1470
- if (env.CI && env.CI !== "false") return "ci";
1507
+ if (setAndNotNo(env.DO_NOT_TRACK)) return "do_not_track";
1508
+ if (setAndNotNo(env.REBASE_TELEMETRY_DISABLED)) return "rebase_telemetry_disabled";
1509
+ if (setAndNotNo(env.CI)) return "ci";
1471
1510
  if (readProjectPolicy(cwd) === "opt_out") return "project_opt_out";
1472
1511
  const config = readConfig();
1473
1512
  if (config.enabled === void 0) return "not_asked";
@@ -2323,11 +2362,11 @@ async function probeRelease(version) {
2323
2362
  };
2324
2363
  return {
2325
2364
  kind: "unreachable",
2326
- reason: firstLine(text) || "the registry could not be reached"
2365
+ reason: firstLine$1(text) || "the registry could not be reached"
2327
2366
  };
2328
2367
  }
2329
2368
  }
2330
- function firstLine(text) {
2369
+ function firstLine$1(text) {
2331
2370
  return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
2332
2371
  }
2333
2372
  async function replacePlaceholders(options) {
@@ -2343,7 +2382,7 @@ async function replacePlaceholders(options) {
2343
2382
  const matches = [...content.matchAll(/"(@rebasepro\/[^"]+)":\s*"workspace:\*"/g)];
2344
2383
  for (const match of matches) allPackages.add(match[1]);
2345
2384
  }
2346
- const probe = process.env.REBASE_E2E === "true" || version === "unknown" ? { kind: "published" } : await probeRelease(version);
2385
+ const probe = parseEnvBoolean(process.env.REBASE_E2E) === true || version === "unknown" ? { kind: "published" } : await probeRelease(version);
2347
2386
  if (probe.kind === "gap") throw new Error(`Rebase ${version} is not fully published to npm.\n\n${RELEASE_PROBE_PACKAGE} has no ${version} release — the registry has ${probe.found}.\nEvery @rebasepro package ships at one version, so scaffolding would pin ${allPackages.size}\ndependencies at a version that is not there, and the install would fail.\n\nThat is a release gap in Rebase itself — not a problem with your machine,
2348
2387
  your network, or your package manager.
2349
2388
 
@@ -4506,7 +4545,7 @@ function checkCmsPath(value, appPath, fieldPath, issues) {
4506
4545
  }
4507
4546
  return cms;
4508
4547
  }
4509
- function isRecord(value) {
4548
+ function isRecord$1(value) {
4510
4549
  return typeof value === "object" && value !== null && !Array.isArray(value);
4511
4550
  }
4512
4551
  /**
@@ -4643,7 +4682,7 @@ function isNearMiss(a, b) {
4643
4682
  }
4644
4683
  function validateApp(name, raw, issues) {
4645
4684
  const base = `apps.${name}`;
4646
- if (!isRecord(raw)) {
4685
+ if (!isRecord$1(raw)) {
4647
4686
  issues.push({
4648
4687
  path: base,
4649
4688
  message: "must be an object"
@@ -4743,7 +4782,7 @@ function validateApp(name, raw, issues) {
4743
4782
  */
4744
4783
  function validateManifest(raw) {
4745
4784
  const issues = [];
4746
- if (!isRecord(raw)) return { issues: [{
4785
+ if (!isRecord$1(raw)) return { issues: [{
4747
4786
  path: "",
4748
4787
  message: `${MANIFEST_FILENAME} must contain a JSON object`
4749
4788
  }] };
@@ -4754,7 +4793,7 @@ function validateManifest(raw) {
4754
4793
  message
4755
4794
  });
4756
4795
  }
4757
- if (!isRecord(raw.apps)) {
4796
+ if (!isRecord$1(raw.apps)) {
4758
4797
  issues.push({
4759
4798
  path: "apps",
4760
4799
  message: "is required and must be an object"
@@ -4856,7 +4895,7 @@ function refuseStorageBlock(raw, issues) {
4856
4895
  * else. It used to be inferred from the presence of `backend/src/index.ts`,
4857
4896
  * which every scaffolded project had whether or not it wanted its own server, so
4858
4897
  * projects predating the manifest silently landed on the custom runtime and paid
4859
- * for it (see `docs/plans/cloud-deploy-workspace-vendoring.md`).
4898
+ * for it.
4860
4899
  */
4861
4900
  function synthesizeManifest(projectRoot) {
4862
4901
  const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
@@ -4942,7 +4981,7 @@ var MODELLED_MANIFEST_KEYS = [
4942
4981
  function readManifestObject(filePath) {
4943
4982
  try {
4944
4983
  const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
4945
- return isRecord(parsed) ? parsed : {};
4984
+ return isRecord$1(parsed) ? parsed : {};
4946
4985
  } catch {
4947
4986
  return {};
4948
4987
  }
@@ -5774,8 +5813,8 @@ async function devCommand(rawArgs) {
5774
5813
  * gating only the preflight left the managed PGlite starting anyway, which
5775
5814
  * is the one database a scaffolded project would otherwise get.
5776
5815
  */
5777
- const noDb = Boolean(args["--no-db"]) || process.env.REBASE_DEV_NO_DB === "1";
5778
- const shouldGenerate = args["--generate"] || process.env.REBASE_AUTO_GENERATE === "true" || process.env.REBASE_GENERATE === "true";
5816
+ const noDb = Boolean(args["--no-db"]) || parseEnvBoolean(process.env.REBASE_DEV_NO_DB) === true;
5817
+ const shouldGenerate = args["--generate"] || parseEnvBoolean(process.env.REBASE_AUTO_GENERATE) === true || parseEnvBoolean(process.env.REBASE_GENERATE) === true;
5779
5818
  const { port: startPort, source: startPortSource } = resolveStartPort(projectRoot, args["--port"]);
5780
5819
  const portIsPinned = args["--port"] !== void 0;
5781
5820
  const pinnedFrontendPort = process.env.REBASE_FRONTEND_PORT;
@@ -7184,7 +7223,7 @@ function collectDeclaredDependencies(projectRoot) {
7184
7223
  if (typeof version === "string" && version.startsWith("workspace:")) continue;
7185
7224
  if (resolvesToWorkspacePackage(projectRoot, name)) continue;
7186
7225
  const protocol = typeof version === "string" ? nonRegistrySpecifier(version) : null;
7187
- if (protocol && process.env.REBASE_E2E === "true" && protocol === "file:") {
7226
+ if (protocol && parseEnvBoolean(process.env.REBASE_E2E) === true && protocol === "file:") {
7188
7227
  declared[name] = version;
7189
7228
  continue;
7190
7229
  }
@@ -8785,7 +8824,7 @@ function projectResourceGraph(graph) {
8785
8824
  * entrypoint, falls back to the previous behaviour: run every workspace's own
8786
8825
  * `build` script. Nothing that built before stops building.
8787
8826
  */
8788
- function printHelp$5() {
8827
+ function printHelp$6() {
8789
8828
  console.log(`
8790
8829
  ${chalk.bold("rebase build")} — build the apps declared in rebase.json
8791
8830
 
@@ -8847,7 +8886,7 @@ function dockerBuildHint(projectRoot, name, app) {
8847
8886
  }
8848
8887
  async function buildCommand(rawArgs = []) {
8849
8888
  if (wantsHelp(rawArgs)) {
8850
- printHelp$5();
8889
+ printHelp$6();
8851
8890
  return;
8852
8891
  }
8853
8892
  const { flags: args, positionals: requested } = parseCommandArgs({
@@ -8973,10 +9012,12 @@ async function buildCommand(rawArgs = []) {
8973
9012
  console.log(chalk.yellow(` ⚠ framework dependencies older than this CLI (${cliVersion$1()}):`));
8974
9013
  for (const dep of drift.behind) console.log(chalk.dim(` ${dep.name}@${dep.range} (${dep.file})`));
8975
9014
  console.log(chalk.dim(" The image supplies the server, but your bundle supplies the database"));
8976
- console.log(chalk.dim(" driver — a newer runtime does not update it. Bump these and rebuild."));
9015
+ console.log(chalk.dim(" driver — a newer runtime does not update it."));
9016
+ console.log(chalk.dim(` Run \`${upgradeToThisCli()}\`, then rebuild.`));
8977
9017
  } else if (drift.disagreeing.length > 0) {
8978
9018
  console.log(chalk.yellow(` ⚠ mixed @rebasepro versions declared: ${drift.disagreeing.join(", ")}`));
8979
- console.log(chalk.dim(" These are published together and expect to run together; pin them alike."));
9019
+ console.log(chalk.dim(" These are published together and expect to run together."));
9020
+ console.log(chalk.dim(` Run \`${upgradeToThisCli()}\` to pin them alike, then rebuild.`));
8980
9021
  }
8981
9022
  if (!args["--no-static"]) {
8982
9023
  const folded = await foldFrontendIntoBundle({
@@ -9052,6 +9093,17 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
9052
9093
  console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s) → served at ${basePath})`));
9053
9094
  return result.outDir;
9054
9095
  }
9096
+ /**
9097
+ * The `rebase upgrade` line the drift warnings print.
9098
+ *
9099
+ * Aimed at this CLI's own version, because that is what the warning measured
9100
+ * against: "older than this CLI" is answered by moving to exactly this CLI, not
9101
+ * to whatever `latest` happens to be — which, on a canary, is older still.
9102
+ */
9103
+ function upgradeToThisCli() {
9104
+ const version = cliVersion$1();
9105
+ return version === "unknown" ? "rebase upgrade" : `rebase upgrade --to ${version}`;
9106
+ }
9055
9107
  /** The pre-manifest behaviour: build every workspace package. */
9056
9108
  async function runWorkspaceBuilds(projectRoot) {
9057
9109
  const pm = detectPackageManager(projectRoot);
@@ -9201,7 +9253,7 @@ function projectNameOf(projectRoot) {
9201
9253
  } catch {}
9202
9254
  return path.basename(projectRoot);
9203
9255
  }
9204
- function printHelp$4() {
9256
+ function printHelp$5() {
9205
9257
  console.log(`
9206
9258
  ${chalk.bold("rebase eject")} — take ownership of the server process
9207
9259
 
@@ -9222,7 +9274,7 @@ ${chalk.bold("Options")}
9222
9274
  }
9223
9275
  async function ejectCommand(rawArgs = []) {
9224
9276
  if (wantsHelp(rawArgs)) {
9225
- printHelp$4();
9277
+ printHelp$5();
9226
9278
  return;
9227
9279
  }
9228
9280
  const { flags: args, positionals } = parseCommandArgs({
@@ -9404,7 +9456,7 @@ function restoreBackendScripts(projectRoot) {
9404
9456
  * `rebase.json`) this falls back to the backend workspace's own `start` script,
9405
9457
  * which is what such a project has always used.
9406
9458
  */
9407
- function printHelp$3() {
9459
+ function printHelp$4() {
9408
9460
  console.log(`
9409
9461
  ${chalk.bold("rebase start")} — run a built bundle
9410
9462
 
@@ -9421,7 +9473,7 @@ Build first with ${chalk.cyan("rebase build")}.
9421
9473
  }
9422
9474
  async function startCommand(rawArgs = []) {
9423
9475
  if (wantsHelp(rawArgs)) {
9424
- printHelp$3();
9476
+ printHelp$4();
9425
9477
  return;
9426
9478
  }
9427
9479
  const { flags: args } = parseCommandArgs({
@@ -10017,7 +10069,7 @@ function checkNodeVersion(running, required) {
10017
10069
  }];
10018
10070
  }
10019
10071
  /** The lockfiles a project may have, and the manager each one belongs to. */
10020
- var LOCKFILES = {
10072
+ var LOCKFILES$1 = {
10021
10073
  "pnpm-lock.yaml": "pnpm",
10022
10074
  "package-lock.json": "npm",
10023
10075
  "yarn.lock": "yarn",
@@ -10034,18 +10086,18 @@ var LOCKFILES = {
10034
10086
  * at it.
10035
10087
  */
10036
10088
  function checkPackageManager(lockfilesPresent, declared) {
10037
- const known = lockfilesPresent.filter((file) => file in LOCKFILES);
10089
+ const known = lockfilesPresent.filter((file) => file in LOCKFILES$1);
10038
10090
  if (known.length < 2) {
10039
10091
  const declaredName = declared?.split("@")[0];
10040
- if (known.length === 1 && declaredName && LOCKFILES[known[0]] !== declaredName) return [{
10092
+ if (known.length === 1 && declaredName && LOCKFILES$1[known[0]] !== declaredName) return [{
10041
10093
  check: "package-manager",
10042
10094
  severity: "warning",
10043
- message: `This project declares packageManager "${declared}" and has a ${LOCKFILES[known[0]]} lockfile (${known[0]}).`,
10095
+ message: `This project declares packageManager "${declared}" and has a ${LOCKFILES$1[known[0]]} lockfile (${known[0]}).`,
10044
10096
  fix: `Delete ${known[0]} and node_modules, then install with ${declaredName}.`
10045
10097
  }];
10046
10098
  return [];
10047
10099
  }
10048
- const managers = known.map((file) => LOCKFILES[file]);
10100
+ const managers = known.map((file) => LOCKFILES$1[file]);
10049
10101
  return [{
10050
10102
  check: "package-manager",
10051
10103
  severity: "error",
@@ -10153,7 +10205,7 @@ function checkVersionSkew(declared) {
10153
10205
  check: "versions",
10154
10206
  severity: "error",
10155
10207
  message: `${name} is pinned to different versions in this project: ${described}.`,
10156
- fix: "Pin one version everywhere and reinstall. Two copies of a Rebase package break `instanceof` between them, which fails as a type guard rejecting its own type."
10208
+ fix: "Run `rebase upgrade` to pin every @rebasepro package to one release and reinstall. Two copies of a Rebase package break `instanceof` between them, which fails as a type guard rejecting its own type."
10157
10209
  });
10158
10210
  }
10159
10211
  return findings;
@@ -10279,7 +10331,7 @@ function collectEnvironmentFindings(projectRoot, envFile) {
10279
10331
  const findings = [];
10280
10332
  findings.push(...checkNodeVersion(process.versions.node, readCliEngines()));
10281
10333
  const rootPackage = readJson(path.join(projectRoot, "package.json"));
10282
- findings.push(...checkPackageManager(Object.keys(LOCKFILES).filter((file) => fs.existsSync(path.join(projectRoot, file))), typeof rootPackage?.packageManager === "string" ? rootPackage.packageManager : void 0));
10334
+ findings.push(...checkPackageManager(Object.keys(LOCKFILES$1).filter((file) => fs.existsSync(path.join(projectRoot, file))), typeof rootPackage?.packageManager === "string" ? rootPackage.packageManager : void 0));
10283
10335
  findings.push(...checkDuplicateSlugs(readDeclaredSlugs(projectRoot)));
10284
10336
  if (envFile && fs.existsSync(envFile)) try {
10285
10337
  findings.push(...checkEnvSanity(parseEnvFile(fs.readFileSync(envFile, "utf-8"))));
@@ -10346,7 +10398,7 @@ function readAtlasBinaryState(projectRoot) {
10346
10398
  }
10347
10399
  /** The reader's own package manager, from the lockfile beside their project. */
10348
10400
  function readPackageManagerName(projectRoot) {
10349
- for (const [file, manager] of Object.entries(LOCKFILES)) if (fs.existsSync(path.join(projectRoot, file))) return manager;
10401
+ for (const [file, manager] of Object.entries(LOCKFILES$1)) if (fs.existsSync(path.join(projectRoot, file))) return manager;
10350
10402
  return "pnpm";
10351
10403
  }
10352
10404
  /** The `engines.node` range of the CLI actually running. */
@@ -11903,7 +11955,7 @@ var TELEMETRY_SUBCOMMANDS = [
11903
11955
  ];
11904
11956
  async function telemetryCommand(rawArgs) {
11905
11957
  if (wantsHelp(rawArgs)) {
11906
- printHelp$2();
11958
+ printHelp$3();
11907
11959
  return;
11908
11960
  }
11909
11961
  const { positionals } = parseCommandArgs({
@@ -11981,7 +12033,7 @@ function printPayload() {
11981
12033
  console.log(chalk.gray(" hostname, your project name or anything you have typed."));
11982
12034
  console.log("");
11983
12035
  }
11984
- function printHelp$2() {
12036
+ function printHelp$3() {
11985
12037
  console.log(`
11986
12038
  ${chalk.bold("rebase telemetry")} — anonymous usage sharing (asked once per project)
11987
12039
 
@@ -13351,7 +13403,7 @@ async function billingCommand(rawArgs) {
13351
13403
  if (hasCluster) monthly = `${monthly} — platform fee, your own cluster`;
13352
13404
  }
13353
13405
  } catch (err) {
13354
- if (process.env.REBASE_DEBUG) console.error(` (no price: ${err instanceof Error ? err.message : String(err)})`);
13406
+ if (wantsRawError()) console.error(` (no price: ${err instanceof Error ? err.message : String(err)})`);
13355
13407
  }
13356
13408
  emit(() => {
13357
13409
  console.log("");
@@ -13470,7 +13522,7 @@ async function computeCommand(action, rawArgs) {
13470
13522
  path: "quote"
13471
13523
  });
13472
13524
  } catch (err) {
13473
- if (process.env.REBASE_DEBUG) console.error(` (no price: ${err instanceof Error ? err.message : String(err)})`);
13525
+ if (wantsRawError()) console.error(` (no price: ${err instanceof Error ? err.message : String(err)})`);
13474
13526
  }
13475
13527
  emit(() => {
13476
13528
  console.log("");
@@ -15081,7 +15133,13 @@ function bundleDeployBody(input) {
15081
15133
  ...input.commit ? {
15082
15134
  gitCommitHash: input.commit.hash,
15083
15135
  gitCommitMessage: input.commit.message
15084
- } : {}
15136
+ } : {},
15137
+ ...input.rebuildSource ? { rebuildSource: {
15138
+ sourceId: input.rebuildSource.sourceId,
15139
+ projectPath: input.rebuildSource.projectPath,
15140
+ buildEnv: input.rebuildSource.buildEnv
15141
+ } } : {},
15142
+ ...input.allowFrameworkDowngrade ? { allowFrameworkDowngrade: true } : {}
15085
15143
  };
15086
15144
  }
15087
15145
  /**
@@ -15122,10 +15180,393 @@ async function uploadBundle(url, token, projectId, tarPath) {
15122
15180
  return data.bundleId;
15123
15181
  }
15124
15182
  //#endregion
15183
+ //#region src/commands/cloud/rebuild-source.ts
15184
+ /**
15185
+ * The project's source, uploaded beside its bundle so the platform can rebuild it.
15186
+ *
15187
+ * A managed deploy ships a built bundle, and a bundle is fixed to the framework
15188
+ * release it was built on. When the platform rolls a newer release across the
15189
+ * fleet it has to rebuild each project from source — and the bundle path never
15190
+ * uploaded any: the control plane held a tarball of compiled output and
15191
+ * nothing it could compile again. So every bundle deploy now also packs the
15192
+ * source the bundle was built from, uploads it, and names it in the trigger.
15193
+ *
15194
+ * Three rules shape what goes in:
15195
+ *
15196
+ * - **Git decides, when there is a repository.** `git ls-files --cached
15197
+ * --others --exclude-standard` at the repository's top level is exactly what
15198
+ * the developer considers source: `.gitignore` honoured the way git honours
15199
+ * it, and a sibling package the project links (`link:../../packages/editor`)
15200
+ * included, because it is outside the project directory but inside the
15201
+ * repository.
15202
+ * - **Secrets never leave the machine, whatever git says.** A committed `.env`
15203
+ * is still a `.env`. {@link neverUploaded} is applied after git, not instead
15204
+ * of it.
15205
+ * - **It never costs the deploy.** The bundle is what runs; the source only
15206
+ * lets a later platform upgrade rebuild it. Too large, unpackable, refused by
15207
+ * the control plane — each is a warning, and the deploy goes on without it.
15208
+ */
15209
+ /**
15210
+ * The control plane's cap on an uploaded source archive.
15211
+ *
15212
+ * Keep in sync with its build-context cap (deploy/upload `MAX_BYTES` and the
15213
+ * backend's `maxBodySize`). Checked before uploading, so an oversized archive is
15214
+ * a message in milliseconds rather than a bare 413 after the upload.
15215
+ */
15216
+ var MAX_SOURCE_UPLOAD_BYTES = 100 * 1024 * 1024;
15217
+ /** Env files that are templates by convention, and safe to ship. */
15218
+ var ENV_TEMPLATES = /* @__PURE__ */ new Set([
15219
+ ".env.example",
15220
+ ".env.sample",
15221
+ ".env.template"
15222
+ ]);
15223
+ /**
15224
+ * Whether a file must never be uploaded, whatever the ignore rules said.
15225
+ *
15226
+ * `relativePath` is POSIX. Installed packages, git's own directory and built
15227
+ * bundles are never source. Every `.env` and `.env.*` is excluded except the
15228
+ * three template names, and so is direnv's `.envrc`, which is the same thing
15229
+ * under another name.
15230
+ */
15231
+ function neverUploaded(relativePath) {
15232
+ const segments = relativePath.split("/");
15233
+ if (segments.some((segment) => segment === "node_modules" || segment === ".git" || segment.startsWith("dist-bundle"))) return true;
15234
+ const base = segments[segments.length - 1];
15235
+ if (base === ".envrc") return true;
15236
+ if ((base === ".env" || base.startsWith(".env.")) && !ENV_TEMPLATES.has(base)) return true;
15237
+ return false;
15238
+ }
15239
+ /** Directories the walk outside a repository never enters. */
15240
+ var WALK_SKIP = /* @__PURE__ */ new Set([
15241
+ "node_modules",
15242
+ ".git",
15243
+ ".rebase",
15244
+ ".turbo",
15245
+ ".next",
15246
+ "coverage"
15247
+ ]);
15248
+ var runGit = (cwd, args) => execFileSync("git", [
15249
+ "-C",
15250
+ cwd,
15251
+ ...args
15252
+ ], {
15253
+ encoding: "utf8",
15254
+ stdio: [
15255
+ "ignore",
15256
+ "pipe",
15257
+ "ignore"
15258
+ ],
15259
+ maxBuffer: 256 * 1024 * 1024
15260
+ });
15261
+ function toPosix(relative) {
15262
+ return relative.split(path.sep).join("/");
15263
+ }
15264
+ /** A regular file on disk: not missing, not a directory or submodule, not a symlink. */
15265
+ function isRegularFile(absolute) {
15266
+ try {
15267
+ return fs.lstatSync(absolute).isFile();
15268
+ } catch {
15269
+ return false;
15270
+ }
15271
+ }
15272
+ /**
15273
+ * 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.
15275
+ */
15276
+ function listUnit(dir, git) {
15277
+ try {
15278
+ const toplevel = git(dir, ["rev-parse", "--show-toplevel"]).trim();
15279
+ if (toplevel) {
15280
+ const root = fs.realpathSync(toplevel);
15281
+ const listed = git(root, [
15282
+ "ls-files",
15283
+ "-z",
15284
+ "--cached",
15285
+ "--others",
15286
+ "--exclude-standard"
15287
+ ]);
15288
+ return {
15289
+ root,
15290
+ files: [...new Set(listed.split("\0").filter(Boolean))].filter((relative) => !neverUploaded(relative) && isRegularFile(path.join(root, relative))),
15291
+ fromGit: true
15292
+ };
15293
+ }
15294
+ } catch {}
15295
+ const files = [];
15296
+ const walk = (current, prefix) => {
15297
+ let entries;
15298
+ try {
15299
+ entries = fs.readdirSync(current, { withFileTypes: true });
15300
+ } catch {
15301
+ return;
15302
+ }
15303
+ for (const entry of entries) {
15304
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
15305
+ if (entry.isDirectory()) {
15306
+ if (WALK_SKIP.has(entry.name) || entry.name.startsWith("dist")) continue;
15307
+ walk(path.join(current, entry.name), relative);
15308
+ } else if (entry.isFile() && !neverUploaded(relative)) files.push(relative);
15309
+ }
15310
+ };
15311
+ walk(dir, "");
15312
+ return {
15313
+ root: dir,
15314
+ files,
15315
+ fromGit: false
15316
+ };
15317
+ }
15318
+ /** Whether `candidate` is `dir` or inside it. */
15319
+ function isWithin(dir, candidate) {
15320
+ const relative = path.relative(dir, candidate);
15321
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
15322
+ }
15323
+ /** The deepest directory containing every path given. */
15324
+ function commonAncestor(paths) {
15325
+ let ancestor = paths[0];
15326
+ for (const p of paths.slice(1)) while (!isWithin(ancestor, p)) ancestor = path.dirname(ancestor);
15327
+ return ancestor;
15328
+ }
15329
+ var DEPENDENCY_FIELDS = [
15330
+ "dependencies",
15331
+ "devDependencies",
15332
+ "optionalDependencies"
15333
+ ];
15334
+ /**
15335
+ * Directories outside every unit that a unit's `package.json` files link to
15336
+ * with `link:` or `file:`.
15337
+ *
15338
+ * `@rebasepro/*` links are not followed. They point at a local framework
15339
+ * checkout, which is not the project's source and would drag a whole monorepo
15340
+ * into the archive; a rebuild moves those packages to a published release
15341
+ * anyway.
15342
+ */
15343
+ function externalLinkTargets(units) {
15344
+ const targets = /* @__PURE__ */ new Set();
15345
+ for (const unit of units) for (const relative of unit.files) {
15346
+ if (path.posix.basename(relative) !== "package.json") continue;
15347
+ let manifest;
15348
+ try {
15349
+ manifest = JSON.parse(fs.readFileSync(path.join(unit.root, relative), "utf8"));
15350
+ } catch {
15351
+ continue;
15352
+ }
15353
+ if (typeof manifest !== "object" || manifest === null) continue;
15354
+ for (const field of DEPENDENCY_FIELDS) {
15355
+ const deps = manifest[field];
15356
+ if (typeof deps !== "object" || deps === null) continue;
15357
+ for (const [name, spec] of Object.entries(deps)) {
15358
+ if (name.startsWith("@rebasepro/") || typeof spec !== "string") continue;
15359
+ const match = /^(?:link|file):(.+)$/.exec(spec);
15360
+ if (!match) continue;
15361
+ const target = path.resolve(unit.root, path.posix.dirname(relative), match[1]);
15362
+ let real;
15363
+ try {
15364
+ real = fs.realpathSync(target);
15365
+ if (!fs.statSync(real).isDirectory()) continue;
15366
+ } catch {
15367
+ continue;
15368
+ }
15369
+ if (!units.some((u) => isWithin(u.root, real))) targets.add(real);
15370
+ }
15371
+ }
15372
+ }
15373
+ return [...targets];
15374
+ }
15375
+ /**
15376
+ * The files a source upload carries, and where they are rooted.
15377
+ *
15378
+ * The project's repository first — or the project walked, outside git. Then
15379
+ * every repository a local `link:`/`file:` dependency reaches outside it, until
15380
+ * nothing new is reached, rooted together at their deepest common directory.
15381
+ * That is dadaki's shape: its Rebase project is a repository of its own, nested
15382
+ * inside the editor's repository (which ignores it), and its frontend links
15383
+ * `../../packages/editor` from there. A listing of the project's repository
15384
+ * alone rebuilt into "Rollup failed to resolve import @dadaki/editor".
15385
+ *
15386
+ * Paths are resolved through `realpath` first: git reports its top level with
15387
+ * symlinks resolved (`/private/var/…` for a macOS temp directory), and a
15388
+ * project path computed against the unresolved one would climb out of the
15389
+ * archive with `../`.
15390
+ */
15391
+ function listSourceFiles(projectRoot, git = runGit) {
15392
+ const realRoot = fs.realpathSync(projectRoot);
15393
+ const units = [listUnit(realRoot, git)];
15394
+ for (let reached = externalLinkTargets(units); reached.length > 0; reached = externalLinkTargets(units)) for (const target of reached) {
15395
+ if (units.some((u) => isWithin(u.root, target))) continue;
15396
+ units.push(listUnit(target, git));
15397
+ }
15398
+ const root = commonAncestor(units.map((u) => u.root));
15399
+ const files = /* @__PURE__ */ new Set();
15400
+ for (const unit of units) {
15401
+ const prefix = toPosix(path.relative(root, unit.root));
15402
+ for (const file of unit.files) files.add(prefix ? `${prefix}/${file}` : file);
15403
+ }
15404
+ return {
15405
+ root,
15406
+ files: [...files].sort(),
15407
+ projectPath: toPosix(path.relative(root, realRoot)),
15408
+ fromGit: units.every((u) => u.fromGit)
15409
+ };
15410
+ }
15411
+ /**
15412
+ * Pack a listing into a gzipped tarball at `outPath`.
15413
+ *
15414
+ * The list goes to tar as a NUL-separated file (`--null -T`), so a path with a
15415
+ * space or a newline in it is one path. `--no-xattrs` and `COPYFILE_DISABLE`
15416
+ * keep macOS's extended attributes and AppleDouble `._*` sidecars out of the
15417
+ * archive, as `packBundle` does; GNU tar accepts both. Every path handed to tar
15418
+ * is absolute, because GNU tar resolves a `-T` file after it has applied `-C`.
15419
+ */
15420
+ function packSource(listing, outPath) {
15421
+ const listPath = `${outPath}.files`;
15422
+ fs.writeFileSync(listPath, listing.files.map((file) => `${file}\0`).join(""));
15423
+ return new Promise((resolve, reject) => {
15424
+ const child = spawn("tar", [
15425
+ "-czf",
15426
+ path.resolve(outPath),
15427
+ "--no-xattrs",
15428
+ "--null",
15429
+ "-C",
15430
+ listing.root,
15431
+ "-T",
15432
+ path.resolve(listPath)
15433
+ ], {
15434
+ stdio: [
15435
+ "ignore",
15436
+ "ignore",
15437
+ "pipe"
15438
+ ],
15439
+ env: {
15440
+ ...process.env,
15441
+ COPYFILE_DISABLE: "1"
15442
+ }
15443
+ });
15444
+ let stderr = "";
15445
+ child.stderr.on("data", (chunk) => stderr += String(chunk));
15446
+ child.on("error", reject);
15447
+ child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr.trim() || `tar exited ${code}`)));
15448
+ }).finally(() => fs.rmSync(listPath, { force: true }));
15449
+ }
15450
+ /** Upload a source archive; returns the control plane's id for it. */
15451
+ async function uploadRebuildSource(url, token, projectId, tarPath) {
15452
+ const bytes = fs.readFileSync(tarPath);
15453
+ const res = await fetch(`${url}/api/functions/deploy/source/upload?projectId=${encodeURIComponent(projectId)}`, {
15454
+ method: "POST",
15455
+ headers: {
15456
+ Authorization: `Bearer ${token}`,
15457
+ "Content-Type": "application/gzip",
15458
+ "User-Agent": cliUserAgent()
15459
+ },
15460
+ body: bytes
15461
+ });
15462
+ if (!res.ok) {
15463
+ const body = await res.text().catch(() => "");
15464
+ throw new Error(`the upload was refused (${res.status}): ${body || res.statusText}`);
15465
+ }
15466
+ const data = await res.json();
15467
+ const sourceId = typeof data === "object" && data !== null && "sourceId" in data ? data.sourceId : void 0;
15468
+ if (typeof sourceId !== "string" || !/^[0-9a-f]{32}$/i.test(sourceId)) throw new Error("the control plane did not return a source id");
15469
+ return sourceId;
15470
+ }
15471
+ /** The env files Vite reads for a production build, lowest precedence first. */
15472
+ var PRODUCTION_ENV_FILES = [
15473
+ ".env",
15474
+ ".env.local",
15475
+ ".env.production",
15476
+ ".env.production.local"
15477
+ ];
15478
+ /**
15479
+ * The `VITE_*` values a rebuild of this project's frontend needs.
15480
+ *
15481
+ * The `.env` files are never uploaded, so the values a static app is built with
15482
+ * have to travel separately — and only the `VITE_*` ones, which Vite inlines
15483
+ * into the client bundle and are public by construction. Nothing else is read.
15484
+ *
15485
+ * Read the way Vite reads them for `vite build`: `.env`, `.env.local`,
15486
+ * `.env.production`, `.env.production.local`, later winning — from the project
15487
+ * root, then from each static app's own `root`, which is where its Vite config
15488
+ * usually points. `process.env` wins over every file, as it does in Vite.
15489
+ *
15490
+ * `VITE_API_URL` is the one exception, taken from `process.env` only.
15491
+ * `staticBuildEnv` sets it to `process.env.VITE_API_URL ?? ""` for every
15492
+ * static build, so a value in a `.env` file never reaches a deployed bundle —
15493
+ * it is the `http://localhost:3001` of local development. Sending it would make
15494
+ * the rebuild bake in exactly the address the local build refuses to.
15495
+ */
15496
+ function collectBuildEnv(projectRoot, appRoots, env = process.env) {
15497
+ const collected = {};
15498
+ const seen = /* @__PURE__ */ new Set();
15499
+ for (const dir of [projectRoot, ...appRoots.map((root) => path.resolve(projectRoot, root))]) {
15500
+ const resolved = path.resolve(dir);
15501
+ if (seen.has(resolved) || !fs.existsSync(resolved)) continue;
15502
+ seen.add(resolved);
15503
+ for (const name of PRODUCTION_ENV_FILES) {
15504
+ let parsed;
15505
+ try {
15506
+ parsed = dotenv.parse(fs.readFileSync(path.join(resolved, name), "utf8"));
15507
+ } catch {
15508
+ continue;
15509
+ }
15510
+ for (const [key, value] of Object.entries(parsed)) if (key.startsWith("VITE_")) collected[key] = value;
15511
+ }
15512
+ }
15513
+ delete collected.VITE_API_URL;
15514
+ for (const [key, value] of Object.entries(env)) if (key.startsWith("VITE_") && value !== void 0) collected[key] = value;
15515
+ return collected;
15516
+ }
15517
+ /** The `root` of every static app a manifest declares, in declaration order. */
15518
+ function staticAppRoots(manifest) {
15519
+ if (!manifest) return [];
15520
+ const roots = [];
15521
+ for (const app of Object.values(manifest.apps)) if (app.type === "static" && typeof app.root === "string" && app.root !== "") roots.push(app.root);
15522
+ return roots;
15523
+ }
15524
+ var DEFAULT_STEPS = {
15525
+ list: (projectRoot) => listSourceFiles(projectRoot),
15526
+ pack: packSource,
15527
+ upload: uploadRebuildSource
15528
+ };
15529
+ /**
15530
+ * List, pack and upload the project's source. Never throws.
15531
+ *
15532
+ * Returns what the trigger should carry, or null — after a warning saying why
15533
+ * and what it costs — when the source could not be sent. The caller deploys
15534
+ * either way.
15535
+ */
15536
+ async function prepareRebuildSource(opts) {
15537
+ const steps = {
15538
+ ...DEFAULT_STEPS,
15539
+ ...opts.steps
15540
+ };
15541
+ const maxBytes = opts.maxBytes ?? 104857600;
15542
+ const tarPath = path.join(os.tmpdir(), `rebase-rebuild-src-${process.pid}-${Date.now()}.tar.gz`);
15543
+ const cost = "Platform upgrades will not rebuild this project until a later deploy uploads its source.";
15544
+ try {
15545
+ const listing = steps.list(opts.projectRoot);
15546
+ await steps.pack(listing, tarPath);
15547
+ const size = fs.statSync(tarPath).size;
15548
+ const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
15549
+ if (size > maxBytes) {
15550
+ opts.warn(`The project source is ${mb(size)} MB compressed, over the ${Math.round(maxBytes / 1024 / 1024)} MB upload limit, so it was not uploaded. ${cost}`, listing.fromGit ? "Stop tracking large assets and build output in git, or pass --no-source to skip it on purpose." : "Initialise a git repository with a .gitignore to choose what is source, or pass --no-source.");
15551
+ return null;
15552
+ }
15553
+ opts.progress(` Uploading source (${listing.files.length} files, ${mb(size)} MB)...`);
15554
+ return {
15555
+ sourceId: await steps.upload(opts.url, opts.token, opts.projectId, tarPath),
15556
+ projectPath: listing.projectPath,
15557
+ buildEnv: collectBuildEnv(opts.projectRoot, staticAppRoots(opts.manifest))
15558
+ };
15559
+ } catch (err) {
15560
+ opts.warn(`The project source was not uploaded: ${err instanceof Error ? err.message : String(err)}. ${cost}`, "The deploy continues with the bundle alone.");
15561
+ return null;
15562
+ } finally {
15563
+ fs.rmSync(tarPath, { force: true });
15564
+ }
15565
+ }
15566
+ //#endregion
15125
15567
  //#region src/commands/cloud/deploy.ts
15126
15568
  var POLL_INTERVAL_MS = 1500;
15127
15569
  var POLL_TIMEOUT_MS = 900 * 1e3;
15128
- var MAX_SOURCE_UPLOAD_BYTES = 100 * 1024 * 1024;
15129
15570
  function sleep(ms) {
15130
15571
  return new Promise((r) => setTimeout(r, ms));
15131
15572
  }
@@ -15217,7 +15658,7 @@ function progress(line) {
15217
15658
  async function uploadSource(url, token, projectId, tarPath) {
15218
15659
  const bytes = fs.readFileSync(tarPath);
15219
15660
  const sizeMb = (bytes.length / 1024 / 1024).toFixed(1);
15220
- if (bytes.length > MAX_SOURCE_UPLOAD_BYTES) fail(`Source context is ${sizeMb} MB — the upload cap is ${Math.round(MAX_SOURCE_UPLOAD_BYTES / 1024 / 1024)} MB.`, "Trim the build context: exclude sourcemaps (*.map), build output and large assets via .rebaseignore or .gitignore.");
15661
+ if (bytes.length > 104857600) fail(`Source context is ${sizeMb} MB — the upload cap is ${Math.round(MAX_SOURCE_UPLOAD_BYTES / 1024 / 1024)} MB.`, "Trim the build context: exclude sourcemaps (*.map), build output and large assets via .rebaseignore or .gitignore.");
15221
15662
  progress(chalk.gray(` Uploading source (${sizeMb} MB)...`));
15222
15663
  const res = await fetch(`${url}/api/functions/deploy/upload?projectId=${encodeURIComponent(projectId)}`, {
15223
15664
  method: "POST",
@@ -15264,6 +15705,7 @@ async function deployBundle(opts) {
15264
15705
  bundleDir = staticDir;
15265
15706
  await uploadAndTrigger({
15266
15707
  ...opts,
15708
+ projectRoot,
15267
15709
  bundleDir,
15268
15710
  appName: target.name
15269
15711
  });
@@ -15299,6 +15741,7 @@ async function deployBundle(opts) {
15299
15741
  }
15300
15742
  await uploadAndTrigger({
15301
15743
  ...opts,
15744
+ projectRoot,
15302
15745
  bundleDir
15303
15746
  });
15304
15747
  }
@@ -15313,7 +15756,7 @@ async function deployBundle(opts) {
15313
15756
  * bundle with no site in it.
15314
15757
  */
15315
15758
  async function uploadAndTrigger(opts) {
15316
- const { client, url, projectId, projectRef, bundleDir } = opts;
15759
+ const { client, url, projectId, projectRef, projectRoot, bundleDir } = opts;
15317
15760
  const manifest = readBundleManifest(bundleDir);
15318
15761
  if (manifest.hooks?.native) {
15319
15762
  const names = (manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
@@ -15338,10 +15781,22 @@ async function uploadAndTrigger(opts) {
15338
15781
  console.log("");
15339
15782
  console.log(` 🚀 Triggering managed deployment for ${chalk.bold(projectRef)} (schema ${manifest.schemaVersion})...`);
15340
15783
  }
15341
- let declaredApps = [];
15784
+ let projectManifest;
15342
15785
  try {
15343
- declaredApps = declaredAppsFrom(loadManifest(process.cwd()).manifest);
15786
+ projectManifest = loadManifest(projectRoot).manifest;
15344
15787
  } catch {}
15788
+ const declaredApps = declaredAppsFrom(projectManifest);
15789
+ let rebuildSource = null;
15790
+ if (opts.uploadSource && manifest.kind !== "static" && await platformRebuildsOff(client, projectId)) progress(chalk.gray(" Platform rebuilds are off for this project, so its source is not uploaded (turn them on with `rebase cloud settings set --platform-rebuilds on`)."));
15791
+ else if (opts.uploadSource && manifest.kind !== "static") rebuildSource = await prepareRebuildSource({
15792
+ projectRoot,
15793
+ url,
15794
+ token,
15795
+ projectId,
15796
+ manifest: projectManifest,
15797
+ progress: (line) => progress(chalk.gray(line)),
15798
+ warn
15799
+ });
15345
15800
  const body = bundleDeployBody({
15346
15801
  projectId,
15347
15802
  bundleId,
@@ -15349,7 +15804,9 @@ async function uploadAndTrigger(opts) {
15349
15804
  app: opts.appName,
15350
15805
  message: opts.message,
15351
15806
  declaredApps,
15352
- commit: bundleCommit(process.cwd())
15807
+ commit: bundleCommit(projectRoot),
15808
+ rebuildSource,
15809
+ allowFrameworkDowngrade: opts.allowDowngrade
15353
15810
  });
15354
15811
  let deploymentId;
15355
15812
  let managed;
@@ -15359,6 +15816,8 @@ async function uploadAndTrigger(opts) {
15359
15816
  deploymentId = String(res.deployment.id);
15360
15817
  managed = res.managed === true;
15361
15818
  } catch (e) {
15819
+ const refusal = intakeRefusal(e);
15820
+ if (refusal) fail(refusal.message, refusal.hint, refusal.code);
15362
15821
  reportError(e, "Managed deploy failed to start");
15363
15822
  }
15364
15823
  if (!opts.follow) {
@@ -15369,6 +15828,7 @@ async function uploadAndTrigger(opts) {
15369
15828
  success: true,
15370
15829
  deploymentId,
15371
15830
  managed,
15831
+ sourceUploaded: rebuildSource !== null,
15372
15832
  following: false
15373
15833
  });
15374
15834
  return;
@@ -15388,6 +15848,7 @@ async function uploadAndTrigger(opts) {
15388
15848
  success: true,
15389
15849
  deploymentId,
15390
15850
  managed,
15851
+ sourceUploaded: rebuildSource !== null,
15391
15852
  following: true,
15392
15853
  status
15393
15854
  });
@@ -15657,6 +16118,8 @@ var DEPLOY_FLAGS = {
15657
16118
  "--bundle-dir": String,
15658
16119
  "--skip-type-check": Boolean,
15659
16120
  "--eject": Boolean,
16121
+ "--no-source": Boolean,
16122
+ "--allow-downgrade": Boolean,
15660
16123
  "-m": "--message"
15661
16124
  };
15662
16125
  /**
@@ -15721,6 +16184,7 @@ async function deployCommand(rawArgs, projectRef) {
15721
16184
  const startedAt = Date.now();
15722
16185
  const { flags: args, appName } = resolveDeployArgs(rawArgs);
15723
16186
  if (args["--wait"] && args["--no-follow"]) fail("--wait and --no-follow ask for opposite things.", "`deploy` follows by default — pass neither, or `--no-follow` to return as soon as the build is triggered.", "usage");
16187
+ if (args["--source"] && args["--no-source"]) fail("--source and --no-source ask for opposite things.", "`--source <path>` builds a container image from a directory; `--no-source` skips uploading the source beside a managed bundle. Pass one.", "usage");
15724
16188
  const { client, url } = await requireClient(rawArgs);
15725
16189
  const projectId = await resolveProjectRef(projectRef, client);
15726
16190
  await requirePaymentMethod(client, projectId);
@@ -15738,7 +16202,9 @@ async function deployCommand(rawArgs, projectRef) {
15738
16202
  appName,
15739
16203
  skipTypeCheck: args["--skip-type-check"] === true,
15740
16204
  follow: args["--no-follow"] !== true,
15741
- timeoutMs: resolveDeployTimeout(args["--timeout"])
16205
+ timeoutMs: resolveDeployTimeout(args["--timeout"]),
16206
+ uploadSource: args["--no-source"] !== true,
16207
+ allowDowngrade: args["--allow-downgrade"] === true
15742
16208
  });
15743
16209
  return;
15744
16210
  }
@@ -15870,10 +16336,62 @@ function resolveTriggerFailure(e) {
15870
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");
15871
16337
  }
15872
16338
  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");
15873
- if (err?.status === 400 && err.details?.intakeCode) fail(err.message || "This bundle was refused.", err.details.hint ?? "Run `rebase cloud compute` to see what this project reserves.", err.details.intakeCode);
16339
+ const refusal = intakeRefusal(e);
16340
+ if (refusal) fail(refusal.message, refusal.hint, refusal.code);
15874
16341
  reportError(e, "Failed to trigger deployment");
15875
16342
  }
15876
16343
  /**
16344
+ * Whether the project's owner turned platform rebuilds off.
16345
+ *
16346
+ * Only an explicit `false` counts: a project row that predates the switch, or
16347
+ * one that could not be read, is on — which is the default, and the control
16348
+ * plane refuses an upload it should not have anyway.
16349
+ */
16350
+ async function platformRebuildsOff(client, projectId) {
16351
+ try {
16352
+ const row = await client.data.collection("projects").findById(projectId);
16353
+ return typeof row === "object" && row !== null && "platformRebuilds" in row && row.platformRebuilds === false;
16354
+ } catch {
16355
+ return false;
16356
+ }
16357
+ }
16358
+ /**
16359
+ * An intake refusal, which is a decision about what was deployed rather than a
16360
+ * transport error — or undefined for anything else.
16361
+ *
16362
+ * It carries a stable code in `details.intakeCode` and usually a remedy in
16363
+ * `details.hint`, and both used to be discarded: the deploy printed `Failed to
16364
+ * trigger deployment (400): …` and threw the hint away. The managed path, where
16365
+ * bundles are refused, never looked for either. Both paths answer through this
16366
+ * now, in the three-argument shape `fail` takes, so `--json` carries the code
16367
+ * and a person sees the fix.
16368
+ *
16369
+ * A downgrade gets one more line, because its two remedies are both in this
16370
+ * CLI: move the project forward, or say the step back is meant.
16371
+ */
16372
+ function intakeRefusal(e) {
16373
+ if (typeof e !== "object" || e === null) return void 0;
16374
+ const status = "status" in e && typeof e.status === "number" ? e.status : void 0;
16375
+ const details = "details" in e && typeof e.details === "object" && e.details !== null ? e.details : void 0;
16376
+ const code = details && "intakeCode" in details && typeof details.intakeCode === "string" ? details.intakeCode : void 0;
16377
+ if (status === void 0 || status < 400 || status >= 500 || !code) return void 0;
16378
+ const message = "message" in e && typeof e.message === "string" && e.message !== "" ? e.message : "This bundle was refused.";
16379
+ const serverHint = details && "hint" in details && typeof details.hint === "string" && details.hint !== "" ? details.hint : void 0;
16380
+ if (code === "FRAMEWORK_DOWNGRADE") {
16381
+ const remedy = "Run `rebase upgrade` to move this project's @rebasepro packages forward and deploy again, or pass `--allow-downgrade` to deploy the older release on purpose.";
16382
+ return {
16383
+ message,
16384
+ hint: serverHint ? `${serverHint}\n ${remedy}` : remedy,
16385
+ code
16386
+ };
16387
+ }
16388
+ return {
16389
+ message,
16390
+ hint: serverHint ?? "Run `rebase cloud compute` to see what this project reserves.",
16391
+ code
16392
+ };
16393
+ }
16394
+ /**
15877
16395
  * Poll a deployment record and print new log output as it arrives. Returns the
15878
16396
  * terminal status; a non-success still exits non-zero, as it always has.
15879
16397
  *
@@ -16994,7 +17512,7 @@ function printExtensionsHelp() {
16994
17512
  * `rebase cloud settings` — a project's editable configuration.
16995
17513
  *
16996
17514
  * settings Show the current settings
16997
- * settings set [flags] Update name / branch / repo / subdomain
17515
+ * settings set [flags] Update name / branch / repo / subdomain / platform rebuilds
16998
17516
  *
16999
17517
  * These are plain `projects` updates. A subdomain change is validated against
17000
17518
  * `check-subdomain` up front so the CLI fails with the real reason rather than a
@@ -17041,7 +17559,8 @@ async function showSettings(rawArgs) {
17041
17559
  ["Branch", p.gitBranch],
17042
17560
  ["Custom domain", p.customDomain],
17043
17561
  ["Provider", p.provider],
17044
- ["Region", p.region]
17562
+ ["Region", p.region],
17563
+ ["Platform rebuilds", p.platformRebuilds === false ? "off" : "on"]
17045
17564
  ]);
17046
17565
  console.log("");
17047
17566
  }, {
@@ -17052,12 +17571,20 @@ async function showSettings(rawArgs) {
17052
17571
  gitBranch: p.gitBranch ?? null,
17053
17572
  customDomain: p.customDomain ?? null,
17054
17573
  provider: p.provider ?? null,
17055
- region: p.region ?? null
17574
+ region: p.region ?? null,
17575
+ platformRebuilds: p.platformRebuilds !== false
17056
17576
  });
17057
17577
  } catch (e) {
17058
17578
  reportError(e, "Failed to load settings");
17059
17579
  }
17060
17580
  }
17581
+ /** `on`/`off` (or `true`/`false`) as a boolean, or null for anything else. */
17582
+ function parseOnOff(value) {
17583
+ const v = value.trim().toLowerCase();
17584
+ if (v === "on" || v === "true") return true;
17585
+ if (v === "off" || v === "false") return false;
17586
+ return null;
17587
+ }
17061
17588
  /** Build the update patch from the flags actually supplied (pure/testable). */
17062
17589
  function buildSettingsPatch(args) {
17063
17590
  const patch = {};
@@ -17065,6 +17592,7 @@ function buildSettingsPatch(args) {
17065
17592
  if (args.subdomain !== void 0) patch.subdomain = args.subdomain.toLowerCase();
17066
17593
  if (args.repo !== void 0) patch.gitRepoUrl = args.repo;
17067
17594
  if (args.branch !== void 0) patch.gitBranch = args.branch;
17595
+ if (args.platformRebuilds !== void 0) patch.platformRebuilds = args.platformRebuilds;
17068
17596
  return patch;
17069
17597
  }
17070
17598
  /** What `rebase cloud settings set` parses. Its page is the group's own. */
@@ -17072,7 +17600,8 @@ var SET_SETTINGS_FLAGS = {
17072
17600
  "--name": String,
17073
17601
  "--subdomain": String,
17074
17602
  "--repo": String,
17075
- "--branch": String
17603
+ "--branch": String,
17604
+ "--platform-rebuilds": String
17076
17605
  };
17077
17606
  async function setSettings(rawArgs) {
17078
17607
  const { flags: args } = parseCloudArgs({
@@ -17085,15 +17614,22 @@ async function setSettings(rawArgs) {
17085
17614
  const { client } = await requireClient(rawArgs);
17086
17615
  const projectId = await requireProject(rawArgs, client);
17087
17616
  const projectRef = displayProjectRef(rawArgs);
17617
+ let platformRebuilds;
17618
+ if (args["--platform-rebuilds"] !== void 0) {
17619
+ const parsed = parseOnOff(args["--platform-rebuilds"]);
17620
+ if (parsed === null) fail(`--platform-rebuilds takes on or off (got "${args["--platform-rebuilds"]}").`, void 0, "usage");
17621
+ platformRebuilds = parsed;
17622
+ }
17088
17623
  const patch = buildSettingsPatch({
17089
17624
  name: args["--name"],
17090
17625
  subdomain: args["--subdomain"],
17091
17626
  repo: args["--repo"],
17092
- branch: args["--branch"]
17627
+ branch: args["--branch"],
17628
+ platformRebuilds
17093
17629
  });
17094
- if (Object.keys(patch).length === 0) fail("Nothing to update.", "Pass --name, --subdomain, --repo, or --branch.", "usage");
17630
+ if (Object.keys(patch).length === 0) fail("Nothing to update.", "Pass --name, --subdomain, --repo, --branch, or --platform-rebuilds.", "usage");
17095
17631
  try {
17096
- if (patch.subdomain) {
17632
+ if (typeof patch.subdomain === "string" && patch.subdomain) {
17097
17633
  const check = await client.functions.invoke("check-subdomain", { subdomain: patch.subdomain }).catch(() => void 0);
17098
17634
  if (check && !check.available) fail(`Subdomain "${patch.subdomain}" is not available${check.reason ? ` (${check.reason})` : ""}.`, void 0, "subdomain_taken");
17099
17635
  }
@@ -17113,7 +17649,7 @@ function printSettingsHelp() {
17113
17649
  title: "Project configuration",
17114
17650
  actions: [{
17115
17651
  action: "show",
17116
- description: "Name, subdomain, repository and branch as recorded"
17652
+ description: "Name, subdomain, repository, branch and platform rebuilds as recorded"
17117
17653
  }, {
17118
17654
  action: "set",
17119
17655
  description: "Change one or more of them",
@@ -17121,7 +17657,8 @@ function printSettingsHelp() {
17121
17657
  ["--name <name>", "Display name"],
17122
17658
  ["--subdomain <sub>", "The <slug>.rebase.website host"],
17123
17659
  ["--repo <git url>", "Repository to build from"],
17124
- ["--branch <branch>", "Branch to build"]
17660
+ ["--branch <branch>", "Branch to build"],
17661
+ ["--platform-rebuilds <on|off>", "Rebuild this app on new framework releases (default on)"]
17125
17662
  ]
17126
17663
  }]
17127
17664
  });
@@ -18538,6 +19075,8 @@ var ACTION_HELP = {
18538
19075
  ["--bundle-dir <path>", "Deploy a bundle that is already built"],
18539
19076
  ["--source <path>", "Upload this directory and build a container image from it"],
18540
19077
  ["--skip-type-check", "Compile without type checking, as `rebase build` does"],
19078
+ ["--no-source", "Do not upload the project's source beside a backend bundle"],
19079
+ ["--allow-downgrade", "Deploy a bundle built on an older framework release than the project runs"],
18541
19080
  ["--eject", "Leave the managed runtime on purpose (ejects to a container image)"]
18542
19081
  ],
18543
19082
  examples: [
@@ -18545,7 +19084,12 @@ var ACTION_HELP = {
18545
19084
  "rebase cloud deploy web --message \"add search\"",
18546
19085
  "rebase cloud deploy --timeout 300 --json"
18547
19086
  ],
18548
- notes: ["A project whose rebase.json declares runtime: managed deploys a bundle without --bundle.", "--source on a managed project is refused: it would swap the project onto a container image."]
19087
+ notes: [
19088
+ "A project whose rebase.json declares runtime: managed deploys a bundle without --bundle.",
19089
+ "--source on a managed project is refused: it would swap the project onto a container image.",
19090
+ "A backend bundle deploy also uploads the project's source (what git tracks, never a .env), so a platform upgrade can rebuild it on a newer release. If it is over 100 MB or the upload fails, the deploy goes on without it and says so.",
19091
+ "A bundle older than the release the project runs is refused as FRAMEWORK_DOWNGRADE. Run `rebase upgrade` and deploy again, or pass --allow-downgrade."
19092
+ ]
18549
19093
  },
18550
19094
  logs: {
18551
19095
  command: "cloud logs",
@@ -19405,7 +19949,7 @@ function printCloudHelp() {
19405
19949
  * a mobile app with no repo relationship at all — an ordinary thing rather than
19406
19950
  * a special case.
19407
19951
  */
19408
- function printHelp$1() {
19952
+ function printHelp$2() {
19409
19953
  console.log(`
19410
19954
  ${chalk.bold("rebase apps")} — the apps this repository contributes
19411
19955
 
@@ -19428,7 +19972,7 @@ var APPS_SUBCOMMANDS = [
19428
19972
  ];
19429
19973
  async function appsCommand(subcommand, rawArgs = []) {
19430
19974
  if (!subcommand || subcommand === "--help" || wantsHelp(rawArgs)) {
19431
- printHelp$1();
19975
+ printHelp$2();
19432
19976
  return;
19433
19977
  }
19434
19978
  const { flags: args, positionals } = parseCommandArgs({
@@ -19590,6 +20134,1000 @@ function loadManifestOrExit(projectRoot, asJson = false) {
19590
20134
  }
19591
20135
  }
19592
20136
  //#endregion
20137
+ //#region src/upgrade.ts
20138
+ /**
20139
+ * Moving a project's `@rebasepro/*` pins to one release.
20140
+ *
20141
+ * Every `@rebasepro/*` package ships at one version, and a project declares them
20142
+ * in several `package.json` files — the root, `backend/`, `frontend/`,
20143
+ * `config/`, and whatever else the repository holds. Bumping them by hand means
20144
+ * finding every one, keeping each range's `^` or `~`, and noticing the override
20145
+ * that quietly wins over all of them. `rebase upgrade` is that job, done the same
20146
+ * way every time; the control plane runs it too, when it rebuilds a managed
20147
+ * project on a newer release.
20148
+ *
20149
+ * Everything here is pure file work, so it can be tested against a temporary
20150
+ * directory: which specs move, the rewrite itself, and what overrides say. The
20151
+ * command in `commands/upgrade.ts` adds the registry lookup, the install and the
20152
+ * printing.
20153
+ *
20154
+ * ## The rewrite is textual
20155
+ *
20156
+ * A `package.json` is authored. Parsing it and writing `JSON.stringify` back
20157
+ * reformats the file, reorders nothing but reindents everything, and drops a
20158
+ * trailing newline or a two-space indent somebody chose — a one-line version
20159
+ * bump that arrives as a whole-file diff. So the file is scanned for the exact
20160
+ * byte range of each value, only those bytes are replaced, and the result is
20161
+ * parsed again and compared against what was intended. A file whose rewrite
20162
+ * does not parse back to exactly the intended values is not written.
20163
+ */
20164
+ /** The dependency blocks a pin is moved in. `peerDependencies` is not one. */
20165
+ var PIN_FIELDS = [
20166
+ "dependencies",
20167
+ "devDependencies",
20168
+ "optionalDependencies"
20169
+ ];
20170
+ /** The package whose published versions stand for the release as a whole. */
20171
+ var RELEASE_PACKAGE = "@rebasepro/cli";
20172
+ /** `MAJOR.MINOR.PATCH`, with an optional prerelease and build, and nothing else. */
20173
+ var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
20174
+ /** Whether `value` is one exact version — `0.21.0`, `0.21.1-canary.g8c5a265`. */
20175
+ function isExactVersion(value) {
20176
+ return SEMVER.test(value);
20177
+ }
20178
+ /** An error that carries the `--json` envelope's code and a remedy. */
20179
+ var UpgradeError = class extends Error {
20180
+ code;
20181
+ hint;
20182
+ constructor(message, code, hint) {
20183
+ super(message);
20184
+ this.code = code;
20185
+ this.hint = hint;
20186
+ this.name = "UpgradeError";
20187
+ }
20188
+ };
20189
+ /**
20190
+ * What a dependency spec is, as far as moving it goes.
20191
+ *
20192
+ * Only a plain version is moved, because only a plain version has an obvious
20193
+ * answer: `^0.19.1` becomes `^0.21.0`. A `>=` floor, a `||` union or a partial
20194
+ * `^0.19` could each be rewritten several ways, and a tag or a workspace link
20195
+ * already means something other than "this release" — so each is reported with
20196
+ * its reason and left as written.
20197
+ */
20198
+ function classifySpec(spec) {
20199
+ const movable = /^([\^~]?)(.+)$/.exec(spec);
20200
+ if (movable && isExactVersion(movable[2])) return {
20201
+ kind: "movable",
20202
+ prefix: movable[1],
20203
+ version: movable[2]
20204
+ };
20205
+ for (const protocol of [
20206
+ "link:",
20207
+ "file:",
20208
+ "portal:"
20209
+ ]) if (spec.startsWith(protocol)) return {
20210
+ kind: "local",
20211
+ protocol
20212
+ };
20213
+ return {
20214
+ kind: "other",
20215
+ reason: otherReason(spec)
20216
+ };
20217
+ }
20218
+ function otherReason(spec) {
20219
+ const trimmed = spec.trim();
20220
+ if (trimmed.startsWith("workspace:")) return "a workspace: link, resolved inside this repository rather than from the registry";
20221
+ if (trimmed.startsWith("npm:")) return "an npm: alias; move the aliased version by hand";
20222
+ if (/^(git\+|git:|github:|gitlab:|bitbucket:|git@)/.test(trimmed) || /^https?:\/\//.test(trimmed)) return "a git or URL dependency, not a registry version";
20223
+ if (trimmed === "" || trimmed === "*" || /^[xX]$/.test(trimmed)) return "a wildcard, which already takes whatever the registry has";
20224
+ if (trimmed.includes("||")) return "a union of ranges, with no single version to move";
20225
+ if (/^[<>=]/.test(trimmed) || /\s-\s/.test(trimmed)) return "a comparator range, with no single version to move";
20226
+ if (/^[\^~]?v?\d/.test(trimmed)) return "not a plain MAJOR.MINOR.PATCH version";
20227
+ return "a dist-tag, not a version";
20228
+ }
20229
+ /**
20230
+ * The `@rebasepro/*` package an override key targets, or null.
20231
+ *
20232
+ * pnpm and npm key an override by name, optionally narrowed: `@rebasepro/x@<1`,
20233
+ * `parent>@rebasepro/x`; yarn's `resolutions` by a path, `**\/@rebasepro/x`. The
20234
+ * target is the last segment either way.
20235
+ */
20236
+ function overrideTarget(key) {
20237
+ const match = /(?:^|[/>])(@rebasepro\/[^/@>\s]+)(?:@[^/>]*)?$/.exec(key);
20238
+ return match ? match[1] : null;
20239
+ }
20240
+ /**
20241
+ * Directories the walk never enters: installed packages, build output (`dist`,
20242
+ * `dist-bundle`, `dist-bundle-admin`, …) and anything hidden (`.git`,
20243
+ * `.rebase`, an editor's worktrees). None of them hold a manifest anybody
20244
+ * authored for this project, and `dist-bundle/package.json` in particular is a
20245
+ * generated copy that `rebase build` rewrites anyway.
20246
+ */
20247
+ function skipDirectory(name) {
20248
+ return name === "node_modules" || name.startsWith("dist") || name.startsWith(".");
20249
+ }
20250
+ /** Every `package.json` and `pnpm-workspace.yaml` under the project root. */
20251
+ function discoverProjectFiles(projectRoot) {
20252
+ const found = {
20253
+ packageJsons: [],
20254
+ workspaceYamls: []
20255
+ };
20256
+ const walk = (dir) => {
20257
+ let entries;
20258
+ try {
20259
+ entries = fs.readdirSync(dir, { withFileTypes: true });
20260
+ } catch {
20261
+ return;
20262
+ }
20263
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
20264
+ for (const entry of entries) {
20265
+ const full = path.join(dir, entry.name);
20266
+ if (entry.isDirectory()) {
20267
+ if (!skipDirectory(entry.name)) walk(full);
20268
+ } else if (entry.isFile()) {
20269
+ if (entry.name === "package.json") found.packageJsons.push(full);
20270
+ else if (entry.name === "pnpm-workspace.yaml") found.workspaceYamls.push(full);
20271
+ }
20272
+ }
20273
+ };
20274
+ walk(projectRoot);
20275
+ return found;
20276
+ }
20277
+ /** Project-relative, POSIX. What every file path in the output looks like. */
20278
+ function relativeTo(projectRoot, absolute) {
20279
+ return path.relative(projectRoot, absolute).split(path.sep).join("/");
20280
+ }
20281
+ /**
20282
+ * Work out every change the upgrade makes, without writing anything.
20283
+ *
20284
+ * `--dry-run` prints this; a real run writes `writes` and nothing else.
20285
+ */
20286
+ function planUpgrade(projectRoot, target, options = {}) {
20287
+ if (!isExactVersion(target)) throw new UpgradeError(`"${target}" is not an exact version.`, "target_invalid");
20288
+ const plan = {
20289
+ target,
20290
+ changed: [],
20291
+ skipped: [],
20292
+ overrides: [],
20293
+ unreadable: [],
20294
+ writes: []
20295
+ };
20296
+ const files = discoverProjectFiles(projectRoot);
20297
+ for (const absolute of files.packageJsons) {
20298
+ const file = relativeTo(projectRoot, absolute);
20299
+ const original = fs.readFileSync(absolute, "utf8");
20300
+ const content = planPackageJson(file, original, target, options, plan);
20301
+ if (content !== null && content !== original) plan.writes.push({
20302
+ file,
20303
+ absolute,
20304
+ content
20305
+ });
20306
+ }
20307
+ for (const absolute of files.workspaceYamls) {
20308
+ const file = relativeTo(projectRoot, absolute);
20309
+ const original = fs.readFileSync(absolute, "utf8");
20310
+ const content = planWorkspaceYaml(file, original, target, options, plan);
20311
+ if (content !== original) plan.writes.push({
20312
+ file,
20313
+ absolute,
20314
+ content
20315
+ });
20316
+ }
20317
+ return plan;
20318
+ }
20319
+ /** Write what the plan changes. Nothing else on disk is touched. */
20320
+ function applyUpgradePlan(plan) {
20321
+ for (const write of plan.writes) fs.writeFileSync(write.absolute, write.content, "utf8");
20322
+ }
20323
+ /** Override blocks in a `package.json`, as paths from the root object. */
20324
+ var JSON_OVERRIDE_BLOCKS = [
20325
+ ["pnpm", "overrides"],
20326
+ ["overrides"],
20327
+ ["resolutions"]
20328
+ ];
20329
+ function planPackageJson(file, original, target, options, plan) {
20330
+ const bom = original.startsWith("") ? "" : "";
20331
+ const text = original.slice(bom.length);
20332
+ let parsed;
20333
+ let tree;
20334
+ try {
20335
+ parsed = JSON.parse(text);
20336
+ tree = scanJson(text);
20337
+ } catch (err) {
20338
+ plan.unreadable.push({
20339
+ file,
20340
+ reason: `not valid JSON: ${err instanceof Error ? err.message : String(err)}`
20341
+ });
20342
+ return null;
20343
+ }
20344
+ if (!isRecord(parsed) || tree.kind !== "object") {
20345
+ plan.unreadable.push({
20346
+ file,
20347
+ reason: "not a JSON object"
20348
+ });
20349
+ return null;
20350
+ }
20351
+ const expected = structuredClone(parsed);
20352
+ const edits = [];
20353
+ /** Override members to remove, as [block path, key]. */
20354
+ const removals = [];
20355
+ for (const field of PIN_FIELDS) {
20356
+ const block = memberValue(tree, [field]);
20357
+ if (block?.kind !== "object") continue;
20358
+ for (const member of block.members) {
20359
+ if (!member.key.startsWith("@rebasepro/")) continue;
20360
+ if (member.value.kind !== "string") {
20361
+ plan.skipped.push({
20362
+ file,
20363
+ name: member.key,
20364
+ field,
20365
+ spec: text.slice(member.value.start, member.value.end),
20366
+ reason: "not a string"
20367
+ });
20368
+ continue;
20369
+ }
20370
+ const spec = member.value.value;
20371
+ const cls = classifySpec(spec);
20372
+ if (cls.kind === "movable") {
20373
+ const to = `${cls.prefix}${target}`;
20374
+ if (to === spec) continue;
20375
+ edits.push({
20376
+ start: member.value.start,
20377
+ end: member.value.end,
20378
+ replacement: JSON.stringify(to)
20379
+ });
20380
+ setPath(expected, [field, member.key], to);
20381
+ plan.changed.push({
20382
+ file,
20383
+ name: member.key,
20384
+ field,
20385
+ from: spec,
20386
+ to
20387
+ });
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
+ }
20398
+ }
20399
+ }
20400
+ for (const blockPath of JSON_OVERRIDE_BLOCKS) {
20401
+ const block = memberValue(tree, blockPath);
20402
+ if (block?.kind !== "object") continue;
20403
+ const field = blockPath.join(".");
20404
+ for (const member of block.members) {
20405
+ if (!overrideTarget(member.key)) continue;
20406
+ if (member.value.kind !== "string") {
20407
+ plan.skipped.push({
20408
+ file,
20409
+ name: member.key,
20410
+ field,
20411
+ spec: text.slice(member.value.start, member.value.end),
20412
+ reason: "a nested override, which has no single version to move; edit it by hand"
20413
+ });
20414
+ continue;
20415
+ }
20416
+ const spec = member.value.value;
20417
+ const cls = classifySpec(spec);
20418
+ if (cls.kind === "movable") {
20419
+ const to = `${cls.prefix}${target}`;
20420
+ if (to === spec) continue;
20421
+ edits.push({
20422
+ start: member.value.start,
20423
+ end: member.value.end,
20424
+ replacement: JSON.stringify(to)
20425
+ });
20426
+ setPath(expected, [...blockPath, member.key], to);
20427
+ plan.overrides.push({
20428
+ file,
20429
+ name: member.key,
20430
+ spec,
20431
+ action: "bumped",
20432
+ to
20433
+ });
20434
+ } else if (cls.kind === "local") if (options.dropLocalOverrides) {
20435
+ removals.push([blockPath, member.key]);
20436
+ deletePath(expected, [...blockPath, member.key]);
20437
+ plan.overrides.push({
20438
+ file,
20439
+ name: member.key,
20440
+ spec,
20441
+ action: "removed-local"
20442
+ });
20443
+ } else plan.overrides.push({
20444
+ file,
20445
+ name: member.key,
20446
+ spec,
20447
+ action: "kept-local"
20448
+ });
20449
+ else plan.skipped.push({
20450
+ file,
20451
+ name: member.key,
20452
+ field,
20453
+ spec,
20454
+ reason: cls.reason
20455
+ });
20456
+ }
20457
+ }
20458
+ if (edits.length === 0 && removals.length === 0) return original;
20459
+ let next = applyEdits(text, edits);
20460
+ for (const [blockPath, key] of removals) next = removeJsonMember(next, blockPath, key);
20461
+ for (const blockPath of JSON_OVERRIDE_BLOCKS) {
20462
+ if (!removals.some(([removedFrom]) => removedFrom === blockPath)) continue;
20463
+ for (let depth = blockPath.length; depth > 0; depth--) {
20464
+ const containerPath = blockPath.slice(0, depth);
20465
+ const node = memberValue(scanJson(next), containerPath);
20466
+ if (node?.kind !== "object" || node.members.length > 0) break;
20467
+ next = removeJsonMember(next, containerPath.slice(0, -1), containerPath[containerPath.length - 1]);
20468
+ deletePath(expected, containerPath);
20469
+ }
20470
+ }
20471
+ let reparsed;
20472
+ try {
20473
+ reparsed = JSON.parse(next);
20474
+ } catch {
20475
+ reparsed = void 0;
20476
+ }
20477
+ if (!isDeepStrictEqual(reparsed, expected)) throw new UpgradeError(`Could not rewrite ${file} without disturbing it; nothing was written.`, "rewrite_failed", "Move its @rebasepro versions by hand, then run `rebase upgrade` again.");
20478
+ return bom + next;
20479
+ }
20480
+ /**
20481
+ * The structure of a JSON document, with where each value starts and ends.
20482
+ *
20483
+ * `JSON.parse` answers what a file says; this answers where it says it, which is
20484
+ * what a rewrite that leaves every other byte alone needs. Throws on anything
20485
+ * that is not JSON — callers have already parsed the same text, so that never
20486
+ * happens in practice.
20487
+ */
20488
+ function scanJson(text) {
20489
+ let i = 0;
20490
+ const WHITESPACE = " \n\r";
20491
+ const skipWhitespace = () => {
20492
+ while (i < text.length && WHITESPACE.includes(text[i])) i++;
20493
+ };
20494
+ const unexpected = () => {
20495
+ throw new Error(`unexpected ${i < text.length ? `"${text[i]}"` : "end of input"} at offset ${i}`);
20496
+ };
20497
+ const readString = () => {
20498
+ const start = i;
20499
+ if (text[i] !== "\"") unexpected();
20500
+ i++;
20501
+ while (i < text.length && text[i] !== "\"") i += text[i] === "\\" ? 2 : 1;
20502
+ if (i >= text.length) unexpected();
20503
+ i++;
20504
+ return {
20505
+ kind: "string",
20506
+ start,
20507
+ end: i,
20508
+ value: JSON.parse(text.slice(start, i))
20509
+ };
20510
+ };
20511
+ const readValue = () => {
20512
+ skipWhitespace();
20513
+ const start = i;
20514
+ if (text[i] === "{") {
20515
+ i++;
20516
+ const members = [];
20517
+ skipWhitespace();
20518
+ if (text[i] === "}") {
20519
+ i++;
20520
+ return {
20521
+ kind: "object",
20522
+ start,
20523
+ end: i,
20524
+ members
20525
+ };
20526
+ }
20527
+ for (;;) {
20528
+ skipWhitespace();
20529
+ const key = readString();
20530
+ skipWhitespace();
20531
+ if (text[i] !== ":") unexpected();
20532
+ i++;
20533
+ members.push({
20534
+ key: key.value,
20535
+ keyStart: key.start,
20536
+ value: readValue()
20537
+ });
20538
+ skipWhitespace();
20539
+ if (text[i] === ",") {
20540
+ i++;
20541
+ continue;
20542
+ }
20543
+ if (text[i] === "}") {
20544
+ i++;
20545
+ return {
20546
+ kind: "object",
20547
+ start,
20548
+ end: i,
20549
+ members
20550
+ };
20551
+ }
20552
+ unexpected();
20553
+ }
20554
+ }
20555
+ if (text[i] === "[") {
20556
+ i++;
20557
+ skipWhitespace();
20558
+ if (text[i] === "]") {
20559
+ i++;
20560
+ return {
20561
+ kind: "array",
20562
+ start,
20563
+ end: i
20564
+ };
20565
+ }
20566
+ for (;;) {
20567
+ readValue();
20568
+ skipWhitespace();
20569
+ if (text[i] === ",") {
20570
+ i++;
20571
+ continue;
20572
+ }
20573
+ if (text[i] === "]") {
20574
+ i++;
20575
+ return {
20576
+ kind: "array",
20577
+ start,
20578
+ end: i
20579
+ };
20580
+ }
20581
+ unexpected();
20582
+ }
20583
+ }
20584
+ if (text[i] === "\"") return readString();
20585
+ while (i < text.length && !",:{}[]\"".includes(text[i]) && !WHITESPACE.includes(text[i])) i++;
20586
+ if (i === start) unexpected();
20587
+ return {
20588
+ kind: "scalar",
20589
+ start,
20590
+ end: i
20591
+ };
20592
+ };
20593
+ const root = readValue();
20594
+ skipWhitespace();
20595
+ if (i !== text.length) unexpected();
20596
+ return root;
20597
+ }
20598
+ /** The value at a path of object keys, or undefined. The last duplicate wins, as in `JSON.parse`. */
20599
+ function memberValue(root, keys) {
20600
+ let node = root;
20601
+ for (const key of keys) {
20602
+ if (node?.kind !== "object") return void 0;
20603
+ const members = node.members.filter((m) => m.key === key);
20604
+ node = members[members.length - 1]?.value;
20605
+ }
20606
+ return node;
20607
+ }
20608
+ /** Apply non-overlapping edits, last first so the earlier offsets stay valid. */
20609
+ function applyEdits(text, edits) {
20610
+ let out = text;
20611
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
20612
+ return out;
20613
+ }
20614
+ /**
20615
+ * Remove one member from the object at `objectPath`, taking its comma with it.
20616
+ *
20617
+ * Rescans rather than trusting offsets from before an earlier edit. A middle or
20618
+ * first member takes everything up to the next key, so the next member inherits
20619
+ * its indentation; the last takes the comma and whitespace after the previous
20620
+ * value, so the closing brace keeps its line; an only member leaves `{}`.
20621
+ */
20622
+ function removeJsonMember(text, objectPath, key) {
20623
+ const container = memberValue(scanJson(text), objectPath);
20624
+ if (container?.kind !== "object") return text;
20625
+ const index = container.members.findIndex((m) => m.key === key);
20626
+ if (index === -1) return text;
20627
+ const members = container.members;
20628
+ const member = members[index];
20629
+ if (members.length === 1) return text.slice(0, container.start + 1) + text.slice(container.end - 1);
20630
+ if (index < members.length - 1) return text.slice(0, member.keyStart) + text.slice(members[index + 1].keyStart);
20631
+ return text.slice(0, members[index - 1].value.end) + text.slice(member.value.end);
20632
+ }
20633
+ function isRecord(value) {
20634
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20635
+ }
20636
+ function setPath(root, keys, value) {
20637
+ let node = root;
20638
+ for (const key of keys.slice(0, -1)) {
20639
+ const next = node[key];
20640
+ if (!isRecord(next)) return;
20641
+ node = next;
20642
+ }
20643
+ node[keys[keys.length - 1]] = value;
20644
+ }
20645
+ function deletePath(root, keys) {
20646
+ let node = root;
20647
+ for (const key of keys.slice(0, -1)) {
20648
+ const next = node[key];
20649
+ if (!isRecord(next)) return;
20650
+ node = next;
20651
+ }
20652
+ delete node[keys[keys.length - 1]];
20653
+ }
20654
+ /** Lines, each without its `\r`, and whether the file used them. */
20655
+ function splitLines(text) {
20656
+ return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
20657
+ }
20658
+ /**
20659
+ * A YAML scalar at the start of `rest`: its text, and where that text sits.
20660
+ *
20661
+ * Double- and single-quoted and plain scalars, which is every shape an override
20662
+ * value is written in. A double-quoted one with an escape in it is refused
20663
+ * rather than decoded: no version or path needs one, and getting the decoding
20664
+ * wrong would rewrite a value this does not understand.
20665
+ */
20666
+ function readYamlScalar(rest) {
20667
+ if (rest.startsWith("\"")) {
20668
+ const close = rest.indexOf("\"", 1);
20669
+ if (close === -1 || rest.slice(1, close).includes("\\")) return null;
20670
+ if (!/^\s*(#.*)?$/.test(rest.slice(close + 1))) return null;
20671
+ return {
20672
+ value: rest.slice(1, close),
20673
+ start: 1,
20674
+ end: close
20675
+ };
20676
+ }
20677
+ if (rest.startsWith("'")) {
20678
+ const close = rest.indexOf("'", 1);
20679
+ if (close === -1 || rest[close + 1] === "'") return null;
20680
+ if (!/^\s*(#.*)?$/.test(rest.slice(close + 1))) return null;
20681
+ return {
20682
+ value: rest.slice(1, close),
20683
+ start: 1,
20684
+ end: close
20685
+ };
20686
+ }
20687
+ const comment = rest.search(/\s#/);
20688
+ const plain = (comment === -1 ? rest : rest.slice(0, comment)).trimEnd();
20689
+ if (plain === "" || /^[[{&*!|>%@`]/.test(plain)) return null;
20690
+ return {
20691
+ value: plain,
20692
+ start: 0,
20693
+ end: plain.length
20694
+ };
20695
+ }
20696
+ /**
20697
+ * The top-level `overrides:` block of a `pnpm-workspace.yaml`, read line by line.
20698
+ *
20699
+ * Deliberately narrow. The file is YAML, and the CLI carries no YAML parser; the
20700
+ * shape pnpm documents and every project writes is a block mapping of one
20701
+ * scalar per line, which a line reader handles exactly. Anything else in the
20702
+ * block is counted rather than guessed at, and an inline `overrides: { … }` is
20703
+ * reported as not handled.
20704
+ */
20705
+ function readYamlOverrides(lines) {
20706
+ const keyLine = lines.findIndex((line) => /^overrides:\s*(#.*)?$/.test(line));
20707
+ if (keyLine === -1) return null;
20708
+ const block = {
20709
+ keyLine,
20710
+ lines: [],
20711
+ entries: [],
20712
+ otherContent: 0
20713
+ };
20714
+ for (let index = keyLine + 1; index < lines.length; index++) {
20715
+ const line = lines[index];
20716
+ if (line.trim() !== "" && !/^\s/.test(line) && !line.startsWith("#")) break;
20717
+ block.lines.push(index);
20718
+ 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++;
20724
+ continue;
20725
+ }
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
20734
+ });
20735
+ }
20736
+ return block;
20737
+ }
20738
+ function planWorkspaceYaml(file, original, target, options, plan) {
20739
+ const lines = splitLines(original);
20740
+ const crlf = original.includes("\r\n");
20741
+ const block = readYamlOverrides(lines);
20742
+ if (!block) {
20743
+ const inline = lines.find((line) => /^overrides:\s*[^\s#]/.test(line));
20744
+ if (inline && inline.includes("@rebasepro/")) plan.skipped.push({
20745
+ file,
20746
+ name: "overrides",
20747
+ field: "overrides",
20748
+ spec: inline.slice(10).trim(),
20749
+ reason: "an inline overrides map, which this does not rewrite; write it as a block, or edit it by hand"
20750
+ });
20751
+ return original;
20752
+ }
20753
+ const next = [...lines];
20754
+ const removed = /* @__PURE__ */ new Set();
20755
+ const expected = /* @__PURE__ */ new Map();
20756
+ for (const entry of block.entries) {
20757
+ if (!overrideTarget(entry.key)) {
20758
+ expected.set(entry.key, entry.value);
20759
+ continue;
20760
+ }
20761
+ const cls = classifySpec(entry.value);
20762
+ if (cls.kind === "movable") {
20763
+ const to = `${cls.prefix}${target}`;
20764
+ expected.set(entry.key, to);
20765
+ if (to === entry.value) continue;
20766
+ const line = lines[entry.line];
20767
+ next[entry.line] = line.slice(0, entry.valueStart) + to + line.slice(entry.valueEnd);
20768
+ plan.overrides.push({
20769
+ file,
20770
+ name: entry.key,
20771
+ spec: entry.value,
20772
+ action: "bumped",
20773
+ to
20774
+ });
20775
+ } else if (cls.kind === "local") if (options.dropLocalOverrides) {
20776
+ removed.add(entry.line);
20777
+ plan.overrides.push({
20778
+ file,
20779
+ name: entry.key,
20780
+ spec: entry.value,
20781
+ action: "removed-local"
20782
+ });
20783
+ } else {
20784
+ expected.set(entry.key, entry.value);
20785
+ plan.overrides.push({
20786
+ file,
20787
+ name: entry.key,
20788
+ spec: entry.value,
20789
+ action: "kept-local"
20790
+ });
20791
+ }
20792
+ else {
20793
+ expected.set(entry.key, entry.value);
20794
+ plan.skipped.push({
20795
+ file,
20796
+ name: entry.key,
20797
+ field: "overrides",
20798
+ spec: entry.value,
20799
+ reason: cls.reason
20800
+ });
20801
+ }
20802
+ }
20803
+ const survivors = block.entries.filter((entry) => !removed.has(entry.line)).length + block.otherContent;
20804
+ if (removed.size > 0 && survivors === 0) removed.add(block.keyLine);
20805
+ const content = next.filter((_, index) => !removed.has(index)).join(crlf ? "\r\n" : "\n");
20806
+ const after = readYamlOverrides(splitLines(content));
20807
+ 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
+ return content;
20809
+ }
20810
+ /** Lockfiles, in the order they are looked for in each directory. */
20811
+ var LOCKFILES = [
20812
+ ["pnpm-lock.yaml", "pnpm"],
20813
+ ["package-lock.json", "npm"],
20814
+ ["yarn.lock", "yarn"],
20815
+ ["bun.lockb", "bun"],
20816
+ ["bun.lock", "bun"]
20817
+ ];
20818
+ /**
20819
+ * The package manager this project installs with, from what is on disk.
20820
+ *
20821
+ * A lockfile is the project's own statement, so it wins. The search starts at
20822
+ * the project root and walks up, because a project inside a workspace has its
20823
+ * lockfile at the workspace root; it stops at the repository's root, so a stray
20824
+ * lockfile in a home directory is never read as this project's choice. With no
20825
+ * lockfile anywhere, a `pnpm-workspace.yaml` still says pnpm; otherwise npm,
20826
+ * the one every Node install has.
20827
+ */
20828
+ function detectInstaller(projectRoot) {
20829
+ let dir = path.resolve(projectRoot);
20830
+ let workspaceYaml = false;
20831
+ for (;;) {
20832
+ for (const [lockfile, installer] of LOCKFILES) if (fs.existsSync(path.join(dir, lockfile))) return installer;
20833
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) workspaceYaml = true;
20834
+ const parent = path.dirname(dir);
20835
+ if (parent === dir || fs.existsSync(path.join(dir, ".git"))) break;
20836
+ dir = parent;
20837
+ }
20838
+ return workspaceYaml ? "pnpm" : "npm";
20839
+ }
20840
+ /**
20841
+ * The install command for each package manager — pnpm's and npm's from the
20842
+ * helpers every other command uses, yarn's and bun's spelled the same way.
20843
+ */
20844
+ function installCommand(installer) {
20845
+ const [bin, ...args] = installer === "pnpm" || installer === "npm" ? getPMCommands(installer).install : [installer, "install"];
20846
+ return [bin, args];
20847
+ }
20848
+ /**
20849
+ * The exact version `--to` names.
20850
+ *
20851
+ * An exact version is used as written, with no network call: the control plane
20852
+ * passes one when it rebuilds a project, and that rebuild must not depend on a
20853
+ * registry lookup succeeding for anything but the install itself. Anything
20854
+ * else is a dist-tag, asked of the registry through `npmView` — which runs npm
20855
+ * in the project, so its `.npmrc` and the user's registry config apply.
20856
+ */
20857
+ async function resolveTarget(requested, npmView) {
20858
+ const trimmed = requested.trim();
20859
+ const bare = trimmed.replace(/^v(?=\d)/, "");
20860
+ if (isExactVersion(bare)) return bare;
20861
+ if (!/^[A-Za-z][\w.-]*$/.test(trimmed)) throw new UpgradeError(`"${requested}" is neither an exact version nor a dist-tag.`, "target_invalid", "Pass an exact version such as 0.21.0, or a dist-tag such as latest or canary.");
20862
+ let answer;
20863
+ try {
20864
+ answer = (await npmView(`${RELEASE_PACKAGE}@${trimmed}`)).trim();
20865
+ } catch (err) {
20866
+ const detail = err instanceof Error ? firstLine(err.message) : String(err);
20867
+ throw new UpgradeError(`Could not resolve "${trimmed}" on the npm registry${detail ? `: ${detail}` : "."}`, "target_unresolved", "Check the tag and your registry access, or pass an exact version with --to.");
20868
+ }
20869
+ if (!isExactVersion(answer)) throw new UpgradeError(`The registry has no single version for ${RELEASE_PACKAGE}@${trimmed}${answer ? ` (it answered "${firstLine(answer)}")` : ""}.`, "target_unresolved", "Check the tag, or pass an exact version with --to.");
20870
+ return answer;
20871
+ }
20872
+ function firstLine(text) {
20873
+ return text.split("\n").map((line) => line.trim()).filter(Boolean)[0] ?? "";
20874
+ }
20875
+ /**
20876
+ * Whether moving from `from` to `to` goes backwards, for the one-word note the
20877
+ * summary prints. Prerelease identifiers compare as SemVer orders them: a
20878
+ * release outranks its own prereleases.
20879
+ */
20880
+ function isDowngrade(from, to) {
20881
+ const a = SEMVER.exec(from.replace(/^[\^~]/, ""));
20882
+ const b = SEMVER.exec(to.replace(/^[\^~]/, ""));
20883
+ if (!a || !b) return false;
20884
+ for (let i = 1; i <= 3; i++) {
20885
+ const diff = Number(a[i]) - Number(b[i]);
20886
+ if (diff !== 0) return diff > 0;
20887
+ }
20888
+ const preA = a[4];
20889
+ const preB = b[4];
20890
+ if (preA === preB) return false;
20891
+ if (preA === void 0) return true;
20892
+ if (preB === void 0) return false;
20893
+ const partsA = preA.split(".");
20894
+ const partsB = preB.split(".");
20895
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
20896
+ const x = partsA[i];
20897
+ const y = partsB[i];
20898
+ if (x === void 0) return false;
20899
+ if (y === void 0) return true;
20900
+ if (x === y) continue;
20901
+ const nx = /^\d+$/.test(x) ? Number(x) : NaN;
20902
+ const ny = /^\d+$/.test(y) ? Number(y) : NaN;
20903
+ if (!Number.isNaN(nx) && !Number.isNaN(ny)) return nx > ny;
20904
+ if (!Number.isNaN(nx)) return false;
20905
+ if (!Number.isNaN(ny)) return true;
20906
+ return x > y;
20907
+ }
20908
+ return false;
20909
+ }
20910
+ //#endregion
20911
+ //#region src/commands/upgrade.ts
20912
+ /**
20913
+ * CLI command: rebase upgrade [--to <version|tag>]
20914
+ *
20915
+ * Moves every `@rebasepro/*` package the project pins to one release, then
20916
+ * installs. The file work — which specs move, the rewrite, what overrides say —
20917
+ * is `../upgrade.ts`; this adds the registry lookup for a tag, the install, and
20918
+ * the two ways of saying what happened.
20919
+ */
20920
+ /** Every flag `rebase upgrade` accepts. Its help page and the docs verifier read this. */
20921
+ var UPGRADE_FLAGS = {
20922
+ "--to": String,
20923
+ "--drop-local-overrides": Boolean,
20924
+ "--no-install": Boolean,
20925
+ "--dry-run": Boolean,
20926
+ "--json": Boolean
20927
+ };
20928
+ function printHelp$1() {
20929
+ console.log(`
20930
+ ${chalk.bold("rebase upgrade")} — move every @rebasepro package this project pins to one release
20931
+
20932
+ ${chalk.bold("Usage")}
20933
+ rebase upgrade [--to <version|tag>] [options]
20934
+
20935
+ ${chalk.bold("Options")}
20936
+ --to <version|tag> The release: an exact version (0.21.0) or a dist-tag
20937
+ (latest, canary). Default: latest
20938
+ --drop-local-overrides Remove link: and file: overrides of @rebasepro packages,
20939
+ which win over every pin
20940
+ --no-install Rewrite the package.json files, but do not install
20941
+ --dry-run Print what would change, and write nothing
20942
+ --json One JSON document on stdout
20943
+ -h, --help Show this help
20944
+
20945
+ ${chalk.bold("What it changes")}
20946
+ Every @rebasepro/* entry in dependencies, devDependencies and optionalDependencies,
20947
+ in every package.json under the project (node_modules, dist* and hidden
20948
+ directories excepted). ^ and ~ are kept. peerDependencies are left alone, and so
20949
+ 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.
20951
+
20952
+ ${chalk.bold("Examples")}
20953
+ rebase upgrade Move to the latest release and install
20954
+ rebase upgrade --to canary --dry-run See what the canary would change
20955
+ rebase upgrade --to 0.21.0 --no-install --json
20956
+ `.trim());
20957
+ }
20958
+ var defaultIo = {
20959
+ npmView: async (spec, cwd) => {
20960
+ try {
20961
+ return (await execa("npm", [
20962
+ "view",
20963
+ spec,
20964
+ "version"
20965
+ ], { cwd })).stdout;
20966
+ } catch (err) {
20967
+ throw new Error(npmFailure(err));
20968
+ }
20969
+ },
20970
+ install: async ([bin, args], cwd, quietStdout) => {
20971
+ await execa(bin, args, {
20972
+ cwd,
20973
+ stdio: quietStdout ? [
20974
+ "ignore",
20975
+ 2,
20976
+ "inherit"
20977
+ ] : "inherit"
20978
+ });
20979
+ }
20980
+ };
20981
+ /**
20982
+ * The one line of a failed `npm view` worth printing.
20983
+ *
20984
+ * execa's own message is the command line that failed, which says nothing the
20985
+ * reader does not know. npm's stderr says why — `404 No match found for version
20986
+ * nosuchtag` — under an `npm error code E404` line that says less.
20987
+ */
20988
+ function npmFailure(err) {
20989
+ return (typeof err === "object" && err !== null && "stderr" in err && typeof err.stderr === "string" ? err.stderr : "").split("\n").map((line) => line.replace(/^npm (?:error|ERR!)\s*/, "").trim()).find((line) => line !== "" && !/^code\s/.test(line) && !/^\d{3}$/.test(line)) ?? (err instanceof Error ? err.message : String(err));
20990
+ }
20991
+ async function upgradeCommand(rawArgs, io = defaultIo) {
20992
+ if (wantsHelp(rawArgs)) {
20993
+ printHelp$1();
20994
+ return;
20995
+ }
20996
+ const { flags } = parseCommandArgs({
20997
+ spec: UPGRADE_FLAGS,
20998
+ rawArgs,
20999
+ commandWords: 1,
21000
+ command: "upgrade",
21001
+ maxPositionals: 0
21002
+ });
21003
+ const json = flags["--json"] === true;
21004
+ const dryRun = flags["--dry-run"] === true;
21005
+ const projectRoot = requireProjectRoot();
21006
+ /** Every refusal, in whichever of the two languages was asked for. */
21007
+ const refuse = (message, code, hint, issues) => {
21008
+ if (json) failAsJson(message, code, hint, issues);
21009
+ console.error(chalk.red(`✗ ${message}`));
21010
+ for (const issue of issues ?? []) console.error(` ${chalk.gray(issue.path ?? "")} ${issue.message}`);
21011
+ if (hint) console.error(chalk.gray(` ${hint}`));
21012
+ process.exit(1);
21013
+ };
21014
+ try {
21015
+ loadManifest(projectRoot);
21016
+ } catch (err) {
21017
+ if (err instanceof ManifestError) refuse(err.message, "manifest_invalid", "Fix rebase.json, then run this again.", err.issues.map((issue) => ({
21018
+ path: issue.path,
21019
+ message: issue.message
21020
+ })));
21021
+ throw err;
21022
+ }
21023
+ let plan;
21024
+ try {
21025
+ plan = planUpgrade(projectRoot, await resolveTarget(flags["--to"] ?? "latest", (spec) => io.npmView(spec, projectRoot)), { dropLocalOverrides: flags["--drop-local-overrides"] === true });
21026
+ } catch (err) {
21027
+ if (err instanceof UpgradeError) refuse(err.message, err.code, err.hint);
21028
+ throw err;
21029
+ }
21030
+ if (!dryRun) applyUpgradePlan(plan);
21031
+ const wrote = !dryRun && plan.writes.length > 0;
21032
+ const install = wrote && flags["--no-install"] !== true;
21033
+ const installer = detectInstaller(projectRoot);
21034
+ const [bin, args] = installCommand(installer);
21035
+ const installLine = [bin, ...args].join(" ");
21036
+ if (!json) printSummary(plan, dryRun);
21037
+ let installed = false;
21038
+ if (install) {
21039
+ if (!json) console.log(chalk.gray(` Installing with ${installer}...\n`));
21040
+ try {
21041
+ await io.install([bin, args], projectRoot, json);
21042
+ installed = true;
21043
+ } catch {
21044
+ refuse(`\`${installLine}\` failed.`, "install_failed", `The package.json edits were kept. Fix what the install reported and run \`${installLine}\` again, or revert the edits with git.`);
21045
+ }
21046
+ }
21047
+ const result = {
21048
+ target: plan.target,
21049
+ changed: plan.changed,
21050
+ skipped: plan.skipped,
21051
+ overrides: plan.overrides,
21052
+ unreadable: plan.unreadable,
21053
+ installed,
21054
+ dryRun
21055
+ };
21056
+ if (json) {
21057
+ console.log(JSON.stringify(result, null, 2));
21058
+ return;
21059
+ }
21060
+ printOutcome(plan, {
21061
+ dryRun,
21062
+ wrote,
21063
+ installed,
21064
+ installLine
21065
+ });
21066
+ }
21067
+ /** Changes grouped by file, then what was left alone and why, then overrides. */
21068
+ function printSummary(plan, dryRun) {
21069
+ console.log("");
21070
+ console.log(`${chalk.bold("Rebase")} — ${dryRun ? "what upgrading" : "upgrading"} @rebasepro packages to ${chalk.cyan(plan.target)}${dryRun ? " would change" : ""}`);
21071
+ const byFile = /* @__PURE__ */ new Map();
21072
+ for (const pin of plan.changed) byFile.set(pin.file, [...byFile.get(pin.file) ?? [], pin]);
21073
+ const nameWidth = Math.max(0, ...plan.changed.map((pin) => pin.name.length));
21074
+ for (const [file, pins] of byFile) {
21075
+ console.log("");
21076
+ console.log(` ${chalk.bold(file)}`);
21077
+ for (const pin of pins) {
21078
+ const field = pin.field === "dependencies" ? "" : chalk.gray(` ${pin.field}`);
21079
+ const older = isDowngrade(pin.from, pin.to) ? chalk.yellow(" (older)") : "";
21080
+ console.log(` ${pin.name.padEnd(nameWidth)} ${chalk.gray(pin.from)} → ${chalk.green(pin.to)}${field}${older}`);
21081
+ }
21082
+ }
21083
+ if (plan.skipped.length > 0) {
21084
+ console.log("");
21085
+ console.log(` ${chalk.bold("Left alone")}`);
21086
+ for (const skip of plan.skipped) {
21087
+ console.log(` ${skip.name} ${chalk.gray(skip.spec)} ${chalk.gray(`(${skip.file}, ${skip.field})`)}`);
21088
+ console.log(chalk.gray(` ${skip.reason}`));
21089
+ }
21090
+ }
21091
+ if (plan.overrides.length > 0) {
21092
+ console.log("");
21093
+ console.log(` ${chalk.bold("Overrides")}`);
21094
+ for (const override of plan.overrides) {
21095
+ const where = chalk.gray(`(${override.file})`);
21096
+ if (override.action === "bumped") console.log(` ${override.name} ${chalk.gray(override.spec)} → ${chalk.green(override.to ?? "")} ${where}`);
21097
+ else if (override.action === "removed-local") console.log(` ${override.name} ${chalk.gray(override.spec)} ${where} ${chalk.green(dryRun ? "would be removed" : "removed")}`);
21098
+ else {
21099
+ console.log(` ${chalk.yellow("⚠")} ${override.name} → ${override.spec} ${where}`);
21100
+ console.log(chalk.yellow(" local override — wins over every pin, the upgrade will not reach these packages"));
21101
+ }
21102
+ }
21103
+ if (plan.overrides.some((o) => o.action === "kept-local")) console.log(chalk.gray(" Run again with --drop-local-overrides to remove them."));
21104
+ }
21105
+ if (plan.unreadable.length > 0) {
21106
+ console.log("");
21107
+ console.log(` ${chalk.bold("Not read")}`);
21108
+ for (const entry of plan.unreadable) console.log(` ${entry.file} ${chalk.gray(entry.reason)}`);
21109
+ }
21110
+ console.log("");
21111
+ }
21112
+ function printOutcome(plan, outcome) {
21113
+ const moved = plan.changed.length + plan.overrides.filter((o) => o.action !== "kept-local").length;
21114
+ if (outcome.dryRun) {
21115
+ 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
+ console.log("");
21117
+ return;
21118
+ }
21119
+ if (!outcome.wrote) {
21120
+ console.log(chalk.green(`✓ This project is already on ${plan.target}.`));
21121
+ console.log("");
21122
+ return;
21123
+ }
21124
+ const files = plan.writes.length;
21125
+ console.log(chalk.green(`✓ Moved ${moved} entr${moved === 1 ? "y" : "ies"} in ${files} file${files === 1 ? "" : "s"} to ${plan.target}${outcome.installed ? " and installed" : ""}.`));
21126
+ if (!outcome.installed) console.log(chalk.gray(` Install with \`${outcome.installLine}\`, then run \`rebase build\`, then \`rebase cloud deploy\`.`));
21127
+ else console.log(chalk.gray(" Run `rebase build`, then `rebase cloud deploy`."));
21128
+ console.log("");
21129
+ }
21130
+ //#endregion
19593
21131
  //#region src/cli.ts
19594
21132
  /**
19595
21133
  * Silence dotenv's own banner, for this process and everything it spawns.
@@ -19658,7 +21196,8 @@ async function entry(args) {
19658
21196
  "generate-sdk",
19659
21197
  "telemetry",
19660
21198
  "resources",
19661
- "status"
21199
+ "status",
21200
+ "upgrade"
19662
21201
  ];
19663
21202
  if (!command || parsedArgs["--help"] && !namespacedCommands.includes(command)) {
19664
21203
  printHelp();
@@ -19737,6 +21276,9 @@ async function dispatch(command, effectiveSubcommand, args, parsedArgs, namespac
19737
21276
  case "start":
19738
21277
  await startCommand(args);
19739
21278
  break;
21279
+ case "upgrade":
21280
+ await upgradeCommand(args);
21281
+ break;
19740
21282
  case "apps":
19741
21283
  await appsCommand(effectiveSubcommand, args);
19742
21284
  break;
@@ -19784,6 +21326,7 @@ ${chalk.green.bold("Commands")}
19784
21326
  ${chalk.blue.bold("build")} Build the apps declared in rebase.json into a bundle
19785
21327
  ${chalk.blue.bold("normalize-imports")} Complete compiled output's relative imports for Node ESM
19786
21328
  ${chalk.blue.bold("start")} Start the backend server ${chalk.gray("(production)")}
21329
+ ${chalk.blue.bold("upgrade")} Move every @rebasepro package to one release, then install
19787
21330
  ${chalk.blue.bold("apps list")} Show the apps this repository declares
19788
21331
 
19789
21332
  ${chalk.green.bold("Schema")}