Skip to content

Commit 0f98fe9

Browse files
committed
fix(paths): findMarkdownFilesRecursive follows symlinks (closes #1501)
`Dirent.isFile()` returns false for symlinks, so symlinked `.md` files in the search root were silently skipped. This made team-shared command directories that get exposed to Archon via symlinks (a `~/.archon/commands/foo.md` → `/path/to/team-repo/...` pattern) unusable — workflow nodes referencing those commands failed with `Command prompt not found` even though the file was present and readable. Workflow discovery in `loadWorkflowsFromDir` already handles this correctly by stat()-ing each entry; this brings command discovery in line. Implementation: when a `Dirent` is a symlink, follow it with `stat()` and use the target's `isFile()` / `isDirectory()` for the branch decision. Broken symlinks are skipped silently (matches the existing tolerance for missing search dirs at the top of the function). Tests cover: symlinked file in the search root, mixed regular + symlinked entries in the same dir, symlinked subdirectory, broken symlink. All four exercise the new branch.
1 parent 3d290d8 commit 0f98fe9

2 files changed

Lines changed: 92 additions & 3 deletions

File tree

packages/paths/src/archon-paths.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import {
3636
resolveProjectRootFromCwd,
3737
ensureProjectStructure,
3838
createProjectSourceSymlink,
39+
findMarkdownFilesRecursive,
3940
} from './archon-paths';
41+
import { symlink as fsSymlink } from 'fs/promises';
4042

4143
/** All env vars that path functions depend on */
4244
const ENV_VARS = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME'];
@@ -776,3 +778,69 @@ describe('createProjectSourceSymlink', () => {
776778
expect(stats.isSymbolicLink()).toBe(true);
777779
});
778780
});
781+
782+
// ─────────────────────────────────────────────────────────────────────────
783+
// findMarkdownFilesRecursive — symlink handling
784+
//
785+
// Regression tests for #1501: symlinked .md files (e.g. team-shared
786+
// commands made available via `~/.archon/commands/foo.md` → repo path)
787+
// must be discovered, not silently ignored.
788+
// ─────────────────────────────────────────────────────────────────────────
789+
790+
describe.skipIf(isWindows)('findMarkdownFilesRecursive — symlinks', () => {
791+
let tempRoot: string;
792+
let realRepo: string;
793+
794+
beforeEach(async () => {
795+
const ts = Date.now() + Math.random();
796+
tempRoot = join(tmpdir(), `archon-find-md-${ts}`);
797+
realRepo = join(tmpdir(), `archon-find-md-source-${ts}`);
798+
await mkdir(tempRoot, { recursive: true });
799+
await mkdir(realRepo, { recursive: true });
800+
});
801+
802+
afterEach(async () => {
803+
await rm(tempRoot, { recursive: true, force: true });
804+
await rm(realRepo, { recursive: true, force: true });
805+
});
806+
807+
test('finds .md file reached via symlink in the search root', async () => {
808+
// Real source file in a "team repo"-shaped location
809+
await writeFile(join(realRepo, 'team-cmd.md'), '# team command');
810+
811+
// Symlink it into the search root (same shape as sync-to-archon.sh)
812+
await fsSymlink(join(realRepo, 'team-cmd.md'), join(tempRoot, 'team-cmd.md'));
813+
814+
const found = await findMarkdownFilesRecursive(tempRoot);
815+
expect(found.map(e => e.commandName)).toContain('team-cmd');
816+
});
817+
818+
test('mixes regular files and symlinks in the same directory', async () => {
819+
await writeFile(join(tempRoot, 'plain.md'), '# plain');
820+
await writeFile(join(realRepo, 'linked.md'), '# linked');
821+
await fsSymlink(join(realRepo, 'linked.md'), join(tempRoot, 'linked.md'));
822+
823+
const found = await findMarkdownFilesRecursive(tempRoot);
824+
const names = found.map(e => e.commandName).sort();
825+
expect(names).toEqual(['linked', 'plain']);
826+
});
827+
828+
test('descends into a symlinked directory of .md files', async () => {
829+
const realSubdir = join(realRepo, 'group');
830+
await mkdir(realSubdir, { recursive: true });
831+
await writeFile(join(realSubdir, 'inside.md'), '# inside');
832+
833+
await fsSymlink(realSubdir, join(tempRoot, 'group'));
834+
835+
const found = await findMarkdownFilesRecursive(tempRoot);
836+
expect(found.map(e => e.commandName)).toContain('inside');
837+
});
838+
839+
test('skips broken symlinks silently', async () => {
840+
await writeFile(join(tempRoot, 'real.md'), '# real');
841+
await fsSymlink(join(realRepo, 'gone.md'), join(tempRoot, 'broken.md'));
842+
843+
const found = await findMarkdownFilesRecursive(tempRoot);
844+
expect(found.map(e => e.commandName)).toEqual(['real']);
845+
});
846+
});

packages/paths/src/archon-paths.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { join, dirname, normalize, basename } from 'path';
1818
import { homedir } from 'os';
19-
import { access, mkdir, symlink, lstat, readdir, readlink, rm } from 'fs/promises';
19+
import { access, mkdir, symlink, lstat, readdir, readlink, rm, stat } from 'fs/promises';
2020
import { createLogger } from './logger';
2121

2222
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
@@ -238,7 +238,28 @@ export async function findMarkdownFilesRecursive(
238238
continue;
239239
}
240240

241-
if (entry.isDirectory()) {
241+
// Resolve the entry's actual type, following symlinks so that team-shared
242+
// command/workflow files made available via symlink (e.g. `~/.archon/commands/foo.md`
243+
// → `/path/to/team-repo/commands/foo.md`) are picked up. `Dirent.isFile()` /
244+
// `Dirent.isDirectory()` both return false for symlinks, so without the
245+
// stat() they would be silently skipped.
246+
let isDir: boolean;
247+
let isFile: boolean;
248+
if (entry.isSymbolicLink()) {
249+
try {
250+
const targetStat = await stat(join(fullPath, entry.name));
251+
isDir = targetStat.isDirectory();
252+
isFile = targetStat.isFile();
253+
} catch {
254+
// Broken symlink — skip silently
255+
continue;
256+
}
257+
} else {
258+
isDir = entry.isDirectory();
259+
isFile = entry.isFile();
260+
}
261+
262+
if (isDir) {
242263
// Skip descending if we're already at the depth cap — files at deeper
243264
// levels are silently ignored (matches the convention that `.archon/*/`
244265
// folders support one level of grouping like `defaults/`).
@@ -249,7 +270,7 @@ export async function findMarkdownFilesRecursive(
249270
options
250271
);
251272
results.push(...subResults);
252-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
273+
} else if (isFile && entry.name.endsWith('.md')) {
253274
results.push({
254275
commandName: basename(entry.name, '.md'),
255276
relativePath: join(relativePath, entry.name),

0 commit comments

Comments
 (0)