robodev 0.1.0 → 0.2.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.1.0",
4
- "description": "CLI for Robodev Starbase — auth, link, and deploy file-based APIs",
3
+ "version": "0.2.0",
4
+ "description": "CLI for Robodev Starbase — create, auth, link, and deploy file-based APIs",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "bin",
17
- "src"
17
+ "src",
18
+ "template"
18
19
  ],
19
20
  "publishConfig": {
20
21
  "access": "public"
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ export type Credentials = {
10
10
  export type ProjectLink = {
11
11
  projectId: string;
12
12
  apiUrl: string;
13
+ projectApiUrl?: string;
13
14
  };
14
15
 
15
16
  const PROD_URL = "https://robodev.povio.dev";
@@ -19,10 +20,7 @@ export function starbaseUrl(): string {
19
20
  }
20
21
 
21
22
  export function feUrl(): string {
22
- return (process.env.STARBASE_FE_URL ?? process.env.STARBASE_URL ?? PROD_URL).replace(
23
- /\/$/,
24
- "",
25
- );
23
+ return (process.env.STARBASE_FE_URL ?? process.env.STARBASE_URL ?? PROD_URL).replace(/\/$/, "");
26
24
  }
27
25
 
28
26
  export function credentialsPath(): string {
@@ -62,3 +60,19 @@ export async function readLink(cwd = process.cwd()): Promise<ProjectLink | null>
62
60
  export async function writeLink(link: ProjectLink, cwd = process.cwd()): Promise<void> {
63
61
  await writeFile(linkPath(cwd), `${JSON.stringify(link, null, 2)}\n`);
64
62
  }
63
+
64
+ export async function writeViteApiUrl(projectApiUrl: string, cwd = process.cwd()): Promise<void> {
65
+ const envPath = join(cwd, ".env");
66
+ let content = "";
67
+ try {
68
+ content = await readFile(envPath, "utf8");
69
+ } catch {
70
+ content = "";
71
+ }
72
+ if (/^VITE_API_URL=/m.test(content)) {
73
+ content = content.replace(/^VITE_API_URL=.*$/m, `VITE_API_URL=${projectApiUrl}`);
74
+ } else {
75
+ content = `${content.replace(/\s*$/, "")}${content ? "\n" : ""}VITE_API_URL=${projectApiUrl}\n`;
76
+ }
77
+ await writeFile(envPath, content.endsWith("\n") ? content : `${content}\n`);
78
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin, stdout } from "node:process";
6
- import { join, relative } from "node:path";
7
- import { execFile } from "node:child_process";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { execFile, spawn } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { ApiError, authed, publicRequest } from "./api.js";
10
11
  import {
11
12
  clearCredentials,
@@ -14,6 +15,7 @@ import {
14
15
  starbaseUrl,
15
16
  writeCredentials,
16
17
  writeLink,
18
+ writeViteApiUrl,
17
19
  } from "./config.js";
18
20
 
19
21
  const execFileAsync = promisify(execFile);
@@ -28,6 +30,7 @@ Commands:
28
30
  logout Remove stored credentials
29
31
  whoami Show the signed-in user
30
32
  projects List your projects
33
+ create [path] Create a project, scaffold the example app, and deploy
31
34
  link [projectId] Write .robodev in the current folder
32
35
  deploy [--force] Deploy database.ts and api/*.ts
33
36
  `);
@@ -171,8 +174,14 @@ async function runLink(projectId?: string): Promise<void> {
171
174
  if (!project) {
172
175
  throw new Error(`Project ${chosen} not found or not yours`);
173
176
  }
174
- await writeLink({ projectId: project.id, apiUrl: starbaseUrl() });
177
+ await writeLink({
178
+ projectId: project.id,
179
+ apiUrl: starbaseUrl(),
180
+ projectApiUrl: project.apiUrl,
181
+ });
182
+ await writeViteApiUrl(project.apiUrl);
175
183
  console.log(`Linked ${process.cwd()} to ${project.id}`);
184
+ console.log(`Frontend API ${project.apiUrl}`);
176
185
  }
177
186
 
178
187
  async function collectFiles(root: string): Promise<{ path: string; content: string }[]> {
@@ -211,12 +220,12 @@ async function collectFiles(root: string): Promise<{ path: string; content: stri
211
220
  return files;
212
221
  }
213
222
 
214
- async function runDeploy(forceFlag: boolean): Promise<void> {
215
- const link = await readLink();
223
+ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void> {
224
+ const link = await readLink(root);
216
225
  if (!link) {
217
- throw new Error("No .robodev file. Run `robodev link` first.");
226
+ throw new Error("No .robodev file. Run `robodev link` or `robodev create` first.");
218
227
  }
219
- const files = await collectFiles(process.cwd());
228
+ const files = await collectFiles(root);
220
229
  let force = forceFlag;
221
230
 
222
231
  for (;;) {
@@ -287,6 +296,111 @@ async function runDeploy(forceFlag: boolean): Promise<void> {
287
296
  }
288
297
  }
289
298
 
299
+ function templateRoot(): string {
300
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "template");
301
+ }
302
+
303
+ function projectNameFromPath(root: string): string {
304
+ const base = basename(root).trim();
305
+ if (!base || base === "." || base === "/") return "my-project";
306
+ return base.slice(0, 80);
307
+ }
308
+
309
+ function packageNameFromPath(root: string): string {
310
+ const slug = projectNameFromPath(root)
311
+ .toLowerCase()
312
+ .replace(/[^a-z0-9._-]+/g, "-")
313
+ .replace(/^[-.]+|[-.]+$/g, "");
314
+ return slug || "robodev-app";
315
+ }
316
+
317
+ async function assertCreatable(root: string): Promise<void> {
318
+ for (const file of ["package.json", "database.ts", ".robodev"]) {
319
+ try {
320
+ await stat(join(root, file));
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
323
+ throw error;
324
+ }
325
+ throw new Error(`Refusing to overwrite ${join(root, file)}`);
326
+ }
327
+ }
328
+
329
+ async function copyTemplate(from: string, to: string, vars: Record<string, string>): Promise<void> {
330
+ await mkdir(to, { recursive: true });
331
+ const entries = await readdir(from, { withFileTypes: true });
332
+ for (const entry of entries) {
333
+ const src = join(from, entry.name);
334
+ const dest = join(to, entry.name);
335
+ if (entry.isDirectory()) {
336
+ await copyTemplate(src, dest, vars);
337
+ continue;
338
+ }
339
+ let content = await readFile(src, "utf8");
340
+ for (const [key, value] of Object.entries(vars)) {
341
+ content = content.replaceAll(`{{${key}}}`, value);
342
+ }
343
+ await writeFile(dest, content);
344
+ }
345
+ }
346
+
347
+ async function npmInstall(root: string): Promise<void> {
348
+ console.log("Installing npm packages…");
349
+ await new Promise<void>((resolve, reject) => {
350
+ const child = spawn("npm", ["install"], { cwd: root, stdio: "inherit" });
351
+ child.on("error", reject);
352
+ child.on("exit", (code) => {
353
+ if (code === 0) resolve();
354
+ else reject(new Error(`npm install failed (${code})`));
355
+ });
356
+ });
357
+ }
358
+
359
+ async function runCreate(pathArg?: string): Promise<void> {
360
+ const root = resolve(process.cwd(), pathArg ?? ".");
361
+ await mkdir(root, { recursive: true });
362
+ await assertCreatable(root);
363
+
364
+ const name = projectNameFromPath(root);
365
+ const project = await authed<Project>("/v1/projects", {
366
+ method: "POST",
367
+ body: JSON.stringify({ name }),
368
+ });
369
+
370
+ await copyTemplate(templateRoot(), root, {
371
+ NAME: name,
372
+ PACKAGE_NAME: packageNameFromPath(root),
373
+ });
374
+ await writeLink(
375
+ {
376
+ projectId: project.id,
377
+ apiUrl: starbaseUrl(),
378
+ projectApiUrl: project.apiUrl,
379
+ },
380
+ root,
381
+ );
382
+ await writeViteApiUrl(project.apiUrl, root);
383
+
384
+ console.log(`Created ${name} (${project.id}) in ${root}`);
385
+ await runDeploy(false, root);
386
+
387
+ try {
388
+ await npmInstall(root);
389
+ } catch (error) {
390
+ console.log(error instanceof Error ? error.message : error);
391
+ console.log("Run `npm install` in the project folder, then `npm run dev`.");
392
+ return;
393
+ }
394
+
395
+ const displayPath = relative(process.cwd(), root) || ".";
396
+ console.log("");
397
+ console.log("Next:");
398
+ if (displayPath !== ".") {
399
+ console.log(` cd ${displayPath}`);
400
+ }
401
+ console.log(" npm run dev");
402
+ }
403
+
290
404
  async function main(): Promise<void> {
291
405
  const [command, ...args] = process.argv.slice(2);
292
406
  switch (command) {
@@ -303,6 +417,9 @@ async function main(): Promise<void> {
303
417
  case "projects":
304
418
  await runProjects();
305
419
  break;
420
+ case "create":
421
+ await runCreate(args.find((arg) => !arg.startsWith("-")));
422
+ break;
306
423
  case "link":
307
424
  await runLink(args[0]);
308
425
  break;
@@ -0,0 +1,17 @@
1
+ # {{NAME}}
2
+
3
+ Example Robodev app: a `space` database (`planets`, `rockets`), APIs in `api/`, and a small React UI.
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
+ Add tables in `database.ts` and routes as `api/<name>.ts`, then:
14
+
15
+ ```bash
16
+ npx robodev deploy
17
+ ```
@@ -0,0 +1,6 @@
1
+ import { defineApi, z } from "robodev-lib";
2
+
3
+ export const get = defineApi({
4
+ response: z.object({ ok: z.literal(true) }),
5
+ handler: async () => ({ ok: true as const }),
6
+ });
@@ -0,0 +1,28 @@
1
+ import { rockets } from "../database";
2
+ import { defineApi, eq, z } from "robodev-lib";
3
+
4
+ const Rocket = z.object({
5
+ id: z.string(),
6
+ name: z.string(),
7
+ destinationId: z.string(),
8
+ launched: z.boolean(),
9
+ });
10
+
11
+ export const post = defineApi({
12
+ body: z.object({
13
+ id: z.string().uuid(),
14
+ }),
15
+ response: Rocket,
16
+ handler: async ({ db, body }) => {
17
+ const rows = await db
18
+ .update(rockets)
19
+ .set({ launched: true })
20
+ .where(eq(rockets.id, body.id))
21
+ .returning();
22
+ const row = rows[0];
23
+ if (!row) {
24
+ throw new Error("Rocket not found");
25
+ }
26
+ return Rocket.parse(row);
27
+ },
28
+ });
@@ -0,0 +1,59 @@
1
+ import { planets, rockets } from "../database";
2
+ import { defineApi, z } from "robodev-lib";
3
+
4
+ const EARTH = "11111111-1111-1111-1111-111111111111";
5
+ const MARS = "22222222-2222-2222-2222-222222222222";
6
+ const KEPLER = "33333333-3333-3333-3333-333333333333";
7
+ const VOYAGER = "44444444-4444-4444-4444-444444444444";
8
+ const ODYSSEY = "55555555-5555-5555-5555-555555555555";
9
+
10
+ const Planet = z.object({
11
+ id: z.string(),
12
+ name: z.string(),
13
+ climate: z.string(),
14
+ moons: z.coerce.number(),
15
+ });
16
+
17
+ export const get = defineApi({
18
+ response: z.array(Planet),
19
+ handler: async ({ db }) => {
20
+ const existing = await db.select().from(planets).limit(1);
21
+ if (existing.length === 0) {
22
+ try {
23
+ await db.insert(planets).values([
24
+ { id: EARTH, name: "Earth", climate: "temperate", moons: 1 },
25
+ { id: MARS, name: "Mars", climate: "arid", moons: 2 },
26
+ { id: KEPLER, name: "Kepler-452b", climate: "unknown", moons: 0 },
27
+ ]);
28
+ await db.insert(rockets).values([
29
+ { id: VOYAGER, name: "Voyager", destinationId: MARS, launched: false },
30
+ { id: ODYSSEY, name: "Odyssey", destinationId: KEPLER, launched: true },
31
+ ]);
32
+ } catch {
33
+ // First request won the seed race.
34
+ }
35
+ }
36
+ const rows = await db.select().from(planets);
37
+ return Planet.array().parse(rows);
38
+ },
39
+ });
40
+
41
+ export const post = defineApi({
42
+ body: z.object({
43
+ name: z.string().min(1),
44
+ climate: z.string().min(1),
45
+ moons: z.coerce.number().int().min(0).default(0),
46
+ }),
47
+ response: Planet,
48
+ handler: async ({ db, body }) => {
49
+ const [row] = await db
50
+ .insert(planets)
51
+ .values({
52
+ name: body.name,
53
+ climate: body.climate,
54
+ moons: body.moons,
55
+ })
56
+ .returning();
57
+ return Planet.parse(row);
58
+ },
59
+ });
@@ -0,0 +1,42 @@
1
+ import { rockets } from "../database";
2
+ import { defineApi, eq, z } from "robodev-lib";
3
+
4
+ const Rocket = z.object({
5
+ id: z.string(),
6
+ name: z.string(),
7
+ destinationId: z.string(),
8
+ launched: z.boolean(),
9
+ });
10
+
11
+ export const get = defineApi({
12
+ query: z.object({
13
+ destinationId: z.string().optional(),
14
+ }),
15
+ response: z.array(Rocket),
16
+ handler: async ({ db, query }) => {
17
+ const rows = query.destinationId
18
+ ? await db.select().from(rockets).where(eq(rockets.destinationId, query.destinationId))
19
+ : await db.select().from(rockets);
20
+ return Rocket.array().parse(rows);
21
+ },
22
+ });
23
+
24
+ export const post = defineApi({
25
+ body: z.object({
26
+ name: z.string().min(1),
27
+ destinationId: z.string().uuid(),
28
+ launched: z.boolean().optional(),
29
+ }),
30
+ response: Rocket,
31
+ handler: async ({ db, body }) => {
32
+ const [row] = await db
33
+ .insert(rockets)
34
+ .values({
35
+ name: body.name,
36
+ destinationId: body.destinationId,
37
+ launched: body.launched ?? false,
38
+ })
39
+ .returning();
40
+ return Rocket.parse(row);
41
+ },
42
+ });
@@ -0,0 +1,23 @@
1
+ import { boolean, defineDatabase, integer, pgTable, text, timestamp, uuid } from "robodev-lib";
2
+
3
+ export const planets = pgTable("planets", {
4
+ id: uuid("id").primaryKey().defaultRandom(),
5
+ name: text("name").notNull(),
6
+ climate: text("climate").notNull(),
7
+ moons: integer("moons").default(0),
8
+ });
9
+
10
+ export const rockets = pgTable("rockets", {
11
+ id: uuid("id").primaryKey().defaultRandom(),
12
+ name: text("name").notNull(),
13
+ destinationId: uuid("destinationId")
14
+ .notNull()
15
+ .references(() => planets.id, { onDelete: "cascade" }),
16
+ launched: boolean("launched").default(false),
17
+ createdAt: timestamp("createdAt", { withTimezone: true }).defaultNow(),
18
+ });
19
+
20
+ export default defineDatabase({
21
+ name: "space",
22
+ tables: { planets, rockets },
23
+ });
@@ -0,0 +1,138 @@
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: #12141a;
11
+ --panel: #1b1e27;
12
+ --line: #2c3140;
13
+ --text: #ece8df;
14
+ --muted: #9a9488;
15
+ --accent: #d4a15a;
16
+ }
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+ body {
21
+ margin: 0;
22
+ min-height: 100vh;
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ font:
26
+ 15px/1.5 ui-sans-serif,
27
+ system-ui,
28
+ sans-serif;
29
+ }
30
+ main {
31
+ max-width: 920px;
32
+ margin: 0 auto;
33
+ padding: 32px 20px 64px;
34
+ }
35
+ h1 {
36
+ font-size: 28px;
37
+ font-weight: 600;
38
+ letter-spacing: -0.03em;
39
+ margin: 0 0 6px;
40
+ }
41
+ .lede {
42
+ color: var(--muted);
43
+ margin: 0 0 28px;
44
+ }
45
+ .grid {
46
+ display: grid;
47
+ gap: 20px;
48
+ grid-template-columns: 1fr;
49
+ }
50
+ @media (min-width: 760px) {
51
+ .grid {
52
+ grid-template-columns: 1fr 1fr;
53
+ }
54
+ }
55
+ section {
56
+ background: var(--panel);
57
+ border: 1px solid var(--line);
58
+ border-radius: 10px;
59
+ padding: 18px;
60
+ }
61
+ h2 {
62
+ margin: 0 0 14px;
63
+ font-size: 16px;
64
+ font-weight: 600;
65
+ }
66
+ ul {
67
+ list-style: none;
68
+ margin: 0 0 16px;
69
+ padding: 0;
70
+ }
71
+ li {
72
+ display: flex;
73
+ justify-content: space-between;
74
+ gap: 12px;
75
+ align-items: baseline;
76
+ padding: 10px 0;
77
+ border-top: 1px solid var(--line);
78
+ }
79
+ li:first-child {
80
+ border-top: 0;
81
+ padding-top: 0;
82
+ }
83
+ .meta {
84
+ color: var(--muted);
85
+ font-size: 13px;
86
+ }
87
+ form {
88
+ display: grid;
89
+ gap: 8px;
90
+ }
91
+ label {
92
+ display: grid;
93
+ gap: 4px;
94
+ font-size: 12px;
95
+ color: var(--muted);
96
+ }
97
+ input,
98
+ select,
99
+ button {
100
+ font: inherit;
101
+ color: var(--text);
102
+ }
103
+ input,
104
+ select {
105
+ background: var(--bg);
106
+ border: 1px solid var(--line);
107
+ border-radius: 6px;
108
+ padding: 8px 10px;
109
+ }
110
+ button {
111
+ background: var(--accent);
112
+ color: #1a140b;
113
+ border: 0;
114
+ border-radius: 6px;
115
+ padding: 8px 12px;
116
+ font-weight: 600;
117
+ cursor: pointer;
118
+ }
119
+ button.ghost {
120
+ background: transparent;
121
+ color: var(--accent);
122
+ border: 1px solid var(--accent);
123
+ }
124
+ button:disabled {
125
+ opacity: 0.5;
126
+ cursor: default;
127
+ }
128
+ .error {
129
+ color: #e08b8b;
130
+ margin: 0 0 16px;
131
+ }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div id="root"></div>
136
+ <script type="module" src="/src/main.tsx"></script>
137
+ </body>
138
+ </html>
@@ -0,0 +1,23 @@
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-lib": "^0.2.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^18.3.24",
17
+ "@types/react-dom": "^18.3.7",
18
+ "@vitejs/plugin-react": "^4.7.0",
19
+ "robodev": "^0.2.0",
20
+ "typescript": "^5.9.2",
21
+ "vite": "^6.3.5"
22
+ }
23
+ }
@@ -0,0 +1,227 @@
1
+ import { StrictMode, useEffect, useState, type FormEvent } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ type Planet = { id: string; name: string; climate: string; moons: number };
5
+ type Rocket = { id: string; name: string; destinationId: string; launched: boolean };
6
+
7
+ const apiUrl = import.meta.env.VITE_API_URL as string | undefined;
8
+
9
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
10
+ if (!apiUrl) {
11
+ throw new Error("Missing VITE_API_URL. Run `robodev create` or `robodev link`.");
12
+ }
13
+ const res = await fetch(`${apiUrl}${path}`, {
14
+ ...init,
15
+ headers: { "Content-Type": "application/json", ...init?.headers },
16
+ });
17
+ if (!res.ok) {
18
+ throw new Error((await res.text()) || `${res.status} ${path}`);
19
+ }
20
+ return res.json() as Promise<T>;
21
+ }
22
+
23
+ function App() {
24
+ const [planets, setPlanets] = useState<Planet[]>([]);
25
+ const [rockets, setRockets] = useState<Rocket[]>([]);
26
+ const [error, setError] = useState<string | null>(null);
27
+ const [pending, setPending] = useState(false);
28
+ const [planetForm, setPlanetForm] = useState({ name: "", climate: "", moons: "0" });
29
+ const [rocketForm, setRocketForm] = useState({ name: "", destinationId: "" });
30
+
31
+ async function refresh() {
32
+ const nextPlanets = await request<Planet[]>("/planets");
33
+ const nextRockets = await request<Rocket[]>("/rockets");
34
+ setPlanets(nextPlanets);
35
+ setRockets(nextRockets);
36
+ setRocketForm((form) => ({
37
+ ...form,
38
+ destinationId: form.destinationId || nextPlanets[0]?.id || "",
39
+ }));
40
+ }
41
+
42
+ useEffect(() => {
43
+ void refresh().catch((err: unknown) =>
44
+ setError(err instanceof Error ? err.message : "Failed to load"),
45
+ );
46
+ }, []);
47
+
48
+ async function onAddPlanet(event: FormEvent) {
49
+ event.preventDefault();
50
+ setPending(true);
51
+ setError(null);
52
+ try {
53
+ await request("/planets", {
54
+ method: "POST",
55
+ body: JSON.stringify({
56
+ name: planetForm.name,
57
+ climate: planetForm.climate,
58
+ moons: Number(planetForm.moons),
59
+ }),
60
+ });
61
+ setPlanetForm({ name: "", climate: "", moons: "0" });
62
+ await refresh();
63
+ } catch (err) {
64
+ setError(err instanceof Error ? err.message : "Could not add planet");
65
+ } finally {
66
+ setPending(false);
67
+ }
68
+ }
69
+
70
+ async function onAddRocket(event: FormEvent) {
71
+ event.preventDefault();
72
+ setPending(true);
73
+ setError(null);
74
+ try {
75
+ await request("/rockets", {
76
+ method: "POST",
77
+ body: JSON.stringify({
78
+ name: rocketForm.name,
79
+ destinationId: rocketForm.destinationId,
80
+ }),
81
+ });
82
+ setRocketForm((form) => ({ ...form, name: "" }));
83
+ await refresh();
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : "Could not add rocket");
86
+ } finally {
87
+ setPending(false);
88
+ }
89
+ }
90
+
91
+ async function onLaunch(id: string) {
92
+ setPending(true);
93
+ setError(null);
94
+ try {
95
+ await request("/launch", {
96
+ method: "POST",
97
+ body: JSON.stringify({ id }),
98
+ });
99
+ await refresh();
100
+ } catch (err) {
101
+ setError(err instanceof Error ? err.message : "Could not launch");
102
+ } finally {
103
+ setPending(false);
104
+ }
105
+ }
106
+
107
+ const planetName = (id: string) => planets.find((planet) => planet.id === id)?.name ?? id;
108
+
109
+ return (
110
+ <main>
111
+ <h1>{"{{NAME}}"}</h1>
112
+ <p className="lede">
113
+ React frontend talking to your Starbase APIs at {apiUrl ?? "(not linked)"}.
114
+ </p>
115
+ {error ? <p className="error">{error}</p> : null}
116
+
117
+ <div className="grid">
118
+ <section>
119
+ <h2>Planets</h2>
120
+ <ul>
121
+ {planets.map((planet) => (
122
+ <li key={planet.id}>
123
+ <div>
124
+ <strong>{planet.name}</strong>
125
+ <div className="meta">
126
+ {planet.climate} · {planet.moons} {planet.moons === 1 ? "moon" : "moons"}
127
+ </div>
128
+ </div>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ <form onSubmit={onAddPlanet}>
133
+ <label>
134
+ Name
135
+ <input
136
+ required
137
+ value={planetForm.name}
138
+ onChange={(event) => setPlanetForm({ ...planetForm, name: event.target.value })}
139
+ />
140
+ </label>
141
+ <label>
142
+ Climate
143
+ <input
144
+ required
145
+ value={planetForm.climate}
146
+ onChange={(event) => setPlanetForm({ ...planetForm, climate: event.target.value })}
147
+ />
148
+ </label>
149
+ <label>
150
+ Moons
151
+ <input
152
+ type="number"
153
+ min={0}
154
+ value={planetForm.moons}
155
+ onChange={(event) => setPlanetForm({ ...planetForm, moons: event.target.value })}
156
+ />
157
+ </label>
158
+ <button type="submit" disabled={pending}>
159
+ Add planet
160
+ </button>
161
+ </form>
162
+ </section>
163
+
164
+ <section>
165
+ <h2>Rockets</h2>
166
+ <ul>
167
+ {rockets.map((rocket) => (
168
+ <li key={rocket.id}>
169
+ <div>
170
+ <strong>{rocket.name}</strong>
171
+ <div className="meta">
172
+ {planetName(rocket.destinationId)} · {rocket.launched ? "launched" : "on pad"}
173
+ </div>
174
+ </div>
175
+ {rocket.launched ? null : (
176
+ <button
177
+ className="ghost"
178
+ type="button"
179
+ disabled={pending}
180
+ onClick={() => onLaunch(rocket.id)}
181
+ >
182
+ Launch
183
+ </button>
184
+ )}
185
+ </li>
186
+ ))}
187
+ </ul>
188
+ <form onSubmit={onAddRocket}>
189
+ <label>
190
+ Name
191
+ <input
192
+ required
193
+ value={rocketForm.name}
194
+ onChange={(event) => setRocketForm({ ...rocketForm, name: event.target.value })}
195
+ />
196
+ </label>
197
+ <label>
198
+ Destination
199
+ <select
200
+ required
201
+ value={rocketForm.destinationId}
202
+ onChange={(event) =>
203
+ setRocketForm({ ...rocketForm, destinationId: event.target.value })
204
+ }
205
+ >
206
+ {planets.map((planet) => (
207
+ <option key={planet.id} value={planet.id}>
208
+ {planet.name}
209
+ </option>
210
+ ))}
211
+ </select>
212
+ </label>
213
+ <button type="submit" disabled={pending || planets.length === 0}>
214
+ Add rocket
215
+ </button>
216
+ </form>
217
+ </section>
218
+ </div>
219
+ </main>
220
+ );
221
+ }
222
+
223
+ createRoot(document.getElementById("root")!).render(
224
+ <StrictMode>
225
+ <App />
226
+ </StrictMode>,
227
+ );
@@ -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
+ });