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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.39.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/sdk": "0.13.0",
30
- "@robodev-ai/runtime": "0.5.1"
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",
@@ -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 collectDeployFiles(
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 collectDeployFiles(root, link, options);
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({ force, files }),
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(flagId: string | undefined): Promise<Starter> {
415
+ async function resolveStarter(parsed: {
416
+ starterId?: string;
417
+ promptTemplate?: boolean;
418
+ }): Promise<Starter> {
401
419
  const starters = await loadCatalog();
402
- if (flagId !== undefined) {
403
- const found = starters.find((starter) => starter.id === flagId);
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 "${flagId}". ${validIdsMessage(starters)}`);
423
+ throw new Error(`Unknown starter "${parsed.starterId}". ${validIdsMessage(starters)}`);
406
424
  }
407
425
  return found;
408
426
  }
409
- if (stdin.isTTY) {
410
- console.log("Starters:");
411
- starters.forEach((starter, i) => {
412
- console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
413
- console.log(` ${starter.description}`);
414
- });
415
- const answer = (await prompt("Starter number or id: ")).trim();
416
- if (!answer) {
417
- throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
418
- }
419
- const asIndex = Number(answer);
420
- if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
421
- return starters[asIndex - 1];
422
- }
423
- const found = starters.find((starter) => starter.id === answer);
424
- if (!found) {
425
- throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
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
- return found;
448
+ console.log("robodev create --template requires a starter id when stdin is not a TTY.");
449
+ usage();
428
450
  }
429
- const space = starters.find((starter) => starter.id === "space");
430
- if (!space) {
431
- throw new Error(`Unknown starter "space". ${validIdsMessage(starters)}`);
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 space;
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.starterId);
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(" npm run dev");
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 accepts --org with starter flags", () => {
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 --empty together with --starter", () => {
98
+ test("parseCreateArgs rejects two starter selectors", () => {
29
99
  assert.throws(
30
100
  () => parseCreateArgs(["--empty", "--starter", "empty"]),
31
- /Use either --empty or --starter, not both/,
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
  });
@@ -1,31 +1,64 @@
1
- export type CreateArgs = { path?: string; starterId?: string; orgId?: string };
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
- const value = args[i + 1];
16
- if (!value || value.startsWith("-")) {
17
- throw new Error("Missing value for --starter");
18
- }
19
- starterId = value;
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
- throw new Error("Missing value for --starter");
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
- if (empty && starterId !== undefined) {
57
- throw new Error("Use either --empty or --starter, not both.");
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
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.39.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.13.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.39.0",
14
+ "robodev": "^0.41.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -24,8 +24,7 @@
24
24
  {
25
25
  "id": "empty",
26
26
  "title": "Empty",
27
- "description": "Blank database.ts and GET /api/health — no sample schema or UI. For Lovable, Bolt, or v0",
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` — no sample schema or UI. For Lovable, Bolt, or v0.
3
+ Blank `robodev/database.ts` and `GET /api/health` plus a hosted Welcome page. No sample schema.
4
4
 
5
- Deploy `robodev/database.ts` and `robodev/api/` from a real terminal:
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
- npx robodev deploy
8
+ robodev create my-app
9
+ robodev dev # local APIs
10
+ npm run dev:fe # optional local Vite
9
11
  ```
10
12
 
11
- Build the UI elsewhere against `VITE_API_URL` (written to `.env` by `robodev create` / `robodev link`). Paste that URL into the frontend tool.
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 run `npm run dev` or `npm run build` in this folder. Do not run `robodev` (auth, create, deploy) or `npm run build` inside Lovable, Bolt, or v0 — those tools cannot complete loopback OAuth and must not treat this folder as a Vite app.
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
- Frontend how-to: https://robodev.povio.dev/docs/frontend
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.39.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
  }
@@ -0,0 +1,8 @@
1
+ export function App() {
2
+ return (
3
+ <main>
4
+ <h1>Welcome</h1>
5
+ <p>Your app is ready.</p>
6
+ </main>
7
+ );
8
+ }
@@ -0,0 +1,10 @@
1
+ :root {
2
+ color: #111;
3
+ font-family: system-ui, sans-serif;
4
+ }
5
+ body {
6
+ margin: 0;
7
+ }
8
+ main {
9
+ padding: 2rem;
10
+ }
@@ -0,0 +1,5 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./App";
3
+ import "./app.css";
4
+
5
+ createRoot(document.getElementById("root")!).render(<App />);
@@ -3,9 +3,10 @@
3
3
  "target": "ES2022",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
6
7
  "strict": true,
7
8
  "skipLibCheck": true,
8
9
  "noEmit": true
9
10
  },
10
- "include": ["robodev/**/*.ts"]
11
+ "include": ["robodev/**/*.ts", "src"]
11
12
  }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ export default defineConfig({
4
+ esbuild: { jsx: "automatic" },
5
+ });
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.39.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.39.0",
15
+ "robodev": "^0.41.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }