@rebasepro/cli 0.11.0 → 0.11.1-canary.g8caabf3

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.
Files changed (33) hide show
  1. package/bin/rebase.js +21 -0
  2. package/dist/bundle.d.ts +15 -5
  3. package/dist/commands/cloud/resources.d.ts +20 -0
  4. package/dist/commands/eject.d.ts +1 -0
  5. package/dist/commands/init.d.ts +19 -15
  6. package/dist/fold-static.d.ts +41 -15
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +766 -241
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/manifest.d.ts +26 -7
  11. package/package.json +7 -7
  12. package/runtime/dev-server.mjs +0 -1
  13. package/templates/{template/backend → eject}/Dockerfile +16 -4
  14. package/templates/{template → eject}/backend/src/env.ts +0 -1
  15. package/templates/eject/docker-compose.custom.yml +71 -0
  16. package/templates/overlays/baas/backend/package.json +1 -4
  17. package/templates/overlays/baas/backend/tsconfig.json +8 -2
  18. package/templates/overlays/baas/config/index.ts +15 -0
  19. package/templates/overlays/baas/config/package.json +28 -0
  20. package/templates/overlays/baas/package.json +2 -1
  21. package/templates/overlays/baas/pnpm-workspace.yaml +1 -0
  22. package/templates/overlays/baas/rebase.json +2 -6
  23. package/templates/template/README.md +34 -16
  24. package/templates/template/backend/package.json +1 -4
  25. package/templates/template/docker-compose.yml +62 -38
  26. package/templates/template/frontend/src/main.tsx +8 -1
  27. package/templates/template/frontend/vite.config.ts +5 -0
  28. package/templates/template/rebase.json +5 -8
  29. package/templates/overlays/baas/backend/src/index.ts +0 -216
  30. package/templates/template/frontend/Dockerfile +0 -52
  31. package/templates/template/frontend/nginx.conf +0 -40
  32. /package/templates/{template → eject}/backend/src/index.ts +0 -0
  33. /package/templates/overlays/baas/{backend/src → config}/storage.ts +0 -0
package/dist/index.es.js CHANGED
@@ -829,7 +829,7 @@ function openUrl(target, label = "Opening") {
829
829
  //#region src/commands/init.ts
830
830
  var access = promisify(fs.access);
831
831
  var __filename$1 = fileURLToPath(import.meta.url);
832
- var __dirname$1 = path.dirname(__filename$1);
832
+ var __dirname$2 = path.dirname(__filename$1);
833
833
  function findParentDir(currentDir, targetName) {
834
834
  const root = path.parse(currentDir).root;
835
835
  while (currentDir && currentDir !== root) {
@@ -838,22 +838,56 @@ function findParentDir(currentDir, targetName) {
838
838
  }
839
839
  return null;
840
840
  }
841
- var cliRoot = findParentDir(__dirname$1, "cli");
841
+ var cliRoot = findParentDir(__dirname$2, "cli");
842
842
  var PROJECT_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
843
+ /**
844
+ * Every scaffolded file that carries a `{{PLACEHOLDER}}`.
845
+ *
846
+ * Exported because it is the single source of truth: `init.test.ts` reads it and
847
+ * asserts that every template file containing `{{` appears here. It used to be a
848
+ * local, with the test harness keeping a *second* copy — so the two drifted, and
849
+ * a test built on the copy could not observe the production list at all.
850
+ *
851
+ * The drift shipped: `docker-compose.yml` arrived with the self-host work
852
+ * (26fd5259c) and was added to neither, so every scaffolded project got a literal
853
+ * `name: {{PROJECT_NAME}}`. In YAML `{{...}}` is a map, not a string, so the
854
+ * documented `docker compose up` path failed on the file before doing anything:
855
+ *
856
+ * yaml: unmarshal errors: line 28: cannot unmarshal !!map into string
857
+ */
858
+ var TEMPLATE_PLACEHOLDER_FILES = [
859
+ "package.json",
860
+ "frontend/package.json",
861
+ "backend/package.json",
862
+ "config/package.json",
863
+ "frontend/index.html",
864
+ "pnpm-workspace.yaml",
865
+ "docker-compose.yml",
866
+ "README.md"
867
+ ];
843
868
  /** Returns an error message, or null when the name is a valid package name. */
844
869
  function validateProjectName(name) {
845
870
  if (!name.trim()) return "Project name is required";
846
871
  if (!PROJECT_NAME_RE.test(name)) return "Project name must start with a lowercase letter or number and contain only lowercase letters, numbers, hyphens, dots, or underscores";
847
872
  return null;
848
873
  }
849
- var FLAVOR_CHOICES = [{
850
- name: "BaaS + admin — API plus an admin UI, driven by collections you define (like Payload/Directus)",
851
- value: "cms",
852
- short: "BaaS + admin"
874
+ /**
875
+ * Whether to scaffold the admin panel alongside the backend.
876
+ *
877
+ * A boolean, not a named pair. It was `--flavor cms|baas`, and neither word
878
+ * survived what they described: "cms" is a product category rather than a thing
879
+ * this tool builds, and "baas" was the same value that used to appear as
880
+ * `backend.mode` — which is now derived from whether collections are declared.
881
+ * What is left is one question: does this project get an admin UI?
882
+ */
883
+ var HEADLESS_CHOICES = [{
884
+ name: "Backend + admin — API plus an admin UI, driven by collections you define (like Payload/Directus)",
885
+ value: false,
886
+ short: "Backend + admin"
853
887
  }, {
854
- name: "BaaS only — headless API over your database. No collections, no UI (like Supabase)",
855
- value: "baas",
856
- short: "BaaS only"
888
+ name: "Backend only — headless API over your database. No collections, no UI (like Supabase)",
889
+ value: true,
890
+ short: "Backend only"
857
891
  }];
858
892
  var PRESET_CHOICES = [
859
893
  {
@@ -878,7 +912,7 @@ var PRESET_CHOICES = [
878
912
  * types registered by the installed version of inquirer.
879
913
  */
880
914
  function buildInitQuestions(params) {
881
- const { nameArg, templateArg, flavorArg, hasGitFlag, hasInstallFlag, pm } = params;
915
+ const { nameArg, templateArg, headlessArg, hasGitFlag, hasInstallFlag, pm } = params;
882
916
  const questions = [];
883
917
  if (!nameArg) questions.push({
884
918
  type: "input",
@@ -887,12 +921,12 @@ function buildInitQuestions(params) {
887
921
  default: "my-rebase-app",
888
922
  validate: (input) => validateProjectName(input) ?? true
889
923
  });
890
- if (!flavorArg) questions.push({
924
+ if (headlessArg === void 0) questions.push({
891
925
  type: "select",
892
- name: "flavor",
926
+ name: "headless",
893
927
  message: "What do you want to build?",
894
- choices: FLAVOR_CHOICES,
895
- default: "cms"
928
+ choices: HEADLESS_CHOICES,
929
+ default: false
896
930
  });
897
931
  if (!templateArg) questions.push({
898
932
  type: "select",
@@ -900,7 +934,7 @@ function buildInitQuestions(params) {
900
934
  message: "Choose a starter template:",
901
935
  choices: PRESET_CHOICES,
902
936
  default: "blog",
903
- when: (answers) => (flavorArg ?? answers.flavor) !== "baas"
937
+ when: (answers) => !(headlessArg ?? answers.headless)
904
938
  });
905
939
  if (!hasGitFlag) questions.push({
906
940
  type: "confirm",
@@ -956,7 +990,7 @@ ${chalk.bold("Usage")}
956
990
 
957
991
  ${chalk.bold("Options")}
958
992
  ${chalk.blue("-t, --template")} ${chalk.gray("<preset>")} blog | ecommerce | blank ${chalk.gray("(default: blog)")}
959
- ${chalk.blue("-f, --flavor")} ${chalk.gray("<flavor>")} cms | baas ${chalk.gray("(default: cms)")}
993
+ ${chalk.blue("--headless")} Backend only no admin panel, no collections
960
994
  ${chalk.blue("-y, --yes")} Accept defaults, never prompt ${chalk.gray("(required for CI / non-TTY)")}
961
995
  ${chalk.blue("-i, --install")} Install dependencies after scaffolding
962
996
  ${chalk.blue("-g, --git")} Initialize a git repository and make an initial commit
@@ -965,14 +999,14 @@ ${chalk.bold("Options")}
965
999
  ${chalk.blue("--project")} ${chalk.gray("<slug>")} Link the scaffold to a Rebase Cloud project
966
1000
  ${chalk.blue("--setup-key")} ${chalk.gray("<key>")} One-time key authenticating the cloud link ${chalk.gray("(use with --project)")}
967
1001
 
968
- ${chalk.bold("Flavors")}
969
- ${chalk.blue("cms")} BaaS + admin UI, driven by collections you define ${chalk.gray("(like Payload/Directus)")}
970
- ${chalk.blue("baas")} Headless API over your database — no collections, no UI ${chalk.gray("(like Supabase)")}
971
- ${chalk.gray("--template has no effect on this flavor.")}
1002
+ ${chalk.bold("What gets scaffolded")}
1003
+ ${chalk.gray("default")} Backend + an admin UI, driven by collections you define ${chalk.gray("(like Payload/Directus)")}
1004
+ ${chalk.blue("--headless")} Backend only, over your existing database ${chalk.gray("(like Supabase)")}
1005
+ ${chalk.gray("--template has no effect: there are no collections to seed.")}
972
1006
 
973
1007
  ${chalk.bold("Examples")}
974
1008
  ${chalk.gray("$")} rebase init my-shop --template ecommerce --install
975
- ${chalk.gray("$")} rebase init my-api --flavor baas --yes
1009
+ ${chalk.gray("$")} rebase init my-api --headless --yes
976
1010
  ${chalk.gray("$")} rebase init . --yes --git
977
1011
  `);
978
1012
  }
@@ -993,14 +1027,13 @@ async function promptForOptions(rawArgs, pm) {
993
1027
  "--database-url": String,
994
1028
  "--introspect": Boolean,
995
1029
  "--template": String,
996
- "--flavor": String,
1030
+ "--headless": Boolean,
997
1031
  "--project": String,
998
1032
  "--setup-key": String,
999
1033
  "--yes": Boolean,
1000
1034
  "-g": "--git",
1001
1035
  "-i": "--install",
1002
1036
  "-t": "--template",
1003
- "-f": "--flavor",
1004
1037
  "-y": "--yes"
1005
1038
  }, {
1006
1039
  argv: rawArgs.slice(3),
@@ -1021,11 +1054,7 @@ async function promptForOptions(rawArgs, pm) {
1021
1054
  console.error(chalk.red(`Unknown template "${templateArg}". Available: ${PRESET_CHOICES.map((p) => p.value).join(", ")}`));
1022
1055
  process.exit(1);
1023
1056
  }
1024
- const flavorArg = args["--flavor"];
1025
- if (flavorArg && !FLAVOR_CHOICES.some((f) => f.value === flavorArg)) {
1026
- console.error(chalk.red(`Unknown flavor "${flavorArg}". Available: ${FLAVOR_CHOICES.map((f) => f.value).join(", ")}`));
1027
- process.exit(1);
1028
- }
1057
+ const headlessArg = args["--headless"] === true ? true : void 0;
1029
1058
  if (isNonInteractive) {
1030
1059
  const projectName = nameArg || "my-rebase-app";
1031
1060
  const targetDirectory = path.resolve(process.cwd(), projectName);
@@ -1041,7 +1070,7 @@ async function promptForOptions(rawArgs, pm) {
1041
1070
  introspect: args["--introspect"] || false,
1042
1071
  preset: templateArg || "blog",
1043
1072
  explicitPreset: !!templateArg,
1044
- flavor: flavorArg || "cms",
1073
+ headless: headlessArg ?? false,
1045
1074
  pm,
1046
1075
  pmCommands,
1047
1076
  cloudProject: args["--project"] || void 0,
@@ -1052,14 +1081,14 @@ async function promptForOptions(rawArgs, pm) {
1052
1081
  if (!process.stdin.isTTY) {
1053
1082
  console.error(chalk.red("Cannot prompt: this is a non-interactive terminal (no TTY)."));
1054
1083
  console.error(chalk.yellow(" Re-run with --yes to accept defaults, passing any choices as flags, e.g.:"));
1055
- console.error(chalk.yellow(` rebase init ${nameArg || "my-app"} --yes --template blog --flavor cms`));
1056
- console.error(chalk.gray(" Options: --template <blog|ecommerce|blank> --flavor <cms|baas> --database-url <url> --install --git"));
1084
+ console.error(chalk.yellow(` rebase init ${nameArg || "my-app"} --yes --template blog`));
1085
+ console.error(chalk.gray(" Options: --template <blog|ecommerce|blank> --headless --database-url <url> --install --git"));
1057
1086
  process.exit(1);
1058
1087
  }
1059
1088
  const questions = buildInitQuestions({
1060
1089
  nameArg,
1061
1090
  templateArg,
1062
- flavorArg,
1091
+ headlessArg,
1063
1092
  hasGitFlag: !!args["--git"],
1064
1093
  hasInstallFlag: !!args["--install"],
1065
1094
  pm
@@ -1079,7 +1108,7 @@ async function promptForOptions(rawArgs, pm) {
1079
1108
  introspect: answers.introspect || false,
1080
1109
  preset: templateArg || answers.preset || "blog",
1081
1110
  explicitPreset: !!templateArg,
1082
- flavor: flavorArg || answers.flavor || "cms",
1111
+ headless: headlessArg ?? Boolean(answers.headless),
1083
1112
  pm,
1084
1113
  pmCommands,
1085
1114
  cloudProject: args["--project"] || void 0,
@@ -1163,12 +1192,12 @@ async function createProject$1(options) {
1163
1192
  const shipped = path.join(options.targetDirectory, from);
1164
1193
  if (fs.existsSync(shipped)) fs.renameSync(shipped, path.join(options.targetDirectory, to));
1165
1194
  }
1166
- if (options.flavor === "baas" && options.explicitPreset) console.log(chalk.yellow(` Ignoring --template ${options.preset}: the baas flavor has no collections.`));
1167
- if (options.flavor !== "baas") {
1195
+ if (options.headless && options.explicitPreset) console.log(chalk.yellow(` Ignoring --template ${options.preset}: a headless project declares no collections.`));
1196
+ if (!options.headless) {
1168
1197
  if (options.introspect && options.preset !== "blank") console.log(chalk.gray(" Using the blank template: collections will come from your database."));
1169
1198
  await applyPreset(options.targetDirectory, options.introspect ? "blank" : options.preset);
1170
1199
  }
1171
- await applyFlavor(options.targetDirectory, options.flavor);
1200
+ await applyHeadless(options.targetDirectory, options.headless);
1172
1201
  await replacePlaceholders(options);
1173
1202
  await configureEnvFile(options.targetDirectory, options.databaseUrl);
1174
1203
  if (options.git) {
@@ -1266,7 +1295,7 @@ async function createProject$1(options) {
1266
1295
  console.log("");
1267
1296
  const runDev = pmCommands.run("dev");
1268
1297
  const runDbPush = pmCommands.run("db:push");
1269
- const isBaas = options.flavor === "baas";
1298
+ const isBaas = options.headless;
1270
1299
  const cdTarget = formatCdTarget(process.cwd(), options.targetDirectory);
1271
1300
  if (cdTarget) console.log(` ${chalk.cyan("cd")} ${cdTarget}`);
1272
1301
  if (!options.installDeps) console.log(` ${chalk.cyan(installCmd.join(" "))}`);
@@ -1336,18 +1365,31 @@ async function createProject$1(options) {
1336
1365
  * presets directory so the final project is clean.
1337
1366
  */
1338
1367
  /**
1339
- * Reduce the scaffolded project to the chosen flavor.
1368
+ * Reduce the scaffolded project to the chosen shape.
1369
+ *
1370
+ * The base template is the full triad. `--headless` drops the frontend and the
1371
+ * declared collections — there is nothing to define, since the server derives
1372
+ * its API from the database — and overlays the files that differ.
1340
1373
  *
1341
- * The base template is the full CMS triad. `baas` drops the frontend and the
1342
- * collections config entirely there is nothing to define, since the server
1343
- * derives its API from the database and overlays the files that differ.
1374
+ * The config *package* stays, holding only `storageAuthorize`. Storage is not
1375
+ * under row-level security, so a deployment with file storage enabled and no
1376
+ * access model serves every user's files to every signed-in user; the server
1377
+ * refuses to boot in that state. Deleting the package outright would leave a
1378
+ * headless project with nowhere to put the hook, and the scaffold's own
1379
+ * docker-compose.yml enables storage — so the first `docker compose up` would
1380
+ * crash-loop.
1344
1381
  */
1345
- async function applyFlavor(targetDirectory, flavor) {
1346
- if (flavor !== "baas") return;
1347
- for (const dir of ["frontend", "config"]) fs.rmSync(path.join(targetDirectory, dir), {
1382
+ async function applyHeadless(targetDirectory, headless) {
1383
+ if (!headless) return;
1384
+ fs.rmSync(path.join(targetDirectory, "frontend"), {
1348
1385
  recursive: true,
1349
1386
  force: true
1350
1387
  });
1388
+ fs.rmSync(path.join(targetDirectory, "config", "collections"), {
1389
+ recursive: true,
1390
+ force: true
1391
+ });
1392
+ for (const stray of ["admin.d.ts", "frontend-assets.d.ts"]) fs.rmSync(path.join(targetDirectory, "config", stray), { force: true });
1351
1393
  fs.rmSync(path.join(targetDirectory, "backend", "src", "schema.generated.ts"), { force: true });
1352
1394
  const overlayDir = path.resolve(cliRoot, "templates", "overlays", "baas");
1353
1395
  if (!fs.existsSync(overlayDir)) {
@@ -1394,15 +1436,7 @@ function cleanupPresets(presetsDir) {
1394
1436
  });
1395
1437
  }
1396
1438
  async function replacePlaceholders(options) {
1397
- const filesToProcess = [
1398
- "package.json",
1399
- "frontend/package.json",
1400
- "backend/package.json",
1401
- "config/package.json",
1402
- "frontend/index.html",
1403
- "pnpm-workspace.yaml",
1404
- "README.md"
1405
- ];
1439
+ const filesToProcess = TEMPLATE_PLACEHOLDER_FILES;
1406
1440
  const packageJsonPath = path.resolve(cliRoot, "package.json");
1407
1441
  let cliVersion = "latest";
1408
1442
  if (fs.existsSync(packageJsonPath)) cliVersion = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")).version || "latest";
@@ -2014,13 +2048,19 @@ var ManifestError = class extends Error {
2014
2048
  this.name = "ManifestError";
2015
2049
  }
2016
2050
  };
2017
- var APP_TYPES = [
2018
- "backend",
2019
- "static",
2020
- "admin",
2021
- "mobile",
2022
- "custom"
2023
- ];
2051
+ var APP_TYPES = ["backend", "static"];
2052
+ /**
2053
+ * App types that used to exist, and what replaced each one.
2054
+ *
2055
+ * Kept so a manifest written against the old vocabulary fails with the fix in
2056
+ * hand rather than with `must be one of: backend, static`, which tells a reader
2057
+ * what is wrong but not what to write instead.
2058
+ */
2059
+ var REMOVED_APP_TYPES = {
2060
+ admin: "the admin panel is an ordinary static app now — declare it as { \"type\": \"static\", \"root\": \"frontend\", \"output\": \"frontend/dist\", \"path\": \"/admin\" }",
2061
+ custom: "who owns the server is a property of the backend now — declare { \"type\": \"backend\", \"runtime\": \"custom\" } instead of a separate app",
2062
+ mobile: "mobile apps are no longer declared in the manifest — nothing consumed this type. Remove the entry"
2063
+ };
2024
2064
  /** Reserved because they name things in URLs and CLI output. */
2025
2065
  var RESERVED_APP_NAMES = new Set([
2026
2066
  "api",
@@ -2029,6 +2069,30 @@ var RESERVED_APP_NAMES = new Set([
2029
2069
  "livez",
2030
2070
  "_rebase"
2031
2071
  ]);
2072
+ /**
2073
+ * Validate a static app's public base path.
2074
+ *
2075
+ * Normalizes to "no trailing slash, except for the root" so that mounting and
2076
+ * the `REBASE_APP_BASE` build variable have one shape to reason about.
2077
+ */
2078
+ function checkAppPath(value, fieldPath, issues) {
2079
+ if (value === void 0) return void 0;
2080
+ if (typeof value !== "string" || !value.startsWith("/") || value.includes("..")) {
2081
+ issues.push({
2082
+ path: fieldPath,
2083
+ message: "must be an absolute path like \"/admin\""
2084
+ });
2085
+ return;
2086
+ }
2087
+ if (value !== "/" && value.endsWith("/")) {
2088
+ issues.push({
2089
+ path: fieldPath,
2090
+ message: "must not end with a slash — write \"/admin\", not \"/admin/\""
2091
+ });
2092
+ return;
2093
+ }
2094
+ return value;
2095
+ }
2032
2096
  function isRecord(value) {
2033
2097
  return typeof value === "object" && value !== null && !Array.isArray(value);
2034
2098
  }
@@ -2071,6 +2135,73 @@ function checkRelativePath(value, fieldPath, issues, { required }) {
2071
2135
  }
2072
2136
  return value;
2073
2137
  }
2138
+ /** Fields each app type understands. Anything else is a typo or a newer CLI. */
2139
+ var KNOWN_APP_FIELDS = {
2140
+ backend: [
2141
+ "type",
2142
+ "runtime",
2143
+ "config",
2144
+ "functions",
2145
+ "crons",
2146
+ "schema",
2147
+ "usersCollection",
2148
+ "dockerfile",
2149
+ "context",
2150
+ "port"
2151
+ ],
2152
+ static: [
2153
+ "type",
2154
+ "root",
2155
+ "build",
2156
+ "output",
2157
+ "path",
2158
+ "spa"
2159
+ ]
2160
+ };
2161
+ /**
2162
+ * Report a field this CLI does not recognise.
2163
+ *
2164
+ * A warning rather than an error, and the distinction matters in both
2165
+ * directions. `"pathh": "/admin"` is a typo that would otherwise be silently
2166
+ * dropped — the app builds for `/`, mounts at `/`, and the only symptom is that
2167
+ * it is not where you put it. But an unknown field is also what an *older* CLI
2168
+ * sees when it opens a manifest written for a newer one, and refusing to build
2169
+ * over a field that is simply from the future would make every manifest addition
2170
+ * a breaking change.
2171
+ *
2172
+ * So: name it, suggest the near-miss, and carry on.
2173
+ */
2174
+ function warnUnknownFields(name, raw, type) {
2175
+ const known = KNOWN_APP_FIELDS[type];
2176
+ if (!known) return;
2177
+ for (const field of Object.keys(raw)) {
2178
+ if (known.includes(field)) continue;
2179
+ const suggestion = known.find((candidate) => isNearMiss(candidate, field));
2180
+ console.warn(`⚠ rebase.json: apps.${name}.${field} is not a field this CLI knows.` + (suggestion ? ` Did you mean "${suggestion}"?` : " It is ignored — your CLI may be older than this manifest."));
2181
+ }
2182
+ }
2183
+ /** One edit apart, ignoring case: enough for a typo, tight enough to stay quiet. */
2184
+ function isNearMiss(a, b) {
2185
+ const x = a.toLowerCase();
2186
+ const y = b.toLowerCase();
2187
+ if (x === y) return true;
2188
+ if (Math.abs(x.length - y.length) > 1) return false;
2189
+ const [shorter, longer] = x.length <= y.length ? [x, y] : [y, x];
2190
+ let i = 0;
2191
+ let j = 0;
2192
+ let edits = 0;
2193
+ while (i < shorter.length && j < longer.length) {
2194
+ if (shorter[i] === longer[j]) {
2195
+ i++;
2196
+ j++;
2197
+ continue;
2198
+ }
2199
+ if (++edits > 1) return false;
2200
+ if (shorter.length === longer.length) i++;
2201
+ j++;
2202
+ }
2203
+ return edits + (longer.length - j) + (shorter.length - i) <= 1;
2204
+ }
2074
2205
  function validateApp(name, raw, issues) {
2075
2206
  const base = `apps.${name}`;
2076
2207
  if (!isRecord(raw)) {
@@ -2081,6 +2212,13 @@ function validateApp(name, raw, issues) {
2081
2212
  return;
2082
2213
  }
2083
2214
  const type = raw.type;
2215
+ if (typeof type === "string" && REMOVED_APP_TYPES[type]) {
2216
+ issues.push({
2217
+ path: `${base}.type`,
2218
+ message: `"${type}" is no longer an app type — ${REMOVED_APP_TYPES[type]}`
2219
+ });
2220
+ return;
2221
+ }
2084
2222
  if (typeof type !== "string" || !APP_TYPES.includes(type)) {
2085
2223
  issues.push({
2086
2224
  path: `${base}.type`,
@@ -2088,18 +2226,39 @@ function validateApp(name, raw, issues) {
2088
2226
  });
2089
2227
  return;
2090
2228
  }
2229
+ warnUnknownFields(name, raw, type);
2091
2230
  switch (type) {
2092
- case "backend":
2231
+ case "backend": {
2093
2232
  checkRelativePath(raw.config, `${base}.config`, issues, { required: false });
2094
2233
  checkRelativePath(raw.functions, `${base}.functions`, issues, { required: false });
2095
2234
  checkRelativePath(raw.crons, `${base}.crons`, issues, { required: false });
2096
2235
  checkRelativePath(raw.schema, `${base}.schema`, issues, { required: false });
2097
2236
  checkRelativePath(raw.usersCollection, `${base}.usersCollection`, issues, { required: false });
2098
- if (raw.mode !== void 0 && raw.mode !== "cms" && raw.mode !== "baas") issues.push({
2237
+ if (raw.mode !== void 0) issues.push({
2099
2238
  path: `${base}.mode`,
2100
- message: "must be \"cms\" or \"baas\""
2239
+ message: "is no longer a field — collections come from the config directory when it exists, and are introspected from the database when it does not"
2240
+ });
2241
+ const custom = raw.runtime === "custom";
2242
+ if (raw.runtime !== "managed" && !custom) issues.push({
2243
+ path: `${base}.runtime`,
2244
+ message: "is required, and must be \"managed\" or \"custom\""
2245
+ });
2246
+ for (const field of [
2247
+ "dockerfile",
2248
+ "context",
2249
+ "port"
2250
+ ]) if (raw[field] !== void 0 && !custom) issues.push({
2251
+ path: `${base}.${field}`,
2252
+ message: "only applies to a custom runtime — set \"runtime\": \"custom\" to build your own image"
2253
+ });
2254
+ checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
2255
+ checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
2256
+ if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
2257
+ path: `${base}.port`,
2258
+ message: "must be an integer"
2101
2259
  });
2102
2260
  return raw;
2261
+ }
2103
2262
  case "static":
2104
2263
  checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2105
2264
  checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
@@ -2111,37 +2270,7 @@ function validateApp(name, raw, issues) {
2111
2270
  path: `${base}.spa`,
2112
2271
  message: "must be a boolean"
2113
2272
  });
2114
- return raw;
2115
- case "admin": {
2116
- const mode = raw.mode ?? "hosted";
2117
- if (mode !== "hosted" && mode !== "bundled") {
2118
- issues.push({
2119
- path: `${base}.mode`,
2120
- message: "must be \"hosted\" or \"bundled\""
2121
- });
2122
- return;
2123
- }
2124
- if (mode === "bundled") {
2125
- checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2126
- checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
2127
- }
2128
- return raw;
2129
- }
2130
- case "mobile": {
2131
- const platform = raw.platform;
2132
- if (platform !== "ios" && platform !== "android" && platform !== "other") issues.push({
2133
- path: `${base}.platform`,
2134
- message: "must be \"ios\", \"android\" or \"other\""
2135
- });
2136
- return raw;
2137
- }
2138
- case "custom":
2139
- checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
2140
- checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
2141
- if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
2142
- path: `${base}.port`,
2143
- message: "must be an integer"
2144
- });
2273
+ checkAppPath(raw.path, `${base}.path`, issues);
2145
2274
  return raw;
2146
2275
  default: return;
2147
2276
  }
@@ -2155,10 +2284,13 @@ function validateManifest(raw) {
2155
2284
  path: "",
2156
2285
  message: `${MANIFEST_FILENAME} must contain a JSON object`
2157
2286
  }] };
2158
- if (typeof raw.runtime !== "string" || raw.runtime.trim() === "") issues.push({
2159
- path: "runtime",
2160
- message: `is required, e.g. "^1"`
2161
- });
2287
+ if (typeof raw.rebase !== "string" || raw.rebase.trim() === "") {
2288
+ const message = typeof raw.runtime === "string" ? `is required, e.g. "^1" — this is the top-level "runtime" key renamed, because "runtime" now means "managed" or "custom" on the backend app` : `is required, e.g. "^1"`;
2289
+ issues.push({
2290
+ path: "rebase",
2291
+ message
2292
+ });
2293
+ }
2162
2294
  if (!isRecord(raw.apps)) {
2163
2295
  issues.push({
2164
2296
  path: "apps",
@@ -2192,11 +2324,25 @@ function validateManifest(raw) {
2192
2324
  path: "apps",
2193
2325
  message: "a project may declare at most one backend app"
2194
2326
  });
2327
+ const byPath = /* @__PURE__ */ new Map();
2328
+ for (const [name, app] of Object.entries(apps)) {
2329
+ if (app.type !== "static") continue;
2330
+ const at = app.path ?? "/";
2331
+ const owner = byPath.get(at);
2332
+ if (owner) {
2333
+ issues.push({
2334
+ path: `apps.${name}.path`,
2335
+ message: `two apps cannot serve the same path — "${owner}" is already at "${at}"`
2336
+ });
2337
+ continue;
2338
+ }
2339
+ byPath.set(at, name);
2340
+ }
2195
2341
  if (issues.length > 0) return { issues };
2196
2342
  return {
2197
2343
  manifest: {
2198
2344
  $schema: typeof raw.$schema === "string" ? raw.$schema : void 0,
2199
- runtime: raw.runtime,
2345
+ rebase: raw.rebase,
2200
2346
  apps
2201
2347
  },
2202
2348
  issues
@@ -2209,38 +2355,44 @@ function validateManifest(raw) {
2209
2355
  * the manifest a no-op for existing projects: the synthesized result is what
2210
2356
  * they would have written by hand.
2211
2357
  *
2212
- * An ejected backend one with its own `src/index.ts` entrypoint is reported
2213
- * as a `custom` app rather than a `backend` app. That is not a downgrade; it is
2214
- * an accurate description, and it is what keeps such a project deploying exactly
2215
- * as it does today.
2358
+ * The backend's `runtime` is inferred from whether the repository declares a
2359
+ * **Dockerfile** the thing that actually builds an image and from nothing
2360
+ * else. It used to be inferred from the presence of `backend/src/index.ts`,
2361
+ * which every scaffolded project had whether or not it wanted its own server, so
2362
+ * projects predating the manifest silently landed on the custom runtime and paid
2363
+ * for it (see `docs/cloud-deploy-workspace-vendoring.md`).
2216
2364
  */
2217
2365
  function synthesizeManifest(projectRoot) {
2218
2366
  const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
2219
2367
  const apps = {};
2220
2368
  const hasConfig = exists(DEFAULT_CONFIG_DIR);
2221
2369
  const hasBackend = exists("backend");
2222
- const backendEntry = exists("backend/src/index.ts");
2223
- if (hasBackend && backendEntry) apps.backend = {
2224
- type: "custom",
2225
- dockerfile: exists("backend/Dockerfile") ? "backend/Dockerfile" : void 0,
2226
- context: "."
2227
- };
2228
- else if (hasBackend || hasConfig) {
2229
- const backend = { type: "backend" };
2230
- if (!hasConfig) backend.mode = "baas";
2370
+ const dockerfile = ["Dockerfile", "backend/Dockerfile"].find(exists);
2371
+ if (hasBackend || hasConfig) {
2372
+ const backend = dockerfile ? {
2373
+ type: "backend",
2374
+ runtime: "custom",
2375
+ dockerfile,
2376
+ context: "."
2377
+ } : {
2378
+ type: "backend",
2379
+ runtime: "managed"
2380
+ };
2231
2381
  if (exists("backend/functions")) backend.functions = DEFAULT_FUNCTIONS_DIR;
2232
2382
  if (exists("backend/crons")) backend.crons = DEFAULT_CRONS_DIR;
2233
2383
  apps.backend = backend;
2384
+ if (!dockerfile && exists("backend/src/index.ts")) console.warn("⚠ backend/src/index.ts exists but this project's backend is managed — it is\n never loaded. Delete it, or run `rebase eject` to make it the entrypoint.");
2234
2385
  }
2235
2386
  if (exists("frontend")) apps.web = {
2236
2387
  type: "static",
2237
2388
  root: "frontend",
2238
2389
  build: "npm run build --workspace frontend",
2239
2390
  output: "frontend/dist",
2391
+ path: "/",
2240
2392
  spa: true
2241
2393
  };
2242
2394
  return {
2243
- runtime: "^1",
2395
+ rebase: "^1",
2244
2396
  apps
2245
2397
  };
2246
2398
  }
@@ -2282,7 +2434,7 @@ function writeManifest(projectRoot, manifest) {
2282
2434
  const filePath = manifestPath(projectRoot);
2283
2435
  const ordered = {
2284
2436
  $schema: manifest.$schema ?? "https://rebase.pro/schemas/rebase.json",
2285
- runtime: manifest.runtime,
2437
+ rebase: manifest.rebase,
2286
2438
  apps: manifest.apps
2287
2439
  };
2288
2440
  fs.writeFileSync(filePath, `${JSON.stringify(ordered, null, 4)}\n`, "utf8");
@@ -2301,13 +2453,8 @@ function buildableApps(manifest) {
2301
2453
  name,
2302
2454
  app
2303
2455
  }));
2304
- const rank = (app) => {
2305
- if (app.type === "backend") return 0;
2306
- if (app.type === "admin") return 1;
2307
- if (app.type === "static") return 2;
2308
- return 3;
2309
- };
2310
- return entries.filter(({ app }) => app.type !== "mobile").sort((a, b) => rank(a.app) - rank(b.app));
2456
+ const rank = (app) => app.type === "backend" ? 0 : 1;
2457
+ return entries.sort((a, b) => rank(a.app) - rank(b.app));
2311
2458
  }
2312
2459
  /**
2313
2460
  * Decide whether a project can run on the managed runtime, and say why not.
@@ -2317,28 +2464,45 @@ function buildableApps(manifest) {
2317
2464
  * verdict.
2318
2465
  */
2319
2466
  function assessManagedCompatibility(manifest) {
2320
- const reasons = [];
2321
2467
  const backend = findBackendApp(manifest);
2322
- if (!backend) {
2323
- const custom = Object.entries(manifest.apps).find(([, app]) => app.type === "custom");
2324
- if (custom) reasons.push(`App "${custom[0]}" is a custom container. The managed runtime runs the platform image with your bundle, so a project that builds its own image uses the custom runtime instead.`);
2325
- else reasons.push("No backend app is declared in this repository. Only the repository that declares the backend selects the runtime.");
2326
- }
2327
- for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "custom") reasons.push(`App "${name}" is a custom container image.`);
2468
+ if (!backend) return {
2469
+ eligible: false,
2470
+ reasons: ["No backend app is declared in this repository. Only the repository that declares the backend selects the runtime."]
2471
+ };
2472
+ if (backend.app.runtime === "custom") return {
2473
+ eligible: false,
2474
+ reasons: [`App "${backend.name}" declares runtime: "custom", so it builds and runs its own image. The managed runtime boots the platform image with your bundle; the custom runtime deploys exactly the same way, from your Dockerfile.`]
2475
+ };
2328
2476
  return {
2329
- eligible: reasons.length === 0 && Boolean(backend),
2330
- reasons
2477
+ eligible: true,
2478
+ reasons: []
2331
2479
  };
2332
2480
  }
2333
- /** Resolve a backend app's directories against the conventions it omits. */
2334
- function resolveBackendPaths(app) {
2481
+ /**
2482
+ * Resolve a backend app's directories against the conventions it omits.
2483
+ *
2484
+ * `hasCollections` replaces the old `mode: "cms" | "baas"` field. Where the
2485
+ * collections come from was never an independent choice: either they are
2486
+ * declared in code and the bundle ships them, or they are not and the runtime
2487
+ * introspects the live database at boot. Declaring it separately only created
2488
+ * the contradictory state — code-first declared, no collections anywhere.
2489
+ *
2490
+ * `hasConfig` is deliberately a **separate** question. A headless project has no
2491
+ * `config/collections`, but it may still ship a config package — that is where
2492
+ * the `storageAuthorize` hook lives, and storage is not under row-level
2493
+ * security, so without one the server refuses to boot with storage enabled.
2494
+ * Collapsing the two would leave a headless project nowhere to put it.
2495
+ */
2496
+ function resolveBackendPaths(app, projectRoot) {
2497
+ const config = app.config ?? "config";
2335
2498
  return {
2336
- config: app.config ?? "config",
2499
+ config,
2337
2500
  functions: app.functions ?? "backend/functions",
2338
2501
  crons: app.crons ?? "backend/crons",
2339
2502
  schema: app.schema ?? "backend/src/schema.generated.ts",
2340
2503
  usersCollection: app.usersCollection ?? "collections/users",
2341
- mode: app.mode ?? "cms"
2504
+ hasConfig: fs.existsSync(path.join(projectRoot, config)),
2505
+ hasCollections: fs.existsSync(path.join(projectRoot, config, "collections"))
2342
2506
  };
2343
2507
  }
2344
2508
  //#endregion
@@ -2401,22 +2565,19 @@ function devRuntimeEnv(projectRoot) {
2401
2565
  REBASE_DEV_CONFIG: "config",
2402
2566
  REBASE_DEV_FUNCTIONS: "backend/functions",
2403
2567
  REBASE_DEV_CRONS: "backend/crons",
2404
- REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts",
2405
- REBASE_DEV_MODE: "cms"
2568
+ REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts"
2406
2569
  };
2407
2570
  try {
2408
2571
  const backend = findBackendApp(loadManifest(projectRoot).manifest);
2409
2572
  if (backend) {
2410
- const paths = resolveBackendPaths(backend.app);
2573
+ const paths = resolveBackendPaths(backend.app, projectRoot);
2411
2574
  result.REBASE_DEV_CONFIG = paths.config;
2412
2575
  result.REBASE_DEV_FUNCTIONS = paths.functions;
2413
2576
  result.REBASE_DEV_CRONS = paths.crons;
2414
2577
  result.REBASE_DEV_SCHEMA = paths.schema;
2415
- result.REBASE_DEV_MODE = paths.mode;
2416
2578
  result.REBASE_DEV_APP = backend.name;
2417
2579
  }
2418
2580
  } catch {}
2419
- if (!fs.existsSync(path.join(projectRoot, result.REBASE_DEV_CONFIG))) result.REBASE_DEV_MODE = "baas";
2420
2581
  return result;
2421
2582
  }
2422
2583
  /** Well-known filename the backend writes its actual port to. */
@@ -3328,11 +3489,10 @@ async function regenerateSchema(projectRoot, configDir, options) {
3328
3489
  * deployed green, and answered 404 on every one of them, with the file still
3329
3490
  * sitting in the repository looking exactly like the server.
3330
3491
  *
3331
- * A project that means to keep its own entrypoint declares the app as
3332
- * `"type": "custom"`, which builds the repository's Dockerfile instead which
3333
- * is what {@link synthesizeManifest} already infers for a manifest-less repo
3334
- * carrying one. The warning names that route rather than implying the file is
3335
- * a mistake.
3492
+ * A project that means to keep its own entrypoint runs `rebase eject`, which
3493
+ * writes the entrypoint, a Dockerfile and a compose file together and flips the
3494
+ * backend to `runtime: "custom"`. The warning names that route rather than
3495
+ * implying the file is a mistake.
3336
3496
  */
3337
3497
  function findUnusedServerEntry(projectRoot, functionsDir) {
3338
3498
  const found = [path.join("backend", "src", "index.ts"), path.join(path.dirname(functionsDir), "src", "index.ts")].find((candidate) => fs.existsSync(path.join(projectRoot, candidate)));
@@ -3343,22 +3503,22 @@ function findUnusedServerEntry(projectRoot, functionsDir) {
3343
3503
  */
3344
3504
  async function buildBundle(options) {
3345
3505
  const { projectRoot, app, appName } = options;
3346
- const paths = resolveBackendPaths(app);
3506
+ const paths = resolveBackendPaths(app, projectRoot);
3347
3507
  const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
3348
3508
  const includes = [];
3349
3509
  const addIfExists = (relative, pattern) => {
3350
3510
  if (fs.existsSync(path.join(projectRoot, relative))) includes.push(pattern);
3351
3511
  };
3352
- if (paths.mode === "cms") addIfExists(paths.config, `${paths.config}/**/*.ts`);
3512
+ if (paths.hasConfig) addIfExists(paths.config, `${paths.config}/**/*.ts`);
3353
3513
  addIfExists(paths.functions, `${paths.functions}/**/*.ts`);
3354
3514
  addIfExists(paths.crons, `${paths.crons}/**/*.ts`);
3355
3515
  if (fs.existsSync(path.join(projectRoot, paths.schema))) includes.push(paths.schema);
3356
3516
  if (includes.length === 0) throw new Error(`Nothing to build for app "${appName}". Expected a config directory at "${paths.config}" or functions at "${paths.functions}".`);
3357
- if (paths.mode === "cms" && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
3517
+ if (paths.hasCollections && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
3358
3518
  const unusedEntry = findUnusedServerEntry(projectRoot, paths.functions);
3359
3519
  if (unusedEntry) {
3360
3520
  const parts = [
3361
- ...paths.mode === "cms" ? [`${paths.config}/`] : [],
3521
+ ...paths.hasCollections ? [`${paths.config}/`] : [],
3362
3522
  `${paths.functions}/`,
3363
3523
  "the schema"
3364
3524
  ];
@@ -3366,7 +3526,7 @@ async function buildBundle(options) {
3366
3526
  console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
3367
3527
  console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
3368
3528
  console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
3369
- console.log(chalk.dim(` or declare this app as "type": "custom" in rebase.json to keep your own entrypoint.`));
3529
+ console.log(chalk.dim(` or run \`rebase eject\` to make this file the entrypoint and own the image.`));
3370
3530
  }
3371
3531
  log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
3372
3532
  cleanOutDir(projectRoot, outDir);
@@ -3391,9 +3551,9 @@ async function buildBundle(options) {
3391
3551
  const compiledConfigDir = path.join(outDir, paths.config);
3392
3552
  const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
3393
3553
  let collections = [];
3394
- if (paths.mode === "cms") {
3554
+ if (paths.hasCollections) {
3395
3555
  collections = await loadSourceCollections(path.join(projectRoot, paths.config, "collections"));
3396
- if (collections.length === 0) throw new Error(`No collections were found in ${path.join(paths.config, "collections")}. A cms-mode project must define at least one collection.`);
3556
+ if (collections.length === 0) throw new Error(`No collections were found in ${path.join(paths.config, "collections")}. Define at least one collection there, or remove the directory to have the runtime introspect collections from the live database instead.`);
3397
3557
  if (!fs.existsSync(compiledCollectionsDir)) throw new Error(`Compilation produced no collections directory at ${path.relative(projectRoot, compiledCollectionsDir)}.`);
3398
3558
  }
3399
3559
  const declared = collectDeclaredDependencies(projectRoot);
@@ -3408,16 +3568,16 @@ async function buildBundle(options) {
3408
3568
  builtAgainst: resolveServerVersion(projectRoot),
3409
3569
  contract: RUNTIME_CONTRACT_VERSION
3410
3570
  },
3411
- schemaVersion: paths.mode === "baas" ? "" : computeSchemaVersion(collections),
3571
+ schemaVersion: paths.hasCollections ? computeSchemaVersion(collections) : "",
3412
3572
  app: appName,
3413
- mode: paths.mode,
3573
+ kind: "backend",
3414
3574
  entry: {
3415
- config: paths.mode === "cms" ? relative(paths.config) : void 0,
3416
- collections: paths.mode === "cms" ? relative(path.join(paths.config, "collections")) : void 0,
3575
+ config: paths.hasConfig ? relative(paths.config) : void 0,
3576
+ collections: paths.hasCollections ? relative(path.join(paths.config, "collections")) : void 0,
3417
3577
  functions: relative(paths.functions),
3418
3578
  crons: relative(paths.crons),
3419
3579
  schema: relative(schemaOut),
3420
- usersCollection: paths.mode === "cms" ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
3580
+ usersCollection: paths.hasCollections ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
3421
3581
  },
3422
3582
  collections: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
3423
3583
  hooks: {
@@ -3487,11 +3647,12 @@ async function buildBundle(options) {
3487
3647
  * in one image already, that is exactly what it had.
3488
3648
  */
3489
3649
  function foldStaticIntoBundle(options) {
3490
- const { bundleDir, assetsDir } = options;
3650
+ const { bundleDir, assetsDir, appName, path: basePath, spa } = options;
3491
3651
  const manifestPath = path.join(bundleDir, "manifest.json");
3492
3652
  if (!fs.existsSync(manifestPath)) throw new Error(`No manifest at ${manifestPath} — build the backend bundle first.`);
3493
3653
  if (!fs.existsSync(assetsDir)) throw new Error(`No built assets at ${assetsDir}.`);
3494
- const staticOut = path.join(bundleDir, "static");
3654
+ const dir = path.posix.join("static", appName);
3655
+ const staticOut = path.join(bundleDir, "static", appName);
3495
3656
  fs.rmSync(staticOut, {
3496
3657
  recursive: true,
3497
3658
  force: true
@@ -3499,21 +3660,30 @@ function foldStaticIntoBundle(options) {
3499
3660
  fs.mkdirSync(staticOut, { recursive: true });
3500
3661
  fs.cpSync(assetsDir, staticOut, { recursive: true });
3501
3662
  let fileCount = 0;
3502
- const count = (dir) => {
3503
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
3663
+ const count = (target) => {
3664
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(target, entry.name));
3504
3665
  else fileCount++;
3505
3666
  };
3506
3667
  count(staticOut);
3507
3668
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
3669
+ const existing = (manifest.entry?.static ?? []).filter((entry) => entry.dir !== dir);
3508
3670
  manifest.entry = {
3509
3671
  ...manifest.entry,
3510
- static: "static"
3672
+ static: [...existing, {
3673
+ path: basePath,
3674
+ dir,
3675
+ spa
3676
+ }]
3511
3677
  };
3512
3678
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3513
- return { fileCount };
3679
+ return {
3680
+ fileCount,
3681
+ dir
3682
+ };
3514
3683
  }
3515
3684
  function buildStaticBundle(options) {
3516
3685
  const { projectRoot, appName, assetsDir, outDir, runtimeRange } = options;
3686
+ const basePath = options.path ?? "/";
3517
3687
  cleanOutDir(projectRoot, outDir);
3518
3688
  const staticOut = path.join(outDir, "static");
3519
3689
  fs.mkdirSync(staticOut, { recursive: true });
@@ -3533,8 +3703,12 @@ function buildStaticBundle(options) {
3533
3703
  },
3534
3704
  schemaVersion: "",
3535
3705
  app: appName,
3536
- mode: "static",
3537
- entry: { static: "static" },
3706
+ kind: "static",
3707
+ entry: { static: [{
3708
+ path: basePath,
3709
+ dir: "static",
3710
+ spa: options.spa ?? true
3711
+ }] },
3538
3712
  hooks: { native: false },
3539
3713
  deps: { declared: {} },
3540
3714
  build: {
@@ -3636,7 +3810,7 @@ function resolveCliVersion() {
3636
3810
  //#endregion
3637
3811
  //#region src/fold-static.ts
3638
3812
  /**
3639
- * Folding a project's frontend into its backend bundle.
3813
+ * Folding a project's static apps into its backend bundle.
3640
3814
  *
3641
3815
  * Shared by `rebase build` and `rebase cloud deploy` deliberately. It lived in
3642
3816
  * the build *command* first, and `deploy` rebuilds the bundle itself — so a
@@ -3645,34 +3819,77 @@ function resolveCliVersion() {
3645
3819
  * folding had never been written. Two callers building the same artefact must
3646
3820
  * share the step that completes it.
3647
3821
  *
3648
- * Why fold at all: `bootFromBundle` already serves a SPA from `entry.static`
3822
+ * Why fold at all: `bootFromBundle` serves static apps from `entry.static`
3649
3823
  * behind `REBASE_SERVE_STATIC` (default on). A managed tenant runs one pod, so
3650
- * putting the built site in the bundle gives it the shape a custom container
3651
- * already had — site at `/`, API at `/api` — which is the only honest baseline
3652
- * for calling the managed runtime a drop-in replacement.
3824
+ * putting the built assets in the bundle gives it the shape a custom container
3825
+ * already had — site at `/`, admin at `/admin`, API at `/api` — which is the
3826
+ * only honest baseline for calling the managed runtime a drop-in replacement.
3827
+ *
3828
+ * **Every** static app is folded, each at its declared path. Folding used to
3829
+ * pick exactly one and refuse when it found two, which meant a project with a
3830
+ * site and an admin panel deployed with neither.
3653
3831
  */
3654
3832
  /**
3655
- * Which static app, if any, should be served by the backend.
3833
+ * Every static app in the manifest, in mount order.
3656
3834
  *
3657
- * Exactly one `static` app is folded. With several, folding would have to choose,
3658
- * and silently picking one of two websites is worse than doing nothing — so it
3659
- * declines and names what it saw. Pure, so the decision is testable without a
3660
- * filesystem.
3835
+ * Longest path first, so the `/`-rooted app is registered last its catch-all
3836
+ * would otherwise claim its siblings' URLs. Pure, so the ordering is testable
3837
+ * without a filesystem.
3661
3838
  */
3662
- function selectFoldableApp(manifest) {
3663
- const statics = Object.entries(manifest.apps ?? {}).filter(([, app]) => app?.type === "static").map(([name, app]) => ({
3664
- name,
3665
- build: app?.build,
3666
- output: app?.output
3667
- }));
3668
- if (statics.length === 0) return {};
3669
- if (statics.length > 1) return { reason: `${statics.length} static apps (${statics.map((s) => s.name).join(", ")}) — none folded in. Pick one to serve from the backend, or host them separately.` };
3670
- const only = statics[0];
3671
- if (!only.output) return { reason: `"${only.name}" declares no output directory — not folded in.` };
3672
- return { app: only };
3839
+ function foldableApps(manifest) {
3840
+ const apps = [];
3841
+ const skipped = [];
3842
+ for (const [name, app] of Object.entries(manifest.apps ?? {})) {
3843
+ if (app?.type !== "static") continue;
3844
+ if (!app.output) {
3845
+ skipped.push({
3846
+ name,
3847
+ reason: `"${name}" declares no output directory — not folded in.`
3848
+ });
3849
+ continue;
3850
+ }
3851
+ apps.push({
3852
+ name,
3853
+ build: app.build,
3854
+ output: app.output,
3855
+ path: app.path ?? "/",
3856
+ spa: app.spa ?? true
3857
+ });
3858
+ }
3859
+ apps.sort((a, b) => b.path.length - a.path.length);
3860
+ return {
3861
+ apps,
3862
+ skipped
3863
+ };
3864
+ }
3865
+ /**
3866
+ * Assert a built app's assets are actually rooted at the path it is served from.
3867
+ *
3868
+ * An app mounted at `/admin` but built with Vite's default `base: "/"` emits
3869
+ * `<script src="/assets/index-a1b2.js">`. The server serves `index.html` fine
3870
+ * and 404s every asset: a blank page, no server error, nothing in the logs. It
3871
+ * is the single most expensive silent failure in this design, so it is a build
3872
+ * error rather than a runtime surprise.
3873
+ *
3874
+ * Only `<script src>` and `<link href>` are inspected — those are what a bundler
3875
+ * rewrites through `base`. Author-written anchors and canonical URLs are not
3876
+ * evidence of a misbuild.
3877
+ */
3878
+ function assertBuiltForPath(indexHtml, basePath, appName) {
3879
+ if (basePath === "/") return;
3880
+ const offenders = [];
3881
+ for (const match of indexHtml.matchAll(/<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
3882
+ const ref = match[1];
3883
+ if (!ref.startsWith("/")) continue;
3884
+ if (ref === `${basePath}` || ref.startsWith(`${basePath}/`)) continue;
3885
+ offenders.push(ref);
3886
+ }
3887
+ if (offenders.length === 0) return;
3888
+ throw new Error(`"${appName}" is declared at ${basePath} but its build emitted assets rooted at /.\n index.html references: ${offenders.slice(0, 3).join(", ")}\n The app would load a blank page. Set \`base\` from REBASE_APP_BASE in its
3889
+ build config — see docs/apps-and-runtimes.md §4.2.`);
3673
3890
  }
3674
3891
  /**
3675
- * Build the project's frontend and fold it into the backend bundle.
3892
+ * Build the project's static apps and fold them into the backend bundle.
3676
3893
  *
3677
3894
  * Throws rather than exiting, so the caller decides whether a missing frontend
3678
3895
  * should fail its command — a `build` may reasonably want to stop, and so should
@@ -3681,27 +3898,39 @@ function selectFoldableApp(manifest) {
3681
3898
  async function foldFrontendIntoBundle(options) {
3682
3899
  const { projectRoot, manifest, bundleDir, skipBuild } = options;
3683
3900
  const log = options.log ?? ((m) => console.log(m));
3684
- const { app, reason } = selectFoldableApp(manifest);
3685
- if (reason) {
3686
- log(chalk.yellow(` ⚠ ${reason}`));
3687
- return null;
3901
+ const { apps, skipped } = foldableApps(manifest);
3902
+ for (const { reason } of skipped) log(chalk.yellow(` ⚠ ${reason}`));
3903
+ if (apps.length === 0) return [];
3904
+ const outcomes = [];
3905
+ for (const app of apps) {
3906
+ if (app.build && !skipBuild) await execa(app.build, {
3907
+ cwd: projectRoot,
3908
+ stdio: "inherit",
3909
+ shell: true,
3910
+ env: {
3911
+ REBASE_APP_PATH: app.path,
3912
+ REBASE_APP_BASE: app.path === "/" ? "/" : `${app.path}/`,
3913
+ REBASE_APP_NAME: app.name
3914
+ }
3915
+ });
3916
+ const assetsDir = path.join(projectRoot, app.output);
3917
+ if (!fs.existsSync(assetsDir)) throw new Error(`"${app.name}" declared output "${app.output}" does not exist after building — the bundle would ship without a frontend.`);
3918
+ const indexHtml = path.join(assetsDir, "index.html");
3919
+ if (fs.existsSync(indexHtml)) assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), app.path, app.name);
3920
+ const { fileCount } = foldStaticIntoBundle({
3921
+ bundleDir,
3922
+ assetsDir,
3923
+ appName: app.name,
3924
+ path: app.path,
3925
+ spa: app.spa
3926
+ });
3927
+ outcomes.push({
3928
+ appName: app.name,
3929
+ fileCount,
3930
+ path: app.path
3931
+ });
3688
3932
  }
3689
- if (!app) return null;
3690
- if (app.build && !skipBuild) await execa(app.build, {
3691
- cwd: projectRoot,
3692
- stdio: "inherit",
3693
- shell: true
3694
- });
3695
- const assetsDir = path.join(projectRoot, app.output);
3696
- if (!fs.existsSync(assetsDir)) throw new Error(`"${app.name}" declared output "${app.output}" does not exist after building — the bundle would ship without a frontend.`);
3697
- const { fileCount } = foldStaticIntoBundle({
3698
- bundleDir,
3699
- assetsDir
3700
- });
3701
- return {
3702
- appName: app.name,
3703
- fileCount
3704
- };
3933
+ return outcomes;
3705
3934
  }
3706
3935
  //#endregion
3707
3936
  //#region src/commands/build.ts
@@ -3719,7 +3948,7 @@ async function foldFrontendIntoBundle(options) {
3719
3948
  * entrypoint, falls back to the previous behaviour: run every workspace's own
3720
3949
  * `build` script. Nothing that built before stops building.
3721
3950
  */
3722
- function printHelp$3() {
3951
+ function printHelp$4() {
3723
3952
  console.log(`
3724
3953
  ${chalk.bold("rebase build")} — build the apps declared in rebase.json
3725
3954
 
@@ -3754,7 +3983,7 @@ async function buildCommand(rawArgs = []) {
3754
3983
  permissive: true
3755
3984
  });
3756
3985
  if (args["--help"]) {
3757
- printHelp$3();
3986
+ printHelp$4();
3758
3987
  return;
3759
3988
  }
3760
3989
  const projectRoot = requireProjectRoot();
@@ -3798,13 +4027,19 @@ async function buildCommand(rawArgs = []) {
3798
4027
  console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
3799
4028
  for (const { name, app } of targets) {
3800
4029
  console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
4030
+ if (app.type === "backend" && app.runtime === "custom") {
4031
+ console.log(chalk.dim(" custom runtime — this project builds its own image, not a bundle"));
4032
+ console.log(chalk.dim(` ${chalk.cyan(`npm run build --workspace ${name}`)} then ${chalk.cyan(`docker build -f ${app.dockerfile ?? "Dockerfile"} .`)}`));
4033
+ console.log("");
4034
+ continue;
4035
+ }
3801
4036
  if (app.type === "backend") {
3802
4037
  const result = await buildBundle({
3803
4038
  projectRoot,
3804
4039
  appName: name,
3805
4040
  app,
3806
4041
  outDir: args["--out"],
3807
- runtimeRange: manifest.runtime,
4042
+ runtimeRange: manifest.rebase,
3808
4043
  skipTypeCheck: args["--skip-type-check"],
3809
4044
  skipSchema: args["--skip-schema"]
3810
4045
  });
@@ -3827,28 +4062,24 @@ async function buildCommand(rawArgs = []) {
3827
4062
  console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
3828
4063
  process.exit(1);
3829
4064
  });
3830
- if (folded) console.log(chalk.green(` ✓ ${folded.appName} folded in`) + chalk.dim(` (${folded.fileCount} file(s) → served at /)`));
4065
+ for (const outcome of folded ?? []) console.log(chalk.green(` ✓ ${outcome.appName} folded in`) + chalk.dim(` (${outcome.fileCount} file(s) → served at ${outcome.path})`));
3831
4066
  }
3832
- } else if (app.type === "static" || app.type === "admin") await buildAssetApp(projectRoot, name, app, manifest.runtime, args["--out"]);
3833
- else if (app.type === "custom") console.log(chalk.dim(" custom container — built at deploy time from its Dockerfile"));
4067
+ } else if (app.type === "static") await buildAssetApp(projectRoot, name, app, manifest.rebase, args["--out"]);
3834
4068
  console.log("");
3835
4069
  }
3836
4070
  console.log(chalk.green("✓ Build complete."));
3837
4071
  }
3838
4072
  /**
3839
- * Build a static or bundled-admin app and package it into a static bundle.
4073
+ * Build a static app and package it into a static bundle.
3840
4074
  *
3841
4075
  * Runs the app's own build command, checks it produced the declared output, then
3842
- * packages that output into a `static`-mode bundle — the same deployable shape as
4076
+ * packages that output into a `static`-kind bundle — the same deployable shape as
3843
4077
  * a backend bundle, so a frontend or admin app deploys through the identical
3844
4078
  * path and runs on the identical image, just serving files instead of an API.
3845
4079
  */
3846
4080
  async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
3847
4081
  const asset = app;
3848
- if (app.type === "admin" && app.mode !== "bundled") {
3849
- console.log(chalk.dim(" hosted admin panel — nothing to build"));
3850
- return;
3851
- }
4082
+ const basePath = asset.path ?? "/";
3852
4083
  if (!asset.build) {
3853
4084
  console.log(chalk.dim(" no build command declared — skipping"));
3854
4085
  return;
@@ -3857,7 +4088,12 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
3857
4088
  await execa(asset.build, {
3858
4089
  cwd: projectRoot,
3859
4090
  stdio: "inherit",
3860
- shell: true
4091
+ shell: true,
4092
+ env: {
4093
+ REBASE_APP_PATH: basePath,
4094
+ REBASE_APP_BASE: basePath === "/" ? "/" : `${basePath}/`,
4095
+ REBASE_APP_NAME: name
4096
+ }
3861
4097
  });
3862
4098
  } catch {
3863
4099
  console.error(chalk.red(` ✗ build command failed for "${name}"`));
@@ -3872,15 +4108,24 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
3872
4108
  console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
3873
4109
  process.exit(1);
3874
4110
  }
4111
+ const indexHtml = path.join(outputPath, "index.html");
4112
+ if (fs.existsSync(indexHtml)) try {
4113
+ assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), basePath, name);
4114
+ } catch (err) {
4115
+ console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
4116
+ process.exit(1);
4117
+ }
3875
4118
  const result = buildStaticBundle({
3876
4119
  projectRoot,
3877
4120
  appName: name,
3878
4121
  assetsDir: outputPath,
3879
4122
  outDir: outOverride ? path.resolve(process.cwd(), outOverride) : path.join(projectRoot, `dist-bundle-${name}`),
3880
- runtimeRange
4123
+ runtimeRange,
4124
+ path: basePath,
4125
+ spa: asset.spa ?? true
3881
4126
  });
3882
4127
  const rel = path.relative(projectRoot, result.outDir);
3883
- console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s))`));
4128
+ console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s) → served at ${basePath})`));
3884
4129
  }
3885
4130
  /** The pre-manifest behaviour: build every workspace package. */
3886
4131
  async function runWorkspaceBuilds(projectRoot) {
@@ -3898,6 +4143,234 @@ async function runWorkspaceBuilds(projectRoot) {
3898
4143
  }
3899
4144
  }
3900
4145
  //#endregion
4146
+ //#region src/commands/eject.ts
4147
+ /**
4148
+ * CLI command: rebase eject
4149
+ *
4150
+ * The supported route from the managed runtime to a custom one.
4151
+ *
4152
+ * Without it, `runtime: "custom"` is a mode a user can only reach by
4153
+ * hand-writing an entrypoint they have never seen. The template used to solve
4154
+ * that by scaffolding `backend/src/index.ts` into every project — ~190 lines
4155
+ * configuring CORS, auth, cookies, storage and history, which the managed
4156
+ * runtime never loads. It was the most important-looking file in a new project
4157
+ * and editing it did nothing.
4158
+ *
4159
+ * So the file moved here. A managed project does not carry it; a project that
4160
+ * asks for it gets it together with the Dockerfile and the manifest change that
4161
+ * make it actually run.
4162
+ *
4163
+ * There is deliberately no `rebase uneject`. Going back is deleting two files
4164
+ * and editing one line, and a command that silently discarded a user's server
4165
+ * code would be worse than its absence.
4166
+ */
4167
+ var __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
4168
+ /** Walk up to the package root, which holds `templates/`. */
4169
+ function findCliRoot(from) {
4170
+ const root = path.parse(from).root;
4171
+ let dir = from;
4172
+ while (dir && dir !== root) {
4173
+ if (fs.existsSync(path.join(dir, "templates", "eject"))) return dir;
4174
+ dir = path.dirname(dir);
4175
+ }
4176
+ return null;
4177
+ }
4178
+ /** Files the eject payload contributes, as `<source> → <destination>`. */
4179
+ var PAYLOAD = [
4180
+ {
4181
+ from: "backend/src/index.ts",
4182
+ to: "backend/src/index.ts",
4183
+ overwrite: true
4184
+ },
4185
+ {
4186
+ from: "backend/src/env.ts",
4187
+ to: "backend/src/env.ts",
4188
+ overwrite: true
4189
+ },
4190
+ {
4191
+ from: "Dockerfile",
4192
+ to: "Dockerfile",
4193
+ overwrite: false
4194
+ },
4195
+ {
4196
+ from: "docker-compose.custom.yml",
4197
+ to: "docker-compose.custom.yml",
4198
+ overwrite: false
4199
+ }
4200
+ ];
4201
+ /**
4202
+ * The project's name, for the `{{PROJECT_NAME}}` the payload carries.
4203
+ *
4204
+ * Falls back to the directory name — a compose project name is cosmetic, and a
4205
+ * missing or unreadable package.json is not a reason to refuse to eject.
4206
+ */
4207
+ function projectNameOf(projectRoot) {
4208
+ try {
4209
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
4210
+ if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim();
4211
+ } catch {}
4212
+ return path.basename(projectRoot);
4213
+ }
4214
+ function printHelp$3() {
4215
+ console.log(`
4216
+ ${chalk.bold("rebase eject")} — take ownership of the server process
4217
+
4218
+ Writes the backend entrypoint and a Dockerfile into this project, and flips its
4219
+ backend to ${chalk.cyan("runtime: \"custom\"")}. From then on this repository builds its own
4220
+ image: platform runtime upgrades no longer reach it, and CORS, auth wiring,
4221
+ storage and shutdown become yours to configure.
4222
+
4223
+ ${chalk.bold("Usage")}
4224
+ rebase eject [app]
4225
+
4226
+ ${chalk.bold("Options")}
4227
+ --dry-run List what would change, and change nothing
4228
+ -h, --help Show this help
4229
+ `.trim());
4230
+ }
4231
+ async function ejectCommand(rawArgs = []) {
4232
+ const args = arg({
4233
+ "--dry-run": Boolean,
4234
+ "--help": Boolean,
4235
+ "-h": "--help"
4236
+ }, {
4237
+ argv: rawArgs.slice(2),
4238
+ permissive: true
4239
+ });
4240
+ if (args["--help"]) {
4241
+ printHelp$3();
4242
+ return;
4243
+ }
4244
+ const projectRoot = requireProjectRoot();
4245
+ const dryRun = Boolean(args["--dry-run"]);
4246
+ const requested = args._.slice(1).find((value) => !value.startsWith("-"));
4247
+ let loaded;
4248
+ try {
4249
+ loaded = loadManifest(projectRoot);
4250
+ } catch (err) {
4251
+ if (err instanceof ManifestError) {
4252
+ console.error(chalk.red(`✗ ${err.message}`));
4253
+ for (const issue of err.issues) console.error(chalk.dim(` ${issue.path}: ${issue.message}`));
4254
+ process.exit(1);
4255
+ }
4256
+ throw err;
4257
+ }
4258
+ const { manifest } = loaded;
4259
+ let appName;
4260
+ let app;
4261
+ if (requested) {
4262
+ const declared = manifest.apps[requested];
4263
+ if (!declared) {
4264
+ console.error(chalk.red(`✗ No app named "${requested}" in rebase.json.`));
4265
+ console.error(chalk.dim(` Declared: ${Object.keys(manifest.apps).join(", ") || "(none)"}`));
4266
+ process.exit(1);
4267
+ }
4268
+ if (declared.type !== "backend") {
4269
+ console.error(chalk.red(`✗ "${requested}" is a ${declared.type} app — only a backend can be ejected.`));
4270
+ process.exit(1);
4271
+ }
4272
+ appName = requested;
4273
+ app = declared;
4274
+ } else {
4275
+ const backend = findBackendApp(manifest);
4276
+ if (!backend) {
4277
+ console.error(chalk.red("✗ This repository declares no backend app."));
4278
+ console.error(chalk.dim(" Only the repository that declares the backend chooses its runtime."));
4279
+ process.exit(1);
4280
+ }
4281
+ appName = backend.name;
4282
+ app = backend.app;
4283
+ }
4284
+ if (app.runtime === "custom") {
4285
+ console.error(chalk.red(`✗ "${appName}" is already ejected — it declares runtime: "custom".`));
4286
+ console.error(chalk.dim(` Its entrypoint is ${app.dockerfile ?? "Dockerfile"} and backend/src/index.ts.`));
4287
+ process.exit(1);
4288
+ }
4289
+ const cliRoot = findCliRoot(__dirname$1);
4290
+ if (!cliRoot) {
4291
+ console.error(chalk.red("✗ Could not locate the eject templates. Reinstall @rebasepro/cli."));
4292
+ process.exit(1);
4293
+ }
4294
+ const payloadDir = path.join(cliRoot, "templates", "eject");
4295
+ const planned = [];
4296
+ for (const file of PAYLOAD) {
4297
+ const source = path.join(payloadDir, file.from);
4298
+ if (!fs.existsSync(source)) {
4299
+ console.error(chalk.red(`✗ The eject template is missing ${file.from}. Reinstall @rebasepro/cli.`));
4300
+ process.exit(1);
4301
+ }
4302
+ const exists = fs.existsSync(path.join(projectRoot, file.to));
4303
+ planned.push({
4304
+ to: file.to,
4305
+ action: exists && !file.overwrite ? "keep" : "write"
4306
+ });
4307
+ }
4308
+ if (dryRun) {
4309
+ console.log(chalk.bold(`Would eject "${appName}" to a custom runtime:`));
4310
+ console.log("");
4311
+ for (const item of planned) console.log(item.action === "write" ? ` ${chalk.green("write")} ${item.to}` : ` ${chalk.dim("keep")} ${item.to} ${chalk.dim("(already exists)")}`);
4312
+ console.log(` ${chalk.green("write")} rebase.json ${chalk.dim("(runtime: \"custom\")")}`);
4313
+ console.log("");
4314
+ console.log(chalk.dim("Nothing was changed."));
4315
+ return;
4316
+ }
4317
+ const projectName = projectNameOf(projectRoot);
4318
+ for (const [index, file] of PAYLOAD.entries()) {
4319
+ if (planned[index].action === "keep") continue;
4320
+ const destination = path.join(projectRoot, file.to);
4321
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
4322
+ const contents = fs.readFileSync(path.join(payloadDir, file.from), "utf8").replace(/\{\{PROJECT_NAME\}\}/g, projectName);
4323
+ fs.writeFileSync(destination, contents, "utf8");
4324
+ }
4325
+ const dockerfile = app.dockerfile ?? "Dockerfile";
4326
+ manifest.apps[appName] = {
4327
+ ...app,
4328
+ runtime: "custom",
4329
+ dockerfile,
4330
+ port: app.port ?? 8080
4331
+ };
4332
+ writeManifest(projectRoot, manifest);
4333
+ restoreBackendScripts(projectRoot);
4334
+ console.log("");
4335
+ console.log(chalk.green(`✓ Ejected "${appName}" to a custom runtime.`));
4336
+ console.log("");
4337
+ console.log(` ${chalk.cyan("backend/src/index.ts".padEnd(26))} your entrypoint — the runtime no longer boots the bundle`);
4338
+ console.log(` ${chalk.cyan("backend/src/env.ts".padEnd(26))} the environment it reads`);
4339
+ console.log(` ${chalk.cyan(dockerfile.padEnd(26))} your image`);
4340
+ console.log(` ${chalk.cyan("docker-compose.custom.yml".padEnd(26))} runs it`);
4341
+ console.log(` ${chalk.cyan("rebase.json".padEnd(26))} runtime: custom`);
4342
+ console.log("");
4343
+ console.log(chalk.yellow(" You now own CORS, auth wiring, storage and shutdown. Platform runtime"));
4344
+ console.log(chalk.yellow(" upgrades no longer reach this project."));
4345
+ console.log("");
4346
+ console.log(chalk.dim(` ${chalk.cyan("docker compose -f docker-compose.custom.yml up --build")}`));
4347
+ console.log(chalk.dim(" docker-compose.yml is untouched — it still runs the managed shape if you go back."));
4348
+ console.log("");
4349
+ }
4350
+ /**
4351
+ * Point the backend workspace's scripts at the entrypoint that now exists.
4352
+ *
4353
+ * A managed project's backend package deliberately declares no `main` and no
4354
+ * `start`: there is no entrypoint to name. Ejecting creates one.
4355
+ */
4356
+ function restoreBackendScripts(projectRoot) {
4357
+ const packagePath = path.join(projectRoot, "backend", "package.json");
4358
+ if (!fs.existsSync(packagePath)) return;
4359
+ let parsed;
4360
+ try {
4361
+ parsed = JSON.parse(fs.readFileSync(packagePath, "utf8"));
4362
+ } catch {
4363
+ console.log(chalk.yellow(" ⚠ backend/package.json is not valid JSON — its scripts were left alone."));
4364
+ return;
4365
+ }
4366
+ const scripts = parsed.scripts ?? {};
4367
+ parsed.main ??= "src/index.ts";
4368
+ scripts.dev ??= "tsx watch --include=\"../config/**/*\" --include=\"./functions/**/*\" src/index.ts";
4369
+ scripts.start ??= "node dist/backend/src/index.js";
4370
+ parsed.scripts = scripts;
4371
+ fs.writeFileSync(packagePath, `${JSON.stringify(parsed, null, 4)}\n`, "utf8");
4372
+ }
4373
+ //#endregion
3901
4374
  //#region src/commands/start.ts
3902
4375
  /**
3903
4376
  * CLI command: rebase start
@@ -5345,7 +5818,7 @@ function declaredAppsFrom(manifest) {
5345
5818
  if (!apps || typeof apps !== "object") return [];
5346
5819
  return Object.entries(apps).filter(([name]) => name.trim().length > 0).map(([name, value]) => ({
5347
5820
  name,
5348
- type: String(value?.type ?? "custom")
5821
+ type: value?.type === "backend" ? "backend" : "static"
5349
5822
  }));
5350
5823
  }
5351
5824
  /** Upload a bundle archive; returns the control-plane bundle id. */
@@ -5502,7 +5975,7 @@ async function deployBundle(opts) {
5502
5975
  projectRoot,
5503
5976
  appName: backend.name,
5504
5977
  app: backend.app,
5505
- runtimeRange: loaded.manifest.runtime,
5978
+ runtimeRange: loaded.manifest.rebase,
5506
5979
  skipTypeCheck: opts.skipTypeCheck,
5507
5980
  log: (m) => console.log(chalk.gray(m))
5508
5981
  })).outDir;
@@ -5513,7 +5986,7 @@ async function deployBundle(opts) {
5513
5986
  bundleDir,
5514
5987
  log: (m) => console.log(m)
5515
5988
  });
5516
- if (folded) console.log(chalk.gray(` folded ${folded.appName} in (${folded.fileCount} file(s), served at /)`));
5989
+ for (const outcome of folded) console.log(chalk.gray(` folded ${outcome.appName} in (${outcome.fileCount} file(s), served at ${outcome.path})`));
5517
5990
  } catch (err) {
5518
5991
  fail(err instanceof Error ? err.message : String(err), "Fix the frontend build, or pass --no-static to deploy the API alone.");
5519
5992
  }
@@ -5650,6 +6123,23 @@ async function readDeployContext(client, projectId) {
5650
6123
  return {};
5651
6124
  }
5652
6125
  }
6126
+ /**
6127
+ * Whether this repository's backend declares the managed runtime.
6128
+ *
6129
+ * Deliberately quiet: a directory that is not a Rebase project, or whose
6130
+ * manifest does not parse, simply does not route this way — `rebase build` is
6131
+ * where a broken manifest gets reported, and a deploy refusing on one before it
6132
+ * has even said what it is doing would be the wrong place to find out.
6133
+ */
6134
+ function declaresManagedRuntime() {
6135
+ try {
6136
+ const projectRoot = findProjectRoot();
6137
+ if (!projectRoot) return false;
6138
+ return findBackendApp(loadManifest(projectRoot).manifest)?.app.runtime === "managed";
6139
+ } catch {
6140
+ return false;
6141
+ }
6142
+ }
5653
6143
  async function deployCommand(rawArgs, projectRef) {
5654
6144
  const args = arg({
5655
6145
  "--no-follow": Boolean,
@@ -5666,8 +6156,10 @@ async function deployCommand(rawArgs, projectRef) {
5666
6156
  });
5667
6157
  const { client, url } = await requireClient(rawArgs);
5668
6158
  const projectId = await resolveProjectRef(projectRef, client);
5669
- if (args["--bundle"]) {
5670
- if (args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
6159
+ const declaredManaged = !args["--source"] && !args["--bundle"] && declaresManagedRuntime();
6160
+ if (args["--bundle"] || declaredManaged) {
6161
+ if (args["--bundle"] && args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
6162
+ if (declaredManaged && !isJsonMode()) console.log(chalk.gray(" rebase.json declares runtime: managed — deploying a bundle."));
5671
6163
  await deployBundle({
5672
6164
  client,
5673
6165
  url,
@@ -8346,6 +8838,27 @@ function describeDatabaseState(db) {
8346
8838
  if (connection === "connected" || connection === "failed") return `${type} (${colorStatus(connection)})`;
8347
8839
  return `${type} ${chalk.gray("· not tested (`rebase cloud db test`)")}`;
8348
8840
  }
8841
+ /**
8842
+ * One line describing what engine is serving this project.
8843
+ *
8844
+ * Three numbers are in play and they are easy to conflate — I have watched it
8845
+ * happen. The **runtime version** (`1.2.0`) is the contract line a bundle's
8846
+ * range resolves against; its major IS the contract major. The **framework
8847
+ * version** (`0.11.0`) is the `@rebasepro` release the runtime image ships. They
8848
+ * move independently on purpose: tying the contract line to the framework would
8849
+ * make `^1` become `^0.11`, and pre-1.0 caret is restrictive, so every framework
8850
+ * minor would fall outside every project's range and force a rebuild to receive
8851
+ * an engine upgrade — the opposite of what the bundle/runtime split is for.
8852
+ *
8853
+ * So both are printed, rather than leaving anyone to infer one from a Docker tag.
8854
+ */
8855
+ function describeRuntime(project) {
8856
+ if (project.runtimeMode !== "managed") return `custom ${chalk.gray("· your own image")}`;
8857
+ const version = project.runtimeVersion ?? "unknown";
8858
+ const framework = project.runtimeFrameworkVersion;
8859
+ const pin = project.runtimeVersionPin ? chalk.gray(` · pinned to ${project.runtimeVersionPin}`) : "";
8860
+ return `managed ${version}${framework ? chalk.gray(` · framework ${framework}`) : ""}${pin}`;
8861
+ }
8349
8862
  async function statusCommand(rawArgs) {
8350
8863
  const { client, url } = await requireClient(rawArgs);
8351
8864
  const projectId = await requireProject(rawArgs, client);
@@ -8371,6 +8884,7 @@ async function statusCommand(rawArgs) {
8371
8884
  ["URL", projectHost(project, baseDomain)],
8372
8885
  ["Branch", project.gitBranch],
8373
8886
  ["Last deploy", deploy ? `${colorStatus(deploy.status)} · ${fmtDate(deploy.createdAt)}` : "never"],
8887
+ ["Runtime", describeRuntime(project)],
8374
8888
  ["Database", databaseLine],
8375
8889
  ["Storage", storageLine]
8376
8890
  ]);
@@ -8387,6 +8901,14 @@ async function statusCommand(rawArgs) {
8387
8901
  status: deploy.status ?? null,
8388
8902
  createdAt: deploy.createdAt ?? null
8389
8903
  } : null,
8904
+ runtime: {
8905
+ mode: project.runtimeMode ?? "custom",
8906
+ version: project.runtimeVersion ?? null,
8907
+ frameworkVersion: project.runtimeFrameworkVersion ?? null,
8908
+ contract: project.runtimeContract ?? null,
8909
+ range: project.runtimeRange ?? null,
8910
+ pin: project.runtimeVersionPin ?? null
8911
+ },
8390
8912
  database: db ? {
8391
8913
  type: db.type ?? null,
8392
8914
  connectionStatus: db.connectionStatus ?? null
@@ -9009,11 +9531,8 @@ async function appsCommand(subcommand, rawArgs = []) {
9009
9531
  }
9010
9532
  function describeApp(app) {
9011
9533
  switch (app.type) {
9012
- case "backend": return `config: ${app.config ?? "config"}, mode: ${app.mode ?? "cms"}`;
9013
- case "static": return `${app.root} → ${app.output}`;
9014
- case "admin": return app.mode === "bundled" ? `bundled → ${app.output ?? "?"}` : "hosted by the platform";
9015
- case "mobile": return app.platform;
9016
- case "custom": return app.dockerfile ?? "Dockerfile";
9534
+ case "backend": return app.runtime === "custom" ? `custom runtime — ${app.dockerfile ?? "Dockerfile"}` : `managed runtime, config: ${app.config ?? "config"}`;
9535
+ case "static": return `${app.root} → ${app.output} @ ${app.path ?? "/"}`;
9017
9536
  default: return "";
9018
9537
  }
9019
9538
  }
@@ -9023,7 +9542,7 @@ async function listApps(asJson) {
9023
9542
  if (asJson) {
9024
9543
  console.log(JSON.stringify({
9025
9544
  source: loaded.source,
9026
- runtime: loaded.manifest.runtime,
9545
+ rebase: loaded.manifest.rebase,
9027
9546
  apps: loaded.manifest.apps,
9028
9547
  managed: compatibility
9029
9548
  }, null, 2));
@@ -9033,7 +9552,7 @@ async function listApps(asJson) {
9033
9552
  console.log(chalk.dim("No rebase.json — showing the layout inferred from this project."));
9034
9553
  console.log(chalk.dim(`Run ${chalk.cyan("rebase apps init")} to write it down.\n`));
9035
9554
  }
9036
- console.log(chalk.bold(`Runtime ${loaded.manifest.runtime}`));
9555
+ console.log(chalk.bold(`Rebase ${loaded.manifest.rebase}`));
9037
9556
  console.log("");
9038
9557
  const entries = Object.entries(loaded.manifest.apps);
9039
9558
  if (entries.length === 0) {
@@ -9179,6 +9698,7 @@ async function entry(args) {
9179
9698
  "api-keys",
9180
9699
  "cloud",
9181
9700
  "apps",
9701
+ "eject",
9182
9702
  "generate-sdk"
9183
9703
  ].includes(command)) {
9184
9704
  printHelp();
@@ -9231,6 +9751,9 @@ async function entry(args) {
9231
9751
  case "apps":
9232
9752
  await appsCommand(effectiveSubcommand, args);
9233
9753
  break;
9754
+ case "eject":
9755
+ await ejectCommand(args);
9756
+ break;
9234
9757
  case "auth":
9235
9758
  await authCommand(effectiveSubcommand, args);
9236
9759
  break;
@@ -9265,6 +9788,8 @@ ${chalk.green.bold("Commands")}
9265
9788
  ${chalk.blue.bold("dev")} Start the development server
9266
9789
  ${chalk.blue.bold("build")} Build all workspace packages
9267
9790
  ${chalk.blue.bold("start")} Start the backend server ${chalk.gray("(production)")}
9791
+ ${chalk.blue.bold("apps list")} Show the apps this repository declares
9792
+ ${chalk.blue.bold("eject")} Take ownership of the server process and image
9268
9793
 
9269
9794
  ${chalk.green.bold("Schema")}
9270
9795
  ${chalk.blue.bold("schema generate")} Generate Drizzle schema from collections
@@ -9282,25 +9807,25 @@ ${chalk.green.bold("SDK")}
9282
9807
 
9283
9808
  ${chalk.green.bold("Auth")}
9284
9809
  ${chalk.blue.bold("auth reset-password")} Reset a user's password
9285
- ${chalk.blue.bold("auth")} ${chalk.gray("--help")} Show auth command help
9810
+ ${chalk.blue.bold("auth")} ${chalk.gray("--help")} Show auth command help
9286
9811
 
9287
9812
  ${chalk.green.bold("Diagnostics")}
9288
9813
  ${chalk.blue.bold("doctor")} Detect schema drift between collections, schema, and DB
9289
9814
 
9290
9815
  ${chalk.green.bold("AI Agent Skills")}
9291
- ${chalk.blue.bold("skills install")} Install Rebase agent skills for your AI coding assistant
9816
+ ${chalk.blue.bold("skills install")} Install Rebase agent skills for your AI coding assistant
9292
9817
 
9293
9818
  ${chalk.green.bold("API Keys")}
9294
- ${chalk.blue.bold("api-keys list")} List all service API keys
9295
- ${chalk.blue.bold("api-keys create")} Create a new scoped API key
9296
- ${chalk.blue.bold("api-keys revoke")} Revoke an existing API key
9297
- ${chalk.blue.bold("api-keys")} ${chalk.gray("--help")} Show API key command help
9819
+ ${chalk.blue.bold("api-keys list")} List all service API keys
9820
+ ${chalk.blue.bold("api-keys create")} Create a new scoped API key
9821
+ ${chalk.blue.bold("api-keys revoke")} Revoke an existing API key
9822
+ ${chalk.blue.bold("api-keys")} ${chalk.gray("--help")} Show API key command help
9298
9823
 
9299
9824
  ${chalk.green.bold("Rebase Cloud")}
9300
9825
  ${chalk.blue.bold("cloud login")} Sign in to the hosted control plane
9301
9826
  ${chalk.blue.bold("cloud link")} Link this directory to a cloud project
9302
9827
  ${chalk.blue.bold("cloud deploy")} Deploy the linked project + stream logs
9303
- ${chalk.blue.bold("cloud")} ${chalk.gray("--help")} Show all cloud commands
9828
+ ${chalk.blue.bold("cloud")} ${chalk.gray("--help")} Show all cloud commands
9304
9829
 
9305
9830
  ${chalk.green.bold("Options")}
9306
9831
  ${chalk.blue("--version, -v")} Show version number
@@ -9310,6 +9835,6 @@ ${chalk.gray("Documentation: https://rebase.pro/docs")}
9310
9835
  `);
9311
9836
  }
9312
9837
  //#endregion
9313
- export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, MANIFEST_FILENAME, ManifestError, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isIdentifierLike, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
9838
+ export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, MANIFEST_FILENAME, ManifestError, TEMPLATE_PLACEHOLDER_FILES, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, ejectCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isIdentifierLike, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
9314
9839
 
9315
9840
  //# sourceMappingURL=index.es.js.map