roforge-cli 0.3.2 → 0.3.3
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/bin/roforge.js +36 -2
- package/package.json +1 -1
- package/src/bridge/server.js +76 -4
- package/src/config.js +1 -0
- package/src/pro.js +126 -0
- package/src/tools/studio.js +5 -2
package/bin/roforge.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Session } from "../src/session.js";
|
|
|
9
9
|
import { BridgeServer } from "../src/bridge/server.js";
|
|
10
10
|
import { probeMcp } from "../src/mcp.js";
|
|
11
11
|
import { TUI } from "../src/tui/tui.js";
|
|
12
|
-
import { bold, dim, red, green, cyan, yellow, gray } from "../src/tui/ansi.js";
|
|
12
|
+
import { bold, dim, red, green, cyan, yellow, gray, magenta } from "../src/tui/ansi.js";
|
|
13
13
|
|
|
14
14
|
const argv = process.argv.slice(2);
|
|
15
15
|
const command = argv[0] || "tui";
|
|
@@ -262,6 +262,39 @@ async function main() {
|
|
|
262
262
|
return;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
case "pro": {
|
|
266
|
+
const { queryProStatus, renderProStatus, probeBridge, startOwnBridge } = await import("../src/pro.js");
|
|
267
|
+
const port = flags.port ? Number(flags.port) : cfg.bridge.port;
|
|
268
|
+
const host = cfg.bridge.host;
|
|
269
|
+
const token = cfg.bridge.token;
|
|
270
|
+
const base = `http://${host}:${port}`;
|
|
271
|
+
console.log(bold("RoForge Pro") + dim(" — license status\n"));
|
|
272
|
+
// Attach to an already-running bridge (e.g. `roforge studio`);
|
|
273
|
+
// otherwise start a throwaway one for the plugin to connect to.
|
|
274
|
+
const health = await probeBridge(base);
|
|
275
|
+
if (health) {
|
|
276
|
+
const res = await queryProStatus(null, { port, token, baseUrl: base });
|
|
277
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const bridge = startOwnBridge({ port, host, token });
|
|
281
|
+
try {
|
|
282
|
+
await bridge.start();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error(red(`bridge could not start on port ${port}: ${e.message}`));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
// The plugin pings every ~1s; give it a moment to show up if it's up.
|
|
288
|
+
const deadline = Date.now() + 6000;
|
|
289
|
+
while (!bridge.connected && Date.now() < deadline) {
|
|
290
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
291
|
+
}
|
|
292
|
+
const res = await queryProStatus(bridge, { port, token });
|
|
293
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
294
|
+
bridge.stop();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
265
298
|
case "help":
|
|
266
299
|
case "--help":
|
|
267
300
|
case "-h": {
|
|
@@ -375,6 +408,7 @@ ${bold("Usage")}
|
|
|
375
408
|
roforge tools list all tools
|
|
376
409
|
roforge login --provider <p> store a key (gemini|groq|openrouter|anthropic|openai)
|
|
377
410
|
roforge providers list providers, keys, and auto-routing order
|
|
411
|
+
roforge pro show RoForge Pro license status (needs Studio bridge)
|
|
378
412
|
roforge analyze <file...> run the official Luau analyzer on files
|
|
379
413
|
roforge config [set k v] show / set configuration
|
|
380
414
|
roforge version
|
|
@@ -389,7 +423,7 @@ ${bold("Model providers (BYOK, zero backend)")}
|
|
|
389
423
|
auto (default): first configured key wins, free tiers first:
|
|
390
424
|
gemini → groq → openrouter → anthropic → openai
|
|
391
425
|
free tiers: Gemini 2.5 Flash (~1,500 req/day), Groq Llama 3.3 70B (~1,000 req/day),
|
|
392
|
-
OpenRouter ":free" models (e.g.
|
|
426
|
+
OpenRouter ":free" models (e.g. nvidia/nemotron-3-super-120b-a12b:free)
|
|
393
427
|
pin: --provider <p> or --model <provider>:<model> · ROFORGE_FREE_FIRST=0
|
|
394
428
|
|
|
395
429
|
${bold("Config & keys")}
|
package/package.json
CHANGED
package/src/bridge/server.js
CHANGED
|
@@ -10,11 +10,12 @@ import http from "node:http";
|
|
|
10
10
|
import { json } from "./wire.js";
|
|
11
11
|
|
|
12
12
|
export class BridgeServer {
|
|
13
|
-
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000 }) {
|
|
13
|
+
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000, completedTtlMs = 30000 }) {
|
|
14
14
|
this.port = port;
|
|
15
15
|
this.host = host;
|
|
16
16
|
this.token = token;
|
|
17
17
|
this.jobTimeoutMs = jobTimeoutMs;
|
|
18
|
+
this.completedTtlMs = completedTtlMs;
|
|
18
19
|
this.jobs = new Map(); // id → {id, tool, args, status, resolve, timer}
|
|
19
20
|
this.lastPingAt = null;
|
|
20
21
|
this.lastSeenAt = null;
|
|
@@ -45,7 +46,7 @@ export class BridgeServer {
|
|
|
45
46
|
stop() {
|
|
46
47
|
for (const job of this.jobs.values()) {
|
|
47
48
|
clearTimeout(job.timer);
|
|
48
|
-
job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
|
+
if (typeof job.resolve === "function") job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
50
|
}
|
|
50
51
|
this.jobs.clear();
|
|
51
52
|
if (this.server) {
|
|
@@ -114,20 +115,91 @@ export class BridgeServer {
|
|
|
114
115
|
}
|
|
115
116
|
clearTimeout(job.timer);
|
|
116
117
|
const out = body.error ? { ok: false, error: body.error } : { ok: true, result: body.result ?? "" };
|
|
117
|
-
|
|
118
|
-
job.
|
|
118
|
+
job.status = "done";
|
|
119
|
+
job.out = out;
|
|
120
|
+
if (typeof job.resolve === "function") job.resolve(out);
|
|
121
|
+
this._retire(job);
|
|
119
122
|
emit("job_done", { id: job.id, ...out });
|
|
120
123
|
json(res, 200, { ok: true });
|
|
121
124
|
});
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Enqueue a job from an authenticated client (used by `roforge pro`
|
|
129
|
+
// attaching to an already-running bridge). The plugin still discovers
|
|
130
|
+
// it via the normal GET /jobs claim path.
|
|
131
|
+
if (req.method === "POST" && path === "/v1/bridge/jobs/enqueue") {
|
|
132
|
+
let body = {};
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
req.on("data", (c) => {
|
|
136
|
+
size += c.length;
|
|
137
|
+
if (size < 1024 * 1024) chunks.push(c);
|
|
138
|
+
});
|
|
139
|
+
req.on("end", () => {
|
|
140
|
+
try {
|
|
141
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
142
|
+
} catch {
|
|
143
|
+
body = {};
|
|
144
|
+
}
|
|
145
|
+
if (typeof body.tool !== "string" || !body.tool) {
|
|
146
|
+
return json(res, 400, { error: "missing tool", code: "BAD_REQUEST" });
|
|
147
|
+
}
|
|
148
|
+
const id = this.enqueueRemote(body.tool, body.args ?? {});
|
|
149
|
+
json(res, 200, { ok: true, id });
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Poll a (possibly completed) job's status — read by an attaching client.
|
|
155
|
+
const gs = /^\/v1\/bridge\/jobs\/(\w+)$/.exec(path);
|
|
156
|
+
if (req.method === "GET" && gs) {
|
|
157
|
+
const st = this.jobStatus(gs[1]);
|
|
158
|
+
if (!st) return json(res, 404, { error: "unknown job", code: "JOB_NOT_FOUND" });
|
|
159
|
+
return json(res, 200, { ok: true, ...st });
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
return json(res, 404, { error: `no route: ${req.method} ${path}`, code: "NOT_FOUND" });
|
|
126
163
|
} catch (e) {
|
|
127
164
|
json(res, 500, { error: e.message, code: "INTERNAL" });
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
167
|
|
|
168
|
+
// Keep a finished job readable for a short window (so an attaching client
|
|
169
|
+
// can poll its result), then drop it. Unref'd: housekeeping must not keep
|
|
170
|
+
// the CLI process (or test runner) alive.
|
|
171
|
+
_retire(job) {
|
|
172
|
+
const t = setTimeout(() => {
|
|
173
|
+
if (this.jobs.get(job.id) === job) this.jobs.delete(job.id);
|
|
174
|
+
}, this.completedTtlMs);
|
|
175
|
+
if (typeof t.unref === "function") t.unref();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Enqueue a job on behalf of a remote (attaching) client. Returns the id to
|
|
179
|
+
// poll. The plugin claims it through the normal GET /jobs path.
|
|
180
|
+
enqueueRemote(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
|
181
|
+
const id = `job_${this.nextJobId++}`;
|
|
182
|
+
const job = { id, tool, args, status: "pending", remote: true, out: null, resolve: null, claimedAt: null, timer: null };
|
|
183
|
+
job.timer = setTimeout(() => {
|
|
184
|
+
if (this.jobs.get(id) === job && job.status !== "done") {
|
|
185
|
+
job.status = "timeout";
|
|
186
|
+
job.out = { ok: false, error: `studio job timed out after ${Math.round(timeoutMs / 1000)}s` };
|
|
187
|
+
this._retire(job);
|
|
188
|
+
}
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
this.jobs.set(id, job);
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Current status of a job for a poller: {id, status} while in flight, or
|
|
195
|
+
// {id, status:"done"|"timeout", ...out} once finished. null if unknown.
|
|
196
|
+
jobStatus(id) {
|
|
197
|
+
const job = this.jobs.get(id);
|
|
198
|
+
if (!job) return null;
|
|
199
|
+
if (job.status === "pending" || job.status === "claimed") return { id, status: job.status };
|
|
200
|
+
return { id, status: job.status, ...(job.out || {}) };
|
|
201
|
+
}
|
|
202
|
+
|
|
131
203
|
// Submit a tool job and wait for the plugin's result. Resolves to
|
|
132
204
|
// {ok:true, result:string} or {ok:false, error:string}.
|
|
133
205
|
submit(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
package/src/config.js
CHANGED
|
@@ -188,6 +188,7 @@ export function resolveConfig() {
|
|
|
188
188
|
if (process.env.ROFORGE_PROVIDER) cfg.provider = process.env.ROFORGE_PROVIDER;
|
|
189
189
|
if (process.env.ROFORGE_MCP_URL) cfg.mcpUrl = process.env.ROFORGE_MCP_URL;
|
|
190
190
|
if (process.env.ROFORGE_BRIDGE_PORT) cfg.bridge.port = Number(process.env.ROFORGE_BRIDGE_PORT);
|
|
191
|
+
if (process.env.ROFORGE_BRIDGE_TOKEN) cfg.bridge.token = process.env.ROFORGE_BRIDGE_TOKEN;
|
|
191
192
|
if (process.env.ROFORGE_STUDIO_MODE) cfg.studioMode = process.env.ROFORGE_STUDIO_MODE;
|
|
192
193
|
if (process.env.ROFORGE_MAX_ITERATIONS) cfg.maxIterations = Number(process.env.ROFORGE_MAX_ITERATIONS);
|
|
193
194
|
if (process.env.ROFORGE_FREE_FIRST === "0" || process.env.ROFORGE_FREE_FIRST === "false") cfg.freeFirst = false;
|
package/src/pro.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// RoForge Pro — CLI-side status plumbing.
|
|
2
|
+
//
|
|
3
|
+
// The entitlement itself is checked inside Studio by the bridge plugin
|
|
4
|
+
// (MarketplaceService only exists there). This module reaches the connected
|
|
5
|
+
// Studio and renders its `forge_pro` report for `roforge pro`.
|
|
6
|
+
//
|
|
7
|
+
// Two ways to reach Studio, tried in order:
|
|
8
|
+
// 1. ATTACH — if a bridge is already running on the configured port (e.g.
|
|
9
|
+
// `roforge studio` is up and the plugin is bound to it), enqueue the
|
|
10
|
+
// forge_pro job on that bridge and poll for the result. This is the
|
|
11
|
+
// normal case: you check Pro while your Studio session is live.
|
|
12
|
+
// 2. OWN — otherwise start a throwaway bridge on the configured port, wait
|
|
13
|
+
// for the plugin (bound to the same port + token) to connect, submit,
|
|
14
|
+
// and stop. This lets `roforge pro` work standalone too.
|
|
15
|
+
|
|
16
|
+
import { BridgeServer } from "./bridge/server.js";
|
|
17
|
+
|
|
18
|
+
const HEALTH_PATH = "/health";
|
|
19
|
+
|
|
20
|
+
// Probe for an already-running bridge on baseUrl. Returns the /health body
|
|
21
|
+
// ({ok, service, connected, port}) or null when nothing is listening.
|
|
22
|
+
export async function probeBridge(baseUrl, { timeoutMs = 1200 } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(`${baseUrl}${HEALTH_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
25
|
+
if (!r.ok) return null;
|
|
26
|
+
const j = await r.json();
|
|
27
|
+
return j && j.service === "roforge-bridge" ? j : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Enqueue `tool` on a running bridge and poll for the result. Resolves to
|
|
34
|
+
// {ok, result?/error}. `connected` lets the caller fail fast when the plugin
|
|
35
|
+
// isn't actually bound to this bridge.
|
|
36
|
+
export async function remoteSubmit(baseUrl, token, tool, args, { timeoutMs = 20000, pollMs = 150 } = {}) {
|
|
37
|
+
const auth = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
38
|
+
const enq = await fetch(`${baseUrl}/v1/bridge/jobs/enqueue`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: auth,
|
|
41
|
+
body: JSON.stringify({ tool, args }),
|
|
42
|
+
});
|
|
43
|
+
if (!enq.ok) {
|
|
44
|
+
let msg = `HTTP ${enq.status}`;
|
|
45
|
+
try { msg = (await enq.json()).error || msg; } catch { /* keep status */ }
|
|
46
|
+
return { ok: false, error: msg };
|
|
47
|
+
}
|
|
48
|
+
const { id } = await enq.json();
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const st = await (await fetch(`${baseUrl}/v1/bridge/jobs/${id}`, { headers: { authorization: `Bearer ${token}` } })).json();
|
|
52
|
+
if (st.status === "done") {
|
|
53
|
+
return st.error ? { ok: false, error: st.error } : { ok: true, result: st.result ?? "" };
|
|
54
|
+
}
|
|
55
|
+
if (st.status === "timeout") return { ok: false, error: st.error || "studio job timed out" };
|
|
56
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: "timed out waiting for Studio" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Query Pro status, attaching to a running bridge when present. Returns
|
|
62
|
+
// { text } on success, { error } otherwise. `bridge` may be null to force the
|
|
63
|
+
// "own bridge" path (used by tests).
|
|
64
|
+
export async function queryProStatus(bridge, { port, token, baseUrl, timeoutMs = 20000 } = {}) {
|
|
65
|
+
const base = baseUrl || `http://127.0.0.1:${port}`;
|
|
66
|
+
|
|
67
|
+
// Path 1: attach to a running bridge.
|
|
68
|
+
if (!bridge) {
|
|
69
|
+
const health = await probeBridge(base);
|
|
70
|
+
if (health) {
|
|
71
|
+
if (!health.connected) {
|
|
72
|
+
return {
|
|
73
|
+
error:
|
|
74
|
+
"A bridge is running but Studio is not connected. Open Roblox Studio with the " +
|
|
75
|
+
"RoForge Bridge plugin active and paste the bridge token into it.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const out = await remoteSubmit(base, token, "forge_pro", {}, { timeoutMs });
|
|
79
|
+
if (!out.ok) return { error: out.error };
|
|
80
|
+
return { text: String(out.result ?? "") };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Path 2: own bridge (already started by the caller).
|
|
85
|
+
if (!bridge || !bridge.connected) {
|
|
86
|
+
return {
|
|
87
|
+
error:
|
|
88
|
+
"No Studio bridge connection. Pro status is checked inside Studio (MarketplaceService), " +
|
|
89
|
+
"so this needs the RoForge Bridge plugin running and connected.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const out = await bridge.submit("forge_pro", {}, { timeoutMs });
|
|
93
|
+
if (!out.ok) return { error: out.error };
|
|
94
|
+
return { text: String(out.result ?? "") };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Start a throwaway bridge for the "own bridge" path. Returns the started
|
|
98
|
+
// BridgeServer or throws on EADDRINUSE.
|
|
99
|
+
export function startOwnBridge({ port, host = "127.0.0.1", token }) {
|
|
100
|
+
const bridge = new BridgeServer({ port, host, token });
|
|
101
|
+
return bridge;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Render the forge_pro report (plain text from the plugin) with minimal
|
|
105
|
+
// styling: the tier header gets color, the rest stays as-is.
|
|
106
|
+
export function renderProStatus(res, { bold, dim, red, green, magenta, yellow } = {}) {
|
|
107
|
+
const id = (fn, s) => (fn ? fn(s) : String(s));
|
|
108
|
+
if (res && res.error) {
|
|
109
|
+
return (
|
|
110
|
+
id(red, res.error) +
|
|
111
|
+
"\n" +
|
|
112
|
+
id(dim, " 1. open Roblox Studio with the RoForge Bridge plugin\n") +
|
|
113
|
+
id(dim, " 2. run `roforge studio` here and paste the token it prints into the plugin\n") +
|
|
114
|
+
id(dim, " 3. re-run `roforge pro` while both are running")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const lines = String(res?.text ?? "").split("\n");
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.startsWith("RoForge Pro: PRO")) out.push(id(green, line));
|
|
121
|
+
else if (line.startsWith("RoForge Pro: FREE")) out.push(id(yellow, line));
|
|
122
|
+
else if (line.startsWith("Unlock Pro:")) out.push(id(magenta, line));
|
|
123
|
+
else out.push(line);
|
|
124
|
+
}
|
|
125
|
+
return out.join("\n");
|
|
126
|
+
}
|
package/src/tools/studio.js
CHANGED
|
@@ -28,6 +28,7 @@ const BRIDGE_TOOL_NAMES = [
|
|
|
28
28
|
"forge_diff",
|
|
29
29
|
"forge_export",
|
|
30
30
|
"forge_import",
|
|
31
|
+
"forge_pro",
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
const BRIDGE_DESCRIPTIONS = {
|
|
@@ -53,8 +54,9 @@ const BRIDGE_DESCRIPTIONS = {
|
|
|
53
54
|
forge_bulk_create: "Create many instances in one call (paste-style): items[] of {path: parent path, class_name, name?, properties?}. Destructive.",
|
|
54
55
|
forge_snapshot: "Capture the current instance tree under a name, so forge_diff can show what changed later. Keeps the last 10.",
|
|
55
56
|
forge_diff: "Compare a snapshot to the current instance tree: added (+) and removed (-) instances. Omit name to diff the most recent snapshot.",
|
|
56
|
-
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6).",
|
|
57
|
-
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes.",
|
|
57
|
+
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6; 10 with RoForge Pro).",
|
|
58
|
+
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes (2500 with RoForge Pro).",
|
|
59
|
+
forge_pro: "Report the current RoForge Pro entitlement: Free or Pro, which pass/product the Studio user owns, and the active limits + Pro features. Call it to know whether the user has Pro.",
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
const BRIDGE_SCHEMAS = {
|
|
@@ -213,6 +215,7 @@ const BRIDGE_SCHEMAS = {
|
|
|
213
215
|
},
|
|
214
216
|
additionalProperties: false,
|
|
215
217
|
},
|
|
218
|
+
forge_pro: { type: "object", properties: {}, additionalProperties: false },
|
|
216
219
|
};
|
|
217
220
|
|
|
218
221
|
// Tools that modify the DataModel — gated by the approval prompt.
|