From 57448a7d1666327097a7b1eaf97c1e8587651e11 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 11 Oct 2026 09:38:01 +0200 Subject: [PATCH 1/9] feat(project): dependency-free foundations for init and migrate src/lib/project/ holds the building blocks the coming `init` and `migrate` commands share, with no new runtime dependency: - package-manager: npm, pnpm, yarn classic vs berry (PnP from .yarnrc.yml), bun (bun.lock / bun.lockb), deno; packageManager pin, then lockfiles, then npm_config_user_agent; add/install/exec/dlx command builders with workspace filters. - monorepo: pnpm-workspace.yaml, package.json workspaces, Turborepo, Nx; app candidates by framework dependency; resolveProjectRoot. - fs-writer: create-only files (created, unchanged, kept-user-version, missing), sha256 managed-file hashes, marked-block upserts, CRLF tolerant, atomic temp+rename writes, file modes, dry-run plans with unified diffs. - env-file: dotenv round-trips byte for byte; ensureVar only adds or fills, never replaces or returns a value, and refuses secrets under NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_. - state: brew.json read/validate/write with a stable key order, plus the JSON Schema in schemas/brew.json. - gitignore: git check-ignore through the spawn seam, fallback parser, managed block. - diff: a small unified line diff. - vendor-http: third-party fetch bounded like rawFetch, never recorded in ctx.transport, VENDOR_ERROR {vendor, status} with credentials redacted, bounded retry-after on 429. CliIo gains optional spawn, openUrl and cwd seams; tests run every library against fixture projects in bounded temp directories. Co-Authored-By: Claude Opus 5.5 --- biome.jsonc | 9 +- schemas/brew.json | 51 +++ src/lib/errors.ts | 45 ++ src/lib/project/diff.ts | 146 ++++++ src/lib/project/env-file.ts | 267 +++++++++++ src/lib/project/fs-writer.ts | 421 +++++++++++++++++ src/lib/project/gitignore.ts | 215 +++++++++ src/lib/project/monorepo.ts | 400 ++++++++++++++++ src/lib/project/package-manager.ts | 430 ++++++++++++++++++ src/lib/project/process.ts | 82 ++++ src/lib/project/state.ts | 314 +++++++++++++ src/lib/project/vendor-http.ts | 286 ++++++++++++ src/lib/raw-request.ts | 2 +- src/lib/types.ts | 35 ++ tests/fixtures/projects/bun/bun.lock | 4 + tests/fixtures/projects/bun/package.json | 6 + tests/fixtures/projects/deno/deno.json | 5 + tests/fixtures/projects/deno/deno.lock | 3 + .../projects/next-app/package-lock.json | 4 + tests/fixtures/projects/next-app/package.json | 7 + .../projects/no-package-json/README.md | 1 + .../npm-workspaces/apps/docs/package.json | 6 + .../npm-workspaces/apps/site/package.json | 6 + .../projects/npm-workspaces/package-lock.json | 4 + .../projects/npm-workspaces/package.json | 5 + .../projects/nx/apps/shop/package.json | 6 + .../projects/nx/libs/util/package.json | 3 + tests/fixtures/projects/nx/nx.json | 3 + tests/fixtures/projects/nx/package.json | 4 + .../projects/pnpm-turbo/apps/api/package.json | 6 + .../pnpm-turbo/apps/web/app/README.md | 1 + .../projects/pnpm-turbo/apps/web/package.json | 7 + .../projects/pnpm-turbo/apps/web/turbo.json | 3 + .../fixtures/projects/pnpm-turbo/package.json | 5 + .../pnpm-turbo/packages/ui/package.json | 6 + .../projects/pnpm-turbo/pnpm-lock.yaml | 1 + .../projects/pnpm-turbo/pnpm-workspace.yaml | 4 + tests/fixtures/projects/pnpm-turbo/turbo.json | 3 + .../projects/yarn-berry-pnp/.yarnrc.yml | 1 + .../projects/yarn-berry-pnp/package.json | 7 + .../projects/yarn-berry-pnp/yarn.lock | 5 + .../projects/yarn-classic/package.json | 6 + .../fixtures/projects/yarn-classic/yarn.lock | 2 + tests/helpers/fixtures.ts | 23 + tests/helpers/run-cli.ts | 11 +- tests/lib/project/env-file.test.ts | 125 +++++ tests/lib/project/fs-writer.test.ts | 250 ++++++++++ tests/lib/project/gitignore.test.ts | 136 ++++++ tests/lib/project/monorepo.test.ts | 129 ++++++ tests/lib/project/package-manager.test.ts | 256 +++++++++++ tests/lib/project/state.test.ts | 164 +++++++ tests/lib/project/vendor-http.test.ts | 252 ++++++++++ 52 files changed, 4170 insertions(+), 3 deletions(-) create mode 100644 schemas/brew.json create mode 100644 src/lib/project/diff.ts create mode 100644 src/lib/project/env-file.ts create mode 100644 src/lib/project/fs-writer.ts create mode 100644 src/lib/project/gitignore.ts create mode 100644 src/lib/project/monorepo.ts create mode 100644 src/lib/project/package-manager.ts create mode 100644 src/lib/project/process.ts create mode 100644 src/lib/project/state.ts create mode 100644 src/lib/project/vendor-http.ts create mode 100644 tests/fixtures/projects/bun/bun.lock create mode 100644 tests/fixtures/projects/bun/package.json create mode 100644 tests/fixtures/projects/deno/deno.json create mode 100644 tests/fixtures/projects/deno/deno.lock create mode 100644 tests/fixtures/projects/next-app/package-lock.json create mode 100644 tests/fixtures/projects/next-app/package.json create mode 100644 tests/fixtures/projects/no-package-json/README.md create mode 100644 tests/fixtures/projects/npm-workspaces/apps/docs/package.json create mode 100644 tests/fixtures/projects/npm-workspaces/apps/site/package.json create mode 100644 tests/fixtures/projects/npm-workspaces/package-lock.json create mode 100644 tests/fixtures/projects/npm-workspaces/package.json create mode 100644 tests/fixtures/projects/nx/apps/shop/package.json create mode 100644 tests/fixtures/projects/nx/libs/util/package.json create mode 100644 tests/fixtures/projects/nx/nx.json create mode 100644 tests/fixtures/projects/nx/package.json create mode 100644 tests/fixtures/projects/pnpm-turbo/apps/api/package.json create mode 100644 tests/fixtures/projects/pnpm-turbo/apps/web/app/README.md create mode 100644 tests/fixtures/projects/pnpm-turbo/apps/web/package.json create mode 100644 tests/fixtures/projects/pnpm-turbo/apps/web/turbo.json create mode 100644 tests/fixtures/projects/pnpm-turbo/package.json create mode 100644 tests/fixtures/projects/pnpm-turbo/packages/ui/package.json create mode 100644 tests/fixtures/projects/pnpm-turbo/pnpm-lock.yaml create mode 100644 tests/fixtures/projects/pnpm-turbo/pnpm-workspace.yaml create mode 100644 tests/fixtures/projects/pnpm-turbo/turbo.json create mode 100644 tests/fixtures/projects/yarn-berry-pnp/.yarnrc.yml create mode 100644 tests/fixtures/projects/yarn-berry-pnp/package.json create mode 100644 tests/fixtures/projects/yarn-berry-pnp/yarn.lock create mode 100644 tests/fixtures/projects/yarn-classic/package.json create mode 100644 tests/fixtures/projects/yarn-classic/yarn.lock create mode 100644 tests/helpers/fixtures.ts create mode 100644 tests/lib/project/env-file.test.ts create mode 100644 tests/lib/project/fs-writer.test.ts create mode 100644 tests/lib/project/gitignore.test.ts create mode 100644 tests/lib/project/monorepo.test.ts create mode 100644 tests/lib/project/package-manager.test.ts create mode 100644 tests/lib/project/state.test.ts create mode 100644 tests/lib/project/vendor-http.test.ts diff --git a/biome.jsonc b/biome.jsonc index b2a712c..20b24f4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -9,7 +9,14 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!dist", "!openapi", "!docs/commands", "!src/generated"] + "includes": [ + "**", + "!dist", + "!openapi", + "!docs/commands", + "!src/generated", + "!tests/fixtures" + ] }, "formatter": { "enabled": true, diff --git a/schemas/brew.json b/schemas/brew.json new file mode 100644 index 0000000..81addb4 --- /dev/null +++ b/schemas/brew.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://unpkg.com/@brew.new/cli/schemas/brew.json", + "title": "brew.json", + "description": "Brew project file written by `brew-cli init`. Committed; holds identifiers and file hashes, never secrets.", + "type": "object", + "required": ["version", "projectId", "emails", "files"], + "properties": { + "$schema": { "type": "string", "minLength": 1 }, + "version": { "const": 1 }, + "projectId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "brandId": { "type": "string", "minLength": 1 }, + "framework": { "type": "string", "minLength": 1 }, + "auth": { "type": "string", "minLength": 1 }, + "contractsPath": { "type": "string", "minLength": 1 }, + "emails": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" + }, + "additionalProperties": { + "type": "object", + "required": ["triggerEventId", "automationId"], + "properties": { + "triggerEventId": { "type": "string", "minLength": 1 }, + "automationId": { "type": "string", "minLength": 1 } + } + } + }, + "files": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["sha256", "generator"], + "properties": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "generator": { "type": "string", "minLength": 1 } + } + } + }, + "providerConfig": { "type": "object" }, + "mcp": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index ae9dc5c..bda7e30 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -85,6 +85,33 @@ export class CliAuthError extends Error { } } +/** + * A third-party API (Supabase, Clerk, Resend) a project command called on + * the user's behalf answered with an error, or could not be reached. Never + * a Brew API error: it exits 1 whatever the vendor's status, and its + * message never repeats a credential (see `lib/project/vendor-http.ts`). + */ +export class CliVendorError extends Error { + readonly vendor: string + /** The vendor's HTTP status; `null` when no response arrived. */ + readonly status: number | null + /** How long a 429 asked us to wait, when that exceeded our bound. */ + readonly retryAfterMs: number | undefined + + constructor(input: { + readonly vendor: string + readonly status: number | null + readonly message: string + readonly retryAfterMs?: number + }) { + super(input.message) + this.name = 'CliVendorError' + this.vendor = input.vendor + this.status = input.status + this.retryAfterMs = input.retryAfterMs + } +} + /** The user declined an interactive confirmation prompt. */ export class CommandAbortedError extends Error {} @@ -339,6 +366,24 @@ function baseEnvelope(error: unknown, context: ErrorContext): CliErrorEnvelope { status: error.status, } } + if (error instanceof CliVendorError) { + return { + code: 'VENDOR_ERROR', + type: 'vendor_error', + message: error.message, + details: { + vendor: error.vendor, + status: error.status, + ...(error.retryAfterMs === undefined + ? {} + : { retryAfterMs: error.retryAfterMs }), + }, + suggestion: + error.status === 429 + ? `${error.vendor} is rate limiting: wait, then run the command again.` + : `Check the ${error.vendor} credentials and settings, then run the command again.`, + } + } if (error instanceof CliAuthError) { return { code: 'CLI_AUTH', diff --git a/src/lib/project/diff.ts b/src/lib/project/diff.ts new file mode 100644 index 0000000..52fbd84 --- /dev/null +++ b/src/lib/project/diff.ts @@ -0,0 +1,146 @@ +/** + * A tiny unified line diff for dry-run previews of the small files project + * commands write (configs, env files, managed blocks). LCS over lines; a + * pair of inputs too large for the quadratic table degrades to one hunk + * that replaces everything, which is still a correct diff. + */ + +type Op = { readonly kind: ' ' | '-' | '+'; readonly line: string } + +const MAX_CELLS = 4_000_000 + +export function splitLines(text: string): string[] { + if (text === '') { + return [] + } + const lines = text.replaceAll('\r\n', '\n').split('\n') + if (lines.at(-1) === '') { + lines.pop() + } + return lines +} + +function diffOps(before: readonly string[], after: readonly string[]): Op[] { + const n = before.length + const m = after.length + if (n * m > MAX_CELLS) { + return [ + ...before.map((line) => ({ kind: '-' as const, line })), + ...after.map((line) => ({ kind: '+' as const, line })), + ] + } + // lcs[i][j] = LCS length of before[i..] and after[j..], flattened. + const width = m + 1 + const lcs = new Uint32Array((n + 1) * width) + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + lcs[i * width + j] = + before[i] === after[j] + ? (lcs[(i + 1) * width + j + 1] ?? 0) + 1 + : Math.max(lcs[(i + 1) * width + j] ?? 0, lcs[i * width + j + 1] ?? 0) + } + } + const ops: Op[] = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + const a = before[i] ?? '' + const b = after[j] ?? '' + if (a === b) { + ops.push({ kind: ' ', line: a }) + i += 1 + j += 1 + } else if ( + (lcs[(i + 1) * width + j] ?? 0) >= (lcs[i * width + j + 1] ?? 0) + ) { + ops.push({ kind: '-', line: a }) + i += 1 + } else { + ops.push({ kind: '+', line: b }) + j += 1 + } + } + for (; i < n; i += 1) { + ops.push({ kind: '-', line: before[i] ?? '' }) + } + for (; j < m; j += 1) { + ops.push({ kind: '+', line: after[j] ?? '' }) + } + return ops +} + +/** + * `before` → `after` as a unified diff with `context` lines around each + * change, or `''` when the texts are equal (line endings ignored). A file + * that does not exist yet is `before: ''`. + */ +export function unifiedDiff( + before: string, + after: string, + options: { + readonly fromPath?: string + readonly toPath?: string + readonly context?: number + } = {} +): string { + const ops = diffOps(splitLines(before), splitLines(after)) + if (ops.every((op) => op.kind === ' ')) { + return '' + } + const context = options.context ?? 3 + const out: string[] = [ + `--- ${options.fromPath ?? 'a'}`, + `+++ ${options.toPath ?? options.fromPath ?? 'b'}`, + ] + // Positions (1-based) of each op in the old and new files. + const oldAt: number[] = [] + const newAt: number[] = [] + let oldLine = 1 + let newLine = 1 + for (const op of ops) { + oldAt.push(oldLine) + newAt.push(newLine) + if (op.kind !== '+') { + oldLine += 1 + } + if (op.kind !== '-') { + newLine += 1 + } + } + let index = 0 + while (index < ops.length) { + if (ops[index]?.kind === ' ') { + index += 1 + continue + } + const start = Math.max(0, index - context) + let end = index + // Extend the hunk while the next change is within 2 * context lines. + for (;;) { + while (end < ops.length && ops[end]?.kind !== ' ') { + end += 1 + } + let next = end + while (next < ops.length && ops[next]?.kind === ' ') { + next += 1 + } + if (next < ops.length && next - end <= context * 2) { + end = next + continue + } + end = Math.min(ops.length, end + context) + break + } + const hunk = ops.slice(start, end) + const oldCount = hunk.filter((op) => op.kind !== '+').length + const newCount = hunk.filter((op) => op.kind !== '-').length + const oldStart = oldCount === 0 ? (oldAt[start] ?? 1) - 1 : oldAt[start] + const newStart = newCount === 0 ? (newAt[start] ?? 1) - 1 : newAt[start] + out.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`) + for (const op of hunk) { + out.push(`${op.kind}${op.line}`) + } + index = end + } + return `${out.join('\n')}\n` +} diff --git a/src/lib/project/env-file.ts b/src/lib/project/env-file.ts new file mode 100644 index 0000000..948bb2f --- /dev/null +++ b/src/lib/project/env-file.ts @@ -0,0 +1,267 @@ +/** + * Dotenv files (`.env.local`, `.env.example`) edited without losing what the + * user wrote: comments, blank lines, order, quoting and line endings all + * round-trip byte for byte, and a variable is only ever ADDED (or filled + * when present but empty), never replaced. + * + * Values are secrets. Nothing here returns or prints one: results name the + * variable and what happened to it, never its value. + */ + +export type EnvLine = + | { + readonly kind: 'var' + readonly key: string + readonly value: string + /** The source line(s) exactly as read (a quoted value can span lines). */ + readonly raw: string + } + | { readonly kind: 'other'; readonly raw: string } + +export type EnvFile = { + readonly lines: readonly EnvLine[] + readonly eol: '\n' | '\r\n' + readonly trailingNewline: boolean +} + +export type EnsureVarAction = + /** The variable was absent and was appended. */ + | 'added' + /** The variable was present with an empty value, now set. */ + | 'filled' + /** The variable already had a value: left exactly as it was. */ + | 'kept-existing' + +export type EnsureVarResult = { + readonly name: string + readonly action: EnsureVarAction +} + +/** + * Prefixes that frameworks inline into the browser bundle (Next.js, Vite, + * Astro/SvelteKit, Expo). A secret under one of these names ships to every + * visitor. + */ +export const CLIENT_EXPOSED_PREFIXES = [ + 'NEXT_PUBLIC_', + 'VITE_', + 'PUBLIC_', + 'EXPO_PUBLIC_', +] as const + +export function isClientExposedName(name: string): boolean { + return CLIENT_EXPOSED_PREFIXES.some((prefix) => name.startsWith(prefix)) +} + +/** A secret was about to be written under a browser-exposed name. */ +export class EnvSecretExposureError extends Error { + readonly variable: string + + constructor(name: string) { + super( + `Refusing to store a secret in ${name}: the ${CLIENT_EXPOSED_PREFIXES.find((prefix) => name.startsWith(prefix)) ?? ''} prefix ships it to the browser.` + ) + this.name = 'EnvSecretExposureError' + this.variable = name + } +} + +const VAR_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/ +const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function parseEnv(text: string): EnvFile { + const eol = text.includes('\r\n') ? '\r\n' : '\n' + const normalized = text.replaceAll('\r\n', '\n') + const trailingNewline = normalized.endsWith('\n') + const source = normalized === '' ? [] : normalized.split('\n') + if (trailingNewline) { + source.pop() + } + const lines: EnvLine[] = [] + for (let index = 0; index < source.length; index += 1) { + const line = source[index] ?? '' + const match = VAR_LINE.exec(line) + if (match === null) { + lines.push({ kind: 'other', raw: line }) + continue + } + const key = match[1] ?? '' + let rest = match[2] ?? '' + const quote = rest[0] + if (quote === '"' || quote === "'" || quote === '`') { + // A quoted value may continue over the following lines. + const rawLines = [line] + let body = rest.slice(1) + let close = findClosingQuote(body, quote) + while (close === -1 && index + 1 < source.length) { + index += 1 + const next = source[index] ?? '' + rawLines.push(next) + body = `${body}\n${next}` + close = findClosingQuote(body, quote) + } + const inner = close === -1 ? body : body.slice(0, close) + const value = quote === '"' ? unescapeDouble(inner) : inner + lines.push({ kind: 'var', key, value, raw: rawLines.join('\n') }) + continue + } + const comment = rest.search(/\s#/) + if (comment !== -1) { + rest = rest.slice(0, comment) + } + lines.push({ kind: 'var', key, value: rest.trim(), raw: line }) + } + return { lines, eol, trailingNewline } +} + +function findClosingQuote(body: string, quote: string): number { + for (let index = 0; index < body.length; index += 1) { + const char = body[index] + if (quote === '"' && char === '\\') { + index += 1 + continue + } + if (char === quote) { + return index + } + } + return -1 +} + +function unescapeDouble(value: string): string { + return value.replace(/\\([nrt"\\$])/g, (_, char: string) => { + switch (char) { + case 'n': + return '\n' + case 'r': + return '\r' + case 't': + return '\t' + default: + return char + } + }) +} + +export function serializeEnv(file: EnvFile): string { + if (file.lines.length === 0) { + return '' + } + const body = file.lines.map((line) => line.raw).join('\n') + const text = file.trailingNewline ? `${body}\n` : body + return file.eol === '\r\n' ? text.replaceAll('\n', '\r\n') : text +} + +/** The LAST assignment wins, as every dotenv loader reads it. */ +export function getEnvValue(file: EnvFile, name: string): string | undefined { + let value: string | undefined + for (const line of file.lines) { + if (line.kind === 'var' && line.key === name) { + value = line.value + } + } + return value +} + +/** `NAME=value`, quoted only when the value needs it. */ +export function formatEnvAssignment(name: string, value: string): string { + if (/^[A-Za-z0-9_./:@+,=-]*$/.test(value)) { + return `${name}=${value}` + } + if (!value.includes("'") && !/[\r\n]/.test(value)) { + // Single quotes: literal, never `$`-expanded by dotenv-expand. + return `${name}='${value}'` + } + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('$', '\\$') + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n') + return `${name}="${escaped}"` +} + +/** + * Makes sure `name` has a value, without ever replacing one: absent → + * appended (`added`), present but empty → set in place (`filled`), + * present → untouched (`kept-existing`). Secrets (the default) refuse a + * browser-exposed name before anything changes. + */ +export function ensureVar( + file: EnvFile, + name: string, + value: string, + options: { readonly secret?: boolean } = {} +): { readonly file: EnvFile; readonly result: EnsureVarResult } { + if (!VALID_NAME.test(name)) { + throw new Error(`Invalid environment variable name: ${name}`) + } + if (options.secret !== false && isClientExposedName(name)) { + throw new EnvSecretExposureError(name) + } + const existing = getEnvValue(file, name) + if (existing !== undefined && existing !== '') { + return { file, result: { name, action: 'kept-existing' } } + } + const assignment = formatEnvAssignment(name, value) + if (existing === '') { + let lastIndex = -1 + file.lines.forEach((line, index) => { + if (line.kind === 'var' && line.key === name) { + lastIndex = index + } + }) + const lines = file.lines.map((line, index) => { + if (index !== lastIndex || line.kind !== 'var') { + return line + } + const exported = /^\s*export\s/.test(line.raw) + return { + kind: 'var' as const, + key: name, + value, + raw: `${exported ? 'export ' : ''}${assignment}`, + } + }) + return { file: { ...file, lines }, result: { name, action: 'filled' } } + } + return { + file: { + ...file, + // A file without a final newline gets one, so the new line stands alone. + trailingNewline: true, + lines: [ + ...file.lines, + { kind: 'var', key: name, value, raw: assignment }, + ], + }, + result: { name, action: 'added' }, + } +} + +/** + * `ensureVar` over several variables, from file text (`null` = no file + * yet) to file text. Results carry names and actions only. + */ +export function ensureEnvVars( + text: string | null, + vars: ReadonlyArray<{ + readonly name: string + readonly value: string + readonly secret?: boolean + }> +): { readonly text: string; readonly results: readonly EnsureVarResult[] } { + let file = parseEnv(text ?? '') + const results: EnsureVarResult[] = [] + for (const entry of vars) { + const next = ensureVar( + file, + entry.name, + entry.value, + entry.secret === undefined ? {} : { secret: entry.secret } + ) + file = next.file + results.push(next.result) + } + return { text: serializeEnv(file), results } +} diff --git a/src/lib/project/fs-writer.ts b/src/lib/project/fs-writer.ts new file mode 100644 index 0000000..a4b6fc7 --- /dev/null +++ b/src/lib/project/fs-writer.ts @@ -0,0 +1,421 @@ +import { createHash, randomBytes } from 'node:crypto' +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { unifiedDiff } from './diff' + +/** + * Every file a project command touches goes through a `ProjectWriter`: + * + * - files are create-only unless the caller proves the current content is + * the CLI's own (a recorded sha256 of what it last wrote) or asks to + * overwrite a file it merged itself; + * - shared files (AGENTS.md, .gitignore) are edited only inside a marked + * block (`upsertBlock`), never outside it; + * - every write is atomic (temp file + rename in the same directory); + * - in dry-run mode nothing is written and the same calls return the plan. + * + * The writer never throws for a file it decides not to touch: the decision + * is the change's `action`, so a dry run and a real run report alike. + */ + +export type ChangeAction = + /** The file did not exist and was (or would be) written. */ + | 'created' + /** The file existed and was (or would be) rewritten. */ + | 'updated' + /** The file already has exactly this content. */ + | 'unchanged' + /** The file differs and is not the CLI's to replace: left as it is. */ + | 'kept-user-version' + /** The file does not exist and was not created (inspection, opt-out). */ + | 'missing' + +export type PlannedChange = { + /** The path as the caller named it (relative to the root, or absolute). */ + readonly path: string + readonly action: ChangeAction + /** sha256 of the content now on disk (or that would be), LF-normalized. */ + readonly sha256?: string + /** Unified diff of a change that writes (dry-run previews and reports). */ + readonly diff?: string + /** Why a file was left alone. */ + readonly note?: string +} + +export type BlockMarkers = { readonly start: string; readonly end: string } + +/** Markers for Markdown (AGENTS.md, CLAUDE.md). */ +export const HTML_BLOCK_MARKERS: BlockMarkers = { + start: '', + end: '', +} + +/** Markers for `#`-comment files (.gitignore, .env, YAML, TOML). */ +export const HASH_BLOCK_MARKERS: BlockMarkers = { + start: '# brew:start', + end: '# brew:end', +} + +/** Line endings are a checkout detail (autocrlf): hashes ignore them. */ +export function normalizeEol(text: string): string { + return text.replaceAll('\r\n', '\n') +} + +export function sha256Hex(text: string): string { + return createHash('sha256').update(normalizeEol(text), 'utf8').digest('hex') +} + +function usesCrlf(text: string): boolean { + return text.includes('\r\n') +} + +function withEol(text: string, crlf: boolean): string { + const lf = normalizeEol(text) + return crlf ? lf.replaceAll('\n', '\r\n') : lf +} + +/** Reads a UTF-8 file, or `null` when it does not exist. */ +export function readTextFile(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} + +/** + * Writes `content` to `path` atomically: a temp file beside it, then a + * rename, so a crash or Ctrl-C never leaves a half-written config. `mode` + * applies to the new file (0o600 for secrets); without it an existing + * file keeps its mode. + */ +export function writeFileAtomic( + path: string, + content: string, + options: { readonly mode?: number } = {} +): void { + mkdirSync(dirname(path), { recursive: true }) + let mode = options.mode + if (mode === undefined) { + try { + mode = statSync(path).mode & 0o777 + } catch { + mode = undefined + } + } + const tmp = `${path}.brew-tmp-${process.pid}-${randomBytes(4).toString('hex')}` + try { + writeFileSync(tmp, content, { + encoding: 'utf8', + ...(mode === undefined ? {} : { mode }), + }) + if (mode !== undefined) { + chmodSync(tmp, mode) + } + renameSync(tmp, path) + } catch (error) { + rmSync(tmp, { force: true }) + throw error + } +} + +export type WriteFileOptions = { + /** File mode for a created (or rewritten) file, e.g. 0o600. */ + readonly mode?: number + /** + * sha256 of what the CLI last wrote here (from `brew.json#files`). When + * the file on disk still hashes to it, nobody edited it since, so the CLI + * may replace it. Any other content is the user's: kept. + */ + readonly managedSha256?: string + /** + * Replace whatever is there. Only for files whose new content the caller + * derived FROM the current content (a JSON merge), so nothing is lost. + */ + readonly overwrite?: boolean +} + +export type UpsertBlockOptions = { + readonly markers?: BlockMarkers + readonly mode?: number + /** Create the file when it does not exist (default true). */ + readonly createIfMissing?: boolean +} + +export class ProjectWriter { + readonly root: string + readonly dryRun: boolean + readonly #changes: PlannedChange[] = [] + + constructor(options: { readonly root: string; readonly dryRun?: boolean }) { + this.root = resolve(options.root) + this.dryRun = options.dryRun === true + } + + /** Every change planned (dry run) or made so far, in call order. */ + get changes(): readonly PlannedChange[] { + return this.#changes + } + + /** + * Absolute location of a path. Relative paths must stay inside the root: + * `../x` is a programming error, never a user's file. + */ + resolvePath(path: string): string { + if (isAbsolute(path)) { + return path + } + const absolute = resolve(this.root, path) + const rel = relative(this.root, absolute) + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error(`Refusing to write outside the project root: ${path}`) + } + return absolute + } + + read(path: string): string | null { + return readTextFile(this.resolvePath(path)) + } + + /** + * A create-only file (see `WriteFileOptions` for the two ways an existing + * file may still be replaced). An existing file with the same content + * (line endings aside) is `unchanged` and is not rewritten. + */ + writeFile( + path: string, + content: string, + options: WriteFileOptions = {} + ): PlannedChange { + const absolute = this.resolvePath(path) + const existing = readTextFile(absolute) + const sha256 = sha256Hex(content) + if (existing === null) { + return this.#commit(absolute, { + path, + action: 'created', + sha256, + content, + before: '', + mode: options.mode, + }) + } + if (normalizeEol(existing) === normalizeEol(content)) { + return this.#record({ path, action: 'unchanged', sha256 }) + } + const isOurs = + options.overwrite === true || + (options.managedSha256 !== undefined && + sha256Hex(existing) === options.managedSha256) + if (!isOurs) { + return this.#record({ + path, + action: 'kept-user-version', + sha256: sha256Hex(existing), + note: + options.managedSha256 === undefined + ? 'exists with different content' + : 'edited since the CLI wrote it', + }) + } + return this.#commit(absolute, { + path, + action: 'updated', + sha256, + content: withEol(content, usesCrlf(existing)), + before: existing, + mode: options.mode, + }) + } + + /** + * Replaces the text between `markers` (or appends a marked block when the + * file has none), leaving every byte outside the block alone. The file's + * own line endings are kept. Unbalanced markers (a start without an end, + * duplicates) are the user's edit: the file is kept as is. + */ + upsertBlock( + path: string, + content: string, + options: UpsertBlockOptions = {} + ): PlannedChange { + const markers = options.markers ?? HTML_BLOCK_MARKERS + const absolute = this.resolvePath(path) + const existing = readTextFile(absolute) + const body = normalizeEol(content).replace(/\n+$/, '') + const block = `${markers.start}\n${body}\n${markers.end}` + if (existing === null) { + if (options.createIfMissing === false) { + return this.#record({ path, action: 'missing' }) + } + const created = `${block}\n` + return this.#commit(absolute, { + path, + action: 'created', + sha256: sha256Hex(created), + content: created, + before: '', + mode: options.mode, + }) + } + const crlf = usesCrlf(existing) + const text = normalizeEol(existing) + const located = locateBlock(text, markers) + if (located === 'unbalanced') { + return this.#record({ + path, + action: 'kept-user-version', + sha256: sha256Hex(existing), + note: `unbalanced ${markers.start} / ${markers.end} markers`, + }) + } + let next: string + if (located === null) { + const separator = + text === '' + ? '' + : text.endsWith('\n\n') + ? '' + : text.endsWith('\n') + ? '\n' + : '\n\n' + next = `${text}${separator}${block}\n` + } else { + next = `${text.slice(0, located.start)}${block}${text.slice(located.end)}` + } + if (next === text) { + return this.#record({ + path, + action: 'unchanged', + sha256: sha256Hex(existing), + }) + } + return this.#commit(absolute, { + path, + action: 'updated', + sha256: sha256Hex(next), + content: withEol(next, crlf), + before: existing, + mode: options.mode, + }) + } + + /** + * Where a file the CLI wrote earlier stands now, without writing: + * `unchanged` (still the recorded hash), `kept-user-version` (edited + * since) or `missing` (deleted). + */ + inspectManaged(path: string, recordedSha256: string): PlannedChange { + const existing = this.read(path) + if (existing === null) { + return { path, action: 'missing' } + } + const sha256 = sha256Hex(existing) + return sha256 === recordedSha256 + ? { path, action: 'unchanged', sha256 } + : { + path, + action: 'kept-user-version', + sha256, + note: 'edited since the CLI wrote it', + } + } + + #record(change: PlannedChange): PlannedChange { + this.#changes.push(change) + return change + } + + #commit( + absolute: string, + input: { + readonly path: string + readonly action: 'created' | 'updated' + readonly sha256: string + readonly content: string + readonly before: string + readonly mode: number | undefined + } + ): PlannedChange { + if (!this.dryRun) { + writeFileAtomic( + absolute, + input.content, + input.mode === undefined ? {} : { mode: input.mode } + ) + } + return this.#record({ + path: input.path, + action: input.action, + sha256: input.sha256, + diff: unifiedDiff(input.before, input.content, { + fromPath: input.path, + }), + }) + } +} + +/** + * The block's span (from its start marker through its end marker) in + * LF-normalized text: `null` when there is none, `'unbalanced'` when the + * markers do not pair up exactly once. Markers match whole lines only. + */ +function locateBlock( + text: string, + markers: BlockMarkers +): { readonly start: number; readonly end: number } | null | 'unbalanced' { + const starts: number[] = [] + const ends: number[] = [] + let offset = 0 + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (trimmed === markers.start) { + starts.push(offset) + } else if (trimmed === markers.end) { + ends.push(offset + line.length) + } + offset += line.length + 1 + } + if (starts.length === 0 && ends.length === 0) { + return null + } + const [start] = starts + const [end] = ends + if ( + starts.length !== 1 || + ends.length !== 1 || + start === undefined || + end === undefined || + end < start + ) { + return 'unbalanced' + } + return { start, end } +} + +/** The text between a file's markers, or `null` when it has no block. */ +export function readBlock( + text: string, + markers: BlockMarkers = HTML_BLOCK_MARKERS +): string | null { + const normalized = normalizeEol(text) + const located = locateBlock(normalized, markers) + if (located === null || located === 'unbalanced') { + return null + } + const lines = normalized.split('\n') + const first = lines.findIndex((line) => line.trim() === markers.start) + const last = lines.findIndex((line) => line.trim() === markers.end) + return lines.slice(first + 1, last).join('\n') +} diff --git a/src/lib/project/gitignore.ts b/src/lib/project/gitignore.ts new file mode 100644 index 0000000..0bcb6d9 --- /dev/null +++ b/src/lib/project/gitignore.ts @@ -0,0 +1,215 @@ +import { join } from 'node:path' +import type { SpawnFn } from '../types' +import { + HASH_BLOCK_MARKERS, + type PlannedChange, + type ProjectWriter, + readBlock, + readTextFile, +} from './fs-writer' +import { defaultSpawn } from './process' + +/** + * Whether git would ignore a path, so a secrets file is never left one + * `git add .` away from a commit. Git itself answers (`git check-ignore` + * honours every .gitignore, `.git/info/exclude` and the global excludes + * file); outside a repository, or without git, a small parser reads the + * root `.gitignore`. + */ +export async function isIgnored( + path: string, + options: { + /** The project root the path is relative to. */ + readonly cwd: string + readonly spawn?: SpawnFn + readonly signal?: AbortSignal + } +): Promise { + const spawn = options.spawn ?? defaultSpawn + try { + const result = await spawn('git', ['check-ignore', '-q', '--', path], { + cwd: options.cwd, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }) + if (result.code === 0) { + return true + } + if (result.code === 1) { + return false + } + // 128: not a repository (or another fatal error) → read the file. + } catch { + options.signal?.throwIfAborted() + // git is not installed (ENOENT) → read the file. + } + const text = readTextFile(join(options.cwd, '.gitignore')) + return text === null ? false : matchesGitignore(text, path) +} + +type Rule = { + readonly negate: boolean + readonly dirOnly: boolean + readonly regex: RegExp +} + +function globToRegex(glob: string): string { + let out = '' + for (let index = 0; index < glob.length; index += 1) { + const char = glob[index] ?? '' + if (char === '*') { + if (glob[index + 1] === '*') { + const before = index === 0 || glob[index - 1] === '/' + const after = glob[index + 2] + if (before && after === '/') { + out += '(?:.*/)?' + index += 2 + continue + } + if (before && after === undefined) { + out += '.*' + index += 1 + continue + } + } + out += '[^/]*' + } else if (char === '?') { + out += '[^/]' + } else if (char === '[') { + const close = glob.indexOf(']', index + 2) + if (close === -1) { + out += '\\[' + continue + } + let body = glob.slice(index + 1, close) + if (body.startsWith('!')) { + body = `^${body.slice(1)}` + } + out += `[${body.replaceAll('\\', '\\\\')}]` + index = close + } else if (char === '\\' && index + 1 < glob.length) { + index += 1 + out += (glob[index] ?? '').replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + } else { + out += char.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + } + } + return out +} + +function parseRules(text: string): Rule[] { + const rules: Rule[] = [] + for (const rawLine of text.replaceAll('\r\n', '\n').split('\n')) { + let line = rawLine.replace(/(? segment !== '') + for (let depth = 1; depth < segments.length; depth += 1) { + if (lastMatch(rules, segments.slice(0, depth).join('/'), true) === true) { + return true + } + } + return lastMatch(rules, segments.join('/'), isDir) === true +} + +/** + * Adds every path git would not ignore to a `# brew:start` / `# brew:end` + * block in the root `.gitignore` (created when missing). Lines outside + * the block are never touched. Returns what it added, and the change when + * it had to write. + */ +export async function ensureIgnored( + writer: ProjectWriter, + paths: readonly string[], + options: { readonly spawn?: SpawnFn; readonly signal?: AbortSignal } = {} +): Promise<{ + readonly added: readonly string[] + readonly change?: PlannedChange +}> { + const added: string[] = [] + for (const path of paths) { + const ignored = await isIgnored(path, { + cwd: writer.root, + ...(options.spawn === undefined ? {} : { spawn: options.spawn }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }) + if (!ignored) { + added.push(path) + } + } + if (added.length === 0) { + return { added } + } + const current = writer.read('.gitignore') + const existing = + current === null + ? [] + : (readBlock(current, HASH_BLOCK_MARKERS) ?? '').split('\n') + const lines = [ + ...existing.filter((line) => line.trim() !== ''), + ...added.filter((path) => !existing.includes(path)), + ] + const change = writer.upsertBlock('.gitignore', lines.join('\n'), { + markers: HASH_BLOCK_MARKERS, + }) + return { added, change } +} diff --git a/src/lib/project/monorepo.ts b/src/lib/project/monorepo.ts new file mode 100644 index 0000000..ec7882a --- /dev/null +++ b/src/lib/project/monorepo.ts @@ -0,0 +1,400 @@ +import { existsSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { readTextFile } from './fs-writer' + +/** + * Where a project command should act: the app the user is standing in, + * the monorepo around it (pnpm / npm / yarn / bun workspaces, Turborepo, + * Nx), and every workspace package that looks like an app (it depends on a + * web framework or server library) for a caller to choose from. + */ + +export type MonorepoTool = 'turbo' | 'nx' | 'pnpm' | 'workspaces' + +export type WorkspacePackage = { + /** `package.json#name`, or the directory name when unnamed. */ + readonly name: string + /** Relative to the monorepo root, POSIX separators. */ + readonly dir: string +} + +export type AppCandidate = WorkspacePackage & { + /** The framework its dependencies name (`next`, `vite`, `express`, …). */ + readonly framework: string +} + +export type MonorepoInfo = { + /** The orchestrator when there is one (turbo, nx), else the workspace source. */ + readonly tool: MonorepoTool + readonly root: string + /** The workspace package containing the cwd; `null` at the root. */ + readonly workspace: WorkspacePackage | null + /** The workspace globs (`apps/*`, `packages/*`). */ + readonly patterns: readonly string[] +} + +export type ResolvedProject = { + /** The directory to act on: the cwd's package (or the cwd without one). */ + readonly root: string + readonly monorepo: MonorepoInfo | null + /** + * App packages: in a monorepo, every workspace that depends on a + * framework; otherwise the root package when it does. + */ + readonly candidates: readonly AppCandidate[] +} + +/** + * Dependency → framework id, checked in order (a Next app that also lists + * `react` is `next`). Server libraries count: an Express API sends email + * too. + */ +export const FRAMEWORK_DEPENDENCIES: ReadonlyArray< + readonly [dependency: string, framework: string] +> = [ + ['next', 'next'], + ['@remix-run/react', 'remix'], + ['@react-router/dev', 'react-router'], + ['@tanstack/react-start', 'tanstack-start'], + ['@sveltejs/kit', 'sveltekit'], + ['nuxt', 'nuxt'], + ['astro', 'astro'], + ['@angular/core', 'angular'], + ['@solidjs/start', 'solid-start'], + ['expo', 'expo'], + ['@nestjs/core', 'nestjs'], + ['hono', 'hono'], + ['fastify', 'fastify'], + ['express', 'express'], + ['koa', 'koa'], + ['vite', 'vite'], + ['react-scripts', 'create-react-app'], +] + +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next']) +const MAX_GLOB_DEPTH = 6 + +export function readJsonObject(path: string): Record | null { + const text = readTextFile(path) + if (text === null) { + return null + } + try { + const parsed: unknown = JSON.parse(text) + return typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +/** The framework a package.json depends on, or `null`. */ +export function detectFramework( + pkg: Record | null +): string | null { + if (pkg === null) { + return null + } + const deps = new Set() + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + const record = pkg[field] + if (typeof record === 'object' && record !== null) { + for (const name of Object.keys(record)) { + deps.add(name) + } + } + } + for (const [dependency, framework] of FRAMEWORK_DEPENDENCIES) { + if (deps.has(dependency)) { + return framework + } + } + return null +} + +/** The `packages:` list of a pnpm-workspace.yaml (the only key we need). */ +export function parsePnpmWorkspaceYaml(text: string): string[] { + const patterns: string[] = [] + let inPackages = false + for (const raw of text.replaceAll('\r\n', '\n').split('\n')) { + const line = raw.replace(/\s+#.*$/, '') + if (/^packages\s*:/.test(line)) { + inPackages = true + const inline = /^packages\s*:\s*\[(.*)\]\s*$/.exec(line) + if (inline !== null) { + for (const item of (inline[1] ?? '').split(',')) { + const value = unquote(item.trim()) + if (value !== '') { + patterns.push(value) + } + } + inPackages = false + } + continue + } + if (!inPackages) { + continue + } + const item = /^\s*-\s*(.+?)\s*$/.exec(line) + if (item !== null) { + patterns.push(unquote(item[1] ?? '')) + } else if (/^\S/.test(line)) { + inPackages = false + } + } + return patterns.filter((pattern) => pattern !== '') +} + +function unquote(value: string): string { + return value.replace(/^(['"])(.*)\1$/, '$2') +} + +function workspacePatterns(dir: string): { + readonly source: 'pnpm' | 'workspaces' + readonly patterns: string[] +} | null { + const pnpm = readTextFile(join(dir, 'pnpm-workspace.yaml')) + if (pnpm !== null) { + return { source: 'pnpm', patterns: parsePnpmWorkspaceYaml(pnpm) } + } + const pkg = readJsonObject(join(dir, 'package.json')) + const field = pkg?.workspaces + const list = Array.isArray(field) + ? field + : typeof field === 'object' && field !== null + ? (field as { packages?: unknown }).packages + : undefined + if (Array.isArray(list)) { + return { + source: 'workspaces', + patterns: list.filter((item): item is string => typeof item === 'string'), + } + } + return null +} + +/** A root turbo.json; a package-level one `extends` the root's (`["//"]`). */ +function hasRootTurboJson(dir: string): boolean { + const turbo = readJsonObject(join(dir, 'turbo.json')) + return turbo !== null && turbo.extends === undefined +} + +function hasMarker(dir: string): boolean { + return ( + existsSync(join(dir, 'pnpm-workspace.yaml')) || + hasRootTurboJson(dir) || + existsSync(join(dir, 'nx.json')) || + workspacePatterns(dir) !== null + ) +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function segmentRegex(segment: string): RegExp { + const body = segment + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replaceAll('*', '[^/]*') + .replaceAll('?', '[^/]') + return new RegExp(`^${body}$`) +} + +/** Directories (root-relative, POSIX) matched by one workspace glob. */ +function expandGlob(root: string, pattern: string): string[] { + const segments = pattern + .replace(/^\.\//, '') + .replace(/\/+$/, '') + .split('/') + .filter((segment) => segment !== '') + const results: string[] = [] + const walk = (dir: string, index: number, depth: number): void => { + if (depth > MAX_GLOB_DEPTH) { + return + } + if (index === segments.length) { + results.push(dir) + return + } + const segment = segments[index] ?? '' + const absolute = join(root, dir) + if (segment === '**') { + walk(dir, index + 1, depth) + for (const child of listDirs(absolute)) { + walk(dir === '' ? child : `${dir}/${child}`, index, depth + 1) + } + return + } + if (!/[*?]/.test(segment)) { + const next = dir === '' ? segment : `${dir}/${segment}` + if (isDirectory(join(root, next))) { + walk(next, index + 1, depth + 1) + } + return + } + const regex = segmentRegex(segment) + for (const child of listDirs(absolute)) { + if (regex.test(child)) { + walk(dir === '' ? child : `${dir}/${child}`, index + 1, depth + 1) + } + } + } + walk('', 0, 0) + return results +} + +function listDirs(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !SKIP_DIRS.has(entry.name)) + .map((entry) => entry.name) + .sort() + } catch { + return [] + } +} + +/** Workspace packages under a monorepo root (dirs that hold a package.json). */ +export function listWorkspacePackages( + root: string, + patterns: readonly string[] +): WorkspacePackage[] { + const include = new Set() + const exclude = new Set() + for (const pattern of patterns) { + const negated = pattern.startsWith('!') + const target = negated ? exclude : include + for (const dir of expandGlob(root, negated ? pattern.slice(1) : pattern)) { + target.add(dir) + } + } + const packages: WorkspacePackage[] = [] + for (const dir of [...include].sort()) { + if (dir === '' || exclude.has(dir)) { + continue + } + const pkg = readJsonObject(join(root, dir, 'package.json')) + if (pkg === null) { + continue + } + const name = + typeof pkg.name === 'string' && pkg.name !== '' + ? pkg.name + : (dir.split('/').at(-1) ?? dir) + packages.push({ name, dir }) + } + return packages +} + +/** Walk up from `start`, stopping after the directory that holds `.git`. */ +function* ancestors(start: string): Generator { + let current = resolve(start) + for (;;) { + yield current + if (existsSync(join(current, '.git'))) { + return + } + const parent = dirname(current) + if (parent === current) { + return + } + current = parent + } +} + +function toPosix(path: string): string { + return path.split(sep).join('/') +} + +/** The monorepo `cwd` sits in, if any. */ +export function findMonorepo(cwd: string): MonorepoInfo | null { + for (const dir of ancestors(cwd)) { + if (!hasMarker(dir)) { + continue + } + const workspaces = workspacePatterns(dir) + const tool: MonorepoTool = hasRootTurboJson(dir) + ? 'turbo' + : existsSync(join(dir, 'nx.json')) + ? 'nx' + : (workspaces?.source ?? 'workspaces') + const patterns = + workspaces !== null && workspaces.patterns.length > 0 + ? workspaces.patterns + : ['apps/*', 'packages/*', 'libs/*'] + const relativeCwd = toPosix(relative(dir, resolve(cwd))) + const packages = listWorkspacePackages(dir, patterns) + const workspace = + relativeCwd === '' + ? null + : (packages + .filter( + (pkg) => + relativeCwd === pkg.dir || relativeCwd.startsWith(`${pkg.dir}/`) + ) + .sort((a, b) => b.dir.length - a.dir.length)[0] ?? null) + return { tool, root: dir, workspace, patterns } + } + return null +} + +/** + * The project to act on from `cwd`: + * - in a monorepo workspace package → that package, candidates = every app + * workspace; + * - at a monorepo root → the root, candidates = every app workspace (more + * than one is the caller's ambiguity to resolve); + * - otherwise the nearest directory with a package.json (or `cwd` itself + * when there is none), candidates = that package when it is an app. + */ +export function resolveProjectRoot(cwd: string): ResolvedProject { + const monorepo = findMonorepo(cwd) + if (monorepo !== null) { + const candidates: AppCandidate[] = [] + for (const pkg of listWorkspacePackages(monorepo.root, monorepo.patterns)) { + const framework = detectFramework( + readJsonObject(join(monorepo.root, pkg.dir, 'package.json')) + ) + if (framework !== null) { + candidates.push({ ...pkg, framework }) + } + } + return { + root: + monorepo.workspace === null + ? monorepo.root + : join(monorepo.root, monorepo.workspace.dir), + monorepo, + candidates, + } + } + for (const dir of ancestors(cwd)) { + const pkg = readJsonObject(join(dir, 'package.json')) + if (pkg === null) { + continue + } + const framework = detectFramework(pkg) + return { + root: dir, + monorepo: null, + candidates: + framework === null + ? [] + : [ + { + name: typeof pkg.name === 'string' ? pkg.name : '.', + dir: '.', + framework, + }, + ], + } + } + return { root: resolve(cwd), monorepo: null, candidates: [] } +} diff --git a/src/lib/project/package-manager.ts b/src/lib/project/package-manager.ts new file mode 100644 index 0000000..b776dd4 --- /dev/null +++ b/src/lib/project/package-manager.ts @@ -0,0 +1,430 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { shellQuote } from '../confirm' +import { readTextFile } from './fs-writer' + +/** + * Which package manager a project uses, and the exact command lines to add + * a dependency, install, or run a binary with it — workspace-aware, so a + * monorepo app gets `pnpm add x --filter web`, not a root install. + * + * Evidence, strongest first: `package.json#packageManager` (Corepack's + * pin), then lockfiles, then the `npm_config_user_agent` of the process + * that launched us (`pnpm dlx …`, `bunx …`), then npm. + */ + +export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'deno' + +export type PackageManager = { + readonly name: PackageManagerName + /** From `packageManager` or the user agent, when known. */ + readonly version?: string + /** Yarn only: v1 (`classic`) or v2+ (`berry`). */ + readonly yarnFlavor?: 'classic' | 'berry' + /** Yarn berry with Plug'n'Play: no node_modules directory. */ + readonly pnp?: boolean + readonly source: 'packageManager' | 'lockfile' | 'user-agent' | 'default' + /** The lockfile that decided it (relative to its directory). */ + readonly lockfile?: string + /** Every lockfile found, when more than one manager's are present. */ + readonly conflictingLockfiles?: readonly string[] +} + +const LOCKFILES: ReadonlyArray<{ + readonly file: string + readonly name: PackageManagerName +}> = [ + { file: 'bun.lock', name: 'bun' }, + { file: 'bun.lockb', name: 'bun' }, + { file: 'pnpm-lock.yaml', name: 'pnpm' }, + { file: 'yarn.lock', name: 'yarn' }, + { file: 'package-lock.json', name: 'npm' }, + { file: 'npm-shrinkwrap.json', name: 'npm' }, + { file: 'deno.lock', name: 'deno' }, +] + +const NAMES: readonly PackageManagerName[] = [ + 'npm', + 'pnpm', + 'yarn', + 'bun', + 'deno', +] + +function isName(value: string): value is PackageManagerName { + return (NAMES as readonly string[]).includes(value) +} + +function readPackageJson(dir: string): Record | null { + const text = readTextFile(join(dir, 'package.json')) + if (text === null) { + return null + } + try { + const parsed: unknown = JSON.parse(text) + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : null + } catch { + return null + } +} + +/** `pnpm@9.1.0+sha512.…` → `{ name: 'pnpm', version: '9.1.0' }`. */ +export function parsePackageManagerField( + value: unknown +): { readonly name: PackageManagerName; readonly version: string } | null { + if (typeof value !== 'string') { + return null + } + const match = /^([a-z]+)@(\d[^+\s]*)/.exec(value.trim()) + const name = match?.[1] + const version = match?.[2] + if (name === undefined || version === undefined || !isName(name)) { + return null + } + return { name, version } +} + +/** `pnpm/9.1.0 npm/? node/v20.11.0 darwin arm64` → pnpm 9.1.0. */ +export function parseUserAgent( + value: string | undefined +): { readonly name: PackageManagerName; readonly version: string } | null { + const match = /^([a-z]+)\/(\S+)/.exec(value ?? '') + const name = match?.[1] + const version = match?.[2] + if (name === undefined || version === undefined || !isName(name)) { + return null + } + return { name, version } +} + +function majorOf(version: string | undefined): number | undefined { + const major = Number.parseInt(version ?? '', 10) + return Number.isNaN(major) ? undefined : major +} + +/** `nodeLinker` from `.yarnrc.yml` (berry), or undefined when unset. */ +function readNodeLinker(dir: string): string | undefined { + const text = readTextFile(join(dir, '.yarnrc.yml')) + if (text === null) { + return undefined + } + const match = /^\s*nodeLinker\s*:\s*["']?([\w-]+)["']?\s*$/m.exec(text) + return match?.[1] +} + +function yarnDetails( + dir: string, + version: string | undefined +): Pick { + const major = majorOf(version) + const lock = readTextFile(join(dir, 'yarn.lock')) + const hasYarnrcYml = existsSync(join(dir, '.yarnrc.yml')) + const isBerry = + major !== undefined + ? major >= 2 + : hasYarnrcYml || lock?.includes('__metadata:') === true + if (!isBerry) { + return { yarnFlavor: 'classic', pnp: false } + } + const linker = readNodeLinker(dir) + // Berry's default linker is Plug'n'Play. + const pnp = + linker === undefined + ? !existsSync(join(dir, 'node_modules')) || + existsSync(join(dir, '.pnp.cjs')) + : linker === 'pnp' + return { yarnFlavor: 'berry', pnp } +} + +function describe( + dir: string, + name: PackageManagerName, + rest: Omit +): PackageManager { + return { + name, + ...rest, + ...(name === 'yarn' ? yarnDetails(dir, rest.version) : {}), + } +} + +/** + * The package manager for a project. `dirs` is where to look, nearest + * first: an app directory, then its monorepo root (lockfiles live at the + * root). `env` supplies `npm_config_user_agent`. + */ +export function detectPackageManager( + dirs: readonly string[], + env: Readonly> = {} +): PackageManager { + for (const dir of dirs) { + const pinned = parsePackageManagerField( + readPackageJson(dir)?.packageManager + ) + if (pinned !== null) { + return describe(dir, pinned.name, { + version: pinned.version, + source: 'packageManager', + }) + } + } + for (const dir of dirs) { + const found = LOCKFILES.filter((entry) => existsSync(join(dir, entry.file))) + const first = found[0] + if (first !== undefined) { + const managers = new Set(found.map((entry) => entry.name)) + return describe(dir, first.name, { + source: 'lockfile', + lockfile: first.file, + ...(managers.size > 1 + ? { conflictingLockfiles: found.map((entry) => entry.file) } + : {}), + }) + } + const isDenoOnly = + !existsSync(join(dir, 'package.json')) && + (existsSync(join(dir, 'deno.json')) || + existsSync(join(dir, 'deno.jsonc'))) + if (isDenoOnly) { + return { name: 'deno', source: 'lockfile', lockfile: 'deno.json' } + } + } + const agent = parseUserAgent(env.npm_config_user_agent) + if (agent !== null) { + return describe(dirs[0] ?? '.', agent.name, { + version: agent.version, + source: 'user-agent', + }) + } + return { name: 'npm', source: 'default' } +} + +/** A command line to run, from the project root (or `cwd` below it). */ +export type CommandLine = { + readonly command: string + readonly args: readonly string[] + /** Run from this root-relative directory instead of the root. */ + readonly cwd?: string +} + +/** The workspace a command targets inside a monorepo. */ +export type WorkspaceTarget = { + /** `package.json#name` of the workspace package. */ + readonly name: string + /** Its directory, relative to the monorepo root (POSIX). */ + readonly dir: string +} + +export function formatCommandLine(line: CommandLine): string { + const text = [line.command, ...line.args].map(shellQuote).join(' ') + return line.cwd === undefined || line.cwd === '.' || line.cwd === '' + ? text + : `cd ${shellQuote(line.cwd)} && ${text}` +} + +export type AddOptions = { + readonly dev?: boolean + /** Add to this workspace package (monorepo), not the root. */ + readonly workspace?: WorkspaceTarget + /** Pin the exact version instead of a range. */ + readonly exact?: boolean +} + +/** `pnpm add @brew.new/sdk --filter web`, `npm install -D x -w web`, … */ +export function addCommand( + pm: PackageManager, + packages: readonly string[], + options: AddOptions = {} +): CommandLine { + const dev = options.dev === true + const exact = options.exact === true + const ws = options.workspace + switch (pm.name) { + case 'npm': + return { + command: 'npm', + args: [ + 'install', + ...(dev ? ['--save-dev'] : []), + ...(exact ? ['--save-exact'] : []), + ...(ws === undefined ? [] : ['--workspace', ws.name]), + ...packages, + ], + } + case 'pnpm': + return { + command: 'pnpm', + args: [ + 'add', + ...(dev ? ['--save-dev'] : []), + ...(exact ? ['--save-exact'] : []), + ...(ws === undefined ? [] : ['--filter', ws.name]), + ...packages, + ], + } + case 'yarn': + return { + command: 'yarn', + args: [ + ...(ws === undefined ? [] : ['workspace', ws.name]), + 'add', + ...(dev ? ['--dev'] : []), + ...(exact ? ['--exact'] : []), + ...packages, + ], + } + case 'bun': + // `bun add` has no workspace filter: run it inside the package. + return { + command: 'bun', + args: [ + 'add', + ...(dev ? ['--dev'] : []), + ...(exact ? ['--exact'] : []), + ...packages, + ], + ...(ws === undefined ? {} : { cwd: ws.dir }), + } + case 'deno': + return { + command: 'deno', + args: [ + 'add', + ...(dev ? ['--dev'] : []), + ...packages.map((spec) => + spec.startsWith('npm:') || spec.startsWith('jsr:') + ? spec + : `npm:${spec}` + ), + ], + ...(ws === undefined ? {} : { cwd: ws.dir }), + } + } +} + +/** Install every dependency (the lockfile's, when `frozen`). */ +export function installCommand( + pm: PackageManager, + options: { readonly frozen?: boolean } = {} +): CommandLine { + const frozen = options.frozen === true + switch (pm.name) { + case 'npm': + return { command: 'npm', args: frozen ? ['ci'] : ['install'] } + case 'pnpm': + return { + command: 'pnpm', + args: ['install', ...(frozen ? ['--frozen-lockfile'] : [])], + } + case 'yarn': + return { + command: 'yarn', + args: [ + 'install', + ...(frozen + ? [pm.yarnFlavor === 'berry' ? '--immutable' : '--frozen-lockfile'] + : []), + ], + } + case 'bun': + return { + command: 'bun', + args: ['install', ...(frozen ? ['--frozen-lockfile'] : [])], + } + case 'deno': + return { + command: 'deno', + args: ['install', ...(frozen ? ['--frozen'] : [])], + } + } +} + +/** Run a binary from the project's own dependencies. */ +export function execCommand( + pm: PackageManager, + bin: string, + args: readonly string[] = [], + options: { readonly workspace?: WorkspaceTarget } = {} +): CommandLine { + const ws = options.workspace + switch (pm.name) { + case 'npm': + return { + command: 'npm', + args: [ + 'exec', + ...(ws === undefined ? [] : ['--workspace', ws.name]), + '--', + bin, + ...args, + ], + } + case 'pnpm': + return { + command: 'pnpm', + args: [ + ...(ws === undefined ? [] : ['--filter', ws.name]), + 'exec', + bin, + ...args, + ], + } + case 'yarn': + if (pm.yarnFlavor === 'berry') { + return { + command: 'yarn', + args: [ + ...(ws === undefined ? [] : ['workspace', ws.name]), + 'exec', + bin, + ...args, + ], + } + } + // Classic has no `exec`: `yarn ` runs a dependency's binary. + return { + command: 'yarn', + args: [ + ...(ws === undefined ? [] : ['workspace', ws.name]), + bin, + ...args, + ], + } + case 'bun': + return { + command: 'bun', + args: ['x', bin, ...args], + ...(ws === undefined ? {} : { cwd: ws.dir }), + } + case 'deno': + return { + command: 'deno', + args: ['run', '-A', `npm:${bin}`, ...args], + ...(ws === undefined ? {} : { cwd: ws.dir }), + } + } +} + +/** Download and run a package once (`npx -y`, `pnpm dlx`, `bunx`). */ +export function dlxCommand( + pm: PackageManager, + spec: string, + args: readonly string[] = [] +): CommandLine { + switch (pm.name) { + case 'pnpm': + return { command: 'pnpm', args: ['dlx', spec, ...args] } + case 'yarn': + return pm.yarnFlavor === 'berry' + ? { command: 'yarn', args: ['dlx', spec, ...args] } + : { command: 'npx', args: ['-y', spec, ...args] } + case 'bun': + return { command: 'bunx', args: [spec, ...args] } + case 'deno': + return { command: 'deno', args: ['run', '-A', `npm:${spec}`, ...args] } + case 'npm': + return { command: 'npx', args: ['-y', spec, ...args] } + } +} diff --git a/src/lib/project/process.ts b/src/lib/project/process.ts new file mode 100644 index 0000000..32e6f33 --- /dev/null +++ b/src/lib/project/process.ts @@ -0,0 +1,82 @@ +import { spawn as nodeSpawn } from 'node:child_process' +import { homedir } from 'node:os' +import type { CliIo, SpawnFn, SpawnResult } from '../types' + +/** + * The real implementations behind the optional `CliIo` seams. Project + * commands read the seam first, so tests inject a fake `spawn`, a recording + * `openUrl` and a temp `cwd` and never touch the host. + */ + +/** The working directory a project command acts on. */ +export function resolveCwd(io: Pick): string { + return io.cwd ?? process.cwd() +} + +export function resolveSpawn(io: Pick): SpawnFn { + return io.spawn ?? defaultSpawn +} + +/** + * The user's home directory, read from the (injected) environment first so + * tests can point user-scope writes at a temp dir. + */ +export function resolveHomeDir( + env: Readonly> +): string { + const fromEnv = env.HOME ?? env.USERPROFILE + return fromEnv !== undefined && fromEnv !== '' ? fromEnv : homedir() +} + +const MAX_CAPTURE_BYTES = 1_000_000 + +/** `child_process.spawn` without a shell, stdout/stderr captured (bounded). */ +export const defaultSpawn: SpawnFn = (command, args, options = {}) => + new Promise((resolve, reject) => { + const child = nodeSpawn(command, [...args], { + cwd: options.cwd, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }) + let stdout = '' + let stderr = '' + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + if (stdout.length < MAX_CAPTURE_BYTES) { + stdout += chunk + } + }) + child.stderr?.on('data', (chunk: string) => { + if (stderr.length < MAX_CAPTURE_BYTES) { + stderr += chunk + } + }) + child.on('error', reject) + child.on('close', (code) => { + resolve({ code, stdout, stderr }) + }) + child.stdin?.end(options.input ?? '') + }) + +/** + * Hands a URL to the platform opener. Only absolute URLs with a scheme are + * accepted, and the URL is one argv entry (never shell-interpreted). + */ +export async function defaultOpenUrl( + url: string, + spawn: SpawnFn = defaultSpawn +): Promise { + if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) { + throw new Error('Refusing to open a URL without a scheme.') + } + const [command, args]: readonly [string, readonly string[]] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['rundll32', ['url.dll,FileProtocolHandler', url]] + : ['xdg-open', [url]] + await spawn(command, args) +} diff --git a/src/lib/project/state.ts b/src/lib/project/state.ts new file mode 100644 index 0000000..fb00a9f --- /dev/null +++ b/src/lib/project/state.ts @@ -0,0 +1,314 @@ +import { join } from 'node:path' +import { readTextFile } from './fs-writer' + +/** + * `brew.json`: the project file `init` writes and commits, so re-running it + * is a no-op and teammates' runs agree. It holds identifiers and hashes, + * never secrets. Validated by hand (no schema library: the CLI ships with + * no runtime dependencies beyond the SDK and commander) and written with a + * stable key order, so a re-run that changes nothing produces no diff. + */ + +export const BREW_STATE_FILE = 'brew.json' + +/** The JSON Schema shipped in this package (`schemas/brew.json`). */ +export const BREW_STATE_SCHEMA_URL = + 'https://unpkg.com/@brew.new/cli/schemas/brew.json' + +export const BREW_STATE_VERSION = 1 + +/** The platform's trigger-key grammar (shared contract). */ +export const TRIGGER_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/ + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const SHA256_PATTERN = /^[0-9a-f]{64}$/ + +export type BrewProjectEmail = { + readonly triggerEventId: string + readonly automationId: string +} + +export type BrewManagedFile = { + /** sha256 (hex, LF-normalized) of the content the CLI last wrote. */ + readonly sha256: string + /** What wrote it, e.g. `init@0.18.0:lib/brew.ts`. */ + readonly generator: string +} + +export type BrewProjectState = { + readonly $schema?: string + readonly version: 1 + readonly projectId: string + readonly brandId?: string + readonly framework?: string + readonly auth?: string + readonly contractsPath?: string + /** Trigger key → the trigger and automation that send it. */ + readonly emails: Readonly> + /** Project-relative path → what the CLI wrote there. */ + readonly files: Readonly> + /** Provider settings saved before changing them, for rollback. */ + readonly providerConfig?: Readonly> + /** MCP clients configured for this project. */ + readonly mcp?: readonly string[] +} + +/** Top-level keys this CLI does not know, kept verbatim on rewrite. */ +export type BrewStateExtra = Readonly> + +export type ParsedBrewState = + | { + readonly ok: true + readonly state: BrewProjectState + readonly extra: BrewStateExtra + } + | { readonly ok: false; readonly errors: readonly string[] } + +export class BrewStateError extends Error { + readonly errors: readonly string[] + + constructor(path: string, errors: readonly string[]) { + super(`${path} is not a valid brew.json: ${errors.join('; ')}`) + this.name = 'BrewStateError' + this.errors = errors + } +} + +const KNOWN_KEYS = [ + '$schema', + 'version', + 'projectId', + 'brandId', + 'framework', + 'auth', + 'contractsPath', + 'emails', + 'files', + 'providerConfig', + 'mcp', +] as const + +export function createBrewState( + input: Partial> = {} +): BrewProjectState { + return { + $schema: BREW_STATE_SCHEMA_URL, + ...input, + version: BREW_STATE_VERSION, + projectId: input.projectId ?? crypto.randomUUID(), + emails: input.emails ?? {}, + files: input.files ?? {}, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A project-relative POSIX path that stays inside the project. */ +export function isSafeRelativePath(path: string): boolean { + return ( + path !== '' && + !path.startsWith('/') && + !path.includes('\\') && + !/^[A-Za-z]:/.test(path) && + !path.split('/').some((segment) => segment === '..' || segment === '') + ) +} + +export function validateBrewState(value: unknown): ParsedBrewState { + if (!isRecord(value)) { + return { ok: false, errors: ['the top level must be an object'] } + } + const errors: string[] = [] + const optionalString = (key: string): void => { + const field = value[key] + if (field !== undefined && (typeof field !== 'string' || field === '')) { + errors.push(`${key} must be a non-empty string`) + } + } + if (value.version !== BREW_STATE_VERSION) { + errors.push( + `version must be ${BREW_STATE_VERSION} (got ${JSON.stringify(value.version)}); a newer CLI may have written this file` + ) + } + if ( + typeof value.projectId !== 'string' || + !UUID_PATTERN.test(value.projectId) + ) { + errors.push('projectId must be a UUID') + } + for (const key of ['$schema', 'brandId', 'framework', 'auth']) { + optionalString(key) + } + if ( + value.contractsPath !== undefined && + (typeof value.contractsPath !== 'string' || + !isSafeRelativePath(value.contractsPath)) + ) { + errors.push('contractsPath must be a project-relative path') + } + if (!isRecord(value.emails)) { + errors.push('emails must be an object') + } else { + for (const [key, entry] of Object.entries(value.emails)) { + if (!TRIGGER_KEY_PATTERN.test(key)) { + errors.push(`emails.${key}: not a valid trigger key`) + } + if ( + !isRecord(entry) || + typeof entry.triggerEventId !== 'string' || + entry.triggerEventId === '' || + typeof entry.automationId !== 'string' || + entry.automationId === '' + ) { + errors.push( + `emails.${key} must be { triggerEventId: string, automationId: string }` + ) + } + } + } + if (!isRecord(value.files)) { + errors.push('files must be an object') + } else { + for (const [path, entry] of Object.entries(value.files)) { + if (!isSafeRelativePath(path)) { + errors.push(`files.${path}: not a project-relative path`) + } + if ( + !isRecord(entry) || + typeof entry.sha256 !== 'string' || + !SHA256_PATTERN.test(entry.sha256) || + typeof entry.generator !== 'string' || + entry.generator === '' + ) { + errors.push( + `files.${path} must be { sha256: <64 hex chars>, generator: string }` + ) + } + } + } + if (value.providerConfig !== undefined && !isRecord(value.providerConfig)) { + errors.push('providerConfig must be an object') + } + if ( + value.mcp !== undefined && + (!Array.isArray(value.mcp) || + !value.mcp.every((id) => typeof id === 'string' && id !== '')) + ) { + errors.push('mcp must be an array of client ids') + } + if (errors.length > 0) { + return { ok: false, errors } + } + const extra: Record = {} + for (const [key, field] of Object.entries(value)) { + if (!(KNOWN_KEYS as readonly string[]).includes(key)) { + extra[key] = field + } + } + return { ok: true, state: value as BrewProjectState, extra } +} + +export function parseBrewState(text: string): ParsedBrewState { + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + return { + ok: false, + errors: [ + `not valid JSON (${error instanceof Error ? error.message : String(error)})`, + ], + } + } + return validateBrewState(value) +} + +/** + * The project's `brew.json`, or `null` when there is none. An invalid file + * throws `BrewStateError`: commands must not silently replace it. + */ +export function readBrewState( + root: string +): { readonly state: BrewProjectState; readonly extra: BrewStateExtra } | null { + const path = join(root, BREW_STATE_FILE) + const text = readTextFile(path) + if (text === null) { + return null + } + const parsed = parseBrewState(text) + if (!parsed.ok) { + throw new BrewStateError(path, parsed.errors) + } + return { state: parsed.state, extra: parsed.extra } +} + +function sortedRecord( + record: Readonly>, + map: (value: T) => unknown +): Record { + const out: Record = {} + for (const key of Object.keys(record).sort()) { + out[key] = map(record[key] as T) + } + return out +} + +function sortDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortDeep) + } + if (isRecord(value)) { + return sortedRecord(value, sortDeep) + } + return value +} + +/** + * Stable text: known keys in schema order, map keys sorted, unknown keys + * last (sorted), two-space indent, trailing newline. + */ +export function serializeBrewState( + state: BrewProjectState, + extra: BrewStateExtra = {} +): string { + const out: Record = { + $schema: state.$schema ?? BREW_STATE_SCHEMA_URL, + version: state.version, + projectId: state.projectId, + } + if (state.brandId !== undefined) { + out.brandId = state.brandId + } + if (state.framework !== undefined) { + out.framework = state.framework + } + if (state.auth !== undefined) { + out.auth = state.auth + } + if (state.contractsPath !== undefined) { + out.contractsPath = state.contractsPath + } + out.emails = sortedRecord(state.emails, (entry) => ({ + triggerEventId: entry.triggerEventId, + automationId: entry.automationId, + })) + out.files = sortedRecord(state.files, (entry) => ({ + sha256: entry.sha256, + generator: entry.generator, + })) + if (state.providerConfig !== undefined) { + out.providerConfig = sortDeep(state.providerConfig) + } + if (state.mcp !== undefined) { + out.mcp = [...new Set(state.mcp)].sort() + } + for (const key of Object.keys(extra).sort()) { + if (!(KNOWN_KEYS as readonly string[]).includes(key)) { + out[key] = extra[key] + } + } + return `${JSON.stringify(out, null, 2)}\n` +} diff --git a/src/lib/project/vendor-http.ts b/src/lib/project/vendor-http.ts new file mode 100644 index 0000000..bbb098e --- /dev/null +++ b/src/lib/project/vendor-http.ts @@ -0,0 +1,286 @@ +import type { HttpMethod } from '../define-command' +import { CliTimeoutError, CliVendorError } from '../errors' +import { DEFAULT_ATTEMPT_TIMEOUT_MS, readBodyText } from '../raw-request' +import { anySignal } from '../transport' +import type { CliContext } from '../types' + +/** + * HTTP to third-party APIs a project command drives on the user's behalf + * (Supabase Management API, Clerk, Resend during a migration), with the + * same discipline as `rawFetch`: a per-attempt deadline that covers the + * body, the command's signal (Ctrl-C, `--timeout`) observed throughout. + * + * Two differences from Brew calls, both deliberate: + * - it never records into `ctx.transport`: error envelopes and their + * replay advice describe Brew requests only, and a vendor URL can carry + * a project ref or token that must not land in a transcript; + * - a failure is a `CliVendorError` (`VENDOR_ERROR`, exit 1) naming the + * vendor and status. Its message quotes the vendor's own error text with + * every credential the request carried — and anything shaped like a key — + * redacted. + * + * A 429 waits out `retry-after` when it is short (bounded by + * `maxRetryAfterMs`), then retries; a longer wait is the caller's call. + */ + +export type VendorRequest = { + /** Display name, e.g. `Supabase`. */ + readonly vendor: string + readonly method: HttpMethod + readonly url: string + readonly headers?: Readonly> + /** A JSON body (serialized here) or a raw string. */ + readonly json?: unknown + readonly body?: string + /** One attempt's deadline. Default: `--timeout`, else 30 s. */ + readonly timeoutMs?: number + /** The longest `retry-after` worth waiting for in-process. Default 10 s. */ + readonly maxRetryAfterMs?: number + /** Attempts in total, 429 retries included. Default 3. */ + readonly maxAttempts?: number +} + +export type VendorResponse = { + readonly status: number + readonly headers: Headers + readonly text: string + /** The body parsed as JSON, or `undefined` when it is not JSON. */ + readonly json: unknown +} + +const DEFAULT_MAX_RETRY_AFTER_MS = 10_000 +const DEFAULT_429_BACKOFF_MS = 1_000 +const MAX_DETAIL_LENGTH = 300 +const CREDENTIAL_HEADER = + /authorization|cookie|api-?key|apikey|auth|token|secret|passw(or)?d|session|signature/i +const KEY_SHAPED = + /\b(?:sk|pk|rk|re|sbp|sb|brew|whsec|ghp|gho|xox[abp])_[A-Za-z0-9_-]{8,}\b|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g + +type VendorContext = Pick + +export async function vendorFetch( + ctx: VendorContext, + request: VendorRequest +): Promise { + const maxAttempts = Math.max(1, request.maxAttempts ?? 3) + const maxRetryAfterMs = request.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS + const secrets = collectSecrets(request) + for (let attempt = 1; ; attempt += 1) { + const response = await attemptOnce(ctx, request, secrets) + if (response.status >= 200 && response.status < 300) { + return response + } + if (response.status === 429) { + const wait = + parseRetryAfter(response.headers.get('retry-after')) ?? + DEFAULT_429_BACKOFF_MS * attempt + if (attempt < maxAttempts && wait <= maxRetryAfterMs) { + await sleep(wait, ctx.signal) + continue + } + throw new CliVendorError({ + vendor: request.vendor, + status: 429, + message: vendorMessage(request.vendor, response, secrets), + retryAfterMs: wait, + }) + } + throw new CliVendorError({ + vendor: request.vendor, + status: response.status, + message: vendorMessage(request.vendor, response, secrets), + }) + } +} + +async function attemptOnce( + ctx: VendorContext, + request: VendorRequest, + secrets: readonly string[] +): Promise { + const timeoutMs = + request.timeoutMs ?? ctx.budget.attemptMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS + const deadline = new AbortController() + const timer = setTimeout(() => { + deadline.abort(new CliTimeoutError(timeoutMs)) + }, timeoutMs) + const combined = anySignal([ctx.signal, deadline.signal]) + const { signal } = combined + try { + signal.throwIfAborted() + const headers = new Headers(request.headers) + let body = request.body + if (request.json !== undefined) { + body = JSON.stringify(request.json) + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + } + if (!headers.has('accept')) { + headers.set('accept', 'application/json') + } + let response: Response + let text: string + try { + response = await fetch(request.url, { + method: request.method, + headers, + signal, + ...(body === undefined ? {} : { body }), + }) + text = await readBodyText(response, signal) + } catch (error) { + if (signal.aborted) { + throw signal.reason + } + const cause = error instanceof Error ? error.cause : undefined + const code = + typeof cause === 'object' && cause !== null && 'code' in cause + ? ` (${String((cause as { code: unknown }).code)})` + : '' + throw new CliVendorError({ + vendor: request.vendor, + status: null, + message: redact( + `Could not reach ${request.vendor} at ${safeOrigin(request.url)}${code}.`, + secrets + ), + }) + } + return { + status: response.status, + headers: response.headers, + text, + json: tryParseJson(text), + } + } finally { + clearTimeout(timer) + combined.release() + } +} + +/** Every credential the request carries, so error text can never echo one. */ +function collectSecrets(request: VendorRequest): string[] { + const secrets: string[] = [] + for (const [name, value] of Object.entries(request.headers ?? {})) { + if (!CREDENTIAL_HEADER.test(name) || value === '') { + continue + } + secrets.push(value) + const scheme = /^\s*(?:bearer|basic|token)\s+(.+)$/i.exec(value) + if (scheme?.[1] !== undefined) { + secrets.push(scheme[1].trim()) + } + } + try { + for (const [, value] of new URL(request.url).searchParams) { + if (value.length >= 8) { + secrets.push(value) + } + } + } catch {} + // Longest first, so a token is redacted whole before any substring. + return [...new Set(secrets)] + .filter((secret) => secret.length >= 4) + .sort((a, b) => b.length - a.length) +} + +export function redact(text: string, secrets: readonly string[]): string { + let out = text + for (const secret of secrets) { + out = out.split(secret).join('[redacted]') + } + return out.replace(KEY_SHAPED, '[redacted]') +} + +function safeOrigin(url: string): string { + try { + return new URL(url).origin + } catch { + return 'an invalid URL' + } +} + +function vendorMessage( + vendor: string, + response: VendorResponse, + secrets: readonly string[] +): string { + const detail = vendorDetail(response.json) + const text = + detail === undefined + ? `${vendor} answered ${response.status}.` + : `${vendor} answered ${response.status}: ${detail.slice(0, MAX_DETAIL_LENGTH)}` + return redact(text, secrets) +} + +/** The human part of common vendor error bodies. */ +function vendorDetail(json: unknown): string | undefined { + if (typeof json !== 'object' || json === null) { + return undefined + } + const record = json as Record + for (const key of ['message', 'error_description', 'msg', 'error']) { + const value = record[key] + if (typeof value === 'string' && value !== '') { + return value + } + if (typeof value === 'object' && value !== null) { + const nested = (value as Record).message + if (typeof nested === 'string' && nested !== '') { + return nested + } + } + } + const errors = record.errors + if (Array.isArray(errors)) { + const first = errors[0] as Record | undefined + const message = first?.long_message ?? first?.message + if (typeof message === 'string' && message !== '') { + return message + } + } + return undefined +} + +/** `retry-after` as milliseconds: delta-seconds or an HTTP date. */ +export function parseRetryAfter( + value: string | null, + now: number = Date.now() +): number | undefined { + if (value === null || value.trim() === '') { + return undefined + } + const trimmed = value.trim() + if (/^\d+(\.\d+)?$/.test(trimmed)) { + return Math.ceil(Number(trimmed) * 1000) + } + const date = Date.parse(trimmed) + return Number.isNaN(date) ? undefined : Math.max(0, date - now) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + const onAbort = (): void => { + clearTimeout(timer) + reject(signal.reason) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown + } catch { + return undefined + } +} diff --git a/src/lib/raw-request.ts b/src/lib/raw-request.ts index d9042db..2b387d1 100644 --- a/src/lib/raw-request.ts +++ b/src/lib/raw-request.ts @@ -74,7 +74,7 @@ export async function rawFetch( * abort — a mocked body, or a runtime that does not, would otherwise hang * past the deadline. */ -async function readBodyText( +export async function readBodyText( response: Response, signal: AbortSignal ): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 58effd3..117dc59 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2,6 +2,29 @@ import type { BrewClient } from './client' import type { CommandMetadata } from './define-command' import type { Budget, TransportState } from './transport' +/** What a spawned child process left behind. */ +export type SpawnResult = { + /** Exit code; `null` when a signal killed the child. */ + readonly code: number | null + readonly stdout: string + readonly stderr: string +} + +/** + * Runs one program WITHOUT a shell (argv is passed through verbatim, so no + * quoting or injection concerns). Rejects only when the program could not + * be started (ENOENT); a non-zero exit resolves with its `code`. + */ +export type SpawnFn = ( + command: string, + args: readonly string[], + options?: { + readonly cwd?: string + readonly signal?: AbortSignal + readonly input?: string + } +) => Promise + /** * The process boundary, injected so tests can run the whole CLI in-process * with captured streams and a synthetic environment. @@ -20,6 +43,18 @@ export type CliIo = { * `src/lib/interrupt.ts`). Absent in tests that do not exercise cancellation. */ readonly signal?: AbortSignal + /** + * Runs a child process (git, a package manager). Absent: the real + * `child_process.spawn` (see `lib/project/process.ts`). + */ + readonly spawn?: SpawnFn + /** + * Opens a URL in the user's browser or editor. Absent: the platform + * opener (`open`, `xdg-open`, `start`). + */ + readonly openUrl?: (url: string) => Promise + /** The working directory project commands act on. Absent: `process.cwd()`. */ + readonly cwd?: string } export type OutputMode = 'human' | 'json' diff --git a/tests/fixtures/projects/bun/bun.lock b/tests/fixtures/projects/bun/bun.lock new file mode 100644 index 0000000..3e4399d --- /dev/null +++ b/tests/fixtures/projects/bun/bun.lock @@ -0,0 +1,4 @@ +{ + "lockfileVersion": 1, + "workspaces": {} +} diff --git a/tests/fixtures/projects/bun/package.json b/tests/fixtures/projects/bun/package.json new file mode 100644 index 0000000..ba5d178 --- /dev/null +++ b/tests/fixtures/projects/bun/package.json @@ -0,0 +1,6 @@ +{ + "name": "bun-worker", + "dependencies": { + "hono": "4.6.0" + } +} diff --git a/tests/fixtures/projects/deno/deno.json b/tests/fixtures/projects/deno/deno.json new file mode 100644 index 0000000..a58c8c6 --- /dev/null +++ b/tests/fixtures/projects/deno/deno.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "dev": "deno run -A main.ts" + } +} diff --git a/tests/fixtures/projects/deno/deno.lock b/tests/fixtures/projects/deno/deno.lock new file mode 100644 index 0000000..a39b837 --- /dev/null +++ b/tests/fixtures/projects/deno/deno.lock @@ -0,0 +1,3 @@ +{ + "version": "4" +} diff --git a/tests/fixtures/projects/next-app/package-lock.json b/tests/fixtures/projects/next-app/package-lock.json new file mode 100644 index 0000000..a972d66 --- /dev/null +++ b/tests/fixtures/projects/next-app/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "next-app", + "lockfileVersion": 3 +} diff --git a/tests/fixtures/projects/next-app/package.json b/tests/fixtures/projects/next-app/package.json new file mode 100644 index 0000000..d8a87f7 --- /dev/null +++ b/tests/fixtures/projects/next-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "next-app", + "dependencies": { + "next": "16.0.0", + "react": "19.2.0" + } +} diff --git a/tests/fixtures/projects/no-package-json/README.md b/tests/fixtures/projects/no-package-json/README.md new file mode 100644 index 0000000..e8e9df5 --- /dev/null +++ b/tests/fixtures/projects/no-package-json/README.md @@ -0,0 +1 @@ +A directory with no package.json. diff --git a/tests/fixtures/projects/npm-workspaces/apps/docs/package.json b/tests/fixtures/projects/npm-workspaces/apps/docs/package.json new file mode 100644 index 0000000..583fede --- /dev/null +++ b/tests/fixtures/projects/npm-workspaces/apps/docs/package.json @@ -0,0 +1,6 @@ +{ + "name": "docs", + "dependencies": { + "markdown-it": "14.0.0" + } +} diff --git a/tests/fixtures/projects/npm-workspaces/apps/site/package.json b/tests/fixtures/projects/npm-workspaces/apps/site/package.json new file mode 100644 index 0000000..aef358e --- /dev/null +++ b/tests/fixtures/projects/npm-workspaces/apps/site/package.json @@ -0,0 +1,6 @@ +{ + "name": "site", + "dependencies": { + "astro": "5.0.0" + } +} diff --git a/tests/fixtures/projects/npm-workspaces/package-lock.json b/tests/fixtures/projects/npm-workspaces/package-lock.json new file mode 100644 index 0000000..26ad437 --- /dev/null +++ b/tests/fixtures/projects/npm-workspaces/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "docs-monorepo", + "lockfileVersion": 3 +} diff --git a/tests/fixtures/projects/npm-workspaces/package.json b/tests/fixtures/projects/npm-workspaces/package.json new file mode 100644 index 0000000..1300aec --- /dev/null +++ b/tests/fixtures/projects/npm-workspaces/package.json @@ -0,0 +1,5 @@ +{ + "name": "docs-monorepo", + "private": true, + "workspaces": ["apps/*"] +} diff --git a/tests/fixtures/projects/nx/apps/shop/package.json b/tests/fixtures/projects/nx/apps/shop/package.json new file mode 100644 index 0000000..7e1d688 --- /dev/null +++ b/tests/fixtures/projects/nx/apps/shop/package.json @@ -0,0 +1,6 @@ +{ + "name": "shop", + "dependencies": { + "next": "16.0.0" + } +} diff --git a/tests/fixtures/projects/nx/libs/util/package.json b/tests/fixtures/projects/nx/libs/util/package.json new file mode 100644 index 0000000..ee06369 --- /dev/null +++ b/tests/fixtures/projects/nx/libs/util/package.json @@ -0,0 +1,3 @@ +{ + "name": "util" +} diff --git a/tests/fixtures/projects/nx/nx.json b/tests/fixtures/projects/nx/nx.json new file mode 100644 index 0000000..17d28f2 --- /dev/null +++ b/tests/fixtures/projects/nx/nx.json @@ -0,0 +1,3 @@ +{ + "targetDefaults": {} +} diff --git a/tests/fixtures/projects/nx/package.json b/tests/fixtures/projects/nx/package.json new file mode 100644 index 0000000..bc664fb --- /dev/null +++ b/tests/fixtures/projects/nx/package.json @@ -0,0 +1,4 @@ +{ + "name": "nx-root", + "private": true +} diff --git a/tests/fixtures/projects/pnpm-turbo/apps/api/package.json b/tests/fixtures/projects/pnpm-turbo/apps/api/package.json new file mode 100644 index 0000000..6efce94 --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/apps/api/package.json @@ -0,0 +1,6 @@ +{ + "name": "api", + "dependencies": { + "hono": "4.6.0" + } +} diff --git a/tests/fixtures/projects/pnpm-turbo/apps/web/app/README.md b/tests/fixtures/projects/pnpm-turbo/apps/web/app/README.md new file mode 100644 index 0000000..2965a3f --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/apps/web/app/README.md @@ -0,0 +1 @@ +Next.js app directory. diff --git a/tests/fixtures/projects/pnpm-turbo/apps/web/package.json b/tests/fixtures/projects/pnpm-turbo/apps/web/package.json new file mode 100644 index 0000000..4be0645 --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/apps/web/package.json @@ -0,0 +1,7 @@ +{ + "name": "web", + "dependencies": { + "next": "16.0.0", + "react": "19.2.0" + } +} diff --git a/tests/fixtures/projects/pnpm-turbo/apps/web/turbo.json b/tests/fixtures/projects/pnpm-turbo/apps/web/turbo.json new file mode 100644 index 0000000..3adcb89 --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/apps/web/turbo.json @@ -0,0 +1,3 @@ +{ + "extends": ["//"] +} diff --git a/tests/fixtures/projects/pnpm-turbo/package.json b/tests/fixtures/projects/pnpm-turbo/package.json new file mode 100644 index 0000000..276502c --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/package.json @@ -0,0 +1,5 @@ +{ + "name": "acme", + "private": true, + "packageManager": "pnpm@9.12.0+sha512.4abf725084d7bcbafbd728bfc7bee61f2f791f977fd87542b3579dcb23504d170d46337945e4c66485cd12d588a0c0e570ed9c477e7ccdd8507cf05f3f92eaca" +} diff --git a/tests/fixtures/projects/pnpm-turbo/packages/ui/package.json b/tests/fixtures/projects/pnpm-turbo/packages/ui/package.json new file mode 100644 index 0000000..01de6bd --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/packages/ui/package.json @@ -0,0 +1,6 @@ +{ + "name": "@acme/ui", + "peerDependencies": { + "react": "19.2.0" + } +} diff --git a/tests/fixtures/projects/pnpm-turbo/pnpm-lock.yaml b/tests/fixtures/projects/pnpm-turbo/pnpm-lock.yaml new file mode 100644 index 0000000..b0a073a --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/tests/fixtures/projects/pnpm-turbo/pnpm-workspace.yaml b/tests/fixtures/projects/pnpm-turbo/pnpm-workspace.yaml new file mode 100644 index 0000000..3f7154f --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'apps/*' + - "packages/*" # shared code + - '!packages/private' diff --git a/tests/fixtures/projects/pnpm-turbo/turbo.json b/tests/fixtures/projects/pnpm-turbo/turbo.json new file mode 100644 index 0000000..90faa72 --- /dev/null +++ b/tests/fixtures/projects/pnpm-turbo/turbo.json @@ -0,0 +1,3 @@ +{ + "tasks": {} +} diff --git a/tests/fixtures/projects/yarn-berry-pnp/.yarnrc.yml b/tests/fixtures/projects/yarn-berry-pnp/.yarnrc.yml new file mode 100644 index 0000000..09b1012 --- /dev/null +++ b/tests/fixtures/projects/yarn-berry-pnp/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: pnp diff --git a/tests/fixtures/projects/yarn-berry-pnp/package.json b/tests/fixtures/projects/yarn-berry-pnp/package.json new file mode 100644 index 0000000..d235393 --- /dev/null +++ b/tests/fixtures/projects/yarn-berry-pnp/package.json @@ -0,0 +1,7 @@ +{ + "name": "berry-app", + "dependencies": { + "@react-router/dev": "7.9.0", + "react-router": "7.9.0" + } +} diff --git a/tests/fixtures/projects/yarn-berry-pnp/yarn.lock b/tests/fixtures/projects/yarn-berry-pnp/yarn.lock new file mode 100644 index 0000000..2bbfd54 --- /dev/null +++ b/tests/fixtures/projects/yarn-berry-pnp/yarn.lock @@ -0,0 +1,5 @@ +# This file is generated by running "yarn install" inside your project. + +__metadata: + version: 8 + cacheKey: 10c0 diff --git a/tests/fixtures/projects/yarn-classic/package.json b/tests/fixtures/projects/yarn-classic/package.json new file mode 100644 index 0000000..220b4d1 --- /dev/null +++ b/tests/fixtures/projects/yarn-classic/package.json @@ -0,0 +1,6 @@ +{ + "name": "classic-api", + "dependencies": { + "express": "4.21.0" + } +} diff --git a/tests/fixtures/projects/yarn-classic/yarn.lock b/tests/fixtures/projects/yarn-classic/yarn.lock new file mode 100644 index 0000000..4a58018 --- /dev/null +++ b/tests/fixtures/projects/yarn-classic/yarn.lock @@ -0,0 +1,2 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..9df43b6 --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,23 @@ +import { cpSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const FIXTURES = join(import.meta.dirname, '../fixtures/projects') + +/** + * A fresh temp directory, bounded by an empty `.git` so walks up from it + * stop there (and never reach a real repository or home directory). + * Realpath'd: macOS tmpdir is a symlink, and paths are compared verbatim. + */ +export function tempProject(prefix = 'brew-cli-project-'): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))) + mkdirSync(join(dir, '.git')) + return dir +} + +/** A copy of `tests/fixtures/projects/` in a bounded temp directory. */ +export function copyFixture(name: string): string { + const dir = tempProject(`brew-cli-${name}-`) + cpSync(join(FIXTURES, name), dir, { recursive: true }) + return dir +} diff --git a/tests/helpers/run-cli.ts b/tests/helpers/run-cli.ts index 7fa2543..7265214 100644 --- a/tests/helpers/run-cli.ts +++ b/tests/helpers/run-cli.ts @@ -1,7 +1,7 @@ import { PassThrough } from 'node:stream' import { run } from '../../src/cli' import type { CommandSpec } from '../../src/lib/define-command' -import type { CliIo } from '../../src/lib/types' +import type { CliIo, SpawnFn } from '../../src/lib/types' import { ALL_COMMANDS } from '../../src/registry' export type RunCliOptions = { @@ -25,6 +25,12 @@ export type RunCliOptions = { * `CliInterruptError` to simulate SIGINT/SIGTERM at an exact moment. */ readonly signal?: AbortSignal + /** Working directory for project commands (`mcp install`, `init`). */ + readonly cwd?: string + /** Stands in for child processes (git, package managers). */ + readonly spawn?: SpawnFn + /** Records URLs a command asks to open. */ + readonly openUrl?: (url: string) => Promise } export type RunCliResult = { @@ -58,6 +64,9 @@ export async function runCli( readStdin: options.readStdin ?? (async () => options.stdin ?? ''), readLine: async () => options.promptAnswer ?? '', ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.spawn === undefined ? {} : { spawn: options.spawn }), + ...(options.openUrl === undefined ? {} : { openUrl: options.openUrl }), } const code = await run( argv, diff --git a/tests/lib/project/env-file.test.ts b/tests/lib/project/env-file.test.ts new file mode 100644 index 0000000..9aaa220 --- /dev/null +++ b/tests/lib/project/env-file.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { + EnvSecretExposureError, + ensureEnvVars, + ensureVar, + formatEnvAssignment, + getEnvValue, + isClientExposedName, + parseEnv, + serializeEnv, +} from '../../../src/lib/project/env-file' + +const SECRET = 'brew_live_secret_value_0123456789' + +const SAMPLE = [ + '# Database', + 'DATABASE_URL="postgres://u:p@localhost/db"', + '', + 'export NODE_ENV=development # inline comment', + "SINGLE='literal $HOME'", + 'MULTI="line one', + 'line two"', + 'EMPTY=', + 'BROKEN LINE', +].join('\n') + +describe('parseEnv / serializeEnv', () => { + it('round-trips byte for byte: comments, order, quoting, export', () => { + expect(serializeEnv(parseEnv(`${SAMPLE}\n`))).toBe(`${SAMPLE}\n`) + expect(serializeEnv(parseEnv(SAMPLE))).toBe(SAMPLE) + const crlf = `${SAMPLE.replaceAll('\n', '\r\n')}\r\n` + expect(serializeEnv(parseEnv(crlf))).toBe(crlf) + }) + + it('reads values the way dotenv does', () => { + const file = parseEnv(SAMPLE) + expect(getEnvValue(file, 'DATABASE_URL')).toBe( + 'postgres://u:p@localhost/db' + ) + expect(getEnvValue(file, 'NODE_ENV')).toBe('development') + expect(getEnvValue(file, 'SINGLE')).toBe('literal $HOME') + expect(getEnvValue(file, 'MULTI')).toBe('line one\nline two') + expect(getEnvValue(file, 'EMPTY')).toBe('') + expect(getEnvValue(file, 'MISSING')).toBeUndefined() + expect(getEnvValue(parseEnv('A=1\nA=2\n'), 'A')).toBe('2') + expect(getEnvValue(parseEnv('ESC="a\\nb"'), 'ESC')).toBe('a\nb') + }) +}) + +describe('ensureVar', () => { + it('appends an absent variable and reports added (never the value)', () => { + const { text, results } = ensureEnvVars(`${SAMPLE}\n`, [ + { name: 'BREW_API_KEY', value: SECRET }, + ]) + expect(text).toBe(`${SAMPLE}\nBREW_API_KEY=${SECRET}\n`) + expect(results).toEqual([{ name: 'BREW_API_KEY', action: 'added' }]) + expect(JSON.stringify(results)).not.toContain(SECRET) + }) + + it('keeps an existing value untouched', () => { + const before = 'BREW_API_KEY=brew_existing\n' + const { text, results } = ensureEnvVars(before, [ + { name: 'BREW_API_KEY', value: SECRET }, + ]) + expect(text).toBe(before) + expect(results).toEqual([{ name: 'BREW_API_KEY', action: 'kept-existing' }]) + }) + + it('fills a present-but-empty variable in place', () => { + const { text, results } = ensureEnvVars( + '# keys\nexport BREW_API_KEY=\nOTHER=1\n', + [{ name: 'BREW_API_KEY', value: SECRET }] + ) + expect(text).toBe(`# keys\nexport BREW_API_KEY=${SECRET}\nOTHER=1\n`) + expect(results[0]?.action).toBe('filled') + }) + + it('creates a file from nothing, and adds a final newline when missing', () => { + expect(ensureEnvVars(null, [{ name: 'A', value: '1' }]).text).toBe('A=1\n') + expect(ensureEnvVars('X=1', [{ name: 'A', value: '1' }]).text).toBe( + 'X=1\nA=1\n' + ) + expect(ensureEnvVars('X=1\r\n', [{ name: 'A', value: '1' }]).text).toBe( + 'X=1\r\nA=1\r\n' + ) + }) + + it('refuses secrets under browser-exposed names', () => { + for (const name of [ + 'NEXT_PUBLIC_BREW_API_KEY', + 'VITE_BREW_API_KEY', + 'PUBLIC_BREW_API_KEY', + 'EXPO_PUBLIC_BREW_API_KEY', + ]) { + expect(isClientExposedName(name)).toBe(true) + expect(() => ensureVar(parseEnv(''), name, SECRET)).toThrow( + EnvSecretExposureError + ) + } + expect(() => + ensureVar(parseEnv(''), 'NEXT_PUBLIC_APP_URL', 'https://x.dev', { + secret: false, + }) + ).not.toThrow() + }) + + it('never puts the secret in the refusal message', () => { + try { + ensureVar(parseEnv(''), 'VITE_KEY', SECRET) + } catch (error) { + expect(String(error)).not.toContain(SECRET) + } + }) + + it('quotes only when a value needs it', () => { + expect(formatEnvAssignment('A', 'plain-value_1.2/3:4@5')).toBe( + 'A=plain-value_1.2/3:4@5' + ) + expect(formatEnvAssignment('A', 'has space $x')).toBe("A='has space $x'") + expect(formatEnvAssignment('A', "it's\nmulti")).toBe('A="it\'s\\nmulti"') + expect( + getEnvValue(parseEnv(formatEnvAssignment('A', "it's\nmulti")), 'A') + ).toBe("it's\nmulti") + }) +}) diff --git a/tests/lib/project/fs-writer.test.ts b/tests/lib/project/fs-writer.test.ts new file mode 100644 index 0000000..edb5706 --- /dev/null +++ b/tests/lib/project/fs-writer.test.ts @@ -0,0 +1,250 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { unifiedDiff } from '../../../src/lib/project/diff' +import { + HASH_BLOCK_MARKERS, + ProjectWriter, + readBlock, + sha256Hex, +} from '../../../src/lib/project/fs-writer' +import { tempProject } from '../../helpers/fixtures' + +describe('ProjectWriter.writeFile (create-only)', () => { + it('creates a missing file, nested directories included', () => { + const root = tempProject() + const writer = new ProjectWriter({ root }) + const change = writer.writeFile('lib/brew.ts', 'export {}\n') + expect(change).toMatchObject({ + path: 'lib/brew.ts', + action: 'created', + sha256: sha256Hex('export {}\n'), + }) + expect(change.diff).toContain('+export {}') + expect(readFileSync(join(root, 'lib/brew.ts'), 'utf8')).toBe('export {}\n') + }) + + it('leaves identical content alone, CRLF checkouts included', () => { + const root = tempProject() + writeFileSync(join(root, 'a.ts'), 'one\r\ntwo\r\n') + const writer = new ProjectWriter({ root }) + expect(writer.writeFile('a.ts', 'one\ntwo\n').action).toBe('unchanged') + expect(readFileSync(join(root, 'a.ts'), 'utf8')).toBe('one\r\ntwo\r\n') + }) + + it('keeps the user version of a file it did not write', () => { + const root = tempProject() + writeFileSync(join(root, 'a.ts'), 'mine\n') + const change = new ProjectWriter({ root }).writeFile('a.ts', 'ours\n') + expect(change).toMatchObject({ + action: 'kept-user-version', + sha256: sha256Hex('mine\n'), + }) + expect(readFileSync(join(root, 'a.ts'), 'utf8')).toBe('mine\n') + }) + + it('replaces its own untouched file (hash match), keeping line endings', () => { + const root = tempProject() + writeFileSync(join(root, 'a.ts'), 'v1\r\n') + const change = new ProjectWriter({ root }).writeFile('a.ts', 'v2\n', { + managedSha256: sha256Hex('v1\n'), + }) + expect(change.action).toBe('updated') + expect(readFileSync(join(root, 'a.ts'), 'utf8')).toBe('v2\r\n') + }) + + it('keeps a managed file the user edited since', () => { + const root = tempProject() + writeFileSync(join(root, 'a.ts'), 'v1 edited\n') + const change = new ProjectWriter({ root }).writeFile('a.ts', 'v2\n', { + managedSha256: sha256Hex('v1\n'), + }) + expect(change).toMatchObject({ + action: 'kept-user-version', + note: 'edited since the CLI wrote it', + }) + }) + + it('writes secrets with mode 0600, atomically (no temp files left)', () => { + const root = tempProject() + new ProjectWriter({ root }).writeFile('.env.local', 'A=1\n', { + mode: 0o600, + }) + expect(statSync(join(root, '.env.local')).mode & 0o777).toBe(0o600) + expect( + readdirSync(root).filter((name) => name.includes('brew-tmp')) + ).toEqual([]) + }) + + it('collects the plan without writing in dry-run mode', () => { + const root = tempProject() + writeFileSync(join(root, 'merged.json'), '{"a":1}\n') + const writer = new ProjectWriter({ root, dryRun: true }) + writer.writeFile('new.ts', 'x\n') + writer.writeFile('merged.json', '{"a":1,"b":2}\n', { overwrite: true }) + expect( + writer.changes.map((change) => [change.path, change.action]) + ).toEqual([ + ['new.ts', 'created'], + ['merged.json', 'updated'], + ]) + expect(writer.changes[1]?.diff).toContain('+{"a":1,"b":2}') + expect(existsSync(join(root, 'new.ts'))).toBe(false) + expect(readFileSync(join(root, 'merged.json'), 'utf8')).toBe('{"a":1}\n') + }) + + it('refuses relative paths that escape the root', () => { + const writer = new ProjectWriter({ root: tempProject() }) + expect(() => writer.writeFile('../outside.ts', 'x')).toThrow( + /outside the project root/ + ) + }) + + it('inspects managed files: unchanged, edited, missing', () => { + const root = tempProject() + writeFileSync(join(root, 'a.ts'), 'v1\n') + writeFileSync(join(root, 'b.ts'), 'edited\n') + const writer = new ProjectWriter({ root }) + expect(writer.inspectManaged('a.ts', sha256Hex('v1\n')).action).toBe( + 'unchanged' + ) + expect(writer.inspectManaged('b.ts', sha256Hex('v1\n')).action).toBe( + 'kept-user-version' + ) + expect(writer.inspectManaged('c.ts', sha256Hex('v1\n')).action).toBe( + 'missing' + ) + }) +}) + +describe('ProjectWriter.upsertBlock', () => { + it('appends a marked block, leaving the rest of the file intact', () => { + const root = tempProject() + writeFileSync(join(root, 'AGENTS.md'), '# Rules\n\nBe kind.\n') + const change = new ProjectWriter({ root }).upsertBlock( + 'AGENTS.md', + 'Use Brew for email.' + ) + expect(change.action).toBe('updated') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toBe( + '# Rules\n\nBe kind.\n\n\nUse Brew for email.\n\n' + ) + }) + + it('replaces only the block on re-run, and is a no-op when equal', () => { + const root = tempProject() + writeFileSync( + join(root, 'AGENTS.md'), + 'top\n\nold\n\nbottom\n' + ) + const writer = new ProjectWriter({ root }) + expect(writer.upsertBlock('AGENTS.md', 'new\nlines').action).toBe('updated') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toBe( + 'top\n\nnew\nlines\n\nbottom\n' + ) + expect(writer.upsertBlock('AGENTS.md', 'new\nlines\n').action).toBe( + 'unchanged' + ) + }) + + it('keeps CRLF files CRLF and supports # markers', () => { + const root = tempProject() + writeFileSync( + join(root, '.gitignore'), + 'node_modules\r\n# brew:start\r\nold\r\n# brew:end\r\n' + ) + new ProjectWriter({ root }).upsertBlock('.gitignore', '.env.local', { + markers: HASH_BLOCK_MARKERS, + }) + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe( + 'node_modules\r\n# brew:start\r\n.env.local\r\n# brew:end\r\n' + ) + }) + + it('creates the file, or reports it missing when told not to', () => { + const root = tempProject() + const writer = new ProjectWriter({ root }) + expect( + writer.upsertBlock('CLAUDE.md', 'x', { createIfMissing: false }).action + ).toBe('missing') + expect(existsSync(join(root, 'CLAUDE.md'))).toBe(false) + expect(writer.upsertBlock('docs/AGENTS.md', 'x').action).toBe('created') + expect(readFileSync(join(root, 'docs/AGENTS.md'), 'utf8')).toBe( + '\nx\n\n' + ) + }) + + it('keeps a file whose markers do not pair up', () => { + const root = tempProject() + mkdirSync(join(root, 'docs')) + const text = '\nno end\n' + writeFileSync(join(root, 'docs/x.md'), text) + const change = new ProjectWriter({ root }).upsertBlock('docs/x.md', 'y') + expect(change.action).toBe('kept-user-version') + expect(change.note).toMatch(/unbalanced/) + expect(readFileSync(join(root, 'docs/x.md'), 'utf8')).toBe(text) + }) + + it('reads a block back', () => { + expect( + readBlock( + 'a\r\n# brew:start\r\nx\r\ny\r\n# brew:end\r\n', + HASH_BLOCK_MARKERS + ) + ).toBe('x\ny') + expect(readBlock('no block')).toBeNull() + }) +}) + +describe('unifiedDiff', () => { + it('is empty for equal texts (line endings ignored)', () => { + expect(unifiedDiff('a\nb\n', 'a\r\nb\r\n')).toBe('') + }) + + it('renders hunks with context and line numbers', () => { + const before = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].join( + '\n' + ) + const after = ['1', '2', '3', '4', 'five', '6', '7', '8', '9', '10'].join( + '\n' + ) + expect(unifiedDiff(before, after, { fromPath: 'n.txt' })).toBe( + [ + '--- n.txt', + '+++ n.txt', + '@@ -2,7 +2,7 @@', + ' 2', + ' 3', + ' 4', + '-5', + '+five', + ' 6', + ' 7', + ' 8', + '', + ].join('\n') + ) + }) + + it('diffs a new file against nothing', () => { + expect(unifiedDiff('', 'a\nb\n', { fromPath: 'x' })).toBe( + '--- x\n+++ x\n@@ -0,0 +1,2 @@\n+a\n+b\n' + ) + }) + + it('splits distant changes into separate hunks', () => { + const lines = Array.from({ length: 30 }, (_, i) => `l${i + 1}`) + const changed = [...lines] + changed[1] = 'X' + changed[27] = 'Y' + const diff = unifiedDiff(lines.join('\n'), changed.join('\n')) + expect(diff.match(/^@@/gm)).toHaveLength(2) + }) +}) diff --git a/tests/lib/project/gitignore.test.ts b/tests/lib/project/gitignore.test.ts new file mode 100644 index 0000000..c10d363 --- /dev/null +++ b/tests/lib/project/gitignore.test.ts @@ -0,0 +1,136 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ProjectWriter } from '../../../src/lib/project/fs-writer' +import { + ensureIgnored, + isIgnored, + matchesGitignore, +} from '../../../src/lib/project/gitignore' +import type { SpawnFn } from '../../../src/lib/types' +import { tempProject } from '../../helpers/fixtures' + +function fakeGit( + answers: Readonly>, + calls: string[][] = [] +): SpawnFn { + return async (command, args) => { + calls.push([command, ...args]) + return { code: answers[args.at(-1) ?? ''] ?? 1, stdout: '', stderr: '' } + } +} + +const enoent: SpawnFn = async () => { + throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) +} + +function hasGit(): boolean { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe('isIgnored', () => { + it('asks git check-ignore through the spawn seam', async () => { + const calls: string[][] = [] + const spawn = fakeGit({ '.env.local': 0, 'brew.json': 1 }, calls) + const cwd = tempProject() + expect(await isIgnored('.env.local', { cwd, spawn })).toBe(true) + expect(await isIgnored('brew.json', { cwd, spawn })).toBe(false) + expect(calls[0]).toEqual(['git', 'check-ignore', '-q', '--', '.env.local']) + }) + + it('falls back to the .gitignore parser outside a repo or without git', async () => { + const cwd = tempProject() + writeFileSync(join(cwd, '.gitignore'), '.env*\n!.env.example\n') + const notARepo: SpawnFn = async () => ({ + code: 128, + stdout: '', + stderr: 'fatal: not a git repository', + }) + expect(await isIgnored('.env.local', { cwd, spawn: notARepo })).toBe(true) + expect(await isIgnored('.env.example', { cwd, spawn: enoent })).toBe(false) + rmSync(join(cwd, '.gitignore')) + expect(await isIgnored('.env.local', { cwd, spawn: enoent })).toBe(false) + }) + + it.runIf(hasGit())('agrees with a real git repository', async () => { + const cwd = tempProject() + rmSync(join(cwd, '.git'), { recursive: true }) + execFileSync('git', ['init', '-q'], { cwd }) + writeFileSync(join(cwd, '.gitignore'), '.env*.local\n') + expect(await isIgnored('.env.local', { cwd })).toBe(true) + expect(await isIgnored('brew.json', { cwd })).toBe(false) + }) +}) + +describe('matchesGitignore (fallback parser)', () => { + const cases: Array<[string, string, boolean]> = [ + ['.env*', '.env.local', true], + ['.env*', 'apps/web/.env.local', true], + ['/.env', 'apps/web/.env', false], + ['/.env', '.env', true], + ['*.log\n!keep.log', 'keep.log', false], + ['*.log\n!keep.log', 'debug.log', true], + ['secrets/', 'secrets/key.pem', true], + ['secrets/', 'secrets', false], + ['node_modules', 'node_modules/x/index.js', true], + ['build/\n!build/keep.txt', 'build/keep.txt', true], + ['apps/**/.env', 'apps/web/deep/.env', true], + ['**/dist', 'packages/ui/dist/index.js', true], + ['config/*.json', 'config/a.json', true], + ['config/*.json', 'config/nested/a.json', false], + ['# comment\n\n.env.[lp]*', '.env.local', true], + ['\\#hash', '#hash', true], + ] + for (const [rules, path, expected] of cases) { + it(`${JSON.stringify(rules)} ${expected ? 'ignores' : 'keeps'} ${path}`, () => { + expect(matchesGitignore(rules, path)).toBe(expected) + }) + } +}) + +describe('ensureIgnored', () => { + it('adds only what git does not ignore, inside a managed block', async () => { + const root = tempProject() + writeFileSync(join(root, '.gitignore'), 'node_modules\n.env\n') + const writer = new ProjectWriter({ root }) + const result = await ensureIgnored(writer, ['.env', '.env.local'], { + spawn: enoent, + }) + expect(result.added).toEqual(['.env.local']) + expect(result.change?.action).toBe('updated') + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe( + 'node_modules\n.env\n\n# brew:start\n.env.local\n# brew:end\n' + ) + // Re-running: the fallback parser now sees the block → nothing to do. + const again = await ensureIgnored(writer, ['.env', '.env.local'], { + spawn: enoent, + }) + expect(again).toEqual({ added: [] }) + }) + + it('extends an existing block and creates a missing .gitignore', async () => { + const root = tempProject() + const writer = new ProjectWriter({ root }) + await ensureIgnored(writer, ['.env.local'], { spawn: fakeGit({}) }) + await ensureIgnored(writer, ['.brew/cache'], { spawn: fakeGit({}) }) + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe( + '# brew:start\n.env.local\n.brew/cache\n# brew:end\n' + ) + }) + + it('plans without writing in dry-run mode', async () => { + const root = tempProject() + const writer = new ProjectWriter({ root, dryRun: true }) + const result = await ensureIgnored(writer, ['.env.local'], { + spawn: fakeGit({}), + }) + expect(result.change?.action).toBe('created') + expect(() => readFileSync(join(root, '.gitignore'))).toThrow() + }) +}) diff --git a/tests/lib/project/monorepo.test.ts b/tests/lib/project/monorepo.test.ts new file mode 100644 index 0000000..4ff96ae --- /dev/null +++ b/tests/lib/project/monorepo.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + detectFramework, + findMonorepo, + listWorkspacePackages, + parsePnpmWorkspaceYaml, + resolveProjectRoot, +} from '../../../src/lib/project/monorepo' +import { copyFixture } from '../../helpers/fixtures' + +describe('resolveProjectRoot', () => { + it('resolves a pnpm + Turborepo workspace app from deep inside it', () => { + const root = copyFixture('pnpm-turbo') + const resolved = resolveProjectRoot(join(root, 'apps/web/app')) + expect(resolved.root).toBe(join(root, 'apps/web')) + expect(resolved.monorepo).toEqual({ + tool: 'turbo', + root, + workspace: { name: 'web', dir: 'apps/web' }, + patterns: ['apps/*', 'packages/*', '!packages/private'], + }) + expect(resolved.candidates).toEqual([ + { name: 'api', dir: 'apps/api', framework: 'hono' }, + { name: 'web', dir: 'apps/web', framework: 'next' }, + ]) + }) + + it('stays at the monorepo root, listing every app candidate', () => { + const root = copyFixture('pnpm-turbo') + const resolved = resolveProjectRoot(root) + expect(resolved.root).toBe(root) + expect(resolved.monorepo?.workspace).toBeNull() + expect(resolved.candidates.map((candidate) => candidate.name)).toEqual([ + 'api', + 'web', + ]) + }) + + it('reads npm workspaces and skips packages with no framework', () => { + const root = copyFixture('npm-workspaces') + const resolved = resolveProjectRoot(join(root, 'apps/docs')) + expect(resolved.monorepo).toMatchObject({ + tool: 'workspaces', + root, + workspace: { name: 'docs', dir: 'apps/docs' }, + }) + expect(resolved.candidates).toEqual([ + { name: 'site', dir: 'apps/site', framework: 'astro' }, + ]) + }) + + it('treats nx.json as a monorepo with the conventional app folders', () => { + const root = copyFixture('nx') + const resolved = resolveProjectRoot(root) + expect(resolved.monorepo).toMatchObject({ + tool: 'nx', + patterns: ['apps/*', 'packages/*', 'libs/*'], + }) + expect(resolved.candidates).toEqual([ + { name: 'shop', dir: 'apps/shop', framework: 'next' }, + ]) + }) + + it('resolves a single app to its own package', () => { + const root = copyFixture('next-app') + mkdirSync(join(root, 'src/app'), { recursive: true }) + expect(resolveProjectRoot(join(root, 'src/app'))).toEqual({ + root, + monorepo: null, + candidates: [{ name: 'next-app', dir: '.', framework: 'next' }], + }) + }) + + it('falls back to the cwd when there is no package.json up to the repo', () => { + const root = copyFixture('no-package-json') + expect(resolveProjectRoot(root)).toEqual({ + root, + monorepo: null, + candidates: [], + }) + }) + + it('never mistakes a package-level turbo.json for the root', () => { + const root = copyFixture('pnpm-turbo') + expect(findMonorepo(join(root, 'apps/web'))?.root).toBe(root) + }) +}) + +describe('workspace helpers', () => { + it('parses pnpm-workspace.yaml lists (block and inline)', () => { + expect( + parsePnpmWorkspaceYaml( + 'packages:\n - \'apps/*\'\n - "packages/**" # all\ncatalog:\n react: 19\n' + ) + ).toEqual(['apps/*', 'packages/**']) + expect(parsePnpmWorkspaceYaml('packages: [\'a/*\', "b"]\n')).toEqual([ + 'a/*', + 'b', + ]) + }) + + it('expands ** globs and honours negations', () => { + const root = copyFixture('pnpm-turbo') + mkdirSync(join(root, 'packages/private'), { recursive: true }) + writeFileSync(join(root, 'packages/private/package.json'), '{}') + mkdirSync(join(root, 'packages/nested/deep'), { recursive: true }) + writeFileSync( + join(root, 'packages/nested/deep/package.json'), + '{"name":"deep"}' + ) + expect( + listWorkspacePackages(root, ['packages/**', '!packages/private']) + ).toEqual([ + { name: 'deep', dir: 'packages/nested/deep' }, + { name: '@acme/ui', dir: 'packages/ui' }, + ]) + }) + + it('names a framework by its most specific dependency', () => { + expect( + detectFramework({ dependencies: { react: '19', next: '16', vite: '6' } }) + ).toBe('next') + expect(detectFramework({ devDependencies: { vite: '6' } })).toBe('vite') + expect(detectFramework({ dependencies: { lodash: '4' } })).toBeNull() + expect(detectFramework(null)).toBeNull() + }) +}) diff --git a/tests/lib/project/package-manager.test.ts b/tests/lib/project/package-manager.test.ts new file mode 100644 index 0000000..e0eccb7 --- /dev/null +++ b/tests/lib/project/package-manager.test.ts @@ -0,0 +1,256 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + addCommand, + detectPackageManager, + dlxCommand, + execCommand, + formatCommandLine, + installCommand, + type PackageManager, + parsePackageManagerField, + parseUserAgent, +} from '../../../src/lib/project/package-manager' +import { copyFixture, tempProject } from '../../helpers/fixtures' + +const WEB = { name: 'web', dir: 'apps/web' } + +function pm( + name: PackageManager['name'], + rest: Partial = {} +): PackageManager { + return { name, source: 'lockfile', ...rest } +} + +describe('detectPackageManager', () => { + it('prefers package.json#packageManager, version included', () => { + const root = copyFixture('pnpm-turbo') + expect(detectPackageManager([join(root, 'apps/web'), root])).toEqual({ + name: 'pnpm', + version: '9.12.0', + source: 'packageManager', + }) + }) + + it('reads yarn berry with Plug and Play from .yarnrc.yml', () => { + const root = copyFixture('yarn-berry-pnp') + expect(detectPackageManager([root])).toEqual({ + name: 'yarn', + source: 'lockfile', + lockfile: 'yarn.lock', + yarnFlavor: 'berry', + pnp: true, + }) + }) + + it('tells yarn classic from its v1 lockfile', () => { + const root = copyFixture('yarn-classic') + expect(detectPackageManager([root])).toMatchObject({ + name: 'yarn', + yarnFlavor: 'classic', + pnp: false, + }) + }) + + it('reads berry without PnP when nodeLinker says node-modules', () => { + const root = copyFixture('yarn-berry-pnp') + writeFileSync(join(root, '.yarnrc.yml'), 'nodeLinker: node-modules\n') + expect(detectPackageManager([root])).toMatchObject({ + yarnFlavor: 'berry', + pnp: false, + }) + }) + + it('classifies a yarn@4 packageManager pin as berry', () => { + const root = tempProject() + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ packageManager: 'yarn@4.5.0' }) + ) + expect(detectPackageManager([root])).toMatchObject({ + name: 'yarn', + version: '4.5.0', + yarnFlavor: 'berry', + }) + }) + + it('detects bun from bun.lock and from the binary bun.lockb', () => { + expect(detectPackageManager([copyFixture('bun')])).toMatchObject({ + name: 'bun', + lockfile: 'bun.lock', + }) + const root = tempProject() + writeFileSync(join(root, 'package.json'), '{}') + writeFileSync(join(root, 'bun.lockb'), Buffer.from([0x23, 0x21, 0x00])) + expect(detectPackageManager([root])).toMatchObject({ + name: 'bun', + lockfile: 'bun.lockb', + }) + }) + + it('detects npm and deno from their lockfiles', () => { + expect(detectPackageManager([copyFixture('next-app')])).toMatchObject({ + name: 'npm', + lockfile: 'package-lock.json', + }) + expect(detectPackageManager([copyFixture('deno')])).toMatchObject({ + name: 'deno', + lockfile: 'deno.lock', + }) + }) + + it('finds the lockfile at the monorepo root when the app has none', () => { + const root = copyFixture('npm-workspaces') + expect(detectPackageManager([join(root, 'apps/site'), root])).toMatchObject( + { name: 'npm', lockfile: 'package-lock.json' } + ) + }) + + it('reports conflicting lockfiles from different managers', () => { + const root = copyFixture('next-app') + writeFileSync(join(root, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n") + expect(detectPackageManager([root])).toMatchObject({ + name: 'pnpm', + conflictingLockfiles: ['pnpm-lock.yaml', 'package-lock.json'], + }) + }) + + it('falls back to the launching user agent, then npm', () => { + const root = copyFixture('no-package-json') + expect( + detectPackageManager([root], { + npm_config_user_agent: 'bun/1.3.10 npm/? node/v24.3.0 darwin arm64', + }) + ).toEqual({ name: 'bun', version: '1.3.10', source: 'user-agent' }) + expect(detectPackageManager([root], {})).toEqual({ + name: 'npm', + source: 'default', + }) + }) +}) + +describe('field parsers', () => { + it('parses packageManager pins and rejects strangers', () => { + expect(parsePackageManagerField('pnpm@9.1.0+sha512.abc')).toEqual({ + name: 'pnpm', + version: '9.1.0', + }) + expect(parsePackageManagerField('volta@1.0.0')).toBeNull() + expect(parsePackageManagerField(42)).toBeNull() + }) + + it('parses npm_config_user_agent', () => { + expect(parseUserAgent('yarn/1.22.22 npm/? node/v20.11.0')).toEqual({ + name: 'yarn', + version: '1.22.22', + }) + expect(parseUserAgent(undefined)).toBeNull() + }) +}) + +describe('command builders', () => { + it('adds a dependency, workspace-aware, for every manager', () => { + const cases: Array<[PackageManager, string]> = [ + [pm('npm'), 'npm install --workspace web @brew.new/sdk'], + [pm('pnpm'), 'pnpm add --filter web @brew.new/sdk'], + [ + pm('yarn', { yarnFlavor: 'classic' }), + 'yarn workspace web add @brew.new/sdk', + ], + [ + pm('yarn', { yarnFlavor: 'berry' }), + 'yarn workspace web add @brew.new/sdk', + ], + [pm('bun'), 'cd apps/web && bun add @brew.new/sdk'], + [pm('deno'), 'cd apps/web && deno add npm:@brew.new/sdk'], + ] + for (const [manager, expected] of cases) { + expect( + formatCommandLine( + addCommand(manager, ['@brew.new/sdk'], { workspace: WEB }) + ) + ).toBe(expected) + } + }) + + it('adds dev dependencies at the root', () => { + expect( + formatCommandLine(addCommand(pm('npm'), ['msw'], { dev: true })) + ).toBe('npm install --save-dev msw') + expect( + formatCommandLine( + addCommand(pm('pnpm'), ['msw'], { dev: true, exact: true }) + ) + ).toBe('pnpm add --save-dev --save-exact msw') + expect( + formatCommandLine( + addCommand(pm('yarn', { yarnFlavor: 'classic' }), ['msw'], { + dev: true, + }) + ) + ).toBe('yarn add --dev msw') + }) + + it('installs, frozen or not', () => { + expect(formatCommandLine(installCommand(pm('npm'), { frozen: true }))).toBe( + 'npm ci' + ) + expect( + formatCommandLine( + installCommand(pm('yarn', { yarnFlavor: 'berry' }), { frozen: true }) + ) + ).toBe('yarn install --immutable') + expect( + formatCommandLine( + installCommand(pm('yarn', { yarnFlavor: 'classic' }), { frozen: true }) + ) + ).toBe('yarn install --frozen-lockfile') + expect(formatCommandLine(installCommand(pm('bun')))).toBe('bun install') + }) + + it('runs project binaries per manager', () => { + expect( + formatCommandLine( + execCommand(pm('pnpm'), 'tsc', ['--noEmit'], { workspace: WEB }) + ) + ).toBe('pnpm --filter web exec tsc --noEmit') + expect( + formatCommandLine( + execCommand(pm('npm'), 'tsc', ['--noEmit'], { workspace: WEB }) + ) + ).toBe('npm exec --workspace web -- tsc --noEmit') + expect( + formatCommandLine( + execCommand(pm('yarn', { yarnFlavor: 'classic' }), 'tsc') + ) + ).toBe('yarn tsc') + expect( + formatCommandLine(execCommand(pm('yarn', { yarnFlavor: 'berry' }), 'tsc')) + ).toBe('yarn exec tsc') + expect(formatCommandLine(execCommand(pm('bun'), 'tsc'))).toBe('bun x tsc') + }) + + it('downloads-and-runs a package once', () => { + expect( + formatCommandLine(dlxCommand(pm('npm'), 'brew.new@0.18.0', ['init'])) + ).toBe('npx -y brew.new@0.18.0 init') + expect(formatCommandLine(dlxCommand(pm('pnpm'), 'brew.new'))).toBe( + 'pnpm dlx brew.new' + ) + expect( + formatCommandLine(dlxCommand(pm('yarn', { yarnFlavor: 'classic' }), 'x')) + ).toBe('npx -y x') + expect(formatCommandLine(dlxCommand(pm('bun'), 'x'))).toBe('bunx x') + }) + + it('shell-quotes arguments that need it', () => { + expect( + formatCommandLine({ + command: 'npm', + args: ['install', "it's"], + cwd: 'apps/my app', + }) + ).toBe(`cd 'apps/my app' && npm install 'it'\\''s'`) + }) +}) diff --git a/tests/lib/project/state.test.ts b/tests/lib/project/state.test.ts new file mode 100644 index 0000000..b366aa7 --- /dev/null +++ b/tests/lib/project/state.test.ts @@ -0,0 +1,164 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + BREW_STATE_SCHEMA_URL, + BrewStateError, + createBrewState, + parseBrewState, + readBrewState, + serializeBrewState, +} from '../../../src/lib/project/state' +import { tempProject } from '../../helpers/fixtures' + +const PROJECT_ID = '0b7f3c1e-9a2d-4e8b-b6c5-3d1f2a9e8c47' +const SHA = 'a'.repeat(64) + +describe('brew.json state', () => { + it('creates a v1 state with a fresh UUID and empty maps', () => { + const state = createBrewState() + expect(state.version).toBe(1) + expect(state.$schema).toBe(BREW_STATE_SCHEMA_URL) + expect(state.projectId).toMatch(/^[0-9a-f-]{36}$/) + expect(state.emails).toEqual({}) + expect(state.files).toEqual({}) + }) + + it('serializes with a stable key order, whatever order it was built in', () => { + const state = createBrewState({ + projectId: PROJECT_ID, + mcp: ['vscode', 'cursor', 'cursor'], + files: { + 'lib/brew.ts': { generator: 'init', sha256: SHA }, + '.env.example': { sha256: SHA, generator: 'init' }, + }, + emails: { + 'lifecycle.welcome': { automationId: 'am_2', triggerEventId: 'tev_2' }, + 'auth.verify-email': { triggerEventId: 'tev_1', automationId: 'am_1' }, + }, + providerConfig: { supabase: { z: 1, a: { y: 2, b: 3 } } }, + framework: 'next', + brandId: 'bd_1', + }) + const text = serializeBrewState(state) + expect(text).toBe( + `${JSON.stringify( + { + $schema: BREW_STATE_SCHEMA_URL, + version: 1, + projectId: PROJECT_ID, + brandId: 'bd_1', + framework: 'next', + emails: { + 'auth.verify-email': { + triggerEventId: 'tev_1', + automationId: 'am_1', + }, + 'lifecycle.welcome': { + triggerEventId: 'tev_2', + automationId: 'am_2', + }, + }, + files: { + '.env.example': { sha256: SHA, generator: 'init' }, + 'lib/brew.ts': { sha256: SHA, generator: 'init' }, + }, + providerConfig: { supabase: { a: { b: 3, y: 2 }, z: 1 } }, + mcp: ['cursor', 'vscode'], + }, + null, + 2 + )}\n` + ) + const reparsed = parseBrewState(text) + expect(reparsed.ok).toBe(true) + if (reparsed.ok) { + expect(serializeBrewState(reparsed.state, reparsed.extra)).toBe(text) + } + }) + + it('keeps unknown top-level keys from a newer CLI on rewrite', () => { + const text = JSON.stringify({ + version: 1, + projectId: PROJECT_ID, + emails: {}, + files: {}, + zeta: true, + alpha: { kept: 1 }, + }) + const parsed = parseBrewState(text) + expect(parsed.ok).toBe(true) + if (parsed.ok) { + expect(parsed.extra).toEqual({ zeta: true, alpha: { kept: 1 } }) + const out = JSON.parse(serializeBrewState(parsed.state, parsed.extra)) + expect(Object.keys(out).slice(-2)).toEqual(['alpha', 'zeta']) + } + }) + + it('validates by hand, naming every problem', () => { + const parsed = parseBrewState( + JSON.stringify({ + version: 2, + projectId: 'nope', + brandId: '', + contractsPath: '../outside.ts', + emails: { 'Bad Key': { triggerEventId: 'tev_1' } }, + files: { '/abs.ts': { sha256: 'short', generator: '' } }, + mcp: 'cursor', + }) + ) + expect(parsed.ok).toBe(false) + if (!parsed.ok) { + expect(parsed.errors).toEqual([ + 'version must be 1 (got 2); a newer CLI may have written this file', + 'projectId must be a UUID', + 'brandId must be a non-empty string', + 'contractsPath must be a project-relative path', + 'emails.Bad Key: not a valid trigger key', + 'emails.Bad Key must be { triggerEventId: string, automationId: string }', + 'files./abs.ts: not a project-relative path', + 'files./abs.ts must be { sha256: <64 hex chars>, generator: string }', + 'mcp must be an array of client ids', + ]) + } + expect(parseBrewState('{').ok).toBe(false) + expect(parseBrewState('[]').ok).toBe(false) + }) + + it('reads brew.json from a project root: absent, valid, invalid', () => { + const root = tempProject() + expect(readBrewState(root)).toBeNull() + const state = createBrewState({ projectId: PROJECT_ID }) + writeFileSync(join(root, 'brew.json'), serializeBrewState(state)) + expect(readBrewState(root)?.state.projectId).toBe(PROJECT_ID) + writeFileSync(join(root, 'brew.json'), '{"version":1}') + expect(() => readBrewState(root)).toThrow(BrewStateError) + }) + + it('ships a JSON Schema that covers exactly the known keys', () => { + const schema = JSON.parse( + readFileSync( + new URL('../../../schemas/brew.json', import.meta.url), + 'utf8' + ) + ) as { $id: string; properties: Record } + expect(schema.$id).toBe(BREW_STATE_SCHEMA_URL) + expect(Object.keys(schema.properties)).toEqual( + Object.keys( + JSON.parse( + serializeBrewState( + createBrewState({ + projectId: PROJECT_ID, + brandId: 'b', + framework: 'f', + auth: 'a', + contractsPath: 'c.ts', + providerConfig: {}, + mcp: [], + }) + ) + ) + ) + ) + }) +}) diff --git a/tests/lib/project/vendor-http.test.ts b/tests/lib/project/vendor-http.test.ts new file mode 100644 index 0000000..e7cd12b --- /dev/null +++ b/tests/lib/project/vendor-http.test.ts @@ -0,0 +1,252 @@ +import { PassThrough } from 'node:stream' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { makeContext } from '../../../src/lib/context' +import { + CliInterruptError, + CliTimeoutError, + CliVendorError, + toErrorEnvelope, + toExitCode, +} from '../../../src/lib/errors' +import { + parseRetryAfter, + vendorFetch, +} from '../../../src/lib/project/vendor-http' +import type { CliContext, CliIo } from '../../../src/lib/types' +import { server } from '../../helpers/msw-server' + +const TOKEN = 'sbp_0123456789abcdef0123456789abcdef' +const URL_BASE = 'https://api.supabase.com/v1/projects/abcd/config/auth' + +function context(io: Partial = {}, flags = {}): CliContext { + return makeContext({ + io: { + stdout: new PassThrough(), + stderr: new PassThrough(), + isTtyOut: false, + isTtyIn: false, + env: {}, + readStdin: async () => '', + readLine: async () => '', + ...io, + }, + flags: { json: true, quiet: true, ...flags }, + rawArgv: [], + }) +} + +async function failure(promise: Promise): Promise { + try { + await promise + } catch (error) { + return error + } + throw new Error('expected a rejection') +} + +describe('vendorFetch', () => { + it('sends JSON and returns the parsed body, recording nothing', async () => { + let seen: { auth: string | null; body: unknown } | undefined + server.use( + http.patch(URL_BASE, async ({ request }) => { + seen = { + auth: request.headers.get('authorization'), + body: await request.json(), + } + return HttpResponse.json({ ok: true }) + }) + ) + const ctx = context() + const response = await vendorFetch(ctx, { + vendor: 'Supabase', + method: 'PATCH', + url: URL_BASE, + headers: { Authorization: `Bearer ${TOKEN}` }, + json: { hook_send_email_enabled: true }, + }) + expect(response.status).toBe(200) + expect(response.json).toEqual({ ok: true }) + expect(seen).toEqual({ + auth: `Bearer ${TOKEN}`, + body: { hook_send_email_enabled: true }, + }) + expect(ctx.transport.attempts()).toBe(0) + expect(ctx.transport.lastRequest()).toBeUndefined() + }) + + it('raises VENDOR_ERROR {vendor, status} without echoing the token', async () => { + server.use( + http.get(URL_BASE, () => + HttpResponse.json( + { + message: `Invalid token ${TOKEN} for project; key sk_live_abcdefghijk`, + }, + { status: 401 } + ) + ) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + headers: { Authorization: `Bearer ${TOKEN}` }, + }) + ) + expect(error).toBeInstanceOf(CliVendorError) + const envelope = toErrorEnvelope(error) + expect(envelope).toMatchObject({ + code: 'VENDOR_ERROR', + details: { vendor: 'Supabase', status: 401 }, + }) + expect(JSON.stringify(envelope)).not.toContain(TOKEN) + expect(JSON.stringify(envelope)).not.toContain('sk_live_abcdefghijk') + expect(envelope.message).toContain('[redacted]') + // A vendor 401 is not a Brew auth failure: exit 1, not 3. + expect(toExitCode(error)).toBe(1) + }) + + it('reads Clerk-style error arrays', async () => { + server.use( + http.get('https://api.clerk.com/v1/instance', () => + HttpResponse.json( + { + errors: [{ message: 'short', long_message: 'Instance not found' }], + }, + { status: 404 } + ) + ) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Clerk', + method: 'GET', + url: 'https://api.clerk.com/v1/instance', + }) + ) + expect((error as Error).message).toBe( + 'Clerk answered 404: Instance not found' + ) + }) + + it('waits out a short retry-after on 429, then succeeds', async () => { + let calls = 0 + server.use( + http.get(URL_BASE, () => { + calls += 1 + return calls === 1 + ? new HttpResponse(null, { + status: 429, + headers: { 'retry-after': '0' }, + }) + : HttpResponse.json({ ok: true }) + }) + ) + const response = await vendorFetch(context(), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + }) + expect(response.status).toBe(200) + expect(calls).toBe(2) + }) + + it('gives up on a retry-after longer than the bound', async () => { + let calls = 0 + server.use( + http.get(URL_BASE, () => { + calls += 1 + return new HttpResponse(null, { + status: 429, + headers: { 'retry-after': '120' }, + }) + }) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + maxRetryAfterMs: 5_000, + }) + ) + expect(calls).toBe(1) + expect(error).toMatchObject({ status: 429, retryAfterMs: 120_000 }) + expect(toErrorEnvelope(error).suggestion).toMatch(/rate limiting/) + }) + + it('times out an attempt whose body never ends', async () => { + server.use( + http.get( + URL_BASE, + () => + new HttpResponse( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"ok":')) + }, + }), + { headers: { 'content-type': 'application/json' } } + ) + ) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + timeoutMs: 50, + }) + ) + expect(error).toBeInstanceOf(CliTimeoutError) + }) + + it('stops the moment the command is interrupted', async () => { + const controller = new AbortController() + server.use( + http.get(URL_BASE, async () => { + controller.abort(new CliInterruptError('SIGINT')) + await new Promise((resolve) => setTimeout(resolve, 1_000)) + return HttpResponse.json({ ok: true }) + }) + ) + const error = await failure( + vendorFetch(context({ signal: controller.signal }), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + }) + ) + expect(error).toBeInstanceOf(CliInterruptError) + }) + + it('reports an unreachable vendor without its query-string token', async () => { + server.use( + http.get('https://api.resend.com/emails', () => HttpResponse.error()) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Resend', + method: 'GET', + url: `https://api.resend.com/emails?api_key=${TOKEN}`, + }) + ) + expect(error).toMatchObject({ vendor: 'Resend', status: null }) + expect((error as Error).message).toMatch( + /^Could not reach Resend at https:\/\/api\.resend\.com/ + ) + expect((error as Error).message).not.toContain(TOKEN) + }) +}) + +describe('parseRetryAfter', () => { + it('reads delta-seconds and HTTP dates', () => { + expect(parseRetryAfter('3')).toBe(3_000) + expect(parseRetryAfter('1.5')).toBe(1_500) + const now = Date.parse('2026-10-11T00:00:00Z') + expect(parseRetryAfter('Sun, 11 Oct 2026 00:00:10 GMT', now)).toBe(10_000) + expect(parseRetryAfter('soon')).toBeUndefined() + expect(parseRetryAfter(null)).toBeUndefined() + }) +}) From 4b5bb4e55ea2250547323d8dcefe7ea3ddf39a00 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 11 Oct 2026 09:38:10 +0200 Subject: [PATCH 2/9] feat(cli): print re-run commands the way the CLI was invoked getInvocationPrefix() reads BREW_CLI_INVOKED_AS, which launcher packages (`npx -y brew.new@`) will set, and defaults to `brew-cli`. confirmCommand, the confirmation envelope's command, retryCommand, and every "run ..." / "see ..." hint (missing key, doctor, types --check, preview polling, usage errors) now start with it, so an agent re-runs through the same launcher instead of a brew-cli binary that is not on the PATH. Anything other than plain shell words falls back to brew-cli. The manifest lists the variable. Co-Authored-By: Claude Opus 5.5 --- src/cli.ts | 4 +- src/commands/analytics/sends/list.ts | 2 +- src/commands/audiences/list.ts | 2 +- src/commands/automations/runs/list.ts | 2 +- src/commands/config.ts | 13 +- src/commands/contacts/search.ts | 10 +- src/commands/doctor.ts | 3 +- .../emails/create-inbox-placement-test.ts | 3 +- src/commands/emails/preview-clients.ts | 7 +- src/commands/emails/restore.ts | 3 +- src/commands/flows/list.ts | 2 +- src/commands/types.ts | 5 +- src/lib/client.ts | 3 +- src/lib/compat.ts | 4 +- src/lib/confirm.ts | 23 ++- src/lib/invocation.ts | 44 ++++++ src/lib/manifest.ts | 2 + tests/lib/invocation.test.ts | 131 ++++++++++++++++++ 18 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 src/lib/invocation.ts create mode 100644 tests/lib/invocation.test.ts diff --git a/src/cli.ts b/src/cli.ts index 323bb54..9ebaaed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { toExitCode, } from './lib/errors' import { IDEMPOTENCY_FLAG } from './lib/input' +import { getInvocationPrefix } from './lib/invocation' import { matchesTemplate } from './lib/long-running' import { printData } from './lib/output' import type { CliContext, CliIo, CommandTraits } from './lib/types' @@ -161,7 +162,8 @@ function errorContextFor( ? ctx.traits?.replays === true : (bound.flags ?? []).includes(IDEMPOTENCY_FLAG), readsStdin: readsStdin(ctx.rawArgv), - retryCommand: (key) => buildRetryCommand(ctx.rawArgv, key), + retryCommand: (key) => + buildRetryCommand(ctx.rawArgv, key, getInvocationPrefix(ctx.io.env)), } } diff --git a/src/commands/analytics/sends/list.ts b/src/commands/analytics/sends/list.ts index bca83da..bc61051 100644 --- a/src/commands/analytics/sends/list.ts +++ b/src/commands/analytics/sends/list.ts @@ -61,7 +61,7 @@ export const analyticsSendsListCommand = defineCommand({ return { data: singleRowPage(row), human: renderSends([row]) } } if (include !== undefined) { - throw includeRidesDetailRead('sends get', '--send') + throw includeRidesDetailRead(ctx.io.env, 'sends get', '--send') } const input = mergeInput(base, { emailId: flagString(flags.email), diff --git a/src/commands/audiences/list.ts b/src/commands/audiences/list.ts index dbf1ceb..8286d6f 100644 --- a/src/commands/audiences/list.ts +++ b/src/commands/audiences/list.ts @@ -48,7 +48,7 @@ export const audiencesListCommand = defineCommand({ if ( (flagString(flags.include) ?? inputField(base, 'include')) !== undefined ) { - throw includeRidesDetailRead('audiences get') + throw includeRidesDetailRead(ctx.io.env, 'audiences get') } const input = mergeInput(base, { search: flagString(flags.search), diff --git a/src/commands/automations/runs/list.ts b/src/commands/automations/runs/list.ts index 0923d50..b24742f 100644 --- a/src/commands/automations/runs/list.ts +++ b/src/commands/automations/runs/list.ts @@ -84,7 +84,7 @@ export const automationsRunsListCommand = defineCommand({ return { data: singleRowPage(row), human: renderRuns([row]) } } if (include !== undefined) { - throw includeRidesDetailRead('automations runs get', '--run') + throw includeRidesDetailRead(ctx.io.env, 'automations runs get', '--run') } const input = mergeInput(base, { automationId: flagString(flags.automation), diff --git a/src/commands/config.ts b/src/commands/config.ts index 6a29234..64cea6f 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -7,6 +7,7 @@ import { } from '../lib/config-store' import { defineCommand } from '../lib/define-command' import { CliUsageError } from '../lib/errors' +import { getInvocationPrefix } from '../lib/invocation' import type { CliContext } from '../lib/types' const SETTABLE_KEYS = ['brandId', 'apiUrl'] as const @@ -47,7 +48,7 @@ export const configSetCommand = defineCommand({ ], examples: ['brew-cli config set brandId bd_123'], run: async ({ ctx, args }) => { - const key = requireSettableKey(args.key) + const key = requireSettableKey(args.key, ctx) const value = args.value ?? '' if (value === '') { throw new CliUsageError('A non-empty value is required.') @@ -65,7 +66,7 @@ export const configUnsetCommand = defineCommand({ args: [{ name: 'key', summary: 'brandId | apiUrl', isRequired: true }], examples: ['brew-cli config unset brandId'], run: async ({ ctx, args }) => { - const key = requireSettableKey(args.key) + const key = requireSettableKey(args.key, ctx) const path = updateConfig(ctx.io.env, { [key]: undefined }) return { data: { [key]: null, configPath: path } } }, @@ -91,10 +92,14 @@ function requireKnownKey( throw new CliUsageError('Unknown key. Expected brandId, apiUrl, or apiKey.') } -function requireSettableKey(value: string | undefined): 'apiUrl' | 'brandId' { +function requireSettableKey( + value: string | undefined, + ctx: CliContext +): 'apiUrl' | 'brandId' { if (value === 'apiKey') { + const prefix = getInvocationPrefix(ctx.io.env) throw new CliUsageError( - 'The API key is managed by `brew-cli login` / `brew-cli logout`.' + `The API key is managed by \`${prefix} login\` / \`${prefix} logout\`.` ) } const key = requireKnownKey(value) diff --git a/src/commands/contacts/search.ts b/src/commands/contacts/search.ts index cf6982d..118fd9f 100644 --- a/src/commands/contacts/search.ts +++ b/src/commands/contacts/search.ts @@ -10,6 +10,7 @@ import { parseFilterFlags, readJsonFlag, } from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' import { renderTable } from '../../lib/output' import { ALL_FLAG, @@ -63,7 +64,7 @@ export const contactsSearchCommand = defineCommand({ limit: flagInt(flags.limit, '--limit'), cursor: flagString(flags.cursor), }) - refuseCountMode(input) + refuseCountMode(input, getInvocationPrefix(ctx.io.env)) const contacts = ctx.client().contacts if (flags.all === true) { const rows = await collectAll( @@ -93,13 +94,16 @@ export const contactsSearchCommand = defineCommand({ * riding --input was dropped and the command printed a page of rows as if it * had counted; `groupBy` / `bucket` only mean anything beside it. */ -function refuseCountMode(input: Readonly>): void { +function refuseCountMode( + input: Readonly>, + prefix: string +): void { const keys = ['count', 'groupBy', 'bucket'].filter( (key) => input[key] !== undefined && input[key] !== false ) if (keys.length > 0) { throw new CliUsageError( - `contacts search returns rows; --input ${keys.join(', ')} asks for a count. Use \`brew-cli contacts count\` for the total or \`brew-cli contacts count-by --group-by | --bucket \` for groups.` + `contacts search returns rows; --input ${keys.join(', ')} asks for a count. Use \`${prefix} contacts count\` for the total or \`${prefix} contacts count-by --group-by | --bucket \` for groups.` ) } } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 8b62675..db5f180 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,6 +2,7 @@ import { BrewApiError } from '@brew.new/sdk' import { maskApiKey, resolveAuth } from '../lib/client' import { defineCommand } from '../lib/define-command' import { CliApiError } from '../lib/errors' +import { getInvocationPrefix } from '../lib/invocation' import type { CliContext } from '../lib/types' /** Discovery endpoints without a dedicated command route. */ @@ -117,7 +118,7 @@ export const doctorCommand = defineCommand({ `${isReachable ? 'ok' : 'FAIL'} api ${auth.apiUrl} (${apiVersion ?? 'unreachable'})`, isKeyPresent ? `${isAuthValid === false ? 'FAIL' : isAuthValid === true ? 'ok' : '??'} auth ${maskApiKey(auth.apiKey)} via ${auth.apiKeySource}${plan === null ? '' : ` (${plan} plan)`}` - : '-- no API key (run `brew-cli login` or set BREW_API_KEY)', + : `-- no API key (run \`${getInvocationPrefix(ctx.io.env)} login\` or set BREW_API_KEY)`, isSurfaceChecked ? missingLocally.length === 0 ? `ok surface in sync (${ctx.commands.length} commands cover the live API)` diff --git a/src/commands/emails/create-inbox-placement-test.ts b/src/commands/emails/create-inbox-placement-test.ts index e25df5a..6d73a14 100644 --- a/src/commands/emails/create-inbox-placement-test.ts +++ b/src/commands/emails/create-inbox-placement-test.ts @@ -8,6 +8,7 @@ import { requestOptions, toStringArray, } from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' export const emailsCreateInboxPlacementTestCommand = defineCommand({ path: ['emails', 'create-inbox-placement-test'], @@ -50,7 +51,7 @@ export const emailsCreateInboxPlacementTestCommand = defineCommand({ const domainId = flagString(flags.domain) if (domainId === undefined) { throw new CliUsageError( - '--domain is required (a verified sending domain id; see `brew-cli domains list`).' + `--domain is required (a verified sending domain id; see \`${getInvocationPrefix(ctx.io.env)} domains list\`).` ) } const subject = flagString(flags.subject) diff --git a/src/commands/emails/preview-clients.ts b/src/commands/emails/preview-clients.ts index 9d3e0e2..79dbf9b 100644 --- a/src/commands/emails/preview-clients.ts +++ b/src/commands/emails/preview-clients.ts @@ -8,6 +8,7 @@ import { requestOptions, toStringArray, } from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' import { progress } from '../../lib/output' export const emailsPreviewClientsCommand = defineCommand({ @@ -56,7 +57,7 @@ export const emailsPreviewClientsCommand = defineCommand({ }), requestOptions(flags) ) - const hint = pollHint(job) + const hint = pollHint(job, getInvocationPrefix(ctx.io.env)) if (hint !== undefined) { progress(ctx, hint) } @@ -69,7 +70,7 @@ export const emailsPreviewClientsCommand = defineCommand({ * (no `previewId`, `ready | partial`), so the job fields are read * defensively until the CLI adopts SDK 11. */ -function pollHint(job: unknown): string | undefined { +function pollHint(job: unknown, prefix: string): string | undefined { if ( typeof job !== 'object' || job === null || @@ -85,5 +86,5 @@ function pollHint(job: unknown): string | undefined { ) { return } - return `Rendering job ${previewId} is ${status}. Poll it with: brew-cli emails get-client-preview ${previewId}` + return `Rendering job ${previewId} is ${status}. Poll it with: ${prefix} emails get-client-preview ${previewId}` } diff --git a/src/commands/emails/restore.ts b/src/commands/emails/restore.ts index 08baf95..ae23e29 100644 --- a/src/commands/emails/restore.ts +++ b/src/commands/emails/restore.ts @@ -1,6 +1,7 @@ import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { flagString, IDEMPOTENCY_FLAG, requestOptions } from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' export const emailsRestoreCommand = defineCommand({ path: ['emails', 'restore'], @@ -27,7 +28,7 @@ export const emailsRestoreCommand = defineCommand({ const emailVersionId = flagString(flags.toVersion) if (emailVersionId === undefined) { throw new CliUsageError( - '--to-version is required (a version id; see `brew-cli emails get --include versions`).' + `--to-version is required (a version id; see \`${getInvocationPrefix(ctx.io.env)} emails get --include versions\`).` ) } const result = await ctx diff --git a/src/commands/flows/list.ts b/src/commands/flows/list.ts index 59c225b..754e7f1 100644 --- a/src/commands/flows/list.ts +++ b/src/commands/flows/list.ts @@ -93,7 +93,7 @@ export const flowsListCommand = defineCommand({ } } if (include !== undefined) { - throw includeRidesDetailRead('flows get', '--slug') + throw includeRidesDetailRead(ctx.io.env, 'flows get', '--slug') } const input = mergeInput(base, { brand: flagString(flags.brandDomain), diff --git a/src/commands/types.ts b/src/commands/types.ts index e571d13..2fbc554 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -4,6 +4,7 @@ import { readFileSync, writeFileSync } from 'node:fs' import type { ListTriggersInput, ListTriggersResponse } from '@brew.new/sdk' import { defineCommand } from '../lib/define-command' import { asSdkInput } from '../lib/input' +import { getInvocationPrefix } from '../lib/invocation' import { collectAll } from '../lib/paginate' /** @@ -201,8 +202,8 @@ export const typesCommand = defineCommand({ data: { checked: outPath, upToDate: false }, human: existing === null - ? `${outPath} does not exist — run \`brew-cli types\` first.` - : `${outPath} is stale — the workspace's payload contracts changed. Run \`brew-cli types\` to regenerate.`, + ? `${outPath} does not exist — run \`${getInvocationPrefix(ctx.io.env)} types\` first.` + : `${outPath} is stale — the workspace's payload contracts changed. Run \`${getInvocationPrefix(ctx.io.env)} types\` to regenerate.`, // Documented drift contract: exit 1 (not the usage-error 2). exitCode: 1, } diff --git a/src/lib/client.ts b/src/lib/client.ts index b91ee6b..63b082f 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -2,6 +2,7 @@ import { createBrewClient } from '@brew.new/sdk' import { CLI_NAME, CLI_VERSION } from '../version' import { readConfig } from './config-store' import { CliAuthError } from './errors' +import { getInvocationPrefix } from './invocation' import type { GlobalFlags } from './types' export type BrewClient = ReturnType @@ -70,7 +71,7 @@ export function resolveAuth(input: { } throw new CliAuthError( 'No API key found.', - `Run \`brew-cli login\`, set BREW_API_KEY, or pass --api-key. Create keys at ${LOGIN_KEYS_URL}` + `Run \`${getInvocationPrefix(env)} login\`, set BREW_API_KEY, or pass --api-key. Create keys at ${LOGIN_KEYS_URL}` ) } const apiKeySource: AuthSource = keyFromFlag diff --git a/src/lib/compat.ts b/src/lib/compat.ts index a0b8491..40fed1a 100644 --- a/src/lib/compat.ts +++ b/src/lib/compat.ts @@ -1,4 +1,5 @@ import { CliUsageError } from './errors' +import { getInvocationPrefix } from './invocation' /** * 0.6 shims. Flags and command names are additive-only after release @@ -32,11 +33,12 @@ export function inputField(base: unknown, key: string): string | undefined { /** `--include` used to ride list reads; detail reads carry it now. */ export function includeRidesDetailRead( + env: Readonly>, getCommand: string, idFlag?: string ): CliUsageError { const pairing = idFlag === undefined ? '' : `, or pair it with ${idFlag}` return new CliUsageError( - `--include rides the detail read: use \`brew-cli ${getCommand} --include …\`${pairing}.` + `--include rides the detail read: use \`${getInvocationPrefix(env)} ${getCommand} --include …\`${pairing}.` ) } diff --git a/src/lib/confirm.ts b/src/lib/confirm.ts index 39119a2..54ac37e 100644 --- a/src/lib/confirm.ts +++ b/src/lib/confirm.ts @@ -1,5 +1,6 @@ import type { CommandInvocation, CommandSpec } from './define-command' import { CommandAbortedError, ConfirmationRequiredError } from './errors' +import { DEFAULT_INVOCATION_PREFIX, getInvocationPrefix } from './invocation' /** * The confirmation protocol for destructive commands: @@ -47,21 +48,30 @@ export async function enforceConfirmation( resumeDeadline() } } + const prefix = getInvocationPrefix(ctx.io.env) throw new ConfirmationRequiredError({ confirmationRequired: true, - command: ['brew-cli', ...spec.path].join(' '), + command: [prefix, ...spec.path].join(' '), summary, confirmCommand: buildConfirmCommand( options.confirmationArgv ?? (invocation.flags.input === '-' && invocation.input !== undefined ? inlineStdinInputArgv(ctx.rawArgv, JSON.stringify(invocation.input)) - : ctx.rawArgv) + : ctx.rawArgv), + prefix ), }) } -export function buildConfirmCommand(rawArgv: readonly string[]): string { - return ['brew-cli', ...rerunTokens(rawArgv), '--yes'].join(' ') +/** + * `prefix` is how the CLI was invoked (`getInvocationPrefix`): `brew-cli`, + * or a launcher such as `npx -y brew.new@0.18.0`. + */ +export function buildConfirmCommand( + rawArgv: readonly string[], + prefix: string = DEFAULT_INVOCATION_PREFIX +): string { + return [prefix, ...rerunTokens(rawArgv), '--yes'].join(' ') } /** Confirm stdin requests after parsing, carrying the merged body on a re-run. */ @@ -109,10 +119,11 @@ function inlineStdinInputArgv( */ export function buildRetryCommand( rawArgv: readonly string[], - idempotencyKey: string + idempotencyKey: string, + prefix: string = DEFAULT_INVOCATION_PREFIX ): string { return [ - 'brew-cli', + prefix, ...rerunTokens(rawArgv, { dropIdempotencyKey: true }), '--idempotency-key', shellQuote(idempotencyKey), diff --git a/src/lib/invocation.ts b/src/lib/invocation.ts new file mode 100644 index 0000000..0986234 --- /dev/null +++ b/src/lib/invocation.ts @@ -0,0 +1,44 @@ +import { CLI_NAME } from '../version' + +/** The command prefix printed when nothing says otherwise. */ +export const DEFAULT_INVOCATION_PREFIX = CLI_NAME + +/** + * Shell words a launcher may name: a package spec with its version + * (`brew.new@0.18.0`, `@brew.new/cli@0.18.0`), runner flags (`-y`), runner + * names (`npx`, `pnpm dlx`, `bunx`). Each word must stay one shell word, so + * the prefix can lead a copy-pasted command without quoting. + */ +const SAFE_WORD = /^[A-Za-z0-9@%+=:,./_-]+$/ +const MAX_PREFIX_LENGTH = 200 + +/** + * How the user (or agent) invoked this CLI, for every command line the CLI + * prints for them to run next: `confirmCommand`, `retryCommand`, "run + * `… login`" hints. A launcher package (`npx -y brew.new@0.18.0 …`) sets + * `BREW_CLI_INVOKED_AS` so those lines re-run through the same launcher + * instead of naming a `brew-cli` binary that is not on the PATH. + * + * Anything that is not a short run of plain shell words (whitespace inside a + * word, quotes, `;`, `$`, a newline) falls back to `brew-cli`: the prefix is + * printed into commands agents execute, so it must never smuggle shell + * syntax. + */ +export function getInvocationPrefix( + env: Readonly> +): string { + const raw = env.BREW_CLI_INVOKED_AS + if (raw === undefined) { + return DEFAULT_INVOCATION_PREFIX + } + const words = raw.trim().split(/ +/) + const prefix = words.join(' ') + if ( + prefix === '' || + prefix.length > MAX_PREFIX_LENGTH || + !words.every((word) => SAFE_WORD.test(word)) + ) { + return DEFAULT_INVOCATION_PREFIX + } + return prefix +} diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index dd7386b..ae3c1e3 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -21,6 +21,8 @@ export function buildManifest({ BREW_BRAND_ID: 'Brand id for organization-scoped keys', BREW_API_URL: 'API base URL', BREW_CLI_CONFIG_DIR: 'Config directory (default ~/.config/brew-cli)', + BREW_CLI_INVOKED_AS: + 'Command prefix for printed re-run commands (set by launchers such as `npx -y brew.new@`; default brew-cli)', }, exitCodes: { '0': 'success', diff --git a/tests/lib/invocation.test.ts b/tests/lib/invocation.test.ts new file mode 100644 index 0000000..e6fed33 --- /dev/null +++ b/tests/lib/invocation.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { http } from 'msw' +import { describe, expect, it } from 'vitest' +import { buildConfirmCommand, buildRetryCommand } from '../../src/lib/confirm' +import { CliInterruptError } from '../../src/lib/errors' +import { getInvocationPrefix } from '../../src/lib/invocation' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const LAUNCHER = 'npx -y brew.new@0.18.0' +const KEY = 'brew_abcdefghijklmnopqrstuvwxyz012345' + +function env( + extra: Record = {} +): Record { + return { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + ...extra, + } +} + +describe('getInvocationPrefix', () => { + it('defaults to brew-cli', () => { + expect(getInvocationPrefix({})).toBe('brew-cli') + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: '' })).toBe('brew-cli') + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: ' ' })).toBe('brew-cli') + }) + + it('uses the launcher a wrapper package sets', () => { + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: LAUNCHER })).toBe( + LAUNCHER + ) + expect( + getInvocationPrefix({ BREW_CLI_INVOKED_AS: ' pnpm dlx brew.new ' }) + ).toBe('pnpm dlx brew.new') + expect( + getInvocationPrefix({ + BREW_CLI_INVOKED_AS: 'npx -y @brew.new/cli@0.16.0', + }) + ).toBe('npx -y @brew.new/cli@0.16.0') + }) + + it('refuses anything that is not plain shell words', () => { + for (const unsafe of [ + 'npx brew.new; rm -rf ~', + 'npx $(whoami)', + "npx 'brew.new'", + 'npx\nbrew.new', + 'npx\tbrew.new', + 'npx `id`', + 'x'.repeat(201), + ]) { + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: unsafe })).toBe( + 'brew-cli' + ) + } + }) + + it('leads confirm and retry commands', () => { + expect( + buildConfirmCommand(['contacts', 'delete', 'a@x.com'], LAUNCHER) + ).toBe(`${LAUNCHER} contacts delete a@x.com --yes`) + expect(buildRetryCommand(['emails', 'generate'], 'k1', LAUNCHER)).toBe( + `${LAUNCHER} emails generate --idempotency-key k1` + ) + expect(buildConfirmCommand(['contacts', 'delete', 'a@x.com'])).toBe( + 'brew-cli contacts delete a@x.com --yes' + ) + }) +}) + +describe('invocation prefix in envelopes', () => { + it('prints the launcher in the exit-4 confirmation envelope', async () => { + const result = await runCli( + ['--api-key', KEY, 'contacts', 'delete', 'jane@example.com'], + { env: env({ BREW_CLI_INVOKED_AS: LAUNCHER }) } + ) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ + confirmationRequired: true, + command: `${LAUNCHER} contacts delete`, + confirmCommand: `${LAUNCHER} contacts delete jane@example.com --yes`, + }) + expect(result.stdout).not.toContain(KEY) + }) + + it('prints the launcher in an interrupted write retryCommand', async () => { + const controller = new AbortController() + let sentKey: string | null = null + server.use( + http.post('https://brew.new/api/v1/emails', ({ request }) => { + sentKey = request.headers.get('idempotency-key') + controller.abort(new CliInterruptError('SIGINT')) + return new Promise(() => undefined) + }) + ) + const result = await runCli(['emails', 'generate', '--prompt', 'Hi'], { + env: env({ BREW_API_KEY: KEY, BREW_CLI_INVOKED_AS: LAUNCHER }), + signal: controller.signal, + }) + expect(result.code).toBe(130) + const line = result.stderr.trim().split('\n').at(-1) ?? '' + const error = (JSON.parse(line) as { error: { retryCommand: string } }) + .error + expect(error.retryCommand).toBe( + `${LAUNCHER} emails generate --prompt Hi --idempotency-key ${String(sentKey)}` + ) + }) + + it('names the launcher in run-next hints (missing key)', async () => { + const result = await runCli(['usage'], { + env: env({ BREW_CLI_INVOKED_AS: LAUNCHER }), + }) + expect(result.code).toBe(3) + expect(JSON.parse(result.stderr).error.suggestion).toContain( + `Run \`${LAUNCHER} login\`` + ) + }) + + it('names the launcher in usage-error hints', async () => { + const result = await runCli(['config', 'set', 'apiKey', 'x'], { + env: env({ BREW_CLI_INVOKED_AS: LAUNCHER }), + }) + expect(result.code).toBe(2) + expect(JSON.parse(result.stderr).error.message).toBe( + `The API key is managed by \`${LAUNCHER} login\` / \`${LAUNCHER} logout\`.` + ) + }) +}) From e0bd36ca809b92e2e87b9562985370dd45d32960 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 11 Oct 2026 09:38:10 +0200 Subject: [PATCH 3/9] feat(mcp): `mcp install` for Cursor, VS Code, Claude Code, Gemini, Windsurf, Codex `brew-cli mcp install` merges a `brew` server entry into each client's config and keeps every other server: .cursor/mcp.json, .vscode/mcp.json, .mcp.json, .gemini/settings.json (project scope, the default) or their user-scope files; Windsurf (~/.codeium/windsurf/mcp_config.json) and Codex (~/.codex/config.toml, appended once) only when named. Without --client it uses the clients the project shows (.cursor/, .vscode/, .claude/ or CLAUDECODE=1, .gemini/). --auth oauth writes the URL only; --auth env adds an Authorization header each client expands from BREW_API_KEY, never a literal key. A config with comments is reported `manual` with the snippet; --print and --dry-run write nothing. The MCP URL follows the --api-url origin. Cursor's deeplink is byte-identical to brew-v2 buildCursorInstallUrl; VS Code's is vscode:mcp/install?. Also: scripts/generate-skills.ts embeds skills/**/SKILL.md into src/generated/skills.ts (generate:skills:check in CI, release and `check`); the brew-cli skill documents mcp install and drops its stale command count; npm keywords for MCP, transactional/auth email, Resend and React Email; the package ships schemas/; README section, CHANGELOG Unreleased entry, regenerated docs/commands/README.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + CHANGELOG.md | 34 ++ README.md | 40 ++- docs/commands/README.md | 20 ++ package.json | 18 +- scripts/generate-skills.ts | 145 +++++++++ skills/brew-cli/SKILL.md | 26 +- src/cli.ts | 1 + src/commands/mcp/install.ts | 482 +++++++++++++++++++++++++++++ src/generated/skills.ts | 19 ++ src/lib/mcp-clients.ts | 303 ++++++++++++++++++ src/registry.ts | 2 + tests/commands/mcp-install.test.ts | 448 +++++++++++++++++++++++++++ tests/generate-skills.test.ts | 57 ++++ tests/package-metadata.test.ts | 30 ++ 16 files changed, 1619 insertions(+), 8 deletions(-) create mode 100644 scripts/generate-skills.ts create mode 100644 src/commands/mcp/install.ts create mode 100644 src/generated/skills.ts create mode 100644 src/lib/mcp-clients.ts create mode 100644 tests/commands/mcp-install.test.ts create mode 100644 tests/generate-skills.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af76f76..ba5d66d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - run: bun run lint - run: bun run test - run: bun run docs:commands:check + - run: bun run generate:skills:check - run: bun run build - name: Bin smoke run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b7e203..fa141bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,7 @@ jobs: - run: bun run lint - run: bun run test - run: bun run docs:commands:check + - run: bun run generate:skills:check - run: bun run build - name: Compile standalone binaries run: bun run scripts/build-binaries.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e528f07..81f7a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,40 @@ canvas commands: the published npm `0.16.0` artifact (`gitHead` on the host backend. Revocation requires confirmation, and create/refresh requests never retry credentials that cannot be replayed. +### Added + +- **`brew-cli mcp install`** adds the Brew MCP server to Cursor + (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code + (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, + Windsurf (`~/.codeium/windsurf/mcp_config.json`) and Codex + (`~/.codex/config.toml`). It merges a `brew` entry and keeps other + servers; a config with comments comes back `manual` with a snippet. + `--client`, `--scope project|user`, `--auth oauth|env` (env adds a header + the client fills from `BREW_API_KEY`, never a literal key), `--print`, + `--dry-run`. JSON output: + `{ mcpUrl, dryRun, clients: [{ client, path?, action, deeplink?, snippet?, note? }] }`. + Cursor and VS Code results carry one-click install deeplinks; the Cursor + link is byte-identical to the one brew.new serves. +- `BREW_CLI_INVOKED_AS`: a launcher package (`npx -y brew.new@`) + sets it, and `confirmCommand`, `retryCommand` and every "run …" hint + start with it instead of `brew-cli`. Anything but plain shell words falls + back to `brew-cli`. +- Project foundations for the coming `init` and `migrate` commands, + dependency-free under `src/lib/project/`: package-manager detection and + command builders (npm, pnpm, yarn classic and berry with PnP, bun, deno, + workspace filters), monorepo resolution (pnpm workspaces, npm/yarn/bun + workspaces, Turborepo, Nx), a create-only file writer (managed-file + hashes, marked-block upserts, atomic writes, dry-run plans), a dotenv + editor that never replaces or prints a value and refuses + browser-exposed names for secrets, `brew.json` state (schema shipped in + `schemas/brew.json`), gitignore checks, a line diff, and vendor HTTP + that honours `--timeout` and Ctrl-C and never echoes a credential. +- The agent skills in `skills/` are embedded in the build + (`src/generated/skills.ts`, `bun run generate:skills`; CI runs + `generate:skills:check`). +- npm keywords for MCP, transactional and auth email, Resend and React + Email, so the package shows up where people look for them. + ### Changed - Canvas session create, refresh, and revoke use the SDK methods. JSON output, diff --git a/README.md b/README.md index 9235a52..54f54b6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,39 @@ data on stdout and progress on stderr, and documents itself: brew-cli emails send --help ``` +## Connect your AI editor to Brew (MCP) + +```bash +brew-cli mcp install # the editors this project uses +brew-cli mcp install --client cursor claude-code # or name them +brew-cli mcp install --client all --dry-run --json # plan only +``` + +`mcp install` adds the Brew MCP server (`https://brew.new/api/mcp`, or the +origin of `--api-url` + `/api/mcp`) to each client's config, merging a +`brew` entry and keeping every other server: + +| Client | `--client` | Project file (`--scope project`, default) | User file (`--scope user`) | +| --- | --- | --- | --- | +| Cursor | `cursor` | `.cursor/mcp.json` | `~/.cursor/mcp.json` | +| VS Code | `vscode` | `.vscode/mcp.json` | the profile's `mcp.json` | +| Claude Code | `claude-code` | `.mcp.json` | prints `claude mcp add --scope user …` | +| Gemini CLI | `gemini` | `.gemini/settings.json` | `~/.gemini/settings.json` | +| Windsurf | `windsurf` | (user only) | `~/.codeium/windsurf/mcp_config.json` | +| Codex | `codex` | (user only) | `~/.codex/config.toml` (`$CODEX_HOME`) | + +Without `--client` it configures the clients it detects (`.cursor/`, +`.vscode/`, `.claude/` or `CLAUDECODE=1`, `.gemini/`); a detected Windsurf +or Codex is reported with its snippet but only written when you name it, +since its config lives in your home directory. `--auth oauth` (default) +writes the URL only and the client signs in on first use; `--auth env` +adds an `Authorization` header that the client fills from `BREW_API_KEY` +at connect time. A literal key is never written. A config file with +comments is left alone and reported `manual` with the snippet to paste. +`--print` prints the snippets and writes nothing. Each result is +`created`, `updated`, `unchanged`, `manual` or `printed`; Cursor and +VS Code results carry a one-click install `deeplink`. + ## Authentication | Precedence | Source | @@ -90,7 +123,10 @@ Designed for AI agents as first-class users: trigger fires) never hang waiting for input: non-interactive callers get exit `4` plus a JSON envelope containing a ready-to-run `confirmCommand`; pass `--yes` to proceed. Humans on a TTY get a y/N - prompt. Test sends (`emails send --test`) skip the gate. + prompt. Test sends (`emails send --test`) skip the gate. Printed + commands (`confirmCommand`, `retryCommand`, "run … login") start the + way the CLI was invoked: a launcher such as `npx -y brew.new@` + sets `BREW_CLI_INVOKED_AS`, so they re-run through it. - **Saved email recovery** — `409 EMAIL_RESULT_UNAVAILABLE` means the generation or edit committed. Do not repeat the write. Read the exact saved result using `error.details.emailId` and `emailVersionId`: @@ -121,7 +157,7 @@ The full generated reference lives in `contacts`, `fields`, `emails` (+ `groups`, `comments`), `sends`, `audiences`, `automations` (+ `triggers`, `runs`, `audience-runs`, `trigger-instances`), `analytics`, `insights`, `brand`, `brands`, `content`, -`templates`, `flows`, `chats`, `notifications`, plus +`templates`, `flows`, `chats`, `notifications`, `mcp`, plus `canvas sessions` and `login`/`logout`/`whoami`/`config`/`usage`/`health`/`docs`/`api`. For an embedded editor, create brands with an organization-scoped key, diff --git a/docs/commands/README.md b/docs/commands/README.md index 374e2b7..73b88d5 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -170,6 +170,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli doctor` | read | — | Trust check: auth, API reachability, and installed-CLI vs live-API drift | | `brew-cli docs` | read | — | Documentation pointers; --agent prints the command manifest | | `brew-cli docs api` | read | `GET /v1/help` | Fetch the live machine-readable API catalog (GET /v1/help) | +| `brew-cli mcp install` | write | — | Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex) | | `brew-cli api` | destructive | — | Raw authenticated request against the Brew public API | ## Details @@ -2685,6 +2686,25 @@ Fetch the live machine-readable API catalog (GET /v1/help) brew-cli docs api --json ``` +### brew-cli mcp install + +Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex) + +- Class: write +- `--client ` — Clients to configure: cursor, vscode, claude-code, gemini, windsurf, codex, all (default: the ones this project uses; Windsurf and Codex only when named) +- `--scope ` — project (files in this repo) or user (your home directory) +- `--auth ` — oauth (URL only; sign in from the client) or env (a header the client fills from BREW_API_KEY; never a literal key) +- `--print` — Print each config snippet instead of writing files +- `--dry-run` — Report what would change without writing anything + +```bash +brew-cli mcp install +brew-cli mcp install --client cursor claude-code +brew-cli mcp install --client codex --auth env +brew-cli mcp install --client all --dry-run --json +brew-cli mcp install --client vscode --print +``` + ### brew-cli api Raw authenticated request against the Brew public API diff --git a/package.json b/package.json index b6609b3..c5e63c5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,16 @@ "email", "marketing", "agent", - "api" + "api", + "mcp", + "model-context-protocol", + "transactional-email", + "resend", + "resend-alternative", + "react-email", + "ai-agent", + "agents", + "auth-emails" ], "type": "module", "bin": { @@ -27,6 +36,7 @@ }, "files": [ "dist", + "schemas", "README.md", "LICENSE" ], @@ -45,11 +55,13 @@ "test": "vitest run", "test:watch": "vitest", "parity": "vitest run tests/parity-sdk.test.ts tests/parity-spec.test.ts", - "check": "bun run tsc && bun run lint && bun run test", + "check": "bun run tsc && bun run lint && bun run test && bun run docs:commands:check && bun run generate:skills:check", "docs:commands": "bun run scripts/generate-command-docs.ts", "docs:commands:check": "bun run scripts/generate-command-docs.ts --check", "generate:types": "node scripts/generate-types.mjs", - "prepublishOnly": "bun run clean && bun run tsc && bun run lint && bun run test && bun run build", + "generate:skills": "bun run scripts/generate-skills.ts", + "generate:skills:check": "bun run scripts/generate-skills.ts --check", + "prepublishOnly": "bun run clean && bun run tsc && bun run lint && bun run test && bun run generate:skills:check && bun run build", "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts new file mode 100644 index 0000000..e7d656f --- /dev/null +++ b/scripts/generate-skills.ts @@ -0,0 +1,145 @@ +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Embeds every `skills//SKILL.md` into `src/generated/skills.ts`, so + * the bundled CLI can install its agent skills into a project (`init`) + * without reading files that the npm package or a compiled binary may not + * carry. + * bun run generate:skills — write the module + * bun run generate:skills:check — fail when it is stale + */ + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export const SKILLS_MODULE_PATH = 'src/generated/skills.ts' + +export type SkillSource = { + readonly name: string + readonly description: string + /** Repo-relative, POSIX. */ + readonly path: string + readonly content: string +} + +function findSkillFiles(dir: string): string[] { + const found: string[] = [] + let entries: string[] + try { + entries = readdirSync(dir).sort() + } catch { + return found + } + for (const entry of entries) { + const path = join(dir, entry) + if (entry === 'SKILL.md') { + found.push(path) + continue + } + found.push(...findSkillFiles(path)) + } + return found +} + +/** `name` and `description` from a SKILL.md's YAML frontmatter. */ +export function readFrontmatter(content: string): { + readonly name?: string + readonly description?: string +} { + const match = /^---\n([\s\S]*?)\n---\n/.exec(content) + if (match === null) { + return {} + } + const fields: Record = {} + for (const line of (match[1] ?? '').split('\n')) { + const field = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line) + if (field?.[1] !== undefined && field[2] !== undefined) { + fields[field[1]] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2') + } + } + return { + ...(fields.name === undefined ? {} : { name: fields.name }), + ...(fields.description === undefined + ? {} + : { description: fields.description }), + } +} + +export function collectSkills(root: string = REPO_ROOT): SkillSource[] { + return findSkillFiles(join(root, 'skills')) + .map((file) => { + const content = readFileSync(file, 'utf8').replaceAll('\r\n', '\n') + const path = relative(root, file).split(sep).join('/') + const frontmatter = readFrontmatter(content) + const name = frontmatter.name ?? path.split('/').at(-2) ?? path + if (frontmatter.description === undefined) { + throw new Error(`${path} has no frontmatter description.`) + } + return { name, description: frontmatter.description, path, content } + }) + .sort((a, b) => a.path.localeCompare(b.path)) +} + +export function renderSkillsModule(skills: readonly SkillSource[]): string { + const lines = [ + '// GENERATED FILE — do not edit. Regenerate with `bun run generate:skills`.', + '// Source: skills/**/SKILL.md', + '', + 'export type EmbeddedSkill = {', + ' readonly name: string', + ' readonly description: string', + ' /** Path in the brew-cli repo, e.g. `skills/brew-cli/SKILL.md`. */', + ' readonly path: string', + ' readonly content: string', + '}', + '', + 'export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [', + ] + for (const skill of skills) { + lines.push( + ' {', + ` name: ${JSON.stringify(skill.name)},`, + ` description: ${JSON.stringify(skill.description)},`, + ` path: ${JSON.stringify(skill.path)},`, + ` content: ${JSON.stringify(skill.content)},`, + ' },' + ) + } + lines.push(']', '') + return lines.join('\n') +} + +/** `null` when the committed module matches the skills; else the fix. */ +export function checkSkillsModule(root: string = REPO_ROOT): string | null { + const next = renderSkillsModule(collectSkills(root)) + let current = '' + try { + current = readFileSync(join(root, SKILLS_MODULE_PATH), 'utf8') + } catch { + current = '' + } + return current === next + ? null + : `${SKILLS_MODULE_PATH} is stale. Run \`bun run generate:skills\` and commit.` +} + +function main(): void { + if (process.argv.includes('--check')) { + const problem = checkSkillsModule() + if (problem !== null) { + console.error(problem) + process.exit(1) + } + console.log(`${SKILLS_MODULE_PATH} is fresh.`) + return + } + const output = join(REPO_ROOT, SKILLS_MODULE_PATH) + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(output, renderSkillsModule(collectSkills())) + console.log(`Wrote ${output}`) +} + +const entry = process.argv[1] +if (entry !== undefined && resolve(entry) === fileURLToPath(import.meta.url)) { + main() +} diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 683a60f..ed1219d 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -5,8 +5,9 @@ description: Drive the Brew email platform from the terminal with brew-cli — e # brew-cli for agents -One typed command per public-API operation (145 commands, generated docs in -`docs/commands/README.md`). JSON output is automatic when stdout is piped. +One typed command per public-API operation (the generated reference is +`docs/commands/README.md`; `brew-cli docs --agent` lists every command). +JSON output is automatic when stdout is piped. ## Start every session with the trust check @@ -27,6 +28,23 @@ this build lacks (update the CLI); auth/reachability failures name the fix. - Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on brand-scoped commands (`brands list` shows ids). +## Connect your editor to the Brew MCP server + +```bash +brew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/) +brew-cli mcp install --client cursor claude-code --dry-run --json +brew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client +brew-cli mcp install --client vscode --print # print the snippet, write nothing +``` + +It merges a `brew` entry into each client's config and keeps every other +server. `--auth oauth` (default) writes the URL only; `--auth env` adds an +`Authorization` header the client expands from `BREW_API_KEY`, never a +literal key. A config with comments comes back `manual` with the snippet to +paste. Windsurf and Codex live in your home directory: only written when +named with `--client`. Results: `created | updated | unchanged | manual | +printed`, plus one-click `deeplink`s for Cursor and VS Code. + ## The contract you can rely on - Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation @@ -45,7 +63,9 @@ this build lacks (update the CLI); auth/reachability failures name the fix. use it to fit a tool-call budget. `--max-retries 0` for one attempt. - Destructive commands (sends, deletes, fires, cancels) never hang: they exit 4 with a `confirmCommand` to re-run once a human approves, or take - `--yes`. Do not pass `--yes` for real campaign sends without an explicit + `--yes`. `confirmCommand` and `retryCommand` start the way you invoked + the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a + launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit human instruction; `emails send --test --to you@x.com` is the safe lane. - `--input ''` (or `-` for stdin) carries full request bodies; positional ids always win over `--input`. `--all` drains pagination. diff --git a/src/cli.ts b/src/cli.ts index 9ebaaed..cb565d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,6 +44,7 @@ const GROUP_SUMMARIES: Readonly> = { insights: "Brew Insights: findings about the brand's email, plus its pulse, report, suggestions and memo", integrations: 'Read the connected-integration catalog', + mcp: 'Connect AI editors and agents to the Brew MCP server', 'inbox-placement-tests': 'Inbox placement (seed) test results', notifications: 'What finished or failed: generations, sends, imports, domain checks', diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts new file mode 100644 index 0000000..4f81953 --- /dev/null +++ b/src/commands/mcp/install.ts @@ -0,0 +1,482 @@ +import { relative, sep } from 'node:path' +import { resolveAuth } from '../../lib/client' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { flagString } from '../../lib/input' +import { + codexTomlBlock, + configDocument, + configTarget, + containerKey, + deeplinkFor, + detectMcpClients, + isMcpClientId, + MCP_CLIENT_IDS, + MCP_CLIENT_LABELS, + MCP_SERVER_NAME, + type McpAuthMode, + type McpClientId, + type McpScope, + mcpUrlFromApiUrl, + serverEntry, + USER_SCOPE_ONLY, +} from '../../lib/mcp-clients' +import { ProjectWriter } from '../../lib/project/fs-writer' +import { resolveProjectRoot } from '../../lib/project/monorepo' +import { resolveCwd, resolveHomeDir } from '../../lib/project/process' + +export type McpInstallAction = + | 'created' + | 'updated' + | 'unchanged' + | 'manual' + | 'printed' + +export type McpInstallResult = { + readonly client: McpClientId + /** Project-relative for project files, absolute for user files. */ + readonly path?: string + readonly action: McpInstallAction + /** One-click install link (Cursor, VS Code). */ + readonly deeplink?: string + /** What to paste (or run) by hand: `manual` and `printed` results. */ + readonly snippet?: string + /** Why a result is `manual`. */ + readonly note?: string +} + +const CLIENT_LIST = `${MCP_CLIENT_IDS.join(', ')}, all` + +export const mcpInstallCommand = defineCommand({ + path: ['mcp', 'install'], + summary: + "Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex)", + sdkMethod: null, + commandClass: 'write', + flags: [ + { + flag: '--client ', + summary: `Clients to configure: ${CLIENT_LIST} (default: the ones this project uses; Windsurf and Codex only when named)`, + }, + { + flag: '--scope ', + summary: 'project (files in this repo) or user (your home directory)', + defaultValue: 'project', + }, + { + flag: '--auth ', + summary: + 'oauth (URL only; sign in from the client) or env (a header the client fills from BREW_API_KEY; never a literal key)', + defaultValue: 'oauth', + }, + { + flag: '--print', + summary: 'Print each config snippet instead of writing files', + }, + { + flag: '--dry-run', + summary: 'Report what would change without writing anything', + }, + ], + examples: [ + 'brew-cli mcp install', + 'brew-cli mcp install --client cursor claude-code', + 'brew-cli mcp install --client codex --auth env', + 'brew-cli mcp install --client all --dry-run --json', + 'brew-cli mcp install --client vscode --print', + ], + run: async ({ ctx, flags }) => { + const scope = parseScope(flags.scope) + const auth = parseAuth(flags.auth) + const isPrint = flags.print === true + const isDryRun = flags.dryRun === true + const resolved = resolveProjectRoot(resolveCwd(ctx.io)) + const projectRoot = resolved.monorepo?.root ?? resolved.root + const env = ctx.io.env + const home = resolveHomeDir(env) + const mcpUrl = mcpUrlFromApiUrl( + resolveAuth({ globals: ctx.globals, env, allowAnonymous: true }).apiUrl + ) + + const requested = parseClients(flags.client) + const detected = detectMcpClients(projectRoot, env) + let clients: readonly McpClientId[] + // Detected Windsurf/Codex are reported, never written: their config is + // in the home directory, so writing it takes an explicit --client. + let reportOnly = new Set() + if (requested !== undefined) { + clients = requested + } else if (detected.length > 0) { + clients = detected + reportOnly = new Set(detected.filter((id) => USER_SCOPE_ONLY.has(id))) + } else if (isPrint) { + clients = MCP_CLIENT_IDS + } else { + throw new CliUsageError( + `No MCP client detected in ${projectRoot} (.cursor/, .vscode/, .claude/, .gemini/, .windsurf/, .codex/). Pass --client with any of: ${CLIENT_LIST}.` + ) + } + + const writer = new ProjectWriter({ root: projectRoot, dryRun: isDryRun }) + const results = clients.map((client) => + installOne({ + client, + scope, + auth, + mcpUrl, + projectRoot, + home, + env, + writer, + mode: isPrint ? 'print' : reportOnly.has(client) ? 'report' : 'write', + }) + ) + return { + data: { mcpUrl, dryRun: isDryRun, clients: results }, + human: renderHuman(results, { isDryRun, mcpUrl }), + } + }, +}) + +function installOne(input: { + readonly client: McpClientId + readonly scope: McpScope + readonly auth: McpAuthMode + readonly mcpUrl: string + readonly projectRoot: string + readonly home: string + readonly env: Readonly> + readonly writer: ProjectWriter + readonly mode: 'write' | 'print' | 'report' +}): McpInstallResult { + const { client, auth, mcpUrl, writer } = input + const deeplink = deeplinkFor(client, mcpUrl, auth) + const withLink = deeplink === undefined ? {} : { deeplink } + const target = configTarget(client, input.scope, { + projectRoot: input.projectRoot, + home: input.home, + env: input.env, + url: mcpUrl, + auth, + }) + if (target.kind === 'command') { + return { + client, + action: input.mode === 'print' ? 'printed' : 'manual', + snippet: target.command, + ...withLink, + ...(input.mode === 'print' + ? {} + : { note: 'Claude Code manages its user config: run this command.' }), + } + } + const path = displayPath(target.path, input.projectRoot) + const snippet = + client === 'codex' + ? codexTomlBlock(mcpUrl, auth) + : `${JSON.stringify(configDocument(client, mcpUrl, auth), null, 2)}\n` + if (input.mode === 'print') { + return { client, path, action: 'printed', snippet, ...withLink } + } + if (input.mode === 'report') { + return { + client, + path, + action: 'manual', + snippet, + note: `Detected, not written: its config is in your home directory. Re-run with --client ${client} to write it.`, + } + } + const outcome = + client === 'codex' + ? installCodex(writer, target.path, mcpUrl, auth) + : installJson(writer, target.path, client, mcpUrl, auth) + return { + client, + path, + action: outcome.action, + ...withLink, + ...(outcome.action === 'manual' ? { snippet } : {}), + ...(outcome.note === undefined ? {} : { note: outcome.note }), + } +} + +type Outcome = { + readonly action: 'created' | 'updated' | 'unchanged' | 'manual' + readonly note?: string +} + +function fromWriter(action: string): Outcome { + switch (action) { + case 'created': + case 'updated': + case 'unchanged': + return { action } + default: + return { action: 'manual', note: 'The file could not be updated safely.' } + } +} + +function installJson( + writer: ProjectWriter, + path: string, + client: Exclude, + url: string, + auth: McpAuthMode +): Outcome { + const existing = writer.read(path) + if (existing === null || existing.trim() === '') { + const text = `${JSON.stringify(configDocument(client, url, auth), null, 2)}\n` + return fromWriter( + writer.writeFile(path, text, { overwrite: existing !== null }).action + ) + } + let root: unknown + try { + root = JSON.parse(existing) + } catch { + return { + action: 'manual', + note: + parseJsonc(existing) === undefined + ? 'Not valid JSON: fix the file, then add this entry.' + : 'Has comments, which a rewrite would drop: add this entry by hand.', + } + } + if (!isRecord(root)) { + return { + action: 'manual', + note: 'The file is not a JSON object: add this entry by hand.', + } + } + const key = containerKey(client) + const container = root[key] + if (container !== undefined && !isRecord(container)) { + return { + action: 'manual', + note: `"${key}" is not an object: add this entry by hand.`, + } + } + const entry = serverEntry(client, url, auth) + if (container !== undefined && deepEqual(container[MCP_SERVER_NAME], entry)) { + return { action: 'unchanged' } + } + // Every other server and setting stays, in its original order. + const merged = { + ...root, + [key]: { ...(container ?? {}), [MCP_SERVER_NAME]: entry }, + } + const text = `${JSON.stringify(merged, null, detectIndent(existing))}\n` + return fromWriter(writer.writeFile(path, text, { overwrite: true }).action) +} + +const CODEX_TABLE = + /^\s*\[\s*mcp_servers\s*\.\s*(?:brew|"brew"|'brew')\s*\]\s*(?:#.*)?$/ + +function installCodex( + writer: ProjectWriter, + path: string, + url: string, + auth: McpAuthMode +): Outcome { + const block = codexTomlBlock(url, auth) + const existing = writer.read(path) + if (existing === null) { + return fromWriter(writer.writeFile(path, block).action) + } + const lines = existing.replaceAll('\r\n', '\n').split('\n') + const start = lines.findIndex((line) => CODEX_TABLE.test(line)) + if (start !== -1) { + const body: string[] = [] + for (const line of lines.slice(start + 1)) { + if (/^\s*\[/.test(line)) { + break + } + const trimmed = line.replace(/\s+#.*$/, '').trim() + if (trimmed !== '') { + body.push(trimmed.replace(/\s*=\s*/, ' = ')) + } + } + const wanted = block + .trim() + .split('\n') + .slice(1) + .map((line) => line.trim()) + const isSame = + body.length === wanted.length && + wanted.every((line) => body.includes(line)) + return isSame + ? { action: 'unchanged' } + : { + action: 'manual', + note: `A [mcp_servers.${MCP_SERVER_NAME}] table with other settings exists: edit it by hand.`, + } + } + const normalized = existing.replaceAll('\r\n', '\n') + const separator = + normalized === '' || normalized.endsWith('\n\n') + ? '' + : normalized.endsWith('\n') + ? '\n' + : '\n\n' + return fromWriter( + writer.writeFile(path, `${normalized}${separator}${block}`, { + overwrite: true, + }).action + ) +} + +function parseClients(value: unknown): McpClientId[] | undefined { + if (value === undefined) { + return undefined + } + const tokens = (Array.isArray(value) ? value : [value]) + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== '') + if (tokens.length === 0) { + throw new CliUsageError(`--client needs at least one of: ${CLIENT_LIST}.`) + } + const clients = new Set() + for (const token of tokens) { + if (token === 'all') { + for (const id of MCP_CLIENT_IDS) { + clients.add(id) + } + } else if (isMcpClientId(token)) { + clients.add(token) + } else { + throw new CliUsageError( + `Unknown MCP client "${token}". Expected: ${CLIENT_LIST}.` + ) + } + } + // Stable output order, whatever order the flags came in. + return MCP_CLIENT_IDS.filter((id) => clients.has(id)) +} + +function parseScope(value: unknown): McpScope { + const scope = flagString(value) ?? 'project' + if (scope !== 'project' && scope !== 'user') { + throw new CliUsageError('--scope must be project or user.') + } + return scope +} + +function parseAuth(value: unknown): McpAuthMode { + const auth = flagString(value) ?? 'oauth' + if (auth !== 'oauth' && auth !== 'env') { + throw new CliUsageError('--auth must be oauth or env.') + } + return auth +} + +function displayPath(absolute: string, projectRoot: string): string { + const rel = relative(projectRoot, absolute) + return rel.startsWith('..') || rel === '' || /^[A-Za-z]:/.test(rel) + ? absolute + : rel.split(sep).join('/') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])) + } + if (isRecord(a) && isRecord(b)) { + const keys = Object.keys(a) + return ( + keys.length === Object.keys(b).length && + keys.every((key) => deepEqual(a[key], b[key])) + ) + } + return false +} + +/** The file's own indentation (a tab, or its first indent's width). */ +function detectIndent(text: string): string | number { + const match = /^([ \t]+)\S/m.exec(text) + const indent = match?.[1] + if (indent === undefined) { + return 2 + } + return indent.startsWith('\t') ? '\t' : indent.length +} + +/** + * JSON with comments and trailing commas (VS Code and Gemini accept it), + * parsed only to tell "has comments" from "broken". `undefined` = neither. + */ +export function parseJsonc(text: string): unknown { + let out = '' + let inString = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + const next = text[index + 1] + if (inString) { + out += char + if (char === '\\') { + out += next ?? '' + index += 1 + } else if (char === '"') { + inString = false + } + continue + } + if (char === '"') { + inString = true + out += char + } else if (char === '/' && next === '/') { + while (index < text.length && text[index] !== '\n') { + index += 1 + } + out += '\n' + } else if (char === '/' && next === '*') { + const end = text.indexOf('*/', index + 2) + index = end === -1 ? text.length : end + 1 + } else { + out += char + } + } + try { + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) as unknown + } catch { + return undefined + } +} + +function renderHuman( + results: readonly McpInstallResult[], + options: { readonly isDryRun: boolean; readonly mcpUrl: string } +): string { + const lines: string[] = [] + if (options.isDryRun) { + lines.push('Dry run: nothing was written.') + } + lines.push(`Brew MCP server: ${options.mcpUrl}`) + const width = Math.max(...results.map((result) => result.action.length)) + for (const result of results) { + const label = MCP_CLIENT_LABELS[result.client] + lines.push( + `${result.action.padEnd(width)} ${label}${result.path === undefined ? '' : ` (${result.path})`}` + ) + if (result.note !== undefined) { + lines.push(` ${result.note}`) + } + if (result.snippet !== undefined) { + for (const line of result.snippet.trimEnd().split('\n')) { + lines.push(` ${line}`) + } + } + if (result.deeplink !== undefined) { + lines.push(` One-click install: ${result.deeplink}`) + } + } + return lines.join('\n') +} diff --git a/src/generated/skills.ts b/src/generated/skills.ts new file mode 100644 index 0000000..0f17b76 --- /dev/null +++ b/src/generated/skills.ts @@ -0,0 +1,19 @@ +// GENERATED FILE — do not edit. Regenerate with `bun run generate:skills`. +// Source: skills/**/SKILL.md + +export type EmbeddedSkill = { + readonly name: string + readonly description: string + /** Path in the brew-cli repo, e.g. `skills/brew-cli/SKILL.md`. */ + readonly path: string + readonly content: string +} + +export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ + { + name: "brew-cli", + description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", + path: "skills/brew-cli/SKILL.md", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY`, never a\nliteral key. A config with comments comes back `manual` with the snippet to\npaste. Windsurf and Codex live in your home directory: only written when\nnamed with `--client`. Results: `created | updated | unchanged | manual |\nprinted`, plus one-click `deeplink`s for Cursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a\n launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + }, +] diff --git a/src/lib/mcp-clients.ts b/src/lib/mcp-clients.ts new file mode 100644 index 0000000..6be3c27 --- /dev/null +++ b/src/lib/mcp-clients.ts @@ -0,0 +1,303 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Where each MCP client keeps its server list, and the Brew entry it needs. + * Pure data and builders: `mcp install` does the file work. + * + * Auth modes: `oauth` writes the URL only (the client runs Brew's OAuth + * flow on first use); `env` adds an `Authorization` header that the CLIENT + * expands from `BREW_API_KEY` at connect time, in each client's own + * interpolation syntax. A literal key is never written anywhere. + */ + +export const MCP_CLIENT_IDS = [ + 'cursor', + 'vscode', + 'claude-code', + 'gemini', + 'windsurf', + 'codex', +] as const + +export type McpClientId = (typeof MCP_CLIENT_IDS)[number] +export type McpAuthMode = 'oauth' | 'env' +export type McpScope = 'project' | 'user' + +export const MCP_SERVER_NAME = 'brew' +export const MCP_API_KEY_ENV = 'BREW_API_KEY' +export const DEFAULT_MCP_URL = 'https://brew.new/api/mcp' + +export const MCP_CLIENT_LABELS: Readonly> = { + cursor: 'Cursor', + vscode: 'VS Code', + 'claude-code': 'Claude Code', + gemini: 'Gemini CLI', + windsurf: 'Windsurf', + codex: 'Codex', +} + +/** Clients whose only config lives in the user's home directory. */ +export const USER_SCOPE_ONLY: ReadonlySet = new Set([ + 'windsurf', + 'codex', +]) + +export function isMcpClientId(value: string): value is McpClientId { + return (MCP_CLIENT_IDS as readonly string[]).includes(value) +} + +/** The MCP endpoint beside an API base URL: its origin + `/api/mcp`. */ +export function mcpUrlFromApiUrl(apiUrl: string): string { + return `${new URL(apiUrl).origin}/api/mcp` +} + +/** `Authorization` in the client's own env-interpolation syntax. */ +function authHeader(client: McpClientId): string { + switch (client) { + case 'cursor': + case 'vscode': + case 'windsurf': + return `Bearer \${env:${MCP_API_KEY_ENV}}` + default: + return `Bearer \${${MCP_API_KEY_ENV}}` + } +} + +/** The JSON clients' container key for their server map. */ +export function containerKey( + client: Exclude +): 'mcpServers' | 'servers' { + return client === 'vscode' ? 'servers' : 'mcpServers' +} + +/** The `brew` server entry for a JSON-configured client. */ +export function serverEntry( + client: Exclude, + url: string, + auth: McpAuthMode +): Record { + const headers = + auth === 'env' ? { headers: { Authorization: authHeader(client) } } : {} + switch (client) { + case 'cursor': + return { url, ...headers } + case 'vscode': + case 'claude-code': + return { type: 'http', url, ...headers } + case 'gemini': + return { httpUrl: url, ...headers } + case 'windsurf': + return { serverUrl: url, ...headers } + } +} + +/** A whole config file holding only the Brew entry (new files, snippets). */ +export function configDocument( + client: Exclude, + url: string, + auth: McpAuthMode +): Record { + return { + [containerKey(client)]: { + [MCP_SERVER_NAME]: serverEntry(client, url, auth), + }, + } +} + +/** Codex's `[mcp_servers.brew]` table (streamable HTTP). */ +export function codexTomlBlock(url: string, auth: McpAuthMode): string { + const lines = [ + `[mcp_servers.${MCP_SERVER_NAME}]`, + `url = ${JSON.stringify(url)}`, + ] + if (auth === 'env') { + lines.push(`bearer_token_env_var = ${JSON.stringify(MCP_API_KEY_ENV)}`) + } + return `${lines.join('\n')}\n` +} + +/** Claude Code's own command for a user-scope server (it owns ~/.claude.json). */ +export function claudeMcpAddCommand(url: string, auth: McpAuthMode): string { + const header = + auth === 'env' + ? ` --header 'Authorization: ${authHeader('claude-code')}'` + : '' + return `claude mcp add --transport http --scope user${header} ${MCP_SERVER_NAME} ${url}` +} + +/** + * Cursor's one-click install link. Byte-compatible with brew-v2 + * `lib/mcp/handoff.ts#buildCursorInstallUrl`: `config` is the entry's JSON + * as unpadded base64url, passed raw (not percent-encoded). + */ +export function buildCursorInstallUrl( + url: string = DEFAULT_MCP_URL, + auth: McpAuthMode = 'oauth' +): string { + const json = JSON.stringify(serverEntry('cursor', url, auth)) + const base64url = Buffer.from(json, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/[=]+$/, '') + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${MCP_SERVER_NAME}&config=${base64url}` +} + +/** VS Code's one-click install link (`vscode:mcp/install?`). */ +export function buildVsCodeInstallUrl( + url: string = DEFAULT_MCP_URL, + auth: McpAuthMode = 'oauth' +): string { + const config = { name: MCP_SERVER_NAME, ...serverEntry('vscode', url, auth) } + return `vscode:mcp/install?${encodeURIComponent(JSON.stringify(config))}` +} + +export function deeplinkFor( + client: McpClientId, + url: string, + auth: McpAuthMode +): string | undefined { + if (client === 'cursor') { + return buildCursorInstallUrl(url, auth) + } + if (client === 'vscode') { + return buildVsCodeInstallUrl(url, auth) + } + return undefined +} + +/** VS Code's user profile directory (where its user `mcp.json` lives). */ +export function vscodeUserDir( + home: string, + env: Readonly>, + platform: NodeJS.Platform = process.platform +): string { + if (platform === 'darwin') { + return join(home, 'Library', 'Application Support', 'Code', 'User') + } + if (platform === 'win32') { + const appData = env.APPDATA + return join( + appData !== undefined && appData !== '' + ? appData + : join(home, 'AppData', 'Roaming'), + 'Code', + 'User' + ) + } + const xdg = env.XDG_CONFIG_HOME + return join( + xdg !== undefined && xdg !== '' ? xdg : join(home, '.config'), + 'Code', + 'User' + ) +} + +export type ConfigTarget = + | { + readonly kind: 'file' + /** Absolute path. */ + readonly path: string + readonly scope: McpScope + } + /** No file this CLI should edit: print the client's own command. */ + | { readonly kind: 'command'; readonly command: string } + +/** + * Where a client's Brew entry goes. Windsurf and Codex only have a user + * config, whatever `scope` says. + */ +export function configTarget( + client: McpClientId, + scope: McpScope, + input: { + readonly projectRoot: string + readonly home: string + readonly env: Readonly> + readonly url: string + readonly auth: McpAuthMode + readonly platform?: NodeJS.Platform + } +): ConfigTarget { + const { projectRoot, home, env } = input + const user = (path: string): ConfigTarget => ({ + kind: 'file', + path, + scope: 'user', + }) + const project = (path: string): ConfigTarget => ({ + kind: 'file', + path: join(projectRoot, path), + scope: 'project', + }) + switch (client) { + case 'windsurf': + return user(join(home, '.codeium', 'windsurf', 'mcp_config.json')) + case 'codex': { + const codexHome = env.CODEX_HOME + return user( + join( + codexHome !== undefined && codexHome !== '' + ? codexHome + : join(home, '.codex'), + 'config.toml' + ) + ) + } + case 'cursor': + return scope === 'user' + ? user(join(home, '.cursor', 'mcp.json')) + : project(join('.cursor', 'mcp.json')) + case 'vscode': + return scope === 'user' + ? user(join(vscodeUserDir(home, env, input.platform), 'mcp.json')) + : project(join('.vscode', 'mcp.json')) + case 'claude-code': + return scope === 'user' + ? { + kind: 'command', + command: claudeMcpAddCommand(input.url, input.auth), + } + : project('.mcp.json') + case 'gemini': + return scope === 'user' + ? user(join(home, '.gemini', 'settings.json')) + : project(join('.gemini', 'settings.json')) + } +} + +/** + * The clients this project (or the agent running us) already uses: their + * config directories in the project root, Claude Code's `CLAUDECODE=1`, + * Codex's `CODEX_*` environment. + */ +export function detectMcpClients( + projectRoot: string, + env: Readonly> +): McpClientId[] { + const has = (dir: string): boolean => existsSync(join(projectRoot, dir)) + const detected: McpClientId[] = [] + if (has('.cursor')) { + detected.push('cursor') + } + if (has('.vscode')) { + detected.push('vscode') + } + if (has('.claude') || env.CLAUDECODE === '1') { + detected.push('claude-code') + } + if (has('.gemini')) { + detected.push('gemini') + } + if (has('.windsurf')) { + detected.push('windsurf') + } + if ( + has('.codex') || + Object.keys(env).some((key) => key.startsWith('CODEX_')) + ) { + detected.push('codex') + } + return detected +} diff --git a/src/registry.ts b/src/registry.ts index 04fd866..1cc5554 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -139,6 +139,7 @@ import { insightsListCommand } from './commands/insights/list' import { integrationsListCommand } from './commands/integrations/list' import { loginCommand } from './commands/login' import { logoutCommand } from './commands/logout' +import { mcpInstallCommand } from './commands/mcp/install' import { notificationsListCommand } from './commands/notifications/list' import { sendsCancelCommand } from './commands/sends/cancel' import { sendsGetCommand } from './commands/sends/get' @@ -306,5 +307,6 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ doctorCommand, docsCommand, docsApiCommand, + mcpInstallCommand, apiCommand, ] diff --git a/tests/commands/mcp-install.test.ts b/tests/commands/mcp-install.test.ts new file mode 100644 index 0000000..5dd094d --- /dev/null +++ b/tests/commands/mcp-install.test.ts @@ -0,0 +1,448 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildCursorInstallUrl, + buildVsCodeInstallUrl, + configTarget, + mcpUrlFromApiUrl, + vscodeUserDir, +} from '../../src/lib/mcp-clients' +import { copyFixture, tempProject } from '../helpers/fixtures' +import { runCli } from '../helpers/run-cli' + +type Result = { + client: string + path?: string + action: string + deeplink?: string + snippet?: string + note?: string +} +type Output = { mcpUrl: string; dryRun: boolean; clients: Result[] } + +/** brew-v2 `buildCursorInstallUrl()` for https://brew.new/api/mcp, verbatim. */ +/** What the client expands at connect time (never the key itself). */ +const ENV_HEADER = `Bearer \${env:BREW_API_KEY}` +const PLAIN_ENV_HEADER = `Bearer \${BREW_API_KEY}` + +const BREW_V2_CURSOR_LINK = + 'cursor://anysphere.cursor-deeplink/mcp/install?name=brew&config=eyJ1cmwiOiJodHRwczovL2JyZXcubmV3L2FwaS9tY3AifQ' + +function setup(dirs: readonly string[] = []): { + root: string + home: string + env: Record +} { + const root = tempProject() + const home = tempProject('brew-cli-home-') + for (const dir of dirs) { + mkdirSync(join(root, dir), { recursive: true }) + } + return { + root, + home, + env: { HOME: home, BREW_CLI_CONFIG_DIR: join(home, '.config/brew-cli') }, + } +} + +async function install( + argv: readonly string[], + options: { root: string; env: Record } +): Promise<{ code: number; output: Output; stderr: string }> { + const result = await runCli(['mcp', 'install', ...argv, '--json'], { + cwd: options.root, + env: options.env, + }) + return { + code: result.code, + output: result.json as Output, + stderr: result.stderr, + } +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) +} + +describe('mcp install', () => { + it('writes every detected project client, with deeplinks', async () => { + const { root, env } = setup(['.cursor', '.vscode', '.claude', '.gemini']) + const { code, output } = await install([], { root, env }) + expect(code).toBe(0) + expect(output.mcpUrl).toBe('https://brew.new/api/mcp') + expect(output.dryRun).toBe(false) + expect( + output.clients.map(({ client, path, action }) => ({ + client, + path, + action, + })) + ).toEqual([ + { client: 'cursor', path: '.cursor/mcp.json', action: 'created' }, + { client: 'vscode', path: '.vscode/mcp.json', action: 'created' }, + { client: 'claude-code', path: '.mcp.json', action: 'created' }, + { client: 'gemini', path: '.gemini/settings.json', action: 'created' }, + ]) + expect(readJson(join(root, '.cursor/mcp.json'))).toEqual({ + mcpServers: { brew: { url: 'https://brew.new/api/mcp' } }, + }) + expect(readJson(join(root, '.vscode/mcp.json'))).toEqual({ + servers: { brew: { type: 'http', url: 'https://brew.new/api/mcp' } }, + }) + expect(readJson(join(root, '.mcp.json'))).toEqual({ + mcpServers: { brew: { type: 'http', url: 'https://brew.new/api/mcp' } }, + }) + expect(readJson(join(root, '.gemini/settings.json'))).toEqual({ + mcpServers: { brew: { httpUrl: 'https://brew.new/api/mcp' } }, + }) + expect(output.clients[0]?.deeplink).toBe(BREW_V2_CURSOR_LINK) + expect(output.clients[1]?.deeplink).toBe( + `vscode:mcp/install?${encodeURIComponent('{"name":"brew","type":"http","url":"https://brew.new/api/mcp"}')}` + ) + }) + + it('is unchanged on a second run', async () => { + const { root, env } = setup(['.cursor']) + await install([], { root, env }) + const before = readFileSync(join(root, '.cursor/mcp.json'), 'utf8') + const { output } = await install([], { root, env }) + expect(output.clients[0]?.action).toBe('unchanged') + expect(readFileSync(join(root, '.cursor/mcp.json'), 'utf8')).toBe(before) + }) + + it('merges into an existing config, keeping other servers, settings and indent', async () => { + const { root, env } = setup() + mkdirSync(join(root, '.gemini')) + writeFileSync( + join(root, '.gemini/settings.json'), + `${JSON.stringify( + { + theme: 'GitHub', + mcpServers: { + github: { httpUrl: 'https://api.githubcopilot.com/mcp/' }, + }, + }, + null, + 4 + )}\n` + ) + const { output } = await install(['--client', 'gemini'], { root, env }) + expect(output.clients[0]?.action).toBe('updated') + const text = readFileSync(join(root, '.gemini/settings.json'), 'utf8') + expect(text).toContain('\n "theme"') + expect(JSON.parse(text)).toEqual({ + theme: 'GitHub', + mcpServers: { + github: { httpUrl: 'https://api.githubcopilot.com/mcp/' }, + brew: { httpUrl: 'https://brew.new/api/mcp' }, + }, + }) + }) + + it('replaces a stale brew entry in place', async () => { + const { root, env } = setup() + writeFileSync( + join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + brew: { type: 'http', url: 'https://old.example/api/mcp' }, + other: { command: 'x' }, + }, + }) + ) + const { output } = await install(['--client', 'claude-code'], { + root, + env, + }) + expect(output.clients[0]?.action).toBe('updated') + expect( + Object.keys( + (readJson(join(root, '.mcp.json')) as { mcpServers: object }).mcpServers + ) + ).toEqual(['brew', 'other']) + }) + + it('hands back a snippet instead of rewriting a config with comments', async () => { + const { root, env } = setup() + mkdirSync(join(root, '.vscode')) + const jsonc = '{\n // my servers\n "servers": {},\n}\n' + writeFileSync(join(root, '.vscode/mcp.json'), jsonc) + const { output } = await install(['--client', 'vscode'], { root, env }) + expect(output.clients[0]).toMatchObject({ + client: 'vscode', + path: '.vscode/mcp.json', + action: 'manual', + note: expect.stringMatching(/comments/), + }) + expect(JSON.parse(output.clients[0]?.snippet ?? '')).toEqual({ + servers: { brew: { type: 'http', url: 'https://brew.new/api/mcp' } }, + }) + expect(readFileSync(join(root, '.vscode/mcp.json'), 'utf8')).toBe(jsonc) + }) + + it('reports a broken JSON file as manual', async () => { + const { root, env } = setup() + writeFileSync(join(root, '.mcp.json'), '{"mcpServers": ') + const { output } = await install(['--client', 'claude-code'], { + root, + env, + }) + expect(output.clients[0]).toMatchObject({ + action: 'manual', + note: expect.stringMatching(/Not valid JSON/), + }) + }) + + it('plans without writing on --dry-run', async () => { + const { root, env } = setup(['.cursor']) + const { code, output } = await install(['--dry-run'], { root, env }) + expect(code).toBe(0) + expect(output.dryRun).toBe(true) + expect(output.clients[0]?.action).toBe('created') + expect(existsSync(join(root, '.cursor/mcp.json'))).toBe(false) + }) + + it('prints snippets for every client on --print, writing nothing', async () => { + const { root, home, env } = setup() + const { output } = await install(['--print'], { root, env }) + expect(output.clients.map((result) => result.client)).toEqual([ + 'cursor', + 'vscode', + 'claude-code', + 'gemini', + 'windsurf', + 'codex', + ]) + expect(output.clients.every((result) => result.action === 'printed')).toBe( + true + ) + expect(output.clients[5]?.snippet).toBe( + '[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\n' + ) + expect(existsSync(join(root, '.cursor'))).toBe(false) + expect(existsSync(join(home, '.codex'))).toBe(false) + }) + + it('writes Windsurf and Codex user configs only when named', async () => { + const { root, home, env } = setup(['.windsurf', '.codex']) + const detected = await install([], { root, env }) + expect(detected.output.clients.map((result) => result.action)).toEqual([ + 'manual', + 'manual', + ]) + expect(existsSync(join(home, '.codeium'))).toBe(false) + + const named = await install( + ['--client', 'windsurf,codex', '--auth', 'env'], + { + root, + env, + } + ) + expect(named.output.clients).toMatchObject([ + { + client: 'windsurf', + path: join(home, '.codeium/windsurf/mcp_config.json'), + action: 'created', + }, + { + client: 'codex', + path: join(home, '.codex/config.toml'), + action: 'created', + }, + ]) + expect(readJson(join(home, '.codeium/windsurf/mcp_config.json'))).toEqual({ + mcpServers: { + brew: { + serverUrl: 'https://brew.new/api/mcp', + headers: { Authorization: ENV_HEADER }, + }, + }, + }) + expect(readFileSync(join(home, '.codex/config.toml'), 'utf8')).toBe( + '[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\nbearer_token_env_var = "BREW_API_KEY"\n' + ) + }) + + it('appends to an existing Codex config once, then reports unchanged', async () => { + const { root, home, env } = setup() + mkdirSync(join(home, '.codex')) + writeFileSync( + join(home, '.codex/config.toml'), + 'model = "gpt-5"\n\n[mcp_servers.docs]\nurl = "https://docs.example/mcp"\n' + ) + const first = await install(['--client', 'codex'], { root, env }) + expect(first.output.clients[0]?.action).toBe('updated') + expect(readFileSync(join(home, '.codex/config.toml'), 'utf8')).toBe( + 'model = "gpt-5"\n\n[mcp_servers.docs]\nurl = "https://docs.example/mcp"\n\n[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\n' + ) + const second = await install(['--client', 'codex'], { root, env }) + expect(second.output.clients[0]?.action).toBe('unchanged') + const envAuth = await install(['--client', 'codex', '--auth', 'env'], { + root, + env, + }) + expect(envAuth.output.clients[0]).toMatchObject({ + action: 'manual', + snippet: expect.stringContaining('bearer_token_env_var'), + }) + }) + + it('honours CODEX_HOME', async () => { + const { root, home, env } = setup() + const codexHome = join(home, 'custom-codex') + const { output } = await install(['--client', 'codex'], { + root, + env: { ...env, CODEX_HOME: codexHome }, + }) + expect(output.clients[0]?.path).toBe(join(codexHome, 'config.toml')) + }) + + it('never writes a literal key with --auth env', async () => { + const { root, env } = setup() + const key = 'brew_live_0123456789abcdefghij' + const { output } = await install( + ['--client', 'all', '--auth', 'env', '--api-key', key], + { root, env: { ...env, BREW_API_KEY: key } } + ) + expect(JSON.stringify(output)).not.toContain(key) + const cursor = readFileSync(join(root, '.cursor/mcp.json'), 'utf8') + expect(cursor).toContain(ENV_HEADER) + expect(cursor).not.toContain(key) + expect(readFileSync(join(root, '.mcp.json'), 'utf8')).toContain( + PLAIN_ENV_HEADER + ) + }) + + it('detects Claude Code from CLAUDECODE=1', async () => { + const { root, env } = setup() + const { output } = await install([], { + root, + env: { ...env, CLAUDECODE: '1' }, + }) + expect(output.clients.map((result) => result.client)).toEqual([ + 'claude-code', + ]) + }) + + it('writes user scope to the home directory; Claude Code gets its own command', async () => { + const { root, home, env } = setup() + const { output } = await install( + ['--client', 'cursor', 'claude-code', '--scope', 'user'], + { root, env } + ) + expect(output.clients[0]).toMatchObject({ + path: join(home, '.cursor/mcp.json'), + action: 'created', + }) + expect(output.clients[1]).toMatchObject({ + client: 'claude-code', + action: 'manual', + snippet: + 'claude mcp add --transport http --scope user brew https://brew.new/api/mcp', + }) + }) + + it('derives the MCP URL from --api-url', async () => { + const { root, env } = setup(['.cursor']) + const { output } = await install( + ['--api-url', 'http://localhost:3000/api'], + { root, env } + ) + expect(output.mcpUrl).toBe('http://localhost:3000/api/mcp') + expect(readJson(join(root, '.cursor/mcp.json'))).toEqual({ + mcpServers: { brew: { url: 'http://localhost:3000/api/mcp' } }, + }) + }) + + it('writes at the monorepo root when run inside a workspace', async () => { + const root = copyFixture('pnpm-turbo') + mkdirSync(join(root, '.cursor')) + const home = tempProject('brew-cli-home-') + const result = await runCli(['mcp', 'install', '--json'], { + cwd: join(root, 'apps/web'), + env: { HOME: home, BREW_CLI_CONFIG_DIR: join(home, '.config/brew-cli') }, + }) + expect((result.json as Output).clients[0]?.path).toBe('.cursor/mcp.json') + expect(existsSync(join(root, '.cursor/mcp.json'))).toBe(true) + }) + + it('fails as a usage error when nothing is detected or a client is unknown', async () => { + const { root, env } = setup() + const none = await runCli(['mcp', 'install', '--json'], { cwd: root, env }) + expect(none.code).toBe(2) + expect(JSON.parse(none.stderr).error.message).toMatch( + /No MCP client detected/ + ) + const unknown = await runCli( + ['mcp', 'install', '--client', 'emacs', '--json'], + { cwd: root, env } + ) + expect(unknown.code).toBe(2) + const scope = await runCli( + ['mcp', 'install', '--client', 'cursor', '--scope', 'global', '--json'], + { cwd: root, env } + ) + expect(scope.code).toBe(2) + }) + + it('renders a readable summary in human mode', async () => { + const { root, env } = setup(['.cursor']) + const result = await runCli(['mcp', 'install'], { + cwd: root, + env, + ttyOut: true, + }) + expect(result.stdout).toContain('created Cursor (.cursor/mcp.json)') + expect(result.stdout).toContain(`One-click install: ${BREW_V2_CURSOR_LINK}`) + }) +}) + +describe('MCP client builders', () => { + it('matches brew-v2 buildCursorInstallUrl byte for byte', () => { + expect(buildCursorInstallUrl()).toBe(BREW_V2_CURSOR_LINK) + // Unpadded base64url: no '+', '/' or '='. + expect( + buildCursorInstallUrl('https://example.test/api/mcp?x=1', 'env') + ).toMatch(/config=[A-Za-z0-9_-]+$/) + }) + + it('builds the VS Code link from encodeURIComponent(JSON)', () => { + expect(buildVsCodeInstallUrl('https://brew.new/api/mcp')).toBe( + 'vscode:mcp/install?%7B%22name%22%3A%22brew%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fbrew.new%2Fapi%2Fmcp%22%7D' + ) + }) + + it('derives the MCP URL from any API base URL', () => { + expect(mcpUrlFromApiUrl('https://brew.new/api')).toBe( + 'https://brew.new/api/mcp' + ) + expect(mcpUrlFromApiUrl('https://tunnel.example/x/api/')).toBe( + 'https://tunnel.example/api/mcp' + ) + }) + + it('finds the VS Code user directory per platform', () => { + expect(vscodeUserDir('/h', {}, 'darwin')).toBe( + '/h/Library/Application Support/Code/User' + ) + expect(vscodeUserDir('/h', {}, 'linux')).toBe('/h/.config/Code/User') + expect(vscodeUserDir('/h', { XDG_CONFIG_HOME: '/x' }, 'linux')).toBe( + '/x/Code/User' + ) + const target = configTarget('vscode', 'user', { + projectRoot: '/p', + home: '/h', + env: {}, + url: 'https://brew.new/api/mcp', + auth: 'oauth', + platform: 'linux', + }) + expect(target).toEqual({ + kind: 'file', + path: '/h/.config/Code/User/mcp.json', + scope: 'user', + }) + }) +}) diff --git a/tests/generate-skills.test.ts b/tests/generate-skills.test.ts new file mode 100644 index 0000000..08f0077 --- /dev/null +++ b/tests/generate-skills.test.ts @@ -0,0 +1,57 @@ +import { cpSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + checkSkillsModule, + collectSkills, + readFrontmatter, + renderSkillsModule, + SKILLS_MODULE_PATH, +} from '../scripts/generate-skills' +import { EMBEDDED_SKILLS } from '../src/generated/skills' + +const REPO = join(import.meta.dirname, '..') + +describe('generate-skills', () => { + it('the committed module is fresh (generate:skills --check)', () => { + expect(checkSkillsModule(REPO)).toBeNull() + }) + + it('embeds every skills/**/SKILL.md verbatim', () => { + const skills = collectSkills(REPO) + expect(skills.map((skill) => skill.path)).toContain( + 'skills/brew-cli/SKILL.md' + ) + expect(EMBEDDED_SKILLS).toEqual(skills) + const cli = EMBEDDED_SKILLS.find((skill) => skill.name === 'brew-cli') + expect(cli?.content).toContain('brew-cli mcp install') + // No hard-coded command count to go stale. + expect(cli?.content).not.toMatch(/\d+ commands/) + }) + + it('reports a stale module, and passes once regenerated', () => { + const root = mkdtempSync(join(tmpdir(), 'brew-cli-skills-')) + cpSync(join(REPO, 'skills'), join(root, 'skills'), { recursive: true }) + mkdirSync(join(root, 'src/generated'), { recursive: true }) + expect(checkSkillsModule(root)).toMatch(/is stale/) + writeFileSync( + join(root, SKILLS_MODULE_PATH), + renderSkillsModule(collectSkills(root)) + ) + expect(checkSkillsModule(root)).toBeNull() + mkdirSync(join(root, 'skills/brew-extra')) + writeFileSync( + join(root, 'skills/brew-extra/SKILL.md'), + '---\nname: brew-extra\ndescription: "Extra"\n---\n\nBody\n' + ) + expect(checkSkillsModule(root)).toMatch(/bun run generate:skills/) + }) + + it('reads frontmatter fields', () => { + expect( + readFrontmatter('---\nname: a\ndescription: "b c"\n---\nbody') + ).toEqual({ name: 'a', description: 'b c' }) + expect(readFrontmatter('no frontmatter')).toEqual({}) + }) +}) diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts index 09278f9..03fa528 100644 --- a/tests/package-metadata.test.ts +++ b/tests/package-metadata.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest' type PackageManifest = { readonly bin?: Readonly> readonly files?: readonly string[] + readonly keywords?: readonly string[] + readonly dependencies?: Readonly> } const manifest = JSON.parse( @@ -17,4 +19,32 @@ describe('npm package metadata', () => { expect(manifest.bin).toEqual({ 'brew-cli': 'dist/bin.js' }) expect(manifest.files).toContain('dist') }) + + it('ships the brew.json schema its $schema URL points at', () => { + expect(manifest.files).toContain('schemas') + }) + + it('is discoverable under the MCP, email and agent keywords', () => { + for (const keyword of [ + 'mcp', + 'model-context-protocol', + 'email', + 'transactional-email', + 'resend', + 'resend-alternative', + 'react-email', + 'ai-agent', + 'agents', + 'auth-emails', + ]) { + expect(manifest.keywords).toContain(keyword) + } + }) + + it('keeps exactly two runtime dependencies', () => { + expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual([ + '@brew.new/sdk', + 'commander', + ]) + }) }) From 19f73f1a095615cd56f775eab7f195c48e4d57dd Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 11 Oct 2026 09:50:23 +0200 Subject: [PATCH 4/9] fix(project,mcp): review round 1 (symlinks, Codex tables, env edge cases) - mcp install: a Codex config that already defines mcp_servers.brew in any other form (quoted table names, dotted keys, sub-tables, an [mcp_servers] table or inline table with `brew =`) is reported `manual` instead of getting a second, config-breaking table. - fs-writer: a symbolic link (a dotfiles-managed config) is written through to its target and survives; `sensitive` writes and block upserts carry no diff, so a dry-run plan never prints a secret. - env-file: `KEY= # todo` reads as empty and gets filled; each line keeps its own ending, so mixed LF/CRLF files round-trip byte for byte. - vendor-http: credentials are redacted before the 300-character cut, so no fragment survives it. - gitignore: ensureIgnored re-checks after editing and reports paths a later rule still re-includes as `conflicts`, never as added. - schemas/brew.json: contractsPath and files keys use the same path rule as the CLI's validator (tested against it). - monorepo: brace patterns (`{apps,packages}/*`) expand in workspace globs. Co-Authored-By: Claude Opus 5.5 --- schemas/brew.json | 61 +++++++++++--- src/commands/mcp/install.ts | 34 +++++++- src/lib/project/env-file.ts | 46 +++++++---- src/lib/project/fs-writer.ts | 111 +++++++++++++++++++------- src/lib/project/gitignore.ts | 50 +++++++++--- src/lib/project/monorepo.ts | 53 +++++++++++- src/lib/project/vendor-http.ts | 16 ++-- tests/commands/mcp-install.test.ts | 33 ++++++++ tests/lib/project/env-file.test.ts | 26 ++++++ tests/lib/project/fs-writer.test.ts | 34 ++++++++ tests/lib/project/gitignore.test.ts | 20 ++++- tests/lib/project/monorepo.test.ts | 14 ++++ tests/lib/project/state.test.ts | 37 +++++++++ tests/lib/project/vendor-http.test.ts | 23 ++++++ 14 files changed, 481 insertions(+), 77 deletions(-) diff --git a/schemas/brew.json b/schemas/brew.json index 81addb4..28bae5d 100644 --- a/schemas/brew.json +++ b/schemas/brew.json @@ -6,16 +6,33 @@ "type": "object", "required": ["version", "projectId", "emails", "files"], "properties": { - "$schema": { "type": "string", "minLength": 1 }, - "version": { "const": 1 }, + "$schema": { + "type": "string", + "minLength": 1 + }, + "version": { + "const": 1 + }, "projectId": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }, - "brandId": { "type": "string", "minLength": 1 }, - "framework": { "type": "string", "minLength": 1 }, - "auth": { "type": "string", "minLength": 1 }, - "contractsPath": { "type": "string", "minLength": 1 }, + "brandId": { + "type": "string", + "minLength": 1 + }, + "framework": { + "type": "string", + "minLength": 1 + }, + "auth": { + "type": "string", + "minLength": 1 + }, + "contractsPath": { + "type": "string", + "pattern": "^(?![A-Za-z]:)(?!/)(?!.*\\\\)(?!(?:.*/)?\\.\\.(?:/|$))(?!.*//)(?!.*/$).+$" + }, "emails": { "type": "object", "propertyNames": { @@ -25,26 +42,46 @@ "type": "object", "required": ["triggerEventId", "automationId"], "properties": { - "triggerEventId": { "type": "string", "minLength": 1 }, - "automationId": { "type": "string", "minLength": 1 } + "triggerEventId": { + "type": "string", + "minLength": 1 + }, + "automationId": { + "type": "string", + "minLength": 1 + } } } }, "files": { "type": "object", + "propertyNames": { + "pattern": "^(?![A-Za-z]:)(?!/)(?!.*\\\\)(?!(?:.*/)?\\.\\.(?:/|$))(?!.*//)(?!.*/$).+$" + }, "additionalProperties": { "type": "object", "required": ["sha256", "generator"], "properties": { - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "generator": { "type": "string", "minLength": 1 } + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "generator": { + "type": "string", + "minLength": 1 + } } } }, - "providerConfig": { "type": "object" }, + "providerConfig": { + "type": "object" + }, "mcp": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true } } diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 4f81953..ebb3fcd 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -270,8 +270,28 @@ function installJson( return fromWriter(writer.writeFile(path, text, { overwrite: true }).action) } -const CODEX_TABLE = - /^\s*\[\s*mcp_servers\s*\.\s*(?:brew|"brew"|'brew')\s*\]\s*(?:#.*)?$/ +/** A TOML key segment, bare or quoted: `mcp_servers`, `"mcp_servers"`. */ +const seg = (name: string): string => `(?:${name}|"${name}"|'${name}')` +const CODEX_TABLE = new RegExp( + `^\\s*\\[\\s*${seg('mcp_servers')}\\s*\\.\\s*${seg('brew')}\\s*\\]\\s*(?:#.*)?$` +) +/** + * Any other way a TOML file can already define `mcp_servers.brew`: a + * dotted key (`mcp_servers.brew.url = …`), a sub-table + * (`[mcp_servers.brew.env]`), or a `brew = {…}` inline table. Appending a + * second definition would make the whole config fail to load. + */ +const CODEX_OTHER_BREW = new RegExp( + `${seg('mcp_servers')}\\s*\\.\\s*${seg('brew')}\\s*[.=\\]]` +) +const CODEX_SERVERS_TABLE = new RegExp( + `^\\s*\\[\\s*${seg('mcp_servers')}\\s*\\]`, + 'm' +) +const BREW_KEY_LINE = new RegExp(`^\\s*${seg('brew')}\\s*=`, 'm') +const CODEX_INLINE_SERVERS = new RegExp( + `${seg('mcp_servers')}\\s*=\\s*\\{[^\\n]*${seg('brew')}\\s*=` +) function installCodex( writer: ProjectWriter, @@ -312,6 +332,16 @@ function installCodex( note: `A [mcp_servers.${MCP_SERVER_NAME}] table with other settings exists: edit it by hand.`, } } + if ( + CODEX_OTHER_BREW.test(existing) || + CODEX_INLINE_SERVERS.test(existing) || + (CODEX_SERVERS_TABLE.test(existing) && BREW_KEY_LINE.test(existing)) + ) { + return { + action: 'manual', + note: `${MCP_SERVER_NAME} is already defined in a form this command does not edit: check it by hand.`, + } + } const normalized = existing.replaceAll('\r\n', '\n') const separator = normalized === '' || normalized.endsWith('\n\n') diff --git a/src/lib/project/env-file.ts b/src/lib/project/env-file.ts index 948bb2f..3929f30 100644 --- a/src/lib/project/env-file.ts +++ b/src/lib/project/env-file.ts @@ -5,7 +5,9 @@ * when present but empty), never replaced. * * Values are secrets. Nothing here returns or prints one: results name the - * variable and what happened to it, never its value. + * variable and what happened to it, never its value. Write the result with + * `ProjectWriter.writeFile(path, text, { overwrite: true, mode: 0o600, + * sensitive: true })` so no dry-run diff carries it either. */ export type EnvLine = @@ -71,18 +73,20 @@ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ export function parseEnv(text: string): EnvFile { const eol = text.includes('\r\n') ? '\r\n' : '\n' - const normalized = text.replaceAll('\r\n', '\n') - const trailingNewline = normalized.endsWith('\n') - const source = normalized === '' ? [] : normalized.split('\n') + const trailingNewline = text.endsWith('\n') + // Split on LF only: a line keeps its own `\r`, so a file that mixes + // endings serializes back exactly as it was read. + const source = text === '' ? [] : text.split('\n') if (trailingNewline) { source.pop() } const lines: EnvLine[] = [] for (let index = 0; index < source.length; index += 1) { - const line = source[index] ?? '' + const raw = source[index] ?? '' + const line = raw.replace(/\r$/, '') const match = VAR_LINE.exec(line) if (match === null) { - lines.push({ kind: 'other', raw: line }) + lines.push({ kind: 'other', raw }) continue } const key = match[1] ?? '' @@ -90,14 +94,14 @@ export function parseEnv(text: string): EnvFile { const quote = rest[0] if (quote === '"' || quote === "'" || quote === '`') { // A quoted value may continue over the following lines. - const rawLines = [line] + const rawLines = [raw] let body = rest.slice(1) let close = findClosingQuote(body, quote) while (close === -1 && index + 1 < source.length) { index += 1 const next = source[index] ?? '' rawLines.push(next) - body = `${body}\n${next}` + body = `${body}\n${next.replace(/\r$/, '')}` close = findClosingQuote(body, quote) } const inner = close === -1 ? body : body.slice(0, close) @@ -105,11 +109,12 @@ export function parseEnv(text: string): EnvFile { lines.push({ kind: 'var', key, value, raw: rawLines.join('\n') }) continue } - const comment = rest.search(/\s#/) + // An unquoted value ends at a ` #` comment, or is one (`KEY= # todo`). + const comment = rest.search(/(?:^|\s)#/) if (comment !== -1) { rest = rest.slice(0, comment) } - lines.push({ kind: 'var', key, value: rest.trim(), raw: line }) + lines.push({ kind: 'var', key, value: rest.trim(), raw }) } return { lines, eol, trailingNewline } } @@ -143,13 +148,13 @@ function unescapeDouble(value: string): string { }) } +/** Each line's own ending (`raw` carries its `\r`); new lines use `eol`. */ export function serializeEnv(file: EnvFile): string { if (file.lines.length === 0) { return '' } const body = file.lines.map((line) => line.raw).join('\n') - const text = file.trailingNewline ? `${body}\n` : body - return file.eol === '\r\n' ? text.replaceAll('\n', '\r\n') : text + return file.trailingNewline ? `${body}\n` : body } /** The LAST assignment wins, as every dotenv loader reads it. */ @@ -216,23 +221,32 @@ export function ensureVar( return line } const exported = /^\s*export\s/.test(line.raw) + const cr = line.raw.endsWith('\r') ? '\r' : '' return { kind: 'var' as const, key: name, value, - raw: `${exported ? 'export ' : ''}${assignment}`, + raw: `${exported ? 'export ' : ''}${assignment}${cr}`, } }) return { file: { ...file, lines }, result: { name, action: 'filled' } } } + const cr = file.eol === '\r\n' ? '\r' : '' + const previous = file.lines.map((line, index) => + // A file without a final newline gets one before the new line. + !file.trailingNewline && + index === file.lines.length - 1 && + !line.raw.endsWith('\r') + ? { ...line, raw: `${line.raw}${cr}` } + : line + ) return { file: { ...file, - // A file without a final newline gets one, so the new line stands alone. trailingNewline: true, lines: [ - ...file.lines, - { kind: 'var', key: name, value, raw: assignment }, + ...previous, + { kind: 'var', key: name, value, raw: `${assignment}${cr}` }, ], }, result: { name, action: 'added' }, diff --git a/src/lib/project/fs-writer.ts b/src/lib/project/fs-writer.ts index a4b6fc7..8714237 100644 --- a/src/lib/project/fs-writer.ts +++ b/src/lib/project/fs-writer.ts @@ -1,8 +1,11 @@ import { createHash, randomBytes } from 'node:crypto' import { chmodSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, + realpathSync, renameSync, rmSync, statSync, @@ -94,17 +97,39 @@ export function readTextFile(path: string): string | null { } } +/** + * The file a write to `path` must land in: a symbolic link (a config kept + * in a dotfiles repo) is followed to its target, so the rename replaces the + * target's content and the link survives. A dangling link resolves to the + * path it names. + */ +export function resolveWriteTarget(path: string): string { + try { + if (!lstatSync(path).isSymbolicLink()) { + return path + } + } catch { + return path + } + try { + return realpathSync(path) + } catch { + return resolve(dirname(path), readlinkSync(path)) + } +} + /** * Writes `content` to `path` atomically: a temp file beside it, then a * rename, so a crash or Ctrl-C never leaves a half-written config. `mode` * applies to the new file (0o600 for secrets); without it an existing - * file keeps its mode. + * file keeps its mode. A symbolic link is written through, never replaced. */ export function writeFileAtomic( - path: string, + linkOrPath: string, content: string, options: { readonly mode?: number } = {} ): void { + const path = resolveWriteTarget(linkOrPath) mkdirSync(dirname(path), { recursive: true }) let mode = options.mode if (mode === undefined) { @@ -139,6 +164,11 @@ export type WriteFileOptions = { * may replace it. Any other content is the user's: kept. */ readonly managedSha256?: string + /** + * The content is secret (`.env.local`): the change carries no diff, so a + * dry-run plan or report can never print a value. + */ + readonly sensitive?: boolean /** * Replace whatever is there. Only for files whose new content the caller * derived FROM the current content (a JSON merge), so nothing is lost. @@ -149,6 +179,8 @@ export type WriteFileOptions = { export type UpsertBlockOptions = { readonly markers?: BlockMarkers readonly mode?: number + /** As `WriteFileOptions.sensitive`: no diff in the change. */ + readonly sensitive?: boolean /** Create the file when it does not exist (default true). */ readonly createIfMissing?: boolean } @@ -209,6 +241,7 @@ export class ProjectWriter { content, before: '', mode: options.mode, + sensitive: options.sensitive === true, }) } if (normalizeEol(existing) === normalizeEol(content)) { @@ -236,6 +269,7 @@ export class ProjectWriter { content: withEol(content, usesCrlf(existing)), before: existing, mode: options.mode, + sensitive: options.sensitive === true, }) } @@ -253,13 +287,15 @@ export class ProjectWriter { const markers = options.markers ?? HTML_BLOCK_MARKERS const absolute = this.resolvePath(path) const existing = readTextFile(absolute) - const body = normalizeEol(content).replace(/\n+$/, '') - const block = `${markers.start}\n${body}\n${markers.end}` + const sensitive = options.sensitive === true if (existing === null) { if (options.createIfMissing === false) { return this.#record({ path, action: 'missing' }) } - const created = `${block}\n` + const created = applyBlock('', content, markers) + if (created === 'unbalanced') { + throw new Error('unreachable: an empty file has no markers') + } return this.#commit(absolute, { path, action: 'created', @@ -267,12 +303,11 @@ export class ProjectWriter { content: created, before: '', mode: options.mode, + sensitive, }) } - const crlf = usesCrlf(existing) - const text = normalizeEol(existing) - const located = locateBlock(text, markers) - if (located === 'unbalanced') { + const next = applyBlock(existing, content, markers) + if (next === 'unbalanced') { return this.#record({ path, action: 'kept-user-version', @@ -280,27 +315,14 @@ export class ProjectWriter { note: `unbalanced ${markers.start} / ${markers.end} markers`, }) } - let next: string - if (located === null) { - const separator = - text === '' - ? '' - : text.endsWith('\n\n') - ? '' - : text.endsWith('\n') - ? '\n' - : '\n\n' - next = `${text}${separator}${block}\n` - } else { - next = `${text.slice(0, located.start)}${block}${text.slice(located.end)}` - } - if (next === text) { + if (next === normalizeEol(existing)) { return this.#record({ path, action: 'unchanged', sha256: sha256Hex(existing), }) } + const crlf = usesCrlf(existing) return this.#commit(absolute, { path, action: 'updated', @@ -308,6 +330,7 @@ export class ProjectWriter { content: withEol(next, crlf), before: existing, mode: options.mode, + sensitive, }) } @@ -346,6 +369,7 @@ export class ProjectWriter { readonly content: string readonly before: string readonly mode: number | undefined + readonly sensitive: boolean } ): PlannedChange { if (!this.dryRun) { @@ -359,13 +383,46 @@ export class ProjectWriter { path: input.path, action: input.action, sha256: input.sha256, - diff: unifiedDiff(input.before, input.content, { - fromPath: input.path, - }), + ...(input.sensitive + ? {} + : { + diff: unifiedDiff(input.before, input.content, { + fromPath: input.path, + }), + }), }) } } +/** + * `text` (LF-normalized) with its marked block set to `content`: replaced + * in place, or appended after a blank line when the text has none. + * `'unbalanced'` when the markers do not pair up exactly once. + */ +export function applyBlock( + text: string, + content: string, + markers: BlockMarkers = HTML_BLOCK_MARKERS +): string | 'unbalanced' { + const normalized = normalizeEol(text) + const body = normalizeEol(content).replace(/\n+$/, '') + const block = `${markers.start}\n${body}\n${markers.end}` + const located = locateBlock(normalized, markers) + if (located === 'unbalanced') { + return located + } + if (located !== null) { + return `${normalized.slice(0, located.start)}${block}${normalized.slice(located.end)}` + } + const separator = + normalized === '' || normalized.endsWith('\n\n') + ? '' + : normalized.endsWith('\n') + ? '\n' + : '\n\n' + return `${normalized}${separator}${block}\n` +} + /** * The block's span (from its start marker through its end marker) in * LF-normalized text: `null` when there is none, `'unbalanced'` when the diff --git a/src/lib/project/gitignore.ts b/src/lib/project/gitignore.ts index 0bcb6d9..cbf7bf2 100644 --- a/src/lib/project/gitignore.ts +++ b/src/lib/project/gitignore.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import type { SpawnFn } from '../types' import { + applyBlock, HASH_BLOCK_MARKERS, type PlannedChange, type ProjectWriter, @@ -174,30 +175,38 @@ export function matchesGitignore(text: string, path: string): boolean { /** * Adds every path git would not ignore to a `# brew:start` / `# brew:end` * block in the root `.gitignore` (created when missing). Lines outside - * the block are never touched. Returns what it added, and the change when - * it had to write. + * the block are never touched. + * + * Then it checks the result: a rule after the block (`!.env.local`), or one + * in a nested `.gitignore`, can still win. Such a path is a `conflict` — + * never reported as added. A real run asks git again; a dry run evaluates + * the planned `.gitignore` with the fallback parser. */ export async function ensureIgnored( writer: ProjectWriter, paths: readonly string[], options: { readonly spawn?: SpawnFn; readonly signal?: AbortSignal } = {} ): Promise<{ + /** Paths the block now makes git ignore. */ readonly added: readonly string[] + /** Paths still not ignored after the edit: the user must resolve them. */ + readonly conflicts: readonly string[] readonly change?: PlannedChange }> { - const added: string[] = [] - for (const path of paths) { - const ignored = await isIgnored(path, { + const check = (path: string): Promise => + isIgnored(path, { cwd: writer.root, ...(options.spawn === undefined ? {} : { spawn: options.spawn }), ...(options.signal === undefined ? {} : { signal: options.signal }), }) - if (!ignored) { - added.push(path) + const missing: string[] = [] + for (const path of paths) { + if (!(await check(path))) { + missing.push(path) } } - if (added.length === 0) { - return { added } + if (missing.length === 0) { + return { added: [], conflicts: [] } } const current = writer.read('.gitignore') const existing = @@ -206,10 +215,27 @@ export async function ensureIgnored( : (readBlock(current, HASH_BLOCK_MARKERS) ?? '').split('\n') const lines = [ ...existing.filter((line) => line.trim() !== ''), - ...added.filter((path) => !existing.includes(path)), + ...missing.filter((path) => !existing.includes(path)), ] - const change = writer.upsertBlock('.gitignore', lines.join('\n'), { + const content = lines.join('\n') + const planned = applyBlock(current ?? '', content, HASH_BLOCK_MARKERS) + const change = writer.upsertBlock('.gitignore', content, { markers: HASH_BLOCK_MARKERS, }) - return { added, change } + const added: string[] = [] + const conflicts: string[] = [] + for (const path of missing) { + const isNowIgnored = + change.action === 'kept-user-version' || planned === 'unbalanced' + ? false + : writer.dryRun + ? matchesGitignore(planned, path) + : await check(path) + if (isNowIgnored) { + added.push(path) + } else { + conflicts.push(path) + } + } + return { added, conflicts, change } } diff --git a/src/lib/project/monorepo.ts b/src/lib/project/monorepo.ts index ec7882a..9ba18d4 100644 --- a/src/lib/project/monorepo.ts +++ b/src/lib/project/monorepo.ts @@ -261,6 +261,53 @@ function listDirs(dir: string): string[] { } } +/** + * `{apps,packages}/*` → `apps/*`, `packages/*` (nested and repeated braces + * included), as the glob libraries behind every workspace tool expand them. + */ +export function expandBraces(pattern: string): string[] { + const open = pattern.indexOf('{') + if (open === -1) { + return [pattern] + } + let depth = 0 + const commas: number[] = [] + let close = -1 + for (let index = open; index < pattern.length; index += 1) { + const char = pattern[index] + if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + if (depth === 0) { + close = index + break + } + } else if (char === ',' && depth === 1) { + commas.push(index) + } + } + if (close === -1 || commas.length === 0) { + // Unbalanced or a single alternative: a literal brace. + const rest = close === -1 ? '' : pattern.slice(open + 1) + return close === -1 + ? [pattern] + : expandBraces(rest).map((tail) => `${pattern.slice(0, open + 1)}${tail}`) + } + const head = pattern.slice(0, open) + const tail = pattern.slice(close + 1) + const bounds = [open, ...commas, close] + const alternatives: string[] = [] + for (let index = 0; index < bounds.length - 1; index += 1) { + alternatives.push( + pattern.slice((bounds[index] ?? 0) + 1, bounds[index + 1] ?? 0) + ) + } + return alternatives.flatMap((alternative) => + expandBraces(`${head}${alternative}${tail}`) + ) +} + /** Workspace packages under a monorepo root (dirs that hold a package.json). */ export function listWorkspacePackages( root: string, @@ -271,8 +318,10 @@ export function listWorkspacePackages( for (const pattern of patterns) { const negated = pattern.startsWith('!') const target = negated ? exclude : include - for (const dir of expandGlob(root, negated ? pattern.slice(1) : pattern)) { - target.add(dir) + for (const glob of expandBraces(negated ? pattern.slice(1) : pattern)) { + for (const dir of expandGlob(root, glob)) { + target.add(dir) + } } } const packages: WorkspacePackage[] = [] diff --git a/src/lib/project/vendor-http.ts b/src/lib/project/vendor-http.ts index bbb098e..062c9e3 100644 --- a/src/lib/project/vendor-http.ts +++ b/src/lib/project/vendor-http.ts @@ -207,11 +207,17 @@ function vendorMessage( secrets: readonly string[] ): string { const detail = vendorDetail(response.json) - const text = - detail === undefined - ? `${vendor} answered ${response.status}.` - : `${vendor} answered ${response.status}: ${detail.slice(0, MAX_DETAIL_LENGTH)}` - return redact(text, secrets) + if (detail === undefined) { + return `${vendor} answered ${response.status}.` + } + // Redact BEFORE cutting: a secret straddling the cut would otherwise + // survive as a fragment no pattern recognizes. + const clean = redact(detail, secrets) + const cut = + clean.length > MAX_DETAIL_LENGTH + ? `${clean.slice(0, MAX_DETAIL_LENGTH)}…` + : clean + return `${vendor} answered ${response.status}: ${cut}` } /** The human part of common vendor error bodies. */ diff --git a/tests/commands/mcp-install.test.ts b/tests/commands/mcp-install.test.ts index 5dd094d..e0895ce 100644 --- a/tests/commands/mcp-install.test.ts +++ b/tests/commands/mcp-install.test.ts @@ -289,6 +289,39 @@ describe('mcp install', () => { }) }) + it('never appends a second brew table to a Codex config that has one', async () => { + const { root, home, env } = setup() + mkdirSync(join(home, '.codex')) + const configs = { + quotedHeader: '["mcp_servers".brew]\nurl = "https://brew.new/api/mcp"\n', + quotedBoth: + '[\'mcp_servers\'."brew"]\nurl = "https://brew.new/api/mcp"\n', + dottedKey: 'mcp_servers.brew.url = "https://brew.new/api/mcp"\n', + subTable: '[mcp_servers.brew.env]\nX = "1"\n', + serversTable: + '[mcp_servers]\nbrew = { url = "https://brew.new/api/mcp" }\n', + inline: 'mcp_servers = { brew = { url = "https://x" } }\n', + } + for (const [name, text] of Object.entries(configs)) { + writeFileSync(join(home, '.codex/config.toml'), text) + const { output } = await install(['--client', 'codex'], { root, env }) + expect( + ['unchanged', 'manual'].includes(output.clients[0]?.action ?? ''), + name + ).toBe(true) + expect(readFileSync(join(home, '.codex/config.toml'), 'utf8'), name).toBe( + text + ) + } + // A different server whose name starts with brew is not ours. + writeFileSync( + join(home, '.codex/config.toml'), + '[mcp_servers.brew-staging]\nurl = "https://staging.example/api/mcp"\n' + ) + const { output } = await install(['--client', 'codex'], { root, env }) + expect(output.clients[0]?.action).toBe('updated') + }) + it('honours CODEX_HOME', async () => { const { root, home, env } = setup() const codexHome = join(home, 'custom-codex') diff --git a/tests/lib/project/env-file.test.ts b/tests/lib/project/env-file.test.ts index 9aaa220..8819b17 100644 --- a/tests/lib/project/env-file.test.ts +++ b/tests/lib/project/env-file.test.ts @@ -75,6 +75,32 @@ describe('ensureVar', () => { expect(results[0]?.action).toBe('filled') }) + it('fills a key whose only content is a comment', () => { + const file = parseEnv('BREW_API_KEY= # TODO\nB=x #note\nC=a#b\n') + expect(getEnvValue(file, 'BREW_API_KEY')).toBe('') + expect(getEnvValue(file, 'B')).toBe('x') + expect(getEnvValue(file, 'C')).toBe('a#b') + const { results } = ensureEnvVars('BREW_API_KEY= # TODO\n', [ + { name: 'BREW_API_KEY', value: SECRET }, + ]) + expect(results[0]?.action).toBe('filled') + }) + + it('round-trips mixed line endings untouched, and matches them when adding', () => { + const mixed = 'A=1\r\nB=2\nC=3\r\n' + expect(serializeEnv(parseEnv(mixed))).toBe(mixed) + expect(ensureEnvVars(mixed, [{ name: 'B', value: 'x' }]).text).toBe(mixed) + expect(ensureEnvVars(mixed, [{ name: 'D', value: '4' }]).text).toBe( + `${mixed}D=4\r\n` + ) + expect( + ensureEnvVars('A=\r\nB=2\r\n', [{ name: 'A', value: '1' }]).text + ).toBe('A=1\r\nB=2\r\n') + expect(ensureEnvVars('X=1\r\nY=2', [{ name: 'A', value: '1' }]).text).toBe( + 'X=1\r\nY=2\r\nA=1\r\n' + ) + }) + it('creates a file from nothing, and adds a final newline when missing', () => { expect(ensureEnvVars(null, [{ name: 'A', value: '1' }]).text).toBe('A=1\n') expect(ensureEnvVars('X=1', [{ name: 'A', value: '1' }]).text).toBe( diff --git a/tests/lib/project/fs-writer.test.ts b/tests/lib/project/fs-writer.test.ts index edb5706..035670e 100644 --- a/tests/lib/project/fs-writer.test.ts +++ b/tests/lib/project/fs-writer.test.ts @@ -1,9 +1,11 @@ import { existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs' import { join } from 'node:path' @@ -100,6 +102,38 @@ describe('ProjectWriter.writeFile (create-only)', () => { expect(readFileSync(join(root, 'merged.json'), 'utf8')).toBe('{"a":1}\n') }) + it('writes through a symbolic link instead of replacing it', () => { + const root = tempProject() + const dotfiles = tempProject('brew-cli-dotfiles-') + writeFileSync(join(dotfiles, 'mcp.json'), '{"a":1}\n') + symlinkSync(join(dotfiles, 'mcp.json'), join(root, 'mcp.json')) + symlinkSync(join(dotfiles, 'missing.json'), join(root, 'dangling.json')) + const writer = new ProjectWriter({ root }) + expect( + writer.writeFile('mcp.json', '{"a":2}\n', { overwrite: true }).action + ).toBe('updated') + expect(lstatSync(join(root, 'mcp.json')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(dotfiles, 'mcp.json'), 'utf8')).toBe('{"a":2}\n') + expect(writer.writeFile('dangling.json', 'x\n').action).toBe('created') + expect(lstatSync(join(root, 'dangling.json')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(dotfiles, 'missing.json'), 'utf8')).toBe('x\n') + }) + + it('never puts sensitive content in a diff', () => { + const root = tempProject() + const writer = new ProjectWriter({ root, dryRun: true }) + const secret = writer.writeFile('.env.local', 'KEY=brew_secret\n', { + sensitive: true, + }) + const block = writer.upsertBlock('.env', 'KEY=brew_secret', { + markers: HASH_BLOCK_MARKERS, + sensitive: true, + }) + expect(secret.diff).toBeUndefined() + expect(block.diff).toBeUndefined() + expect(JSON.stringify(writer.changes)).not.toContain('brew_secret') + }) + it('refuses relative paths that escape the root', () => { const writer = new ProjectWriter({ root: tempProject() }) expect(() => writer.writeFile('../outside.ts', 'x')).toThrow( diff --git a/tests/lib/project/gitignore.test.ts b/tests/lib/project/gitignore.test.ts index c10d363..1da0284 100644 --- a/tests/lib/project/gitignore.test.ts +++ b/tests/lib/project/gitignore.test.ts @@ -111,7 +111,7 @@ describe('ensureIgnored', () => { const again = await ensureIgnored(writer, ['.env', '.env.local'], { spawn: enoent, }) - expect(again).toEqual({ added: [] }) + expect(again).toEqual({ added: [], conflicts: [] }) }) it('extends an existing block and creates a missing .gitignore', async () => { @@ -124,6 +124,23 @@ describe('ensureIgnored', () => { ) }) + it('reports a path a later rule re-includes as a conflict, not added', async () => { + const root = tempProject() + writeFileSync( + join(root, '.gitignore'), + '# brew:start\n# brew:end\n!.env.local\n' + ) + for (const dryRun of [true, false]) { + const result = await ensureIgnored( + new ProjectWriter({ root, dryRun }), + ['.env.local'], + { spawn: enoent } + ) + expect(result.added).toEqual([]) + expect(result.conflicts).toEqual(['.env.local']) + } + }) + it('plans without writing in dry-run mode', async () => { const root = tempProject() const writer = new ProjectWriter({ root, dryRun: true }) @@ -131,6 +148,7 @@ describe('ensureIgnored', () => { spawn: fakeGit({}), }) expect(result.change?.action).toBe('created') + expect(result.added).toEqual(['.env.local']) expect(() => readFileSync(join(root, '.gitignore'))).toThrow() }) }) diff --git a/tests/lib/project/monorepo.test.ts b/tests/lib/project/monorepo.test.ts index 4ff96ae..b17ce18 100644 --- a/tests/lib/project/monorepo.test.ts +++ b/tests/lib/project/monorepo.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { detectFramework, + expandBraces, findMonorepo, listWorkspacePackages, parsePnpmWorkspaceYaml, @@ -118,6 +119,19 @@ describe('workspace helpers', () => { ]) }) + it('expands brace patterns', () => { + expect(expandBraces('{apps,packages}/*')).toEqual(['apps/*', 'packages/*']) + expect(expandBraces('a/{b,{c,d}}/e')).toEqual(['a/b/e', 'a/c/e', 'a/d/e']) + expect(expandBraces('{x,y}/{1,2}')).toEqual(['x/1', 'x/2', 'y/1', 'y/2']) + expect(expandBraces('plain/*')).toEqual(['plain/*']) + expect(expandBraces('odd/{one}/x')).toEqual(['odd/{one}/x']) + expect(expandBraces('open/{a,b')).toEqual(['open/{a,b']) + const root = copyFixture('pnpm-turbo') + expect( + listWorkspacePackages(root, ['{apps,packages}/*']).map((pkg) => pkg.dir) + ).toEqual(['apps/api', 'apps/web', 'packages/ui']) + }) + it('names a framework by its most specific dependency', () => { expect( detectFramework({ dependencies: { react: '19', next: '16', vite: '6' } }) diff --git a/tests/lib/project/state.test.ts b/tests/lib/project/state.test.ts index b366aa7..8da0507 100644 --- a/tests/lib/project/state.test.ts +++ b/tests/lib/project/state.test.ts @@ -5,6 +5,7 @@ import { BREW_STATE_SCHEMA_URL, BrewStateError, createBrewState, + isSafeRelativePath, parseBrewState, readBrewState, serializeBrewState, @@ -161,4 +162,40 @@ describe('brew.json state', () => { ) ) }) + + it('checks paths in the schema exactly as the CLI does', () => { + const schema = JSON.parse( + readFileSync( + new URL('../../../schemas/brew.json', import.meta.url), + 'utf8' + ) + ) as { + properties: { + contractsPath: { pattern: string } + files: { propertyNames: { pattern: string } } + } + } + const pattern = new RegExp(schema.properties.contractsPath.pattern, 'u') + expect(schema.properties.files.propertyNames.pattern).toBe( + schema.properties.contractsPath.pattern + ) + for (const path of [ + 'lib/brew.ts', + '.env.example', + 'a/b/c.ts', + './x.ts', + '..foo/bar', + '../outside.ts', + 'a/../b', + 'a/..', + '/abs.ts', + 'C:/x.ts', + 'a\\b.ts', + 'a//b', + 'dir/', + '', + ]) { + expect(pattern.test(path), path).toBe(isSafeRelativePath(path)) + } + }) }) diff --git a/tests/lib/project/vendor-http.test.ts b/tests/lib/project/vendor-http.test.ts index e7cd12b..fa2884d 100644 --- a/tests/lib/project/vendor-http.test.ts +++ b/tests/lib/project/vendor-http.test.ts @@ -107,6 +107,29 @@ describe('vendorFetch', () => { expect(toExitCode(error)).toBe(1) }) + it('redacts before truncating, so no fragment survives the cut', async () => { + server.use( + http.get(URL_BASE, () => + HttpResponse.json( + { message: `${' '.repeat(291)}${TOKEN} trailing text` }, + { status: 400 } + ) + ) + ) + const error = await failure( + vendorFetch(context(), { + vendor: 'Supabase', + method: 'GET', + url: URL_BASE, + headers: { Authorization: `Bearer ${TOKEN}` }, + }) + ) + const message = (error as Error).message + expect(message).not.toContain('sbp_') + expect(message).not.toContain('0123456789') + expect(message).toMatch(/\[redacted…$/) + }) + it('reads Clerk-style error arrays', async () => { server.use( http.get('https://api.clerk.com/v1/instance', () => From d5442a827dbd248fe0cc46ac542c1014a8f6acb7 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 11 Oct 2026 09:59:07 +0200 Subject: [PATCH 5/9] fix(mcp): ignore comments and string values when checking a Codex config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment or string that merely mentions mcp_servers.brew (`# … was removed`, a commented-out header, `notes = "…"`, a multi-line array or string) no longer reads as an existing definition: the checks run on the TOML structure with comments stripped and string values blanked, quoted keys kept. Co-Authored-By: Claude Opus 5.5 --- src/commands/mcp/install.ts | 74 +++++++++++++++++++++++++++--- tests/commands/mcp-install.test.ts | 16 +++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index ebb3fcd..34a9666 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -293,6 +293,65 @@ const CODEX_INLINE_SERVERS = new RegExp( `${seg('mcp_servers')}\\s*=\\s*\\{[^\\n]*${seg('brew')}\\s*=` ) +/** + * TOML with comments removed and string VALUES blanked (newlines kept, so + * line numbers still match): what is left is keys, headers and + * punctuation. Quoted keys stay — a string before `=`, or one followed by + * `=` / `.` / `]`, is a key. A value context opened by `[` or `{` after + * `=` spans lines until it closes. + */ +export function tomlStructure(text: string): string { + let out = '' + let isValue = false + let depth = 0 + let index = 0 + while (index < text.length) { + const char = text[index] ?? '' + if (char === '\n') { + out += char + isValue = depth > 0 + index += 1 + continue + } + if (char === '#') { + while (index < text.length && text[index] !== '\n') { + index += 1 + } + continue + } + if (char === '"' || char === "'") { + const delimiter = text.startsWith(char.repeat(3), index) + ? char.repeat(3) + : char + let end = index + delimiter.length + while (end < text.length && !text.startsWith(delimiter, end)) { + if (char === '"' && text[end] === '\\') { + end += 1 + } else if (delimiter.length === 1 && text[end] === '\n') { + break + } + end += 1 + } + end = Math.min(text.length, end + delimiter.length) + const literal = text.slice(index, end) + const isKey = !isValue || /^[ \t]*[=.\]]/.test(text.slice(end, end + 64)) + out += isKey ? literal : literal.replace(/[^\n]/g, ' ') + index = end + continue + } + if (char === '=') { + isValue = true + } else if (isValue && (char === '[' || char === '{')) { + depth += 1 + } else if (isValue && depth > 0 && (char === ']' || char === '}')) { + depth -= 1 + } + out += char + index += 1 + } + return out +} + function installCodex( writer: ProjectWriter, path: string, @@ -305,11 +364,14 @@ function installCodex( return fromWriter(writer.writeFile(path, block).action) } const lines = existing.replaceAll('\r\n', '\n').split('\n') - const start = lines.findIndex((line) => CODEX_TABLE.test(line)) + // Comments and string values hidden: only real TOML structure counts. + const structure = tomlStructure(existing.replaceAll('\r\n', '\n')) + const structureLines = structure.split('\n') + const start = structureLines.findIndex((line) => CODEX_TABLE.test(line)) if (start !== -1) { const body: string[] = [] - for (const line of lines.slice(start + 1)) { - if (/^\s*\[/.test(line)) { + for (const [offset, line] of lines.slice(start + 1).entries()) { + if (/^\s*\[/.test(structureLines[start + 1 + offset] ?? '')) { break } const trimmed = line.replace(/\s+#.*$/, '').trim() @@ -333,9 +395,9 @@ function installCodex( } } if ( - CODEX_OTHER_BREW.test(existing) || - CODEX_INLINE_SERVERS.test(existing) || - (CODEX_SERVERS_TABLE.test(existing) && BREW_KEY_LINE.test(existing)) + CODEX_OTHER_BREW.test(structure) || + CODEX_INLINE_SERVERS.test(structure) || + (CODEX_SERVERS_TABLE.test(structure) && BREW_KEY_LINE.test(structure)) ) { return { action: 'manual', diff --git a/tests/commands/mcp-install.test.ts b/tests/commands/mcp-install.test.ts index e0895ce..100bb47 100644 --- a/tests/commands/mcp-install.test.ts +++ b/tests/commands/mcp-install.test.ts @@ -313,6 +313,22 @@ describe('mcp install', () => { text ) } + // Comments and string values that only mention it are not definitions. + for (const text of [ + '# mcp_servers.brew.url was removed\n', + '# [mcp_servers.brew]\nmodel = "x"\n', + 'notes = "mcp_servers.brew = old"\n', + 'args = [\n "mcp_servers.brew.url",\n]\n', + "doc = '''\n[mcp_servers.brew]\n'''\n", + ]) { + writeFileSync(join(home, '.codex/config.toml'), text) + const { output } = await install(['--client', 'codex'], { root, env }) + expect(output.clients[0]?.action, text).toBe('updated') + expect( + readFileSync(join(home, '.codex/config.toml'), 'utf8'), + text + ).toContain('\n[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\n') + } // A different server whose name starts with brew is not ours. writeFileSync( join(home, '.codex/config.toml'), From ab763e651d4a93f5bb537fff8f1a6b7885f42dec Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 13 Oct 2026 00:48:59 +0200 Subject: [PATCH 6/9] fix(mcp,project): audit round 2 (Claude header order, Codex shapes, Devin Desktop path, symlink containment) mcp install - Claude Code user scope: `--header` is variadic in `claude mcp add`, so it now follows the name and URL (before them it swallowed both: "missing required argument 'name'"). Verified against claude 2.1.212. - Codex: the regex checks are replaced by a small TOML key reader over the comment- and value-blanked structure. An inline or empty `mcp_servers = {...}`, `[[mcp_servers]]`, `brew.url = ...` under `[mcp_servers]`, or a `[mcp_servers.brew.*]` sub-table now comes back `manual` with the snippet (each shape checked against `codex mcp list`); dotted root keys (`mcp_servers.docs.url = ...`) still take the table. Unreadable TOML is `manual`, never appended to. - Codex OAuth results carry `nextStep: "codex mcp login brew"`. - Windsurf is now Devin Desktop: its config is `$XDG_CONFIG_HOME/devin/mcp_config.json` (`~/.config/devin`, `%APPDATA%\devin` on Windows) per docs.devin.ai, read by both its agents, and the entry uses `url` (Devin Local documents only `url`). The legacy `~/.codeium/windsurf` path is gone: the docs no longer mention it. `.devin/` is detected beside `.windsurf/`. - An existing `brew` entry is never replaced: equal is `unchanged`, other settings (an env header, a stale URL) are `manual` with the snippet. - JSON configs that start with a UTF-8 BOM are parsed, and keep the mark. - No package.json up to the repo: the project is the git root (`git rev-parse --show-toplevel` through the spawn seam), not the cwd. project foundations - fs-writer resolves where a write really lands: a symbolic link (or a linked parent directory) whose target leaves the project root, or the home directory for user configs, is `refused`; so is a dangling link, and a secret is never written through any link. A home config linked into ~/dotfiles still works. - An unchanged file with a looser mode than requested (0644 .env.local) is tightened to 0600 and the change reports `permissions`. - env-file: an unterminated quote reads as one unquoted line (as dotenv does), `KEY: value` is an assignment, and a line that names the variable inside text the parser cannot read yields `needs-review` instead of a second BREW_API_KEY. No value is ever returned. - pnpm-workspace.yaml comments at column 0 no longer end the package list; package.json, brew.json and lockfile JSON are read past a BOM. - BREW_CLI_INVOKED_AS accepts only npx, npx -y, pnpm dlx, bunx, yarn dlx, npm exec or npm create followed by one package spec (or brew-cli). Tests never spawn host programs: run-cli's default spawn fails as if the program were missing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 +-- README.md | 28 ++- docs/commands/README.md | 6 +- skills/brew-cli/SKILL.md | 15 +- src/commands/mcp/install.ts | 327 ++++++++++++++++++++++------ src/generated/skills.ts | 2 +- src/lib/invocation.ts | 61 ++++-- src/lib/mcp-clients.ts | 91 ++++++-- src/lib/project/env-file.ts | 49 ++++- src/lib/project/fs-writer.ts | 263 +++++++++++++++++++--- src/lib/project/monorepo.ts | 80 ++++++- src/lib/project/package-manager.ts | 4 +- src/lib/project/state.ts | 4 +- tests/commands/mcp-install.test.ts | 259 ++++++++++++++++++++-- tests/helpers/run-cli.ts | 15 +- tests/lib/invocation.test.ts | 39 ++++ tests/lib/project/env-file.test.ts | 45 ++++ tests/lib/project/fs-writer.test.ts | 132 ++++++++++- tests/lib/project/monorepo.test.ts | 74 ++++++- 19 files changed, 1324 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f7a43..361d7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,27 +72,34 @@ canvas commands: the published npm `0.16.0` artifact (`gitHead` - **`brew-cli mcp install`** adds the Brew MCP server to Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, - Windsurf (`~/.codeium/windsurf/mcp_config.json`) and Codex - (`~/.codex/config.toml`). It merges a `brew` entry and keeps other - servers; a config with comments comes back `manual` with a snippet. - `--client`, `--scope project|user`, `--auth oauth|env` (env adds a header - the client fills from `BREW_API_KEY`, never a literal key), `--print`, - `--dry-run`. JSON output: - `{ mcpUrl, dryRun, clients: [{ client, path?, action, deeplink?, snippet?, note? }] }`. + Devin Desktop, formerly Windsurf (`~/.config/devin/mcp_config.json`), + and Codex (`~/.codex/config.toml`). It merges a `brew` entry and keeps + other servers; an existing `brew` entry with other settings, a config + with comments, or a symbolic link whose target leaves the project comes + back `manual` with a snippet. `--client`, `--scope project|user`, + `--auth oauth|env` (env adds a header the client fills from + `BREW_API_KEY`, never a literal key), `--print`, `--dry-run`. JSON + output: + `{ mcpUrl, dryRun, clients: [{ client, path?, action, deeplink?, snippet?, note?, nextStep? }] }` + (`nextStep` is `codex mcp login brew` after a Codex OAuth entry). Cursor and VS Code results carry one-click install deeplinks; the Cursor link is byte-identical to the one brew.new serves. - `BREW_CLI_INVOKED_AS`: a launcher package (`npx -y brew.new@`) sets it, and `confirmCommand`, `retryCommand` and every "run …" hint - start with it instead of `brew-cli`. Anything but plain shell words falls - back to `brew-cli`. + start with it instead of `brew-cli`. Only `npx`, `npx -y`, `pnpm dlx`, + `bunx`, `yarn dlx`, `npm exec` or `npm create` followed by one package + spec is accepted; anything else falls back to `brew-cli`. - Project foundations for the coming `init` and `migrate` commands, dependency-free under `src/lib/project/`: package-manager detection and command builders (npm, pnpm, yarn classic and berry with PnP, bun, deno, workspace filters), monorepo resolution (pnpm workspaces, npm/yarn/bun - workspaces, Turborepo, Nx), a create-only file writer (managed-file - hashes, marked-block upserts, atomic writes, dry-run plans), a dotenv - editor that never replaces or prints a value and refuses - browser-exposed names for secrets, `brew.json` state (schema shipped in + workspaces, Turborepo, Nx, falling back to the git root when there is + no package.json), a create-only file writer (managed-file hashes, + marked-block upserts, atomic writes, dry-run plans; never writes a + secret through a symbolic link or follows one out of the project, and + tightens a secret file's mode to 0600), a dotenv editor that never + replaces, duplicates or prints a value and refuses browser-exposed + names for secrets, `brew.json` state (schema shipped in `schemas/brew.json`), gitignore checks, a line diff, and vendor HTTP that honours `--timeout` and Ctrl-C and never echoes a credential. - The agent skills in `skills/` are embedded in the build diff --git a/README.md b/README.md index 54f54b6..3d84f33 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,28 @@ origin of `--api-url` + `/api/mcp`) to each client's config, merging a | VS Code | `vscode` | `.vscode/mcp.json` | the profile's `mcp.json` | | Claude Code | `claude-code` | `.mcp.json` | prints `claude mcp add --scope user …` | | Gemini CLI | `gemini` | `.gemini/settings.json` | `~/.gemini/settings.json` | -| Windsurf | `windsurf` | (user only) | `~/.codeium/windsurf/mcp_config.json` | +| Devin Desktop (Windsurf) | `windsurf` | (user only) | `~/.config/devin/mcp_config.json` (`$XDG_CONFIG_HOME/devin/`, Windows `%APPDATA%\devin\`) | | Codex | `codex` | (user only) | `~/.codex/config.toml` (`$CODEX_HOME`) | Without `--client` it configures the clients it detects (`.cursor/`, -`.vscode/`, `.claude/` or `CLAUDECODE=1`, `.gemini/`); a detected Windsurf -or Codex is reported with its snippet but only written when you name it, -since its config lives in your home directory. `--auth oauth` (default) -writes the URL only and the client signs in on first use; `--auth env` -adds an `Authorization` header that the client fills from `BREW_API_KEY` -at connect time. A literal key is never written. A config file with -comments is left alone and reported `manual` with the snippet to paste. -`--print` prints the snippets and writes nothing. Each result is +`.vscode/`, `.claude/` or `CLAUDECODE=1`, `.gemini/`); a detected Devin +Desktop (`.windsurf/` or `.devin/`) or Codex is reported with its snippet +but only written when you name it, since its config lives in your home +directory. `--auth oauth` (default) writes the URL only and the client +signs in on first use; Codex signs in only through its own command, so its +result carries `nextStep: "codex mcp login brew"`. `--auth env` adds an +`Authorization` header that the client fills from `BREW_API_KEY` at +connect time: `${env:BREW_API_KEY}` for Cursor, VS Code and Devin Desktop, +`${BREW_API_KEY}` for Claude Code and Gemini CLI, `bearer_token_env_var` +for Codex. The client reads the variable from the environment it was +started with, so an editor opened from the Dock or Start menu may not see +a variable your shell exports. A literal key is never written. + +An existing `brew` entry is never replaced: the same settings report +`unchanged`, other settings come back `manual` with the snippet to paste. +So does a config with comments, and a config that is a symbolic link +whose target leaves the project (or your home directory, for user +files). `--print` prints the snippets and writes nothing. Each result is `created`, `updated`, `unchanged`, `manual` or `printed`; Cursor and VS Code results carry a one-click install `deeplink`. diff --git a/docs/commands/README.md b/docs/commands/README.md index 73b88d5..d339324 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -170,7 +170,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli doctor` | read | — | Trust check: auth, API reachability, and installed-CLI vs live-API drift | | `brew-cli docs` | read | — | Documentation pointers; --agent prints the command manifest | | `brew-cli docs api` | read | `GET /v1/help` | Fetch the live machine-readable API catalog (GET /v1/help) | -| `brew-cli mcp install` | write | — | Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex) | +| `brew-cli mcp install` | write | — | Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Devin Desktop/Windsurf, Codex) | | `brew-cli api` | destructive | — | Raw authenticated request against the Brew public API | ## Details @@ -2688,10 +2688,10 @@ brew-cli docs api --json ### brew-cli mcp install -Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex) +Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Devin Desktop/Windsurf, Codex) - Class: write -- `--client ` — Clients to configure: cursor, vscode, claude-code, gemini, windsurf, codex, all (default: the ones this project uses; Windsurf and Codex only when named) +- `--client ` — Clients to configure: cursor, vscode, claude-code, gemini, windsurf, codex, all (default: the ones this project uses; windsurf (Devin Desktop) and codex only when named) - `--scope ` — project (files in this repo) or user (your home directory) - `--auth ` — oauth (URL only; sign in from the client) or env (a header the client fills from BREW_API_KEY; never a literal key) - `--print` — Print each config snippet instead of writing files diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index ed1219d..e97780f 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -39,11 +39,16 @@ brew-cli mcp install --client vscode --print # print the snippet, write noth It merges a `brew` entry into each client's config and keeps every other server. `--auth oauth` (default) writes the URL only; `--auth env` adds an -`Authorization` header the client expands from `BREW_API_KEY`, never a -literal key. A config with comments comes back `manual` with the snippet to -paste. Windsurf and Codex live in your home directory: only written when -named with `--client`. Results: `created | updated | unchanged | manual | -printed`, plus one-click `deeplink`s for Cursor and VS Code. +`Authorization` header the client expands from `BREW_API_KEY` in its own +environment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a +literal key. An existing `brew` entry with other settings, a config with +comments, or a symbolic link leaving the project comes back `manual` with +the snippet to paste. Devin Desktop (`--client windsurf`, +`~/.config/devin/mcp_config.json`) and Codex live in your home directory: +only written when named with `--client`. After a Codex OAuth entry, run +the result's `nextStep` (`codex mcp login brew`). Results: `created | +updated | unchanged | manual | printed`, plus one-click `deeplink`s for +Cursor and VS Code. ## The contract you can rely on diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 34a9666..3d4acb0 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -4,6 +4,7 @@ import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { flagString } from '../../lib/input' import { + codexLoginCommand, codexTomlBlock, configDocument, configTarget, @@ -21,9 +22,18 @@ import { serverEntry, USER_SCOPE_ONLY, } from '../../lib/mcp-clients' -import { ProjectWriter } from '../../lib/project/fs-writer' -import { resolveProjectRoot } from '../../lib/project/monorepo' -import { resolveCwd, resolveHomeDir } from '../../lib/project/process' +import { + type PlannedChange, + ProjectWriter, + stripBom, + UTF8_BOM, +} from '../../lib/project/fs-writer' +import { resolveProjectRootWithGit } from '../../lib/project/monorepo' +import { + resolveCwd, + resolveHomeDir, + resolveSpawn, +} from '../../lib/project/process' export type McpInstallAction = | 'created' @@ -43,6 +53,8 @@ export type McpInstallResult = { readonly snippet?: string /** Why a result is `manual`. */ readonly note?: string + /** A command to run once the entry is in place (Codex OAuth sign-in). */ + readonly nextStep?: string } const CLIENT_LIST = `${MCP_CLIENT_IDS.join(', ')}, all` @@ -50,13 +62,13 @@ const CLIENT_LIST = `${MCP_CLIENT_IDS.join(', ')}, all` export const mcpInstallCommand = defineCommand({ path: ['mcp', 'install'], summary: - "Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Windsurf, Codex)", + "Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Devin Desktop/Windsurf, Codex)", sdkMethod: null, commandClass: 'write', flags: [ { flag: '--client ', - summary: `Clients to configure: ${CLIENT_LIST} (default: the ones this project uses; Windsurf and Codex only when named)`, + summary: `Clients to configure: ${CLIENT_LIST} (default: the ones this project uses; windsurf (Devin Desktop) and codex only when named)`, }, { flag: '--scope ', @@ -90,7 +102,11 @@ export const mcpInstallCommand = defineCommand({ const auth = parseAuth(flags.auth) const isPrint = flags.print === true const isDryRun = flags.dryRun === true - const resolved = resolveProjectRoot(resolveCwd(ctx.io)) + const resolved = await resolveProjectRootWithGit( + resolveCwd(ctx.io), + resolveSpawn(ctx.io), + ctx.signal + ) const projectRoot = resolved.monorepo?.root ?? resolved.root const env = ctx.io.env const home = resolveHomeDir(env) @@ -113,7 +129,7 @@ export const mcpInstallCommand = defineCommand({ clients = MCP_CLIENT_IDS } else { throw new CliUsageError( - `No MCP client detected in ${projectRoot} (.cursor/, .vscode/, .claude/, .gemini/, .windsurf/, .codex/). Pass --client with any of: ${CLIENT_LIST}.` + `No MCP client detected in ${projectRoot} (.cursor/, .vscode/, .claude/, .gemini/, .windsurf/ or .devin/, .codex/). Pass --client with any of: ${CLIENT_LIST}.` ) } @@ -151,7 +167,13 @@ function installOne(input: { }): McpInstallResult { const { client, auth, mcpUrl, writer } = input const deeplink = deeplinkFor(client, mcpUrl, auth) - const withLink = deeplink === undefined ? {} : { deeplink } + const withLink = { + ...(deeplink === undefined ? {} : { deeplink }), + // Codex signs in to an OAuth server only through its own command. + ...(client === 'codex' && auth === 'oauth' + ? { nextStep: codexLoginCommand() } + : {}), + } const target = configTarget(client, input.scope, { projectRoot: input.projectRoot, home: input.home, @@ -189,8 +211,8 @@ function installOne(input: { } const outcome = client === 'codex' - ? installCodex(writer, target.path, mcpUrl, auth) - : installJson(writer, target.path, client, mcpUrl, auth) + ? installCodex(writer, target.path, target.within, mcpUrl, auth) + : installJson(writer, target.path, target.within, client, mcpUrl, auth) return { client, path, @@ -206,20 +228,29 @@ type Outcome = { readonly note?: string } -function fromWriter(action: string): Outcome { - switch (action) { +function fromWriter(change: PlannedChange): Outcome { + switch (change.action) { case 'created': case 'updated': case 'unchanged': - return { action } + return { action: change.action } + case 'refused': + return { + action: 'manual', + note: `Not written: ${change.note ?? 'the path is not safe to write'}. Add this entry by hand.`, + } default: return { action: 'manual', note: 'The file could not be updated safely.' } } } +/** A different `brew` entry is the user's: never replaced silently. */ +const CUSTOMISED_NOTE = `A ${MCP_SERVER_NAME} entry with other settings is already there and was left as it is. Edit it to match this snippet if you want these settings.` + function installJson( writer: ProjectWriter, path: string, + within: string, client: Exclude, url: string, auth: McpAuthMode @@ -228,17 +259,20 @@ function installJson( if (existing === null || existing.trim() === '') { const text = `${JSON.stringify(configDocument(client, url, auth), null, 2)}\n` return fromWriter( - writer.writeFile(path, text, { overwrite: existing !== null }).action + writer.writeFile(path, text, { overwrite: existing !== null, within }) ) } + // A byte order mark (some Windows editors write one) is kept, not parsed. + const bom = existing.startsWith(UTF8_BOM) ? UTF8_BOM : '' + const body = stripBom(existing) let root: unknown try { - root = JSON.parse(existing) + root = JSON.parse(body) } catch { return { action: 'manual', note: - parseJsonc(existing) === undefined + parseJsonc(body) === undefined ? 'Not valid JSON: fix the file, then add this entry.' : 'Has comments, which a rewrite would drop: add this entry by hand.', } @@ -258,41 +292,20 @@ function installJson( } } const entry = serverEntry(client, url, auth) - if (container !== undefined && deepEqual(container[MCP_SERVER_NAME], entry)) { - return { action: 'unchanged' } + if (container !== undefined && Object.hasOwn(container, MCP_SERVER_NAME)) { + return deepEqual(container[MCP_SERVER_NAME], entry) + ? { action: 'unchanged' } + : { action: 'manual', note: CUSTOMISED_NOTE } } // Every other server and setting stays, in its original order. const merged = { ...root, [key]: { ...(container ?? {}), [MCP_SERVER_NAME]: entry }, } - const text = `${JSON.stringify(merged, null, detectIndent(existing))}\n` - return fromWriter(writer.writeFile(path, text, { overwrite: true }).action) + const text = `${bom}${JSON.stringify(merged, null, detectIndent(body))}\n` + return fromWriter(writer.writeFile(path, text, { overwrite: true, within })) } -/** A TOML key segment, bare or quoted: `mcp_servers`, `"mcp_servers"`. */ -const seg = (name: string): string => `(?:${name}|"${name}"|'${name}')` -const CODEX_TABLE = new RegExp( - `^\\s*\\[\\s*${seg('mcp_servers')}\\s*\\.\\s*${seg('brew')}\\s*\\]\\s*(?:#.*)?$` -) -/** - * Any other way a TOML file can already define `mcp_servers.brew`: a - * dotted key (`mcp_servers.brew.url = …`), a sub-table - * (`[mcp_servers.brew.env]`), or a `brew = {…}` inline table. Appending a - * second definition would make the whole config fail to load. - */ -const CODEX_OTHER_BREW = new RegExp( - `${seg('mcp_servers')}\\s*\\.\\s*${seg('brew')}\\s*[.=\\]]` -) -const CODEX_SERVERS_TABLE = new RegExp( - `^\\s*\\[\\s*${seg('mcp_servers')}\\s*\\]`, - 'm' -) -const BREW_KEY_LINE = new RegExp(`^\\s*${seg('brew')}\\s*=`, 'm') -const CODEX_INLINE_SERVERS = new RegExp( - `${seg('mcp_servers')}\\s*=\\s*\\{[^\\n]*${seg('brew')}\\s*=` -) - /** * TOML with comments removed and string VALUES blanked (newlines kept, so * line numbers still match): what is left is keys, headers and @@ -334,7 +347,9 @@ export function tomlStructure(text: string): string { } end = Math.min(text.length, end + delimiter.length) const literal = text.slice(index, end) - const isKey = !isValue || /^[ \t]*[=.\]]/.test(text.slice(end, end + 64)) + // A value string is followed by `,` `]` `}` or the line's end; a key + // (an inline table's, or a dotted one) by `=` or `.`. + const isKey = !isValue || /^[ \t]*[=.]/.test(text.slice(end, end + 64)) out += isKey ? literal : literal.replace(/[^\n]/g, ' ') index = end continue @@ -352,33 +367,210 @@ export function tomlStructure(text: string): string { return out } +/** One definition in a TOML file, by its full dotted key. */ +export type TomlDefinition = { + /** `[table]`, `[[array-of-tables]]`, or a `key = value` line. */ + readonly kind: 'table' | 'array-table' | 'key' + /** The full key: the enclosing table's path, then the key's own. */ + readonly path: readonly string[] + /** The `[table]` the definition sits under (`[]` = the root). */ + readonly table: readonly string[] + /** Its 0-based line number. */ + readonly line: number +} + +/** + * The tables and keys a TOML file defines, read from `tomlStructure` text + * (comments gone, string values blanked, so brackets that are left are + * structure). `null` when a line is neither a header nor `key = value`: + * TOML this reader does not understand, so nothing may be appended. + */ +export function tomlDefinitions(structure: string): TomlDefinition[] | null { + const definitions: TomlDefinition[] = [] + let table: readonly string[] = [] + let depth = 0 + const lines = structure.split('\n') + for (const [line, text] of lines.entries()) { + if (depth > 0) { + depth += bracketDepth(text) + continue + } + const trimmed = text.trim() + if (trimmed === '') { + continue + } + if (trimmed.startsWith('[')) { + const isArray = trimmed.startsWith('[[') + const key = parseTomlKey(trimmed, isArray ? 2 : 1) + const close = isArray ? ']]' : ']' + if (key === null || trimmed.slice(key.end).trim() !== close) { + return null + } + table = key.segments + definitions.push({ + kind: isArray ? 'array-table' : 'table', + path: table, + table: [], + line, + }) + continue + } + const key = parseTomlKey(trimmed, 0) + if (key === null || trimmed[key.end] !== '=') { + return null + } + definitions.push({ + kind: 'key', + path: [...table, ...key.segments], + table, + line, + }) + depth = Math.max(0, bracketDepth(trimmed.slice(key.end + 1))) + } + return definitions +} + +/** + * Net `[`/`{` minus `]`/`}` on a structure line. Values are blanked there; + * the quoted text left is keys, skipped so a bracket in a key never counts. + */ +function bracketDepth(text: string): number { + let depth = 0 + let quote: string | null = null + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quote !== null) { + if (quote === '"' && char === '\\') { + index += 1 + } else if (char === quote) { + quote = null + } + } else if (char === '"' || char === "'") { + quote = char + } else if (char === '[' || char === '{') { + depth += 1 + } else if (char === ']' || char === '}') { + depth -= 1 + } + } + return depth +} + +/** + * A dotted TOML key starting at `start`: bare, "basic" or 'literal' + * segments joined by dots. Returns the segments and the index after the + * key (and the whitespace after it), or `null` when there is none. + */ +function parseTomlKey( + text: string, + start: number +): { readonly segments: string[]; readonly end: number } | null { + const segments: string[] = [] + let index = start + const skipSpace = (): void => { + while (text[index] === ' ' || text[index] === '\t') { + index += 1 + } + } + for (;;) { + skipSpace() + const char = text[index] + if (char === '"' || char === "'") { + let end = index + 1 + let value = '' + while (end < text.length && text[end] !== char) { + if (char === '"' && text[end] === '\\') { + value += text[end + 1] ?? '' + end += 2 + continue + } + value += text[end] + end += 1 + } + if (end >= text.length) { + return null + } + segments.push(value) + index = end + 1 + } else { + const bare = /^[A-Za-z0-9_-]+/.exec(text.slice(index)) + if (bare === null) { + return null + } + segments.push(bare[0]) + index += bare[0].length + } + skipSpace() + if (text[index] !== '.') { + return { segments, end: index } + } + index += 1 + } +} + +const startsWith = ( + path: readonly string[], + prefix: readonly string[] +): boolean => prefix.every((segment, index) => path[index] === segment) + +const sameKey = (a: readonly string[], b: readonly string[]): boolean => + a.length === b.length && startsWith(a, b) + function installCodex( writer: ProjectWriter, path: string, + within: string, url: string, auth: McpAuthMode ): Outcome { const block = codexTomlBlock(url, auth) const existing = writer.read(path) if (existing === null) { - return fromWriter(writer.writeFile(path, block).action) + return fromWriter(writer.writeFile(path, block, { within })) } - const lines = existing.replaceAll('\r\n', '\n').split('\n') + const normalized = existing.replaceAll('\r\n', '\n') + const lines = normalized.split('\n') // Comments and string values hidden: only real TOML structure counts. - const structure = tomlStructure(existing.replaceAll('\r\n', '\n')) - const structureLines = structure.split('\n') - const start = structureLines.findIndex((line) => CODEX_TABLE.test(line)) - if (start !== -1) { - const body: string[] = [] - for (const [offset, line] of lines.slice(start + 1).entries()) { - if (/^\s*\[/.test(structureLines[start + 1 + offset] ?? '')) { - break - } - const trimmed = line.replace(/\s+#.*$/, '').trim() - if (trimmed !== '') { - body.push(trimmed.replace(/\s*=\s*/, ' = ')) - } + const definitions = tomlDefinitions(tomlStructure(normalized)) + if (definitions === null) { + return { + action: 'manual', + note: 'The file has TOML this command cannot check safely: add this table by hand.', } + } + const servers = ['mcp_servers'] + const brew = [...servers, MCP_SERVER_NAME] + const brewTables = definitions.filter( + (definition) => + definition.kind === 'table' && sameKey(definition.path, brew) + ) + const blocksAppend = definitions.some( + (definition) => + // `mcp_servers = { … }` (an inline table cannot be extended), + // `[[mcp_servers]]` (not a map of servers). + (definition.kind === 'key' && sameKey(definition.path, servers)) || + (definition.kind === 'array-table' && + startsWith(definition.path, servers)) || + // brew defined any way but its own `[mcp_servers.brew]` table: a + // `brew = {…}` or `brew.url = …` key, a dotted key elsewhere, a + // `[mcp_servers.brew.env]` sub-table. + (startsWith(definition.path, brew) && + !(definition.kind === 'table' && sameKey(definition.path, brew)) && + !(definition.kind === 'key' && sameKey(definition.table, brew))) + ) + const [table] = brewTables + if (table !== undefined && !blocksAppend && brewTables.length === 1) { + const body = definitions + .filter( + (definition) => + definition.kind === 'key' && sameKey(definition.table, brew) + ) + .map((definition) => + (lines[definition.line] ?? '') + .replace(/\s+#.*$/, '') + .trim() + .replace(/\s*=\s*/, ' = ') + ) const wanted = block .trim() .split('\n') @@ -391,20 +583,15 @@ function installCodex( ? { action: 'unchanged' } : { action: 'manual', - note: `A [mcp_servers.${MCP_SERVER_NAME}] table with other settings exists: edit it by hand.`, + note: `A [mcp_servers.${MCP_SERVER_NAME}] table with other settings exists and was left as it is. Edit it to match this snippet if you want these settings.`, } } - if ( - CODEX_OTHER_BREW.test(structure) || - CODEX_INLINE_SERVERS.test(structure) || - (CODEX_SERVERS_TABLE.test(structure) && BREW_KEY_LINE.test(structure)) - ) { + if (table !== undefined || blocksAppend) { return { action: 'manual', - note: `${MCP_SERVER_NAME} is already defined in a form this command does not edit: check it by hand.`, + note: `mcp_servers or ${MCP_SERVER_NAME} is already defined in a form this command does not edit: check it by hand.`, } } - const normalized = existing.replaceAll('\r\n', '\n') const separator = normalized === '' || normalized.endsWith('\n\n') ? '' @@ -414,7 +601,8 @@ function installCodex( return fromWriter( writer.writeFile(path, `${normalized}${separator}${block}`, { overwrite: true, - }).action + within, + }) ) } @@ -569,6 +757,9 @@ function renderHuman( if (result.deeplink !== undefined) { lines.push(` One-click install: ${result.deeplink}`) } + if (result.nextStep !== undefined) { + lines.push(` Then sign in: ${result.nextStep}`) + } } return lines.join('\n') } diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 0f17b76..938436f 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY`, never a\nliteral key. A config with comments comes back `manual` with the snippet to\npaste. Windsurf and Codex live in your home directory: only written when\nnamed with `--client`. Results: `created | updated | unchanged | manual |\nprinted`, plus one-click `deeplink`s for Cursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a\n launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a\n launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/invocation.ts b/src/lib/invocation.ts index 0986234..ffd0994 100644 --- a/src/lib/invocation.ts +++ b/src/lib/invocation.ts @@ -4,12 +4,30 @@ import { CLI_NAME } from '../version' export const DEFAULT_INVOCATION_PREFIX = CLI_NAME /** - * Shell words a launcher may name: a package spec with its version - * (`brew.new@0.18.0`, `@brew.new/cli@0.18.0`), runner flags (`-y`), runner - * names (`npx`, `pnpm dlx`, `bunx`). Each word must stay one shell word, so - * the prefix can lead a copy-pasted command without quoting. + * Package runners a launcher may name, as shell words. Each is followed by + * exactly one package spec; npm's own runners may end with `--`, which npm + * needs to pass the CLI's flags through. */ -const SAFE_WORD = /^[A-Za-z0-9@%+=:,./_-]+$/ +const LAUNCHERS: ReadonlyArray<{ + readonly words: readonly string[] + readonly allowsSeparator: boolean +}> = [ + { words: ['npx'], allowsSeparator: false }, + { words: ['npx', '-y'], allowsSeparator: false }, + { words: ['npx', '--yes'], allowsSeparator: false }, + { words: ['pnpm', 'dlx'], allowsSeparator: false }, + { words: ['bunx'], allowsSeparator: false }, + { words: ['yarn', 'dlx'], allowsSeparator: false }, + { words: ['npm', 'exec'], allowsSeparator: true }, + { words: ['npm', 'create'], allowsSeparator: true }, +] + +/** + * An npm package spec: `name` or `@scope/name`, with an optional + * `@version` / `@tag` (`brew.new@0.18.0`, `@brew.new/cli@latest`). + */ +const PACKAGE_SPEC = + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[A-Za-z0-9][A-Za-z0-9._+-]*)?$/ const MAX_PREFIX_LENGTH = 200 /** @@ -19,10 +37,10 @@ const MAX_PREFIX_LENGTH = 200 * `BREW_CLI_INVOKED_AS` so those lines re-run through the same launcher * instead of naming a `brew-cli` binary that is not on the PATH. * - * Anything that is not a short run of plain shell words (whitespace inside a - * word, quotes, `;`, `$`, a newline) falls back to `brew-cli`: the prefix is - * printed into commands agents execute, so it must never smuggle shell - * syntax. + * Only a known package runner followed by one package spec (or `brew-cli` + * itself) is accepted; anything else falls back to `brew-cli`. The prefix + * is printed into commands agents execute, so it must never smuggle shell + * syntax or a different program. */ export function getInvocationPrefix( env: Readonly> @@ -33,12 +51,27 @@ export function getInvocationPrefix( } const words = raw.trim().split(/ +/) const prefix = words.join(' ') - if ( - prefix === '' || - prefix.length > MAX_PREFIX_LENGTH || - !words.every((word) => SAFE_WORD.test(word)) - ) { + if (prefix.length > MAX_PREFIX_LENGTH || !isKnownLauncher(words)) { return DEFAULT_INVOCATION_PREFIX } return prefix } + +function isKnownLauncher(words: readonly string[]): boolean { + if (words.length === 1 && words[0] === CLI_NAME) { + return true + } + return LAUNCHERS.some(({ words: runner, allowsSeparator }) => { + if (!runner.every((word, index) => words[index] === word)) { + return false + } + const rest = words.slice(runner.length) + const [spec, separator, ...extra] = rest + return ( + spec !== undefined && + PACKAGE_SPEC.test(spec) && + extra.length === 0 && + (separator === undefined || (allowsSeparator && separator === '--')) + ) + }) +} diff --git a/src/lib/mcp-clients.ts b/src/lib/mcp-clients.ts index 6be3c27..92a51ad 100644 --- a/src/lib/mcp-clients.ts +++ b/src/lib/mcp-clients.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' /** * Where each MCP client keeps its server list, and the Brew entry it needs. @@ -33,7 +33,7 @@ export const MCP_CLIENT_LABELS: Readonly> = { vscode: 'VS Code', 'claude-code': 'Claude Code', gemini: 'Gemini CLI', - windsurf: 'Windsurf', + windsurf: 'Devin Desktop (Windsurf)', codex: 'Codex', } @@ -52,7 +52,13 @@ export function mcpUrlFromApiUrl(apiUrl: string): string { return `${new URL(apiUrl).origin}/api/mcp` } -/** `Authorization` in the client's own env-interpolation syntax. */ +/** + * `Authorization` in the client's own env-interpolation syntax. Cursor, + * VS Code and Devin Desktop expand `${env:NAME}` (VS Code resolves every + * predefined variable, `env:` included, across the whole server entry); + * Claude Code and Gemini CLI expand `${NAME}`. Each reads the environment + * the client itself was started with. + */ function authHeader(client: McpClientId): string { switch (client) { case 'cursor': @@ -80,15 +86,16 @@ export function serverEntry( const headers = auth === 'env' ? { headers: { Authorization: authHeader(client) } } : {} switch (client) { + // Devin Desktop (`windsurf`): both its agents read `url` from the same + // file (legacy Cascade also takes `serverUrl`; Devin Local only `url`). case 'cursor': + case 'windsurf': return { url, ...headers } case 'vscode': case 'claude-code': return { type: 'http', url, ...headers } case 'gemini': return { httpUrl: url, ...headers } - case 'windsurf': - return { serverUrl: url, ...headers } } } @@ -117,13 +124,22 @@ export function codexTomlBlock(url: string, auth: McpAuthMode): string { return `${lines.join('\n')}\n` } -/** Claude Code's own command for a user-scope server (it owns ~/.claude.json). */ +/** + * Claude Code's own command for a user-scope server (it owns ~/.claude.json). + * `--header` is variadic (`-H, --header `), so it comes after the + * name and URL: before them it would swallow both. + */ export function claudeMcpAddCommand(url: string, auth: McpAuthMode): string { const header = auth === 'env' ? ` --header 'Authorization: ${authHeader('claude-code')}'` : '' - return `claude mcp add --transport http --scope user${header} ${MCP_SERVER_NAME} ${url}` + return `claude mcp add --transport http --scope user ${MCP_SERVER_NAME} ${url}${header}` +} + +/** What Codex needs after an OAuth entry is written: its own login. */ +export function codexLoginCommand(): string { + return `codex mcp login ${MCP_SERVER_NAME}` } /** @@ -194,19 +210,53 @@ export function vscodeUserDir( ) } +/** + * Devin Desktop's (formerly Windsurf) config directory, per + * docs.devin.ai/desktop/cascade/mcp: `$XDG_CONFIG_HOME/devin` or + * `~/.config/devin` on macOS and Linux, `%APPDATA%\devin` on Windows. Its + * legacy Cascade agent and its default Devin Local agent read the same + * `mcp_config.json` there. + */ +export function devinConfigDir( + home: string, + env: Readonly>, + platform: NodeJS.Platform = process.platform +): string { + if (platform === 'win32') { + const appData = env.APPDATA + return join( + appData !== undefined && appData !== '' + ? appData + : join(home, 'AppData', 'Roaming'), + 'devin' + ) + } + const xdg = env.XDG_CONFIG_HOME + return join( + xdg !== undefined && xdg !== '' ? xdg : join(home, '.config'), + 'devin' + ) +} + export type ConfigTarget = | { readonly kind: 'file' /** Absolute path. */ readonly path: string readonly scope: McpScope + /** + * The directory the write must land in once symbolic links are + * resolved: the project root, or the home directory (the client's own + * config directory when it lives elsewhere, e.g. `$CODEX_HOME`). + */ + readonly within: string } /** No file this CLI should edit: print the client's own command. */ | { readonly kind: 'command'; readonly command: string } /** - * Where a client's Brew entry goes. Windsurf and Codex only have a user - * config, whatever `scope` says. + * Where a client's Brew entry goes. Devin Desktop (Windsurf) and Codex + * only have a user config, whatever `scope` says. */ export function configTarget( client: McpClientId, @@ -225,15 +275,19 @@ export function configTarget( kind: 'file', path, scope: 'user', + within: isInsideDir(path, home) ? home : dirname(path), }) const project = (path: string): ConfigTarget => ({ kind: 'file', path: join(projectRoot, path), scope: 'project', + within: projectRoot, }) switch (client) { case 'windsurf': - return user(join(home, '.codeium', 'windsurf', 'mcp_config.json')) + return user( + join(devinConfigDir(home, env, input.platform), 'mcp_config.json') + ) case 'codex': { const codexHome = env.CODEX_HOME return user( @@ -267,10 +321,21 @@ export function configTarget( } } +function isInsideDir(path: string, dir: string): boolean { + const rel = relative(resolve(dir), resolve(path)) + return ( + rel !== '' && + rel !== '..' && + !rel.startsWith(`..${sep}`) && + !isAbsolute(rel) + ) +} + /** * The clients this project (or the agent running us) already uses: their - * config directories in the project root, Claude Code's `CLAUDECODE=1`, - * Codex's `CODEX_*` environment. + * config directories in the project root (`.windsurf/` or Devin's + * `.devin/` for Devin Desktop), Claude Code's `CLAUDECODE=1`, Codex's + * `CODEX_*` environment. */ export function detectMcpClients( projectRoot: string, @@ -290,7 +355,7 @@ export function detectMcpClients( if (has('.gemini')) { detected.push('gemini') } - if (has('.windsurf')) { + if (has('.windsurf') || has('.devin')) { detected.push('windsurf') } if ( diff --git a/src/lib/project/env-file.ts b/src/lib/project/env-file.ts index 3929f30..041846a 100644 --- a/src/lib/project/env-file.ts +++ b/src/lib/project/env-file.ts @@ -33,6 +33,12 @@ export type EnsureVarAction = | 'filled' /** The variable already had a value: left exactly as it was. */ | 'kept-existing' + /** + * A line names the variable, but the file does not parse cleanly around + * it (a quote another line closes): left exactly as it was, never + * appended a second time. A human should look at the file. + */ + | 'needs-review' export type EnsureVarResult = { readonly name: string @@ -68,9 +74,21 @@ export class EnvSecretExposureError extends Error { } } -const VAR_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/ +/** `KEY=value`, `export KEY=value`, and dotenv's YAML-ish `KEY: value`. */ +const VAR_LINE = + /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)(?:\s*=\s*|:\s+)(.*)$/ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ +/** + * Whether any physical line of `text` assigns `name` (`=` or `:`, with or + * without `export`), however the lines around it parse. `name` is a + * validated variable name (`VALID_NAME`): no regex metacharacters. + */ +function mentionsAssignment(text: string, name: string): boolean { + const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*[=:]`) + return text.split('\n').some((line) => pattern.test(line)) +} + export function parseEnv(text: string): EnvFile { const eol = text.includes('\r\n') ? '\r\n' : '\n' const trailingNewline = text.endsWith('\n') @@ -97,17 +115,23 @@ export function parseEnv(text: string): EnvFile { const rawLines = [raw] let body = rest.slice(1) let close = findClosingQuote(body, quote) - while (close === -1 && index + 1 < source.length) { - index += 1 - const next = source[index] ?? '' + let end = index + while (close === -1 && end + 1 < source.length) { + end += 1 + const next = source[end] ?? '' rawLines.push(next) body = `${body}\n${next.replace(/\r$/, '')}` close = findClosingQuote(body, quote) } - const inner = close === -1 ? body : body.slice(0, close) - const value = quote === '"' ? unescapeDouble(inner) : inner - lines.push({ kind: 'var', key, value, raw: rawLines.join('\n') }) - continue + if (close !== -1) { + index = end + const inner = body.slice(0, close) + const value = quote === '"' ? unescapeDouble(inner) : inner + lines.push({ kind: 'var', key, value, raw: rawLines.join('\n') }) + continue + } + // Never closed: dotenv reads this one line as an unquoted value, and + // the lines after it as their own assignments. } // An unquoted value ends at a ` #` comment, or is one (`KEY= # todo`). const comment = rest.search(/(?:^|\s)#/) @@ -189,8 +213,10 @@ export function formatEnvAssignment(name: string, value: string): string { /** * Makes sure `name` has a value, without ever replacing one: absent → * appended (`added`), present but empty → set in place (`filled`), - * present → untouched (`kept-existing`). Secrets (the default) refuse a - * browser-exposed name before anything changes. + * present → untouched (`kept-existing`), named on a line the parser could + * not read as an assignment → untouched (`needs-review`), never a second + * copy. Secrets (the default) refuse a browser-exposed name before + * anything changes. */ export function ensureVar( file: EnvFile, @@ -231,6 +257,9 @@ export function ensureVar( }) return { file: { ...file, lines }, result: { name, action: 'filled' } } } + if (mentionsAssignment(serializeEnv(file), name)) { + return { file, result: { name, action: 'needs-review' } } + } const cr = file.eol === '\r\n' ? '\r' : '' const previous = file.lines.map((line, index) => // A file without a final newline gets one before the new line. diff --git a/src/lib/project/fs-writer.ts b/src/lib/project/fs-writer.ts index 8714237..b76c131 100644 --- a/src/lib/project/fs-writer.ts +++ b/src/lib/project/fs-writer.ts @@ -4,14 +4,21 @@ import { lstatSync, mkdirSync, readFileSync, - readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' import { unifiedDiff } from './diff' /** @@ -40,6 +47,11 @@ export type ChangeAction = | 'kept-user-version' /** The file does not exist and was not created (inspection, opt-out). */ | 'missing' + /** + * Not written: the path is a symbolic link a secret must not go through, + * a dangling link, or one whose target leaves the allowed directory. + */ + | 'refused' export type PlannedChange = { /** The path as the caller named it (relative to the root, or absolute). */ @@ -49,8 +61,13 @@ export type PlannedChange = { readonly sha256?: string /** Unified diff of a change that writes (dry-run previews and reports). */ readonly diff?: string - /** Why a file was left alone. */ + /** Why a file was left alone, or what else changed about it. */ readonly note?: string + /** + * An unchanged file whose permissions were (or would be) tightened to + * the requested mode: octal strings, e.g. `{ from: '0644', to: '0600' }`. + */ + readonly permissions?: { readonly from: string; readonly to: string } } export type BlockMarkers = { readonly start: string; readonly end: string } @@ -97,39 +114,140 @@ export function readTextFile(path: string): string | null { } } +/** A UTF-8 byte order mark some Windows editors put at the start of a file. */ +export const UTF8_BOM = '\uFEFF' + +/** `text` without a leading byte order mark (`JSON.parse` rejects one). */ +export function stripBom(text: string): string { + return text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text +} + +/** A write the CLI refuses: where it would land is not the CLI's to write. */ +export class UnsafeWriteTargetError extends Error { + readonly path: string + readonly reason: string + + constructor(path: string, reason: string) { + super(`Refusing to write ${path}: ${reason}.`) + this.name = 'UnsafeWriteTargetError' + this.path = path + this.reason = reason + } +} + +export type WriteTargetOptions = { + /** + * The directory the write must land in once every symbolic link (the + * file's and its parent directories') is resolved. Absent: no check. + */ + readonly within?: string + /** A secret (`.env.local`): never written through a symbolic link. */ + readonly sensitive?: boolean +} + +function isInside(path: string, dir: string): boolean { + const rel = relative(dir, path) + return ( + rel === '' || + (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + ) +} + /** - * The file a write to `path` must land in: a symbolic link (a config kept - * in a dotfiles repo) is followed to its target, so the rename replaces the - * target's content and the link survives. A dangling link resolves to the - * path it names. + * `path` with the symbolic links of its existing part resolved (the part + * that does not exist yet is appended as named). `null` when a component + * is a dangling link: where the write would land cannot be known. */ -export function resolveWriteTarget(path: string): string { - try { - if (!lstatSync(path).isSymbolicLink()) { - return path +function realLocation(path: string): string | null { + const missing: string[] = [] + let current = resolve(path) + for (;;) { + try { + return join(realpathSync(current), ...missing.reverse()) + } catch { + try { + lstatSync(current) + return null + } catch { + // Does not exist: resolve its parent. + } + const parent = dirname(current) + if (parent === current) { + return resolve(path) + } + missing.push(basename(current)) + current = parent } - } catch { - return path } +} + +/** + * The file a write to `path` must land in. A symbolic link (a config kept + * in a dotfiles directory) is followed to its target, so the rename + * replaces the target's content and the link survives. Throws + * `UnsafeWriteTargetError` instead of writing when the path is a link and + * the content is a secret, when the link dangles, or when the real + * location (links in parent directories included) leaves `within`. + */ +export function resolveWriteTarget( + path: string, + options: WriteTargetOptions = {} +): string { + let isLink = false try { - return realpathSync(path) + isLink = lstatSync(path).isSymbolicLink() } catch { - return resolve(dirname(path), readlinkSync(path)) + isLink = false + } + if (isLink && options.sensitive === true) { + throw new UnsafeWriteTargetError( + path, + 'it is a symbolic link, and a secret is never written through one' + ) + } + let target = path + if (isLink) { + try { + target = realpathSync(path) + } catch { + throw new UnsafeWriteTargetError( + path, + 'it is a symbolic link to a file that does not exist' + ) + } + } + if (options.within !== undefined) { + const real = realLocation(target) + if (real === null) { + throw new UnsafeWriteTargetError( + path, + 'a directory on its path is a symbolic link to nothing' + ) + } + const base = realLocation(options.within) ?? resolve(options.within) + if (!isInside(real, base)) { + throw new UnsafeWriteTargetError( + path, + `it resolves to ${real}, outside ${options.within}` + ) + } } + return target } /** * Writes `content` to `path` atomically: a temp file beside it, then a * rename, so a crash or Ctrl-C never leaves a half-written config. `mode` * applies to the new file (0o600 for secrets); without it an existing - * file keeps its mode. A symbolic link is written through, never replaced. + * file keeps its mode. A symbolic link is written through, never replaced, + * within the limits of `resolveWriteTarget`. */ export function writeFileAtomic( linkOrPath: string, content: string, - options: { readonly mode?: number } = {} + options: WriteTargetOptions & { readonly mode?: number } = {} ): void { - const path = resolveWriteTarget(linkOrPath) + const path = resolveWriteTarget(linkOrPath, options) mkdirSync(dirname(path), { recursive: true }) let mode = options.mode if (mode === undefined) { @@ -156,7 +274,10 @@ export function writeFileAtomic( } export type WriteFileOptions = { - /** File mode for a created (or rewritten) file, e.g. 0o600. */ + /** + * File mode for a created (or rewritten) file, e.g. 0o600. An existing + * file that stays unchanged loses any permission bit outside it. + */ readonly mode?: number /** * sha256 of what the CLI last wrote here (from `brew.json#files`). When @@ -166,9 +287,16 @@ export type WriteFileOptions = { readonly managedSha256?: string /** * The content is secret (`.env.local`): the change carries no diff, so a - * dry-run plan or report can never print a value. + * dry-run plan or report can never print a value, and the file is never + * written through a symbolic link (`refused`). */ readonly sensitive?: boolean + /** + * The directory the write must land in, symbolic links resolved + * (default: the project root). A user-scope config passes the home + * directory, so a link into `~/dotfiles` still works. + */ + readonly within?: string /** * Replace whatever is there. Only for files whose new content the caller * derived FROM the current content (a JSON merge), so nothing is lost. @@ -179,8 +307,10 @@ export type WriteFileOptions = { export type UpsertBlockOptions = { readonly markers?: BlockMarkers readonly mode?: number - /** As `WriteFileOptions.sensitive`: no diff in the change. */ + /** As `WriteFileOptions.sensitive`: no diff, never through a link. */ readonly sensitive?: boolean + /** As `WriteFileOptions.within`. */ + readonly within?: string /** Create the file when it does not exist (default true). */ readonly createIfMissing?: boolean } @@ -231,6 +361,10 @@ export class ProjectWriter { options: WriteFileOptions = {} ): PlannedChange { const absolute = this.resolvePath(path) + const guard = this.#guard(absolute, path, options) + if (guard !== null) { + return this.#record(guard) + } const existing = readTextFile(absolute) const sha256 = sha256Hex(content) if (existing === null) { @@ -242,10 +376,16 @@ export class ProjectWriter { before: '', mode: options.mode, sensitive: options.sensitive === true, + within: this.#within(options), }) } if (normalizeEol(existing) === normalizeEol(content)) { - return this.#record({ path, action: 'unchanged', sha256 }) + return this.#record({ + path, + action: 'unchanged', + sha256, + ...this.#tighten(absolute, options.mode), + }) } const isOurs = options.overwrite === true || @@ -270,6 +410,7 @@ export class ProjectWriter { before: existing, mode: options.mode, sensitive: options.sensitive === true, + within: this.#within(options), }) } @@ -286,8 +427,13 @@ export class ProjectWriter { ): PlannedChange { const markers = options.markers ?? HTML_BLOCK_MARKERS const absolute = this.resolvePath(path) + const guard = this.#guard(absolute, path, options) + if (guard !== null) { + return this.#record(guard) + } const existing = readTextFile(absolute) const sensitive = options.sensitive === true + const within = this.#within(options) if (existing === null) { if (options.createIfMissing === false) { return this.#record({ path, action: 'missing' }) @@ -304,6 +450,7 @@ export class ProjectWriter { before: '', mode: options.mode, sensitive, + within, }) } const next = applyBlock(existing, content, markers) @@ -320,6 +467,7 @@ export class ProjectWriter { path, action: 'unchanged', sha256: sha256Hex(existing), + ...this.#tighten(absolute, options.mode), }) } const crlf = usesCrlf(existing) @@ -331,6 +479,7 @@ export class ProjectWriter { before: existing, mode: options.mode, sensitive, + within, }) } @@ -355,6 +504,61 @@ export class ProjectWriter { } } + #within(options: { readonly within?: string }): string { + return options.within ?? this.root + } + + /** A `refused` change when the write must not happen, else `null`. */ + #guard( + absolute: string, + path: string, + options: { readonly within?: string; readonly sensitive?: boolean } + ): PlannedChange | null { + try { + resolveWriteTarget(absolute, { + within: this.#within(options), + sensitive: options.sensitive === true, + }) + return null + } catch (error) { + if (!(error instanceof UnsafeWriteTargetError)) { + throw error + } + return { path, action: 'refused', note: error.reason } + } + } + + /** + * Drops the permission bits of an existing file that `mode` does not + * grant (0644 → 0600 for a secret). Never loosens; dry runs only report. + */ + #tighten( + absolute: string, + mode: number | undefined + ): Pick { + if (mode === undefined) { + return {} + } + let current: number + try { + current = statSync(absolute).mode & 0o777 + } catch { + return {} + } + const next = current & mode + if (next === current) { + return {} + } + if (!this.dryRun) { + chmodSync(absolute, next) + } + const permissions = { from: octalMode(current), to: octalMode(next) } + return { + permissions, + note: `permissions tightened from ${permissions.from} to ${permissions.to}`, + } + } + #record(change: PlannedChange): PlannedChange { this.#changes.push(change) return change @@ -370,14 +574,15 @@ export class ProjectWriter { readonly before: string readonly mode: number | undefined readonly sensitive: boolean + readonly within: string } ): PlannedChange { if (!this.dryRun) { - writeFileAtomic( - absolute, - input.content, - input.mode === undefined ? {} : { mode: input.mode } - ) + writeFileAtomic(absolute, input.content, { + within: input.within, + sensitive: input.sensitive, + ...(input.mode === undefined ? {} : { mode: input.mode }), + }) } return this.#record({ path: input.path, @@ -394,6 +599,10 @@ export class ProjectWriter { } } +function octalMode(mode: number): string { + return `0${mode.toString(8).padStart(3, '0')}` +} + /** * `text` (LF-normalized) with its marked block set to `content`: replaced * in place, or appended after a blank line when the text has none. diff --git a/src/lib/project/monorepo.ts b/src/lib/project/monorepo.ts index 9ba18d4..f83c1d6 100644 --- a/src/lib/project/monorepo.ts +++ b/src/lib/project/monorepo.ts @@ -1,6 +1,7 @@ -import { existsSync, readdirSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve, sep } from 'node:path' -import { readTextFile } from './fs-writer' +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { SpawnFn } from '../types' +import { readTextFile, stripBom } from './fs-writer' /** * Where a project command should act: the app the user is standing in, @@ -80,7 +81,7 @@ export function readJsonObject(path: string): Record | null { return null } try { - const parsed: unknown = JSON.parse(text) + const parsed: unknown = JSON.parse(stripBom(text)) return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) @@ -119,7 +120,11 @@ export function detectFramework( export function parsePnpmWorkspaceYaml(text: string): string[] { const patterns: string[] = [] let inPackages = false - for (const raw of text.replaceAll('\r\n', '\n').split('\n')) { + for (const raw of stripBom(text).replaceAll('\r\n', '\n').split('\n')) { + if (/^\s*#/.test(raw)) { + // A whole-line comment, at any column, never ends the list. + continue + } const line = raw.replace(/\s+#.*$/, '') if (/^packages\s*:/.test(line)) { inPackages = true @@ -447,3 +452,68 @@ export function resolveProjectRoot(cwd: string): ResolvedProject { } return { root: resolve(cwd), monorepo: null, candidates: [] } } + +/** + * The repository `cwd` is in, from `git rev-parse --show-toplevel` (through + * the injectable spawn seam). `null` when git is missing, `cwd` is not in a + * repository, or git names a directory that does not contain `cwd`. + */ +export async function findGitRoot( + cwd: string, + spawn: SpawnFn, + signal?: AbortSignal +): Promise { + let stdout: string + try { + const result = await spawn('git', ['rev-parse', '--show-toplevel'], { + cwd, + ...(signal === undefined ? {} : { signal }), + }) + if (result.code !== 0) { + return null + } + stdout = result.stdout + } catch { + signal?.throwIfAborted() + return null + } + const top = stdout.trim() + if (top === '' || top.includes('\n')) { + return null + } + const root = resolve(top) + const rel = relative(realPathOr(root), realPathOr(resolve(cwd))) + return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) + ? null + : root +} + +function realPathOr(path: string): string { + try { + return realpathSync(path) + } catch { + return path + } +} + +/** + * `resolveProjectRoot`, except that with no package.json up to the + * repository (and no monorepo) the project is the git repository root + * (`git rev-parse --show-toplevel`), not the cwd: running from `docs/` + * still acts on the repo. + */ +export async function resolveProjectRootWithGit( + cwd: string, + spawn: SpawnFn, + signal?: AbortSignal +): Promise { + const resolved = resolveProjectRoot(cwd) + if ( + resolved.monorepo !== null || + readJsonObject(join(resolved.root, 'package.json')) !== null + ) { + return resolved + } + const gitRoot = await findGitRoot(cwd, spawn, signal) + return gitRoot === null ? resolved : { ...resolved, root: gitRoot } +} diff --git a/src/lib/project/package-manager.ts b/src/lib/project/package-manager.ts index b776dd4..facd036 100644 --- a/src/lib/project/package-manager.ts +++ b/src/lib/project/package-manager.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { shellQuote } from '../confirm' -import { readTextFile } from './fs-writer' +import { readTextFile, stripBom } from './fs-writer' /** * Which package manager a project uses, and the exact command lines to add @@ -61,7 +61,7 @@ function readPackageJson(dir: string): Record | null { return null } try { - const parsed: unknown = JSON.parse(text) + const parsed: unknown = JSON.parse(stripBom(text)) return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null diff --git a/src/lib/project/state.ts b/src/lib/project/state.ts index fb00a9f..1511d29 100644 --- a/src/lib/project/state.ts +++ b/src/lib/project/state.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { readTextFile } from './fs-writer' +import { readTextFile, stripBom } from './fs-writer' /** * `brew.json`: the project file `init` writes and commits, so re-running it @@ -214,7 +214,7 @@ export function validateBrewState(value: unknown): ParsedBrewState { export function parseBrewState(text: string): ParsedBrewState { let value: unknown try { - value = JSON.parse(text) + value = JSON.parse(stripBom(text)) } catch (error) { return { ok: false, diff --git a/tests/commands/mcp-install.test.ts b/tests/commands/mcp-install.test.ts index 100bb47..09cd98b 100644 --- a/tests/commands/mcp-install.test.ts +++ b/tests/commands/mcp-install.test.ts @@ -1,13 +1,23 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildCursorInstallUrl, buildVsCodeInstallUrl, + claudeMcpAddCommand, configTarget, + devinConfigDir, mcpUrlFromApiUrl, vscodeUserDir, } from '../../src/lib/mcp-clients' +import type { SpawnFn } from '../../src/lib/types' import { copyFixture, tempProject } from '../helpers/fixtures' import { runCli } from '../helpers/run-cli' @@ -18,6 +28,7 @@ type Result = { deeplink?: string snippet?: string note?: string + nextStep?: string } type Output = { mcpUrl: string; dryRun: boolean; clients: Result[] } @@ -48,11 +59,16 @@ function setup(dirs: readonly string[] = []): { async function install( argv: readonly string[], - options: { root: string; env: Record } + options: { + root: string + env: Record + spawn?: SpawnFn + } ): Promise<{ code: number; output: Output; stderr: string }> { const result = await runCli(['mcp', 'install', ...argv, '--json'], { cwd: options.root, env: options.env, + ...(options.spawn === undefined ? {} : { spawn: options.spawn }), }) return { code: result.code, @@ -140,27 +156,101 @@ describe('mcp install', () => { }) }) - it('replaces a stale brew entry in place', async () => { + it('never replaces a customised brew entry: unchanged when equal, else manual', async () => { const { root, env } = setup() - writeFileSync( - join(root, '.mcp.json'), - JSON.stringify({ + const custom = `${JSON.stringify( + { mcpServers: { - brew: { type: 'http', url: 'https://old.example/api/mcp' }, + brew: { + type: 'http', + url: 'https://brew.new/api/mcp', + headers: { Authorization: PLAIN_ENV_HEADER }, + }, other: { command: 'x' }, }, + }, + null, + 2 + )}\n` + writeFileSync(join(root, '.mcp.json'), custom) + // Default --auth oauth would drop the header: the entry is the user's. + const oauth = await install(['--client', 'claude-code'], { root, env }) + expect(oauth.output.clients[0]).toMatchObject({ + action: 'manual', + note: expect.stringMatching(/other settings/), + }) + expect(JSON.parse(oauth.output.clients[0]?.snippet ?? '')).toEqual({ + mcpServers: { brew: { type: 'http', url: 'https://brew.new/api/mcp' } }, + }) + expect(readFileSync(join(root, '.mcp.json'), 'utf8')).toBe(custom) + // The same settings again: nothing to do. + const same = await install(['--client', 'claude-code', '--auth', 'env'], { + root, + env, + }) + expect(same.output.clients[0]?.action).toBe('unchanged') + // A stale URL is still the user's entry, never silently rewritten. + writeFileSync( + join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { brew: { type: 'http', url: 'https://old.example/mcp' } }, }) ) - const { output } = await install(['--client', 'claude-code'], { + const stale = await install(['--client', 'claude-code'], { root, env }) + expect(stale.output.clients[0]?.action).toBe('manual') + expect(readFileSync(join(root, '.mcp.json'), 'utf8')).toContain( + 'https://old.example/mcp' + ) + }) + + it('reads a config that starts with a byte order mark, and keeps the mark', async () => { + const { root, env } = setup() + mkdirSync(join(root, '.cursor')) + writeFileSync( + join(root, '.cursor/mcp.json'), + `\uFEFF${JSON.stringify({ mcpServers: { other: { url: 'https://x.example/mcp' } } }, null, 2)}\n` + ) + const first = await install(['--client', 'cursor'], { root, env }) + expect(first.output.clients[0]?.action).toBe('updated') + const text = readFileSync(join(root, '.cursor/mcp.json'), 'utf8') + expect(text.startsWith('\uFEFF')).toBe(true) + expect(JSON.parse(text.slice(1))).toEqual({ + mcpServers: { + other: { url: 'https://x.example/mcp' }, + brew: { url: 'https://brew.new/api/mcp' }, + }, + }) + const second = await install(['--client', 'cursor'], { root, env }) + expect(second.output.clients[0]?.action).toBe('unchanged') + }) + + it('never writes through a link that leaves the project; a home link into dotfiles still works', async () => { + const { root, home, env } = setup(['.cursor']) + const outside = tempProject('brew-cli-outside-') + writeFileSync(join(outside, 'mcp.json'), '{}\n') + symlinkSync(join(outside, 'mcp.json'), join(root, '.cursor/mcp.json')) + const project = await install(['--client', 'cursor'], { root, env }) + expect(project.output.clients[0]).toMatchObject({ + action: 'manual', + note: expect.stringMatching(/^Not written: .*outside/), + }) + expect(readFileSync(join(outside, 'mcp.json'), 'utf8')).toBe('{}\n') + + mkdirSync(join(home, 'dotfiles')) + mkdirSync(join(home, '.cursor')) + writeFileSync(join(home, 'dotfiles/mcp.json'), '{}\n') + symlinkSync(join(home, 'dotfiles/mcp.json'), join(home, '.cursor/mcp.json')) + const user = await install(['--client', 'cursor', '--scope', 'user'], { root, env, }) - expect(output.clients[0]?.action).toBe('updated') - expect( - Object.keys( - (readJson(join(root, '.mcp.json')) as { mcpServers: object }).mcpServers - ) - ).toEqual(['brew', 'other']) + expect(user.output.clients[0]?.action).toBe('updated') + expect(lstatSync(join(home, '.cursor/mcp.json')).isSymbolicLink()).toBe( + true + ) + expect(readJson(join(home, 'dotfiles/mcp.json'))).toEqual({ + mcpServers: { brew: { url: 'https://brew.new/api/mcp' } }, + }) }) it('hands back a snippet instead of rewriting a config with comments', async () => { @@ -224,14 +314,14 @@ describe('mcp install', () => { expect(existsSync(join(home, '.codex'))).toBe(false) }) - it('writes Windsurf and Codex user configs only when named', async () => { + it('writes Devin Desktop (Windsurf) and Codex user configs only when named', async () => { const { root, home, env } = setup(['.windsurf', '.codex']) const detected = await install([], { root, env }) expect(detected.output.clients.map((result) => result.action)).toEqual([ 'manual', 'manual', ]) - expect(existsSync(join(home, '.codeium'))).toBe(false) + expect(existsSync(join(home, '.config'))).toBe(false) const named = await install( ['--client', 'windsurf,codex', '--auth', 'env'], @@ -243,7 +333,7 @@ describe('mcp install', () => { expect(named.output.clients).toMatchObject([ { client: 'windsurf', - path: join(home, '.codeium/windsurf/mcp_config.json'), + path: join(home, '.config/devin/mcp_config.json'), action: 'created', }, { @@ -252,10 +342,12 @@ describe('mcp install', () => { action: 'created', }, ]) - expect(readJson(join(home, '.codeium/windsurf/mcp_config.json'))).toEqual({ + expect(existsSync(join(home, '.codeium'))).toBe(false) + // `url`: read by both of Devin Desktop's agents (Cascade and Devin Local). + expect(readJson(join(home, '.config/devin/mcp_config.json'))).toEqual({ mcpServers: { brew: { - serverUrl: 'https://brew.new/api/mcp', + url: 'https://brew.new/api/mcp', headers: { Authorization: ENV_HEADER }, }, }, @@ -263,6 +355,40 @@ describe('mcp install', () => { expect(readFileSync(join(home, '.codex/config.toml'), 'utf8')).toBe( '[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\nbearer_token_env_var = "BREW_API_KEY"\n' ) + // The env-header entry needs no sign-in step. + expect(named.output.clients[1]?.nextStep).toBeUndefined() + }) + + it('writes Devin Desktop under $XDG_CONFIG_HOME, and detects a .devin/ project', async () => { + const { root, home, env } = setup(['.devin']) + const xdg = join(home, 'xdg') + const detected = await install([], { root, env }) + expect(detected.output.clients).toMatchObject([ + { client: 'windsurf', action: 'manual' }, + ]) + const { output } = await install(['--client', 'windsurf'], { + root, + env: { ...env, XDG_CONFIG_HOME: xdg }, + }) + expect(output.clients[0]).toMatchObject({ + path: join(xdg, 'devin/mcp_config.json'), + action: 'created', + }) + }) + + it('tells Codex to sign in after an OAuth entry', async () => { + const { root, env } = setup() + const { output } = await install(['--client', 'codex'], { root, env }) + expect(output.clients[0]).toMatchObject({ + action: 'created', + nextStep: 'codex mcp login brew', + }) + const human = await runCli(['mcp', 'install', '--client', 'codex'], { + cwd: root, + env, + ttyOut: true, + }) + expect(human.stdout).toContain('Then sign in: codex mcp login brew') }) it('appends to an existing Codex config once, then reports unchanged', async () => { @@ -301,6 +427,19 @@ describe('mcp install', () => { serversTable: '[mcp_servers]\nbrew = { url = "https://brew.new/api/mcp" }\n', inline: 'mcp_servers = { brew = { url = "https://x" } }\n', + // Appending [mcp_servers.brew] to these breaks the file (each checked + // against `codex mcp list`): an inline table cannot be extended, a + // dotted `brew.url` already defines the table, `[[…]]` is not a map. + inlineOthers: + 'mcp_servers = { docs = { url = "https://docs.example" } }\n', + inlineEmpty: 'model = "x"\nmcp_servers = {}\n', + dottedUnderTable: + '[mcp_servers]\nbrew.url = "https://brew.new/api/mcp"\n', + quotedDottedUnderTable: '[mcp_servers]\n"brew" . url = "https://x"\n', + arrayOfTables: '[[mcp_servers]]\nname = "docs"\n', + bracketInValue: 'tags = ["[a"]\nmcp_servers = {}\n', + subTableBesideTable: + '[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\n\n[mcp_servers.brew.http_headers]\nX = "1"\n', } for (const [name, text] of Object.entries(configs)) { writeFileSync(join(home, '.codex/config.toml'), text) @@ -329,6 +468,17 @@ describe('mcp install', () => { text ).toContain('\n[mcp_servers.brew]\nurl = "https://brew.new/api/mcp"\n') } + // A table defined by dotted keys can take a new sub-table (codex + // accepts the result), and an inline table under another table is not + // the root mcp_servers. + for (const text of [ + 'mcp_servers.docs.url = "https://docs.example/mcp"\n', + '[profiles.work]\nmcp_servers = { docs = { url = "https://d" } }\n', + ]) { + writeFileSync(join(home, '.codex/config.toml'), text) + const { output } = await install(['--client', 'codex'], { root, env }) + expect(output.clients[0]?.action, text).toBe('updated') + } // A different server whose name starts with brew is not ours. writeFileSync( join(home, '.codex/config.toml'), @@ -393,6 +543,22 @@ describe('mcp install', () => { }) }) + it('puts the variadic --header after the name and URL in the Claude Code command', async () => { + const { root, env } = setup() + const { output } = await install( + ['--client', 'claude-code', '--scope', 'user', '--auth', 'env'], + { root, env } + ) + // `-H, --header ` swallows every word after it, so before + // the name it fails with "missing required argument 'name'". + expect(output.clients[0]?.snippet).toBe( + `claude mcp add --transport http --scope user brew https://brew.new/api/mcp --header 'Authorization: ${PLAIN_ENV_HEADER}'` + ) + expect(claudeMcpAddCommand('https://x.example/mcp', 'env')).toMatch( + / brew https:\/\/x\.example\/mcp --header '[^']+'$/ + ) + }) + it('derives the MCP URL from --api-url', async () => { const { root, env } = setup(['.cursor']) const { output } = await install( @@ -417,6 +583,32 @@ describe('mcp install', () => { expect(existsSync(join(root, '.cursor/mcp.json'))).toBe(true) }) + it('acts on the git repository root when no package.json is found', async () => { + const { root, env } = setup(['.cursor']) + mkdirSync(join(root, 'docs/guides'), { recursive: true }) + const calls: Array<{ command: string; args: readonly string[] }> = [] + const spawn: SpawnFn = async (command, args) => { + calls.push({ command, args }) + return { code: 0, stdout: `${root}\n`, stderr: '' } + } + const { code, output } = await install([], { + root: join(root, 'docs/guides'), + env, + spawn, + }) + expect(code).toBe(0) + expect(calls).toEqual([ + { command: 'git', args: ['rev-parse', '--show-toplevel'] }, + ]) + expect(output.clients[0]).toMatchObject({ + client: 'cursor', + path: '.cursor/mcp.json', + action: 'created', + }) + expect(existsSync(join(root, '.cursor/mcp.json'))).toBe(true) + expect(existsSync(join(root, 'docs/guides/.cursor'))).toBe(false) + }) + it('fails as a usage error when nothing is detected or a client is unknown', async () => { const { root, env } = setup() const none = await runCli(['mcp', 'install', '--json'], { cwd: root, env }) @@ -492,6 +684,35 @@ describe('MCP client builders', () => { kind: 'file', path: '/h/.config/Code/User/mcp.json', scope: 'user', + within: '/h', + }) + }) + + it('finds the Devin Desktop (Windsurf) config directory per platform', () => { + // docs.devin.ai/desktop/cascade/mcp: ~/.config/devin on macOS and + // Linux ($XDG_CONFIG_HOME first), %APPDATA%\devin on Windows. + expect(devinConfigDir('/h', {}, 'darwin')).toBe('/h/.config/devin') + expect(devinConfigDir('/h', {}, 'linux')).toBe('/h/.config/devin') + expect(devinConfigDir('/h', { XDG_CONFIG_HOME: '/x' }, 'darwin')).toBe( + '/x/devin' + ) + expect(devinConfigDir('/h', { APPDATA: '/appdata' }, 'win32')).toBe( + '/appdata/devin' + ) + expect( + configTarget('windsurf', 'project', { + projectRoot: '/p', + home: '/h', + env: {}, + url: 'https://brew.new/api/mcp', + auth: 'oauth', + platform: 'linux', + }) + ).toEqual({ + kind: 'file', + path: '/h/.config/devin/mcp_config.json', + scope: 'user', + within: '/h', }) }) }) diff --git a/tests/helpers/run-cli.ts b/tests/helpers/run-cli.ts index 7265214..00ea5cd 100644 --- a/tests/helpers/run-cli.ts +++ b/tests/helpers/run-cli.ts @@ -27,12 +27,23 @@ export type RunCliOptions = { readonly signal?: AbortSignal /** Working directory for project commands (`mcp install`, `init`). */ readonly cwd?: string - /** Stands in for child processes (git, package managers). */ + /** + * Stands in for child processes (git, package managers). Absent: every + * spawn fails as if the program were not installed, so a test never runs + * a host binary. + */ readonly spawn?: SpawnFn /** Records URLs a command asks to open. */ readonly openUrl?: (url: string) => Promise } +/** A spawn that finds no program, as on a host without it. */ +const noHostSpawn: SpawnFn = async (command) => { + const error: NodeJS.ErrnoException = new Error(`spawn ${command} ENOENT`) + error.code = 'ENOENT' + throw error +} + export type RunCliResult = { readonly code: number readonly stdout: string @@ -65,7 +76,7 @@ export async function runCli( readLine: async () => options.promptAnswer ?? '', ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - ...(options.spawn === undefined ? {} : { spawn: options.spawn }), + spawn: options.spawn ?? noHostSpawn, ...(options.openUrl === undefined ? {} : { openUrl: options.openUrl }), } const code = await run( diff --git a/tests/lib/invocation.test.ts b/tests/lib/invocation.test.ts index e6fed33..5f1774a 100644 --- a/tests/lib/invocation.test.ts +++ b/tests/lib/invocation.test.ts @@ -42,6 +42,45 @@ describe('getInvocationPrefix', () => { ).toBe('npx -y @brew.new/cli@0.16.0') }) + it('accepts only a known package runner followed by one package spec', () => { + for (const launcher of [ + 'npx brew.new', + 'npx -y brew.new@0.18.0', + 'npx --yes @brew.new/cli@latest', + 'pnpm dlx brew.new@0.18.0', + 'bunx brew.new@0.18.0', + 'yarn dlx @brew.new/cli@0.18.0-beta.1', + 'npm exec brew.new@0.18.0 --', + 'npm create brew@0.18.0 --', + 'npm create brew', + 'brew-cli', + ]) { + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: launcher })).toBe( + launcher + ) + } + for (const other of [ + 'sh -c brew', + 'node ./evil.js', + 'rm -rf /tmp/x', + 'curl https://x.example/install.sh', + 'npx', + 'npx -y', + 'npx brew.new extra', + 'npx -y brew.new@0.18.0 --', + 'npx ./local-dir', + 'npx https://x.example/pkg.tgz', + 'pnpm brew.new', + 'npm exec brew.new -- more', + 'brew-cli brew.new', + 'bunx -y brew.new', + ]) { + expect(getInvocationPrefix({ BREW_CLI_INVOKED_AS: other }), other).toBe( + 'brew-cli' + ) + } + }) + it('refuses anything that is not plain shell words', () => { for (const unsafe of [ 'npx brew.new; rm -rf ~', diff --git a/tests/lib/project/env-file.test.ts b/tests/lib/project/env-file.test.ts index 8819b17..43e6bf6 100644 --- a/tests/lib/project/env-file.test.ts +++ b/tests/lib/project/env-file.test.ts @@ -111,6 +111,51 @@ describe('ensureVar', () => { ) }) + it('never appends a second BREW_API_KEY next to one it cannot parse cleanly', () => { + const existing = 'brew_existing_key_0123456789' + for (const before of [ + // An unterminated quote: dotenv reads it as one unquoted line, and + // the next line as its own assignment. + `NOTE="unterminated\nBREW_API_KEY=${existing}\n`, + // dotenv's YAML-ish form. + `BREW_API_KEY: ${existing}\n`, + `export BREW_API_KEY: ${existing}\n`, + ` export BREW_API_KEY = ${existing}\n`, + ]) { + const { text, results } = ensureEnvVars(before, [ + { name: 'BREW_API_KEY', value: SECRET }, + ]) + expect(results, before).toEqual([ + { name: 'BREW_API_KEY', action: 'kept-existing' }, + ]) + expect(text, before).toBe(before) + } + // A quote another line closes swallows the assignment: still never a + // second copy, and a human is told to look. + for (const before of [ + `NOTE="starts here\nBREW_API_KEY="${existing}"\n`, + 'BREW_API_KEY:\n', + ]) { + const { text, results } = ensureEnvVars(before, [ + { name: 'BREW_API_KEY', value: SECRET }, + ]) + expect(results, before).toEqual([ + { name: 'BREW_API_KEY', action: 'needs-review' }, + ]) + expect(text, before).toBe(before) + expect(JSON.stringify(results)).not.toContain(existing) + expect(JSON.stringify(results)).not.toContain(SECRET) + } + }) + + it('reads an unterminated quote as dotenv does, one line', () => { + const file = parseEnv('A="open\nB=2\n') + expect(getEnvValue(file, 'A')).toBe('"open') + expect(getEnvValue(file, 'B')).toBe('2') + expect(serializeEnv(file)).toBe('A="open\nB=2\n') + expect(getEnvValue(parseEnv('C: three # note\n'), 'C')).toBe('three') + }) + it('refuses secrets under browser-exposed names', () => { for (const name of [ 'NEXT_PUBLIC_BREW_API_KEY', diff --git a/tests/lib/project/fs-writer.test.ts b/tests/lib/project/fs-writer.test.ts index 035670e..102744e 100644 --- a/tests/lib/project/fs-writer.test.ts +++ b/tests/lib/project/fs-writer.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -102,21 +103,134 @@ describe('ProjectWriter.writeFile (create-only)', () => { expect(readFileSync(join(root, 'merged.json'), 'utf8')).toBe('{"a":1}\n') }) - it('writes through a symbolic link instead of replacing it', () => { + it('writes through a symbolic link that stays inside its directory', () => { const root = tempProject() - const dotfiles = tempProject('brew-cli-dotfiles-') - writeFileSync(join(dotfiles, 'mcp.json'), '{"a":1}\n') - symlinkSync(join(dotfiles, 'mcp.json'), join(root, 'mcp.json')) - symlinkSync(join(dotfiles, 'missing.json'), join(root, 'dangling.json')) + mkdirSync(join(root, 'shared')) + writeFileSync(join(root, 'shared/mcp.json'), '{"a":1}\n') + symlinkSync(join(root, 'shared/mcp.json'), join(root, 'mcp.json')) const writer = new ProjectWriter({ root }) expect( writer.writeFile('mcp.json', '{"a":2}\n', { overwrite: true }).action ).toBe('updated') expect(lstatSync(join(root, 'mcp.json')).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dotfiles, 'mcp.json'), 'utf8')).toBe('{"a":2}\n') - expect(writer.writeFile('dangling.json', 'x\n').action).toBe('created') - expect(lstatSync(join(root, 'dangling.json')).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dotfiles, 'missing.json'), 'utf8')).toBe('x\n') + expect(readFileSync(join(root, 'shared/mcp.json'), 'utf8')).toBe( + '{"a":2}\n' + ) + + // A user config linked into ~/dotfiles: `within` is the home directory. + const home = tempProject('brew-cli-home-') + mkdirSync(join(home, 'dotfiles')) + mkdirSync(join(home, '.cursor')) + writeFileSync(join(home, 'dotfiles/mcp.json'), '{"a":1}\n') + symlinkSync(join(home, 'dotfiles/mcp.json'), join(home, '.cursor/mcp.json')) + expect( + writer.writeFile(join(home, '.cursor/mcp.json'), '{"a":3}\n', { + overwrite: true, + within: home, + }).action + ).toBe('updated') + expect(lstatSync(join(home, '.cursor/mcp.json')).isSymbolicLink()).toBe( + true + ) + expect(readFileSync(join(home, 'dotfiles/mcp.json'), 'utf8')).toBe( + '{"a":3}\n' + ) + }) + + it('refuses a link that leaves the root, or dangles', () => { + const root = tempProject() + const outside = tempProject('brew-cli-outside-') + writeFileSync(join(outside, 'mcp.json'), '{"a":1}\n') + symlinkSync(join(outside, 'mcp.json'), join(root, 'mcp.json')) + symlinkSync(join(root, 'public/missing.json'), join(root, 'dangling.json')) + mkdirSync(join(outside, 'cfg')) + symlinkSync(join(outside, 'cfg'), join(root, '.cursor')) + for (const dryRun of [true, false]) { + const writer = new ProjectWriter({ root, dryRun }) + expect( + writer.writeFile('mcp.json', '{"a":2}\n', { overwrite: true }) + ).toMatchObject({ + action: 'refused', + note: expect.stringMatching(/outside/), + }) + expect(writer.writeFile('dangling.json', 'x\n')).toMatchObject({ + action: 'refused', + note: expect.stringMatching(/does not exist/), + }) + // A linked parent directory counts too. + expect(writer.writeFile('.cursor/mcp.json', '{}\n').action).toBe( + 'refused' + ) + expect( + writer.upsertBlock('mcp.json', 'x', { markers: HASH_BLOCK_MARKERS }) + .action + ).toBe('refused') + } + expect(readFileSync(join(outside, 'mcp.json'), 'utf8')).toBe('{"a":1}\n') + expect(existsSync(join(root, 'public'))).toBe(false) + expect(existsSync(join(outside, 'cfg/mcp.json'))).toBe(false) + }) + + it('never writes a secret through a symbolic link, even inside the root', () => { + const root = tempProject() + // `.env.local -> public/leak.txt`: a dangling link into a served folder. + symlinkSync(join(root, 'public/leak.txt'), join(root, '.env.local')) + mkdirSync(join(root, 'config')) + writeFileSync(join(root, 'config/env'), 'A=1\n') + symlinkSync(join(root, 'config/env'), join(root, '.env')) + const writer = new ProjectWriter({ root }) + const secret = 'BREW_API_KEY=brew_secret\n' + expect( + writer.writeFile('.env.local', secret, { + sensitive: true, + mode: 0o600, + overwrite: true, + }) + ).toMatchObject({ action: 'refused', note: expect.stringMatching(/link/) }) + expect( + writer.upsertBlock('.env', 'BREW_API_KEY=brew_secret', { + markers: HASH_BLOCK_MARKERS, + sensitive: true, + }).action + ).toBe('refused') + expect(existsSync(join(root, 'public'))).toBe(false) + expect(lstatSync(join(root, '.env.local')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(root, 'config/env'), 'utf8')).toBe('A=1\n') + expect(JSON.stringify(writer.changes)).not.toContain('brew_secret') + }) + + it('tightens a looser mode on an unchanged secret file, and reports it', () => { + const root = tempProject() + const text = 'BREW_API_KEY=brew_secret\n' + writeFileSync(join(root, '.env.local'), text, { mode: 0o644 }) + chmodSync(join(root, '.env.local'), 0o644) + const options = { mode: 0o600, sensitive: true, overwrite: true } + + const plan = new ProjectWriter({ root, dryRun: true }) + expect(plan.writeFile('.env.local', text, options)).toMatchObject({ + action: 'unchanged', + permissions: { from: '0644', to: '0600' }, + }) + expect(statSync(join(root, '.env.local')).mode & 0o777).toBe(0o644) + + const writer = new ProjectWriter({ root }) + const change = writer.writeFile('.env.local', text, options) + expect(change).toMatchObject({ + action: 'unchanged', + permissions: { from: '0644', to: '0600' }, + note: 'permissions tightened from 0644 to 0600', + }) + expect(statSync(join(root, '.env.local')).mode & 0o777).toBe(0o600) + expect(JSON.stringify(change)).not.toContain('brew_secret') + // Already tight (or tighter): nothing to report, never loosened. + expect(writer.writeFile('.env.local', text, options).permissions).toBe( + undefined + ) + chmodSync(join(root, '.env.local'), 0o400) + expect(writer.writeFile('.env.local', text, options).permissions).toBe( + undefined + ) + expect(statSync(join(root, '.env.local')).mode & 0o777).toBe(0o400) }) it('never puts sensitive content in a diff', () => { diff --git a/tests/lib/project/monorepo.test.ts b/tests/lib/project/monorepo.test.ts index b17ce18..2b36443 100644 --- a/tests/lib/project/monorepo.test.ts +++ b/tests/lib/project/monorepo.test.ts @@ -4,12 +4,31 @@ import { describe, expect, it } from 'vitest' import { detectFramework, expandBraces, + findGitRoot, findMonorepo, listWorkspacePackages, parsePnpmWorkspaceYaml, + readJsonObject, resolveProjectRoot, + resolveProjectRootWithGit, } from '../../../src/lib/project/monorepo' -import { copyFixture } from '../../helpers/fixtures' +import type { SpawnFn } from '../../../src/lib/types' +import { copyFixture, tempProject } from '../../helpers/fixtures' + +/** A spawn that answers `git rev-parse --show-toplevel` with `stdout`. */ +function gitSpawn( + stdout: string, + code = 0 +): { spawn: SpawnFn; calls: Array } { + const calls: Array = [] + return { + calls, + spawn: async (command, args) => { + calls.push([command, args]) + return { code, stdout, stderr: '' } + }, + } +} describe('resolveProjectRoot', () => { it('resolves a pnpm + Turborepo workspace app from deep inside it', () => { @@ -83,6 +102,37 @@ describe('resolveProjectRoot', () => { }) }) + it('falls back to the git root (not the cwd) when no package.json is found', async () => { + const root = copyFixture('no-package-json') + mkdirSync(join(root, 'docs/guides'), { recursive: true }) + const { spawn, calls } = gitSpawn(`${root}\n`) + const resolved = await resolveProjectRootWithGit( + join(root, 'docs/guides'), + spawn + ) + expect(resolved).toEqual({ root, monorepo: null, candidates: [] }) + expect(calls).toEqual([['git', ['rev-parse', '--show-toplevel']]]) + // A package.json wins without asking git. + const app = copyFixture('next-app') + const quiet = gitSpawn('/elsewhere\n') + expect((await resolveProjectRootWithGit(app, quiet.spawn)).root).toBe(app) + expect(quiet.calls).toEqual([]) + }) + + it('ignores a git answer it cannot use, keeping the cwd', async () => { + const root = tempProject() + const cwd = join(root, 'sub') + mkdirSync(cwd) + const outside = tempProject('brew-cli-elsewhere-') + expect(await findGitRoot(cwd, gitSpawn(`${outside}\n`).spawn)).toBeNull() + expect(await findGitRoot(cwd, gitSpawn('', 128).spawn)).toBeNull() + const missing: SpawnFn = async () => { + throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) + } + expect(await findGitRoot(cwd, missing)).toBeNull() + expect((await resolveProjectRootWithGit(cwd, missing)).root).toBe(cwd) + }) + it('never mistakes a package-level turbo.json for the root', () => { const root = copyFixture('pnpm-turbo') expect(findMonorepo(join(root, 'apps/web'))?.root).toBe(root) @@ -102,6 +152,28 @@ describe('workspace helpers', () => { ]) }) + it('keeps reading the package list past comments at column 0', () => { + expect( + parsePnpmWorkspaceYaml( + 'packages:\n# apps first\n - apps/*\n# - old/*\n - packages/*\n # nested\n - tools/*\ncatalog:\n react: 19\n' + ) + ).toEqual(['apps/*', 'packages/*', 'tools/*']) + }) + + it('reads a package.json that starts with a byte order mark', () => { + const root = tempProject() + writeFileSync( + join(root, 'package.json'), + '\uFEFF{"name":"bom-app","dependencies":{"next":"16"}}' + ) + expect(readJsonObject(join(root, 'package.json'))).toMatchObject({ + name: 'bom-app', + }) + expect(resolveProjectRoot(root).candidates).toEqual([ + { name: 'bom-app', dir: '.', framework: 'next' }, + ]) + }) + it('expands ** globs and honours negations', () => { const root = copyFixture('pnpm-turbo') mkdirSync(join(root, 'packages/private'), { recursive: true }) From 4cacfd8062f7ed16a1ca31e880a1388b555da48c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 07:34:19 -0400 Subject: [PATCH 7/9] docs: name no unpublished launcher package The npm names `brew.new` and `create-brew` are not ours yet, so README, the brew-cli skill, `docs --agent` and the changelog show the package-runner prefix with `@brew.new/cli`, which is on npm. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++--- README.md | 5 +++-- skills/brew-cli/SKILL.md | 4 ++-- src/generated/skills.ts | 2 +- src/lib/manifest.ts | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 361d7bc..7641337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,9 +84,10 @@ canvas commands: the published npm `0.16.0` artifact (`gitHead` (`nextStep` is `codex mcp login brew` after a Codex OAuth entry). Cursor and VS Code results carry one-click install deeplinks; the Cursor link is byte-identical to the one brew.new serves. -- `BREW_CLI_INVOKED_AS`: a launcher package (`npx -y brew.new@`) - sets it, and `confirmCommand`, `retryCommand` and every "run …" hint - start with it instead of `brew-cli`. Only `npx`, `npx -y`, `pnpm dlx`, +- `BREW_CLI_INVOKED_AS`: a wrapper that runs the CLI through a package + runner (`npx -y @brew.new/cli@`) sets it, and `confirmCommand`, + `retryCommand` and every "run …" hint start with it instead of + `brew-cli`. Only `npx`, `npx -y`, `pnpm dlx`, `bunx`, `yarn dlx`, `npm exec` or `npm create` followed by one package spec is accepted; anything else falls back to `brew-cli`. - Project foundations for the coming `init` and `migrate` commands, diff --git a/README.md b/README.md index 3d84f33..8ada896 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ Designed for AI agents as first-class users: `confirmCommand`; pass `--yes` to proceed. Humans on a TTY get a y/N prompt. Test sends (`emails send --test`) skip the gate. Printed commands (`confirmCommand`, `retryCommand`, "run … login") start the - way the CLI was invoked: a launcher such as `npx -y brew.new@` - sets `BREW_CLI_INVOKED_AS`, so they re-run through it. + way the CLI was invoked: a wrapper that runs it through a package + runner sets `BREW_CLI_INVOKED_AS` (for example + `npx -y @brew.new/cli@`), so they re-run through it. - **Saved email recovery** — `409 EMAIL_RESULT_UNAVAILABLE` means the generation or edit committed. Do not repeat the write. Read the exact saved result using `error.details.emailId` and `emailVersionId`: diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index e97780f..267559b 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -69,8 +69,8 @@ Cursor and VS Code. - Destructive commands (sends, deletes, fires, cancels) never hang: they exit 4 with a `confirmCommand` to re-run once a human approves, or take `--yes`. `confirmCommand` and `retryCommand` start the way you invoked - the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a - launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit + the CLI (`brew-cli …`, or a package-runner prefix such as + `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit human instruction; `emails send --test --to you@x.com` is the safe lane. - `--input ''` (or `-` for stdin) carries full request bodies; positional ids always win over `--input`. `--all` drains pagination. diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 938436f..4fd3fcd 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or `npx -y brew.new@ …` through a\n launcher), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index ae3c1e3..0ff984f 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -22,7 +22,7 @@ export function buildManifest({ BREW_API_URL: 'API base URL', BREW_CLI_CONFIG_DIR: 'Config directory (default ~/.config/brew-cli)', BREW_CLI_INVOKED_AS: - 'Command prefix for printed re-run commands (set by launchers such as `npx -y brew.new@`; default brew-cli)', + 'Command prefix for printed re-run commands (set by a wrapper that runs the CLI through a package runner, e.g. `npx -y @brew.new/cli@`; default brew-cli)', }, exitCodes: { '0': 'success', From 211a5e6a97af70e696796d476b0cb0eb06f90d26 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 01:28:21 +0900 Subject: [PATCH 8/9] docs(changelog): #44's entries are unreleased, after main's 0.17.0 main released 0.17.0 and renamed its Unreleased section; this PR's items stay under a new Unreleased heading. Also drop the unpublished launcher name from a confirm.ts comment. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 86 ++++++++++++++++++++++++---------------------- src/lib/confirm.ts | 2 +- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7641337..94ad670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## Unreleased + +### Added + +- **`brew-cli mcp install`** adds the Brew MCP server to Cursor + (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code + (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, + Devin Desktop, formerly Windsurf (`~/.config/devin/mcp_config.json`), + and Codex (`~/.codex/config.toml`). It merges a `brew` entry and keeps + other servers; an existing `brew` entry with other settings, a config + with comments, or a symbolic link whose target leaves the project comes + back `manual` with a snippet. `--client`, `--scope project|user`, + `--auth oauth|env` (env adds a header the client fills from + `BREW_API_KEY`, never a literal key), `--print`, `--dry-run`. JSON + output: + `{ mcpUrl, dryRun, clients: [{ client, path?, action, deeplink?, snippet?, note?, nextStep? }] }` + (`nextStep` is `codex mcp login brew` after a Codex OAuth entry). + Cursor and VS Code results carry one-click install deeplinks; the Cursor + link is byte-identical to the one brew.new serves. +- `BREW_CLI_INVOKED_AS`: a wrapper that runs the CLI through a package + runner (`npx -y @brew.new/cli@`) sets it, and `confirmCommand`, + `retryCommand` and every "run …" hint start with it instead of + `brew-cli`. Only `npx`, `npx -y`, `pnpm dlx`, + `bunx`, `yarn dlx`, `npm exec` or `npm create` followed by one package + spec is accepted; anything else falls back to `brew-cli`. +- Project foundations for the coming `init` and `migrate` commands, + dependency-free under `src/lib/project/`: package-manager detection and + command builders (npm, pnpm, yarn classic and berry with PnP, bun, deno, + workspace filters), monorepo resolution (pnpm workspaces, npm/yarn/bun + workspaces, Turborepo, Nx, falling back to the git root when there is + no package.json), a create-only file writer (managed-file hashes, + marked-block upserts, atomic writes, dry-run plans; never writes a + secret through a symbolic link or follows one out of the project, and + tightens a secret file's mode to 0600), a dotenv editor that never + replaces, duplicates or prints a value and refuses browser-exposed + names for secrets, `brew.json` state (schema shipped in + `schemas/brew.json`), gitignore checks, a line diff, and vendor HTTP + that honours `--timeout` and Ctrl-C and never echoes a credential. +- The agent skills in `skills/` are embedded in the build + (`src/generated/skills.ts`, `bun run generate:skills`; CI runs + `generate:skills:check`). +- npm keywords for MCP, transactional and auth email, Resend and React + Email, so the package shows up where people look for them. + ## 0.18.0 These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds @@ -67,48 +111,6 @@ canvas commands: the published npm `0.16.0` artifact (`gitHead` on the host backend. Revocation requires confirmation, and create/refresh requests never retry credentials that cannot be replayed. -### Added - -- **`brew-cli mcp install`** adds the Brew MCP server to Cursor - (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code - (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, - Devin Desktop, formerly Windsurf (`~/.config/devin/mcp_config.json`), - and Codex (`~/.codex/config.toml`). It merges a `brew` entry and keeps - other servers; an existing `brew` entry with other settings, a config - with comments, or a symbolic link whose target leaves the project comes - back `manual` with a snippet. `--client`, `--scope project|user`, - `--auth oauth|env` (env adds a header the client fills from - `BREW_API_KEY`, never a literal key), `--print`, `--dry-run`. JSON - output: - `{ mcpUrl, dryRun, clients: [{ client, path?, action, deeplink?, snippet?, note?, nextStep? }] }` - (`nextStep` is `codex mcp login brew` after a Codex OAuth entry). - Cursor and VS Code results carry one-click install deeplinks; the Cursor - link is byte-identical to the one brew.new serves. -- `BREW_CLI_INVOKED_AS`: a wrapper that runs the CLI through a package - runner (`npx -y @brew.new/cli@`) sets it, and `confirmCommand`, - `retryCommand` and every "run …" hint start with it instead of - `brew-cli`. Only `npx`, `npx -y`, `pnpm dlx`, - `bunx`, `yarn dlx`, `npm exec` or `npm create` followed by one package - spec is accepted; anything else falls back to `brew-cli`. -- Project foundations for the coming `init` and `migrate` commands, - dependency-free under `src/lib/project/`: package-manager detection and - command builders (npm, pnpm, yarn classic and berry with PnP, bun, deno, - workspace filters), monorepo resolution (pnpm workspaces, npm/yarn/bun - workspaces, Turborepo, Nx, falling back to the git root when there is - no package.json), a create-only file writer (managed-file hashes, - marked-block upserts, atomic writes, dry-run plans; never writes a - secret through a symbolic link or follows one out of the project, and - tightens a secret file's mode to 0600), a dotenv editor that never - replaces, duplicates or prints a value and refuses browser-exposed - names for secrets, `brew.json` state (schema shipped in - `schemas/brew.json`), gitignore checks, a line diff, and vendor HTTP - that honours `--timeout` and Ctrl-C and never echoes a credential. -- The agent skills in `skills/` are embedded in the build - (`src/generated/skills.ts`, `bun run generate:skills`; CI runs - `generate:skills:check`). -- npm keywords for MCP, transactional and auth email, Resend and React - Email, so the package shows up where people look for them. - ### Changed - Canvas session create, refresh, and revoke use the SDK methods. JSON output, diff --git a/src/lib/confirm.ts b/src/lib/confirm.ts index 54ac37e..b01d53b 100644 --- a/src/lib/confirm.ts +++ b/src/lib/confirm.ts @@ -65,7 +65,7 @@ export async function enforceConfirmation( /** * `prefix` is how the CLI was invoked (`getInvocationPrefix`): `brew-cli`, - * or a launcher such as `npx -y brew.new@0.18.0`. + * or a package runner such as `npx -y @brew.new/cli@`. */ export function buildConfirmCommand( rawArgv: readonly string[], From 0dbb0fcf3e16bc20ade45ee7a09e88cabc2eb582 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 25 Oct 2026 22:25:35 +0900 Subject: [PATCH 9/9] docs(commands): regenerate the reference on main 0.18.0 (150 commands) Co-Authored-By: Claude Opus 5.5 --- docs/commands/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/README.md b/docs/commands/README.md index d339324..572c5ba 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -149 commands. Classes: read (always safe), write +150 commands. Classes: read (always safe), write (mutating, retry-safe), destructive (irreversible — the confirmation protocol applies: interactive y/N on a TTY, exit 4 + JSON envelope with a `confirmCommand` otherwise, `--yes` to proceed).