diff --git a/README.md b/README.md index c8a1009..938e6e1 100755 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Three things set it apart: 2. **Keyless.** The AI coding agent already on your machine is the backbone. No API keys, no second bill, no gatekeeper. 3. **Honest about scope.** The [status table](#what-ships-today) marks exactly what's stable, experimental, or roadmap — because red-teaming shouldn't be a priesthood, and it damn sure shouldn't run on vibes. -**Jump to** → [Quick start](#quick-start) · [What it hunts](#what-it-hunts) · [What ships today](#what-ships-today) · [Benchmarks](#benchmarks) · [Architecture](#architecture) · [Docs](#documentation) +**Jump to** → [Quick start](#quick-start) · [Updating](#updating-from-upstream) · [What it hunts](#what-it-hunts) · [What ships today](#what-ships-today) · [Benchmarks](#benchmarks) · [Architecture](#architecture) · [Docs](#documentation) ## ⚠️ Authorized use only @@ -101,6 +101,60 @@ npm run verify-claims # re-derives every headline from committed JSO Library/SDK usage, the full HTTP API, and MCP setup live in [docs/](docs/). +## Updating from upstream + +If you installed from a release tarball or copied the tree instead of tracking `git pull`, use the built-in updater to sync with [github.com/elder-plinius/T3MP3ST](https://github.com/elder-plinius/T3MP3ST) without losing local secrets or bench output. It shows a numbered plan, asks **y/N** before changing anything, then runs `npm install`. + +```bash +npm run update # interactive sync from upstream main +npm run update:dry # preview only — no git or npm changes +npm run update:hard # hard reset to upstream/main (still restores protected paths) +``` + +Works on Windows (PowerShell), macOS, Linux, and WSL. Requires **git** and **npm** on your PATH. + +### Safety modes + +The updater is destructive **only when explicitly requested**: + +| Command | What it does | Local changes | +|---|---|---| +| `npm run update` | Default interactive sync | `git merge upstream/main`; protected paths backed up and restored | +| `npm run update:dry` | Read-only preview | No git init, fetch, merge, reset, or `npm install`; safe on tarball installs | +| `npm run update:hard` | Opt-in hard reset | `git reset --hard upstream/main`; protected paths still restored | + +All non-dry-run modes ask **y/N** before changing anything. Pass `--force` only in trusted automation. On the first-time path (no commits yet), the updater replaces the working tree with the upstream snapshot, but protected paths are backed up first and restored afterward. + +### Protected paths (inside the repo) + +Before replacing files, the updater backs up anything on disk that matches [`scripts/update-protected.txt`](scripts/update-protected.txt), then restores it after sync. **Only paths that exist locally are affected** — if you never created them, nothing happens. + +| Path | Why it's protected | +|---|---| +| `.env`, `.env.*` | API keys and local env overrides. `!.env.example` is an exception — the template is **not** protected so upstream can update it. | +| `.keys.local` | One-off key paste file loaded by bench scripts (e.g. `VENICE_API_KEY`) without touching `.env`. | +| `.keys.bounty.json` | HackerOne / Bugcrowd / similar platform credentials. | +| `bench/cybench/corpus-stage/` | Large cloned Cybench corpus (not redistributed; expensive to re-download). | +| `bench/cybench/service-stage/`, `bench/cybench/challenges/` | Per-run Cybench Docker staging and challenge trees (regenerable, but slow to rebuild). | +| `bench/xbow/stage/`, `bench/xbow/challenges/` | XBOW/XBEN challenge staging (large third-party trees). | +| `bench/wild-hunt/` | Cold-hunt findings, PoCs, disclosure drafts, and campaign results — pre-coordination vuln material. | +| `bench/decomposition-results/` | White-box decomposition run JSON (may contain unreported analysis). | +| `bench/refusal-frontier/` | Refusal-boundary probe artifacts (raw model responses). | +| `bench/nyu/` | Staged NYU CTF content from `nyu-prep.mjs`. | +| `docs/disclosures/` | Generated vendor disclosure packages (`disclosure-gen` output). | +| `reports/` | Engagement and hunt reports (persistent output; same tree as Docker volume mounts in deployment setups). | +| `evidence/` | PoCs, screenshots, and other finding evidence kept across updates. | + +Add your own patterns in `scripts/update-protected.local.txt` (optional local overlay; same glob syntax as the main manifest). + +### Never touched (outside the repo) + +These live outside the project tree — an update never reads or writes them: + +- `%APPDATA%\t3mp3st-nodejs\Config\config.json` (or the macOS/Linux `conf` store path) — saved by `npm run setup` +- War Room browser **localStorage** on the War Room origin +- Local agent auth (`~/.codex`, `%LOCALAPPDATA%\hermes`, etc.) + ## What ships today The framework is an 8-operator kill chain, and this table won't blow smoke about it. **Recon is a live, tool-backed engine** — and the teeth are already real: 90.1% pass@1 on XBEN, 8/10 held-out post-cutoff CVEs pinned to exact file/line/CWE, and a coordinated-disclosure pipeline that's live enough to have drafts held for vendor coordination right now. What's *not* proven is the swarm. Each downstream operator — Exploiter, Infiltrator, Exfiltrator, Ghost — runs the **same real, tool-backed ReAct loop as recon** (real exploit tools, not stubs), but the headline numbers came from a single agent, not the coordinated 8-operator cell, and end-to-end swarm exploitation is unbenchmarked and still unreliable. The engine is real; the swarm is the part still earning its stripes. Loud where we've earned it, blunt about the rest. diff --git a/package.json b/package.json index f039b7b..05ecf53 100755 --- a/package.json +++ b/package.json @@ -79,7 +79,11 @@ "smoke:live": "node scripts/smoke.mjs --live", "capability": "node scripts/capability-smoke.mjs", "gauntlet": "node scripts/capability-smoke.mjs --gauntlet", - "test:gate": "npm run build --silent && node scripts/test-gate.mjs" + "test:gate": "npm run build --silent && node scripts/test-gate.mjs", + "update": "node scripts/update.mjs", + "update:dry": "node scripts/update.mjs --dry-run", + "update:hard": "node scripts/update.mjs --hard", + "test:update": "node scripts/test-update.mjs" }, "bin": { "tempest": "./dist/cli.js", diff --git a/scripts/test-update.mjs b/scripts/test-update.mjs new file mode 100644 index 0000000..0037c20 --- /dev/null +++ b/scripts/test-update.mjs @@ -0,0 +1,371 @@ +#!/usr/bin/env node +/** + * Integration tests for the T3MP3ST updater. + * Each test runs the updater in a temporary sandbox repo so real repository state + * is never touched. + */ +import { execSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { platform, tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const isWin = platform() === 'win32'; + +let pass = 0; +let fail = 0; +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, msg) { + if (!cond) { + throw new Error(msg || 'assertion failed'); + } +} + +function ok(label) { + pass++; + console.log(' OK ' + label); +} + +function logFail(label, err) { + fail++; + console.log(' FAIL ' + label + ': ' + err.message); +} + +function stripAnsi(s) { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function psAvailable() { + return spawnSync('powershell', ['-NoProfile', '-Command', 'exit 0'], { stdio: 'pipe' }).status === 0; +} + +function bashAvailable() { + return !!bashExe(); +} + +// On Windows, the bash on PATH is often WSL (can't access Windows temp dirs). +// Prefer Git Bash alongside git if available. Returns null if not found. +function bashExe() { + if (!isWin) { + const r = spawnSync('bash', ['-c', 'exit 0'], { stdio: 'pipe' }); + return r.status === 0 ? 'bash' : null; + } + const result = spawnSync('where', ['git'], { shell: true, stdio: 'pipe', encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout.trim()) return null; + const gitPath = result.stdout.trim().split(/\r?\n/)[0]; + const dir = dirname(gitPath); + const candidates = [join(dir, 'bin', 'bash.exe'), join(dir, '..', 'bin', 'bash.exe')]; + for (const c of candidates) { + if (existsSync(c)) { + const r = spawnSync(c, ['-c', 'exit 0'], { stdio: 'pipe' }); + if (r.status === 0) return c; + } + } + return null; +} + +const _bash = bashExe(); + +function cleanup(dir) { + for (let i = 0; i < 5; i++) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (err) { + if (i === 4) { + console.warn(' WARN could not clean up temp dir: ' + dir + ' (' + err.message + ')'); + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); + } + } +} + +function makeSandbox() { + const dir = join(tmpdir(), `t3mp3st-update-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + + const files = [ + 'update.ps1', + 'update.sh', + 'update-protected.txt', + 'update-banner.txt', + 'update.mjs', + ]; + for (const f of files) { + let content = readFileSync(join(__dirname, f), 'utf8'); + if (f === 'update.sh') { + content = content.replace(/\r\n/g, '\n'); + } + writeFileSync(join(dir, 'scripts', f), content); + } + + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't3mp3st-test-sandbox', version: '0.0.0' }, null, 2)); + writeFileSync(join(dir, '.env'), 'SECRET=local-secret\n'); + writeFileSync(join(dir, '.env.example'), 'SECRET=your-secret\n'); + mkdirSync(join(dir, 'reports'), { recursive: true }); + writeFileSync(join(dir, 'reports', 'engagement.md'), '# engagement\n'); + mkdirSync(join(dir, 'evidence'), { recursive: true }); + writeFileSync(join(dir, 'evidence', 'poc.txt'), 'poc\n'); + writeFileSync(join(dir, 'unprotected.txt'), 'I should be replaced\n'); + + return dir; +} + +function runPowerShell(sandbox, args = []) { + const ps1 = join(sandbox, 'scripts', 'update.ps1'); + const result = spawnSync( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, ...args], + { cwd: sandbox, stdio: 'pipe', encoding: 'utf8' } + ); + return { + status: result.status, + stdout: stripAnsi(result.stdout || ''), + stderr: stripAnsi(result.stderr || ''), + }; +} + +function runBash(sandbox, args = []) { + const sh = 'scripts/update.sh'; + const result = spawnSync(_bash, [sh, ...args], { cwd: sandbox, stdio: 'pipe', encoding: 'utf8', shell: false }); + return { + status: result.status, + stdout: stripAnsi(result.stdout || ''), + stderr: stripAnsi(result.stderr || ''), + }; +} + +function makeEmptySandbox() { + // Same as makeSandbox but with NO .env, reports/, or evidence/ -- exercises + // the zero-protected-path path that the reviewer flagged. + const dir = join(tmpdir(), `t3mp3st-update-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + const files = ['update.ps1', 'update.sh', 'update-protected.txt', 'update-banner.txt', 'update.mjs']; + for (const f of files) { + let content = readFileSync(join(__dirname, f), 'utf8'); + if (f === 'update.sh') { + content = content.replace(/\r\n/g, '\n'); + } + writeFileSync(join(dir, 'scripts', f), content); + } + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't3mp3st-test-empty', version: '0.0.0' }, null, 2)); + writeFileSync(join(dir, 'unprotected.txt'), 'I should be replaced\n'); + return dir; +} + +// --------------------------------------------------------------------------- +// Static / source-level checks +// --------------------------------------------------------------------------- + +test('protected manifest contains reports/ and evidence/', () => { + const manifest = readFileSync(join(__dirname, 'update-protected.txt'), 'utf8'); + assert(manifest.includes('reports/'), 'reports/ missing from protected manifest'); + assert(manifest.includes('evidence/'), 'evidence/ missing from protected manifest'); + assert(manifest.includes('!.env.example'), '!.env.example missing from protected manifest'); +}); + +test('update.mjs maps --dry-run and --hard', () => { + const src = readFileSync(join(__dirname, 'update.mjs'), 'utf8'); + assert(src.includes("case '--dry-run'") && src.includes("return ['-DryRun']"), '--dry-run not mapped'); + assert(src.includes("case '--hard'") && src.includes("return ['-Hard']"), '--hard not mapped'); +}); + +test('update.ps1 has dry-run guards and hard reset opt-in', () => { + const ps1 = readFileSync(join(__dirname, 'update.ps1'), 'utf8'); + assert(ps1.includes('if ($DryRun)'), 'PowerShell dry-run guard missing'); + assert(ps1.includes('if ($Hard)'), 'PowerShell hard reset opt-in missing'); + assert(ps1.includes('[System.IO.Path]::GetTempPath()'), 'PowerShell backup root must use a portable temp path'); +}); + +test('update.sh has dry-run guard, negation and failure trap', () => { + const sh = readFileSync(join(__dirname, 'update.sh'), 'utf8'); + assert(sh.indexOf('if [[ "$DRY_RUN" -eq 1 ]]') < sh.indexOf('git init'), 'bash dry-run guard not before git init'); + assert(sh.includes('apply_negation_patterns'), 'bash negation support missing'); + assert(sh.includes('trap on_err ERR'), 'bash failure trap missing'); +}); + +// --------------------------------------------------------------------------- +// PowerShell integration tests +// --------------------------------------------------------------------------- + +if (psAvailable()) { + test('dry-run is read-only on tarball-like sandbox', () => { + const sb = makeSandbox(); + try { + const out = runPowerShell(sb, ['-DryRun', '-Force']); + assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout); + assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run'); + assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=local-secret\n', '.env should not be modified'); + assert(out.stdout.includes('First-time sync (upstream snapshot)'), 'expected first-time strategy label'); + assert(out.stdout.includes('reports/') || out.stdout.includes('reports'), 'reports/ should be listed as protected'); + assert(out.stdout.includes('evidence/') || out.stdout.includes('evidence'), 'evidence/ should be listed as protected'); + assert(!out.stdout.includes('.env.example'), '.env.example should NOT be listed as protected'); + } finally { + cleanup(sb); + } + }); + + test('dry-run preserves dirty worktree changes in protected paths', () => { + const sb = makeSandbox(); + try { + writeFileSync(join(sb, '.env'), 'SECRET=dirty-secret\n'); + writeFileSync(join(sb, 'reports', 'engagement.md'), '# changed\n'); + const out = runPowerShell(sb, ['-DryRun', '-Force']); + assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout); + assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=dirty-secret\n', 'dirty .env should survive dry-run'); + assert(readFileSync(join(sb, 'reports', 'engagement.md'), 'utf8') === '# changed\n', 'dirty report should survive dry-run'); + } finally { + cleanup(sb); + } + }); + + test('merge strategy shown when local repo has commits', () => { + const sb = makeSandbox(); + try { + execSync('git init', { cwd: sb, stdio: 'pipe' }); + execSync('git config user.email "test@test"', { cwd: sb, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: sb, stdio: 'pipe' }); + execSync('git add .', { cwd: sb, stdio: 'pipe' }); + execSync('git commit -m initial', { cwd: sb, stdio: 'pipe' }); + const out = runPowerShell(sb, ['-DryRun', '-Force']); + assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout); + assert(out.stdout.includes('Merge upstream/main'), 'expected merge strategy label'); + assert(!out.stdout.includes('Hard reset'), 'should not show hard reset without -Hard'); + } finally { + cleanup(sb); + } + }); + + test('hard reset strategy is opt-in', () => { + const sb = makeSandbox(); + try { + const out = runPowerShell(sb, ['-DryRun', '-Hard', '-Force']); + assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout); + assert(out.stdout.includes('Hard reset to upstream/main'), 'expected hard reset strategy label'); + assert(out.stdout.includes('git reset --hard'), 'expected git reset --hard in dry-run plan'); + assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run'); + } finally { + cleanup(sb); + } + }); + + test('non-dry run requires confirmation unless forced', () => { + const sb = makeSandbox(); + try { + const result = spawnSync( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(sb, 'scripts', 'update.ps1'), '-DryRun'], + { cwd: sb, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input: 'n\n' } + ); + const out = stripAnsi((result.stdout || '') + (result.stderr || '')); + assert(out.includes('Cancelled') || out.includes('Cancelled - no changes made'), 'expected cancel message'); + assert(!existsSync(join(sb, '.git')), '.git should not be created after cancellation'); + } finally { + cleanup(sb); + } + }); +} else { + console.log(' SKIP PowerShell integration tests: powershell not available'); +} + +// --------------------------------------------------------------------------- +// Bash integration tests +// --------------------------------------------------------------------------- + +if (bashAvailable()) { + test('bash dry-run is read-only on tarball-like sandbox', () => { + const sb = makeSandbox(); + try { + const out = runBash(sb, ['--dry-run', '--force']); + assert(out.status === 0, 'bash dry-run failed: ' + out.stderr + out.stdout); + assert(!existsSync(join(sb, '.git')), '.git should not be created in bash dry-run'); + assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=local-secret\n', '.env should not be modified by bash dry-run'); + assert(out.stdout.includes('Merge upstream') || out.stdout.includes('First-time sync'), 'expected strategy label in bash output'); + } finally { + cleanup(sb); + } + }); + + test('bash hard reset is opt-in', () => { + const sb = makeSandbox(); + try { + const out = runBash(sb, ['--dry-run', '--hard', '--force']); + assert(out.status === 0, 'bash dry-run failed: ' + out.stderr + out.stdout); + assert(out.stdout.includes('git reset --hard'), 'expected git reset --hard in bash dry-run plan'); + assert(!existsSync(join(sb, '.git')), '.git should not be created in bash dry-run'); + } finally { + cleanup(sb); + } + }); + + test('bash dry-run exits clean with no protected paths (no --force)', () => { + // Regression: grep -c on an empty file exits nonzero, so || echo 0 produced + // "0\n0" and broke [[ -eq ]] comparisons, tripping the ERR trap. + const sb = makeEmptySandbox(); + try { + const result = spawnSync(_bash, ['scripts/update.sh', '--dry-run'], { + cwd: sb, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input: 'n\n', + }); + const out = { + status: result.status, + stdout: stripAnsi(result.stdout || ''), + stderr: stripAnsi(result.stderr || ''), + }; + assert(out.status === 0, 'bash dry-run with empty protected set should exit 0, got ' + out.status + '\n' + out.stdout + out.stderr); + assert(!out.stdout.includes('syntax error'), 'unexpected syntax error from broken protected_count:\n' + out.stdout); + assert(out.stdout.includes('No protected project files'), 'expected "No protected project files" message'); + assert(out.stdout.includes('Cancelled'), 'expected cancel message when stdin declines'); + assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run'); + } finally { + cleanup(sb); + } + }); + + test('bash dry-run --force exits clean with no protected paths', () => { + const sb = makeEmptySandbox(); + try { + const out = runBash(sb, ['--dry-run', '--force']); + assert(out.status === 0, 'bash dry-run --force with empty protected set should exit 0, got ' + out.status + '\n' + out.stdout + out.stderr); + assert(!out.stdout.includes('syntax error'), 'unexpected syntax error:\n' + out.stdout); + assert(out.stdout.includes('No protected project files'), 'expected "No protected project files" message'); + assert(out.stdout.includes('Preview only'), 'expected dry-run preview completion message'); + assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run'); + } finally { + cleanup(sb); + } + }); +} else { + console.log(' SKIP Bash integration tests: bash not available'); +} + +// --------------------------------------------------------------------------- +// Run all tests +// --------------------------------------------------------------------------- + +console.log(''); +console.log('======== updater tests ========'); +console.log(''); + +for (const t of tests) { + try { + t.fn(); + ok(t.name); + } catch (err) { + logFail(t.name, err); + } +} + +console.log(''); +console.log('======== ' + (fail === 0 ? 'ALL PASS' : fail + ' FAILED') + ' ========'); +console.log(''); +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/update-banner.txt b/scripts/update-banner.txt new file mode 100644 index 0000000..c4dc950 --- /dev/null +++ b/scripts/update-banner.txt @@ -0,0 +1,9 @@ + ▄▄▄█████▓▓█████ ███▄ ▄███▓ ██▓███ ▓█████ ██████ ▄▄▄█████▓ + ▓ ██▒ ▓▒▓█ ▀ ▓██▒▀█▀ ██▒▓██░ ██▒▓█ ▀ ▒██ ▒ ▓ ██▒ ▓▒ + ▒ ▓██░ ▒░▒███ ▓██ ▓██░▓██░ ██▓▒▒███ ░ ▓██▄ ▒ ▓██░ ▒░ + ░ ▓██▓ ░ ▒▓█ ▄ ▒██ ▒██ ▒██▄█▓▒ ▒▒▓█ ▄ ▒ ██▒░ ▓██▓ ░ + ▒██▒ ░ ░▒████▒▒██▒ ░██▒▒██▒ ░ ░░▒████▒▒██████▒▒ ▒██▒ ░ + ▒ ░░ ░░ ▒░ ░░ ▒░ ░ ░▒▓▒░ ░ ░░░ ▒░ ░▒ ▒▓▒ ▒ ░ ▒ ░░ + ░ ░ ░ ░░ ░ ░░▒ ░ ░ ░ ░░ ░▒ ░ ░ ░ + ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ + ░ ░ ░ ░ ░ ░ diff --git a/scripts/update-protected.txt b/scripts/update-protected.txt new file mode 100644 index 0000000..ff16c22 --- /dev/null +++ b/scripts/update-protected.txt @@ -0,0 +1,21 @@ +# Paths preserved across `npm run update` (repo-relative). +# Lines starting with # are ignored. Glob * and ** supported. +# Optional overlay: scripts/update-protected.local.txt + +.env +.env.* +!.env.example +.keys.local +.keys.bounty.json +bench/cybench/corpus-stage/ +bench/cybench/service-stage/ +bench/cybench/challenges/ +bench/xbow/stage/ +bench/xbow/challenges/ +bench/wild-hunt/ +bench/decomposition-results/ +bench/refusal-frontier/ +bench/nyu/ +docs/disclosures/ +reports/ +evidence/ diff --git a/scripts/update.mjs b/scripts/update.mjs new file mode 100644 index 0000000..923d026 --- /dev/null +++ b/scripts/update.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Cross-platform entry for `npm run update`. + * Windows -> scripts/update.ps1 + * Linux/macOS/WSL -> scripts/update.sh + */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { platform } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const args = process.argv.slice(2); + +function commandExists(name) { + if (platform() === 'win32') { + const result = spawnSync('where', [name], { shell: true, stdio: 'pipe' }); + return result.status === 0; + } + const result = spawnSync('command', ['-v', name], { shell: true, stdio: 'pipe' }); + return result.status === 0; +} + +function gitInstallHelp() { + const os = platform(); + if (os === 'win32') { + return [ + '[x] Git is required but was not found on PATH.', + '', + 'Install Git for Windows:', + ' https://git-scm.com/download/win', + '', + 'During setup, choose "Git from the command line and also from 3rd-party software".', + 'Then reopen your terminal and run: npm run update', + ].join('\n'); + } + if (os === 'darwin') { + return [ + '[x] Git is required but was not found on PATH.', + '', + 'Install options:', + ' brew install git', + ' xcode-select --install', + '', + 'Then run: npm run update', + ].join('\n'); + } + return [ + '[x] Git is required but was not found on PATH.', + '', + 'Install with your package manager, for example:', + ' sudo apt install git # Debian/Ubuntu', + ' sudo dnf install git # Fedora', + ' sudo pacman -S git # Arch', + '', + 'More: https://git-scm.com/download/linux', + '', + 'Then run: npm run update', + ].join('\n'); +} + +function npmInstallHelp() { + return [ + '[x] npm is required but was not found on PATH.', + '', + 'Install Node.js (includes npm): https://nodejs.org/', + 'Then run: npm run update', + ].join('\n'); +} + +function mapArgsForPowerShell(argv) { + return argv.flatMap((arg) => { + switch (arg) { + case '--dry-run': + return ['-DryRun']; + case '--hard': + return ['-Hard']; + case '--force': + return ['-Force']; + case '--keep-backup': + return ['-KeepBackup']; + default: + return [arg]; + } + }); +} + +if (!existsSync(join(root, 'package.json'))) { + console.error(`[x] Not a T3MP3ST repo (package.json missing): ${root}`); + process.exit(1); +} + +if (!commandExists('git')) { + console.error(gitInstallHelp()); + process.exit(1); +} + +if (!commandExists('npm')) { + console.error(npmInstallHelp()); + process.exit(1); +} + +const os = platform(); +const platformLabel = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[os] ?? os; +const scriptName = os === 'win32' ? 'update.ps1' : 'update.sh'; +console.log('\x1b[90m-- Launcher ----------------------------------------\x1b[0m'); +console.log(` \x1b[36mPlatform\x1b[0m ${platformLabel}`); +console.log(` \x1b[36mDriver\x1b[0m scripts/${scriptName}`); +console.log(` \x1b[36mCommand\x1b[0m npm run update`); +console.log(''); + +let result; + +if (os === 'win32') { + const ps1 = join(__dirname, 'update.ps1'); + if (!existsSync(ps1)) { + console.error(`[x] Missing updater script: ${ps1}`); + process.exit(1); + } + result = spawnSync( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, ...mapArgsForPowerShell(args)], + { cwd: root, stdio: 'inherit' }, + ); +} else { + const sh = join(__dirname, 'update.sh'); + if (!existsSync(sh)) { + console.error(`[x] Missing updater script: ${sh}`); + process.exit(1); + } + result = spawnSync('bash', [sh, ...args], { cwd: root, stdio: 'inherit' }); +} + +if (result.error) { + console.error(`[x] Failed to start updater: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/update.ps1 b/scripts/update.ps1 new file mode 100644 index 0000000..fc70036 --- /dev/null +++ b/scripts/update.ps1 @@ -0,0 +1,716 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Safe upstream sync for T3MP3ST from elder-plinius/T3MP3ST. + +.DESCRIPTION + Backs up local secrets/config/artifacts, fetches upstream main, applies the update, + restores protected paths, then runs npm install. + + Config outside this repo is never touched: + %APPDATA%\t3mp3st-nodejs\Config\config.json (npm run setup wizard) + Browser localStorage "t3mp3st" on the War Room origin + Local agent auth (~/.codex, %LOCALAPPDATA%\hermes, etc.) + +.PARAMETER DryRun + Preview only; no git or npm changes. + +.PARAMETER Hard + git reset --hard upstream/main after fetch (source edits in repo are discarded; + protected files are still restored from backup). + +.PARAMETER Force + Skip the y/N confirmation prompt. + +.PARAMETER KeepBackup + Do not delete the temp backup folder after a successful run. +#> +[CmdletBinding()] +param( + [switch]$DryRun, + [switch]$Hard, + [switch]$Force, + [switch]$KeepBackup +) + +$ErrorActionPreference = 'Stop' + +$UpstreamUrl = 'https://github.com/elder-plinius/T3MP3ST.git' +$UpstreamBranch = 'main' +$UpstreamRemote = 'upstream' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $RepoRoot + +function Get-TerminalColumns { + try { + if ($Host.UI -and $Host.UI.RawUI) { + $width = [int]$Host.UI.RawUI.WindowSize.Width + if ($width -gt 0) { return $width } + } + } catch { } + return 80 +} + +function Get-BannerCharDensity { + param([char]$Char) + $code = [int][char]$Char + switch ($code) { + 0x2588 { return 5 } # █ + 0x2593 { return 4 } # ▓ + 0x2592 { return 3 } # ▒ + 0x2584 { return 3 } # ▄ + 0x2580 { return 3 } # ▀ + 0x2591 { return 2 } # ░ + default { return 0 } + } +} + +function Merge-BannerChars { + param([char]$Left, [char]$Right) + $leftDensity = Get-BannerCharDensity $Left + $rightDensity = Get-BannerCharDensity $Right + if ($leftDensity -ge $rightDensity) { return $Left } + return $Right +} + +function Merge-BannerLines { + param([string]$Top, [string]$Bottom) + $length = [Math]::Max($Top.Length, $Bottom.Length) + $top = $Top.PadRight($length) + $bottom = $Bottom.PadRight($length) + $builder = New-Object System.Text.StringBuilder + for ($i = 0; $i -lt $length; $i++) { + [void]$builder.Append((Merge-BannerChars $top[$i] $bottom[$i])) + } + return $builder.ToString().TrimEnd() +} + +function Scale-BannerLineHorizontal { + param([string]$Line) + if (-not $Line) { return '' } + $builder = New-Object System.Text.StringBuilder + for ($i = 0; $i -lt $Line.Length; $i += 2) { + $left = $Line[$i] + $right = if (($i + 1) -lt $Line.Length) { $Line[$i + 1] } else { ' ' } + [void]$builder.Append((Merge-BannerChars $left $right)) + } + return $builder.ToString().TrimEnd() +} + +function Scale-BannerLines { + param([string[]]$Lines) + $horizontal = foreach ($line in $Lines) { + if ($line) { Scale-BannerLineHorizontal $line } else { '' } + } + $vertical = New-Object System.Collections.Generic.List[string] + for ($i = 0; $i -lt $horizontal.Count; $i += 2) { + if (($i + 1) -lt $horizontal.Count) { + [void]$vertical.Add((Merge-BannerLines $horizontal[$i] $horizontal[$i + 1])) + } else { + [void]$vertical.Add($horizontal[$i]) + } + } + return @($vertical.ToArray()) +} + +function Get-BannerMaxWidth { + param([string[]]$Lines) + $max = 0 + foreach ($line in $Lines) { + if ($line.Length -gt $max) { $max = $line.Length } + } + return $max +} + +function Get-FitBannerLines { + param( + [string[]]$Lines, + [int]$TerminalWidth + ) + $margin = 2 + $target = [Math]::Max(24, $TerminalWidth - $margin) + $current = @($Lines) + while ((Get-BannerMaxWidth $current) -gt $target -and $current.Count -gt 1) { + $current = @(Scale-BannerLines $current) + } + return @($current) +} + +function Write-Banner { + try { + if ($Host.UI.RawUI) { + $Host.UI.RawUI.WindowTitle = 'T3MP3ST Updater' + } + chcp 65001 | Out-Null + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch { } + + $bannerFile = Join-Path $PSScriptRoot 'update-banner.txt' + if (Test-Path $bannerFile) { + $lines = @(Get-Content -Path $bannerFile -Encoding UTF8 | Where-Object { $_.Trim().Length -gt 0 }) + $fit = Get-FitBannerLines -Lines $lines -TerminalWidth (Get-TerminalColumns) + foreach ($line in $fit) { + Write-Host $line -ForegroundColor Cyan + } + } else { + Write-Host 'T3MP3ST' -ForegroundColor Cyan + } + Write-Host ' T3MP3ST Updater' -ForegroundColor White + Write-Host ' Safe upstream sync - secrets, config, and bench artifacts stay local' -ForegroundColor DarkGray + Write-Host '' +} + +$script:StepIndex = 0 +$script:StepTotal = 0 + +function Write-Rule { + param([string]$Title = '') + $width = [Math]::Min(68, [Math]::Max(40, (Get-TerminalColumns))) + Write-Host '' + if ($Title) { + $head = "-- $Title " + $fill = [Math]::Max(0, $width - $Title.Length - 4) + Write-Host ($head + ('-' * $fill)) -ForegroundColor Cyan + } else { + Write-Host ('-' * $width) -ForegroundColor DarkGray + } +} + +function Write-Kv { + param( + [string]$Key, + [string]$Value, + [ConsoleColor]$ValueColor = 'White' + ) + Write-Host (' {0,-12}' -f ($Key + ':')) -NoNewline -ForegroundColor DarkGray + Write-Host $Value -ForegroundColor $ValueColor +} + +function Write-PlanLine { + param([string]$Icon, [string]$Text) + Write-Host " $Icon $Text" -ForegroundColor Gray +} + +function Get-UpdateStrategyLabel { + if ($Hard) { return 'Hard reset to upstream/main' } + if ((Get-LocalCommitCount) -eq 0) { return 'First-time sync (upstream snapshot)' } + return 'Merge upstream/main' +} + +function Get-UpdatePlanSteps { + param([int]$ProtectedCount) + $steps = New-Object System.Collections.Generic.List[string] + if ($ProtectedCount -gt 0) { + [void]$steps.Add("Back up $ProtectedCount protected path(s) to a temp folder") + } else { + [void]$steps.Add('Skip in-repo backup (no protected project files found)') + } + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { + [void]$steps.Add('Initialize git and add upstream remote') + } else { + [void]$steps.Add('Use existing git repo and upstream remote') + } + [void]$steps.Add("Fetch $UpstreamRemote/$UpstreamBranch") + if ($Hard) { + [void]$steps.Add('Reset working tree to upstream/main') + } elseif ((Get-LocalCommitCount) -eq 0) { + [void]$steps.Add('Apply upstream snapshot to project files') + } else { + [void]$steps.Add('Merge upstream changes into your local branch') + } + if ($ProtectedCount -gt 0) { + [void]$steps.Add('Restore backed-up protected paths') + } + [void]$steps.Add('Run npm install to refresh dependencies') + return ,$steps.ToArray() +} + +function Write-UpdatePlan { + param([string[]]$Steps) + $n = 1 + foreach ($step in $Steps) { + Write-Host (' {0,2}.' -f $n) -NoNewline -ForegroundColor Cyan + Write-Host " $step" -ForegroundColor White + $n++ + } +} + +function Start-UpdateStep { + param([string]$Message) + $script:StepIndex++ + if ($script:StepTotal -gt 0) { + Write-Host "" + Write-Host " [$($script:StepIndex)/$($script:StepTotal)] $Message" -ForegroundColor Cyan + } else { + Write-Host "" + Write-Info $Message + } +} + +function Write-DoneBox { + param( + [string]$FromSha, + [string]$ToSha, + [switch]$Preview + ) + Write-Rule 'Done' + if ($Preview) { + Write-Host ' Preview only - no files were changed.' -ForegroundColor Yellow + Write-Host ' Run without --dry-run when you are ready.' -ForegroundColor DarkGray + } else { + Write-Host ' Update complete' -ForegroundColor Green + Write-Kv 'Before' $FromSha + Write-Kv 'After' $ToSha 'Green' + } + Write-Host '' +} + +function Write-GitInstallHelp { + Write-Err 'Git is required but was not found on PATH.' + Write-Host '' + Write-Host 'Install Git for Windows:' -ForegroundColor Yellow + Write-Host ' https://git-scm.com/download/win' + Write-Host '' + Write-Host 'During setup, choose "Git from the command line and also from 3rd-party software".' + Write-Host 'Then reopen your terminal and run: npm run update' +} + +function Write-NpmInstallHelp { + Write-Err 'npm is required but was not found on PATH.' + Write-Host '' + Write-Host 'Install Node.js (includes npm): https://nodejs.org/' -ForegroundColor Yellow + Write-Host 'Then run: npm run update' +} + +function Write-Info([string]$Message) { Write-Host "[*] $Message" -ForegroundColor Cyan } +function Write-Ok([string]$Message) { Write-Host "[+] $Message" -ForegroundColor Green } +function Write-Warn([string]$Message) { Write-Host "[!] $Message" -ForegroundColor Yellow } +function Write-Err([string]$Message) { Write-Host "[x] $Message" -ForegroundColor Red } + +function Test-Command([string]$Name) { + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Invoke-Git { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GitArgs) + $oldErrorAction = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + # Git writes progress ("From https://...") to stderr even on success. + # With $ErrorActionPreference = 'Stop' that can throw unless we normalize it. + $output = & git @GitArgs 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + $_ + } + } + if ($LASTEXITCODE -ne 0) { + $text = ($output | Where-Object { $_ } | Out-String).Trim() + throw "git $($GitArgs -join ' ') failed: $text" + } + if ($output) { return ($output | Out-String).Trim() } + return '' + } finally { + $ErrorActionPreference = $oldErrorAction + } +} + +function Read-ProtectedManifest { + $files = @( + (Join-Path $PSScriptRoot 'update-protected.txt'), + (Join-Path $PSScriptRoot 'update-protected.local.txt') + ) + + $patterns = New-Object System.Collections.Generic.List[string] + foreach ($manifest in $files) { + if (-not (Test-Path $manifest)) { continue } + Get-Content $manifest | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + [void]$patterns.Add($line) + } + } + + if ($patterns.Count -eq 0) { + throw 'No protection patterns found in scripts/update-protected.txt' + } + return ,$patterns +} + +function Test-MatchesProtectedPattern { + param( + [string]$RelativePath, + [string]$Pattern + ) + $rel = $RelativePath -replace '\\', '/' + $pat = $Pattern -replace '\\', '/' + + if ($pat.StartsWith('!')) { return $false } + + if ($pat -match '[\*\?]') { + $regex = '^' + ($pat -replace '\.', '\.' -replace '\*\*', '::DOUBLESTAR::' -replace '\*', '[^/]*' -replace '::DOUBLESTAR::', '.*') + '$' + return [bool]($rel -match $regex) + } + + if ($rel -eq $pat) { return $true } + if ($pat.EndsWith('/') -and $rel.StartsWith($pat.TrimEnd('/'))) { return $true } + return $false +} + +function Get-ProtectedPaths { + param([string[]]$Patterns) + + $results = New-Object System.Collections.Generic.HashSet[string] + $negations = $Patterns | Where-Object { $_.StartsWith('!') } | ForEach-Object { $_.Substring(1) } + + foreach ($pattern in ($Patterns | Where-Object { -not $_.StartsWith('!') })) { + $isDirPattern = $pattern.EndsWith('/') + $normalized = ($pattern -replace '\\', '/').TrimEnd('/') + + if ($pattern -match '[\*\?]') { + $glob = if ($isDirPattern) { $pattern } else { $pattern } + $search = Join-Path $RepoRoot (($glob -split '/')[0]) + if (-not (Test-Path $search)) { + $hits = Get-ChildItem -Path $RepoRoot -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { + $rel = $_.FullName.Substring($RepoRoot.Length).TrimStart('\', '/') + Test-MatchesProtectedPattern -RelativePath $rel -Pattern $pattern + } + } else { + $hits = Get-ChildItem -Path $RepoRoot -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { + $rel = $_.FullName.Substring($RepoRoot.Length).TrimStart('\', '/') + Test-MatchesProtectedPattern -RelativePath $rel -Pattern $pattern + } + } + foreach ($hit in $hits) { + $rel = $hit.FullName.Substring($RepoRoot.Length).TrimStart('\', '/') + [void]$results.Add($rel) + } + continue + } + + $full = Join-Path $RepoRoot $normalized + if (Test-Path $full) { + $rel = $full.Substring($RepoRoot.Length).TrimStart('\', '/') + [void]$results.Add($rel) + } + } + + $final = New-Object System.Collections.Generic.List[string] + foreach ($rel in ($results | Sort-Object)) { + $skip = $false + foreach ($neg in $negations) { + if (Test-MatchesProtectedPattern -RelativePath $rel -Pattern $neg) { + $skip = $true + break + } + } + if (-not $skip) { [void]$final.Add($rel) } + } + return ,$final +} + +function Backup-ProtectedPaths { + param( + [string[]]$RelativePaths, + [string]$BackupRoot + ) + + New-Item -ItemType Directory -Path $BackupRoot -Force | Out-Null + $manifest = Join-Path $BackupRoot 'manifest.json' + $entries = @() + + foreach ($rel in $RelativePaths) { + $src = Join-Path $RepoRoot $rel + if (-not (Test-Path $src)) { continue } + $dest = Join-Path $BackupRoot $rel + $destParent = Split-Path $dest -Parent + if (-not (Test-Path $destParent)) { + New-Item -ItemType Directory -Path $destParent -Force | Out-Null + } + Copy-Item -Path $src -Destination $dest -Recurse -Force + $entries += $rel + } + + $entries | ConvertTo-Json | Set-Content -Path $manifest -Encoding UTF8 + return $entries +} + +function Restore-ProtectedPaths { + param([string]$BackupRoot) + + $manifest = Join-Path $BackupRoot 'manifest.json' + if (-not (Test-Path $manifest)) { + Write-Warn 'No backup manifest found; skipping restore.' + return + } + + $entries = Get-Content $manifest | ConvertFrom-Json + foreach ($rel in $entries) { + $src = Join-Path $BackupRoot $rel + if (-not (Test-Path $src)) { continue } + $dest = Join-Path $RepoRoot $rel + $destParent = Split-Path $dest -Parent + if (-not (Test-Path $destParent)) { + New-Item -ItemType Directory -Path $destParent -Force | Out-Null + } + if (Test-Path $dest) { + Remove-Item -Path $dest -Recurse -Force + } + Copy-Item -Path $src -Destination $dest -Recurse -Force + } +} + +function Get-ShortSha { + param([string]$Ref) + try { + return (Invoke-Git rev-parse --short $Ref) + } catch { + return '(unknown)' + } +} + +function Ensure-GitRepository { + if (Test-Path (Join-Path $RepoRoot '.git')) { return } + + Write-Warn 'No .git directory - bootstrapping a local repository.' + if ($DryRun) { + Write-Info '[dry-run] would run: git init' + return + } + + Invoke-Git init | Out-Null + Invoke-Git branch -M $UpstreamBranch | Out-Null +} + +function Ensure-UpstreamRemote { + $remotes = @() + if (Test-Path (Join-Path $RepoRoot '.git')) { + $remotes = (Invoke-Git remote) -split "`n" | Where-Object { $_ } + } + + if ($remotes -contains $UpstreamRemote) { + $url = Invoke-Git remote get-url $UpstreamRemote + if ($url -ne $UpstreamUrl) { + Write-Warn "Remote $UpstreamRemote points to $url - updating to $UpstreamUrl" + if (-not $DryRun) { + Invoke-Git remote set-url $UpstreamRemote $UpstreamUrl | Out-Null + } + } + return + } + + Write-Info "Adding remote $UpstreamRemote -> $UpstreamUrl" + if (-not $DryRun) { + Invoke-Git remote add $UpstreamRemote $UpstreamUrl | Out-Null + } +} + +function Get-LocalCommitCount { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return 0 } + try { + $count = Invoke-Git rev-list --count HEAD + return [int]$count + } catch { + return 0 + } +} + +function Clear-WorkingTreeExceptGit { + Get-ChildItem -Path $RepoRoot -Force | + Where-Object { $_.Name -ne '.git' } | + Remove-Item -Recurse -Force +} + +function Apply-UpstreamUpdate { + if ($DryRun) { + Write-Info "[dry-run] would run: git fetch $UpstreamRemote $UpstreamBranch" + if ($Hard) { + Write-Info "[dry-run] would run: git reset --hard $UpstreamRemote/$UpstreamBranch" + } elseif ((Get-LocalCommitCount) -eq 0) { + Write-Info '[dry-run] would apply upstream snapshot (checkout upstream/main)' + Write-Info "[dry-run] would run: git checkout -B $UpstreamBranch $UpstreamRemote/$UpstreamBranch" + } else { + Write-Info "[dry-run] would run: git merge $UpstreamRemote/$UpstreamBranch" + } + return + } + + Invoke-Git fetch $UpstreamRemote $UpstreamBranch | Out-Null + + if ($Hard) { + Invoke-Git reset --hard "$UpstreamRemote/$UpstreamBranch" | Out-Null + return + } + + if ((Get-LocalCommitCount) -eq 0) { + # Copied/zipped folder without git history: untracked files block checkout. + # Protected paths are already in temp backup, so replace the tree with upstream. + Write-Info 'First-time git sync: applying upstream snapshot (local copy had no commits)...' + Clear-WorkingTreeExceptGit + Invoke-Git checkout -B $UpstreamBranch "$UpstreamRemote/$UpstreamBranch" | Out-Null + return + } + + try { + Invoke-Git merge "$UpstreamRemote/$UpstreamBranch" --no-edit | Out-Null + } catch { + Write-Err 'Merge failed - resolve conflicts manually or re-run with -Hard.' + throw + } +} + +function Install-NpmPackages { + if (-not (Test-Path (Join-Path $RepoRoot 'package.json'))) { + throw 'package.json not found in repo root.' + } + if (-not (Test-Command 'npm')) { + throw 'npm is not installed or not on PATH.' + } + + if ($DryRun) { + Write-Info '[dry-run] would run: npm install' + return + } + + Write-Info 'Running npm install...' + & npm install + if ($LASTEXITCODE -ne 0) { + throw 'npm install failed.' + } +} + +# --- main --- +Write-Banner + +if (-not (Test-Path (Join-Path $RepoRoot 'package.json'))) { + Write-Err "Not a T3MP3ST repo (package.json missing): $RepoRoot" + exit 1 +} + +if (-not (Test-Command 'git')) { + Write-GitInstallHelp + exit 1 +} +if (-not (Test-Command 'npm')) { + Write-NpmInstallHelp + exit 1 +} + +$patterns = Read-ProtectedManifest +$protected = Get-ProtectedPaths -Patterns $patterns +$planSteps = Get-UpdatePlanSteps -ProtectedCount $protected.Count +$platformLabel = if ($IsWindows -or $env:OS -match 'Windows') { 'Windows' } else { 'Unix' } + +Write-Rule 'Overview' +Write-Kv 'Platform' "$platformLabel (PowerShell)" +Write-Kv 'Repository' $RepoRoot +Write-Kv 'Upstream' "$UpstreamUrl @ $UpstreamBranch" +$strategyLabel = Get-UpdateStrategyLabel +$strategyColor = if ($Hard) { 'Yellow' } elseif ((Get-LocalCommitCount) -eq 0) { 'Cyan' } else { 'White' } +Write-Kv 'Strategy' $strategyLabel $strategyColor +if ($DryRun) { Write-Kv 'Mode' 'Dry run (preview only)' 'Yellow' } + +Write-Rule 'Protected paths in this repo' +if ($protected.Count -eq 0) { + Write-PlanLine '-' 'No protected project files in this repo (.env, .keys.*, bench output, etc.)' +} else { + foreach ($p in $protected) { Write-PlanLine '+' $p } +} + +Write-Rule 'Never touched (outside repo)' +Write-PlanLine '-' '%APPDATA%\t3mp3st-nodejs\Config\config.json' +Write-PlanLine '-' 'War Room browser localStorage' +Write-PlanLine '-' 'Codex / Hermes local agent auth' + +$localSha = '(no git yet)' +$upstreamSha = '(unknown)' +if (Test-Path (Join-Path $RepoRoot '.git')) { + try { $localSha = Get-ShortSha 'HEAD' } catch { $localSha = '(no commits yet)' } +} +try { + Ensure-GitRepository + Ensure-UpstreamRemote + if (-not $DryRun) { + Invoke-Git fetch $UpstreamRemote $UpstreamBranch | Out-Null + $upstreamSha = Get-ShortSha "$UpstreamRemote/$UpstreamBranch" + } else { + $upstreamSha = '(preview)' + } +} catch { + Write-Warn "Could not prefetch upstream: $($_.Exception.Message)" +} + +Write-Rule 'Revisions' +Write-Kv 'Local' $localSha +Write-Kv 'Upstream' $upstreamSha 'Cyan' + +Write-Rule 'Sync plan' +Write-UpdatePlan -Steps $planSteps + +if (-not $Force) { + Write-Host '' + $answer = Read-Host 'Proceed with update? [y/N]' + if ($answer -notmatch '^(y|yes)$') { + Write-Warn 'Cancelled - no changes made.' + exit 0 + } +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$backupRoot = Join-Path ([System.IO.Path]::GetTempPath()) "t3mp3st-update-backup-$timestamp" +$oldSha = $localSha +$script:StepTotal = 2 + $(if ($protected.Count -gt 0) { 2 } else { 0 }) +$script:StepIndex = 0 + +try { + if ($protected.Count -gt 0) { + Start-UpdateStep "Backing up protected files" + if (-not $DryRun) { + $backedUp = Backup-ProtectedPaths -RelativePaths $protected -BackupRoot $backupRoot + Write-Ok "Backed up $($backedUp.Count) path(s)" + } else { + Write-Info '[dry-run] would back up protected paths' + } + } + + Start-UpdateStep 'Syncing from upstream' + Ensure-GitRepository + Ensure-UpstreamRemote + Apply-UpstreamUpdate + if (-not $DryRun) { Write-Ok 'Git sync finished' } + + if ($protected.Count -gt 0) { + Start-UpdateStep 'Restoring protected files' + if (-not $DryRun) { + Restore-ProtectedPaths -BackupRoot $backupRoot + Write-Ok 'Protected paths restored' + } else { + Write-Info '[dry-run] would restore protected paths' + } + } + + Start-UpdateStep 'Refreshing npm packages' + Install-NpmPackages + if (-not $DryRun) { Write-Ok 'npm install finished' } + + $newSha = if ($DryRun) { $upstreamSha } else { Get-ShortSha 'HEAD' } + + Write-DoneBox -FromSha $oldSha -ToSha $newSha -Preview:($DryRun) + + if (-not $KeepBackup -and -not $DryRun -and (Test-Path $backupRoot)) { + Remove-Item -Path $backupRoot -Recurse -Force + } elseif ((Test-Path $backupRoot)) { + Write-Info "Backup kept at $backupRoot" + } +} catch { + Write-Host '' + Write-Err $_.Exception.Message + if ((Test-Path $backupRoot)) { + Write-Warn "Backup preserved at $backupRoot" + } + exit 1 +} diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..d628fe6 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,392 @@ +#!/usr/bin/env bash +# Linux / macOS / WSL updater (Windows uses update.ps1 via scripts/update.mjs). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UPSTREAM_URL='https://github.com/elder-plinius/T3MP3ST.git' +UPSTREAM_BRANCH='main' +UPSTREAM_REMOTE='upstream' +DRY_RUN=0 +HARD=0 +FORCE=0 +KEEP_BACKUP=0 +STEP_INDEX=0 +STEP_TOTAL=0 + +if [[ -t 1 ]]; then + C_CYAN=$'\033[36m' + C_GREEN=$'\033[32m' + C_YELLOW=$'\033[33m' + C_GRAY=$'\033[90m' + C_WHITE=$'\033[97m' + C_RESET=$'\033[0m' +else + C_CYAN='' C_GREEN='' C_YELLOW='' C_GRAY='' C_WHITE='' C_RESET='' +fi + +rule() { + local title="${1:-}" + local width=68 + echo + if [[ -n "$title" ]]; then + local fill=$((width - ${#title} - 4)) + [[ $fill -lt 0 ]] && fill=0 + printf '%s── %s %s%s\n' "$C_CYAN" "$title" "$(printf '─%.0s' $(seq 1 "$fill"))" "$C_RESET" + else + printf '%s%s%s\n' "$C_GRAY" "$(printf '─%.0s' $(seq 1 "$width"))" "$C_RESET" + fi +} + +kv() { + local key="$1" val="$2" color="${3:-$C_WHITE}" + printf ' %-12s %b%s%b\n' "${key}:" "$color" "$val" "$C_RESET" +} + +plan_line() { + printf ' %s %s\n' "$1" "$2" +} + +start_step() { + STEP_INDEX=$((STEP_INDEX + 1)) + echo + if [[ "$STEP_TOTAL" -gt 0 ]]; then + printf ' %s[%d/%d] %s%s\n' "$C_CYAN" "$STEP_INDEX" "$STEP_TOTAL" "$1" "$C_RESET" + else + printf ' [*] %s\n' "$1" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -DryRun|--dry-run) DRY_RUN=1 ;; + -Hard|--hard) HARD=1 ;; + -Force|--force) FORCE=1 ;; + -KeepBackup|--keep-backup) KEEP_BACKUP=1 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done + +git_install_help() { + echo '[x] Git is required but was not found on PATH.' >&2 + echo >&2 + case "$(uname -s 2>/dev/null || echo unknown)" in + Darwin) + echo 'Install options:' >&2 + echo ' brew install git' >&2 + echo ' xcode-select --install' >&2 + ;; + Linux|GNU/*) + echo 'Install with your package manager, for example:' >&2 + echo ' sudo apt install git # Debian/Ubuntu' >&2 + echo ' sudo dnf install git # Fedora' >&2 + echo ' sudo pacman -S git # Arch' >&2 + echo 'More: https://git-scm.com/download/linux' >&2 + ;; + *) + echo 'Install Git: https://git-scm.com/downloads' >&2 + ;; + esac + echo >&2 + echo 'Then run: npm run update' >&2 +} + +npm_install_help() { + echo '[x] npm is required but was not found on PATH.' >&2 + echo 'Install Node.js (includes npm): https://nodejs.org/' >&2 + echo 'Then run: npm run update' >&2 +} + +cd "$ROOT" +[[ -f package.json ]] || { echo '[x] package.json missing' >&2; exit 1; } +command -v git >/dev/null 2>&1 || { git_install_help; exit 1; } +command -v npm >/dev/null 2>&1 || { npm_install_help; exit 1; } + +if [[ -f "$ROOT/scripts/update-banner.txt" ]]; then + cat "$ROOT/scripts/update-banner.txt" +else + echo 'T3MP3ST' +fi +echo ' T3MP3ST Updater' +echo ' Safe upstream sync - secrets, config, and bench artifacts stay local' +echo + +backup="$(mktemp -d -t t3mp3st-update-backup.XXXXXX)" +manifest="$backup/manifest.txt" +: >"$manifest" +protected_count=0 +protected_list="$(mktemp)" + +on_err() { + echo >&2 + echo '[x] Update failed partway through.' >&2 + if [[ -f "$manifest" && -s "$manifest" ]]; then + echo " Local backup of protected files: $backup" >&2 + fi +} +trap on_err ERR + +protect_patterns() { + grep -v '^#' "$ROOT/scripts/update-protected.txt" | grep -v '^[[:space:]]*$' || true + [[ -f "$ROOT/scripts/update-protected.local.txt" ]] && grep -v '^#' "$ROOT/scripts/update-protected.local.txt" | grep -v '^[[:space:]]*$' || true +} + +path_matches_pattern() { + local rel="$1" pat="$2" + pat="${pat//\\//}" + rel="${rel//\\//}" + + if [[ "$pat" == */ ]]; then + local prefix="${pat%/}" + [[ "$rel" == "$prefix" || "$rel" == "$prefix"/* ]] + return + fi + + if [[ "$pat" == *[*?]* ]]; then + case "$rel" in + $pat) return 0 ;; + esac + return 1 + fi + + [[ "$rel" == "$pat" ]] +} + +collect_positive_pattern() { + local pat="$1" + if [[ "$pat" == */ ]]; then + local rel="${pat%/}" + [[ -e "$ROOT/$rel" ]] || return 0 + echo "$rel" >>"$protected_list" + elif [[ "$pat" == *'*'* ]]; then + find "$ROOT" -path "$ROOT/$pat" -print 2>/dev/null | while IFS= read -r hit; do + echo "${hit#"$ROOT/"}" >>"$protected_list" + done + else + [[ -e "$ROOT/$pat" ]] || return 0 + echo "$pat" >>"$protected_list" + fi +} + +apply_negation_patterns() { + local -a negations=() + while IFS= read -r pat; do + [[ "$pat" == !* ]] || continue + negations+=("${pat:1}") + done < <(protect_patterns) + [[ "${#negations[@]}" -eq 0 ]] && return 0 + + local filtered + filtered="$(mktemp)" + while IFS= read -r rel; do + [[ -n "$rel" ]] || continue + local skip=0 neg + for neg in "${negations[@]}"; do + if path_matches_pattern "$rel" "$neg"; then + skip=1 + break + fi + done + [[ "$skip" -eq 0 ]] && echo "$rel" >>"$filtered" + done <"$protected_list" + mv "$filtered" "$protected_list" +} + +collect_protected() { + while IFS= read -r pat; do + [[ "$pat" == !* ]] && continue + collect_positive_pattern "$pat" + done < <(protect_patterns) + apply_negation_patterns +} + +backup_protected() { + : >"$manifest" + while IFS= read -r rel; do + [[ -n "$rel" && -e "$ROOT/$rel" ]] || continue + mkdir -p "$backup/$(dirname "$rel")" + cp -a "$ROOT/$rel" "$backup/$rel" + echo "$rel" >>"$manifest" + done <"$protected_list" +} + +collect_protected +protected_count="$(grep -c . "$protected_list" 2>/dev/null || echo 0)" +protected_count="$(echo "$protected_count" | tr -d '[:space:]')" +[[ "$protected_count" =~ ^[0-9]+$ ]] || protected_count=0 + +commit_count="$(git rev-list --count HEAD 2>/dev/null || echo 0)" +[[ -d .git ]] || commit_count=0 + +strategy='Merge upstream/main' +if [[ "$HARD" -eq 1 ]]; then + strategy='Hard reset to upstream/main' +elif [[ "$commit_count" -eq 0 ]]; then + strategy='First-time sync (upstream snapshot)' +fi + +platform_label="$(uname -s 2>/dev/null || echo Unix)" +case "$platform_label" in + Darwin) platform_label='macOS' ;; + Linux) platform_label='Linux' ;; +esac + +rule 'Overview' +kv 'Platform' "$platform_label (bash)" "$C_WHITE" +kv 'Repository' "$ROOT" "$C_WHITE" +kv 'Upstream' "$UPSTREAM_URL @ $UPSTREAM_BRANCH" "$C_WHITE" +kv 'Strategy' "$strategy" "$C_CYAN" +[[ "$DRY_RUN" -eq 1 ]] && kv 'Mode' 'Dry run (preview only)' "$C_YELLOW" + +rule 'Protected paths in this repo' +if [[ "$protected_count" -eq 0 ]]; then + plan_line '-' 'No protected project files in this repo (.env, .keys.*, bench output, etc.)' +else + while IFS= read -r rel; do + [[ -n "$rel" ]] && plan_line '+' "$rel" + done <"$protected_list" +fi + +rule 'Never touched (outside repo)' +plan_line '-' '$HOME/.config or AppData t3mp3st config' +plan_line '-' 'War Room browser localStorage' +plan_line '-' 'Codex / Hermes local agent auth' + +local_sha='(no commits yet)' +upstream_sha='(unknown)' +if git rev-parse HEAD >/dev/null 2>&1; then + local_sha="$(git rev-parse --short HEAD 2>/dev/null || echo '(no commits yet)')" +fi +if [[ "$DRY_RUN" -eq 1 ]]; then + upstream_sha='(preview)' +elif git rev-parse "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" >/dev/null 2>&1; then + upstream_sha="$(git rev-parse --short "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH")" +fi + +rule 'Revisions' +kv 'Local' "$local_sha" "$C_WHITE" +kv 'Upstream' "$upstream_sha" "$C_CYAN" + +plan_steps=() +if [[ "$protected_count" -gt 0 ]]; then + plan_steps+=("Back up $protected_count protected path(s) to a temp folder") +else + plan_steps+=('Skip in-repo backup (no protected project files found)') +fi +if [[ ! -d .git ]]; then + plan_steps+=('Initialize git and add upstream remote') +else + plan_steps+=('Use existing git repo and upstream remote') +fi +plan_steps+=("Fetch $UPSTREAM_REMOTE/$UPSTREAM_BRANCH") +if [[ "$HARD" -eq 1 ]]; then + plan_steps+=('Reset working tree to upstream/main') +elif [[ "$commit_count" -eq 0 ]]; then + plan_steps+=('Apply upstream snapshot to project files') +else + plan_steps+=('Merge upstream changes into your local branch') +fi +if [[ "$protected_count" -gt 0 ]]; then + plan_steps+=('Restore backed-up protected paths') +fi +plan_steps+=('Run npm install to refresh dependencies') + +rule 'Sync plan' +n=1 +for step in "${plan_steps[@]}"; do + printf ' %2d. %s\n' "$n" "$step" + n=$((n + 1)) +done + +if [[ "$FORCE" -eq 0 ]]; then + echo + printf 'Proceed with update? [y/N] ' + read -r ans + [[ "$ans" =~ ^[Yy]([Ee][Ss])?$ ]] || { echo '[!] Cancelled - no changes made.'; rm -rf "$backup"; trap - ERR; exit 0; } +fi + +STEP_TOTAL=2 +[[ "$protected_count" -gt 0 ]] && STEP_TOTAL=$((STEP_TOTAL + 2)) +STEP_INDEX=0 + +if [[ "$protected_count" -gt 0 ]]; then + start_step 'Backing up protected files' + if [[ "$DRY_RUN" -eq 1 ]]; then + echo ' [*] [dry-run] would back up protected paths' + else + backup_protected + printf ' [+] Backed up %d path(s)\n' "$protected_count" + fi +fi + +start_step 'Syncing from upstream' +if [[ "$DRY_RUN" -eq 1 ]]; then + [[ -d .git ]] || echo ' [*] [dry-run] would run: git init' + if [[ -d .git ]] && ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then + echo " [*] [dry-run] would run: git remote add $UPSTREAM_REMOTE $UPSTREAM_URL" + fi + echo " [*] [dry-run] would run: git fetch $UPSTREAM_REMOTE $UPSTREAM_BRANCH" + if [[ "$HARD" -eq 1 ]]; then + echo " [*] [dry-run] would run: git reset --hard $UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + elif [[ "$commit_count" -eq 0 ]]; then + echo ' [*] [dry-run] would apply upstream snapshot (checkout upstream/main)' + echo " [*] [dry-run] would run: git checkout -B $UPSTREAM_BRANCH $UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + else + echo " [*] [dry-run] would run: git merge $UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + fi +else + [[ -d .git ]] || git init + git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1 || git remote add "$UPSTREAM_REMOTE" "$UPSTREAM_URL" + git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" 2>&1 || true + + if [[ "$HARD" -eq 1 ]]; then + git reset --hard "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + elif [[ "$commit_count" -eq 0 ]]; then + find "$ROOT" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + git checkout -B "$UPSTREAM_BRANCH" "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + else + git merge "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" --no-edit + fi + echo ' [+] Git sync finished' +fi + +if [[ "$protected_count" -gt 0 ]]; then + start_step 'Restoring protected files' + if [[ "$DRY_RUN" -eq 1 ]]; then + echo ' [*] [dry-run] would restore protected paths' + else + while IFS= read -r rel; do + [[ -n "$rel" && -e "$backup/$rel" ]] || continue + mkdir -p "$(dirname "$ROOT/$rel")" + rm -rf "$ROOT/$rel" + cp -a "$backup/$rel" "$ROOT/$rel" + done <"$manifest" + echo ' [+] Protected paths restored' + fi +fi + +start_step 'Refreshing npm packages' +if [[ "$DRY_RUN" -eq 1 ]]; then + echo ' [*] [dry-run] would run: npm install' +else + npm install + echo ' [+] npm install finished' +fi + +new_sha="$upstream_sha" +if [[ "$DRY_RUN" -eq 0 ]] && git rev-parse HEAD >/dev/null 2>&1; then + new_sha="$(git rev-parse --short HEAD)" +fi + +rule 'Done' +if [[ "$DRY_RUN" -eq 1 ]]; then + echo ' Preview only - no files were changed.' + echo ' Run without --dry-run when you are ready.' +else + echo ' Update complete' + kv 'Before' "$local_sha" "$C_WHITE" + kv 'After' "$new_sha" "$C_GREEN" +fi +echo + +trap - ERR +[[ "$KEEP_BACKUP" -eq 1 ]] || rm -rf "$backup"