🌳
pt0/deployF/entrypointsF/registeredEpsAI.mts
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'
11import fs from 'fs'
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
23 // Static import: import { foo } from './bar.mjs'
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)))
28 }
29 }
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)))
36 }
37 }
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)
46 }
47 }
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
58 let ast
59 try {
60 ast = acorn.parse(parseContent, {
61 ecmaVersion: 'latest',
62 sourceType: 'module',
63 allowAwaitOutsideFunction: true,
64 })
65 } catch {
66 return [] // Parse error - skip this file
67 }
69 const importPaths: string[] = []
70 const baseDir = path.dirname(basePtPath)
71 walkAstForImports(ast, importPaths, baseDir)
73 return importPaths
76// Match files against a glob pattern (simple implementation: dir/*, dir/**/*.ext)
77const matchGlob = (globPath: string) => {
78 const absBase = ptDir
80 // Simple dir/* pattern - files directly in dir
81 if (globPath.endsWith('/*') && !globPath.includes('**')) {
82 const dirPath = globPath.slice(0, -2)
83 const absDirPath = pathDownJoin(absBase, dirPath)
84 assertExists(absDirPath, {globPath})
86 const files = fs.readdirSync(absDirPath)
87 return files
88 .filter(f => fs.statSync(pathDownJoin(absDirPath, f)).isFile())
89 .map(f => path.posix.join(dirPath, f))
90 }
92 // Recursive glob: dir/**/*.ext or dir/**/prefix*.ext or *.{ext1,ext2} (brace expansion)
93 const recursiveMatch = globPath.match(/^(.+?)\/\*\*\/([^/]*)\*(\.\{[\w,]+\}|\.\w+)$/)
94 if (recursiveMatch) {
95 const [, dirPath, prefix, extPattern] = recursiveMatch
96 const absDirPath = pathDownJoin(absBase, dirPath)
97 assertExists(absDirPath, {globPath})
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))
109 }
110 }
111 }
112 walk(absDirPath, '')
113 return results
114 }
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) {
128 const absFilePath = pathDownJoin(ptDir, ptPath)
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)
134 if (hasExt) {
135 resolvedEntries.push({ regEpPath: ptPath, epType: null })
136 continue
137 }
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]
150 }
152 if (resolvedPath) {
153 const absResolved = pathDownJoin(ptDir, resolvedPath)
154 if (!fs.existsSync(absResolved)) {
155 debugLog(`[globPath] Warning: ${ptPath} -> ${resolvedPath} not found`)
156 continue
157 }
158 resolvedEntries.push({ regEpPath: resolvedPath, epType: null })
159 }
160 }
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)
169 }
170 return null
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)
203 }
205 if (foundAppPaths.length > 1) {
206 throw new Error(
207 `[ptnode] Multiple appMarkerF.mjs imports found in import tree for ${scriptPath}:\n` +
208 foundAppPaths.map(p => ` - ${p}`).join('\n')
209 )
210 }
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]
230 }
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)) {
236 throw new Error(
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.`
240 )
241 }
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
257 const epType = inferEpTypeFromFile(regEpPath)
258 const dbQsName = (epType === 'eptCnPgDb' || epType === 'eptVanillaPg') ? inferDbQsNameFromFile(regEpPath) : null
259 byPath.set(regEpPath, { regEpPath, epType, dbQsName: dbQsName ?? null })
260 }
261 for (const scope of getRegistryScopes()) {
262 for (const { regEpPath } of resolveGlobPath(mkEpGlob(scope))) addEp(regEpPath)
263 }
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)
292 }
294 const entry = _.find(getRegistrySync(), { regEpPath })
295 if (!entry) return null
297 let appPath
298 try {
299 appPath = await inferAppPathFromFile(regEpPath, entry.epType)
300 } catch {
301 appPath = null
302 }
304 appPathCache.set(regEpPath, appPath)
305 return 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))