@monster-orange/den 0.2.1 → 0.2.2
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 +89 -0
- package/README.md +1 -1
- package/docs/distributions.md +65 -1
- package/package.json +1 -1
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +214 -2
- package/src/shared/manifest/validate-layers.ts +40 -1
- package/src/shared/stacks/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +20 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +184 -34
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +9 -0
- package/src/ui/mcp/server.ts +82 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,95 @@ 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.2] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`<cli> service install | status | print | uninstall`**: the web UI's
|
|
12
|
+
launchd service, over `service/install.sh`, so a distribution no longer
|
|
13
|
+
wraps the script in its Makefile. The label and log directory are
|
|
14
|
+
`web.service: { label, logs }` in the org config; the defaults are the
|
|
15
|
+
script's own (`local.<distribution dir name>.web`,
|
|
16
|
+
`~/Library/Logs/<distribution dir name>`), so an existing install keeps its
|
|
17
|
+
label. A bad label, a non-path `logs` or an unknown key under `web.service`
|
|
18
|
+
is an error. `install` (which also restarts) says `running` only once
|
|
19
|
+
`/api/server-info` answers with JSON, and after 30s without an answer says
|
|
20
|
+
`installed but not answering yet: check <logs dir>` and exits 1. `print`
|
|
21
|
+
writes the plist only, on any OS. See "Running the web UI as a service" in
|
|
22
|
+
`docs/distributions.md`.
|
|
23
|
+
- **`<cli> mcp --call <tool> ['<json args>']` and `<cli> mcp --list`.** One
|
|
24
|
+
tool call from a shell, through the same tool layer as the MCP server
|
|
25
|
+
(registered tools only, so actions only as named in `mcp.actions`; every
|
|
26
|
+
guard; redaction; audit headers), against the running web server. Prints
|
|
27
|
+
the tool's text to stdout and exits 1 when the tool reports an error.
|
|
28
|
+
`--list` prints the tool names.
|
|
29
|
+
- **Service aliases.** `up`, `down`, `restart` and `logs` (and every caller
|
|
30
|
+
of the service resolver, so `/api/service/exec` too) accept a service's
|
|
31
|
+
manifest key or a compose stack's name, when it names exactly one service
|
|
32
|
+
in the workspace: `<cli> restart indexer` restarts the repo whose service
|
|
33
|
+
or stack is `indexer`. Repo names and listed addresses win over an alias.
|
|
34
|
+
An ambiguous alias is an error naming the candidates (`"worker" is
|
|
35
|
+
ambiguous in workspace "default": one/worker, two/worker`). See "Naming a
|
|
36
|
+
service" in `docs/distributions.md`.
|
|
37
|
+
- **`validate` flags a redundant held manifest.** When a repo carries its own
|
|
38
|
+
manifest and the distribution still holds `manifests/<repo>.<name>.yml`,
|
|
39
|
+
`validate` warns (never an error) that the held file is not used and can be
|
|
40
|
+
deleted, or turned into `manifests/<repo>.override.yml`.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`-n`/`--dry-run` and `--plan` run nothing on every path.** Given a
|
|
45
|
+
`repo/service` address or an alias, `up` and `down` ignored them and
|
|
46
|
+
really ran the service (and its dependencies); they now print the order
|
|
47
|
+
they would run and stop. `restart` accepts `-n`/`--dry-run` too. Tested
|
|
48
|
+
for each way to name a target: the cwd repo, a repo, an address, an alias,
|
|
49
|
+
a compose stack name and an app.
|
|
50
|
+
- **A dry run says what the real run does**, per target: the same command
|
|
51
|
+
lookup (own command, restart as down + up, a repo-started service's
|
|
52
|
+
repo up, nothing to stop, not found), so a dry run fails where the real
|
|
53
|
+
run would. Every dry run names its targets (`Would down: …`).
|
|
54
|
+
- **`down` and `restart` on named services act on those services only.**
|
|
55
|
+
They took the services' dependencies down (or restarted them) too, even
|
|
56
|
+
ones other running services still use. `up` still starts dependencies;
|
|
57
|
+
`down <repo>` still stops the repo's closure, naming what else depends on
|
|
58
|
+
it.
|
|
59
|
+
- **`down <repo>` stops that repo only.** It stopped the repo's whole
|
|
60
|
+
derived closure, so `down api` took down stacks it sits on that other
|
|
61
|
+
running systems use. `--with-deps` keeps the old behaviour (naming what
|
|
62
|
+
else uses them); `down -a` still stops everything.
|
|
63
|
+
- **Plans show remote services as skipped, with the reason**
|
|
64
|
+
(`core (remote, skipped): core runs in dev; …`), and a named remote target
|
|
65
|
+
fails with the real run's reason.
|
|
66
|
+
- **A compose sub-service restarts on its own**: `restart <repo>/<svc>` runs
|
|
67
|
+
`docker compose restart <svc>` in its stack (`<cli> stack restart <stack>
|
|
68
|
+
--service <svc>`): the same container and env, nothing recreated. Up and
|
|
69
|
+
down stay the stack's.
|
|
70
|
+
- **A service its repo starts**: `down` on it says it is stopped by the
|
|
71
|
+
repo's down and names the command (exit 0, so a workspace Down that sends
|
|
72
|
+
every service stays clean); `restart` fails the same way, exit 1.
|
|
73
|
+
- **`down` of a remote repo no longer warns about its dependents**: it stops
|
|
74
|
+
nothing, so nothing is at risk. Only targets that really stop here count.
|
|
75
|
+
- **A failed or unknown target exits 1.** `up`, `down` and `restart` exited 0
|
|
76
|
+
when a named service failed or did not exist.
|
|
77
|
+
- **A service with no commands of its own** (its repo's up starts it): `up`
|
|
78
|
+
runs the repo's up and says so, instead of `command "up" not found`; other
|
|
79
|
+
commands point at the repo, since they would reach its other services.
|
|
80
|
+
- **`service status`** no longer prints launchctl's errors when the service
|
|
81
|
+
is not loaded.
|
|
82
|
+
|
|
83
|
+
### Migrations
|
|
84
|
+
|
|
85
|
+
- None required.
|
|
86
|
+
- `down <repo/service>` and `restart <repo/service>` no longer touch the
|
|
87
|
+
service's dependencies; stop or restart those by name.
|
|
88
|
+
- `down <repo>` (and a bare `down` in a repo) stops only that repo; add
|
|
89
|
+
`--with-deps` for its closure.
|
|
90
|
+
- A Makefile target that runs `service/install.sh` with `DEN_SERVICE_LABEL`
|
|
91
|
+
and `DEN_SERVICE_LOGS` can become `<cli> service install`; put a non-default
|
|
92
|
+
label or log directory in `web.service` first, since the CLI reads the
|
|
93
|
+
config, not those env vars.
|
|
94
|
+
- Delete held manifests `validate` now reports as unused.
|
|
95
|
+
|
|
7
96
|
## [0.2.1] - 2026-11-04
|
|
8
97
|
|
|
9
98
|
### Fixed
|
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ provider in `src/shared/auth/token-providers/`.
|
|
|
133
133
|
|
|
134
134
|
```bash
|
|
135
135
|
den up [repo] # start the repo's derived dependency closure (-x deps only, -n dry run)
|
|
136
|
-
den down [repo] # stop it cleanly
|
|
136
|
+
den down [repo] # stop it cleanly (--with-deps: and what it sits on)
|
|
137
137
|
den status # service health for the active workspace
|
|
138
138
|
den apps # Org > Application > Repo > Services
|
|
139
139
|
den graph [repo] # the derived dependency graph
|
package/docs/distributions.md
CHANGED
|
@@ -109,6 +109,23 @@ The web UI's Services cards show the layers each manifest was built from
|
|
|
109
109
|
`manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
|
|
110
110
|
held and override lookup at another directory (tests use it).
|
|
111
111
|
|
|
112
|
+
Once a repo carries its own manifest, a held `manifests/<repo>.acme.yml` is
|
|
113
|
+
never read. `validate` says so, as a warning (not an error), naming the held
|
|
114
|
+
file: delete it, or keep only what this distribution changes as
|
|
115
|
+
`manifests/<repo>.override.yml`.
|
|
116
|
+
|
|
117
|
+
## Naming a service
|
|
118
|
+
|
|
119
|
+
`up`, `down`, `restart` and `logs` take a repo name (`shop`) or a
|
|
120
|
+
`repo/service` address (`shop/api`). A service's manifest key (`api`) or a
|
|
121
|
+
compose stack's name (`compose.name`, else the manifest's `name`) works too,
|
|
122
|
+
when it names exactly one service in the workspace: a stack stands for its
|
|
123
|
+
repo, so `<cli> restart search` restarts the repo whose stack is `search`.
|
|
124
|
+
Repo names and listed addresses always win over an alias, so a catalog repo
|
|
125
|
+
is never mistaken for another repo's service. An alias that names more than
|
|
126
|
+
one service is an error that lists them (`"worker" is ambiguous in workspace
|
|
127
|
+
"default": one/worker, two/worker`); name one of them.
|
|
128
|
+
|
|
112
129
|
## Placement
|
|
113
130
|
|
|
114
131
|
Each service says where it runs, per environment: `local` (on this machine,
|
|
@@ -329,7 +346,40 @@ are the same directory.
|
|
|
329
346
|
## Running the web UI as a service (macOS)
|
|
330
347
|
|
|
331
348
|
Den ships a launchd installer that runs the web UI at login and restarts it if
|
|
332
|
-
it exits
|
|
349
|
+
it exits:
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
./bin/acme service install # install (or refresh) and start it
|
|
353
|
+
./bin/acme service status # launchd's view: state, pid, last exit code
|
|
354
|
+
./bin/acme service print # the LaunchAgent plist it would install; installs nothing
|
|
355
|
+
./bin/acme service uninstall # stop it and remove the LaunchAgent
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The label and log directory come from the org config:
|
|
359
|
+
|
|
360
|
+
```yaml
|
|
361
|
+
web:
|
|
362
|
+
port: 7777
|
|
363
|
+
service:
|
|
364
|
+
label: local.acme.web # default: local.<distribution dir name>.web
|
|
365
|
+
logs: ~/Library/Logs/acme # default: ~/Library/Logs/<distribution dir name>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The defaults are the installer's own, so an install made by the script keeps
|
|
369
|
+
its label. `logs` may start with `~/`; a relative path is under the
|
|
370
|
+
distribution root. A label other than letters, digits, `.`, `-` and `_`, a
|
|
371
|
+
`logs` that is not a path, or any other key under `web.service` is an error.
|
|
372
|
+
`print` works on any OS; the rest are macOS only.
|
|
373
|
+
|
|
374
|
+
`install` also restarts a service that is already installed. It says the
|
|
375
|
+
service is running only once the web server answers
|
|
376
|
+
`http://localhost:<web.port>/api/server-info` with JSON (a fresh server
|
|
377
|
+
answers empty for a few seconds while it starts). If it has not answered
|
|
378
|
+
within 30s, it says `installed but not answering yet: check <logs dir>` and
|
|
379
|
+
exits 1.
|
|
380
|
+
|
|
381
|
+
`<cli> service` runs the installer script below, which you can also run
|
|
382
|
+
yourself, from the distribution root:
|
|
333
383
|
|
|
334
384
|
```bash
|
|
335
385
|
DEN_ROOT="$PWD" DEN_SERVICE_LABEL=local.acme.web \
|
|
@@ -407,6 +457,20 @@ everyone's checkout is in the same place.
|
|
|
407
457
|
`--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
|
|
408
458
|
port.
|
|
409
459
|
|
|
460
|
+
### One call from a shell
|
|
461
|
+
|
|
462
|
+
```sh
|
|
463
|
+
./bin/acme mcp --list # the tool names this org registers
|
|
464
|
+
./bin/acme mcp --call status # one tool; prints its text result
|
|
465
|
+
./bin/acme mcp --call logs_tail '{"service":"shop/api","lines":50}'
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`--call <tool> ['<json args>']` runs one tool through the same tool layer as
|
|
469
|
+
the server: only registered tools (so actions only as named in
|
|
470
|
+
`mcp.actions`), every guard, the same redaction and audit headers. It prints
|
|
471
|
+
the tool's text to stdout and exits 1 when the tool reports an error (a
|
|
472
|
+
refusal, a failed action, the web server down).
|
|
473
|
+
|
|
410
474
|
### Config
|
|
411
475
|
|
|
412
476
|
Without an `mcp:` block in the org config (`config/<name>.yml`) only the read
|
package/package.json
CHANGED
package/service/install.sh
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
#
|
|
16
16
|
# DEN_SERVICE_LABEL launchd label (default local.<dist dir name>.web)
|
|
17
17
|
# DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
|
|
18
|
+
# DEN_SERVICE_READY_CHECK=external install does not print "running": the
|
|
19
|
+
# caller (`<cli> service install`) checks the server
|
|
20
|
+
# answers first.
|
|
18
21
|
set -euo pipefail
|
|
19
22
|
|
|
20
23
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -95,7 +98,11 @@ case "${1:-install}" in
|
|
|
95
98
|
done
|
|
96
99
|
[ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $LABEL (plist: $PLIST)" >&2; exit 1; }
|
|
97
100
|
launchctl kickstart -k "$DOMAIN/$LABEL"
|
|
98
|
-
|
|
101
|
+
if [ "${DEN_SERVICE_READY_CHECK:-}" = external ]; then
|
|
102
|
+
echo "… $LABEL started; waiting for it to answer"
|
|
103
|
+
else
|
|
104
|
+
echo "✓ $LABEL running (logs: $LOGS/web.log)"
|
|
105
|
+
fi
|
|
99
106
|
;;
|
|
100
107
|
uninstall)
|
|
101
108
|
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
|
@@ -106,7 +113,12 @@ case "${1:-install}" in
|
|
|
106
113
|
write_plist -
|
|
107
114
|
;;
|
|
108
115
|
status)
|
|
109
|
-
launchctl
|
|
116
|
+
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
117
|
+
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
118
|
+
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
|
|
119
|
+
else
|
|
120
|
+
echo "$LABEL is not loaded"
|
|
121
|
+
fi
|
|
110
122
|
;;
|
|
111
123
|
*) echo "usage: $0 [install|uninstall|status|print]" >&2; exit 2 ;;
|
|
112
124
|
esac
|
package/src/shared/den-config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
|
+
import * as os from "os";
|
|
12
13
|
import * as path from "path";
|
|
13
14
|
import * as yaml from "yaml";
|
|
14
15
|
import type { Environment, ReleaseSourceConfig } from "./types/index.js";
|
|
@@ -238,6 +239,11 @@ export interface DenConfig {
|
|
|
238
239
|
* only; `actions` opts in to named actions. See readMcpConfig.
|
|
239
240
|
*/
|
|
240
241
|
mcp?: McpConfig;
|
|
242
|
+
/**
|
|
243
|
+
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
244
|
+
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
245
|
+
*/
|
|
246
|
+
web?: WebConfig;
|
|
241
247
|
/**
|
|
242
248
|
* Shared test accounts (email + secrets-backend item per role) the test
|
|
243
249
|
* pillars log in as — see src/shared/auth/test-accounts.ts.
|
|
@@ -426,6 +432,7 @@ export function readDenConfig(): DenConfig {
|
|
|
426
432
|
env_health_urls: parsed?.env_health_urls,
|
|
427
433
|
security: parsed?.security,
|
|
428
434
|
mcp: parsed?.mcp,
|
|
435
|
+
web: parsed?.web,
|
|
429
436
|
test_accounts: parsed?.test_accounts,
|
|
430
437
|
defaults: parsed?.defaults,
|
|
431
438
|
secrets: parsed?.secrets,
|
|
@@ -602,6 +609,68 @@ export function readMcpConfig(
|
|
|
602
609
|
};
|
|
603
610
|
}
|
|
604
611
|
|
|
612
|
+
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
/** `web:` in the org config. */
|
|
615
|
+
export interface WebConfig {
|
|
616
|
+
port?: number;
|
|
617
|
+
/** The launchd service that runs the web UI (`<cli> service install`). */
|
|
618
|
+
service?: { label?: string; logs?: string };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** `web.service` with its defaults applied. */
|
|
622
|
+
export interface ResolvedWebService {
|
|
623
|
+
/** launchd label; also the plist's name in ~/Library/LaunchAgents. */
|
|
624
|
+
label: string;
|
|
625
|
+
/** Directory the service's web.log goes to. */
|
|
626
|
+
logs: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const WEB_SERVICE_KEYS = ["label", "logs"];
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The web UI's launchd service. The defaults are service/install.sh's own
|
|
633
|
+
* (`local.<distribution dir name>.web`, `~/Library/Logs/<dir name>`), so an
|
|
634
|
+
* install made with the script keeps its label. `logs` may start with `~/`;
|
|
635
|
+
* a relative path is under the distribution root. A bad value throws.
|
|
636
|
+
*/
|
|
637
|
+
export function readWebServiceConfig(
|
|
638
|
+
config: DenConfig = readDenConfig(),
|
|
639
|
+
distRoot: string = ROOT,
|
|
640
|
+
home: string = os.homedir(),
|
|
641
|
+
): ResolvedWebService {
|
|
642
|
+
const name = path.basename(distRoot);
|
|
643
|
+
const raw: unknown = config.web?.service;
|
|
644
|
+
if (
|
|
645
|
+
raw !== undefined &&
|
|
646
|
+
(raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
647
|
+
)
|
|
648
|
+
throw new Error("web.service must be a map: { label, logs }");
|
|
649
|
+
const svc = (raw ?? {}) as Record<string, unknown>;
|
|
650
|
+
for (const key of Object.keys(svc))
|
|
651
|
+
if (!WEB_SERVICE_KEYS.includes(key))
|
|
652
|
+
throw new Error(
|
|
653
|
+
`web.service: unknown key "${key}" (known: ${WEB_SERVICE_KEYS.join(", ")})`,
|
|
654
|
+
);
|
|
655
|
+
const label = svc.label ?? `local.${name}.web`;
|
|
656
|
+
if (typeof label !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(label))
|
|
657
|
+
throw new Error(
|
|
658
|
+
`web.service.label must be letters, digits, dots, dashes and underscores, got "${String(label)}"`,
|
|
659
|
+
);
|
|
660
|
+
const logs = svc.logs ?? path.join(home, "Library", "Logs", name);
|
|
661
|
+
if (typeof logs !== "string" || !logs.trim())
|
|
662
|
+
throw new Error(
|
|
663
|
+
`web.service.logs must be a directory path, got "${String(logs)}"`,
|
|
664
|
+
);
|
|
665
|
+
const expanded = logs.startsWith("~/")
|
|
666
|
+
? path.join(home, logs.slice(2))
|
|
667
|
+
: logs;
|
|
668
|
+
return {
|
|
669
|
+
label,
|
|
670
|
+
logs: path.isAbsolute(expanded) ? expanded : path.join(distRoot, expanded),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
605
674
|
// ── Release config (Release tab) ──────────────────────────────────────────────
|
|
606
675
|
|
|
607
676
|
/**
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { hasRuntime, repoKind } from "../repo-kind.js";
|
|
10
10
|
import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
|
|
11
|
+
import { manifestStackName } from "../stacks/config.js";
|
|
11
12
|
import fs from "fs";
|
|
12
13
|
import path from "path";
|
|
13
14
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
getActiveWorkspaces,
|
|
41
42
|
getFocusedWorkspaceName,
|
|
42
43
|
getWorkspaceByName,
|
|
44
|
+
parseAddress,
|
|
43
45
|
resolveServiceSource,
|
|
44
46
|
listWorkspaceServices,
|
|
45
47
|
} from "./workspaces.js";
|
|
@@ -49,7 +51,12 @@ import {
|
|
|
49
51
|
type ServiceHealthResult,
|
|
50
52
|
} from "./health.js";
|
|
51
53
|
import { EventEmitter } from "events";
|
|
52
|
-
import type {
|
|
54
|
+
import type {
|
|
55
|
+
CommandDef,
|
|
56
|
+
ResolvedService,
|
|
57
|
+
ServiceCatalog,
|
|
58
|
+
WorkspaceConfig,
|
|
59
|
+
} from "../types/index.js";
|
|
53
60
|
import type { ChildProcess } from "child_process";
|
|
54
61
|
import { getStateDir } from "../paths.js";
|
|
55
62
|
|
|
@@ -658,7 +665,95 @@ export function resolveService(
|
|
|
658
665
|
: getActiveWorkspace();
|
|
659
666
|
if (!ws) return null;
|
|
660
667
|
const catalog = readCatalog();
|
|
668
|
+
const listed = resolveListedService(address, ws, catalog);
|
|
669
|
+
if (listed || address.includes("/")) return listed;
|
|
670
|
+
// Not a repo or a listed address: a service key or a stack name, when it
|
|
671
|
+
// names exactly one service in the workspace.
|
|
672
|
+
const candidates = aliasCandidates(address, ws, catalog);
|
|
673
|
+
return candidates.length === 1
|
|
674
|
+
? resolveListedService(candidates[0], ws, catalog)
|
|
675
|
+
: null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
type NamedWorkspace = { name: string; config: WorkspaceConfig };
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The addresses `alias` could mean in `ws`: every service whose manifest key
|
|
682
|
+
* is `alias`, and the repo of every compose stack named `alias` (a stack
|
|
683
|
+
* stands for its repo; when that repo also has a service named `alias`, the
|
|
684
|
+
* service is the one match). Sorted; empty for a `repo/service` address and
|
|
685
|
+
* for a catalog repo's name.
|
|
686
|
+
*/
|
|
687
|
+
function aliasCandidates(
|
|
688
|
+
alias: string,
|
|
689
|
+
ws: NamedWorkspace,
|
|
690
|
+
catalog: ServiceCatalog,
|
|
691
|
+
): string[] {
|
|
692
|
+
// A repo name is never an alias, in the workspace or not.
|
|
693
|
+
if (alias.includes("/") || catalog[alias]) return [];
|
|
694
|
+
const services = new Set<string>();
|
|
695
|
+
const stackRepos = new Set<string>();
|
|
696
|
+
for (const s of listWorkspaceServices(ws.config, catalog, ws.name)) {
|
|
697
|
+
if (s.subsystem === alias) services.add(s.address);
|
|
698
|
+
const manifest = readManifest(s.repoPath);
|
|
699
|
+
if (manifest?.compose && manifestStackName(manifest, s.repoName) === alias)
|
|
700
|
+
stackRepos.add(s.repoName);
|
|
701
|
+
}
|
|
702
|
+
const withService = new Set(
|
|
703
|
+
[...services].map((a) => parseAddress(a, catalog).repoName),
|
|
704
|
+
);
|
|
705
|
+
return [
|
|
706
|
+
...services,
|
|
707
|
+
...[...stackRepos].filter((r) => !withService.has(r)),
|
|
708
|
+
].sort();
|
|
709
|
+
}
|
|
661
710
|
|
|
711
|
+
function namedWorkspace(workspaceName?: string): NamedWorkspace | null {
|
|
712
|
+
return workspaceName
|
|
713
|
+
? getWorkspaceByName(workspaceName)
|
|
714
|
+
: getActiveWorkspace();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* What `alias` could address in the workspace (the focused one by default)
|
|
719
|
+
* when it is not a repo or a listed address: see resolveService.
|
|
720
|
+
*/
|
|
721
|
+
export function serviceAliasCandidates(
|
|
722
|
+
alias: string,
|
|
723
|
+
workspaceName?: string,
|
|
724
|
+
): string[] {
|
|
725
|
+
const ws = namedWorkspace(workspaceName);
|
|
726
|
+
if (!ws) return [];
|
|
727
|
+
const catalog = readCatalog();
|
|
728
|
+
if (resolveListedService(alias, ws, catalog)) return [];
|
|
729
|
+
return aliasCandidates(alias, ws, catalog);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The address to use for what someone typed: an unambiguous alias becomes
|
|
734
|
+
* the service's own address; anything else is returned as is. An ambiguous
|
|
735
|
+
* alias throws, naming the candidates.
|
|
736
|
+
*/
|
|
737
|
+
export function canonicalServiceAddress(
|
|
738
|
+
address: string,
|
|
739
|
+
workspaceName?: string,
|
|
740
|
+
): string {
|
|
741
|
+
const candidates = serviceAliasCandidates(address, workspaceName);
|
|
742
|
+
if (candidates.length > 1) {
|
|
743
|
+
const ws = namedWorkspace(workspaceName)?.name ?? "default";
|
|
744
|
+
throw new Error(
|
|
745
|
+
`"${address}" is ambiguous in workspace "${ws}": ${candidates.join(", ")}; name one of them`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
return candidates[0] ?? address;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** resolveService for a repo name or a listed address (no aliases). */
|
|
752
|
+
function resolveListedService(
|
|
753
|
+
address: string,
|
|
754
|
+
ws: NamedWorkspace,
|
|
755
|
+
catalog: ServiceCatalog,
|
|
756
|
+
): ResolvedService | null {
|
|
662
757
|
let resolved = resolveServiceSource(address, ws.name, ws.config, catalog);
|
|
663
758
|
|
|
664
759
|
// Bare multi-service repo registered ONLY by subsystem (e.g. "shop/db" +
|
|
@@ -998,6 +1093,109 @@ export interface RunCommandResult {
|
|
|
998
1093
|
};
|
|
999
1094
|
}
|
|
1000
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* A service with no commands of its own is started by its repo's up
|
|
1098
|
+
* (placement's repo-level rule). For `up` that is the repo's up (`address`
|
|
1099
|
+
* set); any other command would reach the repo's other services too, so it
|
|
1100
|
+
* points at the repo instead: a refusal, except that down succeeds (nothing
|
|
1101
|
+
* of the service's own runs, and a workspace Down sends every service).
|
|
1102
|
+
* Null when the rule does not apply.
|
|
1103
|
+
*/
|
|
1104
|
+
function repoStartedFallback(
|
|
1105
|
+
resolved: ResolvedService,
|
|
1106
|
+
command: string,
|
|
1107
|
+
workspace: string | undefined,
|
|
1108
|
+
): { address?: string; note: string } | null {
|
|
1109
|
+
const repoEntry = resolveService(resolved.repoName, workspace);
|
|
1110
|
+
if (!repoEntry || repoEntry.subsystem === resolved.subsystem) return null;
|
|
1111
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1112
|
+
if (!manifest) return null;
|
|
1113
|
+
const repoCommands = effectiveLocalCommands(
|
|
1114
|
+
manifest,
|
|
1115
|
+
repoEntry.subsystem,
|
|
1116
|
+
resolved.repoName,
|
|
1117
|
+
workspace ?? "default",
|
|
1118
|
+
);
|
|
1119
|
+
if (!repoCommands["up"]) return null;
|
|
1120
|
+
if (command === "up")
|
|
1121
|
+
return {
|
|
1122
|
+
address: resolved.repoName,
|
|
1123
|
+
note: `${resolved.address} has no up of its own; running ${resolved.repoName}'s up, which starts it`,
|
|
1124
|
+
};
|
|
1125
|
+
return command === "down"
|
|
1126
|
+
? {
|
|
1127
|
+
note:
|
|
1128
|
+
`${resolved.address} has no down of its own; it is stopped by ${resolved.repoName}'s down. ` +
|
|
1129
|
+
`Run \`${product().cli} down ${resolved.repoName}\` (that stops every service it starts).`,
|
|
1130
|
+
}
|
|
1131
|
+
: {
|
|
1132
|
+
note:
|
|
1133
|
+
`${resolved.address} has no ${command} of its own; it is started by ${resolved.repoName}'s up. ` +
|
|
1134
|
+
`Run \`${product().cli} ${command} ${resolved.repoName}\` (that reaches every service it starts).`,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** What a run of `command` on `address` would do, without running it. */
|
|
1139
|
+
export type ServiceCommandPreview =
|
|
1140
|
+
| { kind: "run"; address: string }
|
|
1141
|
+
| { kind: "via-repo"; address: string; note: string }
|
|
1142
|
+
| { kind: "nothing"; address: string; note: string }
|
|
1143
|
+
| { kind: "error"; address: string; note: string };
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The dry-run twin of runServiceCommand's command lookup: the same
|
|
1147
|
+
* resolution, own-command, restart-synthesis and repo-started rules, so a
|
|
1148
|
+
* dry run says what a real run does.
|
|
1149
|
+
*/
|
|
1150
|
+
export function previewServiceCommand(
|
|
1151
|
+
address: string,
|
|
1152
|
+
command: string,
|
|
1153
|
+
workspace?: string,
|
|
1154
|
+
): ServiceCommandPreview {
|
|
1155
|
+
const resolved = resolveService(address, workspace);
|
|
1156
|
+
if (!resolved) {
|
|
1157
|
+
const candidates = serviceAliasCandidates(address, workspace);
|
|
1158
|
+
return {
|
|
1159
|
+
kind: "error",
|
|
1160
|
+
address,
|
|
1161
|
+
note:
|
|
1162
|
+
`service "${address}" not found in workspace "${workspace ?? "default"}"` +
|
|
1163
|
+
(candidates.length > 1
|
|
1164
|
+
? `; it is ambiguous: ${candidates.join(", ")}`
|
|
1165
|
+
: ""),
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1169
|
+
const own = manifest
|
|
1170
|
+
? effectiveLocalCommands(
|
|
1171
|
+
manifest,
|
|
1172
|
+
resolved.subsystem,
|
|
1173
|
+
resolved.repoName,
|
|
1174
|
+
workspace ?? "default",
|
|
1175
|
+
)
|
|
1176
|
+
: {};
|
|
1177
|
+
if (own[command] || (command === "restart" && own["down"] && own["up"]))
|
|
1178
|
+
return { kind: "run", address };
|
|
1179
|
+
const viaRepo = repoStartedFallback(resolved, command, workspace);
|
|
1180
|
+
if (viaRepo?.address)
|
|
1181
|
+
return { kind: "via-repo", address: viaRepo.address, note: viaRepo.note };
|
|
1182
|
+
if (viaRepo)
|
|
1183
|
+
return command === "down"
|
|
1184
|
+
? { kind: "nothing", address, note: viaRepo.note }
|
|
1185
|
+
: { kind: "error", address, note: viaRepo.note };
|
|
1186
|
+
if (command === "down")
|
|
1187
|
+
return {
|
|
1188
|
+
kind: "nothing",
|
|
1189
|
+
address,
|
|
1190
|
+
note: "no down command: nothing to stop",
|
|
1191
|
+
};
|
|
1192
|
+
return {
|
|
1193
|
+
kind: "error",
|
|
1194
|
+
address,
|
|
1195
|
+
note: `command "${command}" not found for service "${address}"`,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1001
1199
|
/**
|
|
1002
1200
|
* Resolve and execute a service command.
|
|
1003
1201
|
* Returns the spawned child process and whether it is a long-running (detachable) command.
|
|
@@ -1048,8 +1246,12 @@ export function runServiceCommand(
|
|
|
1048
1246
|
|
|
1049
1247
|
const resolved = resolveService(address, workspace);
|
|
1050
1248
|
if (!resolved) {
|
|
1249
|
+
const candidates = serviceAliasCandidates(address, workspace);
|
|
1051
1250
|
onLine(
|
|
1052
|
-
`Error: service "${address}" not found in catalog or workspace "${workspace}"
|
|
1251
|
+
`Error: service "${address}" not found in catalog or workspace "${workspace}"` +
|
|
1252
|
+
(candidates.length > 1
|
|
1253
|
+
? `; it is ambiguous: ${candidates.join(", ")}`
|
|
1254
|
+
: ""),
|
|
1053
1255
|
);
|
|
1054
1256
|
closeAsync(1);
|
|
1055
1257
|
return dummy();
|
|
@@ -1281,6 +1483,16 @@ export function runServiceCommand(
|
|
|
1281
1483
|
}
|
|
1282
1484
|
|
|
1283
1485
|
if (!cmdDef) {
|
|
1486
|
+
const viaRepo = repoStartedFallback(resolved, command, workspace);
|
|
1487
|
+
if (viaRepo?.address) {
|
|
1488
|
+
onLine(viaRepo.note);
|
|
1489
|
+
return runServiceCommand({ ...options, address: viaRepo.address });
|
|
1490
|
+
}
|
|
1491
|
+
if (viaRepo) {
|
|
1492
|
+
onLine(command === "down" ? viaRepo.note : `Error: ${viaRepo.note}`);
|
|
1493
|
+
closeAsync(command === "down" ? 0 : 1);
|
|
1494
|
+
return dummy();
|
|
1495
|
+
}
|
|
1284
1496
|
// Down is opctl-conventional: when no container is running AND the manifest
|
|
1285
1497
|
// declares no down command, there is simply nothing to stop — that's a
|
|
1286
1498
|
// successful down, not an error (keeps the ⋮ menu / workspace Down safe to
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
18
19
|
import { z } from "zod";
|
|
19
20
|
import type { ValidationError, ValidationResult } from "../types/index.js";
|
|
20
21
|
import {
|
|
@@ -24,11 +25,14 @@ import {
|
|
|
24
25
|
type ManifestMap,
|
|
25
26
|
} from "./merge-override.js";
|
|
26
27
|
import {
|
|
28
|
+
heldManifestPath,
|
|
27
29
|
loadManifest,
|
|
28
30
|
orphanOverrideMessage,
|
|
31
|
+
overrideSuffix,
|
|
29
32
|
parseManifestText,
|
|
30
33
|
type ManifestLayer,
|
|
31
34
|
} from "./read-manifest.js";
|
|
35
|
+
import { product } from "../product.js";
|
|
32
36
|
|
|
33
37
|
/** What layered validation needs from the schema module. */
|
|
34
38
|
export interface SchemaTools {
|
|
@@ -170,6 +174,31 @@ function readLayer(file: string): { value: unknown } | { error: string } {
|
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A warning for a held manifest the repo's own manifest has made redundant:
|
|
179
|
+
* the repo's file always wins, so the held one is never read.
|
|
180
|
+
*/
|
|
181
|
+
function redundantHeldWarning(
|
|
182
|
+
heldFile: string,
|
|
183
|
+
overrideFile: string | null,
|
|
184
|
+
): ValidationError {
|
|
185
|
+
const yml = product().manifestYml;
|
|
186
|
+
const name = path.basename(heldFile);
|
|
187
|
+
const key = name.endsWith(yml) ? name.slice(0, -yml.length) : name;
|
|
188
|
+
const asOverride = path.join(
|
|
189
|
+
path.dirname(heldFile),
|
|
190
|
+
`${key}${overrideSuffix()}`,
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
path: "",
|
|
194
|
+
file: heldFile,
|
|
195
|
+
message: `${heldFile} is not used: the repo now carries its own manifest, which always wins`,
|
|
196
|
+
suggestion: overrideFile
|
|
197
|
+
? `delete it; ${overrideFile} already layers onto the repo's manifest`
|
|
198
|
+
: `delete it, or turn it into an override (${asOverride}) that keeps only what this distribution changes`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
173
202
|
/** Validate every manifest layer of `repoPath`, then the merged result. */
|
|
174
203
|
export function validateLayers(
|
|
175
204
|
tools: SchemaTools,
|
|
@@ -189,10 +218,20 @@ export function validateLayers(
|
|
|
189
218
|
}
|
|
190
219
|
|
|
191
220
|
const base = readLayer(baseFile);
|
|
192
|
-
|
|
221
|
+
let baseResult =
|
|
193
222
|
"error" in base
|
|
194
223
|
? failure(tools.version, baseFile, base.error)
|
|
195
224
|
: withFile(tools.validate(base.value), baseFile);
|
|
225
|
+
const redundantHeld =
|
|
226
|
+
baseLayer === "repo" ? heldManifestPath(repoPath) : null;
|
|
227
|
+
if (redundantHeld)
|
|
228
|
+
baseResult = {
|
|
229
|
+
...baseResult,
|
|
230
|
+
warnings: [
|
|
231
|
+
...baseResult.warnings,
|
|
232
|
+
redundantHeldWarning(redundantHeld, overrideFile),
|
|
233
|
+
],
|
|
234
|
+
};
|
|
196
235
|
layers.push({ layer: baseLayer, file: baseFile, result: baseResult });
|
|
197
236
|
|
|
198
237
|
if (overrideFile) {
|