feat: add safe cross-platform upstream updater (#98)

* feat: add safe upstream update scripts

* fix(updater): use portable PowerShell temp path

---------

Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com>
This commit is contained in:
Joseph MaglyandWibias authored and GitHub committed 2026-07-15 19:56:25 -04:00
1 parent 266655abcf
commit 916c260fd8
8 files changed
+1710 -2

No files matched your search

+55 -1
View File
@@ -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.
+5 -1
View File
@@ -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",
+371
View File
@@ -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);
+9
View File
@@ -0,0 +1,9 @@
▄▄▄█████▓▓█████ ███▄ ▄███▓ ██▓███ ▓█████ ██████ ▄▄▄█████▓
▓ ██▒ ▓▒▓█ ▀ ▓██▒▀█▀ ██▒▓██░ ██▒▓█ ▀ ▒██ ▒ ▓ ██▒ ▓▒
▒ ▓██░ ▒░▒███ ▓██ ▓██░▓██░ ██▓▒▒███ ░ ▓██▄ ▒ ▓██░ ▒░
░ ▓██▓ ░ ▒▓█ ▄ ▒██ ▒██ ▒██▄█▓▒ ▒▒▓█ ▄ ▒ ██▒░ ▓██▓ ░
▒██▒ ░ ░▒████▒▒██▒ ░██▒▒██▒ ░ ░░▒████▒▒██████▒▒ ▒██▒ ░
▒ ░░ ░░ ▒░ ░░ ▒░ ░ ░▒▓▒░ ░ ░░░ ▒░ ░▒ ▒▓▒ ▒ ░ ▒ ░░
░ ░ ░ ░░ ░ ░░▒ ░ ░ ░ ░░ ░▒ ░ ░ ░
░ ░ ░ ░ ░░ ░ ░ ░ ░ ░
░ ░ ░ ░ ░ ░
+21
View File
@@ -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/
+141
View File
@@ -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);
+716
View File
@@ -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
}
+392
View File
@@ -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"