diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f66e0..dd5ebcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Build + run: pnpm build + - name: Test run: pnpm test:coverage @@ -47,9 +50,6 @@ jobs: with: command: audit - - name: Build - run: pnpm build - - name: Size limit run: pnpm size diff --git a/README.md b/README.md index 3fe4a8e..f92aa25 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Deterministic local caching of external documentation for agents and developers Provides agents and developers with local access to external documentation without committing it to the repository. -Documentation is cached in a gitignored location, exposed to agent and tool targets via links or copies, and updated through sync commands or postinstall hooks. +Documentation is cached in a gitignored location. When enabled, docs-cache exposes sources to OpenCode as local references. Optional links or copies remain available for other tools. ## Features @@ -18,8 +18,9 @@ Documentation is cached in a gitignored location, exposed to agent and tool targ - **Deterministic**: `docs-lock.json` pins commits and file metadata. - **Fast**: Local cache avoids network roundtrips after sync. - **Flexible**: Cache full repos or just the subdirectories you need. +- **OpenCode-aware**: Detected OpenCode configs can expose every source as an `@` reference. -> **Note**: Sources are downloaded to a local cache. If you provide a `targetDir`, `docs-cache` creates a symlink or copy from the cache to that target directory. +> **Note**: Sources always materialize in the local cache. `targetDir` is optional and only creates a symlink or copy for tools that need a separate physical path. ## Usage @@ -51,6 +52,10 @@ npx docs-cache clean > for more options: `npx docs-cache --help` +## Requirements + +- Node.js 24 or later + ## Recommended Workflow Use this flow to keep behavior predictable (similar to package manager manifest + lock workflows): @@ -58,7 +63,7 @@ Use this flow to keep behavior predictable (similar to package manager manifest 1. Keep source intent in config (`ref: "main"`, `ref: "v1"`, or a commit SHA). 2. Run `npx docs-cache update ` (or `--all`) to refresh selected sources and lock data. 3. Use `npx docs-cache install` to restore cache/targets from `docs-lock.json` without rewriting the lock file. -4. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data drifts. +4. Use `npx docs-cache sync --frozen` in CI to fail fast when lock data or managed OpenCode references drift. 5. Use `npx docs-cache pin ` only when you explicitly want to rewrite config refs to commit SHAs. ## Configuration @@ -68,14 +73,13 @@ Use this flow to keep behavior predictable (similar to package manager manifest ```jsonc { "$schema": "https://github.com/fbosch/docs-cache/blob/master/docs.config.schema.json", - "sources": [ - { - "id": "framework", - "repo": "https://github.com/framework/core.git", - "ref": "main", // or specific commit hash - "targetDir": "./agents/skills/framework-skill/references", // symlink/copy target - "include": ["guide/**"], // file globs to include from the source - "toc": true, // defaults to "compressed" (for agents) + "sources": [ + { + "id": "framework", + "repo": "https://github.com/framework/core.git", + "ref": "main", // or specific commit hash + "include": ["guide/**"], // file globs to include from the source + "toc": true, // defaults to "compressed" (for agents) }, ], } @@ -89,8 +93,36 @@ Use this flow to keep behavior predictable (similar to package manager manifest | ---------- | -------------------------------------- | -------- | | `cacheDir` | Directory for cache. Default: `.docs`. | Optional | | `defaults` | Default settings for all sources. | Optional | +| `opencode` | OpenCode reference-sync decision. | Optional | | `sources` | List of repositories to sync. | Required | +### OpenCode references + +`docs-cache init` and interactive `docs-cache sync` detect project-local `opencode.json` and `opencode.jsonc` files. When a config is detected, `docs-cache` asks whether to sync references and stores the answer: + +```jsonc +{ + "opencode": true +} +``` + +Declining stores `"opencode": false` and suppresses future prompts. It stops further management without changing existing OpenCode references. Omit the field only while no decision has been made. To target a specific project-local config, set a manual override such as `"opencode": { "configPath": ".opencode/opencode.jsonc" }`. + +`sync` creates or updates managed OpenCode references whose aliases are source IDs. Paths are relative to the OpenCode config file: + +```jsonc +{ + "references": { + "framework": { + "path": "../.docs/framework", + "description": "Use for documentation from framework/core. Start with TOC.md." + } + } +} +``` + +The cache remains gitignored. `targetDir`, symlinks, copies, and unwrapped directories are never used for OpenCode reference paths. `docs-cache` preserves user-owned references and fails on an alias collision. A later `sync` removes stale managed aliases after a source is removed. When TOC generation is disabled for a source, its description does not direct OpenCode to `TOC.md`. `sync --frozen` validates references without writing the OpenCode config. Restart OpenCode after references change because it reads configuration at startup. +
Show default and source options @@ -110,7 +142,7 @@ These fields can be set in `defaults` and are inherited by every source unless o | `maxFiles` | Maximum total files to materialize. | | `ignoreHidden` | Skip hidden files and directories (dotfiles). Default: `false`. | | `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com", "visualstudio.com"]`. | -| `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format: `"tree"` (human readable), `"compressed"` | +| `toc` | Generate per-source `TOC.md`. Default: `true` with compressed output. `"tree"` uses headings and links; `false` removes generated TOC files. | | `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `true`. | > Brace expansion in `include` supports comma-separated lists (including multiple groups) like `**/*.{md,mdx}` and is capped at 500 expanded patterns per include entry. It does not support nested braces or numeric ranges. @@ -130,7 +162,7 @@ These fields can be set in `defaults` and are inherited by every source unless o | ----------- | ---------------------------------------------------------------- | | `targetDir` | Path where files should be symlinked/copied to, outside `.docs`. | -> **Note**: Sources are always downloaded to `.docs//`. If you provide a `targetDir`; `docs-cache` will create a symlink or copy pointing from the cache to that target directory. +> **Note**: Sources are always downloaded to `.docs//`. If you provide a `targetDir`, `docs-cache` creates a symlink or copy pointing from the cache to that target directory. It is not enabled by default.
diff --git a/docs.config.schema.json b/docs.config.schema.json index 3293cd6..0bed2e6 100644 --- a/docs.config.schema.json +++ b/docs.config.schema.json @@ -83,6 +83,29 @@ }, "additionalProperties": false }, + "opencode": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "properties": { + "configPath": { + "type": "string", + "minLength": 1 + } + }, + "required": ["configPath"], + "additionalProperties": false + } + ] + }, "sources": { "type": "array", "items": { diff --git a/package.json b/package.json index 1459981..8bf8fb5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": false, "type": "module", "version": "0.6.0", - "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", + "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a", "description": "CLI for deterministic local caching of external documentation for agents and tools", "author": "Frederik Bosch", "license": "MIT", @@ -95,6 +95,11 @@ "types": "./dist/esm/git/*.d.ts", "default": "./dist/esm/git/*.mjs" }, + "#opencode/*": { + "coverage": "./src/opencode/*.ts", + "types": "./dist/esm/opencode/*.d.ts", + "default": "./dist/esm/opencode/*.mjs" + }, "#types/*": { "coverage": "./src/types/*.ts", "types": "./dist/esm/types/*.d.ts", @@ -108,6 +113,7 @@ "execa": "^9.6.1", "fast-glob": "^3.3.3", "log-update": "7.0.2", + "jsonc-parser": "3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "zod": "^4.3.6" @@ -147,13 +153,5 @@ "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ "biome check --write --no-errors-on-unmatched" ] - }, - "pnpm": { - "overrides": { - "defu@<=6.1.4": "6.1.5", - "lodash@>=4.0.0 <=4.17.23": "4.18.0", - "minimatch@>=10.0.0 <10.2.3": "10.2.4", - "picomatch@<2.3.2": "2.3.2" - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 617d369..320f2d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,11 @@ settings: overrides: defu@<=6.1.4: 6.1.5 lodash@>=4.0.0 <=4.17.23: 4.18.0 - minimatch@>=10.0.0 <10.2.3: 10.2.4 + minimatch@<10.2.1: '>=10.2.1' + minimatch@>=10.0.0 <10.2.3: '>=10.2.3' picomatch@<2.3.2: 2.3.2 + rollup@>=4.0.0 <4.59.0: '>=4.59.0' + svgo@=4.0.0: '>=4.0.1' importers: @@ -29,6 +32,9 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 log-update: specifier: 7.0.2 version: 7.0.2 @@ -113,24 +119,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.11': resolution: {integrity: sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.11': resolution: {integrity: sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.11': resolution: {integrity: sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.11': resolution: {integrity: sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==} @@ -545,16 +555,10 @@ packages: args-tokenizer@0.3.0: resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -853,10 +857,6 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1416,14 +1416,8 @@ snapshots: args-tokenizer@0.3.0: {} - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - brace-expansion@2.0.3: - dependencies: - balanced-match: 1.0.2 - brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -1631,7 +1625,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.9 + minimatch: 10.2.4 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -1750,10 +1744,6 @@ snapshots: dependencies: brace-expansion: 5.0.5 - minimatch@9.0.9: - dependencies: - brace-expansion: 2.0.3 - minipass@7.1.3: {} nanospinner@1.2.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e211d09..fb50251 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,12 @@ +allowBuilds: + esbuild: true + simple-git-hooks: true + overrides: + defu@<=6.1.4: 6.1.5 + lodash@>=4.0.0 <=4.17.23: 4.18.0 minimatch@<10.2.1: '>=10.2.1' minimatch@>=10.0.0 <10.2.3: '>=10.2.3' + picomatch@<2.3.2: 2.3.2 rollup@>=4.0.0 <4.59.0: '>=4.59.0' svgo@=4.0.0: '>=4.0.1' diff --git a/src/api.ts b/src/api.ts index e40038f..4fa3dce 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,3 +14,9 @@ export { loadConfig } from "#config"; export { redactRepoUrl } from "#git/redact"; export { enforceHostAllowlist, parseLsRemote } from "#git/resolve-remote"; export { resolveRepoInput } from "#git/resolve-repo"; +export { saveOpenCodeConsent } from "#opencode/consent"; +export { + detectOpenCodeConfig, + getOpenCodeConfigCandidates, +} from "#opencode/detection"; +export { planOpenCodeReferences } from "#opencode/references"; diff --git a/src/atomic-write.ts b/src/atomic-write.ts new file mode 100644 index 0000000..4af5b97 --- /dev/null +++ b/src/atomic-write.ts @@ -0,0 +1,27 @@ +import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export const writeFileAtomically = async ( + filePath: string, + data: string, + options?: { mode?: number }, +) => { + let mode = options?.mode ?? 0o644; + try { + mode = (await stat(filePath)).mode; + } catch { + // Use the caller's default mode when creating a new file. + } + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + await writeFile(tempPath, data, { encoding: "utf8", mode }); + await chmod(tempPath, mode); + await rename(tempPath, filePath); + } finally { + await rm(tempPath, { force: true }); + } +}; diff --git a/src/cache/lock.ts b/src/cache/lock.ts index e734dee..966c781 100644 --- a/src/cache/lock.ts +++ b/src/cache/lock.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { writeFileAtomically } from "#core/atomic-write"; import { isRecord } from "#core/is-record"; export interface DocsCacheLockSource { @@ -12,10 +13,16 @@ export interface DocsCacheLockSource { rulesSha256?: string; } +export interface DocsCacheOpenCodeLock { + configPath: string; + aliases: string[]; +} + export interface DocsCacheLock { version: 1; toolVersion: string; sources: Record; + opencode?: DocsCacheOpenCodeLock; } export const DEFAULT_LOCK_FILENAME = "docs-lock.json"; @@ -42,49 +49,88 @@ const assertPositiveNumber = (value: unknown, label: string): number => { return numberValue; }; +const validateLockSource = ( + value: unknown, + key: string, +): DocsCacheLockSource => { + if (!isRecord(value)) { + throw new Error(`sources.${key} must be an object.`); + } + return { + repo: assertString(value.repo, `sources.${key}.repo`), + ref: assertString(value.ref, `sources.${key}.ref`), + resolvedCommit: assertString( + value.resolvedCommit, + `sources.${key}.resolvedCommit`, + ), + bytes: assertPositiveNumber(value.bytes, `sources.${key}.bytes`), + fileCount: assertPositiveNumber( + value.fileCount, + `sources.${key}.fileCount`, + ), + manifestSha256: assertString( + value.manifestSha256, + `sources.${key}.manifestSha256`, + ), + rulesSha256: + value.rulesSha256 === undefined + ? undefined + : assertString(value.rulesSha256, `sources.${key}.rulesSha256`), + }; +}; + +const validateSources = (input: unknown) => { + if (!isRecord(input)) { + throw new Error("sources must be an object."); + } + const sources: Record = {}; + for (const [key, value] of Object.entries(input)) { + sources[key] = validateLockSource(value, key); + } + return sources; +}; + +const validateOpenCodeAliases = (value: unknown) => { + if (!Array.isArray(value)) { + throw new Error("opencode.aliases must be an array."); + } + const aliases = value.map((alias, index) => + assertString(alias, `opencode.aliases.${index}`), + ); + if (new Set(aliases).size !== aliases.length) { + throw new Error("opencode.aliases must not contain duplicates."); + } + return aliases; +}; + +const validateOpenCodeLock = (value: unknown): DocsCacheOpenCodeLock => { + if (!isRecord(value)) { + throw new Error("opencode must be an object."); + } + return { + configPath: assertString(value.configPath, "opencode.configPath"), + aliases: validateOpenCodeAliases(value.aliases), + }; +}; + +const validateOptionalOpenCodeLock = (value: unknown) => + value === undefined ? undefined : validateOpenCodeLock(value); + export const validateLock = (input: unknown): DocsCacheLock => { if (!isRecord(input)) { throw new Error("Lock file must be a JSON object."); } - const version = input.version; - if (version !== 1) { + if (input.version !== 1) { throw new Error("Lock file version must be 1."); } const toolVersion = assertString(input.toolVersion, "toolVersion"); - if (!isRecord(input.sources)) { - throw new Error("sources must be an object."); - } - const sources: Record = {}; - for (const [key, value] of Object.entries(input.sources)) { - if (!isRecord(value)) { - throw new Error(`sources.${key} must be an object.`); - } - sources[key] = { - repo: assertString(value.repo, `sources.${key}.repo`), - ref: assertString(value.ref, `sources.${key}.ref`), - resolvedCommit: assertString( - value.resolvedCommit, - `sources.${key}.resolvedCommit`, - ), - bytes: assertPositiveNumber(value.bytes, `sources.${key}.bytes`), - fileCount: assertPositiveNumber( - value.fileCount, - `sources.${key}.fileCount`, - ), - manifestSha256: assertString( - value.manifestSha256, - `sources.${key}.manifestSha256`, - ), - rulesSha256: - value.rulesSha256 === undefined - ? undefined - : assertString(value.rulesSha256, `sources.${key}.rulesSha256`), - }; - } + const sources = validateSources(input.sources); + const opencode = validateOptionalOpenCodeLock(input.opencode); return { version: 1, toolVersion, sources, + ...(opencode ? { opencode } : {}), }; }; @@ -111,5 +157,5 @@ export const readLock = async (lockPath: string) => { export const writeLock = async (lockPath: string, lock: DocsCacheLock) => { const data = `${JSON.stringify(lock, null, 2)}\n`; - await writeFile(lockPath, data, "utf8"); + await writeFileAtomically(lockPath, data); }; diff --git a/src/cache/materialize.ts b/src/cache/materialize.ts index 9e488e6..2238460 100644 --- a/src/cache/materialize.ts +++ b/src/cache/materialize.ts @@ -103,6 +103,11 @@ const STREAM_COPY_THRESHOLD_BYTES = Number.isFinite(STREAM_COPY_THRESHOLD_MB) && STREAM_COPY_THRESHOLD_MB > 0 ? Math.floor(STREAM_COPY_THRESHOLD_MB * 1024 * 1024) : 1024 * 1024; +const NO_FOLLOW_UNSUPPORTED_ERROR_CODES = new Set([ + "EINVAL", + "ENOSYS", + "ENOTSUP", +]); const ensureSafePath = (root: string, target: string) => { const resolvedRoot = path.resolve(root); @@ -120,7 +125,7 @@ const openFileNoFollow = async (filePath: string) => { if (code === "ELOOP") { return null; } - if (code === "EINVAL" || code === "ENOSYS" || code === "ENOTSUP") { + if (NO_FOLLOW_UNSUPPORTED_ERROR_CODES.has(code)) { const stats = await lstat(filePath); if (stats.isSymbolicLink()) { return null; diff --git a/src/cli/index.ts b/src/cli/index.ts index 3be6d34..9ea2a5a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,12 +1,24 @@ import path from "node:path"; import process from "node:process"; +import { confirm, isCancel } from "@clack/prompts"; import pc from "picocolors"; import { ExitCode } from "#cli/exit-code"; -import { parseArgs } from "#cli/parse-args"; +import { type ParsedArgs, parseArgs } from "#cli/parse-args"; import type { CliCommand } from "#cli/types"; import { setSilentMode, setVerboseMode, symbols, ui } from "#cli/ui"; export const CLI_NAME = "docs-cache"; +const COMMANDS_WITH_POSITIONALS = new Set([ + "add", + "remove", + "pin", + "update", + "install", + "sync", +]); + +const hasUnexpectedPositionals = (command: string, positionals: string[]) => + !COMMANDS_WITH_POSITIONALS.has(command) && positionals.length > 0; const HELP_TEXT = ` Usage: ${CLI_NAME} [options] @@ -61,6 +73,61 @@ const printError = (message: string) => { process.stderr.write(`${symbols.error} ${message}\n`); }; +const canPromptForOpenCodeConsent = ( + options: Extract["options"], +) => options.json === false && options.frozen !== true && process.stdin.isTTY; + +const getOpenCodeConsentContext = async ( + options: Extract["options"], +) => { + const { loadConfig } = await import("#config"); + const { detectOpenCodeConfig } = await import("#opencode/detection"); + const { getProjectOpenCodeConfigPath } = await import("#opencode/references"); + const { config, resolvedPath } = await loadConfig(options.config); + if (config.opencode !== undefined) { + return null; + } + const detected = await detectOpenCodeConfig(path.dirname(resolvedPath)); + if (!detected) { + return null; + } + return getProjectOpenCodeConfigPath(resolvedPath, detected) ? detected : null; +}; + +const saveOpenCodeConsentFromPrompt = async ( + options: Extract["options"], + detected: string, +) => { + if (!canPromptForOpenCodeConsent(options)) { + process.stderr.write( + `${symbols.info} OpenCode config detected at ${detected}; run docs-cache sync interactively to choose reference syncing.\n`, + ); + return; + } + const answer = await confirm({ + message: `Sync documentation as OpenCode references using ${detected}`, + initialValue: true, + }); + if (isCancel(answer)) { + throw new Error("Sync cancelled."); + } + const { saveOpenCodeConsent } = await import("#opencode/consent"); + await saveOpenCodeConsent({ + configPath: options.config, + opencode: answer, + }); +}; + +const resolveOpenCodeConsentForSync = async ( + options: Extract["options"], +) => { + const detected = await getOpenCodeConsentContext(options); + if (!detected) { + return; + } + await saveOpenCodeConsentFromPrompt(options, detected); +}; + const runAdd = async (parsed: Extract) => { const options = parsed.options; const { addSources } = await import("#commands/add"); @@ -373,6 +440,7 @@ const runSyncCommand = async ( parsed: Extract, ) => { const options = parsed.options; + await resolveOpenCodeConsentForSync(options); const { printSyncPlan, runSync } = await import("#commands/sync"); const sourceFilter = parsed.ids.length > 0 ? parsed.ids : undefined; const plan = await runSync({ @@ -479,6 +547,24 @@ const runCommand = async (parsed: CliCommand) => { } }; +const handleEarlyExit = (parsed: ParsedArgs) => { + if (parsed.help) { + printHelp(); + process.exit(ExitCode.Success); + } + + if (!parsed.command) { + printHelp(); + process.exit(ExitCode.InvalidArgument); + } + + if (hasUnexpectedPositionals(parsed.command, parsed.positionals)) { + printError(`${CLI_NAME}: unexpected arguments.`); + printHelp(); + process.exit(ExitCode.InvalidArgument); + } +}; + /** * The main entry point of the CLI */ @@ -493,30 +579,7 @@ export async function main(): Promise { setSilentMode(parsed.options.silent); setVerboseMode(parsed.options.verbose); - if (parsed.help) { - printHelp(); - process.exit(ExitCode.Success); - } - - if (!parsed.command) { - printHelp(); - process.exit(ExitCode.InvalidArgument); - } - - if ( - parsed.command !== "add" && - parsed.command !== "remove" && - parsed.command !== "pin" && - parsed.command !== "update" && - parsed.command !== "install" && - parsed.command !== "sync" && - parsed.positionals.length > 0 - ) { - printError(`${CLI_NAME}: unexpected arguments.`); - printHelp(); - process.exit(ExitCode.InvalidArgument); - } - + handleEarlyExit(parsed); await runCommand(parsed.parsed); } catch (error) { errorHandler(error); diff --git a/src/commands/init.ts b/src/commands/init.ts index f4e5c76..841c56a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -10,10 +10,12 @@ import { DEFAULT_CACHE_DIR, DEFAULT_CONFIG_FILENAME, type DocsCacheConfig, + type DocsCacheOpenCode, stripDefaultConfigValues, writeConfig, } from "#config"; import { ensureGitignoreEntry, getGitignoreStatus } from "#core/gitignore"; +import { detectOpenCodeConfig } from "#opencode/detection"; type InitOptions = { cacheDirOverride?: string; @@ -91,6 +93,7 @@ const promptInitAnswers = async ( confirm: typeof clackConfirm, text: typeof clackText, isCancel: typeof clackIsCancel, + opencodeConfigPath: string | null, ) => { const cacheDirAnswer = await text({ message: "Cache directory", @@ -120,14 +123,30 @@ const promptInitAnswers = async ( } gitignoreAnswer = reply; } + let opencode: DocsCacheOpenCode | undefined; + if (opencodeConfigPath) { + const reply = await confirm({ + message: `Sync documentation as OpenCode references using ${opencodeConfigPath}`, + initialValue: true, + }); + if (isCancel(reply)) { + throw new Error("Init cancelled."); + } + opencode = reply; + } return { cacheDir: cacheDirValue, toc: tocAnswer, gitignore: gitignoreAnswer, + opencode, }; }; -const buildBaseConfig = (cacheDir: string, toc: boolean): DocsCacheConfig => { +const buildBaseConfig = ( + cacheDir: string, + toc: boolean, + opencode: DocsCacheOpenCode | undefined, +): DocsCacheConfig => { const config: DocsCacheConfig = { $schema: "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", @@ -139,6 +158,9 @@ const buildBaseConfig = (cacheDir: string, toc: boolean): DocsCacheConfig => { if (!toc) { config.defaults = { toc: false }; } + if (opencode !== undefined) { + config.opencode = opencode; + } return config; }; @@ -188,15 +210,15 @@ const writeStandaloneConfig = async ( }; }; -export const initConfig = async ( - options: InitOptions, - deps: PromptDeps = {}, -) => { - const confirm = deps.confirm ?? clackConfirm; - const isCancel = deps.isCancel ?? clackIsCancel; - const select = deps.select ?? clackSelect; - const text = deps.text ?? clackText; - const cwd = options.cwd ?? process.cwd(); +const getConfirm = (deps: PromptDeps) => deps.confirm ?? clackConfirm; + +const getIsCancel = (deps: PromptDeps) => deps.isCancel ?? clackIsCancel; + +const getSelect = (deps: PromptDeps) => deps.select ?? clackSelect; + +const getText = (deps: PromptDeps) => deps.text ?? clackText; + +const getInitContext = async (cwd: string) => { const { existingConfigPaths, defaultConfigPath, packagePath } = await findExistingConfigPaths(cwd); if (existingConfigPaths.length > 0) { @@ -204,27 +226,69 @@ export const initConfig = async ( `Config already exists at ${existingConfigPaths.join(", ")}. Init aborted.`, ); } + return { defaultConfigPath, packagePath }; +}; + +const writeInitConfig = async (params: { + configPath: string; + config: DocsCacheConfig; + gitignore: boolean; +}) => { + if (path.basename(params.configPath) === "package.json") { + return writePackageConfig( + params.configPath, + params.config, + params.gitignore, + ); + } + if (await exists(params.configPath)) { + throw new Error(`Config already exists at ${params.configPath}.`); + } + return writeStandaloneConfig( + params.configPath, + params.config, + params.gitignore, + ); +}; + +export const initConfig = async ( + options: InitOptions, + deps: PromptDeps = {}, +) => { + const confirm = getConfirm(deps); + const isCancel = getIsCancel(deps); + const select = getSelect(deps); + const text = getText(deps); + const cwd = options.cwd ?? process.cwd(); + const { defaultConfigPath, packagePath } = await getInitContext(cwd); const configPath = await selectConfigPath( packagePath, defaultConfigPath, select, isCancel, ); + const resolvedConfigPath = path.resolve(cwd, configPath); const cacheDir = options.cacheDirOverride ?? DEFAULT_CACHE_DIR; + const detectedOpenCodeConfigPath = await detectOpenCodeConfig( + path.dirname(resolvedConfigPath), + ); + const opencodeConfigPath = detectedOpenCodeConfigPath; const answers = await promptInitAnswers( cacheDir, cwd, confirm, text, isCancel, + opencodeConfigPath, ); - const resolvedConfigPath = path.resolve(cwd, configPath); - const config = buildBaseConfig(answers.cacheDir, answers.toc); - if (path.basename(resolvedConfigPath) === "package.json") { - return writePackageConfig(resolvedConfigPath, config, answers.gitignore); - } - if (await exists(resolvedConfigPath)) { - throw new Error(`Config already exists at ${resolvedConfigPath}.`); - } - return writeStandaloneConfig(resolvedConfigPath, config, answers.gitignore); + const config = buildBaseConfig( + answers.cacheDir, + answers.toc, + answers.opencode, + ); + return writeInitConfig({ + configPath: resolvedConfigPath, + config, + gitignore: answers.gitignore, + }); }; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 0364ae4..00c1a19 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -3,7 +3,11 @@ import { access, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pc from "picocolors"; -import type { DocsCacheLock, DocsCacheLockSource } from "#cache/lock"; +import type { + DocsCacheLock, + DocsCacheLockSource, + DocsCacheOpenCodeLock, +} from "#cache/lock"; import { readLock, resolveLockPath, writeLock } from "#cache/lock"; import { MANIFEST_FILENAME } from "#cache/manifest"; import { computeManifestHash, materializeSource } from "#cache/materialize"; @@ -23,12 +27,19 @@ import { isRecord } from "#core/is-record"; import { resolveCacheDir, resolveTargetDir } from "#core/paths"; import { fetchSource } from "#git/fetch-source"; import { resolveRemoteCommit } from "#git/resolve-remote"; +import { + readOpenCodeOwnership, + restoreOpenCodeOwnership, + writeOpenCodeOwnership, +} from "#opencode/ownership"; +import { planOpenCodeReferences } from "#opencode/references"; import type { SyncOptions, SyncResult } from "#types/sync"; type SyncDeps = { resolveRemoteCommit?: typeof resolveRemoteCommit; fetchSource?: typeof fetchSource; materializeSource?: typeof materializeSource; + writeToc?: typeof writeToc; }; const formatBytes = (value: number) => { @@ -267,6 +278,7 @@ const buildLockSource = ( const buildLock = async ( plan: Awaited>, previous: Awaited> | null, + opencode: DocsCacheOpenCodeLock | null | undefined, ) => { const toolVersion = await loadToolVersion(); const configSourceIds = new Set( @@ -288,6 +300,13 @@ const buildLock = async ( version: 1 as const, toolVersion, sources, + ...(opencode === undefined + ? previous?.opencode + ? { opencode: previous.opencode } + : {} + : opencode + ? { opencode } + : {}), }; }; @@ -818,24 +837,32 @@ const assertInstallLock = (plan: SyncPlan) => { } }; -const finalizeSync = async (params: { +const buildFinalLock = async (params: { plan: SyncPlan; previous: Awaited> | null; - reporter: TaskReporter | null; options: SyncOptions; - startTime: bigint; - warningCount: number; + opencode: DocsCacheOpenCodeLock | null | undefined; }) => { - const { plan, previous, reporter, options, startTime, warningCount } = params; - const lock = options.install ? previous : await buildLock(plan, previous); + const { plan, previous, options, opencode } = params; + const lock = options.install + ? previous + : await buildLock(plan, previous, opencode); if (!lock) { throw new Error( "Install requires docs-lock.json. Run docs-cache sync first.", ); } - if (!options.install) { - await writeLock(plan.lockPath, lock); - } + return lock; +}; + +const finalizeSync = async (params: { + plan: SyncPlan; + reporter: TaskReporter | null; + options: SyncOptions; + startTime: bigint; + warningCount: number; +}) => { + const { plan, reporter, options, startTime, warningCount } = params; const { totalBytes, totalFiles } = summarizePlan(plan); if (reporter) { const summary = `${symbols.info} ${formatBytes(totalBytes)} · ${totalFiles} files`; @@ -847,15 +874,23 @@ const finalizeSync = async (params: { `${symbols.info} Completed in ${elapsedMs.toFixed(0)}ms · ${formatBytes(totalBytes)} · ${totalFiles} files${warningCount ? ` · ${warningCount} warning${warningCount === 1 ? "" : "s"}` : ""}`, ); } - await writeToc({ + plan.lockExists = true; + return plan; +}; + +const writeSyncToc = async (params: { + plan: SyncPlan; + lock: DocsCacheLock; + runWriteToc: typeof writeToc; +}) => { + const { plan, lock, runWriteToc } = params; + await runWriteToc({ cacheDir: plan.cacheDir, configPath: plan.configPath, lock, sources: plan.sources, results: plan.results, }); - plan.lockExists = true; - return plan; }; const createJobRunner = (params: { @@ -945,23 +980,86 @@ const createJobRunner = (params: { }; }; -export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { +const assertValidSyncOptions = (options: SyncOptions) => { if (options.install && options.lockOnly) { throw new Error("Install does not support lockOnly."); } - const startTime = process.hrtime.bigint(); - let warningCount = 0; - const plan = await getSyncPlan(options, deps); - await mkdir(plan.cacheDir, { recursive: true }); +}; +const createSyncReporter = (options: SyncOptions) => { const isTestRunner = process.argv.includes("--test"); - const useLiveOutput = - !options.json && !isSilentMode() && process.stdout.isTTY && !isTestRunner; - const reporter = useLiveOutput ? new TaskReporter() : null; - const previous = plan.lockData; + const useLiveOutput = [ + options.json === false, + isSilentMode() === false, + process.stdout.isTTY, + isTestRunner === false, + ].every(Boolean); + return useLiveOutput ? new TaskReporter() : null; +}; + +const shouldPlanOpenCodeReferences = (options: SyncOptions) => + options.install !== true && + (options.lockOnly !== true || Boolean(options.frozen)); + +const planOpenCodeSync = async (plan: SyncPlan, options: SyncOptions) => { + if (!shouldPlanOpenCodeReferences(options)) { + return { ownership: undefined, references: undefined }; + } + const ownership = await readOpenCodeOwnership(plan.configPath); + const references = await planOpenCodeReferences({ + opencode: plan.config.opencode, + ownership, + sources: plan.config.sources, + cacheDir: plan.cacheDir, + configPath: plan.configPath, + }); + return { ownership, references }; +}; + +const assertFrozenSync = ( + plan: SyncPlan, + openCodeReferences: + | Awaited> + | undefined, +) => { + const drifted = plan.results.filter( + (result) => result.status !== "up-to-date", + ); + if (drifted.length > 0) { + throw new Error( + `Frozen sync failed: lock is out of date for source(s): ${drifted + .map((result) => result.id) + .join( + ", ", + )}. Run docs-cache update or docs-cache sync to refresh the lock.`, + ); + } + if (openCodeReferences?.drift.length) { + throw new Error( + `Frozen sync failed: OpenCode references are out of date for alias(es): ${openCodeReferences.drift.join(", ")}. Run docs-cache sync to refresh them.`, + ); + } +}; + +const assertSyncPreconditions = ( + plan: SyncPlan, + options: SyncOptions, + openCodeReferences: + | Awaited> + | undefined, +) => { + assertInstallPrecondition(plan, options); + assertRequiredSources(plan, options); + assertFrozenPrecondition(plan, options, openCodeReferences); +}; + +const assertInstallPrecondition = (plan: SyncPlan, options: SyncOptions) => { if (options.install) { assertInstallLock(plan); } +}; + +const assertRequiredSources = (plan: SyncPlan, options: SyncOptions) => { const requiredMissing = plan.results.filter((result) => { const source = plan.sources.find((entry) => entry.id === result.id); return result.status === "missing" && (source?.required ?? true); @@ -971,49 +1069,240 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { `Missing required source(s): ${requiredMissing.map((result) => result.id).join(", ")}.`, ); } +}; + +const assertFrozenPrecondition = ( + plan: SyncPlan, + options: SyncOptions, + openCodeReferences: + | Awaited> + | undefined, +) => { if (options.frozen) { - const drifted = plan.results.filter( - (result) => result.status !== "up-to-date", + assertFrozenSync(plan, openCodeReferences); + } +}; + +const syncCache = async (params: { + plan: SyncPlan; + options: SyncOptions; + deps: SyncDeps; + reporter: TaskReporter | null; +}) => { + const { plan, options, deps, reporter } = params; + if (options.lockOnly) { + return 0; + } + const defaults = plan.defaults; + const runFetch = deps.fetchSource ?? fetchSource; + const runMaterialize = deps.materializeSource ?? materializeSource; + const docsPresence = new Map(); + const runJobs = createJobRunner({ + plan, + options, + defaults, + reporter, + runFetch, + runMaterialize, + }); + const initialJobs = await buildJobs(plan, options, docsPresence); + await runJobs(initialJobs); + await ensureTargets(plan, defaults); + return verifyAndRepairCache({ + plan, + options, + docsPresence, + defaults, + reporter, + runJobs, + }); +}; + +const shouldApplyOpenCodeReferences = (options: SyncOptions) => + [ + options.lockOnly !== true, + options.install !== true, + options.frozen !== true, + ].every(Boolean); + +const shouldPersistOpenCodeOwnership = ( + plan: SyncPlan, + shouldApply: boolean, + openCodeReferences: + | Awaited> + | undefined, +) => + [ + shouldApply, + plan.config.opencode !== undefined, + plan.config.opencode !== false, + openCodeReferences?.nextState !== undefined, + ].every(Boolean); + +const applyOpenCodeReferences = async ( + shouldApply: boolean, + openCodeReferences: + | Awaited> + | undefined, +) => { + if (shouldApply) { + await openCodeReferences?.apply(); + } +}; + +const writeSyncState = async (params: { + plan: SyncPlan; + lock: DocsCacheLock; + options: SyncOptions; + shouldPersistOwnership: boolean; + openCodeReferences: + | Awaited> + | undefined; +}) => { + const { plan, lock, options, shouldPersistOwnership, openCodeReferences } = + params; + await writeOpenCodeOwnershipIfNeeded( + plan, + shouldPersistOwnership, + openCodeReferences, + ); + if (!options.install) { + await writeLock(plan.lockPath, lock); + } +}; + +const writeOpenCodeOwnershipIfNeeded = async ( + plan: SyncPlan, + shouldPersistOwnership: boolean, + openCodeReferences: + | Awaited> + | undefined, +) => { + if (shouldPersistOwnership && openCodeReferences?.ownershipState) { + await writeOpenCodeOwnership( + plan.configPath, + openCodeReferences.ownershipState, + ); + } +}; + +const collectRollbackFailure = async ( + rollback: () => Promise, + failures: unknown[], +) => { + try { + await rollback(); + } catch (error) { + failures.push(error); + } +}; + +const rollbackOpenCodeState = async (params: { + plan: SyncPlan; + shouldPersistOwnership: boolean; + shouldApply: boolean; + ownership: DocsCacheOpenCodeLock | undefined; + openCodeReferences: + | Awaited> + | undefined; +}) => { + const failures: unknown[] = []; + if (params.shouldPersistOwnership) { + await collectRollbackFailure( + () => restoreOpenCodeOwnership(params.plan.configPath, params.ownership), + failures, + ); + } + if (params.shouldApply) { + await collectRollbackFailure( + async () => params.openCodeReferences?.rollback(), + failures, ); - if (drifted.length > 0) { - throw new Error( - `Frozen sync failed: lock is out of date for source(s): ${drifted - .map((result) => result.id) - .join( - ", ", - )}. Run docs-cache update or docs-cache sync to refresh the lock.`, - ); - } } - if (!options.lockOnly) { - const defaults = plan.defaults; - const runFetch = deps.fetchSource ?? fetchSource; - const runMaterialize = deps.materializeSource ?? materializeSource; - const docsPresence = new Map(); - const runJobs = createJobRunner({ + return failures; +}; + +const persistSyncState = async (params: { + plan: SyncPlan; + lock: DocsCacheLock; + options: SyncOptions; + ownership: DocsCacheOpenCodeLock | undefined; + openCodeReferences: + | Awaited> + | undefined; +}) => { + const { plan, lock, options, ownership, openCodeReferences } = params; + const shouldApply = shouldApplyOpenCodeReferences(options); + const shouldPersistOwnership = shouldPersistOpenCodeOwnership( + plan, + shouldApply, + openCodeReferences, + ); + await applyOpenCodeReferences(shouldApply, openCodeReferences); + try { + await writeSyncState({ plan, + lock, options, - defaults, - reporter, - runFetch, - runMaterialize, + shouldPersistOwnership, + openCodeReferences, }); - - const initialJobs = await buildJobs(plan, options, docsPresence); - await runJobs(initialJobs); - await ensureTargets(plan, defaults); - warningCount += await verifyAndRepairCache({ + } catch (error) { + const rollbackFailures = await rollbackOpenCodeState({ plan, - options, - docsPresence, - defaults, - reporter, - runJobs, + shouldPersistOwnership, + shouldApply, + ownership, + openCodeReferences, }); + if (rollbackFailures.length > 0) { + throw new AggregateError( + [error, ...rollbackFailures], + "Failed to persist OpenCode state and roll back cleanly.", + { cause: error }, + ); + } + throw error; } - return finalizeSync({ +}; + +export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { + assertValidSyncOptions(options); + const startTime = process.hrtime.bigint(); + const plan = await getSyncPlan(options, deps); + const reporter = createSyncReporter(options); + const previous = plan.lockData; + const { ownership, references: openCodeReferences } = await planOpenCodeSync( + plan, + options, + ); + assertSyncPreconditions(plan, options, openCodeReferences); + await mkdir(plan.cacheDir, { recursive: true }); + const warningCount = await syncCache({ plan, options, deps, reporter }); + const opencode = + options.lockOnly || options.install + ? undefined + : openCodeReferences?.nextState; + const lock = await buildFinalLock({ plan, previous, + options, + opencode, + }); + await writeSyncToc({ + plan, + lock, + runWriteToc: deps.writeToc ?? writeToc, + }); + await persistSyncState({ + plan, + lock, + options, + ownership, + openCodeReferences, + }); + return finalizeSync({ + plan, reporter, options, startTime, diff --git a/src/commands/verify.ts b/src/commands/verify.ts index ad4d0da..2607d53 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -11,6 +11,10 @@ type VerifyOptions = { cacheDirOverride?: string; json: boolean; }; +const MISSING_PATH_ERROR_CODES = new Set([ + "ENOENT", + "ENOTDIR", +]); const exists = async (target: string) => { try { @@ -54,7 +58,7 @@ export const verifyCache = async (options: VerifyOptions) => { } } catch (error) { const code = getErrnoCode(error); - if (code === "ENOENT" || code === "ENOTDIR") { + if (MISSING_PATH_ERROR_CODES.has(code)) { missingCount += 1; continue; } @@ -82,7 +86,7 @@ export const verifyCache = async (options: VerifyOptions) => { }; } catch (error) { const code = getErrnoCode(error); - if (code === "ENOENT" || code === "ENOTDIR") { + if (MISSING_PATH_ERROR_CODES.has(code)) { return { ok: false, issues: [ diff --git a/src/config/index.ts b/src/config/index.ts index 2eb597c..010bac9 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,6 +5,7 @@ import type { DocsCacheConfig, DocsCacheDefaults, DocsCacheIntegrity, + DocsCacheOpenCode, DocsCacheResolvedSource, DocsCacheSource, TocFormat, @@ -17,6 +18,7 @@ export type { DocsCacheConfig, DocsCacheDefaults, DocsCacheIntegrity, + DocsCacheOpenCode, DocsCacheResolvedSource, DocsCacheSource, TocFormat, @@ -84,17 +86,29 @@ const pruneDefaults = ( return result; }; +const buildConfigBaseline = (config: DocsCacheConfig): DocsCacheConfig => ({ + ...DEFAULT_CONFIG, + $schema: config.$schema, + defaults: { + ...DEFAULT_CONFIG.defaults, + ...(config.targetMode ? { targetMode: config.targetMode } : undefined), + }, +}); + +const removeEmptyConfigValues = (config: DocsCacheConfig) => { + if (!config.defaults || Object.keys(config.defaults).length === 0) { + delete config.defaults; + } + if (config.opencode === undefined) { + delete config.opencode; + } + return config; +}; + export const stripDefaultConfigValues = ( config: DocsCacheConfig, ): DocsCacheConfig => { - const baseline: DocsCacheConfig = { - ...DEFAULT_CONFIG, - $schema: config.$schema, - defaults: { - ...DEFAULT_CONFIG.defaults, - ...(config.targetMode ? { targetMode: config.targetMode } : undefined), - }, - }; + const baseline = buildConfigBaseline(config); const pruned = pruneDefaults( config as unknown as Record, baseline as unknown as Record, @@ -103,13 +117,11 @@ export const stripDefaultConfigValues = ( $schema: pruned.$schema as DocsCacheConfig["$schema"], cacheDir: pruned.cacheDir as DocsCacheConfig["cacheDir"], targetMode: pruned.targetMode as DocsCacheConfig["targetMode"], + opencode: pruned.opencode as DocsCacheConfig["opencode"], defaults: pruned.defaults as DocsCacheConfig["defaults"], sources: config.sources, }; - if (!next.defaults || Object.keys(next.defaults).length === 0) { - delete next.defaults; - } - return next; + return removeEmptyConfigValues(next); }; export const validateConfig = (input: unknown): DocsCacheConfig => { @@ -140,6 +152,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { return { cacheDir, targetMode: targetModeOverride, + opencode: configInput.opencode, defaults, sources: configInput.sources as DocsCacheSource[], }; diff --git a/src/config/io.ts b/src/config/io.ts index 813733b..d317aee 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -119,6 +119,9 @@ export const mergeConfigBase = ( if (config.targetMode) { nextConfig.targetMode = config.targetMode; } + if (config.opencode !== undefined) { + nextConfig.opencode = config.opencode; + } return nextConfig; }; diff --git a/src/config/schema.ts b/src/config/schema.ts index dffa2e9..ca6dd50 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -4,6 +4,15 @@ import { assertSafeSourceId } from "#core/source-id"; export const TargetModeSchema = z.enum(["symlink", "copy"]); export const CacheModeSchema = z.enum(["materialize"]); export const TocFormatSchema = z.enum(["tree", "compressed"]); +const OpenCodeSchema = z.union([ + z.literal(true), + z.literal(false), + z + .object({ + configPath: z.string().min(1), + }) + .strict(), +]); export const IntegritySchema = z .object({ type: z.enum(["commit", "manifest"]), @@ -67,6 +76,7 @@ export const ConfigSchema = z cacheDir: z.string().min(1).optional(), targetMode: TargetModeSchema.optional(), defaults: DefaultsSchema.partial().optional(), + opencode: OpenCodeSchema.optional(), sources: z.array(SourceSchema), }) .strict() @@ -87,6 +97,18 @@ export const ConfigSchema = z message: `Duplicate source IDs found: ${Array.from(duplicates).join(", ")}.`, }); } + if (value.opencode) { + value.sources.forEach((source, index) => { + if (/[/\s`,]/.test(source.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sources", index, "id"], + message: + "id must not contain slashes, whitespace, backticks, or commas when OpenCode integration is enabled.", + }); + } + }); + } }); export type DocsCacheDefaults = z.infer; @@ -94,5 +116,6 @@ export type DocsCacheSource = z.infer; export type DocsCacheResolvedSource = z.infer; export type DocsCacheConfig = z.infer; export type DocsCacheIntegrity = z.infer; +export type DocsCacheOpenCode = z.infer; export type CacheMode = z.infer; export type TocFormat = z.infer; diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index 66376b0..40fc922 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -16,6 +16,11 @@ const DEFAULT_GIT_DEPTH = 1; const DEFAULT_RM_RETRIES = 3; const DEFAULT_RM_BACKOFF_MS = 100; const MAX_BRACE_EXPANSIONS = 500; +const RETRYABLE_REMOVE_ERROR_CODES = new Set([ + "ENOTEMPTY", + "EBUSY", + "EPERM", +]); const buildGitConfigs = (allowFileProtocol?: boolean) => [ "-c", @@ -132,7 +137,7 @@ const removeDir = async (dirPath: string, retries = DEFAULT_RM_RETRIES) => { return; } catch (error) { const code = getErrnoCode(error); - if (code !== "ENOTEMPTY" && code !== "EBUSY" && code !== "EPERM") { + if (!RETRYABLE_REMOVE_ERROR_CODES.has(code)) { throw error; } if (attempt === retries) { diff --git a/src/opencode/consent.ts b/src/opencode/consent.ts new file mode 100644 index 0000000..ba65672 --- /dev/null +++ b/src/opencode/consent.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { type DocsCacheOpenCode, validateConfig } from "#config"; +import { + mergeConfigBase, + readConfigAtPath, + resolveConfigTarget, + writeConfigFile, +} from "#config/io"; + +export const saveOpenCodeConsent = async (params: { + configPath?: string; + opencode: DocsCacheOpenCode; +}) => { + const target = await resolveConfigTarget(params.configPath); + const { config, rawConfig, rawPackage } = await readConfigAtPath(target); + const nextConfig = mergeConfigBase(rawConfig ?? config, config.sources); + nextConfig.opencode = params.opencode; + validateConfig(nextConfig); + await writeConfigFile({ + mode: target.mode, + resolvedPath: target.resolvedPath, + config: nextConfig, + rawPackage, + }); + return path.resolve(target.resolvedPath); +}; diff --git a/src/opencode/detection.ts b/src/opencode/detection.ts new file mode 100644 index 0000000..dd30633 --- /dev/null +++ b/src/opencode/detection.ts @@ -0,0 +1,76 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +const exists = async (filePath: string) => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +const configFilesIn = (directory: string) => [ + path.join(directory, "opencode.jsonc"), + path.join(directory, "opencode.json"), +]; + +const isProjectConfigDisabled = () => { + const value = process.env.OPENCODE_DISABLE_PROJECT_CONFIG; + return value === "true" || value === "1"; +}; + +const findProjectRoot = async (startDir: string) => { + let directory = path.resolve(startDir); + while (true) { + if (await exists(path.join(directory, ".git"))) { + return directory; + } + const parent = path.dirname(directory); + if (parent === directory) { + return path.resolve(startDir); + } + directory = parent; + } +}; + +const projectDirectories = (rootDir: string, startDir: string) => { + const directories: string[] = []; + let directory = path.resolve(startDir); + while (true) { + directories.push(directory); + if (directory === rootDir) { + return directories.reverse(); + } + directory = path.dirname(directory); + } +}; + +const projectConfigCandidates = async (startDir: string) => { + const rootDir = await findProjectRoot(startDir); + const directories = projectDirectories(rootDir, path.resolve(startDir)); + return [ + ...directories.flatMap(configFilesIn), + ...[...directories] + .reverse() + .flatMap((directory) => configFilesIn(path.join(directory, ".opencode"))), + ]; +}; + +export const getOpenCodeConfigCandidates = async (startDir: string) => { + if (isProjectConfigDisabled()) { + return []; + } + return projectConfigCandidates(startDir); +}; + +export const detectOpenCodeConfig = async (startDir: string) => { + const candidates = await getOpenCodeConfigCandidates(startDir); + let detected: string | null = null; + for (const candidate of candidates) { + if (await exists(candidate)) { + detected = candidate; + } + } + return detected; +}; diff --git a/src/opencode/ownership.ts b/src/opencode/ownership.ts new file mode 100644 index 0000000..74dbcb1 --- /dev/null +++ b/src/opencode/ownership.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import { readFile, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { DocsCacheOpenCodeLock } from "#cache/lock"; +import { writeFileAtomically } from "#core/atomic-write"; +import { isRecord } from "#core/is-record"; + +const stateDirectory = () => + path.join( + process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state"), + "docs-cache", + "opencode", + ); + +const statePathFor = (configPath: string) => { + const hash = createHash("sha256") + .update(path.resolve(configPath)) + .digest("hex"); + return path.join(stateDirectory(), `${hash}.json`); +}; + +const isValidAliases = (aliases: unknown): aliases is string[] => + Array.isArray(aliases) && + aliases.every( + (alias): alias is string => typeof alias === "string" && alias.length > 0, + ); + +const getOwnershipRecord = (value: unknown) => { + if (!isRecord(value)) { + throw new Error("Invalid local OpenCode ownership state."); + } + return value; +}; + +const assertOwnershipVersion = (value: Record) => { + if (value.version !== 1) { + throw new Error("Invalid local OpenCode ownership state."); + } +}; + +const getOwnershipConfigPath = (value: Record) => { + if (typeof value.configPath !== "string" || value.configPath.length === 0) { + throw new Error("Invalid local OpenCode ownership configPath."); + } + return value.configPath; +}; + +const getOwnershipAliases = (value: Record) => { + if (!isValidAliases(value.aliases)) { + throw new Error("Invalid local OpenCode ownership aliases."); + } + return value.aliases; +}; + +const validateOwnership = (value: unknown): DocsCacheOpenCodeLock => { + const record = getOwnershipRecord(value); + assertOwnershipVersion(record); + return { + configPath: getOwnershipConfigPath(record), + aliases: getOwnershipAliases(record), + }; +}; + +export const readOpenCodeOwnership = async (configPath: string) => { + try { + const raw = await readFile(statePathFor(configPath), "utf8"); + return validateOwnership(JSON.parse(raw)); + } catch (error) { + const code = isRecord(error) ? error.code : undefined; + if (code === "ENOENT") { + return undefined; + } + throw error; + } +}; + +export const writeOpenCodeOwnership = async ( + configPath: string, + ownership: DocsCacheOpenCodeLock, +) => { + const data = `${JSON.stringify({ version: 1, ...ownership }, null, 2)}\n`; + await writeFileAtomically(statePathFor(configPath), data, { mode: 0o600 }); +}; + +export const restoreOpenCodeOwnership = async ( + configPath: string, + ownership: DocsCacheOpenCodeLock | undefined, +) => { + if (ownership) { + await writeOpenCodeOwnership(configPath, ownership); + return; + } + await rm(statePathFor(configPath), { force: true }); +}; diff --git a/src/opencode/references.ts b/src/opencode/references.ts new file mode 100644 index 0000000..5be1542 --- /dev/null +++ b/src/opencode/references.ts @@ -0,0 +1,413 @@ +import { access, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"; +import type { DocsCacheOpenCodeLock } from "#cache/lock"; +import type { DocsCacheOpenCode, DocsCacheSource } from "#config"; +import { writeFileAtomically } from "#core/atomic-write"; +import { isRecord } from "#core/is-record"; +import { detectOpenCodeConfig } from "#opencode/detection"; + +type Reference = { + path: string; + description: string; +}; + +type OpenCodeDocument = { + references: Record; + raw: string; +}; + +export type OpenCodeReferencePlan = { + drift: string[]; + nextState: DocsCacheOpenCodeLock | null | undefined; + ownershipState: DocsCacheOpenCodeLock | undefined; + apply: () => Promise; + rollback: () => Promise; +}; + +type FileChange = { + filePath: string; + raw: string; + next: string; + drift: string[]; +}; + +const exists = async (filePath: string) => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +const parseDocumentValue = (raw: string, filePath: string) => { + const errors: ParseError[] = []; + const value = parse(raw, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`Invalid JSONC in OpenCode config at ${filePath}.`); + } + if (!isRecord(value)) { + throw new Error(`OpenCode config at ${filePath} must be an object.`); + } + return value; +}; + +const getDocumentReferences = ( + value: Record, + filePath: string, +) => { + const references = value.references; + if (references !== undefined && !isRecord(references)) { + throw new Error(`OpenCode references in ${filePath} must be an object.`); + } + return references ?? {}; +}; + +const parseDocument = async (filePath: string): Promise => { + const raw = await readFile(filePath, "utf8"); + const value = parseDocumentValue(raw, filePath); + return { references: getDocumentReferences(value, filePath), raw }; +}; + +const getRepositoryLabel = (repo: string) => { + const sshMatch = repo.match(/^[^@]+@[^:]+:(.+)$/); + const value = sshMatch?.[1] ?? repo.replace(/^https?:\/\/[^/]+\//, ""); + return value.replace(/\.git$/i, "").replace(/^\/+/, ""); +}; + +const buildReference = ( + source: DocsCacheSource, + cacheDir: string, + configPath: string, +): Reference => ({ + path: path + .relative(path.dirname(configPath), path.resolve(cacheDir, source.id)) + .split(path.sep) + .join("/"), + description: `Use for documentation from ${getRepositoryLabel(source.repo)}.${source.toc === false ? "" : " Start with TOC.md."}`, +}); + +const formattingOptionsFor = (raw: string) => { + const indentation = raw.match(/\n([\t ]+)"/)?.[1] ?? "\t"; + return { + insertSpaces: !indentation.includes("\t"), + tabSize: indentation.length, + eol: raw.includes("\r\n") ? "\r\n" : "\n", + }; +}; + +const updateReferences = (params: { + raw: string; + desired: Map; + stale: Set; +}) => { + let next = params.raw; + const formattingOptions = formattingOptionsFor(next); + for (const [alias, reference] of params.desired) { + next = applyEdits( + next, + modify(next, ["references", alias], reference, { formattingOptions }), + ); + } + for (const alias of params.stale) { + next = applyEdits( + next, + modify(next, ["references", alias], undefined, { formattingOptions }), + ); + } + return next; +}; + +const findDesiredDrift = ( + references: Record, + desired: Map, +) => { + const drift: string[] = []; + for (const [alias, reference] of desired) { + if ( + !Object.hasOwn(references, alias) || + JSON.stringify(references[alias]) !== JSON.stringify(reference) + ) { + drift.push(alias); + } + } + return drift; +}; + +const findStaleDrift = ( + references: Record, + stale: Set, +) => { + const drift: string[] = []; + for (const alias of stale) { + if (Object.hasOwn(references, alias)) { + drift.push(alias); + } + } + return drift; +}; + +const findDrift = (params: { + references: Record; + desired: Map; + stale: Set; +}) => [ + ...findDesiredDrift(params.references, params.desired), + ...findStaleDrift(params.references, params.stale), +]; + +const assertNoUserAliasCollisions = ( + references: Record, + desired: Map, + managed: Set, + configPath: string, +) => { + const collisions = Array.from(desired.keys()).filter( + (alias) => Object.hasOwn(references, alias) && !managed.has(alias), + ); + if (collisions.length > 0) { + throw new Error( + `OpenCode reference alias collision in ${configPath}: ${collisions.join(", ")}. Rename the docs-cache source or remove the user-owned reference.`, + ); + } +}; + +const noOpPlan = ( + nextState: DocsCacheOpenCodeLock | null | undefined, +): OpenCodeReferencePlan => ({ + drift: [], + nextState, + ownershipState: undefined, + apply: async () => undefined, + rollback: async () => undefined, +}); + +const rollbackChanges = async (changes: FileChange[]) => { + for (const change of [...changes].reverse()) { + await writeFileAtomically(change.filePath, change.raw); + } +}; + +const restoreAppliedChanges = async (changes: FileChange[]) => { + const failures: unknown[] = []; + for (const change of [...changes].reverse()) { + try { + await writeFileAtomically(change.filePath, change.raw); + } catch (error) { + failures.push(error); + } + } + return failures; +}; + +const applyChanges = async (changes: FileChange[]) => { + const written: FileChange[] = []; + try { + for (const change of changes) { + await writeFileAtomically(change.filePath, change.next); + written.push(change); + } + } catch (error) { + const rollbackFailures = await restoreAppliedChanges(written); + if (rollbackFailures.length > 0) { + throw new AggregateError( + [error, ...rollbackFailures], + "Failed to apply OpenCode references and roll back cleanly.", + { cause: error }, + ); + } + throw error; + } +}; + +const createPlan = ( + changes: FileChange[], + nextState: DocsCacheOpenCodeLock | null, + ownershipState: DocsCacheOpenCodeLock, +): OpenCodeReferencePlan => { + const changed = changes.filter((change) => change.next !== change.raw); + const drift = changes.flatMap((change) => change.drift); + return { + drift, + nextState, + ownershipState, + apply: async () => applyChanges(changed), + rollback: async () => rollbackChanges(changed), + }; +}; + +const buildFileChange = (params: { + configPath: string; + document: OpenCodeDocument; + desired: Map; + stale: Set; +}) => ({ + filePath: params.configPath, + raw: params.document.raw, + next: updateReferences({ + raw: params.document.raw, + desired: params.desired, + stale: params.stale, + }), + drift: findDrift({ + references: params.document.references, + desired: params.desired, + stale: params.stale, + }), +}); + +const resolveOpenCodeConfigPath = ( + configPath: string, + opencodeConfigPath: string, +) => path.resolve(path.dirname(configPath), opencodeConfigPath); + +export const getProjectOpenCodeConfigPath = ( + configPath: string, + opencodeConfigPath: string, +) => { + const relativePath = path.relative( + path.dirname(configPath), + opencodeConfigPath, + ); + if ( + relativePath === "" || + relativePath.split(path.sep, 1)[0] === ".." || + path.isAbsolute(relativePath) + ) { + return null; + } + return relativePath.split(path.sep).join("/"); +}; + +const requireProjectOpenCodeConfigPath = ( + configPath: string, + opencodeConfigPath: string, +) => { + const projectConfigPath = getProjectOpenCodeConfigPath( + configPath, + opencodeConfigPath, + ); + if (projectConfigPath) { + return projectConfigPath; + } + throw new Error( + `OpenCode config at ${opencodeConfigPath} must be within the docs-cache project.`, + ); +}; + +const getConfigPath = async ( + opencode: Exclude, + docsConfigPath: string, +) => { + const configPath = + opencode === true + ? await detectOpenCodeConfig(path.dirname(docsConfigPath)) + : resolveOpenCodeConfigPath(docsConfigPath, opencode.configPath); + if (!configPath) { + throw new Error(`Project OpenCode config not found for ${docsConfigPath}.`); + } + if (!(await exists(configPath))) { + throw new Error(`Configured OpenCode config not found at ${configPath}.`); + } + return configPath; +}; + +const resolveExistingPath = async (filePath: string) => { + if (!(await exists(filePath))) { + return null; + } + return realpath(filePath); +}; + +const getManagedAliases = async ( + ownership: DocsCacheOpenCodeLock | undefined, + configPath: string, +) => { + if (!ownership) { + return new Set(); + } + const ownershipPath = await resolveExistingPath(ownership.configPath); + return new Set(ownershipPath === configPath ? ownership.aliases : []); +}; + +const getPreviousFileChange = async ( + ownership: DocsCacheOpenCodeLock | undefined, + configPath: string, +) => { + if (!ownership) { + return null; + } + const previousWritePath = await resolveExistingPath(ownership.configPath); + if (!previousWritePath || previousWritePath === configPath) { + return null; + } + const document = await parseDocument(ownership.configPath); + return buildFileChange({ + configPath: previousWritePath, + document, + desired: new Map(), + stale: new Set(ownership.aliases), + }); +}; + +export const planOpenCodeReferences = async (params: { + opencode: DocsCacheOpenCode | undefined; + ownership: DocsCacheOpenCodeLock | undefined; + sources: DocsCacheSource[]; + cacheDir: string; + configPath: string; +}): Promise => { + if (params.opencode === undefined) { + return noOpPlan(undefined); + } + if (params.opencode === false) { + return noOpPlan(undefined); + } + + const configPath = await getConfigPath(params.opencode, params.configPath); + const document = await parseDocument(configPath); + const writePath = await realpath(configPath); + const desired = new Map( + params.sources.map((source) => [ + source.id, + buildReference(source, params.cacheDir, configPath), + ]), + ); + const managed = await getManagedAliases(params.ownership, writePath); + assertNoUserAliasCollisions( + document.references, + desired, + managed, + configPath, + ); + const stale = new Set( + Array.from(managed).filter((alias) => !desired.has(alias)), + ); + const currentChange = buildFileChange({ + configPath: writePath, + document, + desired, + stale, + }); + const previousChange = await getPreviousFileChange( + params.ownership, + writePath, + ); + const changes = previousChange + ? [previousChange, currentChange] + : [currentChange]; + const aliases = Array.from(desired.keys()); + const projectConfigPath = requireProjectOpenCodeConfigPath( + params.configPath, + configPath, + ); + return createPlan( + changes, + { configPath: projectConfigPath, aliases }, + { configPath: writePath, aliases }, + ); +}; diff --git a/tests/config-validation.test.js b/tests/config-validation.test.js index 146b15d..44bb69c 100644 --- a/tests/config-validation.test.js +++ b/tests/config-validation.test.js @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { loadConfig } from "../dist/api.mjs"; +import { loadConfig, saveOpenCodeConsent } from "../dist/api.mjs"; const writeConfig = async (data) => { const tmpRoot = path.join( @@ -100,3 +100,31 @@ test("loadConfig supports package.json docs-cache config", async () => { const { config } = await loadConfig(packagePath); assert.equal(config.sources.length, 1); }); + +test("loadConfig validates OpenCode aliases only when integration is enabled", async () => { + const configPath = await writeConfig({ + opencode: { configPath: "/tmp/opencode.json" }, + sources: [{ id: "invalid alias", repo: "https://example.com/repo.git" }], + }); + await assert.rejects( + () => loadConfig(configPath), + /OpenCode integration is enabled/i, + ); +}); + +test("saveOpenCodeConsent rejects invalid aliases before changing config", async () => { + const configPath = await writeConfig({ + sources: [{ id: "invalid alias", repo: "https://example.com/repo.git" }], + }); + const before = await readFile(configPath, "utf8"); + + await assert.rejects( + () => + saveOpenCodeConsent({ + configPath, + opencode: { configPath: "/tmp/opencode.json" }, + }), + /OpenCode integration is enabled/i, + ); + assert.equal(await readFile(configPath, "utf8"), before); +}); diff --git a/tests/init.test.js b/tests/init.test.js index c11f96b..2b8ed37 100644 --- a/tests/init.test.js +++ b/tests/init.test.js @@ -17,6 +17,9 @@ const stubPrompts = (answers, callbacks = {}) => ({ } return answers.gitignore ?? true; } + if (options.message?.startsWith("Sync documentation as OpenCode")) { + return answers.opencode ?? false; + } return false; }, isCancel: () => false, @@ -175,3 +178,84 @@ test("init skips gitignore prompt when entry exists", async () => { assert.equal(prompted, false); }); + +test("init remembers accepted OpenCode reference syncing for the highest-priority config", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-init-opencode-${Date.now().toString(36)}`, + ); + const openCodeDir = path.join(tmpRoot, ".opencode"); + await mkdir(openCodeDir, { recursive: true }); + await writeFile(path.join(tmpRoot, ".git"), "gitdir: /tmp/unused\n", "utf8"); + await writeFile(path.join(tmpRoot, "opencode.json"), "{}\n", "utf8"); + const openCodePath = path.join(openCodeDir, "opencode.jsonc"); + await writeFile(openCodePath, "{}\n", "utf8"); + const previousHome = process.env.HOME; + const previousCustomDir = process.env.OPENCODE_CONFIG_DIR; + process.env.HOME = tmpRoot; + delete process.env.OPENCODE_CONFIG_DIR; + + try { + await initConfig( + { json: false, cwd: tmpRoot }, + stubPrompts({ + location: "config", + cacheDir: ".docs", + toc: true, + gitignore: false, + opencode: true, + }), + ); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousCustomDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = previousCustomDir; + } + } + + const config = JSON.parse( + await readFile(path.join(tmpRoot, "docs.config.json"), "utf8"), + ); + assert.equal(config.opencode, true); +}); + +test("init remembers when OpenCode reference syncing is declined", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-init-opencode-decline-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + await writeFile(path.join(tmpRoot, "opencode.jsonc"), "{}\n", "utf8"); + const previousCustomDir = process.env.OPENCODE_CONFIG_DIR; + process.env.OPENCODE_CONFIG_DIR = tmpRoot; + + try { + await initConfig( + { json: false, cwd: tmpRoot }, + stubPrompts({ + location: "config", + cacheDir: ".docs", + toc: true, + gitignore: false, + opencode: false, + }), + ); + } finally { + if (previousCustomDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = previousCustomDir; + } + } + + const config = JSON.parse( + await readFile(path.join(tmpRoot, "docs.config.json"), "utf8"), + ); + assert.equal(config.opencode, false); +}); diff --git a/tests/install.test.js b/tests/install.test.js index 56e8060..6c785da 100644 --- a/tests/install.test.js +++ b/tests/install.test.js @@ -15,12 +15,13 @@ const exists = async (target) => { } }; -const writeConfig = async (tmpRoot, extra = {}) => { +const writeConfig = async (tmpRoot, extra = {}, topLevel = {}) => { const configPath = path.join(tmpRoot, "docs.config.json"); await writeFile( configPath, `${JSON.stringify( { + ...topLevel, sources: [ { id: "local", @@ -200,3 +201,65 @@ test("install fails when lock repo or ref does not match config", async () => { /Install failed: lock is out of date/i, ); }); + +test("install and lock-only ignore a missing OpenCode config", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-install-opencode-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = await writeConfig( + tmpRoot, + {}, + { opencode: { configPath: path.join(tmpRoot, "missing-opencode.json") } }, + ); + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + const resolveRemoteCommit = async () => ({ + repo: "https://example.com/repo.git", + ref: "main", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + + await runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: true, + offline: false, + failOnMiss: false, + }, + { resolveRemoteCommit }, + ); + + await runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + install: true, + }, + { + fetchSource: async () => ({ + repoDir, + cleanup: async () => undefined, + }), + materializeSource: async ({ cacheDir: outputRoot, sourceId }) => { + const outputDir = path.join(outputRoot, sourceId); + await mkdir(outputDir, { recursive: true }); + await writeFile(path.join(outputDir, "README.md"), "hello", "utf8"); + await writeFile( + path.join(outputDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + return { bytes: 5, fileCount: 1, manifestSha256: "manifest" }; + }, + }, + ); +}); diff --git a/tests/opencode-detection.test.js b/tests/opencode-detection.test.js new file mode 100644 index 0000000..936ba18 --- /dev/null +++ b/tests/opencode-detection.test.js @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + detectOpenCodeConfig, + getOpenCodeConfigCandidates, +} from "../dist/api.mjs"; + +test("OpenCode detection gives project JSON precedence over JSONC and global config", async () => { + const root = path.join( + tmpdir(), + `docs-cache-opencode-detect-${Date.now().toString(36)}`, + ); + const openCodeDir = path.join(root, ".opencode"); + await mkdir(openCodeDir, { recursive: true }); + await writeFile(path.join(root, ".git"), "gitdir: /tmp/unused\n", "utf8"); + await writeFile(path.join(root, "opencode.json"), "{}\n", "utf8"); + await writeFile(path.join(root, "opencode.jsonc"), "{}\n", "utf8"); + await writeFile(path.join(openCodeDir, "opencode.json"), "{}\n", "utf8"); + const expected = path.join(openCodeDir, "opencode.json"); + const globalConfigDir = path.join( + tmpdir(), + `docs-cache-opencode-global-${Date.now().toString(36)}`, + ); + const globalConfigPath = path.join(globalConfigDir, "opencode.jsonc"); + await writeFile(expected, "{}\n", "utf8"); + await mkdir(globalConfigDir, { recursive: true }); + await writeFile(globalConfigPath, "{}\n", "utf8"); + + const previousCustomDir = process.env.OPENCODE_CONFIG_DIR; + process.env.OPENCODE_CONFIG_DIR = globalConfigDir; + try { + assert.equal(await detectOpenCodeConfig(root), expected); + } finally { + if (previousCustomDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = previousCustomDir; + } + } +}); + +test("OpenCode detection disables project config only for true or 1", async () => { + const root = path.join( + tmpdir(), + `docs-cache-opencode-disable-${Date.now().toString(36)}`, + ); + const project = path.join(root, "project"); + const home = path.join(root, "home"); + await mkdir(project, { recursive: true }); + await mkdir(home, { recursive: true }); + await writeFile(path.join(project, ".git"), "gitdir: /tmp/unused\n", "utf8"); + const configPath = path.join(project, "opencode.json"); + await writeFile(configPath, "{}\n", "utf8"); + + const previousHome = process.env.HOME; + const previousValue = process.env.OPENCODE_DISABLE_PROJECT_CONFIG; + process.env.HOME = home; + try { + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "false"; + assert.ok( + (await getOpenCodeConfigCandidates(project)).includes(configPath), + ); + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "0"; + assert.ok( + (await getOpenCodeConfigCandidates(project)).includes(configPath), + ); + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; + assert.ok( + !(await getOpenCodeConfigCandidates(project)).includes(configPath), + ); + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"; + assert.ok( + !(await getOpenCodeConfigCandidates(project)).includes(configPath), + ); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousValue === undefined) { + delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG; + } else { + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previousValue; + } + } +}); diff --git a/tests/opencode-references.test.js b/tests/opencode-references.test.js new file mode 100644 index 0000000..ec3dc73 --- /dev/null +++ b/tests/opencode-references.test.js @@ -0,0 +1,463 @@ +import assert from "node:assert/strict"; +import { + lstat, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { parse } from "jsonc-parser"; + +import { planOpenCodeReferences, runSync } from "../dist/api.mjs"; + +process.env.XDG_STATE_HOME = path.join( + tmpdir(), + `docs-cache-opencode-state-${Date.now()}-${Math.random()}`, +); + +const createRoot = async (name) => { + const root = path.join( + tmpdir(), + `docs-cache-opencode-${name}-${Date.now()}-${Math.random()}`, + ); + await mkdir(root, { recursive: true }); + return root; +}; + +const writeDocsConfig = async (root, sources, opencode) => { + const configPath = path.join(root, "docs.config.json"); + const projectOpenCode = + opencode && typeof opencode === "object" + ? { configPath: path.relative(root, opencode.configPath) } + : opencode; + await writeFile( + configPath, + `${JSON.stringify({ opencode: projectOpenCode, sources }, null, 2)}\n`, + "utf8", + ); + return configPath; +}; + +const sync = async (configPath, cacheDir, options = {}) => { + const { writeToc, ...syncOptions } = options; + const repoDir = path.join(path.dirname(configPath), "repo"); + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + return runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + ...syncOptions, + }, + { + resolveRemoteCommit: async ({ repo }) => ({ + repo, + ref: "HEAD", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + fetchSource: async () => ({ + repoDir, + cleanup: async () => undefined, + fromCache: false, + }), + materializeSource: async ({ cacheDir: outputRoot, sourceId }) => { + const outputDir = path.join(outputRoot, sourceId); + await mkdir(outputDir, { recursive: true }); + await writeFile(path.join(outputDir, "README.md"), "hello", "utf8"); + await writeFile( + path.join(outputDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + "utf8", + ); + return { bytes: 5, fileCount: 1, manifestSha256: "manifest" }; + }, + writeToc, + }, + ); +}; + +const referencePath = (openCodePath, cacheDir, id) => + path + .relative(path.dirname(openCodePath), path.join(cacheDir, id)) + .split(path.sep) + .join("/"); + +test("sync creates canonical OpenCode references and preserves JSONC", async () => { + const root = await createRoot("create"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.jsonc"); + await writeFile( + openCodePath, + `{ + // Keep this user-owned configuration. + "model": "test/model", + "references": { + "user-docs": { "path": "/user/docs", "description": "User docs" }, + }, +} +`, + "utf8", + ); + const configPath = await writeDocsConfig( + root, + [ + { + id: "hyprland-wiki", + repo: "https://github.com/hyprwm/hyprland-wiki.git", + targetDir: "./unused-target", + }, + ], + true, + ); + + await sync(configPath, cacheDir); + + const raw = await readFile(openCodePath, "utf8"); + const config = parse(raw); + assert.match(raw, /Keep this user-owned configuration/); + assert.equal(config.model, "test/model"); + assert.deepEqual(config.references["user-docs"], { + path: "/user/docs", + description: "User docs", + }); + assert.deepEqual(config.references["hyprland-wiki"], { + path: referencePath(openCodePath, cacheDir, "hyprland-wiki"), + description: + "Use for documentation from hyprwm/hyprland-wiki. Start with TOC.md.", + }); + + const lock = JSON.parse( + await readFile(path.join(root, "docs-lock.json"), "utf8"), + ); + assert.deepEqual(lock.opencode, { + configPath: "opencode.jsonc", + aliases: ["hyprland-wiki"], + }); +}); + +test("sync updates managed reference paths when the cache directory changes", async () => { + const root = await createRoot("update"); + const openCodePath = path.join(root, "opencode.json"); + await writeFile(openCodePath, "{}\n", "utf8"); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git", toc: false }], + { configPath: openCodePath }, + ); + const firstCacheDir = path.join(root, ".docs-one"); + const secondCacheDir = path.join(root, ".docs-two"); + + await sync(configPath, firstCacheDir); + await sync(configPath, secondCacheDir); + + const config = JSON.parse(await readFile(openCodePath, "utf8")); + assert.deepEqual(config.references.docs, { + path: referencePath(openCodePath, secondCacheDir, "docs"), + description: "Use for documentation from example/docs.", + }); +}); + +test("sync preserves a symlinked OpenCode config", { + skip: process.platform === "win32", +}, async () => { + const root = await createRoot("symlink"); + const cacheDir = path.join(root, ".docs"); + const targetPath = path.join(root, "managed-opencode.jsonc"); + const openCodePath = path.join(root, "opencode.jsonc"); + await writeFile(targetPath, "{}\n", "utf8"); + await symlink(path.basename(targetPath), openCodePath); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: openCodePath }, + ); + + await sync(configPath, cacheDir); + + assert.equal((await lstat(openCodePath)).isSymbolicLink(), true); + const config = parse(await readFile(targetPath, "utf8")); + assert.deepEqual(config.references.docs, { + path: referencePath(openCodePath, cacheDir, "docs"), + description: "Use for documentation from example/docs. Start with TOC.md.", + }); +}); + +test("sync recognizes managed references through alternate config paths", { + skip: process.platform === "win32", +}, async () => { + const root = await createRoot("alternate-path"); + const cacheDir = path.join(root, ".docs"); + const targetPath = path.join(root, "managed-opencode.jsonc"); + const symlinkPath = path.join(root, "opencode.jsonc"); + await writeFile(targetPath, "{}\n", "utf8"); + await symlink(path.basename(targetPath), symlinkPath); + const sources = [{ id: "docs", repo: "https://github.com/example/docs.git" }]; + const configPath = await writeDocsConfig(root, sources, { + configPath: symlinkPath, + }); + + await sync(configPath, cacheDir); + await writeDocsConfig(root, sources, { configPath: targetPath }); + await sync(configPath, cacheDir); + + const config = parse(await readFile(targetPath, "utf8")); + assert.deepEqual(config.references.docs, { + path: referencePath(symlinkPath, cacheDir, "docs"), + description: "Use for documentation from example/docs. Start with TOC.md.", + }); +}); + +test("sync fails before overwriting a user-owned OpenCode reference", async () => { + const root = await createRoot("collision"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.json"); + const original = `{ + "references": { + "docs": { "path": "/user/docs" } + } +} +`; + await writeFile(openCodePath, original, "utf8"); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: openCodePath }, + ); + + await assert.rejects( + () => sync(configPath, cacheDir), + /OpenCode reference alias collision.*docs/i, + ); + assert.equal(await readFile(openCodePath, "utf8"), original); +}); + +test("sync does not trust lock aliases as OpenCode ownership", async () => { + const root = await createRoot("forged-lock"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.json"); + await writeFile( + openCodePath, + `{ "references": { "docs": { "path": "/user/docs" } } }\n`, + "utf8", + ); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: openCodePath }, + ); + await writeFile( + path.join(root, "docs-lock.json"), + `${JSON.stringify({ + version: 1, + toolVersion: "0.0.0", + sources: { + docs: { + repo: "https://github.com/example/docs.git", + ref: "HEAD", + resolvedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + bytes: 0, + fileCount: 0, + manifestSha256: "manifest", + }, + }, + opencode: { configPath: openCodePath, aliases: ["docs"] }, + })}\n`, + "utf8", + ); + + await assert.rejects( + () => sync(configPath, cacheDir), + /OpenCode reference alias collision.*docs/i, + ); +}); + +test("sync removes stale managed aliases without removing user references", async () => { + const root = await createRoot("stale"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.jsonc"); + await writeFile( + openCodePath, + `{ + "references": { + "user": { "path": "/user" }, + }, +} +`, + "utf8", + ); + const opencode = { configPath: openCodePath }; + const configPath = await writeDocsConfig( + root, + [ + { id: "keep", repo: "https://github.com/example/keep.git" }, + { id: "remove", repo: "https://github.com/example/remove.git" }, + ], + opencode, + ); + + await sync(configPath, cacheDir); + await writeDocsConfig( + root, + [{ id: "keep", repo: "https://github.com/example/keep.git" }], + opencode, + ); + await sync(configPath, cacheDir); + + const config = parse(await readFile(openCodePath, "utf8")); + assert.ok(config.references.user); + assert.ok(config.references.keep); + assert.equal(config.references.remove, undefined); +}); + +test("sync --frozen detects reference drift without writing OpenCode config", async () => { + const root = await createRoot("frozen"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.json"); + await writeFile(openCodePath, "{}\n", "utf8"); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: openCodePath }, + ); + await sync(configPath, cacheDir); + const drifted = `{ + "references": { + "docs": { + "path": "/wrong/path", + "description": "wrong" + } + } +} +`; + await writeFile(openCodePath, drifted, "utf8"); + + await assert.rejects( + () => sync(configPath, cacheDir, { frozen: true }), + /Frozen sync failed: OpenCode references are out of date.*docs/i, + ); + assert.equal(await readFile(openCodePath, "utf8"), drifted); +}); + +test("sync removes managed references from a previously selected OpenCode config", async () => { + const root = await createRoot("move"); + const cacheDir = path.join(root, ".docs"); + const firstOpenCodePath = path.join(root, "first-opencode.json"); + const secondOpenCodePath = path.join(root, "second-opencode.json"); + await writeFile(firstOpenCodePath, "{}\n", "utf8"); + await writeFile(secondOpenCodePath, "{}\n", "utf8"); + const sources = [{ id: "docs", repo: "https://github.com/example/docs.git" }]; + const configPath = await writeDocsConfig(root, sources, { + configPath: firstOpenCodePath, + }); + await sync(configPath, cacheDir); + await writeDocsConfig(root, sources, { configPath: secondOpenCodePath }); + await sync(configPath, cacheDir); + + const first = JSON.parse(await readFile(firstOpenCodePath, "utf8")); + const second = JSON.parse(await readFile(secondOpenCodePath, "utf8")); + assert.equal(first.references.docs, undefined); + assert.deepEqual(second.references.docs, { + path: referencePath(secondOpenCodePath, cacheDir, "docs"), + description: "Use for documentation from example/docs. Start with TOC.md.", + }); +}); + +test("sync leaves existing references unchanged after integration is disabled", async () => { + const root = await createRoot("disabled"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.json"); + await writeFile(openCodePath, "{}\n", "utf8"); + const sources = [{ id: "docs", repo: "https://github.com/example/docs.git" }]; + const configPath = await writeDocsConfig(root, sources, { + configPath: openCodePath, + }); + await sync(configPath, cacheDir); + const before = await readFile(openCodePath, "utf8"); + await writeDocsConfig(root, sources, false); + await sync(configPath, cacheDir); + + assert.equal(await readFile(openCodePath, "utf8"), before); + const lock = JSON.parse( + await readFile(path.join(root, "docs-lock.json"), "utf8"), + ); + assert.deepEqual(lock.opencode, { + configPath: "opencode.json", + aliases: ["docs"], + }); + await writeDocsConfig(root, sources, { configPath: openCodePath }); + await sync(configPath, cacheDir); +}); + +test("sync fails when the remembered OpenCode config no longer exists", async () => { + const root = await createRoot("missing"); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: path.join(root, "missing-opencode.json") }, + ); + + await assert.rejects( + () => sync(configPath, path.join(root, ".docs")), + /Configured OpenCode config not found/i, + ); +}); + +test("OpenCode reference writes restore earlier files when a later write fails", async () => { + const root = await createRoot("reference-rollback"); + const firstOpenCodePath = path.join(root, "first-opencode.json"); + const secondOpenCodePath = path.join(root, "second-opencode.json"); + const configPath = path.join(root, "docs.config.json"); + const original = `{ "references": { "docs": { "path": "/old" } } }\n`; + await writeFile(firstOpenCodePath, original, "utf8"); + await writeFile(secondOpenCodePath, "{}\n", "utf8"); + await writeFile(configPath, `${JSON.stringify({ sources: [] })}\n`, "utf8"); + + const plan = await planOpenCodeReferences({ + opencode: { configPath: "second-opencode.json" }, + ownership: { configPath: firstOpenCodePath, aliases: ["docs"] }, + sources: [{ id: "docs", repo: "https://github.com/example/docs.git" }], + cacheDir: path.join(root, ".docs"), + configPath, + }); + await rm(secondOpenCodePath); + await mkdir(secondOpenCodePath); + + await assert.rejects(() => plan.apply()); + assert.equal(await readFile(firstOpenCodePath, "utf8"), original); +}); + +test("sync leaves OpenCode and lock state unchanged when TOC generation fails", async () => { + const root = await createRoot("toc-rollback"); + const cacheDir = path.join(root, ".docs"); + const openCodePath = path.join(root, "opencode.json"); + await writeFile(openCodePath, "{}\n", "utf8"); + const configPath = await writeDocsConfig( + root, + [{ id: "docs", repo: "https://github.com/example/docs.git" }], + { configPath: openCodePath }, + ); + const before = await readFile(openCodePath, "utf8"); + + await assert.rejects( + () => + sync(configPath, cacheDir, { + writeToc: async () => { + throw new Error("TOC write failed"); + }, + }), + /TOC write failed/, + ); + assert.equal(await readFile(openCodePath, "utf8"), before); + await assert.rejects( + () => readFile(path.join(root, "docs-lock.json"), "utf8"), + /ENOENT/, + ); +}); diff --git a/tests/schema.test.js b/tests/schema.test.js index 8978d16..db4b5fa 100644 --- a/tests/schema.test.js +++ b/tests/schema.test.js @@ -12,5 +12,10 @@ test("docs config schema has required top-level keys", async () => { assert.ok(schema.properties?.$schema); assert.ok(schema.properties?.cacheDir); assert.ok(schema.properties?.defaults); + assert.ok(schema.properties?.opencode); assert.ok(schema.properties?.sources); + assert.equal( + schema.properties?.sources?.items?.properties?.opencodeDescription, + undefined, + ); }); diff --git a/tsconfig.json b/tsconfig.json index de0055b..ea530dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "#config": ["./src/config/index.ts"], "#config/*": ["./src/config/*.ts"], "#git/*": ["./src/git/*.ts"], + "#opencode/*": ["./src/opencode/*.ts"], "#types/*": ["./src/types/*.ts"] }, "strict": true,