robodev 0.39.0 → 0.41.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 +3 -3
- package/src/emit-starter.test.ts +40 -0
- package/src/frontend-source.test.ts +85 -0
- package/src/frontend-source.ts +83 -0
- package/src/index.ts +61 -36
- package/src/parse-create-args.test.ts +86 -4
- package/src/parse-create-args.ts +51 -14
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/catalog.json +1 -2
- package/templates/empty/README.md +8 -6
- package/templates/empty/index.html +12 -0
- package/templates/empty/package.json +10 -4
- package/templates/empty/src/App.tsx +8 -0
- package/templates/empty/src/app.css +10 -0
- package/templates/empty/src/main.tsx +5 -0
- package/templates/empty/tsconfig.json +2 -1
- package/templates/empty/vite.config.ts +5 -0
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"embedded-postgres": "17.10.0-beta.17",
|
|
27
27
|
"fastify": "^5.12.1",
|
|
28
28
|
"tsx": "^4.20.5",
|
|
29
|
-
"@robodev-ai/
|
|
30
|
-
"@robodev-ai/
|
|
29
|
+
"@robodev-ai/runtime": "0.5.1",
|
|
30
|
+
"@robodev-ai/sdk": "0.13.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.3.0",
|
package/src/emit-starter.test.ts
CHANGED
|
@@ -114,6 +114,46 @@ test("collectFiles for marketplace starter is Tiny APIs without apps/fe", async
|
|
|
114
114
|
assert.ok(files.length < 200);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("emitStarter empty includes hosted Welcome files", async () => {
|
|
118
|
+
const dest = await mkdtemp(join(tmpdir(), "rd-emit-"));
|
|
119
|
+
try {
|
|
120
|
+
await emitStarter(join(cliRoot(), "..", "starters", "empty"), dest, {
|
|
121
|
+
name: "Blank",
|
|
122
|
+
packageName: "blank-app",
|
|
123
|
+
title: "Empty",
|
|
124
|
+
versions,
|
|
125
|
+
});
|
|
126
|
+
const index = await readFile(join(dest, "index.html"), "utf8");
|
|
127
|
+
assert.match(index, /<title>Blank<\/title>/);
|
|
128
|
+
assert.match(index, /src\/main\.tsx/);
|
|
129
|
+
const main = await readFile(join(dest, "src/main.tsx"), "utf8");
|
|
130
|
+
assert.match(main, /createRoot/);
|
|
131
|
+
const pkg = JSON.parse(await readFile(join(dest, "package.json"), "utf8")) as {
|
|
132
|
+
name: string;
|
|
133
|
+
scripts?: Record<string, string>;
|
|
134
|
+
dependencies?: Record<string, string>;
|
|
135
|
+
};
|
|
136
|
+
assert.equal(pkg.name, "blank-app");
|
|
137
|
+
assert.equal(pkg.scripts?.dev, "robodev dev");
|
|
138
|
+
assert.equal(pkg.scripts?.["dev:fe"], "vite");
|
|
139
|
+
assert.equal(pkg.dependencies?.["@robodev-ai/client"], "^0.12.0");
|
|
140
|
+
} finally {
|
|
141
|
+
await rm(dest, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("collectFiles for empty starter includes hosted Welcome source", async () => {
|
|
146
|
+
const files = await collectFiles(join(cliRoot(), "..", "starters", "empty"));
|
|
147
|
+
const paths = new Set(files.map((file) => file.path));
|
|
148
|
+
assert.ok(paths.has("robodev/database.ts"));
|
|
149
|
+
assert.ok(paths.has("robodev/api/health.ts"));
|
|
150
|
+
assert.ok(paths.has("index.html"));
|
|
151
|
+
assert.ok(paths.has("src/main.tsx"));
|
|
152
|
+
assert.ok(!paths.has("robodev/api/planets.ts"));
|
|
153
|
+
assert.ok([...paths].every((path) => !path.startsWith("apps/")));
|
|
154
|
+
assert.ok(files.length < 200);
|
|
155
|
+
});
|
|
156
|
+
|
|
117
157
|
test("collectFiles for space starter is Tiny APIs without apps/fe", async () => {
|
|
118
158
|
const files = await collectFiles(join(cliRoot(), "..", "starters", "space"));
|
|
119
159
|
const paths = new Set(files.map((file) => file.path));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-source.js";
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
test("deploy posts frontendSource beside files", () => {
|
|
13
|
+
const source = readFileSync(join(here, "index.ts"), "utf8");
|
|
14
|
+
assert.match(source, /collectFrontendSource/);
|
|
15
|
+
assert.match(source, /collectDeployPayload/);
|
|
16
|
+
assert.match(source, /frontendSource\.length > 0 \? \{ frontendSource \}/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("shouldCollectFrontendSource when .robodev has frontend or apps/fe exists", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
shouldCollectFrontendSource({ hasFrontendConfig: true, hasAppsFePackageJson: false }),
|
|
22
|
+
true,
|
|
23
|
+
);
|
|
24
|
+
assert.equal(
|
|
25
|
+
shouldCollectFrontendSource({ hasFrontendConfig: false, hasAppsFePackageJson: true }),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
shouldCollectFrontendSource({ hasFrontendConfig: false, hasAppsFePackageJson: false }),
|
|
30
|
+
false,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("collectFrontendSource walks apps/fe and skips dist, node_modules, and .env", async () => {
|
|
35
|
+
const root = await mkdtemp(join(tmpdir(), "rd-fe-src-"));
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(join(root, "apps/fe/src"), { recursive: true });
|
|
38
|
+
await mkdir(join(root, "apps/fe/node_modules/left"), { recursive: true });
|
|
39
|
+
await mkdir(join(root, "apps/fe/dist"), { recursive: true });
|
|
40
|
+
await writeFile(join(root, "apps/fe/package.json"), '{"name":"fe"}');
|
|
41
|
+
await writeFile(join(root, "apps/fe/index.html"), "<html></html>");
|
|
42
|
+
await writeFile(join(root, "apps/fe/src/main.tsx"), "export {}");
|
|
43
|
+
await writeFile(join(root, "apps/fe/.env"), "SECRET=1");
|
|
44
|
+
await writeFile(join(root, "apps/fe/node_modules/left/index.js"), "nope");
|
|
45
|
+
await writeFile(join(root, "apps/fe/dist/index.html"), "<html>built</html>");
|
|
46
|
+
await writeFile(join(root, "database.ts"), "export default {}");
|
|
47
|
+
|
|
48
|
+
const files = await collectFrontendSource(root, "apps/fe");
|
|
49
|
+
const paths = new Set(files.map((file) => file.path));
|
|
50
|
+
assert.ok(paths.has("apps/fe/package.json"));
|
|
51
|
+
assert.ok(paths.has("apps/fe/index.html"));
|
|
52
|
+
assert.ok(paths.has("apps/fe/src/main.tsx"));
|
|
53
|
+
assert.ok(!paths.has("apps/fe/.env"));
|
|
54
|
+
assert.ok(!paths.has("apps/fe/node_modules/left/index.js"));
|
|
55
|
+
assert.ok(!paths.has("apps/fe/dist/index.html"));
|
|
56
|
+
assert.ok(!paths.has("database.ts"));
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("collectFrontendSource walks root FE without backend files", async () => {
|
|
63
|
+
const root = await mkdtemp(join(tmpdir(), "rd-fe-root-"));
|
|
64
|
+
try {
|
|
65
|
+
await mkdir(join(root, "src"), { recursive: true });
|
|
66
|
+
await mkdir(join(root, "api"), { recursive: true });
|
|
67
|
+
await writeFile(join(root, "database.ts"), "export default {}");
|
|
68
|
+
await writeFile(join(root, "index.html"), "<html></html>");
|
|
69
|
+
await writeFile(join(root, "src/main.tsx"), "export {}");
|
|
70
|
+
await writeFile(join(root, "package.json"), "{}");
|
|
71
|
+
await writeFile(join(root, "vite.config.ts"), "export default {}");
|
|
72
|
+
await writeFile(join(root, "api/health.ts"), "export {}");
|
|
73
|
+
|
|
74
|
+
const files = await collectFrontendSource(root, ".");
|
|
75
|
+
const paths = new Set(files.map((file) => file.path));
|
|
76
|
+
assert.ok(paths.has("index.html"));
|
|
77
|
+
assert.ok(paths.has("src/main.tsx"));
|
|
78
|
+
assert.ok(paths.has("package.json"));
|
|
79
|
+
assert.ok(paths.has("vite.config.ts"));
|
|
80
|
+
assert.ok(!paths.has("database.ts"));
|
|
81
|
+
assert.ok(!paths.has("api/health.ts"));
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(root, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { isWorkspaceFrontendSourcePath } from "@robodev-ai/runtime";
|
|
4
|
+
import {
|
|
5
|
+
MAX_FILE_BYTES,
|
|
6
|
+
MAX_FILE_COUNT,
|
|
7
|
+
MAX_PATH_DEPTH,
|
|
8
|
+
MAX_PATH_LENGTH,
|
|
9
|
+
MAX_TREE_BYTES,
|
|
10
|
+
type CollectedFile,
|
|
11
|
+
} from "./collect-files.js";
|
|
12
|
+
import { sanitizeProjectRelative } from "./frontend-build.js";
|
|
13
|
+
|
|
14
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
|
|
15
|
+
|
|
16
|
+
function normalize(path: string): string {
|
|
17
|
+
return path.replaceAll("\\", "/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatMaxBytes(bytes: number): string {
|
|
21
|
+
return `${Math.round(bytes / (1024 * 1024))}MB`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function collectFrontendSource(
|
|
25
|
+
projectRoot: string,
|
|
26
|
+
frontendRoot: string,
|
|
27
|
+
): Promise<CollectedFile[]> {
|
|
28
|
+
const rootAbs = sanitizeProjectRelative(projectRoot, frontendRoot, "root");
|
|
29
|
+
try {
|
|
30
|
+
if (!(await stat(rootAbs)).isDirectory()) return [];
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const files: CollectedFile[] = [];
|
|
36
|
+
let totalBytes = 0;
|
|
37
|
+
|
|
38
|
+
async function walk(dir: string): Promise<void> {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const abs = join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
49
|
+
await walk(abs);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!entry.isFile()) continue;
|
|
53
|
+
const path = normalize(relative(projectRoot, abs));
|
|
54
|
+
if (!isWorkspaceFrontendSourcePath(path)) continue;
|
|
55
|
+
if (path.length > MAX_PATH_LENGTH || path.split("/").length > MAX_PATH_DEPTH) {
|
|
56
|
+
throw new Error(`Invalid file path: ${path}`);
|
|
57
|
+
}
|
|
58
|
+
const content = await readFile(abs, "utf8");
|
|
59
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
60
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
61
|
+
throw new Error(`File must be under ${formatMaxBytes(MAX_FILE_BYTES)}: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
totalBytes += bytes;
|
|
64
|
+
if (totalBytes > MAX_TREE_BYTES) {
|
|
65
|
+
throw new Error(`Frontend source tree must be under ${formatMaxBytes(MAX_TREE_BYTES)}`);
|
|
66
|
+
}
|
|
67
|
+
files.push({ path, content });
|
|
68
|
+
if (files.length > MAX_FILE_COUNT) {
|
|
69
|
+
throw new Error(`Frontend source may include at most ${MAX_FILE_COUNT} files`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await walk(rootAbs);
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function shouldCollectFrontendSource(input: {
|
|
79
|
+
hasFrontendConfig: boolean;
|
|
80
|
+
hasAppsFePackageJson: boolean;
|
|
81
|
+
}): boolean {
|
|
82
|
+
return input.hasFrontendConfig || input.hasAppsFePackageJson;
|
|
83
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
commandAvailable,
|
|
29
29
|
frontendOutIndexExists,
|
|
30
30
|
} from "./frontend-build.js";
|
|
31
|
+
import { collectFrontendSource, shouldCollectFrontendSource } from "./frontend-source.js";
|
|
31
32
|
import {
|
|
32
33
|
emitStarter,
|
|
33
34
|
loadCatalog,
|
|
@@ -52,7 +53,7 @@ Commands:
|
|
|
52
53
|
logout Remove stored credentials
|
|
53
54
|
whoami Show the signed-in user
|
|
54
55
|
projects List your projects
|
|
55
|
-
create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
56
|
+
create [path] [--template <id>] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
56
57
|
link [projectId] Write robodev/.robodev in the current folder
|
|
57
58
|
deploy [--force] [--name <name>] [--org <id>] Deploy schema, APIs, jobs, and frontend (creates and links if unlinked)
|
|
58
59
|
dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
|
|
@@ -227,22 +228,32 @@ type DeployOptions = {
|
|
|
227
228
|
skipFrontendBuild?: boolean;
|
|
228
229
|
};
|
|
229
230
|
|
|
230
|
-
async function
|
|
231
|
+
async function collectDeployPayload(
|
|
231
232
|
root: string,
|
|
232
233
|
link: ProjectLink,
|
|
233
234
|
options: DeployOptions,
|
|
234
|
-
): Promise<CollectedFile[]> {
|
|
235
|
+
): Promise<{ files: CollectedFile[]; frontendSource: CollectedFile[] }> {
|
|
235
236
|
const collected = await collectFiles(root);
|
|
237
|
+
const hasAppsFe = await hasAppsFePackageJson(root);
|
|
238
|
+
const frontendSource = shouldCollectFrontendSource({
|
|
239
|
+
hasFrontendConfig: isFrontendConfig(link.frontend),
|
|
240
|
+
hasAppsFePackageJson: hasAppsFe,
|
|
241
|
+
})
|
|
242
|
+
? await collectFrontendSource(
|
|
243
|
+
root,
|
|
244
|
+
isFrontendConfig(link.frontend) ? link.frontend.root : "apps/fe",
|
|
245
|
+
)
|
|
246
|
+
: [];
|
|
236
247
|
if (options.frontendFiles) {
|
|
237
|
-
return mergeCollectedFiles(collected, options.frontendFiles);
|
|
248
|
+
return { files: mergeCollectedFiles(collected, options.frontendFiles), frontendSource };
|
|
238
249
|
}
|
|
239
250
|
if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
|
|
240
|
-
return collected;
|
|
251
|
+
return { files: collected, frontendSource };
|
|
241
252
|
}
|
|
242
253
|
const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
|
|
243
254
|
install: "if-needed",
|
|
244
255
|
});
|
|
245
|
-
return mergeCollectedFiles(collected, frontendFiles);
|
|
256
|
+
return { files: mergeCollectedFiles(collected, frontendFiles), frontendSource };
|
|
246
257
|
}
|
|
247
258
|
|
|
248
259
|
/** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
|
|
@@ -274,7 +285,7 @@ async function runDeploy(
|
|
|
274
285
|
promptName: (defaultName) => prompt(`Project name [${defaultName}]: `),
|
|
275
286
|
},
|
|
276
287
|
);
|
|
277
|
-
const files = await
|
|
288
|
+
const { files, frontendSource } = await collectDeployPayload(root, link, options);
|
|
278
289
|
let force = forceFlag;
|
|
279
290
|
|
|
280
291
|
for (;;) {
|
|
@@ -290,7 +301,11 @@ async function runDeploy(
|
|
|
290
301
|
plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
|
|
291
302
|
}>(`/v1/projects/${link.projectId}/deploy`, {
|
|
292
303
|
method: "POST",
|
|
293
|
-
body: JSON.stringify({
|
|
304
|
+
body: JSON.stringify({
|
|
305
|
+
force,
|
|
306
|
+
files,
|
|
307
|
+
...(frontendSource.length > 0 ? { frontendSource } : {}),
|
|
308
|
+
}),
|
|
294
309
|
});
|
|
295
310
|
await waitUntilLive(result.apiUrl);
|
|
296
311
|
console.log(`App ${result.appUrl ?? result.apiUrl}`);
|
|
@@ -397,40 +412,47 @@ async function resolveOrganization(flagId: string | undefined): Promise<string |
|
|
|
397
412
|
return found.id;
|
|
398
413
|
}
|
|
399
414
|
|
|
400
|
-
async function resolveStarter(
|
|
415
|
+
async function resolveStarter(parsed: {
|
|
416
|
+
starterId?: string;
|
|
417
|
+
promptTemplate?: boolean;
|
|
418
|
+
}): Promise<Starter> {
|
|
401
419
|
const starters = await loadCatalog();
|
|
402
|
-
if (
|
|
403
|
-
const found = starters.find((starter) => starter.id ===
|
|
420
|
+
if (parsed.starterId !== undefined) {
|
|
421
|
+
const found = starters.find((starter) => starter.id === parsed.starterId);
|
|
404
422
|
if (!found) {
|
|
405
|
-
throw new Error(`Unknown starter "${
|
|
423
|
+
throw new Error(`Unknown starter "${parsed.starterId}". ${validIdsMessage(starters)}`);
|
|
406
424
|
}
|
|
407
425
|
return found;
|
|
408
426
|
}
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
427
|
+
if (parsed.promptTemplate) {
|
|
428
|
+
if (stdin.isTTY) {
|
|
429
|
+
console.log("Starters:");
|
|
430
|
+
starters.forEach((starter, i) => {
|
|
431
|
+
console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
|
|
432
|
+
console.log(` ${starter.description}`);
|
|
433
|
+
});
|
|
434
|
+
const answer = (await prompt("Starter number or id: ")).trim();
|
|
435
|
+
if (!answer) {
|
|
436
|
+
throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
|
|
437
|
+
}
|
|
438
|
+
const asIndex = Number(answer);
|
|
439
|
+
if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
|
|
440
|
+
return starters[asIndex - 1];
|
|
441
|
+
}
|
|
442
|
+
const found = starters.find((starter) => starter.id === answer);
|
|
443
|
+
if (!found) {
|
|
444
|
+
throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
|
|
445
|
+
}
|
|
446
|
+
return found;
|
|
426
447
|
}
|
|
427
|
-
|
|
448
|
+
console.log("robodev create --template requires a starter id when stdin is not a TTY.");
|
|
449
|
+
usage();
|
|
428
450
|
}
|
|
429
|
-
const
|
|
430
|
-
if (!
|
|
431
|
-
throw new Error(`Unknown starter "
|
|
451
|
+
const empty = starters.find((starter) => starter.id === "empty");
|
|
452
|
+
if (!empty) {
|
|
453
|
+
throw new Error(`Unknown starter "empty". ${validIdsMessage(starters)}`);
|
|
432
454
|
}
|
|
433
|
-
return
|
|
455
|
+
return empty;
|
|
434
456
|
}
|
|
435
457
|
|
|
436
458
|
function projectNameFromPath(root: string): string {
|
|
@@ -528,7 +550,7 @@ function printFeNextSteps(displayPath: string, hosted: boolean): void {
|
|
|
528
550
|
/** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
|
|
529
551
|
async function runCreate(args: string[]): Promise<void> {
|
|
530
552
|
const parsed = parseCreateArgs(args);
|
|
531
|
-
const starter = await resolveStarter(parsed
|
|
553
|
+
const starter = await resolveStarter(parsed);
|
|
532
554
|
console.log(`Using starter ${starter.title} (${starter.id})`);
|
|
533
555
|
|
|
534
556
|
const root = resolve(process.cwd(), parsed.path ?? ".");
|
|
@@ -623,7 +645,10 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
623
645
|
if (displayPath !== ".") {
|
|
624
646
|
console.log(` cd ${displayPath}`);
|
|
625
647
|
}
|
|
626
|
-
console.log("
|
|
648
|
+
console.log(" The project App URL serves the hosted Welcome page.");
|
|
649
|
+
console.log(" Edit index.html and src/, then robodev deploy (or Code + Save).");
|
|
650
|
+
console.log(" robodev dev # local APIs");
|
|
651
|
+
console.log(" npm run dev:fe # optional local Vite");
|
|
627
652
|
}
|
|
628
653
|
if (liveUrl) {
|
|
629
654
|
printLiveUrl(liveUrl);
|
|
@@ -2,17 +2,86 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { parseCreateArgs } from "./parse-create-args.js";
|
|
4
4
|
|
|
5
|
-
test("parseCreateArgs
|
|
5
|
+
test("parseCreateArgs defaults to empty without a template flag", () => {
|
|
6
|
+
assert.deepEqual(parseCreateArgs([]), {
|
|
7
|
+
path: undefined,
|
|
8
|
+
starterId: "empty",
|
|
9
|
+
orgId: undefined,
|
|
10
|
+
});
|
|
11
|
+
assert.deepEqual(parseCreateArgs(["my-app"]), {
|
|
12
|
+
path: "my-app",
|
|
13
|
+
starterId: "empty",
|
|
14
|
+
orgId: undefined,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseCreateArgs accepts --template and --starter with an id", () => {
|
|
19
|
+
assert.deepEqual(parseCreateArgs(["app", "--template", "space", "--org", "org_1"]), {
|
|
20
|
+
path: "app",
|
|
21
|
+
starterId: "space",
|
|
22
|
+
orgId: "org_1",
|
|
23
|
+
});
|
|
24
|
+
assert.deepEqual(parseCreateArgs(["--template=auth-chat"]), {
|
|
25
|
+
path: undefined,
|
|
26
|
+
starterId: "auth-chat",
|
|
27
|
+
orgId: undefined,
|
|
28
|
+
});
|
|
6
29
|
assert.deepEqual(parseCreateArgs(["app", "--starter", "space", "--org", "org_1"]), {
|
|
7
30
|
path: "app",
|
|
8
31
|
starterId: "space",
|
|
9
32
|
orgId: "org_1",
|
|
10
33
|
});
|
|
34
|
+
assert.deepEqual(parseCreateArgs(["--starter=backend"]), {
|
|
35
|
+
path: undefined,
|
|
36
|
+
starterId: "backend",
|
|
37
|
+
orgId: undefined,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parseCreateArgs treats bare --template and --starter as a picker request", () => {
|
|
42
|
+
assert.deepEqual(parseCreateArgs(["--template"]), {
|
|
43
|
+
path: undefined,
|
|
44
|
+
starterId: undefined,
|
|
45
|
+
orgId: undefined,
|
|
46
|
+
promptTemplate: true,
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(parseCreateArgs(["--template="]), {
|
|
49
|
+
path: undefined,
|
|
50
|
+
starterId: undefined,
|
|
51
|
+
orgId: undefined,
|
|
52
|
+
promptTemplate: true,
|
|
53
|
+
});
|
|
54
|
+
assert.deepEqual(parseCreateArgs(["my-app", "--template", "--org", "org_1"]), {
|
|
55
|
+
path: "my-app",
|
|
56
|
+
starterId: undefined,
|
|
57
|
+
orgId: "org_1",
|
|
58
|
+
promptTemplate: true,
|
|
59
|
+
});
|
|
60
|
+
assert.deepEqual(parseCreateArgs(["--starter"]), {
|
|
61
|
+
path: undefined,
|
|
62
|
+
starterId: undefined,
|
|
63
|
+
orgId: undefined,
|
|
64
|
+
promptTemplate: true,
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(parseCreateArgs(["--starter="]), {
|
|
67
|
+
path: undefined,
|
|
68
|
+
starterId: undefined,
|
|
69
|
+
orgId: undefined,
|
|
70
|
+
promptTemplate: true,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parseCreateArgs treats --empty as empty", () => {
|
|
11
75
|
assert.deepEqual(parseCreateArgs(["--empty", "--org=org_2"]), {
|
|
12
76
|
path: undefined,
|
|
13
77
|
starterId: "empty",
|
|
14
78
|
orgId: "org_2",
|
|
15
79
|
});
|
|
80
|
+
assert.deepEqual(parseCreateArgs(["--template", "empty"]), {
|
|
81
|
+
path: undefined,
|
|
82
|
+
starterId: "empty",
|
|
83
|
+
orgId: undefined,
|
|
84
|
+
});
|
|
16
85
|
});
|
|
17
86
|
|
|
18
87
|
test("parseCreateArgs rejects a missing --org value", () => {
|
|
@@ -21,13 +90,26 @@ test("parseCreateArgs rejects a missing --org value", () => {
|
|
|
21
90
|
assert.throws(() => parseCreateArgs(["--org", "--empty"]), /Missing value for --org/);
|
|
22
91
|
});
|
|
23
92
|
|
|
24
|
-
test("parseCreateArgs rejects unknown flags including --organization", () => {
|
|
93
|
+
test("parseCreateArgs rejects unknown flags including --organization and --empty=", () => {
|
|
25
94
|
assert.throws(() => parseCreateArgs(["--organization", "org_1"]), /Unknown flag --organization/);
|
|
95
|
+
assert.throws(() => parseCreateArgs(["--empty=empty"]), /Unknown flag --empty=empty/);
|
|
26
96
|
});
|
|
27
97
|
|
|
28
|
-
test("parseCreateArgs rejects
|
|
98
|
+
test("parseCreateArgs rejects two starter selectors", () => {
|
|
29
99
|
assert.throws(
|
|
30
100
|
() => parseCreateArgs(["--empty", "--starter", "empty"]),
|
|
31
|
-
/Use
|
|
101
|
+
/Use only one of --empty, --template, or --starter\./,
|
|
102
|
+
);
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => parseCreateArgs(["--empty", "--template", "empty"]),
|
|
105
|
+
/Use only one of --empty, --template, or --starter\./,
|
|
106
|
+
);
|
|
107
|
+
assert.throws(
|
|
108
|
+
() => parseCreateArgs(["--template", "space", "--starter", "space"]),
|
|
109
|
+
/Use only one of --empty, --template, or --starter\./,
|
|
110
|
+
);
|
|
111
|
+
assert.throws(
|
|
112
|
+
() => parseCreateArgs(["--template", "--empty"]),
|
|
113
|
+
/Use only one of --empty, --template, or --starter\./,
|
|
32
114
|
);
|
|
33
115
|
});
|
package/src/parse-create-args.ts
CHANGED
|
@@ -1,31 +1,64 @@
|
|
|
1
|
-
export type CreateArgs = {
|
|
1
|
+
export type CreateArgs = {
|
|
2
|
+
path?: string;
|
|
3
|
+
starterId?: string;
|
|
4
|
+
orgId?: string;
|
|
5
|
+
promptTemplate?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function takeOptionalId(
|
|
9
|
+
args: string[],
|
|
10
|
+
i: number,
|
|
11
|
+
): { value?: string; prompt: boolean; consumed: number } {
|
|
12
|
+
const next = args[i + 1];
|
|
13
|
+
if (!next || next.startsWith("-")) {
|
|
14
|
+
return { prompt: true, consumed: 0 };
|
|
15
|
+
}
|
|
16
|
+
return { value: next, prompt: false, consumed: 1 };
|
|
17
|
+
}
|
|
2
18
|
|
|
3
19
|
export function parseCreateArgs(args: string[]): CreateArgs {
|
|
4
20
|
let path: string | undefined;
|
|
5
21
|
let starterId: string | undefined;
|
|
6
22
|
let orgId: string | undefined;
|
|
7
23
|
let empty = false;
|
|
24
|
+
let sawTemplate = false;
|
|
25
|
+
let sawStarter = false;
|
|
26
|
+
let promptTemplate = false;
|
|
27
|
+
|
|
8
28
|
for (let i = 0; i < args.length; i++) {
|
|
9
29
|
const arg = args[i];
|
|
10
30
|
if (arg === "--empty") {
|
|
11
31
|
empty = true;
|
|
12
32
|
continue;
|
|
13
33
|
}
|
|
34
|
+
if (arg === "--template") {
|
|
35
|
+
sawTemplate = true;
|
|
36
|
+
const taken = takeOptionalId(args, i);
|
|
37
|
+
if (taken.prompt) promptTemplate = true;
|
|
38
|
+
else starterId = taken.value;
|
|
39
|
+
i += taken.consumed;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg.startsWith("--template=")) {
|
|
43
|
+
sawTemplate = true;
|
|
44
|
+
const value = arg.slice("--template=".length);
|
|
45
|
+
if (!value) promptTemplate = true;
|
|
46
|
+
else starterId = value;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
14
49
|
if (arg === "--starter") {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
i += 1;
|
|
50
|
+
sawStarter = true;
|
|
51
|
+
const taken = takeOptionalId(args, i);
|
|
52
|
+
if (taken.prompt) promptTemplate = true;
|
|
53
|
+
else starterId = taken.value;
|
|
54
|
+
i += taken.consumed;
|
|
21
55
|
continue;
|
|
22
56
|
}
|
|
23
57
|
if (arg.startsWith("--starter=")) {
|
|
58
|
+
sawStarter = true;
|
|
24
59
|
const value = arg.slice("--starter=".length);
|
|
25
|
-
if (!value)
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
starterId = value;
|
|
60
|
+
if (!value) promptTemplate = true;
|
|
61
|
+
else starterId = value;
|
|
29
62
|
continue;
|
|
30
63
|
}
|
|
31
64
|
if (arg === "--org") {
|
|
@@ -53,11 +86,15 @@ export function parseCreateArgs(args: string[]): CreateArgs {
|
|
|
53
86
|
}
|
|
54
87
|
path = arg;
|
|
55
88
|
}
|
|
56
|
-
|
|
57
|
-
|
|
89
|
+
|
|
90
|
+
const selectors = Number(empty) + Number(sawTemplate) + Number(sawStarter);
|
|
91
|
+
if (selectors > 1) {
|
|
92
|
+
throw new Error("Use only one of --empty, --template, or --starter.");
|
|
58
93
|
}
|
|
59
94
|
if (empty) {
|
|
60
95
|
starterId = "empty";
|
|
96
|
+
} else if (!sawTemplate && !sawStarter) {
|
|
97
|
+
starterId = "empty";
|
|
61
98
|
}
|
|
62
|
-
return { path, starterId, orgId };
|
|
99
|
+
return { path, starterId, orgId, ...(promptTemplate ? { promptTemplate: true } : {}) };
|
|
63
100
|
}
|
package/templates/catalog.json
CHANGED
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"id": "empty",
|
|
26
26
|
"title": "Empty",
|
|
27
|
-
"description": "Blank database.ts and GET /api/health
|
|
28
|
-
"kind": "backend"
|
|
27
|
+
"description": "Blank database.ts and GET /api/health plus a hosted Welcome page. No sample schema."
|
|
29
28
|
}
|
|
30
29
|
]
|
|
31
30
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# {{NAME}}
|
|
2
2
|
|
|
3
|
-
Blank `robodev/database.ts` and `GET /api/health`
|
|
3
|
+
Blank `robodev/database.ts` and `GET /api/health` plus a hosted Welcome page. No sample schema.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The project App URL serves the hosted Welcome page. Edit `index.html` and `src/`, then `robodev deploy` (or Code + Save).
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
robodev create my-app
|
|
9
|
+
robodev dev # local APIs
|
|
10
|
+
npm run dev:fe # optional local Vite
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
`robodev create` (TTY and non-TTY) uses this starter by default. Pass `--template space` for Space. Lovable, Bolt, or v0 should use `--template backend`.
|
|
12
14
|
|
|
13
|
-
Do not
|
|
15
|
+
Do not write a `frontend` block in `robodev/.robodev` — that block is for Tiny `apps/fe`. Root `index.html` + `src/` deploy as hosted React.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Docs: https://robodev.povio.dev/docs
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "robodev dev",
|
|
8
|
-
"deploy": "robodev deploy"
|
|
8
|
+
"deploy": "robodev deploy",
|
|
9
|
+
"dev:fe": "vite",
|
|
10
|
+
"build": "vite build"
|
|
9
11
|
},
|
|
10
12
|
"dependencies": {
|
|
11
|
-
"@robodev-ai/sdk": "^0.13.0"
|
|
13
|
+
"@robodev-ai/sdk": "^0.13.0",
|
|
14
|
+
"@robodev-ai/client": "^0.5.0",
|
|
15
|
+
"react": "^18.3.1",
|
|
16
|
+
"react-dom": "^18.3.1"
|
|
12
17
|
},
|
|
13
18
|
"devDependencies": {
|
|
14
|
-
"robodev": "^0.
|
|
15
|
-
"typescript": "^5.9.2"
|
|
19
|
+
"robodev": "^0.41.0",
|
|
20
|
+
"typescript": "^5.9.2",
|
|
21
|
+
"vite": "^6.0.0"
|
|
16
22
|
}
|
|
17
23
|
}
|