0
0
Fork 0

tui: wire approve dispatch in bin entry

phobiCS-tui dispatcher:
- --version / -v: print version, exit 0
- approve <existing> <proposed>: read both files, render <Approve>,
  exit 0 on accept / 1 on reject / 2 on bad args or read error
- no args: print 'browser mode coming' placeholder, exit 0
- SIGTERM/SIGINT during approve: unmount + exit 1

Integration tests cover the no-TTY paths (--version, missing file,
missing args). Accept/reject keybinds are covered by the
ink-testing-library tests in Approve.test.tsx; piping stdin into a
no-TTY ink process is flaky on Windows so we don't try to
integration-test that path.

The shebang line is intentionally absent from the source; the build
script prepends one to dist/tui/index.js.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 22:19:51 +02:00
parent 884de61cd7
commit 357c1d11c5
2 changed files with 96 additions and 5 deletions

View file

@ -1,12 +1,61 @@
import React from 'react';
import { render } from 'ink';
import { Text } from 'ink';
import * as fs from 'fs/promises';
import { Approve, Decision } from './approve/Approve.js';
const argv = process.argv.slice(2);
if (argv[0] === '--version' || argv[0] === '-v') {
process.stdout.write('phobiCS-tui v0.1.0\n');
process.exit(0);
async function main(): Promise<number> {
if (argv[0] === '--version' || argv[0] === '-v') {
process.stdout.write('phobiCS-tui v0.1.0\n');
return 0;
}
if (argv[0] === 'approve') {
return runApprove(argv[1], argv[2]);
}
process.stdout.write('phobiCS-tui — browser mode coming in a later task\n');
return 0;
}
render(<Text>phobiCS-tui coming soon</Text>);
async function runApprove(oldPath: string | undefined, newPath: string | undefined): Promise<number> {
if (!oldPath || !newPath) {
process.stderr.write('usage: phobiCS-tui approve <existing> <proposed>\n');
return 2;
}
let oldText: string;
let newText: string;
try {
oldText = await fs.readFile(oldPath, 'utf8');
newText = await fs.readFile(newPath, 'utf8');
} catch (err) {
process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`);
return 2;
}
return new Promise<number>((resolve) => {
const onDecision = (d: Decision) => {
app.unmount();
resolve(d === 'accept' ? 0 : 1);
};
const fileName = oldPath.split(/[/\\]/).pop() ?? oldPath;
const app = render(
<Approve fileName={fileName} oldText={oldText} newText={newText} onDecision={onDecision} />
);
process.on('SIGTERM', () => {
app.unmount();
resolve(1);
});
process.on('SIGINT', () => {
app.unmount();
resolve(1);
});
});
}
main()
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write(`phobiCS-tui: ${err}\n`);
process.exit(2);
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
const BIN = path.resolve('dist/tui/index.js');
beforeAll(async () => {
await fs.access(BIN);
});
function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [BIN, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('exit', (code) => resolve({ code: code ?? -1, stdout, stderr }));
});
}
describe('phobiCS-tui (integration, no-TTY paths only)', () => {
it('--version prints version and exits 0', async () => {
const r = await run(['--version']);
expect(r.code).toBe(0);
expect(r.stdout).toMatch(/phobiCS-tui v\d/);
});
it('approve with missing file exits 2', async () => {
const r = await run(['approve', '/nonexistent/old.st', '/nonexistent/new.st']);
expect(r.code).toBe(2);
});
it('approve with no args exits 2 with usage on stderr', async () => {
const r = await run(['approve']);
expect(r.code).toBe(2);
expect(r.stderr).toMatch(/usage:/);
});
});