@xano/cli 0.0.43 → 0.0.45

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 CHANGED
@@ -112,6 +112,16 @@ xano workspace push ./my-workspace --no-records # Schema only
112
112
  xano workspace push ./my-workspace --no-env # Skip env vars
113
113
  xano workspace push ./my-workspace --truncate # Truncate tables before import
114
114
  xano workspace push ./my-workspace --no-sync-guids # Skip writing GUIDs back to local files
115
+
116
+ # Pull from a git repository to local files
117
+ xano workspace git pull ./output -r https://github.com/owner/repo
118
+ xano workspace git pull ./output -r https://github.com/owner/repo -b main
119
+ xano workspace git pull ./output -r https://github.com/owner/repo/tree/main/path/to/dir
120
+ xano workspace git pull ./output -r https://github.com/owner/repo/blob/main/file.xs
121
+ xano workspace git pull ./output -r git@github.com:owner/repo.git
122
+ xano workspace git pull ./output -r https://gitlab.com/owner/repo/-/tree/master/path
123
+ xano workspace git pull ./output -r https://github.com/owner/private-repo -t ghp_xxx
124
+ xano workspace git pull ./output -r https://github.com/owner/repo --path subdir
115
125
  ```
116
126
 
117
127
  ### Branches
@@ -0,0 +1,6 @@
1
+ import BaseCommand from '../../base-command.js';
2
+ export default class Update extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,26 @@
1
+ import { execSync } from 'node:child_process';
2
+ import BaseCommand from '../../base-command.js';
3
+ export default class Update extends BaseCommand {
4
+ static description = 'Update the Xano CLI to the latest version';
5
+ static examples = [
6
+ `$ xano update`,
7
+ ];
8
+ async run() {
9
+ const currentVersion = this.config.version;
10
+ this.log(`Current version: ${currentVersion}`);
11
+ this.log('Checking for updates...');
12
+ try {
13
+ const latest = execSync('npm view @xano/cli version', { encoding: 'utf8' }).trim();
14
+ if (latest === currentVersion) {
15
+ this.log(`Already up to date (${currentVersion})`);
16
+ return;
17
+ }
18
+ this.log(`Updating @xano/cli ${currentVersion} → ${latest}...`);
19
+ execSync('npm install -g @xano/cli@latest', { stdio: 'inherit' });
20
+ this.log(`Updated to ${latest}`);
21
+ }
22
+ catch (error) {
23
+ this.error(`Failed to update: ${error.message}`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,58 @@
1
+ import BaseCommand from '../../../../base-command.js';
2
+ export default class GitPull extends BaseCommand {
3
+ static args: {
4
+ directory: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ repo: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ };
16
+ run(): Promise<void>;
17
+ /**
18
+ * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
19
+ */
20
+ private collectFiles;
21
+ /**
22
+ * Clone a git repository using shallow clone.
23
+ */
24
+ private cloneRepo;
25
+ /**
26
+ * Fetch a GitHub repository via the tarball API for fast download without requiring git.
27
+ */
28
+ private fetchGitHubTarball;
29
+ /**
30
+ * Parse a repository URL to extract host, owner, repo, ref, and path.
31
+ * Supports various URL formats:
32
+ *
33
+ * GitHub:
34
+ * https://github.com/owner/repo
35
+ * https://github.com/owner/repo.git
36
+ * git@github.com:owner/repo.git
37
+ * https://github.com/owner/repo/tree/main/path/to/dir
38
+ * https://github.com/owner/repo/blob/main/path/to/file.xs
39
+ * https://raw.githubusercontent.com/owner/repo/refs/heads/main/path/to/file.xs
40
+ *
41
+ * GitLab:
42
+ * https://gitlab.com/owner/repo
43
+ * https://gitlab.com/owner/repo/-/tree/master/path/to/dir
44
+ * https://gitlab.com/owner/repo/-/blob/master/path/to/file
45
+ *
46
+ * Other git URLs passed through for git clone.
47
+ */
48
+ private parseRepoUrl;
49
+ /**
50
+ * Resolve the output directory and base filename for a parsed document.
51
+ * Uses the same type-to-directory mapping as workspace pull.
52
+ */
53
+ private resolveOutputPath;
54
+ /**
55
+ * Sanitize a document name for use as a filename.
56
+ */
57
+ private sanitizeFilename;
58
+ }
@@ -0,0 +1,403 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { execSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import snakeCase from 'lodash.snakecase';
7
+ import BaseCommand from '../../../../base-command.js';
8
+ import { parseDocument } from '../../../../utils/document-parser.js';
9
+ export default class GitPull extends BaseCommand {
10
+ static args = {
11
+ directory: Args.string({
12
+ description: 'Output directory for imported files',
13
+ required: true,
14
+ }),
15
+ };
16
+ static description = 'Pull XanoScript files from a git repository into a local directory';
17
+ static examples = [
18
+ `$ xano workspace git pull ./output -r https://github.com/owner/repo`,
19
+ `$ xano workspace git pull ./output -r https://github.com/owner/repo/tree/main/path/to/dir`,
20
+ `$ xano workspace git pull ./output -r https://github.com/owner/repo/blob/main/path/to/file.xs`,
21
+ `$ xano workspace git pull ./output -r git@github.com:owner/repo.git`,
22
+ `$ xano workspace git pull ./output -r https://github.com/owner/private-repo -t ghp_xxx`,
23
+ `$ xano workspace git pull ./output -r https://gitlab.com/owner/repo/-/tree/master/path`,
24
+ `$ xano workspace git pull ./output -r https://gitlab.com/owner/repo -b main`,
25
+ ];
26
+ static flags = {
27
+ ...BaseCommand.baseFlags,
28
+ branch: Flags.string({
29
+ char: 'b',
30
+ description: 'Branch, tag, or ref to fetch (defaults to repository default branch)',
31
+ required: false,
32
+ }),
33
+ path: Flags.string({
34
+ description: 'Subdirectory within the repo to import from',
35
+ required: false,
36
+ }),
37
+ repo: Flags.string({
38
+ char: 'r',
39
+ description: 'Git repository URL (GitHub HTTPS, SSH, or any git URL)',
40
+ required: true,
41
+ }),
42
+ token: Flags.string({
43
+ char: 't',
44
+ description: 'Personal access token for private repos (falls back to GITHUB_TOKEN env var)',
45
+ env: 'GITHUB_TOKEN',
46
+ required: false,
47
+ }),
48
+ };
49
+ async run() {
50
+ const { args, flags } = await this.parse(GitPull);
51
+ const token = flags.token || '';
52
+ const outputDir = path.resolve(args.directory);
53
+ // Normalize the URL to extract owner/repo/ref/path from various formats
54
+ const repoInfo = this.parseRepoUrl(flags.repo);
55
+ // CLI flags override values extracted from the URL
56
+ const ref = flags.branch || repoInfo.ref;
57
+ const subPath = flags.path || repoInfo.pathInRepo;
58
+ // Create a temp directory for fetching
59
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xano-git-pull-'));
60
+ try {
61
+ // Fetch repository contents
62
+ let repoRoot;
63
+ if (repoInfo.host === 'github') {
64
+ repoRoot = await this.fetchGitHubTarball(repoInfo.owner, repoInfo.repo, ref, token, tempDir, flags.verbose);
65
+ }
66
+ else {
67
+ repoRoot = this.cloneRepo(repoInfo.url, ref, token, tempDir, flags.verbose);
68
+ }
69
+ // Determine source directory (optionally scoped to --path or URL path)
70
+ const sourceDir = subPath ? path.join(repoRoot, subPath) : repoRoot;
71
+ if (!fs.existsSync(sourceDir)) {
72
+ this.error(`Path '${subPath}' not found in repository`);
73
+ }
74
+ if (!fs.statSync(sourceDir).isDirectory()) {
75
+ this.error(`Path '${subPath}' is not a directory`);
76
+ }
77
+ // Collect all .xs files
78
+ const xsFiles = this.collectFiles(sourceDir);
79
+ if (xsFiles.length === 0) {
80
+ this.error(`No .xs files found${subPath ? ` in ${subPath}` : ''} in the repository`);
81
+ }
82
+ // Parse each file
83
+ const documents = [];
84
+ for (const filePath of xsFiles) {
85
+ const content = fs.readFileSync(filePath, 'utf8').trim();
86
+ if (!content)
87
+ continue;
88
+ const parsed = parseDocument(content);
89
+ if (parsed) {
90
+ documents.push(parsed);
91
+ }
92
+ }
93
+ if (documents.length === 0) {
94
+ this.error('No valid XanoScript documents found in the repository');
95
+ }
96
+ // Write documents to output directory using the same file tree logic as workspace pull
97
+ fs.mkdirSync(outputDir, { recursive: true });
98
+ const filenameCounters = new Map();
99
+ let writtenCount = 0;
100
+ for (const doc of documents) {
101
+ const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc);
102
+ fs.mkdirSync(typeDir, { recursive: true });
103
+ // Track duplicates per directory
104
+ const dirKey = path.relative(outputDir, typeDir);
105
+ if (!filenameCounters.has(dirKey)) {
106
+ filenameCounters.set(dirKey, new Map());
107
+ }
108
+ const typeCounters = filenameCounters.get(dirKey);
109
+ const count = typeCounters.get(baseName) || 0;
110
+ typeCounters.set(baseName, count + 1);
111
+ const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
112
+ const filePath = path.join(typeDir, filename);
113
+ fs.writeFileSync(filePath, doc.content, 'utf8');
114
+ writtenCount++;
115
+ }
116
+ const source = subPath ? `${flags.repo} (${subPath})` : flags.repo;
117
+ this.log(`Pulled ${writtenCount} documents from ${source} to ${args.directory}`);
118
+ }
119
+ finally {
120
+ // Clean up temp directory
121
+ fs.rmSync(tempDir, { force: true, recursive: true });
122
+ }
123
+ }
124
+ /**
125
+ * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
126
+ */
127
+ collectFiles(dir) {
128
+ const files = [];
129
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
130
+ for (const entry of entries) {
131
+ const fullPath = path.join(dir, entry.name);
132
+ if (entry.isDirectory()) {
133
+ files.push(...this.collectFiles(fullPath));
134
+ }
135
+ else if (entry.isFile() && entry.name.endsWith('.xs')) {
136
+ files.push(fullPath);
137
+ }
138
+ }
139
+ return files.sort();
140
+ }
141
+ /**
142
+ * Clone a git repository using shallow clone.
143
+ */
144
+ cloneRepo(cloneUrl, ref, token, tempDir, verbose) {
145
+ let url = cloneUrl;
146
+ // Inject token into HTTPS URLs for private repos
147
+ if (token && url.startsWith('https://')) {
148
+ const parsed = new URL(url);
149
+ parsed.username = token;
150
+ url = parsed.toString();
151
+ }
152
+ const args = ['git', 'clone', '--depth', '1'];
153
+ if (ref) {
154
+ args.push('--branch', ref);
155
+ }
156
+ const cloneTarget = path.join(tempDir, 'repo');
157
+ args.push(url, cloneTarget);
158
+ if (verbose) {
159
+ this.log(`Cloning ${cloneUrl}...`);
160
+ }
161
+ try {
162
+ execSync(args.join(' '), { stdio: verbose ? 'inherit' : 'pipe' });
163
+ }
164
+ catch (error) {
165
+ this.error(`Failed to clone repository: ${error.message}`);
166
+ }
167
+ return cloneTarget;
168
+ }
169
+ /**
170
+ * Fetch a GitHub repository via the tarball API for fast download without requiring git.
171
+ */
172
+ async fetchGitHubTarball(owner, repo, ref, token, tempDir, verbose) {
173
+ const tarballRef = ref || 'HEAD';
174
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${tarballRef}`;
175
+ const headers = {
176
+ Accept: 'application/vnd.github+json',
177
+ 'User-Agent': 'xano-cli',
178
+ };
179
+ if (token) {
180
+ headers.Authorization = `Bearer ${token}`;
181
+ }
182
+ if (verbose) {
183
+ this.log(`Fetching tarball from GitHub: ${owner}/${repo}${ref ? `@${ref}` : ''}...`);
184
+ }
185
+ const response = await fetch(apiUrl, { headers, redirect: 'follow' });
186
+ if (!response.ok) {
187
+ const errorText = await response.text();
188
+ if (response.status === 404) {
189
+ this.error(`Repository '${owner}/${repo}' not found (or is private). ` +
190
+ `Use --token to authenticate for private repos.`);
191
+ }
192
+ this.error(`GitHub API request failed (${response.status}): ${errorText}`);
193
+ }
194
+ // Save the tarball to a temp file
195
+ const tarballPath = path.join(tempDir, 'repo.tar.gz');
196
+ const buffer = Buffer.from(await response.arrayBuffer());
197
+ fs.writeFileSync(tarballPath, buffer);
198
+ // Extract the tarball
199
+ const extractDir = path.join(tempDir, 'extracted');
200
+ fs.mkdirSync(extractDir, { recursive: true });
201
+ try {
202
+ execSync(`tar xzf "${tarballPath}" -C "${extractDir}"`, { stdio: verbose ? 'inherit' : 'pipe' });
203
+ }
204
+ catch (error) {
205
+ this.error(`Failed to extract tarball: ${error.message}`);
206
+ }
207
+ // GitHub tarballs have a root directory like "owner-repo-sha/"
208
+ const extractedEntries = fs.readdirSync(extractDir);
209
+ if (extractedEntries.length === 1) {
210
+ const rootDir = path.join(extractDir, extractedEntries[0]);
211
+ if (fs.statSync(rootDir).isDirectory()) {
212
+ return rootDir;
213
+ }
214
+ }
215
+ return extractDir;
216
+ }
217
+ /**
218
+ * Parse a repository URL to extract host, owner, repo, ref, and path.
219
+ * Supports various URL formats:
220
+ *
221
+ * GitHub:
222
+ * https://github.com/owner/repo
223
+ * https://github.com/owner/repo.git
224
+ * git@github.com:owner/repo.git
225
+ * https://github.com/owner/repo/tree/main/path/to/dir
226
+ * https://github.com/owner/repo/blob/main/path/to/file.xs
227
+ * https://raw.githubusercontent.com/owner/repo/refs/heads/main/path/to/file.xs
228
+ *
229
+ * GitLab:
230
+ * https://gitlab.com/owner/repo
231
+ * https://gitlab.com/owner/repo/-/tree/master/path/to/dir
232
+ * https://gitlab.com/owner/repo/-/blob/master/path/to/file
233
+ *
234
+ * Other git URLs passed through for git clone.
235
+ */
236
+ parseRepoUrl(inputUrl) {
237
+ // GitHub SSH: git@github.com:owner/repo.git
238
+ const sshMatch = inputUrl.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
239
+ if (sshMatch) {
240
+ return { host: 'github', owner: sshMatch[1], pathInRepo: '', ref: '', repo: sshMatch[2], url: inputUrl };
241
+ }
242
+ // raw.githubusercontent.com: https://raw.githubusercontent.com/owner/repo/refs/heads/branch/path
243
+ const rawGhMatch = inputUrl.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/refs\/heads\/([^/]+)\/?(.*)/);
244
+ if (rawGhMatch) {
245
+ const filePath = rawGhMatch[4] || '';
246
+ // Strip the filename to get the directory path, since this points to a single file
247
+ const dirPath = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
248
+ return {
249
+ host: 'github',
250
+ owner: rawGhMatch[1],
251
+ pathInRepo: dirPath,
252
+ ref: rawGhMatch[3],
253
+ repo: rawGhMatch[2],
254
+ url: inputUrl,
255
+ };
256
+ }
257
+ let parsed;
258
+ try {
259
+ parsed = new URL(inputUrl);
260
+ }
261
+ catch {
262
+ // Not a URL — treat as a generic git clone target
263
+ return { host: 'other', owner: '', pathInRepo: '', ref: '', repo: '', url: inputUrl };
264
+ }
265
+ const host = parsed.hostname.toLowerCase();
266
+ const pathParts = parsed.pathname
267
+ .replace(/^\//, '')
268
+ .replace(/\.git$/, '')
269
+ .split('/');
270
+ // GitHub HTTPS
271
+ if (host === 'github.com' && pathParts.length >= 2) {
272
+ const owner = pathParts[0];
273
+ const repo = pathParts[1];
274
+ // https://github.com/owner/repo/tree/branch/path/to/dir
275
+ // https://github.com/owner/repo/blob/branch/path/to/file.xs
276
+ if (pathParts.length >= 4 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) {
277
+ const ref = pathParts[3];
278
+ const subPath = pathParts.slice(4).join('/');
279
+ let dirPath = subPath;
280
+ // For blob links, strip the filename to get directory
281
+ if (pathParts[2] === 'blob' && subPath.includes('/')) {
282
+ dirPath = subPath.slice(0, subPath.lastIndexOf('/'));
283
+ }
284
+ else if (pathParts[2] === 'blob') {
285
+ dirPath = ''; // File is at repo root
286
+ }
287
+ return { host: 'github', owner, pathInRepo: dirPath, ref, repo, url: inputUrl };
288
+ }
289
+ // https://github.com/owner/repo (root)
290
+ return { host: 'github', owner, pathInRepo: '', ref: '', repo, url: inputUrl };
291
+ }
292
+ // GitLab HTTPS
293
+ if (host === 'gitlab.com' && pathParts.length >= 2) {
294
+ const owner = pathParts[0];
295
+ const repo = pathParts[1];
296
+ // Reconstruct clean clone URL
297
+ const cloneUrl = `https://gitlab.com/${owner}/${repo}.git`;
298
+ // https://gitlab.com/owner/repo/-/tree/branch/path/to/dir
299
+ // https://gitlab.com/owner/repo/-/blob/branch/path/to/file
300
+ const dashIdx = pathParts.indexOf('-');
301
+ if (dashIdx >= 2 && pathParts.length >= dashIdx + 3) {
302
+ const action = pathParts[dashIdx + 1]; // tree, blob, or raw
303
+ if (action === 'tree' || action === 'blob' || action === 'raw') {
304
+ const ref = pathParts[dashIdx + 2];
305
+ const subPath = pathParts.slice(dashIdx + 3).join('/');
306
+ let dirPath = subPath;
307
+ if ((action === 'blob' || action === 'raw') && subPath.includes('/')) {
308
+ dirPath = subPath.slice(0, subPath.lastIndexOf('/'));
309
+ }
310
+ else if (action === 'blob' || action === 'raw') {
311
+ dirPath = '';
312
+ }
313
+ return { host: 'gitlab', owner, pathInRepo: dirPath, ref, repo, url: cloneUrl };
314
+ }
315
+ }
316
+ // https://gitlab.com/owner/repo (root)
317
+ return { host: 'gitlab', owner, pathInRepo: '', ref: '', repo, url: cloneUrl };
318
+ }
319
+ // Other git URLs — pass through for git clone
320
+ return { host: 'other', owner: '', pathInRepo: '', ref: '', repo: '', url: inputUrl };
321
+ }
322
+ /**
323
+ * Resolve the output directory and base filename for a parsed document.
324
+ * Uses the same type-to-directory mapping as workspace pull.
325
+ */
326
+ resolveOutputPath(outputDir, doc) {
327
+ let typeDir;
328
+ let baseName;
329
+ if (doc.type === 'workspace') {
330
+ typeDir = path.join(outputDir, 'workspace');
331
+ baseName = this.sanitizeFilename(doc.name);
332
+ }
333
+ else if (doc.type === 'workspace_trigger') {
334
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
335
+ baseName = this.sanitizeFilename(doc.name);
336
+ }
337
+ else if (doc.type === 'agent') {
338
+ typeDir = path.join(outputDir, 'ai', 'agent');
339
+ baseName = this.sanitizeFilename(doc.name);
340
+ }
341
+ else if (doc.type === 'mcp_server') {
342
+ typeDir = path.join(outputDir, 'ai', 'mcp_server');
343
+ baseName = this.sanitizeFilename(doc.name);
344
+ }
345
+ else if (doc.type === 'tool') {
346
+ typeDir = path.join(outputDir, 'ai', 'tool');
347
+ baseName = this.sanitizeFilename(doc.name);
348
+ }
349
+ else if (doc.type === 'agent_trigger') {
350
+ typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
351
+ baseName = this.sanitizeFilename(doc.name);
352
+ }
353
+ else if (doc.type === 'mcp_server_trigger') {
354
+ typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
355
+ baseName = this.sanitizeFilename(doc.name);
356
+ }
357
+ else if (doc.type === 'table_trigger') {
358
+ typeDir = path.join(outputDir, 'table', 'trigger');
359
+ baseName = this.sanitizeFilename(doc.name);
360
+ }
361
+ else if (doc.type === 'realtime_channel') {
362
+ typeDir = path.join(outputDir, 'realtime', 'channel');
363
+ baseName = this.sanitizeFilename(doc.name);
364
+ }
365
+ else if (doc.type === 'realtime_trigger') {
366
+ typeDir = path.join(outputDir, 'realtime', 'trigger');
367
+ baseName = this.sanitizeFilename(doc.name);
368
+ }
369
+ else if (doc.type === 'api_group') {
370
+ const groupFolder = snakeCase(doc.name);
371
+ typeDir = path.join(outputDir, 'api', groupFolder);
372
+ baseName = 'api_group';
373
+ }
374
+ else if (doc.type === 'query' && doc.apiGroup) {
375
+ const groupFolder = snakeCase(doc.apiGroup);
376
+ const nameParts = doc.name.split('/');
377
+ const leafName = nameParts.pop();
378
+ const folderParts = nameParts.map((part) => snakeCase(part));
379
+ typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
380
+ baseName = this.sanitizeFilename(leafName);
381
+ if (doc.verb) {
382
+ baseName = `${baseName}_${doc.verb}`;
383
+ }
384
+ }
385
+ else {
386
+ const nameParts = doc.name.split('/');
387
+ const leafName = nameParts.pop();
388
+ const folderParts = nameParts.map((part) => snakeCase(part));
389
+ typeDir = path.join(outputDir, doc.type, ...folderParts);
390
+ baseName = this.sanitizeFilename(leafName);
391
+ if (doc.verb) {
392
+ baseName = `${baseName}_${doc.verb}`;
393
+ }
394
+ }
395
+ return { baseName, typeDir };
396
+ }
397
+ /**
398
+ * Sanitize a document name for use as a filename.
399
+ */
400
+ sanitizeFilename(name) {
401
+ return snakeCase(name.replaceAll('"', ''));
402
+ }
403
+ }