From c5b232c7fab61bccd8afa6256032f42b4a394af6 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Mon, 24 Aug 2026 22:04:18 +0530 Subject: [PATCH] fix(npm): scope the platform packages to sidestep npm spam detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1.2 release published five unscoped platform packages and was then refused on the sixth: 403 Package name triggered spam detection - PUT modelslab-cli-win32-x64 Not a token problem — the five before it published fine, with signed provenance, seconds earlier. A thin, unscoped package whose name matches a very common platform-suffix pattern is the shape that heuristic targets, and `win32-x64` is the most heavily squatted suffix of the six. Platform packages become @modelslab/cli--. A scope proves ownership and takes them out of the unscoped-name heuristic entirely, which is why every comparable CLI is scoped: @esbuild/win32-x64, @biomejs/cli-win32-x64, @anthropic-ai/claude-code-win32-x64. esbuild's unscoped esbuild-windows-64 has been frozen at 0.15.18 since they migrated for the same reason. The entry package stays unscoped as `modelslab-cli`, so `npm install -g modelslab-cli` is unchanged and still findable by name. Platform packages also gain a README. A two-file package with no readable content is part of what reads as spam, and "do not install this directly" is worth saying to anyone who lands on one. The five unscoped packages v0.1.2 published are orphaned at that version. Nothing references them — the entry package never published — and npm does not permit unpublishing, so they are left alone. Verified end to end against a real goreleaser snapshot: six scoped packages plus the entry package build, the entry pins all six by exact version, publish.sh finds them under the scope directory, and an install of the entry plus one platform package produces a working `modelslab --version`. Requires the @modelslab organisation to exist on npm and NPM_TOKEN to have publish rights to it. --- .github/workflows/ci.yml | 2 +- packaging/README.md | 21 +++++++++++++++++--- packaging/npm/build.mjs | 42 +++++++++++++++++++++++++++++++++++++--- packaging/npm/publish.sh | 17 ++++++++++++++-- packaging/npm/shim.cjs | 12 ++++++------ 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5790bd9..3a77d26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: set -euo pipefail mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null npm install --no-audit --no-fund \ - "$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \ + "$GITHUB_WORKSPACE/dist/npm/@modelslab/cli-linux-x64" \ "$GITHUB_WORKSPACE/dist/npm/modelslab-cli" # The real check: the shim resolves the binary and the binary runs. ./node_modules/.bin/modelslab --version diff --git a/packaging/README.md b/packaging/README.md index b163bef..86a6136 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -25,9 +25,24 @@ node packaging/npm/build.mjs v1.2.3 artifacts dist/npm ``` Produces seven packages: one entry package (`modelslab-cli`) whose only content -is a launcher shim, plus one package per platform holding just the binary and -the `os`/`cpu` fields npm filters on. The entry package declares the six as -`optionalDependencies`, so npm installs exactly the one that matches. +is a launcher shim, plus one package per platform (`@modelslab/cli--`) +holding the binary, a README and the `os`/`cpu` fields npm filters on. The entry +package declares the six as `optionalDependencies`, so npm installs exactly the +one that matches. + +**Platform packages are scoped; the entry package is not.** Unscoped +`modelslab-cli-win32-x64` was refused during the v0.1.2 release with +`403 Package name triggered spam detection` — a thin, unscoped package whose name +matches a very common platform-suffix pattern is precisely the shape that +heuristic targets. A scope proves ownership and sidesteps it, which is why every +comparable CLI is scoped (`@esbuild/win32-x64`, `@biomejs/cli-win32-x64`, +`@anthropic-ai/claude-code-win32-x64`); esbuild's unscoped `esbuild-windows-64` +has been frozen at 0.15.18 since they migrated. The entry package stays unscoped +so `npm install -g modelslab-cli` is unchanged and findable by name. + +The five unscoped platform packages published by v0.1.2 are orphaned at that +version. They are unreachable — no entry package ever referenced them — and npm +does not allow unpublishing them, so they are simply left alone. The alternative — one package with a postinstall that downloads a binary — was rejected deliberately. It needs network at install time and produces a silently diff --git a/packaging/npm/build.mjs b/packaging/npm/build.mjs index be50c1b..ce27383 100644 --- a/packaging/npm/build.mjs +++ b/packaging/npm/build.mjs @@ -21,6 +21,21 @@ import { mkdirSync, writeFileSync, copyFileSync, existsSync, chmodSync } from 'n import { join, resolve } from 'node:path'; const NAME = 'modelslab-cli'; +/* + * Platform packages are SCOPED; the entry package is not. + * + * Unscoped `modelslab-cli-win32-x64` was refused by npm with + * `403 Package name triggered spam detection` — a thin, unscoped package whose + * name matches a very common platform-suffix pattern is the exact shape that + * heuristic targets. A scope proves ownership and sidesteps it, which is why + * every comparable CLI is scoped (@esbuild/win32-x64, @biomejs/cli-win32-x64, + * @anthropic-ai/claude-code-win32-x64). esbuild's unscoped esbuild-windows-64 + * is frozen at 0.15.18 for the same reason. + * + * The entry package stays unscoped so `npm install -g modelslab-cli` is + * unchanged and still discoverable by name. + */ +const SCOPE = '@modelslab'; const BIN = 'modelslab'; const REPO = 'https://github.com/ModelsLab/modelslab-cli'; const DESCRIPTION = @@ -73,7 +88,7 @@ for (const target of TARGETS) { process.exit(1); } - const pkgName = `${NAME}-${target.os}-${target.cpu}`; + const pkgName = `${SCOPE}/cli-${target.os}-${target.cpu}`; const pkgDir = join(outDir, pkgName); mkdirSync(join(pkgDir, 'bin'), { recursive: true }); @@ -91,8 +106,9 @@ for (const target of TARGETS) { description: `${DESCRIPTION} (${target.os} ${target.cpu} binary)`, os: [target.os], cpu: [target.cpu], - // Only the binary. No lifecycle scripts, nothing to execute at install. - files: ['bin'], + // The binary and the README. No lifecycle scripts, nothing to + // execute at install. + files: ['bin', 'README.md'], preferUnplugged: true, }, null, @@ -100,6 +116,26 @@ for (const target of TARGETS) { ) + '\n' ); + writeFileSync( + join(pkgDir, 'README.md'), + [ + `# ${pkgName}`, + '', + `The ${target.os} ${target.cpu} binary for the [ModelsLab CLI](https://www.npmjs.com/package/${NAME}).`, + '', + '**Do not install this package directly.** It is an optional dependency of', + `\`${NAME}\`, which selects the right one for your platform:`, + '', + '```bash', + `npm install -g ${NAME}`, + '```', + '', + `- Source: ${REPO}`, + '- Docs: https://docs.modelslab.com', + '', + ].join('\n') + ); + platformPackages.push(pkgName); console.log(`built ${pkgName}`); } diff --git a/packaging/npm/publish.sh b/packaging/npm/publish.sh index ec18100..af2ccdf 100755 --- a/packaging/npm/publish.sh +++ b/packaging/npm/publish.sh @@ -20,6 +20,9 @@ set -euo pipefail DIST="${1:?usage: publish.sh }" ENTRY="modelslab-cli" +# Platform packages are scoped, so they land one directory deeper than the entry +# package: dist/npm/@modelslab/cli--. +PLATFORM_GLOB="${DIST}/@*/*" MAX_ATTEMPTS=5 already_published() { @@ -68,14 +71,24 @@ publish_one() { return 1 } -for dir in "${DIST}"/${ENTRY}-*; do +published_any=0 +for dir in ${PLATFORM_GLOB}; do [ -d "$dir" ] || continue publish_one "$dir" + published_any=1 # Pace the platform packages. Publishing them as fast as the API allows is - # what looks like spam in the first place. + # part of what looks like spam in the first place. sleep 10 done +# Guard against a glob that silently matched nothing: the entry package pins all +# six platform packages, so publishing it alone creates a version that resolves +# for nobody. +if [ "$published_any" -eq 0 ]; then + echo "fatal no platform packages found under ${PLATFORM_GLOB}" >&2 + exit 1 +fi + publish_one "${DIST}/${ENTRY}" echo "npm publish complete" diff --git a/packaging/npm/shim.cjs b/packaging/npm/shim.cjs index 8f4790c..7ef8152 100644 --- a/packaging/npm/shim.cjs +++ b/packaging/npm/shim.cjs @@ -13,12 +13,12 @@ const { existsSync, realpathSync } = require('node:fs'); const { join, dirname, sep } = require('node:path'); const PLATFORM_PACKAGES = { - 'darwin x64': 'modelslab-cli-darwin-x64', - 'darwin arm64': 'modelslab-cli-darwin-arm64', - 'linux x64': 'modelslab-cli-linux-x64', - 'linux arm64': 'modelslab-cli-linux-arm64', - 'win32 x64': 'modelslab-cli-win32-x64', - 'win32 arm64': 'modelslab-cli-win32-arm64', + 'darwin x64': '@modelslab/cli-darwin-x64', + 'darwin arm64': '@modelslab/cli-darwin-arm64', + 'linux x64': '@modelslab/cli-linux-x64', + 'linux arm64': '@modelslab/cli-linux-arm64', + 'win32 x64': '@modelslab/cli-win32-x64', + 'win32 arm64': '@modelslab/cli-win32-arm64', }; function resolveBinary() {