Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions scripts/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:*',
Expand All @@ -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',
});
6 changes: 6 additions & 0 deletions scripts/cjs-shim.mjs
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 1 addition & 4 deletions src/commands/agent-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/commands/upgrade-check.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const encoded = packageName.replace('/', '%2F');
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env node
import { Command, CommanderError, InvalidArgumentError } from 'commander';
import { createRequire } from 'node:module';
import chalk from 'chalk';
Expand Down
61 changes: 42 additions & 19 deletions tests/build/bundle-size.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading