robodev 0.40.0 → 0.42.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 +4 -4
- package/src/frontend-source.test.ts +85 -0
- package/src/frontend-source.ts +83 -0
- package/src/index.ts +27 -7
- package/src/load.test.ts +238 -0
- package/src/load.ts +176 -0
- 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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
|
|
3
|
+
"version": "0.42.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -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,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
import { parseCreateArgs } from "./parse-create-args.js";
|
|
39
40
|
import { parseDeployArgs } from "./parse-deploy-args.js";
|
|
40
41
|
import { ensureDeployLink } from "./ensure-deploy-link.js";
|
|
42
|
+
import { runLoad } from "./load.js";
|
|
41
43
|
import { printLiveUrl } from "./print-live-url.js";
|
|
42
44
|
|
|
43
45
|
const execFileAsync = promisify(execFile);
|
|
@@ -55,6 +57,7 @@ Commands:
|
|
|
55
57
|
create [path] [--template <id>] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
56
58
|
link [projectId] Write robodev/.robodev in the current folder
|
|
57
59
|
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
|
|
58
61
|
dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
|
|
59
62
|
|
|
60
63
|
dev options:
|
|
@@ -227,22 +230,32 @@ type DeployOptions = {
|
|
|
227
230
|
skipFrontendBuild?: boolean;
|
|
228
231
|
};
|
|
229
232
|
|
|
230
|
-
async function
|
|
233
|
+
async function collectDeployPayload(
|
|
231
234
|
root: string,
|
|
232
235
|
link: ProjectLink,
|
|
233
236
|
options: DeployOptions,
|
|
234
|
-
): Promise<CollectedFile[]> {
|
|
237
|
+
): Promise<{ files: CollectedFile[]; frontendSource: CollectedFile[] }> {
|
|
235
238
|
const collected = await collectFiles(root);
|
|
239
|
+
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
|
+
: [];
|
|
236
249
|
if (options.frontendFiles) {
|
|
237
|
-
return mergeCollectedFiles(collected, options.frontendFiles);
|
|
250
|
+
return { files: mergeCollectedFiles(collected, options.frontendFiles), frontendSource };
|
|
238
251
|
}
|
|
239
252
|
if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
|
|
240
|
-
return collected;
|
|
253
|
+
return { files: collected, frontendSource };
|
|
241
254
|
}
|
|
242
255
|
const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
|
|
243
256
|
install: "if-needed",
|
|
244
257
|
});
|
|
245
|
-
return mergeCollectedFiles(collected, frontendFiles);
|
|
258
|
+
return { files: mergeCollectedFiles(collected, frontendFiles), frontendSource };
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
/** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
|
|
@@ -274,7 +287,7 @@ async function runDeploy(
|
|
|
274
287
|
promptName: (defaultName) => prompt(`Project name [${defaultName}]: `),
|
|
275
288
|
},
|
|
276
289
|
);
|
|
277
|
-
const files = await
|
|
290
|
+
const { files, frontendSource } = await collectDeployPayload(root, link, options);
|
|
278
291
|
let force = forceFlag;
|
|
279
292
|
|
|
280
293
|
for (;;) {
|
|
@@ -290,7 +303,11 @@ async function runDeploy(
|
|
|
290
303
|
plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
|
|
291
304
|
}>(`/v1/projects/${link.projectId}/deploy`, {
|
|
292
305
|
method: "POST",
|
|
293
|
-
body: JSON.stringify({
|
|
306
|
+
body: JSON.stringify({
|
|
307
|
+
force,
|
|
308
|
+
files,
|
|
309
|
+
...(frontendSource.length > 0 ? { frontendSource } : {}),
|
|
310
|
+
}),
|
|
294
311
|
});
|
|
295
312
|
await waitUntilLive(result.apiUrl);
|
|
296
313
|
console.log(`App ${result.appUrl ?? result.apiUrl}`);
|
|
@@ -667,6 +684,9 @@ async function main(): Promise<void> {
|
|
|
667
684
|
await runDeploy(parsed.force, {}, { name: parsed.name, orgId: parsed.orgId });
|
|
668
685
|
break;
|
|
669
686
|
}
|
|
687
|
+
case "load":
|
|
688
|
+
await runLoad(args);
|
|
689
|
+
break;
|
|
670
690
|
case "dev": {
|
|
671
691
|
const { main: runDev } = await import("./dev.js");
|
|
672
692
|
await runDev(args);
|
package/src/load.test.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
applyLoadFiles,
|
|
9
|
+
NO_DEPLOYMENT_MESSAGE,
|
|
10
|
+
NO_LINK_MESSAGE,
|
|
11
|
+
parseLoadArgs,
|
|
12
|
+
runLoad,
|
|
13
|
+
type LoadDeps,
|
|
14
|
+
type LoadFile,
|
|
15
|
+
} from "./load.js";
|
|
16
|
+
import type { ProjectLink } from "./config.js";
|
|
17
|
+
|
|
18
|
+
const linked: ProjectLink = {
|
|
19
|
+
projectId: "proj_load",
|
|
20
|
+
apiUrl: "https://robodev.povio.dev",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function withTemp<T>(fn: (root: string) => Promise<T>): Promise<T> {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), "rd-load-"));
|
|
25
|
+
try {
|
|
26
|
+
return await fn(root);
|
|
27
|
+
} finally {
|
|
28
|
+
await rm(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stubRun(
|
|
33
|
+
overrides: Partial<LoadDeps> & { files?: LoadFile[] } = {},
|
|
34
|
+
): LoadDeps & { authedPaths: string[]; applied: LoadFile[][] } {
|
|
35
|
+
const authedPaths: string[] = [];
|
|
36
|
+
const applied: LoadFile[][] = [];
|
|
37
|
+
return {
|
|
38
|
+
authedPaths,
|
|
39
|
+
applied,
|
|
40
|
+
readLink: overrides.readLink ?? (async () => linked),
|
|
41
|
+
authed:
|
|
42
|
+
overrides.authed ??
|
|
43
|
+
(async <T>(path: string) => {
|
|
44
|
+
authedPaths.push(path);
|
|
45
|
+
return { files: overrides.files ?? [] } as T;
|
|
46
|
+
}),
|
|
47
|
+
apply:
|
|
48
|
+
overrides.apply ??
|
|
49
|
+
(async (_root, files) => {
|
|
50
|
+
applied.push(files);
|
|
51
|
+
return { written: files.length, removed: 0 };
|
|
52
|
+
}),
|
|
53
|
+
cwd: overrides.cwd ?? (() => "/tmp/app"),
|
|
54
|
+
log: overrides.log ?? (() => {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("parseLoadArgs rejects unknown flags and extra arguments", () => {
|
|
59
|
+
assert.throws(() => parseLoadArgs(["--force"]), /Unknown flag --force/);
|
|
60
|
+
assert.throws(() => parseLoadArgs(["--name", "x"]), /Unknown flag --name/);
|
|
61
|
+
assert.throws(() => parseLoadArgs(["extra"]), /Unexpected argument extra/);
|
|
62
|
+
parseLoadArgs([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("runLoad fails without a link and does not fetch or write", async () => {
|
|
66
|
+
const deps = stubRun({
|
|
67
|
+
readLink: async () => null,
|
|
68
|
+
authed: async () => {
|
|
69
|
+
throw new Error("should not fetch");
|
|
70
|
+
},
|
|
71
|
+
apply: async () => {
|
|
72
|
+
throw new Error("should not apply");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await assert.rejects(() => runLoad([], deps), new Error(NO_LINK_MESSAGE));
|
|
76
|
+
assert.deepEqual(deps.authedPaths, []);
|
|
77
|
+
assert.deepEqual(deps.applied, []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("runLoad fails on an empty deployment and does not write", async () => {
|
|
81
|
+
const deps = stubRun({ files: [] });
|
|
82
|
+
await assert.rejects(() => runLoad([], deps), new Error(NO_DEPLOYMENT_MESSAGE));
|
|
83
|
+
assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
|
|
84
|
+
assert.deepEqual(deps.applied, []);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("runLoad rejects unknown flags before reading the link", async () => {
|
|
88
|
+
const deps = stubRun({
|
|
89
|
+
readLink: async () => {
|
|
90
|
+
throw new Error("should not read link");
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await assert.rejects(() => runLoad(["--workspace"], deps), /Unknown flag --workspace/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("runLoad fetches deployment files and prints a summary", async () => {
|
|
97
|
+
const logs: string[] = [];
|
|
98
|
+
const files = [{ path: "robodev/database.ts", content: "export default {}" }];
|
|
99
|
+
const deps = stubRun({
|
|
100
|
+
files,
|
|
101
|
+
apply: async () => ({ written: 1, removed: 0 }),
|
|
102
|
+
log: (message) => logs.push(message),
|
|
103
|
+
});
|
|
104
|
+
await runLoad([], deps);
|
|
105
|
+
assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
|
|
106
|
+
assert.deepEqual(logs, ["Loaded 1 files from proj_load."]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("runLoad mentions pruned files in the summary", async () => {
|
|
110
|
+
const logs: string[] = [];
|
|
111
|
+
const deps = stubRun({
|
|
112
|
+
files: [{ path: "robodev/database.ts", content: "ok" }],
|
|
113
|
+
apply: async () => ({ written: 2, removed: 3 }),
|
|
114
|
+
log: (message) => logs.push(message),
|
|
115
|
+
});
|
|
116
|
+
await runLoad([], deps);
|
|
117
|
+
assert.deepEqual(logs, ["Loaded 2 files from proj_load (3 removed)."]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("applyLoadFiles aborts unsafe paths with no writes", async () => {
|
|
121
|
+
await withTemp(async (root) => {
|
|
122
|
+
await mkdir(join(root, "robodev"), { recursive: true });
|
|
123
|
+
await writeFile(join(root, "robodev/database.ts"), "keep");
|
|
124
|
+
const cases: LoadFile[][] = [
|
|
125
|
+
[
|
|
126
|
+
{ path: "robodev/database.ts", content: "new" },
|
|
127
|
+
{ path: "../escape.ts", content: "bad" },
|
|
128
|
+
],
|
|
129
|
+
[{ path: "/tmp/abs.ts", content: "bad" }],
|
|
130
|
+
[{ path: "C:/Windows/x.ts", content: "bad" }],
|
|
131
|
+
[{ path: "robodev//database.ts", content: "bad" }],
|
|
132
|
+
];
|
|
133
|
+
for (const files of cases) {
|
|
134
|
+
await assert.rejects(() => applyLoadFiles(root, files), /Invalid file path:/);
|
|
135
|
+
}
|
|
136
|
+
assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "keep");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("applyLoadFiles skips protected and denied paths", async () => {
|
|
141
|
+
await withTemp(async (root) => {
|
|
142
|
+
await mkdir(join(root, "robodev"), { recursive: true });
|
|
143
|
+
await mkdir(join(root, "apps/fe/src"), { recursive: true });
|
|
144
|
+
await writeFile(join(root, ".env"), "SECRET=1");
|
|
145
|
+
await writeFile(join(root, ".env.local"), "LOCAL=1");
|
|
146
|
+
await writeFile(join(root, "robodev/.robodev"), '{"projectId":"keep"}');
|
|
147
|
+
await writeFile(join(root, "apps/fe/src/App.tsx"), "local-fe");
|
|
148
|
+
const result = await applyLoadFiles(root, [
|
|
149
|
+
{ path: "robodev/database.ts", content: "export default {}" },
|
|
150
|
+
{ path: ".env", content: "SECRET=leaked" },
|
|
151
|
+
{ path: ".env.local", content: "LOCAL=leaked" },
|
|
152
|
+
{ path: "robodev/.robodev", content: '{"projectId":"overwrite"}' },
|
|
153
|
+
{ path: "apps/fe/src/App.tsx", content: "payload-fe" },
|
|
154
|
+
{ path: "apps/fe/package.json", content: "{}" },
|
|
155
|
+
]);
|
|
156
|
+
assert.equal(result.written, 1);
|
|
157
|
+
assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "export default {}");
|
|
158
|
+
assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
|
|
159
|
+
assert.equal(await readFile(join(root, ".env.local"), "utf8"), "LOCAL=1");
|
|
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");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("applyLoadFiles treats an all-skipped payload as empty", async () => {
|
|
166
|
+
await withTemp(async (root) => {
|
|
167
|
+
await writeFile(join(root, ".env"), "SECRET=1");
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() =>
|
|
170
|
+
applyLoadFiles(root, [
|
|
171
|
+
{ path: ".env", content: "nope" },
|
|
172
|
+
{ path: "apps/fe/src/App.tsx", content: "nope" },
|
|
173
|
+
]),
|
|
174
|
+
new Error(NO_DEPLOYMENT_MESSAGE),
|
|
175
|
+
);
|
|
176
|
+
assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("applyLoadFiles overwrites existing allowed files", async () => {
|
|
181
|
+
await withTemp(async (root) => {
|
|
182
|
+
await mkdir(join(root, "robodev/api"), { recursive: true });
|
|
183
|
+
await writeFile(join(root, "robodev/database.ts"), "old-db");
|
|
184
|
+
await writeFile(join(root, "robodev/api/health.ts"), "old-health");
|
|
185
|
+
const result = await applyLoadFiles(root, [
|
|
186
|
+
{ path: "robodev/database.ts", content: "new-db" },
|
|
187
|
+
{ path: "robodev/api/health.ts", content: "new-health" },
|
|
188
|
+
]);
|
|
189
|
+
assert.equal(result.written, 2);
|
|
190
|
+
assert.equal(result.removed, 0);
|
|
191
|
+
assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "new-db");
|
|
192
|
+
assert.equal(await readFile(join(root, "robodev/api/health.ts"), "utf8"), "new-health");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("applyLoadFiles prunes stale collectable files and leaves apps/fe and .env", async () => {
|
|
197
|
+
await withTemp(async (root) => {
|
|
198
|
+
await mkdir(join(root, "robodev/api"), { recursive: true });
|
|
199
|
+
await mkdir(join(root, "apps/fe/src"), { recursive: true });
|
|
200
|
+
await writeFile(join(root, "robodev/database.ts"), "old");
|
|
201
|
+
await writeFile(join(root, "robodev/api/gone.ts"), "stale");
|
|
202
|
+
await writeFile(join(root, "robodev/api/keep.ts"), "old-keep");
|
|
203
|
+
await writeFile(join(root, ".env"), "SECRET=1");
|
|
204
|
+
await writeFile(join(root, "apps/fe/src/App.tsx"), "local-fe");
|
|
205
|
+
const result = await applyLoadFiles(root, [
|
|
206
|
+
{ path: "robodev/database.ts", content: "new-db" },
|
|
207
|
+
{ path: "robodev/api/keep.ts", content: "new-keep" },
|
|
208
|
+
]);
|
|
209
|
+
assert.equal(result.written, 2);
|
|
210
|
+
assert.equal(result.removed, 1);
|
|
211
|
+
assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "new-db");
|
|
212
|
+
assert.equal(await readFile(join(root, "robodev/api/keep.ts"), "utf8"), "new-keep");
|
|
213
|
+
await assert.rejects(() => readFile(join(root, "robodev/api/gone.ts")), /ENOENT/);
|
|
214
|
+
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
|
+
});
|
|
218
|
+
|
|
219
|
+
test("applyLoadFiles decodes public binaries from base64", async () => {
|
|
220
|
+
await withTemp(async (root) => {
|
|
221
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
222
|
+
const result = await applyLoadFiles(root, [
|
|
223
|
+
{ path: "index.html", content: "<html></html>" },
|
|
224
|
+
{ path: "public/icon.png", content: bytes.toString("base64") },
|
|
225
|
+
]);
|
|
226
|
+
assert.equal(result.written, 2);
|
|
227
|
+
assert.deepEqual(await readFile(join(root, "public/icon.png")), bytes);
|
|
228
|
+
assert.equal(await readFile(join(root, "index.html"), "utf8"), "<html></html>");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("applyLoadFiles fails on an empty payload and writes nothing", async () => {
|
|
233
|
+
await withTemp(async (root) => {
|
|
234
|
+
await writeFile(join(root, "marker.txt"), "keep");
|
|
235
|
+
await assert.rejects(() => applyLoadFiles(root, []), new Error(NO_DEPLOYMENT_MESSAGE));
|
|
236
|
+
assert.equal(await readFile(join(root, "marker.txt"), "utf8"), "keep");
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { authed } from "./api.js";
|
|
4
|
+
import { isPublicBinaryCollectedPath, shouldCollectPath } from "./collect-files.js";
|
|
5
|
+
import { readLink, type ProjectLink } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export const NO_LINK_MESSAGE =
|
|
8
|
+
"No robodev/.robodev file. Run `robodev link` or `robodev create` first.";
|
|
9
|
+
export const NO_DEPLOYMENT_MESSAGE =
|
|
10
|
+
"No deployment for this project. Deploy from the dashboard or run `robodev deploy` first.";
|
|
11
|
+
|
|
12
|
+
const PRUNE_SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev", "apps"]);
|
|
13
|
+
|
|
14
|
+
export type LoadFile = { path: string; content: string };
|
|
15
|
+
|
|
16
|
+
export type LoadApplyResult = { written: number; removed: number };
|
|
17
|
+
|
|
18
|
+
export type LoadDeps = {
|
|
19
|
+
readLink: (cwd?: string) => Promise<ProjectLink | null>;
|
|
20
|
+
authed: <T>(path: string, init?: RequestInit) => Promise<T>;
|
|
21
|
+
apply: (root: string, files: LoadFile[]) => Promise<LoadApplyResult>;
|
|
22
|
+
cwd?: () => string;
|
|
23
|
+
log?: (message: string) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function parseLoadArgs(args: string[]): void {
|
|
27
|
+
for (const arg of args) {
|
|
28
|
+
if (arg.startsWith("-")) {
|
|
29
|
+
throw new Error(`Unknown flag ${arg}`);
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Unexpected argument ${arg}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalize(path: string): string {
|
|
36
|
+
return path.replaceAll("\\", "/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Same conditions as collect-files `isUnsafe`. */
|
|
40
|
+
function isUnsafePath(path: string): boolean {
|
|
41
|
+
return (
|
|
42
|
+
path.startsWith("/") ||
|
|
43
|
+
path.includes("..") ||
|
|
44
|
+
path.split("/").some((segment) => segment === "") ||
|
|
45
|
+
/^[A-Za-z]:/.test(path)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isProtectedPath(path: string): boolean {
|
|
50
|
+
if (path === "robodev/.robodev" || path === ".robodev" || path.startsWith(".robodev/")) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (path === ".env" || path.startsWith(".env.")) return true;
|
|
54
|
+
if (path === "credentials.json" || path.endsWith("/credentials.json")) return true;
|
|
55
|
+
if (path === "apps" || path.startsWith("apps/")) return true;
|
|
56
|
+
if (path === "node_modules" || path.startsWith("node_modules/")) return true;
|
|
57
|
+
if (path === ".git" || path.startsWith(".git/")) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveUnderRoot(root: string, rel: string): string | null {
|
|
62
|
+
const rootResolved = resolve(root);
|
|
63
|
+
const resolved = resolve(rootResolved, rel);
|
|
64
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : `${rootResolved}${sep}`;
|
|
65
|
+
if (resolved !== rootResolved && !resolved.startsWith(prefix)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Classified =
|
|
72
|
+
| { kind: "unsafe"; path: string }
|
|
73
|
+
| { kind: "denied"; path: string }
|
|
74
|
+
| { kind: "allowed"; path: string; content: string; abs: string };
|
|
75
|
+
|
|
76
|
+
export function classifyLoadFiles(root: string, files: LoadFile[]): Classified[] {
|
|
77
|
+
return files.map((file) => {
|
|
78
|
+
const path = normalize(file.path);
|
|
79
|
+
if (isUnsafePath(path)) return { kind: "unsafe", path };
|
|
80
|
+
const abs = resolveUnderRoot(root, path);
|
|
81
|
+
if (!abs) return { kind: "unsafe", path };
|
|
82
|
+
if (isProtectedPath(path) || !shouldCollectPath(path)) return { kind: "denied", path };
|
|
83
|
+
return { kind: "allowed", path, content: file.content, abs };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listCollectablePaths(root: string): Promise<string[]> {
|
|
88
|
+
const paths: string[] = [];
|
|
89
|
+
|
|
90
|
+
async function walk(dir: string): Promise<void> {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const abs = join(dir, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
if (PRUNE_SKIP_DIRS.has(entry.name)) continue;
|
|
101
|
+
await walk(abs);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!entry.isFile()) continue;
|
|
105
|
+
const path = normalize(relative(root, abs));
|
|
106
|
+
if (!shouldCollectPath(path)) continue;
|
|
107
|
+
paths.push(path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await walk(root);
|
|
112
|
+
return paths;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function applyLoadFiles(root: string, files: LoadFile[]): Promise<LoadApplyResult> {
|
|
116
|
+
const classified = classifyLoadFiles(root, files);
|
|
117
|
+
const unsafe = classified.find((item) => item.kind === "unsafe");
|
|
118
|
+
if (unsafe) {
|
|
119
|
+
throw new Error(`Invalid file path: ${unsafe.path}`);
|
|
120
|
+
}
|
|
121
|
+
const allowed = classified.filter(
|
|
122
|
+
(item): item is Extract<Classified, { kind: "allowed" }> => item.kind === "allowed",
|
|
123
|
+
);
|
|
124
|
+
if (allowed.length === 0) {
|
|
125
|
+
throw new Error(NO_DEPLOYMENT_MESSAGE);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const file of allowed) {
|
|
129
|
+
await mkdir(dirname(file.abs), { recursive: true });
|
|
130
|
+
if (isPublicBinaryCollectedPath(file.path)) {
|
|
131
|
+
await writeFile(file.abs, Buffer.from(file.content, "base64"));
|
|
132
|
+
} else {
|
|
133
|
+
await writeFile(file.abs, file.content, "utf8");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const keep = new Set(allowed.map((file) => file.path));
|
|
138
|
+
let removed = 0;
|
|
139
|
+
for (const path of await listCollectablePaths(root)) {
|
|
140
|
+
if (keep.has(path)) continue;
|
|
141
|
+
const abs = resolveUnderRoot(root, path);
|
|
142
|
+
if (!abs) continue;
|
|
143
|
+
await unlink(abs);
|
|
144
|
+
removed += 1;
|
|
145
|
+
}
|
|
146
|
+
return { written: allowed.length, removed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const defaultDeps: LoadDeps = {
|
|
150
|
+
readLink,
|
|
151
|
+
authed,
|
|
152
|
+
apply: applyLoadFiles,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export async function runLoad(args: string[], deps: LoadDeps = defaultDeps): Promise<void> {
|
|
156
|
+
parseLoadArgs(args);
|
|
157
|
+
const cwd = deps.cwd?.() ?? process.cwd();
|
|
158
|
+
const log = deps.log ?? console.log;
|
|
159
|
+
const link = await deps.readLink(cwd);
|
|
160
|
+
if (!link) {
|
|
161
|
+
throw new Error(NO_LINK_MESSAGE);
|
|
162
|
+
}
|
|
163
|
+
const body = await deps.authed<{ files?: LoadFile[] }>(
|
|
164
|
+
`/v1/projects/${link.projectId}/deployment/files`,
|
|
165
|
+
);
|
|
166
|
+
const files = body.files ?? [];
|
|
167
|
+
if (files.length === 0) {
|
|
168
|
+
throw new Error(NO_DEPLOYMENT_MESSAGE);
|
|
169
|
+
}
|
|
170
|
+
const { written, removed } = await deps.apply(cwd, files);
|
|
171
|
+
if (removed > 0) {
|
|
172
|
+
log(`Loaded ${written} files from ${link.projectId} (${removed} removed).`);
|
|
173
|
+
} else {
|
|
174
|
+
log(`Loaded ${written} files from ${link.projectId}.`);
|
|
175
|
+
}
|
|
176
|
+
}
|