Files
gui/scripts/publish-gui-surface.mjs
T
zeekayandHanzo Dev e87d0068c8 Refuse to publish a tarball that names a local path
workspace:, link: and file: resolve only inside the monorepo that
declares them, so any of the three in a published manifest is a
consumer install that dies. The rewrite handled workspace: alone, and
it trusted the manifest it had just written — but npm pack runs
prepack and prepare, which are free to rewrite package.json again.

Cover all three protocols and read the manifest back out of the
archive before pushing it. A version is immutable once it lands:
@hanzogui/[email protected] still carries eleven of these and no publish can
repair it.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:15:16 -07:00

187 lines
6.3 KiB
JavaScript

// Publish exactly the gui surface — every `@hanzogui/*` package + the
// `hanzogui` umbrella + the `@hanzo/gui` alias — at the current on-disk
// version, with workspace:* specs rewritten to concrete versions.
//
// Unlike `release.ts --force-publish-all`, this does NOT touch unrelated
// workspace packages (fonts, demos…) and uses
// `npm pack --json` to publish the produced tarball directly (no fragile
// rename step). Idempotent: skips any name@version already on npm.
//
// Auth: relies on the npm registry config already in place (CI writes
// ~/.npmrc with the token). Run from the repo root.
import fs from 'node:fs'
import path from 'node:path'
import { execSync, execFileSync } from 'node:child_process'
const ROOT = process.cwd()
const PKGS = path.join(ROOT, 'pkgs')
const DEST = '/tmp/gui-surface'
fs.rmSync(DEST, { recursive: true, force: true })
fs.mkdirSync(DEST, { recursive: true })
// 1. Collect every workspace package.json (skip node_modules/dist).
const pkgDirs = []
function walk(d) {
const base = path.basename(d)
if (base === 'node_modules' || base === 'dist') return
if (fs.existsSync(path.join(d, 'package.json'))) pkgDirs.push(d)
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) walk(path.join(d, e.name))
}
}
walk(PKGS)
const versionMap = {}
const meta = {}
for (const d of pkgDirs) {
try {
const j = JSON.parse(fs.readFileSync(path.join(d, 'package.json')))
if (j.name && j.version) {
versionMap[j.name] = j.version
meta[j.name] = { dir: d, json: j }
}
} catch {}
}
// 2. Targets: the gui surface only, excluding private packages.
const targets = Object.keys(meta)
.filter(
(n) => (n === 'hanzogui' || n.startsWith('@hanzogui/')) && !meta[n].json.private
)
.sort()
console.log(
`Publishing ${targets.length} gui packages (+ @hanzo/gui alias) at their on-disk versions\n`
)
// `workspace:`, `link:` and `file:` all resolve only inside the monorepo that
// declares them. Any of the three in a published manifest is a consumer install
// that dies with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND / EUNSUPPORTEDPROTOCOL, and
// the version is immutable once it lands — @hanzogui/[email protected] still carries 11
// of them and nothing can be published to repair it.
const LOCAL_PROTOCOLS = ['workspace:', 'link:', 'file:']
const localProtocol = (spec) =>
typeof spec === 'string' && LOCAL_PROTOCOLS.find((p) => spec.startsWith(p))
const DEP_FIELDS = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]
function rewriteWorkspaceSpecs(deps, name) {
if (!deps) return
for (const k of Object.keys(deps)) {
const proto = localProtocol(deps[k])
if (!proto) continue
const v = versionMap[k]
if (!v)
throw new Error(
`${name}: dependency ${k} is ${proto}* but has no resolvable version`
)
deps[k] = v
}
}
// Read back what npm actually put in the tarball. The rewrite above works on
// the manifest we wrote, but `npm pack` runs lifecycle scripts (prepack,
// prepare) that are free to rewrite package.json again — so the only manifest
// worth asserting on is the one inside the archive we are about to push.
function assertTarballResolvable(tgz, cwd, name) {
const raw = execFileSync('tar', ['-xOzf', tgz, 'package/package.json'], {
cwd,
maxBuffer: 32 * 1024 * 1024,
}).toString()
const j = JSON.parse(raw)
const offences = []
for (const f of DEP_FIELDS) {
for (const [k, spec] of Object.entries(j[f] || {})) {
const proto = localProtocol(spec)
if (proto) offences.push(`${f}.${k} = ${spec}`)
}
}
if (offences.length)
throw new Error(
`${name}: tarball carries unresolvable specifiers — ${offences.join(', ')}`
)
}
const published = []
const skipped = []
const failed = []
function alreadyOnNpm(name, version) {
try {
execFileSync('npm', ['view', `${name}@${version}`, 'version'], { stdio: 'pipe' })
return true
} catch {
return false
}
}
function publishPackage(srcDir, baseJson, overrideName) {
const name = overrideName || baseJson.name
const version = baseJson.version
if (alreadyOnNpm(name, version)) {
skipped.push(`${name}@${version}`)
console.log(`= skip ${name}@${version} (already on npm)`)
return
}
try {
const tmp = fs.mkdtempSync(path.join(DEST, 'p-'))
execSync(`cp -R ${JSON.stringify(srcDir)}/. ${JSON.stringify(tmp)}/`)
fs.rmSync(path.join(tmp, 'node_modules'), { recursive: true, force: true })
const j = JSON.parse(fs.readFileSync(path.join(tmp, 'package.json')))
j.name = name
for (const f of DEP_FIELDS) {
rewriteWorkspaceSpecs(j[f], name)
}
fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify(j, null, 2) + '\n')
const packOut = execFileSync('npm', ['pack', '--json'], { cwd: tmp }).toString()
const tgz = JSON.parse(packOut)[0].filename
assertTarballResolvable(tgz, tmp, name)
execFileSync('npm', ['publish', path.join(tmp, tgz), '--access', 'public'], {
cwd: tmp,
stdio: 'inherit',
})
published.push(`${name}@${version}`)
console.log(`+ published ${name}@${version}`)
} catch (e) {
const msg = (e && e.message ? e.message : String(e)).split('\n')[0]
failed.push(`${name}@${version}: ${msg}`)
console.error(`x FAIL ${name}@${version}: ${msg}`)
}
}
for (const name of targets) {
publishPackage(meta[name].dir, meta[name].json)
}
// The umbrella. It is NAMED `@hanzo/gui` in the tree, so it matches neither the
// `@hanzogui/*` target filter above nor the `hanzogui` lookup this used to do —
// and it therefore published nothing. Every primitive went to the registry at
// 8.3.1 while the one package consumers actually depend on stayed at 8.3.0, so a
// `@hanzo/[email protected]` pin resolved to nothing at all.
//
// Take it by name, whichever of the two it wears.
const umbrella = meta['@hanzo/gui'] || meta['hanzogui']
if (umbrella) {
publishPackage(umbrella.dir, umbrella.json, '@hanzo/gui')
} else {
console.error('! no umbrella package found — @hanzo/gui was NOT published')
failed.push('@hanzo/gui (package not found in the tree)')
}
console.log(`\n--- summary ---`)
console.log(`published: ${published.length}`)
console.log(`skipped: ${skipped.length}`)
console.log(`failed: ${failed.length}`)
if (failed.length) {
failed.forEach((f) => console.error(' - ' + f))
process.exit(1)
}