@rebasepro/cli 0.9.1-canary.ff338b5 → 0.10.1-canary.14e53ae
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/README.md +1 -1
- package/dist/bundle.d.ts +111 -0
- package/dist/commands/apps.d.ts +1 -0
- package/dist/commands/build.d.ts +1 -1
- package/dist/commands/cloud/bundle-deploy.d.ts +28 -0
- package/dist/commands/cloud/context.d.ts +28 -0
- package/dist/commands/cloud/debug.d.ts +117 -0
- package/dist/commands/cloud/deployments.d.ts +16 -0
- package/dist/commands/cloud/env.d.ts +2 -0
- package/dist/commands/cloud/resources.d.ts +38 -0
- package/dist/commands/generate_sdk.d.ts +12 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/start.d.ts +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +3317 -201
- package/dist/index.es.js.map +1 -1
- package/dist/manifest.d.ts +83 -0
- package/dist/utils/package-manager.d.ts +26 -2
- package/dist/utils/project.d.ts +11 -3
- package/package.json +10 -8
- package/runtime/dev-server.mjs +43 -0
- package/templates/overlays/baas/backend/src/index.ts +21 -4
- package/templates/overlays/baas/rebase.json +14 -0
- package/templates/template/.env.example +16 -3
- package/templates/template/README.md +39 -29
- package/templates/template/backend/src/index.ts +21 -4
- package/templates/template/docker-compose.yml +7 -0
- package/templates/template/gitignore +4 -0
- package/templates/template/rebase.json +20 -0
package/dist/index.es.js
CHANGED
|
@@ -12,6 +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
16
|
import { generateSDK } from "@rebasepro/codegen";
|
|
16
17
|
import { createRequire } from "module";
|
|
17
18
|
//#region src/utils/package-manager.ts
|
|
@@ -23,21 +24,61 @@ import { createRequire } from "module";
|
|
|
23
24
|
* the rest of the CLI never has to hardcode a specific PM.
|
|
24
25
|
*/
|
|
25
26
|
/**
|
|
27
|
+
* How long to wait for `pnpm --version` before giving up on the probe.
|
|
28
|
+
*
|
|
29
|
+
* `pnpm --version` is a cold Node start, and on a machine that is busy — a
|
|
30
|
+
* parallel install, a full test run — it routinely takes seconds. Measured at
|
|
31
|
+
* 630ms, 990ms and 4293ms on three consecutive runs of one developer laptop
|
|
32
|
+
* under load, so the previous 3s budget was inside the normal spread rather
|
|
33
|
+
* than safely outside it.
|
|
34
|
+
*/
|
|
35
|
+
var PNPM_PROBE_TIMEOUT_MS = 5e3;
|
|
36
|
+
/** Memoised result of the probe. pnpm cannot appear or vanish mid-process. */
|
|
37
|
+
var cachedPnpmAvailable;
|
|
38
|
+
/**
|
|
39
|
+
* Decide availability from a `spawnSync` outcome.
|
|
40
|
+
*
|
|
41
|
+
* Split out from the spawn itself so the decision is testable without starting
|
|
42
|
+
* a process — which is what made the old test load-sensitive and occasionally
|
|
43
|
+
* red for reasons that had nothing to do with the code under test.
|
|
44
|
+
*
|
|
45
|
+
* The three outcomes are distinguishable, and the old code conflated two of
|
|
46
|
+
* them by asking only `status === 0`:
|
|
47
|
+
*
|
|
48
|
+
* not installed status null, signal null, error.code ENOENT
|
|
49
|
+
* timed out status null, signal SIGTERM, error.code ETIMEDOUT
|
|
50
|
+
* broken install status non-zero, no error
|
|
51
|
+
*/
|
|
52
|
+
function pnpmAvailabilityFromProbe(res) {
|
|
53
|
+
const code = res.error?.code;
|
|
54
|
+
if (code === "ENOENT") return false;
|
|
55
|
+
if (code === "ETIMEDOUT" || res.signal) return true;
|
|
56
|
+
if (res.error) return false;
|
|
57
|
+
return res.status === 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
26
60
|
* Whether pnpm is runnable on this machine.
|
|
27
61
|
*
|
|
28
62
|
* Used to decide whether a fresh project can be scaffolded with pnpm. Kept
|
|
29
|
-
* cheap and non-interactive (
|
|
30
|
-
* hangs detection if a corepack shim misbehaves
|
|
63
|
+
* cheap and non-interactive (bounded timeout, output discarded) so it never
|
|
64
|
+
* hangs detection if a corepack shim misbehaves, and memoised so that repeated
|
|
65
|
+
* detection in one CLI run costs one process rather than one per call.
|
|
31
66
|
*/
|
|
32
67
|
function isPnpmAvailable() {
|
|
68
|
+
if (cachedPnpmAvailable !== void 0) return cachedPnpmAvailable;
|
|
33
69
|
try {
|
|
34
|
-
|
|
70
|
+
cachedPnpmAvailable = pnpmAvailabilityFromProbe(spawnSync("pnpm", ["--version"], {
|
|
35
71
|
stdio: "ignore",
|
|
36
|
-
timeout:
|
|
37
|
-
})
|
|
72
|
+
timeout: PNPM_PROBE_TIMEOUT_MS
|
|
73
|
+
}));
|
|
38
74
|
} catch {
|
|
39
|
-
|
|
75
|
+
cachedPnpmAvailable = false;
|
|
40
76
|
}
|
|
77
|
+
return cachedPnpmAvailable;
|
|
78
|
+
}
|
|
79
|
+
/** Forget the memoised probe. For tests; nothing in a CLI run needs it. */
|
|
80
|
+
function resetPnpmAvailabilityCache() {
|
|
81
|
+
cachedPnpmAvailable = void 0;
|
|
41
82
|
}
|
|
42
83
|
/**
|
|
43
84
|
* Detect the package manager for a Rebase project.
|
|
@@ -156,17 +197,26 @@ function getPMCommands(pm) {
|
|
|
156
197
|
* These helpers locate the project root, backend directory, .env file,
|
|
157
198
|
* and local binaries — used by all CLI command modules.
|
|
158
199
|
*/
|
|
200
|
+
/** The authored project manifest. Its presence alone marks a project root. */
|
|
201
|
+
var MANIFEST_FILENAME = "rebase.json";
|
|
159
202
|
/**
|
|
160
203
|
* Walk up from `startDir` to find the Rebase project root.
|
|
161
204
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
205
|
+
* A directory is the root when it holds a `rebase.json`, or when it holds a
|
|
206
|
+
* `package.json` that either lists `backend` as a workspace or sits beside both
|
|
207
|
+
* `backend/` and `config/`.
|
|
208
|
+
*
|
|
209
|
+
* `rebase.json` is checked first and needs no `package.json` beside it, because
|
|
210
|
+
* the conventions below all describe a repository that *contains the backend*.
|
|
211
|
+
* A repository holding only a frontend — the normal shape once a project's apps
|
|
212
|
+
* live in separate repositories — matches none of them, so without this the
|
|
213
|
+
* tooling could not run there at all.
|
|
165
214
|
*/
|
|
166
215
|
function findProjectRoot(startDir = process.cwd()) {
|
|
167
216
|
let dir = path.resolve(startDir);
|
|
168
217
|
const root = path.parse(dir).root;
|
|
169
218
|
while (dir !== root) {
|
|
219
|
+
if (fs.existsSync(path.join(dir, "rebase.json"))) return dir;
|
|
170
220
|
const pkgPath = path.join(dir, "package.json");
|
|
171
221
|
if (fs.existsSync(pkgPath)) {
|
|
172
222
|
try {
|
|
@@ -636,9 +686,17 @@ var ANSI_RE = /\[[0-9;]*m/g;
|
|
|
636
686
|
function stripAnsi(s) {
|
|
637
687
|
return s.replace(ANSI_RE, "");
|
|
638
688
|
}
|
|
639
|
-
/**
|
|
689
|
+
/**
|
|
690
|
+
* Write one JSON value to stdout, followed by a newline.
|
|
691
|
+
*
|
|
692
|
+
* Indented, because the overwhelmingly common reader is a person or an agent
|
|
693
|
+
* looking at a terminal — JSON mode is entered automatically whenever stdout is
|
|
694
|
+
* not a TTY, so `rebase cloud deployments list` piped anywhere at all produced
|
|
695
|
+
* a project's entire deployment history as one unwrapped line. `JSON.parse`
|
|
696
|
+
* does not care about the whitespace; everything else does.
|
|
697
|
+
*/
|
|
640
698
|
function printJson(value) {
|
|
641
|
-
process.stdout.write(JSON.stringify(value) + "\n");
|
|
699
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
642
700
|
}
|
|
643
701
|
/**
|
|
644
702
|
* The one output primitive every new command uses: in JSON mode emit `json`
|
|
@@ -875,7 +933,54 @@ function buildInitQuestions(params) {
|
|
|
875
933
|
});
|
|
876
934
|
return questions;
|
|
877
935
|
}
|
|
936
|
+
/**
|
|
937
|
+
* The `cd` a user must type to enter the new project.
|
|
938
|
+
*
|
|
939
|
+
* Not the project's basename: `init apps/my-app` has to say `cd apps/my-app`,
|
|
940
|
+
* and `init .` returns "" because they are already in the project.
|
|
941
|
+
*/
|
|
942
|
+
function formatCdTarget(cwd, targetDirectory) {
|
|
943
|
+
return path.relative(cwd, targetDirectory);
|
|
944
|
+
}
|
|
945
|
+
/** Help for `rebase init` — the flags were previously only discoverable by
|
|
946
|
+
* triggering the non-TTY error. */
|
|
947
|
+
function printInitHelp() {
|
|
948
|
+
console.log(`
|
|
949
|
+
${chalk.bold("rebase init")} — Create a new Rebase project
|
|
950
|
+
|
|
951
|
+
${chalk.bold("Usage")}
|
|
952
|
+
rebase init ${chalk.blue("[name]")} [options]
|
|
953
|
+
|
|
954
|
+
${chalk.gray("The name may be a nested path (apps/my-app) or \".\" for the current directory.")}
|
|
955
|
+
${chalk.gray("Defaults to \"my-rebase-app\" when omitted with --yes.")}
|
|
956
|
+
|
|
957
|
+
${chalk.bold("Options")}
|
|
958
|
+
${chalk.blue("-t, --template")} ${chalk.gray("<preset>")} blog | ecommerce | blank ${chalk.gray("(default: blog)")}
|
|
959
|
+
${chalk.blue("-f, --flavor")} ${chalk.gray("<flavor>")} cms | baas ${chalk.gray("(default: cms)")}
|
|
960
|
+
${chalk.blue("-y, --yes")} Accept defaults, never prompt ${chalk.gray("(required for CI / non-TTY)")}
|
|
961
|
+
${chalk.blue("-i, --install")} Install dependencies after scaffolding
|
|
962
|
+
${chalk.blue("-g, --git")} Initialize a git repository and make an initial commit
|
|
963
|
+
${chalk.blue("--database-url")} ${chalk.gray("<url>")} Use an existing database instead of the generated one
|
|
964
|
+
${chalk.blue("--introspect")} Generate collections from that database ${chalk.gray("(implies --template blank; needs --install)")}
|
|
965
|
+
${chalk.blue("--project")} ${chalk.gray("<slug>")} Link the scaffold to a Rebase Cloud project
|
|
966
|
+
${chalk.blue("--setup-key")} ${chalk.gray("<key>")} One-time key authenticating the cloud link ${chalk.gray("(use with --project)")}
|
|
967
|
+
|
|
968
|
+
${chalk.bold("Flavors")}
|
|
969
|
+
${chalk.blue("cms")} BaaS + admin UI, driven by collections you define ${chalk.gray("(like Payload/Directus)")}
|
|
970
|
+
${chalk.blue("baas")} Headless API over your database — no collections, no UI ${chalk.gray("(like Supabase)")}
|
|
971
|
+
${chalk.gray("--template has no effect on this flavor.")}
|
|
972
|
+
|
|
973
|
+
${chalk.bold("Examples")}
|
|
974
|
+
${chalk.gray("$")} rebase init my-shop --template ecommerce --install
|
|
975
|
+
${chalk.gray("$")} rebase init my-api --flavor baas --yes
|
|
976
|
+
${chalk.gray("$")} rebase init . --yes --git
|
|
977
|
+
`);
|
|
978
|
+
}
|
|
878
979
|
async function createRebaseApp(rawArgs) {
|
|
980
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
981
|
+
printInitHelp();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
879
984
|
console.log(`
|
|
880
985
|
${chalk.bold("Rebase")} — Create a new project 🚀
|
|
881
986
|
`);
|
|
@@ -935,6 +1040,7 @@ async function promptForOptions(rawArgs, pm) {
|
|
|
935
1040
|
databaseUrl: args["--database-url"] || void 0,
|
|
936
1041
|
introspect: args["--introspect"] || false,
|
|
937
1042
|
preset: templateArg || "blog",
|
|
1043
|
+
explicitPreset: !!templateArg,
|
|
938
1044
|
flavor: flavorArg || "cms",
|
|
939
1045
|
pm,
|
|
940
1046
|
pmCommands,
|
|
@@ -972,6 +1078,7 @@ async function promptForOptions(rawArgs, pm) {
|
|
|
972
1078
|
databaseUrl: answers.databaseUrl?.trim() || void 0,
|
|
973
1079
|
introspect: answers.introspect || false,
|
|
974
1080
|
preset: templateArg || answers.preset || "blog",
|
|
1081
|
+
explicitPreset: !!templateArg,
|
|
975
1082
|
flavor: flavorArg || answers.flavor || "cms",
|
|
976
1083
|
pm,
|
|
977
1084
|
pmCommands,
|
|
@@ -1056,6 +1163,7 @@ async function createProject$1(options) {
|
|
|
1056
1163
|
const shipped = path.join(options.targetDirectory, from);
|
|
1057
1164
|
if (fs.existsSync(shipped)) fs.renameSync(shipped, path.join(options.targetDirectory, to));
|
|
1058
1165
|
}
|
|
1166
|
+
if (options.flavor === "baas" && options.explicitPreset) console.log(chalk.yellow(` Ignoring --template ${options.preset}: the baas flavor has no collections.`));
|
|
1059
1167
|
if (options.flavor !== "baas") {
|
|
1060
1168
|
if (options.introspect && options.preset !== "blank") console.log(chalk.gray(" Using the blank template: collections will come from your database."));
|
|
1061
1169
|
await applyPreset(options.targetDirectory, options.introspect ? "blank" : options.preset);
|
|
@@ -1067,6 +1175,33 @@ async function createProject$1(options) {
|
|
|
1067
1175
|
console.log(chalk.gray(" Initializing git repository..."));
|
|
1068
1176
|
try {
|
|
1069
1177
|
await execa("git", ["init"], { cwd: options.targetDirectory });
|
|
1178
|
+
try {
|
|
1179
|
+
await execa("git", [
|
|
1180
|
+
"symbolic-ref",
|
|
1181
|
+
"HEAD",
|
|
1182
|
+
"refs/heads/main"
|
|
1183
|
+
], { cwd: options.targetDirectory });
|
|
1184
|
+
} catch {}
|
|
1185
|
+
await execa("git", ["add", "-A"], { cwd: options.targetDirectory });
|
|
1186
|
+
let identity = {};
|
|
1187
|
+
try {
|
|
1188
|
+
await execa("git", ["config", "user.email"], { cwd: options.targetDirectory });
|
|
1189
|
+
} catch {
|
|
1190
|
+
identity = {
|
|
1191
|
+
GIT_AUTHOR_NAME: "Rebase",
|
|
1192
|
+
GIT_AUTHOR_EMAIL: "noreply@rebase.pro",
|
|
1193
|
+
GIT_COMMITTER_NAME: "Rebase",
|
|
1194
|
+
GIT_COMMITTER_EMAIL: "noreply@rebase.pro"
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
await execa("git", [
|
|
1198
|
+
"commit",
|
|
1199
|
+
"-m",
|
|
1200
|
+
"Initial commit from Rebase"
|
|
1201
|
+
], {
|
|
1202
|
+
cwd: options.targetDirectory,
|
|
1203
|
+
env: identity
|
|
1204
|
+
});
|
|
1070
1205
|
} catch {
|
|
1071
1206
|
console.warn(chalk.yellow(" Warning: Failed to initialize git repository"));
|
|
1072
1207
|
}
|
|
@@ -1097,6 +1232,7 @@ async function createProject$1(options) {
|
|
|
1097
1232
|
console.warn(chalk.yellow(` Warning: Failed to install dependencies. You may need to run \`${installCmd.join(" ")}\` manually.`));
|
|
1098
1233
|
}
|
|
1099
1234
|
}
|
|
1235
|
+
let introspected = false;
|
|
1100
1236
|
if (options.introspect) {
|
|
1101
1237
|
console.log("");
|
|
1102
1238
|
if (options.installDeps) {
|
|
@@ -1112,6 +1248,7 @@ async function createProject$1(options) {
|
|
|
1112
1248
|
stdio: "inherit"
|
|
1113
1249
|
});
|
|
1114
1250
|
console.log(chalk.green(" Database successfully introspected!"));
|
|
1251
|
+
introspected = true;
|
|
1115
1252
|
} catch {
|
|
1116
1253
|
console.warn(chalk.yellow(" Warning: Failed to introspect database automatically."));
|
|
1117
1254
|
console.warn(chalk.yellow(` You can run \`${execCmd.join(" ")}\` then \`${generateCmd.join(" ")}\` manually after setup.`));
|
|
@@ -1130,13 +1267,21 @@ async function createProject$1(options) {
|
|
|
1130
1267
|
const runDev = pmCommands.run("dev");
|
|
1131
1268
|
const runDbPush = pmCommands.run("db:push");
|
|
1132
1269
|
const isBaas = options.flavor === "baas";
|
|
1133
|
-
|
|
1270
|
+
const cdTarget = formatCdTarget(process.cwd(), options.targetDirectory);
|
|
1271
|
+
if (cdTarget) console.log(` ${chalk.cyan("cd")} ${cdTarget}`);
|
|
1134
1272
|
if (!options.installDeps) console.log(` ${chalk.cyan(installCmd.join(" "))}`);
|
|
1135
1273
|
console.log("");
|
|
1136
|
-
if (options.databaseUrl) if (
|
|
1274
|
+
if (options.databaseUrl) if (introspected) {
|
|
1137
1275
|
console.log(chalk.gray(" # Database has been introspected & collections generated!"));
|
|
1138
1276
|
console.log(chalk.gray(" # Start the development server (frontend + backend):"));
|
|
1139
1277
|
console.log(` ${chalk.cyan(runDev.join(" "))}`);
|
|
1278
|
+
} else if (options.introspect) {
|
|
1279
|
+
console.log(chalk.gray(" # Introspection did not run — finish it with:"));
|
|
1280
|
+
console.log(` ${chalk.cyan(execCmd.join(" "))}`);
|
|
1281
|
+
console.log(` ${chalk.cyan(generateCmd.join(" "))}`);
|
|
1282
|
+
console.log("");
|
|
1283
|
+
console.log(chalk.gray(" # Then start the development server:"));
|
|
1284
|
+
console.log(` ${chalk.cyan(runDev.join(" "))}`);
|
|
1140
1285
|
} else {
|
|
1141
1286
|
console.log(chalk.gray(" # Your custom database is configured in .env."));
|
|
1142
1287
|
console.log(chalk.gray(" # If the database is empty, push the Rebase schema to initialize it:"));
|
|
@@ -1365,7 +1510,7 @@ async function configureEnvFile(targetDirectory, databaseUrl) {
|
|
|
1365
1510
|
envContent = envContent.replace(/^#\s*REBASE_SERVICE_KEY=.*$/m, `REBASE_SERVICE_KEY=${serviceKey}`);
|
|
1366
1511
|
if (databaseUrl) {
|
|
1367
1512
|
if (/[\r\n]/.test(databaseUrl)) throw new Error("Invalid DATABASE_URL: multiline values are not allowed.");
|
|
1368
|
-
envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${databaseUrl}`);
|
|
1513
|
+
envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${databaseUrl}\nDATABASE_PASSWORD=${dbPassword}`);
|
|
1369
1514
|
} else {
|
|
1370
1515
|
const dbPort = await findAvailablePort(5432);
|
|
1371
1516
|
envContent = envContent.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=postgresql://rebase:${dbPassword}@localhost:${dbPort}/rebase?options=-c%20search_path=public&sslmode=disable\nDATABASE_PASSWORD=${dbPassword}`);
|
|
@@ -1465,21 +1610,156 @@ function writeFiles(outputDir, files) {
|
|
|
1465
1610
|
fs.writeFileSync(filePath, file.content, "utf-8");
|
|
1466
1611
|
}
|
|
1467
1612
|
}
|
|
1613
|
+
function printSdkHelp() {
|
|
1614
|
+
console.log(`
|
|
1615
|
+
${chalk.bold("rebase generate-sdk")} — generate a typed client from a project's schema
|
|
1616
|
+
|
|
1617
|
+
${chalk.bold("Usage")}
|
|
1618
|
+
rebase generate-sdk [options]
|
|
1619
|
+
|
|
1620
|
+
${chalk.bold("Options")}
|
|
1621
|
+
-c, --collections-dir <dir> Local collections directory (default: ./config/collections)
|
|
1622
|
+
-o, --output <dir> Where to write the SDK (default: ./generated/sdk)
|
|
1623
|
+
--from <link|url> Fetch the schema from a running project instead of
|
|
1624
|
+
local source. "link" uses this checkout's linked project.
|
|
1625
|
+
--token <token> Bearer token for the contract endpoint
|
|
1626
|
+
(default: $REBASE_SERVICE_KEY)
|
|
1627
|
+
-h, --help Show this help
|
|
1628
|
+
|
|
1629
|
+
${chalk.bold("Examples")}
|
|
1630
|
+
rebase generate-sdk From local collections
|
|
1631
|
+
rebase generate-sdk --from link From the linked project
|
|
1632
|
+
rebase generate-sdk --from https://api.acme.com From any Rebase backend
|
|
1633
|
+
`.trim());
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Fetch collections from a running project's contract endpoint.
|
|
1637
|
+
*
|
|
1638
|
+
* The payload replaces relation `target` functions with slug references, so it
|
|
1639
|
+
* has to be rehydrated before the generator sees it — the generator *calls*
|
|
1640
|
+
* `target()` to decide whether a foreign key is a string or a number, and a
|
|
1641
|
+
* missing target silently degrades that to a union rather than failing.
|
|
1642
|
+
*/
|
|
1643
|
+
async function fetchRemoteCollections(baseUrl, token) {
|
|
1644
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/api/meta/contract`;
|
|
1645
|
+
const headers = { accept: "application/json" };
|
|
1646
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
1647
|
+
let response;
|
|
1648
|
+
try {
|
|
1649
|
+
response = await fetch(url, { headers });
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
console.log(chalk.red(` ✗ Could not reach ${url}`));
|
|
1652
|
+
console.log(chalk.gray(` ${err instanceof Error ? err.message : String(err)}`));
|
|
1653
|
+
process.exit(1);
|
|
1654
|
+
}
|
|
1655
|
+
if (response.status === 401 || response.status === 403) {
|
|
1656
|
+
console.log(chalk.red(` ✗ Not authorized to read the project contract (${response.status}).`));
|
|
1657
|
+
console.log(chalk.gray(" The contract describes every table and relation, so it is admin-only."));
|
|
1658
|
+
console.log(chalk.gray(" Pass --token, or set REBASE_SERVICE_KEY."));
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
}
|
|
1661
|
+
if (response.status === 404) {
|
|
1662
|
+
console.log(chalk.red(" ✗ This server has no contract endpoint."));
|
|
1663
|
+
console.log(chalk.gray(" It needs to be running Rebase 0.11 or newer."));
|
|
1664
|
+
process.exit(1);
|
|
1665
|
+
}
|
|
1666
|
+
if (!response.ok) {
|
|
1667
|
+
console.log(chalk.red(` ✗ Contract request failed with ${response.status}.`));
|
|
1668
|
+
process.exit(1);
|
|
1669
|
+
}
|
|
1670
|
+
const contract = await response.json();
|
|
1671
|
+
if (!Array.isArray(contract.collections)) {
|
|
1672
|
+
console.log(chalk.red(" ✗ The contract response did not contain collections."));
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
return {
|
|
1676
|
+
collections: deserializeCollections(contract.collections),
|
|
1677
|
+
schemaVersion: contract.schemaVersion ?? "unknown"
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Decide whether the ambient service key may be sent to this host.
|
|
1682
|
+
*
|
|
1683
|
+
* `REBASE_SERVICE_KEY` grants full admin bypass. Attaching it to whatever URL
|
|
1684
|
+
* happened to be passed — or, worse, to whatever a committed `.rebase/cloud.json`
|
|
1685
|
+
* points at — would hand the project's most powerful credential to a host nobody
|
|
1686
|
+
* vetted. An explicit `--token` is a decision the caller made; the ambient
|
|
1687
|
+
* variable is not, so it only travels to the project this checkout is linked to.
|
|
1688
|
+
*/
|
|
1689
|
+
function mayUseAmbientKey(target, cwd) {
|
|
1690
|
+
const link = readLink(findProjectRoot(cwd) ?? cwd);
|
|
1691
|
+
if (!link?.apiUrl) return false;
|
|
1692
|
+
try {
|
|
1693
|
+
return new URL(link.apiUrl).origin === new URL(target).origin;
|
|
1694
|
+
} catch {
|
|
1695
|
+
return false;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/** Resolve `--from` into a base URL, following the link file when asked. */
|
|
1699
|
+
function resolveSchemaSource(from, cwd) {
|
|
1700
|
+
if (from !== "link") {
|
|
1701
|
+
let parsed;
|
|
1702
|
+
try {
|
|
1703
|
+
parsed = new URL(from);
|
|
1704
|
+
} catch {
|
|
1705
|
+
console.log(chalk.red(` ✗ "${from}" is not a valid URL.`));
|
|
1706
|
+
console.log(chalk.gray(" Pass a full URL, e.g. https://api.example.com, or \"link\"."));
|
|
1707
|
+
process.exit(1);
|
|
1708
|
+
}
|
|
1709
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1710
|
+
console.log(chalk.red(" ✗ The project URL must be http or https."));
|
|
1711
|
+
process.exit(1);
|
|
1712
|
+
}
|
|
1713
|
+
return from;
|
|
1714
|
+
}
|
|
1715
|
+
const link = readLink(findProjectRoot(cwd) ?? cwd);
|
|
1716
|
+
if (!link) {
|
|
1717
|
+
console.log(chalk.red(" ✗ This checkout is not linked to a project."));
|
|
1718
|
+
console.log(chalk.gray(" Run `rebase link <url>`, or pass --from <url>."));
|
|
1719
|
+
process.exit(1);
|
|
1720
|
+
}
|
|
1721
|
+
const apiUrl = link.apiUrl;
|
|
1722
|
+
if (!apiUrl) {
|
|
1723
|
+
console.log(chalk.red(" ✗ The project link has no API URL."));
|
|
1724
|
+
console.log(chalk.gray(" Re-link with `rebase link <url>` to record one."));
|
|
1725
|
+
process.exit(1);
|
|
1726
|
+
}
|
|
1727
|
+
return apiUrl;
|
|
1728
|
+
}
|
|
1468
1729
|
/**
|
|
1469
1730
|
* Main entry point for the generate-sdk command.
|
|
1470
1731
|
*/
|
|
1471
1732
|
async function generateSdkCommand(args) {
|
|
1472
1733
|
const { collectionsDir, output, cwd } = args;
|
|
1734
|
+
if (args.help) {
|
|
1735
|
+
printSdkHelp();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1473
1738
|
const resolvedCollectionsDir = path.isAbsolute(collectionsDir) ? collectionsDir : path.join(cwd, collectionsDir);
|
|
1474
1739
|
const resolvedOutput = path.isAbsolute(output) ? output : path.join(cwd, output);
|
|
1475
1740
|
console.log("");
|
|
1476
1741
|
console.log(chalk.bold(" 🔧 Rebase SDK Generator"));
|
|
1477
1742
|
console.log("");
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1743
|
+
let collections;
|
|
1744
|
+
let remoteSchemaVersion;
|
|
1745
|
+
if (args.from) {
|
|
1746
|
+
const baseUrl = resolveSchemaSource(args.from, cwd);
|
|
1747
|
+
console.log(` ${chalk.gray("Project:")} ${baseUrl}`);
|
|
1748
|
+
console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
|
|
1749
|
+
console.log("");
|
|
1750
|
+
console.log(chalk.cyan(" → Fetching the project contract..."));
|
|
1751
|
+
const ambient = mayUseAmbientKey(baseUrl, cwd) ? process.env.REBASE_SERVICE_KEY : void 0;
|
|
1752
|
+
if (!args.token && !ambient && process.env.REBASE_SERVICE_KEY) console.log(chalk.dim(" (not sending REBASE_SERVICE_KEY — this host is not the linked project; pass --token to override)"));
|
|
1753
|
+
const remote = await fetchRemoteCollections(baseUrl, args.token || ambient);
|
|
1754
|
+
collections = remote.collections;
|
|
1755
|
+
remoteSchemaVersion = remote.schemaVersion;
|
|
1756
|
+
} else {
|
|
1757
|
+
console.log(` ${chalk.gray("Collections:")} ${resolvedCollectionsDir}`);
|
|
1758
|
+
console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
|
|
1759
|
+
console.log("");
|
|
1760
|
+
console.log(chalk.cyan(" → Loading collection definitions..."));
|
|
1761
|
+
collections = await loadCollections(resolvedCollectionsDir);
|
|
1762
|
+
}
|
|
1483
1763
|
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1484
1764
|
if (collections.length === 0) {
|
|
1485
1765
|
console.log(chalk.red(" ✗ No collections found. Nothing to generate."));
|
|
@@ -1489,7 +1769,22 @@ async function generateSdkCommand(args) {
|
|
|
1489
1769
|
console.log("");
|
|
1490
1770
|
console.log(chalk.cyan(" → Generating SDK files..."));
|
|
1491
1771
|
const files = generateSDK(collections);
|
|
1772
|
+
const schemaVersion = remoteSchemaVersion ?? computeSchemaVersion(collections);
|
|
1773
|
+
files.push({
|
|
1774
|
+
path: "schema.meta.ts",
|
|
1775
|
+
content: `// Auto-generated by \`rebase generate-sdk\`. Do not edit.
|
|
1776
|
+
//
|
|
1777
|
+
// The schema version this SDK was generated from. Compare it against the
|
|
1778
|
+
// project's current version to detect drift:
|
|
1779
|
+
//
|
|
1780
|
+
// curl -s <api-url>/api/meta/schema-version
|
|
1781
|
+
//
|
|
1782
|
+
export const SCHEMA_VERSION = ${JSON.stringify(schemaVersion)};
|
|
1783
|
+
export const GENERATED_AT = ${JSON.stringify((/* @__PURE__ */ new Date()).toISOString())};
|
|
1784
|
+
`
|
|
1785
|
+
});
|
|
1492
1786
|
console.log(chalk.green(` ✓ Generated ${files.length} file(s)`));
|
|
1787
|
+
console.log(chalk.gray(` schema ${schemaVersion}`));
|
|
1493
1788
|
console.log(chalk.cyan(` → Writing to ${resolvedOutput}...`));
|
|
1494
1789
|
writeFiles(resolvedOutput, files);
|
|
1495
1790
|
console.log("");
|
|
@@ -1659,6 +1954,367 @@ ${chalk.green.bold("Examples")}
|
|
|
1659
1954
|
`);
|
|
1660
1955
|
}
|
|
1661
1956
|
//#endregion
|
|
1957
|
+
//#region src/manifest.ts
|
|
1958
|
+
/**
|
|
1959
|
+
* Loading, validating and synthesizing `rebase.json`.
|
|
1960
|
+
*
|
|
1961
|
+
* The manifest declares *topology*: which runtime major a project targets and
|
|
1962
|
+
* which apps this repository contributes. It is deliberately small — schema,
|
|
1963
|
+
* security rules, hooks and functions stay in TypeScript, where a type system
|
|
1964
|
+
* can check them.
|
|
1965
|
+
*
|
|
1966
|
+
* Two properties matter more than the file format itself:
|
|
1967
|
+
*
|
|
1968
|
+
* - **A missing manifest is never an error.** Every project that exists today
|
|
1969
|
+
* predates this file. One is synthesized from the conventions the template
|
|
1970
|
+
* already follows, so nothing breaks and nobody is forced to migrate.
|
|
1971
|
+
* - **Validation reports every problem at once**, with the path to each. A
|
|
1972
|
+
* config file that surfaces its mistakes one run at a time is a bad config
|
|
1973
|
+
* file.
|
|
1974
|
+
*/
|
|
1975
|
+
/** Runtime range written into new manifests. */
|
|
1976
|
+
var CURRENT_RUNTIME_RANGE = "^1";
|
|
1977
|
+
/** Conventional locations, matching what `rebase init` scaffolds. */
|
|
1978
|
+
var DEFAULT_CONFIG_DIR = "config";
|
|
1979
|
+
var DEFAULT_FUNCTIONS_DIR = "backend/functions";
|
|
1980
|
+
var DEFAULT_CRONS_DIR = "backend/crons";
|
|
1981
|
+
var DEFAULT_SCHEMA_FILE = "backend/src/schema.generated.ts";
|
|
1982
|
+
var ManifestError = class extends Error {
|
|
1983
|
+
issues;
|
|
1984
|
+
constructor(message, issues = []) {
|
|
1985
|
+
super(message);
|
|
1986
|
+
this.issues = issues;
|
|
1987
|
+
this.name = "ManifestError";
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
var APP_TYPES = [
|
|
1991
|
+
"backend",
|
|
1992
|
+
"static",
|
|
1993
|
+
"admin",
|
|
1994
|
+
"mobile",
|
|
1995
|
+
"custom"
|
|
1996
|
+
];
|
|
1997
|
+
/** Reserved because they name things in URLs and CLI output. */
|
|
1998
|
+
var RESERVED_APP_NAMES = new Set([
|
|
1999
|
+
"api",
|
|
2000
|
+
"health",
|
|
2001
|
+
"metrics",
|
|
2002
|
+
"livez",
|
|
2003
|
+
"_rebase"
|
|
2004
|
+
]);
|
|
2005
|
+
function isRecord(value) {
|
|
2006
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Reject paths that escape the repository.
|
|
2010
|
+
*
|
|
2011
|
+
* A manifest is committed and reviewed, so this is not a security boundary so
|
|
2012
|
+
* much as a guard against `../../` typos that would otherwise have `rebase build`
|
|
2013
|
+
* writing outside the project.
|
|
2014
|
+
*/
|
|
2015
|
+
function checkRelativePath(value, fieldPath, issues, { required }) {
|
|
2016
|
+
if (value === void 0) {
|
|
2017
|
+
if (required) issues.push({
|
|
2018
|
+
path: fieldPath,
|
|
2019
|
+
message: "is required"
|
|
2020
|
+
});
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2024
|
+
issues.push({
|
|
2025
|
+
path: fieldPath,
|
|
2026
|
+
message: "must be a non-empty string"
|
|
2027
|
+
});
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (path.isAbsolute(value)) {
|
|
2031
|
+
issues.push({
|
|
2032
|
+
path: fieldPath,
|
|
2033
|
+
message: "must be a relative path, not absolute"
|
|
2034
|
+
});
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const normalized = path.normalize(value);
|
|
2038
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
2039
|
+
issues.push({
|
|
2040
|
+
path: fieldPath,
|
|
2041
|
+
message: "must stay inside the project directory"
|
|
2042
|
+
});
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
return value;
|
|
2046
|
+
}
|
|
2047
|
+
function validateApp(name, raw, issues) {
|
|
2048
|
+
const base = `apps.${name}`;
|
|
2049
|
+
if (!isRecord(raw)) {
|
|
2050
|
+
issues.push({
|
|
2051
|
+
path: base,
|
|
2052
|
+
message: "must be an object"
|
|
2053
|
+
});
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const type = raw.type;
|
|
2057
|
+
if (typeof type !== "string" || !APP_TYPES.includes(type)) {
|
|
2058
|
+
issues.push({
|
|
2059
|
+
path: `${base}.type`,
|
|
2060
|
+
message: `must be one of: ${APP_TYPES.join(", ")}`
|
|
2061
|
+
});
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
switch (type) {
|
|
2065
|
+
case "backend":
|
|
2066
|
+
checkRelativePath(raw.config, `${base}.config`, issues, { required: false });
|
|
2067
|
+
checkRelativePath(raw.functions, `${base}.functions`, issues, { required: false });
|
|
2068
|
+
checkRelativePath(raw.crons, `${base}.crons`, issues, { required: false });
|
|
2069
|
+
checkRelativePath(raw.schema, `${base}.schema`, issues, { required: false });
|
|
2070
|
+
checkRelativePath(raw.usersCollection, `${base}.usersCollection`, issues, { required: false });
|
|
2071
|
+
if (raw.mode !== void 0 && raw.mode !== "cms" && raw.mode !== "baas") issues.push({
|
|
2072
|
+
path: `${base}.mode`,
|
|
2073
|
+
message: "must be \"cms\" or \"baas\""
|
|
2074
|
+
});
|
|
2075
|
+
return raw;
|
|
2076
|
+
case "static":
|
|
2077
|
+
checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
|
|
2078
|
+
checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
|
|
2079
|
+
if (raw.build !== void 0 && typeof raw.build !== "string") issues.push({
|
|
2080
|
+
path: `${base}.build`,
|
|
2081
|
+
message: "must be a string command"
|
|
2082
|
+
});
|
|
2083
|
+
if (raw.spa !== void 0 && typeof raw.spa !== "boolean") issues.push({
|
|
2084
|
+
path: `${base}.spa`,
|
|
2085
|
+
message: "must be a boolean"
|
|
2086
|
+
});
|
|
2087
|
+
return raw;
|
|
2088
|
+
case "admin": {
|
|
2089
|
+
const mode = raw.mode ?? "hosted";
|
|
2090
|
+
if (mode !== "hosted" && mode !== "bundled") {
|
|
2091
|
+
issues.push({
|
|
2092
|
+
path: `${base}.mode`,
|
|
2093
|
+
message: "must be \"hosted\" or \"bundled\""
|
|
2094
|
+
});
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
if (mode === "bundled") {
|
|
2098
|
+
checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
|
|
2099
|
+
checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
|
|
2100
|
+
}
|
|
2101
|
+
return raw;
|
|
2102
|
+
}
|
|
2103
|
+
case "mobile": {
|
|
2104
|
+
const platform = raw.platform;
|
|
2105
|
+
if (platform !== "ios" && platform !== "android" && platform !== "other") issues.push({
|
|
2106
|
+
path: `${base}.platform`,
|
|
2107
|
+
message: "must be \"ios\", \"android\" or \"other\""
|
|
2108
|
+
});
|
|
2109
|
+
return raw;
|
|
2110
|
+
}
|
|
2111
|
+
case "custom":
|
|
2112
|
+
checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
|
|
2113
|
+
checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
|
|
2114
|
+
if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
|
|
2115
|
+
path: `${base}.port`,
|
|
2116
|
+
message: "must be an integer"
|
|
2117
|
+
});
|
|
2118
|
+
return raw;
|
|
2119
|
+
default: return;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Validate a parsed manifest, collecting every problem.
|
|
2124
|
+
*/
|
|
2125
|
+
function validateManifest(raw) {
|
|
2126
|
+
const issues = [];
|
|
2127
|
+
if (!isRecord(raw)) return { issues: [{
|
|
2128
|
+
path: "",
|
|
2129
|
+
message: `${MANIFEST_FILENAME} must contain a JSON object`
|
|
2130
|
+
}] };
|
|
2131
|
+
if (typeof raw.runtime !== "string" || raw.runtime.trim() === "") issues.push({
|
|
2132
|
+
path: "runtime",
|
|
2133
|
+
message: `is required, e.g. "^1"`
|
|
2134
|
+
});
|
|
2135
|
+
if (!isRecord(raw.apps)) {
|
|
2136
|
+
issues.push({
|
|
2137
|
+
path: "apps",
|
|
2138
|
+
message: "is required and must be an object"
|
|
2139
|
+
});
|
|
2140
|
+
return { issues };
|
|
2141
|
+
}
|
|
2142
|
+
const apps = {};
|
|
2143
|
+
let backendCount = 0;
|
|
2144
|
+
for (const [name, value] of Object.entries(raw.apps)) {
|
|
2145
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
2146
|
+
issues.push({
|
|
2147
|
+
path: `apps.${name}`,
|
|
2148
|
+
message: "name must be lowercase alphanumeric with dashes (it appears in URLs)"
|
|
2149
|
+
});
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
if (RESERVED_APP_NAMES.has(name)) {
|
|
2153
|
+
issues.push({
|
|
2154
|
+
path: `apps.${name}`,
|
|
2155
|
+
message: "name is reserved"
|
|
2156
|
+
});
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
const app = validateApp(name, value, issues);
|
|
2160
|
+
if (!app) continue;
|
|
2161
|
+
if (app.type === "backend") backendCount++;
|
|
2162
|
+
apps[name] = app;
|
|
2163
|
+
}
|
|
2164
|
+
if (backendCount > 1) issues.push({
|
|
2165
|
+
path: "apps",
|
|
2166
|
+
message: "a project may declare at most one backend app"
|
|
2167
|
+
});
|
|
2168
|
+
if (issues.length > 0) return { issues };
|
|
2169
|
+
return {
|
|
2170
|
+
manifest: {
|
|
2171
|
+
$schema: typeof raw.$schema === "string" ? raw.$schema : void 0,
|
|
2172
|
+
runtime: raw.runtime,
|
|
2173
|
+
apps
|
|
2174
|
+
},
|
|
2175
|
+
issues
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Infer a manifest from a directory that does not have one.
|
|
2180
|
+
*
|
|
2181
|
+
* This mirrors exactly what the template scaffolds, which is what makes adopting
|
|
2182
|
+
* the manifest a no-op for existing projects: the synthesized result is what
|
|
2183
|
+
* they would have written by hand.
|
|
2184
|
+
*
|
|
2185
|
+
* An ejected backend — one with its own `src/index.ts` entrypoint — is reported
|
|
2186
|
+
* as a `custom` app rather than a `backend` app. That is not a downgrade; it is
|
|
2187
|
+
* an accurate description, and it is what keeps such a project deploying exactly
|
|
2188
|
+
* as it does today.
|
|
2189
|
+
*/
|
|
2190
|
+
function synthesizeManifest(projectRoot) {
|
|
2191
|
+
const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
|
|
2192
|
+
const apps = {};
|
|
2193
|
+
const hasConfig = exists(DEFAULT_CONFIG_DIR);
|
|
2194
|
+
const hasBackend = exists("backend");
|
|
2195
|
+
const backendEntry = exists("backend/src/index.ts");
|
|
2196
|
+
if (hasBackend && backendEntry) apps.backend = {
|
|
2197
|
+
type: "custom",
|
|
2198
|
+
dockerfile: exists("backend/Dockerfile") ? "backend/Dockerfile" : void 0,
|
|
2199
|
+
context: "."
|
|
2200
|
+
};
|
|
2201
|
+
else if (hasBackend || hasConfig) {
|
|
2202
|
+
const backend = { type: "backend" };
|
|
2203
|
+
if (!hasConfig) backend.mode = "baas";
|
|
2204
|
+
if (exists("backend/functions")) backend.functions = DEFAULT_FUNCTIONS_DIR;
|
|
2205
|
+
if (exists("backend/crons")) backend.crons = DEFAULT_CRONS_DIR;
|
|
2206
|
+
apps.backend = backend;
|
|
2207
|
+
}
|
|
2208
|
+
if (exists("frontend")) apps.web = {
|
|
2209
|
+
type: "static",
|
|
2210
|
+
root: "frontend",
|
|
2211
|
+
build: "npm run build --workspace frontend",
|
|
2212
|
+
output: "frontend/dist",
|
|
2213
|
+
spa: true
|
|
2214
|
+
};
|
|
2215
|
+
return {
|
|
2216
|
+
runtime: "^1",
|
|
2217
|
+
apps
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
function manifestPath(projectRoot) {
|
|
2221
|
+
return path.join(projectRoot, MANIFEST_FILENAME);
|
|
2222
|
+
}
|
|
2223
|
+
function manifestExists(projectRoot) {
|
|
2224
|
+
return fs.existsSync(manifestPath(projectRoot));
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Read the manifest, falling back to a synthesized one.
|
|
2228
|
+
*
|
|
2229
|
+
* A malformed manifest throws — unlike a missing one. Silently ignoring a file
|
|
2230
|
+
* the developer wrote, and building something else instead, is the worst
|
|
2231
|
+
* available behaviour.
|
|
2232
|
+
*/
|
|
2233
|
+
function loadManifest(projectRoot) {
|
|
2234
|
+
const filePath = manifestPath(projectRoot);
|
|
2235
|
+
if (!fs.existsSync(filePath)) return {
|
|
2236
|
+
manifest: synthesizeManifest(projectRoot),
|
|
2237
|
+
source: "synthesized"
|
|
2238
|
+
};
|
|
2239
|
+
let parsed;
|
|
2240
|
+
try {
|
|
2241
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
2242
|
+
} catch (err) {
|
|
2243
|
+
throw new ManifestError(`${MANIFEST_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2244
|
+
}
|
|
2245
|
+
const { manifest, issues } = validateManifest(parsed);
|
|
2246
|
+
if (!manifest) throw new ManifestError(`${MANIFEST_FILENAME} is invalid`, issues);
|
|
2247
|
+
return {
|
|
2248
|
+
manifest,
|
|
2249
|
+
source: "file",
|
|
2250
|
+
filePath
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
/** Write a manifest, with a trailing newline so it plays well with other tools. */
|
|
2254
|
+
function writeManifest(projectRoot, manifest) {
|
|
2255
|
+
const filePath = manifestPath(projectRoot);
|
|
2256
|
+
const ordered = {
|
|
2257
|
+
$schema: manifest.$schema ?? "https://rebase.pro/schemas/rebase.json",
|
|
2258
|
+
runtime: manifest.runtime,
|
|
2259
|
+
apps: manifest.apps
|
|
2260
|
+
};
|
|
2261
|
+
fs.writeFileSync(filePath, `${JSON.stringify(ordered, null, 4)}\n`, "utf8");
|
|
2262
|
+
return filePath;
|
|
2263
|
+
}
|
|
2264
|
+
/** Find the single backend app, if this repository declares one. */
|
|
2265
|
+
function findBackendApp(manifest) {
|
|
2266
|
+
for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "backend") return {
|
|
2267
|
+
name,
|
|
2268
|
+
app
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
/** Apps that produce build output, in the order they should be built. */
|
|
2272
|
+
function buildableApps(manifest) {
|
|
2273
|
+
const entries = Object.entries(manifest.apps).map(([name, app]) => ({
|
|
2274
|
+
name,
|
|
2275
|
+
app
|
|
2276
|
+
}));
|
|
2277
|
+
const rank = (app) => {
|
|
2278
|
+
if (app.type === "backend") return 0;
|
|
2279
|
+
if (app.type === "admin") return 1;
|
|
2280
|
+
if (app.type === "static") return 2;
|
|
2281
|
+
return 3;
|
|
2282
|
+
};
|
|
2283
|
+
return entries.filter(({ app }) => app.type !== "mobile").sort((a, b) => rank(a.app) - rank(b.app));
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Decide whether a project can run on the managed runtime, and say why not.
|
|
2287
|
+
*
|
|
2288
|
+
* "Not eligible" is never a dead end — it selects the custom-runtime path, which
|
|
2289
|
+
* still deploys. The reasons exist so the answer is actionable rather than a
|
|
2290
|
+
* verdict.
|
|
2291
|
+
*/
|
|
2292
|
+
function assessManagedCompatibility(manifest) {
|
|
2293
|
+
const reasons = [];
|
|
2294
|
+
const backend = findBackendApp(manifest);
|
|
2295
|
+
if (!backend) {
|
|
2296
|
+
const custom = Object.entries(manifest.apps).find(([, app]) => app.type === "custom");
|
|
2297
|
+
if (custom) reasons.push(`App "${custom[0]}" is a custom container. The managed runtime runs the platform image with your bundle, so a project that builds its own image uses the custom runtime instead.`);
|
|
2298
|
+
else reasons.push("No backend app is declared in this repository. Only the repository that declares the backend selects the runtime.");
|
|
2299
|
+
}
|
|
2300
|
+
for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "custom") reasons.push(`App "${name}" is a custom container image.`);
|
|
2301
|
+
return {
|
|
2302
|
+
eligible: reasons.length === 0 && Boolean(backend),
|
|
2303
|
+
reasons
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
/** Resolve a backend app's directories against the conventions it omits. */
|
|
2307
|
+
function resolveBackendPaths(app) {
|
|
2308
|
+
return {
|
|
2309
|
+
config: app.config ?? "config",
|
|
2310
|
+
functions: app.functions ?? "backend/functions",
|
|
2311
|
+
crons: app.crons ?? "backend/crons",
|
|
2312
|
+
schema: app.schema ?? "backend/src/schema.generated.ts",
|
|
2313
|
+
usersCollection: app.usersCollection ?? "collections/users",
|
|
2314
|
+
mode: app.mode ?? "cms"
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
//#endregion
|
|
1662
2318
|
//#region src/commands/dev.ts
|
|
1663
2319
|
/**
|
|
1664
2320
|
* CLI command: rebase dev
|
|
@@ -1676,6 +2332,66 @@ ${chalk.green.bold("Examples")}
|
|
|
1676
2332
|
* Each project gets a deterministic default port derived from the project
|
|
1677
2333
|
* root path, so multiple Rebase instances never collide.
|
|
1678
2334
|
*/
|
|
2335
|
+
/**
|
|
2336
|
+
* Quote a path for the shell `execa` runs the backend through.
|
|
2337
|
+
*
|
|
2338
|
+
* The dev runtime's path is absolute and therefore contains whatever the
|
|
2339
|
+
* developer's directories are called. Double quotes do not neutralize `$`,
|
|
2340
|
+
* backticks or backslashes in a POSIX shell, so a checkout under a directory
|
|
2341
|
+
* named `$(...)` would execute it. Single quotes disable all expansion; on
|
|
2342
|
+
* Windows, `cmd.exe` performs no such expansion and wants double quotes.
|
|
2343
|
+
*/
|
|
2344
|
+
function quoteForShell(value) {
|
|
2345
|
+
if (process.platform === "win32") return `"${value.replace(/"/g, "\\\"")}"`;
|
|
2346
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Locate the dev runtime shim shipped with the CLI.
|
|
2350
|
+
*
|
|
2351
|
+
* Published under `runtime/` in the package rather than compiled into `dist/`,
|
|
2352
|
+
* because tsx executes it as a file and it must exist on disk at a stable path.
|
|
2353
|
+
*/
|
|
2354
|
+
function resolveDevRuntimeEntry() {
|
|
2355
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
2356
|
+
for (let i = 0; i < 5; i++) {
|
|
2357
|
+
const candidate = path.join(dir, "runtime", "dev-server.mjs");
|
|
2358
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
2359
|
+
const parent = path.dirname(dir);
|
|
2360
|
+
if (parent === dir) break;
|
|
2361
|
+
dir = parent;
|
|
2362
|
+
}
|
|
2363
|
+
throw new Error("Could not find the Rebase dev runtime (runtime/dev-server.mjs). Reinstall @rebasepro/cli, or add a backend/src/index.ts to run your own entrypoint.");
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Tell the dev runtime where this project keeps its parts.
|
|
2367
|
+
*
|
|
2368
|
+
* Read from `rebase.json` when there is one, so a project that moved its config
|
|
2369
|
+
* directory is honoured; otherwise the conventional layout.
|
|
2370
|
+
*/
|
|
2371
|
+
function devRuntimeEnv(projectRoot) {
|
|
2372
|
+
const result = {
|
|
2373
|
+
REBASE_DEV_PROJECT_ROOT: projectRoot,
|
|
2374
|
+
REBASE_DEV_CONFIG: "config",
|
|
2375
|
+
REBASE_DEV_FUNCTIONS: "backend/functions",
|
|
2376
|
+
REBASE_DEV_CRONS: "backend/crons",
|
|
2377
|
+
REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts",
|
|
2378
|
+
REBASE_DEV_MODE: "cms"
|
|
2379
|
+
};
|
|
2380
|
+
try {
|
|
2381
|
+
const backend = findBackendApp(loadManifest(projectRoot).manifest);
|
|
2382
|
+
if (backend) {
|
|
2383
|
+
const paths = resolveBackendPaths(backend.app);
|
|
2384
|
+
result.REBASE_DEV_CONFIG = paths.config;
|
|
2385
|
+
result.REBASE_DEV_FUNCTIONS = paths.functions;
|
|
2386
|
+
result.REBASE_DEV_CRONS = paths.crons;
|
|
2387
|
+
result.REBASE_DEV_SCHEMA = paths.schema;
|
|
2388
|
+
result.REBASE_DEV_MODE = paths.mode;
|
|
2389
|
+
result.REBASE_DEV_APP = backend.name;
|
|
2390
|
+
}
|
|
2391
|
+
} catch {}
|
|
2392
|
+
if (!fs.existsSync(path.join(projectRoot, result.REBASE_DEV_CONFIG))) result.REBASE_DEV_MODE = "baas";
|
|
2393
|
+
return result;
|
|
2394
|
+
}
|
|
1679
2395
|
/** Well-known filename the backend writes its actual port to. */
|
|
1680
2396
|
var DEV_PORT_FILENAME = ".rebase-dev-port";
|
|
1681
2397
|
/**
|
|
@@ -1926,11 +2642,15 @@ async function devCommand(rawArgs) {
|
|
|
1926
2642
|
});
|
|
1927
2643
|
}
|
|
1928
2644
|
}
|
|
2645
|
+
const ejectedEntry = path.join(backendDir, "src", "index.ts");
|
|
2646
|
+
const usesStockRuntime = !fs.existsSync(ejectedEntry);
|
|
2647
|
+
const entryTarget = usesStockRuntime ? resolveDevRuntimeEntry() : "src/index.ts";
|
|
2648
|
+
if (usesStockRuntime) Object.assign(env, devRuntimeEnv(projectRoot));
|
|
1929
2649
|
const watchArgs = [
|
|
1930
2650
|
"watch",
|
|
1931
2651
|
"--conditions",
|
|
1932
2652
|
"development",
|
|
1933
|
-
|
|
2653
|
+
quoteForShell(entryTarget)
|
|
1934
2654
|
];
|
|
1935
2655
|
if (!shouldGenerate) {
|
|
1936
2656
|
watchArgs.splice(1, 0, `--watch="${path.join("..", "config", "**", "*")}"`);
|
|
@@ -2060,68 +2780,1068 @@ ${chalk.green.bold("Description")}
|
|
|
2060
2780
|
`);
|
|
2061
2781
|
}
|
|
2062
2782
|
//#endregion
|
|
2063
|
-
//#region src/
|
|
2783
|
+
//#region src/bundle.ts
|
|
2064
2784
|
/**
|
|
2065
|
-
*
|
|
2785
|
+
* Building a project bundle.
|
|
2066
2786
|
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2787
|
+
* A bundle is the deployable form of a project: compiled collections, functions,
|
|
2788
|
+
* crons and schema, plus a generated manifest describing exactly what it needs
|
|
2789
|
+
* to run. It contains no Dockerfile and no repository — the runtime is supplied
|
|
2790
|
+
* separately, which is what allows a project to be moved onto a patched runtime
|
|
2791
|
+
* without being rebuilt.
|
|
2792
|
+
*
|
|
2793
|
+
* Compilation runs through a generated tsconfig rooted at the project directory,
|
|
2794
|
+
* so the output mirrors the source layout (`config/…`, `backend/functions/…`)
|
|
2795
|
+
* and every path in the manifest is predictable. Letting each workspace package
|
|
2796
|
+
* emit into its own `dist/` would have meant guessing at three different
|
|
2797
|
+
* layouts, since `rootDir` differs between the template flavours.
|
|
2070
2798
|
*/
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2799
|
+
var DEFAULT_BUNDLE_DIR = "dist-bundle";
|
|
2800
|
+
/** Packages whose presence means the bundle cannot run on a stock runtime image. */
|
|
2801
|
+
var KNOWN_NATIVE_PACKAGES = new Set([
|
|
2802
|
+
"sharp",
|
|
2803
|
+
"canvas",
|
|
2804
|
+
"bcrypt",
|
|
2805
|
+
"argon2",
|
|
2806
|
+
"node-sass",
|
|
2807
|
+
"sqlite3",
|
|
2808
|
+
"better-sqlite3",
|
|
2809
|
+
"grpc",
|
|
2810
|
+
"@grpc/grpc-js-native",
|
|
2811
|
+
"re2",
|
|
2812
|
+
"sodium-native",
|
|
2813
|
+
"libpq",
|
|
2814
|
+
"pg-native"
|
|
2815
|
+
]);
|
|
2816
|
+
/** Dependencies supplied by the runtime image itself, not by the bundle. */
|
|
2817
|
+
var RUNTIME_PROVIDED = new Set([
|
|
2818
|
+
"@rebasepro/server",
|
|
2819
|
+
"@rebasepro/types",
|
|
2820
|
+
"@rebasepro/client",
|
|
2821
|
+
"@rebasepro/common",
|
|
2822
|
+
"@rebasepro/utils",
|
|
2823
|
+
"hono",
|
|
2824
|
+
"@hono/node-server",
|
|
2825
|
+
"typescript",
|
|
2826
|
+
"tsx"
|
|
2827
|
+
]);
|
|
2828
|
+
function log(options, message) {
|
|
2829
|
+
(options.log ?? ((m) => console.log(m)))(message);
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Every `node_modules/@types` directory the project can see.
|
|
2833
|
+
*
|
|
2834
|
+
* Type roots normally resolve by walking up from the tsconfig's own directory,
|
|
2835
|
+
* which breaks here for two reasons: the generated config lives in `.rebase/`,
|
|
2836
|
+
* and a pnpm workspace puts `@types/node` inside the *package* that depends on
|
|
2837
|
+
* it (`config/node_modules/@types`) rather than at the project root. Listing them
|
|
2838
|
+
* explicitly, as absolute paths, sidesteps both.
|
|
2839
|
+
*/
|
|
2840
|
+
function discoverTypeRoots(projectRoot) {
|
|
2841
|
+
const candidates = [];
|
|
2842
|
+
for (const relative of [
|
|
2843
|
+
".",
|
|
2844
|
+
"config",
|
|
2845
|
+
"backend",
|
|
2846
|
+
"frontend"
|
|
2847
|
+
]) candidates.push(path.join(projectRoot, relative, "node_modules", "@types"));
|
|
2848
|
+
let dir = projectRoot;
|
|
2849
|
+
for (let i = 0; i < 4; i++) {
|
|
2850
|
+
const parent = path.dirname(dir);
|
|
2851
|
+
if (parent === dir) break;
|
|
2852
|
+
candidates.push(path.join(parent, "node_modules", "@types"));
|
|
2853
|
+
dir = parent;
|
|
2854
|
+
}
|
|
2855
|
+
return candidates.filter((candidate) => fs.existsSync(candidate));
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Read a tsconfig's own `compilerOptions`.
|
|
2859
|
+
*
|
|
2860
|
+
* Parsed with the project's own TypeScript, because a tsconfig is not JSON: it
|
|
2861
|
+
* permits comments and trailing commas. Hand-rolled comment stripping gets this
|
|
2862
|
+
* wrong in a way that is easy to miss — a `paths` entry like
|
|
2863
|
+
* `"@acme/types/*": ["src/*"]` contains the character sequence that opens a
|
|
2864
|
+
* block comment, so a regex happily eats the rest of the file and the result
|
|
2865
|
+
* parses as *something*, just not the config the developer wrote.
|
|
2866
|
+
*
|
|
2867
|
+
* One level only, and only `paths` is used from it.
|
|
2868
|
+
*/
|
|
2869
|
+
async function readCompilerOptions(projectRoot, file) {
|
|
2870
|
+
if (!fs.existsSync(file)) return void 0;
|
|
2871
|
+
const text = fs.readFileSync(file, "utf8");
|
|
2076
2872
|
try {
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
stdio: "inherit"
|
|
2080
|
-
});
|
|
2873
|
+
const { config } = createRequire(path.join(projectRoot, "package.json"))("typescript").parseConfigFileTextToJson(file, text);
|
|
2874
|
+
return config?.compilerOptions;
|
|
2081
2875
|
} catch {
|
|
2082
|
-
|
|
2083
|
-
|
|
2876
|
+
try {
|
|
2877
|
+
return JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")).compilerOptions;
|
|
2878
|
+
} catch {
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2084
2881
|
}
|
|
2085
2882
|
}
|
|
2086
|
-
//#endregion
|
|
2087
|
-
//#region src/commands/start.ts
|
|
2088
2883
|
/**
|
|
2089
|
-
*
|
|
2884
|
+
* Drop path aliases that resolve outside the project.
|
|
2090
2885
|
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2886
|
+
* A monorepo commonly aliases its workspace packages to their **source**
|
|
2887
|
+
* (`"@acme/types": ["packages/types/src/index.ts"]`) so editors jump to real
|
|
2888
|
+
* files. That is right for developing the monorepo and wrong for building a
|
|
2889
|
+
* bundle: it drags foreign `.ts` files into the program, none of which are under
|
|
2890
|
+
* the project's `rootDir`, and the compile fails on files the developer never
|
|
2891
|
+
* asked to build.
|
|
2892
|
+
*
|
|
2893
|
+
* A bundle is built against *installed packages*. Aliases pointing inside the
|
|
2894
|
+
* project are kept, because those are the project's own code.
|
|
2094
2895
|
*/
|
|
2095
|
-
|
|
2096
|
-
const
|
|
2097
|
-
const
|
|
2098
|
-
const
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
env
|
|
2896
|
+
function filterProjectPaths(baseDir, projectRoot, paths, baseUrl) {
|
|
2897
|
+
const kept = {};
|
|
2898
|
+
const dropped = [];
|
|
2899
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
2900
|
+
if (!Array.isArray(targets)) continue;
|
|
2901
|
+
const resolved = targets.map((target) => path.resolve(baseUrl, target));
|
|
2902
|
+
if (resolved.every((target) => {
|
|
2903
|
+
const relative = path.relative(projectRoot, target);
|
|
2904
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
2905
|
+
})) kept[alias] = resolved.map((target) => {
|
|
2906
|
+
return path.relative(baseDir, target).split(path.sep).join("/");
|
|
2107
2907
|
});
|
|
2108
|
-
|
|
2109
|
-
console.error(chalk.red("\n✗ Failed to start server."));
|
|
2110
|
-
process.exit(1);
|
|
2908
|
+
else dropped.push(alias);
|
|
2111
2909
|
}
|
|
2910
|
+
return {
|
|
2911
|
+
kept,
|
|
2912
|
+
dropped
|
|
2913
|
+
};
|
|
2112
2914
|
}
|
|
2113
|
-
//#endregion
|
|
2114
|
-
//#region src/commands/auth.ts
|
|
2115
2915
|
/**
|
|
2116
|
-
*
|
|
2916
|
+
* Compose the tsconfig used to compile the bundle.
|
|
2117
2917
|
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2918
|
+
* Extends the config package's own tsconfig when there is one, so the project's
|
|
2919
|
+
* choices about target, JSX and strictness are respected. It has to be `extends`
|
|
2920
|
+
* rather than a copy of `compilerOptions`: TypeScript resolves relative paths
|
|
2921
|
+
* against the file they were written in, so copying a value like
|
|
2922
|
+
* `baseUrl: "../../"` into a config in a different directory silently repoints
|
|
2923
|
+
* it at the wrong place.
|
|
2120
2924
|
*/
|
|
2121
|
-
async function
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
return;
|
|
2925
|
+
async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck) {
|
|
2926
|
+
const tsconfigDir = path.join(projectRoot, ".rebase");
|
|
2927
|
+
const fromTsconfig = (target) => {
|
|
2928
|
+
return path.relative(tsconfigDir, path.resolve(projectRoot, target)).split(path.sep).join("/");
|
|
2929
|
+
};
|
|
2930
|
+
const configTsconfigPath = path.join(projectRoot, "config", "tsconfig.json");
|
|
2931
|
+
const extendsFrom = fs.existsSync(configTsconfigPath) ? fromTsconfig(path.join("config", "tsconfig.json")) : void 0;
|
|
2932
|
+
let pathOverrides = {};
|
|
2933
|
+
const baseOptions = await readCompilerOptions(projectRoot, configTsconfigPath);
|
|
2934
|
+
if (baseOptions?.paths && typeof baseOptions.paths === "object") {
|
|
2935
|
+
const baseDir = path.dirname(configTsconfigPath);
|
|
2936
|
+
const baseUrl = path.resolve(baseDir, typeof baseOptions.baseUrl === "string" ? baseOptions.baseUrl : ".");
|
|
2937
|
+
const { kept, dropped } = filterProjectPaths(tsconfigDir, projectRoot, baseOptions.paths, baseUrl);
|
|
2938
|
+
pathOverrides = {
|
|
2939
|
+
baseUrl: fromTsconfig("."),
|
|
2940
|
+
paths: kept
|
|
2941
|
+
};
|
|
2942
|
+
if (dropped.length > 0) console.log(chalk.dim(` ignoring ${dropped.length} path alias(es) pointing outside the project (${dropped.join(", ")}) — resolving those from node_modules instead`));
|
|
2943
|
+
}
|
|
2944
|
+
const compilerOptions = {
|
|
2945
|
+
target: "ES2022",
|
|
2946
|
+
module: "ESNext",
|
|
2947
|
+
moduleResolution: "bundler",
|
|
2948
|
+
lib: ["ES2022"],
|
|
2949
|
+
jsx: "react-jsx",
|
|
2950
|
+
allowSyntheticDefaultImports: true,
|
|
2951
|
+
esModuleInterop: true,
|
|
2952
|
+
resolveJsonModule: true,
|
|
2953
|
+
forceConsistentCasingInFileNames: true,
|
|
2954
|
+
rootDir: fromTsconfig("."),
|
|
2955
|
+
outDir: fromTsconfig(path.relative(projectRoot, outDir) || "."),
|
|
2956
|
+
typeRoots: discoverTypeRoots(projectRoot),
|
|
2957
|
+
...pathOverrides,
|
|
2958
|
+
declaration: false,
|
|
2959
|
+
declarationMap: false,
|
|
2960
|
+
sourceMap: true,
|
|
2961
|
+
noEmit: false,
|
|
2962
|
+
skipLibCheck: true,
|
|
2963
|
+
allowJs: true,
|
|
2964
|
+
...skipTypeCheck ? { noCheck: true } : {}
|
|
2965
|
+
};
|
|
2966
|
+
const tsconfig = {
|
|
2967
|
+
...extendsFrom ? { extends: extendsFrom } : {},
|
|
2968
|
+
compilerOptions,
|
|
2969
|
+
include: includes.map(fromTsconfig),
|
|
2970
|
+
exclude: [
|
|
2971
|
+
"node_modules",
|
|
2972
|
+
"**/*.test.ts",
|
|
2973
|
+
"**/*.spec.ts",
|
|
2974
|
+
"**/dist/**",
|
|
2975
|
+
DEFAULT_BUNDLE_DIR
|
|
2976
|
+
].map((pattern) => pattern.startsWith("**") ? pattern : fromTsconfig(pattern))
|
|
2977
|
+
};
|
|
2978
|
+
fs.mkdirSync(tsconfigDir, { recursive: true });
|
|
2979
|
+
const tsconfigPath = path.join(tsconfigDir, "tsconfig.bundle.json");
|
|
2980
|
+
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2), "utf8");
|
|
2981
|
+
return tsconfigPath;
|
|
2982
|
+
}
|
|
2983
|
+
/**
|
|
2984
|
+
* Whether the compiled config package exports a `storageAuthorize` hook.
|
|
2985
|
+
*
|
|
2986
|
+
* Recorded in the manifest so a host can refuse a deploy that would enable file
|
|
2987
|
+
* storage with no access model, rather than let the runtime's boot guard turn it
|
|
2988
|
+
* into a crash loop the developer cannot read.
|
|
2989
|
+
*
|
|
2990
|
+
* Read from the *compiled* index, deliberately: that is the exact module the
|
|
2991
|
+
* runtime imports and reads the export off, so this cannot disagree with what
|
|
2992
|
+
* actually happens at boot. It is a textual check rather than an import because
|
|
2993
|
+
* a freshly built bundle cannot resolve its own dependencies until it is
|
|
2994
|
+
* deployed — the same reason schema hashing reads source.
|
|
2995
|
+
*
|
|
2996
|
+
* Errs toward `false`: a missed detection costs a deploy rejection whose message
|
|
2997
|
+
* says exactly how to proceed, while a false positive would hand back the crash
|
|
2998
|
+
* loop this exists to prevent.
|
|
2999
|
+
*/
|
|
3000
|
+
function detectStorageAuthorize(compiledConfigDir) {
|
|
3001
|
+
const indexPath = [
|
|
3002
|
+
".js",
|
|
3003
|
+
".mjs",
|
|
3004
|
+
".ts"
|
|
3005
|
+
].map((ext) => path.join(compiledConfigDir, `index${ext}`)).find((candidate) => fs.existsSync(candidate));
|
|
3006
|
+
if (!indexPath) return false;
|
|
3007
|
+
let source;
|
|
3008
|
+
try {
|
|
3009
|
+
source = fs.readFileSync(indexPath, "utf8");
|
|
3010
|
+
} catch {
|
|
3011
|
+
return false;
|
|
3012
|
+
}
|
|
3013
|
+
if (/\bexport\s+(?:async\s+)?(?:const|let|var|function)\s+storageAuthorize\b/.test(source)) return true;
|
|
3014
|
+
for (const clause of source.matchAll(/\bexport\s*\{([^}]*)\}/g)) if (clause[1].split(",").map((entry) => {
|
|
3015
|
+
const parts = entry.split(/\bas\b/);
|
|
3016
|
+
return parts[parts.length - 1].trim();
|
|
3017
|
+
}).includes("storageAuthorize")) return true;
|
|
3018
|
+
return false;
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Detect native code in the dependency closure.
|
|
3022
|
+
*
|
|
3023
|
+
* Walks declared runtime dependencies breadth-first through `node_modules`,
|
|
3024
|
+
* flagging anything with a `binding.gyp`, a prebuilt `.node` binary, or an
|
|
3025
|
+
* install script that builds one. The managed runtime cannot run these: a
|
|
3026
|
+
* binary compiled for one image will not load in another, and finding that out
|
|
3027
|
+
* at deploy time is far better than in a crash loop.
|
|
3028
|
+
*
|
|
3029
|
+
* The walk is bounded. A dependency graph can be enormous, and this is a
|
|
3030
|
+
* heuristic gate whose false negatives are caught at deploy time anyway.
|
|
3031
|
+
*/
|
|
3032
|
+
function detectNativeDependencies(projectRoot, declared, limit = 2e3) {
|
|
3033
|
+
const found = [];
|
|
3034
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3035
|
+
const queue = Object.keys(declared);
|
|
3036
|
+
let visited = 0;
|
|
3037
|
+
const searchRoots = [
|
|
3038
|
+
path.join(projectRoot, "node_modules"),
|
|
3039
|
+
path.join(projectRoot, "backend", "node_modules"),
|
|
3040
|
+
path.join(projectRoot, "config", "node_modules")
|
|
3041
|
+
].filter((dir) => fs.existsSync(dir));
|
|
3042
|
+
while (queue.length > 0 && visited < limit) {
|
|
3043
|
+
const name = queue.shift();
|
|
3044
|
+
if (seen.has(name)) continue;
|
|
3045
|
+
seen.add(name);
|
|
3046
|
+
visited++;
|
|
3047
|
+
if (KNOWN_NATIVE_PACKAGES.has(name)) {
|
|
3048
|
+
found.push({
|
|
3049
|
+
name,
|
|
3050
|
+
reason: "known native module"
|
|
3051
|
+
});
|
|
3052
|
+
continue;
|
|
3053
|
+
}
|
|
3054
|
+
const packageDir = searchRoots.map((root) => path.join(root, ...name.split("/"))).find((dir) => fs.existsSync(path.join(dir, "package.json")));
|
|
3055
|
+
if (!packageDir) continue;
|
|
3056
|
+
let pkg;
|
|
3057
|
+
try {
|
|
3058
|
+
pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
|
|
3059
|
+
} catch {
|
|
3060
|
+
continue;
|
|
3061
|
+
}
|
|
3062
|
+
if (pkg.gypfile || fs.existsSync(path.join(packageDir, "binding.gyp"))) {
|
|
3063
|
+
found.push({
|
|
3064
|
+
name,
|
|
3065
|
+
reason: "builds a native addon (binding.gyp)"
|
|
3066
|
+
});
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
const install = `${pkg.scripts?.install ?? ""} ${pkg.scripts?.preinstall ?? ""} ${pkg.scripts?.postinstall ?? ""}`;
|
|
3070
|
+
if (/node-gyp|prebuild|node-pre-gyp|cmake-js/.test(install)) {
|
|
3071
|
+
found.push({
|
|
3072
|
+
name,
|
|
3073
|
+
reason: "install script compiles native code"
|
|
3074
|
+
});
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
if (hasNodeBinary(packageDir)) {
|
|
3078
|
+
found.push({
|
|
3079
|
+
name,
|
|
3080
|
+
reason: "ships a prebuilt .node binary"
|
|
3081
|
+
});
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
3084
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) if (!seen.has(dep)) queue.push(dep);
|
|
3085
|
+
}
|
|
3086
|
+
return found;
|
|
3087
|
+
}
|
|
3088
|
+
/** Shallow scan for `.node` binaries — deep enough for the usual `build/Release`. */
|
|
3089
|
+
function hasNodeBinary(dir, depth = 0) {
|
|
3090
|
+
if (depth > 3) return false;
|
|
3091
|
+
let entries;
|
|
3092
|
+
try {
|
|
3093
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
3094
|
+
} catch {
|
|
3095
|
+
return false;
|
|
3096
|
+
}
|
|
3097
|
+
for (const entry of entries) {
|
|
3098
|
+
if (entry.isFile() && entry.name.endsWith(".node")) return true;
|
|
3099
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".bin") {
|
|
3100
|
+
if (hasNodeBinary(path.join(dir, entry.name), depth + 1)) return true;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
return false;
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Whether a dependency name resolves to a package *inside this repository* — a
|
|
3107
|
+
* workspace package rather than a registry one.
|
|
3108
|
+
*
|
|
3109
|
+
* The bundle's declared deps are installed with `npm install` from the public
|
|
3110
|
+
* registry beside the bundle at boot. A workspace package is not there, so
|
|
3111
|
+
* declaring it guarantees a boot-time install failure. The most common case is
|
|
3112
|
+
* the standard `config` package: the backend depends on it by name, but it is
|
|
3113
|
+
* *carried in the bundle* (as `entry.config`), so it must never also be an npm
|
|
3114
|
+
* dependency. Projects often express this as a `workspace:` range — caught
|
|
3115
|
+
* separately — but a plain `"*"` against a workspace symlink is just as common
|
|
3116
|
+
* and looks like a registry range, so the symlink is what actually settles it.
|
|
3117
|
+
*
|
|
3118
|
+
* Detection: the installed `node_modules/<name>` is a symlink whose real path is
|
|
3119
|
+
* inside the project and not within a pnpm virtual store (`.pnpm`). That is
|
|
3120
|
+
* exactly a workspace link and nothing else.
|
|
3121
|
+
*/
|
|
3122
|
+
function resolvesToWorkspacePackage(projectRoot, name) {
|
|
3123
|
+
let realRoot;
|
|
3124
|
+
try {
|
|
3125
|
+
realRoot = fs.realpathSync(projectRoot);
|
|
3126
|
+
} catch {
|
|
3127
|
+
realRoot = projectRoot;
|
|
3128
|
+
}
|
|
3129
|
+
for (const base of [
|
|
3130
|
+
projectRoot,
|
|
3131
|
+
path.join(projectRoot, "backend"),
|
|
3132
|
+
path.join(projectRoot, "config")
|
|
3133
|
+
]) {
|
|
3134
|
+
const link = path.join(base, "node_modules", name);
|
|
3135
|
+
try {
|
|
3136
|
+
if (!fs.lstatSync(link).isSymbolicLink()) continue;
|
|
3137
|
+
const real = fs.realpathSync(link);
|
|
3138
|
+
const insideRepo = real.startsWith(realRoot + path.sep);
|
|
3139
|
+
const inStore = real.includes(`${path.sep}.pnpm${path.sep}`) || real.includes(`${path.sep}node_modules${path.sep}`);
|
|
3140
|
+
if (insideRepo && !inStore) return true;
|
|
3141
|
+
} catch {}
|
|
3142
|
+
}
|
|
3143
|
+
return false;
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Collect the runtime dependencies a bundle needs installed beside it.
|
|
3147
|
+
*
|
|
3148
|
+
* Packages the runtime image already provides are excluded — reinstalling a
|
|
3149
|
+
* second copy of the server next to the one running the process is at best
|
|
3150
|
+
* wasted space and at worst a version conflict. Workspace packages are excluded
|
|
3151
|
+
* too: they are not on the registry the runtime installs from, and the project's
|
|
3152
|
+
* own config package already travels inside the bundle.
|
|
3153
|
+
*/
|
|
3154
|
+
function collectDeclaredDependencies(projectRoot) {
|
|
3155
|
+
const declared = {};
|
|
3156
|
+
for (const relative of [
|
|
3157
|
+
"backend/package.json",
|
|
3158
|
+
"config/package.json",
|
|
3159
|
+
"package.json"
|
|
3160
|
+
]) {
|
|
3161
|
+
const file = path.join(projectRoot, relative);
|
|
3162
|
+
if (!fs.existsSync(file)) continue;
|
|
3163
|
+
try {
|
|
3164
|
+
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3165
|
+
for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
|
|
3166
|
+
if (RUNTIME_PROVIDED.has(name)) continue;
|
|
3167
|
+
if (typeof version === "string" && version.startsWith("workspace:")) continue;
|
|
3168
|
+
if (resolvesToWorkspacePackage(projectRoot, name)) continue;
|
|
3169
|
+
declared[name] = version;
|
|
3170
|
+
}
|
|
3171
|
+
} catch {}
|
|
3172
|
+
}
|
|
3173
|
+
return declared;
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* Rewrite relative import specifiers in emitted JavaScript so Node can resolve them.
|
|
3177
|
+
*
|
|
3178
|
+
* TypeScript deliberately does not touch specifiers: `moduleResolution: "bundler"`
|
|
3179
|
+
* lets a project write `from "./posts"` or `from "./collections"`, and TypeScript
|
|
3180
|
+
* emits them unchanged on the assumption that a bundler will finish the job.
|
|
3181
|
+
* Nothing bundles a Rebase bundle — the runtime imports these files directly with
|
|
3182
|
+
* Node's ESM loader, which requires a full path with an extension and refuses
|
|
3183
|
+
* directory imports outright.
|
|
3184
|
+
*
|
|
3185
|
+
* Without this, adopting the bundle would mean asking every project written in
|
|
3186
|
+
* the (extremely common) extensionless style to rewrite all of its imports. The
|
|
3187
|
+
* rewrite is mechanical and verifiable: only relative specifiers are touched, and
|
|
3188
|
+
* only when the target file actually exists on disk.
|
|
3189
|
+
*/
|
|
3190
|
+
function normalizeEsmSpecifiers(outDir) {
|
|
3191
|
+
const unresolved = [];
|
|
3192
|
+
let rewritten = 0;
|
|
3193
|
+
const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(\.[^"']*)\2/g;
|
|
3194
|
+
const walk = (dir) => {
|
|
3195
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
3196
|
+
const full = path.join(dir, entry.name);
|
|
3197
|
+
if (entry.isDirectory()) {
|
|
3198
|
+
if (entry.name === "node_modules") continue;
|
|
3199
|
+
walk(full);
|
|
3200
|
+
} else if (entry.isFile() && entry.name.endsWith(".js")) rewriteFile(full);
|
|
3201
|
+
}
|
|
3202
|
+
};
|
|
3203
|
+
const rewriteFile = (file) => {
|
|
3204
|
+
const original = fs.readFileSync(file, "utf8");
|
|
3205
|
+
const dir = path.dirname(file);
|
|
3206
|
+
const updated = original.replace(SPECIFIER, (match, prefix, quote, specifier) => {
|
|
3207
|
+
if (/\.(js|mjs|cjs|json|node)$/.test(specifier)) return match;
|
|
3208
|
+
const target = path.resolve(dir, specifier);
|
|
3209
|
+
if (fs.existsSync(`${target}.js`)) {
|
|
3210
|
+
rewritten++;
|
|
3211
|
+
return `${prefix}${quote}${specifier}.js${quote}`;
|
|
3212
|
+
}
|
|
3213
|
+
if (fs.existsSync(path.join(target, "index.js"))) {
|
|
3214
|
+
rewritten++;
|
|
3215
|
+
return `${prefix}${quote}${specifier}${specifier.endsWith("/") ? "index.js" : "/index.js"}${quote}`;
|
|
3216
|
+
}
|
|
3217
|
+
if (specifier.endsWith(".ts") && fs.existsSync(`${target.slice(0, -3)}.js`)) {
|
|
3218
|
+
rewritten++;
|
|
3219
|
+
return `${prefix}${quote}${specifier.slice(0, -3)}.js${quote}`;
|
|
3220
|
+
}
|
|
3221
|
+
unresolved.push(`${path.basename(file)} → ${specifier}`);
|
|
3222
|
+
return match;
|
|
3223
|
+
});
|
|
3224
|
+
if (updated !== original) fs.writeFileSync(file, updated, "utf8");
|
|
3225
|
+
};
|
|
3226
|
+
if (fs.existsSync(outDir)) walk(outDir);
|
|
3227
|
+
return {
|
|
3228
|
+
rewritten,
|
|
3229
|
+
unresolved
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Remove a previous build so stale output cannot masquerade as current.
|
|
3234
|
+
*
|
|
3235
|
+
* The containment check matters because this is a recursive force-delete of a
|
|
3236
|
+
* path that came from a command-line flag: `rebase build --out ../..` would
|
|
3237
|
+
* otherwise erase the parent of the project. The manifest's own paths are
|
|
3238
|
+
* checked the same way; a flag deserves no less.
|
|
3239
|
+
*/
|
|
3240
|
+
function cleanOutDir(projectRoot, outDir) {
|
|
3241
|
+
const relative = path.relative(projectRoot, outDir);
|
|
3242
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Refusing to build into "${outDir}": the output directory must be inside the project.`);
|
|
3243
|
+
if (fs.existsSync(outDir)) fs.rmSync(outDir, {
|
|
3244
|
+
recursive: true,
|
|
3245
|
+
force: true
|
|
3246
|
+
});
|
|
3247
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
3248
|
+
}
|
|
3249
|
+
/**
|
|
3250
|
+
* Regenerate the Drizzle schema from the collections.
|
|
3251
|
+
*
|
|
3252
|
+
* Delegated to the database driver's own CLI — the same code `rebase schema
|
|
3253
|
+
* generate` runs — so there is one implementation of what a schema is. When no
|
|
3254
|
+
* driver is resolvable the build continues with a warning rather than failing:
|
|
3255
|
+
* a `baas` project has no schema to generate, and a project mid-install should
|
|
3256
|
+
* get a clear message rather than a hard stop.
|
|
3257
|
+
*/
|
|
3258
|
+
async function regenerateSchema(projectRoot, configDir, options) {
|
|
3259
|
+
const backendDir = path.join(projectRoot, "backend");
|
|
3260
|
+
if (!fs.existsSync(backendDir)) return;
|
|
3261
|
+
const plugin = getActiveBackendPlugin(backendDir);
|
|
3262
|
+
const script = plugin ? resolvePluginCliScript(backendDir, plugin) : null;
|
|
3263
|
+
if (!script) {
|
|
3264
|
+
log(options, chalk.dim(" (no database driver found — skipping schema generation)"));
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
const runner = script.endsWith(".ts") ? resolveTsx(projectRoot) : "node";
|
|
3268
|
+
if (!runner) {
|
|
3269
|
+
log(options, chalk.dim(" (tsx not installed — skipping schema generation)"));
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
const collectionsPath = path.join("..", configDir, "collections");
|
|
3273
|
+
try {
|
|
3274
|
+
await execa(runner, [
|
|
3275
|
+
script,
|
|
3276
|
+
"schema",
|
|
3277
|
+
"generate",
|
|
3278
|
+
"--collections",
|
|
3279
|
+
collectionsPath
|
|
3280
|
+
], {
|
|
3281
|
+
cwd: backendDir,
|
|
3282
|
+
stdio: "pipe"
|
|
3283
|
+
});
|
|
3284
|
+
log(options, chalk.dim(" regenerated database schema from collections"));
|
|
3285
|
+
} catch (err) {
|
|
3286
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3287
|
+
throw new Error(`Schema generation failed, so the bundle was not written.\n${detail}\nRun \`rebase schema generate\` to see the full output, or pass --skip-schema if the committed schema is deliberately hand-maintained.`);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
/**
|
|
3291
|
+
* Compile and assemble a bundle.
|
|
3292
|
+
*/
|
|
3293
|
+
async function buildBundle(options) {
|
|
3294
|
+
const { projectRoot, app, appName } = options;
|
|
3295
|
+
const paths = resolveBackendPaths(app);
|
|
3296
|
+
const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
|
|
3297
|
+
const includes = [];
|
|
3298
|
+
const addIfExists = (relative, pattern) => {
|
|
3299
|
+
if (fs.existsSync(path.join(projectRoot, relative))) includes.push(pattern);
|
|
3300
|
+
};
|
|
3301
|
+
if (paths.mode === "cms") addIfExists(paths.config, `${paths.config}/**/*.ts`);
|
|
3302
|
+
addIfExists(paths.functions, `${paths.functions}/**/*.ts`);
|
|
3303
|
+
addIfExists(paths.crons, `${paths.crons}/**/*.ts`);
|
|
3304
|
+
if (fs.existsSync(path.join(projectRoot, paths.schema))) includes.push(paths.schema);
|
|
3305
|
+
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}".`);
|
|
3306
|
+
if (paths.mode === "cms" && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
|
|
3307
|
+
log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
|
|
3308
|
+
cleanOutDir(projectRoot, outDir);
|
|
3309
|
+
const tsconfigPath = await writeBundleTsconfig(projectRoot, outDir, includes, options.skipTypeCheck === true);
|
|
3310
|
+
const tsc = resolveLocalBin(projectRoot, "tsc");
|
|
3311
|
+
if (!tsc) throw new Error("TypeScript is not installed in this project. Run your package manager's install first.");
|
|
3312
|
+
try {
|
|
3313
|
+
await execa(tsc, ["-p", tsconfigPath], {
|
|
3314
|
+
cwd: projectRoot,
|
|
3315
|
+
stdio: "inherit"
|
|
3316
|
+
});
|
|
3317
|
+
} catch {
|
|
3318
|
+
throw new Error("TypeScript compilation failed — the bundle was not written.");
|
|
3319
|
+
}
|
|
3320
|
+
const normalized = normalizeEsmSpecifiers(outDir);
|
|
3321
|
+
if (normalized.rewritten > 0) log(options, chalk.dim(` resolved ${normalized.rewritten} relative import(s) for Node ESM`));
|
|
3322
|
+
if (normalized.unresolved.length > 0) {
|
|
3323
|
+
console.log(chalk.yellow(` ⚠ ${normalized.unresolved.length} import(s) could not be resolved to a file:`));
|
|
3324
|
+
for (const item of normalized.unresolved.slice(0, 5)) console.log(chalk.dim(` ${item}`));
|
|
3325
|
+
if (normalized.unresolved.length > 5) console.log(chalk.dim(` … and ${normalized.unresolved.length - 5} more`));
|
|
3326
|
+
}
|
|
3327
|
+
const compiledConfigDir = path.join(outDir, paths.config);
|
|
3328
|
+
const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
|
|
3329
|
+
let collections = [];
|
|
3330
|
+
if (paths.mode === "cms") {
|
|
3331
|
+
collections = await loadSourceCollections(path.join(projectRoot, paths.config, "collections"));
|
|
3332
|
+
if (collections.length === 0) throw new Error(`No collections were found in ${path.join(paths.config, "collections")}. A cms-mode project must define at least one collection.`);
|
|
3333
|
+
if (!fs.existsSync(compiledCollectionsDir)) throw new Error(`Compilation produced no collections directory at ${path.relative(projectRoot, compiledCollectionsDir)}.`);
|
|
3334
|
+
}
|
|
3335
|
+
const declared = collectDeclaredDependencies(projectRoot);
|
|
3336
|
+
const nativeModules = detectNativeDependencies(projectRoot, declared);
|
|
3337
|
+
const declaresStorageAuthorize = detectStorageAuthorize(path.join(outDir, paths.config));
|
|
3338
|
+
const schemaOut = paths.schema.replace(/\.ts$/, ".js");
|
|
3339
|
+
const relative = (target) => fs.existsSync(path.join(outDir, target)) ? target : void 0;
|
|
3340
|
+
const manifest = {
|
|
3341
|
+
bundleFormat: BUNDLE_FORMAT_VERSION,
|
|
3342
|
+
runtime: {
|
|
3343
|
+
range: options.runtimeRange,
|
|
3344
|
+
builtAgainst: resolveServerVersion(projectRoot),
|
|
3345
|
+
contract: RUNTIME_CONTRACT_VERSION
|
|
3346
|
+
},
|
|
3347
|
+
schemaVersion: paths.mode === "baas" ? "" : computeSchemaVersion(collections),
|
|
3348
|
+
app: appName,
|
|
3349
|
+
mode: paths.mode,
|
|
3350
|
+
entry: {
|
|
3351
|
+
config: paths.mode === "cms" ? relative(paths.config) : void 0,
|
|
3352
|
+
collections: paths.mode === "cms" ? relative(path.join(paths.config, "collections")) : void 0,
|
|
3353
|
+
functions: relative(paths.functions),
|
|
3354
|
+
crons: relative(paths.crons),
|
|
3355
|
+
schema: relative(schemaOut),
|
|
3356
|
+
usersCollection: paths.mode === "cms" ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
|
|
3357
|
+
},
|
|
3358
|
+
collections: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
|
|
3359
|
+
hooks: {
|
|
3360
|
+
native: nativeModules.length > 0,
|
|
3361
|
+
nativeModules: nativeModules.length > 0 ? nativeModules : void 0
|
|
3362
|
+
},
|
|
3363
|
+
storage: { authorize: declaresStorageAuthorize },
|
|
3364
|
+
deps: { declared },
|
|
3365
|
+
build: {
|
|
3366
|
+
cli: resolveCliVersion(),
|
|
3367
|
+
node: process.versions.node.split(".")[0],
|
|
3368
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3369
|
+
}
|
|
3370
|
+
};
|
|
3371
|
+
fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
3372
|
+
fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
|
|
3373
|
+
name: "rebase-bundle",
|
|
3374
|
+
private: true,
|
|
3375
|
+
type: "module",
|
|
3376
|
+
dependencies: declared
|
|
3377
|
+
}, null, 2)}\n`, "utf8");
|
|
3378
|
+
return {
|
|
3379
|
+
outDir,
|
|
3380
|
+
manifest,
|
|
3381
|
+
collectionCount: collections.length
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Package a built static app (a `static` or bundled-`admin` app) into a bundle.
|
|
3386
|
+
*
|
|
3387
|
+
* A static bundle is the counterpart to a backend bundle: the same shape, the
|
|
3388
|
+
* same runtime image runs it, but its manifest says `mode: "static"` and it
|
|
3389
|
+
* carries only the built assets under `static/`. That is what lets a frontend or
|
|
3390
|
+
* admin app be its own deployable, scalable unit rather than something baked into
|
|
3391
|
+
* the backend container.
|
|
3392
|
+
*
|
|
3393
|
+
* `assetsDir` is the app's built output (e.g. `frontend/dist`), already produced
|
|
3394
|
+
* by its own build command. This copies it into the bundle and writes the
|
|
3395
|
+
* manifest — no compilation, no dependency closure (a static bundle installs
|
|
3396
|
+
* nothing at boot).
|
|
3397
|
+
*/
|
|
3398
|
+
function buildStaticBundle(options) {
|
|
3399
|
+
const { projectRoot, appName, assetsDir, outDir, runtimeRange } = options;
|
|
3400
|
+
cleanOutDir(projectRoot, outDir);
|
|
3401
|
+
const staticOut = path.join(outDir, "static");
|
|
3402
|
+
fs.mkdirSync(staticOut, { recursive: true });
|
|
3403
|
+
fs.cpSync(assetsDir, staticOut, { recursive: true });
|
|
3404
|
+
let fileCount = 0;
|
|
3405
|
+
const count = (dir) => {
|
|
3406
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
|
|
3407
|
+
else fileCount++;
|
|
3408
|
+
};
|
|
3409
|
+
count(staticOut);
|
|
3410
|
+
const manifest = {
|
|
3411
|
+
bundleFormat: BUNDLE_FORMAT_VERSION,
|
|
3412
|
+
runtime: {
|
|
3413
|
+
range: runtimeRange,
|
|
3414
|
+
builtAgainst: resolveServerVersion(projectRoot),
|
|
3415
|
+
contract: RUNTIME_CONTRACT_VERSION
|
|
3416
|
+
},
|
|
3417
|
+
schemaVersion: "",
|
|
3418
|
+
app: appName,
|
|
3419
|
+
mode: "static",
|
|
3420
|
+
entry: { static: "static" },
|
|
3421
|
+
hooks: { native: false },
|
|
3422
|
+
deps: { declared: {} },
|
|
3423
|
+
build: {
|
|
3424
|
+
cli: resolveCliVersion(),
|
|
3425
|
+
node: process.versions.node.split(".")[0],
|
|
3426
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
3430
|
+
fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
|
|
3431
|
+
name: "rebase-bundle",
|
|
3432
|
+
private: true,
|
|
3433
|
+
type: "module",
|
|
3434
|
+
dependencies: {}
|
|
3435
|
+
}, null, 2)}\n`, "utf8");
|
|
3436
|
+
return {
|
|
3437
|
+
outDir,
|
|
3438
|
+
manifest,
|
|
3439
|
+
fileCount
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Which files in a collections directory are collections.
|
|
3444
|
+
*
|
|
3445
|
+
* Mirrors the runtime loader's rules exactly, and must keep mirroring them: the
|
|
3446
|
+
* set of files counted here decides the schema version, and the runtime decides
|
|
3447
|
+
* what it serves the same way. A divergence would show up as a client that is
|
|
3448
|
+
* permanently "out of date" against a server that agrees with it.
|
|
3449
|
+
*
|
|
3450
|
+
* (`._*` guards macOS AppleDouble files, which look like sources and are not.)
|
|
3451
|
+
*/
|
|
3452
|
+
function isCollectionSourceFile(name) {
|
|
3453
|
+
if (name.startsWith(".")) return false;
|
|
3454
|
+
if (name.includes(".test.") || name.includes(".spec.")) return false;
|
|
3455
|
+
if (name.endsWith(".d.ts")) return false;
|
|
3456
|
+
if (name === "index.ts" || name === "index.js") return false;
|
|
3457
|
+
return name.endsWith(".ts") || name.endsWith(".js");
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Load collections from **source**, for hashing and for the manifest's slug list.
|
|
3461
|
+
*
|
|
3462
|
+
* Deliberately not the compiled output. A compiled bundle imports its
|
|
3463
|
+
* dependencies from beside itself — that is the whole point of shipping a
|
|
3464
|
+
* `package.json` with it — but at build time nothing has been installed there
|
|
3465
|
+
* yet, and under pnpm the project's own `node_modules` lives one directory per
|
|
3466
|
+
* package, so the emitted files genuinely cannot resolve their imports until
|
|
3467
|
+
* they are deployed.
|
|
3468
|
+
*
|
|
3469
|
+
* Reading source costs nothing in fidelity: compilation erases types, it does
|
|
3470
|
+
* not change the values a collection module exports, so the hash is the same
|
|
3471
|
+
* either way.
|
|
3472
|
+
*/
|
|
3473
|
+
async function loadSourceCollections(collectionsDir) {
|
|
3474
|
+
if (!fs.existsSync(collectionsDir)) return [];
|
|
3475
|
+
const { createJiti } = await import("jiti");
|
|
3476
|
+
const jiti = createJiti(path.join(collectionsDir, "index.ts"), {
|
|
3477
|
+
interopDefault: true,
|
|
3478
|
+
esmResolve: true
|
|
3479
|
+
});
|
|
3480
|
+
const files = fs.readdirSync(collectionsDir).filter(isCollectionSourceFile).sort();
|
|
3481
|
+
const collections = [];
|
|
3482
|
+
const failures = [];
|
|
3483
|
+
for (const file of files) try {
|
|
3484
|
+
const mod = await jiti.import(path.join(collectionsDir, file));
|
|
3485
|
+
const collection = mod.default ?? mod;
|
|
3486
|
+
if (collection && typeof collection === "object" && "slug" in collection) collections.push(collection);
|
|
3487
|
+
else failures.push(`${file}: no default-exported collection`);
|
|
3488
|
+
} catch (err) {
|
|
3489
|
+
failures.push(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3490
|
+
}
|
|
3491
|
+
if (failures.length > 0) throw new Error(`Could not read ${failures.length} collection file(s):\n` + failures.map((f) => ` • ${f}`).join("\n"));
|
|
3492
|
+
return collections;
|
|
3493
|
+
}
|
|
3494
|
+
/** The `@rebasepro/server` version the project resolves — what it was built against. */
|
|
3495
|
+
function resolveServerVersion(projectRoot) {
|
|
3496
|
+
const candidates = [path.join(projectRoot, "node_modules", "@rebasepro", "server", "package.json"), path.join(projectRoot, "backend", "node_modules", "@rebasepro", "server", "package.json")];
|
|
3497
|
+
for (const candidate of candidates) {
|
|
3498
|
+
if (!fs.existsSync(candidate)) continue;
|
|
3499
|
+
try {
|
|
3500
|
+
return JSON.parse(fs.readFileSync(candidate, "utf8")).version;
|
|
3501
|
+
} catch {}
|
|
3502
|
+
}
|
|
3503
|
+
return "unknown";
|
|
3504
|
+
}
|
|
3505
|
+
function resolveCliVersion() {
|
|
3506
|
+
try {
|
|
3507
|
+
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
3508
|
+
for (let i = 0; i < 5; i++) {
|
|
3509
|
+
const candidate = path.join(dir, "package.json");
|
|
3510
|
+
if (fs.existsSync(candidate)) {
|
|
3511
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
3512
|
+
if (pkg.name === "@rebasepro/cli" && pkg.version) return pkg.version;
|
|
3513
|
+
}
|
|
3514
|
+
dir = path.dirname(dir);
|
|
3515
|
+
}
|
|
3516
|
+
} catch {}
|
|
3517
|
+
return "unknown";
|
|
3518
|
+
}
|
|
3519
|
+
//#endregion
|
|
3520
|
+
//#region src/commands/build.ts
|
|
3521
|
+
/**
|
|
3522
|
+
* CLI command: rebase build [app...]
|
|
3523
|
+
*
|
|
3524
|
+
* Builds the apps a repository declares in `rebase.json`.
|
|
3525
|
+
*
|
|
3526
|
+
* For a `backend` app this produces a **bundle** — compiled collections,
|
|
3527
|
+
* functions and schema plus a manifest — which is the artifact the runtime
|
|
3528
|
+
* loads. For `static` and bundled `admin` apps it runs the declared build
|
|
3529
|
+
* command and reports where the output landed.
|
|
3530
|
+
*
|
|
3531
|
+
* A project with no manifest, or one whose backend has been ejected to its own
|
|
3532
|
+
* entrypoint, falls back to the previous behaviour: run every workspace's own
|
|
3533
|
+
* `build` script. Nothing that built before stops building.
|
|
3534
|
+
*/
|
|
3535
|
+
function printHelp$3() {
|
|
3536
|
+
console.log(`
|
|
3537
|
+
${chalk.bold("rebase build")} — build the apps declared in rebase.json
|
|
3538
|
+
|
|
3539
|
+
${chalk.bold("Usage")}
|
|
3540
|
+
rebase build [app...] Build the named apps (default: all)
|
|
3541
|
+
|
|
3542
|
+
${chalk.bold("Options")}
|
|
3543
|
+
--out <dir> Bundle output directory (default: ${DEFAULT_BUNDLE_DIR})
|
|
3544
|
+
--skip-type-check Compile without type checking (faster; use for iteration only)
|
|
3545
|
+
--skip-schema Do not regenerate the database schema from collections
|
|
3546
|
+
--legacy Run every workspace's own build script instead
|
|
3547
|
+
-h, --help Show this help
|
|
3548
|
+
|
|
3549
|
+
${chalk.bold("Examples")}
|
|
3550
|
+
rebase build Build every app in this repository
|
|
3551
|
+
rebase build backend Build only the backend bundle
|
|
3552
|
+
rebase build web Build only the "web" static app
|
|
3553
|
+
`.trim());
|
|
3554
|
+
}
|
|
3555
|
+
async function buildCommand(rawArgs = []) {
|
|
3556
|
+
const args = arg({
|
|
3557
|
+
"--out": String,
|
|
3558
|
+
"--skip-type-check": Boolean,
|
|
3559
|
+
"--skip-schema": Boolean,
|
|
3560
|
+
"--legacy": Boolean,
|
|
3561
|
+
"--help": Boolean,
|
|
3562
|
+
"-h": "--help"
|
|
3563
|
+
}, {
|
|
3564
|
+
argv: rawArgs.slice(3),
|
|
3565
|
+
permissive: true
|
|
3566
|
+
});
|
|
3567
|
+
if (args["--help"]) {
|
|
3568
|
+
printHelp$3();
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
const projectRoot = requireProjectRoot();
|
|
3572
|
+
if (args["--legacy"]) {
|
|
3573
|
+
await runWorkspaceBuilds(projectRoot);
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
let loaded;
|
|
3577
|
+
try {
|
|
3578
|
+
loaded = loadManifest(projectRoot);
|
|
3579
|
+
} catch (err) {
|
|
3580
|
+
if (err instanceof ManifestError) {
|
|
3581
|
+
console.error(chalk.red(`✗ ${err.message}`));
|
|
3582
|
+
for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
|
|
3583
|
+
process.exit(1);
|
|
3584
|
+
}
|
|
3585
|
+
throw err;
|
|
3586
|
+
}
|
|
3587
|
+
const { manifest, source } = loaded;
|
|
3588
|
+
const requested = args._.filter((a) => !a.startsWith("-"));
|
|
3589
|
+
let targets = buildableApps(manifest);
|
|
3590
|
+
if (requested.length > 0) {
|
|
3591
|
+
const known = new Set(targets.map((t) => t.name));
|
|
3592
|
+
const unknown = requested.filter((name) => !known.has(name));
|
|
3593
|
+
if (unknown.length > 0) {
|
|
3594
|
+
console.error(chalk.red(`✗ Unknown app(s): ${unknown.join(", ")}`));
|
|
3595
|
+
console.error(chalk.dim(` This repository declares: ${targets.map((t) => t.name).join(", ") || "(none)"}`));
|
|
3596
|
+
process.exit(1);
|
|
3597
|
+
}
|
|
3598
|
+
targets = targets.filter((t) => requested.includes(t.name));
|
|
3599
|
+
}
|
|
3600
|
+
if (targets.length === 0) {
|
|
3601
|
+
console.log(chalk.yellow("No buildable apps declared. Nothing to do."));
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
if (!findBackendApp(manifest) && source === "synthesized") {
|
|
3605
|
+
console.log(chalk.dim("No rebase.json found — building workspace packages.\n"));
|
|
3606
|
+
await runWorkspaceBuilds(projectRoot);
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3609
|
+
console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
|
|
3610
|
+
for (const { name, app } of targets) {
|
|
3611
|
+
console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
|
|
3612
|
+
if (app.type === "backend") {
|
|
3613
|
+
const result = await buildBundle({
|
|
3614
|
+
projectRoot,
|
|
3615
|
+
appName: name,
|
|
3616
|
+
app,
|
|
3617
|
+
outDir: args["--out"],
|
|
3618
|
+
runtimeRange: manifest.runtime,
|
|
3619
|
+
skipTypeCheck: args["--skip-type-check"],
|
|
3620
|
+
skipSchema: args["--skip-schema"]
|
|
3621
|
+
});
|
|
3622
|
+
const rel = path.relative(projectRoot, result.outDir);
|
|
3623
|
+
console.log(chalk.green(` ✓ bundle → ${rel}/`));
|
|
3624
|
+
console.log(chalk.dim(` ${result.collectionCount} collection(s), schema ${result.manifest.schemaVersion}`));
|
|
3625
|
+
if (result.manifest.hooks.native) {
|
|
3626
|
+
const names = (result.manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
|
|
3627
|
+
console.log(chalk.yellow(` ⚠ native dependencies detected: ${names}`));
|
|
3628
|
+
console.log(chalk.dim(" These cannot run on the managed runtime. See `rebase doctor`."));
|
|
3629
|
+
}
|
|
3630
|
+
} else if (app.type === "static" || app.type === "admin") await buildAssetApp(projectRoot, name, app, manifest.runtime, args["--out"]);
|
|
3631
|
+
else if (app.type === "custom") console.log(chalk.dim(" custom container — built at deploy time from its Dockerfile"));
|
|
3632
|
+
console.log("");
|
|
3633
|
+
}
|
|
3634
|
+
console.log(chalk.green("✓ Build complete."));
|
|
3635
|
+
}
|
|
3636
|
+
/**
|
|
3637
|
+
* Build a static or bundled-admin app and package it into a static bundle.
|
|
3638
|
+
*
|
|
3639
|
+
* Runs the app's own build command, checks it produced the declared output, then
|
|
3640
|
+
* packages that output into a `static`-mode bundle — the same deployable shape as
|
|
3641
|
+
* a backend bundle, so a frontend or admin app deploys through the identical
|
|
3642
|
+
* path and runs on the identical image, just serving files instead of an API.
|
|
3643
|
+
*/
|
|
3644
|
+
async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
|
|
3645
|
+
const asset = app;
|
|
3646
|
+
if (app.type === "admin" && app.mode !== "bundled") {
|
|
3647
|
+
console.log(chalk.dim(" hosted admin panel — nothing to build"));
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (!asset.build) {
|
|
3651
|
+
console.log(chalk.dim(" no build command declared — skipping"));
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
try {
|
|
3655
|
+
await execa(asset.build, {
|
|
3656
|
+
cwd: projectRoot,
|
|
3657
|
+
stdio: "inherit",
|
|
3658
|
+
shell: true
|
|
3659
|
+
});
|
|
3660
|
+
} catch {
|
|
3661
|
+
console.error(chalk.red(` ✗ build command failed for "${name}"`));
|
|
3662
|
+
process.exit(1);
|
|
3663
|
+
}
|
|
3664
|
+
if (!asset.output) {
|
|
3665
|
+
console.log(chalk.yellow(" no output directory declared — built, but nothing to bundle"));
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
const outputPath = path.join(projectRoot, asset.output);
|
|
3669
|
+
if (!fs.existsSync(outputPath)) {
|
|
3670
|
+
console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
|
|
3671
|
+
process.exit(1);
|
|
3672
|
+
}
|
|
3673
|
+
const result = buildStaticBundle({
|
|
3674
|
+
projectRoot,
|
|
3675
|
+
appName: name,
|
|
3676
|
+
assetsDir: outputPath,
|
|
3677
|
+
outDir: outOverride ? path.resolve(process.cwd(), outOverride) : path.join(projectRoot, `dist-bundle-${name}`),
|
|
3678
|
+
runtimeRange
|
|
3679
|
+
});
|
|
3680
|
+
const rel = path.relative(projectRoot, result.outDir);
|
|
3681
|
+
console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s))`));
|
|
3682
|
+
}
|
|
3683
|
+
/** The pre-manifest behaviour: build every workspace package. */
|
|
3684
|
+
async function runWorkspaceBuilds(projectRoot) {
|
|
3685
|
+
const pm = detectPackageManager(projectRoot);
|
|
3686
|
+
const buildCmd = getPMCommands(pm).runAll("build");
|
|
3687
|
+
console.log(`${chalk.bold("Rebase")} — Building all workspaces with ${chalk.cyan(pm)}...\n`);
|
|
3688
|
+
try {
|
|
3689
|
+
await execa(buildCmd[0], buildCmd.slice(1), {
|
|
3690
|
+
cwd: projectRoot,
|
|
3691
|
+
stdio: "inherit"
|
|
3692
|
+
});
|
|
3693
|
+
} catch {
|
|
3694
|
+
console.error(chalk.red("\n✗ Build failed."));
|
|
3695
|
+
process.exit(1);
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
//#endregion
|
|
3699
|
+
//#region src/commands/start.ts
|
|
3700
|
+
/**
|
|
3701
|
+
* CLI command: rebase start
|
|
3702
|
+
*
|
|
3703
|
+
* Runs a built bundle through the Rebase runtime — the same path the official
|
|
3704
|
+
* container image takes, so what you test locally is what a deployment runs.
|
|
3705
|
+
*
|
|
3706
|
+
* When there is no bundle (an ejected backend, or a project that has not adopted
|
|
3707
|
+
* `rebase.json`) this falls back to the backend workspace's own `start` script,
|
|
3708
|
+
* which is what such a project has always used.
|
|
3709
|
+
*/
|
|
3710
|
+
function printHelp$2() {
|
|
3711
|
+
console.log(`
|
|
3712
|
+
${chalk.bold("rebase start")} — run a built bundle
|
|
3713
|
+
|
|
3714
|
+
${chalk.bold("Usage")}
|
|
3715
|
+
rebase start [options]
|
|
3716
|
+
|
|
3717
|
+
${chalk.bold("Options")}
|
|
3718
|
+
--bundle <dir> Bundle directory (default: ${DEFAULT_BUNDLE_DIR})
|
|
3719
|
+
--legacy Run the backend workspace's own start script
|
|
3720
|
+
-h, --help Show this help
|
|
3721
|
+
|
|
3722
|
+
Build first with ${chalk.cyan("rebase build")}.
|
|
3723
|
+
`.trim());
|
|
3724
|
+
}
|
|
3725
|
+
async function startCommand(rawArgs = []) {
|
|
3726
|
+
const args = arg({
|
|
3727
|
+
"--bundle": String,
|
|
3728
|
+
"--legacy": Boolean,
|
|
3729
|
+
"--help": Boolean,
|
|
3730
|
+
"-h": "--help"
|
|
3731
|
+
}, {
|
|
3732
|
+
argv: rawArgs.slice(3),
|
|
3733
|
+
permissive: true
|
|
3734
|
+
});
|
|
3735
|
+
if (args["--help"]) {
|
|
3736
|
+
printHelp$2();
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3739
|
+
const projectRoot = requireProjectRoot();
|
|
3740
|
+
const envFile = findEnvFile(projectRoot);
|
|
3741
|
+
const env = { ...process.env };
|
|
3742
|
+
if (envFile) env.DOTENV_CONFIG_PATH = envFile;
|
|
3743
|
+
const bundleDir = path.resolve(projectRoot, args["--bundle"] ?? "dist-bundle");
|
|
3744
|
+
const hasBundle = fs.existsSync(path.join(bundleDir, "manifest.json"));
|
|
3745
|
+
if (args["--legacy"] || !hasBundle) {
|
|
3746
|
+
if (!args["--legacy"] && !hasBundle) console.log(chalk.dim(`No bundle at ${path.relative(projectRoot, bundleDir)}/ — starting the backend workspace instead.
|
|
3747
|
+
`));
|
|
3748
|
+
await startWorkspaceBackend(projectRoot, env);
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
ensureBundleDependencies(projectRoot, bundleDir);
|
|
3752
|
+
console.log(`${chalk.bold("Rebase")} — starting runtime from ${chalk.cyan(path.relative(projectRoot, bundleDir))}/\n`);
|
|
3753
|
+
if (envFile && fs.existsSync(envFile)) (await import("dotenv")).config({ path: envFile });
|
|
3754
|
+
process.env.REBASE_BUNDLE = bundleDir;
|
|
3755
|
+
try {
|
|
3756
|
+
const { runFromBundle } = await import("@rebasepro/server");
|
|
3757
|
+
await runFromBundle({ bundleDir });
|
|
3758
|
+
} catch (err) {
|
|
3759
|
+
console.error(chalk.red("\n✗ Failed to start the runtime."));
|
|
3760
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
3761
|
+
process.exit(1);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* Make a bundle's imports resolvable for a local run.
|
|
3766
|
+
*
|
|
3767
|
+
* Node resolves a module by walking up from the *importing file*, so compiled
|
|
3768
|
+
* code sitting in `dist-bundle/` no longer sees the per-package `node_modules`
|
|
3769
|
+
* its source could: pnpm and npm both install a workspace package's
|
|
3770
|
+
* dependencies inside that package, and the bundle is not inside any of them.
|
|
3771
|
+
*
|
|
3772
|
+
* A deployment solves this by installing the bundle's own `package.json` beside
|
|
3773
|
+
* it — that is what the generated `package.json` is for. Locally, doing a second
|
|
3774
|
+
* install to run code whose dependencies are already on disk would be wasteful,
|
|
3775
|
+
* so this links what is already there instead.
|
|
3776
|
+
*
|
|
3777
|
+
* Only ever created when absent, and only under the bundle directory, so a real
|
|
3778
|
+
* install always wins and nothing here is ever uploaded (`rebase build` cleans
|
|
3779
|
+
* the directory and never writes this).
|
|
3780
|
+
*/
|
|
3781
|
+
function ensureBundleDependencies(projectRoot, bundleDir) {
|
|
3782
|
+
const target = path.join(bundleDir, "node_modules");
|
|
3783
|
+
if (fs.existsSync(target)) return;
|
|
3784
|
+
const sources = [
|
|
3785
|
+
"backend/node_modules",
|
|
3786
|
+
"config/node_modules",
|
|
3787
|
+
"node_modules"
|
|
3788
|
+
].map((relative) => path.join(projectRoot, relative)).filter((dir) => fs.existsSync(dir));
|
|
3789
|
+
if (sources.length === 0) return;
|
|
3790
|
+
let linked = 0;
|
|
3791
|
+
fs.mkdirSync(target, { recursive: true });
|
|
3792
|
+
const linkInto = (sourceDir, targetDir) => {
|
|
3793
|
+
let entries;
|
|
3794
|
+
try {
|
|
3795
|
+
entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
3796
|
+
} catch {
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
for (const entry of entries) {
|
|
3800
|
+
if (entry.name === ".bin" || entry.name.startsWith(".")) continue;
|
|
3801
|
+
const from = path.join(sourceDir, entry.name);
|
|
3802
|
+
const to = path.join(targetDir, entry.name);
|
|
3803
|
+
if (entry.name.startsWith("@") && entry.isDirectory()) {
|
|
3804
|
+
fs.mkdirSync(to, { recursive: true });
|
|
3805
|
+
linkInto(from, to);
|
|
3806
|
+
continue;
|
|
3807
|
+
}
|
|
3808
|
+
if (fs.existsSync(to)) continue;
|
|
3809
|
+
try {
|
|
3810
|
+
fs.symlinkSync(fs.realpathSync(from), to, "junction");
|
|
3811
|
+
linked++;
|
|
3812
|
+
} catch {}
|
|
3813
|
+
}
|
|
3814
|
+
};
|
|
3815
|
+
for (const source of sources) linkInto(source, target);
|
|
3816
|
+
if (linked > 0) console.log(chalk.dim(` linked ${linked} package(s) into the bundle for this local run\n (a deployment installs the bundle's package.json instead)
|
|
3817
|
+
`));
|
|
3818
|
+
}
|
|
3819
|
+
async function startWorkspaceBackend(projectRoot, env) {
|
|
3820
|
+
const startCmd = getPMCommands(detectPackageManager(projectRoot)).runWorkspace("backend", "start");
|
|
3821
|
+
console.log(`${chalk.bold("Rebase")} — Starting backend server...\n`);
|
|
3822
|
+
try {
|
|
3823
|
+
await execa(startCmd[0], startCmd.slice(1), {
|
|
3824
|
+
cwd: projectRoot,
|
|
3825
|
+
stdio: "inherit",
|
|
3826
|
+
env
|
|
3827
|
+
});
|
|
3828
|
+
} catch {
|
|
3829
|
+
console.error(chalk.red("\n✗ Failed to start server."));
|
|
3830
|
+
process.exit(1);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
//#endregion
|
|
3834
|
+
//#region src/commands/auth.ts
|
|
3835
|
+
/**
|
|
3836
|
+
* CLI command: rebase auth <action>
|
|
3837
|
+
*
|
|
3838
|
+
* Subcommands:
|
|
3839
|
+
* reset-password — Reset a user's password
|
|
3840
|
+
*/
|
|
3841
|
+
async function authCommand(subcommand, rawArgs) {
|
|
3842
|
+
if (!subcommand || subcommand === "--help") {
|
|
3843
|
+
printAuthHelp();
|
|
3844
|
+
return;
|
|
2125
3845
|
}
|
|
2126
3846
|
switch (subcommand) {
|
|
2127
3847
|
case "reset-password":
|
|
@@ -2974,6 +4694,55 @@ async function whoamiCommand(rawArgs) {
|
|
|
2974
4694
|
* `link` associates the current directory with a cloud project by writing
|
|
2975
4695
|
* `.rebase/cloud.json`; deploy/logs/status then operate on it with no flags.
|
|
2976
4696
|
*/
|
|
4697
|
+
/**
|
|
4698
|
+
* Link this checkout straight at a running backend.
|
|
4699
|
+
*
|
|
4700
|
+
* No control plane, no authentication, no project id — just the URL of a Rebase
|
|
4701
|
+
* API. This is what makes the multi-repo workflow available to self-hosters: a
|
|
4702
|
+
* frontend repository links to `https://api.example.com` and then generates its
|
|
4703
|
+
* typed SDK from that project exactly as a cloud-linked repository would.
|
|
4704
|
+
*
|
|
4705
|
+
* The URL is verified before it is written. Recording an unreachable address and
|
|
4706
|
+
* failing later, in a different command, would be a worse experience than
|
|
4707
|
+
* failing here where the user can see what they typed.
|
|
4708
|
+
*/
|
|
4709
|
+
async function linkDirect(target, rawArgs) {
|
|
4710
|
+
let base;
|
|
4711
|
+
try {
|
|
4712
|
+
base = new URL(target);
|
|
4713
|
+
} catch {
|
|
4714
|
+
fail(`"${target}" is not a valid URL.`);
|
|
4715
|
+
return;
|
|
4716
|
+
}
|
|
4717
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") fail("A project URL must be http or https.");
|
|
4718
|
+
const apiUrl = base.toString().replace(/\/+$/, "");
|
|
4719
|
+
const probe = `${apiUrl}/api/meta/schema-version`;
|
|
4720
|
+
let reachable = false;
|
|
4721
|
+
let detail = "";
|
|
4722
|
+
try {
|
|
4723
|
+
const response = await fetch(probe, { headers: { accept: "application/json" } });
|
|
4724
|
+
reachable = response.ok;
|
|
4725
|
+
if (!response.ok) detail = `responded ${response.status}`;
|
|
4726
|
+
} catch (err) {
|
|
4727
|
+
detail = err instanceof Error ? err.message : String(err);
|
|
4728
|
+
}
|
|
4729
|
+
if (!reachable) {
|
|
4730
|
+
console.log(chalk.yellow(`⚠ Could not reach ${probe}${detail ? ` (${detail})` : ""}.`));
|
|
4731
|
+
console.log(chalk.dim(" Linking anyway — the server may not be running yet."));
|
|
4732
|
+
console.log(chalk.dim(" It must be a Rebase backend of version 0.11 or newer."));
|
|
4733
|
+
}
|
|
4734
|
+
writeLink({
|
|
4735
|
+
url: apiUrl,
|
|
4736
|
+
projectId: "",
|
|
4737
|
+
apiUrl,
|
|
4738
|
+
mode: "direct",
|
|
4739
|
+
projectName: base.host
|
|
4740
|
+
});
|
|
4741
|
+
success(`Linked to ${apiUrl}`);
|
|
4742
|
+
console.log(chalk.dim(` Written to ${projectLinkPath()}`));
|
|
4743
|
+
console.log("");
|
|
4744
|
+
console.log(`Next: ${chalk.cyan("rebase generate-sdk --from link")}`);
|
|
4745
|
+
}
|
|
2977
4746
|
async function linkCommand(rawArgs) {
|
|
2978
4747
|
const args = arg({
|
|
2979
4748
|
"--project": String,
|
|
@@ -2982,6 +4751,11 @@ async function linkCommand(rawArgs) {
|
|
|
2982
4751
|
argv: rawArgs.slice(3),
|
|
2983
4752
|
permissive: true
|
|
2984
4753
|
});
|
|
4754
|
+
const positional = args._.find((value) => /^https?:\/\//i.test(value));
|
|
4755
|
+
if (positional) {
|
|
4756
|
+
await linkDirect(positional, rawArgs);
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
2985
4759
|
const { client, url } = await requireClient(rawArgs);
|
|
2986
4760
|
try {
|
|
2987
4761
|
let project;
|
|
@@ -2997,7 +4771,7 @@ async function linkCommand(rawArgs) {
|
|
|
2997
4771
|
})).data;
|
|
2998
4772
|
if (projects.length === 0) fail("No projects found for your account.", `Create one with ${chalk.bold("rebase cloud projects create")}.`);
|
|
2999
4773
|
const { picked } = await inquirer.prompt([{
|
|
3000
|
-
type: "
|
|
4774
|
+
type: "select",
|
|
3001
4775
|
name: "picked",
|
|
3002
4776
|
message: "Select a project to link:",
|
|
3003
4777
|
choices: projects.map((p) => ({
|
|
@@ -3041,7 +4815,7 @@ async function selectOrgCommand(rawArgs) {
|
|
|
3041
4815
|
let chosen = target ? orgs.find((o) => String(o.id) === target || o.slug === target) : void 0;
|
|
3042
4816
|
if (!chosen && !target) {
|
|
3043
4817
|
const { picked } = await inquirer.prompt([{
|
|
3044
|
-
type: "
|
|
4818
|
+
type: "select",
|
|
3045
4819
|
name: "picked",
|
|
3046
4820
|
message: "Select the active organization:",
|
|
3047
4821
|
choices: orgs.map((o) => ({
|
|
@@ -3277,6 +5051,101 @@ function fmtDate(value) {
|
|
|
3277
5051
|
return isNaN(d.getTime()) ? value : d.toLocaleString();
|
|
3278
5052
|
}
|
|
3279
5053
|
//#endregion
|
|
5054
|
+
//#region src/commands/cloud/bundle-deploy.ts
|
|
5055
|
+
/**
|
|
5056
|
+
* Deploying a project as a managed **bundle** rather than a source build.
|
|
5057
|
+
*
|
|
5058
|
+
* `rebase cloud deploy --bundle` builds the bundle, tars it, uploads it to the
|
|
5059
|
+
* control plane's bundle endpoint, and triggers a deploy carrying the bundle id
|
|
5060
|
+
* and its generated manifest. The control plane resolves a runtime from the
|
|
5061
|
+
* manifest's range and runs the platform image with this bundle — the managed
|
|
5062
|
+
* path. A project not in managed mode, or one whose bundle fails intake, is told
|
|
5063
|
+
* so by the control plane; this side just packages and hands it over.
|
|
5064
|
+
*
|
|
5065
|
+
* The pieces here are separated from the network calls so they can be tested: the
|
|
5066
|
+
* manifest read, the tar packaging, and the request body assembly are pure enough
|
|
5067
|
+
* to check without a control plane.
|
|
5068
|
+
*/
|
|
5069
|
+
/** Read and shallow-validate a built bundle's manifest. */
|
|
5070
|
+
function readBundleManifest(bundleDir) {
|
|
5071
|
+
const manifestPath = path.join(bundleDir, "manifest.json");
|
|
5072
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`No manifest.json in ${bundleDir}. Run \`rebase build\` first.`);
|
|
5073
|
+
let manifest;
|
|
5074
|
+
try {
|
|
5075
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
5076
|
+
} catch (err) {
|
|
5077
|
+
throw new Error(`${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
5078
|
+
}
|
|
5079
|
+
if (typeof manifest.bundleFormat !== "number" || !manifest.runtime?.range) throw new Error(`${manifestPath} is not a valid bundle manifest.`);
|
|
5080
|
+
return manifest;
|
|
5081
|
+
}
|
|
5082
|
+
/**
|
|
5083
|
+
* Tar a built bundle into a gzipped archive.
|
|
5084
|
+
*
|
|
5085
|
+
* `node_modules` is excluded on purpose: the bundle ships a `package.json`, and
|
|
5086
|
+
* the managed runtime installs the declared dependencies at boot. Uploading an
|
|
5087
|
+
* installed `node_modules` would bloat the archive and could carry a
|
|
5088
|
+
* platform-specific build that will not run on the runtime image.
|
|
5089
|
+
*/
|
|
5090
|
+
function packBundle(bundleDir, outPath) {
|
|
5091
|
+
return new Promise((resolve, reject) => {
|
|
5092
|
+
const child = spawn("tar", [
|
|
5093
|
+
"-czf",
|
|
5094
|
+
outPath,
|
|
5095
|
+
"--exclude",
|
|
5096
|
+
"node_modules",
|
|
5097
|
+
"-C",
|
|
5098
|
+
bundleDir,
|
|
5099
|
+
"."
|
|
5100
|
+
], {
|
|
5101
|
+
stdio: "inherit",
|
|
5102
|
+
env: {
|
|
5103
|
+
...process.env,
|
|
5104
|
+
COPYFILE_DISABLE: "1"
|
|
5105
|
+
}
|
|
5106
|
+
});
|
|
5107
|
+
child.on("error", reject);
|
|
5108
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`tar exited ${code}`)));
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
5111
|
+
/**
|
|
5112
|
+
* Assemble the deploy-trigger body for a bundle deploy.
|
|
5113
|
+
*
|
|
5114
|
+
* The manifest travels with the trigger so the control plane can validate intake
|
|
5115
|
+
* without unpacking the uploaded archive first — a rejection (native deps, no
|
|
5116
|
+
* matching runtime) is then a fast, cheap answer.
|
|
5117
|
+
*/
|
|
5118
|
+
function bundleDeployBody(input) {
|
|
5119
|
+
return {
|
|
5120
|
+
projectId: input.projectId,
|
|
5121
|
+
bundleId: input.bundleId,
|
|
5122
|
+
bundleManifest: input.manifest,
|
|
5123
|
+
app: input.app ?? input.manifest.app ?? "backend",
|
|
5124
|
+
client: "cli",
|
|
5125
|
+
frameworkVersion: input.manifest.runtime?.builtAgainst,
|
|
5126
|
+
...input.message ? { message: input.message } : {}
|
|
5127
|
+
};
|
|
5128
|
+
}
|
|
5129
|
+
/** Upload a bundle archive; returns the control-plane bundle id. */
|
|
5130
|
+
async function uploadBundle(url, token, projectId, tarPath) {
|
|
5131
|
+
const bytes = fs.readFileSync(tarPath);
|
|
5132
|
+
const res = await fetch(`${url}/api/functions/deploy/bundle/upload?projectId=${encodeURIComponent(projectId)}`, {
|
|
5133
|
+
method: "POST",
|
|
5134
|
+
headers: {
|
|
5135
|
+
Authorization: `Bearer ${token}`,
|
|
5136
|
+
"Content-Type": "application/gzip"
|
|
5137
|
+
},
|
|
5138
|
+
body: bytes
|
|
5139
|
+
});
|
|
5140
|
+
if (!res.ok) {
|
|
5141
|
+
const body = await res.text().catch(() => "");
|
|
5142
|
+
throw new Error(`Bundle upload failed (${res.status}): ${body || res.statusText}`);
|
|
5143
|
+
}
|
|
5144
|
+
const data = await res.json();
|
|
5145
|
+
if (!data.bundleId) throw new Error("Bundle upload endpoint did not return a bundle id.");
|
|
5146
|
+
return data.bundleId;
|
|
5147
|
+
}
|
|
5148
|
+
//#endregion
|
|
3280
5149
|
//#region src/commands/cloud/deploy.ts
|
|
3281
5150
|
/**
|
|
3282
5151
|
* `rebase cloud deploy` and `rebase cloud logs`.
|
|
@@ -3334,6 +5203,35 @@ async function createSourceTarball(sourceDir) {
|
|
|
3334
5203
|
}
|
|
3335
5204
|
return tarPath;
|
|
3336
5205
|
}
|
|
5206
|
+
/**
|
|
5207
|
+
* The `@rebasepro/*` version this source directory actually resolves.
|
|
5208
|
+
*
|
|
5209
|
+
* Recorded on the deployment so a row in Deployment History says which
|
|
5210
|
+
* framework build shipped. Nothing else on the platform knows: an app that
|
|
5211
|
+
* links the framework locally pins it at package time, and a silent bump is
|
|
5212
|
+
* invisible afterwards — it has already cost one debugging session.
|
|
5213
|
+
*
|
|
5214
|
+
* `@rebasepro/server` first, because that is what the deployed backend runs;
|
|
5215
|
+
* `@rebasepro/client` is the fallback for a frontend-only bundle. Resolution is
|
|
5216
|
+
* a plain walk up from the source directory rather than `require.resolve`,
|
|
5217
|
+
* which would answer for the CLI's own install tree instead of the app's.
|
|
5218
|
+
*
|
|
5219
|
+
* Best effort by construction: a version that cannot be read is simply not
|
|
5220
|
+
* recorded. Nothing about a deploy should fail over a bookkeeping string.
|
|
5221
|
+
*/
|
|
5222
|
+
function resolveFrameworkVersion(sourceDir) {
|
|
5223
|
+
let dir = path.resolve(sourceDir);
|
|
5224
|
+
for (;;) {
|
|
5225
|
+
for (const pkg of ["@rebasepro/server", "@rebasepro/client"]) try {
|
|
5226
|
+
const manifest = path.join(dir, "node_modules", ...pkg.split("/"), "package.json");
|
|
5227
|
+
const version = JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
5228
|
+
if (typeof version === "string" && version.trim() !== "") return version.trim();
|
|
5229
|
+
} catch {}
|
|
5230
|
+
const parent = path.dirname(dir);
|
|
5231
|
+
if (parent === dir) return void 0;
|
|
5232
|
+
dir = parent;
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
3337
5235
|
/** Upload a build-context tarball; returns the opaque `source` ref for deploy. */
|
|
3338
5236
|
async function uploadSource(url, token, projectId, tarPath) {
|
|
3339
5237
|
const bytes = fs.readFileSync(tarPath);
|
|
@@ -3356,16 +5254,100 @@ async function uploadSource(url, token, projectId, tarPath) {
|
|
|
3356
5254
|
if (!data.source) fail("Upload endpoint did not return a source reference.");
|
|
3357
5255
|
return data.source;
|
|
3358
5256
|
}
|
|
5257
|
+
/**
|
|
5258
|
+
* Build, upload and deploy a project as a managed bundle.
|
|
5259
|
+
*
|
|
5260
|
+
* Builds the backend app into `dist-bundle` (unless one is pointed at with
|
|
5261
|
+
* `--bundle-dir`), packs it without `node_modules`, uploads it, and triggers a
|
|
5262
|
+
* deploy carrying the manifest so the control plane can validate intake fast.
|
|
5263
|
+
*/
|
|
5264
|
+
async function deployBundle(opts) {
|
|
5265
|
+
const { client, url, projectId, projectRef } = opts;
|
|
5266
|
+
const projectRoot = requireProjectRoot();
|
|
5267
|
+
let bundleDir = opts.bundleDir ? path.resolve(process.cwd(), opts.bundleDir) : path.join(projectRoot, "dist-bundle");
|
|
5268
|
+
if (!opts.bundleDir) {
|
|
5269
|
+
const loaded = loadManifest(projectRoot);
|
|
5270
|
+
const backend = findBackendApp(loaded.manifest);
|
|
5271
|
+
if (!backend) fail("This repository declares no backend app to deploy as a bundle.", "A managed deploy runs the backend; declare one in rebase.json, or deploy from the backend's repository.");
|
|
5272
|
+
console.log(chalk.gray(" Building bundle..."));
|
|
5273
|
+
bundleDir = (await buildBundle({
|
|
5274
|
+
projectRoot,
|
|
5275
|
+
appName: backend.name,
|
|
5276
|
+
app: backend.app,
|
|
5277
|
+
runtimeRange: loaded.manifest.runtime,
|
|
5278
|
+
log: (m) => console.log(chalk.gray(m))
|
|
5279
|
+
})).outDir;
|
|
5280
|
+
}
|
|
5281
|
+
const manifest = readBundleManifest(bundleDir);
|
|
5282
|
+
if (manifest.hooks?.native) {
|
|
5283
|
+
const names = (manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
|
|
5284
|
+
fail(`This bundle depends on native modules${names ? ` (${names})` : ""}, which the managed runtime cannot run.`, "Remove the native dependency, or deploy on the custom runtime.");
|
|
5285
|
+
}
|
|
5286
|
+
const tarPath = path.join(os.tmpdir(), `rebase-bundle-${Date.now()}.tar.gz`);
|
|
5287
|
+
const token = client.auth.getSession()?.accessToken;
|
|
5288
|
+
if (!token) fail("Not authenticated.", "Run `rebase cloud login`.");
|
|
5289
|
+
let bundleId;
|
|
5290
|
+
try {
|
|
5291
|
+
await packBundle(bundleDir, tarPath);
|
|
5292
|
+
const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(1);
|
|
5293
|
+
console.log(chalk.gray(` Uploading bundle (${sizeMb} MB)...`));
|
|
5294
|
+
bundleId = await uploadBundle(url, token, projectId, tarPath);
|
|
5295
|
+
} catch (e) {
|
|
5296
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
5297
|
+
return;
|
|
5298
|
+
} finally {
|
|
5299
|
+
fs.rmSync(tarPath, { force: true });
|
|
5300
|
+
}
|
|
5301
|
+
console.log("");
|
|
5302
|
+
console.log(` 🚀 Triggering managed deployment for ${chalk.bold(projectRef)} (schema ${manifest.schemaVersion})...`);
|
|
5303
|
+
const body = bundleDeployBody({
|
|
5304
|
+
projectId,
|
|
5305
|
+
bundleId,
|
|
5306
|
+
manifest,
|
|
5307
|
+
message: opts.message
|
|
5308
|
+
});
|
|
5309
|
+
try {
|
|
5310
|
+
const res = await client.functions.invoke("deploy", body);
|
|
5311
|
+
if (!res?.deployment?.id) fail("Control plane did not return a deployment id.");
|
|
5312
|
+
if (isJsonMode()) printJson({
|
|
5313
|
+
success: true,
|
|
5314
|
+
deploymentId: String(res.deployment.id),
|
|
5315
|
+
managed: res.managed === true
|
|
5316
|
+
});
|
|
5317
|
+
else {
|
|
5318
|
+
console.log(chalk.green(` ✓ Managed deploy started (deployment ${res.deployment.id}).`));
|
|
5319
|
+
console.log(chalk.gray(" Track it with `rebase cloud logs` or in the console."));
|
|
5320
|
+
}
|
|
5321
|
+
} catch (e) {
|
|
5322
|
+
reportError(e, "Managed deploy failed to start");
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
3359
5325
|
async function deployCommand(rawArgs, projectRef) {
|
|
3360
5326
|
const args = arg({
|
|
3361
5327
|
"--no-follow": Boolean,
|
|
3362
|
-
"--source": String
|
|
5328
|
+
"--source": String,
|
|
5329
|
+
"--message": String,
|
|
5330
|
+
"--bundle": Boolean,
|
|
5331
|
+
"--bundle-dir": String,
|
|
5332
|
+
"-m": "--message"
|
|
3363
5333
|
}, {
|
|
3364
5334
|
argv: rawArgs.slice(2),
|
|
3365
5335
|
permissive: true
|
|
3366
5336
|
});
|
|
3367
5337
|
const { client, url } = await requireClient(rawArgs);
|
|
3368
5338
|
const projectId = await resolveProjectRef(projectRef, client);
|
|
5339
|
+
if (args["--bundle"]) {
|
|
5340
|
+
if (args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
|
|
5341
|
+
await deployBundle({
|
|
5342
|
+
client,
|
|
5343
|
+
url,
|
|
5344
|
+
projectId,
|
|
5345
|
+
projectRef,
|
|
5346
|
+
bundleDir: args["--bundle-dir"],
|
|
5347
|
+
message: args["--message"]
|
|
5348
|
+
});
|
|
5349
|
+
return;
|
|
5350
|
+
}
|
|
3369
5351
|
let source;
|
|
3370
5352
|
if (args["--source"]) {
|
|
3371
5353
|
const tarPath = await createSourceTarball(args["--source"]);
|
|
@@ -3379,32 +5361,87 @@ async function deployCommand(rawArgs, projectRef) {
|
|
|
3379
5361
|
}
|
|
3380
5362
|
console.log("");
|
|
3381
5363
|
console.log(` 🚀 Triggering deployment for project ${chalk.bold(projectRef)}${source ? " from uploaded source" : ""}...`);
|
|
3382
|
-
|
|
5364
|
+
const body = { projectId };
|
|
5365
|
+
if (source) body.source = source;
|
|
5366
|
+
if (args["--message"]) body.message = args["--message"];
|
|
5367
|
+
body.client = "cli";
|
|
5368
|
+
const frameworkVersion = resolveFrameworkVersion(args["--source"] ?? process.cwd());
|
|
5369
|
+
if (frameworkVersion) body.frameworkVersion = frameworkVersion;
|
|
5370
|
+
let triggered;
|
|
3383
5371
|
try {
|
|
3384
|
-
const res = await client.functions.invoke("deploy",
|
|
3385
|
-
projectId,
|
|
3386
|
-
source
|
|
3387
|
-
} : { projectId });
|
|
5372
|
+
const res = await client.functions.invoke("deploy", body);
|
|
3388
5373
|
if (!res?.deployment?.id) fail("Control plane did not return a deployment id.");
|
|
3389
|
-
|
|
5374
|
+
triggered = {
|
|
5375
|
+
deploymentId: String(res.deployment.id),
|
|
5376
|
+
deduplicated: res.deduplicated === true
|
|
5377
|
+
};
|
|
3390
5378
|
} catch (e) {
|
|
3391
|
-
|
|
3392
|
-
if (err?.status === 409) fail("A deployment is already in progress for this project.");
|
|
3393
|
-
if (err?.status === 402) fail(err.message || "Payment required before deploying.", "Attach a card once with `rebase cloud billing setup`, then deploy again.");
|
|
3394
|
-
reportError(e, "Failed to trigger deployment");
|
|
5379
|
+
triggered = resolveTriggerFailure(e);
|
|
3395
5380
|
}
|
|
3396
|
-
|
|
5381
|
+
const { deploymentId, deduplicated } = triggered;
|
|
5382
|
+
if (!isJsonMode()) console.log(chalk.gray(deduplicated ? ` Deployment ${deploymentId} is already running — following it.` : ` Deployment ${deploymentId} created.${frameworkVersion ? ` (@rebasepro/* ${frameworkVersion})` : ""}`));
|
|
3397
5383
|
if (args["--no-follow"]) {
|
|
3398
|
-
|
|
3399
|
-
|
|
5384
|
+
emit(() => {
|
|
5385
|
+
console.log(chalk.gray(" Not following logs (--no-follow). Check status with `rebase cloud logs`."));
|
|
5386
|
+
console.log("");
|
|
5387
|
+
}, {
|
|
5388
|
+
deploymentId,
|
|
5389
|
+
deduplicated,
|
|
5390
|
+
frameworkVersion: frameworkVersion ?? null,
|
|
5391
|
+
following: false
|
|
5392
|
+
});
|
|
3400
5393
|
return;
|
|
3401
5394
|
}
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
5395
|
+
if (!isJsonMode()) {
|
|
5396
|
+
console.log(chalk.gray(" Streaming build logs (Ctrl-C to stop watching — the build keeps running):"));
|
|
5397
|
+
console.log("");
|
|
5398
|
+
}
|
|
5399
|
+
const status = await streamBuildLogs(client, deploymentId, { quiet: isJsonMode() });
|
|
5400
|
+
emit(() => {}, {
|
|
5401
|
+
deploymentId,
|
|
5402
|
+
deduplicated,
|
|
5403
|
+
frameworkVersion: frameworkVersion ?? null,
|
|
5404
|
+
following: true,
|
|
5405
|
+
status
|
|
5406
|
+
});
|
|
5407
|
+
}
|
|
5408
|
+
/**
|
|
5409
|
+
* Turn a failed trigger into either a deployment to follow, or an exit.
|
|
5410
|
+
*
|
|
5411
|
+
* The 409 is the interesting one. A deploy trigger can reach the control plane
|
|
5412
|
+
* twice without anybody asking twice — the SDK transport replays a request once
|
|
5413
|
+
* after refreshing an expired token, and any lost response has the same effect
|
|
5414
|
+
* — so "a deployment is already in progress" was routinely describing the
|
|
5415
|
+
* deployment this very command had just created. With no id in the message the
|
|
5416
|
+
* only available reading was "someone else is deploying, back off", and the
|
|
5417
|
+
* build stream was lost either way.
|
|
5418
|
+
*
|
|
5419
|
+
* So: if the control plane says the blocking deployment is ours, we attach to
|
|
5420
|
+
* it. If it is not ours, we still name it, because "which one, since when, from
|
|
5421
|
+
* where" is the difference between an actionable refusal and a dead end.
|
|
5422
|
+
*/
|
|
5423
|
+
function resolveTriggerFailure(e) {
|
|
5424
|
+
const err = e;
|
|
5425
|
+
if (err?.status === 409) {
|
|
5426
|
+
const blocking = err.details?.deployment;
|
|
5427
|
+
if (blocking?.id && blocking.mine) return {
|
|
5428
|
+
deploymentId: String(blocking.id),
|
|
5429
|
+
deduplicated: true
|
|
5430
|
+
};
|
|
5431
|
+
fail(blocking?.id ? `Deployment ${blocking.id} is already in progress for this project${blocking.triggerSource && blocking.triggerSource !== "unknown" ? `, triggered from the ${blocking.triggerSource}` : ""}${blocking.createdAt ? ` at ${fmtDate(blocking.createdAt)}` : ""}.` : "A deployment is already in progress for this project.", blocking?.id ? `Follow it with \`rebase cloud logs -f\`, or stop it with \`rebase cloud cancel ${blocking.id}\`.` : "Follow it with `rebase cloud logs -f`.", "deploy_in_progress");
|
|
5432
|
+
}
|
|
5433
|
+
if (err?.status === 402) fail(err.message || "Payment required before deploying.", "Attach a card once with `rebase cloud billing setup`, then deploy again.", "payment_required");
|
|
5434
|
+
reportError(e, "Failed to trigger deployment");
|
|
3405
5435
|
}
|
|
3406
|
-
/**
|
|
3407
|
-
|
|
5436
|
+
/**
|
|
5437
|
+
* Poll a deployment record and print new log output as it arrives. Returns the
|
|
5438
|
+
* terminal status; a non-success still exits non-zero, as it always has.
|
|
5439
|
+
*
|
|
5440
|
+
* `quiet` follows without printing — JSON mode, where the log stream would
|
|
5441
|
+
* corrupt the one object the caller is parsing.
|
|
5442
|
+
*/
|
|
5443
|
+
async function streamBuildLogs(client, deploymentId, opts = {}) {
|
|
5444
|
+
const quiet = opts.quiet === true;
|
|
3408
5445
|
let printed = 0;
|
|
3409
5446
|
const started = Date.now();
|
|
3410
5447
|
for (;;) {
|
|
@@ -3414,26 +5451,37 @@ async function streamBuildLogs(client, deploymentId) {
|
|
|
3414
5451
|
} catch (e) {
|
|
3415
5452
|
reportError(e, "Failed to read deployment status");
|
|
3416
5453
|
}
|
|
3417
|
-
if (!dep) fail(`Deployment ${deploymentId} disappeared
|
|
5454
|
+
if (!dep) fail(`Deployment ${deploymentId} disappeared.`, void 0, "not_found");
|
|
3418
5455
|
const logs = dep.logs ?? "";
|
|
3419
|
-
if (logs.length > printed)
|
|
3420
|
-
|
|
3421
|
-
printed = logs.length;
|
|
3422
|
-
}
|
|
5456
|
+
if (!quiet && logs.length > printed) process.stdout.write(logs.slice(printed));
|
|
5457
|
+
printed = logs.length;
|
|
3423
5458
|
if (dep.status && dep.status !== "deploying") {
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
5459
|
+
if (dep.status !== "success") {
|
|
5460
|
+
if (quiet) {
|
|
5461
|
+
printJson({ error: {
|
|
5462
|
+
message: `Deployment ${deploymentId} ${dep.status}.`,
|
|
5463
|
+
code: "deploy_failed",
|
|
5464
|
+
status: null,
|
|
5465
|
+
deploymentId,
|
|
5466
|
+
logs
|
|
5467
|
+
} });
|
|
5468
|
+
process.exit(1);
|
|
5469
|
+
}
|
|
5470
|
+
console.log("");
|
|
3427
5471
|
console.log(chalk.bold.red(` ✗ Deployment ${dep.status}`));
|
|
3428
5472
|
console.log("");
|
|
3429
5473
|
process.exit(1);
|
|
3430
5474
|
}
|
|
3431
|
-
|
|
3432
|
-
|
|
5475
|
+
if (!quiet) {
|
|
5476
|
+
console.log("");
|
|
5477
|
+
console.log(chalk.bold.green(" ✓ Deployment succeeded"));
|
|
5478
|
+
console.log("");
|
|
5479
|
+
}
|
|
5480
|
+
return dep.status;
|
|
3433
5481
|
}
|
|
3434
5482
|
if (Date.now() - started > POLL_TIMEOUT_MS) {
|
|
3435
|
-
console.log("");
|
|
3436
|
-
fail("Timed out waiting for the build to finish.", "The deployment may still be running — check `rebase cloud logs`.");
|
|
5483
|
+
if (!quiet) console.log("");
|
|
5484
|
+
fail("Timed out waiting for the build to finish.", "The deployment may still be running — check `rebase cloud logs`.", "timeout");
|
|
3437
5485
|
}
|
|
3438
5486
|
await sleep(POLL_INTERVAL_MS);
|
|
3439
5487
|
}
|
|
@@ -3679,7 +5727,7 @@ async function createDatabase(rawArgs) {
|
|
|
3679
5727
|
let type = args["--type"];
|
|
3680
5728
|
if (!type) {
|
|
3681
5729
|
const { picked } = await inquirer.prompt([{
|
|
3682
|
-
type: "
|
|
5730
|
+
type: "select",
|
|
3683
5731
|
name: "picked",
|
|
3684
5732
|
message: "Database type:",
|
|
3685
5733
|
choices: [{
|
|
@@ -4146,9 +6194,35 @@ function parseEnvAssignment(operands) {
|
|
|
4146
6194
|
value: operands[1] ?? ""
|
|
4147
6195
|
};
|
|
4148
6196
|
}
|
|
6197
|
+
/**
|
|
6198
|
+
* Prefixes whose variables are read by a BUNDLER at build time, not by the
|
|
6199
|
+
* process at run time.
|
|
6200
|
+
*
|
|
6201
|
+
* These are the ones this command cannot deliver. A project's environment is
|
|
6202
|
+
* applied at rollout — after Kaniko has already built the image — so a
|
|
6203
|
+
* `VITE_API_URL` set here is present in the running container and absent from
|
|
6204
|
+
* the JavaScript that was compiled minutes earlier. Nothing fails: the variable
|
|
6205
|
+
* exists, the deploy succeeds, and the bundle carries `undefined` where the
|
|
6206
|
+
* value should be. The bug then presents in the browser as missing
|
|
6207
|
+
* configuration, which is several steps away from the cause.
|
|
6208
|
+
*
|
|
6209
|
+
* `import.meta.env` inlining is Vite's; `NEXT_PUBLIC_`/`PUBLIC_`/`REACT_APP_`
|
|
6210
|
+
* are the same contract in Next, Astro/SvelteKit and CRA.
|
|
6211
|
+
*/
|
|
6212
|
+
var BUILD_TIME_ENV_PREFIXES = [
|
|
6213
|
+
"VITE_",
|
|
6214
|
+
"NEXT_PUBLIC_",
|
|
6215
|
+
"PUBLIC_",
|
|
6216
|
+
"REACT_APP_"
|
|
6217
|
+
];
|
|
6218
|
+
/** The prefix that makes `key` a build-time variable, or undefined. */
|
|
6219
|
+
function buildTimeEnvPrefix(key) {
|
|
6220
|
+
return BUILD_TIME_ENV_PREFIXES.find((prefix) => key.toUpperCase().startsWith(prefix));
|
|
6221
|
+
}
|
|
4149
6222
|
async function setEnv(rawArgs) {
|
|
4150
6223
|
const args = arg({
|
|
4151
6224
|
"--secret": Boolean,
|
|
6225
|
+
"--force": Boolean,
|
|
4152
6226
|
"--project": String,
|
|
4153
6227
|
"-p": "--project"
|
|
4154
6228
|
}, {
|
|
@@ -4160,6 +6234,8 @@ async function setEnv(rawArgs) {
|
|
|
4160
6234
|
displayProjectRef(rawArgs);
|
|
4161
6235
|
const parsed = parseEnvAssignment(cloudPositionals(rawArgs).slice(2));
|
|
4162
6236
|
if (!parsed || !parsed.key) fail("Usage: rebase cloud env set KEY=VALUE [--secret]", void 0, "usage");
|
|
6237
|
+
const buildTimePrefix = buildTimeEnvPrefix(parsed.key);
|
|
6238
|
+
if (buildTimePrefix && !args["--force"]) fail(`${parsed.key} is read by your bundler at BUILD time, and project variables are applied at rollout — after the image is built. Setting it here would not reach the bundle.`, `Put ${buildTimePrefix}* variables in the source you deploy (a committed .env, or your build config), then \`rebase cloud deploy\`. Pass --force if your build genuinely reads this at run time.`, "build_time_variable");
|
|
4163
6239
|
const body = {
|
|
4164
6240
|
key: parsed.key,
|
|
4165
6241
|
value: parsed.value
|
|
@@ -4316,10 +6392,13 @@ ${chalk.green.bold("Commands")}
|
|
|
4316
6392
|
|
|
4317
6393
|
${chalk.green.bold("Options")}
|
|
4318
6394
|
${chalk.blue("--secret")} Mark a variable write-only ${chalk.gray("(set)")}
|
|
6395
|
+
${chalk.blue("--force")} Set a build-time key anyway ${chalk.gray("(set)")}
|
|
4319
6396
|
${chalk.blue("--json")} Machine-readable output
|
|
4320
6397
|
${chalk.blue("--project, -p")} Project slug ${chalk.gray("(defaults to the linked project)")}
|
|
4321
6398
|
|
|
4322
6399
|
${chalk.gray("Values are encrypted at rest (AES-256-GCM) and only decrypted at deploy time.")}
|
|
6400
|
+
${chalk.gray("VITE_* / NEXT_PUBLIC_* / PUBLIC_* / REACT_APP_* are read by your bundler at BUILD time;")}
|
|
6401
|
+
${chalk.gray("these are applied at rollout, after the image is built, so they never reach the bundle.")}
|
|
4323
6402
|
`);
|
|
4324
6403
|
}
|
|
4325
6404
|
function printEnvHelpJson() {
|
|
@@ -4871,7 +6950,7 @@ function deploymentDurationMs(dep) {
|
|
|
4871
6950
|
const ms = b - a;
|
|
4872
6951
|
return ms >= 0 ? ms : null;
|
|
4873
6952
|
}
|
|
4874
|
-
function formatDuration(ms) {
|
|
6953
|
+
function formatDuration$1(ms) {
|
|
4875
6954
|
const totalSec = Math.max(0, Math.round(ms / 1e3));
|
|
4876
6955
|
if (totalSec < 60) return `${totalSec}s`;
|
|
4877
6956
|
const m = Math.floor(totalSec / 60);
|
|
@@ -4915,6 +6994,8 @@ function deploymentView(dep) {
|
|
|
4915
6994
|
isRollback: str(dep, "rollbackOf", "rollback_of") !== null,
|
|
4916
6995
|
rollbackable: isRollbackable(dep),
|
|
4917
6996
|
trigger: triggerInfo(dep),
|
|
6997
|
+
message: str(dep, "deployMessage", "deploy_message"),
|
|
6998
|
+
frameworkVersion: str(dep, "frameworkVersion", "framework_version"),
|
|
4918
6999
|
commit: {
|
|
4919
7000
|
hash: str(dep, "gitCommitHash", "gitCommitHash"),
|
|
4920
7001
|
message: str(dep, "gitCommitMessage", "gitCommitMessage")
|
|
@@ -4928,12 +7009,31 @@ async function fetchDeployments(client, projectId, limit = 100) {
|
|
|
4928
7009
|
limit
|
|
4929
7010
|
})).data;
|
|
4930
7011
|
}
|
|
7012
|
+
/** Hard ceiling on `--limit`, matching the backend's own page size. */
|
|
7013
|
+
var MAX_DEPLOYMENTS_LIMIT = 100;
|
|
7014
|
+
/** `--limit N`, bounded. A garbage value is a refusal, never a silent default. */
|
|
7015
|
+
function parseDeploymentsLimit(raw) {
|
|
7016
|
+
if (raw === void 0) return 20;
|
|
7017
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > MAX_DEPLOYMENTS_LIMIT) fail(`--limit must be a whole number between 1 and ${MAX_DEPLOYMENTS_LIMIT}.`, void 0, "usage");
|
|
7018
|
+
return raw;
|
|
7019
|
+
}
|
|
4931
7020
|
async function deploymentsListCommand(rawArgs) {
|
|
7021
|
+
const args = arg({
|
|
7022
|
+
"--limit": Number,
|
|
7023
|
+
"--all": Boolean,
|
|
7024
|
+
"--project": String,
|
|
7025
|
+
"-p": "--project"
|
|
7026
|
+
}, {
|
|
7027
|
+
argv: rawArgs.slice(2),
|
|
7028
|
+
permissive: true
|
|
7029
|
+
});
|
|
7030
|
+
const limit = args["--all"] ? MAX_DEPLOYMENTS_LIMIT : parseDeploymentsLimit(args["--limit"]);
|
|
4932
7031
|
const { client } = await requireClient(rawArgs);
|
|
4933
7032
|
const projectId = await requireProject(rawArgs, client);
|
|
4934
7033
|
const projectRef = displayProjectRef(rawArgs);
|
|
4935
7034
|
try {
|
|
4936
|
-
const views = (await fetchDeployments(client, projectId)).map(deploymentView);
|
|
7035
|
+
const views = (await fetchDeployments(client, projectId, limit)).map(deploymentView);
|
|
7036
|
+
const truncated = views.length === limit;
|
|
4937
7037
|
emit(() => {
|
|
4938
7038
|
console.log("");
|
|
4939
7039
|
console.log(chalk.bold(` 🚀 Deployments — project ${projectRef}`));
|
|
@@ -4944,14 +7044,22 @@ async function deploymentsListCommand(rawArgs) {
|
|
|
4944
7044
|
return;
|
|
4945
7045
|
}
|
|
4946
7046
|
for (const v of views) {
|
|
4947
|
-
const dur = v.durationMs !== null ? formatDuration(v.durationMs) : chalk.gray("running");
|
|
7047
|
+
const dur = v.durationMs !== null ? formatDuration$1(v.durationMs) : chalk.gray("running");
|
|
4948
7048
|
const trig = v.trigger.source;
|
|
4949
7049
|
const roll = v.rollbackable ? chalk.green(" ↺ rollbackable") : "";
|
|
4950
7050
|
console.log(` ${chalk.gray(`[${v.id}]`)} ${colorStatus(v.status)} ${chalk.gray(String(v.createdAt ?? "—"))} ${dur} ${chalk.gray(trig)}${roll}`);
|
|
7051
|
+
const label = [v.message, v.frameworkVersion ? `@rebasepro/* ${v.frameworkVersion}` : null].filter(Boolean).join(" · ");
|
|
7052
|
+
if (label) console.log(` ${chalk.gray(label)}`);
|
|
7053
|
+
}
|
|
7054
|
+
if (truncated) {
|
|
7055
|
+
console.log("");
|
|
7056
|
+
console.log(chalk.gray(` Showing the ${limit} most recent. Use \`--limit N\` or \`--all\` for more.`));
|
|
4951
7057
|
}
|
|
4952
7058
|
console.log("");
|
|
4953
7059
|
}, {
|
|
4954
7060
|
projectId,
|
|
7061
|
+
limit,
|
|
7062
|
+
truncated,
|
|
4955
7063
|
deployments: views
|
|
4956
7064
|
});
|
|
4957
7065
|
} catch (e) {
|
|
@@ -5018,131 +7126,928 @@ async function rollbackCommand(rawArgs) {
|
|
|
5018
7126
|
reportError(e, "Failed to roll back");
|
|
5019
7127
|
}
|
|
5020
7128
|
}
|
|
5021
|
-
async function cancelCommand(rawArgs) {
|
|
5022
|
-
const args = arg({
|
|
5023
|
-
"--yes": Boolean,
|
|
5024
|
-
"-y": "--yes",
|
|
5025
|
-
"--project": String,
|
|
5026
|
-
"-p": "--project"
|
|
7129
|
+
async function cancelCommand(rawArgs) {
|
|
7130
|
+
const args = arg({
|
|
7131
|
+
"--yes": Boolean,
|
|
7132
|
+
"-y": "--yes",
|
|
7133
|
+
"--project": String,
|
|
7134
|
+
"-p": "--project"
|
|
7135
|
+
}, {
|
|
7136
|
+
argv: rawArgs.slice(2),
|
|
7137
|
+
permissive: true
|
|
7138
|
+
});
|
|
7139
|
+
const { client } = await requireClient(rawArgs);
|
|
7140
|
+
const projectId = await requireProject(rawArgs, client);
|
|
7141
|
+
const projectRef = displayProjectRef(rawArgs);
|
|
7142
|
+
const explicitId = cloudPositionals(rawArgs).slice(1)[0];
|
|
7143
|
+
await confirmDestructive({
|
|
7144
|
+
yes: Boolean(args["--yes"]),
|
|
7145
|
+
prompt: `Cancel the in-flight build for project ${projectRef}?`
|
|
7146
|
+
});
|
|
7147
|
+
try {
|
|
7148
|
+
const res = await client.functions.invoke("deploy", explicitId ? {
|
|
7149
|
+
projectId,
|
|
7150
|
+
deploymentId: explicitId
|
|
7151
|
+
} : { projectId }, { path: "cancel" });
|
|
7152
|
+
emit(() => {
|
|
7153
|
+
success(`Cancelled deployment ${chalk.bold(res.deploymentId)}`);
|
|
7154
|
+
if (res.buildJobDeleted) console.log(chalk.gray(" The build job was deleted."));
|
|
7155
|
+
console.log("");
|
|
7156
|
+
}, {
|
|
7157
|
+
success: true,
|
|
7158
|
+
deploymentId: res.deploymentId,
|
|
7159
|
+
buildJobDeleted: res.buildJobDeleted
|
|
7160
|
+
});
|
|
7161
|
+
} catch (e) {
|
|
7162
|
+
if (e?.status === 404) fail("No deployment in progress to cancel.", void 0, "not_found");
|
|
7163
|
+
reportError(e, "Failed to cancel deployment");
|
|
7164
|
+
}
|
|
7165
|
+
}
|
|
7166
|
+
//#endregion
|
|
7167
|
+
//#region src/commands/cloud/power.ts
|
|
7168
|
+
/**
|
|
7169
|
+
* `rebase cloud start | stop | restart` — power operations.
|
|
7170
|
+
*
|
|
7171
|
+
* These flip the project's `status`, exactly as the console's `handleServerAction`
|
|
7172
|
+
* does: stop → `stopped`, start → `active`, restart → stop then start with a
|
|
7173
|
+
* brief pause in between (a real stop→start with genuine downtime). Stop and
|
|
7174
|
+
* restart cause downtime, so they require `--yes` in non-interactive use.
|
|
7175
|
+
*/
|
|
7176
|
+
async function setStatus(client, projectId, status) {
|
|
7177
|
+
await client.data.collection("projects").update(projectId, { status });
|
|
7178
|
+
}
|
|
7179
|
+
async function powerCommand(action, rawArgs) {
|
|
7180
|
+
const args = arg({
|
|
7181
|
+
"--yes": Boolean,
|
|
7182
|
+
"-y": "--yes",
|
|
7183
|
+
"--project": String,
|
|
7184
|
+
"-p": "--project"
|
|
7185
|
+
}, {
|
|
7186
|
+
argv: rawArgs.slice(2),
|
|
7187
|
+
permissive: true
|
|
7188
|
+
});
|
|
7189
|
+
const { client } = await requireClient(rawArgs);
|
|
7190
|
+
const projectId = await requireProject(rawArgs, client);
|
|
7191
|
+
const projectRef = displayProjectRef(rawArgs);
|
|
7192
|
+
if (action !== "start") await confirmDestructive({
|
|
7193
|
+
yes: Boolean(args["--yes"]),
|
|
7194
|
+
prompt: `${action === "stop" ? "Stop" : "Restart"} project ${projectRef}? This causes downtime.`
|
|
7195
|
+
});
|
|
7196
|
+
try {
|
|
7197
|
+
if (action === "stop") {
|
|
7198
|
+
await setStatus(client, projectId, "stopped");
|
|
7199
|
+
emit(() => success(`Stopped project ${projectRef}`), {
|
|
7200
|
+
success: true,
|
|
7201
|
+
projectId,
|
|
7202
|
+
status: "stopped"
|
|
7203
|
+
});
|
|
7204
|
+
} else if (action === "start") {
|
|
7205
|
+
await setStatus(client, projectId, "active");
|
|
7206
|
+
emit(() => success(`Started project ${projectRef}`), {
|
|
7207
|
+
success: true,
|
|
7208
|
+
projectId,
|
|
7209
|
+
status: "active"
|
|
7210
|
+
});
|
|
7211
|
+
} else {
|
|
7212
|
+
await setStatus(client, projectId, "stopped");
|
|
7213
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
7214
|
+
await setStatus(client, projectId, "active");
|
|
7215
|
+
emit(() => success(`Restarted project ${projectRef}`), {
|
|
7216
|
+
success: true,
|
|
7217
|
+
projectId,
|
|
7218
|
+
status: "active"
|
|
7219
|
+
});
|
|
7220
|
+
}
|
|
7221
|
+
} catch (e) {
|
|
7222
|
+
reportError(e, `Failed to ${action} project`);
|
|
7223
|
+
}
|
|
7224
|
+
}
|
|
7225
|
+
//#endregion
|
|
7226
|
+
//#region src/commands/cloud/debug.ts
|
|
7227
|
+
/**
|
|
7228
|
+
* `rebase cloud debug <subcommand>` — one entry point for "why is my deployed
|
|
7229
|
+
* app not behaving".
|
|
7230
|
+
*
|
|
7231
|
+
* ## Why this exists
|
|
7232
|
+
*
|
|
7233
|
+
* The useful signals for a deployed project live in four different places — the
|
|
7234
|
+
* control plane, the workload's pods, the tenant database, and the public URL —
|
|
7235
|
+
* and each is normally reached with a different tool and a different set of
|
|
7236
|
+
* flags. Getting to a log should not be a research task. This started life as a
|
|
7237
|
+
* hand-rolled `prod-debug.sh` for a single project, with the namespace and the
|
|
7238
|
+
* URL hardcoded at the top; it earned its place twice in one week, so it is
|
|
7239
|
+
* generalised here to any project the CLI can already resolve.
|
|
7240
|
+
*
|
|
7241
|
+
* ## Read-only by default
|
|
7242
|
+
*
|
|
7243
|
+
* Every subcommand here only reads. The two things the original script could do
|
|
7244
|
+
* that mutate are deliberately NOT reproduced as-is:
|
|
7245
|
+
*
|
|
7246
|
+
* - restarting the workload lives at `rebase cloud restart`, which already
|
|
7247
|
+
* gates downtime behind `--yes`; duplicating it here would give the same
|
|
7248
|
+
* destructive act a second, ungated spelling.
|
|
7249
|
+
* - `debug db` prints the port-forward recipe and the connection's *shape*.
|
|
7250
|
+
* It opens no session and prints no password — `rebase cloud db info
|
|
7251
|
+
* --reveal` is the explicit, auditable way to get one.
|
|
7252
|
+
*
|
|
7253
|
+
* ## The probes are the point
|
|
7254
|
+
*
|
|
7255
|
+
* `debug health` is the highest-value piece and the reason the script existed.
|
|
7256
|
+
* A bare status code is not a diagnosis: a 404 from a functions route means
|
|
7257
|
+
* something completely different from a 404 at the root, and a 200 on an
|
|
7258
|
+
* unauthenticated read is a finding rather than a success. So every probe ships
|
|
7259
|
+
* with the interpretation of what it got, not just the number — see
|
|
7260
|
+
* {@link PROBES}.
|
|
7261
|
+
*/
|
|
7262
|
+
/** A response never arrived: DNS, TLS, ingress, or the pod. */
|
|
7263
|
+
var NO_RESPONSE = {
|
|
7264
|
+
verdict: "fail",
|
|
7265
|
+
meaning: "nothing answered — the hostname does not resolve, the ingress has no route, or no pod is running"
|
|
7266
|
+
};
|
|
7267
|
+
function serverError(what) {
|
|
7268
|
+
return {
|
|
7269
|
+
verdict: "fail",
|
|
7270
|
+
meaning: `the server is running but ${what}`
|
|
7271
|
+
};
|
|
7272
|
+
}
|
|
7273
|
+
/**
|
|
7274
|
+
* The probe set, in the order a failure cascades: if `health` is down, nothing
|
|
7275
|
+
* below it is meaningful, so it is checked first and reported first.
|
|
7276
|
+
*/
|
|
7277
|
+
var PROBES = [
|
|
7278
|
+
{
|
|
7279
|
+
id: "health",
|
|
7280
|
+
label: "health",
|
|
7281
|
+
method: "GET",
|
|
7282
|
+
path: () => "/health",
|
|
7283
|
+
healthy: "200 — the backend is up",
|
|
7284
|
+
interpret: (status) => {
|
|
7285
|
+
if (status === null) return NO_RESPONSE;
|
|
7286
|
+
if (status === 200) return {
|
|
7287
|
+
verdict: "ok",
|
|
7288
|
+
meaning: "the backend is up and serving"
|
|
7289
|
+
};
|
|
7290
|
+
if (status === 404) return {
|
|
7291
|
+
verdict: "fail",
|
|
7292
|
+
meaning: "something answered but it is not a Rebase backend — the ingress is routing this host elsewhere"
|
|
7293
|
+
};
|
|
7294
|
+
if (status >= 500) return serverError("its health endpoint is failing");
|
|
7295
|
+
return {
|
|
7296
|
+
verdict: "unknown",
|
|
7297
|
+
meaning: "unexpected for a health endpoint"
|
|
7298
|
+
};
|
|
7299
|
+
}
|
|
7300
|
+
},
|
|
7301
|
+
{
|
|
7302
|
+
id: "spa",
|
|
7303
|
+
label: "spa",
|
|
7304
|
+
method: "GET",
|
|
7305
|
+
path: () => "/",
|
|
7306
|
+
healthy: "200 — the frontend is being served",
|
|
7307
|
+
interpret: (status) => {
|
|
7308
|
+
if (status === null) return NO_RESPONSE;
|
|
7309
|
+
if (status === 200) return {
|
|
7310
|
+
verdict: "ok",
|
|
7311
|
+
meaning: "the frontend bundle is being served"
|
|
7312
|
+
};
|
|
7313
|
+
if (status === 404) return {
|
|
7314
|
+
verdict: "warn",
|
|
7315
|
+
meaning: "no frontend at the root — expected for a backend-only project, otherwise the SPA assets were not bundled into the image"
|
|
7316
|
+
};
|
|
7317
|
+
if (status >= 500) return serverError("the root route throws");
|
|
7318
|
+
return {
|
|
7319
|
+
verdict: "unknown",
|
|
7320
|
+
meaning: "unexpected at the site root"
|
|
7321
|
+
};
|
|
7322
|
+
}
|
|
7323
|
+
},
|
|
7324
|
+
{
|
|
7325
|
+
id: "auth",
|
|
7326
|
+
label: "auth",
|
|
7327
|
+
method: "POST",
|
|
7328
|
+
path: () => "/api/auth/login",
|
|
7329
|
+
body: {},
|
|
7330
|
+
healthy: "400 — auth is mounted and rejects an empty body",
|
|
7331
|
+
interpret: (status) => {
|
|
7332
|
+
if (status === null) return NO_RESPONSE;
|
|
7333
|
+
if (status === 400 || status === 422) return {
|
|
7334
|
+
verdict: "ok",
|
|
7335
|
+
meaning: "auth is mounted and rejected the empty body, as it should"
|
|
7336
|
+
};
|
|
7337
|
+
if (status === 401 || status === 403) return {
|
|
7338
|
+
verdict: "ok",
|
|
7339
|
+
meaning: "auth is mounted and refused the credentials"
|
|
7340
|
+
};
|
|
7341
|
+
if (status === 404) return {
|
|
7342
|
+
verdict: "fail",
|
|
7343
|
+
meaning: "the auth routes are NOT mounted — this project cannot sign anyone in"
|
|
7344
|
+
};
|
|
7345
|
+
if (status === 200) return {
|
|
7346
|
+
verdict: "fail",
|
|
7347
|
+
meaning: "an EMPTY login body was accepted — a login with no credentials must never succeed"
|
|
7348
|
+
};
|
|
7349
|
+
if (status >= 500) return serverError("the login route throws — often a missing or unmigrated auth table");
|
|
7350
|
+
return {
|
|
7351
|
+
verdict: "unknown",
|
|
7352
|
+
meaning: "unexpected for a login route"
|
|
7353
|
+
};
|
|
7354
|
+
}
|
|
7355
|
+
},
|
|
7356
|
+
{
|
|
7357
|
+
id: "unauthRead",
|
|
7358
|
+
label: "unauth read",
|
|
7359
|
+
method: "GET",
|
|
7360
|
+
path: (t) => `/api/data/${encodeURIComponent(t.collection)}`,
|
|
7361
|
+
healthy: "401 — reads require authentication",
|
|
7362
|
+
interpret: (status) => {
|
|
7363
|
+
if (status === null) return NO_RESPONSE;
|
|
7364
|
+
if (status === 401 || status === 403) return {
|
|
7365
|
+
verdict: "ok",
|
|
7366
|
+
meaning: "unauthenticated reads are refused — row-level security is enforced"
|
|
7367
|
+
};
|
|
7368
|
+
if (status === 200) return {
|
|
7369
|
+
verdict: "warn",
|
|
7370
|
+
meaning: "this collection is readable with NO authentication — correct only if it is deliberately public"
|
|
7371
|
+
};
|
|
7372
|
+
if (status === 404) return {
|
|
7373
|
+
verdict: "warn",
|
|
7374
|
+
meaning: "no such collection on this deployment — check the name, or pass --collection"
|
|
7375
|
+
};
|
|
7376
|
+
if (status >= 500) return serverError("the read reached the database and failed — most often an RLS policy naming a column or table that is not there");
|
|
7377
|
+
return {
|
|
7378
|
+
verdict: "unknown",
|
|
7379
|
+
meaning: "unexpected for a data read"
|
|
7380
|
+
};
|
|
7381
|
+
}
|
|
7382
|
+
},
|
|
7383
|
+
{
|
|
7384
|
+
id: "functions",
|
|
7385
|
+
label: "functions",
|
|
7386
|
+
method: "GET",
|
|
7387
|
+
/**
|
|
7388
|
+
* The router's own listing endpoint, NOT a function's path.
|
|
7389
|
+
*
|
|
7390
|
+
* This matters, and it is the one place the original script got a wrong
|
|
7391
|
+
* answer. A function is a Hono sub-app mounted at `/<name>`, and it
|
|
7392
|
+
* usually defines only sub-routes (`/get`, `/list`) — so
|
|
7393
|
+
* `/api/functions/<name>` 404s **even when everything is mounted and
|
|
7394
|
+
* healthy**. Probing there cannot separate "the router is missing" from
|
|
7395
|
+
* "that function defines no root route", and reporting the first is how
|
|
7396
|
+
* you send someone to debug a deployment that was fine.
|
|
7397
|
+
*
|
|
7398
|
+
* `GET /api/functions` is unambiguous: the router registers a listing
|
|
7399
|
+
* route at its own root (see `createFunctionRoutes`), so a 200 proves
|
|
7400
|
+
* the mount *and* names every function that loaded.
|
|
7401
|
+
*/
|
|
7402
|
+
path: () => "/api/functions",
|
|
7403
|
+
healthy: "200 — the functions router is mounted and lists its functions",
|
|
7404
|
+
interpret: (status) => {
|
|
7405
|
+
if (status === null) return NO_RESPONSE;
|
|
7406
|
+
if (status === 200) return {
|
|
7407
|
+
verdict: "ok",
|
|
7408
|
+
meaning: "the functions router is mounted"
|
|
7409
|
+
};
|
|
7410
|
+
if (status === 401 || status === 403) return {
|
|
7411
|
+
verdict: "ok",
|
|
7412
|
+
meaning: "the functions router is mounted (its listing requires auth)"
|
|
7413
|
+
};
|
|
7414
|
+
if (status === 404) return {
|
|
7415
|
+
verdict: "fail",
|
|
7416
|
+
meaning: "the functions router did not mount — no functions directory was found at build time, or it held no functions, so every function on this project is unreachable"
|
|
7417
|
+
};
|
|
7418
|
+
if (status >= 500) return serverError("the functions router throws");
|
|
7419
|
+
return {
|
|
7420
|
+
verdict: "unknown",
|
|
7421
|
+
meaning: "unexpected for the functions listing"
|
|
7422
|
+
};
|
|
7423
|
+
},
|
|
7424
|
+
needsBody: true,
|
|
7425
|
+
refine: (body, t) => {
|
|
7426
|
+
const names = functionNames(body);
|
|
7427
|
+
if (!names) return null;
|
|
7428
|
+
if (t.fn && !names.includes(t.fn)) return {
|
|
7429
|
+
verdict: "fail",
|
|
7430
|
+
meaning: `the router is mounted but no function is named "${t.fn}" — it loaded ${names.length}: ${names.join(", ")}`
|
|
7431
|
+
};
|
|
7432
|
+
const found = t.fn ? `, including ${t.fn}` : "";
|
|
7433
|
+
return {
|
|
7434
|
+
verdict: "ok",
|
|
7435
|
+
meaning: `the functions router is mounted and loaded ${names.length} function${names.length === 1 ? "" : "s"}${found}`
|
|
7436
|
+
};
|
|
7437
|
+
}
|
|
7438
|
+
}
|
|
7439
|
+
];
|
|
7440
|
+
/** The function names out of a listing body, or null when it is not one. */
|
|
7441
|
+
function functionNames(body) {
|
|
7442
|
+
const list = body?.functions;
|
|
7443
|
+
if (!Array.isArray(list)) return null;
|
|
7444
|
+
const names = list.map((f) => f?.name).filter((n) => typeof n === "string");
|
|
7445
|
+
return names.length === list.length ? names : null;
|
|
7446
|
+
}
|
|
7447
|
+
/** Milliseconds before a probe is treated as unanswered. */
|
|
7448
|
+
var PROBE_TIMEOUT_MS = 1e4;
|
|
7449
|
+
/**
|
|
7450
|
+
* Ceiling on a probe body we will parse. The only body read is the function
|
|
7451
|
+
* listing; anything larger is a page we have no use for, and a debug command
|
|
7452
|
+
* must not be the thing that runs a machine out of memory.
|
|
7453
|
+
*/
|
|
7454
|
+
var MAX_PROBE_BODY_BYTES = 256 * 1024;
|
|
7455
|
+
/**
|
|
7456
|
+
* Run one probe. A transport failure is a `null` status, never a thrown error:
|
|
7457
|
+
* "nothing answered" is a diagnosis in its own right and the other probes still
|
|
7458
|
+
* need to run.
|
|
7459
|
+
*/
|
|
7460
|
+
async function runProbe(origin, spec, targets) {
|
|
7461
|
+
const url = `${origin}${spec.path(targets)}`;
|
|
7462
|
+
const started = Date.now();
|
|
7463
|
+
let status = null;
|
|
7464
|
+
let body;
|
|
7465
|
+
try {
|
|
7466
|
+
const res = await fetch(url, {
|
|
7467
|
+
method: spec.method,
|
|
7468
|
+
headers: spec.body ? { "Content-Type": "application/json" } : void 0,
|
|
7469
|
+
body: spec.body ? JSON.stringify(spec.body) : void 0,
|
|
7470
|
+
redirect: "manual",
|
|
7471
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
7472
|
+
});
|
|
7473
|
+
status = res.status;
|
|
7474
|
+
if (spec.needsBody && res.ok) {
|
|
7475
|
+
const text = await res.text();
|
|
7476
|
+
if (text.length <= MAX_PROBE_BODY_BYTES) try {
|
|
7477
|
+
body = JSON.parse(text);
|
|
7478
|
+
} catch {}
|
|
7479
|
+
}
|
|
7480
|
+
} catch {
|
|
7481
|
+
status = null;
|
|
7482
|
+
}
|
|
7483
|
+
const statusReading = spec.interpret(status);
|
|
7484
|
+
const reading = status !== null && spec.refine?.(body, targets) || statusReading;
|
|
7485
|
+
return {
|
|
7486
|
+
statusOk: statusReading.verdict === "ok",
|
|
7487
|
+
id: spec.id,
|
|
7488
|
+
label: spec.label,
|
|
7489
|
+
method: spec.method,
|
|
7490
|
+
url,
|
|
7491
|
+
status,
|
|
7492
|
+
ms: Date.now() - started,
|
|
7493
|
+
verdict: reading.verdict,
|
|
7494
|
+
meaning: reading.meaning,
|
|
7495
|
+
healthy: spec.healthy
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
7498
|
+
/** The worst verdict across probes — what the command's exit code keys off. */
|
|
7499
|
+
function overallVerdict(results) {
|
|
7500
|
+
if (results.some((r) => r.verdict === "fail")) return "fail";
|
|
7501
|
+
if (results.some((r) => r.verdict === "unknown")) return "unknown";
|
|
7502
|
+
if (results.some((r) => r.verdict === "warn")) return "warn";
|
|
7503
|
+
return "ok";
|
|
7504
|
+
}
|
|
7505
|
+
function verdictMark(v) {
|
|
7506
|
+
switch (v) {
|
|
7507
|
+
case "ok": return chalk.green("✓");
|
|
7508
|
+
case "warn": return chalk.yellow("!");
|
|
7509
|
+
case "fail": return chalk.red("✗");
|
|
7510
|
+
default: return chalk.gray("?");
|
|
7511
|
+
}
|
|
7512
|
+
}
|
|
7513
|
+
var LOG_PREFIX_RE = /^(?:(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+)?(?:\[([^\]]+)\]\s+)?([\s\S]*)$/;
|
|
7514
|
+
function parseLogLine(line) {
|
|
7515
|
+
const m = LOG_PREFIX_RE.exec(line);
|
|
7516
|
+
if (!m) return {
|
|
7517
|
+
ts: null,
|
|
7518
|
+
pod: null,
|
|
7519
|
+
text: line
|
|
7520
|
+
};
|
|
7521
|
+
return {
|
|
7522
|
+
ts: m[1] ?? null,
|
|
7523
|
+
pod: m[2] ?? null,
|
|
7524
|
+
text: m[3] ?? ""
|
|
7525
|
+
};
|
|
7526
|
+
}
|
|
7527
|
+
/**
|
|
7528
|
+
* Lines an operator scanning for a fault wants to see.
|
|
7529
|
+
*
|
|
7530
|
+
* Matches the structured `severity` field first, then the shapes that show up
|
|
7531
|
+
* in unstructured output. `refus`/`denied` are in the list because a permission
|
|
7532
|
+
* failure often logs at info level and is exactly what one is hunting for.
|
|
7533
|
+
*/
|
|
7534
|
+
function isErrorLine(text) {
|
|
7535
|
+
return /"(?:severity|level)":\s*"(?:ERROR|WARN(?:ING)?|error|warn)"|\bError:|\bERR!|refus|denied|EACCES|ECONNREFUSED/i.test(text);
|
|
7536
|
+
}
|
|
7537
|
+
/**
|
|
7538
|
+
* Pull an HTTP request record out of a log line, or null when it is not one.
|
|
7539
|
+
*
|
|
7540
|
+
* Only structured request lines are recognised. Guessing at prose would produce
|
|
7541
|
+
* a table with invented columns, which is worse than a short one.
|
|
7542
|
+
*/
|
|
7543
|
+
function parseRequestLine(text) {
|
|
7544
|
+
const start = text.indexOf("{");
|
|
7545
|
+
if (start === -1) return null;
|
|
7546
|
+
let parsed;
|
|
7547
|
+
try {
|
|
7548
|
+
parsed = JSON.parse(text.slice(start));
|
|
7549
|
+
} catch {
|
|
7550
|
+
return null;
|
|
7551
|
+
}
|
|
7552
|
+
if (parsed.message !== "request" && parsed.msg !== "request") return null;
|
|
7553
|
+
const num = (v) => {
|
|
7554
|
+
const n = typeof v === "string" ? Number(v) : typeof v === "number" ? v : NaN;
|
|
7555
|
+
return Number.isFinite(n) ? n : null;
|
|
7556
|
+
};
|
|
7557
|
+
return {
|
|
7558
|
+
status: num(parsed.status),
|
|
7559
|
+
method: typeof parsed.method === "string" ? parsed.method : "",
|
|
7560
|
+
path: typeof parsed.path === "string" ? parsed.path : "",
|
|
7561
|
+
latencyMs: num(parsed.latencyMs ?? parsed.durationMs)
|
|
7562
|
+
};
|
|
7563
|
+
}
|
|
7564
|
+
/**
|
|
7565
|
+
* Startup lines: what the server *decided* it was going to do. Which storage
|
|
7566
|
+
* backend it bound, which functions it loaded, whether auth tables were found.
|
|
7567
|
+
* This is the fastest way to tell a misconfiguration from a runtime fault.
|
|
7568
|
+
*/
|
|
7569
|
+
function isBootLine(text) {
|
|
7570
|
+
return /storage|Loaded function|Mounted|Auth tables|Server running|listening|Refusing|migrat/i.test(text);
|
|
7571
|
+
}
|
|
7572
|
+
/** Render a number of seconds back as the compact duration a user would type. */
|
|
7573
|
+
function formatDuration(seconds) {
|
|
7574
|
+
if (seconds % 86400 === 0 && seconds >= 86400) return `${seconds / 86400}d`;
|
|
7575
|
+
if (seconds % 3600 === 0 && seconds >= 3600) return `${seconds / 3600}h`;
|
|
7576
|
+
if (seconds % 60 === 0 && seconds >= 60) return `${seconds / 60}m`;
|
|
7577
|
+
return `${seconds}s`;
|
|
7578
|
+
}
|
|
7579
|
+
/**
|
|
7580
|
+
* Parse a duration like `15m`, `2h`, `90s`, `1d` (or a bare number of seconds)
|
|
7581
|
+
* into seconds. Returns null when it is not a duration.
|
|
7582
|
+
*/
|
|
7583
|
+
function parseSince(input) {
|
|
7584
|
+
if (!input) return null;
|
|
7585
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhd])?$/i.exec(input.trim());
|
|
7586
|
+
if (!m) return null;
|
|
7587
|
+
const value = parseFloat(m[1]);
|
|
7588
|
+
const unit = (m[2] || "s").toLowerCase();
|
|
7589
|
+
return Math.round(value * (unit === "s" ? 1 : unit === "m" ? 60 : unit === "h" ? 3600 : 86400));
|
|
7590
|
+
}
|
|
7591
|
+
async function fetchRuntimeLogs(client, projectId, opts) {
|
|
7592
|
+
const params = new URLSearchParams();
|
|
7593
|
+
if (opts.sinceSeconds !== void 0) params.set("sinceSeconds", String(opts.sinceSeconds));
|
|
7594
|
+
if (opts.tailLines !== void 0) params.set("tailLines", String(opts.tailLines));
|
|
7595
|
+
if (opts.previous) params.set("previous", "true");
|
|
7596
|
+
params.set("timestamps", "true");
|
|
7597
|
+
const qs = params.toString();
|
|
7598
|
+
return client.functions.invoke("runtime-logs", void 0, {
|
|
7599
|
+
method: "GET",
|
|
7600
|
+
path: `${projectId}${qs ? `?${qs}` : ""}`
|
|
7601
|
+
});
|
|
7602
|
+
}
|
|
7603
|
+
/** Print the per-pod states runtime-logs reports, including its hints. */
|
|
7604
|
+
function printPodStates(res) {
|
|
7605
|
+
if (res.state === "no_pods") {
|
|
7606
|
+
console.log(chalk.yellow(` ${res.message ?? "No pods are running for this project."}`));
|
|
7607
|
+
console.log("");
|
|
7608
|
+
return;
|
|
7609
|
+
}
|
|
7610
|
+
for (const p of res.pods ?? []) {
|
|
7611
|
+
if (p.state === "ok") continue;
|
|
7612
|
+
console.log(` ${chalk.yellow(p.pod)} ${chalk.gray(`(${p.state})`)}`);
|
|
7613
|
+
if (p.message) console.log(chalk.gray(` ${p.message}`));
|
|
7614
|
+
if (p.hint) console.log(chalk.cyan(` → ${p.hint}`));
|
|
7615
|
+
}
|
|
7616
|
+
if (res.truncated) console.log(chalk.gray(" (output truncated — narrow the window with --since)"));
|
|
7617
|
+
}
|
|
7618
|
+
/** Resolve the public origin a project is served at. */
|
|
7619
|
+
async function resolveOrigin(rawArgs, client, url, projectId) {
|
|
7620
|
+
const parsed = arg({ "--host": String }, {
|
|
7621
|
+
argv: rawArgs.slice(3),
|
|
7622
|
+
permissive: true
|
|
7623
|
+
});
|
|
7624
|
+
if (parsed["--host"]) {
|
|
7625
|
+
const h = parsed["--host"].trim().replace(/\/+$/, "");
|
|
7626
|
+
return /^https?:\/\//.test(h) ? h : `https://${h}`;
|
|
7627
|
+
}
|
|
7628
|
+
const [project, baseDomain] = await Promise.all([client.data.collection("projects").findById(projectId), fetchTenantBaseDomain(client, url)]);
|
|
7629
|
+
if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found.`);
|
|
7630
|
+
const host = projectHost(project, baseDomain);
|
|
7631
|
+
if (!host) fail("Could not determine the public URL for this project.", "It may never have been deployed. Pass --host <hostname> to probe an address directly.");
|
|
7632
|
+
return `https://${host}`;
|
|
7633
|
+
}
|
|
7634
|
+
async function healthCommand(rawArgs) {
|
|
7635
|
+
const parsed = arg({
|
|
7636
|
+
"--collection": String,
|
|
7637
|
+
"--function": String
|
|
7638
|
+
}, {
|
|
7639
|
+
argv: rawArgs.slice(3),
|
|
7640
|
+
permissive: true
|
|
7641
|
+
});
|
|
7642
|
+
const targets = {
|
|
7643
|
+
collection: parsed["--collection"] || "users",
|
|
7644
|
+
fn: parsed["--function"]
|
|
7645
|
+
};
|
|
7646
|
+
const { client, url } = await requireClient(rawArgs);
|
|
7647
|
+
const origin = await resolveOrigin(rawArgs, client, url, await requireProject(rawArgs, client));
|
|
7648
|
+
const results = [];
|
|
7649
|
+
for (const spec of PROBES) results.push(await runProbe(origin, spec, targets));
|
|
7650
|
+
const overall = overallVerdict(results);
|
|
7651
|
+
emit(() => {
|
|
7652
|
+
console.log("");
|
|
7653
|
+
console.log(chalk.bold(` 🩺 Health — ${displayProjectRef(rawArgs)}`) + chalk.gray(` ${origin}`));
|
|
7654
|
+
console.log("");
|
|
7655
|
+
const width = Math.max(...results.map((r) => r.label.length));
|
|
7656
|
+
for (const r of results) {
|
|
7657
|
+
const code = r.status === null ? chalk.red("---") : String(r.status);
|
|
7658
|
+
console.log(` ${verdictMark(r.verdict)} ${chalk.bold(r.label.padEnd(width))} ${code.padStart(3)} ${chalk.gray(`${r.ms}ms`)}`);
|
|
7659
|
+
console.log(` ${chalk.gray(r.meaning)}`);
|
|
7660
|
+
}
|
|
7661
|
+
console.log("");
|
|
7662
|
+
if (overall === "ok") console.log(chalk.green(" Everything reachable and wired as expected."));
|
|
7663
|
+
else {
|
|
7664
|
+
const bad = results.filter((r) => r.verdict === "fail" || r.verdict === "warn");
|
|
7665
|
+
console.log(chalk.gray(` ${bad.length} of ${results.length} check${bad.length === 1 ? "" : "s"} need attention.`));
|
|
7666
|
+
const wrongStatus = bad.filter((r) => !r.statusOk);
|
|
7667
|
+
if (wrongStatus.length > 0) {
|
|
7668
|
+
console.log(chalk.gray(" A healthy deployment answers:"));
|
|
7669
|
+
for (const r of wrongStatus) console.log(chalk.gray(` ${r.label.padEnd(width)} ${r.healthy}`));
|
|
7670
|
+
}
|
|
7671
|
+
}
|
|
7672
|
+
console.log("");
|
|
7673
|
+
}, {
|
|
7674
|
+
origin,
|
|
7675
|
+
overall,
|
|
7676
|
+
probes: results
|
|
7677
|
+
});
|
|
7678
|
+
if (overall === "fail") process.exit(1);
|
|
7679
|
+
}
|
|
7680
|
+
async function logView(rawArgs, view) {
|
|
7681
|
+
const parsed = arg({
|
|
7682
|
+
"--since": String,
|
|
7683
|
+
"--tail": Number,
|
|
7684
|
+
"--previous": Boolean
|
|
7685
|
+
}, {
|
|
7686
|
+
argv: rawArgs.slice(3),
|
|
7687
|
+
permissive: true
|
|
7688
|
+
});
|
|
7689
|
+
const sinceArg = parsed["--since"];
|
|
7690
|
+
if (sinceArg !== void 0 && parseSince(sinceArg) === null) fail(`--since must be a duration like 15m, 2h or 90s; received "${sinceArg}".`);
|
|
7691
|
+
const sinceSeconds = parseSince(sinceArg) ?? view.defaultSinceSeconds;
|
|
7692
|
+
const { client } = await requireClient(rawArgs);
|
|
7693
|
+
const projectId = await requireProject(rawArgs, client);
|
|
7694
|
+
let res;
|
|
7695
|
+
try {
|
|
7696
|
+
res = await fetchRuntimeLogs(client, projectId, {
|
|
7697
|
+
sinceSeconds,
|
|
7698
|
+
tailLines: parsed["--tail"] ?? 500,
|
|
7699
|
+
previous: Boolean(parsed["--previous"])
|
|
7700
|
+
});
|
|
7701
|
+
} catch (e) {
|
|
7702
|
+
reportError(e, "Failed to fetch runtime logs");
|
|
7703
|
+
}
|
|
7704
|
+
const parsedLines = (res.logs ?? "").split("\n").filter((l) => l !== "").map(parseLogLine);
|
|
7705
|
+
const kept = view.filter ? parsedLines.filter((l) => view.filter(l.text)) : parsedLines;
|
|
7706
|
+
const shown = kept.slice(-view.limit);
|
|
7707
|
+
emit(() => {
|
|
7708
|
+
console.log("");
|
|
7709
|
+
console.log(chalk.bold(` ${view.title} — ${displayProjectRef(rawArgs)}`) + chalk.gray(` last ${formatDuration(sinceSeconds)}`));
|
|
7710
|
+
console.log("");
|
|
7711
|
+
printPodStates(res);
|
|
7712
|
+
if (shown.length === 0) {
|
|
7713
|
+
console.log(chalk.gray(" (nothing matched in this window)"));
|
|
7714
|
+
console.log("");
|
|
7715
|
+
return;
|
|
7716
|
+
}
|
|
7717
|
+
for (const l of shown) console.log(` ${l.pod ? chalk.gray(`[${l.pod}] `) : ""}${l.text}`);
|
|
7718
|
+
console.log("");
|
|
7719
|
+
if (kept.length > shown.length) {
|
|
7720
|
+
console.log(chalk.gray(` (showing the last ${shown.length} of ${kept.length} matching lines)`));
|
|
7721
|
+
console.log("");
|
|
7722
|
+
}
|
|
7723
|
+
}, {
|
|
7724
|
+
sinceSeconds,
|
|
7725
|
+
state: res.state ?? null,
|
|
7726
|
+
pods: res.pods ?? [],
|
|
7727
|
+
truncated: Boolean(res.truncated),
|
|
7728
|
+
matched: kept.length,
|
|
7729
|
+
lines: shown
|
|
7730
|
+
});
|
|
7731
|
+
}
|
|
7732
|
+
async function requestsCommand(rawArgs) {
|
|
7733
|
+
const parsed = arg({
|
|
7734
|
+
"--since": String,
|
|
7735
|
+
"--tail": Number
|
|
5027
7736
|
}, {
|
|
5028
|
-
argv: rawArgs.slice(
|
|
7737
|
+
argv: rawArgs.slice(3),
|
|
5029
7738
|
permissive: true
|
|
5030
7739
|
});
|
|
7740
|
+
const sinceArg = parsed["--since"];
|
|
7741
|
+
if (sinceArg !== void 0 && parseSince(sinceArg) === null) fail(`--since must be a duration like 15m, 2h or 90s; received "${sinceArg}".`);
|
|
7742
|
+
const sinceSeconds = parseSince(sinceArg) ?? 900;
|
|
5031
7743
|
const { client } = await requireClient(rawArgs);
|
|
5032
7744
|
const projectId = await requireProject(rawArgs, client);
|
|
5033
|
-
|
|
5034
|
-
const explicitId = cloudPositionals(rawArgs).slice(1)[0];
|
|
5035
|
-
await confirmDestructive({
|
|
5036
|
-
yes: Boolean(args["--yes"]),
|
|
5037
|
-
prompt: `Cancel the in-flight build for project ${projectRef}?`
|
|
5038
|
-
});
|
|
7745
|
+
let res;
|
|
5039
7746
|
try {
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
} : { projectId }, { path: "cancel" });
|
|
5044
|
-
emit(() => {
|
|
5045
|
-
success(`Cancelled deployment ${chalk.bold(res.deploymentId)}`);
|
|
5046
|
-
if (res.buildJobDeleted) console.log(chalk.gray(" The build job was deleted."));
|
|
5047
|
-
console.log("");
|
|
5048
|
-
}, {
|
|
5049
|
-
success: true,
|
|
5050
|
-
deploymentId: res.deploymentId,
|
|
5051
|
-
buildJobDeleted: res.buildJobDeleted
|
|
7747
|
+
res = await fetchRuntimeLogs(client, projectId, {
|
|
7748
|
+
sinceSeconds,
|
|
7749
|
+
tailLines: parsed["--tail"] ?? 1e3
|
|
5052
7750
|
});
|
|
5053
7751
|
} catch (e) {
|
|
5054
|
-
|
|
5055
|
-
reportError(e, "Failed to cancel deployment");
|
|
7752
|
+
reportError(e, "Failed to fetch runtime logs");
|
|
5056
7753
|
}
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
"
|
|
5075
|
-
"--project": String,
|
|
5076
|
-
"-p": "--project"
|
|
7754
|
+
const shown = (res.logs ?? "").split("\n").filter((l) => l !== "").map((l) => parseRequestLine(parseLogLine(l).text)).filter((e) => e !== null).slice(-40);
|
|
7755
|
+
emit(() => {
|
|
7756
|
+
console.log("");
|
|
7757
|
+
console.log(chalk.bold(` 🌐 Requests — ${displayProjectRef(rawArgs)}`) + chalk.gray(` last ${formatDuration(sinceSeconds)}`));
|
|
7758
|
+
console.log("");
|
|
7759
|
+
printPodStates(res);
|
|
7760
|
+
if (shown.length === 0) {
|
|
7761
|
+
console.log(chalk.gray(" No structured request lines in this window."));
|
|
7762
|
+
console.log(chalk.gray(" (this view needs the server's request logging; try `debug logs`)"));
|
|
7763
|
+
console.log("");
|
|
7764
|
+
return;
|
|
7765
|
+
}
|
|
7766
|
+
for (const e of shown) {
|
|
7767
|
+
const status = e.status ?? 0;
|
|
7768
|
+
const color = status >= 500 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
|
|
7769
|
+
console.log(` ${color(String(e.status ?? "---").padStart(3))} ${e.method.padEnd(6)} ${e.path.slice(0, 70).padEnd(70)} ${chalk.gray(e.latencyMs === null ? "" : `${e.latencyMs}ms`)}`);
|
|
7770
|
+
}
|
|
7771
|
+
console.log("");
|
|
5077
7772
|
}, {
|
|
5078
|
-
|
|
5079
|
-
|
|
7773
|
+
sinceSeconds,
|
|
7774
|
+
requests: shown
|
|
5080
7775
|
});
|
|
7776
|
+
}
|
|
7777
|
+
async function podCommand(rawArgs) {
|
|
5081
7778
|
const { client } = await requireClient(rawArgs);
|
|
5082
7779
|
const projectId = await requireProject(rawArgs, client);
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
7780
|
+
let m;
|
|
7781
|
+
try {
|
|
7782
|
+
m = await client.functions.invoke("metrics", void 0, {
|
|
7783
|
+
method: "GET",
|
|
7784
|
+
path: projectId
|
|
7785
|
+
});
|
|
7786
|
+
} catch (e) {
|
|
7787
|
+
reportError(e, "Failed to read workload placement");
|
|
7788
|
+
}
|
|
7789
|
+
const p = m.placement ?? {};
|
|
7790
|
+
const replicas = p.replicas ?? {};
|
|
7791
|
+
emit(() => {
|
|
7792
|
+
console.log("");
|
|
7793
|
+
console.log(chalk.bold(` ☸ Workload — ${displayProjectRef(rawArgs)}`));
|
|
7794
|
+
console.log("");
|
|
7795
|
+
keyValues([
|
|
7796
|
+
["Status", m.status ? colorStatus(m.status === "running" ? "active" : m.status) : void 0],
|
|
7797
|
+
["Replicas", replicas.desired === void 0 ? void 0 : `${replicas.available ?? 0} / ${replicas.desired} available`],
|
|
7798
|
+
["Namespace", p.namespace],
|
|
7799
|
+
["Cluster", p.cluster],
|
|
7800
|
+
["Region", [p.provider, p.region].filter(Boolean).join(" · ") || void 0],
|
|
7801
|
+
["Host", p.host],
|
|
7802
|
+
["Image", p.image],
|
|
7803
|
+
["CPU", m.cpu ?? void 0],
|
|
7804
|
+
["Memory", m.memory ?? void 0]
|
|
7805
|
+
]);
|
|
7806
|
+
console.log("");
|
|
7807
|
+
if ((replicas.available ?? 0) === 0 && (replicas.desired ?? 0) > 0) {
|
|
7808
|
+
console.log(chalk.yellow(" No replica is available — the pod is not passing its readiness check."));
|
|
7809
|
+
console.log(chalk.gray(" See why with: ") + chalk.bold("rebase cloud debug logs --previous"));
|
|
7810
|
+
console.log("");
|
|
7811
|
+
}
|
|
7812
|
+
}, {
|
|
7813
|
+
status: m.status ?? null,
|
|
7814
|
+
placement: p
|
|
5087
7815
|
});
|
|
7816
|
+
}
|
|
7817
|
+
async function dbDebugCommand(rawArgs) {
|
|
7818
|
+
const { client } = await requireClient(rawArgs);
|
|
7819
|
+
const projectId = await requireProject(rawArgs, client);
|
|
7820
|
+
let info;
|
|
5088
7821
|
try {
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
7822
|
+
info = await client.functions.invoke("db-info", void 0, {
|
|
7823
|
+
method: "GET",
|
|
7824
|
+
path: projectId
|
|
7825
|
+
});
|
|
7826
|
+
} catch (e) {
|
|
7827
|
+
reportError(e, "Failed to read database connection info");
|
|
7828
|
+
}
|
|
7829
|
+
const pf = info.portForward;
|
|
7830
|
+
const forwardCmd = pf ? `kubectl port-forward -n ${pf.namespace} svc/${pf.service} ${pf.localPort}:${pf.remotePort}` : null;
|
|
7831
|
+
const psqlCmd = pf && info.username && info.database ? `psql -h 127.0.0.1 -p ${pf.localPort} -U ${info.username} -d ${info.database}` : null;
|
|
7832
|
+
emit(() => {
|
|
7833
|
+
console.log("");
|
|
7834
|
+
console.log(chalk.bold(` 🐘 Database — ${displayProjectRef(rawArgs)}`));
|
|
7835
|
+
console.log("");
|
|
7836
|
+
if (info.unavailableReason) {
|
|
7837
|
+
console.log(chalk.yellow(` ${info.unavailableReason}`));
|
|
7838
|
+
console.log("");
|
|
7839
|
+
return;
|
|
7840
|
+
}
|
|
7841
|
+
keyValues([
|
|
7842
|
+
["Type", info.type],
|
|
7843
|
+
["Host", info.host],
|
|
7844
|
+
["Port", info.port],
|
|
7845
|
+
["Database", info.database],
|
|
7846
|
+
["Username", info.username],
|
|
7847
|
+
["Password", info.passwordAvailable ? chalk.gray("stored — not shown here") : chalk.yellow("none stored")]
|
|
7848
|
+
]);
|
|
7849
|
+
console.log("");
|
|
7850
|
+
if (forwardCmd) {
|
|
7851
|
+
console.log(chalk.gray(" A managed database is only reachable inside its cluster. To connect:"));
|
|
7852
|
+
console.log("");
|
|
7853
|
+
console.log(` ${forwardCmd}`);
|
|
7854
|
+
if (psqlCmd) console.log(` ${psqlCmd}`);
|
|
7855
|
+
console.log("");
|
|
7856
|
+
if (info.passwordAvailable) {
|
|
7857
|
+
console.log(chalk.gray(" Get the password with: ") + chalk.bold("rebase cloud db info --reveal"));
|
|
7858
|
+
console.log("");
|
|
7859
|
+
}
|
|
7860
|
+
}
|
|
7861
|
+
}, {
|
|
7862
|
+
type: info.type ?? null,
|
|
7863
|
+
host: info.host ?? null,
|
|
7864
|
+
port: info.port ?? null,
|
|
7865
|
+
database: info.database ?? null,
|
|
7866
|
+
username: info.username ?? null,
|
|
7867
|
+
passwordAvailable: Boolean(info.passwordAvailable),
|
|
7868
|
+
portForwardCommand: forwardCmd,
|
|
7869
|
+
psqlCommand: psqlCmd
|
|
7870
|
+
});
|
|
7871
|
+
}
|
|
7872
|
+
async function debugCommand(action, rawArgs) {
|
|
7873
|
+
switch (action) {
|
|
7874
|
+
case void 0:
|
|
7875
|
+
case "health":
|
|
7876
|
+
await healthCommand(rawArgs);
|
|
7877
|
+
break;
|
|
7878
|
+
case "logs":
|
|
7879
|
+
await logView(rawArgs, {
|
|
7880
|
+
defaultSinceSeconds: 900,
|
|
7881
|
+
limit: 200,
|
|
7882
|
+
title: "📄 Logs"
|
|
5095
7883
|
});
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
7884
|
+
break;
|
|
7885
|
+
case "errors":
|
|
7886
|
+
await logView(rawArgs, {
|
|
7887
|
+
filter: isErrorLine,
|
|
7888
|
+
defaultSinceSeconds: 3600,
|
|
7889
|
+
limit: 40,
|
|
7890
|
+
title: "🔥 Errors"
|
|
5102
7891
|
});
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
await
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
status: "active"
|
|
7892
|
+
break;
|
|
7893
|
+
case "boot":
|
|
7894
|
+
await logView(rawArgs, {
|
|
7895
|
+
filter: isBootLine,
|
|
7896
|
+
defaultSinceSeconds: 168 * 3600,
|
|
7897
|
+
limit: 25,
|
|
7898
|
+
title: "🚀 Boot"
|
|
5111
7899
|
});
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
7900
|
+
break;
|
|
7901
|
+
case "requests":
|
|
7902
|
+
await requestsCommand(rawArgs);
|
|
7903
|
+
break;
|
|
7904
|
+
case "pod":
|
|
7905
|
+
case "workload":
|
|
7906
|
+
await podCommand(rawArgs);
|
|
7907
|
+
break;
|
|
7908
|
+
case "db":
|
|
7909
|
+
await dbDebugCommand(rawArgs);
|
|
7910
|
+
break;
|
|
7911
|
+
case "help":
|
|
7912
|
+
case "--help":
|
|
7913
|
+
printDebugHelp();
|
|
7914
|
+
break;
|
|
7915
|
+
default:
|
|
7916
|
+
if (isJsonMode()) fail(`Unknown debug command: ${action}`, void 0, "unknown_command");
|
|
7917
|
+
console.error(chalk.red(`Unknown debug command: ${action}`));
|
|
7918
|
+
console.log("");
|
|
7919
|
+
printDebugHelp();
|
|
7920
|
+
process.exit(1);
|
|
5115
7921
|
}
|
|
5116
7922
|
}
|
|
7923
|
+
function printDebugHelp() {
|
|
7924
|
+
console.log(`
|
|
7925
|
+
${chalk.bold("rebase cloud debug")} — Find out why a deployed project is misbehaving
|
|
7926
|
+
|
|
7927
|
+
${chalk.green.bold("Usage")}
|
|
7928
|
+
rebase cloud debug ${chalk.blue("<subcommand>")} [options]
|
|
7929
|
+
|
|
7930
|
+
${chalk.green.bold("End-to-end")}
|
|
7931
|
+
${chalk.blue.bold("health")} Probe the live URL and explain every status code ${chalk.gray("(default)")}
|
|
7932
|
+
|
|
7933
|
+
${chalk.green.bold("Runtime")}
|
|
7934
|
+
${chalk.blue.bold("logs")} ${chalk.gray("[--since 15m]")} Recent application logs
|
|
7935
|
+
${chalk.blue.bold("errors")} ${chalk.gray("[--since 1h]")} Error and warning lines only
|
|
7936
|
+
${chalk.blue.bold("requests")} ${chalk.gray("[--since 15m]")} HTTP requests the server logged ${chalk.gray("(status, path, latency)")}
|
|
7937
|
+
${chalk.blue.bold("boot")} What the server decided at startup ${chalk.gray("(storage, functions, auth)")}
|
|
7938
|
+
${chalk.blue.bold("pod")} Replicas, image, namespace, cluster placement
|
|
7939
|
+
|
|
7940
|
+
${chalk.green.bold("Data")}
|
|
7941
|
+
${chalk.blue.bold("db")} Connection shape + the port-forward recipe ${chalk.gray("(no password)")}
|
|
7942
|
+
|
|
7943
|
+
${chalk.green.bold("Options")}
|
|
7944
|
+
${chalk.blue("--since <dur>")} Lookback window: ${chalk.gray("90s, 15m, 2h, 1d")}
|
|
7945
|
+
${chalk.blue("--tail <n>")} Lines to read per pod ${chalk.gray("(default 500)")}
|
|
7946
|
+
${chalk.blue("--previous")} Read the CRASHED container instance ${chalk.gray("(where the reason lives)")}
|
|
7947
|
+
${chalk.blue("--host <hostname>")} Probe this address instead of the project's own
|
|
7948
|
+
${chalk.blue("--collection <name>")} Collection for the unauth-read probe ${chalk.gray("(default: users)")}
|
|
7949
|
+
${chalk.blue("--function <name>")} Also assert this function loaded ${chalk.gray("(checked against the listing)")}
|
|
7950
|
+
${chalk.blue("--project, -p <slug>")} Operate on a project without linking
|
|
7951
|
+
|
|
7952
|
+
${chalk.gray("Everything here is read-only. `health` exits non-zero when a check fails,")}
|
|
7953
|
+
${chalk.gray("so it works in a deploy script. To restart a workload, use `rebase cloud restart`.")}
|
|
7954
|
+
`);
|
|
7955
|
+
}
|
|
5117
7956
|
//#endregion
|
|
5118
7957
|
//#region src/commands/cloud/resources.ts
|
|
5119
7958
|
/**
|
|
5120
7959
|
* `rebase cloud` resource subcommands: status, metrics, webhooks, storage,
|
|
5121
7960
|
* clusters, billing.
|
|
5122
7961
|
*/
|
|
7962
|
+
/**
|
|
7963
|
+
* One line describing this project's storage — or `undefined` when the control
|
|
7964
|
+
* plane could not be asked, which prints as a blank rather than a guess.
|
|
7965
|
+
*
|
|
7966
|
+
* `status` used to render the `storages` row and nothing else, so a project
|
|
7967
|
+
* whose bucket is configured through its own `STORAGE_TYPE`/`S3_*` variables —
|
|
7968
|
+
* the supported path, and the one `mergeStorageEnv` deliberately lets WIN over
|
|
7969
|
+
* the row — was reported as `Storage: none` while its pod logged `Initialized
|
|
7970
|
+
* storage backends count: 1` against a live bucket. Storage is the thing an app
|
|
7971
|
+
* refuses to boot without, so that false negative sends someone off to
|
|
7972
|
+
* provision a bucket they already have. The row is not the answer; the tenant's
|
|
7973
|
+
* resolved environment is, and the control plane computes it with the same two
|
|
7974
|
+
* functions the build log uses.
|
|
7975
|
+
*/
|
|
7976
|
+
function describeStorageState(state) {
|
|
7977
|
+
const verdict = state?.effective;
|
|
7978
|
+
if (!verdict?.kind) return void 0;
|
|
7979
|
+
const via = state?.overridden ? chalk.gray(" · from env vars") : "";
|
|
7980
|
+
switch (verdict.kind) {
|
|
7981
|
+
case "durable": return `${chalk.green("durable")}${verdict.summary ? ` · ${verdict.summary}` : ""}${via}`;
|
|
7982
|
+
case "ephemeral": return `${chalk.yellow("ephemeral")} ${chalk.gray("· uploads are lost on restart")}`;
|
|
7983
|
+
case "incomplete": return `${chalk.red("incomplete")} ${chalk.gray(`· missing ${(verdict.missing ?? []).join(", ")}`)}`;
|
|
7984
|
+
case "unrecognized": return `${chalk.red("unrecognized")} ${chalk.gray(`· STORAGE_TYPE=${verdict.storageType ?? "?"}`)}`;
|
|
7985
|
+
default: return;
|
|
7986
|
+
}
|
|
7987
|
+
}
|
|
7988
|
+
/**
|
|
7989
|
+
* One line describing the database.
|
|
7990
|
+
*
|
|
7991
|
+
* `connectionStatus` is written `"untested"` at creation and only ever changed
|
|
7992
|
+
* by `rebase cloud db test`, so `managed (untested)` was reporting the absence
|
|
7993
|
+
* of a manual test as though it were the database's condition — on a project
|
|
7994
|
+
* that had just deployed against it. A never-tested database says only its
|
|
7995
|
+
* type; the verdict appears once there is one.
|
|
7996
|
+
*/
|
|
7997
|
+
function describeDatabaseState(db) {
|
|
7998
|
+
if (!db) return void 0;
|
|
7999
|
+
const type = typeof db.type === "string" ? db.type : "database";
|
|
8000
|
+
const connection = db.connectionStatus;
|
|
8001
|
+
if (connection === "connected" || connection === "failed") return `${type} (${colorStatus(connection)})`;
|
|
8002
|
+
return `${type} ${chalk.gray("· not tested (`rebase cloud db test`)")}`;
|
|
8003
|
+
}
|
|
5123
8004
|
async function statusCommand(rawArgs) {
|
|
5124
8005
|
const { client, url } = await requireClient(rawArgs);
|
|
5125
8006
|
const projectId = await requireProject(rawArgs, client);
|
|
5126
8007
|
try {
|
|
5127
8008
|
const project = await client.data.collection("projects").findById(projectId);
|
|
5128
|
-
if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found
|
|
8009
|
+
if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found.`, void 0, "not_found");
|
|
5129
8010
|
const [db, storage, deploy, baseDomain] = await Promise.all([
|
|
5130
8011
|
firstRow(client, "databases", projectId),
|
|
5131
|
-
|
|
8012
|
+
client.functions.invoke("storage-provision", void 0, {
|
|
8013
|
+
method: "GET",
|
|
8014
|
+
path: projectId
|
|
8015
|
+
}).catch(() => void 0),
|
|
5132
8016
|
latestDeployment(client, projectId),
|
|
5133
8017
|
fetchTenantBaseDomain(client, url)
|
|
5134
8018
|
]);
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
[
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
8019
|
+
const storageLine = describeStorageState(storage);
|
|
8020
|
+
const databaseLine = describeDatabaseState(db);
|
|
8021
|
+
emit(() => {
|
|
8022
|
+
console.log("");
|
|
8023
|
+
console.log(` ${chalk.bold(project.name ?? project.subdomain ?? "")} ${chalk.gray(`[${project.subdomain ?? displayProjectRef(rawArgs)}]`)} ${colorStatus(project.status)}`);
|
|
8024
|
+
console.log("");
|
|
8025
|
+
keyValues([
|
|
8026
|
+
["URL", projectHost(project, baseDomain)],
|
|
8027
|
+
["Branch", project.gitBranch],
|
|
8028
|
+
["Last deploy", deploy ? `${colorStatus(deploy.status)} · ${fmtDate(deploy.createdAt)}` : "never"],
|
|
8029
|
+
["Database", databaseLine],
|
|
8030
|
+
["Storage", storageLine]
|
|
8031
|
+
]);
|
|
8032
|
+
console.log("");
|
|
8033
|
+
}, {
|
|
8034
|
+
projectId: String(project.id),
|
|
8035
|
+
name: project.name ?? null,
|
|
8036
|
+
subdomain: project.subdomain ?? null,
|
|
8037
|
+
status: project.status ?? null,
|
|
8038
|
+
url: projectHost(project, baseDomain) ?? null,
|
|
8039
|
+
branch: project.gitBranch ?? null,
|
|
8040
|
+
lastDeploy: deploy ? {
|
|
8041
|
+
id: String(deploy.id),
|
|
8042
|
+
status: deploy.status ?? null,
|
|
8043
|
+
createdAt: deploy.createdAt ?? null
|
|
8044
|
+
} : null,
|
|
8045
|
+
database: db ? {
|
|
8046
|
+
type: db.type ?? null,
|
|
8047
|
+
connectionStatus: db.connectionStatus ?? null
|
|
8048
|
+
} : null,
|
|
8049
|
+
storage: storage ?? null
|
|
8050
|
+
});
|
|
5146
8051
|
} catch (e) {
|
|
5147
8052
|
reportError(e, "Failed to load status");
|
|
5148
8053
|
}
|
|
@@ -5271,8 +8176,9 @@ function printStorageHelp() {
|
|
|
5271
8176
|
console.log(chalk.gray(" --region <region> Region"));
|
|
5272
8177
|
console.log(chalk.gray(" --force-path-style Required by MinIO and some gateways"));
|
|
5273
8178
|
console.log("");
|
|
5274
|
-
console.log(chalk.gray(" Without either,
|
|
5275
|
-
console.log(chalk.gray("
|
|
8179
|
+
console.log(chalk.gray(" Without either, file storage stays off: uploads are refused with"));
|
|
8180
|
+
console.log(chalk.gray(" 501 STORAGE_NOT_CONFIGURED rather than written to a container"));
|
|
8181
|
+
console.log(chalk.gray(" filesystem that is erased on the next restart."));
|
|
5276
8182
|
console.log("");
|
|
5277
8183
|
}
|
|
5278
8184
|
async function storageCreateCommand(rawArgs) {
|
|
@@ -5547,6 +8453,9 @@ async function cloudCommand(subcommand, rawArgs) {
|
|
|
5547
8453
|
case "metrics":
|
|
5548
8454
|
await metricsCommand(rawArgs);
|
|
5549
8455
|
break;
|
|
8456
|
+
case "debug":
|
|
8457
|
+
await debugCommand(action, rawArgs);
|
|
8458
|
+
break;
|
|
5550
8459
|
case "env":
|
|
5551
8460
|
await envCommand(action, rawArgs);
|
|
5552
8461
|
break;
|
|
@@ -5650,14 +8559,15 @@ ${chalk.green.bold("Projects")}
|
|
|
5650
8559
|
${chalk.blue.bold("projects delete")} ${chalk.gray("[id]")} Delete a project
|
|
5651
8560
|
|
|
5652
8561
|
${chalk.green.bold("Deploy & observe")}
|
|
5653
|
-
${chalk.blue.bold("deploy")} ${chalk.gray("[--source .]")}
|
|
8562
|
+
${chalk.blue.bold("deploy")} ${chalk.gray("[--source .] [-m msg]")} Deploy the linked project + stream build logs
|
|
5654
8563
|
${chalk.blue.bold("logs")} ${chalk.gray("[--runtime] [-f]")} Show build (or runtime) logs
|
|
5655
|
-
${chalk.blue.bold("deployments list")}
|
|
8564
|
+
${chalk.blue.bold("deployments list")} ${chalk.gray("[--limit N|--all]")} Deployment history ${chalk.gray("(status, duration, trigger)")}
|
|
5656
8565
|
${chalk.blue.bold("rollback")} ${chalk.gray("[id] [-y]")} Roll back to a successful deploy
|
|
5657
8566
|
${chalk.blue.bold("cancel")} ${chalk.gray("[-y]")} Cancel the in-flight build
|
|
5658
8567
|
${chalk.blue.bold("start|stop|restart")} ${chalk.gray("[-y]")} Power ops ${chalk.gray("(stop/restart need -y)")}
|
|
5659
8568
|
${chalk.blue.bold("status")} One-glance project status
|
|
5660
8569
|
${chalk.blue.bold("metrics")} Live CPU / memory / disk
|
|
8570
|
+
${chalk.blue.bold("debug")} ${chalk.gray("[health|logs|…]")} Diagnose a misbehaving deployment ${chalk.gray("(read-only)")}
|
|
5661
8571
|
|
|
5662
8572
|
${chalk.green.bold("Config")}
|
|
5663
8573
|
${chalk.blue.bold("env list|set|unset|reveal|pull")}
|
|
@@ -5692,6 +8602,199 @@ ${chalk.gray("Docs: https://rebase.pro/docs")}
|
|
|
5692
8602
|
`);
|
|
5693
8603
|
}
|
|
5694
8604
|
//#endregion
|
|
8605
|
+
//#region src/commands/apps.ts
|
|
8606
|
+
/**
|
|
8607
|
+
* CLI command: rebase apps
|
|
8608
|
+
*
|
|
8609
|
+
* Inspect the apps this repository contributes to a project, adopt a
|
|
8610
|
+
* `rebase.json` for a project that predates it, and print the client bootstrap
|
|
8611
|
+
* an app needs to reach its backend.
|
|
8612
|
+
*
|
|
8613
|
+
* The distinction that runs through all of this: a *repository* declares apps,
|
|
8614
|
+
* a *project* owns them. Two repositories can contribute to the same project and
|
|
8615
|
+
* never know about each other, which is what makes a separate frontend repo — or
|
|
8616
|
+
* a mobile app with no repo relationship at all — an ordinary thing rather than
|
|
8617
|
+
* a special case.
|
|
8618
|
+
*/
|
|
8619
|
+
function printHelp$1() {
|
|
8620
|
+
console.log(`
|
|
8621
|
+
${chalk.bold("rebase apps")} — the apps this repository contributes
|
|
8622
|
+
|
|
8623
|
+
${chalk.bold("Usage")}
|
|
8624
|
+
rebase apps list List declared apps and their build outputs
|
|
8625
|
+
rebase apps init Write a rebase.json inferred from this project
|
|
8626
|
+
rebase apps config <app> Print the client configuration for an app
|
|
8627
|
+
|
|
8628
|
+
${chalk.bold("Options")}
|
|
8629
|
+
--json Machine-readable output
|
|
8630
|
+
--force Overwrite an existing rebase.json (apps init)
|
|
8631
|
+
-h, --help Show this help
|
|
8632
|
+
`.trim());
|
|
8633
|
+
}
|
|
8634
|
+
async function appsCommand(subcommand, rawArgs = []) {
|
|
8635
|
+
const args = arg({
|
|
8636
|
+
"--json": Boolean,
|
|
8637
|
+
"--force": Boolean,
|
|
8638
|
+
"--help": Boolean,
|
|
8639
|
+
"-h": "--help"
|
|
8640
|
+
}, {
|
|
8641
|
+
argv: rawArgs.slice(3),
|
|
8642
|
+
permissive: true
|
|
8643
|
+
});
|
|
8644
|
+
if (args["--help"] || !subcommand || subcommand === "--help") {
|
|
8645
|
+
printHelp$1();
|
|
8646
|
+
return;
|
|
8647
|
+
}
|
|
8648
|
+
switch (subcommand) {
|
|
8649
|
+
case "list":
|
|
8650
|
+
await listApps(Boolean(args["--json"]));
|
|
8651
|
+
break;
|
|
8652
|
+
case "init":
|
|
8653
|
+
await initManifest(Boolean(args["--force"]));
|
|
8654
|
+
break;
|
|
8655
|
+
case "config":
|
|
8656
|
+
await printAppConfig(args._[1], Boolean(args["--json"]));
|
|
8657
|
+
break;
|
|
8658
|
+
default:
|
|
8659
|
+
console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
|
|
8660
|
+
console.log("");
|
|
8661
|
+
printHelp$1();
|
|
8662
|
+
process.exit(1);
|
|
8663
|
+
}
|
|
8664
|
+
}
|
|
8665
|
+
function describeApp(app) {
|
|
8666
|
+
switch (app.type) {
|
|
8667
|
+
case "backend": return `config: ${app.config ?? "config"}, mode: ${app.mode ?? "cms"}`;
|
|
8668
|
+
case "static": return `${app.root} → ${app.output}`;
|
|
8669
|
+
case "admin": return app.mode === "bundled" ? `bundled → ${app.output ?? "?"}` : "hosted by the platform";
|
|
8670
|
+
case "mobile": return app.platform;
|
|
8671
|
+
case "custom": return app.dockerfile ?? "Dockerfile";
|
|
8672
|
+
default: return "";
|
|
8673
|
+
}
|
|
8674
|
+
}
|
|
8675
|
+
async function listApps(asJson) {
|
|
8676
|
+
const loaded = loadManifestOrExit(requireProjectRoot());
|
|
8677
|
+
const compatibility = assessManagedCompatibility(loaded.manifest);
|
|
8678
|
+
if (asJson) {
|
|
8679
|
+
console.log(JSON.stringify({
|
|
8680
|
+
source: loaded.source,
|
|
8681
|
+
runtime: loaded.manifest.runtime,
|
|
8682
|
+
apps: loaded.manifest.apps,
|
|
8683
|
+
managed: compatibility
|
|
8684
|
+
}, null, 2));
|
|
8685
|
+
return;
|
|
8686
|
+
}
|
|
8687
|
+
if (loaded.source === "synthesized") {
|
|
8688
|
+
console.log(chalk.dim("No rebase.json — showing the layout inferred from this project."));
|
|
8689
|
+
console.log(chalk.dim(`Run ${chalk.cyan("rebase apps init")} to write it down.\n`));
|
|
8690
|
+
}
|
|
8691
|
+
console.log(chalk.bold(`Runtime ${loaded.manifest.runtime}`));
|
|
8692
|
+
console.log("");
|
|
8693
|
+
const entries = Object.entries(loaded.manifest.apps);
|
|
8694
|
+
if (entries.length === 0) {
|
|
8695
|
+
console.log(chalk.yellow("No apps declared."));
|
|
8696
|
+
return;
|
|
8697
|
+
}
|
|
8698
|
+
const width = Math.max(...entries.map(([name]) => name.length));
|
|
8699
|
+
for (const [name, app] of entries) console.log(` ${chalk.cyan(name.padEnd(width))} ${chalk.dim(app.type.padEnd(8))} ${describeApp(app)}`);
|
|
8700
|
+
console.log("");
|
|
8701
|
+
if (compatibility.eligible) console.log(chalk.green("✓ Eligible for the managed runtime."));
|
|
8702
|
+
else {
|
|
8703
|
+
console.log(chalk.yellow("• Uses the custom runtime:"));
|
|
8704
|
+
for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
|
|
8705
|
+
}
|
|
8706
|
+
}
|
|
8707
|
+
async function initManifest(force) {
|
|
8708
|
+
const projectRoot = requireProjectRoot();
|
|
8709
|
+
if (manifestExists(projectRoot) && !force) {
|
|
8710
|
+
console.error(chalk.red("✗ rebase.json already exists."));
|
|
8711
|
+
console.error(chalk.dim(" Pass --force to overwrite it."));
|
|
8712
|
+
process.exit(1);
|
|
8713
|
+
}
|
|
8714
|
+
const manifest = synthesizeManifest(projectRoot);
|
|
8715
|
+
const filePath = writeManifest(projectRoot, manifest);
|
|
8716
|
+
console.log(chalk.green(`✓ Wrote ${path.relative(projectRoot, filePath)}`));
|
|
8717
|
+
console.log("");
|
|
8718
|
+
for (const [name, app] of Object.entries(manifest.apps)) console.log(` ${chalk.cyan(name)} ${chalk.dim(`(${app.type})`)}`);
|
|
8719
|
+
const compatibility = assessManagedCompatibility(manifest);
|
|
8720
|
+
if (!compatibility.eligible) {
|
|
8721
|
+
console.log("");
|
|
8722
|
+
console.log(chalk.yellow("This project will use the custom runtime:"));
|
|
8723
|
+
for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
|
|
8724
|
+
}
|
|
8725
|
+
}
|
|
8726
|
+
/**
|
|
8727
|
+
* Print what a client needs to reach this project.
|
|
8728
|
+
*
|
|
8729
|
+
* Never prints a secret. The API URL and an app's publishable identity are meant
|
|
8730
|
+
* to ship inside a client bundle; anything that is not safe there does not belong
|
|
8731
|
+
* in output that will inevitably be pasted into a `.env` that gets committed.
|
|
8732
|
+
*/
|
|
8733
|
+
async function printAppConfig(appName, asJson) {
|
|
8734
|
+
const projectRoot = requireProjectRoot();
|
|
8735
|
+
const loaded = loadManifestOrExit(projectRoot);
|
|
8736
|
+
if (!appName) {
|
|
8737
|
+
console.error(chalk.red("✗ Which app? Usage: rebase apps config <app>"));
|
|
8738
|
+
process.exit(1);
|
|
8739
|
+
}
|
|
8740
|
+
const app = loaded.manifest.apps[appName];
|
|
8741
|
+
if (!app) {
|
|
8742
|
+
console.error(chalk.red(`✗ No app named "${appName}" in rebase.json.`));
|
|
8743
|
+
console.error(chalk.dim(` Declared: ${Object.keys(loaded.manifest.apps).join(", ") || "(none)"}`));
|
|
8744
|
+
process.exit(1);
|
|
8745
|
+
}
|
|
8746
|
+
const link = readLink(projectRoot);
|
|
8747
|
+
const apiUrl = resolveApiUrl(projectRoot, link);
|
|
8748
|
+
const config = {
|
|
8749
|
+
app: appName,
|
|
8750
|
+
type: app.type,
|
|
8751
|
+
apiUrl: apiUrl ?? null,
|
|
8752
|
+
project: link?.projectId ?? link?.slug ?? null
|
|
8753
|
+
};
|
|
8754
|
+
if (asJson) {
|
|
8755
|
+
console.log(JSON.stringify(config, null, 2));
|
|
8756
|
+
return;
|
|
8757
|
+
}
|
|
8758
|
+
if (!apiUrl) {
|
|
8759
|
+
console.log(chalk.yellow("This checkout is not linked to a project yet."));
|
|
8760
|
+
console.log(chalk.dim(` Run ${chalk.cyan("rebase link")} (cloud) or ${chalk.cyan("rebase link <url>")} (self-hosted).`));
|
|
8761
|
+
console.log("");
|
|
8762
|
+
}
|
|
8763
|
+
console.log(chalk.bold(`# ${appName}`));
|
|
8764
|
+
console.log("");
|
|
8765
|
+
console.log(`VITE_API_URL=${apiUrl ?? "http://localhost:3001"}`);
|
|
8766
|
+
console.log("");
|
|
8767
|
+
console.log(chalk.dim("Then, in the app:"));
|
|
8768
|
+
console.log(chalk.dim(" const rebase = createRebaseClient({ baseUrl: import.meta.env.VITE_API_URL });"));
|
|
8769
|
+
}
|
|
8770
|
+
/**
|
|
8771
|
+
* Work out the API base URL for this checkout.
|
|
8772
|
+
*
|
|
8773
|
+
* Prefers an explicit link, then the dev server's own record of where it bound.
|
|
8774
|
+
* The dev port is chosen dynamically, so a hardcoded default would be wrong on
|
|
8775
|
+
* any machine running more than one project.
|
|
8776
|
+
*/
|
|
8777
|
+
function resolveApiUrl(projectRoot, link) {
|
|
8778
|
+
if (link?.apiUrl) return link.apiUrl;
|
|
8779
|
+
const statePath = path.join(projectRoot, ".rebase", "state.json");
|
|
8780
|
+
if (fs.existsSync(statePath)) try {
|
|
8781
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
8782
|
+
if (state.baseUrl) return state.baseUrl;
|
|
8783
|
+
} catch {}
|
|
8784
|
+
}
|
|
8785
|
+
function loadManifestOrExit(projectRoot) {
|
|
8786
|
+
try {
|
|
8787
|
+
return loadManifest(projectRoot);
|
|
8788
|
+
} catch (err) {
|
|
8789
|
+
if (err instanceof ManifestError) {
|
|
8790
|
+
console.error(chalk.red(`✗ ${err.message}`));
|
|
8791
|
+
for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
|
|
8792
|
+
process.exit(1);
|
|
8793
|
+
}
|
|
8794
|
+
throw err;
|
|
8795
|
+
}
|
|
8796
|
+
}
|
|
8797
|
+
//#endregion
|
|
5695
8798
|
//#region src/cli.ts
|
|
5696
8799
|
var __filename = fileURLToPath(import.meta.url);
|
|
5697
8800
|
var __dirname = path.dirname(__filename);
|
|
@@ -5719,6 +8822,7 @@ async function entry(args) {
|
|
|
5719
8822
|
const command = parsedArgs._[0];
|
|
5720
8823
|
const subcommand = parsedArgs._[1];
|
|
5721
8824
|
if (!command || parsedArgs["--help"] && ![
|
|
8825
|
+
"init",
|
|
5722
8826
|
"schema",
|
|
5723
8827
|
"db",
|
|
5724
8828
|
"dev",
|
|
@@ -5728,7 +8832,9 @@ async function entry(args) {
|
|
|
5728
8832
|
"doctor",
|
|
5729
8833
|
"skills",
|
|
5730
8834
|
"api-keys",
|
|
5731
|
-
"cloud"
|
|
8835
|
+
"cloud",
|
|
8836
|
+
"apps",
|
|
8837
|
+
"generate-sdk"
|
|
5732
8838
|
].includes(command)) {
|
|
5733
8839
|
printHelp();
|
|
5734
8840
|
return;
|
|
@@ -5742,8 +8848,12 @@ async function entry(args) {
|
|
|
5742
8848
|
const sdkArgs = arg({
|
|
5743
8849
|
"--collections-dir": String,
|
|
5744
8850
|
"--output": String,
|
|
8851
|
+
"--from": String,
|
|
8852
|
+
"--token": String,
|
|
8853
|
+
"--help": Boolean,
|
|
5745
8854
|
"-c": "--collections-dir",
|
|
5746
|
-
"-o": "--output"
|
|
8855
|
+
"-o": "--output",
|
|
8856
|
+
"-h": "--help"
|
|
5747
8857
|
}, {
|
|
5748
8858
|
argv: args.slice(3),
|
|
5749
8859
|
permissive: true
|
|
@@ -5751,6 +8861,9 @@ async function entry(args) {
|
|
|
5751
8861
|
await generateSdkCommand({
|
|
5752
8862
|
collectionsDir: sdkArgs["--collections-dir"] || "./config/collections",
|
|
5753
8863
|
output: sdkArgs["--output"] || "./generated/sdk",
|
|
8864
|
+
from: sdkArgs["--from"],
|
|
8865
|
+
token: sdkArgs["--token"],
|
|
8866
|
+
help: sdkArgs["--help"],
|
|
5754
8867
|
cwd: process.cwd()
|
|
5755
8868
|
});
|
|
5756
8869
|
break;
|
|
@@ -5765,10 +8878,13 @@ async function entry(args) {
|
|
|
5765
8878
|
await devCommand(args);
|
|
5766
8879
|
break;
|
|
5767
8880
|
case "build":
|
|
5768
|
-
await buildCommand();
|
|
8881
|
+
await buildCommand(args);
|
|
5769
8882
|
break;
|
|
5770
8883
|
case "start":
|
|
5771
|
-
await startCommand();
|
|
8884
|
+
await startCommand(args);
|
|
8885
|
+
break;
|
|
8886
|
+
case "apps":
|
|
8887
|
+
await appsCommand(effectiveSubcommand, args);
|
|
5772
8888
|
break;
|
|
5773
8889
|
case "auth":
|
|
5774
8890
|
await authCommand(effectiveSubcommand, args);
|
|
@@ -5849,6 +8965,6 @@ ${chalk.gray("Documentation: https://rebase.pro/docs")}
|
|
|
5849
8965
|
`);
|
|
5850
8966
|
}
|
|
5851
8967
|
//#endregion
|
|
5852
|
-
export { authCommand, buildCommand, buildInitQuestions, cloudCommand, configureEnvFile, createRebaseApp, dbCommand, detectPackageManager, devCommand, doctorCommand, entry, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isPnpmAvailable, requireBackendDir, requireProjectRoot, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, validateProjectName, validateTsxInstallation };
|
|
8968
|
+
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, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
|
|
5853
8969
|
|
|
5854
8970
|
//# sourceMappingURL=index.es.js.map
|