robodev 0.13.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.13.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,
@@ -25,6 +31,7 @@ import {
25
31
  resolveStarterTree,
26
32
  type Starter,
27
33
  } from "./emit-starter.js";
34
+ import { parseCreateArgs } from "./parse-create-args.js";
28
35
 
29
36
  const execFileAsync = promisify(execFile);
30
37
 
@@ -38,9 +45,9 @@ Commands:
38
45
  logout Remove stored credentials
39
46
  whoami Show the signed-in user
40
47
  projects List your projects
41
- create [path] [--starter <id>] [--empty] Create a project, scaffold a starter, and deploy
48
+ create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
42
49
  link [projectId] Write .robodev in the current folder
43
- 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)
44
51
  `);
45
52
  process.exit(1);
46
53
  }
@@ -183,23 +190,53 @@ async function runLink(projectId?: string): Promise<void> {
183
190
  if (!project) {
184
191
  throw new Error(`Project ${chosen} not found or not yours`);
185
192
  }
186
- await writeLink({
193
+ const existing = await readLinkJson();
194
+ const link: ProjectLink = {
187
195
  projectId: project.id,
188
196
  apiUrl: starbaseUrl(),
189
197
  projectApiUrl: project.apiUrl,
190
- });
198
+ };
199
+ if ((!existing || !("frontend" in existing)) && (await hasAppsFePackageJson(process.cwd()))) {
200
+ link.frontend = defaultFrontendConfig();
201
+ }
202
+ await writeLink(link);
191
203
  await writeViteApiUrl(project.apiUrl);
192
204
  console.log(`Linked ${process.cwd()} to ${project.id}`);
193
205
  console.log(`Frontend API ${project.apiUrl}`);
194
206
  }
195
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
+
196
232
  /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
197
- 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();
198
235
  const link = await readLink(root);
199
236
  if (!link) {
200
237
  throw new Error("No .robodev file. Run `robodev link` or `robodev create` first.");
201
238
  }
202
- const files = await collectFiles(root);
239
+ const files = await collectDeployFiles(root, link, options);
203
240
  let force = forceFlag;
204
241
 
205
242
  for (;;) {
@@ -279,48 +316,47 @@ function validIdsMessage(starters: Starter[]): string {
279
316
  return `Valid ids: ${starters.map((starter) => starter.id).join(", ")}`;
280
317
  }
281
318
 
282
- function parseCreateArgs(args: string[]): { path?: string; starterId?: string } {
283
- let path: string | undefined;
284
- let starterId: string | undefined;
285
- let empty = false;
286
- for (let i = 0; i < args.length; i++) {
287
- const arg = args[i];
288
- if (arg === "--empty") {
289
- empty = true;
290
- continue;
291
- }
292
- if (arg === "--starter") {
293
- const value = args[i + 1];
294
- if (!value || value.startsWith("-")) {
295
- throw new Error("Missing value for --starter");
296
- }
297
- starterId = value;
298
- i += 1;
299
- continue;
300
- }
301
- if (arg.startsWith("--starter=")) {
302
- const value = arg.slice("--starter=".length);
303
- if (!value) {
304
- throw new Error("Missing value for --starter");
305
- }
306
- starterId = value;
307
- continue;
308
- }
309
- if (arg.startsWith("-")) {
310
- throw new Error(`Unknown flag ${arg}`);
311
- }
312
- if (path !== undefined) {
313
- throw new Error(`Unexpected argument ${arg}`);
314
- }
315
- path = arg;
319
+ type Organization = {
320
+ id: string;
321
+ name: string;
322
+ plan: string;
323
+ createdAt: string;
324
+ role: "admin" | "developer";
325
+ };
326
+
327
+ function validOrgIdsMessage(orgs: Organization[]): string {
328
+ return `Valid ids: ${orgs.map((org) => org.id).join(", ")}`;
329
+ }
330
+
331
+ async function resolveOrganization(flagId: string | undefined): Promise<string | undefined> {
332
+ const { organizations } = await authed<{ organizations: Organization[] }>("/v1/organizations");
333
+ const adminOrgs = organizations.filter((org) => org.role === "admin");
334
+ if (flagId !== undefined) {
335
+ return flagId;
316
336
  }
317
- if (empty && starterId !== undefined) {
318
- throw new Error("Use either --empty or --starter, not both.");
337
+ if (adminOrgs.length === 0) {
338
+ throw new Error("You need an admin role in an organization to create a project.");
319
339
  }
320
- if (empty) {
321
- starterId = "empty";
340
+ if (adminOrgs.length === 1 || !stdin.isTTY) {
341
+ return undefined;
322
342
  }
323
- return { path, starterId };
343
+ console.log("Organizations:");
344
+ adminOrgs.forEach((org, i) => {
345
+ console.log(` ${i + 1}. ${org.name} (${org.id})`);
346
+ });
347
+ const answer = (await prompt("Organization number or id: ")).trim();
348
+ if (!answer) {
349
+ throw new Error(`Unknown organization "". ${validOrgIdsMessage(adminOrgs)}`);
350
+ }
351
+ const asIndex = Number(answer);
352
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= adminOrgs.length) {
353
+ return adminOrgs[asIndex - 1].id;
354
+ }
355
+ const found = adminOrgs.find((org) => org.id === answer);
356
+ if (!found) {
357
+ throw new Error(`Unknown organization "${answer}". ${validOrgIdsMessage(adminOrgs)}`);
358
+ }
359
+ return found.id;
324
360
  }
325
361
 
326
362
  async function resolveStarter(flagId: string | undefined): Promise<Starter> {
@@ -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. */
@@ -438,9 +471,10 @@ async function runCreate(args: string[]): Promise<void> {
438
471
  await assertCreatable(root);
439
472
 
440
473
  const name = projectNameFromPath(root);
474
+ const organizationId = await resolveOrganization(parsed.orgId);
441
475
  const project = await authed<Project>("/v1/projects", {
442
476
  method: "POST",
443
- body: JSON.stringify({ name }),
477
+ body: JSON.stringify(organizationId ? { name, organizationId } : { name }),
444
478
  });
445
479
 
446
480
  const { treeRoot } = await resolveStarterTree();
@@ -450,18 +484,35 @@ async function runCreate(args: string[]): Promise<void> {
450
484
  title: starter.title,
451
485
  versions: await loadPackageVersions(),
452
486
  });
487
+ const wantFrontend = await hasAppsFePackageJson(root);
453
488
  await writeLink(
454
489
  {
455
490
  projectId: project.id,
456
491
  apiUrl: starbaseUrl(),
457
492
  projectApiUrl: project.apiUrl,
493
+ ...(wantFrontend ? { frontend: defaultFrontendConfig() } : {}),
458
494
  },
459
495
  root,
460
496
  );
461
497
  await writeViteApiUrl(project.apiUrl, root);
462
498
 
463
499
  console.log(`Created ${name} (${project.id}) in ${root}`);
464
- 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 });
465
516
 
466
517
  try {
467
518
  await npmInstall(root);
@@ -485,10 +536,10 @@ async function runCreate(args: string[]): Promise<void> {
485
536
  console.log("");
486
537
  console.log("Next:");
487
538
  if (await hasAppsFe(root)) {
488
- if (!(await bunAvailable())) {
539
+ if (!(await commandAvailable("bun"))) {
489
540
  console.log("bun is required for apps/fe. APIs are already deployed.");
490
541
  }
491
- printFeNextSteps(displayPath);
542
+ printFeNextSteps(displayPath, feHosted);
492
543
  } else if (starter.kind === "backend") {
493
544
  if (displayPath !== ".") {
494
545
  console.log(` cd ${displayPath}`);
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { parseCreateArgs } from "./parse-create-args.js";
4
+
5
+ test("parseCreateArgs accepts --org with starter flags", () => {
6
+ assert.deepEqual(parseCreateArgs(["app", "--starter", "space", "--org", "org_1"]), {
7
+ path: "app",
8
+ starterId: "space",
9
+ orgId: "org_1",
10
+ });
11
+ assert.deepEqual(parseCreateArgs(["--empty", "--org=org_2"]), {
12
+ path: undefined,
13
+ starterId: "empty",
14
+ orgId: "org_2",
15
+ });
16
+ });
17
+
18
+ test("parseCreateArgs rejects a missing --org value", () => {
19
+ assert.throws(() => parseCreateArgs(["--org"]), /Missing value for --org/);
20
+ assert.throws(() => parseCreateArgs(["--org="]), /Missing value for --org/);
21
+ assert.throws(() => parseCreateArgs(["--org", "--empty"]), /Missing value for --org/);
22
+ });
23
+
24
+ test("parseCreateArgs rejects unknown flags including --organization", () => {
25
+ assert.throws(() => parseCreateArgs(["--organization", "org_1"]), /Unknown flag --organization/);
26
+ });
@@ -0,0 +1,63 @@
1
+ export type CreateArgs = { path?: string; starterId?: string; orgId?: string };
2
+
3
+ export function parseCreateArgs(args: string[]): CreateArgs {
4
+ let path: string | undefined;
5
+ let starterId: string | undefined;
6
+ let orgId: string | undefined;
7
+ let empty = false;
8
+ for (let i = 0; i < args.length; i++) {
9
+ const arg = args[i];
10
+ if (arg === "--empty") {
11
+ empty = true;
12
+ continue;
13
+ }
14
+ if (arg === "--starter") {
15
+ const value = args[i + 1];
16
+ if (!value || value.startsWith("-")) {
17
+ throw new Error("Missing value for --starter");
18
+ }
19
+ starterId = value;
20
+ i += 1;
21
+ continue;
22
+ }
23
+ if (arg.startsWith("--starter=")) {
24
+ const value = arg.slice("--starter=".length);
25
+ if (!value) {
26
+ throw new Error("Missing value for --starter");
27
+ }
28
+ starterId = value;
29
+ continue;
30
+ }
31
+ if (arg === "--org") {
32
+ const value = args[i + 1];
33
+ if (!value || value.startsWith("-")) {
34
+ throw new Error("Missing value for --org");
35
+ }
36
+ orgId = value;
37
+ i += 1;
38
+ continue;
39
+ }
40
+ if (arg.startsWith("--org=")) {
41
+ const value = arg.slice("--org=".length);
42
+ if (!value) {
43
+ throw new Error("Missing value for --org");
44
+ }
45
+ orgId = value;
46
+ continue;
47
+ }
48
+ if (arg.startsWith("-")) {
49
+ throw new Error(`Unknown flag ${arg}`);
50
+ }
51
+ if (path !== undefined) {
52
+ throw new Error(`Unexpected argument ${arg}`);
53
+ }
54
+ path = arg;
55
+ }
56
+ if (empty && starterId !== undefined) {
57
+ throw new Error("Use either --empty or --starter, not both.");
58
+ }
59
+ if (empty) {
60
+ starterId = "empty";
61
+ }
62
+ return { path, starterId, orgId };
63
+ }
@@ -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.13.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.13.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.13.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.13.0",
14
+ "robodev": "^0.15.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }