codexpanel 0.0.1 → 0.1.1
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/README.md +131 -0
- package/README.zh.md +126 -0
- package/bin/codexpanel.cjs +533 -0
- package/docs/desktop-npx-install-flow.md +694 -0
- package/package.json +49 -6
- package/cli.js +0 -2
- package/codexh5/cli.js +0 -2
- package/codexh5/codexfish/cli.js +0 -2
- package/codexh5/codexfish/codexyes/cli.js +0 -2
- package/codexh5/codexfish/codexyes/package.json +0 -7
- package/codexh5/codexfish/package.json +0 -7
- package/codexh5/package.json +0 -7
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const https = require("https");
|
|
7
|
+
const net = require("net");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
const readline = require("readline");
|
|
12
|
+
const { spawn, spawnSync } = require("child_process");
|
|
13
|
+
|
|
14
|
+
const VERSION = "0.1.1";
|
|
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;
|
|
20
|
+
|
|
21
|
+
function usage() {
|
|
22
|
+
return `
|
|
23
|
+
CodexPanel desktop agent installer
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
npx -y codexpanel [install] [options]
|
|
27
|
+
npx -y codexpanel login [options]
|
|
28
|
+
npx -y codexpanel status [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--server <name|url> production/prod, test/jd, local, or a relay URL. Defaults to production
|
|
32
|
+
--workspace, --cwd <path> Default workspace for Codex Desktop/app-server
|
|
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
|
|
37
|
+
--no-autostart Do not enable Windows login autostart
|
|
38
|
+
--no-tunnel Disable Cloudflare WSS direct tunnel
|
|
39
|
+
--dry-run Print resolved installer details without installing
|
|
40
|
+
--print-command Print the equivalent PowerShell bootstrap command after login approval
|
|
41
|
+
-h, --help Show help
|
|
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
|
|
49
|
+
`.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv) {
|
|
53
|
+
const args = [...argv];
|
|
54
|
+
const options = {
|
|
55
|
+
command: "install",
|
|
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,
|
|
62
|
+
autoStart: true,
|
|
63
|
+
tunnelEnabled: true,
|
|
64
|
+
dryRun: false,
|
|
65
|
+
printCommand: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const readValue = (flag) => {
|
|
69
|
+
const value = args.shift();
|
|
70
|
+
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
while (args.length) {
|
|
75
|
+
const arg = args.shift();
|
|
76
|
+
if (!arg) continue;
|
|
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 {
|
|
90
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (options.terminalLogin && options.tokenLogin) throw new Error("Choose only one of --terminal-login or --token-login.");
|
|
94
|
+
return options;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeUrl(value) {
|
|
98
|
+
const raw = String(value || "").trim().replace(/\/+$/, "");
|
|
99
|
+
if (!raw) throw new Error("Server URL is empty.");
|
|
100
|
+
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
101
|
+
const url = new URL(withProtocol);
|
|
102
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error(`Server must use http or https: ${withProtocol}`);
|
|
103
|
+
return url.toString().replace(/\/+$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
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 };
|
|
118
|
+
}
|
|
119
|
+
|
|
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);
|
|
175
|
+
const client = url.protocol === "https:" ? https : http;
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const req = client.request(url, {
|
|
178
|
+
method,
|
|
179
|
+
timeout: timeoutMs,
|
|
180
|
+
headers: {
|
|
181
|
+
"user-agent": `codexpanel-npm/${VERSION}`,
|
|
182
|
+
"accept": "application/json",
|
|
183
|
+
...(text ? { "content-type": "application/json", "content-length": Buffer.byteLength(text) } : {}),
|
|
184
|
+
...headers,
|
|
185
|
+
},
|
|
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,
|
|
218
|
+
}, (res) => {
|
|
219
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && res.headers.location) {
|
|
220
|
+
res.resume();
|
|
221
|
+
downloadText(new URL(res.headers.location, parsed).toString(), timeoutMs).then(resolve, reject);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let data = "";
|
|
225
|
+
res.setEncoding("utf8");
|
|
226
|
+
res.on("data", (chunk) => data += chunk);
|
|
227
|
+
res.on("end", () => {
|
|
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/, ""));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
req.on("timeout", () => req.destroy(new Error(`Timeout downloading ${parsed}`)));
|
|
233
|
+
req.on("error", reject);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function powershellPath() {
|
|
238
|
+
if (process.platform !== "win32") return "pwsh";
|
|
239
|
+
for (const candidate of ["pwsh.exe", "powershell.exe"]) {
|
|
240
|
+
const result = spawnSync("where.exe", [candidate], { encoding: "utf8", windowsHide: true });
|
|
241
|
+
const first = (result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
|
|
242
|
+
if (first) return first;
|
|
243
|
+
}
|
|
244
|
+
return "powershell.exe";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function openBrowser(url) {
|
|
248
|
+
const target = String(url);
|
|
249
|
+
if (process.platform === "win32") return spawn("cmd.exe", ["/c", "start", "", target], { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
250
|
+
if (process.platform === "darwin") return spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
|
|
251
|
+
return spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function localAgentRoot() {
|
|
255
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) return path.join(process.env.LOCALAPPDATA, "CodexPanelAgent");
|
|
256
|
+
return path.join(os.homedir() || process.cwd(), ".codexpanel-agent");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readLocalRuntimeConfig() {
|
|
260
|
+
const root = localAgentRoot();
|
|
261
|
+
const configPath = path.join(root, "agent-runtime.json");
|
|
262
|
+
try {
|
|
263
|
+
const raw = fs.readFileSync(configPath, "utf8").replace(/^\uFEFF/, "");
|
|
264
|
+
return { root, configPath, config: JSON.parse(raw) };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
error.configPath = configPath;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function localPanelUrl(config = {}) {
|
|
272
|
+
const panelBase = config.panelUrl || (config.panelPort ? `http://${config.panelHost || "127.0.0.1"}:${config.panelPort}/` : "");
|
|
273
|
+
if (!panelBase) return "";
|
|
274
|
+
const url = new URL(panelBase);
|
|
275
|
+
if (config.panelToken) url.searchParams.set("token", String(config.panelToken));
|
|
276
|
+
return url.toString();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function createInterface() {
|
|
280
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function question(rl, text, { silent = false } = {}) {
|
|
284
|
+
if (!silent) return new Promise((resolve) => rl.question(text, resolve));
|
|
285
|
+
const input = process.stdin;
|
|
286
|
+
const output = process.stdout;
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const onData = (char) => {
|
|
289
|
+
char = String(char);
|
|
290
|
+
switch (char) {
|
|
291
|
+
case "\n": case "\r": case "\u0004":
|
|
292
|
+
input.setRawMode?.(false);
|
|
293
|
+
input.pause();
|
|
294
|
+
output.write("\n");
|
|
295
|
+
input.removeListener("data", onData);
|
|
296
|
+
resolve(buffer);
|
|
297
|
+
break;
|
|
298
|
+
case "\u0003":
|
|
299
|
+
process.exit(130);
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
buffer += char;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
let buffer = "";
|
|
307
|
+
output.write(text);
|
|
308
|
+
input.resume();
|
|
309
|
+
input.setRawMode?.(true);
|
|
310
|
+
input.on("data", onData);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function preloadResources(relayUrl) {
|
|
315
|
+
const manifestUrl = new URL("/agent/version", relayUrl).toString();
|
|
316
|
+
console.log(`CodexPanel: 正在读取资源清单 / Reading resource manifest: ${manifestUrl}`);
|
|
317
|
+
const manifestText = await downloadText(manifestUrl, 30000);
|
|
318
|
+
let manifest = {};
|
|
319
|
+
try { manifest = JSON.parse(manifestText); }
|
|
320
|
+
catch { throw new Error("Server returned an invalid agent manifest."); }
|
|
321
|
+
const resources = [manifest.desktopAgent, manifest.connector, manifest.bootstrap].filter(Boolean);
|
|
322
|
+
for (const item of resources) {
|
|
323
|
+
const resourceUrl = new URL(item.path, relayUrl).toString();
|
|
324
|
+
console.log(`CodexPanel: 预下载资源 / Downloading ${item.fileName || item.path}`);
|
|
325
|
+
const text = await downloadText(resourceUrl, 60000);
|
|
326
|
+
const hash = crypto.createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
327
|
+
if (item.sha256 && hash !== item.sha256) throw new Error(`Checksum mismatch for ${item.fileName || item.path}`);
|
|
328
|
+
}
|
|
329
|
+
console.log("CodexPanel: 资源已下载并校验完成 / Resources downloaded and verified.");
|
|
330
|
+
return manifest;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function powershellCommand(url) {
|
|
334
|
+
const escaped = String(url).replace(/'/g, "''");
|
|
335
|
+
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "irm '${escaped}' | iex"`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function pollSetup(relayUrl, flowId, flowSecret) {
|
|
339
|
+
const started = Date.now();
|
|
340
|
+
while (Date.now() - started < 10 * 60 * 1000) {
|
|
341
|
+
const result = await requestJson("POST", relayUrl, "/api/desktop/setup/poll", { flowId, flowSecret }, {}, 30000);
|
|
342
|
+
if (result.status === "approved") return result;
|
|
343
|
+
if (result.status === "rejected") throw new Error(result.error || "Setup was rejected in the browser.");
|
|
344
|
+
if (result.status === "expired") throw new Error("Setup login expired. Run npx -y codexpanel again.");
|
|
345
|
+
const hint = result.message || "等待浏览器登录绑定 / Waiting for browser sign-in";
|
|
346
|
+
process.stdout.write(`\rCodexPanel: ${hint}... `);
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
348
|
+
}
|
|
349
|
+
throw new Error("Setup login timed out after 10 minutes.");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function terminalLogin(relayUrl, flowId, flowSecret) {
|
|
353
|
+
const rl = createInterface();
|
|
354
|
+
try {
|
|
355
|
+
const username = (await question(rl, "CodexPanel username: ")).trim();
|
|
356
|
+
const password = await question(rl, "CodexPanel password: ", { silent: true });
|
|
357
|
+
try {
|
|
358
|
+
return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, username, password }, {}, 30000);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.data?.code !== "device_rebind_required") throw error;
|
|
361
|
+
console.log("这台电脑已绑定到其他账号,不会自动切换绑定。");
|
|
362
|
+
const answer = (await question(rl, "Type YES to rebind this computer to the current account / 输入 YES 切换绑定: ")).trim();
|
|
363
|
+
if (answer !== "YES") throw new Error("Setup kept the existing binding. Run npx -y codexpanel again if you want to switch later.");
|
|
364
|
+
return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, username, password, confirmRebind: true }, {}, 30000);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
rl.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function tokenLogin(relayUrl, flowId, flowSecret) {
|
|
372
|
+
const rl = createInterface();
|
|
373
|
+
try {
|
|
374
|
+
const token = (await question(rl, "One-time CodexPanel terminal token from setup page: ")).trim();
|
|
375
|
+
try {
|
|
376
|
+
return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, token }, {}, 30000);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error.data?.code !== "device_rebind_required") throw error;
|
|
379
|
+
console.log("这台电脑已绑定到其他账号,不会自动切换绑定。");
|
|
380
|
+
const answer = (await question(rl, "Type YES to rebind this computer to the current account / 输入 YES 切换绑定: ")).trim();
|
|
381
|
+
if (answer !== "YES") throw new Error("Setup kept the existing binding. Run npx -y codexpanel again if you want to switch later.");
|
|
382
|
+
return await requestJson("POST", relayUrl, "/api/desktop/setup/login-token", { flowId, flowSecret, token, confirmRebind: true }, {}, 30000);
|
|
383
|
+
}
|
|
384
|
+
} finally {
|
|
385
|
+
rl.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function runBootstrap(relayUrl, approval, options) {
|
|
390
|
+
const url = new URL("/agent/bootstrap.ps1", relayUrl);
|
|
391
|
+
url.searchParams.set("setupToken", approval.setupToken);
|
|
392
|
+
url.searchParams.set("manualDeviceId", "1");
|
|
393
|
+
url.searchParams.set("manualDeviceName", "1");
|
|
394
|
+
url.searchParams.set("deviceId", approval.deviceId || stableDeviceId());
|
|
395
|
+
if (approval.deviceName) url.searchParams.set("deviceName", approval.deviceName);
|
|
396
|
+
if (options.workspace) url.searchParams.set("workspace", options.workspace);
|
|
397
|
+
url.searchParams.set("mode", "desktop-agent");
|
|
398
|
+
url.searchParams.set("autoStart", options.autoStart ? "1" : "0");
|
|
399
|
+
url.searchParams.set("tunnelEnabled", options.tunnelEnabled ? "1" : "0");
|
|
400
|
+
|
|
401
|
+
if (options.printCommand) console.log(powershellCommand(url));
|
|
402
|
+
if (process.platform !== "win32") throw new Error("CodexPanel desktop agent installation currently supports Windows. Use the login URL on Windows to bind this computer.");
|
|
403
|
+
|
|
404
|
+
console.log("\nCodexPanel: 正在下载最终安装脚本 / Downloading final bootstrap script...");
|
|
405
|
+
const script = await downloadText(url.toString(), 30000);
|
|
406
|
+
if (!/CodexPanel/i.test(script) || !/desktop-agent/i.test(script)) throw new Error("Downloaded bootstrap script did not look like the CodexPanel Windows installer.");
|
|
407
|
+
const tmp = path.join(os.tmpdir(), `codexpanel-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}.ps1`);
|
|
408
|
+
fs.writeFileSync(tmp, Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(script, "utf8")]));
|
|
409
|
+
console.log("CodexPanel: 正在启动 Windows 安装器 / Starting Windows installer...");
|
|
410
|
+
const result = spawnSync(powershellPath(), ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp], { stdio: "inherit", windowsHide: false });
|
|
411
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
412
|
+
if (result.error) throw result.error;
|
|
413
|
+
if (result.status !== 0) throw new Error(`PowerShell installer exited with code ${result.status}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function install(options) {
|
|
417
|
+
const resolved = await resolveServer(options.server);
|
|
418
|
+
const profile = computerProfile(options, resolved);
|
|
419
|
+
if (options.dryRun) {
|
|
420
|
+
console.log(JSON.stringify({
|
|
421
|
+
ok: true,
|
|
422
|
+
version: VERSION,
|
|
423
|
+
command: options.command,
|
|
424
|
+
server: resolved.label,
|
|
425
|
+
relayUrl: resolved.relayUrl,
|
|
426
|
+
localPort: resolved.localPort,
|
|
427
|
+
deviceId: profile.deviceId,
|
|
428
|
+
deviceName: profile.deviceName,
|
|
429
|
+
workspace: profile.workspace,
|
|
430
|
+
browserLogin: !options.terminalLogin && !options.tokenLogin,
|
|
431
|
+
terminalLogin: options.terminalLogin,
|
|
432
|
+
tokenLogin: options.tokenLogin,
|
|
433
|
+
autoStart: options.autoStart,
|
|
434
|
+
tunnelEnabled: options.tunnelEnabled,
|
|
435
|
+
}, null, 2));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
console.log(`CodexPanel ${VERSION}`);
|
|
440
|
+
console.log(`Server / 服务端: ${resolved.relayUrl} (${resolved.label})`);
|
|
441
|
+
if (resolved.label === "local") {
|
|
442
|
+
console.log(`Local relay port / 本地 relay 端口: ${resolved.localPort} (dynamic, not fixed)`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const manifest = await preloadResources(resolved.relayUrl);
|
|
446
|
+
const start = await requestJson("POST", resolved.relayUrl, "/api/desktop/setup/start", {
|
|
447
|
+
...profile,
|
|
448
|
+
agentVersion: manifest.agentVersion || "",
|
|
449
|
+
}, {}, 30000);
|
|
450
|
+
console.log("\n资源已准备好。按 Enter 拉起浏览器登录并绑定这台电脑。");
|
|
451
|
+
console.log("Resources are ready. Press Enter to open your browser and sign in.");
|
|
452
|
+
console.log(`Login URL / 登录链接: ${start.loginUrl}`);
|
|
453
|
+
if (start.oneTimeCode) console.log(`One-time code / 一次性确认码: ${start.oneTimeCode}`);
|
|
454
|
+
|
|
455
|
+
let approval;
|
|
456
|
+
if (options.terminalLogin) {
|
|
457
|
+
approval = await terminalLogin(resolved.relayUrl, start.flowId, start.flowSecret);
|
|
458
|
+
} else if (options.tokenLogin) {
|
|
459
|
+
approval = await tokenLogin(resolved.relayUrl, start.flowId, start.flowSecret);
|
|
460
|
+
} else {
|
|
461
|
+
if (options.browser) {
|
|
462
|
+
const rl = createInterface();
|
|
463
|
+
await question(rl, "Press Enter to open browser / 按 Enter 拉起浏览器登录...");
|
|
464
|
+
rl.close();
|
|
465
|
+
try { openBrowser(start.loginUrl); }
|
|
466
|
+
catch (error) { console.log(`CodexPanel: browser open failed, please open manually: ${error.message}`); }
|
|
467
|
+
} else {
|
|
468
|
+
console.log("CodexPanel: --no-browser enabled. Open the URL above in your browser.");
|
|
469
|
+
}
|
|
470
|
+
approval = await pollSetup(resolved.relayUrl, start.flowId, start.flowSecret);
|
|
471
|
+
}
|
|
472
|
+
console.log("\nCodexPanel: 登录绑定成功 / Sign-in and binding approved.");
|
|
473
|
+
console.log(`User / 用户: ${approval.user?.name || approval.userId || "unknown"}`);
|
|
474
|
+
console.log(`Device / 设备: ${approval.deviceName || approval.deviceId}`);
|
|
475
|
+
await runBootstrap(resolved.relayUrl, approval, options);
|
|
476
|
+
if (approval.panelUrl) {
|
|
477
|
+
console.log(`CodexPanel local status panel / 本地状态面板: ${approval.panelUrl}`);
|
|
478
|
+
try { openBrowser(approval.panelUrl); } catch {}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function status(options) {
|
|
483
|
+
let runtime;
|
|
484
|
+
try {
|
|
485
|
+
runtime = readLocalRuntimeConfig();
|
|
486
|
+
} catch (error) {
|
|
487
|
+
throw new Error(`No local CodexPanel install found at ${error.configPath}. Run npx -y codexpanel first.`);
|
|
488
|
+
}
|
|
489
|
+
const { root, configPath, config } = runtime;
|
|
490
|
+
const panelUrl = localPanelUrl(config);
|
|
491
|
+
console.log(`CodexPanel ${VERSION} local status`);
|
|
492
|
+
console.log(`Install dir / 安装目录: ${root}`);
|
|
493
|
+
console.log(`Config / 配置文件: ${configPath}`);
|
|
494
|
+
console.log(`Server / 服务端: ${config.relayUrl || "unknown"}`);
|
|
495
|
+
console.log(`User / 用户: ${config.userId || "unknown"}`);
|
|
496
|
+
console.log(`Device / 设备: ${config.deviceName || config.deviceId || "unknown"}`);
|
|
497
|
+
console.log(`Device ID: ${config.deviceId || "unknown"}`);
|
|
498
|
+
if (panelUrl) console.log(`Local status panel / 本地状态面板: ${panelUrl}`);
|
|
499
|
+
else console.log("Local status panel / 本地状态面板: not configured");
|
|
500
|
+
|
|
501
|
+
if (panelUrl) {
|
|
502
|
+
try {
|
|
503
|
+
const panel = new URL(panelUrl);
|
|
504
|
+
const statusPath = `/api/status${panel.search || ""}`;
|
|
505
|
+
const data = await requestJson("GET", panel.origin, statusPath, undefined, {}, 5000);
|
|
506
|
+
console.log(`Agent / 本机 agent: ${data.running ? "running" : "unknown"} (PID ${data.pid || "unknown"})`);
|
|
507
|
+
console.log(`Agent version / agent 版本: ${data.agentVersion || "unknown"}`);
|
|
508
|
+
console.log(`NPM package / npm 包版本: ${data.npmPackageVersion || VERSION}`);
|
|
509
|
+
console.log(`Latest agent / 服务端最新版本: ${data.serverLatestAgentVersion || "unknown"}`);
|
|
510
|
+
console.log(`Update / 更新: ${data.updateAvailable ? data.updateCommand || "npx -y codexpanel" : "up to date"}`);
|
|
511
|
+
console.log(`Heartbeat / 最近心跳: ${data.lastHeartbeatAt || data.lastHeartbeatError || "waiting"}`);
|
|
512
|
+
if (options.browser) openBrowser(panelUrl);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.log(`Agent / 本机 agent: not reachable (${error.message})`);
|
|
515
|
+
if (config.startupScriptPath) console.log(`Start command / 启动命令: powershell -NoProfile -ExecutionPolicy Bypass -File "${config.startupScriptPath}"`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function main() {
|
|
521
|
+
const options = parseArgs(process.argv.slice(2));
|
|
522
|
+
if (options.version) return console.log(VERSION);
|
|
523
|
+
if (options.help) return console.log(usage());
|
|
524
|
+
if (!["install", "login", "status"].includes(options.command)) throw new Error(`Unknown command: ${options.command}`);
|
|
525
|
+
if (options.command === "status") return status(options);
|
|
526
|
+
await install(options);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
main().catch((error) => {
|
|
530
|
+
console.error(`codexpanel: ${error.message}`);
|
|
531
|
+
if (error.data) console.error(JSON.stringify(error.data, null, 2));
|
|
532
|
+
process.exitCode = 1;
|
|
533
|
+
});
|