@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,84 @@
1
+ import type {
2
+ AIProvider,
3
+ AiMessage,
4
+ AiProviderId,
5
+ GenerateRequest,
6
+ GenerateResponse,
7
+ ProviderCapabilities,
8
+ ProviderConfig,
9
+ StreamResponse,
10
+ } from "../../ports/ai";
11
+ import type { ProviderSpec } from "../catalog";
12
+ import { type FetchLike, ensureOk, readSseData, resolveFetch } from "../http/streaming";
13
+ import { collect, requireKey, resolveBaseUrl } from "./shared";
14
+
15
+ /** Google Gemini streamGenerateContent adapter (SSE, API key in query). */
16
+ export class GeminiProvider implements AIProvider {
17
+ readonly id: AiProviderId;
18
+ readonly capabilities: ProviderCapabilities;
19
+
20
+ constructor(
21
+ private readonly spec: ProviderSpec,
22
+ private readonly fetchImpl?: FetchLike,
23
+ ) {
24
+ this.id = spec.id;
25
+ this.capabilities = spec.capabilities;
26
+ }
27
+
28
+ private mapParts(msg: AiMessage): unknown[] {
29
+ const parts: unknown[] = [];
30
+ if (msg.content) parts.push({ text: msg.content });
31
+ for (const att of msg.attachments ?? []) {
32
+ parts.push({ inlineData: { mimeType: att.mimeType, data: att.data } });
33
+ }
34
+ return parts;
35
+ }
36
+
37
+ async *stream(request: GenerateRequest, config: ProviderConfig): AsyncGenerator<string> {
38
+ const fetchImpl = resolveFetch(this.fetchImpl);
39
+ requireKey(this.spec, config);
40
+ const base = resolveBaseUrl(this.spec, config);
41
+ const model = encodeURIComponent(config.model || this.spec.defaultModel);
42
+ const query = new URLSearchParams({ alt: "sse", key: config.apiKey as string });
43
+ const url = `${base}/${model}:streamGenerateContent?${query.toString()}`;
44
+
45
+ const contents = request.messages
46
+ .filter((m) => m.role !== "system")
47
+ .map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: this.mapParts(m) }));
48
+ const systemText =
49
+ request.system ??
50
+ request.messages
51
+ .filter((m) => m.role === "system")
52
+ .map((m) => m.content)
53
+ .join("\n");
54
+
55
+ const response = await fetchImpl(url, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ contents,
60
+ systemInstruction: systemText ? { parts: [{ text: systemText }] } : undefined,
61
+ generationConfig: { temperature: request.temperature, maxOutputTokens: request.maxTokens },
62
+ }),
63
+ signal: request.signal,
64
+ });
65
+ const ok = await ensureOk(response, this.spec.label);
66
+ for await (const data of readSseData(ok.body as ReadableStream<Uint8Array>)) {
67
+ if (!data) continue;
68
+ try {
69
+ const parsed = JSON.parse(data) as {
70
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
71
+ };
72
+ const text = parsed.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
73
+ if (text) yield text;
74
+ } catch {
75
+ /* ignore partial frames */
76
+ }
77
+ }
78
+ }
79
+
80
+ async generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse> {
81
+ const text = await collect(this.stream(request, config) as StreamResponse);
82
+ return { text, model: config.model || this.spec.defaultModel };
83
+ }
84
+ }
@@ -0,0 +1,69 @@
1
+ import type {
2
+ AIProvider,
3
+ AiProviderId,
4
+ GenerateRequest,
5
+ GenerateResponse,
6
+ ProviderCapabilities,
7
+ ProviderConfig,
8
+ StreamResponse,
9
+ } from "../../ports/ai";
10
+ import type { ProviderSpec } from "../catalog";
11
+ import { type FetchLike, ensureOk, readJsonLines, resolveFetch } from "../http/streaming";
12
+ import { collect, resolveBaseUrl } from "./shared";
13
+
14
+ /** Local Ollama /api/chat adapter (NDJSON streaming, no auth). */
15
+ export class OllamaProvider implements AIProvider {
16
+ readonly id: AiProviderId;
17
+ readonly capabilities: ProviderCapabilities;
18
+
19
+ constructor(
20
+ private readonly spec: ProviderSpec,
21
+ private readonly fetchImpl?: FetchLike,
22
+ ) {
23
+ this.id = spec.id;
24
+ this.capabilities = spec.capabilities;
25
+ }
26
+
27
+ private endpoint(config: ProviderConfig): string {
28
+ const base = resolveBaseUrl(this.spec, config);
29
+ return /\/api\/chat\/?$/i.test(base) ? base.replace(/\/$/, "") : `${base}/api/chat`;
30
+ }
31
+
32
+ async *stream(request: GenerateRequest, config: ProviderConfig): AsyncGenerator<string> {
33
+ const fetchImpl = resolveFetch(this.fetchImpl);
34
+ const messages = request.messages.map((m) => {
35
+ const images = (m.attachments ?? []).filter((a) => a.kind === "image").map((a) => a.data);
36
+ return images.length > 0
37
+ ? { role: m.role, content: m.content, images }
38
+ : { role: m.role, content: m.content };
39
+ });
40
+ if (request.system) messages.unshift({ role: "system", content: request.system });
41
+
42
+ const response = await fetchImpl(this.endpoint(config), {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({
46
+ model: config.model || this.spec.defaultModel,
47
+ messages,
48
+ options: { temperature: request.temperature },
49
+ stream: true,
50
+ }),
51
+ signal: request.signal,
52
+ });
53
+ const ok = await ensureOk(response, this.spec.label);
54
+ for await (const line of readJsonLines(ok.body as ReadableStream<Uint8Array>)) {
55
+ try {
56
+ const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
57
+ if (parsed.message?.content) yield parsed.message.content;
58
+ if (parsed.done) break;
59
+ } catch {
60
+ /* ignore partial frames */
61
+ }
62
+ }
63
+ }
64
+
65
+ async generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse> {
66
+ const text = await collect(this.stream(request, config) as StreamResponse);
67
+ return { text, model: config.model || this.spec.defaultModel };
68
+ }
69
+ }
@@ -0,0 +1,75 @@
1
+ import type {
2
+ AIProvider,
3
+ AiProviderId,
4
+ GenerateRequest,
5
+ GenerateResponse,
6
+ ProviderCapabilities,
7
+ ProviderConfig,
8
+ StreamResponse,
9
+ } from "../../ports/ai";
10
+ import type { ProviderSpec } from "../catalog";
11
+ import { type FetchLike, ensureOk, readSseData, resolveFetch } from "../http/streaming";
12
+ import { buildAuthHeaders, collect, resolveBaseUrl, toOpenAiMessages } from "./shared";
13
+
14
+ /**
15
+ * Adapter for every OpenAI chat-completions-compatible provider: OpenAI,
16
+ * OpenRouter, Groq, Mistral, DeepSeek, Together, Fireworks, Perplexity, xAI and
17
+ * Azure OpenAI. One strategy, parameterized by the catalog spec.
18
+ */
19
+ export class OpenAiCompatibleProvider implements AIProvider {
20
+ readonly id: AiProviderId;
21
+ readonly capabilities: ProviderCapabilities;
22
+
23
+ constructor(
24
+ private readonly spec: ProviderSpec,
25
+ private readonly fetchImpl?: FetchLike,
26
+ ) {
27
+ this.id = spec.id;
28
+ this.capabilities = spec.capabilities;
29
+ }
30
+
31
+ private endpoint(config: ProviderConfig): string {
32
+ const base = resolveBaseUrl(this.spec, config);
33
+ // Azure deployment URLs already include the full path via baseUrl.
34
+ if (this.spec.id === "azure-openai") return base;
35
+ return `${base}/chat/completions`;
36
+ }
37
+
38
+ private body(request: GenerateRequest, config: ProviderConfig, stream: boolean): string {
39
+ return JSON.stringify({
40
+ model: config.model || this.spec.defaultModel,
41
+ messages: toOpenAiMessages(request),
42
+ temperature: request.temperature,
43
+ max_tokens: request.maxTokens,
44
+ stream,
45
+ });
46
+ }
47
+
48
+ async *stream(request: GenerateRequest, config: ProviderConfig): AsyncGenerator<string> {
49
+ const fetchImpl = resolveFetch(this.fetchImpl);
50
+ const response = await fetchImpl(this.endpoint(config), {
51
+ method: "POST",
52
+ headers: buildAuthHeaders(this.spec, config),
53
+ body: this.body(request, config, true),
54
+ signal: request.signal,
55
+ });
56
+ const ok = await ensureOk(response, this.spec.label);
57
+ for await (const data of readSseData(ok.body as ReadableStream<Uint8Array>)) {
58
+ if (!data || data === "[DONE]") continue;
59
+ try {
60
+ const parsed = JSON.parse(data) as {
61
+ choices?: Array<{ delta?: { content?: string } }>;
62
+ };
63
+ const delta = parsed.choices?.[0]?.delta?.content;
64
+ if (delta) yield delta;
65
+ } catch {
66
+ /* ignore partial JSON frames */
67
+ }
68
+ }
69
+ }
70
+
71
+ async generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse> {
72
+ const text = await collect(this.stream(request, config) as StreamResponse);
73
+ return { text, model: config.model || this.spec.defaultModel };
74
+ }
75
+ }
@@ -0,0 +1,72 @@
1
+ import type { AiMessage, GenerateRequest, ProviderConfig } from "../../ports/ai";
2
+ import type { ProviderSpec } from "../catalog";
3
+ import { ProviderError } from "../../errors/app-error";
4
+
5
+ /** Build request auth headers from the provider's declared auth style. */
6
+ export function buildAuthHeaders(spec: ProviderSpec, config: ProviderConfig): Record<string, string> {
7
+ const headers: Record<string, string> = { "Content-Type": "application/json", ...config.extraHeaders };
8
+ switch (spec.auth) {
9
+ case "bearer":
10
+ requireKey(spec, config);
11
+ headers.Authorization = `Bearer ${config.apiKey}`;
12
+ break;
13
+ case "x-api-key":
14
+ requireKey(spec, config);
15
+ headers["x-api-key"] = config.apiKey as string;
16
+ headers["anthropic-version"] = "2023-06-01";
17
+ // Required for direct browser calls (the docs run as a static site);
18
+ // ignored server-side. Without it Anthropic rejects the CORS preflight.
19
+ headers["anthropic-dangerous-direct-browser-access"] = "true";
20
+ break;
21
+ case "api-key-header":
22
+ requireKey(spec, config);
23
+ headers["api-key"] = config.apiKey as string;
24
+ break;
25
+ case "query-key":
26
+ case "none":
27
+ break;
28
+ }
29
+ return headers;
30
+ }
31
+
32
+ export function requireKey(spec: ProviderSpec, config: ProviderConfig): void {
33
+ if (!config.apiKey) {
34
+ throw new ProviderError(`${spec.label} requires an API key.`, { details: { provider: spec.id } });
35
+ }
36
+ }
37
+
38
+ export function resolveBaseUrl(spec: ProviderSpec, config: ProviderConfig): string {
39
+ const base = (config.baseUrl ?? spec.baseUrl).replace(/\/+$/, "");
40
+ if (spec.requiresBaseUrl && !config.baseUrl && !spec.baseUrl) {
41
+ throw new ProviderError(`${spec.label} requires a baseUrl.`, { details: { provider: spec.id } });
42
+ }
43
+ return base;
44
+ }
45
+
46
+ /** OpenAI chat-completions message shape (text + optional image parts). */
47
+ export function toOpenAiMessages(request: GenerateRequest): unknown[] {
48
+ const out: unknown[] = [];
49
+ if (request.system) out.push({ role: "system", content: request.system });
50
+ for (const msg of request.messages) {
51
+ out.push(mapOpenAiMessage(msg));
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function mapOpenAiMessage(msg: AiMessage): unknown {
57
+ const images = (msg.attachments ?? []).filter((a) => a.kind === "image");
58
+ if (images.length === 0) return { role: msg.role, content: msg.content };
59
+ const content: unknown[] = [];
60
+ if (msg.content) content.push({ type: "text", text: msg.content });
61
+ for (const img of images) {
62
+ content.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
63
+ }
64
+ return { role: msg.role, content };
65
+ }
66
+
67
+ /** Drain an async iterable of text deltas into a single string. */
68
+ export async function collect(stream: AsyncIterable<string>): Promise<string> {
69
+ let text = "";
70
+ for await (const delta of stream) text += delta;
71
+ return text;
72
+ }
@@ -0,0 +1,29 @@
1
+ import type { AiProviderId, ProviderRegistration, ProviderRegistry } from "../ports/ai";
2
+ import { ProviderError } from "../errors/app-error";
3
+
4
+ /** In-memory provider registry (Registry pattern). */
5
+ export class InMemoryProviderRegistry implements ProviderRegistry {
6
+ private readonly registrations = new Map<AiProviderId, ProviderRegistration>();
7
+
8
+ register(registration: ProviderRegistration): void {
9
+ this.registrations.set(registration.id, registration);
10
+ }
11
+
12
+ has(id: AiProviderId): boolean {
13
+ return this.registrations.has(id);
14
+ }
15
+
16
+ get(id: AiProviderId): ProviderRegistration | undefined {
17
+ return this.registrations.get(id);
18
+ }
19
+
20
+ list(): readonly ProviderRegistration[] {
21
+ return [...this.registrations.values()];
22
+ }
23
+
24
+ require(id: AiProviderId): ProviderRegistration {
25
+ const found = this.registrations.get(id);
26
+ if (!found) throw new ProviderError(`Unknown AI provider: ${id}`, { details: { provider: id } });
27
+ return found;
28
+ }
29
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import type { Cache, CacheEntryOptions } from "../ports/cache";
5
+ import { CacheError } from "../errors/app-error";
6
+
7
+ interface PersistedEntry {
8
+ readonly value: unknown;
9
+ readonly expiresAt?: number;
10
+ }
11
+
12
+ type PersistedMap = Record<string, PersistedEntry>;
13
+
14
+ /**
15
+ * JSON-file backed cache (Node). Suitable for CLI/MCP persistence across runs.
16
+ * One file holds the whole namespace; loaded lazily and written on mutation.
17
+ */
18
+ export class FileCache<TValue = unknown> implements Cache<TValue> {
19
+ private cache?: PersistedMap;
20
+
21
+ constructor(
22
+ private readonly filePath: string,
23
+ private readonly now: () => number = () => Date.now(),
24
+ ) {}
25
+
26
+ private async load(): Promise<PersistedMap> {
27
+ if (this.cache) return this.cache;
28
+ if (!existsSync(this.filePath)) {
29
+ this.cache = {};
30
+ return this.cache;
31
+ }
32
+ try {
33
+ const raw = await readFile(this.filePath, "utf8");
34
+ this.cache = raw.trim() ? (JSON.parse(raw) as PersistedMap) : {};
35
+ } catch (cause) {
36
+ throw new CacheError(`Failed to read cache file: ${this.filePath}`, { cause });
37
+ }
38
+ return this.cache;
39
+ }
40
+
41
+ private async persist(): Promise<void> {
42
+ const dir = path.dirname(this.filePath);
43
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
44
+ await writeFile(this.filePath, JSON.stringify(this.cache ?? {}, null, 2), "utf8");
45
+ }
46
+
47
+ private isExpired(entry: PersistedEntry): boolean {
48
+ return entry.expiresAt !== undefined && entry.expiresAt <= this.now();
49
+ }
50
+
51
+ async get(key: string): Promise<TValue | undefined> {
52
+ const map = await this.load();
53
+ const entry = map[key];
54
+ if (!entry) return undefined;
55
+ if (this.isExpired(entry)) {
56
+ delete map[key];
57
+ await this.persist();
58
+ return undefined;
59
+ }
60
+ return entry.value as TValue;
61
+ }
62
+
63
+ async set(key: string, value: TValue, options?: CacheEntryOptions): Promise<void> {
64
+ const map = await this.load();
65
+ map[key] = {
66
+ value,
67
+ expiresAt: options?.ttlMs !== undefined ? this.now() + options.ttlMs : undefined,
68
+ };
69
+ await this.persist();
70
+ }
71
+
72
+ async has(key: string): Promise<boolean> {
73
+ return (await this.get(key)) !== undefined;
74
+ }
75
+
76
+ async delete(key: string): Promise<void> {
77
+ const map = await this.load();
78
+ delete map[key];
79
+ await this.persist();
80
+ }
81
+
82
+ async clear(): Promise<void> {
83
+ this.cache = {};
84
+ await this.persist();
85
+ }
86
+ }
@@ -0,0 +1,9 @@
1
+ export { MemoryCache } from "./memory-cache";
2
+ export type { Clock } from "./memory-cache";
3
+ export { FileCache } from "./file-cache";
4
+ export {
5
+ WebStorageCache,
6
+ createLocalStorageCache,
7
+ createSessionStorageCache,
8
+ } from "./web-storage-cache";
9
+ export type { WebStorageLike } from "./web-storage-cache";
@@ -0,0 +1,47 @@
1
+ import type { Cache, CacheEntryOptions } from "../ports/cache";
2
+
3
+ interface Entry<TValue> {
4
+ readonly value: TValue;
5
+ readonly expiresAt?: number;
6
+ }
7
+
8
+ /** Clock injection keeps the cache testable without Date.now() flakiness. */
9
+ export type Clock = () => number;
10
+
11
+ /** In-process cache with optional TTL. Default Strategy for hot, ephemeral data. */
12
+ export class MemoryCache<TValue = unknown> implements Cache<TValue> {
13
+ private readonly store = new Map<string, Entry<TValue>>();
14
+
15
+ constructor(private readonly now: Clock = () => Date.now()) {}
16
+
17
+ private isExpired(entry: Entry<TValue>): boolean {
18
+ return entry.expiresAt !== undefined && entry.expiresAt <= this.now();
19
+ }
20
+
21
+ async get(key: string): Promise<TValue | undefined> {
22
+ const entry = this.store.get(key);
23
+ if (!entry) return undefined;
24
+ if (this.isExpired(entry)) {
25
+ this.store.delete(key);
26
+ return undefined;
27
+ }
28
+ return entry.value;
29
+ }
30
+
31
+ async set(key: string, value: TValue, options?: CacheEntryOptions): Promise<void> {
32
+ const expiresAt = options?.ttlMs !== undefined ? this.now() + options.ttlMs : undefined;
33
+ this.store.set(key, { value, expiresAt });
34
+ }
35
+
36
+ async has(key: string): Promise<boolean> {
37
+ return (await this.get(key)) !== undefined;
38
+ }
39
+
40
+ async delete(key: string): Promise<void> {
41
+ this.store.delete(key);
42
+ }
43
+
44
+ async clear(): Promise<void> {
45
+ this.store.clear();
46
+ }
47
+ }
@@ -0,0 +1,91 @@
1
+ import type { Cache, CacheEntryOptions } from "../ports/cache";
2
+
3
+ /**
4
+ * Minimal subset of the Web Storage API. Injected so this module stays free of
5
+ * DOM lib types and works in the Node-typed tools package. The browser passes
6
+ * `window.localStorage` (→ LocalStorageCache) or `window.sessionStorage`
7
+ * (→ SessionStorageCache).
8
+ */
9
+ export interface WebStorageLike {
10
+ getItem(key: string): string | null;
11
+ setItem(key: string, value: string): void;
12
+ removeItem(key: string): void;
13
+ key(index: number): string | null;
14
+ readonly length: number;
15
+ }
16
+
17
+ interface StoredEntry {
18
+ readonly value: unknown;
19
+ readonly expiresAt?: number;
20
+ }
21
+
22
+ /** Cache backed by a Web Storage-like store, namespaced by key prefix. */
23
+ export class WebStorageCache<TValue = unknown> implements Cache<TValue> {
24
+ constructor(
25
+ private readonly storage: WebStorageLike,
26
+ private readonly prefix = "gpd:cache:",
27
+ private readonly now: () => number = () => Date.now(),
28
+ ) {}
29
+
30
+ private k(key: string): string {
31
+ return `${this.prefix}${key}`;
32
+ }
33
+
34
+ async get(key: string): Promise<TValue | undefined> {
35
+ const raw = this.storage.getItem(this.k(key));
36
+ if (raw === null) return undefined;
37
+ let entry: StoredEntry;
38
+ try {
39
+ entry = JSON.parse(raw) as StoredEntry;
40
+ } catch {
41
+ this.storage.removeItem(this.k(key));
42
+ return undefined;
43
+ }
44
+ if (entry.expiresAt !== undefined && entry.expiresAt <= this.now()) {
45
+ this.storage.removeItem(this.k(key));
46
+ return undefined;
47
+ }
48
+ return entry.value as TValue;
49
+ }
50
+
51
+ async set(key: string, value: TValue, options?: CacheEntryOptions): Promise<void> {
52
+ const entry: StoredEntry = {
53
+ value,
54
+ expiresAt: options?.ttlMs !== undefined ? this.now() + options.ttlMs : undefined,
55
+ };
56
+ this.storage.setItem(this.k(key), JSON.stringify(entry));
57
+ }
58
+
59
+ async has(key: string): Promise<boolean> {
60
+ return (await this.get(key)) !== undefined;
61
+ }
62
+
63
+ async delete(key: string): Promise<void> {
64
+ this.storage.removeItem(this.k(key));
65
+ }
66
+
67
+ async clear(): Promise<void> {
68
+ const toRemove: string[] = [];
69
+ for (let i = 0; i < this.storage.length; i += 1) {
70
+ const k = this.storage.key(i);
71
+ if (k && k.startsWith(this.prefix)) toRemove.push(k);
72
+ }
73
+ for (const k of toRemove) this.storage.removeItem(k);
74
+ }
75
+ }
76
+
77
+ /** localStorage-backed cache (persistent). */
78
+ export function createLocalStorageCache<TValue = unknown>(
79
+ storage: WebStorageLike,
80
+ prefix?: string,
81
+ ): WebStorageCache<TValue> {
82
+ return new WebStorageCache<TValue>(storage, prefix ?? "gpd:local:");
83
+ }
84
+
85
+ /** sessionStorage-backed cache (per-session). */
86
+ export function createSessionStorageCache<TValue = unknown>(
87
+ storage: WebStorageLike,
88
+ prefix?: string,
89
+ ): WebStorageCache<TValue> {
90
+ return new WebStorageCache<TValue>(storage, prefix ?? "gpd:session:");
91
+ }
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { ConfigExtension, ConfigLoader, LoadedConfig } from "../ports/config";
6
+ import { CONFIG_BASE, CONFIG_EXTENSIONS } from "../constants/config";
7
+ import { ConfigurationError } from "../errors/app-error";
8
+
9
+ /**
10
+ * Node config loader for gitpagedocs/config.{json,js,ts}. Behaviour mirrors the
11
+ * frontend loader (src/entities/docs/api/io/config-loader.ts) so the contract
12
+ * is identical: JSON is parsed, JS/TS are dynamically imported (default export
13
+ * or module namespace).
14
+ */
15
+ export class GitPageDocsConfigLoader implements ConfigLoader {
16
+ async resolveConfigPath(cwd: string = process.cwd()): Promise<string | undefined> {
17
+ const base = path.join(cwd, CONFIG_BASE);
18
+ for (const ext of CONFIG_EXTENSIONS) {
19
+ const fullPath = base + ext;
20
+ if (existsSync(fullPath)) return fullPath;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ async loadGitPageDocsConfig<TConfig = Record<string, unknown>>(
26
+ cwd: string = process.cwd(),
27
+ ): Promise<LoadedConfig<TConfig>> {
28
+ const sourcePath = await this.resolveConfigPath(cwd);
29
+ if (!sourcePath) {
30
+ throw new ConfigurationError(
31
+ `No config file found. Expected one of: ${CONFIG_EXTENSIONS.map((e) => CONFIG_BASE + e).join(", ")}`,
32
+ { details: { cwd } },
33
+ );
34
+ }
35
+ const extension = path.extname(sourcePath) as ConfigExtension;
36
+ const config = await this.parse<TConfig>(sourcePath, extension);
37
+ return { config, sourcePath, extension };
38
+ }
39
+
40
+ private async parse<TConfig>(resolvedPath: string, ext: ConfigExtension): Promise<TConfig> {
41
+ if (ext === ".json") {
42
+ const text = await readFile(resolvedPath, "utf8");
43
+ return JSON.parse(text) as TConfig;
44
+ }
45
+ if (ext === ".js" || ext === ".ts") {
46
+ const mod = (await import(pathToFileURL(resolvedPath).href)) as Record<string, unknown>;
47
+ const value = (mod.default ?? mod) as TConfig | undefined;
48
+ if (value == null) {
49
+ throw new ConfigurationError(
50
+ `Config file ${resolvedPath} did not export default or module.exports.`,
51
+ );
52
+ }
53
+ return value;
54
+ }
55
+ throw new ConfigurationError(`Unsupported config extension: ${ext}`);
56
+ }
57
+ }
58
+
59
+ export const defaultConfigLoader = new GitPageDocsConfigLoader();
@@ -0,0 +1 @@
1
+ export { GitPageDocsConfigLoader, defaultConfigLoader } from "./config-loader";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Config constants — mirror of src/shared/config/constants.ts so the shared
3
+ * core stays contract-compatible with the frontend's config loader.
4
+ */
5
+ export const CONFIG_BASE = "gitpagedocs/config";
6
+ export const CONFIG_EXTENSIONS = [".json", ".js", ".ts"] as const;
7
+ export const DEFAULT_CONFIG_PATH = "gitpagedocs/config.json";
8
+ export const DEFAULT_HIERARCHY = { md: 0, html: 1, video: 2, audio: 3 } as const;
@@ -0,0 +1 @@
1
+ export * from "./config";
@@ -0,0 +1,2 @@
1
+ export { NodeCryptoService, safeHexEqual } from "./node-crypto-service";
2
+ export { WebCryptoService } from "./web-crypto-service";