chore: add registry install flow

This commit is contained in:
2026-03-20 10:38:05 +08:00
parent 5045756525
commit e8aea4b88a
13 changed files with 1788 additions and 20 deletions
+297
View File
@@ -0,0 +1,297 @@
import fs from "node:fs/promises";
import path from "node:path";
import { builtinModules } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const registryDir = path.join(repoRoot, "registry");
const registryConfigPath = path.join(registryDir, "config.json");
const registryIndexPath = path.join(registryDir, "index.json");
const args = new Set(process.argv.slice(2));
const checkMode = args.has("--check");
const uiPackagePath = path.join(repoRoot, "packages", "ui", "package.json");
const tokensPackagePath = path.join(repoRoot, "packages", "tokens", "package.json");
const rootPackagePath = path.join(repoRoot, "package.json");
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
async function readJson(filePath) {
const source = await fs.readFile(filePath, "utf8");
return JSON.parse(source);
}
function isBuiltinSpecifier(specifier) {
return specifier.startsWith("node:") || builtinModules.includes(specifier);
}
function normalizePackageName(specifier) {
if (specifier.startsWith("@")) {
const [scope, name] = specifier.split("/");
return `${scope}/${name}`;
}
return specifier.split("/")[0];
}
function getDisplayName(name) {
return name
.split("-")
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
}
function getScriptSpecifiers(sourceText) {
const { importedFiles } = ts.preProcessFile(sourceText, true, true);
return importedFiles.map((entry) => entry.fileName);
}
function getCssSpecifiers(sourceText) {
const specifiers = [];
const pattern = /@import\s+["']([^"']+)["']/g;
for (const match of sourceText.matchAll(pattern)) {
specifiers.push(match[1]);
}
return specifiers;
}
async function resolveLocalImport(fromFile, specifier) {
const basePath = path.resolve(path.dirname(fromFile), specifier);
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
`${basePath}.css`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js")
];
for (const candidate of candidates) {
try {
const stats = await fs.stat(candidate);
if (stats.isFile()) {
return candidate;
}
} catch {
continue;
}
}
throw new Error(
`Unable to resolve "${specifier}" from ${toPosixPath(path.relative(repoRoot, fromFile))}.`
);
}
async function collectSourceGraph(entrypoints) {
const filesToVisit = [...entrypoints];
const visited = new Set();
const externalPackages = new Set();
while (filesToVisit.length > 0) {
const currentFile = filesToVisit.pop();
if (!currentFile || visited.has(currentFile)) {
continue;
}
visited.add(currentFile);
const sourceText = await fs.readFile(currentFile, "utf8");
const localSpecifiers = currentFile.endsWith(".css")
? getCssSpecifiers(sourceText)
: getScriptSpecifiers(sourceText);
for (const specifier of localSpecifiers) {
if (specifier.startsWith(".") || specifier.startsWith("..")) {
filesToVisit.push(await resolveLocalImport(currentFile, specifier));
continue;
}
if (isBuiltinSpecifier(specifier)) {
continue;
}
externalPackages.add(normalizePackageName(specifier));
}
}
return {
externalPackages: [...externalPackages].sort(),
files: [...visited]
.map((filePath) => toPosixPath(path.relative(repoRoot, filePath)))
.sort()
};
}
async function discoverComponentEntrypoints(componentsDir) {
const absoluteDir = path.join(repoRoot, componentsDir);
const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".tsx"))
.filter((fileName) => !fileName.endsWith(".test.tsx"))
.sort()
.map((fileName) => ({
description: `Source-owned ${getDisplayName(fileName.replace(/\.tsx$/, ""))} component.`,
entrypoints: [toPosixPath(path.join(componentsDir, fileName))],
kind: "component",
name: fileName.replace(/\.tsx$/, "")
}));
}
function createDependencyLookup(...packageJsonFiles) {
const lookup = new Map();
for (const packageJson of packageJsonFiles) {
for (const section of [
packageJson.peerDependencies ?? {},
packageJson.dependencies ?? {},
packageJson.devDependencies ?? {}
]) {
for (const [name, range] of Object.entries(section)) {
if (!lookup.has(name)) {
lookup.set(name, range);
}
}
}
}
return lookup;
}
async function buildRegistryIndex() {
const [registryConfig, rootPackage, uiPackage, tokensPackage] = await Promise.all([
readJson(registryConfigPath),
readJson(rootPackagePath),
readJson(uiPackagePath),
readJson(tokensPackagePath)
]);
const dependencyLookup = createDependencyLookup(uiPackage, tokensPackage, rootPackage);
const itemDefinitions = [
{
...registryConfig.tokens,
kind: "tokens",
requires: [],
sourcePackage: tokensPackage.name
},
...(await discoverComponentEntrypoints(registryConfig.ui.componentsDir)).map((item) => ({
...item,
requires: [...registryConfig.ui.requires],
sourcePackage: uiPackage.name
}))
];
const items = [];
for (const definition of itemDefinitions) {
const entrypoints = definition.entrypoints.map((entrypoint) =>
path.join(repoRoot, ...entrypoint.split("/"))
);
const sourceGraph = await collectSourceGraph(entrypoints);
const packageDependencies = {};
for (const dependencyName of sourceGraph.externalPackages) {
const range = dependencyLookup.get(dependencyName);
if (!range) {
throw new Error(
`Missing dependency range for "${dependencyName}" while building registry item "${definition.name}".`
);
}
packageDependencies[dependencyName] = range;
}
items.push({
description: definition.description,
displayName: getDisplayName(definition.name),
entrypoints: definition.entrypoints,
files: sourceGraph.files,
kind: definition.kind,
name: definition.name,
packageDependencies,
requires: definition.requires,
sourcePackage: definition.sourcePackage,
sourceVersion:
definition.sourcePackage === uiPackage.name ? uiPackage.version : tokensPackage.version,
targetDirectory:
definition.kind === "tokens"
? toPosixPath(
path.posix.join(registryConfig.defaultTargetDir, registryConfig.tokens.targetSubdir)
)
: toPosixPath(
path.posix.join(
registryConfig.defaultTargetDir,
registryConfig.ui.targetSubdir === "."
? ""
: registryConfig.ui.targetSubdir
)
)
});
}
return {
generatedBy: "scripts/build-registry.mjs",
install: {
defaultTargetDir: registryConfig.defaultTargetDir,
manifestFile: ".install-manifest.json"
},
items,
library: {
name: registryConfig.libraryName,
packageManager: rootPackage.packageManager,
packages: {
[tokensPackage.name]: tokensPackage.version,
[uiPackage.name]: uiPackage.version
}
}
};
}
async function main() {
const nextIndex = await buildRegistryIndex();
const nextContent = `${JSON.stringify(nextIndex, null, 2)}\n`;
if (checkMode) {
let currentContent = "";
try {
currentContent = await fs.readFile(registryIndexPath, "utf8");
} catch {
currentContent = "";
}
if (currentContent !== nextContent) {
console.error("registry/index.json is out of date. Run `pnpm registry:build`.");
process.exitCode = 1;
return;
}
console.log("registry/index.json is up to date.");
return;
}
await fs.mkdir(registryDir, { recursive: true });
await fs.writeFile(registryIndexPath, nextContent);
console.log(`Wrote ${toPosixPath(path.relative(repoRoot, registryIndexPath))}.`);
}
await main();
+327
View File
@@ -0,0 +1,327 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const registryIndexPath = path.join(repoRoot, "registry", "index.json");
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
async function readJson(filePath) {
const source = await fs.readFile(filePath, "utf8");
return JSON.parse(source);
}
function sortObject(input) {
return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left.localeCompare(right)));
}
function parseArgs(argv) {
const options = {
dryRun: false,
project: process.cwd(),
skipPackageJson: false,
targetDir: null
};
const items = [];
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === "--dry-run") {
options.dryRun = true;
continue;
}
if (current === "--skip-package-json") {
options.skipPackageJson = true;
continue;
}
if (current === "--project" || current === "--target-dir") {
const next = argv[index + 1];
if (!next) {
throw new Error(`Expected a value after ${current}.`);
}
if (current === "--project") {
options.project = next;
} else {
options.targetDir = next;
}
index += 1;
continue;
}
if (current.startsWith("--")) {
throw new Error(`Unknown option: ${current}`);
}
items.push(current);
}
return {
items,
options
};
}
function mapSourceToTarget(sourcePath, targetDir) {
if (sourcePath.startsWith("packages/ui/src/")) {
return toPosixPath(path.posix.join(targetDir, sourcePath.replace("packages/ui/src/", "")));
}
if (sourcePath.startsWith("packages/tokens/src/")) {
return toPosixPath(
path.posix.join(targetDir, "tokens", sourcePath.replace("packages/tokens/src/", ""))
);
}
throw new Error(`Unsupported registry source path: ${sourcePath}`);
}
function resolveItems(registryIndex, requestedItems) {
const itemLookup = new Map(registryIndex.items.map((item) => [item.name, item]));
const resolved = new Map();
const stack = [...requestedItems];
while (stack.length > 0) {
const name = stack.pop();
const item = itemLookup.get(name);
if (!item) {
const available = [...itemLookup.keys()].sort().join(", ");
throw new Error(`Unknown registry item "${name}". Available items: ${available}.`);
}
if (resolved.has(name)) {
continue;
}
resolved.set(name, item);
for (const dependency of item.requires) {
stack.push(dependency);
}
}
return [...resolved.values()].sort((left, right) => left.name.localeCompare(right.name));
}
async function readInstallManifest(manifestPath) {
try {
return await readJson(manifestPath);
} catch {
return null;
}
}
function collectPackageDependencies(items) {
const dependencies = {};
for (const item of items) {
for (const [name, range] of Object.entries(item.packageDependencies)) {
dependencies[name] = range;
}
}
return sortObject(dependencies);
}
function upsertDependencySections(packageJson, dependencies) {
const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
const added = {};
const conflicts = [];
const nextPackageJson = JSON.parse(JSON.stringify(packageJson));
nextPackageJson.dependencies = nextPackageJson.dependencies ?? {};
for (const [name, range] of Object.entries(dependencies)) {
let existingSection = null;
let existingRange = null;
for (const section of sections) {
const value = nextPackageJson[section]?.[name];
if (typeof value === "string") {
existingSection = section;
existingRange = value;
break;
}
}
if (!existingSection) {
nextPackageJson.dependencies[name] = range;
added[name] = range;
continue;
}
if (existingRange !== range) {
conflicts.push({
existingRange,
name,
requiredRange: range,
section: existingSection
});
}
}
nextPackageJson.dependencies = sortObject(nextPackageJson.dependencies);
return {
added,
conflicts,
packageJson: nextPackageJson
};
}
async function copyRegistryFiles({ dryRun, items, projectRoot, targetDir }) {
const writtenFiles = [];
const updatedFiles = [];
const unchangedFiles = [];
for (const item of items) {
for (const sourcePath of item.files) {
const sourceFile = path.join(repoRoot, ...sourcePath.split("/"));
const targetRelativePath = mapSourceToTarget(sourcePath, targetDir);
const targetFile = path.join(projectRoot, ...targetRelativePath.split("/"));
const content = await fs.readFile(sourceFile, "utf8");
let existingContent = null;
try {
existingContent = await fs.readFile(targetFile, "utf8");
} catch {
existingContent = null;
}
if (existingContent === content) {
unchangedFiles.push(targetRelativePath);
continue;
}
if (!dryRun) {
await fs.mkdir(path.dirname(targetFile), { recursive: true });
await fs.writeFile(targetFile, content);
}
if (existingContent === null) {
writtenFiles.push(targetRelativePath);
} else {
updatedFiles.push(targetRelativePath);
}
}
}
return {
unchangedFiles,
updatedFiles,
writtenFiles
};
}
async function main() {
const { items: requestedItems, options } = parseArgs(process.argv.slice(2));
const registryIndex = await readJson(registryIndexPath);
const projectRoot = path.resolve(options.project);
const targetDir = toPosixPath(options.targetDir ?? registryIndex.install.defaultTargetDir);
const targetRoot = path.join(projectRoot, ...targetDir.split("/"));
const manifestPath = path.join(targetRoot, registryIndex.install.manifestFile);
let itemNames = requestedItems;
if (itemNames.length === 0) {
const manifest = await readInstallManifest(manifestPath);
if (!manifest?.items?.length) {
throw new Error(
`No registry items were provided and ${toPosixPath(
path.relative(projectRoot, manifestPath)
)} does not exist yet.`
);
}
itemNames = manifest.items;
}
const resolvedItems = resolveItems(registryIndex, itemNames);
const packageDependencies = collectPackageDependencies(resolvedItems);
const copyResult = await copyRegistryFiles({
dryRun: options.dryRun,
items: resolvedItems,
projectRoot,
targetDir
});
let dependencyResult = {
added: {},
conflicts: []
};
if (!options.skipPackageJson) {
const packageJsonPath = path.join(projectRoot, "package.json");
const packageJson = await readJson(packageJsonPath);
dependencyResult = upsertDependencySections(packageJson, packageDependencies);
if (!options.dryRun) {
await fs.writeFile(
packageJsonPath,
`${JSON.stringify(dependencyResult.packageJson, null, 2)}\n`
);
}
}
const installManifest = {
items: resolvedItems.map((item) => item.name),
packageDependencies,
registry: registryIndex.library.name,
sourcePackages: registryIndex.library.packages,
targetDir
};
if (!options.dryRun) {
await fs.mkdir(targetRoot, { recursive: true });
await fs.writeFile(manifestPath, `${JSON.stringify(installManifest, null, 2)}\n`);
}
console.log(
`${options.dryRun ? "Planned" : "Installed"} registry items: ${resolvedItems
.map((item) => item.name)
.join(", ")}`
);
console.log(
`Files written: ${copyResult.writtenFiles.length}, updated: ${copyResult.updatedFiles.length}, unchanged: ${copyResult.unchangedFiles.length}`
);
if (!options.skipPackageJson) {
const added = Object.keys(dependencyResult.added);
console.log(
added.length > 0
? `Added package dependencies: ${added.join(", ")}`
: "Added package dependencies: none"
);
}
if (dependencyResult.conflicts.length > 0) {
console.log("Dependency version conflicts:");
for (const conflict of dependencyResult.conflicts) {
console.log(
`- ${conflict.name}: keeping ${conflict.section}=${conflict.existingRange}, registry expects ${conflict.requiredRange}`
);
}
}
console.log(`Target directory: ${targetDir}`);
console.log(`Tokens import path: ${targetDir}/tokens/styles.css`);
console.log(
options.dryRun
? "Dry run complete."
: "Review the diff, then run your package manager install if package.json changed."
);
}
await main();