@vibe-hero/server 0.1.0

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 (150) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +151 -0
  3. package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
  4. package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
  5. package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
  6. package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
  7. package/dist/catalog/bundled/general/.gitkeep +0 -0
  8. package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
  9. package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
  10. package/dist/catalog/bundled/index.d.ts +39 -0
  11. package/dist/catalog/bundled/index.d.ts.map +1 -0
  12. package/dist/catalog/bundled/index.js +41 -0
  13. package/dist/catalog/bundled/index.js.map +1 -0
  14. package/dist/catalog/fetcher.d.ts +201 -0
  15. package/dist/catalog/fetcher.d.ts.map +1 -0
  16. package/dist/catalog/fetcher.js +452 -0
  17. package/dist/catalog/fetcher.js.map +1 -0
  18. package/dist/catalog/loader.d.ts +165 -0
  19. package/dist/catalog/loader.d.ts.map +1 -0
  20. package/dist/catalog/loader.js +241 -0
  21. package/dist/catalog/loader.js.map +1 -0
  22. package/dist/catalog/resolve.d.ts +85 -0
  23. package/dist/catalog/resolve.d.ts.map +1 -0
  24. package/dist/catalog/resolve.js +103 -0
  25. package/dist/catalog/resolve.js.map +1 -0
  26. package/dist/cli/getOffer.d.ts +38 -0
  27. package/dist/cli/getOffer.d.ts.map +1 -0
  28. package/dist/cli/getOffer.js +150 -0
  29. package/dist/cli/getOffer.js.map +1 -0
  30. package/dist/cli/index.d.ts +46 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +88 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/config.d.ts +34 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +63 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/engine/elo.d.ts +76 -0
  39. package/dist/engine/elo.d.ts.map +1 -0
  40. package/dist/engine/elo.js +79 -0
  41. package/dist/engine/elo.js.map +1 -0
  42. package/dist/engine/graduation.d.ts +108 -0
  43. package/dist/engine/graduation.d.ts.map +1 -0
  44. package/dist/engine/graduation.js +161 -0
  45. package/dist/engine/graduation.js.map +1 -0
  46. package/dist/engine/lapse.d.ts +80 -0
  47. package/dist/engine/lapse.d.ts.map +1 -0
  48. package/dist/engine/lapse.js +125 -0
  49. package/dist/engine/lapse.js.map +1 -0
  50. package/dist/engine/selection.d.ts +84 -0
  51. package/dist/engine/selection.d.ts.map +1 -0
  52. package/dist/engine/selection.js +119 -0
  53. package/dist/engine/selection.js.map +1 -0
  54. package/dist/grading/deterministic.d.ts +102 -0
  55. package/dist/grading/deterministic.d.ts.map +1 -0
  56. package/dist/grading/deterministic.js +118 -0
  57. package/dist/grading/deterministic.js.map +1 -0
  58. package/dist/grading/freeform.d.ts +64 -0
  59. package/dist/grading/freeform.d.ts.map +1 -0
  60. package/dist/grading/freeform.js +85 -0
  61. package/dist/grading/freeform.js.map +1 -0
  62. package/dist/index.d.ts +52 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +91 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/observation/hookEvents.d.ts +113 -0
  67. package/dist/observation/hookEvents.d.ts.map +1 -0
  68. package/dist/observation/hookEvents.js +170 -0
  69. package/dist/observation/hookEvents.js.map +1 -0
  70. package/dist/observation/offers.d.ts +215 -0
  71. package/dist/observation/offers.d.ts.map +1 -0
  72. package/dist/observation/offers.js +327 -0
  73. package/dist/observation/offers.js.map +1 -0
  74. package/dist/observation/source.d.ts +133 -0
  75. package/dist/observation/source.d.ts.map +1 -0
  76. package/dist/observation/source.js +105 -0
  77. package/dist/observation/source.js.map +1 -0
  78. package/dist/profile/migrate.d.ts +122 -0
  79. package/dist/profile/migrate.d.ts.map +1 -0
  80. package/dist/profile/migrate.js +147 -0
  81. package/dist/profile/migrate.js.map +1 -0
  82. package/dist/profile/store.d.ts +84 -0
  83. package/dist/profile/store.d.ts.map +1 -0
  84. package/dist/profile/store.js +267 -0
  85. package/dist/profile/store.js.map +1 -0
  86. package/dist/schemas/common.d.ts +95 -0
  87. package/dist/schemas/common.d.ts.map +1 -0
  88. package/dist/schemas/common.js +106 -0
  89. package/dist/schemas/common.js.map +1 -0
  90. package/dist/schemas/content.d.ts +828 -0
  91. package/dist/schemas/content.d.ts.map +1 -0
  92. package/dist/schemas/content.js +219 -0
  93. package/dist/schemas/content.js.map +1 -0
  94. package/dist/schemas/profile.d.ts +599 -0
  95. package/dist/schemas/profile.d.ts.map +1 -0
  96. package/dist/schemas/profile.js +177 -0
  97. package/dist/schemas/profile.js.map +1 -0
  98. package/dist/schemas/tools.d.ts +1581 -0
  99. package/dist/schemas/tools.d.ts.map +1 -0
  100. package/dist/schemas/tools.js +286 -0
  101. package/dist/schemas/tools.js.map +1 -0
  102. package/dist/tools/config.d.ts +51 -0
  103. package/dist/tools/config.d.ts.map +1 -0
  104. package/dist/tools/config.js +104 -0
  105. package/dist/tools/config.js.map +1 -0
  106. package/dist/tools/gate.d.ts +50 -0
  107. package/dist/tools/gate.d.ts.map +1 -0
  108. package/dist/tools/gate.js +67 -0
  109. package/dist/tools/gate.js.map +1 -0
  110. package/dist/tools/guidance.d.ts +36 -0
  111. package/dist/tools/guidance.d.ts.map +1 -0
  112. package/dist/tools/guidance.js +117 -0
  113. package/dist/tools/guidance.js.map +1 -0
  114. package/dist/tools/listTopics.d.ts +55 -0
  115. package/dist/tools/listTopics.d.ts.map +1 -0
  116. package/dist/tools/listTopics.js +78 -0
  117. package/dist/tools/listTopics.js.map +1 -0
  118. package/dist/tools/offers.d.ts +60 -0
  119. package/dist/tools/offers.d.ts.map +1 -0
  120. package/dist/tools/offers.js +152 -0
  121. package/dist/tools/offers.js.map +1 -0
  122. package/dist/tools/placeholders.d.ts +27 -0
  123. package/dist/tools/placeholders.d.ts.map +1 -0
  124. package/dist/tools/placeholders.js +49 -0
  125. package/dist/tools/placeholders.js.map +1 -0
  126. package/dist/tools/recordObservation.d.ts +52 -0
  127. package/dist/tools/recordObservation.d.ts.map +1 -0
  128. package/dist/tools/recordObservation.js +87 -0
  129. package/dist/tools/recordObservation.js.map +1 -0
  130. package/dist/tools/startQuiz.d.ts +82 -0
  131. package/dist/tools/startQuiz.d.ts.map +1 -0
  132. package/dist/tools/startQuiz.js +180 -0
  133. package/dist/tools/startQuiz.js.map +1 -0
  134. package/dist/tools/status.d.ts +59 -0
  135. package/dist/tools/status.d.ts.map +1 -0
  136. package/dist/tools/status.js +133 -0
  137. package/dist/tools/status.js.map +1 -0
  138. package/dist/tools/submitAnswer.d.ts +156 -0
  139. package/dist/tools/submitAnswer.d.ts.map +1 -0
  140. package/dist/tools/submitAnswer.js +402 -0
  141. package/dist/tools/submitAnswer.js.map +1 -0
  142. package/dist/tools/types.d.ts +82 -0
  143. package/dist/tools/types.d.ts.map +1 -0
  144. package/dist/tools/types.js +48 -0
  145. package/dist/tools/types.js.map +1 -0
  146. package/dist/tools/us2/standing.d.ts +111 -0
  147. package/dist/tools/us2/standing.d.ts.map +1 -0
  148. package/dist/tools/us2/standing.js +143 -0
  149. package/dist/tools/us2/standing.js.map +1 -0
  150. package/package.json +62 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @file Download-only GitHub catalog fetcher (T053).
3
+ *
4
+ * Fetches an updated curriculum from a central, **publicly published** source and
5
+ * caches it locally so a connected user automatically benefits from the latest
6
+ * content without reinstalling the tool (FR-026, FR-027, SC-007). The fetch is
7
+ * strictly **one-directional (download only)** — no user content ever leaves the
8
+ * machine (FR-024).
9
+ *
10
+ * ## Content-source scheme (manifest-first)
11
+ *
12
+ * The published source is a **base URL** (e.g. a GitHub Pages site or a
13
+ * `raw.githubusercontent.com/<owner>/<repo>/<ref>/content` prefix) under which:
14
+ *
15
+ * - `manifest.json` is a {@link CatalogManifest}: `{ version, publishedAt,
16
+ * topics: [{ id, class, file, itemCount, tiers }], etag? }`.
17
+ * - each topic's `file` (relative to the base URL) is a YAML document in the
18
+ * **same authoring format** the bundled catalog uses, parsed + Zod-validated
19
+ * by {@link loadTopicFromYaml}'s sibling {@link parseTopicYaml} (research E8 —
20
+ * validate fetched content BEFORE caching).
21
+ *
22
+ * This reuses the existing {@link CatalogManifest} index and the existing
23
+ * YAML/Zod content pipeline, so a fetched catalog and the bundled snapshot are
24
+ * byte-for-byte interchangeable once on disk. A "manifest listing topic files,
25
+ * then fetch each" scheme is the simplest thing that composes with what we
26
+ * already have (loader.ts) — no archive format, no new parser.
27
+ *
28
+ * ## Configuration
29
+ *
30
+ * The source is configured via the **`VIBE_HERO_CONTENT_URL`** environment
31
+ * variable (a base URL). When unset, fetching is **disabled** and the resolver
32
+ * (resolve.ts) silently serves cache/bundled — so the default, zero-config
33
+ * behavior is exactly the prior offline/bundled behavior (keeps the gate-free
34
+ * pull path and all existing tests green). {@link DEFAULT_CONTENT_URL} documents
35
+ * the intended published location but is intentionally NOT used unless the env
36
+ * var is set, so CI / first-run / offline never reaches for the network.
37
+ *
38
+ * ## Caching + ETag (FR-026)
39
+ *
40
+ * Cache lives under `${VIBE_HERO_HOME}/content/` (sibling of the profile dir; see
41
+ * {@link contentCacheDir}). Alongside the manifest + topic files we persist a
42
+ * small `cache-meta.json` ({@link CacheMeta}) holding the manifest `etag` and
43
+ * `version`. On refresh we send `If-None-Match: <etag>`; a `304 Not Modified`
44
+ * means "unchanged" and we skip rewriting the cache entirely.
45
+ *
46
+ * ## Network safety (FR-027)
47
+ *
48
+ * EVERY fetch failure — offline, DNS error, timeout, 4xx/5xx, malformed body,
49
+ * Zod-invalid content — is caught and reported as a **soft failure**
50
+ * ({@link FetchOutcome} with `ok: false`), never thrown to the caller. This lets
51
+ * the resolver fall back to cache/bundled with NO user-facing error (SC-006).
52
+ *
53
+ * Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ CatalogManifest,
54
+ * § Storage notes), spec FR-024/025/026/027, research.md E8.
55
+ */
56
+ import { type CatalogManifest, type Topic } from "../schemas/content.js";
57
+ /**
58
+ * Default published content base URL. Documented for operators; **not used
59
+ * unless `VIBE_HERO_CONTENT_URL` is set** so first-run / offline / CI never hits
60
+ * the network implicitly (fetch is opt-in). Points at the curriculum published
61
+ * from this repo's `content/` directory on the default branch.
62
+ */
63
+ export declare const DEFAULT_CONTENT_URL = "https://raw.githubusercontent.com/vibe-hero/vibe-hero/main/content";
64
+ /** Environment variable naming the published content base URL. */
65
+ export declare const CONTENT_URL_ENV = "VIBE_HERO_CONTENT_URL";
66
+ /**
67
+ * Minimal structural type of the global `fetch` we depend on, so the fetcher can
68
+ * accept an injected implementation in tests without pulling in DOM lib types.
69
+ * Matches Node's built-in `fetch` (Node ≥18).
70
+ */
71
+ export type FetchImpl = (input: string, init?: {
72
+ readonly headers?: Record<string, string>;
73
+ readonly signal?: AbortSignal;
74
+ }) => Promise<FetchResponseLike>;
75
+ /** The subset of the `fetch` `Response` the fetcher reads. */
76
+ export interface FetchResponseLike {
77
+ readonly ok: boolean;
78
+ readonly status: number;
79
+ /** Response header accessor (case-insensitive, like the WHATWG `Headers`). */
80
+ readonly headers: {
81
+ get(name: string): string | null;
82
+ };
83
+ text(): Promise<string>;
84
+ }
85
+ /**
86
+ * Cache metadata persisted alongside the cached catalog so a later refresh can
87
+ * revalidate with `If-None-Match` and skip unchanged downloads (FR-026).
88
+ */
89
+ export interface CacheMeta {
90
+ /** Catalog version (semver) of the cached manifest. */
91
+ readonly version: string;
92
+ /** Manifest ETag, if the source provided one; drives `If-None-Match`. */
93
+ readonly etag?: string;
94
+ /** When this cache entry was written (ISO datetime). */
95
+ readonly fetchedAt: string;
96
+ }
97
+ /** A successfully fetched + validated catalog, ready to cache and serve. */
98
+ export interface FetchedCatalog {
99
+ /** The validated manifest (its `etag` reflects the response, if any). */
100
+ readonly manifest: CatalogManifest;
101
+ /** Every topic, parsed + Zod-validated BEFORE caching (research E8). */
102
+ readonly topics: Topic[];
103
+ /** Raw YAML text per topic `file`, used to write the cache verbatim. */
104
+ readonly rawByFile: ReadonlyMap<string, string>;
105
+ }
106
+ /**
107
+ * Outcome of a fetch attempt. Always resolved (never rejected) so callers fall
108
+ * back without try/catch (FR-027):
109
+ * - `ok: true` ⇒ fresh catalog fetched + validated.
110
+ * - `ok: false, reason: "not_modified"` ⇒ 304; the cache is still current.
111
+ * - `ok: false, reason: "disabled"` ⇒ no source configured (fetch opt-out).
112
+ * - `ok: false, reason: "unreachable" | "invalid"` ⇒ soft failure; fall back.
113
+ */
114
+ export type FetchOutcome = {
115
+ readonly ok: true;
116
+ readonly catalog: FetchedCatalog;
117
+ } | {
118
+ readonly ok: false;
119
+ readonly reason: "not_modified" | "disabled" | "unreachable" | "invalid";
120
+ /** Human-readable diagnostic (never surfaced to the user; for logs/tests). */
121
+ readonly detail: string;
122
+ };
123
+ /** Options for {@link fetchCatalog} / {@link refreshCatalogCache}. */
124
+ export interface FetchOptions {
125
+ /**
126
+ * Published content base URL. When omitted, falls back to
127
+ * `VIBE_HERO_CONTENT_URL`; if that is also unset, fetching is **disabled**
128
+ * (returns `{ ok: false, reason: "disabled" }`) — the default zero-config
129
+ * offline/bundled behavior.
130
+ */
131
+ readonly contentUrl?: string;
132
+ /** Injected `fetch` (test seam). Defaults to the global `fetch` (Node ≥18). */
133
+ readonly fetchImpl?: FetchImpl;
134
+ /** Per-request timeout in ms. Defaults to {@link DEFAULT_FETCH_TIMEOUT_MS}. */
135
+ readonly timeoutMs?: number;
136
+ /** ETag of the currently-cached manifest, sent as `If-None-Match` (FR-026). */
137
+ readonly etag?: string;
138
+ }
139
+ /**
140
+ * Resolve the cache directory that holds fetched catalog content. It is a
141
+ * **sibling of the profile document directory** under the same `VIBE_HERO_HOME`
142
+ * seam the profile store uses, so a test that points `VIBE_HERO_HOME` at a temp
143
+ * dir transparently isolates both the profile and the content cache.
144
+ *
145
+ * @param dirOverride - Explicit profile-home override (test seam). When omitted,
146
+ * falls back to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
147
+ * @returns Absolute path to `${home}/content`.
148
+ */
149
+ export declare const contentCacheDir: (dirOverride?: string) => string;
150
+ /**
151
+ * Fetch the manifest + every topic file from the configured source, validating
152
+ * each topic with Zod **before** anything is returned for caching (research E8).
153
+ * Pure with respect to disk — it performs NO writes; {@link refreshCatalogCache}
154
+ * composes this with the cache layer.
155
+ *
156
+ * Never throws: all failure modes (disabled, offline/DNS/timeout, 4xx/5xx,
157
+ * malformed JSON/YAML, Zod-invalid content) become a `{ ok: false }`
158
+ * {@link FetchOutcome} (FR-027).
159
+ *
160
+ * @param options - Source URL, injected fetch, timeout, and prior ETag.
161
+ * @returns A {@link FetchOutcome} — never rejects.
162
+ */
163
+ export declare const fetchCatalog: (options?: FetchOptions) => Promise<FetchOutcome>;
164
+ /**
165
+ * Fetch the catalog and, on success, persist it to the content cache atomically
166
+ * enough for a single-writer refresh: writes the manifest, every topic file, and
167
+ * `cache-meta.json` (etag/version) so a later refresh can revalidate via
168
+ * `If-None-Match` (FR-026).
169
+ *
170
+ * The currently-cached ETag is read automatically (if present) and sent as the
171
+ * conditional header, so an unchanged source short-circuits to `not_modified`
172
+ * and the cache is left untouched (no redundant rewrite).
173
+ *
174
+ * Never throws (FR-027): a soft fetch failure is returned verbatim and the cache
175
+ * is left as-is.
176
+ *
177
+ * @param dirOverride - Profile-home override (test seam); see {@link contentCacheDir}.
178
+ * @param options - Fetch options (URL / fetchImpl / timeout). A caller-supplied
179
+ * `etag` overrides the on-disk one.
180
+ * @returns The {@link FetchOutcome}; on `ok` the cache now reflects it.
181
+ */
182
+ export declare const refreshCatalogCache: (dirOverride?: string, options?: FetchOptions) => Promise<FetchOutcome>;
183
+ /** A catalog read back from the on-disk cache. */
184
+ export interface CachedCatalog {
185
+ readonly manifest: CatalogManifest;
186
+ readonly topics: Topic[];
187
+ readonly meta: CacheMeta;
188
+ }
189
+ /**
190
+ * Read the previously-cached catalog from disk, validating both the manifest and
191
+ * every topic file against Zod (a cache corrupted between runs is rejected just
192
+ * like invalid remote content — E8 applies to the cache too).
193
+ *
194
+ * Never throws: returns `undefined` when no usable cache exists (missing,
195
+ * unreadable, or invalid), so the resolver falls through to the bundled snapshot.
196
+ *
197
+ * @param dirOverride - Profile-home override (test seam).
198
+ * @returns The cached catalog, or `undefined` if absent/invalid.
199
+ */
200
+ export declare const readCachedCatalog: (dirOverride?: string) => Promise<CachedCatalog | undefined>;
201
+ //# sourceMappingURL=fetcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetcher.d.ts","sourceRoot":"","sources":["../../src/catalog/fetcher.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,452 @@
1
+ /**
2
+ * @file Download-only GitHub catalog fetcher (T053).
3
+ *
4
+ * Fetches an updated curriculum from a central, **publicly published** source and
5
+ * caches it locally so a connected user automatically benefits from the latest
6
+ * content without reinstalling the tool (FR-026, FR-027, SC-007). The fetch is
7
+ * strictly **one-directional (download only)** — no user content ever leaves the
8
+ * machine (FR-024).
9
+ *
10
+ * ## Content-source scheme (manifest-first)
11
+ *
12
+ * The published source is a **base URL** (e.g. a GitHub Pages site or a
13
+ * `raw.githubusercontent.com/<owner>/<repo>/<ref>/content` prefix) under which:
14
+ *
15
+ * - `manifest.json` is a {@link CatalogManifest}: `{ version, publishedAt,
16
+ * topics: [{ id, class, file, itemCount, tiers }], etag? }`.
17
+ * - each topic's `file` (relative to the base URL) is a YAML document in the
18
+ * **same authoring format** the bundled catalog uses, parsed + Zod-validated
19
+ * by {@link loadTopicFromYaml}'s sibling {@link parseTopicYaml} (research E8 —
20
+ * validate fetched content BEFORE caching).
21
+ *
22
+ * This reuses the existing {@link CatalogManifest} index and the existing
23
+ * YAML/Zod content pipeline, so a fetched catalog and the bundled snapshot are
24
+ * byte-for-byte interchangeable once on disk. A "manifest listing topic files,
25
+ * then fetch each" scheme is the simplest thing that composes with what we
26
+ * already have (loader.ts) — no archive format, no new parser.
27
+ *
28
+ * ## Configuration
29
+ *
30
+ * The source is configured via the **`VIBE_HERO_CONTENT_URL`** environment
31
+ * variable (a base URL). When unset, fetching is **disabled** and the resolver
32
+ * (resolve.ts) silently serves cache/bundled — so the default, zero-config
33
+ * behavior is exactly the prior offline/bundled behavior (keeps the gate-free
34
+ * pull path and all existing tests green). {@link DEFAULT_CONTENT_URL} documents
35
+ * the intended published location but is intentionally NOT used unless the env
36
+ * var is set, so CI / first-run / offline never reaches for the network.
37
+ *
38
+ * ## Caching + ETag (FR-026)
39
+ *
40
+ * Cache lives under `${VIBE_HERO_HOME}/content/` (sibling of the profile dir; see
41
+ * {@link contentCacheDir}). Alongside the manifest + topic files we persist a
42
+ * small `cache-meta.json` ({@link CacheMeta}) holding the manifest `etag` and
43
+ * `version`. On refresh we send `If-None-Match: <etag>`; a `304 Not Modified`
44
+ * means "unchanged" and we skip rewriting the cache entirely.
45
+ *
46
+ * ## Network safety (FR-027)
47
+ *
48
+ * EVERY fetch failure — offline, DNS error, timeout, 4xx/5xx, malformed body,
49
+ * Zod-invalid content — is caught and reported as a **soft failure**
50
+ * ({@link FetchOutcome} with `ok: false`), never thrown to the caller. This lets
51
+ * the resolver fall back to cache/bundled with NO user-facing error (SC-006).
52
+ *
53
+ * Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ CatalogManifest,
54
+ * § Storage notes), spec FR-024/025/026/027, research.md E8.
55
+ */
56
+ import { homedir } from "node:os";
57
+ import * as path from "node:path";
58
+ import * as fs from "node:fs/promises";
59
+ import { CatalogManifestSchema, } from "../schemas/content.js";
60
+ import { parseTopicYaml, isContentVersionSupported, contentVersionRejection, } from "./loader.js";
61
+ /**
62
+ * Default published content base URL. Documented for operators; **not used
63
+ * unless `VIBE_HERO_CONTENT_URL` is set** so first-run / offline / CI never hits
64
+ * the network implicitly (fetch is opt-in). Points at the curriculum published
65
+ * from this repo's `content/` directory on the default branch.
66
+ */
67
+ export const DEFAULT_CONTENT_URL = "https://raw.githubusercontent.com/vibe-hero/vibe-hero/main/content";
68
+ /** Environment variable naming the published content base URL. */
69
+ export const CONTENT_URL_ENV = "VIBE_HERO_CONTENT_URL";
70
+ /** Environment variable naming the profile home (shared with the profile store). */
71
+ const HOME_ENV = "VIBE_HERO_HOME";
72
+ /** Default profile home directory name under `~` (`~/.vibe-hero`). */
73
+ const DEFAULT_DIRNAME = ".vibe-hero";
74
+ /** Sub-directory of the profile home that holds cached catalog content. */
75
+ const CONTENT_SUBDIR = "content";
76
+ /** Basename of the fetched manifest within the cache dir. */
77
+ const MANIFEST_FILENAME = "manifest.json";
78
+ /** Basename of the cache metadata (etag/version) within the cache dir. */
79
+ const CACHE_META_FILENAME = "cache-meta.json";
80
+ /** Default per-request timeout (ms) for catalog fetches. */
81
+ const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
82
+ /**
83
+ * Resolve the cache directory that holds fetched catalog content. It is a
84
+ * **sibling of the profile document directory** under the same `VIBE_HERO_HOME`
85
+ * seam the profile store uses, so a test that points `VIBE_HERO_HOME` at a temp
86
+ * dir transparently isolates both the profile and the content cache.
87
+ *
88
+ * @param dirOverride - Explicit profile-home override (test seam). When omitted,
89
+ * falls back to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
90
+ * @returns Absolute path to `${home}/content`.
91
+ */
92
+ export const contentCacheDir = (dirOverride) => {
93
+ if (dirOverride !== undefined && dirOverride !== "") {
94
+ return path.join(path.resolve(dirOverride), CONTENT_SUBDIR);
95
+ }
96
+ const fromEnv = process.env[HOME_ENV];
97
+ if (fromEnv !== undefined && fromEnv !== "") {
98
+ return path.join(path.resolve(fromEnv), CONTENT_SUBDIR);
99
+ }
100
+ return path.join(homedir(), DEFAULT_DIRNAME, CONTENT_SUBDIR);
101
+ };
102
+ /** Absolute path to the cached manifest. */
103
+ const manifestPath = (cacheDir) => path.join(cacheDir, MANIFEST_FILENAME);
104
+ /** Absolute path to the cache metadata. */
105
+ const cacheMetaPath = (cacheDir) => path.join(cacheDir, CACHE_META_FILENAME);
106
+ /**
107
+ * Resolve the configured content base URL, or `undefined` when fetching is
108
+ * disabled. Order: explicit `contentUrl` → `VIBE_HERO_CONTENT_URL`. The
109
+ * {@link DEFAULT_CONTENT_URL} constant is intentionally NOT consulted here so the
110
+ * network is never reached implicitly (fetch is strictly opt-in).
111
+ */
112
+ const resolveContentUrl = (contentUrl) => {
113
+ if (contentUrl !== undefined && contentUrl !== "")
114
+ return contentUrl;
115
+ const fromEnv = process.env[CONTENT_URL_ENV];
116
+ if (fromEnv !== undefined && fromEnv !== "")
117
+ return fromEnv;
118
+ return undefined;
119
+ };
120
+ /** Join a base URL and a relative path with exactly one separating slash. */
121
+ const joinUrl = (base, rel) => `${base.replace(/\/+$/, "")}/${rel.replace(/^\/+/, "")}`;
122
+ /**
123
+ * GET a URL with a timeout, returning the body text. Throws on any non-2xx
124
+ * status or transport error (the caller converts throws into soft failures).
125
+ * A `304 Not Modified` is signalled via the {@link NotModifiedError} sentinel so
126
+ * the caller can distinguish "unchanged" from a real error.
127
+ */
128
+ const getText = async (fetchImpl, url, timeoutMs, headers) => {
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => {
131
+ controller.abort();
132
+ }, timeoutMs);
133
+ try {
134
+ const res = await fetchImpl(url, { headers, signal: controller.signal });
135
+ if (res.status === 304) {
136
+ throw new NotModifiedError();
137
+ }
138
+ if (!res.ok) {
139
+ throw new Error(`GET ${url} → HTTP ${res.status}`);
140
+ }
141
+ return await res.text();
142
+ }
143
+ finally {
144
+ clearTimeout(timer);
145
+ }
146
+ };
147
+ /** Sentinel thrown by {@link getText} on a `304 Not Modified` response. */
148
+ class NotModifiedError extends Error {
149
+ constructor() {
150
+ super("not_modified");
151
+ this.name = "NotModifiedError";
152
+ }
153
+ }
154
+ /**
155
+ * Fetch the manifest + every topic file from the configured source, validating
156
+ * each topic with Zod **before** anything is returned for caching (research E8).
157
+ * Pure with respect to disk — it performs NO writes; {@link refreshCatalogCache}
158
+ * composes this with the cache layer.
159
+ *
160
+ * Never throws: all failure modes (disabled, offline/DNS/timeout, 4xx/5xx,
161
+ * malformed JSON/YAML, Zod-invalid content) become a `{ ok: false }`
162
+ * {@link FetchOutcome} (FR-027).
163
+ *
164
+ * @param options - Source URL, injected fetch, timeout, and prior ETag.
165
+ * @returns A {@link FetchOutcome} — never rejects.
166
+ */
167
+ export const fetchCatalog = async (options = {}) => {
168
+ const baseUrl = resolveContentUrl(options.contentUrl);
169
+ if (baseUrl === undefined) {
170
+ return {
171
+ ok: false,
172
+ reason: "disabled",
173
+ detail: `no content source configured (set ${CONTENT_URL_ENV})`,
174
+ };
175
+ }
176
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
177
+ if (typeof fetchImpl !== "function") {
178
+ return {
179
+ ok: false,
180
+ reason: "unreachable",
181
+ detail: "no fetch implementation available",
182
+ };
183
+ }
184
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
185
+ // --- 1. Fetch + validate the manifest (with conditional revalidation). ---
186
+ const manifestUrl = joinUrl(baseUrl, MANIFEST_FILENAME);
187
+ const conditionalHeaders = options.etag !== undefined ? { "If-None-Match": options.etag } : {};
188
+ let manifestBody;
189
+ let responseEtag;
190
+ try {
191
+ const controller = new AbortController();
192
+ const timer = setTimeout(() => {
193
+ controller.abort();
194
+ }, timeoutMs);
195
+ let res;
196
+ try {
197
+ res = await fetchImpl(manifestUrl, {
198
+ headers: conditionalHeaders,
199
+ signal: controller.signal,
200
+ });
201
+ }
202
+ finally {
203
+ clearTimeout(timer);
204
+ }
205
+ if (res.status === 304) {
206
+ return {
207
+ ok: false,
208
+ reason: "not_modified",
209
+ detail: "manifest unchanged (304); cache is current",
210
+ };
211
+ }
212
+ if (!res.ok) {
213
+ return {
214
+ ok: false,
215
+ reason: "unreachable",
216
+ detail: `GET ${manifestUrl} → HTTP ${res.status}`,
217
+ };
218
+ }
219
+ manifestBody = await res.text();
220
+ responseEtag = res.headers.get("etag") ?? undefined;
221
+ }
222
+ catch (err) {
223
+ return {
224
+ ok: false,
225
+ reason: "unreachable",
226
+ detail: `fetch failed for ${manifestUrl}: ${describeError(err)}`,
227
+ };
228
+ }
229
+ let manifest;
230
+ try {
231
+ const parsedJson = JSON.parse(manifestBody);
232
+ manifest = CatalogManifestSchema.parse(parsedJson);
233
+ }
234
+ catch (err) {
235
+ // Malformed/invalid manifest is treated as invalid remote content (E8):
236
+ // reject and fall back, never cache.
237
+ return {
238
+ ok: false,
239
+ reason: "invalid",
240
+ detail: `invalid manifest at ${manifestUrl}: ${describeError(err)}`,
241
+ };
242
+ }
243
+ // Content-version compatibility guard (T056, E6): refuse a manifest whose
244
+ // MAJOR is newer than this engine supports BEFORE fetching any topic files.
245
+ // Treated as invalid remote content so the resolver falls back to
246
+ // cache/bundled with no user-facing error (FR-027).
247
+ if (!isContentVersionSupported(manifest.version)) {
248
+ return {
249
+ ok: false,
250
+ reason: "invalid",
251
+ detail: contentVersionRejection(manifest.version),
252
+ };
253
+ }
254
+ // Prefer the HTTP ETag header; fall back to a manifest-embedded etag if any.
255
+ const effectiveEtag = responseEtag ?? manifest.etag;
256
+ // --- 2. Fetch + validate every topic file BEFORE caching (research E8). --
257
+ const topics = [];
258
+ const rawByFile = new Map();
259
+ for (const entry of manifest.topics) {
260
+ const fileUrl = joinUrl(baseUrl, entry.file);
261
+ let body;
262
+ try {
263
+ body = await getText(fetchImpl, fileUrl, timeoutMs, {});
264
+ }
265
+ catch (err) {
266
+ if (err instanceof NotModifiedError) {
267
+ // A per-file 304 without an If-None-Match is anomalous; treat as
268
+ // unreachable so we fall back rather than cache a partial catalog.
269
+ return {
270
+ ok: false,
271
+ reason: "unreachable",
272
+ detail: `unexpected 304 for ${fileUrl}`,
273
+ };
274
+ }
275
+ return {
276
+ ok: false,
277
+ reason: "unreachable",
278
+ detail: `fetch failed for ${fileUrl}: ${describeError(err)}`,
279
+ };
280
+ }
281
+ // E8: Zod-validate the fetched topic BEFORE it is eligible for caching.
282
+ // Any malformed/invalid file rejects the WHOLE fetch so we never serve a
283
+ // partially-valid remote catalog; the resolver falls back to cache/bundled.
284
+ try {
285
+ topics.push(parseTopicYaml(body, fileUrl));
286
+ }
287
+ catch (err) {
288
+ return {
289
+ ok: false,
290
+ reason: "invalid",
291
+ detail: `invalid topic ${entry.file}: ${describeError(err)}`,
292
+ };
293
+ }
294
+ rawByFile.set(entry.file, body);
295
+ }
296
+ const manifestWithEtag = effectiveEtag !== undefined
297
+ ? { ...manifest, etag: effectiveEtag }
298
+ : manifest;
299
+ return {
300
+ ok: true,
301
+ catalog: { manifest: manifestWithEtag, topics, rawByFile },
302
+ };
303
+ };
304
+ /**
305
+ * Fetch the catalog and, on success, persist it to the content cache atomically
306
+ * enough for a single-writer refresh: writes the manifest, every topic file, and
307
+ * `cache-meta.json` (etag/version) so a later refresh can revalidate via
308
+ * `If-None-Match` (FR-026).
309
+ *
310
+ * The currently-cached ETag is read automatically (if present) and sent as the
311
+ * conditional header, so an unchanged source short-circuits to `not_modified`
312
+ * and the cache is left untouched (no redundant rewrite).
313
+ *
314
+ * Never throws (FR-027): a soft fetch failure is returned verbatim and the cache
315
+ * is left as-is.
316
+ *
317
+ * @param dirOverride - Profile-home override (test seam); see {@link contentCacheDir}.
318
+ * @param options - Fetch options (URL / fetchImpl / timeout). A caller-supplied
319
+ * `etag` overrides the on-disk one.
320
+ * @returns The {@link FetchOutcome}; on `ok` the cache now reflects it.
321
+ */
322
+ export const refreshCatalogCache = async (dirOverride, options = {}) => {
323
+ const cacheDir = contentCacheDir(dirOverride);
324
+ // Send the cached ETag (if any) unless the caller pinned one explicitly.
325
+ let etag = options.etag;
326
+ if (etag === undefined) {
327
+ const meta = await readCacheMeta(cacheDir);
328
+ if (meta?.etag !== undefined)
329
+ etag = meta.etag;
330
+ }
331
+ const outcome = await fetchCatalog(etag !== undefined ? { ...options, etag } : options);
332
+ if (!outcome.ok) {
333
+ // not_modified / disabled / unreachable / invalid — leave the cache alone.
334
+ return outcome;
335
+ }
336
+ try {
337
+ await writeCache(cacheDir, outcome.catalog);
338
+ }
339
+ catch (err) {
340
+ // A cache write failure must not throw to the caller (FR-027): the freshly
341
+ // fetched topics are still returned and served this run; next refresh retries.
342
+ return {
343
+ ok: false,
344
+ reason: "unreachable",
345
+ detail: `cache write failed: ${describeError(err)}`,
346
+ };
347
+ }
348
+ return outcome;
349
+ };
350
+ /**
351
+ * Read the previously-cached catalog from disk, validating both the manifest and
352
+ * every topic file against Zod (a cache corrupted between runs is rejected just
353
+ * like invalid remote content — E8 applies to the cache too).
354
+ *
355
+ * Never throws: returns `undefined` when no usable cache exists (missing,
356
+ * unreadable, or invalid), so the resolver falls through to the bundled snapshot.
357
+ *
358
+ * @param dirOverride - Profile-home override (test seam).
359
+ * @returns The cached catalog, or `undefined` if absent/invalid.
360
+ */
361
+ export const readCachedCatalog = async (dirOverride) => {
362
+ const cacheDir = contentCacheDir(dirOverride);
363
+ const meta = await readCacheMeta(cacheDir);
364
+ if (meta === undefined)
365
+ return undefined;
366
+ let manifest;
367
+ try {
368
+ const raw = await fs.readFile(manifestPath(cacheDir), "utf8");
369
+ manifest = CatalogManifestSchema.parse(JSON.parse(raw));
370
+ }
371
+ catch {
372
+ return undefined;
373
+ }
374
+ // Content-version compatibility guard (T056, E6): a cache written by a future
375
+ // engine (major newer than we support) is unreadable — fall through to bundled
376
+ // rather than serve an unknown content format.
377
+ if (!isContentVersionSupported(manifest.version))
378
+ return undefined;
379
+ const topics = [];
380
+ for (const entry of manifest.topics) {
381
+ let body;
382
+ try {
383
+ body = await fs.readFile(path.join(cacheDir, entry.file), "utf8");
384
+ }
385
+ catch {
386
+ return undefined;
387
+ }
388
+ try {
389
+ topics.push(parseTopicYaml(body, entry.file));
390
+ }
391
+ catch {
392
+ return undefined;
393
+ }
394
+ }
395
+ return { manifest, topics, meta };
396
+ };
397
+ // --- cache IO (thin) -------------------------------------------------------
398
+ /** Read + validate `cache-meta.json`, or `undefined` if missing/invalid. */
399
+ const readCacheMeta = async (cacheDir) => {
400
+ let raw;
401
+ try {
402
+ raw = await fs.readFile(cacheMetaPath(cacheDir), "utf8");
403
+ }
404
+ catch {
405
+ return undefined;
406
+ }
407
+ try {
408
+ const parsed = JSON.parse(raw);
409
+ if (typeof parsed.version !== "string" || typeof parsed.fetchedAt !== "string") {
410
+ return undefined;
411
+ }
412
+ const meta = typeof parsed.etag === "string"
413
+ ? { version: parsed.version, etag: parsed.etag, fetchedAt: parsed.fetchedAt }
414
+ : { version: parsed.version, fetchedAt: parsed.fetchedAt };
415
+ return meta;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ };
421
+ /**
422
+ * Write the fetched catalog to the cache dir: the manifest, every topic file
423
+ * (verbatim YAML, preserving authoring), and the cache metadata. Topic files are
424
+ * written under their `file` path so the cache mirrors the published layout and
425
+ * {@link readCachedCatalog} can re-read them by manifest index.
426
+ */
427
+ const writeCache = async (cacheDir, catalog) => {
428
+ await fs.mkdir(cacheDir, { recursive: true });
429
+ // Manifest.
430
+ await fs.writeFile(manifestPath(cacheDir), `${JSON.stringify(catalog.manifest, null, 2)}\n`, "utf8");
431
+ // Topic files (create nested dirs as needed for paths like "general/x.yaml").
432
+ for (const [file, body] of catalog.rawByFile) {
433
+ const dest = path.join(cacheDir, file);
434
+ await fs.mkdir(path.dirname(dest), { recursive: true });
435
+ await fs.writeFile(dest, body, "utf8");
436
+ }
437
+ // Cache metadata (etag/version) for the next conditional refresh.
438
+ const meta = catalog.manifest.etag !== undefined
439
+ ? {
440
+ version: catalog.manifest.version,
441
+ etag: catalog.manifest.etag,
442
+ fetchedAt: new Date().toISOString(),
443
+ }
444
+ : {
445
+ version: catalog.manifest.version,
446
+ fetchedAt: new Date().toISOString(),
447
+ };
448
+ await fs.writeFile(cacheMetaPath(cacheDir), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
449
+ };
450
+ /** Best-effort human description of an unknown thrown value (for logs/tests). */
451
+ const describeError = (err) => err instanceof Error ? err.message : String(err);
452
+ //# sourceMappingURL=fetcher.js.map