Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

842
scripts/publish.ts Normal file
View File

@@ -0,0 +1,842 @@
#!/usr/bin/env bun
/**
* Publish script for Verdaccio registry
*
* This script publishes packages without modifying source files:
* 1. Queries Verdaccio to check published versions
* 2. Compares local vs published versions
* 3. Copies package.json + README.md + documentation into dist/
* 4. Resolves workspace:* and catalog:* references to actual versions
* 5. Adjusts paths (removes dist/ prefix)
* 6. Publishes from dist/ (only if version changed or forced)
* 7. Cleans up
*
* Usage:
* bun scripts/publish.ts
* bun scripts/publish.ts --dry-run
* bun scripts/publish.ts --package @suaveplan/utils
* bun scripts/publish.ts --force
* bun scripts/publish.ts --force=@suaveplan/utils,@suaveplan/crypto
*/
import { createHash } from "node:crypto";
import {
cp,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { join, relative } from "node:path";
import { $ } from "bun";
import {
CatalogResolver,
type ExportsField,
type PackageJson,
} from "./lib/catalog-resolver.js";
import { CONFIG } from "./lib/config.js";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const REGISTRY = CONFIG.registry.verdaccio.url;
const REGISTRY_WEB = CONFIG.registry.verdaccio.webUrl;
const ROOT_DIR = CONFIG.paths.root;
// CLI options
interface CliOptions {
dryRun: boolean;
forcePackages: Set<string>;
forceAll: boolean;
specificPackage?: string;
}
/**
* Parse CLI arguments
*
* Supports:
* - --dry-run: Preview changes without publishing
* - --force: Force publish all packages with version increment
* - --force=pkg1,pkg2: Force publish specific packages with version increment
* - --package=name: Publish only specific package
*/
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = {
dryRun: false,
forcePackages: new Set(),
forceAll: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--force") {
options.forceAll = true;
} else if (arg.startsWith("--force=")) {
const packages = arg.slice(8).split(",");
for (const pkg of packages) {
options.forcePackages.add(pkg.trim());
}
} else if (arg.startsWith("--package=")) {
options.specificPackage = arg.slice(10);
} else if (arg === "--package" && i + 1 < args.length) {
// Support both --package=name and --package name
options.specificPackage = args[i + 1];
i++; // Skip next arg since we consumed it
}
}
return options;
}
/**
* Query Verdaccio for published version of a package
*
* @param packageName - Full package name (e.g., '@suaveplan/utils')
* @returns Latest published version, or null if never published
*/
async function getPublishedVersion(
packageName: string
): Promise<string | null> {
try {
const response = await fetch(`${REGISTRY}${packageName}`);
if (response.status === 404) {
return null; // Package never published
}
if (!response.ok) {
console.warn(
` Warning: Failed to fetch ${packageName} from registry: ${response.status}`
);
return null;
}
const data = (await response.json()) as {
"dist-tags"?: { latest?: string };
};
return data["dist-tags"]?.latest || null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(` Warning: Error fetching ${packageName}: ${message}`);
return null;
}
}
/**
* Simple semver comparison
*
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
/**
* Increment patch version (1.2.3 -> 1.2.4)
*/
function incrementVersion(version: string): string {
const parts = version.split(".");
if (parts.length !== 3) {
throw new Error(`Invalid version format: ${version}`);
}
parts[2] = String(Number(parts[2]) + 1);
return parts.join(".");
}
/**
* Update package.json version in source (for --force)
*/
async function updatePackageVersion(
pkgJsonPath: string,
newVersion: string
): Promise<void> {
const content = await readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(content);
pkg.version = newVersion;
await writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
}
/**
* Get all package directories from the configured packages root.
*/
async function getPackageDirs(): Promise<string[]> {
return discoverPackageDirs();
}
/**
* Transform package.json for publishing from dist/
*
* Removes dist/ prefix from paths since we're publishing from within dist/
*/
function transformPackageJson(
pkg: PackageJson,
publishVersion: string,
resolver: CatalogResolver
): PackageJson {
const transformed = { ...pkg };
// Update version to the one we're publishing
transformed.version = publishVersion;
// Resolve workspace/catalog references in dependencies
transformed.dependencies = resolver.resolveDependencies(
pkg.dependencies,
publishVersion
);
transformed.peerDependencies = resolver.resolveDependencies(
pkg.peerDependencies,
publishVersion
);
// Remove devDependencies - not needed for published package
delete transformed.devDependencies;
// Remove scripts - not needed for published package
delete transformed.scripts;
// Remove files field - we're publishing the whole dist folder
delete transformed.files;
// Fix exports paths - remove "dist/" prefix since we're in dist/
if (transformed.exports) {
transformed.exports = fixPaths(transformed.exports);
}
// Fix main/module/types paths
if (transformed.main) {
transformed.main = transformed.main.replace(/^\.\/dist\//, "./");
}
if (transformed.module) {
transformed.module = transformed.module.replace(/^\.\/dist\//, "./");
}
if (transformed.types) {
transformed.types = transformed.types.replace(/^\.\/dist\//, "./");
}
return transformed;
}
/**
* Transform overview README.md links from relative .md to registry URLs
*/
function transformOverviewReadme(content: string): string {
// Transform links like [text](./package-name.md) to [text](https://npmjs.fq.io/-/web/detail/@suaveplan/package-name)
return content.replace(
/\[([^\]]+)\]\(\.\/([^)]+)\.md\)/g,
(_, text, packageName) =>
`[${text}](${REGISTRY_WEB}/@suaveplan/${packageName})`
);
}
/**
* Recursively fix paths in exports object
*/
function fixPaths<T extends ExportsField>(obj: T): T {
if (typeof obj === "string") {
return obj.replace(/^\.\/dist\//, "./") as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => fixPaths(item)) as T;
}
if (typeof obj === "object" && obj !== null) {
const fixed: Record<string, ExportsField> = {};
for (const [key, value] of Object.entries(obj)) {
fixed[key] = fixPaths(value);
}
return fixed as T;
}
return obj;
}
/**
* Recursively find all .md files in a directory
*/
async function findMarkdownFiles(dir: string): Promise<string[]> {
const results: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (
entry.isDirectory() &&
entry.name !== "node_modules" &&
entry.name !== "dist"
) {
results.push(...(await findMarkdownFiles(fullPath)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(fullPath);
}
}
} catch {
// Directory doesn't exist or isn't readable
}
return results;
}
/**
* Copy all documentation files into dist/ for publishing
*
* Copies:
* - Root-level .md files (README.md, SECURITY.md, TODO.md, etc.)
* - docs/ directory (preserving structure)
* - src/*.md files (co-located documentation, preserving structure)
* - schemas/*.md files outside src/ (preserving structure)
*
* @returns List of paths copied into dist/ (for cleanup)
*/
async function copyDocsToDistDir(
pkgDir: string,
distDir: string
): Promise<string[]> {
const copiedPaths: string[] = [];
// 1. Copy all root-level .md files
try {
const rootEntries = await readdir(pkgDir, { withFileTypes: true });
for (const entry of rootEntries) {
if (entry.isFile() && entry.name.endsWith(".md")) {
const src = join(pkgDir, entry.name);
const dest = join(distDir, entry.name);
await cp(src, dest, { force: true });
copiedPaths.push(dest);
}
}
} catch {
// Skip if we can't read the directory
}
// 2. Copy docs/ directory if it exists
const docsDir = join(pkgDir, "docs");
try {
const docsStat = await stat(docsDir);
if (docsStat.isDirectory()) {
const destDocs = join(distDir, "docs");
await cp(docsDir, destDocs, { recursive: true, force: true });
copiedPaths.push(destDocs);
}
} catch {
// No docs/ directory
}
// 3. Copy .md files from src/ (preserving directory structure)
const srcDir = join(pkgDir, "src");
const srcMdFiles = await findMarkdownFiles(srcDir);
if (srcMdFiles.length > 0) {
const destSrcDocs = join(distDir, "src-docs");
for (const mdFile of srcMdFiles) {
const relPath = relative(srcDir, mdFile);
const destPath = join(destSrcDocs, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSrcDocs);
}
// 4. Copy schemas/ directory .md files if it exists outside src/
const schemasDir = join(pkgDir, "schemas");
try {
const schemasStat = await stat(schemasDir);
if (schemasStat.isDirectory()) {
const schemaMdFiles = await findMarkdownFiles(schemasDir);
if (schemaMdFiles.length > 0) {
const destSchemas = join(distDir, "schemas");
for (const mdFile of schemaMdFiles) {
const relPath = relative(schemasDir, mdFile);
const destPath = join(destSchemas, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSchemas);
}
}
} catch {
// No schemas/ directory
}
return copiedPaths;
}
/**
* Generate SLSA provenance for a successfully published package
*/
async function generatePublishProvenance(
packageName: string,
version: string,
distDir: string
): Promise<void> {
try {
// Compute digest of the dist directory's package.json (representing the published artifact)
const distPkgPath = join(distDir, "package.json");
let artifactDigest: string;
try {
const content = await readFile(distPkgPath);
artifactDigest = createHash("sha256").update(content).digest("hex");
} catch {
// dist/package.json already cleaned up, use package name as fallback
artifactDigest = createHash("sha256")
.update(`${packageName}@${version}`)
.digest("hex");
}
// Read Git info
const commitSha = (await $`git rev-parse HEAD`.text()).trim();
const refName = (
await $`git rev-parse --abbrev-ref HEAD`.text()
).trim();
const projectUrl =
process.env.CI_PROJECT_URL ??
"https://gitlab.fq.io/suaveplan/monorepo";
const runnerId = process.env.CI_RUNNER_ID ?? "local";
const jobId = process.env.CI_JOB_ID ?? `local-${Date.now()}`;
const pipelineId = process.env.CI_PIPELINE_ID ?? "local";
const isCI = !!(process.env.CI || process.env.GITLAB_CI);
const encodedName = packageName.replace("@", "%40");
const statement = {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: `pkg:npm/${encodedName}@${version}`,
digest: { sha256: artifactDigest },
name: packageName,
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType:
"https://suaveplan.app/slsa/build-type/bun-monorepo/v1",
externalParameters: {
repository: projectUrl,
ref: `refs/heads/${refName}`,
},
internalParameters: {
environment: isCI ? "gitlab-ci" : "local",
},
resolvedDependencies: [
{
uri: `git+${projectUrl}@refs/heads/${refName}`,
digest: { sha256: commitSha },
name: "source",
},
],
},
runDetails: {
builder: {
id: `${projectUrl}/-/runners/${runnerId}`,
version: { bun: Bun.version },
},
metadata: {
invocationId: `${projectUrl}/-/pipelines/${pipelineId}/jobs/${jobId}`,
startedOn:
process.env.CI_JOB_STARTED_AT ??
new Date().toISOString(),
finishedOn: new Date().toISOString(),
},
},
},
};
const provenanceDir = join(ROOT_DIR, "provenance");
try {
await stat(provenanceDir);
} catch {
await mkdir(provenanceDir, { recursive: true });
}
const safeName = packageName.replace(/[/@]/g, "_");
const filename = `${safeName}-${version}.provenance.json`;
await writeFile(
join(provenanceDir, filename),
JSON.stringify(statement, null, 2)
);
} catch {
// Provenance generation failure should not block publishing
console.warn(
` Warning: Failed to generate provenance for ${packageName}`
);
}
}
interface PublishResult {
name: string;
success: boolean;
error?: string;
localVersion?: string;
publishedVersion?: string | null;
action?: "published" | "skipped" | "updated" | "failed";
}
/**
* Publish a single package to Verdaccio
*
* @param pkgDir - Absolute path to package directory
* @param options - CLI options (dry-run, force, etc.)
* @param resolver - CatalogResolver instance for dependency resolution
* @returns PublishResult with status and metadata
*/
async function publishPackage(
pkgDir: string,
options: CliOptions,
resolver: CatalogResolver
): Promise<PublishResult> {
const pkgJsonPath = join(pkgDir, "package.json");
const distDir = join(pkgDir, CONFIG.build.distDir);
try {
// Read original package.json
const pkgContent = await readFile(pkgJsonPath, "utf-8");
const pkg: PackageJson = JSON.parse(pkgContent);
// Skip private packages
if (pkg.private) {
return {
name: pkg.name!,
success: true,
error: "private package",
action: "skipped",
};
}
// Check if dist exists
try {
await stat(distDir);
} catch {
return {
name: pkg.name!,
success: false,
error: "no dist folder",
action: "failed",
};
}
const localVersion = pkg.version;
if (!localVersion) {
return {
name: pkg.name!,
success: false,
error: "no version in package.json",
action: "failed",
};
}
// Query Verdaccio for published version
const publishedVersion = await getPublishedVersion(pkg.name!);
// Determine if we should publish
let _shouldPublish = false;
let versionToPublish = localVersion;
let wasForceIncremented = false;
// Check if force is enabled for this package
const isForced =
options.forceAll || options.forcePackages.has(pkg.name!);
if (publishedVersion === null) {
// Never published before
_shouldPublish = true;
} else {
const comparison = compareVersions(localVersion, publishedVersion);
if (comparison > 0) {
// Local version is newer
_shouldPublish = true;
} else if (comparison === 0) {
// Same version - check if force flag is set for this package
if (isForced) {
// Increment version and update source package.json
versionToPublish = incrementVersion(localVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Skip - same version
return {
name: pkg.name!,
success: true,
localVersion,
publishedVersion,
error: "same version",
action: "skipped",
};
}
} else {
// Local version is older than published
if (isForced) {
// Force enabled: increment published version and update local
versionToPublish = incrementVersion(publishedVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Force not enabled: this is an error
return {
name: pkg.name!,
success: false,
localVersion,
publishedVersion,
error: "local version < published version",
action: "failed",
};
}
}
}
// If dry run, stop here
if (options.dryRun) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: wasForceIncremented
? "would increment and publish"
: "would publish",
action: "skipped",
};
}
// Transform package.json with the version we're publishing
const transformedPkg = transformPackageJson(
pkg,
versionToPublish,
resolver
);
// Write transformed package.json to dist/
await writeFile(
join(distDir, "package.json"),
JSON.stringify(transformedPkg, null, 2)
);
// Copy all documentation into dist/ for publishing
const copiedDocPaths = await copyDocsToDistDir(pkgDir, distDir);
// Special handling for overview package README links
try {
const distReadme = join(distDir, "README.md");
const readmeContent = await readFile(distReadme, "utf-8");
if (pkg.name === "@suaveplan/overview") {
await writeFile(
distReadme,
transformOverviewReadme(readmeContent)
);
}
} catch {
// No README, that's ok
}
// Publish from dist/ using npm (more reliable than bun for this)
// Note: We use --userconfig to ensure auth token from root .npmrc is used
// (writing package.json to dist/ makes npm treat it as project root, stopping .npmrc walk-up)
const npmrcPath = join(ROOT_DIR, ".npmrc");
const result =
await $`npm publish --registry ${REGISTRY} --userconfig ${npmrcPath}`
.cwd(distDir)
.quiet()
.nothrow();
// Clean up - remove package.json and documentation from dist
await rm(join(distDir, "package.json")).catch(() => {});
for (const docPath of copiedDocPaths) {
await rm(docPath, { recursive: true }).catch(() => {});
}
if (result.exitCode !== 0) {
const stderr = result.stderr.toString();
const stdout = result.stdout.toString();
const output = stderr + stdout;
if (
output.includes("already exists") ||
output.includes("cannot publish over") ||
output.includes("E409") ||
output.includes("already present")
) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: "already published",
action: "skipped",
};
}
// Filter out npm notice lines to surface real error
const errorLines = stderr
.split("\n")
.filter((line: string) => !line.startsWith("npm notice"))
.join("\n")
.trim();
return {
name: pkg.name!,
success: false,
localVersion: versionToPublish,
publishedVersion,
error: errorLines || stderr.slice(0, 500),
action: "failed",
};
}
// Generate SLSA provenance for the published artifact
await generatePublishProvenance(pkg.name!, versionToPublish, distDir);
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
action: wasForceIncremented ? "updated" : "published",
};
} catch (err) {
return {
name: pkgDir,
success: false,
error: err instanceof Error ? err.message : String(err),
action: "failed",
};
}
}
async function main() {
const options = parseArgs();
console.log("=".repeat(70));
console.log(`Publishing to Verdaccio: ${REGISTRY}`);
if (options.dryRun) {
console.log("🔍 DRY RUN MODE - No actual publishing will occur");
}
if (options.specificPackage) {
console.log(`📦 Publishing only: ${options.specificPackage}`);
}
if (options.forceAll) {
console.log(
"⚡ Force publishing ALL packages (with version increment)"
);
} else if (options.forcePackages.size > 0) {
console.log(
`⚡ Force publishing (with version increment): ${Array.from(options.forcePackages).join(", ")}`
);
}
console.log("=".repeat(70));
console.log();
// Load catalogs
const resolver = await CatalogResolver.create();
console.log(`Loaded ${resolver.getCatalogCount()} catalogs`);
console.log();
let packageDirs = await getPackageDirs();
// Filter to specific package if requested
if (options.specificPackage) {
const filteredDirs: string[] = [];
for (const dir of packageDirs) {
const pkgJsonPath = join(dir, "package.json");
try {
const pkg: PackageJson = JSON.parse(
await readFile(pkgJsonPath, "utf-8")
);
if (pkg.name === options.specificPackage) {
filteredDirs.push(dir);
}
} catch {
// Skip packages with invalid JSON
}
}
packageDirs = filteredDirs;
}
console.log(`Found ${packageDirs.length} packages\n`);
let publishedCount = 0;
let updatedCount = 0;
let skipCount = 0;
let failCount = 0;
for (const pkgDir of packageDirs) {
const result = await publishPackage(pkgDir, options, resolver);
// Format output based on result
const versionInfo = result.localVersion
? `${result.localVersion}${result.publishedVersion !== null ? ` (was: ${result.publishedVersion})` : " (new)"}`
: "";
switch (result.action) {
case "published":
console.log(`✅ ${result.name}@${versionInfo}`);
publishedCount++;
break;
case "updated":
console.log(
`🔄 ${result.name}@${versionInfo} (force incremented)`
);
updatedCount++;
break;
case "skipped":
if (result.error === "same version") {
console.log(
`⏭️ ${result.name}@${versionInfo} - up to date`
);
} else if (result.error?.includes("would")) {
console.log(
`🔍 ${result.name}@${versionInfo} - ${result.error}`
);
} else {
console.log(`⏭️ ${result.name} - ${result.error}`);
}
skipCount++;
break;
case "failed":
console.log(
`❌ ${result.name}@${versionInfo || "?"} - ${result.error}`
);
failCount++;
break;
default:
console.log(`❓ ${result.name} - unknown status`);
}
}
console.log();
console.log("=".repeat(70));
console.log(
`Published: ${publishedCount} | Updated: ${updatedCount} | Skipped: ${skipCount} | Failed: ${failCount}`
);
if (options.dryRun) {
console.log("🔍 DRY RUN - No changes were made");
}
console.log("=".repeat(70));
// Exit with standardized code
if (failCount > 0) {
process.exit(CONFIG.exitCodes.failure);
}
process.exit(CONFIG.exitCodes.success);
}
main().catch(console.error);