@oddessentials/agent-guild 0.13.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 +5 -1
- package/config/providers.default.json +15 -9
- package/package.json +3 -1
- package/src/manager/install-channels.mjs +53 -21
- package/src/manager/providers.mjs +38 -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 +57 -30
- package/web/index.html +5 -4
- package/web/styles.css +12 -4
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
|
|
@@ -47,7 +51,7 @@ Grok Build and a plain shell each get a card. A tool that is missing shows
|
|
|
47
51
|
vendor's install command for a tool not published on npm. An installed
|
|
48
52
|
tool shows its version and offers **Update** when a newer one is out, using
|
|
49
53
|
the same installer that put it there: npm, Homebrew, WinGet or the vendor's
|
|
50
|
-
own.
|
|
54
|
+
own. The cloud icon on each card opens the vendor's web app.
|
|
51
55
|
|
|
52
56
|
**Real terminals that outlive the page.** Every session is a full interactive
|
|
53
57
|
terminal: type instructions, answer prompts, watch output. Run as many as you
|
|
@@ -21,20 +21,21 @@
|
|
|
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",
|
|
31
31
|
"billingUrl": "https://claude.ai/settings/billing",
|
|
32
|
+
"cloudUrl": "https://claude.ai/code",
|
|
32
33
|
"win32": {
|
|
33
34
|
"install": "irm https://claude.ai/install.ps1 | iex",
|
|
34
35
|
"channels": {
|
|
35
|
-
"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"] },
|
|
36
37
|
"winget": { "id": "Anthropic.ClaudeCode" },
|
|
37
|
-
"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"] }
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
},
|
|
@@ -58,17 +59,18 @@
|
|
|
58
59
|
"install": "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
|
|
59
60
|
"npmNote": "npm installs the native Codex binary.",
|
|
60
61
|
"channels": {
|
|
61
|
-
"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"] },
|
|
62
63
|
"brew": { "names": ["codex"] },
|
|
63
64
|
"winget": { "id": "OpenAI.Codex" }
|
|
64
65
|
},
|
|
65
66
|
"docs": "https://github.com/openai/codex",
|
|
66
67
|
"usageUrl": "https://chatgpt.com/codex/settings/usage",
|
|
67
68
|
"billingUrl": "https://chatgpt.com/#settings/Billing",
|
|
69
|
+
"cloudUrl": "https://chatgpt.com/codex",
|
|
68
70
|
"win32": {
|
|
69
71
|
"install": "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"",
|
|
70
72
|
"channels": {
|
|
71
|
-
"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"] },
|
|
72
74
|
"winget": { "id": "OpenAI.Codex" }
|
|
73
75
|
}
|
|
74
76
|
}
|
|
@@ -88,14 +90,16 @@
|
|
|
88
90
|
"monogram": "G",
|
|
89
91
|
"install": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
90
92
|
"channels": {
|
|
91
|
-
"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 }
|
|
92
95
|
},
|
|
93
96
|
"docs": "https://antigravity.google/docs/cli/install",
|
|
94
97
|
"billingUrl": "https://one.google.com/settings",
|
|
98
|
+
"cloudUrl": "https://gemini.google.com/",
|
|
95
99
|
"win32": {
|
|
96
100
|
"install": "irm https://antigravity.google/cli/install.ps1 | iex",
|
|
97
101
|
"channels": {
|
|
98
|
-
"native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "
|
|
102
|
+
"native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "remove": ["~/AppData/Local/agy"] }
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
},
|
|
@@ -118,11 +122,13 @@
|
|
|
118
122
|
"install": "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
119
123
|
"npmNote": "npm installs the native grok binary into ~/.grok/bin.",
|
|
120
124
|
"channels": {
|
|
121
|
-
"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"] }
|
|
122
127
|
},
|
|
123
128
|
"docs": "https://docs.x.ai/build/overview",
|
|
124
129
|
"usageUrl": "https://grok.com/?_s=usage",
|
|
125
130
|
"billingUrl": "https://grok.com/?_s=billing",
|
|
131
|
+
"cloudUrl": "https://grok.com/",
|
|
126
132
|
"win32": {
|
|
127
133
|
"install": "irm https://x.ai/cli/install.ps1 | iex"
|
|
128
134
|
}
|
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;
|
|
@@ -182,6 +184,7 @@ function normalize(raw, platform, warnings) {
|
|
|
182
184
|
docs: String(merged.docs || ''),
|
|
183
185
|
usageUrl: httpsUrl(merged.usageUrl, 'usageUrl', merged.id, warnings),
|
|
184
186
|
billingUrl: httpsUrl(merged.billingUrl, 'billingUrl', merged.id, warnings),
|
|
187
|
+
cloudUrl: httpsUrl(merged.cloudUrl, 'cloudUrl', merged.id, warnings),
|
|
185
188
|
enabled: merged.enabled !== false,
|
|
186
189
|
};
|
|
187
190
|
}
|
|
@@ -224,6 +227,12 @@ export function loadProviders({ userFile, platform = process.platform } = {}) {
|
|
|
224
227
|
return { providers, warnings };
|
|
225
228
|
}
|
|
226
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
|
+
|
|
227
236
|
export class ProviderRegistry extends EventEmitter {
|
|
228
237
|
/**
|
|
229
238
|
* @param {object} opts
|
|
@@ -407,7 +416,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
407
416
|
if (changed) this.emit('updated');
|
|
408
417
|
}
|
|
409
418
|
|
|
410
|
-
async finishInstall(id, { exitCode = null, kind = 'update' } = {}) {
|
|
419
|
+
async finishInstall(id, { exitCode = null, kind = 'update', path: removed = null } = {}) {
|
|
411
420
|
const provider = this.get(id);
|
|
412
421
|
if (!provider) return;
|
|
413
422
|
const before = this.versions.get(id)?.installed ?? null;
|
|
@@ -417,6 +426,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
417
426
|
if (!entry) return;
|
|
418
427
|
let outcome;
|
|
419
428
|
if (exitCode !== 0) outcome = 'failed';
|
|
429
|
+
else if (kind === 'uninstall') outcome = this.installsFor(provider).some((i) => i.resolvedPath === removed) ? 'remaining' : 'removed';
|
|
420
430
|
else if (kind === 'install') outcome = this.resolve(provider) ? 'installed' : 'missing';
|
|
421
431
|
else if (entry.installed === null) outcome = 'done';
|
|
422
432
|
else outcome = entry.installed !== before ? 'updated' : 'unchanged';
|
|
@@ -508,13 +518,13 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
508
518
|
installWarnings(provider, installs) {
|
|
509
519
|
const label = (i) => [CHANNEL_LABELS[i.channel], i.version && `v${i.version}`].filter(Boolean).join(' ');
|
|
510
520
|
const active = installs.find((i) => i.active);
|
|
511
|
-
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.`);
|
|
512
522
|
const warnings = [];
|
|
513
523
|
if (installs.length > 1) {
|
|
514
|
-
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}.`);
|
|
515
525
|
}
|
|
516
526
|
const newer = installs.find((i) => i.newer);
|
|
517
|
-
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}.`);
|
|
518
528
|
return warnings;
|
|
519
529
|
}
|
|
520
530
|
|
|
@@ -586,17 +596,23 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
586
596
|
const latest = versions?.latest ?? null;
|
|
587
597
|
const channel = this.channelFor(provider, resolvedPath);
|
|
588
598
|
const update = this.updateFor(provider, channel);
|
|
599
|
+
const shown = (file) => homeRelative(file, { ...this.env, ...provider.env }, this.platform);
|
|
589
600
|
const installs = this.installsFor(provider, resolvedPath).map((install) => {
|
|
590
601
|
const active = install.resolvedPath === resolvedPath;
|
|
591
602
|
const copy = active ? { version: installed, status: versions?.versionStatus ?? null } : versions?.copies?.[install.resolvedPath];
|
|
592
603
|
return {
|
|
593
604
|
path: install.resolvedPath,
|
|
605
|
+
displayPath: shown(install.resolvedPath),
|
|
594
606
|
channel: install.channel,
|
|
595
607
|
version: copy?.version ?? null,
|
|
596
608
|
versionStatus: copy?.status ?? null,
|
|
597
609
|
active,
|
|
598
610
|
onPath: install.onPath,
|
|
599
|
-
|
|
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)),
|
|
600
616
|
};
|
|
601
617
|
});
|
|
602
618
|
const inUse = installs.find((i) => i.active)?.version;
|
|
@@ -637,6 +653,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
637
653
|
docs: provider.docs,
|
|
638
654
|
usageUrl: provider.usageUrl,
|
|
639
655
|
billingUrl: provider.billingUrl,
|
|
656
|
+
cloudUrl: provider.cloudUrl,
|
|
640
657
|
available: Boolean(resolvedPath),
|
|
641
658
|
resolvedPath,
|
|
642
659
|
};
|
|
@@ -681,6 +698,18 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
681
698
|
return { spec: buildSpawnSpec(update.file, args, this.env, this.platform), channel: channel.channel };
|
|
682
699
|
}
|
|
683
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
|
+
|
|
684
713
|
async installSpec(provider) {
|
|
685
714
|
if (!provider.package) {
|
|
686
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,8 +556,8 @@ 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);
|
|
560
|
-
|
|
559
|
+
renderCopies(node.querySelector('.copies'), provider, node);
|
|
560
|
+
renderVendorLinks(node, provider);
|
|
561
561
|
renderAccounts(node, provider);
|
|
562
562
|
renderUsage(node, provider);
|
|
563
563
|
renderReportingSetup(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
|
}));
|
|
@@ -627,19 +639,22 @@ function renderHint(hint, provider) {
|
|
|
627
639
|
hint.append(' ', link);
|
|
628
640
|
}
|
|
629
641
|
|
|
630
|
-
function
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
|
|
642
|
+
function renderVendorLinks(card, provider) {
|
|
643
|
+
const usage = setVendorLink(card.querySelector('.usage-link'), provider.usageUrl, `${provider.vendor} usage console`);
|
|
644
|
+
const billing = setVendorLink(card.querySelector('.billing-link'), provider.billingUrl, `${provider.vendor} billing console`);
|
|
645
|
+
card.querySelector('.provider-links').hidden = !usage && !billing;
|
|
646
|
+
setVendorLink(card.querySelector('.cloud-link'), provider.cloudUrl, `${provider.vendor} web app`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Point a link at an https URL, or hide it. Returns whether it is shown. */
|
|
650
|
+
function setVendorLink(link, url, label) {
|
|
651
|
+
const href = httpsHref(url);
|
|
652
|
+
link.hidden = !href;
|
|
653
|
+
if (!href) { link.removeAttribute('href'); return false; }
|
|
654
|
+
link.href = href;
|
|
655
|
+
link.title = `${label}: ${href}`;
|
|
656
|
+
link.setAttribute('aria-label', `${label} (opens in a new tab)`);
|
|
657
|
+
return true;
|
|
643
658
|
}
|
|
644
659
|
|
|
645
660
|
function renderTier(card, provider, usage) {
|
|
@@ -1640,9 +1655,10 @@ function installNote(provider) {
|
|
|
1640
1655
|
const last = provider.lastInstall;
|
|
1641
1656
|
if (!last) return '';
|
|
1642
1657
|
if (last.outcome === 'failed') {
|
|
1643
|
-
const what =
|
|
1658
|
+
const what = { install: 'Install', uninstall: 'Uninstall' }[last.kind] || 'Update';
|
|
1644
1659
|
return last.exitCode === null ? `${what} failed` : `${what} failed (exit ${last.exitCode})`;
|
|
1645
1660
|
}
|
|
1661
|
+
if (last.kind === 'uninstall') return last.outcome === 'remaining' ? 'Uninstalled copy is still present' : '';
|
|
1646
1662
|
if (last.verification === 'failed') return `Installation completed, but ${provider.tool} verification failed`;
|
|
1647
1663
|
if (last.outcome === 'missing') return 'Installed, but not found on PATH';
|
|
1648
1664
|
if (last.outcome === 'unchanged') return 'No version change after update';
|
|
@@ -1668,10 +1684,20 @@ function providerState(provider) {
|
|
|
1668
1684
|
return parts.join(' · ');
|
|
1669
1685
|
}
|
|
1670
1686
|
|
|
1671
|
-
|
|
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 } = {}) {
|
|
1672
1696
|
card.classList.add('busy');
|
|
1673
1697
|
try {
|
|
1674
|
-
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 });
|
|
1675
1701
|
upsertSession(session);
|
|
1676
1702
|
openPanel(session.id);
|
|
1677
1703
|
} catch (err) {
|
|
@@ -1680,8 +1706,9 @@ async function installProvider(provider, card, { force = false } = {}) {
|
|
|
1680
1706
|
card.classList.remove('busy');
|
|
1681
1707
|
const n = err.running;
|
|
1682
1708
|
const what = `${n} ${provider.tool} session${n === 1 ? ' is' : 's are'} running`;
|
|
1683
|
-
|
|
1684
|
-
|
|
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 });
|
|
1685
1712
|
}
|
|
1686
1713
|
return;
|
|
1687
1714
|
}
|
package/web/index.html
CHANGED
|
@@ -259,6 +259,7 @@
|
|
|
259
259
|
<span class="tool"></span>
|
|
260
260
|
<span class="state"></span>
|
|
261
261
|
</span>
|
|
262
|
+
<a class="cloud-link" target="_blank" rel="noopener noreferrer" hidden></a>
|
|
262
263
|
</div>
|
|
263
264
|
<div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
|
|
264
265
|
<div class="usage"></div>
|
|
@@ -271,6 +272,10 @@
|
|
|
271
272
|
<a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage</a>
|
|
272
273
|
<a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing</a>
|
|
273
274
|
</div>
|
|
275
|
+
<details class="copies" hidden>
|
|
276
|
+
<summary></summary>
|
|
277
|
+
<ul></ul>
|
|
278
|
+
</details>
|
|
274
279
|
<div class="provider-actions">
|
|
275
280
|
<button class="btn primary new" type="button">New</button>
|
|
276
281
|
<button class="btn existing" type="button">Existing…</button>
|
|
@@ -278,10 +283,6 @@
|
|
|
278
283
|
<button class="btn update" type="button"></button>
|
|
279
284
|
<span class="hint"></span>
|
|
280
285
|
</div>
|
|
281
|
-
<details class="copies" hidden>
|
|
282
|
-
<summary></summary>
|
|
283
|
-
<ul></ul>
|
|
284
|
-
</details>
|
|
285
286
|
</article>
|
|
286
287
|
</template>
|
|
287
288
|
|
package/web/styles.css
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
--icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
|
|
24
24
|
--icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
|
|
25
25
|
--icon-external: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
|
|
26
|
-
--icon-
|
|
26
|
+
--icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
|
|
27
|
+
--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
|
|
27
28
|
--icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
|
|
28
29
|
--icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 10.8v6.2' stroke-width='2.4'/%3E%3Ccircle cx='12' cy='7.3' r='1.2' fill='black' stroke='none'/%3E%3C/svg%3E");
|
|
29
30
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
@@ -201,17 +202,24 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
|
|
|
201
202
|
.console-link { color: var(--accent); text-decoration: none; }
|
|
202
203
|
.console-link:hover { text-decoration: underline; }
|
|
203
204
|
.console-link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 3px; }
|
|
205
|
+
.cloud-link { display: block; flex: none; align-self: flex-start; width: 32px; height: 32px; margin: -4px -6px 0 0; border-radius: 8px; color: var(--muted); }
|
|
206
|
+
.cloud-link::before { content: ""; display: block; width: 100%; height: 100%; background: currentColor; mask: var(--icon-cloud) center / 20px no-repeat; }
|
|
207
|
+
.cloud-link:hover { color: var(--accent); }
|
|
208
|
+
.cloud-link:focus-visible { color: var(--accent); outline: 2px solid var(--focus); outline-offset: 0; }
|
|
204
209
|
.provider-actions { margin-top: auto; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
205
210
|
.provider .hint { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
|
|
206
211
|
.provider .copies { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
|
|
207
|
-
.provider .copies summary { cursor: pointer;
|
|
212
|
+
.provider .copies summary { cursor: pointer; list-style: none; }
|
|
213
|
+
.provider .copies.warned summary { color: var(--warn); }
|
|
208
214
|
.provider .copies summary::-webkit-details-marker { display: none; }
|
|
209
215
|
.provider .copies summary::before { margin: 0 0.3em 0 0; transition: transform 0.15s var(--ease); }
|
|
210
216
|
.provider .copies[open] summary::before { transform: rotate(90deg); }
|
|
211
217
|
.provider .copies ul { margin: 6px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
|
|
212
218
|
.provider .copies li span { display: block; }
|
|
213
|
-
.provider .copies .copy-
|
|
214
|
-
.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; }
|
|
215
223
|
.provider.busy { opacity: 0.6; pointer-events: none; }
|
|
216
224
|
|
|
217
225
|
.provider-icon {
|