@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.
@@ -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: 600s
52
+ start_period: 60s
95
53
  retries: 3
96
54
 
97
55
  # ── Vault ──────────────────────────────────────────────────────────────────
98
- # Knowledge service. Semantic search over the knowledge-base repo.
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: 600s
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 Command10 } from "commander";
5
- import chalk10 from "chalk";
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((resolve2) => setTimeout(resolve2, intervalMs));
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: 600s
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", "curl", "-f", "http://localhost:8400/api/health"]
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: 600s
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
- HORUS_UI_SERVICE,
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
- const pullSpinner = ora3("Pulling latest images...").start();
1653
+ console.log("");
1654
+ console.log(chalk3.bold("Pulling latest images..."));
1636
1655
  try {
1637
- await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
1638
- pullSpinner.succeed("Images up to date");
1656
+ await composeStreaming(runtime, ["pull"]);
1657
+ console.log("");
1658
+ console.log(chalk3.green("\u2713 Pull complete"));
1639
1659
  } catch {
1640
- pullSpinner.warn("Could not pull images, using cached");
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; QMD models take ~2GB)`,
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 Command10();
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(chalk10.red(`Error: ${error.message}`));
3149
+ console.error(chalk11.red(`Error: ${error.message}`));
2648
3150
  } else {
2649
- console.error(chalk10.red("An unexpected error occurred."));
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.2.3",
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": {