robodev 0.3.1 → 0.5.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 +5 -3
- package/src/emit-starter.ts +183 -0
- package/src/generate-templates.ts +31 -0
- package/src/index.ts +95 -31
- package/templates/auth-chat/README.md +21 -0
- package/templates/auth-chat/api/messages.ts +41 -0
- package/templates/auth-chat/database.ts +19 -0
- package/templates/auth-chat/index.html +201 -0
- package/{template → templates/auth-chat}/package.json +4 -3
- package/templates/auth-chat/src/main.tsx +291 -0
- package/templates/catalog.json +14 -0
- package/templates/space/api/health.ts +7 -0
- package/templates/space/api/me.ts +14 -0
- package/templates/space/package.json +25 -0
- package/templates/space/tsconfig.json +13 -0
- package/templates/space/vite.config.ts +7 -0
- /package/{template → templates/auth-chat}/api/health.ts +0 -0
- /package/{template → templates/auth-chat}/api/me.ts +0 -0
- /package/{template → templates/auth-chat}/tsconfig.json +0 -0
- /package/{template → templates/auth-chat}/vite.config.ts +0 -0
- /package/{template → templates/space}/README.md +0 -0
- /package/{template → templates/space}/api/launch.ts +0 -0
- /package/{template → templates/space}/api/planets.ts +0 -0
- /package/{template → templates/space}/api/rockets.ts +0 -0
- /package/{template → templates/space}/database.ts +0 -0
- /package/{template → templates/space}/index.html +0 -0
- /package/{template → templates/space}/src/main.tsx +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,13 +15,15 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
17
17
|
"src",
|
|
18
|
-
"
|
|
18
|
+
"templates"
|
|
19
19
|
],
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"robodev": "tsx src/index.ts"
|
|
24
|
+
"robodev": "tsx src/index.ts",
|
|
25
|
+
"generate:templates": "tsx src/generate-templates.ts",
|
|
26
|
+
"prepack": "tsx src/generate-templates.ts"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
27
29
|
"tsx": "^4.20.5"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type Starter = { id: string; title: string; description: string };
|
|
6
|
+
|
|
7
|
+
export type PackageVersions = {
|
|
8
|
+
robodev: string;
|
|
9
|
+
sdk: string;
|
|
10
|
+
client: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type EmitContext = {
|
|
14
|
+
name: string;
|
|
15
|
+
packageName: string;
|
|
16
|
+
title: string;
|
|
17
|
+
versions: PackageVersions;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const SKIP_DIRS = new Set(["node_modules", ".robodev", ".git"]);
|
|
21
|
+
|
|
22
|
+
type DepMap = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
type PackageJson = {
|
|
25
|
+
name?: string;
|
|
26
|
+
dependencies?: DepMap;
|
|
27
|
+
devDependencies?: DepMap;
|
|
28
|
+
optionalDependencies?: DepMap;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function cliRoot(): string {
|
|
33
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveStarterTree(): Promise<{
|
|
37
|
+
catalogPath: string;
|
|
38
|
+
treeRoot: string;
|
|
39
|
+
catalogLabel: string;
|
|
40
|
+
}> {
|
|
41
|
+
const workspaceCatalog = join(cliRoot(), "..", "starters", "catalog.json");
|
|
42
|
+
if (await pathExists(workspaceCatalog)) {
|
|
43
|
+
return {
|
|
44
|
+
catalogPath: workspaceCatalog,
|
|
45
|
+
treeRoot: join(cliRoot(), "..", "starters"),
|
|
46
|
+
catalogLabel: "starters/catalog.json",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
catalogPath: join(cliRoot(), "templates", "catalog.json"),
|
|
51
|
+
treeRoot: join(cliRoot(), "templates"),
|
|
52
|
+
catalogLabel: "templates/catalog.json",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadCatalog(): Promise<Starter[]> {
|
|
57
|
+
const { catalogPath, treeRoot, catalogLabel } = await resolveStarterTree();
|
|
58
|
+
return loadCatalogFrom(treeRoot, catalogPath, catalogLabel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadCatalogFrom(
|
|
62
|
+
treeRoot: string,
|
|
63
|
+
catalogPath: string,
|
|
64
|
+
catalogLabel: string,
|
|
65
|
+
): Promise<Starter[]> {
|
|
66
|
+
let raw: { starters?: Starter[] };
|
|
67
|
+
try {
|
|
68
|
+
raw = JSON.parse(await readFile(catalogPath, "utf8")) as { starters?: Starter[] };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
71
|
+
throw new Error(`${catalogLabel} is missing`);
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(raw.starters)) {
|
|
76
|
+
throw new Error(`${catalogLabel} is missing a starters array`);
|
|
77
|
+
}
|
|
78
|
+
const treeLabel = catalogLabel.replace(/\/catalog\.json$/, "");
|
|
79
|
+
const starters: Starter[] = [];
|
|
80
|
+
for (const row of raw.starters) {
|
|
81
|
+
if (!(await isDirectory(join(treeRoot, row.id)))) {
|
|
82
|
+
throw new Error(`Starter "${row.id}" is missing a ${treeLabel}/${row.id} directory`);
|
|
83
|
+
}
|
|
84
|
+
starters.push(row);
|
|
85
|
+
}
|
|
86
|
+
return starters;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function loadPackageVersions(): Promise<PackageVersions> {
|
|
90
|
+
const root = cliRoot();
|
|
91
|
+
const repo = join(root, "..");
|
|
92
|
+
const robodev = await readPackageVersion(join(root, "package.json"));
|
|
93
|
+
return {
|
|
94
|
+
robodev,
|
|
95
|
+
sdk: await readPackageVersion(join(repo, "robodev-sdk", "package.json"), robodev),
|
|
96
|
+
client: await readPackageVersion(join(repo, "robodev-client", "package.json"), robodev),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Copies a starter tree and applies package + display rewrites used by create and prepack. */
|
|
101
|
+
export async function emitStarter(src: string, dest: string, ctx: EmitContext): Promise<void> {
|
|
102
|
+
await mkdir(dest, { recursive: true });
|
|
103
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
107
|
+
await emitStarter(join(src, entry.name), join(dest, entry.name), ctx);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!entry.isFile() || entry.name === ".env") continue;
|
|
111
|
+
const content = await readFile(join(src, entry.name), "utf8");
|
|
112
|
+
const next =
|
|
113
|
+
entry.name === "package.json"
|
|
114
|
+
? rewritePackageJson(content, ctx)
|
|
115
|
+
: rewriteDisplay(content, ctx);
|
|
116
|
+
await writeFile(join(dest, entry.name), next);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
121
|
+
try {
|
|
122
|
+
await stat(path);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
return (await stat(path)).isDirectory();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readPackageVersion(path: string, fallback?: string): Promise<string> {
|
|
140
|
+
try {
|
|
141
|
+
const pkg = JSON.parse(await readFile(path, "utf8")) as { version?: string };
|
|
142
|
+
if (typeof pkg.version === "string" && pkg.version) return pkg.version;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || fallback === undefined) {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (fallback !== undefined) return fallback;
|
|
149
|
+
throw new Error(`Missing version in ${path}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function rewritePackageJson(raw: string, ctx: EmitContext): string {
|
|
153
|
+
const pkg = JSON.parse(raw) as PackageJson;
|
|
154
|
+
pkg.name = ctx.packageName;
|
|
155
|
+
|
|
156
|
+
const pins: Record<string, string> = {
|
|
157
|
+
robodev: `^${ctx.versions.robodev}`,
|
|
158
|
+
"@robodev-ai/sdk": `^${ctx.versions.sdk}`,
|
|
159
|
+
"@robodev-ai/client": `^${ctx.versions.client}`,
|
|
160
|
+
};
|
|
161
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies"] as const) {
|
|
162
|
+
const deps = pkg[section];
|
|
163
|
+
if (!deps) continue;
|
|
164
|
+
for (const [name, pin] of Object.entries(pins)) {
|
|
165
|
+
if (deps[name] === "workspace:*") deps[name] = pin;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const moved = pkg.dependencies?.robodev;
|
|
170
|
+
if (pkg.dependencies) delete pkg.dependencies.robodev;
|
|
171
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
172
|
+
pkg.devDependencies.robodev = pkg.devDependencies.robodev ?? moved ?? pins.robodev;
|
|
173
|
+
|
|
174
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rewriteDisplay(content: string, ctx: EmitContext): string {
|
|
178
|
+
let next = content.replace(/<title>[^<]*<\/title>/g, `<title>${ctx.name}</title>`);
|
|
179
|
+
next = next.replace(/^# .+$/m, `# ${ctx.name}`);
|
|
180
|
+
next = next.replaceAll(`<h1>{"${ctx.title}"}</h1>`, `<h1>{"${ctx.name}"}</h1>`);
|
|
181
|
+
next = next.replaceAll(`<h1>${ctx.title}</h1>`, `<h1>${ctx.name}</h1>`);
|
|
182
|
+
return next.replaceAll("{{NAME}}", ctx.name).replaceAll("{{PACKAGE_NAME}}", ctx.packageName);
|
|
183
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { emitStarter, loadCatalogFrom, loadPackageVersions, cliRoot } from "./emit-starter.js";
|
|
4
|
+
|
|
5
|
+
const startersRoot = join(cliRoot(), "..", "starters");
|
|
6
|
+
const templatesRoot = join(cliRoot(), "templates");
|
|
7
|
+
const catalogLabel = "starters/catalog.json";
|
|
8
|
+
|
|
9
|
+
async function main(): Promise<void> {
|
|
10
|
+
const catalogPath = join(startersRoot, "catalog.json");
|
|
11
|
+
const starters = await loadCatalogFrom(startersRoot, catalogPath, catalogLabel);
|
|
12
|
+
const versions = await loadPackageVersions();
|
|
13
|
+
|
|
14
|
+
await rm(templatesRoot, { recursive: true, force: true });
|
|
15
|
+
await mkdir(templatesRoot, { recursive: true });
|
|
16
|
+
await copyFile(catalogPath, join(templatesRoot, "catalog.json"));
|
|
17
|
+
|
|
18
|
+
for (const starter of starters) {
|
|
19
|
+
await emitStarter(join(startersRoot, starter.id), join(templatesRoot, starter.id), {
|
|
20
|
+
name: "{{NAME}}",
|
|
21
|
+
packageName: "{{PACKAGE_NAME}}",
|
|
22
|
+
title: starter.title,
|
|
23
|
+
versions,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main().catch((error) => {
|
|
29
|
+
console.error(error instanceof Error ? error.message : error);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { mkdir, readdir, readFile, stat
|
|
3
|
+
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
|
-
import { basename,
|
|
6
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
7
7
|
import { execFile, spawn } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
9
|
import { ApiError, authed, publicRequest } from "./api.js";
|
|
11
10
|
import {
|
|
12
11
|
clearCredentials,
|
|
@@ -18,6 +17,13 @@ import {
|
|
|
18
17
|
writeViteApiUrl,
|
|
19
18
|
} from "./config.js";
|
|
20
19
|
import { waitUntilLive } from "./wait-until-live.js";
|
|
20
|
+
import {
|
|
21
|
+
emitStarter,
|
|
22
|
+
loadCatalog,
|
|
23
|
+
loadPackageVersions,
|
|
24
|
+
resolveStarterTree,
|
|
25
|
+
type Starter,
|
|
26
|
+
} from "./emit-starter.js";
|
|
21
27
|
|
|
22
28
|
const execFileAsync = promisify(execFile);
|
|
23
29
|
|
|
@@ -31,7 +37,7 @@ Commands:
|
|
|
31
37
|
logout Remove stored credentials
|
|
32
38
|
whoami Show the signed-in user
|
|
33
39
|
projects List your projects
|
|
34
|
-
create [path]
|
|
40
|
+
create [path] [--starter <id>] Create a project, scaffold a starter, and deploy
|
|
35
41
|
link [projectId] Write .robodev in the current folder
|
|
36
42
|
deploy [--force] Deploy database.ts and api/*.ts
|
|
37
43
|
`);
|
|
@@ -301,8 +307,77 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
|
|
|
301
307
|
}
|
|
302
308
|
}
|
|
303
309
|
|
|
304
|
-
function
|
|
305
|
-
return
|
|
310
|
+
function validIdsMessage(starters: Starter[]): string {
|
|
311
|
+
return `Valid ids: ${starters.map((starter) => starter.id).join(", ")}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseCreateArgs(args: string[]): { path?: string; starterId?: string } {
|
|
315
|
+
let path: string | undefined;
|
|
316
|
+
let starterId: string | undefined;
|
|
317
|
+
for (let i = 0; i < args.length; i++) {
|
|
318
|
+
const arg = args[i];
|
|
319
|
+
if (arg === "--starter") {
|
|
320
|
+
const value = args[i + 1];
|
|
321
|
+
if (!value || value.startsWith("-")) {
|
|
322
|
+
throw new Error("Missing value for --starter");
|
|
323
|
+
}
|
|
324
|
+
starterId = value;
|
|
325
|
+
i += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (arg.startsWith("--starter=")) {
|
|
329
|
+
const value = arg.slice("--starter=".length);
|
|
330
|
+
if (!value) {
|
|
331
|
+
throw new Error("Missing value for --starter");
|
|
332
|
+
}
|
|
333
|
+
starterId = value;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (arg.startsWith("-")) {
|
|
337
|
+
throw new Error(`Unknown flag ${arg}`);
|
|
338
|
+
}
|
|
339
|
+
if (path !== undefined) {
|
|
340
|
+
throw new Error(`Unexpected argument ${arg}`);
|
|
341
|
+
}
|
|
342
|
+
path = arg;
|
|
343
|
+
}
|
|
344
|
+
return { path, starterId };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function resolveStarter(flagId: string | undefined): Promise<Starter> {
|
|
348
|
+
const starters = await loadCatalog();
|
|
349
|
+
if (flagId !== undefined) {
|
|
350
|
+
const found = starters.find((starter) => starter.id === flagId);
|
|
351
|
+
if (!found) {
|
|
352
|
+
throw new Error(`Unknown starter "${flagId}". ${validIdsMessage(starters)}`);
|
|
353
|
+
}
|
|
354
|
+
return found;
|
|
355
|
+
}
|
|
356
|
+
if (stdin.isTTY) {
|
|
357
|
+
console.log("Starters:");
|
|
358
|
+
starters.forEach((starter, i) => {
|
|
359
|
+
console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
|
|
360
|
+
console.log(` ${starter.description}`);
|
|
361
|
+
});
|
|
362
|
+
const answer = (await prompt("Starter number or id: ")).trim();
|
|
363
|
+
if (!answer) {
|
|
364
|
+
throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
|
|
365
|
+
}
|
|
366
|
+
const asIndex = Number(answer);
|
|
367
|
+
if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
|
|
368
|
+
return starters[asIndex - 1];
|
|
369
|
+
}
|
|
370
|
+
const found = starters.find((starter) => starter.id === answer);
|
|
371
|
+
if (!found) {
|
|
372
|
+
throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
|
|
373
|
+
}
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
const space = starters.find((starter) => starter.id === "space");
|
|
377
|
+
if (!space) {
|
|
378
|
+
throw new Error(`Unknown starter "space". ${validIdsMessage(starters)}`);
|
|
379
|
+
}
|
|
380
|
+
return space;
|
|
306
381
|
}
|
|
307
382
|
|
|
308
383
|
function projectNameFromPath(root: string): string {
|
|
@@ -331,24 +406,6 @@ async function assertCreatable(root: string): Promise<void> {
|
|
|
331
406
|
}
|
|
332
407
|
}
|
|
333
408
|
|
|
334
|
-
async function copyTemplate(from: string, to: string, vars: Record<string, string>): Promise<void> {
|
|
335
|
-
await mkdir(to, { recursive: true });
|
|
336
|
-
const entries = await readdir(from, { withFileTypes: true });
|
|
337
|
-
for (const entry of entries) {
|
|
338
|
-
const src = join(from, entry.name);
|
|
339
|
-
const dest = join(to, entry.name);
|
|
340
|
-
if (entry.isDirectory()) {
|
|
341
|
-
await copyTemplate(src, dest, vars);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
let content = await readFile(src, "utf8");
|
|
345
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
346
|
-
content = content.replaceAll(`{{${key}}}`, value);
|
|
347
|
-
}
|
|
348
|
-
await writeFile(dest, content);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
409
|
async function npmInstall(root: string): Promise<void> {
|
|
353
410
|
console.log("Installing npm packages…");
|
|
354
411
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -361,9 +418,13 @@ async function npmInstall(root: string): Promise<void> {
|
|
|
361
418
|
});
|
|
362
419
|
}
|
|
363
420
|
|
|
364
|
-
/** Creates a Starbase project, copies the starter
|
|
365
|
-
async function runCreate(
|
|
366
|
-
const
|
|
421
|
+
/** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
|
|
422
|
+
async function runCreate(args: string[]): Promise<void> {
|
|
423
|
+
const parsed = parseCreateArgs(args);
|
|
424
|
+
const starter = await resolveStarter(parsed.starterId);
|
|
425
|
+
console.log(`Using starter ${starter.title} (${starter.id})`);
|
|
426
|
+
|
|
427
|
+
const root = resolve(process.cwd(), parsed.path ?? ".");
|
|
367
428
|
await mkdir(root, { recursive: true });
|
|
368
429
|
await assertCreatable(root);
|
|
369
430
|
|
|
@@ -373,9 +434,12 @@ async function runCreate(pathArg?: string): Promise<void> {
|
|
|
373
434
|
body: JSON.stringify({ name }),
|
|
374
435
|
});
|
|
375
436
|
|
|
376
|
-
await
|
|
377
|
-
|
|
378
|
-
|
|
437
|
+
const { treeRoot } = await resolveStarterTree();
|
|
438
|
+
await emitStarter(join(treeRoot, starter.id), root, {
|
|
439
|
+
name,
|
|
440
|
+
packageName: packageNameFromPath(root),
|
|
441
|
+
title: starter.title,
|
|
442
|
+
versions: await loadPackageVersions(),
|
|
379
443
|
});
|
|
380
444
|
await writeLink(
|
|
381
445
|
{
|
|
@@ -424,7 +488,7 @@ async function main(): Promise<void> {
|
|
|
424
488
|
await runProjects();
|
|
425
489
|
break;
|
|
426
490
|
case "create":
|
|
427
|
-
await runCreate(args
|
|
491
|
+
await runCreate(args);
|
|
428
492
|
break;
|
|
429
493
|
case "link":
|
|
430
494
|
await runLink(args[0]);
|
|
@@ -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>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{PACKAGE_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
3
4
|
"private": true,
|
|
4
5
|
"type": "module",
|
|
5
6
|
"scripts": {
|
|
@@ -10,15 +11,15 @@
|
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"react": "^18.3.1",
|
|
12
13
|
"react-dom": "^18.3.1",
|
|
13
|
-
"@robodev-ai/sdk": "^0.
|
|
14
|
+
"@robodev-ai/sdk": "^0.4.0",
|
|
14
15
|
"@robodev-ai/client": "^0.1.0"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
|
17
18
|
"@types/react": "^18.3.24",
|
|
18
19
|
"@types/react-dom": "^18.3.7",
|
|
19
20
|
"@vitejs/plugin-react": "^4.7.0",
|
|
20
|
-
"robodev": "^0.3.0",
|
|
21
21
|
"typescript": "^5.9.2",
|
|
22
|
-
"vite": "^6.3.5"
|
|
22
|
+
"vite": "^6.3.5",
|
|
23
|
+
"robodev": "^0.5.0"
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -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>{{NAME}}</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>{{NAME}}</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
|
+
/** 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,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PACKAGE_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"deploy": "robodev deploy"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1",
|
|
14
|
+
"@robodev-ai/sdk": "^0.4.0",
|
|
15
|
+
"@robodev-ai/client": "^0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.24",
|
|
19
|
+
"@types/react-dom": "^18.3.7",
|
|
20
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
21
|
+
"typescript": "^5.9.2",
|
|
22
|
+
"vite": "^6.3.5",
|
|
23
|
+
"robodev": "^0.5.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|
|
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
|