robodev 0.5.0 → 0.6.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,7 +1,7 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.5.0",
4
- "description": "CLI for Robodev Starbase — create, auth, link, and deploy file-based APIs",
3
+ "version": "0.6.0",
4
+ "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -23,7 +23,8 @@
23
23
  "scripts": {
24
24
  "robodev": "tsx src/index.ts",
25
25
  "generate:templates": "tsx src/generate-templates.ts",
26
- "prepack": "tsx src/generate-templates.ts"
26
+ "prepack": "tsx src/generate-templates.ts",
27
+ "test": "tsx --test src/**/*.test.ts"
27
28
  },
28
29
  "dependencies": {
29
30
  "tsx": "^4.20.5"
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, 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 { collectFiles } from "./collect-files.js";
8
+
9
+ test("collectFiles includes hosted sources and skips denied paths", async () => {
10
+ const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
11
+ try {
12
+ await writeFile(join(root, "database.ts"), "export default {}");
13
+ await writeFile(join(root, "index.html"), "<html></html>");
14
+ await mkdir(join(root, "src"), { recursive: true });
15
+ await writeFile(join(root, "src/main.tsx"), "export {}");
16
+ await mkdir(join(root, "api"), { recursive: true });
17
+ await writeFile(join(root, "api/x.ts"), "export {}");
18
+ await writeFile(join(root, "package.json"), "{}");
19
+ await mkdir(join(root, "node_modules"), { recursive: true });
20
+ await writeFile(join(root, "node_modules/x.js"), "nope");
21
+ await mkdir(join(root, "dist"), { recursive: true });
22
+ await writeFile(join(root, "dist/x.js"), "nope");
23
+ await writeFile(join(root, ".env"), "SECRET=1");
24
+ await mkdir(join(root, ".robodev"), { recursive: true });
25
+ await writeFile(join(root, ".robodev/x"), "nope");
26
+
27
+ const files = await collectFiles(root);
28
+ const paths = new Set(files.map((file) => file.path));
29
+ assert.ok(paths.has("index.html"));
30
+ assert.ok(paths.has("src/main.tsx"));
31
+ assert.ok(paths.has("database.ts"));
32
+ assert.ok(paths.has("api/x.ts"));
33
+ assert.ok(paths.has("package.json"));
34
+ assert.ok(!paths.has("node_modules/x.js"));
35
+ assert.ok(!paths.has("dist/x.js"));
36
+ assert.ok(!paths.has(".env"));
37
+ assert.ok(!paths.has(".robodev/x"));
38
+ } finally {
39
+ await rm(root, { recursive: true, force: true });
40
+ }
41
+ });
@@ -0,0 +1,164 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+
4
+ export const MAX_FILE_COUNT = 200;
5
+ export const MAX_FILE_BYTES = 1024 * 1024;
6
+ export const MAX_TREE_BYTES = 15 * 1024 * 1024;
7
+ export const MAX_PATH_LENGTH = 240;
8
+ export const MAX_PATH_DEPTH = 12;
9
+
10
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
11
+ const BINARY_EXTENSIONS = new Set([
12
+ ".png",
13
+ ".jpg",
14
+ ".jpeg",
15
+ ".gif",
16
+ ".webp",
17
+ ".ico",
18
+ ".bmp",
19
+ ".woff",
20
+ ".woff2",
21
+ ".ttf",
22
+ ".eot",
23
+ ".otf",
24
+ ".wasm",
25
+ ".zip",
26
+ ".gz",
27
+ ".tgz",
28
+ ".7z",
29
+ ".pdf",
30
+ ".mp4",
31
+ ".webm",
32
+ ".mp3",
33
+ ]);
34
+ const SRC_EXTENSIONS = new Set([
35
+ ".ts",
36
+ ".tsx",
37
+ ".js",
38
+ ".jsx",
39
+ ".mjs",
40
+ ".cjs",
41
+ ".css",
42
+ ".json",
43
+ ".svg",
44
+ ]);
45
+ const PUBLIC_EXTENSIONS = new Set([...SRC_EXTENSIONS, ".html", ".txt", ".md"]);
46
+ const ROOT_STATIC_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".txt", ".md"]);
47
+ const CONFIG_FILES = new Set([
48
+ "package.json",
49
+ "package-lock.json",
50
+ "pnpm-lock.yaml",
51
+ "tsconfig.json",
52
+ "vite.config.ts",
53
+ "vite.config.js",
54
+ "vite.config.mts",
55
+ "vite.config.mjs",
56
+ "README.md",
57
+ ".gitignore",
58
+ ]);
59
+
60
+ export type CollectedFile = { path: string; content: string };
61
+
62
+ function normalize(path: string): string {
63
+ return path.replaceAll("\\", "/");
64
+ }
65
+
66
+ function extname(path: string): string {
67
+ const base = path.split("/").pop() ?? "";
68
+ const dot = base.lastIndexOf(".");
69
+ if (dot <= 0) return "";
70
+ return base.slice(dot).toLowerCase();
71
+ }
72
+
73
+ function basename(path: string): string {
74
+ return path.split("/").pop() ?? path;
75
+ }
76
+
77
+ function isUnsafe(path: string): boolean {
78
+ return (
79
+ path.startsWith("/") ||
80
+ path.includes("..") ||
81
+ path.split("/").some((segment) => segment === "") ||
82
+ /^[A-Za-z]:/.test(path)
83
+ );
84
+ }
85
+
86
+ function isDenied(path: string): boolean {
87
+ const top = path.split("/")[0];
88
+ if (SKIP_DIRS.has(top)) return true;
89
+ if (path === ".env" || path.startsWith(".env.")) return true;
90
+ const name = basename(path);
91
+ if (name.startsWith(".") && name !== ".gitignore") return true;
92
+ if (BINARY_EXTENSIONS.has(extname(path))) return true;
93
+ return false;
94
+ }
95
+
96
+ function isAllowed(path: string): boolean {
97
+ if (path === "database.ts") return true;
98
+ if (path.startsWith("api/")) return path.endsWith(".ts");
99
+ if (path === "index.html") return true;
100
+ if (path.startsWith("src/")) return SRC_EXTENSIONS.has(extname(path));
101
+ if (path.startsWith("public/")) return PUBLIC_EXTENSIONS.has(extname(path));
102
+ if (CONFIG_FILES.has(path) || CONFIG_FILES.has(basename(path))) return true;
103
+ if (/^tsconfig\..+\.json$/.test(basename(path)) && !path.includes("/")) return true;
104
+ if (!path.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(path))) return true;
105
+ return false;
106
+ }
107
+
108
+ export function shouldCollectPath(path: string): boolean {
109
+ const normalized = normalize(path);
110
+ return !isUnsafe(normalized) && !isDenied(normalized) && isAllowed(normalized);
111
+ }
112
+
113
+ export async function collectFiles(root: string): Promise<CollectedFile[]> {
114
+ try {
115
+ await stat(join(root, "database.ts"));
116
+ } catch {
117
+ throw new Error("database.ts not found in the current directory");
118
+ }
119
+
120
+ const files: CollectedFile[] = [];
121
+ let totalBytes = 0;
122
+
123
+ async function walk(dir: string): Promise<void> {
124
+ let entries;
125
+ try {
126
+ entries = await readdir(dir, { withFileTypes: true });
127
+ } catch {
128
+ return;
129
+ }
130
+ for (const entry of entries) {
131
+ const abs = join(dir, entry.name);
132
+ if (entry.isDirectory()) {
133
+ if (SKIP_DIRS.has(entry.name)) continue;
134
+ await walk(abs);
135
+ continue;
136
+ }
137
+ if (!entry.isFile()) continue;
138
+ const path = normalize(relative(root, abs));
139
+ if (!shouldCollectPath(path)) continue;
140
+ if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
141
+ throw new Error(`Invalid file path: ${path}`);
142
+ }
143
+ const content = await readFile(abs, "utf8");
144
+ const bytes = Buffer.byteLength(content, "utf8");
145
+ if (bytes > MAX_FILE_BYTES) {
146
+ throw new Error(`File must be under 1MB: ${path}`);
147
+ }
148
+ totalBytes += bytes;
149
+ if (totalBytes > MAX_TREE_BYTES) {
150
+ throw new Error("Deploy tree must be under 15MB");
151
+ }
152
+ files.push({ path, content });
153
+ if (files.length > MAX_FILE_COUNT) {
154
+ throw new Error(`Deploy may include at most ${MAX_FILE_COUNT} files`);
155
+ }
156
+ }
157
+ }
158
+
159
+ await walk(root);
160
+ if (!files.some((file) => file.path === "database.ts")) {
161
+ throw new Error("database.ts not found in the current directory");
162
+ }
163
+ return files;
164
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { mkdir, readdir, readFile, stat } from "node:fs/promises";
3
+ import { mkdir, stat } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin, stdout } from "node:process";
6
6
  import { basename, join, relative, resolve } from "node:path";
@@ -17,6 +17,7 @@ import {
17
17
  writeViteApiUrl,
18
18
  } from "./config.js";
19
19
  import { waitUntilLive } from "./wait-until-live.js";
20
+ import { collectFiles } from "./collect-files.js";
20
21
  import {
21
22
  emitStarter,
22
23
  loadCatalog,
@@ -39,7 +40,7 @@ Commands:
39
40
  projects List your projects
40
41
  create [path] [--starter <id>] Create a project, scaffold a starter, and deploy
41
42
  link [projectId] Write .robodev in the current folder
42
- deploy [--force] Deploy database.ts and api/*.ts
43
+ deploy [--force] Deploy the project file tree (schema, APIs, and frontend)
43
44
  `);
44
45
  process.exit(1);
45
46
  }
@@ -192,44 +193,7 @@ async function runLink(projectId?: string): Promise<void> {
192
193
  console.log(`Frontend API ${project.apiUrl}`);
193
194
  }
194
195
 
195
- /** Collects `database.ts` plus every TypeScript file under `api/` for a deploy upload. */
196
- async function collectFiles(root: string): Promise<{ path: string; content: string }[]> {
197
- const files: { path: string; content: string }[] = [];
198
- const database = join(root, "database.ts");
199
- try {
200
- await stat(database);
201
- files.push({ path: "database.ts", content: await readFile(database, "utf8") });
202
- } catch {
203
- throw new Error("database.ts not found in the current directory");
204
- }
205
-
206
- async function walk(dir: string): Promise<void> {
207
- let entries;
208
- try {
209
- entries = await readdir(dir, { withFileTypes: true });
210
- } catch {
211
- return;
212
- }
213
- for (const entry of entries) {
214
- const abs = join(dir, entry.name);
215
- if (entry.isDirectory()) {
216
- await walk(abs);
217
- continue;
218
- }
219
- if (entry.isFile() && entry.name.endsWith(".ts")) {
220
- files.push({
221
- path: relative(root, abs).replaceAll("\\", "/"),
222
- content: await readFile(abs, "utf8"),
223
- });
224
- }
225
- }
226
- }
227
-
228
- await walk(join(root, "api"));
229
- return files;
230
- }
231
-
232
- /** Uploads schema + APIs. Destructive schema changes need confirmation or `--force`. */
196
+ /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
233
197
  async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void> {
234
198
  const link = await readLink(root);
235
199
  if (!link) {
@@ -245,6 +209,7 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
245
209
  databaseName: string;
246
210
  databaseCreated?: boolean;
247
211
  apiUrl: string;
212
+ appUrl?: string;
248
213
  docsUrl: string;
249
214
  routes: { method: string; path: string }[];
250
215
  plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
@@ -253,7 +218,7 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
253
218
  body: JSON.stringify({ force, files }),
254
219
  });
255
220
  await waitUntilLive(result.apiUrl);
256
- console.log(`Deployed to ${result.apiUrl}`);
221
+ console.log(`App ${result.appUrl ?? result.apiUrl}`);
257
222
  console.log(`Docs ${result.docsUrl}`);
258
223
  console.log(
259
224
  result.databaseCreated
@@ -302,6 +267,9 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
302
267
  force = true;
303
268
  continue;
304
269
  }
270
+ if (error instanceof ApiError && error.status === 413) {
271
+ throw new Error(error.message);
272
+ }
305
273
  throw error;
306
274
  }
307
275
  }
@@ -2,20 +2,16 @@
2
2
 
3
3
  Starter app from `robodev create --starter auth-chat`: email + Google sign-in and a shared chat room (`chat` database, `messages` table).
4
4
 
5
- The backend is already on Starbase. Run the frontend:
5
+ The project host is the app. After create or deploy, open the printed App URL — no `npm run dev` required. Local Vite is optional:
6
6
 
7
7
  ```bash
8
8
  npm run dev
9
9
  ```
10
10
 
11
- It reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`).
11
+ Local Vite reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`). Hosted builds use the same origin as the APIs.
12
12
 
13
13
  Docs: https://robodev.povio.dev/docs/starter
14
14
 
15
- `GET /me` and `GET/POST /messages` (`api/me.ts`, `api/messages.ts`) require a Robodev Auth Bearer token. `GET /health` stays public. Email auth works without Google. If Google is not configured on Starbase, the button shows a 503 message.
15
+ `GET /me` and `GET/POST /messages` (`api/me.ts`, `api/messages.ts`) require a Robodev Auth Bearer token. `GET /health` stays public. Email auth works without Google. If Google is not configured on Starbase, the button shows a 503 message. Google OAuth may not complete inside the dashboard preview iframe — use Open app.
16
16
 
17
- Add tables in `database.ts` and routes as `api/<name>.ts`, then:
18
-
19
- ```bash
20
- npx robodev deploy
21
- ```
17
+ Edit files in the dashboard Code page, or here, then Save / `npx robodev deploy`.
@@ -11,8 +11,8 @@
11
11
  "dependencies": {
12
12
  "react": "^18.3.1",
13
13
  "react-dom": "^18.3.1",
14
- "@robodev-ai/sdk": "^0.4.0",
15
- "@robodev-ai/client": "^0.1.0"
14
+ "@robodev-ai/sdk": "^0.5.0",
15
+ "@robodev-ai/client": "^0.2.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/react": "^18.3.24",
@@ -20,6 +20,6 @@
20
20
  "@vitejs/plugin-react": "^4.7.0",
21
21
  "typescript": "^5.9.2",
22
22
  "vite": "^6.3.5",
23
- "robodev": "^0.5.0"
23
+ "robodev": "^0.6.0"
24
24
  }
25
25
  }
@@ -11,8 +11,15 @@ type Message = {
11
11
  createdAt: string | null;
12
12
  };
13
13
 
14
- const apiUrl = import.meta.env.VITE_API_URL as string | undefined;
15
- const api = apiUrl ? createClient({ url: apiUrl }) : null;
14
+ function projectApiUrl(): string {
15
+ const fromEnv = import.meta.env.VITE_API_URL;
16
+ if (typeof fromEnv === "string" && fromEnv.trim()) return fromEnv.replace(/\/+$/, "");
17
+ if (typeof window !== "undefined") return window.location.origin;
18
+ return "";
19
+ }
20
+
21
+ const apiUrl = projectApiUrl();
22
+ const api = createClient({ url: apiUrl });
16
23
 
17
24
  function authorLabel(user: { name?: string | null; email: string }) {
18
25
  return user.name?.trim() || user.email;
@@ -33,17 +40,12 @@ function App() {
33
40
  const listRef = useRef<HTMLDivElement>(null);
34
41
 
35
42
  async function refreshMessages() {
36
- if (!api) return;
37
43
  const res = await api.fetch("/messages");
38
44
  if (!res.ok) throw new Error(await res.text());
39
45
  setMessages((await res.json()) as Message[]);
40
46
  }
41
47
 
42
48
  useEffect(() => {
43
- if (!api) {
44
- setReady(true);
45
- return;
46
- }
47
49
  api.consumeTokenFromUrl();
48
50
  void api.auth
49
51
  .getUser()
@@ -53,7 +55,7 @@ function App() {
53
55
  }, []);
54
56
 
55
57
  useEffect(() => {
56
- if (!user || !api) return;
58
+ if (!user) return;
57
59
  let cancelled = false;
58
60
  const load = () =>
59
61
  refreshMessages().catch((err: unknown) => {
@@ -72,7 +74,6 @@ function App() {
72
74
  }, [messages]);
73
75
 
74
76
  useEffect(() => {
75
- if (!apiUrl) return;
76
77
  void fetch(`${apiUrl}/messages`).then((res) => {
77
78
  setUnauthStatus(
78
79
  `${res.status} ${res.status === 401 ? "unauthorized (expected)" : res.statusText}`,
@@ -82,7 +83,6 @@ function App() {
82
83
 
83
84
  async function onEmailAuth(event: FormEvent) {
84
85
  event.preventDefault();
85
- if (!api) return;
86
86
  setPending(true);
87
87
  setError(null);
88
88
  try {
@@ -100,7 +100,6 @@ function App() {
100
100
  }
101
101
 
102
102
  async function onGoogle() {
103
- if (!api) return;
104
103
  setPending(true);
105
104
  setError(null);
106
105
  try {
@@ -122,7 +121,6 @@ function App() {
122
121
  }
123
122
 
124
123
  async function onSignOut() {
125
- if (!api) return;
126
124
  await api.auth.signOut();
127
125
  setUser(null);
128
126
  setMessages([]);
@@ -130,7 +128,7 @@ function App() {
130
128
 
131
129
  async function onSend(event: FormEvent) {
132
130
  event.preventDefault();
133
- if (!api || !draft.trim()) return;
131
+ if (!draft.trim()) return;
134
132
  setPending(true);
135
133
  setError(null);
136
134
  try {
@@ -156,15 +154,6 @@ function App() {
156
154
  );
157
155
  }
158
156
 
159
- if (!api) {
160
- return (
161
- <main>
162
- <h1>{{NAME}}</h1>
163
- <p className="error">Missing VITE_API_URL. Run `robodev create` or `robodev link`.</p>
164
- </main>
165
- );
166
- }
167
-
168
157
  if (!user) {
169
158
  return (
170
159
  <main className="narrow">
@@ -1,21 +1,17 @@
1
1
  # {{NAME}}
2
2
 
3
- Starter app from `robodev create`: a `space` database (`planets`, `rockets`), file-based APIs in `api/`, and a small React UI.
3
+ Starter app from `robodev create`: a `space` database (`planets`, `rockets`), file-based APIs in `api/`, and a React UI.
4
4
 
5
- The backend is already on Starbase. Run the frontend:
5
+ The project host is the app. After create or deploy, open the printed App URL — no `npm run dev` required. Local Vite is optional:
6
6
 
7
7
  ```bash
8
8
  npm run dev
9
9
  ```
10
10
 
11
- It reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`).
11
+ Local Vite reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`). Hosted builds use the same origin as the APIs.
12
12
 
13
13
  Docs: https://robodev.povio.dev/docs
14
14
 
15
15
  `GET /me` (`api/me.ts`) requires a Robodev Auth Bearer token. Planet and rocket routes stay public. Optional browser helper: `@robodev-ai/client`. Auth how-to: https://robodev.povio.dev/docs/auth
16
16
 
17
- Add tables in `database.ts` and routes as `api/<name>.ts`, then:
18
-
19
- ```bash
20
- npx robodev deploy
21
- ```
17
+ Edit files in the dashboard Code page, or here, then Save / `npx robodev deploy`.
@@ -11,8 +11,8 @@
11
11
  "dependencies": {
12
12
  "react": "^18.3.1",
13
13
  "react-dom": "^18.3.1",
14
- "@robodev-ai/sdk": "^0.4.0",
15
- "@robodev-ai/client": "^0.1.0"
14
+ "@robodev-ai/sdk": "^0.5.0",
15
+ "@robodev-ai/client": "^0.2.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/react": "^18.3.24",
@@ -20,6 +20,6 @@
20
20
  "@vitejs/plugin-react": "^4.7.0",
21
21
  "typescript": "^5.9.2",
22
22
  "vite": "^6.3.5",
23
- "robodev": "^0.5.0"
23
+ "robodev": "^0.6.0"
24
24
  }
25
25
  }
@@ -1,17 +1,20 @@
1
- /** Starter UI. Calls the deployed project host using `VITE_API_URL` from `.env`. */
1
+ /** Starter UI. Hosted builds use the project origin; local Vite uses `VITE_API_URL`. */
2
2
  import { StrictMode, useEffect, useState, type FormEvent } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
 
5
5
  type Planet = { id: string; name: string; climate: string; moons: number };
6
6
  type Rocket = { id: string; name: string; destinationId: string; launched: boolean };
7
7
 
8
- const apiUrl = import.meta.env.VITE_API_URL as string | undefined;
8
+ function projectApiUrl(): string {
9
+ const fromEnv = import.meta.env.VITE_API_URL;
10
+ if (typeof fromEnv === "string" && fromEnv.trim()) return fromEnv.replace(/\/+$/, "");
11
+ if (typeof window !== "undefined") return window.location.origin;
12
+ return "";
13
+ }
9
14
 
10
15
  async function request<T>(path: string, init?: RequestInit): Promise<T> {
11
- if (!apiUrl) {
12
- throw new Error("Missing VITE_API_URL. Run `robodev create` or `robodev link`.");
13
- }
14
- const res = await fetch(`${apiUrl}${path}`, {
16
+ const base = projectApiUrl();
17
+ const res = await fetch(`${base}${path}`, {
15
18
  ...init,
16
19
  headers: { "Content-Type": "application/json", ...init?.headers },
17
20
  });
@@ -110,9 +113,7 @@ function App() {
110
113
  return (
111
114
  <main>
112
115
  <h1>{"{{NAME}}"}</h1>
113
- <p className="lede">
114
- React frontend talking to your Starbase APIs at {apiUrl ?? "(not linked)"}.
115
- </p>
116
+ <p className="lede">React frontend talking to your Starbase APIs at {projectApiUrl()}.</p>
116
117
  {error ? <p className="error">{error}</p> : null}
117
118
 
118
119
  <div className="grid">