robodev 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/src/collect-files.test.ts +41 -0
- package/src/collect-files.ts +164 -0
- package/src/emit-starter.ts +183 -0
- package/src/generate-templates.ts +31 -0
- package/src/index.ts +23 -100
- package/templates/auth-chat/README.md +4 -8
- package/templates/auth-chat/package.json +5 -4
- package/templates/auth-chat/src/main.tsx +12 -23
- package/templates/space/README.md +4 -8
- package/templates/space/package.json +5 -4
- package/templates/space/src/main.tsx +10 -9
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Robodev Starbase — create, auth, link, and deploy
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -21,7 +21,10 @@
|
|
|
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",
|
|
27
|
+
"test": "tsx --test src/**/*.test.ts"
|
|
25
28
|
},
|
|
26
29
|
"dependencies": {
|
|
27
30
|
"tsx": "^4.20.5"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { collectFiles } from "./collect-files.js";
|
|
8
|
+
|
|
9
|
+
test("collectFiles includes hosted sources and skips denied paths", async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
|
|
11
|
+
try {
|
|
12
|
+
await writeFile(join(root, "database.ts"), "export default {}");
|
|
13
|
+
await writeFile(join(root, "index.html"), "<html></html>");
|
|
14
|
+
await mkdir(join(root, "src"), { recursive: true });
|
|
15
|
+
await writeFile(join(root, "src/main.tsx"), "export {}");
|
|
16
|
+
await mkdir(join(root, "api"), { recursive: true });
|
|
17
|
+
await writeFile(join(root, "api/x.ts"), "export {}");
|
|
18
|
+
await writeFile(join(root, "package.json"), "{}");
|
|
19
|
+
await mkdir(join(root, "node_modules"), { recursive: true });
|
|
20
|
+
await writeFile(join(root, "node_modules/x.js"), "nope");
|
|
21
|
+
await mkdir(join(root, "dist"), { recursive: true });
|
|
22
|
+
await writeFile(join(root, "dist/x.js"), "nope");
|
|
23
|
+
await writeFile(join(root, ".env"), "SECRET=1");
|
|
24
|
+
await mkdir(join(root, ".robodev"), { recursive: true });
|
|
25
|
+
await writeFile(join(root, ".robodev/x"), "nope");
|
|
26
|
+
|
|
27
|
+
const files = await collectFiles(root);
|
|
28
|
+
const paths = new Set(files.map((file) => file.path));
|
|
29
|
+
assert.ok(paths.has("index.html"));
|
|
30
|
+
assert.ok(paths.has("src/main.tsx"));
|
|
31
|
+
assert.ok(paths.has("database.ts"));
|
|
32
|
+
assert.ok(paths.has("api/x.ts"));
|
|
33
|
+
assert.ok(paths.has("package.json"));
|
|
34
|
+
assert.ok(!paths.has("node_modules/x.js"));
|
|
35
|
+
assert.ok(!paths.has("dist/x.js"));
|
|
36
|
+
assert.ok(!paths.has(".env"));
|
|
37
|
+
assert.ok(!paths.has(".robodev/x"));
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(root, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const MAX_FILE_COUNT = 200;
|
|
5
|
+
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
6
|
+
export const MAX_TREE_BYTES = 15 * 1024 * 1024;
|
|
7
|
+
export const MAX_PATH_LENGTH = 240;
|
|
8
|
+
export const MAX_PATH_DEPTH = 12;
|
|
9
|
+
|
|
10
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
|
|
11
|
+
const BINARY_EXTENSIONS = new Set([
|
|
12
|
+
".png",
|
|
13
|
+
".jpg",
|
|
14
|
+
".jpeg",
|
|
15
|
+
".gif",
|
|
16
|
+
".webp",
|
|
17
|
+
".ico",
|
|
18
|
+
".bmp",
|
|
19
|
+
".woff",
|
|
20
|
+
".woff2",
|
|
21
|
+
".ttf",
|
|
22
|
+
".eot",
|
|
23
|
+
".otf",
|
|
24
|
+
".wasm",
|
|
25
|
+
".zip",
|
|
26
|
+
".gz",
|
|
27
|
+
".tgz",
|
|
28
|
+
".7z",
|
|
29
|
+
".pdf",
|
|
30
|
+
".mp4",
|
|
31
|
+
".webm",
|
|
32
|
+
".mp3",
|
|
33
|
+
]);
|
|
34
|
+
const SRC_EXTENSIONS = new Set([
|
|
35
|
+
".ts",
|
|
36
|
+
".tsx",
|
|
37
|
+
".js",
|
|
38
|
+
".jsx",
|
|
39
|
+
".mjs",
|
|
40
|
+
".cjs",
|
|
41
|
+
".css",
|
|
42
|
+
".json",
|
|
43
|
+
".svg",
|
|
44
|
+
]);
|
|
45
|
+
const PUBLIC_EXTENSIONS = new Set([...SRC_EXTENSIONS, ".html", ".txt", ".md"]);
|
|
46
|
+
const ROOT_STATIC_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".txt", ".md"]);
|
|
47
|
+
const CONFIG_FILES = new Set([
|
|
48
|
+
"package.json",
|
|
49
|
+
"package-lock.json",
|
|
50
|
+
"pnpm-lock.yaml",
|
|
51
|
+
"tsconfig.json",
|
|
52
|
+
"vite.config.ts",
|
|
53
|
+
"vite.config.js",
|
|
54
|
+
"vite.config.mts",
|
|
55
|
+
"vite.config.mjs",
|
|
56
|
+
"README.md",
|
|
57
|
+
".gitignore",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export type CollectedFile = { path: string; content: string };
|
|
61
|
+
|
|
62
|
+
function normalize(path: string): string {
|
|
63
|
+
return path.replaceAll("\\", "/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extname(path: string): string {
|
|
67
|
+
const base = path.split("/").pop() ?? "";
|
|
68
|
+
const dot = base.lastIndexOf(".");
|
|
69
|
+
if (dot <= 0) return "";
|
|
70
|
+
return base.slice(dot).toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function basename(path: string): string {
|
|
74
|
+
return path.split("/").pop() ?? path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isUnsafe(path: string): boolean {
|
|
78
|
+
return (
|
|
79
|
+
path.startsWith("/") ||
|
|
80
|
+
path.includes("..") ||
|
|
81
|
+
path.split("/").some((segment) => segment === "") ||
|
|
82
|
+
/^[A-Za-z]:/.test(path)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDenied(path: string): boolean {
|
|
87
|
+
const top = path.split("/")[0];
|
|
88
|
+
if (SKIP_DIRS.has(top)) return true;
|
|
89
|
+
if (path === ".env" || path.startsWith(".env.")) return true;
|
|
90
|
+
const name = basename(path);
|
|
91
|
+
if (name.startsWith(".") && name !== ".gitignore") return true;
|
|
92
|
+
if (BINARY_EXTENSIONS.has(extname(path))) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isAllowed(path: string): boolean {
|
|
97
|
+
if (path === "database.ts") return true;
|
|
98
|
+
if (path.startsWith("api/")) return path.endsWith(".ts");
|
|
99
|
+
if (path === "index.html") return true;
|
|
100
|
+
if (path.startsWith("src/")) return SRC_EXTENSIONS.has(extname(path));
|
|
101
|
+
if (path.startsWith("public/")) return PUBLIC_EXTENSIONS.has(extname(path));
|
|
102
|
+
if (CONFIG_FILES.has(path) || CONFIG_FILES.has(basename(path))) return true;
|
|
103
|
+
if (/^tsconfig\..+\.json$/.test(basename(path)) && !path.includes("/")) return true;
|
|
104
|
+
if (!path.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(path))) return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shouldCollectPath(path: string): boolean {
|
|
109
|
+
const normalized = normalize(path);
|
|
110
|
+
return !isUnsafe(normalized) && !isDenied(normalized) && isAllowed(normalized);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function collectFiles(root: string): Promise<CollectedFile[]> {
|
|
114
|
+
try {
|
|
115
|
+
await stat(join(root, "database.ts"));
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error("database.ts not found in the current directory");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const files: CollectedFile[] = [];
|
|
121
|
+
let totalBytes = 0;
|
|
122
|
+
|
|
123
|
+
async function walk(dir: string): Promise<void> {
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const abs = join(dir, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
134
|
+
await walk(abs);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!entry.isFile()) continue;
|
|
138
|
+
const path = normalize(relative(root, abs));
|
|
139
|
+
if (!shouldCollectPath(path)) continue;
|
|
140
|
+
if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
|
|
141
|
+
throw new Error(`Invalid file path: ${path}`);
|
|
142
|
+
}
|
|
143
|
+
const content = await readFile(abs, "utf8");
|
|
144
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
145
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
146
|
+
throw new Error(`File must be under 1MB: ${path}`);
|
|
147
|
+
}
|
|
148
|
+
totalBytes += bytes;
|
|
149
|
+
if (totalBytes > MAX_TREE_BYTES) {
|
|
150
|
+
throw new Error("Deploy tree must be under 15MB");
|
|
151
|
+
}
|
|
152
|
+
files.push({ path, content });
|
|
153
|
+
if (files.length > MAX_FILE_COUNT) {
|
|
154
|
+
throw new Error(`Deploy may include at most ${MAX_FILE_COUNT} files`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await walk(root);
|
|
160
|
+
if (!files.some((file) => file.path === "database.ts")) {
|
|
161
|
+
throw new Error("database.ts not found in the current directory");
|
|
162
|
+
}
|
|
163
|
+
return files;
|
|
164
|
+
}
|
|
@@ -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,
|
|
3
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
|
-
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,14 @@ import {
|
|
|
18
17
|
writeViteApiUrl,
|
|
19
18
|
} from "./config.js";
|
|
20
19
|
import { waitUntilLive } from "./wait-until-live.js";
|
|
20
|
+
import { collectFiles } from "./collect-files.js";
|
|
21
|
+
import {
|
|
22
|
+
emitStarter,
|
|
23
|
+
loadCatalog,
|
|
24
|
+
loadPackageVersions,
|
|
25
|
+
resolveStarterTree,
|
|
26
|
+
type Starter,
|
|
27
|
+
} from "./emit-starter.js";
|
|
21
28
|
|
|
22
29
|
const execFileAsync = promisify(execFile);
|
|
23
30
|
|
|
@@ -33,7 +40,7 @@ Commands:
|
|
|
33
40
|
projects List your projects
|
|
34
41
|
create [path] [--starter <id>] Create a project, scaffold a starter, and deploy
|
|
35
42
|
link [projectId] Write .robodev in the current folder
|
|
36
|
-
deploy [--force] Deploy
|
|
43
|
+
deploy [--force] Deploy the project file tree (schema, APIs, and frontend)
|
|
37
44
|
`);
|
|
38
45
|
process.exit(1);
|
|
39
46
|
}
|
|
@@ -186,44 +193,7 @@ async function runLink(projectId?: string): Promise<void> {
|
|
|
186
193
|
console.log(`Frontend API ${project.apiUrl}`);
|
|
187
194
|
}
|
|
188
195
|
|
|
189
|
-
/**
|
|
190
|
-
async function collectFiles(root: string): Promise<{ path: string; content: string }[]> {
|
|
191
|
-
const files: { path: string; content: string }[] = [];
|
|
192
|
-
const database = join(root, "database.ts");
|
|
193
|
-
try {
|
|
194
|
-
await stat(database);
|
|
195
|
-
files.push({ path: "database.ts", content: await readFile(database, "utf8") });
|
|
196
|
-
} catch {
|
|
197
|
-
throw new Error("database.ts not found in the current directory");
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function walk(dir: string): Promise<void> {
|
|
201
|
-
let entries;
|
|
202
|
-
try {
|
|
203
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
204
|
-
} catch {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
for (const entry of entries) {
|
|
208
|
-
const abs = join(dir, entry.name);
|
|
209
|
-
if (entry.isDirectory()) {
|
|
210
|
-
await walk(abs);
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
if (entry.isFile() && entry.name.endsWith(".ts")) {
|
|
214
|
-
files.push({
|
|
215
|
-
path: relative(root, abs).replaceAll("\\", "/"),
|
|
216
|
-
content: await readFile(abs, "utf8"),
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
await walk(join(root, "api"));
|
|
223
|
-
return files;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Uploads schema + APIs. Destructive schema changes need confirmation or `--force`. */
|
|
196
|
+
/** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
|
|
227
197
|
async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void> {
|
|
228
198
|
const link = await readLink(root);
|
|
229
199
|
if (!link) {
|
|
@@ -239,6 +209,7 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
|
|
|
239
209
|
databaseName: string;
|
|
240
210
|
databaseCreated?: boolean;
|
|
241
211
|
apiUrl: string;
|
|
212
|
+
appUrl?: string;
|
|
242
213
|
docsUrl: string;
|
|
243
214
|
routes: { method: string; path: string }[];
|
|
244
215
|
plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
|
|
@@ -247,7 +218,7 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
|
|
|
247
218
|
body: JSON.stringify({ force, files }),
|
|
248
219
|
});
|
|
249
220
|
await waitUntilLive(result.apiUrl);
|
|
250
|
-
console.log(`
|
|
221
|
+
console.log(`App ${result.appUrl ?? result.apiUrl}`);
|
|
251
222
|
console.log(`Docs ${result.docsUrl}`);
|
|
252
223
|
console.log(
|
|
253
224
|
result.databaseCreated
|
|
@@ -296,51 +267,18 @@ async function runDeploy(forceFlag: boolean, root = process.cwd()): Promise<void
|
|
|
296
267
|
force = true;
|
|
297
268
|
continue;
|
|
298
269
|
}
|
|
270
|
+
if (error instanceof ApiError && error.status === 413) {
|
|
271
|
+
throw new Error(error.message);
|
|
272
|
+
}
|
|
299
273
|
throw error;
|
|
300
274
|
}
|
|
301
275
|
}
|
|
302
276
|
}
|
|
303
277
|
|
|
304
|
-
type Starter = { id: string; title: string; description: string };
|
|
305
|
-
|
|
306
|
-
function cliRoot(): string {
|
|
307
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function templateRoot(id: string): string {
|
|
311
|
-
return join(cliRoot(), "templates", id);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
278
|
function validIdsMessage(starters: Starter[]): string {
|
|
315
279
|
return `Valid ids: ${starters.map((starter) => starter.id).join(", ")}`;
|
|
316
280
|
}
|
|
317
281
|
|
|
318
|
-
async function isDirectory(path: string): Promise<boolean> {
|
|
319
|
-
try {
|
|
320
|
-
return (await stat(path)).isDirectory();
|
|
321
|
-
} catch (error) {
|
|
322
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
323
|
-
throw error;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function loadCatalog(): Promise<Starter[]> {
|
|
328
|
-
const raw = JSON.parse(await readFile(join(cliRoot(), "templates", "catalog.json"), "utf8")) as {
|
|
329
|
-
starters?: Starter[];
|
|
330
|
-
};
|
|
331
|
-
if (!Array.isArray(raw.starters)) {
|
|
332
|
-
throw new Error("templates/catalog.json is missing a starters array");
|
|
333
|
-
}
|
|
334
|
-
const starters: Starter[] = [];
|
|
335
|
-
for (const row of raw.starters) {
|
|
336
|
-
if (!(await isDirectory(templateRoot(row.id)))) {
|
|
337
|
-
throw new Error(`Starter "${row.id}" is missing a templates/${row.id} directory`);
|
|
338
|
-
}
|
|
339
|
-
starters.push(row);
|
|
340
|
-
}
|
|
341
|
-
return starters;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
282
|
function parseCreateArgs(args: string[]): { path?: string; starterId?: string } {
|
|
345
283
|
let path: string | undefined;
|
|
346
284
|
let starterId: string | undefined;
|
|
@@ -436,24 +374,6 @@ async function assertCreatable(root: string): Promise<void> {
|
|
|
436
374
|
}
|
|
437
375
|
}
|
|
438
376
|
|
|
439
|
-
async function copyTemplate(from: string, to: string, vars: Record<string, string>): Promise<void> {
|
|
440
|
-
await mkdir(to, { recursive: true });
|
|
441
|
-
const entries = await readdir(from, { withFileTypes: true });
|
|
442
|
-
for (const entry of entries) {
|
|
443
|
-
const src = join(from, entry.name);
|
|
444
|
-
const dest = join(to, entry.name);
|
|
445
|
-
if (entry.isDirectory()) {
|
|
446
|
-
await copyTemplate(src, dest, vars);
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
let content = await readFile(src, "utf8");
|
|
450
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
451
|
-
content = content.replaceAll(`{{${key}}}`, value);
|
|
452
|
-
}
|
|
453
|
-
await writeFile(dest, content);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
377
|
async function npmInstall(root: string): Promise<void> {
|
|
458
378
|
console.log("Installing npm packages…");
|
|
459
379
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -482,9 +402,12 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
482
402
|
body: JSON.stringify({ name }),
|
|
483
403
|
});
|
|
484
404
|
|
|
485
|
-
await
|
|
486
|
-
|
|
487
|
-
|
|
405
|
+
const { treeRoot } = await resolveStarterTree();
|
|
406
|
+
await emitStarter(join(treeRoot, starter.id), root, {
|
|
407
|
+
name,
|
|
408
|
+
packageName: packageNameFromPath(root),
|
|
409
|
+
title: starter.title,
|
|
410
|
+
versions: await loadPackageVersions(),
|
|
488
411
|
});
|
|
489
412
|
await writeLink(
|
|
490
413
|
{
|
|
@@ -2,20 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Starter app from `robodev create --starter auth-chat`: email + Google sign-in and a shared chat room (`chat` database, `messages` table).
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The project host is the app. After create or deploy, open the printed App URL — no `npm run dev` required. Local Vite is optional:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm run dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Local Vite reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`). Hosted builds use the same origin as the APIs.
|
|
12
12
|
|
|
13
13
|
Docs: https://robodev.povio.dev/docs/starter
|
|
14
14
|
|
|
15
|
-
`GET /me` and `GET/POST /messages` (`api/me.ts`, `api/messages.ts`) require a Robodev Auth Bearer token. `GET /health` stays public. Email auth works without Google. If Google is not configured on Starbase, the button shows a 503 message.
|
|
15
|
+
`GET /me` and `GET/POST /messages` (`api/me.ts`, `api/messages.ts`) require a Robodev Auth Bearer token. `GET /health` stays public. Email auth works without Google. If Google is not configured on Starbase, the button shows a 503 message. Google OAuth may not complete inside the dashboard preview iframe — use Open app.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npx robodev deploy
|
|
21
|
-
```
|
|
17
|
+
Edit files in the dashboard Code page, or here, then Save / `npx robodev deploy`.
|
|
@@ -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/client": "^0.
|
|
14
|
+
"@robodev-ai/sdk": "^0.5.0",
|
|
15
|
+
"@robodev-ai/client": "^0.2.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.6.0"
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -11,8 +11,15 @@ type Message = {
|
|
|
11
11
|
createdAt: string | null;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
const
|
|
14
|
+
function projectApiUrl(): string {
|
|
15
|
+
const fromEnv = import.meta.env.VITE_API_URL;
|
|
16
|
+
if (typeof fromEnv === "string" && fromEnv.trim()) return fromEnv.replace(/\/+$/, "");
|
|
17
|
+
if (typeof window !== "undefined") return window.location.origin;
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const apiUrl = projectApiUrl();
|
|
22
|
+
const api = createClient({ url: apiUrl });
|
|
16
23
|
|
|
17
24
|
function authorLabel(user: { name?: string | null; email: string }) {
|
|
18
25
|
return user.name?.trim() || user.email;
|
|
@@ -33,17 +40,12 @@ function App() {
|
|
|
33
40
|
const listRef = useRef<HTMLDivElement>(null);
|
|
34
41
|
|
|
35
42
|
async function refreshMessages() {
|
|
36
|
-
if (!api) return;
|
|
37
43
|
const res = await api.fetch("/messages");
|
|
38
44
|
if (!res.ok) throw new Error(await res.text());
|
|
39
45
|
setMessages((await res.json()) as Message[]);
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
useEffect(() => {
|
|
43
|
-
if (!api) {
|
|
44
|
-
setReady(true);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
49
|
api.consumeTokenFromUrl();
|
|
48
50
|
void api.auth
|
|
49
51
|
.getUser()
|
|
@@ -53,7 +55,7 @@ function App() {
|
|
|
53
55
|
}, []);
|
|
54
56
|
|
|
55
57
|
useEffect(() => {
|
|
56
|
-
if (!user
|
|
58
|
+
if (!user) return;
|
|
57
59
|
let cancelled = false;
|
|
58
60
|
const load = () =>
|
|
59
61
|
refreshMessages().catch((err: unknown) => {
|
|
@@ -72,7 +74,6 @@ function App() {
|
|
|
72
74
|
}, [messages]);
|
|
73
75
|
|
|
74
76
|
useEffect(() => {
|
|
75
|
-
if (!apiUrl) return;
|
|
76
77
|
void fetch(`${apiUrl}/messages`).then((res) => {
|
|
77
78
|
setUnauthStatus(
|
|
78
79
|
`${res.status} ${res.status === 401 ? "unauthorized (expected)" : res.statusText}`,
|
|
@@ -82,7 +83,6 @@ function App() {
|
|
|
82
83
|
|
|
83
84
|
async function onEmailAuth(event: FormEvent) {
|
|
84
85
|
event.preventDefault();
|
|
85
|
-
if (!api) return;
|
|
86
86
|
setPending(true);
|
|
87
87
|
setError(null);
|
|
88
88
|
try {
|
|
@@ -100,7 +100,6 @@ function App() {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
async function onGoogle() {
|
|
103
|
-
if (!api) return;
|
|
104
103
|
setPending(true);
|
|
105
104
|
setError(null);
|
|
106
105
|
try {
|
|
@@ -122,7 +121,6 @@ function App() {
|
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
async function onSignOut() {
|
|
125
|
-
if (!api) return;
|
|
126
124
|
await api.auth.signOut();
|
|
127
125
|
setUser(null);
|
|
128
126
|
setMessages([]);
|
|
@@ -130,7 +128,7 @@ function App() {
|
|
|
130
128
|
|
|
131
129
|
async function onSend(event: FormEvent) {
|
|
132
130
|
event.preventDefault();
|
|
133
|
-
if (!
|
|
131
|
+
if (!draft.trim()) return;
|
|
134
132
|
setPending(true);
|
|
135
133
|
setError(null);
|
|
136
134
|
try {
|
|
@@ -156,20 +154,11 @@ function App() {
|
|
|
156
154
|
);
|
|
157
155
|
}
|
|
158
156
|
|
|
159
|
-
if (!api) {
|
|
160
|
-
return (
|
|
161
|
-
<main>
|
|
162
|
-
<h1>Auth chat</h1>
|
|
163
|
-
<p className="error">Missing VITE_API_URL. Run `robodev create` or `robodev link`.</p>
|
|
164
|
-
</main>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
157
|
if (!user) {
|
|
169
158
|
return (
|
|
170
159
|
<main className="narrow">
|
|
171
160
|
<p className="eyebrow">Robodev Auth</p>
|
|
172
|
-
<h1>
|
|
161
|
+
<h1>{{NAME}}</h1>
|
|
173
162
|
<p className="lede">
|
|
174
163
|
Sign in with email or Google. Protected APIs live at {apiUrl}. Unauthenticated GET
|
|
175
164
|
/messages: {unauthStatus ?? "checking…"}.
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# {{NAME}}
|
|
2
2
|
|
|
3
|
-
Starter app from `robodev create`: a `space` database (`planets`, `rockets`), file-based APIs in `api/`, and a
|
|
3
|
+
Starter app from `robodev create`: a `space` database (`planets`, `rockets`), file-based APIs in `api/`, and a React UI.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The project host is the app. After create or deploy, open the printed App URL — no `npm run dev` required. Local Vite is optional:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm run dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Local Vite reads `VITE_API_URL` from `.env` (written by `robodev create` / `robodev link`). Hosted builds use the same origin as the APIs.
|
|
12
12
|
|
|
13
13
|
Docs: https://robodev.povio.dev/docs
|
|
14
14
|
|
|
15
15
|
`GET /me` (`api/me.ts`) requires a Robodev Auth Bearer token. Planet and rocket routes stay public. Optional browser helper: `@robodev-ai/client`. Auth how-to: https://robodev.povio.dev/docs/auth
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npx robodev deploy
|
|
21
|
-
```
|
|
17
|
+
Edit files in the dashboard Code page, or here, then Save / `npx robodev deploy`.
|
|
@@ -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/client": "^0.
|
|
14
|
+
"@robodev-ai/sdk": "^0.5.0",
|
|
15
|
+
"@robodev-ai/client": "^0.2.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.6.0"
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
/** Starter UI.
|
|
1
|
+
/** Starter UI. Hosted builds use the project origin; local Vite uses `VITE_API_URL`. */
|
|
2
2
|
import { StrictMode, useEffect, useState, type FormEvent } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
|
|
5
5
|
type Planet = { id: string; name: string; climate: string; moons: number };
|
|
6
6
|
type Rocket = { id: string; name: string; destinationId: string; launched: boolean };
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function projectApiUrl(): string {
|
|
9
|
+
const fromEnv = import.meta.env.VITE_API_URL;
|
|
10
|
+
if (typeof fromEnv === "string" && fromEnv.trim()) return fromEnv.replace(/\/+$/, "");
|
|
11
|
+
if (typeof window !== "undefined") return window.location.origin;
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
9
14
|
|
|
10
15
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
const res = await fetch(`${apiUrl}${path}`, {
|
|
16
|
+
const base = projectApiUrl();
|
|
17
|
+
const res = await fetch(`${base}${path}`, {
|
|
15
18
|
...init,
|
|
16
19
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
17
20
|
});
|
|
@@ -110,9 +113,7 @@ function App() {
|
|
|
110
113
|
return (
|
|
111
114
|
<main>
|
|
112
115
|
<h1>{"{{NAME}}"}</h1>
|
|
113
|
-
<p className="lede">
|
|
114
|
-
React frontend talking to your Starbase APIs at {apiUrl ?? "(not linked)"}.
|
|
115
|
-
</p>
|
|
116
|
+
<p className="lede">React frontend talking to your Starbase APIs at {projectApiUrl()}.</p>
|
|
116
117
|
{error ? <p className="error">{error}</p> : null}
|
|
117
118
|
|
|
118
119
|
<div className="grid">
|