841 lines
27 KiB
TypeScript
841 lines
27 KiB
TypeScript
#!/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-to-verdaccio.ts
|
|
* bun scripts/publish-to-verdaccio.ts --dry-run
|
|
* bun scripts/publish-to-verdaccio.ts --package @suaveplan/utils
|
|
* bun scripts/publish-to-verdaccio.ts --force
|
|
* bun scripts/publish-to-verdaccio.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();
|
|
return data["dist-tags"]?.latest || null;
|
|
} catch (err: unknown) {
|
|
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(obj: ExportsField): ExportsField {
|
|
if (typeof obj === "string") {
|
|
return obj.replace(/^\.\/dist\//, "./");
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(fixPaths);
|
|
}
|
|
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;
|
|
}
|
|
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) => !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: unknown) {
|
|
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);
|