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.
- package/README.md +77 -7
- package/README.zh.md +126 -0
- package/bin/codexpanel.cjs +431 -141
- package/docs/desktop-npx-install-flow.md +694 -0
- package/package.json +11 -3
package/bin/codexpanel.cjs
CHANGED
|
@@ -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
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
const readline = require("readline");
|
|
12
|
+
const { spawn, spawnSync } = require("child_process");
|
|
10
13
|
|
|
11
|
-
const VERSION = "0.1.
|
|
12
|
-
const
|
|
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
|
|
27
|
+
npx -y codexpanel login [options]
|
|
28
|
+
npx -y codexpanel status [options]
|
|
21
29
|
|
|
22
30
|
Options:
|
|
23
|
-
--
|
|
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
|
-
--
|
|
29
|
-
--
|
|
30
|
-
--
|
|
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
|
-
--
|
|
34
|
-
--
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
97
|
+
function normalizeUrl(value) {
|
|
113
98
|
const raw = String(value || "").trim().replace(/\/+$/, "");
|
|
114
|
-
if (!raw) throw new Error("
|
|
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 (
|
|
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
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return
|
|
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
|
|
138
|
-
|
|
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.
|
|
177
|
+
const req = client.request(url, {
|
|
178
|
+
method,
|
|
179
|
+
timeout: timeoutMs,
|
|
142
180
|
headers: {
|
|
143
181
|
"user-agent": `codexpanel-npm/${VERSION}`,
|
|
144
|
-
"accept": "
|
|
182
|
+
"accept": "application/json",
|
|
183
|
+
...(text ? { "content-type": "application/json", "content-length": Buffer.byteLength(text) } : {}),
|
|
184
|
+
...headers,
|
|
145
185
|
},
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
224
|
+
let data = "";
|
|
155
225
|
res.setEncoding("utf8");
|
|
156
|
-
res.on("data", (chunk) =>
|
|
226
|
+
res.on("data", (chunk) => data += chunk);
|
|
157
227
|
res.on("end", () => {
|
|
158
|
-
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
|
|
159
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
190
|
-
const
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
});
|