Change how login works

This commit is contained in:
Raven Scott
2026-04-26 06:19:47 -04:00
parent 30e6cfc2ff
commit 09f164b8de
26 changed files with 1027 additions and 41792 deletions
+221 -85
View File
@@ -87,85 +87,102 @@ function bareOsEmitRaw(ctx, chunk) {
return false
}
function loginDecodeChunk(chunk) {
if (typeof chunk === 'string') return chunk
if (chunk instanceof Uint8Array) return new TextDecoder().decode(chunk)
if (
typeof Buffer !== 'undefined' &&
typeof Buffer.isBuffer === 'function' &&
Buffer.isBuffer(chunk)
) {
return chunk.toString('utf8')
/**
* Plain-stream masked line input (same pattern as agent --config / bareAgentPromptSetupLine).
* Used by /bin/login so the Fish REPL is suspended and stdin/stdout are the TTY streams.
*/
/**
* @param {Record<string, unknown> | undefined} ctx
* @param {import('stream').Writable | undefined} out
* @param {string} s
*/
function bareLoginInteractiveWrite(ctx, out, s) {
const text =
ctx && ctx.bareOsPtyStdoutCrlf
? String(s).replace(/\r?\n/g, '\r\n')
: String(s)
if (out && typeof out.write === 'function') {
try {
out.write(text)
} catch {
/* ignore */
}
}
return String(chunk)
}
async function loginReadMaskedLine(ctx, prompt) {
const stdin = ctx.stdin
const stdout = ctx.stdout || globalThis.process?.stdout
if (!stdin || !stdout || typeof stdin.on !== 'function' || typeof stdout.write !== 'function') {
if (typeof ctx.readLine === 'function') return String((await ctx.readLine(prompt)) || '')
return ''
}
stdout.write(prompt)
const ttyIn = stdin
if (!ttyIn.isTTY || typeof ttyIn.setRawMode !== 'function') {
return await new Promise((resolve) => {
let acc = ''
function onData(chunk) {
const s = loginDecodeChunk(chunk)
acc += s
const nl = acc.indexOf('\n')
if (nl >= 0) {
cleanup()
resolve(acc.slice(0, nl).replace(/\r$/, ''))
}
}
function onEnd() {
/**
* @param {import('stream').Readable} stdin
* @returns {Promise<string>}
*/
function bareLoginReadStreamLineOnce(stdin) {
return new Promise((resolve) => {
let acc = ''
/** @param {string | Uint8Array | Buffer} chunk */
function onData(chunk) {
let s = ''
if (typeof chunk === 'string') s = chunk
else if (chunk instanceof Uint8Array) s = new TextDecoder().decode(chunk)
else if (
typeof Buffer !== 'undefined' &&
typeof Buffer.isBuffer === 'function' &&
Buffer.isBuffer(chunk)
)
s = chunk.toString('utf8')
else s = String(chunk)
acc += s
const n = acc.indexOf('\n')
if (n >= 0) {
cleanup()
resolve(acc.replace(/\r$/, ''))
resolve(acc.slice(0, n).replace(/\r$/, ''))
}
function cleanup() {
stdin.removeListener('data', onData)
stdin.removeListener('end', onEnd)
stdin.removeListener('error', onEnd)
}
stdin.on('data', onData)
stdin.once('end', onEnd)
stdin.once('error', onEnd)
if (typeof stdin.resume === 'function') stdin.resume()
})
}
return await new Promise((resolve, reject) => {
const chars = []
let done = false
function finish(ok, err) {
if (done) return
done = true
}
function onEnd() {
cleanup()
stdout.write('\n')
if (ok) resolve(chars.join(''))
else reject(err || new Error('masked_input_failed'))
resolve(acc.replace(/\r$/, ''))
}
function cleanup() {
stdin.removeListener('data', onData)
stdin.removeListener('end', onEnd)
stdin.removeListener('error', onErr)
try {
ttyIn.setRawMode(false)
} catch {
/* ignore */
}
}
function onEnd() {
finish(true)
}
function onErr(e) {
finish(false, e instanceof Error ? e : new Error(String(e)))
stdin.removeListener('error', onEnd)
}
stdin.on('data', onData)
stdin.once('end', onEnd)
stdin.once('error', onEnd)
if (typeof stdin.resume === 'function') stdin.resume()
})
}
/**
* @param {Record<string, unknown>} ctx
* @param {import('stream').Readable} stdin
* @param {import('stream').Writable | undefined} stdout
* @returns {Promise<string>}
*/
function bareLoginReadMaskedLineOnce(ctx, stdin, stdout) {
const ttyIn = /** @type {{ setRawMode?: (v: boolean) => void, isTTY?: boolean }} */ (
stdin
)
if (!ttyIn || typeof ttyIn.setRawMode !== 'function' || !ttyIn.isTTY) {
return bareLoginReadStreamLineOnce(stdin)
}
return new Promise((resolve, reject) => {
/** @type {string[]} */
const chars = []
let done = false
/** @param {string | Uint8Array | Buffer} chunk */
function onData(chunk) {
if (done) return
const s = loginDecodeChunk(chunk)
let s = ''
if (typeof chunk === 'string') s = chunk
else if (chunk instanceof Uint8Array) s = new TextDecoder().decode(chunk)
else if (
typeof Buffer !== 'undefined' &&
typeof Buffer.isBuffer === 'function' &&
Buffer.isBuffer(chunk)
) {
s = chunk.toString('utf8')
} else s = String(chunk)
for (const ch of s) {
const code = ch.charCodeAt(0)
if (ch === '\r' || ch === '\n') {
@@ -179,21 +196,48 @@ async function loginReadMaskedLine(ctx, prompt) {
if (ch === '\u007f' || ch === '\b') {
if (chars.length) {
chars.pop()
stdout.write('\b \b')
bareLoginInteractiveWrite(ctx, stdout, '\b \b')
}
continue
}
if (code >= 32 && code !== 127) {
chars.push(ch)
stdout.write('*')
bareLoginInteractiveWrite(ctx, stdout, '*')
}
}
}
/** @param {boolean} ok @param {Error} [err] */
function finish(ok, err) {
if (done) return
done = true
cleanup()
if (ok) resolve(chars.join(''))
else reject(err || new Error('masked_input_failed'))
}
function cleanup() {
stdin.removeListener('data', onData)
stdin.removeListener('end', onEnd)
stdin.removeListener('error', onErr)
try {
ttyIn.setRawMode(false)
} catch {
/* ignore */
}
bareLoginInteractiveWrite(ctx, stdout, '\n')
}
function onEnd() {
finish(true)
}
/** @param {unknown} e */
function onErr(e) {
const msg =
e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e)
finish(false, new Error(msg))
}
try {
ttyIn.setRawMode(true)
} catch {
finish(true)
return
return resolve('')
}
stdin.on('data', onData)
stdin.once('end', onEnd)
@@ -202,30 +246,122 @@ async function loginReadMaskedLine(ctx, prompt) {
})
}
async function run(ctx, argv) {
const rest = argv.slice(1)
let createNew = false
if (rest[0] === '--new') {
createNew = true
rest.shift()
/**
* @param {Record<string, unknown>} ctx
* @param {string} prompt
* @returns {Promise<string>}
*/
async function bareLoginPromptMaskedLine(ctx, prompt) {
const stdin = /** @type {import('stream').Readable | undefined} */ (
ctx.replStdin || ctx.stdin
)
const stdout = /** @type {import('stream').Writable | undefined} */ (
ctx.replStdout || ctx.stdout
)
if (!stdin || typeof stdin.on !== 'function') {
throw new Error('login: stdin stream unavailable (need an interactive TTY)')
}
const passphrase = rest.length
? rest.join(' ')
: await loginReadMaskedLine(ctx, createNew ? 'New passphrase: ' : 'Passphrase: ')
if (!passphrase) {
ctx.console.error('usage: login [--new] [passphrase]')
bareLoginInteractiveWrite(ctx, stdout, '\x1b[?25h\x1b[0m' + prompt)
return bareLoginReadMaskedLineOnce(ctx, stdin, stdout)
}
async function run(ctx, argv) {
const argv0 = argv[0] || 'login'
const args = argv.slice(1)
if (args.includes('-h') || args.includes('--help')) {
ctx.console.log(
'usage: ' +
argv0 +
' [--new]\n\n' +
'Unlock or register your identity. Run this command with no passphrase on the line;\n' +
'then type your passphrase at the prompt (hidden). A passphrase may be several words.\n' +
'Passphrases must not be given as command-line arguments (they would appear in shell history).\n\n' +
' --new register a new identity instead of unlocking an existing one.'
)
ctx.exitCode = 0
return
}
let createNew = false
if (args[0] === '--new') {
createNew = true
args.shift()
}
if (args.length > 0) {
ctx.console.error(
'login: passphrase must not be given on the command line (it would appear in shell history).'
)
ctx.console.error('Run: login' + (createNew ? ' --new' : ''))
ctx.console.error(
'Then type your passphrase at the prompt. It may be multiple words; typing is hidden.'
)
ctx.exitCode = 1
return
}
const reg = ctx.applyRegister
const unlock = ctx.applyUnlock
if (typeof reg !== 'function' || typeof unlock !== 'function') {
ctx.console.error('login: not supported in this environment')
ctx.exitCode = 1
return
}
const stdin = ctx.replStdin || ctx.stdin
const stdout = ctx.replStdout || ctx.stdout
const tty = /** @type {{ isTTY?: boolean }} */ (stdin)
if (
!stdin ||
typeof stdin.on !== 'function' ||
!tty.isTTY ||
!stdout ||
typeof stdout.write !== 'function'
) {
ctx.console.error(
'login: needs an interactive TTY (same as agent --config). Run from the shell prompt on a terminal.'
)
ctx.exitCode = 1
return
}
let suspended = false
try {
if (createNew) await reg.call(ctx, passphrase)
else await unlock.call(ctx, passphrase)
} catch (err) {
ctx.console.error(err?.message ?? String(err))
if (typeof ctx.suspendReplForSubprocess === 'function') {
ctx.suspendReplForSubprocess()
suspended = true
}
ctx.console.log(
createNew
? 'Creating a new identity. Choose a passphrase (multiple words allowed); typing is hidden.'
: 'Unlocking. Enter your passphrase (multiple words allowed); typing is hidden.'
)
let passphrase
try {
passphrase = await bareLoginPromptMaskedLine(
ctx,
createNew ? 'New passphrase: ' : 'Passphrase: '
)
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 1
return
}
if (!String(passphrase || '').trim()) {
ctx.console.error('login: empty passphrase')
ctx.exitCode = 1
return
}
try {
if (createNew) await reg.call(ctx, String(passphrase))
else await unlock.call(ctx, String(passphrase))
ctx.exitCode = 0
} catch (err) {
ctx.console.error(err?.message ?? String(err))
ctx.exitCode = 1
}
} finally {
if (suspended && typeof ctx.resumeReplAfterSubprocess === 'function') {
ctx.resumeReplAfterSubprocess()
}
}
}