robodev 0.43.0 → 0.45.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.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, deploy, load, and run a project locally with robodev dev",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,13 @@ 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, shouldCollectPath } from "./collect-files.js";
7
+ import {
8
+ collectFiles,
9
+ collectSourceFiles,
10
+ mergeCollectedFiles,
11
+ shouldCollectPath,
12
+ shouldCollectSourcePath,
13
+ } from "./collect-files.js";
8
14
 
9
15
  test("collectFiles includes hosted sources and skips denied paths", async () => {
10
16
  const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
@@ -163,6 +169,50 @@ test("collectFiles rejects mixed backend layouts", async () => {
163
169
  }
164
170
  });
165
171
 
172
+ test("collectSourceFiles includes Tiny apps/fe when CLI database is absent", async () => {
173
+ const root = await mkdtemp(join(tmpdir(), "rd-cli-src-tiny-"));
174
+ try {
175
+ await mkdir(join(root, "packages/be/api"), { recursive: true });
176
+ await mkdir(join(root, "apps/fe/src"), { recursive: true });
177
+ await writeFile(join(root, "packages/be/database.ts"), "export default {}");
178
+ await writeFile(join(root, "packages/be/api/health.ts"), "export {}");
179
+ await writeFile(join(root, "apps/fe/package.json"), "{}");
180
+ await writeFile(join(root, "apps/fe/index.html"), "<html></html>");
181
+ await writeFile(join(root, "apps/fe/src/main.tsx"), "export {}");
182
+ await writeFile(join(root, ".env"), "SECRET=1");
183
+ const files = await collectSourceFiles(root);
184
+ const paths = new Set(files.map((file) => file.path));
185
+ assert.ok(paths.has("packages/be/database.ts"));
186
+ assert.ok(paths.has("apps/fe/src/main.tsx"));
187
+ assert.ok(!paths.has(".env"));
188
+ assert.equal(shouldCollectSourcePath("apps/fe/src/main.tsx"), true);
189
+ } finally {
190
+ await rm(root, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ test("collectSourceFiles CLI database wins and drops leftover Tiny", async () => {
195
+ const root = await mkdtemp(join(tmpdir(), "rd-cli-src-win-"));
196
+ try {
197
+ await mkdir(join(root, "robodev"), { recursive: true });
198
+ await mkdir(join(root, "apps/fe/src"), { recursive: true });
199
+ await mkdir(join(root, "packages/be"), { recursive: true });
200
+ await mkdir(join(root, "src"), { recursive: true });
201
+ await writeFile(join(root, "robodev/database.ts"), "export default {}");
202
+ await writeFile(join(root, "apps/fe/src/main.tsx"), "export {}");
203
+ await writeFile(join(root, "packages/be/database.ts"), "export default {}");
204
+ await writeFile(join(root, "src/App.tsx"), "export {}");
205
+ const files = await collectSourceFiles(root);
206
+ const paths = new Set(files.map((file) => file.path));
207
+ assert.ok(paths.has("robodev/database.ts"));
208
+ assert.ok(paths.has("src/App.tsx"));
209
+ assert.ok(!paths.has("apps/fe/src/main.tsx"));
210
+ assert.ok(!paths.has("packages/be/database.ts"));
211
+ } finally {
212
+ await rm(root, { recursive: true, force: true });
213
+ }
214
+ });
215
+
166
216
  test("backend tests are not collected for deploy", () => {
167
217
  assert.equal(shouldCollectPath("robodev/api/_lib.seed.test.ts"), false);
168
218
  assert.equal(shouldCollectPath("robodev/jobs/resize.spec.ts"), false);
@@ -1,5 +1,10 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
+ import {
4
+ applySourceLayoutWin,
5
+ isAllowedWorkspacePath,
6
+ mapWorkspaceToDeployFiles,
7
+ } from "@robodev-ai/runtime";
3
8
 
4
9
  export const MAX_FILE_COUNT = 400;
5
10
  export const MAX_FILE_BYTES = 1024 * 1024;
@@ -185,6 +190,16 @@ export function shouldCollectPath(path: string): boolean {
185
190
  return !isUnsafe(normalized) && !isDenied(normalized) && isAllowed(normalized);
186
191
  }
187
192
 
193
+ export function shouldCollectSourcePath(path: string): boolean {
194
+ const normalized = normalize(path);
195
+ if (isUnsafe(normalized)) return false;
196
+ if (normalized === ".env" || normalized.startsWith(".env.")) return false;
197
+ const top = normalized.split("/")[0];
198
+ if (SKIP_DIRS.has(top)) return false;
199
+ if (isRobodevLockfile(normalized)) return false;
200
+ return isAllowedWorkspacePath(normalized);
201
+ }
202
+
188
203
  export function isStaticCollectedPath(path: string): boolean {
189
204
  const normalized = normalize(path);
190
205
  if (normalized === "index.html") return true;
@@ -259,6 +274,80 @@ export function mergeCollectedFiles(
259
274
  return files;
260
275
  }
261
276
 
277
+ export async function collectSourceFiles(root: string): Promise<CollectedFile[]> {
278
+ const hasNew = await fileExists(join(root, "robodev/database.ts"));
279
+ const hasLegacy = await fileExists(join(root, "database.ts"));
280
+ const hasTiny = await fileExists(join(root, "packages/be/database.ts"));
281
+ if (!hasNew && !hasLegacy && !hasTiny) {
282
+ throw new Error(
283
+ "database.ts not found (looked in robodev/database.ts, ./database.ts, and packages/be/database.ts)",
284
+ );
285
+ }
286
+
287
+ const files: CollectedFile[] = [];
288
+ let totalBytes = 0;
289
+
290
+ async function walk(dir: string): Promise<void> {
291
+ let entries;
292
+ try {
293
+ entries = await readdir(dir, { withFileTypes: true });
294
+ } catch {
295
+ return;
296
+ }
297
+ for (const entry of entries) {
298
+ const abs = join(dir, entry.name);
299
+ if (entry.isDirectory()) {
300
+ if (SKIP_DIRS.has(entry.name)) continue;
301
+ await walk(abs);
302
+ continue;
303
+ }
304
+ if (!entry.isFile()) continue;
305
+ const path = normalize(relative(root, abs));
306
+ if (!shouldCollectSourcePath(path)) continue;
307
+ if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
308
+ throw new Error(`Invalid file path: ${path}`);
309
+ }
310
+ const content = await readFile(abs, "utf8");
311
+ const bytes = Buffer.byteLength(content, "utf8");
312
+ if (bytes > MAX_FILE_BYTES) {
313
+ throw new Error(`File must be under ${formatMaxBytes(MAX_FILE_BYTES)}: ${path}`);
314
+ }
315
+ totalBytes += bytes;
316
+ if (totalBytes > MAX_TREE_BYTES) {
317
+ throw new Error(`Source tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
318
+ }
319
+ files.push({ path, content });
320
+ if (files.length > 2000) {
321
+ throw new Error("Source may include at most 2000 files");
322
+ }
323
+ }
324
+ }
325
+
326
+ await walk(root);
327
+ const won = applySourceLayoutWin(files);
328
+ const mixed = mixedBackendLayoutError(won.map((file) => file.path));
329
+ if (mixed) {
330
+ throw new Error(mixed);
331
+ }
332
+ if (
333
+ !won.some(
334
+ (file) =>
335
+ file.path === "robodev/database.ts" ||
336
+ file.path === "database.ts" ||
337
+ file.path === "packages/be/database.ts",
338
+ )
339
+ ) {
340
+ throw new Error(
341
+ "database.ts not found (looked in robodev/database.ts, ./database.ts, and packages/be/database.ts)",
342
+ );
343
+ }
344
+ return won;
345
+ }
346
+
347
+ export function hostFilesFromSource(source: readonly CollectedFile[]): CollectedFile[] {
348
+ return mapWorkspaceToDeployFiles(source);
349
+ }
350
+
262
351
  export async function collectFiles(root: string): Promise<CollectedFile[]> {
263
352
  const hasNew = await fileExists(join(root, "robodev/database.ts"));
264
353
  const hasLegacy = await fileExists(join(root, "database.ts"));
@@ -9,11 +9,12 @@ import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-s
9
9
 
10
10
  const here = dirname(fileURLToPath(import.meta.url));
11
11
 
12
- test("deploy posts frontendSource beside files", () => {
12
+ test("deploy posts replace-all source beside host files", () => {
13
13
  const source = readFileSync(join(here, "index.ts"), "utf8");
14
- assert.match(source, /collectFrontendSource/);
14
+ assert.match(source, /collectSourceFiles/);
15
15
  assert.match(source, /collectDeployPayload/);
16
- assert.match(source, /frontendSource\.length > 0 \? \{ frontendSource \}/);
16
+ assert.match(source, /source,/);
17
+ assert.equal(source.includes("frontendSource.length > 0 ? { frontendSource }"), false);
17
18
  });
18
19
 
19
20
  test("shouldCollectFrontendSource when .robodev has frontend or apps/fe exists", () => {
package/src/index.ts CHANGED
@@ -22,13 +22,19 @@ import {
22
22
  writeViteApiUrl,
23
23
  } from "./config.js";
24
24
  import { waitUntilLive } from "./wait-until-live.js";
25
- import { collectFiles, mergeCollectedFiles, type CollectedFile } from "./collect-files.js";
25
+ import {
26
+ collectFiles,
27
+ collectSourceFiles,
28
+ hostFilesFromSource,
29
+ mergeCollectedFiles,
30
+ type CollectedFile,
31
+ } from "./collect-files.js";
26
32
  import {
27
33
  buildAndCollectFrontend,
28
34
  commandAvailable,
29
35
  frontendOutIndexExists,
30
36
  } from "./frontend-build.js";
31
- import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-source.js";
37
+ import { shouldCollectFrontendSource } from "./frontend-source.js";
32
38
  import {
33
39
  emitStarter,
34
40
  loadCatalog,
@@ -57,7 +63,7 @@ Commands:
57
63
  create [path] [--template <id>] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
58
64
  link [projectId] Write robodev/.robodev in the current folder
59
65
  deploy [--force] [--name <name>] [--org <id>] Deploy schema, APIs, jobs, and frontend (creates and links if unlinked)
60
- load Download the latest deploy onto this folder
66
+ load Download the project source onto this folder
61
67
  dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
62
68
 
63
69
  dev options:
@@ -234,28 +240,28 @@ async function collectDeployPayload(
234
240
  root: string,
235
241
  link: ProjectLink,
236
242
  options: DeployOptions,
237
- ): Promise<{ files: CollectedFile[]; frontendSource: CollectedFile[] }> {
238
- const collected = await collectFiles(root);
243
+ ): Promise<{ files: CollectedFile[]; source: CollectedFile[] }> {
244
+ const source = await collectSourceFiles(root);
245
+ const host = hostFilesFromSource(source);
239
246
  const hasAppsFe = await hasAppsFePackageJson(root);
240
- const frontendSource = shouldCollectFrontendSource({
241
- hasFrontendConfig: isFrontendConfig(link.frontend),
242
- hasAppsFePackageJson: hasAppsFe,
243
- })
244
- ? await collectFrontendSource(
245
- root,
246
- isFrontendConfig(link.frontend) ? link.frontend.root : "apps/fe",
247
- )
248
- : [];
249
247
  if (options.frontendFiles) {
250
- return { files: mergeCollectedFiles(collected, options.frontendFiles), frontendSource };
248
+ return { files: mergeCollectedFiles(host, options.frontendFiles), source };
251
249
  }
252
- if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
253
- return { files: collected, frontendSource };
250
+ const shouldBuild =
251
+ !options.skipFrontendBuild &&
252
+ (isFrontendConfig(link.frontend) ||
253
+ shouldCollectFrontendSource({
254
+ hasFrontendConfig: isFrontendConfig(link.frontend),
255
+ hasAppsFePackageJson: hasAppsFe,
256
+ }));
257
+ if (!shouldBuild) {
258
+ return { files: host.length > 0 ? host : await collectFiles(root), source };
254
259
  }
255
- const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
260
+ const frontendRoot = isFrontendConfig(link.frontend) ? link.frontend : defaultFrontendConfig();
261
+ const frontendFiles = await buildAndCollectFrontend(root, frontendRoot, {
256
262
  install: "if-needed",
257
263
  });
258
- return { files: mergeCollectedFiles(collected, frontendFiles), frontendSource };
264
+ return { files: mergeCollectedFiles(host, frontendFiles), source };
259
265
  }
260
266
 
261
267
  /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
@@ -287,7 +293,7 @@ async function runDeploy(
287
293
  promptName: (defaultName) => prompt(`Project name [${defaultName}]: `),
288
294
  },
289
295
  );
290
- const { files, frontendSource } = await collectDeployPayload(root, link, options);
296
+ const { files, source } = await collectDeployPayload(root, link, options);
291
297
  let force = forceFlag;
292
298
 
293
299
  for (;;) {
@@ -306,7 +312,7 @@ async function runDeploy(
306
312
  body: JSON.stringify({
307
313
  force,
308
314
  files,
309
- ...(frontendSource.length > 0 ? { frontendSource } : {}),
315
+ source,
310
316
  }),
311
317
  });
312
318
  await waitUntilLive(result.apiUrl);
@@ -489,7 +495,31 @@ async function assertCreatable(root: string): Promise<void> {
489
495
  }
490
496
  }
491
497
 
492
- async function npmInstallRobodevDeps(root: string): Promise<void> {
498
+ const CREATE_PNPM_VERSION = "10.14.0";
499
+
500
+ async function spawnInherit(command: string, args: string[], cwd?: string): Promise<void> {
501
+ await new Promise<void>((resolve, reject) => {
502
+ const child = spawn(command, args, { cwd, stdio: "inherit" });
503
+ child.on("error", reject);
504
+ child.on("exit", (code) => {
505
+ if (code === 0) resolve();
506
+ else reject(new Error(`${command} ${args.join(" ")} failed (${code})`));
507
+ });
508
+ });
509
+ }
510
+
511
+ async function ensurePnpm(): Promise<{ command: string; prefix: string[] }> {
512
+ if (await commandAvailable("pnpm")) return { command: "pnpm", prefix: [] };
513
+ if (!(await commandAvailable("corepack"))) {
514
+ throw new Error("pnpm is not available. Enable it with Corepack (Node 22) or install pnpm.");
515
+ }
516
+ await spawnInherit("corepack", ["enable"]);
517
+ await spawnInherit("corepack", ["prepare", `pnpm@${CREATE_PNPM_VERSION}`, "--activate"]);
518
+ if (await commandAvailable("pnpm")) return { command: "pnpm", prefix: [] };
519
+ return { command: "corepack", prefix: ["pnpm"] };
520
+ }
521
+
522
+ async function pnpmInstallRobodevDeps(root: string): Promise<void> {
493
523
  const pkgPath = join(root, "robodev", "package.json");
494
524
  let raw: string;
495
525
  try {
@@ -506,18 +536,19 @@ async function npmInstallRobodevDeps(root: string): Promise<void> {
506
536
  const deps = pkg.dependencies ?? {};
507
537
  const extra = Object.keys(deps).filter((name) => name !== "@robodev-ai/sdk");
508
538
  if (extra.length === 0) return;
509
- console.log("Installing robodev/ npm packages…");
510
- await npmInstall(join(root, "robodev"));
539
+ console.log("Installing robodev/ packages…");
540
+ await pnpmInstall(join(root, "robodev"));
511
541
  }
512
542
 
513
- async function npmInstall(root: string): Promise<void> {
514
- console.log("Installing npm packages…");
543
+ async function pnpmInstall(root: string): Promise<void> {
544
+ console.log("Installing packages…");
545
+ const pnpm = await ensurePnpm();
515
546
  await new Promise<void>((resolve, reject) => {
516
- const child = spawn("npm", ["install"], { cwd: root, stdio: "inherit" });
547
+ const child = spawn(pnpm.command, [...pnpm.prefix, "install"], { cwd: root, stdio: "inherit" });
517
548
  child.on("error", reject);
518
549
  child.on("exit", (code) => {
519
550
  if (code === 0) resolve();
520
- else reject(new Error(`npm install failed (${code})`));
551
+ else reject(new Error(`pnpm install failed (${code})`));
521
552
  });
522
553
  });
523
554
  }
@@ -607,20 +638,20 @@ async function runCreate(args: string[]): Promise<void> {
607
638
  const liveUrl = await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
608
639
 
609
640
  try {
610
- await npmInstall(root);
611
- await npmInstallRobodevDeps(root);
641
+ await pnpmInstall(root);
642
+ await pnpmInstallRobodevDeps(root);
612
643
  } catch (error) {
613
644
  console.log(error instanceof Error ? error.message : error);
614
645
  if (starter.kind === "backend") {
615
646
  console.log(
616
- "npm install failed. Copy the API URL printed above into Lovable, Bolt, or v0 as VITE_API_URL.",
647
+ "pnpm install failed. Copy the API URL printed above into Lovable, Bolt, or v0 as VITE_API_URL.",
617
648
  );
618
649
  console.log("https://robodev.povio.dev/docs/frontend");
619
650
  } else if (await hasAppsFe(root)) {
620
- console.log("Run `npm install` in the project folder.");
651
+ console.log("Run `pnpm install` in the project folder.");
621
652
  console.log("bun is required for apps/fe. APIs are already deployed.");
622
653
  } else {
623
- console.log("Run `npm install` in the project folder, then `npm run dev`.");
654
+ console.log("Run `pnpm install` in the project folder, then `pnpm run dev`.");
624
655
  }
625
656
  if (liveUrl) {
626
657
  printLiveUrl(liveUrl);
@@ -650,7 +681,7 @@ async function runCreate(args: string[]): Promise<void> {
650
681
  console.log(" The project App URL serves the hosted Welcome page.");
651
682
  console.log(" Edit index.html and src/, then robodev deploy (or Code + Save).");
652
683
  console.log(" robodev dev # local APIs");
653
- console.log(" npm run dev:fe # optional local Vite");
684
+ console.log(" pnpm run dev:fe # optional local Vite");
654
685
  }
655
686
  if (liveUrl) {
656
687
  printLiveUrl(liveUrl);
package/src/load.test.ts CHANGED
@@ -80,7 +80,7 @@ test("runLoad fails without a link and does not fetch or write", async () => {
80
80
  test("runLoad fails on an empty deployment and does not write", async () => {
81
81
  const deps = stubRun({ files: [] });
82
82
  await assert.rejects(() => runLoad([], deps), new Error(NO_DEPLOYMENT_MESSAGE));
83
- assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
83
+ assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/workspace"]);
84
84
  assert.deepEqual(deps.applied, []);
85
85
  });
86
86
 
@@ -93,7 +93,7 @@ test("runLoad rejects unknown flags before reading the link", async () => {
93
93
  await assert.rejects(() => runLoad(["--workspace"], deps), /Unknown flag --workspace/);
94
94
  });
95
95
 
96
- test("runLoad fetches deployment files and prints a summary", async () => {
96
+ test("runLoad fetches workspace source and prints a summary", async () => {
97
97
  const logs: string[] = [];
98
98
  const files = [{ path: "robodev/database.ts", content: "export default {}" }];
99
99
  const deps = stubRun({
@@ -102,7 +102,7 @@ test("runLoad fetches deployment files and prints a summary", async () => {
102
102
  log: (message) => logs.push(message),
103
103
  });
104
104
  await runLoad([], deps);
105
- assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
105
+ assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/workspace"]);
106
106
  assert.deepEqual(logs, ["Loaded 1 files from proj_load."]);
107
107
  });
108
108
 
@@ -153,12 +153,13 @@ test("applyLoadFiles skips protected and denied paths", async () => {
153
153
  { path: "apps/fe/src/App.tsx", content: "payload-fe" },
154
154
  { path: "apps/fe/package.json", content: "{}" },
155
155
  ]);
156
- assert.equal(result.written, 1);
156
+ assert.equal(result.written, 3);
157
157
  assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "export default {}");
158
158
  assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
159
159
  assert.equal(await readFile(join(root, ".env.local"), "utf8"), "LOCAL=1");
160
160
  assert.equal(await readFile(join(root, "robodev/.robodev"), "utf8"), '{"projectId":"keep"}');
161
- assert.equal(await readFile(join(root, "apps/fe/src/App.tsx"), "utf8"), "local-fe");
161
+ assert.equal(await readFile(join(root, "apps/fe/src/App.tsx"), "utf8"), "payload-fe");
162
+ assert.equal(await readFile(join(root, "apps/fe/package.json"), "utf8"), "{}");
162
163
  });
163
164
  });
164
165
 
@@ -169,7 +170,7 @@ test("applyLoadFiles treats an all-skipped payload as empty", async () => {
169
170
  () =>
170
171
  applyLoadFiles(root, [
171
172
  { path: ".env", content: "nope" },
172
- { path: "apps/fe/src/App.tsx", content: "nope" },
173
+ { path: "robodev/.robodev", content: "nope" },
173
174
  ]),
174
175
  new Error(NO_DEPLOYMENT_MESSAGE),
175
176
  );
@@ -193,7 +194,7 @@ test("applyLoadFiles overwrites existing allowed files", async () => {
193
194
  });
194
195
  });
195
196
 
196
- test("applyLoadFiles prunes stale collectable files and leaves apps/fe and .env", async () => {
197
+ test("applyLoadFiles prunes stale collectables including leftover apps/fe", async () => {
197
198
  await withTemp(async (root) => {
198
199
  await mkdir(join(root, "robodev/api"), { recursive: true });
199
200
  await mkdir(join(root, "apps/fe/src"), { recursive: true });
@@ -207,25 +208,27 @@ test("applyLoadFiles prunes stale collectable files and leaves apps/fe and .env"
207
208
  { path: "robodev/api/keep.ts", content: "new-keep" },
208
209
  ]);
209
210
  assert.equal(result.written, 2);
210
- assert.equal(result.removed, 1);
211
+ assert.equal(result.removed, 2);
211
212
  assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "new-db");
212
213
  assert.equal(await readFile(join(root, "robodev/api/keep.ts"), "utf8"), "new-keep");
213
214
  await assert.rejects(() => readFile(join(root, "robodev/api/gone.ts")), /ENOENT/);
215
+ await assert.rejects(() => readFile(join(root, "apps/fe/src/App.tsx")), /ENOENT/);
214
216
  assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
215
- assert.equal(await readFile(join(root, "apps/fe/src/App.tsx"), "utf8"), "local-fe");
216
217
  });
217
218
  });
218
219
 
219
- test("applyLoadFiles decodes public binaries from base64", async () => {
220
+ test("applyLoadFiles writes source frontend files and skips host dist binaries", async () => {
220
221
  await withTemp(async (root) => {
221
222
  const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
222
223
  const result = await applyLoadFiles(root, [
223
224
  { path: "index.html", content: "<html></html>" },
225
+ { path: "apps/fe/public/favicon.svg", content: "<svg></svg>" },
224
226
  { path: "public/icon.png", content: bytes.toString("base64") },
225
227
  ]);
226
228
  assert.equal(result.written, 2);
227
- assert.deepEqual(await readFile(join(root, "public/icon.png")), bytes);
228
229
  assert.equal(await readFile(join(root, "index.html"), "utf8"), "<html></html>");
230
+ assert.equal(await readFile(join(root, "apps/fe/public/favicon.svg"), "utf8"), "<svg></svg>");
231
+ await assert.rejects(() => readFile(join(root, "public/icon.png")), /ENOENT/);
229
232
  });
230
233
  });
231
234
 
package/src/load.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { authed } from "./api.js";
4
- import { isPublicBinaryCollectedPath, shouldCollectPath } from "./collect-files.js";
4
+ import { isPublicBinaryCollectedPath, shouldCollectSourcePath } from "./collect-files.js";
5
5
  import { readLink, type ProjectLink } from "./config.js";
6
6
 
7
7
  export const NO_LINK_MESSAGE =
8
8
  "No robodev/.robodev file. Run `robodev link` or `robodev create` first.";
9
9
  export const NO_DEPLOYMENT_MESSAGE =
10
- "No deployment for this project. Deploy from the dashboard or run `robodev deploy` first.";
10
+ "No source files for this project. Deploy from the dashboard or run `robodev deploy` first.";
11
+ export const NO_SOURCE_MESSAGE = NO_DEPLOYMENT_MESSAGE;
11
12
 
12
- const PRUNE_SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev", "apps"]);
13
+ const PRUNE_SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
13
14
 
14
15
  export type LoadFile = { path: string; content: string };
15
16
 
@@ -52,7 +53,6 @@ function isProtectedPath(path: string): boolean {
52
53
  }
53
54
  if (path === ".env" || path.startsWith(".env.")) return true;
54
55
  if (path === "credentials.json" || path.endsWith("/credentials.json")) return true;
55
- if (path === "apps" || path.startsWith("apps/")) return true;
56
56
  if (path === "node_modules" || path.startsWith("node_modules/")) return true;
57
57
  if (path === ".git" || path.startsWith(".git/")) return true;
58
58
  return false;
@@ -79,7 +79,7 @@ export function classifyLoadFiles(root: string, files: LoadFile[]): Classified[]
79
79
  if (isUnsafePath(path)) return { kind: "unsafe", path };
80
80
  const abs = resolveUnderRoot(root, path);
81
81
  if (!abs) return { kind: "unsafe", path };
82
- if (isProtectedPath(path) || !shouldCollectPath(path)) return { kind: "denied", path };
82
+ if (isProtectedPath(path) || !shouldCollectSourcePath(path)) return { kind: "denied", path };
83
83
  return { kind: "allowed", path, content: file.content, abs };
84
84
  });
85
85
  }
@@ -103,7 +103,7 @@ async function listCollectablePaths(root: string): Promise<string[]> {
103
103
  }
104
104
  if (!entry.isFile()) continue;
105
105
  const path = normalize(relative(root, abs));
106
- if (!shouldCollectPath(path)) continue;
106
+ if (!shouldCollectSourcePath(path)) continue;
107
107
  paths.push(path);
108
108
  }
109
109
  }
@@ -161,7 +161,7 @@ export async function runLoad(args: string[], deps: LoadDeps = defaultDeps): Pro
161
161
  throw new Error(NO_LINK_MESSAGE);
162
162
  }
163
163
  const body = await deps.authed<{ files?: LoadFile[] }>(
164
- `/v1/projects/${link.projectId}/deployment/files`,
164
+ `/v1/projects/${link.projectId}/workspace`,
165
165
  );
166
166
  const files = body.files ?? [];
167
167
  if (files.length === 0) {
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
4
4
  import { devtools } from "@tanstack/devtools-vite";
5
5
  import { tanstackRouter } from "@tanstack/router-plugin/vite";
6
6
  import viteReact from "@vitejs/plugin-react";
7
- import { readFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { defineConfig as defineViteConfig, mergeConfig } from "vite";
9
+ import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
10
10
  import devtoolsJson from "vite-plugin-devtools-json";
11
11
  import { defineConfig as defineVitestConfig } from "vitest/config";
12
12
  import { reactIconsSprite } from "react-icons-sprite/vite";
13
13
 
14
+ /** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
15
+ function lucideSpriteIconAliases(): Plugin {
16
+ return {
17
+ name: "lucide-sprite-icon-aliases",
18
+ enforce: "pre",
19
+ apply: "build",
20
+ buildStart() {
21
+ const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
22
+ if (!existsSync(iconsDir)) return;
23
+ for (const file of readdirSync(iconsDir)) {
24
+ if (!file.endsWith(".mjs")) continue;
25
+ const src = join(iconsDir, file);
26
+ const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
27
+ if (digit) {
28
+ const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
29
+ if (!existsSync(dest)) copyFileSync(src, dest);
30
+ }
31
+ if (!file.endsWith("-icon.mjs")) {
32
+ const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
33
+ if (!existsSync(dest)) copyFileSync(src, dest);
34
+ }
35
+ }
36
+ },
37
+ };
38
+ }
39
+
14
40
  const repoRoot = new URL("../..", import.meta.url).pathname;
15
41
 
16
42
  function readDotEnvKey(root: string, key: string): string | undefined {
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
71
97
  devtools({
72
98
  eventBusConfig: { enabled: false },
73
99
  }),
100
+ lucideSpriteIconAliases(),
74
101
  iconsSpritePlugin,
75
102
  viteReact({
76
103
  babel: {
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.43.0",
15
+ "robodev": "^0.45.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.43.0",
14
+ "robodev": "^0.45.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -7,7 +7,7 @@ The project App URL serves the hosted Welcome page. Edit `index.html` and `src/`
7
7
  ```bash
8
8
  robodev create my-app
9
9
  robodev dev # local APIs
10
- npm run dev:fe # optional local Vite
10
+ pnpm run dev:fe # optional local Vite
11
11
  ```
12
12
 
13
13
  `robodev create` (TTY and non-TTY) uses this starter by default. Pass `--template space` for Space. Lovable, Bolt, or v0 should use `--template backend`.
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^18.3.1"
17
17
  },
18
18
  "devDependencies": {
19
- "robodev": "^0.43.0",
19
+ "robodev": "^0.45.0",
20
20
  "typescript": "^5.9.2",
21
21
  "vite": "^6.0.0"
22
22
  }
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
4
4
  import { devtools } from "@tanstack/devtools-vite";
5
5
  import { tanstackRouter } from "@tanstack/router-plugin/vite";
6
6
  import viteReact from "@vitejs/plugin-react";
7
- import { readFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { defineConfig as defineViteConfig, mergeConfig } from "vite";
9
+ import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
10
10
  import devtoolsJson from "vite-plugin-devtools-json";
11
11
  import { defineConfig as defineVitestConfig } from "vitest/config";
12
12
  import { reactIconsSprite } from "react-icons-sprite/vite";
13
13
 
14
+ /** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
15
+ function lucideSpriteIconAliases(): Plugin {
16
+ return {
17
+ name: "lucide-sprite-icon-aliases",
18
+ enforce: "pre",
19
+ apply: "build",
20
+ buildStart() {
21
+ const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
22
+ if (!existsSync(iconsDir)) return;
23
+ for (const file of readdirSync(iconsDir)) {
24
+ if (!file.endsWith(".mjs")) continue;
25
+ const src = join(iconsDir, file);
26
+ const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
27
+ if (digit) {
28
+ const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
29
+ if (!existsSync(dest)) copyFileSync(src, dest);
30
+ }
31
+ if (!file.endsWith("-icon.mjs")) {
32
+ const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
33
+ if (!existsSync(dest)) copyFileSync(src, dest);
34
+ }
35
+ }
36
+ },
37
+ };
38
+ }
39
+
14
40
  const repoRoot = new URL("../..", import.meta.url).pathname;
15
41
 
16
42
  function readDotEnvKey(root: string, key: string): string | undefined {
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
71
97
  devtools({
72
98
  eventBusConfig: { enabled: false },
73
99
  }),
100
+ lucideSpriteIconAliases(),
74
101
  iconsSpritePlugin,
75
102
  viteReact({
76
103
  babel: {
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.43.0",
15
+ "robodev": "^0.45.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
4
4
  import { devtools } from "@tanstack/devtools-vite";
5
5
  import { tanstackRouter } from "@tanstack/router-plugin/vite";
6
6
  import viteReact from "@vitejs/plugin-react";
7
- import { readFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { defineConfig as defineViteConfig, mergeConfig } from "vite";
9
+ import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
10
10
  import devtoolsJson from "vite-plugin-devtools-json";
11
11
  import { defineConfig as defineVitestConfig } from "vitest/config";
12
12
  import { reactIconsSprite } from "react-icons-sprite/vite";
13
13
 
14
+ /** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
15
+ function lucideSpriteIconAliases(): Plugin {
16
+ return {
17
+ name: "lucide-sprite-icon-aliases",
18
+ enforce: "pre",
19
+ apply: "build",
20
+ buildStart() {
21
+ const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
22
+ if (!existsSync(iconsDir)) return;
23
+ for (const file of readdirSync(iconsDir)) {
24
+ if (!file.endsWith(".mjs")) continue;
25
+ const src = join(iconsDir, file);
26
+ const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
27
+ if (digit) {
28
+ const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
29
+ if (!existsSync(dest)) copyFileSync(src, dest);
30
+ }
31
+ if (!file.endsWith("-icon.mjs")) {
32
+ const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
33
+ if (!existsSync(dest)) copyFileSync(src, dest);
34
+ }
35
+ }
36
+ },
37
+ };
38
+ }
39
+
14
40
  const repoRoot = new URL("../..", import.meta.url).pathname;
15
41
 
16
42
  function readDotEnvKey(root: string, key: string): string | undefined {
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
71
97
  devtools({
72
98
  eventBusConfig: { enabled: false },
73
99
  }),
100
+ lucideSpriteIconAliases(),
74
101
  iconsSpritePlugin,
75
102
  viteReact({
76
103
  babel: {
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.43.0",
15
+ "robodev": "^0.45.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }