/ src / external.test.ts
external.test.ts
 1  import { beforeEach, describe, expect, it, vi } from 'vitest';
 2  
 3  const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
 4    mockExecFileSync: vi.fn(),
 5    mockPlatform: vi.fn(() => 'darwin'),
 6  }));
 7  
 8  vi.mock('node:child_process', () => ({
 9    spawnSync: vi.fn(),
10    execFileSync: mockExecFileSync,
11  }));
12  
13  vi.mock('node:os', async () => {
14    const actual = await vi.importActual<typeof import('node:os')>('node:os');
15    return {
16      ...actual,
17      platform: mockPlatform,
18    };
19  });
20  
21  import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
22  
23  describe('parseCommand', () => {
24    it('splits binaries and quoted arguments without invoking a shell', () => {
25      expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
26        binary: 'npm',
27        args: ['install', '-g', '@scope/tool name'],
28      });
29    });
30  
31    it('rejects shell operators', () => {
32      expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
33        'Install command contains unsafe shell operators',
34      );
35    });
36  
37    it('rejects command substitution and multiline input', () => {
38      expect(() => parseCommand('brew install $(whoami)')).toThrow(
39        'Install command contains unsafe shell operators',
40      );
41      expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow(
42        'Install command contains unsafe shell operators',
43      );
44    });
45  });
46  
47  describe('installExternalCli', () => {
48    const cli: ExternalCliConfig = {
49      name: 'readwise',
50      binary: 'readwise',
51      install: {
52        default: 'npm install -g @readwiseio/readwise-cli',
53      },
54    };
55  
56    beforeEach(() => {
57      mockExecFileSync.mockReset();
58      mockPlatform.mockReturnValue('darwin');
59    });
60  
61    it('retries with .cmd on Windows when the bare binary is unavailable', () => {
62      mockPlatform.mockReturnValue('win32');
63      mockExecFileSync
64        .mockImplementationOnce(() => {
65          const err = new Error('not found') as NodeJS.ErrnoException;
66          err.code = 'ENOENT';
67          throw err;
68        })
69        .mockReturnValueOnce(Buffer.from(''));
70  
71      expect(installExternalCli(cli)).toBe(true);
72      expect(mockExecFileSync).toHaveBeenNthCalledWith(
73        1,
74        'npm',
75        ['install', '-g', '@readwiseio/readwise-cli'],
76        { stdio: 'inherit' },
77      );
78      expect(mockExecFileSync).toHaveBeenNthCalledWith(
79        2,
80        'npm.cmd',
81        ['install', '-g', '@readwiseio/readwise-cli'],
82        { stdio: 'inherit' },
83      );
84    });
85  
86    it('does not mask non-ENOENT failures', () => {
87      mockPlatform.mockReturnValue('win32');
88      mockExecFileSync.mockImplementationOnce(() => {
89        const err = new Error('permission denied') as NodeJS.ErrnoException;
90        err.code = 'EACCES';
91        throw err;
92      });
93  
94      expect(installExternalCli(cli)).toBe(false);
95      expect(mockExecFileSync).toHaveBeenCalledTimes(1);
96    });
97  });