codify-plugin-lib 1.0.109 → 1.0.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './plan/change-set.js';
4
4
  export * from './plan/plan.js';
5
5
  export * from './plan/plan-types.js';
6
6
  export * from './plugin/plugin.js';
7
+ export * from './pty/index.js';
7
8
  export * from './resource/parsed-resource-settings.js';
8
9
  export * from './resource/resource.js';
9
10
  export * from './resource/resource-settings.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from './plan/change-set.js';
4
4
  export * from './plan/plan.js';
5
5
  export * from './plan/plan-types.js';
6
6
  export * from './plugin/plugin.js';
7
+ export * from './pty/index.js';
7
8
  export * from './resource/parsed-resource-settings.js';
8
9
  export * from './resource/resource.js';
9
10
  export * from './resource/resource-settings.js';
@@ -2,10 +2,12 @@ import { ApplyRequestData, GetResourceInfoRequestData, GetResourceInfoResponseDa
2
2
  import { Plan } from '../plan/plan.js';
3
3
  import { Resource } from '../resource/resource.js';
4
4
  import { ResourceController } from '../resource/resource-controller.js';
5
+ import { BackgroundPty } from '../pty/background-pty.js';
5
6
  export declare class Plugin {
6
7
  name: string;
7
8
  resourceControllers: Map<string, ResourceController<ResourceConfig>>;
8
9
  planStorage: Map<string, Plan<any>>;
10
+ planPty: BackgroundPty;
9
11
  constructor(name: string, resourceControllers: Map<string, ResourceController<ResourceConfig>>);
10
12
  static create(name: string, resources: Resource<any>[]): Plugin;
11
13
  initialize(): Promise<InitializeResponseData>;
@@ -1,9 +1,12 @@
1
1
  import { Plan } from '../plan/plan.js';
2
2
  import { ResourceController } from '../resource/resource-controller.js';
3
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
4
+ import { BackgroundPty } from '../pty/background-pty.js';
3
5
  export class Plugin {
4
6
  name;
5
7
  resourceControllers;
6
8
  planStorage;
9
+ planPty = new BackgroundPty();
7
10
  constructor(name, resourceControllers) {
8
11
  this.name = name;
9
12
  this.resourceControllers = resourceControllers;
@@ -79,7 +82,9 @@ export class Plugin {
79
82
  if (!type || !this.resourceControllers.has(type)) {
80
83
  throw new Error(`Resource type not found: ${type}`);
81
84
  }
82
- const plan = await this.resourceControllers.get(type).plan(data.desired ?? null, data.state ?? null, data.isStateful);
85
+ const plan = await ptyLocalStorage.run(this.planPty, async () => {
86
+ return this.resourceControllers.get(type).plan(data.desired ?? null, data.state ?? null, data.isStateful);
87
+ });
83
88
  this.planStorage.set(plan.id, plan);
84
89
  return plan.toResponse();
85
90
  }
@@ -0,0 +1,19 @@
1
+ import { IPty, SpawnOptions, SpawnResult } from './index.js';
2
+ /**
3
+ * The background pty is a specialized pty designed for speed. It can launch multiple tasks
4
+ * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
5
+ * to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
6
+ * without a tty (or even a stdin) attached so interactive commands will not work.
7
+ */
8
+ export declare class BackgroundPty implements IPty {
9
+ private basePty;
10
+ private promiseQueue;
11
+ constructor();
12
+ spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
13
+ spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
14
+ kill(): Promise<{
15
+ exitCode: number;
16
+ signal?: number | undefined;
17
+ }>;
18
+ private initialize;
19
+ }
@@ -0,0 +1,119 @@
1
+ import { nanoid } from 'nanoid';
2
+ import * as cp from 'node:child_process';
3
+ import { EventEmitter } from 'node:events';
4
+ import * as fs from 'node:fs/promises';
5
+ import * as net from 'node:net';
6
+ import pty from 'node-pty';
7
+ import stripAnsi from 'strip-ansi';
8
+ import { SpawnError } from './index.js';
9
+ import { PromiseQueue } from './promise-queue.js';
10
+ import { debugLog } from '../utils/debug.js';
11
+ EventEmitter.defaultMaxListeners = 1000;
12
+ /**
13
+ * The background pty is a specialized pty designed for speed. It can launch multiple tasks
14
+ * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
15
+ * to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
16
+ * without a tty (or even a stdin) attached so interactive commands will not work.
17
+ */
18
+ export class BackgroundPty {
19
+ basePty = pty.spawn('zsh', ['-i'], {
20
+ env: process.env, name: nanoid(6),
21
+ handleFlowControl: true
22
+ });
23
+ promiseQueue = new PromiseQueue();
24
+ constructor() {
25
+ this.initialize();
26
+ }
27
+ async spawn(cmd, options) {
28
+ const spawnResult = await this.spawnSafe(cmd, options);
29
+ if (spawnResult.status !== 'success') {
30
+ throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
31
+ }
32
+ return spawnResult;
33
+ }
34
+ async spawnSafe(cmd, options) {
35
+ // cid is command id
36
+ const cid = nanoid(10);
37
+ debugLog(cid);
38
+ await new Promise((resolve) => {
39
+ // 600 permissions means only the current user will be able to rw from the FIFO
40
+ // Create in /tmp so it could be automatically cleaned up if the clean-up was missed
41
+ const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
42
+ mkfifoSpawn.on('close', () => {
43
+ resolve(null);
44
+ });
45
+ });
46
+ // Use read and write so that the pipe doesn't close
47
+ const fileHandle = await fs.open(`/tmp/${cid}`, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
48
+ let pipe;
49
+ return new Promise((resolve) => {
50
+ pipe = new net.Socket({ fd: fileHandle.fd });
51
+ // pipe.pipe(process.stdout);
52
+ let output = '';
53
+ pipe.on('data', (data) => {
54
+ output += data.toString();
55
+ if (output.includes('%%%done%%%"')) {
56
+ const truncOutput = output.replace('%%%done%%%"\n', '');
57
+ const [data, exit] = truncOutput.split('%%%');
58
+ // Clean up trailing \n newline if it exists
59
+ let strippedData = stripAnsi(data);
60
+ if (strippedData.endsWith('\n')) {
61
+ strippedData = strippedData.slice(0, -1);
62
+ }
63
+ resolve({
64
+ status: Number.parseInt(exit ?? 1, 10) === 0 ? 'success' : 'error',
65
+ exitCode: Number.parseInt(exit ?? 1, 10),
66
+ data: strippedData,
67
+ });
68
+ }
69
+ });
70
+ this.promiseQueue.run(async () => new Promise((resolve) => {
71
+ const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
72
+ // Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
73
+ // Done is used to denote the end of the command
74
+ // Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
75
+ const command = `((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`;
76
+ let output = '';
77
+ const listener = this.basePty.onData((data) => {
78
+ output += data;
79
+ if (output.includes(`%%%done%%%${cid}"`)) {
80
+ listener.dispose();
81
+ resolve(null);
82
+ }
83
+ });
84
+ // console.log(`Running command ${cmd}`)
85
+ this.basePty.write(`${command}\r`);
86
+ }));
87
+ }).finally(async () => {
88
+ // console.log('finally');
89
+ // await fileHandle?.close();
90
+ await fs.rm(`/tmp/${cid}`);
91
+ });
92
+ }
93
+ async kill() {
94
+ return new Promise((resolve) => {
95
+ this.basePty.onExit((status) => {
96
+ resolve(status);
97
+ });
98
+ this.basePty.kill();
99
+ });
100
+ }
101
+ async initialize() {
102
+ // this.basePty.onData((data: string) => process.stdout.write(data));
103
+ await this.promiseQueue.run(async () => {
104
+ let outputBuffer = '';
105
+ return new Promise(resolve => {
106
+ this.basePty.write('unset PS1;\n');
107
+ this.basePty.write('unset PS0;\n');
108
+ this.basePty.write('echo setup complete\\"\n');
109
+ const listener = this.basePty.onData((data) => {
110
+ outputBuffer += data;
111
+ if (outputBuffer.includes('setup complete"')) {
112
+ listener.dispose();
113
+ resolve(null);
114
+ }
115
+ });
116
+ });
117
+ });
118
+ }
119
+ }
@@ -0,0 +1,24 @@
1
+ export interface SpawnResult {
2
+ status: 'success' | 'error';
3
+ exitCode: number;
4
+ data: string;
5
+ }
6
+ export interface SpawnOptions {
7
+ cwd?: string;
8
+ env?: Record<string, unknown>;
9
+ }
10
+ export declare class SpawnError extends Error {
11
+ data: string;
12
+ cmd: string;
13
+ exitCode: number;
14
+ constructor(cmd: string, exitCode: number, data: string);
15
+ }
16
+ export interface IPty {
17
+ spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
18
+ spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
19
+ kill(): Promise<{
20
+ exitCode: number;
21
+ signal?: number | undefined;
22
+ }>;
23
+ }
24
+ export declare function getPty(): IPty;
@@ -0,0 +1,15 @@
1
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
2
+ export class SpawnError extends Error {
3
+ data;
4
+ cmd;
5
+ exitCode;
6
+ constructor(cmd, exitCode, data) {
7
+ super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`);
8
+ this.data = data;
9
+ this.cmd = cmd;
10
+ this.exitCode = exitCode;
11
+ }
12
+ }
13
+ export function getPty() {
14
+ return ptyLocalStorage.getStore();
15
+ }
@@ -0,0 +1,5 @@
1
+ export declare class PromiseQueue {
2
+ private queue;
3
+ private eventBus;
4
+ run<T>(fn: () => Promise<T> | T): Promise<T>;
5
+ }
@@ -0,0 +1,26 @@
1
+ import { nanoid } from 'nanoid';
2
+ import EventEmitter from 'node:events';
3
+ export class PromiseQueue {
4
+ // Cid stands for command id;
5
+ queue = [];
6
+ eventBus = new EventEmitter();
7
+ async run(fn) {
8
+ const cid = nanoid();
9
+ this.queue.push({ cid, fn });
10
+ if (this.queue.length !== 1) {
11
+ await new Promise((resolve) => {
12
+ const listener = () => {
13
+ if (this.queue[0].cid === cid) {
14
+ this.eventBus.removeListener('dequeue', listener);
15
+ resolve(null);
16
+ }
17
+ };
18
+ this.eventBus.on('dequeue', listener);
19
+ });
20
+ }
21
+ const result = await fn();
22
+ this.queue.shift();
23
+ this.eventBus.emit('dequeue');
24
+ return result;
25
+ }
26
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defaultExclude, defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ pool: 'forks',
5
+ fileParallelism: false,
6
+ exclude: [
7
+ ...defaultExclude,
8
+ './src/utils/test-utils.test.ts',
9
+ ]
10
+ },
11
+ });
@@ -0,0 +1,2 @@
1
+ export declare function debugLog(message: any): void;
2
+ export declare function debugWrite(message: any): void;
@@ -0,0 +1,10 @@
1
+ export function debugLog(message) {
2
+ if (process.env.DEBUG) {
3
+ console.log(message);
4
+ }
5
+ }
6
+ export function debugWrite(message) {
7
+ if (process.env.DEBUG) {
8
+ process.stdout.write(message);
9
+ }
10
+ }
@@ -0,0 +1,3 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ export declare const ptyLocalStorage: AsyncLocalStorage<unknown>;
@@ -0,0 +1,2 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export const ptyLocalStorage = new AsyncLocalStorage();
@@ -1,34 +1,4 @@
1
- /// <reference types="node" resolution-mode="require"/>
2
1
  import { ResourceConfig, StringIndexedObject } from 'codify-schemas';
3
- import { SpawnOptions } from 'node:child_process';
4
- export declare enum SpawnStatus {
5
- SUCCESS = "success",
6
- ERROR = "error"
7
- }
8
- export interface SpawnResult {
9
- status: SpawnStatus;
10
- data: string;
11
- }
12
- type CodifySpawnOptions = {
13
- cwd?: string;
14
- stdioString?: boolean;
15
- } & SpawnOptions;
16
- /**
17
- *
18
- * @param cmd Command to run. Ex: `rm -rf`
19
- * @param args Optional additional arguments to append
20
- * @param opts Standard options for node spawn. Additional argument:
21
- * throws determines if a shell will throw a JS error. Defaults to true
22
- * @param extras From PromiseSpawn
23
- *
24
- * @see promiseSpawn
25
- * @see spawn
26
- *
27
- * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
28
- */
29
- export declare function codifySpawn(cmd: string, args?: string[], opts?: Omit<CodifySpawnOptions, 'stdio' | 'stdioString'> & {
30
- throws?: boolean;
31
- }, extras?: Record<any, any>): Promise<SpawnResult>;
32
2
  export declare function isDebug(): boolean;
33
3
  export declare function splitUserConfig<T extends StringIndexedObject>(config: ResourceConfig & T): {
34
4
  parameters: T;
@@ -37,4 +7,3 @@ export declare function splitUserConfig<T extends StringIndexedObject>(config: R
37
7
  export declare function setsEqual(set1: Set<unknown>, set2: Set<unknown>): boolean;
38
8
  export declare function untildify(pathWithTilde: string): string;
39
9
  export declare function areArraysEqual(isElementEqual: ((desired: unknown, current: unknown) => boolean) | undefined, desired: unknown, current: unknown): boolean;
40
- export {};
@@ -1,54 +1,4 @@
1
- import promiseSpawn from '@npmcli/promise-spawn';
2
1
  import os from 'node:os';
3
- export var SpawnStatus;
4
- (function (SpawnStatus) {
5
- SpawnStatus["SUCCESS"] = "success";
6
- SpawnStatus["ERROR"] = "error";
7
- })(SpawnStatus || (SpawnStatus = {}));
8
- /**
9
- *
10
- * @param cmd Command to run. Ex: `rm -rf`
11
- * @param args Optional additional arguments to append
12
- * @param opts Standard options for node spawn. Additional argument:
13
- * throws determines if a shell will throw a JS error. Defaults to true
14
- * @param extras From PromiseSpawn
15
- *
16
- * @see promiseSpawn
17
- * @see spawn
18
- *
19
- * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
20
- */
21
- export async function codifySpawn(cmd, args, opts, extras) {
22
- try {
23
- // TODO: Need to benchmark the effects of using sh vs zsh for shell.
24
- // Seems like zsh shells run slower
25
- const result = await promiseSpawn(cmd, args ?? [], { ...opts, stdio: 'pipe', stdioString: true, shell: opts?.shell ?? process.env.SHELL }, extras);
26
- if (isDebug()) {
27
- console.log(`codifySpawn result for: ${cmd}`);
28
- console.log(JSON.stringify(result, null, 2));
29
- }
30
- const status = result.code === 0
31
- ? SpawnStatus.SUCCESS
32
- : SpawnStatus.ERROR;
33
- return {
34
- status,
35
- data: status === SpawnStatus.SUCCESS ? result.stdout : result.stderr
36
- };
37
- }
38
- catch (error) {
39
- const shouldThrow = opts?.throws ?? true;
40
- if (isDebug() || shouldThrow) {
41
- console.error(`CodifySpawn Error for command ${cmd} ${args}`, error);
42
- }
43
- if (shouldThrow) {
44
- throw error;
45
- }
46
- return {
47
- status: SpawnStatus.ERROR,
48
- data: error,
49
- };
50
- }
51
- }
52
2
  export function isDebug() {
53
3
  return process.env.DEBUG != null && process.env.DEBUG.includes('codify'); // TODO: replace with debug library
54
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codify-plugin-lib",
3
- "version": "1.0.109",
3
+ "version": "1.0.111",
4
4
  "description": "Library plugin library",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -17,7 +17,10 @@
17
17
  "codify-schemas": "1.0.53",
18
18
  "@npmcli/promise-spawn": "^7.0.1",
19
19
  "uuid": "^10.0.0",
20
- "lodash.isequal": "^4.5.0"
20
+ "lodash.isequal": "^4.5.0",
21
+ "nanoid": "^5.0.9",
22
+ "node-pty": "^1.0.0",
23
+ "strip-ansi": "^7.1.0"
21
24
  },
22
25
  "devDependencies": {
23
26
  "@oclif/prettier-config": "^0.2.1",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './plan/change-set.js'
6
6
  export * from './plan/plan.js'
7
7
  export * from './plan/plan-types.js'
8
8
  export * from './plugin/plugin.js'
9
+ export * from './pty/index.js'
9
10
  export * from './resource/parsed-resource-settings.js';
10
11
  export * from './resource/resource.js'
11
12
  export * from './resource/resource-settings.js'
@@ -16,9 +16,12 @@ import {
16
16
  import { Plan } from '../plan/plan.js';
17
17
  import { Resource } from '../resource/resource.js';
18
18
  import { ResourceController } from '../resource/resource-controller.js';
19
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
20
+ import { BackgroundPty } from '../pty/background-pty.js';
19
21
 
20
22
  export class Plugin {
21
23
  planStorage: Map<string, Plan<any>>;
24
+ planPty = new BackgroundPty();
22
25
 
23
26
  constructor(
24
27
  public name: string,
@@ -119,11 +122,14 @@ export class Plugin {
119
122
  throw new Error(`Resource type not found: ${type}`);
120
123
  }
121
124
 
122
- const plan = await this.resourceControllers.get(type)!.plan(
123
- data.desired ?? null,
124
- data.state ?? null,
125
- data.isStateful
126
- );
125
+ const plan = await ptyLocalStorage.run(this.planPty, async () => {
126
+ return this.resourceControllers.get(type)!.plan(
127
+ data.desired ?? null,
128
+ data.state ?? null,
129
+ data.isStateful
130
+ );
131
+ })
132
+
127
133
  this.planStorage.set(plan.id, plan);
128
134
 
129
135
  return plan.toResponse();
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { BackgroundPty } from './background-pty.js';
3
+
4
+ describe('BackgroundPty tests', () => {
5
+ it('Can launch a simple command', async () => {
6
+ const pty = new BackgroundPty();
7
+
8
+ const result = await pty.spawnSafe('ls');
9
+ expect(result).toMatchObject({
10
+ status: 'success',
11
+ exitCode: 0,
12
+ })
13
+
14
+
15
+ const exitCode = await pty.kill();
16
+ expect(exitCode).toMatchObject({
17
+ exitCode: 1,
18
+ signal: 0,
19
+ });
20
+ })
21
+
22
+ it('Can launch 100 commands in parallel', { timeout: 15000 }, async () => {
23
+ const pty = new BackgroundPty();
24
+
25
+ const fn = async () => pty.spawnSafe('ls');
26
+
27
+ const results = await Promise.all(
28
+ Array.from({ length: 100 }, (_, i) => i + 1)
29
+ .map(() => fn())
30
+ )
31
+
32
+ expect(results.length).to.eq(100);
33
+ expect(results.every((r) => r.exitCode === 0))
34
+
35
+ await pty.kill();
36
+ })
37
+
38
+ it('Reports back the correct exit code and status', async () => {
39
+ const pty = new BackgroundPty();
40
+
41
+ const resultSuccess = await pty.spawnSafe('ls');
42
+ expect(resultSuccess).toMatchObject({
43
+ status: 'success',
44
+ exitCode: 0,
45
+ })
46
+
47
+ const resultFailed = await pty.spawnSafe('which sjkdhsakjdhjkash');
48
+ expect(resultFailed).toMatchObject({
49
+ status: 'error',
50
+ exitCode: 1,
51
+ data: 'sjkdhsakjdhjkash not found' // This might change on different os or shells. Keep for now.
52
+ })
53
+
54
+ await pty.kill();
55
+ });
56
+
57
+ it('Can use a different cwd', async () => {
58
+ const pty = new BackgroundPty();
59
+
60
+ const resultSuccess = await pty.spawnSafe('pwd', { cwd: '/tmp' });
61
+ expect(resultSuccess).toMatchObject({
62
+ status: 'success',
63
+ exitCode: 0,
64
+ data: '/tmp'
65
+ })
66
+
67
+ await pty.kill();
68
+ });
69
+ })
@@ -0,0 +1,147 @@
1
+ import { nanoid } from 'nanoid';
2
+ import * as cp from 'node:child_process';
3
+ import { EventEmitter } from 'node:events';
4
+ import * as fs from 'node:fs/promises';
5
+ import * as net from 'node:net';
6
+ import pty from 'node-pty';
7
+ import stripAnsi from 'strip-ansi';
8
+
9
+ import { IPty, SpawnError, SpawnOptions, SpawnResult } from './index.js';
10
+ import { PromiseQueue } from './promise-queue.js';
11
+ import { debugLog } from '../utils/debug.js';
12
+
13
+ EventEmitter.defaultMaxListeners = 1000;
14
+
15
+ /**
16
+ * The background pty is a specialized pty designed for speed. It can launch multiple tasks
17
+ * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
18
+ * to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
19
+ * without a tty (or even a stdin) attached so interactive commands will not work.
20
+ */
21
+ export class BackgroundPty implements IPty {
22
+ private basePty = pty.spawn('zsh', ['-i'], {
23
+ env: process.env, name: nanoid(6),
24
+ handleFlowControl: true
25
+ });
26
+
27
+ private promiseQueue = new PromiseQueue();
28
+
29
+ constructor() {
30
+ this.initialize();
31
+ }
32
+
33
+ async spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
34
+ const spawnResult = await this.spawnSafe(cmd, options);
35
+
36
+ if (spawnResult.status !== 'success') {
37
+ throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
38
+ }
39
+
40
+ return spawnResult;
41
+ }
42
+
43
+ async spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
44
+ // cid is command id
45
+ const cid = nanoid(10);
46
+ debugLog(cid);
47
+
48
+ await new Promise((resolve) => {
49
+ // 600 permissions means only the current user will be able to rw from the FIFO
50
+ // Create in /tmp so it could be automatically cleaned up if the clean-up was missed
51
+ const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
52
+ mkfifoSpawn.on('close', () => {
53
+ resolve(null);
54
+ })
55
+ })
56
+
57
+ // Use read and write so that the pipe doesn't close
58
+ const fileHandle = await fs.open(`/tmp/${cid}`, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
59
+ let pipe: net.Socket;
60
+
61
+ return new Promise<SpawnResult>((resolve) => {
62
+ pipe = new net.Socket({ fd: fileHandle.fd });
63
+
64
+ // pipe.pipe(process.stdout);
65
+
66
+ let output = '';
67
+ pipe.on('data', (data) => {
68
+ output += data.toString();
69
+
70
+ if (output.includes('%%%done%%%"')) {
71
+ const truncOutput = output.replace('%%%done%%%"\n', '');
72
+ const [data, exit] = truncOutput.split('%%%');
73
+
74
+ // Clean up trailing \n newline if it exists
75
+ let strippedData = stripAnsi(data);
76
+ if (strippedData.endsWith('\n')) {
77
+ strippedData = strippedData.slice(0, -1);
78
+ }
79
+
80
+ resolve(<SpawnResult>{
81
+ status: Number.parseInt(exit ?? 1, 10) === 0 ? 'success' : 'error',
82
+ exitCode: Number.parseInt(exit ?? 1, 10),
83
+ data: strippedData,
84
+ });
85
+ }
86
+ })
87
+
88
+ this.promiseQueue.run(async () => new Promise((resolve) => {
89
+ const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
90
+ // Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
91
+ // Done is used to denote the end of the command
92
+ // Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
93
+ const command = `((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`
94
+
95
+ let output = '';
96
+ const listener = this.basePty.onData((data: any) => {
97
+ output += data;
98
+
99
+ if (output.includes(`%%%done%%%${cid}"`)) {
100
+ listener.dispose();
101
+ resolve(null);
102
+ }
103
+ });
104
+
105
+ // console.log(`Running command ${cmd}`)
106
+ this.basePty.write(`${command}\r`);
107
+
108
+ }));
109
+ }).finally(async () => {
110
+ // console.log('finally');
111
+ // await fileHandle?.close();
112
+ await fs.rm(`/tmp/${cid}`);
113
+ })
114
+ }
115
+
116
+ async kill(): Promise<{ exitCode: number, signal?: number | undefined }> {
117
+ return new Promise((resolve) => {
118
+ this.basePty.onExit((status) => {
119
+ resolve(status);
120
+ })
121
+
122
+ this.basePty.kill()
123
+ })
124
+ }
125
+
126
+ private async initialize() {
127
+ // this.basePty.onData((data: string) => process.stdout.write(data));
128
+
129
+ await this.promiseQueue.run(async () => {
130
+ let outputBuffer = '';
131
+
132
+ return new Promise(resolve => {
133
+ this.basePty.write('unset PS1;\n');
134
+ this.basePty.write('unset PS0;\n')
135
+ this.basePty.write('echo setup complete\\"\n')
136
+
137
+ const listener = this.basePty.onData((data: string) => {
138
+ outputBuffer += data;
139
+ if (outputBuffer.includes('setup complete"')) {
140
+ listener.dispose();
141
+ resolve(null);
142
+ }
143
+ })
144
+ })
145
+ })
146
+ }
147
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it, vitest } from 'vitest';
2
+ import { TestConfig, TestResource } from '../utils/test-utils.test.js';
3
+ import { getPty, IPty } from './index.js';
4
+ import { Plugin } from '../plugin/plugin.js'
5
+ import { CreatePlan } from '../plan/plan-types.js';
6
+ import { ResourceOperation } from 'codify-schemas';
7
+ import { ResourceSettings } from '../resource/resource-settings.js';
8
+
9
+ describe('General tests for PTYs', () => {
10
+ it('Can get pty within refresh', async () => {
11
+ const testResource = new class extends TestResource {
12
+ async refresh(): Promise<Partial<TestConfig> | null> {
13
+ const $ = getPty();
14
+ const lsResult = await $.spawnSafe('ls');
15
+
16
+ expect(lsResult.exitCode).to.eq(0);
17
+ expect(lsResult.data).to.be.not.null;
18
+ expect(lsResult.status).to.eq('success');
19
+
20
+ return {};
21
+ }
22
+ }
23
+
24
+ const spy = vitest.spyOn(testResource, 'refresh')
25
+
26
+ const plugin = Plugin.create('test plugin', [testResource])
27
+ const plan = await plugin.plan({
28
+ desired: {
29
+ type: 'type'
30
+ },
31
+ state: undefined,
32
+ isStateful: false,
33
+ })
34
+
35
+ expect(plan).toMatchObject({
36
+ operation: 'noop',
37
+ resourceType: 'type',
38
+ })
39
+ expect(spy).toHaveBeenCalledOnce()
40
+ })
41
+
42
+ it('The same pty instance is shared cross multiple resources', async () => {
43
+ let pty1: IPty;
44
+ let pty2: IPty;
45
+
46
+ const testResource1 = new class extends TestResource {
47
+ getSettings(): ResourceSettings<TestConfig> {
48
+ return {
49
+ id: 'type1'
50
+ }
51
+ }
52
+
53
+ async refresh(): Promise<Partial<TestConfig> | null> {
54
+ const $ = getPty();
55
+ const lsResult = await $.spawnSafe('ls');
56
+
57
+ expect(lsResult.exitCode).to.eq(0);
58
+ pty1 = $;
59
+
60
+ return {};
61
+ }
62
+ }
63
+
64
+ const testResource2 = new class extends TestResource {
65
+ getSettings(): ResourceSettings<TestConfig> {
66
+ return {
67
+ id: 'type2',
68
+ }
69
+ }
70
+
71
+ async refresh(): Promise<Partial<TestConfig> | null> {
72
+ const $ = getPty();
73
+ const pwdResult = await $.spawnSafe('pwd');
74
+
75
+ expect(pwdResult.exitCode).to.eq(0);
76
+ pty2 = $;
77
+
78
+ return {};
79
+ }
80
+ }
81
+
82
+ const spy1 = vitest.spyOn(testResource1, 'refresh')
83
+ const spy2 = vitest.spyOn(testResource2, 'refresh')
84
+
85
+ const plugin = Plugin.create('test plugin', [testResource1, testResource2]);
86
+ await plugin.plan({
87
+ desired: {
88
+ type: 'type1'
89
+ },
90
+ state: undefined,
91
+ isStateful: false,
92
+ })
93
+
94
+ await plugin.plan({
95
+ desired: {
96
+ type: 'type2'
97
+ },
98
+ state: undefined,
99
+ isStateful: false,
100
+ })
101
+
102
+ expect(spy1).toHaveBeenCalledOnce();
103
+ expect(spy2).toHaveBeenCalledOnce();
104
+
105
+ // The main check here is that the refresh method for both are sharing the same pty instance.
106
+ expect(pty1).to.eq(pty2);
107
+ })
108
+
109
+ it('Currently pty not available for apply', async () => {
110
+ const testResource = new class extends TestResource {
111
+ create(plan: CreatePlan<TestConfig>): Promise<void> {
112
+ const $ = getPty();
113
+ expect($).to.be.undefined;
114
+ }
115
+ }
116
+
117
+ const spy = vitest.spyOn(testResource, 'create')
118
+
119
+ const plugin = Plugin.create('test plugin', [testResource])
120
+ await plugin.apply({
121
+ plan: {
122
+ operation: ResourceOperation.CREATE,
123
+ resourceType: 'type',
124
+ parameters: [],
125
+ }
126
+ })
127
+ expect(spy).toHaveBeenCalledOnce()
128
+ })
129
+ })
@@ -0,0 +1,39 @@
1
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
2
+
3
+ export interface SpawnResult {
4
+ status: 'success' | 'error';
5
+ exitCode: number;
6
+ data: string;
7
+ }
8
+
9
+ export interface SpawnOptions {
10
+ cwd?: string;
11
+ env?: Record<string, unknown>,
12
+ }
13
+
14
+ export class SpawnError extends Error {
15
+ data: string;
16
+ cmd: string;
17
+ exitCode: number;
18
+
19
+ constructor(cmd: string, exitCode: number, data: string) {
20
+ super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`);
21
+
22
+ this.data = data;
23
+ this.cmd = cmd;
24
+ this.exitCode = exitCode;
25
+ }
26
+
27
+ }
28
+
29
+ export interface IPty {
30
+ spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>
31
+
32
+ spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>
33
+
34
+ kill(): Promise<{ exitCode: number, signal?: number | undefined }>
35
+ }
36
+
37
+ export function getPty(): IPty {
38
+ return ptyLocalStorage.getStore() as IPty;
39
+ }
@@ -0,0 +1,33 @@
1
+ import { nanoid } from 'nanoid';
2
+ import EventEmitter from 'node:events';
3
+
4
+ export class PromiseQueue {
5
+ // Cid stands for command id;
6
+ private queue: Array<{ cid: string, fn: () => Promise<any> | any }> = [];
7
+ private eventBus = new EventEmitter()
8
+
9
+ async run<T>(fn: () => Promise<T> | T): Promise<T> {
10
+ const cid = nanoid();
11
+ this.queue.push({ cid, fn })
12
+
13
+ if (this.queue.length !== 1) {
14
+ await new Promise((resolve) => {
15
+ const listener = () => {
16
+ if (this.queue[0].cid === cid) {
17
+ this.eventBus.removeListener('dequeue', listener);
18
+ resolve(null);
19
+ }
20
+ }
21
+
22
+ this.eventBus.on('dequeue', listener);
23
+ });
24
+ }
25
+
26
+ const result = await fn();
27
+
28
+ this.queue.shift();
29
+ this.eventBus.emit('dequeue');
30
+
31
+ return result;
32
+ }
33
+ }
@@ -0,0 +1,12 @@
1
+ import { defaultExclude, defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ pool: 'forks',
6
+ fileParallelism: false,
7
+ exclude: [
8
+ ...defaultExclude,
9
+ './src/utils/test-utils.test.ts',
10
+ ]
11
+ },
12
+ });
@@ -0,0 +1,11 @@
1
+ export function debugLog(message: any): void {
2
+ if (process.env.DEBUG) {
3
+ console.log(message);
4
+ }
5
+ }
6
+
7
+ export function debugWrite(message: any): void {
8
+ if (process.env.DEBUG) {
9
+ process.stdout.write(message);
10
+ }
11
+ }
@@ -0,0 +1,3 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export const ptyLocalStorage = new AsyncLocalStorage();
@@ -1,82 +1,6 @@
1
- import promiseSpawn from '@npmcli/promise-spawn';
2
1
  import { ResourceConfig, StringIndexedObject } from 'codify-schemas';
3
- import { SpawnOptions } from 'node:child_process';
4
2
  import os from 'node:os';
5
3
 
6
- export enum SpawnStatus {
7
- SUCCESS = 'success',
8
- ERROR = 'error',
9
- }
10
-
11
- export interface SpawnResult {
12
- status: SpawnStatus,
13
- data: string;
14
- }
15
-
16
- type CodifySpawnOptions = {
17
- cwd?: string;
18
- stdioString?: boolean;
19
- } & SpawnOptions
20
-
21
- /**
22
- *
23
- * @param cmd Command to run. Ex: `rm -rf`
24
- * @param args Optional additional arguments to append
25
- * @param opts Standard options for node spawn. Additional argument:
26
- * throws determines if a shell will throw a JS error. Defaults to true
27
- * @param extras From PromiseSpawn
28
- *
29
- * @see promiseSpawn
30
- * @see spawn
31
- *
32
- * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
33
- */
34
- export async function codifySpawn(
35
- cmd: string,
36
- args?: string[],
37
- opts?: Omit<CodifySpawnOptions, 'stdio' | 'stdioString'> & { throws?: boolean },
38
- extras?: Record<any, any>,
39
- ): Promise<SpawnResult> {
40
- try {
41
- // TODO: Need to benchmark the effects of using sh vs zsh for shell.
42
- // Seems like zsh shells run slower
43
- const result = await promiseSpawn(
44
- cmd,
45
- args ?? [],
46
- { ...opts, stdio: 'pipe', stdioString: true, shell: opts?.shell ?? process.env.SHELL },
47
- extras,
48
- );
49
-
50
- if (isDebug()) {
51
- console.log(`codifySpawn result for: ${cmd}`);
52
- console.log(JSON.stringify(result, null, 2))
53
- }
54
-
55
- const status = result.code === 0
56
- ? SpawnStatus.SUCCESS
57
- : SpawnStatus.ERROR;
58
-
59
- return {
60
- status,
61
- data: status === SpawnStatus.SUCCESS ? result.stdout : result.stderr
62
- }
63
- } catch (error) {
64
- const shouldThrow = opts?.throws ?? true;
65
- if (isDebug() || shouldThrow) {
66
- console.error(`CodifySpawn Error for command ${cmd} ${args}`, error);
67
- }
68
-
69
- if (shouldThrow) {
70
- throw error;
71
- }
72
-
73
- return {
74
- status: SpawnStatus.ERROR,
75
- data: error as string,
76
- }
77
- }
78
- }
79
-
80
4
  export function isDebug(): boolean {
81
5
  return process.env.DEBUG != null && process.env.DEBUG.includes('codify'); // TODO: replace with debug library
82
6
  }
package/tsconfig.json CHANGED
@@ -20,6 +20,7 @@
20
20
  ],
21
21
  "exclude": [
22
22
  "node_modules",
23
- "src/**/*.test.ts"
23
+ "src/**/*.test.ts",
24
+ "**/vitest.config.ts"
24
25
  ]
25
26
  }
package/vitest.config.ts CHANGED
@@ -4,7 +4,8 @@ export default defineConfig({
4
4
  test: {
5
5
  exclude: [
6
6
  ...defaultExclude,
7
- './src/utils/test-utils.test.ts'
7
+ './src/utils/test-utils.test.ts',
8
+ './src/pty/*'
8
9
  ]
9
10
  },
10
11
  });