robodev 0.30.0 → 0.32.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 +14 -10
- package/src/collect-files.test.ts +10 -1
- package/src/collect-files.ts +6 -1
- package/src/dev-clients.ts +66 -0
- package/src/dev-postgres.ts +161 -0
- package/src/dev-project.test.ts +168 -0
- package/src/dev-project.ts +147 -0
- package/src/dev-server.ts +205 -0
- package/src/dev.ts +376 -0
- package/src/index.ts +14 -0
- package/templates/auth-chat/package.json +3 -2
- package/templates/backend/package.json +3 -2
- package/templates/empty/package.json +3 -2
- package/templates/marketplace/package.json +3 -2
- package/templates/space/package.json +3 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Robodev Starbase — create, auth, link, and
|
|
3
|
+
"version": "0.32.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -20,17 +20,21 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"robodev": "tsx src/index.ts",
|
|
25
|
-
"generate:templates": "tsx src/generate-templates.ts",
|
|
26
|
-
"prepack": "tsx src/generate-templates.ts",
|
|
27
|
-
"test": "tsx --test src/**/*.test.ts"
|
|
28
|
-
},
|
|
29
23
|
"dependencies": {
|
|
30
|
-
"
|
|
24
|
+
"@fastify/cors": "^11.3.0",
|
|
25
|
+
"embedded-postgres": "17.10.0-beta.17",
|
|
26
|
+
"fastify": "^5.12.1",
|
|
27
|
+
"tsx": "^4.20.5",
|
|
28
|
+
"@robodev-ai/runtime": "0.2.0",
|
|
29
|
+
"@robodev-ai/sdk": "0.11.0"
|
|
31
30
|
},
|
|
32
31
|
"devDependencies": {
|
|
33
32
|
"@types/node": "^24.3.0",
|
|
34
33
|
"typescript": "^5.9.2"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"robodev": "tsx src/index.ts",
|
|
37
|
+
"generate:templates": "tsx src/generate-templates.ts",
|
|
38
|
+
"test": "tsx --test src/**/*.test.ts"
|
|
35
39
|
}
|
|
36
|
-
}
|
|
40
|
+
}
|
|
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { test } from "node:test";
|
|
7
|
-
import { collectFiles, mergeCollectedFiles } from "./collect-files.js";
|
|
7
|
+
import { collectFiles, mergeCollectedFiles, shouldCollectPath } from "./collect-files.js";
|
|
8
8
|
|
|
9
9
|
test("collectFiles includes hosted sources and skips denied paths", async () => {
|
|
10
10
|
const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
|
|
@@ -162,3 +162,12 @@ test("collectFiles rejects mixed backend layouts", async () => {
|
|
|
162
162
|
await rm(root, { recursive: true, force: true });
|
|
163
163
|
}
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
test("backend tests are not collected for deploy", () => {
|
|
167
|
+
assert.equal(shouldCollectPath("robodev/api/_lib.seed.test.ts"), false);
|
|
168
|
+
assert.equal(shouldCollectPath("robodev/jobs/resize.spec.ts"), false);
|
|
169
|
+
assert.equal(shouldCollectPath("robodev/sockets/chat.test.ts"), false);
|
|
170
|
+
assert.equal(shouldCollectPath("api/health.test.ts"), false);
|
|
171
|
+
assert.equal(shouldCollectPath("robodev/api/_lib.ts"), true);
|
|
172
|
+
assert.equal(shouldCollectPath("robodev/api/latest.ts"), true);
|
|
173
|
+
});
|
package/src/collect-files.ts
CHANGED
|
@@ -123,6 +123,11 @@ export function isPublicBinaryCollectedPath(path: string): boolean {
|
|
|
123
123
|
return normalized.startsWith("public/") && PUBLIC_BINARY_EXTENSIONS.has(extname(normalized));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** Project tests live beside the handlers they cover; they are not part of the deployed tree. */
|
|
127
|
+
function isBackendTestPath(path: string): boolean {
|
|
128
|
+
return /\.(test|spec)\.ts$/.test(path);
|
|
129
|
+
}
|
|
130
|
+
|
|
126
131
|
function isBackendPath(path: string): boolean {
|
|
127
132
|
const inner = path.startsWith("robodev/") ? path.slice("robodev/".length) : path;
|
|
128
133
|
if (inner === "database.ts" && (path === "database.ts" || path === "robodev/database.ts")) {
|
|
@@ -134,7 +139,7 @@ function isBackendPath(path: string): boolean {
|
|
|
134
139
|
inner.startsWith("hooks/") ||
|
|
135
140
|
inner.startsWith("sockets/")
|
|
136
141
|
) {
|
|
137
|
-
return inner.endsWith(".ts");
|
|
142
|
+
return inner.endsWith(".ts") && !isBackendTestPath(inner);
|
|
138
143
|
}
|
|
139
144
|
return path === "robodev/package.json";
|
|
140
145
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RouteClients } from "@robodev-ai/runtime";
|
|
2
|
+
import type { EmailClient, JobEnqueueInput, LlmClient } from "@robodev-ai/sdk";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
|
|
6
|
+
* `jobs` enqueues onto the local engine; everything else that needs Starbase
|
|
7
|
+
* infrastructure fails with a message that says so rather than silently doing nothing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function unavailable(feature: string): never {
|
|
11
|
+
throw Object.assign(
|
|
12
|
+
new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`),
|
|
13
|
+
{ statusCode: 501 },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Prints mail to stdout so local signup and reset flows are followable. */
|
|
18
|
+
export function consoleEmailClient(log: (line: string) => void): EmailClient {
|
|
19
|
+
return {
|
|
20
|
+
async send(input) {
|
|
21
|
+
const to = Array.isArray(input.to) ? input.to.join(", ") : input.to;
|
|
22
|
+
log(`email → ${to}: ${input.subject}`);
|
|
23
|
+
if (input.text) log(` ${input.text}`);
|
|
24
|
+
return { id: `local_${Date.now().toString(36)}`, status: "sent" };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function offlineLlmClient(): LlmClient {
|
|
30
|
+
return {
|
|
31
|
+
complete: () => unavailable("llm"),
|
|
32
|
+
stream: () => unavailable("llm"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function devRouteClients(input: {
|
|
37
|
+
env: Record<string, string>;
|
|
38
|
+
log: (line: string) => void;
|
|
39
|
+
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
40
|
+
}): RouteClients {
|
|
41
|
+
return {
|
|
42
|
+
email: consoleEmailClient(input.log),
|
|
43
|
+
llm: offlineLlmClient(),
|
|
44
|
+
agent: {
|
|
45
|
+
createSession: () => unavailable("agent"),
|
|
46
|
+
start: () => unavailable("agent"),
|
|
47
|
+
events: () => unavailable("agent"),
|
|
48
|
+
subscribe: () => unavailable("agent"),
|
|
49
|
+
destroy: () => unavailable("agent"),
|
|
50
|
+
},
|
|
51
|
+
storage: {
|
|
52
|
+
upload: () => unavailable("storage"),
|
|
53
|
+
get: () => unavailable("storage"),
|
|
54
|
+
getUrl: () => unavailable("storage"),
|
|
55
|
+
delete: () => unavailable("storage"),
|
|
56
|
+
list: () => unavailable("storage"),
|
|
57
|
+
},
|
|
58
|
+
push: { send: () => unavailable("push") },
|
|
59
|
+
jobs: { enqueue: input.enqueue },
|
|
60
|
+
sockets: {
|
|
61
|
+
send: () => unavailable("sockets"),
|
|
62
|
+
broadcast: () => unavailable("sockets"),
|
|
63
|
+
},
|
|
64
|
+
env: input.env,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, symlink } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { arch, platform } from "node:os";
|
|
6
|
+
|
|
7
|
+
export type DevPostgres = {
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
dataDir: string;
|
|
11
|
+
database: string;
|
|
12
|
+
connectionString: string;
|
|
13
|
+
stop: () => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEV_USER = "robodev";
|
|
17
|
+
const DEV_PASSWORD = "robodev";
|
|
18
|
+
const DEV_DATABASE = "robodev";
|
|
19
|
+
|
|
20
|
+
/** Asks the OS for a free TCP port so parallel projects never collide. */
|
|
21
|
+
export async function freePort(): Promise<number> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const server = createServer();
|
|
24
|
+
server.unref();
|
|
25
|
+
server.on("error", reject);
|
|
26
|
+
server.listen(0, "127.0.0.1", () => {
|
|
27
|
+
const address = server.address();
|
|
28
|
+
if (!address || typeof address === "string") {
|
|
29
|
+
server.close();
|
|
30
|
+
reject(new Error("Could not reserve a port for Postgres"));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const { port } = address;
|
|
34
|
+
server.close(() => resolve(port));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function devConnectionString(input: { port: number; database?: string }): string {
|
|
40
|
+
return `postgres://${DEV_USER}:${DEV_PASSWORD}@127.0.0.1:${input.port}/${
|
|
41
|
+
input.database ?? DEV_DATABASE
|
|
42
|
+
}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function isInitialised(dataDir: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
return (await readdir(dataDir)).includes("PG_VERSION");
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `@embedded-postgres/*` tarballs ship dylibs/shared objects without their version
|
|
55
|
+
* symlinks (npm tarballs cannot carry symlinks) and rebuild them from `pg-symlinks.json`
|
|
56
|
+
* in a `postinstall`. Package managers that skip install scripts leave the cluster
|
|
57
|
+
* unlaunchable with a cryptic loader error, so hydrate any missing links ourselves.
|
|
58
|
+
*/
|
|
59
|
+
export async function hydratePostgresSymlinks(): Promise<void> {
|
|
60
|
+
const pkg = `@embedded-postgres/${platform() === "win32" ? "windows" : platform()}-${arch()}`;
|
|
61
|
+
let pkgRoot: string;
|
|
62
|
+
try {
|
|
63
|
+
// The package only exports its entry point, so derive the root from `dist/index.js`.
|
|
64
|
+
pkgRoot = dirname(dirname(createRequire(import.meta.url).resolve(pkg)));
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let entries: { source: string; target: string }[];
|
|
70
|
+
try {
|
|
71
|
+
entries = JSON.parse(
|
|
72
|
+
await readFile(join(pkgRoot, "native", "pg-symlinks.json"), "utf8"),
|
|
73
|
+
) as typeof entries;
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const target = join(pkgRoot, entry.target);
|
|
80
|
+
try {
|
|
81
|
+
await lstat(target);
|
|
82
|
+
continue;
|
|
83
|
+
} catch {
|
|
84
|
+
/* missing, create it below */
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await symlink(relative(dirname(target), join(pkgRoot, entry.source)), target);
|
|
88
|
+
} catch {
|
|
89
|
+
/* another process won the race, or the tree is read-only */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadEmbeddedPostgres() {
|
|
95
|
+
try {
|
|
96
|
+
const mod = await import("embedded-postgres");
|
|
97
|
+
return mod.default;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Could not load the embedded Postgres binaries (${
|
|
101
|
+
error instanceof Error ? error.message : String(error)
|
|
102
|
+
}). Reinstall dependencies, or point robodev dev at your own server with --database-url.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Starts a real Postgres on a free port with its cluster under `dataDir`, so rows survive
|
|
109
|
+
* restarts. `initdb` only runs the first time.
|
|
110
|
+
*/
|
|
111
|
+
export async function startDevPostgres(input: {
|
|
112
|
+
dataDir: string;
|
|
113
|
+
port?: number;
|
|
114
|
+
quiet?: boolean;
|
|
115
|
+
}): Promise<DevPostgres> {
|
|
116
|
+
await hydratePostgresSymlinks();
|
|
117
|
+
const EmbeddedPostgres = await loadEmbeddedPostgres();
|
|
118
|
+
const port = input.port && input.port > 0 ? input.port : await freePort();
|
|
119
|
+
await mkdir(input.dataDir, { recursive: true });
|
|
120
|
+
const fresh = !(await isInitialised(input.dataDir));
|
|
121
|
+
|
|
122
|
+
const postgres = new EmbeddedPostgres({
|
|
123
|
+
databaseDir: input.dataDir,
|
|
124
|
+
port,
|
|
125
|
+
user: DEV_USER,
|
|
126
|
+
password: DEV_PASSWORD,
|
|
127
|
+
persistent: true,
|
|
128
|
+
onLog: input.quiet ? () => undefined : (message) => process.stderr.write(`${message}\n`),
|
|
129
|
+
onError: input.quiet ? () => undefined : (message) => process.stderr.write(`${message}\n`),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (fresh) {
|
|
133
|
+
await postgres.initialise();
|
|
134
|
+
}
|
|
135
|
+
await postgres.start();
|
|
136
|
+
if (fresh) {
|
|
137
|
+
await postgres.createDatabase(DEV_DATABASE);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
host: "127.0.0.1",
|
|
142
|
+
port,
|
|
143
|
+
dataDir: input.dataDir,
|
|
144
|
+
database: DEV_DATABASE,
|
|
145
|
+
connectionString: devConnectionString({ port }),
|
|
146
|
+
stop: () => postgres.stop(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Wraps a Postgres the user already runs, so `--database-url` behaves like the embedded one. */
|
|
151
|
+
export function externalDevPostgres(connectionString: string): DevPostgres {
|
|
152
|
+
const url = new URL(connectionString);
|
|
153
|
+
return {
|
|
154
|
+
host: url.hostname,
|
|
155
|
+
port: Number(url.port || 5432),
|
|
156
|
+
dataDir: "",
|
|
157
|
+
database: url.pathname.replace(/^\//, "") || "postgres",
|
|
158
|
+
connectionString,
|
|
159
|
+
stop: async () => undefined,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
backendCandidates,
|
|
8
|
+
collectBackendFiles,
|
|
9
|
+
devDirFor,
|
|
10
|
+
findDevProject,
|
|
11
|
+
isBackendDevPath,
|
|
12
|
+
} from "./dev-project.js";
|
|
13
|
+
import { parseDevArgs, parseEnvFile } from "./dev.js";
|
|
14
|
+
import { devConnectionString } from "./dev-postgres.js";
|
|
15
|
+
|
|
16
|
+
async function tempProject(): Promise<string> {
|
|
17
|
+
return mkdtemp(join(tmpdir(), "robodev-dev-"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DATABASE_TS = `import { defineDatabase, pgTable, text } from "@robodev-ai/sdk";
|
|
21
|
+
export const notes = pgTable("notes", { id: text("id").primaryKey() });
|
|
22
|
+
export default defineDatabase({ name: "app", tables: { notes } });
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
test("backendCandidates prefers robodev/ then the root then packages/be", () => {
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
backendCandidates("/p").map((entry) => entry.layout),
|
|
28
|
+
["robodev", "root", "packages/be"],
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("findDevProject finds a robodev/ backend", async () => {
|
|
33
|
+
const root = await tempProject();
|
|
34
|
+
await mkdir(join(root, "robodev"), { recursive: true });
|
|
35
|
+
await writeFile(join(root, "robodev", "database.ts"), DATABASE_TS);
|
|
36
|
+
|
|
37
|
+
const project = await findDevProject(root);
|
|
38
|
+
assert.equal(project.root, root);
|
|
39
|
+
assert.equal(project.backendDir, join(root, "robodev"));
|
|
40
|
+
assert.equal(project.layout, "robodev");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("findDevProject finds Tiny's packages/be and walks up from a subdirectory", async () => {
|
|
44
|
+
const root = await tempProject();
|
|
45
|
+
await mkdir(join(root, "packages", "be"), { recursive: true });
|
|
46
|
+
await mkdir(join(root, "apps", "fe"), { recursive: true });
|
|
47
|
+
await writeFile(join(root, "packages", "be", "database.ts"), DATABASE_TS);
|
|
48
|
+
|
|
49
|
+
const project = await findDevProject(join(root, "apps", "fe"));
|
|
50
|
+
assert.equal(project.root, root);
|
|
51
|
+
assert.equal(project.backendDir, join(root, "packages", "be"));
|
|
52
|
+
assert.equal(project.layout, "packages/be");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("findDevProject explains itself when there is no project", async () => {
|
|
56
|
+
const root = await mkdtemp(join(tmpdir(), "robodev-empty-"));
|
|
57
|
+
await assert.rejects(() => findDevProject(root), /No database\.ts found/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("isBackendDevPath accepts backend sources only", () => {
|
|
61
|
+
assert.equal(isBackendDevPath("database.ts"), true);
|
|
62
|
+
assert.equal(isBackendDevPath("api/planets.ts"), true);
|
|
63
|
+
assert.equal(isBackendDevPath("api/planets/[id].ts"), true);
|
|
64
|
+
assert.equal(isBackendDevPath("jobs/digest.ts"), true);
|
|
65
|
+
assert.equal(isBackendDevPath("hooks/auth.ts"), true);
|
|
66
|
+
assert.equal(isBackendDevPath("sockets/chat.ts"), true);
|
|
67
|
+
assert.equal(isBackendDevPath("src/main.tsx"), false);
|
|
68
|
+
assert.equal(isBackendDevPath("api/planets.js"), false);
|
|
69
|
+
assert.equal(isBackendDevPath("package.json"), false);
|
|
70
|
+
assert.equal(isBackendDevPath("../secrets.ts"), false);
|
|
71
|
+
// Project tests stay out of the dev tree, exactly as deploy excludes them.
|
|
72
|
+
assert.equal(isBackendDevPath("api/_lib.seed.test.ts"), false);
|
|
73
|
+
assert.equal(isBackendDevPath("api/planets.spec.ts"), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("collectBackendFiles reads deploy-shaped paths and skips the rest", async () => {
|
|
77
|
+
const root = await tempProject();
|
|
78
|
+
const backend = join(root, "robodev");
|
|
79
|
+
await mkdir(join(backend, "api", "planets"), { recursive: true });
|
|
80
|
+
await mkdir(join(backend, "jobs"), { recursive: true });
|
|
81
|
+
await mkdir(join(backend, "node_modules"), { recursive: true });
|
|
82
|
+
await writeFile(join(backend, "database.ts"), DATABASE_TS);
|
|
83
|
+
await writeFile(join(backend, "api", "planets.ts"), "export const get = 1;");
|
|
84
|
+
await writeFile(join(backend, "api", "planets", "[id].ts"), "export const get = 1;");
|
|
85
|
+
await writeFile(join(backend, "api", "notes.md"), "ignored");
|
|
86
|
+
await writeFile(join(backend, "api", "_lib.seed.test.ts"), "ignored");
|
|
87
|
+
await writeFile(join(backend, "jobs", "digest.ts"), "export default 1;");
|
|
88
|
+
await writeFile(join(backend, "node_modules", "x.ts"), "ignored");
|
|
89
|
+
await writeFile(join(backend, "package.json"), "{}");
|
|
90
|
+
|
|
91
|
+
const files = await collectBackendFiles(backend);
|
|
92
|
+
assert.deepEqual(files.map((file) => file.path).sort(), [
|
|
93
|
+
"api/planets.ts",
|
|
94
|
+
"api/planets/[id].ts",
|
|
95
|
+
"database.ts",
|
|
96
|
+
"jobs/digest.ts",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("collectBackendFiles requires database.ts", async () => {
|
|
101
|
+
const root = await tempProject();
|
|
102
|
+
await mkdir(join(root, "api"), { recursive: true });
|
|
103
|
+
await writeFile(join(root, "api", "planets.ts"), "export const get = 1;");
|
|
104
|
+
await assert.rejects(() => collectBackendFiles(root), /database\.ts not found/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("devDirFor sidesteps a legacy .robodev link file", async () => {
|
|
108
|
+
const withDir = await tempProject();
|
|
109
|
+
assert.equal(await devDirFor(withDir), join(withDir, ".robodev", "dev"));
|
|
110
|
+
|
|
111
|
+
const withFile = await tempProject();
|
|
112
|
+
await writeFile(join(withFile, ".robodev"), "{}");
|
|
113
|
+
assert.equal(await devDirFor(withFile), join(withFile, ".robodev-dev"));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("parseDevArgs defaults to :4000, an ephemeral db port, and watching", () => {
|
|
117
|
+
assert.deepEqual(parseDevArgs([]), {
|
|
118
|
+
port: 4000,
|
|
119
|
+
dbPort: 0,
|
|
120
|
+
force: false,
|
|
121
|
+
watch: true,
|
|
122
|
+
withFe: false,
|
|
123
|
+
databaseUrl: undefined,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("parseDevArgs reads every flag", () => {
|
|
128
|
+
const parsed = parseDevArgs([
|
|
129
|
+
"--port",
|
|
130
|
+
"4500",
|
|
131
|
+
"--db-port",
|
|
132
|
+
"55433",
|
|
133
|
+
"--force",
|
|
134
|
+
"--no-watch",
|
|
135
|
+
"--with-fe",
|
|
136
|
+
"--database-url",
|
|
137
|
+
"postgres://u:p@localhost:5432/app",
|
|
138
|
+
]);
|
|
139
|
+
assert.deepEqual(parsed, {
|
|
140
|
+
port: 4500,
|
|
141
|
+
dbPort: 55433,
|
|
142
|
+
force: true,
|
|
143
|
+
watch: false,
|
|
144
|
+
withFe: true,
|
|
145
|
+
databaseUrl: "postgres://u:p@localhost:5432/app",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("parseDevArgs rejects malformed flag values", () => {
|
|
150
|
+
assert.throws(() => parseDevArgs(["--port", "abc"]), /--port needs/);
|
|
151
|
+
assert.throws(() => parseDevArgs(["--port", "-1"]), /--port needs/);
|
|
152
|
+
assert.throws(() => parseDevArgs(["--database-url"]), /--database-url needs/);
|
|
153
|
+
assert.throws(() => parseDevArgs(["--database-url", "--force"]), /--database-url needs/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("parseEnvFile handles comments, blanks, quotes, and values with =", () => {
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
parseEnvFile(["# comment", "", "A=1", 'B="two"', "C='three'", "D=a=b", " E = 5 "].join("\n")),
|
|
159
|
+
{ A: "1", B: "two", C: "three", D: "a=b", E: "5" },
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("devConnectionString points at loopback", () => {
|
|
164
|
+
assert.equal(
|
|
165
|
+
devConnectionString({ port: 55433 }),
|
|
166
|
+
"postgres://robodev:robodev@127.0.0.1:55433/robodev",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { isBackendTestPath } from "@robodev-ai/runtime";
|
|
4
|
+
|
|
5
|
+
export type DevFile = { path: string; content: string };
|
|
6
|
+
|
|
7
|
+
export type DevProject = {
|
|
8
|
+
/** Where `.robodev/dev/` and the root `package.json` live. */
|
|
9
|
+
root: string;
|
|
10
|
+
/** Absolute directory holding `database.ts` plus `api/`, `jobs/`, `hooks/`, `sockets/`. */
|
|
11
|
+
backendDir: string;
|
|
12
|
+
/** Backend layout that was matched, for the startup banner. */
|
|
13
|
+
layout: "root" | "robodev" | "packages/be";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Directories under the backend root that hold compilable project code. */
|
|
17
|
+
export const BACKEND_DIRS = ["api", "jobs", "hooks", "sockets"] as const;
|
|
18
|
+
|
|
19
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
|
|
20
|
+
const MAX_BACKEND_FILES = 400;
|
|
21
|
+
|
|
22
|
+
async function isFile(path: string): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
return (await stat(path)).isFile();
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Candidate backend roots for one directory, in the order `robodev deploy` prefers:
|
|
32
|
+
* `robodev/` first, then the project root, then Tiny's `packages/be`.
|
|
33
|
+
*/
|
|
34
|
+
export function backendCandidates(dir: string): { dir: string; layout: DevProject["layout"] }[] {
|
|
35
|
+
return [
|
|
36
|
+
{ dir: join(dir, "robodev"), layout: "robodev" },
|
|
37
|
+
{ dir, layout: "root" },
|
|
38
|
+
{ dir: join(dir, "packages", "be"), layout: "packages/be" },
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walks up from `cwd` looking for a created project: `database.ts` in `robodev/`, at the
|
|
44
|
+
* root, or in `packages/be` (Tiny). Walking up means `robodev dev` also works from
|
|
45
|
+
* `apps/fe`.
|
|
46
|
+
*/
|
|
47
|
+
export async function findDevProject(cwd: string): Promise<DevProject> {
|
|
48
|
+
let dir = cwd;
|
|
49
|
+
for (;;) {
|
|
50
|
+
for (const candidate of backendCandidates(dir)) {
|
|
51
|
+
if (await isFile(join(candidate.dir, "database.ts"))) {
|
|
52
|
+
return { root: dir, backendDir: candidate.dir, layout: candidate.layout };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const parent = dirname(dir);
|
|
56
|
+
if (parent === dir) break;
|
|
57
|
+
dir = parent;
|
|
58
|
+
}
|
|
59
|
+
throw new Error(
|
|
60
|
+
"No database.ts found. Run robodev dev from a created project (database.ts, robodev/database.ts, or packages/be/database.ts).",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True for a path the runtime compiles: `database.ts` or a `.ts` file under a backend dir.
|
|
66
|
+
* Project tests are excluded, exactly as `robodev deploy` excludes them.
|
|
67
|
+
*/
|
|
68
|
+
export function isBackendDevPath(path: string): boolean {
|
|
69
|
+
const normalized = path.replaceAll("\\", "/");
|
|
70
|
+
if (normalized.includes("..")) return false;
|
|
71
|
+
if (isBackendTestPath(normalized)) return false;
|
|
72
|
+
if (normalized === "database.ts") return true;
|
|
73
|
+
if (!normalized.endsWith(".ts")) return false;
|
|
74
|
+
const top = normalized.split("/")[0] ?? "";
|
|
75
|
+
return (BACKEND_DIRS as readonly string[]).includes(top);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reads the backend tree off disk as deploy files. Paths are the same ones hosted deploy
|
|
80
|
+
* uses (`database.ts`, `api/planets.ts`), so route mapping is identical.
|
|
81
|
+
*/
|
|
82
|
+
export async function collectBackendFiles(backendDir: string): Promise<DevFile[]> {
|
|
83
|
+
const files: DevFile[] = [];
|
|
84
|
+
|
|
85
|
+
async function walk(dir: string): Promise<void> {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const abs = join(dir, entry.name);
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
96
|
+
await walk(abs);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.isFile()) continue;
|
|
100
|
+
const path = relative(backendDir, abs).replaceAll("\\", "/");
|
|
101
|
+
if (!isBackendDevPath(path)) continue;
|
|
102
|
+
files.push({ path, content: await readFile(abs, "utf8") });
|
|
103
|
+
if (files.length > MAX_BACKEND_FILES) {
|
|
104
|
+
throw new Error(`Backend may include at most ${MAX_BACKEND_FILES} files`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (await isFile(join(backendDir, "database.ts"))) {
|
|
110
|
+
files.push({
|
|
111
|
+
path: "database.ts",
|
|
112
|
+
content: await readFile(join(backendDir, "database.ts"), "utf8"),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
for (const name of BACKEND_DIRS) {
|
|
116
|
+
await walk(join(backendDir, name));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!files.some((file) => file.path === "database.ts")) {
|
|
120
|
+
throw new Error(`database.ts not found in ${backendDir}`);
|
|
121
|
+
}
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where the local Postgres cluster and build output live. Legacy-layout projects keep
|
|
127
|
+
* `.robodev` as a link *file* at the root, so fall back beside it rather than failing.
|
|
128
|
+
*/
|
|
129
|
+
export async function devDirFor(root: string): Promise<string> {
|
|
130
|
+
const dotRobodev = join(root, ".robodev");
|
|
131
|
+
try {
|
|
132
|
+
if (!(await stat(dotRobodev)).isDirectory()) {
|
|
133
|
+
return join(root, ".robodev-dev");
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* absent: safe to create as a directory */
|
|
137
|
+
}
|
|
138
|
+
return join(dotRobodev, "dev");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function hasAppsFe(root: string): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
return (await stat(join(root, "apps/fe/package.json"))).isFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
|
|
4
|
+
import {
|
|
5
|
+
buildOpenApi,
|
|
6
|
+
handleLocalAuth,
|
|
7
|
+
isLocalAuthPath,
|
|
8
|
+
matchProjectRoute,
|
|
9
|
+
resolveLocalUser,
|
|
10
|
+
runRoute,
|
|
11
|
+
scalarHtml,
|
|
12
|
+
swaggerHtml,
|
|
13
|
+
type AuthQueryable,
|
|
14
|
+
type RuntimeRoute,
|
|
15
|
+
} from "@robodev-ai/runtime";
|
|
16
|
+
import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
|
|
17
|
+
import type { RuntimeJob } from "@robodev-ai/runtime";
|
|
18
|
+
import { devRouteClients } from "./dev-clients.js";
|
|
19
|
+
|
|
20
|
+
/** One compiled generation of the project. Swapped wholesale on reload. */
|
|
21
|
+
export type DevRuntime = {
|
|
22
|
+
db: RobodevDb;
|
|
23
|
+
pool: AuthQueryable & { end: () => Promise<void> };
|
|
24
|
+
routes: RuntimeRoute[];
|
|
25
|
+
jobs: RuntimeJob[];
|
|
26
|
+
openapi: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type DevServerOptions = {
|
|
30
|
+
port: number;
|
|
31
|
+
projectId: string;
|
|
32
|
+
jwtSecret: string;
|
|
33
|
+
env: Record<string, string>;
|
|
34
|
+
log: (line: string) => void;
|
|
35
|
+
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
|
|
39
|
+
const SCALAR_PATHS = new Set(["/_robodev/scalar", "/scalar"]);
|
|
40
|
+
|
|
41
|
+
export function buildDevOpenApi(projectName: string, routes: RuntimeRoute[]) {
|
|
42
|
+
return buildOpenApi({ title: `${projectName} APIs`, projectId: "local", routes });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pathnameOf(url: string): string {
|
|
46
|
+
const raw = decodeURIComponent(url.split("?")[0] ?? "/").replace(/\\/g, "/");
|
|
47
|
+
return raw.replace(/\/$/, "") || "/";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rawBodyOf(request: FastifyRequest): Buffer | undefined {
|
|
51
|
+
return (request as FastifyRequest & { rawBody?: Buffer }).rawBody;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup,
|
|
56
|
+
* no static SPA serving (local Vite does that), and no request logging — the routes,
|
|
57
|
+
* validation, and OpenAPI all come from `@robodev-ai/runtime`.
|
|
58
|
+
*/
|
|
59
|
+
export async function startDevServer(
|
|
60
|
+
options: DevServerOptions,
|
|
61
|
+
getRuntime: () => DevRuntime | null,
|
|
62
|
+
): Promise<{ app: FastifyInstance; close: () => Promise<void> }> {
|
|
63
|
+
const app = Fastify({ logger: false, bodyLimit: 25 * 1024 * 1024 });
|
|
64
|
+
|
|
65
|
+
await app.register(cors, {
|
|
66
|
+
origin: true,
|
|
67
|
+
credentials: true,
|
|
68
|
+
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
|
|
72
|
+
if (!body) {
|
|
73
|
+
done(null, {});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
done(null, JSON.parse(body as string));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
done(error as Error, undefined);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
app.addContentTypeParser(
|
|
83
|
+
"application/x-www-form-urlencoded",
|
|
84
|
+
{ parseAs: "string" },
|
|
85
|
+
(_request, body, done) => {
|
|
86
|
+
done(null, Object.fromEntries(new URLSearchParams(String(body ?? "")).entries()));
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
app.addContentTypeParser(
|
|
90
|
+
"application/octet-stream",
|
|
91
|
+
{ parseAs: "buffer" },
|
|
92
|
+
(_request, body, done) => done(null, body),
|
|
93
|
+
);
|
|
94
|
+
app.addContentTypeParser("multipart/form-data", { parseAs: "buffer" }, (_request, body, done) =>
|
|
95
|
+
done(null, body),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// `rawBody` handlers and multipart parsing need the untouched bytes.
|
|
99
|
+
app.addHook("preParsing", async (_request, _reply, payload) => {
|
|
100
|
+
const chunks: Buffer[] = [];
|
|
101
|
+
for await (const chunk of payload) {
|
|
102
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
103
|
+
}
|
|
104
|
+
const raw = Buffer.concat(chunks);
|
|
105
|
+
(_request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
|
|
106
|
+
return Readable.from(raw);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// The catch-all lives in the not-found handler: `@fastify/cors` already owns `OPTIONS /*`,
|
|
110
|
+
// and every project route is resolved by the runtime rather than registered with Fastify.
|
|
111
|
+
app.setNotFoundHandler(async (request, reply) => {
|
|
112
|
+
const pathname = pathnameOf(request.url);
|
|
113
|
+
const method = request.method.toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (pathname === "/_robodev/ready") {
|
|
116
|
+
const runtime = getRuntime();
|
|
117
|
+
if (!runtime) return reply.code(503).send({ error: "not_ready" });
|
|
118
|
+
try {
|
|
119
|
+
await runtime.pool.query("SELECT 1");
|
|
120
|
+
return reply.send({ ok: true });
|
|
121
|
+
} catch {
|
|
122
|
+
return reply.code(503).send({ error: "not_ready" });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (SWAGGER_PATHS.has(pathname)) {
|
|
127
|
+
return reply.type("text/html; charset=utf-8").send(swaggerHtml("/openapi.json"));
|
|
128
|
+
}
|
|
129
|
+
if (SCALAR_PATHS.has(pathname)) {
|
|
130
|
+
return reply.type("text/html; charset=utf-8").send(scalarHtml("/openapi.json"));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const runtime = getRuntime();
|
|
134
|
+
if (!runtime) {
|
|
135
|
+
return reply.code(503).send({
|
|
136
|
+
error: "not_ready",
|
|
137
|
+
message: "The project is still compiling. Retry in a moment.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (pathname === "/openapi.json") {
|
|
142
|
+
return reply.send(runtime.openapi);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isLocalAuthPath(pathname)) {
|
|
146
|
+
const result = await handleLocalAuth(
|
|
147
|
+
{
|
|
148
|
+
method: request.method,
|
|
149
|
+
pathname,
|
|
150
|
+
body: request.body,
|
|
151
|
+
query: request.query,
|
|
152
|
+
authorization: request.headers.authorization,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
db: runtime.pool,
|
|
156
|
+
projectId: options.projectId,
|
|
157
|
+
jwtSecret: options.jwtSecret,
|
|
158
|
+
deliverCode: ({ to, type, code }) => options.log(`${type} code for ${to}: ${code}`),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
return reply.code(result.status).send(result.body);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!matchProjectRoute(pathname, method, runtime.routes)) {
|
|
165
|
+
return reply.code(404).send({
|
|
166
|
+
error: "not_found",
|
|
167
|
+
message: `${request.method} ${pathname} is not defined`,
|
|
168
|
+
routes: runtime.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const outcome = await runRoute({
|
|
173
|
+
db: runtime.db,
|
|
174
|
+
routes: runtime.routes,
|
|
175
|
+
method,
|
|
176
|
+
pathname,
|
|
177
|
+
query: request.query ?? {},
|
|
178
|
+
body: request.body,
|
|
179
|
+
rawBody: rawBodyOf(request),
|
|
180
|
+
headers: request.headers as Record<string, string | string[] | undefined>,
|
|
181
|
+
authorization: request.headers.authorization,
|
|
182
|
+
resolveUser: (authorization) =>
|
|
183
|
+
resolveLocalUser(
|
|
184
|
+
{ projectId: options.projectId, jwtSecret: options.jwtSecret },
|
|
185
|
+
authorization,
|
|
186
|
+
),
|
|
187
|
+
clients: () =>
|
|
188
|
+
devRouteClients({ env: options.env, log: options.log, enqueue: options.enqueue }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (outcome.kind === "error") {
|
|
192
|
+
return reply.code(outcome.status).send(outcome.body);
|
|
193
|
+
}
|
|
194
|
+
for (const [name, value] of Object.entries(outcome.encoded.headers)) {
|
|
195
|
+
reply.header(name, value);
|
|
196
|
+
}
|
|
197
|
+
return reply
|
|
198
|
+
.code(outcome.encoded.status)
|
|
199
|
+
.type(outcome.encoded.contentType)
|
|
200
|
+
.send(outcome.encoded.payload);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await app.listen({ port: options.port, host: "0.0.0.0" });
|
|
204
|
+
return { app, close: () => app.close() };
|
|
205
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
2
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
apiBundleExternals,
|
|
8
|
+
applyPlan,
|
|
9
|
+
assertJobCrons,
|
|
10
|
+
buildPlan,
|
|
11
|
+
compileApiTree,
|
|
12
|
+
createLocalJobsEngine,
|
|
13
|
+
ensureAuthSchema,
|
|
14
|
+
ensureJobsSchema,
|
|
15
|
+
isEsbuildFailure,
|
|
16
|
+
loadModules,
|
|
17
|
+
mapEsbuildErrors,
|
|
18
|
+
writeDeploySources,
|
|
19
|
+
type SchemaPlan,
|
|
20
|
+
} from "@robodev-ai/runtime";
|
|
21
|
+
import { createDb as fallbackCreateDb, type DatabaseDef } from "@robodev-ai/sdk";
|
|
22
|
+
import { commandAvailable } from "./frontend-build.js";
|
|
23
|
+
import {
|
|
24
|
+
collectBackendFiles,
|
|
25
|
+
devDirFor,
|
|
26
|
+
findDevProject,
|
|
27
|
+
hasAppsFe,
|
|
28
|
+
isBackendDevPath,
|
|
29
|
+
type DevProject,
|
|
30
|
+
} from "./dev-project.js";
|
|
31
|
+
import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
|
|
32
|
+
import { devRouteClients } from "./dev-clients.js";
|
|
33
|
+
import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
|
|
34
|
+
|
|
35
|
+
export type DevArgs = {
|
|
36
|
+
port: number;
|
|
37
|
+
dbPort: number;
|
|
38
|
+
force: boolean;
|
|
39
|
+
watch: boolean;
|
|
40
|
+
withFe: boolean;
|
|
41
|
+
databaseUrl?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEFAULT_PORT = 4000;
|
|
45
|
+
const RELOAD_DEBOUNCE_MS = 150;
|
|
46
|
+
/** Local-only signing key. Tokens never leave the machine. */
|
|
47
|
+
const DEV_JWT_SECRET = "robodev-dev-local-secret";
|
|
48
|
+
const DEV_PROJECT_ID = "prj-local";
|
|
49
|
+
|
|
50
|
+
function numberFlag(args: string[], name: string, fallback: number): number {
|
|
51
|
+
const index = args.indexOf(name);
|
|
52
|
+
if (index === -1) return fallback;
|
|
53
|
+
const raw = args[index + 1];
|
|
54
|
+
const value = Number(raw);
|
|
55
|
+
if (raw === undefined || !Number.isInteger(value) || value < 0) {
|
|
56
|
+
throw new Error(`${name} needs a non-negative integer`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stringFlag(args: string[], name: string): string | undefined {
|
|
62
|
+
const index = args.indexOf(name);
|
|
63
|
+
if (index === -1) return undefined;
|
|
64
|
+
const value = args[index + 1];
|
|
65
|
+
if (!value || value.startsWith("--")) {
|
|
66
|
+
throw new Error(`${name} needs a value`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseDevArgs(args: string[]): DevArgs {
|
|
72
|
+
return {
|
|
73
|
+
port: numberFlag(args, "--port", DEFAULT_PORT),
|
|
74
|
+
dbPort: numberFlag(args, "--db-port", 0),
|
|
75
|
+
force: args.includes("--force"),
|
|
76
|
+
watch: !args.includes("--no-watch"),
|
|
77
|
+
withFe: args.includes("--with-fe"),
|
|
78
|
+
databaseUrl: stringFlag(args, "--database-url"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Minimal `.env` reader for `ctx.env`. Hosted projects get this from project secrets. */
|
|
83
|
+
export function parseEnvFile(content: string): Record<string, string> {
|
|
84
|
+
const env: Record<string, string> = {};
|
|
85
|
+
for (const line of content.split("\n")) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
88
|
+
const eq = trimmed.indexOf("=");
|
|
89
|
+
if (eq <= 0) continue;
|
|
90
|
+
const key = trimmed.slice(0, eq).trim();
|
|
91
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
92
|
+
if (
|
|
93
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
94
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
95
|
+
) {
|
|
96
|
+
value = value.slice(1, -1);
|
|
97
|
+
}
|
|
98
|
+
env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return env;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadProjectEnv(root: string): Promise<Record<string, string>> {
|
|
104
|
+
try {
|
|
105
|
+
return parseEnvFile(await readFile(join(root, ".env"), "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Uses the project's own `@robodev-ai/sdk` when it has one, so `ctx.db` and the Drizzle
|
|
113
|
+
* tables from `database.ts` come from the same drizzle-orm copy — the way hosted deploy
|
|
114
|
+
* compiles them.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveCreateDb(backendDir: string, root: string): Promise<typeof fallbackCreateDb> {
|
|
117
|
+
for (const base of [backendDir, root]) {
|
|
118
|
+
try {
|
|
119
|
+
const require = createRequire(join(base, "package.json"));
|
|
120
|
+
const entry = require.resolve("@robodev-ai/sdk");
|
|
121
|
+
const mod = (await import(entry)) as { createDb?: typeof fallbackCreateDb };
|
|
122
|
+
if (mod.createDb) return mod.createDb;
|
|
123
|
+
} catch {
|
|
124
|
+
/* fall through to the CLI's own copy */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return fallbackCreateDb;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function printPlan(plan: SchemaPlan): void {
|
|
131
|
+
for (const op of plan.operations) {
|
|
132
|
+
const mark = op.destructive ? "!" : " ";
|
|
133
|
+
console.log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type DevCompileResult = {
|
|
138
|
+
database: DatabaseDef;
|
|
139
|
+
runtime: DevRuntime;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export async function main(args: string[]): Promise<void> {
|
|
143
|
+
const parsed = parseDevArgs(args);
|
|
144
|
+
const project = await findDevProject(process.cwd());
|
|
145
|
+
const devDir = await devDirFor(project.root);
|
|
146
|
+
const buildDir = join(devDir, "build");
|
|
147
|
+
await mkdir(buildDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
const createDb = await resolveCreateDb(project.backendDir, project.root);
|
|
150
|
+
const env = await loadProjectEnv(project.root);
|
|
151
|
+
|
|
152
|
+
const postgres: DevPostgres = parsed.databaseUrl
|
|
153
|
+
? externalDevPostgres(parsed.databaseUrl)
|
|
154
|
+
: await startDevPostgres({ dataDir: join(devDir, "pg"), port: parsed.dbPort, quiet: true });
|
|
155
|
+
|
|
156
|
+
let current: DevRuntime | null = null;
|
|
157
|
+
let lastSchemaSource = "";
|
|
158
|
+
let closing = false;
|
|
159
|
+
|
|
160
|
+
const log = (line: string) => console.log(line);
|
|
161
|
+
const engine = createLocalJobsEngine({
|
|
162
|
+
query: (sql, values) => {
|
|
163
|
+
if (!current) throw new Error("jobs engine not ready");
|
|
164
|
+
return current.pool.query(sql, values);
|
|
165
|
+
},
|
|
166
|
+
getGeneration: () => current,
|
|
167
|
+
log,
|
|
168
|
+
clients: () => devRouteClients({ env, log, enqueue: (input) => engine.enqueue(input) }),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
|
|
172
|
+
const files = await collectBackendFiles(project.backendDir);
|
|
173
|
+
await writeDeploySources(join(buildDir, "src"), files);
|
|
174
|
+
|
|
175
|
+
let distDir: string;
|
|
176
|
+
try {
|
|
177
|
+
distDir = await compileApiTree({
|
|
178
|
+
dir: buildDir,
|
|
179
|
+
files,
|
|
180
|
+
external: apiBundleExternals(),
|
|
181
|
+
nodePaths: [join(project.backendDir, "node_modules"), join(project.root, "node_modules")],
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (isEsbuildFailure(error)) {
|
|
185
|
+
for (const issue of mapEsbuildErrors(error.errors, join(buildDir, "src"))) {
|
|
186
|
+
const where = issue.path ? `${issue.path}${issue.line ? `:${issue.line}` : ""}: ` : "";
|
|
187
|
+
console.error(` ${where}${issue.text}`);
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Compile failed");
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const loaded = await loadModules(distDir, files);
|
|
195
|
+
assertJobCrons(loaded.jobs);
|
|
196
|
+
// Any edit to database.ts can change columns, not just tables, so compare its source.
|
|
197
|
+
const schemaSource = files.find((file) => file.path === "database.ts")?.content ?? "";
|
|
198
|
+
|
|
199
|
+
if (input.syncSchema || schemaSource !== lastSchemaSource) {
|
|
200
|
+
const plan = await buildPlan(postgres.connectionString, loaded.database);
|
|
201
|
+
if (plan.destructive && !parsed.force) {
|
|
202
|
+
console.log("Destructive schema changes (skipped, rerun with --force):");
|
|
203
|
+
printPlan(plan);
|
|
204
|
+
} else {
|
|
205
|
+
if (plan.operations.length > 0) {
|
|
206
|
+
console.log("Schema changes:");
|
|
207
|
+
printPlan(plan);
|
|
208
|
+
}
|
|
209
|
+
await applyPlan(postgres.connectionString, plan);
|
|
210
|
+
lastSchemaSource = schemaSource;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { db, pool } = createDb(postgres.connectionString, loaded.database.tables);
|
|
215
|
+
await pool.query("SELECT 1");
|
|
216
|
+
await ensureAuthSchema(pool);
|
|
217
|
+
await ensureJobsSchema(pool);
|
|
218
|
+
|
|
219
|
+
const previous = current;
|
|
220
|
+
current = {
|
|
221
|
+
db,
|
|
222
|
+
pool,
|
|
223
|
+
routes: loaded.routes,
|
|
224
|
+
jobs: loaded.jobs,
|
|
225
|
+
openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
|
|
226
|
+
};
|
|
227
|
+
await engine.reconcile(loaded.jobs);
|
|
228
|
+
|
|
229
|
+
for (const route of loaded.routes) {
|
|
230
|
+
console.log(` ${route.method.toUpperCase()} ${route.path}`);
|
|
231
|
+
}
|
|
232
|
+
for (const job of loaded.jobs) {
|
|
233
|
+
if (job.def.cron) {
|
|
234
|
+
console.log(` job ${job.name} (cron ${job.def.cron})`);
|
|
235
|
+
} else {
|
|
236
|
+
console.log(` job ${job.name}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (loaded.sockets.length > 0) {
|
|
240
|
+
console.log(
|
|
241
|
+
` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (previous) {
|
|
245
|
+
await previous.pool.end().catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function releaseDatabase(): Promise<void> {
|
|
250
|
+
await current?.pool.end().catch(() => undefined);
|
|
251
|
+
await postgres.stop().catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let server: Awaited<ReturnType<typeof startDevServer>>;
|
|
255
|
+
try {
|
|
256
|
+
await compileAndActivate({ syncSchema: true });
|
|
257
|
+
await engine.start();
|
|
258
|
+
server = await startDevServer(
|
|
259
|
+
{
|
|
260
|
+
port: parsed.port,
|
|
261
|
+
projectId: DEV_PROJECT_ID,
|
|
262
|
+
jwtSecret: DEV_JWT_SECRET,
|
|
263
|
+
env,
|
|
264
|
+
log,
|
|
265
|
+
enqueue: (input) => engine.enqueue(input),
|
|
266
|
+
},
|
|
267
|
+
() => current,
|
|
268
|
+
);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Nothing is watching yet, so shut the cluster down instead of orphaning it.
|
|
271
|
+
await releaseDatabase();
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const apiUrl = `http://localhost:${parsed.port}`;
|
|
276
|
+
console.log("");
|
|
277
|
+
if (postgres.dataDir) {
|
|
278
|
+
console.log(
|
|
279
|
+
`Postgres localhost:${postgres.port} (data: ${relative(project.root, postgres.dataDir) || postgres.dataDir})`,
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
console.log(
|
|
283
|
+
`Postgres ${postgres.host}:${postgres.port}/${postgres.database} (--database-url)`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
console.log(`API ${apiUrl}`);
|
|
287
|
+
console.log(`Swagger ${apiUrl}/_robodev/docs`);
|
|
288
|
+
console.log(
|
|
289
|
+
`Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
|
|
290
|
+
);
|
|
291
|
+
console.log("");
|
|
292
|
+
|
|
293
|
+
const fe = parsed.withFe ? await startFrontend(project, apiUrl) : null;
|
|
294
|
+
const watchers = parsed.watch ? startWatch() : [];
|
|
295
|
+
|
|
296
|
+
function startWatch(): FSWatcher[] {
|
|
297
|
+
let timer: NodeJS.Timeout | undefined;
|
|
298
|
+
let running = false;
|
|
299
|
+
let pending = false;
|
|
300
|
+
|
|
301
|
+
async function reload(): Promise<void> {
|
|
302
|
+
if (running) {
|
|
303
|
+
pending = true;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
running = true;
|
|
307
|
+
try {
|
|
308
|
+
console.log("Change detected, reloading…");
|
|
309
|
+
await compileAndActivate({ syncSchema: false });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error(error instanceof Error ? error.message : error);
|
|
312
|
+
console.error("Keeping the previous build. Fix the error and save again.");
|
|
313
|
+
} finally {
|
|
314
|
+
running = false;
|
|
315
|
+
if (pending) {
|
|
316
|
+
pending = false;
|
|
317
|
+
void reload();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
return [
|
|
324
|
+
watch(project.backendDir, { recursive: true }, (_event, filename) => {
|
|
325
|
+
// Ignore editor scratch files and anything the runtime would not compile.
|
|
326
|
+
if (filename && !isBackendDevPath(String(filename))) return;
|
|
327
|
+
if (timer) clearTimeout(timer);
|
|
328
|
+
timer = setTimeout(() => void reload(), RELOAD_DEBOUNCE_MS);
|
|
329
|
+
}),
|
|
330
|
+
];
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error(
|
|
333
|
+
`Could not watch ${project.backendDir} (${error instanceof Error ? error.message : error}). Restart after edits, or pass --no-watch.`,
|
|
334
|
+
);
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function shutdown(code: number): Promise<void> {
|
|
340
|
+
if (closing) return;
|
|
341
|
+
closing = true;
|
|
342
|
+
await engine.stop();
|
|
343
|
+
for (const watcher of watchers) watcher.close();
|
|
344
|
+
fe?.kill("SIGTERM");
|
|
345
|
+
await server.close().catch(() => undefined);
|
|
346
|
+
await releaseDatabase();
|
|
347
|
+
process.exit(code);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
process.on("SIGINT", () => void shutdown(0));
|
|
351
|
+
process.on("SIGTERM", () => void shutdown(0));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Runs the starter's `apps/fe` dev server with the local API pre-wired. */
|
|
355
|
+
async function startFrontend(project: DevProject, apiUrl: string): Promise<ChildProcess | null> {
|
|
356
|
+
if (!(await hasAppsFe(project.root))) {
|
|
357
|
+
console.log("--with-fe: no apps/fe/package.json, serving APIs only.");
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const command = (await commandAvailable("bun")) ? "bun" : "npm";
|
|
361
|
+
const args = command === "bun" ? ["run", "dev"] : ["run", "dev"];
|
|
362
|
+
const child = spawn(command, args, {
|
|
363
|
+
cwd: join(project.root, "apps", "fe"),
|
|
364
|
+
stdio: "inherit",
|
|
365
|
+
env: {
|
|
366
|
+
...process.env,
|
|
367
|
+
VITE_API_URL: apiUrl,
|
|
368
|
+
VITE_PUBLIC_API_URL: apiUrl,
|
|
369
|
+
APP_PUBLIC_API_URL: apiUrl,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
child.on("error", (error) => {
|
|
373
|
+
console.error(`Could not start apps/fe with ${command}: ${error.message}`);
|
|
374
|
+
});
|
|
375
|
+
return child;
|
|
376
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,15 @@ Commands:
|
|
|
53
53
|
create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
54
54
|
link [projectId] Write robodev/.robodev in the current folder
|
|
55
55
|
deploy [--force] Deploy schema, APIs, jobs, and frontend (local Vite build when .robodev frontend is set)
|
|
56
|
+
dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
|
|
57
|
+
|
|
58
|
+
dev options:
|
|
59
|
+
--port <n> API port (default 4000)
|
|
60
|
+
--db-port <n> Postgres port (default 0 = pick a free one)
|
|
61
|
+
--database-url <url> Use an existing Postgres instead of the embedded one
|
|
62
|
+
--force Apply destructive schema changes
|
|
63
|
+
--no-watch Compile once and serve, without watching files
|
|
64
|
+
--with-fe Also start apps/fe with VITE_API_URL pointed at the local API
|
|
56
65
|
`);
|
|
57
66
|
process.exit(1);
|
|
58
67
|
}
|
|
@@ -625,6 +634,11 @@ async function main(): Promise<void> {
|
|
|
625
634
|
case "deploy":
|
|
626
635
|
await runDeploy(args.includes("--force"));
|
|
627
636
|
break;
|
|
637
|
+
case "dev": {
|
|
638
|
+
const { main: runDev } = await import("./dev.js");
|
|
639
|
+
await runDev(args);
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
628
642
|
default:
|
|
629
643
|
usage();
|
|
630
644
|
}
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"dev": "robodev dev --with-fe",
|
|
7
8
|
"deploy": "robodev deploy",
|
|
8
9
|
"rules:gen": "rulesync generate"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"@robodev-ai/sdk": "^0.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.32.0",
|
|
15
16
|
"rulesync": "^8.18.0",
|
|
16
17
|
"typescript": "^5.9.2"
|
|
17
18
|
}
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"dev": "robodev dev",
|
|
7
8
|
"deploy": "robodev deploy"
|
|
8
9
|
},
|
|
9
10
|
"dependencies": {
|
|
10
|
-
"@robodev-ai/sdk": "^0.
|
|
11
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
11
12
|
},
|
|
12
13
|
"devDependencies": {
|
|
13
|
-
"robodev": "^0.
|
|
14
|
+
"robodev": "^0.32.0",
|
|
14
15
|
"typescript": "^5.9.2"
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"dev": "robodev dev",
|
|
7
8
|
"deploy": "robodev deploy"
|
|
8
9
|
},
|
|
9
10
|
"dependencies": {
|
|
10
|
-
"@robodev-ai/sdk": "^0.
|
|
11
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
11
12
|
},
|
|
12
13
|
"devDependencies": {
|
|
13
|
-
"robodev": "^0.
|
|
14
|
+
"robodev": "^0.32.0",
|
|
14
15
|
"typescript": "^5.9.2"
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"dev": "robodev dev --with-fe",
|
|
7
8
|
"deploy": "robodev deploy",
|
|
8
9
|
"rules:gen": "rulesync generate"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"@robodev-ai/sdk": "^0.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.32.0",
|
|
15
16
|
"rulesync": "^8.18.0",
|
|
16
17
|
"typescript": "^5.9.2"
|
|
17
18
|
}
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"dev": "robodev dev --with-fe",
|
|
7
8
|
"deploy": "robodev deploy",
|
|
8
9
|
"rules:gen": "rulesync generate"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"@robodev-ai/sdk": "^0.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.32.0",
|
|
15
16
|
"rulesync": "^8.18.0",
|
|
16
17
|
"typescript": "^5.9.2"
|
|
17
18
|
}
|