Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 6 additions & 36 deletions src/commands/ai/setup.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
50 changes: 49 additions & 1 deletion src/lib/env-writer.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -78,3 +79,50 @@ export function upsertEnvFile(

return result;
}

/** True for the local env files git is expected to ignore: `.env.local` and
* `.env.<name>.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)) {
Comment thread
ssrajadh marked this conversation as resolved.
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*') ||
Comment thread
ssrajadh marked this conversation as resolved.
lines.has('.env.*') ||
lines.has('.env*.local') ||
(lines.has('.env.local') && envBasename === '.env.local')
Comment thread
ssrajadh marked this conversation as resolved.
) {
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;
}
62 changes: 61 additions & 1 deletion src/lib/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand Down Expand Up @@ -148,3 +151,60 @@ describe('reportCliUsage', () => {
expect(body.success).toBe(false);
});
});

describe('updateGitignore', () => {
let dir: string;
let cwdSpy: ReturnType<typeof vi.spyOn>;

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',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
'.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);
});
});
18 changes: 14 additions & 4 deletions src/lib/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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');
Comment thread
ssrajadh marked this conversation as resolved.
}

// Agents that the `npx skills add -a <agent>` CLI knows how to target. Kept
Expand Down