Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ web_modules/
# Output of 'npm pack'
*.tgz
bulma-ui/CLAUDE.md.bak
bestax-migrate/package.json.pack-backup

# Yarn Integrity file
.yarn-integrity
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ React component library for **Bulma v1** in TypeScript. pnpm monorepo orchestrat
- `bulma-ui/` — the library, published as `@allxsmith/bestax-bulma` (has its own CLAUDE.md)
- `docs/` — Docusaurus site → https://bestax.io (has its own CLAUDE.md)
- `create-bestax/` — the `npm create bestax` scaffolder (has its own CLAUDE.md)
- `bestax-migrate/` — the `bestax-migrate` codemod CLI (has its own CLAUDE.md)
- `skills/` — Agent Skills, a **shipped product** bundled into create-bestax (has its own CLAUDE.md)
- `scripts/gen-component-catalog.mjs` — generates the skill component catalog (`pnpm gen:catalog`)

Expand Down Expand Up @@ -70,6 +71,9 @@ Full versioning details (breaking-change footers, tag formats): `VERSIONING.md`.
- Install/postinstall scripts are **blocked by default** — new native deps need an `allowBuilds` entry.
- `minimumReleaseAge` cooldown: versions younger than 3 days won't install.
- Isolated node linker: undeclared (phantom) dependencies fail — declare everything you import.
- Published packages must not ship a `workspace:` or `catalog:` specifier — `npm publish`
(what semantic-release runs) resolves neither, and the tarball becomes uninstallable
(#412). `check:conformance --only=publishable-manifests` enforces this.

## Workflow

Expand Down
14 changes: 13 additions & 1 deletion bestax-migrate/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,19 @@ each source library registers in `src/sources/registry.ts`; the first is
## Releases

Independent semantic-release, keyed off the `bestax-migrate` commit scope
(`release.config.js`, tag `bestax-migrate@x.y.z`). The skill lives at repo-root
(`release.config.js`, tag `bestax-migrate@x.y.z`). Publishing goes through
`npm publish`, which — unlike `pnpm publish` — does **not** resolve pnpm's
`workspace:` protocol, so `workspace:^` shipped verbatim in 1.0.0 and made the
package uninstallable (#412). Two rules follow: `@allxsmith/bestax-bulma` stays
a **devDependency** (it is only the typecheck target for the e2e, never
imported at runtime — consumers of a codemod CLI must not be made to install
the component library), and `scripts/pack-manifest.mjs` resolves any remaining
`workspace:` specifier during `prepack`/`postpack`. `pnpm check:conformance
--only=publishable-manifests` enforces the protocol half of both: no
`workspace:`/`catalog:` specifier in the sections consumers resolve, and one
left in `devDependencies` only with the pack hooks present. It does **not**
check which section `@allxsmith/bestax-bulma` sits in — re-adding it as a
plain-semver runtime dependency passes CI, so that one is on review. The skill lives at repo-root
`skills/bestax-migrate/` and is deliberately **not** bundled into create-bestax (existing
sites only) — keep `create-bestax/scripts/sync-skills.mjs` untouched. When the mapping
gains or loses coverage, update `skills/bestax-migrate/references/` and the docs migration
Expand Down
6 changes: 4 additions & 2 deletions bestax-migrate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"clean": "rimraf dist",
"release": "npx semantic-release"
"release": "npx semantic-release",
"prepack": "node scripts/pack-manifest.mjs prepack",
"postpack": "node scripts/pack-manifest.mjs postpack"
},
"keywords": [
"bestax",
Expand All @@ -48,13 +50,13 @@
"url": "https://github.com/allxsmith/bestax/issues"
},
"dependencies": {
"@allxsmith/bestax-bulma": "workspace:^",
"@babel/parser": "^7.26.0",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"jscodeshift": "^17.3.0"
},
"devDependencies": {
"@allxsmith/bestax-bulma": "workspace:^",
"@jest/globals": "^30.0.0",
"@types/jest": "^30.0.0",
"@types/jscodeshift": "^17.3.0",
Expand Down
159 changes: 159 additions & 0 deletions bestax-migrate/scripts/pack-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* Resolves pnpm `workspace:` specifiers in package.json while the tarball is
* packed (issue #412).
*
* `pnpm publish` rewrites `workspace:^` to the real semver range at pack time;
* `npm publish` does NOT — and the release pipeline runs `@semantic-release/npm`,
* which shells out to `npm publish`. That shipped `bestax-migrate@1.0.0` with a
* literal `"workspace:^"` in its manifest, making it uninstallable by every
* package manager (`EUNSUPPORTEDPROTOCOL`).
*
* So do the rewrite ourselves. The repo file must come back untouched — the
* release commits package.json — so `prepack` backs it up and `postpack`
* restores it, exactly like bulma-ui/scripts/pack-pointer-files.mjs.
*
* This runs over EVERY dependency section, not just `dependencies`: a
* `workspace:` specifier is meaningless outside the workspace wherever it
* appears. The companion guard is the `publishable-manifests` sub-check in
* scripts/check-conformance.mjs, which fails CI if a published package ever
* declares a workspace dep in `dependencies` (a runtime dep the tarball would
* need) rather than `devDependencies`.
*
* Note for reviewers: the published manifest still carries the `prepack` /
* `postpack` hooks pointing here, while `files: ["dist"]` keeps this script out
* of the tarball. That is deliberate and inert — npm runs those hooks on
* pack/publish, never on install from a tarball, so the path is never followed
* by a consumer. Shipping `scripts/` to fix the dangling reference would add
* dead weight to every install, and stripping the hooks during `prepack` would
* risk npm not running `postpack` and leaving the repo manifest rewritten for
* @semantic-release/git to commit — the exact failure the backup below exists
* to prevent.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const manifest = path.join(pkgRoot, 'package.json');
const backup = path.join(pkgRoot, 'package.json.pack-backup');

const DEP_SECTIONS = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
];

/** Version of a workspace package, read through the linked node_modules entry. */
function resolveWorkspaceVersion(name) {
const linked = path.join(pkgRoot, 'node_modules', name, 'package.json');
if (!fs.existsSync(linked)) {
console.error(
`pack-manifest: cannot resolve the workspace dependency "${name}" — ` +
`${path.relative(pkgRoot, linked)} does not exist.\n` +
'Run `pnpm install` from the repo root before packing.'
);
process.exit(1);
}
const { version } = JSON.parse(fs.readFileSync(linked, 'utf8'));
if (!version) {
console.error(`pack-manifest: "${name}" has no version in its manifest`);
process.exit(1);
}
return version;
}

/**
* `workspace:^` / `workspace:~` / `workspace:*` take the prefix from the
* protocol and the version from the linked package; `workspace:<range>`
* (e.g. `workspace:^5.0.0`) already carries its own range, so just unwrap it.
* A bare `workspace:` is pnpm's shorthand for `workspace:*` — leaving it to the
* unwrap branch would emit an empty specifier.
*/
function resolveSpecifier(name, spec) {
const rest = spec.slice('workspace:'.length);
if (rest === '*' || rest === '') return resolveWorkspaceVersion(name);
if (rest === '^' || rest === '~')
return `${rest}${resolveWorkspaceVersion(name)}`;
// `workspace:<name>@<range>` is pnpm's alias form, and it does NOT publish as
// a bare range: pnpm emits `npm:<name>@<version>`. Unwrapping it would write
// "@scope/pkg@^5", which no package manager can install — #412 again, wearing
// a different hat. A semver range never contains "/" or a non-leading "@",
// so this only catches the alias form.
if (rest.includes('/') || rest.lastIndexOf('@') > 0) {
console.error(
`pack-manifest: "${name}": "${spec}" is pnpm's alias form, which ` +
`publishes as \`npm:<name>@<version>\`.\n` +
'This script does not synthesize that. Depend on the package under its ' +
'real name, or teach pack-manifest.mjs the npm: alias rewrite.'
);
process.exit(1);
}
return rest;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const mode = process.argv[2];

if (mode === 'prepack') {
if (fs.existsSync(backup)) {
console.error(
'pack-manifest: package.json.pack-backup already exists — a previous ' +
'pack did not finish.\n' +
'Restore the workspace manifest first: mv package.json.pack-backup package.json'
);
process.exit(1);
}

const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
const rewritten = [];

for (const section of DEP_SECTIONS) {
for (const [name, spec] of Object.entries(pkg[section] ?? {})) {
if (typeof spec !== 'string') continue;
// `catalog:` is the other protocol `pnpm publish` resolves and
// `npm publish` ships verbatim — the exact shape of #412. This script
// cannot resolve it (the range lives in pnpm-workspace.yaml, not in the
// linked package), so fail the release rather than pack a broken tarball.
if (spec.startsWith('catalog:')) {
console.error(
`pack-manifest: ${section}.${name} is "${spec}", and this script ` +
`cannot resolve the catalog: protocol.\n` +
'Give it a plain semver range, or teach pack-manifest.mjs to read ' +
'`catalog`/`catalogs` from pnpm-workspace.yaml.'
);
process.exit(1);
}
if (!spec.startsWith('workspace:')) continue;
const resolved = resolveSpecifier(name, spec);
pkg[section][name] = resolved;
rewritten.push(`${section}.${name}: ${spec} -> ${resolved}`);
}
}

if (!rewritten.length) {
console.log('pack-manifest: no workspace: specifiers to resolve');
process.exit(0);
}

fs.copyFileSync(manifest, backup);
// Keep npm's own formatting (2-space + trailing newline) so the restored
// file and the packed one differ only in the specifiers.
fs.writeFileSync(manifest, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(
`pack-manifest: resolved ${rewritten.length} workspace specifier(s)`
);
for (const line of rewritten) console.log(` ${line}`);
} else if (mode === 'postpack') {
if (!fs.existsSync(backup)) {
// prepack exits early when there is nothing to rewrite; that is not an error.
console.log('pack-manifest: no backup to restore');
process.exit(0);
}
fs.copyFileSync(backup, manifest);
fs.rmSync(backup);
console.log('pack-manifest: workspace manifest restored');
} else {
console.error('Usage: node scripts/pack-manifest.mjs <prepack|postpack>');
process.exit(1);
}
Loading
Loading