diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index 4a7837d5..5d039a1e 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -202,9 +202,14 @@ export function registerGatewayCommand(program: Command): void { gw.command('uninstall') .description('Uninstall the gateway daemon and optionally remove data') - .action(async () => { + .option( + '-y, --yes', + 'Headless: skip the confirmation prompt (required when stdin is not a TTY)', + ) + .option('--remove-data', 'Also remove all gateway data (never implied by --yes)') + .action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => { const { runUninstall } = await import('./gateway/uninstall.js'); - await runUninstall(); + await runUninstall(cmdOpts); }); // ─── doctor ───────────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/gateway/uninstall.spec.ts b/packages/mosaic/src/commands/gateway/uninstall.spec.ts new file mode 100644 index 00000000..1d3697ff --- /dev/null +++ b/packages/mosaic/src/commands/gateway/uninstall.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdirSync } from 'node:fs'; + +vi.mock('./daemon.js', () => ({ + GATEWAY_HOME: '/tmp/u-test-gateway-home', + getDaemonPid: vi.fn().mockReturnValue(null), + readMeta: vi.fn(), + stopDaemon: vi.fn(), + uninstallGatewayPackage: vi.fn(), +})); + +import { runUninstall } from './uninstall.js'; +import { readMeta, uninstallGatewayPackage } from './daemon.js'; + +describe('gateway uninstall — #1390 headless semantics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => { + vi.mocked(readMeta).mockReturnValue({ + version: '0.0.7', + installedAt: '', + entryPoint: '', + host: 'localhost', + port: 14242, + }); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('EXIT'); + }) as never); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runUninstall()).rejects.toThrow('EXIT'); + expect(exit).toHaveBeenCalledWith(1); + expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY')); + expect(uninstallGatewayPackage).not.toHaveBeenCalled(); + + exit.mockRestore(); + err.mockRestore(); + }); + + it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => { + const meta = { + version: '0.0.7', + installedAt: '', + entryPoint: '', + host: 'localhost', + port: 14242, + }; + vi.mocked(readMeta).mockReturnValue(meta); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runUninstall({ yes: true }); + + expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept')); + log.mockRestore(); + }); + + it('--yes --remove-data removes data headlessly', async () => { + vi.mocked(readMeta).mockReturnValue({ + version: '0.0.7', + installedAt: '', + entryPoint: '', + host: 'localhost', + port: 14242, + }); + mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runUninstall({ yes: true, removeData: true }); + + expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed')); + log.mockRestore(); + }); + + it('no meta → clean no-op even with --yes', async () => { + vi.mocked(readMeta).mockReturnValue(null); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runUninstall({ yes: true }); + expect(log).toHaveBeenCalledWith('Gateway is not installed.'); + expect(uninstallGatewayPackage).not.toHaveBeenCalled(); + log.mockRestore(); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/uninstall.ts b/packages/mosaic/src/commands/gateway/uninstall.ts index ae14a150..9caa29b7 100644 --- a/packages/mosaic/src/commands/gateway/uninstall.ts +++ b/packages/mosaic/src/commands/gateway/uninstall.ts @@ -8,30 +8,65 @@ import { uninstallGatewayPackage, } from './daemon.js'; -export async function runUninstall(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); +export interface UninstallOptions { + /** Skip the confirmation prompt (headless/scripted uninstall). */ + yes?: boolean; + /** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */ + removeData?: boolean; +} + +export async function runUninstall(opts: UninstallOptions = {}): Promise { + const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1'; + + // Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing: + // the pre-fix behavior (prompt on a closed stdin → default No → exit 0, + // gateway untouched) reported success-by-silence to every scripted caller + // (#1390). An explicit refusal beats a silent no-op. + if (!nonInteractive && !process.stdin.isTTY) { + console.error( + 'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' + + 'run an interactive uninstall headlessly (nothing was changed). ' + + 'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.', + ); + process.exit(1); + } + + const rl = nonInteractive + ? null + : createInterface({ input: process.stdin, output: process.stdout }); try { - await doUninstall(rl); + await doUninstall(rl as NonNullable, opts, nonInteractive); } finally { - rl.close(); + rl?.close(); } } -function prompt(rl: ReturnType, question: string): Promise { +function prompt( + rl: NonNullable>, + question: string, +): Promise { return new Promise((resolve) => rl.question(question, resolve)); } -async function doUninstall(rl: ReturnType): Promise { +async function doUninstall( + rl: ReturnType, + opts: UninstallOptions, + nonInteractive: boolean, +): Promise { const meta = readMeta(); if (!meta) { console.log('Gateway is not installed.'); return; } - const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] '); - if (answer.toLowerCase() !== 'y') { - console.log('Aborted.'); - return; + if (nonInteractive) { + console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`); + } else { + const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] '); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } } // Stop if running @@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType): Promise