📝 feat: update TypeScript configuration and add API support

- Changed root directory in tsconfig.json to include all source files.
- Updated server.json to include npm package configuration for claude-code-explorer-mcp.
- Enhanced x402 command to support non-interactive mode.
- Refactored x402 command call function to simplify argument handling.
- Introduced .mcp.json for MCP server configuration.
- Added bunfig.toml for Bun development mode configuration.
- Created bridge.md documentation for IDE integration and architecture overview.
- Added .npmignore to exclude unnecessary files from npm package.
- Implemented build-bundle script for production and development builds.
- Developed bun-plugin-shims for Bun preload plugin.
- Created ci-build.sh for CI/CD build pipeline.
- Added dev.ts for development launcher using Bun's TS runtime.
- Implemented package-npm.ts to generate a publishable npm package.
- Created test-auth.ts to verify API key configuration.
- Developed test-mcp.ts for MCP client/server roundtrip testing.
- Implemented test-services.ts to ensure all services initialize correctly.
- Added stub.ts for bridge functionality when BRIDGE_MODE is disabled.
This commit is contained in:
nirholas
2026-03-31 10:59:36 +00:00
parent c0b205208d
commit d35ea47ba7
22 changed files with 1377 additions and 29 deletions

138
scripts/build-bundle.ts Normal file
View File

@@ -0,0 +1,138 @@
// scripts/build-bundle.ts
// Usage: bun scripts/build-bundle.ts [--watch] [--minify] [--no-sourcemap]
//
// Production build: bun scripts/build-bundle.ts --minify
// Dev build: bun scripts/build-bundle.ts
// Watch mode: bun scripts/build-bundle.ts --watch
import * as esbuild from 'esbuild'
import { resolve } from 'path'
import { chmodSync, readFileSync } from 'fs'
const ROOT = resolve(import.meta.dir, '..')
const watch = process.argv.includes('--watch')
const minify = process.argv.includes('--minify')
const noSourcemap = process.argv.includes('--no-sourcemap')
// Read version from package.json for MACRO injection
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
const version = pkg.version || '0.0.0-dev'
const buildOptions: esbuild.BuildOptions = {
entryPoints: [resolve(ROOT, 'src/entrypoints/cli.tsx')],
bundle: true,
platform: 'node',
target: ['node20', 'es2022'],
format: 'esm',
outdir: resolve(ROOT, 'dist'),
outExtension: { '.js': '.mjs' },
// Single-file output — no code splitting for CLI tools
splitting: false,
// Inject the MACRO global before all other code
inject: [resolve(ROOT, 'src/shims/macro.ts')],
// Alias bun:bundle to our runtime shim
alias: {
'bun:bundle': resolve(ROOT, 'src/shims/bun-bundle.ts'),
},
// Don't bundle node built-ins or problematic native packages
external: [
// Node built-ins (with and without node: prefix)
'fs', 'path', 'os', 'crypto', 'child_process', 'http', 'https',
'net', 'tls', 'url', 'util', 'stream', 'events', 'buffer',
'querystring', 'readline', 'zlib', 'assert', 'tty', 'worker_threads',
'perf_hooks', 'async_hooks', 'dns', 'dgram', 'cluster',
'string_decoder', 'module', 'vm', 'constants', 'domain',
'console', 'process', 'v8', 'inspector',
'node:*',
// Native addons that can't be bundled
'fsevents',
],
jsx: 'automatic',
// Source maps for production debugging (external .map files)
sourcemap: noSourcemap ? false : 'external',
// Minification for production
minify,
// Tree shaking (on by default, explicit for clarity)
treeShaking: true,
// Define replacements — inline constants at build time
// Eliminates process.env.USER_TYPE === 'ant' branches (Anthropic-internal code)
// Sets NODE_ENV to production for production builds
define: {
'process.env.USER_TYPE': '"external"',
'process.env.NODE_ENV': minify ? '"production"' : '"development"',
},
// Banner: shebang for direct CLI execution
banner: {
js: '#!/usr/bin/env node\n',
},
// Handle the .js → .ts resolution that the codebase uses
resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
logLevel: 'info',
// Metafile for bundle analysis
metafile: true,
}
async function main() {
if (watch) {
const ctx = await esbuild.context(buildOptions)
await ctx.watch()
console.log('Watching for changes...')
} else {
const startTime = Date.now()
const result = await esbuild.build(buildOptions)
if (result.errors.length > 0) {
console.error('Build failed')
process.exit(1)
}
// Make the output executable
const outPath = resolve(ROOT, 'dist/cli.mjs')
try {
chmodSync(outPath, 0o755)
} catch {
// chmod may fail on some platforms, non-fatal
}
const elapsed = Date.now() - startTime
// Print bundle size info
if (result.metafile) {
const text = await esbuild.analyzeMetafile(result.metafile, { verbose: false })
const outFiles = Object.entries(result.metafile.outputs)
for (const [file, info] of outFiles) {
if (file.endsWith('.mjs')) {
const sizeMB = (info.bytes / 1024 / 1024).toFixed(2)
console.log(`\n ${file}: ${sizeMB} MB`)
}
}
console.log(`\nBuild complete in ${elapsed}ms → dist/`)
// Write metafile for further analysis
const { writeFileSync } = await import('fs')
writeFileSync(
resolve(ROOT, 'dist/meta.json'),
JSON.stringify(result.metafile),
)
console.log(' Metafile written to dist/meta.json')
}
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,18 @@
// scripts/bun-plugin-shims.ts
// Bun preload plugin — intercepts `bun:bundle` imports at runtime
// and resolves them to our local shim so the CLI can run without
// the production Bun bundler pass.
import { plugin } from 'bun'
import { resolve } from 'path'
plugin({
name: 'bun-bundle-shim',
setup(build) {
const shimPath = resolve(import.meta.dir, '../src/shims/bun-bundle.ts')
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
path: shimPath,
}))
},
})

49
scripts/ci-build.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# ci-build.sh — CI/CD build pipeline
# ─────────────────────────────────────────────────────────────
# Runs the full build pipeline: install, typecheck, lint, build,
# and verify the output. Intended for CI environments.
#
# Usage:
# ./scripts/ci-build.sh
# ─────────────────────────────────────────────────────────────
set -euo pipefail
echo "=== Installing dependencies ==="
bun install
echo "=== Type checking ==="
bun run typecheck
echo "=== Linting ==="
bun run lint
echo "=== Building production bundle ==="
bun run build:prod
echo "=== Verifying build output ==="
# Check that the bundle was produced
if [ ! -f dist/cli.mjs ]; then
echo "ERROR: dist/cli.mjs not found"
exit 1
fi
# Print bundle size
SIZE=$(ls -lh dist/cli.mjs | awk '{print $5}')
echo " Bundle size: $SIZE"
# Verify the bundle runs with Node.js
if command -v node &>/dev/null; then
VERSION=$(node dist/cli.mjs --version 2>&1 || true)
echo " node dist/cli.mjs --version → $VERSION"
fi
# Verify the bundle runs with Bun
if command -v bun &>/dev/null; then
VERSION=$(bun dist/cli.mjs --version 2>&1 || true)
echo " bun dist/cli.mjs --version → $VERSION"
fi
echo "=== Done ==="

15
scripts/dev.ts Normal file
View File

@@ -0,0 +1,15 @@
// scripts/dev.ts
// Development launcher — runs the CLI directly via Bun's TS runtime.
//
// Usage:
// bun scripts/dev.ts [args...]
// bun run dev [args...]
//
// The bun:bundle shim is loaded automatically via bunfig.toml preload.
// Bun automatically reads .env files from the project root.
// Load MACRO global (version, package url, etc.) before any app code
import '../src/shims/macro.js'
// Launch the CLI entrypoint
await import('../src/entrypoints/cli.js')

85
scripts/package-npm.ts Normal file
View File

@@ -0,0 +1,85 @@
// scripts/package-npm.ts
// Generate a publishable npm package in dist/npm/
//
// Usage: bun scripts/package-npm.ts
//
// Prerequisites: run `bun run build:prod` first to generate dist/cli.mjs
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, chmodSync } from 'fs'
import { resolve } from 'path'
const ROOT = resolve(import.meta.dir, '..')
const DIST = resolve(ROOT, 'dist')
const NPM_DIR = resolve(DIST, 'npm')
const CLI_BUNDLE = resolve(DIST, 'cli.mjs')
function main() {
// Verify the bundle exists
if (!existsSync(CLI_BUNDLE)) {
console.error('Error: dist/cli.mjs not found. Run `bun run build:prod` first.')
process.exit(1)
}
// Read source package.json
const srcPkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
// Create npm output directory
mkdirSync(NPM_DIR, { recursive: true })
// Copy the bundled CLI
copyFileSync(CLI_BUNDLE, resolve(NPM_DIR, 'cli.mjs'))
chmodSync(resolve(NPM_DIR, 'cli.mjs'), 0o755)
// Copy source map if it exists
const sourceMap = resolve(DIST, 'cli.mjs.map')
if (existsSync(sourceMap)) {
copyFileSync(sourceMap, resolve(NPM_DIR, 'cli.mjs.map'))
}
// Generate a publishable package.json
const npmPkg = {
name: srcPkg.name || '@anthropic-ai/claude-code',
version: srcPkg.version || '0.0.0',
description: srcPkg.description || 'Anthropic Claude Code CLI',
license: 'MIT',
type: 'module',
main: './cli.mjs',
bin: {
claude: './cli.mjs',
},
engines: {
node: '>=20.0.0',
},
os: ['darwin', 'linux', 'win32'],
files: [
'cli.mjs',
'cli.mjs.map',
'README.md',
],
}
writeFileSync(
resolve(NPM_DIR, 'package.json'),
JSON.stringify(npmPkg, null, 2) + '\n',
)
// Copy README if it exists
const readme = resolve(ROOT, 'README.md')
if (existsSync(readme)) {
copyFileSync(readme, resolve(NPM_DIR, 'README.md'))
}
// Summary
const bundleSize = readFileSync(CLI_BUNDLE).byteLength
const sizeMB = (bundleSize / 1024 / 1024).toFixed(2)
console.log('npm package generated in dist/npm/')
console.log(` package: ${npmPkg.name}@${npmPkg.version}`)
console.log(` bundle: cli.mjs (${sizeMB} MB)`)
console.log(` bin: claude → ./cli.mjs`)
console.log('')
console.log('To publish:')
console.log(' cd dist/npm && npm publish')
}
main()

26
scripts/test-auth.ts Normal file
View File

@@ -0,0 +1,26 @@
// scripts/test-auth.ts
// Quick test that the API key is configured and can reach Anthropic
// Usage: bun scripts/test-auth.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
async function main() {
try {
const msg = await client.messages.create({
model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',
max_tokens: 50,
messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }],
})
console.log('✅ API connection successful!')
console.log('Response:', msg.content[0].type === 'text' ? msg.content[0].text : msg.content[0])
} catch (err: any) {
console.error('❌ API connection failed:', err.message)
process.exit(1)
}
}
main()

180
scripts/test-mcp.ts Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env npx tsx
/**
* scripts/test-mcp.ts
* Test MCP client/server roundtrip using the standalone mcp-server sub-project.
*
* Usage:
* cd mcp-server && npm install && npm run build && cd ..
* npx tsx scripts/test-mcp.ts
*
* What it does:
* 1. Spawns mcp-server/dist/index.js as a child process (stdio transport)
* 2. Creates an MCP client using @modelcontextprotocol/sdk
* 3. Connects client to server
* 4. Lists available tools
* 5. Calls list_tools and read_source_file tools
* 6. Lists resources and reads one
* 7. Prints results and exits
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = resolve(__dirname, "..");
// ── Helpers ───────────────────────────────────────────────────────────────
function section(title: string) {
console.log(`\n${"─".repeat(60)}`);
console.log(` ${title}`);
console.log(`${"─".repeat(60)}`);
}
function jsonPretty(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const serverScript = resolve(PROJECT_ROOT, "mcp-server", "dist", "index.js");
const srcRoot = resolve(PROJECT_ROOT, "src");
section("1. Spawning MCP server (stdio transport)");
console.log(` Server: ${serverScript}`);
console.log(` SRC_ROOT: ${srcRoot}`);
const transport = new StdioClientTransport({
command: "node",
args: [serverScript],
env: {
...process.env,
CLAUDE_CODE_SRC_ROOT: srcRoot,
} as Record<string, string>,
stderr: "pipe",
});
// Log stderr from the server process
if (transport.stderr) {
transport.stderr.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) console.log(` [server stderr] ${msg}`);
});
}
section("2. Creating MCP client");
const client = new Client(
{
name: "test-mcp-client",
version: "1.0.0",
},
{
capabilities: {},
}
);
section("3. Connecting client → server");
await client.connect(transport);
console.log(" ✓ Connected successfully");
// ── List Tools ──────────────────────────────────────────────────────────
section("4. Listing available tools");
const toolsResult = await client.listTools();
console.log(` Found ${toolsResult.tools.length} tool(s):`);
for (const tool of toolsResult.tools) {
console.log(`${tool.name}${tool.description?.slice(0, 80)}`);
}
// ── Call list_tools ─────────────────────────────────────────────────────
section("5. Calling tool: list_tools");
const listToolsResult = await client.callTool({
name: "list_tools",
arguments: {},
});
const listToolsContent = listToolsResult.content as Array<{
type: string;
text: string;
}>;
const listToolsText = listToolsContent
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
// Show first 500 chars
console.log(
` Result (first 500 chars):\n${listToolsText.slice(0, 500)}${listToolsText.length > 500 ? "\n …(truncated)" : ""}`
);
// ── Call read_source_file ───────────────────────────────────────────────
section("6. Calling tool: read_source_file (path: 'main.tsx', lines 1-20)");
const readResult = await client.callTool({
name: "read_source_file",
arguments: { path: "main.tsx", startLine: 1, endLine: 20 },
});
const readContent = readResult.content as Array<{
type: string;
text: string;
}>;
const readText = readContent
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
console.log(` Result:\n${readText.slice(0, 600)}`);
// ── List Resources ──────────────────────────────────────────────────────
section("7. Listing resources");
try {
const resourcesResult = await client.listResources();
console.log(` Found ${resourcesResult.resources.length} resource(s):`);
for (const res of resourcesResult.resources.slice(0, 10)) {
console.log(`${res.name} (${res.uri})`);
}
if (resourcesResult.resources.length > 10) {
console.log(
` …and ${resourcesResult.resources.length - 10} more`
);
}
// Read the first resource
if (resourcesResult.resources.length > 0) {
const firstRes = resourcesResult.resources[0]!;
section(`8. Reading resource: ${firstRes.name}`);
const resContent = await client.readResource({ uri: firstRes.uri });
const resText = resContent.contents
.filter((c): c is { uri: string; text: string; mimeType?: string } => "text" in c)
.map((c) => c.text)
.join("\n");
console.log(
` Content (first 400 chars):\n${resText.slice(0, 400)}${resText.length > 400 ? "\n …(truncated)" : ""}`
);
}
} catch (err) {
console.log(` Resources not supported or error: ${err}`);
}
// ── List Prompts ────────────────────────────────────────────────────────
section("9. Listing prompts");
try {
const promptsResult = await client.listPrompts();
console.log(` Found ${promptsResult.prompts.length} prompt(s):`);
for (const p of promptsResult.prompts) {
console.log(`${p.name}${p.description?.slice(0, 80)}`);
}
} catch (err) {
console.log(` Prompts not supported or error: ${err}`);
}
// ── Cleanup ─────────────────────────────────────────────────────────────
section("Done ✓");
console.log(" All tests passed. Closing connection.");
await client.close();
process.exit(0);
}
main().catch((err) => {
console.error("\n✗ Test failed:", err);
process.exit(1);
});

233
scripts/test-services.ts Normal file
View File

@@ -0,0 +1,233 @@
// scripts/test-services.ts
// Test that all services initialize without crashing
// Usage: bun scripts/test-services.ts
import '../src/shims/preload.js'
// Ensure we don't accidentally talk to real servers
process.env.NODE_ENV = process.env.NODE_ENV || 'test'
type TestResult = { name: string; status: 'pass' | 'fail' | 'skip'; detail?: string }
const results: TestResult[] = []
function pass(name: string, detail?: string) {
results.push({ name, status: 'pass', detail })
console.log(`${name}${detail ? `${detail}` : ''}`)
}
function fail(name: string, detail: string) {
results.push({ name, status: 'fail', detail })
console.log(`${name}${detail}`)
}
function skip(name: string, detail: string) {
results.push({ name, status: 'skip', detail })
console.log(` ⏭️ ${name}${detail}`)
}
async function testGrowthBook() {
console.log('\n--- GrowthBook (Feature Flags) ---')
try {
const gb = await import('../src/services/analytics/growthbook.js')
// Test cached feature value returns default when GrowthBook is unavailable
const boolResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_feature', false)
if (boolResult === false) {
pass('getFeatureValue_CACHED_MAY_BE_STALE (bool)', 'returns default false')
} else {
fail('getFeatureValue_CACHED_MAY_BE_STALE (bool)', `expected false, got ${boolResult}`)
}
const strResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_str', 'default_val')
if (strResult === 'default_val') {
pass('getFeatureValue_CACHED_MAY_BE_STALE (str)', 'returns default string')
} else {
fail('getFeatureValue_CACHED_MAY_BE_STALE (str)', `expected "default_val", got "${strResult}"`)
}
// Test Statsig gate check returns false
const gateResult = gb.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('nonexistent_gate')
if (gateResult === false) {
pass('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', 'returns false for unknown gate')
} else {
fail('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', `expected false, got ${gateResult}`)
}
} catch (err: any) {
fail('GrowthBook import', err.message)
}
}
async function testAnalyticsSink() {
console.log('\n--- Analytics Sink ---')
try {
const analytics = await import('../src/services/analytics/index.js')
// logEvent should queue without crashing when no sink is attached
analytics.logEvent('test_event', { test_key: 1 })
pass('logEvent (no sink)', 'queues without crash')
await analytics.logEventAsync('test_async_event', { test_key: 2 })
pass('logEventAsync (no sink)', 'queues without crash')
} catch (err: any) {
fail('Analytics sink', err.message)
}
}
async function testPolicyLimits() {
console.log('\n--- Policy Limits ---')
try {
const pl = await import('../src/services/policyLimits/index.js')
// isPolicyAllowed should return true (fail open) when no restrictions loaded
const result = pl.isPolicyAllowed('allow_remote_sessions')
if (result === true) {
pass('isPolicyAllowed (no cache)', 'fails open — returns true')
} else {
fail('isPolicyAllowed (no cache)', `expected true (fail open), got ${result}`)
}
// isPolicyLimitsEligible should return false without valid auth
const eligible = pl.isPolicyLimitsEligible()
pass('isPolicyLimitsEligible', `returns ${eligible} (expected false in test env)`)
} catch (err: any) {
fail('Policy limits', err.message)
}
}
async function testRemoteManagedSettings() {
console.log('\n--- Remote Managed Settings ---')
try {
const rms = await import('../src/services/remoteManagedSettings/index.js')
// isEligibleForRemoteManagedSettings should return false without auth
const eligible = rms.isEligibleForRemoteManagedSettings()
pass('isEligibleForRemoteManagedSettings', `returns ${eligible} (expected false in test env)`)
// waitForRemoteManagedSettingsToLoad should resolve immediately if not eligible
await rms.waitForRemoteManagedSettingsToLoad()
pass('waitForRemoteManagedSettingsToLoad', 'resolves immediately when not eligible')
} catch (err: any) {
fail('Remote managed settings', err.message)
}
}
async function testBootstrapData() {
console.log('\n--- Bootstrap Data ---')
try {
const bootstrap = await import('../src/services/api/bootstrap.js')
// fetchBootstrapData should not crash — just skip when no auth
await bootstrap.fetchBootstrapData()
pass('fetchBootstrapData', 'completes without crash (skips when no auth)')
} catch (err: any) {
// fetchBootstrapData catches its own errors, so this means an import-level issue
fail('Bootstrap data', err.message)
}
}
async function testSessionMemoryUtils() {
console.log('\n--- Session Memory ---')
try {
const smUtils = await import('../src/services/SessionMemory/sessionMemoryUtils.js')
// Default config should be sensible
const config = smUtils.DEFAULT_SESSION_MEMORY_CONFIG
if (config.minimumMessageTokensToInit > 0 && config.minimumTokensBetweenUpdate > 0) {
pass('DEFAULT_SESSION_MEMORY_CONFIG', `init=${config.minimumMessageTokensToInit} tokens, update=${config.minimumTokensBetweenUpdate} tokens`)
} else {
fail('DEFAULT_SESSION_MEMORY_CONFIG', 'unexpected config values')
}
// getLastSummarizedMessageId should return undefined initially
const lastId = smUtils.getLastSummarizedMessageId()
if (lastId === undefined) {
pass('getLastSummarizedMessageId', 'returns undefined initially')
} else {
fail('getLastSummarizedMessageId', `expected undefined, got ${lastId}`)
}
} catch (err: any) {
fail('Session memory utils', err.message)
}
}
async function testCostTracker() {
console.log('\n--- Cost Tracking ---')
try {
const ct = await import('../src/cost-tracker.js')
// Total cost should start at 0
const cost = ct.getTotalCost()
if (cost === 0) {
pass('getTotalCost', 'starts at $0.00')
} else {
pass('getTotalCost', `current: $${cost.toFixed(4)} (non-zero means restored session)`)
}
// Duration should be available
const duration = ct.getTotalDuration()
pass('getTotalDuration', `${duration}ms`)
// Token counters should be available
const inputTokens = ct.getTotalInputTokens()
const outputTokens = ct.getTotalOutputTokens()
pass('Token counters', `input=${inputTokens}, output=${outputTokens}`)
// Lines changed
const added = ct.getTotalLinesAdded()
const removed = ct.getTotalLinesRemoved()
pass('Lines changed', `+${added} -${removed}`)
} catch (err: any) {
fail('Cost tracker', err.message)
}
}
async function testInit() {
console.log('\n--- Init (entrypoint) ---')
try {
const { init } = await import('../src/entrypoints/init.js')
await init()
pass('init()', 'completed successfully')
} catch (err: any) {
fail('init()', err.message)
}
}
async function main() {
console.log('=== Services Layer Smoke Test ===')
console.log(`Environment: NODE_ENV=${process.env.NODE_ENV}`)
console.log(`Auth: ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY ? '(set)' : '(not set)'}`)
// Test individual services first (order: least-dependent → most-dependent)
await testAnalyticsSink()
await testGrowthBook()
await testPolicyLimits()
await testRemoteManagedSettings()
await testBootstrapData()
await testSessionMemoryUtils()
await testCostTracker()
// Then test the full init sequence
await testInit()
// Summary
console.log('\n=== Summary ===')
const passed = results.filter(r => r.status === 'pass').length
const failed = results.filter(r => r.status === 'fail').length
const skipped = results.filter(r => r.status === 'skip').length
console.log(` ${passed} passed, ${failed} failed, ${skipped} skipped`)
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter(r => r.status === 'fail')) {
console.log(`${r.name}: ${r.detail}`)
}
process.exit(1)
}
console.log('\n✅ All services handle graceful degradation correctly')
}
main().catch(err => {
console.error('Fatal error in smoke test:', err)
process.exit(1)
})