linkshell-cli 0.10.46 → 0.10.47

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.
Files changed (35) hide show
  1. package/dist/cli/src/auth.d.ts +6 -2
  2. package/dist/cli/src/auth.js +9 -5
  3. package/dist/cli/src/auth.js.map +1 -1
  4. package/dist/cli/src/commands/desktop-workspace-state.d.ts +19 -0
  5. package/dist/cli/src/commands/desktop-workspace-state.js +92 -0
  6. package/dist/cli/src/commands/desktop-workspace-state.js.map +1 -0
  7. package/dist/cli/src/commands/desktop-workspace.d.ts +12 -0
  8. package/dist/cli/src/commands/desktop-workspace.js +415 -0
  9. package/dist/cli/src/commands/desktop-workspace.js.map +1 -0
  10. package/dist/cli/src/commands/login.d.ts +3 -1
  11. package/dist/cli/src/commands/login.js +35 -10
  12. package/dist/cli/src/commands/login.js.map +1 -1
  13. package/dist/cli/src/index.js +15 -0
  14. package/dist/cli/src/index.js.map +1 -1
  15. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  16. package/dist/client-core/src/pro-membership.d.ts +1 -0
  17. package/dist/client-core/src/pro-membership.js +3 -1
  18. package/dist/client-core/src/pro-membership.js.map +1 -1
  19. package/dist/host/src/input.d.ts +29 -2
  20. package/dist/host/src/input.js +99 -4
  21. package/dist/host/src/input.js.map +1 -1
  22. package/dist/host/src/screen-session.d.ts +35 -0
  23. package/dist/host/src/screen-session.js +26 -0
  24. package/dist/host/src/screen-session.js.map +1 -0
  25. package/dist/host/src/screen-viewer.js +293 -30
  26. package/dist/host/src/screen-viewer.js.map +1 -1
  27. package/dist/host/src/screen.d.ts +3 -1
  28. package/dist/host/src/screen.js +454 -160
  29. package/dist/host/src/screen.js.map +1 -1
  30. package/package.json +6 -4
  31. package/src/auth.ts +7 -5
  32. package/src/commands/desktop-workspace-state.ts +87 -0
  33. package/src/commands/desktop-workspace.ts +353 -0
  34. package/src/commands/login.ts +27 -10
  35. package/src/index.ts +16 -0
@@ -0,0 +1,353 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { readFile, readdir, realpath } from "node:fs/promises";
4
+ import { extname, isAbsolute, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import type { Readable } from "node:stream";
7
+ import type { Socket } from "node:net";
8
+ import WebSocket, { WebSocketServer } from "ws";
9
+ import { isMethodName, machineInfoSchema, rpcMessageSchema, type MachineEntry } from "@linkshell/wire";
10
+ import { getValidToken, loadAuth } from "../auth.js";
11
+ import { runLogin } from "./login.js";
12
+ import { runLogout } from "./logout.js";
13
+ import { silenceSqliteWarning } from "./host.js";
14
+ import { DesktopClientState, validStorageKey } from "./desktop-workspace-state.js";
15
+
16
+ const MAX_REQUEST_BYTES = 1024 * 1024;
17
+ const MAX_PROXY_BYTES = 40 * 1024 * 1024;
18
+ const MIME: Record<string, string> = {
19
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
21
+ ".jpg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon",
22
+ ".woff": "font/woff", ".woff2": "font/woff2", ".json": "application/json",
23
+ };
24
+
25
+ interface LocalComputer {
26
+ key: "desktop:local";
27
+ gateway: "desktop:local";
28
+ machine: MachineEntry;
29
+ }
30
+
31
+ export interface DesktopWorkspace {
32
+ url: string;
33
+ stop(): Promise<void>;
34
+ }
35
+
36
+ class HttpError extends Error {
37
+ constructor(readonly status: number, message: string) { super(message); }
38
+ }
39
+
40
+ function json(response: ServerResponse, status: number, value: unknown): void {
41
+ if (response.destroyed || response.writableEnded) return;
42
+ response.writeHead(status, {
43
+ "content-type": "application/json; charset=utf-8",
44
+ "cache-control": "no-store", "x-content-type-options": "nosniff",
45
+ "referrer-policy": "no-referrer", "x-frame-options": "DENY",
46
+ });
47
+ response.end(JSON.stringify(value));
48
+ }
49
+
50
+ async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
51
+ if (request.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
52
+ throw new HttpError(415, "请求需要使用 JSON 格式");
53
+ }
54
+ if (Number(request.headers["content-length"]) > MAX_REQUEST_BYTES) throw new HttpError(413, "偏好内容过长");
55
+ let length = 0;
56
+ const chunks: Buffer[] = [];
57
+ for await (const chunk of request) {
58
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
59
+ length += bytes.length;
60
+ if (length > MAX_REQUEST_BYTES) throw new HttpError(413, "偏好内容过长");
61
+ chunks.push(bytes);
62
+ }
63
+ try {
64
+ const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
65
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
66
+ } catch { /* Request contents may contain private drafts; never echo them. */ }
67
+ throw new HttpError(400, "无效的 JSON 请求");
68
+ }
69
+
70
+ async function loadAssets(directory: string): Promise<Map<string, Buffer>> {
71
+ if (!isAbsolute(directory)) throw new Error("桌面工作台资源目录必须使用绝对路径");
72
+ const assets = new Map<string, Buffer>();
73
+ const root = await realpath(directory);
74
+ const walk = async (path: string, prefix = "") => {
75
+ for (const entry of await readdir(path, { withFileTypes: true })) {
76
+ if (entry.isDirectory()) await walk(join(path, entry.name), `${prefix}/${entry.name}`);
77
+ else if (entry.isFile()) assets.set(`${prefix}/${entry.name}`, await readFile(join(path, entry.name)));
78
+ }
79
+ };
80
+ await walk(root);
81
+ if (!assets.has("/index.html")) throw new Error("桌面工作台资源缺失,请重新安装 LinkShell");
82
+ return assets;
83
+ }
84
+
85
+ async function localComputer(target: string, upstreams: Set<WebSocket>): Promise<LocalComputer | undefined> {
86
+ // Query the existing socket directly: discovering this computer never starts a host.
87
+ return new Promise((resolve) => {
88
+ const client = new WebSocket(`ws+unix://${target}:/`, { perMessageDeflate: false, handshakeTimeout: 1_500, maxPayload: MAX_PROXY_BYTES });
89
+ upstreams.add(client);
90
+ const finish = (computer?: LocalComputer) => {
91
+ clearTimeout(timeout);
92
+ resolve(computer);
93
+ client.terminate();
94
+ };
95
+ const timeout = setTimeout(() => finish(), 3_000);
96
+ client.on("error", () => finish());
97
+ client.on("close", () => { upstreams.delete(client); clearTimeout(timeout); resolve(undefined); });
98
+ client.on("open", () => client.send(JSON.stringify({ jsonrpc: "2.0", id: "desktop-info", method: "machine.info", params: {} })));
99
+ client.on("message", (raw) => {
100
+ try {
101
+ const response: unknown = JSON.parse(raw.toString());
102
+ if (!response || typeof response !== "object" || !("id" in response) || response.id !== "desktop-info") return;
103
+ const parsed = machineInfoSchema.safeParse("result" in response ? response.result : undefined);
104
+ if (!parsed.success) { finish(); return; }
105
+ const info = parsed.data;
106
+ finish({
107
+ key: "desktop:local", gateway: "desktop:local",
108
+ machine: {
109
+ id: info.machineId, name: info.hostname.replace(/\.local$/, ""), platform: info.platform,
110
+ // Local transport uses a protected Unix socket, not a relay identity or tunnel.
111
+ signKey: "", boxKey: "", online: true, via: "paired",
112
+ },
113
+ });
114
+ } catch { finish(); }
115
+ });
116
+ });
117
+ }
118
+
119
+ async function account(signal: AbortSignal) {
120
+ const accessToken = await getValidToken({ signal });
121
+ const current = loadAuth();
122
+ if (!current?.userId) return {};
123
+ return {
124
+ userId: current.userId, email: current.email,
125
+ ...(accessToken && current.accessToken === accessToken ? { accessToken } : {}),
126
+ };
127
+ }
128
+
129
+ function proxyHost(client: WebSocket, target: string, upstreams: Set<WebSocket>): void {
130
+ const host = new WebSocket(`ws+unix://${target}:/`, {
131
+ perMessageDeflate: false, handshakeTimeout: 3_000, maxPayload: MAX_PROXY_BYTES,
132
+ });
133
+ upstreams.add(host);
134
+ let pending: string[] = [];
135
+ let pendingBytes = 0;
136
+ const fail = (code: number, reason: string) => {
137
+ if (client.readyState === WebSocket.OPEN) client.close(code, reason);
138
+ host.terminate();
139
+ pending = [];
140
+ };
141
+ const forward = (text: string) => {
142
+ if (host.bufferedAmount + Buffer.byteLength(text) > MAX_PROXY_BYTES) {
143
+ fail(1013, "连接繁忙,请重试");
144
+ } else host.send(text, (error) => { if (error) fail(1011, "本机连接已断开"); });
145
+ };
146
+ client.on("message", (raw, binary) => {
147
+ if (binary) { fail(1008, "不支持此消息格式"); return; }
148
+ const text = raw.toString();
149
+ let message;
150
+ try { message = rpcMessageSchema.safeParse(JSON.parse(text)); }
151
+ catch { fail(1008, "无效的 RPC 请求"); return; }
152
+ if (!message.success || typeof message.data.method !== "string") {
153
+ fail(1008, "无效的 RPC 请求"); return;
154
+ }
155
+ const { method, id } = message.data;
156
+ // Unix clients include privileged native-agent shims; this browser is only a normal client.
157
+ if (method.startsWith("desktop.") || !isMethodName(method)) {
158
+ if (id !== undefined && client.readyState === WebSocket.OPEN) {
159
+ client.send(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: "不支持此操作" } }));
160
+ }
161
+ return;
162
+ }
163
+ if (host.readyState === WebSocket.CONNECTING) {
164
+ pendingBytes += Buffer.byteLength(text);
165
+ if (pendingBytes > MAX_PROXY_BYTES) { fail(1013, "连接繁忙,请重试"); return; }
166
+ pending.push(text);
167
+ } else if (host.readyState === WebSocket.OPEN) forward(text);
168
+ });
169
+ host.on("open", () => {
170
+ if (client.readyState !== WebSocket.OPEN) { host.terminate(); return; }
171
+ for (const text of pending) {
172
+ if (host.readyState !== WebSocket.OPEN) break;
173
+ forward(text);
174
+ }
175
+ pending = [];
176
+ pendingBytes = 0;
177
+ });
178
+ host.on("message", (data, binary) => {
179
+ if (client.readyState !== WebSocket.OPEN) return;
180
+ const size = Array.isArray(data) ? data.reduce((total, part) => total + part.length, 0) : data.byteLength;
181
+ if (client.bufferedAmount + size > MAX_PROXY_BYTES) { fail(1013, "连接繁忙,请重试"); return; }
182
+ client.send(data, { binary }, (error) => { if (error) host.terminate(); });
183
+ });
184
+ host.on("error", () => fail(1013, "本机服务未运行,请在本机服务中启动"));
185
+ host.on("close", () => {
186
+ upstreams.delete(host);
187
+ pending = [];
188
+ if (client.readyState === WebSocket.OPEN) client.close(1013, "本机服务已断开");
189
+ });
190
+ client.on("error", () => host.terminate());
191
+ client.on("close", () => { pending = []; host.terminate(); });
192
+ }
193
+
194
+ /** A capability-protected UI server owned by the native application's lifetime. */
195
+ export async function startDesktopWorkspace(options: { assets: string; home?: string; hostSocket?: string }): Promise<DesktopWorkspace> {
196
+ silenceSqliteWarning();
197
+ const home = options.home ?? process.env.LINKSHELL_HOME ?? join(homedir(), ".linkshell");
198
+ const assets = await loadAssets(options.assets);
199
+ const state = await DesktopClientState.open(home);
200
+ const hostSocket = options.hostSocket ?? (await import("@linkshell/host")).hostPaths(home).hostSocket;
201
+ const capability = randomBytes(32).toString("base64url");
202
+ const secret = Buffer.from(capability);
203
+ const hasToken = (value: string | undefined): boolean => {
204
+ const supplied = Buffer.from(value ?? "");
205
+ return supplied.length === secret.length && timingSafeEqual(supplied, secret);
206
+ };
207
+ let workspaceOrigin = "";
208
+ let workspaceHost = "";
209
+ let previewOrigin = "";
210
+ let previewHost = "";
211
+ let login: Promise<Awaited<ReturnType<typeof runLogin>>> | undefined;
212
+ let loginAbort: AbortController | undefined;
213
+ let stopping: Promise<void> | undefined;
214
+ const peers = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PROXY_BYTES });
215
+ const upstreams = new Set<WebSocket>();
216
+ const sockets = new Set<Socket>();
217
+ const accountAbort = new AbortController();
218
+
219
+ const serve = async (request: IncomingMessage, response: ServerResponse) => {
220
+ const host = request.headers.host;
221
+ if (host !== workspaceHost && host !== previewHost) throw new HttpError(403, "来源不受信任");
222
+ if (!request.url?.startsWith("/") || request.url.startsWith("//")) throw new HttpError(400, "无效的请求地址");
223
+ const url = new URL(request.url, workspaceOrigin);
224
+ const path = url.pathname;
225
+ if (host === previewHost) {
226
+ // A preview service worker can only ever own this other origin and /preview/ scope.
227
+ if (!path.startsWith("/preview/")) throw new HttpError(404, "未找到资源");
228
+ } else {
229
+ const origin = request.headers.origin;
230
+ if (origin !== undefined && origin !== workspaceOrigin) throw new HttpError(403, "来源不受信任");
231
+ if (path.startsWith("/desktop/")) {
232
+ if (!hasToken(request.headers.authorization?.replace(/^Bearer /, "")) ||
233
+ !request.headers.authorization?.startsWith("Bearer ")) throw new HttpError(401, "桌面连接已过期,请重新打开工作台");
234
+ // Same-origin GET fetches omit Origin. Mutations always send it in WebKit.
235
+ if (!["GET", "HEAD"].includes(request.method ?? "") && origin !== workspaceOrigin) throw new HttpError(403, "来源不受信任");
236
+ if (request.method === "GET" && path === "/desktop/bootstrap") {
237
+ json(response, 200, { ...state.snapshot(), localComputer: await localComputer(hostSocket, upstreams) }); return;
238
+ }
239
+ if (request.method === "GET" && path === "/desktop/local") {
240
+ json(response, 200, { computer: await localComputer(hostSocket, upstreams) }); return;
241
+ }
242
+ if (request.method === "GET" && path === "/desktop/account") {
243
+ json(response, 200, await account(accountAbort.signal)); return;
244
+ }
245
+ if (request.method === "PUT" && path === "/desktop/storage") {
246
+ const input = await body(request);
247
+ if (!validStorageKey(input.key) || !Object.hasOwn(input, "value")) throw new HttpError(400, "无效的偏好内容");
248
+ try { await state.set(input.key, input.value); }
249
+ catch { throw new HttpError(507, "桌面偏好保存失败,请检查可用空间或缩短内容"); }
250
+ json(response, 200, { ok: true }); return;
251
+ }
252
+ if (request.method === "POST" && path === "/desktop/login") {
253
+ if (!login) {
254
+ loginAbort = new AbortController();
255
+ login = runLogin({ signal: loginAbort.signal }).finally(() => { login = undefined; loginAbort = undefined; });
256
+ }
257
+ const result = await login;
258
+ if (!result?.success) throw new HttpError(409, "登录未完成,请重试");
259
+ json(response, 200, await account(accountAbort.signal)); return;
260
+ }
261
+ if (request.method === "POST" && path === "/desktop/logout") {
262
+ loginAbort?.abort();
263
+ await login;
264
+ await runLogout();
265
+ json(response, 200, {}); return;
266
+ }
267
+ throw new HttpError(404, "未找到操作");
268
+ }
269
+ // Never install a preview service worker in the identity-bearing workspace origin.
270
+ if (path.startsWith("/preview/")) throw new HttpError(404, "未找到资源");
271
+ }
272
+ if (!["GET", "HEAD"].includes(request.method ?? "")) throw new HttpError(405, "不支持此请求方法");
273
+ const assetPath = path === "/" ? "/index.html" : path;
274
+ const content = path === "/config.js" ? Buffer.from(`window.__LINKSHELL_CONFIG__ = ${JSON.stringify({ desktop: true, deployment: "official", previewOrigin })};\n`) : assets.get(assetPath);
275
+ if (!content) throw new HttpError(404, "未找到资源");
276
+ response.writeHead(200, {
277
+ "content-type": MIME[extname(assetPath)] ?? "application/octet-stream", "content-length": content.byteLength,
278
+ "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
279
+ ...(host === workspaceHost ? { "x-frame-options": "DENY", "content-security-policy": "frame-ancestors 'none'" } : {}),
280
+ });
281
+ response.end(request.method === "HEAD" ? undefined : content);
282
+ };
283
+ const server = createServer((request, response) => {
284
+ void serve(request, response).catch((error: unknown) => {
285
+ json(response, error instanceof HttpError ? error.status : 500,
286
+ { error: error instanceof HttpError ? error.message : "桌面工作台暂时无法完成操作,请重试" });
287
+ });
288
+ });
289
+ server.requestTimeout = 6 * 60_000;
290
+ server.headersTimeout = 10_000;
291
+ server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
292
+ server.on("upgrade", (request, socket, head) => {
293
+ let url: URL;
294
+ try { url = new URL(request.url ?? "", workspaceOrigin); }
295
+ catch { socket.destroy(); return; }
296
+ if (request.headers.host !== workspaceHost || request.headers.origin !== workspaceOrigin ||
297
+ !request.url?.startsWith("/") || request.url.startsWith("//") || url.pathname !== "/desktop/host" ||
298
+ !hasToken(url.searchParams.get("token") ?? undefined)) {
299
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); return;
300
+ }
301
+ peers.handleUpgrade(request, socket, head, (client) => proxyHost(client, hostSocket, upstreams));
302
+ });
303
+ await new Promise<void>((resolve, reject) => {
304
+ server.once("error", reject);
305
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
306
+ });
307
+ const address = server.address();
308
+ if (!address || typeof address === "string") throw new Error("桌面工作台无法启动");
309
+ workspaceHost = `127.0.0.1:${address.port}`;
310
+ previewHost = `localhost:${address.port}`;
311
+ workspaceOrigin = `http://${workspaceHost}`;
312
+ previewOrigin = `http://${previewHost}`;
313
+ return {
314
+ url: `${workspaceOrigin}/#desktop=${capability}`,
315
+ stop() {
316
+ return stopping ??= (async () => {
317
+ accountAbort.abort();
318
+ loginAbort?.abort();
319
+ for (const client of peers.clients) client.terminate();
320
+ for (const upstream of upstreams) upstream.terminate();
321
+ for (const socket of sockets) socket.destroy();
322
+ await Promise.all([
323
+ new Promise<void>((resolve) => server.close(() => resolve())),
324
+ new Promise<void>((resolve) => peers.close(() => resolve())),
325
+ state.flush(),
326
+ ]);
327
+ })();
328
+ },
329
+ };
330
+ }
331
+
332
+ export async function runDesktopWorkspace(assets: string, input: Readable = process.stdin): Promise<void> {
333
+ let close!: () => void;
334
+ const ended = new Promise<void>((resolve) => { close = resolve; });
335
+ input.once("end", close);
336
+ input.once("close", close);
337
+ input.once("error", close);
338
+ process.once("SIGINT", close);
339
+ process.once("SIGTERM", close);
340
+ input.resume();
341
+ if (input.readableEnded || input.destroyed) close();
342
+ let workspace: DesktopWorkspace | undefined;
343
+ try {
344
+ workspace = await startDesktopWorkspace({ assets });
345
+ process.stdout.write(`${JSON.stringify({ url: workspace.url })}\n`);
346
+ await ended;
347
+ } finally {
348
+ input.off("end", close); input.off("close", close); input.off("error", close);
349
+ input.pause();
350
+ process.off("SIGINT", close); process.off("SIGTERM", close);
351
+ await workspace?.stop();
352
+ }
353
+ }
@@ -24,12 +24,27 @@ export interface LoginResult {
24
24
  accessToken: string;
25
25
  }
26
26
 
27
- export async function runLogin(): Promise<LoginResult | null> {
27
+ export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<LoginResult | null> {
28
+ if (options.signal?.aborted) return null;
28
29
  process.stderr.write("\n LinkShell Login\n\n");
29
30
 
30
31
  return new Promise<LoginResult | null>((resolve) => {
31
32
  let timeout: ReturnType<typeof setTimeout> | undefined;
33
+ let completion: ReturnType<typeof setTimeout> | undefined;
34
+ let finished = false;
35
+ const finish = (result: LoginResult | null) => {
36
+ if (finished) return;
37
+ finished = true;
38
+ clearTimeout(timeout);
39
+ clearTimeout(completion);
40
+ options.signal?.removeEventListener("abort", abort);
41
+ server.closeAllConnections();
42
+ server.close();
43
+ resolve(result);
44
+ };
45
+ const abort = () => finish(null);
32
46
  const server = http.createServer(async (req, res) => {
47
+ if (finished) { res.destroy(); return; }
33
48
  const url = new URL(req.url ?? "/", "http://localhost");
34
49
 
35
50
  if (url.pathname === "/callback") {
@@ -85,7 +100,7 @@ export async function runLogin(): Promise<LoginResult | null> {
85
100
  Authorization: `Bearer ${accessToken}`,
86
101
  apikey: SUPABASE_ANON_KEY,
87
102
  },
88
- signal: AbortSignal.timeout(10_000),
103
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
89
104
  });
90
105
  if (userRes.ok) {
91
106
  const user = (await userRes.json()) as {
@@ -95,12 +110,14 @@ export async function runLogin(): Promise<LoginResult | null> {
95
110
  userId = user.id;
96
111
  email = user.email ?? "";
97
112
 
98
- const profile = await fetchProMembership({ userId, accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
113
+ const profile = await fetchProMembership({ userId, accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY, signal: options.signal });
99
114
  plan = profile.plan;
100
115
  membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
101
116
  }
102
117
  } catch {}
103
118
 
119
+ if (finished || options.signal?.aborted) { res.destroy(); return; }
120
+
104
121
  if (!userId) {
105
122
  res.writeHead(401);
106
123
  res.end("无法验证账号,请重新登录。");
@@ -127,9 +144,8 @@ export async function runLogin(): Promise<LoginResult | null> {
127
144
 
128
145
  // Left pending, the five-minute limit would keep the command alive long after the login.
129
146
  clearTimeout(timeout);
130
- setTimeout(() => {
131
- server.close();
132
- resolve({
147
+ completion = setTimeout(() => {
148
+ finish({
133
149
  success: true,
134
150
  plan: plan as "pro" | "free",
135
151
  email,
@@ -144,15 +160,17 @@ export async function runLogin(): Promise<LoginResult | null> {
144
160
  res.end("Not found");
145
161
  });
146
162
 
147
- server.on("error", () => { clearTimeout(timeout); resolve(null); });
163
+ server.on("error", () => finish(null));
164
+ options.signal?.addEventListener("abort", abort, { once: true });
148
165
 
149
166
  server.listen(0, "127.0.0.1", () => {
167
+ if (finished) return;
150
168
  const addr = server.address();
151
169
  if (!addr || typeof addr === "string") {
152
170
  process.stderr.write(
153
171
  " \x1b[31m✗\x1b[0m Failed to start local server\n\n",
154
172
  );
155
- resolve(null);
173
+ finish(null);
156
174
  return;
157
175
  }
158
176
 
@@ -177,8 +195,7 @@ export async function runLogin(): Promise<LoginResult | null> {
177
195
  // Timeout after 5 minutes
178
196
  timeout = setTimeout(() => {
179
197
  process.stderr.write(" Login timed out.\n\n");
180
- server.close();
181
- resolve(null);
198
+ finish(null);
182
199
  }, 5 * 60 * 1000);
183
200
  });
184
201
  });
package/src/index.ts CHANGED
@@ -32,6 +32,22 @@ program.command("desktop", { hidden: true })
32
32
  .option("--action <action>", "snapshot, start, stop or restart", "snapshot")
33
33
  .action(async (options) => { const { runDesktop } = await import("./commands/desktop.js"); await runDesktop(pkg.version, options.action); });
34
34
 
35
+ program.command("desktop-workspace", { hidden: true })
36
+ .requiredOption("--assets <directory>", "Absolute path to the bundled workspace assets")
37
+ .action(async (options) => {
38
+ const { runDesktopWorkspace } = await import("./commands/desktop-workspace.js");
39
+ try {
40
+ await runDesktopWorkspace(options.assets);
41
+ // This owned command has flushed persistent writes and closed its servers. Legacy
42
+ // account/Host probes may still hold sockets; none should outlive the desktop UI.
43
+ process.exit(0);
44
+ }
45
+ catch {
46
+ process.stderr.write("桌面工作台无法启动,请检查安装资源与本地存储后重试。\n");
47
+ process.exitCode = 1;
48
+ }
49
+ });
50
+
35
51
  // ── host ────────────────────────────────────────────────────────────
36
52
 
37
53
  const hostCmd = program