robodev 0.29.0 → 0.30.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.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 {
@@ -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,15 @@ 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
+
109
126
  function isBackendPath(path: string): boolean {
110
127
  const inner = path.startsWith("robodev/") ? path.slice("robodev/".length) : path;
111
128
  if (inner === "database.ts" && (path === "database.ts" || path === "robodev/database.ts")) {
@@ -127,7 +144,9 @@ function isAllowed(path: string): boolean {
127
144
  if (path.startsWith("robodev/")) return false;
128
145
  if (path === "index.html") return true;
129
146
  if (path.startsWith("src/")) return SRC_EXTENSIONS.has(extname(path));
130
- if (path.startsWith("public/")) return PUBLIC_EXTENSIONS.has(extname(path));
147
+ if (path.startsWith("public/")) {
148
+ return PUBLIC_EXTENSIONS.has(extname(path)) || PUBLIC_BINARY_EXTENSIONS.has(extname(path));
149
+ }
131
150
  if (CONFIG_FILES.has(path) || CONFIG_FILES.has(basename(path))) return true;
132
151
  if (/^tsconfig\..+\.json$/.test(basename(path)) && !path.includes("/")) return true;
133
152
  if (!path.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(path))) return true;
@@ -177,6 +196,20 @@ function formatMaxBytes(bytes: number): string {
177
196
  return `${Math.round(bytes / (1024 * 1024))}MB`;
178
197
  }
179
198
 
199
+ export function collectedFileStoredBytes(file: CollectedFile): number {
200
+ if (isPublicBinaryCollectedPath(file.path)) {
201
+ return Buffer.from(file.content, "base64").byteLength;
202
+ }
203
+ return Buffer.byteLength(file.content, "utf8");
204
+ }
205
+
206
+ export async function readCollectedContent(abs: string, deployPath: string): Promise<string> {
207
+ if (isPublicBinaryCollectedPath(deployPath)) {
208
+ return (await readFile(abs)).toString("base64");
209
+ }
210
+ return readFile(abs, "utf8");
211
+ }
212
+
180
213
  export function assertCollectedFile(file: CollectedFile): void {
181
214
  const path = normalize(file.path);
182
215
  if (
@@ -187,7 +220,7 @@ export function assertCollectedFile(file: CollectedFile): void {
187
220
  ) {
188
221
  throw new Error(`Invalid file path: ${path}`);
189
222
  }
190
- const bytes = Buffer.byteLength(file.content, "utf8");
223
+ const bytes = collectedFileStoredBytes(file);
191
224
  const max = maxBytesForCollectedPath(path);
192
225
  if (bytes > max) {
193
226
  throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
@@ -203,7 +236,7 @@ export function assertCollectedLimits(files: CollectedFile[]): void {
203
236
  assertCollectedFile(file);
204
237
  totalBytes += Buffer.byteLength(file.content, "utf8");
205
238
  if (totalBytes > MAX_TREE_BYTES) {
206
- throw new Error("Deploy tree must be under 15MB");
239
+ throw new Error(`Deploy tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
207
240
  }
208
241
  }
209
242
  }
@@ -251,15 +284,15 @@ export async function collectFiles(root: string): Promise<CollectedFile[]> {
251
284
  if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
252
285
  throw new Error(`Invalid file path: ${path}`);
253
286
  }
254
- const content = await readFile(abs, "utf8");
255
- const bytes = Buffer.byteLength(content, "utf8");
287
+ const content = await readCollectedContent(abs, path);
288
+ const bytes = collectedFileStoredBytes({ path, content });
256
289
  const max = maxBytesForCollectedPath(path);
257
290
  if (bytes > max) {
258
291
  throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
259
292
  }
260
- totalBytes += bytes;
293
+ totalBytes += Buffer.byteLength(content, "utf8");
261
294
  if (totalBytes > MAX_TREE_BYTES) {
262
- throw new Error("Deploy tree must be under 15MB");
295
+ throw new Error(`Deploy tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
263
296
  }
264
297
  files.push({ path, content });
265
298
  if (files.length > MAX_FILE_COUNT) {
@@ -32,27 +32,54 @@ test("resolveFrontendPaths keeps outDir inside the project", () => {
32
32
  );
33
33
  });
34
34
 
35
- test("mapFrontendOutDir remaps text files and skips binaries", async () => {
35
+ test("mapFrontendOutDir remaps text files and includes public binaries", async () => {
36
36
  const out = await mkdtemp(join(tmpdir(), "rd-out-"));
37
37
  try {
38
38
  await writeFile(join(out, "index.html"), "<html><body>app</body></html>");
39
39
  await mkdir(join(out, "assets"), { recursive: true });
40
40
  await writeFile(join(out, "assets/index-abc.js"), "console.log(1)");
41
41
  await writeFile(join(out, "assets/index-abc.css"), "body{color:red}");
42
- await writeFile(join(out, "assets/font.woff2"), "binary-font");
43
- await writeFile(join(out, "icon.png"), "binary-png");
44
- const { files, skippedBinaries } = await mapFrontendOutDir(out);
42
+ await writeFile(join(out, "assets/font.woff2"), Buffer.from("binary-font"));
43
+ await writeFile(join(out, "icon.png"), Buffer.from("binary-png"));
44
+ const files = await mapFrontendOutDir(out);
45
45
  const paths = new Set(files.map((file) => file.path));
46
46
  assert.ok(paths.has("index.html"));
47
47
  assert.ok(paths.has("public/assets/index-abc.js"));
48
48
  assert.ok(paths.has("public/assets/index-abc.css"));
49
- assert.ok(!paths.has("public/assets/font.woff2"));
50
- assert.ok(!paths.has("public/icon.png"));
51
- assert.equal(skippedBinaries, true);
49
+ assert.ok(paths.has("public/assets/font.woff2"));
50
+ assert.ok(paths.has("public/icon.png"));
52
51
  assert.equal(
53
52
  files.find((file) => file.path === "index.html")?.content,
54
53
  "<html><body>app</body></html>",
55
54
  );
55
+ assert.equal(
56
+ files.find((file) => file.path === "public/assets/font.woff2")?.content,
57
+ Buffer.from("binary-font").toString("base64"),
58
+ );
59
+ assert.equal(
60
+ files.find((file) => file.path === "public/icon.png")?.content,
61
+ Buffer.from("binary-png").toString("base64"),
62
+ );
63
+ } finally {
64
+ await rm(out, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("mapFrontendOutDir throws when dist still has denied files", async () => {
69
+ const out = await mkdtemp(join(tmpdir(), "rd-out-skip-"));
70
+ try {
71
+ await writeFile(join(out, "index.html"), "<html></html>");
72
+ await mkdir(join(out, "assets"), { recursive: true });
73
+ await writeFile(join(out, "assets/app.wasm"), Buffer.from("wasm"));
74
+ await writeFile(join(out, "clip.mp4"), Buffer.from("mp4"));
75
+ await assert.rejects(
76
+ () => mapFrontendOutDir(out),
77
+ (err: unknown) =>
78
+ err instanceof Error &&
79
+ /cannot be uploaded/.test(err.message) &&
80
+ err.message.includes("public/assets/app.wasm") &&
81
+ err.message.includes("public/clip.mp4"),
82
+ );
56
83
  } finally {
57
84
  await rm(out, { recursive: true, force: true });
58
85
  }
@@ -2,7 +2,12 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { assertCollectedFile, type CollectedFile, shouldCollectPath } from "./collect-files.js";
5
+ import {
6
+ assertCollectedFile,
7
+ type CollectedFile,
8
+ readCollectedContent,
9
+ shouldCollectPath,
10
+ } from "./collect-files.js";
6
11
  import { isFrontendConfig, normalizeFrontendConfig, type FrontendConfig } from "./config.js";
7
12
 
8
13
  const execFileAsync = promisify(execFile);
@@ -132,12 +137,9 @@ function mappedDeployPath(relFromOutDir: string): string {
132
137
  return `public/${rel}`;
133
138
  }
134
139
 
135
- export async function mapFrontendOutDir(outDirAbs: string): Promise<{
136
- files: CollectedFile[];
137
- skippedBinaries: boolean;
138
- }> {
140
+ export async function mapFrontendOutDir(outDirAbs: string): Promise<CollectedFile[]> {
139
141
  const files: CollectedFile[] = [];
140
- let skippedBinaries = false;
142
+ const skipped: string[] = [];
141
143
 
142
144
  async function walk(dir: string): Promise<void> {
143
145
  let entries;
@@ -156,10 +158,10 @@ export async function mapFrontendOutDir(outDirAbs: string): Promise<{
156
158
  const rel = relative(outDirAbs, abs).replaceAll("\\", "/");
157
159
  const deployPath = mappedDeployPath(rel);
158
160
  if (!shouldCollectPath(deployPath)) {
159
- skippedBinaries = true;
161
+ skipped.push(deployPath);
160
162
  continue;
161
163
  }
162
- const content = await readFile(abs, "utf8");
164
+ const content = await readCollectedContent(abs, deployPath);
163
165
  const file = { path: deployPath, content };
164
166
  assertCollectedFile(file);
165
167
  files.push(file);
@@ -167,7 +169,24 @@ export async function mapFrontendOutDir(outDirAbs: string): Promise<{
167
169
  }
168
170
 
169
171
  await walk(outDirAbs);
170
- return { files, skippedBinaries };
172
+ if (skipped.length > 0) {
173
+ throw new Error(
174
+ `Frontend build produced files that cannot be uploaded: ${skipped.sort().join(", ")}`,
175
+ );
176
+ }
177
+ return files;
178
+ }
179
+
180
+ export async function frontendOutIndexExists(
181
+ projectRoot: string,
182
+ frontend: FrontendConfig,
183
+ ): Promise<boolean> {
184
+ try {
185
+ const { outAbs } = resolveFrontendPaths(projectRoot, frontend);
186
+ return (await stat(join(outAbs, "index.html"))).isFile();
187
+ } catch {
188
+ return false;
189
+ }
171
190
  }
172
191
 
173
192
  export async function buildAndCollectFrontend(
@@ -199,14 +218,9 @@ export async function buildAndCollectFrontend(
199
218
  throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
200
219
  }
201
220
 
202
- const { files, skippedBinaries } = await mapFrontendOutDir(outAbs);
221
+ const files = await mapFrontendOutDir(outAbs);
203
222
  if (!files.some((file) => file.path === "index.html")) {
204
223
  throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
205
224
  }
206
- if (skippedBinaries) {
207
- console.log(
208
- "Skipped binary frontend assets (fonts/images). Hosted UI may use system font fallback.",
209
- );
210
- }
211
225
  return files;
212
226
  }
package/src/index.ts CHANGED
@@ -23,7 +23,11 @@ import {
23
23
  } from "./config.js";
24
24
  import { waitUntilLive } from "./wait-until-live.js";
25
25
  import { collectFiles, mergeCollectedFiles, type CollectedFile } from "./collect-files.js";
26
- import { buildAndCollectFrontend, commandAvailable } from "./frontend-build.js";
26
+ import {
27
+ buildAndCollectFrontend,
28
+ commandAvailable,
29
+ frontendOutIndexExists,
30
+ } from "./frontend-build.js";
27
31
  import {
28
32
  emitStarter,
29
33
  loadCatalog,
@@ -538,6 +542,9 @@ async function runCreate(args: string[]): Promise<void> {
538
542
  });
539
543
  feHosted = true;
540
544
  } catch (error) {
545
+ if (await frontendOutIndexExists(root, defaultFrontendConfig())) {
546
+ throw error;
547
+ }
541
548
  console.log(error instanceof Error ? error.message : error);
542
549
  console.log("Frontend build failed. Deploying schema and APIs only.");
543
550
  skipFrontendBuild = true;
@@ -13,7 +13,7 @@ cursor:
13
13
 
14
14
  This project is a Vite React frontend in `apps/fe` plus a Robodev backend under **`robodev/`** (`robodev/database.ts` + `robodev/api/**/*.ts` + `robodev/hooks/auth.ts` using `@robodev-ai/sdk`). There is no Expo / mobile app in this starter. End-user auth is reserved on the project host at `/api/user/*`. There is no in-browser fake backend.
15
15
 
16
- `robodev deploy` from this root uploads schema and APIs. It does not host the Povio SPA. Run the UI with bun in `apps/fe`.
16
+ `robodev deploy` from this root uploads schema, APIs, and (when `.robodev` has a `frontend` block) the local Vite build as a hosted static SPA. Use bun in `apps/fe` to iterate locally.
17
17
 
18
18
  Shared AI rules and skills are authored in `.rulesync/`. Generated files for Codex, Claude Code, and Cursor are local outputs and should be regenerated with `bun rules:gen`.
19
19
 
@@ -5,7 +5,7 @@
5
5
  "description": "Auth chat starter frontend",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "dev": "bunx --bun vite",
8
+ "dev": "vite --host",
9
9
  "build": "bun run openapi:gen && bunx --bun vite build",
10
10
  "clean": "rimraf dist src/openapi",
11
11
  "lint": "oxlint --type-aware . ../../robodev/api ../../robodev/database.ts --fix",
@@ -8,10 +8,10 @@
8
8
  "rules:gen": "rulesync generate"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.8.0"
11
+ "@robodev-ai/sdk": "^0.10.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.29.0",
14
+ "robodev": "^0.30.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -7,10 +7,10 @@
7
7
  "deploy": "robodev deploy"
8
8
  },
9
9
  "dependencies": {
10
- "@robodev-ai/sdk": "^0.8.0"
10
+ "@robodev-ai/sdk": "^0.10.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.29.0",
13
+ "robodev": "^0.30.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -7,10 +7,10 @@
7
7
  "deploy": "robodev deploy"
8
8
  },
9
9
  "dependencies": {
10
- "@robodev-ai/sdk": "^0.8.0"
10
+ "@robodev-ai/sdk": "^0.10.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.29.0",
13
+ "robodev": "^0.30.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -13,7 +13,7 @@ cursor:
13
13
 
14
14
  This project is a Vite React frontend in `apps/fe` plus a Robodev backend under **`robodev/`** (`robodev/database.ts` + `robodev/api/**/*.ts` using `@robodev-ai/sdk`). There is no Expo / mobile app in this starter. End-user auth is reserved on the project host at `/api/user/*`. There is no in-browser fake backend.
15
15
 
16
- `robodev deploy` from this root uploads schema and APIs. It does not host the Povio SPA. Run the UI with bun in `apps/fe`.
16
+ `robodev deploy` from this root uploads schema, APIs, and (when `.robodev` has a `frontend` block) the local Vite build as a hosted static SPA. Use bun in `apps/fe` to iterate locally.
17
17
 
18
18
  Shared AI rules and skills are authored in `.rulesync/`. Generated files for Codex, Claude Code, and Cursor are local outputs and should be regenerated with `bun rules:gen`.
19
19
 
@@ -5,7 +5,7 @@
5
5
  "description": "Marketplace starter frontend",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "dev": "bunx --bun vite",
8
+ "dev": "vite --host",
9
9
  "build": "bun run openapi:gen && bunx --bun vite build",
10
10
  "clean": "rimraf dist src/openapi",
11
11
  "lint": "oxlint --type-aware . ../../robodev/api ../../robodev/database.ts --fix",
@@ -8,10 +8,10 @@
8
8
  "rules:gen": "rulesync generate"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.8.0"
11
+ "@robodev-ai/sdk": "^0.10.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.29.0",
14
+ "robodev": "^0.30.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -13,7 +13,7 @@ cursor:
13
13
 
14
14
  This project is a Vite React frontend in `apps/fe` plus a Robodev backend under **`robodev/`** (`robodev/database.ts` + `robodev/api/**/*.ts` using `@robodev-ai/sdk`). There is no Expo / mobile app in this starter. End-user auth is reserved on the project host at `/api/user/*`. There is no in-browser fake backend.
15
15
 
16
- `robodev deploy` from this root uploads schema and APIs. It does not host the Povio SPA. Run the UI with bun in `apps/fe`.
16
+ `robodev deploy` from this root uploads schema, APIs, and (when `.robodev` has a `frontend` block) the local Vite build as a hosted static SPA. Use bun in `apps/fe` to iterate locally.
17
17
 
18
18
  Shared AI rules and skills are authored in `.rulesync/`. Generated files for Codex, Claude Code, and Cursor are local outputs and should be regenerated with `bun rules:gen`.
19
19
 
@@ -5,7 +5,7 @@
5
5
  "description": "Space starter frontend",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "dev": "bunx --bun vite",
8
+ "dev": "vite --host",
9
9
  "build": "bun run openapi:gen && bunx --bun vite build",
10
10
  "clean": "rimraf dist src/openapi",
11
11
  "lint": "oxlint --type-aware . ../../robodev/api ../../robodev/database.ts --fix",
@@ -8,10 +8,10 @@
8
8
  "rules:gen": "rulesync generate"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.8.0"
11
+ "@robodev-ai/sdk": "^0.10.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.29.0",
14
+ "robodev": "^0.30.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }