@rebasepro/cli 0.11.0 → 0.11.1-canary.g16c8254
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/bin/rebase.js +21 -0
- package/dist/bundle.d.ts +24 -7
- package/dist/commands/cloud/resources.d.ts +20 -0
- package/dist/commands/eject.d.ts +1 -0
- package/dist/commands/init.d.ts +19 -15
- package/dist/fold-static.d.ts +41 -15
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +902 -246
- package/dist/index.es.js.map +1 -1
- package/dist/manifest.d.ts +27 -8
- package/package.json +7 -7
- package/runtime/dev-server.mjs +0 -1
- package/templates/{template/backend → eject}/Dockerfile +16 -4
- package/templates/{template → eject}/backend/src/env.ts +0 -1
- package/templates/{template → eject}/backend/src/index.ts +41 -27
- package/templates/eject/docker-compose.custom.yml +71 -0
- package/templates/overlays/baas/backend/package.json +1 -4
- package/templates/overlays/baas/backend/tsconfig.json +8 -2
- package/templates/overlays/baas/config/index.ts +15 -0
- package/templates/overlays/baas/config/package.json +28 -0
- package/templates/overlays/baas/package.json +2 -1
- package/templates/overlays/baas/pnpm-workspace.yaml +1 -0
- package/templates/overlays/baas/rebase.json +2 -6
- package/templates/template/.env.example +15 -0
- package/templates/template/README.md +56 -22
- package/templates/template/ai-instructions.md +1 -0
- package/templates/template/backend/functions/hello.ts +45 -14
- package/templates/template/backend/package.json +1 -4
- package/templates/template/backend/tsconfig.json +7 -1
- package/templates/template/docker-compose.yml +62 -38
- package/templates/template/frontend/src/main.tsx +8 -1
- package/templates/template/frontend/vite.config.ts +5 -0
- package/templates/template/rebase.json +5 -8
- package/templates/overlays/baas/backend/src/index.ts +0 -216
- package/templates/template/frontend/Dockerfile +0 -52
- package/templates/template/frontend/nginx.conf +0 -40
- /package/templates/overlays/baas/{backend/src → config}/storage.ts +0 -0
package/dist/index.es.js
CHANGED
|
@@ -12,7 +12,7 @@ import crypto from "crypto";
|
|
|
12
12
|
import { execSync, spawn, spawnSync } from "child_process";
|
|
13
13
|
import os from "os";
|
|
14
14
|
import { createRebaseClient } from "@rebasepro/client";
|
|
15
|
-
import { BUNDLE_FORMAT_VERSION, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections } from "@rebasepro/types";
|
|
15
|
+
import { BUNDLE_FORMAT_VERSION, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, normalizeStorageSources, storageEnvSuffix } from "@rebasepro/types";
|
|
16
16
|
import { generateSDK } from "@rebasepro/codegen";
|
|
17
17
|
import { createRequire } from "module";
|
|
18
18
|
//#region src/utils/package-manager.ts
|
|
@@ -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$
|
|
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$
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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: "
|
|
855
|
-
value:
|
|
856
|
-
short: "
|
|
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,
|
|
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 (
|
|
924
|
+
if (headlessArg === void 0) questions.push({
|
|
891
925
|
type: "select",
|
|
892
|
-
name: "
|
|
926
|
+
name: "headless",
|
|
893
927
|
message: "What do you want to build?",
|
|
894
|
-
choices:
|
|
895
|
-
default:
|
|
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) => (
|
|
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("
|
|
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("
|
|
969
|
-
${chalk.
|
|
970
|
-
${chalk.blue("
|
|
971
|
-
|
|
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 --
|
|
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
|
-
"--
|
|
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
|
|
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
|
-
|
|
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
|
|
1056
|
-
console.error(chalk.gray(" Options: --template <blog|ecommerce|blank> --
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1167
|
-
if (options.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
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
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
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
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
2237
|
+
if (raw.mode !== void 0) issues.push({
|
|
2099
2238
|
path: `${base}.mode`,
|
|
2100
|
-
message: "
|
|
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
|
-
|
|
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.
|
|
2159
|
-
|
|
2160
|
-
|
|
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,55 +2324,148 @@ 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
|
+
}
|
|
2341
|
+
const storage = validateStorageSources(raw.storage, issues);
|
|
2195
2342
|
if (issues.length > 0) return { issues };
|
|
2196
2343
|
return {
|
|
2197
2344
|
manifest: {
|
|
2198
2345
|
$schema: typeof raw.$schema === "string" ? raw.$schema : void 0,
|
|
2199
|
-
|
|
2200
|
-
apps
|
|
2346
|
+
rebase: raw.rebase,
|
|
2347
|
+
apps,
|
|
2348
|
+
...storage ? { storage } : {}
|
|
2201
2349
|
},
|
|
2202
2350
|
issues
|
|
2203
2351
|
};
|
|
2204
2352
|
}
|
|
2205
2353
|
/**
|
|
2354
|
+
* Validate the `storage` block — which buckets this project uses.
|
|
2355
|
+
*
|
|
2356
|
+
* Absent means one default source, so `undefined` is a valid answer and not an
|
|
2357
|
+
* issue. Everything else is checked strictly, because each key here becomes the
|
|
2358
|
+
* suffix of a set of environment variables: a key that cannot become a variable
|
|
2359
|
+
* name, or two keys that become the *same* one, are failures worth catching
|
|
2360
|
+
* while someone is looking at the file rather than at a tenant serving one
|
|
2361
|
+
* bucket's files with another's credentials.
|
|
2362
|
+
*/
|
|
2363
|
+
function validateStorageSources(raw, issues) {
|
|
2364
|
+
if (raw === void 0) return void 0;
|
|
2365
|
+
if (!isRecord(raw)) {
|
|
2366
|
+
issues.push({
|
|
2367
|
+
path: "storage",
|
|
2368
|
+
message: "must be an object keyed by storage source name"
|
|
2369
|
+
});
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const sources = {};
|
|
2373
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
2374
|
+
if (!isRecord(value)) {
|
|
2375
|
+
issues.push({
|
|
2376
|
+
path: `storage.${key}`,
|
|
2377
|
+
message: "must be an object"
|
|
2378
|
+
});
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2381
|
+
if (typeof value.engine !== "string" || value.engine.trim() === "") {
|
|
2382
|
+
issues.push({
|
|
2383
|
+
path: `storage.${key}.engine`,
|
|
2384
|
+
message: "is required, e.g. \"s3\", \"gcs\" or \"local\""
|
|
2385
|
+
});
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
if (value.transport !== void 0 && value.transport !== "server" && value.transport !== "direct") {
|
|
2389
|
+
issues.push({
|
|
2390
|
+
path: `storage.${key}.transport`,
|
|
2391
|
+
message: "must be \"server\" or \"direct\""
|
|
2392
|
+
});
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
if (value.label !== void 0 && typeof value.label !== "string") {
|
|
2396
|
+
issues.push({
|
|
2397
|
+
path: `storage.${key}.label`,
|
|
2398
|
+
message: "must be a string"
|
|
2399
|
+
});
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
try {
|
|
2403
|
+
storageEnvSuffix(key);
|
|
2404
|
+
} catch {
|
|
2405
|
+
issues.push({
|
|
2406
|
+
path: `storage.${key}`,
|
|
2407
|
+
message: "name cannot become an environment variable suffix — use a name containing at least one letter or digit"
|
|
2408
|
+
});
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
sources[key] = {
|
|
2412
|
+
engine: value.engine,
|
|
2413
|
+
...value.transport !== void 0 ? { transport: value.transport } : {},
|
|
2414
|
+
...value.label !== void 0 ? { label: value.label } : {}
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
const collision = findStorageSuffixCollision(Object.keys(sources));
|
|
2418
|
+
if (collision) issues.push({
|
|
2419
|
+
path: `storage.${collision.b}`,
|
|
2420
|
+
message: `maps to the same environment variable suffix ("${collision.suffix || "(none)"}") as "${collision.a}", so the two would read each other's configuration — rename one of them`
|
|
2421
|
+
});
|
|
2422
|
+
return Object.keys(sources).length > 0 ? sources : void 0;
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2206
2425
|
* Infer a manifest from a directory that does not have one.
|
|
2207
2426
|
*
|
|
2208
2427
|
* This mirrors exactly what the template scaffolds, which is what makes adopting
|
|
2209
2428
|
* the manifest a no-op for existing projects: the synthesized result is what
|
|
2210
2429
|
* they would have written by hand.
|
|
2211
2430
|
*
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2431
|
+
* The backend's `runtime` is inferred from whether the repository declares a
|
|
2432
|
+
* **Dockerfile** — the thing that actually builds an image — and from nothing
|
|
2433
|
+
* else. It used to be inferred from the presence of `backend/src/index.ts`,
|
|
2434
|
+
* which every scaffolded project had whether or not it wanted its own server, so
|
|
2435
|
+
* projects predating the manifest silently landed on the custom runtime and paid
|
|
2436
|
+
* for it (see `docs/cloud-deploy-workspace-vendoring.md`).
|
|
2216
2437
|
*/
|
|
2217
2438
|
function synthesizeManifest(projectRoot) {
|
|
2218
2439
|
const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
|
|
2219
2440
|
const apps = {};
|
|
2220
2441
|
const hasConfig = exists(DEFAULT_CONFIG_DIR);
|
|
2221
2442
|
const hasBackend = exists("backend");
|
|
2222
|
-
const
|
|
2223
|
-
if (hasBackend
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2443
|
+
const dockerfile = ["Dockerfile", "backend/Dockerfile"].find(exists);
|
|
2444
|
+
if (hasBackend || hasConfig) {
|
|
2445
|
+
const backend = dockerfile ? {
|
|
2446
|
+
type: "backend",
|
|
2447
|
+
runtime: "custom",
|
|
2448
|
+
dockerfile,
|
|
2449
|
+
context: "."
|
|
2450
|
+
} : {
|
|
2451
|
+
type: "backend",
|
|
2452
|
+
runtime: "managed"
|
|
2453
|
+
};
|
|
2231
2454
|
if (exists("backend/functions")) backend.functions = DEFAULT_FUNCTIONS_DIR;
|
|
2232
2455
|
if (exists("backend/crons")) backend.crons = DEFAULT_CRONS_DIR;
|
|
2233
2456
|
apps.backend = backend;
|
|
2457
|
+
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
2458
|
}
|
|
2235
2459
|
if (exists("frontend")) apps.web = {
|
|
2236
2460
|
type: "static",
|
|
2237
2461
|
root: "frontend",
|
|
2238
2462
|
build: "npm run build --workspace frontend",
|
|
2239
2463
|
output: "frontend/dist",
|
|
2464
|
+
path: "/",
|
|
2240
2465
|
spa: true
|
|
2241
2466
|
};
|
|
2242
2467
|
return {
|
|
2243
|
-
|
|
2468
|
+
rebase: "^1",
|
|
2244
2469
|
apps
|
|
2245
2470
|
};
|
|
2246
2471
|
}
|
|
@@ -2282,7 +2507,7 @@ function writeManifest(projectRoot, manifest) {
|
|
|
2282
2507
|
const filePath = manifestPath(projectRoot);
|
|
2283
2508
|
const ordered = {
|
|
2284
2509
|
$schema: manifest.$schema ?? "https://rebase.pro/schemas/rebase.json",
|
|
2285
|
-
|
|
2510
|
+
rebase: manifest.rebase,
|
|
2286
2511
|
apps: manifest.apps
|
|
2287
2512
|
};
|
|
2288
2513
|
fs.writeFileSync(filePath, `${JSON.stringify(ordered, null, 4)}\n`, "utf8");
|
|
@@ -2301,13 +2526,8 @@ function buildableApps(manifest) {
|
|
|
2301
2526
|
name,
|
|
2302
2527
|
app
|
|
2303
2528
|
}));
|
|
2304
|
-
const rank = (app) =>
|
|
2305
|
-
|
|
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));
|
|
2529
|
+
const rank = (app) => app.type === "backend" ? 0 : 1;
|
|
2530
|
+
return entries.sort((a, b) => rank(a.app) - rank(b.app));
|
|
2311
2531
|
}
|
|
2312
2532
|
/**
|
|
2313
2533
|
* Decide whether a project can run on the managed runtime, and say why not.
|
|
@@ -2317,28 +2537,45 @@ function buildableApps(manifest) {
|
|
|
2317
2537
|
* verdict.
|
|
2318
2538
|
*/
|
|
2319
2539
|
function assessManagedCompatibility(manifest) {
|
|
2320
|
-
const reasons = [];
|
|
2321
2540
|
const backend = findBackendApp(manifest);
|
|
2322
|
-
if (!backend) {
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2541
|
+
if (!backend) return {
|
|
2542
|
+
eligible: false,
|
|
2543
|
+
reasons: ["No backend app is declared in this repository. Only the repository that declares the backend selects the runtime."]
|
|
2544
|
+
};
|
|
2545
|
+
if (backend.app.runtime === "custom") return {
|
|
2546
|
+
eligible: false,
|
|
2547
|
+
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.`]
|
|
2548
|
+
};
|
|
2328
2549
|
return {
|
|
2329
|
-
eligible:
|
|
2330
|
-
reasons
|
|
2550
|
+
eligible: true,
|
|
2551
|
+
reasons: []
|
|
2331
2552
|
};
|
|
2332
2553
|
}
|
|
2333
|
-
/**
|
|
2334
|
-
|
|
2554
|
+
/**
|
|
2555
|
+
* Resolve a backend app's directories against the conventions it omits.
|
|
2556
|
+
*
|
|
2557
|
+
* `hasCollections` replaces the old `mode: "cms" | "baas"` field. Where the
|
|
2558
|
+
* collections come from was never an independent choice: either they are
|
|
2559
|
+
* declared in code and the bundle ships them, or they are not and the runtime
|
|
2560
|
+
* introspects the live database at boot. Declaring it separately only created
|
|
2561
|
+
* the contradictory state — code-first declared, no collections anywhere.
|
|
2562
|
+
*
|
|
2563
|
+
* `hasConfig` is deliberately a **separate** question. A headless project has no
|
|
2564
|
+
* `config/collections`, but it may still ship a config package — that is where
|
|
2565
|
+
* the `storageAuthorize` hook lives, and storage is not under row-level
|
|
2566
|
+
* security, so without one the server refuses to boot with storage enabled.
|
|
2567
|
+
* Collapsing the two would leave a headless project nowhere to put it.
|
|
2568
|
+
*/
|
|
2569
|
+
function resolveBackendPaths(app, projectRoot) {
|
|
2570
|
+
const config = app.config ?? "config";
|
|
2335
2571
|
return {
|
|
2336
|
-
config
|
|
2572
|
+
config,
|
|
2337
2573
|
functions: app.functions ?? "backend/functions",
|
|
2338
2574
|
crons: app.crons ?? "backend/crons",
|
|
2339
2575
|
schema: app.schema ?? "backend/src/schema.generated.ts",
|
|
2340
2576
|
usersCollection: app.usersCollection ?? "collections/users",
|
|
2341
|
-
|
|
2577
|
+
hasConfig: fs.existsSync(path.join(projectRoot, config)),
|
|
2578
|
+
hasCollections: fs.existsSync(path.join(projectRoot, config, "collections"))
|
|
2342
2579
|
};
|
|
2343
2580
|
}
|
|
2344
2581
|
//#endregion
|
|
@@ -2401,22 +2638,19 @@ function devRuntimeEnv(projectRoot) {
|
|
|
2401
2638
|
REBASE_DEV_CONFIG: "config",
|
|
2402
2639
|
REBASE_DEV_FUNCTIONS: "backend/functions",
|
|
2403
2640
|
REBASE_DEV_CRONS: "backend/crons",
|
|
2404
|
-
REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts"
|
|
2405
|
-
REBASE_DEV_MODE: "cms"
|
|
2641
|
+
REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts"
|
|
2406
2642
|
};
|
|
2407
2643
|
try {
|
|
2408
2644
|
const backend = findBackendApp(loadManifest(projectRoot).manifest);
|
|
2409
2645
|
if (backend) {
|
|
2410
|
-
const paths = resolveBackendPaths(backend.app);
|
|
2646
|
+
const paths = resolveBackendPaths(backend.app, projectRoot);
|
|
2411
2647
|
result.REBASE_DEV_CONFIG = paths.config;
|
|
2412
2648
|
result.REBASE_DEV_FUNCTIONS = paths.functions;
|
|
2413
2649
|
result.REBASE_DEV_CRONS = paths.crons;
|
|
2414
2650
|
result.REBASE_DEV_SCHEMA = paths.schema;
|
|
2415
|
-
result.REBASE_DEV_MODE = paths.mode;
|
|
2416
2651
|
result.REBASE_DEV_APP = backend.name;
|
|
2417
2652
|
}
|
|
2418
2653
|
} catch {}
|
|
2419
|
-
if (!fs.existsSync(path.join(projectRoot, result.REBASE_DEV_CONFIG))) result.REBASE_DEV_MODE = "baas";
|
|
2420
2654
|
return result;
|
|
2421
2655
|
}
|
|
2422
2656
|
/** Well-known filename the backend writes its actual port to. */
|
|
@@ -3024,16 +3258,27 @@ async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck)
|
|
|
3024
3258
|
* says exactly how to proceed, while a false positive would hand back the crash
|
|
3025
3259
|
* loop this exists to prevent.
|
|
3026
3260
|
*/
|
|
3027
|
-
function detectStorageAuthorize(compiledConfigDir) {
|
|
3261
|
+
function detectStorageAuthorize(compiledConfigDir, depth = 0) {
|
|
3028
3262
|
const indexPath = [
|
|
3029
3263
|
".js",
|
|
3030
3264
|
".mjs",
|
|
3031
3265
|
".ts"
|
|
3032
3266
|
].map((ext) => path.join(compiledConfigDir, `index${ext}`)).find((candidate) => fs.existsSync(candidate));
|
|
3033
3267
|
if (!indexPath) return false;
|
|
3268
|
+
return moduleExportsStorageAuthorize(indexPath, depth);
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* Whether one compiled module re-exports or defines `storageAuthorize`.
|
|
3272
|
+
*
|
|
3273
|
+
* Split out from {@link detectStorageAuthorize} so a wildcard re-export can be
|
|
3274
|
+
* followed. `export * from "./storage.js"` is an ordinary way to write a config
|
|
3275
|
+
* barrel, and treating it as "no hook" rejected deploys that were correct — with
|
|
3276
|
+
* a message telling the developer to add a hook they had already written.
|
|
3277
|
+
*/
|
|
3278
|
+
function moduleExportsStorageAuthorize(modulePath, depth) {
|
|
3034
3279
|
let source;
|
|
3035
3280
|
try {
|
|
3036
|
-
source = fs.readFileSync(
|
|
3281
|
+
source = fs.readFileSync(modulePath, "utf8");
|
|
3037
3282
|
} catch {
|
|
3038
3283
|
return false;
|
|
3039
3284
|
}
|
|
@@ -3042,9 +3287,48 @@ function detectStorageAuthorize(compiledConfigDir) {
|
|
|
3042
3287
|
const parts = entry.split(/\bas\b/);
|
|
3043
3288
|
return parts[parts.length - 1].trim();
|
|
3044
3289
|
}).includes("storageAuthorize")) return true;
|
|
3290
|
+
if (depth < 3) for (const clause of source.matchAll(/\bexport\s*\*\s*from\s*["']([^"']+)["']/g)) {
|
|
3291
|
+
const specifier = clause[1];
|
|
3292
|
+
if (!specifier.startsWith(".")) continue;
|
|
3293
|
+
const resolved = resolveRelativeModule(path.dirname(modulePath), specifier);
|
|
3294
|
+
if (resolved && moduleExportsStorageAuthorize(resolved, depth + 1)) return true;
|
|
3295
|
+
}
|
|
3045
3296
|
return false;
|
|
3046
3297
|
}
|
|
3047
3298
|
/**
|
|
3299
|
+
* Resolve a relative ESM specifier to a file on disk.
|
|
3300
|
+
*
|
|
3301
|
+
* Compiled output carries explicit `.js` extensions, but the same function reads
|
|
3302
|
+
* TypeScript sources during a source boot, where the specifier may be
|
|
3303
|
+
* extensionless or point at a directory index.
|
|
3304
|
+
*/
|
|
3305
|
+
function resolveRelativeModule(fromDir, specifier) {
|
|
3306
|
+
const base = path.resolve(fromDir, specifier);
|
|
3307
|
+
const candidates = [
|
|
3308
|
+
base,
|
|
3309
|
+
`${base}.js`,
|
|
3310
|
+
`${base}.mjs`,
|
|
3311
|
+
`${base}.ts`,
|
|
3312
|
+
path.join(base, "index.js"),
|
|
3313
|
+
path.join(base, "index.mjs"),
|
|
3314
|
+
path.join(base, "index.ts")
|
|
3315
|
+
];
|
|
3316
|
+
for (const candidate of candidates) try {
|
|
3317
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
3318
|
+
} catch {
|
|
3319
|
+
continue;
|
|
3320
|
+
}
|
|
3321
|
+
if (/\.js$/.test(base)) {
|
|
3322
|
+
const asTs = base.replace(/\.js$/, ".ts");
|
|
3323
|
+
try {
|
|
3324
|
+
if (fs.existsSync(asTs) && fs.statSync(asTs).isFile()) return asTs;
|
|
3325
|
+
} catch {
|
|
3326
|
+
return null;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3048
3332
|
* Detect native code in the dependency closure.
|
|
3049
3333
|
*
|
|
3050
3334
|
* Walks declared runtime dependencies breadth-first through `node_modules`,
|
|
@@ -3328,11 +3612,10 @@ async function regenerateSchema(projectRoot, configDir, options) {
|
|
|
3328
3612
|
* deployed green, and answered 404 on every one of them, with the file still
|
|
3329
3613
|
* sitting in the repository looking exactly like the server.
|
|
3330
3614
|
*
|
|
3331
|
-
* A project that means to keep its own entrypoint
|
|
3332
|
-
*
|
|
3333
|
-
*
|
|
3334
|
-
*
|
|
3335
|
-
* a mistake.
|
|
3615
|
+
* A project that means to keep its own entrypoint runs `rebase eject`, which
|
|
3616
|
+
* writes the entrypoint, a Dockerfile and a compose file together and flips the
|
|
3617
|
+
* backend to `runtime: "custom"`. The warning names that route rather than
|
|
3618
|
+
* implying the file is a mistake.
|
|
3336
3619
|
*/
|
|
3337
3620
|
function findUnusedServerEntry(projectRoot, functionsDir) {
|
|
3338
3621
|
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 +3626,22 @@ function findUnusedServerEntry(projectRoot, functionsDir) {
|
|
|
3343
3626
|
*/
|
|
3344
3627
|
async function buildBundle(options) {
|
|
3345
3628
|
const { projectRoot, app, appName } = options;
|
|
3346
|
-
const paths = resolveBackendPaths(app);
|
|
3629
|
+
const paths = resolveBackendPaths(app, projectRoot);
|
|
3347
3630
|
const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
|
|
3348
3631
|
const includes = [];
|
|
3349
3632
|
const addIfExists = (relative, pattern) => {
|
|
3350
3633
|
if (fs.existsSync(path.join(projectRoot, relative))) includes.push(pattern);
|
|
3351
3634
|
};
|
|
3352
|
-
if (paths.
|
|
3635
|
+
if (paths.hasConfig) addIfExists(paths.config, `${paths.config}/**/*.ts`);
|
|
3353
3636
|
addIfExists(paths.functions, `${paths.functions}/**/*.ts`);
|
|
3354
3637
|
addIfExists(paths.crons, `${paths.crons}/**/*.ts`);
|
|
3355
3638
|
if (fs.existsSync(path.join(projectRoot, paths.schema))) includes.push(paths.schema);
|
|
3356
3639
|
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.
|
|
3640
|
+
if (paths.hasCollections && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
|
|
3358
3641
|
const unusedEntry = findUnusedServerEntry(projectRoot, paths.functions);
|
|
3359
3642
|
if (unusedEntry) {
|
|
3360
3643
|
const parts = [
|
|
3361
|
-
...paths.
|
|
3644
|
+
...paths.hasCollections ? [`${paths.config}/`] : [],
|
|
3362
3645
|
`${paths.functions}/`,
|
|
3363
3646
|
"the schema"
|
|
3364
3647
|
];
|
|
@@ -3366,7 +3649,7 @@ async function buildBundle(options) {
|
|
|
3366
3649
|
console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
|
|
3367
3650
|
console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
|
|
3368
3651
|
console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
|
|
3369
|
-
console.log(chalk.dim(` or
|
|
3652
|
+
console.log(chalk.dim(` or run \`rebase eject\` to make this file the entrypoint and own the image.`));
|
|
3370
3653
|
}
|
|
3371
3654
|
log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
|
|
3372
3655
|
cleanOutDir(projectRoot, outDir);
|
|
@@ -3391,14 +3674,17 @@ async function buildBundle(options) {
|
|
|
3391
3674
|
const compiledConfigDir = path.join(outDir, paths.config);
|
|
3392
3675
|
const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
|
|
3393
3676
|
let collections = [];
|
|
3394
|
-
if (paths.
|
|
3677
|
+
if (paths.hasCollections) {
|
|
3395
3678
|
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")}.
|
|
3679
|
+
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
3680
|
if (!fs.existsSync(compiledCollectionsDir)) throw new Error(`Compilation produced no collections directory at ${path.relative(projectRoot, compiledCollectionsDir)}.`);
|
|
3398
3681
|
}
|
|
3399
3682
|
const declared = collectDeclaredDependencies(projectRoot);
|
|
3400
3683
|
const nativeModules = detectNativeDependencies(projectRoot, declared);
|
|
3401
3684
|
const declaresStorageAuthorize = detectStorageAuthorize(path.join(outDir, paths.config));
|
|
3685
|
+
const storageSources = normalizeStorageSources(options.storage, void 0);
|
|
3686
|
+
const collision = findStorageSuffixCollision(storageSources.map((s) => s.key));
|
|
3687
|
+
if (collision) throw new Error(`Storage sources "${collision.a}" and "${collision.b}" in rebase.json both map to the environment variable suffix "${collision.suffix || "(none)"}", so they would read each other's configuration. Rename one of them.`);
|
|
3402
3688
|
const schemaOut = paths.schema.replace(/\.ts$/, ".js");
|
|
3403
3689
|
const relative = (target) => fs.existsSync(path.join(outDir, target)) ? target : void 0;
|
|
3404
3690
|
const manifest = {
|
|
@@ -3408,23 +3694,26 @@ async function buildBundle(options) {
|
|
|
3408
3694
|
builtAgainst: resolveServerVersion(projectRoot),
|
|
3409
3695
|
contract: RUNTIME_CONTRACT_VERSION
|
|
3410
3696
|
},
|
|
3411
|
-
schemaVersion: paths.
|
|
3697
|
+
schemaVersion: paths.hasCollections ? computeSchemaVersion(collections) : "",
|
|
3412
3698
|
app: appName,
|
|
3413
|
-
|
|
3699
|
+
kind: "backend",
|
|
3414
3700
|
entry: {
|
|
3415
|
-
config: paths.
|
|
3416
|
-
collections: paths.
|
|
3701
|
+
config: paths.hasConfig ? relative(paths.config) : void 0,
|
|
3702
|
+
collections: paths.hasCollections ? relative(path.join(paths.config, "collections")) : void 0,
|
|
3417
3703
|
functions: relative(paths.functions),
|
|
3418
3704
|
crons: relative(paths.crons),
|
|
3419
3705
|
schema: relative(schemaOut),
|
|
3420
|
-
usersCollection: paths.
|
|
3706
|
+
usersCollection: paths.hasCollections ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
|
|
3421
3707
|
},
|
|
3422
3708
|
collections: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
|
|
3423
3709
|
hooks: {
|
|
3424
3710
|
native: nativeModules.length > 0,
|
|
3425
3711
|
nativeModules: nativeModules.length > 0 ? nativeModules : void 0
|
|
3426
3712
|
},
|
|
3427
|
-
storage: {
|
|
3713
|
+
storage: {
|
|
3714
|
+
authorize: declaresStorageAuthorize,
|
|
3715
|
+
...storageSources.length > 0 ? { sources: storageSources } : {}
|
|
3716
|
+
},
|
|
3428
3717
|
deps: { declared },
|
|
3429
3718
|
build: {
|
|
3430
3719
|
cli: resolveCliVersion(),
|
|
@@ -3487,11 +3776,12 @@ async function buildBundle(options) {
|
|
|
3487
3776
|
* in one image already, that is exactly what it had.
|
|
3488
3777
|
*/
|
|
3489
3778
|
function foldStaticIntoBundle(options) {
|
|
3490
|
-
const { bundleDir, assetsDir } = options;
|
|
3779
|
+
const { bundleDir, assetsDir, appName, path: basePath, spa } = options;
|
|
3491
3780
|
const manifestPath = path.join(bundleDir, "manifest.json");
|
|
3492
3781
|
if (!fs.existsSync(manifestPath)) throw new Error(`No manifest at ${manifestPath} — build the backend bundle first.`);
|
|
3493
3782
|
if (!fs.existsSync(assetsDir)) throw new Error(`No built assets at ${assetsDir}.`);
|
|
3494
|
-
const
|
|
3783
|
+
const dir = path.posix.join("static", appName);
|
|
3784
|
+
const staticOut = path.join(bundleDir, "static", appName);
|
|
3495
3785
|
fs.rmSync(staticOut, {
|
|
3496
3786
|
recursive: true,
|
|
3497
3787
|
force: true
|
|
@@ -3499,21 +3789,30 @@ function foldStaticIntoBundle(options) {
|
|
|
3499
3789
|
fs.mkdirSync(staticOut, { recursive: true });
|
|
3500
3790
|
fs.cpSync(assetsDir, staticOut, { recursive: true });
|
|
3501
3791
|
let fileCount = 0;
|
|
3502
|
-
const count = (
|
|
3503
|
-
for (const entry of fs.readdirSync(
|
|
3792
|
+
const count = (target) => {
|
|
3793
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(target, entry.name));
|
|
3504
3794
|
else fileCount++;
|
|
3505
3795
|
};
|
|
3506
3796
|
count(staticOut);
|
|
3507
3797
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
3798
|
+
const existing = (manifest.entry?.static ?? []).filter((entry) => entry.dir !== dir);
|
|
3508
3799
|
manifest.entry = {
|
|
3509
3800
|
...manifest.entry,
|
|
3510
|
-
static:
|
|
3801
|
+
static: [...existing, {
|
|
3802
|
+
path: basePath,
|
|
3803
|
+
dir,
|
|
3804
|
+
spa
|
|
3805
|
+
}]
|
|
3511
3806
|
};
|
|
3512
3807
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
3513
|
-
return {
|
|
3808
|
+
return {
|
|
3809
|
+
fileCount,
|
|
3810
|
+
dir
|
|
3811
|
+
};
|
|
3514
3812
|
}
|
|
3515
3813
|
function buildStaticBundle(options) {
|
|
3516
3814
|
const { projectRoot, appName, assetsDir, outDir, runtimeRange } = options;
|
|
3815
|
+
const basePath = options.path ?? "/";
|
|
3517
3816
|
cleanOutDir(projectRoot, outDir);
|
|
3518
3817
|
const staticOut = path.join(outDir, "static");
|
|
3519
3818
|
fs.mkdirSync(staticOut, { recursive: true });
|
|
@@ -3533,8 +3832,12 @@ function buildStaticBundle(options) {
|
|
|
3533
3832
|
},
|
|
3534
3833
|
schemaVersion: "",
|
|
3535
3834
|
app: appName,
|
|
3536
|
-
|
|
3537
|
-
entry: { static:
|
|
3835
|
+
kind: "static",
|
|
3836
|
+
entry: { static: [{
|
|
3837
|
+
path: basePath,
|
|
3838
|
+
dir: "static",
|
|
3839
|
+
spa: options.spa ?? true
|
|
3840
|
+
}] },
|
|
3538
3841
|
hooks: { native: false },
|
|
3539
3842
|
deps: { declared: {} },
|
|
3540
3843
|
build: {
|
|
@@ -3636,7 +3939,7 @@ function resolveCliVersion() {
|
|
|
3636
3939
|
//#endregion
|
|
3637
3940
|
//#region src/fold-static.ts
|
|
3638
3941
|
/**
|
|
3639
|
-
* Folding a project's
|
|
3942
|
+
* Folding a project's static apps into its backend bundle.
|
|
3640
3943
|
*
|
|
3641
3944
|
* Shared by `rebase build` and `rebase cloud deploy` deliberately. It lived in
|
|
3642
3945
|
* the build *command* first, and `deploy` rebuilds the bundle itself — so a
|
|
@@ -3645,34 +3948,77 @@ function resolveCliVersion() {
|
|
|
3645
3948
|
* folding had never been written. Two callers building the same artefact must
|
|
3646
3949
|
* share the step that completes it.
|
|
3647
3950
|
*
|
|
3648
|
-
* Why fold at all: `bootFromBundle`
|
|
3951
|
+
* Why fold at all: `bootFromBundle` serves static apps from `entry.static`
|
|
3649
3952
|
* behind `REBASE_SERVE_STATIC` (default on). A managed tenant runs one pod, so
|
|
3650
|
-
* putting the built
|
|
3651
|
-
* already had — site at `/`, API at `/api` — which is the
|
|
3652
|
-
* for calling the managed runtime a drop-in replacement.
|
|
3953
|
+
* putting the built assets in the bundle gives it the shape a custom container
|
|
3954
|
+
* already had — site at `/`, admin at `/admin`, API at `/api` — which is the
|
|
3955
|
+
* only honest baseline for calling the managed runtime a drop-in replacement.
|
|
3956
|
+
*
|
|
3957
|
+
* **Every** static app is folded, each at its declared path. Folding used to
|
|
3958
|
+
* pick exactly one and refuse when it found two, which meant a project with a
|
|
3959
|
+
* site and an admin panel deployed with neither.
|
|
3653
3960
|
*/
|
|
3654
3961
|
/**
|
|
3655
|
-
*
|
|
3962
|
+
* Every static app in the manifest, in mount order.
|
|
3656
3963
|
*
|
|
3657
|
-
*
|
|
3658
|
-
*
|
|
3659
|
-
*
|
|
3660
|
-
* filesystem.
|
|
3964
|
+
* Longest path first, so the `/`-rooted app is registered last — its catch-all
|
|
3965
|
+
* would otherwise claim its siblings' URLs. Pure, so the ordering is testable
|
|
3966
|
+
* without a filesystem.
|
|
3661
3967
|
*/
|
|
3662
|
-
function
|
|
3663
|
-
const
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3968
|
+
function foldableApps(manifest) {
|
|
3969
|
+
const apps = [];
|
|
3970
|
+
const skipped = [];
|
|
3971
|
+
for (const [name, app] of Object.entries(manifest.apps ?? {})) {
|
|
3972
|
+
if (app?.type !== "static") continue;
|
|
3973
|
+
if (!app.output) {
|
|
3974
|
+
skipped.push({
|
|
3975
|
+
name,
|
|
3976
|
+
reason: `"${name}" declares no output directory — not folded in.`
|
|
3977
|
+
});
|
|
3978
|
+
continue;
|
|
3979
|
+
}
|
|
3980
|
+
apps.push({
|
|
3981
|
+
name,
|
|
3982
|
+
build: app.build,
|
|
3983
|
+
output: app.output,
|
|
3984
|
+
path: app.path ?? "/",
|
|
3985
|
+
spa: app.spa ?? true
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
apps.sort((a, b) => b.path.length - a.path.length);
|
|
3989
|
+
return {
|
|
3990
|
+
apps,
|
|
3991
|
+
skipped
|
|
3992
|
+
};
|
|
3673
3993
|
}
|
|
3674
3994
|
/**
|
|
3675
|
-
*
|
|
3995
|
+
* Assert a built app's assets are actually rooted at the path it is served from.
|
|
3996
|
+
*
|
|
3997
|
+
* An app mounted at `/admin` but built with Vite's default `base: "/"` emits
|
|
3998
|
+
* `<script src="/assets/index-a1b2.js">`. The server serves `index.html` fine
|
|
3999
|
+
* and 404s every asset: a blank page, no server error, nothing in the logs. It
|
|
4000
|
+
* is the single most expensive silent failure in this design, so it is a build
|
|
4001
|
+
* error rather than a runtime surprise.
|
|
4002
|
+
*
|
|
4003
|
+
* Only `<script src>` and `<link href>` are inspected — those are what a bundler
|
|
4004
|
+
* rewrites through `base`. Author-written anchors and canonical URLs are not
|
|
4005
|
+
* evidence of a misbuild.
|
|
4006
|
+
*/
|
|
4007
|
+
function assertBuiltForPath(indexHtml, basePath, appName) {
|
|
4008
|
+
if (basePath === "/") return;
|
|
4009
|
+
const offenders = [];
|
|
4010
|
+
for (const match of indexHtml.matchAll(/<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
|
|
4011
|
+
const ref = match[1];
|
|
4012
|
+
if (!ref.startsWith("/")) continue;
|
|
4013
|
+
if (ref === `${basePath}` || ref.startsWith(`${basePath}/`)) continue;
|
|
4014
|
+
offenders.push(ref);
|
|
4015
|
+
}
|
|
4016
|
+
if (offenders.length === 0) return;
|
|
4017
|
+
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
|
|
4018
|
+
build config — see docs/apps-and-runtimes.md §4.2.`);
|
|
4019
|
+
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Build the project's static apps and fold them into the backend bundle.
|
|
3676
4022
|
*
|
|
3677
4023
|
* Throws rather than exiting, so the caller decides whether a missing frontend
|
|
3678
4024
|
* should fail its command — a `build` may reasonably want to stop, and so should
|
|
@@ -3681,27 +4027,39 @@ function selectFoldableApp(manifest) {
|
|
|
3681
4027
|
async function foldFrontendIntoBundle(options) {
|
|
3682
4028
|
const { projectRoot, manifest, bundleDir, skipBuild } = options;
|
|
3683
4029
|
const log = options.log ?? ((m) => console.log(m));
|
|
3684
|
-
const {
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
4030
|
+
const { apps, skipped } = foldableApps(manifest);
|
|
4031
|
+
for (const { reason } of skipped) log(chalk.yellow(` ⚠ ${reason}`));
|
|
4032
|
+
if (apps.length === 0) return [];
|
|
4033
|
+
const outcomes = [];
|
|
4034
|
+
for (const app of apps) {
|
|
4035
|
+
if (app.build && !skipBuild) await execa(app.build, {
|
|
4036
|
+
cwd: projectRoot,
|
|
4037
|
+
stdio: "inherit",
|
|
4038
|
+
shell: true,
|
|
4039
|
+
env: {
|
|
4040
|
+
REBASE_APP_PATH: app.path,
|
|
4041
|
+
REBASE_APP_BASE: app.path === "/" ? "/" : `${app.path}/`,
|
|
4042
|
+
REBASE_APP_NAME: app.name
|
|
4043
|
+
}
|
|
4044
|
+
});
|
|
4045
|
+
const assetsDir = path.join(projectRoot, app.output);
|
|
4046
|
+
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.`);
|
|
4047
|
+
const indexHtml = path.join(assetsDir, "index.html");
|
|
4048
|
+
if (fs.existsSync(indexHtml)) assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), app.path, app.name);
|
|
4049
|
+
const { fileCount } = foldStaticIntoBundle({
|
|
4050
|
+
bundleDir,
|
|
4051
|
+
assetsDir,
|
|
4052
|
+
appName: app.name,
|
|
4053
|
+
path: app.path,
|
|
4054
|
+
spa: app.spa
|
|
4055
|
+
});
|
|
4056
|
+
outcomes.push({
|
|
4057
|
+
appName: app.name,
|
|
4058
|
+
fileCount,
|
|
4059
|
+
path: app.path
|
|
4060
|
+
});
|
|
3688
4061
|
}
|
|
3689
|
-
|
|
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
|
-
};
|
|
4062
|
+
return outcomes;
|
|
3705
4063
|
}
|
|
3706
4064
|
//#endregion
|
|
3707
4065
|
//#region src/commands/build.ts
|
|
@@ -3719,7 +4077,7 @@ async function foldFrontendIntoBundle(options) {
|
|
|
3719
4077
|
* entrypoint, falls back to the previous behaviour: run every workspace's own
|
|
3720
4078
|
* `build` script. Nothing that built before stops building.
|
|
3721
4079
|
*/
|
|
3722
|
-
function printHelp$
|
|
4080
|
+
function printHelp$4() {
|
|
3723
4081
|
console.log(`
|
|
3724
4082
|
${chalk.bold("rebase build")} — build the apps declared in rebase.json
|
|
3725
4083
|
|
|
@@ -3754,7 +4112,7 @@ async function buildCommand(rawArgs = []) {
|
|
|
3754
4112
|
permissive: true
|
|
3755
4113
|
});
|
|
3756
4114
|
if (args["--help"]) {
|
|
3757
|
-
printHelp$
|
|
4115
|
+
printHelp$4();
|
|
3758
4116
|
return;
|
|
3759
4117
|
}
|
|
3760
4118
|
const projectRoot = requireProjectRoot();
|
|
@@ -3798,13 +4156,20 @@ async function buildCommand(rawArgs = []) {
|
|
|
3798
4156
|
console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
|
|
3799
4157
|
for (const { name, app } of targets) {
|
|
3800
4158
|
console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
|
|
4159
|
+
if (app.type === "backend" && app.runtime === "custom") {
|
|
4160
|
+
console.log(chalk.dim(" custom runtime — this project builds its own image, not a bundle"));
|
|
4161
|
+
console.log(chalk.dim(` ${chalk.cyan(`npm run build --workspace ${name}`)} then ${chalk.cyan(`docker build -f ${app.dockerfile ?? "Dockerfile"} .`)}`));
|
|
4162
|
+
console.log("");
|
|
4163
|
+
continue;
|
|
4164
|
+
}
|
|
3801
4165
|
if (app.type === "backend") {
|
|
3802
4166
|
const result = await buildBundle({
|
|
3803
4167
|
projectRoot,
|
|
3804
4168
|
appName: name,
|
|
3805
4169
|
app,
|
|
3806
4170
|
outDir: args["--out"],
|
|
3807
|
-
runtimeRange: manifest.
|
|
4171
|
+
runtimeRange: manifest.rebase,
|
|
4172
|
+
storage: manifest.storage,
|
|
3808
4173
|
skipTypeCheck: args["--skip-type-check"],
|
|
3809
4174
|
skipSchema: args["--skip-schema"]
|
|
3810
4175
|
});
|
|
@@ -3827,28 +4192,24 @@ async function buildCommand(rawArgs = []) {
|
|
|
3827
4192
|
console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
3828
4193
|
process.exit(1);
|
|
3829
4194
|
});
|
|
3830
|
-
|
|
4195
|
+
for (const outcome of folded ?? []) console.log(chalk.green(` ✓ ${outcome.appName} folded in`) + chalk.dim(` (${outcome.fileCount} file(s) → served at ${outcome.path})`));
|
|
3831
4196
|
}
|
|
3832
|
-
} else if (app.type === "static"
|
|
3833
|
-
else if (app.type === "custom") console.log(chalk.dim(" custom container — built at deploy time from its Dockerfile"));
|
|
4197
|
+
} else if (app.type === "static") await buildAssetApp(projectRoot, name, app, manifest.rebase, args["--out"]);
|
|
3834
4198
|
console.log("");
|
|
3835
4199
|
}
|
|
3836
4200
|
console.log(chalk.green("✓ Build complete."));
|
|
3837
4201
|
}
|
|
3838
4202
|
/**
|
|
3839
|
-
* Build a static
|
|
4203
|
+
* Build a static app and package it into a static bundle.
|
|
3840
4204
|
*
|
|
3841
4205
|
* Runs the app's own build command, checks it produced the declared output, then
|
|
3842
|
-
* packages that output into a `static`-
|
|
4206
|
+
* packages that output into a `static`-kind bundle — the same deployable shape as
|
|
3843
4207
|
* a backend bundle, so a frontend or admin app deploys through the identical
|
|
3844
4208
|
* path and runs on the identical image, just serving files instead of an API.
|
|
3845
4209
|
*/
|
|
3846
4210
|
async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
|
|
3847
4211
|
const asset = app;
|
|
3848
|
-
|
|
3849
|
-
console.log(chalk.dim(" hosted admin panel — nothing to build"));
|
|
3850
|
-
return;
|
|
3851
|
-
}
|
|
4212
|
+
const basePath = asset.path ?? "/";
|
|
3852
4213
|
if (!asset.build) {
|
|
3853
4214
|
console.log(chalk.dim(" no build command declared — skipping"));
|
|
3854
4215
|
return;
|
|
@@ -3857,7 +4218,12 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
|
|
|
3857
4218
|
await execa(asset.build, {
|
|
3858
4219
|
cwd: projectRoot,
|
|
3859
4220
|
stdio: "inherit",
|
|
3860
|
-
shell: true
|
|
4221
|
+
shell: true,
|
|
4222
|
+
env: {
|
|
4223
|
+
REBASE_APP_PATH: basePath,
|
|
4224
|
+
REBASE_APP_BASE: basePath === "/" ? "/" : `${basePath}/`,
|
|
4225
|
+
REBASE_APP_NAME: name
|
|
4226
|
+
}
|
|
3861
4227
|
});
|
|
3862
4228
|
} catch {
|
|
3863
4229
|
console.error(chalk.red(` ✗ build command failed for "${name}"`));
|
|
@@ -3872,15 +4238,24 @@ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride)
|
|
|
3872
4238
|
console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
|
|
3873
4239
|
process.exit(1);
|
|
3874
4240
|
}
|
|
4241
|
+
const indexHtml = path.join(outputPath, "index.html");
|
|
4242
|
+
if (fs.existsSync(indexHtml)) try {
|
|
4243
|
+
assertBuiltForPath(fs.readFileSync(indexHtml, "utf8"), basePath, name);
|
|
4244
|
+
} catch (err) {
|
|
4245
|
+
console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
4246
|
+
process.exit(1);
|
|
4247
|
+
}
|
|
3875
4248
|
const result = buildStaticBundle({
|
|
3876
4249
|
projectRoot,
|
|
3877
4250
|
appName: name,
|
|
3878
4251
|
assetsDir: outputPath,
|
|
3879
4252
|
outDir: outOverride ? path.resolve(process.cwd(), outOverride) : path.join(projectRoot, `dist-bundle-${name}`),
|
|
3880
|
-
runtimeRange
|
|
4253
|
+
runtimeRange,
|
|
4254
|
+
path: basePath,
|
|
4255
|
+
spa: asset.spa ?? true
|
|
3881
4256
|
});
|
|
3882
4257
|
const rel = path.relative(projectRoot, result.outDir);
|
|
3883
|
-
console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s))`));
|
|
4258
|
+
console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s) → served at ${basePath})`));
|
|
3884
4259
|
}
|
|
3885
4260
|
/** The pre-manifest behaviour: build every workspace package. */
|
|
3886
4261
|
async function runWorkspaceBuilds(projectRoot) {
|
|
@@ -3898,6 +4273,234 @@ async function runWorkspaceBuilds(projectRoot) {
|
|
|
3898
4273
|
}
|
|
3899
4274
|
}
|
|
3900
4275
|
//#endregion
|
|
4276
|
+
//#region src/commands/eject.ts
|
|
4277
|
+
/**
|
|
4278
|
+
* CLI command: rebase eject
|
|
4279
|
+
*
|
|
4280
|
+
* The supported route from the managed runtime to a custom one.
|
|
4281
|
+
*
|
|
4282
|
+
* Without it, `runtime: "custom"` is a mode a user can only reach by
|
|
4283
|
+
* hand-writing an entrypoint they have never seen. The template used to solve
|
|
4284
|
+
* that by scaffolding `backend/src/index.ts` into every project — ~190 lines
|
|
4285
|
+
* configuring CORS, auth, cookies, storage and history, which the managed
|
|
4286
|
+
* runtime never loads. It was the most important-looking file in a new project
|
|
4287
|
+
* and editing it did nothing.
|
|
4288
|
+
*
|
|
4289
|
+
* So the file moved here. A managed project does not carry it; a project that
|
|
4290
|
+
* asks for it gets it together with the Dockerfile and the manifest change that
|
|
4291
|
+
* make it actually run.
|
|
4292
|
+
*
|
|
4293
|
+
* There is deliberately no `rebase uneject`. Going back is deleting two files
|
|
4294
|
+
* and editing one line, and a command that silently discarded a user's server
|
|
4295
|
+
* code would be worse than its absence.
|
|
4296
|
+
*/
|
|
4297
|
+
var __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|
4298
|
+
/** Walk up to the package root, which holds `templates/`. */
|
|
4299
|
+
function findCliRoot(from) {
|
|
4300
|
+
const root = path.parse(from).root;
|
|
4301
|
+
let dir = from;
|
|
4302
|
+
while (dir && dir !== root) {
|
|
4303
|
+
if (fs.existsSync(path.join(dir, "templates", "eject"))) return dir;
|
|
4304
|
+
dir = path.dirname(dir);
|
|
4305
|
+
}
|
|
4306
|
+
return null;
|
|
4307
|
+
}
|
|
4308
|
+
/** Files the eject payload contributes, as `<source> → <destination>`. */
|
|
4309
|
+
var PAYLOAD = [
|
|
4310
|
+
{
|
|
4311
|
+
from: "backend/src/index.ts",
|
|
4312
|
+
to: "backend/src/index.ts",
|
|
4313
|
+
overwrite: true
|
|
4314
|
+
},
|
|
4315
|
+
{
|
|
4316
|
+
from: "backend/src/env.ts",
|
|
4317
|
+
to: "backend/src/env.ts",
|
|
4318
|
+
overwrite: true
|
|
4319
|
+
},
|
|
4320
|
+
{
|
|
4321
|
+
from: "Dockerfile",
|
|
4322
|
+
to: "Dockerfile",
|
|
4323
|
+
overwrite: false
|
|
4324
|
+
},
|
|
4325
|
+
{
|
|
4326
|
+
from: "docker-compose.custom.yml",
|
|
4327
|
+
to: "docker-compose.custom.yml",
|
|
4328
|
+
overwrite: false
|
|
4329
|
+
}
|
|
4330
|
+
];
|
|
4331
|
+
/**
|
|
4332
|
+
* The project's name, for the `{{PROJECT_NAME}}` the payload carries.
|
|
4333
|
+
*
|
|
4334
|
+
* Falls back to the directory name — a compose project name is cosmetic, and a
|
|
4335
|
+
* missing or unreadable package.json is not a reason to refuse to eject.
|
|
4336
|
+
*/
|
|
4337
|
+
function projectNameOf(projectRoot) {
|
|
4338
|
+
try {
|
|
4339
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
|
4340
|
+
if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim();
|
|
4341
|
+
} catch {}
|
|
4342
|
+
return path.basename(projectRoot);
|
|
4343
|
+
}
|
|
4344
|
+
function printHelp$3() {
|
|
4345
|
+
console.log(`
|
|
4346
|
+
${chalk.bold("rebase eject")} — take ownership of the server process
|
|
4347
|
+
|
|
4348
|
+
Writes the backend entrypoint and a Dockerfile into this project, and flips its
|
|
4349
|
+
backend to ${chalk.cyan("runtime: \"custom\"")}. From then on this repository builds its own
|
|
4350
|
+
image: platform runtime upgrades no longer reach it, and CORS, auth wiring,
|
|
4351
|
+
storage and shutdown become yours to configure.
|
|
4352
|
+
|
|
4353
|
+
${chalk.bold("Usage")}
|
|
4354
|
+
rebase eject [app]
|
|
4355
|
+
|
|
4356
|
+
${chalk.bold("Options")}
|
|
4357
|
+
--dry-run List what would change, and change nothing
|
|
4358
|
+
-h, --help Show this help
|
|
4359
|
+
`.trim());
|
|
4360
|
+
}
|
|
4361
|
+
async function ejectCommand(rawArgs = []) {
|
|
4362
|
+
const args = arg({
|
|
4363
|
+
"--dry-run": Boolean,
|
|
4364
|
+
"--help": Boolean,
|
|
4365
|
+
"-h": "--help"
|
|
4366
|
+
}, {
|
|
4367
|
+
argv: rawArgs.slice(2),
|
|
4368
|
+
permissive: true
|
|
4369
|
+
});
|
|
4370
|
+
if (args["--help"]) {
|
|
4371
|
+
printHelp$3();
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
const projectRoot = requireProjectRoot();
|
|
4375
|
+
const dryRun = Boolean(args["--dry-run"]);
|
|
4376
|
+
const requested = args._.slice(1).find((value) => !value.startsWith("-"));
|
|
4377
|
+
let loaded;
|
|
4378
|
+
try {
|
|
4379
|
+
loaded = loadManifest(projectRoot);
|
|
4380
|
+
} catch (err) {
|
|
4381
|
+
if (err instanceof ManifestError) {
|
|
4382
|
+
console.error(chalk.red(`✗ ${err.message}`));
|
|
4383
|
+
for (const issue of err.issues) console.error(chalk.dim(` ${issue.path}: ${issue.message}`));
|
|
4384
|
+
process.exit(1);
|
|
4385
|
+
}
|
|
4386
|
+
throw err;
|
|
4387
|
+
}
|
|
4388
|
+
const { manifest } = loaded;
|
|
4389
|
+
let appName;
|
|
4390
|
+
let app;
|
|
4391
|
+
if (requested) {
|
|
4392
|
+
const declared = manifest.apps[requested];
|
|
4393
|
+
if (!declared) {
|
|
4394
|
+
console.error(chalk.red(`✗ No app named "${requested}" in rebase.json.`));
|
|
4395
|
+
console.error(chalk.dim(` Declared: ${Object.keys(manifest.apps).join(", ") || "(none)"}`));
|
|
4396
|
+
process.exit(1);
|
|
4397
|
+
}
|
|
4398
|
+
if (declared.type !== "backend") {
|
|
4399
|
+
console.error(chalk.red(`✗ "${requested}" is a ${declared.type} app — only a backend can be ejected.`));
|
|
4400
|
+
process.exit(1);
|
|
4401
|
+
}
|
|
4402
|
+
appName = requested;
|
|
4403
|
+
app = declared;
|
|
4404
|
+
} else {
|
|
4405
|
+
const backend = findBackendApp(manifest);
|
|
4406
|
+
if (!backend) {
|
|
4407
|
+
console.error(chalk.red("✗ This repository declares no backend app."));
|
|
4408
|
+
console.error(chalk.dim(" Only the repository that declares the backend chooses its runtime."));
|
|
4409
|
+
process.exit(1);
|
|
4410
|
+
}
|
|
4411
|
+
appName = backend.name;
|
|
4412
|
+
app = backend.app;
|
|
4413
|
+
}
|
|
4414
|
+
if (app.runtime === "custom") {
|
|
4415
|
+
console.error(chalk.red(`✗ "${appName}" is already ejected — it declares runtime: "custom".`));
|
|
4416
|
+
console.error(chalk.dim(` Its entrypoint is ${app.dockerfile ?? "Dockerfile"} and backend/src/index.ts.`));
|
|
4417
|
+
process.exit(1);
|
|
4418
|
+
}
|
|
4419
|
+
const cliRoot = findCliRoot(__dirname$1);
|
|
4420
|
+
if (!cliRoot) {
|
|
4421
|
+
console.error(chalk.red("✗ Could not locate the eject templates. Reinstall @rebasepro/cli."));
|
|
4422
|
+
process.exit(1);
|
|
4423
|
+
}
|
|
4424
|
+
const payloadDir = path.join(cliRoot, "templates", "eject");
|
|
4425
|
+
const planned = [];
|
|
4426
|
+
for (const file of PAYLOAD) {
|
|
4427
|
+
const source = path.join(payloadDir, file.from);
|
|
4428
|
+
if (!fs.existsSync(source)) {
|
|
4429
|
+
console.error(chalk.red(`✗ The eject template is missing ${file.from}. Reinstall @rebasepro/cli.`));
|
|
4430
|
+
process.exit(1);
|
|
4431
|
+
}
|
|
4432
|
+
const exists = fs.existsSync(path.join(projectRoot, file.to));
|
|
4433
|
+
planned.push({
|
|
4434
|
+
to: file.to,
|
|
4435
|
+
action: exists && !file.overwrite ? "keep" : "write"
|
|
4436
|
+
});
|
|
4437
|
+
}
|
|
4438
|
+
if (dryRun) {
|
|
4439
|
+
console.log(chalk.bold(`Would eject "${appName}" to a custom runtime:`));
|
|
4440
|
+
console.log("");
|
|
4441
|
+
for (const item of planned) console.log(item.action === "write" ? ` ${chalk.green("write")} ${item.to}` : ` ${chalk.dim("keep")} ${item.to} ${chalk.dim("(already exists)")}`);
|
|
4442
|
+
console.log(` ${chalk.green("write")} rebase.json ${chalk.dim("(runtime: \"custom\")")}`);
|
|
4443
|
+
console.log("");
|
|
4444
|
+
console.log(chalk.dim("Nothing was changed."));
|
|
4445
|
+
return;
|
|
4446
|
+
}
|
|
4447
|
+
const projectName = projectNameOf(projectRoot);
|
|
4448
|
+
for (const [index, file] of PAYLOAD.entries()) {
|
|
4449
|
+
if (planned[index].action === "keep") continue;
|
|
4450
|
+
const destination = path.join(projectRoot, file.to);
|
|
4451
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
4452
|
+
const contents = fs.readFileSync(path.join(payloadDir, file.from), "utf8").replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
4453
|
+
fs.writeFileSync(destination, contents, "utf8");
|
|
4454
|
+
}
|
|
4455
|
+
const dockerfile = app.dockerfile ?? "Dockerfile";
|
|
4456
|
+
manifest.apps[appName] = {
|
|
4457
|
+
...app,
|
|
4458
|
+
runtime: "custom",
|
|
4459
|
+
dockerfile,
|
|
4460
|
+
port: app.port ?? 8080
|
|
4461
|
+
};
|
|
4462
|
+
writeManifest(projectRoot, manifest);
|
|
4463
|
+
restoreBackendScripts(projectRoot);
|
|
4464
|
+
console.log("");
|
|
4465
|
+
console.log(chalk.green(`✓ Ejected "${appName}" to a custom runtime.`));
|
|
4466
|
+
console.log("");
|
|
4467
|
+
console.log(` ${chalk.cyan("backend/src/index.ts".padEnd(26))} your entrypoint — the runtime no longer boots the bundle`);
|
|
4468
|
+
console.log(` ${chalk.cyan("backend/src/env.ts".padEnd(26))} the environment it reads`);
|
|
4469
|
+
console.log(` ${chalk.cyan(dockerfile.padEnd(26))} your image`);
|
|
4470
|
+
console.log(` ${chalk.cyan("docker-compose.custom.yml".padEnd(26))} runs it`);
|
|
4471
|
+
console.log(` ${chalk.cyan("rebase.json".padEnd(26))} runtime: custom`);
|
|
4472
|
+
console.log("");
|
|
4473
|
+
console.log(chalk.yellow(" You now own CORS, auth wiring, storage and shutdown. Platform runtime"));
|
|
4474
|
+
console.log(chalk.yellow(" upgrades no longer reach this project."));
|
|
4475
|
+
console.log("");
|
|
4476
|
+
console.log(chalk.dim(` ${chalk.cyan("docker compose -f docker-compose.custom.yml up --build")}`));
|
|
4477
|
+
console.log(chalk.dim(" docker-compose.yml is untouched — it still runs the managed shape if you go back."));
|
|
4478
|
+
console.log("");
|
|
4479
|
+
}
|
|
4480
|
+
/**
|
|
4481
|
+
* Point the backend workspace's scripts at the entrypoint that now exists.
|
|
4482
|
+
*
|
|
4483
|
+
* A managed project's backend package deliberately declares no `main` and no
|
|
4484
|
+
* `start`: there is no entrypoint to name. Ejecting creates one.
|
|
4485
|
+
*/
|
|
4486
|
+
function restoreBackendScripts(projectRoot) {
|
|
4487
|
+
const packagePath = path.join(projectRoot, "backend", "package.json");
|
|
4488
|
+
if (!fs.existsSync(packagePath)) return;
|
|
4489
|
+
let parsed;
|
|
4490
|
+
try {
|
|
4491
|
+
parsed = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
4492
|
+
} catch {
|
|
4493
|
+
console.log(chalk.yellow(" ⚠ backend/package.json is not valid JSON — its scripts were left alone."));
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
const scripts = parsed.scripts ?? {};
|
|
4497
|
+
parsed.main ??= "src/index.ts";
|
|
4498
|
+
scripts.dev ??= "tsx watch --include=\"../config/**/*\" --include=\"./functions/**/*\" src/index.ts";
|
|
4499
|
+
scripts.start ??= "node dist/backend/src/index.js";
|
|
4500
|
+
parsed.scripts = scripts;
|
|
4501
|
+
fs.writeFileSync(packagePath, `${JSON.stringify(parsed, null, 4)}\n`, "utf8");
|
|
4502
|
+
}
|
|
4503
|
+
//#endregion
|
|
3901
4504
|
//#region src/commands/start.ts
|
|
3902
4505
|
/**
|
|
3903
4506
|
* CLI command: rebase start
|
|
@@ -5345,7 +5948,7 @@ function declaredAppsFrom(manifest) {
|
|
|
5345
5948
|
if (!apps || typeof apps !== "object") return [];
|
|
5346
5949
|
return Object.entries(apps).filter(([name]) => name.trim().length > 0).map(([name, value]) => ({
|
|
5347
5950
|
name,
|
|
5348
|
-
type:
|
|
5951
|
+
type: value?.type === "backend" ? "backend" : "static"
|
|
5349
5952
|
}));
|
|
5350
5953
|
}
|
|
5351
5954
|
/** Upload a bundle archive; returns the control-plane bundle id. */
|
|
@@ -5502,7 +6105,8 @@ async function deployBundle(opts) {
|
|
|
5502
6105
|
projectRoot,
|
|
5503
6106
|
appName: backend.name,
|
|
5504
6107
|
app: backend.app,
|
|
5505
|
-
runtimeRange: loaded.manifest.
|
|
6108
|
+
runtimeRange: loaded.manifest.rebase,
|
|
6109
|
+
storage: loaded.manifest.storage,
|
|
5506
6110
|
skipTypeCheck: opts.skipTypeCheck,
|
|
5507
6111
|
log: (m) => console.log(chalk.gray(m))
|
|
5508
6112
|
})).outDir;
|
|
@@ -5513,7 +6117,7 @@ async function deployBundle(opts) {
|
|
|
5513
6117
|
bundleDir,
|
|
5514
6118
|
log: (m) => console.log(m)
|
|
5515
6119
|
});
|
|
5516
|
-
|
|
6120
|
+
for (const outcome of folded) console.log(chalk.gray(` folded ${outcome.appName} in (${outcome.fileCount} file(s), served at ${outcome.path})`));
|
|
5517
6121
|
} catch (err) {
|
|
5518
6122
|
fail(err instanceof Error ? err.message : String(err), "Fix the frontend build, or pass --no-static to deploy the API alone.");
|
|
5519
6123
|
}
|
|
@@ -5650,6 +6254,23 @@ async function readDeployContext(client, projectId) {
|
|
|
5650
6254
|
return {};
|
|
5651
6255
|
}
|
|
5652
6256
|
}
|
|
6257
|
+
/**
|
|
6258
|
+
* Whether this repository's backend declares the managed runtime.
|
|
6259
|
+
*
|
|
6260
|
+
* Deliberately quiet: a directory that is not a Rebase project, or whose
|
|
6261
|
+
* manifest does not parse, simply does not route this way — `rebase build` is
|
|
6262
|
+
* where a broken manifest gets reported, and a deploy refusing on one before it
|
|
6263
|
+
* has even said what it is doing would be the wrong place to find out.
|
|
6264
|
+
*/
|
|
6265
|
+
function declaresManagedRuntime() {
|
|
6266
|
+
try {
|
|
6267
|
+
const projectRoot = findProjectRoot();
|
|
6268
|
+
if (!projectRoot) return false;
|
|
6269
|
+
return findBackendApp(loadManifest(projectRoot).manifest)?.app.runtime === "managed";
|
|
6270
|
+
} catch {
|
|
6271
|
+
return false;
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
5653
6274
|
async function deployCommand(rawArgs, projectRef) {
|
|
5654
6275
|
const args = arg({
|
|
5655
6276
|
"--no-follow": Boolean,
|
|
@@ -5666,8 +6287,10 @@ async function deployCommand(rawArgs, projectRef) {
|
|
|
5666
6287
|
});
|
|
5667
6288
|
const { client, url } = await requireClient(rawArgs);
|
|
5668
6289
|
const projectId = await resolveProjectRef(projectRef, client);
|
|
5669
|
-
|
|
5670
|
-
|
|
6290
|
+
const declaredManaged = !args["--source"] && !args["--bundle"] && declaresManagedRuntime();
|
|
6291
|
+
if (args["--bundle"] || declaredManaged) {
|
|
6292
|
+
if (args["--bundle"] && args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
|
|
6293
|
+
if (declaredManaged && !isJsonMode()) console.log(chalk.gray(" rebase.json declares runtime: managed — deploying a bundle."));
|
|
5671
6294
|
await deployBundle({
|
|
5672
6295
|
client,
|
|
5673
6296
|
url,
|
|
@@ -8346,6 +8969,27 @@ function describeDatabaseState(db) {
|
|
|
8346
8969
|
if (connection === "connected" || connection === "failed") return `${type} (${colorStatus(connection)})`;
|
|
8347
8970
|
return `${type} ${chalk.gray("· not tested (`rebase cloud db test`)")}`;
|
|
8348
8971
|
}
|
|
8972
|
+
/**
|
|
8973
|
+
* One line describing what engine is serving this project.
|
|
8974
|
+
*
|
|
8975
|
+
* Three numbers are in play and they are easy to conflate — I have watched it
|
|
8976
|
+
* happen. The **runtime version** (`1.2.0`) is the contract line a bundle's
|
|
8977
|
+
* range resolves against; its major IS the contract major. The **framework
|
|
8978
|
+
* version** (`0.11.0`) is the `@rebasepro` release the runtime image ships. They
|
|
8979
|
+
* move independently on purpose: tying the contract line to the framework would
|
|
8980
|
+
* make `^1` become `^0.11`, and pre-1.0 caret is restrictive, so every framework
|
|
8981
|
+
* minor would fall outside every project's range and force a rebuild to receive
|
|
8982
|
+
* an engine upgrade — the opposite of what the bundle/runtime split is for.
|
|
8983
|
+
*
|
|
8984
|
+
* So both are printed, rather than leaving anyone to infer one from a Docker tag.
|
|
8985
|
+
*/
|
|
8986
|
+
function describeRuntime(project) {
|
|
8987
|
+
if (project.runtimeMode !== "managed") return `custom ${chalk.gray("· your own image")}`;
|
|
8988
|
+
const version = project.runtimeVersion ?? "unknown";
|
|
8989
|
+
const framework = project.runtimeFrameworkVersion;
|
|
8990
|
+
const pin = project.runtimeVersionPin ? chalk.gray(` · pinned to ${project.runtimeVersionPin}`) : "";
|
|
8991
|
+
return `managed ${version}${framework ? chalk.gray(` · framework ${framework}`) : ""}${pin}`;
|
|
8992
|
+
}
|
|
8349
8993
|
async function statusCommand(rawArgs) {
|
|
8350
8994
|
const { client, url } = await requireClient(rawArgs);
|
|
8351
8995
|
const projectId = await requireProject(rawArgs, client);
|
|
@@ -8371,6 +9015,7 @@ async function statusCommand(rawArgs) {
|
|
|
8371
9015
|
["URL", projectHost(project, baseDomain)],
|
|
8372
9016
|
["Branch", project.gitBranch],
|
|
8373
9017
|
["Last deploy", deploy ? `${colorStatus(deploy.status)} · ${fmtDate(deploy.createdAt)}` : "never"],
|
|
9018
|
+
["Runtime", describeRuntime(project)],
|
|
8374
9019
|
["Database", databaseLine],
|
|
8375
9020
|
["Storage", storageLine]
|
|
8376
9021
|
]);
|
|
@@ -8387,6 +9032,14 @@ async function statusCommand(rawArgs) {
|
|
|
8387
9032
|
status: deploy.status ?? null,
|
|
8388
9033
|
createdAt: deploy.createdAt ?? null
|
|
8389
9034
|
} : null,
|
|
9035
|
+
runtime: {
|
|
9036
|
+
mode: project.runtimeMode ?? "custom",
|
|
9037
|
+
version: project.runtimeVersion ?? null,
|
|
9038
|
+
frameworkVersion: project.runtimeFrameworkVersion ?? null,
|
|
9039
|
+
contract: project.runtimeContract ?? null,
|
|
9040
|
+
range: project.runtimeRange ?? null,
|
|
9041
|
+
pin: project.runtimeVersionPin ?? null
|
|
9042
|
+
},
|
|
8390
9043
|
database: db ? {
|
|
8391
9044
|
type: db.type ?? null,
|
|
8392
9045
|
connectionStatus: db.connectionStatus ?? null
|
|
@@ -9009,11 +9662,8 @@ async function appsCommand(subcommand, rawArgs = []) {
|
|
|
9009
9662
|
}
|
|
9010
9663
|
function describeApp(app) {
|
|
9011
9664
|
switch (app.type) {
|
|
9012
|
-
case "backend": return `
|
|
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";
|
|
9665
|
+
case "backend": return app.runtime === "custom" ? `custom runtime — ${app.dockerfile ?? "Dockerfile"}` : `managed runtime, config: ${app.config ?? "config"}`;
|
|
9666
|
+
case "static": return `${app.root} → ${app.output} @ ${app.path ?? "/"}`;
|
|
9017
9667
|
default: return "";
|
|
9018
9668
|
}
|
|
9019
9669
|
}
|
|
@@ -9023,7 +9673,7 @@ async function listApps(asJson) {
|
|
|
9023
9673
|
if (asJson) {
|
|
9024
9674
|
console.log(JSON.stringify({
|
|
9025
9675
|
source: loaded.source,
|
|
9026
|
-
|
|
9676
|
+
rebase: loaded.manifest.rebase,
|
|
9027
9677
|
apps: loaded.manifest.apps,
|
|
9028
9678
|
managed: compatibility
|
|
9029
9679
|
}, null, 2));
|
|
@@ -9033,7 +9683,7 @@ async function listApps(asJson) {
|
|
|
9033
9683
|
console.log(chalk.dim("No rebase.json — showing the layout inferred from this project."));
|
|
9034
9684
|
console.log(chalk.dim(`Run ${chalk.cyan("rebase apps init")} to write it down.\n`));
|
|
9035
9685
|
}
|
|
9036
|
-
console.log(chalk.bold(`
|
|
9686
|
+
console.log(chalk.bold(`Rebase ${loaded.manifest.rebase}`));
|
|
9037
9687
|
console.log("");
|
|
9038
9688
|
const entries = Object.entries(loaded.manifest.apps);
|
|
9039
9689
|
if (entries.length === 0) {
|
|
@@ -9179,6 +9829,7 @@ async function entry(args) {
|
|
|
9179
9829
|
"api-keys",
|
|
9180
9830
|
"cloud",
|
|
9181
9831
|
"apps",
|
|
9832
|
+
"eject",
|
|
9182
9833
|
"generate-sdk"
|
|
9183
9834
|
].includes(command)) {
|
|
9184
9835
|
printHelp();
|
|
@@ -9231,6 +9882,9 @@ async function entry(args) {
|
|
|
9231
9882
|
case "apps":
|
|
9232
9883
|
await appsCommand(effectiveSubcommand, args);
|
|
9233
9884
|
break;
|
|
9885
|
+
case "eject":
|
|
9886
|
+
await ejectCommand(args);
|
|
9887
|
+
break;
|
|
9234
9888
|
case "auth":
|
|
9235
9889
|
await authCommand(effectiveSubcommand, args);
|
|
9236
9890
|
break;
|
|
@@ -9265,6 +9919,8 @@ ${chalk.green.bold("Commands")}
|
|
|
9265
9919
|
${chalk.blue.bold("dev")} Start the development server
|
|
9266
9920
|
${chalk.blue.bold("build")} Build all workspace packages
|
|
9267
9921
|
${chalk.blue.bold("start")} Start the backend server ${chalk.gray("(production)")}
|
|
9922
|
+
${chalk.blue.bold("apps list")} Show the apps this repository declares
|
|
9923
|
+
${chalk.blue.bold("eject")} Take ownership of the server process and image
|
|
9268
9924
|
|
|
9269
9925
|
${chalk.green.bold("Schema")}
|
|
9270
9926
|
${chalk.blue.bold("schema generate")} Generate Drizzle schema from collections
|
|
@@ -9282,25 +9938,25 @@ ${chalk.green.bold("SDK")}
|
|
|
9282
9938
|
|
|
9283
9939
|
${chalk.green.bold("Auth")}
|
|
9284
9940
|
${chalk.blue.bold("auth reset-password")} Reset a user's password
|
|
9285
|
-
${chalk.blue.bold("auth")} ${chalk.gray("--help")}
|
|
9941
|
+
${chalk.blue.bold("auth")} ${chalk.gray("--help")} Show auth command help
|
|
9286
9942
|
|
|
9287
9943
|
${chalk.green.bold("Diagnostics")}
|
|
9288
9944
|
${chalk.blue.bold("doctor")} Detect schema drift between collections, schema, and DB
|
|
9289
9945
|
|
|
9290
9946
|
${chalk.green.bold("AI Agent Skills")}
|
|
9291
|
-
${chalk.blue.bold("skills install")}
|
|
9947
|
+
${chalk.blue.bold("skills install")} Install Rebase agent skills for your AI coding assistant
|
|
9292
9948
|
|
|
9293
9949
|
${chalk.green.bold("API Keys")}
|
|
9294
|
-
${chalk.blue.bold("api-keys list")}
|
|
9295
|
-
${chalk.blue.bold("api-keys create")}
|
|
9296
|
-
${chalk.blue.bold("api-keys revoke")}
|
|
9297
|
-
${chalk.blue.bold("api-keys")} ${chalk.gray("--help")}
|
|
9950
|
+
${chalk.blue.bold("api-keys list")} List all service API keys
|
|
9951
|
+
${chalk.blue.bold("api-keys create")} Create a new scoped API key
|
|
9952
|
+
${chalk.blue.bold("api-keys revoke")} Revoke an existing API key
|
|
9953
|
+
${chalk.blue.bold("api-keys")} ${chalk.gray("--help")} Show API key command help
|
|
9298
9954
|
|
|
9299
9955
|
${chalk.green.bold("Rebase Cloud")}
|
|
9300
9956
|
${chalk.blue.bold("cloud login")} Sign in to the hosted control plane
|
|
9301
9957
|
${chalk.blue.bold("cloud link")} Link this directory to a cloud project
|
|
9302
9958
|
${chalk.blue.bold("cloud deploy")} Deploy the linked project + stream logs
|
|
9303
|
-
${chalk.blue.bold("cloud")} ${chalk.gray("--help")}
|
|
9959
|
+
${chalk.blue.bold("cloud")} ${chalk.gray("--help")} Show all cloud commands
|
|
9304
9960
|
|
|
9305
9961
|
${chalk.green.bold("Options")}
|
|
9306
9962
|
${chalk.blue("--version, -v")} Show version number
|
|
@@ -9310,6 +9966,6 @@ ${chalk.gray("Documentation: https://rebase.pro/docs")}
|
|
|
9310
9966
|
`);
|
|
9311
9967
|
}
|
|
9312
9968
|
//#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 };
|
|
9969
|
+
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
9970
|
|
|
9315
9971
|
//# sourceMappingURL=index.es.js.map
|