theseus/bundled-addons/pithos/core/login.js

186 lines
6.8 KiB
JavaScript
Raw Normal View History

// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase
// with term.ReadPassword, which needs a real console, so plain pipes fail.
// The phrase only ever lives in this process's memory: it is written to the
// pty and never logged or emitted except the one-time display of a phrase
// s3d generated, which the user has to copy down.
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
let ptyModule;
async function loadPty() {
if (ptyModule === undefined) {
try { ptyModule = await import('@lydell/node-pty'); } catch { ptyModule = null; }
}
return ptyModule && (ptyModule.default || ptyModule);
}
export class LoginSession extends EventEmitter {
// registration: async () => ({ registered, indexerUrl }), used to learn the
// outcome when login ran in a console window we cannot read.
constructor(daemon, { registration } = {}) {
super();
this.daemon = daemon;
this.registration = registration;
this.term = null;
this.buffer = '';
this.state = 'idle';
// idle | indexer | phrase | confirm | approve | registering | done | already | failed
this.info = {};
}
snapshot() { return { state: this.state, ...this.info }; }
set(state, info = {}) {
this.state = state;
Object.assign(this.info, info);
this.emit('state', this.snapshot());
}
async start({ indexerUrl, phrase }) {
if (this.term) throw new Error('a login is already in progress');
this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' };
this.info = {};
const pty = await loadPty();
if (!pty) return this.startInConsole();
this.buffer = '';
this.generated = null;
this.lastError = null;
const term = pty.spawn(this.daemon.binary, ['login'], {
name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(),
});
this.term = term;
this.set('starting');
// A cancelled session's pty can still flush output or exit after a new
// one started; only the current term may drive the state machine.
term.onData((d) => {
if (this.term !== term) return;
try { this.onData(d); } catch (e) { this.fail(e.message); }
});
term.onExit(({ exitCode }) => {
if (this.term !== term) return;
this.term = null;
this.answers = null;
if (!['done', 'already'].includes(this.state)) {
this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` });
}
});
return this.snapshot();
}
// Fallback when the pty module is not shipped (the Theseus add-on): run
// s3d login in its own console window and check the result when it closes.
startInConsole() {
if (process.platform !== 'win32') {
throw new Error('run "s3d login" in a terminal on this machine, then reload this page');
}
// detached gives a console app its own visible console window on Windows.
const child = spawn(this.daemon.binary, ['login'], {
cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false,
});
this.term = { kill: () => child.kill(), write() {} };
const term = this.term;
this.answers = null;
this.set('terminal');
child.on('error', (e) => { if (this.term === term) this.fail(e.message); });
child.on('exit', async () => {
if (this.term !== term) return;
this.term = null;
try {
const r = await this.registration();
if (r.registered) this.set('done', { indexerUrl: r.indexerUrl });
else this.set('failed', { error: 'the login window closed before registration finished' });
} catch (e) { this.set('failed', { error: e.message }); }
});
return this.snapshot();
}
// The user confirms they stored a phrase s3d generated; we echo it back.
confirmGenerated() {
if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm');
this.write(this.generated);
this.generated = null;
this.info.generatedPhrase = undefined;
this.set('approve-wait');
}
cancel() {
if (this.term) { try { this.term.kill(); } catch {} }
this.term = null;
this.answers = null;
this.generated = null;
this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined });
}
write(s) { this.term?.write(s + '\r'); }
onData(chunk) {
this.buffer += chunk.replace(ANSI, '');
// Work line-by-line, but prompts have no trailing newline, so also look at
// the unterminated tail.
const text = this.buffer;
// Checked first: s3d prints the error and re-prompts in the same chunk,
// and answering that prompt with a blank would make it generate a phrase.
const bad = text.match(/Invalid recovery phrase: (.+)$/m);
if (bad) {
// s3d would re-prompt forever; stop and let the user retype.
return this.fail(`invalid recovery phrase: ${bad[1].trim()}`);
}
if (/already registered with (\S+?)\.?\s*$/m.test(text)) {
const m = text.match(/already registered with (\S+?)\.?\s*$/m);
this.buffer = '';
return this.set('already', { indexerUrl: m[1] });
}
if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) {
this.buffer = '';
this.set('indexer');
return this.write(this.answers.indexerUrl);
}
if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) {
// s3d generated a phrase because we sent a blank one.
this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim();
}
if (/Confirm recovery phrase:\s*$/.test(text)) {
this.buffer = '';
if (!this.generated) return this.fail('could not read the generated recovery phrase');
return this.set('confirm', { generatedPhrase: this.generated });
}
if (/Enter recovery phrase:\s*$/.test(text)) {
this.buffer = '';
this.set('phrase');
const p = this.answers.phrase;
this.answers.phrase = '';
return this.write(p);
}
const url = text.match(/visiting the following URL:\s*(\S+)/);
if (url) {
this.buffer = text.slice(url.index + url[0].length);
return this.set('approve', { approvalUrl: url[1] });
}
if (/Login successful/.test(text)) {
this.buffer = '';
return this.set('done');
}
if (/app connection was declined/.test(text)) {
this.buffer = '';
return this.fail('the connection was declined on the indexer');
}
const fatal = text.match(/^(failed to [^\n]+)$/m);
if (fatal) this.lastError = fatal[1].trim();
// Keep the tail bounded; anything we care about is short.
if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048);
}
fail(error) {
const term = this.term;
this.term = null;
this.answers = null;
this.generated = null;
this.set('failed', { error, generatedPhrase: undefined });
if (term) { try { term.kill(); } catch {} }
}
}