diff --git a/src/commands/ai/setup.ts b/src/commands/ai/setup.ts index 624978e..8c311e5 100644 --- a/src/commands/ai/setup.ts +++ b/src/commands/ai/setup.ts @@ -1,16 +1,19 @@ import type { Command } from 'commander'; -import { appendFileSync, existsSync, readFileSync } from 'node:fs'; -import { isAbsolute, join, relative, resolve } from 'node:path'; +import { isAbsolute, relative, resolve } from 'node:path'; import * as clack from '@clack/prompts'; import pc from 'picocolors'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; import { getOpenRouterApiKey } from '../../lib/api/ai.js'; import { getProjectConfig } from '../../lib/config.js'; import { getRootOpts, handleError, ProjectNotLinkedError } from '../../lib/errors.js'; -import { upsertEnvFile } from '../../lib/env-writer.js'; +import { ensureLocalEnvIgnored, isLocalEnvFile, upsertEnvFile } from '../../lib/env-writer.js'; import { outputInfo, outputJson, outputSuccess } from '../../lib/output.js'; import { isInteractive } from '../../lib/prompts.js'; +// Re-exported from its new home in lib so `./setup.js` stays a valid import +// path for it (setup.test.ts and any external callers are unaffected). +export { ensureLocalEnvIgnored }; + const DEFAULT_ENV_FILE = '.env.local'; const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; @@ -136,36 +139,3 @@ function displayPath(path: string): string { return rel; } -function isLocalEnvFile(envFile: string): boolean { - const normalized = envFile.replace(/\\/g, '/'); - const basename = normalized.split('/').pop() ?? normalized; - return basename === '.env.local' || /^\.env\..+\.local$/.test(basename); -} - -export function ensureLocalEnvIgnored(cwd: string, envFile: string): boolean { - if (!isLocalEnvFile(envFile)) return false; - - const envPath = resolve(cwd, envFile); - const relEnvPath = relative(cwd, envPath); - if (!relEnvPath || relEnvPath.startsWith('..') || isAbsolute(relEnvPath)) { - return false; - } - - const gitignorePath = join(cwd, '.gitignore'); - const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; - const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); - const envBasename = envFile.replace(/\\/g, '/').split('/').pop() ?? envFile; - if ( - lines.has('.env*') || - lines.has('.env.*') || - lines.has('.env*.local') || - (lines.has('.env.local') && envBasename === '.env.local') - ) { - return false; - } - - const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; - const spacer = existing.length > 0 ? '\n' : ''; - appendFileSync(gitignorePath, `${prefix}${spacer}# Local environment secrets\n.env*.local\n`); - return true; -} diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 459a174..b0c9a4d 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; export interface EnvUpdateResult { /** Variables that were appended (key was not already set). */ @@ -78,3 +79,50 @@ export function upsertEnvFile( return result; } + +/** True for the local env files git is expected to ignore: `.env.local` and + * `.env..local`. A plain `.env` is deliberately excluded — some projects + * commit a non-secret one on purpose, so we never touch it. */ +export function isLocalEnvFile(envFile: string): boolean { + const normalized = envFile.replace(/\\/g, '/'); + const basename = normalized.split('/').pop() ?? normalized; + return basename === '.env.local' || /^\.env\..+\.local$/.test(basename); +} + +/** + * Make sure a local env file we just wrote credentials into is gitignored. + * Shared by `ai setup` (which writes OPENROUTER_API_KEY) and the skills + * install used by `link`/`create` (which seed .env.local and then point + * AGENTS.md at it), so the ignore rule lives in exactly one place. + * + * No-ops when the file is not a local env file, when it resolves outside + * `cwd` (nothing we should be editing a .gitignore for), or when an existing + * pattern already covers it. Returns `true` only if .gitignore was written. + */ +export function ensureLocalEnvIgnored(cwd: string, envFile: string): boolean { + if (!isLocalEnvFile(envFile)) return false; + + const envPath = resolve(cwd, envFile); + const relEnvPath = relative(cwd, envPath); + if (!relEnvPath || relEnvPath.startsWith('..') || isAbsolute(relEnvPath)) { + return false; + } + + const gitignorePath = join(cwd, '.gitignore'); + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; + const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); + const envBasename = envFile.replace(/\\/g, '/').split('/').pop() ?? envFile; + if ( + lines.has('.env*') || + lines.has('.env.*') || + lines.has('.env*.local') || + (lines.has('.env.local') && envBasename === '.env.local') + ) { + return false; + } + + const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + const spacer = existing.length > 0 ? '\n' : ''; + appendFileSync(gitignorePath, `${prefix}${spacer}# Local environment secrets\n.env*.local\n`); + return true; +} diff --git a/src/lib/skills.test.ts b/src/lib/skills.test.ts index 8067090..66eb093 100644 --- a/src/lib/skills.test.ts +++ b/src/lib/skills.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; -import { describeExecError, reportCliUsage, PROVIDER_SKILLS } from './skills.js'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describeExecError, reportCliUsage, updateGitignore, PROVIDER_SKILLS } from './skills.js'; test('apify provider installs apify/agent-skills', () => { expect(PROVIDER_SKILLS.apify).toEqual({ repo: 'apify/agent-skills', label: 'Apify skills' }); @@ -148,3 +151,60 @@ describe('reportCliUsage', () => { expect(body.success).toBe(false); }); }); + +describe('updateGitignore', () => { + let dir: string; + let cwdSpy: ReturnType; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-gitignore-')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(dir); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + }); + + const read = (): string => readFileSync(join(dir, '.gitignore'), 'utf-8'); + + it('ignores .env.local alongside the agent directories', () => { + updateGitignore(); + + const gitignore = read(); + expect(gitignore).toContain('.insforge'); + expect(gitignore).toContain('.env*.local'); + }); + + it('does not add an env pattern when one already covers the file', () => { + writeFileSync(join(dir, '.gitignore'), '.env*\n'); + updateGitignore(); + + expect(read().match(/^\.env.*$/gm)).toEqual(['.env*']); + }); + + it('still ignores .env.local when every agent entry is already present', () => { + writeFileSync( + join(dir, '.gitignore'), + [ + '.insforge', '.agent', '.agents', '.augment', '.claude', '.cline', + '.github/copilot*', '.kilocode', '.qoder', '.qwen', '.roo', '.trae', '.windsurf', + ].join('\n') + '\n', + ); + updateGitignore(); + + // Guards the precondition: if GITIGNORE_ENTRIES grows and the fixture above + // goes stale, the agent block gets appended and this fails, rather than the + // test quietly passing while no longer exercising the early-return path. + expect(read()).not.toContain('# InsForge & AI agent skills'); + expect(read()).toContain('.env*.local'); + }); + + it('is idempotent across repeated runs', () => { + updateGitignore(); + const afterFirst = read(); + updateGitignore(); + + expect(read()).toBe(afterFirst); + }); +}); diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 64b858d..704721a 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -5,6 +5,7 @@ import { promisify } from 'node:util'; import * as clack from '@clack/prompts'; import { writeLocalAgentsMd } from './agents-md.js'; import { getProjectConfig } from './config.js'; +import { ensureLocalEnvIgnored } from './env-writer.js'; const execAsync = promisify(exec); @@ -57,16 +58,25 @@ const GITIGNORE_ENTRIES = [ '.windsurf', ]; -function updateGitignore(): void { +/** Exported for tests; call sites go through `installSkills`. */ +export function updateGitignore(): void { const gitignorePath = join(process.cwd(), '.gitignore'); const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; const lines = new Set(existing.split('\n').map((l) => l.trim())); const missing = GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry)); - if (!missing.length) return; + if (missing.length) { + const block = `\n# InsForge & AI agent skills\n${missing.join('\n')}\n`; + appendFileSync(gitignorePath, block); + } - const block = `\n# InsForge & AI agent skills\n${missing.join('\n')}\n`; - appendFileSync(gitignorePath, block); + // `create` seeds .env.local with the project URL and anon key, and the + // AGENTS.md we write next tells app code to read its keys from there — so it + // needs the same protection `ai setup` already gives it. Shared helper rather + // than a GITIGNORE_ENTRIES entry: it knows the existing patterns that already + // cover the file, which the exact-match filter above does not. Runs even when + // every agent entry is present, since those are unrelated to the env file. + ensureLocalEnvIgnored(process.cwd(), '.env.local'); } // Agents that the `npx skills add -a ` CLI knows how to target. Kept