vansrouter 0.9.5 → 0.9.56
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/cli.js +18 -0
- package/package.json +11 -3
- package/src/cli/commands/xaiVideo.js +300 -0
- package/src/cli/menus/providers.js +1 -1
package/cli.js
CHANGED
|
@@ -67,6 +67,19 @@ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRunt
|
|
|
67
67
|
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
|
|
68
68
|
const args = process.argv.slice(2);
|
|
69
69
|
|
|
70
|
+
// Subcommands (`9router xai video …`) run against an already-running gateway
|
|
71
|
+
// and bypass the launcher flow (no runtime self-heal, no server spawn).
|
|
72
|
+
if (args[0] === "xai" && args[1] === "video") {
|
|
73
|
+
const { run } = require("./src/cli/commands/xaiVideo");
|
|
74
|
+
run(args.slice(2))
|
|
75
|
+
.then((code) => process.exit(code))
|
|
76
|
+
.catch((err) => {
|
|
77
|
+
console.error(`❌ ${err?.message || err}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
|
|
71
84
|
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
|
|
72
85
|
// better-sqlite3 is optional. Logs to stderr only on failure.
|
|
@@ -139,6 +152,11 @@ Options:
|
|
|
139
152
|
--skip-update Skip auto-update check
|
|
140
153
|
-h, --help Show this help message
|
|
141
154
|
-v, --version Show version
|
|
155
|
+
|
|
156
|
+
Commands:
|
|
157
|
+
xai video --prompt "..." --output video.mp4
|
|
158
|
+
Generate a Grok Imagine video via the running gateway
|
|
159
|
+
(see: ${APP_NAME} xai video --help)
|
|
142
160
|
`);
|
|
143
161
|
process.exit(0);
|
|
144
162
|
} else if (args[i] === "--version" || args[i] === "-v") {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vansrouter",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.9.56",
|
|
4
|
+
"description": "9Router CLI - Start and manage 9Router server",
|
|
5
5
|
"repository": "https://github.com/Vanszs/VansRouter",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vansrouter": "./cli.js"
|
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
"README.md",
|
|
12
12
|
"LICENSE",
|
|
13
13
|
"hooks",
|
|
14
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"app"
|
|
15
16
|
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node scripts/build-cli.js",
|
|
19
|
+
"pack:cli": "npm run build && npm pack --pack-destination ../..",
|
|
20
|
+
"publish:cli": "npm run build && npm publish",
|
|
21
|
+
"postinstall": "node hooks/postinstall.js"
|
|
22
|
+
},
|
|
16
23
|
"dependencies": {
|
|
17
24
|
"chalk": "^4.1.2",
|
|
18
25
|
"commander": "^11.1.0",
|
|
@@ -20,6 +27,7 @@
|
|
|
20
27
|
"open": "^8.4.2",
|
|
21
28
|
"ws": "^8.16.0"
|
|
22
29
|
},
|
|
30
|
+
"comment_name": "DO NOT rename this package back to 9router. It must remain vansrouter to match the global updater configuration (see UPDATER_CONFIG in src/shared/constants/config.js). Changing this causes update loops and data mismatches.",
|
|
23
31
|
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
|
|
24
32
|
"comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.",
|
|
25
33
|
"keywords": [
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `9router xai video` — generate a Grok Imagine video through the local
|
|
3
|
+
* 9router gateway and save the result as an MP4 file.
|
|
4
|
+
*
|
|
5
|
+
* Flow: POST /v1/videos/generations → poll GET /v1/videos/{request_id}
|
|
6
|
+
* until done/failed/timeout → download video.url → atomic rename.
|
|
7
|
+
*
|
|
8
|
+
* No OAuth tokens or Authorization headers are ever printed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const http = require("http");
|
|
12
|
+
const https = require("https");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PORT = 20128;
|
|
17
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
18
|
+
const DEFAULT_MODEL = "xai/grok-imagine-video";
|
|
19
|
+
const DEFAULT_TIMEOUT_SEC = 600;
|
|
20
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
21
|
+
|
|
22
|
+
const TERMINAL_STATUSES = new Set(["done", "failed", "completed", "error", "expired", "cancelled"]);
|
|
23
|
+
const FAILED_STATUSES = new Set(["failed", "error", "expired", "cancelled"]);
|
|
24
|
+
|
|
25
|
+
const HELP = `
|
|
26
|
+
Usage: 9router xai video --prompt "..." [options]
|
|
27
|
+
|
|
28
|
+
Generate a Grok Imagine video via your local 9router gateway
|
|
29
|
+
(requires a connected xAI account — Grok Build OAuth or API key).
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--prompt <text> Video description (required)
|
|
33
|
+
--output <file> Output MP4 path (default: video.mp4)
|
|
34
|
+
--model <id> Model (default: ${DEFAULT_MODEL})
|
|
35
|
+
--duration <seconds> Video duration
|
|
36
|
+
--aspect-ratio <ratio> e.g. 16:9, 9:16, 1:1
|
|
37
|
+
--resolution <res> 480p | 720p | 1080p
|
|
38
|
+
--image <path-or-url> Image input for image-to-video
|
|
39
|
+
--timeout <seconds> Max wait for the job (default: ${DEFAULT_TIMEOUT_SEC})
|
|
40
|
+
--port <port> Gateway port (default: ${DEFAULT_PORT})
|
|
41
|
+
--host <host> Gateway host (default: ${DEFAULT_HOST})
|
|
42
|
+
--api-key <key> 9router API key (or env NINE_ROUTER_API_KEY)
|
|
43
|
+
-h, --help Show this help
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function sanitizeText(text) {
|
|
47
|
+
return String(text ?? "").replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const opts = {
|
|
52
|
+
model: DEFAULT_MODEL,
|
|
53
|
+
output: "video.mp4",
|
|
54
|
+
timeoutSec: DEFAULT_TIMEOUT_SEC,
|
|
55
|
+
port: DEFAULT_PORT,
|
|
56
|
+
host: DEFAULT_HOST,
|
|
57
|
+
apiKey: process.env.NINE_ROUTER_API_KEY || null,
|
|
58
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
59
|
+
};
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
const a = argv[i];
|
|
62
|
+
const next = () => argv[++i];
|
|
63
|
+
if (a === "--prompt") opts.prompt = next();
|
|
64
|
+
else if (a === "--output" || a === "-o") opts.output = next();
|
|
65
|
+
else if (a === "--model") opts.model = next();
|
|
66
|
+
else if (a === "--duration") opts.duration = parseInt(next(), 10);
|
|
67
|
+
else if (a === "--aspect-ratio") opts.aspectRatio = next();
|
|
68
|
+
else if (a === "--resolution") opts.resolution = next();
|
|
69
|
+
else if (a === "--image") opts.image = next();
|
|
70
|
+
else if (a === "--timeout") opts.timeoutSec = parseInt(next(), 10) || DEFAULT_TIMEOUT_SEC;
|
|
71
|
+
else if (a === "--port" || a === "-p") opts.port = parseInt(next(), 10) || DEFAULT_PORT;
|
|
72
|
+
else if (a === "--host" || a === "-H") opts.host = next() || DEFAULT_HOST;
|
|
73
|
+
else if (a === "--api-key") opts.apiKey = next();
|
|
74
|
+
else if (a === "--poll-interval-ms") opts.pollIntervalMs = parseInt(next(), 10) || DEFAULT_POLL_INTERVAL_MS;
|
|
75
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
76
|
+
else {
|
|
77
|
+
throw new Error(`Unknown option: ${a}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Local file path → base64 data URL; URLs pass through untouched. */
|
|
84
|
+
function imageInputToUrl(input) {
|
|
85
|
+
if (/^(https?:|data:)/i.test(input)) return input;
|
|
86
|
+
const buf = fs.readFileSync(input);
|
|
87
|
+
const ext = path.extname(input).toLowerCase();
|
|
88
|
+
const mime = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
|
|
89
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Minimal JSON request against the local gateway. Returns { status, headers, body }. */
|
|
93
|
+
function gatewayRequest({ host, port, apiKey, method, reqPath, body, signal }) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
96
|
+
const headers = { Accept: "application/json" };
|
|
97
|
+
if (payload) {
|
|
98
|
+
headers["Content-Type"] = "application/json";
|
|
99
|
+
headers["Content-Length"] = Buffer.byteLength(payload);
|
|
100
|
+
}
|
|
101
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
102
|
+
|
|
103
|
+
const req = http.request({ hostname: host, port, path: reqPath, method, headers, signal }, (res) => {
|
|
104
|
+
let data = "";
|
|
105
|
+
res.on("data", (c) => (data += c));
|
|
106
|
+
res.on("end", () => {
|
|
107
|
+
let parsed = null;
|
|
108
|
+
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
|
|
109
|
+
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: data });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
req.on("error", reject);
|
|
113
|
+
if (payload) req.write(payload);
|
|
114
|
+
req.end();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sleep = (ms, signal) =>
|
|
119
|
+
new Promise((resolve, reject) => {
|
|
120
|
+
const t = setTimeout(resolve, ms);
|
|
121
|
+
signal?.addEventListener?.("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Poll GET /v1/videos/{id} until a terminal status or deadline.
|
|
126
|
+
* @returns {Promise<object>} final poll body (status done) — throws on failed/timeout.
|
|
127
|
+
*/
|
|
128
|
+
async function pollUntilDone({ host, port, apiKey, requestId, connectionId, timeoutSec, pollIntervalMs, signal, onProgress }) {
|
|
129
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
130
|
+
while (true) {
|
|
131
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
132
|
+
if (Date.now() > deadline) {
|
|
133
|
+
throw new Error(`Timed out after ${timeoutSec}s waiting for video job ${requestId}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const res = await gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal });
|
|
137
|
+
if (res.status === 200 && res.body) {
|
|
138
|
+
const status = String(res.body.status || "").toLowerCase();
|
|
139
|
+
onProgress?.(status || "pending", res.body.progress);
|
|
140
|
+
if (FAILED_STATUSES.has(status)) {
|
|
141
|
+
const msg = res.body.error?.message || res.body.error || "video generation failed";
|
|
142
|
+
throw new Error(`Job ${requestId} failed: ${sanitizeText(typeof msg === "string" ? msg : JSON.stringify(msg))}`);
|
|
143
|
+
}
|
|
144
|
+
if (TERMINAL_STATUSES.has(status)) return res.body;
|
|
145
|
+
} else if (res.status >= 400 && res.status !== 429 && res.status !== 503) {
|
|
146
|
+
throw new Error(`Polling failed (HTTP ${res.status}): ${sanitizeText(res.raw?.slice(0, 300))}`);
|
|
147
|
+
}
|
|
148
|
+
await sleep(pollIntervalMs, signal);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal }) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const headers = { Accept: "application/json" };
|
|
155
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
156
|
+
if (connectionId) headers["x-connection-id"] = connectionId;
|
|
157
|
+
const req = http.request(
|
|
158
|
+
{ hostname: host, port, path: `/v1/videos/${encodeURIComponent(requestId)}`, method: "GET", headers, signal },
|
|
159
|
+
(res) => {
|
|
160
|
+
let data = "";
|
|
161
|
+
res.on("data", (c) => (data += c));
|
|
162
|
+
res.on("end", () => {
|
|
163
|
+
let parsed = null;
|
|
164
|
+
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
|
|
165
|
+
resolve({ status: res.statusCode, body: parsed, raw: data });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
req.on("error", reject);
|
|
170
|
+
req.end();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Download a URL to `outputPath` via a `.part` temp file with atomic rename.
|
|
176
|
+
* The temp file is removed on any failure.
|
|
177
|
+
*/
|
|
178
|
+
async function downloadToFile(url, outputPath, { signal } = {}) {
|
|
179
|
+
const partPath = `${outputPath}.part`;
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
181
|
+
const cleanupAnd = (fn) => (err) => {
|
|
182
|
+
try { fs.unlinkSync(partPath); } catch { /* not created yet */ }
|
|
183
|
+
fn(err);
|
|
184
|
+
};
|
|
185
|
+
const get = (target, redirectsLeft) => {
|
|
186
|
+
const mod = target.startsWith("https:") ? https : http;
|
|
187
|
+
const req = mod.get(target, { signal }, (res) => {
|
|
188
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
|
|
189
|
+
res.resume();
|
|
190
|
+
return get(new URL(res.headers.location, target).toString(), redirectsLeft - 1);
|
|
191
|
+
}
|
|
192
|
+
if (res.statusCode !== 200) {
|
|
193
|
+
res.resume();
|
|
194
|
+
return cleanupAnd(reject)(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
195
|
+
}
|
|
196
|
+
const out = fs.createWriteStream(partPath);
|
|
197
|
+
res.pipe(out);
|
|
198
|
+
out.on("finish", () => out.close(resolve));
|
|
199
|
+
out.on("error", cleanupAnd(reject));
|
|
200
|
+
res.on("error", cleanupAnd(reject));
|
|
201
|
+
});
|
|
202
|
+
req.on("error", cleanupAnd(reject));
|
|
203
|
+
};
|
|
204
|
+
get(url, 5);
|
|
205
|
+
});
|
|
206
|
+
fs.renameSync(partPath, outputPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function run(argv) {
|
|
210
|
+
let opts;
|
|
211
|
+
try {
|
|
212
|
+
opts = parseArgs(argv);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error(`❌ ${err.message}`);
|
|
215
|
+
console.log(HELP);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
if (opts.help) {
|
|
219
|
+
console.log(HELP);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (!opts.prompt) {
|
|
223
|
+
console.error("❌ --prompt is required");
|
|
224
|
+
console.log(HELP);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
const partPath = `${opts.output}.part`;
|
|
230
|
+
const onSigint = () => {
|
|
231
|
+
controller.abort();
|
|
232
|
+
try { fs.unlinkSync(partPath); } catch { /* absent */ }
|
|
233
|
+
console.error("\n✋ Cancelled");
|
|
234
|
+
process.exit(130);
|
|
235
|
+
};
|
|
236
|
+
process.on("SIGINT", onSigint);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const body = { model: opts.model, prompt: opts.prompt };
|
|
240
|
+
if (opts.duration) body.duration = opts.duration;
|
|
241
|
+
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
|
242
|
+
if (opts.resolution) body.resolution = opts.resolution;
|
|
243
|
+
if (opts.image) body.image = { url: imageInputToUrl(opts.image) };
|
|
244
|
+
|
|
245
|
+
console.log(`🎬 Requesting video (${opts.model})…`);
|
|
246
|
+
const create = await gatewayRequest({
|
|
247
|
+
host: opts.host, port: opts.port, apiKey: opts.apiKey,
|
|
248
|
+
method: "POST", reqPath: "/v1/videos/generations", body, signal: controller.signal,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (create.status !== 200 || !create.body?.request_id) {
|
|
252
|
+
const detail = create.body?.error?.message || create.body?.error || create.raw || `HTTP ${create.status}`;
|
|
253
|
+
console.error(`❌ Create failed: ${sanitizeText(typeof detail === "string" ? detail : JSON.stringify(detail)).slice(0, 500)}`);
|
|
254
|
+
if (create.status === 400 && /No credentials/i.test(String(detail))) {
|
|
255
|
+
console.error(" Connect an xAI account first: dashboard → Providers → xAI (Grok).");
|
|
256
|
+
}
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const requestId = create.body.request_id;
|
|
261
|
+
const connectionId = create.headers["x-9router-connection-id"] || null;
|
|
262
|
+
console.log(`📋 Job accepted: ${requestId}`);
|
|
263
|
+
|
|
264
|
+
let lastLine = "";
|
|
265
|
+
const result = await pollUntilDone({
|
|
266
|
+
host: opts.host, port: opts.port, apiKey: opts.apiKey,
|
|
267
|
+
requestId, connectionId,
|
|
268
|
+
timeoutSec: opts.timeoutSec, pollIntervalMs: opts.pollIntervalMs,
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
onProgress: (status, progress) => {
|
|
271
|
+
const line = `⏳ ${status}${Number.isFinite(progress) ? ` ${progress}%` : ""}`;
|
|
272
|
+
if (line !== lastLine) {
|
|
273
|
+
lastLine = line;
|
|
274
|
+
if (process.stdout.isTTY) process.stdout.write(`\r\x1b[K${line}`);
|
|
275
|
+
else console.log(line);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
280
|
+
|
|
281
|
+
const videoUrl = result.video?.url || result.video?.file_output?.public_url;
|
|
282
|
+
if (!videoUrl) {
|
|
283
|
+
console.error("❌ Job finished but no video URL was returned");
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log("⬇️ Downloading…");
|
|
288
|
+
await downloadToFile(videoUrl, opts.output, { signal: controller.signal });
|
|
289
|
+
console.log(`✅ Saved ${opts.output}`);
|
|
290
|
+
return 0;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
293
|
+
console.error(`❌ ${sanitizeText(err?.message || String(err))}`);
|
|
294
|
+
return 1;
|
|
295
|
+
} finally {
|
|
296
|
+
process.removeListener("SIGINT", onSigint);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { run, parseArgs, pollUntilDone, downloadToFile, imageInputToUrl, sanitizeText };
|
|
@@ -131,7 +131,7 @@ const APIKEY_PROVIDERS = {
|
|
|
131
131
|
openrouter: { id: "openrouter", name: "OpenRouter" },
|
|
132
132
|
glm: { id: "glm", name: "GLM Coding" },
|
|
133
133
|
minimax: { id: "minimax", name: "Minimax Coding" },
|
|
134
|
-
kimi: { id: "kimi", name: "Kimi
|
|
134
|
+
kimi: { id: "kimi", name: "Kimi" },
|
|
135
135
|
openai: { id: "openai", name: "OpenAI" },
|
|
136
136
|
anthropic: { id: "anthropic", name: "Anthropic" },
|
|
137
137
|
gemini: { id: "gemini", name: "Gemini" },
|