@gorilla-engine-sdk/gorilla-engine-scripts 1.3.4

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.
@@ -0,0 +1,355 @@
1
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { ts } from './utils.js';
5
+ import { deployAssetFilesToOutput } from './assets.js';
6
+
7
+ function toTypeIdentifier(value) {
8
+ const identifier = value
9
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
10
+ .trim()
11
+ .split(/\s+/)
12
+ .filter(Boolean)
13
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
14
+ .join('');
15
+
16
+ if (!identifier) return 'Instrument';
17
+ return /^\d/.test(identifier) ? `Instrument${identifier}` : identifier;
18
+ }
19
+
20
+ function toUnionLines(values, indent) {
21
+ if (values.length === 0) {
22
+ return [`${indent}never;`];
23
+ }
24
+
25
+ return values.map((value, index) => {
26
+ const suffix = index === values.length - 1 ? ';' : '';
27
+ return `${indent}| ${JSON.stringify(value)}${suffix}`;
28
+ });
29
+ }
30
+
31
+ function toLowerCaseNames(values) {
32
+ return [...new Set(values.map((value) => value.toLowerCase()))];
33
+ }
34
+
35
+ function toRuntimeParamKey(value) {
36
+ return value.toLowerCase();
37
+ }
38
+
39
+ function normalizeParameterInfo(parameterInfo) {
40
+ const seenNames = new Set();
41
+
42
+ return parameterInfo.filter((parameter) => {
43
+ if (!parameter || typeof parameter.name !== 'string' || typeof parameter.path !== 'string') {
44
+ return false;
45
+ }
46
+ if (seenNames.has(parameter.name)) {
47
+ return false;
48
+ }
49
+ seenNames.add(parameter.name);
50
+ return true;
51
+ });
52
+ }
53
+
54
+ function extractJsonArray(stdout, instrumentPath) {
55
+ const start = stdout.indexOf('[');
56
+ const end = stdout.lastIndexOf(']');
57
+
58
+ if (start === -1 || end === -1 || end < start) {
59
+ throw new Error(`${path.basename(instrumentPath)} parameter info did not contain a JSON array`);
60
+ }
61
+
62
+ return stdout.slice(start, end + 1);
63
+ }
64
+
65
+ export function parseBlobExportCapabilities(helpOutput) {
66
+ const versionMatch = helpOutput.match(/Blob Export\s*-\s*Version:\s*([^\r\n]+)/i);
67
+ return {
68
+ version: versionMatch?.[1].trim() ?? 'unknown',
69
+ supportsParameterInfo: /(?:^|\s)--parameter-info(?:\s|$)/m.test(helpOutput),
70
+ };
71
+ }
72
+
73
+ async function getBlobExportCapabilities(blobExportPath, cwd) {
74
+ const helpOutput = await new Promise((resolve, reject) => {
75
+ let output = '';
76
+ const child = spawn(blobExportPath, ['-h'], {
77
+ cwd,
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ });
80
+
81
+ child.stdout.on('data', (chunk) => {
82
+ output += chunk.toString();
83
+ });
84
+ child.stderr.on('data', (chunk) => {
85
+ output += chunk.toString();
86
+ });
87
+ child.on('error', reject);
88
+ child.on('exit', () => resolve(output));
89
+ });
90
+
91
+ return parseBlobExportCapabilities(helpOutput);
92
+ }
93
+
94
+ async function getInstrumentParameterInfo(blobExportPath, instrumentPath, cwd) {
95
+ const stdout = await new Promise((resolve, reject) => {
96
+ let output = '';
97
+ let errors = '';
98
+ const child = spawn(blobExportPath, ['--parameter-info', instrumentPath], {
99
+ cwd,
100
+ stdio: ['ignore', 'pipe', 'pipe'],
101
+ });
102
+
103
+ child.stdout.on('data', (chunk) => {
104
+ output += chunk.toString();
105
+ });
106
+ child.stderr.on('data', (chunk) => {
107
+ errors += chunk.toString();
108
+ });
109
+ child.on('error', reject);
110
+ child.on('exit', (code) => {
111
+ if (code === 0) {
112
+ resolve(output);
113
+ return;
114
+ }
115
+
116
+ const details = errors.trim() || output.trim();
117
+ reject(
118
+ new Error(
119
+ `${path.basename(blobExportPath)} failed to read parameter info for ${path.basename(instrumentPath)}${details ? `: ${details}` : ''}`,
120
+ ),
121
+ );
122
+ });
123
+ });
124
+
125
+ return normalizeParameterInfo(JSON.parse(extractJsonArray(stdout, instrumentPath)));
126
+ }
127
+
128
+ export function generateInstrumentTypes(instrumentDefinitions) {
129
+ const lines = [
130
+ '// This file is auto-generated by @gorilla-engine-sdk/gorilla-engine-scripts.',
131
+ '// Do not edit manually.',
132
+ '',
133
+ 'export {};',
134
+ '',
135
+ 'declare global {',
136
+ ' namespace GorillaEngine {',
137
+ ' namespace Runtime {',
138
+ ];
139
+
140
+ const allParams = [];
141
+ const pSeen = new Set();
142
+ for (const instrument of instrumentDefinitions) {
143
+ for (const parameter of instrument.parameters) {
144
+ const name = toRuntimeParamKey(parameter.name);
145
+ if (!pSeen.has(name)) {
146
+ pSeen.add(name);
147
+ allParams.push(name);
148
+ }
149
+ }
150
+ }
151
+
152
+ lines.push(' type ParamName =');
153
+ lines.push(...toUnionLines(allParams, ' '));
154
+ lines.push('');
155
+
156
+ lines.push(' interface ParameterMap {');
157
+ for (const name of allParams) {
158
+ lines.push(` ${JSON.stringify(name)}: GorillaEngine.InstrumentProperty;`);
159
+ }
160
+ lines.push(' }');
161
+ lines.push('');
162
+
163
+ lines.push(' type Instrument = GorillaEngine.Instrument & ParameterMap;');
164
+ lines.push('');
165
+
166
+ lines.push(
167
+ ' type DynamicInstrument = GorillaEngine.Instrument & Partial<ParameterMap> & Record<string, GorillaEngine.InstrumentProperty | undefined>;',
168
+ );
169
+
170
+ lines.push(' }');
171
+ lines.push(' }');
172
+ lines.push('}');
173
+ lines.push('');
174
+
175
+ return `${lines.join('\n')}`;
176
+ }
177
+
178
+ async function writeInstrumentTypesFile(generatedInstrumentTypesPath, instrumentDefinitions) {
179
+ await mkdir(path.dirname(generatedInstrumentTypesPath), { recursive: true });
180
+ await writeFile(
181
+ generatedInstrumentTypesPath,
182
+ generateInstrumentTypes(instrumentDefinitions),
183
+ 'utf8',
184
+ );
185
+ }
186
+
187
+ export async function runBlobExport(blobExportPath, instrumentPath, blobOutputPath, cwd) {
188
+ console.log(
189
+ `[${ts()}] Running ${path.basename(blobExportPath)} for ${path.basename(instrumentPath)}`,
190
+ );
191
+
192
+ await new Promise((resolve, reject) => {
193
+ const child = spawn(
194
+ blobExportPath,
195
+ ['--reload-scripts-update', instrumentPath, blobOutputPath],
196
+ {
197
+ cwd,
198
+ stdio: ['ignore', 'inherit', 'inherit'],
199
+ },
200
+ );
201
+ child.on('error', reject);
202
+ child.on('exit', (code) => {
203
+ if (code === 0) {
204
+ resolve();
205
+ } else {
206
+ reject(new Error(`${path.basename(blobExportPath)} exited with code ${code ?? 'unknown'}`));
207
+ }
208
+ });
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Manages debounced instrument blob export triggered by file-system watch events.
214
+ * After exporting, it also re-deploys the updated asset files.
215
+ */
216
+ export class InstrumentExporter {
217
+ suppressWatcher = false;
218
+
219
+ #config;
220
+ #assetDeployer;
221
+ #standaloneManager;
222
+ #timer = null;
223
+ #inProgress = false;
224
+ #rerun = false;
225
+ #warnedMissingBlobExport = false;
226
+
227
+ constructor(config, assetDeployer, standaloneManager) {
228
+ this.#config = config;
229
+ this.#assetDeployer = assetDeployer;
230
+ this.#standaloneManager = standaloneManager;
231
+ }
232
+
233
+ schedule() {
234
+ if (this.suppressWatcher) return;
235
+
236
+ clearTimeout(this.#timer);
237
+ this.#timer = setTimeout(() => {
238
+ this.#timer = null;
239
+ void this.#run();
240
+ }, 250);
241
+ }
242
+
243
+ clearTimer() {
244
+ clearTimeout(this.#timer);
245
+ this.#timer = null;
246
+ }
247
+
248
+ async runOnce() {
249
+ await this.#run('one-shot');
250
+ }
251
+
252
+ #warnBlobExportUnavailable() {
253
+ if (this.#warnedMissingBlobExport) return;
254
+
255
+ const { blobExportDefaultPath } = this.#config;
256
+ console.warn(
257
+ `[${ts()}] WARNING: blob-export was not found. Instrument blob export and generated typings are being skipped. Checked ${blobExportDefaultPath} and PATH.`,
258
+ );
259
+ this.#warnedMissingBlobExport = true;
260
+ }
261
+
262
+ async #run(trigger = 'watch') {
263
+ if (this.#inProgress) {
264
+ this.#rerun = true;
265
+ return;
266
+ }
267
+ this.#inProgress = true;
268
+
269
+ const {
270
+ instrumentSourceFolder,
271
+ instrumentAssetFolder,
272
+ blobExportPath,
273
+ blobExportAvailable,
274
+ generatedInstrumentTypesPath,
275
+ ugepFolder,
276
+ flags,
277
+ } = this.#config;
278
+
279
+ try {
280
+ if (!blobExportAvailable) {
281
+ this.#warnBlobExportUnavailable();
282
+ return;
283
+ }
284
+
285
+ this.suppressWatcher = true;
286
+ this.#assetDeployer.suppressWatcher = true;
287
+
288
+ const instrumentFiles = (await readdir(instrumentSourceFolder))
289
+ .filter((filename) => filename.toLowerCase().endsWith('.inst'))
290
+ .sort();
291
+
292
+ if (instrumentFiles.length === 0) return;
293
+
294
+ const blobExportCapabilities = await getBlobExportCapabilities(blobExportPath, ugepFolder);
295
+ if (!blobExportCapabilities.supportsParameterInfo) {
296
+ throw new Error(
297
+ `blob-export ${blobExportCapabilities.version} does not support --parameter-info. Install a newer version of the Gorilla Engine SDK that includes this option.`,
298
+ );
299
+ }
300
+
301
+ const instrumentDefinitions = [];
302
+ const usedTypeNames = new Map();
303
+
304
+ await mkdir(instrumentAssetFolder, { recursive: true });
305
+
306
+ const action =
307
+ trigger === 'watch'
308
+ ? 'Instrument changes detected - exporting blobs and regenerating typings'
309
+ : 'Syncing instrument blobs and regenerating typings';
310
+ console.log(`[${ts()}] ${action}`);
311
+
312
+ for (const instrumentFile of instrumentFiles) {
313
+ const instrumentPath = path.join(instrumentSourceFolder, instrumentFile);
314
+ const blobOutputPath = path.join(
315
+ instrumentAssetFolder,
316
+ `${path.parse(instrumentFile).name}.blob`,
317
+ );
318
+ const baseTypeName = toTypeIdentifier(path.parse(instrumentFile).name);
319
+ const nextCount = (usedTypeNames.get(baseTypeName) ?? 0) + 1;
320
+ usedTypeNames.set(baseTypeName, nextCount);
321
+
322
+ // Running blob-export also updates the embedded script in the inst file so this needs to be run first
323
+ await runBlobExport(blobExportPath, instrumentPath, blobOutputPath, ugepFolder);
324
+
325
+ instrumentDefinitions.push({
326
+ fileName: path.parse(instrumentFile).name,
327
+ typeName: nextCount === 1 ? baseTypeName : `${baseTypeName}${nextCount}`,
328
+ parameters: await getInstrumentParameterInfo(blobExportPath, instrumentPath, ugepFolder),
329
+ });
330
+ }
331
+
332
+ await writeInstrumentTypesFile(generatedInstrumentTypesPath, instrumentDefinitions);
333
+
334
+ if (flags.deploy) await deployAssetFilesToOutput(this.#config);
335
+
336
+ console.log(`[${ts()}] Instrument sync finished\n`);
337
+ if (flags.standalone) await this.#standaloneManager.restart();
338
+ } catch (error) {
339
+ if (error?.code === 'ENOENT') {
340
+ this.#warnBlobExportUnavailable();
341
+ return;
342
+ }
343
+ console.error(`[${ts()}] Instrument sync failed: ${error.message}`);
344
+ if (trigger === 'one-shot') throw error;
345
+ } finally {
346
+ this.suppressWatcher = false;
347
+ this.#assetDeployer.suppressWatcher = false;
348
+ this.#inProgress = false;
349
+ if (this.#rerun) {
350
+ this.#rerun = false;
351
+ void this.#run('watch');
352
+ }
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,121 @@
1
+ import { open, readdir } from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { userInfo } from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { assertNoSymlinksWithinBase, askQuestion } from './utils.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const windowsPermissionScript = fileURLToPath(
11
+ new URL('./repair-windows-permissions.ps1', import.meta.url),
12
+ );
13
+
14
+ export function getPermissionRepairInvocation({ isMac, username, outputFolder }) {
15
+ if (isMac) {
16
+ return {
17
+ file: 'sudo',
18
+ args: ['chown', '-R', '-P', username, outputFolder],
19
+ };
20
+ }
21
+
22
+ const encodeValue = (value) => Buffer.from(value, 'utf8').toString('base64');
23
+ const decodeValue = (value) =>
24
+ `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodeValue(value)}'))`;
25
+ const permissionScript =
26
+ `$env:GORILLA_PERMISSION_FOLDER = ${decodeValue(outputFolder)}; ` +
27
+ `$env:GORILLA_PERMISSION_USER = ${decodeValue(username)}; ` +
28
+ `$scriptPath = ${decodeValue(windowsPermissionScript)}; ` +
29
+ '& $scriptPath; exit $LASTEXITCODE';
30
+ const encodedPermissionScript = Buffer.from(permissionScript, 'utf16le').toString('base64');
31
+ const elevationScript =
32
+ `$process = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedPermissionScript}') -Verb RunAs -Wait -PassThru; ` +
33
+ "$exitCode = $process.ExitCode; if ($exitCode -ne 0) { [Console]::Error.WriteLine('Elevated permission repair exited with code ' + $exitCode) }; exit $exitCode";
34
+ return {
35
+ file: 'powershell',
36
+ args: ['-NoProfile', '-NonInteractive', '-Command', elevationScript],
37
+ };
38
+ }
39
+
40
+ async function isWriteable(filePath) {
41
+ const handle = await open(filePath, 'r+');
42
+ await handle.close();
43
+ }
44
+
45
+ export async function assertTreeWriteable(folderPath, checkFile = isWriteable) {
46
+ const entries = await readdir(folderPath, { withFileTypes: true });
47
+ for (const entry of entries) {
48
+ const entryPath = path.join(folderPath, entry.name);
49
+ if (entry.isSymbolicLink()) {
50
+ throw new Error(`Refusing to access symbolic link "${entryPath}"`);
51
+ }
52
+ if (entry.isDirectory()) {
53
+ await assertTreeWriteable(entryPath, checkFile);
54
+ } else {
55
+ await checkFile(entryPath);
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Checks that the plugin's deployed files are writeable by the current user.
62
+ * If not, offers to take ownership via sudo (macOS) or grant access via icacls (Windows).
63
+ * Exits the process if permissions cannot be secured.
64
+ * @param {object} config - Resolved plugin config
65
+ */
66
+ export async function ensureWriteable(config) {
67
+ const { outputRoot, outputFolder, pluginName, ugepContent, isMac } = config;
68
+ const scriptPath = path.join(outputFolder, `${pluginName}.js`);
69
+ await assertNoSymlinksWithinBase(outputRoot ?? outputFolder, scriptPath);
70
+ const yamlPath = path.join(outputFolder, `${pluginName}.yaml`);
71
+ if (ugepContent.ymlFiles.length > 0) {
72
+ await assertNoSymlinksWithinBase(outputRoot ?? outputFolder, yamlPath);
73
+ }
74
+
75
+ try {
76
+ if (isMac) {
77
+ await isWriteable(scriptPath);
78
+ if (ugepContent.ymlFiles.length > 0) {
79
+ await isWriteable(yamlPath);
80
+ }
81
+ } else {
82
+ await assertTreeWriteable(outputFolder);
83
+ }
84
+ } catch {
85
+ console.error(`${outputFolder} is *NOT* writeable!`);
86
+ const promptMsg = isMac
87
+ ? `Do you want to chown "${outputFolder}" to the current user? This requires sudo. [y/N] `
88
+ : `Do you want to make "${outputFolder}" writable for the current user? [y/N] `;
89
+
90
+ const answer = await askQuestion(promptMsg);
91
+ if (answer.trim().toLowerCase() === 'y') {
92
+ const username = userInfo().username;
93
+ try {
94
+ if (!isMac) {
95
+ console.log(
96
+ 'UAC dialog for "icacls.exe" will come up. Please approve it to change the folder\'s permissions.',
97
+ );
98
+ }
99
+ const invocation = getPermissionRepairInvocation({
100
+ isMac,
101
+ username,
102
+ outputFolder,
103
+ });
104
+ await execFileAsync(invocation.file, invocation.args, invocation.options);
105
+ if (isMac) {
106
+ console.log(`Changed ownership of ${outputFolder} to ${username}`);
107
+ } else {
108
+ await assertTreeWriteable(outputFolder);
109
+ console.log(`Made ${outputFolder} writeable for ${username}`);
110
+ }
111
+ } catch (err) {
112
+ const failure = isMac ? 'Failed to change ownership' : 'Failed to make output files writeable';
113
+ console.error(`${failure}: ${err.message}`);
114
+ process.exit(2);
115
+ }
116
+ } else {
117
+ console.error(`Please make sure that it is writeable for this script to work`);
118
+ process.exit(2);
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,8 @@
1
+ $ErrorActionPreference = 'Stop'
2
+
3
+ $folder = $env:GORILLA_PERMISSION_FOLDER
4
+ $user = $env:GORILLA_PERMISSION_USER
5
+ $grant = $user + ':(OI)(CI)F'
6
+
7
+ & icacls.exe $folder /grant $grant /T /L
8
+ exit $LASTEXITCODE
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { ts } from './utils.js';
4
+
5
+ export class StandaloneManager {
6
+ #config;
7
+ #process = null;
8
+
9
+ constructor(config) {
10
+ this.#config = config;
11
+ }
12
+
13
+ getAppPath() {
14
+ const { pluginName, manufacturerName, isMac } = this.#config;
15
+ if (isMac) {
16
+ return `/Applications/${manufacturerName}/${pluginName}.app`;
17
+ } else {
18
+ return `${process.env['ProgramFiles']}\\${manufacturerName}\\${pluginName}\\${pluginName}.exe`;
19
+ }
20
+ }
21
+
22
+ exists() {
23
+ return existsSync(this.getAppPath());
24
+ }
25
+
26
+ kill() {
27
+ this.#process?.kill();
28
+ this.#process = null;
29
+ }
30
+
31
+ async restart() {
32
+ if (!this.#config.ugepContent.pluginConfig.buildStandalone) return;
33
+ const { pluginName, isMac } = this.#config;
34
+ const appPath = this.getAppPath();
35
+ this.kill();
36
+ const binaryPath = isMac ? `${appPath}/Contents/MacOS/${pluginName}` : appPath;
37
+ this.#process = spawn(binaryPath, [], {
38
+ detached: true,
39
+ stdio: ['ignore', 'inherit', 'inherit'],
40
+ });
41
+ this.#process.unref();
42
+ console.log(`[${ts()}] Started ${pluginName} standalone`);
43
+ }
44
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+
5
+ export function ts() {
6
+ return new Date().toLocaleTimeString();
7
+ }
8
+
9
+ export function assertSafePathSegment(value, label) {
10
+ if (
11
+ typeof value !== 'string' ||
12
+ value.length === 0 ||
13
+ value === '.' ||
14
+ value === '..' ||
15
+ /[<>:"/\\|?*\u0000-\u001f]/.test(value) ||
16
+ /[. ]$/.test(value)
17
+ ) {
18
+ throw new Error(`${label} must be a safe file name: ${JSON.stringify(value)}`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ /**
24
+ * Resolves `segments` against `baseDir` and throws if the result would land
25
+ * outside of `baseDir` (e.g. via ".." components or an absolute path segment).
26
+ * Use this whenever a destination path is built from untrusted input (ugep
27
+ * file contents) to prevent writes/deletes outside the intended folder.
28
+ */
29
+ export function resolveWithinBase(baseDir, ...segments) {
30
+ const resolvedBase = path.resolve(baseDir);
31
+ const resolvedTarget = path.resolve(resolvedBase, ...segments);
32
+ if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
33
+ throw new Error(
34
+ `Refusing to access path outside of "${resolvedBase}" (resolved to "${resolvedTarget}")`,
35
+ );
36
+ }
37
+ return resolvedTarget;
38
+ }
39
+
40
+ export async function resolveExistingWithinBase(baseDir, ...segments) {
41
+ const resolvedTarget = resolveWithinBase(baseDir, ...segments);
42
+ const [realBase, realTarget] = await Promise.all([realpath(baseDir), realpath(resolvedTarget)]);
43
+ return resolveWithinBase(realBase, path.relative(realBase, realTarget));
44
+ }
45
+
46
+ export async function assertNoSymlinksWithinBase(baseDir, targetPath) {
47
+ const resolvedBase = path.resolve(baseDir);
48
+ const resolvedTarget = resolveWithinBase(resolvedBase, path.relative(resolvedBase, targetPath));
49
+ const relativeParts = path.relative(resolvedBase, resolvedTarget).split(path.sep).filter(Boolean);
50
+ const pathsToCheck = [
51
+ resolvedBase,
52
+ ...relativeParts.map((_, index) => path.join(resolvedBase, ...relativeParts.slice(0, index + 1))),
53
+ ];
54
+
55
+ for (const pathToCheck of pathsToCheck) {
56
+ try {
57
+ if ((await lstat(pathToCheck)).isSymbolicLink()) {
58
+ throw new Error(`Refusing to access symbolic link "${pathToCheck}"`);
59
+ }
60
+ } catch (error) {
61
+ if (error?.code === 'ENOENT') break;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ return resolvedTarget;
67
+ }
68
+
69
+ export async function askQuestion(question) {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ return new Promise((resolve) =>
72
+ rl.question(question, (answer) => {
73
+ rl.close();
74
+ resolve(answer);
75
+ }),
76
+ );
77
+ }
78
+
79
+ export function shouldIgnoreWatchedChange(filename) {
80
+ if (!filename) {
81
+ return false;
82
+ }
83
+ return (
84
+ filename.startsWith('.') ||
85
+ filename.endsWith('~') ||
86
+ filename.endsWith('.swp') ||
87
+ filename.endsWith('.tmp')
88
+ );
89
+ }
@@ -0,0 +1,59 @@
1
+ import { watch } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
3
+ import { shouldIgnoreWatchedChange } from './utils.js';
4
+
5
+ export function shouldIgnoreInstrumentChange(filename, suppressWatcher = false) {
6
+ return suppressWatcher || shouldIgnoreWatchedChange(filename?.toString());
7
+ }
8
+
9
+ /**
10
+ * Creates file-system watchers for asset folders and the instrument source folder.
11
+ * Returns an object with a `close()` method to tear down all watchers.
12
+ * @param {object} config - Resolved plugin config
13
+ * @param {import('./assets.js').AssetDeployer} assetDeployer
14
+ * @param {import('./instruments.js').InstrumentExporter} instrumentExporter
15
+ */
16
+ export function createWatchers(config, assetDeployer, instrumentExporter) {
17
+ const { assetSourceFolders, presetSourceFolders, instrumentSourceFolder, flags } = config;
18
+ const watchers = [];
19
+
20
+ if (flags.deploy) {
21
+ const contentSourceFolders = [
22
+ ...new Set([...(assetSourceFolders ?? []), ...(presetSourceFolders ?? [])]),
23
+ ];
24
+ for (const folder of contentSourceFolders) {
25
+ if (!existsSync(folder)) continue;
26
+
27
+ const handler = (_eventType, filename) => {
28
+ if (assetDeployer.suppressWatcher || shouldIgnoreWatchedChange(filename?.toString())) {
29
+ return;
30
+ }
31
+ assetDeployer.schedule();
32
+ };
33
+
34
+ try {
35
+ watchers.push(watch(folder, { persistent: true, recursive: true }, handler));
36
+ } catch {
37
+ // Fallback for platforms that don't support recursive watching
38
+ watchers.push(watch(folder, { persistent: true }, handler));
39
+ }
40
+ }
41
+ }
42
+
43
+ if (existsSync(instrumentSourceFolder)) {
44
+ watchers.push(
45
+ watch(instrumentSourceFolder, { persistent: true }, (_eventType, filename) => {
46
+ if (shouldIgnoreInstrumentChange(filename, instrumentExporter.suppressWatcher)) return;
47
+ instrumentExporter.schedule();
48
+ }),
49
+ );
50
+ }
51
+
52
+ return {
53
+ close() {
54
+ for (const watcher of watchers) {
55
+ watcher.close();
56
+ }
57
+ },
58
+ };
59
+ }