/** * agent/webmaker-inventory.mjs — 기존 Webmaker 사이트 디렉터리를 HAL 서버 인벤토리로 정리한다. * * 운영 정보만 보낸다. 페이지 본문, 프롬프트, 사용자 연락처, 인증정보와 비밀값은 결과에 * 넣지 않는다. 사이트 작업 디렉터리도 존재할 수 있으므로 배포 이력을 함께 읽어 상태를 * deployed/unpublished/draft/unknown으로 구분한다. 메타데이터 없는 디렉터리도 누락하지 않는다. */ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const SITE_ID = /^[a-f0-9]{32}$/; const text = (v, max = 240) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max) : null; function domainsOf(site) { const src = Array.isArray(site?.domains) ? site.domains : []; return [...new Set(src.map((d) => typeof d === 'string' ? d : d?.domain || d?.hostname || d?.host || d?.name) .map((d) => text(d, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100); } function treeStat(root, errors, siteId) { let bytes = 0; let files = 0; let complete = true; const stack = [root]; const note = (e) => { complete = false; const code = e?.code || 'read_failed'; const marker = `site:${siteId}:${code}`; if (!errors.includes(marker)) errors.push(marker); }; while (stack.length) { const dir = stack.pop(); let entries = []; try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { note(e); continue; } for (const e of entries) { const p = join(dir, e.name); try { if (e.isSymbolicLink()) continue; if (e.isDirectory()) stack.push(p); else if (e.isFile()) { bytes += lstatSync(p).size; files += 1; } } catch (error) { note(error); } } } return { bytes, files, complete }; } function deploymentState(site) { if (!site) return 'unknown'; if (site.unpublishedAt) return 'unpublished'; const history = Array.isArray(site.deployments) ? site.deployments : []; const lastLifecycle = [...history].reverse().find((d) => d && ((d.kind === 'unpublish' && d.ok !== false) || (d.ok && d.kind !== 'unpublish' && (d.ready === true || d.phase === 'ready' || d.phase === 'transferred' || !d.phase)))); if (lastLifecycle?.kind === 'unpublish') return 'unpublished'; if (lastLifecycle || site.deployedAt || site.publishedAt) return 'deployed'; return 'draft'; } export function scanWebmakerInventory({ dataRoot, dataDir = null, sitesRoot, maxSites = Infinity } = {}) { const metaRoot = dataDir || join(dataRoot || '', 'sites'); let dirs = []; let metadataIds = []; let complete = true; const errors = []; try { dirs = readdirSync(sitesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && SITE_ID.test(e.name)).map((e) => e.name); } catch (e) { complete = false; errors.push(`sites:${e.code || 'read_failed'}`); } try { metadataIds = readdirSync(metaRoot, { withFileTypes: true }).filter((e) => e.isFile() && SITE_ID.test(e.name.replace(/\.json$/i, '')) && /\.json$/i.test(e.name)).map((e) => e.name.slice(0, -5)); } catch (e) { complete = false; errors.push(`metadata:${e.code || 'read_failed'}`); } const deployed = new Set(dirs); const ids = [...new Set([...dirs, ...metadataIds])].sort(); const limit = Number.isFinite(maxSites) && maxSites >= 0 ? Math.floor(maxSites) : ids.length; const selected = ids.slice(0, limit); const sites = selected.map((id) => { let site = null; let metadata = 'missing'; const metadataFile = join(metaRoot, `${id}.json`); try { site = JSON.parse(readFileSync(metadataFile, 'utf8')); if (!site || typeof site !== 'object' || Array.isArray(site)) { site = null; metadata = 'invalid'; const marker = `metadata:${id}:invalid_shape`; if (!errors.includes(marker)) errors.push(marker); } else metadata = 'ok'; } catch (e) { if (e?.code === 'ENOENT' && !existsSync(metadataFile)) metadata = 'missing'; else { metadata = 'invalid'; const marker = `metadata:${id}:${e?.code || 'invalid_json'}`; if (!errors.includes(marker)) errors.push(marker); } } const size = deployed.has(id) ? treeStat(join(sitesRoot, id), errors, id) : { bytes: 0, files: 0, complete: true }; if (!size.complete) complete = false; return { id, title: text(site?.title || site?.name || id, 200), ownerId: text(site?.ownerId, 128), status: text(site?.status, 80), deploymentState: deploymentState(site), domains: domainsOf(site), updatedAt: text(site?.updatedAt || site?.deployedAt || site?.publishedAt, 40), metadata, bytes: size.bytes, files: size.files, }; }); if (sites.length < ids.length) complete = false; return { dataRoot: dataRoot || null, dataDir: metaRoot || null, sitesRoot: sitesRoot || null, total: ids.length, returned: sites.length, truncated: sites.length < ids.length, errors, complete, scannedAt: new Date().toISOString(), sites, }; } /** 운영 경로는 Agent unit에 명시적으로 주입한다. systemd 설정을 추측·재해석해 다른 데이터 루트를 스캔하지 않는다. */ export function configuredWebmakerInventory(facts, env = process.env) { if (!facts?.units?.webmaker) return facts; const dataRoot = env.ARIWEB_WEBMAKER_DATA_ROOT; const dataDir = env.ARIWEB_WEBMAKER_DATA_DIR; const sitesRoot = env.ARIWEB_WEBMAKER_SITES_ROOT; if (![dataRoot, dataDir, sitesRoot].every((p) => typeof p === 'string' && p.startsWith('/'))) return { ...facts, webmakerInventory: { dataRoot: null, dataDir: null, sitesRoot: null, total: 0, returned: 0, truncated: false, errors: ['configuration:ARIWEB_WEBMAKER_DATA_ROOT_DATA_DIR_SITES_ROOT_required'], complete: false, scannedAt: new Date().toISOString(), sites: [] } }; return { ...facts, webmakerInventory: scanWebmakerInventory({ dataRoot, dataDir, sitesRoot }) }; } /** HAL JSON 본문 상한과 무관하게 전체 사이트를 byte·항목 수 기준 배치로 나눈다. */ export function webmakerInventoryBatches(inv, maxBytes = 512 * 1024) { if (!Number.isInteger(maxBytes) || maxBytes < 64 * 1024) throw new Error('인벤토리 배치 한도가 너무 작습니다'); if (inv?.complete === false) throw new Error('inventory_incomplete'); const sites = Array.isArray(inv?.sites) ? inv.sites : []; const rawErrors = Array.isArray(inv?.errors) ? inv.errors : []; const meta = { dataRoot: String(inv?.dataRoot || '').slice(0, 4096), dataDir: String(inv?.dataDir || '').slice(0, 4096), sitesRoot: String(inv?.sitesRoot || '').slice(0, 4096), total: Number.isFinite(Number(inv?.total)) ? Number(inv.total) : sites.length, truncated: !!inv?.truncated, errors: rawErrors.slice(0, 1000).map((x) => String(x).slice(0, 500)), errorsTruncated: rawErrors.length > 1000, scannedAt: String(inv?.scannedAt || new Date().toISOString()).slice(0, 80), }; const scanId = meta.scannedAt || String(Date.now()); const out = []; let offset = 0; let part = []; const body = (items) => ({ scanId, offset, itemTotal: sites.length, meta, sites: items }); for (const site of sites) { const candidate = [...part, site]; if (candidate.length > 1000 || Buffer.byteLength(JSON.stringify(body(candidate))) > maxBytes) { if (!part.length) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); out.push(body(part)); offset += part.length; part = [site]; if (Buffer.byteLength(JSON.stringify(body(part))) > maxBytes) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); } else part = candidate; } if (part.length || !sites.length) { const last = body(part); if (Buffer.byteLength(JSON.stringify(last)) > maxBytes) throw new Error('Webmaker 인벤토리 메타데이터가 배치 한도를 넘습니다'); out.push(last); } return out; }