robodev 0.29.0 → 0.31.0

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.
@@ -0,0 +1,147 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { isBackendTestPath } from "@robodev-ai/runtime";
4
+
5
+ export type DevFile = { path: string; content: string };
6
+
7
+ export type DevProject = {
8
+ /** Where `.robodev/dev/` and the root `package.json` live. */
9
+ root: string;
10
+ /** Absolute directory holding `database.ts` plus `api/`, `jobs/`, `hooks/`, `sockets/`. */
11
+ backendDir: string;
12
+ /** Backend layout that was matched, for the startup banner. */
13
+ layout: "root" | "robodev" | "packages/be";
14
+ };
15
+
16
+ /** Directories under the backend root that hold compilable project code. */
17
+ export const BACKEND_DIRS = ["api", "jobs", "hooks", "sockets"] as const;
18
+
19
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
20
+ const MAX_BACKEND_FILES = 400;
21
+
22
+ async function isFile(path: string): Promise<boolean> {
23
+ try {
24
+ return (await stat(path)).isFile();
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Candidate backend roots for one directory, in the order `robodev deploy` prefers:
32
+ * `robodev/` first, then the project root, then Tiny's `packages/be`.
33
+ */
34
+ export function backendCandidates(dir: string): { dir: string; layout: DevProject["layout"] }[] {
35
+ return [
36
+ { dir: join(dir, "robodev"), layout: "robodev" },
37
+ { dir, layout: "root" },
38
+ { dir: join(dir, "packages", "be"), layout: "packages/be" },
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * Walks up from `cwd` looking for a created project: `database.ts` in `robodev/`, at the
44
+ * root, or in `packages/be` (Tiny). Walking up means `robodev dev` also works from
45
+ * `apps/fe`.
46
+ */
47
+ export async function findDevProject(cwd: string): Promise<DevProject> {
48
+ let dir = cwd;
49
+ for (;;) {
50
+ for (const candidate of backendCandidates(dir)) {
51
+ if (await isFile(join(candidate.dir, "database.ts"))) {
52
+ return { root: dir, backendDir: candidate.dir, layout: candidate.layout };
53
+ }
54
+ }
55
+ const parent = dirname(dir);
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ throw new Error(
60
+ "No database.ts found. Run robodev dev from a created project (database.ts, robodev/database.ts, or packages/be/database.ts).",
61
+ );
62
+ }
63
+
64
+ /**
65
+ * True for a path the runtime compiles: `database.ts` or a `.ts` file under a backend dir.
66
+ * Project tests are excluded, exactly as `robodev deploy` excludes them.
67
+ */
68
+ export function isBackendDevPath(path: string): boolean {
69
+ const normalized = path.replaceAll("\\", "/");
70
+ if (normalized.includes("..")) return false;
71
+ if (isBackendTestPath(normalized)) return false;
72
+ if (normalized === "database.ts") return true;
73
+ if (!normalized.endsWith(".ts")) return false;
74
+ const top = normalized.split("/")[0] ?? "";
75
+ return (BACKEND_DIRS as readonly string[]).includes(top);
76
+ }
77
+
78
+ /**
79
+ * Reads the backend tree off disk as deploy files. Paths are the same ones hosted deploy
80
+ * uses (`database.ts`, `api/planets.ts`), so route mapping is identical.
81
+ */
82
+ export async function collectBackendFiles(backendDir: string): Promise<DevFile[]> {
83
+ const files: DevFile[] = [];
84
+
85
+ async function walk(dir: string): Promise<void> {
86
+ let entries;
87
+ try {
88
+ entries = await readdir(dir, { withFileTypes: true });
89
+ } catch {
90
+ return;
91
+ }
92
+ for (const entry of entries) {
93
+ const abs = join(dir, entry.name);
94
+ if (entry.isDirectory()) {
95
+ if (SKIP_DIRS.has(entry.name)) continue;
96
+ await walk(abs);
97
+ continue;
98
+ }
99
+ if (!entry.isFile()) continue;
100
+ const path = relative(backendDir, abs).replaceAll("\\", "/");
101
+ if (!isBackendDevPath(path)) continue;
102
+ files.push({ path, content: await readFile(abs, "utf8") });
103
+ if (files.length > MAX_BACKEND_FILES) {
104
+ throw new Error(`Backend may include at most ${MAX_BACKEND_FILES} files`);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (await isFile(join(backendDir, "database.ts"))) {
110
+ files.push({
111
+ path: "database.ts",
112
+ content: await readFile(join(backendDir, "database.ts"), "utf8"),
113
+ });
114
+ }
115
+ for (const name of BACKEND_DIRS) {
116
+ await walk(join(backendDir, name));
117
+ }
118
+
119
+ if (!files.some((file) => file.path === "database.ts")) {
120
+ throw new Error(`database.ts not found in ${backendDir}`);
121
+ }
122
+ return files;
123
+ }
124
+
125
+ /**
126
+ * Where the local Postgres cluster and build output live. Legacy-layout projects keep
127
+ * `.robodev` as a link *file* at the root, so fall back beside it rather than failing.
128
+ */
129
+ export async function devDirFor(root: string): Promise<string> {
130
+ const dotRobodev = join(root, ".robodev");
131
+ try {
132
+ if (!(await stat(dotRobodev)).isDirectory()) {
133
+ return join(root, ".robodev-dev");
134
+ }
135
+ } catch {
136
+ /* absent: safe to create as a directory */
137
+ }
138
+ return join(dotRobodev, "dev");
139
+ }
140
+
141
+ export async function hasAppsFe(root: string): Promise<boolean> {
142
+ try {
143
+ return (await stat(join(root, "apps/fe/package.json"))).isFile();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
@@ -0,0 +1,201 @@
1
+ import { Readable } from "node:stream";
2
+ import cors from "@fastify/cors";
3
+ import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
4
+ import {
5
+ buildOpenApi,
6
+ handleLocalAuth,
7
+ isLocalAuthPath,
8
+ matchProjectRoute,
9
+ resolveLocalUser,
10
+ runRoute,
11
+ scalarHtml,
12
+ swaggerHtml,
13
+ type AuthQueryable,
14
+ type RuntimeRoute,
15
+ } from "@robodev-ai/runtime";
16
+ import type { RobodevDb } from "@robodev-ai/sdk";
17
+ import { devRouteClients } from "./dev-clients.js";
18
+
19
+ /** One compiled generation of the project. Swapped wholesale on reload. */
20
+ export type DevRuntime = {
21
+ db: RobodevDb;
22
+ pool: AuthQueryable & { end: () => Promise<void> };
23
+ routes: RuntimeRoute[];
24
+ openapi: Record<string, unknown>;
25
+ };
26
+
27
+ export type DevServerOptions = {
28
+ port: number;
29
+ projectId: string;
30
+ jwtSecret: string;
31
+ env: Record<string, string>;
32
+ log: (line: string) => void;
33
+ };
34
+
35
+ const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
36
+ const SCALAR_PATHS = new Set(["/_robodev/scalar", "/scalar"]);
37
+
38
+ export function buildDevOpenApi(projectName: string, routes: RuntimeRoute[]) {
39
+ return buildOpenApi({ title: `${projectName} APIs`, projectId: "local", routes });
40
+ }
41
+
42
+ function pathnameOf(url: string): string {
43
+ const raw = decodeURIComponent(url.split("?")[0] ?? "/").replace(/\\/g, "/");
44
+ return raw.replace(/\/$/, "") || "/";
45
+ }
46
+
47
+ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
48
+ return (request as FastifyRequest & { rawBody?: Buffer }).rawBody;
49
+ }
50
+
51
+ /**
52
+ * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup,
53
+ * no static SPA serving (local Vite does that), and no request logging — the routes,
54
+ * validation, and OpenAPI all come from `@robodev-ai/runtime`.
55
+ */
56
+ export async function startDevServer(
57
+ options: DevServerOptions,
58
+ getRuntime: () => DevRuntime | null,
59
+ ): Promise<{ app: FastifyInstance; close: () => Promise<void> }> {
60
+ const app = Fastify({ logger: false, bodyLimit: 25 * 1024 * 1024 });
61
+
62
+ await app.register(cors, {
63
+ origin: true,
64
+ credentials: true,
65
+ methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
66
+ });
67
+
68
+ app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
69
+ if (!body) {
70
+ done(null, {});
71
+ return;
72
+ }
73
+ try {
74
+ done(null, JSON.parse(body as string));
75
+ } catch (error) {
76
+ done(error as Error, undefined);
77
+ }
78
+ });
79
+ app.addContentTypeParser(
80
+ "application/x-www-form-urlencoded",
81
+ { parseAs: "string" },
82
+ (_request, body, done) => {
83
+ done(null, Object.fromEntries(new URLSearchParams(String(body ?? "")).entries()));
84
+ },
85
+ );
86
+ app.addContentTypeParser(
87
+ "application/octet-stream",
88
+ { parseAs: "buffer" },
89
+ (_request, body, done) => done(null, body),
90
+ );
91
+ app.addContentTypeParser("multipart/form-data", { parseAs: "buffer" }, (_request, body, done) =>
92
+ done(null, body),
93
+ );
94
+
95
+ // `rawBody` handlers and multipart parsing need the untouched bytes.
96
+ app.addHook("preParsing", async (_request, _reply, payload) => {
97
+ const chunks: Buffer[] = [];
98
+ for await (const chunk of payload) {
99
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
100
+ }
101
+ const raw = Buffer.concat(chunks);
102
+ (_request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
103
+ return Readable.from(raw);
104
+ });
105
+
106
+ // The catch-all lives in the not-found handler: `@fastify/cors` already owns `OPTIONS /*`,
107
+ // and every project route is resolved by the runtime rather than registered with Fastify.
108
+ app.setNotFoundHandler(async (request, reply) => {
109
+ const pathname = pathnameOf(request.url);
110
+ const method = request.method.toLowerCase();
111
+
112
+ if (pathname === "/_robodev/ready") {
113
+ const runtime = getRuntime();
114
+ if (!runtime) return reply.code(503).send({ error: "not_ready" });
115
+ try {
116
+ await runtime.pool.query("SELECT 1");
117
+ return reply.send({ ok: true });
118
+ } catch {
119
+ return reply.code(503).send({ error: "not_ready" });
120
+ }
121
+ }
122
+
123
+ if (SWAGGER_PATHS.has(pathname)) {
124
+ return reply.type("text/html; charset=utf-8").send(swaggerHtml("/openapi.json"));
125
+ }
126
+ if (SCALAR_PATHS.has(pathname)) {
127
+ return reply.type("text/html; charset=utf-8").send(scalarHtml("/openapi.json"));
128
+ }
129
+
130
+ const runtime = getRuntime();
131
+ if (!runtime) {
132
+ return reply.code(503).send({
133
+ error: "not_ready",
134
+ message: "The project is still compiling. Retry in a moment.",
135
+ });
136
+ }
137
+
138
+ if (pathname === "/openapi.json") {
139
+ return reply.send(runtime.openapi);
140
+ }
141
+
142
+ if (isLocalAuthPath(pathname)) {
143
+ const result = await handleLocalAuth(
144
+ {
145
+ method: request.method,
146
+ pathname,
147
+ body: request.body,
148
+ query: request.query,
149
+ authorization: request.headers.authorization,
150
+ },
151
+ {
152
+ db: runtime.pool,
153
+ projectId: options.projectId,
154
+ jwtSecret: options.jwtSecret,
155
+ deliverCode: ({ to, type, code }) => options.log(`${type} code for ${to}: ${code}`),
156
+ },
157
+ );
158
+ return reply.code(result.status).send(result.body);
159
+ }
160
+
161
+ if (!matchProjectRoute(pathname, method, runtime.routes)) {
162
+ return reply.code(404).send({
163
+ error: "not_found",
164
+ message: `${request.method} ${pathname} is not defined`,
165
+ routes: runtime.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
166
+ });
167
+ }
168
+
169
+ const outcome = await runRoute({
170
+ db: runtime.db,
171
+ routes: runtime.routes,
172
+ method,
173
+ pathname,
174
+ query: request.query ?? {},
175
+ body: request.body,
176
+ rawBody: rawBodyOf(request),
177
+ headers: request.headers as Record<string, string | string[] | undefined>,
178
+ authorization: request.headers.authorization,
179
+ resolveUser: (authorization) =>
180
+ resolveLocalUser(
181
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
182
+ authorization,
183
+ ),
184
+ clients: () => devRouteClients({ env: options.env, log: options.log }),
185
+ });
186
+
187
+ if (outcome.kind === "error") {
188
+ return reply.code(outcome.status).send(outcome.body);
189
+ }
190
+ for (const [name, value] of Object.entries(outcome.encoded.headers)) {
191
+ reply.header(name, value);
192
+ }
193
+ return reply
194
+ .code(outcome.encoded.status)
195
+ .type(outcome.encoded.contentType)
196
+ .send(outcome.encoded.payload);
197
+ });
198
+
199
+ await app.listen({ port: options.port, host: "0.0.0.0" });
200
+ return { app, close: () => app.close() };
201
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,348 @@
1
+ import { watch, type FSWatcher } from "node:fs";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { join, relative } from "node:path";
5
+ import { spawn, type ChildProcess } from "node:child_process";
6
+ import {
7
+ apiBundleExternals,
8
+ applyPlan,
9
+ buildPlan,
10
+ compileApiTree,
11
+ ensureAuthSchema,
12
+ isEsbuildFailure,
13
+ loadModules,
14
+ mapEsbuildErrors,
15
+ writeDeploySources,
16
+ type SchemaPlan,
17
+ } from "@robodev-ai/runtime";
18
+ import { createDb as fallbackCreateDb, type DatabaseDef } from "@robodev-ai/sdk";
19
+ import { commandAvailable } from "./frontend-build.js";
20
+ import {
21
+ collectBackendFiles,
22
+ devDirFor,
23
+ findDevProject,
24
+ hasAppsFe,
25
+ isBackendDevPath,
26
+ type DevProject,
27
+ } from "./dev-project.js";
28
+ import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
29
+ import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
30
+
31
+ export type DevArgs = {
32
+ port: number;
33
+ dbPort: number;
34
+ force: boolean;
35
+ watch: boolean;
36
+ withFe: boolean;
37
+ databaseUrl?: string;
38
+ };
39
+
40
+ const DEFAULT_PORT = 4000;
41
+ const RELOAD_DEBOUNCE_MS = 150;
42
+ /** Local-only signing key. Tokens never leave the machine. */
43
+ const DEV_JWT_SECRET = "robodev-dev-local-secret";
44
+ const DEV_PROJECT_ID = "prj-local";
45
+
46
+ function numberFlag(args: string[], name: string, fallback: number): number {
47
+ const index = args.indexOf(name);
48
+ if (index === -1) return fallback;
49
+ const raw = args[index + 1];
50
+ const value = Number(raw);
51
+ if (raw === undefined || !Number.isInteger(value) || value < 0) {
52
+ throw new Error(`${name} needs a non-negative integer`);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function stringFlag(args: string[], name: string): string | undefined {
58
+ const index = args.indexOf(name);
59
+ if (index === -1) return undefined;
60
+ const value = args[index + 1];
61
+ if (!value || value.startsWith("--")) {
62
+ throw new Error(`${name} needs a value`);
63
+ }
64
+ return value;
65
+ }
66
+
67
+ export function parseDevArgs(args: string[]): DevArgs {
68
+ return {
69
+ port: numberFlag(args, "--port", DEFAULT_PORT),
70
+ dbPort: numberFlag(args, "--db-port", 0),
71
+ force: args.includes("--force"),
72
+ watch: !args.includes("--no-watch"),
73
+ withFe: args.includes("--with-fe"),
74
+ databaseUrl: stringFlag(args, "--database-url"),
75
+ };
76
+ }
77
+
78
+ /** Minimal `.env` reader for `ctx.env`. Hosted projects get this from project secrets. */
79
+ export function parseEnvFile(content: string): Record<string, string> {
80
+ const env: Record<string, string> = {};
81
+ for (const line of content.split("\n")) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed || trimmed.startsWith("#")) continue;
84
+ const eq = trimmed.indexOf("=");
85
+ if (eq <= 0) continue;
86
+ const key = trimmed.slice(0, eq).trim();
87
+ let value = trimmed.slice(eq + 1).trim();
88
+ if (
89
+ (value.startsWith('"') && value.endsWith('"')) ||
90
+ (value.startsWith("'") && value.endsWith("'"))
91
+ ) {
92
+ value = value.slice(1, -1);
93
+ }
94
+ env[key] = value;
95
+ }
96
+ return env;
97
+ }
98
+
99
+ async function loadProjectEnv(root: string): Promise<Record<string, string>> {
100
+ try {
101
+ return parseEnvFile(await readFile(join(root, ".env"), "utf8"));
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Uses the project's own `@robodev-ai/sdk` when it has one, so `ctx.db` and the Drizzle
109
+ * tables from `database.ts` come from the same drizzle-orm copy — the way hosted deploy
110
+ * compiles them.
111
+ */
112
+ async function resolveCreateDb(backendDir: string, root: string): Promise<typeof fallbackCreateDb> {
113
+ for (const base of [backendDir, root]) {
114
+ try {
115
+ const require = createRequire(join(base, "package.json"));
116
+ const entry = require.resolve("@robodev-ai/sdk");
117
+ const mod = (await import(entry)) as { createDb?: typeof fallbackCreateDb };
118
+ if (mod.createDb) return mod.createDb;
119
+ } catch {
120
+ /* fall through to the CLI's own copy */
121
+ }
122
+ }
123
+ return fallbackCreateDb;
124
+ }
125
+
126
+ function printPlan(plan: SchemaPlan): void {
127
+ for (const op of plan.operations) {
128
+ const mark = op.destructive ? "!" : " ";
129
+ console.log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
130
+ }
131
+ }
132
+
133
+ export type DevCompileResult = {
134
+ database: DatabaseDef;
135
+ runtime: DevRuntime;
136
+ };
137
+
138
+ export async function main(args: string[]): Promise<void> {
139
+ const parsed = parseDevArgs(args);
140
+ const project = await findDevProject(process.cwd());
141
+ const devDir = await devDirFor(project.root);
142
+ const buildDir = join(devDir, "build");
143
+ await mkdir(buildDir, { recursive: true });
144
+
145
+ const createDb = await resolveCreateDb(project.backendDir, project.root);
146
+ const env = await loadProjectEnv(project.root);
147
+
148
+ const postgres: DevPostgres = parsed.databaseUrl
149
+ ? externalDevPostgres(parsed.databaseUrl)
150
+ : await startDevPostgres({ dataDir: join(devDir, "pg"), port: parsed.dbPort, quiet: true });
151
+
152
+ let current: DevRuntime | null = null;
153
+ let lastSchemaSource = "";
154
+ let closing = false;
155
+
156
+ const log = (line: string) => console.log(line);
157
+
158
+ async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
159
+ const files = await collectBackendFiles(project.backendDir);
160
+ await writeDeploySources(join(buildDir, "src"), files);
161
+
162
+ let distDir: string;
163
+ try {
164
+ distDir = await compileApiTree({
165
+ dir: buildDir,
166
+ files,
167
+ external: apiBundleExternals(),
168
+ nodePaths: [join(project.backendDir, "node_modules"), join(project.root, "node_modules")],
169
+ });
170
+ } catch (error) {
171
+ if (isEsbuildFailure(error)) {
172
+ for (const issue of mapEsbuildErrors(error.errors, join(buildDir, "src"))) {
173
+ const where = issue.path ? `${issue.path}${issue.line ? `:${issue.line}` : ""}: ` : "";
174
+ console.error(` ${where}${issue.text}`);
175
+ }
176
+ throw new Error("Compile failed");
177
+ }
178
+ throw error;
179
+ }
180
+
181
+ const loaded = await loadModules(distDir, files);
182
+ // Any edit to database.ts can change columns, not just tables, so compare its source.
183
+ const schemaSource = files.find((file) => file.path === "database.ts")?.content ?? "";
184
+
185
+ if (input.syncSchema || schemaSource !== lastSchemaSource) {
186
+ const plan = await buildPlan(postgres.connectionString, loaded.database);
187
+ if (plan.destructive && !parsed.force) {
188
+ console.log("Destructive schema changes (skipped, rerun with --force):");
189
+ printPlan(plan);
190
+ } else {
191
+ if (plan.operations.length > 0) {
192
+ console.log("Schema changes:");
193
+ printPlan(plan);
194
+ }
195
+ await applyPlan(postgres.connectionString, plan);
196
+ lastSchemaSource = schemaSource;
197
+ }
198
+ }
199
+
200
+ const { db, pool } = createDb(postgres.connectionString, loaded.database.tables);
201
+ await pool.query("SELECT 1");
202
+ await ensureAuthSchema(pool);
203
+
204
+ const previous = current;
205
+ current = {
206
+ db,
207
+ pool,
208
+ routes: loaded.routes,
209
+ openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
210
+ };
211
+ if (previous) {
212
+ await previous.pool.end().catch(() => undefined);
213
+ }
214
+
215
+ for (const route of loaded.routes) {
216
+ console.log(` ${route.method.toUpperCase()} ${route.path}`);
217
+ }
218
+ if (loaded.jobs.length > 0) {
219
+ console.log(
220
+ ` ${loaded.jobs.length} job(s) compiled but not run — jobs need hosted Starbase.`,
221
+ );
222
+ }
223
+ if (loaded.sockets.length > 0) {
224
+ console.log(
225
+ ` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
226
+ );
227
+ }
228
+ }
229
+
230
+ async function releaseDatabase(): Promise<void> {
231
+ await current?.pool.end().catch(() => undefined);
232
+ await postgres.stop().catch(() => undefined);
233
+ }
234
+
235
+ let server: Awaited<ReturnType<typeof startDevServer>>;
236
+ try {
237
+ await compileAndActivate({ syncSchema: true });
238
+ server = await startDevServer(
239
+ { port: parsed.port, projectId: DEV_PROJECT_ID, jwtSecret: DEV_JWT_SECRET, env, log },
240
+ () => current,
241
+ );
242
+ } catch (error) {
243
+ // Nothing is watching yet, so shut the cluster down instead of orphaning it.
244
+ await releaseDatabase();
245
+ throw error;
246
+ }
247
+
248
+ const apiUrl = `http://localhost:${parsed.port}`;
249
+ console.log("");
250
+ if (postgres.dataDir) {
251
+ console.log(
252
+ `Postgres localhost:${postgres.port} (data: ${relative(project.root, postgres.dataDir) || postgres.dataDir})`,
253
+ );
254
+ } else {
255
+ console.log(
256
+ `Postgres ${postgres.host}:${postgres.port}/${postgres.database} (--database-url)`,
257
+ );
258
+ }
259
+ console.log(`API ${apiUrl}`);
260
+ console.log(`Swagger ${apiUrl}/_robodev/docs`);
261
+ console.log(
262
+ `Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
263
+ );
264
+ console.log("");
265
+
266
+ const fe = parsed.withFe ? await startFrontend(project, apiUrl) : null;
267
+ const watchers = parsed.watch ? startWatch() : [];
268
+
269
+ function startWatch(): FSWatcher[] {
270
+ let timer: NodeJS.Timeout | undefined;
271
+ let running = false;
272
+ let pending = false;
273
+
274
+ async function reload(): Promise<void> {
275
+ if (running) {
276
+ pending = true;
277
+ return;
278
+ }
279
+ running = true;
280
+ try {
281
+ console.log("Change detected, reloading…");
282
+ await compileAndActivate({ syncSchema: false });
283
+ } catch (error) {
284
+ console.error(error instanceof Error ? error.message : error);
285
+ console.error("Keeping the previous build. Fix the error and save again.");
286
+ } finally {
287
+ running = false;
288
+ if (pending) {
289
+ pending = false;
290
+ void reload();
291
+ }
292
+ }
293
+ }
294
+
295
+ try {
296
+ return [
297
+ watch(project.backendDir, { recursive: true }, (_event, filename) => {
298
+ // Ignore editor scratch files and anything the runtime would not compile.
299
+ if (filename && !isBackendDevPath(String(filename))) return;
300
+ if (timer) clearTimeout(timer);
301
+ timer = setTimeout(() => void reload(), RELOAD_DEBOUNCE_MS);
302
+ }),
303
+ ];
304
+ } catch (error) {
305
+ console.error(
306
+ `Could not watch ${project.backendDir} (${error instanceof Error ? error.message : error}). Restart after edits, or pass --no-watch.`,
307
+ );
308
+ return [];
309
+ }
310
+ }
311
+
312
+ async function shutdown(code: number): Promise<void> {
313
+ if (closing) return;
314
+ closing = true;
315
+ for (const watcher of watchers) watcher.close();
316
+ fe?.kill("SIGTERM");
317
+ await server.close().catch(() => undefined);
318
+ await releaseDatabase();
319
+ process.exit(code);
320
+ }
321
+
322
+ process.on("SIGINT", () => void shutdown(0));
323
+ process.on("SIGTERM", () => void shutdown(0));
324
+ }
325
+
326
+ /** Runs the starter's `apps/fe` dev server with the local API pre-wired. */
327
+ async function startFrontend(project: DevProject, apiUrl: string): Promise<ChildProcess | null> {
328
+ if (!(await hasAppsFe(project.root))) {
329
+ console.log("--with-fe: no apps/fe/package.json, serving APIs only.");
330
+ return null;
331
+ }
332
+ const command = (await commandAvailable("bun")) ? "bun" : "npm";
333
+ const args = command === "bun" ? ["run", "dev"] : ["run", "dev"];
334
+ const child = spawn(command, args, {
335
+ cwd: join(project.root, "apps", "fe"),
336
+ stdio: "inherit",
337
+ env: {
338
+ ...process.env,
339
+ VITE_API_URL: apiUrl,
340
+ VITE_PUBLIC_API_URL: apiUrl,
341
+ APP_PUBLIC_API_URL: apiUrl,
342
+ },
343
+ });
344
+ child.on("error", (error) => {
345
+ console.error(`Could not start apps/fe with ${command}: ${error.message}`);
346
+ });
347
+ return child;
348
+ }