135 lines
4.5 KiB
JavaScript
Executable File
135 lines
4.5 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Password Reset CLI — T-38
|
||
*
|
||
* Usage:
|
||
* node bin/reset-password.mjs
|
||
* omniroute reset-password
|
||
*
|
||
* Non-interactive / scripted usage (piped stdin, e.g. CI or Docker):
|
||
* printf 'NewPass123\nNewPass123\n' | omniroute reset-password
|
||
* printf 'NewPass123' | omniroute reset-password --password-stdin
|
||
*
|
||
* Resets the admin password for OmniRoute.
|
||
* Prompts for a new password (interactive TTY) or reads it from stdin
|
||
* (non-TTY) and updates the database directly.
|
||
*
|
||
* @module bin/reset-password
|
||
*/
|
||
|
||
import { createInterface } from "node:readline";
|
||
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
|
||
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
|
||
|
||
// Resolve data directory — same logic as the server
|
||
const DATA_DIR = resolveDataDir();
|
||
const DB_PATH = resolveStoragePath(DATA_DIR);
|
||
|
||
const MIN_PASSWORD_LENGTH = 8;
|
||
|
||
/** Read the entire stdin stream as a UTF-8 string (used for non-TTY input). */
|
||
function readAllStdin() {
|
||
return new Promise((resolve) => {
|
||
let data = "";
|
||
process.stdin.setEncoding("utf8");
|
||
process.stdin.on("data", (chunk) => {
|
||
data += chunk;
|
||
});
|
||
process.stdin.on("end", () => resolve(data));
|
||
process.stdin.on("error", () => resolve(data));
|
||
// Resuming is implied by attaching a 'data' listener, but be explicit so a
|
||
// paused stream (some spawn setups) still flows to EOF.
|
||
process.stdin.resume();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Obtain the new password (and its confirmation).
|
||
*
|
||
* - `--password-stdin`: the ENTIRE stdin is the password, no confirmation.
|
||
* - non-TTY stdin (piped): read all of stdin once; first line is the password,
|
||
* second line — when present — is the confirmation, else the first line is
|
||
* reused (a single-line pipe means "no separate confirmation").
|
||
* - interactive TTY: two sequential prompts (unchanged behavior).
|
||
*
|
||
* The non-TTY path exists because two sequential `rl.question` promises never
|
||
* settle under a piped EOF — the second read blocks forever, so the reset was
|
||
* silently never applied (#6258).
|
||
*/
|
||
async function collectPassword() {
|
||
if (process.argv.includes("--password-stdin")) {
|
||
const raw = await readAllStdin();
|
||
const password = raw.replace(/[\r\n]+$/, "");
|
||
return { password, confirm: password };
|
||
}
|
||
|
||
if (!process.stdin.isTTY) {
|
||
const raw = await readAllStdin();
|
||
const lines = raw.split(/\r?\n/);
|
||
const password = lines[0] ?? "";
|
||
const confirm = lines[1] ? lines[1] : password;
|
||
return { password, confirm };
|
||
}
|
||
|
||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||
try {
|
||
const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
|
||
const password = await ask("Enter new password (min 8 chars): ");
|
||
const confirm = await ask("Confirm new password: ");
|
||
return { password, confirm };
|
||
} finally {
|
||
rl.close();
|
||
}
|
||
}
|
||
|
||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||
|
||
async function main() {
|
||
// Check if database exists
|
||
const passwordState = await readManagementPasswordState(DB_PATH);
|
||
if (!passwordState.exists) {
|
||
console.error(`❌ Database not found at: ${DB_PATH}`);
|
||
console.error(` Make sure OmniRoute has been started at least once.`);
|
||
console.error(` Or set DATA_DIR env var to your data directory.\n`);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (passwordState.hasPassword) {
|
||
console.log("ℹ️ A password is currently set.");
|
||
} else {
|
||
console.log("ℹ️ No password is currently set.");
|
||
}
|
||
|
||
const { password, confirm } = await collectPassword();
|
||
|
||
if (!password || password.length < MIN_PASSWORD_LENGTH) {
|
||
console.error(`\n❌ Password must be at least ${MIN_PASSWORD_LENGTH} characters.\n`);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (password !== confirm) {
|
||
console.error("\n❌ Passwords do not match.\n");
|
||
process.exit(1);
|
||
}
|
||
|
||
await resetManagementPassword(password, DB_PATH);
|
||
|
||
console.log("\n✅ Password reset successfully!");
|
||
console.log(" Restart OmniRoute for changes to take effect.\n");
|
||
}
|
||
|
||
main()
|
||
.then(() => {
|
||
// Explicit exit(0) so a caller that imports this module (bin/omniroute.mjs
|
||
// routes `omniroute reset-password` here) terminates cleanly instead of
|
||
// hanging / exiting with code 13 on an unsettled wrapper await. On POSIX,
|
||
// console.log to a pipe is synchronous, so the success line is already
|
||
// flushed by the time we exit.
|
||
process.exit(0);
|
||
})
|
||
.catch((err) => {
|
||
console.error(`\n❌ Error: ${err.message}\n`);
|
||
process.exit(1);
|
||
});
|