@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
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/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
package/dist/PatternStore.js
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
|
+
import { randomBytes } from 'crypto';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import { Logger } from './utils/Logger.js';
|
|
5
|
+
import { ValidationError, BusinessError } from './utils/CategorisedError.js';
|
|
6
|
+
/**
|
|
7
|
+
* Narrows a parsed JSON value to a pattern, or null when it is not one.
|
|
8
|
+
*
|
|
9
|
+
* `JSON.parse(data) as PatternData` is a cast: it asserts a shape rather
|
|
10
|
+
* than checking it, so a file that parses but lacks `tags` or
|
|
11
|
+
* `timestamp` threw further downstream and took the whole listing with
|
|
12
|
+
* it (#426).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Longest pattern name that can actually be saved.
|
|
16
|
+
*
|
|
17
|
+
* 255 (the filesystem's limit on one path component) minus `.json` and
|
|
18
|
+
* minus the `.<16 hex>.tmp` suffix the atomic write needs beside it.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_PATTERN_NAME_LENGTH = 255 - '.json'.length - ('.'.length + 16 + '.tmp'.length);
|
|
21
|
+
function asPatternData(value) {
|
|
22
|
+
if (typeof value !== 'object' || value === null)
|
|
23
|
+
return null;
|
|
24
|
+
const candidate = value;
|
|
25
|
+
if (typeof candidate.name !== 'string')
|
|
26
|
+
return null;
|
|
27
|
+
if (typeof candidate.content !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
if (typeof candidate.timestamp !== 'string')
|
|
30
|
+
return null;
|
|
31
|
+
if (!Array.isArray(candidate.tags))
|
|
32
|
+
return null;
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
4
35
|
export class PatternStore {
|
|
5
36
|
basePath;
|
|
6
37
|
patternCache = new Map();
|
|
@@ -55,35 +86,167 @@ export class PatternStore {
|
|
|
55
86
|
console.error('Failed to create patterns directory:', error);
|
|
56
87
|
}
|
|
57
88
|
}
|
|
58
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Writes a pattern, refusing to silently destroy a different one.
|
|
91
|
+
*
|
|
92
|
+
* `sanitizeFilename` is many-to-one and always has been: `Track 1`,
|
|
93
|
+
* `TRACK 1` and `Track#1` all land on `track_1.json`, `drums/kick` and
|
|
94
|
+
* `synth/kick` both land on `kick.json`, and any two all-non-ASCII
|
|
95
|
+
* names collapse together. That mapping stays — names with spaces and
|
|
96
|
+
* capitals are natural for music, and rejecting them would break every
|
|
97
|
+
* pattern already saved.
|
|
98
|
+
*
|
|
99
|
+
* What changes is that a collision is no longer silent. Measured
|
|
100
|
+
* before this guard:
|
|
101
|
+
*
|
|
102
|
+
* save("My-Jam", "VERSION-ONE"); save("my-jam", "VERSION-TWO")
|
|
103
|
+
* -> one file, VERSION-ONE gone, both saves reported success
|
|
104
|
+
*
|
|
105
|
+
* Re-saving under the SAME original name is an ordinary update and is
|
|
106
|
+
* not a collision — refusing that would break every edit of an
|
|
107
|
+
* existing pattern.
|
|
108
|
+
*
|
|
109
|
+
* @param name - Pattern name as the caller spells it
|
|
110
|
+
* @param content - Pattern source
|
|
111
|
+
* @param tags - Tags to attach
|
|
112
|
+
* @param options.overwrite - Replace a different pattern on the same
|
|
113
|
+
* filename. Deliberately explicit: the loss is not recoverable.
|
|
114
|
+
* @throws {ValidationError} When the filename is taken by a pattern
|
|
115
|
+
* saved under a different name and `overwrite` is not set
|
|
116
|
+
*/
|
|
117
|
+
async save(name, content, tags = [], options = {}) {
|
|
59
118
|
await this.ensureDirectory();
|
|
60
119
|
const filename = this.sanitizeFilename(name) + '.json';
|
|
61
120
|
const filepath = path.join(this.basePath, filename);
|
|
121
|
+
// One file, one cache entry. See the note on setCacheWithLRU below.
|
|
122
|
+
const cacheKey = this.sanitizeFilename(name);
|
|
123
|
+
if (options.overwrite !== true) {
|
|
124
|
+
const existing = await this.readIfPresent(filepath);
|
|
125
|
+
if (existing !== null && existing.name !== name) {
|
|
126
|
+
throw new ValidationError(`Cannot save "${name}": the name "${existing.name}" already uses the file ` +
|
|
127
|
+
`${filename}. Names are lowercased and stripped of punctuation, so these ` +
|
|
128
|
+
`collide. Choose a name that differs by more than case or punctuation, or ` +
|
|
129
|
+
`pass overwrite: true to replace "${existing.name}" — which cannot be undone.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
62
132
|
const data = {
|
|
63
133
|
name,
|
|
64
134
|
content,
|
|
65
|
-
|
|
135
|
+
// Copied, not aliased. The caller's array was stored by reference
|
|
136
|
+
// and cached, so mutating it after the save changed what `load`
|
|
137
|
+
// returned while the file on disk kept the original — the cache
|
|
138
|
+
// and the disk disagreeing, which is the thing #428 exists to
|
|
139
|
+
// prevent:
|
|
140
|
+
//
|
|
141
|
+
// const tags = ['x'];
|
|
142
|
+
// await save('p', 'code', tags);
|
|
143
|
+
// tags[0] = 'y';
|
|
144
|
+
// (await load('p')).tags // ['y'], file holds ['x'] (#473)
|
|
145
|
+
tags: [...tags],
|
|
66
146
|
timestamp: new Date().toISOString(),
|
|
67
147
|
};
|
|
68
|
-
//
|
|
69
|
-
|
|
148
|
+
// Write first, cache second.
|
|
149
|
+
//
|
|
150
|
+
// The cache used to be written before the file, so a rejected write
|
|
151
|
+
// left a pattern cached that was never persisted and `load` served
|
|
152
|
+
// it as though it had been. A 255-character name reaches here — it
|
|
153
|
+
// passes InputValidator's 255 limit and `sanitizeFilename` measures
|
|
154
|
+
// the stem without `.json` — and then fails ENAMETOOLONG on the
|
|
155
|
+
// 260-byte component (#428).
|
|
156
|
+
// Write to a temp file, then rename.
|
|
157
|
+
//
|
|
158
|
+
// `rename` is atomic within a filesystem, so a reader sees either
|
|
159
|
+
// the old pattern or the new one and never a half-written file. The
|
|
160
|
+
// direct write left truncated JSON behind on a crash or a full disk
|
|
161
|
+
// — which is exactly the input that used to take the entire listing
|
|
162
|
+
// down (#426). That fix made the listing survive such a file; this
|
|
163
|
+
// one stops producing them.
|
|
164
|
+
//
|
|
165
|
+
// The temp name is random, and the write is exclusive.
|
|
166
|
+
//
|
|
167
|
+
// A predictable name (pid plus a counter) is guessable, so anyone
|
|
168
|
+
// able to write into the pattern directory could pre-create it as a
|
|
169
|
+
// symlink and redirect the write elsewhere — CodeQL flags exactly
|
|
170
|
+
// this as js/insecure-temporary-file, and it was right. `randomBytes`
|
|
171
|
+
// removes the guess and the `wx` flag refuses to open a path that
|
|
172
|
+
// already exists, symlink included, rather than following it.
|
|
173
|
+
//
|
|
174
|
+
// It sits in the same directory so the rename stays within one
|
|
175
|
+
// filesystem, which is what makes the rename atomic.
|
|
176
|
+
const tempPath = `${filepath}.${randomBytes(8).toString('hex')}.tmp`;
|
|
177
|
+
try {
|
|
178
|
+
// `mode: 0o600` is the part CodeQL's js/insecure-temporary-file is
|
|
179
|
+
// actually about — "accessible by other users". A temp file
|
|
180
|
+
// holding the user's pattern has no business being world-readable
|
|
181
|
+
// even for the instant before the rename.
|
|
182
|
+
await fs.writeFile(tempPath, JSON.stringify(data, null, 2), { flag: 'wx', mode: 0o600 });
|
|
183
|
+
await fs.rename(tempPath, filepath);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// Leaving a stray .tmp would make it the next listing's problem.
|
|
187
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
// Keyed by the FILE's name, not the caller's.
|
|
191
|
+
//
|
|
192
|
+
// The cache was keyed by the raw name while the file is keyed by the
|
|
193
|
+
// sanitized one, so two names that sanitize alike had one file and
|
|
194
|
+
// two cache entries. Measured:
|
|
195
|
+
//
|
|
196
|
+
// save("My-Jam", "VERSION-ONE"); save("my-jam", "VERSION-TWO")
|
|
197
|
+
// files on disk: ["my-jam.json"] disk content: VERSION-TWO
|
|
198
|
+
// load("My-Jam") -> VERSION-ONE load("my-jam") -> VERSION-TWO
|
|
199
|
+
//
|
|
200
|
+
// Two answers for one file, and the stale one never expires — this
|
|
201
|
+
// cache has no TTL.
|
|
202
|
+
this.setCacheWithLRU(cacheKey, data);
|
|
70
203
|
this.listCache = null; // Invalidate list cache
|
|
71
|
-
|
|
72
|
-
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The pattern in a file, or null when it is absent or unreadable.
|
|
207
|
+
*
|
|
208
|
+
* An unreadable file is treated as absent on purpose: refusing a save
|
|
209
|
+
* because something in the directory cannot be parsed would make one
|
|
210
|
+
* corrupt file block writes, which is the mistake #426 fixed for
|
|
211
|
+
* reads.
|
|
212
|
+
*/
|
|
213
|
+
async readIfPresent(filepath) {
|
|
214
|
+
try {
|
|
215
|
+
return asPatternData(JSON.parse(await fs.readFile(filepath, 'utf-8')));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
73
220
|
}
|
|
74
221
|
async load(name) {
|
|
222
|
+
// Keyed by the file, not the caller's spelling of it (#428).
|
|
223
|
+
const cacheKey = this.sanitizeFilename(name);
|
|
75
224
|
// Check cache first with LRU update
|
|
76
|
-
const cached = this.getCacheWithLRU(
|
|
225
|
+
const cached = this.getCacheWithLRU(cacheKey);
|
|
77
226
|
if (cached) {
|
|
78
227
|
return cached;
|
|
79
228
|
}
|
|
80
|
-
const filename =
|
|
229
|
+
const filename = cacheKey + '.json';
|
|
81
230
|
const filepath = path.join(this.basePath, filename);
|
|
82
231
|
try {
|
|
83
232
|
const data = await fs.readFile(filepath, 'utf-8');
|
|
84
|
-
|
|
233
|
+
// Validated, like every other reader.
|
|
234
|
+
//
|
|
235
|
+
// `load` parsed the JSON and returned it, so a file holding
|
|
236
|
+
// `{"nonsense": 1}` or `[1,2,3]` came back as a PatternData that
|
|
237
|
+
// is nothing of the kind — and callers read `.code` off it and
|
|
238
|
+
// wrote `undefined` into the editor. `listDetailed` and
|
|
239
|
+
// `readIfPresent` both run `asPatternData`; this one never got
|
|
240
|
+
// the #426 hardening, so the two disagreed about the same file:
|
|
241
|
+
// the listing skipped it as malformed while `load` handed it
|
|
242
|
+
// straight back (#471).
|
|
243
|
+
const pattern = asPatternData(JSON.parse(data));
|
|
244
|
+
if (pattern === null) {
|
|
245
|
+
this.logger.warn(`Pattern file is not a pattern: ${filename}`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
85
248
|
// Update cache with LRU eviction
|
|
86
|
-
this.setCacheWithLRU(
|
|
249
|
+
this.setCacheWithLRU(cacheKey, pattern);
|
|
87
250
|
return pattern;
|
|
88
251
|
}
|
|
89
252
|
catch (error) {
|
|
@@ -91,23 +254,82 @@ export class PatternStore {
|
|
|
91
254
|
return null;
|
|
92
255
|
}
|
|
93
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Patterns, plus how many files could not be read.
|
|
259
|
+
*
|
|
260
|
+
* A partial listing presented as complete is the same lie as reporting
|
|
261
|
+
* zero, quieter — the store logs what it skipped and no MCP client
|
|
262
|
+
* reads the log (#426; #335 is the precedent for making a warning
|
|
263
|
+
* reach the caller).
|
|
264
|
+
*/
|
|
265
|
+
async listDetailed(tag) {
|
|
266
|
+
return this.readAll(tag);
|
|
267
|
+
}
|
|
94
268
|
async list(tag) {
|
|
269
|
+
return (await this.readAll(tag)).patterns;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The one read. Returns the count WITH the data.
|
|
273
|
+
*
|
|
274
|
+
* This used to leave the count on the instance as `skippedInLastRead`
|
|
275
|
+
* and have `listDetailed` pick it up after awaiting `list`. The
|
|
276
|
+
* docstring above it disclaimed exactly that design — "a
|
|
277
|
+
* `getLastSkipped()` would belong to whichever listing finished most
|
|
278
|
+
* recently, and two concurrent calls would cross their answers" —
|
|
279
|
+
* while the code did it anyway: the field is written inside `list`
|
|
280
|
+
* and read after an await, so two concurrent listings genuinely
|
|
281
|
+
* could, and the second one's count would be reported for the first
|
|
282
|
+
* one's patterns (#473).
|
|
283
|
+
*
|
|
284
|
+
* Returning both from one call is what makes the docstring true.
|
|
285
|
+
*/
|
|
286
|
+
async readAll(tag) {
|
|
95
287
|
// Use cached list if available and not expired
|
|
96
288
|
const now = Date.now();
|
|
97
289
|
if (!tag && this.listCache && (now - this.listCache.timestamp) < this.LIST_CACHE_TTL) {
|
|
98
|
-
|
|
290
|
+
// The cached listing carries its own skipped count, so a cache hit
|
|
291
|
+
// does not inherit the previous uncached read's.
|
|
292
|
+
return { patterns: this.listCache.patterns, skipped: this.listCache.skipped };
|
|
99
293
|
}
|
|
100
294
|
try {
|
|
295
|
+
await this.ensureDirectory();
|
|
101
296
|
const files = await fs.readdir(this.basePath);
|
|
102
|
-
// Parallel file reading for better performance
|
|
103
|
-
|
|
297
|
+
// Parallel file reading for better performance.
|
|
298
|
+
//
|
|
299
|
+
// `allSettled`, not `all`. Fail-fast meant one truncated or
|
|
300
|
+
// unparseable `.json` rejected the whole batch, the catch below
|
|
301
|
+
// returned `[]`, and `doList` reported that as the success "No
|
|
302
|
+
// patterns found" — measured, five saved patterns plus one
|
|
303
|
+
// truncated file listed as zero (#426). A hundred patterns and one
|
|
304
|
+
// bad file told the user they had none.
|
|
305
|
+
const results = await Promise.allSettled(files
|
|
104
306
|
.filter(file => file.endsWith('.json'))
|
|
105
307
|
.map(async (file) => {
|
|
106
308
|
const filepath = path.join(this.basePath, file);
|
|
107
309
|
const data = await fs.readFile(filepath, 'utf-8');
|
|
108
|
-
return JSON.parse(data);
|
|
109
|
-
});
|
|
110
|
-
const allPatterns =
|
|
310
|
+
return { file, parsed: JSON.parse(data) };
|
|
311
|
+
}));
|
|
312
|
+
const allPatterns = [];
|
|
313
|
+
const skipped = [];
|
|
314
|
+
for (const result of results) {
|
|
315
|
+
if (result.status !== 'fulfilled') {
|
|
316
|
+
skipped.push('unreadable');
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// A cast is not a check. A file missing `timestamp` threw in the
|
|
320
|
+
// sort below and one missing `tags` threw in the tag filter —
|
|
321
|
+
// each taking the entire listing down the same way a truncated
|
|
322
|
+
// file did.
|
|
323
|
+
const pattern = asPatternData(result.value.parsed);
|
|
324
|
+
if (pattern === null) {
|
|
325
|
+
skipped.push(result.value.file);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
allPatterns.push(pattern);
|
|
329
|
+
}
|
|
330
|
+
if (skipped.length > 0) {
|
|
331
|
+
this.logger.warn(`Skipped ${String(skipped.length)} unreadable or malformed pattern file(s)`, { skipped });
|
|
332
|
+
}
|
|
111
333
|
// Filter and sort
|
|
112
334
|
const filteredPatterns = tag
|
|
113
335
|
? allPatterns.filter(p => p.tags.includes(tag))
|
|
@@ -115,13 +337,29 @@ export class PatternStore {
|
|
|
115
337
|
const sorted = filteredPatterns.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
116
338
|
// Update cache only for non-filtered lists
|
|
117
339
|
if (!tag) {
|
|
118
|
-
this.listCache = { patterns: sorted, timestamp: now };
|
|
340
|
+
this.listCache = { patterns: sorted, timestamp: now, skipped: skipped.length };
|
|
119
341
|
}
|
|
120
|
-
return sorted;
|
|
342
|
+
return { patterns: sorted, skipped: skipped.length };
|
|
121
343
|
}
|
|
122
344
|
catch (error) {
|
|
345
|
+
// A directory that cannot be READ is not a directory with no
|
|
346
|
+
// patterns in it.
|
|
347
|
+
//
|
|
348
|
+
// Individual bad files are already handled above by `allSettled`
|
|
349
|
+
// and counted in `skipped`, so this catch only fires when the
|
|
350
|
+
// whole directory is unavailable — the readdir or the
|
|
351
|
+
// ensureDirectory failed. Returning `[]` reported that as a clean
|
|
352
|
+
// empty listing:
|
|
353
|
+
//
|
|
354
|
+
// chmod 000 <store>; listDetailed()
|
|
355
|
+
// -> {"patterns":[],"skipped":0}
|
|
356
|
+
//
|
|
357
|
+
// which tells the caller they have no saved patterns when the
|
|
358
|
+
// truth is that nothing could be looked at. That is #426's lie
|
|
359
|
+
// exactly, reached down a different path (#473).
|
|
123
360
|
this.logger.warn(`Failed to list patterns${tag ? ` with tag: ${tag}` : ''}`, error);
|
|
124
|
-
|
|
361
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
362
|
+
throw new BusinessError(`Cannot read the pattern directory ${this.basePath}: ${reason}`);
|
|
125
363
|
}
|
|
126
364
|
}
|
|
127
365
|
// Clear cache method
|
|
@@ -154,14 +392,29 @@ export class PatternStore {
|
|
|
154
392
|
const cleaned = baseName
|
|
155
393
|
.replace(/[^a-z0-9_-]/gi, '_')
|
|
156
394
|
.toLowerCase();
|
|
157
|
-
// Validate length
|
|
158
|
-
|
|
159
|
-
|
|
395
|
+
// Validate length against the ceiling that actually exists.
|
|
396
|
+
//
|
|
397
|
+
// The stem is not the whole filename. `.json` costs 5 bytes, and
|
|
398
|
+
// the atomic write needs `<file>.<16 hex>.tmp` to fit in the same
|
|
399
|
+
// directory entry, which costs 21 more. A 255-byte component is the
|
|
400
|
+
// limit on ext4 and on every filesystem this runs on, so the real
|
|
401
|
+
// ceiling for the stem is 229.
|
|
402
|
+
//
|
|
403
|
+
// This check said 255 and let anything under it through to fail as
|
|
404
|
+
// a raw ENAMETOOLONG from `fs` — an uncategorised Error, so the
|
|
405
|
+
// envelope called it `internal` and not retryable, when it is
|
|
406
|
+
// exactly the caller's input being too long (#471, and the same
|
|
407
|
+
// class of miscategorisation as #382). Saving a 230-character name
|
|
408
|
+
// has never worked; it now says so, in a message with the number
|
|
409
|
+
// in it.
|
|
410
|
+
if (cleaned.length === 0 || cleaned.length > MAX_PATTERN_NAME_LENGTH) {
|
|
411
|
+
throw new ValidationError(`Pattern name must be between 1 and ${MAX_PATTERN_NAME_LENGTH} characters `
|
|
412
|
+
+ `after sanitizing; got ${cleaned.length}.`);
|
|
160
413
|
}
|
|
161
414
|
// Prevent reserved filenames on Windows
|
|
162
415
|
const reserved = ['con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9'];
|
|
163
416
|
if (reserved.includes(cleaned.toLowerCase())) {
|
|
164
|
-
throw new
|
|
417
|
+
throw new ValidationError('Pattern name uses a reserved filename');
|
|
165
418
|
}
|
|
166
419
|
return cleaned;
|
|
167
420
|
}
|