#!/usr/bin/env bun /** * Headless publisher for all `@shade/*` packages. * * Use `scripts/publish-shade.sh` for the interactive human flow (token * prompt, conflict detection, version bump-on-conflict). This script is * the env-driven variant — designed for `DRY_RUN=1` smoke tests, CI * pipelines, and any context where prompts are not appropriate. * * Required env (when not DRY_RUN): * GITEA_TOKEN — publish token from Gitea (Settings → Applications) * GITEA_USER — Gitea username that owns the registry (default: Stian) * * Optional: * DRY_RUN=1 — pack tarballs but do not publish (no token required) */ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; import { $ } from 'bun'; // Order matters: each package only depends on packages above it. // Mirrors the PACKAGES list in scripts/publish-shade.sh. const PACKAGES = [ 'shade-core', 'shade-proto', 'shade-crypto-web', 'shade-observability', 'shade-keychain', 'shade-key-transparency', 'shade-storage-sqlite', 'shade-storage-postgres', 'shade-storage-indexeddb', 'shade-storage-encrypted', 'shade-streams', 'shade-transport', 'shade-transport-bridge', 'shade-transport-webrtc', 'shade-server', 'shade-inbox-server', 'shade-inbox', 'shade-transfer', 'shade-files', 'shade-recovery', 'shade-observer', 'shade-dashboard', 'shade-sdk', 'shade-widgets', 'shade-cli', ]; const REGISTRY_HOST = 'gt.zyon.no'; const ROOT = join(import.meta.dir, '..'); async function main() { const token = process.env.GITEA_TOKEN; const user = process.env.GITEA_USER ?? 'Stian'; const dryRun = process.env.DRY_RUN === '1'; if (!token && !dryRun) { console.error('GITEA_TOKEN is required (or set DRY_RUN=1)'); process.exit(1); } const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`; console.log(`Target registry: ${registryUrl}`); console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`); console.log(); const npmrcPath = join(ROOT, '.npmrc.publish'); const npmrc = [ `@shade:registry=${registryUrl}`, dryRun ? '' : `//${REGISTRY_HOST}/api/packages/${user}/npm/:_authToken=${token}`, ].filter(Boolean).join('\n'); writeFileSync(npmrcPath, npmrc); const versionByName = new Map(); for (const pkg of PACKAGES) { const pkgDir = join(ROOT, 'packages', pkg); if (!existsSync(join(pkgDir, 'package.json'))) continue; const json = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')); versionByName.set(json.name, json.version); } let published = 0; let skipped = 0; let alreadyPublished = 0; let failed = 0; for (const pkg of PACKAGES) { const pkgDir = join(ROOT, 'packages', pkg); const pkgJsonPath = join(pkgDir, 'package.json'); if (!existsSync(pkgJsonPath)) { console.log(`⊘ ${pkg} — package.json not found, skipping`); skipped++; continue; } const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8'); const pkgJson = JSON.parse(originalPkgJson); console.log(`→ ${pkgJson.name}@${pkgJson.version}`); try { // Phase 1 — build. Leave `main`/`types`/`exports` pointing at `src/` // so `tsc` can resolve cross-package `@shade/*` imports through // workspace source (`dist/` doesn't exist yet for sibling packages // when their package.json points there). We ship pre-built artefacts // so strict-mode consumers (Cyndr et al.) don't recompile our source. await $`cd ${pkgDir} && rm -rf dist && bunx tsc -p tsconfig.json`.quiet(); // Phase 2 — rewrite package.json for the publish surface. Swap entry // points to `dist/`, drop `workspace:*` for real versions, ensure // `files: ["dist"]` so npm pack ships only the built artefacts. // Restored from `originalPkgJson` in the `finally` block at the end. rewriteWorkspaceSpecs(pkgJson, versionByName); rewriteEntryPointsForDist(pkgJson); ensureFilesIncludesDist(pkgJson); writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`); if (dryRun) { await $`cd ${pkgDir} && bun pm pack --dry-run`.quiet(); } else { await $`cd ${pkgDir} && npm publish --registry=${registryUrl} --userconfig ${npmrcPath}`.quiet(); } published++; console.log(` ✓ ${dryRun ? 'packed' : 'published'}`); } catch (err) { const out = `${err instanceof Error ? err.message : String(err)} ${ (err as { stderr?: { toString(): string } }).stderr?.toString() ?? '' } ${(err as { stdout?: { toString(): string } }).stdout?.toString() ?? ''}`; if (/409|EPUBLISHCONFLICT|already exists|already been published/i.test(out)) { alreadyPublished++; console.log(` ⊙ already published — skipping`); } else { failed++; console.error(` ✗ failed: ${(err as Error).message}`); process.exitCode = 1; } } finally { writeFileSync(pkgJsonPath, originalPkgJson); } } try { await $`rm ${npmrcPath}`.quiet(); } catch {} console.log(); console.log( `Done: ${published} ${dryRun ? 'packed' : 'published'}, ${alreadyPublished} already published, ${skipped} skipped, ${failed} failed`, ); } function rewriteWorkspaceSpecs( pkgJson: Record, versionByName: Map, ): void { const sections = ['dependencies', 'peerDependencies', 'optionalDependencies'] as const; for (const section of sections) { const deps = pkgJson[section]; if (!deps || typeof deps !== 'object') continue; for (const [name, spec] of Object.entries(deps as Record)) { if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue; const version = versionByName.get(name); if (!version) { throw new Error( `No workspace version known for ${name} (referenced from ${pkgJson.name as string}). ` + `Add it to PACKAGES or remove the workspace dependency.`, ); } (deps as Record)[name] = `^${version}`; } } } /** * Swap `src/*.ts` references in `main`, `types`, and every `exports.` * entry over to `dist/*.{js,d.ts}` for the duration of the publish. The * in-repo `package.json` is restored from `originalPkgJson` after pack so * the source-pointing form survives in git. * * Why this lives at publish time instead of permanently: tooling that runs * before any build (`tsc --noEmit`, IDE hover, the workspace dev loop) needs * `main` to point at source so cross-package imports resolve without a * forced build pass. Shipping dist-only is a consumer-facing concern. */ function rewriteEntryPointsForDist(pkgJson: Record): void { if (typeof pkgJson.main === 'string') { pkgJson.main = pkgJson.main.replace(/^src\//, 'dist/').replace(/\.ts$/, '.js'); } if (typeof pkgJson.types === 'string') { pkgJson.types = pkgJson.types.replace(/^src\//, 'dist/').replace(/\.ts$/, '.d.ts'); } if (pkgJson.exports !== undefined) { pkgJson.exports = mapExportsForDist(pkgJson.exports); } } function mapExportsForDist(node: unknown, isTypesCondition = false): unknown { if (typeof node === 'string') { if (!node.startsWith('./src/')) return node; return node .replace(/^\.\/src\//, './dist/') .replace(/\.ts$/, isTypesCondition ? '.d.ts' : '.js'); } if (node === null || typeof node !== 'object' || Array.isArray(node)) { return node; } const out: Record = {}; for (const [key, value] of Object.entries(node as Record)) { out[key] = mapExportsForDist(value, key === 'types' || isTypesCondition); } return out; } function ensureFilesIncludesDist(pkgJson: Record): void { const files = pkgJson['files']; if (Array.isArray(files)) { if (!files.includes('dist')) files.push('dist'); } else { pkgJson['files'] = ['dist']; } } main().catch((err) => { console.error(err); process.exit(1); });