Implement S7 operator connect helpers for SaaS and channels.
Add dry-run-default connect dispatcher and per-integration scripts with gitignored local capability state, docs, and unit tests. Mutations require --apply and use nemohermes/openshell only.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/lib/connect_state.sh — S7: local capability state management
|
||||
# Sourced by connect scripts. Manages .local/capability_state.json.
|
||||
# Do not execute directly.
|
||||
#
|
||||
# All state is written to .local/ (gitignored) — never into repo-tracked files.
|
||||
# Fixtures under data/fixtures/setup/ remain untouched.
|
||||
#
|
||||
# SECURITY: All Python invocations pass data via stdin, env vars, or sys.argv.
|
||||
# Shell variables are NEVER interpolated into Python source strings.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Local state directory ──────────────────────────────────────────────────
|
||||
LOCAL_DIR="${REPO_ROOT}/.local"
|
||||
STATE_FILE="${LOCAL_DIR}/capability_state.json"
|
||||
|
||||
# Valid status values (must match setup-education ConnectionStatus enum)
|
||||
VALID_STATUSES="connected skipped later error offline"
|
||||
|
||||
# ── Ensure .local directory exists ─────────────────────────────────────────
|
||||
ensure_local_dir() {
|
||||
if [[ ! -d "$LOCAL_DIR" ]]; then
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
log_info "Created local state directory: $LOCAL_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Read current state (returns empty JSON object if no state file) ────────
|
||||
read_state() {
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
cat "$STATE_FILE"
|
||||
else
|
||||
echo '{}'
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Get status for a target ────────────────────────────────────────────────
|
||||
# Usage: get_status "square" → "connected" | "skipped" | "later" | "error" | ""
|
||||
get_status() {
|
||||
local target="$1"
|
||||
local state
|
||||
state="$(read_state)"
|
||||
|
||||
# Pass state via stdin, target via env var — no string interpolation
|
||||
printf '%s' "$state" | TARGET="$target" python3 -c "
|
||||
import json, sys, os
|
||||
state = json.loads(sys.stdin.read())
|
||||
target = os.environ['TARGET']
|
||||
print(state.get(target, {}).get('status', ''))
|
||||
" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# ── Set status for a target ────────────────────────────────────────────────
|
||||
# Usage: set_status "square" "connected" "Square MCP registered"
|
||||
set_status() {
|
||||
local target="$1"
|
||||
local status="$2"
|
||||
local details="${3:-}"
|
||||
|
||||
# Validate status
|
||||
local valid=0
|
||||
for s in $VALID_STATUSES; do
|
||||
if [[ "$s" == "$status" ]]; then
|
||||
valid=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $valid -eq 0 ]]; then
|
||||
log_error "Invalid status '$status'. Must be one of: $VALID_STATUSES"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ensure_local_dir
|
||||
|
||||
local state
|
||||
state="$(read_state)"
|
||||
|
||||
# Pass state via stdin, target/status/details via env vars — no interpolation
|
||||
printf '%s' "$state" | \
|
||||
CONNECT_TARGET="$target" \
|
||||
CONNECT_STATUS="$status" \
|
||||
CONNECT_DETAILS="$details" \
|
||||
python3 -c "
|
||||
import json, sys, os, datetime
|
||||
|
||||
state = json.loads(sys.stdin.read())
|
||||
target = os.environ['CONNECT_TARGET']
|
||||
status = os.environ['CONNECT_STATUS']
|
||||
details = os.environ['CONNECT_DETAILS']
|
||||
|
||||
state[target] = {
|
||||
'status': status,
|
||||
'details': details,
|
||||
'updated_at': datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
# Ensure top-level metadata
|
||||
if 'generated_at' not in state:
|
||||
state['generated_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
if 'is_fixture' not in state:
|
||||
state['is_fixture'] = False
|
||||
|
||||
json.dump(state, sys.stdout, indent=2)
|
||||
" > "${STATE_FILE}.tmp" 2>/dev/null
|
||||
|
||||
mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
log_info "State updated: ${target} → ${status}"
|
||||
}
|
||||
|
||||
# ── Print status summary (human-readable) ──────────────────────────────────
|
||||
print_status() {
|
||||
local state
|
||||
state="$(read_state)"
|
||||
|
||||
if [[ "$state" == "{}" ]]; then
|
||||
log_info "No connection state recorded yet."
|
||||
log_info "Run connect commands to establish integrations."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_section "Connection Status"
|
||||
|
||||
# Pass state via stdin — no interpolation
|
||||
printf '%s' "$state" | python3 -c "
|
||||
import json, sys
|
||||
|
||||
state = json.loads(sys.stdin.read())
|
||||
# Remove metadata keys
|
||||
meta_keys = {'generated_at', 'is_fixture'}
|
||||
targets = {k: v for k, v in state.items() if k not in meta_keys}
|
||||
|
||||
if not targets:
|
||||
print(' No targets configured yet.')
|
||||
else:
|
||||
status_icons = {
|
||||
'connected': '✅',
|
||||
'skipped': '⏭️',
|
||||
'later': '⏳',
|
||||
'error': '❌',
|
||||
'offline': '📋',
|
||||
}
|
||||
for target, info in sorted(targets.items()):
|
||||
status = info.get('status', 'unknown')
|
||||
icon = status_icons.get(status, '❓')
|
||||
details = info.get('details', '')
|
||||
updated = info.get('updated_at', '')
|
||||
display = target.replace('_', ' ').title()
|
||||
print(f' {icon} {display:25s} {status:10s} {details}')
|
||||
if updated:
|
||||
print(f' Updated: {updated}')
|
||||
" 2>/dev/null
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Export state as capability report JSON (compatible with setup-education) ─
|
||||
# Produces the same schema as data/fixtures/setup/capability_matrix.json
|
||||
export_capability_report() {
|
||||
local output_file="${1:-}"
|
||||
local state
|
||||
state="$(read_state)"
|
||||
|
||||
if [[ "$state" == "{}" ]]; then
|
||||
log_warn "No connection state to export."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sandbox_name
|
||||
sandbox_name="$(get_sandbox_name)"
|
||||
|
||||
# Pass state via stdin, sandbox_name via env var — no interpolation
|
||||
local report
|
||||
report="$(printf '%s' "$state" | \
|
||||
CONNECT_SANDBOX="$sandbox_name" \
|
||||
python3 -c "
|
||||
import json, sys, os, datetime
|
||||
|
||||
state = json.loads(sys.stdin.read())
|
||||
sandbox_name = os.environ['CONNECT_SANDBOX']
|
||||
meta_keys = {'generated_at', 'is_fixture'}
|
||||
targets = {k: v for k, v in state.items() if k not in meta_keys}
|
||||
|
||||
capabilities = []
|
||||
area_map = {
|
||||
'name': 'identity',
|
||||
'profile': 'profile',
|
||||
'whatsapp': 'channels',
|
||||
'email': 'channels',
|
||||
'telegram': 'channels',
|
||||
'square': 'scheduling',
|
||||
'quickbooks': 'books',
|
||||
'vagaro': 'scheduling',
|
||||
}
|
||||
|
||||
for target, info in sorted(targets.items()):
|
||||
area = area_map.get(target, 'other')
|
||||
capabilities.append({
|
||||
'area': area,
|
||||
'provider': target,
|
||||
'status': info.get('status', 'offline'),
|
||||
'details': info.get('details', ''),
|
||||
})
|
||||
|
||||
report = {
|
||||
'salon_name': sandbox_name,
|
||||
'is_fixture': False,
|
||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'capabilities': capabilities,
|
||||
}
|
||||
|
||||
json.dump(report, sys.stdout, indent=2)
|
||||
" 2>/dev/null)"
|
||||
|
||||
if [[ -n "$output_file" ]]; then
|
||||
echo "$report" > "$output_file"
|
||||
log_info "Capability report exported to $output_file"
|
||||
else
|
||||
echo "$report"
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user