everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
540 lines (538 loc) • 21.2 kB
JavaScript
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