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