/* Manage the hub's users file. node --experimental-strip-types hub/users-cli.ts add node --experimental-strip-types hub/users-cli.ts passwd node --experimental-strip-types hub/users-cli.ts role node --experimental-strip-types hub/users-cli.ts disable node --experimental-strip-types hub/users-cli.ts enable node --experimental-strip-types hub/users-cli.ts list The file is `${HUB_DATA}/users.json` (default /data). On a terminal the password is asked twice without echo; in a script, put it in HUB_NEW_PASSWORD. In the container: docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin */ import path from 'node:path' import { fileURLToPath } from 'node:url' import { ROLES, addUser, describeUsers, readUsersFile, setDisabled, setPassword, setRole, writeUsersFile, type UserRecord, } from './users.ts' const USAGE = `Usage: users-cli add roles: ${ROLES.join(', ')} passwd role disable enable list The password is asked twice, or taken from HUB_NEW_PASSWORD.` type Env = Record /* Reads one line from the terminal without echoing it. */ function askHidden(prompt: string): Promise { return new Promise((resolve, reject) => { const stdin = process.stdin process.stderr.write(prompt) let line = '' stdin.setRawMode(true) stdin.resume() stdin.setEncoding('utf8') const done = (): void => { stdin.setRawMode(false) stdin.pause() stdin.off('data', onData) process.stderr.write('\n') } const onData = (chunk: string): void => { for (const ch of chunk) { if (ch === '\r' || ch === '\n') { done() resolve(line) return } if (ch === '\u0003') { done() reject(new Error('Cancelled.')) return } if (ch === '\u007f' || ch === '\b') line = line.slice(0, -1) else line += ch } } stdin.on('data', onData) }) } async function newPassword(env: Env): Promise { if (env.HUB_NEW_PASSWORD !== undefined && env.HUB_NEW_PASSWORD !== '') return env.HUB_NEW_PASSWORD if (!process.stdin.isTTY) throw new Error('No terminal: set HUB_NEW_PASSWORD.') const first = await askHidden('New password: ') const second = await askHidden('Repeat it: ') if (first !== second) throw new Error('The passwords differ.') return first } /* Runs one command against the users in `dir` and returns the lines to print. `password` supplies the password for add and passwd, so the tests need no terminal. */ export async function run( args: string[], dir: string, password: () => Promise, ): Promise { const [command, name, extra] = args const users: UserRecord[] = readUsersFile(dir) ?? [] const need = (value: string | undefined, what: string): string => { if (value === undefined || value === '') throw new Error(`Missing ${what}.\n${USAGE}`) return value } switch (command) { case 'add': writeUsersFile(dir, addUser(users, need(name, 'name'), need(extra, 'role'), await password())) return [`Added ${name} (${extra}).`] case 'passwd': writeUsersFile(dir, setPassword(users, need(name, 'name'), await password())) return [`Password of ${name} changed. Their sessions end on their next request.`] case 'role': writeUsersFile(dir, setRole(users, need(name, 'name'), need(extra, 'role'))) return [`${name} is now ${extra}.`] case 'disable': writeUsersFile(dir, setDisabled(users, need(name, 'name'), true)) return [`Disabled ${name}.`] case 'enable': writeUsersFile(dir, setDisabled(users, need(name, 'name'), false)) return [`Enabled ${name}.`] case 'list': return users.length === 0 ? ['No users.'] : describeUsers(users) default: throw new Error(USAGE) } } if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const dir = process.env.HUB_DATA?.trim() || '/data' run(process.argv.slice(2), dir, () => newPassword(process.env)).then( (lines) => { for (const line of lines) console.log(line) }, (e: unknown) => { console.error(`users: ${e instanceof Error ? e.message : String(e)}`) process.exit(1) }, ) }