#!/usr/bin/env node // Local recovery only: requires filesystem/container access to the controller. import Database from 'better-sqlite3'; import { emitKeypressEvents } from 'node:readline'; import { hashPassword } from '../src/lib/server/auth/passwords.js'; function secret(prompt) { return new Promise((resolve, reject) => { let value = ''; process.stdout.write(prompt); emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); const cleanup = () => { process.stdin.off('keypress', listener); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\n'); }; const listener = (text, key) => { if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); } else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); } else if (key?.name === 'backspace') value = [...value].slice(0, -1).join(''); else if ( text && !key?.ctrl && !key?.meta && // eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input. !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128 ) value += text; }; process.stdin.on('keypress', listener); }); } try { const username = process.argv[2]?.toLowerCase(); if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs '); if (!process.stdin.isTTY) throw new Error( 'Run this command in an interactive terminal. Passwords are never accepted as command arguments.' ); const database = new Database(process.env.DB_PATH || './data/cherry.db', { fileMustExist: true }); database.pragma('busy_timeout=5000'); const user = database .prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0") .get(username); if (!user) throw new Error('An enabled administrator with that username was not found.'); const password = await secret('New administrator password (8–128 characters): '); const confirmation = await secret('Repeat password: '); if (password !== confirmation) throw new Error('Passwords did not match.'); const passwordHash = await hashPassword(password); database .transaction(() => { const changed = database .prepare( "UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0" ) .run(passwordHash, Date.now(), user.id); if (changed.changes !== 1) throw new Error('Administrator changed during recovery. Try again.'); database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id); database .prepare( 'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)' ) .run('password.recovered', user.id, '{"source":"local-cli"}', Date.now()); }) .immediate(); database.close(); console.log('Administrator password reset. Existing sessions have been revoked.'); } catch (cause) { console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.'); process.exitCode = 1; }