@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,1081 @@
1
+ import { Resource } from './resource.js';
2
+ import { OS, ResourceOperation } from 'codify-schemas';
3
+ import { spy } from 'sinon';
4
+ import { describe, expect, it } from 'vitest'
5
+ import { ArrayParameterSetting, ParameterSetting, ResourceSettings } from './resource-settings.js';
6
+ import { CreatePlan, DestroyPlan, ModifyPlan } from '../plan/plan-types.js';
7
+ import { ParameterChange } from '../plan/change-set.js';
8
+ import { ResourceController } from './resource-controller.js';
9
+ import { TestConfig, testPlan, TestResource, TestStatefulParameter } from '../utils/test-utils.test.js';
10
+ import { tildify, untildify } from '../utils/functions.js';
11
+ import { ArrayStatefulParameter, StatefulParameter } from '../stateful-parameter/stateful-parameter.js';
12
+ import { Plan } from '../plan/plan.js';
13
+ import os from 'node:os';
14
+ import { z } from 'zod';
15
+
16
+ describe('Resource tests', () => {
17
+
18
+ it('Validate applies transformations before validating', async () => {
19
+ const resource = new class extends TestResource {
20
+ getSettings(): ResourceSettings<TestConfig> {
21
+ return {
22
+ id: 'type',
23
+ operatingSystems: [OS.Darwin],
24
+ dependencies: ['homebrew', 'python'],
25
+ parameterSettings: {
26
+ propA: {
27
+ canModify: true,
28
+ transformation: { to: (input) => untildify(input), from: (input) => tildify(input) }
29
+ },
30
+ },
31
+ }
32
+ }
33
+
34
+ async validate(parameters: Partial<TestConfig>): Promise<void> {
35
+ expect(parameters.propA).to.not.include('~');
36
+ expect(parameters.propB).to.not.exist;
37
+ expect(parameters.propC).to.equal(10);
38
+ }
39
+ }
40
+
41
+ const controller = new ResourceController(resource);
42
+ await controller.validate(
43
+ { type: 'type' },
44
+ {
45
+ type: 'type',
46
+ propA: '~/.tool_versions',
47
+ propB: 10,
48
+ }
49
+ )
50
+ })
51
+
52
+ it('Plans successfully', async () => {
53
+ const resource = new class extends TestResource {
54
+
55
+ async refresh(): Promise<TestConfig> {
56
+ return {
57
+ propA: 'propABefore',
58
+ propB: 10,
59
+ };
60
+ }
61
+ }
62
+
63
+ const controller = new ResourceController(resource)
64
+
65
+ const resourceSpy = spy(controller);
66
+ const result = await resourceSpy.plan(
67
+ { type: 'type', name: 'name' },
68
+ {
69
+ propA: 'propA',
70
+ propB: 10,
71
+ },
72
+ null,
73
+ false,
74
+ )
75
+
76
+ expect(result.desiredConfig).to.deep.eq({
77
+ propA: 'propA',
78
+ propB: 10,
79
+ });
80
+ expect(result.changeSet.operation).to.eq(ResourceOperation.RECREATE);
81
+ expect(result.changeSet.parameterChanges[0]).to.deep.eq({
82
+ name: 'propA',
83
+ previousValue: 'propABefore',
84
+ newValue: 'propA',
85
+ operation: 'modify',
86
+ isSensitive: false,
87
+ })
88
+ expect(result.changeSet.parameterChanges[1]).to.deep.eq({
89
+ name: 'propB',
90
+ previousValue: 10,
91
+ newValue: 10,
92
+ operation: 'noop',
93
+ isSensitive: false,
94
+ })
95
+ })
96
+
97
+ it('creates the resource if it doesnt exist', async () => {
98
+ const resource = new class extends TestResource {
99
+ async refresh(): Promise<TestConfig | null> {
100
+ return null;
101
+ }
102
+ }
103
+ const controller = new ResourceController(resource);
104
+
105
+ const resourceSpy = spy(controller);
106
+ const result = await resourceSpy.plan(
107
+ { type: 'type', name: 'name' },
108
+ {
109
+ propA: 'propA',
110
+ propB: 10,
111
+ propC: 'somethingAfter'
112
+ },
113
+ null,
114
+ false,
115
+ )
116
+
117
+ expect(result.changeSet.operation).to.eq(ResourceOperation.CREATE);
118
+ expect(result.changeSet.parameterChanges.length).to.eq(3);
119
+ })
120
+
121
+ it('handles empty parameters', async () => {
122
+ const resource = new class extends TestResource {
123
+ async refresh(): Promise<Partial<TestConfig> | null> {
124
+ return null;
125
+ }
126
+ }
127
+ const controller = new ResourceController(resource);
128
+
129
+ const resourceSpy = spy(controller);
130
+ const result = await resourceSpy.plan(
131
+ { type: 'type' },
132
+ {},
133
+ null,
134
+ false
135
+ )
136
+
137
+ expect(result.changeSet.operation).to.eq(ResourceOperation.CREATE);
138
+ expect(result.changeSet.parameterChanges.length).to.eq(0);
139
+ })
140
+
141
+ it('chooses the create apply properly', async () => {
142
+ const resource = new class extends TestResource {
143
+ }
144
+ const controller = new ResourceController(resource);
145
+
146
+ const controllerSpy = spy(controller);
147
+ const resourceSpy = spy(resource);
148
+
149
+ await controllerSpy.apply(
150
+ testPlan({
151
+ desired: { propA: 'a', propB: 0 },
152
+ })
153
+ )
154
+
155
+ expect(resourceSpy.create.calledOnce).to.be.true;
156
+ })
157
+
158
+ it('chooses the destroy apply properly', async () => {
159
+ const resource = new class extends TestResource {
160
+ }
161
+ const controller = new ResourceController(resource);
162
+
163
+ const controllerSpy = spy(controller);
164
+ const resourceSpy = spy(resource);
165
+
166
+ await controllerSpy.apply(
167
+ testPlan({
168
+ current: [{ propA: 'a', propB: 0 }],
169
+ state: { propA: 'a', propB: 0 },
170
+ isStateful: true,
171
+ })
172
+ )
173
+
174
+ expect(resourceSpy.destroy.calledOnce).to.be.true;
175
+ })
176
+
177
+ it('Defaults parameter changes to recreate', async () => {
178
+ const resource = new class extends TestResource {
179
+ }
180
+ const controller = new ResourceController(resource);
181
+
182
+ const controllerSpy = spy(controller);
183
+ const resourceSpy = spy(resource);
184
+
185
+ await controllerSpy.apply(
186
+ testPlan({
187
+ desired: { propA: 'a', propB: 0 },
188
+ current: [{ propA: 'b', propB: -1 }],
189
+ isStateful: true
190
+ })
191
+ );
192
+
193
+ expect(resourceSpy.destroy.calledOnce).to.be.true;
194
+ expect(resourceSpy.create.calledOnce).to.be.true;
195
+ })
196
+
197
+ it('Allows modification of parameter behavior to allow modify for parameters', async () => {
198
+ const resource = new class extends TestResource {
199
+ getSettings(): ResourceSettings<TestConfig> {
200
+ return {
201
+ id: 'resource',
202
+ operatingSystems: [OS.Darwin],
203
+ parameterSettings: {
204
+ propA: { canModify: true },
205
+ propB: { canModify: true },
206
+ }
207
+ }
208
+ }
209
+
210
+ async refresh(): Promise<TestConfig | null> {
211
+ return { propA: 'b', propB: -1 };
212
+ }
213
+ }
214
+ const controller = new ResourceController(resource);
215
+
216
+ const plan = await controller.plan(
217
+ { type: 'resource' },
218
+ { propA: 'a', propB: 0 },
219
+ null,
220
+ false,
221
+ )
222
+
223
+ const resourceSpy = spy(resource);
224
+ await controller.apply(
225
+ plan
226
+ );
227
+
228
+ expect(resourceSpy.modify.calledTwice).to.be.true;
229
+ })
230
+
231
+ it('Validates the resource options correct (pass)', () => {
232
+ const statefulParameter = new TestStatefulParameter();
233
+
234
+ expect(() => new ResourceController(new class extends TestResource {
235
+ getSettings(): ResourceSettings<TestConfig> {
236
+ return {
237
+ id: 'type',
238
+ operatingSystems: [OS.Darwin],
239
+ dependencies: ['homebrew', 'python'],
240
+ parameterSettings: {
241
+ propA: { canModify: true },
242
+ propB: { type: 'stateful', definition: statefulParameter },
243
+ propC: { isEqual: (a, b) => true },
244
+ }
245
+ }
246
+ }
247
+ })).to.not.throw;
248
+ })
249
+
250
+ it('Validates the resource options correct (fail)', () => {
251
+ const statefulParameter = new class extends TestStatefulParameter {
252
+ async refresh(desired: string | null): Promise<string | null> {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ expect(() => new ResourceController(new class extends TestResource {
258
+ getSettings(): ResourceSettings<TestConfig> {
259
+ return {
260
+ id: 'type',
261
+ operatingSystems: [OS.Darwin],
262
+ dependencies: ['homebrew', 'python'],
263
+ parameterSettings: {
264
+ propA: { canModify: true },
265
+ propB: { type: 'stateful', definition: statefulParameter },
266
+ propC: { isEqual: (a, b) => true },
267
+ }
268
+ }
269
+ }
270
+ })).to.not.throw;
271
+ })
272
+
273
+ it('Allows default values to be added', async () => {
274
+ const resource = new class extends TestResource {
275
+ getSettings(): ResourceSettings<TestConfig> {
276
+ return {
277
+ id: 'type',
278
+ operatingSystems: [OS.Darwin],
279
+ parameterSettings: {
280
+ propA: { default: 'propADefault' }
281
+ }
282
+ }
283
+ }
284
+
285
+ // @ts-ignore
286
+ async refresh(desired: Partial<TestConfig>): Promise<Partial<TestConfig>> {
287
+ expect(desired['propA']).to.be.eq('propADefault');
288
+
289
+ return {
290
+ propA: 'propAAfter'
291
+ };
292
+ }
293
+ }
294
+ const controller = new ResourceController(resource);
295
+
296
+ const plan = await controller.plan({ type: 'resource' }, {}, null, false)
297
+ expect(plan.currentConfig?.propA).to.eq('propAAfter');
298
+ expect(plan.desiredConfig?.propA).to.eq('propADefault');
299
+ expect(plan.changeSet.operation).to.eq(ResourceOperation.RECREATE);
300
+ })
301
+
302
+ it('Allows default values to be added to both desired and current', async () => {
303
+ const resource = new class extends TestResource {
304
+ getSettings(): ResourceSettings<TestConfig> {
305
+ return {
306
+ id: 'type',
307
+ operatingSystems: [OS.Darwin],
308
+ parameterSettings: {
309
+ propE: { default: 'propEDefault' }
310
+ }
311
+ }
312
+ }
313
+
314
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
315
+ expect(parameters['propE']).to.exist;
316
+
317
+ return {
318
+ propE: parameters['propE'],
319
+ };
320
+ }
321
+ }
322
+ const controller = new ResourceController(resource);
323
+
324
+ const plan = await controller.plan({ type: 'resource' }, {}, null, false)
325
+ expect(plan.currentConfig?.propE).to.eq('propEDefault');
326
+ expect(plan.desiredConfig?.propE).to.eq('propEDefault');
327
+ expect(plan.changeSet.operation).to.eq(ResourceOperation.NOOP);
328
+ })
329
+
330
+ it('Allows default values to be added even when refresh returns null', async () => {
331
+ const resource = new class extends TestResource {
332
+ getSettings(): ResourceSettings<TestConfig> {
333
+ return {
334
+ id: 'type',
335
+ operatingSystems: [OS.Darwin],
336
+ parameterSettings: {
337
+ propE: { default: 'propEDefault' }
338
+ }
339
+ }
340
+ }
341
+
342
+ async refresh(): Promise<Partial<TestConfig> | null> {
343
+ return null;
344
+ }
345
+ }
346
+ const controller = new ResourceController(resource);
347
+
348
+ const plan = await controller.plan({ type: 'resource' }, {}, null, false)
349
+ expect(plan.currentConfig).to.be.null
350
+ expect(plan.desiredConfig!.propE).to.eq('propEDefault');
351
+ expect(plan.changeSet.operation).to.eq(ResourceOperation.CREATE);
352
+ })
353
+
354
+ it('Allows default values to be added (ignore default value if already present)', async () => {
355
+ const resource = new class extends TestResource {
356
+ getSettings(): ResourceSettings<TestConfig> {
357
+ return {
358
+ id: 'type',
359
+ operatingSystems: [OS.Darwin],
360
+ parameterSettings: {
361
+ propA: { default: 'propADefault' }
362
+ }
363
+ }
364
+ }
365
+
366
+ // @ts-ignore
367
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig>> {
368
+ expect(parameters['propA']).to.be.eq('propA');
369
+
370
+ return {
371
+ propA: 'propAAfter'
372
+ };
373
+ }
374
+ }
375
+ const controller = new ResourceController(resource);
376
+
377
+ const plan = await controller.plan({ type: 'resource' }, { propA: 'propA' }, null, false)
378
+ expect(plan.currentConfig?.propA).to.eq('propAAfter');
379
+ expect(plan.desiredConfig?.propA).to.eq('propA');
380
+ expect(plan.changeSet.operation).to.eq(ResourceOperation.RECREATE);
381
+ });
382
+
383
+ it('Sets the default value properly on the resource', () => {
384
+ const resource = new class extends TestResource {
385
+ getSettings(): ResourceSettings<TestConfig> {
386
+ return {
387
+ id: 'type',
388
+ operatingSystems: [OS.Darwin],
389
+ parameterSettings: {
390
+ propA: { default: 'propADefault' }
391
+ }
392
+ }
393
+ }
394
+ }
395
+ const controller = new ResourceController(resource);
396
+
397
+ expect(controller.parsedSettings.defaultValues).to.deep.eq({
398
+ propA: 'propADefault',
399
+ })
400
+ })
401
+
402
+ it('Has the correct typing for applys', () => {
403
+ const resource = new class extends Resource<TestConfig> {
404
+ getSettings(): ResourceSettings<TestConfig> {
405
+ return { id: 'type', operatingSystems: [OS.Darwin], }
406
+ }
407
+
408
+ async refresh(): Promise<Partial<TestConfig> | null> {
409
+ return null;
410
+ }
411
+
412
+ async create(plan: CreatePlan<TestConfig>): Promise<void> {
413
+ plan.desiredConfig.propA
414
+ }
415
+
416
+ async destroy(plan: DestroyPlan<TestConfig>): Promise<void> {
417
+ plan.currentConfig.propB
418
+ }
419
+
420
+ async modify(pc: ParameterChange<TestConfig>, plan: ModifyPlan<TestConfig>): Promise<void> {
421
+ plan.desiredConfig.propA
422
+ plan.currentConfig.propB
423
+ }
424
+ }
425
+ })
426
+
427
+ it('Can use multiple stateful parameters (create)', async () => {
428
+ const parameter1 = new class extends StatefulParameter<any, any> {
429
+ getSettings(): ParameterSetting {
430
+ return {
431
+ type: 'version',
432
+ operatingSystems: [OS.Darwin],
433
+ }
434
+ }
435
+
436
+ override async refresh(desired: any, config: Partial<any>): Promise<any> {
437
+ return null;
438
+ }
439
+
440
+ override async add(valueToAdd: any, plan: Plan<any>): Promise<void> {
441
+ }
442
+
443
+ override async modify(newValue: any, previousValue: any, plan: Plan<any>): Promise<void> {
444
+ }
445
+
446
+ override async remove(valueToRemove: any, plan: Plan<any>): Promise<void> {
447
+ }
448
+ }
449
+
450
+ const parameter2 = new class extends ArrayStatefulParameter<any, any> {
451
+ override getSettings(): ArrayParameterSetting {
452
+ return {
453
+ type: 'array',
454
+ isElementEqual: (desired, current) => current.includes(desired),
455
+ }
456
+ }
457
+
458
+ override async refresh(desired: any[] | null, config: Partial<any>): Promise<any[] | null> {
459
+ return null;
460
+ }
461
+
462
+ override async addItem(item: any, plan: Plan<any>): Promise<void> {
463
+ }
464
+
465
+ override async removeItem(item: any, plan: Plan<any>): Promise<void> {
466
+ }
467
+ }
468
+
469
+ const p1Spy = spy(parameter1);
470
+ const p2Spy = spy(parameter2);
471
+
472
+ const resource = new class extends TestResource {
473
+ getSettings(): ResourceSettings<TestConfig> {
474
+ return {
475
+ id: 'nvm',
476
+ operatingSystems: [OS.Darwin],
477
+ parameterSettings: {
478
+ global: { type: 'stateful', definition: parameter1, order: 2 },
479
+ nodeVersions: { type: 'stateful', definition: parameter2, order: 1 },
480
+ },
481
+ }
482
+ }
483
+
484
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
485
+ return null;
486
+ }
487
+ }
488
+
489
+ const controller = new ResourceController(resource);
490
+ const plan = await controller.plan({ type: 'nvm' }, { global: '20.12', nodeVersions: ['18', '20'] }, null, false)
491
+
492
+ expect(plan).toMatchObject({
493
+ changeSet: {
494
+ operation: ResourceOperation.CREATE,
495
+ }
496
+ })
497
+
498
+ console.log(JSON.stringify(plan, null, 2))
499
+
500
+ await controller.apply(plan)
501
+
502
+ expect(p1Spy.add.calledOnce).to.be.true;
503
+ expect(p2Spy.addItem.calledTwice).to.be.true;
504
+ });
505
+
506
+ it('Can use multiple stateful parameters (modify)', async () => {
507
+ const parameter1 = spy(new class extends TestStatefulParameter {
508
+ async refresh(desired: string | null): Promise<string | null> {
509
+ return '16';
510
+ }
511
+ })
512
+
513
+ const parameter2 = spy(new class extends ArrayStatefulParameter<any, any> {
514
+ async refresh(desired: any[] | null, config: Partial<any>): Promise<any[] | null> {
515
+ return ['20']
516
+ }
517
+
518
+ async addItem(item: any, plan: Plan<any>): Promise<void> {
519
+ }
520
+
521
+ async removeItem(item: any, plan: Plan<any>): Promise<void> {
522
+ }
523
+ })
524
+
525
+ const resource = new class extends TestResource {
526
+ getSettings(): ResourceSettings<TestConfig> {
527
+ return {
528
+ id: 'nvm',
529
+ operatingSystems: [OS.Darwin],
530
+ parameterSettings: {
531
+ global: { type: 'stateful', definition: parameter1, order: 2 },
532
+ nodeVersions: { type: 'stateful', definition: parameter2, order: 1 },
533
+ },
534
+ }
535
+ }
536
+
537
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
538
+ return {};
539
+ }
540
+ }
541
+
542
+ const controller = new ResourceController(resource);
543
+ const plan = await controller.plan({ type: 'nvm' }, { global: '20.12', nodeVersions: ['18', '20'] }, null, false)
544
+
545
+ expect(plan).toMatchObject({
546
+ changeSet: {
547
+ operation: ResourceOperation.MODIFY,
548
+ }
549
+ })
550
+
551
+ await controller.apply(plan)
552
+
553
+ expect(parameter1.modify.calledOnce).to.be.true;
554
+ expect(parameter2.addItem.calledOnce).to.be.true;
555
+ });
556
+
557
+ it('Applies reverse input transformations for imports', async () => {
558
+ const resource = new class extends TestResource {
559
+ getSettings(): ResourceSettings<TestConfig> {
560
+ return {
561
+ id: 'resourceType',
562
+ operatingSystems: [OS.Darwin],
563
+ parameterSettings: {
564
+ propD: {
565
+ type: 'array',
566
+ transformation: {
567
+ to: (hosts: Record<string, unknown>[]) => hosts.map((h) => Object.fromEntries(
568
+ Object.entries(h)
569
+ .map(([k, v]) => [
570
+ k,
571
+ typeof v === 'boolean'
572
+ ? (v ? 'yes' : 'no') // The file takes 'yes' or 'no' instead of booleans
573
+ : v,
574
+ ])
575
+ )
576
+ ),
577
+ from: (hosts: Record<string, unknown>[]) => hosts.map((h) => Object.fromEntries(
578
+ Object.entries(h)
579
+ .map(([k, v]) => [
580
+ k,
581
+ v === 'yes' || v === 'no'
582
+ ? (v === 'yes')
583
+ : v,
584
+ ])
585
+ ))
586
+ }
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
593
+ return {
594
+ propD: [
595
+ {
596
+ Host: 'new.com',
597
+ AddKeysToAgent: true,
598
+ IdentityFile: 'id_ed25519'
599
+ },
600
+ {
601
+ Host: 'github.com',
602
+ AddKeysToAgent: true,
603
+ UseKeychain: true,
604
+ },
605
+ {
606
+ Match: 'User bob,joe,phil',
607
+ PasswordAuthentication: true,
608
+ }
609
+ ],
610
+ }
611
+ }
612
+ }
613
+
614
+ const controller = new ResourceController(resource);
615
+ const plan = await controller.import({ type: 'resourceType' }, {});
616
+
617
+ expect(plan![0]).toMatchObject({
618
+ 'core': {
619
+ 'type': 'resourceType'
620
+ },
621
+ 'parameters': {
622
+ 'propD': [
623
+ {
624
+ 'Host': 'new.com',
625
+ 'AddKeysToAgent': true,
626
+ 'IdentityFile': 'id_ed25519'
627
+ },
628
+ {
629
+ 'Host': 'github.com',
630
+ 'AddKeysToAgent': true,
631
+ 'UseKeychain': true
632
+ },
633
+ {
634
+ 'Match': 'User bob,joe,phil',
635
+ 'PasswordAuthentication': true
636
+ }
637
+ ]
638
+ }
639
+ })
640
+ })
641
+
642
+ it('Applies reverse input transformations for imports (object level)', async () => {
643
+ const resource = new class extends TestResource {
644
+ getSettings(): ResourceSettings<TestConfig> {
645
+ return {
646
+ id: 'resourceType',
647
+ operatingSystems: [OS.Darwin],
648
+ parameterSettings: {
649
+ propD: {
650
+ type: 'array',
651
+ }
652
+ },
653
+ transformation: {
654
+ to: (input: any) => ({
655
+ ...input,
656
+ propD: input.propD?.map((h) => Object.fromEntries(
657
+ Object.entries(h)
658
+ .map(([k, v]) => [
659
+ k,
660
+ typeof v === 'boolean'
661
+ ? (v ? 'yes' : 'no') // The file takes 'yes' or 'no' instead of booleans
662
+ : v,
663
+ ])
664
+ )
665
+ )
666
+ }),
667
+ from: (output: any) => ({
668
+ ...output,
669
+ propD: output.propD?.map((h) => Object.fromEntries(
670
+ Object.entries(h)
671
+ .map(([k, v]) => [
672
+ k,
673
+ v === 'yes' || v === 'no'
674
+ ? (v === 'yes')
675
+ : v,
676
+ ])
677
+ ))
678
+ })
679
+ }
680
+ }
681
+ }
682
+
683
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
684
+ return {
685
+ propD: [
686
+ {
687
+ Host: 'new.com',
688
+ AddKeysToAgent: true,
689
+ IdentityFile: 'id_ed25519'
690
+ },
691
+ {
692
+ Host: 'github.com',
693
+ AddKeysToAgent: true,
694
+ UseKeychain: true,
695
+ },
696
+ {
697
+ Match: 'User bob,joe,phil',
698
+ PasswordAuthentication: true,
699
+ }
700
+ ],
701
+ }
702
+ }
703
+ }
704
+
705
+ const controller = new ResourceController(resource);
706
+ const plan = await controller.import({ type: 'resourceType' }, {});
707
+
708
+ expect(plan![0]).toMatchObject({
709
+ 'core': {
710
+ 'type': 'resourceType'
711
+ },
712
+ 'parameters': {
713
+ 'propD': [
714
+ {
715
+ 'Host': 'new.com',
716
+ 'AddKeysToAgent': true,
717
+ 'IdentityFile': 'id_ed25519'
718
+ },
719
+ {
720
+ 'Host': 'github.com',
721
+ 'AddKeysToAgent': true,
722
+ 'UseKeychain': true
723
+ },
724
+ {
725
+ 'Match': 'User bob,joe,phil',
726
+ 'PasswordAuthentication': true
727
+ }
728
+ ]
729
+ }
730
+ })
731
+ })
732
+
733
+ it('Applies removes default values if they remain default for imports', async () => {
734
+ const resource = new class extends TestResource {
735
+ getSettings(): ResourceSettings<TestConfig> {
736
+ return {
737
+ id: 'resourceType',
738
+ operatingSystems: [OS.Darwin],
739
+ parameterSettings: {
740
+ propA: { type: 'string', default: 'defaultValue' },
741
+ propB: { type: 'boolean', default: true }
742
+ },
743
+ }
744
+ }
745
+
746
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
747
+ return {
748
+ propA: 'defaultValue',
749
+ propB: false,
750
+ propC: 'newPropC'
751
+ }
752
+ }
753
+ }
754
+
755
+ const controller = new ResourceController(resource);
756
+ const plan = await controller.import({ type: 'resourceType' }, {});
757
+
758
+ expect(plan![0]).toMatchObject({
759
+ 'core': {
760
+ 'type': 'resourceType'
761
+ },
762
+ 'parameters': {
763
+ propB: false,
764
+ propC: 'newPropC'
765
+ }
766
+ })
767
+ })
768
+
769
+ it('Can plan with settings', async () => {
770
+ const resource = new class extends TestResource {
771
+ getSettings(): ResourceSettings<any> {
772
+ return {
773
+ id: 'path',
774
+ operatingSystems: [OS.Darwin],
775
+ parameterSettings: {
776
+ path: { type: 'string', isEqual: 'directory' },
777
+ paths: { canModify: true, type: 'array', isElementEqual: 'directory' },
778
+ prepend: { default: false, setting: true },
779
+ declarationsOnly: { default: false, setting: true },
780
+ },
781
+ importAndDestroy: {
782
+ refreshKeys: ['paths', 'declarationsOnly'],
783
+ defaultRefreshValues: {
784
+ paths: [],
785
+ declarationsOnly: true,
786
+ }
787
+ },
788
+ allowMultiple: {
789
+ matcher: (desired, current) => {
790
+ if (desired.path) {
791
+ return desired.path === current.path;
792
+ }
793
+
794
+ const currentPaths = new Set(current.paths)
795
+ return desired.paths?.some((p) => currentPaths.has(p));
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
802
+ return { path: '$HOME/.bun/bin', prepend: false, declarationsOnly: false }
803
+ }
804
+ }
805
+
806
+ const controller = new ResourceController(resource);
807
+ const plan = await controller.plan({ type: 'path' }, { path: '$HOME/.bun/bin' }, null, false);
808
+
809
+ expect(plan.requiresChanges()).to.be.false;
810
+ })
811
+
812
+ it('Can import with the correct default parameters', async () => {
813
+ const resource = new class extends TestResource {
814
+ getSettings(): ResourceSettings<any> {
815
+ return {
816
+ id: 'path',
817
+ operatingSystems: [OS.Darwin],
818
+ parameterSettings: {
819
+ path: { type: 'string', isEqual: 'directory' },
820
+ paths: { canModify: true, type: 'array', isElementEqual: 'directory' },
821
+ prepend: { default: false, setting: true },
822
+ declarationsOnly: { default: false, setting: true },
823
+ },
824
+ importAndDestroy: {
825
+ refreshKeys: ['paths', 'declarationsOnly'],
826
+ defaultRefreshValues: {
827
+ paths: [],
828
+ declarationsOnly: true,
829
+ }
830
+ },
831
+ allowMultiple: {
832
+ matcher: (desired, current) => {
833
+ if (desired.path) {
834
+ return desired.path === current.path;
835
+ }
836
+
837
+ const currentPaths = new Set(current.paths)
838
+ return desired.paths?.some((p) => currentPaths.has(p));
839
+ }
840
+ }
841
+ }
842
+ }
843
+
844
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
845
+ expect(parameters.declarationsOnly).to.be.true;
846
+
847
+ return null;
848
+ }
849
+ }
850
+
851
+ const controller = new ResourceController(resource);
852
+ await controller.import({ type: 'path' }, {});
853
+ ;
854
+ })
855
+
856
+ it('Can import and return all of the imported parameters', async () => {
857
+ const resource = new class extends TestResource {
858
+ getSettings(): ResourceSettings<any> {
859
+ return {
860
+ id: 'path',
861
+ operatingSystems: [OS.Darwin],
862
+ parameterSettings: {
863
+ path: { type: 'directory' },
864
+ paths: { canModify: true, type: 'array', itemType: 'directory' },
865
+ prepend: { default: false, setting: true },
866
+ declarationsOnly: { default: false, setting: true },
867
+ },
868
+ importAndDestroy: {
869
+ refreshMapper: (input, context) => {
870
+ if (Object.keys(input).length === 0) {
871
+ return { paths: [], declarationsOnly: true };
872
+ }
873
+
874
+ return input;
875
+ }
876
+ },
877
+ allowMultiple: {
878
+ matcher: (desired, current) => {
879
+ if (desired.path) {
880
+ return desired.path === current.path;
881
+ }
882
+
883
+ const currentPaths = new Set(current.paths)
884
+ return desired.paths?.some((p) => currentPaths.has(p));
885
+ }
886
+ }
887
+ }
888
+ }
889
+
890
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
891
+ return {
892
+ paths: [
893
+ `${os.homedir()}/.pyenv/bin`,
894
+ `${os.homedir()}/.bun/bin`,
895
+ `${os.homedir()}/.deno/bin`,
896
+ `${os.homedir()}/.jenv/bin`,
897
+ `${os.homedir()}/a/random/path`,
898
+ `${os.homedir()}/.nvm/.bin/2`,
899
+ `${os.homedir()}/.nvm/.bin/3`
900
+ ]
901
+ }
902
+ }
903
+ }
904
+
905
+ const oldProcessEnv = structuredClone(process.env);
906
+
907
+ process.env['PYENV_ROOT'] = `${os.homedir()}/.pyenv`
908
+ process.env['BUN_INSTALL'] = `${os.homedir()}/.bun`
909
+ process.env['DENO_INSTALL'] = `${os.homedir()}/.deno`
910
+ process.env['JENV'] = `${os.homedir()}/.jenv`
911
+ process.env['NVM_DIR'] = `${os.homedir()}/.nvm`
912
+
913
+ const controller = new ResourceController(resource);
914
+ const importResult1 = await controller.import({ type: 'path' }, {});
915
+ expect(importResult1).toMatchObject([
916
+ {
917
+ 'core': {
918
+ 'type': 'path'
919
+ },
920
+ 'parameters': {
921
+ 'paths': [
922
+ '$PYENV_ROOT/bin',
923
+ '$BUN_INSTALL/bin',
924
+ '$DENO_INSTALL/bin',
925
+ '$JENV/bin',
926
+ '~/a/random/path',
927
+ '$NVM_DIR/.bin/2',
928
+ '$NVM_DIR/.bin/3'
929
+ ]
930
+ }
931
+ }
932
+ ])
933
+
934
+ const importResult2 = await controller.import({ type: 'path' }, { paths: ['$PYENV_ROOT/bin', '$BUN_INSTALL/bin'] });
935
+ expect(importResult2).toMatchObject([
936
+ {
937
+ 'core': {
938
+ 'type': 'path'
939
+ },
940
+ 'parameters': {
941
+ 'paths': [
942
+ '$PYENV_ROOT/bin',
943
+ '$BUN_INSTALL/bin',
944
+ '$DENO_INSTALL/bin',
945
+ '$JENV/bin',
946
+ '~/a/random/path',
947
+ '$NVM_DIR/.bin/2',
948
+ '$NVM_DIR/.bin/3'
949
+ ]
950
+ }
951
+ }
952
+ ])
953
+
954
+ process.env = oldProcessEnv;
955
+ })
956
+
957
+ it('Can import and return all of the imported parameters (zod schema)', async () => {
958
+ const schema = z.object({
959
+ path: z
960
+ .string()
961
+ .describe(
962
+ 'A list of paths to add to the PATH environment variable'
963
+ ),
964
+ paths: z
965
+ .array(z.string())
966
+ .describe(
967
+ 'A list of paths to add to the PATH environment variable'
968
+ ),
969
+ prepend: z
970
+ .boolean()
971
+ .describe(
972
+ 'Whether to prepend the paths to the PATH environment variable'
973
+ ),
974
+ declarationsOnly: z
975
+ .boolean()
976
+ .describe(
977
+ 'Whether to only declare the paths in the PATH environment variable'
978
+ ),
979
+ })
980
+
981
+ const resource = new class extends TestResource {
982
+ getSettings(): ResourceSettings<any> {
983
+ return {
984
+ id: 'path',
985
+ schema,
986
+ operatingSystems: [OS.Darwin],
987
+ parameterSettings: {
988
+ path: { type: 'directory' },
989
+ paths: { canModify: true, type: 'array', itemType: 'directory' },
990
+ prepend: { default: false, setting: true },
991
+ declarationsOnly: { default: false, setting: true },
992
+ },
993
+ importAndDestroy: {
994
+ refreshMapper: (input, context) => {
995
+ if (Object.keys(input).length === 0) {
996
+ return { paths: [], declarationsOnly: true };
997
+ }
998
+
999
+ return input;
1000
+ }
1001
+ },
1002
+ allowMultiple: {
1003
+ matcher: (desired, current) => {
1004
+ if (desired.path) {
1005
+ return desired.path === current.path;
1006
+ }
1007
+
1008
+ const currentPaths = new Set(current.paths)
1009
+ return desired.paths?.some((p) => currentPaths.has(p));
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
1016
+ return {
1017
+ paths: [
1018
+ `${os.homedir()}/.pyenv/bin`,
1019
+ `${os.homedir()}/.bun/bin`,
1020
+ `${os.homedir()}/.deno/bin`,
1021
+ `${os.homedir()}/.jenv/bin`,
1022
+ `${os.homedir()}/a/random/path`,
1023
+ `${os.homedir()}/.nvm/.bin/2`,
1024
+ `${os.homedir()}/.nvm/.bin/3`
1025
+ ]
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ const oldProcessEnv = structuredClone(process.env);
1031
+
1032
+ process.env['PYENV_ROOT'] = `${os.homedir()}/.pyenv`
1033
+ process.env['BUN_INSTALL'] = `${os.homedir()}/.bun`
1034
+ process.env['DENO_INSTALL'] = `${os.homedir()}/.deno`
1035
+ process.env['JENV'] = `${os.homedir()}/.jenv`
1036
+ process.env['NVM_DIR'] = `${os.homedir()}/.nvm`
1037
+
1038
+ const controller = new ResourceController(resource);
1039
+ const importResult1 = await controller.import({ type: 'path' }, {});
1040
+ expect(importResult1).toMatchObject([
1041
+ {
1042
+ 'core': {
1043
+ 'type': 'path'
1044
+ },
1045
+ 'parameters': {
1046
+ 'paths': [
1047
+ '$PYENV_ROOT/bin',
1048
+ '$BUN_INSTALL/bin',
1049
+ '$DENO_INSTALL/bin',
1050
+ '$JENV/bin',
1051
+ '~/a/random/path',
1052
+ '$NVM_DIR/.bin/2',
1053
+ '$NVM_DIR/.bin/3'
1054
+ ]
1055
+ }
1056
+ }
1057
+ ])
1058
+
1059
+ const importResult2 = await controller.import({ type: 'path' }, { paths: ['$PYENV_ROOT/bin', '$BUN_INSTALL/bin'] });
1060
+ expect(importResult2).toMatchObject([
1061
+ {
1062
+ 'core': {
1063
+ 'type': 'path'
1064
+ },
1065
+ 'parameters': {
1066
+ 'paths': [
1067
+ '$PYENV_ROOT/bin',
1068
+ '$BUN_INSTALL/bin',
1069
+ '$DENO_INSTALL/bin',
1070
+ '$JENV/bin',
1071
+ '~/a/random/path',
1072
+ '$NVM_DIR/.bin/2',
1073
+ '$NVM_DIR/.bin/3'
1074
+ ]
1075
+ }
1076
+ }
1077
+ ])
1078
+
1079
+ process.env = oldProcessEnv;
1080
+ })
1081
+ });