robodev 0.14.0 → 0.15.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.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ 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 } from "./collect-files.js";
7
+ import { collectFiles, mergeCollectedFiles } from "./collect-files.js";
8
8
 
9
9
  test("collectFiles includes hosted sources and skips denied paths", async () => {
10
10
  const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
@@ -60,6 +60,15 @@ test("collectFiles skips nested apps/fe sources", async () => {
60
60
  assert.ok(!paths.has("apps/fe/src/main.tsx"));
61
61
  assert.ok(!paths.has("apps/fe/index.html"));
62
62
  assert.ok(!paths.has("apps/fe/package.json"));
63
+ const merged = mergeCollectedFiles(files, [
64
+ { path: "index.html", content: "<html>built</html>" },
65
+ { path: "public/assets/app.js", content: "console.log(1)" },
66
+ ]);
67
+ const mergedPaths = new Set(merged.map((file) => file.path));
68
+ assert.ok(mergedPaths.has("index.html"));
69
+ assert.ok(mergedPaths.has("public/assets/app.js"));
70
+ assert.ok(!mergedPaths.has("apps/fe/src/main.tsx"));
71
+ assert.equal(merged.find((file) => file.path === "index.html")?.content, "<html>built</html>");
63
72
  } finally {
64
73
  await rm(root, { recursive: true, force: true });
65
74
  }
@@ -3,6 +3,8 @@ import { join, relative } from "node:path";
3
3
 
4
4
  export const MAX_FILE_COUNT = 200;
5
5
  export const MAX_FILE_BYTES = 1024 * 1024;
6
+ /** Hashed Vite JS from Tiny can exceed 1MB; keep API/source at 1MB. */
7
+ export const MAX_STATIC_FILE_BYTES = 4 * 1024 * 1024;
6
8
  export const MAX_TREE_BYTES = 15 * 1024 * 1024;
7
9
  export const MAX_PATH_LENGTH = 240;
8
10
  export const MAX_PATH_DEPTH = 12;
@@ -112,6 +114,66 @@ export function shouldCollectPath(path: string): boolean {
112
114
  return !isUnsafe(normalized) && !isDenied(normalized) && isAllowed(normalized);
113
115
  }
114
116
 
117
+ export function isStaticCollectedPath(path: string): boolean {
118
+ const normalized = normalize(path);
119
+ if (normalized === "index.html") return true;
120
+ if (normalized.startsWith("public/")) return true;
121
+ if (!normalized.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(normalized))) return true;
122
+ return false;
123
+ }
124
+
125
+ export function maxBytesForCollectedPath(path: string): number {
126
+ return isStaticCollectedPath(path) ? MAX_STATIC_FILE_BYTES : MAX_FILE_BYTES;
127
+ }
128
+
129
+ function formatMaxBytes(bytes: number): string {
130
+ return `${Math.round(bytes / (1024 * 1024))}MB`;
131
+ }
132
+
133
+ export function assertCollectedFile(file: CollectedFile): void {
134
+ const path = normalize(file.path);
135
+ if (
136
+ isUnsafe(path) ||
137
+ !shouldCollectPath(path) ||
138
+ path.length > MAX_PATH_LENGTH ||
139
+ path.split("/").length > MAX_PATH_DEPTH
140
+ ) {
141
+ throw new Error(`Invalid file path: ${path}`);
142
+ }
143
+ const bytes = Buffer.byteLength(file.content, "utf8");
144
+ const max = maxBytesForCollectedPath(path);
145
+ if (bytes > max) {
146
+ throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
147
+ }
148
+ }
149
+
150
+ export function assertCollectedLimits(files: CollectedFile[]): void {
151
+ if (files.length > MAX_FILE_COUNT) {
152
+ throw new Error(`Deploy may include at most ${MAX_FILE_COUNT} files`);
153
+ }
154
+ let totalBytes = 0;
155
+ for (const file of files) {
156
+ assertCollectedFile(file);
157
+ totalBytes += Buffer.byteLength(file.content, "utf8");
158
+ if (totalBytes > MAX_TREE_BYTES) {
159
+ throw new Error("Deploy tree must be under 15MB");
160
+ }
161
+ }
162
+ }
163
+
164
+ export function mergeCollectedFiles(
165
+ base: CollectedFile[],
166
+ extra: CollectedFile[],
167
+ ): CollectedFile[] {
168
+ const map = new Map(base.map((file) => [normalize(file.path), file]));
169
+ for (const file of extra) {
170
+ map.set(normalize(file.path), file);
171
+ }
172
+ const files = [...map.values()];
173
+ assertCollectedLimits(files);
174
+ return files;
175
+ }
176
+
115
177
  export async function collectFiles(root: string): Promise<CollectedFile[]> {
116
178
  try {
117
179
  await stat(join(root, "database.ts"));
@@ -144,8 +206,9 @@ export async function collectFiles(root: string): Promise<CollectedFile[]> {
144
206
  }
145
207
  const content = await readFile(abs, "utf8");
146
208
  const bytes = Buffer.byteLength(content, "utf8");
147
- if (bytes > MAX_FILE_BYTES) {
148
- throw new Error(`File must be under 1MB: ${path}`);
209
+ const max = maxBytesForCollectedPath(path);
210
+ if (bytes > max) {
211
+ throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
149
212
  }
150
213
  totalBytes += bytes;
151
214
  if (totalBytes > MAX_TREE_BYTES) {
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "node:test";
6
- import { writeViteApiUrl } from "./config.js";
6
+ import { defaultFrontendConfig, readLink, writeLink, writeViteApiUrl } from "./config.js";
7
7
 
8
8
  test("writeViteApiUrl sets Vite and Povio public API URLs", async () => {
9
9
  const root = await mkdtemp(join(tmpdir(), "rd-env-"));
@@ -18,8 +18,76 @@ test("writeViteApiUrl sets Vite and Povio public API URLs", async () => {
18
18
  assert.match(env, /^VITE_API_URL=https:\/\/prj\.example\.test$/m);
19
19
  assert.match(env, /^VITE_PUBLIC_API_URL=https:\/\/prj\.example\.test$/m);
20
20
  assert.match(env, /^APP_PUBLIC_API_URL=https:\/\/prj\.example\.test$/m);
21
+ const template = await readFile(join(root, ".config/local.spa.template.yml"), "utf8");
22
+ assert.match(
23
+ template,
24
+ /APP_PUBLIC_API_URL: &APP_PUBLIC_API_URL "https:\/\/prj\.example\.test"/,
25
+ );
21
26
  const spa = await readFile(join(root, ".config/local.spa.yml"), "utf8");
22
- assert.match(spa, /APP_PUBLIC_API_URL: &APP_PUBLIC_API_URL "https:\/\/prj\.example\.test"/);
27
+ assert.match(spa, /^\s*configs\s*:/m);
28
+ assert.match(spa, /templateModule:\s*spa\.template/);
29
+ } finally {
30
+ await rm(root, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test("writeLink merges frontend and unknown keys", async () => {
35
+ const root = await mkdtemp(join(tmpdir(), "rd-link-"));
36
+ try {
37
+ await writeFile(
38
+ join(root, ".robodev"),
39
+ `${JSON.stringify(
40
+ {
41
+ projectId: "prj-old",
42
+ apiUrl: "https://old.example",
43
+ frontend: defaultFrontendConfig(),
44
+ extra: { keep: true },
45
+ },
46
+ null,
47
+ 2,
48
+ )}\n`,
49
+ );
50
+ await writeLink(
51
+ {
52
+ projectId: "prj-new",
53
+ apiUrl: "https://new.example",
54
+ projectApiUrl: "https://prj-new.example",
55
+ },
56
+ root,
57
+ );
58
+ const raw = JSON.parse(await readFile(join(root, ".robodev"), "utf8")) as {
59
+ projectId: string;
60
+ apiUrl: string;
61
+ projectApiUrl: string;
62
+ frontend: { root: string; script: string; outDir: string };
63
+ extra: { keep: boolean };
64
+ };
65
+ assert.equal(raw.projectId, "prj-new");
66
+ assert.equal(raw.apiUrl, "https://new.example");
67
+ assert.equal(raw.projectApiUrl, "https://prj-new.example");
68
+ assert.deepEqual(raw.frontend, defaultFrontendConfig());
69
+ assert.deepEqual(raw.extra, { keep: true });
70
+ const link = await readLink(root);
71
+ assert.deepEqual(link?.frontend, defaultFrontendConfig());
72
+ } finally {
73
+ await rm(root, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("writeLink preserves frontend false opt-out", async () => {
78
+ const root = await mkdtemp(join(tmpdir(), "rd-link-off-"));
79
+ try {
80
+ await writeFile(
81
+ join(root, ".robodev"),
82
+ `${JSON.stringify({ projectId: "prj-1", apiUrl: "https://a.example", frontend: false }, null, 2)}\n`,
83
+ );
84
+ await writeLink({ projectId: "prj-1", apiUrl: "https://b.example" }, root);
85
+ const raw = JSON.parse(await readFile(join(root, ".robodev"), "utf8")) as {
86
+ frontend: unknown;
87
+ };
88
+ assert.equal(raw.frontend, false);
89
+ const link = await readLink(root);
90
+ assert.equal(link?.frontend, false);
23
91
  } finally {
24
92
  await rm(root, { recursive: true, force: true });
25
93
  }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
@@ -7,14 +7,27 @@ export type Credentials = {
7
7
  refreshToken: string;
8
8
  };
9
9
 
10
+ export type FrontendConfig = {
11
+ root: string;
12
+ script?: string;
13
+ outDir: string;
14
+ };
15
+
10
16
  export type ProjectLink = {
11
17
  projectId: string;
12
18
  apiUrl: string;
13
19
  projectApiUrl?: string;
20
+ frontend?: FrontendConfig | false;
14
21
  };
15
22
 
16
23
  const PROD_URL = "https://robodev.povio.dev";
17
24
 
25
+ const SPA_MANIFEST = `configs:
26
+ values:
27
+ - name: "@"
28
+ templateModule: spa.template
29
+ `;
30
+
18
31
  /** Control-plane API. Override with `STARBASE_URL` for local Starbase. */
19
32
  export function starbaseUrl(): string {
20
33
  return (process.env.STARBASE_URL ?? PROD_URL).replace(/\/$/, "");
@@ -35,6 +48,37 @@ export function linkPath(cwd = process.cwd()): string {
35
48
  return join(cwd, ".robodev");
36
49
  }
37
50
 
51
+ export function defaultFrontendConfig(): FrontendConfig {
52
+ return { root: "apps/fe", script: "build", outDir: "dist" };
53
+ }
54
+
55
+ export function isFrontendConfig(value: unknown): value is FrontendConfig {
56
+ return (
57
+ typeof value === "object" &&
58
+ value !== null &&
59
+ !Array.isArray(value) &&
60
+ typeof (value as FrontendConfig).root === "string" &&
61
+ typeof (value as FrontendConfig).outDir === "string"
62
+ );
63
+ }
64
+
65
+ export function normalizeFrontendConfig(frontend: FrontendConfig): Required<FrontendConfig> {
66
+ const script = frontend.script?.trim();
67
+ return {
68
+ root: frontend.root,
69
+ script: script && script.length > 0 ? script : "build",
70
+ outDir: frontend.outDir,
71
+ };
72
+ }
73
+
74
+ export async function hasAppsFePackageJson(root: string): Promise<boolean> {
75
+ try {
76
+ return (await stat(join(root, "apps/fe/package.json"))).isFile();
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
38
82
  export async function readCredentials(): Promise<Credentials | null> {
39
83
  try {
40
84
  return JSON.parse(await readFile(credentialsPath(), "utf8")) as Credentials;
@@ -53,16 +97,50 @@ export async function clearCredentials(): Promise<void> {
53
97
  await rm(credentialsPath(), { force: true });
54
98
  }
55
99
 
56
- export async function readLink(cwd = process.cwd()): Promise<ProjectLink | null> {
100
+ export async function readLinkJson(cwd = process.cwd()): Promise<Record<string, unknown> | null> {
57
101
  try {
58
- return JSON.parse(await readFile(linkPath(cwd), "utf8")) as ProjectLink;
102
+ const parsed = JSON.parse(await readFile(linkPath(cwd), "utf8")) as unknown;
103
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
104
+ return parsed as Record<string, unknown>;
105
+ }
106
+ return null;
59
107
  } catch {
60
108
  return null;
61
109
  }
62
110
  }
63
111
 
112
+ export async function readLink(cwd = process.cwd()): Promise<ProjectLink | null> {
113
+ const raw = await readLinkJson(cwd);
114
+ if (!raw || typeof raw.projectId !== "string" || typeof raw.apiUrl !== "string") {
115
+ return null;
116
+ }
117
+ const link: ProjectLink = {
118
+ projectId: raw.projectId,
119
+ apiUrl: raw.apiUrl,
120
+ };
121
+ if (typeof raw.projectApiUrl === "string") {
122
+ link.projectApiUrl = raw.projectApiUrl;
123
+ }
124
+ if (raw.frontend === false || isFrontendConfig(raw.frontend)) {
125
+ link.frontend = raw.frontend;
126
+ }
127
+ return link;
128
+ }
129
+
64
130
  export async function writeLink(link: ProjectLink, cwd = process.cwd()): Promise<void> {
65
- await writeFile(linkPath(cwd), `${JSON.stringify(link, null, 2)}\n`);
131
+ const existing = (await readLinkJson(cwd)) ?? {};
132
+ const next: Record<string, unknown> = {
133
+ ...existing,
134
+ projectId: link.projectId,
135
+ apiUrl: link.apiUrl,
136
+ };
137
+ if (link.projectApiUrl !== undefined) {
138
+ next.projectApiUrl = link.projectApiUrl;
139
+ }
140
+ if ("frontend" in link) {
141
+ next.frontend = link.frontend;
142
+ }
143
+ await writeFile(linkPath(cwd), `${JSON.stringify(next, null, 2)}\n`);
66
144
  }
67
145
 
68
146
  const PROJECT_API_ENV_KEYS = ["VITE_API_URL", "VITE_PUBLIC_API_URL", "APP_PUBLIC_API_URL"] as const;
@@ -75,6 +153,17 @@ function upsertEnvKey(content: string, key: string, value: string): string {
75
153
  return `${trimmed}${trimmed ? "\n" : ""}${line}\n`;
76
154
  }
77
155
 
156
+ function isSpaManifest(content: string): boolean {
157
+ return /^\s*configs\s*:/m.test(content);
158
+ }
159
+
160
+ function rewriteApiUrlInSpaValues(source: string, projectApiUrl: string): string {
161
+ const quoted = JSON.stringify(projectApiUrl);
162
+ const next = source.replace(/^(APP_PUBLIC_API_URL:\s*&APP_PUBLIC_API_URL\s+).+$/m, `$1${quoted}`);
163
+ if (next !== source) return next;
164
+ return source.replace(/^(VITE_PUBLIC_API_URL:\s+)(?!\*).+$/m, `$1${quoted}`);
165
+ }
166
+
78
167
  /** Sets Vite / Povio API URLs so starter UIs talk to the deployed project host. */
79
168
  export async function writeViteApiUrl(projectApiUrl: string, cwd = process.cwd()): Promise<void> {
80
169
  const envPath = join(cwd, ".env");
@@ -95,18 +184,32 @@ async function rewriteLocalSpaConfig(projectApiUrl: string, cwd: string): Promis
95
184
  const configDir = join(cwd, ".config");
96
185
  const templatePath = join(configDir, "local.spa.template.yml");
97
186
  const destPath = join(configDir, "local.spa.yml");
98
- let source: string;
187
+ let template: string | null = null;
99
188
  try {
100
- source = await readFile(templatePath, "utf8");
189
+ template = await readFile(templatePath, "utf8");
101
190
  } catch {
102
- try {
103
- source = await readFile(destPath, "utf8");
104
- } catch {
105
- return;
106
- }
191
+ template = null;
107
192
  }
108
- const quoted = JSON.stringify(projectApiUrl);
109
- const next = source.replace(/^(APP_PUBLIC_API_URL:\s*&APP_PUBLIC_API_URL\s+).+$/m, `$1${quoted}`);
193
+ let dest: string | null = null;
194
+ try {
195
+ dest = await readFile(destPath, "utf8");
196
+ } catch {
197
+ dest = null;
198
+ }
199
+ if (!template && !dest) return;
200
+
110
201
  await mkdir(configDir, { recursive: true });
111
- await writeFile(destPath, next.endsWith("\n") ? next : `${next}\n`);
202
+ if (template) {
203
+ const next = rewriteApiUrlInSpaValues(template, projectApiUrl);
204
+ if (next !== template) {
205
+ await writeFile(templatePath, next.endsWith("\n") ? next : `${next}\n`);
206
+ }
207
+ } else if (dest && !isSpaManifest(dest)) {
208
+ const next = rewriteApiUrlInSpaValues(dest, projectApiUrl);
209
+ await writeFile(templatePath, next.endsWith("\n") ? next : `${next}\n`);
210
+ }
211
+
212
+ if (!dest || !isSpaManifest(dest)) {
213
+ await writeFile(destPath, SPA_MANIFEST);
214
+ }
112
215
  }
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ mapFrontendOutDir,
8
+ resolveFrontendPaths,
9
+ sanitizeProjectRelative,
10
+ } from "./frontend-build.js";
11
+
12
+ test("sanitizeProjectRelative rejects parent and absolute paths", () => {
13
+ const root = resolve("/tmp/project");
14
+ assert.throws(() => sanitizeProjectRelative(root, "../fe", "root"), /Invalid frontend\.root/);
15
+ assert.throws(() => sanitizeProjectRelative(root, "/abs", "root"), /Invalid frontend\.root/);
16
+ assert.throws(
17
+ () => sanitizeProjectRelative(root, "apps/fe/../../etc", "root"),
18
+ /Invalid frontend\.root/,
19
+ );
20
+ assert.equal(sanitizeProjectRelative(root, "apps/fe", "root"), resolve(root, "apps/fe"));
21
+ });
22
+
23
+ test("resolveFrontendPaths keeps outDir inside the project", () => {
24
+ const root = resolve("/tmp/project");
25
+ const resolved = resolveFrontendPaths(root, { root: "apps/fe", script: "build", outDir: "dist" });
26
+ assert.equal(resolved.rootAbs, resolve(root, "apps/fe"));
27
+ assert.equal(resolved.outAbs, resolve(root, "apps/fe/dist"));
28
+ assert.throws(
29
+ () =>
30
+ resolveFrontendPaths(root, { root: "apps/fe", script: "build && rm -rf /", outDir: "dist" }),
31
+ /Invalid frontend\.script/,
32
+ );
33
+ });
34
+
35
+ test("mapFrontendOutDir remaps text files and skips binaries", async () => {
36
+ const out = await mkdtemp(join(tmpdir(), "rd-out-"));
37
+ try {
38
+ await writeFile(join(out, "index.html"), "<html><body>app</body></html>");
39
+ await mkdir(join(out, "assets"), { recursive: true });
40
+ await writeFile(join(out, "assets/index-abc.js"), "console.log(1)");
41
+ await writeFile(join(out, "assets/index-abc.css"), "body{color:red}");
42
+ await writeFile(join(out, "assets/font.woff2"), "binary-font");
43
+ await writeFile(join(out, "icon.png"), "binary-png");
44
+ const { files, skippedBinaries } = await mapFrontendOutDir(out);
45
+ const paths = new Set(files.map((file) => file.path));
46
+ assert.ok(paths.has("index.html"));
47
+ assert.ok(paths.has("public/assets/index-abc.js"));
48
+ assert.ok(paths.has("public/assets/index-abc.css"));
49
+ assert.ok(!paths.has("public/assets/font.woff2"));
50
+ assert.ok(!paths.has("public/icon.png"));
51
+ assert.equal(skippedBinaries, true);
52
+ assert.equal(
53
+ files.find((file) => file.path === "index.html")?.content,
54
+ "<html><body>app</body></html>",
55
+ );
56
+ } finally {
57
+ await rm(out, { recursive: true, force: true });
58
+ }
59
+ });
@@ -0,0 +1,212 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { assertCollectedFile, type CollectedFile, shouldCollectPath } from "./collect-files.js";
6
+ import { isFrontendConfig, normalizeFrontendConfig, type FrontendConfig } from "./config.js";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export type FrontendBuildOptions = {
11
+ install: "always" | "if-needed";
12
+ };
13
+
14
+ export function sanitizeProjectRelative(projectRoot: string, rel: string, label: string): string {
15
+ const raw = rel.trim();
16
+ const normalized = raw.replaceAll("\\", "/");
17
+ if (
18
+ !normalized ||
19
+ normalized.includes("..") ||
20
+ isAbsolute(raw) ||
21
+ normalized.startsWith("/") ||
22
+ /^[A-Za-z]:/.test(raw)
23
+ ) {
24
+ throw new Error(`Invalid frontend.${label}: ${rel}`);
25
+ }
26
+ const abs = resolve(projectRoot, normalized);
27
+ const relToRoot = relative(resolve(projectRoot), abs);
28
+ if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) {
29
+ throw new Error(`frontend.${label} must stay inside the project: ${rel}`);
30
+ }
31
+ return abs;
32
+ }
33
+
34
+ export function resolveFrontendPaths(
35
+ projectRoot: string,
36
+ frontend: FrontendConfig,
37
+ ): { rootAbs: string; outAbs: string; config: Required<FrontendConfig> } {
38
+ const config = normalizeFrontendConfig(frontend);
39
+ const rootAbs = sanitizeProjectRelative(projectRoot, config.root, "root");
40
+ const outAbs = sanitizeProjectRelative(rootAbs, config.outDir, "outDir");
41
+ const outRelToProject = relative(resolve(projectRoot), outAbs);
42
+ if (outRelToProject.startsWith("..") || isAbsolute(outRelToProject)) {
43
+ throw new Error(`frontend.outDir must stay inside the project: ${config.outDir}`);
44
+ }
45
+ if (!/^[A-Za-z0-9:_-]+$/.test(config.script)) {
46
+ throw new Error(`Invalid frontend.script: ${config.script}`);
47
+ }
48
+ return { rootAbs, outAbs, config };
49
+ }
50
+
51
+ export async function commandAvailable(command: string): Promise<boolean> {
52
+ try {
53
+ await execFileAsync(command, ["--version"]);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ async function runCommand(
61
+ command: string,
62
+ args: string[],
63
+ cwd: string,
64
+ label: string,
65
+ ): Promise<void> {
66
+ const stdoutChunks: Buffer[] = [];
67
+ const stderrChunks: Buffer[] = [];
68
+ await new Promise<void>((resolvePromise, reject) => {
69
+ const child = spawn(command, args, {
70
+ cwd,
71
+ env: process.env,
72
+ shell: false,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ });
75
+ child.stdout?.on("data", (chunk: Buffer) => {
76
+ stdoutChunks.push(chunk);
77
+ process.stdout.write(chunk);
78
+ });
79
+ child.stderr?.on("data", (chunk: Buffer) => {
80
+ stderrChunks.push(chunk);
81
+ process.stderr.write(chunk);
82
+ });
83
+ child.on("error", reject);
84
+ child.on("exit", (code) => {
85
+ if (code === 0) {
86
+ resolvePromise();
87
+ return;
88
+ }
89
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim();
90
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
91
+ const detail = [stderr, stdout].filter(Boolean).join("\n");
92
+ reject(
93
+ new Error(detail ? `${label} failed (${code})\n${detail}` : `${label} failed (${code})`),
94
+ );
95
+ });
96
+ });
97
+ }
98
+
99
+ async function packageScripts(rootAbs: string): Promise<Record<string, string>> {
100
+ const pkgPath = join(rootAbs, "package.json");
101
+ try {
102
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as { scripts?: Record<string, string> };
103
+ return pkg.scripts ?? {};
104
+ } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
106
+ throw new Error(`Missing frontend package.json at ${pkgPath}`);
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ async function ensureInstalled(
113
+ rootAbs: string,
114
+ mode: FrontendBuildOptions["install"],
115
+ ): Promise<void> {
116
+ if (mode === "if-needed") {
117
+ try {
118
+ if ((await stat(join(rootAbs, "node_modules"))).isDirectory()) return;
119
+ } catch {
120
+ // install
121
+ }
122
+ }
123
+ const bun = await commandAvailable("bun");
124
+ const command = bun ? "bun" : "npm";
125
+ console.log(`Installing frontend packages with ${command}…`);
126
+ await runCommand(command, ["install"], rootAbs, `${command} install`);
127
+ }
128
+
129
+ function mappedDeployPath(relFromOutDir: string): string {
130
+ const rel = relFromOutDir.replaceAll("\\", "/");
131
+ if (rel === "index.html") return "index.html";
132
+ return `public/${rel}`;
133
+ }
134
+
135
+ export async function mapFrontendOutDir(outDirAbs: string): Promise<{
136
+ files: CollectedFile[];
137
+ skippedBinaries: boolean;
138
+ }> {
139
+ const files: CollectedFile[] = [];
140
+ let skippedBinaries = false;
141
+
142
+ async function walk(dir: string): Promise<void> {
143
+ let entries;
144
+ try {
145
+ entries = await readdir(dir, { withFileTypes: true });
146
+ } catch {
147
+ return;
148
+ }
149
+ for (const entry of entries) {
150
+ const abs = join(dir, entry.name);
151
+ if (entry.isDirectory()) {
152
+ await walk(abs);
153
+ continue;
154
+ }
155
+ if (!entry.isFile()) continue;
156
+ const rel = relative(outDirAbs, abs).replaceAll("\\", "/");
157
+ const deployPath = mappedDeployPath(rel);
158
+ if (!shouldCollectPath(deployPath)) {
159
+ skippedBinaries = true;
160
+ continue;
161
+ }
162
+ const content = await readFile(abs, "utf8");
163
+ const file = { path: deployPath, content };
164
+ assertCollectedFile(file);
165
+ files.push(file);
166
+ }
167
+ }
168
+
169
+ await walk(outDirAbs);
170
+ return { files, skippedBinaries };
171
+ }
172
+
173
+ export async function buildAndCollectFrontend(
174
+ projectRoot: string,
175
+ frontend: FrontendConfig,
176
+ options: FrontendBuildOptions,
177
+ ): Promise<CollectedFile[]> {
178
+ if (!isFrontendConfig(frontend)) {
179
+ throw new Error("Invalid frontend config in .robodev");
180
+ }
181
+ const { rootAbs, outAbs, config } = resolveFrontendPaths(projectRoot, frontend);
182
+ const scripts = await packageScripts(rootAbs);
183
+ if (!(config.script in scripts)) {
184
+ throw new Error(
185
+ `package.json has no "${config.script}" script at ${join(rootAbs, "package.json")}`,
186
+ );
187
+ }
188
+
189
+ await ensureInstalled(rootAbs, options.install);
190
+
191
+ const bun = await commandAvailable("bun");
192
+ const command = bun ? "bun" : "npm";
193
+ console.log(`Building frontend (${command} run ${config.script})…`);
194
+ await runCommand(command, ["run", config.script], rootAbs, `${command} run ${config.script}`);
195
+
196
+ try {
197
+ await stat(join(outAbs, "index.html"));
198
+ } catch {
199
+ throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
200
+ }
201
+
202
+ const { files, skippedBinaries } = await mapFrontendOutDir(outAbs);
203
+ if (!files.some((file) => file.path === "index.html")) {
204
+ throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
205
+ }
206
+ if (skippedBinaries) {
207
+ console.log(
208
+ "Skipped binary frontend assets (fonts/images). Hosted UI may use system font fallback.",
209
+ );
210
+ }
211
+ return files;
212
+ }
package/src/index.ts CHANGED
@@ -9,15 +9,21 @@ import { promisify } from "node:util";
9
9
  import { ApiError, authed, publicRequest } from "./api.js";
10
10
  import {
11
11
  clearCredentials,
12
+ defaultFrontendConfig,
12
13
  feUrl,
14
+ hasAppsFePackageJson,
15
+ isFrontendConfig,
16
+ type ProjectLink,
13
17
  readLink,
18
+ readLinkJson,
14
19
  starbaseUrl,
15
20
  writeCredentials,
16
21
  writeLink,
17
22
  writeViteApiUrl,
18
23
  } from "./config.js";
19
24
  import { waitUntilLive } from "./wait-until-live.js";
20
- import { collectFiles } from "./collect-files.js";
25
+ import { collectFiles, mergeCollectedFiles, type CollectedFile } from "./collect-files.js";
26
+ import { buildAndCollectFrontend, commandAvailable } from "./frontend-build.js";
21
27
  import {
22
28
  emitStarter,
23
29
  loadCatalog,
@@ -41,7 +47,7 @@ Commands:
41
47
  projects List your projects
42
48
  create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
43
49
  link [projectId] Write .robodev in the current folder
44
- deploy [--force] Deploy database.ts, api/*.ts, jobs, and frontend files (no Vite / npm run build)
50
+ deploy [--force] Deploy schema, APIs, jobs, and frontend (local Vite build when .robodev frontend is set)
45
51
  `);
46
52
  process.exit(1);
47
53
  }
@@ -184,23 +190,53 @@ async function runLink(projectId?: string): Promise<void> {
184
190
  if (!project) {
185
191
  throw new Error(`Project ${chosen} not found or not yours`);
186
192
  }
187
- await writeLink({
193
+ const existing = await readLinkJson();
194
+ const link: ProjectLink = {
188
195
  projectId: project.id,
189
196
  apiUrl: starbaseUrl(),
190
197
  projectApiUrl: project.apiUrl,
191
- });
198
+ };
199
+ if ((!existing || !("frontend" in existing)) && (await hasAppsFePackageJson(process.cwd()))) {
200
+ link.frontend = defaultFrontendConfig();
201
+ }
202
+ await writeLink(link);
192
203
  await writeViteApiUrl(project.apiUrl);
193
204
  console.log(`Linked ${process.cwd()} to ${project.id}`);
194
205
  console.log(`Frontend API ${project.apiUrl}`);
195
206
  }
196
207
 
208
+ type DeployOptions = {
209
+ root?: string;
210
+ frontendFiles?: CollectedFile[];
211
+ skipFrontendBuild?: boolean;
212
+ };
213
+
214
+ async function collectDeployFiles(
215
+ root: string,
216
+ link: ProjectLink,
217
+ options: DeployOptions,
218
+ ): Promise<CollectedFile[]> {
219
+ const collected = await collectFiles(root);
220
+ if (options.frontendFiles) {
221
+ return mergeCollectedFiles(collected, options.frontendFiles);
222
+ }
223
+ if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
224
+ return collected;
225
+ }
226
+ const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
227
+ install: "if-needed",
228
+ });
229
+ return mergeCollectedFiles(collected, frontendFiles);
230
+ }
231
+
197
232
  /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
198
- async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void> {
233
+ async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promise<void> {
234
+ const root = options.root ?? process.cwd();
199
235
  const link = await readLink(root);
200
236
  if (!link) {
201
237
  throw new Error("No .robodev file. Run `robodev link` or `robodev create` first.");
202
238
  }
203
- const files = await collectFiles(root);
239
+ const files = await collectDeployFiles(root, link, options);
204
240
  let force = forceFlag;
205
241
 
206
242
  for (;;) {
@@ -397,15 +433,6 @@ async function npmInstall(root: string): Promise<void> {
397
433
  });
398
434
  }
399
435
 
400
- async function bunAvailable(): Promise<boolean> {
401
- try {
402
- await execFileAsync("bun", ["--version"]);
403
- return true;
404
- } catch {
405
- return false;
406
- }
407
- }
408
-
409
436
  async function hasAppsFe(root: string): Promise<boolean> {
410
437
  try {
411
438
  return (await stat(join(root, "apps/fe"))).isDirectory();
@@ -414,7 +441,7 @@ async function hasAppsFe(root: string): Promise<boolean> {
414
441
  }
415
442
  }
416
443
 
417
- function printFeNextSteps(displayPath: string): void {
444
+ function printFeNextSteps(displayPath: string, hosted: boolean): void {
418
445
  if (displayPath !== ".") {
419
446
  console.log(` cd ${displayPath}`);
420
447
  }
@@ -422,9 +449,15 @@ function printFeNextSteps(displayPath: string): void {
422
449
  console.log(" bun install");
423
450
  console.log(" bun openapi:gen");
424
451
  console.log(" bun dev");
425
- console.log(
426
- " Open the Vite URL (default http://localhost:3000). The project App URL is APIs and Swagger, not this UI.",
427
- );
452
+ if (hosted) {
453
+ console.log(
454
+ " The project App URL serves the hosted SPA. Use bun in apps/fe (Vite default http://localhost:3000) to iterate locally.",
455
+ );
456
+ } else {
457
+ console.log(
458
+ " The project App URL is APIs and Swagger until a frontend build succeeds. Use bun in apps/fe (Vite default http://localhost:3000) to iterate locally.",
459
+ );
460
+ }
428
461
  }
429
462
 
430
463
  /** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
@@ -451,18 +484,35 @@ async function runCreate(args: string[]): Promise<void> {
451
484
  title: starter.title,
452
485
  versions: await loadPackageVersions(),
453
486
  });
487
+ const wantFrontend = await hasAppsFePackageJson(root);
454
488
  await writeLink(
455
489
  {
456
490
  projectId: project.id,
457
491
  apiUrl: starbaseUrl(),
458
492
  projectApiUrl: project.apiUrl,
493
+ ...(wantFrontend ? { frontend: defaultFrontendConfig() } : {}),
459
494
  },
460
495
  root,
461
496
  );
462
497
  await writeViteApiUrl(project.apiUrl, root);
463
498
 
464
499
  console.log(`Created ${name} (${project.id}) in ${root}`);
465
- await runDeploy(false, root);
500
+ let frontendFiles: CollectedFile[] | undefined;
501
+ let skipFrontendBuild = false;
502
+ let feHosted = false;
503
+ if (wantFrontend) {
504
+ try {
505
+ frontendFiles = await buildAndCollectFrontend(root, defaultFrontendConfig(), {
506
+ install: "always",
507
+ });
508
+ feHosted = true;
509
+ } catch (error) {
510
+ console.log(error instanceof Error ? error.message : error);
511
+ console.log("Frontend build failed. Deploying schema and APIs only.");
512
+ skipFrontendBuild = true;
513
+ }
514
+ }
515
+ await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
466
516
 
467
517
  try {
468
518
  await npmInstall(root);
@@ -486,10 +536,10 @@ async function runCreate(args: string[]): Promise<void> {
486
536
  console.log("");
487
537
  console.log("Next:");
488
538
  if (await hasAppsFe(root)) {
489
- if (!(await bunAvailable())) {
539
+ if (!(await commandAvailable("bun"))) {
490
540
  console.log("bun is required for apps/fe. APIs are already deployed.");
491
541
  }
492
- printFeNextSteps(displayPath);
542
+ printFeNextSteps(displayPath, feHosted);
493
543
  } else if (starter.kind === "backend") {
494
544
  if (displayPath !== ".") {
495
545
  console.log(` cd ${displayPath}`);
@@ -1,8 +1,8 @@
1
1
  # {{NAME}}
2
2
 
3
- Starter from `robodev create --starter auth-chat`: Tiny chat APIs (`chat` database, `messages` table), authenticated invite email, and a **local** Povio UI in `apps/fe`.
3
+ Starter from `robodev create --starter auth-chat`: Tiny chat APIs (`chat` database, `messages` table), authenticated invite email, and a Povio UI in `apps/fe`.
4
4
 
5
- The project host is **API-only**. After create or deploy, the printed App URL serves APIs and Swagger not the SPA. Run the UI with bun:
5
+ After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Tiny SPA at `GET /`. APIs and Swagger still win over static files. Use bun in `apps/fe` to iterate locally:
6
6
 
7
7
  ```bash
8
8
  cd apps/fe
@@ -21,9 +21,9 @@ npx robodev link
21
21
  npx robodev deploy
22
22
  ```
23
23
 
24
- `robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, and rewrite `.config/local.spa.yml` so the FE talks to the project host.
24
+ `robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, rewrite `.config/local.spa.yml` so the FE talks to the project host, and (when `apps/fe/package.json` exists) add a `frontend` block to `.robodev`. `robodev deploy` then runs the local Vite build and uploads the static output.
25
25
 
26
- Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`.
26
+ Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
27
27
 
28
28
  `GET /api/me`, `GET/POST /api/messages`, and `POST /api/invite` require a Robodev Auth Bearer token. `GET /api/health` stays public. Invites send one email; posting a chat message does not. Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
29
29
 
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.14.0",
14
+ "robodev": "^0.15.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.14.0",
13
+ "robodev": "^0.15.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.14.0",
13
+ "robodev": "^0.15.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -1,8 +1,8 @@
1
1
  # {{NAME}}
2
2
 
3
- Starter from `robodev create`: Tiny Template schema (`planets`, `aliens`, `media`, `planet_likes`), file-based APIs in `api/`, and a **local** Povio UI in `apps/fe`.
3
+ Starter from `robodev create`: Tiny Template schema (`planets`, `aliens`, `media`, `planet_likes`), file-based APIs in `api/`, and a Povio UI in `apps/fe`.
4
4
 
5
- The project host is **API-only**. After create or deploy, the printed App URL serves APIs and Swagger not the SPA. Run the UI with bun:
5
+ After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Tiny SPA at `GET /`. APIs and Swagger still win over static files. Use bun in `apps/fe` to iterate locally:
6
6
 
7
7
  ```bash
8
8
  cd apps/fe
@@ -21,9 +21,9 @@ npx robodev link
21
21
  npx robodev deploy
22
22
  ```
23
23
 
24
- `robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, and rewrite `.config/local.spa.yml` so the FE talks to the project host.
24
+ `robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, rewrite `.config/local.spa.yml` so the FE talks to the project host, and (when `apps/fe/package.json` exists) add a `frontend` block to `.robodev`. `robodev deploy` then runs the local Vite build and uploads the static output.
25
25
 
26
- Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`.
26
+ Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
27
27
 
28
28
  Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
29
29
 
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.14.0",
14
+ "robodev": "^0.15.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }