codify-plugin-lib 1.0.108 → 1.0.110

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,9 +4,9 @@ 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';
10
11
  export * from './stateful-parameter/stateful-parameter.js';
11
- export * from './utils/utils.js';
12
12
  export declare function runPlugin(plugin: Plugin): Promise<void>;
package/dist/index.js CHANGED
@@ -4,11 +4,12 @@ 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 './utils/utils.js'
8
+ export * from './pty/index.js';
7
9
  export * from './resource/parsed-resource-settings.js';
8
10
  export * from './resource/resource.js';
9
11
  export * from './resource/resource-settings.js';
10
12
  export * from './stateful-parameter/stateful-parameter.js';
11
- export * from './utils/utils.js';
12
13
  export async function runPlugin(plugin) {
13
14
  const messageHandler = new MessageHandler(plugin);
14
15
  process.on('message', (message) => messageHandler.onMessage(message));
@@ -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
+ });
@@ -263,13 +263,13 @@ ${JSON.stringify(refresh, null, 2)}
263
263
  const result = {};
264
264
  const sortedEntries = Object.entries(statefulParametersConfig)
265
265
  .sort(([key1], [key2]) => this.parsedSettings.statefulParameterOrder.get(key1) - this.parsedSettings.statefulParameterOrder.get(key2));
266
- for (const [key, desiredValue] of sortedEntries) {
266
+ await Promise.all(sortedEntries.map(async ([key, desiredValue]) => {
267
267
  const statefulParameter = this.parsedSettings.statefulParameters.get(key);
268
268
  if (!statefulParameter) {
269
269
  throw new Error(`Stateful parameter ${key} was not found`);
270
270
  }
271
271
  result[key] = await statefulParameter.refresh(desiredValue ?? null, allParameters);
272
- }
272
+ }));
273
273
  return result;
274
274
  }
275
275
  validatePlanInputs(desired, current, statefulMode) {
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codify-plugin-lib",
3
- "version": "1.0.108",
3
+ "version": "1.0.110",
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,11 +6,12 @@ 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 './utils/utils.js'
10
+ export * from './pty/index.js'
9
11
  export * from './resource/parsed-resource-settings.js';
10
12
  export * from './resource/resource.js'
11
13
  export * from './resource/resource-settings.js'
12
14
  export * from './stateful-parameter/stateful-parameter.js'
13
- export * from './utils/utils.js'
14
15
 
15
16
  export async function runPlugin(plugin: Plugin) {
16
17
  const messageHandler = new MessageHandler(plugin);
@@ -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
+ });
@@ -362,14 +362,14 @@ ${JSON.stringify(refresh, null, 2)}
362
362
  ([key1], [key2]) => this.parsedSettings.statefulParameterOrder.get(key1)! - this.parsedSettings.statefulParameterOrder.get(key2)!
363
363
  )
364
364
 
365
- for (const [key, desiredValue] of sortedEntries) {
365
+ await Promise.all(sortedEntries.map(async ([key, desiredValue]) => {
366
366
  const statefulParameter = this.parsedSettings.statefulParameters.get(key);
367
367
  if (!statefulParameter) {
368
368
  throw new Error(`Stateful parameter ${key} was not found`);
369
369
  }
370
370
 
371
371
  (result as Record<string, unknown>)[key] = await statefulParameter.refresh(desiredValue ?? null, allParameters)
372
- }
372
+ }))
373
373
 
374
374
  return result;
375
375
  }
@@ -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();
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
  });