robodev 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, and deploy file-based APIs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  "files": [
16
16
  "bin",
17
17
  "src",
18
- "template"
18
+ "templates"
19
19
  ],
20
20
  "publishConfig": {
21
21
  "access": "public"
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  writeLink,
18
18
  writeViteApiUrl,
19
19
  } from "./config.js";
20
+ import { waitUntilLive } from "./wait-until-live.js";
20
21
 
21
22
  const execFileAsync = promisify(execFile);
22
23
 
@@ -30,7 +31,7 @@ Commands:
30
31
  logout Remove stored credentials
31
32
  whoami Show the signed-in user
32
33
  projects List your projects
33
- create [path] Create a project, scaffold the example app, and deploy
34
+ create [path] [--starter <id>] Create a project, scaffold a starter, and deploy
34
35
  link [projectId] Write .robodev in the current folder
35
36
  deploy [--force] Deploy database.ts and api/*.ts
36
37
  `);
@@ -245,6 +246,7 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
245
246
  method: "POST",
246
247
  body: JSON.stringify({ force, files }),
247
248
  });
249
+ await waitUntilLive(result.apiUrl);
248
250
  console.log(`Deployed to ${result.apiUrl}`);
249
251
  console.log(`Docs ${result.docsUrl}`);
250
252
  console.log(
@@ -299,8 +301,113 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
299
301
  }
300
302
  }
301
303
 
302
- function templateRoot(): string {
303
- return join(dirname(fileURLToPath(import.meta.url)), "..", "template");
304
+ type Starter = { id: string; title: string; description: string };
305
+
306
+ function cliRoot(): string {
307
+ return join(dirname(fileURLToPath(import.meta.url)), "..");
308
+ }
309
+
310
+ function templateRoot(id: string): string {
311
+ return join(cliRoot(), "templates", id);
312
+ }
313
+
314
+ function validIdsMessage(starters: Starter[]): string {
315
+ return `Valid ids: ${starters.map((starter) => starter.id).join(", ")}`;
316
+ }
317
+
318
+ async function isDirectory(path: string): Promise<boolean> {
319
+ try {
320
+ return (await stat(path)).isDirectory();
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ async function loadCatalog(): Promise<Starter[]> {
328
+ const raw = JSON.parse(await readFile(join(cliRoot(), "templates", "catalog.json"), "utf8")) as {
329
+ starters?: Starter[];
330
+ };
331
+ if (!Array.isArray(raw.starters)) {
332
+ throw new Error("templates/catalog.json is missing a starters array");
333
+ }
334
+ const starters: Starter[] = [];
335
+ for (const row of raw.starters) {
336
+ if (!(await isDirectory(templateRoot(row.id)))) {
337
+ throw new Error(`Starter "${row.id}" is missing a templates/${row.id} directory`);
338
+ }
339
+ starters.push(row);
340
+ }
341
+ return starters;
342
+ }
343
+
344
+ function parseCreateArgs(args: string[]): { path?: string; starterId?: string } {
345
+ let path: string | undefined;
346
+ let starterId: string | undefined;
347
+ for (let i = 0; i < args.length; i++) {
348
+ const arg = args[i];
349
+ if (arg === "--starter") {
350
+ const value = args[i + 1];
351
+ if (!value || value.startsWith("-")) {
352
+ throw new Error("Missing value for --starter");
353
+ }
354
+ starterId = value;
355
+ i += 1;
356
+ continue;
357
+ }
358
+ if (arg.startsWith("--starter=")) {
359
+ const value = arg.slice("--starter=".length);
360
+ if (!value) {
361
+ throw new Error("Missing value for --starter");
362
+ }
363
+ starterId = value;
364
+ continue;
365
+ }
366
+ if (arg.startsWith("-")) {
367
+ throw new Error(`Unknown flag ${arg}`);
368
+ }
369
+ if (path !== undefined) {
370
+ throw new Error(`Unexpected argument ${arg}`);
371
+ }
372
+ path = arg;
373
+ }
374
+ return { path, starterId };
375
+ }
376
+
377
+ async function resolveStarter(flagId: string | undefined): Promise<Starter> {
378
+ const starters = await loadCatalog();
379
+ if (flagId !== undefined) {
380
+ const found = starters.find((starter) => starter.id === flagId);
381
+ if (!found) {
382
+ throw new Error(`Unknown starter "${flagId}". ${validIdsMessage(starters)}`);
383
+ }
384
+ return found;
385
+ }
386
+ if (stdin.isTTY) {
387
+ console.log("Starters:");
388
+ starters.forEach((starter, i) => {
389
+ console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
390
+ console.log(` ${starter.description}`);
391
+ });
392
+ const answer = (await prompt("Starter number or id: ")).trim();
393
+ if (!answer) {
394
+ throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
395
+ }
396
+ const asIndex = Number(answer);
397
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
398
+ return starters[asIndex - 1];
399
+ }
400
+ const found = starters.find((starter) => starter.id === answer);
401
+ if (!found) {
402
+ throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
403
+ }
404
+ return found;
405
+ }
406
+ const space = starters.find((starter) => starter.id === "space");
407
+ if (!space) {
408
+ throw new Error(`Unknown starter "space". ${validIdsMessage(starters)}`);
409
+ }
410
+ return space;
304
411
  }
305
412
 
306
413
  function projectNameFromPath(root: string): string {
@@ -359,9 +466,13 @@ async function npmInstall(root: string): Promise<void> {
359
466
  });
360
467
  }
361
468
 
362
- /** Creates a Starbase project, copies the starter template, links, deploys, and installs deps. */
363
- async function runCreate(pathArg?: string): Promise<void> {
364
- const root = resolve(process.cwd(), pathArg ?? ".");
469
+ /** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
470
+ async function runCreate(args: string[]): Promise<void> {
471
+ const parsed = parseCreateArgs(args);
472
+ const starter = await resolveStarter(parsed.starterId);
473
+ console.log(`Using starter ${starter.title} (${starter.id})`);
474
+
475
+ const root = resolve(process.cwd(), parsed.path ?? ".");
365
476
  await mkdir(root, { recursive: true });
366
477
  await assertCreatable(root);
367
478
 
@@ -371,7 +482,7 @@ async function runCreate(pathArg?: string): Promise<void> {
371
482
  body: JSON.stringify({ name }),
372
483
  });
373
484
 
374
- await copyTemplate(templateRoot(), root, {
485
+ await copyTemplate(templateRoot(starter.id), root, {
375
486
  NAME: name,
376
487
  PACKAGE_NAME: packageNameFromPath(root),
377
488
  });
@@ -422,7 +533,7 @@ async function main(): Promise<void> {
422
533
  await runProjects();
423
534
  break;
424
535
  case "create":
425
- await runCreate(args.find((arg) => !arg.startsWith("-")));
536
+ await runCreate(args);
426
537
  break;
427
538
  case "link":
428
539
  await runLink(args[0]);
@@ -0,0 +1,51 @@
1
+ const POLL_INTERVAL_MS = 400;
2
+ const OVERALL_TIMEOUT_MS = 30_000;
3
+ const ATTEMPT_TIMEOUT_MS = 3_000;
4
+
5
+ function sleep(ms: number): Promise<void> {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+
9
+ async function probe(url: string, timeoutMs: number): Promise<{ status: number; error?: string }> {
10
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
11
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
12
+ return {
13
+ status: res.status,
14
+ error: typeof body.error === "string" ? body.error : undefined,
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Polls the public project host until it answers as live.
20
+ * Prefers `GET /_robodev/ready` (200). Falls back to `GET /openapi.json` when an
21
+ * older Starbase serves a user-route 404 (`error === "not_found"`).
22
+ */
23
+ export async function waitUntilLive(apiUrl: string): Promise<void> {
24
+ const base = apiUrl.replace(/\/$/, "");
25
+ const deadline = Date.now() + OVERALL_TIMEOUT_MS;
26
+ let path = "/_robodev/ready";
27
+
28
+ console.log("Waiting for endpoints to come online…");
29
+
30
+ while (Date.now() < deadline) {
31
+ const remaining = deadline - Date.now();
32
+ if (remaining <= 0) break;
33
+
34
+ try {
35
+ const result = await probe(`${base}${path}`, Math.min(ATTEMPT_TIMEOUT_MS, remaining));
36
+ if (result.status === 200) return;
37
+ if (path === "/_robodev/ready" && result.status === 404 && result.error === "not_found") {
38
+ path = "/openapi.json";
39
+ continue;
40
+ }
41
+ } catch {
42
+ // Connection, TLS, or attempt timeout — keep polling until the overall deadline.
43
+ }
44
+
45
+ const wait = Math.min(POLL_INTERVAL_MS, deadline - Date.now());
46
+ if (wait <= 0) break;
47
+ await sleep(wait);
48
+ }
49
+
50
+ throw new Error(`Deploy was accepted but the project host did not become reachable: ${base}`);
51
+ }
@@ -0,0 +1,21 @@
1
+ # {{NAME}}
2
+
3
+ Starter app from `robodev create --starter auth-chat`: email + Google sign-in and a shared chat room (`chat` database, `messages` table).
4
+
5
+ The backend is already on Starbase. Run the frontend:
6
+
7
+ ```bash
8
+ npm run dev
9
+ ```
10
+
11
+ It reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`).
12
+
13
+ Docs: https://robodev.povio.dev/docs/starter
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.
16
+
17
+ Add tables in `database.ts` and routes as `api/<name>.ts`, then:
18
+
19
+ ```bash
20
+ npx robodev deploy
21
+ ```
@@ -0,0 +1,41 @@
1
+ /** GET/POST /messages — signed-in users read and post in the shared room. */
2
+ import { messages } from "../database";
3
+ import { defineApi, desc, z } from "@robodev-ai/sdk";
4
+
5
+ const Message = z.object({
6
+ id: z.string(),
7
+ userId: z.string(),
8
+ authorName: z.string().nullable(),
9
+ authorEmail: z.string(),
10
+ body: z.string(),
11
+ createdAt: z.coerce.string().nullable(),
12
+ });
13
+
14
+ export const get = defineApi({
15
+ auth: "required",
16
+ response: z.array(Message),
17
+ handler: async ({ db }) => {
18
+ const rows = await db.select().from(messages).orderBy(desc(messages.createdAt)).limit(100);
19
+ return Message.array().parse(rows.reverse());
20
+ },
21
+ });
22
+
23
+ export const post = defineApi({
24
+ auth: "required",
25
+ body: z.object({
26
+ body: z.string().trim().min(1).max(2000),
27
+ }),
28
+ response: Message,
29
+ handler: async ({ db, user, body }) => {
30
+ const [row] = await db
31
+ .insert(messages)
32
+ .values({
33
+ userId: user!.id,
34
+ authorName: user!.name ?? null,
35
+ authorEmail: user!.email,
36
+ body: body.body,
37
+ })
38
+ .returning();
39
+ return Message.parse(row);
40
+ },
41
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared chat room. `robodev_auth.users` is platform-owned — store only a
3
+ * snapshot of the author on each message.
4
+ */
5
+ import { defineDatabase, pgTable, text, timestamp, uuid } from "@robodev-ai/sdk";
6
+
7
+ export const messages = pgTable("messages", {
8
+ id: uuid("id").primaryKey().defaultRandom(),
9
+ userId: text("userId").notNull(),
10
+ authorName: text("authorName"),
11
+ authorEmail: text("authorEmail").notNull(),
12
+ body: text("body").notNull(),
13
+ createdAt: timestamp("createdAt", { withTimezone: true }).defaultNow(),
14
+ });
15
+
16
+ export default defineDatabase({
17
+ name: "chat",
18
+ tables: { messages },
19
+ });
@@ -0,0 +1,201 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{NAME}}</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #10131a;
11
+ --panel: #181c26;
12
+ --line: #2a3140;
13
+ --text: #f2eee6;
14
+ --muted: #9aa3b2;
15
+ --accent: #7ee0c6;
16
+ --ink: #10221c;
17
+ --mine: #243f38;
18
+ --theirs: #222733;
19
+ --danger: #f0a0a0;
20
+ }
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ background:
28
+ radial-gradient(900px 420px at 10% -10%, #1d3a34 0%, transparent 55%), var(--bg);
29
+ color: var(--text);
30
+ font:
31
+ 15px/1.5 ui-sans-serif,
32
+ system-ui,
33
+ sans-serif;
34
+ }
35
+ main {
36
+ max-width: 720px;
37
+ margin: 0 auto;
38
+ padding: 36px 20px 48px;
39
+ }
40
+ main.narrow {
41
+ max-width: 420px;
42
+ }
43
+ main.chat {
44
+ display: flex;
45
+ flex-direction: column;
46
+ min-height: 100vh;
47
+ padding-bottom: 24px;
48
+ }
49
+ h1 {
50
+ font-size: 28px;
51
+ font-weight: 650;
52
+ letter-spacing: -0.04em;
53
+ margin: 0 0 4px;
54
+ }
55
+ .eyebrow {
56
+ margin: 0 0 6px;
57
+ color: var(--accent);
58
+ font-size: 12px;
59
+ letter-spacing: 0.08em;
60
+ text-transform: uppercase;
61
+ }
62
+ .lede {
63
+ color: var(--muted);
64
+ margin: 0 0 22px;
65
+ overflow-wrap: anywhere;
66
+ }
67
+ .error {
68
+ color: var(--danger);
69
+ margin: 0 0 16px;
70
+ }
71
+ .tabs {
72
+ display: flex;
73
+ gap: 8px;
74
+ margin-bottom: 14px;
75
+ }
76
+ .tab,
77
+ .ghost,
78
+ button {
79
+ font: inherit;
80
+ cursor: pointer;
81
+ }
82
+ .tab {
83
+ background: transparent;
84
+ color: var(--muted);
85
+ border: 1px solid var(--line);
86
+ border-radius: 999px;
87
+ padding: 6px 12px;
88
+ }
89
+ .tab.on {
90
+ color: var(--ink);
91
+ background: var(--accent);
92
+ border-color: var(--accent);
93
+ }
94
+ form {
95
+ display: grid;
96
+ gap: 10px;
97
+ }
98
+ label {
99
+ display: grid;
100
+ gap: 4px;
101
+ font-size: 12px;
102
+ color: var(--muted);
103
+ }
104
+ input,
105
+ button {
106
+ color: var(--text);
107
+ }
108
+ input {
109
+ background: var(--panel);
110
+ border: 1px solid var(--line);
111
+ border-radius: 8px;
112
+ padding: 10px 12px;
113
+ }
114
+ button[type="submit"],
115
+ .google {
116
+ background: var(--accent);
117
+ color: var(--ink);
118
+ border: 0;
119
+ border-radius: 8px;
120
+ padding: 10px 12px;
121
+ font-weight: 650;
122
+ }
123
+ .google {
124
+ width: 100%;
125
+ background: #fff;
126
+ color: #1f1f1f;
127
+ }
128
+ .ghost {
129
+ background: transparent;
130
+ color: var(--accent);
131
+ border: 1px solid var(--accent);
132
+ border-radius: 8px;
133
+ padding: 8px 12px;
134
+ height: fit-content;
135
+ }
136
+ button:disabled {
137
+ opacity: 0.5;
138
+ cursor: default;
139
+ }
140
+ .or {
141
+ text-align: center;
142
+ color: var(--muted);
143
+ margin: 16px 0 12px;
144
+ font-size: 12px;
145
+ }
146
+ header {
147
+ display: flex;
148
+ justify-content: space-between;
149
+ align-items: flex-start;
150
+ gap: 16px;
151
+ margin-bottom: 16px;
152
+ }
153
+ .transcript {
154
+ flex: 1;
155
+ min-height: 320px;
156
+ max-height: calc(100vh - 240px);
157
+ overflow: auto;
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 10px;
161
+ padding: 12px;
162
+ background: var(--panel);
163
+ border: 1px solid var(--line);
164
+ border-radius: 12px;
165
+ margin-bottom: 12px;
166
+ }
167
+ .empty {
168
+ color: var(--muted);
169
+ margin: auto;
170
+ }
171
+ .bubble {
172
+ max-width: 80%;
173
+ padding: 8px 12px;
174
+ border-radius: 12px;
175
+ background: var(--theirs);
176
+ }
177
+ .bubble.mine {
178
+ align-self: flex-end;
179
+ background: var(--mine);
180
+ }
181
+ .bubble p {
182
+ margin: 0;
183
+ white-space: pre-wrap;
184
+ }
185
+ .meta {
186
+ color: var(--muted);
187
+ font-size: 12px;
188
+ margin-bottom: 2px;
189
+ }
190
+ .composer {
191
+ display: grid;
192
+ grid-template-columns: 1fr auto;
193
+ gap: 8px;
194
+ }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <div id="root"></div>
199
+ <script type="module" src="/src/main.tsx"></script>
200
+ </body>
201
+ </html>
@@ -0,0 +1,291 @@
1
+ import { StrictMode, useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { createClient, type AuthUser } from "@robodev-ai/client";
4
+
5
+ type Message = {
6
+ id: string;
7
+ userId: string;
8
+ authorName: string | null;
9
+ authorEmail: string;
10
+ body: string;
11
+ createdAt: string | null;
12
+ };
13
+
14
+ const apiUrl = import.meta.env.VITE_API_URL as string | undefined;
15
+ const api = apiUrl ? createClient({ url: apiUrl }) : null;
16
+
17
+ function authorLabel(user: { name?: string | null; email: string }) {
18
+ return user.name?.trim() || user.email;
19
+ }
20
+
21
+ function App() {
22
+ const [mode, setMode] = useState<"signin" | "signup">("signin");
23
+ const [user, setUser] = useState<AuthUser | null>(null);
24
+ const [ready, setReady] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+ const [pending, setPending] = useState(false);
27
+ const [email, setEmail] = useState("");
28
+ const [password, setPassword] = useState("");
29
+ const [name, setName] = useState("");
30
+ const [messages, setMessages] = useState<Message[]>([]);
31
+ const [draft, setDraft] = useState("");
32
+ const [unauthStatus, setUnauthStatus] = useState<string | null>(null);
33
+ const listRef = useRef<HTMLDivElement>(null);
34
+
35
+ async function refreshMessages() {
36
+ if (!api) return;
37
+ const res = await api.fetch("/messages");
38
+ if (!res.ok) throw new Error(await res.text());
39
+ setMessages((await res.json()) as Message[]);
40
+ }
41
+
42
+ useEffect(() => {
43
+ if (!api) {
44
+ setReady(true);
45
+ return;
46
+ }
47
+ api.consumeTokenFromUrl();
48
+ void api.auth
49
+ .getUser()
50
+ .then(({ user: next }) => setUser(next))
51
+ .catch(() => setUser(null))
52
+ .finally(() => setReady(true));
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ if (!user || !api) return;
57
+ let cancelled = false;
58
+ const load = () =>
59
+ refreshMessages().catch((err: unknown) => {
60
+ if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load messages");
61
+ });
62
+ void load();
63
+ const timer = window.setInterval(() => void load(), 2500);
64
+ return () => {
65
+ cancelled = true;
66
+ window.clearInterval(timer);
67
+ };
68
+ }, [user]);
69
+
70
+ useEffect(() => {
71
+ listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
72
+ }, [messages]);
73
+
74
+ useEffect(() => {
75
+ if (!apiUrl) return;
76
+ void fetch(`${apiUrl}/messages`).then((res) => {
77
+ setUnauthStatus(
78
+ `${res.status} ${res.status === 401 ? "unauthorized (expected)" : res.statusText}`,
79
+ );
80
+ });
81
+ }, []);
82
+
83
+ async function onEmailAuth(event: FormEvent) {
84
+ event.preventDefault();
85
+ if (!api) return;
86
+ setPending(true);
87
+ setError(null);
88
+ try {
89
+ const session =
90
+ mode === "signup"
91
+ ? await api.auth.signUp({ email, password, name: name.trim() || undefined })
92
+ : await api.auth.signIn.email({ email, password });
93
+ setUser(session.user);
94
+ setPassword("");
95
+ } catch (err) {
96
+ setError(err instanceof Error ? err.message : "Auth failed");
97
+ } finally {
98
+ setPending(false);
99
+ }
100
+ }
101
+
102
+ async function onGoogle() {
103
+ if (!api) return;
104
+ setPending(true);
105
+ setError(null);
106
+ try {
107
+ const start = new URL(`${api.url}/auth/google/start`);
108
+ start.searchParams.set("redirect_uri", window.location.href);
109
+ const probe = await fetch(start.toString(), { redirect: "manual" });
110
+ if (probe.status === 503) {
111
+ const body = (await probe.json().catch(() => ({}))) as { error?: string; message?: string };
112
+ throw new Error(
113
+ body.message ||
114
+ `Google sign-in is not configured on Starbase (${body.error ?? "503"}). Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and PUBLIC_URL.`,
115
+ );
116
+ }
117
+ api.auth.signIn.google();
118
+ } catch (err) {
119
+ setError(err instanceof Error ? err.message : "Google sign-in failed");
120
+ setPending(false);
121
+ }
122
+ }
123
+
124
+ async function onSignOut() {
125
+ if (!api) return;
126
+ await api.auth.signOut();
127
+ setUser(null);
128
+ setMessages([]);
129
+ }
130
+
131
+ async function onSend(event: FormEvent) {
132
+ event.preventDefault();
133
+ if (!api || !draft.trim()) return;
134
+ setPending(true);
135
+ setError(null);
136
+ try {
137
+ const res = await api.fetch("/messages", {
138
+ method: "POST",
139
+ body: JSON.stringify({ body: draft.trim() }),
140
+ });
141
+ if (!res.ok) throw new Error(await res.text());
142
+ setDraft("");
143
+ await refreshMessages();
144
+ } catch (err) {
145
+ setError(err instanceof Error ? err.message : "Could not send");
146
+ } finally {
147
+ setPending(false);
148
+ }
149
+ }
150
+
151
+ if (!ready) {
152
+ return (
153
+ <main>
154
+ <p className="lede">Loading session…</p>
155
+ </main>
156
+ );
157
+ }
158
+
159
+ if (!api) {
160
+ return (
161
+ <main>
162
+ <h1>Auth chat</h1>
163
+ <p className="error">Missing VITE_API_URL. Run `robodev create` or `robodev link`.</p>
164
+ </main>
165
+ );
166
+ }
167
+
168
+ if (!user) {
169
+ return (
170
+ <main className="narrow">
171
+ <p className="eyebrow">Robodev Auth</p>
172
+ <h1>Auth chat</h1>
173
+ <p className="lede">
174
+ Sign in with email or Google. Protected APIs live at {apiUrl}. Unauthenticated GET
175
+ /messages: {unauthStatus ?? "checking…"}.
176
+ </p>
177
+ {error ? <p className="error">{error}</p> : null}
178
+
179
+ <div className="tabs">
180
+ <button
181
+ type="button"
182
+ className={mode === "signin" ? "tab on" : "tab"}
183
+ onClick={() => setMode("signin")}
184
+ >
185
+ Sign in
186
+ </button>
187
+ <button
188
+ type="button"
189
+ className={mode === "signup" ? "tab on" : "tab"}
190
+ onClick={() => setMode("signup")}
191
+ >
192
+ Sign up
193
+ </button>
194
+ </div>
195
+
196
+ <form onSubmit={onEmailAuth}>
197
+ {mode === "signup" ? (
198
+ <label>
199
+ Name
200
+ <input
201
+ value={name}
202
+ onChange={(event) => setName(event.target.value)}
203
+ placeholder="Ada"
204
+ />
205
+ </label>
206
+ ) : null}
207
+ <label>
208
+ Email
209
+ <input
210
+ type="email"
211
+ required
212
+ autoComplete="email"
213
+ value={email}
214
+ onChange={(event) => setEmail(event.target.value)}
215
+ />
216
+ </label>
217
+ <label>
218
+ Password
219
+ <input
220
+ type="password"
221
+ required
222
+ minLength={8}
223
+ autoComplete={mode === "signup" ? "new-password" : "current-password"}
224
+ value={password}
225
+ onChange={(event) => setPassword(event.target.value)}
226
+ />
227
+ </label>
228
+ <button type="submit" disabled={pending}>
229
+ {mode === "signup" ? "Create account" : "Sign in"}
230
+ </button>
231
+ </form>
232
+
233
+ <div className="or">or</div>
234
+ <button className="google" type="button" onClick={onGoogle} disabled={pending}>
235
+ Continue with Google
236
+ </button>
237
+ </main>
238
+ );
239
+ }
240
+
241
+ return (
242
+ <main className="chat">
243
+ <header>
244
+ <div>
245
+ <p className="eyebrow">Signed in</p>
246
+ <h1>{authorLabel(user)}</h1>
247
+ <p className="lede">{user.email}</p>
248
+ </div>
249
+ <button className="ghost" type="button" onClick={() => void onSignOut()}>
250
+ Sign out
251
+ </button>
252
+ </header>
253
+ {error ? <p className="error">{error}</p> : null}
254
+ <div className="transcript" ref={listRef}>
255
+ {messages.length === 0 ? <p className="empty">No messages yet. Say hello.</p> : null}
256
+ {messages.map((message) => {
257
+ const mine = message.userId === user.id;
258
+ return (
259
+ <article key={message.id} className={mine ? "bubble mine" : "bubble"}>
260
+ <div className="meta">
261
+ {authorLabel({ name: message.authorName, email: message.authorEmail })}
262
+ {message.createdAt
263
+ ? ` · ${new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
264
+ : ""}
265
+ </div>
266
+ <p>{message.body}</p>
267
+ </article>
268
+ );
269
+ })}
270
+ </div>
271
+ <form className="composer" onSubmit={onSend}>
272
+ <input
273
+ required
274
+ maxLength={2000}
275
+ placeholder="Message the room…"
276
+ value={draft}
277
+ onChange={(event) => setDraft(event.target.value)}
278
+ />
279
+ <button type="submit" disabled={pending || !draft.trim()}>
280
+ Send
281
+ </button>
282
+ </form>
283
+ </main>
284
+ );
285
+ }
286
+
287
+ createRoot(document.getElementById("root")!).render(
288
+ <StrictMode>
289
+ <App />
290
+ </StrictMode>,
291
+ );
@@ -0,0 +1,14 @@
1
+ {
2
+ "starters": [
3
+ {
4
+ "id": "space",
5
+ "title": "Space",
6
+ "description": "Planets, rockets, and a small React UI"
7
+ },
8
+ {
9
+ "id": "auth-chat",
10
+ "title": "Auth chat",
11
+ "description": "Email + Google sign-in and a shared chat room"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,7 @@
1
+ /** GET /health — liveness check for the deployed project host. */
2
+ import { defineApi, z } from "@robodev-ai/sdk";
3
+
4
+ export const get = defineApi({
5
+ response: z.object({ ok: z.literal(true) }),
6
+ handler: async () => ({ ok: true as const }),
7
+ });
@@ -0,0 +1,14 @@
1
+ /** GET /me — current project user (Robodev Auth required). */
2
+ import { defineApi, z } from "@robodev-ai/sdk";
3
+
4
+ export const get = defineApi({
5
+ auth: "required",
6
+ response: z.object({
7
+ user: z.object({
8
+ id: z.string(),
9
+ email: z.string(),
10
+ name: z.string().nullable().optional(),
11
+ }),
12
+ }),
13
+ handler: async ({ user }) => ({ user }),
14
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "{{PACKAGE_NAME}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "deploy": "robodev deploy"
9
+ },
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1",
13
+ "@robodev-ai/sdk": "^0.3.0",
14
+ "@robodev-ai/client": "^0.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^18.3.24",
18
+ "@types/react-dom": "^18.3.7",
19
+ "@vitejs/plugin-react": "^4.7.0",
20
+ "robodev": "^0.3.0",
21
+ "typescript": "^5.9.2",
22
+ "vite": "^6.3.5"
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["vite/client"]
11
+ },
12
+ "include": ["./**/*.ts", "./**/*.tsx"]
13
+ }
@@ -0,0 +1,7 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineConfig } from "vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: { port: 5173 },
7
+ });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes