@arkhera30/cli 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compose/docker-compose.yml +3 -57
- package/dist/index.js +566 -64
- package/package.json +2 -2
|
@@ -16,40 +16,6 @@
|
|
|
16
16
|
|
|
17
17
|
services:
|
|
18
18
|
|
|
19
|
-
# ── QMD Daemon ─────────────────────────────────────────────────────────────
|
|
20
|
-
# Shared QMD MCP HTTP server. Keeps GGUF models warm in memory so Anvil and
|
|
21
|
-
# Vault pay the model-load cost only once.
|
|
22
|
-
#
|
|
23
|
-
# The qmd-daemon-data volume is also mounted into the Anvil and Vault
|
|
24
|
-
# containers at their respective ~/.cache/qmd paths. This lets both services
|
|
25
|
-
# run `qmd collection add` / `qmd update` subprocess calls that write to the
|
|
26
|
-
# same SQLite database the daemon reads from — keeping the index current.
|
|
27
|
-
#
|
|
28
|
-
# start_period covers first-boot GGUF model download (~1-2 GB) + initial embed.
|
|
29
|
-
qmd-daemon:
|
|
30
|
-
image: ghcr.io/arjunkhera/horus/qmd-daemon:latest
|
|
31
|
-
environment:
|
|
32
|
-
- QMD_DAEMON_PORT=8181
|
|
33
|
-
- HORUS_RUNTIME=${HORUS_RUNTIME:-docker}
|
|
34
|
-
volumes:
|
|
35
|
-
- qmd-daemon-data:/home/qmd/.cache/qmd
|
|
36
|
-
networks:
|
|
37
|
-
- horus-net
|
|
38
|
-
restart: unless-stopped
|
|
39
|
-
stop_grace_period: 15s
|
|
40
|
-
deploy:
|
|
41
|
-
resources:
|
|
42
|
-
limits:
|
|
43
|
-
memory: 4g
|
|
44
|
-
reservations:
|
|
45
|
-
memory: 512m
|
|
46
|
-
healthcheck:
|
|
47
|
-
test: ["CMD", "curl", "-f", "http://localhost:8181/health"]
|
|
48
|
-
interval: 30s
|
|
49
|
-
timeout: 10s
|
|
50
|
-
start_period: 600s
|
|
51
|
-
retries: 3
|
|
52
|
-
|
|
53
19
|
# ── Anvil ──────────────────────────────────────────────────────────────────
|
|
54
20
|
# Notes system and MCP server. Indexes markdown files from the Notes repo.
|
|
55
21
|
anvil:
|
|
@@ -59,8 +25,6 @@ services:
|
|
|
59
25
|
volumes:
|
|
60
26
|
# Notes repo — read/write so Anvil can git-sync or clone on first boot
|
|
61
27
|
- ${HORUS_DATA_PATH}/notes:/data/notes:rw
|
|
62
|
-
# Shared QMD database + model cache (same volume as qmd-daemon).
|
|
63
|
-
- qmd-daemon-data:/home/anvil/.cache/qmd
|
|
64
28
|
environment:
|
|
65
29
|
- HORUS_RUNTIME=${HORUS_RUNTIME:-docker}
|
|
66
30
|
- ANVIL_TRANSPORT=http
|
|
@@ -68,15 +32,9 @@ services:
|
|
|
68
32
|
- ANVIL_HOST=0.0.0.0
|
|
69
33
|
- ANVIL_NOTES_PATH=/data/notes
|
|
70
34
|
- ANVIL_REPO_URL=${ANVIL_REPO_URL:-}
|
|
71
|
-
- ANVIL_QMD_COLLECTION=${ANVIL_QMD_COLLECTION:-anvil}
|
|
72
35
|
- ANVIL_SYNC_INTERVAL=${ANVIL_SYNC_INTERVAL:-300}
|
|
73
36
|
- ANVIL_DEBOUNCE_SECONDS=${ANVIL_DEBOUNCE_SECONDS:-5}
|
|
74
37
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
|
75
|
-
# Route search calls to the shared daemon; fall back to subprocess if unset.
|
|
76
|
-
- QMD_DAEMON_URL=http://qmd-daemon:8181
|
|
77
|
-
depends_on:
|
|
78
|
-
qmd-daemon:
|
|
79
|
-
condition: service_healthy
|
|
80
38
|
networks:
|
|
81
39
|
- horus-net
|
|
82
40
|
restart: unless-stopped
|
|
@@ -91,11 +49,11 @@ services:
|
|
|
91
49
|
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
|
92
50
|
interval: 30s
|
|
93
51
|
timeout: 5s
|
|
94
|
-
start_period:
|
|
52
|
+
start_period: 60s
|
|
95
53
|
retries: 3
|
|
96
54
|
|
|
97
55
|
# ── Vault ──────────────────────────────────────────────────────────────────
|
|
98
|
-
# Knowledge service.
|
|
56
|
+
# Knowledge service. Search over the knowledge-base repo.
|
|
99
57
|
vault:
|
|
100
58
|
image: ghcr.io/arjunkhera/horus/vault:latest
|
|
101
59
|
ports:
|
|
@@ -105,25 +63,17 @@ services:
|
|
|
105
63
|
- ${HORUS_DATA_PATH}/knowledge-base:/data/knowledge-repo:rw
|
|
106
64
|
# Write-path workspace: staging area for draft pages before PR
|
|
107
65
|
- vault-workspace:/data/workspace
|
|
108
|
-
# Shared QMD database + model cache (same volume as qmd-daemon).
|
|
109
|
-
- qmd-daemon-data:/home/appuser/.cache/qmd
|
|
110
66
|
environment:
|
|
111
67
|
- HORUS_RUNTIME=${HORUS_RUNTIME:-docker}
|
|
112
68
|
- KNOWLEDGE_REPO_PATH=/data/knowledge-repo
|
|
113
69
|
- WORKSPACE_PATH=/data/workspace
|
|
114
70
|
- VAULT_KNOWLEDGE_REPO_URL=${VAULT_KNOWLEDGE_REPO_URL:-}
|
|
115
|
-
- QMD_INDEX_NAME=${QMD_INDEX_NAME:-knowledge}
|
|
116
71
|
- SYNC_INTERVAL=${VAULT_SYNC_INTERVAL:-300}
|
|
117
72
|
- VAULT_SYNC_INTERVAL=${VAULT_SYNC_INTERVAL:-300}
|
|
118
73
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
|
119
74
|
- HOST=0.0.0.0
|
|
120
75
|
- PORT=8000
|
|
121
76
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
|
122
|
-
# Route search calls to the shared daemon; fall back to subprocess if unset.
|
|
123
|
-
- QMD_DAEMON_URL=http://qmd-daemon:8181
|
|
124
|
-
depends_on:
|
|
125
|
-
qmd-daemon:
|
|
126
|
-
condition: service_healthy
|
|
127
77
|
networks:
|
|
128
78
|
- horus-net
|
|
129
79
|
restart: unless-stopped
|
|
@@ -138,7 +88,7 @@ services:
|
|
|
138
88
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
139
89
|
interval: 30s
|
|
140
90
|
timeout: 10s
|
|
141
|
-
start_period:
|
|
91
|
+
start_period: 60s
|
|
142
92
|
retries: 3
|
|
143
93
|
|
|
144
94
|
# ── Vault MCP ──────────────────────────────────────────────────────────────
|
|
@@ -236,9 +186,5 @@ networks:
|
|
|
236
186
|
|
|
237
187
|
# ── Volumes ───────────────────────────────────────────────────────────────────
|
|
238
188
|
volumes:
|
|
239
|
-
# Shared QMD daemon database + GGUF model cache.
|
|
240
|
-
# Mounted into qmd-daemon, Anvil, and Vault so all three share one SQLite index.
|
|
241
|
-
# Persists model downloads (~1-2 GB) and index across container rebuilds.
|
|
242
|
-
qmd-daemon-data:
|
|
243
189
|
# Vault write-path staging workspace
|
|
244
190
|
vault-workspace:
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
5
|
-
import
|
|
4
|
+
import { Command as Command11 } from "commander";
|
|
5
|
+
import chalk11 from "chalk";
|
|
6
6
|
|
|
7
7
|
// src/commands/setup.ts
|
|
8
8
|
import { Command as Command2 } from "commander";
|
|
@@ -52,17 +52,19 @@ var DEFAULT_PORTS = {
|
|
|
52
52
|
// internal routing layer
|
|
53
53
|
ui: 8400,
|
|
54
54
|
// horus-ui — user-facing web interface
|
|
55
|
-
forge: 8200
|
|
55
|
+
forge: 8200,
|
|
56
|
+
typesense: 8108
|
|
57
|
+
// Typesense search engine
|
|
56
58
|
};
|
|
57
59
|
var DEFAULT_DATA_DIR = join(homedir(), "Horus", "data");
|
|
58
60
|
var SERVICES = [
|
|
59
|
-
"qmd-daemon",
|
|
60
61
|
"anvil",
|
|
61
62
|
"vault-router",
|
|
62
63
|
// replaces 'vault'
|
|
63
64
|
"vault-mcp",
|
|
64
65
|
"forge",
|
|
65
|
-
"horus-ui"
|
|
66
|
+
"horus-ui",
|
|
67
|
+
"typesense"
|
|
66
68
|
];
|
|
67
69
|
var CONFIG_VERSION = "1.0";
|
|
68
70
|
|
|
@@ -77,10 +79,14 @@ function defaultConfig() {
|
|
|
77
79
|
anvil_notes: "",
|
|
78
80
|
forge_registry: ""
|
|
79
81
|
},
|
|
82
|
+
search: {
|
|
83
|
+
api_key: "horus-local-key"
|
|
84
|
+
},
|
|
80
85
|
vaults: {},
|
|
81
86
|
github_hosts: {},
|
|
82
87
|
host_repos_path: "",
|
|
83
|
-
host_repos_extra_scan_dirs: []
|
|
88
|
+
host_repos_extra_scan_dirs: [],
|
|
89
|
+
enable_ui: true
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
function ensureHorusDir() {
|
|
@@ -127,16 +133,21 @@ function buildConfigFromParsed(parsed) {
|
|
|
127
133
|
vault_rest: parsedPorts?.vault_rest ?? defaults.ports.vault_rest,
|
|
128
134
|
vault_mcp: parsedPorts?.vault_mcp ?? defaults.ports.vault_mcp,
|
|
129
135
|
vault_router: parsedPorts?.vault_router ?? defaults.ports.vault_router,
|
|
130
|
-
forge: parsedPorts?.forge ?? defaults.ports.forge
|
|
136
|
+
forge: parsedPorts?.forge ?? defaults.ports.forge,
|
|
137
|
+
typesense: parsedPorts?.typesense ?? defaults.ports.typesense
|
|
131
138
|
},
|
|
132
139
|
repos: {
|
|
133
140
|
anvil_notes: repos?.anvil_notes ?? defaults.repos.anvil_notes,
|
|
134
141
|
forge_registry: repos?.forge_registry ?? defaults.repos.forge_registry
|
|
135
142
|
},
|
|
143
|
+
search: {
|
|
144
|
+
api_key: parsed.search?.api_key ?? defaults.search.api_key
|
|
145
|
+
},
|
|
136
146
|
vaults: parsed.vaults ?? defaults.vaults,
|
|
137
147
|
github_hosts: parsed.github_hosts ?? defaults.github_hosts,
|
|
138
148
|
host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
|
|
139
|
-
host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs
|
|
149
|
+
host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs,
|
|
150
|
+
enable_ui: parsed.enable_ui ?? defaults.enable_ui
|
|
140
151
|
};
|
|
141
152
|
}
|
|
142
153
|
function saveConfig(config) {
|
|
@@ -223,6 +234,10 @@ function generateEnv(config) {
|
|
|
223
234
|
`VAULT_MCP_PORT=${config.ports.vault_mcp}`,
|
|
224
235
|
`VAULT_ROUTER_PORT=${config.ports.vault_router}`,
|
|
225
236
|
`FORGE_PORT=${config.ports.forge}`,
|
|
237
|
+
`TYPESENSE_PORT=${config.ports.typesense}`,
|
|
238
|
+
"",
|
|
239
|
+
"# Search",
|
|
240
|
+
`TYPESENSE_API_KEY=${config.search.api_key}`,
|
|
226
241
|
"",
|
|
227
242
|
"# Repository URLs (must be HTTPS \u2014 container services do not have SSH keys)",
|
|
228
243
|
`ANVIL_REPO_URL=${config.repos.anvil_notes}`,
|
|
@@ -246,8 +261,11 @@ var CONFIG_KEYS = [
|
|
|
246
261
|
"port.vault-mcp",
|
|
247
262
|
"port.vault-router",
|
|
248
263
|
"port.forge",
|
|
264
|
+
"port.typesense",
|
|
249
265
|
"repo.anvil-notes",
|
|
250
|
-
"repo.forge-registry"
|
|
266
|
+
"repo.forge-registry",
|
|
267
|
+
"search.api-key",
|
|
268
|
+
"enable-ui"
|
|
251
269
|
];
|
|
252
270
|
function getConfigValue(config, key) {
|
|
253
271
|
switch (key) {
|
|
@@ -269,10 +287,16 @@ function getConfigValue(config, key) {
|
|
|
269
287
|
return String(config.ports.vault_router);
|
|
270
288
|
case "port.forge":
|
|
271
289
|
return String(config.ports.forge);
|
|
290
|
+
case "port.typesense":
|
|
291
|
+
return String(config.ports.typesense);
|
|
272
292
|
case "repo.anvil-notes":
|
|
273
293
|
return config.repos.anvil_notes;
|
|
274
294
|
case "repo.forge-registry":
|
|
275
295
|
return config.repos.forge_registry;
|
|
296
|
+
case "search.api-key":
|
|
297
|
+
return config.search.api_key;
|
|
298
|
+
case "enable-ui":
|
|
299
|
+
return String(config.enable_ui);
|
|
276
300
|
}
|
|
277
301
|
}
|
|
278
302
|
function setConfigValue(config, key, value) {
|
|
@@ -308,12 +332,24 @@ function setConfigValue(config, key, value) {
|
|
|
308
332
|
case "port.forge":
|
|
309
333
|
updated.ports = { ...updated.ports, forge: parseInt(value, 10) };
|
|
310
334
|
break;
|
|
335
|
+
case "port.typesense":
|
|
336
|
+
updated.ports = { ...updated.ports, typesense: parseInt(value, 10) };
|
|
337
|
+
break;
|
|
311
338
|
case "repo.anvil-notes":
|
|
312
339
|
updated.repos = { ...updated.repos, anvil_notes: value };
|
|
313
340
|
break;
|
|
314
341
|
case "repo.forge-registry":
|
|
315
342
|
updated.repos = { ...updated.repos, forge_registry: value };
|
|
316
343
|
break;
|
|
344
|
+
case "search.api-key":
|
|
345
|
+
updated.search = { ...updated.search, api_key: value };
|
|
346
|
+
break;
|
|
347
|
+
case "enable-ui":
|
|
348
|
+
if (value !== "true" && value !== "false") {
|
|
349
|
+
throw new Error(`Invalid value for enable-ui: ${value}. Must be "true" or "false".`);
|
|
350
|
+
}
|
|
351
|
+
updated.enable_ui = value === "true";
|
|
352
|
+
break;
|
|
317
353
|
}
|
|
318
354
|
return updated;
|
|
319
355
|
}
|
|
@@ -534,7 +570,7 @@ Run '${runtime.name} compose logs <service>' from ~/Horus/ to investigate.`
|
|
|
534
570
|
Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
|
|
535
571
|
);
|
|
536
572
|
}
|
|
537
|
-
await new Promise((
|
|
573
|
+
await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
|
|
538
574
|
}
|
|
539
575
|
}
|
|
540
576
|
|
|
@@ -546,32 +582,6 @@ function applyPodmanUserOverride(compose) {
|
|
|
546
582
|
'$1\n user: "0:0"'
|
|
547
583
|
);
|
|
548
584
|
}
|
|
549
|
-
var QMD_DAEMON_SERVICE = ` # \u2500\u2500 QMD Daemon \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
550
|
-
# Shared QMD MCP HTTP server. Keeps GGUF models warm in memory so Anvil and
|
|
551
|
-
# Vault pay the model-load cost only once.
|
|
552
|
-
qmd-daemon:
|
|
553
|
-
image: ghcr.io/arjunkhera/horus/qmd-daemon:latest
|
|
554
|
-
environment:
|
|
555
|
-
- QMD_DAEMON_PORT=8181
|
|
556
|
-
- HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
|
|
557
|
-
volumes:
|
|
558
|
-
- qmd-daemon-data:/home/qmd/.cache/qmd
|
|
559
|
-
networks:
|
|
560
|
-
- horus-net
|
|
561
|
-
restart: unless-stopped
|
|
562
|
-
stop_grace_period: 15s
|
|
563
|
-
deploy:
|
|
564
|
-
resources:
|
|
565
|
-
limits:
|
|
566
|
-
memory: 4g
|
|
567
|
-
reservations:
|
|
568
|
-
memory: 512m
|
|
569
|
-
healthcheck:
|
|
570
|
-
test: ["CMD", "curl", "-f", "http://localhost:8181/health"]
|
|
571
|
-
interval: 30s
|
|
572
|
-
timeout: 10s
|
|
573
|
-
start_period: 600s
|
|
574
|
-
retries: 3`;
|
|
575
585
|
var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
576
586
|
# Notes system and MCP server. Indexes markdown files from the Notes repo.
|
|
577
587
|
anvil:
|
|
@@ -580,7 +590,6 @@ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500
|
|
|
580
590
|
- "\${ANVIL_PORT:-8100}:8100"
|
|
581
591
|
volumes:
|
|
582
592
|
- \${HORUS_DATA_PATH}/notes:/data/notes:rw
|
|
583
|
-
- qmd-daemon-data:/home/anvil/.cache/qmd
|
|
584
593
|
environment:
|
|
585
594
|
- HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
|
|
586
595
|
- ANVIL_TRANSPORT=http
|
|
@@ -588,14 +597,9 @@ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500
|
|
|
588
597
|
- ANVIL_HOST=0.0.0.0
|
|
589
598
|
- ANVIL_NOTES_PATH=/data/notes
|
|
590
599
|
- ANVIL_REPO_URL=\${ANVIL_REPO_URL:-}
|
|
591
|
-
- ANVIL_QMD_COLLECTION=\${ANVIL_QMD_COLLECTION:-anvil}
|
|
592
600
|
- ANVIL_SYNC_INTERVAL=\${ANVIL_SYNC_INTERVAL:-300}
|
|
593
601
|
- ANVIL_DEBOUNCE_SECONDS=\${ANVIL_DEBOUNCE_SECONDS:-5}
|
|
594
602
|
- GITHUB_TOKEN=\${GITHUB_TOKEN:-}
|
|
595
|
-
- QMD_DAEMON_URL=http://qmd-daemon:8181
|
|
596
|
-
depends_on:
|
|
597
|
-
qmd-daemon:
|
|
598
|
-
condition: service_healthy
|
|
599
603
|
networks:
|
|
600
604
|
- horus-net
|
|
601
605
|
restart: unless-stopped
|
|
@@ -610,7 +614,7 @@ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500
|
|
|
610
614
|
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
|
611
615
|
interval: 30s
|
|
612
616
|
timeout: 5s
|
|
613
|
-
start_period:
|
|
617
|
+
start_period: 60s
|
|
614
618
|
retries: 3`;
|
|
615
619
|
var FORGE_SERVICE = ` # \u2500\u2500 Forge \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
616
620
|
# Workspace manager and package registry MCP server.
|
|
@@ -660,6 +664,27 @@ var FORGE_SERVICE = ` # \u2500\u2500 Forge \u2500\u2500\u2500\u2500\u2500\u2500
|
|
|
660
664
|
timeout: 5s
|
|
661
665
|
start_period: 60s
|
|
662
666
|
retries: 3`;
|
|
667
|
+
var TYPESENSE_SERVICE = ` # \u2500\u2500 Typesense \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
668
|
+
# Full-text and vector search engine for unified Horus Search.
|
|
669
|
+
typesense:
|
|
670
|
+
image: typesense/typesense:27.1
|
|
671
|
+
ports:
|
|
672
|
+
- "\${TYPESENSE_PORT:-8108}:8108"
|
|
673
|
+
volumes:
|
|
674
|
+
- \${HORUS_DATA_PATH}/typesense-data:/data
|
|
675
|
+
command: >
|
|
676
|
+
--data-dir=/data
|
|
677
|
+
--api-key=\${TYPESENSE_API_KEY:-horus-local-key}
|
|
678
|
+
--enable-cors
|
|
679
|
+
networks:
|
|
680
|
+
- horus-net
|
|
681
|
+
healthcheck:
|
|
682
|
+
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8108'"]
|
|
683
|
+
interval: 10s
|
|
684
|
+
timeout: 5s
|
|
685
|
+
retries: 3
|
|
686
|
+
start_period: 5s
|
|
687
|
+
restart: unless-stopped`;
|
|
663
688
|
var HORUS_UI_SERVICE = ` # \u2500\u2500 Horus UI \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
664
689
|
# Web interface \u2014 React SPA served by Express proxy on port 8400.
|
|
665
690
|
# Proxies /api/anvil, /api/vault, /api/forge to the respective services.
|
|
@@ -695,7 +720,7 @@ var HORUS_UI_SERVICE = ` # \u2500\u2500 Horus UI \u2500\u2500\u2500\u2500\u2500
|
|
|
695
720
|
reservations:
|
|
696
721
|
memory: 64m
|
|
697
722
|
healthcheck:
|
|
698
|
-
test: ["CMD", "
|
|
723
|
+
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8400/api/health"]
|
|
699
724
|
interval: 30s
|
|
700
725
|
timeout: 5s
|
|
701
726
|
start_period: 30s
|
|
@@ -716,13 +741,11 @@ function generateComposeFile(config, runtime) {
|
|
|
716
741
|
volumes:
|
|
717
742
|
- \${HORUS_DATA_PATH}/vaults/${name}:/data/knowledge-repo:rw
|
|
718
743
|
- vault-${name}-workspace:/data/workspace
|
|
719
|
-
- qmd-daemon-data:/home/appuser/.cache/qmd
|
|
720
744
|
environment:
|
|
721
745
|
- HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
|
|
722
746
|
- KNOWLEDGE_REPO_PATH=/data/knowledge-repo
|
|
723
747
|
- WORKSPACE_PATH=/data/workspace
|
|
724
748
|
- VAULT_KNOWLEDGE_REPO_URL=${vault.repo}
|
|
725
|
-
- QMD_INDEX_NAME=vault-${name}
|
|
726
749
|
- SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
|
|
727
750
|
- VAULT_SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
|
|
728
751
|
- LOG_LEVEL=\${LOG_LEVEL:-info}
|
|
@@ -730,10 +753,6 @@ function generateComposeFile(config, runtime) {
|
|
|
730
753
|
- PORT=8000
|
|
731
754
|
- GITHUB_TOKEN=${token}
|
|
732
755
|
- GITHUB_API_HOST=${apiHost}
|
|
733
|
-
- QMD_DAEMON_URL=http://qmd-daemon:8181
|
|
734
|
-
depends_on:
|
|
735
|
-
qmd-daemon:
|
|
736
|
-
condition: service_healthy
|
|
737
756
|
networks:
|
|
738
757
|
- horus-net
|
|
739
758
|
restart: unless-stopped
|
|
@@ -747,7 +766,7 @@ function generateComposeFile(config, runtime) {
|
|
|
747
766
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
748
767
|
interval: 30s
|
|
749
768
|
timeout: 10s
|
|
750
|
-
start_period:
|
|
769
|
+
start_period: 60s
|
|
751
770
|
retries: 3`;
|
|
752
771
|
});
|
|
753
772
|
const defaultVaultEntry = vaultEntries.find(([, v]) => v.default);
|
|
@@ -821,8 +840,6 @@ ${vaultRouterDependsOn}
|
|
|
821
840
|
"",
|
|
822
841
|
"services:",
|
|
823
842
|
"",
|
|
824
|
-
QMD_DAEMON_SERVICE,
|
|
825
|
-
"",
|
|
826
843
|
ANVIL_SERVICE,
|
|
827
844
|
"",
|
|
828
845
|
...vaultServices.map((s) => s + "\n"),
|
|
@@ -832,8 +849,9 @@ ${vaultRouterDependsOn}
|
|
|
832
849
|
"",
|
|
833
850
|
FORGE_SERVICE,
|
|
834
851
|
"",
|
|
835
|
-
|
|
852
|
+
TYPESENSE_SERVICE,
|
|
836
853
|
"",
|
|
854
|
+
...config.enable_ui !== false ? [HORUS_UI_SERVICE, ""] : [],
|
|
837
855
|
"# \u2500\u2500 Networks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
838
856
|
"networks:",
|
|
839
857
|
" horus-net:",
|
|
@@ -841,7 +859,6 @@ ${vaultRouterDependsOn}
|
|
|
841
859
|
"",
|
|
842
860
|
"# \u2500\u2500 Volumes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
843
861
|
"volumes:",
|
|
844
|
-
" qmd-daemon-data:",
|
|
845
862
|
vaultVolumeEntries
|
|
846
863
|
];
|
|
847
864
|
let content = sections.join("\n");
|
|
@@ -1327,7 +1344,8 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
|
|
|
1327
1344
|
vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
|
|
1328
1345
|
vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
|
|
1329
1346
|
vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
|
|
1330
|
-
forge: forge ?? DEFAULT_PORTS.forge
|
|
1347
|
+
forge: forge ?? DEFAULT_PORTS.forge,
|
|
1348
|
+
typesense: DEFAULT_PORTS.typesense
|
|
1331
1349
|
};
|
|
1332
1350
|
}
|
|
1333
1351
|
console.log("");
|
|
@@ -1632,12 +1650,16 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
|
|
|
1632
1650
|
process.exit(1);
|
|
1633
1651
|
}
|
|
1634
1652
|
if (opts.pull) {
|
|
1635
|
-
|
|
1653
|
+
console.log("");
|
|
1654
|
+
console.log(chalk3.bold("Pulling latest images..."));
|
|
1636
1655
|
try {
|
|
1637
|
-
await composeStreaming(runtime,
|
|
1638
|
-
|
|
1656
|
+
await composeStreaming(runtime, ["pull"]);
|
|
1657
|
+
console.log("");
|
|
1658
|
+
console.log(chalk3.green("\u2713 Pull complete"));
|
|
1639
1659
|
} catch {
|
|
1640
|
-
|
|
1660
|
+
console.log("");
|
|
1661
|
+
console.log(chalk3.yellow("\u26A0 Warning: failed to pull one or more images \u2014 using cached versions."));
|
|
1662
|
+
console.log(chalk3.dim(" Run `docker compose pull` to see which services failed."));
|
|
1641
1663
|
}
|
|
1642
1664
|
}
|
|
1643
1665
|
console.log("");
|
|
@@ -2316,7 +2338,7 @@ function checkDiskSpace(dataDir) {
|
|
|
2316
2338
|
return {
|
|
2317
2339
|
status: "warn",
|
|
2318
2340
|
label: "Disk space",
|
|
2319
|
-
message: `Disk space low: only ${freeGBStr}GB available (5GB recommended
|
|
2341
|
+
message: `Disk space low: only ${freeGBStr}GB available (5GB recommended)`,
|
|
2320
2342
|
hint: "Free up disk space before running Horus"
|
|
2321
2343
|
};
|
|
2322
2344
|
} catch {
|
|
@@ -2624,8 +2646,487 @@ backupCommand.command("restore <file>").description("Restore Horus data from a b
|
|
|
2624
2646
|
await restoreBackup(file, opts.yes);
|
|
2625
2647
|
});
|
|
2626
2648
|
|
|
2649
|
+
// src/commands/test-env.ts
|
|
2650
|
+
import { Command as Command10 } from "commander";
|
|
2651
|
+
import chalk10 from "chalk";
|
|
2652
|
+
import ora8 from "ora";
|
|
2653
|
+
import { join as join8 } from "path";
|
|
2654
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2655
|
+
import { dirname as dirname2 } from "path";
|
|
2656
|
+
|
|
2657
|
+
// src/lib/test-env.ts
|
|
2658
|
+
import {
|
|
2659
|
+
existsSync as existsSync9,
|
|
2660
|
+
mkdirSync as mkdirSync6,
|
|
2661
|
+
readFileSync as readFileSync6,
|
|
2662
|
+
writeFileSync as writeFileSync6,
|
|
2663
|
+
rmSync,
|
|
2664
|
+
readdirSync as readdirSync3,
|
|
2665
|
+
cpSync
|
|
2666
|
+
} from "fs";
|
|
2667
|
+
import { join as join7 } from "path";
|
|
2668
|
+
import { parse as parseYaml3 } from "yaml";
|
|
2669
|
+
import { execa as execa3 } from "execa";
|
|
2670
|
+
function getTestEnvRoot(dataDir) {
|
|
2671
|
+
return join7(dataDir, "test-env");
|
|
2672
|
+
}
|
|
2673
|
+
function getLockPath(dataDir, slot) {
|
|
2674
|
+
return join7(getTestEnvRoot(dataDir), `slot-${slot}.lock`);
|
|
2675
|
+
}
|
|
2676
|
+
function getSlotDataPath(dataDir, slot) {
|
|
2677
|
+
return join7(getTestEnvRoot(dataDir), `slot-${slot}`);
|
|
2678
|
+
}
|
|
2679
|
+
function getTestEnvConfigPath(dataDir) {
|
|
2680
|
+
return join7(dataDir, "config", "test-env.yaml");
|
|
2681
|
+
}
|
|
2682
|
+
var DEFAULT_CONFIG = {
|
|
2683
|
+
max_slots: 1,
|
|
2684
|
+
timeout_minutes: 10,
|
|
2685
|
+
base_port: 9100
|
|
2686
|
+
};
|
|
2687
|
+
var PORT_OFFSETS = {
|
|
2688
|
+
anvil: 0,
|
|
2689
|
+
typesense: 8,
|
|
2690
|
+
vault_svc: 1,
|
|
2691
|
+
vault_router: 50,
|
|
2692
|
+
vault_mcp: 100,
|
|
2693
|
+
forge: 150,
|
|
2694
|
+
ui: 160
|
|
2695
|
+
};
|
|
2696
|
+
function loadTestEnvConfig(dataDir) {
|
|
2697
|
+
const configPath = getTestEnvConfigPath(dataDir);
|
|
2698
|
+
if (!existsSync9(configPath)) {
|
|
2699
|
+
return { ...DEFAULT_CONFIG };
|
|
2700
|
+
}
|
|
2701
|
+
try {
|
|
2702
|
+
const raw = readFileSync6(configPath, "utf-8");
|
|
2703
|
+
const parsed = parseYaml3(raw);
|
|
2704
|
+
const cfg = parsed?.test_env ?? {};
|
|
2705
|
+
return {
|
|
2706
|
+
max_slots: cfg.max_slots ?? DEFAULT_CONFIG.max_slots,
|
|
2707
|
+
timeout_minutes: cfg.timeout_minutes ?? DEFAULT_CONFIG.timeout_minutes,
|
|
2708
|
+
base_port: cfg.base_port ?? DEFAULT_CONFIG.base_port
|
|
2709
|
+
};
|
|
2710
|
+
} catch {
|
|
2711
|
+
return { ...DEFAULT_CONFIG };
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
function calcPorts(slot, basePort) {
|
|
2715
|
+
const base = basePort + slot * 300;
|
|
2716
|
+
return {
|
|
2717
|
+
anvil: base + PORT_OFFSETS.anvil,
|
|
2718
|
+
typesense: base + PORT_OFFSETS.typesense,
|
|
2719
|
+
vault_svc: base + PORT_OFFSETS.vault_svc,
|
|
2720
|
+
vault_router: base + PORT_OFFSETS.vault_router,
|
|
2721
|
+
vault_mcp: base + PORT_OFFSETS.vault_mcp,
|
|
2722
|
+
forge: base + PORT_OFFSETS.forge,
|
|
2723
|
+
ui: base + PORT_OFFSETS.ui
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
function readLock(dataDir, slot) {
|
|
2727
|
+
const lockPath = getLockPath(dataDir, slot);
|
|
2728
|
+
if (!existsSync9(lockPath)) return null;
|
|
2729
|
+
try {
|
|
2730
|
+
return JSON.parse(readFileSync6(lockPath, "utf-8"));
|
|
2731
|
+
} catch {
|
|
2732
|
+
return null;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
function writeLock(dataDir, lock) {
|
|
2736
|
+
mkdirSync6(getTestEnvRoot(dataDir), { recursive: true });
|
|
2737
|
+
writeFileSync6(getLockPath(dataDir, lock.slot), JSON.stringify(lock, null, 2), "utf-8");
|
|
2738
|
+
}
|
|
2739
|
+
function removeLock(dataDir, slot) {
|
|
2740
|
+
const lockPath = getLockPath(dataDir, slot);
|
|
2741
|
+
if (existsSync9(lockPath)) {
|
|
2742
|
+
rmSync(lockPath);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
function isLockExpired(lock, timeoutMinutes) {
|
|
2746
|
+
const acquired = new Date(lock.acquiredAt).getTime();
|
|
2747
|
+
return Date.now() - acquired > timeoutMinutes * 60 * 1e3;
|
|
2748
|
+
}
|
|
2749
|
+
function getAllSlotStatuses(dataDir, cfg) {
|
|
2750
|
+
return Array.from({ length: cfg.max_slots }, (_, slot) => {
|
|
2751
|
+
const lock = readLock(dataDir, slot);
|
|
2752
|
+
if (!lock) {
|
|
2753
|
+
return { slot, state: "free" };
|
|
2754
|
+
}
|
|
2755
|
+
const expired = isLockExpired(lock, cfg.timeout_minutes);
|
|
2756
|
+
const elapsed = (Date.now() - new Date(lock.acquiredAt).getTime()) / 6e4;
|
|
2757
|
+
return {
|
|
2758
|
+
slot,
|
|
2759
|
+
state: expired ? "expired" : "acquired",
|
|
2760
|
+
lock,
|
|
2761
|
+
ports: lock.ports,
|
|
2762
|
+
dataPath: lock.dataPath,
|
|
2763
|
+
acquiredAt: lock.acquiredAt,
|
|
2764
|
+
elapsedMinutes: Math.round(elapsed)
|
|
2765
|
+
};
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
function findFreeSlot(dataDir, cfg) {
|
|
2769
|
+
for (let slot = 0; slot < cfg.max_slots; slot++) {
|
|
2770
|
+
const lock = readLock(dataDir, slot);
|
|
2771
|
+
if (!lock) return slot;
|
|
2772
|
+
if (isLockExpired(lock, cfg.timeout_minutes)) {
|
|
2773
|
+
removeLock(dataDir, slot);
|
|
2774
|
+
return slot;
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
return null;
|
|
2778
|
+
}
|
|
2779
|
+
function createSlotDirs(slotDataPath) {
|
|
2780
|
+
const dirs = [
|
|
2781
|
+
"notes",
|
|
2782
|
+
join7("vaults", "personal"),
|
|
2783
|
+
"registry",
|
|
2784
|
+
"workspaces",
|
|
2785
|
+
"sessions",
|
|
2786
|
+
"typesense-data"
|
|
2787
|
+
];
|
|
2788
|
+
for (const dir of dirs) {
|
|
2789
|
+
mkdirSync6(join7(slotDataPath, dir), { recursive: true });
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
function removeSlotDirs(slotDataPath) {
|
|
2793
|
+
if (existsSync9(slotDataPath)) {
|
|
2794
|
+
rmSync(slotDataPath, { recursive: true, force: true });
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
function buildComposeEnv(runtime, ports, slotDataPath) {
|
|
2798
|
+
return {
|
|
2799
|
+
...process.env,
|
|
2800
|
+
HORUS_RUNTIME: runtime.name,
|
|
2801
|
+
TEST_DATA_PATH: slotDataPath,
|
|
2802
|
+
TEST_PORT_ANVIL: String(ports.anvil),
|
|
2803
|
+
TEST_PORT_TYPESENSE: String(ports.typesense),
|
|
2804
|
+
TEST_PORT_VAULT_SVC: String(ports.vault_svc),
|
|
2805
|
+
TEST_PORT_VAULT_ROUTER: String(ports.vault_router),
|
|
2806
|
+
TEST_PORT_VAULT_MCP: String(ports.vault_mcp),
|
|
2807
|
+
TEST_PORT_FORGE: String(ports.forge),
|
|
2808
|
+
TEST_PORT_UI: String(ports.ui)
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
async function composeUp(runtime, projectName2, ports, slotDataPath) {
|
|
2812
|
+
const env = buildComposeEnv(runtime, ports, slotDataPath);
|
|
2813
|
+
const result = await execa3(
|
|
2814
|
+
runtime.name,
|
|
2815
|
+
[
|
|
2816
|
+
"compose",
|
|
2817
|
+
"-p",
|
|
2818
|
+
projectName2,
|
|
2819
|
+
"-f",
|
|
2820
|
+
join7(HORUS_DIR, "docker-compose.yml"),
|
|
2821
|
+
"-f",
|
|
2822
|
+
join7(HORUS_DIR, "docker-compose.test.yml"),
|
|
2823
|
+
"up",
|
|
2824
|
+
"-d"
|
|
2825
|
+
],
|
|
2826
|
+
{ cwd: HORUS_DIR, env, reject: false }
|
|
2827
|
+
);
|
|
2828
|
+
if (result.exitCode !== 0) {
|
|
2829
|
+
throw new Error(
|
|
2830
|
+
`Failed to start shadow stack (project ${projectName2}):
|
|
2831
|
+
${result.stderr}`
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
async function composeDown(runtime, projectName2, ports, slotDataPath) {
|
|
2836
|
+
const env = buildComposeEnv(runtime, ports, slotDataPath);
|
|
2837
|
+
await execa3(
|
|
2838
|
+
runtime.name,
|
|
2839
|
+
["compose", "-p", projectName2, "down", "--volumes", "--remove-orphans"],
|
|
2840
|
+
{ cwd: HORUS_DIR, env, reject: false }
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
var HEALTH_SERVICES = ["anvil", "forge", "vault-mcp", "typesense"];
|
|
2844
|
+
async function checkContainerHealthByProject(runtime, projectName2, service) {
|
|
2845
|
+
const candidates = [
|
|
2846
|
+
`${projectName2}-${service}-1`,
|
|
2847
|
+
`${projectName2}_${service}_1`
|
|
2848
|
+
];
|
|
2849
|
+
for (const name of candidates) {
|
|
2850
|
+
try {
|
|
2851
|
+
const result = await execa3(
|
|
2852
|
+
runtime.name,
|
|
2853
|
+
["inspect", "--format", "{{.State.Health.Status}}", name],
|
|
2854
|
+
{ reject: false }
|
|
2855
|
+
);
|
|
2856
|
+
if (result.exitCode === 0) {
|
|
2857
|
+
const status = result.stdout.toString().trim().toLowerCase();
|
|
2858
|
+
if (status === "healthy") return "healthy";
|
|
2859
|
+
if (status === "unhealthy") return "unhealthy";
|
|
2860
|
+
return "starting";
|
|
2861
|
+
}
|
|
2862
|
+
} catch {
|
|
2863
|
+
continue;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
return "starting";
|
|
2867
|
+
}
|
|
2868
|
+
async function waitForShadowStackHealthy(runtime, projectName2, timeoutMs = 12e4, intervalMs = 3e3, onUpdate) {
|
|
2869
|
+
const start = Date.now();
|
|
2870
|
+
while (true) {
|
|
2871
|
+
const statuses = {};
|
|
2872
|
+
await Promise.all(
|
|
2873
|
+
HEALTH_SERVICES.map(async (svc) => {
|
|
2874
|
+
statuses[svc] = await checkContainerHealthByProject(runtime, projectName2, svc);
|
|
2875
|
+
})
|
|
2876
|
+
);
|
|
2877
|
+
if (onUpdate) onUpdate(statuses);
|
|
2878
|
+
const allHealthy = Object.values(statuses).every((s) => s === "healthy");
|
|
2879
|
+
if (allHealthy) return;
|
|
2880
|
+
const anyUnhealthy = Object.values(statuses).some((s) => s === "unhealthy");
|
|
2881
|
+
if (anyUnhealthy) {
|
|
2882
|
+
const failed = Object.entries(statuses).filter(([, s]) => s === "unhealthy").map(([n]) => n).join(", ");
|
|
2883
|
+
throw new Error(`Shadow stack services failed health check: ${failed}`);
|
|
2884
|
+
}
|
|
2885
|
+
if (Date.now() - start >= timeoutMs) {
|
|
2886
|
+
const notReady = Object.entries(statuses).filter(([, s]) => s !== "healthy").map(([n, s]) => `${n}(${s})`).join(", ");
|
|
2887
|
+
throw new Error(`Timed out after ${timeoutMs / 1e3}s waiting for: ${notReady}`);
|
|
2888
|
+
}
|
|
2889
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
function seedFromFixtures(fixturesPath, slotDataPath) {
|
|
2893
|
+
if (!existsSync9(fixturesPath)) {
|
|
2894
|
+
throw new Error(`Fixtures not found at ${fixturesPath}. Run from the Horus repo root.`);
|
|
2895
|
+
}
|
|
2896
|
+
const dirs = readdirSync3(fixturesPath);
|
|
2897
|
+
for (const dir of dirs) {
|
|
2898
|
+
const src = join7(fixturesPath, dir);
|
|
2899
|
+
const dest = join7(slotDataPath, dir);
|
|
2900
|
+
cpSync(src, dest, { recursive: true });
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
function seedFromLive(dataDir, slotDataPath) {
|
|
2904
|
+
const liveDirs = ["notes", "vaults", "registry"];
|
|
2905
|
+
for (const dir of liveDirs) {
|
|
2906
|
+
const src = join7(dataDir, dir);
|
|
2907
|
+
const dest = join7(slotDataPath, dir);
|
|
2908
|
+
if (existsSync9(src)) {
|
|
2909
|
+
cpSync(src, dest, { recursive: true });
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
function projectName(slot) {
|
|
2914
|
+
return `horus-test-${slot}`;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// src/commands/test-env.ts
|
|
2918
|
+
var testEnvCommand = new Command10("test-env").description("Manage isolated shadow stacks for integration testing");
|
|
2919
|
+
testEnvCommand.command("acquire").description("Start a shadow stack on alternate ports with isolated data").option("--timeout <seconds>", "Max wait for health checks (default: 120)", "120").action(async (opts) => {
|
|
2920
|
+
const config = loadConfig();
|
|
2921
|
+
const dataDir = config.data_dir;
|
|
2922
|
+
const testCfg = loadTestEnvConfig(dataDir);
|
|
2923
|
+
const spinner = ora8("Detecting runtime...").start();
|
|
2924
|
+
let runtime;
|
|
2925
|
+
try {
|
|
2926
|
+
runtime = await detectRuntime(config.runtime);
|
|
2927
|
+
spinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
|
|
2928
|
+
} catch (error) {
|
|
2929
|
+
spinner.fail("No container runtime found");
|
|
2930
|
+
console.error(error.message);
|
|
2931
|
+
process.exit(1);
|
|
2932
|
+
}
|
|
2933
|
+
const slot = findFreeSlot(dataDir, testCfg);
|
|
2934
|
+
if (slot === null) {
|
|
2935
|
+
console.error(chalk10.red(
|
|
2936
|
+
`All ${testCfg.max_slots} slot(s) are in use. Run ${chalk10.bold("horus test-env status")} to see active slots, or ${chalk10.bold("horus test-env release")} to free one.`
|
|
2937
|
+
));
|
|
2938
|
+
process.exit(1);
|
|
2939
|
+
}
|
|
2940
|
+
const ports = calcPorts(slot, testCfg.base_port);
|
|
2941
|
+
const slotDataPath = getSlotDataPath(dataDir, slot);
|
|
2942
|
+
const project = projectName(slot);
|
|
2943
|
+
const dirSpinner = ora8(`Creating slot-${slot} data directories...`).start();
|
|
2944
|
+
createSlotDirs(slotDataPath);
|
|
2945
|
+
dirSpinner.succeed(`Data directory: ${chalk10.dim(slotDataPath)}`);
|
|
2946
|
+
writeLock(dataDir, {
|
|
2947
|
+
slot,
|
|
2948
|
+
pid: process.pid,
|
|
2949
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2950
|
+
ports,
|
|
2951
|
+
dataPath: slotDataPath
|
|
2952
|
+
});
|
|
2953
|
+
const upSpinner = ora8(`Starting shadow stack (project ${chalk10.cyan(project)})...`).start();
|
|
2954
|
+
try {
|
|
2955
|
+
await composeUp(runtime, project, ports, slotDataPath);
|
|
2956
|
+
upSpinner.succeed(`Shadow stack started`);
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
upSpinner.fail("Failed to start shadow stack");
|
|
2959
|
+
removeLock(dataDir, slot);
|
|
2960
|
+
removeSlotDirs(slotDataPath);
|
|
2961
|
+
console.error(error.message);
|
|
2962
|
+
process.exit(1);
|
|
2963
|
+
}
|
|
2964
|
+
const healthSpinner = ora8("Waiting for services to be healthy...").start();
|
|
2965
|
+
const timeoutMs = parseInt(opts.timeout, 10) * 1e3;
|
|
2966
|
+
try {
|
|
2967
|
+
await waitForShadowStackHealthy(runtime, project, timeoutMs, 3e3, (statuses) => {
|
|
2968
|
+
const parts = Object.entries(statuses).map(([svc, s]) => `${svc}:${s === "healthy" ? chalk10.green(s) : chalk10.yellow(s)}`).join(" ");
|
|
2969
|
+
healthSpinner.text = `Waiting for services... ${parts}`;
|
|
2970
|
+
});
|
|
2971
|
+
healthSpinner.succeed("All services healthy");
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
healthSpinner.fail("Health check failed");
|
|
2974
|
+
await composeDown(runtime, project, ports, slotDataPath);
|
|
2975
|
+
removeLock(dataDir, slot);
|
|
2976
|
+
removeSlotDirs(slotDataPath);
|
|
2977
|
+
console.error(error.message);
|
|
2978
|
+
process.exit(1);
|
|
2979
|
+
}
|
|
2980
|
+
console.log("");
|
|
2981
|
+
console.log(chalk10.bold.green(`\u2713 Slot ${slot} acquired`));
|
|
2982
|
+
console.log("");
|
|
2983
|
+
console.log(chalk10.bold("Connection info:"));
|
|
2984
|
+
console.log(` Slot: ${chalk10.cyan(slot)}`);
|
|
2985
|
+
console.log(` Project: ${chalk10.cyan(project)}`);
|
|
2986
|
+
console.log(` Data: ${chalk10.dim(slotDataPath)}`);
|
|
2987
|
+
console.log("");
|
|
2988
|
+
console.log(chalk10.bold("Ports:"));
|
|
2989
|
+
console.log(` Anvil: http://localhost:${chalk10.cyan(ports.anvil)}`);
|
|
2990
|
+
console.log(` Forge: http://localhost:${chalk10.cyan(ports.forge)}`);
|
|
2991
|
+
console.log(` Vault MCP: http://localhost:${chalk10.cyan(ports.vault_mcp)}`);
|
|
2992
|
+
console.log(` Vault Router: http://localhost:${chalk10.cyan(ports.vault_router)}`);
|
|
2993
|
+
console.log(` Typesense: http://localhost:${chalk10.cyan(ports.typesense)}`);
|
|
2994
|
+
console.log(` UI: http://localhost:${chalk10.cyan(ports.ui)}`);
|
|
2995
|
+
console.log("");
|
|
2996
|
+
console.log(chalk10.bold("Environment:"));
|
|
2997
|
+
console.log(` export TEST_SLOT=${slot}`);
|
|
2998
|
+
console.log(` export TEST_ANVIL_URL=http://localhost:${ports.anvil}`);
|
|
2999
|
+
console.log(` export TEST_FORGE_URL=http://localhost:${ports.forge}`);
|
|
3000
|
+
console.log(` export TEST_VAULT_MCP_URL=http://localhost:${ports.vault_mcp}`);
|
|
3001
|
+
console.log(` export TEST_DATA_PATH=${slotDataPath}`);
|
|
3002
|
+
console.log("");
|
|
3003
|
+
console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
|
|
3004
|
+
console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env release --slot ${slot}`)} when done.`));
|
|
3005
|
+
});
|
|
3006
|
+
testEnvCommand.command("release").description("Tear down a shadow stack and remove its data").option("--slot <n>", "Slot number to release (default: auto-detect acquired slot)").action(async (opts) => {
|
|
3007
|
+
const config = loadConfig();
|
|
3008
|
+
const dataDir = config.data_dir;
|
|
3009
|
+
const testCfg = loadTestEnvConfig(dataDir);
|
|
3010
|
+
let slot;
|
|
3011
|
+
if (opts.slot !== void 0) {
|
|
3012
|
+
slot = parseInt(opts.slot, 10);
|
|
3013
|
+
} else {
|
|
3014
|
+
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3015
|
+
const acquired = statuses.find((s) => s.state === "acquired" || s.state === "expired");
|
|
3016
|
+
if (!acquired) {
|
|
3017
|
+
console.log(chalk10.yellow("No active slots found."));
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
slot = acquired.slot;
|
|
3021
|
+
}
|
|
3022
|
+
const lock = readLock(dataDir, slot);
|
|
3023
|
+
const slotDataPath = getSlotDataPath(dataDir, slot);
|
|
3024
|
+
const project = projectName(slot);
|
|
3025
|
+
const ports = lock?.ports ?? calcPorts(slot, testCfg.base_port);
|
|
3026
|
+
const spinner = ora8("Detecting runtime...").start();
|
|
3027
|
+
let runtime;
|
|
3028
|
+
try {
|
|
3029
|
+
runtime = await detectRuntime(config.runtime);
|
|
3030
|
+
spinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
spinner.fail("No container runtime found");
|
|
3033
|
+
console.error(error.message);
|
|
3034
|
+
process.exit(1);
|
|
3035
|
+
}
|
|
3036
|
+
const downSpinner = ora8(`Stopping ${chalk10.cyan(project)}...`).start();
|
|
3037
|
+
try {
|
|
3038
|
+
await composeDown(runtime, project, ports, slotDataPath);
|
|
3039
|
+
downSpinner.succeed("Shadow stack stopped");
|
|
3040
|
+
} catch {
|
|
3041
|
+
downSpinner.warn("Failed to stop cleanly (continuing cleanup)");
|
|
3042
|
+
}
|
|
3043
|
+
const cleanSpinner = ora8("Removing test data...").start();
|
|
3044
|
+
removeSlotDirs(slotDataPath);
|
|
3045
|
+
removeLock(dataDir, slot);
|
|
3046
|
+
cleanSpinner.succeed("Test data removed");
|
|
3047
|
+
console.log("");
|
|
3048
|
+
console.log(chalk10.bold.green(`\u2713 Slot ${slot} released`));
|
|
3049
|
+
});
|
|
3050
|
+
testEnvCommand.command("status").description("Show active shadow stack slots").action(() => {
|
|
3051
|
+
const config = loadConfig();
|
|
3052
|
+
const dataDir = config.data_dir;
|
|
3053
|
+
const testCfg = loadTestEnvConfig(dataDir);
|
|
3054
|
+
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3055
|
+
const acquiredCount = statuses.filter((s) => s.state === "acquired").length;
|
|
3056
|
+
console.log("");
|
|
3057
|
+
console.log(chalk10.bold("Test Environment Status"));
|
|
3058
|
+
console.log(` Max slots: ${testCfg.max_slots}`);
|
|
3059
|
+
console.log(` In use: ${acquiredCount} / ${testCfg.max_slots}`);
|
|
3060
|
+
console.log(` Base port: ${testCfg.base_port}`);
|
|
3061
|
+
console.log("");
|
|
3062
|
+
if (statuses.every((s) => s.state === "free")) {
|
|
3063
|
+
console.log(chalk10.dim(" No active slots."));
|
|
3064
|
+
console.log("");
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
for (const s of statuses) {
|
|
3068
|
+
if (s.state === "free") continue;
|
|
3069
|
+
const stateLabel = s.state === "expired" ? chalk10.yellow("EXPIRED") : chalk10.green("ACTIVE");
|
|
3070
|
+
console.log(` ${chalk10.bold(`Slot ${s.slot}`)} ${stateLabel}`);
|
|
3071
|
+
if (s.acquiredAt) {
|
|
3072
|
+
console.log(` Acquired: ${s.acquiredAt} (${s.elapsedMinutes}m ago)`);
|
|
3073
|
+
}
|
|
3074
|
+
if (s.ports) {
|
|
3075
|
+
console.log(` Ports: anvil=${s.ports.anvil} forge=${s.ports.forge} vault-mcp=${s.ports.vault_mcp} typesense=${s.ports.typesense}`);
|
|
3076
|
+
}
|
|
3077
|
+
if (s.dataPath) {
|
|
3078
|
+
console.log(` Data: ${chalk10.dim(s.dataPath)}`);
|
|
3079
|
+
}
|
|
3080
|
+
console.log("");
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
testEnvCommand.command("seed").description("Populate a slot with test fixtures (or a snapshot of live data)").option("--slot <n>", "Slot to seed (default: auto-detect)").option("--from-live", "Snapshot live data instead of using fixtures").action(async (opts) => {
|
|
3084
|
+
const config = loadConfig();
|
|
3085
|
+
const dataDir = config.data_dir;
|
|
3086
|
+
const testCfg = loadTestEnvConfig(dataDir);
|
|
3087
|
+
let slot;
|
|
3088
|
+
if (opts.slot !== void 0) {
|
|
3089
|
+
slot = parseInt(opts.slot, 10);
|
|
3090
|
+
} else {
|
|
3091
|
+
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3092
|
+
const acquired = statuses.find((s) => s.state === "acquired");
|
|
3093
|
+
if (!acquired) {
|
|
3094
|
+
console.error(chalk10.red("No active slot found. Run `horus test-env acquire` first."));
|
|
3095
|
+
process.exit(1);
|
|
3096
|
+
}
|
|
3097
|
+
slot = acquired.slot;
|
|
3098
|
+
}
|
|
3099
|
+
const slotDataPath = getSlotDataPath(dataDir, slot);
|
|
3100
|
+
if (opts.fromLive) {
|
|
3101
|
+
const spinner = ora8("Snapshotting live data into slot...").start();
|
|
3102
|
+
try {
|
|
3103
|
+
seedFromLive(dataDir, slotDataPath);
|
|
3104
|
+
spinner.succeed("Live data snapshotted");
|
|
3105
|
+
} catch (error) {
|
|
3106
|
+
spinner.fail("Failed to snapshot live data");
|
|
3107
|
+
console.error(error.message);
|
|
3108
|
+
process.exit(1);
|
|
3109
|
+
}
|
|
3110
|
+
} else {
|
|
3111
|
+
const here = dirname2(fileURLToPath2(import.meta.url));
|
|
3112
|
+
const repoRoot = join8(here, "..", "..", "..", "..", "..");
|
|
3113
|
+
const fixturesPath = join8(repoRoot, "test", "fixtures");
|
|
3114
|
+
const spinner = ora8(`Seeding slot-${slot} from fixtures...`).start();
|
|
3115
|
+
try {
|
|
3116
|
+
seedFromFixtures(fixturesPath, slotDataPath);
|
|
3117
|
+
spinner.succeed(`Slot ${slot} seeded from fixtures`);
|
|
3118
|
+
} catch (error) {
|
|
3119
|
+
spinner.fail("Failed to seed fixtures");
|
|
3120
|
+
console.error(error.message);
|
|
3121
|
+
process.exit(1);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
console.log("");
|
|
3125
|
+
console.log(chalk10.dim("Services will re-index automatically. Allow ~10s before running tests."));
|
|
3126
|
+
});
|
|
3127
|
+
|
|
2627
3128
|
// src/index.ts
|
|
2628
|
-
var program = new
|
|
3129
|
+
var program = new Command11();
|
|
2629
3130
|
program.name("horus").description("CLI for managing the Horus Docker Compose stack").version(CLI_VERSION);
|
|
2630
3131
|
program.addCommand(setupCommand);
|
|
2631
3132
|
program.addCommand(upCommand);
|
|
@@ -2636,6 +3137,7 @@ program.addCommand(connectCommand);
|
|
|
2636
3137
|
program.addCommand(updateCommand);
|
|
2637
3138
|
program.addCommand(doctorCommand);
|
|
2638
3139
|
program.addCommand(backupCommand);
|
|
3140
|
+
program.addCommand(testEnvCommand);
|
|
2639
3141
|
program.exitOverride();
|
|
2640
3142
|
try {
|
|
2641
3143
|
await program.parseAsync(process.argv);
|
|
@@ -2644,9 +3146,9 @@ try {
|
|
|
2644
3146
|
process.exit(0);
|
|
2645
3147
|
}
|
|
2646
3148
|
if (error instanceof Error) {
|
|
2647
|
-
console.error(
|
|
3149
|
+
console.error(chalk11.red(`Error: ${error.message}`));
|
|
2648
3150
|
} else {
|
|
2649
|
-
console.error(
|
|
3151
|
+
console.error(chalk11.red("An unexpected error occurred."));
|
|
2650
3152
|
}
|
|
2651
3153
|
process.exit(1);
|
|
2652
3154
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arkhera30/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI for managing the Horus AI development stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
39
|
"url": "git+https://github.com/Arjunkhera/Horus.git",
|
|
40
|
-
"directory": "cli"
|
|
40
|
+
"directory": "packages/cli"
|
|
41
41
|
},
|
|
42
42
|
"homepage": "https://github.com/Arjunkhera/Horus#readme",
|
|
43
43
|
"bugs": {
|