linkshell-cli 0.10.42 → 0.10.44

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 (50) hide show
  1. package/dist/cli/src/commands/desktop-update.d.ts +4 -0
  2. package/dist/cli/src/commands/desktop-update.js +54 -0
  3. package/dist/cli/src/commands/desktop-update.js.map +1 -0
  4. package/dist/cli/src/commands/desktop.d.ts +273 -0
  5. package/dist/cli/src/commands/desktop.js +103 -0
  6. package/dist/cli/src/commands/desktop.js.map +1 -0
  7. package/dist/cli/src/commands/login.js +9 -3
  8. package/dist/cli/src/commands/login.js.map +1 -1
  9. package/dist/cli/src/commands/upgrade.js +8 -0
  10. package/dist/cli/src/commands/upgrade.js.map +1 -1
  11. package/dist/cli/src/index.js +12 -0
  12. package/dist/cli/src/index.js.map +1 -1
  13. package/dist/cli/src/utils/daemon.d.ts +2 -0
  14. package/dist/cli/src/utils/daemon.js +12 -2
  15. package/dist/cli/src/utils/daemon.js.map +1 -1
  16. package/dist/cli/src/utils/standalone.d.ts +2 -0
  17. package/dist/cli/src/utils/standalone.js +19 -0
  18. package/dist/cli/src/utils/standalone.js.map +1 -0
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/host/src/host.js +2 -0
  21. package/dist/host/src/host.js.map +1 -1
  22. package/dist/host/src/index.d.ts +1 -0
  23. package/dist/host/src/index.js +1 -0
  24. package/dist/host/src/index.js.map +1 -1
  25. package/dist/host/src/input.js +3 -0
  26. package/dist/host/src/input.js.map +1 -1
  27. package/dist/host/src/rpc/server.d.ts +2 -0
  28. package/dist/host/src/rpc/server.js +3 -0
  29. package/dist/host/src/rpc/server.js.map +1 -1
  30. package/dist/host/src/usage.d.ts +39 -0
  31. package/dist/host/src/usage.js +217 -0
  32. package/dist/host/src/usage.js.map +1 -0
  33. package/dist/wire/src/index.d.ts +1 -0
  34. package/dist/wire/src/index.js +1 -0
  35. package/dist/wire/src/index.js.map +1 -1
  36. package/dist/wire/src/rpc.d.ts +177 -0
  37. package/dist/wire/src/rpc.js +2 -0
  38. package/dist/wire/src/rpc.js.map +1 -1
  39. package/dist/wire/src/usage.d.ts +204 -0
  40. package/dist/wire/src/usage.js +17 -0
  41. package/dist/wire/src/usage.js.map +1 -0
  42. package/dist/wire/src/watch.d.ts +2 -2
  43. package/package.json +4 -4
  44. package/src/commands/desktop-update.ts +56 -0
  45. package/src/commands/desktop.ts +86 -0
  46. package/src/commands/login.ts +11 -3
  47. package/src/commands/upgrade.ts +9 -0
  48. package/src/index.ts +13 -0
  49. package/src/utils/daemon.ts +10 -2
  50. package/src/utils/standalone.ts +13 -0
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ensureHostRunning, withRunningHost } from "./host.js";
4
+ import { linkshellDir, readHostLaunch, readHostOwner, readPid, stopDaemonAndWait } from "../utils/daemon.js";
5
+ import { standaloneBundle } from "../utils/standalone.js";
6
+
7
+ type Marker = {
8
+ schema: 1;
9
+ app: string;
10
+ machineId: string;
11
+ gatewayKey?: string;
12
+ deviceIds: string[];
13
+ env: NodeJS.ProcessEnv;
14
+ };
15
+ const markerPath = () => join(linkshellDir(), "desktop-update.json");
16
+
17
+ /** Called only after the desktop's explicit install confirmation. External hosts are left alone. */
18
+ export async function prepareDesktopUpdate(): Promise<void> {
19
+ const app = standaloneBundle();
20
+ if (!app) throw new Error("只有独立 App 可以协调自身更新");
21
+ if (existsSync(markerPath())) throw new Error("上次更新尚未恢复,请先重新打开 LinkShell");
22
+ const pid = readPid("host");
23
+ if (!pid || readHostOwner(pid) !== app) return;
24
+ const before = await withRunningHost(async (client) => ({
25
+ machine: await client.call("machine.info", {}, 5000),
26
+ gateway: await client.call("gateway.status", {}, 5000),
27
+ }));
28
+ if (!before) throw new Error("无法确认正在运行的服务状态,更新已暂停");
29
+ const marker: Marker = { schema: 1, app, machineId: before.machine.machineId, gatewayKey: before.gateway.machine?.id, deviceIds: before.gateway.devices.map((d) => d.id), env: readHostLaunch(pid) };
30
+ mkdirSync(linkshellDir(), { recursive: true });
31
+ const temporary = `${markerPath()}.${process.pid}.tmp`;
32
+ writeFileSync(temporary, JSON.stringify(marker), { mode: 0o600, flag: "wx" });
33
+ renameSync(temporary, markerPath());
34
+ // Keep the recovery marker even on a failed stop; never allow bundle replacement after this fails.
35
+ await stopDaemonAndWait("host", 30_000, pid);
36
+ }
37
+
38
+ /** New app and canceled update both run this, using their own bundled runtime and preserved settings. */
39
+ export async function recoverDesktopUpdate(version: string): Promise<void> {
40
+ if (!existsSync(markerPath())) return;
41
+ const marker = JSON.parse(readFileSync(markerPath(), "utf8")) as Marker;
42
+ const app = standaloneBundle();
43
+ if (!app || marker.schema !== 1 || marker.app !== app) return;
44
+ const pid = readPid("host");
45
+ if (pid && readHostOwner(pid) !== app) throw new Error("另一套安装正在运行,未自动替换该服务。请先停止它,再重试恢复。");
46
+ await ensureHostRunning({ ...process.env, ...marker.env, LINKSHELL_APP_BUNDLE: app });
47
+ const after = await withRunningHost(async (client) => ({
48
+ machine: await client.call("machine.info", {}, 5000),
49
+ gateway: await client.call("gateway.status", {}, 5000),
50
+ }));
51
+ if (!after || after.machine.hostVersion !== version) throw new Error("新版 Host 尚未就绪,请查看日志并重试恢复");
52
+ if (after.machine.machineId !== marker.machineId || (marker.gatewayKey && after.gateway.machine?.id !== marker.gatewayKey) || marker.deviceIds.some((id) => !after.gateway.devices.some((device) => device.id === id))) {
53
+ throw new Error("恢复后的身份或配对信息不一致;保留恢复记录,请查看日志");
54
+ }
55
+ unlinkSync(markerPath());
56
+ }
@@ -0,0 +1,86 @@
1
+ import { loadAuth } from "../auth.js";
2
+ import { runLogin } from "./login.js";
3
+ import { runLogout } from "./logout.js";
4
+ import { joinGatewayAfterLogin, readHostConfig, writeHostConfig, refreshGateway } from "./host.js";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { realpathSync } from "node:fs";
8
+ import { standaloneBundle } from "../utils/standalone.js";
9
+ import { prepareDesktopUpdate, recoverDesktopUpdate } from "./desktop-update.js";
10
+ import { sep } from "node:path";
11
+ import { ensureHostRunning, silenceSqliteWarning, withRunningHost } from "./host.js";
12
+ import { linkshellDir, readHostLaunch, readHostOwner, readPid, stopDaemonAndWait } from "../utils/daemon.js";
13
+
14
+ export function installationSource(path: string): "homebrew" | "npm" {
15
+ return path.includes(`${sep}Cellar${sep}linkshell${sep}`) ? "homebrew" : "npm";
16
+ }
17
+
18
+ /** A versioned, read-only snapshot. Missing sections stay unknown, never healthy. */
19
+ export async function desktopSnapshot(version: string) {
20
+ let executable = process.argv[1]!;
21
+ try { executable = realpathSync(executable); } catch { /* Retain the launch path for diagnosis. */ }
22
+ const auth = loadAuth();
23
+ const account = auth?.userId ? { state: "signed-in", email: auth.email, userId: auth.userId } : { state: "signed-out" };
24
+ const base = { account, schema: 1, cliVersion: version, home: linkshellDir(), source: standaloneBundle() ? "standalone" : installationSource(executable), executable, hostOwner: readPid("host") ? readHostOwner(readPid("host")!) ?? "external" : "none", appBundle: standaloneBundle() };
25
+ const result = await withRunningHost(async (client) => {
26
+ const machine = await client.call("machine.info", {}, 5000);
27
+ const [gateway, sessions] = await Promise.all([
28
+ client.call("gateway.status", {}, 5000),
29
+ client.call("sessions.list", { limit: 200 }, 5000),
30
+ ]);
31
+ return { machine, gateway, sessions: sessions.sessions, hasMoreSessions: Boolean(sessions.nextBefore) };
32
+ });
33
+ return result ? { ...base, status: "running", ...result } : { ...base, status: readPid("host") ? "unreachable" : "stopped" };
34
+ }
35
+
36
+ export async function runDesktop(version: string, action: string): Promise<void> {
37
+ silenceSqliteWarning();
38
+ try {
39
+ if (action.startsWith("usage-")) {
40
+ const match = /^usage-(1|7|30)-(all|codex|claude)$/.exec(action);
41
+ if (!match) throw new Error("无效的用量筛选条件");
42
+ const result = await withRunningHost(client => client.call("usage.summary", { days: Number(match[1]) as 1 | 7 | 30, agent: match[2] as "all" | "codex" | "claude" }, 120_000));
43
+ if (!result) throw new Error("请先启动本机服务");
44
+ process.stdout.write(`${JSON.stringify(result)}\n`);
45
+ return;
46
+ }
47
+ if (action === "login") {
48
+ const result = await runLogin();
49
+ if (!result) throw new Error("登录未完成或已超时,请重试。");
50
+ await joinGatewayAfterLogin(result.plan);
51
+ }
52
+ else if (action === "logout") await runLogout();
53
+ else if (action === "connect-gateway") {
54
+ if (!loadAuth()?.userId) throw new Error("请先登录账号。");
55
+ if (process.env.LINKSHELL_GATEWAY) throw new Error("网关由启动环境指定,请移除 LINKSHELL_GATEWAY 后重新打开 App。");
56
+ const config = readHostConfig(linkshellDir());
57
+ delete config.gateway;
58
+ writeHostConfig(linkshellDir(), config);
59
+ await ensureHostRunning();
60
+ await refreshGateway(15_000);
61
+ }
62
+ else if (action === "prepare-update") { await prepareDesktopUpdate(); }
63
+ else if (action === "recover-update") { await recoverDesktopUpdate(version); }
64
+ else if (action === "start") await ensureHostRunning();
65
+ else if (action === "stop" || action === "restart") {
66
+ const pid = readPid("host");
67
+ const env = { ...process.env, ...(pid ? readHostLaunch(pid) : {}) };
68
+ await stopDaemonAndWait("host", 30_000, pid ?? undefined);
69
+ if (action === "restart") await ensureHostRunning(env);
70
+ } else if (action !== "snapshot") throw new Error("Unknown desktop action");
71
+ process.stdout.write(`${JSON.stringify(await desktopSnapshot(version))}\n`);
72
+ } catch (error) {
73
+ process.stdout.write(`${JSON.stringify({ schema: 1, status: "unknown", error: error instanceof Error ? error.message : String(error) })}\n`);
74
+ process.exitCode = 1;
75
+ }
76
+ }
77
+
78
+ export async function openDesktop(): Promise<void> {
79
+ if (process.platform !== "darwin") throw new Error("LinkShell.app is available on macOS only");
80
+ silenceSqliteWarning();
81
+ const { shippedApp } = await import("@linkshell/host");
82
+ const app = shippedApp();
83
+ if (!app) throw new Error("LinkShell.app is missing; reinstall linkshell-cli with optional dependencies enabled");
84
+ // Pass the exact Node and CLI pair: Finder's PATH need not contain the user's package manager.
85
+ await promisify(execFile)("/usr/bin/open", ["-n", "-a", app, "--args", "--dashboard", "--cli-node", process.execPath, ...process.execArgv.flatMap((arg) => ["--cli-arg", arg]), "--cli-script", realpathSync(process.argv[1]!), "--cli-path", process.env.PATH ?? "", "--home", linkshellDir()]);
86
+ }
@@ -42,14 +42,14 @@ export async function runLogin(): Promise<LoginResult | null> {
42
42
  const hash = window.location.hash.substring(1);
43
43
  if (hash) {
44
44
  fetch('/token?' + hash, { method: 'POST' })
45
- .then(() => { document.body.innerHTML = '<h2>Login successful! You can close this tab.</h2>'; })
45
+ .then(response => { if (!response.ok) throw new Error('Login failed'); document.body.innerHTML = '<h2>Login successful! You can close this tab.</h2>'; })
46
46
  .catch(() => { document.body.innerHTML = '<h2>Login failed. Please try again.</h2>'; });
47
47
  } else {
48
48
  // Try query params (iTool may pass tokens as query params)
49
49
  const params = window.location.search.substring(1);
50
50
  if (params) {
51
51
  fetch('/token?' + params, { method: 'POST' })
52
- .then(() => { document.body.innerHTML = '<h2>Login successful! You can close this tab.</h2>'; })
52
+ .then(response => { if (!response.ok) throw new Error('Login failed'); document.body.innerHTML = '<h2>Login successful! You can close this tab.</h2>'; })
53
53
  .catch(() => { document.body.innerHTML = '<h2>Login failed. Please try again.</h2>'; });
54
54
  } else {
55
55
  document.body.innerHTML = '<h2>No auth data received. Please try again.</h2>';
@@ -120,10 +120,16 @@ export async function runLogin(): Promise<LoginResult | null> {
120
120
  }
121
121
  } catch {}
122
122
 
123
+ if (!userId) {
124
+ res.writeHead(401);
125
+ res.end("无法验证账号,请重新登录。");
126
+ return;
127
+ }
128
+
123
129
  const tokens: AuthTokens = {
124
130
  accessToken,
125
131
  refreshToken,
126
- expiresAt: Date.now() + expiresIn * 1000,
132
+ expiresAt: Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000,
127
133
  userId,
128
134
  email,
129
135
  };
@@ -156,6 +162,8 @@ export async function runLogin(): Promise<LoginResult | null> {
156
162
  res.end("Not found");
157
163
  });
158
164
 
165
+ server.on("error", () => { clearTimeout(timeout); resolve(null); });
166
+
159
167
  server.listen(0, "127.0.0.1", () => {
160
168
  const addr = server.address();
161
169
  if (!addr || typeof addr === "string") {
@@ -1,6 +1,8 @@
1
1
  import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { join, sep } from "node:path";
4
+ import { standaloneBundle } from "../utils/standalone.js";
5
+ import { linkshellDir } from "../utils/daemon.js";
4
6
  import { runUpgradeWorker, type CliCommand } from "./upgrade-worker.js";
5
7
 
6
8
  function isBrewInstall(): boolean {
@@ -34,6 +36,13 @@ async function fetchLatestVersion(): Promise<string | null> {
34
36
  }
35
37
 
36
38
  export async function runUpgrade(current: string): Promise<void> {
39
+ const app = standaloneBundle();
40
+ if (app) {
41
+ execFileSync("/usr/bin/open", ["-n", "-a", app, "--args", "--dashboard", "--check-updates", "--home", linkshellDir()]);
42
+ process.stderr.write(" 已打开 LinkShell 软件更新;App、内置 CLI 和 Host 将一起更新。\n");
43
+ return;
44
+ }
45
+
37
46
  process.stderr.write(`\n Current version: ${current}\n Checking for updates...\n\n`);
38
47
  const latest = await fetchLatestVersion();
39
48
  if (!latest) {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { standaloneBundle } from "./utils/standalone.js";
2
3
  import { Command, Option } from "commander";
3
4
  import { runDoctor } from "./commands/doctor.js";
4
5
  import { runSetup } from "./commands/setup.js";
@@ -10,6 +11,10 @@ import { createRequire } from "node:module";
10
11
  const require = createRequire(import.meta.url);
11
12
  const pkg = require("../../../package.json") as { version: string };
12
13
 
14
+ const bundledApp = standaloneBundle();
15
+ if (bundledApp) process.env.LINKSHELL_APP_BUNDLE = bundledApp;
16
+ else delete process.env.LINKSHELL_APP_BUNDLE;
17
+
13
18
  const program = new Command();
14
19
 
15
20
  program
@@ -19,6 +24,14 @@ program
19
24
  )
20
25
  .version(pkg.version);
21
26
 
27
+ program.command("app")
28
+ .description("Open the native Mac status app (included with the CLI)")
29
+ .action(async () => { const { openDesktop } = await import("./commands/desktop.js"); await openDesktop(); });
30
+
31
+ program.command("desktop", { hidden: true })
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); });
34
+
22
35
  // ── host ────────────────────────────────────────────────────────────
23
36
 
24
37
  const hostCmd = program
@@ -12,7 +12,7 @@ const hostLaunchFile = join(LINKSHELL_DIR, "host-launch.json");
12
12
  export function saveHostLaunch(pid: number, tcpPort?: number): void {
13
13
  const env = Object.fromEntries(HOST_ENV.flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]));
14
14
  if (tcpPort !== undefined) env.LINKSHELL_DEV_PORT = String(tcpPort);
15
- writeFileSync(hostLaunchFile, `${JSON.stringify({ pid, env })}\n`, { mode: 0o600 });
15
+ writeFileSync(hostLaunchFile, `${JSON.stringify({ pid, env, executable: process.execPath, entry: process.argv[1], appBundle: process.env.LINKSHELL_APP_BUNDLE })}\n`, { mode: 0o600 });
16
16
  }
17
17
 
18
18
  export function readHostLaunch(pid: number): NodeJS.ProcessEnv {
@@ -26,6 +26,14 @@ export function readHostLaunch(pid: number): NodeJS.ProcessEnv {
26
26
  }
27
27
  }
28
28
 
29
+ /** Ownership is recorded by the host itself, and is valid only for that live PID. */
30
+ export function readHostOwner(pid: number): string | undefined {
31
+ try {
32
+ const saved = JSON.parse(readFileSync(hostLaunchFile, "utf8")) as { pid?: number; appBundle?: string };
33
+ return saved.pid === pid && typeof saved.appBundle === "string" ? saved.appBundle : undefined;
34
+ } catch { return undefined; }
35
+ }
36
+
29
37
  // "bridge" is the 1.x daemon: nothing starts one any more, but `linkshell stop` still ends one left running.
30
38
  type ServiceName = "gateway" | "bridge" | "host";
31
39
 
@@ -110,7 +118,7 @@ export function spawnDaemon(service: ServiceName, args: string[], env: NodeJS.Pr
110
118
  detached: true,
111
119
  stdio: ["ignore", out, err],
112
120
  // The launching terminal's environment is the most faithful one agents can get.
113
- env: { ...env, LINKSHELL_ENV_FROM_TERMINAL: "1" },
121
+ env: { ...env, LINKSHELL_ENV_FROM_TERMINAL: env.LINKSHELL_APP_BUNDLE ? "0" : "1" },
114
122
  },
115
123
  );
116
124
 
@@ -0,0 +1,13 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Only an entry point physically inside a complete app may claim app-managed updates. */
5
+ export function standaloneBundle(entry = process.argv[1] ?? ""): string | undefined {
6
+ let path: string;
7
+ try { path = realpathSync(entry); } catch { return undefined; }
8
+ const marker = ".app/Contents/Resources/runtime/cli/";
9
+ const index = path.lastIndexOf(marker);
10
+ if (index < 0) return undefined;
11
+ const app = path.slice(0, index + 4);
12
+ return existsSync(join(app, "Contents/Resources/standalone.json")) && existsSync(join(app, "Contents/MacOS/node")) ? app : undefined;
13
+ }