linkshell-cli 0.10.73 → 0.10.75

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 (110) 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 +15 -2
  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/gateway/src/relay.d.ts +1 -1
  22. package/dist/gateway/src/relay.js +8 -1
  23. package/dist/gateway/src/relay.js.map +1 -1
  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 +23 -2
  28. package/dist/host/src/drivers/acp/driver.js +89 -5
  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/claude/app-sessions.d.ts +58 -0
  34. package/dist/host/src/drivers/claude/app-sessions.js +183 -0
  35. package/dist/host/src/drivers/claude/app-sessions.js.map +1 -0
  36. package/dist/host/src/drivers/claude/driver.d.ts +37 -1
  37. package/dist/host/src/drivers/claude/driver.js +199 -9
  38. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  39. package/dist/host/src/drivers/claude/holders.d.ts +3 -1
  40. package/dist/host/src/drivers/claude/holders.js +4 -0
  41. package/dist/host/src/drivers/claude/holders.js.map +1 -1
  42. package/dist/host/src/drivers/claude/routing.d.ts +19 -0
  43. package/dist/host/src/drivers/claude/routing.js +145 -0
  44. package/dist/host/src/drivers/claude/routing.js.map +1 -0
  45. package/dist/host/src/drivers/claude/transcript.d.ts +10 -0
  46. package/dist/host/src/drivers/claude/transcript.js +97 -22
  47. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  48. package/dist/host/src/drivers/codex/driver.d.ts +45 -2
  49. package/dist/host/src/drivers/codex/driver.js +288 -18
  50. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  51. package/dist/host/src/drivers/codex/mapper.d.ts +41 -7
  52. package/dist/host/src/drivers/codex/mapper.js +167 -26
  53. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  54. package/dist/host/src/drivers/codex/mcp-apps.d.ts +69 -0
  55. package/dist/host/src/drivers/codex/mcp-apps.js +340 -0
  56. package/dist/host/src/drivers/codex/mcp-apps.js.map +1 -0
  57. package/dist/host/src/drivers/codex/settings.d.ts +13 -0
  58. package/dist/host/src/drivers/codex/settings.js +22 -6
  59. package/dist/host/src/drivers/codex/settings.js.map +1 -1
  60. package/dist/host/src/drivers/types.d.ts +82 -1
  61. package/dist/host/src/host.js +2 -1
  62. package/dist/host/src/host.js.map +1 -1
  63. package/dist/host/src/hub.d.ts +48 -1
  64. package/dist/host/src/hub.js +235 -13
  65. package/dist/host/src/hub.js.map +1 -1
  66. package/dist/host/src/rpc/mcp.d.ts +8 -0
  67. package/dist/host/src/rpc/mcp.js +33 -0
  68. package/dist/host/src/rpc/mcp.js.map +1 -0
  69. package/dist/host/src/rpc/server.js +27 -3
  70. package/dist/host/src/rpc/server.js.map +1 -1
  71. package/dist/host/src/side.d.ts +28 -0
  72. package/dist/host/src/side.js +142 -0
  73. package/dist/host/src/side.js.map +1 -0
  74. package/dist/host/src/store.d.ts +7 -0
  75. package/dist/host/src/store.js +27 -3
  76. package/dist/host/src/store.js.map +1 -1
  77. package/dist/host/src/terminals.d.ts +6 -0
  78. package/dist/host/src/terminals.js +29 -2
  79. package/dist/host/src/terminals.js.map +1 -1
  80. package/dist/host/src/usage.d.ts +5 -0
  81. package/dist/host/src/usage.js +10 -2
  82. package/dist/host/src/usage.js.map +1 -1
  83. package/dist/wire/src/envelopes.d.ts +53 -0
  84. package/dist/wire/src/envelopes.js +251 -0
  85. package/dist/wire/src/envelopes.js.map +1 -0
  86. package/dist/wire/src/index.d.ts +3 -0
  87. package/dist/wire/src/index.js +3 -0
  88. package/dist/wire/src/index.js.map +1 -1
  89. package/dist/wire/src/model.d.ts +54 -0
  90. package/dist/wire/src/model.js +28 -0
  91. package/dist/wire/src/model.js.map +1 -1
  92. package/dist/wire/src/rpc-mcp.d.ts +199 -0
  93. package/dist/wire/src/rpc-mcp.js +78 -0
  94. package/dist/wire/src/rpc-mcp.js.map +1 -0
  95. package/dist/wire/src/rpc.d.ts +823 -6
  96. package/dist/wire/src/rpc.js +64 -1
  97. package/dist/wire/src/rpc.js.map +1 -1
  98. package/dist/wire/src/side.d.ts +83 -0
  99. package/dist/wire/src/side.js +26 -0
  100. package/dist/wire/src/side.js.map +1 -0
  101. package/dist/wire/src/updates.d.ts +211 -0
  102. package/dist/wire/src/updates.js +14 -1
  103. package/dist/wire/src/updates.js.map +1 -1
  104. package/package.json +4 -4
  105. package/src/commands/desktop-workspace.ts +69 -11
  106. package/src/commands/desktop.ts +71 -1
  107. package/src/commands/login-error.ts +12 -0
  108. package/src/commands/login.ts +13 -62
  109. package/src/commands/sign-in.ts +408 -0
  110. package/src/index.ts +2 -1
@@ -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
 
@@ -0,0 +1,408 @@
1
+ import * as http from "node:http";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { saveAuth, SUPABASE_ANON_KEY, SUPABASE_URL, type AuthTokens } from "../auth.js";
5
+ import { cacheMembership, type DesktopMembership } from "../pro-membership.js";
6
+ import { fetchProMembership } from "../../../client-core/src/pro-membership.js";
7
+ import { isDefinitiveAccountError, oauthErrorCode, tokenErrorCode } from "../../../client-core/src/account-errors.js";
8
+ import { LoginError } from "./login-error.js";
9
+
10
+ /**
11
+ * Signing in without iTool's authorization page: email and password, or GitHub / Google through
12
+ * the system browser. Every way ends in `completeLogin`, which writes `~/.linkshell/auth.json`
13
+ * (the Host, the CLI and the desktop all read that one file); nothing here hands a refresh
14
+ * token to a browser or a renderer.
15
+ */
16
+
17
+ export interface LoginResult {
18
+ success: boolean;
19
+ plan: "pro" | "free";
20
+ email: string;
21
+ userId: string;
22
+ accessToken: string;
23
+ }
24
+
25
+ /** A Supabase session, as `/auth/v1/token` (and itool.tech's proxy of it) returns it. */
26
+ export interface SessionGrant {
27
+ accessToken: string;
28
+ refreshToken: string;
29
+ /** Seconds. */
30
+ expiresIn: number;
31
+ /** Present for the password and PKCE grants; the authorization page's tokens come without it. */
32
+ user?: { id: string; email?: string };
33
+ }
34
+
35
+ export type OAuthProvider = "github" | "google";
36
+ export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["github", "google"];
37
+
38
+ // itool.tech forwards the exchange for places where supabase.co is blocked but itool.tech is
39
+ // not, as it does for refreshes. LINKSHELL_TOKEN_PROXY=off skips it (the same kill switch).
40
+ export const AUTH_PROXY_URL = "https://itool.tech/api/linkshell/auth";
41
+ // Must match the Supabase redirect allowlist entry `http://127.0.0.1:*/linkshell/callback`.
42
+ // An IP literal rather than `localhost` (RFC 8252 §7.3): nothing can resolve it elsewhere.
43
+ export const OAUTH_CALLBACK_PATH = "/linkshell/callback";
44
+ const OAUTH_DONE_PATH = "/linkshell/done";
45
+ export const OAUTH_TIMEOUT_MS = 5 * 60_000;
46
+ const EXCHANGE_TIMEOUT_MS = 10_000;
47
+ const USER_TIMEOUT_MS = 10_000;
48
+
49
+ type Grant =
50
+ | { grant_type: "password"; email: string; password: string }
51
+ | { grant_type: "pkce"; auth_code: string; code_verifier: string };
52
+
53
+ function record(value: unknown): Record<string, unknown> {
54
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
55
+ }
56
+
57
+ function bounded(signal: AbortSignal | undefined, timeout: AbortSignal): AbortSignal {
58
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
59
+ }
60
+
61
+ async function fetchUser(accessToken: string, signal?: AbortSignal): Promise<{ id: string; email?: string }> {
62
+ let response: Response;
63
+ try {
64
+ response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
65
+ headers: { Authorization: `Bearer ${accessToken}`, apikey: SUPABASE_ANON_KEY },
66
+ signal: bounded(signal, AbortSignal.timeout(USER_TIMEOUT_MS)),
67
+ });
68
+ } catch {
69
+ throw new LoginError(signal?.aborted ? "cancelled" : "unverified");
70
+ }
71
+ const user = record(await response.json().catch(() => undefined));
72
+ if (!response.ok || typeof user.id !== "string" || !user.id) throw new LoginError("unverified");
73
+ return { id: user.id, email: typeof user.email === "string" ? user.email : undefined };
74
+ }
75
+
76
+ /**
77
+ * What every sign-in does once it holds a session: learn whose it is, save it, and note the
78
+ * plan. A membership lookup that fails leaves the plan unknown rather than failing the login;
79
+ * the desktop asks again on its next account refresh.
80
+ */
81
+ export async function completeLogin(grant: SessionGrant, options: { signal?: AbortSignal } = {}): Promise<LoginResult> {
82
+ if (options.signal?.aborted) throw new LoginError("cancelled");
83
+ const user = grant.user ?? await fetchUser(grant.accessToken, options.signal);
84
+ let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
85
+ try {
86
+ const profile = await fetchProMembership({ userId: user.id, accessToken: grant.accessToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY, signal: options.signal });
87
+ membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
88
+ } catch { /* Unknown until the next refresh. */ }
89
+ // The owner gave up while this was in flight: an existing login must stay as it was.
90
+ if (options.signal?.aborted) throw new LoginError("cancelled");
91
+ const email = user.email ?? "";
92
+ const tokens: AuthTokens = {
93
+ accessToken: grant.accessToken,
94
+ refreshToken: grant.refreshToken,
95
+ expiresAt: Date.now() + (Number.isFinite(grant.expiresIn) && grant.expiresIn > 0 ? grant.expiresIn : 3600) * 1000,
96
+ userId: user.id,
97
+ email,
98
+ };
99
+ saveAuth(tokens);
100
+ try { cacheMembership(user.id, membership); } catch { /* A cache write cannot invalidate a completed login. */ }
101
+ return { success: true, plan: membership.plan === "pro" ? "pro" : "free", email, userId: user.id, accessToken: grant.accessToken };
102
+ }
103
+
104
+ function parseGrant(body: unknown): SessionGrant | undefined {
105
+ const json = record(body);
106
+ const user = record(json.user);
107
+ if (typeof json.access_token !== "string" || !json.access_token || typeof json.refresh_token !== "string" || !json.refresh_token) return undefined;
108
+ if (typeof user.id !== "string" || !user.id) return undefined;
109
+ const expiresIn = Number(json.expires_in);
110
+ return {
111
+ accessToken: json.access_token,
112
+ refreshToken: json.refresh_token,
113
+ expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
114
+ user: { id: user.id, email: typeof user.email === "string" ? user.email : undefined },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * A session, a refusal to show (never retried directly: a wrong password stays wrong), or
120
+ * undefined when the proxy gave no verdict (unreachable, absent, failing) and Supabase should be
121
+ * asked directly.
122
+ */
123
+ async function exchangeViaProxy(grant: Grant, signal?: AbortSignal): Promise<SessionGrant | LoginError | undefined> {
124
+ let response: Response;
125
+ try {
126
+ response = await fetch(AUTH_PROXY_URL, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify(grant),
130
+ signal: bounded(signal, AbortSignal.timeout(EXCHANGE_TIMEOUT_MS)),
131
+ });
132
+ } catch {
133
+ return signal?.aborted ? new LoginError("cancelled") : undefined;
134
+ }
135
+ const body = await response.json().catch(() => undefined);
136
+ if (response.ok) return parseGrant(body);
137
+ // Older itool.tech deployments have no such route yet.
138
+ if (response.status === 404) return undefined;
139
+ const code = tokenErrorCode(response.status, body, grant.grant_type);
140
+ return isDefinitiveAccountError(code) ? new LoginError(code) : undefined;
141
+ }
142
+
143
+ async function exchangeDirect(grant: Grant, signal?: AbortSignal): Promise<SessionGrant> {
144
+ const { grant_type: type, ...fields } = grant;
145
+ const timeout = AbortSignal.timeout(EXCHANGE_TIMEOUT_MS);
146
+ let response: Response;
147
+ try {
148
+ response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=${type}`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json", apikey: SUPABASE_ANON_KEY },
151
+ body: JSON.stringify(fields),
152
+ signal: bounded(signal, timeout),
153
+ });
154
+ } catch {
155
+ throw new LoginError(signal?.aborted ? "cancelled" : timeout.aborted ? "timeout" : "network");
156
+ }
157
+ const body = await response.json().catch(() => undefined);
158
+ if (!response.ok) throw new LoginError(tokenErrorCode(response.status, body, type));
159
+ const parsed = parseGrant(body);
160
+ if (!parsed) throw new LoginError("unavailable");
161
+ return parsed;
162
+ }
163
+
164
+ /** Proxy first, then Supabase directly, unless the proxy refused outright. */
165
+ export async function exchangeGrant(grant: Grant, signal?: AbortSignal): Promise<SessionGrant> {
166
+ if (process.env.LINKSHELL_TOKEN_PROXY !== "off") {
167
+ const viaProxy = await exchangeViaProxy(grant, signal);
168
+ if (viaProxy instanceof LoginError) throw viaProxy;
169
+ if (viaProxy) return viaProxy;
170
+ }
171
+ if (signal?.aborted) throw new LoginError("cancelled");
172
+ return exchangeDirect(grant, signal);
173
+ }
174
+
175
+ export async function loginWithPassword(options: { email: string; password: string; signal?: AbortSignal }): Promise<LoginResult> {
176
+ const email = options.email.trim();
177
+ if (!email || !options.password) throw new LoginError("missing_credentials");
178
+ const grant = await exchangeGrant({ grant_type: "password", email, password: options.password }, options.signal);
179
+ return completeLogin(grant, { signal: options.signal });
180
+ }
181
+
182
+ // ── GitHub / Google: PKCE through the system browser, back to a loopback listener ───────────
183
+
184
+ /** RFC 7636 S256. */
185
+ export function pkceChallenge(verifier: string): string {
186
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
187
+ }
188
+
189
+ /** 32 random bytes: a 43-character verifier, the RFC's recommendation. */
190
+ export function pkcePair(): { verifier: string; challenge: string } {
191
+ const verifier = randomBytes(32).toString("base64url");
192
+ return { verifier, challenge: pkceChallenge(verifier) };
193
+ }
194
+
195
+ export function authorizeURL(provider: OAuthProvider, redirectTo: string, challenge: string): string {
196
+ const query = new URLSearchParams({
197
+ provider,
198
+ redirect_to: redirectTo,
199
+ code_challenge: challenge,
200
+ code_challenge_method: "s256",
201
+ // As the app asks: without user:email a GitHub account with a private email has none.
202
+ ...(provider === "github" ? { scopes: "read:user user:email" } : {}),
203
+ });
204
+ return `${SUPABASE_URL}/auth/v1/authorize?${query}`;
205
+ }
206
+
207
+ export function openInBrowser(url: string): void {
208
+ const [command, args] = process.platform === "win32"
209
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
210
+ : [process.platform === "darwin" ? "open" : "xdg-open", [url]];
211
+ try { execFile(command, args, { windowsHide: true }, () => { /* The UI offers the link to copy. */ }); }
212
+ catch { /* As above. */ }
213
+ }
214
+
215
+ type PageState = "ok" | "failed" | "empty" | "busy";
216
+
217
+ // Served by the loopback itself, so it carries its own styles and copy. It shows only an
218
+ // outcome: the code was exchanged in this process and no token ever reaches the browser.
219
+ function loopbackPage(state: PageState | "forward"): string {
220
+ return `<!DOCTYPE html>
221
+ <html lang="zh-CN">
222
+ <head>
223
+ <meta charset="utf-8">
224
+ <meta name="viewport" content="width=device-width, initial-scale=1">
225
+ <meta name="referrer" content="no-referrer">
226
+ <title>LinkShell</title>
227
+ <style>
228
+ :root { color-scheme: dark; --canvas:#09090A; --surface:#111113; --border:#1E1E22; --text:#F4F4F2; --muted:#A8A8AF;
229
+ --brand:#34D399; --brand-bg:#0E1A15; --error:#F87171; --error-bg:#2A1212; }
230
+ @media (prefers-color-scheme: light) {
231
+ :root { color-scheme: light; --canvas:#F2F2EF; --surface:#FFFFFF; --border:#E2E2DD; --text:#0A0A0A; --muted:#52525B;
232
+ --brand:#047857; --brand-bg:#ECFDF5; --error:#B91C1C; --error-bg:#FEF0F0; }
233
+ }
234
+ * { box-sizing: border-box; }
235
+ html, body { height: 100%; margin: 0; }
236
+ body { display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--canvas); color: var(--text);
237
+ font: 15px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Hiragino Sans GB", "Segoe UI", sans-serif;
238
+ -webkit-font-smoothing: antialiased; }
239
+ main { width: 100%; max-width: 380px; padding: 36px 32px 28px; text-align: center; background: var(--surface); border-radius: 24px;
240
+ box-shadow: inset 0 0 0 1px var(--border), 0 24px 64px -16px rgba(0,0,0,.35); }
241
+ .icon { width: 56px; height: 56px; margin: 0 auto 22px; display: flex; align-items: center; justify-content: center; border-radius: 999px;
242
+ background: var(--brand-bg); color: var(--brand); }
243
+ [data-state="failed"] .icon, [data-state="empty"] .icon { background: var(--error-bg); color: var(--error); }
244
+ .icon svg { width: 26px; height: 26px; }
245
+ h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: -.02em; }
246
+ p { margin: 0; color: var(--muted); }
247
+ footer { margin-top: 28px; padding-top: 16px; font-size: 13px; color: var(--muted); box-shadow: inset 0 1px 0 var(--border); }
248
+ .spin { animation: spin 900ms linear infinite; }
249
+ @keyframes spin { to { transform: rotate(360deg); } }
250
+ @media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
251
+ </style>
252
+ </head>
253
+ <body>
254
+ <main id="card" data-state="${state === "forward" ? "busy" : state}" aria-live="polite">
255
+ <div class="icon" id="icon"></div>
256
+ <h1 id="title"></h1>
257
+ <p id="body"></p>
258
+ <footer>LinkShell</footer>
259
+ </main>
260
+ <script>
261
+ var state = ${JSON.stringify(state)};
262
+ ${state === "forward" ? `// Supabase can put an OAuth error in the fragment, which never reaches a server: pass only
263
+ // the error fields on to the listener, nothing else the fragment might hold.
264
+ var fragment = new URLSearchParams(location.hash.slice(1));
265
+ var forward = new URLSearchParams();
266
+ ["error", "error_code", "error_description"].forEach(function (key) { var value = fragment.get(key); if (value) forward.set(key, value); });
267
+ location.replace(forward.toString() ? ${JSON.stringify(OAUTH_CALLBACK_PATH)} + "?" + forward : ${JSON.stringify(OAUTH_DONE_PATH)} + "?status=empty");
268
+ state = "busy";` : ""}
269
+ var zh = /^zh/i.test(navigator.language || "");
270
+ if (!zh) document.documentElement.lang = "en";
271
+ var copy = {
272
+ ok: zh ? ["已完成", "可以回到 LinkShell 了。"] : ["Signed in", "You can return to LinkShell."],
273
+ failed: zh ? ["登录未完成", "请回到 LinkShell 查看原因并重试。"] : ["Sign-in didn't finish", "Return to LinkShell to see why and try again."],
274
+ empty: zh ? ["没有收到登录结果", "请回到 LinkShell 重新登录。"] : ["No sign-in result received", "Return to LinkShell and sign in again."],
275
+ busy: zh ? ["正在完成登录", "请稍候。"] : ["Finishing sign-in", "One moment."]
276
+ };
277
+ var icons = {
278
+ busy: '<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>',
279
+ ok: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
280
+ failed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4.5M12 16h.01"/></svg>'
281
+ };
282
+ document.getElementById("icon").innerHTML = icons[state === "empty" ? "failed" : state];
283
+ document.getElementById("title").textContent = copy[state][0];
284
+ document.getElementById("body").textContent = copy[state][1];
285
+ document.title = copy[state][0] + " · LinkShell";
286
+ </script>
287
+ </body>
288
+ </html>`;
289
+ }
290
+
291
+ const PAGE_HEADERS = {
292
+ "Content-Type": "text/html; charset=utf-8",
293
+ "Cache-Control": "no-store",
294
+ "Referrer-Policy": "no-referrer",
295
+ "X-Content-Type-Options": "nosniff",
296
+ };
297
+
298
+ export interface OAuthLoginOptions {
299
+ provider: OAuthProvider;
300
+ signal?: AbortSignal;
301
+ /** Opens the authorize URL; the system browser by default. Also how a UI learns the URL to copy. */
302
+ open?: (url: string) => void | Promise<void>;
303
+ timeoutMs?: number;
304
+ }
305
+
306
+ /**
307
+ * GitHub / Google sign-in. The browser comes back to `http://127.0.0.1:<port>/linkshell/callback`
308
+ * with a one-time code; the code is worthless without the PKCE verifier, which never leaves this
309
+ * process, so a page or app that sees the redirect gets nothing it can use.
310
+ */
311
+ export function loginWithOAuth(options: OAuthLoginOptions): Promise<LoginResult> {
312
+ if (!OAUTH_PROVIDERS.includes(options.provider)) return Promise.reject(new LoginError("provider_disabled"));
313
+ if (options.signal?.aborted) return Promise.reject(new LoginError("cancelled"));
314
+ const { verifier, challenge } = pkcePair();
315
+
316
+ return new Promise<LoginResult>((resolve, reject) => {
317
+ let phase: "waiting" | "exchanging" | "done" = "waiting";
318
+ let outcome: PageState = "busy";
319
+ let timeout: ReturnType<typeof setTimeout> | undefined;
320
+ let linger: ReturnType<typeof setTimeout> | undefined;
321
+ // Ends an exchange in flight when the owner cancels.
322
+ const stop = new AbortController();
323
+ const signal = options.signal ? AbortSignal.any([options.signal, stop.signal]) : stop.signal;
324
+
325
+ const close = () => {
326
+ clearTimeout(linger);
327
+ server.closeAllConnections();
328
+ server.close();
329
+ };
330
+ const settle = (result: LoginResult | LoginError) => {
331
+ if (phase === "done") return;
332
+ phase = "done";
333
+ clearTimeout(timeout);
334
+ options.signal?.removeEventListener("abort", abort);
335
+ if (result instanceof LoginError) reject(result); else resolve(result);
336
+ // The browser still follows the redirect to the outcome page; don't keep the port for long.
337
+ linger = setTimeout(close, 2_000);
338
+ };
339
+ const abort = () => {
340
+ stop.abort();
341
+ settle(new LoginError("cancelled"));
342
+ close();
343
+ };
344
+ const redirect = (response: http.ServerResponse, state: PageState) => {
345
+ if (response.headersSent || response.destroyed) return;
346
+ response.writeHead(302, { Location: `${OAUTH_DONE_PATH}?status=${state}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
347
+ response.end();
348
+ };
349
+
350
+ const server = http.createServer((request, response) => {
351
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
352
+ if (request.method !== "GET") { response.writeHead(405).end(); return; }
353
+
354
+ if (url.pathname === OAUTH_DONE_PATH) {
355
+ const asked = url.searchParams.get("status");
356
+ const state: PageState = phase === "done" ? outcome : asked === "empty" ? "empty" : "busy";
357
+ response.writeHead(200, PAGE_HEADERS);
358
+ response.end(loopbackPage(state));
359
+ if (phase === "done") response.once("finish", close);
360
+ return;
361
+ }
362
+ if (url.pathname !== OAUTH_CALLBACK_PATH) { response.writeHead(404).end(); return; }
363
+ // A reload or a second tab: the first callback decides.
364
+ if (phase !== "waiting") { redirect(response, phase === "done" ? outcome : "busy"); return; }
365
+
366
+ const error = url.searchParams.get("error");
367
+ const errorCode = url.searchParams.get("error_code");
368
+ if (error || errorCode) {
369
+ outcome = "failed";
370
+ redirect(response, "failed");
371
+ settle(new LoginError(oauthErrorCode({ error, errorCode, description: url.searchParams.get("error_description") })));
372
+ return;
373
+ }
374
+ const code = url.searchParams.get("code");
375
+ if (!code) {
376
+ response.writeHead(200, PAGE_HEADERS);
377
+ response.end(loopbackPage("forward"));
378
+ return;
379
+ }
380
+ phase = "exchanging";
381
+ // The browser answered: the five-minute wait is over, the exchange has its own limits.
382
+ clearTimeout(timeout);
383
+ void exchangeGrant({ grant_type: "pkce", auth_code: code, code_verifier: verifier }, signal)
384
+ .then((grant) => completeLogin(grant, { signal }))
385
+ .then((result) => {
386
+ outcome = "ok";
387
+ redirect(response, "ok");
388
+ settle(result);
389
+ }, (failure: unknown) => {
390
+ outcome = "failed";
391
+ redirect(response, "failed");
392
+ settle(failure instanceof LoginError ? failure : new LoginError("unknown"));
393
+ });
394
+ });
395
+
396
+ server.on("error", () => { settle(new LoginError("unavailable")); close(); });
397
+ options.signal?.addEventListener("abort", abort, { once: true });
398
+ server.listen(0, "127.0.0.1", () => {
399
+ if (phase === "done") return;
400
+ const address = server.address();
401
+ if (!address || typeof address === "string") { settle(new LoginError("unavailable")); close(); return; }
402
+ const url = authorizeURL(options.provider, `http://127.0.0.1:${address.port}${OAUTH_CALLBACK_PATH}`, challenge);
403
+ timeout = setTimeout(() => { settle(new LoginError("browser_timeout")); close(); }, options.timeoutMs ?? OAUTH_TIMEOUT_MS);
404
+ try { void Promise.resolve((options.open ?? openInBrowser)(url)).catch(() => undefined); }
405
+ catch { /* The UI still has the link to open or copy. */ }
406
+ });
407
+ });
408
+ }
package/src/index.ts CHANGED
@@ -30,7 +30,8 @@ program.command("app")
30
30
 
31
31
  program.command("desktop", { hidden: true })
32
32
  .option("--action <action>", "snapshot, start, stop or restart", "snapshot")
33
- .action(async (options) => { const { runDesktop } = await import("./commands/desktop.js"); await runDesktop(pkg.version, options.action); });
33
+ .option("--provider <provider>", "github or google, for --action login-oauth")
34
+ .action(async (options) => { const { runDesktop } = await import("./commands/desktop.js"); await runDesktop(pkg.version, options.action, process.stdin, { provider: options.provider }); });
34
35
 
35
36
  program.command("desktop-workspace", { hidden: true })
36
37
  .requiredOption("--assets <directory>", "Absolute path to the bundled workspace assets")