codexpanel 0.1.0 → 0.1.2

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.
@@ -4,12 +4,19 @@
4
4
  const fs = require("fs");
5
5
  const http = require("http");
6
6
  const https = require("https");
7
+ const net = require("net");
7
8
  const os = require("os");
8
9
  const path = require("path");
9
- const { spawnSync } = require("child_process");
10
+ const crypto = require("crypto");
11
+ const readline = require("readline");
12
+ const { spawn, spawnSync } = require("child_process");
10
13
 
11
- const VERSION = "0.1.0";
12
- const DEFAULT_RELAY_URL = "https://jd.6a.gs";
14
+ const VERSION = "0.1.2";
15
+ const PROD_URL = "https://codexpanel.com";
16
+ const TEST_URL = "https://jd.6a.gs";
17
+ const LOCAL_HOST = "127.0.0.1";
18
+ const LOCAL_PORT_START = 0;
19
+ const DEFAULT_PANEL_PORT_START = 4971;
13
20
 
14
21
  function usage() {
15
22
  return `
@@ -17,23 +24,28 @@ CodexPanel desktop agent installer
17
24
 
18
25
  Usage:
19
26
  npx -y codexpanel [install] [options]
20
- npx -y codexpanel --relay http://127.0.0.1:4871
27
+ npx -y codexpanel login [options]
28
+ npx -y codexpanel status [options]
21
29
 
22
30
  Options:
23
- --relay, --url <url> Relay URL. Defaults to CODEXPANEL_RELAY_URL or ${DEFAULT_RELAY_URL}
24
- --user, --user-id <id> CodexPanel user id. Defaults to CODEXPANEL_USER_ID or demo_user_a
25
- --device, --device-id <id> Stable device id. Defaults to the relay bootstrap script's generated id
26
- --name, --device-name <name> Device display name
31
+ --server <name|url> production/prod, test/jd, local, or a relay URL. Defaults to production
27
32
  --workspace, --cwd <path> Default workspace for Codex Desktop/app-server
28
- --access-code <code> Access code for protected relay deployments
29
- --mode <mode> Agent mode. Defaults to desktop-agent
30
- --app-server-listen <url> Local Codex app-server WebSocket URL
33
+ --device-name, --name <name> Device display name shown in CodexPanel
34
+ --terminal-login Ask for username and password in this terminal
35
+ --token-login Ask for a one-time terminal token from the setup page
36
+ --no-browser Do not open a browser; print the login URL instead
31
37
  --no-autostart Do not enable Windows login autostart
32
38
  --no-tunnel Disable Cloudflare WSS direct tunnel
33
- --print-command Print the equivalent PowerShell bootstrap command
34
- --dry-run Print resolved installer details without running PowerShell
39
+ --dry-run Print resolved installer details without installing
40
+ --print-command Print the equivalent PowerShell bootstrap command after login approval
35
41
  -h, --help Show help
36
42
  -v, --version Show version
43
+
44
+ Examples:
45
+ npx -y codexpanel
46
+ npx -y codexpanel --server test
47
+ npx -y codexpanel --server local
48
+ npx -y codexpanel --server https://example.com
37
49
  `.trim();
38
50
  }
39
51
 
@@ -41,19 +53,16 @@ function parseArgs(argv) {
41
53
  const args = [...argv];
42
54
  const options = {
43
55
  command: "install",
44
- relayUrl: process.env.CODEXPANEL_RELAY_URL || process.env.BRIDGECODEX_RELAY_URL || DEFAULT_RELAY_URL,
45
- userId: process.env.CODEXPANEL_USER_ID || process.env.BRIDGECODEX_USER_ID || "demo_user_a",
46
- deviceId: process.env.CODEXPANEL_DEVICE_ID || process.env.BRIDGECODEX_DEVICE_ID || "",
47
- deviceName: process.env.CODEXPANEL_DEVICE_NAME || process.env.BRIDGECODEX_DEVICE_NAME || "",
48
- workspace: process.env.CODEXPANEL_WORKSPACE || process.env.BRIDGECODEX_WORKSPACE || "",
49
- accessCode: process.env.CODEXPANEL_ACCESS_CODE || process.env.BRIDGECODEX_ACCESS_CODE || "",
50
- mode: process.env.CODEXPANEL_AGENT_MODE || process.env.BRIDGECODEX_AGENT_MODE || "desktop-agent",
51
- appServerListen: process.env.CODEX_APP_SERVER_LISTEN || "",
56
+ server: process.env.CODEXPANEL_SERVER || "production",
57
+ workspace: process.env.CODEXPANEL_WORKSPACE || "",
58
+ deviceName: process.env.CODEXPANEL_DEVICE_NAME || "",
59
+ terminalLogin: false,
60
+ tokenLogin: false,
61
+ browser: true,
52
62
  autoStart: true,
53
63
  tunnelEnabled: true,
54
64
  dryRun: false,
55
65
  printCommand: false,
56
- keepScript: false,
57
66
  };
58
67
 
59
68
  const readValue = (flag) => {
@@ -65,115 +74,169 @@ function parseArgs(argv) {
65
74
  while (args.length) {
66
75
  const arg = args.shift();
67
76
  if (!arg) continue;
68
- if (arg === "install" || arg === "setup") {
69
- options.command = "install";
70
- } else if (arg === "help") {
71
- options.help = true;
72
- } else if (arg === "version") {
73
- options.version = true;
74
- } else if (arg === "-h" || arg === "--help") {
75
- options.help = true;
76
- } else if (arg === "-v" || arg === "--version") {
77
- options.version = true;
78
- } else if (arg === "--relay" || arg === "--url") {
79
- options.relayUrl = readValue(arg);
80
- } else if (arg === "--user" || arg === "--user-id") {
81
- options.userId = readValue(arg);
82
- } else if (arg === "--device" || arg === "--device-id") {
83
- options.deviceId = readValue(arg);
84
- } else if (arg === "--name" || arg === "--device-name") {
85
- options.deviceName = readValue(arg);
86
- } else if (arg === "--workspace" || arg === "--cwd") {
87
- options.workspace = readValue(arg);
88
- } else if (arg === "--access-code") {
89
- options.accessCode = readValue(arg);
90
- } else if (arg === "--mode") {
91
- options.mode = readValue(arg);
92
- } else if (arg === "--app-server-listen") {
93
- options.appServerListen = readValue(arg);
94
- } else if (arg === "--no-autostart") {
95
- options.autoStart = false;
96
- } else if (arg === "--no-tunnel") {
97
- options.tunnelEnabled = false;
98
- } else if (arg === "--print-command") {
99
- options.printCommand = true;
100
- } else if (arg === "--dry-run") {
101
- options.dryRun = true;
102
- } else if (arg === "--keep-script") {
103
- options.keepScript = true;
104
- } else {
77
+ if (["install", "setup", "login", "status"].includes(arg)) options.command = arg === "setup" ? "install" : arg;
78
+ else if (arg === "help" || arg === "-h" || arg === "--help") options.help = true;
79
+ else if (arg === "version" || arg === "-v" || arg === "--version") options.version = true;
80
+ else if (arg === "--server") options.server = readValue(arg);
81
+ else if (arg === "--workspace" || arg === "--cwd") options.workspace = readValue(arg);
82
+ else if (arg === "--device-name" || arg === "--name") options.deviceName = readValue(arg);
83
+ else if (arg === "--terminal-login") options.terminalLogin = true;
84
+ else if (arg === "--token-login") options.tokenLogin = true;
85
+ else if (arg === "--no-browser") options.browser = false;
86
+ else if (arg === "--no-autostart") options.autoStart = false;
87
+ else if (arg === "--no-tunnel") options.tunnelEnabled = false;
88
+ else if (arg === "--dry-run") options.dryRun = true;
89
+ else if (arg === "--print-command") options.printCommand = true; else {
105
90
  throw new Error(`Unknown option: ${arg}`);
106
91
  }
107
92
  }
108
-
93
+ if (options.terminalLogin && options.tokenLogin) throw new Error("Choose only one of --terminal-login or --token-login.");
109
94
  return options;
110
95
  }
111
96
 
112
- function normalizeRelayUrl(value) {
97
+ function normalizeUrl(value) {
113
98
  const raw = String(value || "").trim().replace(/\/+$/, "");
114
- if (!raw) throw new Error("Relay URL is empty");
99
+ if (!raw) throw new Error("Server URL is empty.");
115
100
  const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
116
101
  const url = new URL(withProtocol);
117
- if (url.protocol !== "https:" && url.protocol !== "http:") {
118
- throw new Error(`Relay URL must use http or https: ${withProtocol}`);
119
- }
102
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error(`Server must use http or https: ${withProtocol}`);
120
103
  return url.toString().replace(/\/+$/, "");
121
104
  }
122
105
 
123
- function bootstrapUrl(options) {
124
- const url = new URL("/agent/bootstrap.ps1", normalizeRelayUrl(options.relayUrl));
125
- url.searchParams.set("userId", options.userId);
126
- if (options.deviceId) url.searchParams.set("deviceId", options.deviceId);
127
- if (options.deviceName) url.searchParams.set("deviceName", options.deviceName);
128
- if (options.workspace) url.searchParams.set("workspace", options.workspace);
129
- if (options.accessCode) url.searchParams.set("accessCode", options.accessCode);
130
- if (options.mode) url.searchParams.set("mode", options.mode);
131
- if (options.appServerListen) url.searchParams.set("appServerListen", options.appServerListen);
132
- url.searchParams.set("autoStart", options.autoStart ? "1" : "0");
133
- url.searchParams.set("tunnelEnabled", options.tunnelEnabled ? "1" : "0");
134
- return url;
106
+ async function resolveServer(server) {
107
+ const value = String(server || "production").trim();
108
+ const key = value.toLowerCase();
109
+ if (["", "prod", "production", "codexpanel", "codexpanel.com"].includes(key)) {
110
+ return { label: "production", relayUrl: PROD_URL, localPort: null };
111
+ }
112
+ if (["test", "jd", "jd.6a.gs"].includes(key)) return { label: "test", relayUrl: TEST_URL, localPort: null };
113
+ if (key === "local") {
114
+ const port = await findFreePort(LOCAL_PORT_START);
115
+ return { label: "local", relayUrl: `http://${LOCAL_HOST}:${port}`, localPort: port };
116
+ }
117
+ return { label: "custom", relayUrl: normalizeUrl(value), localPort: null };
135
118
  }
136
119
 
137
- function downloadText(url, redirects = 0) {
138
- if (redirects > 5) return Promise.reject(new Error(`Too many redirects for ${url}`));
120
+ function findFreePort(start) {
121
+ return new Promise((resolve, reject) => {
122
+ let port = Number(start) || 0;
123
+ let attempts = 0;
124
+ const tryPort = () => {
125
+ attempts += 1;
126
+ if (attempts > 200) {
127
+ reject(new Error(`No available local port found after ${attempts - 1} attempts.`));
128
+ return;
129
+ }
130
+ const server = net.createServer();
131
+ server.once("error", () => {
132
+ port += 1;
133
+ tryPort();
134
+ });
135
+ server.listen(port, LOCAL_HOST, () => {
136
+ const actual = server.address().port;
137
+ server.close(() => resolve(actual));
138
+ });
139
+ };
140
+ tryPort();
141
+ });
142
+ }
143
+
144
+ function defaultUsername() {
145
+ try { return os.userInfo().username || process.env.USERNAME || process.env.USER || "unknown-user"; }
146
+ catch { return process.env.USERNAME || process.env.USER || "unknown-user"; }
147
+ }
148
+
149
+ function stableDeviceId() {
150
+ const seed = [os.hostname(), process.env.USERDOMAIN || "", defaultUsername(), process.env.USERPROFILE || process.env.HOME || ""].join("|").toLowerCase();
151
+ return `bcx-${crypto.createHash("sha256").update(seed).digest("hex").slice(0, 16)}`;
152
+ }
153
+
154
+ function computerProfile(options, resolvedServer) {
155
+ return {
156
+ cliVersion: VERSION,
157
+ serverLabel: resolvedServer.label,
158
+ platform: process.platform,
159
+ arch: process.arch,
160
+ host: os.hostname(),
161
+ computerUser: defaultUsername(),
162
+ userDomain: process.env.USERDOMAIN || "",
163
+ deviceId: stableDeviceId(),
164
+ deviceName: options.deviceName || `${os.hostname()} / ${defaultUsername()} Codex Desktop Agent`,
165
+ workspace: options.workspace || process.env.USERPROFILE || process.env.HOME || process.cwd(),
166
+ autoStart: options.autoStart,
167
+ tunnelEnabled: options.tunnelEnabled,
168
+ panelPortHint: DEFAULT_PANEL_PORT_START,
169
+ };
170
+ }
171
+
172
+ function requestJson(method, relayUrl, pathname, body, headers = {}, timeoutMs = 30000) {
173
+ const url = new URL(pathname, relayUrl);
174
+ const text = body === undefined ? "" : JSON.stringify(body);
139
175
  const client = url.protocol === "https:" ? https : http;
140
176
  return new Promise((resolve, reject) => {
141
- const req = client.get(url, {
177
+ const req = client.request(url, {
178
+ method,
179
+ timeout: timeoutMs,
142
180
  headers: {
143
181
  "user-agent": `codexpanel-npm/${VERSION}`,
144
- "accept": "text/plain, */*",
182
+ "accept": "application/json",
183
+ ...(text ? { "content-type": "application/json", "content-length": Buffer.byteLength(text) } : {}),
184
+ ...headers,
145
185
  },
146
- timeout: 30000,
186
+ }, (res) => {
187
+ let data = "";
188
+ res.setEncoding("utf8");
189
+ res.on("data", (chunk) => data += chunk);
190
+ res.on("end", () => {
191
+ let parsed = {};
192
+ try { parsed = data ? JSON.parse(data) : {}; }
193
+ catch { parsed = { raw: data }; }
194
+ if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300 || parsed.ok === false) {
195
+ const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
196
+ error.statusCode = res.statusCode;
197
+ error.data = parsed;
198
+ reject(error);
199
+ return;
200
+ }
201
+ resolve(parsed);
202
+ });
203
+ });
204
+ req.on("timeout", () => req.destroy(new Error(`Timeout calling ${url}`)));
205
+ req.on("error", reject);
206
+ if (text) req.write(text);
207
+ req.end();
208
+ });
209
+ }
210
+
211
+ function downloadText(url, timeoutMs = 30000) {
212
+ const parsed = new URL(url);
213
+ const client = parsed.protocol === "https:" ? https : http;
214
+ return new Promise((resolve, reject) => {
215
+ const req = client.get(parsed, {
216
+ headers: { "user-agent": `codexpanel-npm/${VERSION}`, "accept": "text/plain, application/json, */*" },
217
+ timeout: timeoutMs,
147
218
  }, (res) => {
148
219
  if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && res.headers.location) {
149
220
  res.resume();
150
- const next = new URL(res.headers.location, url);
151
- downloadText(next, redirects + 1).then(resolve, reject);
221
+ downloadText(new URL(res.headers.location, parsed).toString(), timeoutMs).then(resolve, reject);
152
222
  return;
153
223
  }
154
- let body = "";
224
+ let data = "";
155
225
  res.setEncoding("utf8");
156
- res.on("data", (chunk) => body += chunk);
226
+ res.on("data", (chunk) => data += chunk);
157
227
  res.on("end", () => {
158
- if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
159
- const authHint = res.statusCode === 401 || res.statusCode === 403
160
- ? " The relay requires an access code; re-run with --access-code <code> or set CODEXPANEL_ACCESS_CODE."
161
- : "";
162
- reject(new Error(`HTTP ${res.statusCode} while downloading ${url}.${authHint}`));
163
- return;
164
- }
165
- resolve(body.replace(/^\uFEFF/, ""));
228
+ if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) reject(new Error(`HTTP ${res.statusCode} while downloading ${parsed}`));
229
+ else resolve(data.replace(/^\uFEFF/, ""));
166
230
  });
167
231
  });
168
- req.on("timeout", () => req.destroy(new Error(`Timeout downloading ${url}`)));
232
+ req.on("timeout", () => req.destroy(new Error(`Timeout downloading ${parsed}`)));
169
233
  req.on("error", reject);
170
234
  });
171
235
  }
172
236
 
173
237
  function powershellPath() {
174
238
  if (process.platform !== "win32") return "pwsh";
175
- const candidates = ["pwsh.exe", "powershell.exe"];
176
- for (const candidate of candidates) {
239
+ for (const candidate of ["pwsh.exe", "powershell.exe"]) {
177
240
  const result = spawnSync("where.exe", [candidate], { encoding: "utf8", windowsHide: true });
178
241
  const first = (result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
179
242
  if (first) return first;
@@ -181,79 +244,306 @@ function powershellPath() {
181
244
  return "powershell.exe";
182
245
  }
183
246
 
247
+ function openBrowser(url) {
248
+ const target = String(url);
249
+ if (process.platform === "win32") return spawn(powershellPath(), [
250
+ "-NoProfile",
251
+ "-ExecutionPolicy",
252
+ "Bypass",
253
+ "-Command",
254
+ "Start-Process -FilePath $args[0]",
255
+ target,
256
+ ], { detached: true, stdio: "ignore", windowsHide: true }).unref();
257
+ if (process.platform === "darwin") return spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
258
+ return spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
259
+ }
260
+
261
+ function localAgentRoot() {
262
+ if (process.platform === "win32" && process.env.LOCALAPPDATA) return path.join(process.env.LOCALAPPDATA, "CodexPanelAgent");
263
+ return path.join(os.homedir() || process.cwd(), ".codexpanel-agent");
264
+ }
265
+
266
+ function readLocalRuntimeConfig() {
267
+ const root = localAgentRoot();
268
+ const configPath = path.join(root, "agent-runtime.json");
269
+ try {
270
+ const raw = fs.readFileSync(configPath, "utf8").replace(/^\uFEFF/, "");
271
+ return { root, configPath, config: JSON.parse(raw) };
272
+ } catch (error) {
273
+ error.configPath = configPath;
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ function localPanelUrl(config = {}) {
279
+ const panelBase = config.panelUrl || (config.panelPort ? `http://${config.panelHost || "127.0.0.1"}:${config.panelPort}/` : "");
280
+ if (!panelBase) return "";
281
+ const url = new URL(panelBase);
282
+ if (config.panelToken) url.searchParams.set("token", String(config.panelToken));
283
+ return url.toString();
284
+ }
285
+
286
+ function createInterface() {
287
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
288
+ }
289
+
290
+ function question(rl, text, { silent = false } = {}) {
291
+ if (!silent) return new Promise((resolve) => rl.question(text, resolve));
292
+ const input = process.stdin;
293
+ const output = process.stdout;
294
+ return new Promise((resolve) => {
295
+ const onData = (char) => {
296
+ char = String(char);
297
+ switch (char) {
298
+ case "\n": case "\r": case "\u0004":
299
+ input.setRawMode?.(false);
300
+ input.pause();
301
+ output.write("\n");
302
+ input.removeListener("data", onData);
303
+ resolve(buffer);
304
+ break;
305
+ case "\u0003":
306
+ process.exit(130);
307
+ break;
308
+ default:
309
+ buffer += char;
310
+ break;
311
+ }
312
+ };
313
+ let buffer = "";
314
+ output.write(text);
315
+ input.resume();
316
+ input.setRawMode?.(true);
317
+ input.on("data", onData);
318
+ });
319
+ }
320
+
321
+ async function preloadResources(relayUrl) {
322
+ const manifestUrl = new URL("/agent/version", relayUrl).toString();
323
+ console.log(`CodexPanel: 正在读取资源清单 / Reading resource manifest: ${manifestUrl}`);
324
+ const manifestText = await downloadText(manifestUrl, 30000);
325
+ let manifest = {};
326
+ try { manifest = JSON.parse(manifestText); }
327
+ catch { throw new Error("Server returned an invalid agent manifest."); }
328
+ const resources = [manifest.desktopAgent, manifest.connector, manifest.bootstrap].filter(Boolean);
329
+ for (const item of resources) {
330
+ const resourceUrl = new URL(item.path, relayUrl).toString();
331
+ console.log(`CodexPanel: 预下载资源 / Downloading ${item.fileName || item.path}`);
332
+ const text = await downloadText(resourceUrl, 60000);
333
+ const hash = crypto.createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
334
+ if (item.sha256 && hash !== item.sha256) throw new Error(`Checksum mismatch for ${item.fileName || item.path}`);
335
+ }
336
+ console.log("CodexPanel: 资源已下载并校验完成 / Resources downloaded and verified.");
337
+ return manifest;
338
+ }
339
+
184
340
  function powershellCommand(url) {
185
341
  const escaped = String(url).replace(/'/g, "''");
186
342
  return `powershell -NoProfile -ExecutionPolicy Bypass -Command "irm '${escaped}' | iex"`;
187
343
  }
188
344
 
189
- async function install(options) {
190
- const url = bootstrapUrl(options);
345
+ async function pollSetup(relayUrl, flowId, flowSecret) {
346
+ const started = Date.now();
347
+ let transientFailures = 0;
348
+ while (Date.now() - started < 10 * 60 * 1000) {
349
+ try {
350
+ const result = await requestJson("POST", relayUrl, "/api/desktop/setup/poll", { flowId, flowSecret }, {}, 30000);
351
+ transientFailures = 0;
352
+ if (result.status === "approved") return result;
353
+ if (result.status === "rejected") throw new Error(result.error || "Setup was rejected in the browser.");
354
+ if (result.status === "expired") throw new Error("Setup login expired. Run npx -y codexpanel again.");
355
+ const hint = result.message || "等待浏览器登录绑定 / Waiting for browser sign-in";
356
+ process.stdout.write(`\rCodexPanel: ${hint}... `);
357
+ } catch (error) {
358
+ if (![429, 502, 503, 504].includes(Number(error.statusCode))) throw error;
359
+ transientFailures += 1;
360
+ const detail = error.data?.error || error.data?.raw || error.message || `HTTP ${error.statusCode}`;
361
+ process.stdout.write(`\rCodexPanel: 服务端暂时不可用,正在重试 / Server temporarily unavailable, retrying (${error.statusCode}, ${transientFailures}): ${detail} `);
362
+ }
363
+ await new Promise((resolve) => setTimeout(resolve, 2000));
364
+ }
365
+ throw new Error("Setup login timed out after 10 minutes.");
366
+ }
367
+
368
+ async function terminalLogin(relayUrl, flowId, flowSecret) {
369
+ const rl = createInterface();
370
+ try {
371
+ const username = (await question(rl, "CodexPanel username: ")).trim();
372
+ const password = await question(rl, "CodexPanel password: ", { silent: true });
373
+ try {
374
+ return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, username, password }, {}, 30000);
375
+ } catch (error) {
376
+ if (error.data?.code !== "device_rebind_required") throw error;
377
+ console.log("这台电脑已绑定到其他账号,不会自动切换绑定。");
378
+ const answer = (await question(rl, "Type YES to rebind this computer to the current account / 输入 YES 切换绑定: ")).trim();
379
+ if (answer !== "YES") throw new Error("Setup kept the existing binding. Run npx -y codexpanel again if you want to switch later.");
380
+ return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, username, password, confirmRebind: true }, {}, 30000);
381
+ }
382
+ } finally {
383
+ rl.close();
384
+ }
385
+ }
386
+
387
+ async function tokenLogin(relayUrl, flowId, flowSecret) {
388
+ const rl = createInterface();
389
+ try {
390
+ const token = (await question(rl, "One-time CodexPanel terminal token from setup page: ")).trim();
391
+ try {
392
+ return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, token }, {}, 30000);
393
+ } catch (error) {
394
+ if (error.data?.code !== "device_rebind_required") throw error;
395
+ console.log("这台电脑已绑定到其他账号,不会自动切换绑定。");
396
+ const answer = (await question(rl, "Type YES to rebind this computer to the current account / 输入 YES 切换绑定: ")).trim();
397
+ if (answer !== "YES") throw new Error("Setup kept the existing binding. Run npx -y codexpanel again if you want to switch later.");
398
+ return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, token, confirmRebind: true }, {}, 30000);
399
+ }
400
+ } finally {
401
+ rl.close();
402
+ }
403
+ }
404
+
405
+ async function runBootstrap(relayUrl, approval, options) {
406
+ const url = new URL("/agent/bootstrap.ps1", relayUrl);
407
+ url.searchParams.set("setupToken", approval.setupToken);
408
+ url.searchParams.set("manualDeviceId", "1");
409
+ url.searchParams.set("manualDeviceName", "1");
410
+ url.searchParams.set("deviceId", approval.deviceId || stableDeviceId());
411
+ if (approval.deviceName) url.searchParams.set("deviceName", approval.deviceName);
412
+ if (options.workspace) url.searchParams.set("workspace", options.workspace);
413
+ url.searchParams.set("mode", "desktop-agent");
414
+ url.searchParams.set("autoStart", options.autoStart ? "1" : "0");
415
+ url.searchParams.set("tunnelEnabled", options.tunnelEnabled ? "1" : "0");
416
+
191
417
  if (options.printCommand) console.log(powershellCommand(url));
418
+ if (process.platform !== "win32") throw new Error("CodexPanel desktop agent installation currently supports Windows. Use the login URL on Windows to bind this computer.");
419
+
420
+ console.log("\nCodexPanel: 正在下载最终安装脚本 / Downloading final bootstrap script...");
421
+ const script = await downloadText(url.toString(), 30000);
422
+ if (!/CodexPanel/i.test(script) || !/desktop-agent/i.test(script)) throw new Error("Downloaded bootstrap script did not look like the CodexPanel Windows installer.");
423
+ const tmp = path.join(os.tmpdir(), `codexpanel-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}.ps1`);
424
+ fs.writeFileSync(tmp, Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(script, "utf8")]));
425
+ console.log("CodexPanel: 正在启动 Windows 安装器 / Starting Windows installer...");
426
+ const result = spawnSync(powershellPath(), ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp], { stdio: "inherit", windowsHide: false });
427
+ try { fs.unlinkSync(tmp); } catch {}
428
+ if (result.error) throw result.error;
429
+ if (result.status !== 0) throw new Error(`PowerShell installer exited with code ${result.status}`);
430
+ }
431
+
432
+ async function install(options) {
433
+ const resolved = await resolveServer(options.server);
434
+ const profile = computerProfile(options, resolved);
192
435
  if (options.dryRun) {
193
436
  console.log(JSON.stringify({
194
437
  ok: true,
195
- platform: process.platform,
196
- relayUrl: normalizeRelayUrl(options.relayUrl),
197
- bootstrapUrl: String(url),
198
- userId: options.userId,
199
- mode: options.mode,
438
+ version: VERSION,
439
+ command: options.command,
440
+ server: resolved.label,
441
+ relayUrl: resolved.relayUrl,
442
+ localPort: resolved.localPort,
443
+ deviceId: profile.deviceId,
444
+ deviceName: profile.deviceName,
445
+ workspace: profile.workspace,
446
+ browserLogin: !options.terminalLogin && !options.tokenLogin,
447
+ terminalLogin: options.terminalLogin,
448
+ tokenLogin: options.tokenLogin,
200
449
  autoStart: options.autoStart,
201
450
  tunnelEnabled: options.tunnelEnabled,
202
451
  }, null, 2));
203
452
  return;
204
453
  }
205
454
 
206
- if (process.platform !== "win32") {
207
- throw new Error("CodexPanel desktop agent installation currently supports Windows. Use --print-command on Windows, or open the relay setup page for this platform.");
455
+ console.log(`CodexPanel ${VERSION}`);
456
+ console.log(`Server / 服务端: ${resolved.relayUrl} (${resolved.label})`);
457
+ if (resolved.label === "local") {
458
+ console.log(`Local relay port / 本地 relay 端口: ${resolved.localPort} (dynamic, not fixed)`);
208
459
  }
209
460
 
210
- console.log(`CodexPanel: downloading installer from ${normalizeRelayUrl(options.relayUrl)}`);
211
- const script = await downloadText(url);
212
- if (!/CodexPanel/i.test(script) || !/desktop-agent/i.test(script)) {
213
- throw new Error("Downloaded bootstrap script did not look like the CodexPanel Windows installer.");
214
- }
215
-
216
- const tmp = path.join(os.tmpdir(), `codexpanel-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}.ps1`);
217
- fs.writeFileSync(tmp, Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(script, "utf8")]));
461
+ const manifest = await preloadResources(resolved.relayUrl);
462
+ const start = await requestJson("POST", resolved.relayUrl, "/api/desktop/setup/start", {
463
+ ...profile,
464
+ agentVersion: manifest.agentVersion || "",
465
+ }, {}, 30000);
466
+ console.log("\n资源已准备好。按 Enter 拉起浏览器登录并绑定这台电脑。");
467
+ console.log("Resources are ready. Press Enter to open your browser and sign in.");
468
+ console.log(`Login URL / 登录链接: ${start.loginUrl}`);
469
+ if (start.oneTimeCode) console.log(`One-time code / 一次性确认码: ${start.oneTimeCode}`);
218
470
 
219
- console.log("CodexPanel: starting Windows installer");
220
- const result = spawnSync(powershellPath(), [
221
- "-NoLogo",
222
- "-NoProfile",
223
- "-ExecutionPolicy",
224
- "Bypass",
225
- "-File",
226
- tmp,
227
- ], {
228
- stdio: "inherit",
229
- windowsHide: false,
230
- });
231
-
232
- if (!options.keepScript) {
233
- try { fs.unlinkSync(tmp); } catch {}
471
+ let approval;
472
+ if (options.terminalLogin) {
473
+ approval = await terminalLogin(resolved.relayUrl, start.flowId, start.flowSecret);
474
+ } else if (options.tokenLogin) {
475
+ approval = await tokenLogin(resolved.relayUrl, start.flowId, start.flowSecret);
234
476
  } else {
235
- console.log(`CodexPanel: kept bootstrap script at ${tmp}`);
477
+ if (options.browser) {
478
+ const rl = createInterface();
479
+ await question(rl, "Press Enter to open browser / 按 Enter 拉起浏览器登录...");
480
+ rl.close();
481
+ try { openBrowser(start.loginUrl); }
482
+ catch (error) { console.log(`CodexPanel: browser open failed, please open manually: ${error.message}`); }
483
+ } else {
484
+ console.log("CodexPanel: --no-browser enabled. Open the URL above in your browser.");
485
+ }
486
+ approval = await pollSetup(resolved.relayUrl, start.flowId, start.flowSecret);
487
+ }
488
+ console.log("\nCodexPanel: 登录绑定成功 / Sign-in and binding approved.");
489
+ console.log(`User / 用户: ${approval.user?.name || approval.userId || "unknown"}`);
490
+ console.log(`Device / 设备: ${approval.deviceName || approval.deviceId}`);
491
+ await runBootstrap(resolved.relayUrl, approval, options);
492
+ if (approval.panelUrl) {
493
+ console.log(`CodexPanel local status panel / 本地状态面板: ${approval.panelUrl}`);
494
+ try { openBrowser(approval.panelUrl); } catch {}
236
495
  }
496
+ }
237
497
 
238
- if (result.error) throw result.error;
239
- if (result.status !== 0) throw new Error(`PowerShell installer exited with code ${result.status}`);
498
+ async function status(options) {
499
+ let runtime;
500
+ try {
501
+ runtime = readLocalRuntimeConfig();
502
+ } catch (error) {
503
+ throw new Error(`No local CodexPanel install found at ${error.configPath}. Run npx -y codexpanel first.`);
504
+ }
505
+ const { root, configPath, config } = runtime;
506
+ const panelUrl = localPanelUrl(config);
507
+ console.log(`CodexPanel ${VERSION} local status`);
508
+ console.log(`Install dir / 安装目录: ${root}`);
509
+ console.log(`Config / 配置文件: ${configPath}`);
510
+ console.log(`Server / 服务端: ${config.relayUrl || "unknown"}`);
511
+ console.log(`User / 用户: ${config.userId || "unknown"}`);
512
+ console.log(`Device / 设备: ${config.deviceName || config.deviceId || "unknown"}`);
513
+ console.log(`Device ID: ${config.deviceId || "unknown"}`);
514
+ if (panelUrl) console.log(`Local status panel / 本地状态面板: ${panelUrl}`);
515
+ else console.log("Local status panel / 本地状态面板: not configured");
516
+
517
+ if (panelUrl) {
518
+ try {
519
+ const panel = new URL(panelUrl);
520
+ const statusPath = `/api/status${panel.search || ""}`;
521
+ const data = await requestJson("GET", panel.origin, statusPath, undefined, {}, 5000);
522
+ console.log(`Agent / 本机 agent: ${data.running ? "running" : "unknown"} (PID ${data.pid || "unknown"})`);
523
+ console.log(`Agent version / agent 版本: ${data.agentVersion || "unknown"}`);
524
+ console.log(`NPM package / npm 包版本: ${data.npmPackageVersion || VERSION}`);
525
+ console.log(`Latest agent / 服务端最新版本: ${data.serverLatestAgentVersion || "unknown"}`);
526
+ console.log(`Update / 更新: ${data.updateAvailable ? data.updateCommand || "npx -y codexpanel" : "up to date"}`);
527
+ console.log(`Heartbeat / 最近心跳: ${data.lastHeartbeatAt || data.lastHeartbeatError || "waiting"}`);
528
+ if (options.browser) openBrowser(panelUrl);
529
+ } catch (error) {
530
+ console.log(`Agent / 本机 agent: not reachable (${error.message})`);
531
+ if (config.startupScriptPath) console.log(`Start command / 启动命令: powershell -NoProfile -ExecutionPolicy Bypass -File "${config.startupScriptPath}"`);
532
+ }
533
+ }
240
534
  }
241
535
 
242
536
  async function main() {
243
537
  const options = parseArgs(process.argv.slice(2));
244
- if (options.version) {
245
- console.log(VERSION);
246
- return;
247
- }
248
- if (options.help) {
249
- console.log(usage());
250
- return;
251
- }
252
- if (options.command !== "install") throw new Error(`Unknown command: ${options.command}`);
538
+ if (options.version) return console.log(VERSION);
539
+ if (options.help) return console.log(usage());
540
+ if (!["install", "login", "status"].includes(options.command)) throw new Error(`Unknown command: ${options.command}`);
541
+ if (options.command === "status") return status(options);
253
542
  await install(options);
254
543
  }
255
544
 
256
545
  main().catch((error) => {
257
546
  console.error(`codexpanel: ${error.message}`);
547
+ if (error.data) console.error(JSON.stringify(error.data, null, 2));
258
548
  process.exitCode = 1;
259
549
  });