@soederpop/luca 0.0.26 → 0.0.29

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.
@@ -1,7 +1,7 @@
1
1
  import { setBuildTimeData, setContainerBuildTimeData } from './index.js';
2
2
 
3
3
  // Auto-generated introspection registry data
4
- // Generated at: 2026-03-22T20:57:23.113Z
4
+ // Generated at: 2026-03-24T01:41:39.159Z
5
5
 
6
6
  setBuildTimeData('features.containerLink', {
7
7
  "id": "features.containerLink",
@@ -3,7 +3,7 @@ import { FeatureEventsSchema, FeatureStateSchema, FeatureOptionsSchema } from '.
3
3
  import { State } from "../../state.js";
4
4
  import { Feature } from "../feature.js";
5
5
  import { parse, relative, join as pathJoin } from "path";
6
- import { statSync, readFileSync, existsSync } from "fs";
6
+ import { statSync, readFileSync, existsSync, readdirSync, lstatSync } from "fs";
7
7
  import micromatch from "micromatch";
8
8
  import { castArray } from "lodash-es";
9
9
  import chokidar from "chokidar";
@@ -339,7 +339,28 @@ export class FileManager<
339
339
  }
340
340
  }
341
341
  }
342
+
343
+ // git ls-files doesn't traverse symlinked directories — walk them via fs
344
+ // to pick up their contents. fs.walk now follows symlinks natively.
345
+ try {
346
+ const topEntries = readdirSync(cwd, { withFileTypes: true });
347
+ for (const entry of topEntries) {
348
+ if (entry.isSymbolicLink()) {
349
+ const fullPath = pathJoin(cwd, entry.name);
350
+ try {
351
+ const target = statSync(fullPath);
352
+ if (target.isDirectory()) {
353
+ const walked = await fs.walkAsync(fullPath, { exclude });
354
+ for (const absFile of walked.files) {
355
+ fileIds.push(relative(cwd, absFile));
356
+ }
357
+ }
358
+ } catch {}
359
+ }
360
+ }
361
+ } catch {}
342
362
  } else {
363
+ // fs.walkAsync follows symlinks, so non-git repos get symlink support for free
343
364
  await fs.walkAsync(cwd).then(({ files } : { files: string[] }) => fileIds.push(...files));
344
365
  }
345
366
 
@@ -9,11 +9,13 @@ import {
9
9
  readFileSync,
10
10
  cpSync,
11
11
  renameSync,
12
+ lstatSync,
13
+ realpathSync,
12
14
 
13
15
  rmSync as nodeRmSync,
14
16
  } from "fs";
15
17
  import { join, resolve, dirname, relative } from "path";
16
- import { readFile, stat, unlink, mkdir, writeFile, appendFile, readdir, cp, rename, rm as nodeRm } from "fs/promises";
18
+ import { readFile, stat, lstat, realpath, unlink, mkdir, writeFile, appendFile, readdir, cp, rename, rm as nodeRm } from "fs/promises";
17
19
  import { native as rimraf } from 'rimraf'
18
20
 
19
21
  type WalkOptions = {
@@ -40,6 +42,16 @@ function matchesPattern(filePath: string, patterns: string[]): boolean {
40
42
  })
41
43
  }
42
44
 
45
+ /** Sync: check if a symlink target is a directory */
46
+ function lstatFollowIsDir(fullPath: string): boolean {
47
+ try { return statSync(fullPath).isDirectory() } catch { return false }
48
+ }
49
+
50
+ /** Async: check if a symlink target is a directory */
51
+ async function lstatFollowIsDirAsync(fullPath: string): Promise<boolean> {
52
+ try { return (await stat(fullPath)).isDirectory() } catch { return false }
53
+ }
54
+
43
55
  /**
44
56
  * The FS feature provides methods for interacting with the file system, relative to the
45
57
  * container's cwd.
@@ -472,6 +484,28 @@ export class FS extends Feature {
472
484
  return stat(filePath).then(() => true).catch(() => false)
473
485
  }
474
486
 
487
+ /**
488
+ * Checks if a path is a symbolic link.
489
+ *
490
+ * @param {string} path - The path to check
491
+ * @returns {boolean} True if the path is a symlink
492
+ */
493
+ isSymlink(path: string): boolean {
494
+ const filePath = this.container.paths.resolve(path);
495
+ try { return lstatSync(filePath).isSymbolicLink() } catch { return false }
496
+ }
497
+
498
+ /**
499
+ * Resolves a symlink to its real path. Returns the resolved path as-is if not a symlink.
500
+ *
501
+ * @param {string} path - The path to resolve
502
+ * @returns {string} The real path after resolving all symlinks
503
+ */
504
+ realpath(path: string): string {
505
+ const filePath = this.container.paths.resolve(path);
506
+ return realpathSync(filePath)
507
+ }
508
+
475
509
  /**
476
510
  * Synchronously returns the stat object for a file or directory.
477
511
  *
@@ -811,7 +845,9 @@ export class FS extends Feature {
811
845
  const fullPath = join(baseDir, name);
812
846
  const relativePath = relative(resolvedBase, fullPath)
813
847
  const outputPath = useRelative ? relativePath : fullPath;
814
- const isDir = entry.isDirectory();
848
+ // Follow symlinks: isDirectory() returns false for symlinks,
849
+ // so check isSymbolicLink() and resolve to the real target
850
+ const isDir = entry.isDirectory() || (entry.isSymbolicLink() && lstatFollowIsDir(fullPath));
815
851
 
816
852
  if (excludePatterns.length && matchesPattern(relativePath, excludePatterns)) {
817
853
  continue
@@ -888,7 +924,9 @@ export class FS extends Feature {
888
924
  const fullPath = join(currentDir, name);
889
925
  const relativePath = relative(resolvedBase, fullPath)
890
926
  const outputPath = useRelative ? relativePath : fullPath;
891
- const isDir = entry.isDirectory();
927
+ // Follow symlinks: isDirectory() returns false for symlinks,
928
+ // so check isSymbolicLink() and resolve to the real target
929
+ const isDir = entry.isDirectory() || (entry.isSymbolicLink() && await lstatFollowIsDirAsync(fullPath));
892
930
 
893
931
  if (excludePatterns.length && matchesPattern(relativePath, excludePatterns)) {
894
932
  continue
@@ -438,24 +438,22 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
438
438
  }
439
439
  } catch {}
440
440
 
441
- // Try fileManager first (faster in git repos), fall back to Glob
441
+ // Try fileManager first (faster in git repos, and now symlink-aware),
442
+ // fall back to fs.walk which also follows symlinks natively.
442
443
  let files: string[] = []
443
444
  try {
444
445
  const fm = await this.ensureFileManager()
445
- // fileManager may store absolute or relative keys — use absolute patterns
446
446
  const absPatterns = [`${dir}/*.ts`, `${dir}/**/*.ts`]
447
447
  const relPatterns = [`${type}/*.ts`, `${type}/**/*.ts`]
448
448
  const matched = fm.match([...absPatterns, ...relPatterns])
449
449
  files = matched.map((f: string) => f.startsWith('/') ? f : resolve(this.rootDir, f))
450
450
  } catch {}
451
451
 
452
- // Fall back to Glob if fileManager found nothing
452
+ // Fall back to fs.walk if fileManager found nothing
453
453
  if (files.length === 0) {
454
- const { Glob } = globalThis.Bun || (await import('bun'))
455
- const glob = new Glob('**/*.ts')
456
- for await (const file of glob.scan({ cwd: dir })) {
457
- files.push(resolve(dir, file))
458
- }
454
+ const { fs } = this.container
455
+ const walked = fs.walk(dir, { include: ['**/*.ts'] })
456
+ files = walked.files
459
457
  }
460
458
 
461
459
  for (const absPath of files) {
@@ -550,10 +548,15 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
550
548
  */
551
549
  private async discoverCommandsViaVM(dir: string): Promise<void> {
552
550
  this.seedVirtualModules()
553
- const { Glob } = globalThis.Bun || (await import('bun'))
554
- const glob = new Glob('*.ts')
555
-
556
- for await (const file of glob.scan({ cwd: dir })) {
551
+ const { fs } = this.container
552
+ // Commands are top-level only (not recursive) — walk and filter to *.ts in the immediate dir
553
+ const walked = fs.walk(dir, { include: ['*.ts'] })
554
+ const tsFiles = walked.files
555
+ .map(f => parse(f))
556
+ .filter(p => p.dir === dir) // top-level only
557
+ .map(p => p.base)
558
+
559
+ for (const file of tsFiles) {
557
560
  if (file === 'index.ts') continue
558
561
 
559
562
  const absPath = resolve(dir, file)
@@ -619,10 +622,14 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
619
622
  */
620
623
  private async discoverSelectorsViaVM(dir: string): Promise<void> {
621
624
  this.seedVirtualModules()
622
- const { Glob } = globalThis.Bun || (await import('bun'))
623
- const glob = new Glob('*.ts')
625
+ const { fs } = this.container
626
+ const walked = fs.walk(dir, { include: ['*.ts'] })
627
+ const tsFiles = walked.files
628
+ .map(f => parse(f))
629
+ .filter(p => p.dir === dir)
630
+ .map(p => p.base)
624
631
 
625
- for await (const file of glob.scan({ cwd: dir })) {
632
+ for (const file of tsFiles) {
626
633
  if (file === 'index.ts') continue
627
634
 
628
635
  const absPath = resolve(dir, file)
@@ -661,10 +668,10 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
661
668
  * Actual mounting to an express server is handled separately by ExpressServer.useEndpoints().
662
669
  */
663
670
  private async discoverEndpoints(dir: string): Promise<void> {
664
- const { Glob } = globalThis.Bun || (await import('bun'))
665
- const glob = new Glob('**/*.ts')
671
+ const { fs } = this.container
672
+ const walked = fs.walk(dir, { include: ['**/*.ts'] })
666
673
 
667
- for await (const file of glob.scan({ cwd: dir, absolute: true })) {
674
+ for (const file of walked.files) {
668
675
  try {
669
676
  const mod = await this.loadModuleExports(file)
670
677
  const endpointModule = mod.default || mod