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 +1 -1
- package/src/collect-files.test.ts +51 -1
- package/src/collect-files.ts +89 -0
- package/src/frontend-source.test.ts +4 -3
- package/src/index.ts +65 -34
- package/src/load.test.ts +14 -11
- package/src/load.ts +7 -7
- package/templates/auth-chat/apps/fe/vite.config.ts +29 -2
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/README.md +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/apps/fe/vite.config.ts +29 -2
- package/templates/marketplace/package.json +1 -1
- package/templates/space/apps/fe/vite.config.ts +29 -2
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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);
|
package/src/collect-files.ts
CHANGED
|
@@ -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
|
|
12
|
+
test("deploy posts replace-all source beside host files", () => {
|
|
13
13
|
const source = readFileSync(join(here, "index.ts"), "utf8");
|
|
14
|
-
assert.match(source, /
|
|
14
|
+
assert.match(source, /collectSourceFiles/);
|
|
15
15
|
assert.match(source, /collectDeployPayload/);
|
|
16
|
-
assert.match(source, /
|
|
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 {
|
|
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 {
|
|
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
|
|
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[];
|
|
238
|
-
const
|
|
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(
|
|
248
|
+
return { files: mergeCollectedFiles(host, options.frontendFiles), source };
|
|
251
249
|
}
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
510
|
-
await
|
|
539
|
+
console.log("Installing robodev/ packages…");
|
|
540
|
+
await pnpmInstall(join(root, "robodev"));
|
|
511
541
|
}
|
|
512
542
|
|
|
513
|
-
async function
|
|
514
|
-
console.log("Installing
|
|
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(
|
|
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(`
|
|
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
|
|
611
|
-
await
|
|
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
|
-
"
|
|
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 `
|
|
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 `
|
|
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("
|
|
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/
|
|
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
|
|
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/
|
|
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,
|
|
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"), "
|
|
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: "
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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"
|
|
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) || !
|
|
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 (!
|
|
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}/
|
|
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: {
|
|
@@ -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
|
-
|
|
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`.
|
|
@@ -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: {
|
|
@@ -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: {
|