linkshell-cli 0.10.74 → 0.10.76

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 (134) hide show
  1. package/dist/cli/src/commands/desktop-workspace.js +62 -9
  2. package/dist/cli/src/commands/desktop-workspace.js.map +1 -1
  3. package/dist/cli/src/commands/desktop.d.ts +90 -74
  4. package/dist/cli/src/commands/desktop.js +87 -1
  5. package/dist/cli/src/commands/desktop.js.map +1 -1
  6. package/dist/cli/src/commands/login-error.d.ts +9 -0
  7. package/dist/cli/src/commands/login-error.js +14 -0
  8. package/dist/cli/src/commands/login-error.js.map +1 -0
  9. package/dist/cli/src/commands/login.d.ts +2 -7
  10. package/dist/cli/src/commands/login.js +23 -52
  11. package/dist/cli/src/commands/login.js.map +1 -1
  12. package/dist/cli/src/commands/sign-in.d.ts +77 -0
  13. package/dist/cli/src/commands/sign-in.js +390 -0
  14. package/dist/cli/src/commands/sign-in.js.map +1 -0
  15. package/dist/cli/src/index.js +2 -1
  16. package/dist/cli/src/index.js.map +1 -1
  17. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  18. package/dist/client-core/src/account-errors.d.ts +17 -0
  19. package/dist/client-core/src/account-errors.js +82 -0
  20. package/dist/client-core/src/account-errors.js.map +1 -0
  21. package/dist/host/src/acp-settings.js +1 -1
  22. package/dist/host/src/acp-settings.js.map +1 -1
  23. package/dist/host/src/computer-preview.d.ts +1 -9
  24. package/dist/host/src/drivers/acp/antigravity.d.ts +13 -0
  25. package/dist/host/src/drivers/acp/antigravity.js +13 -0
  26. package/dist/host/src/drivers/acp/antigravity.js.map +1 -1
  27. package/dist/host/src/drivers/acp/driver.d.ts +14 -1
  28. package/dist/host/src/drivers/acp/driver.js +45 -3
  29. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  30. package/dist/host/src/drivers/acp/mapper.d.ts +2 -0
  31. package/dist/host/src/drivers/acp/mapper.js +6 -1
  32. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  33. package/dist/host/src/drivers/acp/questions.js +2 -2
  34. package/dist/host/src/drivers/acp/questions.js.map +1 -1
  35. package/dist/host/src/drivers/claude/app-sessions.d.ts +58 -0
  36. package/dist/host/src/drivers/claude/app-sessions.js +183 -0
  37. package/dist/host/src/drivers/claude/app-sessions.js.map +1 -0
  38. package/dist/host/src/drivers/claude/driver.d.ts +22 -1
  39. package/dist/host/src/drivers/claude/driver.js +99 -11
  40. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  41. package/dist/host/src/drivers/claude/holders.d.ts +3 -1
  42. package/dist/host/src/drivers/claude/holders.js +4 -0
  43. package/dist/host/src/drivers/claude/holders.js.map +1 -1
  44. package/dist/host/src/drivers/claude/routing.d.ts +19 -0
  45. package/dist/host/src/drivers/claude/routing.js +145 -0
  46. package/dist/host/src/drivers/claude/routing.js.map +1 -0
  47. package/dist/host/src/drivers/claude/tasks.d.ts +2 -2
  48. package/dist/host/src/drivers/claude/transcript.d.ts +389 -344
  49. package/dist/host/src/drivers/claude/transcript.js +97 -22
  50. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  51. package/dist/host/src/drivers/codex/driver.d.ts +15 -0
  52. package/dist/host/src/drivers/codex/driver.js +47 -9
  53. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  54. package/dist/host/src/drivers/codex/mapper.d.ts +41 -7
  55. package/dist/host/src/drivers/codex/mapper.js +167 -26
  56. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  57. package/dist/host/src/drivers/codex/mcp-apps.d.ts +69 -0
  58. package/dist/host/src/drivers/codex/mcp-apps.js +340 -0
  59. package/dist/host/src/drivers/codex/mcp-apps.js.map +1 -0
  60. package/dist/host/src/drivers/codex/settings.d.ts +13 -0
  61. package/dist/host/src/drivers/codex/settings.js +22 -6
  62. package/dist/host/src/drivers/codex/settings.js.map +1 -1
  63. package/dist/host/src/drivers/types.d.ts +50 -1
  64. package/dist/host/src/fs.d.ts +9 -9
  65. package/dist/host/src/hub.d.ts +120 -83
  66. package/dist/host/src/hub.js +157 -13
  67. package/dist/host/src/hub.js.map +1 -1
  68. package/dist/host/src/input.d.ts +43 -89
  69. package/dist/host/src/input.js +1 -1
  70. package/dist/host/src/input.js.map +1 -1
  71. package/dist/host/src/rpc/mcp.d.ts +8 -0
  72. package/dist/host/src/rpc/mcp.js +33 -0
  73. package/dist/host/src/rpc/mcp.js.map +1 -0
  74. package/dist/host/src/rpc/server.js +16 -2
  75. package/dist/host/src/rpc/server.js.map +1 -1
  76. package/dist/host/src/screen-session.d.ts +3 -17
  77. package/dist/host/src/screen-session.js +2 -2
  78. package/dist/host/src/screen-session.js.map +1 -1
  79. package/dist/host/src/store.d.ts +7 -0
  80. package/dist/host/src/store.js +27 -3
  81. package/dist/host/src/store.js.map +1 -1
  82. package/dist/host/src/terminals.d.ts +12 -6
  83. package/dist/host/src/terminals.js +29 -2
  84. package/dist/host/src/terminals.js.map +1 -1
  85. package/dist/host/src/usage.d.ts +5 -0
  86. package/dist/host/src/usage.js +10 -2
  87. package/dist/host/src/usage.js.map +1 -1
  88. package/dist/wire/src/acp.d.ts +68 -430
  89. package/dist/wire/src/acp.js +5 -5
  90. package/dist/wire/src/acp.js.map +1 -1
  91. package/dist/wire/src/async-questions.d.ts +2 -10
  92. package/dist/wire/src/computer-preview.d.ts +1 -15
  93. package/dist/wire/src/computer-preview.js +1 -1
  94. package/dist/wire/src/computer-preview.js.map +1 -1
  95. package/dist/wire/src/content.d.ts +205 -370
  96. package/dist/wire/src/content.js +4 -4
  97. package/dist/wire/src/content.js.map +1 -1
  98. package/dist/wire/src/editor.d.ts +48 -592
  99. package/dist/wire/src/envelopes.d.ts +53 -0
  100. package/dist/wire/src/envelopes.js +263 -0
  101. package/dist/wire/src/envelopes.js.map +1 -0
  102. package/dist/wire/src/goal.d.ts +15 -48
  103. package/dist/wire/src/index.d.ts +2 -0
  104. package/dist/wire/src/index.js +2 -0
  105. package/dist/wire/src/index.js.map +1 -1
  106. package/dist/wire/src/live-activity.d.ts +83 -212
  107. package/dist/wire/src/live-activity.js +1 -1
  108. package/dist/wire/src/live-activity.js.map +1 -1
  109. package/dist/wire/src/model.d.ts +454 -2277
  110. package/dist/wire/src/model.js +24 -0
  111. package/dist/wire/src/model.js.map +1 -1
  112. package/dist/wire/src/relay.d.ts +41 -267
  113. package/dist/wire/src/relay.js +1 -1
  114. package/dist/wire/src/relay.js.map +1 -1
  115. package/dist/wire/src/rpc-mcp.d.ts +84 -0
  116. package/dist/wire/src/rpc-mcp.js +78 -0
  117. package/dist/wire/src/rpc-mcp.js.map +1 -0
  118. package/dist/wire/src/rpc.d.ts +6747 -30929
  119. package/dist/wire/src/rpc.js +35 -7
  120. package/dist/wire/src/rpc.js.map +1 -1
  121. package/dist/wire/src/side.d.ts +13 -61
  122. package/dist/wire/src/updates.d.ts +1743 -6652
  123. package/dist/wire/src/updates.js +14 -1
  124. package/dist/wire/src/updates.js.map +1 -1
  125. package/dist/wire/src/usage.d.ts +17 -195
  126. package/dist/wire/src/watch.d.ts +223 -1141
  127. package/dist/wire/src/workflow.d.ts +46 -139
  128. package/package.json +4 -4
  129. package/src/commands/desktop-workspace.ts +69 -11
  130. package/src/commands/desktop.ts +71 -1
  131. package/src/commands/login-error.ts +12 -0
  132. package/src/commands/login.ts +13 -62
  133. package/src/commands/sign-in.ts +408 -0
  134. package/src/index.ts +2 -1
@@ -1,14 +1,35 @@
1
1
  import { z } from "zod";
2
- export declare const workflowStateSchema: z.ZodEnum<["running", "paused", "completed", "failed", "stopped"]>;
2
+ export declare const workflowStateSchema: z.ZodEnum<{
3
+ paused: "paused";
4
+ completed: "completed";
5
+ failed: "failed";
6
+ running: "running";
7
+ stopped: "stopped";
8
+ }>;
3
9
  export type WorkflowState = z.infer<typeof workflowStateSchema>;
4
- export declare const workflowAgentStateSchema: z.ZodEnum<["pending", "running", "paused", "completed", "failed", "stopped", "unknown"]>;
10
+ export declare const workflowAgentStateSchema: z.ZodEnum<{
11
+ paused: "paused";
12
+ unknown: "unknown";
13
+ pending: "pending";
14
+ completed: "completed";
15
+ failed: "failed";
16
+ running: "running";
17
+ stopped: "stopped";
18
+ }>;
5
19
  export type WorkflowAgentState = z.infer<typeof workflowAgentStateSchema>;
6
20
  export declare const workflowAgentSchema: z.ZodObject<{
7
21
  id: z.ZodString;
8
- /** Present only after the host has imported this agent's transcript. */
9
22
  toolCallId: z.ZodOptional<z.ZodString>;
10
23
  title: z.ZodString;
11
- state: z.ZodEnum<["pending", "running", "paused", "completed", "failed", "stopped", "unknown"]>;
24
+ state: z.ZodEnum<{
25
+ paused: "paused";
26
+ unknown: "unknown";
27
+ pending: "pending";
28
+ completed: "completed";
29
+ failed: "failed";
30
+ running: "running";
31
+ stopped: "stopped";
32
+ }>;
12
33
  phaseId: z.ZodOptional<z.ZodString>;
13
34
  model: z.ZodOptional<z.ZodString>;
14
35
  tokens: z.ZodOptional<z.ZodNumber>;
@@ -17,54 +38,25 @@ export declare const workflowAgentSchema: z.ZodObject<{
17
38
  startedAt: z.ZodOptional<z.ZodNumber>;
18
39
  endedAt: z.ZodOptional<z.ZodNumber>;
19
40
  result: z.ZodOptional<z.ZodString>;
20
- }, "strip", z.ZodTypeAny, {
21
- title: string;
22
- id: string;
23
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
24
- toolCallId?: string | undefined;
25
- phaseId?: string | undefined;
26
- model?: string | undefined;
27
- tokens?: number | undefined;
28
- toolCalls?: number | undefined;
29
- durationMs?: number | undefined;
30
- startedAt?: number | undefined;
31
- endedAt?: number | undefined;
32
- result?: string | undefined;
33
- }, {
34
- title: string;
35
- id: string;
36
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
37
- toolCallId?: string | undefined;
38
- phaseId?: string | undefined;
39
- model?: string | undefined;
40
- tokens?: number | undefined;
41
- toolCalls?: number | undefined;
42
- durationMs?: number | undefined;
43
- startedAt?: number | undefined;
44
- endedAt?: number | undefined;
45
- result?: string | undefined;
46
- }>;
41
+ }, z.core.$strip>;
47
42
  export type WorkflowAgent = z.infer<typeof workflowAgentSchema>;
48
43
  export declare const workflowPhaseSchema: z.ZodObject<{
49
44
  id: z.ZodString;
50
45
  title: z.ZodString;
51
46
  order: z.ZodNumber;
52
- }, "strip", z.ZodTypeAny, {
53
- title: string;
54
- id: string;
55
- order: number;
56
- }, {
57
- title: string;
58
- id: string;
59
- order: number;
60
- }>;
47
+ }, z.core.$strip>;
61
48
  export type WorkflowPhase = z.infer<typeof workflowPhaseSchema>;
62
49
  /** A complete run snapshot, independent of the loaded conversation window. */
63
50
  export declare const workflowSchema: z.ZodObject<{
64
51
  runId: z.ZodOptional<z.ZodString>;
65
52
  name: z.ZodOptional<z.ZodString>;
66
- state: z.ZodOptional<z.ZodEnum<["running", "paused", "completed", "failed", "stopped"]>>;
67
- /** Observed counts: a dynamic script may start more agents later. */
53
+ state: z.ZodOptional<z.ZodEnum<{
54
+ paused: "paused";
55
+ completed: "completed";
56
+ failed: "failed";
57
+ running: "running";
58
+ stopped: "stopped";
59
+ }>>;
68
60
  started: z.ZodOptional<z.ZodNumber>;
69
61
  completed: z.ZodOptional<z.ZodNumber>;
70
62
  tokens: z.ZodOptional<z.ZodNumber>;
@@ -75,21 +67,20 @@ export declare const workflowSchema: z.ZodObject<{
75
67
  id: z.ZodString;
76
68
  title: z.ZodString;
77
69
  order: z.ZodNumber;
78
- }, "strip", z.ZodTypeAny, {
79
- title: string;
80
- id: string;
81
- order: number;
82
- }, {
83
- title: string;
84
- id: string;
85
- order: number;
86
- }>, "many">>;
70
+ }, z.core.$strip>>>;
87
71
  agents: z.ZodOptional<z.ZodArray<z.ZodObject<{
88
72
  id: z.ZodString;
89
- /** Present only after the host has imported this agent's transcript. */
90
73
  toolCallId: z.ZodOptional<z.ZodString>;
91
74
  title: z.ZodString;
92
- state: z.ZodEnum<["pending", "running", "paused", "completed", "failed", "stopped", "unknown"]>;
75
+ state: z.ZodEnum<{
76
+ paused: "paused";
77
+ unknown: "unknown";
78
+ pending: "pending";
79
+ completed: "completed";
80
+ failed: "failed";
81
+ running: "running";
82
+ stopped: "stopped";
83
+ }>;
93
84
  phaseId: z.ZodOptional<z.ZodString>;
94
85
  model: z.ZodOptional<z.ZodString>;
95
86
  tokens: z.ZodOptional<z.ZodNumber>;
@@ -98,90 +89,6 @@ export declare const workflowSchema: z.ZodObject<{
98
89
  startedAt: z.ZodOptional<z.ZodNumber>;
99
90
  endedAt: z.ZodOptional<z.ZodNumber>;
100
91
  result: z.ZodOptional<z.ZodString>;
101
- }, "strip", z.ZodTypeAny, {
102
- title: string;
103
- id: string;
104
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
105
- toolCallId?: string | undefined;
106
- phaseId?: string | undefined;
107
- model?: string | undefined;
108
- tokens?: number | undefined;
109
- toolCalls?: number | undefined;
110
- durationMs?: number | undefined;
111
- startedAt?: number | undefined;
112
- endedAt?: number | undefined;
113
- result?: string | undefined;
114
- }, {
115
- title: string;
116
- id: string;
117
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
118
- toolCallId?: string | undefined;
119
- phaseId?: string | undefined;
120
- model?: string | undefined;
121
- tokens?: number | undefined;
122
- toolCalls?: number | undefined;
123
- durationMs?: number | undefined;
124
- startedAt?: number | undefined;
125
- endedAt?: number | undefined;
126
- result?: string | undefined;
127
- }>, "many">>;
128
- }, "strip", z.ZodTypeAny, {
129
- name?: string | undefined;
130
- completed?: number | undefined;
131
- state?: "paused" | "completed" | "failed" | "running" | "stopped" | undefined;
132
- tokens?: number | undefined;
133
- durationMs?: number | undefined;
134
- startedAt?: number | undefined;
135
- endedAt?: number | undefined;
136
- runId?: string | undefined;
137
- started?: number | undefined;
138
- phases?: {
139
- title: string;
140
- id: string;
141
- order: number;
142
- }[] | undefined;
143
- agents?: {
144
- title: string;
145
- id: string;
146
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
147
- toolCallId?: string | undefined;
148
- phaseId?: string | undefined;
149
- model?: string | undefined;
150
- tokens?: number | undefined;
151
- toolCalls?: number | undefined;
152
- durationMs?: number | undefined;
153
- startedAt?: number | undefined;
154
- endedAt?: number | undefined;
155
- result?: string | undefined;
156
- }[] | undefined;
157
- }, {
158
- name?: string | undefined;
159
- completed?: number | undefined;
160
- state?: "paused" | "completed" | "failed" | "running" | "stopped" | undefined;
161
- tokens?: number | undefined;
162
- durationMs?: number | undefined;
163
- startedAt?: number | undefined;
164
- endedAt?: number | undefined;
165
- runId?: string | undefined;
166
- started?: number | undefined;
167
- phases?: {
168
- title: string;
169
- id: string;
170
- order: number;
171
- }[] | undefined;
172
- agents?: {
173
- title: string;
174
- id: string;
175
- state: "unknown" | "paused" | "pending" | "completed" | "failed" | "running" | "stopped";
176
- toolCallId?: string | undefined;
177
- phaseId?: string | undefined;
178
- model?: string | undefined;
179
- tokens?: number | undefined;
180
- toolCalls?: number | undefined;
181
- durationMs?: number | undefined;
182
- startedAt?: number | undefined;
183
- endedAt?: number | undefined;
184
- result?: string | undefined;
185
- }[] | undefined;
186
- }>;
92
+ }, z.core.$strip>>>;
93
+ }, z.core.$strip>;
187
94
  export type Workflow = z.infer<typeof workflowSchema>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkshell-cli",
3
- "version": "0.10.74",
3
+ "version": "0.10.76",
4
4
  "description": "Follow, steer and approve the coding agents on your computer (Claude Code, Codex, Gemini, Copilot and more) from your phone",
5
5
  "type": "module",
6
6
  "main": "dist/cli/src/index.js",
@@ -34,9 +34,9 @@
34
34
  "node-pty": "^1.1.0",
35
35
  "qrcode-terminal": "^0.12.0",
36
36
  "ws": "^8.21.0",
37
- "@linkshell/gateway": "0.6.39",
38
- "@linkshell/wire": "0.2.21",
39
- "@linkshell/host": "0.4.69"
37
+ "@linkshell/gateway": "0.6.41",
38
+ "@linkshell/host": "0.4.71",
39
+ "@linkshell/wire": "0.2.23"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/ws": "^8.5.14",
@@ -9,7 +9,9 @@ import WebSocket, { WebSocketServer } from "ws";
9
9
  import { localWebSocket } from "@linkshell/host/local-transport";
10
10
  import { isMethodName, machineInfoSchema, rpcMessageSchema, type MachineEntry } from "@linkshell/wire";
11
11
  import { getValidToken, loadAuth } from "../auth.js";
12
- import { runLogin } from "./login.js";
12
+ import { runLogin, type LoginResult } from "./login.js";
13
+ import { loginWithOAuth, loginWithPassword, openInBrowser } from "./sign-in.js";
14
+ import { LoginError } from "./login-error.js";
13
15
  import { runLogout } from "./logout.js";
14
16
  import { followAccountGateway, refreshGateway, silenceSqliteWarning } from "./host.js";
15
17
  import { DesktopClientState, validStorageKey } from "./desktop-workspace-state.js";
@@ -68,6 +70,38 @@ async function body(request: IncomingMessage): Promise<Record<string, unknown>>
68
70
  throw new HttpError(400, "无效的 JSON 请求");
69
71
  }
70
72
 
73
+ /** POST bodies that may be empty: an empty body is `{}` (what older pages send). */
74
+ async function optionalBody(request: IncomingMessage): Promise<Record<string, unknown>> {
75
+ if (request.headers["content-type"] === undefined && !Number(request.headers["content-length"] ?? 0) && !request.headers["transfer-encoding"]) {
76
+ request.resume();
77
+ return {};
78
+ }
79
+ return body(request);
80
+ }
81
+
82
+ type LoginMethod = "web" | "password" | "github" | "google";
83
+ const LOGIN_METHODS: readonly LoginMethod[] = ["web", "password", "github", "google"];
84
+
85
+ interface LoginRequest { method: LoginMethod; email?: string; password?: string }
86
+
87
+ function loginRequest(input: Record<string, unknown>): LoginRequest {
88
+ const method = input.method ?? "web";
89
+ if (typeof method !== "string" || !LOGIN_METHODS.includes(method as LoginMethod)) throw new HttpError(400, "不支持的登录方式");
90
+ if (method !== "password") return { method: method as LoginMethod };
91
+ const { email, password } = input;
92
+ if (typeof email !== "string" || typeof password !== "string" || !email.trim() || !password) throw new HttpError(400, "请输入邮箱和密码。");
93
+ if (email.length > 320 || password.length > 1024) throw new HttpError(400, "登录信息过长,请检查邮箱和密码");
94
+ return { method: "password", email, password };
95
+ }
96
+
97
+ interface PendingLogin {
98
+ method: LoginMethod;
99
+ abort: AbortController;
100
+ result: Promise<LoginResult | null>;
101
+ /** The provider's authorize URL, once the loopback is listening (GitHub / Google only). */
102
+ url?: string;
103
+ }
104
+
71
105
  async function loadAssets(directory: string): Promise<Map<string, Buffer>> {
72
106
  if (!isAbsolute(directory)) throw new Error("桌面工作台资源目录必须使用绝对路径");
73
107
  const assets = new Map<string, Buffer>();
@@ -219,8 +253,20 @@ export async function startDesktopWorkspace(options: { assets: string; home?: st
219
253
  let workspaceHost = "";
220
254
  let previewOrigin = "";
221
255
  let previewHost = "";
222
- let login: Promise<Awaited<ReturnType<typeof runLogin>>> | undefined;
223
- let loginAbort: AbortController | undefined;
256
+ let login: PendingLogin | undefined;
257
+ const startLogin = (request: LoginRequest): PendingLogin => {
258
+ // One sign-in at a time: choosing another way ends the one still waiting on the browser.
259
+ login?.abort.abort();
260
+ const abort = new AbortController();
261
+ const pending: PendingLogin = { method: request.method, abort, result: Promise.resolve(null) };
262
+ const signal = abort.signal;
263
+ pending.result = (request.method === "web" ? runLogin({ signal })
264
+ : request.method === "password" ? loginWithPassword({ email: request.email!, password: request.password!, signal })
265
+ : loginWithOAuth({ provider: request.method, signal, open: (url) => { pending.url = url; openInBrowser(url); } }))
266
+ .finally(() => { if (login === pending) login = undefined; });
267
+ login = pending;
268
+ return pending;
269
+ };
224
270
  let stopping: Promise<void> | undefined;
225
271
  const peers = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PROXY_BYTES });
226
272
  const upstreams = new Set<WebSocket>();
@@ -261,18 +307,30 @@ export async function startDesktopWorkspace(options: { assets: string; home?: st
261
307
  json(response, 200, { ok: true }); return;
262
308
  }
263
309
  if (request.method === "POST" && path === "/desktop/login") {
264
- if (!login) {
265
- loginAbort = new AbortController();
266
- login = runLogin({ signal: loginAbort.signal }).finally(() => { login = undefined; loginAbort = undefined; });
310
+ const wanted = loginRequest(await optionalBody(request));
311
+ // A page that reloaded while the browser was open rejoins the same sign-in.
312
+ const pending = login && login.method === wanted.method && wanted.method !== "password" ? login : startLogin(wanted);
313
+ let result: LoginResult | null;
314
+ try { result = await pending.result; }
315
+ catch (error) {
316
+ // Only our own messages reach the page; anything else may name paths or tokens.
317
+ throw new HttpError(409, error instanceof LoginError ? error.message : "登录未完成,请重试");
267
318
  }
268
- const result = await login;
269
- if (!result?.success) throw new HttpError(409, "登录未完成,请重试");
319
+ if (!result?.success) throw new HttpError(409, pending.abort.signal.aborted ? "已取消登录。" : "登录未完成,请重试");
270
320
  await accountChanged(result.plan);
271
321
  json(response, 200, await account(accountAbort.signal)); return;
272
322
  }
323
+ if (request.method === "GET" && path === "/desktop/login") {
324
+ json(response, 200, { pending: login ? { method: login.method, ...(login.url ? { url: login.url } : {}) } : null }); return;
325
+ }
326
+ if (request.method === "DELETE" && path === "/desktop/login") {
327
+ login?.abort.abort();
328
+ json(response, 200, {}); return;
329
+ }
273
330
  if (request.method === "POST" && path === "/desktop/logout") {
274
- loginAbort?.abort();
275
- await login;
331
+ const pending = login;
332
+ pending?.abort.abort();
333
+ await pending?.result.catch(() => undefined);
276
334
  await runLogout();
277
335
  await accountChanged();
278
336
  json(response, 200, {}); return;
@@ -328,7 +386,7 @@ export async function startDesktopWorkspace(options: { assets: string; home?: st
328
386
  stop() {
329
387
  return stopping ??= (async () => {
330
388
  accountAbort.abort();
331
- loginAbort?.abort();
389
+ login?.abort.abort();
332
390
  for (const client of peers.clients) client.terminate();
333
391
  for (const upstream of upstreams) upstream.terminate();
334
392
  for (const socket of sockets) socket.destroy();
@@ -1,5 +1,6 @@
1
1
  import { loadAuth } from "../auth.js";
2
2
  import { runLogin } from "./login.js";
3
+ import { loginWithOAuth, loginWithPassword, openInBrowser, OAUTH_PROVIDERS, type OAuthProvider } from "./sign-in.js";
3
4
  import { runLogout } from "./logout.js";
4
5
  import { followAccountGateway, joinGatewayAfterLogin, readHostConfig, writeHostConfig, refreshGateway } from "./host.js";
5
6
  import { execFile, spawn } from "node:child_process";
@@ -43,6 +44,32 @@ export async function desktopSnapshot(version: string) {
43
44
  }
44
45
 
45
46
  /** The new password, as JSON on stdin (never on the command line, where `ps` shows it). */
47
+ /**
48
+ * The gateway the window chose: `off`, or the address of one's own (ws, wss, http or https; the
49
+ * host speaks WebSocket to either). The official one is `connect-gateway`.
50
+ */
51
+ export function gatewayChoice(raw: unknown): string {
52
+ if (raw === "off") return "off";
53
+ if (typeof raw !== "string" || !raw.trim() || raw.length > 2048) throw new Error("请输入网关地址");
54
+ let url: URL;
55
+ try { url = new URL(raw.trim()); } catch { throw new Error("网关地址格式不正确,例如 wss://gateway.example.com"); }
56
+ if (!["ws:", "wss:", "http:", "https:"].includes(url.protocol) || !url.hostname) throw new Error("网关地址需要以 wss://、ws://、https:// 或 http:// 开头");
57
+ if (url.username || url.password) throw new Error("网关地址里不能带用户名或密码");
58
+ return url.toString().replace(/\/+$/, "");
59
+ }
60
+
61
+ async function jsonInput(input: AsyncIterable<string | Buffer>): Promise<Record<string, unknown>> {
62
+ const chunks: Buffer[] = [];
63
+ let length = 0;
64
+ for await (const chunk of input) {
65
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
66
+ length += buffer.length;
67
+ if (length > 8192) throw new Error("输入过长");
68
+ chunks.push(buffer);
69
+ }
70
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")) ?? {}; } catch { return {}; }
71
+ }
72
+
46
73
  async function passwordInput(input: AsyncIterable<string | Buffer>): Promise<string> {
47
74
  const chunks: Buffer[] = [];
48
75
  let length = 0;
@@ -58,6 +85,25 @@ async function passwordInput(input: AsyncIterable<string | Buffer>): Promise<str
58
85
  return password;
59
86
  }
60
87
 
88
+ /** Email and password, as JSON on stdin like the access password: never on the command line. */
89
+ async function credentialsInput(input: AsyncIterable<string | Buffer>): Promise<{ email: string; password: string }> {
90
+ const chunks: Buffer[] = [];
91
+ let length = 0;
92
+ for await (const chunk of input) {
93
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
94
+ length += buffer.length;
95
+ if (length > 4096) throw new Error("登录信息过长,请检查邮箱和密码");
96
+ chunks.push(buffer);
97
+ }
98
+ try {
99
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
100
+ if (typeof value?.email === "string" && typeof value?.password === "string" && value.email.trim() && value.password) {
101
+ return { email: value.email, password: value.password };
102
+ }
103
+ } catch { /* Never echo an input that contains a password. */ }
104
+ throw new Error("请输入邮箱和密码。");
105
+ }
106
+
61
107
  async function redemptionInput(input: AsyncIterable<string | Buffer>): Promise<{ code: string; userId: string }> {
62
108
  const chunks: Buffer[] = [];
63
109
  let length = 0;
@@ -74,7 +120,7 @@ async function redemptionInput(input: AsyncIterable<string | Buffer>): Promise<{
74
120
  throw new Error("激活码请求无效,请刷新账号后重试");
75
121
  }
76
122
 
77
- export async function runDesktop(version: string, action: string, input: AsyncIterable<string | Buffer> = process.stdin): Promise<void> {
123
+ export async function runDesktop(version: string, action: string, input: AsyncIterable<string | Buffer> = process.stdin, options: { provider?: string } = {}): Promise<void> {
78
124
  silenceSqliteWarning();
79
125
  try {
80
126
  if (action === "redeem-pro") {
@@ -130,6 +176,23 @@ export async function runDesktop(version: string, action: string, input: AsyncIt
130
176
  if (!result) throw new Error("登录未完成或已超时,请重试。");
131
177
  await joinGatewayAfterLogin(result.plan);
132
178
  }
179
+ else if (action === "login-password") {
180
+ const result = await loginWithPassword(await credentialsInput(input));
181
+ await joinGatewayAfterLogin(result.plan);
182
+ }
183
+ else if (action === "login-oauth") {
184
+ const provider = options.provider as OAuthProvider;
185
+ if (!OAUTH_PROVIDERS.includes(provider)) throw new Error("不支持的登录方式");
186
+ const result = await loginWithOAuth({
187
+ provider,
188
+ // A line before the result: the window shows the link to reopen or copy while it waits.
189
+ open: (url) => {
190
+ process.stdout.write(`${JSON.stringify({ event: "authorize", provider, url })}\n`);
191
+ openInBrowser(url);
192
+ },
193
+ });
194
+ await joinGatewayAfterLogin(result.plan);
195
+ }
133
196
  else if (action === "logout") await runLogout();
134
197
  else if (action === "connect-gateway") {
135
198
  if (!loadAuth()?.userId) throw new Error("请先登录账号。");
@@ -140,6 +203,13 @@ export async function runDesktop(version: string, action: string, input: AsyncIt
140
203
  await ensureHostRunning();
141
204
  await refreshGateway(15_000);
142
205
  }
206
+ else if (action === "set-gateway") {
207
+ if (process.env.LINKSHELL_GATEWAY) throw new Error("网关由启动环境指定,请移除 LINKSHELL_GATEWAY 后重新打开 App。");
208
+ const gateway = gatewayChoice((await jsonInput(input)).gateway);
209
+ writeHostConfig(linkshellDir(), { ...readHostConfig(linkshellDir()), gateway });
210
+ await ensureHostRunning();
211
+ await refreshGateway(15_000);
212
+ }
143
213
  else if (action === "set-password") await applyAccessPassword(await passwordInput(input));
144
214
  else if (action === "remove-password") await applyAccessPassword(undefined);
145
215
  else if (action === "prepare-update") { await prepareDesktopUpdate(); }
@@ -0,0 +1,12 @@
1
+ import { accountErrorMessage, type AccountErrorCode } from "../../../client-core/src/account-errors.js";
2
+
3
+ /**
4
+ * A sign-in that did not finish, with a message the desktop can show as is. Kept apart from the
5
+ * sign-in code so callers (and their tests) can recognise one without loading the network code.
6
+ */
7
+ export class LoginError extends Error {
8
+ constructor(readonly code: AccountErrorCode) {
9
+ super(accountErrorMessage(code));
10
+ this.name = "LoginError";
11
+ }
12
+ }
@@ -1,9 +1,8 @@
1
1
  import * as http from "node:http";
2
2
  import { execFileSync } from "node:child_process";
3
- import { saveAuth, SUPABASE_URL, SUPABASE_ANON_KEY } from "../auth.js";
4
- import type { AuthTokens } from "../auth.js";
5
- import { cacheMembership, type DesktopMembership } from "../pro-membership.js";
6
- import { fetchProMembership } from "../../../client-core/src/pro-membership.js";
3
+ import { completeLogin, type LoginResult } from "./sign-in.js";
4
+
5
+ export type { LoginResult } from "./sign-in.js";
7
6
 
8
7
  const ITOOL_AUTH_URL = "https://itool.tech/en/auth/linkshell";
9
8
 
@@ -105,14 +104,6 @@ const CALLBACK_PAGE = `<!DOCTYPE html>
105
104
  * 4. iTool redirects back to localhost with Supabase tokens
106
105
  * 5. Save tokens to ~/.linkshell/auth.json
107
106
  */
108
- export interface LoginResult {
109
- success: boolean;
110
- plan: "pro" | "free";
111
- email: string;
112
- userId: string;
113
- accessToken: string;
114
- }
115
-
116
107
  export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<LoginResult | null> {
117
108
  if (options.signal?.aborted) return null;
118
109
  process.stderr.write("\n LinkShell Login\n\n");
@@ -121,9 +112,12 @@ export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<
121
112
  let timeout: ReturnType<typeof setTimeout> | undefined;
122
113
  let completion: ReturnType<typeof setTimeout> | undefined;
123
114
  let finished = false;
115
+ // Ends an account check still in flight, so a login that timed out or was cancelled never saves.
116
+ const stopped = new AbortController();
124
117
  const finish = (result: LoginResult | null) => {
125
118
  if (finished) return;
126
119
  finished = true;
120
+ stopped.abort();
127
121
  clearTimeout(timeout);
128
122
  clearTimeout(completion);
129
123
  options.signal?.removeEventListener("abort", abort);
@@ -154,70 +148,27 @@ export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<
154
148
  return;
155
149
  }
156
150
 
157
- // Fetch user info + plan
158
- let userId = "";
159
- let email = "";
160
- let plan = "free";
161
- let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
151
+ let result: LoginResult;
162
152
  try {
163
- const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
164
- headers: {
165
- Authorization: `Bearer ${accessToken}`,
166
- apikey: SUPABASE_ANON_KEY,
167
- },
168
- signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
169
- });
170
- if (userRes.ok) {
171
- const user = (await userRes.json()) as {
172
- id: string;
173
- email?: string;
174
- };
175
- userId = user.id;
176
- email = user.email ?? "";
177
-
178
- const profile = await fetchProMembership({ userId, accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY, signal: options.signal });
179
- plan = profile.plan;
180
- membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
181
- }
182
- } catch {}
183
-
184
- if (finished || options.signal?.aborted) { res.destroy(); return; }
185
-
186
- if (!userId) {
153
+ result = await completeLogin({ accessToken, refreshToken, expiresIn }, { signal: stopped.signal });
154
+ } catch {
155
+ if (finished || options.signal?.aborted) { res.destroy(); return; }
187
156
  res.writeHead(401);
188
157
  res.end("无法验证账号,请重新登录。");
189
158
  return;
190
159
  }
191
160
 
192
- const tokens: AuthTokens = {
193
- accessToken,
194
- refreshToken,
195
- expiresAt: Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000,
196
- userId,
197
- email,
198
- };
199
- saveAuth(tokens);
200
- try { cacheMembership(userId, membership); } catch { /* A cache write cannot invalidate a completed login. */ }
201
-
202
161
  res.writeHead(200);
203
162
  res.end("ok");
204
163
 
205
- const planLabel = plan === "pro" ? "\x1b[32mPro\x1b[0m" : "Free";
164
+ const planLabel = result.plan === "pro" ? "\x1b[32mPro\x1b[0m" : "Free";
206
165
  process.stderr.write(
207
- ` \x1b[32m✓\x1b[0m Logged in as ${email || userId} (${planLabel})\n\n`,
166
+ ` \x1b[32m✓\x1b[0m Logged in as ${result.email || result.userId} (${planLabel})\n\n`,
208
167
  );
209
168
 
210
169
  // Left pending, the five-minute limit would keep the command alive long after the login.
211
170
  clearTimeout(timeout);
212
- completion = setTimeout(() => {
213
- finish({
214
- success: true,
215
- plan: plan as "pro" | "free",
216
- email,
217
- userId,
218
- accessToken,
219
- });
220
- }, 500);
171
+ completion = setTimeout(() => finish(result), 500);
221
172
  return;
222
173
  }
223
174