#!/bin/sh
# Public bootstrap. Immutable copies retain the URL placeholder and require
# --base-url; the stable root copy is rendered by the publisher. HTTPS and
# SHA-256 detect corruption; these are not signatures.
set -eu
umask 077
LC_ALL=C
export LC_ALL
unset TAR_OPTIONS NODE_OPTIONS NODE_PATH

fail() { printf 'reflow install: %s\n' "$*" >&2; exit 1; }
usage() {
  cat <<'USAGE'
Usage: sh install.sh [--channel stable|candidate] [--version VERSION]
                     [--prefix DIRECTORY] [--base-url HTTPS_URL] [--no-agent-setup]

Install Reflow for this macOS or Linux host. Node is bundled.
The default channel is stable; --version selects a retained immutable release.
The default prefix is ~/.local/share/reflow-sidecar. Add its bin directory to PATH.
Offers to connect detected Codex and Claude Code installations through your terminal.
Existing agent configurations and Reflow profiles are preserved on repeat installs.
Without terminal output, in CI/NONINTERACTIVE mode, or with --no-agent-setup,
agent configuration is left unchanged.
USAGE
}
base_url=https://dubkqvsn5jvb2.cloudfront.net
channel=stable
version=
prefix=
agent_setup=prompt
while [ "$#" -gt 0 ]; do
  case "$1" in
    --help|-h) usage; exit 0 ;;
    --no-agent-setup) agent_setup=skip; shift ;;
    --base-url|--channel|--version|--prefix)
      [ "$#" -ge 2 ] && [ -n "$2" ] || fail "$1 requires a value"
      case "$1" in
        --base-url) base_url=$2 ;;
        --channel) channel=$2 ;;
        --version) version=$2 ;;
        --prefix) prefix=$2 ;;
      esac
      shift 2 ;;
    *) fail "unknown option: $1" ;;
  esac
done
case "$channel" in stable|candidate) ;; *) fail 'channel must be stable or candidate' ;; esac
if [ -n "$version" ]; then
  printf '%s\n' "$version" | grep -Eq '^0\.1\.0-[a-f0-9]{20}$' || fail 'invalid release version'
fi
base_url=${base_url%/}
case "$base_url" in
  *[!a-zA-Z0-9:/._~\[\]-]*) fail 'base URL contains unsupported characters' ;;
esac
protocol='=https'
redirects=3
case "$base_url" in
  https://*)
    authority=${base_url#https://}; authority=${authority%%/*}
    [ -n "$authority" ] || fail 'base URL requires an HTTPS hostname' ;;
  http://*)
    [ "${REFLOW_RELEASE_TEST_HTTP:-}" = 1 ] || fail 'base URL must use HTTPS'
    authority=${base_url#http://}; authority=${authority%%/*}
    printf '%s\n' "$authority" | grep -Eq '^(127\.0\.0\.1|\[::1\])(:[0-9]+)?$' ||
      fail 'test HTTP is restricted to loopback addresses'
    protocol='=http'
    redirects=0 ;;
  *) fail 'base URL must use HTTPS; download the published installer or pass --base-url' ;;
esac
if [ -z "$prefix" ]; then
  [ -n "${HOME:-}" ] || fail 'HOME is unset; pass --prefix'
  prefix=$HOME/.local/share/reflow-sidecar
fi
case "$prefix" in /*) ;; *) prefix=$PWD/$prefix ;; esac
for required in curl tar awk grep mktemp wc; do
  command -v "$required" >/dev/null 2>&1 || fail "required command is missing: $required"
done
if command -v sha256sum >/dev/null 2>&1; then
  checksum() { sha256sum "$1"; }
elif command -v shasum >/dev/null 2>&1; then
  checksum() { shasum -a 256 "$1"; }
else
  fail 'sha256sum or shasum is required'
fi
case "$(uname -s)" in
  Darwin) os=darwin ;;
  Linux) os=linux ;;
  *) fail 'supported operating systems are macOS and Linux' ;;
esac
case "$(uname -m)" in
  arm64|aarch64) arch=arm64 ;;
  x86_64|amd64) arch=x64 ;;
  *) fail 'supported architectures are arm64 and x64' ;;
esac
platform=$os-$arch
work=$(mktemp -d "${TMPDIR:-/tmp}/reflow-install.XXXXXXXX") || fail 'cannot create temporary directory'
trap 'rm -rf "$work"' 0
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

fetch() {
  curl -q --fail --silent --show-error --location --max-redirs "$redirects" \
    --proto "$protocol" --proto-redir "$protocol" --tlsv1.2 \
    --connect-timeout 15 --max-time 600 --retry 2 --max-filesize "$3" \
    --output "$2" "$base_url/$1" || fail "download failed: $1"
  [ "$(wc -c < "$2")" -le "$3" ] || fail "download exceeds size limit: $1"
}
verify_hash() {
  actual=$(checksum "$1") || fail 'could not calculate SHA-256'
  [ "${actual%% *}" = "$2" ] || fail "checksum mismatch: $3; installation unchanged"
}
hex64() { [ "${#1}" = 64 ] && case "$1" in *[!a-f0-9]*) return 1 ;; esac; }
hex40() { [ "${#1}" = 40 ] && case "$1" in *[!a-f0-9]*) return 1 ;; esac; }

# A bounded JSON reader avoids requiring Node, jq or Python before downloading
# the runtime. It never evaluates input and refuses duplicate or escaped keys.
cat > "$work/json.awk" <<'AWK'
function bad() { exit 1 }
function ws() { while (substr(s,p,1) ~ /^[ \t\r\n]$/) p++ }
function string( start,c,esc,i) {
  if (substr(s,p++,1)!="\"") bad()
  start=p
  while (p<=length(s)) {
    c=substr(s,p++,1)
    if(c=="\"") return substr(s,start,p-start-1)
    if(c ~ /[[:cntrl:]]/) bad()
    if(c=="\\") {
      esc=substr(s,p++,1)
      if(esc=="u") { for(i=0;i<4;i++) if(substr(s,p++,1)!~/^[a-fA-F0-9]$/) bad() }
      else if(esc!~/^["\\\/bfnrt]$/) bad()
    }
  }
  bad()
}
function value(path,depth, c,key,n,t,v,child) {
  if(depth>32) bad()
  ws(); c=substr(s,p,1)
  if(c=="{") {
    p++; ws()
    if(substr(s,p,1)=="}") { p++; return }
    while(1) {
      ws(); key=string()
      if(index(key,"\\")) bad()
      child=path SUBSEP key
      if(seen[child]++) bad()
      ws(); if(substr(s,p++,1)!=":") bad()
      value(child,depth+1); ws(); c=substr(s,p++,1)
      if(c=="}") return
      if(c!=",") bad()
    }
  }
  if(c=="[") {
    p++; ws(); n=0
    if(substr(s,p,1)=="]") { p++; return }
    while(1) {
      value(path SUBSEP n++,depth+1); ws(); c=substr(s,p++,1)
      if(c=="]") return
      if(c!=",") bad()
    }
  }
  if(c=="\"") { v=string(); t="string" }
  else if(match(substr(s,p),/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/)) {
    v=substr(s,p,RLENGTH); p+=RLENGTH; t="number"
  } else if(match(substr(s,p),/^(true|false|null)/)) {
    v=substr(s,p,RLENGTH); p+=RLENGTH; t="literal"
  } else bad()
  if(path==wanted) { result=v; found++; actualtype=t }
}
{ s=s $0 "\n" }
END {
  p=1; count=split(want,parts,"/"); wanted=""
  for(i=2;i<=count;i++) wanted=wanted SUBSEP parts[i]
  value("",0); ws()
  if(p<=length(s) || found!=1 || actualtype!=type) exit 1
  print result
}
AWK
json() {
  awk -v want="$2" -v type="$3" -f "$work/json.awk" "$1" || fail "invalid release metadata: $2"
}
channel_file=
if [ -z "$version" ]; then
  channel_file=$work/channel.json
  fetch "channels/$channel.json" "$channel_file" 65536
  [ "$(json "$channel_file" /format number)" = 1 ] || fail 'unsupported channel format'
  [ "$(json "$channel_file" /channel string)" = "$channel" ] || fail 'channel identity mismatch'
  version=$(json "$channel_file" /version string)
  printf '%s\n' "$version" | grep -Eq '^0\.1\.0-[a-f0-9]{20}$' || fail 'invalid channel version'
  manifest_hash=$(json "$channel_file" /manifestSha256 string)
else
  fetch "releases/$version/manifest.sha256" "$work/manifest.sha256" 256
  manifest_hash=$(awk 'NR==1 && NF==2 && $2=="manifest.json" {print $1} NR>1 {exit 1}' "$work/manifest.sha256") ||
    fail 'invalid manifest checksum file'
fi
hex64 "$manifest_hash" || fail 'invalid manifest checksum'
fetch "releases/$version/manifest.json" "$work/manifest.json" 1048576
verify_hash "$work/manifest.json" "$manifest_hash" manifest.json
[ "$(json "$work/manifest.json" /format number)" = 1 ] || fail 'unsupported manifest format'
[ "$(json "$work/manifest.json" /version string)" = "$version" ] || fail 'manifest version mismatch'
input_digest=$(json "$work/manifest.json" /inputDigest string)
source_commit=$(json "$work/manifest.json" /sourceCommit string)
hex64 "$input_digest" || fail 'invalid release input digest'
hex40 "$source_commit" || fail 'invalid release source commit'
[ "0.1.0-$(printf '%.20s' "$input_digest")" = "$version" ] || fail 'release input/version mismatch'
if [ -n "$channel_file" ]; then
  [ "$(json "$channel_file" /inputDigest string)" = "$input_digest" ] || fail 'channel input digest mismatch'
  [ "$(json "$channel_file" /sourceCommit string)" = "$source_commit" ] || fail 'channel source commit mismatch'
fi
archive_name=$(json "$work/manifest.json" "/assets/$platform/file" string)
[ "$archive_name" = "reflow-$platform.tar.gz" ] || fail 'release asset name mismatch'
archive_hash=$(json "$work/manifest.json" "/assets/$platform/sha256" string)
release_id=$(json "$work/manifest.json" "/assets/$platform/releaseId" string)
archive_bytes=$(json "$work/manifest.json" "/assets/$platform/bytes" number)
hex64 "$archive_hash" && hex64 "$release_id" || fail 'invalid archive identity'
printf '%s\n' "$archive_bytes" | grep -Eq '^[1-9][0-9]{0,9}$' || fail 'invalid archive size'
[ "$archive_bytes" -le 2147483648 ] || fail 'archive exceeds size limit'
printf 'Downloading Reflow %s for %s…\n' "$version" "$platform"
fetch "releases/$version/$archive_name" "$work/archive.tar.gz" "$archive_bytes"
[ "$(wc -c < "$work/archive.tar.gz")" -eq "$archive_bytes" ] || fail 'archive size mismatch; installation unchanged'
verify_hash "$work/archive.tar.gz" "$archive_hash" "$archive_name"

# Restrict archive names to the portable package alphabet so GNU/BSD tar listings
# are unambiguous. Reject special files, duplicate entries and every write through
# a symlink; resolve link chains before extraction, including ../ after a link.
tar -tzf "$work/archive.tar.gz" > "$work/names" || fail 'invalid archive'
tar -tvzf "$work/archive.tar.gz" > "$work/entries" || fail 'invalid archive'
awk '
function bad() { invalid=1; exit 1 }
function parent(path) { sub(/\/[^\/]*$/, "", path); return path }
function target(name, path,rest,part,pos,hops) {
  path=parent(name); rest=links[name]; hops=0
  while(length(rest)) {
    pos=index(rest,"/")
    if(pos) { part=substr(rest,1,pos-1); rest=substr(rest,pos+1) }
    else { part=rest; rest="" }
    if(part=="" || part==".") continue
    if(part=="..") { if(path=="package") bad(); path=parent(path); continue }
    path=path "/" part
    if(!(path in kinds)) bad()
    if(kinds[path]=="l") {
      if(++hops>40) bad()
      rest=links[path] (length(rest)?"/" rest:""); path=parent(path)
    } else if(length(rest) && kinds[path]!="d") bad()
  }
  return path
}
NR==FNR {
  if($0!~/^package\// || $0~/[^a-zA-Z0-9_@+.,:=~%\/-]/) bad()
  name=$0; sub(/\/$/,"",name)
  if(name~/\/(\.|\.\.)($|\/)/ || name~/\/\// || seen[name]++) bad()
  names[FNR]=name; count++; next
}
{
  kind=substr($1,1,1)
  if(kind=="l") {
    if($(NF-1)!="->") bad()
    name=$(NF-2); link=$NF
    if(link~/^\// || link~/[^a-zA-Z0-9_@+.,:=~%\/-]/) bad()
    links[name]=link
  } else {
    if(kind!="d" && kind!="-") bad()
    name=$NF; sub(/\/$/,"",name)
  }
  if(name!=names[FNR]) bad()
  kinds[name]=kind; entries++
}
END {
  if(invalid || !count || count!=entries || count>100000 || kinds["package"]!="d") exit 1
  for(name in kinds) {
    path=name
    while(path!="package") { path=parent(path); if(kinds[path]!="d") exit 1 }
  }
  for(name in links) target(name)
  if(kinds["package/runtime/bin/node"]!="-" || kinds["package/scripts/sidecar-install.mjs"]!="-" ||
     kinds["package/scripts/sidecar-release.mjs"]!="-" || kinds["package/release.json"]!="-") exit 1
}
' "$work/names" "$work/entries" || fail 'unsafe or unsupported archive paths; installation unchanged'
mkdir "$work/unpacked"
tar -xzf "$work/archive.tar.gz" -C "$work/unpacked" --no-same-owner --no-same-permissions || fail 'archive extraction failed'

# The archive has been authenticated against HTTPS metadata. Its bundled Node
# validates the complete package and all cross-file identity before selection.
"$work/unpacked/package/runtime/bin/node" --input-type=module - \
  "$work/manifest.json" "$channel_file" "$work/unpacked/package" "$version" \
  "$input_digest" "$source_commit" "$platform" "$archive_hash" "$archive_bytes" \
  "$release_id" "$manifest_hash" "$channel" "$prefix" "$base_url" <<'NODE'
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
const [manifestFile, channelFile, directory, version, inputDigest, sourceCommit,
  platform, sha256, bytes, releaseId, manifestSha256, channel, prefix, origin] = process.argv.slice(2);
const require = (condition, message) => { if (!condition) throw new Error(message); };
try {
  const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
  const asset = manifest.assets?.[platform];
  require(manifest.format === 1 && manifest.version === version && manifest.inputDigest === inputDigest &&
    manifest.sourceCommit === sourceCommit && asset?.file === `reflow-${platform}.tar.gz` &&
    asset.sha256 === sha256 && asset.bytes === Number(bytes) && asset.releaseId === releaseId,
    'Public manifest identity mismatch; installation unchanged');
  if (channelFile) {
    const pointer = JSON.parse(readFileSync(channelFile, 'utf8'));
    require(pointer.format === 1 && pointer.channel === channel && pointer.version === version &&
      pointer.inputDigest === inputDigest && pointer.sourceCommit === sourceCommit &&
      pointer.manifestSha256 === manifestSha256 && Number.isSafeInteger(pointer.generation) && pointer.generation > 0,
      'Channel identity mismatch; installation unchanged');
  }
  require(platform === `${process.platform}-${process.arch}` && /^v24\./.test(process.version),
    'Bundled runtime does not match this host');
  const { verifyRelease } = await import(pathToFileURL(`${directory}/scripts/sidecar-release.mjs`));
  const release = verifyRelease(directory);
  require(release.id === releaseId && release.publicVersion === version && release.inputDigest === inputDigest &&
    release.sourceCommit === sourceCommit && release.nodeVersion === process.version &&
    `${release.platform}-${release.arch}` === platform,
    'Package release identity mismatch; installation unchanged');
  const result = spawnSync(process.execPath,
    [`${directory}/scripts/sidecar-install.mjs`, 'install', directory, '--prefix', prefix],
    { encoding: 'utf8', maxBuffer: 1024 * 1024 });
  require(!result.error && result.status === 0, result.stderr || result.error?.message || 'Installation failed');
  const installed = JSON.parse(result.stdout);
  require(installed.currentRelease === releaseId && installed.integrityChecked === true,
    'Installer did not confirm the requested release; inspect installation status');
  // Preserve exact acquisition metadata outside the immutable package inventory.
  // Reuse the private installation DB and its existing cross-process write lock.
  const receiptDb = new DatabaseSync(`${installed.prefix}/install.sqlite`);
  try {
    receiptDb.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE');
    receiptDb.exec('CREATE TABLE IF NOT EXISTS public_release_sources (release_id TEXT PRIMARY KEY, origin TEXT NOT NULL, version TEXT NOT NULL, manifest_sha256 TEXT NOT NULL)');
    receiptDb.prepare('INSERT OR REPLACE INTO public_release_sources VALUES (?, ?, ?, ?)').run(releaseId, origin, version, manifestSha256);
    receiptDb.exec('COMMIT');
  } finally { receiptDb.close(); }
  console.log(`Installed Reflow ${version}.\nBinaries: ${installed.prefix}/bin`);
  if (installed.cleanup?.deferred > 0) {
    console.log(installed.cleanup.reason === 'process_inspection_unavailable'
      ? 'Old-release cleanup deferred: the OS prevents complete process inspection. Releases are preserved and cleanup retries automatically on the next install.'
      : 'Some older releases are still protected or in use; cleanup retries automatically on the next install.');
  }
} catch (error) {
  console.error(`reflow install: ${error.message}`);
  process.exitCode = 1;
}
NODE

# Read prompts from the controlling terminal, never from the piped script.
# Keep this bootstrap self-contained: retained public releases need no new helper.
"$work/unpacked/package/runtime/bin/node" --input-type=module - "$prefix" "$agent_setup" <<'AGENTS'
import { accessSync, constants, openSync, closeSync, readSync, writeSync, readFileSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { isatty } from 'node:tty';

const [prefix, mode] = process.argv.slice(2);
const binary = join(prefix, 'bin/reflow');
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
const run = (command, args) => spawnSync(command, args, {
  cwd: homedir(), encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
  stdio: ['ignore', 'pipe', 'pipe'],
});
function executable(name) {
  for (const directory of (process.env.PATH ?? '').split(':')) {
    const path = resolve(directory || '.', name);
    try {
      accessSync(path, constants.X_OK);
      if (statSync(path).isFile()) return path;
    } catch {}
  }
}
function claudeServers() {
  const path = process.env.CLAUDE_CONFIG_DIR
    ? join(process.env.CLAUDE_CONFIG_DIR, '.claude.json') : join(homedir(), '.claude.json');
  try {
    const config = JSON.parse(readFileSync(path, 'utf8'));
    // Preserve both user-level and existing per-project Reflow registrations.
    return [config.mcpServers, ...Object.values(config.projects ?? {}).map(p => p.mcpServers)]
      .flatMap(servers => Object.keys(servers ?? {}));
  } catch (error) {
    if (error.code === 'ENOENT') return [];
    throw new Error('Could not read Claude Code settings');
  }
}
let terminal;
try {
  if (mode !== 'skip' && isatty(1) && !process.env.CI && !process.env.NONINTERACTIVE) {
    try {
      terminal = openSync('/dev/tty', 'r+');
      if (!isatty(terminal)) { closeSync(terminal); terminal = undefined; }
    } catch {}
  }
  const agents = [['codex', 'Codex'], ['claude', 'Claude Code']]
    .map(([name, label]) => ({ name, label, command: executable(name) }))
    .filter(agent => agent.command);
  if (mode === 'skip' || terminal === undefined) {
    console.log('Agent setup skipped. Rerun this installer in an interactive terminal to connect your agents.');
  } else if (!agents.length) {
    console.log('No Codex or Claude Code command found on PATH. Install an agent, then rerun this installer to connect it.');
  } else {
    for (const {name, label, command} of agents) {
      try {
        let names;
        if (name === 'codex') {
          const listed = run(command, ['mcp', 'list', '--json']);
          if (listed.error || listed.status !== 0) throw new Error('Could not read Codex settings');
          const servers = JSON.parse(listed.stdout);
          if (!Array.isArray(servers) || servers.some(s => typeof s.name !== 'string'))
            throw new Error('Unrecognized Codex settings');
          names = servers.map(s => s.name);
        } else names = claudeServers();
        if (names.some(n => n === 'reflow' || n === 'reflow-review')) {
          console.log(`${label}: existing Reflow configuration kept unchanged. Check it in your agent if you need to switch connections.`);
          continue;
        }
        writeSync(terminal, `Connect Reflow to ${label} for your user account? [y/N] `);
        let answer = '';
        const byte = Buffer.alloc(1);
        while (readSync(terminal, byte, 0, 1, null)) {
          if (byte[0] === 10 || byte[0] === 13) break;
          if (answer.length < 256) answer += byte.toString();
        }
        if (!/^y(es)?$/i.test(answer.trim())) { console.log(`${label}: skipped.`); continue; }
        const args = name === 'codex'
          ? ['mcp', 'add', 'reflow', '--', binary, 'mcp']
          : ['mcp', 'add', '--scope', 'user', '--transport', 'stdio', 'reflow', '--', binary, 'mcp'];
        const added = run(command, args);
        if (added.error || added.status !== 0) throw new Error('Agent registration failed');
        console.log(`${label}: Reflow connected. Reopen the agent in your application's folder.`);
      } catch {
        // Agent configuration is optional. Never expose configuration or credentials
        // from CLI diagnostics, and never replace a configuration we cannot inspect.
        console.log(`${label}: setup could not finish. Check its MCP settings or update ${label}, then rerun the installer.`);
      }
    }
  }
} finally {
  if (terminal !== undefined) closeSync(terminal);
}
console.log(`\nTo use Reflow in this terminal:\n  export PATH=${quote(join(prefix, 'bin'))}:"$PATH"\n  reflow login\n\nThen reopen your agent in your application's folder. Existing sign-in profiles are preserved.\nCustom deployment? See https://reflow.io/docs/agents-start-here/`);
AGENTS
