feat: launch dsh source with native TypeScript
This commit is contained in:
@@ -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<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, Promise<PackageManifest | undefined>>()
|
||||
|
||||
private constructor(tsconfigPath: string, paths: ts.MapLike<string[]>) {
|
||||
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<string | undefined> {
|
||||
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<boolean> {
|
||||
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<PackageManifest | undefined> {
|
||||
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<ResolveFnOutput>,
|
||||
): Promise<ResolveFnOutput> {
|
||||
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<string | undefined> {
|
||||
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
|
||||
}
|
||||
@@ -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" "$@"
|
||||
+3
-3
@@ -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"
|
||||
|
||||
@@ -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)`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']],
|
||||
])
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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<string, unknown>, file: string): void {
|
||||
if (file.startsWith('examples/') && typeof entry.name === 'string') {
|
||||
examplePluginReferences.push({ file, name: entry.name })
|
||||
}
|
||||
function recordPlugin(entry: Record<string, unknown>, 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<string, Set<string>>()
|
||||
|
||||
for (const reference of examplePluginReferences) {
|
||||
const packageName = packageNameFromSpecifier(reference.name)
|
||||
if (packageName === undefined) continue
|
||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||
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<Record<string, string>>,
|
||||
manifestPath: string,
|
||||
): string[] {
|
||||
const requiredPackages = new Map<string, Set<string>>()
|
||||
for (const reference of references) {
|
||||
const packageName = packageNameFromSpecifier(reference.name)
|
||||
if (packageName === undefined) continue
|
||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||
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<string> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user