@gitpagedocs/tools 1.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +33 -0
  2. package/package.json +37 -0
  3. package/src/ai/bootstrap.ts +50 -0
  4. package/src/ai/catalog.ts +188 -0
  5. package/src/ai/factory.ts +14 -0
  6. package/src/ai/http/streaming.ts +60 -0
  7. package/src/ai/index.ts +27 -0
  8. package/src/ai/legacy-adapter.ts +33 -0
  9. package/src/ai/model-registry.ts +13 -0
  10. package/src/ai/providers/anthropic-provider.ts +83 -0
  11. package/src/ai/providers/cohere-provider.ts +66 -0
  12. package/src/ai/providers/gemini-provider.ts +84 -0
  13. package/src/ai/providers/ollama-provider.ts +69 -0
  14. package/src/ai/providers/openai-compatible-provider.ts +75 -0
  15. package/src/ai/providers/shared.ts +72 -0
  16. package/src/ai/registry.ts +29 -0
  17. package/src/cache/file-cache.ts +86 -0
  18. package/src/cache/index.ts +9 -0
  19. package/src/cache/memory-cache.ts +47 -0
  20. package/src/cache/web-storage-cache.ts +91 -0
  21. package/src/config/config-loader.ts +59 -0
  22. package/src/config/index.ts +1 -0
  23. package/src/constants/config.ts +8 -0
  24. package/src/constants/index.ts +1 -0
  25. package/src/crypto/index.ts +2 -0
  26. package/src/crypto/node-crypto-service.ts +87 -0
  27. package/src/crypto/web-crypto-service.ts +102 -0
  28. package/src/crypto/web.ts +2 -0
  29. package/src/documentation/doc-generator.ts +74 -0
  30. package/src/documentation/doc-updater.ts +47 -0
  31. package/src/documentation/index.ts +18 -0
  32. package/src/documentation/marker-patcher.ts +33 -0
  33. package/src/documentation/sections.ts +82 -0
  34. package/src/errors/app-error.ts +84 -0
  35. package/src/errors/index.ts +1 -0
  36. package/src/filesystem/file-service.ts +121 -0
  37. package/src/filesystem/index.ts +2 -0
  38. package/src/index.ts +24 -0
  39. package/src/logger/index.ts +2 -0
  40. package/src/logger/logger.ts +73 -0
  41. package/src/logger/redaction.ts +43 -0
  42. package/src/ports/ai.ts +109 -0
  43. package/src/ports/cache.ts +16 -0
  44. package/src/ports/config.ts +21 -0
  45. package/src/ports/crypto.ts +33 -0
  46. package/src/ports/index.ts +15 -0
  47. package/src/ports/logger.ts +23 -0
  48. package/src/ports/security.ts +42 -0
  49. package/src/security/credential-vault.ts +117 -0
  50. package/src/security/file-vault-storage.ts +25 -0
  51. package/src/security/index.ts +8 -0
  52. package/src/security/migrate-plaintext-key.ts +38 -0
  53. package/src/security/password-gate.ts +62 -0
  54. package/src/security/web-storage-vault-storage.ts +23 -0
  55. package/src/security/web.ts +9 -0
@@ -0,0 +1,87 @@
1
+ import {
2
+ createHash,
3
+ pbkdf2Sync,
4
+ randomBytes,
5
+ createCipheriv,
6
+ createDecipheriv,
7
+ timingSafeEqual,
8
+ } from "node:crypto";
9
+ import type { CryptoService, EncryptedPayload, KeyDerivationParams } from "../ports/crypto";
10
+ import { SecurityError } from "../errors/app-error";
11
+
12
+ const KEY_LENGTH = 32; // AES-256
13
+ const IV_LENGTH = 12; // GCM nonce
14
+ const DEFAULT_ITERATIONS = 210_000; // OWASP PBKDF2-SHA256 guidance
15
+ const DIGEST = "sha256";
16
+
17
+ /** Node.js implementation of the CryptoService port (CLI + MCP). */
18
+ export class NodeCryptoService implements CryptoService {
19
+ constructor(private readonly iterations: number = DEFAULT_ITERATIONS) {}
20
+
21
+ async sha256(input: string): Promise<string> {
22
+ return createHash("sha256").update(input, "utf8").digest("hex");
23
+ }
24
+
25
+ async deriveKey(password: string, params: KeyDerivationParams): Promise<Uint8Array> {
26
+ const salt = Buffer.from(params.salt, "base64");
27
+ return new Uint8Array(pbkdf2Sync(password, salt, params.iterations, KEY_LENGTH, DIGEST));
28
+ }
29
+
30
+ async encrypt(plaintext: string, password: string): Promise<EncryptedPayload> {
31
+ const kdf: KeyDerivationParams = {
32
+ salt: randomBytes(16).toString("base64"),
33
+ iterations: this.iterations,
34
+ };
35
+ const key = await this.deriveKey(password, kdf);
36
+ const iv = randomBytes(IV_LENGTH);
37
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
38
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
39
+ const authTag = cipher.getAuthTag();
40
+ this.wipe(key);
41
+ return {
42
+ ciphertext: encrypted.toString("base64"),
43
+ iv: iv.toString("base64"),
44
+ authTag: authTag.toString("base64"),
45
+ kdf,
46
+ };
47
+ }
48
+
49
+ async decrypt(payload: EncryptedPayload, password: string): Promise<string> {
50
+ if (!payload.authTag) {
51
+ throw new SecurityError("Missing authentication tag for AES-256-GCM payload.");
52
+ }
53
+ const key = await this.deriveKey(password, payload.kdf);
54
+ try {
55
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(payload.iv, "base64"));
56
+ decipher.setAuthTag(Buffer.from(payload.authTag, "base64"));
57
+ const decrypted = Buffer.concat([
58
+ decipher.update(Buffer.from(payload.ciphertext, "base64")),
59
+ decipher.final(),
60
+ ]);
61
+ return decrypted.toString("utf8");
62
+ } catch (cause) {
63
+ // Wrong password or tampered ciphertext both surface here.
64
+ throw new SecurityError("Failed to decrypt payload (wrong password or corrupted data).", { cause });
65
+ } finally {
66
+ this.wipe(key);
67
+ }
68
+ }
69
+
70
+ mask(secret: string): string {
71
+ if (!secret) return "";
72
+ if (secret.length <= 8) return "…";
73
+ return `${secret.slice(0, 3)}…${secret.slice(-4)}`;
74
+ }
75
+
76
+ wipe(buffer: Uint8Array): void {
77
+ buffer.fill(0);
78
+ }
79
+ }
80
+
81
+ /** Constant-time comparison of two hex digests. */
82
+ export function safeHexEqual(a: string, b: string): boolean {
83
+ const bufA = Buffer.from(a, "hex");
84
+ const bufB = Buffer.from(b, "hex");
85
+ if (bufA.length !== bufB.length) return false;
86
+ return timingSafeEqual(bufA, bufB);
87
+ }
@@ -0,0 +1,102 @@
1
+ import type { CryptoService, EncryptedPayload, KeyDerivationParams } from "../ports/crypto";
2
+ import { SecurityError } from "../errors/app-error";
3
+
4
+ const KEY_BITS = 256;
5
+ const IV_LENGTH = 12;
6
+ const DEFAULT_ITERATIONS = 210_000;
7
+
8
+ function toBase64(bytes: Uint8Array): string {
9
+ let binary = "";
10
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
11
+ return btoa(binary);
12
+ }
13
+
14
+ function fromBase64(b64: string): Uint8Array<ArrayBuffer> {
15
+ const binary = atob(b64);
16
+ const out = new Uint8Array(binary.length);
17
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
18
+ return out;
19
+ }
20
+
21
+ // Web Crypto args must be ArrayBuffer-backed (BufferSource) under DOM lib; the
22
+ // cast keeps this file valid under both the Node-typed and DOM-typed programs.
23
+ function enc(text: string): Uint8Array<ArrayBuffer> {
24
+ return new TextEncoder().encode(text) as Uint8Array<ArrayBuffer>;
25
+ }
26
+
27
+ /**
28
+ * Web Crypto (SubtleCrypto) implementation of CryptoService for the browser.
29
+ * Also runs under Node 22 (globalThis.crypto). AES-GCM keeps the auth tag inside
30
+ * the ciphertext, so a payload encrypted here is decrypted here.
31
+ */
32
+ export class WebCryptoService implements CryptoService {
33
+ constructor(private readonly iterations: number = DEFAULT_ITERATIONS) {}
34
+
35
+ // Return type is inferred (SubtleCrypto) to avoid pulling DOM lib type names
36
+ // into this Node-typed package.
37
+ private get subtle() {
38
+ const c = globalThis.crypto;
39
+ if (!c?.subtle) throw new SecurityError("Web Crypto API is unavailable in this runtime.");
40
+ return c.subtle;
41
+ }
42
+
43
+ async sha256(input: string): Promise<string> {
44
+ const digest = await this.subtle.digest("SHA-256", enc(input));
45
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
46
+ }
47
+
48
+ async deriveKey(password: string, params: KeyDerivationParams): Promise<Uint8Array> {
49
+ const bits = await this.deriveBits(password, params);
50
+ return new Uint8Array(bits);
51
+ }
52
+
53
+ private async deriveBits(password: string, params: KeyDerivationParams): Promise<ArrayBuffer> {
54
+ const baseKey = await this.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
55
+ return this.subtle.deriveBits(
56
+ { name: "PBKDF2", salt: fromBase64(params.salt), iterations: params.iterations, hash: "SHA-256" },
57
+ baseKey,
58
+ KEY_BITS,
59
+ );
60
+ }
61
+
62
+ // Returns an AES-GCM CryptoKey (type inferred to avoid DOM lib names).
63
+ private async aesKey(password: string, params: KeyDerivationParams) {
64
+ const bits = await this.deriveBits(password, params);
65
+ return this.subtle.importKey("raw", bits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
66
+ }
67
+
68
+ async encrypt(plaintext: string, password: string): Promise<EncryptedPayload> {
69
+ const kdf: KeyDerivationParams = {
70
+ salt: toBase64(globalThis.crypto.getRandomValues(new Uint8Array(16))),
71
+ iterations: this.iterations,
72
+ };
73
+ const iv: Uint8Array<ArrayBuffer> = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
74
+ const key = await this.aesKey(password, kdf);
75
+ const encrypted = await this.subtle.encrypt({ name: "AES-GCM", iv }, key, enc(plaintext));
76
+ return { ciphertext: toBase64(new Uint8Array(encrypted)), iv: toBase64(iv), kdf };
77
+ }
78
+
79
+ async decrypt(payload: EncryptedPayload, password: string): Promise<string> {
80
+ const key = await this.aesKey(password, payload.kdf);
81
+ try {
82
+ const decrypted = await this.subtle.decrypt(
83
+ { name: "AES-GCM", iv: fromBase64(payload.iv) },
84
+ key,
85
+ fromBase64(payload.ciphertext),
86
+ );
87
+ return new TextDecoder().decode(decrypted);
88
+ } catch (cause) {
89
+ throw new SecurityError("Failed to decrypt payload (wrong password or corrupted data).", { cause });
90
+ }
91
+ }
92
+
93
+ mask(secret: string): string {
94
+ if (!secret) return "";
95
+ if (secret.length <= 8) return "…";
96
+ return `${secret.slice(0, 3)}…${secret.slice(-4)}`;
97
+ }
98
+
99
+ wipe(buffer: Uint8Array): void {
100
+ buffer.fill(0);
101
+ }
102
+ }
@@ -0,0 +1,2 @@
1
+ // Browser-safe crypto entry: Web Crypto only (no node:crypto).
2
+ export { WebCryptoService } from "./web-crypto-service";
@@ -0,0 +1,74 @@
1
+ import type { AIProvider, GenerateRequest, ProviderConfig } from "../ports/ai";
2
+
3
+ /** Documentation artifact kinds the generator can produce. */
4
+ export type DocKind =
5
+ | "readme"
6
+ | "changelog"
7
+ | "release-notes"
8
+ | "api"
9
+ | "architecture"
10
+ | "database"
11
+ | "documentation"
12
+ | "update"
13
+ | "analyze-repository"
14
+ | "analyze-project"
15
+ | "analyze-source"
16
+ | "validate";
17
+
18
+ const BASE_SYSTEM =
19
+ "You are a senior staff engineer and technical writer. Produce precise, " +
20
+ "well-structured GitHub-flavored Markdown. Be accurate to the provided source; " +
21
+ "never invent APIs, files, or behavior that is not present in the input.";
22
+
23
+ const KIND_SYSTEM: Record<DocKind, string> = {
24
+ readme: "Generate a complete README.md: title, summary, features, install, usage, configuration, and license.",
25
+ changelog: "Produce a Keep a Changelog style CHANGELOG entry grouping changes under Added/Changed/Fixed/Removed.",
26
+ "release-notes": "Write concise, user-facing release notes highlighting notable changes and breaking changes.",
27
+ api: "Generate API reference documentation for the exported functions, classes, and types in the input.",
28
+ architecture: "Describe the architecture: layers, modules, data flow, and key design decisions. Use diagrams in text form where helpful.",
29
+ database: "Document the data model: entities, fields, relationships, and constraints inferred from the input.",
30
+ documentation: "Generate clear developer documentation for the provided code.",
31
+ update: "Update the existing documentation to reflect the provided source changes, preserving correct content.",
32
+ "analyze-repository": "Analyze the repository structure and summarize purpose, stack, modules, and notable patterns.",
33
+ "analyze-project": "Analyze the project and summarize architecture, dependencies, and areas of risk or improvement.",
34
+ "analyze-source": "Analyze the provided source code: responsibilities, complexity, issues, and suggestions.",
35
+ validate: "Review the documentation for accuracy, completeness, broken references, and gaps. Return a findings list.",
36
+ };
37
+
38
+ export interface GenerateDocInput {
39
+ readonly kind: DocKind;
40
+ /** Source/context material (file listing, code, existing docs, …). */
41
+ readonly context: string;
42
+ /** Optional extra user instructions. */
43
+ readonly instructions?: string;
44
+ readonly signal?: AbortSignal;
45
+ }
46
+
47
+ /**
48
+ * Generates documentation by composing a kind-specific prompt and delegating to
49
+ * any AIProvider. The single place documentation prompts live, shared by the
50
+ * CLI, MCP and frontend.
51
+ */
52
+ export class DocumentationService {
53
+ constructor(
54
+ private readonly provider: AIProvider,
55
+ private readonly config: ProviderConfig,
56
+ ) {}
57
+
58
+ buildRequest(input: GenerateDocInput): GenerateRequest {
59
+ const system = `${BASE_SYSTEM}\n${KIND_SYSTEM[input.kind]}`;
60
+ const userParts = [input.instructions, "--- INPUT ---", input.context].filter(Boolean);
61
+ return {
62
+ system,
63
+ messages: [{ role: "user", content: userParts.join("\n\n") }],
64
+ temperature: 0.2,
65
+ maxTokens: 4000,
66
+ signal: input.signal,
67
+ };
68
+ }
69
+
70
+ async generate(input: GenerateDocInput): Promise<string> {
71
+ const response = await this.provider.generate(this.buildRequest(input), this.config);
72
+ return response.text;
73
+ }
74
+ }
@@ -0,0 +1,47 @@
1
+ import type { FileService } from "../filesystem/file-service";
2
+ import { patchManagedRegion } from "./marker-patcher";
3
+
4
+ export interface ManagedUpdate {
5
+ /** Target file relative to the project root. */
6
+ readonly path: string;
7
+ /** Generated content for the managed region (markers added automatically). */
8
+ readonly generated: string;
9
+ }
10
+
11
+ export interface UpdateResult {
12
+ readonly path: string;
13
+ /** True if an existing managed region was replaced (vs appended). */
14
+ readonly replaced: boolean;
15
+ /** True if the file content actually changed (idempotent re-runs are false). */
16
+ readonly changed: boolean;
17
+ }
18
+
19
+ /**
20
+ * Updates only the managed region of documentation files, preserving manual
21
+ * content. Idempotent: re-running with identical generated content leaves files
22
+ * untouched (`changed: false`). Built on patchManagedRegion + FileService.
23
+ */
24
+ export class DocUpdater {
25
+ constructor(private readonly files: FileService) {}
26
+
27
+ async updateManagedFile(path: string, generated: string): Promise<UpdateResult> {
28
+ let existing = "";
29
+ try {
30
+ existing = await this.files.read(path, 2_000_000);
31
+ } catch {
32
+ existing = "";
33
+ }
34
+ const { content, replaced } = patchManagedRegion(existing, generated);
35
+ const changed = content !== existing;
36
+ if (changed) await this.files.write(path, content);
37
+ return { path, replaced, changed };
38
+ }
39
+
40
+ async updateManagedFiles(updates: ManagedUpdate[]): Promise<UpdateResult[]> {
41
+ const results: UpdateResult[] = [];
42
+ for (const update of updates) {
43
+ results.push(await this.updateManagedFile(update.path, update.generated));
44
+ }
45
+ return results;
46
+ }
47
+ }
@@ -0,0 +1,18 @@
1
+ export { DocumentationService } from "./doc-generator";
2
+ export type { DocKind, GenerateDocInput } from "./doc-generator";
3
+ export {
4
+ patchManagedRegion,
5
+ START_MARKER,
6
+ END_MARKER,
7
+ } from "./marker-patcher";
8
+ export type { PatchResult } from "./marker-patcher";
9
+ export { DocUpdater } from "./doc-updater";
10
+ export type { ManagedUpdate, UpdateResult } from "./doc-updater";
11
+ export {
12
+ providersSection,
13
+ cliCommandsSection,
14
+ devWorkflowSection,
15
+ securityNoteSection,
16
+ CLI_COMMANDS,
17
+ } from "./sections";
18
+ export type { CommandDoc } from "./sections";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Idempotent marker-based content patcher. Only the region between the markers
3
+ * is rewritten; manual content outside is preserved. Used by update flows and
4
+ * the Phase 9 documentation automation.
5
+ */
6
+ export const START_MARKER = "<!-- gitpagedocs:start -->";
7
+ export const END_MARKER = "<!-- gitpagedocs:end -->";
8
+
9
+ export interface PatchResult {
10
+ readonly content: string;
11
+ /** True when an existing managed region was replaced (vs appended). */
12
+ readonly replaced: boolean;
13
+ }
14
+
15
+ /**
16
+ * Replace the managed region in `existing` with `generated`. If no markers are
17
+ * present, the managed block is appended. Re-running with the same generated
18
+ * content yields identical output (idempotent).
19
+ */
20
+ export function patchManagedRegion(existing: string, generated: string): PatchResult {
21
+ const block = `${START_MARKER}\n${generated.trim()}\n${END_MARKER}`;
22
+ const startIdx = existing.indexOf(START_MARKER);
23
+ const endIdx = existing.indexOf(END_MARKER);
24
+
25
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
26
+ const before = existing.slice(0, startIdx);
27
+ const after = existing.slice(endIdx + END_MARKER.length);
28
+ return { content: `${before}${block}${after}`, replaced: true };
29
+ }
30
+
31
+ const separator = existing.trim() ? `${existing.replace(/\s+$/, "")}\n\n` : "";
32
+ return { content: `${separator}${block}\n`, replaced: false };
33
+ }
@@ -0,0 +1,82 @@
1
+ import { PROVIDER_CATALOG, ALL_PROVIDER_IDS } from "../ai/catalog";
2
+
3
+ /**
4
+ * Deterministic documentation section generators. Pure functions over the
5
+ * static catalog/command data, so the managed regions they produce are stable
6
+ * across runs (idempotent) and require no AI call.
7
+ */
8
+
9
+ /** Markdown table of supported AI providers and their default models. */
10
+ export function providersSection(): string {
11
+ const header = "| Provider | ID | Default model | Capabilities |\n| --- | --- | --- | --- |";
12
+ const rows = ALL_PROVIDER_IDS.map((id) => {
13
+ const spec = PROVIDER_CATALOG[id];
14
+ const caps = [
15
+ spec.capabilities.streaming ? "stream" : null,
16
+ spec.capabilities.vision ? "vision" : null,
17
+ spec.capabilities.audio ? "audio" : null,
18
+ ]
19
+ .filter(Boolean)
20
+ .join(", ");
21
+ return `| ${spec.label} | \`${id}\` | \`${spec.defaultModel}\` | ${caps || "text"} |`;
22
+ });
23
+ return [`### Supported AI providers (${ALL_PROVIDER_IDS.length})`, "", header, ...rows].join("\n");
24
+ }
25
+
26
+ export interface CommandDoc {
27
+ readonly name: string;
28
+ readonly summary: string;
29
+ }
30
+
31
+ /** Markdown list of CLI commands. */
32
+ export function cliCommandsSection(commands: readonly CommandDoc[]): string {
33
+ const items = commands.map((c) => `- \`gitpagedocs ${c.name}\` — ${c.summary}`);
34
+ return ["### CLI commands", "", ...items].join("\n");
35
+ }
36
+
37
+ /** Markdown block documenting the monorepo dev workflow (deterministic). */
38
+ export function devWorkflowSection(): string {
39
+ const cmds = [
40
+ ["pnpm install", "install workspace dependencies"],
41
+ ["pnpm run typecheck", "type-check frontend + tools + mcp"],
42
+ ["pnpm run lint", "lint the project"],
43
+ ["pnpm run test:unit", "run the Vitest unit/integration suite"],
44
+ ["pnpm run test:cov", "unit tests with coverage"],
45
+ ["pnpm run test:e2e", "Playwright frontend E2E"],
46
+ ["pnpm run smoke:all", "CLI/contract/tools/mcp regression wall"],
47
+ ["pnpm run build", "build the static site"],
48
+ ];
49
+ return [
50
+ "### Development",
51
+ "",
52
+ "This is a pnpm + turbo monorepo: `frontend/` (Next.js viewer), `cli/` (the published `gitpagedocs` npm package), `tools/` (`@gitpagedocs/tools` shared core), and `mcp/` (`@gitpagedocs/mcp` server).",
53
+ "",
54
+ ...cmds.map(([c, d]) => `- \`${c}\` — ${d}`),
55
+ ].join("\n");
56
+ }
57
+
58
+ /** Markdown block summarizing how API keys are handled (deterministic). */
59
+ export function securityNoteSection(): string {
60
+ return [
61
+ "### API key handling",
62
+ "",
63
+ "- API keys are stored **encrypted at rest** (AES-256-GCM) behind a local password — never in plaintext.",
64
+ "- The password gates sensitive operations and is held only in memory for the session.",
65
+ "- Keys are **never logged** (the logger redacts secrets) and are sent only to the AI provider you select.",
66
+ "- To report a vulnerability, open a security advisory or issue on the repository.",
67
+ ].join("\n");
68
+ }
69
+
70
+ /** The canonical command list for the documentation (legacy + new verbs). */
71
+ export const CLI_COMMANDS: readonly CommandDoc[] = [
72
+ { name: "init", summary: "scaffold gitpagedocs config files" },
73
+ { name: "config", summary: "show the resolved gitpagedocs config" },
74
+ { name: "provider [id]", summary: "list AI providers or show one" },
75
+ { name: "models [provider]", summary: "list catalog models" },
76
+ { name: "document[:repo|:file|:folder]", summary: "generate documentation with AI" },
77
+ { name: "deploy | pages", summary: "configure GitHub Pages via Actions and push" },
78
+ { name: "doctor", summary: "diagnose the environment" },
79
+ { name: "mcp start", summary: "start the MCP server over stdio" },
80
+ { name: "version", summary: "print the CLI version" },
81
+ { name: "update", summary: "show how to update the CLI" },
82
+ ];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Error taxonomy for @gitpagedocs/tools.
3
+ *
4
+ * Every domain error extends AppError so consumers can branch on a stable
5
+ * machine-readable `code` and surface `details` without leaking secrets.
6
+ */
7
+ export type ErrorCode =
8
+ | "APP_ERROR"
9
+ | "VALIDATION_ERROR"
10
+ | "PROVIDER_ERROR"
11
+ | "CONFIGURATION_ERROR"
12
+ | "DOCUMENTATION_ERROR"
13
+ | "REPOSITORY_ERROR"
14
+ | "SECURITY_ERROR"
15
+ | "CACHE_ERROR";
16
+
17
+ export interface AppErrorOptions {
18
+ /** Machine-readable, stable across versions. */
19
+ readonly code?: ErrorCode;
20
+ /** Underlying error, preserved for diagnostics. */
21
+ readonly cause?: unknown;
22
+ /** Structured, secret-free context. */
23
+ readonly details?: Record<string, unknown>;
24
+ }
25
+
26
+ export class AppError extends Error {
27
+ readonly code: ErrorCode;
28
+ readonly details?: Record<string, unknown>;
29
+
30
+ constructor(message: string, options: AppErrorOptions = {}) {
31
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
32
+ this.name = new.target.name;
33
+ this.code = options.code ?? "APP_ERROR";
34
+ this.details = options.details;
35
+ // Preserve prototype chain when targeting ES with downleveled classes.
36
+ Object.setPrototypeOf(this, new.target.prototype);
37
+ }
38
+ }
39
+
40
+ export class ValidationError extends AppError {
41
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
42
+ super(message, { ...options, code: "VALIDATION_ERROR" });
43
+ }
44
+ }
45
+
46
+ export class ProviderError extends AppError {
47
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
48
+ super(message, { ...options, code: "PROVIDER_ERROR" });
49
+ }
50
+ }
51
+
52
+ export class ConfigurationError extends AppError {
53
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
54
+ super(message, { ...options, code: "CONFIGURATION_ERROR" });
55
+ }
56
+ }
57
+
58
+ export class DocumentationError extends AppError {
59
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
60
+ super(message, { ...options, code: "DOCUMENTATION_ERROR" });
61
+ }
62
+ }
63
+
64
+ export class RepositoryError extends AppError {
65
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
66
+ super(message, { ...options, code: "REPOSITORY_ERROR" });
67
+ }
68
+ }
69
+
70
+ export class SecurityError extends AppError {
71
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
72
+ super(message, { ...options, code: "SECURITY_ERROR" });
73
+ }
74
+ }
75
+
76
+ export class CacheError extends AppError {
77
+ constructor(message: string, options: Omit<AppErrorOptions, "code"> = {}) {
78
+ super(message, { ...options, code: "CACHE_ERROR" });
79
+ }
80
+ }
81
+
82
+ export function isAppError(value: unknown): value is AppError {
83
+ return value instanceof AppError;
84
+ }
@@ -0,0 +1 @@
1
+ export * from "./app-error";
@@ -0,0 +1,121 @@
1
+ import path from "node:path";
2
+ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
3
+ import { RepositoryError, ValidationError } from "../errors/app-error";
4
+
5
+ const DEFAULT_IGNORE = new Set([
6
+ "node_modules", ".git", ".next", "out", "dist", "prebuilt", ".turbo", "coverage",
7
+ ]);
8
+
9
+ export interface ListOptions {
10
+ recursive?: boolean;
11
+ maxEntries?: number;
12
+ }
13
+
14
+ export interface SearchOptions {
15
+ /** Restrict to files whose path includes this substring (e.g. ".ts"). */
16
+ extension?: string;
17
+ maxResults?: number;
18
+ maxFileBytes?: number;
19
+ }
20
+
21
+ export interface SearchMatch {
22
+ readonly file: string;
23
+ readonly line: number;
24
+ readonly text: string;
25
+ }
26
+
27
+ const TEXT_EXT = /\.(md|markdown|mdx|txt|json|ya?ml|ts|tsx|js|jsx|mjs|cjs|css|scss|html?|svg|toml|env)$/i;
28
+
29
+ /**
30
+ * Root-bounded filesystem service. All paths are resolved relative to `root`
31
+ * and may not escape it — the single safe FS surface for the CLI and MCP.
32
+ */
33
+ export class FileService {
34
+ constructor(private readonly root: string) {}
35
+
36
+ private resolveInside(relativePath: string): string {
37
+ const abs = path.resolve(this.root, relativePath);
38
+ const rel = path.relative(this.root, abs);
39
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
40
+ throw new ValidationError(`Path escapes the project root: ${relativePath}`);
41
+ }
42
+ return abs;
43
+ }
44
+
45
+ private toRel(abs: string): string {
46
+ return path.relative(this.root, abs).split(path.sep).join("/");
47
+ }
48
+
49
+ async list(relativeDir = ".", options: ListOptions = {}): Promise<string[]> {
50
+ const { recursive = false, maxEntries = 2000 } = options;
51
+ const start = this.resolveInside(relativeDir);
52
+ const out: string[] = [];
53
+
54
+ const walk = async (dir: string): Promise<void> => {
55
+ if (out.length >= maxEntries) return;
56
+ let entries: import("node:fs").Dirent[];
57
+ try {
58
+ entries = await readdir(dir, { withFileTypes: true });
59
+ } catch (cause) {
60
+ throw new RepositoryError(`Cannot read directory: ${this.toRel(dir)}`, { cause });
61
+ }
62
+ for (const entry of entries) {
63
+ if (DEFAULT_IGNORE.has(entry.name)) continue;
64
+ if (out.length >= maxEntries) break;
65
+ const abs = path.join(dir, entry.name);
66
+ out.push(this.toRel(abs) + (entry.isDirectory() ? "/" : ""));
67
+ if (recursive && entry.isDirectory()) await walk(abs);
68
+ }
69
+ };
70
+
71
+ await walk(start);
72
+ return out.sort();
73
+ }
74
+
75
+ async read(relativePath: string, maxBytes = 512_000): Promise<string> {
76
+ const abs = this.resolveInside(relativePath);
77
+ const info = await stat(abs).catch((cause) => {
78
+ throw new RepositoryError(`File not found: ${relativePath}`, { cause });
79
+ });
80
+ if (info.size > maxBytes) {
81
+ throw new ValidationError(`File too large (${info.size} bytes > ${maxBytes}): ${relativePath}`);
82
+ }
83
+ return readFile(abs, "utf8");
84
+ }
85
+
86
+ async write(relativePath: string, content: string): Promise<string> {
87
+ const abs = this.resolveInside(relativePath);
88
+ await mkdir(path.dirname(abs), { recursive: true });
89
+ await writeFile(abs, content, "utf8");
90
+ return this.toRel(abs);
91
+ }
92
+
93
+ async search(query: string, options: SearchOptions = {}): Promise<SearchMatch[]> {
94
+ if (!query.trim()) throw new ValidationError("Search query must not be empty.");
95
+ const { extension, maxResults = 200, maxFileBytes = 512_000 } = options;
96
+ const files = await this.list(".", { recursive: true, maxEntries: 5000 });
97
+ const matches: SearchMatch[] = [];
98
+ const needle = query.toLowerCase();
99
+
100
+ for (const file of files) {
101
+ if (file.endsWith("/")) continue;
102
+ if (extension && !file.endsWith(extension)) continue;
103
+ if (!extension && !TEXT_EXT.test(file)) continue;
104
+ if (matches.length >= maxResults) break;
105
+ let content: string;
106
+ try {
107
+ content = await this.read(file, maxFileBytes);
108
+ } catch {
109
+ continue;
110
+ }
111
+ const lines = content.split("\n");
112
+ for (let i = 0; i < lines.length; i += 1) {
113
+ if (lines[i].toLowerCase().includes(needle)) {
114
+ matches.push({ file, line: i + 1, text: lines[i].trim().slice(0, 240) });
115
+ if (matches.length >= maxResults) break;
116
+ }
117
+ }
118
+ }
119
+ return matches;
120
+ }
121
+ }
@@ -0,0 +1,2 @@
1
+ export { FileService } from "./file-service";
2
+ export type { ListOptions, SearchOptions, SearchMatch } from "./file-service";