#!/bin/bash
set +x
set -euo pipefail
set +a

# Usage: ./setup.sh '<GITHUB_TOKEN>' '<OPENCODE_API_KEY>' '<SUME_API_KEY>'
# On configured Macs, omit arguments or use '' to reuse an existing credential.
# Command-line keys can appear in shell history and process listings.
# Create GITHUB_TOKEN:
# 1. Sign in with a GitHub account that has admin access to ParallelPods.
# 2. Open https://github.com/settings/personal-access-tokens/new
#    Set Resource owner = paralleldistribution and choose an allowed expiration.
# 3. Select Only select repositories > ParallelPods.
#    Set Repository permissions > Administration = Read and write.
# 4. Generate the token and obtain organization approval if required.
#    Copy it and pass it as the first argument. No account permissions are needed.
# The token creates a read-only deploy key on the repository for this Mac.
# A personal access token remains tied to its creator, even with an org resource owner.
# Deleting the token also deletes deploy keys it created; rerun with a new token to repair.
#
# Create OPENCODE_API_KEY (Zen / Console):
# 1. Sign in at https://opencode.ai/console/ and select your workspace.
# 2. Open Billing and add credits for paid models.
# 3. Open Account > Personal API keys > Add API key.
#    Select the same workspace and name the key ParallelPods.
#    If shown, set Permissions = Restricted and Models = Request.
# 4. Click Create, copy the key, and pass it as the second argument.
#    Copy it immediately; the full key is shown only once.
#
# SUME_API_KEY is required on first setup and enables the sume__ media tools:
# create it in the Sume dashboard with image, video, TTS and music router access.
GITHUB_TOKEN=${1:-}
OPENCODE_API_KEY=${2:-}
SUME_API_KEY=${3:-}
export -n GITHUB_TOKEN OPENCODE_API_KEY SUME_API_KEY

fail() { printf 'Setup failed: %s\n' "$*" >&2; exit 1; }
trap 'printf "Setup failed at line %s. Fix the reported problem and rerun.\n" "$LINENO" >&2' ERR

[[ $# -le 3 ]] || fail "Usage: ./setup.sh '<GITHUB_TOKEN>' '<OPENCODE_API_KEY>' '<SUME_API_KEY>'"
[[ -n "$GITHUB_TOKEN" || -f "$HOME/.ssh/parallelpods_deploy_ed25519" ]] || fail 'Pass GITHUB_TOKEN as the first argument for the first repository deploy-key setup.'
[[ -z "$SUME_API_KEY" || "$SUME_API_KEY" =~ ^[[:graph:]]+$ ]] || fail 'SUME_API_KEY must be a single printable value without whitespace.'
opencode_config="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
[[ -n "$OPENCODE_API_KEY" || -f "$opencode_config/opencode.json" || -f "$opencode_config/opencode.jsonc" ]] || fail 'Pass OPENCODE_API_KEY as the second argument for the first OpenCode setup.'
if [[ -z "$SUME_API_KEY" ]]; then
  saved_sume_key=$(cat "$HOME/.config/parallelpods/sume_api_key" 2>/dev/null || true)
  [[ "$saved_sume_key" =~ ^[[:graph:]]+$ ]] || fail "Pass SUME_API_KEY as the third argument. On a configured Mac, run ./setup.sh '' '' '<SUME_API_KEY>'."
  unset saved_sume_key
fi
[[ "$(uname -s)" == Darwin ]] || fail 'This installer requires macOS.'
[[ "$(id -u)" != 0 ]] || fail 'Run bash setup.sh as your logged-in user, without sudo.'
case "$(uname -m)" in
  arm64) default_brew=/opt/homebrew/bin/brew ;;
  x86_64) default_brew=/usr/local/bin/brew ;;
  *) fail 'Unsupported Mac architecture.' ;;
esac
launchctl print "gui/$(id -u)" >/dev/null 2>&1 || fail 'Log into the macOS desktop before running setup.'

repo="$HOME/ParallelPods"
repo_url='git@github.com:paralleldistribution/ParallelPods.git'
state="$HOME/.config/parallelpods"
ssh_key="$HOME/.ssh/parallelpods_deploy_ed25519"
known_hosts="$HOME/.ssh/parallelpods_known_hosts"
mkdir -p "$state"
chmod 700 "$state"
# The gateway reads this file per call, so the key stays out of PM2's saved environment.
sume_key_file="$state/sume_api_key"
if [[ -n "$SUME_API_KEY" ]]; then
  (umask 077; printf '%s\n' "$SUME_API_KEY" > "$sume_key_file.tmp")
  mv "$sume_key_file.tmp" "$sume_key_file"
fi
unset SUME_API_KEY
scratch=$(mktemp -d "$state/setup.XXXXXX")
progress_pid=''
stop_progress() {
  if [[ -n "$progress_pid" ]]; then
    kill "$progress_pid" 2>/dev/null || true
    wait "$progress_pid" 2>/dev/null || true
    progress_pid=''
  fi
}
trap 'stop_progress; rm -rf "$scratch"' EXIT

printf 'Installing prerequisites...\n'
brew_bin=$(command -v brew || true)
if [[ -z "$brew_bin" && -x "$default_brew" ]]; then brew_bin=$default_brew; fi
if [[ -z "$brew_bin" ]]; then
  curl --fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 2 https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$scratch/homebrew.sh"
  brew_log="$state/homebrew-install.log"
  touch "$brew_log"
  chmod 600 "$brew_log"
  printf 'Installing Homebrew. Log: %s\nKeep this terminal open and respond to any password or confirmation prompt.\n' "$brew_log"
  (
    pause_pid=''
    trap '[[ -z "$pause_pid" ]] || kill "$pause_pid" 2>/dev/null; exit 0' TERM INT
    elapsed=0
    while true; do
      sleep 30 & pause_pid=$!
      wait "$pause_pid"
      elapsed=$((elapsed + 30))
      printf '[setup] Homebrew installer still running (%ss); this does not confirm download progress. Log: %s\n' "$elapsed" "$brew_log"
    done
  ) & progress_pid=$!
  brew_status=0
  # Bound stalled HTTP transfers without changing the user's global Git settings.
  GIT_HTTP_LOW_SPEED_LIMIT=1 GIT_HTTP_LOW_SPEED_TIME=120 /bin/bash "$scratch/homebrew.sh" 2>&1 | tee "$brew_log" || brew_status=$?
  stop_progress
  [[ "$brew_status" == 0 ]] || fail "Homebrew installation failed. See $brew_log; check network/VPN access to GitHub before rerunning."
  brew_bin=$default_brew
fi
[[ -x "$brew_bin" ]] || fail 'Homebrew installation did not produce brew.'
eval "$("$brew_bin" shellenv)"
"$brew_bin" --version >/dev/null || fail 'The existing Homebrew installation needs repair.'
brew_prefix=$("$brew_bin" --prefix)
mkdir -p "$state/bin"
tool_path="$state/bin:$brew_prefix/bin:$brew_prefix/sbin"
export PATH="$tool_path:$PATH:$HOME/.local/bin:$HOME/.opencode/bin:$state/npm/bin"

use_tool() {
  local selected=$1 target="$state/bin/$(basename "$1")"
  if [[ "$selected" == "$target" ]]; then selected=$(readlink "$target"); fi
  # Keep selected commands together so another tool's directory cannot shadow them.
  if [[ "$(readlink "$target" 2>/dev/null || true)" != "$selected" ]]; then
    ln -sfn "$selected" "$target"
  fi
  tool_path="$state/bin:$(dirname "$selected"):$tool_path"
  export PATH="$state/bin:$(dirname "$selected"):$PATH"
}
command_works() {
  [[ -n "$2" && -x "$2" ]] || return 1
  case "$1" in
    node)
      "$2" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || major === 22 && minor >= 16 ? 0 : 1)' &&
        PATH="$(dirname "$2"):$PATH" "$(dirname "$2")/npm" --version >/dev/null ;;
    uv) "$2" sync --help | grep -- '--check' >/dev/null ;;
    opencode)
      local version
      version=$("$2" --version) || return 1
      version=${version#opencode }
      version=${version#v}
      [[ "$version" =~ ^2\.([0-9]+)\.([0-9]+)$ ]] &&
        (( BASH_REMATCH[1] > 0 || BASH_REMATCH[2] >= 18 )) ;;
    adb) "$2" version >/dev/null ;;
    *) "$2" --version >/dev/null ;;
  esac
}
ensure_formula_command() {
  local command_name=$1 formula=$2 variable=$3 candidate installed
  candidate=$(command -v "$command_name" || true)
  if ! command_works "$command_name" "$candidate" 2>/dev/null; then
    candidate=''
    # An unlinked formula may already work; use its bin directory directly.
    for installed in "$formula" "${4:-$formula}"; do
      if "$brew_bin" list --formula --versions "$installed" >/dev/null 2>&1; then
        candidate="$("$brew_bin" --prefix "$installed")/bin/$command_name"
        command_works "$command_name" "$candidate" 2>/dev/null && break
      fi
      candidate=''
    done
  fi
  if [[ -z "$candidate" ]]; then
    printf 'Installing or updating %s...\n' "$formula"
    # brew install upgrades an outdated formula; skip links to avoid collisions.
    env -u HOMEBREW_NO_INSTALL_UPGRADE "$brew_bin" install --formula --skip-link "$formula"
    candidate="$("$brew_bin" --prefix "$formula")/bin/$command_name"
    command_works "$command_name" "$candidate" 2>/dev/null || fail "$formula is installed but $command_name is incompatible or broken. Repair that Homebrew formula and rerun."
  else
    printf 'Skipping %s installation; using %s.\n' "$command_name" "$candidate"
  fi
  use_tool "$candidate"
  printf -v "$variable" '%s' "$state/bin/$command_name"
}

ensure_formula_command node node@24 node_bin
# Resolve version-manager shims to the actual runtime for PM2 and login startup.
node_bin=$("$node_bin" -p process.execPath)
use_tool "$node_bin"
use_tool "$(dirname "$node_bin")/npm"
ensure_formula_command git git git_bin
ensure_formula_command uv uv uv_bin
ensure_formula_command opencode opencode opencode_bin opencode-v2

adb_bin=$(command -v adb || true)
if ! command_works adb "$adb_bin" 2>/dev/null; then
  adb_bin=''
  for candidate in "${ANDROID_HOME:-$HOME/Library/Android/sdk}/platform-tools/adb" "${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}/platform-tools/adb" "$brew_prefix/Caskroom/android-platform-tools/"*/platform-tools/adb; do
    if command_works adb "$candidate" 2>/dev/null; then adb_bin=$candidate; break; fi
  done
fi
if [[ -z "$adb_bin" ]]; then
  if "$brew_bin" list --cask --versions android-platform-tools >/dev/null 2>&1; then
    "$brew_bin" reinstall --cask --no-binaries android-platform-tools
  else
    "$brew_bin" install --cask --no-binaries android-platform-tools
  fi
  for candidate in "$brew_prefix/Caskroom/android-platform-tools/"*/platform-tools/adb; do
    if command_works adb "$candidate" 2>/dev/null; then adb_bin=$candidate; break; fi
  done
fi
[[ -n "$adb_bin" ]] || fail 'Android platform tools were installed but adb is not working.'
use_tool "$adb_bin"
printf 'Using Android platform tools: %s\n' "$adb_bin"

t3_installed() {
  local application executable
  for application in /Applications/"T3 Code"*.app "$HOME/Applications/T3 Code"*.app; do
    for executable in "$application/Contents/MacOS/"*; do
      [[ -f "$executable" && -x "$executable" ]] && return 0
    done
  done
  return 1
}
if t3_installed; then
  printf 'Skipping T3 Code installation; the desktop app is already installed.\n'
elif "$brew_bin" list --cask --versions t3-code >/dev/null 2>&1; then
  "$brew_bin" reinstall --cask t3-code
else
  "$brew_bin" install --cask t3-code
fi
t3_installed || fail 'T3 Code is missing from /Applications or ~/Applications after installation.'

pm2_bin=$(command -v pm2 || true)
if ! command_works pm2 "$pm2_bin" 2>/dev/null; then
  pm2_bin="$state/npm/bin/pm2"
fi
if ! command_works pm2 "$pm2_bin" 2>/dev/null; then
  # A private npm prefix avoids overwriting PM2 installed by another manager.
  npm install --global --prefix "$state/npm" pm2@7
  pm2_bin="$state/npm/bin/pm2"
fi
command_works pm2 "$pm2_bin" 2>/dev/null || fail 'PM2 is not working after installation.'
use_tool "$pm2_bin"
pm2_bin="$state/bin/pm2"
printf -v pm2_logs_command '%q logs parallel-pods --nostream --lines 60' "$pm2_bin"
printf 'Using PM2: %s\n' "$pm2_bin"

# Keep the selected tools available to GUI apps and after login.
tool_path=$("$node_bin" -e 'console.log([...new Set(process.argv[1].split(":"))].join(":"))' "$tool_path")
PATH=$("$node_bin" -e 'console.log([...new Set(process.env.PATH.split(":"))].join(":"))')
export PATH

# Both Terminal login shells and GUI apps that probe a login shell need this PATH.
printf -v path_line 'export PATH=%q:"$PATH" # ParallelPods setup' "$tool_path"
for profile in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc"; do
  if ! grep -Fqx "$path_line" "$profile" 2>/dev/null; then printf '\n%s\n' "$path_line" >> "$profile"; fi
done

printf 'Configuring GitHub repository deploy key...\n'
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
if [[ ! -e "$ssh_key" ]]; then
  ssh-keygen -q -t ed25519 -N '' -C 'ParallelPods' -f "$ssh_key"
fi
chmod 600 "$ssh_key"
ssh-keygen -y -f "$ssh_key" > "$scratch/public_key"
cmp -s "$scratch/public_key" "$ssh_key.pub" || cp "$scratch/public_key" "$ssh_key.pub"
github_setup() {
SETUP_GITHUB_TOKEN="$GITHUB_TOKEN" "$node_bin" - "$ssh_key.pub" "$known_hosts" "$1" <<'NODE'
const fs = require('node:fs');
const os = require('node:os');
const keysPath = '/repos/paralleldistribution/ParallelPods/keys';
async function github(path, method = 'GET', body) {
  if (path !== '/meta' && !/^[\x21-\x7e]+$/.test(process.env.SETUP_GITHUB_TOKEN || '')) {
    throw new Error('GitHub token must be a single printable value without whitespace.');
  }
  const response = await fetch(`https://api.github.com${path}`, {
    method,
    headers: {
      Accept: 'application/vnd.github+json',
      ...(path === '/meta' ? {} : { Authorization: `Bearer ${process.env.SETUP_GITHUB_TOKEN}` }),
      'X-GitHub-Api-Version': '2022-11-28',
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!response.ok) throw new Error(`GitHub ${method} ${path}: HTTP ${response.status}. Check resource owner paralleldistribution, selected repository ParallelPods, Administration read/write permission, repository admin access, organization approval, and deploy-key policy.`);
  return response.json();
}
(async () => {
  if (process.argv[4] === 'hosts') {
    const meta = await github('/meta');
    if (!Array.isArray(meta.ssh_keys) || !meta.ssh_keys.length ||
        !meta.ssh_keys.every(key => /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256) [A-Za-z0-9+/=]+$/.test(key))) {
      throw new Error('GitHub did not return valid SSH host keys.');
    }
    const target = process.argv[3];
    fs.writeFileSync(`${target}.tmp`, meta.ssh_keys.map(key => `github.com ${key}\n`).join(''), { mode: 0o600 });
    fs.renameSync(`${target}.tmp`, target);
    return;
  }
  const key = fs.readFileSync(process.argv[2], 'utf8').trim();
  const identity = value => value.trim().split(/\s+/).slice(0, 2).join(' ');
  let registered = false;
  for (let page = 1; ; page++) {
    const keys = await github(`${keysPath}?per_page=100&page=${page}`);
    if (!Array.isArray(keys)) throw new Error('Unexpected GitHub deploy-key response.');
    if (keys.some(item => identity(item.key) === identity(key))) { registered = true; break; }
    if (keys.length < 100) break;
  }
  if (!registered) await github(keysPath, 'POST', { key, title: `ParallelPods - ${os.hostname()}`, read_only: true });
})().catch(error => { console.error(error.message); process.exitCode = 1; });
NODE
}
[[ -s "$known_hosts" ]] || github_setup hosts
printf -v ssh_command 'ssh -i %q -oIdentitiesOnly=yes -oUserKnownHostsFile=%q -oStrictHostKeyChecking=yes -oBatchMode=yes' "$ssh_key" "$known_hosts"
if ! git -c "core.sshCommand=$ssh_command" ls-remote "$repo_url" HEAD >/dev/null 2>&1; then
  [[ -n "$GITHUB_TOKEN" ]] || fail 'SSH needs authorization. Pass GITHUB_TOKEN as the first argument and rerun.'
  github_setup register
  git -c "core.sshCommand=$ssh_command" ls-remote "$repo_url" HEAD >/dev/null || fail 'SSH cannot access ParallelPods. Check the repository deploy key, organization deploy-key policy, and network access to GitHub SSH.'
fi
unset GITHUB_TOKEN

existing_checkout=false
if [[ -e "$repo" ]]; then
  existing_checkout=true
  [[ -d "$repo/.git" ]] || fail "$repo exists but is not a checkout. Move it aside first."
  origin=$(git -C "$repo" remote get-url origin)
  case "$origin" in
    "$repo_url"|https://github.com/paralleldistribution/ParallelPods.git|https://github.com/paralleldistribution/ParallelPods) ;;
    *) fail "$repo belongs to another repository." ;;
  esac
else
  git -c "core.sshCommand=$ssh_command" clone "$repo_url" "$repo"
fi
if [[ "$(git -C "$repo" config --get core.sshCommand || true)" != "$ssh_command" ]]; then
  git -C "$repo" config core.sshCommand "$ssh_command"
fi
# Existing HTTPS checkouts must also use the dedicated SSH identity on future pulls.
if [[ "$(git -C "$repo" remote get-url origin)" != "$repo_url" ]]; then
  git -C "$repo" remote set-url origin "$repo_url"
fi
if [[ "$existing_checkout" == true ]]; then
  if [[ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null || true)" == main && -z "$(git -C "$repo" status --porcelain)" ]]; then
    printf 'Checking for repository updates...\n'
    git -C "$repo" fetch origin main
    git -C "$repo" merge --ff-only FETCH_HEAD || fail "Cannot fast-forward $repo. Resolve its local commits before rerunning setup."
  else
    printf 'Skipping repository update: checkout has local changes or is not on main.\n'
  fi
fi
cd "$repo"
fingerprint() {
  "$node_bin" - "$@" <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const hash = require('node:crypto').createHash('sha256');
hash.update(`${process.version}:${process.arch}`);
function add(file) {
  if (fs.statSync(file).isDirectory()) {
    for (const entry of fs.readdirSync(file).sort()) {
      if (entry !== '__pycache__' && !entry.endsWith('.pyc')) add(path.join(file, entry));
    }
  } else { hash.update(file); hash.update(fs.readFileSync(file)); }
}
process.argv.slice(2).forEach(add);
console.log(hash.digest('hex'));
NODE
}
printf 'Checking repository dependencies and build...\n'
runtime_changed=false
node_changed=false
node_hash=$(fingerprint package.json package-lock.json)
if [[ ! -d node_modules || ! -x node_modules/.bin/nest || "$(cat "$state/node.hash" 2>/dev/null || true)" != "$node_hash" ]] || ! npm ls --depth=0 >/dev/null 2>&1; then
  rm -f "$state/node.hash" "$state/build.hash"
  npm ci --include=dev
  printf '%s\n' "$node_hash" > "$state/node.hash"
  runtime_changed=true
  node_changed=true
fi
if ! uv sync --frozen --project apps/devices --python 3.11 --check >/dev/null 2>&1; then
  npm run setup:devices
  runtime_changed=true
fi
build_hash=$(fingerprint package.json package-lock.json nest-cli.json tsconfig.json tsconfig.build.json apps/mcp/src)
if [[ "$node_changed" == true || ! -f dist/main.js || "$(cat "$state/build.hash" 2>/dev/null || true)" != "$build_hash" ]]; then
  rm -f "$state/build.hash"
  npm run build
  printf '%s\n' "$build_hash" > "$state/build.hash"
  runtime_changed=true
fi
service_hash=$(fingerprint dist apps.config.json apps/devices/src apps/devices/uv.lock)

printf 'Configuring OpenCode...\n'
SETUP_OPENCODE_KEY="$OPENCODE_API_KEY" "$node_bin" - <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const { parse, modify, applyEdits } = require('jsonc-parser');
const directory = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, '.config'), 'opencode');
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
let files = ['opencode.json', 'opencode.jsonc'].map(name => path.join(directory, name)).filter(file => fs.existsSync(file));
if (!files.length) files = [path.join(directory, 'opencode.json')];
try {
  // Validate every file before writing any; update both if both are loaded.
  const updates = files.map(file => {
    let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '{}\n';
    const errors = [];
    const config = parse(text, errors, { allowTrailingComma: true });
    if (errors.length || !config || typeof config !== 'object' || Array.isArray(config)) {
      throw new Error('Invalid global OpenCode JSON/JSONC. Repair it before rerunning setup.');
    }
    const edits = [
      [['$schema'], 'https://opencode.ai/config.json'],
      [['mcp', 'servers', 'parallelpods'], {
        type: 'remote', url: 'http://127.0.0.1:3000/mcp', oauth: false,
        disabled: false, protocol: 'legacy', timeout: { execution: 660000 },
      }],
    ];
    if (process.env.SETUP_OPENCODE_KEY) edits.push([['providers', 'opencode', 'settings', 'apiKey'], process.env.SETUP_OPENCODE_KEY]);
    for (const [keys, value] of edits) {
      text = applyEdits(text, modify(text, keys, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
    }
    return { file, text, config: parse(text) };
  });
  if (!updates.some(({ config }) => config.providers?.opencode?.settings?.apiKey)) {
    throw new Error('OPENCODE_API_KEY is required when no Zen key is configured.');
  }
  for (const { file, text } of updates) {
    if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === text) {
      fs.chmodSync(file, 0o600);
      continue;
    }
    const temporary = `${file}.parallelpods-${process.pid}`;
    try {
      fs.writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' });
      fs.renameSync(temporary, file);
    } finally {
      fs.rmSync(temporary, { force: true });
    }
  }
} catch {
  console.error('Could not update OpenCode configuration. Check its JSON/JSONC structure, file permissions, and OPENCODE_API_KEY; existing settings were not replaced with defaults.');
  process.exitCode = 1;
}
NODE
unset OPENCODE_API_KEY

# This read-only probe checks the worker as well as the HTTP transport.
gateway_check() {
"$node_bin" --input-type=module - "$1" "$pm2_logs_command" <<'NODE'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const attempts = Number(process.argv[2]);
const logs = `Logs: ${process.argv[3]}`;
let lastFailure = 'Gateway is not accepting MCP connections';
const timer = setTimeout(() => { console.error(`Gateway MCP verification timed out: ${lastFailure}.\n${logs}`); process.exit(1); }, attempts === 1 ? 15000 : 60000);
try {
  let ready = false;
  for (let attempt = 0; attempt < attempts; attempt++) {
    const client = new Client({ name: 'parallelpods-setup', version: '1' }, { versionNegotiation: { mode: 'legacy' } });
    const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp'));
    try {
      lastFailure = 'Gateway is not accepting MCP connections';
      await client.connect(transport, { timeout: 3000 });
      lastFailure = 'Gateway could not list its tools';
      const { tools } = await client.listTools(undefined, { timeout: 3000 });
      if (!tools.some(tool => tool.name === 'devices__sessions_list')) {
        lastFailure = 'Required device tool devices__sessions_list is missing';
        throw new Error(lastFailure);
      }
      if (!tools.some(tool => tool.name === 'sume__generate')) {
        lastFailure = 'Required Sume tool sume__generate is missing; update the ParallelPods checkout to a version that includes Sume and rerun setup';
        throw new Error(lastFailure);
      }
      lastFailure = 'Device worker did not answer sessions_list successfully';
      const result = await client.callTool({ name: 'devices__sessions_list' }, { timeout: 3000 });
      if (result.isError) throw new Error('Device worker unavailable');
      lastFailure = 'Gateway could not close the health-check session';
      await transport.terminateSession();
      ready = true;
      break;
    } catch {
      if (attempt + 1 < attempts) await new Promise(resolve => setTimeout(resolve, 1000));
    } finally {
      await client.close();
    }
  }
  if (!ready) throw new Error(`Gateway MCP verification failed: ${lastFailure}.\n${logs}`);
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
} finally { clearTimeout(timer); }
NODE
}

printf 'Checking the PM2 gateway...\n'
# Start a missing daemon before requesting machine-readable output.
"$pm2_bin" ping >/dev/null
"$pm2_bin" jlist --silent > "$scratch/processes.json"
process_online=$("$node_bin" - "$scratch/processes.json" "$repo" <<'NODE'
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const processes = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const owned = processes.filter(app => app.name === 'parallel-pods');
if (owned.length > 1 || owned.some(app => app.pm2_env.pm_exec_path !== path.join(process.argv[3], 'dist/main.js'))) {
  console.error('A different PM2 application already uses the name parallel-pods.');
  process.exit(1);
}
const online = owned.some(app => app.pm2_env.status === 'online');
console.log(online);
if (!online) {
  const server = net.createServer();
  server.once('error', () => { console.error('Port 3000 is unavailable. Stop the process using it and rerun setup.'); process.exitCode = 1; });
  server.listen(3000, '127.0.0.1', () => server.close());
}
NODE
)
# PM2 treats paths containing node@ (such as Homebrew's node@24) as NVM selectors.
config_changed=$("$node_bin" - "$state/ecosystem.config.json" "$repo" "$state/bin/node" <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const root = process.argv[3];
const text = JSON.stringify({ apps: [{
  name: 'parallel-pods', script: path.join(root, 'dist/main.js'), cwd: root,
  interpreter: process.argv[4], exec_mode: 'fork', instances: 1,
  autorestart: true, kill_timeout: 35000,
  env: { NODE_ENV: 'production', PORT: '3000', MCP_APPS_CONFIG: path.join(root, 'apps.config.json'), SUME_API_KEY_FILE: path.join(path.dirname(process.argv[2]), 'sume_api_key'), PATH: process.env.PATH },
}] }, null, 2) + '\n';
const file = process.argv[2];
const changed = !fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== text;
if (changed) fs.writeFileSync(file, text, { mode: 0o600 });
console.log(changed);
NODE
)
restart=false
if [[ "$runtime_changed" == true || "$config_changed" == true || "$process_online" != true || "$(cat "$state/service.hash" 2>/dev/null || true)" != "$service_hash" ]]; then
  restart=true
elif ! gateway_check 1 >/dev/null 2>&1; then
  restart=true
fi
if [[ "$restart" == true ]]; then
  "$pm2_bin" startOrRestart "$state/ecosystem.config.json" --update-env
else
  printf 'Gateway is healthy and unchanged; leaving it running.\n'
fi
gateway_check 30
printf '%s\n' "$service_hash" > "$state/service.hash"
if [[ "$restart" == true ]] || ! "$node_bin" - "${PM2_HOME:-$HOME/.pm2}/dump.pm2" "$repo" <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
try {
  const saved = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
  process.exit(saved.some(app => app.name === 'parallel-pods' && app.pm_exec_path === path.join(process.argv[3], 'dist/main.js') && app.PORT === '3000') ? 0 : 1);
} catch { process.exit(1); }
NODE
then
  "$pm2_bin" save
fi

printf 'Enabling startup at user login...\n'
agent="$HOME/Library/LaunchAgents/com.parallelpods.pm2.plist"
mkdir -p "$(dirname "$agent")"
agent_changed=$("$repo/apps/devices/.venv/bin/python" - "$agent" "$pm2_bin" "$state" <<'PY'
import os, plistlib, sys
target, pm2, state = sys.argv[1:]
data = {
    'Label': 'com.parallelpods.pm2',
    'ProgramArguments': [pm2, 'resurrect'],
    'RunAtLoad': True,
    # PM2 owns its daemon after the short-lived resurrect command exits.
    'AbandonProcessGroup': True,
    'EnvironmentVariables': {
        'PATH': os.environ['PATH'], 'HOME': os.environ['HOME'],
        'PM2_HOME': os.environ.get('PM2_HOME', os.path.join(os.environ['HOME'], '.pm2')),
    },
    'StandardOutPath': os.path.join(state, 'startup.log'),
    'StandardErrorPath': os.path.join(state, 'startup-error.log'),
}
content = plistlib.dumps(data)
changed = not os.path.exists(target) or open(target, 'rb').read() != content
if changed:
    temporary = target + '.tmp'
    with open(temporary, 'wb') as output:
        output.write(content)
    os.chmod(temporary, 0o600)
    os.replace(temporary, target)
print('true' if changed else 'false')
PY
)
if [[ "$agent_changed" == true ]] || ! launchctl print "gui/$(id -u)/com.parallelpods.pm2" >/dev/null 2>&1; then
  if launchctl print "gui/$(id -u)/com.parallelpods.pm2" >/dev/null 2>&1; then
    launchctl bootout "gui/$(id -u)/com.parallelpods.pm2"
  fi
  launchctl enable "gui/$(id -u)/com.parallelpods.pm2"
  launchctl bootstrap "gui/$(id -u)" "$agent"
fi
launchctl print "gui/$(id -u)/com.parallelpods.pm2" >/dev/null

printf 'Checking OpenCode MCP connection...\n'
"$node_bin" - "$opencode_bin" <<'NODE'
const { spawnSync } = require('node:child_process');
// OpenCode loads MCP configuration asynchronously after its service starts.
const deadline = Date.now() + 60000;
(async () => {
  let reported = 'not listed';
  let previous;
  while (Date.now() < deadline) {
    const result = spawnSync(process.argv[2], ['mcp', 'list'], { encoding: 'utf8', timeout: Math.max(1, deadline - Date.now()) });
    const output = (result.stdout || '').replace(/\x1b\[[0-9;]*m/g, '');
    if (result.status === 0 && /^✓\s+parallelpods\s+connected\s*$/m.test(output)) return;
    if (result.status !== 0) { reported = result.error?.code === 'ETIMEDOUT' ? 'CLI timed out' : 'CLI failed'; break; }
    const status = output.match(/^[✓✗○⚠]\s+parallelpods\s+(connected|pending|disabled|failed|needs authentication)\b/m)?.[1];
    reported = status || 'not listed';
    if (status && status !== 'pending') break;
    if (reported !== previous) console.log(`Waiting for OpenCode MCP initialization (parallelpods: ${reported})...`);
    previous = reported;
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
  console.error(`OpenCode could not connect to parallelpods (${reported}). Run opencode mcp list to diagnose; if OpenCode was already running, restart it to reload configuration.`);
  process.exitCode = 1;
})().catch(() => { console.error('OpenCode MCP verification failed.'); process.exitCode = 1; });
NODE
"$pm2_bin" jlist --silent > "$scratch/processes.json"
"$node_bin" - "$scratch/processes.json" "$pm2_logs_command" <<'NODE'
const fs = require('node:fs');
const processes = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
if (!processes.some(app => app.name === 'parallel-pods' && app.pm2_env.status === 'online')) {
  console.error(`parallel-pods is not online.\nLogs: ${process.argv[3]}`);
  process.exitCode = 1;
}
NODE
printf '\nReady: http://127.0.0.1:3000/mcp\nRepository: %s\n' "$repo"
printf 'Health checks passed: dependencies, PM2, device worker, login startup, and OpenCode MCP.\n'
printf 'Logs: %s\nRestart: %q restart parallel-pods\n' "$pm2_logs_command" "$pm2_bin"
printf 'To use pm2 and opencode in this Terminal: export PATH=%q:"$PATH"\n' "$state/bin"
printf 'In T3 Code, enable OpenCode in Settings > Providers. Select a Zen model when starting a thread.\n'
printf 'Desktop permissions, Android USB authorization, and iPhone signing are manual; see README.md.\n'
