robodev 0.40.0 → 0.41.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,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
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",
@@ -26,8 +26,8 @@
26
26
  "embedded-postgres": "17.10.0-beta.17",
27
27
  "fastify": "^5.12.1",
28
28
  "tsx": "^4.20.5",
29
- "@robodev-ai/sdk": "0.13.0",
30
- "@robodev-ai/runtime": "0.5.1"
29
+ "@robodev-ai/runtime": "0.5.1",
30
+ "@robodev-ai/sdk": "0.13.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.3.0",
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-source.js";
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+
12
+ test("deploy posts frontendSource beside files", () => {
13
+ const source = readFileSync(join(here, "index.ts"), "utf8");
14
+ assert.match(source, /collectFrontendSource/);
15
+ assert.match(source, /collectDeployPayload/);
16
+ assert.match(source, /frontendSource\.length > 0 \? \{ frontendSource \}/);
17
+ });
18
+
19
+ test("shouldCollectFrontendSource when .robodev has frontend or apps/fe exists", () => {
20
+ assert.equal(
21
+ shouldCollectFrontendSource({ hasFrontendConfig: true, hasAppsFePackageJson: false }),
22
+ true,
23
+ );
24
+ assert.equal(
25
+ shouldCollectFrontendSource({ hasFrontendConfig: false, hasAppsFePackageJson: true }),
26
+ true,
27
+ );
28
+ assert.equal(
29
+ shouldCollectFrontendSource({ hasFrontendConfig: false, hasAppsFePackageJson: false }),
30
+ false,
31
+ );
32
+ });
33
+
34
+ test("collectFrontendSource walks apps/fe and skips dist, node_modules, and .env", async () => {
35
+ const root = await mkdtemp(join(tmpdir(), "rd-fe-src-"));
36
+ try {
37
+ await mkdir(join(root, "apps/fe/src"), { recursive: true });
38
+ await mkdir(join(root, "apps/fe/node_modules/left"), { recursive: true });
39
+ await mkdir(join(root, "apps/fe/dist"), { recursive: true });
40
+ await writeFile(join(root, "apps/fe/package.json"), '{"name":"fe"}');
41
+ await writeFile(join(root, "apps/fe/index.html"), "<html></html>");
42
+ await writeFile(join(root, "apps/fe/src/main.tsx"), "export {}");
43
+ await writeFile(join(root, "apps/fe/.env"), "SECRET=1");
44
+ await writeFile(join(root, "apps/fe/node_modules/left/index.js"), "nope");
45
+ await writeFile(join(root, "apps/fe/dist/index.html"), "<html>built</html>");
46
+ await writeFile(join(root, "database.ts"), "export default {}");
47
+
48
+ const files = await collectFrontendSource(root, "apps/fe");
49
+ const paths = new Set(files.map((file) => file.path));
50
+ assert.ok(paths.has("apps/fe/package.json"));
51
+ assert.ok(paths.has("apps/fe/index.html"));
52
+ assert.ok(paths.has("apps/fe/src/main.tsx"));
53
+ assert.ok(!paths.has("apps/fe/.env"));
54
+ assert.ok(!paths.has("apps/fe/node_modules/left/index.js"));
55
+ assert.ok(!paths.has("apps/fe/dist/index.html"));
56
+ assert.ok(!paths.has("database.ts"));
57
+ } finally {
58
+ await rm(root, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test("collectFrontendSource walks root FE without backend files", async () => {
63
+ const root = await mkdtemp(join(tmpdir(), "rd-fe-root-"));
64
+ try {
65
+ await mkdir(join(root, "src"), { recursive: true });
66
+ await mkdir(join(root, "api"), { recursive: true });
67
+ await writeFile(join(root, "database.ts"), "export default {}");
68
+ await writeFile(join(root, "index.html"), "<html></html>");
69
+ await writeFile(join(root, "src/main.tsx"), "export {}");
70
+ await writeFile(join(root, "package.json"), "{}");
71
+ await writeFile(join(root, "vite.config.ts"), "export default {}");
72
+ await writeFile(join(root, "api/health.ts"), "export {}");
73
+
74
+ const files = await collectFrontendSource(root, ".");
75
+ const paths = new Set(files.map((file) => file.path));
76
+ assert.ok(paths.has("index.html"));
77
+ assert.ok(paths.has("src/main.tsx"));
78
+ assert.ok(paths.has("package.json"));
79
+ assert.ok(paths.has("vite.config.ts"));
80
+ assert.ok(!paths.has("database.ts"));
81
+ assert.ok(!paths.has("api/health.ts"));
82
+ } finally {
83
+ await rm(root, { recursive: true, force: true });
84
+ }
85
+ });
@@ -0,0 +1,83 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { isWorkspaceFrontendSourcePath } from "@robodev-ai/runtime";
4
+ import {
5
+ MAX_FILE_BYTES,
6
+ MAX_FILE_COUNT,
7
+ MAX_PATH_DEPTH,
8
+ MAX_PATH_LENGTH,
9
+ MAX_TREE_BYTES,
10
+ type CollectedFile,
11
+ } from "./collect-files.js";
12
+ import { sanitizeProjectRelative } from "./frontend-build.js";
13
+
14
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
15
+
16
+ function normalize(path: string): string {
17
+ return path.replaceAll("\\", "/");
18
+ }
19
+
20
+ function formatMaxBytes(bytes: number): string {
21
+ return `${Math.round(bytes / (1024 * 1024))}MB`;
22
+ }
23
+
24
+ export async function collectFrontendSource(
25
+ projectRoot: string,
26
+ frontendRoot: string,
27
+ ): Promise<CollectedFile[]> {
28
+ const rootAbs = sanitizeProjectRelative(projectRoot, frontendRoot, "root");
29
+ try {
30
+ if (!(await stat(rootAbs)).isDirectory()) return [];
31
+ } catch {
32
+ return [];
33
+ }
34
+
35
+ const files: CollectedFile[] = [];
36
+ let totalBytes = 0;
37
+
38
+ async function walk(dir: string): Promise<void> {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir, { withFileTypes: true });
42
+ } catch {
43
+ return;
44
+ }
45
+ for (const entry of entries) {
46
+ const abs = join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ if (SKIP_DIRS.has(entry.name)) continue;
49
+ await walk(abs);
50
+ continue;
51
+ }
52
+ if (!entry.isFile()) continue;
53
+ const path = normalize(relative(projectRoot, abs));
54
+ if (!isWorkspaceFrontendSourcePath(path)) continue;
55
+ if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
56
+ throw new Error(`Invalid file path: ${path}`);
57
+ }
58
+ const content = await readFile(abs, "utf8");
59
+ const bytes = Buffer.byteLength(content, "utf8");
60
+ if (bytes > MAX_FILE_BYTES) {
61
+ throw new Error(`File must be under ${formatMaxBytes(MAX_FILE_BYTES)}: ${path}`);
62
+ }
63
+ totalBytes += bytes;
64
+ if (totalBytes > MAX_TREE_BYTES) {
65
+ throw new Error(`Frontend source tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
66
+ }
67
+ files.push({ path, content });
68
+ if (files.length > MAX_FILE_COUNT) {
69
+ throw new Error(`Frontend source may include at most ${MAX_FILE_COUNT} files`);
70
+ }
71
+ }
72
+ }
73
+
74
+ await walk(rootAbs);
75
+ return files;
76
+ }
77
+
78
+ export function shouldCollectFrontendSource(input: {
79
+ hasFrontendConfig: boolean;
80
+ hasAppsFePackageJson: boolean;
81
+ }): boolean {
82
+ return input.hasFrontendConfig || input.hasAppsFePackageJson;
83
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  commandAvailable,
29
29
  frontendOutIndexExists,
30
30
  } from "./frontend-build.js";
31
+ import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-source.js";
31
32
  import {
32
33
  emitStarter,
33
34
  loadCatalog,
@@ -227,22 +228,32 @@ type DeployOptions = {
227
228
  skipFrontendBuild?: boolean;
228
229
  };
229
230
 
230
- async function collectDeployFiles(
231
+ async function collectDeployPayload(
231
232
  root: string,
232
233
  link: ProjectLink,
233
234
  options: DeployOptions,
234
- ): Promise<CollectedFile[]> {
235
+ ): Promise<{ files: CollectedFile[]; frontendSource: CollectedFile[] }> {
235
236
  const collected = await collectFiles(root);
237
+ const hasAppsFe = await hasAppsFePackageJson(root);
238
+ const frontendSource = shouldCollectFrontendSource({
239
+ hasFrontendConfig: isFrontendConfig(link.frontend),
240
+ hasAppsFePackageJson: hasAppsFe,
241
+ })
242
+ ? await collectFrontendSource(
243
+ root,
244
+ isFrontendConfig(link.frontend) ? link.frontend.root : "apps/fe",
245
+ )
246
+ : [];
236
247
  if (options.frontendFiles) {
237
- return mergeCollectedFiles(collected, options.frontendFiles);
248
+ return { files: mergeCollectedFiles(collected, options.frontendFiles), frontendSource };
238
249
  }
239
250
  if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
240
- return collected;
251
+ return { files: collected, frontendSource };
241
252
  }
242
253
  const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
243
254
  install: "if-needed",
244
255
  });
245
- return mergeCollectedFiles(collected, frontendFiles);
256
+ return { files: mergeCollectedFiles(collected, frontendFiles), frontendSource };
246
257
  }
247
258
 
248
259
  /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
@@ -274,7 +285,7 @@ async function runDeploy(
274
285
  promptName: (defaultName) => prompt(`Project name [${defaultName}]: `),
275
286
  },
276
287
  );
277
- const files = await collectDeployFiles(root, link, options);
288
+ const { files, frontendSource } = await collectDeployPayload(root, link, options);
278
289
  let force = forceFlag;
279
290
 
280
291
  for (;;) {
@@ -290,7 +301,11 @@ async function runDeploy(
290
301
  plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
291
302
  }>(`/v1/projects/${link.projectId}/deploy`, {
292
303
  method: "POST",
293
- body: JSON.stringify({ force, files }),
304
+ body: JSON.stringify({
305
+ force,
306
+ files,
307
+ ...(frontendSource.length > 0 ? { frontendSource } : {}),
308
+ }),
294
309
  });
295
310
  await waitUntilLive(result.apiUrl);
296
311
  console.log(`App ${result.appUrl ?? result.apiUrl}`);
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.40.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.13.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.40.0",
14
+ "robodev": "^0.41.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^18.3.1"
17
17
  },
18
18
  "devDependencies": {
19
- "robodev": "^0.40.0",
19
+ "robodev": "^0.41.0",
20
20
  "typescript": "^5.9.2",
21
21
  "vite": "^6.0.0"
22
22
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.40.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.40.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }