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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.29.0",
4
- "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
3
+ "version": "0.31.0",
4
+ "description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -20,17 +20,21 @@
20
20
  "publishConfig": {
21
21
  "access": "public"
22
22
  },
23
- "scripts": {
24
- "robodev": "tsx src/index.ts",
25
- "generate:templates": "tsx src/generate-templates.ts",
26
- "prepack": "tsx src/generate-templates.ts",
27
- "test": "tsx --test src/**/*.test.ts"
28
- },
29
23
  "dependencies": {
30
- "tsx": "^4.20.5"
24
+ "@fastify/cors": "^11.3.0",
25
+ "embedded-postgres": "17.10.0-beta.17",
26
+ "fastify": "^5.12.1",
27
+ "tsx": "^4.20.5",
28
+ "@robodev-ai/runtime": "0.1.0",
29
+ "@robodev-ai/sdk": "0.10.0"
31
30
  },
32
31
  "devDependencies": {
33
32
  "@types/node": "^24.3.0",
34
33
  "typescript": "^5.9.2"
34
+ },
35
+ "scripts": {
36
+ "robodev": "tsx src/index.ts",
37
+ "generate:templates": "tsx src/generate-templates.ts",
38
+ "test": "tsx --test src/**/*.test.ts"
35
39
  }
36
- }
40
+ }
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { test } from "node:test";
7
- import { collectFiles, mergeCollectedFiles } from "./collect-files.js";
7
+ import { collectFiles, mergeCollectedFiles, shouldCollectPath } from "./collect-files.js";
8
8
 
9
9
  test("collectFiles includes hosted sources and skips denied paths", async () => {
10
10
  const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
@@ -57,6 +57,22 @@ test("collectFiles includes hosted sources and skips denied paths", async () =>
57
57
  }
58
58
  });
59
59
 
60
+ test("collectFiles includes public SPA binaries as base64", async () => {
61
+ const root = await mkdtemp(join(tmpdir(), "rd-cli-bin-"));
62
+ try {
63
+ await writeFile(join(root, "database.ts"), "export default {}");
64
+ await mkdir(join(root, "public/assets"), { recursive: true });
65
+ await writeFile(join(root, "public/assets/font.woff2"), Buffer.from("woff2-bytes"));
66
+ await writeFile(join(root, "public/icon.png"), Buffer.from("png-bytes"));
67
+ const files = await collectFiles(root);
68
+ const byPath = Object.fromEntries(files.map((file) => [file.path, file.content]));
69
+ assert.equal(byPath["public/assets/font.woff2"], Buffer.from("woff2-bytes").toString("base64"));
70
+ assert.equal(byPath["public/icon.png"], Buffer.from("png-bytes").toString("base64"));
71
+ } finally {
72
+ await rm(root, { recursive: true, force: true });
73
+ }
74
+ });
75
+
60
76
  test("collectFiles still accepts a legacy root database.ts tree", async () => {
61
77
  const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
62
78
  try {
@@ -146,3 +162,12 @@ test("collectFiles rejects mixed backend layouts", async () => {
146
162
  await rm(root, { recursive: true, force: true });
147
163
  }
148
164
  });
165
+
166
+ test("backend tests are not collected for deploy", () => {
167
+ assert.equal(shouldCollectPath("robodev/api/_lib.seed.test.ts"), false);
168
+ assert.equal(shouldCollectPath("robodev/jobs/resize.spec.ts"), false);
169
+ assert.equal(shouldCollectPath("robodev/sockets/chat.test.ts"), false);
170
+ assert.equal(shouldCollectPath("api/health.test.ts"), false);
171
+ assert.equal(shouldCollectPath("robodev/api/_lib.ts"), true);
172
+ assert.equal(shouldCollectPath("robodev/api/latest.ts"), true);
173
+ });
@@ -1,11 +1,11 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
3
 
4
- export const MAX_FILE_COUNT = 200;
4
+ export const MAX_FILE_COUNT = 400;
5
5
  export const MAX_FILE_BYTES = 1024 * 1024;
6
- /** Hashed Vite JS from Tiny can exceed 1MB; keep API/source at 1MB. */
7
- export const MAX_STATIC_FILE_BYTES = 4 * 1024 * 1024;
8
- export const MAX_TREE_BYTES = 15 * 1024 * 1024;
6
+ /** Hashed Vite JS and SPA fonts from Tiny can exceed 1MB; keep API/source at 1MB. */
7
+ export const MAX_STATIC_FILE_BYTES = 8 * 1024 * 1024;
8
+ export const MAX_TREE_BYTES = 25 * 1024 * 1024;
9
9
  export const MAX_PATH_LENGTH = 240;
10
10
  export const MAX_PATH_DEPTH = 12;
11
11
 
@@ -33,6 +33,18 @@ const BINARY_EXTENSIONS = new Set([
33
33
  ".webm",
34
34
  ".mp3",
35
35
  ]);
36
+ const PUBLIC_BINARY_EXTENSIONS = new Set([
37
+ ".woff",
38
+ ".woff2",
39
+ ".ttf",
40
+ ".otf",
41
+ ".png",
42
+ ".jpg",
43
+ ".jpeg",
44
+ ".gif",
45
+ ".webp",
46
+ ".ico",
47
+ ]);
36
48
  const SRC_EXTENSIONS = new Set([
37
49
  ".ts",
38
50
  ".tsx",
@@ -102,10 +114,20 @@ function isDenied(path: string): boolean {
102
114
  if (isRobodevLockfile(path)) return true;
103
115
  const name = basename(path);
104
116
  if (name.startsWith(".") && name !== ".gitignore") return true;
105
- if (BINARY_EXTENSIONS.has(extname(path))) return true;
117
+ if (BINARY_EXTENSIONS.has(extname(path)) && !isPublicBinaryCollectedPath(path)) return true;
106
118
  return false;
107
119
  }
108
120
 
121
+ export function isPublicBinaryCollectedPath(path: string): boolean {
122
+ const normalized = normalize(path);
123
+ return normalized.startsWith("public/") && PUBLIC_BINARY_EXTENSIONS.has(extname(normalized));
124
+ }
125
+
126
+ /** Project tests live beside the handlers they cover; they are not part of the deployed tree. */
127
+ function isBackendTestPath(path: string): boolean {
128
+ return /\.(test|spec)\.ts$/.test(path);
129
+ }
130
+
109
131
  function isBackendPath(path: string): boolean {
110
132
  const inner = path.startsWith("robodev/") ? path.slice("robodev/".length) : path;
111
133
  if (inner === "database.ts" && (path === "database.ts" || path === "robodev/database.ts")) {
@@ -117,7 +139,7 @@ function isBackendPath(path: string): boolean {
117
139
  inner.startsWith("hooks/") ||
118
140
  inner.startsWith("sockets/")
119
141
  ) {
120
- return inner.endsWith(".ts");
142
+ return inner.endsWith(".ts") && !isBackendTestPath(inner);
121
143
  }
122
144
  return path === "robodev/package.json";
123
145
  }
@@ -127,7 +149,9 @@ function isAllowed(path: string): boolean {
127
149
  if (path.startsWith("robodev/")) return false;
128
150
  if (path === "index.html") return true;
129
151
  if (path.startsWith("src/")) return SRC_EXTENSIONS.has(extname(path));
130
- if (path.startsWith("public/")) return PUBLIC_EXTENSIONS.has(extname(path));
152
+ if (path.startsWith("public/")) {
153
+ return PUBLIC_EXTENSIONS.has(extname(path)) || PUBLIC_BINARY_EXTENSIONS.has(extname(path));
154
+ }
131
155
  if (CONFIG_FILES.has(path) || CONFIG_FILES.has(basename(path))) return true;
132
156
  if (/^tsconfig\..+\.json$/.test(basename(path)) && !path.includes("/")) return true;
133
157
  if (!path.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(path))) return true;
@@ -177,6 +201,20 @@ function formatMaxBytes(bytes: number): string {
177
201
  return `${Math.round(bytes / (1024 * 1024))}MB`;
178
202
  }
179
203
 
204
+ export function collectedFileStoredBytes(file: CollectedFile): number {
205
+ if (isPublicBinaryCollectedPath(file.path)) {
206
+ return Buffer.from(file.content, "base64").byteLength;
207
+ }
208
+ return Buffer.byteLength(file.content, "utf8");
209
+ }
210
+
211
+ export async function readCollectedContent(abs: string, deployPath: string): Promise<string> {
212
+ if (isPublicBinaryCollectedPath(deployPath)) {
213
+ return (await readFile(abs)).toString("base64");
214
+ }
215
+ return readFile(abs, "utf8");
216
+ }
217
+
180
218
  export function assertCollectedFile(file: CollectedFile): void {
181
219
  const path = normalize(file.path);
182
220
  if (
@@ -187,7 +225,7 @@ export function assertCollectedFile(file: CollectedFile): void {
187
225
  ) {
188
226
  throw new Error(`Invalid file path: ${path}`);
189
227
  }
190
- const bytes = Buffer.byteLength(file.content, "utf8");
228
+ const bytes = collectedFileStoredBytes(file);
191
229
  const max = maxBytesForCollectedPath(path);
192
230
  if (bytes > max) {
193
231
  throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
@@ -203,7 +241,7 @@ export function assertCollectedLimits(files: CollectedFile[]): void {
203
241
  assertCollectedFile(file);
204
242
  totalBytes += Buffer.byteLength(file.content, "utf8");
205
243
  if (totalBytes > MAX_TREE_BYTES) {
206
- throw new Error("Deploy tree must be under 15MB");
244
+ throw new Error(`Deploy tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
207
245
  }
208
246
  }
209
247
  }
@@ -251,15 +289,15 @@ export async function collectFiles(root: string): Promise<CollectedFile[]> {
251
289
  if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
252
290
  throw new Error(`Invalid file path: ${path}`);
253
291
  }
254
- const content = await readFile(abs, "utf8");
255
- const bytes = Buffer.byteLength(content, "utf8");
292
+ const content = await readCollectedContent(abs, path);
293
+ const bytes = collectedFileStoredBytes({ path, content });
256
294
  const max = maxBytesForCollectedPath(path);
257
295
  if (bytes > max) {
258
296
  throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
259
297
  }
260
- totalBytes += bytes;
298
+ totalBytes += Buffer.byteLength(content, "utf8");
261
299
  if (totalBytes > MAX_TREE_BYTES) {
262
- throw new Error("Deploy tree must be under 15MB");
300
+ throw new Error(`Deploy tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
263
301
  }
264
302
  files.push({ path, content });
265
303
  if (files.length > MAX_FILE_COUNT) {
@@ -0,0 +1,65 @@
1
+ import type { RouteClients } from "@robodev-ai/runtime";
2
+ import type { EmailClient, LlmClient } from "@robodev-ai/sdk";
3
+
4
+ /**
5
+ * The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
6
+ * everything that needs Starbase infrastructure fails with a message that says so rather
7
+ * than silently doing nothing.
8
+ */
9
+
10
+ function unavailable(feature: string): never {
11
+ throw Object.assign(
12
+ new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`),
13
+ { statusCode: 501 },
14
+ );
15
+ }
16
+
17
+ /** Prints mail to stdout so local signup and reset flows are followable. */
18
+ export function consoleEmailClient(log: (line: string) => void): EmailClient {
19
+ return {
20
+ async send(input) {
21
+ const to = Array.isArray(input.to) ? input.to.join(", ") : input.to;
22
+ log(`email → ${to}: ${input.subject}`);
23
+ if (input.text) log(` ${input.text}`);
24
+ return { id: `local_${Date.now().toString(36)}`, status: "sent" };
25
+ },
26
+ };
27
+ }
28
+
29
+ function offlineLlmClient(): LlmClient {
30
+ return {
31
+ complete: () => unavailable("llm"),
32
+ stream: () => unavailable("llm"),
33
+ };
34
+ }
35
+
36
+ export function devRouteClients(input: {
37
+ env: Record<string, string>;
38
+ log: (line: string) => void;
39
+ }): RouteClients {
40
+ return {
41
+ email: consoleEmailClient(input.log),
42
+ llm: offlineLlmClient(),
43
+ agent: {
44
+ createSession: () => unavailable("agent"),
45
+ start: () => unavailable("agent"),
46
+ events: () => unavailable("agent"),
47
+ subscribe: () => unavailable("agent"),
48
+ destroy: () => unavailable("agent"),
49
+ },
50
+ storage: {
51
+ upload: () => unavailable("storage"),
52
+ get: () => unavailable("storage"),
53
+ getUrl: () => unavailable("storage"),
54
+ delete: () => unavailable("storage"),
55
+ list: () => unavailable("storage"),
56
+ },
57
+ push: { send: () => unavailable("push") },
58
+ jobs: { enqueue: () => unavailable("jobs") },
59
+ sockets: {
60
+ send: () => unavailable("sockets"),
61
+ broadcast: () => unavailable("sockets"),
62
+ },
63
+ env: input.env,
64
+ };
65
+ }
@@ -0,0 +1,161 @@
1
+ import { createServer } from "node:net";
2
+ import { lstat, mkdir, readdir, readFile, symlink } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { arch, platform } from "node:os";
6
+
7
+ export type DevPostgres = {
8
+ host: string;
9
+ port: number;
10
+ dataDir: string;
11
+ database: string;
12
+ connectionString: string;
13
+ stop: () => Promise<void>;
14
+ };
15
+
16
+ const DEV_USER = "robodev";
17
+ const DEV_PASSWORD = "robodev";
18
+ const DEV_DATABASE = "robodev";
19
+
20
+ /** Asks the OS for a free TCP port so parallel projects never collide. */
21
+ export async function freePort(): Promise<number> {
22
+ return new Promise((resolve, reject) => {
23
+ const server = createServer();
24
+ server.unref();
25
+ server.on("error", reject);
26
+ server.listen(0, "127.0.0.1", () => {
27
+ const address = server.address();
28
+ if (!address || typeof address === "string") {
29
+ server.close();
30
+ reject(new Error("Could not reserve a port for Postgres"));
31
+ return;
32
+ }
33
+ const { port } = address;
34
+ server.close(() => resolve(port));
35
+ });
36
+ });
37
+ }
38
+
39
+ export function devConnectionString(input: { port: number; database?: string }): string {
40
+ return `postgres://${DEV_USER}:${DEV_PASSWORD}@127.0.0.1:${input.port}/${
41
+ input.database ?? DEV_DATABASE
42
+ }`;
43
+ }
44
+
45
+ async function isInitialised(dataDir: string): Promise<boolean> {
46
+ try {
47
+ return (await readdir(dataDir)).includes("PG_VERSION");
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The `@embedded-postgres/*` tarballs ship dylibs/shared objects without their version
55
+ * symlinks (npm tarballs cannot carry symlinks) and rebuild them from `pg-symlinks.json`
56
+ * in a `postinstall`. Package managers that skip install scripts leave the cluster
57
+ * unlaunchable with a cryptic loader error, so hydrate any missing links ourselves.
58
+ */
59
+ export async function hydratePostgresSymlinks(): Promise<void> {
60
+ const pkg = `@embedded-postgres/${platform() === "win32" ? "windows" : platform()}-${arch()}`;
61
+ let pkgRoot: string;
62
+ try {
63
+ // The package only exports its entry point, so derive the root from `dist/index.js`.
64
+ pkgRoot = dirname(dirname(createRequire(import.meta.url).resolve(pkg)));
65
+ } catch {
66
+ return;
67
+ }
68
+
69
+ let entries: { source: string; target: string }[];
70
+ try {
71
+ entries = JSON.parse(
72
+ await readFile(join(pkgRoot, "native", "pg-symlinks.json"), "utf8"),
73
+ ) as typeof entries;
74
+ } catch {
75
+ return;
76
+ }
77
+
78
+ for (const entry of entries) {
79
+ const target = join(pkgRoot, entry.target);
80
+ try {
81
+ await lstat(target);
82
+ continue;
83
+ } catch {
84
+ /* missing, create it below */
85
+ }
86
+ try {
87
+ await symlink(relative(dirname(target), join(pkgRoot, entry.source)), target);
88
+ } catch {
89
+ /* another process won the race, or the tree is read-only */
90
+ }
91
+ }
92
+ }
93
+
94
+ async function loadEmbeddedPostgres() {
95
+ try {
96
+ const mod = await import("embedded-postgres");
97
+ return mod.default;
98
+ } catch (error) {
99
+ throw new Error(
100
+ `Could not load the embedded Postgres binaries (${
101
+ error instanceof Error ? error.message : String(error)
102
+ }). Reinstall dependencies, or point robodev dev at your own server with --database-url.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Starts a real Postgres on a free port with its cluster under `dataDir`, so rows survive
109
+ * restarts. `initdb` only runs the first time.
110
+ */
111
+ export async function startDevPostgres(input: {
112
+ dataDir: string;
113
+ port?: number;
114
+ quiet?: boolean;
115
+ }): Promise<DevPostgres> {
116
+ await hydratePostgresSymlinks();
117
+ const EmbeddedPostgres = await loadEmbeddedPostgres();
118
+ const port = input.port && input.port > 0 ? input.port : await freePort();
119
+ await mkdir(input.dataDir, { recursive: true });
120
+ const fresh = !(await isInitialised(input.dataDir));
121
+
122
+ const postgres = new EmbeddedPostgres({
123
+ databaseDir: input.dataDir,
124
+ port,
125
+ user: DEV_USER,
126
+ password: DEV_PASSWORD,
127
+ persistent: true,
128
+ onLog: input.quiet ? () => undefined : (message) => process.stderr.write(`${message}\n`),
129
+ onError: input.quiet ? () => undefined : (message) => process.stderr.write(`${message}\n`),
130
+ });
131
+
132
+ if (fresh) {
133
+ await postgres.initialise();
134
+ }
135
+ await postgres.start();
136
+ if (fresh) {
137
+ await postgres.createDatabase(DEV_DATABASE);
138
+ }
139
+
140
+ return {
141
+ host: "127.0.0.1",
142
+ port,
143
+ dataDir: input.dataDir,
144
+ database: DEV_DATABASE,
145
+ connectionString: devConnectionString({ port }),
146
+ stop: () => postgres.stop(),
147
+ };
148
+ }
149
+
150
+ /** Wraps a Postgres the user already runs, so `--database-url` behaves like the embedded one. */
151
+ export function externalDevPostgres(connectionString: string): DevPostgres {
152
+ const url = new URL(connectionString);
153
+ return {
154
+ host: url.hostname,
155
+ port: Number(url.port || 5432),
156
+ dataDir: "",
157
+ database: url.pathname.replace(/^\//, "") || "postgres",
158
+ connectionString,
159
+ stop: async () => undefined,
160
+ };
161
+ }
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ backendCandidates,
8
+ collectBackendFiles,
9
+ devDirFor,
10
+ findDevProject,
11
+ isBackendDevPath,
12
+ } from "./dev-project.js";
13
+ import { parseDevArgs, parseEnvFile } from "./dev.js";
14
+ import { devConnectionString } from "./dev-postgres.js";
15
+
16
+ async function tempProject(): Promise<string> {
17
+ return mkdtemp(join(tmpdir(), "robodev-dev-"));
18
+ }
19
+
20
+ const DATABASE_TS = `import { defineDatabase, pgTable, text } from "@robodev-ai/sdk";
21
+ export const notes = pgTable("notes", { id: text("id").primaryKey() });
22
+ export default defineDatabase({ name: "app", tables: { notes } });
23
+ `;
24
+
25
+ test("backendCandidates prefers robodev/ then the root then packages/be", () => {
26
+ assert.deepEqual(
27
+ backendCandidates("/p").map((entry) => entry.layout),
28
+ ["robodev", "root", "packages/be"],
29
+ );
30
+ });
31
+
32
+ test("findDevProject finds a robodev/ backend", async () => {
33
+ const root = await tempProject();
34
+ await mkdir(join(root, "robodev"), { recursive: true });
35
+ await writeFile(join(root, "robodev", "database.ts"), DATABASE_TS);
36
+
37
+ const project = await findDevProject(root);
38
+ assert.equal(project.root, root);
39
+ assert.equal(project.backendDir, join(root, "robodev"));
40
+ assert.equal(project.layout, "robodev");
41
+ });
42
+
43
+ test("findDevProject finds Tiny's packages/be and walks up from a subdirectory", async () => {
44
+ const root = await tempProject();
45
+ await mkdir(join(root, "packages", "be"), { recursive: true });
46
+ await mkdir(join(root, "apps", "fe"), { recursive: true });
47
+ await writeFile(join(root, "packages", "be", "database.ts"), DATABASE_TS);
48
+
49
+ const project = await findDevProject(join(root, "apps", "fe"));
50
+ assert.equal(project.root, root);
51
+ assert.equal(project.backendDir, join(root, "packages", "be"));
52
+ assert.equal(project.layout, "packages/be");
53
+ });
54
+
55
+ test("findDevProject explains itself when there is no project", async () => {
56
+ const root = await mkdtemp(join(tmpdir(), "robodev-empty-"));
57
+ await assert.rejects(() => findDevProject(root), /No database\.ts found/);
58
+ });
59
+
60
+ test("isBackendDevPath accepts backend sources only", () => {
61
+ assert.equal(isBackendDevPath("database.ts"), true);
62
+ assert.equal(isBackendDevPath("api/planets.ts"), true);
63
+ assert.equal(isBackendDevPath("api/planets/[id].ts"), true);
64
+ assert.equal(isBackendDevPath("jobs/digest.ts"), true);
65
+ assert.equal(isBackendDevPath("hooks/auth.ts"), true);
66
+ assert.equal(isBackendDevPath("sockets/chat.ts"), true);
67
+ assert.equal(isBackendDevPath("src/main.tsx"), false);
68
+ assert.equal(isBackendDevPath("api/planets.js"), false);
69
+ assert.equal(isBackendDevPath("package.json"), false);
70
+ assert.equal(isBackendDevPath("../secrets.ts"), false);
71
+ // Project tests stay out of the dev tree, exactly as deploy excludes them.
72
+ assert.equal(isBackendDevPath("api/_lib.seed.test.ts"), false);
73
+ assert.equal(isBackendDevPath("api/planets.spec.ts"), false);
74
+ });
75
+
76
+ test("collectBackendFiles reads deploy-shaped paths and skips the rest", async () => {
77
+ const root = await tempProject();
78
+ const backend = join(root, "robodev");
79
+ await mkdir(join(backend, "api", "planets"), { recursive: true });
80
+ await mkdir(join(backend, "jobs"), { recursive: true });
81
+ await mkdir(join(backend, "node_modules"), { recursive: true });
82
+ await writeFile(join(backend, "database.ts"), DATABASE_TS);
83
+ await writeFile(join(backend, "api", "planets.ts"), "export const get = 1;");
84
+ await writeFile(join(backend, "api", "planets", "[id].ts"), "export const get = 1;");
85
+ await writeFile(join(backend, "api", "notes.md"), "ignored");
86
+ await writeFile(join(backend, "api", "_lib.seed.test.ts"), "ignored");
87
+ await writeFile(join(backend, "jobs", "digest.ts"), "export default 1;");
88
+ await writeFile(join(backend, "node_modules", "x.ts"), "ignored");
89
+ await writeFile(join(backend, "package.json"), "{}");
90
+
91
+ const files = await collectBackendFiles(backend);
92
+ assert.deepEqual(files.map((file) => file.path).sort(), [
93
+ "api/planets.ts",
94
+ "api/planets/[id].ts",
95
+ "database.ts",
96
+ "jobs/digest.ts",
97
+ ]);
98
+ });
99
+
100
+ test("collectBackendFiles requires database.ts", async () => {
101
+ const root = await tempProject();
102
+ await mkdir(join(root, "api"), { recursive: true });
103
+ await writeFile(join(root, "api", "planets.ts"), "export const get = 1;");
104
+ await assert.rejects(() => collectBackendFiles(root), /database\.ts not found/);
105
+ });
106
+
107
+ test("devDirFor sidesteps a legacy .robodev link file", async () => {
108
+ const withDir = await tempProject();
109
+ assert.equal(await devDirFor(withDir), join(withDir, ".robodev", "dev"));
110
+
111
+ const withFile = await tempProject();
112
+ await writeFile(join(withFile, ".robodev"), "{}");
113
+ assert.equal(await devDirFor(withFile), join(withFile, ".robodev-dev"));
114
+ });
115
+
116
+ test("parseDevArgs defaults to :4000, an ephemeral db port, and watching", () => {
117
+ assert.deepEqual(parseDevArgs([]), {
118
+ port: 4000,
119
+ dbPort: 0,
120
+ force: false,
121
+ watch: true,
122
+ withFe: false,
123
+ databaseUrl: undefined,
124
+ });
125
+ });
126
+
127
+ test("parseDevArgs reads every flag", () => {
128
+ const parsed = parseDevArgs([
129
+ "--port",
130
+ "4500",
131
+ "--db-port",
132
+ "55433",
133
+ "--force",
134
+ "--no-watch",
135
+ "--with-fe",
136
+ "--database-url",
137
+ "postgres://u:p@localhost:5432/app",
138
+ ]);
139
+ assert.deepEqual(parsed, {
140
+ port: 4500,
141
+ dbPort: 55433,
142
+ force: true,
143
+ watch: false,
144
+ withFe: true,
145
+ databaseUrl: "postgres://u:p@localhost:5432/app",
146
+ });
147
+ });
148
+
149
+ test("parseDevArgs rejects malformed flag values", () => {
150
+ assert.throws(() => parseDevArgs(["--port", "abc"]), /--port needs/);
151
+ assert.throws(() => parseDevArgs(["--port", "-1"]), /--port needs/);
152
+ assert.throws(() => parseDevArgs(["--database-url"]), /--database-url needs/);
153
+ assert.throws(() => parseDevArgs(["--database-url", "--force"]), /--database-url needs/);
154
+ });
155
+
156
+ test("parseEnvFile handles comments, blanks, quotes, and values with =", () => {
157
+ assert.deepEqual(
158
+ parseEnvFile(["# comment", "", "A=1", 'B="two"', "C='three'", "D=a=b", " E = 5 "].join("\n")),
159
+ { A: "1", B: "two", C: "three", D: "a=b", E: "5" },
160
+ );
161
+ });
162
+
163
+ test("devConnectionString points at loopback", () => {
164
+ assert.equal(
165
+ devConnectionString({ port: 55433 }),
166
+ "postgres://robodev:robodev@127.0.0.1:55433/robodev",
167
+ );
168
+ });