robodev 0.38.1 → 0.40.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.38.1",
3
+ "version": "0.40.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/runtime": "0.5.1",
30
- "@robodev-ai/sdk": "0.13.0"
29
+ "@robodev-ai/sdk": "0.13.0",
30
+ "@robodev-ai/runtime": "0.5.1"
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,228 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ ensureDeployLink,
5
+ UNLINKED_NON_TTY_MESSAGE,
6
+ type EnsureDeployLinkDeps,
7
+ type EnsureDeployLinkInput,
8
+ } from "./ensure-deploy-link.js";
9
+ import { defaultFrontendConfig, type ProjectLink } from "./config.js";
10
+
11
+ const linked: ProjectLink = {
12
+ projectId: "proj_existing",
13
+ apiUrl: "https://robodev.povio.dev",
14
+ projectApiUrl: "https://proj_existing.robodev.povio.dev",
15
+ };
16
+
17
+ function stubDeps(overrides: Partial<EnsureDeployLinkDeps> = {}): EnsureDeployLinkDeps & {
18
+ calls: {
19
+ ensureAuth: number;
20
+ collectFiles: number;
21
+ resolveOrganization: number;
22
+ createProject: number;
23
+ writeLink: number;
24
+ writeViteApiUrl: number;
25
+ promptName: number;
26
+ };
27
+ createdBodies: { name: string; organizationId?: string }[];
28
+ writtenLinks: ProjectLink[];
29
+ } {
30
+ const calls = {
31
+ ensureAuth: 0,
32
+ collectFiles: 0,
33
+ resolveOrganization: 0,
34
+ createProject: 0,
35
+ writeLink: 0,
36
+ writeViteApiUrl: 0,
37
+ promptName: 0,
38
+ };
39
+ const createdBodies: { name: string; organizationId?: string }[] = [];
40
+ const writtenLinks: ProjectLink[] = [];
41
+ return {
42
+ calls,
43
+ createdBodies,
44
+ writtenLinks,
45
+ readLink: async () => null,
46
+ collectFiles: async () => {
47
+ calls.collectFiles += 1;
48
+ return [];
49
+ },
50
+ ensureAuth: async () => {
51
+ calls.ensureAuth += 1;
52
+ },
53
+ resolveOrganization: async (flagId) => {
54
+ calls.resolveOrganization += 1;
55
+ return flagId;
56
+ },
57
+ createProject: async (body) => {
58
+ calls.createProject += 1;
59
+ createdBodies.push(body);
60
+ return { id: "proj_new", name: body.name, apiUrl: "https://proj_new.robodev.povio.dev" };
61
+ },
62
+ writeLink: async (link) => {
63
+ calls.writeLink += 1;
64
+ writtenLinks.push(link);
65
+ },
66
+ writeViteApiUrl: async () => {
67
+ calls.writeViteApiUrl += 1;
68
+ },
69
+ hasAppsFePackageJson: async () => false,
70
+ starbaseUrl: () => "https://robodev.povio.dev",
71
+ projectNameFromPath: () => "my-folder",
72
+ promptName: async () => {
73
+ calls.promptName += 1;
74
+ return "";
75
+ },
76
+ ...overrides,
77
+ ...(overrides.collectFiles
78
+ ? {
79
+ collectFiles: async (root: string) => {
80
+ calls.collectFiles += 1;
81
+ return overrides.collectFiles!(root);
82
+ },
83
+ }
84
+ : {}),
85
+ ...(overrides.ensureAuth
86
+ ? {
87
+ ensureAuth: async () => {
88
+ calls.ensureAuth += 1;
89
+ return overrides.ensureAuth!();
90
+ },
91
+ }
92
+ : {}),
93
+ ...(overrides.resolveOrganization
94
+ ? {
95
+ resolveOrganization: async (flagId: string | undefined) => {
96
+ calls.resolveOrganization += 1;
97
+ return overrides.resolveOrganization!(flagId);
98
+ },
99
+ }
100
+ : {}),
101
+ ...(overrides.createProject
102
+ ? {
103
+ createProject: async (body: { name: string; organizationId?: string }) => {
104
+ calls.createProject += 1;
105
+ createdBodies.push(body);
106
+ return overrides.createProject!(body);
107
+ },
108
+ }
109
+ : {}),
110
+ ...(overrides.writeLink
111
+ ? {
112
+ writeLink: async (link: ProjectLink, root: string) => {
113
+ calls.writeLink += 1;
114
+ writtenLinks.push(link);
115
+ return overrides.writeLink!(link, root);
116
+ },
117
+ }
118
+ : {}),
119
+ ...(overrides.writeViteApiUrl
120
+ ? {
121
+ writeViteApiUrl: async (url: string, root: string) => {
122
+ calls.writeViteApiUrl += 1;
123
+ return overrides.writeViteApiUrl!(url, root);
124
+ },
125
+ }
126
+ : {}),
127
+ ...(overrides.promptName
128
+ ? {
129
+ promptName: async (defaultName: string) => {
130
+ calls.promptName += 1;
131
+ return overrides.promptName!(defaultName);
132
+ },
133
+ }
134
+ : {}),
135
+ };
136
+ }
137
+
138
+ async function run(input: Partial<EnsureDeployLinkInput>, deps: ReturnType<typeof stubDeps>) {
139
+ return ensureDeployLink({ root: "/tmp/app", isTty: false, ...input }, deps);
140
+ }
141
+
142
+ test("ensureDeployLink returns an existing link and does not create again", async () => {
143
+ const deps = stubDeps({ readLink: async () => linked });
144
+ const result = await run({ name: "Ignored", orgId: "org_x", isTty: true }, deps);
145
+ assert.deepEqual(result, linked);
146
+ assert.equal(deps.calls.ensureAuth, 0);
147
+ assert.equal(deps.calls.collectFiles, 0);
148
+ assert.equal(deps.calls.createProject, 0);
149
+ assert.equal(deps.calls.writeLink, 0);
150
+ assert.equal(deps.calls.resolveOrganization, 0);
151
+ });
152
+
153
+ test("ensureDeployLink fails collect before creating a project", async () => {
154
+ const deps = stubDeps({
155
+ collectFiles: async () => {
156
+ throw new Error("database.ts not found (looked in robodev/database.ts and ./database.ts)");
157
+ },
158
+ });
159
+ await assert.rejects(() => run({ name: "Orbit", isTty: false }, deps), /database\.ts not found/);
160
+ assert.equal(deps.calls.ensureAuth, 1);
161
+ assert.equal(deps.calls.collectFiles, 1);
162
+ assert.equal(deps.calls.createProject, 0);
163
+ assert.equal(deps.calls.writeLink, 0);
164
+ assert.equal(deps.calls.resolveOrganization, 0);
165
+ });
166
+
167
+ test("ensureDeployLink non-TTY without --name exits without creating", async () => {
168
+ const deps = stubDeps();
169
+ await assert.rejects(() => run({ isTty: false }, deps), new Error(UNLINKED_NON_TTY_MESSAGE));
170
+ assert.equal(deps.calls.ensureAuth, 1);
171
+ assert.equal(deps.calls.collectFiles, 1);
172
+ assert.equal(deps.calls.createProject, 0);
173
+ assert.equal(deps.calls.writeLink, 0);
174
+ assert.equal(deps.calls.promptName, 0);
175
+ assert.equal(deps.calls.resolveOrganization, 0);
176
+ });
177
+
178
+ test("ensureDeployLink TTY prompts for name and writes the link before returning", async () => {
179
+ const deps = stubDeps({
180
+ promptName: async (defaultName) => {
181
+ assert.equal(defaultName, "my-folder");
182
+ return "";
183
+ },
184
+ });
185
+ const result = await run({ isTty: true }, deps);
186
+ assert.equal(deps.calls.promptName, 1);
187
+ assert.deepEqual(deps.createdBodies, [{ name: "my-folder" }]);
188
+ assert.equal(deps.calls.writeLink, 1);
189
+ assert.equal(deps.calls.writeViteApiUrl, 1);
190
+ assert.equal(deps.calls.createProject, 1);
191
+ assert.ok(deps.calls.writeLink >= 1);
192
+ assert.deepEqual(result, deps.writtenLinks[0]);
193
+ assert.equal(result.projectId, "proj_new");
194
+ assert.equal(result.frontend, undefined);
195
+ });
196
+
197
+ test("ensureDeployLink TTY uses a typed name and optional org", async () => {
198
+ const deps = stubDeps({
199
+ resolveOrganization: async (flagId) => flagId,
200
+ promptName: async () => "From prompt",
201
+ });
202
+ await run({ isTty: true, orgId: "org_2" }, deps);
203
+ assert.deepEqual(deps.createdBodies, [{ name: "From prompt", organizationId: "org_2" }]);
204
+ });
205
+
206
+ test("ensureDeployLink non-TTY with --name creates a name-only project", async () => {
207
+ const deps = stubDeps({
208
+ hasAppsFePackageJson: async () => true,
209
+ });
210
+ const result = await run({ isTty: false, name: " Orbit ", orgId: "org_1" }, deps);
211
+ assert.equal(deps.calls.promptName, 0);
212
+ assert.deepEqual(deps.createdBodies, [{ name: "Orbit", organizationId: "org_1" }]);
213
+ assert.equal("starterId" in deps.createdBodies[0], false);
214
+ assert.deepEqual(result.frontend, defaultFrontendConfig());
215
+ assert.equal(deps.calls.writeLink, 1);
216
+ });
217
+
218
+ test("ensureDeployLink auth failure happens before collect and create", async () => {
219
+ const deps = stubDeps({
220
+ ensureAuth: async () => {
221
+ throw new Error("Not authenticated. Run `robodev auth` first.");
222
+ },
223
+ });
224
+ await assert.rejects(() => run({ name: "Orbit", isTty: false }, deps), /Not authenticated/);
225
+ assert.equal(deps.calls.ensureAuth, 1);
226
+ assert.equal(deps.calls.collectFiles, 0);
227
+ assert.equal(deps.calls.createProject, 0);
228
+ });
@@ -0,0 +1,67 @@
1
+ import { defaultFrontendConfig, type ProjectLink } from "./config.js";
2
+
3
+ export type DeployProject = { id: string; name: string; apiUrl: string };
4
+
5
+ export const UNLINKED_NON_TTY_MESSAGE =
6
+ "No robodev/.robodev file. Pass --name / --org or run `robodev link` or `robodev create`.";
7
+
8
+ export type EnsureDeployLinkInput = {
9
+ root: string;
10
+ name?: string;
11
+ orgId?: string;
12
+ isTty: boolean;
13
+ };
14
+
15
+ export type EnsureDeployLinkDeps = {
16
+ readLink: (root: string) => Promise<ProjectLink | null>;
17
+ collectFiles: (root: string) => Promise<unknown>;
18
+ ensureAuth: () => Promise<void>;
19
+ resolveOrganization: (flagId: string | undefined) => Promise<string | undefined>;
20
+ createProject: (body: { name: string; organizationId?: string }) => Promise<DeployProject>;
21
+ writeLink: (link: ProjectLink, root: string) => Promise<void>;
22
+ writeViteApiUrl: (projectApiUrl: string, root: string) => Promise<void>;
23
+ hasAppsFePackageJson: (root: string) => Promise<boolean>;
24
+ starbaseUrl: () => string;
25
+ projectNameFromPath: (root: string) => string;
26
+ promptName: (defaultName: string) => Promise<string>;
27
+ };
28
+
29
+ function resolveProjectName(raw: string | undefined, fallback: string): string {
30
+ const trimmed = raw?.trim() ?? "";
31
+ return (trimmed || fallback).slice(0, 80);
32
+ }
33
+
34
+ /** Creates and writes a project link when the folder is not linked yet. */
35
+ export async function ensureDeployLink(
36
+ input: EnsureDeployLinkInput,
37
+ deps: EnsureDeployLinkDeps,
38
+ ): Promise<ProjectLink> {
39
+ const existing = await deps.readLink(input.root);
40
+ if (existing) return existing;
41
+
42
+ await deps.ensureAuth();
43
+ await deps.collectFiles(input.root);
44
+
45
+ const flaggedName = input.name?.trim() ?? "";
46
+ if (!input.isTty && !flaggedName) {
47
+ throw new Error(UNLINKED_NON_TTY_MESSAGE);
48
+ }
49
+
50
+ const organizationId = await deps.resolveOrganization(input.orgId);
51
+ const defaultName = deps.projectNameFromPath(input.root);
52
+ const name = flaggedName
53
+ ? resolveProjectName(flaggedName, defaultName)
54
+ : resolveProjectName(await deps.promptName(defaultName), defaultName);
55
+
56
+ const project = await deps.createProject(organizationId ? { name, organizationId } : { name });
57
+ const wantFrontend = await deps.hasAppsFePackageJson(input.root);
58
+ const link: ProjectLink = {
59
+ projectId: project.id,
60
+ apiUrl: deps.starbaseUrl(),
61
+ projectApiUrl: project.apiUrl,
62
+ ...(wantFrontend ? { frontend: defaultFrontendConfig() } : {}),
63
+ };
64
+ await deps.writeLink(link, input.root);
65
+ await deps.writeViteApiUrl(project.apiUrl, input.root);
66
+ return link;
67
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  type Starter,
37
37
  } from "./emit-starter.js";
38
38
  import { parseCreateArgs } from "./parse-create-args.js";
39
+ import { parseDeployArgs } from "./parse-deploy-args.js";
40
+ import { ensureDeployLink } from "./ensure-deploy-link.js";
39
41
  import { printLiveUrl } from "./print-live-url.js";
40
42
 
41
43
  const execFileAsync = promisify(execFile);
@@ -50,9 +52,9 @@ Commands:
50
52
  logout Remove stored credentials
51
53
  whoami Show the signed-in user
52
54
  projects List your projects
53
- create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
55
+ create [path] [--template <id>] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
54
56
  link [projectId] Write robodev/.robodev in the current folder
55
- deploy [--force] Deploy schema, APIs, jobs, and frontend (local Vite build when .robodev frontend is set)
57
+ deploy [--force] [--name <name>] [--org <id>] Deploy schema, APIs, jobs, and frontend (creates and links if unlinked)
56
58
  dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
57
59
 
58
60
  dev options:
@@ -247,12 +249,31 @@ async function collectDeployFiles(
247
249
  async function runDeploy(
248
250
  forceFlag: boolean,
249
251
  options: DeployOptions = {},
252
+ flags: { name?: string; orgId?: string } = {},
250
253
  ): Promise<string | undefined> {
251
254
  const root = options.root ?? process.cwd();
252
- const link = await readLink(root);
253
- if (!link) {
254
- throw new Error("No robodev/.robodev file. Run `robodev link` or `robodev create` first.");
255
- }
255
+ const link = await ensureDeployLink(
256
+ { root, name: flags.name, orgId: flags.orgId, isTty: Boolean(stdin.isTTY) },
257
+ {
258
+ readLink,
259
+ collectFiles,
260
+ ensureAuth: async () => {
261
+ await authed("/v1/auth/me");
262
+ },
263
+ resolveOrganization,
264
+ createProject: (body) =>
265
+ authed<Project>("/v1/projects", {
266
+ method: "POST",
267
+ body: JSON.stringify(body),
268
+ }),
269
+ writeLink,
270
+ writeViteApiUrl,
271
+ hasAppsFePackageJson,
272
+ starbaseUrl,
273
+ projectNameFromPath,
274
+ promptName: (defaultName) => prompt(`Project name [${defaultName}]: `),
275
+ },
276
+ );
256
277
  const files = await collectDeployFiles(root, link, options);
257
278
  let force = forceFlag;
258
279
 
@@ -376,40 +397,47 @@ async function resolveOrganization(flagId: string | undefined): Promise<string |
376
397
  return found.id;
377
398
  }
378
399
 
379
- async function resolveStarter(flagId: string | undefined): Promise<Starter> {
400
+ async function resolveStarter(parsed: {
401
+ starterId?: string;
402
+ promptTemplate?: boolean;
403
+ }): Promise<Starter> {
380
404
  const starters = await loadCatalog();
381
- if (flagId !== undefined) {
382
- const found = starters.find((starter) => starter.id === flagId);
405
+ if (parsed.starterId !== undefined) {
406
+ const found = starters.find((starter) => starter.id === parsed.starterId);
383
407
  if (!found) {
384
- throw new Error(`Unknown starter "${flagId}". ${validIdsMessage(starters)}`);
408
+ throw new Error(`Unknown starter "${parsed.starterId}". ${validIdsMessage(starters)}`);
385
409
  }
386
410
  return found;
387
411
  }
388
- if (stdin.isTTY) {
389
- console.log("Starters:");
390
- starters.forEach((starter, i) => {
391
- console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
392
- console.log(` ${starter.description}`);
393
- });
394
- const answer = (await prompt("Starter number or id: ")).trim();
395
- if (!answer) {
396
- throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
397
- }
398
- const asIndex = Number(answer);
399
- if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
400
- return starters[asIndex - 1];
401
- }
402
- const found = starters.find((starter) => starter.id === answer);
403
- if (!found) {
404
- throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
412
+ if (parsed.promptTemplate) {
413
+ if (stdin.isTTY) {
414
+ console.log("Starters:");
415
+ starters.forEach((starter, i) => {
416
+ console.log(` ${i + 1}. ${starter.title} (${starter.id})`);
417
+ console.log(` ${starter.description}`);
418
+ });
419
+ const answer = (await prompt("Starter number or id: ")).trim();
420
+ if (!answer) {
421
+ throw new Error(`Unknown starter "". ${validIdsMessage(starters)}`);
422
+ }
423
+ const asIndex = Number(answer);
424
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= starters.length) {
425
+ return starters[asIndex - 1];
426
+ }
427
+ const found = starters.find((starter) => starter.id === answer);
428
+ if (!found) {
429
+ throw new Error(`Unknown starter "${answer}". ${validIdsMessage(starters)}`);
430
+ }
431
+ return found;
405
432
  }
406
- return found;
433
+ console.log("robodev create --template requires a starter id when stdin is not a TTY.");
434
+ usage();
407
435
  }
408
- const space = starters.find((starter) => starter.id === "space");
409
- if (!space) {
410
- throw new Error(`Unknown starter "space". ${validIdsMessage(starters)}`);
436
+ const empty = starters.find((starter) => starter.id === "empty");
437
+ if (!empty) {
438
+ throw new Error(`Unknown starter "empty". ${validIdsMessage(starters)}`);
411
439
  }
412
- return space;
440
+ return empty;
413
441
  }
414
442
 
415
443
  function projectNameFromPath(root: string): string {
@@ -507,7 +535,7 @@ function printFeNextSteps(displayPath: string, hosted: boolean): void {
507
535
  /** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
508
536
  async function runCreate(args: string[]): Promise<void> {
509
537
  const parsed = parseCreateArgs(args);
510
- const starter = await resolveStarter(parsed.starterId);
538
+ const starter = await resolveStarter(parsed);
511
539
  console.log(`Using starter ${starter.title} (${starter.id})`);
512
540
 
513
541
  const root = resolve(process.cwd(), parsed.path ?? ".");
@@ -602,7 +630,10 @@ async function runCreate(args: string[]): Promise<void> {
602
630
  if (displayPath !== ".") {
603
631
  console.log(` cd ${displayPath}`);
604
632
  }
605
- console.log(" npm run dev");
633
+ console.log(" The project App URL serves the hosted Welcome page.");
634
+ console.log(" Edit index.html and src/, then robodev deploy (or Code + Save).");
635
+ console.log(" robodev dev # local APIs");
636
+ console.log(" npm run dev:fe # optional local Vite");
606
637
  }
607
638
  if (liveUrl) {
608
639
  printLiveUrl(liveUrl);
@@ -631,9 +662,11 @@ async function main(): Promise<void> {
631
662
  case "link":
632
663
  await runLink(args[0]);
633
664
  break;
634
- case "deploy":
635
- await runDeploy(args.includes("--force"));
665
+ case "deploy": {
666
+ const parsed = parseDeployArgs(args);
667
+ await runDeploy(parsed.force, {}, { name: parsed.name, orgId: parsed.orgId });
636
668
  break;
669
+ }
637
670
  case "dev": {
638
671
  const { main: runDev } = await import("./dev.js");
639
672
  await runDev(args);
@@ -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,6 +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/);
96
+ });
97
+
98
+ test("parseCreateArgs rejects two starter selectors", () => {
99
+ assert.throws(
100
+ () => parseCreateArgs(["--empty", "--starter", "empty"]),
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\./,
114
+ );
26
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
  }
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { parseDeployArgs } from "./parse-deploy-args.js";
4
+
5
+ test("parseDeployArgs accepts --force --name and --org", () => {
6
+ assert.deepEqual(parseDeployArgs([]), { force: false, name: undefined, orgId: undefined });
7
+ assert.deepEqual(parseDeployArgs(["--force"]), {
8
+ force: true,
9
+ name: undefined,
10
+ orgId: undefined,
11
+ });
12
+ assert.deepEqual(parseDeployArgs(["--name", "Orbit", "--org", "org_1"]), {
13
+ force: false,
14
+ name: "Orbit",
15
+ orgId: "org_1",
16
+ });
17
+ assert.deepEqual(parseDeployArgs(["--name=Orbit", "--org=org_2", "--force"]), {
18
+ force: true,
19
+ name: "Orbit",
20
+ orgId: "org_2",
21
+ });
22
+ });
23
+
24
+ test("parseDeployArgs rejects a missing --name or --org value", () => {
25
+ assert.throws(() => parseDeployArgs(["--name"]), /Missing value for --name/);
26
+ assert.throws(() => parseDeployArgs(["--name="]), /Missing value for --name/);
27
+ assert.throws(() => parseDeployArgs(["--name", "--force"]), /Missing value for --name/);
28
+ assert.throws(() => parseDeployArgs(["--org"]), /Missing value for --org/);
29
+ assert.throws(() => parseDeployArgs(["--org="]), /Missing value for --org/);
30
+ assert.throws(() => parseDeployArgs(["--org", "--name", "x"]), /Missing value for --org/);
31
+ });
32
+
33
+ test("parseDeployArgs rejects unknown flags the same way as create", () => {
34
+ assert.throws(() => parseDeployArgs(["--organization", "org_1"]), /Unknown flag --organization/);
35
+ assert.throws(() => parseDeployArgs(["--empty"]), /Unknown flag --empty/);
36
+ assert.throws(() => parseDeployArgs(["my-app"]), /Unexpected argument my-app/);
37
+ });
@@ -0,0 +1,53 @@
1
+ export type DeployArgs = { force: boolean; name?: string; orgId?: string };
2
+
3
+ export function parseDeployArgs(args: string[]): DeployArgs {
4
+ let force = false;
5
+ let name: string | undefined;
6
+ let orgId: string | undefined;
7
+ for (let i = 0; i < args.length; i++) {
8
+ const arg = args[i];
9
+ if (arg === "--force") {
10
+ force = true;
11
+ continue;
12
+ }
13
+ if (arg === "--name") {
14
+ const value = args[i + 1];
15
+ if (!value || value.startsWith("-")) {
16
+ throw new Error("Missing value for --name");
17
+ }
18
+ name = value;
19
+ i += 1;
20
+ continue;
21
+ }
22
+ if (arg.startsWith("--name=")) {
23
+ const value = arg.slice("--name=".length);
24
+ if (!value) {
25
+ throw new Error("Missing value for --name");
26
+ }
27
+ name = value;
28
+ continue;
29
+ }
30
+ if (arg === "--org") {
31
+ const value = args[i + 1];
32
+ if (!value || value.startsWith("-")) {
33
+ throw new Error("Missing value for --org");
34
+ }
35
+ orgId = value;
36
+ i += 1;
37
+ continue;
38
+ }
39
+ if (arg.startsWith("--org=")) {
40
+ const value = arg.slice("--org=".length);
41
+ if (!value) {
42
+ throw new Error("Missing value for --org");
43
+ }
44
+ orgId = value;
45
+ continue;
46
+ }
47
+ if (arg.startsWith("-")) {
48
+ throw new Error(`Unknown flag ${arg}`);
49
+ }
50
+ throw new Error(`Unexpected argument ${arg}`);
51
+ }
52
+ return { force, name, orgId };
53
+ }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.38.1",
15
+ "robodev": "^0.40.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.38.1",
14
+ "robodev": "^0.40.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.38.1",
15
- "typescript": "^5.9.2"
19
+ "robodev": "^0.40.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.38.1",
15
+ "robodev": "^0.40.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.38.1",
15
+ "robodev": "^0.40.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }