@arkhera30/cli 0.7.0 → 0.8.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/LICENSE +631 -0
- package/dist/index.js +1093 -889
- package/guides/index.json +1 -1
- package/package.json +13 -11
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
5
|
-
import
|
|
4
|
+
import { Command as Command16 } from "commander";
|
|
5
|
+
import chalk15 from "chalk";
|
|
6
6
|
|
|
7
7
|
// src/commands/setup.ts
|
|
8
|
-
import { Command as
|
|
9
|
-
import
|
|
8
|
+
import { Command as Command3 } from "commander";
|
|
9
|
+
import chalk3 from "chalk";
|
|
10
10
|
import ora2 from "ora";
|
|
11
11
|
import { execSync } from "child_process";
|
|
12
12
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
|
|
@@ -18,6 +18,7 @@ import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as
|
|
|
18
18
|
import { resolve, join as pathJoin, relative } from "path";
|
|
19
19
|
import { homedir as homedir2 } from "os";
|
|
20
20
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
21
|
+
import { z } from "zod";
|
|
21
22
|
|
|
22
23
|
// src/lib/constants.ts
|
|
23
24
|
import { homedir } from "os";
|
|
@@ -52,7 +53,7 @@ var DEFAULT_PORTS = {
|
|
|
52
53
|
vault_router: 8050,
|
|
53
54
|
// internal routing layer
|
|
54
55
|
ui: 8400,
|
|
55
|
-
//
|
|
56
|
+
// horus-ui — Horus unified UI
|
|
56
57
|
forge: 8200,
|
|
57
58
|
typesense: 8108,
|
|
58
59
|
// Typesense search engine
|
|
@@ -64,11 +65,7 @@ var DEFAULT_PORTS = {
|
|
|
64
65
|
var DEFAULT_DATA_DIR = join(homedir(), "Horus", "data");
|
|
65
66
|
var SERVICES = [
|
|
66
67
|
"anvil",
|
|
67
|
-
"
|
|
68
|
-
// replaces 'vault'
|
|
69
|
-
"vault-mcp",
|
|
70
|
-
"forge",
|
|
71
|
-
"reader",
|
|
68
|
+
"horus-ui",
|
|
72
69
|
"typesense",
|
|
73
70
|
"neo4j"
|
|
74
71
|
];
|
|
@@ -88,8 +85,12 @@ function defaultConfig() {
|
|
|
88
85
|
search: {
|
|
89
86
|
api_key: "horus-local-key"
|
|
90
87
|
},
|
|
88
|
+
control_plane_url: "",
|
|
89
|
+
token_provider: { kind: "", config: "" },
|
|
91
90
|
ai: {
|
|
92
|
-
key: ""
|
|
91
|
+
key: "",
|
|
92
|
+
anthropic_api_key: "",
|
|
93
|
+
model: "claude-sonnet-4-6"
|
|
93
94
|
},
|
|
94
95
|
vaults: {},
|
|
95
96
|
github_hosts: {},
|
|
@@ -101,6 +102,18 @@ function defaultConfig() {
|
|
|
101
102
|
function ensureHorusDir() {
|
|
102
103
|
mkdirSync(HORUS_DIR, { recursive: true });
|
|
103
104
|
}
|
|
105
|
+
function ensureFsLayout() {
|
|
106
|
+
ensureHorusDir();
|
|
107
|
+
const dirs = {
|
|
108
|
+
providers: pathJoin(HORUS_DIR, "providers"),
|
|
109
|
+
logs: pathJoin(HORUS_DIR, "logs"),
|
|
110
|
+
keys: pathJoin(HORUS_DIR, "keys")
|
|
111
|
+
};
|
|
112
|
+
for (const d of Object.values(dirs)) {
|
|
113
|
+
mkdirSync(d, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
return dirs;
|
|
116
|
+
}
|
|
104
117
|
function configExists() {
|
|
105
118
|
return existsSync2(CONFIG_PATH);
|
|
106
119
|
}
|
|
@@ -155,16 +168,68 @@ function buildConfigFromParsed(parsed) {
|
|
|
155
168
|
search: {
|
|
156
169
|
api_key: parsed.search?.api_key ?? defaults.search.api_key
|
|
157
170
|
},
|
|
171
|
+
control_plane_url: parsed.control_plane_url ?? defaults.control_plane_url,
|
|
172
|
+
token_provider: {
|
|
173
|
+
kind: parsed.token_provider?.kind ?? defaults.token_provider.kind,
|
|
174
|
+
config: parsed.token_provider?.config ?? defaults.token_provider.config
|
|
175
|
+
},
|
|
158
176
|
ai: {
|
|
159
|
-
key: parsed.ai?.key ?? defaults.ai.key
|
|
177
|
+
key: parsed.ai?.key ?? defaults.ai.key,
|
|
178
|
+
anthropic_api_key: parsed.ai?.anthropic_api_key ?? defaults.ai.anthropic_api_key,
|
|
179
|
+
model: parsed.ai?.model ?? defaults.ai.model
|
|
160
180
|
},
|
|
161
181
|
vaults: parsed.vaults ?? defaults.vaults,
|
|
162
182
|
github_hosts: parsed.github_hosts ?? defaults.github_hosts,
|
|
163
183
|
host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
|
|
164
184
|
host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs,
|
|
165
|
-
enable_ui: parsed.enable_ui ?? defaults.enable_ui
|
|
185
|
+
enable_ui: parsed.enable_ui ?? defaults.enable_ui,
|
|
186
|
+
registry_git_url: parsed.registry_git_url,
|
|
187
|
+
registry_deploy_key: parsed.registry_deploy_key,
|
|
188
|
+
enterprise_registry_url: parsed.enterprise_registry_url
|
|
166
189
|
};
|
|
167
190
|
}
|
|
191
|
+
var preprovisionedSchema = z.object({
|
|
192
|
+
version: z.string().optional(),
|
|
193
|
+
data_dir: z.string().optional(),
|
|
194
|
+
runtime: z.enum(["docker", "podman"]).optional(),
|
|
195
|
+
control_plane_url: z.string().optional(),
|
|
196
|
+
token_provider: z.object({ kind: z.string(), config: z.string() }).optional(),
|
|
197
|
+
ports: z.record(z.number()).optional(),
|
|
198
|
+
repos: z.object({ anvil_notes: z.string().optional(), forge_registry: z.string().optional() }).optional(),
|
|
199
|
+
vaults: z.record(z.any()).optional(),
|
|
200
|
+
github_hosts: z.record(z.any()).optional(),
|
|
201
|
+
ai: z.object({
|
|
202
|
+
key: z.string().optional(),
|
|
203
|
+
anthropic_api_key: z.string().optional(),
|
|
204
|
+
model: z.string().optional()
|
|
205
|
+
}).optional()
|
|
206
|
+
}).passthrough();
|
|
207
|
+
function loadPreprovisionedConfig(path2) {
|
|
208
|
+
if (!existsSync2(path2)) {
|
|
209
|
+
throw new Error(`Pre-provisioned config not found at ${path2}`);
|
|
210
|
+
}
|
|
211
|
+
const raw = readFileSync2(path2, "utf-8");
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = parseYaml(raw);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(`Pre-provisioned config is not valid YAML: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
const result = preprovisionedSchema.safeParse(parsed);
|
|
219
|
+
if (!result.success) {
|
|
220
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
221
|
+
throw new Error(`Pre-provisioned config failed validation:
|
|
222
|
+
${issues}`);
|
|
223
|
+
}
|
|
224
|
+
return buildConfigFromParsed(result.data);
|
|
225
|
+
}
|
|
226
|
+
function detectPreprovisionedConfig() {
|
|
227
|
+
const candidates = [
|
|
228
|
+
pathJoin(process.cwd(), "config.yaml.preprovisioned"),
|
|
229
|
+
pathJoin(HORUS_DIR, "config.yaml.preprovisioned")
|
|
230
|
+
];
|
|
231
|
+
return candidates.find((p) => existsSync2(p)) ?? null;
|
|
232
|
+
}
|
|
168
233
|
function saveConfig(config) {
|
|
169
234
|
ensureHorusDir();
|
|
170
235
|
const yaml = stringifyYaml(config, { lineWidth: 0 });
|
|
@@ -215,6 +280,7 @@ function discoverRepoDirs(rootDir, maxDepth = 4) {
|
|
|
215
280
|
}
|
|
216
281
|
function generateEnv(config) {
|
|
217
282
|
const dataDir = resolvePath(config.data_dir);
|
|
283
|
+
const providersPath = pathJoin(HORUS_DIR, "providers");
|
|
218
284
|
const hostReposPath = config.host_repos_path ? resolvePath(config.host_repos_path) : "";
|
|
219
285
|
const baseScanPath = "/data/repos";
|
|
220
286
|
let forgeScanPaths;
|
|
@@ -256,12 +322,22 @@ function generateEnv(config) {
|
|
|
256
322
|
"# Search",
|
|
257
323
|
`TYPESENSE_API_KEY=${config.search.api_key}`,
|
|
258
324
|
"",
|
|
259
|
-
"#
|
|
325
|
+
"# Control plane (empty HORUS_CONTROL_PLANE_URL => local-only mode)",
|
|
326
|
+
`HORUS_CONTROL_PLANE_URL=${config.control_plane_url ?? ""}`,
|
|
327
|
+
`TOKEN_PROVIDER_KIND=${config.token_provider?.kind ?? ""}`,
|
|
328
|
+
`TOKEN_PROVIDER_CONFIG=${config.token_provider?.config ?? ""}`,
|
|
329
|
+
"",
|
|
330
|
+
"# Agent chat (Anthropic). Chat surface is disabled when the key is empty.",
|
|
331
|
+
`HORUS_ANTHROPIC_API_KEY=${config.ai.anthropic_api_key}`,
|
|
332
|
+
`HORUS_AGENT_MODEL=${config.ai.model}`,
|
|
333
|
+
"# Legacy Cursor key \u2014 retained for back-compat, unused by the alpha client.",
|
|
260
334
|
`HORUS_AI_KEY=${config.ai.key}`,
|
|
261
335
|
"",
|
|
262
|
-
"#
|
|
336
|
+
"# Providers mount (read-only secrets/config dir for horus-ui)",
|
|
337
|
+
`HORUS_PROVIDERS_PATH=${providersPath}`,
|
|
338
|
+
"",
|
|
339
|
+
"# Anvil notes repo (must be HTTPS \u2014 container services do not have SSH keys)",
|
|
263
340
|
`ANVIL_REPO_URL=${config.repos.anvil_notes}`,
|
|
264
|
-
`FORGE_REGISTRY_REPO_URL=${config.repos.forge_registry}`,
|
|
265
341
|
""
|
|
266
342
|
];
|
|
267
343
|
return lines.join("\n");
|
|
@@ -286,6 +362,11 @@ var CONFIG_KEYS = [
|
|
|
286
362
|
"repo.forge-registry",
|
|
287
363
|
"search.api-key",
|
|
288
364
|
"ai.key",
|
|
365
|
+
"ai.anthropic-key",
|
|
366
|
+
"ai.model",
|
|
367
|
+
"control-plane-url",
|
|
368
|
+
"token-provider-kind",
|
|
369
|
+
"token-provider-config",
|
|
289
370
|
"enable-ui"
|
|
290
371
|
];
|
|
291
372
|
function getConfigValue(config, key) {
|
|
@@ -318,6 +399,16 @@ function getConfigValue(config, key) {
|
|
|
318
399
|
return config.search.api_key;
|
|
319
400
|
case "ai.key":
|
|
320
401
|
return config.ai.key;
|
|
402
|
+
case "ai.anthropic-key":
|
|
403
|
+
return config.ai.anthropic_api_key;
|
|
404
|
+
case "ai.model":
|
|
405
|
+
return config.ai.model;
|
|
406
|
+
case "control-plane-url":
|
|
407
|
+
return config.control_plane_url ?? "";
|
|
408
|
+
case "token-provider-kind":
|
|
409
|
+
return config.token_provider?.kind ?? "";
|
|
410
|
+
case "token-provider-config":
|
|
411
|
+
return config.token_provider?.config ?? "";
|
|
321
412
|
case "enable-ui":
|
|
322
413
|
return String(config.enable_ui);
|
|
323
414
|
}
|
|
@@ -370,6 +461,21 @@ function setConfigValue(config, key, value) {
|
|
|
370
461
|
case "ai.key":
|
|
371
462
|
updated.ai = { ...updated.ai, key: value };
|
|
372
463
|
break;
|
|
464
|
+
case "ai.anthropic-key":
|
|
465
|
+
updated.ai = { ...updated.ai, anthropic_api_key: value };
|
|
466
|
+
break;
|
|
467
|
+
case "ai.model":
|
|
468
|
+
updated.ai = { ...updated.ai, model: value };
|
|
469
|
+
break;
|
|
470
|
+
case "control-plane-url":
|
|
471
|
+
updated.control_plane_url = value;
|
|
472
|
+
break;
|
|
473
|
+
case "token-provider-kind":
|
|
474
|
+
updated.token_provider = { kind: value, config: updated.token_provider?.config ?? "" };
|
|
475
|
+
break;
|
|
476
|
+
case "token-provider-config":
|
|
477
|
+
updated.token_provider = { kind: updated.token_provider?.kind ?? "", config: value };
|
|
478
|
+
break;
|
|
373
479
|
case "enable-ui":
|
|
374
480
|
if (value !== "true" && value !== "false") {
|
|
375
481
|
throw new Error(`Invalid value for enable-ui: ${value}. Must be "true" or "false".`);
|
|
@@ -596,7 +702,7 @@ Run '${runtime.name} compose logs <service>' from ~/Horus/ to investigate.`
|
|
|
596
702
|
Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
|
|
597
703
|
);
|
|
598
704
|
}
|
|
599
|
-
await new Promise((
|
|
705
|
+
await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
|
|
600
706
|
}
|
|
601
707
|
}
|
|
602
708
|
|
|
@@ -653,67 +759,6 @@ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500
|
|
|
653
759
|
timeout: 5s
|
|
654
760
|
start_period: 60s
|
|
655
761
|
retries: 3`;
|
|
656
|
-
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
|
|
657
|
-
# Workspace manager and package registry MCP server.
|
|
658
|
-
forge:
|
|
659
|
-
image: ghcr.io/arjunkhera/horus/forge:latest
|
|
660
|
-
ports:
|
|
661
|
-
- "\${FORGE_PORT:-8200}:8200"
|
|
662
|
-
volumes:
|
|
663
|
-
- \${HORUS_DATA_PATH}/config:/data/config:rw
|
|
664
|
-
- \${HORUS_DATA_PATH}/registry:/data/registry:rw
|
|
665
|
-
- \${HORUS_DATA_PATH}/workspaces:/data/workspaces:rw
|
|
666
|
-
- \${HORUS_DATA_PATH}/repos:/data/horus-repos:rw
|
|
667
|
-
- \${HORUS_DATA_PATH}/sessions:/data/sessions:rw
|
|
668
|
-
- \${HOST_REPOS_PATH}:/data/repos:ro
|
|
669
|
-
environment:
|
|
670
|
-
- HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
|
|
671
|
-
- FORGE_PORT=8200
|
|
672
|
-
- FORGE_HOST=0.0.0.0
|
|
673
|
-
- FORGE_REGISTRY_PATH=/data/registry
|
|
674
|
-
- FORGE_WORKSPACES_PATH=/data/workspaces
|
|
675
|
-
- FORGE_CONFIG_PATH=/data/config
|
|
676
|
-
- FORGE_MANAGED_REPOS_PATH=/data/horus-repos
|
|
677
|
-
- FORGE_REGISTRY_REPO_URL=\${FORGE_REGISTRY_REPO_URL:-}
|
|
678
|
-
- FORGE_SYNC_INTERVAL=\${FORGE_SYNC_INTERVAL:-300}
|
|
679
|
-
- FORGE_ANVIL_URL=http://anvil:8100
|
|
680
|
-
- FORGE_VAULT_URL=http://vault-mcp:8300
|
|
681
|
-
- FORGE_HOST_WORKSPACES_PATH=\${HORUS_DATA_PATH}/workspaces
|
|
682
|
-
- FORGE_HOST_MANAGED_REPOS_PATH=\${HORUS_DATA_PATH}/repos
|
|
683
|
-
- FORGE_HOST_REPOS_PATH=\${HOST_REPOS_PATH}
|
|
684
|
-
- FORGE_HOST_MANAGED_REPOS_PATH=\${HORUS_DATA_PATH}/repos
|
|
685
|
-
- FORGE_HOST_ANVIL_URL=http://localhost:\${ANVIL_PORT:-8100}
|
|
686
|
-
- FORGE_HOST_VAULT_URL=http://localhost:\${VAULT_MCP_PORT:-8300}
|
|
687
|
-
- FORGE_HOST_FORGE_URL=http://localhost:\${FORGE_PORT:-8200}
|
|
688
|
-
- FORGE_SCAN_PATHS=\${FORGE_SCAN_PATHS:-/data/repos}
|
|
689
|
-
- FORGE_SESSION_TTL_MS=\${FORGE_SESSION_TTL_MS:-1800000}
|
|
690
|
-
- GITHUB_TOKEN=\${GITHUB_TOKEN:-}
|
|
691
|
-
- TYPESENSE_HOST=typesense
|
|
692
|
-
- TYPESENSE_PORT=8108
|
|
693
|
-
- TYPESENSE_API_KEY=\${TYPESENSE_API_KEY:-horus-local-key}
|
|
694
|
-
depends_on:
|
|
695
|
-
anvil:
|
|
696
|
-
condition: service_healthy
|
|
697
|
-
typesense:
|
|
698
|
-
condition: service_healthy
|
|
699
|
-
vault-router:
|
|
700
|
-
condition: service_healthy
|
|
701
|
-
networks:
|
|
702
|
-
- horus-net
|
|
703
|
-
restart: unless-stopped
|
|
704
|
-
stop_grace_period: 15s
|
|
705
|
-
deploy:
|
|
706
|
-
resources:
|
|
707
|
-
limits:
|
|
708
|
-
memory: 512m
|
|
709
|
-
reservations:
|
|
710
|
-
memory: 128m
|
|
711
|
-
healthcheck:
|
|
712
|
-
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
|
713
|
-
interval: 30s
|
|
714
|
-
timeout: 5s
|
|
715
|
-
start_period: 60s
|
|
716
|
-
retries: 3`;
|
|
717
762
|
var NEO4J_SERVICE = ` # \u2500\u2500 Neo4j \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
718
763
|
# Graph database for relationship-aware knowledge queries.
|
|
719
764
|
neo4j:
|
|
@@ -767,21 +812,33 @@ var TYPESENSE_SERVICE = ` # \u2500\u2500 Typesense \u2500\u2500\u2500\u2500\u25
|
|
|
767
812
|
retries: 3
|
|
768
813
|
start_period: 5s
|
|
769
814
|
restart: unless-stopped`;
|
|
770
|
-
var
|
|
771
|
-
# Horus
|
|
772
|
-
#
|
|
773
|
-
|
|
774
|
-
|
|
815
|
+
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
|
|
816
|
+
# Unified Horus client. Serves the SPA, proxies /api/anvil/* to the local
|
|
817
|
+
# Anvil and (when connected) vault/forge/admin to the control plane, and hosts
|
|
818
|
+
# the agent chat at POST /api/ai/ask. Boots first \u2014 no depends_on. Connected
|
|
819
|
+
# vs local-only mode is config-driven: an empty HORUS_CONTROL_PLANE_URL is
|
|
820
|
+
# local-only.
|
|
821
|
+
horus-ui:
|
|
822
|
+
image: ghcr.io/arjunkhera/horus/ui:latest
|
|
775
823
|
ports:
|
|
776
824
|
- "\${UI_PORT:-8400}:8400"
|
|
777
825
|
environment:
|
|
778
|
-
-
|
|
779
|
-
-
|
|
826
|
+
- HORUS_CONTROL_PLANE_URL=\${HORUS_CONTROL_PLANE_URL:-}
|
|
827
|
+
- TOKEN_PROVIDER_KIND=\${TOKEN_PROVIDER_KIND:-}
|
|
828
|
+
- TOKEN_PROVIDER_CONFIG=\${TOKEN_PROVIDER_CONFIG:-}
|
|
829
|
+
- HORUS_ANTHROPIC_API_KEY=\${HORUS_ANTHROPIC_API_KEY:-}
|
|
830
|
+
- HORUS_AGENT_MODEL=\${HORUS_AGENT_MODEL:-claude-sonnet-4-6}
|
|
780
831
|
- ANVIL_HOST=anvil
|
|
781
832
|
- ANVIL_PORT=8100
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
833
|
+
- FORGE_CONFIG_PATH=/horus/config
|
|
834
|
+
- FORGE_SESSIONS_ROOT=/horus/sessions
|
|
835
|
+
- FORGE_MANAGED_REPOS_PATH=/horus/repos
|
|
836
|
+
- FORGE_WORKSPACES_PATH=/horus/workspaces
|
|
837
|
+
volumes:
|
|
838
|
+
- \${HORUS_PROVIDERS_PATH}:/horus-providers:ro
|
|
839
|
+
- \${HORUS_DATA_PATH}/sessions:/horus/sessions:rw
|
|
840
|
+
- \${HORUS_DATA_PATH}/repos:/horus/repos:rw
|
|
841
|
+
- \${HORUS_DATA_PATH}/config:/horus/config:rw
|
|
785
842
|
networks:
|
|
786
843
|
- horus-net
|
|
787
844
|
restart: unless-stopped
|
|
@@ -789,9 +846,9 @@ var READER_SERVICE = ` # \u2500\u2500 Reader \u2500\u2500\u2500\u2500\u2500\u25
|
|
|
789
846
|
deploy:
|
|
790
847
|
resources:
|
|
791
848
|
limits:
|
|
792
|
-
memory:
|
|
849
|
+
memory: 512m
|
|
793
850
|
reservations:
|
|
794
|
-
memory:
|
|
851
|
+
memory: 256m
|
|
795
852
|
healthcheck:
|
|
796
853
|
test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8400/health"]
|
|
797
854
|
interval: 30s
|
|
@@ -854,7 +911,7 @@ services:
|
|
|
854
911
|
volumes:
|
|
855
912
|
- "\${TEST_DATA_PATH:-/tmp/horus-test}/typesense-data:/data"
|
|
856
913
|
|
|
857
|
-
|
|
914
|
+
horus-ui:
|
|
858
915
|
ports:
|
|
859
916
|
- "\${TEST_PORT_UI:-9260}:8400"
|
|
860
917
|
|
|
@@ -865,152 +922,37 @@ services:
|
|
|
865
922
|
volumes:
|
|
866
923
|
- "\${TEST_DATA_PATH:-/tmp/horus-test}/neo4j-data:/data"
|
|
867
924
|
- "\${TEST_DATA_PATH:-/tmp/horus-test}/neo4j-logs:/logs"
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
function generateComposeFile(config, runtime) {
|
|
871
|
-
const vaultEntries = Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b));
|
|
872
|
-
const vaultServices = vaultEntries.map(([name, vault], index) => {
|
|
873
|
-
const hostPort = `800${index + 1}`;
|
|
874
|
-
const envVarName = `VAULT_REST_PORT_${name.toUpperCase().replace(/-/g, "_")}`;
|
|
875
|
-
const githubHost = resolveGitHubHost(vault.repo, config.github_hosts);
|
|
876
|
-
const token = githubHost?.token ?? "";
|
|
877
|
-
const apiHost = githubHost?.host ?? "github.com";
|
|
878
|
-
return ` # \u2500\u2500 Vault: ${name} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
879
|
-
vault-${name}:
|
|
880
|
-
image: ghcr.io/arjunkhera/horus/vault:latest
|
|
925
|
+
${config.registry_git_url ? `
|
|
926
|
+
forge-registry:
|
|
881
927
|
ports:
|
|
882
|
-
- "\${
|
|
928
|
+
- "\${TEST_PORT_FORGE_REGISTRY:-9270}:8744"
|
|
883
929
|
volumes:
|
|
884
|
-
- \${
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
- KNOWLEDGE_REPO_PATH=/data/knowledge-repo
|
|
889
|
-
- WORKSPACE_PATH=/data/workspace
|
|
890
|
-
- VAULT_KNOWLEDGE_REPO_URL=${vault.repo}
|
|
891
|
-
- SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
|
|
892
|
-
- VAULT_SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
|
|
893
|
-
- LOG_LEVEL=\${LOG_LEVEL:-info}
|
|
894
|
-
- HOST=0.0.0.0
|
|
895
|
-
- PORT=8000
|
|
896
|
-
- GITHUB_TOKEN=${token}
|
|
897
|
-
- GITHUB_API_HOST=${apiHost}
|
|
898
|
-
- TYPESENSE_HOST=typesense
|
|
899
|
-
- TYPESENSE_PORT=8108
|
|
900
|
-
- TYPESENSE_API_KEY=\${TYPESENSE_API_KEY:-horus-local-key}
|
|
901
|
-
- NEO4J_URI=bolt://neo4j:7687
|
|
902
|
-
- NEO4J_USER=neo4j
|
|
903
|
-
- NEO4J_PASSWORD=horus-neo4j
|
|
904
|
-
depends_on:
|
|
905
|
-
typesense:
|
|
906
|
-
condition: service_healthy
|
|
907
|
-
neo4j:
|
|
908
|
-
condition: service_healthy
|
|
909
|
-
networks:
|
|
910
|
-
- horus-net
|
|
911
|
-
restart: unless-stopped
|
|
912
|
-
deploy:
|
|
913
|
-
resources:
|
|
914
|
-
limits:
|
|
915
|
-
memory: 512m
|
|
916
|
-
reservations:
|
|
917
|
-
memory: 256m
|
|
918
|
-
healthcheck:
|
|
919
|
-
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
920
|
-
interval: 30s
|
|
921
|
-
timeout: 10s
|
|
922
|
-
start_period: 60s
|
|
923
|
-
retries: 3`;
|
|
924
|
-
});
|
|
925
|
-
const defaultVaultEntry = vaultEntries.find(([, v]) => v.default);
|
|
926
|
-
const defaultVaultName = defaultVaultEntry ? defaultVaultEntry[0] : vaultEntries[0]?.[0] ?? "";
|
|
927
|
-
const vaultEndpoints = vaultEntries.map(([name]) => `${name}=http://vault-${name}:8000`).join(",");
|
|
928
|
-
const vaultRouterDependsOn = vaultEntries.map(([name]) => ` vault-${name}:
|
|
929
|
-
condition: service_healthy`).join("\n");
|
|
930
|
-
const vaultRouterService = ` # \u2500\u2500 Vault Router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
931
|
-
# Routes requests to the appropriate vault instance by name.
|
|
932
|
-
vault-router:
|
|
933
|
-
image: ghcr.io/arjunkhera/horus/vault-router:latest
|
|
934
|
-
ports:
|
|
935
|
-
- "\${VAULT_ROUTER_PORT:-8050}:8400"
|
|
936
|
-
environment:
|
|
937
|
-
${vaultEndpoints ? ` - VAULT_ENDPOINTS=${vaultEndpoints}
|
|
938
|
-
` : ""} - VAULT_DEFAULT=${defaultVaultName}
|
|
939
|
-
${vaultRouterDependsOn ? ` depends_on:
|
|
940
|
-
${vaultRouterDependsOn}
|
|
941
|
-
` : ""} networks:
|
|
942
|
-
- horus-net
|
|
943
|
-
restart: unless-stopped
|
|
944
|
-
deploy:
|
|
945
|
-
resources:
|
|
946
|
-
limits:
|
|
947
|
-
memory: 256m
|
|
948
|
-
reservations:
|
|
949
|
-
memory: 64m
|
|
950
|
-
healthcheck:
|
|
951
|
-
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8400/health')"]
|
|
952
|
-
interval: 30s
|
|
953
|
-
timeout: 10s
|
|
954
|
-
start_period: 30s
|
|
955
|
-
retries: 3`;
|
|
956
|
-
const vaultMcpService = ` # \u2500\u2500 Vault MCP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
957
|
-
# Thin MCP adapter that translates MCP tool calls to Vault REST API calls.
|
|
958
|
-
vault-mcp:
|
|
959
|
-
image: ghcr.io/arjunkhera/horus/vault-mcp:latest
|
|
960
|
-
ports:
|
|
961
|
-
- "\${VAULT_MCP_PORT:-8300}:8300"
|
|
962
|
-
environment:
|
|
963
|
-
- VAULT_MCP_HTTP=true
|
|
964
|
-
- VAULT_MCP_PORT=8300
|
|
965
|
-
- VAULT_MCP_HOST=0.0.0.0
|
|
966
|
-
- KNOWLEDGE_SERVICE_URL=http://vault-router:8400
|
|
967
|
-
depends_on:
|
|
968
|
-
vault-router:
|
|
969
|
-
condition: service_healthy
|
|
970
|
-
networks:
|
|
971
|
-
- horus-net
|
|
972
|
-
restart: unless-stopped
|
|
973
|
-
stop_grace_period: 15s
|
|
974
|
-
deploy:
|
|
975
|
-
resources:
|
|
976
|
-
limits:
|
|
977
|
-
memory: 256m
|
|
978
|
-
reservations:
|
|
979
|
-
memory: 64m
|
|
980
|
-
healthcheck:
|
|
981
|
-
test: ["CMD", "curl", "-f", "http://localhost:8300/health"]
|
|
982
|
-
interval: 30s
|
|
983
|
-
timeout: 5s
|
|
984
|
-
start_period: 30s
|
|
985
|
-
retries: 3`;
|
|
986
|
-
const vaultVolumeEntries = [
|
|
987
|
-
...vaultEntries.map(([name]) => ` vault-${name}-workspace:`),
|
|
988
|
-
" neo4j-data:",
|
|
989
|
-
" neo4j-logs:"
|
|
990
|
-
].join("\n");
|
|
930
|
+
- "\${TEST_DATA_PATH:-/tmp/horus-test}/registry:/data/registry:rw"
|
|
931
|
+
` : ""}`;
|
|
932
|
+
}
|
|
933
|
+
function generateComposeFile(_config, runtime) {
|
|
991
934
|
const sections = [
|
|
992
935
|
"# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
993
936
|
"# Horus \u2014 Generated Docker Compose",
|
|
994
937
|
"# Managed by @arkhera30/cli. Do not edit manually.",
|
|
995
938
|
"# Generated dynamically from ~/Horus/config.yaml by `horus setup`.",
|
|
939
|
+
"#",
|
|
940
|
+
"# Alpha client topology (\xA7C): horus-ui, anvil, typesense, neo4j only.",
|
|
941
|
+
"# Vault and Forge are remote behind the control plane. Connected vs",
|
|
942
|
+
"# local-only mode is config-driven (empty HORUS_CONTROL_PLANE_URL =",
|
|
943
|
+
"# local-only), NOT compose-driven \u2014 the service set is identical in both.",
|
|
996
944
|
"# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
997
945
|
"",
|
|
998
946
|
"services:",
|
|
999
947
|
"",
|
|
1000
|
-
|
|
1001
|
-
"",
|
|
1002
|
-
...vaultServices.map((s) => s + "\n"),
|
|
1003
|
-
vaultRouterService,
|
|
948
|
+
HORUS_UI_SERVICE,
|
|
1004
949
|
"",
|
|
1005
|
-
|
|
950
|
+
ANVIL_SERVICE,
|
|
1006
951
|
"",
|
|
1007
|
-
|
|
952
|
+
TYPESENSE_SERVICE,
|
|
1008
953
|
"",
|
|
1009
954
|
NEO4J_SERVICE,
|
|
1010
955
|
"",
|
|
1011
|
-
TYPESENSE_SERVICE,
|
|
1012
|
-
"",
|
|
1013
|
-
...config.enable_ui !== false ? [READER_SERVICE, ""] : [],
|
|
1014
956
|
"# \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",
|
|
1015
957
|
"networks:",
|
|
1016
958
|
" horus-net:",
|
|
@@ -1018,7 +960,8 @@ ${vaultRouterDependsOn}
|
|
|
1018
960
|
"",
|
|
1019
961
|
"# \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",
|
|
1020
962
|
"volumes:",
|
|
1021
|
-
|
|
963
|
+
" neo4j-data:",
|
|
964
|
+
" neo4j-logs:"
|
|
1022
965
|
];
|
|
1023
966
|
let content = sections.join("\n");
|
|
1024
967
|
if (runtime === "podman") {
|
|
@@ -1432,10 +1375,13 @@ function buildClaudeDesktopServers(config, host) {
|
|
|
1432
1375
|
const npxPath = detectNpxPath();
|
|
1433
1376
|
const npxDir = npxPath === "npx" ? "/usr/local/bin" : npxPath.substring(0, npxPath.lastIndexOf("/"));
|
|
1434
1377
|
const envPath = `${npxDir}:/usr/local/bin:/usr/bin:/bin`;
|
|
1378
|
+
const connectedMode = !!(config.control_plane_url && config.control_plane_url.trim());
|
|
1379
|
+
const vaultUrl = connectedMode ? `http://${host}:${config.ports.ui}/vault/mcp` : `http://${host}:${config.ports.vault_mcp}/mcp`;
|
|
1380
|
+
const forgeUrl = connectedMode ? `http://${host}:${config.ports.ui}/forge/mcp` : `http://${host}:${config.ports.forge}/mcp`;
|
|
1435
1381
|
return {
|
|
1436
1382
|
anvil: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.anvil}/mcp`], env: { PATH: envPath } },
|
|
1437
|
-
vault: { command: npxPath, args: ["mcp-remote",
|
|
1438
|
-
forge: { command: npxPath, args: ["mcp-remote",
|
|
1383
|
+
vault: { command: npxPath, args: ["mcp-remote", vaultUrl], env: { PATH: envPath } },
|
|
1384
|
+
forge: { command: npxPath, args: ["mcp-remote", forgeUrl], env: { PATH: envPath } }
|
|
1439
1385
|
};
|
|
1440
1386
|
}
|
|
1441
1387
|
async function isClaudeCliAvailable() {
|
|
@@ -1529,7 +1475,12 @@ function printNextSteps(targets) {
|
|
|
1529
1475
|
console.log("");
|
|
1530
1476
|
}
|
|
1531
1477
|
async function runConnect(config, runtime, targets, host = "localhost") {
|
|
1532
|
-
const
|
|
1478
|
+
const connectedMode = !!(config.control_plane_url && config.control_plane_url.trim());
|
|
1479
|
+
const httpServers = connectedMode ? {
|
|
1480
|
+
anvil: { url: `http://${host}:${config.ports.anvil}/mcp` },
|
|
1481
|
+
vault: { url: `http://${host}:${config.ports.ui}/vault/mcp` },
|
|
1482
|
+
forge: { url: `http://${host}:${config.ports.ui}/forge/mcp` }
|
|
1483
|
+
} : {
|
|
1533
1484
|
anvil: { url: `http://${host}:${config.ports.anvil}/mcp` },
|
|
1534
1485
|
vault: { url: `http://${host}:${config.ports.vault_mcp}/mcp` },
|
|
1535
1486
|
forge: { url: `http://${host}:${config.ports.forge}/mcp` }
|
|
@@ -1589,23 +1540,32 @@ async function runConnect(config, runtime, targets, host = "localhost") {
|
|
|
1589
1540
|
}
|
|
1590
1541
|
}
|
|
1591
1542
|
if (targets.includes("claude-code")) {
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1543
|
+
if (connectedMode) {
|
|
1544
|
+
console.warn(chalk.yellow("[connect] Skipping skills sync \u2014 connected mode has no local forge container."));
|
|
1545
|
+
console.log(chalk.dim(" TODO: fetch skills via the control plane when the skills API is available."));
|
|
1546
|
+
} else {
|
|
1547
|
+
const skillsSpinner = ora("Syncing horus-core skills...").start();
|
|
1548
|
+
try {
|
|
1549
|
+
await syncSkills(runtime);
|
|
1550
|
+
skillsSpinner.succeed("horus-core skills synced to ~/.claude/skills/");
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
skillsSpinner.warn("Could not sync skills (Forge container may not be running)");
|
|
1553
|
+
console.log(chalk.dim(error.message));
|
|
1554
|
+
}
|
|
1599
1555
|
}
|
|
1600
1556
|
}
|
|
1601
1557
|
if (targets.includes("cursor")) {
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
cursorRulesSpinner
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1558
|
+
if (connectedMode) {
|
|
1559
|
+
console.warn(chalk.yellow("[connect] Skipping Cursor rules sync \u2014 connected mode has no local forge container."));
|
|
1560
|
+
} else {
|
|
1561
|
+
const cursorRulesSpinner = ora("Syncing horus-core rules for Cursor...").start();
|
|
1562
|
+
try {
|
|
1563
|
+
await syncSkillsForCursor(runtime);
|
|
1564
|
+
cursorRulesSpinner.succeed("horus-core rules synced to ~/.cursor/rules/ and skills to ~/.cursor/skills-cursor/");
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
cursorRulesSpinner.warn("Could not sync Cursor rules (Forge container may not be running)");
|
|
1567
|
+
console.log(chalk.dim(error.message));
|
|
1568
|
+
}
|
|
1609
1569
|
}
|
|
1610
1570
|
}
|
|
1611
1571
|
if (configured.length > 0) {
|
|
@@ -1674,321 +1634,220 @@ var connectCommand = new Command("connect").description("Configure Claude/Cursor
|
|
|
1674
1634
|
await runConnect(config, runtime, targets, opts.host);
|
|
1675
1635
|
});
|
|
1676
1636
|
|
|
1677
|
-
// src/commands/
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
return parsed.toString();
|
|
1685
|
-
} catch {
|
|
1686
|
-
return url;
|
|
1637
|
+
// src/commands/login.ts
|
|
1638
|
+
import { Command as Command2 } from "commander";
|
|
1639
|
+
import chalk2 from "chalk";
|
|
1640
|
+
async function runLogin(config) {
|
|
1641
|
+
const cp = (config.control_plane_url ?? "").trim();
|
|
1642
|
+
if (!cp) {
|
|
1643
|
+
return { ok: true, deferred: false, message: "Local-only mode \u2014 no control plane to log into." };
|
|
1687
1644
|
}
|
|
1688
|
-
|
|
1689
|
-
function extractHostname(url) {
|
|
1645
|
+
let reachable = false;
|
|
1690
1646
|
try {
|
|
1691
|
-
|
|
1647
|
+
const res = await fetch(cp.replace(/\/$/, "") + "/health", { signal: AbortSignal.timeout(8e3) });
|
|
1648
|
+
reachable = res.ok;
|
|
1692
1649
|
} catch {
|
|
1693
|
-
|
|
1650
|
+
reachable = false;
|
|
1651
|
+
}
|
|
1652
|
+
if (!reachable) {
|
|
1653
|
+
return {
|
|
1654
|
+
ok: false,
|
|
1655
|
+
deferred: true,
|
|
1656
|
+
message: `Control plane at ${cp} is not reachable yet. Run \`horus login\` once it is available.`
|
|
1657
|
+
};
|
|
1694
1658
|
}
|
|
1659
|
+
const kind = config.token_provider?.kind ?? "";
|
|
1660
|
+
if (kind === "static") {
|
|
1661
|
+
const hasToken = !!(config.token_provider?.config ?? "").trim();
|
|
1662
|
+
return hasToken ? { ok: true, deferred: false, message: "Static principal token configured \u2014 client is authenticated." } : {
|
|
1663
|
+
ok: false,
|
|
1664
|
+
deferred: true,
|
|
1665
|
+
message: "Static token provider selected but no token is set. Run `horus config set token-provider-config <token>`."
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
ok: false,
|
|
1670
|
+
deferred: true,
|
|
1671
|
+
message: `Interactive login (${kind || "unconfigured"}) completes against the control plane. Run \`horus login\` once it is available.`
|
|
1672
|
+
};
|
|
1695
1673
|
}
|
|
1696
|
-
var
|
|
1674
|
+
var loginCommand = new Command2("login").description("Authenticate the client against the configured control plane").action(async () => {
|
|
1675
|
+
if (!configExists()) {
|
|
1676
|
+
console.log(chalk2.red("Horus is not configured yet. Run `horus setup` first."));
|
|
1677
|
+
process.exit(1);
|
|
1678
|
+
}
|
|
1679
|
+
const result = await runLogin(loadConfig());
|
|
1680
|
+
if (result.ok) {
|
|
1681
|
+
console.log(chalk2.green(result.message));
|
|
1682
|
+
} else {
|
|
1683
|
+
console.log(chalk2.yellow(result.message));
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
// src/commands/setup.ts
|
|
1688
|
+
var setupCommand = new Command3("setup").description("First-run setup for the Horus client").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--local-only", "Local-only mode: skip the control-plane / login prompts").option("--config <path>", "Provision from a pre-provisioned, zod-validated config.yaml bundle").option("--no-pull", "Skip pulling container images").option("--runtime <runtime>", "Container runtime to use: docker or podman").option("--data-dir <path>", "Data directory path").option("--anvil-repo <url>", "Anvil notes repository URL (HTTPS)").option("--control-plane <url>", "Control-plane base URL (connected mode)").option("--claude-desktop", "Configure Claude Desktop MCP servers during setup").action(async (opts) => {
|
|
1697
1689
|
console.log("");
|
|
1698
|
-
console.log(
|
|
1699
|
-
console.log(
|
|
1690
|
+
console.log(chalk3.bold("Horus Setup"));
|
|
1691
|
+
console.log(chalk3.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1700
1692
|
console.log("");
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1693
|
+
const preprovisionedPath = opts.config ?? detectPreprovisionedConfig();
|
|
1694
|
+
const isPreprovisioned = !!preprovisionedPath;
|
|
1695
|
+
const isYes = !!opts.yes;
|
|
1696
|
+
const isLocalOnly = !!opts.localOnly;
|
|
1697
|
+
const interactive = !isPreprovisioned && !isYes;
|
|
1698
|
+
if (configExists() && interactive) {
|
|
1699
|
+
const proceed = await confirm({
|
|
1700
|
+
message: "Horus is already configured. Reconfigure? (existing data is preserved)",
|
|
1701
|
+
default: false
|
|
1702
|
+
});
|
|
1703
|
+
if (!proceed) {
|
|
1704
|
+
console.log(chalk3.dim("Setup cancelled."));
|
|
1705
|
+
return;
|
|
1713
1706
|
}
|
|
1714
1707
|
}
|
|
1715
1708
|
const checkSpinner = ora2("Checking for container runtimes...").start();
|
|
1716
|
-
const [hasDocker, hasPodman] = await Promise.all([
|
|
1717
|
-
checkRuntime("docker"),
|
|
1718
|
-
checkRuntime("podman")
|
|
1719
|
-
]);
|
|
1709
|
+
const [hasDocker, hasPodman] = await Promise.all([checkRuntime("docker"), checkRuntime("podman")]);
|
|
1720
1710
|
checkSpinner.stop();
|
|
1721
1711
|
const available = [
|
|
1722
1712
|
...hasDocker ? ["docker"] : [],
|
|
1723
1713
|
...hasPodman ? ["podman"] : []
|
|
1724
1714
|
];
|
|
1725
1715
|
if (available.length === 0) {
|
|
1726
|
-
console.log(
|
|
1716
|
+
console.log(chalk3.red("No container runtime found."));
|
|
1727
1717
|
console.log("");
|
|
1728
1718
|
console.log("Horus requires Docker or Podman with the Compose plugin.");
|
|
1729
|
-
console.log("");
|
|
1730
|
-
console.log("Install one of:");
|
|
1731
1719
|
console.log(" Docker Desktop: https://www.docker.com/products/docker-desktop/");
|
|
1732
1720
|
console.log(" Podman Desktop: https://podman-desktop.io/");
|
|
1733
1721
|
process.exit(1);
|
|
1734
1722
|
}
|
|
1723
|
+
let config;
|
|
1735
1724
|
let selectedRuntime;
|
|
1736
|
-
if (
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1725
|
+
if (isPreprovisioned) {
|
|
1726
|
+
const loadSpinner = ora2(`Loading pre-provisioned config: ${preprovisionedPath}`).start();
|
|
1727
|
+
try {
|
|
1728
|
+
config = loadPreprovisionedConfig(preprovisionedPath);
|
|
1729
|
+
loadSpinner.succeed("Pre-provisioned config validated");
|
|
1730
|
+
} catch (err) {
|
|
1731
|
+
loadSpinner.fail("Pre-provisioned config is invalid");
|
|
1732
|
+
console.log(chalk3.red(err.message));
|
|
1741
1733
|
process.exit(1);
|
|
1742
1734
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
} else {
|
|
1746
|
-
selectedRuntime = await select({
|
|
1747
|
-
message: "Which container runtime would you like to use?",
|
|
1748
|
-
choices: available.map((r) => ({
|
|
1749
|
-
value: r,
|
|
1750
|
-
name: r === "docker" ? "Docker" : "Podman"
|
|
1751
|
-
}))
|
|
1752
|
-
});
|
|
1753
|
-
}
|
|
1754
|
-
const runtime = await detectRuntime(selectedRuntime);
|
|
1755
|
-
let config;
|
|
1756
|
-
if (opts.yes) {
|
|
1735
|
+
const requested = opts.runtime ?? config.runtime;
|
|
1736
|
+
selectedRuntime = available.includes(requested) ? requested : available[0];
|
|
1737
|
+
} else if (isYes) {
|
|
1757
1738
|
const existing = configExists() ? loadConfig() : null;
|
|
1758
1739
|
const defaults = defaultConfig();
|
|
1759
|
-
|
|
1760
|
-
if (
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
vaults = {};
|
|
1764
|
-
vaultNames.forEach((name, i) => {
|
|
1765
|
-
vaults[name] = {
|
|
1766
|
-
repo: vaultRepos[i] ?? vaultRepos[0] ?? "",
|
|
1767
|
-
default: i === 0
|
|
1768
|
-
};
|
|
1769
|
-
});
|
|
1770
|
-
} else {
|
|
1771
|
-
vaults = existing?.vaults ?? (process.env.VAULT_KNOWLEDGE_REPO_URL ? { default: { repo: process.env.VAULT_KNOWLEDGE_REPO_URL, default: true } } : defaults.vaults);
|
|
1772
|
-
}
|
|
1773
|
-
const primaryToken = opts.githubToken || process.env.GITHUB_TOKEN || "";
|
|
1774
|
-
const anvilRepo = opts.anvilRepo || process.env.ANVIL_REPO_URL || existing?.repos.anvil_notes || defaults.repos.anvil_notes;
|
|
1775
|
-
let github_hosts;
|
|
1776
|
-
if (opts.githubToken || !existing || Object.keys(existing.github_hosts).length === 0) {
|
|
1777
|
-
const allRepoUrls = [anvilRepo, ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
|
|
1778
|
-
const seenHosts = /* @__PURE__ */ new Set();
|
|
1779
|
-
github_hosts = {};
|
|
1780
|
-
let hostIndex = 0;
|
|
1781
|
-
for (const url of allRepoUrls) {
|
|
1782
|
-
const hostname = extractHostname(url);
|
|
1783
|
-
if (!seenHosts.has(hostname)) {
|
|
1784
|
-
seenHosts.add(hostname);
|
|
1785
|
-
const hostKey = hostIndex === 0 ? "default" : hostname;
|
|
1786
|
-
github_hosts[hostKey] = {
|
|
1787
|
-
host: hostname,
|
|
1788
|
-
token: primaryToken
|
|
1789
|
-
};
|
|
1790
|
-
hostIndex++;
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
if (Object.keys(github_hosts).length === 0) {
|
|
1794
|
-
github_hosts["default"] = { host: "github.com", token: primaryToken };
|
|
1795
|
-
}
|
|
1796
|
-
} else {
|
|
1797
|
-
github_hosts = existing.github_hosts;
|
|
1740
|
+
const requested = opts.runtime;
|
|
1741
|
+
if (requested && !available.includes(requested)) {
|
|
1742
|
+
console.log(chalk3.red(`Requested runtime "${requested}" is not installed.`));
|
|
1743
|
+
process.exit(1);
|
|
1798
1744
|
}
|
|
1745
|
+
selectedRuntime = requested ?? existing?.runtime ?? available[0];
|
|
1746
|
+
const controlPlane = opts.controlPlane || process.env.HORUS_CONTROL_PLANE_URL || existing?.control_plane_url || "";
|
|
1799
1747
|
config = {
|
|
1800
1748
|
...defaults,
|
|
1801
|
-
// Preserve all existing top-level fields first
|
|
1802
1749
|
...existing ?? {},
|
|
1803
|
-
|
|
1804
|
-
runtime: runtime.name,
|
|
1805
|
-
// Apply field-level overrides: explicit flag > existing value > default
|
|
1750
|
+
runtime: selectedRuntime,
|
|
1806
1751
|
data_dir: opts.dataDir || existing?.data_dir || DEFAULT_DATA_DIR,
|
|
1807
|
-
|
|
1752
|
+
control_plane_url: controlPlane,
|
|
1753
|
+
token_provider: {
|
|
1754
|
+
kind: process.env.TOKEN_PROVIDER_KIND || existing?.token_provider?.kind || (controlPlane ? "static" : ""),
|
|
1755
|
+
config: process.env.TOKEN_PROVIDER_CONFIG || existing?.token_provider?.config || ""
|
|
1756
|
+
},
|
|
1808
1757
|
repos: {
|
|
1809
|
-
anvil_notes: anvilRepo,
|
|
1810
|
-
forge_registry:
|
|
1758
|
+
anvil_notes: opts.anvilRepo || process.env.ANVIL_REPO_URL || existing?.repos.anvil_notes || "",
|
|
1759
|
+
forge_registry: existing?.repos.forge_registry || ""
|
|
1811
1760
|
},
|
|
1812
|
-
vaults,
|
|
1813
|
-
github_hosts,
|
|
1814
1761
|
ai: {
|
|
1815
|
-
key: process.env.HORUS_AI_KEY || existing?.ai.key || ""
|
|
1762
|
+
key: process.env.HORUS_AI_KEY || existing?.ai.key || "",
|
|
1763
|
+
anthropic_api_key: process.env.HORUS_ANTHROPIC_API_KEY || existing?.ai.anthropic_api_key || "",
|
|
1764
|
+
model: process.env.HORUS_AGENT_MODEL || existing?.ai.model || defaults.ai.model
|
|
1816
1765
|
}
|
|
1817
1766
|
};
|
|
1818
1767
|
} else {
|
|
1819
|
-
|
|
1820
|
-
message: "
|
|
1821
|
-
|
|
1822
|
-
});
|
|
1823
|
-
const host_repos_path = await input({
|
|
1824
|
-
message: "Host repos path (for Forge repo scanning, leave empty to skip):",
|
|
1825
|
-
default: ""
|
|
1826
|
-
});
|
|
1827
|
-
const host_repos_extra_scan_dirs = [];
|
|
1828
|
-
const customize_ports = await confirm({
|
|
1829
|
-
message: "Customize port assignments?",
|
|
1830
|
-
default: false
|
|
1831
|
-
});
|
|
1832
|
-
let ports = { ...DEFAULT_PORTS };
|
|
1833
|
-
if (customize_ports) {
|
|
1834
|
-
const anvil = await number({
|
|
1835
|
-
message: "Anvil port:",
|
|
1836
|
-
default: DEFAULT_PORTS.anvil
|
|
1837
|
-
});
|
|
1838
|
-
const vault_rest = await number({
|
|
1839
|
-
message: "Vault REST port (per-vault instances):",
|
|
1840
|
-
default: DEFAULT_PORTS.vault_rest
|
|
1841
|
-
});
|
|
1842
|
-
const vault_mcp = await number({
|
|
1843
|
-
message: "Vault MCP port:",
|
|
1844
|
-
default: DEFAULT_PORTS.vault_mcp
|
|
1845
|
-
});
|
|
1846
|
-
const vault_router = await number({
|
|
1847
|
-
message: "Vault Router port:",
|
|
1848
|
-
default: DEFAULT_PORTS.vault_router
|
|
1849
|
-
});
|
|
1850
|
-
const forge = await number({
|
|
1851
|
-
message: "Forge port:",
|
|
1852
|
-
default: DEFAULT_PORTS.forge
|
|
1853
|
-
});
|
|
1854
|
-
ports = {
|
|
1855
|
-
anvil: anvil ?? DEFAULT_PORTS.anvil,
|
|
1856
|
-
vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
|
|
1857
|
-
vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
|
|
1858
|
-
vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
|
|
1859
|
-
ui: DEFAULT_PORTS.ui,
|
|
1860
|
-
forge: forge ?? DEFAULT_PORTS.forge,
|
|
1861
|
-
typesense: DEFAULT_PORTS.typesense,
|
|
1862
|
-
neo4j_http: DEFAULT_PORTS.neo4j_http,
|
|
1863
|
-
neo4j_bolt: DEFAULT_PORTS.neo4j_bolt
|
|
1864
|
-
};
|
|
1865
|
-
}
|
|
1866
|
-
console.log("");
|
|
1867
|
-
console.log(chalk2.bold("Repository Configuration"));
|
|
1868
|
-
console.log(chalk2.dim("Horus stores notes and knowledge in Git repos you own."));
|
|
1869
|
-
console.log(chalk2.dim("Create empty repos on your Git server, then paste the URLs below."));
|
|
1870
|
-
console.log("");
|
|
1871
|
-
console.log(chalk2.yellow(" Use HTTPS URLs \u2014 container services do not have SSH keys."));
|
|
1872
|
-
console.log(chalk2.dim(" SSH URLs (git@github.com:...) will fail at runtime inside Docker/Podman."));
|
|
1873
|
-
console.log("");
|
|
1874
|
-
const primaryHost = await input({
|
|
1875
|
-
message: "Primary Git server hostname:",
|
|
1876
|
-
default: "github.com"
|
|
1877
|
-
});
|
|
1878
|
-
const example = (repo) => chalk2.dim(` e.g., https://${primaryHost}/<owner>/${repo}`);
|
|
1879
|
-
console.log("");
|
|
1880
|
-
const anvil_notes = await input({
|
|
1881
|
-
message: `Anvil notes repo URL:
|
|
1882
|
-
${example("horus-notes")}
|
|
1883
|
-
`,
|
|
1884
|
-
validate: (v) => v.trim().length > 0 || "Anvil needs a notes repo to store your data."
|
|
1885
|
-
});
|
|
1886
|
-
const forge_registry = await input({
|
|
1887
|
-
message: `Forge registry repo URL:
|
|
1888
|
-
${example("forge-registry")}
|
|
1889
|
-
`,
|
|
1890
|
-
validate: (v) => v.trim().length > 0 || "Forge needs a registry repo."
|
|
1768
|
+
selectedRuntime = available.length === 1 ? available[0] : await select({
|
|
1769
|
+
message: "Which container runtime would you like to use?",
|
|
1770
|
+
choices: available.map((r) => ({ value: r, name: r === "docker" ? "Docker" : "Podman" }))
|
|
1891
1771
|
});
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
const
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (
|
|
1907
|
-
|
|
1772
|
+
const defaults = defaultConfig();
|
|
1773
|
+
let control_plane_url = "";
|
|
1774
|
+
let token_provider = { kind: "", config: "" };
|
|
1775
|
+
if (!isLocalOnly) {
|
|
1776
|
+
control_plane_url = (await input({
|
|
1777
|
+
message: "Control-plane URL (leave empty for local-only):",
|
|
1778
|
+
default: opts.controlPlane ?? ""
|
|
1779
|
+
})).trim();
|
|
1780
|
+
if (control_plane_url) {
|
|
1781
|
+
const cpSpinner = ora2(`Checking control plane at ${control_plane_url}...`).start();
|
|
1782
|
+
try {
|
|
1783
|
+
const res = await fetch(control_plane_url.replace(/\/$/, "") + "/health", {
|
|
1784
|
+
signal: AbortSignal.timeout(8e3)
|
|
1785
|
+
});
|
|
1786
|
+
if (res.ok) cpSpinner.succeed("Control plane reachable");
|
|
1787
|
+
else cpSpinner.warn(`Control plane responded HTTP ${res.status} \u2014 continuing (login can be deferred)`);
|
|
1788
|
+
} catch {
|
|
1789
|
+
cpSpinner.warn("Control plane unreachable \u2014 continuing; run `horus login` later");
|
|
1908
1790
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
let isDefault = isFirstVault;
|
|
1917
|
-
if (!isFirstVault) {
|
|
1918
|
-
isDefault = await confirm({
|
|
1919
|
-
message: `Is "${vaultName.trim()}" the default vault?`,
|
|
1920
|
-
default: false
|
|
1791
|
+
const kind = await select({
|
|
1792
|
+
message: "Principal token provider:",
|
|
1793
|
+
choices: [
|
|
1794
|
+
{ value: "static", name: "Static token (paste a token)" },
|
|
1795
|
+
{ value: "oidc", name: "OIDC (issuer URL; login completes against the control plane)" },
|
|
1796
|
+
{ value: "none", name: "None (anonymous / configure later)" }
|
|
1797
|
+
]
|
|
1921
1798
|
});
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1799
|
+
let providerConfig = "";
|
|
1800
|
+
if (kind === "static") {
|
|
1801
|
+
providerConfig = (await password({ message: "Principal token:", mask: "*" })).trim();
|
|
1802
|
+
} else if (kind === "oidc") {
|
|
1803
|
+
providerConfig = (await input({ message: "OIDC issuer URL:" })).trim();
|
|
1926
1804
|
}
|
|
1805
|
+
token_provider = { kind, config: providerConfig };
|
|
1927
1806
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
default: isDefault || isFirstVault
|
|
1931
|
-
};
|
|
1932
|
-
isFirstVault = false;
|
|
1933
|
-
addingVaults = await confirm({
|
|
1934
|
-
message: "Add another vault?",
|
|
1935
|
-
default: false
|
|
1936
|
-
});
|
|
1937
|
-
}
|
|
1938
|
-
const defaultCount = Object.values(vaults).filter((v) => v.default).length;
|
|
1939
|
-
if (defaultCount === 0 && Object.keys(vaults).length > 0) {
|
|
1940
|
-
const firstKey = Object.keys(vaults)[0];
|
|
1941
|
-
vaults[firstKey].default = true;
|
|
1807
|
+
} else {
|
|
1808
|
+
console.log(chalk3.dim("Local-only mode \u2014 skipping control-plane and login prompts."));
|
|
1942
1809
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
const
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
const
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
});
|
|
1956
|
-
const
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1810
|
+
const anvil_notes = (await input({
|
|
1811
|
+
message: "Anvil notes repo URL (HTTPS, optional \u2014 leave empty to start with an empty local notes dir):",
|
|
1812
|
+
default: opts.anvilRepo ?? ""
|
|
1813
|
+
})).trim();
|
|
1814
|
+
const data_dir = await input({ message: "Data directory:", default: opts.dataDir ?? DEFAULT_DATA_DIR });
|
|
1815
|
+
const anthropicKey = (await password({ message: "Anthropic API key for agent chat (leave empty to skip):", mask: "*" })).trim();
|
|
1816
|
+
let ports = { ...DEFAULT_PORTS };
|
|
1817
|
+
const customizePorts = await confirm({ message: "Customize port assignments?", default: false });
|
|
1818
|
+
if (customizePorts) {
|
|
1819
|
+
const anvil = await number({ message: "Anvil port:", default: DEFAULT_PORTS.anvil });
|
|
1820
|
+
const ui = await number({ message: "Horus UI port:", default: DEFAULT_PORTS.ui });
|
|
1821
|
+
const typesense = await number({ message: "Typesense port:", default: DEFAULT_PORTS.typesense });
|
|
1822
|
+
const neo4j_http = await number({ message: "Neo4j HTTP port:", default: DEFAULT_PORTS.neo4j_http });
|
|
1823
|
+
const neo4j_bolt = await number({ message: "Neo4j Bolt port:", default: DEFAULT_PORTS.neo4j_bolt });
|
|
1824
|
+
ports = {
|
|
1825
|
+
...ports,
|
|
1826
|
+
anvil: anvil ?? DEFAULT_PORTS.anvil,
|
|
1827
|
+
ui: ui ?? DEFAULT_PORTS.ui,
|
|
1828
|
+
typesense: typesense ?? DEFAULT_PORTS.typesense,
|
|
1829
|
+
neo4j_http: neo4j_http ?? DEFAULT_PORTS.neo4j_http,
|
|
1830
|
+
neo4j_bolt: neo4j_bolt ?? DEFAULT_PORTS.neo4j_bolt
|
|
1960
1831
|
};
|
|
1961
1832
|
}
|
|
1962
|
-
console.log("");
|
|
1963
|
-
console.log(chalk2.bold("NLP Agent Search"));
|
|
1964
|
-
console.log(chalk2.dim("Required for the \u2726 Ask bar in the Horus Reader."));
|
|
1965
|
-
console.log("");
|
|
1966
|
-
const aiKey = await password({
|
|
1967
|
-
message: "Horus AI key (leave empty to configure later):",
|
|
1968
|
-
mask: "*"
|
|
1969
|
-
});
|
|
1970
1833
|
config = {
|
|
1971
|
-
...
|
|
1834
|
+
...defaults,
|
|
1835
|
+
runtime: selectedRuntime,
|
|
1972
1836
|
data_dir,
|
|
1973
|
-
host_repos_path,
|
|
1974
|
-
host_repos_extra_scan_dirs,
|
|
1975
|
-
runtime: runtime.name,
|
|
1976
1837
|
ports,
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
}
|
|
1981
|
-
vaults,
|
|
1982
|
-
github_hosts,
|
|
1983
|
-
ai: {
|
|
1984
|
-
key: aiKey.trim()
|
|
1985
|
-
}
|
|
1838
|
+
control_plane_url,
|
|
1839
|
+
token_provider,
|
|
1840
|
+
repos: { anvil_notes, forge_registry: "" },
|
|
1841
|
+
ai: { key: "", anthropic_api_key: anthropicKey, model: defaults.ai.model }
|
|
1986
1842
|
};
|
|
1987
1843
|
}
|
|
1844
|
+
const runtime = await detectRuntime(selectedRuntime);
|
|
1845
|
+
config.runtime = runtime.name;
|
|
1988
1846
|
const configSpinner = ora2("Saving configuration...").start();
|
|
1989
1847
|
try {
|
|
1990
1848
|
saveConfig(config);
|
|
1991
|
-
|
|
1849
|
+
ensureFsLayout();
|
|
1850
|
+
configSpinner.succeed("Configuration saved to ~/Horus/config.yaml (providers/, logs/, keys/ ready)");
|
|
1992
1851
|
} catch (error) {
|
|
1993
1852
|
configSpinner.fail("Failed to save configuration");
|
|
1994
1853
|
console.error(error.message);
|
|
@@ -2013,108 +1872,57 @@ ${example(`${vaultName.trim()}-knowledge`)}
|
|
|
2013
1872
|
process.exit(1);
|
|
2014
1873
|
}
|
|
2015
1874
|
const dataDir = resolvePath(config.data_dir);
|
|
2016
|
-
|
|
2017
|
-
const
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
token: anvilToken
|
|
2024
|
-
},
|
|
2025
|
-
{
|
|
2026
|
-
url: config.repos.forge_registry,
|
|
2027
|
-
dest: join3(dataDir, "registry"),
|
|
2028
|
-
label: "Forge registry",
|
|
2029
|
-
token: forgeToken
|
|
2030
|
-
}
|
|
2031
|
-
].filter((r) => r.url);
|
|
2032
|
-
for (const [name, vault] of Object.entries(config.vaults)) {
|
|
2033
|
-
if (vault.repo) {
|
|
2034
|
-
const vaultToken = resolveGitHubHost(vault.repo, config.github_hosts)?.token ?? "";
|
|
2035
|
-
reposToClone.push({
|
|
2036
|
-
url: vault.repo,
|
|
2037
|
-
dest: join3(dataDir, "vaults", name),
|
|
2038
|
-
label: `Vault: ${name}`,
|
|
2039
|
-
token: vaultToken
|
|
2040
|
-
});
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
if (reposToClone.length > 0) {
|
|
2044
|
-
console.log("");
|
|
2045
|
-
console.log(chalk2.bold("Cloning repositories..."));
|
|
2046
|
-
mkdirSync3(dataDir, { recursive: true });
|
|
2047
|
-
for (const repo of reposToClone) {
|
|
2048
|
-
const spinner = ora2(`Cloning ${repo.label}...`).start();
|
|
2049
|
-
if (existsSync5(join3(repo.dest, ".git"))) {
|
|
2050
|
-
spinner.succeed(`${repo.label} already cloned`);
|
|
2051
|
-
continue;
|
|
2052
|
-
}
|
|
1875
|
+
mkdirSync3(dataDir, { recursive: true });
|
|
1876
|
+
const notesDest = join3(dataDir, "notes");
|
|
1877
|
+
if (config.repos.anvil_notes) {
|
|
1878
|
+
const spinner = ora2("Cloning Anvil notes...").start();
|
|
1879
|
+
if (existsSync5(join3(notesDest, ".git"))) {
|
|
1880
|
+
spinner.succeed("Anvil notes already cloned");
|
|
1881
|
+
} else {
|
|
2053
1882
|
try {
|
|
2054
|
-
mkdirSync3(
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
stdio: "pipe",
|
|
2058
|
-
timeout: 6e4
|
|
2059
|
-
});
|
|
2060
|
-
spinner.succeed(`${repo.label} cloned`);
|
|
1883
|
+
mkdirSync3(notesDest, { recursive: true });
|
|
1884
|
+
execSync(`git clone "${config.repos.anvil_notes}" "${notesDest}"`, { stdio: "pipe", timeout: 6e4 });
|
|
1885
|
+
spinner.succeed("Anvil notes cloned");
|
|
2061
1886
|
} catch (error) {
|
|
2062
|
-
spinner.fail(
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
} else {
|
|
2067
|
-
console.log(chalk2.dim(` ${msg.split("\n")[0]}`));
|
|
2068
|
-
}
|
|
2069
|
-
console.log(chalk2.dim(` URL: ${repo.url}`));
|
|
2070
|
-
if (!repo.token) {
|
|
2071
|
-
console.log(chalk2.dim(" Tip: Re-run setup and provide a GitHub token if the repo is private."));
|
|
2072
|
-
}
|
|
2073
|
-
process.exit(1);
|
|
1887
|
+
spinner.fail("Failed to clone Anvil notes (continuing with an empty notes dir)");
|
|
1888
|
+
console.log(chalk3.dim(` ${(error.message || "").split("\n")[0]}`));
|
|
1889
|
+
console.log(chalk3.dim(` URL: ${config.repos.anvil_notes}`));
|
|
1890
|
+
mkdirSync3(notesDest, { recursive: true });
|
|
2074
1891
|
}
|
|
2075
1892
|
}
|
|
1893
|
+
} else {
|
|
1894
|
+
mkdirSync3(notesDest, { recursive: true });
|
|
2076
1895
|
}
|
|
2077
|
-
if (
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
mkdirSync3(dir, { recursive: true });
|
|
2081
|
-
}
|
|
2082
|
-
const dirList = forgeDirs.map((d) => `"${d}"`).join(" ");
|
|
1896
|
+
if (opts.pull !== false) {
|
|
1897
|
+
console.log("");
|
|
1898
|
+
console.log(chalk3.bold("Pulling container images..."));
|
|
2083
1899
|
try {
|
|
2084
|
-
|
|
2085
|
-
} catch
|
|
2086
|
-
console.log(
|
|
2087
|
-
console.log(chalk2.dim(" Forge may fail to start if directory ownership is incorrect."));
|
|
2088
|
-
console.log(chalk2.dim(` Run manually: sudo chown -R 1001:1001 ${forgeDirs.join(" ")}`));
|
|
1900
|
+
await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
|
|
1901
|
+
} catch {
|
|
1902
|
+
console.log(chalk3.yellow("Some images could not be pulled \u2014 continuing."));
|
|
2089
1903
|
}
|
|
1904
|
+
} else {
|
|
1905
|
+
console.log(chalk3.dim("Skipping image pull (--no-pull)."));
|
|
2090
1906
|
}
|
|
2091
1907
|
console.log("");
|
|
2092
|
-
console.log(
|
|
2093
|
-
try {
|
|
2094
|
-
await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
|
|
2095
|
-
} catch {
|
|
2096
|
-
console.log(chalk2.yellow("Some images could not be pulled."));
|
|
2097
|
-
console.log(chalk2.dim("Continuing \u2014 services will be built from source if build contexts are available."));
|
|
2098
|
-
}
|
|
2099
|
-
console.log("");
|
|
2100
|
-
console.log(chalk2.bold("Starting Horus services..."));
|
|
1908
|
+
console.log(chalk3.bold("Starting Horus services..."));
|
|
2101
1909
|
try {
|
|
2102
1910
|
await composeStreaming(runtime, ["up", "-d", "--remove-orphans"]);
|
|
2103
1911
|
} catch (error) {
|
|
2104
|
-
console.log(
|
|
2105
|
-
console.log(
|
|
1912
|
+
console.log(chalk3.red("Failed to start services."));
|
|
1913
|
+
console.log(chalk3.dim(error.message));
|
|
2106
1914
|
process.exit(1);
|
|
2107
1915
|
}
|
|
2108
1916
|
console.log("");
|
|
2109
1917
|
const healthSpinner = ora2("Waiting for services to become healthy...").start();
|
|
2110
1918
|
let lastStates = [];
|
|
2111
1919
|
try {
|
|
2112
|
-
|
|
1920
|
+
lastStates = await pollUntilHealthy(
|
|
2113
1921
|
runtime,
|
|
2114
1922
|
(current) => {
|
|
2115
1923
|
lastStates = current;
|
|
2116
1924
|
const summary = current.map((s) => {
|
|
2117
|
-
const icon = s.status === "healthy" ?
|
|
1925
|
+
const icon = s.status === "healthy" ? chalk3.green("*") : s.status === "starting" ? chalk3.yellow("~") : chalk3.red("x");
|
|
2118
1926
|
return `${icon} ${s.name}`;
|
|
2119
1927
|
}).join(" ");
|
|
2120
1928
|
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
@@ -2123,76 +1931,75 @@ ${example(`${vaultName.trim()}-knowledge`)}
|
|
|
2123
1931
|
5e3
|
|
2124
1932
|
);
|
|
2125
1933
|
healthSpinner.succeed("All services are healthy");
|
|
2126
|
-
lastStates = states;
|
|
2127
1934
|
} catch (error) {
|
|
2128
1935
|
healthSpinner.fail("Some services did not become healthy");
|
|
2129
|
-
console.log(
|
|
1936
|
+
console.log(chalk3.dim(error.message));
|
|
1937
|
+
console.log(chalk3.dim("Tip: check logs with `horus status` or `docker compose logs` from ~/Horus/"));
|
|
1938
|
+
}
|
|
1939
|
+
if (config.control_plane_url) {
|
|
2130
1940
|
console.log("");
|
|
2131
|
-
|
|
2132
|
-
|
|
1941
|
+
const loginSpinner = ora2("Logging in to the control plane...").start();
|
|
1942
|
+
const result = await runLogin(config);
|
|
1943
|
+
if (result.ok) loginSpinner.succeed(result.message);
|
|
1944
|
+
else loginSpinner.warn(result.message);
|
|
2133
1945
|
}
|
|
2134
1946
|
console.log("");
|
|
2135
1947
|
const detectedClients = detectInstalledClients();
|
|
2136
1948
|
if (detectedClients.length > 0) {
|
|
2137
|
-
console.log(
|
|
1949
|
+
console.log(chalk3.bold("Configuring AI clients..."));
|
|
2138
1950
|
let clientsToConnect = [...detectedClients];
|
|
2139
1951
|
if (clientsToConnect.includes("claude-desktop")) {
|
|
2140
1952
|
let configureDesktop;
|
|
2141
|
-
if (
|
|
1953
|
+
if (!interactive) {
|
|
2142
1954
|
configureDesktop = opts.claudeDesktop === true;
|
|
2143
|
-
if (!configureDesktop) {
|
|
2144
|
-
console.log(chalk2.dim("Skipping Claude Desktop (pass --claude-desktop to configure it)."));
|
|
2145
|
-
}
|
|
2146
1955
|
} else {
|
|
2147
1956
|
configureDesktop = opts.claudeDesktop === false ? false : await confirm({ message: "Setup for Claude Desktop?", default: true });
|
|
2148
1957
|
}
|
|
2149
|
-
if (!configureDesktop)
|
|
2150
|
-
clientsToConnect = clientsToConnect.filter((c) => c !== "claude-desktop");
|
|
2151
|
-
}
|
|
1958
|
+
if (!configureDesktop) clientsToConnect = clientsToConnect.filter((c) => c !== "claude-desktop");
|
|
2152
1959
|
}
|
|
2153
1960
|
if (clientsToConnect.length > 0) {
|
|
2154
1961
|
try {
|
|
2155
1962
|
await runConnect(config, runtime, clientsToConnect, "localhost");
|
|
2156
|
-
} catch
|
|
2157
|
-
console.log(
|
|
2158
|
-
console.log(
|
|
1963
|
+
} catch {
|
|
1964
|
+
console.log(chalk3.yellow("Could not configure AI clients automatically."));
|
|
1965
|
+
console.log(chalk3.dim(`Run ${chalk3.cyan("horus connect")} to configure them manually.`));
|
|
2159
1966
|
}
|
|
2160
1967
|
}
|
|
2161
1968
|
} else {
|
|
2162
|
-
console.log(
|
|
1969
|
+
console.log(chalk3.dim(`No AI clients detected. Run ${chalk3.cyan("horus connect")} after installing one.`));
|
|
2163
1970
|
}
|
|
1971
|
+
const mode = config.control_plane_url ? "connected" : "local-only";
|
|
2164
1972
|
console.log("");
|
|
2165
|
-
console.log(
|
|
2166
|
-
console.log(
|
|
1973
|
+
console.log(chalk3.bold.green("Setup complete!"));
|
|
1974
|
+
console.log(chalk3.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2167
1975
|
console.log("");
|
|
2168
|
-
console.log(` ${
|
|
2169
|
-
console.log(` ${
|
|
2170
|
-
console.log(` ${
|
|
2171
|
-
console.log("");
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
|
|
2176
|
-
console.log(` Forge: http://localhost:${config.ports.forge}`);
|
|
1976
|
+
console.log(` ${chalk3.bold("Mode:")} ${mode}`);
|
|
1977
|
+
console.log(` ${chalk3.bold("Runtime:")} ${runtime.name}`);
|
|
1978
|
+
console.log(` ${chalk3.bold("Config:")} ~/Horus/config.yaml`);
|
|
1979
|
+
console.log(` ${chalk3.bold("Data:")} ${config.data_dir}`);
|
|
1980
|
+
if (config.control_plane_url) {
|
|
1981
|
+
console.log(` ${chalk3.bold("Control plane:")} ${config.control_plane_url}`);
|
|
1982
|
+
}
|
|
2177
1983
|
console.log("");
|
|
2178
|
-
console.log(
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
const defaultLabel = vault.default ? chalk2.dim(" (default)") : "";
|
|
2182
|
-
console.log(` ${name}${defaultLabel}: http://localhost:${port}`);
|
|
2183
|
-
});
|
|
1984
|
+
console.log(chalk3.bold(" Service URLs:"));
|
|
1985
|
+
console.log(` Horus UI: http://localhost:${config.ports.ui}`);
|
|
1986
|
+
console.log(` Anvil: http://localhost:${config.ports.anvil}`);
|
|
2184
1987
|
console.log("");
|
|
1988
|
+
if (mode === "local-only") {
|
|
1989
|
+
console.log(chalk3.dim(" Vault, Forge, and Admin require a control plane \u2014 not available in local-only mode."));
|
|
1990
|
+
console.log("");
|
|
1991
|
+
}
|
|
2185
1992
|
void lastStates;
|
|
2186
1993
|
});
|
|
2187
1994
|
|
|
2188
1995
|
// src/commands/up.ts
|
|
2189
|
-
import { Command as
|
|
2190
|
-
import
|
|
1996
|
+
import { Command as Command4 } from "commander";
|
|
1997
|
+
import chalk4 from "chalk";
|
|
2191
1998
|
import ora3 from "ora";
|
|
2192
|
-
var upCommand = new
|
|
1999
|
+
var upCommand = new Command4("up").description("Start the Horus stack").option("--no-pull", "Skip pulling latest images before starting").action(async (opts) => {
|
|
2193
2000
|
if (!configExists() || !composeFileExists()) {
|
|
2194
|
-
console.log(
|
|
2195
|
-
console.log(
|
|
2001
|
+
console.log(chalk4.red("Horus is not set up yet."));
|
|
2002
|
+
console.log(chalk4.dim("Run `horus setup` first."));
|
|
2196
2003
|
process.exit(1);
|
|
2197
2004
|
}
|
|
2198
2005
|
const config = loadConfig();
|
|
@@ -2200,7 +2007,7 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
|
|
|
2200
2007
|
let runtime;
|
|
2201
2008
|
try {
|
|
2202
2009
|
runtime = await detectRuntime(config.runtime);
|
|
2203
|
-
spinner.succeed(`Using ${
|
|
2010
|
+
spinner.succeed(`Using ${chalk4.cyan(runtime.name)}`);
|
|
2204
2011
|
} catch (error) {
|
|
2205
2012
|
spinner.fail("No container runtime found");
|
|
2206
2013
|
console.log(error.message);
|
|
@@ -2208,25 +2015,25 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
|
|
|
2208
2015
|
}
|
|
2209
2016
|
if (opts.pull) {
|
|
2210
2017
|
console.log("");
|
|
2211
|
-
console.log(
|
|
2018
|
+
console.log(chalk4.bold("Pulling latest images..."));
|
|
2212
2019
|
try {
|
|
2213
2020
|
await composeStreaming(runtime, ["pull"]);
|
|
2214
2021
|
console.log("");
|
|
2215
|
-
console.log(
|
|
2022
|
+
console.log(chalk4.green("\u2713 Pull complete"));
|
|
2216
2023
|
} catch {
|
|
2217
2024
|
console.log("");
|
|
2218
|
-
console.log(
|
|
2219
|
-
console.log(
|
|
2025
|
+
console.log(chalk4.yellow("\u26A0 Warning: failed to pull one or more images \u2014 using cached versions."));
|
|
2026
|
+
console.log(chalk4.dim(" Run `docker compose pull` to see which services failed."));
|
|
2220
2027
|
}
|
|
2221
2028
|
}
|
|
2222
2029
|
console.log("");
|
|
2223
|
-
console.log(
|
|
2030
|
+
console.log(chalk4.bold("Starting Horus services..."));
|
|
2224
2031
|
try {
|
|
2225
2032
|
const upArgs = opts.pull ? ["up", "-d", "--force-recreate", "--remove-orphans"] : ["up", "-d", "--remove-orphans"];
|
|
2226
2033
|
await composeStreaming(runtime, upArgs);
|
|
2227
2034
|
} catch (error) {
|
|
2228
|
-
console.log(
|
|
2229
|
-
console.log(
|
|
2035
|
+
console.log(chalk4.red("Failed to start services."));
|
|
2036
|
+
console.log(chalk4.dim(error.message));
|
|
2230
2037
|
process.exit(1);
|
|
2231
2038
|
}
|
|
2232
2039
|
console.log("");
|
|
@@ -2234,16 +2041,16 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
|
|
|
2234
2041
|
try {
|
|
2235
2042
|
const states = await checkAllHealth(runtime);
|
|
2236
2043
|
statusSpinner.stop();
|
|
2237
|
-
console.log(
|
|
2044
|
+
console.log(chalk4.bold("Service Status:"));
|
|
2238
2045
|
for (const s of states) {
|
|
2239
|
-
const color = s.status === "healthy" ?
|
|
2046
|
+
const color = s.status === "healthy" ? chalk4.green : s.status === "starting" ? chalk4.yellow : chalk4.red;
|
|
2240
2047
|
console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
|
|
2241
2048
|
}
|
|
2242
2049
|
const allHealthy = states.every((s) => s.status === "healthy");
|
|
2243
2050
|
if (!allHealthy) {
|
|
2244
2051
|
console.log("");
|
|
2245
2052
|
console.log(
|
|
2246
|
-
|
|
2053
|
+
chalk4.yellow("Some services are still starting. Run `horus status` to check progress.")
|
|
2247
2054
|
);
|
|
2248
2055
|
}
|
|
2249
2056
|
} catch {
|
|
@@ -2253,13 +2060,13 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
|
|
|
2253
2060
|
});
|
|
2254
2061
|
|
|
2255
2062
|
// src/commands/down.ts
|
|
2256
|
-
import { Command as
|
|
2257
|
-
import
|
|
2063
|
+
import { Command as Command5 } from "commander";
|
|
2064
|
+
import chalk5 from "chalk";
|
|
2258
2065
|
import ora4 from "ora";
|
|
2259
|
-
var downCommand = new
|
|
2066
|
+
var downCommand = new Command5("down").description("Stop the Horus stack").action(async () => {
|
|
2260
2067
|
if (!configExists() || !composeFileExists()) {
|
|
2261
|
-
console.log(
|
|
2262
|
-
console.log(
|
|
2068
|
+
console.log(chalk5.red("Horus is not set up yet."));
|
|
2069
|
+
console.log(chalk5.dim("Run `horus setup` first."));
|
|
2263
2070
|
process.exit(1);
|
|
2264
2071
|
}
|
|
2265
2072
|
const config = loadConfig();
|
|
@@ -2267,35 +2074,35 @@ var downCommand = new Command4("down").description("Stop the Horus stack").actio
|
|
|
2267
2074
|
let runtime;
|
|
2268
2075
|
try {
|
|
2269
2076
|
runtime = await detectRuntime(config.runtime);
|
|
2270
|
-
spinner.succeed(`Using ${
|
|
2077
|
+
spinner.succeed(`Using ${chalk5.cyan(runtime.name)}`);
|
|
2271
2078
|
} catch (error) {
|
|
2272
2079
|
spinner.fail("No container runtime found");
|
|
2273
2080
|
console.log(error.message);
|
|
2274
2081
|
process.exit(1);
|
|
2275
2082
|
}
|
|
2276
2083
|
console.log("");
|
|
2277
|
-
console.log(
|
|
2084
|
+
console.log(chalk5.bold("Stopping Horus services..."));
|
|
2278
2085
|
try {
|
|
2279
2086
|
await composeStreaming(runtime, ["down"]);
|
|
2280
2087
|
} catch (error) {
|
|
2281
|
-
console.log(
|
|
2282
|
-
console.log(
|
|
2088
|
+
console.log(chalk5.red("Failed to stop services."));
|
|
2089
|
+
console.log(chalk5.dim(error.message));
|
|
2283
2090
|
process.exit(1);
|
|
2284
2091
|
}
|
|
2285
2092
|
console.log("");
|
|
2286
|
-
console.log(
|
|
2287
|
-
console.log(
|
|
2093
|
+
console.log(chalk5.green("All services stopped."));
|
|
2094
|
+
console.log(chalk5.dim("Data volumes have been preserved. Run `horus up` to restart."));
|
|
2288
2095
|
console.log("");
|
|
2289
2096
|
});
|
|
2290
2097
|
|
|
2291
2098
|
// src/commands/status.ts
|
|
2292
|
-
import { Command as
|
|
2293
|
-
import
|
|
2099
|
+
import { Command as Command6 } from "commander";
|
|
2100
|
+
import chalk6 from "chalk";
|
|
2294
2101
|
import ora5 from "ora";
|
|
2295
|
-
var statusCommand = new
|
|
2102
|
+
var statusCommand = new Command6("status").description("Show status of Horus services").action(async () => {
|
|
2296
2103
|
if (!configExists() || !composeFileExists()) {
|
|
2297
|
-
console.log(
|
|
2298
|
-
console.log(
|
|
2104
|
+
console.log(chalk6.red("Horus is not set up yet."));
|
|
2105
|
+
console.log(chalk6.dim("Run `horus setup` first."));
|
|
2299
2106
|
process.exit(1);
|
|
2300
2107
|
}
|
|
2301
2108
|
const config = loadConfig();
|
|
@@ -2317,28 +2124,28 @@ var statusCommand = new Command5("status").description("Show status of Horus ser
|
|
|
2317
2124
|
}
|
|
2318
2125
|
spinner.stop();
|
|
2319
2126
|
console.log("");
|
|
2320
|
-
console.log(
|
|
2321
|
-
console.log(
|
|
2322
|
-
console.log(` ${
|
|
2323
|
-
console.log(` ${
|
|
2324
|
-
console.log(` ${
|
|
2127
|
+
console.log(chalk6.bold("Horus Status"));
|
|
2128
|
+
console.log(chalk6.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2129
|
+
console.log(` ${chalk6.bold("Version:")} ${CLI_VERSION}`);
|
|
2130
|
+
console.log(` ${chalk6.bold("Runtime:")} ${runtime.name}`);
|
|
2131
|
+
console.log(` ${chalk6.bold("Config:")} ~/Horus/config.yaml`);
|
|
2325
2132
|
console.log("");
|
|
2326
2133
|
if (containers.length === 0) {
|
|
2327
|
-
console.log(
|
|
2328
|
-
console.log(
|
|
2134
|
+
console.log(chalk6.yellow(" No services are running."));
|
|
2135
|
+
console.log(chalk6.dim(" Run `horus up` to start the stack."));
|
|
2329
2136
|
console.log("");
|
|
2330
2137
|
return;
|
|
2331
2138
|
}
|
|
2332
2139
|
const header = ` ${pad("SERVICE", 14)} ${pad("STATUS", 12)} ${pad("PORTS", 20)} ${pad("UPTIME", 20)}`;
|
|
2333
|
-
console.log(
|
|
2334
|
-
console.log(
|
|
2140
|
+
console.log(chalk6.bold(header));
|
|
2141
|
+
console.log(chalk6.dim(" " + "\u2500".repeat(66)));
|
|
2335
2142
|
for (const service of SERVICES) {
|
|
2336
2143
|
const container = containers.find(
|
|
2337
2144
|
(c) => c.Service === service || c.Name?.includes(service)
|
|
2338
2145
|
);
|
|
2339
2146
|
if (!container) {
|
|
2340
2147
|
console.log(
|
|
2341
|
-
` ${pad(service, 14)} ${
|
|
2148
|
+
` ${pad(service, 14)} ${chalk6.red(pad("stopped", 12))} ${pad("-", 20)} ${pad("-", 20)}`
|
|
2342
2149
|
);
|
|
2343
2150
|
continue;
|
|
2344
2151
|
}
|
|
@@ -2356,9 +2163,9 @@ function pad(str, width) {
|
|
|
2356
2163
|
}
|
|
2357
2164
|
function getStatusColor(status) {
|
|
2358
2165
|
const lower = status.toLowerCase();
|
|
2359
|
-
if (lower === "healthy" || lower === "running") return
|
|
2360
|
-
if (lower === "starting") return
|
|
2361
|
-
return
|
|
2166
|
+
if (lower === "healthy" || lower === "running") return chalk6.green;
|
|
2167
|
+
if (lower === "starting") return chalk6.yellow;
|
|
2168
|
+
return chalk6.red;
|
|
2362
2169
|
}
|
|
2363
2170
|
function formatPorts(publishers) {
|
|
2364
2171
|
if (!publishers || publishers.length === 0) return "-";
|
|
@@ -2373,69 +2180,69 @@ function extractUptime(status) {
|
|
|
2373
2180
|
}
|
|
2374
2181
|
|
|
2375
2182
|
// src/commands/config.ts
|
|
2376
|
-
import { Command as
|
|
2377
|
-
import
|
|
2183
|
+
import { Command as Command7 } from "commander";
|
|
2184
|
+
import chalk7 from "chalk";
|
|
2378
2185
|
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
2379
|
-
var configCommand = new
|
|
2186
|
+
var configCommand = new Command7("config").description("View or modify Horus configuration").action(async () => {
|
|
2380
2187
|
if (!configExists()) {
|
|
2381
|
-
console.log(
|
|
2382
|
-
console.log(
|
|
2188
|
+
console.log(chalk7.red("Horus is not configured yet."));
|
|
2189
|
+
console.log(chalk7.dim("Run `horus setup` first."));
|
|
2383
2190
|
process.exit(1);
|
|
2384
2191
|
}
|
|
2385
2192
|
const config = loadConfig();
|
|
2386
2193
|
console.log("");
|
|
2387
|
-
console.log(
|
|
2388
|
-
console.log(
|
|
2389
|
-
console.log(` ${
|
|
2390
|
-
console.log(` ${
|
|
2391
|
-
console.log(` ${
|
|
2392
|
-
console.log(` ${
|
|
2194
|
+
console.log(chalk7.bold("Horus Configuration"));
|
|
2195
|
+
console.log(chalk7.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2196
|
+
console.log(` ${chalk7.bold("version:")} ${config.version}`);
|
|
2197
|
+
console.log(` ${chalk7.bold("data-dir:")} ${config.data_dir}`);
|
|
2198
|
+
console.log(` ${chalk7.bold("runtime:")} ${config.runtime}`);
|
|
2199
|
+
console.log(` ${chalk7.bold("host-repos-path:")} ${config.host_repos_path || chalk7.dim("(not set)")}`);
|
|
2393
2200
|
const extraDirs = (config.host_repos_extra_scan_dirs ?? []).join(", ");
|
|
2394
|
-
console.log(` ${
|
|
2201
|
+
console.log(` ${chalk7.bold("host-repos-extra-scan-dirs:")} ${extraDirs || chalk7.dim("(not set)")}`);
|
|
2395
2202
|
console.log("");
|
|
2396
|
-
console.log(
|
|
2397
|
-
console.log(` ${
|
|
2398
|
-
console.log(` ${
|
|
2399
|
-
console.log(` ${
|
|
2400
|
-
console.log(` ${
|
|
2401
|
-
console.log(` ${
|
|
2203
|
+
console.log(chalk7.bold(" Ports:"));
|
|
2204
|
+
console.log(` ${chalk7.bold("anvil:")} ${config.ports.anvil}`);
|
|
2205
|
+
console.log(` ${chalk7.bold("vault-rest:")} ${config.ports.vault_rest}`);
|
|
2206
|
+
console.log(` ${chalk7.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
|
|
2207
|
+
console.log(` ${chalk7.bold("vault-router:")} ${config.ports.vault_router}`);
|
|
2208
|
+
console.log(` ${chalk7.bold("forge:")} ${config.ports.forge}`);
|
|
2402
2209
|
console.log("");
|
|
2403
|
-
console.log(
|
|
2404
|
-
console.log(` ${
|
|
2405
|
-
console.log(` ${
|
|
2210
|
+
console.log(chalk7.bold(" Repos:"));
|
|
2211
|
+
console.log(` ${chalk7.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk7.dim("(not set)")}`);
|
|
2212
|
+
console.log(` ${chalk7.bold("forge-registry:")} ${config.repos.forge_registry || chalk7.dim("(not set)")}`);
|
|
2406
2213
|
console.log("");
|
|
2407
|
-
console.log(
|
|
2214
|
+
console.log(chalk7.bold(" Vaults:"));
|
|
2408
2215
|
if (Object.keys(config.vaults ?? {}).length === 0) {
|
|
2409
|
-
console.log(
|
|
2216
|
+
console.log(chalk7.dim(" (none configured)"));
|
|
2410
2217
|
} else {
|
|
2411
2218
|
for (const [name, vault] of Object.entries(config.vaults)) {
|
|
2412
|
-
const defaultLabel = vault.default ?
|
|
2413
|
-
console.log(` ${
|
|
2219
|
+
const defaultLabel = vault.default ? chalk7.dim(" (default)") : "";
|
|
2220
|
+
console.log(` ${chalk7.bold(name)}${defaultLabel}: ${vault.repo || chalk7.dim("(no repo)")}`);
|
|
2414
2221
|
}
|
|
2415
2222
|
}
|
|
2416
2223
|
console.log("");
|
|
2417
|
-
console.log(
|
|
2224
|
+
console.log(chalk7.bold(" GitHub Hosts:"));
|
|
2418
2225
|
if (Object.keys(config.github_hosts ?? {}).length === 0) {
|
|
2419
|
-
console.log(
|
|
2226
|
+
console.log(chalk7.dim(" (none configured)"));
|
|
2420
2227
|
} else {
|
|
2421
2228
|
for (const [key, gh] of Object.entries(config.github_hosts)) {
|
|
2422
|
-
console.log(` ${
|
|
2229
|
+
console.log(` ${chalk7.bold(key)}: ${gh.host} token: ${gh.token ? maskApiKey(gh.token) : chalk7.dim("(not set)")}`);
|
|
2423
2230
|
}
|
|
2424
2231
|
}
|
|
2425
2232
|
console.log("");
|
|
2426
|
-
console.log(
|
|
2427
|
-
console.log(
|
|
2233
|
+
console.log(chalk7.dim(` Config file: ~/Horus/config.yaml`));
|
|
2234
|
+
console.log(chalk7.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
|
|
2428
2235
|
console.log("");
|
|
2429
2236
|
});
|
|
2430
2237
|
configCommand.command("get <key>").description("Get a configuration value").action(async (key) => {
|
|
2431
2238
|
if (!configExists()) {
|
|
2432
|
-
console.log(
|
|
2433
|
-
console.log(
|
|
2239
|
+
console.log(chalk7.red("Horus is not configured yet."));
|
|
2240
|
+
console.log(chalk7.dim("Run `horus setup` first."));
|
|
2434
2241
|
process.exit(1);
|
|
2435
2242
|
}
|
|
2436
2243
|
if (!isValidKey(key)) {
|
|
2437
|
-
console.log(
|
|
2438
|
-
console.log(
|
|
2244
|
+
console.log(chalk7.red(`Unknown config key: ${key}`));
|
|
2245
|
+
console.log(chalk7.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
|
|
2439
2246
|
process.exit(1);
|
|
2440
2247
|
}
|
|
2441
2248
|
const config = loadConfig();
|
|
@@ -2444,25 +2251,26 @@ configCommand.command("get <key>").description("Get a configuration value").acti
|
|
|
2444
2251
|
});
|
|
2445
2252
|
configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
|
|
2446
2253
|
if (!configExists()) {
|
|
2447
|
-
console.log(
|
|
2448
|
-
console.log(
|
|
2254
|
+
console.log(chalk7.red("Horus is not configured yet."));
|
|
2255
|
+
console.log(chalk7.dim("Run `horus setup` first."));
|
|
2449
2256
|
process.exit(1);
|
|
2450
2257
|
}
|
|
2451
2258
|
if (!isValidKey(key)) {
|
|
2452
|
-
console.log(
|
|
2453
|
-
console.log(
|
|
2259
|
+
console.log(chalk7.red(`Unknown config key: ${key}`));
|
|
2260
|
+
console.log(chalk7.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
|
|
2454
2261
|
process.exit(1);
|
|
2455
2262
|
}
|
|
2456
2263
|
let config = loadConfig();
|
|
2457
2264
|
try {
|
|
2458
2265
|
config = setConfigValue(config, key, value);
|
|
2459
2266
|
} catch (error) {
|
|
2460
|
-
console.log(
|
|
2267
|
+
console.log(chalk7.red(error.message));
|
|
2461
2268
|
process.exit(1);
|
|
2462
2269
|
}
|
|
2463
2270
|
saveConfig(config);
|
|
2464
2271
|
writeEnvFile(config);
|
|
2465
|
-
|
|
2272
|
+
installComposeFile(config, config.runtime === "podman" ? "podman" : "docker");
|
|
2273
|
+
console.log(chalk7.green(`Set ${key} and regenerated .env + docker-compose.yml.`));
|
|
2466
2274
|
const needsRestart = [
|
|
2467
2275
|
"data-dir",
|
|
2468
2276
|
"host-repos-path",
|
|
@@ -2475,17 +2283,17 @@ configCommand.command("set <key> <value>").description("Set a configuration valu
|
|
|
2475
2283
|
"port.forge"
|
|
2476
2284
|
];
|
|
2477
2285
|
if (needsRestart.includes(key)) {
|
|
2478
|
-
console.log(
|
|
2286
|
+
console.log(chalk7.yellow("Restart required for changes to take effect."));
|
|
2479
2287
|
if (process.stdin.isTTY) {
|
|
2480
2288
|
const restart = await confirm2({
|
|
2481
2289
|
message: "Restart Horus now?",
|
|
2482
2290
|
default: false
|
|
2483
2291
|
});
|
|
2484
2292
|
if (restart) {
|
|
2485
|
-
console.log(
|
|
2293
|
+
console.log(chalk7.dim("Run `horus down && horus up` to restart."));
|
|
2486
2294
|
}
|
|
2487
2295
|
} else {
|
|
2488
|
-
console.log(
|
|
2296
|
+
console.log(chalk7.dim("Run `horus down && horus up` to restart."));
|
|
2489
2297
|
}
|
|
2490
2298
|
}
|
|
2491
2299
|
});
|
|
@@ -2494,8 +2302,8 @@ function isValidKey(key) {
|
|
|
2494
2302
|
}
|
|
2495
2303
|
|
|
2496
2304
|
// src/commands/update.ts
|
|
2497
|
-
import { Command as
|
|
2498
|
-
import
|
|
2305
|
+
import { Command as Command8 } from "commander";
|
|
2306
|
+
import chalk8 from "chalk";
|
|
2499
2307
|
import ora6 from "ora";
|
|
2500
2308
|
import { select as select2, confirm as confirm3 } from "@inquirer/prompts";
|
|
2501
2309
|
import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2, existsSync as existsSync6 } from "fs";
|
|
@@ -2562,17 +2370,17 @@ async function fetchLatestVersion() {
|
|
|
2562
2370
|
return null;
|
|
2563
2371
|
}
|
|
2564
2372
|
}
|
|
2565
|
-
var updateCommand = new
|
|
2373
|
+
var updateCommand = new Command8("update").description("Update Horus to the latest version").option("--rollback", "Roll back to the previous version").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
|
|
2566
2374
|
console.log("");
|
|
2567
|
-
console.log(
|
|
2568
|
-
console.log(
|
|
2375
|
+
console.log(chalk8.bold(opts.rollback ? "Horus Rollback" : "Horus Update"));
|
|
2376
|
+
console.log(chalk8.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2569
2377
|
console.log("");
|
|
2570
2378
|
const config = loadConfig();
|
|
2571
2379
|
const runtimeSpinner = ora6("Detecting runtime...").start();
|
|
2572
2380
|
let runtime;
|
|
2573
2381
|
try {
|
|
2574
2382
|
runtime = await detectRuntime(config.runtime);
|
|
2575
|
-
runtimeSpinner.succeed(`Using ${
|
|
2383
|
+
runtimeSpinner.succeed(`Using ${chalk8.cyan(runtime.name)}`);
|
|
2576
2384
|
} catch (error) {
|
|
2577
2385
|
runtimeSpinner.fail("No container runtime found");
|
|
2578
2386
|
console.log(error.message);
|
|
@@ -2581,14 +2389,14 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2581
2389
|
if (opts.rollback) {
|
|
2582
2390
|
const snapshots = listSnapshots();
|
|
2583
2391
|
if (snapshots.length === 0) {
|
|
2584
|
-
console.log(
|
|
2585
|
-
console.log(
|
|
2392
|
+
console.log(chalk8.red("No snapshots found. Cannot roll back."));
|
|
2393
|
+
console.log(chalk8.dim(`Snapshots are stored in ${SNAPSHOTS_DIR}`));
|
|
2586
2394
|
process.exit(1);
|
|
2587
2395
|
}
|
|
2588
2396
|
let snapshotToRestore;
|
|
2589
2397
|
if (opts.yes) {
|
|
2590
2398
|
snapshotToRestore = snapshots[0].snapshot;
|
|
2591
|
-
console.log(`Using most recent snapshot: ${
|
|
2399
|
+
console.log(`Using most recent snapshot: ${chalk8.cyan(snapshotToRestore.timestamp)}`);
|
|
2592
2400
|
} else {
|
|
2593
2401
|
const choices = snapshots.map(({ snapshot }, i) => ({
|
|
2594
2402
|
name: `${snapshot.timestamp} (images: ${Object.keys(snapshot.images).length})`,
|
|
@@ -2606,7 +2414,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2606
2414
|
default: false
|
|
2607
2415
|
});
|
|
2608
2416
|
if (!confirmed) {
|
|
2609
|
-
console.log(
|
|
2417
|
+
console.log(chalk8.dim("Rollback cancelled."));
|
|
2610
2418
|
return;
|
|
2611
2419
|
}
|
|
2612
2420
|
}
|
|
@@ -2616,16 +2424,16 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2616
2424
|
stopSpinner.succeed("Services stopped");
|
|
2617
2425
|
} catch (error) {
|
|
2618
2426
|
stopSpinner.fail("Failed to stop services");
|
|
2619
|
-
console.log(
|
|
2427
|
+
console.log(chalk8.dim(error.message));
|
|
2620
2428
|
process.exit(1);
|
|
2621
2429
|
}
|
|
2622
2430
|
console.log("");
|
|
2623
|
-
console.log(
|
|
2431
|
+
console.log(chalk8.bold("Restarting from snapshot (using cached images)..."));
|
|
2624
2432
|
try {
|
|
2625
2433
|
await composeStreaming(runtime, ["up", "-d", "--remove-orphans"]);
|
|
2626
2434
|
} catch (error) {
|
|
2627
|
-
console.log(
|
|
2628
|
-
console.log(
|
|
2435
|
+
console.log(chalk8.red("Failed to restart services."));
|
|
2436
|
+
console.log(chalk8.dim(error.message));
|
|
2629
2437
|
process.exit(1);
|
|
2630
2438
|
}
|
|
2631
2439
|
console.log("");
|
|
@@ -2635,7 +2443,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2635
2443
|
runtime,
|
|
2636
2444
|
(current) => {
|
|
2637
2445
|
const summary = current.map((s) => {
|
|
2638
|
-
const icon = s.status === "healthy" ?
|
|
2446
|
+
const icon = s.status === "healthy" ? chalk8.green("*") : s.status === "starting" ? chalk8.yellow("~") : chalk8.red("x");
|
|
2639
2447
|
return `${icon} ${s.name}`;
|
|
2640
2448
|
}).join(" ");
|
|
2641
2449
|
healthSpinner2.text = `Waiting... ${summary}`;
|
|
@@ -2646,11 +2454,11 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2646
2454
|
healthSpinner2.succeed("All services healthy after rollback");
|
|
2647
2455
|
} catch (error) {
|
|
2648
2456
|
healthSpinner2.fail("Some services did not become healthy");
|
|
2649
|
-
console.log(
|
|
2457
|
+
console.log(chalk8.dim(error.message));
|
|
2650
2458
|
process.exit(1);
|
|
2651
2459
|
}
|
|
2652
2460
|
console.log("");
|
|
2653
|
-
console.log(
|
|
2461
|
+
console.log(chalk8.bold.green("Rollback complete!"));
|
|
2654
2462
|
console.log("");
|
|
2655
2463
|
return;
|
|
2656
2464
|
}
|
|
@@ -2661,14 +2469,14 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2661
2469
|
]);
|
|
2662
2470
|
versionSpinner.stop();
|
|
2663
2471
|
if (latestVersion) {
|
|
2664
|
-
console.log(` Latest release: ${
|
|
2472
|
+
console.log(` Latest release: ${chalk8.cyan(latestVersion)}`);
|
|
2665
2473
|
} else {
|
|
2666
|
-
console.log(
|
|
2474
|
+
console.log(chalk8.dim(" Could not reach GitHub to check latest version."));
|
|
2667
2475
|
}
|
|
2668
2476
|
console.log("");
|
|
2669
|
-
console.log(
|
|
2670
|
-
console.log(
|
|
2671
|
-
console.log(` ${
|
|
2477
|
+
console.log(chalk8.dim(" Note: this updates the Horus container services only."));
|
|
2478
|
+
console.log(chalk8.dim(" To update the Horus CLI itself, run:"));
|
|
2479
|
+
console.log(` ${chalk8.cyan("npm install -g @arkhera30/cli@latest")}`);
|
|
2672
2480
|
console.log("");
|
|
2673
2481
|
if (!opts.yes) {
|
|
2674
2482
|
const confirmed = await confirm3({
|
|
@@ -2676,7 +2484,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2676
2484
|
default: true
|
|
2677
2485
|
});
|
|
2678
2486
|
if (!confirmed) {
|
|
2679
|
-
console.log(
|
|
2487
|
+
console.log(chalk8.dim("Update cancelled."));
|
|
2680
2488
|
return;
|
|
2681
2489
|
}
|
|
2682
2490
|
}
|
|
@@ -2684,10 +2492,10 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2684
2492
|
let snapshotPath = "";
|
|
2685
2493
|
try {
|
|
2686
2494
|
snapshotPath = saveSnapshot(currentImages);
|
|
2687
|
-
snapshotSpinner.succeed(`Snapshot saved: ${
|
|
2495
|
+
snapshotSpinner.succeed(`Snapshot saved: ${chalk8.dim(snapshotPath)}`);
|
|
2688
2496
|
} catch (error) {
|
|
2689
2497
|
snapshotSpinner.warn("Could not save snapshot (update will proceed)");
|
|
2690
|
-
console.log(
|
|
2498
|
+
console.log(chalk8.dim(error.message));
|
|
2691
2499
|
}
|
|
2692
2500
|
const composeSpinner = ora6("Updating compose configuration...").start();
|
|
2693
2501
|
try {
|
|
@@ -2696,23 +2504,23 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2696
2504
|
composeSpinner.succeed("Compose configuration updated");
|
|
2697
2505
|
} catch (error) {
|
|
2698
2506
|
composeSpinner.warn("Could not update compose configuration \u2014 using existing file");
|
|
2699
|
-
console.log(
|
|
2507
|
+
console.log(chalk8.dim(error.message));
|
|
2700
2508
|
}
|
|
2701
2509
|
console.log("");
|
|
2702
|
-
console.log(
|
|
2510
|
+
console.log(chalk8.bold("Pulling latest images..."));
|
|
2703
2511
|
try {
|
|
2704
2512
|
await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
|
|
2705
2513
|
} catch {
|
|
2706
|
-
console.log(
|
|
2707
|
-
console.log(
|
|
2514
|
+
console.log(chalk8.yellow("Some images could not be pulled."));
|
|
2515
|
+
console.log(chalk8.dim("Continuing \u2014 services will be built from source if build contexts are available."));
|
|
2708
2516
|
}
|
|
2709
2517
|
console.log("");
|
|
2710
|
-
console.log(
|
|
2518
|
+
console.log(chalk8.bold("Restarting services..."));
|
|
2711
2519
|
try {
|
|
2712
2520
|
await composeStreaming(runtime, ["up", "-d", "--force-recreate", "--remove-orphans"]);
|
|
2713
2521
|
} catch (error) {
|
|
2714
|
-
console.log(
|
|
2715
|
-
console.log(
|
|
2522
|
+
console.log(chalk8.red("Failed to restart services."));
|
|
2523
|
+
console.log(chalk8.dim(error.message));
|
|
2716
2524
|
process.exit(1);
|
|
2717
2525
|
}
|
|
2718
2526
|
console.log("");
|
|
@@ -2723,7 +2531,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2723
2531
|
runtime,
|
|
2724
2532
|
(current) => {
|
|
2725
2533
|
const summary = current.map((s) => {
|
|
2726
|
-
const icon = s.status === "healthy" ?
|
|
2534
|
+
const icon = s.status === "healthy" ? chalk8.green("*") : s.status === "starting" ? chalk8.yellow("~") : chalk8.red("x");
|
|
2727
2535
|
return `${icon} ${s.name}`;
|
|
2728
2536
|
}).join(" ");
|
|
2729
2537
|
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
@@ -2734,55 +2542,55 @@ var updateCommand = new Command7("update").description("Update Horus to the late
|
|
|
2734
2542
|
healthSpinner.succeed("All services healthy");
|
|
2735
2543
|
} catch (error) {
|
|
2736
2544
|
healthSpinner.fail("Some services did not become healthy");
|
|
2737
|
-
console.log(
|
|
2545
|
+
console.log(chalk8.dim(error.message));
|
|
2738
2546
|
console.log("");
|
|
2739
|
-
console.log(
|
|
2547
|
+
console.log(chalk8.dim(`Tip: Roll back with \`horus update --rollback\``));
|
|
2740
2548
|
process.exit(1);
|
|
2741
2549
|
}
|
|
2742
2550
|
console.log("");
|
|
2743
|
-
console.log(
|
|
2744
|
-
console.log(
|
|
2551
|
+
console.log(chalk8.bold.green("Update complete!"));
|
|
2552
|
+
console.log(chalk8.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2745
2553
|
if (latestVersion) {
|
|
2746
|
-
console.log(` ${
|
|
2554
|
+
console.log(` ${chalk8.bold("Version:")} ${latestVersion}`);
|
|
2747
2555
|
}
|
|
2748
2556
|
console.log("");
|
|
2749
|
-
console.log(
|
|
2557
|
+
console.log(chalk8.bold(" Service Status:"));
|
|
2750
2558
|
for (const s of finalStates) {
|
|
2751
|
-
const color = s.status === "healthy" ?
|
|
2559
|
+
const color = s.status === "healthy" ? chalk8.green : s.status === "starting" ? chalk8.yellow : chalk8.red;
|
|
2752
2560
|
console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
|
|
2753
2561
|
}
|
|
2754
2562
|
if (snapshotPath) {
|
|
2755
2563
|
console.log("");
|
|
2756
|
-
console.log(
|
|
2757
|
-
console.log(
|
|
2564
|
+
console.log(chalk8.dim(` Snapshot saved for rollback: ${snapshotPath}`));
|
|
2565
|
+
console.log(chalk8.dim(" Run `horus update --rollback` to revert if needed."));
|
|
2758
2566
|
}
|
|
2759
2567
|
console.log("");
|
|
2760
2568
|
});
|
|
2761
2569
|
|
|
2762
2570
|
// src/commands/doctor.ts
|
|
2763
|
-
import { Command as
|
|
2764
|
-
import
|
|
2571
|
+
import { Command as Command9 } from "commander";
|
|
2572
|
+
import chalk9 from "chalk";
|
|
2765
2573
|
import { execSync as execSync2 } from "child_process";
|
|
2766
2574
|
import { existsSync as existsSync7, accessSync, statfsSync, constants } from "fs";
|
|
2767
2575
|
import { join as join5 } from "path";
|
|
2768
2576
|
function symbol(status) {
|
|
2769
2577
|
switch (status) {
|
|
2770
2578
|
case "pass":
|
|
2771
|
-
return
|
|
2579
|
+
return chalk9.green(" \u2713 ");
|
|
2772
2580
|
case "warn":
|
|
2773
|
-
return
|
|
2581
|
+
return chalk9.yellow(" \u26A0 ");
|
|
2774
2582
|
case "fail":
|
|
2775
|
-
return
|
|
2583
|
+
return chalk9.red(" \u2717 ");
|
|
2776
2584
|
}
|
|
2777
2585
|
}
|
|
2778
2586
|
function colorMessage(status, msg) {
|
|
2779
2587
|
switch (status) {
|
|
2780
2588
|
case "pass":
|
|
2781
|
-
return
|
|
2589
|
+
return chalk9.white(msg);
|
|
2782
2590
|
case "warn":
|
|
2783
|
-
return
|
|
2591
|
+
return chalk9.yellow(msg);
|
|
2784
2592
|
case "fail":
|
|
2785
|
-
return
|
|
2593
|
+
return chalk9.red(msg);
|
|
2786
2594
|
}
|
|
2787
2595
|
}
|
|
2788
2596
|
async function checkRuntimeAvailability(preferred) {
|
|
@@ -3007,10 +2815,10 @@ async function checkSyncHealth(serviceName, ports) {
|
|
|
3007
2815
|
};
|
|
3008
2816
|
}
|
|
3009
2817
|
}
|
|
3010
|
-
var doctorCommand = new
|
|
2818
|
+
var doctorCommand = new Command9("doctor").description("Diagnose common Horus issues").action(async () => {
|
|
3011
2819
|
console.log("");
|
|
3012
|
-
console.log(
|
|
3013
|
-
console.log(
|
|
2820
|
+
console.log(chalk9.bold("Horus Doctor"));
|
|
2821
|
+
console.log(chalk9.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3014
2822
|
const allResults = [];
|
|
3015
2823
|
const config = configExists() ? loadConfig() : null;
|
|
3016
2824
|
allResults.push(await checkRuntimeAvailability(config?.runtime));
|
|
@@ -3052,20 +2860,20 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
|
|
|
3052
2860
|
}
|
|
3053
2861
|
const errors = allResults.filter((r) => r.status === "fail");
|
|
3054
2862
|
const warnings = allResults.filter((r) => r.status === "warn");
|
|
3055
|
-
console.log(
|
|
2863
|
+
console.log(chalk9.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3056
2864
|
if (errors.length === 0 && warnings.length === 0) {
|
|
3057
|
-
console.log(
|
|
2865
|
+
console.log(chalk9.green(" All checks passed."));
|
|
3058
2866
|
} else {
|
|
3059
2867
|
const parts = [];
|
|
3060
|
-
if (errors.length > 0) parts.push(
|
|
3061
|
-
if (warnings.length > 0) parts.push(
|
|
2868
|
+
if (errors.length > 0) parts.push(chalk9.red(`${errors.length} error${errors.length > 1 ? "s" : ""}`));
|
|
2869
|
+
if (warnings.length > 0) parts.push(chalk9.yellow(`${warnings.length} warning${warnings.length > 1 ? "s" : ""}`));
|
|
3062
2870
|
console.log(` ${parts.join(", ")}`);
|
|
3063
2871
|
const withHints = [...errors, ...warnings].filter((r) => r.hint);
|
|
3064
2872
|
if (withHints.length > 0) {
|
|
3065
2873
|
console.log("");
|
|
3066
2874
|
for (const r of withHints) {
|
|
3067
|
-
const icon = r.status === "fail" ?
|
|
3068
|
-
console.log(` ${icon} ${
|
|
2875
|
+
const icon = r.status === "fail" ? chalk9.red("\u2717") : chalk9.yellow("\u26A0");
|
|
2876
|
+
console.log(` ${icon} ${chalk9.dim(r.hint)}`);
|
|
3069
2877
|
}
|
|
3070
2878
|
}
|
|
3071
2879
|
}
|
|
@@ -3076,8 +2884,8 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
|
|
|
3076
2884
|
});
|
|
3077
2885
|
|
|
3078
2886
|
// src/commands/backup.ts
|
|
3079
|
-
import { Command as
|
|
3080
|
-
import
|
|
2887
|
+
import { Command as Command10 } from "commander";
|
|
2888
|
+
import chalk10 from "chalk";
|
|
3081
2889
|
import ora7 from "ora";
|
|
3082
2890
|
import { confirm as confirm4 } from "@inquirer/prompts";
|
|
3083
2891
|
import { mkdirSync as mkdirSync5, statSync as statSync2, existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
@@ -3096,15 +2904,15 @@ function formatBytes(bytes) {
|
|
|
3096
2904
|
}
|
|
3097
2905
|
async function createBackup(yes) {
|
|
3098
2906
|
console.log("");
|
|
3099
|
-
console.log(
|
|
3100
|
-
console.log(
|
|
2907
|
+
console.log(chalk10.bold("Horus Backup"));
|
|
2908
|
+
console.log(chalk10.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3101
2909
|
console.log("");
|
|
3102
2910
|
const config = loadConfig();
|
|
3103
2911
|
const runtimeSpinner = ora7("Detecting runtime...").start();
|
|
3104
2912
|
let runtime;
|
|
3105
2913
|
try {
|
|
3106
2914
|
runtime = await detectRuntime(config.runtime);
|
|
3107
|
-
runtimeSpinner.succeed(`Using ${
|
|
2915
|
+
runtimeSpinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
|
|
3108
2916
|
} catch (error) {
|
|
3109
2917
|
runtimeSpinner.fail("No container runtime found");
|
|
3110
2918
|
console.log(error.message);
|
|
@@ -3116,7 +2924,7 @@ async function createBackup(yes) {
|
|
|
3116
2924
|
default: true
|
|
3117
2925
|
});
|
|
3118
2926
|
if (!confirmed) {
|
|
3119
|
-
console.log(
|
|
2927
|
+
console.log(chalk10.dim("Backup cancelled."));
|
|
3120
2928
|
return;
|
|
3121
2929
|
}
|
|
3122
2930
|
}
|
|
@@ -3126,7 +2934,7 @@ async function createBackup(yes) {
|
|
|
3126
2934
|
stopSpinner.succeed("Services stopped");
|
|
3127
2935
|
} catch (error) {
|
|
3128
2936
|
stopSpinner.fail("Failed to stop services");
|
|
3129
|
-
console.log(
|
|
2937
|
+
console.log(chalk10.dim(error.message));
|
|
3130
2938
|
process.exit(1);
|
|
3131
2939
|
}
|
|
3132
2940
|
ensureBackupsDir();
|
|
@@ -3138,10 +2946,10 @@ async function createBackup(yes) {
|
|
|
3138
2946
|
execSync3(`tar -czf "${tarFile}" -C "${HORUS_DIR}" data/`, {
|
|
3139
2947
|
stdio: "pipe"
|
|
3140
2948
|
});
|
|
3141
|
-
backupSpinner.succeed(`Archive created: ${
|
|
2949
|
+
backupSpinner.succeed(`Archive created: ${chalk10.dim(tarFile)}`);
|
|
3142
2950
|
} catch (error) {
|
|
3143
2951
|
backupSpinner.fail("Failed to create backup archive");
|
|
3144
|
-
console.log(
|
|
2952
|
+
console.log(chalk10.dim(error.message));
|
|
3145
2953
|
await composeStreaming(runtime, ["start"]).catch(() => {
|
|
3146
2954
|
});
|
|
3147
2955
|
process.exit(1);
|
|
@@ -3164,25 +2972,25 @@ async function createBackup(yes) {
|
|
|
3164
2972
|
startSpinner.succeed("Services restarted");
|
|
3165
2973
|
} catch (error) {
|
|
3166
2974
|
startSpinner.fail("Failed to restart services");
|
|
3167
|
-
console.log(
|
|
3168
|
-
console.log(
|
|
2975
|
+
console.log(chalk10.dim(error.message));
|
|
2976
|
+
console.log(chalk10.yellow("Run `horus up` to restart services manually."));
|
|
3169
2977
|
}
|
|
3170
2978
|
console.log("");
|
|
3171
|
-
console.log(
|
|
3172
|
-
console.log(
|
|
3173
|
-
console.log(` ${
|
|
3174
|
-
console.log(` ${
|
|
2979
|
+
console.log(chalk10.bold.green("Backup complete!"));
|
|
2980
|
+
console.log(chalk10.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2981
|
+
console.log(` ${chalk10.bold("File:")} ${tarFile}`);
|
|
2982
|
+
console.log(` ${chalk10.bold("Size:")} ${formatBytes(sizeBytes)}`);
|
|
3175
2983
|
console.log("");
|
|
3176
|
-
console.log(
|
|
2984
|
+
console.log(chalk10.dim(" Restore with: horus backup restore <file>"));
|
|
3177
2985
|
console.log("");
|
|
3178
2986
|
}
|
|
3179
2987
|
async function restoreBackup(file, yes) {
|
|
3180
2988
|
console.log("");
|
|
3181
|
-
console.log(
|
|
3182
|
-
console.log(
|
|
2989
|
+
console.log(chalk10.bold("Horus Restore"));
|
|
2990
|
+
console.log(chalk10.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3183
2991
|
console.log("");
|
|
3184
2992
|
if (!existsSync8(file)) {
|
|
3185
|
-
console.log(
|
|
2993
|
+
console.log(chalk10.red(`Backup file not found: ${file}`));
|
|
3186
2994
|
process.exit(1);
|
|
3187
2995
|
}
|
|
3188
2996
|
const config = loadConfig();
|
|
@@ -3190,21 +2998,21 @@ async function restoreBackup(file, yes) {
|
|
|
3190
2998
|
let runtime;
|
|
3191
2999
|
try {
|
|
3192
3000
|
runtime = await detectRuntime(config.runtime);
|
|
3193
|
-
runtimeSpinner.succeed(`Using ${
|
|
3001
|
+
runtimeSpinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
|
|
3194
3002
|
} catch (error) {
|
|
3195
3003
|
runtimeSpinner.fail("No container runtime found");
|
|
3196
3004
|
console.log(error.message);
|
|
3197
3005
|
process.exit(1);
|
|
3198
3006
|
}
|
|
3199
3007
|
if (!yes) {
|
|
3200
|
-
console.log(
|
|
3008
|
+
console.log(chalk10.yellow(` Warning: This will overwrite current data in ${config.data_dir}`));
|
|
3201
3009
|
console.log("");
|
|
3202
3010
|
const confirmed = await confirm4({
|
|
3203
3011
|
message: `Restore from ${basename(file)}? Current data will be overwritten.`,
|
|
3204
3012
|
default: false
|
|
3205
3013
|
});
|
|
3206
3014
|
if (!confirmed) {
|
|
3207
|
-
console.log(
|
|
3015
|
+
console.log(chalk10.dim("Restore cancelled."));
|
|
3208
3016
|
return;
|
|
3209
3017
|
}
|
|
3210
3018
|
}
|
|
@@ -3214,7 +3022,7 @@ async function restoreBackup(file, yes) {
|
|
|
3214
3022
|
stopSpinner.succeed("Services stopped");
|
|
3215
3023
|
} catch (error) {
|
|
3216
3024
|
stopSpinner.fail("Failed to stop services");
|
|
3217
|
-
console.log(
|
|
3025
|
+
console.log(chalk10.dim(error.message));
|
|
3218
3026
|
process.exit(1);
|
|
3219
3027
|
}
|
|
3220
3028
|
const extractSpinner = ora7("Extracting backup...").start();
|
|
@@ -3223,18 +3031,18 @@ async function restoreBackup(file, yes) {
|
|
|
3223
3031
|
extractSpinner.succeed("Backup extracted");
|
|
3224
3032
|
} catch (error) {
|
|
3225
3033
|
extractSpinner.fail("Failed to extract backup");
|
|
3226
|
-
console.log(
|
|
3034
|
+
console.log(chalk10.dim(error.message));
|
|
3227
3035
|
await composeStreaming(runtime, ["start"]).catch(() => {
|
|
3228
3036
|
});
|
|
3229
3037
|
process.exit(1);
|
|
3230
3038
|
}
|
|
3231
3039
|
console.log("");
|
|
3232
|
-
console.log(
|
|
3040
|
+
console.log(chalk10.bold("Starting services..."));
|
|
3233
3041
|
try {
|
|
3234
3042
|
await composeStreaming(runtime, ["start"]);
|
|
3235
3043
|
} catch (error) {
|
|
3236
|
-
console.log(
|
|
3237
|
-
console.log(
|
|
3044
|
+
console.log(chalk10.red("Failed to start services."));
|
|
3045
|
+
console.log(chalk10.dim(error.message));
|
|
3238
3046
|
process.exit(1);
|
|
3239
3047
|
}
|
|
3240
3048
|
console.log("");
|
|
@@ -3244,7 +3052,7 @@ async function restoreBackup(file, yes) {
|
|
|
3244
3052
|
runtime,
|
|
3245
3053
|
(current) => {
|
|
3246
3054
|
const summary = current.map((s) => {
|
|
3247
|
-
const icon = s.status === "healthy" ?
|
|
3055
|
+
const icon = s.status === "healthy" ? chalk10.green("*") : s.status === "starting" ? chalk10.yellow("~") : chalk10.red("x");
|
|
3248
3056
|
return `${icon} ${s.name}`;
|
|
3249
3057
|
}).join(" ");
|
|
3250
3058
|
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
@@ -3255,14 +3063,14 @@ async function restoreBackup(file, yes) {
|
|
|
3255
3063
|
healthSpinner.succeed("All services healthy");
|
|
3256
3064
|
} catch (error) {
|
|
3257
3065
|
healthSpinner.fail("Some services did not become healthy");
|
|
3258
|
-
console.log(
|
|
3066
|
+
console.log(chalk10.dim(error.message));
|
|
3259
3067
|
process.exit(1);
|
|
3260
3068
|
}
|
|
3261
3069
|
console.log("");
|
|
3262
|
-
console.log(
|
|
3070
|
+
console.log(chalk10.bold.green("Restore complete!"));
|
|
3263
3071
|
console.log("");
|
|
3264
3072
|
}
|
|
3265
|
-
var backupCommand = new
|
|
3073
|
+
var backupCommand = new Command10("backup").description("Backup or restore Horus data").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
|
|
3266
3074
|
await createBackup(opts.yes);
|
|
3267
3075
|
});
|
|
3268
3076
|
backupCommand.command("restore <file>").description("Restore Horus data from a backup file").option("-y, --yes", "Skip confirmation prompts").action(async (file, opts) => {
|
|
@@ -3270,8 +3078,8 @@ backupCommand.command("restore <file>").description("Restore Horus data from a b
|
|
|
3270
3078
|
});
|
|
3271
3079
|
|
|
3272
3080
|
// src/commands/test-env.ts
|
|
3273
|
-
import { Command as
|
|
3274
|
-
import
|
|
3081
|
+
import { Command as Command11 } from "commander";
|
|
3082
|
+
import chalk11 from "chalk";
|
|
3275
3083
|
import ora8 from "ora";
|
|
3276
3084
|
import { join as join8 } from "path";
|
|
3277
3085
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
@@ -3724,22 +3532,32 @@ function projectName(slot) {
|
|
|
3724
3532
|
}
|
|
3725
3533
|
|
|
3726
3534
|
// src/commands/test-env.ts
|
|
3727
|
-
var testEnvCommand = new
|
|
3535
|
+
var testEnvCommand = new Command11("test-env").description("Manage isolated shadow stacks for integration testing");
|
|
3728
3536
|
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").option("--image <overrides...>", "Override service images (format: service=image:tag)").option(
|
|
3729
3537
|
"--standalone",
|
|
3730
3538
|
"Generate a fully-projected compose file (no overlay-merge). Eliminates port-collision with a live stack. Required on fresh VMs."
|
|
3539
|
+
).option(
|
|
3540
|
+
"--json",
|
|
3541
|
+
"Emit a single machine-readable JSON object {slot, slot_data_path, project, ports} on stdout. All human/progress output is redirected to stderr so stdout is pure JSON (for the testenv runner)."
|
|
3731
3542
|
).action(async (opts) => {
|
|
3543
|
+
const jsonMode = Boolean(opts.json);
|
|
3544
|
+
const mkSpinner = (text) => ora8({ text, stream: jsonMode ? process.stderr : process.stdout });
|
|
3732
3545
|
const config = loadConfig();
|
|
3733
3546
|
const dataDir = config.data_dir;
|
|
3547
|
+
if (!config.vaults || Object.keys(config.vaults).length === 0) {
|
|
3548
|
+
config.vaults = {
|
|
3549
|
+
default: { repo: "", default: true }
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3734
3552
|
const testCfg = loadTestEnvConfig(dataDir);
|
|
3735
3553
|
const vaultNames = Object.keys(config.vaults).sort();
|
|
3736
3554
|
const defaultVaultEntry = Object.entries(config.vaults).find(([, v]) => v.default);
|
|
3737
3555
|
const defaultVaultName = defaultVaultEntry?.[0] ?? vaultNames[0] ?? "default";
|
|
3738
|
-
const spinner =
|
|
3556
|
+
const spinner = mkSpinner("Detecting runtime...").start();
|
|
3739
3557
|
let runtime;
|
|
3740
3558
|
try {
|
|
3741
3559
|
runtime = await detectRuntime(config.runtime);
|
|
3742
|
-
spinner.succeed(`Using ${
|
|
3560
|
+
spinner.succeed(`Using ${chalk11.cyan(runtime.name)}`);
|
|
3743
3561
|
} catch (error) {
|
|
3744
3562
|
spinner.fail("No container runtime found");
|
|
3745
3563
|
console.error(error.message);
|
|
@@ -3747,18 +3565,18 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3747
3565
|
}
|
|
3748
3566
|
const slot = findFreeSlot(dataDir, testCfg);
|
|
3749
3567
|
if (slot === null) {
|
|
3750
|
-
console.error(
|
|
3751
|
-
`All ${testCfg.max_slots} slot(s) are in use. Run ${
|
|
3568
|
+
console.error(chalk11.red(
|
|
3569
|
+
`All ${testCfg.max_slots} slot(s) are in use. Run ${chalk11.bold("horus test-env status")} to see active slots, or ${chalk11.bold("horus test-env release")} to free one.`
|
|
3752
3570
|
));
|
|
3753
3571
|
process.exit(1);
|
|
3754
3572
|
}
|
|
3755
3573
|
const ports = calcPorts(slot, testCfg.base_port);
|
|
3756
3574
|
const slotDataPath = getSlotDataPath(dataDir, slot);
|
|
3757
3575
|
const project = projectName(slot);
|
|
3758
|
-
const dirSpinner =
|
|
3576
|
+
const dirSpinner = mkSpinner(`Creating slot-${slot} data directories...`).start();
|
|
3759
3577
|
createSlotDirs(slotDataPath, vaultNames);
|
|
3760
|
-
dirSpinner.succeed(`Data directory: ${
|
|
3761
|
-
const seedSpinner =
|
|
3578
|
+
dirSpinner.succeed(`Data directory: ${chalk11.dim(slotDataPath)}`);
|
|
3579
|
+
const seedSpinner = mkSpinner("Pre-seeding git repos...").start();
|
|
3762
3580
|
try {
|
|
3763
3581
|
await preSeedNotesDir(dataDir, slotDataPath);
|
|
3764
3582
|
await preSeedVaultDirs(dataDir, slotDataPath, vaultNames);
|
|
@@ -3782,7 +3600,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3782
3600
|
for (const entry of opts.image) {
|
|
3783
3601
|
const eqIdx = entry.indexOf("=");
|
|
3784
3602
|
if (eqIdx < 1) {
|
|
3785
|
-
console.error(
|
|
3603
|
+
console.error(chalk11.red(`Invalid --image format: "${entry}". Expected: service=image:tag`));
|
|
3786
3604
|
process.exit(1);
|
|
3787
3605
|
}
|
|
3788
3606
|
imageOverrides[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
|
@@ -3790,8 +3608,8 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3790
3608
|
}
|
|
3791
3609
|
const standaloneMode = Boolean(opts.standalone);
|
|
3792
3610
|
const modeLabel = standaloneMode ? "standalone" : "overlay";
|
|
3793
|
-
const upSpinner =
|
|
3794
|
-
`Starting shadow stack (project ${
|
|
3611
|
+
const upSpinner = mkSpinner(
|
|
3612
|
+
`Starting shadow stack (project ${chalk11.cyan(project)}, mode: ${chalk11.dim(modeLabel)})...`
|
|
3795
3613
|
).start();
|
|
3796
3614
|
try {
|
|
3797
3615
|
if (standaloneMode) {
|
|
@@ -3807,11 +3625,11 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3807
3625
|
console.error(error.message);
|
|
3808
3626
|
process.exit(1);
|
|
3809
3627
|
}
|
|
3810
|
-
const healthSpinner =
|
|
3628
|
+
const healthSpinner = mkSpinner("Waiting for services to be healthy...").start();
|
|
3811
3629
|
const timeoutMs = parseInt(opts.timeout, 10) * 1e3;
|
|
3812
3630
|
try {
|
|
3813
3631
|
await waitForShadowStackHealthy(runtime, project, timeoutMs, 3e3, (statuses) => {
|
|
3814
|
-
const parts = Object.entries(statuses).map(([svc, s]) => `${svc}:${s === "healthy" ?
|
|
3632
|
+
const parts = Object.entries(statuses).map(([svc, s]) => `${svc}:${s === "healthy" ? chalk11.green(s) : chalk11.yellow(s)}`).join(" ");
|
|
3815
3633
|
healthSpinner.text = `Waiting for services... ${parts}`;
|
|
3816
3634
|
});
|
|
3817
3635
|
healthSpinner.succeed("All services healthy");
|
|
@@ -3832,23 +3650,29 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3832
3650
|
console.error(error.message);
|
|
3833
3651
|
process.exit(1);
|
|
3834
3652
|
}
|
|
3653
|
+
if (jsonMode) {
|
|
3654
|
+
process.stdout.write(
|
|
3655
|
+
JSON.stringify({ slot, slot_data_path: slotDataPath, project, ports }) + "\n"
|
|
3656
|
+
);
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3835
3659
|
console.log("");
|
|
3836
|
-
console.log(
|
|
3660
|
+
console.log(chalk11.bold.green(`\u2713 Slot ${slot} acquired`));
|
|
3837
3661
|
console.log("");
|
|
3838
|
-
console.log(
|
|
3839
|
-
console.log(` Slot: ${
|
|
3840
|
-
console.log(` Project: ${
|
|
3841
|
-
console.log(` Data: ${
|
|
3662
|
+
console.log(chalk11.bold("Connection info:"));
|
|
3663
|
+
console.log(` Slot: ${chalk11.cyan(slot)}`);
|
|
3664
|
+
console.log(` Project: ${chalk11.cyan(project)}`);
|
|
3665
|
+
console.log(` Data: ${chalk11.dim(slotDataPath)}`);
|
|
3842
3666
|
console.log("");
|
|
3843
|
-
console.log(
|
|
3844
|
-
console.log(` Anvil: http://localhost:${
|
|
3845
|
-
console.log(` Forge: http://localhost:${
|
|
3846
|
-
console.log(` Vault MCP: http://localhost:${
|
|
3847
|
-
console.log(` Vault Router: http://localhost:${
|
|
3848
|
-
console.log(` Typesense: http://localhost:${
|
|
3849
|
-
console.log(` UI: http://localhost:${
|
|
3667
|
+
console.log(chalk11.bold("Ports:"));
|
|
3668
|
+
console.log(` Anvil: http://localhost:${chalk11.cyan(ports.anvil)}`);
|
|
3669
|
+
console.log(` Forge: http://localhost:${chalk11.cyan(ports.forge)}`);
|
|
3670
|
+
console.log(` Vault MCP: http://localhost:${chalk11.cyan(ports.vault_mcp)}`);
|
|
3671
|
+
console.log(` Vault Router: http://localhost:${chalk11.cyan(ports.vault_router)}`);
|
|
3672
|
+
console.log(` Typesense: http://localhost:${chalk11.cyan(ports.typesense)}`);
|
|
3673
|
+
console.log(` UI: http://localhost:${chalk11.cyan(ports.ui)}`);
|
|
3850
3674
|
console.log("");
|
|
3851
|
-
console.log(
|
|
3675
|
+
console.log(chalk11.bold("Environment:"));
|
|
3852
3676
|
console.log(` export TEST_SLOT=${slot}`);
|
|
3853
3677
|
console.log(` export TEST_ANVIL_URL=http://localhost:${ports.anvil}`);
|
|
3854
3678
|
console.log(` export TEST_FORGE_URL=http://localhost:${ports.forge}`);
|
|
@@ -3856,16 +3680,21 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3856
3680
|
console.log(` export TEST_DATA_PATH=${slotDataPath}`);
|
|
3857
3681
|
console.log("");
|
|
3858
3682
|
const settingsPath = join8(slotDataPath, "claude-settings.json");
|
|
3859
|
-
console.log(
|
|
3860
|
-
console.log(` MCP config: ${
|
|
3861
|
-
console.log(` Launch: ${
|
|
3683
|
+
console.log(chalk11.bold("Agent dev mode:"));
|
|
3684
|
+
console.log(` MCP config: ${chalk11.dim(settingsPath)}`);
|
|
3685
|
+
console.log(` Launch: ${chalk11.cyan(`claude --mcp-config ${settingsPath}`)}`);
|
|
3862
3686
|
console.log("");
|
|
3863
|
-
console.log(
|
|
3864
|
-
console.log(
|
|
3687
|
+
console.log(chalk11.dim(`Run ${chalk11.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
|
|
3688
|
+
console.log(chalk11.dim(`Run ${chalk11.bold(`horus test-env release --slot ${slot}`)} when done.`));
|
|
3865
3689
|
});
|
|
3866
3690
|
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) => {
|
|
3867
3691
|
const config = loadConfig();
|
|
3868
3692
|
const dataDir = config.data_dir;
|
|
3693
|
+
if (!config.vaults || Object.keys(config.vaults).length === 0) {
|
|
3694
|
+
config.vaults = {
|
|
3695
|
+
default: { repo: "", default: true }
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3869
3698
|
const testCfg = loadTestEnvConfig(dataDir);
|
|
3870
3699
|
const vaultNames = Object.keys(config.vaults).sort();
|
|
3871
3700
|
const defaultVaultEntry = Object.entries(config.vaults).find(([, v]) => v.default);
|
|
@@ -3877,7 +3706,7 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
|
|
|
3877
3706
|
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3878
3707
|
const acquired = statuses.find((s) => s.state === "acquired" || s.state === "expired");
|
|
3879
3708
|
if (!acquired) {
|
|
3880
|
-
console.log(
|
|
3709
|
+
console.log(chalk11.yellow("No active slots found."));
|
|
3881
3710
|
return;
|
|
3882
3711
|
}
|
|
3883
3712
|
slot = acquired.slot;
|
|
@@ -3890,13 +3719,13 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
|
|
|
3890
3719
|
let runtime;
|
|
3891
3720
|
try {
|
|
3892
3721
|
runtime = await detectRuntime(config.runtime);
|
|
3893
|
-
spinner.succeed(`Using ${
|
|
3722
|
+
spinner.succeed(`Using ${chalk11.cyan(runtime.name)}`);
|
|
3894
3723
|
} catch (error) {
|
|
3895
3724
|
spinner.fail("No container runtime found");
|
|
3896
3725
|
console.error(error.message);
|
|
3897
3726
|
process.exit(1);
|
|
3898
3727
|
}
|
|
3899
|
-
const downSpinner = ora8(`Stopping ${
|
|
3728
|
+
const downSpinner = ora8(`Stopping ${chalk11.cyan(project)}...`).start();
|
|
3900
3729
|
try {
|
|
3901
3730
|
const { existsSync: existsSync12 } = await import("fs");
|
|
3902
3731
|
const { join: join11 } = await import("path");
|
|
@@ -3915,7 +3744,7 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
|
|
|
3915
3744
|
removeLock(dataDir, slot);
|
|
3916
3745
|
cleanSpinner.succeed("Test data removed");
|
|
3917
3746
|
console.log("");
|
|
3918
|
-
console.log(
|
|
3747
|
+
console.log(chalk11.bold.green(`\u2713 Slot ${slot} released`));
|
|
3919
3748
|
});
|
|
3920
3749
|
testEnvCommand.command("status").description("Show active shadow stack slots").action(() => {
|
|
3921
3750
|
const config = loadConfig();
|
|
@@ -3924,20 +3753,20 @@ testEnvCommand.command("status").description("Show active shadow stack slots").a
|
|
|
3924
3753
|
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3925
3754
|
const acquiredCount = statuses.filter((s) => s.state === "acquired").length;
|
|
3926
3755
|
console.log("");
|
|
3927
|
-
console.log(
|
|
3756
|
+
console.log(chalk11.bold("Test Environment Status"));
|
|
3928
3757
|
console.log(` Max slots: ${testCfg.max_slots}`);
|
|
3929
3758
|
console.log(` In use: ${acquiredCount} / ${testCfg.max_slots}`);
|
|
3930
3759
|
console.log(` Base port: ${testCfg.base_port}`);
|
|
3931
3760
|
console.log("");
|
|
3932
3761
|
if (statuses.every((s) => s.state === "free")) {
|
|
3933
|
-
console.log(
|
|
3762
|
+
console.log(chalk11.dim(" No active slots."));
|
|
3934
3763
|
console.log("");
|
|
3935
3764
|
return;
|
|
3936
3765
|
}
|
|
3937
3766
|
for (const s of statuses) {
|
|
3938
3767
|
if (s.state === "free") continue;
|
|
3939
|
-
const stateLabel = s.state === "expired" ?
|
|
3940
|
-
console.log(` ${
|
|
3768
|
+
const stateLabel = s.state === "expired" ? chalk11.yellow("EXPIRED") : chalk11.green("ACTIVE");
|
|
3769
|
+
console.log(` ${chalk11.bold(`Slot ${s.slot}`)} ${stateLabel}`);
|
|
3941
3770
|
if (s.acquiredAt) {
|
|
3942
3771
|
console.log(` Acquired: ${s.acquiredAt} (${s.elapsedMinutes}m ago)`);
|
|
3943
3772
|
}
|
|
@@ -3945,7 +3774,7 @@ testEnvCommand.command("status").description("Show active shadow stack slots").a
|
|
|
3945
3774
|
console.log(` Ports: anvil=${s.ports.anvil} forge=${s.ports.forge} vault-mcp=${s.ports.vault_mcp} typesense=${s.ports.typesense}`);
|
|
3946
3775
|
}
|
|
3947
3776
|
if (s.dataPath) {
|
|
3948
|
-
console.log(` Data: ${
|
|
3777
|
+
console.log(` Data: ${chalk11.dim(s.dataPath)}`);
|
|
3949
3778
|
}
|
|
3950
3779
|
console.log("");
|
|
3951
3780
|
}
|
|
@@ -3961,7 +3790,7 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
|
|
|
3961
3790
|
const statuses = getAllSlotStatuses(dataDir, testCfg);
|
|
3962
3791
|
const acquired = statuses.find((s) => s.state === "acquired");
|
|
3963
3792
|
if (!acquired) {
|
|
3964
|
-
console.error(
|
|
3793
|
+
console.error(chalk11.red("No active slot found. Run `horus test-env acquire` first."));
|
|
3965
3794
|
process.exit(1);
|
|
3966
3795
|
}
|
|
3967
3796
|
slot = acquired.slot;
|
|
@@ -3992,12 +3821,12 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
|
|
|
3992
3821
|
}
|
|
3993
3822
|
}
|
|
3994
3823
|
console.log("");
|
|
3995
|
-
console.log(
|
|
3824
|
+
console.log(chalk11.dim("Services will re-index automatically. Allow ~10s before running tests."));
|
|
3996
3825
|
});
|
|
3997
3826
|
|
|
3998
3827
|
// src/commands/help.ts
|
|
3999
|
-
import { Command as
|
|
4000
|
-
import
|
|
3828
|
+
import { Command as Command12 } from "commander";
|
|
3829
|
+
import chalk12 from "chalk";
|
|
4001
3830
|
import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
|
|
4002
3831
|
import { join as join9, dirname as dirname3 } from "path";
|
|
4003
3832
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
@@ -4174,47 +4003,47 @@ function stripFrontMatter(content) {
|
|
|
4174
4003
|
}
|
|
4175
4004
|
function printTopicIndex(index) {
|
|
4176
4005
|
console.log("");
|
|
4177
|
-
console.log(
|
|
4178
|
-
console.log(
|
|
4006
|
+
console.log(chalk12.bold("Horus Help \u2014 Available Guides"));
|
|
4007
|
+
console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4179
4008
|
console.log("");
|
|
4180
4009
|
for (const g of index.guides) {
|
|
4181
|
-
console.log(` ${
|
|
4182
|
-
console.log(` ${" ".repeat(20)} ${
|
|
4010
|
+
console.log(` ${chalk12.cyan(g.slug.padEnd(20))} ${g.title}`);
|
|
4011
|
+
console.log(` ${" ".repeat(20)} ${chalk12.dim(g.description)}`);
|
|
4183
4012
|
console.log("");
|
|
4184
4013
|
}
|
|
4185
|
-
console.log(
|
|
4186
|
-
console.log(
|
|
4187
|
-
console.log(
|
|
4188
|
-
console.log(
|
|
4014
|
+
console.log(chalk12.dim("Example queries:"));
|
|
4015
|
+
console.log(chalk12.dim(" horus help how do I start"));
|
|
4016
|
+
console.log(chalk12.dim(" horus help what is a forge workspace"));
|
|
4017
|
+
console.log(chalk12.dim(" horus help create my first anvil note"));
|
|
4189
4018
|
console.log("");
|
|
4190
|
-
console.log(
|
|
4191
|
-
console.log(
|
|
4019
|
+
console.log(chalk12.dim("To print a specific guide directly:"));
|
|
4020
|
+
console.log(chalk12.dim(" horus guide <slug>"));
|
|
4192
4021
|
console.log("");
|
|
4193
4022
|
}
|
|
4194
4023
|
function printGuideBody(guidesDir, file) {
|
|
4195
|
-
const
|
|
4196
|
-
const content = readFileSync7(
|
|
4024
|
+
const path2 = join9(guidesDir, file);
|
|
4025
|
+
const content = readFileSync7(path2, "utf8");
|
|
4197
4026
|
console.log(stripFrontMatter(content));
|
|
4198
4027
|
}
|
|
4199
4028
|
function printSeeAlso(alternates, guidesDir) {
|
|
4200
4029
|
if (alternates.length === 0) return;
|
|
4201
|
-
console.log(
|
|
4202
|
-
console.log(
|
|
4030
|
+
console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4031
|
+
console.log(chalk12.bold("See also:"));
|
|
4203
4032
|
for (const a of alternates) {
|
|
4204
|
-
console.log(` ${
|
|
4205
|
-
console.log(` ${" ".repeat(20)} ${
|
|
4033
|
+
console.log(` ${chalk12.cyan(a.slug.padEnd(20))} ${a.title}`);
|
|
4034
|
+
console.log(` ${" ".repeat(20)} ${chalk12.dim(join9(guidesDir, a.file))}`);
|
|
4206
4035
|
}
|
|
4207
4036
|
console.log("");
|
|
4208
4037
|
}
|
|
4209
4038
|
function printFooter() {
|
|
4210
4039
|
console.log(
|
|
4211
|
-
|
|
4040
|
+
chalk12.dim(
|
|
4212
4041
|
"Run `horus guide <slug>` to print a specific guide without retrieval, or `horus guide` to list all."
|
|
4213
4042
|
)
|
|
4214
4043
|
);
|
|
4215
4044
|
console.log("");
|
|
4216
4045
|
}
|
|
4217
|
-
var helpCommand = new
|
|
4046
|
+
var helpCommand = new Command12("help").description("Search and print bundled Horus getting-started guides").argument("[query...]", "Natural-language query. Omit to see the topic index.").action((query) => {
|
|
4218
4047
|
const guidesDir = findGuidesDir();
|
|
4219
4048
|
const index = loadIndex(guidesDir);
|
|
4220
4049
|
if (!query || query.length === 0) {
|
|
@@ -4225,15 +4054,15 @@ var helpCommand = new Command11("help").description("Search and print bundled Ho
|
|
|
4225
4054
|
const result = retrieve(queryStr, index, 3);
|
|
4226
4055
|
if (!result.primary) {
|
|
4227
4056
|
console.log("");
|
|
4228
|
-
console.log(
|
|
4057
|
+
console.log(chalk12.yellow(`No guide matched "${queryStr}".`));
|
|
4229
4058
|
console.log("");
|
|
4230
|
-
console.log(
|
|
4231
|
-
console.log(
|
|
4059
|
+
console.log(chalk12.dim("Try `horus help` with no arguments to see the full topic index,"));
|
|
4060
|
+
console.log(chalk12.dim("or pick a slug directly with `horus guide <slug>`."));
|
|
4232
4061
|
console.log("");
|
|
4233
4062
|
return;
|
|
4234
4063
|
}
|
|
4235
4064
|
console.log("");
|
|
4236
|
-
console.log(
|
|
4065
|
+
console.log(chalk12.dim(`# ${result.primary.title} (${result.primary.slug})`));
|
|
4237
4066
|
console.log("");
|
|
4238
4067
|
printGuideBody(guidesDir, result.primary.file);
|
|
4239
4068
|
console.log("");
|
|
@@ -4242,8 +4071,8 @@ var helpCommand = new Command11("help").description("Search and print bundled Ho
|
|
|
4242
4071
|
});
|
|
4243
4072
|
|
|
4244
4073
|
// src/commands/guide.ts
|
|
4245
|
-
import { Command as
|
|
4246
|
-
import
|
|
4074
|
+
import { Command as Command13 } from "commander";
|
|
4075
|
+
import chalk13 from "chalk";
|
|
4247
4076
|
import { readFileSync as readFileSync8, existsSync as existsSync11 } from "fs";
|
|
4248
4077
|
import { join as join10, dirname as dirname4 } from "path";
|
|
4249
4078
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
@@ -4282,17 +4111,17 @@ function lookupTopic(topic, index) {
|
|
|
4282
4111
|
}
|
|
4283
4112
|
function printGuideList(index) {
|
|
4284
4113
|
console.log("");
|
|
4285
|
-
console.log(
|
|
4286
|
-
console.log(
|
|
4114
|
+
console.log(chalk13.bold("Bundled Horus Guides"));
|
|
4115
|
+
console.log(chalk13.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4287
4116
|
console.log("");
|
|
4288
4117
|
for (const g of index.guides) {
|
|
4289
|
-
console.log(` ${
|
|
4290
|
-
console.log(` ${" ".repeat(20)} ${
|
|
4118
|
+
console.log(` ${chalk13.cyan(g.slug.padEnd(20))} ${g.title}`);
|
|
4119
|
+
console.log(` ${" ".repeat(20)} ${chalk13.dim(g.description)}`);
|
|
4291
4120
|
console.log("");
|
|
4292
4121
|
}
|
|
4293
|
-
console.log(
|
|
4294
|
-
console.log(
|
|
4295
|
-
console.log(
|
|
4122
|
+
console.log(chalk13.dim("Print a guide: horus guide <slug>"));
|
|
4123
|
+
console.log(chalk13.dim("Print a guide file path: horus guide <slug> --path"));
|
|
4124
|
+
console.log(chalk13.dim("Print the guides root: horus guide --path"));
|
|
4296
4125
|
console.log("");
|
|
4297
4126
|
}
|
|
4298
4127
|
function printGuideBody2(guidesDir, file) {
|
|
@@ -4301,16 +4130,16 @@ function printGuideBody2(guidesDir, file) {
|
|
|
4301
4130
|
}
|
|
4302
4131
|
function printDisambiguation(tier, matches) {
|
|
4303
4132
|
console.log("");
|
|
4304
|
-
console.log(
|
|
4133
|
+
console.log(chalk13.yellow(`Multiple guides matched (tier: ${tier}):`));
|
|
4305
4134
|
console.log("");
|
|
4306
4135
|
for (const m of matches) {
|
|
4307
|
-
console.log(` ${
|
|
4136
|
+
console.log(` ${chalk13.cyan(m.slug.padEnd(20))} ${m.title}`);
|
|
4308
4137
|
}
|
|
4309
4138
|
console.log("");
|
|
4310
|
-
console.log(
|
|
4139
|
+
console.log(chalk13.dim("Pick one: horus guide <slug>"));
|
|
4311
4140
|
console.log("");
|
|
4312
4141
|
}
|
|
4313
|
-
var guideCommand = new
|
|
4142
|
+
var guideCommand = new Command13("guide").description("Print a bundled Horus guide, or list all guides").argument("[topic]", "Slug, slug prefix, or search term. Omit to list all guides.").option("--path", "Print the file path instead of the body (or the guides dir root if no topic)").action((topic, opts) => {
|
|
4314
4143
|
const guidesDir = findGuidesDir2();
|
|
4315
4144
|
const index = loadIndex2(guidesDir);
|
|
4316
4145
|
if (!topic) {
|
|
@@ -4324,9 +4153,9 @@ var guideCommand = new Command12("guide").description("Print a bundled Horus gui
|
|
|
4324
4153
|
const result = lookupTopic(topic, index);
|
|
4325
4154
|
if (result.matches.length === 0) {
|
|
4326
4155
|
console.log("");
|
|
4327
|
-
console.log(
|
|
4156
|
+
console.log(chalk13.yellow(`No guide matched "${topic}".`));
|
|
4328
4157
|
console.log("");
|
|
4329
|
-
console.log(
|
|
4158
|
+
console.log(chalk13.dim("Run `horus guide` to see all available guides."));
|
|
4330
4159
|
console.log("");
|
|
4331
4160
|
process.exitCode = 1;
|
|
4332
4161
|
return;
|
|
@@ -4345,14 +4174,88 @@ var guideCommand = new Command12("guide").description("Print a bundled Horus gui
|
|
|
4345
4174
|
});
|
|
4346
4175
|
|
|
4347
4176
|
// src/commands/repo.ts
|
|
4348
|
-
import { Command as
|
|
4349
|
-
import
|
|
4177
|
+
import { Command as Command14 } from "commander";
|
|
4178
|
+
import chalk14 from "chalk";
|
|
4350
4179
|
import ora9 from "ora";
|
|
4351
|
-
|
|
4180
|
+
import { RepoRegistryClient } from "@forge/core";
|
|
4181
|
+
|
|
4182
|
+
// src/lib/repo-migrate.ts
|
|
4183
|
+
import { promises as fs } from "fs";
|
|
4184
|
+
import os from "os";
|
|
4185
|
+
import path from "path";
|
|
4186
|
+
import { RepoExistsError } from "@forge/core";
|
|
4187
|
+
function inferOrg(remoteUrl) {
|
|
4188
|
+
const sshMatch = remoteUrl.match(/git@[^:]+:([^/]+)\//);
|
|
4189
|
+
if (sshMatch) return sshMatch[1];
|
|
4190
|
+
const httpsMatch = remoteUrl.match(/https?:\/\/[^/]+\/([^/]+)\//);
|
|
4191
|
+
if (httpsMatch) return httpsMatch[1];
|
|
4192
|
+
return null;
|
|
4193
|
+
}
|
|
4194
|
+
function mapWorkflow(entry) {
|
|
4195
|
+
if (!entry.workflow) return void 0;
|
|
4196
|
+
const wf = entry.workflow;
|
|
4197
|
+
return {
|
|
4198
|
+
type: entry.workflow.type,
|
|
4199
|
+
pushTo: entry.workflow.pushTo,
|
|
4200
|
+
prTarget: entry.workflow.prTarget,
|
|
4201
|
+
branchPattern: entry.workflow.branchPattern,
|
|
4202
|
+
commitFormat: entry.workflow.commitFormat,
|
|
4203
|
+
mergeStrategy: wf["mergeStrategy"]
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
async function migrateRepos(opts) {
|
|
4207
|
+
const fromPath = opts.from ? opts.from : path.join(os.homedir(), "Horus", "data", "config", "repos.json");
|
|
4208
|
+
const raw = await fs.readFile(fromPath, "utf8");
|
|
4209
|
+
const index = JSON.parse(raw);
|
|
4210
|
+
const entries = index.repos ?? [];
|
|
4211
|
+
const result = { migrated: [], skipped: [], failed: [] };
|
|
4212
|
+
for (const entry of entries) {
|
|
4213
|
+
if (!entry.remoteUrl) {
|
|
4214
|
+
result.skipped.push({ repo: entry.name, reason: "no remoteUrl" });
|
|
4215
|
+
continue;
|
|
4216
|
+
}
|
|
4217
|
+
const org = inferOrg(entry.remoteUrl);
|
|
4218
|
+
if (!org) {
|
|
4219
|
+
result.skipped.push({ repo: entry.name, reason: "could not infer org from remoteUrl" });
|
|
4220
|
+
continue;
|
|
4221
|
+
}
|
|
4222
|
+
const input2 = {
|
|
4223
|
+
org,
|
|
4224
|
+
name: entry.name,
|
|
4225
|
+
canonicalUrl: entry.remoteUrl,
|
|
4226
|
+
defaultBranch: entry.defaultBranch,
|
|
4227
|
+
language: entry.language ?? void 0,
|
|
4228
|
+
workflow: mapWorkflow(entry)
|
|
4229
|
+
};
|
|
4230
|
+
if (opts.dryRun) {
|
|
4231
|
+
result.migrated.push(`${org}/${entry.name} (dry-run)`);
|
|
4232
|
+
continue;
|
|
4233
|
+
}
|
|
4234
|
+
try {
|
|
4235
|
+
await opts.client.register(input2);
|
|
4236
|
+
result.migrated.push(`${org}/${entry.name}`);
|
|
4237
|
+
} catch (err) {
|
|
4238
|
+
if (err instanceof RepoExistsError) {
|
|
4239
|
+
result.skipped.push({ repo: `${org}/${entry.name}`, reason: "already registered" });
|
|
4240
|
+
} else {
|
|
4241
|
+
const msg = err.message ?? String(err);
|
|
4242
|
+
if (msg.includes("REPO_EXISTS") || err.code === "REPO_EXISTS") {
|
|
4243
|
+
result.skipped.push({ repo: `${org}/${entry.name}`, reason: "already registered" });
|
|
4244
|
+
} else {
|
|
4245
|
+
result.failed.push({ repo: `${org}/${entry.name}`, error: msg });
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
return result;
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
// src/commands/repo.ts
|
|
4254
|
+
var repoCommand = new Command14("repo").description("Manage the Forge repository index");
|
|
4352
4255
|
repoCommand.command("rindex").alias("scan").description("Trigger a full repository index rescan via Forge").action(async () => {
|
|
4353
4256
|
if (!configExists()) {
|
|
4354
|
-
console.log(
|
|
4355
|
-
console.log(
|
|
4257
|
+
console.log(chalk14.red("Horus is not set up yet."));
|
|
4258
|
+
console.log(chalk14.dim("Run `horus setup` first."));
|
|
4356
4259
|
process.exit(1);
|
|
4357
4260
|
}
|
|
4358
4261
|
const config = loadConfig();
|
|
@@ -4374,13 +4277,13 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
|
|
|
4374
4277
|
body = await res.text();
|
|
4375
4278
|
if (!res.ok) {
|
|
4376
4279
|
spinner.fail(`Forge returned HTTP ${res.status}`);
|
|
4377
|
-
console.error(
|
|
4280
|
+
console.error(chalk14.red(body));
|
|
4378
4281
|
process.exit(1);
|
|
4379
4282
|
}
|
|
4380
4283
|
} catch (err) {
|
|
4381
4284
|
spinner.fail("Could not reach Forge");
|
|
4382
|
-
console.error(
|
|
4383
|
-
console.error(
|
|
4285
|
+
console.error(chalk14.red(`Is Horus running? (horus up)`));
|
|
4286
|
+
console.error(chalk14.dim(err.message));
|
|
4384
4287
|
process.exit(1);
|
|
4385
4288
|
}
|
|
4386
4289
|
let parsed;
|
|
@@ -4393,7 +4296,7 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
|
|
|
4393
4296
|
}
|
|
4394
4297
|
if (parsed.error) {
|
|
4395
4298
|
spinner.fail("Scan failed");
|
|
4396
|
-
console.error(
|
|
4299
|
+
console.error(chalk14.red(parsed.error.message ?? JSON.stringify(parsed.error)));
|
|
4397
4300
|
process.exit(1);
|
|
4398
4301
|
}
|
|
4399
4302
|
let result = {};
|
|
@@ -4404,19 +4307,318 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
|
|
|
4404
4307
|
}
|
|
4405
4308
|
spinner.succeed("Repository scan complete");
|
|
4406
4309
|
console.log("");
|
|
4407
|
-
console.log(` ${
|
|
4408
|
-
console.log(` ${
|
|
4310
|
+
console.log(` ${chalk14.bold("Scan paths:")} ${(result.scanPaths ?? []).length}`);
|
|
4311
|
+
console.log(` ${chalk14.bold("Repos found:")} ${result.reposFound ?? 0}`);
|
|
4409
4312
|
if (result.repos && result.repos.length > 0) {
|
|
4410
4313
|
console.log("");
|
|
4411
4314
|
for (const repo of result.repos) {
|
|
4412
|
-
console.log(` ${
|
|
4315
|
+
console.log(` ${chalk14.green("\u2713")} ${chalk14.bold(repo.name)} ${chalk14.dim(repo.localPath)}`);
|
|
4413
4316
|
}
|
|
4414
4317
|
}
|
|
4415
4318
|
console.log("");
|
|
4416
4319
|
});
|
|
4320
|
+
repoCommand.command("migrate").description("Import existing repos.json entries into the shared repo registry").option("--from <path>", "Path to repos.json (default: ~/Horus/data/config/repos.json)").option("--registry <url>", "Shared registry base URL (overrides enterprise_registry_url)").option("--dry-run", "Preview what would be migrated without writing to the registry").action(async (opts) => {
|
|
4321
|
+
console.log("");
|
|
4322
|
+
console.log(chalk14.bold("Horus Repo Migrate"));
|
|
4323
|
+
console.log(chalk14.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4324
|
+
console.log("");
|
|
4325
|
+
if (!configExists()) {
|
|
4326
|
+
console.log(chalk14.red("Horus is not set up yet."));
|
|
4327
|
+
console.log(chalk14.dim("Run `horus setup` first."));
|
|
4328
|
+
process.exit(1);
|
|
4329
|
+
}
|
|
4330
|
+
const config = loadConfig();
|
|
4331
|
+
const registryUrl = opts.registry ?? config.enterprise_registry_url ?? "http://localhost:8744";
|
|
4332
|
+
const client = new RepoRegistryClient({ baseUrl: registryUrl });
|
|
4333
|
+
if (opts.dryRun) {
|
|
4334
|
+
console.log(chalk14.yellow(" Dry-run mode \u2014 no changes will be written to the registry."));
|
|
4335
|
+
console.log("");
|
|
4336
|
+
}
|
|
4337
|
+
const spinner = ora9("Reading repos.json...").start();
|
|
4338
|
+
let migrateResult;
|
|
4339
|
+
try {
|
|
4340
|
+
migrateResult = await migrateRepos({
|
|
4341
|
+
from: opts.from,
|
|
4342
|
+
dryRun: opts.dryRun,
|
|
4343
|
+
client
|
|
4344
|
+
});
|
|
4345
|
+
} catch (err) {
|
|
4346
|
+
spinner.fail("Migration failed");
|
|
4347
|
+
console.error(chalk14.red(err.message));
|
|
4348
|
+
process.exit(1);
|
|
4349
|
+
}
|
|
4350
|
+
spinner.stop();
|
|
4351
|
+
const total = migrateResult.migrated.length + migrateResult.skipped.length + migrateResult.failed.length;
|
|
4352
|
+
console.log(
|
|
4353
|
+
`Migrated ${chalk14.bold(String(migrateResult.migrated.length))}/${total} repos:`
|
|
4354
|
+
);
|
|
4355
|
+
console.log("");
|
|
4356
|
+
for (const repo of migrateResult.migrated) {
|
|
4357
|
+
console.log(` ${chalk14.green("\u2705")} ${repo}`);
|
|
4358
|
+
}
|
|
4359
|
+
for (const { repo, reason } of migrateResult.skipped) {
|
|
4360
|
+
console.log(` ${chalk14.yellow("\u26A0\uFE0F ")} ${repo} ${chalk14.dim(`(skipped \u2014 ${reason})`)}`);
|
|
4361
|
+
}
|
|
4362
|
+
for (const { repo, error } of migrateResult.failed) {
|
|
4363
|
+
console.log(` ${chalk14.red("\u274C")} ${repo} ${chalk14.dim(`(failed \u2014 ${error})`)}`);
|
|
4364
|
+
}
|
|
4365
|
+
console.log("");
|
|
4366
|
+
if (migrateResult.failed.length > 0) {
|
|
4367
|
+
process.exit(1);
|
|
4368
|
+
}
|
|
4369
|
+
});
|
|
4370
|
+
|
|
4371
|
+
// src/commands/operator.ts
|
|
4372
|
+
import { writeFileSync as writeFileSync8 } from "fs";
|
|
4373
|
+
import { resolve as resolve2 } from "path";
|
|
4374
|
+
import { Command as Command15 } from "commander";
|
|
4375
|
+
import { execa as execa4 } from "execa";
|
|
4376
|
+
|
|
4377
|
+
// src/lib/bundle.ts
|
|
4378
|
+
import { stringify as stringifyYaml4 } from "yaml";
|
|
4379
|
+
var BUNDLE_VERSION = "1";
|
|
4380
|
+
function buildBundle(input2) {
|
|
4381
|
+
const vaults = {};
|
|
4382
|
+
for (const v of input2.vaults ?? []) {
|
|
4383
|
+
vaults[v.namespace] = v.default ? { endpoint: v.endpoint, default: true } : { endpoint: v.endpoint };
|
|
4384
|
+
}
|
|
4385
|
+
const bundle = {
|
|
4386
|
+
version: input2.version ?? BUNDLE_VERSION,
|
|
4387
|
+
control_plane_url: input2.controlPlaneUrl,
|
|
4388
|
+
token_provider: { kind: input2.tokenProviderKind ?? "static", config: input2.initialToken },
|
|
4389
|
+
vaults
|
|
4390
|
+
};
|
|
4391
|
+
if (input2.dataDir) bundle.data_dir = input2.dataDir;
|
|
4392
|
+
if (input2.runtime) bundle.runtime = input2.runtime;
|
|
4393
|
+
return bundle;
|
|
4394
|
+
}
|
|
4395
|
+
function serializeBundle(bundle) {
|
|
4396
|
+
return stringifyYaml4(bundle);
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
// src/commands/operator.ts
|
|
4400
|
+
async function connect(opts) {
|
|
4401
|
+
const explicit = opts.operatorUrl ?? process.env.HORUS_OPERATOR_URL;
|
|
4402
|
+
if (explicit) {
|
|
4403
|
+
return { baseUrl: explicit.replace(/\/$/, ""), close: async () => {
|
|
4404
|
+
} };
|
|
4405
|
+
}
|
|
4406
|
+
const localPort = Number(opts.port ?? "8090");
|
|
4407
|
+
const ns = opts.namespace ?? "horus-system";
|
|
4408
|
+
const proc = execa4(
|
|
4409
|
+
"kubectl",
|
|
4410
|
+
["port-forward", "svc/operator-service", `${localPort}:8090`, "-n", ns],
|
|
4411
|
+
{ stdio: "ignore" }
|
|
4412
|
+
);
|
|
4413
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4414
|
+
return {
|
|
4415
|
+
baseUrl: `http://127.0.0.1:${localPort}`,
|
|
4416
|
+
close: async () => {
|
|
4417
|
+
proc.kill();
|
|
4418
|
+
}
|
|
4419
|
+
};
|
|
4420
|
+
}
|
|
4421
|
+
async function api(baseUrl, method, path2, body) {
|
|
4422
|
+
const res = await fetch(`${baseUrl}${path2}`, {
|
|
4423
|
+
method,
|
|
4424
|
+
headers: { "content-type": "application/json", "x-operator-role": "admin", "x-operator-user": "admin" },
|
|
4425
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
4426
|
+
});
|
|
4427
|
+
const text = await res.text();
|
|
4428
|
+
if (!res.ok) {
|
|
4429
|
+
throw new Error(`operator-service ${method} ${path2} \u2192 ${res.status}: ${text}`);
|
|
4430
|
+
}
|
|
4431
|
+
return text ? JSON.parse(text) : {};
|
|
4432
|
+
}
|
|
4433
|
+
function collectVault(value, prev) {
|
|
4434
|
+
const [namespace, endpoint] = value.split("=");
|
|
4435
|
+
if (!namespace || !endpoint) {
|
|
4436
|
+
throw new Error(`--vault must be "<owner/ns>=<endpoint>", got "${value}"`);
|
|
4437
|
+
}
|
|
4438
|
+
return [...prev, { namespace, endpoint }];
|
|
4439
|
+
}
|
|
4440
|
+
var operatorCommand = new Command15("operator").description(
|
|
4441
|
+
"Control Plane admin: users, vaults, provisioning requests"
|
|
4442
|
+
);
|
|
4443
|
+
var userCmd = operatorCommand.command("user").description("User management");
|
|
4444
|
+
userCmd.command("add <userId>").description("Onboard a user and emit a pre-provisioned config bundle").requiredOption("--tenant <tenant>", "tenant the user belongs to").requiredOption("--cp-url <url>", "public Control Plane URL the client connects to").option("--role <role>", "user role", "user").option("--vault <ns=endpoint>", "assign a vault (repeatable)", collectVault, []).option("--ttl <seconds>", "initial token lifetime", "86400").option("--out <path>", "bundle output path").option("--operator-url <url>", "operator-service base URL (skip port-forward)").option("--namespace <ns>", "k8s namespace for port-forward", "horus-system").action(async (userId, opts) => {
|
|
4445
|
+
const vaults = opts.vault;
|
|
4446
|
+
const client = await connect(opts);
|
|
4447
|
+
try {
|
|
4448
|
+
await api(client.baseUrl, "POST", "/requests", {
|
|
4449
|
+
kind: "onboard",
|
|
4450
|
+
tenant: opts.tenant,
|
|
4451
|
+
payload: { userId, role: opts.role, assignedVaults: vaults.map((v) => v.namespace) }
|
|
4452
|
+
});
|
|
4453
|
+
const minted = await api(client.baseUrl, "POST", "/tokens", {
|
|
4454
|
+
userId,
|
|
4455
|
+
ttlSeconds: Number(opts.ttl)
|
|
4456
|
+
});
|
|
4457
|
+
const bundle = buildBundle({
|
|
4458
|
+
controlPlaneUrl: opts.cpUrl,
|
|
4459
|
+
initialToken: minted.token,
|
|
4460
|
+
vaults
|
|
4461
|
+
});
|
|
4462
|
+
const outPath = resolve2(opts.out ?? `./${userId}.bundle.yaml`);
|
|
4463
|
+
writeFileSync8(outPath, serializeBundle(bundle), "utf8");
|
|
4464
|
+
console.log(`Bundle written to ${outPath}`);
|
|
4465
|
+
console.log(`Run: horus setup --config ${outPath}`);
|
|
4466
|
+
} finally {
|
|
4467
|
+
await client.close();
|
|
4468
|
+
}
|
|
4469
|
+
});
|
|
4470
|
+
userCmd.command("list").description("List users").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
|
|
4471
|
+
const client = await connect(opts);
|
|
4472
|
+
try {
|
|
4473
|
+
console.log(JSON.stringify(await api(client.baseUrl, "GET", "/users"), null, 2));
|
|
4474
|
+
} finally {
|
|
4475
|
+
await client.close();
|
|
4476
|
+
}
|
|
4477
|
+
});
|
|
4478
|
+
var vaultCmd = operatorCommand.command("vault").description("Vault provisioning");
|
|
4479
|
+
function vaultRequest(kind) {
|
|
4480
|
+
return async (opts) => {
|
|
4481
|
+
const client = await connect(opts);
|
|
4482
|
+
try {
|
|
4483
|
+
const out = await api(client.baseUrl, "POST", "/requests", {
|
|
4484
|
+
kind,
|
|
4485
|
+
tenant: opts.tenant,
|
|
4486
|
+
payload: {
|
|
4487
|
+
namespace: opts.namespace,
|
|
4488
|
+
target_router: opts.router,
|
|
4489
|
+
backing_store_adapter: opts.adapter,
|
|
4490
|
+
endpoint: opts.endpoint
|
|
4491
|
+
}
|
|
4492
|
+
});
|
|
4493
|
+
console.log(JSON.stringify(out, null, 2));
|
|
4494
|
+
} finally {
|
|
4495
|
+
await client.close();
|
|
4496
|
+
}
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
for (const [sub, kind] of [
|
|
4500
|
+
["create", "vault_create"],
|
|
4501
|
+
["attach", "vault_attach"],
|
|
4502
|
+
["delete", "vault_delete"]
|
|
4503
|
+
]) {
|
|
4504
|
+
vaultCmd.command(`${sub}`).requiredOption("--namespace <owner/ns>", "vault namespace").requiredOption("--tenant <tenant>", "tenant").option("--router <name>", "target router", "vault-router").option("--adapter <adapter>", "backing-store adapter", "git-subdir").option("--endpoint <url>", "explicit upstream endpoint").option("--operator-url <url>", "operator-service base URL").action(vaultRequest(kind));
|
|
4505
|
+
}
|
|
4506
|
+
var reqCmd = operatorCommand.command("request").description("Provisioning requests");
|
|
4507
|
+
reqCmd.command("list").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
|
|
4508
|
+
const client = await connect(opts);
|
|
4509
|
+
try {
|
|
4510
|
+
console.log(JSON.stringify(await api(client.baseUrl, "GET", "/requests"), null, 2));
|
|
4511
|
+
} finally {
|
|
4512
|
+
await client.close();
|
|
4513
|
+
}
|
|
4514
|
+
});
|
|
4515
|
+
reqCmd.command("show <id>").option("--operator-url <url>", "operator-service base URL").action(async (id, opts) => {
|
|
4516
|
+
const client = await connect(opts);
|
|
4517
|
+
try {
|
|
4518
|
+
console.log(JSON.stringify(await api(client.baseUrl, "GET", `/requests/${id}`), null, 2));
|
|
4519
|
+
} finally {
|
|
4520
|
+
await client.close();
|
|
4521
|
+
}
|
|
4522
|
+
});
|
|
4523
|
+
for (const decision of ["approve", "reject"]) {
|
|
4524
|
+
reqCmd.command(`${decision} <id>`).option("--operator-url <url>", "operator-service base URL").action(async (id, opts) => {
|
|
4525
|
+
const client = await connect(opts);
|
|
4526
|
+
try {
|
|
4527
|
+
console.log(
|
|
4528
|
+
JSON.stringify(await api(client.baseUrl, "POST", `/requests/${id}/${decision}`), null, 2)
|
|
4529
|
+
);
|
|
4530
|
+
} finally {
|
|
4531
|
+
await client.close();
|
|
4532
|
+
}
|
|
4533
|
+
});
|
|
4534
|
+
}
|
|
4535
|
+
operatorCommand.command("login").description("Authenticate; forces bootstrap-admin credential rotation on first login (\xA7H)").option("--admin <id>", "admin user id", "admin").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
|
|
4536
|
+
const client = await connect(opts);
|
|
4537
|
+
try {
|
|
4538
|
+
const adminId = opts.admin ?? "admin";
|
|
4539
|
+
const users = await api(
|
|
4540
|
+
client.baseUrl,
|
|
4541
|
+
"GET",
|
|
4542
|
+
"/users"
|
|
4543
|
+
);
|
|
4544
|
+
const admin = users.find((u) => u.userId === adminId);
|
|
4545
|
+
if (admin?.mustRotate) {
|
|
4546
|
+
await api(client.baseUrl, "POST", "/admin/rotate", { adminId });
|
|
4547
|
+
console.log("Bootstrap admin credential rotated (forced on first login).");
|
|
4548
|
+
} else {
|
|
4549
|
+
console.log("Logged in. No rotation required.");
|
|
4550
|
+
}
|
|
4551
|
+
} finally {
|
|
4552
|
+
await client.close();
|
|
4553
|
+
}
|
|
4554
|
+
});
|
|
4555
|
+
function renderPrincipalSecrets(namespace, b) {
|
|
4556
|
+
const clientJwks = JSON.stringify(b.clientJwks);
|
|
4557
|
+
const internalSigningKey = JSON.stringify(b.internalSigningKey);
|
|
4558
|
+
const pubJwk = JSON.stringify(b.internalPublicJwk);
|
|
4559
|
+
return `apiVersion: v1
|
|
4560
|
+
kind: Secret
|
|
4561
|
+
metadata:
|
|
4562
|
+
name: horus-service-secrets
|
|
4563
|
+
namespace: ${namespace}
|
|
4564
|
+
type: Opaque
|
|
4565
|
+
stringData:
|
|
4566
|
+
HORUS_CLIENT_JWKS_JSON: '${clientJwks}'
|
|
4567
|
+
HORUS_INTERNAL_SIGNING_KEY_JSON: '${internalSigningKey}'
|
|
4568
|
+
---
|
|
4569
|
+
apiVersion: v1
|
|
4570
|
+
kind: Secret
|
|
4571
|
+
metadata:
|
|
4572
|
+
name: horus-principal-pub
|
|
4573
|
+
namespace: ${namespace}
|
|
4574
|
+
type: Opaque
|
|
4575
|
+
stringData:
|
|
4576
|
+
pub.jwk: '${pubJwk}'
|
|
4577
|
+
`;
|
|
4578
|
+
}
|
|
4579
|
+
operatorCommand.command("init").description(
|
|
4580
|
+
"Derive horus-service-secrets + horus-principal-pub from operator-service keys and apply them"
|
|
4581
|
+
).option("--namespace <ns>", "k8s namespace", "horus-system").option("--operator-url <url>", "operator-service base URL (skip port-forward)").option("--port <port>", "local port for port-forward", "8090").option("--dry-run", "print the Secret manifests instead of applying", false).option("--out <path>", "write the Secret manifests to a file instead of applying").action(async (opts) => {
|
|
4582
|
+
const client = await connect(opts);
|
|
4583
|
+
try {
|
|
4584
|
+
const bundle = await api(client.baseUrl, "GET", "/admin/principal-bundle");
|
|
4585
|
+
const ns = opts.namespace ?? "horus-system";
|
|
4586
|
+
const manifests = renderPrincipalSecrets(ns, bundle);
|
|
4587
|
+
if (opts.out) {
|
|
4588
|
+
writeFileSync8(resolve2(opts.out), manifests, "utf8");
|
|
4589
|
+
console.log(`Principal Secrets written to ${resolve2(opts.out)}`);
|
|
4590
|
+
return;
|
|
4591
|
+
}
|
|
4592
|
+
if (opts.dryRun) {
|
|
4593
|
+
process.stdout.write(manifests);
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
await execa4("kubectl", ["apply", "-n", ns, "-f", "-"], {
|
|
4597
|
+
input: manifests,
|
|
4598
|
+
stdio: ["pipe", "inherit", "inherit"]
|
|
4599
|
+
});
|
|
4600
|
+
console.log(`Applied horus-service-secrets + horus-principal-pub to namespace ${ns}.`);
|
|
4601
|
+
console.log("horus-service can now mint X-Horus-Principal; Vault and forge-registry can verify it.");
|
|
4602
|
+
} finally {
|
|
4603
|
+
await client.close();
|
|
4604
|
+
}
|
|
4605
|
+
});
|
|
4606
|
+
operatorCommand.command("status").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
|
|
4607
|
+
const client = await connect(opts);
|
|
4608
|
+
try {
|
|
4609
|
+
const health = await api(client.baseUrl, "GET", "/health");
|
|
4610
|
+
const requests = await api(client.baseUrl, "GET", "/requests");
|
|
4611
|
+
const users = await api(client.baseUrl, "GET", "/users");
|
|
4612
|
+
console.log(
|
|
4613
|
+
JSON.stringify({ health, requests: requests.length, users: users.length }, null, 2)
|
|
4614
|
+
);
|
|
4615
|
+
} finally {
|
|
4616
|
+
await client.close();
|
|
4617
|
+
}
|
|
4618
|
+
});
|
|
4417
4619
|
|
|
4418
4620
|
// src/index.ts
|
|
4419
|
-
var program = new
|
|
4621
|
+
var program = new Command16();
|
|
4420
4622
|
program.name("horus").description("CLI for managing the Horus Docker Compose stack").version(CLI_VERSION);
|
|
4421
4623
|
program.addCommand(setupCommand);
|
|
4422
4624
|
program.addCommand(upCommand);
|
|
@@ -4424,6 +4626,7 @@ program.addCommand(downCommand);
|
|
|
4424
4626
|
program.addCommand(statusCommand);
|
|
4425
4627
|
program.addCommand(configCommand);
|
|
4426
4628
|
program.addCommand(connectCommand);
|
|
4629
|
+
program.addCommand(loginCommand);
|
|
4427
4630
|
program.addCommand(updateCommand);
|
|
4428
4631
|
program.addCommand(doctorCommand);
|
|
4429
4632
|
program.addCommand(backupCommand);
|
|
@@ -4431,6 +4634,7 @@ program.addCommand(testEnvCommand);
|
|
|
4431
4634
|
program.addCommand(helpCommand);
|
|
4432
4635
|
program.addCommand(guideCommand);
|
|
4433
4636
|
program.addCommand(repoCommand);
|
|
4637
|
+
program.addCommand(operatorCommand);
|
|
4434
4638
|
program.exitOverride();
|
|
4435
4639
|
try {
|
|
4436
4640
|
await program.parseAsync(process.argv);
|
|
@@ -4439,9 +4643,9 @@ try {
|
|
|
4439
4643
|
process.exit(0);
|
|
4440
4644
|
}
|
|
4441
4645
|
if (error instanceof Error) {
|
|
4442
|
-
console.error(
|
|
4646
|
+
console.error(chalk15.red(`Error: ${error.message}`));
|
|
4443
4647
|
} else {
|
|
4444
|
-
console.error(
|
|
4648
|
+
console.error(chalk15.red("An unexpected error occurred."));
|
|
4445
4649
|
}
|
|
4446
4650
|
process.exit(1);
|
|
4447
4651
|
}
|