@multiplatform.one/cli 5.0.26 → 6.0.0

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,682 @@
1
+ import { execSync } from "node:child_process";
2
+ import { type Dirent, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import inquirer from "inquirer";
5
+
6
+ const repoUrl = "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one.git";
7
+
8
+ const services = ["frappe", "solana", "ethereum", "sui"] as const;
9
+
10
+ /** Resolve a service name to its relative directory from the project root. */
11
+ function serviceDir(name: string): string {
12
+ return `apps/${name}`;
13
+ }
14
+ const apps = [
15
+ "one",
16
+ "keycloak",
17
+ "storybook",
18
+ "storybook-expo",
19
+ "vscode",
20
+ "webext",
21
+ "vocs",
22
+ ] as const;
23
+
24
+ type Service = (typeof services)[number];
25
+ type App = (typeof apps)[number];
26
+
27
+ /** env variable prefixes to remove per service */
28
+ const serviceEnvPrefixes: Record<Service, string[]> = {
29
+ frappe: ["FRAPPE_", "MARIADB_"],
30
+ solana: [],
31
+ ethereum: [],
32
+ sui: [],
33
+ };
34
+
35
+ /** env variable prefixes to remove per app */
36
+ const appEnvPrefixes: Record<App, string[]> = {
37
+ one: ["ONE_"],
38
+ keycloak: ["KEYCLOAK_"],
39
+ storybook: ["STORYBOOK_", "VR_DIFFING_ENGINE="],
40
+ "storybook-expo": [],
41
+ vscode: ["VSCODE_"],
42
+ webext: ["WEBEXT_"],
43
+ vocs: ["VOCS_"],
44
+ };
45
+
46
+ /** env section headers (comments) to remove per app/service */
47
+ const serviceEnvSections: Record<Service, string[]> = {
48
+ frappe: ["# frappe", "# mariadb"],
49
+ solana: [],
50
+ ethereum: [],
51
+ sui: [],
52
+ };
53
+
54
+ const appEnvSections: Record<App, string[]> = {
55
+ one: ["# one"],
56
+ keycloak: ["# keycloak"],
57
+ storybook: ["# storybook"],
58
+ "storybook-expo": [],
59
+ vscode: ["# vscode"],
60
+ webext: ["# webext"],
61
+ vocs: ["# vocs"],
62
+ };
63
+
64
+ /** launch.json configuration names per service/app */
65
+ const serviceLaunchNames: Record<Service, string[]> = {
66
+ frappe: ["frappe dev"],
67
+ solana: ["solana dev", "solana localnet"],
68
+ ethereum: ["ethereum dev"],
69
+ sui: ["sui dev"],
70
+ };
71
+
72
+ const appLaunchNames: Record<App, string[]> = {
73
+ one: ["one dev"],
74
+ keycloak: ["keycloak dev", "keycloak storybook"],
75
+ storybook: ["storybook dev"],
76
+ "storybook-expo": ["storybook-expo dev"],
77
+ vscode: ["vscode dev"],
78
+ webext: ["webext dev"],
79
+ vocs: ["vocs dev"],
80
+ };
81
+
82
+ interface InitOptions {
83
+ name?: string;
84
+ services?: string;
85
+ apps?: string;
86
+ checkout?: string;
87
+ /** Path to clone.sh script (from CLI); if set, clone via script instead of execSync */
88
+ cloneScript?: string;
89
+ }
90
+
91
+ /**
92
+ * Shared modify step: remove unselected apps/services and rewrite configs.
93
+ * Used by both init (with prompted selections) and update (with getPresentApps/getPresentServices).
94
+ */
95
+ export function runModifyStep(
96
+ dir: string,
97
+ selectedServices: string[],
98
+ selectedApps: string[],
99
+ ): void {
100
+ const services = selectedServices as Service[];
101
+ const apps = selectedApps as App[];
102
+
103
+ for (const service of services) {
104
+ if (!services.includes(service)) {
105
+ const servicePath = join(dir, serviceDir(service));
106
+ if (existsSync(servicePath)) {
107
+ rmSync(servicePath, { recursive: true });
108
+ console.log(`Removed service: ${service}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ for (const app of apps) {
114
+ if (!apps.includes(app)) {
115
+ const appPath = join(dir, "apps", app);
116
+ if (existsSync(appPath)) {
117
+ rmSync(appPath, { recursive: true });
118
+ console.log(`Removed app: ${app}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ const packagesDir = join(dir, "packages");
124
+ if (existsSync(packagesDir)) {
125
+ const versionMap = new Map<string, string>();
126
+ for (const pkg of readdirSync(packagesDir)) {
127
+ const pkgJsonPath = join(packagesDir, pkg, "package.json");
128
+ if (existsSync(pkgJsonPath)) {
129
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
130
+ if (pkgJson.name && pkgJson.version) {
131
+ versionMap.set(pkgJson.name, pkgJson.version);
132
+ }
133
+ }
134
+ }
135
+ for (const pkg of readdirSync(packagesDir)) {
136
+ const pkgJsonPath = join(packagesDir, pkg, "package.json");
137
+ if (existsSync(pkgJsonPath)) {
138
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
139
+ if (!pkgJson.private) {
140
+ rmSync(join(packagesDir, pkg), { recursive: true });
141
+ console.log(`Removed public package: ${pkgJson.name}`);
142
+ }
143
+ }
144
+ }
145
+ convertWorkspaceVersions(dir, versionMap);
146
+ }
147
+
148
+ cleanupPnpmWorkspace(dir, services, apps);
149
+ cleanupLaunchJson(dir, services, apps);
150
+ cleanupEnvFiles(dir, services, apps);
151
+ cleanupTsconfigs(dir);
152
+ cleanupMisc(dir);
153
+ removeLicenseHeaders(dir);
154
+ }
155
+
156
+ export async function init(nameArg: string | undefined, options: InitOptions) {
157
+ let projectName = nameArg;
158
+ if (!projectName) {
159
+ const result = await inquirer.prompt([
160
+ {
161
+ message: "What is the project name?",
162
+ name: "name",
163
+ type: "input",
164
+ default: "my-app",
165
+ },
166
+ ]);
167
+ projectName = result.name as string;
168
+ }
169
+
170
+ let selectedServices: Service[];
171
+ if (options.services) {
172
+ selectedServices = options.services.split(",").map((s) => s.trim()) as Service[];
173
+ } else {
174
+ const result = await inquirer.prompt([
175
+ {
176
+ message: "Select services:",
177
+ name: "services",
178
+ type: "checkbox",
179
+ choices: services.map((s) => ({ name: s, value: s })),
180
+ },
181
+ ]);
182
+ selectedServices = result.services as Service[];
183
+ }
184
+
185
+ let selectedApps: App[];
186
+ if (options.apps) {
187
+ selectedApps = options.apps.split(",").map((s) => s.trim()) as App[];
188
+ } else {
189
+ const result = await inquirer.prompt([
190
+ {
191
+ message: "Select apps:",
192
+ name: "apps",
193
+ type: "checkbox",
194
+ choices: apps.map((a) => ({
195
+ name: a,
196
+ value: a,
197
+ checked: a === "one",
198
+ })),
199
+ },
200
+ ]);
201
+ selectedApps = result.apps as App[];
202
+ }
203
+
204
+ // Target dir: when project name is provided, use ./<name>; when not provided, we prompted above (default "my-app"). Init cannot run inside an existing git repo (enforced by CLI).
205
+ const targetDir = resolve(projectName);
206
+
207
+ if (existsSync(targetDir)) {
208
+ const entries = readdirSync(targetDir);
209
+ if (entries.length > 0) {
210
+ throw new Error(`Directory "${targetDir}" already exists and is not empty`);
211
+ }
212
+ }
213
+
214
+ // Clone repository (via script when provided by CLI, else inline)
215
+ const branch = options.checkout || "main";
216
+ console.log("\nCloning repository...");
217
+ if (options.cloneScript) {
218
+ execSync(`sh "${options.cloneScript}" "${repoUrl}" "${branch}" "${targetDir}"`, {
219
+ stdio: "inherit",
220
+ });
221
+ } else {
222
+ execSync(`git clone --depth 1 --branch "${branch}" ${repoUrl} "${targetDir}"`, {
223
+ stdio: "inherit",
224
+ });
225
+ }
226
+
227
+ // Remove .git to start fresh
228
+ rmSync(join(targetDir, ".git"), { recursive: true, force: true });
229
+
230
+ runModifyStep(targetDir, selectedServices, selectedApps);
231
+
232
+ // Init-only: update root package.json name
233
+ const rootPkgPath = join(targetDir, "package.json");
234
+ if (existsSync(rootPkgPath)) {
235
+ const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf-8"));
236
+ rootPkg.name = projectName;
237
+ rootPkg.packageManager = undefined;
238
+ writeFileSync(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
239
+ }
240
+
241
+ // Copy .env.example to .env if it exists
242
+ const envDefaultPath = join(targetDir, ".env.example");
243
+ const envPath = join(targetDir, ".env");
244
+ if (existsSync(envDefaultPath) && !existsSync(envPath)) {
245
+ writeFileSync(envPath, readFileSync(envDefaultPath, "utf-8"));
246
+ }
247
+
248
+ // Remove storybook baselines
249
+ const lostpixelBaseline = join(targetDir, "apps", "storybook", ".lostpixel", "baseline");
250
+ if (existsSync(lostpixelBaseline)) {
251
+ rmSync(lostpixelBaseline, { recursive: true });
252
+ }
253
+
254
+ // Remove pnpm-lock.yaml for fresh install
255
+ const lockfile = join(targetDir, "pnpm-lock.yaml");
256
+ if (existsSync(lockfile)) {
257
+ rmSync(lockfile);
258
+ }
259
+
260
+ // Remove scaffolding artifacts
261
+ for (const artifact of [
262
+ "agent-os",
263
+ "public/cli/scripts/init.sh",
264
+ "public/cli/scripts/update.sh",
265
+ ".changeset",
266
+ ]) {
267
+ const p = join(targetDir, artifact);
268
+ if (existsSync(p)) {
269
+ rmSync(p, { recursive: true, force: true });
270
+ }
271
+ }
272
+
273
+ // Initialize project
274
+ console.log("\nInstalling dependencies...");
275
+ execSync("pnpm install", { cwd: targetDir, stdio: "inherit" });
276
+
277
+ console.log("\nInitializing git repository...");
278
+ execSync("git init", { cwd: targetDir, stdio: "inherit" });
279
+ execSync("git add -A", { cwd: targetDir, stdio: "inherit" });
280
+ execSync('git commit -m "Initial commit from multiplatform.one"', {
281
+ cwd: targetDir,
282
+ stdio: "inherit",
283
+ });
284
+
285
+ console.log(`\n✅ Project created at ${targetDir}`);
286
+ console.log("\nNext steps:");
287
+ console.log(` cd ${projectName}`);
288
+ console.log(" pnpm dev");
289
+ }
290
+
291
+ function convertWorkspaceVersions(dir: string, versionMap: Map<string, string>) {
292
+ const files = findPackageJsonFiles(dir);
293
+ for (const file of files) {
294
+ const content = JSON.parse(readFileSync(file, "utf-8"));
295
+ let changed = false;
296
+ for (const depType of ["dependencies", "devDependencies", "peerDependencies"]) {
297
+ const deps = content[depType] as Record<string, string> | undefined;
298
+ if (!deps) continue;
299
+ for (const [depName, version] of Object.entries(deps)) {
300
+ if (typeof version === "string" && version.startsWith("workspace:")) {
301
+ const realVersion = versionMap.get(depName);
302
+ if (realVersion) {
303
+ // workspace:* -> ^version, workspace:^x -> ^x (keep the range)
304
+ const workspaceRange = version.replace("workspace:", "");
305
+ deps[depName] =
306
+ workspaceRange === "*" || workspaceRange === "^" ? `^${realVersion}` : workspaceRange;
307
+ changed = true;
308
+ }
309
+ }
310
+ }
311
+ }
312
+ if (changed) {
313
+ writeFileSync(file, `${JSON.stringify(content, null, 2)}\n`);
314
+ }
315
+ }
316
+ }
317
+
318
+ function findPackageJsonFiles(dir: string): string[] {
319
+ const results: string[] = [];
320
+ function walk(d: string) {
321
+ let entries: Dirent[];
322
+ try {
323
+ entries = readdirSync(d, { withFileTypes: true });
324
+ } catch {
325
+ return;
326
+ }
327
+ for (const entry of entries) {
328
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
329
+ const full = join(d, entry.name);
330
+ if (entry.isDirectory()) walk(full);
331
+ else if (entry.name === "package.json") results.push(full);
332
+ }
333
+ }
334
+ walk(dir);
335
+ return results;
336
+ }
337
+
338
+ function cleanupPnpmWorkspace(dir: string, _services: Service[], _apps: App[]) {
339
+ const wsPath = join(dir, "pnpm-workspace.yaml");
340
+ if (!existsSync(wsPath)) return;
341
+
342
+ let content = readFileSync(wsPath, "utf-8");
343
+
344
+ // If no apps remain, remove apps/* entry
345
+ const appsDir = join(dir, "apps");
346
+ if (!existsSync(appsDir) || readdirSync(appsDir).length === 0) {
347
+ content = content.replace(/^\s*-\s*['"]?apps\/\*['"]?\s*$/gm, "");
348
+ }
349
+
350
+ // Check if packages dir still has entries
351
+ const pkgDir = join(dir, "packages");
352
+ if (!existsSync(pkgDir) || readdirSync(pkgDir).length === 0) {
353
+ content = content.replace(/^\s*-\s*['"]?packages\/\*['"]?\s*$/gm, "");
354
+ }
355
+
356
+ // Clean up multiple blank lines
357
+ content = `${content.replace(/\n{3,}/g, "\n\n").trim()}\n`;
358
+ writeFileSync(wsPath, content);
359
+ }
360
+
361
+ function cleanupLaunchJson(dir: string, services: Service[], apps: App[]) {
362
+ const launchPath = join(dir, ".vscode", "launch.json");
363
+ if (!existsSync(launchPath)) return;
364
+
365
+ const launch = JSON.parse(readFileSync(launchPath, "utf-8"));
366
+ const removedNames = new Set<string>();
367
+
368
+ // Collect names to remove for unselected services
369
+ for (const service of services) {
370
+ if (!services.includes(service)) {
371
+ for (const configName of serviceLaunchNames[service]) {
372
+ removedNames.add(configName);
373
+ }
374
+ }
375
+ }
376
+
377
+ // Collect names to remove for unselected apps
378
+ for (const app of apps) {
379
+ if (!apps.includes(app)) {
380
+ for (const configName of appLaunchNames[app]) {
381
+ removedNames.add(configName);
382
+ }
383
+ }
384
+ }
385
+
386
+ // Filter configurations
387
+ if (launch.configurations) {
388
+ launch.configurations = launch.configurations.filter(
389
+ (c: { name: string }) => !removedNames.has(c.name),
390
+ );
391
+ }
392
+
393
+ // Filter compound configurations and remove empty compounds
394
+ if (launch.compounds) {
395
+ for (const compound of launch.compounds) {
396
+ if (compound.configurations) {
397
+ compound.configurations = compound.configurations.filter(
398
+ (configName: string) => !removedNames.has(configName),
399
+ );
400
+ }
401
+ }
402
+ launch.compounds = launch.compounds.filter(
403
+ (c: { configurations?: string[] }) => c.configurations && c.configurations.length > 0,
404
+ );
405
+ }
406
+
407
+ writeFileSync(launchPath, `${JSON.stringify(launch, null, 2)}\n`);
408
+ }
409
+
410
+ function cleanupEnvFiles(dir: string, services: Service[], apps: App[]) {
411
+ for (const envFile of [".env.example", ".env"]) {
412
+ const filePath = join(dir, envFile);
413
+ if (!existsSync(filePath)) continue;
414
+
415
+ let lines = readFileSync(filePath, "utf-8").split("\n");
416
+
417
+ // Remove lines for unselected services
418
+ for (const service of services) {
419
+ if (!services.includes(service)) {
420
+ const prefixes = serviceEnvPrefixes[service];
421
+ const sections = serviceEnvSections[service];
422
+ lines = filterEnvLines(lines, prefixes, sections);
423
+ }
424
+ }
425
+
426
+ // Remove lines for unselected apps
427
+ for (const app of apps) {
428
+ if (!apps.includes(app)) {
429
+ const prefixes = appEnvPrefixes[app];
430
+ const sections = appEnvSections[app];
431
+ lines = filterEnvLines(lines, prefixes, sections);
432
+ }
433
+ }
434
+
435
+ // Clean up multiple blank lines
436
+ const content = lines
437
+ .join("\n")
438
+ .replace(/\n{3,}/g, "\n\n")
439
+ .trim();
440
+ writeFileSync(filePath, `${content}\n`);
441
+ }
442
+ }
443
+
444
+ function filterEnvLines(lines: string[], prefixes: string[], sections: string[]): string[] {
445
+ return lines.filter((line) => {
446
+ const trimmed = line.trim();
447
+ // Remove section headers
448
+ for (const section of sections) {
449
+ if (trimmed === section) return false;
450
+ }
451
+ // Remove lines with matching prefixes
452
+ for (const prefix of prefixes) {
453
+ if (prefix.endsWith("=")) {
454
+ // Exact match like "VR_DIFFING_ENGINE="
455
+ if (trimmed.startsWith(prefix)) return false;
456
+ } else {
457
+ // Prefix match like "FRAPPE_"
458
+ if (trimmed.startsWith(prefix) || trimmed.startsWith(`# ${prefix}`)) return false;
459
+ }
460
+ }
461
+ return true;
462
+ });
463
+ }
464
+
465
+ function cleanupTsconfigs(dir: string) {
466
+ const tsconfigFiles = findTsconfigFiles(dir);
467
+ for (const file of tsconfigFiles) {
468
+ try {
469
+ const content = readFileSync(file, "utf-8");
470
+ // Parse JSON with comments (tsconfig allows comments)
471
+ const json = JSON.parse(stripJsonComments(content));
472
+ if (!json.references) continue;
473
+
474
+ let changed = false;
475
+ json.references = json.references.filter((ref: { path: string }) => {
476
+ const refPath = resolve(join(file, "..", ref.path));
477
+ // Check if the referenced path exists (could be a dir or file)
478
+ const pathExists =
479
+ existsSync(refPath) ||
480
+ existsSync(`${refPath}.json`) ||
481
+ existsSync(join(refPath, "tsconfig.json"));
482
+ if (!pathExists) changed = true;
483
+ return pathExists;
484
+ });
485
+
486
+ if (changed) {
487
+ writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
488
+ }
489
+ } catch {
490
+ // Skip files that can't be parsed
491
+ }
492
+ }
493
+ }
494
+
495
+ function findTsconfigFiles(dir: string): string[] {
496
+ const results: string[] = [];
497
+ function walk(d: string) {
498
+ let entries: Dirent[];
499
+ try {
500
+ entries = readdirSync(d, { withFileTypes: true });
501
+ } catch {
502
+ return;
503
+ }
504
+ for (const entry of entries) {
505
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
506
+ const full = join(d, entry.name);
507
+ if (entry.isDirectory()) walk(full);
508
+ else if (entry.name.startsWith("tsconfig") && entry.name.endsWith(".json")) {
509
+ results.push(full);
510
+ }
511
+ }
512
+ }
513
+ walk(dir);
514
+ return results;
515
+ }
516
+
517
+ function stripJsonComments(str: string): string {
518
+ // Simple JSON comment stripper for tsconfig files
519
+ return str.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
520
+ }
521
+
522
+ function cleanupMisc(dir: string) {
523
+ // Remove docker dns dir (if generating from template)
524
+ for (const subdir of ["docker/dns"]) {
525
+ const p = join(dir, subdir);
526
+ if (existsSync(p)) {
527
+ rmSync(p, { recursive: true });
528
+ }
529
+ }
530
+
531
+ // Remove VSCode psi-header config from settings.json
532
+ const settingsPath = join(dir, ".vscode", "settings.json");
533
+ if (existsSync(settingsPath)) {
534
+ try {
535
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
536
+ const psiKeys = Object.keys(settings).filter((k) => k.startsWith("psi-header"));
537
+ const cleaned = { ...settings };
538
+ for (const key of psiKeys) {
539
+ cleaned[key] = undefined;
540
+ }
541
+ // Filter out undefined keys by serializing
542
+ const filtered = JSON.parse(JSON.stringify(cleaned));
543
+ writeFileSync(settingsPath, `${JSON.stringify(filtered, null, 2)}\n`);
544
+ } catch {
545
+ // Skip if settings can't be parsed
546
+ }
547
+ }
548
+
549
+ // Remove psi-header from extensions.json recommendations
550
+ const extPath = join(dir, ".vscode", "extensions.json");
551
+ if (existsSync(extPath)) {
552
+ try {
553
+ const ext = JSON.parse(readFileSync(extPath, "utf-8"));
554
+ if (ext.recommendations) {
555
+ ext.recommendations = ext.recommendations.filter((r: string) => r !== "psioniq.psi-header");
556
+ }
557
+ writeFileSync(extPath, `${JSON.stringify(ext, null, 2)}\n`);
558
+ } catch {
559
+ // Skip if can't be parsed
560
+ }
561
+ }
562
+ }
563
+
564
+ const licenseIndicators = ["File:", "Project:", "File Created:", "Author:", "Licensed under"];
565
+
566
+ function removeLicenseHeaders(dir: string) {
567
+ const files = findSourceFiles(dir);
568
+ for (const file of files) {
569
+ try {
570
+ const content = readFileSync(file, "utf-8");
571
+ // Check for block comment license header at start of file
572
+ const match = content.match(/^\s*\/\*[\s\S]*?\*\//);
573
+ if (match) {
574
+ const comment = match[0];
575
+ const isLicense = licenseIndicators.some((indicator) => comment.includes(indicator));
576
+ if (isLicense && match.index !== undefined) {
577
+ const cleaned = content.slice(match.index + comment.length).replace(/^\s*\n/, "");
578
+ writeFileSync(file, cleaned);
579
+ }
580
+ }
581
+ } catch {
582
+ // Skip files that can't be read
583
+ }
584
+ }
585
+
586
+ // Also handle hash-style license headers for shell/yaml/python files
587
+ const hashFiles = findHashCommentFiles(dir);
588
+ for (const file of hashFiles) {
589
+ try {
590
+ const content = readFileSync(file, "utf-8");
591
+ const lines = content.split("\n");
592
+ let headerEnd = 0;
593
+ let inHeader = false;
594
+
595
+ for (let i = 0; i < lines.length; i++) {
596
+ const line = lines[i];
597
+ if (i === 0 && line.startsWith("#!")) {
598
+ // Skip shebang
599
+ continue;
600
+ }
601
+ if (
602
+ line.startsWith("# File:") ||
603
+ line.startsWith("# Project:") ||
604
+ line.startsWith("# File Created:") ||
605
+ line.startsWith("# Author:")
606
+ ) {
607
+ inHeader = true;
608
+ headerEnd = i + 1;
609
+ continue;
610
+ }
611
+ if (inHeader && line.startsWith("#")) {
612
+ headerEnd = i + 1;
613
+ continue;
614
+ }
615
+ if (inHeader && line.trim() === "") {
616
+ headerEnd = i + 1;
617
+ break;
618
+ }
619
+ if (inHeader) break;
620
+ }
621
+
622
+ if (inHeader && headerEnd > 0) {
623
+ const shebang = lines[0].startsWith("#!") ? `${lines[0]}\n\n` : "";
624
+ const remaining = lines
625
+ .slice(headerEnd)
626
+ .join("\n")
627
+ .replace(/^\s*\n/, "");
628
+ writeFileSync(file, `${shebang}${remaining}`);
629
+ }
630
+ } catch {
631
+ // Skip files that can't be read
632
+ }
633
+ }
634
+ }
635
+
636
+ function findSourceFiles(dir: string): string[] {
637
+ const results: string[] = [];
638
+ const extensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]);
639
+ function walk(d: string) {
640
+ let entries: Dirent[];
641
+ try {
642
+ entries = readdirSync(d, { withFileTypes: true });
643
+ } catch {
644
+ return;
645
+ }
646
+ for (const entry of entries) {
647
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
648
+ const full = join(d, entry.name);
649
+ if (entry.isDirectory()) walk(full);
650
+ else {
651
+ const ext = `.${entry.name.split(".").pop()}`;
652
+ if (extensions.has(ext)) results.push(full);
653
+ }
654
+ }
655
+ }
656
+ walk(dir);
657
+ return results;
658
+ }
659
+
660
+ function findHashCommentFiles(dir: string): string[] {
661
+ const results: string[] = [];
662
+ const extensions = new Set([".sh", ".mk", ".yaml", ".yml", ".py"]);
663
+ function walk(d: string) {
664
+ let entries: Dirent[];
665
+ try {
666
+ entries = readdirSync(d, { withFileTypes: true });
667
+ } catch {
668
+ return;
669
+ }
670
+ for (const entry of entries) {
671
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
672
+ const full = join(d, entry.name);
673
+ if (entry.isDirectory()) walk(full);
674
+ else {
675
+ const ext = `.${entry.name.split(".").pop()}`;
676
+ if (extensions.has(ext) || entry.name === "Makefile") results.push(full);
677
+ }
678
+ }
679
+ }
680
+ walk(dir);
681
+ return results;
682
+ }