mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-06-01 15:27:41 +00:00
* Fix flaky smoke build checks Replace feature-flag build preprocessing with a Bun onLoad transform so smoke/build no longer rewrites tracked src files while tests may be reading them. Keep telemetry stubs ahead of the feature transform in both CLI and SDK builds, preserve non-empty text token counts in hybrid context splitting, and make the corrupted Orama stress test assert against the actual project directory used by the test. Verified with bun run smoke, bun test src/utils/hybridContextStrategy.test.ts, bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 3, and bun test --max-concurrency=1. * Harden KnowledgeGraph smoke stress isolation Give each KnowledgeGraph stress test its own temporary config directory and remove it during teardown so Orama, SQLite, and corrupted-file state cannot bleed between stress cases or later PR test runs. Reviewed at least 20 open PRs and found the recurring smoke-and-tests failure cluster is the full unit suite, especially KnowledgeGraph corrupted Orama recovery. Verified with bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 5, bun test --max-concurrency=1, and bun run smoke. * Harden smoke test isolation Audit and harden broad smoke-adjacent test suites for process-global leaks, including env/config restoration, shared registry/module mock cleanup, fetch/axios/mock restoration, and global MACRO/platform/sandbox mutations. Replace fragile render sleeps in interactive tests with output-driven waits, and isolate provider/model/profile tests behind the shared mutation lock so unrelated PRs do not inherit stale process state. Make SQLite knowledge graph cleanup clear closed on-disk databases before best-effort file cleanup, with coverage for the stale database reset path. Verified: bun run smoke; bun test --max-concurrency=1; python -m pytest -q python/tests; bun run security:pr-scan -- --base origin/main; bun run test:provider; npm run test:provider-recommendation. * Harden test isolation across smoke suite Guard process-global test mutations with the shared mutation lock across env, module mock, config cache, and storage tests.\n\nDeep-copy global config snapshots, restore transient globals precisely, and make plugin/LSP mocks expose compatible export surfaces so concurrent test loading does not poison unrelated suites.\n\nReplace fixed SDK cleanup sleeps with call polling to remove timing sensitivity.\n\nVerification:\n- bun test --max-concurrency=1\n- bun run smoke\n- python -m pytest -q python/tests\n- bun run test:provider\n- npm run test:provider-recommendation\n- bun run security:pr-scan -- --base origin/main\n- git diff --check * Close remaining test global-state leaks Guard remaining cache, plugin, console, and VS Code module-mock tests with the shared mutation lock.\n\nThis follow-up audit covers non-env process-global state that can leak across test files: tool schema cache, cache stats tracker state, plugin loader caches, console.error replacement, and VS Code mock.module usage.\n\nVerification:\n- leak-surface scans for env/global/mock.module/cache outliers\n- duplicate top-level mock collision cluster\n- affected tests cluster\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check * Guard remaining mock restore cleanup Lock tests that call bun:test mock.restore without installing module mocks themselves.\n\nmock.restore is process-global, so these cleanup hooks can still tear down another test file's active module mocks when files run concurrently.\n\nVerification:\n- expanded leak scans for env, globals, module mocks, mock.restore, timers, argv, and caches\n- bun test src/components/useCodexOAuthFlow.test.tsx src/services/github/deviceFlow.test.ts\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check * Harden test isolation for smoke stability Serialize tests that mutate process-global state behind the shared mutation lock, including process.env, transient globals, global config/cache state, storage mocks, and Bun module mocks. Add isolated env mutex instances for SDK mutex tests so timeout coverage no longer manipulates the live process-global mutex. Move top-level mock.module setup behind lock acquisition and restore mocks before releasing locks to prevent cross-file leakage under parallel smoke runs. Verified with: bun test --max-concurrency=1; bun test; bun run smoke.
889 lines
38 KiB
TypeScript
889 lines
38 KiB
TypeScript
/**
|
|
* OpenClaude build script — bundles the TypeScript source into a single
|
|
* distributable JS file using Bun's bundler.
|
|
*
|
|
* Handles:
|
|
* - bun:bundle feature() flags for the open build
|
|
* - MACRO.* globals → inlined version/build-time constants
|
|
* - src/ path aliases
|
|
*/
|
|
|
|
import { readFileSync } from 'fs'
|
|
import { noTelemetryPlugin } from './no-telemetry-plugin'
|
|
import { CLI_EXTERNALS, SDK_EXTERNALS } from './externals.js'
|
|
|
|
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
|
const version = pkg.version
|
|
|
|
// Feature flags for the open build.
|
|
// Most Anthropic-internal features stay off; open-build features can be
|
|
// selectively enabled here when their full source exists in the mirror.
|
|
const featureFlags: Record<string, boolean> = {
|
|
// ── Disabled: require Anthropic infrastructure or missing source ─────
|
|
VOICE_MODE: false, // Push-to-talk STT via claude.ai OAuth endpoint
|
|
PROACTIVE: false, // Autonomous agent mode (missing proactive/ module)
|
|
KAIROS: false, // Persistent assistant/session mode (cloud backend)
|
|
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
|
|
DAEMON: false, // Background daemon process (stubbed in open build)
|
|
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
|
|
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
|
|
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
|
|
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
|
|
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
|
|
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
|
|
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
|
|
CHICAGO_MCP: false, // Computer-use MCP (native Swift modules stubbed)
|
|
COWORKER_TYPE_TELEMETRY: false, // Telemetry for agent/coworker type classification
|
|
MCP_SKILLS: false, // Dynamic MCP skill discovery (src/skills/mcpSkills.ts not mirrored; enabling this causes "fetchMcpSkillsForClient is not a function" when MCP servers with resources connect — see #856)
|
|
|
|
// ── Enabled: upstream defaults ──────────────────────────────────────
|
|
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
|
|
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
|
|
BUDDY: true, // Buddy mode for paired programming
|
|
MONITOR_TOOL: true, // MCP server monitoring/streaming tool
|
|
TEAMMEM: true, // Team memory management
|
|
MESSAGE_ACTIONS: true, // Message action buttons in the UI
|
|
|
|
// ── Enabled: new activations ────────────────────────────────────────
|
|
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
|
|
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
|
|
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
|
|
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
|
|
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
|
|
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
|
|
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
|
|
QUICK_SEARCH: true, // Ctrl+G quick search across prompts
|
|
SHOT_STATS: true, // Shot distribution stats in session summary
|
|
EXTRACT_MEMORIES: true, // Auto-extract durable memories from conversations
|
|
FORK_SUBAGENT: true, // Implicit context-forking when omitting subagent_type
|
|
VERIFICATION_AGENT: true, // Built-in read-only agent for test/verification
|
|
PROMPT_CACHE_BREAK_DETECTION: true, // Detect & log unexpected prompt cache invalidations
|
|
HOOK_PROMPTS: true, // Allow tools to request interactive user prompts
|
|
}
|
|
|
|
// ── Pre-process: replace feature() calls with boolean literals ──────
|
|
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
|
|
// before plugins can intercept it via onResolve. The bun: namespace is
|
|
// handled by Bun's C++ resolver which runs before the JS plugin phase,
|
|
// so the previous onResolve/onLoad shim was silently ineffective — ALL
|
|
// feature() calls evaluated to false regardless of the featureFlags map.
|
|
//
|
|
// Fix: transform source as Bun loads each module, stripping the bun:bundle
|
|
// import and replacing feature('FLAG') calls with their boolean literal.
|
|
// The working tree stays immutable while smoke/build runs.
|
|
|
|
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
|
|
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
|
|
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
|
|
const featureFlagTransformedFiles = new Set<string>()
|
|
|
|
const featureFlagPreprocessPlugin = {
|
|
name: 'feature-flag-preprocess',
|
|
setup(build) {
|
|
build.onLoad({ filter: /\.[cm]?tsx?$/ }, args => {
|
|
const normalizedPath = args.path.replace(/\\/g, '/')
|
|
if (!normalizedPath.includes('/src/')) return null
|
|
|
|
const raw = readFileSync(args.path, 'utf-8')
|
|
if (!raw.includes('feature(')) return null
|
|
|
|
let contents = raw
|
|
contents = contents.replace(featureImportRe, '')
|
|
contents = contents.replace(featureCallRe, (_match, name) =>
|
|
String((featureFlags as Record<string, boolean>)[name] ?? false),
|
|
)
|
|
|
|
if (contents === raw) return null
|
|
|
|
featureFlagTransformedFiles.add(args.path)
|
|
return {
|
|
contents,
|
|
loader: args.path.endsWith('.tsx') || args.path.endsWith('.jsx')
|
|
? 'tsx'
|
|
: 'ts',
|
|
}
|
|
})
|
|
},
|
|
}
|
|
|
|
let result: Awaited<ReturnType<typeof Bun.build>> | undefined
|
|
let sdkResult: Awaited<ReturnType<typeof Bun.build>> | undefined
|
|
|
|
try {
|
|
|
|
result = await Bun.build({
|
|
entrypoints: ['./src/entrypoints/cli.tsx'],
|
|
outdir: './dist',
|
|
target: 'node',
|
|
format: 'esm',
|
|
splitting: false,
|
|
sourcemap: 'external',
|
|
minify: false,
|
|
naming: 'cli.mjs',
|
|
define: {
|
|
// MACRO.* build-time constants
|
|
// Keep the internal compatibility version high enough to pass
|
|
// first-party minimum-version guards, but expose the real package
|
|
// version separately in Open Claude branding.
|
|
'MACRO.VERSION': JSON.stringify('99.0.0'),
|
|
'MACRO.DISPLAY_VERSION': JSON.stringify(version),
|
|
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
|
'MACRO.ISSUES_EXPLAINER':
|
|
JSON.stringify('report the issue at https://github.com/Gitlawb/openclaude/issues'),
|
|
'MACRO.FEEDBACK_CHANNEL':
|
|
JSON.stringify('https://github.com/Gitlawb/openclaude/issues'),
|
|
'MACRO.PACKAGE_URL': JSON.stringify('@gitlawb/openclaude'),
|
|
'MACRO.NATIVE_PACKAGE_URL': 'undefined',
|
|
},
|
|
plugins: [
|
|
noTelemetryPlugin,
|
|
featureFlagPreprocessPlugin,
|
|
{
|
|
name: 'bun-bundle-shim',
|
|
setup(build) {
|
|
const internalFeatureStubModules = new Map([
|
|
[
|
|
'../daemon/workerRegistry.js',
|
|
'export async function runDaemonWorker() { throw new Error("Daemon worker is unavailable in the open build."); }',
|
|
],
|
|
[
|
|
'../daemon/main.js',
|
|
'export async function daemonMain() { throw new Error("Daemon mode is unavailable in the open build."); }',
|
|
],
|
|
[
|
|
'../cli/bg.js',
|
|
`
|
|
export async function psHandler() { throw new Error("Background sessions are unavailable in the open build."); }
|
|
export async function logsHandler() { throw new Error("Background sessions are unavailable in the open build."); }
|
|
export async function attachHandler() { throw new Error("Background sessions are unavailable in the open build."); }
|
|
export async function killHandler() { throw new Error("Background sessions are unavailable in the open build."); }
|
|
export async function handleBgFlag() { throw new Error("Background sessions are unavailable in the open build."); }
|
|
`,
|
|
],
|
|
[
|
|
'../cli/handlers/templateJobs.js',
|
|
'export async function templatesMain() { throw new Error("Template jobs are unavailable in the open build."); }',
|
|
],
|
|
[
|
|
'../environment-runner/main.js',
|
|
'export async function environmentRunnerMain() { throw new Error("Environment runner is unavailable in the open build."); }',
|
|
],
|
|
[
|
|
'../self-hosted-runner/main.js',
|
|
'export async function selfHostedRunnerMain() { throw new Error("Self-hosted runner is unavailable in the open build."); }',
|
|
],
|
|
] as const)
|
|
|
|
// bun:bundle feature() replacement is handled by featureFlagPreprocessPlugin.
|
|
// The previous onResolve/onLoad shim was ineffective in Bun
|
|
// v1.3.9+ because the bun: namespace is resolved natively
|
|
// before the JS plugin phase runs.
|
|
|
|
build.onResolve(
|
|
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
|
|
args => {
|
|
if (!internalFeatureStubModules.has(args.path)) return null
|
|
return {
|
|
path: args.path,
|
|
namespace: 'internal-feature-stub',
|
|
}
|
|
},
|
|
)
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'internal-feature-stub' },
|
|
args => ({
|
|
contents:
|
|
internalFeatureStubModules.get(args.path) ??
|
|
'export {}',
|
|
loader: 'js',
|
|
}),
|
|
)
|
|
|
|
// Resolve react/compiler-runtime to the standalone package
|
|
build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({
|
|
path: 'react/compiler-runtime',
|
|
namespace: 'react-compiler-shim',
|
|
}))
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'react-compiler-shim' },
|
|
() => ({
|
|
contents: `export function c(size) { return new Array(size).fill(Symbol.for('react.memo_cache_sentinel')); }`,
|
|
loader: 'js',
|
|
}),
|
|
)
|
|
|
|
// Resolve native addon and missing snapshot imports to stubs
|
|
for (const mod of [
|
|
'audio-capture-napi',
|
|
'audio-capture.node',
|
|
'image-processor-napi',
|
|
'modifiers-napi',
|
|
'url-handler-napi',
|
|
'color-diff-napi',
|
|
'@anthropic-ai/mcpb',
|
|
'@ant/claude-for-chrome-mcp',
|
|
'@anthropic-ai/sandbox-runtime',
|
|
'asciichart',
|
|
'plist',
|
|
'cacache',
|
|
'fuse',
|
|
'code-excerpt',
|
|
'stack-utils',
|
|
]) {
|
|
build.onResolve({ filter: new RegExp(`^${mod}$`) }, () => ({
|
|
path: mod,
|
|
namespace: 'native-stub',
|
|
}))
|
|
}
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'native-stub' },
|
|
() => ({
|
|
// Comprehensive stub that handles any named export via Proxy
|
|
contents: `
|
|
const noop = () => null;
|
|
const noopClass = class {};
|
|
const handler = {
|
|
get(_, prop) {
|
|
if (prop === '__esModule') return true;
|
|
if (prop === 'default') return new Proxy({}, handler);
|
|
if (prop === 'SandboxRuntimeConfigSchema') return { parse: () => ({}) };
|
|
return noop;
|
|
}
|
|
};
|
|
const stub = new Proxy(noop, handler);
|
|
export default stub;
|
|
export const __stub = true;
|
|
// Named exports for all known imports
|
|
export const SandboxViolationStore = null;
|
|
export const SandboxManager = new Proxy({}, { get: () => noop });
|
|
export const SandboxRuntimeConfigSchema = { parse: () => ({}) };
|
|
export const BROWSER_TOOLS = [];
|
|
export const getMcpConfigForManifest = noop;
|
|
export const ColorDiff = null;
|
|
export const ColorFile = null;
|
|
export const getSyntaxTheme = noop;
|
|
export const plot = noop;
|
|
export const createClaudeForChromeMcpServer = noop;
|
|
`,
|
|
loader: 'js',
|
|
}),
|
|
)
|
|
|
|
// Resolve .md and .txt file imports to empty string stubs
|
|
build.onResolve({ filter: /\.(md|txt)$/ }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'text-stub',
|
|
}))
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'text-stub' },
|
|
() => ({
|
|
contents: `export default '';`,
|
|
loader: 'js',
|
|
}),
|
|
)
|
|
|
|
// Pre-scan: find all missing modules that need stubbing
|
|
// (Bun's onResolve corrupts module graph even when returning null,
|
|
// so we use exact-match resolvers instead of catch-all patterns)
|
|
const fs = require('fs')
|
|
const pathMod = require('path')
|
|
const srcDir = pathMod.resolve(__dirname, '..', 'src')
|
|
const missingModules = new Set<string>()
|
|
const missingModuleExports = new Map<string, Set<string>>()
|
|
|
|
// Known missing external packages
|
|
for (const pkg of [
|
|
'@ant/computer-use-mcp',
|
|
'@ant/computer-use-mcp/sentinelApps',
|
|
'@ant/computer-use-mcp/types',
|
|
'@ant/computer-use-swift',
|
|
'@ant/computer-use-input',
|
|
]) {
|
|
missingModules.add(pkg)
|
|
}
|
|
|
|
// Scan source to find imports that can't resolve
|
|
function scanForMissingImports() {
|
|
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
|
|
const names = namedPart.split(',')
|
|
.map((s: string) => s.trim().replace(/^type\s+/, ''))
|
|
.filter((s: string) => s && !s.startsWith('type '))
|
|
|
|
// Check src/tasks/ non-relative imports
|
|
if (specifier.startsWith('src/tasks/')) {
|
|
const resolved = pathMod.resolve(__dirname, '..', specifier)
|
|
const candidates = [
|
|
resolved,
|
|
`${resolved}.ts`, `${resolved}.tsx`,
|
|
resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'),
|
|
pathMod.join(resolved, 'index.ts'), pathMod.join(resolved, 'index.tsx'),
|
|
]
|
|
if (!candidates.some((c: string) => fs.existsSync(c))) {
|
|
missingModules.add(specifier)
|
|
}
|
|
}
|
|
// Check relative .js imports
|
|
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
|
|
const resolved = pathMod.resolve(fileDir, specifier)
|
|
const tsVariant = resolved.replace(/\.js$/, '.ts')
|
|
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
|
|
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
|
|
missingModules.add(specifier)
|
|
}
|
|
}
|
|
|
|
// Track named exports for missing modules
|
|
if (names.length > 0) {
|
|
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
|
|
for (const n of names) missingModuleExports.get(specifier)!.add(n)
|
|
}
|
|
}
|
|
|
|
function walk(dir: string) {
|
|
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = pathMod.join(dir, ent.name)
|
|
if (ent.isDirectory()) { walk(full); continue }
|
|
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
|
const rawCode: string = fs.readFileSync(full, 'utf-8')
|
|
const fileDir = pathMod.dirname(full)
|
|
|
|
// Strip comments before scanning for imports/requires.
|
|
// The regex scanner matches require()/import() patterns
|
|
// inside JSDoc comments, causing false-positive missing
|
|
// module detection that breaks the build with noop stubs.
|
|
const code = rawCode
|
|
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
.replace(/\/\/.*$/gm, '') // line comments
|
|
|
|
// Collect static imports: import { X } from '...'
|
|
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
|
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
|
|
}
|
|
|
|
// Collect dynamic requires: require('...') — these are used
|
|
// behind feature() gates and become live when flags are enabled.
|
|
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
|
|
checkAndRegister(m[1], fileDir, '')
|
|
}
|
|
|
|
// Collect dynamic imports: import('...')
|
|
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
|
|
checkAndRegister(m[1], fileDir, '')
|
|
}
|
|
}
|
|
}
|
|
walk(srcDir)
|
|
}
|
|
scanForMissingImports()
|
|
|
|
// Register exact-match resolvers for each missing module
|
|
for (const mod of missingModules) {
|
|
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
|
|
path: mod,
|
|
namespace: 'missing-module-stub',
|
|
}))
|
|
}
|
|
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'missing-module-stub' },
|
|
(args) => {
|
|
const names = missingModuleExports.get(args.path) ?? new Set()
|
|
const exports = [...names].map(n => `export const ${n} = noop;`).join('\n')
|
|
return {
|
|
contents: `
|
|
const noop = () => null;
|
|
export default noop;
|
|
${exports}
|
|
`,
|
|
loader: 'js',
|
|
}
|
|
},
|
|
)
|
|
},
|
|
},
|
|
],
|
|
external: CLI_EXTERNALS,
|
|
})
|
|
|
|
if (!result.success) {
|
|
console.error('Build failed:')
|
|
for (const log of result.logs) {
|
|
console.error(log)
|
|
}
|
|
process.exitCode = 1
|
|
} else {
|
|
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
|
|
}
|
|
|
|
// ── SDK Bundle Build ──────────────────────────────────────────────────────
|
|
// SDK is a separate bundle for npm consumption - must NOT bundle React/Ink
|
|
console.log('Building SDK bundle...')
|
|
|
|
sdkResult = await Bun.build({
|
|
entrypoints: ['./src/entrypoints/sdk/index.ts'],
|
|
outdir: './dist',
|
|
target: 'node',
|
|
format: 'esm',
|
|
splitting: false,
|
|
sourcemap: 'external',
|
|
minify: false,
|
|
naming: 'sdk.mjs',
|
|
define: {
|
|
'MACRO.VERSION': JSON.stringify(version),
|
|
'MACRO.DISPLAY_VERSION': JSON.stringify(version),
|
|
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
|
'MACRO.ISSUES_EXPLAINER':
|
|
JSON.stringify('report the issue at https://github.com/Gitlawb/openclaude/issues'),
|
|
'MACRO.FEEDBACK_CHANNEL':
|
|
JSON.stringify('https://github.com/Gitlawb/openclaude/issues'),
|
|
'MACRO.PACKAGE_URL': JSON.stringify('@gitlawb/openclaude'),
|
|
'MACRO.NATIVE_PACKAGE_URL': 'undefined',
|
|
},
|
|
// External: everything TUI-related + native modules
|
|
external: SDK_EXTERNALS,
|
|
plugins: [
|
|
noTelemetryPlugin,
|
|
featureFlagPreprocessPlugin,
|
|
// Stub missing internal/optional modules (same pattern as CLI build)
|
|
{
|
|
name: 'sdk-missing-stub',
|
|
setup(build) {
|
|
const missingModules = [
|
|
'@anthropic-ai/mcpb',
|
|
'@ant/claude-for-chrome-mcp',
|
|
'@ant/computer-use-mcp',
|
|
'@ant/computer-use-swift',
|
|
'@ant/computer-use-input',
|
|
'@anthropic-ai/sandbox-runtime',
|
|
'audio-capture-napi', 'audio-capture.node',
|
|
'image-processor-napi', 'modifiers-napi', 'url-handler-napi', 'color-diff-napi',
|
|
'asciichart', 'plist', 'cacache', 'fuse', 'code-excerpt', 'stack-utils',
|
|
]
|
|
for (const mod of missingModules) {
|
|
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
|
|
path: mod,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
}
|
|
// Stub relative imports to TUI directories
|
|
// Use (\.\.?\/)+ to match multiple ../ prefixes like ../../components/
|
|
build.onResolve({ filter: /^(\.\.?\/)+components\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^(\.\.?\/)+ink\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^(\.\.?\/)+commands\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^(\.\.?\/)+cli\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub relative imports to state/ directory EXCEPT for store.js and AppStateStore.js
|
|
// which are React-free utilities needed by the SDK for state management.
|
|
build.onResolve({ filter: /^(\.\.?\/)+state\// }, (args) => {
|
|
// Exclude React-free state utilities from stubbing
|
|
const isReactFreeStateModule =
|
|
args.path.endsWith('store.js') ||
|
|
args.path.endsWith('AppStateStore.js') ||
|
|
args.path.endsWith('store.ts') ||
|
|
args.path.endsWith('AppStateStore.ts')
|
|
if (isReactFreeStateModule) {
|
|
return null // Let Bun resolve normally
|
|
}
|
|
return {
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}
|
|
})
|
|
build.onResolve({ filter: /^(\.\.?\/)+context\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub root ink.js barrel imports (../ink.js, ../../ink.js, ./ink.js)
|
|
// These are TUI entry points that import React directly.
|
|
build.onResolve({ filter: /^(\.\.?\/)+ink\.js$/ }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Also stub ./ paths used by re-exports in src/ink.ts, src/components/, etc.
|
|
build.onResolve({ filter: /^\.\/components\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^\.\/ink\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^\.\/commands\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^\.\/cli\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub tool UI.js imports from within src/tools/ subdirectories.
|
|
// Tool UI modules render React/TUI components that are not needed
|
|
// in the SDK (headless) bundle. Only stub when the importer is
|
|
// inside src/tools/ to avoid blind-matching other UI.js files.
|
|
build.onResolve({ filter: /(?:^|\/)UI\.js$/ }, (args) => {
|
|
// Normalize path separators for cross-platform matching
|
|
const importer = (args.importer || '').replace(/\\/g, '/')
|
|
if (importer.includes('src/tools/')) {
|
|
return {
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
// Stub src/ alias imports that resolve to TUI directories
|
|
// These are used by require('src/components/...') style imports
|
|
build.onResolve({ filter: /^src\/components\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^src\/ink\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub src/ink.js root barrel import (used by some files via 'src/ink.js')
|
|
build.onResolve({ filter: /^src\/ink\.js$/ }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^src\/commands\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^src\/cli\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// src/state/ contains AppState.tsx with React hooks, but store.ts and AppStateStore.ts
|
|
// are React-free utilities needed by the SDK - exclude them from stubbing.
|
|
build.onResolve({ filter: /^src\/state\// }, (args) => {
|
|
// Exclude React-free state utilities from stubbing
|
|
const isReactFreeStateModule =
|
|
args.path.endsWith('store.js') ||
|
|
args.path.endsWith('AppStateStore.js') ||
|
|
args.path.endsWith('store.ts') ||
|
|
args.path.endsWith('AppStateStore.ts')
|
|
if (isReactFreeStateModule) {
|
|
return null // Let Bun resolve normally
|
|
}
|
|
return {
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}
|
|
})
|
|
build.onResolve({ filter: /^src\/context\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub src/keybindings/ — React-dependent keybinding system not needed in SDK
|
|
build.onResolve({ filter: /^src\/keybindings\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
build.onResolve({ filter: /^(\.\.?\/)+keybindings\// }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub react-compiler-runtime — not needed in SDK bundle
|
|
build.onResolve({ filter: /^react-compiler-runtime$/ }, () => ({
|
|
path: 'react-compiler-runtime',
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
// Stub TUI-only React hook files that leak into SDK via tool imports.
|
|
// These are imported transitively through spawnMultiAgent → It2SetupPrompt
|
|
// and through keybinding hooks. The SDK doesn't use TUI features.
|
|
for (const hookPath of [
|
|
'useDoublePress.js', 'useExitOnCtrlCD.js', 'useExitOnCtrlCDWithKeybindings.js',
|
|
'useTerminalSize.js', 'useShortcutDisplay.js',
|
|
]) {
|
|
const escaped = hookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
build.onResolve({ filter: new RegExp(`(^|/)${escaped}$`) }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
}
|
|
// Stub It2SetupPrompt.tsx — TUI component pulled in by spawnMultiAgent
|
|
build.onResolve({ filter: /It2SetupPrompt\.js$/ }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
|
|
// Stub react/jsx-dev-runtime with local no-op — tool .tsx files compile
|
|
// to jsxDEV() calls that are never rendered in SDK headless mode.
|
|
// This eliminates the external react/jsx-dev-runtime import entirely.
|
|
build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
|
|
path: 'react/jsx-dev-runtime',
|
|
namespace: 'sdk-jsx-stub',
|
|
}))
|
|
build.onLoad({ filter: /.*/, namespace: 'sdk-jsx-stub' }, () => ({
|
|
contents: `
|
|
// No-op jsxDEV: returns null (SDK never renders JSX)
|
|
export function jsxDEV(type, props, key, isStaticChildren, source, self) {
|
|
return null;
|
|
}
|
|
// No-op Fragment: returns null (never used in SDK rendering)
|
|
export const Fragment = null;
|
|
`,
|
|
loader: 'js',
|
|
}))
|
|
|
|
// Resolve .md and .txt file imports (used by yolo-classifier etc.) to empty string stubs
|
|
build.onResolve({ filter: /\.(md|txt)$/, namespace: 'file' }, (args) => ({
|
|
path: args.path,
|
|
namespace: 'sdk-text-stub',
|
|
}))
|
|
build.onLoad(
|
|
{ filter: /.*/, namespace: 'sdk-text-stub' },
|
|
() => ({
|
|
contents: `export default '';`,
|
|
loader: 'js',
|
|
}),
|
|
)
|
|
|
|
// Stub require() calls to modules that don't exist on disk.
|
|
// These are feature-gated lazy imports (e.g. cachedMCConfig, VerifyPlanExecutionTool,
|
|
// mcpSkills) that only resolve when the feature flag is enabled at build time.
|
|
// Pre-scan source files for require('...') to non-existent .js paths.
|
|
const sdkRequireScanDir = require('path').resolve(__dirname, '..', 'src')
|
|
const sdkMissingRequires = new Set<string>()
|
|
const sdkPathMod = require('path')
|
|
const sdkFs = require('fs')
|
|
function scanSdkRequireImports() {
|
|
function walkRequireScan(dir: string) {
|
|
for (const ent of sdkFs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = sdkPathMod.join(dir, ent.name)
|
|
if (ent.isDirectory()) { walkRequireScan(full); continue }
|
|
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
|
const fileDir = sdkPathMod.dirname(full)
|
|
const rawCode: string = sdkFs.readFileSync(full, 'utf-8')
|
|
// Strip comments
|
|
const code = rawCode
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/.*$/gm, '')
|
|
// Collect require('...') calls for relative .js paths
|
|
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+\.js)['"]\s*\)/g)) {
|
|
const specifier = m[1]
|
|
const resolved = sdkPathMod.resolve(fileDir, specifier)
|
|
const tsVariant = resolved.replace(/\.js$/, '.ts')
|
|
if (!sdkFs.existsSync(resolved) && !sdkFs.existsSync(tsVariant)) {
|
|
sdkMissingRequires.add(specifier)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walkRequireScan(sdkRequireScanDir)
|
|
}
|
|
scanSdkRequireImports()
|
|
for (const mod of sdkMissingRequires) {
|
|
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
|
|
path: mod,
|
|
namespace: 'sdk-missing-stub',
|
|
}))
|
|
}
|
|
|
|
// Pre-scan: find all named imports for each stubbed module so we can
|
|
// generate matching exports dynamically (avoids the whack-a-mole of
|
|
// static export lists that break whenever a new import is added).
|
|
const fs = require('fs')
|
|
const pathMod = require('path')
|
|
const srcDir = pathMod.resolve(__dirname, '..', 'src')
|
|
const sdkStubExports = new Map<string, Set<string>>() // module path → set of imported names
|
|
|
|
function scanSdkStubImports() {
|
|
function register(specifier: string, namedPart: string) {
|
|
const rawNames = namedPart.split(',')
|
|
.map((s: string) => s.trim().replace(/^type\s+/, ''))
|
|
.filter((s: string) => s && !s.startsWith('type '))
|
|
if (rawNames.length === 0) return
|
|
if (!sdkStubExports.has(specifier)) sdkStubExports.set(specifier, new Set())
|
|
const names = sdkStubExports.get(specifier)!
|
|
for (const s of rawNames) {
|
|
// Handle "originalName as localName" — export BOTH names
|
|
// because Bun validates the original export name exists
|
|
const asMatch = s.match(/^(\w+)\s+as\s+(\w+)$/)
|
|
if (asMatch) {
|
|
names.add(asMatch[1]) // original name
|
|
names.add(asMatch[2]) // aliased name
|
|
} else {
|
|
names.add(s)
|
|
}
|
|
}
|
|
}
|
|
const isStubbedSpecifier = (s: string) =>
|
|
missingModules.includes(s) ||
|
|
/^(\.\.?\/)+(components|ink|commands|cli|context|state|keybindings)\//.test(s) ||
|
|
/^(\.\.?\/)+ink\.js$/.test(s) ||
|
|
/^src\/(components|ink|commands|cli|state|context|keybindings)\//.test(s) ||
|
|
/^src\/ink\.js$/.test(s) ||
|
|
/(?:^|\/)UI\.js$/.test(s) ||
|
|
s === 'react-compiler-runtime' ||
|
|
/(?:^|\/)It2SetupPrompt\.js$/.test(s) ||
|
|
/(?:^|\/)(useDoublePress|useExitOnCtrlCD|useExitOnCtrlCDWithKeybindings|useTerminalSize|useShortcutDisplay)\.js$/.test(s)
|
|
function walk(dir: string) {
|
|
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = pathMod.join(dir, ent.name)
|
|
if (ent.isDirectory()) { walk(full); continue }
|
|
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
|
const fileDir = pathMod.dirname(full)
|
|
const rawCode: string = fs.readFileSync(full, 'utf-8')
|
|
// Strip comments
|
|
const code = rawCode
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/.*$/gm, '')
|
|
// Collect static imports: import { X } from '...'
|
|
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
|
const specifier = m[4]
|
|
if (isStubbedSpecifier(specifier)) {
|
|
register(specifier, m[1] || m[3] || '')
|
|
}
|
|
}
|
|
// Collect re-exports: export { X, Y } from '...'
|
|
for (const m of code.matchAll(/export\s+\{([^}]*)\}\s*from\s+['"](.*?)['"]/g)) {
|
|
const specifier = m[2]
|
|
if (isStubbedSpecifier(specifier)) {
|
|
register(specifier, m[1])
|
|
}
|
|
}
|
|
// Collect star re-exports: export * from '...'
|
|
// These re-export all named exports from the source module.
|
|
// For stubbed modules, we need to scan the re-exported module
|
|
// to find its exports and register them under the stubbed specifier.
|
|
for (const m of code.matchAll(/export\s+\*\s+from\s+['"](.*?)['"]/g)) {
|
|
const specifier = m[1]
|
|
if (isStubbedSpecifier(specifier)) {
|
|
// The re-exported module might itself be stubbed, so we need
|
|
// to find its exports. Parse the relative path and scan it.
|
|
const reexportPath = pathMod.resolve(fileDir, specifier)
|
|
const reexportBase = reexportPath.replace(/\.js$/, '')
|
|
const candidates = [
|
|
`${reexportBase}.ts`,
|
|
`${reexportBase}.tsx`,
|
|
reexportPath,
|
|
`${reexportPath}.ts`,
|
|
`${reexportPath}.tsx`,
|
|
]
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) {
|
|
const reexportCode = fs.readFileSync(candidate, 'utf-8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/.*$/gm, '')
|
|
// Collect exports from the re-exported module
|
|
for (const exp of reexportCode.matchAll(/export\s+(?:const|let|var|function|class|type|interface)\s+(\w+)/g)) {
|
|
register(specifier, exp[1])
|
|
}
|
|
for (const exp of reexportCode.matchAll(/export\s+\{([^}]*)\}/g)) {
|
|
register(specifier, exp[1])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(srcDir)
|
|
}
|
|
scanSdkStubImports()
|
|
|
|
// Special default exports for known modules
|
|
const defaultExportOverrides: Record<string, string> = {
|
|
'stringWidth': '(s) => s?.length || 0',
|
|
'wrapAnsi': '(s) => s',
|
|
'instances': 'new Map()',
|
|
'selectableUserMessagesFilter': '() => true',
|
|
'messagesAfterAreOnlySynthetic': '() => false',
|
|
'SandboxManager': 'class { static isSupportedPlatform = () => false; static create = noop; static Version = \'\'; }',
|
|
'SandboxRuntimeConfigSchema': '{ parse: noop }',
|
|
'SandboxViolationStore': 'null',
|
|
'BaseSandboxManager': 'class { static isSupportedPlatform = () => false; }',
|
|
'ExportResultCode': '{ SUCCESS: 0, FAILED: 1 }',
|
|
'linkifyUrlsInText': '(s) => s',
|
|
}
|
|
|
|
build.onLoad({ filter: /.*/, namespace: 'sdk-missing-stub' }, (args) => {
|
|
const names = sdkStubExports.get(args.path) ?? new Set()
|
|
const parts: string[] = []
|
|
for (const n of names) {
|
|
if (n === 'default') continue // handled by `export default noop` below
|
|
const val = defaultExportOverrides[n] ?? 'noop'
|
|
parts.push(`export const ${n} = ${val};`)
|
|
}
|
|
return {
|
|
contents: `
|
|
const noop = () => null;
|
|
export default noop;
|
|
export const __stub = true;
|
|
${parts.join('\n')}
|
|
`,
|
|
loader: 'js',
|
|
}
|
|
})
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
if (!sdkResult.success) {
|
|
console.error('SDK build failed:')
|
|
for (const log of sdkResult.logs) {
|
|
console.error(log)
|
|
}
|
|
process.exitCode = 1
|
|
} else {
|
|
console.log(`✓ Built SDK bundle → dist/sdk.mjs`)
|
|
}
|
|
|
|
} finally {
|
|
console.log(` 🔄 feature-flags: transformed ${featureFlagTransformedFiles.size} files during bundling`)
|
|
}
|
|
|
|
// ── Validate SDK bundle for React/Ink leakage ──────────────────────────────
|
|
if (sdkResult?.success) {
|
|
const sdkBundle = readFileSync('./dist/sdk.mjs', 'utf-8')
|
|
// Patterns that indicate React/Ink code leaked into the SDK bundle.
|
|
const reactInkPatterns = [
|
|
/from\s+["']react["']/, // direct react import
|
|
/from\s+["']ink["']/, // direct ink import
|
|
/from\s+["']react\/jsx-dev-runtime["']/, // JSX runtime (must be stubbed, not external)
|
|
]
|
|
const leaks: string[] = []
|
|
for (const pattern of reactInkPatterns) {
|
|
const match = sdkBundle.match(pattern)
|
|
if (match) leaks.push(match[0])
|
|
}
|
|
if (leaks.length > 0) {
|
|
console.error(`\n❌ SDK bundle contains React/Ink imports (must be stubbed):`)
|
|
for (const leak of leaks) console.error(` - ${leak}`)
|
|
process.exitCode = 1
|
|
} else {
|
|
console.log(`✓ SDK bundle: no React/Ink leakage detected`)
|
|
}
|
|
}
|
|
|
|
// ── Validate external lists ──────────────────────────────────────────────
|
|
if (result?.success && sdkResult?.success) {
|
|
console.log('\nValidating external lists...')
|
|
const validation = Bun.spawnSync(['bun', 'run', 'scripts/validate-externals.ts'], {
|
|
stdout: 'inherit',
|
|
stderr: 'inherit',
|
|
})
|
|
if (validation.exitCode !== 0) {
|
|
process.exitCode = 1
|
|
}
|
|
}
|