robodev 0.1.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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { register } from "tsx/esm/api";
3
+
4
+ register();
5
+ await import("../src/index.ts");
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "robodev",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Robodev Starbase — auth, link, and deploy file-based APIs",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/robodev-com/robodev-starbase.git",
10
+ "directory": "robodev-cli"
11
+ },
12
+ "bin": {
13
+ "robodev": "./bin/robodev.mjs"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "robodev": "tsx src/index.ts"
24
+ },
25
+ "dependencies": {
26
+ "tsx": "^4.20.5"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.3.0",
30
+ "typescript": "^5.9.2"
31
+ }
32
+ }
package/src/api.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { readCredentials, starbaseUrl, writeCredentials, type Credentials } from "./config.js";
2
+
3
+ export class ApiError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public status: number,
7
+ public body: Record<string, unknown>,
8
+ ) {
9
+ super(message);
10
+ }
11
+ }
12
+
13
+ async function request<T>(
14
+ path: string,
15
+ init: RequestInit = {},
16
+ credentials: Credentials | null = null,
17
+ retry = true,
18
+ ): Promise<T> {
19
+ const headers = new Headers(init.headers);
20
+ if (init.body && !headers.has("Content-Type")) {
21
+ headers.set("Content-Type", "application/json");
22
+ }
23
+ if (credentials?.accessToken) {
24
+ headers.set("Authorization", `Bearer ${credentials.accessToken}`);
25
+ }
26
+
27
+ const res = await fetch(`${starbaseUrl()}${path}`, { ...init, headers });
28
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
29
+
30
+ if (res.status === 401 && retry && credentials?.refreshToken) {
31
+ const refreshed = await refresh(credentials.refreshToken);
32
+ if (refreshed) {
33
+ await writeCredentials(refreshed);
34
+ return request<T>(path, init, refreshed, false);
35
+ }
36
+ }
37
+
38
+ if (!res.ok) {
39
+ throw new ApiError(
40
+ String(body.message ?? body.error ?? `Request failed (${res.status})`),
41
+ res.status,
42
+ body,
43
+ );
44
+ }
45
+ return body as T;
46
+ }
47
+
48
+ async function refresh(refreshToken: string): Promise<Credentials | null> {
49
+ const res = await fetch(`${starbaseUrl()}/v1/auth/refresh`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ refreshToken }),
53
+ });
54
+ if (!res.ok) return null;
55
+ return (await res.json()) as Credentials;
56
+ }
57
+
58
+ export async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
59
+ const credentials = await readCredentials();
60
+ if (!credentials) {
61
+ throw new Error("Not authenticated. Run `robodev auth` first.");
62
+ }
63
+ return request<T>(path, init, credentials);
64
+ }
65
+
66
+ export async function publicRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
67
+ return request<T>(path, init, null, false);
68
+ }
package/src/config.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export type Credentials = {
6
+ accessToken: string;
7
+ refreshToken: string;
8
+ };
9
+
10
+ export type ProjectLink = {
11
+ projectId: string;
12
+ apiUrl: string;
13
+ };
14
+
15
+ const PROD_URL = "https://robodev.povio.dev";
16
+
17
+ export function starbaseUrl(): string {
18
+ return (process.env.STARBASE_URL ?? PROD_URL).replace(/\/$/, "");
19
+ }
20
+
21
+ export function feUrl(): string {
22
+ return (process.env.STARBASE_FE_URL ?? process.env.STARBASE_URL ?? PROD_URL).replace(
23
+ /\/$/,
24
+ "",
25
+ );
26
+ }
27
+
28
+ export function credentialsPath(): string {
29
+ return join(homedir(), ".robodev", "credentials.json");
30
+ }
31
+
32
+ export function linkPath(cwd = process.cwd()): string {
33
+ return join(cwd, ".robodev");
34
+ }
35
+
36
+ export async function readCredentials(): Promise<Credentials | null> {
37
+ try {
38
+ return JSON.parse(await readFile(credentialsPath(), "utf8")) as Credentials;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export async function writeCredentials(credentials: Credentials): Promise<void> {
45
+ const path = credentialsPath();
46
+ await mkdir(dirname(path), { recursive: true });
47
+ await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`);
48
+ }
49
+
50
+ export async function clearCredentials(): Promise<void> {
51
+ await rm(credentialsPath(), { force: true });
52
+ }
53
+
54
+ export async function readLink(cwd = process.cwd()): Promise<ProjectLink | null> {
55
+ try {
56
+ return JSON.parse(await readFile(linkPath(cwd), "utf8")) as ProjectLink;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export async function writeLink(link: ProjectLink, cwd = process.cwd()): Promise<void> {
63
+ await writeFile(linkPath(cwd), `${JSON.stringify(link, null, 2)}\n`);
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,320 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+ import { join, relative } from "node:path";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { ApiError, authed, publicRequest } from "./api.js";
10
+ import {
11
+ clearCredentials,
12
+ feUrl,
13
+ readLink,
14
+ starbaseUrl,
15
+ writeCredentials,
16
+ writeLink,
17
+ } from "./config.js";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ type Project = { id: string; name: string; apiUrl: string };
22
+
23
+ function usage(): never {
24
+ console.log(`robodev <command>
25
+
26
+ Commands:
27
+ auth Sign in via Starbase in the browser
28
+ logout Remove stored credentials
29
+ whoami Show the signed-in user
30
+ projects List your projects
31
+ link [projectId] Write .robodev in the current folder
32
+ deploy [--force] Deploy database.ts and api/*.ts
33
+ `);
34
+ process.exit(1);
35
+ }
36
+
37
+ async function openBrowser(url: string): Promise<void> {
38
+ const platform = process.platform;
39
+ if (platform === "darwin") {
40
+ await execFileAsync("open", [url]);
41
+ return;
42
+ }
43
+ if (platform === "win32") {
44
+ await execFileAsync("cmd", ["/c", "start", "", url]);
45
+ return;
46
+ }
47
+ await execFileAsync("xdg-open", [url]);
48
+ }
49
+
50
+ function prompt(question: string): Promise<string> {
51
+ const rl = createInterface({ input: stdin, output: stdout });
52
+ return rl.question(question).finally(() => rl.close());
53
+ }
54
+
55
+ async function waitForOauthCode(): Promise<{ code: string; redirectUri: string }> {
56
+ const state = randomBytes(16).toString("hex");
57
+ return new Promise((resolve, reject) => {
58
+ const server = createServer((req, res) => {
59
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
60
+ if (url.pathname !== "/callback") {
61
+ res.statusCode = 404;
62
+ res.end("Not found");
63
+ return;
64
+ }
65
+ const redirectUri = `http://127.0.0.1:${(server.address() as { port: number }).port}/callback`;
66
+ if (url.searchParams.get("error")) {
67
+ res.end("Authorization denied. You can close this window.");
68
+ server.close();
69
+ reject(new Error(url.searchParams.get("error") ?? "access_denied"));
70
+ return;
71
+ }
72
+ if (url.searchParams.get("state") !== state) {
73
+ res.statusCode = 400;
74
+ res.end("Invalid state");
75
+ server.close();
76
+ reject(new Error("OAuth state mismatch"));
77
+ return;
78
+ }
79
+ const authCode = url.searchParams.get("code");
80
+ if (!authCode) {
81
+ res.statusCode = 400;
82
+ res.end("Missing code");
83
+ server.close();
84
+ reject(new Error("Missing authorization code"));
85
+ return;
86
+ }
87
+ res.end("Authenticated. You can close this window and return to the terminal.");
88
+ server.close();
89
+ resolve({ code: authCode, redirectUri });
90
+ });
91
+
92
+ server.listen(0, "127.0.0.1", () => {
93
+ const address = server.address();
94
+ if (!address || typeof address === "string") {
95
+ reject(new Error("Could not bind OAuth callback"));
96
+ return;
97
+ }
98
+ const redirectUri = `http://127.0.0.1:${address.port}/callback`;
99
+ const authorize = new URL("/oauth/authorize", feUrl());
100
+ authorize.searchParams.set("client_id", "robodev-cli");
101
+ authorize.searchParams.set("response_type", "code");
102
+ authorize.searchParams.set("redirect_uri", redirectUri);
103
+ authorize.searchParams.set("state", state);
104
+ console.log(`Opening browser to authorize robodev-cli…`);
105
+ openBrowser(authorize.toString()).catch(() => {
106
+ console.log("Open this URL in your browser:");
107
+ console.log(authorize.toString());
108
+ });
109
+ });
110
+ });
111
+ }
112
+
113
+ async function runAuth(): Promise<void> {
114
+ const { code, redirectUri } = await waitForOauthCode();
115
+ const tokens = await publicRequest<{ accessToken: string; refreshToken: string }>(
116
+ "/v1/oauth/token",
117
+ {
118
+ method: "POST",
119
+ body: JSON.stringify({
120
+ grantType: "authorization_code",
121
+ code,
122
+ clientId: "robodev-cli",
123
+ redirectUri,
124
+ }),
125
+ },
126
+ );
127
+ await writeCredentials(tokens);
128
+ const me = await authed<{ email: string }>("/v1/auth/me");
129
+ console.log(`Signed in as ${me.email}`);
130
+ }
131
+
132
+ async function runWhoami(): Promise<void> {
133
+ const me = await authed<{ id: string; email: string }>("/v1/auth/me");
134
+ console.log(`${me.email} (${me.id})`);
135
+ }
136
+
137
+ async function listProjects(): Promise<Project[]> {
138
+ const data = await authed<{ projects: Project[] }>("/v1/projects");
139
+ return data.projects;
140
+ }
141
+
142
+ async function runProjects(): Promise<void> {
143
+ const projects = await listProjects();
144
+ if (projects.length === 0) {
145
+ console.log("No projects. Create one in Starbase first.");
146
+ return;
147
+ }
148
+ for (const project of projects) {
149
+ console.log(`${project.id}\t${project.name}\t${project.apiUrl}`);
150
+ }
151
+ }
152
+
153
+ async function runLink(projectId?: string): Promise<void> {
154
+ const projects = await listProjects();
155
+ let chosen = projectId;
156
+ if (!chosen) {
157
+ if (projects.length === 0) {
158
+ throw new Error("No projects to link.");
159
+ }
160
+ console.log("Projects:");
161
+ projects.forEach((p, i) => console.log(` ${i + 1}. ${p.name} (${p.id})`));
162
+ const answer = await prompt("Project number or id: ");
163
+ const asIndex = Number(answer);
164
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= projects.length) {
165
+ chosen = projects[asIndex - 1].id;
166
+ } else {
167
+ chosen = answer.trim();
168
+ }
169
+ }
170
+ const project = projects.find((p) => p.id === chosen);
171
+ if (!project) {
172
+ throw new Error(`Project ${chosen} not found or not yours`);
173
+ }
174
+ await writeLink({ projectId: project.id, apiUrl: starbaseUrl() });
175
+ console.log(`Linked ${process.cwd()} to ${project.id}`);
176
+ }
177
+
178
+ async function collectFiles(root: string): Promise<{ path: string; content: string }[]> {
179
+ const files: { path: string; content: string }[] = [];
180
+ const database = join(root, "database.ts");
181
+ try {
182
+ await stat(database);
183
+ files.push({ path: "database.ts", content: await readFile(database, "utf8") });
184
+ } catch {
185
+ throw new Error("database.ts not found in the current directory");
186
+ }
187
+
188
+ async function walk(dir: string): Promise<void> {
189
+ let entries;
190
+ try {
191
+ entries = await readdir(dir, { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+ for (const entry of entries) {
196
+ const abs = join(dir, entry.name);
197
+ if (entry.isDirectory()) {
198
+ await walk(abs);
199
+ continue;
200
+ }
201
+ if (entry.isFile() && entry.name.endsWith(".ts")) {
202
+ files.push({
203
+ path: relative(root, abs).replaceAll("\\", "/"),
204
+ content: await readFile(abs, "utf8"),
205
+ });
206
+ }
207
+ }
208
+ }
209
+
210
+ await walk(join(root, "api"));
211
+ return files;
212
+ }
213
+
214
+ async function runDeploy(forceFlag: boolean): Promise<void> {
215
+ const link = await readLink();
216
+ if (!link) {
217
+ throw new Error("No .robodev file. Run `robodev link` first.");
218
+ }
219
+ const files = await collectFiles(process.cwd());
220
+ let force = forceFlag;
221
+
222
+ for (;;) {
223
+ try {
224
+ const result = await authed<{
225
+ deploymentId: string;
226
+ databaseName: string;
227
+ databaseCreated?: boolean;
228
+ apiUrl: string;
229
+ docsUrl: string;
230
+ routes: { method: string; path: string }[];
231
+ plan: { operations: { type: string; table: string; column?: string; sql: string }[] };
232
+ }>(`/v1/projects/${link.projectId}/deploy`, {
233
+ method: "POST",
234
+ body: JSON.stringify({ force, files }),
235
+ });
236
+ console.log(`Deployed to ${result.apiUrl}`);
237
+ console.log(`Docs ${result.docsUrl}`);
238
+ console.log(
239
+ result.databaseCreated
240
+ ? `Created database ${result.databaseName}`
241
+ : `Database ${result.databaseName}`,
242
+ );
243
+ if (result.plan.operations.length === 0) {
244
+ console.log("Schema already up to date.");
245
+ } else {
246
+ console.log("Schema changes:");
247
+ for (const op of result.plan.operations) {
248
+ console.log(` ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
249
+ }
250
+ }
251
+ for (const route of result.routes) {
252
+ console.log(` ${route.method.toUpperCase()} ${route.path}`);
253
+ }
254
+ return;
255
+ } catch (error) {
256
+ if (
257
+ error instanceof ApiError &&
258
+ error.status === 409 &&
259
+ !force &&
260
+ error.body.error === "destructive_schema_changes"
261
+ ) {
262
+ const plan = error.body.plan as {
263
+ operations: {
264
+ type: string;
265
+ table: string;
266
+ column?: string;
267
+ sql: string;
268
+ destructive: boolean;
269
+ }[];
270
+ };
271
+ console.log("Destructive schema changes:");
272
+ for (const op of plan.operations) {
273
+ const mark = op.destructive ? "!" : " ";
274
+ console.log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
275
+ console.log(` ${op.sql}`);
276
+ }
277
+ const answer = await prompt("Apply destructive changes? (y/N) ");
278
+ if (!/^y(es)?$/i.test(answer.trim())) {
279
+ console.log("Deploy cancelled.");
280
+ return;
281
+ }
282
+ force = true;
283
+ continue;
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+ }
289
+
290
+ async function main(): Promise<void> {
291
+ const [command, ...args] = process.argv.slice(2);
292
+ switch (command) {
293
+ case "auth":
294
+ await runAuth();
295
+ break;
296
+ case "logout":
297
+ await clearCredentials();
298
+ console.log("Signed out.");
299
+ break;
300
+ case "whoami":
301
+ await runWhoami();
302
+ break;
303
+ case "projects":
304
+ await runProjects();
305
+ break;
306
+ case "link":
307
+ await runLink(args[0]);
308
+ break;
309
+ case "deploy":
310
+ await runDeploy(args.includes("--force"));
311
+ break;
312
+ default:
313
+ usage();
314
+ }
315
+ }
316
+
317
+ main().catch((error) => {
318
+ console.error(error instanceof Error ? error.message : error);
319
+ process.exit(1);
320
+ });