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.
- package/dist/cli/src/commands/desktop-workspace.js +62 -9
- package/dist/cli/src/commands/desktop-workspace.js.map +1 -1
- package/dist/cli/src/commands/desktop.d.ts +90 -74
- package/dist/cli/src/commands/desktop.js +87 -1
- package/dist/cli/src/commands/desktop.js.map +1 -1
- package/dist/cli/src/commands/login-error.d.ts +9 -0
- package/dist/cli/src/commands/login-error.js +14 -0
- package/dist/cli/src/commands/login-error.js.map +1 -0
- package/dist/cli/src/commands/login.d.ts +2 -7
- package/dist/cli/src/commands/login.js +23 -52
- package/dist/cli/src/commands/login.js.map +1 -1
- package/dist/cli/src/commands/sign-in.d.ts +77 -0
- package/dist/cli/src/commands/sign-in.js +390 -0
- package/dist/cli/src/commands/sign-in.js.map +1 -0
- package/dist/cli/src/index.js +2 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/client-core/src/account-errors.d.ts +17 -0
- package/dist/client-core/src/account-errors.js +82 -0
- package/dist/client-core/src/account-errors.js.map +1 -0
- package/dist/host/src/acp-settings.js +1 -1
- package/dist/host/src/acp-settings.js.map +1 -1
- package/dist/host/src/computer-preview.d.ts +1 -9
- package/dist/host/src/drivers/acp/antigravity.d.ts +13 -0
- package/dist/host/src/drivers/acp/antigravity.js +13 -0
- package/dist/host/src/drivers/acp/antigravity.js.map +1 -1
- package/dist/host/src/drivers/acp/driver.d.ts +14 -1
- package/dist/host/src/drivers/acp/driver.js +45 -3
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/acp/mapper.d.ts +2 -0
- package/dist/host/src/drivers/acp/mapper.js +6 -1
- package/dist/host/src/drivers/acp/mapper.js.map +1 -1
- package/dist/host/src/drivers/acp/questions.js +2 -2
- package/dist/host/src/drivers/acp/questions.js.map +1 -1
- package/dist/host/src/drivers/claude/app-sessions.d.ts +58 -0
- package/dist/host/src/drivers/claude/app-sessions.js +183 -0
- package/dist/host/src/drivers/claude/app-sessions.js.map +1 -0
- package/dist/host/src/drivers/claude/driver.d.ts +22 -1
- package/dist/host/src/drivers/claude/driver.js +99 -11
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/holders.d.ts +3 -1
- package/dist/host/src/drivers/claude/holders.js +4 -0
- package/dist/host/src/drivers/claude/holders.js.map +1 -1
- package/dist/host/src/drivers/claude/routing.d.ts +19 -0
- package/dist/host/src/drivers/claude/routing.js +145 -0
- package/dist/host/src/drivers/claude/routing.js.map +1 -0
- package/dist/host/src/drivers/claude/tasks.d.ts +2 -2
- package/dist/host/src/drivers/claude/transcript.d.ts +389 -344
- package/dist/host/src/drivers/claude/transcript.js +97 -22
- package/dist/host/src/drivers/claude/transcript.js.map +1 -1
- package/dist/host/src/drivers/codex/driver.d.ts +15 -0
- package/dist/host/src/drivers/codex/driver.js +47 -9
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/mapper.d.ts +41 -7
- package/dist/host/src/drivers/codex/mapper.js +167 -26
- package/dist/host/src/drivers/codex/mapper.js.map +1 -1
- package/dist/host/src/drivers/codex/mcp-apps.d.ts +69 -0
- package/dist/host/src/drivers/codex/mcp-apps.js +340 -0
- package/dist/host/src/drivers/codex/mcp-apps.js.map +1 -0
- package/dist/host/src/drivers/codex/settings.d.ts +13 -0
- package/dist/host/src/drivers/codex/settings.js +22 -6
- package/dist/host/src/drivers/codex/settings.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +50 -1
- package/dist/host/src/fs.d.ts +9 -9
- package/dist/host/src/hub.d.ts +120 -83
- package/dist/host/src/hub.js +157 -13
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/input.d.ts +43 -89
- package/dist/host/src/input.js +1 -1
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/rpc/mcp.d.ts +8 -0
- package/dist/host/src/rpc/mcp.js +33 -0
- package/dist/host/src/rpc/mcp.js.map +1 -0
- package/dist/host/src/rpc/server.js +16 -2
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen-session.d.ts +3 -17
- package/dist/host/src/screen-session.js +2 -2
- package/dist/host/src/screen-session.js.map +1 -1
- package/dist/host/src/store.d.ts +7 -0
- package/dist/host/src/store.js +27 -3
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/src/terminals.d.ts +12 -6
- package/dist/host/src/terminals.js +29 -2
- package/dist/host/src/terminals.js.map +1 -1
- package/dist/host/src/usage.d.ts +5 -0
- package/dist/host/src/usage.js +10 -2
- package/dist/host/src/usage.js.map +1 -1
- package/dist/wire/src/acp.d.ts +68 -430
- package/dist/wire/src/acp.js +5 -5
- package/dist/wire/src/acp.js.map +1 -1
- package/dist/wire/src/async-questions.d.ts +2 -10
- package/dist/wire/src/computer-preview.d.ts +1 -15
- package/dist/wire/src/computer-preview.js +1 -1
- package/dist/wire/src/computer-preview.js.map +1 -1
- package/dist/wire/src/content.d.ts +205 -370
- package/dist/wire/src/content.js +4 -4
- package/dist/wire/src/content.js.map +1 -1
- package/dist/wire/src/editor.d.ts +48 -592
- package/dist/wire/src/envelopes.d.ts +53 -0
- package/dist/wire/src/envelopes.js +263 -0
- package/dist/wire/src/envelopes.js.map +1 -0
- package/dist/wire/src/goal.d.ts +15 -48
- package/dist/wire/src/index.d.ts +2 -0
- package/dist/wire/src/index.js +2 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/live-activity.d.ts +83 -212
- package/dist/wire/src/live-activity.js +1 -1
- package/dist/wire/src/live-activity.js.map +1 -1
- package/dist/wire/src/model.d.ts +454 -2277
- package/dist/wire/src/model.js +24 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay.d.ts +41 -267
- package/dist/wire/src/relay.js +1 -1
- package/dist/wire/src/relay.js.map +1 -1
- package/dist/wire/src/rpc-mcp.d.ts +84 -0
- package/dist/wire/src/rpc-mcp.js +78 -0
- package/dist/wire/src/rpc-mcp.js.map +1 -0
- package/dist/wire/src/rpc.d.ts +6747 -30929
- package/dist/wire/src/rpc.js +35 -7
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/side.d.ts +13 -61
- package/dist/wire/src/updates.d.ts +1743 -6652
- package/dist/wire/src/updates.js +14 -1
- package/dist/wire/src/updates.js.map +1 -1
- package/dist/wire/src/usage.d.ts +17 -195
- package/dist/wire/src/watch.d.ts +223 -1141
- package/dist/wire/src/workflow.d.ts +46 -139
- package/package.json +4 -4
- package/src/commands/desktop-workspace.ts +69 -11
- package/src/commands/desktop.ts +71 -1
- package/src/commands/login-error.ts +12 -0
- package/src/commands/login.ts +13 -62
- package/src/commands/sign-in.ts +408 -0
- package/src/index.ts +2 -1
|
@@ -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
|
-
.
|
|
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")
|