agentcage 0.1.7 → 0.1.9
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 +29 -7
- package/bin/agentcage.js +29 -10
- 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 +21 -5
- 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 +191 -0
- package/src/settings.js +4 -3
- package/src/shortcut.js +92 -0
- package/src/ui.html +477 -0
- package/src/wizard.js +4 -14
package/README.md
CHANGED
|
@@ -23,11 +23,6 @@ You need **Docker** (Docker Desktop on Windows/macOS, Docker Engine on Linux), *
|
|
|
23
23
|
npm install -g agentcage # also updates to the latest version
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
```bash
|
|
27
|
-
agentcage doctor # checks all the prerequisites.
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
|
|
31
26
|
|
|
32
27
|
## Quickstart
|
|
33
28
|
|
|
@@ -41,10 +36,36 @@ Run this in terminal:
|
|
|
41
36
|
|
|
42
37
|
```bash
|
|
43
38
|
agentcage doctor # checks all the prerequisites.
|
|
44
|
-
agentcage # Starts the container-creation dialogue
|
|
39
|
+
agentcage # Starts the CLI: UI or container-creation dialogue
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## The agentcage window (UI)
|
|
44
|
+
Agentcage UI gives you a graphical way to see and manage your devcontainers
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
agentcage ui # opens the agentcage window
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
Running `agentcage` without a command also offers it as the first choice in the menu.
|
|
51
|
+
|
|
52
|
+
A local web UI with the same features as the terminal dialog:
|
|
53
|
+
- **Containers:** each container's status, and Start, Stop, Open in IDE, Add to OpenCode, OpenCode providers (gear button: add model provider API keys), Rebuild and Remove.
|
|
54
|
+
- **New container:** a dialog to create one from a folder, a git URL, or a repository picked from **GitHub** or **Azure Repos**.
|
|
55
|
+
- **Connections:** reuses a logged-in `gh` / `az` CLI. Otherwise you paste a personal access token, which is stored in
|
|
56
|
+
`~/.agentcage/connections.json`. These tokens are only used on your computer to list and clone repositories. They never go
|
|
57
|
+
into a container.
|
|
58
|
+
- **Settings** and **Help**.
|
|
59
|
+
|
|
60
|
+
The window opens as an app window (Edge or Chrome `--app`), or as a normal browser tab if neither is installed.
|
|
61
|
+
|
|
62
|
+
**Desktop icon:** the first `agentcage ui` offers to add an agentcage icon to the desktop and Start Menu (Windows) or app
|
|
63
|
+
menu (Linux). Clicking it starts the UI without a console window. The window also offers it when it opens (until you add
|
|
64
|
+
it or pick "Don't show this again"), and you can always add it under **Settings → Desktop icon** or with
|
|
65
|
+
`agentcage ui --shortcut`; `--no-shortcut` removes it.
|
|
66
|
+
|
|
67
|
+
**How it runs:** the UI is served by a small server on `127.0.0.1` only, protected by a random session token. It stops a
|
|
68
|
+
few seconds after you close the window, unless a container is still being created. `--no-open` only prints the URL.
|
|
48
69
|
|
|
49
70
|
## Creating a container
|
|
50
71
|
|
|
@@ -77,6 +98,7 @@ AGENTCAGE_GIT_TOKEN=github_pat_... agentcage new https://github.com/me/repo.git
|
|
|
77
98
|
|
|
78
99
|
```bash
|
|
79
100
|
agentcage # menu: create a container, or start/open/shell/upgrade/remove an existing one
|
|
101
|
+
agentcage ui # the agentcage window: containers, create dialog, GitHub / Azure Repos, help
|
|
80
102
|
agentcage list # containers, state, port, OpenCode version
|
|
81
103
|
agentcage start <name> # start after a reboot, print the OpenCode server URL and password
|
|
82
104
|
agentcage shell <name> # shell in the container (opencode, claude, git ... work here)
|
package/bin/agentcage.js
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
|
-
import { adoptContainer, listContainers, loadContainer, removeContainer,
|
|
5
|
+
import { adoptContainer, listContainers, loadContainer, removeContainer, state, up, upgradeContainer, openIde, ws } from '../src/container.js';
|
|
6
6
|
import { ask, connectionInfo, newWizard, providerFlow, registerFlow, settingsWizard } from '../src/wizard.js';
|
|
7
7
|
import { detectDesktop, latestVersion, setupProject } from '../src/opencode.js';
|
|
8
8
|
import { ok, run } from '../src/run.js';
|
|
9
|
+
import { loadSettings, saveSettings } from '../src/settings.js';
|
|
9
10
|
|
|
10
11
|
process.removeAllListeners('warning'); // hide node:sqlite ExperimentalWarning
|
|
11
12
|
|
|
12
13
|
const HELP = `agentcage: isolated dev containers for AI coding agents
|
|
13
14
|
|
|
14
|
-
agentcage
|
|
15
|
+
agentcage menu: open the agentcage window, or create / manage containers in the terminal
|
|
16
|
+
agentcage ui open the agentcage window (containers, create dialog, GitHub / Azure Repos, help)
|
|
17
|
+
[--shortcut] add a desktop / menu icon [--no-shortcut] remove it [--no-open] only print the URL
|
|
15
18
|
agentcage new [folder|git-url] create a container [--name x] [--tools opencode,claude-code,python,gh,pnpm]
|
|
16
19
|
[--git token|forward] [--providers anthropic,...] [--ide cursor|code|none] [--yes]
|
|
17
20
|
agentcage list containers, state, port, OpenCode version
|
|
@@ -34,7 +37,8 @@ const HELP = `agentcage: isolated dev containers for AI coding agents
|
|
|
34
37
|
let { values: f, positionals: [cmd, arg, arg2] } = parseArgs({
|
|
35
38
|
allowPositionals: true,
|
|
36
39
|
options: { name: { type: 'string' }, tools: { type: 'string' }, git: { type: 'string' }, providers: { type: 'string' },
|
|
37
|
-
ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }
|
|
40
|
+
ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
41
|
+
shortcut: { type: 'boolean' }, 'no-shortcut': { type: 'boolean' }, detach: { type: 'boolean' }, 'no-open': { type: 'boolean' } },
|
|
38
42
|
});
|
|
39
43
|
const csv = s => s?.split(',').map(x => x.trim()).filter(Boolean);
|
|
40
44
|
const container = () => loadContainer(arg ?? (() => { throw new Error(`Usage: agentcage ${cmd} <name>`); })());
|
|
@@ -51,6 +55,21 @@ async function start(b) {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const commands = {
|
|
58
|
+
// The UI. The desktop shortcut runs it with --detach: the server continues in the background without a console window.
|
|
59
|
+
async ui() {
|
|
60
|
+
const sc = await import('../src/shortcut.js');
|
|
61
|
+
if (f.shortcut || f['no-shortcut']) {
|
|
62
|
+
const files = f.shortcut ? sc.createShortcuts() : sc.removeShortcuts();
|
|
63
|
+
return console.log(files.length ? `${f.shortcut ? 'Created' : 'Removed'}:\n ${files.join('\n ')}` : 'No shortcuts found.');
|
|
64
|
+
}
|
|
65
|
+
if (f.detach) return sc.detach(!f['no-open']);
|
|
66
|
+
if (process.stdout.isTTY && !loadSettings().uiShortcutAsked && process.platform !== 'darwin') {
|
|
67
|
+
const add = await ask(p.confirm({ message: `Add an agentcage icon to ${sc.SHORTCUT_PLACES}?` }));
|
|
68
|
+
saveSettings({ ...loadSettings(), uiShortcutAsked: true });
|
|
69
|
+
if (add) p.log.success(`Added: ${sc.createShortcuts().join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
await (await import('../src/server.js')).serve({ open: !f['no-open'] });
|
|
72
|
+
},
|
|
54
73
|
settings: settingsWizard,
|
|
55
74
|
async version() {
|
|
56
75
|
const v = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -82,9 +101,7 @@ const commands = {
|
|
|
82
101
|
async upgrade() {
|
|
83
102
|
const b = container(), v = detectDesktop().version ?? await latestVersion();
|
|
84
103
|
const s = p.spinner(); s.start(`Rebuilding '${b.name}' with OpenCode ${v} (volumes are kept)`);
|
|
85
|
-
b.
|
|
86
|
-
await up(b, true).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
|
|
87
|
-
saveContainer(b);
|
|
104
|
+
await upgradeContainer(b, v).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
|
|
88
105
|
if (b.tools.includes('opencode')) await setupProject(b).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
|
|
89
106
|
},
|
|
90
107
|
async remove() {
|
|
@@ -106,14 +123,16 @@ const commands = {
|
|
|
106
123
|
},
|
|
107
124
|
adopt() { const b = adoptContainer(arg); console.log(`Adopted '${b.name}' (${b.container}).`); },
|
|
108
125
|
|
|
109
|
-
// No command: menu
|
|
126
|
+
// No command: menu. The window (UI) comes first; the rest is the terminal flow.
|
|
110
127
|
async menu() {
|
|
111
128
|
const containers = listContainers();
|
|
112
|
-
if (!containers.length) return newWizard();
|
|
113
129
|
p.intro('agentcage');
|
|
114
|
-
// '-settings' can't clash with a container name (names start with a letter or digit)
|
|
115
|
-
const pick = await ask(p.select({ message: 'What do you want to do?', options: [
|
|
130
|
+
// '-ui' and '-settings' can't clash with a container name (names start with a letter or digit)
|
|
131
|
+
const pick = await ask(p.select({ message: 'What do you want to do?', options: [
|
|
132
|
+
{ value: '-ui', label: 'Open the agentcage window', hint: 'containers, create dialog, GitHub / Azure Repos' },
|
|
133
|
+
{ value: '', label: 'Create a new container (here in the terminal)' },
|
|
116
134
|
...containers.map(b => ({ value: b.name, label: b.name, hint: state(b) })), { value: '-settings', label: 'Settings' }] }));
|
|
135
|
+
if (pick === '-ui') return commands.ui();
|
|
117
136
|
if (pick === '-settings') return settingsWizard();
|
|
118
137
|
if (!pick) return newWizard();
|
|
119
138
|
const action = await ask(p.select({ message: pick, options: [
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -24,5 +24,7 @@ const HOSTS = {
|
|
|
24
24
|
'github.com': { user: 'x-access-token', help: p => `Create a fine-grained token: https://github.com/settings/personal-access-tokens/new\n Repository access: Only select repositories → ${p}\n Permissions: Contents → Read and write (add Pull requests if agents should open PRs)` },
|
|
25
25
|
'gitlab.com': { user: 'oauth2', help: p => `Create a project access token: https://gitlab.com/${p}/-/settings/access_tokens\n Role: Developer. Scopes: read_repository, write_repository` },
|
|
26
26
|
'bitbucket.org': { user: 'x-token-auth', help: p => `Create a repository access token: https://bitbucket.org/${p}/admin/access-tokens\n Permissions: Repositories → Write` },
|
|
27
|
+
// Azure accepts any username with a PAT. A PAT can't be limited to one repo: it reaches every repo in the organization.
|
|
28
|
+
'dev.azure.com': { user: 'pat', help: p => `Create a personal access token: https://dev.azure.com/${p.split('/')[0]}/_usersSettings/tokens\n Organization: ${p.split('/')[0]}. Scopes: Code → Read & write\n Note: Azure DevOps tokens cover every repo in the organization you can access, not only this one.` },
|
|
27
29
|
};
|
|
28
30
|
export const hostInfo = host => HOSTS[host] ?? { user: 'git', help: p => `Create an access token on ${host} with read/write access to ${p} only.` };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GitHub and Azure DevOps connections for `agentcage ui`: list repos to create containers from, and clone them.
|
|
2
|
+
// Tokens come from a logged-in `gh` / `az` CLI first, else from a PAT saved in ~/.agentcage/connections.json.
|
|
3
|
+
// They are only used on this machine to list and clone; they never go into a container or to the browser.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parseRemote } from './git.js';
|
|
9
|
+
import { runSync } from './run.js';
|
|
10
|
+
|
|
11
|
+
const FILE = join(homedir(), '.agentcage', 'connections.json');
|
|
12
|
+
const AZ_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798'; // Azure DevOps
|
|
13
|
+
export const HOSTS = { github: 'github.com', azure: 'dev.azure.com' };
|
|
14
|
+
|
|
15
|
+
const load = () => (existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {});
|
|
16
|
+
function save(all) {
|
|
17
|
+
mkdirSync(join(FILE, '..'), { recursive: true });
|
|
18
|
+
writeFileSync(FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
19
|
+
chmodSync(FILE, 0o600); // mode only applies to new files. On Windows the user profile's ACL protects it.
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// az is az.cmd on Windows, which Node only runs through a shell; the command line is constant
|
|
23
|
+
const azToken = () => spawnSync(`az account get-access-token --resource ${AZ_RESOURCE} --query accessToken -o tsv`,
|
|
24
|
+
{ shell: true, encoding: 'utf8', windowsHide: true }).stdout?.trim() || null;
|
|
25
|
+
|
|
26
|
+
// { token, source: 'gh'|'az'|'pat', bearer } or null
|
|
27
|
+
export function token(id) {
|
|
28
|
+
const cli = id === 'github' ? runSync('gh', ['auth', 'token']) : azToken();
|
|
29
|
+
if (cli) return { token: cli, source: id === 'github' ? 'gh' : 'az', bearer: true };
|
|
30
|
+
const pat = load()[id]?.token;
|
|
31
|
+
return pat ? { token: pat, source: 'pat', bearer: id === 'github' } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const auth = t => (t.bearer ? `Bearer ${t.token}` : `Basic ${Buffer.from(`:${t.token}`).toString('base64')}`);
|
|
35
|
+
async function get(url, t) {
|
|
36
|
+
const r = await fetch(url, { headers: { authorization: auth(t), accept: 'application/json', 'user-agent': 'agentcage' } });
|
|
37
|
+
// Azure DevOps answers a bad token with an HTML sign-in page (203), not an error status
|
|
38
|
+
if (r.status === 401 || r.status === 203 || (r.ok && !r.headers.get('content-type')?.includes('json')))
|
|
39
|
+
throw new Error(`${new URL(url).host} did not accept the token. Check that it is valid, not expired, and has the needed scopes.`);
|
|
40
|
+
if (!r.ok) throw new Error(`${new URL(url).host}: HTTP ${r.status}`);
|
|
41
|
+
return { body: await r.json(), link: r.headers.get('link') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Azure DevOps organizations the token can see. Needs a token for "all accessible organizations";
|
|
45
|
+
// orgs the user typed in are always included.
|
|
46
|
+
async function azureOrgs(t) {
|
|
47
|
+
const saved = load().azure?.orgs ?? [];
|
|
48
|
+
try {
|
|
49
|
+
const { body: me } = await get('https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1', t);
|
|
50
|
+
const { body } = await get(`https://app.vssps.visualstudio.com/_apis/accounts?memberId=${me.id}&api-version=7.1`, t);
|
|
51
|
+
return { user: me.emailAddress ?? me.displayName, orgs: [...new Set([...body.value.map(a => a.accountName), ...saved])] };
|
|
52
|
+
} catch { return { user: null, orgs: saved }; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Connection status for the UI, without tokens: { github: { source, user } | null, azure: { source, user, orgs } | null }
|
|
56
|
+
export async function status() {
|
|
57
|
+
const gh = token('github'), az = token('azure');
|
|
58
|
+
return {
|
|
59
|
+
github: gh && { source: gh.source, user: (await get('https://api.github.com/user', gh).catch(() => null))?.body.login ?? null },
|
|
60
|
+
azure: az && { source: az.source, ...(await azureOrgs(az)) },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Checks a pasted PAT before saving it. Azure: org is needed when the PAT is limited to one organization.
|
|
65
|
+
export async function savePat(id, pat, org) {
|
|
66
|
+
const t = { token: pat, bearer: id === 'github' };
|
|
67
|
+
if (id === 'github') await get('https://api.github.com/user', t);
|
|
68
|
+
else await get(org ? `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?api-version=7.1&$top=1`
|
|
69
|
+
: 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1', t);
|
|
70
|
+
const all = load();
|
|
71
|
+
all[id] = { token: pat, ...(org && { orgs: [org] }) };
|
|
72
|
+
save(all);
|
|
73
|
+
}
|
|
74
|
+
export function removePat(id) { const all = load(); delete all[id]; save(all); }
|
|
75
|
+
|
|
76
|
+
// [{ name, url, private }] with HTTPS clone URLs
|
|
77
|
+
export async function repos(id, org) {
|
|
78
|
+
const t = token(id);
|
|
79
|
+
if (!t) throw new Error(`Not connected to ${id === 'github' ? 'GitHub' : 'Azure DevOps'}`);
|
|
80
|
+
if (id === 'azure') {
|
|
81
|
+
if (!org) throw new Error('Pick an Azure DevOps organization');
|
|
82
|
+
const { body } = await get(`https://dev.azure.com/${encodeURIComponent(org)}/_apis/git/repositories?api-version=7.1`, t);
|
|
83
|
+
return body.value.map(r => ({ name: `${r.project.name}/${r.name}`, url: r.remoteUrl.replace(/\/\/[^@/]+@/, '//'), private: r.project.visibility !== 'public' }))
|
|
84
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
85
|
+
}
|
|
86
|
+
const list = [];
|
|
87
|
+
let url = 'https://api.github.com/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
|
|
88
|
+
for (let i = 0; url && i < 10; i++) {
|
|
89
|
+
const { body, link } = await get(url, t);
|
|
90
|
+
list.push(...body.map(r => ({ name: r.full_name, url: r.clone_url, private: r.private })));
|
|
91
|
+
url = link?.match(/<([^>]+)>;\s*rel="next"/)?.[1];
|
|
92
|
+
}
|
|
93
|
+
return list;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Token to clone a URL with, if it is on a connected host: { token, header } (header: send as Bearer, for az tokens)
|
|
97
|
+
export function cloneToken(url) {
|
|
98
|
+
const id = Object.keys(HOSTS).find(k => HOSTS[k] === parseRemote(url)?.host), t = id && token(id);
|
|
99
|
+
return t ? { token: t.token, header: t.source === 'az' } : null;
|
|
100
|
+
}
|
package/src/container.js
CHANGED
|
@@ -37,6 +37,9 @@ export function nameTaken(n) {
|
|
|
37
37
|
const px = loadSettings().containerPrefix;
|
|
38
38
|
return hasContainer(n) || ok('docker', ['inspect', px + n]) || ok('docker', ['volume', 'inspect', `${px}${n}-src`]);
|
|
39
39
|
}
|
|
40
|
+
// Why a container name can't be used, or undefined if it can
|
|
41
|
+
export const nameError = v => (!/^[a-z0-9][a-z0-9-]*$/.test(v ?? '') ? 'Use lowercase letters, digits and -'
|
|
42
|
+
: nameTaken(v) ? `A container named '${v}' already exists` : undefined);
|
|
40
43
|
|
|
41
44
|
const bindable = port => new Promise(r => {
|
|
42
45
|
const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
|
|
@@ -82,11 +85,20 @@ export async function up(b, rebuild) {
|
|
|
82
85
|
}
|
|
83
86
|
export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
|
|
84
87
|
|
|
85
|
-
//
|
|
88
|
+
// Rebuild with another OpenCode version (and the current template); volumes are kept
|
|
89
|
+
export async function upgradeContainer(b, version) {
|
|
90
|
+
b.opencodeVersion = version;
|
|
91
|
+
writeDevcontainer(b);
|
|
92
|
+
await up(b, true);
|
|
93
|
+
saveContainer(b);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// o: { src, name, tools, git: 'token'|'forward'|'none', token, remote, opencodeVersion, cloneToken, cloneBearer }.
|
|
97
|
+
// step(label, fn) shows progress.
|
|
86
98
|
export async function createContainer(o, step = (_, f) => f()) {
|
|
87
99
|
const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src), s = loadSettings(), px = s.containerPrefix;
|
|
88
100
|
const b = {
|
|
89
|
-
name, dir: join(ROOT, name), container: px + name, serverName: s.serverPrefix + name, projectName: s.projectPrefix + name, color: s.color,
|
|
101
|
+
name, dir: join(ROOT, name), container: px + name, serverName: s.serverPrefix + name, projectName: s.projectPrefix + name, color: s.color, projectIcon: s.projectIcon,
|
|
90
102
|
volumes: { src: `${px}${name}-src`, home: `${px}${name}-home` },
|
|
91
103
|
port: await freePort(), password: randomBytes(16).toString('hex'),
|
|
92
104
|
source: src, git: o.git, tools: o.tools, opencodeVersion: o.opencodeVersion, created: new Date().toISOString(),
|
|
@@ -94,7 +106,8 @@ export async function createContainer(o, step = (_, f) => f()) {
|
|
|
94
106
|
saveContainer(b);
|
|
95
107
|
writeDevcontainer(b);
|
|
96
108
|
await run('docker', ['volume', 'create', b.volumes.src]);
|
|
97
|
-
|
|
109
|
+
// Clone with the repo token, else with a connection token (o.cloneToken, from agentcage ui); none is kept in the container
|
|
110
|
+
await step('Copying the source into the container', () => seed(b.volumes.src, { src, token: o.token ?? o.cloneToken, bearer: !o.token && o.cloneBearer }));
|
|
98
111
|
await step('Building and starting the container (the first build takes a few minutes)', () => up(b));
|
|
99
112
|
await step('Setting up git', async () => {
|
|
100
113
|
await copyIdentity(b.container);
|
|
@@ -134,6 +147,14 @@ export function pinIdeFolder(b) {
|
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
// IDEs that can attach to a container, found on PATH or in their usual install folder: [{ value: command, label }]
|
|
151
|
+
export function findIdes() {
|
|
152
|
+
const found = cmd => !!runSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
|
|
153
|
+
const ag = process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd') : 'antigravity-ide';
|
|
154
|
+
return [['cursor', 'Cursor'], ['code', 'VS Code'], [ag, 'Antigravity']].filter(([c]) => existsSync(c) || found(c))
|
|
155
|
+
.map(([value, label]) => ({ value, label }));
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
// Opens a VS Code-based IDE attached to the running container
|
|
138
159
|
export function openIde(b, ide) {
|
|
139
160
|
pinIdeFolder(b);
|
package/src/git.js
CHANGED
|
@@ -6,9 +6,10 @@ import { run, runSync } from './run.js';
|
|
|
6
6
|
import { hostInfo } from './catalog.js';
|
|
7
7
|
|
|
8
8
|
// https://host/owner/repo(.git), git@host:owner/repo(.git), ssh://git@host/owner/repo → { host, path, https }
|
|
9
|
+
// Azure DevOps URLs (https://[org@]dev.azure.com/org/project/_git/repo) have no .git suffix
|
|
9
10
|
export function parseRemote(url) {
|
|
10
11
|
const m = url?.match(/^(?:(?:https?|ssh|git):\/\/(?:[^@/]+@)?|[^@\s/]+@)([^/:]+)[:/](.+?)(?:\.git)?\/?$/);
|
|
11
|
-
return m && { host: m[1], path: m[2], https: `https://${m[1]}/${m[2]}.git` };
|
|
12
|
+
return m && { host: m[1], path: m[2], https: `https://${m[1]}/${m[2]}${m[1] === 'dev.azure.com' ? '' : '.git'}` };
|
|
12
13
|
}
|
|
13
14
|
export const isGitUrl = s => /^(https?|ssh|git):\/\/|^[^@\s/]+@[^:]+:/.test(s);
|
|
14
15
|
export const localRemote = dir => runSync('git', ['-C', dir, 'remote', 'get-url', 'origin']) || null;
|
|
@@ -17,12 +18,16 @@ export const localRemote = dir => runSync('git', ['-C', dir, 'remote', 'get-url'
|
|
|
17
18
|
const TAR = 'cd /src && tar -cf - --exclude=node_modules --exclude=.venv --exclude=.next . | tar -xf - -C /dst';
|
|
18
19
|
const seedFolder = (vol, dir) => run('docker', ['run', '--rm', '-v', `${vol}:/dst`, '-v', `${dir}:/src:ro`, 'alpine', 'sh', '-c', TAR]);
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
// token: clone with it (a repo token, or a GitHub / Azure connection token). bearer: send it as a Bearer header
|
|
22
|
+
// (Microsoft Entra tokens from `az`) instead of as a password.
|
|
23
|
+
export async function seed(vol, { src, token, bearer }) {
|
|
21
24
|
if (!isGitUrl(src)) await seedFolder(vol, src);
|
|
22
|
-
else if (
|
|
25
|
+
else if (token) {
|
|
23
26
|
// Clone in a throwaway container; the token travels as an env var, never in the URL or on disk
|
|
24
27
|
const helper = '!f() { echo username=$GIT_USER; echo password=$GIT_TOKEN; }; f';
|
|
25
|
-
|
|
28
|
+
const clone = bearer ? ['--entrypoint', 'sh', 'alpine/git', '-c', 'git -c "http.extraHeader=Authorization: Bearer $GIT_TOKEN" clone "$0" /dst', src]
|
|
29
|
+
: ['alpine/git', '-c', `credential.helper=${helper}`, 'clone', src, '/dst'];
|
|
30
|
+
await run('docker', ['run', '--rm', '-e', 'GIT_USER', '-e', 'GIT_TOKEN', '-v', `${vol}:/dst`, ...clone],
|
|
26
31
|
{ env: { GIT_USER: hostInfo(parseRemote(src).host).user, GIT_TOKEN: token } });
|
|
27
32
|
} else {
|
|
28
33
|
// Clone on the host with the user's own git credentials (private repos work), then copy in
|
|
@@ -45,11 +50,12 @@ export async function copyIdentity(container) {
|
|
|
45
50
|
|
|
46
51
|
// Repo-scoped token: stored with git's credential store in the container's home volume, used for this repo only.
|
|
47
52
|
// The empty credential.helper entry clears helpers set earlier (such as the one an attached IDE forwards).
|
|
53
|
+
// $2 is host/path exactly as cloned (with .git, or without for Azure): useHttpPath matches the credential on it.
|
|
48
54
|
const TOKEN_SETUP = `IFS= read -r T; umask 077
|
|
49
|
-
printf 'https://%s:%s@%s
|
|
50
|
-
git remote set-url origin "https://$2
|
|
55
|
+
printf 'https://%s:%s@%s\\n' "$1" "$T" "$2" > ~/.git-credentials
|
|
56
|
+
git remote set-url origin "https://$2"
|
|
51
57
|
git config --local --unset-all credential.helper
|
|
52
58
|
git config --local --add credential.helper '' && git config --local --add credential.helper store && git config --local credential.useHttpPath true`;
|
|
53
59
|
|
|
54
60
|
export const setupToken = (container, dir, remote, token) =>
|
|
55
|
-
run('docker', ['exec', '-i', '-u', 'node', '-w', dir, container, 'sh', '-c', TOKEN_SETUP, '_', hostInfo(remote.host).user, remote.
|
|
61
|
+
run('docker', ['exec', '-i', '-u', 'node', '-w', dir, container, 'sh', '-c', TOKEN_SETUP, '_', hostInfo(remote.host).user, remote.https.slice(8)], { input: token + '\n' });
|
package/src/icon.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// the project prefix ("ac") in small letters on top, the first three letters of the container name in capitals below —
|
|
3
3
|
// and a light color band along the bottom, its hue picked from the name (looks random, stays the same per project).
|
|
4
4
|
// A hand-rolled PNG encoder (zlib + CRC) with a 5x7 bitmap font keeps this dependency-free.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
5
6
|
import { deflateSync } from 'node:zlib';
|
|
6
7
|
|
|
7
8
|
const SIZE = 128, SS = 4, BG = [20, 83, 45], FG = [255, 255, 255]; // SS: supersampling for smooth edges
|
|
@@ -110,11 +111,26 @@ const draw = (img, dim, text, s, y) => {
|
|
|
110
111
|
};
|
|
111
112
|
|
|
112
113
|
// The icon as a data URL for OpenCode's project icon override
|
|
113
|
-
export const projectIcon = (name, prefix = 'ac') =>
|
|
114
|
+
export const projectIcon = (name, prefix = 'ac') =>
|
|
115
|
+
`data:image/png;base64,${png(render([[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3).toUpperCase(), 6]], name)).toString('base64')}`;
|
|
116
|
+
|
|
117
|
+
// The agentcage app icon (UI, shortcuts): src/logo.svg, with logo.png rendered from it at 256 px
|
|
118
|
+
export const appIcon = () => readFileSync(new URL('./logo.png', import.meta.url));
|
|
119
|
+
|
|
120
|
+
// The app icon as .ico for Windows shortcuts: one 256 px PNG entry (size bytes 0 mean 256)
|
|
121
|
+
export function appIco() {
|
|
122
|
+
const p = appIcon(), head = Buffer.alloc(22);
|
|
123
|
+
head.writeUInt16LE(1, 2); head.writeUInt16LE(1, 4); // ICONDIR: icon type, one image
|
|
124
|
+
head.writeUInt16LE(1, 10); head.writeUInt16LE(32, 12); head.writeUInt32LE(p.length, 14); head.writeUInt32LE(22, 18);
|
|
125
|
+
return Buffer.concat([head, p]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// rows: [[text, scale]] stacked and centered above the accent band; seed picks the band's hue. Returns RGBA pixels.
|
|
129
|
+
function render(rows, seed) {
|
|
114
130
|
const dim = SIZE * SS, img = Buffer.alloc(dim * dim * 3);
|
|
115
|
-
const band = accent(
|
|
131
|
+
const band = accent(seed);
|
|
116
132
|
for (let i = 0; i < dim * dim; i++) { const c = i >= dim * (SIZE - BAND) * SS ? band : BG; img[i * 3] = c[0]; img[i * 3 + 1] = c[1]; img[i * 3 + 2] = c[2]; }
|
|
117
|
-
|
|
133
|
+
rows = rows.filter(([t]) => t);
|
|
118
134
|
const gap = 12, total = rows.reduce((n, [, s]) => n + 7 * s, 0) + gap * (rows.length - 1);
|
|
119
135
|
let y = Math.round((SIZE - BAND - total) / 2) - 4; // centered above the band, then nudged up
|
|
120
136
|
for (const [t, s] of rows) { draw(img, dim, t, s, y); y += 7 * s + gap; }
|
|
@@ -128,5 +144,5 @@ export const projectIcon = (name, prefix = 'ac') => {
|
|
|
128
144
|
const o = (py * SIZE + px) * 4, n = SS * SS;
|
|
129
145
|
out[o] = Math.round(r / n); out[o + 1] = Math.round(g / n); out[o + 2] = Math.round(b / n); out[o + 3] = 255;
|
|
130
146
|
}
|
|
131
|
-
return
|
|
132
|
-
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
package/src/logo.png
ADDED
|
Binary file
|
package/src/logo.svg
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1f7a44"/><stop offset="1" stop-color="#14532d"/></linearGradient>
|
|
4
|
+
</defs>
|
|
5
|
+
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
|
|
6
|
+
<g stroke-linecap="round" stroke-linejoin="round" fill="none">
|
|
7
|
+
<!-- side faces, lightly shaded -->
|
|
8
|
+
<path d="M56 92 L128 134 L128 216 L56 174 Z" fill="#fff" fill-opacity=".08" stroke="none"/>
|
|
9
|
+
<path d="M128 134 L200 92 L200 174 L128 216 Z" fill="#fff" fill-opacity=".16" stroke="none"/>
|
|
10
|
+
<!-- lid -->
|
|
11
|
+
<path d="M128 50 L200 92 L128 134 L56 92 Z" fill="#bbf7d0" stroke="#fff" stroke-width="10"/>
|
|
12
|
+
<!-- bars -->
|
|
13
|
+
<path d="M80 106 V188 M104 120 V202 M152 120 V202 M176 106 V188" stroke="#fff" stroke-width="8"/>
|
|
14
|
+
<!-- outline -->
|
|
15
|
+
<path d="M56 92 V174 L128 216 L200 174 V92 M128 134 V216" stroke="#fff" stroke-width="10"/>
|
|
16
|
+
</g>
|
|
17
|
+
</svg>
|
package/src/opencode.js
CHANGED
|
@@ -31,6 +31,10 @@ export function detectDesktop() {
|
|
|
31
31
|
export const desktopRunning = () => P === 'win32'
|
|
32
32
|
? /^OpenCode\.exe/im.test(runSync('tasklist', ['/FI', 'IMAGENAME eq OpenCode.exe', '/NH']))
|
|
33
33
|
: ok('pgrep', ['-x', 'OpenCode']);
|
|
34
|
+
// Same check without blocking (agentcage ui polls it)
|
|
35
|
+
export const desktopRunningAsync = () => (P === 'win32'
|
|
36
|
+
? run('tasklist', ['/FI', 'IMAGENAME eq OpenCode.exe', '/NH']).then(o => /^OpenCode\.exe/im.test(o))
|
|
37
|
+
: run('pgrep', ['-x', 'OpenCode']).then(() => true)).catch(() => false);
|
|
34
38
|
|
|
35
39
|
export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
|
|
36
40
|
|
|
@@ -81,18 +85,27 @@ export const removeDesktopServers = urls => serverState((s, d) => {
|
|
|
81
85
|
// Names stored at creation; containers made before the settings existed use the current settings
|
|
82
86
|
export const serverName = c => c.serverName ?? loadSettings().serverPrefix + c.name;
|
|
83
87
|
|
|
84
|
-
//
|
|
88
|
+
// JSON calls to the container's OpenCode server API (over the published loopback port, with its password)
|
|
89
|
+
const serverApi = c => (path, init) => fetch(`http://127.0.0.1:${c.port}/api${path}`, { ...init,
|
|
90
|
+
headers: { authorization: `Basic ${Buffer.from(`opencode:${c.password}`).toString('base64')}`, 'content-type': 'application/json' } })
|
|
91
|
+
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`OpenCode API ${path}: HTTP ${r.status}`))));
|
|
92
|
+
|
|
93
|
+
// Stores a model provider API key in the container's OpenCode (its home volume), without a terminal
|
|
94
|
+
export const setProviderKey = (c, id, key) =>
|
|
95
|
+
serverApi(c)('/credential', { method: 'POST', body: JSON.stringify({ integrationID: id, label: 'agentcage', value: { type: 'key', key } }) });
|
|
96
|
+
|
|
97
|
+
// Sets the project's name and icon (generated image or plain color) in the container's OpenCode server. Asking about the folder creates the project first.
|
|
85
98
|
// The server starts in the background after `devcontainer up`, so retry for ~30 s.
|
|
86
99
|
export async function setupProject(c) {
|
|
87
100
|
const s = loadSettings(), name = c.projectName ?? s.projectPrefix + c.name, color = c.color ?? s.color;
|
|
88
|
-
const
|
|
89
|
-
const api = (path, init) => fetch(base + path, { ...init, headers: { authorization: `Basic ${Buffer.from(`opencode:${c.password}`).toString('base64')}`, 'content-type': 'application/json' } })
|
|
90
|
-
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`OpenCode API ${path}: HTTP ${r.status}`))));
|
|
101
|
+
const api = serverApi(c), dir = `/workspaces/${c.name}`;
|
|
91
102
|
for (let i = 0; ; i++) {
|
|
92
103
|
try {
|
|
93
104
|
await api(`/vcs?location[directory]=${encodeURIComponent(dir)}`);
|
|
94
105
|
const { id } = (await api('/project')).find(x => x.canonical === dir);
|
|
95
|
-
|
|
106
|
+
// An empty override clears an earlier generated image, so the plain color shows
|
|
107
|
+
const override = (c.projectIcon ?? s.projectIcon) === 'color' ? '' : projectIcon(c.name, s.projectPrefix);
|
|
108
|
+
return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color, override } }) });
|
|
96
109
|
} catch (e) { if (i >= 15) throw e; await new Promise(r => setTimeout(r, 2000)); }
|
|
97
110
|
}
|
|
98
111
|
}
|
package/src/run.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Process helpers. Arguments are always passed as arrays (no shell), so paths never get mangled.
|
|
2
|
+
// windowsHide: on Windows, no console window flashes up for each command when agentcage ui runs without a terminal.
|
|
2
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
@@ -8,15 +9,15 @@ const fail = (cmd, args, code, err) =>
|
|
|
8
9
|
|
|
9
10
|
// Quick commands: returns trimmed stdout ('' if the command is missing and check is off)
|
|
10
11
|
export function runSync(cmd, args = [], { check = false, input } = {}) {
|
|
11
|
-
const r = spawnSync(cmd, args, { encoding: 'utf8', input });
|
|
12
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', input, windowsHide: true });
|
|
12
13
|
if (check && r.status !== 0) throw fail(cmd, args, r.status, r.stderr || r.error?.message);
|
|
13
14
|
return (r.stdout || '').trim();
|
|
14
15
|
}
|
|
15
|
-
export const ok = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
|
|
16
|
+
export const ok = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore', windowsHide: true }).status === 0;
|
|
16
17
|
|
|
17
18
|
// Long-running commands (keeps spinners animating). input: string sent on stdin; tty: hand over the terminal
|
|
18
19
|
export const run = (cmd, args = [], { input, env, tty } = {}) => new Promise((resolve, reject) => {
|
|
19
|
-
const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: tty ? 'inherit' : 'pipe' });
|
|
20
|
+
const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: tty ? 'inherit' : 'pipe', windowsHide: true });
|
|
20
21
|
let out = '', err = '';
|
|
21
22
|
p.stdout?.on('data', d => (out += d));
|
|
22
23
|
p.stderr?.on('data', d => (err += d));
|
|
@@ -29,9 +30,11 @@ export const run = (cmd, args = [], { input, env, tty } = {}) => new Promise((re
|
|
|
29
30
|
const DC = join(dirname(createRequire(import.meta.url).resolve('@devcontainers/cli/package.json')), 'devcontainer.js');
|
|
30
31
|
export const devcontainer = args => run(process.execPath, [DC, ...args]);
|
|
31
32
|
|
|
32
|
-
// IDE launchers (cursor, code) are .cmd shims on Windows and need a shell there
|
|
33
|
+
// IDE launchers (cursor, code) are .cmd shims on Windows and need a shell there; its console window is hidden.
|
|
34
|
+
// Programs given by .exe path (the browser) start directly, since hiding would hide their window too.
|
|
33
35
|
export function launch(cmd, args) {
|
|
34
|
-
const
|
|
36
|
+
const shell = process.platform === 'win32' && !/\.exe$/i.test(cmd);
|
|
35
37
|
const line = [cmd, ...args].map(a => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
36
|
-
|
|
38
|
+
const c = spawn(shell ? line : cmd, shell ? [] : args, { shell, stdio: 'ignore', detached: true, windowsHide: shell });
|
|
39
|
+
c.on('error', () => {}).unref(); // a missing program must not crash a long-running process (agentcage ui)
|
|
37
40
|
}
|