robodev 0.38.1 → 0.39.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.39.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",
@@ -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);
@@ -52,7 +54,7 @@ Commands:
52
54
  projects List your projects
53
55
  create [path] [--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
 
@@ -631,9 +652,11 @@ async function main(): Promise<void> {
631
652
  case "link":
632
653
  await runLink(args[0]);
633
654
  break;
634
- case "deploy":
635
- await runDeploy(args.includes("--force"));
655
+ case "deploy": {
656
+ const parsed = parseDeployArgs(args);
657
+ await runDeploy(parsed.force, {}, { name: parsed.name, orgId: parsed.orgId });
636
658
  break;
659
+ }
637
660
  case "dev": {
638
661
  const { main: runDev } = await import("./dev.js");
639
662
  await runDev(args);
@@ -24,3 +24,10 @@ test("parseCreateArgs rejects a missing --org value", () => {
24
24
  test("parseCreateArgs rejects unknown flags including --organization", () => {
25
25
  assert.throws(() => parseCreateArgs(["--organization", "org_1"]), /Unknown flag --organization/);
26
26
  });
27
+
28
+ test("parseCreateArgs rejects --empty together with --starter", () => {
29
+ assert.throws(
30
+ () => parseCreateArgs(["--empty", "--starter", "empty"]),
31
+ /Use either --empty or --starter, not both/,
32
+ );
33
+ });
@@ -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.39.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.39.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -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.39.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -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.39.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.39.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }