@rebasepro/cli 0.24.0 → 0.24.1-canary.g56af4a4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/rebase.js +3 -0
- package/dist/agent-setup-B31F00DC.js +324 -0
- package/dist/agent-setup-B31F00DC.js.map +1 -0
- package/dist/api-keys-BPBi5nOF.js +524 -0
- package/dist/api-keys-BPBi5nOF.js.map +1 -0
- package/dist/apps-CPXNElX4.js +256 -0
- package/dist/apps-CPXNElX4.js.map +1 -0
- package/dist/args-BnmsEXvu.js +109 -0
- package/dist/args-BnmsEXvu.js.map +1 -0
- package/dist/atlas-dev-database-DTMuEScS.js +62 -0
- package/dist/atlas-dev-database-DTMuEScS.js.map +1 -0
- package/dist/auth-DvY94ZuL.js +395 -0
- package/dist/auth-DvY94ZuL.js.map +1 -0
- package/dist/branch-pointer-DIhKbQLp.js +2 -0
- package/dist/build-CFwUx-Ff.js +336 -0
- package/dist/build-CFwUx-Ff.js.map +1 -0
- package/dist/bundle-BQhP_xiA.js +1617 -0
- package/dist/bundle-BQhP_xiA.js.map +1 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cloud-CkTr-yt0.js +9640 -0
- package/dist/cloud-CkTr-yt0.js.map +1 -0
- package/dist/collection-drift-BKj_BoMq.js +106 -0
- package/dist/collection-drift-BKj_BoMq.js.map +1 -0
- package/dist/commands/api-keys.d.ts +2 -0
- package/dist/commands/db.d.ts +37 -24
- package/dist/commands/dev.d.ts +103 -0
- package/dist/commands/generate_sdk.d.ts +26 -0
- package/dist/commands/skills.d.ts +22 -7
- package/dist/commands/storage.d.ts +46 -0
- package/dist/consent-BWHaYx5p.js +76 -0
- package/dist/consent-BWHaYx5p.js.map +1 -0
- package/dist/context-BqhRydBg.js +1234 -0
- package/dist/context-BqhRydBg.js.map +1 -0
- package/dist/daemon-DBn-shxX.js +2 -0
- package/dist/{daemon-entry-LTFKtpHy.js → daemon-entry-Da-D3rmH.js} +3 -4
- package/dist/daemon-entry-Da-D3rmH.js.map +1 -0
- package/dist/db-CMpBK3sv.js +1252 -0
- package/dist/db-CMpBK3sv.js.map +1 -0
- package/dist/derive-RPIsAVO4.js +474 -0
- package/dist/derive-RPIsAVO4.js.map +1 -0
- package/dist/dev-C9P5Yp9k.js +1593 -0
- package/dist/dev-C9P5Yp9k.js.map +1 -0
- package/dist/dev-db/atlas-dev-database.d.ts +32 -0
- package/dist/dev-db/daemon-entry.d.ts +13 -0
- package/dist/dev-preflight-N1evJL3S.js +2 -0
- package/dist/doctor-DYs-NqFI.js +771 -0
- package/dist/doctor-DYs-NqFI.js.map +1 -0
- package/dist/eject-BT6t37D0.js +335 -0
- package/dist/eject-BT6t37D0.js.map +1 -0
- package/dist/fold-static-C1EtHlfV.js +201 -0
- package/dist/fold-static-C1EtHlfV.js.map +1 -0
- package/dist/function-portability-BIvlvR9q.js +265 -0
- package/dist/function-portability-BIvlvR9q.js.map +1 -0
- package/dist/generate_sdk-UV635zSe.js +477 -0
- package/dist/generate_sdk-UV635zSe.js.map +1 -0
- package/dist/index.es.js +141 -24674
- package/dist/index.es.js.map +1 -1
- package/dist/init-fXeBcxuN.js +1042 -0
- package/dist/init-fXeBcxuN.js.map +1 -0
- package/dist/manifest-DzlC60WX.js +871 -0
- package/dist/manifest-DzlC60WX.js.map +1 -0
- package/dist/normalize-imports-CmVdE9iZ.js +100 -0
- package/dist/normalize-imports-CmVdE9iZ.js.map +1 -0
- package/dist/{package-manager-CXQevsE1.js → package-manager-DSEOwTqM.js} +2 -2
- package/dist/{package-manager-CXQevsE1.js.map → package-manager-DSEOwTqM.js.map} +1 -1
- package/dist/path-flags-DUBuI2i1.js +77 -0
- package/dist/path-flags-DUBuI2i1.js.map +1 -0
- package/dist/precompress.d.ts +17 -0
- package/dist/prepare-Btn9Ewbn.js +1052 -0
- package/dist/prepare-Btn9Ewbn.js.map +1 -0
- package/dist/{project-DmaGLmH2.js → project-Wp2zoyeN.js} +33 -4
- package/dist/project-Wp2zoyeN.js.map +1 -0
- package/dist/resolve-CJLkfFV0.js +2 -0
- package/dist/resources-DT1COn_6.js +161 -0
- package/dist/resources-DT1COn_6.js.map +1 -0
- package/dist/schema-BDBWvCHu.js +125 -0
- package/dist/schema-BDBWvCHu.js.map +1 -0
- package/dist/skills-DTFibT1m.js +642 -0
- package/dist/skills-DTFibT1m.js.map +1 -0
- package/dist/start-rU2e3Kp5.js +228 -0
- package/dist/start-rU2e3Kp5.js.map +1 -0
- package/dist/{state-C59Elrnt.js → state-Do7ZJYkF.js} +49 -4
- package/dist/state-Do7ZJYkF.js.map +1 -0
- package/dist/status-BR9oyi-y.js +351 -0
- package/dist/status-BR9oyi-y.js.map +1 -0
- package/dist/storage-DyvrSTH3.js +303 -0
- package/dist/storage-DyvrSTH3.js.map +1 -0
- package/dist/telemetry-CAWNCugs.js +338 -0
- package/dist/telemetry-CAWNCugs.js.map +1 -0
- package/dist/telemetry-DbLqGIBW.js +129 -0
- package/dist/telemetry-DbLqGIBW.js.map +1 -0
- package/dist/unknown-command-DNwDcX3w.js +98 -0
- package/dist/unknown-command-DNwDcX3w.js.map +1 -0
- package/dist/upgrade-D-5-Od96.js +1265 -0
- package/dist/upgrade-D-5-Od96.js.map +1 -0
- package/dist/utils/project.d.ts +16 -0
- package/dist/{version-DsgBmP22.js → version-Buyyo1ut.js} +2 -2
- package/dist/{version-DsgBmP22.js.map → version-Buyyo1ut.js.map} +1 -1
- package/package.json +7 -7
- package/templates/eject/backend/src/env.ts +1 -1
- package/templates/eject/backend/src/index.ts +10 -2
- package/templates/template/README.md +1 -1
- package/templates/template/ai-instructions.md +2 -1
- package/templates/template/frontend/vite.config.ts +11 -3
- package/templates/template/scripts/example.ts +2 -1
- package/dist/branch-pointer-kk9F5FBO.js +0 -111
- package/dist/branch-pointer-kk9F5FBO.js.map +0 -1
- package/dist/constraints-DKGbNfUW.js +0 -48
- package/dist/constraints-DKGbNfUW.js.map +0 -1
- package/dist/daemon-C8p32i-n.js +0 -357
- package/dist/daemon-C8p32i-n.js.map +0 -1
- package/dist/daemon-entry-LTFKtpHy.js.map +0 -1
- package/dist/dev-preflight-CNLl4rdo.js +0 -367
- package/dist/dev-preflight-CNLl4rdo.js.map +0 -1
- package/dist/project-DmaGLmH2.js.map +0 -1
- package/dist/resolve-BAeRUBhg.js +0 -74
- package/dist/resolve-BAeRUBhg.js.map +0 -1
- package/dist/state-C59Elrnt.js.map +0 -1
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
import { i as recordEvent } from "./telemetry-CAWNCugs.js";
|
|
2
|
+
import { n as getPMCommands, t as detectPackageManager } from "./package-manager-DSEOwTqM.js";
|
|
3
|
+
import { a as findBackendDir, d as readEnvFile, g as resolveTsx, h as resolvePluginCliScript, l as getActiveBackendPlugin, n as dependenciesNotInstalled, o as findEnvFile, p as requireProjectRoot, s as findFrontendDir, v as validateTsxInstallation } from "./project-Wp2zoyeN.js";
|
|
4
|
+
import { n as parseCommandArgs, r as wantsHelp, t as UsageError } from "./args-BnmsEXvu.js";
|
|
5
|
+
import { d as findBackendApp, f as formatAppAddress, g as resolveBackendPaths, l as cmsMountOf, p as loadManifest, v as staticAppAddress } from "./manifest-DzlC60WX.js";
|
|
6
|
+
import { DEV_DATABASE_KIND_ENV, S as ensureDevDatabase, l as resolveCliEntry, managedNotices, prepareDatabaseEnv, resolveComposeUrl, u as resolveSpawn, w as probeTcp } from "./prepare-Btn9Ewbn.js";
|
|
7
|
+
import { runDriverDbCommand, runDriverSchemaCommand } from "./db-CMpBK3sv.js";
|
|
8
|
+
import { t as affectsSqlSchema } from "./collection-drift-BKj_BoMq.js";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { createHash } from "crypto";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import dotenv from "dotenv";
|
|
16
|
+
import { DEFAULT_RESOURCE_KEY, parseEnvBoolean } from "@rebasepro/types";
|
|
17
|
+
import { execa, execaCommandSync } from "execa";
|
|
18
|
+
import { StringDecoder } from "string_decoder";
|
|
19
|
+
//#region src/commands/dev.ts
|
|
20
|
+
/**
|
|
21
|
+
* CLI command: rebase dev
|
|
22
|
+
*
|
|
23
|
+
* Starts the full development environment:
|
|
24
|
+
* - Backend: tsx watch with auto-reload
|
|
25
|
+
* - Frontend: vite dev server
|
|
26
|
+
*
|
|
27
|
+
* Both processes stream output with color-coded prefixes.
|
|
28
|
+
*
|
|
29
|
+
* When the backend uses port-retry (i.e. the configured port is busy and it
|
|
30
|
+
* binds to the next free one), the CLI detects the actual port from stdout
|
|
31
|
+
* and injects VITE_API_URL into the frontend so it connects automatically.
|
|
32
|
+
*
|
|
33
|
+
* Each project gets a deterministic default port derived from the project
|
|
34
|
+
* root path, so multiple Rebase instances never collide.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The line the watcher prints when it is about to run the entry again.
|
|
38
|
+
*
|
|
39
|
+
* `rebase dev` has no exit code to read: the watcher outlives the process it
|
|
40
|
+
* started, so a backend that dies leaves it running and the CLI printing
|
|
41
|
+
* "Press Ctrl+C to stop all servers." under the stack trace, as if everything
|
|
42
|
+
* were fine.
|
|
43
|
+
*
|
|
44
|
+
* What used to be here was `/Waiting for file changes before restarting/` —
|
|
45
|
+
* **Node's** message, on the theory that `--watch=…` was passed through to
|
|
46
|
+
* Node. It is not: the backend is spawned as `tsx watch …` (see `watchArgs`),
|
|
47
|
+
* so tsx does the watching and Node's line is never printed. The marker
|
|
48
|
+
* matched nothing at all — and the test guarding it ran `node --watch`
|
|
49
|
+
* directly, so it kept proving that Node's wording was intact while the CLI
|
|
50
|
+
* watched a stream that never contained it. That is precisely the silent death
|
|
51
|
+
* the old comment warned about, reached by a different route.
|
|
52
|
+
*
|
|
53
|
+
* So this is tsx's, captured from tsx 4.23.1:
|
|
54
|
+
*
|
|
55
|
+
* 6:26:11 AM [tsx] change in ./src/index.ts Rerunning...
|
|
56
|
+
* 6:26:11 AM [tsx] change in ./src/index.ts Restarting...
|
|
57
|
+
*
|
|
58
|
+
* "Rerunning" when the previous run had already exited, "Restarting" when it
|
|
59
|
+
* was still up and had to be killed first. Both mean the same thing here: the
|
|
60
|
+
* backend that was announced is gone, and whether another one arrives is an
|
|
61
|
+
* open question again — so the readiness deadline is re-armed.
|
|
62
|
+
*
|
|
63
|
+
* Exported so `dev-crash-marker.test.ts` can hold the running tsx to it.
|
|
64
|
+
*/
|
|
65
|
+
var WATCHER_RESTART_MARKER = /\[tsx\][^\n]*\b(?:Rerunning|Restarting)\b/i;
|
|
66
|
+
/**
|
|
67
|
+
* How often to ask whether the announced backend is still listening.
|
|
68
|
+
*
|
|
69
|
+
* The watcher says nothing when its child is killed outright, so a poll is the
|
|
70
|
+
* only signal there is. Five seconds is chosen against the thing it must not
|
|
71
|
+
* do — report a healthy server as dead — rather than against detection speed:
|
|
72
|
+
* two consecutive misses are needed, so it takes ten seconds of a genuinely
|
|
73
|
+
* closed port to say anything.
|
|
74
|
+
*/
|
|
75
|
+
var LIVENESS_INTERVAL_MS = 5e3;
|
|
76
|
+
/**
|
|
77
|
+
* Strip ANSI codes before matching.
|
|
78
|
+
*
|
|
79
|
+
* At module scope so `portMovedNotice` below can use the same one the stream
|
|
80
|
+
* handlers do: a second copy of this regex is a second thing to keep in step.
|
|
81
|
+
*/
|
|
82
|
+
var stripAnsi = (str) => str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
|
|
83
|
+
/**
|
|
84
|
+
* Calls `onLine` with each line a child process writes to `stream`.
|
|
85
|
+
*
|
|
86
|
+
* A pipe hands over whatever has been written so far, so one line can arrive
|
|
87
|
+
* in two chunks. Split chunk by chunk, "Server running at http://localhost:3001"
|
|
88
|
+
* could read as two fragments that neither matched, and `rebase dev` then
|
|
89
|
+
* never announced the backend or started the admin panel. So the piece after
|
|
90
|
+
* the last newline is held for the next chunk, decoded as UTF-8 across chunk
|
|
91
|
+
* boundaries. A held piece is handed over at the end of the stream, or after
|
|
92
|
+
* `idleFlushMs` with nothing more written, so a prompt or a progress line
|
|
93
|
+
* still shows when it is written.
|
|
94
|
+
*
|
|
95
|
+
* Exported for its test.
|
|
96
|
+
*/
|
|
97
|
+
function forEachOutputLine(stream, onLine, idleFlushMs = 100) {
|
|
98
|
+
if (!stream) return;
|
|
99
|
+
const decoder = new StringDecoder("utf8");
|
|
100
|
+
let pending = "";
|
|
101
|
+
let idle;
|
|
102
|
+
const emit = (text) => {
|
|
103
|
+
for (const line of text.split("\n")) if (line) onLine(line);
|
|
104
|
+
};
|
|
105
|
+
const flush = (atEnd) => {
|
|
106
|
+
clearTimeout(idle);
|
|
107
|
+
idle = void 0;
|
|
108
|
+
const rest = pending + (atEnd ? decoder.end() : "");
|
|
109
|
+
pending = "";
|
|
110
|
+
emit(rest);
|
|
111
|
+
};
|
|
112
|
+
stream.on("data", (data) => {
|
|
113
|
+
clearTimeout(idle);
|
|
114
|
+
const text = pending + (typeof data === "string" ? data : decoder.write(data));
|
|
115
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
116
|
+
pending = text.slice(lastNewline + 1);
|
|
117
|
+
if (lastNewline >= 0) emit(text.slice(0, lastNewline));
|
|
118
|
+
if (pending) {
|
|
119
|
+
idle = setTimeout(() => flush(false), idleFlushMs);
|
|
120
|
+
idle.unref?.();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
stream.on("end", () => flush(true));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The corrected banner line, when the backend says it moved port.
|
|
127
|
+
*
|
|
128
|
+
* `↳ PORT = 3014` is printed before the server binds — the backend's
|
|
129
|
+
* environment has to be fixed before it spawns — so when the port turns out to
|
|
130
|
+
* be taken and the server moves, the banner two inches up is simply wrong. The
|
|
131
|
+
* true number then appears in a `[backend]` line a hundred lines of DDL later,
|
|
132
|
+
* which nobody scrolls back through, and the banner is where a reader looks for
|
|
133
|
+
* the port.
|
|
134
|
+
*
|
|
135
|
+
* A correction rather than a rewrite: the terminal is a transcript, and a line
|
|
136
|
+
* that changes after the fact is a line nobody can trust. So the old number is
|
|
137
|
+
* named too.
|
|
138
|
+
*
|
|
139
|
+
* Exported for its test.
|
|
140
|
+
*/
|
|
141
|
+
function portMovedNotice(line) {
|
|
142
|
+
const moved = stripAnsi(line).match(/Port (\d+) is in use — trying (\d+)/);
|
|
143
|
+
return moved ? {
|
|
144
|
+
from: moved[1],
|
|
145
|
+
to: moved[2]
|
|
146
|
+
} : null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Quote a path for the shell `execa` runs the backend through.
|
|
150
|
+
*
|
|
151
|
+
* The dev runtime's path is absolute and therefore contains whatever the
|
|
152
|
+
* developer's directories are called. Double quotes do not neutralize `$`,
|
|
153
|
+
* backticks or backslashes in a POSIX shell, so a checkout under a directory
|
|
154
|
+
* named `$(...)` would execute it. Single quotes disable all expansion; on
|
|
155
|
+
* Windows, `cmd.exe` performs no such expansion and wants double quotes.
|
|
156
|
+
*/
|
|
157
|
+
function quoteForShell(value) {
|
|
158
|
+
if (process.platform === "win32") return `"${value.replace(/"/g, "\\\"")}"`;
|
|
159
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Locate the dev runtime shim shipped with the CLI.
|
|
163
|
+
*
|
|
164
|
+
* Published under `runtime/` in the package rather than compiled into `dist/`,
|
|
165
|
+
* because tsx executes it as a file and it must exist on disk at a stable path.
|
|
166
|
+
*/
|
|
167
|
+
function resolveDevRuntimeEntry() {
|
|
168
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
169
|
+
for (let i = 0; i < 5; i++) {
|
|
170
|
+
const candidate = path.join(dir, "runtime", "dev-server.mjs");
|
|
171
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
172
|
+
const parent = path.dirname(dir);
|
|
173
|
+
if (parent === dir) break;
|
|
174
|
+
dir = parent;
|
|
175
|
+
}
|
|
176
|
+
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.");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Tell the dev runtime where this project keeps its parts.
|
|
180
|
+
*
|
|
181
|
+
* Read from `rebase.json` when there is one, so a project that moved its config
|
|
182
|
+
* directory is honoured; otherwise the conventional layout.
|
|
183
|
+
*/
|
|
184
|
+
function devRuntimeEnv(projectRoot) {
|
|
185
|
+
const result = {
|
|
186
|
+
REBASE_DEV_PROJECT_ROOT: projectRoot,
|
|
187
|
+
REBASE_DEV_CONFIG: "config",
|
|
188
|
+
REBASE_DEV_FUNCTIONS: "backend/functions",
|
|
189
|
+
REBASE_DEV_CRONS: "backend/crons",
|
|
190
|
+
REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts"
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
const backend = findBackendApp(loadManifest(projectRoot).manifest);
|
|
194
|
+
if (backend) {
|
|
195
|
+
const paths = resolveBackendPaths(backend.app, projectRoot);
|
|
196
|
+
result.REBASE_DEV_CONFIG = paths.config;
|
|
197
|
+
result.REBASE_DEV_FUNCTIONS = paths.functions;
|
|
198
|
+
result.REBASE_DEV_CRONS = paths.crons;
|
|
199
|
+
result.REBASE_DEV_SCHEMA = paths.schema;
|
|
200
|
+
result.REBASE_DEV_APP = backend.name;
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Directories `tsx watch` must be told about explicitly, as absolute paths.
|
|
207
|
+
*
|
|
208
|
+
* tsx restarts on a change to anything the entrypoint *imports*. Functions and
|
|
209
|
+
* crons are not imported: the runtime scans their directories at boot. So
|
|
210
|
+
* adding `backend/functions/new.ts` while `rebase dev` ran did nothing —
|
|
211
|
+
* `GET /api/functions/new` stayed 404, and a new cron never registered — until
|
|
212
|
+
* somebody restarted by hand. The same is true of `config/`, which is read as
|
|
213
|
+
* data.
|
|
214
|
+
*
|
|
215
|
+
* `--include` is tsx's own answer to this, it takes a directory rather than a
|
|
216
|
+
* glob, and it tolerates a directory that does not exist yet: a `crons/`
|
|
217
|
+
* created mid-session starts being watched when it appears.
|
|
218
|
+
*
|
|
219
|
+
* @param projectRoot Absolute path the relative entries are resolved against.
|
|
220
|
+
* @param paths The `REBASE_DEV_*` map from {@link devRuntimeEnv}.
|
|
221
|
+
*/
|
|
222
|
+
function devWatchIncludes(projectRoot, paths) {
|
|
223
|
+
const dirs = [paths.REBASE_DEV_FUNCTIONS, paths.REBASE_DEV_CRONS].filter((entry) => Boolean(entry)).map((entry) => path.resolve(projectRoot, entry));
|
|
224
|
+
dirs.push(path.join(projectRoot, "config"));
|
|
225
|
+
return dirs;
|
|
226
|
+
}
|
|
227
|
+
/** Where dev watches the collections it regenerates the schema and SDK from. */
|
|
228
|
+
function devCollectionsDir(projectRoot) {
|
|
229
|
+
return path.join(projectRoot, "config", "collections");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The file dev writes to restart the backend, or `null` when tsx would not see it.
|
|
233
|
+
*
|
|
234
|
+
* Outside the project, so nothing appears in the developer's tree, and keyed by
|
|
235
|
+
* the project so two `rebase dev`s never restart each other. tsx ignores every
|
|
236
|
+
* path with a dot-named segment (its built-in `**\/.*\/**`), so a trigger under
|
|
237
|
+
* `.rebase/` — or under a TMPDIR inside a dot directory — would never fire; in
|
|
238
|
+
* that case there is no trigger and {@link devWatchArgs} leaves the
|
|
239
|
+
* collections to tsx, as before.
|
|
240
|
+
*/
|
|
241
|
+
function devRestartTrigger(projectRoot) {
|
|
242
|
+
let tmp = os.tmpdir();
|
|
243
|
+
try {
|
|
244
|
+
tmp = fs.realpathSync(tmp);
|
|
245
|
+
} catch {}
|
|
246
|
+
const key = createHash("sha256").update(projectRoot).digest("hex").slice(0, 16);
|
|
247
|
+
const trigger = path.join(tmp, "rebase-dev", key, "collections-changed");
|
|
248
|
+
return trigger.split(path.sep).some((segment) => segment.startsWith(".")) ? null : trigger;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* tsx's built-in watch ignores (tsx 4's `watch` command), which it applies
|
|
252
|
+
* relative to its cwd only. {@link devWatchArgs} repeats them under every
|
|
253
|
+
* directory it includes from outside that cwd.
|
|
254
|
+
*/
|
|
255
|
+
var TSX_DEFAULT_IGNORES = [
|
|
256
|
+
"**/.*/**",
|
|
257
|
+
"**/.*",
|
|
258
|
+
"**/{node_modules,bower_components,vendor}/**"
|
|
259
|
+
];
|
|
260
|
+
/**
|
|
261
|
+
* The `tsx watch` argument list for the backend (unquoted).
|
|
262
|
+
*
|
|
263
|
+
* One save of a collection file must restart the backend once. The runtime
|
|
264
|
+
* `import()`s the collections, so tsx tracks them as dependencies and used to
|
|
265
|
+
* restart the moment a file was saved — before dev had regenerated
|
|
266
|
+
* `schema.generated.ts`. That boot warned "schema.generated.ts no longer
|
|
267
|
+
* describes the database … Run `rebase schema generate`", telling the reader to
|
|
268
|
+
* run what dev was running at that moment, and the regeneration then rewrote
|
|
269
|
+
* the file and restarted the backend a second time. So tsx is told to leave the
|
|
270
|
+
* collections directory alone (`--exclude` wins over its dependency tracking),
|
|
271
|
+
* and dev restarts the backend itself, once, after the regeneration — through
|
|
272
|
+
* `restartTrigger`, which tsx watches.
|
|
273
|
+
*
|
|
274
|
+
* Every included directory also gets tsx's own default ignores, made absolute.
|
|
275
|
+
* tsx resolves those defaults (`**\/node_modules/**`, `**\/.*`, …) against its
|
|
276
|
+
* cwd, which is `backend/`, so they never reached `config/`, a sibling: chokidar
|
|
277
|
+
* walked `config/node_modules` — following symlinks — before tsx spawned the
|
|
278
|
+
* backend at all, and kept every file in it watched. In the monorepo's example
|
|
279
|
+
* app that was 17 s before the first backend line and 1,400 open files, and any
|
|
280
|
+
* install (or another checkout's Vite build writing `.vite-temp` deep inside
|
|
281
|
+
* that tree) restarted the backend. A `.DS_Store` or an editor swap file in
|
|
282
|
+
* `config/` restarted it too.
|
|
283
|
+
*/
|
|
284
|
+
function devWatchArgs(projectRoot, paths, entryTarget, restartTrigger) {
|
|
285
|
+
const args = ["watch"];
|
|
286
|
+
const glob = (dir) => dir.split(path.sep).join("/");
|
|
287
|
+
for (const dir of devWatchIncludes(projectRoot, paths)) {
|
|
288
|
+
args.push("--include", dir);
|
|
289
|
+
for (const ignored of TSX_DEFAULT_IGNORES) args.push("--exclude", `${glob(dir)}/${ignored}`);
|
|
290
|
+
}
|
|
291
|
+
if (restartTrigger) {
|
|
292
|
+
args.push("--include", restartTrigger);
|
|
293
|
+
args.push("--exclude", `${glob(devCollectionsDir(projectRoot))}/**`);
|
|
294
|
+
}
|
|
295
|
+
args.push("--conditions", "development", entryTarget);
|
|
296
|
+
return args;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The databases the project declares beyond the default, by key.
|
|
300
|
+
*
|
|
301
|
+
* Read from the graph, so `database("analytics")` in `config/resources.ts`
|
|
302
|
+
* is enough for `rebase dev` to serve a second database — the declaration is
|
|
303
|
+
* the request. A config that does not evaluate is the backend's problem to
|
|
304
|
+
* report, in the terms it already uses; here it means "no additional
|
|
305
|
+
* databases", not a refusal to start.
|
|
306
|
+
*/
|
|
307
|
+
async function declaredAdditionalDatabases(projectRoot) {
|
|
308
|
+
try {
|
|
309
|
+
const backend = findBackendApp(loadManifest(projectRoot).manifest);
|
|
310
|
+
if (!backend) return [];
|
|
311
|
+
const { deriveResourceGraph, deriveOptionsFor } = await import("./derive-RPIsAVO4.js").then((n) => n.i);
|
|
312
|
+
const { graph } = await deriveResourceGraph({
|
|
313
|
+
...deriveOptionsFor(projectRoot, backend.app),
|
|
314
|
+
cronsDir: void 0,
|
|
315
|
+
functionsDir: void 0
|
|
316
|
+
});
|
|
317
|
+
return graph.resources.filter((r) => r.kind === "database" && r.transport === "server" && r.key !== DEFAULT_RESOURCE_KEY).map((r) => r.key);
|
|
318
|
+
} catch {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Well-known filename the backend writes its actual port to. */
|
|
323
|
+
var DEV_PORT_FILENAME = ".rebase-dev-port";
|
|
324
|
+
/**
|
|
325
|
+
* Compute a deterministic port from the project root path.
|
|
326
|
+
* Range: 3001–3999 (avoids privileged ports and common services).
|
|
327
|
+
* Two different project directories will almost always get different ports.
|
|
328
|
+
*/
|
|
329
|
+
function getProjectPort(projectRoot) {
|
|
330
|
+
let hash = 0;
|
|
331
|
+
for (let i = 0; i < projectRoot.length; i++) hash = (hash << 5) - hash + projectRoot.charCodeAt(i) | 0;
|
|
332
|
+
return 3001 + Math.abs(hash) % 999;
|
|
333
|
+
}
|
|
334
|
+
/** What the banner says after the port, naming the rung. */
|
|
335
|
+
var START_PORT_SOURCE_LABELS = {
|
|
336
|
+
"--port": "from --port",
|
|
337
|
+
PORT: "from PORT",
|
|
338
|
+
affinity: "kept from the last run",
|
|
339
|
+
derived: "derived"
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* A TCP port, or `undefined` for anything that is not one.
|
|
343
|
+
*
|
|
344
|
+
* One predicate for both sources below. The port file was already checked for
|
|
345
|
+
* range, and `PORT` — the source a human or a platform actually sets — was not,
|
|
346
|
+
* so `PORT=oops` reached `parseInt` and was returned as `NaN`: the dev server
|
|
347
|
+
* then bound to whatever the OS handed out and the CLI printed a URL for a port
|
|
348
|
+
* nothing was listening on.
|
|
349
|
+
*/
|
|
350
|
+
function parsePort(raw) {
|
|
351
|
+
if (raw === void 0) return void 0;
|
|
352
|
+
const port = Number(raw.trim());
|
|
353
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) return void 0;
|
|
354
|
+
return port;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The port this project's backend starts on, and the rung that decided it:
|
|
358
|
+
* 1. `--port` (highest priority)
|
|
359
|
+
* 2. `PORT` **in the shell environment** — a `PORT` in `.env` is not read here
|
|
360
|
+
* 3. the port a previous run saved in `.rebase-dev-port` (affinity)
|
|
361
|
+
* 4. a port derived from the project path, unique per project
|
|
362
|
+
*/
|
|
363
|
+
function resolveStartPort(projectRoot, explicitPort) {
|
|
364
|
+
if (explicitPort) return {
|
|
365
|
+
port: explicitPort,
|
|
366
|
+
source: "--port"
|
|
367
|
+
};
|
|
368
|
+
if (process.env.PORT) {
|
|
369
|
+
const fromEnv = parsePort(process.env.PORT);
|
|
370
|
+
if (fromEnv !== void 0) return {
|
|
371
|
+
port: fromEnv,
|
|
372
|
+
source: "PORT"
|
|
373
|
+
};
|
|
374
|
+
console.warn(chalk.yellow(` ⚠ Ignoring PORT="${process.env.PORT}" — not a port between 1 and 65535.`));
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const portFile = path.join(projectRoot, DEV_PORT_FILENAME);
|
|
378
|
+
if (fs.existsSync(portFile)) {
|
|
379
|
+
const saved = parsePort(fs.readFileSync(portFile, "utf-8"));
|
|
380
|
+
if (saved !== void 0) return {
|
|
381
|
+
port: saved,
|
|
382
|
+
source: "affinity"
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
} catch {}
|
|
386
|
+
return {
|
|
387
|
+
port: getProjectPort(projectRoot),
|
|
388
|
+
source: "derived"
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Where a connection string actually points, as `host:port/name`.
|
|
393
|
+
*
|
|
394
|
+
* The banner used to name the database only by *provenance* — "your database
|
|
395
|
+
* (DATABASE_URL in the environment)" — and the boot that follows it writes
|
|
396
|
+
* thirty DDL statements. Which host, which port, which database name appeared
|
|
397
|
+
* nowhere on the success path; only a *failed* connection printed an address.
|
|
398
|
+
* So the one question after pointing a project at staging by accident — did it
|
|
399
|
+
* touch staging? — had no answer in a hundred and twenty lines of output.
|
|
400
|
+
*
|
|
401
|
+
* The password is never in it. `new URL` keeps the credentials in their own
|
|
402
|
+
* fields, so building the string out of `hostname`, `port` and `pathname`
|
|
403
|
+
* cannot carry them by accident, which string surgery on a DSN reliably does.
|
|
404
|
+
*
|
|
405
|
+
* A DSN with no host is a UNIX socket (`postgresql:///app?host=/tmp`), and the
|
|
406
|
+
* socket directory is the address there.
|
|
407
|
+
*
|
|
408
|
+
* Returns null for anything that is not a parseable URL rather than guessing:
|
|
409
|
+
* a banner line that is wrong about the database is worse than one that is
|
|
410
|
+
* missing.
|
|
411
|
+
*
|
|
412
|
+
* Exported for its test.
|
|
413
|
+
*/
|
|
414
|
+
function databaseEndpoint(url) {
|
|
415
|
+
if (!url) return null;
|
|
416
|
+
let parsed;
|
|
417
|
+
try {
|
|
418
|
+
parsed = new URL(url);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const host = parsed.hostname || parsed.searchParams.get("host");
|
|
423
|
+
if (!host) return null;
|
|
424
|
+
const name = parsed.pathname.replace(/^\//, "");
|
|
425
|
+
return `${host}${parsed.port ? `:${parsed.port}` : ""}${name ? `/${name}` : ""}`;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Read {@link DevStaticShape} out of a loaded manifest. Exported for its test.
|
|
429
|
+
*
|
|
430
|
+
* Which declared app is running is decided by its `root` being `frontend/`,
|
|
431
|
+
* because that directory is what `startFrontend` launches and nothing else is.
|
|
432
|
+
* When no declared app has that root, which app serves the CMS cannot be told,
|
|
433
|
+
* and it is read as the frontend that does run — what the banner has always
|
|
434
|
+
* assumed, and right for every project `rebase init` scaffolds.
|
|
435
|
+
*/
|
|
436
|
+
function devStaticShape(manifest, projectRoot, frontendDir) {
|
|
437
|
+
const statics = [];
|
|
438
|
+
for (const [name, app] of Object.entries(manifest.apps ?? {})) if (app.type === "static") statics.push([name, app]);
|
|
439
|
+
let served;
|
|
440
|
+
if (frontendDir) {
|
|
441
|
+
const running = path.resolve(frontendDir);
|
|
442
|
+
const match = statics.find(([, app]) => path.resolve(projectRoot, app.root) === running);
|
|
443
|
+
if (match) served = {
|
|
444
|
+
appName: match[0],
|
|
445
|
+
address: staticAppAddress(match[0], match[1])
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const mount = cmsMountOf(manifest);
|
|
449
|
+
return {
|
|
450
|
+
declaresStaticApp: statics.length > 0,
|
|
451
|
+
...served ? { served } : {},
|
|
452
|
+
...mount ? { cms: {
|
|
453
|
+
appName: mount.appName,
|
|
454
|
+
path: mount.path,
|
|
455
|
+
...mount.host ? { host: mount.host } : {},
|
|
456
|
+
servedLocally: served ? served.appName === mount.appName : true
|
|
457
|
+
} } : {}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The banner's lines for a project with a frontend. Exported for its test.
|
|
462
|
+
*
|
|
463
|
+
* Every address is printed whole, once: the local one Vite answers on, and —
|
|
464
|
+
* for an app with a hostname — the https URL it answers on once deployed, on a
|
|
465
|
+
* line of its own beneath it. A CMS in an app `rebase dev` does not run gets its
|
|
466
|
+
* deployed address and a note saying so, rather than a localhost URL that the
|
|
467
|
+
* running frontend's SPA fallback would answer with the wrong app.
|
|
468
|
+
*/
|
|
469
|
+
function appBannerLines(frontendUrl, api, shape) {
|
|
470
|
+
const deployedNote = (address) => [" ", `${address} when deployed`];
|
|
471
|
+
const lines = [
|
|
472
|
+
["", ""],
|
|
473
|
+
["✦ Rebase is ready!", ""],
|
|
474
|
+
["➜ Admin: ", frontendUrl]
|
|
475
|
+
];
|
|
476
|
+
const servedDeployed = shape.served?.address.host ? formatAppAddress(shape.served.address) : void 0;
|
|
477
|
+
if (servedDeployed) lines.push(deployedNote(servedDeployed));
|
|
478
|
+
lines.push(["➜ API: ", api]);
|
|
479
|
+
const cms = shape.cms;
|
|
480
|
+
if (!cms) return lines;
|
|
481
|
+
const cmsDeployed = cms.host ? formatAppAddress({
|
|
482
|
+
host: cms.host,
|
|
483
|
+
path: cms.path
|
|
484
|
+
}) : void 0;
|
|
485
|
+
if (!cms.servedLocally) {
|
|
486
|
+
lines.push(["➜ CMS: ", cmsDeployed ?? cms.path]);
|
|
487
|
+
lines.push([" ", `(app "${cms.appName}" — rebase dev only starts frontend/)`]);
|
|
488
|
+
return lines;
|
|
489
|
+
}
|
|
490
|
+
if (cms.path === "/" && cmsDeployed === servedDeployed) return lines;
|
|
491
|
+
lines.push(["➜ CMS: ", cms.path === "/" ? frontendUrl : `${frontendUrl.replace(/\/$/, "")}${cms.path}`]);
|
|
492
|
+
if (cmsDeployed) lines.push(deployedNote(cmsDeployed));
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The `↳ Database =` line: which database, why it was chosen, and where it is.
|
|
497
|
+
*
|
|
498
|
+
* Provenance alone was what this line said, and the boot underneath it applies
|
|
499
|
+
* schema. "your database (DATABASE_URL in the environment)" and "Applied 30
|
|
500
|
+
* additive schema change(s) before boot" together still do not answer the
|
|
501
|
+
* question somebody asks after pointing a project at staging by accident.
|
|
502
|
+
*
|
|
503
|
+
* The managed database gets its address too — the daemon's port is what a
|
|
504
|
+
* `psql` in another terminal needs, and it is nowhere else in the banner.
|
|
505
|
+
*
|
|
506
|
+
* Exported for its test.
|
|
507
|
+
*/
|
|
508
|
+
function databaseBannerValue(prepared) {
|
|
509
|
+
if (!prepared) return "none (--no-db) — the backend needs DATABASE_URL";
|
|
510
|
+
const endpoint = databaseEndpoint(prepared.database.kind === "managed" ? prepared.env.DATABASE_URL : prepared.database.url);
|
|
511
|
+
return endpoint ? `${prepared.description} · ${endpoint}` : prepared.description;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Refuse a port the developer named and something else already answers on.
|
|
515
|
+
*
|
|
516
|
+
* `--port` used to be advice, not an instruction: `resolveStartPort` returned
|
|
517
|
+
* the number, the banner printed it, and the server then walked past the
|
|
518
|
+
* collision to 3141, 3142. Because dev ports are derived per project, the
|
|
519
|
+
* number typed into `--port` is very often *another Rebase backend*, so the
|
|
520
|
+
* curl that followed answered 200 from the wrong project rather than refusing
|
|
521
|
+
* — the one failure mode a port collision is supposed to rule out.
|
|
522
|
+
*
|
|
523
|
+
* Asked here as well as in the server (`listenWithPortRetry`'s `explicit`
|
|
524
|
+
* option, which `REBASE_DEV_PORT_EXPLICIT` below turns on) so the refusal
|
|
525
|
+
* arrives before a database is started and a schema is pushed, rather than a
|
|
526
|
+
* minute later underneath them.
|
|
527
|
+
*
|
|
528
|
+
* Both loopback addresses, because a listener on `::1` alone is the same
|
|
529
|
+
* collision and costs one more connect to rule out. Returns the sentence to
|
|
530
|
+
* print, or `null` when there is nothing to refuse.
|
|
531
|
+
*
|
|
532
|
+
* Exported for its test.
|
|
533
|
+
*/
|
|
534
|
+
async function pinnedPortRefusal(port, pinned) {
|
|
535
|
+
if (!pinned) return null;
|
|
536
|
+
if (!(await Promise.all([probeTcp("127.0.0.1", port), probeTcp("::1", port)])).some(Boolean)) return null;
|
|
537
|
+
return `Port ${port} is in use, and you asked for it with --port. Stop whatever is listening on ${port}, or pass a different port. (Without --port, dev derives a port for this project and walks past a collision.)`;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The frontend port for this project, derived the same way the backend's is.
|
|
541
|
+
*
|
|
542
|
+
* `rebase dev` used to leave this entirely to Vite, which takes 5173 and, when
|
|
543
|
+
* that is busy, silently walks to 5174, 5175 and onward. The backend was then
|
|
544
|
+
* started with whatever `FRONTEND_URL` the scaffold's `.env` happened to say —
|
|
545
|
+
* `http://localhost:5173` — so the welcome email linked a port the app was not
|
|
546
|
+
* on. Deriving it here means the value handed to the backend describes the
|
|
547
|
+
* server we are about to start rather than a default nobody checked.
|
|
548
|
+
*
|
|
549
|
+
* Range 5173–5372, so the familiar port is still the common case for a single
|
|
550
|
+
* project and two projects rarely collide.
|
|
551
|
+
*
|
|
552
|
+
* Note this is passed to Vite WITHOUT `--strictPort` unless the developer
|
|
553
|
+
* pinned it explicitly. Vite keeps its fallback, so a busy port still starts —
|
|
554
|
+
* it just starts somewhere the email link does not name, which is exactly
|
|
555
|
+
* today's behaviour and no worse. Pinning REBASE_FRONTEND_PORT makes both
|
|
556
|
+
* exact.
|
|
557
|
+
*/
|
|
558
|
+
function getProjectFrontendPort(projectRoot) {
|
|
559
|
+
let hash = 0;
|
|
560
|
+
for (let i = 0; i < projectRoot.length; i++) hash = (hash << 5) - hash + projectRoot.charCodeAt(i) | 0;
|
|
561
|
+
return 5173 + Math.abs(hash) % 200;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The flags `rebase dev` takes.
|
|
565
|
+
*
|
|
566
|
+
* Exported so `dev.test.ts` can assert that every short alias the help
|
|
567
|
+
* advertises is declared here: the help said `--port, -p` while the spec has
|
|
568
|
+
* only ever declared `-P`, so `rebase dev -p 4000` typed straight off the help
|
|
569
|
+
* page passed `4000` as a positional and started on the default port.
|
|
570
|
+
*/
|
|
571
|
+
var DEV_FLAGS = {
|
|
572
|
+
"--backend-only": Boolean,
|
|
573
|
+
"--frontend-only": Boolean,
|
|
574
|
+
"--port": Number,
|
|
575
|
+
/**
|
|
576
|
+
* Point this run at a database of your own, ahead of everything else.
|
|
577
|
+
*
|
|
578
|
+
* The managed development database fills a vacuum; it never redirects a
|
|
579
|
+
* project that has said which Postgres it wants. This flag is the loudest
|
|
580
|
+
* way to say it, and outranks DATABASE_URL in the environment and in .env.
|
|
581
|
+
*/
|
|
582
|
+
"--database-url": String,
|
|
583
|
+
/** Use Postgres in Docker rather than the managed database. */
|
|
584
|
+
"--docker": Boolean,
|
|
585
|
+
"-b": "--backend-only",
|
|
586
|
+
"-f": "--frontend-only",
|
|
587
|
+
"-P": "--port",
|
|
588
|
+
"--no-db": Boolean
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Read one variable out of the project's env file.
|
|
592
|
+
*
|
|
593
|
+
* `dotenv.parse` rather than `dotenv.config`, because this must not put the
|
|
594
|
+
* project's secrets into the CLI process's own environment — the child
|
|
595
|
+
* processes are given the env file by path and read it themselves.
|
|
596
|
+
*/
|
|
597
|
+
function readEnvVar(projectRoot, name) {
|
|
598
|
+
const envFile = findEnvFile(projectRoot);
|
|
599
|
+
if (!envFile || !fs.existsSync(envFile)) return void 0;
|
|
600
|
+
try {
|
|
601
|
+
return dotenv.parse(fs.readFileSync(envFile))[name] || void 0;
|
|
602
|
+
} catch {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Whether this project declares collections at all.
|
|
608
|
+
*
|
|
609
|
+
* A project with none has no schema to generate or push — the headless BaaS
|
|
610
|
+
* mode serves whatever the database already has.
|
|
611
|
+
*/
|
|
612
|
+
function projectHasCollections(projectRoot) {
|
|
613
|
+
const collectionsDir = path.join(projectRoot, "config", "collections");
|
|
614
|
+
return fs.existsSync(collectionsDir) && fs.readdirSync(collectionsDir).some((name) => /\.(ts|js|mts|mjs)$/.test(name) && !name.startsWith("index."));
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Make the generated Drizzle schema match the collections before anything reads it.
|
|
618
|
+
*
|
|
619
|
+
* ## The bug this exists for
|
|
620
|
+
*
|
|
621
|
+
* The scaffold ships `backend/src/schema.generated.ts` as a stub — `export const
|
|
622
|
+
* tables = {}` — and something has to replace it. On the Docker path
|
|
623
|
+
* `rebase db push` did, as step one of three. On the **managed PGlite path
|
|
624
|
+
* nothing did**, and that is the path a new project takes, because `rebase init`
|
|
625
|
+
* deliberately leaves `DATABASE_URL` commented out.
|
|
626
|
+
*
|
|
627
|
+
* The result was the worst shape a first run can have: the database was fine.
|
|
628
|
+
* Boot created all 30 tables through the additive ensure, `/health` answered
|
|
629
|
+
* 200, auth worked, the admin panel loaded — and every single
|
|
630
|
+
* `GET /api/data/*` returned a 500, `Table not found for collection 'posts'`,
|
|
631
|
+
* because the driver looked the table up in the generated file rather than in
|
|
632
|
+
* the database. A stranger following the README had no way to guess that
|
|
633
|
+
* `pnpm run schema:generate` was the missing step, and the README told them the
|
|
634
|
+
* schema was pushed for them.
|
|
635
|
+
*
|
|
636
|
+
* That failure is fixed at the root — the driver builds its tables from
|
|
637
|
+
* `information_schema` now, so a stub `schema.generated.ts` cannot empty the
|
|
638
|
+
* API. This still runs, because the file is a real artifact for everything
|
|
639
|
+
* around the server: `db push` diffs it, Atlas plans from it, `eject` writes it
|
|
640
|
+
* out, and application code imports it.
|
|
641
|
+
*
|
|
642
|
+
* ## Why it runs unconditionally
|
|
643
|
+
*
|
|
644
|
+
* Generation reads collection files and writes one file. It needs no database,
|
|
645
|
+
* it is idempotent, and it takes about two seconds. Making it a precondition of
|
|
646
|
+
* starting the dev server — rather than a side effect of one database path —
|
|
647
|
+
* is what makes "the generated schema matches the collections" true by
|
|
648
|
+
* construction instead of true by remembering. The same staleness bites a
|
|
649
|
+
* project whose database was already running, where the push never ran either.
|
|
650
|
+
*
|
|
651
|
+
* Failure is not fatal: the server still starts, and it will report the real
|
|
652
|
+
* problem itself. A dev server that refuses to boot because codegen failed is
|
|
653
|
+
* worse than one that boots and says so.
|
|
654
|
+
*/
|
|
655
|
+
async function ensureGeneratedSchema(projectRoot) {
|
|
656
|
+
if (!projectHasCollections(projectRoot)) return "skipped";
|
|
657
|
+
try {
|
|
658
|
+
await runDriverSchemaCommand([
|
|
659
|
+
"node",
|
|
660
|
+
"rebase",
|
|
661
|
+
"schema",
|
|
662
|
+
"generate"
|
|
663
|
+
], { quiet: true });
|
|
664
|
+
return "generated";
|
|
665
|
+
} catch (error) {
|
|
666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
667
|
+
console.log(` ${chalk.yellow("⚠")} ${chalk.gray(`Could not regenerate the database schema: ${message}`)}`);
|
|
668
|
+
console.log(` ${chalk.gray(`Run ${chalk.cyan("rebase schema generate")} to see why. Collection reads will fail until it succeeds.`)}`);
|
|
669
|
+
return "failed";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Whether dev must still run the driver's `schema stale --fix` before the
|
|
674
|
+
* backend boots.
|
|
675
|
+
*
|
|
676
|
+
* Not after a regeneration that succeeded: that wrote `schema.generated.ts`
|
|
677
|
+
* from these collections with this driver, moments earlier, and `stale`
|
|
678
|
+
* compares the file against the same generator — it can only answer "nothing
|
|
679
|
+
* stale", and it cost a driver process (~0.6 s) on every start. The repair it
|
|
680
|
+
* exists for, a library upgrade that changed what the generator derives, is
|
|
681
|
+
* exactly what the regeneration already did. When generation was skipped or
|
|
682
|
+
* failed, the preflight is the remaining safety net and still runs.
|
|
683
|
+
*/
|
|
684
|
+
function needsStaleSchemaPreflight(generation) {
|
|
685
|
+
return generation !== "generated";
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Make `generated/sdk` match the collections, the same way the schema is kept.
|
|
689
|
+
*
|
|
690
|
+
* This used to happen only under `--generate`, and `generated/` is gitignored,
|
|
691
|
+
* so a fresh clone had no SDK types at all until someone ran
|
|
692
|
+
* `rebase generate-sdk`, and every collection edit left the typed client one
|
|
693
|
+
* step behind. A frontend compiling against a `Database` type that no longer
|
|
694
|
+
* describes the backend is the drift this whole command exists to prevent.
|
|
695
|
+
*
|
|
696
|
+
* A child process rather than a call into `generateSdkCommand`: that loads the
|
|
697
|
+
* collections through jiti, whose module cache would hand the second
|
|
698
|
+
* regeneration the collections the first one loaded. And this CLI re-run
|
|
699
|
+
* directly, not `pnpm exec rebase`: the package manager's pre-run dependency
|
|
700
|
+
* check can start an install on every save, and its `rebase` is whichever one
|
|
701
|
+
* it resolves, not necessarily the one running `dev`.
|
|
702
|
+
*
|
|
703
|
+
* The generator's own output is a usage guide meant for a person who ran it by
|
|
704
|
+
* hand, so it is captured, and on failure only the lines that say what went
|
|
705
|
+
* wrong are shown — not the stack under them. Like the schema, a failure never
|
|
706
|
+
* stops `dev`.
|
|
707
|
+
*/
|
|
708
|
+
async function regenerateSdk(projectRoot) {
|
|
709
|
+
if (!projectHasCollections(projectRoot)) return;
|
|
710
|
+
const entry = resolveCliEntry();
|
|
711
|
+
const { execPath, prefixArgs } = resolveSpawn(entry);
|
|
712
|
+
try {
|
|
713
|
+
await execa(execPath, [
|
|
714
|
+
...prefixArgs,
|
|
715
|
+
entry,
|
|
716
|
+
"generate-sdk"
|
|
717
|
+
], {
|
|
718
|
+
cwd: projectRoot,
|
|
719
|
+
all: true
|
|
720
|
+
});
|
|
721
|
+
} catch (error) {
|
|
722
|
+
const cause = (typeof error === "object" && error !== null && "all" in error && typeof error.all === "string" ? error.all : "").split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)).slice(-6);
|
|
723
|
+
console.log(` ${chalk.yellow("⚠")} ${chalk.gray("Could not regenerate the SDK types in generated/sdk.")}`);
|
|
724
|
+
if (cause.length > 0) console.log(chalk.gray(cause.map((line) => ` ${line.trim()}`).join("\n")));
|
|
725
|
+
console.log(` ${chalk.gray(`Run ${chalk.cyan("rebase generate-sdk")} to see why.`)}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* One variable's value out of `.env` text, or undefined when it has none.
|
|
730
|
+
*
|
|
731
|
+
* The obvious `\s*(.+?)\s*$` does not do this, because `\s` matches a newline
|
|
732
|
+
* and `$` under /m matches at every line end: on `VITE_API_URL=` followed by
|
|
733
|
+
* `# VITE_GOOGLE_CLIENT_ID=`, the leading `\s*` ate the line break and the
|
|
734
|
+
* capture returned the *next* line. The scaffold ships `VITE_API_URL=` empty,
|
|
735
|
+
* so every first `rebase dev` read a comment as its value, decided it differed
|
|
736
|
+
* from the derived URL, and warned that a variable the developer had not set
|
|
737
|
+
* was being ignored.
|
|
738
|
+
*
|
|
739
|
+
* Horizontal whitespace only, so a value stops at its own line.
|
|
740
|
+
*/
|
|
741
|
+
function readEnvValue(envText, key) {
|
|
742
|
+
const match = envText.match(new RegExp(`^[^\\S\\r\\n]*${key}[^\\S\\r\\n]*=[^\\S\\r\\n]*(.*?)[^\\S\\r\\n]*$`, "m"));
|
|
743
|
+
if (!match) return void 0;
|
|
744
|
+
const value = match[1].replace(/^["']|["']$/g, "");
|
|
745
|
+
return value.length > 0 ? value : void 0;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* The argv the first-boot schema push runs with.
|
|
749
|
+
*
|
|
750
|
+
* The database is named on the command line rather than left to be re-resolved.
|
|
751
|
+
* `runDriverDbCommand` runs the resolver again for whatever argv it is handed,
|
|
752
|
+
* and the argv it used to be handed (`["node","rebase","db","push"]`) carried
|
|
753
|
+
* nothing at all — so on the `--docker` path it read a `.env` whose
|
|
754
|
+
* `DATABASE_URL` is commented out, concluded "managed PGlite", and refused the
|
|
755
|
+
* push for a database that was not the one the preflight had just started.
|
|
756
|
+
*
|
|
757
|
+
* Exported for its test: the missing pair of arguments is the whole defect, and
|
|
758
|
+
* it is invisible in any assertion about the push's *outcome*.
|
|
759
|
+
*/
|
|
760
|
+
function schemaPushArgv(databaseUrl) {
|
|
761
|
+
const argv = [
|
|
762
|
+
"node",
|
|
763
|
+
"rebase",
|
|
764
|
+
"db",
|
|
765
|
+
"push"
|
|
766
|
+
];
|
|
767
|
+
if (databaseUrl) argv.push("--database-url", databaseUrl);
|
|
768
|
+
return argv;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Start-up's independent halves, overlapped rather than run one after another.
|
|
772
|
+
*
|
|
773
|
+
* `rebase dev` used to regenerate the schema and the SDK, then derive the
|
|
774
|
+
* resource graph (every collection evaluated again, in this process), then
|
|
775
|
+
* start the managed database and wait for it — about a second when it was not
|
|
776
|
+
* already running — and only then spawn the backend. None of the three reads
|
|
777
|
+
* what another writes: the generators read `config/` and write
|
|
778
|
+
* `backend/src/schema.generated.ts` and `generated/sdk/`; the derivation reads
|
|
779
|
+
* `config/resources.ts` and the collections; the database needs neither, and
|
|
780
|
+
* `schema generate` never connects to one. So `database` starts first and is
|
|
781
|
+
* collected last.
|
|
782
|
+
*
|
|
783
|
+
* `preflight` is the exception, and the reason this is not one `Promise.all`:
|
|
784
|
+
* on the `--docker` path it pushes the schema the generators have just written,
|
|
785
|
+
* so it still runs after them.
|
|
786
|
+
*
|
|
787
|
+
* A database that fails to start is reported where it always was — after the
|
|
788
|
+
* generators' and the preflight's output — rather than ahead of it, and it is
|
|
789
|
+
* never an unhandled rejection while they run.
|
|
790
|
+
*
|
|
791
|
+
* Exported for its test.
|
|
792
|
+
*/
|
|
793
|
+
async function overlapDevStartup(steps) {
|
|
794
|
+
const database = steps.database ? steps.database().then((value) => ({
|
|
795
|
+
ok: true,
|
|
796
|
+
value
|
|
797
|
+
}), (error) => ({
|
|
798
|
+
ok: false,
|
|
799
|
+
error
|
|
800
|
+
})) : null;
|
|
801
|
+
const schemaGeneration = await steps.generate();
|
|
802
|
+
await steps.preflight();
|
|
803
|
+
const settled = database ? await database : null;
|
|
804
|
+
if (settled && !settled.ok) throw settled.error;
|
|
805
|
+
return {
|
|
806
|
+
schemaGeneration,
|
|
807
|
+
database: settled ? settled.value : null
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The database half of `rebase dev` starting up.
|
|
812
|
+
*
|
|
813
|
+
* Separated from `ensureDevDatabase` so that everything needing a project on
|
|
814
|
+
* disk lives here and the decision logic stays testable without one.
|
|
815
|
+
*/
|
|
816
|
+
async function runDatabasePreflight(options) {
|
|
817
|
+
const { projectRoot, disabled } = options;
|
|
818
|
+
const hasCollections = projectHasCollections(projectRoot);
|
|
819
|
+
const databaseUrl = options.databaseUrl ?? readEnvVar(projectRoot, "DATABASE_URL");
|
|
820
|
+
if ((await ensureDevDatabase({
|
|
821
|
+
projectRoot,
|
|
822
|
+
databaseUrl,
|
|
823
|
+
disabled,
|
|
824
|
+
hasCollections,
|
|
825
|
+
pushSchema: async () => {
|
|
826
|
+
await runDriverDbCommand(schemaPushArgv(databaseUrl), { quiet: true });
|
|
827
|
+
}
|
|
828
|
+
})).action === "started") console.log("");
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Kill a child `rebase dev` started, with everything it started in turn.
|
|
832
|
+
*
|
|
833
|
+
* Both servers run detached, in a process group of their own, behind a shell
|
|
834
|
+
* and a package manager — so the group is what has to go, not the pid.
|
|
835
|
+
*/
|
|
836
|
+
function killChildTree(child) {
|
|
837
|
+
if (child.killed) return;
|
|
838
|
+
try {
|
|
839
|
+
if (!child.pid) throw new Error("no pid");
|
|
840
|
+
if (process.platform === "win32") execaCommandSync(`taskkill /pid ${child.pid} /T /F`);
|
|
841
|
+
else process.kill(-child.pid, "SIGKILL");
|
|
842
|
+
} catch {
|
|
843
|
+
try {
|
|
844
|
+
child.kill("SIGKILL");
|
|
845
|
+
} catch {}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The port the last `rebase dev` backend landed on, from `.rebase-dev-port`.
|
|
850
|
+
*
|
|
851
|
+
* Read the way the server reads it (`listenWithPortRetry`): the first number,
|
|
852
|
+
* whether the file is the server's `<bound> <requested>` or the CLI's bare
|
|
853
|
+
* `<bound>`. `null` when there is no file or no number in it.
|
|
854
|
+
*/
|
|
855
|
+
function rememberedDevPort(projectRoot) {
|
|
856
|
+
try {
|
|
857
|
+
const raw = fs.readFileSync(path.join(projectRoot, DEV_PORT_FILENAME), "utf-8").trim().split(/\s+/)[0];
|
|
858
|
+
return parsePort(raw) ?? null;
|
|
859
|
+
} catch {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Whether the admin panel can start beside the backend instead of after it.
|
|
865
|
+
*
|
|
866
|
+
* Vite has to be told the backend's URL when it starts (`VITE_API_URL`), and
|
|
867
|
+
* the backend's port is only certain once it has bound — so the admin panel
|
|
868
|
+
* used to wait for "Server running at", and the time to it was the backend's
|
|
869
|
+
* whole boot plus Vite's start. It can start beside the backend whenever the
|
|
870
|
+
* port the backend will bind is known in advance:
|
|
871
|
+
*
|
|
872
|
+
* - **named with `--port`**: the server binds it or refuses to start, never
|
|
873
|
+
* walks past it (`REBASE_DEV_PORT_EXPLICIT`), and `pinnedPortRefusal` has
|
|
874
|
+
* already checked that nothing answers there;
|
|
875
|
+
* - **free, and the port the backend tries first**: the server tries the port
|
|
876
|
+
* its last run landed on before the one it is given (`.rebase-dev-port`), so
|
|
877
|
+
* a remembered port must be this one — the "kept from the last run" rung —
|
|
878
|
+
* or absent, which it is after every clean exit, and the derived port is
|
|
879
|
+
* then the one tried. A port something already answers on would make the
|
|
880
|
+
* backend move, so that case keeps the old order.
|
|
881
|
+
*
|
|
882
|
+
* Nothing here can rule out another process taking the port in the second the
|
|
883
|
+
* backend boots, so `devCommand` still compares the announced port with the
|
|
884
|
+
* one Vite was given and restarts the admin panel when they differ.
|
|
885
|
+
*
|
|
886
|
+
* Exported for its test.
|
|
887
|
+
*/
|
|
888
|
+
async function frontendCanStartWithBackend(options) {
|
|
889
|
+
if (options.pinned) return true;
|
|
890
|
+
const remembered = rememberedDevPort(options.projectRoot);
|
|
891
|
+
if (remembered !== null && remembered !== options.port) return false;
|
|
892
|
+
const probe = options.probe ?? probeTcp;
|
|
893
|
+
return !(await Promise.all([probe("127.0.0.1", options.port), probe("::1", options.port)])).some(Boolean);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* The tsx the backend is watched with, or exit saying why there is none.
|
|
897
|
+
*
|
|
898
|
+
* Checked before anything is started rather than just before the backend
|
|
899
|
+
* spawns: start-up begins the database at once now, and a project whose
|
|
900
|
+
* dependencies are missing should hear that first, not after two generators
|
|
901
|
+
* have each failed on the same cause.
|
|
902
|
+
*/
|
|
903
|
+
function requireUsableTsx(projectRoot) {
|
|
904
|
+
const tsxBin = resolveTsx(projectRoot);
|
|
905
|
+
if (!tsxBin) {
|
|
906
|
+
console.error(chalk.red(` ✗ ${dependenciesNotInstalled(projectRoot)}`));
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
const tsxValidationError = validateTsxInstallation(tsxBin);
|
|
910
|
+
if (tsxValidationError) {
|
|
911
|
+
const installCmd = getPMCommands(detectPackageManager(projectRoot)).install.join(" ");
|
|
912
|
+
console.error(chalk.red(" ✗ tsx installation appears corrupted."));
|
|
913
|
+
console.error(chalk.gray(` ${tsxValidationError}`));
|
|
914
|
+
console.error("");
|
|
915
|
+
console.error(chalk.gray(" To fix, run:"));
|
|
916
|
+
console.error(chalk.cyan(` rm -rf node_modules && ${installCmd}`));
|
|
917
|
+
process.exit(1);
|
|
918
|
+
}
|
|
919
|
+
return tsxBin;
|
|
920
|
+
}
|
|
921
|
+
async function devCommand(rawArgs) {
|
|
922
|
+
if (wantsHelp(rawArgs)) {
|
|
923
|
+
printDevHelp();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (rawArgs.includes("--generate") || rawArgs.includes("-g")) throw new UsageError("`rebase dev --generate` is gone: dev now regenerates the schema and the SDK types on every start and every collection save. Drop the flag.");
|
|
927
|
+
const { flags: args } = parseCommandArgs({
|
|
928
|
+
spec: DEV_FLAGS,
|
|
929
|
+
rawArgs,
|
|
930
|
+
commandWords: 1,
|
|
931
|
+
command: "dev",
|
|
932
|
+
maxPositionals: 0
|
|
933
|
+
});
|
|
934
|
+
const projectRoot = requireProjectRoot();
|
|
935
|
+
recordEvent("cli.dev", {
|
|
936
|
+
backend_only: Boolean(args["--backend-only"]),
|
|
937
|
+
frontend_only: Boolean(args["--frontend-only"])
|
|
938
|
+
}, { projectRoot });
|
|
939
|
+
const backendDir = findBackendDir(projectRoot);
|
|
940
|
+
const frontendDir = findFrontendDir(projectRoot);
|
|
941
|
+
const backendOnly = args["--backend-only"] || false;
|
|
942
|
+
const frontendOnly = args["--frontend-only"] || false;
|
|
943
|
+
/**
|
|
944
|
+
* Start no database at all — not the compose container, and not the managed
|
|
945
|
+
* one either. Read once, here, because both halves of `dev` have to agree:
|
|
946
|
+
* gating only the preflight left the managed PGlite starting anyway, which
|
|
947
|
+
* is the one database a scaffolded project would otherwise get.
|
|
948
|
+
*/
|
|
949
|
+
const noDb = Boolean(args["--no-db"]) || parseEnvBoolean(process.env.REBASE_DEV_NO_DB) === true;
|
|
950
|
+
const { port: startPort, source: startPortSource } = resolveStartPort(projectRoot, args["--port"]);
|
|
951
|
+
const portIsPinned = args["--port"] !== void 0;
|
|
952
|
+
const pinnedFrontendPort = process.env.REBASE_FRONTEND_PORT;
|
|
953
|
+
if (pinnedFrontendPort && !/^\d+$/.test(pinnedFrontendPort)) throw new Error(`REBASE_FRONTEND_PORT must be a number, got ${JSON.stringify(pinnedFrontendPort)}.`);
|
|
954
|
+
const frontendPort = pinnedFrontendPort ? Number(pinnedFrontendPort) : getProjectFrontendPort(projectRoot);
|
|
955
|
+
if (!frontendOnly) {
|
|
956
|
+
const refusal = await pinnedPortRefusal(startPort, portIsPinned);
|
|
957
|
+
if (refusal) {
|
|
958
|
+
console.error(chalk.red(` ✗ ${refusal}`));
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const startsBackend = !frontendOnly && Boolean(backendDir);
|
|
963
|
+
const tsxBin = startsBackend ? requireUsableTsx(projectRoot) : null;
|
|
964
|
+
console.log("");
|
|
965
|
+
console.log(chalk.bold(" 🚀 Rebase Dev Server"));
|
|
966
|
+
console.log("");
|
|
967
|
+
let schemaGeneration = "skipped";
|
|
968
|
+
/**
|
|
969
|
+
* The database this run's backend gets. A project with a DATABASE_URL is
|
|
970
|
+
* untouched; a project without one gets a managed Postgres, which is what
|
|
971
|
+
* makes `rebase dev` the only command a new project needs.
|
|
972
|
+
*
|
|
973
|
+
* `--no-db` means start nothing, and that includes the managed database.
|
|
974
|
+
* It used to gate only the preflight, so the one path that actually starts
|
|
975
|
+
* a database on a scaffolded project — the managed PGlite — ran anyway:
|
|
976
|
+
* `rebase dev --no-db` wrote `.rebase/pglite/`, booted a daemon and served
|
|
977
|
+
* against it, which is the opposite of what a reader asking for no database
|
|
978
|
+
* expects. The backend is left to fail on the DATABASE_URL it cannot find,
|
|
979
|
+
* which is the failure the flag exists to produce.
|
|
980
|
+
*/
|
|
981
|
+
let prepared = null;
|
|
982
|
+
if (!frontendOnly) ({schemaGeneration, database: prepared} = await overlapDevStartup({
|
|
983
|
+
generate: async () => (await Promise.all([ensureGeneratedSchema(projectRoot), regenerateSdk(projectRoot)]))[0],
|
|
984
|
+
preflight: () => runDatabasePreflight({
|
|
985
|
+
projectRoot,
|
|
986
|
+
disabled: noDb,
|
|
987
|
+
databaseUrl: args["--docker"] ? resolveComposeUrl(projectRoot, readEnvFile(projectRoot)) ?? void 0 : void 0
|
|
988
|
+
}),
|
|
989
|
+
database: startsBackend && !noDb ? async () => prepareDatabaseEnv(projectRoot, {
|
|
990
|
+
flagUrl: typeof args["--database-url"] === "string" ? args["--database-url"] : null,
|
|
991
|
+
flagDocker: Boolean(args["--docker"]),
|
|
992
|
+
onProgress: (message) => console.log(chalk.gray(` ${message}`)),
|
|
993
|
+
additionalKeys: await declaredAdditionalDatabases(projectRoot)
|
|
994
|
+
}) : null
|
|
995
|
+
}));
|
|
996
|
+
const children = [];
|
|
997
|
+
/**
|
|
998
|
+
* Whether this project has an admin panel to serve at all.
|
|
999
|
+
*
|
|
1000
|
+
* A headless project declares one app, of type "backend", so there is no
|
|
1001
|
+
* `frontend/` and never will be. Warning that a directory is "missing" for
|
|
1002
|
+
* a shape that has no such directory reads as a broken scaffold on the
|
|
1003
|
+
* very first run of the very command the headless quickstart names.
|
|
1004
|
+
*/
|
|
1005
|
+
const staticShape = (() => {
|
|
1006
|
+
try {
|
|
1007
|
+
return devStaticShape(loadManifest(projectRoot).manifest, projectRoot, frontendDir);
|
|
1008
|
+
} catch {
|
|
1009
|
+
return { declaresStaticApp: Boolean(frontendDir) };
|
|
1010
|
+
}
|
|
1011
|
+
})();
|
|
1012
|
+
const declaresStaticApp = staticShape.declaresStaticApp;
|
|
1013
|
+
let frontendUrl = "";
|
|
1014
|
+
let backendUrl = "";
|
|
1015
|
+
let debounceSummary = null;
|
|
1016
|
+
let bannerPrinted = false;
|
|
1017
|
+
/** Actual backend port, resolved once the server prints its URL. */
|
|
1018
|
+
let resolvedBackendPort = null;
|
|
1019
|
+
/**
|
|
1020
|
+
* The connection string of the database this run started, when it started
|
|
1021
|
+
* one. Filled in below; read by the headless banner, which is the only
|
|
1022
|
+
* place a headless project could learn it — there is no admin panel to
|
|
1023
|
+
* open, and `.env` deliberately does not name the managed database.
|
|
1024
|
+
*/
|
|
1025
|
+
let managedConnectionString = null;
|
|
1026
|
+
/**
|
|
1027
|
+
* Where the backend mounted Swagger, if it mounted it at all.
|
|
1028
|
+
*
|
|
1029
|
+
* Read from the backend's own log rather than assumed: a headless project
|
|
1030
|
+
* with no tables yet serves no data API and no Swagger, and the banner must
|
|
1031
|
+
* not name a URL that answers 404.
|
|
1032
|
+
*/
|
|
1033
|
+
let swaggerPath = null;
|
|
1034
|
+
function printSummary() {
|
|
1035
|
+
if (!backendUrl) return;
|
|
1036
|
+
if (declaresStaticApp && !frontendUrl) return;
|
|
1037
|
+
if (debounceSummary) clearTimeout(debounceSummary);
|
|
1038
|
+
debounceSummary = setTimeout(() => {
|
|
1039
|
+
if (bannerPrinted) return;
|
|
1040
|
+
const api = `http://localhost:${resolvedBackendPort}`;
|
|
1041
|
+
/** `[label, value]` per line; an empty pair is a blank line. */
|
|
1042
|
+
const lines = declaresStaticApp ? appBannerLines(stripAnsi(frontendUrl), api, staticShape) : [
|
|
1043
|
+
["", ""],
|
|
1044
|
+
["✦ Rebase API is ready!", ""],
|
|
1045
|
+
["➜ API: ", api]
|
|
1046
|
+
];
|
|
1047
|
+
if (!declaresStaticApp) {
|
|
1048
|
+
if (swaggerPath) lines.push(["➜ Swagger: ", `${api}${swaggerPath}`]);
|
|
1049
|
+
else lines.push([" ", "(no tables served yet, so no data API and no docs)"]);
|
|
1050
|
+
if (managedConnectionString) {
|
|
1051
|
+
lines.push(["", ""]);
|
|
1052
|
+
lines.push(["Database (managed, this project only):", ""]);
|
|
1053
|
+
lines.push([" ", managedConnectionString]);
|
|
1054
|
+
lines.push([" ", "also: rebase db url"]);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
lines.push(["", ""]);
|
|
1058
|
+
lines.push(["First login: ", "the first account to register becomes admin"]);
|
|
1059
|
+
lines.push([" ", "(a production boot seeds REBASE_ADMIN_EMAIL from .env instead)"]);
|
|
1060
|
+
lines.push(["", ""]);
|
|
1061
|
+
const inner = Math.max(...lines.map(([label, value]) => label.length + value.length)) + 4;
|
|
1062
|
+
console.log("");
|
|
1063
|
+
console.log(chalk.cyan("┌" + "─".repeat(inner) + "┐"));
|
|
1064
|
+
for (const [label, value] of lines) console.log(chalk.cyan("│ ") + label + chalk.white(value) + " ".repeat(inner - 2 - label.length - value.length) + chalk.cyan("│"));
|
|
1065
|
+
console.log(chalk.cyan("└" + "─".repeat(inner) + "┘"));
|
|
1066
|
+
console.log("");
|
|
1067
|
+
bannerPrinted = true;
|
|
1068
|
+
}, 500);
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Set once Ctrl+C has been seen, so a child dying on the way out is not
|
|
1072
|
+
* reported as a crash. Everything below distinguishes "the backend fell
|
|
1073
|
+
* over" from "you asked me to stop" by this flag alone — an exit code of
|
|
1074
|
+
* `null` with a signal is the shape of both.
|
|
1075
|
+
*/
|
|
1076
|
+
let shuttingDown = false;
|
|
1077
|
+
const cleanup = () => {
|
|
1078
|
+
shuttingDown = true;
|
|
1079
|
+
try {
|
|
1080
|
+
const portFile = path.join(projectRoot, DEV_PORT_FILENAME);
|
|
1081
|
+
if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
|
|
1082
|
+
const urlFile = path.join(projectRoot, ".rebase-dev-url");
|
|
1083
|
+
if (fs.existsSync(urlFile)) fs.unlinkSync(urlFile);
|
|
1084
|
+
} catch {}
|
|
1085
|
+
children.forEach(killChildTree);
|
|
1086
|
+
process.exit(typeof process.exitCode === "number" ? process.exitCode : 0);
|
|
1087
|
+
};
|
|
1088
|
+
process.on("SIGINT", cleanup);
|
|
1089
|
+
process.on("SIGTERM", cleanup);
|
|
1090
|
+
/** The running admin panel, and the backend port it was told (`VITE_API_URL`). */
|
|
1091
|
+
let frontendChild = null;
|
|
1092
|
+
let frontendApiPort = null;
|
|
1093
|
+
/**
|
|
1094
|
+
* Start the Vite frontend, optionally injecting the backend port.
|
|
1095
|
+
*/
|
|
1096
|
+
function startFrontend(backendPort) {
|
|
1097
|
+
if (!frontendDir) return;
|
|
1098
|
+
console.log(` ${chalk.magenta("▶")} Frontend: ${chalk.gray(frontendDir)}`);
|
|
1099
|
+
const frontendEnv = { ...process.env };
|
|
1100
|
+
frontendApiPort = backendPort;
|
|
1101
|
+
if (backendPort) {
|
|
1102
|
+
frontendEnv.VITE_API_URL = `http://localhost:${backendPort}`;
|
|
1103
|
+
console.log(` ${chalk.gray("↳ VITE_API_URL")} = ${chalk.white(`http://localhost:${backendPort}`)}`);
|
|
1104
|
+
}
|
|
1105
|
+
const pm = detectPackageManager(projectRoot);
|
|
1106
|
+
const runDevCmd = getPMCommands(pm).run("dev");
|
|
1107
|
+
/**
|
|
1108
|
+
* Pin the frontend's port when asked.
|
|
1109
|
+
*
|
|
1110
|
+
* Vite takes 5173 and hands it to the first asker, so a machine already
|
|
1111
|
+
* running one dev server gives the second either a bind failure or —
|
|
1112
|
+
* worse — somebody else's app on the address you expected. The backend
|
|
1113
|
+
* port has been pinnable since it had the same problem; this is the
|
|
1114
|
+
* other half.
|
|
1115
|
+
*
|
|
1116
|
+
* npm needs `--` before script arguments and pnpm does not, which is
|
|
1117
|
+
* the kind of difference that silently passes `--port` to the package
|
|
1118
|
+
* manager instead of to Vite.
|
|
1119
|
+
*/
|
|
1120
|
+
if (pm === "npm") runDevCmd.push("--");
|
|
1121
|
+
runDevCmd.push("--port", String(frontendPort));
|
|
1122
|
+
if (pinnedFrontendPort) runDevCmd.push("--strictPort");
|
|
1123
|
+
console.log(` ${chalk.gray("↳ frontend port")} = ${chalk.white(String(frontendPort))}`);
|
|
1124
|
+
const child = execa(runDevCmd[0], runDevCmd.slice(1), {
|
|
1125
|
+
cwd: frontendDir,
|
|
1126
|
+
stdio: [
|
|
1127
|
+
"inherit",
|
|
1128
|
+
"pipe",
|
|
1129
|
+
"pipe"
|
|
1130
|
+
],
|
|
1131
|
+
env: frontendEnv,
|
|
1132
|
+
shell: true,
|
|
1133
|
+
detached: process.platform !== "win32"
|
|
1134
|
+
});
|
|
1135
|
+
child.catch(() => {});
|
|
1136
|
+
frontendChild = child;
|
|
1137
|
+
forEachOutputLine(child.stdout, (line) => {
|
|
1138
|
+
console.log(`${chalk.magenta.bold("[admin]")} ${line}`);
|
|
1139
|
+
const cleanLine = stripAnsi(line);
|
|
1140
|
+
const urlMatch = cleanLine.match(/(http:\/\/(?:localhost|127\.0\.0\.1):\d+)/);
|
|
1141
|
+
if (cleanLine.includes("Local:") && urlMatch && child === frontendChild) {
|
|
1142
|
+
frontendUrl = urlMatch[1];
|
|
1143
|
+
printSummary();
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
forEachOutputLine(child.stderr, (line) => {
|
|
1147
|
+
console.log(`${chalk.magenta.bold("[admin]")} ${line}`);
|
|
1148
|
+
});
|
|
1149
|
+
children.push(child);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Start the admin panel again, told the port the backend actually bound.
|
|
1153
|
+
*
|
|
1154
|
+
* Only reachable when it was started beside the backend
|
|
1155
|
+
* ({@link frontendCanStartWithBackend}) and something took that port in the
|
|
1156
|
+
* second the backend booted, so the backend moved. Left alone, the admin
|
|
1157
|
+
* panel would talk to whatever now answers on the old port — very often
|
|
1158
|
+
* another Rebase backend, since dev ports are derived per project.
|
|
1159
|
+
*/
|
|
1160
|
+
async function restartFrontend(backendPort) {
|
|
1161
|
+
const stale = frontendChild;
|
|
1162
|
+
frontendUrl = "";
|
|
1163
|
+
console.log(` ${chalk.gray(`↳ The backend moved from ${frontendApiPort} to ${backendPort}; restarting the admin panel on it.`)}`);
|
|
1164
|
+
if (stale) {
|
|
1165
|
+
frontendChild = null;
|
|
1166
|
+
const index = children.indexOf(stale);
|
|
1167
|
+
if (index >= 0) children.splice(index, 1);
|
|
1168
|
+
killChildTree(stale);
|
|
1169
|
+
await stale.then(() => void 0, () => void 0);
|
|
1170
|
+
for (let attempt = 0; attempt < 30 && await probeTcp("127.0.0.1", frontendPort, 200); attempt++) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1171
|
+
}
|
|
1172
|
+
if (!shuttingDown) startFrontend(backendPort);
|
|
1173
|
+
}
|
|
1174
|
+
if (startsBackend && backendDir && tsxBin) {
|
|
1175
|
+
const envFile = findEnvFile(projectRoot);
|
|
1176
|
+
const env = { ...process.env };
|
|
1177
|
+
if (envFile) env.DOTENV_CONFIG_PATH = envFile;
|
|
1178
|
+
if (prepared) Object.assign(env, prepared.env);
|
|
1179
|
+
/**
|
|
1180
|
+
* Whether this run is on the managed database.
|
|
1181
|
+
*
|
|
1182
|
+
* `db push` is the remedy for what boot leaves alone, and it cannot run
|
|
1183
|
+
* against the managed one at all — Atlas plans by diffing against a
|
|
1184
|
+
* second, empty database, and PGlite serves exactly one. So every
|
|
1185
|
+
* remedy that names it has to know which database is under this run.
|
|
1186
|
+
*/
|
|
1187
|
+
const managed = prepared?.database.kind === "managed";
|
|
1188
|
+
if (managed) managedConnectionString = prepared?.env.DATABASE_URL ?? null;
|
|
1189
|
+
if (prepared) env[DEV_DATABASE_KIND_ENV] = prepared.database.kind;
|
|
1190
|
+
env.PORT = String(startPort);
|
|
1191
|
+
env.REBASE_DEV_PORT_EXPLICIT = String(portIsPinned);
|
|
1192
|
+
if (!backendOnly && frontendDir) env.FRONTEND_URL = `http://localhost:${frontendPort}`;
|
|
1193
|
+
console.log(` ${chalk.cyan("▶")} Backend: ${chalk.gray(backendDir)}`);
|
|
1194
|
+
console.log(` ${chalk.gray("↳ PORT")} = ${chalk.white(String(startPort))} ${chalk.gray(`(${START_PORT_SOURCE_LABELS[startPortSource]})`)}`);
|
|
1195
|
+
console.log(` ${chalk.gray("↳ Database")} = ${chalk.white(databaseBannerValue(prepared))}`);
|
|
1196
|
+
if (prepared) for (const line of managedNotices(prepared).slice(1)) console.log(` ${chalk.gray(line)}`);
|
|
1197
|
+
if (envFile) try {
|
|
1198
|
+
const envText = fs.readFileSync(envFile, "utf-8");
|
|
1199
|
+
const envPort = readEnvValue(envText, "PORT");
|
|
1200
|
+
const envApiUrl = readEnvValue(envText, "VITE_API_URL");
|
|
1201
|
+
const overridden = [];
|
|
1202
|
+
if (Boolean(envPort) && envPort !== String(3001) && envPort !== String(startPort)) overridden.push("PORT");
|
|
1203
|
+
if (envApiUrl && envApiUrl !== `http://localhost:${startPort}`) overridden.push("VITE_API_URL");
|
|
1204
|
+
if (overridden.length > 0) console.log(chalk.yellow(` ⚠ dev uses a derived per-project port (${startPort}); your .env ${overridden.join(" / ")} ${overridden.length > 1 ? "are" : "is"} ignored here (avoids cross-project collisions). Pass ${chalk.white("--port")} to pin a port.`));
|
|
1205
|
+
} catch {}
|
|
1206
|
+
/** Whether the frontend has been launched (we only launch it once). */
|
|
1207
|
+
let frontendLaunched = false;
|
|
1208
|
+
/**
|
|
1209
|
+
* Whether the backend's first "Server running at" has been compared
|
|
1210
|
+
* with the port an admin panel started beside it was given. Once:
|
|
1211
|
+
* where a later restart lands is the watcher's business, as before.
|
|
1212
|
+
*/
|
|
1213
|
+
let frontendPortChecked = false;
|
|
1214
|
+
try {
|
|
1215
|
+
const activePlugin = getActiveBackendPlugin(backendDir);
|
|
1216
|
+
const pluginCli = activePlugin ? resolvePluginCliScript(backendDir, activePlugin) : null;
|
|
1217
|
+
if (pluginCli && needsStaleSchemaPreflight(schemaGeneration)) await execa(tsxBin, [
|
|
1218
|
+
pluginCli,
|
|
1219
|
+
"schema",
|
|
1220
|
+
"stale",
|
|
1221
|
+
"--fix"
|
|
1222
|
+
], {
|
|
1223
|
+
cwd: backendDir,
|
|
1224
|
+
stdio: "inherit",
|
|
1225
|
+
env
|
|
1226
|
+
});
|
|
1227
|
+
} catch {}
|
|
1228
|
+
const ejectedEntry = path.join(backendDir, "src", "index.ts");
|
|
1229
|
+
const usesStockRuntime = !fs.existsSync(ejectedEntry);
|
|
1230
|
+
const entryTarget = usesStockRuntime ? resolveDevRuntimeEntry() : "src/index.ts";
|
|
1231
|
+
const runtimePaths = devRuntimeEnv(projectRoot);
|
|
1232
|
+
if (usesStockRuntime) Object.assign(env, runtimePaths);
|
|
1233
|
+
const restartTrigger = devRestartTrigger(projectRoot);
|
|
1234
|
+
if (restartTrigger) try {
|
|
1235
|
+
fs.mkdirSync(path.dirname(restartTrigger), { recursive: true });
|
|
1236
|
+
fs.writeFileSync(restartTrigger, String(Date.now()));
|
|
1237
|
+
} catch {}
|
|
1238
|
+
const usableTrigger = restartTrigger && fs.existsSync(restartTrigger) ? restartTrigger : null;
|
|
1239
|
+
const watchArgs = devWatchArgs(projectRoot, runtimePaths, entryTarget, usableTrigger).map(quoteForShell);
|
|
1240
|
+
/** Restart the backend once, after dev has finished reacting to a collection save. */
|
|
1241
|
+
const restartBackend = () => {
|
|
1242
|
+
if (!usableTrigger) return;
|
|
1243
|
+
try {
|
|
1244
|
+
fs.writeFileSync(usableTrigger, String(Date.now()));
|
|
1245
|
+
} catch {}
|
|
1246
|
+
};
|
|
1247
|
+
const generatedSchemaFile = path.resolve(projectRoot, runtimePaths.REBASE_DEV_SCHEMA);
|
|
1248
|
+
/** What tsx reacts to: a write, even of identical bytes, moves the mtime. */
|
|
1249
|
+
const generatedSchemaStamp = () => {
|
|
1250
|
+
try {
|
|
1251
|
+
const stat = fs.statSync(generatedSchemaFile);
|
|
1252
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
1253
|
+
} catch {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
const collectionsDir = devCollectionsDir(projectRoot);
|
|
1258
|
+
if (fs.existsSync(collectionsDir)) {
|
|
1259
|
+
let debounce = null;
|
|
1260
|
+
let sqlSchemaAffected = false;
|
|
1261
|
+
let changed = "";
|
|
1262
|
+
fs.watch(collectionsDir, { recursive: true }, (_eventType, filename) => {
|
|
1263
|
+
if (!filename || filename.startsWith(".") || filename.endsWith(".tmp")) return;
|
|
1264
|
+
sqlSchemaAffected = sqlSchemaAffected || affectsSqlSchema(collectionsDir, filename);
|
|
1265
|
+
changed = filename;
|
|
1266
|
+
if (debounce) clearTimeout(debounce);
|
|
1267
|
+
debounce = setTimeout(() => {
|
|
1268
|
+
const regenerateSchema = sqlSchemaAffected;
|
|
1269
|
+
sqlSchemaAffected = false;
|
|
1270
|
+
(async () => {
|
|
1271
|
+
if (!regenerateSchema) {
|
|
1272
|
+
await regenerateSdk(projectRoot);
|
|
1273
|
+
restartBackend();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const shown = changed.length > 31 ? `…${changed.slice(-30)}` : changed.padEnd(31);
|
|
1277
|
+
console.log([
|
|
1278
|
+
"",
|
|
1279
|
+
chalk.yellow(" ┌──────────────────────────────────────────────────────────────┐"),
|
|
1280
|
+
chalk.yellow(" │ 🔄 Collection file changed: ") + chalk.white(shown) + chalk.yellow("│"),
|
|
1281
|
+
chalk.yellow(" │ Regenerating the schema and the SDK types… │"),
|
|
1282
|
+
chalk.yellow(" └──────────────────────────────────────────────────────────────┘")
|
|
1283
|
+
].join("\n"));
|
|
1284
|
+
const schemaBefore = generatedSchemaStamp();
|
|
1285
|
+
await Promise.all([ensureGeneratedSchema(projectRoot), regenerateSdk(projectRoot)]);
|
|
1286
|
+
if (!(usesStockRuntime && generatedSchemaStamp() !== schemaBefore)) restartBackend();
|
|
1287
|
+
console.log([
|
|
1288
|
+
chalk.green(" ✓ Schema and SDK types regenerated. The backend restarts and boot"),
|
|
1289
|
+
chalk.green(" creates what is missing — a new collection, a new property."),
|
|
1290
|
+
...managed ? [chalk.gray(" A renamed column, a narrowed type or a removed field needs your"), chalk.gray(" own PostgreSQL: set DATABASE_URL, then rebase db push.")] : [chalk.gray(" For what boot leaves alone — a renamed column, a narrowed type,"), chalk.gray(` a removed field — run ${chalk.cyan("rebase db push")}.`)],
|
|
1291
|
+
""
|
|
1292
|
+
].join("\n"));
|
|
1293
|
+
})();
|
|
1294
|
+
}, 500);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
if (!backendOnly && frontendDir && await frontendCanStartWithBackend({
|
|
1298
|
+
projectRoot,
|
|
1299
|
+
port: startPort,
|
|
1300
|
+
pinned: portIsPinned
|
|
1301
|
+
})) {
|
|
1302
|
+
frontendLaunched = true;
|
|
1303
|
+
startFrontend(startPort);
|
|
1304
|
+
}
|
|
1305
|
+
const backendChild = execa(tsxBin, watchArgs, {
|
|
1306
|
+
cwd: backendDir,
|
|
1307
|
+
stdio: [
|
|
1308
|
+
"inherit",
|
|
1309
|
+
"pipe",
|
|
1310
|
+
"pipe"
|
|
1311
|
+
],
|
|
1312
|
+
env,
|
|
1313
|
+
shell: true,
|
|
1314
|
+
detached: process.platform !== "win32"
|
|
1315
|
+
});
|
|
1316
|
+
backendChild.catch(() => {});
|
|
1317
|
+
/**
|
|
1318
|
+
* Whether the backend ever said "Server running at".
|
|
1319
|
+
*
|
|
1320
|
+
* The one fact this command has about whether the thing it started
|
|
1321
|
+
* works. Without it, a backend that threw on boot left `rebase dev`
|
|
1322
|
+
* printing "Press Ctrl+C to stop all servers." under a stack trace, as
|
|
1323
|
+
* if everything were fine — and `tsx watch` keeps its own process alive
|
|
1324
|
+
* after the entry crashes, so nothing exits to contradict it.
|
|
1325
|
+
*/
|
|
1326
|
+
let backendAnnounced = false;
|
|
1327
|
+
/** The verdict, printed once per failure rather than once per line. */
|
|
1328
|
+
let backendDownReported = false;
|
|
1329
|
+
const reportBackendDown = (headline) => {
|
|
1330
|
+
if (shuttingDown || backendDownReported) return;
|
|
1331
|
+
backendDownReported = true;
|
|
1332
|
+
console.log("");
|
|
1333
|
+
console.error(chalk.red(` ✗ ${headline}`));
|
|
1334
|
+
console.error(chalk.gray(" Fix the error above; the watcher restarts it on the next change."));
|
|
1335
|
+
console.log("");
|
|
1336
|
+
if (backendOnly) process.exitCode = 1;
|
|
1337
|
+
};
|
|
1338
|
+
/**
|
|
1339
|
+
* Say so when the backend is not up.
|
|
1340
|
+
*
|
|
1341
|
+
* A deadline rather than an exit code, because there is usually no exit
|
|
1342
|
+
* to watch: the watcher survives its child and waits for a file change,
|
|
1343
|
+
* so a crash is visible only as an absence.
|
|
1344
|
+
*
|
|
1345
|
+
* Armed once at spawn and **re-armed on every restart**, which is the
|
|
1346
|
+
* half that was missing. The old timer fired once, thirty seconds after
|
|
1347
|
+
* start-up, and its body began `if (backendAnnounced) return` — so a
|
|
1348
|
+
* backend that came up and then died on the next save was covered by
|
|
1349
|
+
* nothing at all. Breaking `config/resources.ts` under a running
|
|
1350
|
+
* `rebase dev` printed a stack trace, took `/health` to nothing, and
|
|
1351
|
+
* left the CLI saying "Press Ctrl+C to stop all servers."
|
|
1352
|
+
*/
|
|
1353
|
+
const readyTimeoutMs = Number(env.REBASE_DEV_READY_TIMEOUT_MS ?? process.env.REBASE_DEV_READY_TIMEOUT_MS ?? 3e4);
|
|
1354
|
+
let readyDeadline;
|
|
1355
|
+
const disarmReadyDeadline = () => {
|
|
1356
|
+
if (readyDeadline) clearTimeout(readyDeadline);
|
|
1357
|
+
readyDeadline = void 0;
|
|
1358
|
+
};
|
|
1359
|
+
const armReadyDeadline = (headline) => {
|
|
1360
|
+
disarmReadyDeadline();
|
|
1361
|
+
if (readyTimeoutMs <= 0) return;
|
|
1362
|
+
readyDeadline = setTimeout(() => {
|
|
1363
|
+
if (backendAnnounced) return;
|
|
1364
|
+
reportBackendDown(headline);
|
|
1365
|
+
}, readyTimeoutMs);
|
|
1366
|
+
readyDeadline.unref?.();
|
|
1367
|
+
};
|
|
1368
|
+
const readySeconds = Math.round(readyTimeoutMs / 1e3);
|
|
1369
|
+
armReadyDeadline(`The backend has not started after ${readySeconds}s.`);
|
|
1370
|
+
const noticePortMoved = (line) => {
|
|
1371
|
+
const moved = portMovedNotice(line);
|
|
1372
|
+
if (!moved) return;
|
|
1373
|
+
console.log(` ${chalk.gray("↳ PORT")} = ${chalk.white(moved.to)} ${chalk.gray(`(${moved.from} was in use)`)}`);
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* The restart line, on either stream.
|
|
1377
|
+
*
|
|
1378
|
+
* A restart means the announced backend is gone: it either exited on
|
|
1379
|
+
* its own (tsx says "Rerunning") or is being killed (tsx says
|
|
1380
|
+
* "Restarting"). Either way the question "is there a server?" is open
|
|
1381
|
+
* again, so readiness is un-announced and the deadline re-armed. Without
|
|
1382
|
+
* this the flag stayed true forever after the first successful boot.
|
|
1383
|
+
*
|
|
1384
|
+
* Checked on both streams because which pipe it lands on is tsx's
|
|
1385
|
+
* business, and a marker watched on the wrong one never fires.
|
|
1386
|
+
*/
|
|
1387
|
+
const noticeWatcherRestart = (line) => {
|
|
1388
|
+
if (!WATCHER_RESTART_MARKER.test(stripAnsi(line))) return;
|
|
1389
|
+
backendAnnounced = false;
|
|
1390
|
+
backendDownReported = false;
|
|
1391
|
+
armReadyDeadline(`The backend did not come back up after ${readySeconds}s.`);
|
|
1392
|
+
};
|
|
1393
|
+
/**
|
|
1394
|
+
* And notice a backend that dies without tsx saying anything.
|
|
1395
|
+
*
|
|
1396
|
+
* `kill -9` of the server process is the plain case: tsx stays alive,
|
|
1397
|
+
* prints nothing, and waits for a file change that may never come.
|
|
1398
|
+
* Measured on tsx 4.23.1 — the watcher's transcript after the kill is
|
|
1399
|
+
* empty. So the only honest question left is whether anything is still
|
|
1400
|
+
* listening on the port the backend announced, and this asks it.
|
|
1401
|
+
*
|
|
1402
|
+
* Only runs between an announcement and the next restart, so an
|
|
1403
|
+
* ordinary restart (where the port is legitimately closed for a second)
|
|
1404
|
+
* cannot trip it: `noticeWatcherRestart` clears `backendAnnounced`
|
|
1405
|
+
* before the process is killed. Two misses rather than one, because a
|
|
1406
|
+
* single refused connect under load is not yet news.
|
|
1407
|
+
*/
|
|
1408
|
+
let missedProbes = 0;
|
|
1409
|
+
const liveness = setInterval(() => {
|
|
1410
|
+
if (shuttingDown || !backendAnnounced || !resolvedBackendPort) {
|
|
1411
|
+
missedProbes = 0;
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
const port = resolvedBackendPort;
|
|
1415
|
+
probeTcp("127.0.0.1", port, 1e3).then((alive) => {
|
|
1416
|
+
if (!backendAnnounced || resolvedBackendPort !== port) return;
|
|
1417
|
+
if (alive) {
|
|
1418
|
+
missedProbes = 0;
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (++missedProbes < 2) return;
|
|
1422
|
+
backendAnnounced = false;
|
|
1423
|
+
missedProbes = 0;
|
|
1424
|
+
reportBackendDown(`The backend stopped listening on port ${port}.`);
|
|
1425
|
+
});
|
|
1426
|
+
}, LIVENESS_INTERVAL_MS);
|
|
1427
|
+
liveness.unref?.();
|
|
1428
|
+
/**
|
|
1429
|
+
* And say so when tsx itself gives up.
|
|
1430
|
+
*
|
|
1431
|
+
* Distinct from the deadline above: this is the watcher exiting, not
|
|
1432
|
+
* the app under it. It happens when the entry cannot be resolved at
|
|
1433
|
+
* all, when node_modules is broken, or when something kills the
|
|
1434
|
+
* process — none of which tsx will retry, so the run is over and the
|
|
1435
|
+
* exit code should say so.
|
|
1436
|
+
*/
|
|
1437
|
+
backendChild.on("exit", (code, signal) => {
|
|
1438
|
+
disarmReadyDeadline();
|
|
1439
|
+
clearInterval(liveness);
|
|
1440
|
+
backendAnnounced = false;
|
|
1441
|
+
backendDownReported = false;
|
|
1442
|
+
reportBackendDown(signal ? `Backend was killed by ${signal}.` : `Backend exited with code ${code ?? 1}.`);
|
|
1443
|
+
if (backendOnly) process.exitCode = code ?? 1;
|
|
1444
|
+
});
|
|
1445
|
+
forEachOutputLine(backendChild.stdout, (line) => {
|
|
1446
|
+
console.log(`${chalk.cyan.bold("[backend]")} ${line}`);
|
|
1447
|
+
noticeWatcherRestart(line);
|
|
1448
|
+
noticePortMoved(line);
|
|
1449
|
+
const cleanLine = stripAnsi(line);
|
|
1450
|
+
const swaggerMatch = cleanLine.match(/Swagger UI available.*"path":"([^"]+)"/);
|
|
1451
|
+
if (swaggerMatch) swaggerPath = swaggerMatch[1];
|
|
1452
|
+
const serverMatch = cleanLine.match(/Server running at http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/);
|
|
1453
|
+
if (serverMatch) {
|
|
1454
|
+
backendAnnounced = true;
|
|
1455
|
+
backendDownReported = false;
|
|
1456
|
+
missedProbes = 0;
|
|
1457
|
+
if (backendOnly) process.exitCode = 0;
|
|
1458
|
+
disarmReadyDeadline();
|
|
1459
|
+
resolvedBackendPort = parseInt(serverMatch[1], 10);
|
|
1460
|
+
backendUrl = "started";
|
|
1461
|
+
if (!backendOnly && frontendDir) {
|
|
1462
|
+
if (!frontendLaunched) {
|
|
1463
|
+
frontendLaunched = true;
|
|
1464
|
+
startFrontend(resolvedBackendPort);
|
|
1465
|
+
} else if (!frontendPortChecked && frontendApiPort !== resolvedBackendPort) restartFrontend(resolvedBackendPort);
|
|
1466
|
+
frontendPortChecked = true;
|
|
1467
|
+
}
|
|
1468
|
+
printSummary();
|
|
1469
|
+
const urlFile = path.join(projectRoot, ".rebase-dev-url");
|
|
1470
|
+
fs.writeFileSync(urlFile, `http://localhost:${resolvedBackendPort}`, "utf-8");
|
|
1471
|
+
const portFile = path.join(projectRoot, DEV_PORT_FILENAME);
|
|
1472
|
+
fs.writeFileSync(portFile, String(resolvedBackendPort), "utf-8");
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
/** Whether we've already shown a corrupted-modules recovery hint. */
|
|
1476
|
+
let corruptedModulesWarned = false;
|
|
1477
|
+
forEachOutputLine(backendChild.stderr, (line) => {
|
|
1478
|
+
console.log(`${chalk.cyan.bold("[backend]")} ${line}`);
|
|
1479
|
+
noticeWatcherRestart(line);
|
|
1480
|
+
noticePortMoved(line);
|
|
1481
|
+
if (!corruptedModulesWarned) {
|
|
1482
|
+
const cleanLine = stripAnsi(line);
|
|
1483
|
+
if (cleanLine.includes("Cannot find module") && cleanLine.includes("node_modules/.pnpm/")) {
|
|
1484
|
+
corruptedModulesWarned = true;
|
|
1485
|
+
setTimeout(() => {
|
|
1486
|
+
const installCmd = getPMCommands(detectPackageManager(projectRoot)).install.join(" ");
|
|
1487
|
+
console.error("");
|
|
1488
|
+
console.error(chalk.red(" ✗ node_modules appears corrupted — a required file is missing."));
|
|
1489
|
+
console.error(chalk.gray(" This usually happens when a previous install was interrupted"));
|
|
1490
|
+
console.error(chalk.gray(" or the package manager store was cleaned."));
|
|
1491
|
+
console.error("");
|
|
1492
|
+
console.error(chalk.gray(" To fix, stop the dev server and run:"));
|
|
1493
|
+
console.error(chalk.cyan(` rm -rf node_modules && ${installCmd}`));
|
|
1494
|
+
console.error("");
|
|
1495
|
+
}, 200);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
children.push(backendChild);
|
|
1500
|
+
} else if (!frontendOnly && !backendDir) console.warn(chalk.yellow(" ⚠ No backend/ directory found, skipping backend."));
|
|
1501
|
+
if (!backendOnly && frontendDir && (frontendOnly || !backendDir)) startFrontend(null);
|
|
1502
|
+
else if (!backendOnly && !frontendDir && declaresStaticApp) console.warn(chalk.yellow(" ⚠ No frontend/ directory found, skipping frontend."));
|
|
1503
|
+
if (children.length === 0) {
|
|
1504
|
+
console.error(chalk.red(" ✗ Nothing to start. Check your project structure."));
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
console.log("");
|
|
1508
|
+
console.log(chalk.gray(" Press Ctrl+C to stop all servers."));
|
|
1509
|
+
console.log("");
|
|
1510
|
+
const awaited = /* @__PURE__ */ new Set();
|
|
1511
|
+
for (let pending = [...children]; pending.length > 0; pending = children.filter((child) => !awaited.has(child))) {
|
|
1512
|
+
for (const child of pending) awaited.add(child);
|
|
1513
|
+
await Promise.all(pending.map((child) => child.then(() => void 0, () => void 0)));
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function printDevHelp() {
|
|
1517
|
+
console.log(`
|
|
1518
|
+
${chalk.bold("rebase dev")} — Start the development server
|
|
1519
|
+
|
|
1520
|
+
${chalk.green.bold("Usage")}
|
|
1521
|
+
rebase dev [options]
|
|
1522
|
+
|
|
1523
|
+
${chalk.green.bold("Options")}
|
|
1524
|
+
${chalk.blue("--backend-only, -b")} Only start the backend server
|
|
1525
|
+
${chalk.blue("--frontend-only, -f")} Only start the frontend server
|
|
1526
|
+
${chalk.blue("--port, -P")} Backend port (default: auto-detected per project)
|
|
1527
|
+
${chalk.blue("--database-url")} ${chalk.gray("<url>")} Use this Postgres, ahead of everything else
|
|
1528
|
+
${chalk.blue("--docker")} Use the project's docker-compose ${chalk.gray("db")} service
|
|
1529
|
+
${chalk.blue("--no-db")} Start no database at all
|
|
1530
|
+
|
|
1531
|
+
${chalk.green.bold("Which database")}
|
|
1532
|
+
Ordered, and the order is the promise. The first of these that says
|
|
1533
|
+
something wins:
|
|
1534
|
+
|
|
1535
|
+
1. ${chalk.blue("--database-url <url>")} on this command line
|
|
1536
|
+
2. ${chalk.gray("DATABASE_URL")} in the shell environment
|
|
1537
|
+
3. the branch this checkout is switched to ${chalk.gray("(rebase db branch switch)")}
|
|
1538
|
+
4. ${chalk.gray("DATABASE_URL")} in the project's .env
|
|
1539
|
+
5. ${chalk.blue("--docker")} on this command line
|
|
1540
|
+
6. the managed development database ${chalk.gray("(PGlite, data in .rebase/)")}
|
|
1541
|
+
|
|
1542
|
+
A scaffolded project sets none of 1–5, so it lands on (6): no Docker,
|
|
1543
|
+
nothing to install, and the collections' tables are created at boot.
|
|
1544
|
+
|
|
1545
|
+
${chalk.blue("--docker")} reaches the ${chalk.gray("db")} service in this project's docker-compose.yml,
|
|
1546
|
+
starting the container if nothing is listening on its port, and pushing
|
|
1547
|
+
the schema when this command is what started it. Its connection string
|
|
1548
|
+
is derived from the compose file and ${chalk.gray("DATABASE_PASSWORD")} in .env — the same
|
|
1549
|
+
string .env carries, commented out, as ${chalk.gray("DATABASE_URL")}.
|
|
1550
|
+
|
|
1551
|
+
A database that is already running is never touched: no schema push,
|
|
1552
|
+
no connection. A DATABASE_URL pointing anywhere other than this machine
|
|
1553
|
+
is left alone entirely. Pass ${chalk.blue("--no-db")}, or set REBASE_DEV_NO_DB=1, to
|
|
1554
|
+
start nothing — the backend then fails on the database it cannot reach,
|
|
1555
|
+
which is the point.
|
|
1556
|
+
|
|
1557
|
+
${chalk.green.bold("Which port")}
|
|
1558
|
+
Ordered like the database, and the order is the promise. The first of
|
|
1559
|
+
these that says something wins, and the start banner names which one did:
|
|
1560
|
+
|
|
1561
|
+
1. ${chalk.blue("--port <n>")} on this command line
|
|
1562
|
+
2. ${chalk.gray("PORT")} in the shell environment
|
|
1563
|
+
3. the port the last ${chalk.gray("rebase dev")} used ${chalk.gray("(.rebase-dev-port)")}
|
|
1564
|
+
4. a port derived from this project's path
|
|
1565
|
+
|
|
1566
|
+
${chalk.gray("PORT")} in the project's .env is ${chalk.bold("not")} read here: this command resolves
|
|
1567
|
+
its port before .env is loaded and then sets ${chalk.gray("PORT")} for the backend from
|
|
1568
|
+
what it resolved. Export it in your shell, or pass ${chalk.blue("--port")}.
|
|
1569
|
+
|
|
1570
|
+
${chalk.green.bold("Description")}
|
|
1571
|
+
Starts both the backend (tsx watch + Hono) and frontend (Vite)
|
|
1572
|
+
dev servers concurrently with color-coded output prefixes.
|
|
1573
|
+
|
|
1574
|
+
Each project automatically receives a unique default port derived
|
|
1575
|
+
from its directory path, preventing collisions when running multiple
|
|
1576
|
+
Rebase instances simultaneously.
|
|
1577
|
+
|
|
1578
|
+
If the assigned port is already in use, the server will automatically
|
|
1579
|
+
try the next available port. The frontend starts beside the backend
|
|
1580
|
+
when the backend's port is known in advance (named with --port, or
|
|
1581
|
+
free), and after the backend is ready otherwise. Either way
|
|
1582
|
+
VITE_API_URL is injected with the port the backend actually bound —
|
|
1583
|
+
the frontend is restarted if the backend had to move.
|
|
1584
|
+
|
|
1585
|
+
On startup and on every save under config/collections, the Drizzle
|
|
1586
|
+
schema (backend/src/schema.generated.ts) and the SDK types
|
|
1587
|
+
(generated/sdk) are regenerated from the collections.
|
|
1588
|
+
`);
|
|
1589
|
+
}
|
|
1590
|
+
//#endregion
|
|
1591
|
+
export { devCommand };
|
|
1592
|
+
|
|
1593
|
+
//# sourceMappingURL=dev-C9P5Yp9k.js.map
|