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 +3 -3
- package/src/frontend-source.test.ts +85 -0
- package/src/frontend-source.ts +83 -0
- package/src/index.ts +22 -7
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "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/
|
|
30
|
-
"@robodev-ai/
|
|
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
|
|
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
|
|
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({
|
|
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}`);
|