diff --git a/.changeset/init-git-root-promotion-warning.md b/.changeset/init-git-root-promotion-warning.md new file mode 100644 index 000000000..1e8882405 --- /dev/null +++ b/.changeset/init-git-root-promotion-warning.md @@ -0,0 +1,29 @@ +--- +"@inkeep/open-knowledge": patch +--- + +fix(open-knowledge): make `ok init` git-root promotion visible + add `--content-dir` + +When `ok init` runs in a sub-folder of a git repo, it scaffolds `.ok/` at the +git root and scopes the whole repo as content (one `.ok/` per git repo). That +promotion previously surfaced only as a single `console.log` line on stdout, +which `ok init 2>&1 | tail` / `| head` silently dropped — so a large repo could +get whole-repo-scoped without the user noticing. + +- The promotion is now disclosed as a styled warning on stderr at decision + time, and repeated in the init summary directly next to the "Found N markdown + files" preview, naming the sub-folder and how to narrow scope. +- New `ok init --content-dir ` flag scopes content to `` (resolved + relative to the current directory, written to `.ok/config.yml` as + `content.dir`). `--content-dir .` from a promoted sub-folder scopes the + project to that folder. Paths outside the project root, missing paths, or + files are rejected with a clear usage error (exit 64). Supplying the flag + suppresses the whole-repo promotion warning (the scope choice was explicit). +- New `ok init --json` flag prints a machine-readable summary to stdout + (`projectRoot`, `gitRootPromoted`, `promotedFromDir`, `contentDir`, + `contentDirRequested`, `contentDirApplied`, `contentFileCount`, `didGitInit`, + `mcpAction`, `editors[]`) so scripts and agents read the promotion + scope + signals as fields instead of scraping log text. Diagnostics stay on stderr; + stdout carries only the JSON document. + +Behavior of the promotion itself is unchanged. diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 3cd7d96df..593d50ba7 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -35,6 +35,8 @@ import { } from '../native/toml-config-engine.ts'; import { applySharingMode, + buildInitJsonSummary, + ContentDirError, classifyExistingMcpEntry, detectInstalledEditors, type EditorMcpResult, @@ -45,6 +47,7 @@ import { LAUNCH_UI_CHAIN_V1, readExistingMcpEntry, resolveMcpScope, + resolveRequestedContentDir, resolveSharingMode, runInit, writeEditorMcpConfig, @@ -1518,6 +1521,11 @@ describe('runInit — projectRoot threading', () => { expect(result.projectRoot).toBe(repo); expect(existsSync(join(repo, OK_DIR))).toBe(true); expect(existsSync(join(sub, OK_DIR))).toBe(false); + expect(result.gitRootPromoted).toBe(true); + expect(result.promotedFromDir).toBe('sub'); + const output = formatInitResult(result, result.projectRoot); + expect(output).toContain('Content scope promoted to the git repo root'); + expect(output).toContain('content.dir: sub'); }); it('returns projectRoot equal to cwd when cwd is the git root', async () => { @@ -1534,6 +1542,10 @@ describe('runInit — projectRoot threading', () => { expect(result.projectRoot).toBe(repo); expect(existsSync(join(repo, OK_DIR))).toBe(true); + expect(result.gitRootPromoted).toBe(false); + expect(result.promotedFromDir).toBeUndefined(); + const output = formatInitResult(result, result.projectRoot); + expect(output).not.toContain('Content scope promoted to the git repo root'); }); it('loadConfig succeeds when called against the resolved projectRoot', async () => { @@ -1555,6 +1567,254 @@ describe('runInit — projectRoot threading', () => { expect(rootConfig).toBeDefined(); expect(rootConfig.content.dir).toBe('.'); }); + + it('--content-dir . from a sub-folder narrows scope to that folder', async () => { + const repo = join(fakeHome, 'repo-cd-dot'); + const sub = join(repo, 'notes'); + mkdirSync(sub, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + const result = await runInit({ + cwd: sub, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + contentDir: '.', + }); + + expect(result.projectRoot).toBe(repo); + expect(result.gitRootPromoted).toBe(true); + expect(result.contentDir).toBe('notes'); + const { config } = loadConfig(result.projectRoot); + expect(config.content.dir).toBe('notes'); + + const output = formatInitResult(result, result.projectRoot); + expect(output).not.toContain('Content scope promoted to the git repo root'); + expect(output).toContain('Content scope set to notes/'); + }); + + it('--content-dir narrows scope relative to cwd', async () => { + const repo = join(fakeHome, 'repo-cd-sub'); + const nested = join(repo, 'docs', 'guides'); + mkdirSync(nested, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + const result = await runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + contentDir: 'docs/guides', + }); + + expect(result.projectRoot).toBe(repo); + expect(result.contentDir).toBe('docs/guides'); + const { config } = loadConfig(result.projectRoot); + expect(config.content.dir).toBe('docs/guides'); + }); + + it('--content-dir outside the project root throws ContentDirError', async () => { + const repo = join(fakeHome, 'repo-cd-escape'); + mkdirSync(repo, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + await expect( + runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + contentDir: '..', + }), + ).rejects.toBeInstanceOf(ContentDirError); + expect(existsSync(join(repo, OK_DIR))).toBe(false); + }); + + it('--content-dir on re-init is ignored (config.yml already exists) and warns', async () => { + const repo = join(fakeHome, 'repo-cd-reinit'); + const sub = join(repo, 'notes'); + mkdirSync(sub, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + await runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + }); + expect(loadConfig(repo).config.content.dir).toBe('.'); + + const result = await runInit({ + cwd: sub, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + contentDir: '.', + }); + expect(result.contentDir).toBeUndefined(); + expect(result.contentDirRequested).toBe('.'); + expect(result.contentScaffoldFailed).toBe(false); + expect(loadConfig(repo).config.content.dir).toBe('.'); + const output = formatInitResult(result, result.projectRoot); + expect(output).toContain('ignored'); + expect( + buildInitJsonSummary(result, { contentDir: '.', contentFileCount: null }).contentDirApplied, + ).toBe(false); + }); + + it('does not claim "config.yml already exists" when content scaffolding failed', async () => { + const repo = join(fakeHome, 'repo-scaffold-fail'); + mkdirSync(repo, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + const base = await runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + }); + + const scaffoldFailed = { + ...base, + contentDirRequested: 'notes', + contentDir: undefined, + contentScaffoldFailed: true, + }; + expect(formatInitResult(scaffoldFailed, base.projectRoot)).not.toContain('ignored'); + + const configExisted = { + ...base, + contentDirRequested: 'notes', + contentDir: undefined, + contentScaffoldFailed: false, + }; + expect(formatInitResult(configExisted, base.projectRoot)).toContain('ignored'); + }); + + it('buildInitJsonSummary projects a promoted, narrowed result into stable JSON fields', async () => { + const repo = join(fakeHome, 'repo-json'); + const sub = join(repo, 'notes'); + mkdirSync(sub, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + const result = await runInit({ + cwd: sub, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + contentDir: '.', + }); + + const summary = buildInitJsonSummary(result, { contentDir: 'notes', contentFileCount: 3 }); + expect(summary.projectRoot).toBe(repo); + expect(summary.gitRootPromoted).toBe(true); + expect(summary.promotedFromDir).toBe('notes'); + expect(summary.contentDir).toBe('notes'); + expect(summary.contentDirRequested).toBe('.'); + expect(summary.contentDirApplied).toBe(true); + expect(summary.contentFileCount).toBe(3); + expect(summary.previewError).toBeNull(); + expect(JSON.parse(JSON.stringify(summary))).toEqual(summary); + }); + + it('buildInitJsonSummary surfaces previewError so a null count is unambiguous', async () => { + const repo = join(fakeHome, 'repo-json-previewerr'); + mkdirSync(repo, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + const base = await runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + }); + const withPreviewError = { ...base, previewWarning: 'cannot access content directory' }; + const summary = buildInitJsonSummary(withPreviewError, { + contentDir: '.', + contentFileCount: null, + }); + expect(summary.contentFileCount).toBeNull(); + expect(summary.previewError).toBe('cannot access content directory'); + }); + + it('buildInitJsonSummary uses null for absent promotion / request / count', async () => { + const repo = join(fakeHome, 'repo-json-flat'); + mkdirSync(repo, { recursive: true }); + Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' }); + + const result = await runInit({ + cwd: repo, + home: fakeHome, + installUserSkill: defaultInstallUserSkill, + scope: 'user', + }); + + const summary = buildInitJsonSummary(result, { contentDir: '.', contentFileCount: null }); + expect(summary.gitRootPromoted).toBe(false); + expect(summary.promotedFromDir).toBeNull(); + expect(summary.contentDirRequested).toBeNull(); + expect(summary.contentDirApplied).toBe(true); + expect(summary.contentFileCount).toBeNull(); + }); +}); + +describe('resolveRequestedContentDir', () => { + let root: string; + beforeEach(() => { + const raw = join(tmpdir(), `rrcd-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(raw, { recursive: true }); + root = realpathSync(raw); + mkdirSync(join(root, 'sub'), { recursive: true }); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('returns "." when the request resolves to the project root itself', () => { + expect(resolveRequestedContentDir('.', root, root)).toBe('.'); + }); + + it('returns the git-root-relative path for a descendant (cwd-relative input)', () => { + expect(resolveRequestedContentDir('sub', root, root)).toBe('sub'); + expect(resolveRequestedContentDir('.', root, join(root, 'sub'))).toBe('sub'); + }); + + it('throws when the resolved path escapes the project root', () => { + expect(() => resolveRequestedContentDir('..', root, root)).toThrow(ContentDirError); + }); + + it('throws when the path does not exist', () => { + expect(() => resolveRequestedContentDir('nope', root, root)).toThrow(ContentDirError); + }); + + it('throws when the path is a file, not a directory', () => { + writeFileSync(join(root, 'file.md'), '# x'); + expect(() => resolveRequestedContentDir('file.md', root, root)).toThrow(ContentDirError); + }); + + it('reports a non-ENOENT stat error as "not accessible", not "does not exist"', () => { + writeFileSync(join(root, 'file.md'), '# x'); + let msg = ''; + try { + resolveRequestedContentDir('file.md/nested', root, root); + } catch (e) { + msg = e instanceof Error ? e.message : String(e); + } + expect(msg).toContain('not accessible'); + expect(msg).not.toContain('does not exist'); + }); + + it('resolves . when cwd reaches the project via a symlinked prefix', () => { + const linkParent = join( + tmpdir(), + `rrcd-link-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + symlinkSync(root, linkParent); + try { + expect(resolveRequestedContentDir('.', root, linkParent)).toBe('.'); + expect(resolveRequestedContentDir('sub', root, linkParent)).toBe('sub'); + } finally { + rmSync(linkParent, { force: true }); + } + }); }); describe('resolveMcpScope', () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b2941c5e6..1123a886b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,5 +1,13 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { inspect } from 'node:util'; import { atomicWriteFileSync, withFileLockSync } from '@inkeep/open-knowledge-core/server'; import type { InstallUserSkillOptions, @@ -27,7 +35,7 @@ import { parseTree as parseJsoncTree, } from 'jsonc-parser'; import { stringify as stringifyToml } from 'smol-toml'; -import { OK_DIR } from '../constants.ts'; +import { CONFIG_FILENAME, OK_DIR } from '../constants.ts'; import { formatPreviewBlock, type PreviewResult } from '../content/preview.ts'; import { resolveProjectRoot } from '../integrations/resolve-project-root.ts'; import { @@ -370,6 +378,54 @@ interface InitCommandOptions { promptFn?: () => Promise; sharing?: 'shared' | 'local-only'; sharingPromptFn?: (defaultMode: 'shared' | 'local-only') => Promise<'shared' | 'local-only'>; + contentDir?: string; +} + +export class ContentDirError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContentDirError'; + } +} + +export function resolveRequestedContentDir( + input: string, + projectRoot: string, + cwd: string, +): string { + const abs = resolve(cwd, input); + let stat: ReturnType; + try { + stat = statSync(abs); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw new ContentDirError(`--content-dir path does not exist: ${abs}`); + } + throw new ContentDirError( + `--content-dir path is not accessible (${code ?? 'unknown error'}): ${abs}`, + ); + } + if (!stat.isDirectory()) { + throw new ContentDirError(`--content-dir must be a directory: ${abs}`); + } + const canonRoot = safeRealpath(projectRoot); + const canonAbs = safeRealpath(abs); + const rel = relative(canonRoot, canonAbs); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new ContentDirError( + `--content-dir must be inside the project root (${projectRoot}); got ${abs}`, + ); + } + return rel === '' ? '.' : rel; +} + +function safeRealpath(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } } interface InitCommandResult { @@ -388,6 +444,11 @@ interface InitCommandResult { * Only set when `didGitInit` is also `true` AND no `.gitignore` was already * present at `projectRoot` — pre-existing files are never touched. */ rootGitignoreCreated: boolean; + gitRootPromoted: boolean; + promotedFromDir?: string; + contentDir?: string; + contentDirRequested?: string; + contentScaffoldFailed: boolean; mcpAction: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed' | 'declined'; mcpPath: string; mcpError?: string; @@ -802,11 +863,19 @@ export async function runInit(options: InitCommandOptions = {}): Promise; try { - contentResult = initContent(projectRoot, { contentDir: resolution.defaultContentDir }); + contentResult = initContent(projectRoot, { contentDir: contentDirScope }); } catch (err) { const fallbackPath = EDITOR_TARGETS.claude.configPath(projectRoot, options.home); return { @@ -831,6 +900,10 @@ export async function runInit(options: InitCommandOptions = {}): Promise; +} + +export function buildInitJsonSummary( + result: InitCommandResult, + opts: { contentDir: string; contentFileCount: number | null }, +): InitJsonSummary { + return { + projectRoot: result.projectRoot, + gitRootPromoted: result.gitRootPromoted, + promotedFromDir: result.promotedFromDir ?? null, + contentDir: opts.contentDir, + contentDirRequested: result.contentDirRequested ?? null, + contentDirApplied: result.contentDir !== undefined, + contentFileCount: opts.contentFileCount, + previewError: result.previewWarning ?? null, + didGitInit: result.didGitInit, + mcpAction: result.mcpAction, + editors: result.editors.map((e) => ({ + editorId: e.editorId, + label: e.label, + action: e.action, + configPath: e.configPath, + scope: e.configScope === 'project' ? 'project' : 'user', + })), + }; +} + export function detectInstalledEditors(cwd: string, home?: string): EditorId[] { const detected: EditorId[] = []; for (const id of ALL_EDITOR_IDS) { @@ -1319,6 +1487,23 @@ export function detectInstalledEditors(cwd: string, home?: string): EditorId[] { return detected; } +function redirectStdoutConsoleToStderr(): () => void { + const orig = { log: console.log, info: console.info, debug: console.debug }; + const toErr = (...args: unknown[]): void => { + process.stderr.write( + `${args.map((a) => (typeof a === 'string' ? a : inspect(a))).join(' ')}\n`, + ); + }; + console.log = toErr; + console.info = toErr; + console.debug = toErr; + return () => { + console.log = orig.log; + console.info = orig.info; + console.debug = orig.debug; + }; +} + export function initCommand(): Command { return new Command('init') .description( @@ -1330,6 +1515,11 @@ export function initCommand(): Command { '--dev-mcp', 'Register a local dev MCP entry using node + packages/cli/dist/cli.mjs with debug logging', ) + .option( + '--content-dir ', + `Limit content to instead of the whole project. is interpreted relative to your current directory (e.g. "." = the folder you run the command in), then saved to ${OK_DIR}/config.yml as content.dir.`, + ) + .option('--json', 'Emit a structured JSON summary to stdout (diagnostics stay on stderr)') .addOption( new Option( '--scope ', @@ -1355,6 +1545,8 @@ export function initCommand(): Command { scope?: McpScope; shared?: boolean; localOnly?: boolean; + contentDir?: string; + json?: boolean; }) => { const cwd = process.cwd(); @@ -1363,50 +1555,82 @@ export function initCommand(): Command { : opts.localOnly ? 'local-only' : undefined; - let result: InitCommandResult; + const restoreConsole = opts.json ? redirectStdoutConsoleToStderr() : null; try { - result = await runInit({ - cwd, - mcp: opts.mcp, - devMcp: opts.devMcp, - scope: opts.scope, - sharing, - }); - } catch (err) { - if (err instanceof GitNotAvailableError || err instanceof GitTooOldError) { - process.stderr.write(`${err.message}\n`); - process.exitCode = 78; - return; + let result: InitCommandResult; + try { + result = await runInit({ + cwd, + mcp: opts.mcp, + devMcp: opts.devMcp, + scope: opts.scope, + sharing, + contentDir: opts.contentDir, + }); + } catch (err) { + if (err instanceof ContentDirError) { + process.stderr.write(`${err.message}\n`); + process.exitCode = 64; + return; + } + if (err instanceof GitNotAvailableError || err instanceof GitTooOldError) { + process.stderr.write(`${err.message}\n`); + process.exitCode = 78; + return; + } + if (err instanceof ProjectGitInitError) { + process.stderr.write( + "open-knowledge could not initialize a git repo for this project. Re-run, or run 'git init' yourself in the project folder.\n", + ); + if (err.stderr) process.stderr.write(`${err.stderr.trim()}\n`); + process.exitCode = 1; + return; + } + throw err; } - if (err instanceof ProjectGitInitError) { - process.stderr.write( - "open-knowledge could not initialize a git repo for this project. Re-run, or run 'git init' yourself in the project folder.\n", - ); - if (err.stderr) process.stderr.write(`${err.stderr.trim()}\n`); - process.exitCode = 1; - return; - } - throw err; - } - try { - const { previewContent } = await import('../content/preview.ts'); + let effectiveContentDir = result.contentDir ?? '.'; + let contentFileCount: number | null = null; const { loadConfig } = await import('../config/loader.ts'); const { resolveContentDir } = await import('@inkeep/open-knowledge-server'); - const { config } = loadConfig(result.projectRoot); - const contentDir = resolveContentDir(config, result.projectRoot); - result.preview = previewContent({ - projectDir: result.projectRoot, - contentDir, - }); - } catch (e) { - result.previewWarning = e instanceof Error ? e.message : String(e); - } + let config: Awaited>['config'] | undefined; + try { + config = loadConfig(result.projectRoot).config; + effectiveContentDir = config.content.dir; + } catch (e) { + result.previewWarning = e instanceof Error ? e.message : String(e); + } + if (config) { + try { + const { previewContent } = await import('../content/preview.ts'); + const contentDir = resolveContentDir(config, result.projectRoot); + result.preview = previewContent({ + projectDir: result.projectRoot, + contentDir, + }); + contentFileCount = result.preview.totalCount; + } catch (e) { + result.previewWarning = e instanceof Error ? e.message : String(e); + } + } - process.stdout.write(`${formatInitResult(result, result.projectRoot)}\n`); + if (opts.json) { + process.stdout.write( + `${JSON.stringify( + buildInitJsonSummary(result, { contentDir: effectiveContentDir, contentFileCount }), + null, + 2, + )}\n`, + ); + } else { + process.stdout.write(`${formatInitResult(result, result.projectRoot)}\n`); + } - if (result.editors.some((e) => e.action === 'failed') || result.mcpAction === 'failed') { - process.exitCode = 1; + if (result.editors.some((e) => e.action === 'failed') || result.mcpAction === 'failed') { + process.exitCode = 1; + } + } finally { + restoreConsole?.(); } }, );