direxio-deployer 0.1.0 → 0.1.2

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 (51) hide show
  1. package/README.md +10 -2
  2. package/README_zh.md +10 -2
  3. package/SKILL.md +32 -8
  4. package/bin/direxio-deployer.mjs +1 -2
  5. package/package.json +2 -3
  6. package/references/agent-targets.md +7 -1
  7. package/references/deployment-lessons.md +5 -7
  8. package/references/deployment-workflow.md +8 -4
  9. package/references/runtime-wiring.md +5 -5
  10. package/references/tooling.md +11 -12
  11. package/references/user-journey.md +2 -2
  12. package/references/voip-turn-runbook.md +2 -2
  13. package/references/windows-deployment-notes.md +2 -1
  14. package/scripts/destroy.sh +24 -43
  15. package/scripts/json.mjs +841 -0
  16. package/scripts/lib/aws.sh +5 -1
  17. package/scripts/lib/json.sh +114 -0
  18. package/scripts/lib/operation_report.sh +8 -195
  19. package/scripts/lib/ops.sh +8 -21
  20. package/scripts/lib/state.sh +18 -44
  21. package/scripts/mcp-tools-list.mjs +66 -5
  22. package/scripts/orchestrate.sh +166 -249
  23. package/scripts/phases/s3_provision.sh +5 -10
  24. package/scripts/phases/s5_init_tokens.sh +7 -17
  25. package/scripts/phases/s6_wire_local.sh +22 -42
  26. package/scripts/phases/s7_verify_e2e.sh +5 -5
  27. package/scripts/pricing-estimate.sh +36 -80
  28. package/tests/aws_credentials_test.sh +0 -139
  29. package/tests/connect_daemon_runtime_check_test.sh +0 -120
  30. package/tests/default_paths_test.sh +0 -58
  31. package/tests/destroy_local_bridge_test.sh +0 -154
  32. package/tests/destroy_root_identity_test.sh +0 -91
  33. package/tests/destroy_route53_zone_test.sh +0 -80
  34. package/tests/domain_authoritative_dns_test.sh +0 -49
  35. package/tests/mcp_doctor_runtime_check_test.sh +0 -86
  36. package/tests/mcp_smoke_runtime_check_test.sh +0 -121
  37. package/tests/mcp_tools_runtime_check_test.sh +0 -123
  38. package/tests/npm_skill_distribution_test.sh +0 -95
  39. package/tests/operation_report_test.sh +0 -258
  40. package/tests/orchestrate_status_recovery_test.sh +0 -91
  41. package/tests/phase_timeout_test.sh +0 -88
  42. package/tests/pricing_estimate_test.sh +0 -159
  43. package/tests/render_userdata_remote_nodes_test.sh +0 -40
  44. package/tests/root_volume_tracking_test.sh +0 -41
  45. package/tests/route53_overwrite_guard_test.sh +0 -86
  46. package/tests/route53_zone_auto_create_test.sh +0 -66
  47. package/tests/runtime_summary_check_test.sh +0 -203
  48. package/tests/s6_wire_local_test.sh +0 -405
  49. package/tests/skill_structure_test.sh +0 -298
  50. package/tests/update_reset_ops_test.sh +0 -230
  51. package/tests/user_confirmation_gates_test.sh +0 -152
@@ -4,11 +4,15 @@
4
4
  # Some local proxy setups truncate AWS API TLS (UNEXPECTED_EOF). Bypass proxies
5
5
  # for AWS endpoints in every phase that calls aws.
6
6
 
7
+ AWS_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
8
+ # shellcheck disable=SC1090
9
+ source "$AWS_LIB_DIR/json.sh"
10
+
7
11
  aws_env_prep() {
8
12
  local region=${AWS_DEFAULT_REGION:-${AWS_REGION:-}}
9
13
  if [ -n "${STATE_JSON:-}" ] && [ -f "$STATE_JSON" ]; then
10
14
  local state_region
11
- state_region=$(jq -r '.region // empty' "$STATE_JSON" 2>/dev/null || true)
15
+ state_region=$(json_get "$STATE_JSON" region 2>/dev/null || true)
12
16
  [ -n "$state_region" ] && region="$state_region"
13
17
  fi
14
18
  if [ -z "$region" ]; then
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # Portable JSON helpers backed by Node.js.
3
+
4
+ JSON_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5
+ JSON_HELPER="$JSON_LIB_DIR/../json.mjs"
6
+
7
+ json_node() {
8
+ local uname_s node_path
9
+ if [ -n "${NODE:-}" ]; then
10
+ printf '%s\n' "$NODE"
11
+ return 0
12
+ fi
13
+ uname_s=$(uname -s 2>/dev/null || printf unknown)
14
+ if command -v node >/dev/null 2>&1; then
15
+ node_path=$(command -v node)
16
+ case "$uname_s:$node_path" in
17
+ Linux*:*.exe|Linux*:/mnt/*|Linux*:/c/*) ;;
18
+ *)
19
+ printf '%s\n' "$node_path"
20
+ return 0
21
+ ;;
22
+ esac
23
+ fi
24
+ case "$uname_s" in
25
+ Linux*)
26
+ local user_home
27
+ user_home=$(eval "printf '%s' ~${USER:-}" 2>/dev/null || true)
28
+ for node_path in "$HOME/.local/node/bin/node" "$user_home/.local/node/bin/node" /usr/local/bin/node /usr/bin/node; do
29
+ if [ -x "$node_path" ]; then
30
+ printf '%s\n' "$node_path"
31
+ return 0
32
+ fi
33
+ done
34
+ echo "POSIX node is required for JSON processing in Linux/WSL; Windows node.exe cannot read POSIX paths." >&2
35
+ return 1
36
+ ;;
37
+ esac
38
+ if command -v node.exe >/dev/null 2>&1; then
39
+ command -v node.exe
40
+ return 0
41
+ fi
42
+ echo "node is required for JSON processing." >&2
43
+ return 1
44
+ }
45
+
46
+ json_cli() {
47
+ local node_bin
48
+ node_bin=$(json_node) || return 1
49
+ "$node_bin" "$JSON_HELPER" "$@"
50
+ }
51
+
52
+ json_get() {
53
+ json_cli get "$@"
54
+ }
55
+
56
+ json_stdin_get() {
57
+ json_cli stdin-get "$@"
58
+ }
59
+
60
+ json_assert() {
61
+ json_cli assert "$@"
62
+ }
63
+
64
+ json_stdin_assert() {
65
+ json_cli stdin-assert "$@"
66
+ }
67
+
68
+ json_check() {
69
+ json_cli check "$@"
70
+ }
71
+
72
+ json_entries() {
73
+ json_cli entries "$@"
74
+ }
75
+
76
+ json_stdin_tsv() {
77
+ json_cli stdin-tsv "$@"
78
+ }
79
+
80
+ json_stdin_join() {
81
+ json_cli stdin-join "$@"
82
+ }
83
+
84
+ json_stdin_route53_a_values() {
85
+ json_cli stdin-route53-a-values "$@"
86
+ }
87
+
88
+ json_stdin_route53_a_present() {
89
+ json_cli stdin-route53-a-present "$@"
90
+ }
91
+
92
+ json_stdin_price_usd() {
93
+ json_cli stdin-price-usd "$@"
94
+ }
95
+
96
+ json_length() {
97
+ json_cli length "$@"
98
+ }
99
+
100
+ json_type() {
101
+ json_cli type "$@"
102
+ }
103
+
104
+ json_build() {
105
+ json_cli build "$@"
106
+ }
107
+
108
+ json_mutate() {
109
+ json_cli mutate "$@"
110
+ }
111
+
112
+ json_valid() {
113
+ json_cli valid "$@"
114
+ }
@@ -1,20 +1,25 @@
1
1
  #!/usr/bin/env bash
2
2
  # lib/operation_report.sh - redacted operation reports for deploy/destroy flows.
3
3
 
4
+ OPERATION_REPORT_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5
+ # shellcheck disable=SC1090
6
+ source "$OPERATION_REPORT_LIB_DIR/json.sh"
7
+
4
8
  operation_report_now() {
5
9
  date -u +%Y-%m-%dT%H:%M:%SZ
6
10
  }
7
11
 
8
12
  operation_report_service_id() {
9
13
  local state=$1 service_id
10
- service_id=$(jq -r '.agent_service_id // .domain // empty' "$state")
14
+ service_id=$(json_get "$state" agent_service_id)
15
+ [ -n "$service_id" ] || service_id=$(json_get "$state" domain)
11
16
  printf '%s\n' "${service_id:-unknown-service}"
12
17
  }
13
18
 
14
19
  operation_report_default_path() {
15
20
  local operation=$1 state=$2 service_id service_dir root
16
21
  service_id=$(operation_report_service_id "$state")
17
- service_dir=$(jq -r '.agent_service_dir // empty' "$state")
22
+ service_dir=$(json_get "$state" agent_service_dir)
18
23
  [ -n "$service_dir" ] || service_dir=$(dirname "$state")
19
24
  case "$operation" in
20
25
  destroy)
@@ -29,199 +34,7 @@ operation_report_default_path() {
29
34
 
30
35
  operation_report_json() {
31
36
  local operation=$1 status=$2 state=$3 generated_at=$4
32
- jq -n \
33
- --arg operation_type "$operation" \
34
- --arg status "$status" \
35
- --arg generated_at "$generated_at" \
36
- --arg state_json "$state" \
37
- --slurpfile state "$state" '
38
- $state[0] as $st |
39
- def redacted_status:
40
- if (($st.password // "") | tostring | length) > 0
41
- then "available_in_state_password_field_redacted"
42
- else "missing"
43
- end;
44
- def phase_statuses:
45
- ($st.phases // {} | with_entries(.value = (.value.status // "unknown")));
46
- def user_gate($gate; $default):
47
- ($st.user_confirmations[$gate].status // $default);
48
- def local_refresh_status:
49
- if ($st.agent_install_status // "") == "refresh_pending"
50
- then "refresh_pending"
51
- else "current_or_not_recorded"
52
- end;
53
- def redact_text($value):
54
- def redact_one($secret):
55
- if (($secret // "") | tostring | length) > 0
56
- then split($secret | tostring) | join("<redacted>")
57
- else .
58
- end;
59
- ($value // "" | tostring)
60
- | redact_one($st.password)
61
- | redact_one($st.access_token)
62
- | redact_one($st.agent_token)
63
- | redact_one($st.matrix_access_token)
64
- | redact_one($st.owner_access_token)
65
- | redact_one($st.aws_secret_access_key)
66
- | redact_one($st.aws_session_token)
67
- | gsub("[0-9]{8,}"; "<redacted>");
68
- def user_gate_detail($gate; $default):
69
- ($st.user_confirmations[$gate] // {}) as $gate_state |
70
- {
71
- status: ($gate_state.status // $default),
72
- ts: ($gate_state.ts // ""),
73
- evidence: redact_text($gate_state.evidence // ""),
74
- evidence_redacted: ((redact_text($gate_state.evidence // "")) != (($gate_state.evidence // "") | tostring))
75
- }
76
- + (if $gate == "agent_mcp_runtime" then {
77
- runtime_summary_status: ($gate_state.runtime_summary_status // ""),
78
- runtime_probe_confirmed: ($gate_state.runtime_probe_confirmed // false)
79
- } else {} end);
80
- def billable:
81
- [
82
- (if (($st.resources.instance_id // "") | tostring | length) > 0 then "EC2 \($st.resources.instance_id)" else empty end),
83
- (if (($st.resources.root_volume_id // "") | tostring | length) > 0 then "EBS root volume \($st.resources.root_volume_id)" else empty end),
84
- (if (($st.resources.public_ip // "") | tostring | length) > 0 then "public IPv4 \($st.resources.public_ip)" else empty end),
85
- (if (($st.resources.eip_id // "") | tostring | length) > 0 then "Elastic IP \($st.resources.eip_id)" else empty end),
86
- (if (($st.resources.route53_zone_id // "") | tostring | length) > 0 then "Route53 hosted zone \($st.resources.route53_zone_id)" else empty end)
87
- ];
88
- def destroy_status($key):
89
- ($st.destroy_evidence[$key].status // "not_checked");
90
- def status_not_in($status; $safe):
91
- (($safe | index($status)) == null);
92
- def destroy_billable_residue:
93
- [
94
- (if (($st.resources.instance_id // "") | tostring | length) > 0
95
- and status_not_in(destroy_status("ec2_instance"); ["terminated", "not_found", "skipped"])
96
- then "EC2 \($st.resources.instance_id) status=\(destroy_status("ec2_instance"))"
97
- else empty end),
98
- (if (($st.resources.root_volume_id // "") | tostring | length) > 0
99
- and status_not_in(destroy_status("ebs_root_volume"); ["deleted", "skipped"])
100
- then "EBS root volume \($st.resources.root_volume_id) status=\(destroy_status("ebs_root_volume"))"
101
- else empty end),
102
- (if (($st.resources.eip_id // "") | tostring | length) > 0
103
- and status_not_in(destroy_status("elastic_ip"); ["released", "skipped"])
104
- then "Elastic IP \($st.resources.eip_id) status=\(destroy_status("elastic_ip"))"
105
- else empty end),
106
- (if (($st.resources.route53_zone_id // "") | tostring | length) > 0
107
- and status_not_in(destroy_status("route53_hosted_zone"); ["deleted", "skipped"])
108
- then "Route53 hosted zone \($st.resources.route53_zone_id) status=\(destroy_status("route53_hosted_zone"))"
109
- else empty end)
110
- ];
111
- {
112
- operation_type: $operation_type,
113
- status: $status,
114
- generated_at: $generated_at,
115
- domain: ($st.domain // ""),
116
- service_id: ($st.agent_service_id // $st.domain // ""),
117
- service_dir: ($st.agent_service_dir // ""),
118
- state_json: $state_json,
119
- delivery: {
120
- app_domain: ($st.domain // ""),
121
- product_completion_status: $status,
122
- init_code_status: redacted_status,
123
- init_code_secret_redacted: true,
124
- user_path: "enter app_domain and the eight-digit initialization code in the App"
125
- },
126
- agent: {
127
- node_id: ($st.agent_node_id // ""),
128
- room_id: ($st.agent_room_id // ""),
129
- runtime: ($st.agent_runtime // "unknown"),
130
- service_id: ($st.agent_service_id // $st.domain // ""),
131
- credentials_file: ($st.agent_credentials_file // "")
132
- },
133
- gates: {
134
- automated: phase_statuses,
135
- user_confirmation: {
136
- app_initialization: user_gate("app_initialization"; "pending_user_confirmation"),
137
- real_chat: user_gate("real_chat"; "pending_user_confirmation"),
138
- agent_mcp_runtime: user_gate("agent_mcp_runtime"; "pending_runtime_confirmation")
139
- },
140
- user_confirmation_details: {
141
- app_initialization: user_gate_detail("app_initialization"; "pending_user_confirmation"),
142
- real_chat: user_gate_detail("real_chat"; "pending_user_confirmation"),
143
- agent_mcp_runtime: user_gate_detail("agent_mcp_runtime"; "pending_runtime_confirmation")
144
- }
145
- },
146
- runtime_checks: {
147
- summary: ($st.runtime_checks.summary // {status: "not_run"}),
148
- connect_daemon: ($st.runtime_checks.connect_daemon // {status: "not_run"}),
149
- mcp_doctor: ($st.runtime_checks.mcp_doctor // {status: "not_run"}),
150
- mcp_smoke: ($st.runtime_checks.mcp_smoke // {status: "not_run"}),
151
- mcp_tools: ($st.runtime_checks.mcp_tools // {status: "not_run"})
152
- },
153
- credentials: {
154
- status: local_refresh_status,
155
- credentials_file: ($st.agent_credentials_file // ""),
156
- contains_secrets: true,
157
- values_redacted: true
158
- },
159
- connect: {
160
- package: ($st.cc_connect_npm_package // "direxio-connent@latest"),
161
- agent: ($st.cc_connect_agent // ""),
162
- config: ($st.cc_connect_config // ""),
163
- install_status: ($st.agent_install_status // "")
164
- },
165
- mcp: {
166
- status: local_refresh_status,
167
- package: ($st.mcp_npm_package // "direxio-mcp@latest"),
168
- server_name: ($st.mcp_server_name // ""),
169
- config_dir: ($st.mcp_config_dir // ""),
170
- codex: ($st.mcp_codex_config // ""),
171
- openclaw: ($st.mcp_openclaw_config // ""),
172
- hermes: ($st.mcp_hermes_config // ""),
173
- doctor: ($st.mcp_doctor_command // "")
174
- },
175
- resources: {
176
- region: ($st.region // ""),
177
- domain_mode: ($st.domain_mode // ""),
178
- instance_type: ($st.instance_type // ""),
179
- instance_id: ($st.resources.instance_id // ""),
180
- root_volume_id: ($st.resources.root_volume_id // ""),
181
- public_ip: ($st.resources.public_ip // ""),
182
- eip_id: ($st.resources.eip_id // ""),
183
- route53_zone_id: ($st.resources.route53_zone_id // ""),
184
- route53_zone_name: ($st.resources.route53_zone_name // ""),
185
- route53_zone_created_by_deployer: ($st.resources.route53_zone_created_by_deployer // ""),
186
- route53_name_servers: ($st.resources.route53_name_servers // ""),
187
- route53_existing_a_value: ($st.resources.route53_existing_a_value // ""),
188
- route53_pending_a_value: ($st.resources.route53_pending_a_value // ""),
189
- route53_overwrite_confirmed: ($st.resources.route53_overwrite_confirmed // ""),
190
- sg_id: ($st.resources.sg_id // ""),
191
- key_name: ($st.resources.key_name // "")
192
- },
193
- billing: {
194
- keeps_billing_until_destroy: ($operation_type != "destroy"),
195
- recorded_billable_resources: billable,
196
- cost_estimate: ($st.cost_estimate // null),
197
- destroy_cleanup_status: (
198
- if $operation_type != "destroy" then "not_destroy"
199
- elif (destroy_billable_residue | length) == 0 then "no_recorded_billable_resource_residue"
200
- else "possible_billable_resource_residue"
201
- end
202
- ),
203
- possible_remaining_billable_resources: (
204
- if $operation_type == "destroy" then destroy_billable_residue else [] end
205
- )
206
- },
207
- security: {
208
- secrets_included: false,
209
- values_redacted: true,
210
- root_access_key_allowed: true,
211
- temporary_iam_cleanup_required: true,
212
- temporary_iam_cleanup_action: "if a temporary DirexioDeployer access key was used, delete or disable it after deployment, or reduce it to a maintenance-only policy"
213
- }
214
- }
215
- + (if $operation_type == "destroy" then {
216
- destroy: {
217
- resources_processed_from_state: true,
218
- user_managed_dns_not_removed: true,
219
- purchased_domain_not_removed: true,
220
- local_service_dir: ($st.agent_service_dir // ""),
221
- evidence: ($st.destroy_evidence // {})
222
- }
223
- } else {} end)
224
- '
37
+ json_cli operation-report "$operation" "$status" "$state" "$generated_at"
225
38
  }
226
39
 
227
40
  operation_report_write() {
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # lib/ops.sh - existing-node update/reset helpers.
3
3
 
4
+ OPS_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5
+ # shellcheck disable=SC1090
6
+ source "$OPS_LIB_DIR/json.sh"
7
+
4
8
  ops_state_path() {
5
9
  local explicit=${1:-}
6
10
  if [ -n "$explicit" ]; then
@@ -20,7 +24,8 @@ ops_require_state() {
20
24
 
21
25
  ops_state_get() {
22
26
  local state=$1 path=$2
23
- jq -r "$path // empty" "$state"
27
+ path=${path#\.}
28
+ json_get "$state" "$path"
24
29
  }
25
30
 
26
31
  ops_sh_quote() {
@@ -201,26 +206,8 @@ EOF
201
206
  }
202
207
 
203
208
  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"
209
+ local state=$1 start_phase=${2:-S4_BOOTSTRAP_STACK}
210
+ json_mutate "$state" ops-refresh-pending "$start_phase" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
224
211
  }
225
212
 
226
213
  ops_write_report() {
@@ -2,7 +2,7 @@
2
2
  # lib/state.sh - state.json helpers for the deployment state machine.
3
3
  #
4
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.
5
+ # through this file to keep structure and fields consistent. Requires Node.js.
6
6
  #
7
7
  # state.json path: $P2P_WORKDIR/state.json.
8
8
  # By default, DOMAIN=__DOMAIN__ maps to ~/.direxio/nodes/<service_id>/state.json.
@@ -12,6 +12,8 @@
12
12
  STATE_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
13
13
  # shellcheck disable=SC1090
14
14
  source "$STATE_LIB_DIR/paths.sh"
15
+ # shellcheck disable=SC1090
16
+ source "$STATE_LIB_DIR/json.sh"
15
17
 
16
18
  # Phase list; order matters.
17
19
  PHASES=(
@@ -48,30 +50,8 @@ is_yes() {
48
50
  state_init() {
49
51
  mkdir -p "$P2P_WORKDIR"
50
52
  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"
53
+ : > "$STATE_JSON"
54
+ json_mutate "$STATE_JSON" state-init "$run_id" "${AWS_DEFAULT_REGION:-${AWS_REGION:-}}" "$(_now)" "${PHASES[@]}"
75
55
  log "Initialized state.json -> $STATE_JSON (run_id=$run_id)"
76
56
  }
77
57
 
@@ -80,35 +60,29 @@ state_ensure() {
80
60
  [ -f "$STATE_JSON" ] || state_init
81
61
  }
82
62
 
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
63
  # 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"; }
64
+ state_get() { json_get "$STATE_JSON" "$1"; }
65
+ state_set() { json_mutate "$STATE_JSON" set-string "$1" "$2"; }
66
+ state_set_raw() { json_mutate "$STATE_JSON" set-json "$1" "$2"; }
67
+ state_set_object() {
68
+ local path=$1 object_json
69
+ shift
70
+ object_json=$(json_build object "$@")
71
+ json_mutate "$STATE_JSON" set-json "$path" "$object_json"
72
+ }
94
73
 
95
74
  # 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"; }
75
+ res_set() { json_mutate "$STATE_JSON" set-string "resources.$1" "$2"; }
76
+ res_get() { json_get "$STATE_JSON" "resources.$1"; }
98
77
 
99
78
  # Phase status helpers.
100
79
  # phase_status <PHASE>
101
- phase_status() { jq -r --arg p "$1" '.phases[$p].status // "pending"' "$STATE_JSON"; }
80
+ phase_status() { json_get "$STATE_JSON" "phases.$1.status" "pending"; }
102
81
 
103
82
  # phase_set <PHASE> <status> [evidence]
104
83
  phase_set() {
105
84
  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)"
85
+ json_mutate "$STATE_JSON" phase-set "$p" "$st" "$(_now)" "$ev"
112
86
  }
113
87
 
114
88
  # Find the first phase whose status is not done.
@@ -26,6 +26,7 @@ child.stderr.on("data", (chunk) => {
26
26
  child.stdout.on("data", (chunk) => {
27
27
  stdout = Buffer.concat([stdout, chunk]);
28
28
  readFrames();
29
+ if (completed) return;
29
30
  if (responses.has(2)) {
30
31
  const response = responses.get(2);
31
32
  const tools = Array.isArray(response?.result?.tools) ? response.result.tools : [];
@@ -60,20 +61,80 @@ send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
60
61
  send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
61
62
 
62
63
  function send(message) {
63
- child.stdin.write(`${JSON.stringify(message)}\n`);
64
+ const body = JSON.stringify(message);
65
+ child.stdin.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
64
66
  }
65
67
 
66
68
  function readFrames() {
67
69
  while (true) {
70
+ if (stdout.length === 0) return;
71
+ if (stdout[0] === 10 || stdout[0] === 13) {
72
+ stdout = stdout.subarray(1);
73
+ continue;
74
+ }
75
+ if (startsWithHeader(stdout, "Content-Length:")) {
76
+ const header = readHeader(stdout);
77
+ if (!header) return;
78
+ const contentLength = parseContentLength(header.text);
79
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
80
+ finishWithError("MCP response frame is missing a valid Content-Length header");
81
+ return;
82
+ }
83
+ const messageEnd = header.bodyStart + contentLength;
84
+ if (stdout.length < messageEnd) return;
85
+ const body = stdout.subarray(header.bodyStart, messageEnd).toString("utf8");
86
+ stdout = stdout.subarray(messageEnd);
87
+ handleMessage(body);
88
+ if (completed) return;
89
+ continue;
90
+ }
91
+
68
92
  const lineEnd = stdout.indexOf("\n");
69
93
  if (lineEnd < 0) return;
70
94
  const line = stdout.subarray(0, lineEnd).toString("utf8").replace(/\r$/, "");
71
95
  stdout = stdout.subarray(lineEnd + 1);
72
96
  if (line.length === 0) continue;
73
- const message = JSON.parse(line);
74
- if (typeof message.id !== "undefined") {
75
- responses.set(message.id, message);
76
- }
97
+ handleMessage(line);
98
+ if (completed) return;
99
+ }
100
+ }
101
+
102
+ function startsWithHeader(buffer, header) {
103
+ return buffer.subarray(0, header.length).toString("utf8").toLowerCase() === header.toLowerCase();
104
+ }
105
+
106
+ function readHeader(buffer) {
107
+ let marker = "\r\n\r\n";
108
+ let headerEnd = buffer.indexOf(marker);
109
+ if (headerEnd < 0) {
110
+ marker = "\n\n";
111
+ headerEnd = buffer.indexOf(marker);
112
+ }
113
+ if (headerEnd < 0) return null;
114
+ return {
115
+ text: buffer.subarray(0, headerEnd).toString("utf8"),
116
+ bodyStart: headerEnd + marker.length
117
+ };
118
+ }
119
+
120
+ function parseContentLength(headerText) {
121
+ for (const line of headerText.split(/\r?\n/)) {
122
+ const match = /^content-length:\s*(\d+)\s*$/i.exec(line);
123
+ if (match) return Number.parseInt(match[1], 10);
124
+ }
125
+ return NaN;
126
+ }
127
+
128
+ function handleMessage(raw) {
129
+ let message;
130
+ try {
131
+ message = JSON.parse(raw);
132
+ } catch (error) {
133
+ finishWithError(`invalid MCP JSON response: ${error.message}`);
134
+ return;
135
+ }
136
+ if (typeof message.id !== "undefined") {
137
+ responses.set(message.id, message);
77
138
  }
78
139
  }
79
140