🌳
pt0/deployF/hostnameF/derivedHostnameAI.mts
1import { scryptSync } from 'node:crypto'
2import fs from 'fs'
3import * as _ from 'lodash-es'
4import { fileURLToPath } from 'url'
5import { styleText } from 'node:util'
15import { tsAbsPath } from '../../ptDirF.mts'
18const codeExtRe = extRe(ptFileExtA)
20const derivedHostnameSeedName = 'derived-hostname-seed'
22// Core derivation function - derives hostname from name + wcDomain using master secret
23export const getDerivedHostname = async ({name, wcDomain}: {name: string, wcDomain: string}) => {
24 const seed = await genPlainSecretIfMissing({secretName: derivedHostnameSeedName, autoYes: true})
26 const hash = scryptSync(name, seed, 16).toString('hex')
27 return `${name}-${hash}.${wcDomain}`
30// Sync variant for scaffold codegen — unhashed fallback when no seed exists (e.g. OSS clone)
31export const derivedHostnameSync = ({name, wcDomain}: {name: string, wcDomain: string}): string => {
32 const seed = getGenPlainSec(derivedHostnameSeedName)
33 if (!seed) return `${name}.${wcDomain}`
34 return `${name}-${scryptSync(name, seed, 16).toString('hex')}.${wcDomain}`
37// Parse hostname to extract derivation params
38// Format: <name>-<hash>.<wcDomain> (hashed) or <name>.<wcDomain> (unhashed)
39const parseHostname = (hostname: string) => {
40 const dotIdx = hostname.indexOf('.')
41 if (dotIdx === -1) return null
42 const firstPart = hostname.slice(0, dotIdx)
43 const wcDomain = hostname.slice(dotIdx + 1)
44 // Check if already hashed: name-<32-char-hex>
45 const hashMatch = firstPart.match(/^(.+)-([a-f0-9]{32})$/)
46 if (hashMatch) return {name: hashMatch[1], wcDomain}
47 // Unhashed: just <name>
48 return {name: firstPart, wcDomain}
51// Scan file for // derivedhn lines, re-derive, update if needed
52export const doUpdateDerivedHostnames = async ({filePath}: {filePath: string}) => {
53 const content = fs.readFileSync(filePath, 'utf8')
54 const lines = content.split('\n')
55 const updates: {from: string, to: string}[] = []
57 for (let i = 0; i < lines.length; i++) {
58 const line = lines[i]
59 if (!line.endsWith('// derivedhn')) continue
61 const quoteMatch = line.match(/'([^']*)'/)
62 if (!quoteMatch) continue
64 const currentHostname = quoteMatch[1]
65 const parsed = parseHostname(currentHostname)
66 if (!parsed) {
67 console.log(`WARN: cannot parse hostname: ${currentHostname}`)
68 continue
69 }
71 const derivedHostname = await getDerivedHostname(parsed)
72 if (currentHostname === derivedHostname) continue
74 lines[i] = line.replace(`'${currentHostname}'`, `'${derivedHostname}'`)
75 updates.push({from: currentHostname, to: derivedHostname})
76 }
78 if (updates.length === 0) return {filePath, updated: false}
80 fs.writeFileSync(filePath, lines.join('\n'))
81 return {filePath, updated: true, updates}
84// Get all files in ep's import tree (ES imports + Next.js pages/app + their transitive imports)
85// Uses filtered paths (excludes devpconlyF) since this is for deploy validation
86const getEpImportTreePaths = async (regEpPath: string) => {
87 const {pathsA: esImportPaths} = await epsToAllPathsForDocker({epPtPathsA: [regEpPath]})
88 const {getVirtualPathsFnc} = getAppCfg()
89 if (!getVirtualPathsFnc) return esImportPaths
90 const virtualImportPaths = await getVirtualPathsFnc()
91 // Also trace transitive imports of virtual imports (pages/app files)
92 const jsVirtualPaths = virtualImportPaths.filter(p => codeExtRe.test(p))
93 const {pathsA: virtualTransitivePaths} = jsVirtualPaths.length > 0
94 ? await epsToAllPathsForDocker({epPtPathsA: jsVirtualPaths})
95 : {pathsA: []}
96 return _.uniq([...esImportPaths, ...virtualImportPaths, ...virtualTransitivePaths])
99// Check for stale derived hostnames without updating - returns array of stale entries
100export const checkDerivedHostnames = async ({regEpPath}: {regEpPath: string}) => {
101 const allPaths = await getEpImportTreePaths(regEpPath)
102 const staleEntries: {filePath: string, current: string, expected: string}[] = []
104 for (const relPath of allPaths) {
105 const absPath = `${ptDir}/${relPath}`
106 if (!fs.existsSync(absPath)) continue
107 const content = fs.readFileSync(absPath, 'utf8')
108 if (!content.includes('// derivedhn')) continue
110 const lines = content.split('\n')
111 for (const line of lines) {
112 if (!line.endsWith('// derivedhn')) continue
114 const quoteMatch = line.match(/'([^']*)'/)
115 if (!quoteMatch) continue
117 const currentHostname = quoteMatch[1]
118 const parsed = parseHostname(currentHostname)
119 if (!parsed) continue
121 const derivedHostname = await getDerivedHostname(parsed)
122 if (currentHostname !== derivedHostname) {
123 staleEntries.push({filePath: relPath, current: currentHostname, expected: derivedHostname})
124 }
125 }
126 }
128 return staleEntries
131// Warn or throw if derived hostnames are stale during apply
132export const warnOrThrowStaleHostnames = async ({regEpPath, action}: {regEpPath: string, action: string}) => {
133 const staleEntries = await checkDerivedHostnames({regEpPath})
134 if (staleEntries.length === 0) return
136 const msg = `Stale derived hostnames:\n${staleEntries.map(e => ` ${e.filePath}: ${e.current} -> ${e.expected}`).join('\n')}\nRun 'rotatewchn' action to fix.`
138 if (action === 'apply') {
139 assertEmpty(staleEntries, {staleEntries})
140 } else {
141 console.log(styleText('yellow', `WARN: ${msg}`))
142 }
145// CLI action - scans ep import tree for // derivedhn and updates
146export const rotatewchn = async () => {
147 const { importMetaUrl } = getAppCfg()
148 assertDefined(importMetaUrl)
149 const regEpPath = absPathToPtPath(tsAbsPath(fileURLToPath(importMetaUrl)))
151 const allPaths = await getEpImportTreePaths(regEpPath)
153 let totalUpdates = 0, filesWithMarkers = 0
154 for (const relPath of allPaths) {
155 const absPath = `${ptDir}/${relPath}`
156 if (!fs.existsSync(absPath)) continue
157 const content = fs.readFileSync(absPath, 'utf8')
158 if (!content.split('\n').some(line => line.endsWith('// derivedhn'))) continue
159 filesWithMarkers++
161 const result = await doUpdateDerivedHostnames({filePath: absPath})
162 if (result.updated) {
163 betLog({updated: relPath, changes: result.updates})
164 totalUpdates += result.updates!.length
165 }
166 }
168 if (filesWithMarkers === 0) console.log('no derived hostnames in this ep')
169 else console.log(totalUpdates === 0 ? 'all derived hostnames up to date' : `updated ${totalUpdates} hostname(s)`)
171rotatewchn.cliDescript = 'validate/update derived hostnames in import tree'