@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,200 @@
1
+ import { ApplyValidationError } from '../common/errors.js';
2
+ import { Plan } from '../plan/plan.js';
3
+ import { BackgroundPty } from '../pty/background-pty.js';
4
+ import { getPty } from '../pty/index.js';
5
+ import { SequentialPty } from '../pty/seqeuntial-pty.js';
6
+ import { ResourceController } from '../resource/resource-controller.js';
7
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
8
+ import { VerbosityLevel } from '../utils/verbosity-level.js';
9
+ export class Plugin {
10
+ name;
11
+ resourceControllers;
12
+ planStorage;
13
+ planPty = new BackgroundPty();
14
+ constructor(name, resourceControllers) {
15
+ this.name = name;
16
+ this.resourceControllers = resourceControllers;
17
+ this.planStorage = new Map();
18
+ }
19
+ static create(name, resources) {
20
+ const controllers = resources
21
+ .map((resource) => new ResourceController(resource));
22
+ const controllersMap = new Map(controllers.map((r) => [r.typeId, r]));
23
+ return new Plugin(name, controllersMap);
24
+ }
25
+ async initialize(data) {
26
+ if (data.verbosityLevel) {
27
+ VerbosityLevel.set(data.verbosityLevel);
28
+ }
29
+ for (const controller of this.resourceControllers.values()) {
30
+ await controller.initialize();
31
+ }
32
+ return {
33
+ resourceDefinitions: [...this.resourceControllers.values()]
34
+ .map((r) => {
35
+ const sensitiveParameters = Object.entries(r.settings.parameterSettings ?? {})
36
+ .filter(([, v]) => v?.isSensitive)
37
+ .map(([k]) => k);
38
+ // Here we add '*' if the resource is sensitive but no sensitive parameters are found. This works because the import
39
+ // sensitivity check only checks for the existance of a sensitive parameter whereas the parameter blocking one blocks
40
+ // on a specific sensitive parameter.
41
+ if (r.settings.isSensitive && sensitiveParameters.length === 0) {
42
+ sensitiveParameters.push('*');
43
+ }
44
+ return {
45
+ dependencies: r.dependencies,
46
+ type: r.typeId,
47
+ sensitiveParameters,
48
+ operatingSystems: r.settings.operatingSystems,
49
+ linuxDistros: r.settings.linuxDistros,
50
+ };
51
+ })
52
+ };
53
+ }
54
+ getResourceInfo(data) {
55
+ if (!this.resourceControllers.has(data.type)) {
56
+ throw new Error(`Cannot get info for resource ${data.type}, resource doesn't exist`);
57
+ }
58
+ const resource = this.resourceControllers.get(data.type);
59
+ const schema = resource.parsedSettings.schema;
60
+ const requiredPropertyNames = (resource.settings.importAndDestroy?.requiredParameters
61
+ ?? (typeof resource.settings.allowMultiple === 'object' ? resource.settings.allowMultiple.identifyingParameters : null)
62
+ ?? schema?.required
63
+ ?? undefined);
64
+ const allowMultiple = resource.settings.allowMultiple !== undefined
65
+ && resource.settings.allowMultiple !== false;
66
+ // Here we add '*' if the resource is sensitive but no sensitive parameters are found. This works because the import
67
+ // sensitivity check only checks for the existance of a sensitive parameter whereas the parameter blocking one blocks
68
+ // on a specific sensitive parameter.
69
+ const sensitiveParameters = Object.entries(resource.settings.parameterSettings ?? {})
70
+ .filter(([, v]) => v?.isSensitive)
71
+ .map(([k]) => k);
72
+ if (resource.settings.isSensitive && sensitiveParameters.length === 0) {
73
+ sensitiveParameters.push('*');
74
+ }
75
+ return {
76
+ plugin: this.name,
77
+ type: data.type,
78
+ dependencies: resource.dependencies,
79
+ schema: schema,
80
+ importAndDestroy: {
81
+ preventImport: resource.settings.importAndDestroy?.preventImport,
82
+ requiredParameters: requiredPropertyNames,
83
+ },
84
+ import: {
85
+ requiredParameters: requiredPropertyNames,
86
+ },
87
+ operatingSystems: resource.settings.operatingSystems,
88
+ linuxDistros: resource.settings.linuxDistros,
89
+ sensitiveParameters,
90
+ allowMultiple
91
+ };
92
+ }
93
+ async match(data) {
94
+ const { resource: resourceConfig, array } = data;
95
+ const resource = this.resourceControllers.get(resourceConfig.core.type);
96
+ if (!resource) {
97
+ throw new Error(`Resource of type ${resourceConfig.core.type} could not be found for match`);
98
+ }
99
+ const match = await resource.match(resourceConfig, array);
100
+ return { match };
101
+ }
102
+ async import(data) {
103
+ const { core, parameters, autoSearchAll } = data;
104
+ if (!this.resourceControllers.has(core.type)) {
105
+ throw new Error(`Cannot get info for resource ${core.type}, resource doesn't exist`);
106
+ }
107
+ const result = await ptyLocalStorage.run(this.planPty, () => this.resourceControllers
108
+ .get(core.type)
109
+ ?.import(core, parameters, autoSearchAll));
110
+ return {
111
+ request: data,
112
+ result: result ?? [],
113
+ };
114
+ }
115
+ async validate(data) {
116
+ const validationResults = [];
117
+ for (const config of data.configs) {
118
+ const { core, parameters } = config;
119
+ if (!this.resourceControllers.has(core.type)) {
120
+ throw new Error(`Resource type not found: ${core.type}`);
121
+ }
122
+ const validation = await this.resourceControllers
123
+ .get(core.type)
124
+ .validate(core, parameters);
125
+ validationResults.push(validation);
126
+ }
127
+ // Validate that if allow multiple is false, then only 1 of each resource exists
128
+ const countMap = data.configs.reduce((map, resource) => {
129
+ if (!map.has(resource.core.type)) {
130
+ map.set(resource.core.type, 0);
131
+ }
132
+ const count = map.get(resource.core.type);
133
+ map.set(resource.core.type, count + 1);
134
+ return map;
135
+ }, new Map());
136
+ const invalidMultipleConfigs = [...countMap.entries()].filter(([k, v]) => {
137
+ const controller = this.resourceControllers.get(k);
138
+ return !controller.parsedSettings.allowMultiple && v > 1;
139
+ });
140
+ if (invalidMultipleConfigs.length > 0) {
141
+ throw new Error(`Multiples of the following configs were found but only 1 is allowed. [${invalidMultipleConfigs.map(([k, v]) => `${v}x ${k}`).join(', ')}] found.`);
142
+ }
143
+ await this.crossValidateResources(data.configs);
144
+ return {
145
+ resourceValidations: validationResults
146
+ };
147
+ }
148
+ async plan(data) {
149
+ const { type } = data.core;
150
+ if (!this.resourceControllers.has(type)) {
151
+ throw new Error(`Resource type not found: ${type}`);
152
+ }
153
+ const plan = await ptyLocalStorage.run(this.planPty, async () => this.resourceControllers.get(type).plan(data.core, data.desired ?? null, data.state ?? null, data.isStateful));
154
+ this.planStorage.set(plan.id, plan);
155
+ return plan.toResponse();
156
+ }
157
+ async apply(data) {
158
+ if (!data.planId && !data.plan) {
159
+ throw new Error('For applies either plan or planId must be supplied');
160
+ }
161
+ const plan = this.resolvePlan(data);
162
+ const resource = this.resourceControllers.get(plan.getResourceType());
163
+ if (!resource) {
164
+ throw new Error('Malformed plan with resource that cannot be found');
165
+ }
166
+ await ptyLocalStorage.run(new SequentialPty(), async () => resource.apply(plan));
167
+ // Validate using desired/desired. If the apply was successful, no changes should be reported back.
168
+ // Default back desired back to current if it is not defined (for destroys only)
169
+ const validationPlan = await ptyLocalStorage.run(new BackgroundPty(), async () => {
170
+ const result = await resource.plan(plan.coreParameters, plan.desiredConfig, plan.desiredConfig ?? plan.currentConfig, plan.isStateful, 'validationPlan');
171
+ await getPty().kill();
172
+ return result;
173
+ });
174
+ if (validationPlan.requiresChanges()) {
175
+ throw new ApplyValidationError(plan);
176
+ }
177
+ }
178
+ async setVerbosityLevel(data) {
179
+ VerbosityLevel.set(data.verbosityLevel);
180
+ }
181
+ async kill() {
182
+ await this.planPty.kill();
183
+ }
184
+ resolvePlan(data) {
185
+ const { plan: planRequest, planId } = data;
186
+ if (planId) {
187
+ if (!this.planStorage.has(planId)) {
188
+ throw new Error(`Plan with id: ${planId} was not found`);
189
+ }
190
+ return this.planStorage.get(planId);
191
+ }
192
+ if (!planRequest?.resourceType || !this.resourceControllers.has(planRequest.resourceType)) {
193
+ throw new Error('Malformed plan. Resource type must be supplied or resource type was not found');
194
+ }
195
+ const resource = this.resourceControllers.get(planRequest.resourceType);
196
+ return Plan.fromResponse(planRequest, resource.parsedSettings.defaultValues);
197
+ }
198
+ async crossValidateResources(resources) {
199
+ }
200
+ }
@@ -0,0 +1,21 @@
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 historyIgnore;
10
+ private basePty;
11
+ private promiseQueue;
12
+ constructor();
13
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
14
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
15
+ kill(): Promise<{
16
+ exitCode: number;
17
+ signal?: number | undefined;
18
+ }>;
19
+ private initialize;
20
+ private getDefaultShell;
21
+ }
@@ -0,0 +1,127 @@
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
+ import { debugLog } from '../utils/debug.js';
8
+ import { Shell, Utils } from '../utils/index.js';
9
+ import { VerbosityLevel } from '../utils/verbosity-level.js';
10
+ import { SpawnError } from './index.js';
11
+ import { PromiseQueue } from './promise-queue.js';
12
+ EventEmitter.defaultMaxListeners = 1000;
13
+ /**
14
+ * The background pty is a specialized pty designed for speed. It can launch multiple tasks
15
+ * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
16
+ * to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
17
+ * without a tty (or even a stdin) attached so interactive commands will not work.
18
+ */
19
+ export class BackgroundPty {
20
+ historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
21
+ basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
22
+ env: { ...process.env, ...this.historyIgnore },
23
+ cols: 10_000, // Set to a really large value to prevent wrapping
24
+ name: nanoid(6),
25
+ handleFlowControl: true
26
+ });
27
+ promiseQueue = new PromiseQueue();
28
+ constructor() {
29
+ this.initialize();
30
+ }
31
+ async spawn(cmd, options) {
32
+ const spawnResult = await this.spawnSafe(cmd, options);
33
+ if (spawnResult.status !== 'success') {
34
+ throw new SpawnError(Array.isArray(cmd) ? cmd.join(' ') : cmd, spawnResult.exitCode, spawnResult.data);
35
+ }
36
+ return spawnResult;
37
+ }
38
+ async spawnSafe(cmd, options) {
39
+ cmd = Array.isArray(cmd) ? cmd.join('\\\n') : cmd;
40
+ // cid is command id
41
+ const cid = nanoid(10);
42
+ debugLog(cid);
43
+ await new Promise((resolve) => {
44
+ // 600 permissions means only the current user will be able to rw from the FIFO
45
+ // Create in /tmp so it could be automatically cleaned up if the clean-up was missed
46
+ const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
47
+ mkfifoSpawn.on('close', () => {
48
+ resolve(null);
49
+ });
50
+ });
51
+ return new Promise((resolve) => {
52
+ const cat = cp.spawn('cat', [`/tmp/${cid}`]);
53
+ let output = '';
54
+ cat.stdout.on('data', (data) => {
55
+ output += data.toString();
56
+ if (output.includes('%%%done%%%"')) {
57
+ const truncOutput = output.replace('%%%done%%%"\n', '');
58
+ const [data, exit] = truncOutput.split('%%%');
59
+ // Clean up trailing \n newline if it exists
60
+ let strippedData = stripAnsi(data);
61
+ if (strippedData.endsWith('\n')) {
62
+ strippedData = strippedData.slice(0, -1);
63
+ }
64
+ resolve({
65
+ status: Number.parseInt(exit ?? 1, 10) === 0 ? 'success' : 'error',
66
+ exitCode: Number.parseInt(exit ?? 1, 10),
67
+ data: strippedData,
68
+ });
69
+ }
70
+ else {
71
+ // Print to stdout if the verbosity level is above 0
72
+ if (VerbosityLevel.get() > 0) {
73
+ process.stdout.write(data);
74
+ }
75
+ }
76
+ });
77
+ this.promiseQueue.run(async () => new Promise((resolve) => {
78
+ const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
79
+ // Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
80
+ // Done is used to denote the end of the command
81
+ // Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
82
+ const command = ` ((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`;
83
+ let output = '';
84
+ const listener = this.basePty.onData((data) => {
85
+ output += data;
86
+ if (output.includes(`%%%done%%%${cid}"`)) {
87
+ listener.dispose();
88
+ resolve(null);
89
+ }
90
+ });
91
+ console.log(`Running command: ${cmd}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
92
+ this.basePty.write(`${command}\r`);
93
+ }));
94
+ }).finally(async () => {
95
+ await fs.rm(`/tmp/${cid}`);
96
+ });
97
+ }
98
+ async kill() {
99
+ return new Promise((resolve) => {
100
+ this.basePty.onExit((status) => {
101
+ resolve(status);
102
+ });
103
+ this.basePty.kill('SIGKILL');
104
+ });
105
+ }
106
+ async initialize() {
107
+ // this.basePty.onData((data: string) => process.stdout.write(data));
108
+ await this.promiseQueue.run(async () => {
109
+ let outputBuffer = '';
110
+ return new Promise(resolve => {
111
+ this.basePty.write(' unset PS1;\n');
112
+ this.basePty.write(' unset PS0;\n');
113
+ this.basePty.write(' echo setup complete\\"\n');
114
+ const listener = this.basePty.onData((data) => {
115
+ outputBuffer += data;
116
+ if (outputBuffer.includes('setup complete"')) {
117
+ listener.dispose();
118
+ resolve(null);
119
+ }
120
+ });
121
+ });
122
+ });
123
+ }
124
+ getDefaultShell() {
125
+ return process.env.SHELL;
126
+ }
127
+ }
@@ -0,0 +1,50 @@
1
+ export interface SpawnResult {
2
+ status: 'error' | 'success';
3
+ exitCode: number;
4
+ data: string;
5
+ }
6
+ export declare enum SpawnStatus {
7
+ SUCCESS = "success",
8
+ ERROR = "error"
9
+ }
10
+ /**
11
+ * Represents the configuration options for spawning a child process.
12
+ *
13
+ * @interface SpawnOptions
14
+ *
15
+ * @property {string} [cwd] - Specifies the working directory of the child process.
16
+ * If not provided, the current working directory of the parent process is used.
17
+ *
18
+ * @property {Record<string, unknown>} [env] - Defines environment key-value pairs
19
+ * that will be available to the child process. If not specified, the child process
20
+ * will inherit the environment variables of the parent process.
21
+ *
22
+ * @property {boolean} [interactive] - Indicates whether the spawned process needs
23
+ * to be interactive. Only works within apply (not plan). Defaults to true.
24
+ *
25
+ * @property {boolean} [disableWrapping] - Forces the terminal width to 10_000 to disable wrapping.
26
+ * In applys, this is off by default while it is on during plans.
27
+ */
28
+ export interface SpawnOptions {
29
+ cwd?: string;
30
+ env?: Record<string, unknown>;
31
+ interactive?: boolean;
32
+ requiresRoot?: boolean;
33
+ stdin?: boolean;
34
+ disableWrapping?: boolean;
35
+ }
36
+ export declare class SpawnError extends Error {
37
+ data: string;
38
+ cmd: string;
39
+ exitCode: number;
40
+ constructor(cmd: string, exitCode: number, data: string);
41
+ }
42
+ export interface IPty {
43
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
44
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
45
+ kill(): Promise<{
46
+ exitCode: number;
47
+ signal?: number | undefined;
48
+ }>;
49
+ }
50
+ export declare function getPty(): IPty;
@@ -0,0 +1,20 @@
1
+ import { ptyLocalStorage } from '../utils/pty-local-storage.js';
2
+ export var SpawnStatus;
3
+ (function (SpawnStatus) {
4
+ SpawnStatus["SUCCESS"] = "success";
5
+ SpawnStatus["ERROR"] = "error";
6
+ })(SpawnStatus || (SpawnStatus = {}));
7
+ export class SpawnError extends Error {
8
+ data;
9
+ cmd;
10
+ exitCode;
11
+ constructor(cmd, exitCode, data) {
12
+ super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`);
13
+ this.data = data;
14
+ this.cmd = cmd;
15
+ this.exitCode = exitCode;
16
+ }
17
+ }
18
+ export function getPty() {
19
+ return ptyLocalStorage.getStore();
20
+ }
@@ -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,17 @@
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 SequentialPty implements IPty {
9
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
10
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
11
+ kill(): Promise<{
12
+ exitCode: number;
13
+ signal?: number | undefined;
14
+ }>;
15
+ externalSpawn(cmd: string, opts: SpawnOptions): Promise<SpawnResult>;
16
+ private getDefaultShell;
17
+ }
@@ -0,0 +1,119 @@
1
+ import pty from '@homebridge/node-pty-prebuilt-multiarch';
2
+ import { Ajv } from 'ajv';
3
+ import { CommandRequestResponseDataSchema, MessageCmd } from 'codify-schemas';
4
+ import { nanoid } from 'nanoid';
5
+ import { EventEmitter } from 'node:events';
6
+ import stripAnsi from 'strip-ansi';
7
+ import { Shell, Utils } from '../utils/index.js';
8
+ import { VerbosityLevel } from '../utils/verbosity-level.js';
9
+ import { SpawnError, SpawnStatus } from './index.js';
10
+ EventEmitter.defaultMaxListeners = 1000;
11
+ const ajv = new Ajv({
12
+ strict: true,
13
+ });
14
+ const validateSudoRequestResponse = ajv.compile(CommandRequestResponseDataSchema);
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 SequentialPty {
22
+ async spawn(cmd, options) {
23
+ const spawnResult = await this.spawnSafe(cmd, options);
24
+ if (spawnResult.status !== 'success') {
25
+ throw new SpawnError(Array.isArray(cmd) ? cmd.join('\n') : cmd, spawnResult.exitCode, spawnResult.data);
26
+ }
27
+ return spawnResult;
28
+ }
29
+ async spawnSafe(cmd, options) {
30
+ cmd = Array.isArray(cmd) ? cmd.join(' ') : cmd;
31
+ if (cmd.includes('sudo')) {
32
+ throw new Error('Do not directly use sudo. Use the option { requiresRoot: true } instead');
33
+ }
34
+ // If sudo is required, we must delegate to the main codify process.
35
+ if (options?.stdin || options?.requiresRoot) {
36
+ return this.externalSpawn(cmd, options);
37
+ }
38
+ console.log(`Running command: ${Array.isArray(cmd) ? cmd.join('\\\n') : cmd}` + (options?.cwd ? `(${options?.cwd})` : ''));
39
+ return new Promise((resolve) => {
40
+ const output = [];
41
+ const historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
42
+ // If TERM_PROGRAM=Apple_Terminal is set then ANSI escape characters may be included
43
+ // in the response.
44
+ const env = {
45
+ ...process.env, ...options?.env,
46
+ TERM_PROGRAM: 'codify',
47
+ COMMAND_MODE: 'unix2003',
48
+ COLORTERM: 'truecolor',
49
+ ...historyIgnore
50
+ };
51
+ // Initial terminal dimensions
52
+ // Set to a really large value to prevent wrapping
53
+ const initialCols = options?.disableWrapping ? 10_000 : process.stdout.columns ?? 80;
54
+ const initialRows = process.stdout.rows ?? 24;
55
+ const args = options?.interactive ? ['-i', '-c', cmd] : ['-c', cmd];
56
+ // Run the command in a pty for interactivity
57
+ const mPty = pty.spawn(this.getDefaultShell(), args, {
58
+ ...options,
59
+ cols: initialCols,
60
+ rows: initialRows,
61
+ env
62
+ });
63
+ mPty.onData((data) => {
64
+ if (VerbosityLevel.get() > 0) {
65
+ process.stdout.write(data);
66
+ }
67
+ output.push(data.toString());
68
+ });
69
+ const resizeListener = () => {
70
+ const { columns, rows } = process.stdout;
71
+ mPty.resize(columns, options?.disableWrapping ? 10_000 : rows);
72
+ };
73
+ // Listen to resize events for the terminal window;
74
+ process.stdout.on('resize', resizeListener);
75
+ mPty.onExit((result) => {
76
+ process.stdout.off('resize', resizeListener);
77
+ resolve({
78
+ status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
79
+ exitCode: result.exitCode,
80
+ data: stripAnsi(output.join('\n').trim()),
81
+ });
82
+ });
83
+ });
84
+ }
85
+ async kill() {
86
+ // No-op here. Each pty instance is stand alone and tied to the parent process. Everything should be killed as expected.
87
+ return {
88
+ exitCode: 0,
89
+ signal: 0,
90
+ };
91
+ }
92
+ // For safety reasons, requests that require sudo or are interactive must be run via the main client
93
+ async externalSpawn(cmd, opts) {
94
+ return new Promise((resolve) => {
95
+ const requestId = nanoid(8);
96
+ const listener = (data) => {
97
+ if (data.requestId === requestId) {
98
+ process.removeListener('message', listener);
99
+ if (!validateSudoRequestResponse(data.data)) {
100
+ throw new Error(`Invalid response for sudo request: ${JSON.stringify(validateSudoRequestResponse.errors, null, 2)}`);
101
+ }
102
+ resolve(data.data);
103
+ }
104
+ };
105
+ process.on('message', listener);
106
+ process.send({
107
+ cmd: MessageCmd.COMMAND_REQUEST,
108
+ data: {
109
+ command: cmd,
110
+ options: opts ?? {},
111
+ },
112
+ requestId
113
+ });
114
+ });
115
+ }
116
+ getDefaultShell() {
117
+ return process.env.SHELL;
118
+ }
119
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defaultExclude, defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ pool: 'forks',
5
+ fileParallelism: false,
6
+ exclude: [
7
+ ...defaultExclude,
8
+ './src/utils/test-utils.test.ts',
9
+ ]
10
+ },
11
+ });
@@ -0,0 +1,11 @@
1
+ import { StringIndexedObject } from 'codify-schemas';
2
+ import { StatefulParameterController } from '../stateful-parameter/stateful-parameter-controller.js';
3
+ export declare class ConfigParser<T extends StringIndexedObject> {
4
+ private readonly desiredConfig;
5
+ private readonly stateConfig;
6
+ private statefulParametersMap;
7
+ constructor(desiredConfig: Partial<T> | null, stateConfig: Partial<T> | null, statefulParameters: Map<keyof T, StatefulParameterController<T, T[keyof T]>>);
8
+ get allParameters(): Partial<T>;
9
+ get allNonStatefulParameters(): Partial<T>;
10
+ get allStatefulParameters(): Partial<T>;
11
+ }
@@ -0,0 +1,21 @@
1
+ export class ConfigParser {
2
+ desiredConfig;
3
+ stateConfig;
4
+ statefulParametersMap;
5
+ constructor(desiredConfig, stateConfig, statefulParameters) {
6
+ this.desiredConfig = desiredConfig;
7
+ this.stateConfig = stateConfig;
8
+ this.statefulParametersMap = statefulParameters;
9
+ }
10
+ get allParameters() {
11
+ return { ...this.desiredConfig, ...this.stateConfig };
12
+ }
13
+ get allNonStatefulParameters() {
14
+ const { allParameters, statefulParametersMap, } = this;
15
+ return Object.fromEntries(Object.entries(allParameters).filter(([key]) => !statefulParametersMap.has(key)));
16
+ }
17
+ get allStatefulParameters() {
18
+ const { allParameters, statefulParametersMap } = this;
19
+ return Object.fromEntries(Object.entries(allParameters).filter(([key]) => statefulParametersMap.has(key)));
20
+ }
21
+ }