diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d07fb3..4593fd933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A new `deprioritize` setting in `codegraph.json` keeps the paths you name from outranking your product code in search and `codegraph_explore` answers, without removing anything from the index. It takes gitignore-style patterns just like `exclude`, but is ranking-only: helper-script trees, generated output, or optional add-on directories whose generic symbol names (`usage`, `run`, `status`) would otherwise crowd out the code that actually answers a query stay fully indexed and findable — and a query that genuinely targets such a tree still returns it. Thanks @maxmilian. (#982) +- `codegraph install --init` wires up your agents and builds the current project's index in one command, and `codegraph init --yes` runs without any prompts — so a fresh container or CI job can bootstrap CodeGraph with a single non-interactive line (`codegraph install --yes --init`). The installer still never indexes anything unless you ask for it with the flag, and the usual safety refusal for a home directory or filesystem root applies. (#1578) + ### Fixes - Indexing no longer hangs on a Swift Vapor project containing a call with a long argument list. A single `.get(...)`-style call with many labeled arguments and no `use:` handler — the shape generated request builders produce — could stall `codegraph index`, `codegraph sync`, and the MCP server indefinitely. Route detection now handles such files in milliseconds, and every previously-recognized route shape still parses exactly as before. Thanks @maxmilian. (#1544) (Swift) diff --git a/README.md b/README.md index 4f89a40e6..7f1ed7316 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,7 @@ The installer **wires up your agents only — it does not index your code.** Aft ```bash codegraph install --yes # auto-detect agents, install global +codegraph install --yes --init # same, then build the current project's index (one-shot bootstrap) codegraph install --target=cursor,claude --yes # explicit target list codegraph install --target=auto --location=local # detected agents, project-local codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere @@ -400,6 +401,7 @@ codegraph install --print-config copilot-vscode # same, for Copilot in VS C | `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt | | `--location` | `global`, `local` | prompt | | `--yes` | (boolean) | prompt every step | +| `--init` | (boolean) run `codegraph init` in the current directory after wiring agents | — | | `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on | | `--print-config ` | dump snippet for one agent and exit | — | @@ -414,7 +416,7 @@ cd your-project codegraph init ``` -Builds the per-project knowledge graph index, which then auto-syncs on every file change. A single global `codegraph install` works in every project you open — no need to re-run the installer per project. +Builds the per-project knowledge graph index, which then auto-syncs on every file change. A single global `codegraph install` works in every project you open — no need to re-run the installer per project. Add `--yes` to skip every prompt (scripts / CI / container bootstraps). That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. diff --git a/__tests__/cli-install-init.test.ts b/__tests__/cli-install-init.test.ts new file mode 100644 index 000000000..f647e884b --- /dev/null +++ b/__tests__/cli-install-init.test.ts @@ -0,0 +1,108 @@ +/** + * `codegraph install --init` and `codegraph init --yes` (#1578): the one-shot, + * non-interactive "wire agents + build this project's index" bootstrap a fresh + * container / CI job needs. + * + * Exercised end-to-end against the built binary so the CLI wiring (the shared + * `runInit` flow, the flag plumbing, exit codes) is what's covered. Every run + * uses `--target none`, so the installer touches no agent config on the + * machine running the suite; the only side effect is the temp project's + * `.codegraph/`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +interface RunResult { + status: number; + stdout: string; + stderr: string; +} + +/** Run the CLI with stdin closed — a prompt that blocks would hang / fail here. */ +function runCodegraph(args: string[], cwd: string): RunResult { + try { + const stdout = execFileSync(process.execPath, [BIN, ...args], { + cwd, + encoding: 'utf-8', + env: { + ...process.env, + CODEGRAPH_NO_DAEMON: '1', + CODEGRAPH_TELEMETRY: '0', + DO_NOT_TRACK: '1', + NO_COLOR: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status?: number | null; stdout?: string | Buffer; stderr?: string | Buffer }; + return { + status: e.status ?? -1, + stdout: String(e.stdout ?? ''), + stderr: String(e.stderr ?? ''), + }; + } +} + +describe('codegraph install --init / init --yes (#1578)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-install-init-')); + fs.writeFileSync( + path.join(tempDir, 'a.ts'), + `export function greet(name: string) { return hello(name); }\n` + + `export function hello(n: string) { return 'hi ' + n; }\n`, + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('install --yes --target none --init builds the current project\'s index in one command', () => { + const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], tempDir); + expect(r.status, r.stdout + r.stderr).toBe(0); + // The installer ran (and had nothing to wire) … + expect(r.stdout).toContain('No agent targets selected'); + // … and the init ran afterwards, in cwd. + expect(r.stdout).toContain(`Initialized in ${fs.realpathSync(tempDir)}`); + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + }); + + it('install --init on an already-initialized project reports that and still exits 0', () => { + expect(runCodegraph(['init', '--yes'], tempDir).status).toBe(0); + const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], tempDir); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('Already initialized'); + }); + + it('install --init refuses an unsafe root (filesystem root) with exit code 1, like init does', () => { + // `/` (or the drive root on Windows) is the canonical unsafe root: the + // refusal fires before anything is created, so nothing is written there. + const root = path.parse(process.cwd()).root; + const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], root); + expect(r.status).toBe(1); + expect(r.stdout).toContain('Refusing to initialize'); + expect(fs.existsSync(path.join(root, '.codegraph'))).toBe(false); + }); + + it('init --yes runs non-interactively with stdin closed and builds the index', () => { + const r = runCodegraph(['init', '--yes'], tempDir); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('Initialized in'); + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + }); + + it('documents the new flags in --help', () => { + expect(runCodegraph(['init', '--help'], tempDir).stdout).toMatch(/-y, --yes\b/); + expect(runCodegraph(['install', '--help'], tempDir).stdout).toMatch(/-i, --init\b/); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e4038200d..dfad645ae 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -607,94 +607,111 @@ async function recordIndexTelemetry( // ============================================================================= /** - * codegraph init [path] + * The `init` flow — shared by `codegraph init` and `codegraph install --init` + * (#1578): refuse an unsafe root, create `.codegraph/`, build the initial + * index under supervision, then the post-index offers. `yes` makes every + * offer non-interactive (defaults only), so a container / CI bootstrap never + * blocks on a prompt. An unsafe root sets `process.exitCode = 1` and returns + * (no `--force` is implied by any caller); an index failure exits 1. */ -program - .command('init [path]') - .description('Initialize CodeGraph in a project directory and build the initial index') - .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') - .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') - .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') - .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean }) => { - const projectPath = path.resolve(pathArg || process.cwd()); - const clack = await importESM('@clack/prompts'); - - clack.intro('Initializing CodeGraph'); - - try { - // Refuse to index your home directory / a filesystem root — it pulls in - // caches, other projects, and your whole tree (a multi-GB index + watcher - // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). - const unsafe = unsafeIndexRootReason(projectPath); - if (unsafe && !options.force) { - clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); - clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); - clack.outro(''); - process.exitCode = 1; - return; - } - - if (isInitialized(projectPath)) { - clack.log.warn(`Already initialized in ${projectPath}`); - clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); - try { - const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath); - } catch { /* non-fatal */ } - clack.outro(''); - return; - } +async function runInit( + projectPath: string, + options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean }, +): Promise { + const clack = await importESM('@clack/prompts'); - const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); - const cg = await CodeGraph.init(projectPath, { index: false }); - clack.log.success(`Initialized in ${projectPath}`); + clack.intro('Initializing CodeGraph'); - // Indexing runs by default now. The legacy -i/--index flag is still - // accepted (so existing muscle memory and scripts don't break) but is a - // no-op — initializing always builds the initial index. - // Supervise the index: self-terminate if orphaned or wedged (#999). - // The DB + WAL paths let the liveness watchdog tell a slow store on - // degraded storage from a true wedge (#1231). - // A closure so we can re-run the exact same supervised, progress-rendered - // index if the user opts gitignored child repos in below (#1156). - const dbPath = getDatabasePath(projectPath); - const runIndex = async (): Promise => { - const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); - try { - if (options.verbose) { - return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); - } - process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); - const progress = createShimmerProgress(); - const r = await cg.indexAll({ onProgress: progress.onProgress }); - await progress.stop(); - return r; - } finally { - supervision.stop(); - } - }; - const result = await runIndex(); - printIndexResult(clack, result, projectPath); - await recordIndexTelemetry(cg, result); - - // An empty graph at a git super-repo usually means `.gitignore` excludes - // the child repos that hold the code — surface them and offer to opt in - // rather than leaving the user with a silent 0-node "Done". (#1156) - if (result.nodesCreated === 0) { - await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true }); - } + try { + // Refuse to index your home directory / a filesystem root — it pulls in + // caches, other projects, and your whole tree (a multi-GB index + watcher + // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). + const unsafe = unsafeIndexRootReason(projectPath); + if (unsafe && !options.force) { + clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); + clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); + clack.outro(''); + process.exitCode = 1; + return; + } + if (isInitialized(projectPath)) { + clack.log.warn(`Already initialized in ${projectPath}`); + clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); try { const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath); + await offerWatchFallback(clack, projectPath, { yes: options.yes }); } catch { /* non-fatal */ } + clack.outro(''); + return; + } - clack.outro('Done'); - cg.destroy(); - } catch (err) { - clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); + const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); + const cg = await CodeGraph.init(projectPath, { index: false }); + clack.log.success(`Initialized in ${projectPath}`); + + // Indexing runs by default now. The legacy -i/--index flag is still + // accepted (so existing muscle memory and scripts don't break) but is a + // no-op — initializing always builds the initial index. + // Supervise the index: self-terminate if orphaned or wedged (#999). + // The DB + WAL paths let the liveness watchdog tell a slow store on + // degraded storage from a true wedge (#1231). + // A closure so we can re-run the exact same supervised, progress-rendered + // index if the user opts gitignored child repos in below (#1156). + const dbPath = getDatabasePath(projectPath); + const runIndex = async (): Promise => { + const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); + try { + if (options.verbose) { + return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); + } + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); + const progress = createShimmerProgress(); + const r = await cg.indexAll({ onProgress: progress.onProgress }); + await progress.stop(); + return r; + } finally { + supervision.stop(); + } + }; + const result = await runIndex(); + printIndexResult(clack, result, projectPath); + await recordIndexTelemetry(cg, result); + + // An empty graph at a git super-repo usually means `.gitignore` excludes + // the child repos that hold the code — surface them and offer to opt in + // rather than leaving the user with a silent 0-node "Done". (#1156) + // Under --yes the offer prints its one-line opt-in snippet instead of + // prompting (same as a non-TTY run). + if (result.nodesCreated === 0) { + await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: !options.yes }); } + + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath, { yes: options.yes }); + } catch { /* non-fatal */ } + + clack.outro('Done'); + cg.destroy(); + } catch (err) { + clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +/** + * codegraph init [path] + */ +program + .command('init [path]') + .description('Initialize CodeGraph in a project directory and build the initial index') + .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') + .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') + .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') + .option('-y, --yes', 'Non-interactive: skip every prompt and take the defaults (for scripts / CI / container bootstraps)') + .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean }) => { + await runInit(path.resolve(pathArg || process.cwd()), options); }); /** @@ -2268,6 +2285,7 @@ program .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') + .option('-i, --init', 'After wiring agents, also run `codegraph init` in the current directory — builds this project’s index, so install + index is one command (combine with --yes for an unattended bootstrap)') .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)') .option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)') .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`') @@ -2275,6 +2293,7 @@ program target?: string; location?: string; yes?: boolean; + init?: boolean; permissions?: boolean; printConfig?: string; refresh?: boolean; @@ -2352,6 +2371,18 @@ program error(err instanceof Error ? err.message : String(err)); process.exit(1); } + + // --init: the one-shot "wire agents AND build this project's index" + // bootstrap (#1578). The installer itself never indexes implicitly (a + // surprise index of $HOME is the thing we refuse) — an explicit flag is + // the user choosing. Runs after a successful install, including the + // `--target none` / nothing-detected case (the installer returns normally + // there), and shares every guard with `codegraph init`: an unsafe root + // is refused (exit 1, no implied --force), an already-initialized + // project just says so. `--yes` flows through so no offer prompts. + if (opts.init) { + await runInit(process.cwd(), { yes: opts.yes }); + } }); /** diff --git a/src/installer/index.ts b/src/installer/index.ts index 9a99ffe34..199a6de75 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -285,9 +285,10 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis // index a surprise directory (e.g. a shell sitting in $HOME). Same next step // regardless of global/local scope. clack.note( - location === 'local' + (location === 'local' ? 'codegraph init # build this project’s graph (one time; auto-syncs after)' - : 'cd \ncodegraph init # build a project’s graph (one time; auto-syncs after)', + : 'cd \ncodegraph init # build a project’s graph (one time; auto-syncs after)') + + '\n# (codegraph install --init does both steps in one command)', 'Next: index a project', );