mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* feat(install): enforce and guard the zero-warning npm install contract
`npm install -g @gitlawb/openclaude` is verified zero-warning today, but
nothing kept it that way: the runtime deps were caret ranges resolved
fresh on every user install (the published tarball ships no lockfile),
no CI step ever installed the package, and registry-side drift (a
transitive dep deprecated after we ship) is invisible to file-based CI.
Static contract (fast, offline, every PR via `bun run build`):
- Pin the 3 runtime deps to exact versions so the verified resolution IS
the shipped resolution.
- New validators in scripts/externalsValidation.ts (unit-tested):
dependencies must equal RUNTIME_DEPENDENCY_CONTRACT exactly (no ranges,
no unreviewed additions), no consumer-run install hooks or funding
field, engines.node pinned. Wired into validate-externals.ts.
Runtime verification (scripts/verify-clean-install.ts, `install:verify`):
- Tarball mode (release gate) and published mode (registry watch), each
running cold-install and upgrade-over-previous scenarios in throwaway
prefixes with a cold cache and normalized env/flags.
- Strict output whitelist (summary lines only) with network failures
retried and reported as infra (exit 2), never as a hygiene verdict.
- Structural authority over the installed tree: any package declaring
install scripts fails, the installed manifest must match the static
contract, tarball payload/size asserted.
- Boot must be silent: --version prints the exact packed version;
--help (which, unlike the --version zero-import fast path, loads the
real bundle) must exit 0 with empty stderr.
CI: release publishes only after the verify passes on Node 22 (npm 10,
the supported floor — warning phrasing and EBADENGINE behavior differ
from npm 11) and Node 24, plus a final gate on the publishing machine
replacing `npm pack --dry-run`. A daily install-hygiene workflow
re-verifies the published @latest on {ubuntu, macos, windows} x
{Node 22, 24} — the only defense against post-release registry drift,
and the OS matrix covers the per-platform @vscode/ripgrep packages.
Found-by-the-guard fix: a fresh machine printed "Warning: ignoring saved
provider profile. OPENGATEWAY_API_KEY is required..." on every command
(even --help) because the injected fresh-install Opengateway default
fails validation without a key (#1651 chose ignore+warn). The default
env is still ignored, but the warning now only fires for genuinely
persisted profiles; published 0.24.0 carries the old noise, so the
verify script exempts exactly that version until the next release.
Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
* fix(install): address CodeRabbit review on the install-hygiene guard
- release.yml: pin install-verify to least-privilege `contents: read` and
disable credential persistence on its checkout; same persist-credentials
hardening on the install-hygiene cron checkout.
- verify-clean-install: previousPublishedVersion now follows the same
retry/infra discipline as installWithRetry — transient registry failures
retry and then exit 2 (infra) instead of silently skipping the
upgrade-scenario coverage; a clean not-published answer still skips.
- providerProfile: the fresh-install warning suppression now keys on
explicit provenance (persisted profile resolved once in
applyStartupEnvFromProfile) instead of sniffing the
DEFAULT_STARTUP_PROVIDER_ENV_VAR marker, which a persisted profile's
env can inherit from a parent CLI process; regression test covers the
marker-collision case.
Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
* test(install): cover previousPublishedVersion retry/skip/infra branches
CodeRabbit follow-up: the branches deciding whether the upgrade-install
scenario runs, skips, or aborts as infra were untested. Extract the loop
as resolvePreviousPublishedVersion with injected effects (runView,
onRetry, onInfraFailure) per the repo's dependency-injection testing
convention, guard main() behind import.meta.main so the test import does
not launch a real verification, and add regression tests: first-try
success, transient-infra retry then success, clean E404 → null skip
without retries, persistent infra → onInfraFailure (exit 2 in the real
wiring), and unparseable version output → null.
Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
---------
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
/**
|
|
* Pure validation helpers for the externals/bundling contract, factored out of
|
|
* validate-externals.ts so the rules (bundle-specific bundled exemptions, the
|
|
* minimal-install dependency placement contract) are unit-testable with
|
|
* synthetic package.json / externals inputs.
|
|
*/
|
|
|
|
export type ValidationResult = { ok: boolean; errors: string[] }
|
|
|
|
export type PkgDeps = {
|
|
dependencies?: Record<string, string>
|
|
peerDependencies?: Record<string, string>
|
|
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
devDependencies?: Record<string, string>
|
|
}
|
|
|
|
export type PkgInstallHygiene = PkgDeps & {
|
|
scripts?: Record<string, string>
|
|
engines?: Record<string, string>
|
|
funding?: unknown
|
|
}
|
|
|
|
/**
|
|
* The exact runtime dependency set shipped to `npm install -g` users — the
|
|
* zero-warning install contract. Every entry is EXACT-pinned on purpose: the
|
|
* published tarball carries no lockfile, so any semver range would re-resolve
|
|
* on every end-user install and the version we verified as warning-free would
|
|
* not be the version users get. Changing this list (or bumping a pin) is a
|
|
* deliberate act: update package.json and this contract together, and re-run
|
|
* `bun run install:verify` so the new resolution is certified clean.
|
|
*
|
|
* Note: package.json `overrides` do NOT apply to consumers of the published
|
|
* tarball — install-noise regressions must be fixed by changing the dependency
|
|
* itself, never papered over with an override.
|
|
*/
|
|
export const RUNTIME_DEPENDENCY_CONTRACT: Readonly<Record<string, string>> = {
|
|
'@orama/orama': '3.1.18',
|
|
'@orama/plugin-data-persistence': '3.1.18',
|
|
'@vscode/ripgrep': '1.18.0',
|
|
}
|
|
|
|
/** Node range advertised to installers; changing it changes who gets EBADENGINE. */
|
|
export const ENGINES_NODE_CONTRACT = '>=22.0.0'
|
|
|
|
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/
|
|
|
|
/**
|
|
* `dependencies` must equal the contract exactly — same names, same exact-pinned
|
|
* versions. A new runtime dep, a dropped one, or a caret/tilde range sneaking
|
|
* back in all fail the build instead of silently changing what users install.
|
|
*/
|
|
export function validateRuntimeDependencyContract(
|
|
pkg: PkgDeps,
|
|
contract: Readonly<Record<string, string>> = RUNTIME_DEPENDENCY_CONTRACT,
|
|
): ValidationResult {
|
|
const deps = pkg.dependencies ?? {}
|
|
const errors: string[] = []
|
|
|
|
const unexpected = Object.keys(deps).filter(d => !(d in contract))
|
|
if (unexpected.length > 0) {
|
|
errors.push(
|
|
`Runtime dependencies not in RUNTIME_DEPENDENCY_CONTRACT (new deps change the zero-warning install surface — verify and update the contract): ${unexpected.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
const missing = Object.keys(contract).filter(d => !(d in deps))
|
|
if (missing.length > 0) {
|
|
errors.push(
|
|
`RUNTIME_DEPENDENCY_CONTRACT entries missing from dependencies: ${missing.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
for (const [name, version] of Object.entries(deps)) {
|
|
const expected = contract[name]
|
|
if (expected === undefined) continue
|
|
if (version !== expected) {
|
|
errors.push(
|
|
`${name}: dependencies has "${version}" but RUNTIME_DEPENDENCY_CONTRACT pins "${expected}" (update both together + re-verify).`,
|
|
)
|
|
} else if (!EXACT_VERSION_RE.test(version)) {
|
|
errors.push(
|
|
`${name}: "${version}" is not an exact version — ranges re-resolve per user install and void the verified zero-warning contract.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors }
|
|
}
|
|
|
|
/**
|
|
* Install-hygiene fields: nothing in our own package.json may run code or print
|
|
* extra lines during a consumer install.
|
|
* - preinstall/install/postinstall execute on every `npm install -g` (script
|
|
* output + a trust prompt surface); prepack/prepare only run for publishers
|
|
* and git installs, so they stay allowed.
|
|
* - a `funding` field adds "looking for funding" lines on some npm configs.
|
|
* - engines.node is pinned so the EBADENGINE boundary only moves deliberately.
|
|
*/
|
|
export function validateInstallHygieneFields(pkg: PkgInstallHygiene): ValidationResult {
|
|
const errors: string[] = []
|
|
const scripts = pkg.scripts ?? {}
|
|
|
|
const consumerHooks = ['preinstall', 'install', 'postinstall'].filter(
|
|
hook => hook in scripts,
|
|
)
|
|
if (consumerHooks.length > 0) {
|
|
errors.push(
|
|
`package.json must not declare consumer-run install hooks (they execute and print on every user install): ${consumerHooks.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
if (pkg.funding !== undefined) {
|
|
errors.push(
|
|
'package.json must not declare a `funding` field (it adds funding lines to user installs).',
|
|
)
|
|
}
|
|
|
|
const enginesNode = pkg.engines?.node
|
|
if (enginesNode !== ENGINES_NODE_CONTRACT) {
|
|
errors.push(
|
|
`engines.node must stay "${ENGINES_NODE_CONTRACT}" (found ${enginesNode === undefined ? 'none' : `"${enginesNode}"`}); changing it moves the EBADENGINE boundary for installers — update ENGINES_NODE_CONTRACT deliberately if intended.`,
|
|
)
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors }
|
|
}
|
|
|
|
/**
|
|
* The set of INTENTIONALLY_BUNDLED packages that are genuinely inlined into a
|
|
* given bundle. A package declared as a peerDependency is provided by the
|
|
* consumer, so it must be EXTERNAL in the SDK bundle and is therefore NOT
|
|
* exempt there — pass `peerDepNames` (from package.json) for the SDK so the
|
|
* exemption stays independent of the externals list it is meant to guard.
|
|
*/
|
|
export function bundledExemptionFor(
|
|
intentionallyBundled: string[],
|
|
externalizedHere: ReadonlySet<string>,
|
|
): Set<string> {
|
|
return new Set(intentionallyBundled.filter(d => !externalizedHere.has(d)))
|
|
}
|
|
|
|
/**
|
|
* Every runtime dependency (shipped `dependencies` + `peerDependencies`) must be
|
|
* a genuine external for a bundle, unless it is intentionally bundled INTO that
|
|
* bundle. Anything else would be missing at runtime for end users.
|
|
*/
|
|
export function validateBundleExternals(
|
|
bundleName: string,
|
|
runtimeDeps: ReadonlySet<string>,
|
|
externals: string[],
|
|
bundledExemption: ReadonlySet<string>,
|
|
): ValidationResult {
|
|
const externalSet = new Set(externals)
|
|
const missing = [...runtimeDeps].filter(
|
|
d => !externalSet.has(d) && !bundledExemption.has(d),
|
|
)
|
|
if (missing.length > 0) {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
`${bundleName}: Dependencies missing from externals: ${missing.join(', ')}`,
|
|
],
|
|
}
|
|
}
|
|
return { ok: true, errors: [] }
|
|
}
|
|
|
|
/**
|
|
* The minimal-install contract for INTENTIONALLY_BUNDLED packages:
|
|
* - every entry must be a devDependency (available to build, not shipped),
|
|
* - none may be a runtime `dependency` (they are inlined; shipping them would
|
|
* install them for every user), and
|
|
* - only the SDK-externalized subset may be an (optional) peerDependency.
|
|
*/
|
|
export function validateIntentionallyBundled(
|
|
pkg: PkgDeps,
|
|
intentionallyBundled: string[],
|
|
sdkOnlyExternals: string[],
|
|
): ValidationResult {
|
|
const directDeps = pkg.dependencies ?? {}
|
|
const peerDeps = pkg.peerDependencies ?? {}
|
|
const devDeps = pkg.devDependencies ?? {}
|
|
const sdkExternalOnly = new Set(sdkOnlyExternals)
|
|
const errors: string[] = []
|
|
|
|
const missingFromDev = intentionallyBundled.filter(dep => !(dep in devDeps))
|
|
if (missingFromDev.length > 0) {
|
|
errors.push(
|
|
`INTENTIONALLY_BUNDLED entries missing from devDependencies: ${missingFromDev.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
const shippedAsRuntime = intentionallyBundled.filter(dep => dep in directDeps)
|
|
if (shippedAsRuntime.length > 0) {
|
|
errors.push(
|
|
`INTENTIONALLY_BUNDLED entries must not be in dependencies (they are inlined): ${shippedAsRuntime.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
const unexpectedPeers = intentionallyBundled.filter(
|
|
dep => dep in peerDeps && !sdkExternalOnly.has(dep),
|
|
)
|
|
if (unexpectedPeers.length > 0) {
|
|
errors.push(
|
|
`INTENTIONALLY_BUNDLED entries in peerDependencies that are not SDK externals: ${unexpectedPeers.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
// Every SDK-external must STAY a peerDependency: the SDK bundle externalizes
|
|
// it, so consumers provide it. If one drops out of peerDependencies it leaves
|
|
// runtimeDeps (and other checks stop seeing it) while the SDK still expects it
|
|
// resolved at the consumer — a broken SDK publish surface.
|
|
const missingPeers = sdkOnlyExternals.filter(dep => !(dep in peerDeps))
|
|
if (missingPeers.length > 0) {
|
|
errors.push(
|
|
`SDK externals must remain peerDependencies (the SDK bundle externalizes them): ${missingPeers.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors }
|
|
}
|
|
|
|
/**
|
|
* The minimal-install goal depends on every peerDependency being OPTIONAL: a
|
|
* non-optional peer makes npm warn (and, on npm 7+, try to install it) for every
|
|
* end user. Assert each declared peer is marked `{ optional: true }` in
|
|
* peerDependenciesMeta so losing that flag fails the build instead of silently
|
|
* regressing the warning-free install.
|
|
*/
|
|
export function validateOptionalPeers(pkg: PkgDeps): ValidationResult {
|
|
const peers = Object.keys(pkg.peerDependencies ?? {})
|
|
const meta = pkg.peerDependenciesMeta ?? {}
|
|
const notOptional = peers.filter(p => meta[p]?.optional !== true)
|
|
if (notOptional.length > 0) {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
`peerDependencies must be marked optional in peerDependenciesMeta (warning-free install): ${notOptional.join(', ')}`,
|
|
],
|
|
}
|
|
}
|
|
return { ok: true, errors: [] }
|
|
}
|
|
|
|
/**
|
|
* OPTIONAL_RUNTIME_EXTERNALS are never shipped and never inlined. Anything
|
|
* esbuild can see statically must therefore stay external in BOTH bundles;
|
|
* dropping one from the externals lists would let esbuild bundle it (a native
|
|
* module like sharp) or hoist its transitive imports. The indirection-only
|
|
* subset (loaded purely via the runtime importer) is the inverse: it must stay
|
|
* OUT of the externals lists, or esbuild would re-introduce its static imports.
|
|
*
|
|
* Also guards both halves of the install contract: optional packages must never
|
|
* be shipped (in dependencies/peerDependencies), and the non-transitive ones
|
|
* must be devDependencies so source/dev builds still resolve them.
|
|
*/
|
|
export function validateOptionalRuntimeExternals(
|
|
optionalRuntimeExternals: string[],
|
|
cliExternals: string[],
|
|
sdkExternals: string[],
|
|
indirectionOnly: string[],
|
|
pkg: PkgDeps = {},
|
|
transitiveExternals: string[] = [],
|
|
): ValidationResult {
|
|
const cli = new Set(cliExternals)
|
|
const sdk = new Set(sdkExternals)
|
|
const indirection = new Set(indirectionOnly)
|
|
const transitive = new Set(transitiveExternals)
|
|
const directDeps = pkg.dependencies ?? {}
|
|
const peerDeps = pkg.peerDependencies ?? {}
|
|
const devDeps = pkg.devDependencies ?? {}
|
|
const errors: string[] = []
|
|
|
|
// The indirection-only set must be a subset of the optional externals (a
|
|
// stray entry would silently exempt something that is not actually optional).
|
|
const strayIndirection = indirectionOnly.filter(
|
|
p => !optionalRuntimeExternals.includes(p),
|
|
)
|
|
if (strayIndirection.length > 0) {
|
|
errors.push(
|
|
`RUNTIME_INDIRECTION_ONLY_EXTERNALS entries not in OPTIONAL_RUNTIME_EXTERNALS: ${strayIndirection.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
// Optional runtime externals are loaded on demand and must NEVER be shipped by
|
|
// default — listing one in dependencies or peerDependencies installs it for
|
|
// every user and breaks the minimal/warning-free install contract.
|
|
const shipped = optionalRuntimeExternals.filter(
|
|
dep => dep in directDeps || dep in peerDeps,
|
|
)
|
|
if (shipped.length > 0) {
|
|
errors.push(
|
|
`OPTIONAL_RUNTIME_EXTERNALS must not be shipped (found in dependencies/peerDependencies): ${shipped.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
// Source-install contract: optional packages that source code references
|
|
// directly must be devDependencies so `bun install` source/dev builds resolve
|
|
// them. The transitive set is exempt (provided by another optional package's
|
|
// dependency tree, e.g. @aws-sdk/* via @anthropic-ai/bedrock-sdk).
|
|
const missingFromDev = optionalRuntimeExternals.filter(
|
|
dep => !transitive.has(dep) && !(dep in devDeps),
|
|
)
|
|
if (missingFromDev.length > 0) {
|
|
errors.push(
|
|
`OPTIONAL_RUNTIME_EXTERNALS missing from devDependencies (source builds need them): ${missingFromDev.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
for (const dep of optionalRuntimeExternals) {
|
|
if (indirection.has(dep)) {
|
|
// Must NOT be external (would re-expose its static imports to esbuild).
|
|
if (cli.has(dep) || sdk.has(dep)) {
|
|
errors.push(
|
|
`${dep} is runtime-indirection-only and must NOT be in CLI/SDK externals.`,
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
// Must stay external in both bundles so it is never inlined.
|
|
const missingIn: string[] = []
|
|
if (!cli.has(dep)) missingIn.push('CLI_EXTERNALS')
|
|
if (!sdk.has(dep)) missingIn.push('SDK_EXTERNALS')
|
|
if (missingIn.length > 0) {
|
|
errors.push(
|
|
`${dep} is an OPTIONAL_RUNTIME_EXTERNAL but missing from ${missingIn.join(' and ')} (it must never be bundled).`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors }
|
|
}
|