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 +4 -3
- package/src/config.ts +18 -4
- package/src/index.ts +125 -8
- package/template/README.md +17 -0
- package/template/api/health.ts +6 -0
- package/template/api/launch.ts +28 -0
- package/template/api/planets.ts +59 -0
- package/template/api/rockets.ts +42 -0
- package/template/database.ts +23 -0
- package/template/index.html +138 -0
- package/template/package.json +23 -0
- package/template/src/main.tsx +227 -0
- package/template/tsconfig.json +13 -0
- package/template/vite.config.ts +7 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "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({
|
|
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(
|
|
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,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
|
+
}
|