#!/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; 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 { 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 { 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 { 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(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 = {}; 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 { 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 { 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 { 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 { 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);