UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

540 lines (538 loc) • 21.2 kB
import { isPlainObject, mergeBosConfigWithTemplate, resolveExtendsRef } from "../merge.mjs"; import { loadAppDescriptorConfig } from "../config.mjs"; import { computeSnapshotHash } from "../utils/snapshot-hash.mjs"; import { readSnapshot, writeSnapshot } from "./snapshot.mjs"; import { buildChildAgentsMd, buildChildRootScripts, extractSkillsBlock, personalizeConfig, resolveSourceDir, runPnpmInstall, runTypesGen } from "./init.mjs"; import { openResolution } from "../resolution/session.mjs"; import { findWorkspaceRoot } from "../workspace.mjs"; import { syncResolvedSharedDeps } from "../shared-deps.mjs"; import { materializeViaLayer } from "../infra/materializer.mjs"; import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { glob } from "glob"; import { parse } from "yaml"; //#region src/cli/sync.ts const FRAMEWORK_OWNED_SYNC_FILES = /* @__PURE__ */ new Set([ ".env.example", ".gitignore", "biome.json", "bos.config.json", "package.json", ".changeset/config.json", ".changeset/README.md", ".github/workflows/ci.yml", ".github/workflows/deploy.yml", ".github/workflows/staging.yml", ".github/workflows/release.yml", "railway.toml", "ui/package.json", "ui/postcss.config.mjs", "ui/tsconfig.json", "api/package.json", "api/plugin.dev.ts", "api/rspack.config.js", "api/tsconfig.json", "api/src/lib/context.ts", "api/drizzle.config.ts", "api/src/db/index.ts", "api/src/db/layer.ts", "api/src/db/migrate.ts", "api/src/global.d.ts", "api/tests/types.d.ts" ]); /** * Files the scaffold previously sync-owned but has retired (ADR 0023) — the * generated `.gen` bootstrap stubs replaced them. During sync: a local copy * whose hash still matches the sync snapshot is deleted silently; a * hand-modified (or never-snapshotted) copy is backed up, then deleted. The * app's authored ui files (router.tsx, app.ts, lib, routes, components) are * NOT retired — they leave the sync list as app-owned files, untouched. */ const RETIRED_SYNC_FILES = /* @__PURE__ */ new Set([ "ui/src/entry.ts", "ui/src/globals.d.ts", "ui/src/hydrate.tsx", "ui/src/router.server.tsx", "ui/src/compose.ts" ]); const AGENT_SYNC_GLOBS = [ ".agents/skills/**", "docs/agents/**", "skills-lock.json" ]; function isFrameworkOwnedSyncFile(filePath) { if (FRAMEWORK_OWNED_SYNC_FILES.has(filePath)) return true; if (filePath.startsWith(".agents/skills/") || filePath.startsWith("docs/agents/") || filePath === "skills-lock.json") return true; if (/^plugins\/[^/]+\/src\/lib\/context\.ts$/.test(filePath)) return true; if (/^plugins\/[^/]+\/src\/db\/(index|layer|migrate)\.ts$/.test(filePath)) return true; if (/^plugins\/[^/]+\/rspack\.config\.js$/.test(filePath)) return true; if (/^plugins\/[^/]+\/drizzle\.config\.ts$/.test(filePath)) return true; if (/^plugins\/[^/]+\/src\/global\.d\.ts$/.test(filePath)) return true; if (/^plugins\/[^/]+\/tests\/types\.d\.ts$/.test(filePath)) return true; if (/^plugins\/[^/]+\/tsconfig\.json$/.test(filePath)) return true; if (/^plugins\/[^/]+\/tsconfig\.contract\.json$/.test(filePath)) return true; return false; } function computeLocalHash(projectDir, filePath) { const fullPath = join(projectDir, filePath); if (!existsSync(fullPath)) return null; try { const content = readFileSync(fullPath); return computeSnapshotHash(content); } catch { return null; } } function backupFiles(projectDir, filePaths) { const filesToBackup = filePaths.filter((f) => existsSync(join(projectDir, f))); if (filesToBackup.length === 0) return null; const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const backupDir = join(projectDir, ".bos", "sync-backup", timestamp); for (const filePath of filesToBackup) { const src = join(projectDir, filePath); const dest = join(backupDir, filePath); mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); } return backupDir; } /** * Migrate a child off the retired bootstrap stub files (ADR 0023): unmodified * copies are deleted silently, hand-modified copies are backed up first. In * dry-run the actions are reported without touching the tree. */ function retireSyncedFiles(projectDir, snapshotFiles, opts) { const retired = []; const retiredConflicted = []; for (const destPath of RETIRED_SYNC_FILES) { const localHash = computeLocalHash(projectDir, destPath); if (localHash === null) continue; if (localHash === snapshotFiles[destPath]) retired.push(destPath); else retiredConflicted.push(destPath); } if (opts.dryRun || retired.length === 0 && retiredConflicted.length === 0) return { retired, retiredConflicted, backupDir: null }; const backupDir = backupFiles(projectDir, retiredConflicted); for (const destPath of [...retired, ...retiredConflicted]) rmSync(join(projectDir, destPath)); return { retired, retiredConflicted, backupDir }; } function mergeStringMaps(local, template) { if (!local && !template) return void 0; const merged = { ...local }; for (const [name, value] of Object.entries(template ?? {})) merged[name] = value; return Object.keys(merged).length > 0 ? merged : void 0; } function mergeWorkspacePackages(local, template) { const localPackages = Array.isArray(local) ? local : []; const templatePackages = Array.isArray(template) ? template : []; if (localPackages.length === 0 && templatePackages.length === 0) return void 0; const ordered = /* @__PURE__ */ new Set(); for (const entry of templatePackages) if (typeof entry === "string" && entry.length > 0) ordered.add(entry); for (const entry of localPackages) if (typeof entry === "string" && entry.length > 0) ordered.add(entry); if ([...ordered].some((e) => e.startsWith("plugins/") && e !== "plugins/*")) { for (const entry of ordered) if (entry.startsWith("plugins/") && entry !== "plugins/*") ordered.delete(entry); ordered.add("plugins/*"); } return ordered.size > 0 ? [...ordered] : void 0; } function mergePackageJson(filePath, local, template, childScripts) { const merged = { ...local, ...template }; if (filePath === "package.json") { for (const key of [ "name", "private", "version" ]) if (key in local) merged[key] = local[key]; } else if ("version" in local) merged.version = local.version; for (const depField of [ "dependencies", "devDependencies", "peerDependencies", "overrides" ]) { const localDeps = local[depField]; const templateDeps = template[depField]; const mergedDeps = mergeStringMaps(localDeps, templateDeps); if (mergedDeps) merged[depField] = mergedDeps; else delete merged[depField]; } if (local.scripts && typeof local.scripts === "object" || template.scripts && typeof template.scripts === "object" || childScripts) { const mergedScripts = mergeStringMaps(local.scripts, filePath === "package.json" && childScripts ? childScripts : template.scripts); if (mergedScripts) merged.scripts = mergedScripts; else delete merged.scripts; } if (local.workspaces && typeof local.workspaces === "object" || template.workspaces && typeof template.workspaces === "object") { const localWorkspaces = local.workspaces ?? {}; const templateWorkspaces = template.workspaces ?? {}; const mergedWorkspaces = { ...localWorkspaces, ...templateWorkspaces }; const mergedPackages = mergeWorkspacePackages(localWorkspaces.packages, templateWorkspaces.packages); if (mergedPackages) mergedWorkspaces.packages = mergedPackages; else delete mergedWorkspaces.packages; const mergedCatalog = mergeStringMaps(localWorkspaces.catalog, templateWorkspaces.catalog); if (mergedCatalog) mergedWorkspaces.catalog = mergedCatalog; else delete mergedWorkspaces.catalog; if (Object.keys(mergedWorkspaces).length > 0) merged.workspaces = mergedWorkspaces; else delete merged.workspaces; } return merged; } function toSourcePath(sourceDir, destPath) { if (destPath.startsWith(".github/")) { const templatePath = destPath.replace(/^\.github\//, ".github/templates/"); if (existsSync(join(sourceDir, templatePath))) return templatePath; } const directPath = join(sourceDir, destPath); if (existsSync(directPath)) return destPath; return null; } function buildSyncedFileContent(sourceDir, projectDir, filePath, explicitDestPath, childScripts) { const src = join(sourceDir, filePath); const destPath = explicitDestPath ?? (filePath.startsWith(".github/templates/") ? filePath.replace(/^\.github\/templates\//, ".github/") : filePath); const dest = join(projectDir, destPath); if (filePath.endsWith("bos.config.json")) { const localContent = existsSync(dest) ? readFileSync(dest, "utf-8") : null; const templateContent = readFileSync(src, "utf-8"); if (localContent) { const local = JSON.parse(localContent); const template = JSON.parse(templateContent); const merged = mergeBosConfigWithTemplate(local, template); return `${JSON.stringify(merged, null, 2)}\n`; } } if (filePath.endsWith("package.json")) { const localContent = existsSync(dest) ? readFileSync(dest, "utf-8") : null; const templateContent = readFileSync(src, "utf-8"); if (localContent) { const merged = mergePackageJson(destPath, JSON.parse(localContent), JSON.parse(templateContent), childScripts); return `${JSON.stringify(merged, null, 2)}\n`; } } return readFileSync(src); } function writeSyncedFile(sourceDir, projectDir, filePath, explicitDestPath, childScripts) { const destPath = explicitDestPath ?? (filePath.startsWith(".github/templates/") ? filePath.replace(/^\.github\/templates\//, ".github/") : filePath); const dest = join(projectDir, destPath); mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, buildSyncedFileContent(sourceDir, projectDir, filePath, destPath, childScripts)); } async function getSelectedChildPlugins(projectDir, localConfig) { if (!localConfig.plugins || typeof localConfig.plugins !== "object") return []; const pluginDirs = new Set((await glob("plugins/*/package.json", { cwd: projectDir, nodir: true, dot: false, absolute: false })).map((file) => file.match(/^plugins\/([^/]+)\/package\.json$/)?.[1]).filter((key) => Boolean(key))); const selected = []; for (const [pluginKey, rawEntry] of Object.entries(localConfig.plugins)) { if (typeof rawEntry === "string") { if (!rawEntry.startsWith("local:")) { selected.push(pluginKey); continue; } const localPath = join(projectDir, rawEntry.slice(6).trim()); if (existsSync(localPath) || pluginDirs.has(pluginKey)) selected.push(pluginKey); continue; } if (!rawEntry || typeof rawEntry !== "object") { selected.push(pluginKey); continue; } const entry = rawEntry; const development = typeof entry.development === "string" ? entry.development : void 0; if (!development?.startsWith("local:")) { selected.push(pluginKey); continue; } const localPath = join(projectDir, development.slice(6).trim()); if (existsSync(localPath) || pluginDirs.has(pluginKey)) selected.push(pluginKey); } return selected; } function hasPluginsWorkspace(projectDir) { const root = findWorkspaceRoot(projectDir); if (!root) return false; const rel = relative(root.dir, projectDir); const baseDir = rel.startsWith("..") || rel === "" ? root.dir : projectDir; for (const manifest of ["pnpm-workspace.yaml", "package.json"]) { const manifestPath = join(baseDir, manifest); if (!existsSync(manifestPath)) continue; try { const content = readFileSync(manifestPath, "utf-8"); const packages = manifest === "pnpm-workspace.yaml" ? parse(content)?.packages : JSON.parse(content).workspaces; if ((Array.isArray(packages) ? packages : Array.isArray(packages?.packages) ? packages.packages ?? [] : []).includes("plugins/*")) return true; } catch { return false; } } return false; } async function syncTemplate(projectDir, options) { const localConfig = existsSync(join(projectDir, "bos.app.ts")) ? await loadAppDescriptorConfig(join(projectDir, "bos.app.ts")) : JSON.parse(readFileSync(join(projectDir, "bos.config.json"), "utf-8")); let extendsRef; if (typeof localConfig.extends === "string") extendsRef = localConfig.extends; else if (isPlainObject(localConfig.extends)) extendsRef = resolveExtendsRef(localConfig.extends, "production"); if (!extendsRef?.startsWith("bos://")) return { status: "error", updated: [], skipped: [], added: [], conflicted: [], error: "No extends field found in bos.config.json — cannot determine parent" }; const extendsMatch = extendsRef.match(/^bos:\/\/([^/]+)\/(.+)$/); if (!extendsMatch) return { status: "error", updated: [], skipped: [], added: [], conflicted: [], error: `Invalid extends reference: ${extendsRef}` }; const extendsAccount = extendsMatch[1]; const extendsGateway = extendsMatch[2]; const { sourceDir, cleanup } = await resolveSourceDir({ extendsAccount, extendsGateway }); try { const childPlugins = await getSelectedChildPlugins(projectDir, localConfig); const withUi = existsSync(join(projectDir, "ui", "package.json")); const withApi = existsSync(join(projectDir, "api", "package.json")); const withHost = existsSync(join(projectDir, "host", "package.json")); const withPlugins = childPlugins.length > 0 || hasPluginsWorkspace(projectDir); const childScripts = buildChildRootScripts({ ui: withUi, api: withApi, host: withHost, plugins: withPlugins }); const destToSource = /* @__PURE__ */ new Map(); const tsFormChild = !existsSync(join(projectDir, "bos.config.json")); for (const destPath of FRAMEWORK_OWNED_SYNC_FILES) { if (destPath === "bos.config.json" && tsFormChild) continue; if (destPath.startsWith("ui/") && !withUi) continue; if (destPath.startsWith("api/") && !withApi) continue; if (destPath.startsWith("host/") && !withHost) continue; const sourcePath = toSourcePath(sourceDir, destPath); if (!sourcePath) continue; destToSource.set(destPath, sourcePath); } for (const destPath of await glob(AGENT_SYNC_GLOBS, { cwd: sourceDir, nodir: true, dot: true, absolute: false, ignore: [ "**/node_modules/**", "**/.git/**", "**/dist/**", "**/.bos/**" ] })) destToSource.set(destPath, destPath); for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey, "src"))) continue; for (const libFile of ["auth.ts", "context.ts"]) { const sourceFile = `api/src/lib/${libFile}`; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/src/lib/${libFile}`, sourceFile); } } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey, "src"))) continue; const sourceFile = "api/src/global.d.ts"; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/src/global.d.ts`, sourceFile); } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey))) continue; const sourceFile = "plugins/_template/tests/types.d.ts"; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/tests/types.d.ts`, sourceFile); } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey, "src", "db"))) continue; for (const dbFile of [ "index.ts", "layer.ts", "migrate.ts" ]) { const sourceFile = `api/src/db/${dbFile}`; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/src/db/${dbFile}`, sourceFile); } } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey))) continue; const sourceFile = "plugins/_template/rspack.config.js"; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/rspack.config.js`, sourceFile); } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey, "src", "db"))) continue; const sourceFile = "api/drizzle.config.ts"; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/drizzle.config.ts`, sourceFile); } for (const pluginKey of childPlugins) { if (!existsSync(join(projectDir, "plugins", pluginKey))) continue; const sourceFile = "plugins/_template/tsconfig.json"; if (!existsSync(join(sourceDir, sourceFile))) continue; destToSource.set(`plugins/${pluginKey}/tsconfig.json`, sourceFile); } const updated = []; const skipped = []; const added = []; const conflicted = []; const snapshotFiles = (await readSnapshot(projectDir))?.files ?? {}; for (const [destPath, filePath] of destToSource.entries()) { if (!existsSync(join(sourceDir, filePath))) { skipped.push(destPath); continue; } const localHash = computeLocalHash(projectDir, destPath); const sourceHash = computeSnapshotHash(buildSyncedFileContent(sourceDir, projectDir, filePath, void 0, childScripts)); if (localHash === null) { added.push(destPath); continue; } if (localHash === sourceHash) { skipped.push(destPath); continue; } const snapshotHash = snapshotFiles[destPath]; if (!snapshotHash) { updated.push(destPath); continue; } if (localHash === snapshotHash) { updated.push(destPath); continue; } if (sourceHash === snapshotHash) { skipped.push(destPath); continue; } conflicted.push(destPath); } const account = localConfig.account || extendsAccount; const domain = localConfig.domain || extendsGateway; const overrides = []; if (withUi) overrides.push("ui"); if (withApi) overrides.push("api"); if (withHost) overrides.push("host"); if (withPlugins) overrides.push("plugins"); if (options.dryRun) { const agentsMdSourcePath = toSourcePath(sourceDir, "AGENTS.md"); if (agentsMdSourcePath) { const agentsMdSourceContent = readFileSync(join(sourceDir, agentsMdSourcePath), "utf-8"); const skillsBlock = extractSkillsBlock(agentsMdSourceContent); if (skillsBlock) { const expectedContent = buildChildAgentsMd(skillsBlock, { overrides, plugins: childPlugins }); const expectedHash = computeSnapshotHash(expectedContent); if (computeLocalHash(projectDir, "AGENTS.md") !== expectedHash) updated.push("AGENTS.md"); } } const retirement = retireSyncedFiles(projectDir, snapshotFiles, { dryRun: true }); return { status: "dry-run", updated, skipped, added, conflicted, retired: retirement.retired, retiredConflicted: retirement.retiredConflicted }; } const retirement = retireSyncedFiles(projectDir, snapshotFiles, { dryRun: false }); const filesToWrite = [ ...updated, ...added, ...conflicted ]; let backupDir = retirement.backupDir ?? void 0; if (filesToWrite.length > 0) { backupDir = backupFiles(projectDir, filesToWrite) ?? backupDir; for (const destPath of filesToWrite) writeSyncedFile(sourceDir, projectDir, destToSource.get(destPath) ?? destPath, destPath, childScripts); } await personalizeConfig(projectDir, { extendsAccount, extendsGateway, account, domain, overrides, plugins: childPlugins, workspaceOpts: { sourceDir }, mode: "sync", existingConfig: localConfig }); await syncResolvedSharedDeps({ configDir: projectDir, hostMode: "local" }); const syncedConfig = await openResolution({ cwd: projectDir }); if (syncedConfig?.runtime) await materializeViaLayer(projectDir, syncedConfig.runtime); const newSnapshotFiles = {}; for (const destPath of destToSource.keys()) { const hash = computeLocalHash(projectDir, destPath); if (hash) newSnapshotFiles[destPath] = hash; } const agentsMdSourcePath = toSourcePath(sourceDir, "AGENTS.md"); if (agentsMdSourcePath) { const agentsMdSourceContent = readFileSync(join(sourceDir, agentsMdSourcePath), "utf-8"); const skillsBlock = extractSkillsBlock(agentsMdSourceContent); if (skillsBlock) { const expectedContent = buildChildAgentsMd(skillsBlock, { overrides, plugins: childPlugins }); const expectedHash = computeSnapshotHash(expectedContent); if (computeLocalHash(projectDir, "AGENTS.md") !== expectedHash) { writeFileSync(join(projectDir, "AGENTS.md"), expectedContent); updated.push("AGENTS.md"); } newSnapshotFiles["AGENTS.md"] = expectedHash; } } await writeSnapshot(projectDir, { parentRef: `bos://${extendsAccount}/${extendsGateway}`, files: newSnapshotFiles }); if (!options.noInstall) { await runPnpmInstall(findWorkspaceRoot(projectDir)?.dir ?? projectDir); await runTypesGen(projectDir); } return { status: "synced", updated, skipped, added, conflicted, retired: retirement.retired, retiredConflicted: retirement.retiredConflicted, retiredBackupDir: retirement.backupDir ?? void 0, backupDir }; } finally { await cleanup(); } } //#endregion export { RETIRED_SYNC_FILES, isFrameworkOwnedSyncFile, mergePackageJson, retireSyncedFiles, syncTemplate }; //# sourceMappingURL=sync.mjs.map