@event4u/agent-switch 2.2.0 → 2.3.1
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/README.md +28 -4
- package/dist/activity.js +2 -0
- package/dist/auth-status.js +10 -0
- package/dist/balance.js +4 -1
- package/dist/daemon.js +33 -15
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +55 -28
- package/dist/doctor.js +35 -1
- package/dist/history.js +20 -0
- package/dist/index.js +198 -13
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/os-notify.js +3 -9
- package/dist/pools.js +58 -45
- package/dist/profiles.js +132 -81
- package/dist/protection.js +26 -19
- package/dist/rebind.js +7 -1
- package/dist/service.js +16 -0
- package/dist/shellenv.js +25 -3
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import * as readline from "node:readline";
|
|
17
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
-
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, colorOf, isDisabled, setDisabled, setColor, isProfileColor, PROFILE_COLORS, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
|
|
18
|
+
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, colorOf, isDisabled, setSharesDefaultLogin, sharesDefaultLogin, setDisabled, setColor, isProfileColor, PROFILE_COLORS, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
|
|
19
19
|
import { browserWrapperScript, defaultBrowserDeps, listBrowsers, locateBrowser } from "./browsers.js";
|
|
20
20
|
import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
|
|
21
21
|
import { borrowerOf, discardBinding, readBinding, rebind, releaseBorrowedAccount, restoreRebind } from "./rebind.js";
|
|
@@ -33,22 +33,22 @@ import { readGuiSettings, writeGuiSettings, applyGuiSetting } from "./gui-settin
|
|
|
33
33
|
import { readProtection, setProtection, renameProtection, forgetProtection } from "./protection.js";
|
|
34
34
|
import { POOL_PREFIX, addPoolMember, createPool, deletePool, findPool, isPoolRef, parseMember, poolIdOf, readPools, removePoolMember, renamePool, renamePoolMember, suggestPoolsFromLabels, } from "./pools.js";
|
|
35
35
|
import { resolveForDir } from "./dir-resolve.js";
|
|
36
|
-
import { antigravityAuthStatus, claudeAuthStatus, claudeCredentialFile, codexAuthStatus, fileMtimeMs, readText, } from "./auth-status.js";
|
|
36
|
+
import { antigravityAuthStatus, claudeAuthStatus, claudeCredentialFile, codexAuthStatus, fileMtimeMs, readText, keptFreshByFor, } from "./auth-status.js";
|
|
37
37
|
import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
|
|
38
38
|
import { releaseNoteBlocks } from "./release-notes.js";
|
|
39
|
-
import { readHistory, recordHistorySample } from "./history.js";
|
|
39
|
+
import { LATEST_FILE, readHistory, readLatestSnapshot, recordHistorySample, writeLatestSnapshot } from "./history.js";
|
|
40
40
|
import { launchGui } from "./gui-launch.js";
|
|
41
41
|
import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
|
|
42
42
|
import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
|
|
43
43
|
import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, setPaused, normalizePath, } from "./mappings.js";
|
|
44
44
|
import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
|
|
45
|
-
import { defaultFreshenDeps,
|
|
45
|
+
import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
|
|
46
46
|
import { formatSnapshot, parseUsage } from "./usage.js";
|
|
47
47
|
import { readCodexUsage } from "./codex-usage.js";
|
|
48
48
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
49
49
|
import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, openQuestion, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
|
|
50
50
|
import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
|
|
51
|
-
import { cmdService, serviceInstall, serviceTarget, serviceUninstall } from "./service.js";
|
|
51
|
+
import { cmdService, serviceAfterUpdate, serviceInstall, serviceInstallFromDisk, serviceTarget, serviceUninstall } from "./service.js";
|
|
52
52
|
import { fileURLToPath } from "node:url";
|
|
53
53
|
import { readContext, turnInFlight } from "./telemetry.js";
|
|
54
54
|
import { readPreview } from "./session-preview.js";
|
|
@@ -56,6 +56,8 @@ import { installHooks, uninstallHooks, hooksInstalled, readSettings, appendEvent
|
|
|
56
56
|
import { readTelemetryConfig, writeTelemetryConfig } from "./notify.js";
|
|
57
57
|
import { appendNotification, readNotifications, clearNotifications } from "./notifications.js";
|
|
58
58
|
import { APPS, buildLaunch, findApp, guiDataDir, isInstalled } from "./apps.js";
|
|
59
|
+
import { copyDesktopSettings, DEFAULT_DATA_DIR, hasSettingsLayout } from "./desktop-settings.js";
|
|
60
|
+
import { createLauncher, findRunning, focusRunning, readLaunchers, removeAllLaunchers, removeLauncher, removeLaunchersFor, renameLaunchersFor, repairLaunchers, } from "./launchers.js";
|
|
59
61
|
import { scanDesktop } from "./desktop-sessions.js";
|
|
60
62
|
import { ensureAgyKeychain } from "./agy-keychain.js";
|
|
61
63
|
import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
|
|
@@ -231,6 +233,9 @@ function cmdLogin(providerId, name, browserId) {
|
|
|
231
233
|
console.error(`${providerId}/${n} is still not signed in (${after.detail})${code !== 0 ? ` — ${p.binary} exited with ${code}` : ""}.`);
|
|
232
234
|
process.exit(1);
|
|
233
235
|
}
|
|
236
|
+
// Its own sign-in ends the lineage it shared with the default install.
|
|
237
|
+
if (sharesDefaultLogin(providerId, n))
|
|
238
|
+
setSharesDefaultLogin(providerId, n, false);
|
|
234
239
|
const id = identity(providerId, n);
|
|
235
240
|
console.log(`${providerId}/${n} is signed in${id ? ` as ${id}` : ""}.`);
|
|
236
241
|
}
|
|
@@ -262,6 +267,8 @@ async function importClaude(name) {
|
|
|
262
267
|
fs.writeFileSync(path.join(dst, ".credentials.json"), creds, { mode: 0o600 });
|
|
263
268
|
return creds !== null;
|
|
264
269
|
}));
|
|
270
|
+
if (seeded)
|
|
271
|
+
setSharesDefaultLogin("claude", name, true);
|
|
265
272
|
console.log(`Imported default Claude setup into profile "${name}".`);
|
|
266
273
|
console.log(seeded
|
|
267
274
|
? "Credentials were seeded — no re-login needed. Claude Code migrates the seed into\n" +
|
|
@@ -289,6 +296,8 @@ function importFileProvider(p, name) {
|
|
|
289
296
|
if (path.join(srcDir, file) === p.credentialPath(srcDir))
|
|
290
297
|
seeded = true;
|
|
291
298
|
}
|
|
299
|
+
if (seeded)
|
|
300
|
+
setSharesDefaultLogin(p.id, name, true);
|
|
292
301
|
console.log(`Imported default ${p.binary} setup into profile "${name}".`);
|
|
293
302
|
console.log(seeded
|
|
294
303
|
? `Credential seeded — no re-login needed.\nIMPORTANT: this profile shares one OAuth ` +
|
|
@@ -455,7 +464,7 @@ async function claudeSnapshot(name) {
|
|
|
455
464
|
if (cached)
|
|
456
465
|
return cached;
|
|
457
466
|
}
|
|
458
|
-
const token =
|
|
467
|
+
const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
|
|
459
468
|
if (!token)
|
|
460
469
|
return null;
|
|
461
470
|
const raw = await fetchUsage(token);
|
|
@@ -481,8 +490,11 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
481
490
|
// every displayed profile on each refresh, so recording the fresh snapshot
|
|
482
491
|
// here (throttled to ~hourly) fills the chart for all profiles without
|
|
483
492
|
// depending on the background daemon or an "active" profile being set.
|
|
484
|
-
if (usage)
|
|
493
|
+
if (usage) {
|
|
485
494
|
recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
|
|
495
|
+
// Unthrottled: `dir` balances on usage only while it is minutes old.
|
|
496
|
+
writeLatestSnapshot(path.join(profileDir(pid, active), LATEST_FILE), usage);
|
|
497
|
+
}
|
|
486
498
|
const wc = worstLiveContext(pid, active);
|
|
487
499
|
const context = wc
|
|
488
500
|
? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
|
|
@@ -516,7 +528,7 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
516
528
|
console.log(" (no usage readout for this provider — shows own usage only where available)");
|
|
517
529
|
continue;
|
|
518
530
|
}
|
|
519
|
-
const token =
|
|
531
|
+
const token = usageToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", n));
|
|
520
532
|
if (!token) {
|
|
521
533
|
const auth = authStatusFor("claude", n);
|
|
522
534
|
console.log(auth.state === "sign-in-needed"
|
|
@@ -546,6 +558,12 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
546
558
|
/** Cached usage for one profile: the daemon's snapshot or the newest history
|
|
547
559
|
* sample, whichever is newer. Local reads only — `dir` runs on every start. */
|
|
548
560
|
function cachedUsage(providerId, name) {
|
|
561
|
+
const fromDaemonOrHistory = cachedUsageFromDaemonOrHistory(providerId, name);
|
|
562
|
+
// The app's own reads land here unthrottled; take whichever source is newest.
|
|
563
|
+
const latest = readLatestSnapshot(path.join(profileDir(providerId, name), LATEST_FILE));
|
|
564
|
+
return latest && (fromDaemonOrHistory.capturedMs === null || latest.capturedMs > fromDaemonOrHistory.capturedMs) ? latest : fromDaemonOrHistory;
|
|
565
|
+
}
|
|
566
|
+
function cachedUsageFromDaemonOrHistory(providerId, name) {
|
|
549
567
|
const daemonSnap = readDaemonState()?.profiles?.[`${providerId}/${name}`] ?? null;
|
|
550
568
|
const daemonMs = daemonSnap ? Date.parse(daemonSnap.capturedAt) : NaN;
|
|
551
569
|
const hist = readHistory(path.join(profileDir(providerId, name), "usage-history.json"));
|
|
@@ -581,6 +599,7 @@ function resolveDir(providerId, plain = false) {
|
|
|
581
599
|
exists: (n) => profileExistsSafe(providerId, n),
|
|
582
600
|
signedIn: (n) => identity(providerId, n) !== null,
|
|
583
601
|
disabled: (n) => isDisabled(providerId, n),
|
|
602
|
+
sharesDefaultLogin: (n) => profileExistsSafe(providerId, n) && sharesDefaultLogin(providerId, n),
|
|
584
603
|
pool: (id) => findPool(id),
|
|
585
604
|
usage: (n) => cachedUsage(providerId, n),
|
|
586
605
|
// Only providers that write a live-session signal can report one; the
|
|
@@ -605,9 +624,22 @@ function resolveDir(providerId, plain = false) {
|
|
|
605
624
|
function cmdDir(providerId, guarded = false, json = false, plain = false) {
|
|
606
625
|
const r = resolveDir(providerId, plain);
|
|
607
626
|
if (json) {
|
|
608
|
-
console.log(JSON.stringify({
|
|
627
|
+
console.log(JSON.stringify({
|
|
628
|
+
profile: r.name ? `${providerId}/${r.name}` : null,
|
|
629
|
+
configDir: r.name ? configDir(providerId, r.name) : null,
|
|
630
|
+
why: r.why,
|
|
631
|
+
...(r.blocked ? { blocked: r.blocked } : {}),
|
|
632
|
+
}, null, 2));
|
|
633
|
+
if (r.blocked)
|
|
634
|
+
process.exit(GUARD_REFUSED_EXIT);
|
|
609
635
|
return;
|
|
610
636
|
}
|
|
637
|
+
// A folder rule that cannot be served refuses — starting on an account
|
|
638
|
+
// outside the rule is the one outcome the rule exists to prevent.
|
|
639
|
+
if (r.blocked) {
|
|
640
|
+
console.error(`agent-switch: not starting here — ${r.blocked}.`);
|
|
641
|
+
process.exit(GUARD_REFUSED_EXIT);
|
|
642
|
+
}
|
|
611
643
|
const name = r.name;
|
|
612
644
|
if (!name)
|
|
613
645
|
return;
|
|
@@ -1632,6 +1664,7 @@ async function cmdRemove(providerId, name, force = false, yes = false) {
|
|
|
1632
1664
|
}
|
|
1633
1665
|
const droppedRebind = binding ? discardBinding(n) : false;
|
|
1634
1666
|
const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
|
|
1667
|
+
removeLaunchersFor(providerId, n); // they would start a profile that no longer exists
|
|
1635
1668
|
fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
|
|
1636
1669
|
if (activeFor(providerId) === n)
|
|
1637
1670
|
setActive(providerId, null);
|
|
@@ -1671,6 +1704,15 @@ function cmdLabel(providerId, name, label) {
|
|
|
1671
1704
|
}
|
|
1672
1705
|
/** Disable keeps a profile (login, history, settings) but takes it out of every
|
|
1673
1706
|
* pick: no active slot, no pool or folder rule, no background polling. */
|
|
1707
|
+
/** `mark-shared <name> [--off]`: mark (or unmark) a profile as sharing the
|
|
1708
|
+
* default install's login — for imports made before `import` set the mark. */
|
|
1709
|
+
function cmdMarkShared(providerId, name, off) {
|
|
1710
|
+
const n = requireProfile(providerId, name, "mark-shared");
|
|
1711
|
+
setSharesDefaultLogin(providerId, n, !off);
|
|
1712
|
+
console.log(off
|
|
1713
|
+
? `${providerId}/${n} no longer counts as sharing the default install's login.`
|
|
1714
|
+
: `${providerId}/${n} now counts as sharing the default install's login: agent-switch never refreshes it and never starts it by balancing. \`agent-switch login ${n}\` gives it its own login.`);
|
|
1715
|
+
}
|
|
1674
1716
|
function cmdDisable(providerId, name, disabled) {
|
|
1675
1717
|
const n = requireProfile(providerId, name, disabled ? "disable" : "enable");
|
|
1676
1718
|
const wasActive = activeFor(providerId) === n;
|
|
@@ -1749,6 +1791,7 @@ function cmdRename(providerId, from, to) {
|
|
|
1749
1791
|
}
|
|
1750
1792
|
renameProtection(providerId, n, to);
|
|
1751
1793
|
renamePoolMember(`${providerId}/${n}`, `${providerId}/${to}`);
|
|
1794
|
+
renameLaunchersFor(providerId, n, to);
|
|
1752
1795
|
console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
|
|
1753
1796
|
}
|
|
1754
1797
|
/** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
|
|
@@ -1890,6 +1933,7 @@ function cmdUninstall(flags = {}) {
|
|
|
1890
1933
|
console.log(` - ${allProfiles.length} profile(s) and their configs under ${ROOT}`);
|
|
1891
1934
|
console.log(` - ${claudeProfiles.length} Claude keychain credential entr(y/ies) (macOS)`);
|
|
1892
1935
|
console.log(" - directory mappings, active-profile state, and the background daemon/service");
|
|
1936
|
+
console.log(" - app launchers it created in ~/Applications/Agent Switch");
|
|
1893
1937
|
console.log("It does NOT touch your default claude/codex/antigravity installs.");
|
|
1894
1938
|
console.log("\nRe-run with --force to proceed: agent-switch uninstall --force");
|
|
1895
1939
|
return;
|
|
@@ -1916,8 +1960,12 @@ function cmdUninstall(flags = {}) {
|
|
|
1916
1960
|
if (credentials.removeEntry(configDir("claude", n)))
|
|
1917
1961
|
removedEntries++;
|
|
1918
1962
|
}
|
|
1963
|
+
// Launchers live in ~/Applications, outside ROOT, and are listed in its manifest.
|
|
1964
|
+
const removedLaunchers = removeAllLaunchers();
|
|
1919
1965
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
1920
1966
|
console.log(`Removed ${allProfiles.length} profile(s) and all agent-switch data under ${ROOT}.`);
|
|
1967
|
+
if (removedLaunchers > 0)
|
|
1968
|
+
console.log(`Removed ${removedLaunchers} app launcher(s) from ~/Applications/Agent Switch.`);
|
|
1921
1969
|
if (removedEntries > 0)
|
|
1922
1970
|
console.log(`Removed ${removedEntries} Claude keychain entr(y/ies).`);
|
|
1923
1971
|
console.log("Follow-ups (manual, if you set them up):");
|
|
@@ -1964,6 +2012,7 @@ function cmdApps(json = false) {
|
|
|
1964
2012
|
provider: a.provider,
|
|
1965
2013
|
strategy: a.strategy,
|
|
1966
2014
|
installed: isInstalled(a),
|
|
2015
|
+
copiesSettings: hasSettingsLayout(a.id),
|
|
1967
2016
|
}));
|
|
1968
2017
|
console.log(JSON.stringify(rows, null, 2));
|
|
1969
2018
|
return;
|
|
@@ -2009,8 +2058,11 @@ function cmdOsNotify(sub, json = false) {
|
|
|
2009
2058
|
const enabled = readOsNotifications();
|
|
2010
2059
|
if (json)
|
|
2011
2060
|
console.log(JSON.stringify({ enabled }));
|
|
2012
|
-
else
|
|
2061
|
+
else {
|
|
2013
2062
|
console.log(`Daemon OS notifications ${enabled ? "ON" : "OFF"}.`);
|
|
2063
|
+
if (enabled && process.platform === "darwin")
|
|
2064
|
+
console.log("On macOS the daemon shows none itself — the Agent Switch app shows them while it runs.");
|
|
2065
|
+
}
|
|
2014
2066
|
}
|
|
2015
2067
|
/** `agent-switch notifications [clear] [--json]` — list the recent
|
|
2016
2068
|
* notifications (newest first) or clear the log. */
|
|
@@ -2083,8 +2135,34 @@ async function cmdUpdate() {
|
|
|
2083
2135
|
console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
|
|
2084
2136
|
const code = selfUpdate();
|
|
2085
2137
|
logActivity("update", null, code === 0 ? "self-update installed" : `self-update failed (exit ${code})`, "user ran `agent-switch update`", code === 0);
|
|
2138
|
+
if (code === 0)
|
|
2139
|
+
await serviceAfterSelfUpdate();
|
|
2086
2140
|
process.exit(code);
|
|
2087
2141
|
}
|
|
2142
|
+
/** Keeps the usage service on the version just installed. Without it the
|
|
2143
|
+
* balanced start has no fresh usage to weigh. */
|
|
2144
|
+
async function serviceAfterSelfUpdate() {
|
|
2145
|
+
switch (serviceAfterUpdate(serviceTarget(), !!process.stdin.isTTY)) {
|
|
2146
|
+
case "reinstall":
|
|
2147
|
+
console.log("Restarting the background service on the new version…");
|
|
2148
|
+
if (serviceInstallFromDisk() !== 0)
|
|
2149
|
+
console.error("Couldn't restart the service — run `agent-switch service install`.");
|
|
2150
|
+
return;
|
|
2151
|
+
case "ask":
|
|
2152
|
+
if (await confirmAction("Install the background service that keeps usage fresh (starts at login)?", false)) {
|
|
2153
|
+
if (serviceInstallFromDisk() !== 0)
|
|
2154
|
+
console.error("Couldn't install the service — run `agent-switch service install`.");
|
|
2155
|
+
}
|
|
2156
|
+
else
|
|
2157
|
+
console.log("Skipped. Install it any time with `agent-switch service install`.");
|
|
2158
|
+
return;
|
|
2159
|
+
case "hint":
|
|
2160
|
+
console.log("Tip: `agent-switch service install` keeps usage fresh so starts can be balanced.");
|
|
2161
|
+
return;
|
|
2162
|
+
case "leave":
|
|
2163
|
+
console.log("The background service runs another agent-switch build — left as it is.");
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2088
2166
|
function cmdOpen(appId, name) {
|
|
2089
2167
|
if (!appId)
|
|
2090
2168
|
die("usage: agent-switch open <app> [profile] (see `agent-switch apps`)");
|
|
@@ -2099,6 +2177,12 @@ function cmdOpen(appId, name) {
|
|
|
2099
2177
|
requireProfile(app.provider, n, "open");
|
|
2100
2178
|
if (!isInstalled(app))
|
|
2101
2179
|
die(`${app.displayName} is not installed.`);
|
|
2180
|
+
// A second launch on the same data dir would only hit the app's single-instance
|
|
2181
|
+
// lock — bring the window that profile already has to the front instead.
|
|
2182
|
+
if (app.strategy !== "env" && focusRunning(guiDataDir(app, n))) {
|
|
2183
|
+
console.log(`${app.displayName} on ${app.provider}/${n} is already open — brought it to the front.`);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2102
2186
|
let firstLaunch = false;
|
|
2103
2187
|
if (app.strategy === "user-data-dir") {
|
|
2104
2188
|
const dir = guiDataDir(app, n);
|
|
@@ -2113,6 +2197,95 @@ function cmdOpen(appId, name) {
|
|
|
2113
2197
|
console.log("is saved to this profile. Quit other windows of this app before signing in.");
|
|
2114
2198
|
}
|
|
2115
2199
|
}
|
|
2200
|
+
/** `agent-switch launcher add|remove <app> <profile>` · `list [--json]` · `repair`. */
|
|
2201
|
+
function cmdLauncher(sub, args, flags) {
|
|
2202
|
+
const target = (verb) => {
|
|
2203
|
+
const app = findApp(args[0] ?? "");
|
|
2204
|
+
if (!app)
|
|
2205
|
+
die(`usage: agent-switch launcher ${verb} <app> <profile> (see \`agent-switch apps\`)`);
|
|
2206
|
+
const n = requireProfile(app.provider, args[1], `launcher ${verb}`);
|
|
2207
|
+
return { app, n };
|
|
2208
|
+
};
|
|
2209
|
+
switch (sub) {
|
|
2210
|
+
case "add": {
|
|
2211
|
+
const { app, n } = target("add");
|
|
2212
|
+
try {
|
|
2213
|
+
const e = createLauncher(app, n);
|
|
2214
|
+
logActivity("launcher", `${app.provider}/${n}`, `created ${app.displayName} launcher`, "user ran `agent-switch launcher add`");
|
|
2215
|
+
console.log(`Created ${e.path}`);
|
|
2216
|
+
console.log("Start it from Spotlight or drag it into the Dock. It opens the app on this account only.");
|
|
2217
|
+
}
|
|
2218
|
+
catch (err) {
|
|
2219
|
+
die(err instanceof Error ? err.message : String(err));
|
|
2220
|
+
}
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
case "remove":
|
|
2224
|
+
case "rm": {
|
|
2225
|
+
const { app, n } = target("remove");
|
|
2226
|
+
if (!removeLauncher(app.id, n))
|
|
2227
|
+
die(`no ${app.displayName} launcher for ${app.provider}/${n}.`);
|
|
2228
|
+
logActivity("launcher", `${app.provider}/${n}`, `removed ${app.displayName} launcher`, "user ran `agent-switch launcher remove`");
|
|
2229
|
+
console.log(`Removed the ${app.displayName} launcher for ${app.provider}/${n}.`);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
case "repair": {
|
|
2233
|
+
const r = repairLaunchers();
|
|
2234
|
+
for (const e of r.rebuilt)
|
|
2235
|
+
console.log(`Rebuilt ${e.path}`);
|
|
2236
|
+
for (const f of r.failed)
|
|
2237
|
+
console.error(`Couldn't rebuild ${f.entry.path}: ${f.reason}`);
|
|
2238
|
+
if (!r.rebuilt.length && !r.failed.length)
|
|
2239
|
+
console.log("No launchers to repair.");
|
|
2240
|
+
if (r.failed.length)
|
|
2241
|
+
process.exit(1);
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
case "list":
|
|
2245
|
+
case undefined: {
|
|
2246
|
+
const rows = readLaunchers().map((e) => ({ ...e, exists: fs.existsSync(e.path) }));
|
|
2247
|
+
if (flags.json)
|
|
2248
|
+
return void console.log(JSON.stringify(rows, null, 2));
|
|
2249
|
+
if (!rows.length)
|
|
2250
|
+
return void console.log("No launchers. Create one with `agent-switch launcher add <app> <profile>`.");
|
|
2251
|
+
for (const r of rows)
|
|
2252
|
+
console.log(`${r.exists ? "●" : "○ missing"} ${r.app.padEnd(16)} ${r.provider}/${r.profile} ${r.path}`);
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
default:
|
|
2256
|
+
die("usage: agent-switch launcher add|remove <app> <profile> | list [--json] | repair");
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
/** `agent-switch app-settings copy <app> --from <profile|default> --to <profile>`. */
|
|
2260
|
+
function cmdAppSettings(sub, args, flags) {
|
|
2261
|
+
const app = findApp(args[0] ?? "");
|
|
2262
|
+
const from = typeof flags.from === "string" ? flags.from : null;
|
|
2263
|
+
const to = typeof flags.to === "string" ? flags.to : null;
|
|
2264
|
+
if (sub !== "copy" || !app || !from || !to)
|
|
2265
|
+
die("usage: agent-switch app-settings copy <app> --from <profile|default> --to <profile>");
|
|
2266
|
+
if (!hasSettingsLayout(app.id))
|
|
2267
|
+
die(`copying settings is not supported for ${app.displayName}.`);
|
|
2268
|
+
if (from === to)
|
|
2269
|
+
die("--from and --to are the same profile.");
|
|
2270
|
+
const toProfile = requireProfile(app.provider, to, "app-settings copy");
|
|
2271
|
+
const fromDir = from === "default" ? DEFAULT_DATA_DIR[app.id] : guiDataDir(app, requireProfile(app.provider, from, "app-settings copy"));
|
|
2272
|
+
const toDir = guiDataDir(app, toProfile);
|
|
2273
|
+
if (!fs.existsSync(fromDir))
|
|
2274
|
+
die(`${from === "default" ? `The default ${app.displayName}` : `${app.provider}/${from}`} has no ${app.displayName} data yet.`);
|
|
2275
|
+
// The running app rewrites its own files on quit and would undo the copy.
|
|
2276
|
+
if (findRunning(toDir))
|
|
2277
|
+
die(`${app.displayName} is open on ${app.provider}/${toProfile} — quit it first, then copy.`);
|
|
2278
|
+
const r = copyDesktopSettings(app.id, fromDir, toDir);
|
|
2279
|
+
logActivity("settings", `${app.provider}/${toProfile}`, `copied ${app.displayName} settings from ${from}`, "user ran `agent-switch app-settings copy`");
|
|
2280
|
+
if (flags.json)
|
|
2281
|
+
return void console.log(JSON.stringify({ app: app.id, from, to: toProfile, ...r }, null, 2));
|
|
2282
|
+
if (!r.copied.length)
|
|
2283
|
+
return void console.log(`Nothing to copy — ${from} has no ${app.displayName} settings yet.`);
|
|
2284
|
+
console.log(`Copied ${r.copied.join(", ")} from ${from} to ${app.provider}/${toProfile}.`);
|
|
2285
|
+
if (r.backups.length)
|
|
2286
|
+
console.log(`Previous files kept as ${r.backups.map((b) => path.basename(b)).join(", ")}.`);
|
|
2287
|
+
console.log("Logins, chats and account-bound preferences were not copied.");
|
|
2288
|
+
}
|
|
2116
2289
|
/**
|
|
2117
2290
|
* `agent-switch guard [block|warn|off]` — read or set the single-load policy.
|
|
2118
2291
|
* The bare form also reports what is loaded RIGHT NOW, because "what is my
|
|
@@ -2302,6 +2475,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
|
|
|
2302
2475
|
agent-switch login [--provider P] <name> [--browser <id>] sign an existing profile in again (after a sign-out / expired login); --browser opens the Claude sign-in in that browser
|
|
2303
2476
|
agent-switch browsers [--json] browsers --browser can open (macOS / Linux)
|
|
2304
2477
|
agent-switch import [--provider P] <name> migrate the default install (no re-login)
|
|
2478
|
+
agent-switch mark-shared [--provider P] <name> [--off] mark a profile as sharing the default install's login (never refreshed or balanced)
|
|
2305
2479
|
agent-switch use [--provider P] <name> set the active profile for a provider
|
|
2306
2480
|
agent-switch deactivate [--provider P] clear the active profile for a provider
|
|
2307
2481
|
agent-switch run [--provider P] <name> [--tmux] [..] launch the provider's CLI (--tmux = managed tmux session, POSIX)
|
|
@@ -2340,15 +2514,17 @@ Run with no command to launch the tray/menubar GUI (single-instance).
|
|
|
2340
2514
|
agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
|
|
2341
2515
|
agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
|
|
2342
2516
|
agent-switch check-update [--json] check GitHub Releases for a newer version
|
|
2343
|
-
agent-switch update self-update to the latest version (via npm)
|
|
2517
|
+
agent-switch update self-update to the latest version (via npm); restarts the service on it, or offers to install one
|
|
2344
2518
|
agent-switch apps list launchable GUI apps (macOS)
|
|
2345
|
-
agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
|
|
2519
|
+
agent-switch open <app> [profile] launch a GUI app on a profile, isolated; focuses it if already open (macOS)
|
|
2520
|
+
agent-switch launcher add|remove <app> <profile> | list [--json] | repair Dock/Spotlight launcher per account in ~/Applications/Agent Switch (macOS)
|
|
2521
|
+
agent-switch app-settings copy <app> --from <profile|default> --to <profile> [--json] copy desktop settings (MCP, preferences, extensions) — never logins
|
|
2346
2522
|
agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
|
|
2347
2523
|
agent-switch shellenv install [--shell S] [--json] add it to your shell's startup file
|
|
2348
2524
|
agent-switch service run|start|stop|status|install|uninstall background usage daemon
|
|
2349
2525
|
agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
|
|
2350
2526
|
agent-switch notify --kind K --title T --message M [--json] record a notification event
|
|
2351
|
-
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
|
|
2527
|
+
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications, Linux/Windows (default off; macOS: the app shows them)
|
|
2352
2528
|
agent-switch uninstall [--force] remove all agent-switch data + daemon
|
|
2353
2529
|
agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
|
|
2354
2530
|
agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
|
|
@@ -2569,6 +2745,11 @@ function cmdActivity(flags) {
|
|
|
2569
2745
|
}
|
|
2570
2746
|
}
|
|
2571
2747
|
function authStatusFor(providerId, name) {
|
|
2748
|
+
const s = rawAuthStatusFor(providerId, name);
|
|
2749
|
+
const keptFreshBy = keptFreshByFor(providerId, { shared: sharesDefaultLogin(providerId, name), serviceInstalled: serviceTarget() !== "not-installed" }, s.keptFreshBy);
|
|
2750
|
+
return { ...s, keptFreshBy };
|
|
2751
|
+
}
|
|
2752
|
+
function rawAuthStatusFor(providerId, name) {
|
|
2572
2753
|
const now = Date.now();
|
|
2573
2754
|
const cfg = provider(providerId).configDirFor(configDir(providerId, name));
|
|
2574
2755
|
if (providerId === "claude") {
|
|
@@ -2763,6 +2944,7 @@ async function main() {
|
|
|
2763
2944
|
case "desktop": return cmdDesktop(!!flags.json);
|
|
2764
2945
|
case "disable": return cmdDisable(providerId, positional[0], true);
|
|
2765
2946
|
case "enable": return cmdDisable(providerId, positional[0], false);
|
|
2947
|
+
case "mark-shared": return cmdMarkShared(providerId, positional[0], !!flags.off);
|
|
2766
2948
|
case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
|
|
2767
2949
|
case "reset": return cmdReset(providerId, positional[0], !!flags.yes);
|
|
2768
2950
|
case "rename": return cmdRename(providerId, positional[0], positional[1]);
|
|
@@ -2773,6 +2955,9 @@ async function main() {
|
|
|
2773
2955
|
case "check-update":
|
|
2774
2956
|
case "check-updates": return cmdCheckUpdate(!!flags.json);
|
|
2775
2957
|
case "open": return cmdOpen(positional[0], positional[1]);
|
|
2958
|
+
case "launcher":
|
|
2959
|
+
case "launchers": return cmdLauncher(positional[0], positional.slice(1), flags);
|
|
2960
|
+
case "app-settings": return cmdAppSettings(positional[0], positional.slice(1), flags);
|
|
2776
2961
|
case "apps": return cmdApps(!!flags.json);
|
|
2777
2962
|
case "notify": return cmdNotify(flags);
|
|
2778
2963
|
case "notifications": return cmdNotifications(positional[0], !!flags.json);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed desktop launchers (macOS): one small app per app × profile in
|
|
3
|
+
* `~/Applications/Agent Switch/`, which a Dock or Spotlight click can start.
|
|
4
|
+
*
|
|
5
|
+
* Each launcher is an AppleScript applet (`osacompile`) whose only line runs
|
|
6
|
+
* this CLI's `open <app> <profile>` — the same isolated start the app menu
|
|
7
|
+
* uses, including "focus the window it already has". The original app is never
|
|
8
|
+
* copied, patched or re-signed. A locally built applet carries no quarantine
|
|
9
|
+
* flag, so Gatekeeper does not prompt; swapping its icon breaks the ad-hoc
|
|
10
|
+
* seal, which is why the bundle is re-signed ad hoc afterwards.
|
|
11
|
+
*
|
|
12
|
+
* Every launcher made is recorded in `launchers.json`, so remove, repair and
|
|
13
|
+
* uninstall touch exactly those bundles.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { findApp } from "./apps.js";
|
|
21
|
+
import { ROOT } from "./profiles.js";
|
|
22
|
+
const MANIFEST = path.join(ROOT, "launchers.json");
|
|
23
|
+
/** macOS-only paths: built with path.posix so they are right on any host. */
|
|
24
|
+
export function launchersDir(home = os.homedir()) {
|
|
25
|
+
return path.posix.join(home, "Applications", "Agent Switch");
|
|
26
|
+
}
|
|
27
|
+
export function launcherPath(app, profile, home = os.homedir()) {
|
|
28
|
+
return path.posix.join(launchersDir(home), `${app.displayName} – ${profile}.app`);
|
|
29
|
+
}
|
|
30
|
+
function asLiteral(s) {
|
|
31
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
32
|
+
}
|
|
33
|
+
/** The applet's single line: run `<node> <index.js> open <app> <profile>`,
|
|
34
|
+
* carrying a custom AGENT_SWITCH_HOME — a Dock start has none in its env. */
|
|
35
|
+
export function appleScriptFor(node, index, appId, profile, home) {
|
|
36
|
+
const env = home ? `"AGENT_SWITCH_HOME=" & quoted form of ${asLiteral(home)} & " " & ` : "";
|
|
37
|
+
return (`do shell script ${env}quoted form of ${asLiteral(node)} & " " & quoted form of ${asLiteral(index)}` +
|
|
38
|
+
` & " open " & quoted form of ${asLiteral(appId)} & " " & quoted form of ${asLiteral(profile)}`);
|
|
39
|
+
}
|
|
40
|
+
/** A brew/installer `bin/node` symlink that resolves to the running node — it
|
|
41
|
+
* survives an upgrade, the versioned path behind it does not. */
|
|
42
|
+
export function stableNode(execPath, candidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node"], realpath = (p) => fs.realpathSync(p)) {
|
|
43
|
+
let running;
|
|
44
|
+
try {
|
|
45
|
+
running = realpath(execPath);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return execPath;
|
|
49
|
+
}
|
|
50
|
+
for (const c of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
if (realpath(c) === running)
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* not installed there */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return execPath;
|
|
60
|
+
}
|
|
61
|
+
/** The main (non-helper) process started with exactly this `--user-data-dir`. */
|
|
62
|
+
export function findMainPid(psOutput, userDataDir) {
|
|
63
|
+
const flag = `--user-data-dir=${userDataDir}`;
|
|
64
|
+
for (const line of psOutput.split("\n")) {
|
|
65
|
+
const m = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
66
|
+
if (!m)
|
|
67
|
+
continue;
|
|
68
|
+
const args = m[2];
|
|
69
|
+
const at = args.indexOf(flag);
|
|
70
|
+
if (at < 0 || args.includes("--type="))
|
|
71
|
+
continue;
|
|
72
|
+
const next = args[at + flag.length];
|
|
73
|
+
if (next === undefined || next === " ")
|
|
74
|
+
return Number(m[1]);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Brings an already running profile instance to the front. False = none runs
|
|
79
|
+
* (or it could not be activated), so the caller starts one. */
|
|
80
|
+
export function findRunning(userDataDir) {
|
|
81
|
+
if (process.platform !== "darwin")
|
|
82
|
+
return null;
|
|
83
|
+
const ps = spawnSync("ps", ["-axo", "pid=,args="], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
84
|
+
return ps.status === 0 ? findMainPid(ps.stdout, userDataDir) : null;
|
|
85
|
+
}
|
|
86
|
+
export function focusRunning(userDataDir) {
|
|
87
|
+
const pid = findRunning(userDataDir);
|
|
88
|
+
if (pid === null)
|
|
89
|
+
return false;
|
|
90
|
+
// NSRunningApplication.activate needs no Accessibility/Automation permission.
|
|
91
|
+
const js = `ObjC.import('AppKit'); const a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${pid}); a.isNil() ? 'no' : (a.activateWithOptions($.NSApplicationActivateAllWindows) ? 'yes' : 'no')`;
|
|
92
|
+
const r = spawnSync("osascript", ["-l", "JavaScript", "-e", js], { encoding: "utf8" });
|
|
93
|
+
return r.status === 0 && r.stdout.trim() === "yes";
|
|
94
|
+
}
|
|
95
|
+
export function upsertEntry(entries, e) {
|
|
96
|
+
return [...withoutEntry(entries, e.app, e.profile), e];
|
|
97
|
+
}
|
|
98
|
+
export function withoutEntry(entries, app, profile) {
|
|
99
|
+
return entries.filter((x) => !(x.app === app && x.profile === profile));
|
|
100
|
+
}
|
|
101
|
+
export function entriesFor(entries, provider, profile) {
|
|
102
|
+
return entries.filter((x) => x.provider === provider && x.profile === profile);
|
|
103
|
+
}
|
|
104
|
+
export function readLaunchers() {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
|
|
107
|
+
return Array.isArray(raw) ? raw.filter((x) => x && typeof x.app === "string" && typeof x.profile === "string" && typeof x.path === "string") : [];
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function writeLaunchers(entries) {
|
|
114
|
+
fs.mkdirSync(ROOT, { recursive: true });
|
|
115
|
+
fs.writeFileSync(MANIFEST, JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
|
|
116
|
+
}
|
|
117
|
+
/** Where the app is installed (first Spotlight hit), or null. */
|
|
118
|
+
export function appBundlePath(app) {
|
|
119
|
+
const r = spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${app.bundleId}'`], { encoding: "utf8" });
|
|
120
|
+
const hit = r.status === 0 ? r.stdout.split("\n").find((l) => l.endsWith(".app")) : undefined;
|
|
121
|
+
return hit ?? null;
|
|
122
|
+
}
|
|
123
|
+
function appIcon(bundle) {
|
|
124
|
+
const r = spawnSync("plutil", ["-extract", "CFBundleIconFile", "raw", path.join(bundle, "Contents", "Info.plist")], { encoding: "utf8" });
|
|
125
|
+
if (r.status !== 0)
|
|
126
|
+
return null;
|
|
127
|
+
const name = r.stdout.trim();
|
|
128
|
+
const file = path.join(bundle, "Contents", "Resources", name.endsWith(".icns") ? name : `${name}.icns`);
|
|
129
|
+
return fs.existsSync(file) ? file : null;
|
|
130
|
+
}
|
|
131
|
+
/** This CLI as the launcher should call it: a stable node + this index.js. */
|
|
132
|
+
function cliInvocation() {
|
|
133
|
+
return { node: stableNode(process.execPath), index: path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js") };
|
|
134
|
+
}
|
|
135
|
+
function run(cmd, args) {
|
|
136
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
137
|
+
if (r.status !== 0)
|
|
138
|
+
throw new Error(`${cmd} failed: ${(r.stderr || r.stdout).trim()}`);
|
|
139
|
+
}
|
|
140
|
+
/** Builds (or rebuilds) the launcher for app × profile and records it. */
|
|
141
|
+
export function createLauncher(app, profile) {
|
|
142
|
+
if (process.platform !== "darwin")
|
|
143
|
+
throw new Error("launchers are macOS-only for now.");
|
|
144
|
+
const bundle = appBundlePath(app);
|
|
145
|
+
if (!bundle)
|
|
146
|
+
throw new Error(`${app.displayName} is not installed.`);
|
|
147
|
+
const target = launcherPath(app, profile);
|
|
148
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
149
|
+
// Built next to the target, then swapped in: a failed build leaves the old one.
|
|
150
|
+
// The name must end in .app, or osacompile writes a bare script, not a bundle.
|
|
151
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target, ".app")}.building.app`);
|
|
152
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
153
|
+
try {
|
|
154
|
+
const { node, index } = cliInvocation();
|
|
155
|
+
run("osacompile", ["-o", tmp, "-e", appleScriptFor(node, index, app.id, profile, process.env.AGENT_SWITCH_HOME)]);
|
|
156
|
+
const icon = appIcon(bundle);
|
|
157
|
+
if (icon) {
|
|
158
|
+
// The applet's asset catalog would win over applet.icns and show a blank icon.
|
|
159
|
+
fs.rmSync(path.join(tmp, "Contents", "Resources", "Assets.car"), { force: true });
|
|
160
|
+
fs.copyFileSync(icon, path.join(tmp, "Contents", "Resources", "applet.icns"));
|
|
161
|
+
}
|
|
162
|
+
run("codesign", ["--force", "--sign", "-", tmp]);
|
|
163
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
164
|
+
fs.renameSync(tmp, target);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
const entry = { app: app.id, provider: app.provider, profile, path: target };
|
|
170
|
+
writeLaunchers(upsertEntry(readLaunchers(), entry));
|
|
171
|
+
return entry;
|
|
172
|
+
}
|
|
173
|
+
/** Removes a recorded launcher; a path the manifest doesn't list is never deleted. */
|
|
174
|
+
export function removeLauncher(appId, profile) {
|
|
175
|
+
const entries = readLaunchers();
|
|
176
|
+
const hit = entries.find((x) => x.app === appId && x.profile === profile);
|
|
177
|
+
if (!hit)
|
|
178
|
+
return false;
|
|
179
|
+
fs.rmSync(hit.path, { recursive: true, force: true });
|
|
180
|
+
writeLaunchers(withoutEntry(entries, appId, profile));
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
export function removeLaunchersFor(provider, profile) {
|
|
184
|
+
const mine = entriesFor(readLaunchers(), provider, profile);
|
|
185
|
+
for (const e of mine)
|
|
186
|
+
removeLauncher(e.app, e.profile);
|
|
187
|
+
return mine.length;
|
|
188
|
+
}
|
|
189
|
+
/** A renamed profile's launchers are rebuilt under the new name (best effort:
|
|
190
|
+
* one that can't be rebuilt is removed rather than left pointing at nothing). */
|
|
191
|
+
export function renameLaunchersFor(provider, from, to) {
|
|
192
|
+
for (const e of entriesFor(readLaunchers(), provider, from)) {
|
|
193
|
+
removeLauncher(e.app, e.profile);
|
|
194
|
+
const app = findApp(e.app);
|
|
195
|
+
if (!app)
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
createLauncher(app, to);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* app gone — nothing to rebuild */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Rebuilds every recorded launcher — after a move of this CLI or a node upgrade. */
|
|
206
|
+
export function repairLaunchers() {
|
|
207
|
+
const rebuilt = [];
|
|
208
|
+
const failed = [];
|
|
209
|
+
for (const e of readLaunchers()) {
|
|
210
|
+
const app = findApp(e.app);
|
|
211
|
+
try {
|
|
212
|
+
if (!app)
|
|
213
|
+
throw new Error(`unknown app ${e.app}`);
|
|
214
|
+
rebuilt.push(createLauncher(app, e.profile));
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
failed.push({ entry: e, reason: err instanceof Error ? err.message : String(err) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { rebuilt, failed };
|
|
221
|
+
}
|
|
222
|
+
export function removeAllLaunchers() {
|
|
223
|
+
const entries = readLaunchers();
|
|
224
|
+
for (const e of entries)
|
|
225
|
+
fs.rmSync(e.path, { recursive: true, force: true });
|
|
226
|
+
writeLaunchers([]);
|
|
227
|
+
return entries.length;
|
|
228
|
+
}
|