@arkhera30/cli 0.6.1 → 0.7.1

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/dist/index.js CHANGED
@@ -596,7 +596,7 @@ Run '${runtime.name} compose logs <service>' from ~/Horus/ to investigate.`
596
596
  Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
597
597
  );
598
598
  }
599
- await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
599
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
600
600
  }
601
601
  }
602
602
 
@@ -934,11 +934,11 @@ function generateComposeFile(config, runtime) {
934
934
  ports:
935
935
  - "\${VAULT_ROUTER_PORT:-8050}:8400"
936
936
  environment:
937
- - VAULT_ENDPOINTS=${vaultEndpoints}
938
- - VAULT_DEFAULT=${defaultVaultName}
939
- depends_on:
937
+ ${vaultEndpoints ? ` - VAULT_ENDPOINTS=${vaultEndpoints}
938
+ ` : ""} - VAULT_DEFAULT=${defaultVaultName}
939
+ ${vaultRouterDependsOn ? ` depends_on:
940
940
  ${vaultRouterDependsOn}
941
- networks:
941
+ ` : ""} networks:
942
942
  - horus-net
943
943
  restart: unless-stopped
944
944
  deploy:
@@ -1026,6 +1026,335 @@ ${vaultRouterDependsOn}
1026
1026
  }
1027
1027
  return content;
1028
1028
  }
1029
+ function generateStandaloneComposeFile(options) {
1030
+ const { config, ports, slotDataPath, slot, runtime, imageOverrides } = options;
1031
+ const project = `horus-test-${slot}`;
1032
+ const network = `${project}-net`;
1033
+ const vaultEntries = Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b));
1034
+ const defaultVaultEntry = vaultEntries.find(([, v]) => v.default);
1035
+ const defaultVaultName = defaultVaultEntry ? defaultVaultEntry[0] : vaultEntries[0]?.[0] ?? "default";
1036
+ function img(service, fallback) {
1037
+ return imageOverrides?.[service] ?? fallback;
1038
+ }
1039
+ const vaultServices = vaultEntries.map(([name], index) => {
1040
+ const vault = config.vaults[name];
1041
+ const githubHost = resolveGitHubHost(vault?.repo ?? "", config.github_hosts);
1042
+ const token = githubHost?.token ?? "";
1043
+ const apiHost = githubHost?.host ?? "github.com";
1044
+ const vaultHostPort = index === 0 ? ports.vault_svc : ports.vault_svc + index;
1045
+ const serviceImage = img(`vault-${name}`, "ghcr.io/arjunkhera/horus/vault:latest");
1046
+ let block = ` vault-${name}:
1047
+ image: ${serviceImage}
1048
+ ports:
1049
+ - "${vaultHostPort}:8000"
1050
+ volumes:
1051
+ - ${slotDataPath}/vaults/${name}:/data/knowledge-repo:rw
1052
+ - ${project}-vault-${name}-workspace:/data/workspace
1053
+ environment:
1054
+ - HORUS_RUNTIME=${runtime ?? "docker"}
1055
+ - KNOWLEDGE_REPO_PATH=/data/knowledge-repo
1056
+ - WORKSPACE_PATH=/data/workspace
1057
+ - VAULT_KNOWLEDGE_REPO_URL=${vault?.repo ?? ""}
1058
+ - SYNC_INTERVAL=300
1059
+ - VAULT_SYNC_INTERVAL=300
1060
+ - LOG_LEVEL=info
1061
+ - HOST=0.0.0.0
1062
+ - PORT=8000
1063
+ - GITHUB_TOKEN=${token}
1064
+ - GITHUB_API_HOST=${apiHost}
1065
+ - TYPESENSE_HOST=typesense
1066
+ - TYPESENSE_PORT=8108
1067
+ - TYPESENSE_API_KEY=horus-local-key
1068
+ - NEO4J_URI=bolt://neo4j:7687
1069
+ - NEO4J_USER=neo4j
1070
+ - NEO4J_PASSWORD=horus-neo4j
1071
+ depends_on:
1072
+ typesense:
1073
+ condition: service_healthy
1074
+ neo4j:
1075
+ condition: service_healthy
1076
+ networks:
1077
+ - ${network}
1078
+ restart: unless-stopped
1079
+ deploy:
1080
+ resources:
1081
+ limits:
1082
+ memory: 512m
1083
+ reservations:
1084
+ memory: 256m
1085
+ healthcheck:
1086
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
1087
+ interval: 30s
1088
+ timeout: 10s
1089
+ start_period: 60s
1090
+ retries: 3`;
1091
+ if (runtime === "podman") {
1092
+ block = block.replace(/^( image: .+)$/m, '$1\n user: "0:0"');
1093
+ }
1094
+ return block;
1095
+ });
1096
+ const vaultRouterDependsOn = vaultEntries.map(([name]) => ` vault-${name}:
1097
+ condition: service_healthy`).join("\n");
1098
+ const vaultEndpoints = vaultEntries.map(([name]) => `${name}=http://vault-${name}:8000`).join(",");
1099
+ const vaultRouterImage = img("vault-router", "ghcr.io/arjunkhera/horus/vault-router:latest");
1100
+ let vaultRouterBlock = ` vault-router:
1101
+ image: ${vaultRouterImage}
1102
+ ports:
1103
+ - "${ports.vault_router}:8400"
1104
+ environment:
1105
+ ${vaultEndpoints ? ` - VAULT_ENDPOINTS=${vaultEndpoints}
1106
+ ` : ""} - VAULT_DEFAULT=${defaultVaultName}
1107
+ - LOG_LEVEL=info
1108
+ ${vaultRouterDependsOn ? ` depends_on:
1109
+ ${vaultRouterDependsOn}
1110
+ ` : ""} networks:
1111
+ - ${network}
1112
+ restart: unless-stopped
1113
+ deploy:
1114
+ resources:
1115
+ limits:
1116
+ memory: 256m
1117
+ reservations:
1118
+ memory: 64m
1119
+ healthcheck:
1120
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8400/health')"]
1121
+ interval: 30s
1122
+ timeout: 10s
1123
+ start_period: 30s
1124
+ retries: 3`;
1125
+ if (runtime === "podman") {
1126
+ vaultRouterBlock = vaultRouterBlock.replace(/^( image: .+)$/m, '$1\n user: "0:0"');
1127
+ }
1128
+ const vaultMcpImage = img("vault-mcp", "ghcr.io/arjunkhera/horus/vault-mcp:latest");
1129
+ let vaultMcpBlock = ` vault-mcp:
1130
+ image: ${vaultMcpImage}
1131
+ ports:
1132
+ - "${ports.vault_mcp}:8300"
1133
+ environment:
1134
+ - VAULT_MCP_HTTP=true
1135
+ - VAULT_MCP_PORT=8300
1136
+ - VAULT_MCP_HOST=0.0.0.0
1137
+ - KNOWLEDGE_SERVICE_URL=http://vault-router:8400
1138
+ depends_on:
1139
+ vault-router:
1140
+ condition: service_healthy
1141
+ networks:
1142
+ - ${network}
1143
+ restart: unless-stopped
1144
+ stop_grace_period: 15s
1145
+ deploy:
1146
+ resources:
1147
+ limits:
1148
+ memory: 256m
1149
+ reservations:
1150
+ memory: 64m
1151
+ healthcheck:
1152
+ test: ["CMD", "curl", "-f", "http://localhost:8300/health"]
1153
+ interval: 30s
1154
+ timeout: 5s
1155
+ start_period: 30s
1156
+ retries: 3`;
1157
+ if (runtime === "podman") {
1158
+ vaultMcpBlock = vaultMcpBlock.replace(/^( image: .+)$/m, '$1\n user: "0:0"');
1159
+ }
1160
+ const anvilImage = img("anvil", "ghcr.io/arjunkhera/horus/anvil:latest");
1161
+ let anvilBlock = ` anvil:
1162
+ image: ${anvilImage}
1163
+ ports:
1164
+ - "${ports.anvil}:8100"
1165
+ volumes:
1166
+ - ${slotDataPath}/notes:/data/notes:rw
1167
+ environment:
1168
+ - HORUS_RUNTIME=${runtime ?? "docker"}
1169
+ - ANVIL_TRANSPORT=http
1170
+ - ANVIL_PORT=8100
1171
+ - ANVIL_HOST=0.0.0.0
1172
+ - ANVIL_NOTES_PATH=/data/notes
1173
+ - ANVIL_REPO_URL=
1174
+ - ANVIL_SYNC_INTERVAL=300
1175
+ - ANVIL_DEBOUNCE_SECONDS=5
1176
+ - GITHUB_TOKEN=
1177
+ - TYPESENSE_HOST=typesense
1178
+ - TYPESENSE_PORT=8108
1179
+ - TYPESENSE_API_KEY=horus-local-key
1180
+ - NEO4J_URI=bolt://neo4j:7687
1181
+ - NEO4J_USER=neo4j
1182
+ - NEO4J_PASSWORD=horus-neo4j
1183
+ depends_on:
1184
+ typesense:
1185
+ condition: service_healthy
1186
+ neo4j:
1187
+ condition: service_healthy
1188
+ networks:
1189
+ - ${network}
1190
+ restart: unless-stopped
1191
+ stop_grace_period: 15s
1192
+ deploy:
1193
+ resources:
1194
+ limits:
1195
+ memory: 512m
1196
+ reservations:
1197
+ memory: 256m
1198
+ healthcheck:
1199
+ test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
1200
+ interval: 30s
1201
+ timeout: 5s
1202
+ start_period: 60s
1203
+ retries: 3`;
1204
+ if (runtime === "podman") {
1205
+ anvilBlock = anvilBlock.replace(/^( image: .+)$/m, '$1\n user: "0:0"');
1206
+ }
1207
+ const forgeImage = img("forge", "ghcr.io/arjunkhera/horus/forge:latest");
1208
+ let forgeBlock = ` forge:
1209
+ image: ${forgeImage}
1210
+ ports:
1211
+ - "${ports.forge}:8200"
1212
+ volumes:
1213
+ - ${slotDataPath}/config:/data/config:rw
1214
+ - ${slotDataPath}/registry:/data/registry:rw
1215
+ - ${slotDataPath}/workspaces:/data/workspaces:rw
1216
+ - ${slotDataPath}/repos:/data/horus-repos:rw
1217
+ - ${slotDataPath}/sessions:/data/sessions:rw
1218
+ environment:
1219
+ - HORUS_RUNTIME=${runtime ?? "docker"}
1220
+ - FORGE_PORT=8200
1221
+ - FORGE_HOST=0.0.0.0
1222
+ - FORGE_REGISTRY_PATH=/data/registry
1223
+ - FORGE_WORKSPACES_PATH=/data/workspaces
1224
+ - FORGE_CONFIG_PATH=/data/config
1225
+ - FORGE_MANAGED_REPOS_PATH=/data/horus-repos
1226
+ - FORGE_REGISTRY_REPO_URL=
1227
+ - FORGE_SYNC_INTERVAL=300
1228
+ - FORGE_ANVIL_URL=http://anvil:8100
1229
+ - FORGE_VAULT_URL=http://vault-mcp:8300
1230
+ - FORGE_HOST_WORKSPACES_PATH=${slotDataPath}/workspaces
1231
+ - FORGE_HOST_MANAGED_REPOS_PATH=${slotDataPath}/repos
1232
+ - FORGE_HOST_REPOS_PATH=${slotDataPath}/repos
1233
+ - FORGE_HOST_ANVIL_URL=http://localhost:${ports.anvil}
1234
+ - FORGE_HOST_VAULT_URL=http://localhost:${ports.vault_mcp}
1235
+ - FORGE_HOST_FORGE_URL=http://localhost:${ports.forge}
1236
+ - FORGE_SCAN_PATHS=/data/horus-repos
1237
+ - FORGE_SESSION_TTL_MS=1800000
1238
+ - GITHUB_TOKEN=
1239
+ - TYPESENSE_HOST=typesense
1240
+ - TYPESENSE_PORT=8108
1241
+ - TYPESENSE_API_KEY=horus-local-key
1242
+ depends_on:
1243
+ anvil:
1244
+ condition: service_healthy
1245
+ typesense:
1246
+ condition: service_healthy
1247
+ vault-router:
1248
+ condition: service_healthy
1249
+ networks:
1250
+ - ${network}
1251
+ restart: unless-stopped
1252
+ stop_grace_period: 15s
1253
+ deploy:
1254
+ resources:
1255
+ limits:
1256
+ memory: 512m
1257
+ reservations:
1258
+ memory: 128m
1259
+ healthcheck:
1260
+ test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
1261
+ interval: 30s
1262
+ timeout: 5s
1263
+ start_period: 60s
1264
+ retries: 3`;
1265
+ if (runtime === "podman") {
1266
+ forgeBlock = forgeBlock.replace(/^( image: .+)$/m, '$1\n user: "0:0"');
1267
+ }
1268
+ const typesenseBlock = ` typesense:
1269
+ image: typesense/typesense:27.1
1270
+ ports:
1271
+ - "${ports.typesense}:8108"
1272
+ volumes:
1273
+ - ${slotDataPath}/typesense-data:/data
1274
+ command: >
1275
+ --data-dir=/data
1276
+ --api-key=horus-local-key
1277
+ --enable-cors
1278
+ networks:
1279
+ - ${network}
1280
+ healthcheck:
1281
+ test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8108'"]
1282
+ interval: 10s
1283
+ timeout: 5s
1284
+ retries: 3
1285
+ start_period: 5s
1286
+ restart: unless-stopped`;
1287
+ const neo4jBlock = ` neo4j:
1288
+ image: neo4j:5-community
1289
+ ports:
1290
+ - "${ports.neo4j_http}:7474"
1291
+ - "${ports.neo4j_bolt}:7687"
1292
+ volumes:
1293
+ - ${slotDataPath}/neo4j-data:/data
1294
+ - ${slotDataPath}/neo4j-logs:/logs
1295
+ environment:
1296
+ - NEO4J_AUTH=neo4j/horus-neo4j
1297
+ - NEO4J_server_memory_heap_initial__size=256m
1298
+ - NEO4J_server_memory_heap_max__size=512m
1299
+ - NEO4J_server_memory_pagecache_size=256m
1300
+ - NEO4J_PLUGINS=[]
1301
+ networks:
1302
+ - ${network}
1303
+ restart: unless-stopped
1304
+ stop_grace_period: 30s
1305
+ deploy:
1306
+ resources:
1307
+ limits:
1308
+ memory: 1g
1309
+ reservations:
1310
+ memory: 512m
1311
+ healthcheck:
1312
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:7474"]
1313
+ interval: 30s
1314
+ timeout: 10s
1315
+ start_period: 60s
1316
+ retries: 3`;
1317
+ const vaultVolumeNames = vaultEntries.map(([name]) => ` ${project}-vault-${name}-workspace:`).join("\n");
1318
+ const volumesSection = vaultVolumeNames ? [
1319
+ `# \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`,
1320
+ `volumes:`,
1321
+ vaultVolumeNames
1322
+ ] : [];
1323
+ const sections = [
1324
+ `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
1325
+ `# Horus \u2014 Standalone Shadow Stack (slot ${slot})`,
1326
+ `# Generated by \`horus test-env acquire --standalone\`. Do not edit manually.`,
1327
+ `# Fully-projected: no overlay merge; all ports/volumes are explicit.`,
1328
+ `# Isolation: project=${project} network=${network}`,
1329
+ `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
1330
+ ``,
1331
+ `services:`,
1332
+ ``,
1333
+ anvilBlock,
1334
+ ``,
1335
+ ...vaultServices.map((s) => s + "\n"),
1336
+ vaultRouterBlock,
1337
+ ``,
1338
+ vaultMcpBlock,
1339
+ ``,
1340
+ forgeBlock,
1341
+ ``,
1342
+ typesenseBlock,
1343
+ ``,
1344
+ neo4jBlock,
1345
+ ``,
1346
+ `# \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`,
1347
+ `networks:`,
1348
+ ` ${network}:`,
1349
+ ` driver: bridge`,
1350
+ ``,
1351
+ ...volumesSection
1352
+ ];
1353
+ return sections.join("\n");
1354
+ }
1355
+ function standaloneComposePath(slotDataPath) {
1356
+ return `${slotDataPath}/docker-compose.standalone.yml`;
1357
+ }
1029
1358
  function composeFileExists() {
1030
1359
  return existsSync3(COMPOSE_PATH);
1031
1360
  }
@@ -1371,7 +1700,7 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1371
1700
  console.log("");
1372
1701
  if (configExists()) {
1373
1702
  if (opts.yes) {
1374
- console.log(chalk2.yellow("Existing configuration found. Overwriting in non-interactive mode."));
1703
+ console.log(chalk2.yellow("Existing configuration found. Merging with existing values in non-interactive mode."));
1375
1704
  } else {
1376
1705
  const proceed = await confirm({
1377
1706
  message: "Horus is already configured. Reconfigure?",
@@ -1425,50 +1754,65 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1425
1754
  const runtime = await detectRuntime(selectedRuntime);
1426
1755
  let config;
1427
1756
  if (opts.yes) {
1757
+ const existing = configExists() ? loadConfig() : null;
1428
1758
  const defaults = defaultConfig();
1429
- const vaultNames = opts.vaultName ? Array.isArray(opts.vaultName) ? opts.vaultName : [opts.vaultName] : ["default"];
1430
- const vaultRepos = opts.vaultRepo ? Array.isArray(opts.vaultRepo) ? opts.vaultRepo : [opts.vaultRepo] : [process.env.VAULT_KNOWLEDGE_REPO_URL ?? ""];
1431
- const vaults = {};
1432
- vaultNames.forEach((name, i) => {
1433
- vaults[name] = {
1434
- repo: vaultRepos[i] ?? vaultRepos[0] ?? "",
1435
- default: i === 0
1436
- };
1437
- });
1438
- const primaryToken = opts.githubToken || process.env.GITHUB_TOKEN || "";
1439
- const anvilRepo = opts.anvilRepo || process.env.ANVIL_REPO_URL || defaults.repos.anvil_notes;
1440
- const allRepoUrls = [anvilRepo, ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
1441
- const seenHosts = /* @__PURE__ */ new Set();
1442
- const github_hosts = {};
1443
- let hostIndex = 0;
1444
- for (const url of allRepoUrls) {
1445
- const hostname = extractHostname(url);
1446
- if (!seenHosts.has(hostname)) {
1447
- seenHosts.add(hostname);
1448
- const hostKey = hostIndex === 0 ? "default" : hostname;
1449
- github_hosts[hostKey] = {
1450
- host: hostname,
1451
- token: primaryToken
1759
+ let vaults;
1760
+ if (opts.vaultName || opts.vaultRepo) {
1761
+ const vaultNames = opts.vaultName ? Array.isArray(opts.vaultName) ? opts.vaultName : [opts.vaultName] : ["default"];
1762
+ const vaultRepos = opts.vaultRepo ? Array.isArray(opts.vaultRepo) ? opts.vaultRepo : [opts.vaultRepo] : [process.env.VAULT_KNOWLEDGE_REPO_URL ?? ""];
1763
+ vaults = {};
1764
+ vaultNames.forEach((name, i) => {
1765
+ vaults[name] = {
1766
+ repo: vaultRepos[i] ?? vaultRepos[0] ?? "",
1767
+ default: i === 0
1452
1768
  };
1453
- hostIndex++;
1454
- }
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);
1455
1772
  }
1456
- if (Object.keys(github_hosts).length === 0) {
1457
- github_hosts["default"] = { host: "github.com", token: primaryToken };
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;
1458
1798
  }
1459
1799
  config = {
1460
1800
  ...defaults,
1801
+ // Preserve all existing top-level fields first
1802
+ ...existing ?? {},
1803
+ // Then apply runtime (always re-detected)
1461
1804
  runtime: runtime.name,
1462
- data_dir: opts.dataDir || DEFAULT_DATA_DIR,
1463
- host_repos_path: opts.reposPath || "",
1805
+ // Apply field-level overrides: explicit flag > existing value > default
1806
+ data_dir: opts.dataDir || existing?.data_dir || DEFAULT_DATA_DIR,
1807
+ host_repos_path: opts.reposPath || existing?.host_repos_path || "",
1464
1808
  repos: {
1465
1809
  anvil_notes: anvilRepo,
1466
- forge_registry: opts.forgeRepo || process.env.FORGE_REGISTRY_REPO_URL || defaults.repos.forge_registry
1810
+ forge_registry: opts.forgeRepo || process.env.FORGE_REGISTRY_REPO_URL || existing?.repos.forge_registry || defaults.repos.forge_registry
1467
1811
  },
1468
1812
  vaults,
1469
1813
  github_hosts,
1470
1814
  ai: {
1471
- key: process.env.HORUS_AI_KEY || ""
1815
+ key: process.env.HORUS_AI_KEY || existing?.ai.key || ""
1472
1816
  }
1473
1817
  };
1474
1818
  } else {
@@ -1730,6 +2074,20 @@ ${example(`${vaultName.trim()}-knowledge`)}
1730
2074
  }
1731
2075
  }
1732
2076
  }
2077
+ if (process.platform === "linux") {
2078
+ const forgeDirs = ["config", "registry", "workspaces", "sessions"].map((d) => join3(dataDir, d));
2079
+ for (const dir of forgeDirs) {
2080
+ mkdirSync3(dir, { recursive: true });
2081
+ }
2082
+ const dirList = forgeDirs.map((d) => `"${d}"`).join(" ");
2083
+ try {
2084
+ execSync(`chown -R 1001:1001 ${dirList}`, { stdio: "pipe" });
2085
+ } catch (error) {
2086
+ console.log(chalk2.yellow("Warning: could not chown Forge data dirs to UID 1001."));
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(" ")}`));
2089
+ }
2090
+ }
1733
2091
  console.log("");
1734
2092
  console.log(chalk2.bold("Pulling container images..."));
1735
2093
  try {
@@ -2918,17 +3276,20 @@ import ora8 from "ora";
2918
3276
  import { join as join8 } from "path";
2919
3277
  import { fileURLToPath as fileURLToPath2 } from "url";
2920
3278
  import { dirname as dirname2 } from "path";
3279
+ import { writeFileSync as writeFileSync7 } from "fs";
2921
3280
 
2922
3281
  // src/lib/test-env.ts
2923
3282
  import {
2924
3283
  existsSync as existsSync9,
2925
3284
  mkdirSync as mkdirSync6,
3285
+ chmodSync,
2926
3286
  readFileSync as readFileSync6,
2927
3287
  writeFileSync as writeFileSync6,
2928
3288
  rmSync,
2929
3289
  readdirSync as readdirSync3,
2930
3290
  cpSync
2931
3291
  } from "fs";
3292
+ import { execFileSync, execSync as execSync4 } from "child_process";
2932
3293
  import { join as join7 } from "path";
2933
3294
  import { parse as parseYaml3 } from "yaml";
2934
3295
  import { execa as execa3 } from "execa";
@@ -2956,7 +3317,9 @@ var PORT_OFFSETS = {
2956
3317
  vault_router: 50,
2957
3318
  vault_mcp: 100,
2958
3319
  forge: 150,
2959
- ui: 160
3320
+ ui: 160,
3321
+ neo4j_http: 174,
3322
+ neo4j_bolt: 187
2960
3323
  };
2961
3324
  function loadTestEnvConfig(dataDir) {
2962
3325
  const configPath = getTestEnvConfigPath(dataDir);
@@ -2985,7 +3348,9 @@ function calcPorts(slot, basePort) {
2985
3348
  vault_router: base + PORT_OFFSETS.vault_router,
2986
3349
  vault_mcp: base + PORT_OFFSETS.vault_mcp,
2987
3350
  forge: base + PORT_OFFSETS.forge,
2988
- ui: base + PORT_OFFSETS.ui
3351
+ ui: base + PORT_OFFSETS.ui,
3352
+ neo4j_http: base + PORT_OFFSETS.neo4j_http,
3353
+ neo4j_bolt: base + PORT_OFFSETS.neo4j_bolt
2989
3354
  };
2990
3355
  }
2991
3356
  function readLock(dataDir, slot) {
@@ -3045,18 +3410,43 @@ function createSlotDirs(slotDataPath, vaultNames = ["default"]) {
3045
3410
  const dirs = [
3046
3411
  "notes",
3047
3412
  ...vaultNames.map((name) => join7("vaults", name)),
3413
+ "config",
3048
3414
  "registry",
3049
3415
  "workspaces",
3050
3416
  "sessions",
3051
- "typesense-data"
3417
+ "repos",
3418
+ "typesense-data",
3419
+ "neo4j-data",
3420
+ "neo4j-logs"
3052
3421
  ];
3053
3422
  for (const dir of dirs) {
3054
- mkdirSync6(join7(slotDataPath, dir), { recursive: true });
3423
+ const fullPath = join7(slotDataPath, dir);
3424
+ mkdirSync6(fullPath, { recursive: true });
3425
+ try {
3426
+ chmodSync(fullPath, 511);
3427
+ } catch (err) {
3428
+ const code = err.code;
3429
+ if (code === "EPERM" || code === "EACCES") {
3430
+ try {
3431
+ execSync4("sudo chmod 777 " + fullPath, { stdio: "pipe" });
3432
+ } catch {
3433
+ console.warn(`[test-env] Warning: could not chmod ${fullPath} (permission denied, sudo also failed)`);
3434
+ }
3435
+ } else {
3436
+ throw err;
3437
+ }
3438
+ }
3055
3439
  }
3056
3440
  }
3057
3441
  function removeSlotDirs(slotDataPath) {
3058
- if (existsSync9(slotDataPath)) {
3442
+ if (!existsSync9(slotDataPath)) return;
3443
+ try {
3059
3444
  rmSync(slotDataPath, { recursive: true, force: true });
3445
+ } catch {
3446
+ try {
3447
+ execFileSync("sudo", ["rm", "-rf", slotDataPath]);
3448
+ } catch {
3449
+ }
3060
3450
  }
3061
3451
  }
3062
3452
  async function preSeedNotesDir(dataDir, slotDataPath) {
@@ -3066,7 +3456,11 @@ async function preSeedNotesDir(dataDir, slotDataPath) {
3066
3456
  if (existsSync9(destNotesPath)) {
3067
3457
  rmSync(destNotesPath, { recursive: true });
3068
3458
  }
3069
- await execa3("git", ["clone", "--local", srcNotesPath, destNotesPath]);
3459
+ try {
3460
+ execSync4(`git config --global --add safe.directory ${srcNotesPath}`, { stdio: "pipe" });
3461
+ } catch {
3462
+ }
3463
+ await execa3("git", ["clone", "--no-hardlinks", srcNotesPath, destNotesPath]);
3070
3464
  } else {
3071
3465
  await execa3("git", ["-C", destNotesPath, "init"]);
3072
3466
  await execa3("git", [
@@ -3083,6 +3477,65 @@ async function preSeedNotesDir(dataDir, slotDataPath) {
3083
3477
  ]);
3084
3478
  }
3085
3479
  }
3480
+ async function preSeedVaultDirs(dataDir, slotDataPath, vaultNames) {
3481
+ for (const name of vaultNames) {
3482
+ const srcVaultPath = join7(dataDir, "vaults", name);
3483
+ const destVaultPath = join7(slotDataPath, "vaults", name);
3484
+ if (existsSync9(join7(srcVaultPath, ".git"))) {
3485
+ if (existsSync9(destVaultPath)) {
3486
+ rmSync(destVaultPath, { recursive: true });
3487
+ }
3488
+ try {
3489
+ execSync4(`git config --global --add safe.directory ${srcVaultPath}`, { stdio: "pipe" });
3490
+ } catch {
3491
+ }
3492
+ await execa3("git", ["clone", "--no-hardlinks", srcVaultPath, destVaultPath]);
3493
+ } else {
3494
+ await execa3("git", ["-C", destVaultPath, "init"]);
3495
+ await execa3("git", [
3496
+ "-C",
3497
+ destVaultPath,
3498
+ "-c",
3499
+ "user.email=horus@local",
3500
+ "-c",
3501
+ "user.name=Horus",
3502
+ "commit",
3503
+ "--allow-empty",
3504
+ "-m",
3505
+ "init"
3506
+ ]);
3507
+ }
3508
+ }
3509
+ }
3510
+ async function preSeedRegistryDir(dataDir, slotDataPath) {
3511
+ const srcRegistryPath = join7(dataDir, "registry");
3512
+ const destRegistryPath = join7(slotDataPath, "registry");
3513
+ if (existsSync9(join7(srcRegistryPath, ".git"))) {
3514
+ if (existsSync9(destRegistryPath)) {
3515
+ rmSync(destRegistryPath, { recursive: true });
3516
+ }
3517
+ try {
3518
+ execSync4(`git config --global --add safe.directory ${srcRegistryPath}`, { stdio: "pipe" });
3519
+ } catch {
3520
+ }
3521
+ await execa3("git", ["clone", "--no-hardlinks", srcRegistryPath, destRegistryPath]);
3522
+ chmodSync(destRegistryPath, 511);
3523
+ } else {
3524
+ await execa3("git", ["-C", destRegistryPath, "init"]);
3525
+ await execa3("git", [
3526
+ "-C",
3527
+ destRegistryPath,
3528
+ "-c",
3529
+ "user.email=horus@local",
3530
+ "-c",
3531
+ "user.name=Horus",
3532
+ "commit",
3533
+ "--allow-empty",
3534
+ "-m",
3535
+ "init"
3536
+ ]);
3537
+ }
3538
+ }
3086
3539
  function buildComposeEnv(runtime, ports, slotDataPath, defaultVaultName = "default") {
3087
3540
  const vaultPortEnvVar = `VAULT_REST_PORT_${defaultVaultName.toUpperCase().replace(/-/g, "_")}`;
3088
3541
  return {
@@ -3107,26 +3560,36 @@ function buildComposeEnv(runtime, ports, slotDataPath, defaultVaultName = "defau
3107
3560
  TEST_PORT_VAULT_ROUTER: String(ports.vault_router),
3108
3561
  TEST_PORT_VAULT_MCP: String(ports.vault_mcp),
3109
3562
  TEST_PORT_FORGE: String(ports.forge),
3110
- TEST_PORT_UI: String(ports.ui)
3563
+ TEST_PORT_UI: String(ports.ui),
3564
+ NEO4J_HTTP_PORT: String(ports.neo4j_http),
3565
+ NEO4J_BOLT_PORT: String(ports.neo4j_bolt),
3566
+ TEST_PORT_NEO4J_HTTP: String(ports.neo4j_http),
3567
+ TEST_PORT_NEO4J_BOLT: String(ports.neo4j_bolt)
3111
3568
  };
3112
3569
  }
3113
- async function composeUp(runtime, projectName2, ports, slotDataPath, defaultVaultName = "default") {
3570
+ async function composeUp(runtime, projectName2, ports, slotDataPath, defaultVaultName = "default", imageOverrides) {
3114
3571
  const env = buildComposeEnv(runtime, ports, slotDataPath, defaultVaultName);
3115
- const result = await execa3(
3116
- runtime.name,
3117
- [
3118
- "compose",
3119
- "-p",
3120
- projectName2,
3121
- "-f",
3122
- join7(HORUS_DIR, "docker-compose.yml"),
3123
- "-f",
3124
- join7(HORUS_DIR, "docker-compose.test.yml"),
3125
- "up",
3126
- "-d"
3127
- ],
3128
- { cwd: HORUS_DIR, env, reject: false }
3129
- );
3572
+ const composeArgs = [
3573
+ "compose",
3574
+ "-p",
3575
+ projectName2,
3576
+ "-f",
3577
+ join7(HORUS_DIR, "docker-compose.yml"),
3578
+ "-f",
3579
+ join7(HORUS_DIR, "docker-compose.test.yml")
3580
+ ];
3581
+ if (imageOverrides && Object.keys(imageOverrides).length > 0) {
3582
+ const overrideYaml = {
3583
+ services: Object.fromEntries(
3584
+ Object.entries(imageOverrides).map(([svc, img]) => [svc, { image: img }])
3585
+ )
3586
+ };
3587
+ const overridePath = join7(slotDataPath, "docker-compose.image-overrides.json");
3588
+ writeFileSync6(overridePath, JSON.stringify(overrideYaml, null, 2));
3589
+ composeArgs.push("-f", overridePath);
3590
+ }
3591
+ composeArgs.push("up", "-d", "--force-recreate");
3592
+ const result = await execa3(runtime.name, composeArgs, { cwd: HORUS_DIR, env, reject: false });
3130
3593
  if (result.exitCode !== 0) {
3131
3594
  throw new Error(
3132
3595
  `Failed to start shadow stack (project ${projectName2}):
@@ -3142,7 +3605,51 @@ async function composeDown(runtime, projectName2, ports, slotDataPath, defaultVa
3142
3605
  { cwd: HORUS_DIR, env, reject: false }
3143
3606
  );
3144
3607
  }
3145
- var HEALTH_SERVICES = ["anvil", "forge", "vault-mcp", "typesense"];
3608
+ async function composeUpStandalone(runtime, projectName2, ports, slotDataPath, config, imageOverrides) {
3609
+ const forgeDirs = ["config", "registry", "workspaces", "sessions"];
3610
+ for (const dir of forgeDirs) {
3611
+ const fullPath = join7(slotDataPath, dir);
3612
+ try {
3613
+ execSync4(`chown -R 1001:1001 ${fullPath}`, { stdio: "pipe" });
3614
+ } catch {
3615
+ try {
3616
+ execSync4(`sudo chown -R 1001:1001 ${fullPath}`, { stdio: "pipe" });
3617
+ } catch {
3618
+ }
3619
+ }
3620
+ }
3621
+ const content = generateStandaloneComposeFile({
3622
+ config,
3623
+ ports,
3624
+ slotDataPath,
3625
+ slot: parseInt(projectName2.replace("horus-test-", ""), 10),
3626
+ runtime: runtime.name,
3627
+ imageOverrides
3628
+ });
3629
+ const composePath = standaloneComposePath(slotDataPath);
3630
+ writeFileSync6(composePath, content, "utf-8");
3631
+ const result = await execa3(
3632
+ runtime.name,
3633
+ ["compose", "-p", projectName2, "-f", composePath, "up", "-d", "--force-recreate"],
3634
+ { cwd: slotDataPath, env: { ...process.env, HORUS_RUNTIME: runtime.name }, reject: false }
3635
+ );
3636
+ if (result.exitCode !== 0) {
3637
+ throw new Error(
3638
+ `Failed to start standalone shadow stack (project ${projectName2}):
3639
+ ${result.stderr}`
3640
+ );
3641
+ }
3642
+ }
3643
+ async function composeDownStandalone(runtime, projectName2, slotDataPath) {
3644
+ const composePath = standaloneComposePath(slotDataPath);
3645
+ const args = existsSync9(composePath) ? ["compose", "-p", projectName2, "-f", composePath, "down", "--volumes", "--remove-orphans"] : ["compose", "-p", projectName2, "down", "--volumes", "--remove-orphans"];
3646
+ await execa3(
3647
+ runtime.name,
3648
+ args,
3649
+ { cwd: slotDataPath, env: { ...process.env, HORUS_RUNTIME: runtime.name }, reject: false }
3650
+ );
3651
+ }
3652
+ var HEALTH_SERVICES = ["anvil", "forge", "vault-mcp", "typesense", "neo4j"];
3146
3653
  async function checkContainerHealthByProject(runtime, projectName2, service) {
3147
3654
  const candidates = [
3148
3655
  `${projectName2}-${service}-1`,
@@ -3218,14 +3725,22 @@ function projectName(slot) {
3218
3725
 
3219
3726
  // src/commands/test-env.ts
3220
3727
  var testEnvCommand = new Command10("test-env").description("Manage isolated shadow stacks for integration testing");
3221
- testEnvCommand.command("acquire").description("Start a shadow stack on alternate ports with isolated data").option("--timeout <seconds>", "Max wait for health checks (default: 120)", "120").action(async (opts) => {
3728
+ 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
+ "--standalone",
3730
+ "Generate a fully-projected compose file (no overlay-merge). Eliminates port-collision with a live stack. Required on fresh VMs."
3731
+ ).option(
3732
+ "--json",
3733
+ "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)."
3734
+ ).action(async (opts) => {
3735
+ const jsonMode = Boolean(opts.json);
3736
+ const mkSpinner = (text) => ora8({ text, stream: jsonMode ? process.stderr : process.stdout });
3222
3737
  const config = loadConfig();
3223
3738
  const dataDir = config.data_dir;
3224
3739
  const testCfg = loadTestEnvConfig(dataDir);
3225
3740
  const vaultNames = Object.keys(config.vaults).sort();
3226
3741
  const defaultVaultEntry = Object.entries(config.vaults).find(([, v]) => v.default);
3227
3742
  const defaultVaultName = defaultVaultEntry?.[0] ?? vaultNames[0] ?? "default";
3228
- const spinner = ora8("Detecting runtime...").start();
3743
+ const spinner = mkSpinner("Detecting runtime...").start();
3229
3744
  let runtime;
3230
3745
  try {
3231
3746
  runtime = await detectRuntime(config.runtime);
@@ -3245,15 +3760,17 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3245
3760
  const ports = calcPorts(slot, testCfg.base_port);
3246
3761
  const slotDataPath = getSlotDataPath(dataDir, slot);
3247
3762
  const project = projectName(slot);
3248
- const dirSpinner = ora8(`Creating slot-${slot} data directories...`).start();
3763
+ const dirSpinner = mkSpinner(`Creating slot-${slot} data directories...`).start();
3249
3764
  createSlotDirs(slotDataPath, vaultNames);
3250
3765
  dirSpinner.succeed(`Data directory: ${chalk10.dim(slotDataPath)}`);
3251
- const seedSpinner = ora8("Pre-seeding notes directory...").start();
3766
+ const seedSpinner = mkSpinner("Pre-seeding git repos...").start();
3252
3767
  try {
3253
3768
  await preSeedNotesDir(dataDir, slotDataPath);
3254
- seedSpinner.succeed("Notes directory ready");
3769
+ await preSeedVaultDirs(dataDir, slotDataPath, vaultNames);
3770
+ await preSeedRegistryDir(dataDir, slotDataPath);
3771
+ seedSpinner.succeed("Git repos ready");
3255
3772
  } catch (error) {
3256
- seedSpinner.fail(`Notes pre-seed failed: ${error.message}`);
3773
+ seedSpinner.fail(`Pre-seed failed: ${error.message}`);
3257
3774
  removeLock(dataDir, slot);
3258
3775
  removeSlotDirs(slotDataPath);
3259
3776
  process.exit(1);
@@ -3265,10 +3782,29 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3265
3782
  ports,
3266
3783
  dataPath: slotDataPath
3267
3784
  });
3268
- const upSpinner = ora8(`Starting shadow stack (project ${chalk10.cyan(project)})...`).start();
3785
+ const imageOverrides = {};
3786
+ if (opts.image) {
3787
+ for (const entry of opts.image) {
3788
+ const eqIdx = entry.indexOf("=");
3789
+ if (eqIdx < 1) {
3790
+ console.error(chalk10.red(`Invalid --image format: "${entry}". Expected: service=image:tag`));
3791
+ process.exit(1);
3792
+ }
3793
+ imageOverrides[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
3794
+ }
3795
+ }
3796
+ const standaloneMode = Boolean(opts.standalone);
3797
+ const modeLabel = standaloneMode ? "standalone" : "overlay";
3798
+ const upSpinner = mkSpinner(
3799
+ `Starting shadow stack (project ${chalk10.cyan(project)}, mode: ${chalk10.dim(modeLabel)})...`
3800
+ ).start();
3269
3801
  try {
3270
- await composeUp(runtime, project, ports, slotDataPath, defaultVaultName);
3271
- upSpinner.succeed(`Shadow stack started`);
3802
+ if (standaloneMode) {
3803
+ await composeUpStandalone(runtime, project, ports, slotDataPath, config, imageOverrides);
3804
+ } else {
3805
+ await composeUp(runtime, project, ports, slotDataPath, defaultVaultName, imageOverrides);
3806
+ }
3807
+ upSpinner.succeed(`Shadow stack started (${modeLabel})`);
3272
3808
  } catch (error) {
3273
3809
  upSpinner.fail("Failed to start shadow stack");
3274
3810
  removeLock(dataDir, slot);
@@ -3276,7 +3812,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3276
3812
  console.error(error.message);
3277
3813
  process.exit(1);
3278
3814
  }
3279
- const healthSpinner = ora8("Waiting for services to be healthy...").start();
3815
+ const healthSpinner = mkSpinner("Waiting for services to be healthy...").start();
3280
3816
  const timeoutMs = parseInt(opts.timeout, 10) * 1e3;
3281
3817
  try {
3282
3818
  await waitForShadowStackHealthy(runtime, project, timeoutMs, 3e3, (statuses) => {
@@ -3284,6 +3820,15 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3284
3820
  healthSpinner.text = `Waiting for services... ${parts}`;
3285
3821
  });
3286
3822
  healthSpinner.succeed("All services healthy");
3823
+ const mcpSettings = {
3824
+ mcpServers: {
3825
+ "anvil-dev": { type: "http", url: `http://localhost:${ports.anvil}` },
3826
+ "vault-dev": { type: "http", url: `http://localhost:${ports.vault_mcp}` },
3827
+ "forge-dev": { type: "http", url: `http://localhost:${ports.forge}` }
3828
+ }
3829
+ };
3830
+ const settingsPath2 = join8(slotDataPath, "claude-settings.json");
3831
+ writeFileSync7(settingsPath2, JSON.stringify(mcpSettings, null, 2));
3287
3832
  } catch (error) {
3288
3833
  healthSpinner.fail("Health check failed");
3289
3834
  await composeDown(runtime, project, ports, slotDataPath, defaultVaultName);
@@ -3292,6 +3837,12 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3292
3837
  console.error(error.message);
3293
3838
  process.exit(1);
3294
3839
  }
3840
+ if (jsonMode) {
3841
+ process.stdout.write(
3842
+ JSON.stringify({ slot, slot_data_path: slotDataPath, project, ports }) + "\n"
3843
+ );
3844
+ return;
3845
+ }
3295
3846
  console.log("");
3296
3847
  console.log(chalk10.bold.green(`\u2713 Slot ${slot} acquired`));
3297
3848
  console.log("");
@@ -3315,6 +3866,11 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3315
3866
  console.log(` export TEST_VAULT_MCP_URL=http://localhost:${ports.vault_mcp}`);
3316
3867
  console.log(` export TEST_DATA_PATH=${slotDataPath}`);
3317
3868
  console.log("");
3869
+ const settingsPath = join8(slotDataPath, "claude-settings.json");
3870
+ console.log(chalk10.bold("Agent dev mode:"));
3871
+ console.log(` MCP config: ${chalk10.dim(settingsPath)}`);
3872
+ console.log(` Launch: ${chalk10.cyan(`claude --mcp-config ${settingsPath}`)}`);
3873
+ console.log("");
3318
3874
  console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
3319
3875
  console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env release --slot ${slot}`)} when done.`));
3320
3876
  });
@@ -3353,7 +3909,14 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
3353
3909
  }
3354
3910
  const downSpinner = ora8(`Stopping ${chalk10.cyan(project)}...`).start();
3355
3911
  try {
3356
- await composeDown(runtime, project, ports, slotDataPath, defaultVaultName);
3912
+ const { existsSync: existsSync12 } = await import("fs");
3913
+ const { join: join11 } = await import("path");
3914
+ const standaloneFile = join11(slotDataPath, "docker-compose.standalone.yml");
3915
+ if (existsSync12(standaloneFile)) {
3916
+ await composeDownStandalone(runtime, project, slotDataPath);
3917
+ } else {
3918
+ await composeDown(runtime, project, ports, slotDataPath, defaultVaultName);
3919
+ }
3357
3920
  downSpinner.succeed("Shadow stack stopped");
3358
3921
  } catch {
3359
3922
  downSpinner.warn("Failed to stop cleanly (continuing cleanup)");