@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.
Files changed (118) hide show
  1. package/bin/rebase.js +3 -0
  2. package/dist/agent-setup-B31F00DC.js +324 -0
  3. package/dist/agent-setup-B31F00DC.js.map +1 -0
  4. package/dist/api-keys-BPBi5nOF.js +524 -0
  5. package/dist/api-keys-BPBi5nOF.js.map +1 -0
  6. package/dist/apps-CPXNElX4.js +256 -0
  7. package/dist/apps-CPXNElX4.js.map +1 -0
  8. package/dist/args-BnmsEXvu.js +109 -0
  9. package/dist/args-BnmsEXvu.js.map +1 -0
  10. package/dist/atlas-dev-database-DTMuEScS.js +62 -0
  11. package/dist/atlas-dev-database-DTMuEScS.js.map +1 -0
  12. package/dist/auth-DvY94ZuL.js +395 -0
  13. package/dist/auth-DvY94ZuL.js.map +1 -0
  14. package/dist/branch-pointer-DIhKbQLp.js +2 -0
  15. package/dist/build-CFwUx-Ff.js +336 -0
  16. package/dist/build-CFwUx-Ff.js.map +1 -0
  17. package/dist/bundle-BQhP_xiA.js +1617 -0
  18. package/dist/bundle-BQhP_xiA.js.map +1 -0
  19. package/dist/cli.d.ts +2 -2
  20. package/dist/cloud-CkTr-yt0.js +9640 -0
  21. package/dist/cloud-CkTr-yt0.js.map +1 -0
  22. package/dist/collection-drift-BKj_BoMq.js +106 -0
  23. package/dist/collection-drift-BKj_BoMq.js.map +1 -0
  24. package/dist/commands/api-keys.d.ts +2 -0
  25. package/dist/commands/db.d.ts +37 -24
  26. package/dist/commands/dev.d.ts +103 -0
  27. package/dist/commands/generate_sdk.d.ts +26 -0
  28. package/dist/commands/skills.d.ts +22 -7
  29. package/dist/commands/storage.d.ts +46 -0
  30. package/dist/consent-BWHaYx5p.js +76 -0
  31. package/dist/consent-BWHaYx5p.js.map +1 -0
  32. package/dist/context-BqhRydBg.js +1234 -0
  33. package/dist/context-BqhRydBg.js.map +1 -0
  34. package/dist/daemon-DBn-shxX.js +2 -0
  35. package/dist/{daemon-entry-LTFKtpHy.js → daemon-entry-Da-D3rmH.js} +3 -4
  36. package/dist/daemon-entry-Da-D3rmH.js.map +1 -0
  37. package/dist/db-CMpBK3sv.js +1252 -0
  38. package/dist/db-CMpBK3sv.js.map +1 -0
  39. package/dist/derive-RPIsAVO4.js +474 -0
  40. package/dist/derive-RPIsAVO4.js.map +1 -0
  41. package/dist/dev-C9P5Yp9k.js +1593 -0
  42. package/dist/dev-C9P5Yp9k.js.map +1 -0
  43. package/dist/dev-db/atlas-dev-database.d.ts +32 -0
  44. package/dist/dev-db/daemon-entry.d.ts +13 -0
  45. package/dist/dev-preflight-N1evJL3S.js +2 -0
  46. package/dist/doctor-DYs-NqFI.js +771 -0
  47. package/dist/doctor-DYs-NqFI.js.map +1 -0
  48. package/dist/eject-BT6t37D0.js +335 -0
  49. package/dist/eject-BT6t37D0.js.map +1 -0
  50. package/dist/fold-static-C1EtHlfV.js +201 -0
  51. package/dist/fold-static-C1EtHlfV.js.map +1 -0
  52. package/dist/function-portability-BIvlvR9q.js +265 -0
  53. package/dist/function-portability-BIvlvR9q.js.map +1 -0
  54. package/dist/generate_sdk-UV635zSe.js +477 -0
  55. package/dist/generate_sdk-UV635zSe.js.map +1 -0
  56. package/dist/index.es.js +141 -24674
  57. package/dist/index.es.js.map +1 -1
  58. package/dist/init-fXeBcxuN.js +1042 -0
  59. package/dist/init-fXeBcxuN.js.map +1 -0
  60. package/dist/manifest-DzlC60WX.js +871 -0
  61. package/dist/manifest-DzlC60WX.js.map +1 -0
  62. package/dist/normalize-imports-CmVdE9iZ.js +100 -0
  63. package/dist/normalize-imports-CmVdE9iZ.js.map +1 -0
  64. package/dist/{package-manager-CXQevsE1.js → package-manager-DSEOwTqM.js} +2 -2
  65. package/dist/{package-manager-CXQevsE1.js.map → package-manager-DSEOwTqM.js.map} +1 -1
  66. package/dist/path-flags-DUBuI2i1.js +77 -0
  67. package/dist/path-flags-DUBuI2i1.js.map +1 -0
  68. package/dist/precompress.d.ts +17 -0
  69. package/dist/prepare-Btn9Ewbn.js +1052 -0
  70. package/dist/prepare-Btn9Ewbn.js.map +1 -0
  71. package/dist/{project-DmaGLmH2.js → project-Wp2zoyeN.js} +33 -4
  72. package/dist/project-Wp2zoyeN.js.map +1 -0
  73. package/dist/resolve-CJLkfFV0.js +2 -0
  74. package/dist/resources-DT1COn_6.js +161 -0
  75. package/dist/resources-DT1COn_6.js.map +1 -0
  76. package/dist/schema-BDBWvCHu.js +125 -0
  77. package/dist/schema-BDBWvCHu.js.map +1 -0
  78. package/dist/skills-DTFibT1m.js +642 -0
  79. package/dist/skills-DTFibT1m.js.map +1 -0
  80. package/dist/start-rU2e3Kp5.js +228 -0
  81. package/dist/start-rU2e3Kp5.js.map +1 -0
  82. package/dist/{state-C59Elrnt.js → state-Do7ZJYkF.js} +49 -4
  83. package/dist/state-Do7ZJYkF.js.map +1 -0
  84. package/dist/status-BR9oyi-y.js +351 -0
  85. package/dist/status-BR9oyi-y.js.map +1 -0
  86. package/dist/storage-DyvrSTH3.js +303 -0
  87. package/dist/storage-DyvrSTH3.js.map +1 -0
  88. package/dist/telemetry-CAWNCugs.js +338 -0
  89. package/dist/telemetry-CAWNCugs.js.map +1 -0
  90. package/dist/telemetry-DbLqGIBW.js +129 -0
  91. package/dist/telemetry-DbLqGIBW.js.map +1 -0
  92. package/dist/unknown-command-DNwDcX3w.js +98 -0
  93. package/dist/unknown-command-DNwDcX3w.js.map +1 -0
  94. package/dist/upgrade-D-5-Od96.js +1265 -0
  95. package/dist/upgrade-D-5-Od96.js.map +1 -0
  96. package/dist/utils/project.d.ts +16 -0
  97. package/dist/{version-DsgBmP22.js → version-Buyyo1ut.js} +2 -2
  98. package/dist/{version-DsgBmP22.js.map → version-Buyyo1ut.js.map} +1 -1
  99. package/package.json +7 -7
  100. package/templates/eject/backend/src/env.ts +1 -1
  101. package/templates/eject/backend/src/index.ts +10 -2
  102. package/templates/template/README.md +1 -1
  103. package/templates/template/ai-instructions.md +2 -1
  104. package/templates/template/frontend/vite.config.ts +11 -3
  105. package/templates/template/scripts/example.ts +2 -1
  106. package/dist/branch-pointer-kk9F5FBO.js +0 -111
  107. package/dist/branch-pointer-kk9F5FBO.js.map +0 -1
  108. package/dist/constraints-DKGbNfUW.js +0 -48
  109. package/dist/constraints-DKGbNfUW.js.map +0 -1
  110. package/dist/daemon-C8p32i-n.js +0 -357
  111. package/dist/daemon-C8p32i-n.js.map +0 -1
  112. package/dist/daemon-entry-LTFKtpHy.js.map +0 -1
  113. package/dist/dev-preflight-CNLl4rdo.js +0 -367
  114. package/dist/dev-preflight-CNLl4rdo.js.map +0 -1
  115. package/dist/project-DmaGLmH2.js.map +0 -1
  116. package/dist/resolve-BAeRUBhg.js +0 -74
  117. package/dist/resolve-BAeRUBhg.js.map +0 -1
  118. 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