@arkhera30/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compose/docker-compose.yml +238 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1796 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1796 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command10 } from "commander";
|
|
5
|
+
import chalk10 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/commands/setup.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import { password, input, confirm, number } from "@inquirer/prompts";
|
|
12
|
+
|
|
13
|
+
// src/lib/config.ts
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
15
|
+
import { resolve } from "path";
|
|
16
|
+
import { homedir as homedir2 } from "os";
|
|
17
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
18
|
+
|
|
19
|
+
// src/lib/constants.ts
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
var HORUS_DIR = join(homedir(), ".horus");
|
|
23
|
+
var CONFIG_PATH = join(HORUS_DIR, "config.yaml");
|
|
24
|
+
var ENV_PATH = join(HORUS_DIR, ".env");
|
|
25
|
+
var COMPOSE_PATH = join(HORUS_DIR, "docker-compose.yml");
|
|
26
|
+
var DEFAULT_PORTS = {
|
|
27
|
+
anvil: 8100,
|
|
28
|
+
vault_rest: 8e3,
|
|
29
|
+
vault_mcp: 8300,
|
|
30
|
+
forge: 8200
|
|
31
|
+
};
|
|
32
|
+
var DEFAULT_REPOS = {
|
|
33
|
+
anvil_notes: "",
|
|
34
|
+
vault_knowledge: "https://github.com/arkhera/knowledge-base",
|
|
35
|
+
forge_registry: "https://github.com/arkhera/Forge-Registry"
|
|
36
|
+
};
|
|
37
|
+
var DEFAULT_DATA_DIR = join(homedir(), ".horus", "data");
|
|
38
|
+
var SERVICES = [
|
|
39
|
+
"qmd-daemon",
|
|
40
|
+
"anvil",
|
|
41
|
+
"vault",
|
|
42
|
+
"vault-mcp",
|
|
43
|
+
"forge"
|
|
44
|
+
];
|
|
45
|
+
var CONFIG_VERSION = "1.0";
|
|
46
|
+
|
|
47
|
+
// src/lib/config.ts
|
|
48
|
+
function defaultConfig() {
|
|
49
|
+
return {
|
|
50
|
+
version: CONFIG_VERSION,
|
|
51
|
+
api_key: "",
|
|
52
|
+
data_dir: DEFAULT_DATA_DIR,
|
|
53
|
+
runtime: "docker",
|
|
54
|
+
ports: { ...DEFAULT_PORTS },
|
|
55
|
+
repos: { ...DEFAULT_REPOS },
|
|
56
|
+
host_repos_path: "",
|
|
57
|
+
github_token: ""
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function ensureHorusDir() {
|
|
61
|
+
mkdirSync(HORUS_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
function configExists() {
|
|
64
|
+
return existsSync(CONFIG_PATH);
|
|
65
|
+
}
|
|
66
|
+
function loadConfig() {
|
|
67
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
68
|
+
return defaultConfig();
|
|
69
|
+
}
|
|
70
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
71
|
+
const parsed = parseYaml(raw);
|
|
72
|
+
const defaults = defaultConfig();
|
|
73
|
+
return {
|
|
74
|
+
version: parsed.version ?? defaults.version,
|
|
75
|
+
api_key: parsed.api_key ?? defaults.api_key,
|
|
76
|
+
data_dir: parsed.data_dir ?? defaults.data_dir,
|
|
77
|
+
runtime: parsed.runtime ?? defaults.runtime,
|
|
78
|
+
ports: {
|
|
79
|
+
anvil: parsed.ports?.anvil ?? defaults.ports.anvil,
|
|
80
|
+
vault_rest: parsed.ports?.vault_rest ?? defaults.ports.vault_rest,
|
|
81
|
+
vault_mcp: parsed.ports?.vault_mcp ?? defaults.ports.vault_mcp,
|
|
82
|
+
forge: parsed.ports?.forge ?? defaults.ports.forge
|
|
83
|
+
},
|
|
84
|
+
repos: {
|
|
85
|
+
anvil_notes: parsed.repos?.anvil_notes ?? defaults.repos.anvil_notes,
|
|
86
|
+
vault_knowledge: parsed.repos?.vault_knowledge ?? defaults.repos.vault_knowledge,
|
|
87
|
+
forge_registry: parsed.repos?.forge_registry ?? defaults.repos.forge_registry
|
|
88
|
+
},
|
|
89
|
+
host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
|
|
90
|
+
github_token: parsed.github_token ?? defaults.github_token
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function saveConfig(config) {
|
|
94
|
+
ensureHorusDir();
|
|
95
|
+
const yaml = stringifyYaml(config, { lineWidth: 0 });
|
|
96
|
+
writeFileSync(CONFIG_PATH, yaml, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
function resolvePath(p) {
|
|
99
|
+
if (p.startsWith("~")) {
|
|
100
|
+
return resolve(homedir2(), p.slice(2));
|
|
101
|
+
}
|
|
102
|
+
return resolve(p);
|
|
103
|
+
}
|
|
104
|
+
function generateEnv(config) {
|
|
105
|
+
const dataDir = resolvePath(config.data_dir);
|
|
106
|
+
const hostReposPath = config.host_repos_path ? resolvePath(config.host_repos_path) : "";
|
|
107
|
+
const lines = [
|
|
108
|
+
"# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
109
|
+
"# Horus \u2014 Generated .env file",
|
|
110
|
+
"# Do not edit manually. Use `horus config set <key> <value>` instead.",
|
|
111
|
+
"# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
112
|
+
"",
|
|
113
|
+
`HORUS_DATA_PATH=${dataDir}`,
|
|
114
|
+
`HOST_REPOS_PATH=${hostReposPath}`,
|
|
115
|
+
"",
|
|
116
|
+
"# Ports",
|
|
117
|
+
`ANVIL_PORT=${config.ports.anvil}`,
|
|
118
|
+
`VAULT_PORT=${config.ports.vault_rest}`,
|
|
119
|
+
`VAULT_MCP_PORT=${config.ports.vault_mcp}`,
|
|
120
|
+
`FORGE_PORT=${config.ports.forge}`,
|
|
121
|
+
"",
|
|
122
|
+
"# Auth",
|
|
123
|
+
`GITHUB_TOKEN=${config.github_token}`,
|
|
124
|
+
"",
|
|
125
|
+
"# Repository URLs",
|
|
126
|
+
`ANVIL_REPO_URL=${config.repos.anvil_notes}`,
|
|
127
|
+
`VAULT_KNOWLEDGE_REPO_URL=${config.repos.vault_knowledge}`,
|
|
128
|
+
`FORGE_REGISTRY_REPO_URL=${config.repos.forge_registry}`,
|
|
129
|
+
""
|
|
130
|
+
];
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
function writeEnvFile(config) {
|
|
134
|
+
ensureHorusDir();
|
|
135
|
+
const content = generateEnv(config);
|
|
136
|
+
writeFileSync(ENV_PATH, content, "utf-8");
|
|
137
|
+
}
|
|
138
|
+
var CONFIG_KEYS = [
|
|
139
|
+
"api-key",
|
|
140
|
+
"data-dir",
|
|
141
|
+
"host-repos-path",
|
|
142
|
+
"runtime",
|
|
143
|
+
"port.anvil",
|
|
144
|
+
"port.vault-rest",
|
|
145
|
+
"port.vault-mcp",
|
|
146
|
+
"port.forge",
|
|
147
|
+
"github-token"
|
|
148
|
+
];
|
|
149
|
+
function getConfigValue(config, key) {
|
|
150
|
+
switch (key) {
|
|
151
|
+
case "api-key":
|
|
152
|
+
return config.api_key;
|
|
153
|
+
case "data-dir":
|
|
154
|
+
return config.data_dir;
|
|
155
|
+
case "host-repos-path":
|
|
156
|
+
return config.host_repos_path;
|
|
157
|
+
case "runtime":
|
|
158
|
+
return config.runtime;
|
|
159
|
+
case "port.anvil":
|
|
160
|
+
return String(config.ports.anvil);
|
|
161
|
+
case "port.vault-rest":
|
|
162
|
+
return String(config.ports.vault_rest);
|
|
163
|
+
case "port.vault-mcp":
|
|
164
|
+
return String(config.ports.vault_mcp);
|
|
165
|
+
case "port.forge":
|
|
166
|
+
return String(config.ports.forge);
|
|
167
|
+
case "github-token":
|
|
168
|
+
return config.github_token;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function setConfigValue(config, key, value) {
|
|
172
|
+
const updated = { ...config };
|
|
173
|
+
switch (key) {
|
|
174
|
+
case "api-key":
|
|
175
|
+
updated.api_key = value;
|
|
176
|
+
break;
|
|
177
|
+
case "data-dir":
|
|
178
|
+
updated.data_dir = value;
|
|
179
|
+
break;
|
|
180
|
+
case "host-repos-path":
|
|
181
|
+
updated.host_repos_path = value;
|
|
182
|
+
break;
|
|
183
|
+
case "runtime":
|
|
184
|
+
if (value !== "docker" && value !== "podman") {
|
|
185
|
+
throw new Error(`Invalid runtime: ${value}. Must be "docker" or "podman".`);
|
|
186
|
+
}
|
|
187
|
+
updated.runtime = value;
|
|
188
|
+
break;
|
|
189
|
+
case "port.anvil":
|
|
190
|
+
updated.ports = { ...updated.ports, anvil: parseInt(value, 10) };
|
|
191
|
+
break;
|
|
192
|
+
case "port.vault-rest":
|
|
193
|
+
updated.ports = { ...updated.ports, vault_rest: parseInt(value, 10) };
|
|
194
|
+
break;
|
|
195
|
+
case "port.vault-mcp":
|
|
196
|
+
updated.ports = { ...updated.ports, vault_mcp: parseInt(value, 10) };
|
|
197
|
+
break;
|
|
198
|
+
case "port.forge":
|
|
199
|
+
updated.ports = { ...updated.ports, forge: parseInt(value, 10) };
|
|
200
|
+
break;
|
|
201
|
+
case "github-token":
|
|
202
|
+
updated.github_token = value;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
return updated;
|
|
206
|
+
}
|
|
207
|
+
function maskApiKey(key) {
|
|
208
|
+
if (!key || key.length < 12) return key ? "****" : "(not set)";
|
|
209
|
+
return `${key.slice(0, 7)}...${key.slice(-4)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/lib/runtime.ts
|
|
213
|
+
import { execa } from "execa";
|
|
214
|
+
function toResult(result) {
|
|
215
|
+
return {
|
|
216
|
+
stdout: result.stdout?.toString() ?? "",
|
|
217
|
+
stderr: result.stderr?.toString() ?? "",
|
|
218
|
+
exitCode: result.exitCode ?? 0
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function tryCommand(command, args) {
|
|
222
|
+
try {
|
|
223
|
+
await execa(command, args, { reject: false });
|
|
224
|
+
return true;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function createRuntime(name) {
|
|
230
|
+
const bin = name;
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
async compose(...args) {
|
|
234
|
+
const result = await execa(bin, ["compose", ...args], {
|
|
235
|
+
cwd: HORUS_DIR,
|
|
236
|
+
reject: false
|
|
237
|
+
});
|
|
238
|
+
if (result.exitCode !== 0) {
|
|
239
|
+
const error = new Error(
|
|
240
|
+
`${bin} compose ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr}`
|
|
241
|
+
);
|
|
242
|
+
error.result = toResult(result);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
return toResult(result);
|
|
246
|
+
},
|
|
247
|
+
async exec(container, ...cmd) {
|
|
248
|
+
const result = await execa(bin, ["exec", container, ...cmd], {
|
|
249
|
+
reject: false
|
|
250
|
+
});
|
|
251
|
+
return toResult(result);
|
|
252
|
+
},
|
|
253
|
+
async inspect(container, format) {
|
|
254
|
+
const result = await execa(bin, ["inspect", "--format", format, container], {
|
|
255
|
+
reject: false
|
|
256
|
+
});
|
|
257
|
+
return result.stdout?.toString().trim() ?? "";
|
|
258
|
+
},
|
|
259
|
+
async isRunning() {
|
|
260
|
+
try {
|
|
261
|
+
const result = await execa(bin, ["compose", "ps", "--format", "json"], {
|
|
262
|
+
cwd: HORUS_DIR,
|
|
263
|
+
reject: false
|
|
264
|
+
});
|
|
265
|
+
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function detectRuntime(preferred) {
|
|
273
|
+
if (preferred) {
|
|
274
|
+
const hasPreferred = await tryCommand(preferred, ["compose", "version"]);
|
|
275
|
+
if (hasPreferred) {
|
|
276
|
+
return createRuntime(preferred);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const hasDocker = await tryCommand("docker", ["compose", "version"]);
|
|
280
|
+
if (hasDocker) {
|
|
281
|
+
return createRuntime("docker");
|
|
282
|
+
}
|
|
283
|
+
const hasPodman = await tryCommand("podman", ["compose", "version"]);
|
|
284
|
+
if (hasPodman) {
|
|
285
|
+
return createRuntime("podman");
|
|
286
|
+
}
|
|
287
|
+
throw new Error(
|
|
288
|
+
"No container runtime found.\n\nHorus requires Docker or Podman with the Compose plugin.\n\nInstall one of:\n - Docker Desktop: https://www.docker.com/products/docker-desktop/\n - Podman Desktop: https://podman-desktop.io/\n"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
async function composeStreaming(runtime, args) {
|
|
292
|
+
const bin = runtime.name;
|
|
293
|
+
const result = await execa(bin, ["compose", ...args], {
|
|
294
|
+
cwd: HORUS_DIR,
|
|
295
|
+
stdio: "inherit",
|
|
296
|
+
reject: false
|
|
297
|
+
});
|
|
298
|
+
if (result.exitCode !== 0) {
|
|
299
|
+
throw new Error(`${bin} compose ${args.join(" ")} failed with exit code ${result.exitCode}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/lib/health.ts
|
|
304
|
+
async function checkContainerHealth(runtime, service) {
|
|
305
|
+
const containerName = `horus-${service}-1`;
|
|
306
|
+
try {
|
|
307
|
+
const status = await runtime.inspect(containerName, "{{.State.Health.Status}}");
|
|
308
|
+
const mappedStatus = mapStatus(status);
|
|
309
|
+
return { name: service, status: mappedStatus };
|
|
310
|
+
} catch {
|
|
311
|
+
return { name: service, status: "stopped" };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function mapStatus(raw) {
|
|
315
|
+
switch (raw.trim().toLowerCase()) {
|
|
316
|
+
case "healthy":
|
|
317
|
+
return "healthy";
|
|
318
|
+
case "starting":
|
|
319
|
+
return "starting";
|
|
320
|
+
case "unhealthy":
|
|
321
|
+
return "unhealthy";
|
|
322
|
+
default:
|
|
323
|
+
return "unknown";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function checkAllHealth(runtime) {
|
|
327
|
+
const results = await Promise.all(
|
|
328
|
+
SERVICES.map((service) => checkContainerHealth(runtime, service))
|
|
329
|
+
);
|
|
330
|
+
return results;
|
|
331
|
+
}
|
|
332
|
+
async function pollUntilHealthy(runtime, onUpdate, timeoutMs = 6e5, intervalMs = 5e3) {
|
|
333
|
+
const startTime = Date.now();
|
|
334
|
+
while (true) {
|
|
335
|
+
const states = await checkAllHealth(runtime);
|
|
336
|
+
if (onUpdate) {
|
|
337
|
+
onUpdate(states);
|
|
338
|
+
}
|
|
339
|
+
const allHealthy = states.every((s) => s.status === "healthy");
|
|
340
|
+
if (allHealthy) {
|
|
341
|
+
return states;
|
|
342
|
+
}
|
|
343
|
+
const hasUnhealthy = states.some((s) => s.status === "unhealthy");
|
|
344
|
+
if (hasUnhealthy) {
|
|
345
|
+
const unhealthyServices = states.filter((s) => s.status === "unhealthy").map((s) => s.name).join(", ");
|
|
346
|
+
throw new Error(
|
|
347
|
+
`Services failed health check: ${unhealthyServices}
|
|
348
|
+
Run 'docker compose logs <service>' from ~/.horus/ to investigate.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const elapsed = Date.now() - startTime;
|
|
352
|
+
if (elapsed >= timeoutMs) {
|
|
353
|
+
const notReady = states.filter((s) => s.status !== "healthy").map((s) => `${s.name} (${s.status})`).join(", ");
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for services: ${notReady}
|
|
356
|
+
Run 'docker compose logs' from ~/.horus/ to investigate.`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/lib/compose.ts
|
|
364
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
365
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
366
|
+
import { fileURLToPath } from "url";
|
|
367
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
368
|
+
var __dirname = dirname2(__filename);
|
|
369
|
+
function getBundledComposePath() {
|
|
370
|
+
const candidates = [
|
|
371
|
+
join2(__dirname, "..", "..", "compose", "docker-compose.yml"),
|
|
372
|
+
join2(__dirname, "..", "compose", "docker-compose.yml")
|
|
373
|
+
];
|
|
374
|
+
for (const candidate of candidates) {
|
|
375
|
+
if (existsSync2(candidate)) {
|
|
376
|
+
return candidate;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Bundled docker-compose.yml not found. The CLI package may be corrupted.
|
|
381
|
+
Searched: ${candidates.join(", ")}`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
function composeFileExists() {
|
|
385
|
+
return existsSync2(COMPOSE_PATH);
|
|
386
|
+
}
|
|
387
|
+
function installComposeFile() {
|
|
388
|
+
ensureHorusDir();
|
|
389
|
+
const bundledPath = getBundledComposePath();
|
|
390
|
+
const content = readFileSync2(bundledPath, "utf-8");
|
|
391
|
+
writeFileSync2(COMPOSE_PATH, content, "utf-8");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/commands/setup.ts
|
|
395
|
+
var setupCommand = new Command("setup").description("Interactive first-run setup for Horus").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--api-key <key>", "Anthropic API key").option("--data-dir <path>", "Data directory path").option("--repos-path <path>", "Host repos path for Forge scanning").action(async (opts) => {
|
|
396
|
+
console.log("");
|
|
397
|
+
console.log(chalk.bold("Horus Setup"));
|
|
398
|
+
console.log(chalk.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"));
|
|
399
|
+
console.log("");
|
|
400
|
+
if (configExists()) {
|
|
401
|
+
if (opts.yes) {
|
|
402
|
+
console.log(chalk.yellow("Existing configuration found. Overwriting in non-interactive mode."));
|
|
403
|
+
} else {
|
|
404
|
+
const proceed = await confirm({
|
|
405
|
+
message: "Horus is already configured. Reconfigure?",
|
|
406
|
+
default: false
|
|
407
|
+
});
|
|
408
|
+
if (!proceed) {
|
|
409
|
+
console.log(chalk.dim("Setup cancelled."));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const runtimeSpinner = ora("Detecting container runtime...").start();
|
|
415
|
+
let runtime;
|
|
416
|
+
try {
|
|
417
|
+
runtime = await detectRuntime();
|
|
418
|
+
runtimeSpinner.succeed(`Detected ${chalk.cyan(runtime.name)}`);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
runtimeSpinner.fail("No container runtime found");
|
|
421
|
+
console.log("");
|
|
422
|
+
console.log(error.message);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
let config;
|
|
426
|
+
if (opts.yes) {
|
|
427
|
+
const apiKey = opts.apiKey || process.env.HORUS_API_KEY || "";
|
|
428
|
+
if (!apiKey) {
|
|
429
|
+
console.log(chalk.red("Error: API key is required."));
|
|
430
|
+
console.log(chalk.dim("Set HORUS_API_KEY env var or use --api-key flag."));
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
config = {
|
|
434
|
+
...defaultConfig(),
|
|
435
|
+
api_key: apiKey,
|
|
436
|
+
runtime: runtime.name,
|
|
437
|
+
data_dir: opts.dataDir || DEFAULT_DATA_DIR,
|
|
438
|
+
host_repos_path: opts.reposPath || ""
|
|
439
|
+
};
|
|
440
|
+
} else {
|
|
441
|
+
const api_key = await password({
|
|
442
|
+
message: "Anthropic API key:",
|
|
443
|
+
mask: "*",
|
|
444
|
+
validate: (val) => {
|
|
445
|
+
if (!val) return "API key is required";
|
|
446
|
+
if (!val.startsWith("sk-ant-")) return 'API key must start with "sk-ant-"';
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
const data_dir = await input({
|
|
451
|
+
message: "Data directory:",
|
|
452
|
+
default: DEFAULT_DATA_DIR
|
|
453
|
+
});
|
|
454
|
+
const host_repos_path = await input({
|
|
455
|
+
message: "Host repos path (for Forge repo scanning, leave empty to skip):",
|
|
456
|
+
default: ""
|
|
457
|
+
});
|
|
458
|
+
const customize_ports = await confirm({
|
|
459
|
+
message: "Customize port assignments?",
|
|
460
|
+
default: false
|
|
461
|
+
});
|
|
462
|
+
let ports = { ...DEFAULT_PORTS };
|
|
463
|
+
if (customize_ports) {
|
|
464
|
+
const anvil = await number({
|
|
465
|
+
message: "Anvil port:",
|
|
466
|
+
default: DEFAULT_PORTS.anvil
|
|
467
|
+
});
|
|
468
|
+
const vault_rest = await number({
|
|
469
|
+
message: "Vault REST port:",
|
|
470
|
+
default: DEFAULT_PORTS.vault_rest
|
|
471
|
+
});
|
|
472
|
+
const vault_mcp = await number({
|
|
473
|
+
message: "Vault MCP port:",
|
|
474
|
+
default: DEFAULT_PORTS.vault_mcp
|
|
475
|
+
});
|
|
476
|
+
const forge = await number({
|
|
477
|
+
message: "Forge port:",
|
|
478
|
+
default: DEFAULT_PORTS.forge
|
|
479
|
+
});
|
|
480
|
+
ports = {
|
|
481
|
+
anvil: anvil ?? DEFAULT_PORTS.anvil,
|
|
482
|
+
vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
|
|
483
|
+
vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
|
|
484
|
+
forge: forge ?? DEFAULT_PORTS.forge
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
config = {
|
|
488
|
+
...defaultConfig(),
|
|
489
|
+
api_key,
|
|
490
|
+
data_dir,
|
|
491
|
+
host_repos_path,
|
|
492
|
+
runtime: runtime.name,
|
|
493
|
+
ports
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const configSpinner = ora("Saving configuration...").start();
|
|
497
|
+
try {
|
|
498
|
+
saveConfig(config);
|
|
499
|
+
configSpinner.succeed("Configuration saved to ~/.horus/config.yaml");
|
|
500
|
+
} catch (error) {
|
|
501
|
+
configSpinner.fail("Failed to save configuration");
|
|
502
|
+
console.error(error.message);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
const envSpinner = ora("Generating .env file...").start();
|
|
506
|
+
try {
|
|
507
|
+
writeEnvFile(config);
|
|
508
|
+
envSpinner.succeed("Environment file written to ~/.horus/.env");
|
|
509
|
+
} catch (error) {
|
|
510
|
+
envSpinner.fail("Failed to generate .env");
|
|
511
|
+
console.error(error.message);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
const composeSpinner = ora("Installing docker-compose.yml...").start();
|
|
515
|
+
try {
|
|
516
|
+
installComposeFile();
|
|
517
|
+
composeSpinner.succeed("Compose file installed to ~/.horus/docker-compose.yml");
|
|
518
|
+
} catch (error) {
|
|
519
|
+
composeSpinner.fail("Failed to install compose file");
|
|
520
|
+
console.error(error.message);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
console.log("");
|
|
524
|
+
console.log(chalk.bold("Pulling container images..."));
|
|
525
|
+
try {
|
|
526
|
+
await composeStreaming(runtime, ["pull"]);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
console.log(chalk.red("Failed to pull images."));
|
|
529
|
+
console.log(chalk.dim(error.message));
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
console.log("");
|
|
533
|
+
console.log(chalk.bold("Starting Horus services..."));
|
|
534
|
+
try {
|
|
535
|
+
await composeStreaming(runtime, ["up", "-d"]);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
console.log(chalk.red("Failed to start services."));
|
|
538
|
+
console.log(chalk.dim(error.message));
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
console.log("");
|
|
542
|
+
const healthSpinner = ora("Waiting for services to become healthy...").start();
|
|
543
|
+
let lastStates = [];
|
|
544
|
+
try {
|
|
545
|
+
const states = await pollUntilHealthy(
|
|
546
|
+
runtime,
|
|
547
|
+
(current) => {
|
|
548
|
+
lastStates = current;
|
|
549
|
+
const summary = current.map((s) => {
|
|
550
|
+
const icon = s.status === "healthy" ? chalk.green("*") : s.status === "starting" ? chalk.yellow("~") : chalk.red("x");
|
|
551
|
+
return `${icon} ${s.name}`;
|
|
552
|
+
}).join(" ");
|
|
553
|
+
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
554
|
+
},
|
|
555
|
+
6e5,
|
|
556
|
+
5e3
|
|
557
|
+
);
|
|
558
|
+
healthSpinner.succeed("All services are healthy");
|
|
559
|
+
lastStates = states;
|
|
560
|
+
} catch (error) {
|
|
561
|
+
healthSpinner.fail("Some services did not become healthy");
|
|
562
|
+
console.log(chalk.dim(error.message));
|
|
563
|
+
console.log("");
|
|
564
|
+
console.log(chalk.dim("Tip: Check logs with `docker compose logs` from ~/.horus/"));
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
console.log("");
|
|
568
|
+
console.log(chalk.bold.green("Setup complete!"));
|
|
569
|
+
console.log(chalk.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"));
|
|
570
|
+
console.log("");
|
|
571
|
+
console.log(` ${chalk.bold("Runtime:")} ${runtime.name}`);
|
|
572
|
+
console.log(` ${chalk.bold("Config:")} ~/.horus/config.yaml`);
|
|
573
|
+
console.log(` ${chalk.bold("Data:")} ${config.data_dir}`);
|
|
574
|
+
console.log("");
|
|
575
|
+
console.log(chalk.bold(" Service URLs:"));
|
|
576
|
+
console.log(` Anvil: http://localhost:${config.ports.anvil}`);
|
|
577
|
+
console.log(` Vault REST: http://localhost:${config.ports.vault_rest}`);
|
|
578
|
+
console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
|
|
579
|
+
console.log(` Forge: http://localhost:${config.ports.forge}`);
|
|
580
|
+
console.log("");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// src/commands/up.ts
|
|
584
|
+
import { Command as Command2 } from "commander";
|
|
585
|
+
import chalk2 from "chalk";
|
|
586
|
+
import ora2 from "ora";
|
|
587
|
+
var upCommand = new Command2("up").description("Start the Horus stack").action(async () => {
|
|
588
|
+
if (!configExists() || !composeFileExists()) {
|
|
589
|
+
console.log(chalk2.red("Horus is not set up yet."));
|
|
590
|
+
console.log(chalk2.dim("Run `horus setup` first."));
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
const config = loadConfig();
|
|
594
|
+
const spinner = ora2("Detecting runtime...").start();
|
|
595
|
+
let runtime;
|
|
596
|
+
try {
|
|
597
|
+
runtime = await detectRuntime(config.runtime);
|
|
598
|
+
spinner.succeed(`Using ${chalk2.cyan(runtime.name)}`);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
spinner.fail("No container runtime found");
|
|
601
|
+
console.log(error.message);
|
|
602
|
+
process.exit(1);
|
|
603
|
+
}
|
|
604
|
+
console.log("");
|
|
605
|
+
console.log(chalk2.bold("Starting Horus services..."));
|
|
606
|
+
try {
|
|
607
|
+
await composeStreaming(runtime, ["up", "-d"]);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
console.log(chalk2.red("Failed to start services."));
|
|
610
|
+
console.log(chalk2.dim(error.message));
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
console.log("");
|
|
614
|
+
const statusSpinner = ora2("Checking service status...").start();
|
|
615
|
+
try {
|
|
616
|
+
const states = await checkAllHealth(runtime);
|
|
617
|
+
statusSpinner.stop();
|
|
618
|
+
console.log(chalk2.bold("Service Status:"));
|
|
619
|
+
for (const s of states) {
|
|
620
|
+
const color = s.status === "healthy" ? chalk2.green : s.status === "starting" ? chalk2.yellow : chalk2.red;
|
|
621
|
+
console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
|
|
622
|
+
}
|
|
623
|
+
const allHealthy = states.every((s) => s.status === "healthy");
|
|
624
|
+
if (!allHealthy) {
|
|
625
|
+
console.log("");
|
|
626
|
+
console.log(
|
|
627
|
+
chalk2.yellow("Some services are still starting. Run `horus status` to check progress.")
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
statusSpinner.warn("Could not check service status");
|
|
632
|
+
}
|
|
633
|
+
console.log("");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// src/commands/down.ts
|
|
637
|
+
import { Command as Command3 } from "commander";
|
|
638
|
+
import chalk3 from "chalk";
|
|
639
|
+
import ora3 from "ora";
|
|
640
|
+
var downCommand = new Command3("down").description("Stop the Horus stack").action(async () => {
|
|
641
|
+
if (!configExists() || !composeFileExists()) {
|
|
642
|
+
console.log(chalk3.red("Horus is not set up yet."));
|
|
643
|
+
console.log(chalk3.dim("Run `horus setup` first."));
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
const config = loadConfig();
|
|
647
|
+
const spinner = ora3("Detecting runtime...").start();
|
|
648
|
+
let runtime;
|
|
649
|
+
try {
|
|
650
|
+
runtime = await detectRuntime(config.runtime);
|
|
651
|
+
spinner.succeed(`Using ${chalk3.cyan(runtime.name)}`);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
spinner.fail("No container runtime found");
|
|
654
|
+
console.log(error.message);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
console.log("");
|
|
658
|
+
console.log(chalk3.bold("Stopping Horus services..."));
|
|
659
|
+
try {
|
|
660
|
+
await composeStreaming(runtime, ["down"]);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
console.log(chalk3.red("Failed to stop services."));
|
|
663
|
+
console.log(chalk3.dim(error.message));
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
console.log("");
|
|
667
|
+
console.log(chalk3.green("All services stopped."));
|
|
668
|
+
console.log(chalk3.dim("Data volumes have been preserved. Run `horus up` to restart."));
|
|
669
|
+
console.log("");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// src/commands/status.ts
|
|
673
|
+
import { Command as Command4 } from "commander";
|
|
674
|
+
import chalk4 from "chalk";
|
|
675
|
+
import ora4 from "ora";
|
|
676
|
+
var statusCommand = new Command4("status").description("Show status of Horus services").action(async () => {
|
|
677
|
+
if (!configExists() || !composeFileExists()) {
|
|
678
|
+
console.log(chalk4.red("Horus is not set up yet."));
|
|
679
|
+
console.log(chalk4.dim("Run `horus setup` first."));
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
const config = loadConfig();
|
|
683
|
+
const spinner = ora4("Checking services...").start();
|
|
684
|
+
let runtime;
|
|
685
|
+
try {
|
|
686
|
+
runtime = await detectRuntime(config.runtime);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
spinner.fail("No container runtime found");
|
|
689
|
+
console.log(error.message);
|
|
690
|
+
process.exit(1);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
let containers = [];
|
|
694
|
+
try {
|
|
695
|
+
const result = await runtime.compose("ps", "--format", "json");
|
|
696
|
+
const output = result.stdout.trim();
|
|
697
|
+
if (output) {
|
|
698
|
+
containers = output.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
699
|
+
}
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
spinner.stop();
|
|
703
|
+
console.log("");
|
|
704
|
+
console.log(chalk4.bold("Horus Status"));
|
|
705
|
+
console.log(chalk4.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"));
|
|
706
|
+
console.log(` ${chalk4.bold("Version:")} ${config.version}`);
|
|
707
|
+
console.log(` ${chalk4.bold("Runtime:")} ${runtime.name}`);
|
|
708
|
+
console.log(` ${chalk4.bold("Config:")} ~/.horus/config.yaml`);
|
|
709
|
+
console.log("");
|
|
710
|
+
if (containers.length === 0) {
|
|
711
|
+
console.log(chalk4.yellow(" No services are running."));
|
|
712
|
+
console.log(chalk4.dim(" Run `horus up` to start the stack."));
|
|
713
|
+
console.log("");
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const header = ` ${pad("SERVICE", 14)} ${pad("STATUS", 12)} ${pad("PORTS", 20)} ${pad("UPTIME", 20)}`;
|
|
717
|
+
console.log(chalk4.bold(header));
|
|
718
|
+
console.log(chalk4.dim(" " + "\u2500".repeat(66)));
|
|
719
|
+
for (const service of SERVICES) {
|
|
720
|
+
const container = containers.find(
|
|
721
|
+
(c) => c.Service === service || c.Name?.includes(service)
|
|
722
|
+
);
|
|
723
|
+
if (!container) {
|
|
724
|
+
console.log(
|
|
725
|
+
` ${pad(service, 14)} ${chalk4.red(pad("stopped", 12))} ${pad("-", 20)} ${pad("-", 20)}`
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const healthStatus = container.Health || container.State || "unknown";
|
|
730
|
+
const statusColor = getStatusColor(healthStatus);
|
|
731
|
+
const displayStatus = statusColor(pad(healthStatus, 12));
|
|
732
|
+
const ports = formatPorts(container.Publishers);
|
|
733
|
+
const uptime = extractUptime(container.Status);
|
|
734
|
+
console.log(` ${pad(service, 14)} ${displayStatus} ${pad(ports, 20)} ${pad(uptime, 20)}`);
|
|
735
|
+
}
|
|
736
|
+
console.log("");
|
|
737
|
+
});
|
|
738
|
+
function pad(str, width) {
|
|
739
|
+
return str.padEnd(width);
|
|
740
|
+
}
|
|
741
|
+
function getStatusColor(status) {
|
|
742
|
+
const lower = status.toLowerCase();
|
|
743
|
+
if (lower === "healthy" || lower === "running") return chalk4.green;
|
|
744
|
+
if (lower === "starting") return chalk4.yellow;
|
|
745
|
+
return chalk4.red;
|
|
746
|
+
}
|
|
747
|
+
function formatPorts(publishers) {
|
|
748
|
+
if (!publishers || publishers.length === 0) return "-";
|
|
749
|
+
const mapped = publishers.filter((p) => p.PublishedPort > 0).map((p) => `${p.PublishedPort}:${p.TargetPort}`).filter((v, i, a) => a.indexOf(v) === i);
|
|
750
|
+
return mapped.length > 0 ? mapped.join(", ") : "-";
|
|
751
|
+
}
|
|
752
|
+
function extractUptime(status) {
|
|
753
|
+
if (!status) return "-";
|
|
754
|
+
const match = status.match(/^Up\s+(.+?)(?:\s*\(.*\))?$/i);
|
|
755
|
+
if (match) return match[1].trim();
|
|
756
|
+
return status;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/commands/config.ts
|
|
760
|
+
import { Command as Command5 } from "commander";
|
|
761
|
+
import chalk5 from "chalk";
|
|
762
|
+
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
763
|
+
var configCommand = new Command5("config").description("View or modify Horus configuration").action(async () => {
|
|
764
|
+
if (!configExists()) {
|
|
765
|
+
console.log(chalk5.red("Horus is not configured yet."));
|
|
766
|
+
console.log(chalk5.dim("Run `horus setup` first."));
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
const config = loadConfig();
|
|
770
|
+
console.log("");
|
|
771
|
+
console.log(chalk5.bold("Horus Configuration"));
|
|
772
|
+
console.log(chalk5.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"));
|
|
773
|
+
console.log(` ${chalk5.bold("version:")} ${config.version}`);
|
|
774
|
+
console.log(` ${chalk5.bold("api-key:")} ${maskApiKey(config.api_key)}`);
|
|
775
|
+
console.log(` ${chalk5.bold("data-dir:")} ${config.data_dir}`);
|
|
776
|
+
console.log(` ${chalk5.bold("runtime:")} ${config.runtime}`);
|
|
777
|
+
console.log(` ${chalk5.bold("host-repos-path:")} ${config.host_repos_path || chalk5.dim("(not set)")}`);
|
|
778
|
+
console.log(` ${chalk5.bold("github-token:")} ${config.github_token ? maskApiKey(config.github_token) : chalk5.dim("(not set)")}`);
|
|
779
|
+
console.log("");
|
|
780
|
+
console.log(chalk5.bold(" Ports:"));
|
|
781
|
+
console.log(` ${chalk5.bold("anvil:")} ${config.ports.anvil}`);
|
|
782
|
+
console.log(` ${chalk5.bold("vault-rest:")} ${config.ports.vault_rest}`);
|
|
783
|
+
console.log(` ${chalk5.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
|
|
784
|
+
console.log(` ${chalk5.bold("forge:")} ${config.ports.forge}`);
|
|
785
|
+
console.log("");
|
|
786
|
+
console.log(chalk5.bold(" Repos:"));
|
|
787
|
+
console.log(` ${chalk5.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk5.dim("(not set)")}`);
|
|
788
|
+
console.log(` ${chalk5.bold("vault-knowledge:")} ${config.repos.vault_knowledge || chalk5.dim("(not set)")}`);
|
|
789
|
+
console.log(` ${chalk5.bold("forge-registry:")} ${config.repos.forge_registry || chalk5.dim("(not set)")}`);
|
|
790
|
+
console.log("");
|
|
791
|
+
console.log(chalk5.dim(` Config file: ~/.horus/config.yaml`));
|
|
792
|
+
console.log(chalk5.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
|
|
793
|
+
console.log("");
|
|
794
|
+
});
|
|
795
|
+
configCommand.command("get <key>").description("Get a configuration value").action(async (key) => {
|
|
796
|
+
if (!configExists()) {
|
|
797
|
+
console.log(chalk5.red("Horus is not configured yet."));
|
|
798
|
+
console.log(chalk5.dim("Run `horus setup` first."));
|
|
799
|
+
process.exit(1);
|
|
800
|
+
}
|
|
801
|
+
if (!isValidKey(key)) {
|
|
802
|
+
console.log(chalk5.red(`Unknown config key: ${key}`));
|
|
803
|
+
console.log(chalk5.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
const config = loadConfig();
|
|
807
|
+
const value = getConfigValue(config, key);
|
|
808
|
+
if (key === "api-key" || key === "github-token") {
|
|
809
|
+
console.log(maskApiKey(value));
|
|
810
|
+
} else {
|
|
811
|
+
console.log(value || "");
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
|
|
815
|
+
if (!configExists()) {
|
|
816
|
+
console.log(chalk5.red("Horus is not configured yet."));
|
|
817
|
+
console.log(chalk5.dim("Run `horus setup` first."));
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
if (!isValidKey(key)) {
|
|
821
|
+
console.log(chalk5.red(`Unknown config key: ${key}`));
|
|
822
|
+
console.log(chalk5.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
let config = loadConfig();
|
|
826
|
+
try {
|
|
827
|
+
config = setConfigValue(config, key, value);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
console.log(chalk5.red(error.message));
|
|
830
|
+
process.exit(1);
|
|
831
|
+
}
|
|
832
|
+
saveConfig(config);
|
|
833
|
+
writeEnvFile(config);
|
|
834
|
+
console.log(chalk5.green(`Set ${key} and regenerated .env file.`));
|
|
835
|
+
const needsRestart = [
|
|
836
|
+
"api-key",
|
|
837
|
+
"data-dir",
|
|
838
|
+
"host-repos-path",
|
|
839
|
+
"runtime",
|
|
840
|
+
"port.anvil",
|
|
841
|
+
"port.vault-rest",
|
|
842
|
+
"port.vault-mcp",
|
|
843
|
+
"port.forge"
|
|
844
|
+
];
|
|
845
|
+
if (needsRestart.includes(key)) {
|
|
846
|
+
console.log(chalk5.yellow("Restart required for changes to take effect."));
|
|
847
|
+
if (process.stdin.isTTY) {
|
|
848
|
+
const restart = await confirm2({
|
|
849
|
+
message: "Restart Horus now?",
|
|
850
|
+
default: false
|
|
851
|
+
});
|
|
852
|
+
if (restart) {
|
|
853
|
+
console.log(chalk5.dim("Run `horus down && horus up` to restart."));
|
|
854
|
+
}
|
|
855
|
+
} else {
|
|
856
|
+
console.log(chalk5.dim("Run `horus down && horus up` to restart."));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
function isValidKey(key) {
|
|
861
|
+
return CONFIG_KEYS.includes(key);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/commands/connect.ts
|
|
865
|
+
import { Command as Command6 } from "commander";
|
|
866
|
+
import chalk6 from "chalk";
|
|
867
|
+
import ora5 from "ora";
|
|
868
|
+
import { checkbox } from "@inquirer/prompts";
|
|
869
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
|
|
870
|
+
import { join as join3 } from "path";
|
|
871
|
+
import { homedir as homedir3 } from "os";
|
|
872
|
+
function detectInstalledClients() {
|
|
873
|
+
const detected = [];
|
|
874
|
+
const home = homedir3();
|
|
875
|
+
const claudeDesktopDir = join3(home, "Library", "Application Support", "Claude");
|
|
876
|
+
if (existsSync3(claudeDesktopDir)) {
|
|
877
|
+
detected.push("claude-desktop");
|
|
878
|
+
}
|
|
879
|
+
const claudeCodeDir = join3(home, ".claude");
|
|
880
|
+
if (existsSync3(claudeCodeDir)) {
|
|
881
|
+
detected.push("claude-code");
|
|
882
|
+
}
|
|
883
|
+
const cursorDir = join3(home, ".cursor");
|
|
884
|
+
const cursorAppDir = join3(home, "Library", "Application Support", "Cursor");
|
|
885
|
+
if (existsSync3(cursorDir) || existsSync3(cursorAppDir)) {
|
|
886
|
+
detected.push("cursor");
|
|
887
|
+
}
|
|
888
|
+
return detected;
|
|
889
|
+
}
|
|
890
|
+
function getConfigPath(target) {
|
|
891
|
+
const home = homedir3();
|
|
892
|
+
switch (target) {
|
|
893
|
+
case "claude-desktop":
|
|
894
|
+
return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
895
|
+
case "claude-code":
|
|
896
|
+
return join3(home, ".claude", "settings.json");
|
|
897
|
+
case "cursor":
|
|
898
|
+
return join3(home, ".cursor", "mcp.json");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function mergeAndWriteConfig(configPath, mcpServers) {
|
|
902
|
+
let existing = {};
|
|
903
|
+
if (existsSync3(configPath)) {
|
|
904
|
+
try {
|
|
905
|
+
const raw = readFileSync3(configPath, "utf-8");
|
|
906
|
+
existing = JSON.parse(raw);
|
|
907
|
+
} catch {
|
|
908
|
+
existing = {};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const existingServers = existing.mcpServers ?? {};
|
|
912
|
+
existing.mcpServers = { ...existingServers, ...mcpServers };
|
|
913
|
+
const dir = configPath.substring(0, configPath.lastIndexOf("/"));
|
|
914
|
+
mkdirSync2(dir, { recursive: true });
|
|
915
|
+
writeFileSync3(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
916
|
+
}
|
|
917
|
+
async function syncSkills(runtime) {
|
|
918
|
+
const home = homedir3();
|
|
919
|
+
const skillsBase = join3(home, ".claude", "skills");
|
|
920
|
+
const skills = ["horus-anvil", "horus-vault", "horus-forge"];
|
|
921
|
+
const forgeContainer = "horus-forge-1";
|
|
922
|
+
for (const skill of skills) {
|
|
923
|
+
const destDir = join3(skillsBase, skill);
|
|
924
|
+
mkdirSync2(destDir, { recursive: true });
|
|
925
|
+
const src = `/home/forge/.claude/skills/${skill}/SKILL.md`;
|
|
926
|
+
const dest = join3(destDir, "SKILL.md");
|
|
927
|
+
const result = await runtime.exec(forgeContainer, "cat", src);
|
|
928
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
929
|
+
writeFileSync3(dest, result.stdout, "utf-8");
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function printNextSteps(targets) {
|
|
934
|
+
console.log("");
|
|
935
|
+
console.log(chalk6.bold("Next steps:"));
|
|
936
|
+
for (const target of targets) {
|
|
937
|
+
switch (target) {
|
|
938
|
+
case "claude-desktop":
|
|
939
|
+
console.log(` ${chalk6.cyan("Claude Desktop")} Restart Claude Desktop to pick up the new MCP configuration`);
|
|
940
|
+
break;
|
|
941
|
+
case "claude-code":
|
|
942
|
+
console.log(` ${chalk6.cyan("Claude Code")} Start a new Claude Code session`);
|
|
943
|
+
break;
|
|
944
|
+
case "cursor":
|
|
945
|
+
console.log(` ${chalk6.cyan("Cursor")} Restart Cursor`);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
console.log("");
|
|
950
|
+
}
|
|
951
|
+
var connectCommand = new Command6("connect").description("Configure Claude/Cursor MCP integration").option("--target <client>", "Client to configure: claude-desktop, claude-code, cursor, all (default: auto-detect)").option("--host <host>", "MCP host (default: localhost)", "localhost").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
|
|
952
|
+
console.log("");
|
|
953
|
+
console.log(chalk6.bold("Horus Connect"));
|
|
954
|
+
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"));
|
|
955
|
+
console.log("");
|
|
956
|
+
const config = loadConfig();
|
|
957
|
+
const runtimeSpinner = ora5("Detecting runtime...").start();
|
|
958
|
+
let runtime;
|
|
959
|
+
try {
|
|
960
|
+
runtime = await detectRuntime(config.runtime);
|
|
961
|
+
runtimeSpinner.succeed(`Using ${chalk6.cyan(runtime.name)}`);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
runtimeSpinner.fail("No container runtime found");
|
|
964
|
+
console.log(error.message);
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
const runningSpinner = ora5("Checking Horus status...").start();
|
|
968
|
+
const running = await runtime.isRunning();
|
|
969
|
+
if (!running) {
|
|
970
|
+
runningSpinner.fail("Horus is not running");
|
|
971
|
+
console.log(chalk6.dim("Run `horus up` first, then re-run `horus connect`."));
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
runningSpinner.succeed("Horus is running");
|
|
975
|
+
let targets = [];
|
|
976
|
+
if (opts.target === "all") {
|
|
977
|
+
targets = ["claude-desktop", "claude-code", "cursor"];
|
|
978
|
+
} else if (opts.target) {
|
|
979
|
+
const valid = ["claude-desktop", "claude-code", "cursor"];
|
|
980
|
+
if (!valid.includes(opts.target)) {
|
|
981
|
+
console.log(chalk6.red(`Invalid target: ${opts.target}`));
|
|
982
|
+
console.log(chalk6.dim("Valid targets: claude-desktop, claude-code, cursor, all"));
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
targets = [opts.target];
|
|
986
|
+
} else {
|
|
987
|
+
const detected = detectInstalledClients();
|
|
988
|
+
if (detected.length === 0) {
|
|
989
|
+
console.log(chalk6.yellow("No supported clients detected (Claude Desktop, Claude Code, or Cursor)."));
|
|
990
|
+
console.log(chalk6.dim("Use --target to specify a client manually."));
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
if (opts.yes) {
|
|
994
|
+
targets = detected;
|
|
995
|
+
console.log(`Detected clients: ${detected.map((t) => chalk6.cyan(t)).join(", ")}`);
|
|
996
|
+
} else {
|
|
997
|
+
const chosen = await checkbox({
|
|
998
|
+
message: "Select clients to configure:",
|
|
999
|
+
choices: detected.map((t) => ({ name: t, value: t, checked: true })),
|
|
1000
|
+
validate: (input2) => input2.length > 0 ? true : "Select at least one client."
|
|
1001
|
+
});
|
|
1002
|
+
targets = chosen;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (targets.length === 0) {
|
|
1006
|
+
console.log(chalk6.yellow("No clients selected. Exiting."));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const host = opts.host;
|
|
1010
|
+
const mcpServers = {
|
|
1011
|
+
anvil: { url: `http://${host}:${config.ports.anvil}/sse` },
|
|
1012
|
+
vault: { url: `http://${host}:${config.ports.vault_mcp}/sse` },
|
|
1013
|
+
forge: { url: `http://${host}:${config.ports.forge}/sse` }
|
|
1014
|
+
};
|
|
1015
|
+
for (const target of targets) {
|
|
1016
|
+
const configPath = getConfigPath(target);
|
|
1017
|
+
const writeSpinner = ora5(`Configuring ${chalk6.cyan(target)}...`).start();
|
|
1018
|
+
try {
|
|
1019
|
+
mergeAndWriteConfig(configPath, mcpServers);
|
|
1020
|
+
writeSpinner.succeed(`Configured ${chalk6.cyan(target)} \u2014 ${chalk6.dim(configPath)}`);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
writeSpinner.fail(`Failed to configure ${target}`);
|
|
1023
|
+
console.log(chalk6.dim(error.message));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (targets.includes("claude-code")) {
|
|
1027
|
+
const skillsSpinner = ora5("Syncing horus-core skills...").start();
|
|
1028
|
+
try {
|
|
1029
|
+
await syncSkills(runtime);
|
|
1030
|
+
skillsSpinner.succeed("horus-core skills synced to ~/.claude/skills/");
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
skillsSpinner.warn("Could not sync skills (Forge container may not be running)");
|
|
1033
|
+
console.log(chalk6.dim(error.message));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
printNextSteps(targets);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// src/commands/update.ts
|
|
1040
|
+
import { Command as Command7 } from "commander";
|
|
1041
|
+
import chalk7 from "chalk";
|
|
1042
|
+
import ora6 from "ora";
|
|
1043
|
+
import { select, confirm as confirm3 } from "@inquirer/prompts";
|
|
1044
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, readdirSync, existsSync as existsSync4 } from "fs";
|
|
1045
|
+
import { join as join4 } from "path";
|
|
1046
|
+
import { createHash } from "crypto";
|
|
1047
|
+
import { stringify as stringifyYaml2, parse as parseYaml2 } from "yaml";
|
|
1048
|
+
var SNAPSHOTS_DIR = join4(HORUS_DIR, "snapshots");
|
|
1049
|
+
function ensureSnapshotsDir() {
|
|
1050
|
+
mkdirSync3(SNAPSHOTS_DIR, { recursive: true });
|
|
1051
|
+
}
|
|
1052
|
+
function composeFileHash() {
|
|
1053
|
+
if (!existsSync4(COMPOSE_PATH)) return "";
|
|
1054
|
+
const content = readFileSync4(COMPOSE_PATH, "utf-8");
|
|
1055
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 12);
|
|
1056
|
+
}
|
|
1057
|
+
async function captureCurrentImages(runtime) {
|
|
1058
|
+
const images = {};
|
|
1059
|
+
try {
|
|
1060
|
+
const result = await runtime.compose("images", "--format", "json");
|
|
1061
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
1062
|
+
for (const line of lines) {
|
|
1063
|
+
try {
|
|
1064
|
+
const obj = JSON.parse(line);
|
|
1065
|
+
const service = obj.Service ?? "";
|
|
1066
|
+
const tag = obj.Tag ?? obj.Image ?? "unknown";
|
|
1067
|
+
if (service) images[service] = tag;
|
|
1068
|
+
} catch {
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
}
|
|
1073
|
+
return images;
|
|
1074
|
+
}
|
|
1075
|
+
function saveSnapshot(images) {
|
|
1076
|
+
ensureSnapshotsDir();
|
|
1077
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1078
|
+
const snapshot = {
|
|
1079
|
+
timestamp,
|
|
1080
|
+
images,
|
|
1081
|
+
compose_hash: composeFileHash()
|
|
1082
|
+
};
|
|
1083
|
+
const filePath = join4(SNAPSHOTS_DIR, `${timestamp}.yaml`);
|
|
1084
|
+
writeFileSync4(filePath, stringifyYaml2(snapshot, { lineWidth: 0 }), "utf-8");
|
|
1085
|
+
return filePath;
|
|
1086
|
+
}
|
|
1087
|
+
function listSnapshots() {
|
|
1088
|
+
if (!existsSync4(SNAPSHOTS_DIR)) return [];
|
|
1089
|
+
return readdirSync(SNAPSHOTS_DIR).filter((f) => f.endsWith(".yaml")).sort().reverse().map((f) => {
|
|
1090
|
+
const file = join4(SNAPSHOTS_DIR, f);
|
|
1091
|
+
const snapshot = parseYaml2(readFileSync4(file, "utf-8"));
|
|
1092
|
+
return { file, snapshot };
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
async function fetchLatestVersion() {
|
|
1096
|
+
try {
|
|
1097
|
+
const res = await fetch("https://api.github.com/repos/Arjunkhera/Horus/releases/latest", {
|
|
1098
|
+
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
1099
|
+
signal: AbortSignal.timeout(1e4)
|
|
1100
|
+
});
|
|
1101
|
+
if (!res.ok) return null;
|
|
1102
|
+
const data = await res.json();
|
|
1103
|
+
return data.tag_name ?? null;
|
|
1104
|
+
} catch {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
var updateCommand = new Command7("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) => {
|
|
1109
|
+
console.log("");
|
|
1110
|
+
console.log(chalk7.bold(opts.rollback ? "Horus Rollback" : "Horus Update"));
|
|
1111
|
+
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"));
|
|
1112
|
+
console.log("");
|
|
1113
|
+
const config = loadConfig();
|
|
1114
|
+
const runtimeSpinner = ora6("Detecting runtime...").start();
|
|
1115
|
+
let runtime;
|
|
1116
|
+
try {
|
|
1117
|
+
runtime = await detectRuntime(config.runtime);
|
|
1118
|
+
runtimeSpinner.succeed(`Using ${chalk7.cyan(runtime.name)}`);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
runtimeSpinner.fail("No container runtime found");
|
|
1121
|
+
console.log(error.message);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
}
|
|
1124
|
+
if (opts.rollback) {
|
|
1125
|
+
const snapshots = listSnapshots();
|
|
1126
|
+
if (snapshots.length === 0) {
|
|
1127
|
+
console.log(chalk7.red("No snapshots found. Cannot roll back."));
|
|
1128
|
+
console.log(chalk7.dim(`Snapshots are stored in ${SNAPSHOTS_DIR}`));
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
let snapshotToRestore;
|
|
1132
|
+
if (opts.yes) {
|
|
1133
|
+
snapshotToRestore = snapshots[0].snapshot;
|
|
1134
|
+
console.log(`Using most recent snapshot: ${chalk7.cyan(snapshotToRestore.timestamp)}`);
|
|
1135
|
+
} else {
|
|
1136
|
+
const choices = snapshots.map(({ snapshot }, i) => ({
|
|
1137
|
+
name: `${snapshot.timestamp} (images: ${Object.keys(snapshot.images).length})`,
|
|
1138
|
+
value: i
|
|
1139
|
+
}));
|
|
1140
|
+
const idx = await select({
|
|
1141
|
+
message: "Select snapshot to restore:",
|
|
1142
|
+
choices
|
|
1143
|
+
});
|
|
1144
|
+
snapshotToRestore = snapshots[idx].snapshot;
|
|
1145
|
+
}
|
|
1146
|
+
if (!opts.yes) {
|
|
1147
|
+
const confirmed = await confirm3({
|
|
1148
|
+
message: `Roll back to snapshot from ${snapshotToRestore.timestamp}? This will restart services.`,
|
|
1149
|
+
default: false
|
|
1150
|
+
});
|
|
1151
|
+
if (!confirmed) {
|
|
1152
|
+
console.log(chalk7.dim("Rollback cancelled."));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const stopSpinner = ora6("Stopping services...").start();
|
|
1157
|
+
try {
|
|
1158
|
+
await composeStreaming(runtime, ["down"]);
|
|
1159
|
+
stopSpinner.succeed("Services stopped");
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
stopSpinner.fail("Failed to stop services");
|
|
1162
|
+
console.log(chalk7.dim(error.message));
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
console.log("");
|
|
1166
|
+
console.log(chalk7.bold("Restarting from snapshot (using cached images)..."));
|
|
1167
|
+
try {
|
|
1168
|
+
await composeStreaming(runtime, ["up", "-d"]);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
console.log(chalk7.red("Failed to restart services."));
|
|
1171
|
+
console.log(chalk7.dim(error.message));
|
|
1172
|
+
process.exit(1);
|
|
1173
|
+
}
|
|
1174
|
+
console.log("");
|
|
1175
|
+
const healthSpinner2 = ora6("Waiting for services to become healthy...").start();
|
|
1176
|
+
try {
|
|
1177
|
+
await pollUntilHealthy(
|
|
1178
|
+
runtime,
|
|
1179
|
+
(current) => {
|
|
1180
|
+
const summary = current.map((s) => {
|
|
1181
|
+
const icon = s.status === "healthy" ? chalk7.green("*") : s.status === "starting" ? chalk7.yellow("~") : chalk7.red("x");
|
|
1182
|
+
return `${icon} ${s.name}`;
|
|
1183
|
+
}).join(" ");
|
|
1184
|
+
healthSpinner2.text = `Waiting... ${summary}`;
|
|
1185
|
+
},
|
|
1186
|
+
3e5,
|
|
1187
|
+
5e3
|
|
1188
|
+
);
|
|
1189
|
+
healthSpinner2.succeed("All services healthy after rollback");
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
healthSpinner2.fail("Some services did not become healthy");
|
|
1192
|
+
console.log(chalk7.dim(error.message));
|
|
1193
|
+
process.exit(1);
|
|
1194
|
+
}
|
|
1195
|
+
console.log("");
|
|
1196
|
+
console.log(chalk7.bold.green("Rollback complete!"));
|
|
1197
|
+
console.log("");
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const versionSpinner = ora6("Checking for updates...").start();
|
|
1201
|
+
const [currentImages, latestVersion] = await Promise.all([
|
|
1202
|
+
captureCurrentImages(runtime),
|
|
1203
|
+
fetchLatestVersion()
|
|
1204
|
+
]);
|
|
1205
|
+
versionSpinner.stop();
|
|
1206
|
+
if (latestVersion) {
|
|
1207
|
+
console.log(` Latest release: ${chalk7.cyan(latestVersion)}`);
|
|
1208
|
+
} else {
|
|
1209
|
+
console.log(chalk7.dim(" Could not reach GitHub to check latest version."));
|
|
1210
|
+
}
|
|
1211
|
+
console.log("");
|
|
1212
|
+
if (!opts.yes) {
|
|
1213
|
+
const confirmed = await confirm3({
|
|
1214
|
+
message: "Pull latest images and restart services?",
|
|
1215
|
+
default: true
|
|
1216
|
+
});
|
|
1217
|
+
if (!confirmed) {
|
|
1218
|
+
console.log(chalk7.dim("Update cancelled."));
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const snapshotSpinner = ora6("Saving pre-update snapshot...").start();
|
|
1223
|
+
let snapshotPath = "";
|
|
1224
|
+
try {
|
|
1225
|
+
snapshotPath = saveSnapshot(currentImages);
|
|
1226
|
+
snapshotSpinner.succeed(`Snapshot saved: ${chalk7.dim(snapshotPath)}`);
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
snapshotSpinner.warn("Could not save snapshot (update will proceed)");
|
|
1229
|
+
console.log(chalk7.dim(error.message));
|
|
1230
|
+
}
|
|
1231
|
+
console.log("");
|
|
1232
|
+
console.log(chalk7.bold("Pulling latest images..."));
|
|
1233
|
+
try {
|
|
1234
|
+
await composeStreaming(runtime, ["pull"]);
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
console.log(chalk7.red("Failed to pull images."));
|
|
1237
|
+
console.log(chalk7.dim(error.message));
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
}
|
|
1240
|
+
console.log("");
|
|
1241
|
+
console.log(chalk7.bold("Restarting services..."));
|
|
1242
|
+
try {
|
|
1243
|
+
await composeStreaming(runtime, ["up", "-d"]);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
console.log(chalk7.red("Failed to restart services."));
|
|
1246
|
+
console.log(chalk7.dim(error.message));
|
|
1247
|
+
process.exit(1);
|
|
1248
|
+
}
|
|
1249
|
+
console.log("");
|
|
1250
|
+
const healthSpinner = ora6("Waiting for services to become healthy...").start();
|
|
1251
|
+
let finalStates = [];
|
|
1252
|
+
try {
|
|
1253
|
+
finalStates = await pollUntilHealthy(
|
|
1254
|
+
runtime,
|
|
1255
|
+
(current) => {
|
|
1256
|
+
const summary = current.map((s) => {
|
|
1257
|
+
const icon = s.status === "healthy" ? chalk7.green("*") : s.status === "starting" ? chalk7.yellow("~") : chalk7.red("x");
|
|
1258
|
+
return `${icon} ${s.name}`;
|
|
1259
|
+
}).join(" ");
|
|
1260
|
+
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
1261
|
+
},
|
|
1262
|
+
3e5,
|
|
1263
|
+
5e3
|
|
1264
|
+
);
|
|
1265
|
+
healthSpinner.succeed("All services healthy");
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
healthSpinner.fail("Some services did not become healthy");
|
|
1268
|
+
console.log(chalk7.dim(error.message));
|
|
1269
|
+
console.log("");
|
|
1270
|
+
console.log(chalk7.dim(`Tip: Roll back with \`horus update --rollback\``));
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1273
|
+
console.log("");
|
|
1274
|
+
console.log(chalk7.bold.green("Update complete!"));
|
|
1275
|
+
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"));
|
|
1276
|
+
if (latestVersion) {
|
|
1277
|
+
console.log(` ${chalk7.bold("Version:")} ${latestVersion}`);
|
|
1278
|
+
}
|
|
1279
|
+
console.log("");
|
|
1280
|
+
console.log(chalk7.bold(" Service Status:"));
|
|
1281
|
+
for (const s of finalStates) {
|
|
1282
|
+
const color = s.status === "healthy" ? chalk7.green : s.status === "starting" ? chalk7.yellow : chalk7.red;
|
|
1283
|
+
console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
|
|
1284
|
+
}
|
|
1285
|
+
if (snapshotPath) {
|
|
1286
|
+
console.log("");
|
|
1287
|
+
console.log(chalk7.dim(` Snapshot saved for rollback: ${snapshotPath}`));
|
|
1288
|
+
console.log(chalk7.dim(" Run `horus update --rollback` to revert if needed."));
|
|
1289
|
+
}
|
|
1290
|
+
console.log("");
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
// src/commands/doctor.ts
|
|
1294
|
+
import { Command as Command8 } from "commander";
|
|
1295
|
+
import chalk8 from "chalk";
|
|
1296
|
+
import { execSync } from "child_process";
|
|
1297
|
+
import { existsSync as existsSync5, accessSync, statfsSync, constants } from "fs";
|
|
1298
|
+
import { join as join5 } from "path";
|
|
1299
|
+
function symbol(status) {
|
|
1300
|
+
switch (status) {
|
|
1301
|
+
case "pass":
|
|
1302
|
+
return chalk8.green(" \u2713 ");
|
|
1303
|
+
case "warn":
|
|
1304
|
+
return chalk8.yellow(" \u26A0 ");
|
|
1305
|
+
case "fail":
|
|
1306
|
+
return chalk8.red(" \u2717 ");
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function colorMessage(status, msg) {
|
|
1310
|
+
switch (status) {
|
|
1311
|
+
case "pass":
|
|
1312
|
+
return chalk8.white(msg);
|
|
1313
|
+
case "warn":
|
|
1314
|
+
return chalk8.yellow(msg);
|
|
1315
|
+
case "fail":
|
|
1316
|
+
return chalk8.red(msg);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async function checkRuntime() {
|
|
1320
|
+
try {
|
|
1321
|
+
execSync("docker info", { stdio: "ignore" });
|
|
1322
|
+
return { status: "pass", label: "Runtime", message: "Docker is running" };
|
|
1323
|
+
} catch {
|
|
1324
|
+
try {
|
|
1325
|
+
execSync("podman info", { stdio: "ignore" });
|
|
1326
|
+
return { status: "pass", label: "Runtime", message: "Podman is running" };
|
|
1327
|
+
} catch {
|
|
1328
|
+
return {
|
|
1329
|
+
status: "fail",
|
|
1330
|
+
label: "Runtime",
|
|
1331
|
+
message: "Docker/Podman is not running",
|
|
1332
|
+
hint: "Start Docker Desktop or Podman Desktop"
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
async function checkCompose() {
|
|
1338
|
+
try {
|
|
1339
|
+
execSync("docker compose version", { stdio: "ignore" });
|
|
1340
|
+
return { status: "pass", label: "Compose", message: "Compose plugin available" };
|
|
1341
|
+
} catch {
|
|
1342
|
+
try {
|
|
1343
|
+
execSync("podman compose version", { stdio: "ignore" });
|
|
1344
|
+
return { status: "pass", label: "Compose", message: "Compose plugin available (podman)" };
|
|
1345
|
+
} catch {
|
|
1346
|
+
return {
|
|
1347
|
+
status: "fail",
|
|
1348
|
+
label: "Compose",
|
|
1349
|
+
message: "Compose plugin not found",
|
|
1350
|
+
hint: "Install Docker Compose plugin: https://docs.docker.com/compose/install/"
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
function checkConfig() {
|
|
1356
|
+
if (configExists()) {
|
|
1357
|
+
return { status: "pass", label: "Config", message: "Configuration file exists (~/.horus/config.yaml)" };
|
|
1358
|
+
}
|
|
1359
|
+
return {
|
|
1360
|
+
status: "fail",
|
|
1361
|
+
label: "Config",
|
|
1362
|
+
message: "Configuration file missing (~/.horus/config.yaml)",
|
|
1363
|
+
hint: "Run `horus setup` to create the configuration"
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function checkComposeFile() {
|
|
1367
|
+
if (existsSync5(COMPOSE_PATH)) {
|
|
1368
|
+
return { status: "pass", label: "Compose file", message: "Compose file installed (~/.horus/docker-compose.yml)" };
|
|
1369
|
+
}
|
|
1370
|
+
return {
|
|
1371
|
+
status: "fail",
|
|
1372
|
+
label: "Compose file",
|
|
1373
|
+
message: "Compose file missing (~/.horus/docker-compose.yml)",
|
|
1374
|
+
hint: "Run `horus setup` to install the compose file"
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
function checkPort(port, serviceName) {
|
|
1378
|
+
try {
|
|
1379
|
+
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null || true`, {
|
|
1380
|
+
encoding: "utf-8"
|
|
1381
|
+
}).trim();
|
|
1382
|
+
if (!output) {
|
|
1383
|
+
return { status: "pass", label: `Port ${port}`, message: `Port ${port} is free (${serviceName})` };
|
|
1384
|
+
}
|
|
1385
|
+
const pids = output.split("\n").filter(Boolean);
|
|
1386
|
+
for (const pid of pids) {
|
|
1387
|
+
try {
|
|
1388
|
+
const cmdline = execSync(`ps -p ${pid} -o comm= 2>/dev/null || true`, {
|
|
1389
|
+
encoding: "utf-8"
|
|
1390
|
+
}).trim();
|
|
1391
|
+
if (cmdline.toLowerCase().includes("docker") || cmdline.toLowerCase().includes("podman")) {
|
|
1392
|
+
return { status: "pass", label: `Port ${port}`, message: `Port ${port} in use by Horus (${serviceName})` };
|
|
1393
|
+
}
|
|
1394
|
+
} catch {
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return {
|
|
1398
|
+
status: "warn",
|
|
1399
|
+
label: `Port ${port}`,
|
|
1400
|
+
message: `Port ${port} in use by another process (${serviceName} needs port ${port})`,
|
|
1401
|
+
hint: `Change the port with \`horus config set port.${serviceName.toLowerCase()} <port>\``
|
|
1402
|
+
};
|
|
1403
|
+
} catch {
|
|
1404
|
+
return { status: "pass", label: `Port ${port}`, message: `Port ${port} status unknown` };
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function checkDataDir(dataDir) {
|
|
1408
|
+
if (!existsSync5(dataDir)) {
|
|
1409
|
+
return {
|
|
1410
|
+
status: "warn",
|
|
1411
|
+
label: "Data directory",
|
|
1412
|
+
message: `Data directory does not exist: ${dataDir}`,
|
|
1413
|
+
hint: "It will be created automatically when Horus starts"
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
accessSync(dataDir, constants.W_OK);
|
|
1418
|
+
return { status: "pass", label: "Data directory", message: `Data directory exists and is writable (${dataDir})` };
|
|
1419
|
+
} catch {
|
|
1420
|
+
return {
|
|
1421
|
+
status: "fail",
|
|
1422
|
+
label: "Data directory",
|
|
1423
|
+
message: `Data directory is not writable: ${dataDir}`,
|
|
1424
|
+
hint: `Run: chmod u+w "${dataDir}"`
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
function checkDiskSpace(dataDir) {
|
|
1429
|
+
const checkDir = existsSync5(dataDir) ? dataDir : join5(dataDir, "..");
|
|
1430
|
+
try {
|
|
1431
|
+
const stats = statfsSync(checkDir);
|
|
1432
|
+
const freeBytes = stats.bfree * stats.bsize;
|
|
1433
|
+
const freeGB = freeBytes / 1024 ** 3;
|
|
1434
|
+
const freeGBStr = freeGB.toFixed(1);
|
|
1435
|
+
const MIN_GB = 5;
|
|
1436
|
+
if (freeGB >= MIN_GB) {
|
|
1437
|
+
return { status: "pass", label: "Disk space", message: `Disk space: ${freeGBStr}GB available` };
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
status: "warn",
|
|
1441
|
+
label: "Disk space",
|
|
1442
|
+
message: `Disk space low: only ${freeGBStr}GB available (5GB recommended; QMD models take ~2GB)`,
|
|
1443
|
+
hint: "Free up disk space before running Horus"
|
|
1444
|
+
};
|
|
1445
|
+
} catch {
|
|
1446
|
+
return { status: "warn", label: "Disk space", message: "Could not check available disk space" };
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
async function checkServices(runtime) {
|
|
1450
|
+
const results = [];
|
|
1451
|
+
try {
|
|
1452
|
+
const psResult = await runtime.compose("ps", "--format", "json");
|
|
1453
|
+
const lines = psResult.stdout.trim().split("\n").filter(Boolean);
|
|
1454
|
+
if (lines.length === 0) {
|
|
1455
|
+
return [
|
|
1456
|
+
{
|
|
1457
|
+
status: "warn",
|
|
1458
|
+
label: "Services",
|
|
1459
|
+
message: "No services are running",
|
|
1460
|
+
hint: "Run `horus up` to start the stack"
|
|
1461
|
+
}
|
|
1462
|
+
];
|
|
1463
|
+
}
|
|
1464
|
+
const containers = lines.map((l) => {
|
|
1465
|
+
try {
|
|
1466
|
+
return JSON.parse(l);
|
|
1467
|
+
} catch {
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
}).filter((c) => c !== null);
|
|
1471
|
+
for (const c of containers) {
|
|
1472
|
+
const name = c.Service ?? "unknown";
|
|
1473
|
+
const health = (c.Health || c.State || "unknown").toLowerCase();
|
|
1474
|
+
if (health === "healthy" || health === "running") {
|
|
1475
|
+
results.push({ status: "pass", label: `Service: ${name}`, message: `${name} is ${health}` });
|
|
1476
|
+
} else if (health === "starting") {
|
|
1477
|
+
results.push({
|
|
1478
|
+
status: "warn",
|
|
1479
|
+
label: `Service: ${name}`,
|
|
1480
|
+
message: `${name} is still starting`,
|
|
1481
|
+
hint: "Wait a moment and re-run `horus doctor`"
|
|
1482
|
+
});
|
|
1483
|
+
} else {
|
|
1484
|
+
results.push({
|
|
1485
|
+
status: "fail",
|
|
1486
|
+
label: `Service: ${name}`,
|
|
1487
|
+
message: `${name} service is ${health}`,
|
|
1488
|
+
hint: `Run: horus logs ${name}`
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
} catch {
|
|
1493
|
+
results.push({
|
|
1494
|
+
status: "warn",
|
|
1495
|
+
label: "Services",
|
|
1496
|
+
message: "Could not check service status (stack may not be running)",
|
|
1497
|
+
hint: "Run `horus up` to start the stack"
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return results;
|
|
1501
|
+
}
|
|
1502
|
+
function checkApiKey(config) {
|
|
1503
|
+
if (config.api_key && config.api_key.length > 0) {
|
|
1504
|
+
return { status: "pass", label: "API key", message: "API key is configured" };
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
status: "warn",
|
|
1508
|
+
label: "API key",
|
|
1509
|
+
message: "API key is not set",
|
|
1510
|
+
hint: "Run: horus config set api-key <your-key>"
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
var doctorCommand = new Command8("doctor").description("Diagnose common Horus issues").action(async () => {
|
|
1514
|
+
console.log("");
|
|
1515
|
+
console.log(chalk8.bold("Horus Doctor"));
|
|
1516
|
+
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"));
|
|
1517
|
+
const allResults = [];
|
|
1518
|
+
allResults.push(await checkRuntime());
|
|
1519
|
+
allResults.push(await checkCompose());
|
|
1520
|
+
allResults.push(checkConfig());
|
|
1521
|
+
allResults.push(checkComposeFile());
|
|
1522
|
+
const config = configExists() ? loadConfig() : null;
|
|
1523
|
+
const ports = config?.ports ?? DEFAULT_PORTS;
|
|
1524
|
+
const dataDir = config?.data_dir ?? join5(process.env.HOME ?? "~", ".horus", "data");
|
|
1525
|
+
allResults.push(checkPort(ports.anvil, "Anvil"));
|
|
1526
|
+
allResults.push(checkPort(ports.vault_rest, "Vault"));
|
|
1527
|
+
allResults.push(checkPort(ports.vault_mcp, "Vault MCP"));
|
|
1528
|
+
allResults.push(checkPort(ports.forge, "Forge"));
|
|
1529
|
+
allResults.push(checkDataDir(dataDir));
|
|
1530
|
+
allResults.push(checkDiskSpace(dataDir));
|
|
1531
|
+
if (config) {
|
|
1532
|
+
allResults.push(checkApiKey(config));
|
|
1533
|
+
}
|
|
1534
|
+
const runtimeOk = allResults[0].status !== "fail";
|
|
1535
|
+
const composeOk = allResults[1].status !== "fail";
|
|
1536
|
+
if (runtimeOk && composeOk) {
|
|
1537
|
+
try {
|
|
1538
|
+
const runtime = await detectRuntime(config?.runtime);
|
|
1539
|
+
const serviceResults = await checkServices(runtime);
|
|
1540
|
+
allResults.push(...serviceResults);
|
|
1541
|
+
} catch {
|
|
1542
|
+
allResults.push({
|
|
1543
|
+
status: "warn",
|
|
1544
|
+
label: "Services",
|
|
1545
|
+
message: "Could not detect runtime to check services"
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
for (const result of allResults) {
|
|
1550
|
+
console.log(`${symbol(result.status)}${colorMessage(result.status, result.message)}`);
|
|
1551
|
+
}
|
|
1552
|
+
const errors = allResults.filter((r) => r.status === "fail");
|
|
1553
|
+
const warnings = allResults.filter((r) => r.status === "warn");
|
|
1554
|
+
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"));
|
|
1555
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
1556
|
+
console.log(chalk8.green(" All checks passed."));
|
|
1557
|
+
} else {
|
|
1558
|
+
const parts = [];
|
|
1559
|
+
if (errors.length > 0) parts.push(chalk8.red(`${errors.length} error${errors.length > 1 ? "s" : ""}`));
|
|
1560
|
+
if (warnings.length > 0) parts.push(chalk8.yellow(`${warnings.length} warning${warnings.length > 1 ? "s" : ""}`));
|
|
1561
|
+
console.log(` ${parts.join(", ")}`);
|
|
1562
|
+
const withHints = [...errors, ...warnings].filter((r) => r.hint);
|
|
1563
|
+
if (withHints.length > 0) {
|
|
1564
|
+
console.log("");
|
|
1565
|
+
for (const r of withHints) {
|
|
1566
|
+
const icon = r.status === "fail" ? chalk8.red("\u2717") : chalk8.yellow("\u26A0");
|
|
1567
|
+
console.log(` ${icon} ${chalk8.dim(r.hint)}`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
console.log("");
|
|
1572
|
+
if (errors.length > 0) {
|
|
1573
|
+
process.exit(1);
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// src/commands/backup.ts
|
|
1578
|
+
import { Command as Command9 } from "commander";
|
|
1579
|
+
import chalk9 from "chalk";
|
|
1580
|
+
import ora7 from "ora";
|
|
1581
|
+
import { confirm as confirm4 } from "@inquirer/prompts";
|
|
1582
|
+
import { mkdirSync as mkdirSync4, statSync, existsSync as existsSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1583
|
+
import { join as join6, basename } from "path";
|
|
1584
|
+
import { execSync as execSync2 } from "child_process";
|
|
1585
|
+
import { stringify as stringifyYaml3 } from "yaml";
|
|
1586
|
+
var BACKUPS_DIR = join6(HORUS_DIR, "backups");
|
|
1587
|
+
function ensureBackupsDir() {
|
|
1588
|
+
mkdirSync4(BACKUPS_DIR, { recursive: true });
|
|
1589
|
+
}
|
|
1590
|
+
function formatBytes(bytes) {
|
|
1591
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1592
|
+
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1593
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)}MB`;
|
|
1594
|
+
return `${(bytes / 1024 ** 3).toFixed(2)}GB`;
|
|
1595
|
+
}
|
|
1596
|
+
async function createBackup(yes) {
|
|
1597
|
+
console.log("");
|
|
1598
|
+
console.log(chalk9.bold("Horus Backup"));
|
|
1599
|
+
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"));
|
|
1600
|
+
console.log("");
|
|
1601
|
+
const config = loadConfig();
|
|
1602
|
+
const runtimeSpinner = ora7("Detecting runtime...").start();
|
|
1603
|
+
let runtime;
|
|
1604
|
+
try {
|
|
1605
|
+
runtime = await detectRuntime(config.runtime);
|
|
1606
|
+
runtimeSpinner.succeed(`Using ${chalk9.cyan(runtime.name)}`);
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
runtimeSpinner.fail("No container runtime found");
|
|
1609
|
+
console.log(error.message);
|
|
1610
|
+
process.exit(1);
|
|
1611
|
+
}
|
|
1612
|
+
if (!yes) {
|
|
1613
|
+
const confirmed = await confirm4({
|
|
1614
|
+
message: "This will briefly stop services to create a consistent backup. Continue?",
|
|
1615
|
+
default: true
|
|
1616
|
+
});
|
|
1617
|
+
if (!confirmed) {
|
|
1618
|
+
console.log(chalk9.dim("Backup cancelled."));
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const stopSpinner = ora7("Stopping services...").start();
|
|
1623
|
+
try {
|
|
1624
|
+
await composeStreaming(runtime, ["stop"]);
|
|
1625
|
+
stopSpinner.succeed("Services stopped");
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
stopSpinner.fail("Failed to stop services");
|
|
1628
|
+
console.log(chalk9.dim(error.message));
|
|
1629
|
+
process.exit(1);
|
|
1630
|
+
}
|
|
1631
|
+
ensureBackupsDir();
|
|
1632
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1633
|
+
const tarFile = join6(BACKUPS_DIR, `${timestamp}.tar.gz`);
|
|
1634
|
+
const metaFile = join6(BACKUPS_DIR, `${timestamp}.meta.yaml`);
|
|
1635
|
+
const backupSpinner = ora7("Creating backup archive...").start();
|
|
1636
|
+
try {
|
|
1637
|
+
execSync2(`tar -czf "${tarFile}" -C "${HORUS_DIR}" data/`, {
|
|
1638
|
+
stdio: "pipe"
|
|
1639
|
+
});
|
|
1640
|
+
backupSpinner.succeed(`Archive created: ${chalk9.dim(tarFile)}`);
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
backupSpinner.fail("Failed to create backup archive");
|
|
1643
|
+
console.log(chalk9.dim(error.message));
|
|
1644
|
+
await composeStreaming(runtime, ["start"]).catch(() => {
|
|
1645
|
+
});
|
|
1646
|
+
process.exit(1);
|
|
1647
|
+
}
|
|
1648
|
+
let sizeBytes = 0;
|
|
1649
|
+
try {
|
|
1650
|
+
sizeBytes = statSync(tarFile).size;
|
|
1651
|
+
} catch {
|
|
1652
|
+
}
|
|
1653
|
+
const meta = {
|
|
1654
|
+
timestamp,
|
|
1655
|
+
data_dir: config.data_dir,
|
|
1656
|
+
version: config.version,
|
|
1657
|
+
size_bytes: sizeBytes
|
|
1658
|
+
};
|
|
1659
|
+
writeFileSync5(metaFile, stringifyYaml3(meta, { lineWidth: 0 }), "utf-8");
|
|
1660
|
+
const startSpinner = ora7("Restarting services...").start();
|
|
1661
|
+
try {
|
|
1662
|
+
await composeStreaming(runtime, ["start"]);
|
|
1663
|
+
startSpinner.succeed("Services restarted");
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
startSpinner.fail("Failed to restart services");
|
|
1666
|
+
console.log(chalk9.dim(error.message));
|
|
1667
|
+
console.log(chalk9.yellow("Run `horus up` to restart services manually."));
|
|
1668
|
+
}
|
|
1669
|
+
console.log("");
|
|
1670
|
+
console.log(chalk9.bold.green("Backup complete!"));
|
|
1671
|
+
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"));
|
|
1672
|
+
console.log(` ${chalk9.bold("File:")} ${tarFile}`);
|
|
1673
|
+
console.log(` ${chalk9.bold("Size:")} ${formatBytes(sizeBytes)}`);
|
|
1674
|
+
console.log("");
|
|
1675
|
+
console.log(chalk9.dim(" Restore with: horus backup restore <file>"));
|
|
1676
|
+
console.log("");
|
|
1677
|
+
}
|
|
1678
|
+
async function restoreBackup(file, yes) {
|
|
1679
|
+
console.log("");
|
|
1680
|
+
console.log(chalk9.bold("Horus Restore"));
|
|
1681
|
+
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"));
|
|
1682
|
+
console.log("");
|
|
1683
|
+
if (!existsSync6(file)) {
|
|
1684
|
+
console.log(chalk9.red(`Backup file not found: ${file}`));
|
|
1685
|
+
process.exit(1);
|
|
1686
|
+
}
|
|
1687
|
+
const config = loadConfig();
|
|
1688
|
+
const runtimeSpinner = ora7("Detecting runtime...").start();
|
|
1689
|
+
let runtime;
|
|
1690
|
+
try {
|
|
1691
|
+
runtime = await detectRuntime(config.runtime);
|
|
1692
|
+
runtimeSpinner.succeed(`Using ${chalk9.cyan(runtime.name)}`);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
runtimeSpinner.fail("No container runtime found");
|
|
1695
|
+
console.log(error.message);
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
}
|
|
1698
|
+
if (!yes) {
|
|
1699
|
+
console.log(chalk9.yellow(` Warning: This will overwrite current data in ${config.data_dir}`));
|
|
1700
|
+
console.log("");
|
|
1701
|
+
const confirmed = await confirm4({
|
|
1702
|
+
message: `Restore from ${basename(file)}? Current data will be overwritten.`,
|
|
1703
|
+
default: false
|
|
1704
|
+
});
|
|
1705
|
+
if (!confirmed) {
|
|
1706
|
+
console.log(chalk9.dim("Restore cancelled."));
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const stopSpinner = ora7("Stopping services...").start();
|
|
1711
|
+
try {
|
|
1712
|
+
await composeStreaming(runtime, ["stop"]);
|
|
1713
|
+
stopSpinner.succeed("Services stopped");
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
stopSpinner.fail("Failed to stop services");
|
|
1716
|
+
console.log(chalk9.dim(error.message));
|
|
1717
|
+
process.exit(1);
|
|
1718
|
+
}
|
|
1719
|
+
const extractSpinner = ora7("Extracting backup...").start();
|
|
1720
|
+
try {
|
|
1721
|
+
execSync2(`tar -xzf "${file}" -C "${HORUS_DIR}/"`, { stdio: "pipe" });
|
|
1722
|
+
extractSpinner.succeed("Backup extracted");
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
extractSpinner.fail("Failed to extract backup");
|
|
1725
|
+
console.log(chalk9.dim(error.message));
|
|
1726
|
+
await composeStreaming(runtime, ["start"]).catch(() => {
|
|
1727
|
+
});
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
console.log("");
|
|
1731
|
+
console.log(chalk9.bold("Starting services..."));
|
|
1732
|
+
try {
|
|
1733
|
+
await composeStreaming(runtime, ["start"]);
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
console.log(chalk9.red("Failed to start services."));
|
|
1736
|
+
console.log(chalk9.dim(error.message));
|
|
1737
|
+
process.exit(1);
|
|
1738
|
+
}
|
|
1739
|
+
console.log("");
|
|
1740
|
+
const healthSpinner = ora7("Waiting for services to become healthy...").start();
|
|
1741
|
+
try {
|
|
1742
|
+
await pollUntilHealthy(
|
|
1743
|
+
runtime,
|
|
1744
|
+
(current) => {
|
|
1745
|
+
const summary = current.map((s) => {
|
|
1746
|
+
const icon = s.status === "healthy" ? chalk9.green("*") : s.status === "starting" ? chalk9.yellow("~") : chalk9.red("x");
|
|
1747
|
+
return `${icon} ${s.name}`;
|
|
1748
|
+
}).join(" ");
|
|
1749
|
+
healthSpinner.text = `Waiting for services... ${summary}`;
|
|
1750
|
+
},
|
|
1751
|
+
3e5,
|
|
1752
|
+
5e3
|
|
1753
|
+
);
|
|
1754
|
+
healthSpinner.succeed("All services healthy");
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
healthSpinner.fail("Some services did not become healthy");
|
|
1757
|
+
console.log(chalk9.dim(error.message));
|
|
1758
|
+
process.exit(1);
|
|
1759
|
+
}
|
|
1760
|
+
console.log("");
|
|
1761
|
+
console.log(chalk9.bold.green("Restore complete!"));
|
|
1762
|
+
console.log("");
|
|
1763
|
+
}
|
|
1764
|
+
var backupCommand = new Command9("backup").description("Backup or restore Horus data").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
|
|
1765
|
+
await createBackup(opts.yes);
|
|
1766
|
+
});
|
|
1767
|
+
backupCommand.command("restore <file>").description("Restore Horus data from a backup file").option("-y, --yes", "Skip confirmation prompts").action(async (file, opts) => {
|
|
1768
|
+
await restoreBackup(file, opts.yes);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
// src/index.ts
|
|
1772
|
+
var program = new Command10();
|
|
1773
|
+
program.name("horus").description("CLI for managing the Horus Docker Compose stack").version("0.1.0");
|
|
1774
|
+
program.addCommand(setupCommand);
|
|
1775
|
+
program.addCommand(upCommand);
|
|
1776
|
+
program.addCommand(downCommand);
|
|
1777
|
+
program.addCommand(statusCommand);
|
|
1778
|
+
program.addCommand(configCommand);
|
|
1779
|
+
program.addCommand(connectCommand);
|
|
1780
|
+
program.addCommand(updateCommand);
|
|
1781
|
+
program.addCommand(doctorCommand);
|
|
1782
|
+
program.addCommand(backupCommand);
|
|
1783
|
+
program.exitOverride();
|
|
1784
|
+
try {
|
|
1785
|
+
await program.parseAsync(process.argv);
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
1788
|
+
process.exit(0);
|
|
1789
|
+
}
|
|
1790
|
+
if (error instanceof Error) {
|
|
1791
|
+
console.error(chalk10.red(`Error: ${error.message}`));
|
|
1792
|
+
} else {
|
|
1793
|
+
console.error(chalk10.red("An unexpected error occurred."));
|
|
1794
|
+
}
|
|
1795
|
+
process.exit(1);
|
|
1796
|
+
}
|