agentcage 0.1.6 → 0.1.8
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 +26 -1
- package/bin/agentcage.js +23 -5
- package/package.json +1 -1
- package/src/catalog.js +2 -0
- package/src/connections.js +100 -0
- package/src/container.js +24 -3
- package/src/git.js +13 -7
- package/src/icon.js +61 -8
- package/src/logo.png +0 -0
- package/src/logo.svg +17 -0
- package/src/opencode.js +18 -5
- package/src/run.js +9 -6
- package/src/server.js +188 -0
- package/src/settings.js +9 -2
- package/src/shortcut.js +90 -0
- package/src/ui.html +448 -0
- package/src/wizard.js +13 -19
package/src/server.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// `agentcage ui`: a local web UI. A JSON API on 127.0.0.1 over the same functions the CLI uses, plus one static page.
|
|
2
|
+
// Security: loopback only, a random session token (sent by the page in a header, so other sites can't call the API:
|
|
3
|
+
// a custom header needs a CORS preflight, which is never answered) and a Host check against DNS rebinding.
|
|
4
|
+
// The server stops itself when the UI window has been closed for a few seconds and no job is running.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { AGENTS, DEV_TOOLS, PROVIDERS, TOOLS, hostInfo } from './catalog.js';
|
|
10
|
+
import { ROOT, cleanName, createContainer, findIdes, hasContainer, listContainers, loadContainer, nameError, openIde, removeContainer, state, up, upgradeContainer } from './container.js';
|
|
11
|
+
import { isGitUrl, localRemote, parseRemote } from './git.js';
|
|
12
|
+
import { connectDesktop, desktopRunning, desktopRunningAsync, desktopServers, detectDesktop, latestVersion, serverName, setProviderKey, setupProject } from './opencode.js';
|
|
13
|
+
import { run } from './run.js';
|
|
14
|
+
import { CHOICES, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
|
|
15
|
+
import { openWindow, pickFolder } from './shortcut.js';
|
|
16
|
+
import * as conn from './connections.js';
|
|
17
|
+
|
|
18
|
+
const PAGE = new URL('./ui.html', import.meta.url), LOCK = join(ROOT, 'ui.json');
|
|
19
|
+
const CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'";
|
|
20
|
+
const fail = (status, message) => Object.assign(new Error(message), { status });
|
|
21
|
+
|
|
22
|
+
// Already running? Then just open its window again.
|
|
23
|
+
async function running() {
|
|
24
|
+
try {
|
|
25
|
+
const { port, token } = JSON.parse(readFileSync(LOCK, 'utf8'));
|
|
26
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/meta`, { headers: { 'x-agentcage-token': token }, signal: AbortSignal.timeout(1500) });
|
|
27
|
+
return r.ok && `http://127.0.0.1:${port}/#${token}`;
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function serve({ open = true } = {}) {
|
|
32
|
+
const again = await running();
|
|
33
|
+
if (again) { if (open) openWindow(again); return console.log(`agentcage UI is already running: ${again}`); }
|
|
34
|
+
|
|
35
|
+
const token = randomBytes(24).toString('hex'), tok = Buffer.from(token), ides = findIdes();
|
|
36
|
+
const busy = new Map(); // container name → { label, error?, create? }: what is running for it (polled by the page)
|
|
37
|
+
let port, clients = 0, seen = false;
|
|
38
|
+
|
|
39
|
+
// Runs fn in the background; fn gets createContainer's step(label, f) to report progress
|
|
40
|
+
const job = (name, label, fn, create = false) => {
|
|
41
|
+
busy.set(name, { label, create });
|
|
42
|
+
fn((l, f) => ((busy.get(name).label = l), f())).then(() => busy.delete(name), e => busy.set(name, { ...busy.get(name), error: e.message }))
|
|
43
|
+
.finally(maybeExit);
|
|
44
|
+
};
|
|
45
|
+
const ensureRunning = async b => { if (state(b) !== 'running') await up(b); };
|
|
46
|
+
const project = b => (b.tools.includes('opencode') ? setupProject(b).catch(() => {}) : null); // icon/name: nice to have
|
|
47
|
+
|
|
48
|
+
// One `docker ps` for all containers (null: Docker is not running)
|
|
49
|
+
const dockerStates = () => run('docker', ['ps', '-a', '--format', '{{.Names}}\t{{.State}}\t{{.Status}}'])
|
|
50
|
+
.then(out => new Map(out.split('\n').filter(Boolean).map(l => l.split('\t')).map(([n, s, st]) => [n, { state: s, status: st }])), () => null);
|
|
51
|
+
|
|
52
|
+
async function snapshot() {
|
|
53
|
+
const d = detectDesktop();
|
|
54
|
+
const [docker, desktopOpen, ports] = await Promise.all([dockerStates(), desktopRunningAsync(),
|
|
55
|
+
d.hasDb ? desktopServers().then(l => new Set(l.map(x => x.port)), () => null) : null]);
|
|
56
|
+
const list = listContainers().map(b => ({
|
|
57
|
+
name: b.name, container: b.container, ...(docker?.get(b.container) ?? { state: docker ? 'missing' : 'unknown', status: '' }),
|
|
58
|
+
port: b.port, tools: b.tools, git: b.git, source: b.source, opencodeVersion: b.opencodeVersion, server: serverName(b),
|
|
59
|
+
registered: ports && b.tools.includes('opencode') ? ports.has(b.port) : null, busy: busy.get(b.name) ?? null,
|
|
60
|
+
}));
|
|
61
|
+
for (const [name, job] of busy) if (!list.some(c => c.name === name)) list.push({ name, state: 'creating', status: '', tools: [], busy: job });
|
|
62
|
+
return { docker: !!docker, desktop: { found: d.hasDb, version: d.version ?? null, running: desktopOpen }, containers: list, ides, settings: loadSettings() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// What the dialog needs to know about a source and name
|
|
66
|
+
function check(src, name) {
|
|
67
|
+
const git = !!src && isGitUrl(src), srcError = !src ? 'Enter a folder or a repository URL' : !git && !existsSync(src) ? 'Folder not found' : undefined;
|
|
68
|
+
const remote = !srcError && parseRemote(git ? src : localRemote(src));
|
|
69
|
+
const suggested = srcError ? '' : cleanName(src);
|
|
70
|
+
return { srcError, suggested, nameError: (name || suggested) ? nameError(name || suggested) : undefined,
|
|
71
|
+
remote: remote ? `${remote.host}/${remote.path}` : null, help: remote ? hostInfo(remote.host).help(remote.path) : null,
|
|
72
|
+
cloneWith: git && conn.cloneToken(src) ? 'your connection' : null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function create(o) {
|
|
76
|
+
if ([...busy.values()].some(j => j.create && !j.error)) throw fail(409, 'Another container is being created. Wait for it to finish.');
|
|
77
|
+
const src = o.src?.trim(), name = o.name?.trim(), c = check(src, name);
|
|
78
|
+
if (c.srcError || c.nameError || !name) throw fail(400, c.srcError ?? c.nameError ?? 'Enter a name');
|
|
79
|
+
const remoteUrl = isGitUrl(src) ? src : localRemote(src), git = !c.remote ? 'none' : o.git === 'token' ? 'token' : 'forward';
|
|
80
|
+
if (git === 'token' && !o.token) throw fail(400, 'Paste the repo token, or choose forwarded credentials');
|
|
81
|
+
const tools = TOOLS.map(t => t.id).filter(id => o.tools?.includes(id)), clone = isGitUrl(src) ? conn.cloneToken(src) : null;
|
|
82
|
+
job(name, 'Preparing', async step => {
|
|
83
|
+
const oc = tools.includes('opencode'), opencodeVersion = oc ? detectDesktop().version ?? await latestVersion() : undefined;
|
|
84
|
+
const b = await createContainer({ src, name, tools, git, token: git === 'token' ? o.token : undefined, remote: remoteUrl, opencodeVersion,
|
|
85
|
+
cloneToken: clone?.token, cloneBearer: clone?.header }, step);
|
|
86
|
+
if (oc) {
|
|
87
|
+
await step('Setting up the OpenCode project', () => project(b));
|
|
88
|
+
if (detectDesktop().hasDb && !desktopRunning()) await step('Adding it to OpenCode Desktop', () => connectDesktop(b));
|
|
89
|
+
}
|
|
90
|
+
if (o.ide) openIde(b, o.ide);
|
|
91
|
+
}, true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ACTIONS = {
|
|
95
|
+
start: b => job(b.name, 'Starting', () => ensureRunning(b)),
|
|
96
|
+
stop: b => job(b.name, 'Stopping', () => run('docker', ['stop', b.container])),
|
|
97
|
+
open: (b, o) => job(b.name, 'Starting', async () => { await ensureRunning(b); openIde(b, o.ide || ides[0]?.value || 'code'); }),
|
|
98
|
+
upgrade: b => job(b.name, 'Rebuilding (code, logins and sessions are kept)', async () => {
|
|
99
|
+
await upgradeContainer(b, detectDesktop().version ?? await latestVersion()); await project(b);
|
|
100
|
+
}),
|
|
101
|
+
remove: b => job(b.name, 'Removing', () => removeContainer(b)),
|
|
102
|
+
connect: b => {
|
|
103
|
+
if (desktopRunning()) throw fail(409, 'Close OpenCode Desktop first, then click Add to OpenCode again. Its server list can only be changed while it is closed.');
|
|
104
|
+
job(b.name, 'Adding to OpenCode Desktop', async () => { await ensureRunning(b); await project(b); await connectDesktop(b); });
|
|
105
|
+
},
|
|
106
|
+
provider: async (b, o) => {
|
|
107
|
+
if (!PROVIDERS.some(x => x.id === o.id) || !o.key) throw fail(400, 'Pick a provider and paste its API key');
|
|
108
|
+
await ensureRunning(b);
|
|
109
|
+
await setProviderKey(b, o.id, o.key);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function saveSettingsFrom(body) {
|
|
114
|
+
const s = loadSettings();
|
|
115
|
+
for (const [k, v] of Object.entries(body)) {
|
|
116
|
+
if (!(k in DEFAULTS)) continue;
|
|
117
|
+
if (CHOICES[k] ? !(v in CHOICES[k]) : !/^[a-z0-9-]*$/.test(v)) throw fail(400, `${LABELS[k]}: invalid value`);
|
|
118
|
+
s[k] = v;
|
|
119
|
+
}
|
|
120
|
+
saveSettings(s);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ROUTES = [
|
|
124
|
+
['GET', /^\/api\/meta$/, () => ({ agents: AGENTS, devTools: DEV_TOOLS, providers: PROVIDERS, labels: LABELS, choices: CHOICES, keys: Object.keys(DEFAULTS), platform: process.platform })],
|
|
125
|
+
['GET', /^\/api\/state$/, snapshot],
|
|
126
|
+
['GET', /^\/api\/check$/, (_, q) => check(q.get('src')?.trim(), q.get('name')?.trim())],
|
|
127
|
+
['POST', /^\/api\/containers$/, (_, __, body) => create(body)],
|
|
128
|
+
['POST', /^\/api\/containers\/([a-z0-9-]+)\/(\w+)$/, async ([, name, action], _, body) => {
|
|
129
|
+
if (action === 'dismiss' || (action === 'remove' && !hasContainer(name))) return void busy.delete(name);
|
|
130
|
+
if (!ACTIONS[action]) throw fail(404, 'Unknown action');
|
|
131
|
+
if (busy.get(name) && !busy.get(name).error) throw fail(409, `${name} is busy: ${busy.get(name).label}`);
|
|
132
|
+
busy.delete(name);
|
|
133
|
+
await ACTIONS[action](loadContainer(name), body);
|
|
134
|
+
}],
|
|
135
|
+
['PUT', /^\/api\/settings$/, (_, __, body) => saveSettingsFrom(body)],
|
|
136
|
+
['GET', /^\/api\/connections$/, () => conn.status()],
|
|
137
|
+
['PUT', /^\/api\/connections\/(github|azure)$/, ([, id], _, body) => conn.savePat(id, body.token?.trim(), body.org?.trim())],
|
|
138
|
+
['DELETE', /^\/api\/connections\/(github|azure)$/, ([, id]) => conn.removePat(id)],
|
|
139
|
+
['GET', /^\/api\/repos\/(github|azure)$/, ([, id], q) => conn.repos(id, q.get('org'))],
|
|
140
|
+
['POST', /^\/api\/browse$/, async (_, __, body) => ({ path: await pickFolder(body.start?.trim()) })],
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const server = createServer(async (req, res) => {
|
|
144
|
+
const url = new URL(req.url, 'http://localhost');
|
|
145
|
+
const send = (code, body, type = 'application/json') => {
|
|
146
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': CSP });
|
|
147
|
+
res.end(type === 'application/json' ? JSON.stringify(body ?? {}) : body);
|
|
148
|
+
};
|
|
149
|
+
if (req.headers.host !== `127.0.0.1:${port}`) return send(403, { error: 'Wrong host' });
|
|
150
|
+
if (req.method === 'GET' && url.pathname === '/') return send(200, readFileSync(PAGE), 'text/html; charset=utf-8');
|
|
151
|
+
if (req.method === 'GET' && url.pathname === '/icon.svg') return send(200, readFileSync(new URL('./logo.svg', import.meta.url)), 'image/svg+xml');
|
|
152
|
+
const t = Buffer.from(String(req.headers['x-agentcage-token'] ?? ''));
|
|
153
|
+
if (t.length !== tok.length || !timingSafeEqual(t, tok)) return send(401, { error: 'Not authorized. Open the UI with: agentcage ui' });
|
|
154
|
+
|
|
155
|
+
// The page keeps this request open while it is open; when it closes, the server can stop
|
|
156
|
+
if (url.pathname === '/api/alive') {
|
|
157
|
+
clients++; seen = true;
|
|
158
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).write('ok\n');
|
|
159
|
+
const ping = setInterval(() => res.write('.'), 20000);
|
|
160
|
+
// res, not req: req's close fires as soon as its (empty) body is read; res's fires when the connection drops
|
|
161
|
+
return res.on('close', () => { clients--; clearInterval(ping); setTimeout(maybeExit, 8000); });
|
|
162
|
+
}
|
|
163
|
+
const route = ROUTES.find(([m, re]) => m === req.method && re.test(url.pathname));
|
|
164
|
+
if (!route) return send(404, { error: 'Not found' });
|
|
165
|
+
try {
|
|
166
|
+
let raw = '';
|
|
167
|
+
for await (const c of req) raw += c;
|
|
168
|
+
send(200, await route[2](url.pathname.match(route[1]), url.searchParams, raw ? JSON.parse(raw) : {}));
|
|
169
|
+
} catch (e) { send(e.status ?? 500, { error: e.message }); }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function maybeExit() {
|
|
173
|
+
if (clients > 0 || !seen || [...busy.values()].some(j => !j.error)) return;
|
|
174
|
+
rmSync(LOCK, { force: true });
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
// Nobody opened the page within 10 minutes (no browser?): stop
|
|
178
|
+
setTimeout(() => { if (!seen) { seen = true; maybeExit(); } }, 10 * 60 * 1000).unref();
|
|
179
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { rmSync(LOCK, { force: true }); process.exit(0); });
|
|
180
|
+
|
|
181
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
182
|
+
port = server.address().port;
|
|
183
|
+
mkdirSync(ROOT, { recursive: true }); // first run: ~/.agentcage may not exist yet
|
|
184
|
+
writeFileSync(LOCK, JSON.stringify({ port, token, pid: process.pid }), { mode: 0o600 });
|
|
185
|
+
const url = `http://127.0.0.1:${port}/#${token}`;
|
|
186
|
+
console.log(`agentcage UI: ${url}\nIt stops by itself when you close the window. Ctrl+C stops it now.`);
|
|
187
|
+
if (open) openWindow(url);
|
|
188
|
+
}
|
package/src/settings.js
CHANGED
|
@@ -6,10 +6,17 @@ import { join } from 'node:path';
|
|
|
6
6
|
const FILE = join(homedir(), '.agentcage', 'settings.json');
|
|
7
7
|
// Project icon colors OpenCode Desktop can show (others render as gray). green first: our default, gray: OpenCode's.
|
|
8
8
|
export const COLORS = ['green', 'gray', 'pink', 'blue', 'red', 'purple', 'yellow', 'orange', 'cyan'];
|
|
9
|
-
export const DEFAULTS = { color: 'green', containerPrefix: 'ac-', serverPrefix: 'ac-', projectPrefix: 'ac-' };
|
|
9
|
+
export const DEFAULTS = { projectIcon: 'image', color: 'green', containerPrefix: 'ac-', serverPrefix: 'ac-', projectPrefix: 'ac-', gitToken: 'never' };
|
|
10
10
|
export const LABELS = {
|
|
11
|
-
color: 'OpenCode project color', containerPrefix: 'Docker container prefix',
|
|
11
|
+
projectIcon: 'OpenCode project icon', color: 'OpenCode project color (for a plain-color icon)', containerPrefix: 'Docker container prefix',
|
|
12
12
|
serverPrefix: 'OpenCode server prefix', projectPrefix: 'OpenCode project prefix',
|
|
13
|
+
gitToken: 'Git token per project (GitHub, GitLab, Bitbucket, Azure DevOps)',
|
|
14
|
+
};
|
|
15
|
+
// Settings with fixed choices ({ value: label }); the others are free-text prefixes
|
|
16
|
+
export const CHOICES = {
|
|
17
|
+
projectIcon: { image: 'generated image (prefix + first three letters of the name)', color: 'plain color' },
|
|
18
|
+
color: Object.fromEntries(COLORS.map(c => [c, c === 'gray' ? "gray (OpenCode's default)" : c])),
|
|
19
|
+
gitToken: { never: "don't use (push with the IDE's forwarded credentials)", ask: 'ask every time', always: 'always use' },
|
|
13
20
|
};
|
|
14
21
|
|
|
15
22
|
export const loadSettings = () => ({ ...DEFAULTS, ...(existsSync(FILE) && JSON.parse(readFileSync(FILE, 'utf8'))) });
|
package/src/shortcut.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Desktop / Start Menu shortcut for `agentcage ui`, its icon, and opening the UI in an app-style browser window.
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { ROOT } from './container.js';
|
|
8
|
+
import { appIcon, appIco } from './icon.js';
|
|
9
|
+
import { launch, run, runSync } from './run.js';
|
|
10
|
+
|
|
11
|
+
const P = process.platform, WIN = P === 'win32', SCRIPT = fileURLToPath(new URL('../bin/agentcage.js', import.meta.url));
|
|
12
|
+
export const SHORTCUT_PLACES = WIN ? 'the desktop and Start Menu' : 'the desktop and app menu';
|
|
13
|
+
|
|
14
|
+
// Starts the UI server in the background without a console window (the shortcut runs `agentcage ui --detach`)
|
|
15
|
+
export const detach = (open = true) => spawn(process.execPath, [SCRIPT, 'ui', ...(open ? [] : ['--no-open'])], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
16
|
+
|
|
17
|
+
// ~/.agentcage/agentcage.png (Linux) and .ico (Windows shortcuts)
|
|
18
|
+
function iconFiles() {
|
|
19
|
+
mkdirSync(ROOT, { recursive: true });
|
|
20
|
+
const f = ext => join(ROOT, `agentcage.${ext}`);
|
|
21
|
+
writeFileSync(f('png'), appIcon());
|
|
22
|
+
writeFileSync(f('ico'), appIco());
|
|
23
|
+
return { png: f('png'), ico: f('ico') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Windows: .lnk files via the built-in WScript.Shell. Paths come in through env vars, so nothing needs quoting.
|
|
27
|
+
// GetFolderPath follows a Desktop redirected to OneDrive.
|
|
28
|
+
const PS = `$w = New-Object -ComObject WScript.Shell
|
|
29
|
+
foreach ($d in [Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('Programs')) {
|
|
30
|
+
$f = Join-Path $d 'agentcage.lnk'
|
|
31
|
+
if ($env:AC_REMOVE) { if (Test-Path $f) { Remove-Item $f; $f }; continue }
|
|
32
|
+
$s = $w.CreateShortcut($f); $s.TargetPath = $env:AC_NODE; $s.Arguments = '"' + $env:AC_SCRIPT + '" ui --detach'
|
|
33
|
+
$s.IconLocation = $env:AC_ICO; $s.WindowStyle = 7; $s.Description = 'agentcage: dev containers for AI coding agents'; $s.Save(); $f }`;
|
|
34
|
+
const powershell = env => spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', PS], { encoding: 'utf8', env: { ...process.env, ...env }, windowsHide: true })
|
|
35
|
+
.stdout.split(/\r?\n/).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
// Linux: .desktop entries in the app menu and on the desktop
|
|
38
|
+
const linuxFiles = () => [join(homedir(), '.local', 'share', 'applications'), runSync('xdg-user-dir', ['DESKTOP']) || join(homedir(), 'Desktop')]
|
|
39
|
+
.filter(existsSync).map(d => join(d, 'agentcage.desktop'));
|
|
40
|
+
|
|
41
|
+
// Returns the shortcut files created
|
|
42
|
+
export function createShortcuts() {
|
|
43
|
+
const { png, ico } = iconFiles();
|
|
44
|
+
if (WIN) return powershell({ AC_NODE: process.execPath, AC_SCRIPT: SCRIPT, AC_ICO: ico });
|
|
45
|
+
if (P === 'darwin') throw new Error('Shortcuts are not supported on macOS yet. Run: agentcage ui');
|
|
46
|
+
const entry = `[Desktop Entry]\nType=Application\nName=agentcage\nComment=Dev containers for AI coding agents\n`
|
|
47
|
+
+ `Exec="${process.execPath}" "${SCRIPT}" ui\nIcon=${png}\nTerminal=false\nCategories=Development;\n`;
|
|
48
|
+
return linuxFiles().map(f => {
|
|
49
|
+
writeFileSync(f, entry);
|
|
50
|
+
chmodSync(f, 0o755);
|
|
51
|
+
spawnSync('gio', ['set', f, 'metadata::trusted', 'true'], { stdio: 'ignore' }); // GNOME: launchable from the desktop
|
|
52
|
+
return f;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export const removeShortcuts = () => (WIN ? powershell({ AC_REMOVE: '1' }) : linuxFiles().filter(existsSync).map(f => (rmSync(f), f)));
|
|
56
|
+
|
|
57
|
+
// Edge / Chrome / Chromium, for an app window (own window and taskbar icon, no tabs or address bar)
|
|
58
|
+
function appBrowser() {
|
|
59
|
+
if (WIN) {
|
|
60
|
+
const E = process.env;
|
|
61
|
+
return [[E['ProgramFiles(x86)'], 'Microsoft', 'Edge'], [E.ProgramFiles, 'Microsoft', 'Edge'], [E.ProgramFiles, 'Google', 'Chrome'], [E.LOCALAPPDATA, 'Google', 'Chrome']]
|
|
62
|
+
.filter(([root]) => root).map(([root, ...p]) => join(root, ...p, 'Application', p[1] === 'Edge' ? 'msedge.exe' : 'chrome.exe')).find(existsSync);
|
|
63
|
+
}
|
|
64
|
+
if (P === 'linux') return ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'microsoft-edge-stable']
|
|
65
|
+
.find(c => runSync('which', [c]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Opens the UI: an app window when a Chromium browser is installed, else the default browser
|
|
69
|
+
export function openWindow(url) {
|
|
70
|
+
const b = appBrowser();
|
|
71
|
+
if (b) launch(b, [`--app=${url}`, '--window-size=1200,860']);
|
|
72
|
+
else launch(WIN ? 'start' : P === 'darwin' ? 'open' : 'xdg-open', [url]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Native folder picker for the New container dialog (a web page can't see full paths). Resolves to the path, or null if cancelled.
|
|
76
|
+
// Windows: PowerShell 7 (.NET 8) shows the modern dialog, Windows PowerShell the classic folder tree; -STA is needed for dialogs.
|
|
77
|
+
const PICK = `Add-Type -AssemblyName System.Windows.Forms
|
|
78
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
79
|
+
$d.Description = 'Choose the project folder for the new container'
|
|
80
|
+
try { $d.UseDescriptionForTitle = $true } catch {}
|
|
81
|
+
if ($env:AC_START -and (Test-Path $env:AC_START)) { $d.SelectedPath = $env:AC_START }
|
|
82
|
+
if ($d.ShowDialog((New-Object System.Windows.Forms.Form -Property @{ TopMost = $true })) -eq 'OK') { $d.SelectedPath }`;
|
|
83
|
+
export function pickFolder(start = '') {
|
|
84
|
+
const has = c => runSync(WIN ? 'where' : 'which', [c]);
|
|
85
|
+
const [cmd, args] = WIN ? [has('pwsh') ? 'pwsh' : 'powershell', ['-NoProfile', '-NonInteractive', '-STA', '-Command', PICK]]
|
|
86
|
+
: has('zenity') ? ['zenity', ['--file-selection', '--directory', '--title=Choose the project folder', ...(start ? [`--filename=${start}/`] : [])]]
|
|
87
|
+
: has('kdialog') ? ['kdialog', ['--getexistingdirectory', start || homedir()]] : [];
|
|
88
|
+
if (!cmd) return Promise.reject(new Error('No folder picker found (install zenity). Type the folder path instead.'));
|
|
89
|
+
return run(cmd, args, { env: { AC_START: start } }).then(p => p || null, () => null); // cancel exits non-zero
|
|
90
|
+
}
|