Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:

- name: Verify no private URLs in package-lock.json
shell: bash
run: '! grep -E "\"resolved\": \"https?://" package-lock.json | grep -v registry.npmjs.org'
run: node scripts/check-lockfile-registry.mjs

- name: Verify example dependency versions
shell: bash
Expand Down
10 changes: 4 additions & 6 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ export NVM_DIR="$HOME/.nvm"
[ -d "/opt/homebrew/bin" ] && export PATH="/opt/homebrew/bin:$PATH" # homebrew (macOS ARM)
[ -d "/usr/local/bin" ] && export PATH="/usr/local/bin:$PATH" # homebrew (macOS Intel)

# Verify no private registry URLs in package-lock.json
if grep -E '"resolved": "https?://' package-lock.json | grep -v registry.npmjs.org > /dev/null; then
echo "ERROR: package-lock.json contains non-npmjs.org URLs"
echo "Run: docker run --rm -i -v \$PWD:/src -w /src node:latest npm i --registry=https://registry.npmjs.org/"
exit 1
fi
# Verify no private registry URLs in package-lock.json. `npm install` records
# whichever registry it resolved against (e.g. a corporate proxy from
# ~/.npmrc); the committed lockfile must reference only the public registry.
node scripts/check-lockfile-registry.mjs

# Capture staged files so we only re-stage what the user intended to commit
# (avoids sweeping unrelated WIP into the commit). --diff-filter=d skips
Expand Down
9 changes: 8 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
registry=https://registry.npmjs.org/
# Intentionally no `registry=` pin here. The npm default is the public
# registry (https://registry.npmjs.org/), and contributors who fetch through a
# corporate proxy registry should keep using it (it's their supply-chain gate).
#
# What *must* hold is that the committed package-lock.json references only the
# public registry — that's enforced by `npm run lint:lockfile`
# (scripts/check-lockfile-registry.mjs), which runs in CI and the pre-commit
# hook, and auto-fixed via `npm run lint:lockfile -- --fix`.
19 changes: 11 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@ npm test
# Check JSDoc comment syntax and `{@link}` references
npm exec typedoc -- --treatValidationWarningsAsErrors --emit none

# Regenerate package-lock.json
# Note: repo .npmrc pins registry to npmjs.org, so a plain `npm i` is safe even
# if your global npm config points elsewhere. The Docker step below is optional
# — it locks linux-amd64 optionalDependencies (sharp, rollup, bun) for CI.
rm -fR package-lock.json node_modules && \
docker run --rm -it --platform linux/amd64 -v $PWD:/src:rw -w /src node:latest npm i && \
rm -fR node_modules && \
npm i --cache=~/.npm-mcp-apps --registry=https://registry.npmjs.org/
# Regenerate package-lock.json from scratch (the Docker step locks
# linux-amd64 optionalDependencies — sharp, rollup, bun — for CI).
# Use whatever registry your machine is configured for; the final --fix step
# rewrites the committed lockfile to public registry.npmjs.org URLs.
npm run update-lock:docker

# Verify the lockfile only references the public npm registry (also runs in
# CI and the pre-commit hook). Use --fix after `npm install <pkg>` against a
# proxy registry to rewrite the resolved URLs.
npm run lint:lockfile
npm run lint:lockfile -- --fix
```

## Architecture
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@
"prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check",
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write",
"check:versions": "node scripts/check-versions.mjs",
"lint:lockfile": "node scripts/check-lockfile-registry.mjs",
"bump": "node scripts/bump-version.mjs",
"update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/"
"update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i && node scripts/check-lockfile-registry.mjs --fix"
},
"author": "Olivier Chafik",
"devDependencies": {
Expand Down
125 changes: 125 additions & 0 deletions scripts/check-lockfile-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Checks that package-lock.json only references the public npm registry.
*
* Why: contributors and CI may have a corporate proxy registry configured
* (e.g. via `~/.npmrc` or `npm_config_registry`). npm records the registry it
* resolved against in each entry's `resolved` field. Committing those URLs
* leaks an internal hostname, breaks installs for everyone else, and can
* record a different `integrity` digest than the public registry would.
*
* Usage:
* node scripts/check-lockfile-registry.mjs # check (exit 1 on leak)
* node scripts/check-lockfile-registry.mjs --fix # rewrite leaked entries
* # from registry.npmjs.org
*
* `--fix` looks up each offending `<name>@<version>` on registry.npmjs.org and
* rewrites the entry's `resolved` / `integrity` to the public registry's
* canonical values, then re-stringifies the lockfile with npm's two-space
* indentation. It is safe to run after `npm install <pkg>` against a proxy.
*
* Run automatically by `.husky/pre-commit` and CI.
*/

import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const PUBLIC_REGISTRY = "https://registry.npmjs.org/";
const LOCKFILE = resolve(
dirname(fileURLToPath(import.meta.url)),
"..",
"package-lock.json",
);

const fix = process.argv.includes("--fix");

let raw;
try {
raw = readFileSync(LOCKFILE, "utf8");
} catch {
// No lockfile (fresh clone before install) — nothing to check.
process.exit(0);
}
const lock = JSON.parse(raw);

/** @type {{ path: string, name: string, version: string, pkg: any }[]} */
const offenders = [];
for (const [path, pkg] of Object.entries(lock.packages ?? {})) {
if (!path || !pkg.resolved) continue; // root / workspace member
if (!/^https?:\/\//.test(pkg.resolved)) continue; // git, file, link…
if (pkg.resolved.startsWith(PUBLIC_REGISTRY)) continue;
const name = path.replace(/^.*node_modules\//, "");
offenders.push({ path, name, version: pkg.version, pkg });
}

if (offenders.length === 0) process.exit(0);

if (!fix) {
console.error(
`package-lock.json references ${offenders.length} non-public registry URL(s):\n`,
);
for (const { name, version, pkg } of offenders.slice(0, 20)) {
console.error(` ${name}@${version}\n ${pkg.resolved}`);
}
if (offenders.length > 20)
console.error(` … and ${offenders.length - 20} more`);
console.error(`
This usually means a dependency was added while a corporate proxy registry was
configured (~/.npmrc, npm_config_registry, etc.). Public lockfiles must only
reference ${PUBLIC_REGISTRY} so they're reproducible and don't leak internal
hostnames.

To fix:
node scripts/check-lockfile-registry.mjs --fix
git add package-lock.json
`);
process.exit(1);
}

// --fix: rewrite each offending entry from registry.npmjs.org metadata.
const fetchMeta = async (name, version) => {
const url = `${PUBLIC_REGISTRY}${name.replaceAll("/", "%2f")}/${version}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`${name}@${version}: ${res.status} ${res.statusText} from ${PUBLIC_REGISTRY} — ` +
`not a public package? It can't be referenced from a public lockfile.`,
);
}
return /** @type {{ dist: { tarball: string, integrity?: string, shasum?: string } }} */ (
await res.json()
);
};

let failed = false;
for (const { name, version, pkg } of offenders) {
let dist;
try {
({ dist } = await fetchMeta(name, version));
} catch (err) {
console.error(String(err));
failed = true;
continue;
}
const integrity =
dist.integrity ??
(dist.shasum
? `sha1-${Buffer.from(dist.shasum, "hex").toString("base64")}`
: undefined);
if (!integrity) {
console.error(
`${name}@${version}: no integrity from registry; fix manually.`,
);
failed = true;
continue;
}
pkg.resolved = dist.tarball;
pkg.integrity = integrity;
console.log(`fixed ${name}@${version}`);
}

if (failed) process.exit(1);
writeFileSync(LOCKFILE, JSON.stringify(lock, null, 2) + "\n");
console.log("package-lock.json sanitized.");
Loading