agentcage 0.1.7 → 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 +24 -0
- package/bin/agentcage.js +22 -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 +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 +188 -0
- package/src/settings.js +4 -3
- package/src/shortcut.js +90 -0
- package/src/ui.html +448 -0
- package/src/wizard.js +4 -14
package/src/ui.html
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>agentcage</title>
|
|
7
|
+
<link rel="icon" href="/icon.svg">
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg: #f6f7f6; --panel: #fff; --text: #1b1f1c; --muted: #66706a; --line: #dfe4e0; --brand: #14532d; --brand-text: #fff;
|
|
10
|
+
--ok: #15803d; --on: #22c55e; --off: #c5ccc7; --warn: #b45309; --bad: #b91c1c; --chip: #eef2ef; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; }
|
|
11
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #121614; --panel: #1b201d; --text: #e5ebe7; --muted: #95a29a; --line: #2c342f;
|
|
12
|
+
--brand: #1f6f3e; --ok: #4ade80; --on: #6ee7a0; --off: #4a534e; --warn: #fbbf24; --bad: #f87171; --chip: #252c28; } }
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; background: var(--bg); color: var(--text); }
|
|
15
|
+
header { display: flex; align-items: center; gap: 12px; padding: 10px 20px; background: var(--brand); color: var(--brand-text); }
|
|
16
|
+
header img { width: 28px; height: 28px; border-radius: 6px; }
|
|
17
|
+
header b { font-size: 16px; margin-right: 16px; }
|
|
18
|
+
nav button { background: none; border: 0; color: inherit; opacity: .75; padding: 6px 10px; border-radius: 6px; font: inherit; cursor: pointer; }
|
|
19
|
+
nav button.on, nav button:hover { opacity: 1; background: rgba(255,255,255,.14); }
|
|
20
|
+
main { max-width: 1320px; margin: 0 auto; padding: 20px; }
|
|
21
|
+
h2 { font-size: 18px; margin: 0 0 4px; } h3 { font-size: 15px; margin: 0 0 8px; }
|
|
22
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
23
|
+
.spread { justify-content: space-between; }
|
|
24
|
+
.muted { color: var(--muted); } .small { font-size: 12px; }
|
|
25
|
+
.banner { padding: 10px 14px; border-radius: 8px; margin: 12px 0; background: var(--chip); border-left: 4px solid var(--warn); }
|
|
26
|
+
.banner.bad { border-color: var(--bad); }
|
|
27
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; margin-top: 14px; }
|
|
28
|
+
#cards { grid-template-columns: repeat(auto-fill, minmax(min(100%, 620px), 1fr)); } /* wide enough for all buttons in one row */
|
|
29
|
+
.desc { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.45; }
|
|
30
|
+
.gear { font-size: 16px; line-height: 1; padding: 5px 9px; }
|
|
31
|
+
.card .actions { flex-wrap: nowrap; } dd.src { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; word-break: normal; }
|
|
32
|
+
.ok { color: var(--ok); font-weight: 600; } .steps { margin: 8px 0 0; padding-left: 20px; line-height: 1.6; } a { color: var(--ok); }
|
|
33
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
34
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--off); flex: none; }
|
|
35
|
+
.dot.running { background: var(--on); } .dot.creating, .dot.busy { background: var(--warn); } .dot.missing { background: var(--bad); }
|
|
36
|
+
.name { font-weight: 600; font-size: 15px; word-break: break-all; }
|
|
37
|
+
.chip { background: var(--chip); border-radius: 999px; padding: 2px 8px; font-size: 12px; }
|
|
38
|
+
dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 13px; } dt { color: var(--muted); } dd { margin: 0; word-break: break-all; }
|
|
39
|
+
button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 7px; padding: 6px 11px; cursor: pointer; }
|
|
40
|
+
button:hover { border-color: var(--muted); } button:disabled { opacity: .5; cursor: default; }
|
|
41
|
+
button.primary { background: var(--brand); border-color: var(--brand); color: var(--brand-text); }
|
|
42
|
+
button.danger { color: var(--bad); } button.warn { color: var(--warn); border-color: var(--warn); }
|
|
43
|
+
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; width: 100%; }
|
|
44
|
+
label { display: block; margin: 12px 0 4px; font-weight: 500; }
|
|
45
|
+
label.inline { display: flex; gap: 8px; align-items: flex-start; font-weight: normal; margin: 6px 0; } label.inline input { width: auto; margin-top: 3px; }
|
|
46
|
+
.err { color: var(--bad); font-size: 12px; min-height: 16px; margin-top: 3px; }
|
|
47
|
+
.busy { display: flex; gap: 8px; align-items: center; color: var(--warn); font-size: 13px; }
|
|
48
|
+
.spin { width: 13px; height: 13px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; flex: none; }
|
|
49
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
50
|
+
dialog { border: 1px solid var(--line); border-radius: 12px; padding: 0; width: min(640px, 94vw); background: var(--panel); color: var(--text); }
|
|
51
|
+
dialog::backdrop { background: rgba(0,0,0,.45); }
|
|
52
|
+
dialog form { padding: 20px; max-height: 88vh; overflow: auto; }
|
|
53
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-top: 8px; }
|
|
54
|
+
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; }
|
|
55
|
+
.tabs button.on { border-bottom-color: var(--brand); font-weight: 600; }
|
|
56
|
+
.list { border: 1px solid var(--line); border-radius: 7px; max-height: 220px; overflow: auto; margin-top: 6px; }
|
|
57
|
+
.list div { padding: 6px 10px; cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
|
|
58
|
+
.list div:hover { background: var(--chip); } .list div.on { background: var(--brand); color: var(--brand-text); }
|
|
59
|
+
pre { white-space: pre-wrap; background: var(--chip); padding: 8px 10px; border-radius: 7px; font-size: 12px; margin: 6px 0 0; }
|
|
60
|
+
code { background: var(--chip); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
|
|
61
|
+
details { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; margin: 10px 0; }
|
|
62
|
+
summary { cursor: pointer; font-weight: 600; } details p, details li { line-height: 1.5; }
|
|
63
|
+
.hidden { display: none !important; }
|
|
64
|
+
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 9px 16px; border-radius: 8px; max-width: 90vw; }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<header>
|
|
69
|
+
<img src="/icon.svg" alt=""><b>agentcage</b>
|
|
70
|
+
<nav class="row">
|
|
71
|
+
<button data-view="containers" class="on">Containers</button>
|
|
72
|
+
<button data-view="connections">Connections</button>
|
|
73
|
+
<button data-view="settings">Settings</button>
|
|
74
|
+
<button data-view="help">Help</button>
|
|
75
|
+
</nav>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<main>
|
|
79
|
+
<section id="containers">
|
|
80
|
+
<div class="row spread">
|
|
81
|
+
<div><h2>Containers</h2><div id="desktop" class="muted small"></div></div>
|
|
82
|
+
<button class="primary" id="new" disabled>+ New container</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div id="banners"></div>
|
|
85
|
+
<div id="cards" class="grid"><p class="muted">Loading containers…</p></div>
|
|
86
|
+
<p id="empty" class="muted hidden">No containers yet. Click <b>+ New container</b> to create one from a folder or a repository.</p>
|
|
87
|
+
</section>
|
|
88
|
+
|
|
89
|
+
<section id="connections" class="hidden">
|
|
90
|
+
<div class="row spread"><h2>Connections</h2><button id="conn-refresh">Check again</button></div>
|
|
91
|
+
<p class="muted">Connect GitHub and Azure Repos to pick repositories when you create a container. A logged-in <code>gh</code> or
|
|
92
|
+
<code>az</code> CLI is used automatically; otherwise paste a personal access token. Tokens are only used on this computer to list
|
|
93
|
+
and clone repositories. They never go into a container.</p>
|
|
94
|
+
<div class="grid">
|
|
95
|
+
<div class="card" id="conn-github">
|
|
96
|
+
<h3>GitHub</h3><div class="status muted">Checking…</div>
|
|
97
|
+
<details class="howto"><summary>How to connect</summary><ol class="steps">
|
|
98
|
+
<li>Install the GitHub CLI: <a href="https://cli.github.com/" target="_blank" rel="noopener">cli.github.com</a> (on Windows you can also run <code>winget install GitHub.cli</code>).</li>
|
|
99
|
+
<li>Open a new terminal and run <code>gh auth login</code>. It logs you in through the browser.</li>
|
|
100
|
+
<li>Click <b>Check again</b>. Still not connected? Close this window and open agentcage again, so it finds the newly installed CLI.</li></ol>
|
|
101
|
+
<p class="small muted">The CLI is the easiest way. Or paste a personal access token below.</p></details>
|
|
102
|
+
<div class="pat"><label>Personal access token</label><input type="password" autocomplete="off" placeholder="github_pat_…">
|
|
103
|
+
<div class="small muted">Fine-grained or classic, with read access to the repositories (Contents: Read). Or run <code>gh auth login</code>.</div>
|
|
104
|
+
<div class="row" style="margin-top:8px"><button class="save">Save token</button><button class="remove danger hidden">Remove saved token</button></div><div class="err"></div></div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="card" id="conn-azure">
|
|
107
|
+
<h3>Azure Repos</h3><div class="status muted">Checking…</div>
|
|
108
|
+
<details class="howto"><summary>How to connect</summary><ol class="steps">
|
|
109
|
+
<li>Install the Azure CLI: <a href="https://learn.microsoft.com/cli/azure/install-azure-cli" target="_blank" rel="noopener">learn.microsoft.com/cli/azure/install-azure-cli</a> (on Windows you can also run <code>winget install Microsoft.AzureCLI</code>).</li>
|
|
110
|
+
<li>Open a new terminal and run <code>az login</code>. It logs you in through the browser.</li>
|
|
111
|
+
<li>Click <b>Check again</b>. Still not connected? Close this window and open agentcage again, so it finds the newly installed CLI.</li></ol>
|
|
112
|
+
<p class="small muted">The CLI is the easiest way. Or paste a personal access token below.</p></details>
|
|
113
|
+
<div class="pat"><label>Personal access token</label><input type="password" autocomplete="off">
|
|
114
|
+
<label>Organization <span class="muted small">(needed if the token is for one organization)</span></label><input class="org" placeholder="myorg">
|
|
115
|
+
<div class="small muted">Scope: Code (Read), or Code (Read & write) if you also use it as repo token. Or run <code>az login</code>.</div>
|
|
116
|
+
<div class="row" style="margin-top:8px"><button class="save">Save token</button><button class="remove danger hidden">Remove saved token</button></div><div class="err"></div></div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
|
|
121
|
+
<section id="settings" class="hidden">
|
|
122
|
+
<h2>Settings</h2>
|
|
123
|
+
<p class="muted">Apply to containers created afterwards. Stored in <code>~/.agentcage/settings.json</code>, shared with <code>agentcage settings</code>.</p>
|
|
124
|
+
<div class="card" style="max-width:560px"><div id="settings-form"></div>
|
|
125
|
+
<div class="row" style="margin-top:14px"><button class="primary" id="settings-save">Save</button><span id="settings-msg" class="muted small"></span></div></div>
|
|
126
|
+
</section>
|
|
127
|
+
|
|
128
|
+
<section id="help" class="hidden">
|
|
129
|
+
<h2>How agentcage works</h2>
|
|
130
|
+
<details open><summary>What agentcage does</summary>
|
|
131
|
+
<p>Each project gets its own Linux container, so AI coding agents work in isolation. The project's code lives in a Docker
|
|
132
|
+
<b>volume</b>, not in a folder mounted from your computer, so agents in the container can't see your other files. The container
|
|
133
|
+
has no access to the Docker socket and is not privileged.</p>
|
|
134
|
+
<p>Each container can run an <b>OpenCode server</b>. The OpenCode Desktop app on your computer connects to it and is only the UI:
|
|
135
|
+
every command, file edit and tool call of the agent runs inside the container. Claude Code can be installed in the container too.</p></details>
|
|
136
|
+
<details><summary>Creating a container</summary>
|
|
137
|
+
<ul><li><b>Folder:</b> copied once into the container, including untracked files such as <code>.env.local</code>.
|
|
138
|
+
<code>node_modules</code>, <code>.venv</code> and <code>.next</code> are skipped; the container runs <code>npm ci</code> / <code>uv sync</code> instead.</li>
|
|
139
|
+
<li><b>Git URL, GitHub or Azure Repos:</b> cloned into the container. Private repositories are cloned with your connection (see Connections).</li>
|
|
140
|
+
<li>The first build downloads the base image and takes a few minutes. Later containers are faster.</li>
|
|
141
|
+
<li>The container's copy is separate from your computer's: changes leave the container with <b>git push</b>.</li></ul></details>
|
|
142
|
+
<details><summary>OpenCode Desktop</summary>
|
|
143
|
+
<ul><li>Each container is added to OpenCode Desktop as a server named like <code>ac-<name></code>, with the project already open.
|
|
144
|
+
Start your sessions there. <b>Only sessions on that server are isolated</b>; the app's built-in Local server runs on your computer.</li>
|
|
145
|
+
<li>Desktop's server list can only be changed while the app is <b>closed</b>. If it was open, close it and click <b>Add to OpenCode</b> on the container.</li>
|
|
146
|
+
<li>The container runs the same OpenCode version as your Desktop app. When the app updates, click <b>Upgrade</b>: the container is
|
|
147
|
+
rebuilt and your code, logins and sessions are kept.</li></ul></details>
|
|
148
|
+
<details><summary>Git push access</summary>
|
|
149
|
+
<ul><li><b>Forwarded IDE credentials:</b> while Cursor or VS Code is attached to the container, your git credentials and SSH agent are
|
|
150
|
+
forwarded into it, so pushing just works. <b>Any agent in the container can then push to every repository you can access.</b>
|
|
151
|
+
Without an attached IDE the container can't push.</li>
|
|
152
|
+
<li><b>Repo token:</b> a token limited to this repository is stored in the container (GitHub fine-grained token, GitLab project token,
|
|
153
|
+
Bitbucket repository token). Agents can only push there. Azure DevOps tokens can't be limited to one repository: they reach every
|
|
154
|
+
repository in the organization.</li>
|
|
155
|
+
<li>The default is set in Settings (<i>Git token per project</i>).</li></ul></details>
|
|
156
|
+
<details><summary>Connections</summary>
|
|
157
|
+
<p>GitHub and Azure Repos connections are used to list your repositories and to clone private ones. A logged-in <code>gh</code> /
|
|
158
|
+
<code>az</code> CLI is reused and nothing is stored. A pasted token is stored in <code>~/.agentcage/connections.json</code>, readable
|
|
159
|
+
only by your user. Connection tokens are never put into a container, because they reach all your repositories.</p></details>
|
|
160
|
+
<details><summary>Model providers and Claude Code</summary>
|
|
161
|
+
<p>OpenCode needs an API key for each model provider. Click the gear button (⚙, OpenCode providers) on a container to add one; it is stored only in the
|
|
162
|
+
container's home volume. Claude Code logs in on first use: run <code>agentcage shell <name></code>, then <code>claude</code>.</p></details>
|
|
163
|
+
<details><summary>IDEs</summary>
|
|
164
|
+
<p><b>Open in IDE</b> attaches Cursor, VS Code or Antigravity to the container in the project folder. Use the IDE for editing, diffs
|
|
165
|
+
and git. Its built-in AI agent runs outside the container's isolation, so run agents through OpenCode or Claude Code.</p></details>
|
|
166
|
+
<details><summary>Command line and troubleshooting</summary>
|
|
167
|
+
<ul><li>Everything here also works in a terminal: <code>agentcage</code> (menu), <code>agentcage list</code>, <code>agentcage new</code>,
|
|
168
|
+
<code>agentcage shell <name></code>, <code>agentcage help</code>.</li>
|
|
169
|
+
<li><code>agentcage doctor</code> checks Docker, Node and OpenCode Desktop.</li>
|
|
170
|
+
<li>If creating a container failed, fix the cause and click <b>Remove</b> on it to clean up.</li>
|
|
171
|
+
<li>This window talks to a small server on your computer only (127.0.0.1). It stops when you close the window.
|
|
172
|
+
Start it again from the agentcage icon or with <code>agentcage ui</code>.</li></ul></details>
|
|
173
|
+
</section>
|
|
174
|
+
</main>
|
|
175
|
+
|
|
176
|
+
<dialog id="dlg-new"><form method="dialog" id="new-form">
|
|
177
|
+
<div class="row spread"><h2>New container</h2><button type="button" class="close">✕</button></div>
|
|
178
|
+
<label>Project source</label>
|
|
179
|
+
<div class="tabs" id="src-tabs">
|
|
180
|
+
<button type="button" data-src="folder" class="on">Folder</button><button type="button" data-src="url">Git URL</button>
|
|
181
|
+
<button type="button" data-src="github">GitHub</button><button type="button" data-src="azure">Azure Repos</button>
|
|
182
|
+
</div>
|
|
183
|
+
<div data-pane="folder"><label>Folder on this computer</label><div class="row" style="flex-wrap:nowrap"><input id="f-folder" placeholder="C:\Users\me\code\my-project or /home/me/code/my-project"><button type="button" id="browse">Browse…</button></div></div>
|
|
184
|
+
<div data-pane="url" class="hidden"><label>Repository URL</label><input id="f-url" placeholder="https://github.com/owner/repo.git"></div>
|
|
185
|
+
<div data-pane="github" class="hidden"><div class="notconn muted hidden">Not connected. Connect GitHub under <b>Connections</b>.</div>
|
|
186
|
+
<div class="conn"><input class="filter" placeholder="Filter repositories…" style="margin-top:10px"><div class="list"></div></div></div>
|
|
187
|
+
<div data-pane="azure" class="hidden"><div class="notconn muted hidden">Not connected. Connect Azure Repos under <b>Connections</b>.</div>
|
|
188
|
+
<div class="conn"><label>Organization</label><select class="orgs"></select><input class="filter" placeholder="Filter repositories…" style="margin-top:8px"><div class="list"></div></div></div>
|
|
189
|
+
<div class="err" id="e-src"></div>
|
|
190
|
+
|
|
191
|
+
<label>Name</label><input id="f-name" autocomplete="off"><div class="err" id="e-name"></div>
|
|
192
|
+
<div id="prefix-note" class="small muted"></div>
|
|
193
|
+
|
|
194
|
+
<div id="push" class="hidden"><label>How should the container push to <span id="remote"></span>?</label>
|
|
195
|
+
<label class="inline"><input type="radio" name="git" value="forward"><span><b>My IDE's forwarded credentials</b><span class="desc">Agents can only push while your IDE is attached to the
|
|
196
|
+
container, but then they can push to all your repositories. Pushing and other source control are done from the attached IDE or
|
|
197
|
+
over SSH into the container.</span></span></label>
|
|
198
|
+
<label class="inline"><input type="radio" name="git" value="token"><span><b>A token for this repo only</b> <span class="chip">recommended</span><span class="desc">Agents can push to
|
|
199
|
+
this repository only, also when no IDE is attached. Paste a token limited to this repository below.</span></span></label>
|
|
200
|
+
<div id="token-box" class="hidden"><pre id="token-help"></pre><input type="password" id="f-token" placeholder="Paste the token" autocomplete="off" style="margin-top:6px"></div>
|
|
201
|
+
</div>
|
|
202
|
+
<div id="clone-note" class="small muted"></div>
|
|
203
|
+
|
|
204
|
+
<label>AI agents and tools</label><div id="tools"></div>
|
|
205
|
+
<label>Open in an IDE when ready</label><select id="f-ide"></select>
|
|
206
|
+
<div class="err" id="e-create"></div>
|
|
207
|
+
<div class="row" style="justify-content:flex-end;margin-top:8px"><button type="button" class="close">Cancel</button><button class="primary" id="create" value="default">Create container</button></div>
|
|
208
|
+
</form></dialog>
|
|
209
|
+
|
|
210
|
+
<dialog id="dlg-key"><form method="dialog">
|
|
211
|
+
<h2>OpenCode providers</h2>
|
|
212
|
+
<p class="muted small">Connect a model provider to OpenCode in this container by adding its API key. Stored only in the container's OpenCode (its home volume). <span id="key-for"></span></p>
|
|
213
|
+
<label>Provider</label><select id="k-provider"></select><div id="k-hint" class="small muted"></div>
|
|
214
|
+
<label>API key</label><input type="password" id="k-key" autocomplete="off"><div class="err" id="e-key"></div>
|
|
215
|
+
<div class="row" style="justify-content:flex-end;margin-top:8px"><button type="button" class="close">Cancel</button><button class="primary" id="k-save" value="default">Save key</button></div>
|
|
216
|
+
</form></dialog>
|
|
217
|
+
|
|
218
|
+
<script>
|
|
219
|
+
const TOKEN = location.hash.slice(1), $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
|
|
220
|
+
let META = {}, STATE = { containers: [] }, CONN = null, timer;
|
|
221
|
+
|
|
222
|
+
async function api(path, opt = {}) {
|
|
223
|
+
const r = await fetch(path, { method: opt.method ?? 'GET', headers: { 'x-agentcage-token': TOKEN, 'content-type': 'application/json' },
|
|
224
|
+
body: opt.body && JSON.stringify(opt.body) });
|
|
225
|
+
const j = await r.json().catch(() => ({}));
|
|
226
|
+
if (!r.ok) throw new Error(j.error ?? r.statusText);
|
|
227
|
+
return j;
|
|
228
|
+
}
|
|
229
|
+
// Element builder; text always goes through textContent
|
|
230
|
+
function h(tag, props = {}, ...kids) {
|
|
231
|
+
const e = document.createElement(tag);
|
|
232
|
+
for (const [k, v] of Object.entries(props)) if (k.startsWith('on')) e.addEventListener(k.slice(2), v); else if (v !== undefined && v !== false) e.setAttribute(k, v);
|
|
233
|
+
e.append(...kids.flat().filter(k => k !== null && k !== undefined && k !== false));
|
|
234
|
+
return e;
|
|
235
|
+
}
|
|
236
|
+
function toast(msg) { const t = h('div', { class: 'toast' }, msg); document.body.append(t); setTimeout(() => t.remove(), 4500); }
|
|
237
|
+
const run = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message); } };
|
|
238
|
+
|
|
239
|
+
// Views
|
|
240
|
+
$$('nav button').forEach(b => b.onclick = () => {
|
|
241
|
+
$$('nav button').forEach(x => x.classList.toggle('on', x === b));
|
|
242
|
+
$$('main section').forEach(s => s.classList.toggle('hidden', s.id !== b.dataset.view));
|
|
243
|
+
if (b.dataset.view === 'connections') loadConnections();
|
|
244
|
+
if (b.dataset.view === 'settings') renderSettings();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Containers
|
|
248
|
+
async function refresh() {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
try { STATE = await api('/api/state'); render(); $('#new').disabled = false; } catch (e) { $('#banners').replaceChildren(h('div', { class: 'banner bad' }, `Lost the connection to agentcage: ${e.message}. Restart it with: agentcage ui`)); }
|
|
251
|
+
timer = setTimeout(refresh, STATE.containers?.some(c => c.busy && !c.busy.error) ? 1500 : 10000);
|
|
252
|
+
}
|
|
253
|
+
const act = (name, action, body) => run(async () => { await api(`/api/containers/${name}/${action}`, { method: 'POST', body }); await refresh(); })();
|
|
254
|
+
|
|
255
|
+
function render() {
|
|
256
|
+
const d = STATE.desktop;
|
|
257
|
+
$('#desktop').textContent = d.found ? `OpenCode Desktop ${d.version ?? ''}${d.running ? ' · open' : ' · closed'}` : 'OpenCode Desktop not found: add servers by hand (shown on each container)';
|
|
258
|
+
$('#banners').replaceChildren(...[!STATE.docker && h('div', { class: 'banner bad' }, 'Docker is not running. Start Docker Desktop (or the Docker service). This page updates by itself.')].filter(Boolean));
|
|
259
|
+
$('#empty').classList.toggle('hidden', STATE.containers.length > 0);
|
|
260
|
+
$('#cards').replaceChildren(...STATE.containers.map(card));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function card(c) {
|
|
264
|
+
const busy = c.busy && !c.busy.error, oc = c.tools.includes('opencode'), outdated = oc && STATE.desktop.version && c.opencodeVersion && c.opencodeVersion !== STATE.desktop.version;
|
|
265
|
+
const ide = STATE.ides[0];
|
|
266
|
+
const actions = busy || c.state === 'creating' ? [] : [
|
|
267
|
+
c.state === 'running' ? h('button', { onclick: () => act(c.name, 'stop') }, 'Stop') : c.state !== 'missing' && h('button', { onclick: () => act(c.name, 'start') }, 'Start'),
|
|
268
|
+
c.state !== 'missing' && (STATE.ides.length > 1
|
|
269
|
+
? h('select', { style: 'width:auto', onchange: e => { if (e.target.value) act(c.name, 'open', { ide: e.target.value }); e.target.value = ''; } },
|
|
270
|
+
h('option', { value: '' }, 'Open in…'), STATE.ides.map(i => h('option', { value: i.value }, i.label)))
|
|
271
|
+
: h('button', { onclick: () => act(c.name, 'open', { ide: ide?.value }) }, `Open in ${ide?.label ?? 'IDE'}`)),
|
|
272
|
+
oc && c.registered === false && h('button', { onclick: () => act(c.name, 'connect') }, 'Add to OpenCode'),
|
|
273
|
+
oc && c.state !== 'missing' && h('button', { class: 'gear', title: 'OpenCode providers: add a model provider API key', 'aria-label': 'OpenCode providers', onclick: () => keyDialog(c) }, '⚙'),
|
|
274
|
+
oc && h('button', { class: outdated ? 'warn' : '', title: 'Rebuild with the Desktop app\'s OpenCode version and the current agentcage setup. Code, logins and sessions are kept.',
|
|
275
|
+
onclick: () => confirm(`Rebuild ${c.name}? Code, logins and sessions are kept; it takes a few minutes.`) && act(c.name, 'upgrade') }, outdated ? 'Upgrade OpenCode' : 'Rebuild'),
|
|
276
|
+
h('button', { class: 'danger', onclick: () => confirm(`Delete ${c.name} with its volumes? Unpushed work in the container is lost.`) && act(c.name, 'remove') }, 'Remove'),
|
|
277
|
+
];
|
|
278
|
+
return h('div', { class: 'card' },
|
|
279
|
+
h('div', { class: 'row' }, h('span', { class: `dot ${busy ? 'busy' : c.state}` }), h('span', { class: 'name' }, c.name),
|
|
280
|
+
h('span', { class: 'chip' }, busy && c.state === 'missing' ? 'creating' : c.state === 'exited' ? 'stopped' : c.state)),
|
|
281
|
+
c.source && h('dl', {},
|
|
282
|
+
h('dt', {}, 'Source'), h('dd', { class: 'src', title: c.source }, c.source),
|
|
283
|
+
h('dt', {}, 'Tools'), h('dd', {}, c.tools.join(', ') || 'none'),
|
|
284
|
+
oc && [h('dt', {}, 'OpenCode'), h('dd', {}, `${c.server} · port ${c.port} · ${c.opencodeVersion ?? '?'}`, outdated ? ` (Desktop has ${STATE.desktop.version})` : '')],
|
|
285
|
+
oc && c.registered === false && !busy && [h('dt', {}, 'Desktop'), h('dd', {}, STATE.desktop.running ? 'Not added yet: close OpenCode Desktop, then click Add to OpenCode' : 'Not added yet')],
|
|
286
|
+
h('dt', {}, 'Git push'), h('dd', {}, { token: 'repo token', forward: 'forwarded IDE credentials', none: 'no remote' }[c.git] ?? c.git),
|
|
287
|
+
c.status && [h('dt', {}, 'Docker'), h('dd', {}, c.status)]),
|
|
288
|
+
busy && h('div', { class: 'busy' }, h('span', { class: 'spin' }), c.busy.label),
|
|
289
|
+
c.busy?.error && h('div', {}, h('div', { class: 'err' }, `${c.busy.label} failed: ${c.busy.error}`),
|
|
290
|
+
h('button', { class: 'small', onclick: () => act(c.name, 'dismiss') }, 'Dismiss')),
|
|
291
|
+
c.state === 'missing' && !busy && h('div', { class: 'small muted' }, 'The Docker container no longer exists. Remove cleans up its config and volumes.'),
|
|
292
|
+
actions.length > 0 && h('div', { class: 'row actions' }, actions));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// New container dialog
|
|
296
|
+
const dlg = $('#dlg-new'), F = { src: 'folder', picked: { github: null, azure: null }, nameEdited: false, repos: {} };
|
|
297
|
+
const source = () => ({ folder: $('#f-folder').value.trim(), url: $('#f-url').value.trim(), github: F.picked.github, azure: F.picked.azure })[F.src] ?? '';
|
|
298
|
+
$('#new').onclick = run(async () => {
|
|
299
|
+
F.nameEdited = false; $('#new-form').reset(); $('#e-create').textContent = '';
|
|
300
|
+
const s = STATE.settings;
|
|
301
|
+
$('#prefix-note').textContent = `Docker container ${s.containerPrefix}<name>, OpenCode server ${s.serverPrefix}<name>, project ${s.projectPrefix}<name> (change in Settings)`;
|
|
302
|
+
$('#tools').replaceChildren(...[...META.agents, ...META.devTools].map(t => h('label', { class: 'inline' },
|
|
303
|
+
h('input', { type: 'checkbox', value: t.id, checked: t.id === 'opencode' }), h('span', {}, t.label, ' ', h('span', { class: 'muted small' }, `— ${t.hint}`)))));
|
|
304
|
+
$('#f-ide').replaceChildren(h('option', { value: '' }, "Don't open"), ...STATE.ides.map(i => h('option', { value: i.value }, i.label)));
|
|
305
|
+
setSrc(F.src); checkSrc(); dlg.showModal();
|
|
306
|
+
});
|
|
307
|
+
$$('#src-tabs button').forEach(b => b.onclick = () => setSrc(b.dataset.src));
|
|
308
|
+
function setSrc(src) {
|
|
309
|
+
F.src = src;
|
|
310
|
+
$$('#src-tabs button').forEach(b => b.classList.toggle('on', b.dataset.src === src));
|
|
311
|
+
$$('[data-pane]').forEach(p => p.classList.toggle('hidden', p.dataset.pane !== src));
|
|
312
|
+
if (src === 'github' || src === 'azure') loadRepoPane(src);
|
|
313
|
+
checkSrc();
|
|
314
|
+
}
|
|
315
|
+
async function loadRepoPane(id) {
|
|
316
|
+
const pane = $(`[data-pane=${id}]`);
|
|
317
|
+
CONN ??= await api('/api/connections').catch(() => ({}));
|
|
318
|
+
const c = CONN[id];
|
|
319
|
+
pane.querySelector('.notconn').classList.toggle('hidden', !!c);
|
|
320
|
+
pane.querySelector('.conn').classList.toggle('hidden', !c);
|
|
321
|
+
if (!c) return;
|
|
322
|
+
if (id === 'azure') {
|
|
323
|
+
const sel = pane.querySelector('.orgs');
|
|
324
|
+
if (!sel.options.length) { sel.replaceChildren(...c.orgs.map(o => h('option', { value: o }, o))); sel.onchange = () => loadRepos('azure'); }
|
|
325
|
+
if (!c.orgs.length) return pane.querySelector('.list').replaceChildren(h('div', { class: 'muted' }, 'No organizations found. Add one under Connections.'));
|
|
326
|
+
}
|
|
327
|
+
pane.querySelector('.filter').oninput = () => showRepos(id);
|
|
328
|
+
if (!F.repos[id + (id === 'azure' ? pane.querySelector('.orgs').value : '')]) await loadRepos(id); else showRepos(id);
|
|
329
|
+
}
|
|
330
|
+
async function loadRepos(id) {
|
|
331
|
+
const pane = $(`[data-pane=${id}]`), org = id === 'azure' ? pane.querySelector('.orgs').value : '';
|
|
332
|
+
pane.querySelector('.list').replaceChildren(h('div', { class: 'muted' }, 'Loading repositories…'));
|
|
333
|
+
try { F.repos[id + org] = await api(`/api/repos/${id}${org ? `?org=${encodeURIComponent(org)}` : ''}`); showRepos(id); }
|
|
334
|
+
catch (e) { pane.querySelector('.list').replaceChildren(h('div', { class: 'err' }, e.message)); }
|
|
335
|
+
}
|
|
336
|
+
function showRepos(id) {
|
|
337
|
+
const pane = $(`[data-pane=${id}]`), org = id === 'azure' ? pane.querySelector('.orgs').value : '', q = pane.querySelector('.filter').value.toLowerCase();
|
|
338
|
+
const list = (F.repos[id + org] ?? []).filter(r => r.name.toLowerCase().includes(q)).slice(0, 200);
|
|
339
|
+
pane.querySelector('.list').replaceChildren(...list.map(r => h('div', { class: F.picked[id] === r.url ? 'on' : '', onclick: () => { F.picked[id] = r.url; showRepos(id); checkSrc(); } },
|
|
340
|
+
h('span', {}, r.name), r.private && h('span', { class: 'small' }, 'private'))), ...(list.length ? [] : [h('div', { class: 'muted' }, 'No repositories')]));
|
|
341
|
+
}
|
|
342
|
+
let checkTimer;
|
|
343
|
+
const checkSrc = () => { clearTimeout(checkTimer); checkTimer = setTimeout(run(doCheck), 300); };
|
|
344
|
+
$('#f-folder').oninput = $('#f-url').oninput = checkSrc;
|
|
345
|
+
// Opens the system folder picker (shown by the agentcage process on this computer)
|
|
346
|
+
$('#browse').onclick = run(async () => {
|
|
347
|
+
$('#browse').disabled = true;
|
|
348
|
+
try { const { path } = await api('/api/browse', { method: 'POST', body: { start: $('#f-folder').value } }); if (path) { $('#f-folder').value = path; checkSrc(); } }
|
|
349
|
+
finally { $('#browse').disabled = false; }
|
|
350
|
+
});
|
|
351
|
+
$('#f-name').oninput = () => { F.nameEdited = true; checkSrc(); };
|
|
352
|
+
async function doCheck() {
|
|
353
|
+
const src = source(), name = F.nameEdited ? $('#f-name').value.trim() : '';
|
|
354
|
+
if (!src) { if (!F.nameEdited) $('#f-name').value = ''; $('#e-src').textContent = ''; $('#push').classList.add('hidden'); $('#clone-note').textContent = ''; return; }
|
|
355
|
+
const c = await api(`/api/check?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
|
|
356
|
+
if (src !== source()) return; // changed meanwhile
|
|
357
|
+
$('#e-src').textContent = c.srcError ?? '';
|
|
358
|
+
if (!F.nameEdited) $('#f-name').value = c.suggested;
|
|
359
|
+
$('#e-name').textContent = c.nameError ?? '';
|
|
360
|
+
$('#push').classList.toggle('hidden', !c.remote);
|
|
361
|
+
$('#remote').textContent = c.remote ?? '';
|
|
362
|
+
$('#token-help').textContent = c.help ?? '';
|
|
363
|
+
$('#clone-note').textContent = c.cloneWith ? 'Cloned with your connection; that token is not put into the container.' : '';
|
|
364
|
+
if (c.remote && !$$('[name=git]').some(r => r.checked)) {
|
|
365
|
+
const def = { never: 'forward', always: 'token' }[STATE.settings.gitToken];
|
|
366
|
+
if (def) $(`[name=git][value=${def}]`).checked = true;
|
|
367
|
+
}
|
|
368
|
+
$('#token-box').classList.toggle('hidden', $('[name=git][value=token]').checked !== true);
|
|
369
|
+
}
|
|
370
|
+
$$('[name=git]').forEach(r => r.onchange = () => $('#token-box').classList.toggle('hidden', !$('[name=git][value=token]').checked));
|
|
371
|
+
$('#create').onclick = run(async e => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
const git = $$('[name=git]').find(r => r.checked)?.value;
|
|
374
|
+
if (!$('#push').classList.contains('hidden') && !git) return ($('#e-create').textContent = 'Choose how the container pushes to git.');
|
|
375
|
+
try {
|
|
376
|
+
await api('/api/containers', { method: 'POST', body: { src: source(), name: $('#f-name').value.trim(), git, token: $('#f-token').value,
|
|
377
|
+
tools: $$('#tools input:checked').map(i => i.value), ide: $('#f-ide').value } });
|
|
378
|
+
} catch (err) { return ($('#e-create').textContent = err.message); }
|
|
379
|
+
dlg.close(); toast('Creating the container. The first build takes a few minutes.'); await refresh();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
$$("dialog .close").forEach(b => b.onclick = () => b.closest("dialog").close());
|
|
383
|
+
|
|
384
|
+
// API key dialog
|
|
385
|
+
const kd = $('#dlg-key');
|
|
386
|
+
function keyDialog(c) {
|
|
387
|
+
kd.dataset.name = c.name; $('#key-for').textContent = `Container: ${c.name}.`; $('#k-key').value = ''; $('#e-key').textContent = '';
|
|
388
|
+
$('#k-provider').replaceChildren(...META.providers.map(p => h('option', { value: p.id }, p.label)));
|
|
389
|
+
$('#k-provider').onchange = () => ($('#k-hint').textContent = `Get a key at ${META.providers.find(p => p.id === $('#k-provider').value).hint}`);
|
|
390
|
+
$('#k-provider').onchange(); kd.showModal();
|
|
391
|
+
}
|
|
392
|
+
$('#k-save').onclick = async e => {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
try { await api(`/api/containers/${kd.dataset.name}/provider`, { method: 'POST', body: { id: $('#k-provider').value, key: $('#k-key').value.trim() } }); }
|
|
395
|
+
catch (err) { return ($('#e-key').textContent = err.message); }
|
|
396
|
+
kd.close(); toast('API key saved in the container. Pick the provider\'s models in OpenCode.');
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Connections
|
|
400
|
+
async function loadConnections() {
|
|
401
|
+
CONN = await api('/api/connections').catch(e => (toast(e.message), {}));
|
|
402
|
+
for (const id of ['github', 'azure']) {
|
|
403
|
+
const card = $(`#conn-${id}`), c = CONN[id], via = { gh: 'the gh CLI', az: 'the az CLI', pat: 'a saved token' }[c?.source];
|
|
404
|
+
card.querySelector('.status').replaceChildren(...(c ? [h('span', { class: 'ok' }, '✓ Connected'), `${c.user ? ` as ${c.user}` : ''} through ${via}${id === 'azure' && c.orgs?.length ? ` · organizations: ${c.orgs.join(', ')}` : ''}`] : ['Not connected']));
|
|
405
|
+
card.querySelector('.howto').open = !c;
|
|
406
|
+
card.querySelector('.remove').classList.toggle('hidden', c?.source !== 'pat');
|
|
407
|
+
card.querySelector('.save').onclick = async () => {
|
|
408
|
+
const err = card.querySelector('.err'); err.textContent = '';
|
|
409
|
+
try { await api(`/api/connections/${id}`, { method: 'PUT', body: { token: card.querySelector('.pat input').value, org: card.querySelector('.org')?.value } }); }
|
|
410
|
+
catch (e) { return (err.textContent = e.message); }
|
|
411
|
+
card.querySelector('.pat input').value = ''; F.repos = {}; loadConnections();
|
|
412
|
+
};
|
|
413
|
+
card.querySelector('.remove').onclick = run(async () => { await api(`/api/connections/${id}`, { method: 'DELETE' }); F.repos = {}; loadConnections(); });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
$('#conn-refresh').onclick = () => { F.repos = {}; loadConnections(); };
|
|
418
|
+
|
|
419
|
+
// Settings
|
|
420
|
+
function renderSettings() {
|
|
421
|
+
const s = STATE.settings;
|
|
422
|
+
const field = (k, c = META.choices[k]) => k === 'projectIcon' // radios: the color picker only shows for a plain-color icon
|
|
423
|
+
? Object.entries(c).map(([v, l]) => h('label', { class: 'inline' }, h('input', { type: 'radio', name: k, value: v, checked: s[k] === v, onchange: showColor }), l))
|
|
424
|
+
: c ? h('select', { name: k }, Object.entries(c).map(([v, l]) => h('option', { value: v, selected: s[k] === v }, l)))
|
|
425
|
+
: h('input', { name: k, value: s[k], placeholder: 'none' });
|
|
426
|
+
$('#settings-form').replaceChildren(...META.keys.map(k => h('div', { 'data-key': k }, h('label', {}, META.labels[k]), field(k))));
|
|
427
|
+
showColor();
|
|
428
|
+
$('#settings-msg').textContent = '';
|
|
429
|
+
}
|
|
430
|
+
function showColor() {
|
|
431
|
+
$('#settings-form [data-key=color]').classList.toggle('hidden', !$('#settings-form [name=projectIcon][value=color]').checked);
|
|
432
|
+
}
|
|
433
|
+
$('#settings-save').onclick = run(async () => {
|
|
434
|
+
await api('/api/settings', { method: 'PUT', body: Object.fromEntries($$('#settings-form [name]').filter(i => i.type !== 'radio' || i.checked).map(i => [i.name, i.value.trim()])) });
|
|
435
|
+
STATE.settings = (await api('/api/state')).settings; $('#settings-msg').textContent = 'Saved.';
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Tell the server this window is open (it stops a few seconds after the window closes); reconnect if it drops
|
|
439
|
+
async function alive() { try { const r = await fetch('/api/alive', { headers: { 'x-agentcage-token': TOKEN } }); await r.text(); } catch {} setTimeout(alive, 2000); }
|
|
440
|
+
|
|
441
|
+
(async () => {
|
|
442
|
+
if (!TOKEN) return $('#banners').replaceChildren(h('div', { class: 'banner bad' }, 'Open agentcage with the agentcage icon or by running: agentcage ui'));
|
|
443
|
+
try { META = await api('/api/meta'); } catch (e) { return $('#banners').replaceChildren(h('div', { class: 'banner bad' }, `${e.message}`)); }
|
|
444
|
+
alive(); refresh();
|
|
445
|
+
})();
|
|
446
|
+
</script>
|
|
447
|
+
</body>
|
|
448
|
+
</html>
|
package/src/wizard.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Interactive dialogs (@clack/prompts) on top of the container functions. Flags pre-fill answers; --yes takes defaults.
|
|
2
2
|
import * as p from '@clack/prompts';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
4
|
import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
|
|
6
|
-
import { cleanName, createContainer, listContainers,
|
|
5
|
+
import { cleanName, createContainer, findIdes, listContainers, nameError, openIde, publishedPorts, removeContainer, state, ws } from './container.js';
|
|
7
6
|
import { isGitUrl, localRemote, parseRemote } from './git.js';
|
|
8
7
|
import { connectDesktop, desktopRunning, desktopServers, detectDesktop, latestVersion, loginProvider, removeDesktopServers, serverName, setupProject } from './opencode.js';
|
|
9
|
-
import { ok
|
|
8
|
+
import { ok } from './run.js';
|
|
10
9
|
import { CHOICES, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
|
|
11
10
|
|
|
12
11
|
export const ask = async q => {
|
|
@@ -28,14 +27,6 @@ and SSH agent are forwarded into the container. Any agent running in the contain
|
|
|
28
27
|
that time can use them to push to EVERY repository you have access to, not only
|
|
29
28
|
this one. When no IDE is attached, the container cannot push at all.`;
|
|
30
29
|
|
|
31
|
-
// IDEs that can attach to a container, found on PATH or in their usual install folder
|
|
32
|
-
function findIdes() {
|
|
33
|
-
const found = cmd => !!runSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
|
|
34
|
-
const ag = process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd') : 'antigravity-ide';
|
|
35
|
-
return [['cursor', 'Cursor'], ['code', 'VS Code'], [ag, 'Antigravity']].filter(([c]) => existsSync(c) || found(c))
|
|
36
|
-
.map(([value, label]) => ({ value, label }));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
30
|
async function waitForDocker() {
|
|
40
31
|
while (!ok('docker', ['info'])) {
|
|
41
32
|
p.log.warn('Docker is not running (or not installed). Start Docker Desktop / the Docker service.');
|
|
@@ -104,9 +95,8 @@ export async function newWizard(f = {}) {
|
|
|
104
95
|
|
|
105
96
|
const s = loadSettings(), px = v => (v ? `"${v}"` : 'none');
|
|
106
97
|
if (!f.yes) p.log.info(`Name prefixes: Docker container ${px(s.containerPrefix)}, OpenCode server ${px(s.serverPrefix)}, OpenCode project ${px(s.projectPrefix)}. Change with: agentcage settings`);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
if (nameOk(name)) throw new Error(nameOk(name));
|
|
98
|
+
const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Container name', initialValue: cleanName(src), validate: nameError })));
|
|
99
|
+
if (nameError(name)) throw new Error(nameError(name));
|
|
110
100
|
|
|
111
101
|
// Git push access
|
|
112
102
|
const remoteUrl = isGitUrl(src) ? src : localRemote(src), remote = parseRemote(remoteUrl);
|