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 (#2019)
* 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>
This commit is contained in:
co-authored by
OpenClaude
parent
7674d4d73e
commit
ca7a7e0791
@@ -0,0 +1,51 @@
|
||||
# Daily zero-warning-install watch on the PUBLISHED package.
|
||||
#
|
||||
# The published tarball ships no lockfile, so even with exact-pinned direct
|
||||
# dependencies the registry can drift under us after a release: a transitive
|
||||
# dependency gets deprecated (npm prints the deprecation on every user
|
||||
# install), a platform-specific optional package (@vscode/ripgrep-*) changes,
|
||||
# or a new npm version alters behavior. None of that touches a file in this
|
||||
# repo, so no PR or release gate can catch it — only re-verifying the real
|
||||
# `npm install -g @gitlawb/openclaude@latest` against the live registry does.
|
||||
#
|
||||
# The OS matrix matters: each platform resolves a different ripgrep optional
|
||||
# package, so a Linux-only check is blind to what macOS/Windows users install.
|
||||
name: Install hygiene
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 6 * * *' # daily, off the top-of-hour rush
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-published-install:
|
||||
name: ${{ matrix.os }} / Node ${{ matrix.node-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: [22, 24]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: .bun-version
|
||||
|
||||
# The verify script only uses node builtins + scripts/externalsValidation
|
||||
# (relative import) — no bun install needed, keeping the matrix cheap.
|
||||
- name: Verify published package installs clean
|
||||
run: bun run scripts/verify-clean-install.ts --published
|
||||
@@ -29,9 +29,50 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release-type: node
|
||||
|
||||
# Zero-warning install gate: pack the release tag and install it globally in
|
||||
# a sandbox (cold + upgrade scenarios) on the supported Node floor (22 →
|
||||
# npm 10) and current (24 → npm 11), whose warning output differs. Publish
|
||||
# is blocked unless the real `npm install -g` experience is clean.
|
||||
install-verify:
|
||||
name: Verify clean npm install (Node ${{ matrix.node-version }})
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [22, 24]
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: .bun-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Verify zero-warning global install
|
||||
run: bun run install:verify
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: release-please
|
||||
needs: [release-please, install-verify]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
@@ -68,8 +109,12 @@ jobs:
|
||||
- name: Smoke test
|
||||
run: bun run smoke
|
||||
|
||||
- name: Dry-run package
|
||||
run: npm pack --dry-run
|
||||
# Final install-hygiene gate on the publishing machine itself (the
|
||||
# install-verify job covered Node 22/24 already); packs with
|
||||
# --ignore-scripts to reuse the dist/ built above. Supersedes the old
|
||||
# `npm pack --dry-run` (contents are asserted inside the verify).
|
||||
- name: Verify zero-warning global install
|
||||
run: bun run install:verify
|
||||
|
||||
- name: Clear token auth for trusted publishing
|
||||
run: |
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"": {
|
||||
"name": "@gitlawb/openclaude",
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@orama/plugin-data-persistence": "^3.1.18",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
"@orama/orama": "3.1.18",
|
||||
"@orama/plugin-data-persistence": "3.1.18",
|
||||
"@vscode/ripgrep": "1.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "0.3.0",
|
||||
|
||||
+5
-3
@@ -65,6 +65,8 @@
|
||||
"deadcode": "knip --include files,dependencies",
|
||||
"check": "bun run smoke && bun run deadcode && bun run test:full",
|
||||
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
|
||||
"install:verify": "bun run scripts/verify-clean-install.ts",
|
||||
"install:verify:published": "bun run scripts/verify-clean-install.ts --published",
|
||||
"build:verified": "bun run build && bun run verify:privacy",
|
||||
"test:provider": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts",
|
||||
"doctor:runtime": "bun run scripts/system-check.ts",
|
||||
@@ -75,9 +77,9 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@orama/plugin-data-persistence": "^3.1.18",
|
||||
"@vscode/ripgrep": "^1.17.1"
|
||||
"@orama/orama": "3.1.18",
|
||||
"@orama/plugin-data-persistence": "3.1.18",
|
||||
"@vscode/ripgrep": "1.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "0.3.0",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
bundledExemptionFor,
|
||||
validateBundleExternals,
|
||||
validateInstallHygieneFields,
|
||||
validateIntentionallyBundled,
|
||||
validateOptionalPeers,
|
||||
validateOptionalRuntimeExternals,
|
||||
validateRuntimeDependencyContract,
|
||||
type PkgDeps,
|
||||
} from './externalsValidation.js'
|
||||
|
||||
@@ -250,3 +252,93 @@ describe('validateOptionalRuntimeExternals', () => {
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRuntimeDependencyContract', () => {
|
||||
const CONTRACT = { '@example/a': '1.2.3', '@example/b': '4.5.6' } as const
|
||||
|
||||
test('passes when dependencies exactly match the contract', () => {
|
||||
const r = validateRuntimeDependencyContract(
|
||||
{ dependencies: { '@example/a': '1.2.3', '@example/b': '4.5.6' } },
|
||||
CONTRACT,
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('FAILS on a new runtime dependency not in the contract', () => {
|
||||
const r = validateRuntimeDependencyContract(
|
||||
{
|
||||
dependencies: {
|
||||
'@example/a': '1.2.3',
|
||||
'@example/b': '4.5.6',
|
||||
'left-pad': '1.0.0',
|
||||
},
|
||||
},
|
||||
CONTRACT,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.errors.join(' ')).toMatch(/not in RUNTIME_DEPENDENCY_CONTRACT.*left-pad/)
|
||||
})
|
||||
|
||||
test('FAILS when a contract entry is missing from dependencies', () => {
|
||||
const r = validateRuntimeDependencyContract(
|
||||
{ dependencies: { '@example/a': '1.2.3' } },
|
||||
CONTRACT,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.errors.join(' ')).toMatch(/missing from dependencies.*@example\/b/)
|
||||
})
|
||||
|
||||
test('FAILS when a caret range sneaks back in', () => {
|
||||
const r = validateRuntimeDependencyContract(
|
||||
{ dependencies: { '@example/a': '^1.2.3', '@example/b': '4.5.6' } },
|
||||
CONTRACT,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.errors.join(' ')).toMatch(/@example\/a/)
|
||||
})
|
||||
|
||||
test('the real package.json satisfies the real contract', async () => {
|
||||
const pkg = (await import('../package.json')) as PkgDeps
|
||||
expect(validateRuntimeDependencyContract(pkg).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateInstallHygieneFields', () => {
|
||||
const CLEAN = { engines: { node: '>=22.0.0' } }
|
||||
|
||||
test('passes for a clean manifest', () => {
|
||||
expect(validateInstallHygieneFields(CLEAN).ok).toBe(true)
|
||||
})
|
||||
|
||||
test('FAILS on consumer-run install hooks but allows publisher hooks', () => {
|
||||
const withPublisherHooks = validateInstallHygieneFields({
|
||||
...CLEAN,
|
||||
scripts: { prepack: 'npm run build', prepare: 'true' },
|
||||
})
|
||||
expect(withPublisherHooks.ok).toBe(true)
|
||||
|
||||
const withPostinstall = validateInstallHygieneFields({
|
||||
...CLEAN,
|
||||
scripts: { postinstall: 'node download.js' },
|
||||
})
|
||||
expect(withPostinstall.ok).toBe(false)
|
||||
expect(withPostinstall.errors.join(' ')).toMatch(/postinstall/)
|
||||
})
|
||||
|
||||
test('FAILS on a funding field', () => {
|
||||
const r = validateInstallHygieneFields({ ...CLEAN, funding: 'https://x' })
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.errors.join(' ')).toMatch(/funding/)
|
||||
})
|
||||
|
||||
test('FAILS when engines.node drifts from the contract', () => {
|
||||
const r = validateInstallHygieneFields({ engines: { node: '>=24.0.0' } })
|
||||
expect(r.ok).toBe(false)
|
||||
expect(r.errors.join(' ')).toMatch(/EBADENGINE/)
|
||||
})
|
||||
|
||||
test('the real package.json passes install hygiene', async () => {
|
||||
const pkg = await import('../package.json')
|
||||
expect(validateInstallHygieneFields(pkg as never).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,117 @@ export type PkgDeps = {
|
||||
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
|
||||
|
||||
@@ -9,9 +9,11 @@ import { CLI_EXTERNALS, SDK_EXTERNALS, SDK_ONLY_EXTERNALS, INTENTIONALLY_BUNDLED
|
||||
import {
|
||||
bundledExemptionFor,
|
||||
validateBundleExternals,
|
||||
validateInstallHygieneFields,
|
||||
validateIntentionallyBundled,
|
||||
validateOptionalPeers,
|
||||
validateOptionalRuntimeExternals,
|
||||
validateRuntimeDependencyContract,
|
||||
} from './externalsValidation.js'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
@@ -70,8 +72,17 @@ for (const [name, externals] of [
|
||||
}
|
||||
}
|
||||
|
||||
const depContractOk = report(validateRuntimeDependencyContract(pkg))
|
||||
const installHygieneOk = report(validateInstallHygieneFields(pkg))
|
||||
|
||||
const allOk =
|
||||
cliOk && sdkOk && intentionallyBundledOk && optionalPeersOk && optionalExternalsOk
|
||||
cliOk &&
|
||||
sdkOk &&
|
||||
intentionallyBundledOk &&
|
||||
optionalPeersOk &&
|
||||
optionalExternalsOk &&
|
||||
depContractOk &&
|
||||
installHygieneOk
|
||||
|
||||
if (allOk) {
|
||||
console.log(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { resolvePreviousPublishedVersion } from './verify-clean-install.js'
|
||||
|
||||
// The retry/skip/infra branches decide whether the upgrade-install scenario
|
||||
// runs, is skipped, or aborts as an infra failure — regression-covered here
|
||||
// with injected npm results (the real script wires runView to `npm view` and
|
||||
// onInfraFailure to process.exit(2)).
|
||||
|
||||
const ok = (version: string) => ({ status: 0, stdout: `${version}\n`, stderr: '' })
|
||||
const infraFail = { status: 1, stdout: '', stderr: 'npm error network ECONNRESET while fetching' }
|
||||
const notPublished = { status: 1, stdout: '', stderr: 'npm error code E404\nnpm error 404 Not Found' }
|
||||
|
||||
class InfraExit extends Error {
|
||||
constructor(readonly combined: string) {
|
||||
super('infra exit')
|
||||
}
|
||||
}
|
||||
|
||||
function run(results: Array<{ status: number; stdout: string; stderr: string }>, retries = 3) {
|
||||
let calls = 0
|
||||
const retryAttempts: number[] = []
|
||||
const value = resolvePreviousPublishedVersion({
|
||||
runView: () => {
|
||||
const result = results[calls]
|
||||
calls++
|
||||
if (!result) throw new Error(`runView called ${calls} times, only ${results.length} results provided`)
|
||||
return result
|
||||
},
|
||||
onRetry: attempt => retryAttempts.push(attempt),
|
||||
onInfraFailure: combined => {
|
||||
throw new InfraExit(combined)
|
||||
},
|
||||
retries,
|
||||
})
|
||||
return { value, calls, retryAttempts }
|
||||
}
|
||||
|
||||
describe('resolvePreviousPublishedVersion', () => {
|
||||
test('returns the version on first success without retrying', () => {
|
||||
const { value, calls, retryAttempts } = run([ok('0.24.0')])
|
||||
expect(value).toBe('0.24.0')
|
||||
expect(calls).toBe(1)
|
||||
expect(retryAttempts).toEqual([])
|
||||
})
|
||||
|
||||
test('transient infra failure retries and then succeeds', () => {
|
||||
const { value, calls, retryAttempts } = run([infraFail, infraFail, ok('0.24.0')])
|
||||
expect(value).toBe('0.24.0')
|
||||
expect(calls).toBe(3)
|
||||
expect(retryAttempts).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test('clean unavailability (E404) returns null immediately — skip, not infra', () => {
|
||||
const { value, calls, retryAttempts } = run([notPublished])
|
||||
expect(value).toBeNull()
|
||||
expect(calls).toBe(1)
|
||||
expect(retryAttempts).toEqual([])
|
||||
})
|
||||
|
||||
test('persistent infra failure invokes onInfraFailure after exhausting retries', () => {
|
||||
let caught: InfraExit | null = null
|
||||
try {
|
||||
run([infraFail, infraFail, infraFail])
|
||||
} catch (error) {
|
||||
caught = error as InfraExit
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InfraExit)
|
||||
expect(caught!.combined).toContain('ECONNRESET')
|
||||
})
|
||||
|
||||
test('unparseable success output returns null rather than a bogus version', () => {
|
||||
const { value } = run([ok('not-a-version')])
|
||||
expect(value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* End-to-end verification that `npm install -g @gitlawb/openclaude` is a
|
||||
* zero-warning experience — the runtime half of the install contract whose
|
||||
* static half lives in externalsValidation.ts (RUNTIME_DEPENDENCY_CONTRACT).
|
||||
*
|
||||
* Modes:
|
||||
* --tarball [path] Verify a local tarball. Without a path, packs one from
|
||||
* the working tree with `npm pack --ignore-scripts`
|
||||
* (dist/ must already be built — CI builds it first).
|
||||
* --published [spec] Verify the real registry artifact (default
|
||||
* @gitlawb/openclaude@latest). Used by the scheduled
|
||||
* install-hygiene workflow to catch registry drift
|
||||
* (e.g. a transitive dep deprecated after we shipped).
|
||||
*
|
||||
* Each mode runs two scenarios in throwaway prefixes with a cold cache:
|
||||
* 1. cold — fresh global install
|
||||
* 2. upgrade — install the previously published version, then install the
|
||||
* target over it (the most common real-world path; different
|
||||
* npm output shapes than a cold install)
|
||||
*
|
||||
* Verdicts are strict-whitelist: any npm output line that is not an expected
|
||||
* summary fails the run — `npm warn`, `deprecated`, EBADENGINE, funding hints,
|
||||
* and install-script chatter all land here without being special-cased.
|
||||
* Registry/network failures retry and then exit 2 (infra), never 1 (hygiene),
|
||||
* so CI can distinguish a flaky registry from a real regression.
|
||||
*
|
||||
* After installing, the script also proves the artifact works and is silent:
|
||||
* `--version` must print the exact packed version, `--help` must load the real
|
||||
* bundle (--version short-circuits via a zero-import fast path in cli.tsx and
|
||||
* proves almost nothing), both with empty stderr. The installed tree is
|
||||
* scanned structurally for install scripts — a transitive postinstall that
|
||||
* exits quietly would pass an output whitelist, so the tree is the authority.
|
||||
*
|
||||
* Note: package.json `overrides` do NOT travel to consumers; this script
|
||||
* intentionally reproduces the user's resolution, not the repo's.
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
validateInstallHygieneFields,
|
||||
validateRuntimeDependencyContract,
|
||||
} from './externalsValidation.js'
|
||||
|
||||
const PACKAGE_NAME = '@gitlawb/openclaude'
|
||||
const MAX_TARBALL_BYTES = 12_000_000 // current tarball is ~8.8MB; catch payload blowups
|
||||
const INSTALL_RETRIES = 3
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
// Published artifacts that predate the silent-first-boot fix (the fresh-install
|
||||
// Opengateway default used to print a "saved provider profile" warning on
|
||||
// every command). Their stderr noise is a KNOWN issue, not a regression —
|
||||
// exempt exactly these versions so the scheduled published-mode run stays
|
||||
// signal. Self-cleaning: the next release is not in this set; remove the
|
||||
// constant once 0.24.0 is no longer `latest`.
|
||||
const KNOWN_FIRST_BOOT_NOISE_VERSIONS = new Set(['0.24.0'])
|
||||
|
||||
// Lines npm may legitimately print at --loglevel=warn. Everything else fails.
|
||||
const ALLOWED_OUTPUT = [
|
||||
// "added 8 packages in 19s", "added 1 package in 340ms",
|
||||
// "added 1 package, removed 2 packages, and changed 3 packages in 4s",
|
||||
// "up to date in 1s" — summary phrasing varies across npm 10/11.
|
||||
/^(?:added|removed|changed|up to date)[\w ,]* in [\d.]+m?s$/i,
|
||||
/^npm notice\b/i, // defense in depth; --loglevel=warn hides notices
|
||||
]
|
||||
|
||||
const INFRA_FAILURE_PATTERNS = [
|
||||
/ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|EPROTO/,
|
||||
/network|socket hang up|fetch failed|registry.*(?:unavailable|error)/i,
|
||||
/npm error code E(?:429|5\d\d)\b/,
|
||||
]
|
||||
|
||||
type Failure = { scenario: string; problem: string }
|
||||
const failures: Failure[] = []
|
||||
function fail(scenario: string, problem: string): void {
|
||||
failures.push({ scenario, problem })
|
||||
console.error(` ❌ [${scenario}] ${problem}`)
|
||||
}
|
||||
function pass(scenario: string, what: string): void {
|
||||
console.log(` ✓ [${scenario}] ${what}`)
|
||||
}
|
||||
|
||||
function npmEnv(home: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
// Deterministic, machine-independent output: no color/TTY decoration, no
|
||||
// npm self-update notice, English formatting, isolated config/home.
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
LANG: 'C',
|
||||
LC_ALL: 'C',
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
npm_config_update_notifier: 'false',
|
||||
}
|
||||
}
|
||||
|
||||
function runNpm(
|
||||
args: string[],
|
||||
home: string,
|
||||
): { status: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync('npm', args, {
|
||||
encoding: 'utf8',
|
||||
env: npmEnv(home),
|
||||
shell: IS_WINDOWS, // npm is npm.cmd on Windows
|
||||
timeout: 10 * 60 * 1000,
|
||||
})
|
||||
return {
|
||||
status: result.status ?? -1,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function installFlags(prefix: string, cache: string): string[] {
|
||||
return [
|
||||
'--global',
|
||||
`--prefix=${prefix}`,
|
||||
`--cache=${cache}`,
|
||||
'--no-fund',
|
||||
'--no-audit',
|
||||
'--no-progress',
|
||||
'--no-color',
|
||||
'--loglevel=warn',
|
||||
'--foreground-scripts',
|
||||
]
|
||||
}
|
||||
|
||||
function looksLikeInfraFailure(output: string): boolean {
|
||||
return INFRA_FAILURE_PATTERNS.some(re => re.test(output))
|
||||
}
|
||||
|
||||
/** Install with retry-on-network; returns combined output once npm exits 0. */
|
||||
function installWithRetry(
|
||||
scenario: string,
|
||||
spec: string,
|
||||
prefix: string,
|
||||
cache: string,
|
||||
home: string,
|
||||
): string | null {
|
||||
for (let attempt = 1; attempt <= INSTALL_RETRIES; attempt++) {
|
||||
const { status, stdout, stderr } = runNpm(
|
||||
['install', spec, ...installFlags(prefix, cache)],
|
||||
home,
|
||||
)
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (status === 0) return combined
|
||||
if (attempt < INSTALL_RETRIES && looksLikeInfraFailure(combined)) {
|
||||
console.log(` … [${scenario}] transient install failure, retrying (${attempt}/${INSTALL_RETRIES})`)
|
||||
continue
|
||||
}
|
||||
if (looksLikeInfraFailure(combined)) {
|
||||
console.error(combined)
|
||||
console.error(`\n⚠️ [${scenario}] npm install failed with network/registry symptoms after ${INSTALL_RETRIES} attempts — infra problem, not a hygiene verdict.`)
|
||||
process.exit(2)
|
||||
}
|
||||
fail(scenario, `npm install exited ${status}:\n${combined}`)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function checkOutputWhitelist(scenario: string, output: string): void {
|
||||
const offending = output
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => !ALLOWED_OUTPUT.some(re => re.test(line)))
|
||||
if (offending.length === 0) {
|
||||
pass(scenario, 'install output is clean (summary line only)')
|
||||
} else {
|
||||
for (const line of offending) {
|
||||
fail(scenario, `unexpected install output: "${line}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function globalRoot(prefix: string): string {
|
||||
return IS_WINDOWS ? join(prefix, 'node_modules') : join(prefix, 'lib', 'node_modules')
|
||||
}
|
||||
|
||||
/** Every package.json in the installed tree; global deps nest under the package. */
|
||||
function collectInstalledManifests(dir: string, out: string[] = []): string[] {
|
||||
if (!existsSync(dir)) return out
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
const child = join(dir, entry.name)
|
||||
if (entry.name.startsWith('@')) {
|
||||
collectInstalledManifests(child, out)
|
||||
continue
|
||||
}
|
||||
const manifest = join(child, 'package.json')
|
||||
if (existsSync(manifest)) out.push(manifest)
|
||||
const nested = join(child, 'node_modules')
|
||||
if (existsSync(nested)) collectInstalledManifests(nested, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkNoInstallScripts(scenario: string, prefix: string): void {
|
||||
const manifests = collectInstalledManifests(globalRoot(prefix))
|
||||
if (manifests.length === 0) {
|
||||
fail(scenario, `no installed packages found under ${globalRoot(prefix)}`)
|
||||
return
|
||||
}
|
||||
const offenders: string[] = []
|
||||
for (const manifest of manifests) {
|
||||
const pkg = JSON.parse(readFileSync(manifest, 'utf8'))
|
||||
const hooks = ['preinstall', 'install', 'postinstall'].filter(
|
||||
hook => pkg.scripts?.[hook],
|
||||
)
|
||||
if (hooks.length > 0) offenders.push(`${pkg.name}@${pkg.version} (${hooks.join(', ')})`)
|
||||
}
|
||||
if (offenders.length > 0) {
|
||||
fail(scenario, `installed packages declare install scripts: ${offenders.join('; ')}`)
|
||||
} else {
|
||||
pass(scenario, `no install scripts across ${manifests.length} installed packages`)
|
||||
}
|
||||
}
|
||||
|
||||
function checkInstalledContract(scenario: string, prefix: string): void {
|
||||
const manifestPath = join(globalRoot(prefix), ...PACKAGE_NAME.split('/'), 'package.json')
|
||||
if (!existsSync(manifestPath)) {
|
||||
fail(scenario, `installed manifest missing at ${manifestPath}`)
|
||||
return
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const errors = [
|
||||
...validateRuntimeDependencyContract(pkg).errors,
|
||||
...validateInstallHygieneFields(pkg).errors,
|
||||
]
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) fail(scenario, `installed artifact: ${error}`)
|
||||
} else {
|
||||
pass(scenario, 'installed artifact matches the static install contract')
|
||||
}
|
||||
}
|
||||
|
||||
function binPath(prefix: string): string {
|
||||
return IS_WINDOWS ? join(prefix, 'openclaude.cmd') : join(prefix, 'bin', 'openclaude')
|
||||
}
|
||||
|
||||
function runBin(
|
||||
prefix: string,
|
||||
home: string,
|
||||
args: string[],
|
||||
): { status: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync(binPath(prefix), args, {
|
||||
encoding: 'utf8',
|
||||
env: npmEnv(home),
|
||||
cwd: home,
|
||||
shell: IS_WINDOWS,
|
||||
timeout: 2 * 60 * 1000,
|
||||
})
|
||||
return {
|
||||
status: result.status ?? -1,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinBoots(scenario: string, prefix: string, home: string, expectedVersion: string | null): void {
|
||||
const version = runBin(prefix, home, ['--version'])
|
||||
if (version.status !== 0) {
|
||||
fail(scenario, `\`openclaude --version\` exited ${version.status}: ${version.stderr}`)
|
||||
} else if (expectedVersion && version.stdout.trim() !== `${expectedVersion} (OpenClaude)`) {
|
||||
fail(scenario, `--version printed "${version.stdout.trim()}", expected "${expectedVersion} (OpenClaude)"`)
|
||||
} else if (version.stderr.trim().length > 0) {
|
||||
fail(scenario, `--version wrote to stderr: "${version.stderr.trim()}"`)
|
||||
} else {
|
||||
pass(scenario, `--version prints ${version.stdout.trim()}`)
|
||||
}
|
||||
|
||||
// --version is a zero-import fast path; --help forces the real bundle to
|
||||
// load, so a broken or noisy-at-boot build fails here.
|
||||
const installedVersion = version.status === 0 ? version.stdout.trim().split(' ')[0] : ''
|
||||
const bootNoiseKnown = KNOWN_FIRST_BOOT_NOISE_VERSIONS.has(installedVersion ?? '')
|
||||
const help = runBin(prefix, home, ['--help'])
|
||||
if (help.status !== 0) {
|
||||
fail(scenario, `\`openclaude --help\` exited ${help.status}: ${help.stderr}`)
|
||||
} else if (!/usage/i.test(help.stdout)) {
|
||||
fail(scenario, `--help output does not look like help text: "${help.stdout.slice(0, 200)}"`)
|
||||
} else if (help.stderr.trim().length > 0) {
|
||||
if (bootNoiseKnown) {
|
||||
console.log(` … [${scenario}] --help stderr noise is a known issue in ${installedVersion} (fixed in the next release)`)
|
||||
} else {
|
||||
fail(scenario, `--help wrote to stderr (boot must be silent): "${help.stderr.trim()}"`)
|
||||
}
|
||||
} else {
|
||||
pass(scenario, '--help loads the full bundle with silent stderr')
|
||||
}
|
||||
}
|
||||
|
||||
function checkTarballContents(tarballPath: string): void {
|
||||
const scenario = 'tarball'
|
||||
const required = [
|
||||
'package/package.json',
|
||||
'package/bin/openclaude',
|
||||
'package/dist/cli.mjs',
|
||||
'package/dist/sdk.mjs',
|
||||
'package/src/entrypoints/sdk.d.ts',
|
||||
]
|
||||
const listing = execFileSync('tar', ['-tzf', tarballPath], { encoding: 'utf8' })
|
||||
const entries = new Set(listing.split(/\r?\n/).map(l => l.trim()))
|
||||
const missing = required.filter(entry => !entries.has(entry))
|
||||
if (missing.length > 0) {
|
||||
fail(scenario, `tarball is missing declared payload: ${missing.join(', ')}`)
|
||||
} else {
|
||||
pass(scenario, `tarball carries the full declared payload (${entries.size - 1} files)`)
|
||||
}
|
||||
const size = statSync(tarballPath).size
|
||||
if (size > MAX_TARBALL_BYTES) {
|
||||
fail(scenario, `tarball is ${size} bytes (> ${MAX_TARBALL_BYTES} bound) — payload blowup?`)
|
||||
} else {
|
||||
pass(scenario, `tarball size ${(size / 1e6).toFixed(1)}MB within bound`)
|
||||
}
|
||||
}
|
||||
|
||||
function makeSandbox(work: string, name: string): { prefix: string; cache: string; home: string } {
|
||||
const prefix = join(work, name, 'prefix')
|
||||
const cache = join(work, name, 'cache')
|
||||
const home = join(work, name, 'home')
|
||||
for (const dir of [prefix, cache, home]) mkdirSync(dir, { recursive: true })
|
||||
return { prefix, cache, home }
|
||||
}
|
||||
|
||||
function packWorkingTree(work: string): string {
|
||||
for (const artifact of ['dist/cli.mjs', 'dist/sdk.mjs']) {
|
||||
if (!existsSync(artifact)) {
|
||||
console.error(`❌ ${artifact} not found — run \`bun run build\` before --tarball mode (the pack uses --ignore-scripts to avoid a redundant prepack build).`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const home = join(work, 'pack-home')
|
||||
mkdirSync(home, { recursive: true })
|
||||
const { status, stdout, stderr } = runNpm(
|
||||
['pack', '--ignore-scripts', '--json', `--pack-destination=${work}`, '--loglevel=error'],
|
||||
home,
|
||||
)
|
||||
if (status !== 0) {
|
||||
console.error(`❌ npm pack failed: ${stderr}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const filename = JSON.parse(stdout)[0]?.filename
|
||||
if (!filename) {
|
||||
console.error(`❌ npm pack returned no filename: ${stdout}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return join(work, filename)
|
||||
}
|
||||
|
||||
type NpmRunResult = { status: number; stdout: string; stderr: string }
|
||||
|
||||
// Same retry/infra discipline as installWithRetry: a transient registry
|
||||
// hiccup must not silently drop the upgrade-scenario coverage (the infra
|
||||
// callback exits 2, distinguishable from a hygiene verdict). A clean "not
|
||||
// published" answer (e.g. E404 before the first release) legitimately
|
||||
// returns null → skip. Effects are injected so the retry/skip/infra branches
|
||||
// are unit-testable (verify-clean-install.test.ts) without shelling out.
|
||||
export function resolvePreviousPublishedVersion(options: {
|
||||
runView: () => NpmRunResult
|
||||
onRetry: (attempt: number) => void
|
||||
onInfraFailure: (combinedOutput: string) => never
|
||||
retries?: number
|
||||
}): string | null {
|
||||
const retries = options.retries ?? INSTALL_RETRIES
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
const { status, stdout, stderr } = options.runView()
|
||||
if (status === 0) {
|
||||
const version = stdout.trim()
|
||||
return /^\d+\.\d+\.\d+/.test(version) ? version : null
|
||||
}
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (!looksLikeInfraFailure(combined)) return null
|
||||
if (attempt < retries) {
|
||||
options.onRetry(attempt)
|
||||
continue
|
||||
}
|
||||
options.onInfraFailure(combined)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function previousPublishedVersion(home: string): string | null {
|
||||
return resolvePreviousPublishedVersion({
|
||||
runView: () =>
|
||||
runNpm(['view', `${PACKAGE_NAME}@latest`, 'version', '--loglevel=error'], home),
|
||||
onRetry: attempt =>
|
||||
console.log(` … npm view failed with network symptoms, retrying (${attempt}/${INSTALL_RETRIES})`),
|
||||
onInfraFailure: combined => {
|
||||
console.error(combined)
|
||||
console.error(`\n⚠️ npm view ${PACKAGE_NAME}@latest failed with network/registry symptoms after ${INSTALL_RETRIES} attempts — infra problem, not a hygiene verdict.`)
|
||||
return process.exit(2)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function runScenarios(
|
||||
target: string,
|
||||
expectedVersion: string | null,
|
||||
work: string,
|
||||
checkContract: boolean,
|
||||
): void {
|
||||
// Scenario 1: cold install into a pristine prefix.
|
||||
{
|
||||
const scenario = 'cold-install'
|
||||
console.log(`\n▶ ${scenario}: npm install -g ${target}`)
|
||||
const { prefix, cache, home } = makeSandbox(work, 'cold')
|
||||
const output = installWithRetry(scenario, target, prefix, cache, home)
|
||||
if (output !== null) {
|
||||
checkOutputWhitelist(scenario, output)
|
||||
checkNoInstallScripts(scenario, prefix)
|
||||
// Contract comparison only makes sense for the artifact built from THIS
|
||||
// tree; a published artifact predates contract bumps (version skew).
|
||||
if (checkContract) checkInstalledContract(scenario, prefix)
|
||||
checkBinBoots(scenario, prefix, home, expectedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 2: upgrade over the previously published version — the common
|
||||
// real-world path, with different npm summary output than a cold install.
|
||||
{
|
||||
const scenario = 'upgrade-install'
|
||||
const { prefix, cache, home } = makeSandbox(work, 'upgrade')
|
||||
const previous = previousPublishedVersion(home)
|
||||
if (previous === null) {
|
||||
console.log(`\n▶ ${scenario}: skipped (no published ${PACKAGE_NAME}@latest reachable)`)
|
||||
return
|
||||
}
|
||||
console.log(`\n▶ ${scenario}: ${PACKAGE_NAME}@${previous} → ${target}`)
|
||||
// The baseline install is not under test (it is the already-shipped
|
||||
// version); only the upgrade on top of it must be clean.
|
||||
const baseline = installWithRetry(scenario, `${PACKAGE_NAME}@${previous}`, prefix, cache, home)
|
||||
if (baseline === null) return
|
||||
const output = installWithRetry(scenario, target, prefix, cache, home)
|
||||
if (output !== null) {
|
||||
checkOutputWhitelist(scenario, output)
|
||||
checkNoInstallScripts(scenario, prefix)
|
||||
checkBinBoots(scenario, prefix, home, expectedVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args[0] === '--published' ? 'published' : '--tarball' === args[0] || args.length === 0 ? 'tarball' : null
|
||||
if (mode === null) {
|
||||
console.error('Usage: verify-clean-install.ts [--tarball [path] | --published [spec]]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), 'openclaude-install-verify-'))
|
||||
try {
|
||||
let target: string
|
||||
let expectedVersion: string | null
|
||||
if (mode === 'tarball') {
|
||||
const tarballPath = args[1] ?? packWorkingTree(work)
|
||||
checkTarballContents(tarballPath)
|
||||
target = tarballPath
|
||||
expectedVersion = JSON.parse(readFileSync('package.json', 'utf8')).version
|
||||
} else {
|
||||
target = args[1] ?? `${PACKAGE_NAME}@latest`
|
||||
expectedVersion = null // registry version; asserted non-empty via --version format
|
||||
}
|
||||
|
||||
console.log(`Verifying zero-warning install: ${target}`)
|
||||
runScenarios(target, expectedVersion, work, mode === 'tarball')
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n❌ install hygiene FAILED (${failures.length} problem${failures.length === 1 ? '' : 's'}). The npm install experience is not zero-warning.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n✓ install hygiene verified: clean output, no install scripts, contract intact, binary boots silently.')
|
||||
}
|
||||
|
||||
// Guarded so the test file can import resolvePreviousPublishedVersion without
|
||||
// kicking off a real pack + registry install.
|
||||
if (import.meta.main) {
|
||||
main()
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
buildOpenAIProfileEnv,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
DEFAULT_STARTUP_PROVIDER_ENV_VAR,
|
||||
deleteProfileFile,
|
||||
getDefaultProfileFilePath,
|
||||
isDefaultStartupProviderEnv,
|
||||
@@ -773,7 +774,7 @@ test('buildStartupEnvFromProfile fresh-install OpenGateway env is invalid withou
|
||||
assert.ok(error!.includes('OPENGATEWAY_API_KEY'))
|
||||
})
|
||||
|
||||
test('applyStartupEnvFromProfile ignores invalid startup env and warns (issue #1651)', async () => {
|
||||
test('applyStartupEnvFromProfile ignores the invalid fresh-install default SILENTLY (issue #1651 + zero-warning install)', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {}
|
||||
const warnings: string[] = []
|
||||
|
||||
@@ -783,14 +784,66 @@ test('applyStartupEnvFromProfile ignores invalid startup env and warns (issue #1
|
||||
onValidationError: message => warnings.push(message),
|
||||
})
|
||||
|
||||
// Still ignored (not applied), but a brand-new machine must not see a
|
||||
// "saved provider profile" warning on every command — nothing was saved.
|
||||
assert.notEqual(error, null)
|
||||
assert.ok(error!.includes('OPENGATEWAY_API_KEY'))
|
||||
assert.deepEqual(warnings, [])
|
||||
assert.deepEqual(processEnv, {})
|
||||
})
|
||||
|
||||
test('applyStartupEnvFromProfile still warns when a genuinely saved profile fails validation', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {}
|
||||
const warnings: string[] = []
|
||||
|
||||
const error = await applyStartupEnvFromProfile({
|
||||
persisted: {
|
||||
profile: 'openai',
|
||||
env: { OPENAI_BASE_URL: 'https://api.openai.com/v1' },
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
processEnv,
|
||||
onValidationError: message => warnings.push(message),
|
||||
})
|
||||
|
||||
assert.notEqual(error, null)
|
||||
assert.deepEqual(warnings, [
|
||||
`Warning: ignoring saved provider profile. ${error}`,
|
||||
])
|
||||
assert.deepEqual(processEnv, {})
|
||||
})
|
||||
|
||||
test('applyStartupEnvFromProfile warns for a saved Opengateway-shaped profile even when the default-startup marker leaks in from a parent process', async () => {
|
||||
// Collision guard: a persisted profile's launch env spreads processEnv, so
|
||||
// a CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER marker inherited from a parent CLI
|
||||
// process can make the saved profile's env indistinguishable from the
|
||||
// injected fresh-install default by marker-sniffing alone. Provenance
|
||||
// (persisted !== null) must win: this saved-but-invalid profile warns.
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
[DEFAULT_STARTUP_PROVIDER_ENV_VAR]: 'gitlawb-opengateway',
|
||||
}
|
||||
const warnings: string[] = []
|
||||
|
||||
const error = await applyStartupEnvFromProfile({
|
||||
persisted: {
|
||||
profile: 'openai',
|
||||
env: { OPENAI_BASE_URL: 'https://opengateway.gitlawb.com/v1' },
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
processEnv,
|
||||
onValidationError: message => warnings.push(message),
|
||||
})
|
||||
|
||||
assert.notEqual(error, null)
|
||||
assert.deepEqual(warnings, [
|
||||
`Warning: ignoring saved provider profile. ${error}`,
|
||||
])
|
||||
// The invalid env is ignored: only the pre-existing marker remains.
|
||||
assert.deepEqual(processEnv, {
|
||||
[DEFAULT_STARTUP_PROVIDER_ENV_VAR]: 'gitlawb-opengateway',
|
||||
})
|
||||
})
|
||||
|
||||
test('applyStartupEnvFromProfile applies valid startup env (issue #1651)', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
OPENGATEWAY_API_KEY: 'test-key',
|
||||
|
||||
@@ -2230,8 +2230,18 @@ export async function applyStartupEnvFromProfile(options?: StartupEnvOptions & {
|
||||
}): Promise<string | null> {
|
||||
const processEnv = options?.processEnv ?? process.env
|
||||
const { onValidationError, ...startupOptions } = options ?? {}
|
||||
// Resolve the persisted profile HERE (once) so the warning gate below has
|
||||
// explicit provenance. Sniffing the DEFAULT_STARTUP_PROVIDER_ENV_VAR marker
|
||||
// alone is not enough: a persisted profile's launch env spreads processEnv,
|
||||
// so a marker inherited from a parent CLI process (pane/teammate children)
|
||||
// could make a genuinely saved profile look like the injected default.
|
||||
const persisted =
|
||||
startupOptions && 'persisted' in startupOptions
|
||||
? startupOptions.persisted
|
||||
: loadProfileFile()
|
||||
const startupEnv = await buildStartupEnvFromProfile({
|
||||
...startupOptions,
|
||||
persisted,
|
||||
processEnv,
|
||||
})
|
||||
if (startupEnv === processEnv) {
|
||||
@@ -2240,9 +2250,20 @@ export async function applyStartupEnvFromProfile(options?: StartupEnvOptions & {
|
||||
|
||||
const validationError = await getProviderValidationError(startupEnv)
|
||||
if (validationError) {
|
||||
onValidationError?.(
|
||||
`Warning: ignoring saved provider profile. ${validationError}`,
|
||||
)
|
||||
// The injected fresh-install Opengateway default failing validation is the
|
||||
// EXPECTED state for a brand-new machine with no OPENGATEWAY_API_KEY —
|
||||
// nothing was "saved", so warning on every command (even --help) is
|
||||
// first-boot noise, not signal (#1651 chose ignore+warn; the warn half
|
||||
// broke the zero-warning install contract). Onboarding surfaces provider
|
||||
// setup instead. Genuinely persisted profiles that fail validation still
|
||||
// warn: the user configured something that no longer works. Both checks
|
||||
// are required — `!persisted` is the provenance, the marker check keeps
|
||||
// non-default fallback envs (e.g. the nvidia-nim rescue path) warning.
|
||||
if (persisted || !isDefaultStartupProviderEnv(startupEnv)) {
|
||||
onValidationError?.(
|
||||
`Warning: ignoring saved provider profile. ${validationError}`,
|
||||
)
|
||||
}
|
||||
return validationError
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user