@codifycli/plugin-core 1.0.0-beta1

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.
Files changed (152) hide show
  1. package/.eslintignore +2 -0
  2. package/.eslintrc.json +30 -0
  3. package/.github/workflows/release.yaml +19 -0
  4. package/.github/workflows/unit-test-ci.yaml +18 -0
  5. package/.prettierrc.json +1 -0
  6. package/bin/build.js +189 -0
  7. package/dist/bin/build.d.ts +1 -0
  8. package/dist/bin/build.js +80 -0
  9. package/dist/bin/deploy-plugin.d.ts +2 -0
  10. package/dist/bin/deploy-plugin.js +8 -0
  11. package/dist/common/errors.d.ts +8 -0
  12. package/dist/common/errors.js +24 -0
  13. package/dist/entities/change-set.d.ts +24 -0
  14. package/dist/entities/change-set.js +152 -0
  15. package/dist/entities/errors.d.ts +4 -0
  16. package/dist/entities/errors.js +7 -0
  17. package/dist/entities/plan-types.d.ts +25 -0
  18. package/dist/entities/plan-types.js +1 -0
  19. package/dist/entities/plan.d.ts +15 -0
  20. package/dist/entities/plan.js +127 -0
  21. package/dist/entities/plugin.d.ts +16 -0
  22. package/dist/entities/plugin.js +80 -0
  23. package/dist/entities/resource-options.d.ts +31 -0
  24. package/dist/entities/resource-options.js +76 -0
  25. package/dist/entities/resource-types.d.ts +11 -0
  26. package/dist/entities/resource-types.js +1 -0
  27. package/dist/entities/resource.d.ts +42 -0
  28. package/dist/entities/resource.js +303 -0
  29. package/dist/entities/stateful-parameter.d.ts +29 -0
  30. package/dist/entities/stateful-parameter.js +46 -0
  31. package/dist/entities/transform-parameter.d.ts +4 -0
  32. package/dist/entities/transform-parameter.js +2 -0
  33. package/dist/errors.d.ts +4 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/index.d.ts +20 -0
  36. package/dist/index.js +26 -0
  37. package/dist/messages/handlers.d.ts +14 -0
  38. package/dist/messages/handlers.js +134 -0
  39. package/dist/messages/sender.d.ts +11 -0
  40. package/dist/messages/sender.js +57 -0
  41. package/dist/plan/change-set.d.ts +53 -0
  42. package/dist/plan/change-set.js +153 -0
  43. package/dist/plan/plan-types.d.ts +23 -0
  44. package/dist/plan/plan-types.js +1 -0
  45. package/dist/plan/plan.d.ts +66 -0
  46. package/dist/plan/plan.js +328 -0
  47. package/dist/plugin/plugin.d.ts +24 -0
  48. package/dist/plugin/plugin.js +200 -0
  49. package/dist/pty/background-pty.d.ts +21 -0
  50. package/dist/pty/background-pty.js +127 -0
  51. package/dist/pty/index.d.ts +50 -0
  52. package/dist/pty/index.js +20 -0
  53. package/dist/pty/promise-queue.d.ts +5 -0
  54. package/dist/pty/promise-queue.js +26 -0
  55. package/dist/pty/seqeuntial-pty.d.ts +17 -0
  56. package/dist/pty/seqeuntial-pty.js +119 -0
  57. package/dist/pty/vitest.config.d.ts +2 -0
  58. package/dist/pty/vitest.config.js +11 -0
  59. package/dist/resource/config-parser.d.ts +11 -0
  60. package/dist/resource/config-parser.js +21 -0
  61. package/dist/resource/parsed-resource-settings.d.ts +47 -0
  62. package/dist/resource/parsed-resource-settings.js +196 -0
  63. package/dist/resource/resource-controller.d.ts +36 -0
  64. package/dist/resource/resource-controller.js +402 -0
  65. package/dist/resource/resource-settings.d.ts +303 -0
  66. package/dist/resource/resource-settings.js +147 -0
  67. package/dist/resource/resource.d.ts +144 -0
  68. package/dist/resource/resource.js +44 -0
  69. package/dist/resource/stateful-parameter.d.ts +165 -0
  70. package/dist/resource/stateful-parameter.js +94 -0
  71. package/dist/scripts/deploy.d.ts +1 -0
  72. package/dist/scripts/deploy.js +2 -0
  73. package/dist/stateful-parameter/stateful-parameter-controller.d.ts +21 -0
  74. package/dist/stateful-parameter/stateful-parameter-controller.js +81 -0
  75. package/dist/stateful-parameter/stateful-parameter.d.ts +144 -0
  76. package/dist/stateful-parameter/stateful-parameter.js +43 -0
  77. package/dist/test.d.ts +1 -0
  78. package/dist/test.js +5 -0
  79. package/dist/utils/codify-spawn.d.ts +29 -0
  80. package/dist/utils/codify-spawn.js +136 -0
  81. package/dist/utils/debug.d.ts +2 -0
  82. package/dist/utils/debug.js +10 -0
  83. package/dist/utils/file-utils.d.ts +23 -0
  84. package/dist/utils/file-utils.js +186 -0
  85. package/dist/utils/functions.d.ts +12 -0
  86. package/dist/utils/functions.js +74 -0
  87. package/dist/utils/index.d.ts +46 -0
  88. package/dist/utils/index.js +271 -0
  89. package/dist/utils/internal-utils.d.ts +12 -0
  90. package/dist/utils/internal-utils.js +74 -0
  91. package/dist/utils/load-resources.d.ts +1 -0
  92. package/dist/utils/load-resources.js +46 -0
  93. package/dist/utils/package-json-utils.d.ts +12 -0
  94. package/dist/utils/package-json-utils.js +34 -0
  95. package/dist/utils/pty-local-storage.d.ts +2 -0
  96. package/dist/utils/pty-local-storage.js +2 -0
  97. package/dist/utils/spawn-2.d.ts +5 -0
  98. package/dist/utils/spawn-2.js +7 -0
  99. package/dist/utils/spawn.d.ts +29 -0
  100. package/dist/utils/spawn.js +124 -0
  101. package/dist/utils/utils.d.ts +18 -0
  102. package/dist/utils/utils.js +86 -0
  103. package/dist/utils/verbosity-level.d.ts +5 -0
  104. package/dist/utils/verbosity-level.js +9 -0
  105. package/package.json +59 -0
  106. package/rollup.config.js +24 -0
  107. package/src/common/errors.test.ts +43 -0
  108. package/src/common/errors.ts +31 -0
  109. package/src/errors.ts +8 -0
  110. package/src/index.test.ts +6 -0
  111. package/src/index.ts +30 -0
  112. package/src/messages/handlers.test.ts +329 -0
  113. package/src/messages/handlers.ts +181 -0
  114. package/src/messages/sender.ts +69 -0
  115. package/src/plan/change-set.test.ts +280 -0
  116. package/src/plan/change-set.ts +236 -0
  117. package/src/plan/plan-types.ts +27 -0
  118. package/src/plan/plan.test.ts +413 -0
  119. package/src/plan/plan.ts +499 -0
  120. package/src/plugin/plugin.test.ts +533 -0
  121. package/src/plugin/plugin.ts +291 -0
  122. package/src/pty/background-pty.test.ts +69 -0
  123. package/src/pty/background-pty.ts +154 -0
  124. package/src/pty/index.test.ts +129 -0
  125. package/src/pty/index.ts +66 -0
  126. package/src/pty/promise-queue.ts +33 -0
  127. package/src/pty/seqeuntial-pty.ts +151 -0
  128. package/src/pty/sequential-pty.test.ts +194 -0
  129. package/src/resource/config-parser.ts +42 -0
  130. package/src/resource/parsed-resource-settings.test.ts +186 -0
  131. package/src/resource/parsed-resource-settings.ts +307 -0
  132. package/src/resource/resource-controller-stateful-mode.test.ts +253 -0
  133. package/src/resource/resource-controller.test.ts +1081 -0
  134. package/src/resource/resource-controller.ts +563 -0
  135. package/src/resource/resource-settings.test.ts +1213 -0
  136. package/src/resource/resource-settings.ts +545 -0
  137. package/src/resource/resource.ts +157 -0
  138. package/src/stateful-parameter/stateful-parameter-controller.test.ts +244 -0
  139. package/src/stateful-parameter/stateful-parameter-controller.ts +111 -0
  140. package/src/stateful-parameter/stateful-parameter.ts +160 -0
  141. package/src/utils/debug.ts +11 -0
  142. package/src/utils/file-utils.test.ts +7 -0
  143. package/src/utils/file-utils.ts +231 -0
  144. package/src/utils/functions.ts +103 -0
  145. package/src/utils/index.ts +340 -0
  146. package/src/utils/internal-utils.test.ts +52 -0
  147. package/src/utils/pty-local-storage.ts +3 -0
  148. package/src/utils/test-utils.test.ts +96 -0
  149. package/src/utils/verbosity-level.ts +11 -0
  150. package/tsconfig.json +26 -0
  151. package/tsconfig.test.json +9 -0
  152. package/vitest.config.ts +10 -0
@@ -0,0 +1,291 @@
1
+ import { JSONSchemaType } from 'ajv';
2
+ import {
3
+ ApplyRequestData,
4
+ GetResourceInfoRequestData,
5
+ GetResourceInfoResponseData,
6
+ ImportRequestData,
7
+ ImportResponseData,
8
+ InitializeRequestData,
9
+ InitializeResponseData,
10
+ MatchRequestData,
11
+ MatchResponseData,
12
+ PlanRequestData,
13
+ PlanResponseData,
14
+ ResourceConfig,
15
+ ResourceJson, SetVerbosityRequestData,
16
+ ValidateRequestData,
17
+ ValidateResponseData
18
+ } from 'codify-schemas';
19
+
20
+ import { ApplyValidationError } from '../common/errors.js';
21
+ import { Plan } from '../plan/plan.js';
22
+ import { BackgroundPty } from '../pty/background-pty.js';
23
+ import { getPty } from '../pty/index.js';
24
+ import { SequentialPty } from '../pty/seqeuntial-pty.js';
25
+ import { Resource } from '../resource/resource.js';
26
+ import { ResourceController } from '../resource/resource-controller.js';
27
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
28
+ import { VerbosityLevel } from '../utils/verbosity-level.js';
29
+
30
+ export class Plugin {
31
+ planStorage: Map<string, Plan<any>>;
32
+ planPty = new BackgroundPty();
33
+
34
+ constructor(
35
+ public name: string,
36
+ public resourceControllers: Map<string, ResourceController<ResourceConfig>>
37
+ ) {
38
+ this.planStorage = new Map();
39
+ }
40
+
41
+ static create(name: string, resources: Resource<any>[]) {
42
+ const controllers = resources
43
+ .map((resource) => new ResourceController(resource))
44
+
45
+ const controllersMap = new Map<string, ResourceController<any>>(
46
+ controllers.map((r) => [r.typeId, r] as const)
47
+ );
48
+
49
+ return new Plugin(name, controllersMap);
50
+ }
51
+
52
+ async initialize(data: InitializeRequestData): Promise<InitializeResponseData> {
53
+ if (data.verbosityLevel) {
54
+ VerbosityLevel.set(data.verbosityLevel);
55
+ }
56
+
57
+ for (const controller of this.resourceControllers.values()) {
58
+ await controller.initialize();
59
+ }
60
+
61
+ return {
62
+ resourceDefinitions: [...this.resourceControllers.values()]
63
+ .map((r) => {
64
+ const sensitiveParameters = Object.entries(r.settings.parameterSettings ?? {})
65
+ .filter(([, v]) => v?.isSensitive)
66
+ .map(([k]) => k);
67
+
68
+ // Here we add '*' if the resource is sensitive but no sensitive parameters are found. This works because the import
69
+ // sensitivity check only checks for the existance of a sensitive parameter whereas the parameter blocking one blocks
70
+ // on a specific sensitive parameter.
71
+ if (r.settings.isSensitive && sensitiveParameters.length === 0) {
72
+ sensitiveParameters.push('*');
73
+ }
74
+
75
+ return {
76
+ dependencies: r.dependencies,
77
+ type: r.typeId,
78
+ sensitiveParameters,
79
+ operatingSystems: r.settings.operatingSystems,
80
+ linuxDistros: r.settings.linuxDistros,
81
+ }
82
+ })
83
+ }
84
+ }
85
+
86
+ getResourceInfo(data: GetResourceInfoRequestData): GetResourceInfoResponseData {
87
+ if (!this.resourceControllers.has(data.type)) {
88
+ throw new Error(`Cannot get info for resource ${data.type}, resource doesn't exist`);
89
+ }
90
+
91
+ const resource = this.resourceControllers.get(data.type)!;
92
+
93
+ const schema = resource.parsedSettings.schema as JSONSchemaType<any> | undefined;
94
+ const requiredPropertyNames = (
95
+ resource.settings.importAndDestroy?.requiredParameters
96
+ ?? (typeof resource.settings.allowMultiple === 'object' ? resource.settings.allowMultiple.identifyingParameters : null)
97
+ ?? schema?.required
98
+ ?? undefined
99
+ ) as any;
100
+
101
+ const allowMultiple = resource.settings.allowMultiple !== undefined
102
+ && resource.settings.allowMultiple !== false;
103
+
104
+ // Here we add '*' if the resource is sensitive but no sensitive parameters are found. This works because the import
105
+ // sensitivity check only checks for the existance of a sensitive parameter whereas the parameter blocking one blocks
106
+ // on a specific sensitive parameter.
107
+ const sensitiveParameters = Object.entries(resource.settings.parameterSettings ?? {})
108
+ .filter(([, v]) => v?.isSensitive)
109
+ .map(([k]) => k);
110
+
111
+ if (resource.settings.isSensitive && sensitiveParameters.length === 0) {
112
+ sensitiveParameters.push('*');
113
+ }
114
+
115
+ return {
116
+ plugin: this.name,
117
+ type: data.type,
118
+ dependencies: resource.dependencies,
119
+ schema: schema as Record<string, unknown> | undefined,
120
+ importAndDestroy: {
121
+ preventImport: resource.settings.importAndDestroy?.preventImport,
122
+ requiredParameters: requiredPropertyNames,
123
+ },
124
+ import: {
125
+ requiredParameters: requiredPropertyNames,
126
+ },
127
+ operatingSystems: resource.settings.operatingSystems,
128
+ linuxDistros: resource.settings.linuxDistros,
129
+ sensitiveParameters,
130
+ allowMultiple
131
+ }
132
+ }
133
+
134
+ async match(data: MatchRequestData): Promise<MatchResponseData> {
135
+ const { resource: resourceConfig, array } = data;
136
+
137
+ const resource = this.resourceControllers.get(resourceConfig.core.type);
138
+ if (!resource) {
139
+ throw new Error(`Resource of type ${resourceConfig.core.type} could not be found for match`);
140
+ }
141
+
142
+ const match = await resource.match(resourceConfig, array);
143
+ return { match }
144
+ }
145
+
146
+ async import(data: ImportRequestData): Promise<ImportResponseData> {
147
+ const { core, parameters, autoSearchAll } = data;
148
+
149
+ if (!this.resourceControllers.has(core.type)) {
150
+ throw new Error(`Cannot get info for resource ${core.type}, resource doesn't exist`);
151
+ }
152
+
153
+ const result = await ptyLocalStorage.run(this.planPty, () =>
154
+ this.resourceControllers
155
+ .get(core.type!)
156
+ ?.import(core, parameters, autoSearchAll)
157
+ )
158
+
159
+ return {
160
+ request: data,
161
+ result: result ?? [],
162
+ }
163
+ }
164
+
165
+ async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
166
+ const validationResults: ValidateResponseData['resourceValidations'] = [];
167
+ for (const config of data.configs) {
168
+ const { core, parameters } = config;
169
+
170
+ if (!this.resourceControllers.has(core.type)) {
171
+ throw new Error(`Resource type not found: ${core.type}`);
172
+ }
173
+
174
+ const validation = await this.resourceControllers
175
+ .get(core.type)!
176
+ .validate(core, parameters);
177
+
178
+ validationResults.push(validation);
179
+ }
180
+
181
+ // Validate that if allow multiple is false, then only 1 of each resource exists
182
+ const countMap = data.configs.reduce((map, resource) => {
183
+ if (!map.has(resource.core.type)) {
184
+ map.set(resource.core.type, 0);
185
+ }
186
+
187
+ const count = map.get(resource.core.type)!;
188
+ map.set(resource.core.type, count + 1)
189
+
190
+ return map;
191
+ }, new Map<string, number>())
192
+
193
+ const invalidMultipleConfigs = [...countMap.entries()].filter(([k, v]) => {
194
+ const controller = this.resourceControllers.get(k)!;
195
+ return !controller.parsedSettings.allowMultiple && v > 1;
196
+ });
197
+
198
+ if (invalidMultipleConfigs.length > 0) {
199
+ throw new Error(
200
+ `Multiples of the following configs were found but only 1 is allowed. [${invalidMultipleConfigs.map(([k, v]) => `${v}x ${k}`).join(', ')}] found.`)
201
+ }
202
+
203
+ await this.crossValidateResources(data.configs);
204
+ return {
205
+ resourceValidations: validationResults
206
+ };
207
+ }
208
+
209
+ async plan(data: PlanRequestData): Promise<PlanResponseData> {
210
+ const { type } = data.core
211
+
212
+ if (!this.resourceControllers.has(type)) {
213
+ throw new Error(`Resource type not found: ${type}`);
214
+ }
215
+
216
+ const plan = await ptyLocalStorage.run(this.planPty, async () => this.resourceControllers.get(type)!.plan(
217
+ data.core,
218
+ data.desired ?? null,
219
+ data.state ?? null,
220
+ data.isStateful
221
+ ))
222
+
223
+ this.planStorage.set(plan.id, plan);
224
+
225
+ return plan.toResponse();
226
+ }
227
+
228
+ async apply(data: ApplyRequestData): Promise<void> {
229
+ if (!data.planId && !data.plan) {
230
+ throw new Error('For applies either plan or planId must be supplied');
231
+ }
232
+
233
+ const plan = this.resolvePlan(data);
234
+
235
+ const resource = this.resourceControllers.get(plan.getResourceType());
236
+ if (!resource) {
237
+ throw new Error('Malformed plan with resource that cannot be found');
238
+ }
239
+
240
+ await ptyLocalStorage.run(new SequentialPty(), async () => resource.apply(plan))
241
+
242
+ // Validate using desired/desired. If the apply was successful, no changes should be reported back.
243
+ // Default back desired back to current if it is not defined (for destroys only)
244
+ const validationPlan = await ptyLocalStorage.run(new BackgroundPty(), async () => {
245
+ const result = await resource.plan(
246
+ plan.coreParameters,
247
+ plan.desiredConfig,
248
+ plan.desiredConfig ?? plan.currentConfig,
249
+ plan.isStateful,
250
+ 'validationPlan'
251
+ );
252
+
253
+ await getPty().kill();
254
+ return result;
255
+ })
256
+
257
+ if (validationPlan.requiresChanges()) {
258
+ throw new ApplyValidationError(plan);
259
+ }
260
+ }
261
+
262
+ async setVerbosityLevel(data: SetVerbosityRequestData): Promise<void> {
263
+ VerbosityLevel.set(data.verbosityLevel);
264
+ }
265
+
266
+ async kill() {
267
+ await this.planPty.kill();
268
+ }
269
+
270
+ private resolvePlan(data: ApplyRequestData): Plan<ResourceConfig> {
271
+ const { plan: planRequest, planId } = data;
272
+
273
+ if (planId) {
274
+ if (!this.planStorage.has(planId)) {
275
+ throw new Error(`Plan with id: ${planId} was not found`);
276
+ }
277
+
278
+ return this.planStorage.get(planId)!
279
+ }
280
+
281
+ if (!planRequest?.resourceType || !this.resourceControllers.has(planRequest.resourceType)) {
282
+ throw new Error('Malformed plan. Resource type must be supplied or resource type was not found');
283
+ }
284
+
285
+ const resource = this.resourceControllers.get(planRequest.resourceType)!;
286
+ return Plan.fromResponse(planRequest, resource.parsedSettings.defaultValues);
287
+ }
288
+
289
+ protected async crossValidateResources(resources: ResourceJson[]): Promise<void> {
290
+ }
291
+ }
@@ -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: 0,
18
+ });
19
+ })
20
+
21
+ // This test takes forever so going to disable for now.
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,154 @@
1
+ import pty from '@homebridge/node-pty-prebuilt-multiarch';
2
+ import { nanoid } from 'nanoid';
3
+ import * as cp from 'node:child_process';
4
+ import { EventEmitter } from 'node:events';
5
+ import * as fs from 'node:fs/promises';
6
+ import stripAnsi from 'strip-ansi';
7
+
8
+ import { debugLog } from '../utils/debug.js';
9
+ import { Shell, Utils } from '../utils/index.js';
10
+ import { VerbosityLevel } from '../utils/verbosity-level.js';
11
+ import { IPty, SpawnError, SpawnOptions, SpawnResult } from './index.js';
12
+ import { PromiseQueue } from './promise-queue.js';
13
+
14
+ EventEmitter.defaultMaxListeners = 1000;
15
+
16
+ /**
17
+ * The background pty is a specialized pty designed for speed. It can launch multiple tasks
18
+ * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
19
+ * to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
20
+ * without a tty (or even a stdin) attached so interactive commands will not work.
21
+ */
22
+ export class BackgroundPty implements IPty {
23
+ private historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
24
+ private basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
25
+ env: { ...process.env, ...this.historyIgnore },
26
+ cols: 10_000, // Set to a really large value to prevent wrapping
27
+ name: nanoid(6),
28
+ handleFlowControl: true
29
+ });
30
+
31
+ private promiseQueue = new PromiseQueue();
32
+
33
+ constructor() {
34
+ this.initialize();
35
+ }
36
+
37
+ async spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult> {
38
+ const spawnResult = await this.spawnSafe(cmd, options);
39
+
40
+ if (spawnResult.status !== 'success') {
41
+ throw new SpawnError(Array.isArray(cmd) ? cmd.join(' ') : cmd, spawnResult.exitCode, spawnResult.data);
42
+ }
43
+
44
+ return spawnResult;
45
+ }
46
+
47
+ async spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult> {
48
+ cmd = Array.isArray(cmd) ? cmd.join('\\\n') : cmd;
49
+
50
+ // cid is command id
51
+ const cid = nanoid(10);
52
+ debugLog(cid);
53
+
54
+ await new Promise((resolve) => {
55
+ // 600 permissions means only the current user will be able to rw from the FIFO
56
+ // Create in /tmp so it could be automatically cleaned up if the clean-up was missed
57
+ const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
58
+ mkfifoSpawn.on('close', () => {
59
+ resolve(null);
60
+ })
61
+ })
62
+
63
+ return new Promise<SpawnResult>((resolve) => {
64
+ const cat = cp.spawn('cat', [`/tmp/${cid}`])
65
+
66
+ let output = '';
67
+ cat.stdout.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
+ } else {
86
+ // Print to stdout if the verbosity level is above 0
87
+ if (VerbosityLevel.get() > 0) {
88
+ process.stdout.write(data);
89
+ }
90
+ }
91
+ })
92
+
93
+ this.promiseQueue.run(async () => new Promise((resolve) => {
94
+ const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
95
+ // Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
96
+ // Done is used to denote the end of the command
97
+ // Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
98
+ const command = ` ((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`
99
+
100
+ let output = '';
101
+ const listener = this.basePty.onData((data: any) => {
102
+ output += data;
103
+
104
+ if (output.includes(`%%%done%%%${cid}"`)) {
105
+ listener.dispose();
106
+ resolve(null);
107
+ }
108
+ });
109
+
110
+ console.log(`Running command: ${cmd}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`)
111
+ this.basePty.write(`${command}\r`);
112
+
113
+ }));
114
+ }).finally(async () => {
115
+ await fs.rm(`/tmp/${cid}`);
116
+ })
117
+ }
118
+
119
+ async kill(): Promise<{ exitCode: number, signal?: number | undefined }> {
120
+ return new Promise((resolve) => {
121
+ this.basePty.onExit((status) => {
122
+ resolve(status);
123
+ })
124
+
125
+ this.basePty.kill('SIGKILL')
126
+ })
127
+ }
128
+
129
+ private async initialize() {
130
+ // this.basePty.onData((data: string) => process.stdout.write(data));
131
+
132
+ await this.promiseQueue.run(async () => {
133
+ let outputBuffer = '';
134
+
135
+ return new Promise(resolve => {
136
+ this.basePty.write(' unset PS1;\n');
137
+ this.basePty.write(' unset PS0;\n')
138
+ this.basePty.write(' echo setup complete\\"\n')
139
+
140
+ const listener = this.basePty.onData((data: string) => {
141
+ outputBuffer += data;
142
+ if (outputBuffer.includes('setup complete"')) {
143
+ listener.dispose();
144
+ resolve(null);
145
+ }
146
+ })
147
+ })
148
+ })
149
+ }
150
+
151
+ private getDefaultShell(): string {
152
+ return process.env.SHELL!;
153
+ }
154
+ }
@@ -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 { OS, ResourceOperation } from 'codify-schemas';
7
+ import { ResourceSettings } from '../resource/resource-settings.js';
8
+ import { SequentialPty } from './seqeuntial-pty.js';
9
+
10
+ describe('General tests for PTYs', () => {
11
+ it('Can get pty within refresh', async () => {
12
+ const testResource = new class extends TestResource {
13
+ async refresh(): Promise<Partial<TestConfig> | null> {
14
+ const $ = getPty();
15
+ const lsResult = await $.spawnSafe('ls');
16
+
17
+ expect(lsResult.exitCode).to.eq(0);
18
+ expect(lsResult.data).to.be.not.null;
19
+ expect(lsResult.status).to.eq('success');
20
+
21
+ return {};
22
+ }
23
+ }
24
+
25
+ const spy = vitest.spyOn(testResource, 'refresh')
26
+
27
+ const plugin = Plugin.create('test plugin', [testResource])
28
+ const plan = await plugin.plan({
29
+ core: { type: 'type' },
30
+ desired: {},
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
+ operatingSystems: [OS.Darwin],
51
+ }
52
+ }
53
+
54
+ async refresh(): Promise<Partial<TestConfig> | null> {
55
+ const $ = getPty();
56
+ const lsResult = await $.spawnSafe('ls');
57
+
58
+ expect(lsResult.exitCode).to.eq(0);
59
+ pty1 = $;
60
+
61
+ return {};
62
+ }
63
+ }
64
+
65
+ const testResource2 = new class extends TestResource {
66
+ getSettings(): ResourceSettings<TestConfig> {
67
+ return {
68
+ id: 'type2',
69
+ operatingSystems: [OS.Darwin],
70
+ }
71
+ }
72
+
73
+ async refresh(): Promise<Partial<TestConfig> | null> {
74
+ const $ = getPty();
75
+ const pwdResult = await $.spawnSafe('pwd');
76
+
77
+ expect(pwdResult.exitCode).to.eq(0);
78
+ pty2 = $;
79
+
80
+ return {};
81
+ }
82
+ }
83
+
84
+ const spy1 = vitest.spyOn(testResource1, 'refresh')
85
+ const spy2 = vitest.spyOn(testResource2, 'refresh')
86
+
87
+ const plugin = Plugin.create('test plugin', [testResource1, testResource2]);
88
+ await plugin.plan({
89
+ core: { type: 'type1' },
90
+ desired: {},
91
+ state: undefined,
92
+ isStateful: false,
93
+ })
94
+
95
+ await plugin.plan({
96
+ core: { type: 'type2' },
97
+ desired: {},
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('Sequential pty should be available for applies', async () => {
110
+ const testResource = new class extends TestResource {
111
+ create(plan: CreatePlan<TestConfig>): Promise<void> {
112
+ const $ = getPty();
113
+ expect($).to.instanceof(SequentialPty)
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
+ })