1// Registered entrypoint registry - derived purely from filename convention + globs 2// Used by codegraph to resolve "virtual imports" (files included via deploy, not ES import) 10import * as _ from 'lodash-es' 12import path from 'path' 13import * as acorn from 'acorn' 15// Use console.error for debug messages to avoid corrupting OpenCode MCP tool output 16// (console.log goes to stdout which interferes with tool result formatting) 17const debugLog = (msg: string) => console.error(msg) 19// Recursively walk AST to find all import paths (static and dynamic) 20const walkAstForImports = (node: any, importPaths: string[], baseDir: string) => { 21 if (!node || typeof node !== 'object') return 24 if (node.type === 'ImportDeclaration' && node.source?.value) { 25 const importPath = node.source.value 26 if (importPath.startsWith('.')) { 27 importPaths.push(path.posix.normalize(path.posix.join(baseDir, importPath))) 31 // Dynamic import: import('./bar.mjs') or await import('./bar.mjs') 32 if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && typeof node.source.value === 'string') { 33 const importPath = node.source.value 34 if (importPath.startsWith('.')) { 35 importPaths.push(path.posix.normalize(path.posix.join(baseDir, importPath))) 39 // Recurse into child nodes 40 for (const key of Object.keys(node)) { 41 const child = node[key] 42 if (Array.isArray(child)) { 43 for (const item of child) walkAstForImports(item, importPaths, baseDir) 44 } else if (child && typeof child === 'object' && child.type) { 45 walkAstForImports(child, importPaths, baseDir) 50// Sync import extractor using acorn - extracts all import paths (static and dynamic) from JS/TS content 51const extractImportPathsSync = (content: string, basePtPath: string) => { 52 const shebang = content.split('\n')[0] || '' 53 if (shebang.startsWith('#!/bin/sh') || shebang.startsWith('#!/bin/bash')) return [] 55 // Strip shebang for acorn parsing 56 const parseContent = content.startsWith('#!') ? content.replace(/^#![^\n]*\n/, '') : content 60 ast = acorn.parse(parseContent, { 61 ecmaVersion: 'latest', 63 allowAwaitOutsideFunction: true, 66 return [] // Parse error - skip this file 69 const importPaths: string[] = [] 70 const baseDir = path.dirname(basePtPath) 71 walkAstForImports(ast, importPaths, baseDir) 76// Match files against a glob pattern (simple implementation: dir/*, dir/**/*.ext) 77const matchGlob = (globPath: string) => { 80 // Simple dir/* pattern - files directly in dir 81 if (globPath.endsWith('/*') && !globPath.includes('**')) { 82 const dirPath = globPath.slice(0, -2) 86 const files = fs.readdirSync(absDirPath) 89 .map(f => path.posix.join(dirPath, f)) 92 // Recursive glob: dir/**/*.ext or dir/**/prefix*.ext or *.{ext1,ext2} (brace expansion) 93 const recursiveMatch = globPath.match(/^(.+?)\/\*\*\/([^/]*)\*(\.\{[\w,]+\}|\.\w+)$/) 95 const [, dirPath, prefix, extPattern] = recursiveMatch 98 const prefixRegex = prefix ? new RegExp('^' + prefix) : null 99 const braceMatch = extPattern.match(/^\.\{([\w,]+)\}$/), extA: string[] = braceMatch ? braceMatch[1].split(',').map((e: string) => '.' + e) : [extPattern] 100 const results: string[] = [] 101 const walk = (dir: string, relDir: string) => { 102 const entries = fs.readdirSync(dir, { withFileTypes: true }) 103 for (const entry of entries) { 104 const relPath = relDir ? `${relDir}/${entry.name}` : entry.name 105 if (entry.isDirectory()) { 106 if (!['node_modules', '.git', 'tmp', '.next', 'dist'].includes(entry.name)) walk(pathDownJoin(dir, entry.name), relPath) 107 } else if (entry.isFile() && extA.some((ext: string) => entry.name.endsWith(ext))) { 108 if (!prefixRegex || prefixRegex.test(entry.name)) results.push(path.posix.join(dirPath, relPath)) 116 throw new Error(`[registeredEps] unsupported glob pattern: ${globPath}`) 119// Resolve globPath entry to actual regEpPath entries 120// For path_bin/* style (extensionless CLI scripts): follows import indirection 121// For **/*.ext style: treats matched files directly as entrypoints 122type RegEntry = { regEpPath: string, epType: string | null, dbQsName?: string | null } 123const resolveGlobPath = (globPath: string) => { 124 const matchedFiles = matchGlob(globPath) 125 const resolvedEntries: RegEntry[] = [] 127 for (const ptPath of matchedFiles) { 129 const content = fs.readFileSync(absFilePath, 'utf8') 130 const shebang = content.split('\n')[0] || '' 132 // Files with extensions (e.g. .ts, .mjs) are direct entrypoints 133 const hasExt = /\.\w+$/.test(ptPath) 135 resolvedEntries.push({ regEpPath: ptPath, epType: null }) 139 // Extensionless files (path_bin style): follow indirection 140 let resolvedPath: string | null = null 142 if (shebang.startsWith('#!/bin/sh') || shebang.startsWith('#!/bin/bash')) { 143 // Shell script: look for `ptnode <path>` 144 const shellMatch = content.match(/ptnode\s+(\S+)/) 145 if (shellMatch) resolvedPath = shellMatch[1] 146 } else if (shebang.includes('node') || shebang.includes('ptnode')) { 147 // Node script: use acorn to extract first import (primary entrypoint) 148 const imports = extractImportPathsSync(content, ptPath) 149 if (imports.length > 0) resolvedPath = imports[0] 154 if (!fs.existsSync(absResolved)) { 155 debugLog(`[globPath] Warning: ${ptPath} -> ${resolvedPath} not found`) 158 resolvedEntries.push({ regEpPath: resolvedPath, epType: null }) 162 return resolvedEntries 165const inferMonorSubdirFromPath = (regEpPath: string) => { 166 for (const binDir of ['/bin/', '/binF/']) { 167 const idx = regEpPath.indexOf(binDir) 168 if (idx !== -1) return regEpPath.slice(0, idx) 173// ep-types that require appPath to be resolvable 174const epTypesRequiringAppPath = ['eptNextjsApp'] 176const extractAppPathFromMarkerImport = (content: string, scriptPath: string) => { 177 const markerImport = extractImportPathsSync(content, scriptPath).find(p => p.endsWith('/appMarkerF.mjs')) 178 return markerImport?.replace(/\/appMarkerF\.mjs$/, '') ?? null 181// Follow imports transitively to find appMarkerF.mjs 182// Throws if multiple different appMarkers found in import tree 183const findAppMarkerTransitively = (scriptPath: string, visited = new Set<string>()): string | null => { 184 if (visited.has(scriptPath)) return null 185 visited.add(scriptPath) 187 const absPath = scriptPath.startsWith('/') ? scriptPath : [ptDir, scriptPath].join('/') 188 if (!fs.existsSync(absPath)) return null 190 const content = fs.readFileSync(absPath, 'utf8') 192 const direct = extractAppPathFromMarkerImport(content, scriptPath) 193 if (direct) return direct 195 const imports = extractImportPathsSync(content, scriptPath) 196 const foundAppPaths: string[] = [] 198 for (const importPath of imports) { 199 if (!importPath.endsWith('.mjs')) continue 201 const result: string | null = findAppMarkerTransitively(importPath, visited) 202 if (result && !foundAppPaths.includes(result)) foundAppPaths.push(result) 205 if (foundAppPaths.length > 1) { 207 `[ptnode] Multiple appMarkerF.mjs imports found in import tree for ${scriptPath}:\n` + 208 foundAppPaths.map(p => ` - ${p}`).join('\n') 212 return foundAppPaths[0] || null 215// Infer appPath from entrypoint file: appMarkerF import (preferred) → explicit export 216// (legacy) → path-based inference (only for epTypes not requiring appPath). 217// epTypes in epTypesRequiringAppPath fail-fast if no marker found. 218const inferAppPathFromFile = async (scriptPath: string, epType: string | null) => { 219 const absPath = scriptPath.startsWith('/') ? scriptPath : [ptDir, scriptPath].join('/') 221 const fromMarker = findAppMarkerTransitively(scriptPath) 222 if (fromMarker) return fromMarker 224 if (fs.existsSync(absPath)) { 225 const content = fs.readFileSync(absPath, 'utf8') 227 // 2. Look for explicit export (legacy, still supported) 228 const match = content.match(/export\s+const\s+appPath\s*=\s*['"]([^'"]+)['"]/) 229 if (match) return match[1] 232 // epTypes requiring appPath must use appMarkerF.mjs — no path-based fallback. 233 // Locks the invariant: single source of truth for both static analysis and runtime 234 // (setPtDirForImportMetaUrlF.mts also consumes appMarker). 235 if (epType && epTypesRequiringAppPath.includes(epType)) { 237 `[ptnode] Cannot infer appPath for ${scriptPath} (epType: ${epType}).\n` + 238 ` Add to the entrypoint file: import { appMarker } from '<app-path>/appMarkerF.mjs'\n` + 239 ` and pass appMarker in the ept* config object.` 243 // Path-based fallback for epTypes that don't strictly require appPath 244 return inferMonorSubdirFromPath(scriptPath) 247// Lazily loaded registry (populated on first access) 248let registeredEpsA: RegEntry[] | null = null 250// Pure glob-based discovery: ep*.{mjs,mts} per scope + pt0/path_bin/* + .opencode/**/*.ts 251// epType inferred via static analysis (ept* factory call); dbQsName only for db epTypes. 252export const getRegistrySync = () => { 253 if (registeredEpsA) return registeredEpsA 254 const byPath = new Map<string, RegEntry>() 255 const addEp = (regEpPath: string) => { 256 if (byPath.has(regEpPath)) return 259 byPath.set(regEpPath, { regEpPath, epType, dbQsName: dbQsName ?? null }) 262 for (const { regEpPath } of resolveGlobPath(mkEpGlob(scope))) addEp(regEpPath) 264 for (const { regEpPath } of resolveGlobPath('pt0/path_bin/*')) addEp(regEpPath) 265 for (const { regEpPath } of resolveGlobPath('.opencode/**/*.ts')) addEp(regEpPath) 266 registeredEpsA = [...byPath.values()] 267 return registeredEpsA 270export const getRegisteredEpsRegistry = () => getRegistrySync() 272// Get all deploy entrypoints (auto-removes non-existent files from in-memory cache) 273export const getAllRegisteredEps = () => { 274 const allScripts = _.map(getRegistrySync(), 'regEpPath') 275 return _.filter(allScripts, (script) => fs.existsSync(pathDownJoin(ptDir, script))) 278// Cache for computed appPaths (regEpPath -> appPath | Promise<appPath>) 279const appPathCache = new Map() 281// Bust the session-scoped registry + appPath caches. REQUIRED at the entry of any correctness-critical 282// reader (commit/deploy/codegraph): registeredEpsA is populated once per process, so in a long-running 283// host (e.g. the opencode MCP server) it never sees files added mid-session → false "not reachable". 284// Callers are infrequent (human-speed), so the re-walk on next access is negligible vs their 285// madge/acorn costs; internal loops within one operation stay warm after the first re-walk. 286export const resetRegistryCache = () => { registeredEpsA = null; appPathCache.clear() } 288// Get appPath for a deploy script (computed on-demand, cached) 289export const getMonorSubdirForRegEp = async (regEpPath: string) => { 290 if (appPathCache.has(regEpPath)) { 291 return appPathCache.get(regEpPath) 294 const entry = _.find(getRegistrySync(), { regEpPath }) 295 if (!entry) return null 299 appPath = await inferAppPathFromFile(regEpPath, entry.epType) 304 appPathCache.set(regEpPath, appPath) 308// Get ep-type for a deploy script 309export const getEpType = (regEpPath: string) => { 310 const entry = _.find(getRegistrySync(), { regEpPath }) 311 return entry?.epType || null 314// Get db entrypoints (epCnPg/eptVanillaPg) that serve a specific dbQsName 315export const getDbEntrypointsByQsName = (dbQsName: string) => { 316 return _.filter(getRegistrySync(), (entry) => entry.dbQsName === dbQsName) 319// Filter entrypoints by directory prefix (e.g., 'pt0' -> all pt0/* eps) 320export const filterEpsByDir = (epA: string[], epDir?: string) => { 321 if (!epDir) return epA 322 const prefix = epDir.endsWith('/') ? epDir : epDir + '/' 323 return epA.filter((ep: string) => ep.startsWith(prefix))