diff --git a/.claude/skills/nodejs-cli-best-practices/ATTRIBUTION.md b/.claude/skills/nodejs-cli-best-practices/ATTRIBUTION.md new file mode 100644 index 00000000..424d14b3 --- /dev/null +++ b/.claude/skills/nodejs-cli-best-practices/ATTRIBUTION.md @@ -0,0 +1,11 @@ +# Attribution + +This skill is vendored verbatim from +[lirantal/nodejs-cli-apps-best-practices](https://github.com/lirantal/nodejs-cli-apps-best-practices) +(`skills/nodejs-cli-best-practices/`), created by Liran Tal and contributors. + +The upstream content is licensed under +[CC BY-SA 4.0](https://github.com/lirantal/nodejs-cli-apps-best-practices/blob/main/LICENSE), +which also applies to the files in this directory (`SKILL.md`, +`references/best-practices.md`). To update, re-copy the files from the +upstream repository. diff --git a/.claude/skills/nodejs-cli-best-practices/SKILL.md b/.claude/skills/nodejs-cli-best-practices/SKILL.md new file mode 100644 index 00000000..ae5c3e01 --- /dev/null +++ b/.claude/skills/nodejs-cli-best-practices/SKILL.md @@ -0,0 +1,177 @@ +--- +name: nodejs-cli-best-practices +description: Guide and audit Node.js CLI application development against 37 established best practices covering UX, distribution, interoperability, accessibility, testing, error handling, development setup, analytics, versioning, and security. Use this skill when building, extending, reviewing, or scaffolding a Node.js CLI — including when someone says "audit my CLI", "review my CLI code", "I'm building a CLI tool", or asks about adding argument parsing, error handling, color output, STDIN, --json flags, exit codes, --version flags, or npm publishing. Applies even when best practices are not explicitly mentioned. Also trigger for "how should I implement X in my CLI" or "what's the right way to do Y in a Node.js CLI". Do NOT use for Node.js backend or API development with no CLI entry point. +metadata: + version: 1.0.0 + category: nodejs + tags: [nodejs, cli, best-practices, audit, command-line] +--- + +This skill operates in two modes — **audit** for reviewing existing CLI code and **development guide** for building new features or tools. + +## Critical + +Always read `references/best-practices.md` before producing any output. It contains the complete reference for all 37 practices with code examples and recommended packages. It is the source of truth for both modes — do not rely on general knowledge alone, as several practices (specific package recommendations, §X.X numbering, configuration precedence order) are specific to this guide. + +## Determining the mode + +| Trigger | Mode | Action | +|---------|------|--------| +| User provides existing CLI code or files to review | **Audit** | Produce a structured audit report | +| User asks how to build or implement something | **Development guide** | Surface relevant practices with examples | +| User says "review and help me improve" | **Both** | Audit first, then provide concrete guidance for each failing practice | + +--- + +## Audit mode + +Systematically compare the provided code against the practices in `references/best-practices.md`. Focus on what is verifiable from the code. Use judgment to mark practices as not applicable (➖) when there's genuinely no way to assess them from static analysis (e.g., Docker support when the project shows no distribution intent). + +**Prioritize by user impact** — a missing exit code (§6.4) or absent `--version` flag (§9.1) affects every user and every CI pipeline; missing Docker image (§4.1) is low priority for most projects. + +### Audit report format + +``` +## Node.js CLI Best Practices Audit + +### Summary +✅ N practices followed ⚠️ N need attention ❌ N not implemented ➖ N not applicable + +--- + +### 1. Command Line Experience +| # | Practice | Status | Finding | +|---|----------|--------|---------| +| 1.1 | Respect POSIX args | ✅ | Uses yargs with proper short/long aliases | +| 1.2 | Build empathic CLIs | ❌ | No interactive fallback when required args absent | +... + +[Repeat for each section with applicable practices] + +--- + +### Priority recommendations + +**High priority** (user-facing or CI-breaking): +- **§6.4 Exit codes** — `process.exit()` called without a code + ```js + // Fix + process.exit(1); // on error + process.exit(0); // on success + ``` + +**Medium priority**: +- ... + +**Low priority / nice to have**: +- ... +``` + +Keep findings concise and tied to specific code patterns. Always include a concrete fix with code for each ❌. + +--- + +## Development guide mode + +When building a new CLI feature or tool, don't wait to be asked — surface relevant best practices immediately based on what the user is building. + +**For a "building a CLI from scratch" request**, organize guidance by development phase: + +1. **Project setup** (§7.1, §7.3, §4.4, §2.2): bin object, shebang, files field, shrinkwrap +2. **Argument design** (§1.1, §1.2, §1.7, §3.4): POSIX compliance, empathic fallbacks, zero-config, config precedence +3. **I/O and interoperability** (§3.1, §3.2, §4.2): STDIN, structured output, graceful degradation +4. **Error handling** (§6.1–§6.5): trackable codes, actionable messages, debug mode, exit codes +5. **UX polish** (§1.4, §1.5, §1.6): colors, rich interactions, hyperlinks +6. **Versioning** (§9.1–§9.7): `--version` flag, semver, changelog +7. **Security** (§10.1): argument injection + +**For a targeted feature request** (e.g., "add error handling", "add a --json flag"), surface only the directly relevant practices. + +### Development guidance format + +``` +## Node.js CLI best practices for [feature/topic] + +### Checklist +- [ ] §X.Y Practice name — one-line explanation of why it matters + +### Implementation +[Concrete, copy-pasteable code] + +### Recommended packages +- `package-name` — when and why to use it + npm install package-name + +### Common mistakes +- What not to do → why it breaks → what to do instead +``` + +--- + +## Quick reference: which section applies + +| Topic | Sections | +|-------|----------| +| Argument parsing / flags | §1.1, §1.7, §3.4 | +| Prompts / interactivity | §1.2, §1.5 | +| Colors / styling | §1.4, §4.2 | +| STDIN / piping | §3.1 | +| JSON / structured output | §3.2, §4.2 | +| Cross-platform issues | §3.3, §7.2 | +| Error messages | §6.1, §6.2 | +| Exit codes | §6.4 | +| Debug / verbose mode | §6.3 | +| `--version` flag | §9.1, §9.3 | +| package.json setup | §7.1, §7.3, §9.3 | +| npm publishing / distribution | §2.1, §2.2, §9.6 | +| Security / user input | §10.1 | +| Configuration persistence | §1.3, §2.3 | +| Node.js version targeting | §4.3 | +| Analytics / telemetry | §8.1 | + +--- + +## Examples + +### Example 1: Audit mode + +**User says:** "Audit my Node.js CLI against best practices. Here's my entry file and package.json." + +**Actions:** +1. Read `references/best-practices.md` +2. Examine the provided files against each applicable practice +3. Produce the structured audit report with per-section table and priority recommendations + +--- + +### Example 2: Development guide mode (new CLI from scratch) + +**User says:** "I'm building a Node.js CLI that parses log files and outputs a summary table. It needs to work cross-platform and I want to publish it on npm." + +**Actions:** +1. Read `references/best-practices.md` +2. Identify all practices relevant to this CLI type (cross-platform, npm-published, table output) +3. Organize guidance by development phase (project setup → argument design → I/O → errors → UX → versioning → security) + +--- + +### Example 3: Development guide mode (targeted feature) + +**User says:** "My CLI is crashing and just dumping stack traces. How do I add proper error handling?" + +**Actions:** +1. Read `references/best-practices.md` sections §6.1–§6.5 +2. Surface only the directly relevant practices +3. Provide a concrete, copy-pasteable error handling wrapper + +--- + +## Common mistakes to avoid + +| Mistake | Why it matters | Correct approach | +|---------|---------------|------------------| +| Marking §4.1 (Docker) as ❌ for a small personal CLI | Docker is rarely needed; not applicable is honest | Use ➖ when the practice genuinely doesn't fit the project's scope | +| Only auditing the entry file, ignoring package.json | Several high-impact violations (§7.1, §7.3, §2.1, §4.3) live in package.json | Always audit both the entry file and package.json when both are provided | +| Recommending `package-lock.json` for §2.2 | The practice specifically calls for `npm-shrinkwrap.json` for published CLIs | Reference `references/best-practices.md` for the exact requirement | +| Skipping the priority grouping in audit reports | Users need to know what to fix first, not just what's wrong | Always include High / Medium / Low priority sections with concrete fixes | +| Providing a development guide without code examples | Prose guidance alone is not actionable | Every recommended practice should include at least one copy-pasteable code block | diff --git a/.claude/skills/nodejs-cli-best-practices/references/best-practices.md b/.claude/skills/nodejs-cli-best-practices/references/best-practices.md new file mode 100644 index 00000000..00012de5 --- /dev/null +++ b/.claude/skills/nodejs-cli-best-practices/references/best-practices.md @@ -0,0 +1,563 @@ +# Node.js CLI Best Practices — Reference + +All 37 practices condensed for use during audits and development guidance. + +--- + +## 1. Command Line Experience + +### §1.1 Respect POSIX args +**Rule:** Use POSIX-compliant argument syntax. +- Long flags: `--flag`, short aliases: `-f` +- Optional args in `[brackets]`, required in `` +- Multiple short flags can be grouped: `-abc` = `-a -b -c` + +**Violation pattern:** Custom positional syntax like `cmd ACTION key=value` instead of `cmd --action --key value` + +**Packages:** `commander`, `yargs`, `meow` + +--- + +### §1.2 Build empathic CLIs +**Rule:** When the user omits required input, don't just error — prompt them interactively to recover. + +```js +// Instead of: throw new Error('API key required') +// Do: prompt when input is missing +const { apiKey } = await inquirer.prompt([{ + type: 'password', + name: 'apiKey', + message: 'Enter your API key:', + when: !options.apiKey +}]); +``` + +**Packages:** `enquirer`, `inquirer`, `prompts` + +--- + +### §1.3 Stateful data +**Rule:** Persist user preferences between invocations. Follow XDG Base Directory Specification for storage paths. + +```js +import Conf from 'conf'; +const config = new Conf({ projectName: 'my-cli' }); +config.set('apiKey', key); +config.get('apiKey'); +``` + +**Packages:** `conf`, `configstore` + +--- + +### §1.4 Provide a colorful experience +**Rule:** Use colors to improve readability, but always support opt-out via `NO_COLOR` env var, `--no-color` flag, or auto-detection of non-TTY environments. + +```js +import chalk from 'chalk'; +// chalk automatically respects NO_COLOR and non-TTY +console.log(chalk.green('Success')); + +// Manual check if needed +if (process.stdout.isTTY && !process.env.NO_COLOR) { + // apply color +} +``` + +**Violation pattern:** Hardcoded ANSI escape codes with no opt-out mechanism. + +**Packages:** `chalk`, `kleur`, `picocolors` + +--- + +### §1.5 Rich interactions +**Rule:** Use interactive prompts (dropdowns, checkboxes, autocomplete) and animated loaders/progress bars for async operations. Don't force users to provide what the app can detect. + +```js +import ora from 'ora'; +const spinner = ora('Fetching data...').start(); +await fetchData(); +spinner.succeed('Done'); +``` + +**Packages:** `enquirer`, `ora`, `ink`, `prompts`, `listr2` + +--- + +### §1.6 Hyperlinks everywhere +**Rule:** Output properly formatted hyperlinks for URLs and file paths so modern terminals can make them clickable. + +```js +// Clickable URL in terminal +console.log('\u001B]8;;https://example.com\u0007Click here\u001B]8;;\u0007'); + +// Or use a package +import terminalLink from 'terminal-link'; +console.log(terminalLink('Click here', 'https://example.com')); +``` + +**Packages:** `terminal-link` + +--- + +### §1.7 Zero configuration +**Rule:** Auto-detect required values from environment (env vars, config files, git context) and only prompt when necessary. Follow POSIX environment variable conventions (`NO_COLOR`, `DEBUG`, `HTTP_PROXY`, etc.). + +**Packages:** `cosmiconfig` (auto-discover config files) + +--- + +### §1.8 Respect POSIX signals +**Rule:** Handle `SIGINT`, `SIGTERM`, `SIGHUP` gracefully — clean up resources and exit properly. + +```js +process.on('SIGINT', () => { + cleanup(); + process.exit(0); +}); +``` + +**Violation pattern:** App freezes or leaves orphaned processes when user presses Ctrl+C. + +--- + +## 2. Distribution + +### §2.1 Prefer a small dependency footprint +**Rule:** Minimize production dependencies. Avoid bloated packages (e.g., `moment`, `lodash`, `request`). Prefer modern lightweight alternatives. + +| Avoid | Use instead | +|-------|------------| +| `moment` | `date-fns`, native `Intl` | +| `lodash` | native array/object methods | +| `request` | native `fetch`, `got`, `undici` | +| `colors` | `chalk`, `kleur`, `picocolors` | + +**Tool:** [bundlephobia.com](https://bundlephobia.com) to check package cost. + +--- + +### §2.2 Use the shrinkwrap +**Rule:** Commit `npm-shrinkwrap.json` (not just `package-lock.json`) to pin transitive dependency versions for end users. + +```sh +npm shrinkwrap +``` + +Alternatively, bundle all dependencies into a single file using `@vercel/ncc`: +```sh +npx ncc build src/index.js -o dist +``` + +**Packages:** `@vercel/ncc` + +--- + +### §2.3 Cleanup configuration files +**Rule:** Provide an `--uninstall` flag or similar option to remove configuration files created by the CLI. Don't leave orphaned data in the user's filesystem. + +--- + +## 3. Interoperability + +### §3.1 Accept input as STDIN +**Rule:** Support piping data into your CLI via STDIN, enabling Unix one-liners. + +```js +// Check if stdin has data (piped input) +if (!process.stdin.isTTY) { + const rl = require('readline').createInterface({ input: process.stdin }); + rl.on('line', (line) => processLine(line)); +} else { + // use --file argument or prompt +} +``` + +**Violation pattern:** Only accepting input via file path argument, blocking `cat file.json | mycli`. + +--- + +### §3.2 Enable structured output +**Rule:** Provide a `--json` flag (or similar) that outputs machine-readable JSON instead of human-readable formatted text. Essential for CI pipelines and scripting. + +```js +if (options.json) { + console.log(JSON.stringify(result)); +} else { + console.log(formatTable(result)); +} +``` + +--- + +### §3.3 Cross-platform etiquette +**Rule:** Write code that works on Windows, macOS, and Linux. + +**Common violations:** + +```js +// ❌ Shebang doesn't work on Windows in npm scripts +"postinstall": "setup.js" +// ✅ Always prefix with node +"postinstall": "node setup.js" + +// ❌ String path concatenation +const p = __dirname + '/../bin/cli.js'; +// ✅ Use path.join +const p = path.join(__dirname, '..', 'bin', 'cli.js'); + +// ❌ Semicolons to chain commands (fails on Windows cmd) +childProcess.exec(`${cmd1}; ${cmd2}`); +// ✅ Use && or || +childProcess.exec(`${cmd1} && ${cmd2}`); + +// ❌ Single quotes in npm scripts (fail on Windows) +"format": "prettier '**/*.js'" +// ✅ Double quotes, escaped in JSON +"format": "prettier \"**/*.js\"" + +// ❌ Spawn script directly (shebang ignored on Windows) +childProcess.spawn('program.js', []) +// ✅ Spawn via node +childProcess.spawn('node', ['program.js']) +``` + +--- + +### §3.4 Support configuration precedence +**Rule:** Respect this config priority order (highest to lowest): +1. CLI arguments +2. Environment variables +3. Project-level config (`.myapprc`, `.git/config`) +4. User-level config (`~/.config/myapp`) +5. System-level config (`/etc/myapp`) + +**Packages:** `cosmiconfig` (handles config file discovery automatically) + +--- + +## 4. Accessibility + +### §4.1 Containerize the CLI +**Rule:** Publish a Docker image for users without a Node.js environment. + +```dockerfile +FROM node:lts-alpine +RUN npm install -g my-cli +ENTRYPOINT ["my-cli"] +``` + +--- + +### §4.2 Graceful degradation +**Rule:** Auto-detect terminal capabilities and degrade gracefully in unsupported environments (CI, pipes, old terminals). The `--json` flag (§3.2) doubles as a graceful degradation mechanism. + +```js +// Auto-detect: disable color and interactivity when not in a TTY +const isInteractive = process.stdout.isTTY; +const supportsColor = isInteractive && !process.env.NO_COLOR; +``` + +--- + +### §4.3 Node.js versions compatibility +**Rule:** Target only current LTS and active Node.js versions. Declare the requirement in `package.json`. + +```json +{ + "engines": { + "node": ">=18.0.0" + } +} +``` + +If the CLI is invoked in an unsupported environment, detect and exit with a clear message: +```js +if (parseInt(process.versions.node) < 18) { + console.error('my-cli requires Node.js 18 or higher'); + process.exit(1); +} +``` + +--- + +### §4.4 Shebang autodetect the Node.js runtime +**Rule:** Use `#!/usr/bin/env node` — never hardcode the Node.js path. + +```js +#!/usr/bin/env node +// ✅ Works everywhere +``` + +```js +#!/usr/local/bin/node +// ❌ Breaks if node is installed elsewhere +``` + +--- + +## 5. Testing + +### §5.1 Put no trust in locales +**Rule:** Don't assert on user-visible strings that may be translated. Test behavior, not text, or lock the locale in tests. + +```js +// ❌ Fails on non-English systems +expect(output).to.contain('Examples:'); + +// ✅ Lock locale or test non-text behavior +const output = execSync('LC_ALL=en_US.UTF-8 mycli --help'); +``` + +--- + +## 6. Errors + +### §6.1 Trackable errors +**Rule:** Every error message must include a unique, documented error code so users can look it up. + +```js +// ❌ Generic +console.error('Authentication failed'); + +// ✅ Trackable +console.error('Error (E4002): Authentication failed — provide API key via MY_APP_API_KEY env var'); +``` + +--- + +### §6.2 Actionable errors +**Rule:** Error messages must tell the user exactly what to do, not just what went wrong. + +```js +// ❌ Not actionable +console.error('Error: no config file found'); + +// ✅ Actionable +console.error('Error (E1001): No config file found. Run `my-cli init` to create one, or pass --config .'); +``` + +--- + +### §6.3 Provide debug mode +**Rule:** Enable verbose/debug output via `DEBUG` env var or `--debug`/`--verbose` flag. Use it throughout the codebase for troubleshooting. + +```js +// Using the debug package +import debug from 'debug'; +const log = debug('my-cli:http'); +log('GET %s', url); // only shown when DEBUG=my-cli:* is set +``` + +```sh +DEBUG=my-cli:* my-cli do-thing +``` + +**Packages:** `debug` + +--- + +### §6.4 Proper use of exit codes +**Rule:** Always exit with a meaningful exit code. Never call `process.exit()` without a code. + +```js +// ❌ Exits with undefined code (treated as 0 = success) +process.exit(); + +// ✅ Explicit codes +process.exit(0); // success +process.exit(1); // general failure + +// ✅ In async main +try { + await main(); + process.exit(0); +} catch (err) { + console.error(err.message); + process.exit(1); +} +``` + +Exit code conventions: +- `0` — success +- `1` — general failure +- `2` — misuse of shell builtins / bad arguments +- Custom codes should be documented + +--- + +### §6.5 Effortless bug reports +**Rule:** When a crash occurs, output a URL to file a bug report, pre-populated with version info and error details. + +```js +const bugReportUrl = `https://github.com/org/repo/issues/new?title=${encodeURIComponent(err.message)}&body=${encodeURIComponent(`Version: ${pkg.version}\n\nError:\n${err.stack}`)}`; +console.error(`\nPlease report this bug: ${bugReportUrl}`); +``` + +--- + +## 7. Development + +### §7.1 Use a bin object +**Rule:** In `package.json`, define `bin` as an object to decouple the executable name from the package name and file path. + +```json +// ❌ Couples name to file +{ + "bin": "./cli.js" +} + +// ✅ Explicit name → path mapping +{ + "bin": { + "my-cli": "./bin/cli.js" + } +} +``` + +--- + +### §7.2 Use relative paths correctly +**Rule:** +- Use `process.cwd()` for user-specified paths (files the user references) +- Use `__dirname` (or `import.meta.dirname` in ESM) for paths within the project + +```js +// User's file (relative to where they ran the command) +const outputPath = path.resolve(process.cwd(), options.output); + +// The CLI's own data file (relative to source) +const templatePath = path.join(__dirname, '..', 'templates', 'default.json'); +``` + +--- + +### §7.3 Use the `files` field +**Rule:** Allowlist exactly what gets published to npm to keep package size small. + +```json +{ + "files": [ + "bin", + "src", + "!src/**/*.spec.js", + "!src/**/*.test.js" + ] +} +``` + +--- + +## 8. Analytics + +### §8.1 Strict opt-in analytics +**Rule:** Never collect telemetry without explicit user consent. When implementing analytics: +- Ask permission on first run +- Document exactly what data is collected, where it goes, and for how long +- Provide `--no-telemetry` or similar opt-out mechanism at any time + +**Reference implementations:** Angular CLI, Next.js telemetry + +--- + +## 9. Versioning + +### §9.1 Include a `--version` flag +**Rule:** Implement `--version` / `-V` to display the current version and exit. + +```js +program.version(pkg.version, '-V, --version'); +``` + +--- + +### §9.2 Use Semantic Versioning +**Rule:** Follow [semver](https://semver.org/): MAJOR.MINOR.PATCH. Breaking changes → major bump. + +--- + +### §9.3 Provide version in `package.json` +**Rule:** `version` field in `package.json` is the single source of truth. Read it in code: + +```js +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const { version } = require('../package.json'); +``` + +--- + +### §9.4 Display version in error messages +**Rule:** Include version in error output so users can include it in bug reports without being asked. + +```js +console.error(`my-cli v${version} — Error (E4002): ...`); +``` + +--- + +### §9.5 Backward compatibility +**Rule:** Deprecate features gracefully before removing them. Display a deprecation warning with migration path. + +```sh +DEPRECATED: --old-flag is deprecated and will be removed in v3.0. + Use --new-flag instead. See: https://docs.example.com/migration +``` + +--- + +### §9.6 Publish versioned releases on npm +**Rule:** Use npm version tags. Always publish to npm so users can pin versions. + +```sh +npm version patch # or minor, major +npm publish +``` + +--- + +### §9.7 Update version documents +**Rule:** Maintain a `CHANGELOG.md` (or release notes) for every version. Follow [Keep a Changelog](https://keepachangelog.com/) format. + +--- + +## 10. Security + +### §10.1 Minimize argument injection +**Rule:** Never pass unsanitized user input as shell arguments. Treat all user-supplied values as untrusted. + +```js +// ❌ Argument injection vulnerability +const { execSync } = require('child_process'); +execSync(`git clone ${userInput}`); + +// ✅ Use array form — values are not interpreted as flags +const { execFileSync } = require('child_process'); +execFileSync('git', ['clone', '--', userInput]); + +// ✅ Validate against allowlist if needed +const ALLOWED = ['fetch', 'push', 'pull']; +if (!ALLOWED.includes(userInput)) throw new Error('Invalid operation'); +``` + +**Real-world CVEs:** git-interface (SNYK-JS-GITINTERFACE-2774028), simple-git (SNYK-JS-SIMPLEGIT-2421199), ungit (SNYK-JS-UNGIT-2414099) + +--- + +## 11. Appendix: CLI Frameworks + +| Framework | Best for | npm | +|-----------|----------|-----| +| `commander` | General-purpose, minimal | `npm i commander` | +| `yargs` | Complex CLIs with subcommands | `npm i yargs` | +| `oclif` | Large plugin-based CLIs | `npm i oclif` | +| `meow` | Minimal single-command CLIs | `npm i meow` | +| `ink` | React-based terminal UIs | `npm i ink` | +| `inquirer` | Interactive prompts | `npm i inquirer` | +| `enquirer` | Lightweight prompts | `npm i enquirer` | +| `ora` | Spinners / loaders | `npm i ora` | +| `listr2` | Task lists with progress | `npm i listr2` | +| `chalk` | Terminal colors | `npm i chalk` | +| `debug` | Debug logging | `npm i debug` | +| `cosmiconfig` | Config file discovery | `npm i cosmiconfig` | +| `conf` | Persistent config storage | `npm i conf` | diff --git a/CHANGELOG.md b/CHANGELOG.md index dc39a156..ce9b7665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [0.1.1] - 2026-06-29 + +### Added + +- Add `--env` flag to `logs` to fetch preview, prod, or all logs (#551) + +## [0.1.0] - 2026-06-28 + +### Added + +- Remote development commands: `sandbox` and projectless `connectors` (#547) + +## [0.0.57] - 2026-06-28 + +### Added + +- Add `--format json` and relative `--since`/`--until` shortcuts to `logs` (#552) + +### Fixed + +- Normalize Deno Deploy "warn" log level to "warning" (#549) + +## [0.0.56] - 2026-06-17 + +### Added + +- Run the frontend dev server from `base44 dev` (#545) +- Support running app-scoped commands outside a project with `--app-id` (#541) + +### Changed + +- Introduce app context lifecycle for command wiring (#540) + +## [0.0.55] - 2026-06-10 + +### Added + +- Seed `auth.json` from environment-supplied credentials (#537) + +### Changed + +- Extract shared scaffold logic into a common module (#536) + +## [0.0.54] - 2026-06-07 + +### Fixed + +- Write `VITE_BASE44_APP_BASE_URL` instead of `VITE_BASE44_BACKEND_URL` in `.env.local` (#534) + +## [0.0.53] - 2026-06-07 + +### Added + +- Write `.env.local` on first run of `base44 dev` (#517) + +### Fixed + +- Inject service role token for unauthenticated function calls in local dev (#516) +- Patch `Deno.serve` via `defineProperty` for Deno 2.8 compatibility (#533) + +## [0.0.52] - 2026-05-17 + +### Added + +- Add plugins support (#503) +- Add `auth sso` command for SSO provider configuration (#484) +- Add row-level security (RLS) support in local dev (#481) + +### Fixed + +- Exit with a non-zero code when function deploy fails (#504) + ## [0.0.51] - 2026-04-28 ### Added diff --git a/docs/telemetry.md b/docs/telemetry.md index 470386d3..d00bc46e 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -46,6 +46,26 @@ errorReporter.captureException(error); - Error stack traces - Error code and `isUserError` (for `CLIError` instances) +### Redaction + +Sensitive values are redacted before capture (`src/cli/telemetry/redact.ts`): + +- Positional args shaped like `KEY=VALUE` (e.g. `base44 secrets set FOO=bar`) + keep the key but the value is replaced with `[REDACTED]` +- API request/response bodies for `/secrets` endpoints are replaced entirely + with `[REDACTED]` + +Anything new that may carry user secrets (args, request bodies, options) must +go through these helpers before being added to event properties. + +## First-Run Notice + +`runCLI()` calls `maybeShowTelemetryNotice(log)` (in +`src/cli/telemetry/first-run-notice.ts`), which prints a one-time notice about +what is collected and how to opt out. A marker file at +`~/.base44/telemetry-notice` records that the notice was shown. The notice is +skipped when telemetry is disabled (the testkit disables it for all tests). + ## Disabling Telemetry Set the environment variable: diff --git a/packages/cli/README.md b/packages/cli/README.md index 4a48fb8c..547ef5ba 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -119,6 +119,16 @@ base44 --help base44 --version ``` +## Telemetry + +When a command fails unexpectedly, the CLI sends an error report so we can find and fix bugs. A one-time notice is shown on first run. Reports include the command name, CLI version, OS and Node.js info, the error message and stack trace, and your account email when logged in. Secret values (e.g. from `base44 secrets set`) are redacted before anything is sent. + +To opt out, set the environment variable: + +```bash +BASE44_DISABLE_TELEMETRY=1 +``` + ## Beta The CLI and Base44 backend service are currently in beta. We're actively improving them based on user feedback. Share your thoughts and feature requests on our [GitHub Discussions](https://github.com/orgs/base44/discussions). diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 8fd0e4b2..f3c31813 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -1,5 +1,31 @@ #!/usr/bin/env node -import { runCLI } from "../dist/cli/index.js"; +// Keep this file free of syntax newer than Node 12 can parse (no top-level +// await, no static import of the bundle), so users on unsupported Node +// versions get the clear version error below instead of a SyntaxError. +import { readFileSync } from "fs"; + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf-8"), +); +const requiredVersion = packageJson.engines.node.replace(/[^\d.]/g, ""); + +function versionAtLeast(current, required) { + const cur = current.split(".").map(Number); + const req = required.split(".").map(Number); + for (let i = 0; i < req.length; i++) { + if ((cur[i] || 0) > (req[i] || 0)) return true; + if ((cur[i] || 0) < (req[i] || 0)) return false; + } + return true; +} + +if (!versionAtLeast(process.versions.node, requiredVersion)) { + process.stderr.write( + `base44 requires Node.js >= ${requiredVersion}, but you are running Node.js ${process.versions.node}.\n` + + "Upgrade Node.js to use the Base44 CLI: https://nodejs.org/\n", + ); + process.exit(1); +} // Disable Clack spinners and animations in non-interactive environments. // Clack only checks the CI env var, so we set it when stdin/stdout aren't TTYs. @@ -7,4 +33,9 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { process.env.CI = "true"; } -await runCLI(); +import("../dist/cli/index.js") + .then(({ runCLI }) => runCLI()) + .catch((error) => { + process.stderr.write(`${error && error.stack ? error.stack : error}\n`); + process.exitCode = 1; + }); diff --git a/packages/cli/src/cli/commands/project/scaffold-shared.ts b/packages/cli/src/cli/commands/project/scaffold-shared.ts index 975bd6b7..2d2698c9 100644 --- a/packages/cli/src/cli/commands/project/scaffold-shared.ts +++ b/packages/cli/src/cli/commands/project/scaffold-shared.ts @@ -127,7 +127,6 @@ export async function completeProjectSetup( async () => { await execa("npx", ["-y", "skills", "add", "base44/skills", "-y"], { cwd: resolvedPath, - shell: true, }); }, { diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index fdd57254..e5156edc 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -298,6 +298,7 @@ export async function createDevServer( }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + process.on("SIGHUP", shutdown); // If the frontend dies, tear the whole dev environment down. serveRunner?.onExit(() => { diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 3d8d5f0f..9df36206 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -8,6 +8,7 @@ import { ensureNpmAssets } from "@/core/assets.js"; import { readAuth } from "@/core/auth/index.js"; import { CLIExitError } from "./errors.js"; import { ErrorReporter } from "./telemetry/error-reporter.js"; +import { maybeShowTelemetryNotice } from "./telemetry/first-run-notice.js"; import { addCommandInfoToErrorReporter } from "./telemetry/index.js"; import type { CLIContext, Distribution } from "./types.js"; import { @@ -49,6 +50,8 @@ async function runCLI(options?: RunCLIOptions): Promise { runTask, }; + maybeShowTelemetryNotice(log); + // Create program with injected context const program = createProgram(context); diff --git a/packages/cli/src/cli/telemetry/commander-hooks.ts b/packages/cli/src/cli/telemetry/commander-hooks.ts index 7c95a5f6..f4b9713f 100644 --- a/packages/cli/src/cli/telemetry/commander-hooks.ts +++ b/packages/cli/src/cli/telemetry/commander-hooks.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import type { ErrorReporter } from "./error-reporter.js"; +import { redactCommandArgs } from "./redact.js"; /** * Get the full command name by traversing parent commands. @@ -31,7 +32,7 @@ export function addCommandInfoToErrorReporter( errorReporter.setContext({ command: { name: fullCommandName, - args: actionCommand.args, + args: redactCommandArgs(actionCommand.args), options: actionCommand.opts(), }, }); diff --git a/packages/cli/src/cli/telemetry/error-reporter.ts b/packages/cli/src/cli/telemetry/error-reporter.ts index e7bf9cd1..aaf79ccb 100644 --- a/packages/cli/src/cli/telemetry/error-reporter.ts +++ b/packages/cli/src/cli/telemetry/error-reporter.ts @@ -4,6 +4,7 @@ import { nanoid } from "nanoid"; import { ApiError, isCLIError, isUserError } from "@/core/errors.js"; import packageJson from "../../../package.json"; import { getPostHogClient, isTelemetryEnabled } from "./posthog.js"; +import { redactApiBody } from "./redact.js"; /** * Context that can be set during CLI execution. @@ -70,8 +71,14 @@ export class ErrorReporter { api_status_code: error.statusCode, api_request_url: error.requestUrl, api_request_method: error.requestMethod, - api_request_body: error.requestBody, - api_response_body: error.responseBody, + api_request_body: redactApiBody( + error.requestUrl, + error.requestBody, + ), + api_response_body: redactApiBody( + error.requestUrl, + error.responseBody, + ), api_request_id: error.requestId, } : {}; diff --git a/packages/cli/src/cli/telemetry/first-run-notice.ts b/packages/cli/src/cli/telemetry/first-run-notice.ts new file mode 100644 index 00000000..2f42b939 --- /dev/null +++ b/packages/cli/src/cli/telemetry/first-run-notice.ts @@ -0,0 +1,34 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { Logger } from "@base44-cli/logger"; +import { getTelemetryNoticePath } from "@/core/config.js"; +import { isTelemetryEnabled } from "./posthog.js"; + +const NOTICE = + "The Base44 CLI sends error reports (command name, CLI version, OS info, " + + "and your account email when logged in) to help improve the CLI. " + + "Set BASE44_DISABLE_TELEMETRY=1 to opt out."; + +/** + * Print a one-time notice that error telemetry is collected and how to opt + * out. A marker file in the Base44 global dir records that the notice was + * shown. Never throws — telemetry UX must not break the CLI. + */ +export function maybeShowTelemetryNotice(log: Logger): void { + if (!isTelemetryEnabled()) { + return; + } + + try { + const markerPath = getTelemetryNoticePath(); + if (existsSync(markerPath)) { + return; + } + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, `${new Date().toISOString()}\n`); + log.message(NOTICE); + } catch { + // Best-effort: if the marker can't be written, skip the notice rather + // than printing it on every run or crashing the CLI. + } +} diff --git a/packages/cli/src/cli/telemetry/redact.ts b/packages/cli/src/cli/telemetry/redact.ts new file mode 100644 index 00000000..f1eedd59 --- /dev/null +++ b/packages/cli/src/cli/telemetry/redact.ts @@ -0,0 +1,35 @@ +const REDACTED = "[REDACTED]"; + +/** + * Redact user-supplied values from positional command args before they are + * attached to telemetry events. Args shaped like KEY=VALUE (e.g. + * `base44 secrets set STRIPE_KEY=sk_live_...`) keep the key but drop the + * value, which may be a secret. + */ +export function redactCommandArgs(args: string[]): string[] { + return args.map((arg) => { + const eqIndex = arg.indexOf("="); + return eqIndex > 0 ? `${arg.slice(0, eqIndex + 1)}${REDACTED}` : arg; + }); +} + +/** URL paths whose request/response bodies contain plaintext secret values. */ +const SENSITIVE_URL_PATTERN = /\/secrets(\/|\?|$)/; + +/** + * Redact API request/response bodies for endpoints that carry plaintext + * secrets. The whole body is dropped rather than picking fields, since + * secret names are user-defined and cannot be allowlisted. + */ +export function redactApiBody( + requestUrl: string | undefined, + body: unknown, +): unknown { + if (body === undefined || body === null) { + return body; + } + if (requestUrl && SENSITIVE_URL_PATTERN.test(requestUrl)) { + return REDACTED; + } + return body; +} diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 058b5b3e..d6bf09f2 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -205,7 +205,7 @@ export class Base44Command extends Command { // --json: emit the error as JSON on stdout (single machine channel). writeJsonError(error); } else if (quiet) { - showPlainError(error); + showPlainError(error, this.context); } else { showThemedError(error, this.context); } diff --git a/packages/cli/src/cli/utils/command/bug-report.ts b/packages/cli/src/cli/utils/command/bug-report.ts new file mode 100644 index 00000000..062e80e9 --- /dev/null +++ b/packages/cli/src/cli/utils/command/bug-report.ts @@ -0,0 +1,56 @@ +import { release, type } from "node:os"; +import type { ErrorContext } from "@/cli/telemetry/error-reporter.js"; +import { isCLIError, isUserError } from "@/core/errors.js"; +import packageJson from "../../../../package.json"; + +const ISSUES_URL = "https://github.com/base44/cli/issues/new"; +const MAX_MESSAGE_LENGTH = 300; + +/** + * Whether an error warrants pointing the user at the bug tracker. User + * errors (bad input, missing auth) are expected and actionable via hints; + * everything else is a bug worth reporting. + */ +export function shouldOfferBugReport(error: unknown): boolean { + return !isCLIError(error) || !isUserError(error); +} + +/** + * Build a GitHub new-issue URL pre-filled with the error message, CLI + * version, and environment details, so filing a bug requires no manual + * information gathering. + */ +export function buildBugReportUrl(error: unknown, ctx: ErrorContext): string { + const message = error instanceof Error ? error.message : String(error); + const truncated = + message.length > MAX_MESSAGE_LENGTH + ? `${message.slice(0, MAX_MESSAGE_LENGTH)}…` + : message; + const code = isCLIError(error) ? error.code : "UNEXPECTED"; + + const title = `[bug] ${code}: ${truncated.split("\n")[0]?.slice(0, 80)}`; + const body = [ + "## What happened?", + "", + "", + "", + "## Error", + "", + "```", + truncated, + "```", + "", + "## Environment", + "", + `- CLI version: ${packageJson.version}`, + ctx.command?.name ? `- Command: base44 ${ctx.command.name}` : null, + ctx.sessionId ? `- Session: ${ctx.sessionId}` : null, + `- OS: ${type()} ${release()} (${process.platform}/${process.arch})`, + `- Node.js: ${process.version}`, + ] + .filter((line) => line !== null) + .join("\n"); + + const params = new URLSearchParams({ title, body }); + return `${ISSUES_URL}?${params.toString()}`; +} diff --git a/packages/cli/src/cli/utils/command/render.ts b/packages/cli/src/cli/utils/command/render.ts index 73541482..998e8d18 100644 --- a/packages/cli/src/cli/utils/command/render.ts +++ b/packages/cli/src/cli/utils/command/render.ts @@ -5,6 +5,8 @@ import { theme } from "@/cli/utils/theme.js"; import { printUpgradeNotification } from "@/cli/utils/upgradeNotification.js"; import type { UpgradeInfo } from "@/cli/utils/version-check.js"; import { isCLIError } from "@/core/errors.js"; +import packageJson from "../../../../package.json"; +import { buildBugReportUrl, shouldOfferBugReport } from "./bug-report.js"; /** * Show the command start UI: intro banner or simple tag. @@ -57,13 +59,23 @@ export function showThemedError(error: unknown, context: CLIContext): void { } const errorContext = context.errorReporter.getErrorContext(); - outro(theme.format.errorContext(errorContext)); + + if (shouldOfferBugReport(error)) { + log.info( + `Report this issue: ${theme.colors.links( + buildBugReportUrl(error, errorContext), + )}`, + ); + } + + const errorCode = isCLIError(error) ? error.code : undefined; + outro(theme.format.errorContext(errorContext, errorCode)); } /** * Display an error as plain text to stderr (non-interactive / CI mode). */ -export function showPlainError(error: unknown): void { +export function showPlainError(error: unknown, context?: CLIContext): void { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`Error: ${errorMessage}\n`); @@ -80,4 +92,14 @@ export function showPlainError(error: unknown): void { if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { process.stderr.write(`${error.stack}\n`); } + + if (shouldOfferBugReport(error)) { + const errorContext = context?.errorReporter.getErrorContext() ?? {}; + process.stderr.write( + `Report this issue: ${buildBugReportUrl(error, errorContext)}\n`, + ); + } + + const codePart = isCLIError(error) ? ` | Code: ${error.code}` : ""; + process.stderr.write(`base44 v${packageJson.version}${codePart}\n`); } diff --git a/packages/cli/src/cli/utils/theme.ts b/packages/cli/src/cli/utils/theme.ts index 99217c58..d5cef974 100644 --- a/packages/cli/src/cli/utils/theme.ts +++ b/packages/cli/src/cli/utils/theme.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import type { ErrorContext } from "@/cli/telemetry/error-reporter.js"; import type { ErrorHint } from "@/core/errors.js"; +import packageJson from "../../../package.json"; /** * Base44 CLI theme configuration @@ -22,8 +23,10 @@ export const theme = { info: chalk.cyan, }, format: { - errorContext(ctx: ErrorContext): string { + errorContext(ctx: ErrorContext, errorCode?: string): string { const parts = [ + `base44 v${packageJson.version}`, + errorCode ? `Code: ${errorCode}` : null, ctx.sessionId ? `Session: ${ctx.sessionId}` : null, ctx.appId ? `App: ${ctx.appId}` : null, new Date().toISOString(), diff --git a/packages/cli/src/cli/utils/version-check.ts b/packages/cli/src/cli/utils/version-check.ts index 65e16b74..74f6d97a 100644 --- a/packages/cli/src/cli/utils/version-check.ts +++ b/packages/cli/src/cli/utils/version-check.ts @@ -23,7 +23,6 @@ export async function checkForUpgrade(): Promise { try { const { stdout } = await execa("npm", ["view", "base44", "version"], { timeout: 1000, - shell: true, env: { CI: "1" }, }); const latestVersion = stdout.trim(); diff --git a/packages/cli/src/core/assets.ts b/packages/cli/src/core/assets.ts index 237b0990..ba877460 100644 --- a/packages/cli/src/core/assets.ts +++ b/packages/cli/src/core/assets.ts @@ -1,9 +1,10 @@ -import { cpSync, existsSync } from "node:fs"; +import { cpSync, existsSync, readdirSync, rmSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import packageJson from "../../package.json"; -const ASSETS_DIR = join(homedir(), ".base44", "assets", packageJson.version); +const ASSETS_ROOT = join(homedir(), ".base44", "assets"); +const ASSETS_DIR = join(ASSETS_ROOT, packageJson.version); export function getTemplatesDir(): string { return join(ASSETS_DIR, "templates"); @@ -29,4 +30,26 @@ export function ensureNpmAssets(sourceDir: string): void { if (existsSync(ASSETS_DIR)) return; if (!existsSync(sourceDir)) return; cpSync(sourceDir, ASSETS_DIR, { recursive: true }); + pruneOldAssetVersions(); +} + +/** + * Remove asset directories left behind by previous CLI versions so + * ~/.base44/assets doesn't grow with every release. Best effort: a failure + * (e.g. another CLI version running concurrently on Windows holding a file + * open) is ignored and retried on the next version's first run. + */ +function pruneOldAssetVersions(): void { + try { + for (const entry of readdirSync(ASSETS_ROOT, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== packageJson.version) { + rmSync(join(ASSETS_ROOT, entry.name), { + recursive: true, + force: true, + }); + } + } + } catch { + // Ignore: pruning must never break the CLI. + } } diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index 6e2af15b..ca4170eb 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -18,6 +18,10 @@ export function getAuthFilePath(): string { return join(getBase44GlobalDir(), "auth", "auth.json"); } +export function getTelemetryNoticePath(): string { + return join(getBase44GlobalDir(), "telemetry-notice"); +} + export function getAppConfigPath(projectRoot: string): string { return join(projectRoot, PROJECT_SUBDIR, ".app.jsonc"); } diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index 0baf7591..7163f41f 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -11,10 +11,13 @@ describe("exec command", () => { const result = await t.run("exec"); t.expectResult(result).toFail(); - expect(stripAnsi(result.stderr)).toBe( - "Error: No input provided. Pipe a script to stdin.\n" + - " Hint: File: cat ./script.ts | base44 exec\n" + - ' Hint: Eval: echo "const users = await base44.entities.User.list(); console.log(users)" | base44 exec', + expect(stripAnsi(result.stderr)).toMatch( + new RegExp( + "^Error: No input provided\\. Pipe a script to stdin\\.\n" + + " Hint: File: cat \\./script\\.ts \\| base44 exec\n" + + ' Hint: Eval: echo "const users = await base44\\.entities\\.User\\.list\\(\\); console\\.log\\(users\\)" \\| base44 exec\n' + + "base44 v\\d+\\.\\d+\\.\\d+ \\| Code: INVALID_INPUT$", + ), ); }); @@ -30,10 +33,15 @@ describe("exec command", () => { const result = await t.run("exec"); t.expectResult(result).toFail(); - expect(stripAnsi(result.stderr)).toBe( + const stderr = stripAnsi(result.stderr); + expect(stderr).toContain( "Error: Error exchanging platform token for app user token: Internal server error\n" + " Hint: Check your network connection and try again", ); + expect(stderr).toContain( + "Report this issue: https://github.com/base44/cli/issues/new?", + ); + expect(stderr).toMatch(/base44 v\d+\.\d+\.\d+ \| Code: API_ERROR/); }); it("executes a piped script and captures its output", async () => { diff --git a/packages/cli/tests/core/telemetry-redact.spec.ts b/packages/cli/tests/core/telemetry-redact.spec.ts new file mode 100644 index 00000000..c90adfc0 --- /dev/null +++ b/packages/cli/tests/core/telemetry-redact.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + redactApiBody, + redactCommandArgs, +} from "../../src/cli/telemetry/redact.js"; + +describe("redactCommandArgs", () => { + it("masks values of KEY=VALUE args", () => { + expect(redactCommandArgs(["STRIPE_KEY=sk_live_abc123"])).toEqual([ + "STRIPE_KEY=[REDACTED]", + ]); + }); + + it("masks only the value when it contains '='", () => { + expect(redactCommandArgs(["TOKEN=abc=def"])).toEqual(["TOKEN=[REDACTED]"]); + }); + + it("leaves args without '=' untouched", () => { + expect(redactCommandArgs(["my-function", "./path/to/file"])).toEqual([ + "my-function", + "./path/to/file", + ]); + }); + + it("leaves args starting with '=' untouched", () => { + expect(redactCommandArgs(["=weird"])).toEqual(["=weird"]); + }); + + it("handles empty arg lists", () => { + expect(redactCommandArgs([])).toEqual([]); + }); +}); + +describe("redactApiBody", () => { + it("redacts bodies for secrets endpoints", () => { + expect( + redactApiBody("https://app.base44.com/api/apps/123/secrets", { + MY_SECRET: "value", + }), + ).toBe("[REDACTED]"); + }); + + it("redacts bodies for secrets endpoints with query params", () => { + expect( + redactApiBody( + "https://app.base44.com/api/apps/123/secrets?secret_name=FOO", + { deleted: true }, + ), + ).toBe("[REDACTED]"); + }); + + it("keeps bodies for other endpoints", () => { + const body = { name: "my-entity" }; + expect( + redactApiBody("https://app.base44.com/api/apps/123/entities", body), + ).toBe(body); + }); + + it("passes through undefined body and URL", () => { + expect(redactApiBody(undefined, undefined)).toBeUndefined(); + expect(redactApiBody(undefined, { a: 1 })).toEqual({ a: 1 }); + }); +});