direxio-deployer 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.
Files changed (77) hide show
  1. package/AGENTS.md +92 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -0
  4. package/README_zh.md +218 -0
  5. package/SKILL.md +722 -0
  6. package/agents/README.md +25 -0
  7. package/agents/openai.yaml +12 -0
  8. package/bin/direxio-deployer.mjs +375 -0
  9. package/package.json +28 -0
  10. package/references/agent-targets.md +128 -0
  11. package/references/architecture.md +44 -0
  12. package/references/bug-history.md +78 -0
  13. package/references/deployment-lessons.md +218 -0
  14. package/references/deployment-optimization-audit.md +317 -0
  15. package/references/deployment-workflow.md +341 -0
  16. package/references/iam-policy.json +52 -0
  17. package/references/runtime-wiring.md +209 -0
  18. package/references/state-machine.md +46 -0
  19. package/references/token-refresh.md +81 -0
  20. package/references/tooling.md +106 -0
  21. package/references/troubleshooting.md +26 -0
  22. package/references/user-journey.md +75 -0
  23. package/references/verification-recovery.md +84 -0
  24. package/references/voip-turn-runbook.md +154 -0
  25. package/references/windows-deployment-notes.md +119 -0
  26. package/scripts/aws-credentials.sh +195 -0
  27. package/scripts/cloud-init/Caddyfile +48 -0
  28. package/scripts/cloud-init/docker-compose.yml +125 -0
  29. package/scripts/cloud-init/init-tokens.sh +238 -0
  30. package/scripts/cloud-init/user-data.yaml +40 -0
  31. package/scripts/destroy.ps1 +77 -0
  32. package/scripts/destroy.sh +589 -0
  33. package/scripts/lib/aws.sh +73 -0
  34. package/scripts/lib/domain.sh +175 -0
  35. package/scripts/lib/operation_report.sh +240 -0
  36. package/scripts/lib/ops.sh +230 -0
  37. package/scripts/lib/paths.sh +35 -0
  38. package/scripts/lib/state.sh +137 -0
  39. package/scripts/mcp-tools-list.mjs +95 -0
  40. package/scripts/orchestrate.ps1 +112 -0
  41. package/scripts/orchestrate.sh +1126 -0
  42. package/scripts/phases/s0_prereq_aws.sh +39 -0
  43. package/scripts/phases/s1_preflight.sh +72 -0
  44. package/scripts/phases/s2_domain.sh +103 -0
  45. package/scripts/phases/s3_provision.sh +421 -0
  46. package/scripts/phases/s4_bootstrap_stack.sh +38 -0
  47. package/scripts/phases/s5_init_tokens.sh +118 -0
  48. package/scripts/phases/s6_wire_local.sh +1435 -0
  49. package/scripts/phases/s7_verify_e2e.sh +136 -0
  50. package/scripts/pricing-estimate.sh +256 -0
  51. package/scripts/render/render-userdata.sh +86 -0
  52. package/scripts/reset-app-data.sh +40 -0
  53. package/scripts/update.sh +30 -0
  54. package/tests/aws_credentials_test.sh +139 -0
  55. package/tests/connect_daemon_runtime_check_test.sh +120 -0
  56. package/tests/default_paths_test.sh +58 -0
  57. package/tests/destroy_local_bridge_test.sh +154 -0
  58. package/tests/destroy_root_identity_test.sh +91 -0
  59. package/tests/destroy_route53_zone_test.sh +80 -0
  60. package/tests/domain_authoritative_dns_test.sh +49 -0
  61. package/tests/mcp_doctor_runtime_check_test.sh +86 -0
  62. package/tests/mcp_smoke_runtime_check_test.sh +121 -0
  63. package/tests/mcp_tools_runtime_check_test.sh +123 -0
  64. package/tests/npm_skill_distribution_test.sh +95 -0
  65. package/tests/operation_report_test.sh +258 -0
  66. package/tests/orchestrate_status_recovery_test.sh +91 -0
  67. package/tests/phase_timeout_test.sh +88 -0
  68. package/tests/pricing_estimate_test.sh +159 -0
  69. package/tests/render_userdata_remote_nodes_test.sh +40 -0
  70. package/tests/root_volume_tracking_test.sh +41 -0
  71. package/tests/route53_overwrite_guard_test.sh +86 -0
  72. package/tests/route53_zone_auto_create_test.sh +66 -0
  73. package/tests/runtime_summary_check_test.sh +203 -0
  74. package/tests/s6_wire_local_test.sh +405 -0
  75. package/tests/skill_structure_test.sh +298 -0
  76. package/tests/update_reset_ops_test.sh +230 -0
  77. package/tests/user_confirmation_gates_test.sh +152 -0
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bash
2
+ # lib/ops.sh - existing-node update/reset helpers.
3
+
4
+ ops_state_path() {
5
+ local explicit=${1:-}
6
+ if [ -n "$explicit" ]; then
7
+ printf '%s\n' "$explicit"
8
+ return 0
9
+ fi
10
+ printf '%s/state.json\n' "$(direxio_default_workdir)"
11
+ }
12
+
13
+ ops_require_state() {
14
+ local state=$1
15
+ [ -f "$state" ] || {
16
+ echo "state.json not found: $state" >&2
17
+ return 1
18
+ }
19
+ }
20
+
21
+ ops_state_get() {
22
+ local state=$1 path=$2
23
+ jq -r "$path // empty" "$state"
24
+ }
25
+
26
+ ops_sh_quote() {
27
+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
28
+ }
29
+
30
+ ops_path_dirname() {
31
+ local path=$1
32
+ path=${path%/}
33
+ case "$path" in
34
+ */*) printf '%s\n' "${path%/*}" ;;
35
+ *) printf '.\n' ;;
36
+ esac
37
+ }
38
+
39
+ ops_normalize_path() {
40
+ local path=$1
41
+ path=$(printf '%s' "$path" | sed 's#\\#/#g')
42
+ if command -v cygpath >/dev/null 2>&1; then
43
+ cygpath -m "$path" 2>/dev/null && return 0
44
+ fi
45
+ while [ "${#path}" -gt 1 ] && [ "${path%/}" != "$path" ]; do
46
+ case "$path" in [A-Za-z]:/) break ;; esac
47
+ path=${path%/}
48
+ done
49
+ printf '%s\n' "$path"
50
+ }
51
+
52
+ ops_paths_match() {
53
+ local left right
54
+ left=$(ops_normalize_path "$1")
55
+ right=$(ops_normalize_path "$2")
56
+ case "$left:$right" in
57
+ [A-Za-z]:/*:[A-Za-z]:/*)
58
+ [ "$(printf '%s' "$left" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$right" | tr '[:upper:]' '[:lower:]')" ]
59
+ ;;
60
+ *)
61
+ [ "$left" = "$right" ]
62
+ ;;
63
+ esac
64
+ }
65
+
66
+ ops_remote_base() {
67
+ local state=$1 keyfile pubip
68
+ keyfile=$(ops_state_get "$state" '.resources.key_file')
69
+ pubip=$(ops_state_get "$state" '.resources.public_ip')
70
+ [ -n "$keyfile" ] && [ -n "$pubip" ] || {
71
+ echo "state is missing resources.key_file or resources.public_ip; cannot SSH to existing EC2" >&2
72
+ return 1
73
+ }
74
+ printf '%s\t%s\n' "$keyfile" "$pubip"
75
+ }
76
+
77
+ ops_ssh() {
78
+ local state=$1 command=$2 keyfile pubip
79
+ IFS=$'\t' read -r keyfile pubip < <(ops_remote_base "$state")
80
+ ssh -i "$keyfile" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ubuntu@"$pubip" "$command"
81
+ }
82
+
83
+ ops_cc_connect_service_name() {
84
+ local state=$1 service_name service_dir
85
+ service_name=$(ops_state_get "$state" '.agent_service_id')
86
+ [ -n "$service_name" ] || service_name=$(ops_state_get "$state" '.domain')
87
+ if [ -z "$service_name" ]; then
88
+ service_dir=$(ops_state_get "$state" '.agent_service_dir')
89
+ [ -n "$service_dir" ] && service_name=$(basename "$service_dir")
90
+ fi
91
+ printf '%s\n' "${service_name:-cc-connect}"
92
+ }
93
+
94
+ ops_cc_connect_target_work_dir() {
95
+ local state=$1 config runtime_dir service_dir
96
+ config=$(ops_state_get "$state" '.cc_connect_config')
97
+ runtime_dir=$(ops_state_get "$state" '.cc_connect_runtime_dir')
98
+ service_dir=$(ops_state_get "$state" '.agent_service_dir')
99
+ if [ -n "$config" ]; then
100
+ ops_path_dirname "$config"
101
+ elif [ -n "$runtime_dir" ]; then
102
+ printf '%s\n' "$runtime_dir"
103
+ elif [ -n "$service_dir" ]; then
104
+ printf '%s/cc-connect\n' "${service_dir%/}"
105
+ fi
106
+ }
107
+
108
+ ops_stop_scoped_daemon() {
109
+ local state=$1 binary service_name target_work_dir status_out daemon_status work_dir
110
+ binary=$(ops_state_get "$state" '.cc_connect_binary')
111
+ [ -n "$binary" ] || binary=direxio-connect
112
+ service_name=$(ops_cc_connect_service_name "$state")
113
+ target_work_dir=$(ops_cc_connect_target_work_dir "$state")
114
+ [ -n "$target_work_dir" ] || return 1
115
+
116
+ case "$binary" in
117
+ */*|[A-Za-z]:/*|[A-Za-z]:\\*)
118
+ [ -x "$binary" ] || return 1
119
+ ;;
120
+ *)
121
+ command -v "$binary" >/dev/null 2>&1 || return 1
122
+ ;;
123
+ esac
124
+
125
+ status_out=$("$binary" daemon status --service-name "$service_name" 2>/dev/null) || return 1
126
+ daemon_status=$(printf '%s\n' "$status_out" | sed -nE 's/^[[:space:]]*Status:[[:space:]]*//p' | head -n 1)
127
+ work_dir=$(printf '%s\n' "$status_out" | sed -nE 's/^[[:space:]]*WorkDir:[[:space:]]*//p' | head -n 1)
128
+ [ "$daemon_status" = "Running" ] || return 1
129
+ [ -n "$work_dir" ] || return 1
130
+ ops_paths_match "$target_work_dir" "$work_dir" || return 1
131
+
132
+ "$binary" daemon stop --service-name "$service_name" >/dev/null 2>&1
133
+ }
134
+
135
+ ops_update_remote_command() {
136
+ local image=${1:-} image_q remote_script
137
+ image_q=$(ops_sh_quote "$image")
138
+ remote_script=$(cat <<'EOF'
139
+ set -eu
140
+ cd /opt/p2p
141
+ if [ -n "${MESSAGE_SERVER_IMAGE:-}" ]; then
142
+ IMAGE=$MESSAGE_SERVER_IMAGE
143
+ escaped_image=$(printf '%s\n' "$IMAGE" | sed 's/[\/&]/\\&/g')
144
+ if grep -q '^MESSAGE_SERVER_IMAGE=' .env; then
145
+ sed -i "s#^MESSAGE_SERVER_IMAGE=.*#MESSAGE_SERVER_IMAGE=$escaped_image#" .env
146
+ else
147
+ printf 'MESSAGE_SERVER_IMAGE=%s\n' "$IMAGE" | tee -a .env >/dev/null
148
+ fi
149
+ fi
150
+ docker compose --env-file .env pull
151
+ docker compose --env-file .env up -d
152
+ DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
153
+ sync_container_bootstrap() {
154
+ tmp=$(mktemp)
155
+ if docker compose --env-file .env exec -T message-server sh -c 'test -s /var/direxio-message-server/p2p/bootstrap.json && cat /var/direxio-message-server/p2p/bootstrap.json' > "$tmp"; then
156
+ install -m 0600 "$tmp" /opt/p2p/bootstrap.json
157
+ rm -f "$tmp"
158
+ return 0
159
+ fi
160
+ rm -f "$tmp"
161
+ return 1
162
+ }
163
+ bootstrap_ready() {
164
+ test -s /opt/p2p/bootstrap.json \
165
+ && grep -q '"password"[[:space:]]*:' /opt/p2p/bootstrap.json \
166
+ && grep -q '"agent_token"[[:space:]]*:' /opt/p2p/bootstrap.json \
167
+ && grep -q '"access_token"[[:space:]]*:' /opt/p2p/bootstrap.json \
168
+ && grep -Eq '"agent_room_id"[[:space:]]*:[[:space:]]*"![^"]+"' /opt/p2p/bootstrap.json
169
+ }
170
+ if sync_container_bootstrap && bootstrap_ready; then
171
+ echo "[update] existing bootstrap credentials are present; skipping portal.bootstrap."
172
+ else
173
+ DOMAIN="$DOMAIN" bash /opt/p2p/init-tokens.sh
174
+ fi
175
+ EOF
176
+ )
177
+ printf 'sudo MESSAGE_SERVER_IMAGE=%s sh -lc %s\n' "$image_q" "$(ops_sh_quote "$remote_script")"
178
+ }
179
+
180
+ ops_reset_remote_command() {
181
+ cat <<'EOF'
182
+ set -eu
183
+ cd /opt/p2p
184
+ sudo docker compose --env-file .env down
185
+ project=$(basename "$PWD")
186
+ for volume in postgres-data message-config message-data; do
187
+ ids=$(sudo docker volume ls -q --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.volume=$volume" 2>/dev/null || true)
188
+ if [ -n "$ids" ]; then
189
+ sudo docker volume rm $ids >/dev/null 2>&1 || true
190
+ fi
191
+ sudo docker volume rm "${project}_${volume}" >/dev/null 2>&1 || true
192
+ done
193
+ sudo rm -f /opt/p2p/bootstrap.json /opt/p2p/wellknown/owner.json
194
+ new_code=$(od -An -N4 -tu4 /dev/urandom | awk '{printf "%08d", $1 % 100000000}')
195
+ sudo sed -i '/^P2P_PORTAL_PASSWORD=/d' .env
196
+ printf 'P2P_PORTAL_PASSWORD=%s\n' "$new_code" | sudo tee -a .env >/dev/null
197
+ sudo docker compose --env-file .env up -d
198
+ DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
199
+ sudo DOMAIN="$DOMAIN" bash /opt/p2p/init-tokens.sh
200
+ EOF
201
+ }
202
+
203
+ ops_mark_refresh_pending() {
204
+ local state=$1 start_phase=${2:-S4_BOOTSTRAP_STACK} tmp
205
+ tmp="$state.tmp.$$"
206
+ jq --arg start "$start_phase" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
207
+ del(
208
+ .password,
209
+ .access_token,
210
+ .agent_token,
211
+ .agent_room_id,
212
+ .user_confirmations,
213
+ .runtime_checks
214
+ )
215
+ | .agent_install_status = "refresh_pending"
216
+ | .phase = $start
217
+ | (if ($start == "S4_BOOTSTRAP_STACK") then
218
+ .phases.S4_BOOTSTRAP_STACK = {status:"pending", ts:$ts, evidence:"existing node operation requires fresh health check"}
219
+ else . end)
220
+ | .phases.S5_INIT_TOKENS = {status:"pending", ts:$ts, evidence:"existing node operation requires fresh bootstrap credentials"}
221
+ | .phases.S6_WIRE_LOCAL = {status:"pending", ts:$ts, evidence:"existing node operation requires local credentials and MCP refresh"}
222
+ | .phases.S7_VERIFY_E2E = {status:"pending", ts:$ts, evidence:"existing node operation requires fresh verification"}
223
+ ' "$state" > "$tmp" && mv "$tmp" "$state"
224
+ }
225
+
226
+ ops_write_report() {
227
+ local operation=$1 status=$2 state=$3 report
228
+ report=$(operation_report_write "$operation" "$status" "$state")
229
+ printf '%s\n' "$report"
230
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # lib/paths.sh - local Direxio service directory helpers.
3
+
4
+ direxio_home() {
5
+ printf '%s\n' "${DIREXIO_HOME:-$HOME/.direxio}"
6
+ }
7
+
8
+ direxio_service_id() {
9
+ local raw=${1:-} host
10
+ host=${raw#http://}
11
+ host=${host#https://}
12
+ host=${host%%/*}
13
+ case "$host" in
14
+ *:*) host="${host%%:*}-${host#*:}" ;;
15
+ esac
16
+ printf '%s\n' "$host" | tr '[:upper:]' '[:lower:]' | sed -E 's/:/-/g; s/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/^$/direxio-service/'
17
+ }
18
+
19
+ direxio_service_dir() {
20
+ local service_id
21
+ service_id=$(direxio_service_id "$1")
22
+ printf '%s/nodes/%s\n' "$(direxio_home)" "$service_id"
23
+ }
24
+
25
+ direxio_default_workdir() {
26
+ if [ -n "${P2P_WORKDIR:-}" ]; then
27
+ printf '%s\n' "$P2P_WORKDIR"
28
+ elif [ -n "${DIREXIO_WORKDIR:-}" ]; then
29
+ printf '%s\n' "$DIREXIO_WORKDIR"
30
+ elif [ -n "${DOMAIN:-}" ]; then
31
+ direxio_service_dir "$DOMAIN"
32
+ else
33
+ printf '%s/nodes\n' "$(direxio_home)"
34
+ fi
35
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # lib/state.sh - state.json helpers for the deployment state machine.
3
+ #
4
+ # Sourced by orchestrate.sh and phases/*.sh. All state.json reads/writes go
5
+ # through this file to keep structure and fields consistent. Requires jq.
6
+ #
7
+ # state.json path: $P2P_WORKDIR/state.json.
8
+ # By default, DOMAIN=__DOMAIN__ maps to ~/.direxio/nodes/<service_id>/state.json.
9
+ #
10
+ # PHASES order is the state-machine execution order.
11
+
12
+ STATE_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
13
+ # shellcheck disable=SC1090
14
+ source "$STATE_LIB_DIR/paths.sh"
15
+
16
+ # Phase list; order matters.
17
+ PHASES=(
18
+ S0_PREREQ_AWS
19
+ S1_PREFLIGHT
20
+ S2_DOMAIN
21
+ S3_PROVISION
22
+ S4_BOOTSTRAP_STACK
23
+ S5_INIT_TOKENS
24
+ S6_WIRE_LOCAL
25
+ S7_VERIFY_E2E
26
+ )
27
+
28
+ # Paths.
29
+ P2P_WORKDIR=$(direxio_default_workdir)
30
+ STATE_JSON="$P2P_WORKDIR/state.json"
31
+
32
+ # Timestamp helper.
33
+ _now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
34
+
35
+ # Shared logging helpers.
36
+ log() { echo -e "\033[36m[p2p]\033[0m $*" >&2; }
37
+ ok() { echo -e "\033[32m[p2p]\033[0m $*" >&2; }
38
+ warn() { echo -e "\033[33m[p2p]\033[0m $*" >&2; }
39
+ fail() { echo -e "\033[31m[p2p][FATAL]\033[0m $*" >&2; exit 1; }
40
+ is_yes() {
41
+ case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
42
+ y|yes|true|1) return 0 ;;
43
+ *) return 1 ;;
44
+ esac
45
+ }
46
+
47
+ # Initialize state.json for a new deployment.
48
+ state_init() {
49
+ mkdir -p "$P2P_WORKDIR"
50
+ local run_id=${RUN_ID:-p2p-$(date -u +%Y%m%d-%H%M%S)}
51
+ local phases_json="{}"
52
+ local p
53
+ for p in "${PHASES[@]}"; do
54
+ phases_json=$(echo "$phases_json" | jq --arg k "$p" '. + {($k): {"status":"pending"}}')
55
+ done
56
+ jq -n \
57
+ --arg run_id "$run_id" \
58
+ --arg region "${AWS_DEFAULT_REGION:-${AWS_REGION:-}}" \
59
+ --argjson phases "$phases_json" \
60
+ --arg ts "$(_now)" \
61
+ '{
62
+ run_id: $run_id,
63
+ region: (if $region == "" then null else $region end),
64
+ domain_mode: null,
65
+ domain: null,
66
+ domain_confirmed_irreversible: false,
67
+ instance_type: null,
68
+ dns_ready: false,
69
+ existing_state_confirmed: false,
70
+ phase: "S0_PREREQ_AWS",
71
+ created_at: $ts,
72
+ phases: $phases,
73
+ resources: {}
74
+ }' > "$STATE_JSON"
75
+ log "Initialized state.json -> $STATE_JSON (run_id=$run_id)"
76
+ }
77
+
78
+ # Ensure state.json exists.
79
+ state_ensure() {
80
+ [ -f "$STATE_JSON" ] || state_init
81
+ }
82
+
83
+ # Atomic write using a jq filter.
84
+ _state_write() {
85
+ local filter=$1; shift
86
+ local tmp="$STATE_JSON.tmp.$$"
87
+ jq "$@" "$filter" "$STATE_JSON" > "$tmp" && mv "$tmp" "$STATE_JSON"
88
+ }
89
+
90
+ # Top-level field accessors.
91
+ state_get() { jq -r --arg k "$1" '.[$k] // empty' "$STATE_JSON"; }
92
+ state_set() { _state_write '.[$k] = $v' --arg k "$1" --arg v "$2"; }
93
+ state_set_raw() { _state_write ".$1 = $2"; }
94
+
95
+ # Resource records used by destroy.sh.
96
+ res_set() { _state_write '.resources[$k] = $v' --arg k "$1" --arg v "$2"; }
97
+ res_get() { jq -r --arg k "$1" '.resources[$k] // empty' "$STATE_JSON"; }
98
+
99
+ # Phase status helpers.
100
+ # phase_status <PHASE>
101
+ phase_status() { jq -r --arg p "$1" '.phases[$p].status // "pending"' "$STATE_JSON"; }
102
+
103
+ # phase_set <PHASE> <status> [evidence]
104
+ phase_set() {
105
+ local p=$1 st=$2 ev=${3:-}
106
+ _state_write '
107
+ .phases[$p].status = $st
108
+ | .phases[$p].ts = $ts
109
+ | (if $ev != "" then .phases[$p].evidence = $ev else . end)
110
+ | .phase = $p
111
+ ' --arg p "$p" --arg st "$st" --arg ev "$ev" --arg ts "$(_now)"
112
+ }
113
+
114
+ # Find the first phase whose status is not done.
115
+ first_unfinished_phase() {
116
+ local p
117
+ for p in "${PHASES[@]}"; do
118
+ [ "$(phase_status "$p")" != "done" ] && { echo "$p"; return 0; }
119
+ done
120
+ echo "DONE"
121
+ }
122
+
123
+ # poll_until <description> <interval-seconds> <max-attempts> <check-command...>
124
+ # Return 0 when the check command succeeds. max=0 means poll forever.
125
+ poll_until() {
126
+ local desc=$1 interval=$2 maxn=$3; shift 3
127
+ local i=0
128
+ while true; do
129
+ if "$@"; then ok "$desc ✓"; return 0; fi
130
+ i=$((i+1))
131
+ if [ "$maxn" -gt 0 ] && [ "$i" -ge "$maxn" ]; then
132
+ warn "$desc timed out after $i unsuccessful attempts"; return 1
133
+ fi
134
+ log "$desc waiting (attempt $i, retry in ${interval}s)"
135
+ sleep "$interval"
136
+ done
137
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+
4
+ const command = process.argv[2] || "direxio-mcp";
5
+ const timeoutMs = Number.parseInt(process.env.DIREXIO_MCP_TOOLS_TIMEOUT_MS || "8000", 10);
6
+
7
+ const child = spawn(command, {
8
+ env: process.env,
9
+ shell: true,
10
+ stdio: ["pipe", "pipe", "pipe"]
11
+ });
12
+
13
+ let stdout = Buffer.alloc(0);
14
+ let stderr = "";
15
+ let completed = false;
16
+ const responses = new Map();
17
+
18
+ const timer = setTimeout(() => {
19
+ finishWithError(`timed out waiting for MCP tools/list after ${timeoutMs}ms`);
20
+ }, timeoutMs);
21
+
22
+ child.stderr.on("data", (chunk) => {
23
+ stderr += chunk.toString("utf8");
24
+ });
25
+
26
+ child.stdout.on("data", (chunk) => {
27
+ stdout = Buffer.concat([stdout, chunk]);
28
+ readFrames();
29
+ if (responses.has(2)) {
30
+ const response = responses.get(2);
31
+ const tools = Array.isArray(response?.result?.tools) ? response.result.tools : [];
32
+ const names = tools
33
+ .map((tool) => tool?.name)
34
+ .filter((name) => typeof name === "string" && name.length > 0);
35
+ finish({ tools: names, tool_count: names.length });
36
+ }
37
+ });
38
+
39
+ child.on("error", (error) => {
40
+ finishWithError(error.message);
41
+ });
42
+
43
+ child.on("exit", (code) => {
44
+ if (!completed && code !== 0) {
45
+ finishWithError(`MCP server exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`);
46
+ }
47
+ });
48
+
49
+ send({
50
+ jsonrpc: "2.0",
51
+ id: 1,
52
+ method: "initialize",
53
+ params: {
54
+ protocolVersion: "2024-11-05",
55
+ capabilities: {},
56
+ clientInfo: { name: "direxio-deployer", version: "0.0.0" }
57
+ }
58
+ });
59
+ send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
60
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
61
+
62
+ function send(message) {
63
+ child.stdin.write(`${JSON.stringify(message)}\n`);
64
+ }
65
+
66
+ function readFrames() {
67
+ while (true) {
68
+ const lineEnd = stdout.indexOf("\n");
69
+ if (lineEnd < 0) return;
70
+ const line = stdout.subarray(0, lineEnd).toString("utf8").replace(/\r$/, "");
71
+ stdout = stdout.subarray(lineEnd + 1);
72
+ if (line.length === 0) continue;
73
+ const message = JSON.parse(line);
74
+ if (typeof message.id !== "undefined") {
75
+ responses.set(message.id, message);
76
+ }
77
+ }
78
+ }
79
+
80
+ function finish(value) {
81
+ if (completed) return;
82
+ completed = true;
83
+ clearTimeout(timer);
84
+ console.log(JSON.stringify(value));
85
+ child.kill();
86
+ }
87
+
88
+ function finishWithError(message) {
89
+ if (completed) return;
90
+ completed = true;
91
+ clearTimeout(timer);
92
+ console.error(message);
93
+ child.kill();
94
+ process.exitCode = 1;
95
+ }
@@ -0,0 +1,112 @@
1
+ param(
2
+ [Parameter(ValueFromRemainingArguments = $true)]
3
+ [string[]] $OrchestrateArgs
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
9
+ $RepoRoot = Split-Path -Parent $ScriptDir
10
+
11
+ function Find-GitBash {
12
+ $candidates = @(
13
+ "$env:ProgramFiles\Git\bin\bash.exe",
14
+ "$env:ProgramFiles\Git\usr\bin\bash.exe",
15
+ "$env:LOCALAPPDATA\Programs\Git\bin\bash.exe",
16
+ "$env:LOCALAPPDATA\Programs\Git\usr\bin\bash.exe"
17
+ )
18
+ foreach ($candidate in $candidates) {
19
+ if ($candidate -and (Test-Path $candidate)) {
20
+ return $candidate
21
+ }
22
+ }
23
+ throw "Git Bash was not found. Install Git for Windows or run scripts/orchestrate.sh from a POSIX shell."
24
+ }
25
+
26
+ function ConvertTo-GitBashPath([string] $Path) {
27
+ $full = [System.IO.Path]::GetFullPath($Path)
28
+ $drive = $full.Substring(0, 1).ToLowerInvariant()
29
+ $rest = $full.Substring(2).Replace('\', '/')
30
+ return "/$drive$rest"
31
+ }
32
+
33
+ function Quote-BashArg([string] $Value) {
34
+ return "'" + ($Value -replace "'", "'\''") + "'"
35
+ }
36
+
37
+ function Find-CodexBinary {
38
+ $root = Join-Path $env:LOCALAPPDATA 'OpenAI\Codex\bin'
39
+ if (-not (Test-Path $root)) {
40
+ return Find-CommandPath @('codex.exe', 'codex.cmd', 'codex')
41
+ }
42
+ $bundled = Get-ChildItem $root -Filter codex.exe -Recurse -ErrorAction SilentlyContinue |
43
+ Select-Object -First 1 -ExpandProperty FullName
44
+ if ($bundled) {
45
+ return $bundled
46
+ }
47
+ return Find-CommandPath @('codex.exe', 'codex.cmd', 'codex')
48
+ }
49
+
50
+ function Find-CommandPath([string[]] $Names) {
51
+ foreach ($name in $Names) {
52
+ $cmd = Get-Command $name -ErrorAction SilentlyContinue | Select-Object -First 1
53
+ if ($cmd -and $cmd.Source) {
54
+ return $cmd.Source
55
+ }
56
+ }
57
+ return $null
58
+ }
59
+
60
+ function Set-AgentCommandIfMissing([string[]] $EnvNames, [string[]] $CommandNames) {
61
+ foreach ($envName in $EnvNames) {
62
+ if ([Environment]::GetEnvironmentVariable($envName, 'Process')) {
63
+ return
64
+ }
65
+ }
66
+ $path = Find-CommandPath $CommandNames
67
+ if (-not $path) {
68
+ return
69
+ }
70
+ [Environment]::SetEnvironmentVariable($EnvNames[0], $path, 'Process')
71
+ }
72
+
73
+ $bash = Find-GitBash
74
+
75
+ $windowsDirexioHome = if ($env:DIREXIO_HOME) { $env:DIREXIO_HOME } else { Join-Path $env:USERPROFILE '.direxio' }
76
+ $env:DIREXIO_WINDOWS_HOME = $windowsDirexioHome
77
+ $env:DIREXIO_HOME = ConvertTo-GitBashPath $windowsDirexioHome
78
+ $env:DIREXIO_LOCAL_PATH_STYLE = 'windows'
79
+
80
+ if (-not $env:DIREXIO_AGENT_WORKSPACE) {
81
+ $env:DIREXIO_AGENT_WORKSPACE_WINDOWS = (Get-Location).ProviderPath
82
+ }
83
+
84
+ if ($env:P2P_WORKDIR) {
85
+ $env:P2P_WORKDIR_WINDOWS = $env:P2P_WORKDIR
86
+ $env:P2P_WORKDIR = ConvertTo-GitBashPath $env:P2P_WORKDIR
87
+ }
88
+
89
+ if (-not $env:DIREXIO_CODEX_COMMAND) {
90
+ $codex = Find-CodexBinary
91
+ if ($codex) { $env:DIREXIO_CODEX_COMMAND = $codex }
92
+ }
93
+
94
+ Set-AgentCommandIfMissing @('DIREXIO_CLAUDECODE_COMMAND', 'DIREXIO_CLAUDE_CODE_COMMAND', 'DIREXIO_CLAUDE_COMMAND') @('claude.exe', 'claude.cmd', 'claude', 'claude-code.exe', 'claude-code.cmd', 'claude-code')
95
+ Set-AgentCommandIfMissing @('DIREXIO_GEMINI_COMMAND') @('gemini.exe', 'gemini.cmd', 'gemini')
96
+ Set-AgentCommandIfMissing @('DIREXIO_COPILOT_COMMAND') @('copilot.exe', 'copilot.cmd', 'copilot')
97
+ Set-AgentCommandIfMissing @('DIREXIO_DEVIN_COMMAND') @('devin.exe', 'devin.cmd', 'devin')
98
+ Set-AgentCommandIfMissing @('DIREXIO_KIMI_COMMAND') @('kimi.exe', 'kimi.cmd', 'kimi')
99
+ Set-AgentCommandIfMissing @('DIREXIO_OPENCODE_COMMAND', 'DIREXIO_OPEN_CODE_COMMAND') @('opencode.exe', 'opencode.cmd', 'opencode')
100
+ Set-AgentCommandIfMissing @('DIREXIO_IFLOW_COMMAND') @('iflow.exe', 'iflow.cmd', 'iflow')
101
+ Set-AgentCommandIfMissing @('DIREXIO_QODER_COMMAND', 'DIREXIO_QODERCLI_COMMAND') @('qodercli.exe', 'qodercli.cmd', 'qodercli', 'qoder.exe', 'qoder.cmd', 'qoder')
102
+ Set-AgentCommandIfMissing @('DIREXIO_PI_COMMAND') @('pi.exe', 'pi.cmd', 'pi')
103
+ Set-AgentCommandIfMissing @('DIREXIO_ANTIGRAVITY_COMMAND', 'DIREXIO_AGY_COMMAND') @('agy.exe', 'agy.cmd', 'agy')
104
+ Set-AgentCommandIfMissing @('DIREXIO_OPENCLAW_COMMAND') @('openclaw.exe', 'openclaw.cmd', 'openclaw')
105
+ Set-AgentCommandIfMissing @('DIREXIO_HERMES_COMMAND') @('hermes.exe', 'hermes.cmd', 'hermes')
106
+
107
+ $repoRootForBash = ConvertTo-GitBashPath $RepoRoot
108
+ $quotedArgs = ($OrchestrateArgs | ForEach-Object { Quote-BashArg $_ }) -join ' '
109
+ $command = "cd $(Quote-BashArg $repoRootForBash) && ./scripts/orchestrate.sh $quotedArgs"
110
+
111
+ & $bash -lc $command
112
+ exit $LASTEXITCODE