fix(#1390): gateway uninstall headless — --yes/--remove-data; non-TTY without consent fails loud #1422
@@ -202,9 +202,14 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
|
|
||||||
gw.command('uninstall')
|
gw.command('uninstall')
|
||||||
.description('Uninstall the gateway daemon and optionally remove data')
|
.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');
|
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||||
await runUninstall();
|
await runUninstall(cmdOpts);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── doctor ─────────────────────────────────────────────────────────────────
|
// ─── doctor ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,30 +8,65 @@ import {
|
|||||||
uninstallGatewayPackage,
|
uninstallGatewayPackage,
|
||||||
} from './daemon.js';
|
} from './daemon.js';
|
||||||
|
|
||||||
export async function runUninstall(): Promise<void> {
|
export interface UninstallOptions {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
/** 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<void> {
|
||||||
|
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 {
|
try {
|
||||||
await doUninstall(rl);
|
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
rl?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
function prompt(
|
||||||
|
rl: NonNullable<ReturnType<typeof createInterface>>,
|
||||||
|
question: string,
|
||||||
|
): Promise<string> {
|
||||||
return new Promise((resolve) => rl.question(question, resolve));
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
async function doUninstall(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
opts: UninstallOptions,
|
||||||
|
nonInteractive: boolean,
|
||||||
|
): Promise<void> {
|
||||||
const meta = readMeta();
|
const meta = readMeta();
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.log('Gateway is not installed.');
|
console.log('Gateway is not installed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
if (nonInteractive) {
|
||||||
if (answer.toLowerCase() !== 'y') {
|
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
|
||||||
console.log('Aborted.');
|
} else {
|
||||||
return;
|
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||||
|
if (answer.toLowerCase() !== 'y') {
|
||||||
|
console.log('Aborted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop if running
|
// Stop if running
|
||||||
@@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove config/data
|
// Remove config/data. Interactive: ask. Headless: only with the explicit
|
||||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
// flag — destructive recursion is never implied by --yes alone (#1390).
|
||||||
if (removeData.toLowerCase() === 'y') {
|
let removeData = Boolean(opts.removeData);
|
||||||
|
if (!nonInteractive) {
|
||||||
|
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||||
|
removeData = answer.toLowerCase() === 'y';
|
||||||
|
}
|
||||||
|
if (removeData) {
|
||||||
if (existsSync(GATEWAY_HOME)) {
|
if (existsSync(GATEWAY_HOME)) {
|
||||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||||
console.log('Gateway data removed.');
|
console.log('Gateway data removed.');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall npm package
|
// Uninstall npm package
|
||||||
|
|||||||
Reference in New Issue
Block a user