diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts new file mode 100644 index 0000000000..e70f788f3d --- /dev/null +++ b/apps/cli/src/tsconfig-paths-loader.ts @@ -0,0 +1,199 @@ +/** + * Node module resolve hook for the `dsh` source launcher. It projects the root + * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax + * handling to Node's native transform-types runtime. + * @module @deepseek-ai/dsh/tsconfig-paths-loader + */ + +import { readFile, stat } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { ResolveHookContext, ResolveFnOutput } from 'node:module' +import ts from 'typescript' + +interface LoaderData { + tsconfigPath: string +} + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +interface PathRule { + pattern: string + prefix: string + suffix: string + targets: readonly string[] +} + +const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const + +/** Resolve package imports through one parsed tsconfig paths table. */ +export class TsconfigPathsResolver { + private readonly rules: readonly PathRule[] + private readonly configDirectory: string + private readonly manifests = new Map>() + + private constructor(tsconfigPath: string, paths: ts.MapLike) { + this.configDirectory = dirname(tsconfigPath) + this.rules = Object.entries(paths) + .map(([pattern, targets]) => { + const wildcard = pattern.indexOf('*') + return { + pattern, + prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard), + suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1), + targets, + } + }) + .sort((left, right) => { + const leftExact = left.pattern.includes('*') ? 0 : 1 + const rightExact = right.pattern.includes('*') ? 0 : 1 + return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length + }) + } + + /** + * Parse a tsconfig including its `extends` chain. + * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`. + * @returns A resolver backed by that path table. + */ + static create(tsconfigPath: string): TsconfigPathsResolver { + let unrecoverable: ts.Diagnostic | undefined + const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic }, + }) + if (parsed === undefined) { + const detail = unrecoverable === undefined + ? 'unknown configuration error' + : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n') + throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`) + } + const paths = parsed.options.paths + if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`) + return new TsconfigPathsResolver(tsconfigPath, paths) + } + + /** + * Resolve one bare package specifier to a source file when the importing + * package (or config-directory owner) declares that package at runtime. + * @param specifier Module specifier passed to Node. + * @param parentURL Importing file or Loader config-directory URL. + * @returns Source file URL, or `undefined` when normal Node resolution owns the request. + */ + async resolve(specifier: string, parentURL: string | undefined): Promise { + const packageName = packageNameFromSpecifier(specifier) + if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined + const matched = this.match(specifier) + if (matched === undefined) return undefined + const configParent = parentURL.endsWith('/') + const parentPath = fileURLToPath(parentURL) + const startDirectory = configParent ? parentPath : dirname(parentPath) + if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined + + for (const target of matched.targets) { + const substituted = target.replace('*', matched.wildcard) + const candidate = await existingSourcePath(resolve(this.configDirectory, substituted)) + if (candidate !== undefined) return pathToFileURL(candidate).href + } + return undefined + } + + private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined { + for (const rule of this.rules) { + if (!rule.pattern.includes('*')) { + if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' } + continue + } + if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue + const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length) + return { targets: rule.targets, wildcard } + } + return undefined + } + + private async isDeclaredRuntimeDependency( + startDirectory: string, + packageName: string, + searchAncestors: boolean, + ): Promise { + for (let directory = startDirectory; ; directory = dirname(directory)) { + const manifest = await this.readManifest(join(directory, 'package.json')) + if (manifest !== undefined) { + if (declaresRuntimeDependency(manifest, packageName)) return true + if (!searchAncestors) return false + } + const parent = dirname(directory) + if (parent === directory) return false + } + } + + private readManifest(path: string): Promise { + let pending = this.manifests.get(path) + if (pending !== undefined) return pending + pending = readFile(path, 'utf8').then( + content => JSON.parse(content) as PackageManifest, + (error: unknown) => { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + }, + ) + this.manifests.set(path, pending) + return pending + } +} + +let resolver: TsconfigPathsResolver | undefined + +/** Initialize the hook worker from the source-launch preloader. */ +export function initialize(data: LoaderData): void { + resolver = TsconfigPathsResolver.create(data.tsconfigPath) +} + +/** Resolve declared workspace packages to source and delegate every other request to Node. */ +export async function resolveHook( + specifier: string, + context: ResolveHookContext, + nextResolve: (specifier: string, context: ResolveHookContext) => Promise, +): Promise { + const url = await resolver?.resolve(specifier, context.parentURL) + return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true } +} + +// Node customization hooks discover this exact export name. +export { resolveHook as resolve } + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) { + return undefined + } + const segments = specifier.split('/') + return specifier.startsWith('@') + ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + : segments[0] || undefined +} + +function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean { + return manifest.name === packageName + || packageName in (manifest.dependencies ?? {}) + || packageName in (manifest.optionalDependencies ?? {}) + || packageName in (manifest.peerDependencies ?? {}) +} + +async function existingSourcePath(base: string): Promise { + const candidates = extname(base) === '' + ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))] + : [base] + for (const candidate of candidates) { + try { + if ((await stat(candidate)).isFile()) return candidate + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + return undefined +} diff --git a/bin/dsh b/bin/dsh index 040cfface2..319d915b18 100755 --- a/bin/dsh +++ b/bin/dsh @@ -1,7 +1,7 @@ #!/bin/sh -# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's -# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the -# current working tree — code changes apply on the next launch, no build step. +# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native +# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh) +# always executes the current working tree without a build step. set -eu # Resolve symlink chains without readlink -f (not on every macOS). @@ -15,7 +15,8 @@ while [ -L "$script" ]; do done root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) -# tsx is imported by absolute path because bare `--import tsx` resolves from -# the invoking cwd, which is usually outside this repository. -export TSX_TSCONFIG_PATH="$root/tsconfig.json" -exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@" +# The preloader projects this checkout's tsconfig paths into Node resolution; +# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook). +exec node --experimental-transform-types \ + --import "$root/scripts/tspath-loader.ts" \ + "$root/apps/cli/src/bin.ts" "$@" diff --git a/package.json b/package.json index a0fc4f780f..5134a597d8 100644 --- a/package.json +++ b/package.json @@ -95,13 +95,13 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "dsh": "node --import tsx apps/cli/src/bin.ts", + "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx apps/cli/src/bin.ts", + "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", + "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index df2d2b1ba4..4644912304 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process } /** - * After the tree settles, reject entries with no fiber, which indicates a - * swallowed module-import failure. Disabled entries are the only valid + * After the tree settles, reject entries with no fiber and name every plugin + * whose module failed to resolve. Disabled entries are the only valid * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. @@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) if (failed.length > 0) { const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`) + throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) } } diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 1118f10b96..d790fe15d5 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,14 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', [ + '--experimental-transform-types', + '--import', + './scripts/tspath-loader.ts', + 'apps/cli/src/bin.ts', + '--config', + 'examples/tui-agent/code-mode.cordis.yml', + ]], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/tspath-loader.ts b/scripts/tspath-loader.ts new file mode 100644 index 0000000000..adeecb6b64 --- /dev/null +++ b/scripts/tspath-loader.ts @@ -0,0 +1,14 @@ +/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */ + +import { register } from 'node:module' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined + ? fileURLToPath(new URL('../tsconfig.json', import.meta.url)) + : resolve(process.env.TSX_TSCONFIG_PATH) + +register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), { + parentURL: import.meta.url, + data: { tsconfigPath }, +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 6041be78ed..66d6e0f2a3 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,10 +1,10 @@ /** - * Validate Cordis Loader entry metadata and example package resolution. + * Validate Cordis Loader entry metadata and package resolution. * * The Loader interpolates only a plugin entry's `config`; expression objects in * fields such as `disabled` remain truthy data and silently change composition. - * Example configs run from built packages, so every named package must resolve - * from the examples workspace and every local package must be in the root + * Example configs and the dsh Web composition resolve named plugins from their + * owning workspace manifests. Local example packages must also be in the root * TypeScript project graph. */ @@ -42,7 +42,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType) const files = cordisConfigFiles(root) const errors: string[] = [] -const examplePluginReferences: PluginReference[] = [] +const pluginReferences: PluginReference[] = [] for (const file of files) { const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) @@ -56,9 +56,10 @@ for (const file of files) { } errors.push(...validateExampleResolution()) +errors.push(...validateAppResolution()) if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or example package resolution:') + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') for (const error of errors) console.error(`- ${error}`) process.exitCode = 1 } else { @@ -70,7 +71,7 @@ function validateEntry(value: unknown, file: string, path: string): void { errors.push(`${file}${path}: entry must be an object`) return } - recordExamplePlugin(value, file) + recordPlugin(value, file) validateMetadata(value, file, path) if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { @@ -84,7 +85,7 @@ function validateEntry(value: unknown, file: string, path: string): void { const patch = config.patches[index] const patchPath = `${path}.config.patches[${index}]` if (!isRecord(patch)) continue - recordExamplePlugin(patch, file) + recordPlugin(patch, file) validateMetadata(patch, file, patchPath) if (!isUnknownArray(patch.insert)) continue for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) { @@ -93,10 +94,8 @@ function validateEntry(value: unknown, file: string, path: string): void { } } -function recordExamplePlugin(entry: Record, file: string): void { - if (file.startsWith('examples/') && typeof entry.name === 'string') { - examplePluginReferences.push({ file, name: entry.name }) - } +function recordPlugin(entry: Record, file: string): void { + if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name }) } function validateExampleResolution(): string[] { @@ -105,25 +104,13 @@ function validateExampleResolution(): string[] { const dependencies = exampleManifest.dependencies ?? {} const localPackages = localPackageDirectories() const rootReferences = rootProjectReferences() - const requiredPackages = new Map>() - - for (const reference of examplePluginReferences) { - const packageName = packageNameFromSpecifier(reference.name) - if (packageName === undefined) continue - const locations = requiredPackages.get(packageName) ?? new Set() - locations.add(reference.file) - requiredPackages.set(packageName, locations) - } - - for (const [packageName, locations] of requiredPackages) { - if (!(packageName in dependencies)) { - violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`) - } - } + const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/')) + violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json')) + const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name))) const localExamplePackages = new Set([ ...Object.keys(dependencies), - ...requiredPackages.keys(), + ...[...requiredPackages].filter(packageName => packageName !== undefined), ]) for (const packageName of localExamplePackages) { const packageDirectory = localPackages.get(packageName) @@ -135,6 +122,30 @@ function validateExampleResolution(): string[] { return violations } +function validateAppResolution(): string[] { + const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml') + return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') +} + +function missingPluginDependencies( + references: readonly PluginReference[], + dependencies: Readonly>, + manifestPath: string, +): string[] { + const requiredPackages = new Map>() + for (const reference of references) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined) continue + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(reference.file) + requiredPackages.set(packageName, locations) + } + return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies + ? [] + : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) +} + function readManifest(path: string): PackageManifest { return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest } @@ -177,7 +188,7 @@ function rootProjectReferences(): Set { } function packageNameFromSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined const segments = specifier.split('/') if (specifier.startsWith('@')) { return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined