@monster-orange/den 0.2.2 → 0.2.4
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/CHANGELOG.md +151 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +4 -2
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/environment-manager/health.ts +36 -1
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +205 -8
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/manifest/schema.ts +15 -0
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/runtime.ts +51 -3
- package/src/shared/stacks/status.ts +109 -1
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +151 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +101 -183
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/workspace-routes.ts +272 -181
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,157 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.2.4] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`GET /api/services` no longer blocks the web server.** It looked up
|
|
12
|
+
each workspace checkout's branch with a synchronous `git rev-parse`, one
|
|
13
|
+
after another (~40ms each), so with a dozen repos every request held the
|
|
14
|
+
event loop for 0.5-1.5s and everything else waited behind it: the UI,
|
|
15
|
+
the MCP server's reads, and `/api/server-info`, which `<cli> mcp connect`
|
|
16
|
+
probes with a 1.5s timeout. The lookups now run as async child processes
|
|
17
|
+
in parallel; concurrent requests (one per open tab) share one build, and
|
|
18
|
+
a settled build is reused for 2s (`?refresh=1` forces a new one; any
|
|
19
|
+
mutating request clears it). Concurrent `/api/health` polls also share
|
|
20
|
+
one run instead of each starting its own.
|
|
21
|
+
- **`<cli> mcp connect` / `mcp clients` tell a slow web server from a
|
|
22
|
+
stopped one.** A server that accepts the connection but does not answer
|
|
23
|
+
within 1.5s is reported as "not answering within 1.5s", not "not
|
|
24
|
+
running". When the org config sets `web.service` (the web UI runs as a
|
|
25
|
+
launchd service), the advice is `<cli> service status` instead of
|
|
26
|
+
`<cli> web up`.
|
|
27
|
+
- **Web server shutdown no longer leaks an unhandled rejection, or hangs.**
|
|
28
|
+
On SIGTERM/SIGINT the server reads the running-services registry, whose
|
|
29
|
+
path resolves the distribution root again; if that walk fails (the dist's
|
|
30
|
+
den.yml moved or removed while the server ran), the async shutdown
|
|
31
|
+
rejected unhandled ("unhandled rejection (continuing): Error: cannot
|
|
32
|
+
locate the distribution root: walked up from
|
|
33
|
+
<dist>/node_modules/@monster-orange/den/src/ui/server") and, its signal
|
|
34
|
+
guard already set, the process never exited, so a service manager had to
|
|
35
|
+
wait out its timeout and kill it. The shutdown now logs the failure and
|
|
36
|
+
exits.
|
|
37
|
+
- **Deactivating a workspace no longer stops a service that moved to
|
|
38
|
+
another workspace.** A daemon's owners were the workspaces recorded when
|
|
39
|
+
it started, so a repo moved from `default` into `ws-b` while its daemon
|
|
40
|
+
ran was still "owned" only by `default`, and `workspace deactivate
|
|
41
|
+
default` stopped it. Release now re-derives the owners from the current
|
|
42
|
+
workspaces.yml membership: a daemon is stopped only when no active
|
|
43
|
+
workspace still lists its repo, and otherwise its recorded owners become
|
|
44
|
+
the active workspaces that do.
|
|
45
|
+
- **`<cli> workspace --help` lists every subcommand**: activate,
|
|
46
|
+
deactivate and focus were missing (and `workspace focus <name>` now
|
|
47
|
+
exists in the CLI, as it did in the web UI). Deactivate's help and output
|
|
48
|
+
say that it releases only the services Den tracks: compose stacks of the
|
|
49
|
+
workspace's repos keep running, and the output names the running ones
|
|
50
|
+
with `<cli> down <repo>` for each (from one `docker ps`; a generic line
|
|
51
|
+
when docker can't be asked).
|
|
52
|
+
- **The first `/api/health/stream` (and `/api/health`) cycle no longer
|
|
53
|
+
blocks the web server for a second.** Resolving the manifest of a repo
|
|
54
|
+
without its own (a held manifest under `manifests/`) looked up the
|
|
55
|
+
checkout's origin with a synchronous `git config --get
|
|
56
|
+
remote.origin.url`, one repo after another (~40-100ms each), so a
|
|
57
|
+
server's first health run held the event loop for 1-1.5s. The origin is
|
|
58
|
+
now read from the checkout's git config file (worktrees and
|
|
59
|
+
subdirectories included), never by spawning git. Parsed manifests are
|
|
60
|
+
also reused while the file is unchanged: one request read the same
|
|
61
|
+
manifests several times, and parsing them held the loop ~100-200ms per
|
|
62
|
+
health run.
|
|
63
|
+
- **`GET /api/workspaces/available-repos` no longer blocks the web
|
|
64
|
+
server.** It ran `git remote get-url origin` (and, for managed clones,
|
|
65
|
+
`git rev-parse`) synchronously per checkout; the lookups are now async
|
|
66
|
+
and in parallel, and concurrent requests share one walk (reused for 2s;
|
|
67
|
+
`?refresh=1` forces a new one, any mutating request clears it).
|
|
68
|
+
- **`workspace deactivate` names the compose stacks a repo runs itself.**
|
|
69
|
+
It only knew Den stacks (a manifest's `compose:` section, project
|
|
70
|
+
`<stack>-<workspace>`), so a distribution whose repos start their own
|
|
71
|
+
compose projects (`make up`) got no line at all. A running project now
|
|
72
|
+
counts when Compose started it from inside one of the workspace's
|
|
73
|
+
checkouts (its `com.docker.compose.project.working_dir` label), as well
|
|
74
|
+
as when it is a Den stack's project.
|
|
75
|
+
- **`<cli> workspace <sub> --help`** prints that subcommand's help, not the
|
|
76
|
+
whole `workspace` help.
|
|
77
|
+
- **No synchronous directory walks or spawns on the web server's hot
|
|
78
|
+
request paths** (`/api/workspaces/available-repos`, the `/api/health`
|
|
79
|
+
stream). On a loaded machine (load average ~50) the dev.1 fixes still
|
|
80
|
+
left stalls of 1-5s; a CPU profile showed why:
|
|
81
|
+
- available-repos recursed into every directory without a manifest file
|
|
82
|
+
of its own, so in a distribution whose repos use held manifests it
|
|
83
|
+
walked each repo's whole tree (to depth 4) with `readdirSync` /
|
|
84
|
+
`existsSync`: seconds per call. It now never descends into a git
|
|
85
|
+
checkout or `node_modules`, and the walk is `fs.promises`.
|
|
86
|
+
- The distribution root (and so `product()`) was re-resolved on every
|
|
87
|
+
call, a den.yml `existsSync` per `*_ROOT` variable each time, hundreds
|
|
88
|
+
of times per request. It is now resolved once per set of inputs (the
|
|
89
|
+
env vars and entry script it reads); the engine root likewise.
|
|
90
|
+
- Manifests, the edge config and the derived graph order are reused
|
|
91
|
+
across a request and the polls after it (5s; any mutating request
|
|
92
|
+
clears them), and the graph order is recomputed after a request, never
|
|
93
|
+
inside one.
|
|
94
|
+
- HTTPS health checks built a TLS context from ~150 root certificates on
|
|
95
|
+
every check; one context is built once, at startup.
|
|
96
|
+
- The startup sweep of stale daemon records ran `docker ps` / `opctl
|
|
97
|
+
container ls` synchronously; it is async now (concurrent callers share
|
|
98
|
+
one call), and `/api/service/daemons` waits for it.
|
|
99
|
+
A new test runs the server with a spy on every synchronous fs and
|
|
100
|
+
child_process call and fails if either route makes a sync spawn or
|
|
101
|
+
directory walk, or any sync call outside a short allowlist of single
|
|
102
|
+
config-file reads.
|
|
103
|
+
- **The web UI service no longer runs at background priority.**
|
|
104
|
+
`service install` wrote `ProcessType: Background` into the LaunchAgent,
|
|
105
|
+
so macOS ran the UI server on efficiency cores with throttled I/O: on a
|
|
106
|
+
busy machine its first requests stalled for 5-11s. It is now
|
|
107
|
+
`Interactive`, and `service status` shows launchd's spawn type.
|
|
108
|
+
- **`workspace deactivate` never suggests stopping a stack another active
|
|
109
|
+
workspace still holds.** Such a stack is listed as "in use by another
|
|
110
|
+
workspace: <project> (still in <ws>)" instead of with a `down <repo>`
|
|
111
|
+
command, and a repo with a stack still in use gets no `down` suggestion.
|
|
112
|
+
|
|
113
|
+
### Migrations
|
|
114
|
+
|
|
115
|
+
- Re-run `<cli> service install` to pick up the new LaunchAgent priority
|
|
116
|
+
(`<cli> service status` should show `spawn type = interactive`).
|
|
117
|
+
|
|
118
|
+
## [0.2.3] - 2026-11-04
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- **`secret_env`: secrets resolved by Den into a stack's or a service's env**
|
|
123
|
+
(R16), so a consumer no longer wraps `<provider> run -- <cli> restart
|
|
124
|
+
<repo>` by hand, and a plain restart no longer drops a key. In a manifest,
|
|
125
|
+
`compose.secret_env` (for the docker compose process: the compose file's
|
|
126
|
+
`${VAR}` or pass-through `environment:` entries) and
|
|
127
|
+
`services.<svc>.secret_env` (for that service's own up/restart command)
|
|
128
|
+
map an env var to a fully qualified ref, `<vault>/<item>/<field>`. For
|
|
129
|
+
Doppler that is `<project>/<config>/<key>`; for 1Password vault / item /
|
|
130
|
+
field; for `env` the field is the env var to read. Existing refs (the
|
|
131
|
+
engine's named secrets, test users) are unchanged. Resolved through the
|
|
132
|
+
configured `secrets.provider` at `up` and `restart` (also the in-place
|
|
133
|
+
restart of one compose service), all or nothing, before anything stops or
|
|
134
|
+
starts. A missing key fails that target, exit 1, naming the key, ref and
|
|
135
|
+
provider. Values go only into the spawned process's env: never into the
|
|
136
|
+
compose override, logs, activity records, MCP output or messages. A dry
|
|
137
|
+
run (`-n`) never asks the provider; it lists the key names it would
|
|
138
|
+
inject. A CLI-tabs start of a service with `secret_env` runs as a daemon,
|
|
139
|
+
so the values stay out of the tab's shell. `validate` rejects a ref that
|
|
140
|
+
is not exactly three non-empty parts, and a key that is not an env var
|
|
141
|
+
name. See "Secrets in a target's env" in `docs/distributions.md`.
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
|
|
145
|
+
- **`<cli> mcp connect` for Claude Code failed whenever the server has env**
|
|
146
|
+
("Invalid environment variable format: <name>"): `claude mcp add`'s `-e`
|
|
147
|
+
takes several values, so the server name after the env pairs was read as
|
|
148
|
+
one more. The name now comes right after `add`.
|
|
149
|
+
- **The up/restart secrets check said the backend was "not ready" when it
|
|
150
|
+
was signed in with no default scope.** `secret_env` refs name their own
|
|
151
|
+
scope, so they still resolve; it now says "signed in, no default scope
|
|
152
|
+
set" instead of warning.
|
|
153
|
+
|
|
154
|
+
### Migrations
|
|
155
|
+
|
|
156
|
+
- None required.
|
|
157
|
+
|
|
7
158
|
## [0.2.2] - 2026-11-04
|
|
8
159
|
|
|
9
160
|
### Added
|
package/docs/distributions.md
CHANGED
|
@@ -213,6 +213,57 @@ ones (`⇄ hybrid`).
|
|
|
213
213
|
production_envs: [prod] # default: [prod, production]
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
## Secrets in a target's env
|
|
217
|
+
|
|
218
|
+
`secret_env` puts secrets from the configured secrets provider
|
|
219
|
+
(`secrets.provider`: `doppler`, `1password` or `env`) into the env of what
|
|
220
|
+
Den starts, instead of wrapping `<provider> run -- <cli> restart <repo>` by
|
|
221
|
+
hand:
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
# .acme.yml
|
|
225
|
+
compose:
|
|
226
|
+
secret_env: # the docker compose process's env, at stack up / restart
|
|
227
|
+
OPENAI_API_KEY: shop/dev_personal/OPENAI_API_KEY
|
|
228
|
+
services:
|
|
229
|
+
worker:
|
|
230
|
+
secret_env: # the env of this service's own up / restart command
|
|
231
|
+
STRIPE_KEY: Engineering/Stripe Test/secret key
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Each value is a fully qualified ref, `<vault>/<item>/<field>`:
|
|
235
|
+
|
|
236
|
+
| Provider | `a/b/c` means |
|
|
237
|
+
| ----------- | -------------------------------------------------------------------------- |
|
|
238
|
+
| `doppler` | `--project a --config b`, key `c` (`secrets.doppler` is not consulted) |
|
|
239
|
+
| `1password` | vault `a`, item `b`, field `c` |
|
|
240
|
+
| `env` | the env var `c` of the process running Den (`secrets.keys` is not applied) |
|
|
241
|
+
|
|
242
|
+
A `compose.secret_env` key reaches a container only through the compose
|
|
243
|
+
file: `${OPENAI_API_KEY}` in it, or a pass-through `environment:
|
|
244
|
+
[OPENAI_API_KEY]`.
|
|
245
|
+
|
|
246
|
+
- **When.** `up` and `restart`, including `restart <repo>/<service>` of one
|
|
247
|
+
compose service (`stack up`, `stack restart`, `stack restart --service`),
|
|
248
|
+
from the CLI, the web UI and MCP. Every key is resolved before anything
|
|
249
|
+
stops or starts.
|
|
250
|
+
- **Missing.** A key the provider cannot resolve (unset, empty, no access)
|
|
251
|
+
fails that target, exit 1, naming the key, the ref and the provider. The
|
|
252
|
+
rest of the set is not used: nothing starts with a partial set, and a
|
|
253
|
+
restart leaves the old instance running.
|
|
254
|
+
- **Where values go.** Only into the spawned process's env (the docker
|
|
255
|
+
compose process, or the service's command). Never into the generated
|
|
256
|
+
compose override, logs, activity records, MCP output, dry runs or
|
|
257
|
+
messages. A CLI-tabs (`cli_tabs`) start of a service with `secret_env`
|
|
258
|
+
runs as a daemon, since a tab would put them in its shell history.
|
|
259
|
+
- **Dry run.** `-n`/`--dry-run` never asks the provider. It lists the key
|
|
260
|
+
names it would inject: `shop: would inject secret_env compose:
|
|
261
|
+
OPENAI_API_KEY (names only; not resolved in a dry run)`.
|
|
262
|
+
- **Validation.** `<cli> validate` rejects a ref that is not exactly three
|
|
263
|
+
non-empty parts and a key that is not an env var name
|
|
264
|
+
(`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
|
|
265
|
+
`secret_env` too.
|
|
266
|
+
|
|
216
267
|
## den.yml: the product profile
|
|
217
268
|
|
|
218
269
|
Every runtime name derives from `product.name`. Set only what you want to
|
package/package.json
CHANGED
package/scripts/setup-secrets.ts
CHANGED
|
@@ -72,6 +72,17 @@ async function main(): Promise<number> {
|
|
|
72
72
|
const r = await provider.preflight();
|
|
73
73
|
if (r.ok) return 0;
|
|
74
74
|
if (gate && !cfg.required) {
|
|
75
|
+
// Signed in but no default scope: secret_env refs name their own
|
|
76
|
+
// (<project>/<config>/<key>), so only the engine's own secrets lack one.
|
|
77
|
+
if (await provider.isReady()) {
|
|
78
|
+
console.error(
|
|
79
|
+
chalk.gray(
|
|
80
|
+
` ${label}: signed in, no default scope set (secret_env refs still resolve;` +
|
|
81
|
+
` test logins and the saved sudo password need one: \`make ${product().cli}-setup-secrets\`).`,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
75
86
|
console.error(
|
|
76
87
|
chalk.yellow(
|
|
77
88
|
`⚠️ ${label} is not ready — continuing.\n` +
|
package/service/install.sh
CHANGED
|
@@ -65,7 +65,9 @@ data = {
|
|
|
65
65
|
"RunAtLoad": True,
|
|
66
66
|
"KeepAlive": True,
|
|
67
67
|
"ThrottleInterval": 10,
|
|
68
|
-
|
|
68
|
+
# Interactive, not Background: macOS gives Background agents efficiency
|
|
69
|
+
# cores and throttled I/O, and the UI stalled for seconds under load.
|
|
70
|
+
"ProcessType": "Interactive",
|
|
69
71
|
"StandardOutPath": f"{logs}/web.log",
|
|
70
72
|
"StandardErrorPath": f"{logs}/web.log",
|
|
71
73
|
}
|
|
@@ -115,7 +117,7 @@ case "${1:-install}" in
|
|
|
115
117
|
status)
|
|
116
118
|
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
117
119
|
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
118
|
-
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
|
|
120
|
+
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
119
121
|
else
|
|
120
122
|
echo "$LABEL is not loaded"
|
|
121
123
|
fi
|
|
@@ -31,6 +31,7 @@ import * as yaml from "yaml";
|
|
|
31
31
|
import { brandSlug, defaultBrand } from "../brand.js";
|
|
32
32
|
import { DEFAULT_PORT } from "../constants.js";
|
|
33
33
|
import { getDistRoot } from "../paths.js";
|
|
34
|
+
import { cachedRead } from "../read-cache.js";
|
|
34
35
|
import { readManifestStacks } from "../stacks/config.js";
|
|
35
36
|
import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
|
|
36
37
|
import { assertDomain } from "./render.js";
|
|
@@ -154,6 +155,21 @@ function manifestStackNames(): string[] {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
export function readEdgeConfig(root: string = getDistRoot()): EdgeConfig {
|
|
158
|
+
// The web server reads this on every request (open links, health rows);
|
|
159
|
+
// reused for the read cache's TTL there, read fresh everywhere else. An
|
|
160
|
+
// error is cached as such: most instances have no edge block at all.
|
|
161
|
+
const r = cachedRead(`edge-config:${root}`, () => {
|
|
162
|
+
try {
|
|
163
|
+
return { config: computeEdgeConfig(root) };
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { error: (e as Error).message };
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if ("error" in r) throw new Error(r.error);
|
|
169
|
+
return r.config;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function computeEdgeConfig(root: string): EdgeConfig {
|
|
157
173
|
const { team: t, user: u } = layers(root);
|
|
158
174
|
const team = (t.edge ?? {}) as RawEdge;
|
|
159
175
|
const user = (u.edge ?? {}) as RawEdge;
|
|
@@ -175,6 +175,36 @@ export function __setExtraCaForTests(pems: string[] | null): void {
|
|
|
175
175
|
_extraCa = pems;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* One TLS context with the public roots plus extraCa(), built once. Passing
|
|
180
|
+
* `ca` per request made Node parse ~150 root certificates on every HTTPS
|
|
181
|
+
* health check, synchronously: tens of ms per check, per poll.
|
|
182
|
+
*/
|
|
183
|
+
/** Build the HTTPS checks' trust context now (web server startup). */
|
|
184
|
+
export function prewarmHealthTls(): void {
|
|
185
|
+
trustContext();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let _trust: {
|
|
189
|
+
ca: string[];
|
|
190
|
+
context: tls.SecureContext;
|
|
191
|
+
agent: https.Agent;
|
|
192
|
+
} | null = null;
|
|
193
|
+
function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
|
|
194
|
+
const extra = extraCa();
|
|
195
|
+
if (_trust?.ca !== extra) {
|
|
196
|
+
_trust?.agent.destroy();
|
|
197
|
+
_trust = {
|
|
198
|
+
ca: extra,
|
|
199
|
+
context: tls.createSecureContext({
|
|
200
|
+
ca: [...tls.rootCertificates, ...extra],
|
|
201
|
+
}),
|
|
202
|
+
agent: new https.Agent({ keepAlive: false }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return _trust;
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
/**
|
|
179
209
|
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
180
210
|
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
@@ -201,7 +231,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
201
231
|
{
|
|
202
232
|
method: "GET",
|
|
203
233
|
servername: isIpLiteral(host) ? undefined : host,
|
|
204
|
-
|
|
234
|
+
// tls.connect's option, passed through by the agent (not in the
|
|
235
|
+
// https.RequestOptions type).
|
|
236
|
+
...({ secureContext: trustContext().context } as https.RequestOptions),
|
|
237
|
+
// Its own agent: a socket kept alive under one trust set must never
|
|
238
|
+
// be reused for a check under another.
|
|
239
|
+
agent: trustContext().agent,
|
|
205
240
|
lookup: (_h, opts, cb) => {
|
|
206
241
|
// Node may ask for all addresses (autoSelectFamily).
|
|
207
242
|
if ((opts as { all?: boolean }).all)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* repo's dev/up op, captured at spawn time and/or parsed on demand.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { execFileSync, execSync } from "child_process";
|
|
13
|
+
import { execFile, execFileSync, execSync } from "child_process";
|
|
14
14
|
import * as fs from "fs";
|
|
15
15
|
import * as path from "path";
|
|
16
16
|
import { parse as parseYaml } from "yaml";
|
|
@@ -333,3 +333,85 @@ export function diffSpawnedContainers(
|
|
|
333
333
|
for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
|
|
334
334
|
return [...fresh];
|
|
335
335
|
}
|
|
336
|
+
|
|
337
|
+
// ── Async variants (the web server) ──────────────────────────────────────────
|
|
338
|
+
// The sync calls above block the event loop for the whole `docker ps` /
|
|
339
|
+
// `opctl container ls` (hundreds of ms on a loaded machine). The server uses
|
|
340
|
+
// these instead; concurrent callers share one in-flight call.
|
|
341
|
+
|
|
342
|
+
function execOut(cmd: string, args: string[]): Promise<string | null> {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
execFile(
|
|
345
|
+
cmd,
|
|
346
|
+
args,
|
|
347
|
+
{ encoding: "utf-8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 },
|
|
348
|
+
(err, stdout) => resolve(err ? null : stdout),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function shared<T>(run: () => Promise<T>): () => Promise<T> {
|
|
354
|
+
let inFlight: Promise<T> | null = null;
|
|
355
|
+
return () =>
|
|
356
|
+
(inFlight ??= run().finally(() => {
|
|
357
|
+
inFlight = null;
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether `opctl` is on PATH, without spawning a shell. Cached. */
|
|
362
|
+
export async function isOpctlAvailableAsync(): Promise<boolean> {
|
|
363
|
+
if (opctlAvailableCache !== null) return opctlAvailableCache;
|
|
364
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
365
|
+
if (!dir) continue;
|
|
366
|
+
try {
|
|
367
|
+
await fs.promises.access(path.join(dir, "opctl"), fs.constants.X_OK);
|
|
368
|
+
return (opctlAvailableCache = true);
|
|
369
|
+
} catch {
|
|
370
|
+
/* next */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return (opctlAvailableCache = false);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** runningDockerNames, async; null when docker is unavailable. */
|
|
377
|
+
export const runningDockerNamesAsync = shared(
|
|
378
|
+
async (): Promise<string[] | null> => {
|
|
379
|
+
const out = await execOut("docker", ["ps", "--format", "{{.Names}}"]);
|
|
380
|
+
return out === null
|
|
381
|
+
? null
|
|
382
|
+
: out
|
|
383
|
+
.split("\n")
|
|
384
|
+
.map((l) => l.trim())
|
|
385
|
+
.filter(Boolean);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
/** listRunningOpctlContainers, async. */
|
|
390
|
+
export const listRunningOpctlContainersAsync = shared(
|
|
391
|
+
async (): Promise<{ name: string; base: string }[]> => {
|
|
392
|
+
const byBase = new Map<string, { name: string; base: string }>();
|
|
393
|
+
const [opctlOut, dockerNames] = await Promise.all([
|
|
394
|
+
isOpctlAvailableAsync().then((ok) =>
|
|
395
|
+
ok ? execOut("opctl", ["container", "ls"]) : null,
|
|
396
|
+
),
|
|
397
|
+
runningDockerNamesAsync(),
|
|
398
|
+
]);
|
|
399
|
+
if (opctlOut !== null) {
|
|
400
|
+
const lines = opctlOut.split("\n");
|
|
401
|
+
const firstToken = lines[0]?.trim().split(/\s+/)[0];
|
|
402
|
+
const body =
|
|
403
|
+
firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
|
|
404
|
+
for (const line of body) {
|
|
405
|
+
const name = line.trim().split(/\s+/)[0];
|
|
406
|
+
if (!name) continue;
|
|
407
|
+
const base = name.replace(/-[0-9a-f]{8}$/i, "");
|
|
408
|
+
byBase.set(base, { name, base });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const name of dockerNames ?? []) {
|
|
412
|
+
const m = name.match(DOCKER_OPCTL_NAME);
|
|
413
|
+
if (m && !byBase.has(m[1])) byBase.set(m[1], { name, base: m[1] });
|
|
414
|
+
}
|
|
415
|
+
return [...byBase.values()];
|
|
416
|
+
},
|
|
417
|
+
);
|