diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7449c28..ed1e38b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,43 @@ jobs: fi - run: npm test + bundle-smoke: + name: esbuild bundle smoke test + runs-on: ubuntu-latest + needs: test + # esbuild CJS interop issues on Node 22 are tracked in a follow-up PR. + # This job is advisory until that is resolved. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + - run: npm ci + - run: npm run build:prod + - name: Shebang count must be exactly 1 + run: | + COUNT=$(grep -c "#!/usr/bin/env node" dist/index.js) + if [ "$COUNT" -ne 1 ]; then + echo "FAIL: Expected 1 shebang, found $COUNT" + exit 1 + fi + echo "OK: shebang count = $COUNT" + - name: Node.js syntax check + run: node --check dist/index.js + - name: --version smoke test (exits 0, outputs correct semver) + run: | + PKG=$(node -p "require('./package.json').version") + CLI=$(node dist/index.js --version) + echo "package.json=$PKG bundle=$CLI" + if [ "$PKG" != "$CLI" ]; then + echo "FAIL: version mismatch" + exit 1 + fi + - name: Bundle size check + run: npm test -- tests/build/ + offline-smoke: name: Offline size budgets runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index 160bb0d..8705814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.2.0", + "version": "3.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.2.0", + "version": "3.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index b36bf9e..438076d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.2.0", + "version": "3.2.1", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", @@ -46,7 +46,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "prepublishOnly": "npm test && npm run clean && npm run build:prod" + "prepublishOnly": "npm test && npm run clean && npm run build && node dist/index.js --version" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 0b2b975..bf3a7ec 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -10,13 +10,15 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); +const outfile = process.env.BUNDLE_OUTFILE ?? path.join(root, 'dist/index.js'); + await build({ entryPoints: [path.join(root, 'src/index.ts')], bundle: true, platform: 'node', target: 'node18', format: 'esm', - outfile: path.join(root, 'dist/index.js'), + outfile, // Keep heavy native-binding or large deps external; they stay in node_modules. external: [ 'node:*', @@ -29,8 +31,20 @@ await build({ '@modelcontextprotocol/sdk', // pure-JS but large — inline separately if needed ], + // Inject a createRequire-based require() so CJS packages bundled into the + // ESM output can call require('process'), require('events'), etc. (bare names + // without node: prefix) without hitting esbuild's __require2 "not supported" error. + inject: [path.join(root, 'scripts/cjs-shim.mjs')], banner: { - js: '#!/usr/bin/env node', + // The shebang must come first (Node.js requires it at byte 0). + // The `const require` line runs BEFORE esbuild's __require IIFE (which checks + // `typeof require !== "undefined"`), so CJS packages that call bare + // require('process') or require('node:events') get the real Node require(). + js: [ + '#!/usr/bin/env node', + 'import { createRequire as __cjsReq } from "node:module";', + 'const require = __cjsReq(import.meta.url);', + ].join('\n'), }, logLevel: 'info', }); diff --git a/scripts/cjs-shim.mjs b/scripts/cjs-shim.mjs new file mode 100644 index 0000000..844da6b --- /dev/null +++ b/scripts/cjs-shim.mjs @@ -0,0 +1,6 @@ +// Inject a proper require() implementation for CJS packages bundled into the +// ESM output. Without this, esbuild's __require2 shim throws +// "Dynamic require of X is not supported" when CJS packages call +// require('process'), require('events'), etc. (bare names, no node: prefix). +import { createRequire } from 'node:module'; +export const require = createRequire(import.meta.url); diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts index ceda31f..e3ec25d 100644 --- a/src/commands/agent-bootstrap.ts +++ b/src/commands/agent-bootstrap.ts @@ -18,10 +18,7 @@ import { } from '../policy/load.js'; import { validateLoadedPolicy } from '../policy/validate.js'; import { selectCredentialStore, CredentialBackendName } from '../credentials/keychain.js'; -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const { version: pkgVersion } = require('../../package.json') as { version: string }; +import { VERSION as pkgVersion } from '../version.js'; /** * Schema version of the agent-bootstrap payload. Must stay in lockstep diff --git a/src/commands/upgrade-check.ts b/src/commands/upgrade-check.ts index 7871f81..a39e77c 100644 --- a/src/commands/upgrade-check.ts +++ b/src/commands/upgrade-check.ts @@ -1,11 +1,10 @@ import { Command } from 'commander'; -import { createRequire } from 'node:module'; import https from 'node:https'; import { isJsonMode, printJson } from '../utils/output.js'; import chalk from 'chalk'; +import { VERSION as currentVersion } from '../version.js'; -const require = createRequire(import.meta.url); -const { name: pkgName, version: currentVersion } = require('../../package.json') as { name: string; version: string }; +const pkgName = '@switchbot/openapi-cli'; function fetchLatestVersion(packageName: string, timeoutMs = 8000): Promise { const encoded = packageName.replace('/', '%2F'); diff --git a/src/index.ts b/src/index.ts index 25763c2..1759a7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { createRequire } from 'node:module'; import chalk from 'chalk'; diff --git a/tests/build/bundle-size.test.ts b/tests/build/bundle-size.test.ts index 816432d..050a6a5 100644 --- a/tests/build/bundle-size.test.ts +++ b/tests/build/bundle-size.test.ts @@ -1,29 +1,52 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync, execSync } from 'node:child_process'; -describe('production bundle size', () => { - const distEntry = path.resolve('dist/index.js'); +// Build to a separate path so we don't overwrite the tsc dist/index.js +// that other tests (install smoke, status-sync smoke) depend on. +// Must stay in dist/ (not a subdirectory) so require('../package.json') resolves correctly. +const bundleEntry = path.resolve('dist/bundle-test.js'); - // tsc output has relative imports like `from './utils/...'`; esbuild inlines everything. - function isBundledOutput(): boolean { - if (!fs.existsSync(distEntry)) return false; - const head = fs.readFileSync(distEntry, 'utf-8').slice(0, 4096); - return !head.includes("from './"); - } +describe('esbuild production bundle', () => { + beforeAll(() => { + execSync(`node scripts/bundle.mjs --outfile=${bundleEntry}`, { + stdio: 'pipe', + env: { ...process.env, BUNDLE_OUTFILE: bundleEntry }, + }); + }, 30_000); - it('dist/index.js exists', () => { - expect(fs.existsSync(distEntry)).toBe(true); + it('bundle output exists', () => { + expect(fs.existsSync(bundleEntry), `${bundleEntry} not found after build:prod`).toBe(true); }); - it('esbuild bundle is under 15 MB (skipped when tsc output is present)', () => { - if (!isBundledOutput()) { - // CI runs `npm run build` (tsc), not `npm run build:prod` (esbuild). - // Skip size guard when the single-file esbuild bundle has not been built. - return; - } - const { size } = fs.statSync(distEntry); + it('has exactly one shebang line', () => { + const content = fs.readFileSync(bundleEntry, 'utf-8'); + const count = (content.match(/^#!\/usr\/bin\/env node/gm) ?? []).length; + expect(count, `Expected exactly 1 shebang, found ${count} — check bundle.mjs banner vs src/index.ts`).toBe(1); + }); + + it('passes Node.js syntax check', () => { + const result = spawnSync(process.execPath, ['--check', bundleEntry], { encoding: 'utf-8' }); + expect(result.status, `node --check failed (exit ${result.status}):\n${result.stderr}`).toBe(0); + expect(result.stderr).toBe(''); + }); + + it('--version exits 0 and outputs a valid semver', () => { + const result = spawnSync(process.execPath, [bundleEntry, '--version'], { encoding: 'utf-8' }); + expect(result.status, `--version exited ${result.status}:\n${result.stderr}`).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('--version matches package.json version', () => { + const pkgVersion = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf-8')).version as string; + const result = spawnSync(process.execPath, [bundleEntry, '--version'], { encoding: 'utf-8' }); + expect(result.stdout.trim(), `Bundle reports ${result.stdout.trim()} but package.json says ${pkgVersion}`).toBe(pkgVersion); + }); + + it('is under 15 MB', () => { + const { size } = fs.statSync(bundleEntry); const sizeMb = size / (1024 * 1024); - expect(sizeMb, `dist/index.js is ${sizeMb.toFixed(1)} MB — exceeds 15 MB budget`).toBeLessThan(15); + expect(sizeMb, `bundle is ${sizeMb.toFixed(1)} MB — exceeds 15 MB budget`).toBeLessThan(15); }); });