Merge remote-tracking branch 'upstream/main' into pr-70
# Conflicts: # src/config/index.ts # src/llm/index.ts # src/server.ts # src/setup.ts # src/types/index.ts
This commit is contained in:
75 files changed
+7788
-586
No files matched your search
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { posix, resolve, win32 } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const rawBaseUrl = process.env.T3MP3ST_API_URL || 'http://127.0.0.1:3333';
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
const strictMode = process.argv.includes('--strict');
|
||||
const checks = [];
|
||||
|
||||
function check(name, passed, detail = '', severity = 'block') {
|
||||
checks.push({ name, passed: Boolean(passed), detail, severity });
|
||||
}
|
||||
|
||||
export function validateBaseUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
const loopbackIpv4 = /^127(?:\.\d{1,3}){3}$/.test(hostname)
|
||||
&& hostname.split('.').every(part => Number(part) <= 255);
|
||||
const loopback = hostname === 'localhost' || hostname === '::1' || loopbackIpv4;
|
||||
const rootPath = url.pathname === '' || url.pathname === '/';
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol must be http or https');
|
||||
if (!loopback) throw new Error('host must be loopback (localhost, 127.0.0.0/8, or ::1)');
|
||||
if (url.username || url.password) throw new Error('credentials are not allowed in the API URL');
|
||||
if (!rootPath || url.search || url.hash) throw new Error('API URL must be an origin without a path, query, or fragment');
|
||||
return { ok: true, url: url.origin };
|
||||
} catch (error) {
|
||||
return { ok: false, url: value, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function commandCandidates(binary, platform, env) {
|
||||
const windows = platform === 'win32';
|
||||
const pathApi = windows ? win32 : posix;
|
||||
const separator = windows ? ';' : ':';
|
||||
const pathValue = env.PATH || env.Path || env.path || '';
|
||||
const directories = pathValue.split(separator).map(item => item.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
||||
const hasExtension = windows && win32.extname(binary) !== '';
|
||||
const extensions = windows && !hasExtension
|
||||
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
||||
: [''];
|
||||
return directories.flatMap(directory => extensions.map(extension => pathApi.join(directory, `${binary}${extension}`)));
|
||||
}
|
||||
|
||||
export async function commandExists(binary, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const env = options.env || process.env;
|
||||
const accessFn = options.accessFn || access;
|
||||
const mode = platform === 'win32' ? constants.F_OK : constants.X_OK;
|
||||
for (const candidate of commandCandidates(binary, platform, env)) {
|
||||
try {
|
||||
await accessFn(candidate, mode);
|
||||
return true;
|
||||
} catch {
|
||||
// Keep searching PATH candidates.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function apiGet(baseUrl, path) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
const text = await response.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
return { ok: response.ok, status: response.status, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function apiFailure(result) {
|
||||
if (result.status) return `HTTP ${result.status}`;
|
||||
return result.error || 'request failed';
|
||||
}
|
||||
|
||||
function finish(startedAt, baseUrl, forcedPass) {
|
||||
const blockers = checks.filter(item => !item.passed && item.severity === 'block');
|
||||
const warnings = checks.filter(item => !item.passed && item.severity === 'warn');
|
||||
const passed = forcedPass ?? (blockers.length === 0 && (!strictMode || warnings.length === 0));
|
||||
const result = {
|
||||
tool: 't3mp3st-ops-preflight',
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
baseUrl,
|
||||
passed,
|
||||
summary: {
|
||||
checks: checks.length,
|
||||
passed: checks.filter(item => item.passed).length,
|
||||
warnings: warnings.length,
|
||||
blockers: blockers.length,
|
||||
strict: strictMode,
|
||||
},
|
||||
checks,
|
||||
};
|
||||
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`T3MP3ST ops preflight: ${passed ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`${result.summary.passed}/${result.summary.checks} checks passed, ${warnings.length} warning(s), ${blockers.length} blocker(s)`);
|
||||
for (const item of checks) {
|
||||
const marker = item.passed ? 'ok' : item.severity === 'warn' ? 'warn' : 'block';
|
||||
console.log(`- ${marker.padEnd(5)} ${item.name}${item.detail ? ` - ${item.detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = passed ? 0 : 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const startedAt = new Date().toISOString();
|
||||
const baseUrlResult = validateBaseUrl(rawBaseUrl);
|
||||
check('Local API URL', baseUrlResult.ok, baseUrlResult.ok ? baseUrlResult.url : baseUrlResult.error, 'block');
|
||||
if (!baseUrlResult.ok) return finish(startedAt, rawBaseUrl, false);
|
||||
const baseUrl = baseUrlResult.url;
|
||||
|
||||
const health = await apiGet(baseUrl, '/health');
|
||||
const apiReachable = health.ok;
|
||||
check('API health endpoint', apiReachable,
|
||||
apiReachable ? `HTTP ${health.status}` : `${apiFailure(health)} - run npm run server`, 'block');
|
||||
|
||||
if (!apiReachable) {
|
||||
check('Node runtime', true, `${process.execPath} (${process.version})`, 'warn');
|
||||
const npmAvailable = Boolean(process.env.npm_execpath) || await commandExists('npm');
|
||||
check('npm available', npmAvailable, npmAvailable ? 'available' : 'not found on PATH', 'warn');
|
||||
return finish(startedAt, baseUrl, false);
|
||||
}
|
||||
|
||||
check('API reports operational', health.data?.status === 'operational',
|
||||
`status: ${health.data?.status || 'unknown'}`, 'block');
|
||||
|
||||
const mission = await apiGet(baseUrl, '/api/mission/status');
|
||||
const missionShapeValid = mission.ok && typeof mission.data?.active === 'boolean';
|
||||
check('Mission status available', missionShapeValid,
|
||||
missionShapeValid ? 'verified' : mission.ok ? 'invalid response shape' : apiFailure(mission), 'block');
|
||||
if (missionShapeValid) {
|
||||
const missionActive = mission.data.active === true;
|
||||
const activeMission = mission.data.mission || {};
|
||||
const activeDetail = [
|
||||
`mission ${activeMission.id || mission.data.name || 'unknown'}`,
|
||||
activeMission.currentPhase ? `phase ${activeMission.currentPhase}` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
check('No active mission in progress', !missionActive, missionActive ? activeDetail : 'idle', 'block');
|
||||
}
|
||||
|
||||
const approvals = await apiGet(baseUrl, '/api/approvals?status=pending');
|
||||
const approvalsShapeValid = approvals.ok && Array.isArray(approvals.data?.approvals);
|
||||
check('Approval receipt status available', approvalsShapeValid,
|
||||
approvalsShapeValid ? 'verified' : approvals.ok ? 'invalid response shape' : apiFailure(approvals), 'block');
|
||||
if (approvalsShapeValid) {
|
||||
const pendingCount = approvals.data.approvals.length;
|
||||
check('No pending action receipts', pendingCount === 0,
|
||||
pendingCount > 0 ? `${pendingCount} pending approval(s) - review before launch` : 'none pending', 'warn');
|
||||
}
|
||||
|
||||
const llmStatus = health.data?.llm;
|
||||
const serverBackendAvailable = llmStatus?.configured === true || llmStatus?.connected === true;
|
||||
const localAgent = await apiGet(baseUrl, '/api/agents/local/status');
|
||||
const localAgentShapeValid = localAgent.ok && Array.isArray(localAgent.data?.connected);
|
||||
const connectedAgents = localAgentShapeValid ? localAgent.data.connected : [];
|
||||
const backendAvailable = serverBackendAvailable || connectedAgents.length > 0;
|
||||
let backendDetail;
|
||||
if (serverBackendAvailable) {
|
||||
backendDetail = `${llmStatus.provider || 'server'} backend ${llmStatus.connected ? 'connected' : 'configured'}`;
|
||||
} else if (connectedAgents.length > 0) {
|
||||
const firstAgent = connectedAgents[0];
|
||||
backendDetail = `${firstAgent.name || firstAgent.id || 'local agent'} connected`;
|
||||
} else if (!localAgentShapeValid) {
|
||||
backendDetail = `server LLM unavailable; local-agent status ${localAgent.ok ? 'has invalid shape' : apiFailure(localAgent)}`;
|
||||
} else {
|
||||
backendDetail = 'no server LLM or connected local agent - configure War Room Settings';
|
||||
}
|
||||
check('LLM backend available', backendAvailable, backendDetail, 'warn');
|
||||
|
||||
const arsenal = await apiGet(baseUrl, '/api/arsenal/status');
|
||||
const arsenalShapeValid = arsenal.ok
|
||||
&& Number.isFinite(arsenal.data?.summary?.installedCommandReady)
|
||||
&& Number.isFinite(arsenal.data?.summary?.commandReady);
|
||||
check('Arsenal status available', arsenalShapeValid,
|
||||
arsenalShapeValid ? 'verified' : arsenal.ok ? 'invalid response shape' : apiFailure(arsenal), 'warn');
|
||||
if (arsenalShapeValid) {
|
||||
const installed = arsenal.data.summary.installedCommandReady;
|
||||
const total = arsenal.data.summary.commandReady;
|
||||
check('Arsenal tools available', installed > 0,
|
||||
`${installed}/${total} command-ready tools installed`, 'warn');
|
||||
}
|
||||
|
||||
return finish(startedAt, baseUrl);
|
||||
}
|
||||
|
||||
const invokedAsScript = process.argv[1]
|
||||
&& import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
||||
if (invokedAsScript) {
|
||||
main().catch(error => {
|
||||
console.error(`ops-preflight failed: ${error?.stack || error?.message || error}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { commandExists, validateBaseUrl } from './ops-preflight.mjs';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const preflightPath = join(scriptDir, 'ops-preflight.mjs');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function healthyRoutes(overrides = {}) {
|
||||
return {
|
||||
'/health': {
|
||||
status: 'operational',
|
||||
llm: { configured: true, connected: true, provider: 'mock' },
|
||||
},
|
||||
'/api/mission/status': { active: false, progress: [], tasks: [] },
|
||||
'/api/approvals?status=pending': { approvals: [] },
|
||||
'/api/agents/local/status': { connected: [] },
|
||||
'/api/arsenal/status': {
|
||||
summary: { installedCommandReady: 2, commandReady: 4 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(routes, fn) {
|
||||
const server = createServer((req, res) => {
|
||||
const route = routes[req.url];
|
||||
if (!route) {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
const status = route.httpStatus || 200;
|
||||
const headers = { 'content-type': 'application/json', ...(route.headers || {}) };
|
||||
res.writeHead(status, headers);
|
||||
res.end(route.raw ?? JSON.stringify(route.body ?? route));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
return await fn(url);
|
||||
} finally {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function runPreflight(baseUrl, args = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [preflightPath, '--json', ...args], {
|
||||
env: { ...process.env, T3MP3ST_API_URL: baseUrl },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', code => {
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
reject(new Error(`invalid JSON output (exit ${code}): ${error.message}\nstdout=${stdout}\nstderr=${stderr}`));
|
||||
return;
|
||||
}
|
||||
resolve({ code, result, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findCheck(result, name) {
|
||||
const item = result.checks.find(check => check.name === name);
|
||||
assert(item, `missing check: ${name}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` OK ${name}`);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(` FAIL ${name}: ${error.stack || error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n======== ops preflight tests ========\n');
|
||||
|
||||
await test('accepts loopback origins and rejects non-local or credentialed URLs', async () => {
|
||||
assert.equal(validateBaseUrl('http://127.0.0.1:3333').ok, true);
|
||||
assert.equal(validateBaseUrl('https://localhost:3333/').ok, true);
|
||||
assert.equal(validateBaseUrl('http://[::1]:3333').ok, true);
|
||||
assert.equal(validateBaseUrl('https://example.com').ok, false);
|
||||
assert.equal(validateBaseUrl('https://127.example.com').ok, false);
|
||||
assert.equal(validateBaseUrl('http://user:secret@127.0.0.1:3333').ok, false);
|
||||
assert.equal(validateBaseUrl('http://127.0.0.1:3333/api').ok, false);
|
||||
});
|
||||
|
||||
await test('resolves command candidates portably without invoking a shell', async () => {
|
||||
const windowsAttempts = [];
|
||||
const windowsFound = await commandExists('npm', {
|
||||
platform: 'win32',
|
||||
env: { PATH: 'C:\\Tools;D:\\Node', PATHEXT: '.EXE;.CMD' },
|
||||
accessFn: async candidate => {
|
||||
windowsAttempts.push(candidate);
|
||||
if (candidate.toLowerCase() === 'd:\\node\\npm.cmd') return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
});
|
||||
assert.equal(windowsFound, true);
|
||||
assert(windowsAttempts.some(candidate => candidate.toLowerCase() === 'd:\\node\\npm.cmd'));
|
||||
|
||||
const posixFound = await commandExists('npm', {
|
||||
platform: 'linux',
|
||||
env: { PATH: '/usr/bin:/opt/node/bin' },
|
||||
accessFn: async candidate => {
|
||||
if (candidate === '/opt/node/bin/npm') return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
});
|
||||
assert.equal(posixFound, true);
|
||||
});
|
||||
|
||||
await test('passes a healthy idle War Room using server-authoritative backend state', async () => {
|
||||
await withServer(healthyRoutes(), async url => {
|
||||
const { code, result, stderr } = await runPreflight(url);
|
||||
assert.equal(stderr, '');
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.passed, true);
|
||||
assert.equal(findCheck(result, 'LLM backend available').passed, true);
|
||||
assert.equal(findCheck(result, 'No active mission in progress').detail, 'idle');
|
||||
});
|
||||
});
|
||||
|
||||
await test('blocks an active mission and reports current nested mission fields', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/mission/status': {
|
||||
active: true,
|
||||
mission: { id: 'mission-123', currentPhase: 'recon' },
|
||||
},
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const { code, result } = await runPreflight(url);
|
||||
assert.equal(code, 1);
|
||||
const item = findCheck(result, 'No active mission in progress');
|
||||
assert.equal(item.passed, false);
|
||||
assert.match(item.detail, /mission-123/);
|
||||
assert.match(item.detail, /recon/);
|
||||
});
|
||||
});
|
||||
|
||||
await test('warns for pending approvals and strict mode fails the warning', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/approvals?status=pending': { approvals: [{ id: 'approval-1' }] },
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const normal = await runPreflight(url);
|
||||
assert.equal(normal.code, 0);
|
||||
assert.equal(findCheck(normal.result, 'No pending action receipts').passed, false);
|
||||
const strict = await runPreflight(url, ['--strict']);
|
||||
assert.equal(strict.code, 1);
|
||||
assert.equal(strict.result.passed, false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('blocks when mission state cannot be verified', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/mission/status': { httpStatus: 503, body: { error: 'unavailable' } },
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const { code, result } = await runPreflight(url);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(findCheck(result, 'Mission status available').passed, false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('blocks when approval state is malformed instead of assuming zero', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/approvals?status=pending': { total: 0 },
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const { code, result } = await runPreflight(url);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(findCheck(result, 'Approval receipt status available').passed, false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('uses health LLM state when the local-agent endpoint is unavailable', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/agents/local/status': { httpStatus: 500, body: { error: 'unavailable' } },
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const { code, result } = await runPreflight(url);
|
||||
assert.equal(code, 0);
|
||||
assert.equal(findCheck(result, 'LLM backend available').passed, true);
|
||||
});
|
||||
});
|
||||
|
||||
await test('reports a missing backend and strict mode treats it as a failure', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/health': {
|
||||
status: 'operational',
|
||||
llm: { configured: false, connected: false, provider: 'openrouter' },
|
||||
},
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const normal = await runPreflight(url);
|
||||
assert.equal(normal.code, 0);
|
||||
assert.equal(findCheck(normal.result, 'LLM backend available').passed, false);
|
||||
const strict = await runPreflight(url, ['--strict']);
|
||||
assert.equal(strict.code, 1);
|
||||
});
|
||||
});
|
||||
|
||||
await test('reports arsenal endpoint failure and strict mode treats it as a failure', async () => {
|
||||
const routes = healthyRoutes({
|
||||
'/api/arsenal/status': { httpStatus: 500, body: { error: 'unavailable' } },
|
||||
});
|
||||
await withServer(routes, async url => {
|
||||
const normal = await runPreflight(url);
|
||||
assert.equal(normal.code, 0);
|
||||
assert.equal(findCheck(normal.result, 'Arsenal status available').passed, false);
|
||||
const strict = await runPreflight(url, ['--strict']);
|
||||
assert.equal(strict.code, 1);
|
||||
});
|
||||
});
|
||||
|
||||
await test('rejects a non-loopback API URL before making a request', async () => {
|
||||
const { code, result } = await runPreflight('https://example.com');
|
||||
assert.equal(code, 1);
|
||||
assert.equal(result.checks.length, 1);
|
||||
assert.equal(findCheck(result, 'Local API URL').passed, false);
|
||||
});
|
||||
|
||||
console.log(`\n======== ${failed ? `${failed} FAILED` : 'ALL PASS'} (${passed} passed) ========\n`);
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,9 @@
|
||||
▄▄▄█████▓▓█████ ███▄ ▄███▓ ██▓███ ▓█████ ██████ ▄▄▄█████▓
|
||||
▓ ██▒ ▓▒▓█ ▀ ▓██▒▀█▀ ██▒▓██░ ██▒▓█ ▀ ▒██ ▒ ▓ ██▒ ▓▒
|
||||
▒ ▓██░ ▒░▒███ ▓██ ▓██░▓██░ ██▓▒▒███ ░ ▓██▄ ▒ ▓██░ ▒░
|
||||
░ ▓██▓ ░ ▒▓█ ▄ ▒██ ▒██ ▒██▄█▓▒ ▒▒▓█ ▄ ▒ ██▒░ ▓██▓ ░
|
||||
▒██▒ ░ ░▒████▒▒██▒ ░██▒▒██▒ ░ ░░▒████▒▒██████▒▒ ▒██▒ ░
|
||||
▒ ░░ ░░ ▒░ ░░ ▒░ ░ ░▒▓▒░ ░ ░░░ ▒░ ░▒ ▒▓▒ ▒ ░ ▒ ░░
|
||||
░ ░ ░ ░░ ░ ░░▒ ░ ░ ░ ░░ ░▒ ░ ░ ░
|
||||
░ ░ ░ ░ ░░ ░ ░ ░ ░ ░
|
||||
░ ░ ░ ░ ░ ░
|
||||
@@ -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/
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -159,9 +159,9 @@ check('VERIFY gate implemented', /VERIFY gate|never appeared in tool output/.tes
|
||||
check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot');
|
||||
|
||||
// ── CLAIM 4: capability breadth ─────────────────────────────────────────────
|
||||
console.log('\nCLAIM 4 — capability: 83 tools, 8-operator kill-chain');
|
||||
console.log('\nCLAIM 4 — capability: 108 tools, 8-operator kill-chain');
|
||||
// DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom
|
||||
// built-in tools (index.ts BUILTIN_TOOLS top-level name:). NOT a name:/id: regex count
|
||||
// built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count
|
||||
// (that double-counts each tool's id+name AND every parameter name).
|
||||
const catalogSrc = fs.existsSync(R('src/arsenal/catalog.ts')) ? fs.readFileSync(R('src/arsenal/catalog.ts'), 'utf8') : '';
|
||||
const indexSrc = fs.existsSync(R('src/arsenal/index.ts')) ? fs.readFileSync(R('src/arsenal/index.ts'), 'utf8') : '';
|
||||
@@ -229,6 +229,48 @@ console.log('\nCLAIM 7 — CVE-Zero-v2 (held-out): hunt generalizes to fresh uns
|
||||
}
|
||||
}
|
||||
|
||||
// ── INTEGRITY GATE: every scored solve is oracle-grounded (no phantom pass) ───
|
||||
// Every check above credits a solve straight from the committed `verdict` (detected /
|
||||
// score===1). This gate closes the obvious tamper hole in that trust: a scored solve must
|
||||
// still AGREE with its own committed flag oracle. A solve is oracle-grounded iff EITHER the
|
||||
// agent's self-reported flag matches the committed `expected` under the same case-insensitive
|
||||
// compare the harness uses (ciMatch), OR the harness auto-detected `expected` verbatim in tool
|
||||
// output (auto_detected — the agent didn't self-report, so `reported` may be "UNKNOWN").
|
||||
// Anything scored as a solve that is NEITHER is a phantom pass: a hand-edited `detected:true`
|
||||
// whose committed oracle does not back it. This wires ciMatch (until now defined but unused)
|
||||
// into an executable gate, and rejects a fabricated placeholder credited via the self-report path.
|
||||
console.log('\nINTEGRITY GATE — every committed solve is oracle-grounded (no phantom pass, no fabricated flag)');
|
||||
const oracleGrounded = (v) =>
|
||||
ciMatch(v.reported, v.expected) || v.auto_detected === true || /auto-?detected/i.test(String(v.reason || ''));
|
||||
const scanSolves = (files) => {
|
||||
let solves = 0, phantom = 0, fabricated = 0, grounded = 0;
|
||||
for (const f of files) {
|
||||
const v = (((JSON.parse(fs.readFileSync(f, 'utf8')).results) || [{}])[0] || {}).verdict || {};
|
||||
if (!(v.detected === true || v.score === 1)) continue;
|
||||
solves++;
|
||||
if (oracleGrounded(v)) grounded++; else phantom++;
|
||||
// A fabricated placeholder may only ever be "credited" via the self-report path.
|
||||
if (ciMatch(v.reported, v.expected) && looksFabricated(v.reported)) fabricated++;
|
||||
}
|
||||
return { solves, phantom, fabricated, grounded };
|
||||
};
|
||||
const xbenFiles = [...BESTBALL_DIRS, ...WHITEBOX_DIRS].flatMap((sub) =>
|
||||
glob(R('bench/xbow/results/' + sub), /^xben_\d+_24.*\.json$/));
|
||||
const cybenchFiles = [...glob(R('bench/cybench/results'), /^clean-.*\.json$/),
|
||||
...glob(R('bench/cybench/results'), /^service-cybsvc_.*\.json$/)];
|
||||
const ig = scanSolves([...xbenFiles, ...cybenchFiles]);
|
||||
check('every scored solve is oracle-grounded (self-report ≡ committed oracle, or auto-detected)',
|
||||
ig.phantom === 0, `${ig.phantom} phantom / ${ig.solves} scored solves`);
|
||||
check('no fabricated placeholder flag is credited as a self-reported solve',
|
||||
ig.fabricated === 0, `${ig.fabricated} fabricated / ${ig.solves} scored solves`);
|
||||
// Informational — how much of the Cybench headline is backed by an oracle-bearing per-run
|
||||
// artifact vs. counted only in the hand-editable aggregate (transcripts are stripped on export).
|
||||
{
|
||||
const cRun = scanSolves(cybenchFiles);
|
||||
const aggSolved = fs.existsSync(cf) ? JSON.parse(fs.readFileSync(cf, 'utf8')).solved : null;
|
||||
console.log(` ℹ️ Cybench: aggregate reports ${aggSolved} solved · ${cRun.grounded} oracle-grounded per-run artifact(s) committed · ${(aggSolved ?? 0) - cRun.grounded} aggregate-only`);
|
||||
}
|
||||
|
||||
// ── verdict ─────────────────────────────────────────────────────────────────
|
||||
console.log(`\n════════ ${fail === 0 ? '✅ ALL CLAIMS VERIFIED' : `❌ ${fail} CHECK(S) FAILED`} — ${pass} passed, ${fail} failed ════════\n`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user