diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c595498 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: + push: + branches: [main] + pull_request: +jobs: + check: + name: Type-check & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run type-check + - run: pnpm test diff --git a/.gitignore b/.gitignore index 695fd23..b987586 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage/ .DS_Store *.md !README.md +!CONTRIBUTING.md # Local-only agent context files (never commit — local knowledge base) CLAUDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fca9c96 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +Thanks for contributing to the Bright Data CLI. This is a TypeScript project that +compiles to a Node CLI (`brightdata` / `bdata`). + +## Prerequisites + +- **Node.js 20+** (CI builds on Node 24 — matching the current LTS avoids surprises). +- **pnpm** — pinned via the `packageManager` field. Let Corepack provide it: + ```bash + corepack enable + ``` + +## Setup + +```bash +pnpm install +pnpm run build # compile src/ → dist/ +node dist/index.js --help # run your build (or: pnpm start) +``` + +## Common commands + +| Command | What it does | +|---|---| +| `pnpm run build` | Compile TypeScript to `dist/` | +| `pnpm run dev` | Compile in watch mode | +| `pnpm run type-check` | Type-check only, no emit (`tsc --noEmit`) | +| `pnpm test` | Run the test suite once (Vitest) | +| `pnpm run test:watch` | Run tests in watch mode | +| `pnpm start` | Run the built CLI (`node dist/index.js`) | +| `pnpm run clean` | Remove `dist/` | + +## Project layout + +``` +src/ + index.ts # entry point / bin (wires up all commands) + commands/ # one file per CLI command (scrape, search, browser, …) + browser/ # local browser-daemon: lifecycle, ipc, connection, interaction + utils/ # shared helpers (client, config, auth, output, polling, …) + types/ # shared type definitions + __tests__/ # Vitest tests, mirroring the src/ layout +install.sh # curl | sh installer +``` + +## Testing + +- Tests live in `src/__tests__/**/*.test.ts`, mirroring the source tree, and run on + **Vitest** (`pnpm test`). Add a test alongside any behavior change. +- **CI does not run the suite** — `release.yml` only builds and publishes on release + tags. Please run `pnpm run type-check` **and** `pnpm test` locally before opening a + PR; that's the only safety net. +- A few browser/daemon tests depend on a real browser environment and may not pass on + every machine — note in your PR if a failure is pre-existing/environmental rather + than caused by your change. + +## Code style + +Match the surrounding file. The house style is: + +- **`snake_case`** for functions and variables (`handle_scrape`, `ensure_authenticated`). +- **Allman braces** — opening brace on its own line for blocks: + ```ts + if (!zone) + { + fail('...'); + return; + } + ``` +- 4-space indentation, single quotes, arrow functions assigned to `const`, and + **named exports** grouped at the bottom of the file. +- Keep diffs minimal and consistent with the file you're editing. + +## Commits & pull requests + +- Use **Conventional Commits**: `feat(scraper): …`, `fix(browser): …`, + `docs(readme): …`, `refactor: …`, `chore: …`. +- Branch off `main` and open your PR against `main`. +- Before pushing: `pnpm run type-check && pnpm test && pnpm run build` should all pass. +- Keep PRs focused; describe what changed and how you verified it. + +## Releases + +Maintainers cut releases by bumping the version and pushing a `v*` tag, which triggers +the `release.yml` workflow to build and `npm publish`. diff --git a/package.json b/package.json index 6970b0f..91e0911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brightdata/cli", - "version": "0.3.1", + "version": "0.3.2", "description": "Command-line interface for Bright Data. Scrape, search, extract structured data, and automate browsers directly from your terminal.", "main": "dist/index.js", "bin": { 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, +};