@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.
- package/.eslintignore +2 -0
- package/.eslintrc.json +30 -0
- package/.github/workflows/release.yaml +19 -0
- package/.github/workflows/unit-test-ci.yaml +18 -0
- package/.prettierrc.json +1 -0
- package/bin/build.js +189 -0
- package/dist/bin/build.d.ts +1 -0
- package/dist/bin/build.js +80 -0
- package/dist/bin/deploy-plugin.d.ts +2 -0
- package/dist/bin/deploy-plugin.js +8 -0
- package/dist/common/errors.d.ts +8 -0
- package/dist/common/errors.js +24 -0
- package/dist/entities/change-set.d.ts +24 -0
- package/dist/entities/change-set.js +152 -0
- package/dist/entities/errors.d.ts +4 -0
- package/dist/entities/errors.js +7 -0
- package/dist/entities/plan-types.d.ts +25 -0
- package/dist/entities/plan-types.js +1 -0
- package/dist/entities/plan.d.ts +15 -0
- package/dist/entities/plan.js +127 -0
- package/dist/entities/plugin.d.ts +16 -0
- package/dist/entities/plugin.js +80 -0
- package/dist/entities/resource-options.d.ts +31 -0
- package/dist/entities/resource-options.js +76 -0
- package/dist/entities/resource-types.d.ts +11 -0
- package/dist/entities/resource-types.js +1 -0
- package/dist/entities/resource.d.ts +42 -0
- package/dist/entities/resource.js +303 -0
- package/dist/entities/stateful-parameter.d.ts +29 -0
- package/dist/entities/stateful-parameter.js +46 -0
- package/dist/entities/transform-parameter.d.ts +4 -0
- package/dist/entities/transform-parameter.js +2 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +7 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +26 -0
- package/dist/messages/handlers.d.ts +14 -0
- package/dist/messages/handlers.js +134 -0
- package/dist/messages/sender.d.ts +11 -0
- package/dist/messages/sender.js +57 -0
- package/dist/plan/change-set.d.ts +53 -0
- package/dist/plan/change-set.js +153 -0
- package/dist/plan/plan-types.d.ts +23 -0
- package/dist/plan/plan-types.js +1 -0
- package/dist/plan/plan.d.ts +66 -0
- package/dist/plan/plan.js +328 -0
- package/dist/plugin/plugin.d.ts +24 -0
- package/dist/plugin/plugin.js +200 -0
- package/dist/pty/background-pty.d.ts +21 -0
- package/dist/pty/background-pty.js +127 -0
- package/dist/pty/index.d.ts +50 -0
- package/dist/pty/index.js +20 -0
- package/dist/pty/promise-queue.d.ts +5 -0
- package/dist/pty/promise-queue.js +26 -0
- package/dist/pty/seqeuntial-pty.d.ts +17 -0
- package/dist/pty/seqeuntial-pty.js +119 -0
- package/dist/pty/vitest.config.d.ts +2 -0
- package/dist/pty/vitest.config.js +11 -0
- package/dist/resource/config-parser.d.ts +11 -0
- package/dist/resource/config-parser.js +21 -0
- package/dist/resource/parsed-resource-settings.d.ts +47 -0
- package/dist/resource/parsed-resource-settings.js +196 -0
- package/dist/resource/resource-controller.d.ts +36 -0
- package/dist/resource/resource-controller.js +402 -0
- package/dist/resource/resource-settings.d.ts +303 -0
- package/dist/resource/resource-settings.js +147 -0
- package/dist/resource/resource.d.ts +144 -0
- package/dist/resource/resource.js +44 -0
- package/dist/resource/stateful-parameter.d.ts +165 -0
- package/dist/resource/stateful-parameter.js +94 -0
- package/dist/scripts/deploy.d.ts +1 -0
- package/dist/scripts/deploy.js +2 -0
- package/dist/stateful-parameter/stateful-parameter-controller.d.ts +21 -0
- package/dist/stateful-parameter/stateful-parameter-controller.js +81 -0
- package/dist/stateful-parameter/stateful-parameter.d.ts +144 -0
- package/dist/stateful-parameter/stateful-parameter.js +43 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +5 -0
- package/dist/utils/codify-spawn.d.ts +29 -0
- package/dist/utils/codify-spawn.js +136 -0
- package/dist/utils/debug.d.ts +2 -0
- package/dist/utils/debug.js +10 -0
- package/dist/utils/file-utils.d.ts +23 -0
- package/dist/utils/file-utils.js +186 -0
- package/dist/utils/functions.d.ts +12 -0
- package/dist/utils/functions.js +74 -0
- package/dist/utils/index.d.ts +46 -0
- package/dist/utils/index.js +271 -0
- package/dist/utils/internal-utils.d.ts +12 -0
- package/dist/utils/internal-utils.js +74 -0
- package/dist/utils/load-resources.d.ts +1 -0
- package/dist/utils/load-resources.js +46 -0
- package/dist/utils/package-json-utils.d.ts +12 -0
- package/dist/utils/package-json-utils.js +34 -0
- package/dist/utils/pty-local-storage.d.ts +2 -0
- package/dist/utils/pty-local-storage.js +2 -0
- package/dist/utils/spawn-2.d.ts +5 -0
- package/dist/utils/spawn-2.js +7 -0
- package/dist/utils/spawn.d.ts +29 -0
- package/dist/utils/spawn.js +124 -0
- package/dist/utils/utils.d.ts +18 -0
- package/dist/utils/utils.js +86 -0
- package/dist/utils/verbosity-level.d.ts +5 -0
- package/dist/utils/verbosity-level.js +9 -0
- package/package.json +59 -0
- package/rollup.config.js +24 -0
- package/src/common/errors.test.ts +43 -0
- package/src/common/errors.ts +31 -0
- package/src/errors.ts +8 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +30 -0
- package/src/messages/handlers.test.ts +329 -0
- package/src/messages/handlers.ts +181 -0
- package/src/messages/sender.ts +69 -0
- package/src/plan/change-set.test.ts +280 -0
- package/src/plan/change-set.ts +236 -0
- package/src/plan/plan-types.ts +27 -0
- package/src/plan/plan.test.ts +413 -0
- package/src/plan/plan.ts +499 -0
- package/src/plugin/plugin.test.ts +533 -0
- package/src/plugin/plugin.ts +291 -0
- package/src/pty/background-pty.test.ts +69 -0
- package/src/pty/background-pty.ts +154 -0
- package/src/pty/index.test.ts +129 -0
- package/src/pty/index.ts +66 -0
- package/src/pty/promise-queue.ts +33 -0
- package/src/pty/seqeuntial-pty.ts +151 -0
- package/src/pty/sequential-pty.test.ts +194 -0
- package/src/resource/config-parser.ts +42 -0
- package/src/resource/parsed-resource-settings.test.ts +186 -0
- package/src/resource/parsed-resource-settings.ts +307 -0
- package/src/resource/resource-controller-stateful-mode.test.ts +253 -0
- package/src/resource/resource-controller.test.ts +1081 -0
- package/src/resource/resource-controller.ts +563 -0
- package/src/resource/resource-settings.test.ts +1213 -0
- package/src/resource/resource-settings.ts +545 -0
- package/src/resource/resource.ts +157 -0
- package/src/stateful-parameter/stateful-parameter-controller.test.ts +244 -0
- package/src/stateful-parameter/stateful-parameter-controller.ts +111 -0
- package/src/stateful-parameter/stateful-parameter.ts +160 -0
- package/src/utils/debug.ts +11 -0
- package/src/utils/file-utils.test.ts +7 -0
- package/src/utils/file-utils.ts +231 -0
- package/src/utils/functions.ts +103 -0
- package/src/utils/index.ts +340 -0
- package/src/utils/internal-utils.test.ts +52 -0
- package/src/utils/pty-local-storage.ts +3 -0
- package/src/utils/test-utils.test.ts +96 -0
- package/src/utils/verbosity-level.ts +11 -0
- package/tsconfig.json +26 -0
- package/tsconfig.test.json +9 -0
- 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,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,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
|
+
}
|