diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..981859d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI +on: + push: + branches: [main] + pull_request: +jobs: + check: + name: Type-check & test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 20.17.0 is pinned deliberately: it predates the require(ESM) backport + # (Node 20.19 / 22.12), so it is the oldest runtime our declared + # `engines: >=20.0.0` still promises to support. Testing only the latest + # 20.x would silently pass code that crashes for those users. + node: ['20.17.0', '24'] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run type-check + - run: pnpm test + # Unit tests cannot execute the dynamic-import loader (Vitest rejects the + # `new Function('return import()')` form), so the built CLI is smoke-run + # here — this is what actually catches an ERR_REQUIRE_ESM regression. + - run: pnpm run build + - run: node dist/index.js --version + - run: node dist/index.js pipelines list diff --git a/src/__tests__/commands/add-mcp.test.ts b/src/__tests__/commands/add-mcp.test.ts index 78b42a0..03153b5 100644 --- a/src/__tests__/commands/add-mcp.test.ts +++ b/src/__tests__/commands/add-mcp.test.ts @@ -14,10 +14,12 @@ const mocks = vi.hoisted(()=>({ warn: vi.fn(), })); -vi.mock('@inquirer/prompts', ()=>({ - checkbox: mocks.checkbox, - select: mocks.select, - confirm: mocks.confirm, +vi.mock('../../utils/load-prompts', ()=>({ + load_prompts: vi.fn(async()=>({ + checkbox: mocks.checkbox, + select: mocks.select, + confirm: mocks.confirm, + })), })); vi.mock('../../utils/credentials', ()=>({ diff --git a/src/__tests__/utils/node-version.test.ts b/src/__tests__/utils/node-version.test.ts new file mode 100644 index 0000000..b30f66c --- /dev/null +++ b/src/__tests__/utils/node-version.test.ts @@ -0,0 +1,41 @@ +import {describe, it, expect, vi} from 'vitest'; +import { + parse_major, + is_supported_node, + unsupported_message, + assert_supported_node, +} from '../../utils/node-version'; + +describe('utils/node-version (floor 20)', ()=>{ + it('parses the major version', ()=>{ + expect(parse_major('20.17.0')).toBe(20); + expect(parse_major('24.16.0')).toBe(24); + expect(parse_major('garbage')).toBe(0); + }); + + it('accepts >= 20, rejects < 20', ()=>{ + expect(is_supported_node('20.0.0')).toBe(true); + expect(is_supported_node('22.12.0')).toBe(true); + expect(is_supported_node('24.16.0')).toBe(true); + expect(is_supported_node('18.19.0')).toBe(false); + }); + + it('names the detected version in the message', ()=>{ + expect(unsupported_message('18.19.0')).toContain('v18.19.0'); + expect(unsupported_message('18.19.0')).toContain('Node 20 or newer'); + }); + + it('writes + exits 1 on unsupported, no-ops on supported', ()=>{ + const write = vi.fn(); + const exit = vi.fn(); + assert_supported_node('18.19.0', write, exit as never); + expect(write).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(1); + + write.mockClear(); + exit.mockClear(); + assert_supported_node('20.0.0', write, exit as never); + expect(write).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/add-mcp.ts b/src/commands/add-mcp.ts index ee6733b..858f4e3 100644 --- a/src/commands/add-mcp.ts +++ b/src/commands/add-mcp.ts @@ -1,5 +1,5 @@ -import {checkbox, confirm, select} from '@inquirer/prompts'; import {Command} from 'commander'; +import {load_prompts} from '../utils/load-prompts'; import {resolve_key} from '../utils/auth'; import {dim, green, red, warn} from '../utils/output'; import { @@ -112,6 +112,7 @@ const resolve_selected_agents = async( return null; } + const {checkbox} = await load_prompts(); return await checkbox({ message: 'Which coding agents should Bright Data MCP be added to?', choices: mcp_agents.map(agent=>({ @@ -177,6 +178,7 @@ const resolve_scope = async( return null; } + const {select} = await load_prompts(); return await select({ message: 'Install globally or for this project?', choices: [ @@ -229,6 +231,7 @@ const write_agent_with_recovery = async( ); } + const {confirm} = await load_prompts(); const overwrite = await confirm({ message: 'Overwrite invalid config at '+error.file_path+'?', default: false, diff --git a/src/commands/init.ts b/src/commands/init.ts index ad77a55..6e20698 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import {confirm, input, password, select} from '@inquirer/prompts'; +import {load_prompts} from '../utils/load-prompts'; import {validate_key, mask_key, resolve_key} from '../utils/auth'; import {get_api_key, save as save_credentials} from '../utils/credentials'; import {resolve, get as get_config, set as set_config} from '../utils/config'; @@ -97,6 +97,7 @@ const prompt_zone = async( ): Promise=>{ if (!is_tty) return suggested; + const {input, select} = await load_prompts(); if (!zone_names.length) { const typed = (await input({ @@ -131,6 +132,7 @@ const prompt_default_format = async(current: string|undefined): Promise=>{ if (!is_tty) return current ?? 'markdown'; + const {select} = await load_prompts(); const selected = await select({ message: 'Choose default output format', choices: [ @@ -163,6 +165,7 @@ const prompt_api_key = async( ): Promise=>{ if (!is_tty) return initial; + const {confirm, password} = await load_prompts(); if (initial) { const reuse = await confirm({ @@ -238,6 +241,7 @@ const show_quick_start = ( const maybe_show_install_hint = async()=>{ if (!is_tty) return; + const {confirm} = await load_prompts(); const show = await confirm({ message: 'Show global install command?', default: false, @@ -289,6 +293,7 @@ const handle_init = async(opts: Init_opts)=>{ serp_zone = pick_best_zone(zone_names, serp_zone ?? unlocker_zone); if (is_tty) { + const {confirm} = await load_prompts(); unlocker_zone = await prompt_zone( 'Select default Web Unlocker zone', zone_names, diff --git a/src/index.ts b/src/index.ts index 2136762..7493e74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import {Command} from 'commander'; +import {assert_supported_node} from './utils/node-version'; import {maybe_run_browser_daemon} from './browser/entrypoint'; import {login_command} from './commands/login'; import {logout_command} from './commands/logout'; @@ -61,6 +62,7 @@ const build_program = ()=>{ }; const main = async()=>{ + assert_supported_node(); if (await maybe_run_browser_daemon()) return; build_program().parse(process.argv); diff --git a/src/utils/load-prompts.ts b/src/utils/load-prompts.ts new file mode 100644 index 0000000..d420687 --- /dev/null +++ b/src/utils/load-prompts.ts @@ -0,0 +1,24 @@ +// Full module type, erased at compile time (no runtime require emitted). +type Prompts_module = typeof import('@inquirer/prompts'); + +let prompts_promise: Promise|undefined; + +// @inquirer/prompts is ESM-only. Under tsconfig `module: commonjs`, a literal +// import('@inquirer/prompts') is down-compiled by tsc back into require(), which +// throws ERR_REQUIRE_ESM on Node < 22.12 / < 20.19. Wrapping import() in +// new Function() hides it from the compiler so it is emitted as a genuine native +// dynamic import. Same technique as load_open() in utils/browser_auth.ts. +const load_prompts = (): Promise=>{ + if (!prompts_promise) + { + const dynamic_import = new Function( + 'specifier', + 'return import(specifier);' + ) as (specifier: string)=>Promise; + prompts_promise = dynamic_import('@inquirer/prompts'); + } + return prompts_promise; +}; + +export {load_prompts}; +export type {Prompts_module}; diff --git a/src/utils/node-version.ts b/src/utils/node-version.ts new file mode 100644 index 0000000..f00f9a8 --- /dev/null +++ b/src/utils/node-version.ts @@ -0,0 +1,37 @@ +// Floor = genuine dependency minimum (driven by deps' own `engines`, e.g. +// commander), NOT the require(ESM) boundary. The dynamic-import loader in +// utils/load-prompts.ts removes the ERR_REQUIRE_ESM crash at its source, so the +// CLI runs on Node 20 again; this guard only catches runtimes below that floor. +const MIN_NODE_MAJOR = 20; + +const parse_major = (version: string): number=>{ + const major = Number(version.split('.')[0]); + return Number.isFinite(major) ? major : 0; +}; + +const is_supported_node = (version = process.versions.node): boolean=> + parse_major(version) >= MIN_NODE_MAJOR; + +const unsupported_message = (version = process.versions.node): string=> + `✗ Unsupported Node.js version: you are running Node v${version}.\n` + +` @brightdata/cli requires Node ${MIN_NODE_MAJOR} or newer.\n` + +` Please update Node and try again: https://nodejs.org\n`; + +const assert_supported_node = ( + version = process.versions.node, + write: (s: string)=>void = s=>{ process.stderr.write(s); }, + exit: (code: number)=>never = code=>process.exit(code), +): void=>{ + if (is_supported_node(version)) + return; + write(unsupported_message(version)); + exit(1); +}; + +export { + MIN_NODE_MAJOR, + parse_major, + is_supported_node, + unsupported_message, + assert_supported_node, +};