Skip to content

Commit a970a4b

Browse files
committed
feat: Added proper spawn. Fixed missing initialize calls for subsequent plugins. Updated sudo to command.
1 parent 90e0621 commit a970a4b

4 files changed

Lines changed: 162 additions & 73 deletions

File tree

src/plugin-process.ts

Lines changed: 23 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
import Ajv from 'ajv';
22
import {
3-
ApplyRequestData, ImportRequestData, ImportResponseData,
3+
ApplyRequestData,
4+
CommandRequestData,
5+
CommandRequestDataSchema,
6+
ImportRequestData,
7+
ImportResponseData,
48
InitializeResponseData,
59
IpcMessageSchema,
610
IpcMessageV2,
7-
MessageCmd, PlanRequestData, PlanResponseData,
8-
SpawnStatus,
9-
SudoRequestData,
10-
SudoRequestDataSchema, ValidateRequestData, ValidateResponseData
11+
MessageCmd,
12+
PlanRequestData,
13+
PlanResponseData,
14+
ValidateRequestData,
15+
ValidateResponseData
1116
} from 'codify-schemas';
1217
import { nanoid } from 'nanoid';
13-
import { ChildProcess, SpawnOptions, fork, spawn } from 'node:child_process';
18+
import { ChildProcess, fork } from 'node:child_process';
19+
import fs from 'node:fs/promises';
20+
import * as os from 'node:os';
1421
import path from 'node:path';
1522

23+
import { spawnSafe } from './spawn.js';
1624
import { CodifyTestUtils } from './test-utils.js';
17-
import fs from 'node:fs/promises';
18-
import * as os from 'node:os';
1925

2026
const ajv = new Ajv.default({
2127
strict: true
2228
});
2329

2430
const ipcMessageValidator = ajv.compile(IpcMessageSchema);
25-
const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
31+
const commandRequestValidator = ajv.compile(CommandRequestDataSchema);
2632

2733
export class PluginProcess {
2834
childProcess: ChildProcess
@@ -108,26 +114,26 @@ export class PluginProcess {
108114
throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
109115
}
110116

111-
if (message.cmd === MessageCmd.SUDO_REQUEST) {
117+
if (message.cmd === MessageCmd.COMMAND_REQUEST) {
112118
const { data, requestId } = message;
113-
if (!sudoRequestValidator(data)) {
114-
throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
119+
if (!commandRequestValidator(data)) {
120+
throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(commandRequestValidator.errors, null, 2)}`);
115121
}
116122

117-
const { command, options } = data as unknown as SudoRequestData;
118-
const result = await sudoSpawn(command, options);
123+
const { command, options } = data as unknown as CommandRequestData;
124+
const result = await spawnSafe(command, options);
119125

120126
cp.send(<IpcMessageV2>{
121-
cmd: MessageCmd.SUDO_REQUEST + '_Response',
127+
cmd: MessageCmd.COMMAND_REQUEST + '_Response',
122128
data: result,
123129
requestId,
124130
})
125131
}
126132

127133
if (message.cmd === MessageCmd.PRESS_KEY_TO_CONTINUE_REQUEST) {
128134
const { data, requestId } = message;
129-
if (!sudoRequestValidator(data)) {
130-
throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
135+
if (!commandRequestValidator(data)) {
136+
throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(commandRequestValidator.errors, null, 2)}`);
131137
}
132138

133139
cp.send(<IpcMessageV2>{
@@ -155,59 +161,3 @@ export class PluginProcess {
155161
}
156162

157163
}
158-
159-
type CodifySpawnOptions = {
160-
cwd?: string;
161-
throws?: boolean,
162-
} & Omit<SpawnOptions, 'detached' | 'shell' | 'stdio'>
163-
164-
/**
165-
*
166-
* @param cmd Command to run. Ex: `rm -rf`
167-
* @param opts Options for spawn
168-
*
169-
* @see promiseSpawn
170-
* @see spawn
171-
*
172-
* @returns SpawnResult { status: SUCCESS | ERROR; data: string }
173-
*/
174-
async function sudoSpawn(
175-
cmd: string,
176-
opts: CodifySpawnOptions,
177-
): Promise<{ data: string, status: SpawnStatus }> {
178-
return new Promise((resolve) => {
179-
const output: string[] = [];
180-
181-
const _cmd = `sudo ${cmd}`;
182-
183-
// Source start up shells to emulate a users environment vs. a non-interactive non-login shell script
184-
// Ignore all stdin
185-
const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
186-
...opts,
187-
shell: 'zsh',
188-
stdio: ['ignore', 'pipe', 'pipe'],
189-
});
190-
191-
const { stderr, stdout } = _process
192-
stdout.setEncoding('utf8');
193-
stderr.setEncoding('utf8');
194-
195-
stdout.on('data', (data) => {
196-
output.push(data.toString());
197-
})
198-
199-
stderr.on('data', (data) => {
200-
output.push(data.toString());
201-
})
202-
203-
stdout.pipe(process.stdout);
204-
stderr.pipe(process.stderr);
205-
206-
_process.on('close', (code) => {
207-
resolve({
208-
data: output.join(''),
209-
status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
210-
})
211-
})
212-
})
213-
}

src/plugin-tester.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export class PluginTester {
9191
if (!options?.skipImport) {
9292
const importPlugin = new PluginProcess(pluginPath);
9393
try {
94+
await importPlugin.initialize();
9495
console.info(chalk.cyan('Testing import...'))
9596

9697
const importResults = [];
@@ -113,6 +114,7 @@ export class PluginTester {
113114
const modifyPlugin = new PluginProcess(pluginPath);
114115

115116
try {
117+
await modifyPlugin.initialize();
116118
console.info(chalk.cyan('Testing modify...'))
117119

118120
const modifyPlans = [];
@@ -164,6 +166,7 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
164166
const destroyPlugin = new PluginProcess(pluginPath);
165167

166168
try {
169+
await destroyPlugin.initialize();
167170
console.info(chalk.cyan('Testing destroy...'))
168171

169172
const plans = [];

src/shell.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export enum Shell {
2+
ZSH = 'zsh',
3+
BASH = 'bash',
4+
SH = 'sh',
5+
KSH = 'ksh',
6+
CSH = 'csh',
7+
FISH = 'fish',
8+
}
9+
10+
11+
export const ShellUtils = {
12+
getShell(): Shell | undefined {
13+
const shell = process.env.SHELL || '';
14+
15+
if (shell.endsWith('bash')) {
16+
return Shell.BASH
17+
}
18+
19+
if (shell.endsWith('zsh')) {
20+
return Shell.ZSH
21+
}
22+
23+
if (shell.endsWith('sh')) {
24+
return Shell.SH
25+
}
26+
27+
if (shell.endsWith('csh')) {
28+
return Shell.CSH
29+
}
30+
31+
if (shell.endsWith('ksh')) {
32+
return Shell.KSH
33+
}
34+
35+
if (shell.endsWith('fish')) {
36+
return Shell.FISH
37+
}
38+
39+
return undefined;
40+
},
41+
42+
getDefaultShell(): string {
43+
return process.env.SHELL!;
44+
}
45+
}

src/spawn.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
2+
import { SpawnStatus } from 'codify-schemas';
3+
import stripAnsi from 'strip-ansi';
4+
5+
import { Shell, ShellUtils } from './shell.js';
6+
7+
export interface SpawnResult {
8+
status: SpawnStatus;
9+
exitCode: number;
10+
data: string;
11+
}
12+
13+
export interface SpawnOptions {
14+
cwd?: string;
15+
env?: Record<string, unknown>,
16+
interactive?: boolean,
17+
requiresRoot?: boolean,
18+
stdin?: boolean,
19+
}
20+
21+
export function spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
22+
if (cmd.toLowerCase().includes('sudo')) {
23+
throw new Error('Command must not include sudo')
24+
}
25+
26+
process.stdout.write(`Running command: ${options?.requiresRoot ? 'sudo' : ''} ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
27+
28+
return new Promise((resolve) => {
29+
const output: string[] = [];
30+
const historyIgnore = ShellUtils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
31+
32+
// If TERM_PROGRAM=Apple_Terminal is set then ANSI escape characters may be included
33+
// in the response.
34+
const env = {
35+
...process.env, ...options?.env,
36+
TERM_PROGRAM: 'codify',
37+
COMMAND_MODE: 'unix2003',
38+
COLORTERM: 'truecolor',
39+
...historyIgnore
40+
}
41+
42+
// Initial terminal dimensions
43+
const initialCols = process.stdout.columns ?? 80;
44+
const initialRows = process.stdout.rows ?? 24;
45+
46+
const command = options?.requiresRoot ? `sudo ${cmd}` : cmd;
47+
const args = options?.interactive ? ['-i', '-c', command] : ['-c', command]
48+
49+
// Run the command in a pty for interactivity
50+
const mPty = pty.spawn(ShellUtils.getDefaultShell(), args, {
51+
...options,
52+
cols: initialCols,
53+
rows: initialRows,
54+
env
55+
});
56+
57+
mPty.onData((data) => {
58+
process.stdout.write(data);
59+
output.push(data.toString());
60+
})
61+
62+
const resizeListener = () => {
63+
const { columns, rows } = process.stdout;
64+
mPty.resize(columns, rows);
65+
}
66+
67+
const stdinListener = (data: any) => {
68+
// console.log('stdinListener', data);
69+
mPty.write(data.toString());
70+
}
71+
72+
// Listen to resize events for the terminal window;
73+
process.stdout.on('resize', resizeListener);
74+
if (options?.stdin) {
75+
process.stdin.on('data', stdinListener)
76+
}
77+
78+
mPty.onExit((result) => {
79+
process.stdout.off('resize', resizeListener);
80+
if (options?.stdin) {
81+
process.stdin.off('data', stdinListener);
82+
}
83+
84+
resolve({
85+
status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
86+
exitCode: result.exitCode,
87+
data: stripAnsi(output.join('\n').trim()),
88+
})
89+
})
90+
})
91+
}

0 commit comments

Comments
 (0)