@oddessentials/agent-guild 0.14.0 → 0.15.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.
- package/README.md +4 -0
- package/config/providers.default.json +11 -9
- package/package.json +3 -1
- package/src/manager/install-channels.mjs +53 -21
- package/src/manager/providers.mjs +36 -9
- package/src/manager/server.mjs +6 -0
- package/src/manager/session-manager.mjs +37 -19
- package/src/manager/uninstall.mjs +148 -0
- package/web/app.js +40 -16
- package/web/index.html +4 -4
- package/web/styles.css +6 -3
package/README.md
CHANGED
|
@@ -34,6 +34,10 @@ agent-guild
|
|
|
34
34
|
The page opens in your browser. Pick a provider, press **New**, and you are
|
|
35
35
|
in a live terminal.
|
|
36
36
|
|
|
37
|
+
Want to look around first? The [interactive web demo](https://oddessentials.github.io/agent-guild/)
|
|
38
|
+
uses simulated sessions and usage data in your browser; it never runs commands
|
|
39
|
+
or connects to a local session manager.
|
|
40
|
+
|
|
37
41
|
**You need** Node.js 22 or newer on Windows 10 1809+, macOS 11+ or Linux
|
|
38
42
|
(x64 or arm64), and each coding tool you want to use, signed in on its own.
|
|
39
43
|
Agent Guild installs the tools for you but never handles your sign-in. No
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"install": "curl -fsSL https://claude.ai/install.sh | bash",
|
|
22
22
|
"npmNote": "npm installs the same native build as Anthropic's installer.",
|
|
23
23
|
"channels": {
|
|
24
|
-
"native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "
|
|
24
|
+
"native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "remove": ["~/.local/bin/claude", "~/.local/share/claude"] },
|
|
25
25
|
"brew": { "names": ["claude-code", "claude-code@latest"] },
|
|
26
26
|
"winget": { "id": "Anthropic.ClaudeCode" },
|
|
27
|
-
"legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "
|
|
27
|
+
"legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "remove": ["~/.claude/local"] }
|
|
28
28
|
},
|
|
29
29
|
"docs": "https://code.claude.com/docs/en/setup",
|
|
30
30
|
"usageUrl": "https://claude.ai/settings/usage",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"win32": {
|
|
34
34
|
"install": "irm https://claude.ai/install.ps1 | iex",
|
|
35
35
|
"channels": {
|
|
36
|
-
"native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "
|
|
36
|
+
"native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "remove": ["~/.local/bin/claude.exe", "~/.local/share/claude"] },
|
|
37
37
|
"winget": { "id": "Anthropic.ClaudeCode" },
|
|
38
|
-
"legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "
|
|
38
|
+
"legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "remove": ["~/.claude/local"] }
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
},
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"install": "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
|
|
60
60
|
"npmNote": "npm installs the native Codex binary.",
|
|
61
61
|
"channels": {
|
|
62
|
-
"native": { "paths": ["~/.local/bin/codex", "~/.codex/packages/standalone"], "update": ["update"] },
|
|
62
|
+
"native": { "paths": ["~/.local/bin/codex", "~/.codex/packages/standalone"], "update": ["update"], "remove": ["~/.local/bin/codex", "~/.local/bin/codex-code-mode-host", "~/.codex/packages/standalone"] },
|
|
63
63
|
"brew": { "names": ["codex"] },
|
|
64
64
|
"winget": { "id": "OpenAI.Codex" }
|
|
65
65
|
},
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"win32": {
|
|
71
71
|
"install": "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"",
|
|
72
72
|
"channels": {
|
|
73
|
-
"native": { "paths": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"], "update": ["update"] },
|
|
73
|
+
"native": { "paths": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"], "update": ["update"], "remove": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"] },
|
|
74
74
|
"winget": { "id": "OpenAI.Codex" }
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
"monogram": "G",
|
|
91
91
|
"install": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
92
92
|
"channels": {
|
|
93
|
-
"native": { "paths": ["~/.local/bin/agy"], "update": ["update"], "
|
|
93
|
+
"native": { "paths": ["~/.local/bin/agy"], "update": ["update"], "remove": ["~/.local/bin/agy"] },
|
|
94
|
+
"brew": { "names": ["antigravity-cli"], "autoUpdates": true }
|
|
94
95
|
},
|
|
95
96
|
"docs": "https://antigravity.google/docs/cli/install",
|
|
96
97
|
"billingUrl": "https://one.google.com/settings",
|
|
@@ -98,7 +99,7 @@
|
|
|
98
99
|
"win32": {
|
|
99
100
|
"install": "irm https://antigravity.google/cli/install.ps1 | iex",
|
|
100
101
|
"channels": {
|
|
101
|
-
"native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "
|
|
102
|
+
"native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "remove": ["~/AppData/Local/agy"] }
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
},
|
|
@@ -121,7 +122,8 @@
|
|
|
121
122
|
"install": "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
122
123
|
"npmNote": "npm installs the native grok binary into ~/.grok/bin.",
|
|
123
124
|
"channels": {
|
|
124
|
-
"native": { "paths": ["~/.grok/bin", "~/.grok/downloads"], "update": ["update"], "sharedWithNpm": true }
|
|
125
|
+
"native": { "paths": ["~/.grok/bin", "~/.grok/downloads"], "update": ["update"], "remove": ["~/.grok/bin", "~/.grok/downloads", "~/.grok/completions"], "links": ["~/.local/bin/grok", "~/.local/bin/agent", "/usr/local/bin/grok", "/usr/local/bin/agent"], "sharedWithNpm": true },
|
|
126
|
+
"brew": { "names": ["grok-build"] }
|
|
125
127
|
},
|
|
126
128
|
"docs": "https://docs.x.ai/build/overview",
|
|
127
129
|
"usageUrl": "https://grok.com/?_s=usage",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"manager": "node bin/agent-guild.mjs start",
|
|
32
32
|
"stop": "node bin/agent-guild.mjs stop",
|
|
33
33
|
"status": "node bin/agent-guild.mjs status",
|
|
34
|
+
"demo:build": "node docs/demo/build.mjs",
|
|
35
|
+
"demo:check": "node docs/demo/check.mjs",
|
|
34
36
|
"test": "node --test --test-concurrency=1"
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {
|
|
@@ -35,6 +35,12 @@ export function homeDir(env, platform) {
|
|
|
35
35
|
return (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export function homeRelative(p, env, platform) {
|
|
39
|
+
const m = pathModule(platform);
|
|
40
|
+
const home = homeDir(env, platform);
|
|
41
|
+
return isInside(p, home, platform) ? `~${m.sep}${m.relative(home, p)}` : p;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
export function expandHome(p, env, platform) {
|
|
39
45
|
const m = pathModule(platform);
|
|
40
46
|
if (p === '~') return homeDir(env, platform);
|
|
@@ -150,17 +156,6 @@ export function formatCommand(file, args) {
|
|
|
150
156
|
return [file, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
function shellQuote(arg, platform) {
|
|
154
|
-
if (platform === 'win32') return /^[\w\\/.:=+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "''")}'`;
|
|
155
|
-
return /^[\w/.:=,+@%-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function shellCommand(file, args, platform) {
|
|
159
|
-
const command = shellQuote(file, platform);
|
|
160
|
-
const line = [command, ...args.map((arg) => shellQuote(arg, platform))].join(' ');
|
|
161
|
-
return platform === 'win32' && command !== file ? `& ${line}` : line;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
159
|
export function classifyInstall({
|
|
165
160
|
resolvedPath, provider, env = process.env, platform = process.platform, fsx = defaultFsx, npmOnPath = null, wingetOnPath = null,
|
|
166
161
|
}) {
|
|
@@ -180,8 +175,9 @@ export function classifyInstall({
|
|
|
180
175
|
|
|
181
176
|
const brew = brewOwner({ realPath, names: channels.brew?.names || [], platform, fsx });
|
|
182
177
|
if (brew) {
|
|
183
|
-
const owned = { brewPrefix: brew.prefix, token: brew.token, cask: brew.cask };
|
|
178
|
+
const owned = { brewPrefix: brew.prefix, token: brew.token, cask: brew.cask, brewFile: brew.brew };
|
|
184
179
|
if (!brew.brew) return result('brew', { ...owned, guidance: `Installed by Homebrew under ${brew.prefix}, but brew was not found at ${brew.prefix}/bin/brew.` });
|
|
180
|
+
if (channels.brew.autoUpdates) return result('brew', { ...owned, guidance: `${provider.tool} updates itself; Homebrew does not upgrade it.` });
|
|
185
181
|
return result('brew', { ...owned, update: { file: brew.brew, args: brew.cask ? ['upgrade', '--cask', brew.token] : ['upgrade', brew.token] } });
|
|
186
182
|
}
|
|
187
183
|
|
|
@@ -227,18 +223,48 @@ export function installationKey(install, provider, platform, fsx = defaultFsx) {
|
|
|
227
223
|
}
|
|
228
224
|
}
|
|
229
225
|
|
|
230
|
-
|
|
226
|
+
function ownedPaths(entries, env, platform) {
|
|
227
|
+
const m = pathModule(platform);
|
|
228
|
+
const home = homeDir(env, platform);
|
|
229
|
+
return (entries || []).map((entry) => expandHome(entry, env, platform))
|
|
230
|
+
.filter((p) => m.isAbsolute(p) && isInside(p, home, platform) && m.relative(home, p).split(/[\\/]+/).length >= 2)
|
|
231
|
+
.map((p) => m.normalize(p));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Links may sit outside home, e.g. /usr/local/bin: the runner deletes one only
|
|
235
|
+
// when it is a symlink into the removed paths, which stay inside home.
|
|
236
|
+
function linkPaths(entries, env, platform) {
|
|
237
|
+
const m = pathModule(platform);
|
|
238
|
+
return (entries || []).map((entry) => expandHome(entry, env, platform))
|
|
239
|
+
.filter((p) => (platform === 'win32' ? /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(p) : m.isAbsolute(p)))
|
|
240
|
+
.map((p) => m.normalize(p));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function removalFor(channel, env, platform) {
|
|
244
|
+
const remove = ownedPaths(channel?.remove, env, platform);
|
|
245
|
+
return remove.length > 0 ? { remove, links: linkPaths(channel.links, env, platform) } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function uninstallPlan(install, provider, env, platform, fsx = defaultFsx) {
|
|
249
|
+
const channels = provider.channels || {};
|
|
250
|
+
const plan = (run, removal = null, launcher = null) => ({ run, remove: removal?.remove || [], links: removal?.links || [], launcher });
|
|
231
251
|
switch (install.channel) {
|
|
232
|
-
case 'npm':
|
|
233
|
-
|
|
252
|
+
case 'npm': {
|
|
253
|
+
if (!install.update) return null;
|
|
254
|
+
const run = { file: install.update.file, args: ['uninstall', '-g', '--prefix', install.prefix, provider.package] };
|
|
255
|
+
const removal = channels.native?.sharedWithNpm ? removalFor(channels.native, env, platform) : null;
|
|
256
|
+
const launcher = removal ? knownLaunchers({ provider, command: provider.command, env, platform, fsx })[0] : null;
|
|
257
|
+
return plan(run, removal, launcher);
|
|
258
|
+
}
|
|
234
259
|
case 'brew':
|
|
235
|
-
return install.
|
|
260
|
+
return install.brewFile ? plan({ file: install.brewFile, args: install.cask ? ['uninstall', '--cask', install.token] : ['uninstall', install.token] }) : null;
|
|
236
261
|
case 'winget':
|
|
237
|
-
return
|
|
262
|
+
return install.update ? plan({ file: install.update.file, args: ['uninstall', '--id', install.wingetId, '--exact'] }) : null;
|
|
238
263
|
case 'native':
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return
|
|
264
|
+
case 'legacy': {
|
|
265
|
+
const removal = removalFor(channels[install.channel], env, platform);
|
|
266
|
+
return removal ? plan(null, removal, install.resolvedPath) : null;
|
|
267
|
+
}
|
|
242
268
|
default:
|
|
243
269
|
return null;
|
|
244
270
|
}
|
|
@@ -267,7 +293,13 @@ export function listInstallations({
|
|
|
267
293
|
const add = (file, isOnPath) => {
|
|
268
294
|
const install = classifyInstall({ resolvedPath: file, provider, env, platform, fsx, npmOnPath, wingetOnPath });
|
|
269
295
|
const key = installationKey(install, provider, platform, fsx);
|
|
270
|
-
|
|
296
|
+
const existing = installs.get(key);
|
|
297
|
+
if (!existing) installs.set(key, { ...install, key, onPath: isOnPath, uninstall: uninstallPlan(install, provider, env, platform, fsx) });
|
|
298
|
+
// A shared native copy also needs its npm owner removed, whichever comes first on PATH.
|
|
299
|
+
else if (install.channel === 'npm' && !existing.uninstall?.run) {
|
|
300
|
+
existing.uninstall = uninstallPlan(install, provider, env, platform, fsx);
|
|
301
|
+
if (!existing.uninstall) existing.guidance = install.guidance;
|
|
302
|
+
}
|
|
271
303
|
};
|
|
272
304
|
for (const file of onPath) add(file, true);
|
|
273
305
|
for (const file of known) add(file, false);
|
|
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
13
13
|
import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
|
|
14
|
-
import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
|
|
14
|
+
import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
|
|
15
15
|
import { weavePaths } from './shell-env.mjs';
|
|
16
|
+
import { RUNNER, encodePlan } from './uninstall.mjs';
|
|
16
17
|
import { paths } from './config.mjs';
|
|
17
18
|
|
|
18
19
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -81,17 +82,18 @@ function normalizeChannels(raw) {
|
|
|
81
82
|
out.native = {
|
|
82
83
|
paths: stringList(raw.native.paths),
|
|
83
84
|
update: stringList(raw.native.update),
|
|
84
|
-
|
|
85
|
+
remove: stringList(raw.native.remove),
|
|
86
|
+
links: stringList(raw.native.links),
|
|
85
87
|
sharedWithNpm: raw.native.sharedWithNpm === true,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
|
-
if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names) };
|
|
90
|
+
if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names), autoUpdates: raw.brew.autoUpdates === true };
|
|
89
91
|
if (raw.winget && typeof raw.winget === 'object' && raw.winget.id) out.winget = { id: String(raw.winget.id) };
|
|
90
92
|
if (raw.legacy && typeof raw.legacy === 'object') {
|
|
91
93
|
out.legacy = {
|
|
92
94
|
paths: stringList(raw.legacy.paths),
|
|
93
95
|
guidance: raw.legacy.guidance ? String(raw.legacy.guidance) : null,
|
|
94
|
-
|
|
96
|
+
remove: stringList(raw.legacy.remove),
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
return out;
|
|
@@ -225,6 +227,12 @@ export function loadProviders({ userFile, platform = process.platform } = {}) {
|
|
|
225
227
|
return { providers, warnings };
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
// Why a copy cannot be removed here. An unrecognised copy's own guidance is about updating it.
|
|
231
|
+
function removalGuidance(provider, install, where) {
|
|
232
|
+
if (install.channel !== 'unknown' && install.guidance) return install.guidance;
|
|
233
|
+
return `Agent Guild does not know how ${provider.tool} at ${where} was installed. Remove it the way you installed it.`;
|
|
234
|
+
}
|
|
235
|
+
|
|
228
236
|
export class ProviderRegistry extends EventEmitter {
|
|
229
237
|
/**
|
|
230
238
|
* @param {object} opts
|
|
@@ -408,7 +416,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
408
416
|
if (changed) this.emit('updated');
|
|
409
417
|
}
|
|
410
418
|
|
|
411
|
-
async finishInstall(id, { exitCode = null, kind = 'update' } = {}) {
|
|
419
|
+
async finishInstall(id, { exitCode = null, kind = 'update', path: removed = null } = {}) {
|
|
412
420
|
const provider = this.get(id);
|
|
413
421
|
if (!provider) return;
|
|
414
422
|
const before = this.versions.get(id)?.installed ?? null;
|
|
@@ -418,6 +426,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
418
426
|
if (!entry) return;
|
|
419
427
|
let outcome;
|
|
420
428
|
if (exitCode !== 0) outcome = 'failed';
|
|
429
|
+
else if (kind === 'uninstall') outcome = this.installsFor(provider).some((i) => i.resolvedPath === removed) ? 'remaining' : 'removed';
|
|
421
430
|
else if (kind === 'install') outcome = this.resolve(provider) ? 'installed' : 'missing';
|
|
422
431
|
else if (entry.installed === null) outcome = 'done';
|
|
423
432
|
else outcome = entry.installed !== before ? 'updated' : 'unchanged';
|
|
@@ -509,13 +518,13 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
509
518
|
installWarnings(provider, installs) {
|
|
510
519
|
const label = (i) => [CHANNEL_LABELS[i.channel], i.version && `v${i.version}`].filter(Boolean).join(' ');
|
|
511
520
|
const active = installs.find((i) => i.active);
|
|
512
|
-
if (!active) return installs.map((i) => `A copy of ${provider.tool} exists at ${i.
|
|
521
|
+
if (!active) return installs.map((i) => `A copy of ${provider.tool} exists at ${i.displayPath}, but its folder is not on PATH.`);
|
|
513
522
|
const warnings = [];
|
|
514
523
|
if (installs.length > 1) {
|
|
515
|
-
warnings.push(`${installs.length} copies of ${provider.tool} are installed. The one in use is ${label(active)} at ${active.
|
|
524
|
+
warnings.push(`${installs.length} copies of ${provider.tool} are installed. The one in use is ${label(active)} at ${active.displayPath}.`);
|
|
516
525
|
}
|
|
517
526
|
const newer = installs.find((i) => i.newer);
|
|
518
|
-
if (newer) warnings.push(`An older copy comes first on PATH: ${label(active)} is in use while ${label(newer)} is installed at ${newer.
|
|
527
|
+
if (newer) warnings.push(`An older copy comes first on PATH: ${label(active)} is in use while ${label(newer)} is installed at ${newer.displayPath}.`);
|
|
519
528
|
return warnings;
|
|
520
529
|
}
|
|
521
530
|
|
|
@@ -587,17 +596,23 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
587
596
|
const latest = versions?.latest ?? null;
|
|
588
597
|
const channel = this.channelFor(provider, resolvedPath);
|
|
589
598
|
const update = this.updateFor(provider, channel);
|
|
599
|
+
const shown = (file) => homeRelative(file, { ...this.env, ...provider.env }, this.platform);
|
|
590
600
|
const installs = this.installsFor(provider, resolvedPath).map((install) => {
|
|
591
601
|
const active = install.resolvedPath === resolvedPath;
|
|
592
602
|
const copy = active ? { version: installed, status: versions?.versionStatus ?? null } : versions?.copies?.[install.resolvedPath];
|
|
593
603
|
return {
|
|
594
604
|
path: install.resolvedPath,
|
|
605
|
+
displayPath: shown(install.resolvedPath),
|
|
595
606
|
channel: install.channel,
|
|
596
607
|
version: copy?.version ?? null,
|
|
597
608
|
versionStatus: copy?.status ?? null,
|
|
598
609
|
active,
|
|
599
610
|
onPath: install.onPath,
|
|
600
|
-
|
|
611
|
+
uninstall: install.uninstall && {
|
|
612
|
+
command: install.uninstall.run ? formatCommand(install.uninstall.run.file, install.uninstall.run.args) : null,
|
|
613
|
+
remove: install.uninstall.remove.map(shown),
|
|
614
|
+
},
|
|
615
|
+
uninstallGuidance: install.uninstall ? null : removalGuidance(provider, install, shown(install.resolvedPath)),
|
|
601
616
|
};
|
|
602
617
|
});
|
|
603
618
|
const inUse = installs.find((i) => i.active)?.version;
|
|
@@ -683,6 +698,18 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
683
698
|
return { spec: buildSpawnSpec(update.file, args, this.env, this.platform), channel: channel.channel };
|
|
684
699
|
}
|
|
685
700
|
|
|
701
|
+
uninstallSpec(provider, copyPath) {
|
|
702
|
+
const install = this.listInstalls(provider).find((i) => i.resolvedPath === copyPath);
|
|
703
|
+
if (!install) throw refusal(404, 'unknown_copy', `${provider.tool} has no copy at ${copyPath}`);
|
|
704
|
+
if (!install.uninstall) {
|
|
705
|
+
throw refusal(400, 'not_removable', removalGuidance(provider, install, copyPath));
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
spec: buildSpawnSpec(process.execPath, [RUNNER, encodePlan(install.uninstall)], this.env, this.platform),
|
|
709
|
+
channel: install.channel,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
686
713
|
async installSpec(provider) {
|
|
687
714
|
if (!provider.package) {
|
|
688
715
|
const err = new Error(`${provider.tool} has no npm package configured; install it by hand: ${provider.install || provider.docs || 'see its documentation'}`);
|
package/src/manager/server.mjs
CHANGED
|
@@ -290,6 +290,12 @@ export function createManagerServer({
|
|
|
290
290
|
const session = await manager.install(installMatch[1], { force: body.force === true });
|
|
291
291
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
292
292
|
}
|
|
293
|
+
const uninstallMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/uninstall$/);
|
|
294
|
+
if (uninstallMatch && method === 'POST') {
|
|
295
|
+
const body = await readJsonBody(req);
|
|
296
|
+
const session = manager.uninstall(uninstallMatch[1], body.path, { force: body.force === true });
|
|
297
|
+
return sendJson(res, 201, { session: session.toJSON() });
|
|
298
|
+
}
|
|
293
299
|
if (route === '/sessions' && method === 'GET') {
|
|
294
300
|
return sendJson(res, 200, { sessions: manager.list() });
|
|
295
301
|
}
|
|
@@ -104,6 +104,10 @@ export class SessionManager extends EventEmitter {
|
|
|
104
104
|
const workDir = this.resolveCwd(cwd);
|
|
105
105
|
const signIn = this.registry.account(provider, account);
|
|
106
106
|
const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
|
|
107
|
+
// Checked after the await, so an install that started meanwhile is seen.
|
|
108
|
+
if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
|
|
109
|
+
throw httpError(409, `${provider.tool} is being installed, updated or removed; start it once that finishes`, 'install_in_progress');
|
|
110
|
+
}
|
|
107
111
|
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
|
|
108
112
|
this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
|
|
109
113
|
const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
|
|
@@ -132,21 +136,7 @@ export class SessionManager extends EventEmitter {
|
|
|
132
136
|
* set, because replacing a tool under a running process can break it.
|
|
133
137
|
*/
|
|
134
138
|
async install(providerId, { force = false } = {}) {
|
|
135
|
-
const provider = this.
|
|
136
|
-
if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
|
|
137
|
-
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
138
|
-
const guard = () => {
|
|
139
|
-
const running = this.runningFor(provider.id);
|
|
140
|
-
if (running > 0 && !force) {
|
|
141
|
-
const err = httpError(409, `${running} ${provider.tool} session(s) are running; updating the tool now may break them`, 'provider_in_use');
|
|
142
|
-
err.running = running;
|
|
143
|
-
throw err;
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
guard();
|
|
147
|
-
if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
|
|
148
|
-
throw httpError(409, `${provider.tool} is already being installed or updated`, 'install_in_progress');
|
|
149
|
-
}
|
|
139
|
+
const { provider, guard } = this._installGuard(providerId, force, 'updating the tool now may break them');
|
|
150
140
|
this.installing.add(provider.id);
|
|
151
141
|
try {
|
|
152
142
|
if (this.registry.resolve(provider)) {
|
|
@@ -163,9 +153,37 @@ export class SessionManager extends EventEmitter {
|
|
|
163
153
|
}
|
|
164
154
|
}
|
|
165
155
|
|
|
156
|
+
uninstall(providerId, copyPath, { force = false } = {}) {
|
|
157
|
+
if (typeof copyPath !== 'string' || !copyPath) throw httpError(400, 'path must name the copy to remove', 'bad_request');
|
|
158
|
+
const { provider, guard } = this._installGuard(providerId, force, 'removing the tool now may break them');
|
|
159
|
+
const { spec, channel } = this.registry.uninstallSpec(provider, copyPath);
|
|
160
|
+
guard();
|
|
161
|
+
const name = `Uninstall ${provider.tool} (${CHANNEL_LABELS[channel]})`;
|
|
162
|
+
return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'uninstall', installPath: copyPath });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_installGuard(providerId, force, risk) {
|
|
166
|
+
const provider = this.registry.get(String(providerId || ''));
|
|
167
|
+
if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
|
|
168
|
+
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
169
|
+
const guard = () => {
|
|
170
|
+
const running = this.runningFor(provider.id);
|
|
171
|
+
if (running > 0 && !force) {
|
|
172
|
+
const err = httpError(409, `${running} ${provider.tool} session(s) are running; ${risk}`, 'provider_in_use');
|
|
173
|
+
err.running = running;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
guard();
|
|
178
|
+
if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
|
|
179
|
+
throw httpError(409, `${provider.tool} is already being installed, updated or removed`, 'install_in_progress');
|
|
180
|
+
}
|
|
181
|
+
return { provider, guard };
|
|
182
|
+
}
|
|
183
|
+
|
|
166
184
|
installsRunningFor(providerId) {
|
|
167
185
|
let n = 0;
|
|
168
|
-
for (const s of this.sessions.values()) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
|
|
186
|
+
for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
|
|
169
187
|
return n;
|
|
170
188
|
}
|
|
171
189
|
|
|
@@ -214,7 +232,7 @@ export class SessionManager extends EventEmitter {
|
|
|
214
232
|
|
|
215
233
|
runningFor(providerId) {
|
|
216
234
|
let n = 0;
|
|
217
|
-
for (const s of this.sessions.values()) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
|
|
235
|
+
for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
|
|
218
236
|
return n;
|
|
219
237
|
}
|
|
220
238
|
|
|
@@ -226,7 +244,7 @@ export class SessionManager extends EventEmitter {
|
|
|
226
244
|
}
|
|
227
245
|
|
|
228
246
|
_spawn({
|
|
229
|
-
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null,
|
|
247
|
+
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
230
248
|
extraEnv = null, dropEnv = null, clone = null, reporting = null,
|
|
231
249
|
}) {
|
|
232
250
|
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
@@ -285,7 +303,7 @@ export class SessionManager extends EventEmitter {
|
|
|
285
303
|
session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
|
|
286
304
|
if (task === 'install') {
|
|
287
305
|
session.on('exit', () => {
|
|
288
|
-
this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind }).catch(() => {});
|
|
306
|
+
this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind, path: installPath }).catch(() => {});
|
|
289
307
|
});
|
|
290
308
|
}
|
|
291
309
|
this.sessions.set(id, session);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { buildSpawnSpec } from './command-resolver.mjs';
|
|
6
|
+
import { formatCommand } from './install-channels.mjs';
|
|
7
|
+
|
|
8
|
+
export const RUNNER = fileURLToPath(import.meta.url);
|
|
9
|
+
|
|
10
|
+
export function encodePlan(plan) {
|
|
11
|
+
return Buffer.from(JSON.stringify(plan)).toString('base64url');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function comparable(p, platform) {
|
|
15
|
+
const m = platform === 'win32' ? path.win32 : path.posix;
|
|
16
|
+
const out = m.normalize(p.replace(/^\\\\\?\\/, ''));
|
|
17
|
+
return platform === 'win32' ? out.toLowerCase() : out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inside(file, dir, platform) {
|
|
21
|
+
const m = platform === 'win32' ? path.win32 : path.posix;
|
|
22
|
+
const f = comparable(file, platform);
|
|
23
|
+
const d = comparable(dir, platform);
|
|
24
|
+
return f === d || f.startsWith(d + m.sep);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function within(file, dirs, platform) {
|
|
28
|
+
return dirs.some((dir) => inside(file, dir, platform));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function linkTarget(file) {
|
|
32
|
+
try {
|
|
33
|
+
return fs.lstatSync(file).isSymbolicLink() ? path.resolve(path.dirname(file), fs.readlinkSync(file).replace(/^\\\\\?\\/, '')) : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function exists(file) {
|
|
40
|
+
try {
|
|
41
|
+
fs.lstatSync(file);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstLink(file) {
|
|
49
|
+
const steps = [];
|
|
50
|
+
for (let p = file; ; p = path.dirname(p)) {
|
|
51
|
+
steps.unshift(p);
|
|
52
|
+
if (path.dirname(p) === p) break;
|
|
53
|
+
}
|
|
54
|
+
return steps.find((p) => linkTarget(p) !== null) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function launchChain(launcher) {
|
|
58
|
+
const chain = [];
|
|
59
|
+
let file = launcher;
|
|
60
|
+
for (let hops = 0; hops < 40 && exists(file); hops++) {
|
|
61
|
+
chain.push(file);
|
|
62
|
+
const link = firstLink(file);
|
|
63
|
+
if (!link) break;
|
|
64
|
+
chain.push(link);
|
|
65
|
+
file = path.join(linkTarget(link), path.relative(link, file));
|
|
66
|
+
}
|
|
67
|
+
return chain;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function removeFile(file) {
|
|
71
|
+
fs.rmSync(file, { recursive: true, force: true, maxRetries: 3 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function runPlan(
|
|
75
|
+
{ run = null, remove = [], links = [], launcher = null },
|
|
76
|
+
{ env = process.env, platform = process.platform, log = console.log, rm = removeFile } = {},
|
|
77
|
+
) {
|
|
78
|
+
if (run) {
|
|
79
|
+
log(`> ${formatCommand(run.file, run.args)}`);
|
|
80
|
+
const spec = buildSpawnSpec(run.file, run.args, env, platform);
|
|
81
|
+
const verbatim = typeof spec.args === 'string';
|
|
82
|
+
const result = spawnSync(spec.file, verbatim ? [spec.args] : spec.args, { stdio: 'inherit', env, windowsVerbatimArguments: verbatim });
|
|
83
|
+
if (result.error) {
|
|
84
|
+
log(`Could not run ${run.file}: ${result.error.message}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (result.status !== 0) return result.status ?? 1;
|
|
88
|
+
}
|
|
89
|
+
const chain = launcher ? launchChain(launcher) : [];
|
|
90
|
+
const holdsChain = (file) => chain.some((kept) => inside(kept, file, platform));
|
|
91
|
+
const inChain = (file) => chain.some((kept) => comparable(kept, platform) === comparable(file, platform));
|
|
92
|
+
const del = (file) => {
|
|
93
|
+
try {
|
|
94
|
+
rm(file);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw Object.assign(err, { file });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const prune = (dir) => {
|
|
100
|
+
for (const name of fs.readdirSync(dir)) {
|
|
101
|
+
const file = path.join(dir, name);
|
|
102
|
+
if (!holdsChain(file)) del(file);
|
|
103
|
+
else if (!inChain(file) && linkTarget(file) === null) prune(file);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const owned = [];
|
|
107
|
+
const deferred = [];
|
|
108
|
+
try {
|
|
109
|
+
for (const file of [...remove, ...links]) {
|
|
110
|
+
const target = linkTarget(file);
|
|
111
|
+
if (target === null && !exists(file)) continue;
|
|
112
|
+
if (target !== null && !within(target, remove, platform)) {
|
|
113
|
+
log(`Kept ${file}: it links to ${target}, outside this installation.`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (target === null && links.includes(file)) {
|
|
117
|
+
log(`Kept ${file}: it is not a link to this installation.`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
owned.push(file);
|
|
121
|
+
if (!holdsChain(file)) {
|
|
122
|
+
del(file);
|
|
123
|
+
log(`Removed ${file}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
deferred.push(file);
|
|
127
|
+
if (target === null && fs.lstatSync(file).isDirectory()) prune(file);
|
|
128
|
+
}
|
|
129
|
+
for (const file of [...new Set([...chain].reverse())]) {
|
|
130
|
+
if (within(file, owned, platform) && exists(file)) del(file);
|
|
131
|
+
}
|
|
132
|
+
for (const file of deferred.sort((a, b) => b.length - a.length)) {
|
|
133
|
+
if (exists(file)) del(file);
|
|
134
|
+
log(`Removed ${file}`);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const code = err.code === 'ENOTDIR' && err.syscall === 'scandir' ? 'EPERM' : err.code;
|
|
138
|
+
const reason = { EBUSY: 'it is in use', EPERM: 'it is in use or protected', EACCES: 'permission was denied' }[code] || err.message;
|
|
139
|
+
log(`Could not remove ${err.path || err.file}: ${reason}. Stopped there. Close any program using it, then uninstall again.`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === RUNNER) {
|
|
146
|
+
const plan = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString('utf8') || '{}');
|
|
147
|
+
process.exitCode = runPlan(plan);
|
|
148
|
+
}
|
package/web/app.js
CHANGED
|
@@ -556,7 +556,7 @@ function renderProviders() {
|
|
|
556
556
|
update.title = provider.updateCommand ? `Run "${provider.updateCommand}" in a session` : '';
|
|
557
557
|
update.addEventListener('click', () => installProvider(provider, node));
|
|
558
558
|
renderHint(hint, provider);
|
|
559
|
-
renderCopies(node.querySelector('.copies'), provider);
|
|
559
|
+
renderCopies(node.querySelector('.copies'), provider, node);
|
|
560
560
|
renderVendorLinks(node, provider);
|
|
561
561
|
renderAccounts(node, provider);
|
|
562
562
|
renderUsage(node, provider);
|
|
@@ -572,16 +572,17 @@ function renderProviders() {
|
|
|
572
572
|
|
|
573
573
|
const openCopies = new Set();
|
|
574
574
|
|
|
575
|
-
function renderCopies(box, provider) {
|
|
575
|
+
function renderCopies(box, provider, card) {
|
|
576
576
|
const installs = provider.installs || [];
|
|
577
577
|
const warnings = provider.warnings || [];
|
|
578
|
-
box.hidden = warnings.length === 0;
|
|
578
|
+
box.hidden = warnings.length === 0 && !installs.some((i) => i.uninstall);
|
|
579
579
|
if (box.hidden) return;
|
|
580
|
+
box.classList.toggle('warned', warnings.length > 0);
|
|
580
581
|
const inUse = installs.some((i) => i.active);
|
|
581
582
|
const older = installs.some((i) => i.newer);
|
|
582
583
|
box.querySelector('summary').textContent = !inUse
|
|
583
584
|
? 'A copy exists off PATH'
|
|
584
|
-
: `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
|
|
585
|
+
: installs.length === 1 ? 'Installation' : `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
|
|
585
586
|
box.open = openCopies.has(provider.id);
|
|
586
587
|
box.addEventListener('toggle', () => (box.open ? openCopies.add(provider.id) : openCopies.delete(provider.id)));
|
|
587
588
|
const line = (className, ...content) => {
|
|
@@ -596,14 +597,25 @@ function renderCopies(box, provider) {
|
|
|
596
597
|
return item;
|
|
597
598
|
}), ...installs.map((install) => {
|
|
598
599
|
const item = document.createElement('li');
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
600
|
+
const channel = CHANNEL_LABELS[install.channel] || install.channel;
|
|
601
|
+
const name = [channel, install.version && `v${install.version}`, installs.length > 1 && (install.active ? 'in use' : 'not in use')];
|
|
602
|
+
const head = line('copy-head', line('copy-name', name.filter(Boolean).join(' · ')));
|
|
603
|
+
const where = line('copy-path', install.displayPath);
|
|
604
|
+
where.title = install.path;
|
|
605
|
+
item.append(head, where);
|
|
606
|
+
if (install.uninstall) {
|
|
607
|
+
const button = document.createElement('button');
|
|
608
|
+
button.type = 'button';
|
|
609
|
+
button.className = 'btn danger copy-uninstall';
|
|
610
|
+
button.textContent = 'Uninstall';
|
|
611
|
+
button.setAttribute('aria-label', `Uninstall the ${channel} copy of ${provider.tool} at ${install.displayPath}`);
|
|
612
|
+
button.title = install.uninstall.command
|
|
613
|
+
? [`Runs ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `then deletes ${p}`)].join('\n')
|
|
614
|
+
: ['Deletes', ...install.uninstall.remove].join('\n');
|
|
615
|
+
button.addEventListener('click', () => uninstallCopy(provider, card, install));
|
|
616
|
+
head.append(button);
|
|
605
617
|
} else {
|
|
606
|
-
item.append(line('copy-remove',
|
|
618
|
+
item.append(line('copy-remove', install.uninstallGuidance));
|
|
607
619
|
}
|
|
608
620
|
return item;
|
|
609
621
|
}));
|
|
@@ -1643,9 +1655,10 @@ function installNote(provider) {
|
|
|
1643
1655
|
const last = provider.lastInstall;
|
|
1644
1656
|
if (!last) return '';
|
|
1645
1657
|
if (last.outcome === 'failed') {
|
|
1646
|
-
const what =
|
|
1658
|
+
const what = { install: 'Install', uninstall: 'Uninstall' }[last.kind] || 'Update';
|
|
1647
1659
|
return last.exitCode === null ? `${what} failed` : `${what} failed (exit ${last.exitCode})`;
|
|
1648
1660
|
}
|
|
1661
|
+
if (last.kind === 'uninstall') return last.outcome === 'remaining' ? 'Uninstalled copy is still present' : '';
|
|
1649
1662
|
if (last.verification === 'failed') return `Installation completed, but ${provider.tool} verification failed`;
|
|
1650
1663
|
if (last.outcome === 'missing') return 'Installed, but not found on PATH';
|
|
1651
1664
|
if (last.outcome === 'unchanged') return 'No version change after update';
|
|
@@ -1671,10 +1684,20 @@ function providerState(provider) {
|
|
|
1671
1684
|
return parts.join(' · ');
|
|
1672
1685
|
}
|
|
1673
1686
|
|
|
1674
|
-
|
|
1687
|
+
function uninstallCopy(provider, card, install) {
|
|
1688
|
+
const what = install.uninstall.command
|
|
1689
|
+
? [`Runs: ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `Then deletes: ${p}`)]
|
|
1690
|
+
: install.uninstall.remove.map((p) => `Deletes: ${p}`);
|
|
1691
|
+
if (!confirm([`Uninstall ${provider.tool} from ${install.displayPath}?`, '', ...what, '', 'Your sign-in, settings and history are kept.'].join('\n'))) return;
|
|
1692
|
+
return installProvider(provider, card, { path: install.path });
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function installProvider(provider, card, { force = false, path = null } = {}) {
|
|
1675
1696
|
card.classList.add('busy');
|
|
1676
1697
|
try {
|
|
1677
|
-
const { session } =
|
|
1698
|
+
const { session } = path
|
|
1699
|
+
? await api('POST', `/providers/${provider.id}/uninstall`, { path, force })
|
|
1700
|
+
: await api('POST', `/providers/${provider.id}/install`, { force });
|
|
1678
1701
|
upsertSession(session);
|
|
1679
1702
|
openPanel(session.id);
|
|
1680
1703
|
} catch (err) {
|
|
@@ -1683,8 +1706,9 @@ async function installProvider(provider, card, { force = false } = {}) {
|
|
|
1683
1706
|
card.classList.remove('busy');
|
|
1684
1707
|
const n = err.running;
|
|
1685
1708
|
const what = `${n} ${provider.tool} session${n === 1 ? ' is' : 's are'} running`;
|
|
1686
|
-
|
|
1687
|
-
|
|
1709
|
+
const [doing, verb] = path ? ['Removing', 'Uninstall'] : ['Updating', 'Update'];
|
|
1710
|
+
if (confirm(`${what}. ${doing} ${provider.tool} while it runs can break ${n === 1 ? 'that session' : 'those sessions'}. ${verb} anyway?`)) {
|
|
1711
|
+
return installProvider(provider, card, { force: true, path });
|
|
1688
1712
|
}
|
|
1689
1713
|
return;
|
|
1690
1714
|
}
|
package/web/index.html
CHANGED
|
@@ -272,6 +272,10 @@
|
|
|
272
272
|
<a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage</a>
|
|
273
273
|
<a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing</a>
|
|
274
274
|
</div>
|
|
275
|
+
<details class="copies" hidden>
|
|
276
|
+
<summary></summary>
|
|
277
|
+
<ul></ul>
|
|
278
|
+
</details>
|
|
275
279
|
<div class="provider-actions">
|
|
276
280
|
<button class="btn primary new" type="button">New</button>
|
|
277
281
|
<button class="btn existing" type="button">Existing…</button>
|
|
@@ -279,10 +283,6 @@
|
|
|
279
283
|
<button class="btn update" type="button"></button>
|
|
280
284
|
<span class="hint"></span>
|
|
281
285
|
</div>
|
|
282
|
-
<details class="copies" hidden>
|
|
283
|
-
<summary></summary>
|
|
284
|
-
<ul></ul>
|
|
285
|
-
</details>
|
|
286
286
|
</article>
|
|
287
287
|
</template>
|
|
288
288
|
|
package/web/styles.css
CHANGED
|
@@ -209,14 +209,17 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
|
|
|
209
209
|
.provider-actions { margin-top: auto; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
210
210
|
.provider .hint { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
|
|
211
211
|
.provider .copies { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
|
|
212
|
-
.provider .copies summary { cursor: pointer;
|
|
212
|
+
.provider .copies summary { cursor: pointer; list-style: none; }
|
|
213
|
+
.provider .copies.warned summary { color: var(--warn); }
|
|
213
214
|
.provider .copies summary::-webkit-details-marker { display: none; }
|
|
214
215
|
.provider .copies summary::before { margin: 0 0.3em 0 0; transition: transform 0.15s var(--ease); }
|
|
215
216
|
.provider .copies[open] summary::before { transform: rotate(90deg); }
|
|
216
217
|
.provider .copies ul { margin: 6px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
|
|
217
218
|
.provider .copies li span { display: block; }
|
|
218
|
-
.provider .copies .copy-
|
|
219
|
-
.provider .copies
|
|
219
|
+
.provider .copies .copy-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
|
|
220
|
+
.provider .copies .copy-name { color: var(--text); min-width: 0; }
|
|
221
|
+
.provider .copies .copy-path { font-family: var(--font-mono); font-size: 0.72rem; }
|
|
222
|
+
.provider .copies .copy-uninstall { flex: none; padding: 2px 10px; font-size: 0.75rem; }
|
|
220
223
|
.provider.busy { opacity: 0.6; pointer-events: none; }
|
|
221
224
|
|
|
222
225
|
.provider-icon {
|