vansrouter 0.9.5 → 0.9.55

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 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.5",
4
- "description": "VansRouter CLI - Start and manage VansRouter server",
3
+ "version": "0.9.55",
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 Coding" },
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" },