Skip to content

Commit 5cbdfdd

Browse files
committed
feat: Added unified way to run command in plugins (similar to plan)
1 parent 98d5abe commit 5cbdfdd

File tree

4 files changed

+186
-3
lines changed

4 files changed

+186
-3
lines changed

src/plugin/plugin.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import { ApplyValidationError } from '../common/errors.js';
2121
import { Plan } from '../plan/plan.js';
2222
import { BackgroundPty } from '../pty/background-pty.js';
2323
import { getPty } from '../pty/index.js';
24+
import { SequentialPty } from '../pty/seqeuntial-pty.js';
2425
import { Resource } from '../resource/resource.js';
2526
import { ResourceController } from '../resource/resource-controller.js';
26-
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
2727
import { VerbosityLevel } from '../utils/internal-utils.js';
28+
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
2829

2930
export class Plugin {
3031
planStorage: Map<string, Plan<any>>;
@@ -232,7 +233,7 @@ export class Plugin {
232233
throw new Error('Malformed plan with resource that cannot be found');
233234
}
234235

235-
await resource.apply(plan);
236+
await ptyLocalStorage.run(new SequentialPty(), async () => resource.apply(plan))
236237

237238
// Validate using desired/desired. If the apply was successful, no changes should be reported back.
238239
// Default back desired back to current if it is not defined (for destroys only)

src/pty/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
22

33
export interface SpawnResult {
4-
status: 'success' | 'error';
4+
status: 'error' | 'success';
55
exitCode: number;
66
data: string;
77
}
@@ -11,9 +11,25 @@ export enum SpawnStatus {
1111
ERROR = 'error',
1212
}
1313

14+
/**
15+
* Represents the configuration options for spawning a child process.
16+
*
17+
* @interface SpawnOptions
18+
*
19+
* @property {string} [cwd] - Specifies the working directory of the child process.
20+
* If not provided, the current working directory of the parent process is used.
21+
*
22+
* @property {Record<string, unknown>} [env] - Defines environment key-value pairs
23+
* that will be available to the child process. If not specified, the child process
24+
* will inherit the environment variables of the parent process.
25+
*
26+
* @property {boolean} [interactive] - Indicates whether the spawned process needs
27+
* to be interactive. Only works within apply (not plan). Defaults to true.
28+
*/
1429
export interface SpawnOptions {
1530
cwd?: string;
1631
env?: Record<string, unknown>,
32+
interactive?: boolean,
1733
}
1834

1935
export class SpawnError extends Error {

src/pty/seqeuntial-pty.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import pty from '@homebridge/node-pty-prebuilt-multiarch';
2+
import { EventEmitter } from 'node:events';
3+
import stripAnsi from 'strip-ansi';
4+
5+
import { Shell, Utils } from '../utils/index.js';
6+
import { VerbosityLevel } from '../utils/internal-utils.js';
7+
import { IPty, SpawnError, SpawnOptions, SpawnResult, SpawnStatus } from './index.js';
8+
9+
EventEmitter.defaultMaxListeners = 1000;
10+
11+
/**
12+
* The background pty is a specialized pty designed for speed. It can launch multiple tasks
13+
* in parallel by moving them to the background. It attaches unix FIFO pipes to each process
14+
* to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
15+
* without a tty (or even a stdin) attached so interactive commands will not work.
16+
*/
17+
export class SequentialPty implements IPty {
18+
async spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
19+
const spawnResult = await this.spawnSafe(cmd, options);
20+
21+
if (spawnResult.status !== 'success') {
22+
throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
23+
}
24+
25+
return spawnResult;
26+
}
27+
28+
async spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
29+
console.log(`Running command: ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
30+
31+
return new Promise((resolve) => {
32+
const output: string[] = [];
33+
34+
const historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
35+
36+
// If TERM_PROGRAM=Apple_Terminal is set then ANSI escape characters may be included
37+
// in the response.
38+
const env = {
39+
...process.env, ...options?.env,
40+
TERM_PROGRAM: 'codify',
41+
COMMAND_MODE: 'unix2003',
42+
COLORTERM: 'truecolor', ...historyIgnore
43+
}
44+
45+
// Initial terminal dimensions
46+
const initialCols = process.stdout.columns ?? 80;
47+
const initialRows = process.stdout.rows ?? 24;
48+
49+
const args = (options?.interactive ?? true) ? ['-i', '-c', `"${cmd}"`] : ['-c', `"${cmd}"`]
50+
51+
// Run the command in a pty for interactivity
52+
const mPty = pty.spawn(this.getDefaultShell(), args, {
53+
...options,
54+
cols: initialCols,
55+
rows: initialRows,
56+
env
57+
});
58+
59+
mPty.onData((data) => {
60+
if (VerbosityLevel.get() > 0) {
61+
process.stdout.write(data);
62+
}
63+
64+
output.push(data.toString());
65+
})
66+
67+
const stdinListener = (data) => {
68+
mPty.write(data.toString());
69+
};
70+
71+
const resizeListener = () => {
72+
const { columns, rows } = process.stdout;
73+
mPty.resize(columns, rows);
74+
}
75+
76+
// Listen to resize events for the terminal window;
77+
process.stdout.on('resize', resizeListener);
78+
// Listen for user input
79+
process.stdin.on('data', stdinListener);
80+
81+
mPty.onExit((result) => {
82+
process.stdout.off('resize', resizeListener);
83+
process.stdin.off('data', stdinListener);
84+
85+
resolve({
86+
status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
87+
exitCode: result.exitCode,
88+
data: stripAnsi(output.join('\n').trim()),
89+
})
90+
})
91+
})
92+
}
93+
94+
async kill(): Promise<{ exitCode: number, signal?: number | undefined }> {
95+
// No-op here. Each pty instance is stand alone and tied to the parent process. Everything should be killed as expected.
96+
return {
97+
exitCode: 0,
98+
signal: 0,
99+
}
100+
}
101+
102+
private getDefaultShell(): string {
103+
return process.env.SHELL!;
104+
}
105+
}

src/pty/sequential-pty.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { SequentialPty } from './seqeuntial-pty.js';
3+
import { VerbosityLevel } from '../utils/internal-utils.js';
4+
5+
describe('SequentialPty tests', () => {
6+
it('Can launch a simple command', async () => {
7+
const pty = new SequentialPty();
8+
9+
VerbosityLevel.set(1);
10+
11+
const result = await pty.spawnSafe('ls');
12+
expect(result).toMatchObject({
13+
status: 'success',
14+
exitCode: 0,
15+
data: expect.any(String),
16+
})
17+
18+
const exitCode = await pty.kill();
19+
expect(exitCode).toMatchObject({
20+
exitCode: 0,
21+
});
22+
})
23+
24+
it('Reports back the correct exit code and status', async () => {
25+
const pty = new SequentialPty();
26+
27+
const resultSuccess = await pty.spawnSafe('ls');
28+
expect(resultSuccess).toMatchObject({
29+
status: 'success',
30+
exitCode: 0,
31+
})
32+
33+
const resultFailed = await pty.spawnSafe('which sjkdhsakjdhjkash');
34+
expect(resultFailed).toMatchObject({
35+
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+
})
39+
});
40+
41+
it('Can use a different cwd', async () => {
42+
const pty = new SequentialPty();
43+
44+
const resultSuccess = await pty.spawnSafe('pwd', { cwd: '/tmp' });
45+
expect(resultSuccess).toMatchObject({
46+
status: 'success',
47+
exitCode: 0,
48+
data: '/tmp'
49+
})
50+
});
51+
52+
it('It can launch a command in interactive mode', async () => {
53+
const pty = new SequentialPty();
54+
55+
const resultSuccess = await pty.spawnSafe('ls', { interactive: false });
56+
expect(resultSuccess).toMatchObject({
57+
status: 'success',
58+
exitCode: 0,
59+
})
60+
});
61+
})

0 commit comments

Comments
 (0)