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>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
/**
|
|
* Validates that all package.json dependencies are accounted for
|
|
* in the external lists or explicitly marked as intentionally bundled.
|
|
*
|
|
* Run as part of the build to catch missing externals early.
|
|
*/
|
|
import { readFileSync } from 'fs'
|
|
import { CLI_EXTERNALS, SDK_EXTERNALS, SDK_ONLY_EXTERNALS, INTENTIONALLY_BUNDLED, OPTIONAL_RUNTIME_EXTERNALS, RUNTIME_INDIRECTION_ONLY_EXTERNALS, TRANSITIVE_OPTIONAL_EXTERNALS } from './externals.js'
|
|
import {
|
|
bundledExemptionFor,
|
|
validateBundleExternals,
|
|
validateInstallHygieneFields,
|
|
validateIntentionallyBundled,
|
|
validateOptionalPeers,
|
|
validateOptionalRuntimeExternals,
|
|
validateRuntimeDependencyContract,
|
|
} from './externalsValidation.js'
|
|
|
|
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
|
|
// Runtime deps: shipped to users and resolved from node_modules at runtime.
|
|
// These must each be a genuine external (the bundle inlines everything else).
|
|
const runtimeDeps = new Set<string>([
|
|
...Object.keys(pkg.dependencies || {}),
|
|
...Object.keys(pkg.peerDependencies || {}),
|
|
])
|
|
const peerDepNames = new Set(Object.keys(pkg.peerDependencies ?? {}))
|
|
|
|
// The bundled allowlist is scoped PER bundle. The CLI inlines every
|
|
// INTENTIONALLY_BUNDLED package. In the SDK the optional peers (react,
|
|
// @anthropic-ai/sdk, ...) are EXTERNAL — keyed on package.json's
|
|
// peerDependencies (an independent source of truth) so dropping one of them
|
|
// from SDK_EXTERNALS fails validation instead of silently passing.
|
|
const CLI_BUNDLED_EXEMPTION = bundledExemptionFor(INTENTIONALLY_BUNDLED, new Set())
|
|
const SDK_BUNDLED_EXEMPTION = bundledExemptionFor(INTENTIONALLY_BUNDLED, peerDepNames)
|
|
|
|
function report(result: { ok: boolean; errors: string[] }): boolean {
|
|
for (const err of result.errors) console.error(`❌ ${err}`)
|
|
return result.ok
|
|
}
|
|
|
|
const cliOk = report(
|
|
validateBundleExternals('CLI bundle', runtimeDeps, CLI_EXTERNALS, CLI_BUNDLED_EXEMPTION),
|
|
)
|
|
const sdkOk = report(
|
|
validateBundleExternals('SDK bundle', runtimeDeps, SDK_EXTERNALS, SDK_BUNDLED_EXEMPTION),
|
|
)
|
|
const intentionallyBundledOk = report(
|
|
validateIntentionallyBundled(pkg, INTENTIONALLY_BUNDLED, SDK_ONLY_EXTERNALS),
|
|
)
|
|
const optionalPeersOk = report(validateOptionalPeers(pkg))
|
|
const optionalExternalsOk = report(
|
|
validateOptionalRuntimeExternals(
|
|
OPTIONAL_RUNTIME_EXTERNALS,
|
|
CLI_EXTERNALS,
|
|
SDK_EXTERNALS,
|
|
RUNTIME_INDIRECTION_ONLY_EXTERNALS,
|
|
pkg,
|
|
TRANSITIVE_OPTIONAL_EXTERNALS,
|
|
),
|
|
)
|
|
|
|
// Surface external entries not declared in package.json (informational only).
|
|
for (const [name, externals] of [
|
|
['CLI bundle', CLI_EXTERNALS],
|
|
['SDK bundle', SDK_EXTERNALS],
|
|
] as const) {
|
|
const optionalSet = new Set(OPTIONAL_RUNTIME_EXTERNALS)
|
|
const extra = externals.filter(d => !runtimeDeps.has(d) && !optionalSet.has(d))
|
|
if (extra.length > 0) {
|
|
console.warn(`⚠️ ${name}: External entries not in package.json (may be ok): ${extra.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
const depContractOk = report(validateRuntimeDependencyContract(pkg))
|
|
const installHygieneOk = report(validateInstallHygieneFields(pkg))
|
|
|
|
const allOk =
|
|
cliOk &&
|
|
sdkOk &&
|
|
intentionallyBundledOk &&
|
|
optionalPeersOk &&
|
|
optionalExternalsOk &&
|
|
depContractOk &&
|
|
installHygieneOk
|
|
|
|
if (allOk) {
|
|
console.log(
|
|
`✓ CLI/SDK externals + ${INTENTIONALLY_BUNDLED.length} bundled packages valid (devDependencies-only; SDK peers external & optional; optional externals never bundled).`,
|
|
)
|
|
} else {
|
|
console.error(`\n❌ External list validation failed. Fix scripts/externals.ts before committing.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('\n✓ All external lists valid.')
|
|
|
|
// ============================================================================
|
|
// Validate sdk.d.ts ↔ index.ts export drift
|
|
// ============================================================================
|
|
|
|
const SDK_DTS_PATH = 'src/entrypoints/sdk.d.ts'
|
|
const SDK_INDEX_PATH = 'src/entrypoints/sdk/index.ts'
|
|
|
|
function extractExportNames(filePath: string): Set<string> {
|
|
const content = readFileSync(filePath, 'utf8')
|
|
const names = new Set<string>()
|
|
// Match: export { name1, name2 } / export type { name1 } / export class/function/interface/const/type Name
|
|
for (const match of content.matchAll(/export\s+(?:type\s+)?\{([^}]+)\}/g)) {
|
|
for (const name of match[1].split(',')) {
|
|
const trimmed = name.trim().split(/\s+as\s+/)[0].trim()
|
|
if (trimmed) names.add(trimmed)
|
|
}
|
|
}
|
|
for (const match of content.matchAll(
|
|
/export\s+(?:type\s+)?(?:class|function|interface|const|type)\s+(\w+)/g,
|
|
)) {
|
|
names.add(match[1])
|
|
}
|
|
return names
|
|
}
|
|
|
|
const dtsExports = extractExportNames(SDK_DTS_PATH)
|
|
const indexExports = extractExportNames(SDK_INDEX_PATH)
|
|
|
|
const inDtsNotIndex = [...dtsExports].filter(n => !indexExports.has(n))
|
|
const inIndexNotDts = [...indexExports].filter(n => !dtsExports.has(n))
|
|
|
|
if (inDtsNotIndex.length > 0 || inIndexNotDts.length > 0) {
|
|
console.error(`\n❌ SDK type declaration drift detected:`)
|
|
if (inDtsNotIndex.length > 0) {
|
|
console.error(` In sdk.d.ts but not in index.ts:`)
|
|
for (const name of inDtsNotIndex) console.error(` - ${name}`)
|
|
}
|
|
if (inIndexNotDts.length > 0) {
|
|
console.error(` In index.ts but not in sdk.d.ts:`)
|
|
for (const name of inIndexNotDts) console.error(` - ${name}`)
|
|
}
|
|
console.error(`\n Keep sdk.d.ts in sync with src/entrypoints/sdk/index.ts.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`✓ SDK type declarations in sync (${dtsExports.size} exports match).`)
|