@arkhera30/cli 0.6.1 → 0.7.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/dist/index.js +621 -69
- package/guides/index.json +1 -1
- package/package.json +1 -1
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((
|
|
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.
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
vaults
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1463
|
-
|
|
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
|
-
"
|
|
3417
|
+
"repos",
|
|
3418
|
+
"typesense-data",
|
|
3419
|
+
"neo4j-data",
|
|
3420
|
+
"neo4j-logs"
|
|
3052
3421
|
];
|
|
3053
3422
|
for (const dir of dirs) {
|
|
3054
|
-
|
|
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
|
-
|
|
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
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
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
|
-
|
|
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,7 +3725,10 @@ 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").
|
|
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
|
+
).action(async (opts) => {
|
|
3222
3732
|
const config = loadConfig();
|
|
3223
3733
|
const dataDir = config.data_dir;
|
|
3224
3734
|
const testCfg = loadTestEnvConfig(dataDir);
|
|
@@ -3248,12 +3758,14 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3248
3758
|
const dirSpinner = ora8(`Creating slot-${slot} data directories...`).start();
|
|
3249
3759
|
createSlotDirs(slotDataPath, vaultNames);
|
|
3250
3760
|
dirSpinner.succeed(`Data directory: ${chalk10.dim(slotDataPath)}`);
|
|
3251
|
-
const seedSpinner = ora8("Pre-seeding
|
|
3761
|
+
const seedSpinner = ora8("Pre-seeding git repos...").start();
|
|
3252
3762
|
try {
|
|
3253
3763
|
await preSeedNotesDir(dataDir, slotDataPath);
|
|
3254
|
-
|
|
3764
|
+
await preSeedVaultDirs(dataDir, slotDataPath, vaultNames);
|
|
3765
|
+
await preSeedRegistryDir(dataDir, slotDataPath);
|
|
3766
|
+
seedSpinner.succeed("Git repos ready");
|
|
3255
3767
|
} catch (error) {
|
|
3256
|
-
seedSpinner.fail(`
|
|
3768
|
+
seedSpinner.fail(`Pre-seed failed: ${error.message}`);
|
|
3257
3769
|
removeLock(dataDir, slot);
|
|
3258
3770
|
removeSlotDirs(slotDataPath);
|
|
3259
3771
|
process.exit(1);
|
|
@@ -3265,10 +3777,29 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3265
3777
|
ports,
|
|
3266
3778
|
dataPath: slotDataPath
|
|
3267
3779
|
});
|
|
3268
|
-
const
|
|
3780
|
+
const imageOverrides = {};
|
|
3781
|
+
if (opts.image) {
|
|
3782
|
+
for (const entry of opts.image) {
|
|
3783
|
+
const eqIdx = entry.indexOf("=");
|
|
3784
|
+
if (eqIdx < 1) {
|
|
3785
|
+
console.error(chalk10.red(`Invalid --image format: "${entry}". Expected: service=image:tag`));
|
|
3786
|
+
process.exit(1);
|
|
3787
|
+
}
|
|
3788
|
+
imageOverrides[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
const standaloneMode = Boolean(opts.standalone);
|
|
3792
|
+
const modeLabel = standaloneMode ? "standalone" : "overlay";
|
|
3793
|
+
const upSpinner = ora8(
|
|
3794
|
+
`Starting shadow stack (project ${chalk10.cyan(project)}, mode: ${chalk10.dim(modeLabel)})...`
|
|
3795
|
+
).start();
|
|
3269
3796
|
try {
|
|
3270
|
-
|
|
3271
|
-
|
|
3797
|
+
if (standaloneMode) {
|
|
3798
|
+
await composeUpStandalone(runtime, project, ports, slotDataPath, config, imageOverrides);
|
|
3799
|
+
} else {
|
|
3800
|
+
await composeUp(runtime, project, ports, slotDataPath, defaultVaultName, imageOverrides);
|
|
3801
|
+
}
|
|
3802
|
+
upSpinner.succeed(`Shadow stack started (${modeLabel})`);
|
|
3272
3803
|
} catch (error) {
|
|
3273
3804
|
upSpinner.fail("Failed to start shadow stack");
|
|
3274
3805
|
removeLock(dataDir, slot);
|
|
@@ -3284,6 +3815,15 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3284
3815
|
healthSpinner.text = `Waiting for services... ${parts}`;
|
|
3285
3816
|
});
|
|
3286
3817
|
healthSpinner.succeed("All services healthy");
|
|
3818
|
+
const mcpSettings = {
|
|
3819
|
+
mcpServers: {
|
|
3820
|
+
"anvil-dev": { type: "http", url: `http://localhost:${ports.anvil}` },
|
|
3821
|
+
"vault-dev": { type: "http", url: `http://localhost:${ports.vault_mcp}` },
|
|
3822
|
+
"forge-dev": { type: "http", url: `http://localhost:${ports.forge}` }
|
|
3823
|
+
}
|
|
3824
|
+
};
|
|
3825
|
+
const settingsPath2 = join8(slotDataPath, "claude-settings.json");
|
|
3826
|
+
writeFileSync7(settingsPath2, JSON.stringify(mcpSettings, null, 2));
|
|
3287
3827
|
} catch (error) {
|
|
3288
3828
|
healthSpinner.fail("Health check failed");
|
|
3289
3829
|
await composeDown(runtime, project, ports, slotDataPath, defaultVaultName);
|
|
@@ -3315,6 +3855,11 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
|
|
|
3315
3855
|
console.log(` export TEST_VAULT_MCP_URL=http://localhost:${ports.vault_mcp}`);
|
|
3316
3856
|
console.log(` export TEST_DATA_PATH=${slotDataPath}`);
|
|
3317
3857
|
console.log("");
|
|
3858
|
+
const settingsPath = join8(slotDataPath, "claude-settings.json");
|
|
3859
|
+
console.log(chalk10.bold("Agent dev mode:"));
|
|
3860
|
+
console.log(` MCP config: ${chalk10.dim(settingsPath)}`);
|
|
3861
|
+
console.log(` Launch: ${chalk10.cyan(`claude --mcp-config ${settingsPath}`)}`);
|
|
3862
|
+
console.log("");
|
|
3318
3863
|
console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
|
|
3319
3864
|
console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env release --slot ${slot}`)} when done.`));
|
|
3320
3865
|
});
|
|
@@ -3353,7 +3898,14 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
|
|
|
3353
3898
|
}
|
|
3354
3899
|
const downSpinner = ora8(`Stopping ${chalk10.cyan(project)}...`).start();
|
|
3355
3900
|
try {
|
|
3356
|
-
|
|
3901
|
+
const { existsSync: existsSync12 } = await import("fs");
|
|
3902
|
+
const { join: join11 } = await import("path");
|
|
3903
|
+
const standaloneFile = join11(slotDataPath, "docker-compose.standalone.yml");
|
|
3904
|
+
if (existsSync12(standaloneFile)) {
|
|
3905
|
+
await composeDownStandalone(runtime, project, slotDataPath);
|
|
3906
|
+
} else {
|
|
3907
|
+
await composeDown(runtime, project, ports, slotDataPath, defaultVaultName);
|
|
3908
|
+
}
|
|
3357
3909
|
downSpinner.succeed("Shadow stack stopped");
|
|
3358
3910
|
} catch {
|
|
3359
3911
|
downSpinner.warn("Failed to stop cleanly (continuing cleanup)");
|
package/guides/index.json
CHANGED