/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, resolve, sep } from "node:path"; import { Effect, Schema } from "effect"; import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover"; import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; import { AppSourceError, type AppSourceSnapshot } from "./app-source"; import { classifyAppSourcePath, isRelevantAppSourcePath, type SourceSkippedFile, } from "./relevant-files"; export interface LocalDirectoryAppSourceInput { readonly path: string; } export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { readonly root: string; readonly skipped: readonly SourceSkippedFile[]; } export interface LocalDirectoryDirsInput { readonly path?: string; readonly includeHidden?: boolean; } export interface LocalDirectoryDirEntry { readonly name: string; readonly path: string; readonly isSymlink: boolean; readonly hasTools: boolean; } export interface LocalDirectorySourceShape { readonly toolFiles: readonly string[]; readonly skipped: readonly string[]; readonly hasPackageJson: boolean; } export interface LocalDirectoryDirsResult { readonly path: string; readonly parent: string | null; readonly dirs: readonly LocalDirectoryDirEntry[]; readonly source: LocalDirectorySourceShape; } const sha256 = (bytes: Uint8Array | string): string => createHash("sha256").update(bytes).digest("hex"); export const validateLocalDirectoryPath = (path: string): Effect.Effect => Effect.try({ try: () => { if (!isAbsolute(path)) { throw new AppSourceError({ message: "local-directory source path must be absolute", path }); } const parts = path.split(/[\\/]+/); if (parts.includes("..")) { throw new AppSourceError({ message: "local-directory source path must not contain ..", path, }); } return resolve(path); }, catch: (cause) => cause instanceof AppSourceError ? cause : new AppSourceError({ message: "invalid local-directory source path", path, cause }), }); const isUnderRoot = (root: string, candidate: string): boolean => candidate === root || candidate.startsWith(`${root}${sep}`); const readRegularFileNoFollow = ( root: string, rootReal: string, child: string, ): Effect.Effect => Effect.tryPromise({ try: async () => { const fullPath = `${root}${sep}${child}`; const before = await lstat(fullPath); if (!before.isFile()) throw new Error("not a regular file"); const resolved = await realpath(fullPath); if (!isUnderRoot(rootReal, resolved)) throw new Error("file resolves outside source root"); const handle = await open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW); try { const after = await handle.stat(); if (!after.isFile()) throw new Error("not a regular file"); if (before.dev !== after.dev || before.ino !== after.ino) { throw new Error("file changed while being read"); } return await handle.readFile(); } finally { await handle.close(); } }, catch: (cause) => new AppSourceError({ message: `failed to read local-directory file: ${child}`, path: child, cause, }), }); const walk = ( root: string, rootReal: string, relative = "", ): Effect.Effect< { readonly files: readonly PublishFile[]; readonly skipped: readonly SourceSkippedFile[] }, AppSourceError | PublishError > => Effect.gen(function* () { const dir = relative ? `${root}${sep}${relative}` : root; const entries = yield* Effect.tryPromise({ try: () => readdir(dir, { withFileTypes: true }), catch: (cause) => new AppSourceError({ message: `failed to read local-directory source: ${relative || root}`, path: relative || root, cause, }), }); const files: PublishFile[] = []; const skipped: SourceSkippedFile[] = []; for (const entry of entries) { const child = relative ? `${relative}/${entry.name}` : entry.name; if (entry.isSymbolicLink()) { skipped.push({ path: child, reason: "unsupported file type" }); continue; } if (entry.isDirectory()) { if ( child === "tools" || child === "workflows" || child === "ui" || child === "skills" || child.startsWith("tools/") || child.startsWith("workflows/") || child.startsWith("ui/") || child.startsWith("skills/") ) { const nested = yield* walk(root, rootReal, child); files.push(...nested.files); skipped.push(...nested.skipped); } continue; } if (!entry.isFile()) { skipped.push({ path: child, reason: "unsupported file type" }); continue; } const classified = classifyAppSourcePath(child); if (classified !== "fetch") { skipped.push(classified); continue; } if (!isRelevantAppSourcePath(child)) continue; const bytes = yield* readRegularFileNoFollow(root, rootReal, child); files.push({ path: child, bytes }); } const limitError = enforcePublishLimits(files); if (limitError) return yield* limitError; return { files, skipped }; }); const sourceRefFor = (files: readonly PublishFile[]): string => { const manifest = files .map((file) => `${file.path}:${sha256(file.bytes)}`) .sort() .join("\n"); return sha256(manifest); }; const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); const descriptionFor = ( files: readonly PublishFile[], ): Effect.Effect => Effect.gen(function* () { const file = files.find((item) => item.path === "executor.json"); if (!file) return undefined; const parsed = yield* decodeExecutorJson(new TextDecoder().decode(file.bytes)).pipe( Effect.mapError( (cause) => new AppSourceError({ message: "executor.json is not valid JSON", path: "executor.json", cause, }), ), ); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const description = (parsed as { readonly description?: unknown }).description; return typeof description === "string" ? description : undefined; }); const lstatOrNull = (path: string): Effect.Effect> | null> => Effect.tryPromise({ try: () => lstat(path), catch: (cause) => new AppSourceError({ message: "failed to stat local-directory path", path, cause }), }).pipe(Effect.catch(() => Effect.succeed(null))); const hasNonSymlinkDirectory = (path: string): Effect.Effect => Effect.gen(function* () { const entry = yield* lstatOrNull(path); return entry !== null && entry.isDirectory() && !entry.isSymbolicLink(); }); const hasNonSymlinkFile = (path: string): Effect.Effect => Effect.gen(function* () { const entry = yield* lstatOrNull(path); return entry !== null && entry.isFile() && !entry.isSymbolicLink(); }); const listLocalDirectorySourceShape = ( root: string, ): Effect.Effect => Effect.gen(function* () { const toolsPath = `${root}${sep}tools`; const toolsStat = yield* lstatOrNull(toolsPath); const toolFiles: string[] = []; if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) { const toolEntries = yield* Effect.tryPromise({ try: () => readdir(toolsPath, { withFileTypes: true }), catch: (cause) => new AppSourceError({ message: "failed to read local-directory tools folder", path: toolsPath, cause, }), }); for (const entry of toolEntries) { if (!entry.isFile()) continue; if (toolKeyFromPath(`tools/${entry.name}`) === null) continue; toolFiles.push(entry.name); } toolFiles.sort((a, b) => a.localeCompare(b)); } const skipped: string[] = []; for (const name of RESERVED_ARTIFACT_DIRS) { if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name); } return { toolFiles: toolFiles.slice(0, 20), skipped, hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`), }; }); export const fetchLocalDirectoryAppSource = ( input: LocalDirectoryAppSourceInput, ): Effect.Effect => Effect.gen(function* () { const root = yield* validateLocalDirectoryPath(input.path); const rootReal = yield* Effect.tryPromise({ try: () => realpath(root), catch: (cause) => new AppSourceError({ message: "failed to resolve local-directory source root", path: root, cause, }), }); const collected = yield* walk(root, rootReal); return { root, files: collected.files, sourceRef: sourceRefFor(collected.files), description: yield* descriptionFor(collected.files), skipped: collected.skipped, }; }); export const listLocalDirectoryDirs = ( input: LocalDirectoryDirsInput, ): Effect.Effect => Effect.gen(function* () { const root = yield* validateLocalDirectoryPath(input.path?.trim() || homedir()); const rootStat = yield* Effect.tryPromise({ try: () => lstat(root), catch: (cause) => new AppSourceError({ message: "failed to read local-directory source", path: root, cause, }), }); if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { return yield* new AppSourceError({ message: "local-directory source path must be a directory", path: root, }); } const entries = yield* Effect.tryPromise({ try: () => readdir(root), catch: (cause) => new AppSourceError({ message: "failed to read local-directory source", path: root, cause, }), }); const dirs: LocalDirectoryDirEntry[] = []; const source = yield* listLocalDirectorySourceShape(root); for (const name of entries) { if (!input.includeHidden && name.startsWith(".")) continue; const child = `${root}${sep}${name}`; const entryStat = yield* Effect.tryPromise({ try: () => lstat(child), catch: (cause) => new AppSourceError({ message: "failed to stat local-directory child", path: child, cause, }), }).pipe(Effect.catch(() => Effect.succeed(null))); if (!entryStat) continue; if (entryStat.isDirectory()) { dirs.push({ name, path: child, isSymlink: false, hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`), }); continue; } if (!entryStat.isSymbolicLink()) continue; const targetStat = yield* Effect.tryPromise({ try: () => stat(child), catch: (cause) => new AppSourceError({ message: "failed to stat local-directory symlink target", path: child, cause, }), }).pipe(Effect.catch(() => Effect.succeed(null))); if (targetStat?.isDirectory()) { dirs.push({ name, path: child, isSymlink: true, hasTools: false }); } } dirs.sort((a, b) => a.name.localeCompare(b.name)); const parent = dirname(root); return { path: root, parent: parent === root ? null : parent, dirs, source, }; }); export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ fetch: () => fetchLocalDirectoryAppSource(input), });