@event4u/agent-switch 1.0.2 → 1.3.0

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.
@@ -1,91 +1,128 @@
1
1
  /**
2
2
  * `agent-switch gui` — launch the desktop tray GUI.
3
3
  *
4
- * The GUI is a native (Tauri) binary, so it can't run "as JS". Instead the
5
- * prebuilt binary ships as a per-platform optional-dependency npm package
6
- * (`@event4u/agent-switch-<platform>`, the esbuild pattern), installed
7
- * automatically alongside the CLI. Because it arrives via npm — not a
8
- * browser-downloaded DMG/exe — it carries no quarantine flag, so it launches
9
- * without the Gatekeeper / SmartScreen "unverified developer" block.
4
+ * The GUI is a native (Tauri) binary. Rather than ship it through npm, this
5
+ * downloads the prebuilt artifact from the matching GitHub Release on first
6
+ * use, caches it under ~/.agent-switch/gui/<version>/, and launches it. A
7
+ * release-downloaded, cached binary avoids the browser-download Gatekeeper
8
+ * quarantine, and reuses the artifacts the release already publishes.
10
9
  *
11
- * The platform→package / artifact / launch-argv resolution is pure and tested;
12
- * only {@link launchGui} touches the filesystem and spawns a process.
10
+ * Per platform the release carries a runnable artifact:
11
+ * - macOS: `*.app.tar.gz` (the .app bundle) → extract, `open`.
12
+ * - Linux: `*.AppImage` → chmod +x, run.
13
+ * - Windows: `*-setup.exe` (NSIS installer) → run (installs; no portable exe).
14
+ *
15
+ * The platform→asset resolution is pure and tested; download/extract/launch
16
+ * touch the filesystem + network.
13
17
  */
14
- import { spawn } from "node:child_process";
15
- import { createRequire } from "node:module";
18
+ import { spawn, spawnSync } from "node:child_process";
16
19
  import * as fs from "node:fs";
20
+ import * as os from "node:os";
17
21
  import * as path from "node:path";
18
- const require = createRequire(import.meta.url);
19
- /** The optional-dependency package carrying the GUI binary for a platform+arch,
20
- * or null when the platform is unsupported. macOS ships a universal binary, so
21
- * one darwin package serves both arches. Pure. */
22
- export function guiPackageFor(platform = process.platform, arch = process.arch) {
22
+ import { fileURLToPath } from "node:url";
23
+ /** The repo whose GitHub Releases carry the GUI artifacts. */
24
+ const UPDATE_REPO = "event4u-app/agent-switch";
25
+ const CACHE_ROOT = path.join(os.homedir(), ".agent-switch", "gui");
26
+ /** The running CLI's version, from the package's own package.json. */
27
+ function currentVersion() {
28
+ try {
29
+ const pkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
30
+ return JSON.parse(fs.readFileSync(pkg, "utf8")).version ?? "0.0.0";
31
+ }
32
+ catch {
33
+ return "0.0.0";
34
+ }
35
+ }
36
+ /** The release-asset pattern + handling for the host platform+arch, or null
37
+ * when unsupported. Pure. */
38
+ export function guiAssetSpec(platform = process.platform, arch = process.arch) {
23
39
  if (platform === "darwin")
24
- return "@event4u/agent-switch-darwin";
25
- if (platform === "win32" && arch === "x64")
26
- return "@event4u/agent-switch-win32-x64";
40
+ return { match: /\.app\.tar\.gz$/, kind: "app" };
27
41
  if (platform === "linux" && arch === "x64")
28
- return "@event4u/agent-switch-linux-x64";
42
+ return { match: /\.AppImage$/i, kind: "appimage" };
43
+ if (platform === "win32" && arch === "x64")
44
+ return { match: /-setup\.exe$/i, kind: "win-setup" };
29
45
  return null;
30
46
  }
31
- /** The artifact file name inside a platform package. macOS ships the `.app`
32
- * bundle (launched via `open`), Windows the `.exe`, Linux the raw binary. Pure. */
33
- export function guiArtifactName(platform = process.platform) {
34
- if (platform === "darwin")
35
- return "agent-switch.app";
36
- if (platform === "win32")
37
- return "agent-switch.exe";
38
- if (platform === "linux")
39
- return "agent-switch";
47
+ /** Program + args to launch a prepared artifact. Pure. */
48
+ export function guiLaunchArgv(kind, target) {
49
+ if (kind === "app")
50
+ return { program: "open", args: ["-n", target] };
51
+ return { program: target, args: [] }; // AppImage runs directly; win-setup runs the installer
52
+ }
53
+ /** Find a matching GUI asset on the release for `version`, falling back to the
54
+ * latest release. Returns null when neither has one. */
55
+ async function findAsset(version, match) {
56
+ const headers = { Accept: "application/vnd.github+json", "User-Agent": "@event4u/agent-switch gui-launch" };
57
+ for (const ref of [`tags/${version}`, "latest"]) {
58
+ let res;
59
+ try {
60
+ res = await fetch(`https://api.github.com/repos/${UPDATE_REPO}/releases/${ref}`, { headers });
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ if (!res.ok)
66
+ continue;
67
+ const rel = (await res.json());
68
+ const a = (rel.assets ?? []).find((x) => match.test(x.name));
69
+ if (a)
70
+ return { name: a.name, url: a.browser_download_url };
71
+ }
40
72
  return null;
41
73
  }
42
- /** Program + args to launch the artifact at `artifactPath`. macOS uses `open`
43
- * (the artifact is an .app bundle); elsewhere the binary is executed directly.
44
- * Pure — the launch convention split out for testing. */
45
- export function guiLaunchArgv(platform, artifactPath) {
46
- if (platform === "darwin")
47
- return { program: "open", args: ["-n", artifactPath] };
48
- return { program: artifactPath, args: [] };
74
+ /** Download `url` to `dest` (follows redirects via global fetch). */
75
+ async function download(url, dest) {
76
+ const res = await fetch(url, { headers: { "User-Agent": "@event4u/agent-switch gui-launch" } });
77
+ if (!res.ok)
78
+ throw new Error(`download failed: ${res.status} ${url}`);
79
+ const buf = Buffer.from(await res.arrayBuffer());
80
+ fs.writeFileSync(dest, buf);
49
81
  }
50
- /** Absolute path to the GUI artifact for the host, or null when the platform
51
- * package isn't installed / the artifact is missing. */
52
- export function resolveGuiArtifact(platform = process.platform, arch = process.arch) {
53
- const pkg = guiPackageFor(platform, arch);
54
- const artifact = guiArtifactName(platform);
55
- if (!pkg || !artifact)
56
- return null;
57
- let pkgDir;
58
- try {
59
- pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
82
+ /** Ensure the GUI artifact for the host is downloaded + prepared under the
83
+ * version cache, and return the path to launch. */
84
+ async function ensureGuiArtifact(spec) {
85
+ const version = currentVersion();
86
+ const dir = path.join(CACHE_ROOT, version);
87
+ const launchName = spec.kind === "app" ? "agent-switch.app" : spec.kind === "appimage" ? "agent-switch.AppImage" : "agent-switch-setup.exe";
88
+ const launchPath = path.join(dir, launchName);
89
+ if (fs.existsSync(launchPath))
90
+ return launchPath; // already cached
91
+ const asset = await findAsset(version, spec.match);
92
+ if (!asset) {
93
+ throw new Error(`no GUI artifact for ${process.platform}/${process.arch} on the ${version} (or latest) release.\n` +
94
+ `Download an installer from https://github.com/${UPDATE_REPO}/releases instead.`);
60
95
  }
61
- catch {
62
- return null; // optional dependency not installed for this platform
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ console.log(`Downloading the GUI (${asset.name})…`);
98
+ if (spec.kind === "app") {
99
+ const tgz = path.join(dir, "app.tar.gz");
100
+ await download(asset.url, tgz);
101
+ // Extract the .app bundle, then locate it (the tarball name may vary).
102
+ const r = spawnSync("tar", ["-xzf", tgz, "-C", dir], { encoding: "utf8" });
103
+ if (r.status !== 0)
104
+ throw new Error(`extract failed: ${r.stderr || r.status}`);
105
+ fs.rmSync(tgz, { force: true });
106
+ const appDir = fs.readdirSync(dir).find((n) => n.endsWith(".app"));
107
+ if (!appDir)
108
+ throw new Error("extracted archive contained no .app bundle");
109
+ if (appDir !== launchName)
110
+ fs.renameSync(path.join(dir, appDir), launchPath);
111
+ return launchPath;
63
112
  }
64
- const p = path.join(pkgDir, "bin", artifact);
65
- return fs.existsSync(p) ? p : null;
113
+ await download(asset.url, launchPath);
114
+ if (spec.kind === "appimage")
115
+ fs.chmodSync(launchPath, 0o755);
116
+ return launchPath;
66
117
  }
67
- /** Launch the desktop GUI, detached so the CLI returns immediately. Throws a
68
- * helpful error when the platform is unsupported or the package is absent. */
69
- export function launchGui() {
70
- const pkg = guiPackageFor();
71
- if (!pkg) {
72
- throw new Error(`the desktop GUI has no prebuilt binary for ${process.platform}/${process.arch} — build it from source with \`task gui:build\`.`);
73
- }
74
- const artifact = resolveGuiArtifact();
75
- if (!artifact) {
76
- throw new Error(`GUI binary not found — the platform package "${pkg}" is not installed.\n` +
77
- `Reinstall the CLI so npm pulls it: \`npm install -g @event4u/agent-switch\`.`);
78
- }
79
- // On POSIX the raw binary needs the executable bit (npm can drop it).
80
- if (process.platform !== "win32" && process.platform !== "darwin") {
81
- try {
82
- fs.chmodSync(artifact, 0o755);
83
- }
84
- catch {
85
- /* best-effort */
86
- }
118
+ /** Launch the desktop GUI (downloading + caching it on first use). */
119
+ export async function launchGui() {
120
+ const spec = guiAssetSpec();
121
+ if (!spec) {
122
+ throw new Error(`the desktop GUI has no prebuilt artifact for ${process.platform}/${process.arch} — build it from source with \`task gui:build\`.`);
87
123
  }
88
- const { program, args } = guiLaunchArgv(process.platform, artifact);
124
+ const target = await ensureGuiArtifact(spec);
125
+ const { program, args } = guiLaunchArgv(spec.kind, target);
89
126
  const child = spawn(program, args, { detached: true, stdio: "ignore" });
90
127
  child.unref();
91
128
  }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./sh
24
24
  import { detectShell, shellenvScript } from "./shellenv.js";
25
25
  import { runDoctor } from "./doctor.js";
26
26
  import { launchGui } from "./gui-launch.js";
27
+ import { checkForUpdate, selfUpdate } from "./updates.js";
27
28
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
28
29
  import { accessTokenOf, checkAuth, fetchProfile, fetchUsage, liveSessionPids, readProfileCredential, } from "./api.js";
29
30
  import { formatSnapshot, parseUsage } from "./usage.js";
@@ -1525,17 +1526,53 @@ function cmdNotifications(sub, json = false) {
1525
1526
  }
1526
1527
  /** Launch a registered GUI app on a profile, isolated (macOS). Profile: an
1527
1528
  * explicit positional name, else the active profile for the app's provider. */
1528
- function cmdGui() {
1529
- // Launch the tray/menubar GUI (its prebuilt binary ships as a per-platform
1530
- // optional-dependency package; see gui-launch.ts).
1529
+ async function cmdGui() {
1530
+ // Launch the tray/menubar GUI — downloads its prebuilt artifact from the
1531
+ // matching GitHub Release on first use, then caches it (see gui-launch.ts).
1531
1532
  try {
1532
- launchGui();
1533
+ await launchGui();
1533
1534
  console.log("Launched the agent-switch GUI.");
1534
1535
  }
1535
1536
  catch (err) {
1536
1537
  die(String(err?.message ?? err));
1537
1538
  }
1538
1539
  }
1540
+ /** `agent-switch check-update` — compare the running version against the latest
1541
+ * GitHub release (read-only; the same check the GUI runs). */
1542
+ async function cmdCheckUpdate(json = false) {
1543
+ const c = await checkForUpdate();
1544
+ if (json) {
1545
+ console.log(JSON.stringify(c));
1546
+ return;
1547
+ }
1548
+ switch (c.kind) {
1549
+ case "uptodate":
1550
+ console.log(`agent-switch ${c.current} — up to date (latest ${c.latest}).`);
1551
+ break;
1552
+ case "available":
1553
+ console.log(`Update available: ${c.current} → ${c.release.tag}\n ${c.release.url}\n Run \`agent-switch update\` to install it.`);
1554
+ break;
1555
+ case "no-releases":
1556
+ console.log(`agent-switch ${c.current} — no releases published yet.`);
1557
+ break;
1558
+ case "error":
1559
+ console.log(`agent-switch ${c.current} — update check failed: ${c.message}`);
1560
+ break;
1561
+ }
1562
+ }
1563
+ /** `agent-switch update` — self-update to the latest published version via npm. */
1564
+ async function cmdUpdate() {
1565
+ const c = await checkForUpdate();
1566
+ if (c.kind === "uptodate")
1567
+ return void console.log(`Already on the latest version (${c.current}).`);
1568
+ if (c.kind === "no-releases")
1569
+ return void console.log("No releases published yet — nothing to update to.");
1570
+ if (c.kind === "available")
1571
+ console.log(`Updating ${c.current} → ${c.release.tag} via npm…`);
1572
+ else
1573
+ console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
1574
+ process.exit(selfUpdate());
1575
+ }
1539
1576
  function cmdOpen(appId, name) {
1540
1577
  if (!appId)
1541
1578
  die("usage: agent-switch open <app> [profile] (see `agent-switch apps`)");
@@ -1599,6 +1636,8 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
1599
1636
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1600
1637
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1601
1638
  agent-switch gui launch the tray/menubar GUI (bundled via npm)
1639
+ agent-switch check-update [--json] check GitHub Releases for a newer version
1640
+ agent-switch update self-update to the latest version (via npm)
1602
1641
  agent-switch apps list launchable GUI apps (macOS)
1603
1642
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1604
1643
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
@@ -1643,6 +1682,10 @@ async function main() {
1643
1682
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
1644
1683
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
1645
1684
  case "gui": return cmdGui();
1685
+ case "update":
1686
+ case "upgrade": return cmdUpdate();
1687
+ case "check-update":
1688
+ case "check-updates": return cmdCheckUpdate(!!flags.json);
1646
1689
  case "open": return cmdOpen(positional[0], positional[1]);
1647
1690
  case "apps": return cmdApps(!!flags.json);
1648
1691
  case "notify": return cmdNotify(flags);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CLI update check + self-update.
3
+ *
4
+ * Mirrors the GUI's update logic (gui/src/updates.ts): the same version math
5
+ * and GitHub `/releases/latest` parsing, but for the CLI — the running version
6
+ * comes from the package's own package.json, the fetch uses Node's global
7
+ * `fetch`, and `selfUpdate` reinstalls the npm package.
8
+ *
9
+ * The version/parse helpers are pure and unit-tested; only currentVersion,
10
+ * fetchLatestRelease, and selfUpdate touch the outside world.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ /** The published npm package + the repo whose GitHub Releases are the update source. */
17
+ export const PACKAGE_NAME = "@event4u/agent-switch";
18
+ export const UPDATE_REPO = "event4u-app/agent-switch";
19
+ /** Parse a version tag (`v1.2.3`, `1.2.3`, `1.2`) into numeric components. A
20
+ * leading `v`/`V` and any pre-release/build suffix are stripped; non-numeric
21
+ * components collapse to 0 so a malformed tag can never throw. */
22
+ export function parseVersion(tag) {
23
+ const core = String(tag).trim().replace(/^[vV]/, "").split(/[-+]/, 1)[0];
24
+ return core.split(".").map((p) => {
25
+ const n = Number.parseInt(p, 10);
26
+ return Number.isFinite(n) ? n : 0;
27
+ });
28
+ }
29
+ /** Compare two version tags: -1 (a<b), 0 (equal), 1 (a>b). */
30
+ export function compareVersions(a, b) {
31
+ const pa = parseVersion(a);
32
+ const pb = parseVersion(b);
33
+ const len = Math.max(pa.length, pb.length);
34
+ for (let i = 0; i < len; i++) {
35
+ const x = pa[i] ?? 0;
36
+ const y = pb[i] ?? 0;
37
+ if (x > y)
38
+ return 1;
39
+ if (x < y)
40
+ return -1;
41
+ }
42
+ return 0;
43
+ }
44
+ /** True when `latest` is strictly newer than `current`. */
45
+ export function isNewer(latest, current) {
46
+ return compareVersions(latest, current) > 0;
47
+ }
48
+ /** Reshape a raw GitHub `releases/latest` payload into {@link ReleaseInfo}, or
49
+ * null for a draft/prerelease or a payload without a tag. Pure — no network. */
50
+ export function parseRelease(raw) {
51
+ if (!raw || typeof raw !== "object")
52
+ return null;
53
+ const r = raw;
54
+ if (r.draft === true || r.prerelease === true)
55
+ return null;
56
+ const tag = typeof r.tag_name === "string" ? r.tag_name : "";
57
+ if (!tag)
58
+ return null;
59
+ return {
60
+ tag,
61
+ name: typeof r.name === "string" && r.name ? r.name : tag,
62
+ url: typeof r.html_url === "string" ? r.html_url : `https://github.com/${UPDATE_REPO}/releases`,
63
+ notes: typeof r.body === "string" ? r.body : "",
64
+ publishedAt: typeof r.published_at === "string" ? r.published_at : "",
65
+ };
66
+ }
67
+ /** The running CLI's version, from the package's own package.json (one level up
68
+ * from the compiled dist/). "0.0.0" if unreadable so callers never crash. */
69
+ export function currentVersion() {
70
+ try {
71
+ const pkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
72
+ return JSON.parse(fs.readFileSync(pkg, "utf8")).version ?? "0.0.0";
73
+ }
74
+ catch {
75
+ return "0.0.0";
76
+ }
77
+ }
78
+ /** Latest published release, or null when the repo has none yet (404). Throws
79
+ * on other network/HTTP failures so the caller can surface them. */
80
+ export async function fetchLatestRelease(repo = UPDATE_REPO) {
81
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
82
+ headers: { Accept: "application/vnd.github+json", "User-Agent": `${PACKAGE_NAME} cli-updater` },
83
+ });
84
+ if (res.status === 404)
85
+ return null;
86
+ if (!res.ok)
87
+ throw new Error(`GitHub API returned ${res.status}`);
88
+ return parseRelease(await res.json());
89
+ }
90
+ /** Read the current version, fetch the latest release, classify. Never throws. */
91
+ export async function checkForUpdate(repo = UPDATE_REPO) {
92
+ const current = currentVersion();
93
+ try {
94
+ const release = await fetchLatestRelease(repo);
95
+ if (!release)
96
+ return { kind: "no-releases", current };
97
+ if (isNewer(release.tag, current))
98
+ return { kind: "available", current, release };
99
+ return { kind: "uptodate", current, latest: release.tag };
100
+ }
101
+ catch (e) {
102
+ return { kind: "error", current, message: e instanceof Error ? e.message : String(e) };
103
+ }
104
+ }
105
+ /** Self-update: reinstall the npm package at @latest (global). Streams npm's
106
+ * own output; returns its exit code (0 = success). */
107
+ export function selfUpdate() {
108
+ const r = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { stdio: "inherit" });
109
+ return r.status ?? 1;
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "1.0.2",
3
+ "version": "1.3.0",
4
4
  "description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,9 +31,6 @@
31
31
  "typescript": "^5.9.3"
32
32
  },
33
33
  "optionalDependencies": {
34
- "playwright": "^1.49.0",
35
- "@event4u/agent-switch-darwin": "1.0.2",
36
- "@event4u/agent-switch-linux-x64": "1.0.2",
37
- "@event4u/agent-switch-win32-x64": "1.0.2"
34
+ "playwright": "^1.49.0"
38
35
  }
39
36
  }