Skip to content

Commit 960e7dd

Browse files
committed
feat: Added ability to do external spawns
1 parent 55516ef commit 960e7dd

File tree

8 files changed

+203
-37
lines changed

8 files changed

+203
-37
lines changed

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codify-plugin-lib",
3-
"version": "1.0.182-beta5",
3+
"version": "1.0.182-beta9",
44
"description": "Library plugin library",
55
"main": "dist/index.js",
66
"typings": "dist/index.d.ts",
@@ -22,7 +22,7 @@
2222
"ajv": "^8.12.0",
2323
"ajv-formats": "^2.1.1",
2424
"clean-deep": "^3.4.0",
25-
"codify-schemas": "1.0.86",
25+
"codify-schemas": "1.0.86-beta4",
2626
"lodash.isequal": "^4.5.0",
2727
"nanoid": "^5.0.9",
2828
"strip-ansi": "^7.1.0",

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './resource/parsed-resource-settings.js';
1212
export * from './resource/resource.js'
1313
export * from './resource/resource-settings.js'
1414
export * from './stateful-parameter/stateful-parameter.js'
15+
export * from './utils/file-utils.js'
1516
export * from './utils/functions.js'
1617
export * from './utils/index.js'
1718
export * from './utils/verbosity-level.js'

src/pty/background-pty.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ EventEmitter.defaultMaxListeners = 1000;
2020
* without a tty (or even a stdin) attached so interactive commands will not work.
2121
*/
2222
export class BackgroundPty implements IPty {
23+
private historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
2324
private basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
24-
env: process.env, name: nanoid(6),
25+
env: { ...process.env, ...this.historyIgnore },
26+
name: nanoid(6),
2527
handleFlowControl: true
2628
});
2729

@@ -129,17 +131,17 @@ export class BackgroundPty implements IPty {
129131

130132
return new Promise(resolve => {
131133
// zsh-specific commands
132-
switch (Utils.getShell()) {
133-
case Shell.ZSH: {
134-
this.basePty.write('setopt HIST_NO_STORE;\n');
135-
break;
136-
}
137-
138-
default: {
139-
this.basePty.write('export HISTIGNORE=\'history*\';\n');
140-
break;
141-
}
142-
}
134+
// switch (Utils.getShell()) {
135+
// case Shell.ZSH: {
136+
// this.basePty.write('setopt HIST_NO_STORE;\n');
137+
// break;
138+
// }
139+
//
140+
// default: {
141+
// this.basePty.write('export HISTIGNORE=\'history*\';\n');
142+
// break;
143+
// }
144+
// }
143145

144146
this.basePty.write(' unset PS1;\n');
145147
this.basePty.write(' unset PS0;\n')

src/pty/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface SpawnOptions {
3030
cwd?: string;
3131
env?: Record<string, unknown>,
3232
interactive?: boolean,
33+
requiresRoot?: boolean,
3334
}
3435

3536
export class SpawnError extends Error {

src/pty/seqeuntial-pty.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import pty from '@homebridge/node-pty-prebuilt-multiarch';
2+
import { Ajv } from 'ajv';
3+
import { CommandRequestResponseData, CommandRequestResponseDataSchema, IpcMessageV2, MessageCmd } from 'codify-schemas';
4+
import { nanoid } from 'nanoid';
25
import { EventEmitter } from 'node:events';
36
import stripAnsi from 'strip-ansi';
47

@@ -8,6 +11,11 @@ import { IPty, SpawnError, SpawnOptions, SpawnResult, SpawnStatus } from './inde
811

912
EventEmitter.defaultMaxListeners = 1000;
1013

14+
const ajv = new Ajv({
15+
strict: true,
16+
});
17+
const validateSudoRequestResponse = ajv.compile(CommandRequestResponseDataSchema);
18+
1119
/**
1220
* The background pty is a specialized pty designed for speed. It can launch multiple tasks
1321
* in parallel by moving them to the background. It attaches unix FIFO pipes to each process
@@ -26,11 +34,15 @@ export class SequentialPty implements IPty {
2634
}
2735

2836
async spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
37+
// If sudo is required, we must delegate to the main codify process.
38+
if (options?.interactive || options?.requiresRoot) {
39+
return this.externalSpawn(cmd, options);
40+
}
41+
2942
console.log(`Running command: ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
3043

3144
return new Promise((resolve) => {
3245
const output: string[] = [];
33-
3446
const historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
3547

3648
// If TERM_PROGRAM=Apple_Terminal is set then ANSI escape characters may be included
@@ -39,17 +51,16 @@ export class SequentialPty implements IPty {
3951
...process.env, ...options?.env,
4052
TERM_PROGRAM: 'codify',
4153
COMMAND_MODE: 'unix2003',
42-
COLORTERM: 'truecolor', ...historyIgnore
54+
COLORTERM: 'truecolor',
55+
...historyIgnore
4356
}
4457

4558
// Initial terminal dimensions
4659
const initialCols = process.stdout.columns ?? 80;
4760
const initialRows = process.stdout.rows ?? 24;
4861

49-
const args = (options?.interactive ?? false) ? ['-i', '-c', cmd] : ['-c', cmd]
50-
5162
// Run the command in a pty for interactivity
52-
const mPty = pty.spawn(this.getDefaultShell(), args, {
63+
const mPty = pty.spawn(this.getDefaultShell(), ['-c', cmd], {
5364
...options,
5465
cols: initialCols,
5566
rows: initialRows,
@@ -64,23 +75,16 @@ export class SequentialPty implements IPty {
6475
output.push(data.toString());
6576
})
6677

67-
const stdinListener = (data: any) => {
68-
mPty.write(data.toString());
69-
};
70-
7178
const resizeListener = () => {
7279
const { columns, rows } = process.stdout;
7380
mPty.resize(columns, rows);
7481
}
7582

7683
// Listen to resize events for the terminal window;
7784
process.stdout.on('resize', resizeListener);
78-
// Listen for user input
79-
process.stdin.on('data', stdinListener);
8085

8186
mPty.onExit((result) => {
8287
process.stdout.off('resize', resizeListener);
83-
process.stdin.off('data', stdinListener);
8488

8589
resolve({
8690
status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
@@ -99,6 +103,39 @@ export class SequentialPty implements IPty {
99103
}
100104
}
101105

106+
// For safety reasons, requests that require sudo or are interactive must be run via the main client
107+
async externalSpawn(
108+
cmd: string,
109+
opts: SpawnOptions
110+
): Promise<SpawnResult> {
111+
return new Promise((resolve) => {
112+
const requestId = nanoid(8);
113+
114+
const listener = (data: IpcMessageV2) => {
115+
if (data.requestId === requestId) {
116+
process.removeListener('message', listener);
117+
118+
if (!validateSudoRequestResponse(data.data)) {
119+
throw new Error(`Invalid response for sudo request: ${JSON.stringify(validateSudoRequestResponse.errors, null, 2)}`);
120+
}
121+
122+
resolve(data.data as unknown as CommandRequestResponseData);
123+
}
124+
}
125+
126+
process.on('message', listener);
127+
128+
process.send!(<IpcMessageV2>{
129+
cmd: MessageCmd.COMMAND_REQUEST,
130+
data: {
131+
command: cmd,
132+
options: opts ?? {},
133+
},
134+
requestId
135+
})
136+
});
137+
}
138+
102139
private getDefaultShell(): string {
103140
return process.env.SHELL!;
104141
}

src/pty/sequential-pty.test.ts

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from 'vitest';
22
import { SequentialPty } from './seqeuntial-pty.js';
33
import { VerbosityLevel } from '../utils/verbosity-level.js';
4+
import { MessageStatus, SpawnStatus } from 'codify-schemas/src/types/index.js';
5+
import { IpcMessageV2, MessageCmd } from 'codify-schemas';
46

57
describe('SequentialPty tests', () => {
68
it('Can launch a simple command', async () => {
@@ -33,8 +35,8 @@ describe('SequentialPty tests', () => {
3335
const resultFailed = await pty.spawnSafe('which sjkdhsakjdhjkash');
3436
expect(resultFailed).toMatchObject({
3537
status: 'error',
36-
exitCode: 127,
37-
data: 'zsh:1: command not found: which sjkdhsakjdhjkash' // This might change on different os or shells. Keep for now.
38+
exitCode: 1,
39+
data: 'sjkdhsakjdhjkash not found' // This might change on different os or shells. Keep for now.
3840
})
3941
});
4042

@@ -50,12 +52,128 @@ describe('SequentialPty tests', () => {
5052
});
5153

5254
it('It can launch a command in interactive mode', async () => {
53-
const pty = new SequentialPty();
55+
const originalSend = process.send;
56+
process.send = (req: IpcMessageV2) => {
57+
expect(req).toMatchObject({
58+
cmd: MessageCmd.COMMAND_REQUEST,
59+
requestId: expect.any(String),
60+
data: {
61+
command: 'ls',
62+
options: {
63+
cwd: '/tmp',
64+
interactive: true,
65+
}
66+
}
67+
})
68+
69+
// This may look confusing but what we're doing here is directly finding the process listener and calling it without going through serialization
70+
const listeners = process.listeners('message');
71+
listeners[2](({
72+
cmd: MessageCmd.COMMAND_REQUEST,
73+
requestId: req.requestId,
74+
status: MessageStatus.SUCCESS,
75+
data: {
76+
status: SpawnStatus.SUCCESS,
77+
exitCode: 0,
78+
data: 'My data',
79+
}
80+
}))
81+
82+
return true;
83+
}
84+
85+
const $ = new SequentialPty();
86+
const resultSuccess = await $.spawnSafe('ls', { interactive: true, cwd: '/tmp' });
5487

55-
const resultSuccess = await pty.spawnSafe('ls', { interactive: true });
5688
expect(resultSuccess).toMatchObject({
5789
status: 'success',
5890
exitCode: 0,
59-
})
91+
});
92+
93+
process.send = originalSend;
6094
});
95+
96+
it('It can work with root (sudo)', async () => {
97+
const originalSend = process.send;
98+
process.send = (req: IpcMessageV2) => {
99+
expect(req).toMatchObject({
100+
cmd: MessageCmd.COMMAND_REQUEST,
101+
requestId: expect.any(String),
102+
data: {
103+
command: 'ls',
104+
options: {
105+
interactive: true,
106+
requiresRoot: true,
107+
}
108+
}
109+
})
110+
111+
// This may look confusing but what we're doing here is directly finding the process listener and calling it without going through serialization
112+
const listeners = process.listeners('message');
113+
listeners[2](({
114+
cmd: MessageCmd.COMMAND_REQUEST,
115+
requestId: req.requestId,
116+
status: MessageStatus.SUCCESS,
117+
data: {
118+
status: SpawnStatus.SUCCESS,
119+
exitCode: 0,
120+
data: 'My data',
121+
}
122+
}))
123+
124+
return true;
125+
}
126+
127+
const $ = new SequentialPty();
128+
const resultSuccess = await $.spawn('ls', { interactive: true, requiresRoot: true });
129+
130+
expect(resultSuccess).toMatchObject({
131+
status: 'success',
132+
exitCode: 0,
133+
});
134+
135+
process.send = originalSend;
136+
})
137+
138+
it('It can handle errors when in sudo', async () => {
139+
const originalSend = process.send;
140+
process.send = (req: IpcMessageV2) => {
141+
expect(req).toMatchObject({
142+
cmd: MessageCmd.COMMAND_REQUEST,
143+
requestId: expect.any(String),
144+
data: {
145+
command: 'ls',
146+
options: {
147+
requiresRoot: true,
148+
interactive: true,
149+
}
150+
}
151+
})
152+
153+
// This may look confusing but what we're doing here is directly finding the process listener and calling it without going through serialization
154+
const listeners = process.listeners('message');
155+
listeners[2](({
156+
cmd: MessageCmd.COMMAND_REQUEST,
157+
requestId: req.requestId,
158+
status: MessageStatus.SUCCESS,
159+
data: {
160+
status: SpawnStatus.ERROR,
161+
exitCode: 127,
162+
data: 'My data',
163+
}
164+
}))
165+
166+
return true;
167+
}
168+
169+
const $ = new SequentialPty();
170+
const resultSuccess = await $.spawnSafe('ls', { interactive: true, requiresRoot: true });
171+
172+
expect(resultSuccess).toMatchObject({
173+
status: SpawnStatus.ERROR,
174+
exitCode: 127,
175+
});
176+
177+
process.send = originalSend;
178+
})
61179
})

src/utils/file-utils.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { describe, it } from 'vitest';
2+
3+
describe('File utils tests', { timeout: 100_000_000 }, () => {
4+
it('Can download a file', async () => {
5+
// await FileUtils.downloadFile('https://download.jetbrains.com/webstorm/WebStorm-2025.3.1-aarch64.dmg?_gl=1*1huoi7o*_gcl_aw*R0NMLjE3NjU3NDAwMTcuQ2p3S0NBaUEzZm5KQmhBZ0Vpd0F5cW1ZNVhLVENlbHJOcTk2YXdjZVlfMS1wdE91MXc0WDk2bFJkVDM3QURhUFNJMUtwNVVSVUhxWTJob0NuZ0FRQXZEX0J3RQ..*_gcl_au*MjA0MDQ0MjE2My4xNzYzNjQzNzMz*FPAU*MjA0MDQ0MjE2My4xNzYzNjQzNzMz*_ga*MTYxMDg4MTkzMi4xNzYzNjQzNzMz*_ga_9J976DJZ68*czE3NjYzNjI5ODAkbzEyJGcxJHQxNzY2MzYzMDQwJGo2MCRsMCRoMA..', path.join(process.cwd(), 'google.html'));
6+
})
7+
})

0 commit comments

Comments
 (0)