@desplega.ai/agent-env 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -2
  3. package/assets/icon.svg +5 -0
  4. package/bin/agent-env.mjs +210 -0
  5. package/docs/agent-swarm.md +146 -0
  6. package/docs/releases.md +207 -0
  7. package/docs/screenshots/dashboard-desktop.png +0 -0
  8. package/docs/screenshots/dashboard-mobile.png +0 -0
  9. package/docs/snapshots-and-worktrees.md +221 -0
  10. package/examples/agent-swarm/README.md +5 -0
  11. package/examples/agent-swarm/agent-env.json +11 -0
  12. package/examples/agent-swarm/compose.agent-env.yaml +101 -0
  13. package/examples/agent-swarm/verify.mjs +55 -0
  14. package/examples/compose/.dockerignore +3 -0
  15. package/examples/compose/Dockerfile +4 -0
  16. package/examples/compose/README.md +109 -0
  17. package/examples/compose/compose.yaml +36 -0
  18. package/examples/compose/server.mjs +6 -0
  19. package/examples/hello/agent-env.json +20 -0
  20. package/examples/hello/index.html +12 -0
  21. package/package.json +49 -4
  22. package/src/cloud.mjs +314 -0
  23. package/src/config.mjs +133 -0
  24. package/src/daemon.mjs +484 -0
  25. package/src/environments.mjs +207 -0
  26. package/src/goodies.mjs +71 -0
  27. package/src/init.mjs +68 -0
  28. package/src/locks.mjs +56 -0
  29. package/src/maintenance.mjs +74 -0
  30. package/src/paths.mjs +5 -0
  31. package/src/providers/CLOUD_SANDBOX.md +240 -0
  32. package/src/providers/README.md +62 -0
  33. package/src/providers/compose.mjs +460 -0
  34. package/src/providers/docker.mjs +207 -0
  35. package/src/providers/index.mjs +13 -0
  36. package/src/providers/sandbox.mjs +170 -0
  37. package/src/proxy.mjs +149 -0
  38. package/src/runtime.mjs +83 -0
  39. package/src/snapshots.mjs +394 -0
  40. package/src/source-paths.mjs +15 -0
  41. package/src/system.mjs +39 -0
  42. package/src/tls.mjs +57 -0
  43. package/src/ui-client.js +51 -0
  44. package/src/ui.html +12 -0
  45. package/src/ui.mjs +32 -0
  46. package/src/watch.mjs +20 -0
  47. package/src/worktrees.mjs +154 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Desplega Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,344 @@
1
- # Temporary Holding Version
1
+ <img src="assets/icon.svg" width="64" height="64" alt="agent-env Workspace logo">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # agent-env
4
+
5
+ Run a development stack locally, over SSH, or on an on-demand Hetzner machine. Open each instance through localhost.
6
+
7
+ Part of [Desplega Labs](https://desplega.sh), alongside [Agent Swarm](https://github.com/desplega-ai/agent-swarm) and [agent-fs](https://github.com/desplega-ai/agent-fs).
8
+ Released under the [MIT license](LICENSE).
9
+
10
+ The CLI runs on macOS or Linux with Node 22+, Docker, and a local routing daemon. It has no npm dependencies.
11
+ [Tesser](https://tesser.sh/) inspired the workflow. [Portless](https://github.com/vercel-labs/portless) informed the shared HTTPS proxy and local certificate authority.
12
+
13
+ ## Start
14
+
15
+ Install the published package:
16
+
17
+ ```sh
18
+ npm install -g @desplega.ai/agent-env
19
+ ```
20
+
21
+ For a source checkout, run `npm link --ignore-scripts --no-audit --no-fund` in this repository instead.
22
+
23
+ ```sh
24
+ cd /path/to/project
25
+ agent-env init
26
+ agent-env up --name app --https
27
+ agent-env ui
28
+ ```
29
+
30
+ `init` detects Compose files, package scripts, package managers, and common framework ports.
31
+ It never replaces an existing configuration or edits package scripts.
32
+ For a first dependency installation, use `agent-env up --timeout 900` or set `startupTimeoutSeconds` in `agent-env.json`.
33
+ Compose builds and image downloads finish before its shared readiness timeout begins.
34
+ For an unknown project, supply a command:
35
+
36
+ ```sh
37
+ agent-env init --port 3000 --image node:22-bookworm-slim -- node server.js
38
+ ```
39
+
40
+ For the included example:
41
+
42
+ ```sh
43
+ cd examples/hello
44
+ agent-env up --name demo
45
+ ```
46
+
47
+ Open `http://demo.localhost:3300`. The example also provides Postgres on `localhost:55432` with development-only credentials.
48
+
49
+ For a full application, use the [Agent Swarm development example](docs/agent-swarm.md).
50
+
51
+ ## Local and remote
52
+
53
+ ```sh
54
+ agent-env up --name local --local
55
+ agent-env up --name remote --ssh user@server
56
+ agent-env use remote
57
+ ```
58
+
59
+ Both instances retain named addresses, such as `local.localhost:3000` and `remote.localhost:3000`.
60
+ `use` selects the instance behind bare `localhost:3000` and raw TCP ports.
61
+ A selection change closes existing connections that used bare localhost. Named HTTP connections remain separate.
62
+
63
+ SSH targets need Docker, rsync, Bash, and `timeout`. The local machine needs SSH and rsync.
64
+ Use an SSH alias or `--ssh-config FILE` for keys and ports. Existing SSH hosts require verified host keys.
65
+
66
+ Remote source synchronization starts automatically and follows file edits. Use `--no-watch` for manual synchronization.
67
+ `agent-env sync remote` synchronizes immediately. The daemon reconnects failed SSH tunnels with bounded retry intervals.
68
+ Synchronization excludes Git metadata, dependencies, common build directories, `.agent-env`, and `.env*` files.
69
+ Generated directories such as `build` and `dist` are excluded at the checkout root. Nested source paths such as `src/build` remain available.
70
+ Remote Compose validates bind mounts, build contexts, and Dockerfiles against the same rules.
71
+ Keep other credentials outside the source tree. Explicit Compose secret files use a separate private transfer.
72
+
73
+ ## Compose
74
+
75
+ Existing `compose.yaml`, `compose.yml`, and `docker-compose.*` files work directly, without `init`.
76
+
77
+ ```sh
78
+ agent-env up --name app --profile tools
79
+ agent-env up --name remote --compose-file compose.yaml --compose-file compose.dev.yaml --ssh my-host
80
+ ```
81
+
82
+ The provider preserves builds, dependencies, health checks, profiles, networks, and named volumes.
83
+ Each instance receives isolated names and loopback backend ports. Original Compose files remain unchanged.
84
+ Internal services remain internal. External volumes and networks survive removal and purge.
85
+
86
+ Multiple published ports on one service require explicit aliases in `compose.routes`.
87
+ UDP, replicas, host namespaces, custom network allocation, and some deployment features return explicit unsupported-feature errors.
88
+ Remote bind and build paths must remain inside the checkout.
89
+ See the [Compose example and contract](examples/compose/README.md).
90
+
91
+ ## Environments and secrets
92
+
93
+ `agent-env up` reads `.env` and `.env.local` automatically when those files exist.
94
+ `agent-env up --env review` also reads `.env.review` and `.env.review.local`, in that order.
95
+ A named dotenv file can define an environment without a JSON overlay.
96
+ Files with names such as `.env.example` never load automatically.
97
+
98
+ ```sh
99
+ agent-env up # Uses the project's dotenv files
100
+ agent-env up --env review # Adds the review dotenv files
101
+ agent-env up --env-file .env.custom # Replaces automatic file selection
102
+ agent-env up --no-env-file # Disables global dotenv files
103
+ ```
104
+
105
+ For native Docker services, later files override earlier files.
106
+ Private stored values override files, configuration values override the private store, and service values override the project configuration.
107
+ Native Docker treats file values literally. It never executes shell commands or expands `${VARIABLE}` expressions.
108
+ The parent shell enters native containers only through explicit `{ "env": "NAME" }` references.
109
+
110
+ Compose keeps its own interpolation rules and shell precedence.
111
+ Private stored values and explicit project configuration override the Compose interpolation environment.
112
+ Each Compose service still declares its container variables with `environment` or `env_file`.
113
+ `--no-env-file` disables global dotenv files. It does not disable service `env_file` declarations.
114
+
115
+ Environment files stay outside remote source synchronization.
116
+ The CLI resolves values locally and sends the required container configuration over SSH.
117
+ Saved instances retain their resolved values. Use `up --name NAME` to load edited files, or `up NAME` to resume saved values.
118
+ For Docker and Compose, environment-only changes recreate containers and retain their named volumes.
119
+ Use `up NAME --timeout 900` to increase the startup timeout of a saved instance.
120
+
121
+ ```sh
122
+ agent-env env set POSTGRES_PASSWORD --from-env MY_DATABASE_PASSWORD
123
+ # Or: agent-env env set POSTGRES_PASSWORD < /path/to/private/password-file
124
+ agent-env env list
125
+ agent-env env unset POSTGRES_PASSWORD
126
+ ```
127
+
128
+ Secret commands accept `--env NAME`. Values stay in a private store outside the checkout.
129
+ The CLI prints secret names only.
130
+ Logs and execution output redact private store values, secret references, and explicit environment references.
131
+ Credential names such as `PASSWORD`, `TOKEN`, and `API_KEY` also trigger redaction, including values loaded from dotenv files.
132
+ Credential URLs and common token formats also trigger redaction. Ordinary ports, hosts, modes, and database names remain readable.
133
+ Store secrets with unusual variable names in the private store, or use an explicit secret reference.
134
+
135
+ ```json
136
+ {
137
+ "provider": "compose",
138
+ "compose": { "files": ["compose.yaml"] },
139
+ "environments": {
140
+ "review": {
141
+ "envFiles": [".env.review"],
142
+ "env": { "POSTGRES_PASSWORD": { "secret": "POSTGRES_PASSWORD" } },
143
+ "compose": { "profiles": ["tools"] }
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ ```sh
150
+ agent-env env set POSTGRES_PASSWORD --env review --from-env MY_DATABASE_PASSWORD
151
+ agent-env up --env review --name review
152
+ ```
153
+
154
+ Values can be strings, `{ "secret": "KEY" }`, or `{ "env": "SHELL_VARIABLE" }`.
155
+ Repeated `--env-file FILE` options select explicit environment files.
156
+ Set `envFiles: []` in `agent-env.json` to disable automatic dotenv files for that configuration.
157
+
158
+ ## Services and caching
159
+
160
+ ```sh
161
+ agent-env add postgres
162
+ agent-env add redis
163
+ agent-env up
164
+ ```
165
+
166
+ Postgres defaults to public port 55432. Redis defaults to 56379. Use `--port` to select another port.
167
+ Both services retain data in named volumes. Postgres receives a generated password in the private environment store.
168
+ Compose projects receive a separate `agent-env.addons.json` override.
169
+ Use `add postgres --env review` to add the service and its secret only to the `review` environment.
170
+ Compose uses a separate override file for each selected environment.
171
+
172
+ Native Docker projects use this format:
173
+
174
+ ```json
175
+ {
176
+ "services": {
177
+ "web": {
178
+ "image": "node:22-bookworm-slim",
179
+ "port": 3000,
180
+ "command": "npm ci && npm run dev -- --host 0.0.0.0",
181
+ "volumes": { "dependencies": "/workspace/node_modules", "cache": "/root/.npm" }
182
+ }
183
+ }
184
+ }
185
+ ```
186
+
187
+ The checkout appears at `/workspace`. `containerPort` defaults to `port`.
188
+ Processes must listen on `0.0.0.0`. The runtime sets `HOST` and `PORT` for native Docker services.
189
+ Services reach each other by service name on the instance network.
190
+ Named volumes retain dependencies and package caches. Docker retains build and image caches.
191
+
192
+ ## Hetzner
193
+
194
+ Authenticate the installed `hcloud` CLI, then run:
195
+
196
+ ```sh
197
+ agent-env up --name cloud --cloud hetzner --location hel1
198
+ agent-env down cloud
199
+ agent-env up cloud
200
+ agent-env rm cloud --purge
201
+ ```
202
+
203
+ The CLI provisions Docker and Compose, generates dedicated SSH credentials, and pins the host identity before connecting.
204
+ Ownership records and labels protect unrelated machines, keys, and IP addresses.
205
+ Each record also binds to the active hcloud context, API endpoint, and a fingerprint of its credential.
206
+ Changed settings stop cloud operations before resource access. Local keys and ownership records remain available for recovery.
207
+ Older records without this binding require manual recovery in Hetzner. The CLI preserves their local keys.
208
+ `down` stops containers and powers off an otherwise unused owned machine. Its disk remains available for resume.
209
+ Powered-off Hetzner machines still incur charges. `rm --purge` deletes the last instance's owned machine, IP, key, and data.
210
+ A plain `rm` retains the cloud record so `list`, resume, and later purge remain available.
211
+ Forks share their source machine. Removing one fork preserves other instances on that machine.
212
+
213
+ The default idle timeout is 30 minutes. Use `--idle-minutes 0` to disable it.
214
+ Machine and lifecycle flags require `--cloud hetzner` or a configured cloud provider.
215
+ An explicit `--ssh` or `--local` target overrides a configured cloud target.
216
+ HTTP requests, WebSocket traffic, TCP traffic, source synchronization, and explicit service commands record activity.
217
+ Use `--ttl-hours N` for an explicit destructive expiry.
218
+ Expiry deletes the shared machine and every instance on it, including sleeping forks. It does not wake the machine.
219
+ Failed idle and expiry actions use bounded retry delays.
220
+ **Idle shutdown and expiry require the local daemon to remain running and connected.** They are not cloud-side schedules.
221
+ Keep the same `hcloud` context and credential for a machine's lifecycle.
222
+ The CLI reads effective settings through [hcloud config](https://github.com/hetznercloud/cli/blob/main/docs/reference/manual/hcloud_config_get.md).
223
+
224
+ ## Forks and snapshots
225
+
226
+ Stop the source before copying database volumes:
227
+
228
+ ```sh
229
+ agent-env down app
230
+ agent-env fork app experiment --worktree ../experiment --branch experiment
231
+ ```
232
+
233
+ This creates a Git worktree, copies current source changes, copies volumes, and starts the fork.
234
+ Credentials and generated files are excluded from copied changes. The source remains unchanged.
235
+ Without `--worktree`, `--branch`, or `--ref`, `fork` copies data and uses the existing source checkout.
236
+ Forks require a new destination name and run on the source Docker host.
237
+
238
+ Resume a saved fork from any directory:
239
+
240
+ ```sh
241
+ agent-env down experiment
242
+ agent-env up experiment
243
+ ```
244
+
245
+ `up NAME` uses the saved configuration, including resolved secrets and the source path.
246
+ `up --name NAME` loads the current project's configuration and checks it against the saved instance.
247
+
248
+ ```sh
249
+ agent-env snapshot app /existing/backups/checkpoint
250
+ agent-env restore /existing/backups/checkpoint --name restored
251
+
252
+ agent-env snapshot app s3://my-bucket/checkpoint
253
+ agent-env restore s3://my-bucket/checkpoint --name restored --ssh my-host
254
+ ```
255
+
256
+ Restore uses the current project's configuration and creates fresh volumes before starting services.
257
+ It verifies archive hashes and extraction paths. It refuses existing destination volumes.
258
+ Snapshots contain named volume data. Source files require Git or another backup procedure.
259
+ Compose anonymous volumes require a separate backup or explicit omission. Snapshot export rejects them before archive creation.
260
+ Unsupported or shared mounts cause an error. `--omit-mounts` explicitly excludes them and reports each omission.
261
+
262
+ S3 uses the installed AWS CLI and its normal credentials. `--endpoint URL` selects compatible storage.
263
+ Each snapshot needs a new prefix. Conditional writes prevent overwrites, and the manifest uploads last.
264
+ A failed upload can leave partial objects for manual inspection. Export uses single-object uploads, limited to 5 GB per volume on AWS S3.
265
+ Archives require temporary local disk space. S3 stores backups, while running databases use Docker volumes.
266
+ See [snapshot format and worktree ownership](docs/snapshots-and-worktrees.md).
267
+
268
+ ## HTTPS and status UI
269
+
270
+ ```sh
271
+ agent-env https --https-port 8443
272
+ agent-env trust
273
+ agent-env ui --port 4780
274
+ ```
275
+
276
+ HTTPS uses a private local CA and one loopback proxy. HTTP/2, HTTP/1.1, and WebSocket upgrades are supported.
277
+ The primary HTTP service uses `https://app.localhost:8443`.
278
+ Other HTTP services use `https://app--SERVICE.localhost:8443`.
279
+ `up --https` enables the proxy during startup.
280
+
281
+ `trust` explicitly installs the CA in the macOS user keychain or a Debian-based Linux trust store.
282
+ Certificate generation uses a process lock. New certificate authorities restrict signing to localhost names and loopback addresses.
283
+ Linux trust installation requires sudo permission. Some browsers and runtimes maintain separate trust stores.
284
+ For curl without OS trust installation:
285
+
286
+ ```sh
287
+ curl --cacert ~/.local/state/agent-env/tls/ca.pem \
288
+ --resolve app.localhost:8443:127.0.0.1 https://app.localhost:8443/
289
+ ```
290
+
291
+ The dashboard displays instances, service health, addresses, synchronization state, and redacted logs.
292
+ Automatic refresh is optional and persists in browser storage. The dashboard accepts local read-only requests.
293
+ Application pages contain no injected browser controls.
294
+
295
+ ## Lifecycle and provider boundaries
296
+
297
+ ```sh
298
+ agent-env list
299
+ agent-env logs app --service web
300
+ agent-env exec app --service postgres -- psql -U postgres -d app -c 'select 1'
301
+ agent-env down app
302
+ agent-env rm app --purge
303
+ agent-env daemon stop
304
+ ```
305
+
306
+ `down` retains containers and data. `rm` removes containers and retains volumes and built images.
307
+ `rm --purge` also removes managed volumes and the instance's owned Compose image tags.
308
+ Shared base images and Docker build caches remain available.
309
+ For a removed local instance, recreate it with `up` before purging its retained volumes.
310
+ Changes to ports, images, commands, mounts, or providers require `rm`, then `up`.
311
+ Environment-only changes use `up --name NAME` and preserve volumes.
312
+ `daemon stop` stops routing, synchronization, and lifecycle timers. Containers remain running.
313
+ The next command restores saved routing and enabled UI/HTTPS listeners.
314
+
315
+ Worktrees remain after instance removal by default.
316
+ `rm NAME --remove-worktree` removes an owned clean worktree.
317
+ Add `--discard-worktree` only when you intend to discard its changed and ignored files.
318
+
319
+ State defaults to `~/.local/state/agent-env`. `AGENT_ENV_HOME` selects an isolated private state directory.
320
+ State can contain environment values. Back up cloud ownership records and credentials before moving it.
321
+ All application ports bind loopback. Remote traffic travels through SSH.
322
+
323
+ The [sandbox adapter contract](src/providers/CLOUD_SANDBOX.md) accepts trusted local ESM adapters with explicit capabilities.
324
+ Use `--provider sandbox --adapter docker` for the included bridge.
325
+ No hosted sandbox vendor or Firecracker runtime is bundled. Those require separate adapters.
326
+ Firecracker requires a Linux host with KVM access, a guest kernel, root filesystem, and guest networking.
327
+ See [Firecracker's host requirements](https://github.com/firecracker-microvm/firecracker/blob/main/docs/getting-started.md).
328
+
329
+ ## Verification
330
+
331
+ ```sh
332
+ npm run check
333
+ npm test
334
+ npm run test:e2e
335
+ npm run test:runtime
336
+ npm run test:features
337
+ AGENT_ENV_TEST_SSH=my-host AGENT_ENV_TEST_SSH_CONFIG=/path/to/config npm run test:features
338
+ # Creates and deletes one billed Hetzner machine:
339
+ AGENT_ENV_TEST_CLOUD=1 npm run test:cloud
340
+ ```
341
+
342
+ Tests create isolated resources and remove them afterward. SSH tests use an existing host without provisioning or deleting it.
343
+ The explicit cloud test provisions a temporary machine and verifies credential protection, source sync, fork recreation, sleeping expiry, and resource deletion.
344
+ See [verification evidence](VERIFICATION.md) for completed local, remote, S3, cloud, and UI checks.
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <path d="M32 4 57 18.5v27L32 60 7 45.5v-27Z" fill="#f59e0b"/>
3
+ <path d="m32 12 18 10.5-18 10.4-18-10.4Z" fill="#fcd34d"/>
4
+ <path d="M14 29v12.5l15 8.7V37.7Zm21 8.7v12.5l15-8.7V29Z" fill="#17130c"/>
5
+ </svg>
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import http from 'node:http';
4
+ import { mkdir, open, unlink, rm, stat, readFile } from 'node:fs/promises';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { join, resolve } from 'node:path';
7
+ import { trustTls } from '../src/tls.mjs';
8
+ import { parseArgs } from 'node:util';
9
+ import { home, socketPath } from '../src/paths.mjs';
10
+ import { loadSpec } from '../src/config.mjs';
11
+ import { initialize } from '../src/init.mjs';
12
+ import { addService } from '../src/goodies.mjs';
13
+ import { setEnvironmentSecret, listEnvironmentSecrets, unsetEnvironmentSecret } from '../src/environments.mjs';
14
+ import { delay } from '../src/system.mjs';
15
+
16
+ const help = `agent-env: development services with stable localhost addresses
17
+
18
+ agent-env init Create agent-env.json
19
+ agent-env add postgres | redis Add a persistent service
20
+ agent-env up [--ssh user@host] Start the configured stack
21
+ agent-env up NAME Resume the saved instance from any directory
22
+ agent-env up --name demo --port 3000 -- node server.js
23
+ agent-env list Show services and addresses
24
+ agent-env ui Open the local status dashboard
25
+ agent-env https [--https-port 8443] Enable local HTTPS
26
+ agent-env trust Trust the local certificate authority
27
+ agent-env use NAME Select NAME for bare localhost
28
+ agent-env logs NAME [--service web] Read recent service logs
29
+ agent-env exec NAME [--service db] -- COMMAND [ARGS...]
30
+ agent-env sync NAME Copy local changes to an SSH host
31
+ agent-env env set KEY [--from-env VAR] Store a secret from stdin or a variable
32
+ agent-env env list | unset KEY Manage secret names for --env NAME
33
+ agent-env snapshot NAME LOCATION Save stopped volumes locally or to S3
34
+ agent-env restore LOCATION --name NAME Restore into a new instance
35
+ agent-env down NAME | --all Stop containers and retain data
36
+ agent-env fork SOURCE DEST [--worktree PATH] Fork stopped data and optional source files
37
+ agent-env rm NAME [--purge] Remove containers, optionally data
38
+ agent-env daemon stop Stop routing and retain containers
39
+
40
+ Options:
41
+ --name NAME, --port PORT, --image IMAGE, --config FILE, --service NAME, --json
42
+ --ssh HOST, --ssh-config FILE, --local, --provider NAME, --adapter FILE
43
+ --cloud hetzner, --cloud-type TYPE, --location LOCATION
44
+ --idle-minutes MINUTES, --ttl-hours HOURS (require a cloud target)
45
+ --env NAME, --env-file FILE, --no-env-file, --from-env VARIABLE
46
+ --version Show the installed package version
47
+ --compose-file FILE, --profile NAME (repeat for multiple files or profiles)
48
+ --https, --https-port PORT, --no-watch, --timeout SECONDS (startup, default 60)
49
+ --worktree PATH, --branch BRANCH, --ref REF
50
+ --endpoint URL, --omit-mounts, --all, --purge
51
+ --remove-worktree, --discard-worktree (requires --remove-worktree)
52
+
53
+ Requires Node 22+ and Docker. SSH targets require Docker, rsync, and Bash.
54
+ HTTP supports named hosts and WebSockets. TCP uses the selected instance.
55
+ `;
56
+ function request(message, timeout = message.type === 'resume' ? (message.startupTimeoutSeconds ?? 3600) * 1000 + 120000 : Math.max(600000, (message.spec?.startupTimeoutSeconds ?? 0) * 1000 + 120000)) {
57
+ return new Promise((resolve, reject) => {
58
+ const req = http.request({ socketPath, path: '/', method: 'POST', headers: { 'content-type': 'application/json' } }, async response => {
59
+ try {
60
+ let body = '';
61
+ for await (const part of response) body += part;
62
+ const result = JSON.parse(body);
63
+ if (result.error) throw Object.assign(new Error(result.error), { exitCode: result.exitCode });
64
+ resolve(result);
65
+ } catch (error) { reject(error); }
66
+ });
67
+ req.setTimeout(timeout, () => req.destroy(new Error('The daemon request timed out. Check agent-env list before retrying.')));
68
+ req.on('error', reject);
69
+ req.end(JSON.stringify(message));
70
+ });
71
+ }
72
+ async function ensureDaemon() {
73
+ try { await request({ type: 'ping' }, 1000); return; } catch {}
74
+ await mkdir(home, { recursive: true, mode: 0o700 });
75
+ const lock = join(home, 'start.lock');
76
+ let acquired = false;
77
+ const deadline = Date.now() + 15000;
78
+ while (!acquired) {
79
+ try { await mkdir(lock); acquired = true; }
80
+ catch (error) {
81
+ if (error.code !== 'EEXIST') throw error;
82
+ try { await request({ type: 'ping' }, 500); return; } catch {}
83
+ const info = await stat(lock).catch(() => null);
84
+ if (info && Date.now() - info.mtimeMs > 20000) { await rm(lock, { recursive: true, force: true }); continue; }
85
+ if (Date.now() > deadline) throw new Error('The daemon startup lock did not clear. Retry after 20 seconds.');
86
+ await delay(100);
87
+ }
88
+ }
89
+ try {
90
+ try { await request({ type: 'ping' }, 500); return; } catch {}
91
+ await unlink(socketPath).catch(error => { if (error.code !== 'ENOENT') throw error; });
92
+ const log = await open(join(home, 'daemon.log'), 'a', 0o600);
93
+ const child = spawn(process.execPath, [fileURLToPath(new URL('../src/daemon.mjs', import.meta.url))], { detached: true, stdio: ['ignore', log.fd, log.fd] });
94
+ child.unref();
95
+ await log.close();
96
+ while (Date.now() < deadline) {
97
+ try { await request({ type: 'ping' }, 500); return; } catch {}
98
+ if (child.exitCode !== null) break;
99
+ await delay(100);
100
+ }
101
+ throw new Error(`The daemon did not start. Read ${join(home, 'daemon.log')}.`);
102
+ } finally { await rm(lock, { recursive: true, force: true }); }
103
+ }
104
+ function display(result, json) {
105
+ if (json) { console.log(JSON.stringify(result, null, 2)); return; }
106
+ if (Array.isArray(result)) {
107
+ if (!result.length) console.log('No instances. Run agent-env up.');
108
+ for (const item of result) typeof item === 'string' ? console.log(item) : display(item, false);
109
+ } else if (result.url) console.log(result.url);
110
+ else if ('output' in result) console.log(result.output);
111
+ else if (result.message) console.log(result.message);
112
+ else if (result.name) {
113
+ console.log(`${result.name} (${result.host}, ${result.connected ? 'connected' : result.running ? 'disconnected' : 'stopped'})`);
114
+ for (const [name, service] of Object.entries(result.services)) {
115
+ console.log(` ${name.padEnd(12)} ${service.httpsUrl ?? service.url ?? 'internal'}${service.selected ? ' [localhost selected]' : ''}${result.status ? ` ${result.status[name]}` : ''}`);
116
+ }
117
+ if (result.worktree) console.log(` Source: ${result.worktree}`);
118
+ if (result.error) console.log(` ${result.error}`);
119
+ }
120
+ for (const mount of result.omittedMounts ?? []) console.log(` Omitted ${mount.service}:${mount.target}: ${mount.reason}`);
121
+ for (const path of result.omittedPaths ?? []) console.log(` Omitted source file: ${path}`);
122
+ }
123
+ async function main() {
124
+ const args = process.argv.slice(2);
125
+ const separator = args.indexOf('--');
126
+ const command = separator < 0 ? [] : args.splice(separator).slice(1);
127
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
128
+ 'env-file': { type: 'string', multiple: true }, 'no-env-file': { type: 'boolean' }, 'from-env': { type: 'string' },
129
+ version: { type: 'boolean', short: 'v' },
130
+ timeout: { type: 'string' },
131
+ 'remove-worktree': { type: 'boolean' }, 'discard-worktree': { type: 'boolean' }, 'omit-mounts': { type: 'boolean' },
132
+ cloud: { type: 'string' }, 'cloud-type': { type: 'string' }, location: { type: 'string' }, 'idle-minutes': { type: 'string' }, 'ttl-hours': { type: 'string' },
133
+ provider: { type: 'string' }, adapter: { type: 'string' }, env: { type: 'string' }, profile: { type: 'string', multiple: true }, 'compose-file': { type: 'string', multiple: true },
134
+ https: { type: 'boolean' }, 'https-port': { type: 'string' }, 'no-watch': { type: 'boolean' },
135
+ worktree: { type: 'string' }, branch: { type: 'string' }, ref: { type: 'string' }, endpoint: { type: 'string' },
136
+ name: { type: 'string' }, port: { type: 'string' }, image: { type: 'string' }, ssh: { type: 'string' },
137
+ 'ssh-config': { type: 'string' }, local: { type: 'boolean' }, config: { type: 'string' }, service: { type: 'string' },
138
+ json: { type: 'boolean' }, all: { type: 'boolean' }, purge: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
139
+ } });
140
+ const [type, name, destination] = positionals;
141
+ if (values.version) {
142
+ const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
143
+ console.log(values.json ? JSON.stringify({ version }) : version);
144
+ return;
145
+ }
146
+ if (!type || values.help || type === 'help') { console.log(help); return; }
147
+ if (type === 'trust') { display(await trustTls(), values.json); return; }
148
+ if (type === 'add') { display(await addService(name, { config: values.config, port: values.port, environment: values.env }), values.json); return; }
149
+ if (type === 'init') {
150
+ const result = await initialize({ config: values.config, command, port: values.port, image: values.image });
151
+ display({ message: `Created ${result.filename}. Run agent-env up.` }, values.json);
152
+ return;
153
+ }
154
+ if (type === 'env') {
155
+ const options = { cwd: process.cwd(), environment: values.env ?? 'default', key: destination };
156
+ if (name === 'list') display(await listEnvironmentSecrets(options), values.json);
157
+ else if (name === 'unset') display({ message: await unsetEnvironmentSecret(options) ? 'Secret removed.' : 'Secret was not set.' }, values.json);
158
+ else if (name === 'set') {
159
+ if (!values['from-env'] && process.stdin.isTTY) throw new Error('Provide the secret through stdin or --from-env VARIABLE.');
160
+ await setEnvironmentSecret({ ...options, ...(values['from-env'] ? { fromEnv: values['from-env'] } : { stdin: process.stdin }) });
161
+ display({ message: `Stored ${destination} for environment ${options.environment}.` }, values.json);
162
+ } else throw new Error('Use env set KEY, env list, or env unset KEY.');
163
+ return;
164
+ }
165
+ if (type === 'daemon' && name === 'stop') {
166
+ const result = await request({ type: 'shutdown' });
167
+ const deadline = Date.now() + 15000;
168
+ while (Date.now() < deadline) {
169
+ try { await request({ type: 'ping' }, 1000); }
170
+ catch (error) {
171
+ if (['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error.code)) { display(result, values.json); return; }
172
+ throw error;
173
+ }
174
+ await delay(100);
175
+ }
176
+ throw new Error('The daemon is still stopping. Wait for the current operation to finish.');
177
+ }
178
+ if (!['up', 'list', 'use', 'logs', 'exec', 'sync', 'down', 'rm', 'fork', 'ui', 'https', 'snapshot', 'restore'].includes(type)) throw new Error(`Unknown command: ${type}. Run agent-env --help.`);
179
+ if (!['up', 'list', 'ui', 'https', 'restore'].includes(type) && !name && !(values.all && ['down', 'rm'].includes(type))) throw new Error(`Specify an instance name for ${type}.`);
180
+ if (['fork', 'snapshot'].includes(type) && !destination) throw new Error('Specify a destination name.');
181
+ if (type === 'exec' && !command.length) throw new Error('Specify a command after --.');
182
+ if (type === 'restore' && !name) throw new Error('Specify a snapshot directory or S3 URL.');
183
+ if (values['discard-worktree'] && !values['remove-worktree']) throw new Error('Use --discard-worktree with --remove-worktree.');
184
+ const message = { type, name, destination, service: values.service, command, all: values.all, purge: values.purge, worktree: values.worktree ? resolve(values.worktree) : undefined, branch: values.branch, ref: values.ref, endpoint: values.endpoint, removeWorktree: values['remove-worktree'], discardWorktree: values['discard-worktree'], omitMounts: values['omit-mounts'] };
185
+ if (type === 'snapshot' || type === 'restore') {
186
+ const location = type === 'snapshot' ? destination : name;
187
+ message.location = location.startsWith('s3://') ? location : resolve(location);
188
+ message.awsEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_ENDPOINT_URL_S3', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_CONFIG_FILE', 'AWS_CA_BUNDLE'].includes(key)));
189
+ }
190
+ if (type === 'https') message.port = Number(values['https-port'] ?? 8443);
191
+ if (type === 'ui') message.port = Number(values.port ?? 4780);
192
+ if (type === 'up' && name) {
193
+ if (destination || command.length || Object.keys(values).some(key => !['https', 'https-port', 'json', 'timeout'].includes(key))) throw new Error('Use up NAME without configuration options to resume a saved instance.');
194
+ message.type = 'resume';
195
+ if (values.timeout !== undefined) message.startupTimeoutSeconds = Number(values.timeout);
196
+ } else if (type === 'up' || type === 'restore') {
197
+ message.spec = await loadSpec(values, command);
198
+ if (values.provider) message.spec.provider = values.provider;
199
+ if (values.adapter) message.spec.sandbox = { ...message.spec.sandbox, adapter: values.adapter };
200
+ if (values['no-watch']) message.spec.watch = false;
201
+ }
202
+ await ensureDaemon();
203
+ if (type === 'up' && !values.json) console.error('Starting services. The first image download can take longer.');
204
+ const result = await request(message);
205
+ if (type === 'up' && values.https) {
206
+ await request({ type: 'https', port: Number(values['https-port'] ?? 8443) });
207
+ display((await request({ type: 'list' })).find(item => item.name === result.name), values.json);
208
+ } else display(result, values.json);
209
+ }
210
+ main().catch(error => { console.error(`agent-env: ${error.message}`); process.exitCode = error.exitCode ?? 1; });