primitive-admin 1.0.53 → 1.0.55

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 (173) hide show
  1. package/README.md +38 -10
  2. package/dist/bin/primitive.js +8 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/apps.js +54 -2
  5. package/dist/src/commands/apps.js.map +1 -1
  6. package/dist/src/commands/auth.js +16 -6
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/blob-buckets.js +84 -7
  9. package/dist/src/commands/blob-buckets.js.map +1 -1
  10. package/dist/src/commands/catalog.js +3 -3
  11. package/dist/src/commands/catalog.js.map +1 -1
  12. package/dist/src/commands/collections.js +12 -9
  13. package/dist/src/commands/collections.js.map +1 -1
  14. package/dist/src/commands/databases.js +220 -82
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/documents.js +77 -0
  17. package/dist/src/commands/documents.js.map +1 -1
  18. package/dist/src/commands/group-type-configs.js +1 -9
  19. package/dist/src/commands/group-type-configs.js.map +1 -1
  20. package/dist/src/commands/groups.js +12 -6
  21. package/dist/src/commands/groups.js.map +1 -1
  22. package/dist/src/commands/guides.d.ts +161 -0
  23. package/dist/src/commands/guides.js +502 -84
  24. package/dist/src/commands/guides.js.map +1 -1
  25. package/dist/src/commands/integrations.js +2 -2
  26. package/dist/src/commands/integrations.js.map +1 -1
  27. package/dist/src/commands/metadata.d.ts +2 -0
  28. package/dist/src/commands/metadata.js +344 -0
  29. package/dist/src/commands/metadata.js.map +1 -0
  30. package/dist/src/commands/prompts.js +2 -2
  31. package/dist/src/commands/prompts.js.map +1 -1
  32. package/dist/src/commands/rule-sets.d.ts +1 -0
  33. package/dist/src/commands/rule-sets.js +25 -3
  34. package/dist/src/commands/rule-sets.js.map +1 -1
  35. package/dist/src/commands/scripts.d.ts +2 -0
  36. package/dist/src/commands/scripts.js +667 -0
  37. package/dist/src/commands/scripts.js.map +1 -0
  38. package/dist/src/commands/settings.d.ts +15 -0
  39. package/dist/src/commands/settings.js +102 -0
  40. package/dist/src/commands/settings.js.map +1 -0
  41. package/dist/src/commands/sync-app-settings.d.ts +105 -0
  42. package/dist/src/commands/sync-app-settings.js +339 -0
  43. package/dist/src/commands/sync-app-settings.js.map +1 -0
  44. package/dist/src/commands/sync.d.ts +204 -9
  45. package/dist/src/commands/sync.js +1706 -371
  46. package/dist/src/commands/sync.js.map +1 -1
  47. package/dist/src/commands/vars.d.ts +8 -0
  48. package/dist/src/commands/vars.js +110 -0
  49. package/dist/src/commands/vars.js.map +1 -0
  50. package/dist/src/commands/webhooks.js +17 -3
  51. package/dist/src/commands/webhooks.js.map +1 -1
  52. package/dist/src/commands/workflows.d.ts +30 -0
  53. package/dist/src/commands/workflows.js +667 -59
  54. package/dist/src/commands/workflows.js.map +1 -1
  55. package/dist/src/lib/api-client.d.ts +122 -18
  56. package/dist/src/lib/api-client.js +131 -2
  57. package/dist/src/lib/api-client.js.map +1 -1
  58. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  59. package/dist/src/lib/app-settings-descriptor.js +250 -0
  60. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  61. package/dist/src/lib/batch.d.ts +26 -0
  62. package/dist/src/lib/batch.js +32 -0
  63. package/dist/src/lib/batch.js.map +1 -0
  64. package/dist/src/lib/block-layout.d.ts +160 -0
  65. package/dist/src/lib/block-layout.js +451 -0
  66. package/dist/src/lib/block-layout.js.map +1 -0
  67. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +76 -0
  68. package/dist/src/lib/codegen-shared/generatedFiles.js +144 -0
  69. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  70. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  71. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  72. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  73. package/dist/src/lib/config-toml.d.ts +10 -0
  74. package/dist/src/lib/config-toml.js +42 -0
  75. package/dist/src/lib/config-toml.js.map +1 -0
  76. package/dist/src/lib/config.d.ts +16 -0
  77. package/dist/src/lib/config.js +41 -0
  78. package/dist/src/lib/config.js.map +1 -1
  79. package/dist/src/lib/constants.d.ts +9 -0
  80. package/dist/src/lib/constants.js +9 -0
  81. package/dist/src/lib/constants.js.map +1 -1
  82. package/dist/src/lib/csv.d.ts +1 -2
  83. package/dist/src/lib/csv.js +3 -8
  84. package/dist/src/lib/csv.js.map +1 -1
  85. package/dist/src/lib/db-codegen/dbGenerator.d.ts +21 -65
  86. package/dist/src/lib/db-codegen/dbGenerator.js +117 -321
  87. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  88. package/dist/src/lib/db-codegen/dbNaming.d.ts +37 -0
  89. package/dist/src/lib/db-codegen/dbNaming.js +70 -0
  90. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  91. package/dist/src/lib/db-codegen/dbTemplates.d.ts +165 -5
  92. package/dist/src/lib/db-codegen/dbTemplates.js +292 -16
  93. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  94. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  95. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  96. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  97. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  98. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  99. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  100. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
  101. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
  102. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  103. package/dist/src/lib/generated-allowlist.js +43 -0
  104. package/dist/src/lib/generated-allowlist.js.map +1 -1
  105. package/dist/src/lib/init-config.js +2 -2
  106. package/dist/src/lib/init-config.js.map +1 -1
  107. package/dist/src/lib/output.d.ts +25 -5
  108. package/dist/src/lib/output.js +32 -4
  109. package/dist/src/lib/output.js.map +1 -1
  110. package/dist/src/lib/query-operators.d.ts +43 -0
  111. package/dist/src/lib/query-operators.js +80 -0
  112. package/dist/src/lib/query-operators.js.map +1 -0
  113. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
  114. package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
  115. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  116. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  117. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  118. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  119. package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
  120. package/dist/src/lib/swift-codegen/generator.js +178 -0
  121. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  122. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  123. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  124. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  125. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  126. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  127. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  128. package/dist/src/lib/sync-resource-types.d.ts +225 -0
  129. package/dist/src/lib/sync-resource-types.js +394 -0
  130. package/dist/src/lib/sync-resource-types.js.map +1 -0
  131. package/dist/src/lib/toml-database-config.d.ts +10 -19
  132. package/dist/src/lib/toml-database-config.js +28 -59
  133. package/dist/src/lib/toml-database-config.js.map +1 -1
  134. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  135. package/dist/src/lib/toml-metadata-config.js +463 -0
  136. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  137. package/dist/src/lib/toml-native-form.d.ts +46 -0
  138. package/dist/src/lib/toml-native-form.js +78 -0
  139. package/dist/src/lib/toml-native-form.js.map +1 -0
  140. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  141. package/dist/src/lib/toml-params-validator.js +118 -3
  142. package/dist/src/lib/toml-params-validator.js.map +1 -1
  143. package/dist/src/lib/workflow-apply.d.ts +86 -0
  144. package/dist/src/lib/workflow-apply.js +127 -0
  145. package/dist/src/lib/workflow-apply.js.map +1 -0
  146. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  147. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  148. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  149. package/dist/src/lib/workflow-codegen/generator.d.ts +87 -0
  150. package/dist/src/lib/workflow-codegen/generator.js +290 -0
  151. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  152. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
  153. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  154. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  155. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  156. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  157. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  158. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  159. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  160. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  161. package/dist/src/lib/workflow-fragments.js +3 -3
  162. package/dist/src/lib/workflow-fragments.js.map +1 -1
  163. package/dist/src/lib/workflow-payload.d.ts +68 -0
  164. package/dist/src/lib/workflow-payload.js +150 -0
  165. package/dist/src/lib/workflow-payload.js.map +1 -0
  166. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  167. package/dist/src/lib/workflow-toml-validator.js +37 -1
  168. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  169. package/dist/src/types/index.d.ts +11 -0
  170. package/dist/src/validators.d.ts +4 -3
  171. package/dist/src/validators.js +6 -5
  172. package/dist/src/validators.js.map +1 -1
  173. package/package.json +7 -3
@@ -2,13 +2,97 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, basename } from "path";
4
4
  import { error, info, warn, formatTable, json, keyValue, } from "../lib/output.js";
5
+ import { getServerUrl } from "../lib/config.js";
6
+ import { ALPHA_SERVER_URL } from "../lib/constants.js";
5
7
  const GUIDES_CACHE_DIR = process.env.PRIMITIVE_CONFIG_DIR
6
8
  ? join(process.env.PRIMITIVE_CONFIG_DIR, "guides")
7
9
  : join(homedir(), ".primitive", "guides");
8
10
  const CACHE_META_FILE = join(GUIDES_CACHE_DIR, "cache-meta.json");
9
- const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/Primitive-Labs/primitive-docs/main/guides";
11
+ const GITHUB_RAW_ROOT = "https://raw.githubusercontent.com/Primitive-Labs/primitive-docs";
12
+ const DEFAULT_DOCS_BRANCH = "main";
10
13
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
14
  const CLIENT_PACKAGE_NAME = "js-bao-wss-client";
15
+ /**
16
+ * Normalize a server URL for comparison against `ALPHA_SERVER_URL`: keep the
17
+ * scheme/host/port, strip all trailing slashes from the pathname. Returns null
18
+ * for an unparseable value. `http` vs `https` intentionally does NOT normalize
19
+ * to the same string (scheme is significant), and a custom domain is never an
20
+ * alias of the alpha constant.
21
+ */
22
+ export function normalizeServerUrl(raw) {
23
+ try {
24
+ const u = new URL(raw);
25
+ const path = u.pathname.replace(/\/+$/, "");
26
+ return `${u.protocol}//${u.host}${path}`;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * Is `value` a plausible git branch ref that is safe to interpolate into a raw
34
+ * GitHub URL path? Deliberately strict — only ASCII letters/digits and
35
+ * `._/-`, no leading dash, no `..`, capped length. This guards the
36
+ * `PRIMITIVE_GUIDES_BRANCH` override so no arbitrary string reaches the URL.
37
+ */
38
+ export function isValidBranchName(value) {
39
+ if (value.length === 0 || value.length > 100)
40
+ return false;
41
+ if (value.includes(".."))
42
+ return false;
43
+ return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
44
+ }
45
+ /**
46
+ * Resolve which `primitive-docs` branch to fetch guides from.
47
+ *
48
+ * Precedence (issue #1463):
49
+ * 1. `PRIMITIVE_GUIDES_BRANCH` override (undocumented, internal). Takes
50
+ * precedence over detection. An empty value is ignored; a value that is
51
+ * not a plausible git ref is rejected (throws) so no arbitrary string is
52
+ * interpolated into a raw GitHub URL.
53
+ * 2. Alpha detection: a normalized-URL match of the resolved server URL
54
+ * against `ALPHA_SERVER_URL` → `next`.
55
+ * 3. Otherwise `main` (production / local / any non-alpha target, or no
56
+ * target at all when the CLI is logged out).
57
+ *
58
+ * Pure and side-effect free: the caller passes the resolved server URL (or
59
+ * `undefined` when `getServerUrl()` threw) and the override, so this is fully
60
+ * unit-testable without config/credentials.
61
+ */
62
+ export function resolveDocsBranch(serverUrl, override = process.env.PRIMITIVE_GUIDES_BRANCH) {
63
+ const trimmedOverride = override?.trim();
64
+ if (trimmedOverride) {
65
+ if (!isValidBranchName(trimmedOverride)) {
66
+ throw new Error(`Invalid PRIMITIVE_GUIDES_BRANCH "${override}": must be a plausible git branch name.`);
67
+ }
68
+ return trimmedOverride;
69
+ }
70
+ if (serverUrl) {
71
+ const target = normalizeServerUrl(serverUrl);
72
+ const alpha = normalizeServerUrl(ALPHA_SERVER_URL);
73
+ if (target !== null && alpha !== null && target === alpha) {
74
+ return "next";
75
+ }
76
+ }
77
+ return DEFAULT_DOCS_BRANCH;
78
+ }
79
+ /**
80
+ * Resolve the docs branch for the current invocation, reading the target from
81
+ * `getServerUrl()`. Logged-out / legacy-no-creds makes `getServerUrl()` throw;
82
+ * we treat that as "no target" and fall back to `main` (the override still
83
+ * applies). Only the branch decision is wrapped here — an invalid override
84
+ * still throws out to the command's error handler.
85
+ */
86
+ function resolveDocsBranchForInvocation() {
87
+ let serverUrl;
88
+ try {
89
+ serverUrl = getServerUrl();
90
+ }
91
+ catch {
92
+ serverUrl = undefined;
93
+ }
94
+ return resolveDocsBranch(serverUrl);
95
+ }
12
96
  // Language aliases — map the agent's natural vocabulary onto canonical values.
13
97
  const LANGUAGE_ALIASES = {
14
98
  typescript: "ts",
@@ -112,6 +196,84 @@ export function selectVariant(guide, request) {
112
196
  // 4. Nothing matched → the guide's default file (never throws).
113
197
  return { file: guide.file, matchedOn: "default" };
114
198
  }
199
+ /**
200
+ * The global set of languages a manifest supports. Derived (never hardcoded)
201
+ * from: every guide variant's `language`, the manifest-level `defaults.language`
202
+ * (falling back to `"ts"` when absent), and every `platforms[*].language`. Used
203
+ * to validate `--language` and to render the `guides list` legend / `--json`.
204
+ *
205
+ * Derived per-request from the manifest ACTUALLY loaded (so it stays correct
206
+ * for stale-cache / `--guide-version` fallbacks), and grows automatically as
207
+ * new languages appear in the manifest — no CLI change needed.
208
+ */
209
+ export function deriveLanguages(manifest) {
210
+ const languages = new Set();
211
+ for (const guide of manifest.guides ?? []) {
212
+ for (const variant of guide.variants ?? []) {
213
+ if (variant.language !== undefined)
214
+ languages.add(variant.language);
215
+ }
216
+ }
217
+ languages.add(manifest.defaults?.language ?? "ts");
218
+ for (const entry of Object.values(manifest.platforms ?? {})) {
219
+ if (entry.language)
220
+ languages.add(entry.language);
221
+ }
222
+ return languages;
223
+ }
224
+ /**
225
+ * Validate the requested `(language, platform)` against the loaded manifest and
226
+ * resolve the effective language (issue #1219). Behavior:
227
+ *
228
+ * - Normalizes both values first (lowercase + trim, aliases, `"" → undefined`)
229
+ * via the existing `normalizeLanguage`/`normalizePlatform` helpers, then
230
+ * validates the NORMALIZED value.
231
+ * - `--language` is validated against `deriveLanguages(manifest)` whether or
232
+ * not a `platforms` block exists (the set is derivable from any manifest).
233
+ * - `--platform` is validated + inferred ONLY when `manifest.platforms` is
234
+ * present. With the block, an unknown platform is a hard error and a known
235
+ * platform infers `language = platforms[p].language` (an explicit
236
+ * `--language` always wins the language dimension). WITHOUT the block, the
237
+ * platform passes through unvalidated and uninferred — today's non-enforcing
238
+ * behavior, so older manifests / stale cache don't start hard-failing.
239
+ * - Unknown values produce a hard-error message mirroring `init --platform`'s
240
+ * style, plus a cross-hint when the bad value names the other dimension
241
+ * (`--platform swift` → "Did you mean --language swift?", and vice versa).
242
+ */
243
+ export function validateAndResolveRequest(manifest, rawLanguage, rawPlatform) {
244
+ const language = normalizeLanguage(rawLanguage);
245
+ const platform = normalizePlatform(rawPlatform);
246
+ const supportedLanguages = deriveLanguages(manifest);
247
+ const platforms = manifest.platforms; // may be undefined (back-compat)
248
+ // Validate --language (independent of the platforms block).
249
+ if (language !== undefined && !supportedLanguages.has(language)) {
250
+ const supported = [...supportedLanguages].sort().join(", ");
251
+ let message = `Unknown language "${language}". Supported languages: ${supported}`;
252
+ // Cross-hint: the value names a known platform, not a language.
253
+ if (platforms && platforms[language]) {
254
+ message += `. Did you mean --platform ${language}?`;
255
+ }
256
+ return { error: message };
257
+ }
258
+ let effectiveLanguage = language;
259
+ // Validate + infer --platform — active ONLY when the platforms block exists.
260
+ if (platform !== undefined && platforms !== undefined) {
261
+ if (!platforms[platform]) {
262
+ const supported = Object.keys(platforms).sort().join(", ");
263
+ let message = `Unknown platform "${platform}". Supported platforms: ${supported}`;
264
+ // Cross-hint: the value names a known language, not a platform
265
+ // (the reporter's exact `--platform swift` case).
266
+ if (supportedLanguages.has(platform)) {
267
+ message += `. Did you mean --language ${platform}?`;
268
+ }
269
+ return { error: message };
270
+ }
271
+ // Infer the language from the platform; an explicit --language wins.
272
+ effectiveLanguage = language ?? platforms[platform].language;
273
+ }
274
+ // else: no platforms block → back-compat pass-through (no validate, no infer).
275
+ return { language: effectiveLanguage, platform };
276
+ }
115
277
  function detectClientVersion() {
116
278
  const clientPkgPath = join(process.cwd(), "node_modules", CLIENT_PACKAGE_NAME, "package.json");
117
279
  try {
@@ -159,18 +321,21 @@ function formatClientVersion(info) {
159
321
  function formatGuidesVersion(info) {
160
322
  return info.version;
161
323
  }
162
- function getVersionCacheDir(version) {
163
- return join(GUIDES_CACHE_DIR, version);
324
+ // Cache paths are segmented by branch (#1463): ~/.primitive/guides/{branch}/
325
+ // {version}/... so a `next` fetch never overwrites the `main` cached copy of
326
+ // the same version+filename (and vice versa).
327
+ function getVersionCacheDir(branch, version) {
328
+ return join(GUIDES_CACHE_DIR, branch, version);
164
329
  }
165
- function getManifestPath(version) {
166
- return join(getVersionCacheDir(version), "manifest.json");
330
+ function getManifestPath(branch, version) {
331
+ return join(getVersionCacheDir(branch, version), "manifest.json");
167
332
  }
168
- function getGuidesCacheDir(version) {
169
- return join(getVersionCacheDir(version), "guides");
333
+ function getGuidesCacheDir(branch, version) {
334
+ return join(getVersionCacheDir(branch, version), "guides");
170
335
  }
171
- function ensureCacheDir(version) {
172
- const versionDir = getVersionCacheDir(version);
173
- const guidesDir = getGuidesCacheDir(version);
336
+ function ensureCacheDir(branch, version) {
337
+ const versionDir = getVersionCacheDir(branch, version);
338
+ const guidesDir = getGuidesCacheDir(branch, version);
174
339
  if (!existsSync(versionDir)) {
175
340
  mkdirSync(versionDir, { recursive: true });
176
341
  }
@@ -201,11 +366,15 @@ function isCacheExpired(fetchedAt) {
201
366
  const fetchedTime = new Date(fetchedAt).getTime();
202
367
  return Date.now() - fetchedTime > CACHE_TTL_MS;
203
368
  }
204
- function getManifestUrl(version) {
205
- return `${GITHUB_RAW_BASE}/${version}/guides.json`;
369
+ /** The raw-GitHub base for a docs branch, e.g. `.../primitive-docs/next/guides`. */
370
+ function githubRawBase(branch) {
371
+ return `${GITHUB_RAW_ROOT}/${branch}/guides`;
206
372
  }
207
- function getGuideUrl(version, fileName) {
208
- return `${GITHUB_RAW_BASE}/${version}/${fileName}`;
373
+ export function buildManifestUrl(branch, version) {
374
+ return `${githubRawBase(branch)}/${version}/guides.json`;
375
+ }
376
+ export function buildGuideUrl(branch, version, fileName) {
377
+ return `${githubRawBase(branch)}/${version}/${fileName}`;
209
378
  }
210
379
  async function fetchWithTimeout(url, timeoutMs = 10000) {
211
380
  const controller = new AbortController();
@@ -218,11 +387,26 @@ async function fetchWithTimeout(url, timeoutMs = 10000) {
218
387
  clearTimeout(timeout);
219
388
  }
220
389
  }
221
- async function fetchManifestForVersion(version, forceRefresh = false) {
222
- ensureCacheDir(version);
390
+ /**
391
+ * Thrown by `fetchManifest` when a branch/version genuinely has no published
392
+ * manifest — every candidate manifest URL returned 404. Distinct from a
393
+ * transport or server error (timeout, DNS, 5xx), which `fetchManifest` lets
394
+ * propagate as a plain `Error`. `resolveManifest` relies on this distinction:
395
+ * only a truly-absent branch may fall back to `main`; a transient failure must
396
+ * surface so an alpha/preview invocation is never silently downgraded to
397
+ * production `main` docs on a flake (#1463).
398
+ */
399
+ export class ManifestNotFoundError extends Error {
400
+ constructor(message) {
401
+ super(message);
402
+ this.name = "ManifestNotFoundError";
403
+ }
404
+ }
405
+ async function fetchManifestForVersion(branch, version, forceRefresh = false) {
406
+ ensureCacheDir(branch, version);
223
407
  const meta = loadCacheMeta();
224
- const manifestPath = getManifestPath(version);
225
- const cacheExpired = isCacheExpired(meta.manifestFetchedAt?.[version]);
408
+ const manifestPath = getManifestPath(branch, version);
409
+ const cacheExpired = isCacheExpired(meta.manifestFetchedAt?.[branch]?.[version]);
226
410
  const hasCachedManifest = existsSync(manifestPath);
227
411
  // If cache is valid and not forcing refresh, use it
228
412
  if (!forceRefresh && !cacheExpired && hasCachedManifest) {
@@ -235,11 +419,11 @@ async function fetchManifestForVersion(version, forceRefresh = false) {
235
419
  }
236
420
  }
237
421
  // Try to fetch from network
238
- const manifestUrl = getManifestUrl(version);
422
+ const manifestUrl = buildManifestUrl(branch, version);
239
423
  try {
240
424
  const response = await fetchWithTimeout(manifestUrl);
241
425
  if (response.status === 404) {
242
- return null; // Version doesn't exist
426
+ return null; // Version (or branch) doesn't exist
243
427
  }
244
428
  if (!response.ok) {
245
429
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -248,7 +432,8 @@ async function fetchManifestForVersion(version, forceRefresh = false) {
248
432
  // Save to cache
249
433
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
250
434
  meta.manifestFetchedAt = meta.manifestFetchedAt || {};
251
- meta.manifestFetchedAt[version] = new Date().toISOString();
435
+ meta.manifestFetchedAt[branch] = meta.manifestFetchedAt[branch] || {};
436
+ meta.manifestFetchedAt[branch][version] = new Date().toISOString();
252
437
  saveCacheMeta(meta);
253
438
  return { manifest, fromCache: false, stale: false };
254
439
  }
@@ -266,15 +451,15 @@ async function fetchManifestForVersion(version, forceRefresh = false) {
266
451
  throw new Error(`Failed to fetch guides manifest: ${err.message}`);
267
452
  }
268
453
  }
269
- async function fetchManifest(versionInfo, forceRefresh = false) {
454
+ async function fetchManifest(branch, versionInfo, forceRefresh = false) {
270
455
  // Try the requested version first
271
- const result = await fetchManifestForVersion(versionInfo.version, forceRefresh);
456
+ const result = await fetchManifestForVersion(branch, versionInfo.version, forceRefresh);
272
457
  if (result) {
273
458
  return { ...result, versionInfo };
274
459
  }
275
460
  // Version not found, fall back to latest (unless already trying latest)
276
461
  if (versionInfo.version !== "latest") {
277
- const fallbackResult = await fetchManifestForVersion("latest", forceRefresh);
462
+ const fallbackResult = await fetchManifestForVersion(branch, "latest", forceRefresh);
278
463
  if (fallbackResult) {
279
464
  const fallbackInfo = {
280
465
  version: "latest",
@@ -285,18 +470,110 @@ async function fetchManifest(versionInfo, forceRefresh = false) {
285
470
  return { ...fallbackResult, versionInfo: fallbackInfo };
286
471
  }
287
472
  }
288
- throw new Error(`Failed to fetch guides manifest for ${versionInfo.version}`);
473
+ // Every candidate URL 404'd the branch/version is genuinely absent. Signal
474
+ // this with a typed error so `resolveManifest` can tell it apart from a
475
+ // transport/server error (which arrives as a plain `Error` from
476
+ // `fetchManifestForVersion`) and only fall back to `main` in the former case.
477
+ throw new ManifestNotFoundError(`Failed to fetch guides manifest for ${versionInfo.version}`);
289
478
  }
290
- async function fetchGuide(guide, version, request = {}, forceRefresh = false) {
291
- ensureCacheDir(version);
479
+ /**
480
+ * Fetch the manifest for `requestedBranch`, falling back to `main` **only when
481
+ * the non-`main` branch has no published manifest** — i.e. every candidate URL
482
+ * 404s and `fetchManifest` throws a `ManifestNotFoundError`. That fallback is
483
+ * what makes the alpha `next` target safe before `primitive-docs` publishes its
484
+ * `next` guides, and it covers a `PRIMITIVE_GUIDES_BRANCH` override that names a
485
+ * nonexistent branch.
486
+ *
487
+ * A transport or server error (timeout, DNS, 5xx) on the requested branch is
488
+ * NOT a missing branch — it arrives as a plain `Error` and is re-thrown, never
489
+ * downgraded to `main`. Silently serving production `main` docs for an
490
+ * alpha/preview invocation because of a transient flake would be wrong: a
491
+ * preview run must fail loudly rather than quietly return the wrong branch's
492
+ * docs (#1463).
493
+ */
494
+ export async function resolveManifest(requestedBranch, versionInfo, forceRefresh = false) {
495
+ try {
496
+ const result = await fetchManifest(requestedBranch, versionInfo, forceRefresh);
497
+ return { ...result, branch: requestedBranch, requestedBranch, branchFellBack: false };
498
+ }
499
+ catch (err) {
500
+ // Only a genuinely-absent branch/version (a 404 on every candidate URL) may
501
+ // fall back to `main`. A transport/server error must surface — see the
502
+ // function doc.
503
+ if (!(err instanceof ManifestNotFoundError)) {
504
+ throw err;
505
+ }
506
+ if (requestedBranch === DEFAULT_DOCS_BRANCH) {
507
+ throw err; // already on main; nothing to fall back to
508
+ }
509
+ const result = await fetchManifest(DEFAULT_DOCS_BRANCH, versionInfo, forceRefresh);
510
+ return {
511
+ ...result,
512
+ branch: DEFAULT_DOCS_BRANCH,
513
+ requestedBranch,
514
+ branchFellBack: true,
515
+ };
516
+ }
517
+ }
518
+ /**
519
+ * One stderr line reporting the served docs branch, only when a non-`main`
520
+ * branch was requested (alpha detection or the override) — a plain `main`
521
+ * target prints nothing so stdout/stderr stay clean for the common case
522
+ * (#1463, behavior 8). Uses `info()` (stderr) so guide content on stdout is
523
+ * never polluted.
524
+ */
525
+ function reportDocsBranch(resolved) {
526
+ if (resolved.requestedBranch === DEFAULT_DOCS_BRANCH)
527
+ return;
528
+ if (resolved.branchFellBack) {
529
+ info(`Guides branch: ${resolved.requestedBranch} unavailable; using main`);
530
+ }
531
+ else {
532
+ info(`Guides branch: ${resolved.requestedBranch}`);
533
+ }
534
+ }
535
+ /**
536
+ * Internal error thrown by `fetchGuide` on a non-ok guide-file response. NOT
537
+ * exported. `status` lets the `get` action distinguish a 404 (the cached
538
+ * manifest may name a file that was renamed/removed upstream — heal by
539
+ * refetching the manifest) from a transport error (keep the stale-cache
540
+ * fallback). `staleContent` carries the on-disk copy for the failed filename,
541
+ * if any, so the action can serve it as the option-(b) last resort (#1034).
542
+ */
543
+ class GuideFetchError extends Error {
544
+ status;
545
+ selectedFile;
546
+ staleContent;
547
+ constructor(status, message, selectedFile, staleContent) {
548
+ super(message);
549
+ this.status = status;
550
+ this.selectedFile = selectedFile;
551
+ this.staleContent = staleContent;
552
+ this.name = "GuideFetchError";
553
+ }
554
+ }
555
+ /** Read an on-disk cached guide file, or undefined if absent/corrupted. */
556
+ function readCachedGuide(cachedPath) {
557
+ if (!existsSync(cachedPath))
558
+ return undefined;
559
+ try {
560
+ return readFileSync(cachedPath, "utf-8");
561
+ }
562
+ catch {
563
+ return undefined; // corrupted cache
564
+ }
565
+ }
566
+ async function fetchGuide(guide, branch, version, request = {}, forceRefresh = false) {
567
+ ensureCacheDir(branch, version);
292
568
  const meta = loadCacheMeta();
293
569
  // Resolve the variant filename for the requested (language, platform).
294
570
  const { file: selectedFile } = selectVariant(guide, request);
295
571
  const guideFileName = basename(selectedFile);
296
- const cachedPath = join(getGuidesCacheDir(version), guideFileName);
297
- // Freshness is keyed by the RESOLVED filename (not topic) so a `ts` fetch
298
- // cannot mark the `swift` variant fresh and vice versa (#977).
299
- const fetchedAt = meta.guidesFetchedAt?.[version]?.[guideFileName];
572
+ const cachedPath = join(getGuidesCacheDir(branch, version), guideFileName);
573
+ // Freshness is keyed by branch + the RESOLVED filename (not topic): a `ts`
574
+ // fetch cannot mark the `swift` variant fresh (#977), and a `next` fetch
575
+ // cannot mark the `main` copy of the same file fresh (#1463, behavior 7).
576
+ const fetchedAt = meta.guidesFetchedAt?.[branch]?.[version]?.[guideFileName];
300
577
  const cacheExpired = isCacheExpired(fetchedAt);
301
578
  const hasCachedGuide = existsSync(cachedPath);
302
579
  // If cache is valid and not forcing refresh, use it
@@ -310,9 +587,17 @@ async function fetchGuide(guide, version, request = {}, forceRefresh = false) {
310
587
  }
311
588
  }
312
589
  // Try to fetch from network
313
- const guideUrl = getGuideUrl(version, selectedFile);
590
+ const guideUrl = buildGuideUrl(branch, version, selectedFile);
314
591
  try {
315
592
  const response = await fetchWithTimeout(guideUrl);
593
+ if (response.status === 404) {
594
+ // The cache-derived filename no longer exists upstream (a docs rename /
595
+ // removal). Let the 404 escape to the `get` action so it can refetch the
596
+ // manifest and re-resolve — heal must take priority over serving stale.
597
+ // Attach the on-disk copy (if any) so the action can serve it as the
598
+ // option-(b) last resort when the heal can't help (#1034).
599
+ throw new GuideFetchError(404, `Failed to fetch guide "${guide.topic}": HTTP 404: ${response.statusText}`, selectedFile, readCachedGuide(cachedPath));
600
+ }
316
601
  if (!response.ok) {
317
602
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
318
603
  }
@@ -320,21 +605,23 @@ async function fetchGuide(guide, version, request = {}, forceRefresh = false) {
320
605
  // Save to cache
321
606
  writeFileSync(cachedPath, content);
322
607
  meta.guidesFetchedAt = meta.guidesFetchedAt || {};
323
- meta.guidesFetchedAt[version] = meta.guidesFetchedAt[version] || {};
324
- meta.guidesFetchedAt[version][guideFileName] = new Date().toISOString();
608
+ meta.guidesFetchedAt[branch] = meta.guidesFetchedAt[branch] || {};
609
+ meta.guidesFetchedAt[branch][version] = meta.guidesFetchedAt[branch][version] || {};
610
+ meta.guidesFetchedAt[branch][version][guideFileName] = new Date().toISOString();
325
611
  saveCacheMeta(meta);
326
612
  return { content, fromCache: false, stale: false, selectedFile };
327
613
  }
328
614
  catch (err) {
329
- // Network failed, try stale cache
330
- if (hasCachedGuide) {
331
- try {
332
- const content = readFileSync(cachedPath, "utf-8");
333
- return { content, fromCache: true, stale: true, selectedFile };
334
- }
335
- catch {
336
- // Cache corrupted
337
- }
615
+ // A 404 must reach the action (heal takes priority over stale) — re-throw it
616
+ // unchanged rather than serving the stale copy here.
617
+ if (err instanceof GuideFetchError && err.status === 404) {
618
+ throw err;
619
+ }
620
+ // Non-404 (transport error / 5xx): serve the stale on-disk copy if present
621
+ // (Fork F — transport problems are not manifest skew), else throw.
622
+ const stale = readCachedGuide(cachedPath);
623
+ if (stale !== undefined) {
624
+ return { content: stale, fromCache: true, stale: true, selectedFile };
338
625
  }
339
626
  throw new Error(`Failed to fetch guide "${guide.topic}": ${err.message}`);
340
627
  }
@@ -347,23 +634,40 @@ function normalizeVariants(guide) {
347
634
  file: v.file,
348
635
  }));
349
636
  }
350
- /** Compact human-readable rendering of a guide's available combinations. */
351
- function formatCombinations(guide) {
637
+ /**
638
+ * Compact rendering of the LANGUAGES a guide offers, for the `guides list`
639
+ * table (issue #1219 renamed the ambiguous `COMBINATIONS` column to
640
+ * `LANGUAGES`). Lists each variant's `language`, de-duplicated and order-
641
+ * preserving; a guide with no language-pinned variants renders `default`.
642
+ * Platforms are surfaced separately as a manifest-level legend, since they are
643
+ * a manifest-wide map rather than a per-guide attribute.
644
+ */
645
+ export function formatLanguages(guide) {
352
646
  const variants = guide.variants ?? [];
353
647
  if (variants.length === 0)
354
648
  return "default";
355
- const parts = variants.map((v) => {
356
- if (v.language !== undefined && v.platform !== undefined) {
357
- return `${v.language}_${v.platform}`;
358
- }
359
- if (v.language !== undefined)
360
- return v.language;
361
- if (v.platform !== undefined)
362
- return v.platform;
649
+ const languages = variants
650
+ .map((v) => v.language)
651
+ .filter((l) => l !== undefined);
652
+ if (languages.length === 0)
363
653
  return "default";
364
- });
365
- // De-dupe while preserving order.
366
- return [...new Set(parts)].join("; ");
654
+ return [...new Set(languages)].join("; ");
655
+ }
656
+ /**
657
+ * Render the manifest-level platform→language legend for `guides list`, e.g.
658
+ * `web -> ts, ios -> swift, macos -> swift`. When the manifest has no
659
+ * `platforms` block (older manifests / stale cache, before
660
+ * Primitive-Labs/primitive-docs#194), platforms aren't modeled yet, so this
661
+ * reports `none defined yet` rather than implying a map that doesn't exist.
662
+ */
663
+ function formatPlatformsLegend(manifest) {
664
+ const platforms = manifest.platforms;
665
+ if (!platforms || Object.keys(platforms).length === 0) {
666
+ return "none defined yet";
667
+ }
668
+ return Object.entries(platforms)
669
+ .map(([platform, entry]) => `${platform} -> ${entry.language}`)
670
+ .join(", ");
367
671
  }
368
672
  export function registerGuidesCommands(program) {
369
673
  const guides = program
@@ -371,21 +675,25 @@ export function registerGuidesCommands(program) {
371
675
  .description("Access Primitive how-to guides for building apps")
372
676
  .addHelpText("after", `
373
677
  Examples:
374
- $ primitive guides list # List available guides + combinations
678
+ $ primitive guides list # List available guides + languages
375
679
  $ primitive guides list --json # List as JSON for programmatic use
376
680
  $ primitive guides get documents # Fetch the default (TS) documents guide
377
681
  $ primitive guides get documents --language swift # Fetch the Swift variant
378
- $ primitive guides get documents --language swift --platform ios
682
+ $ primitive guides get documents --platform ios # Fetch the platform's language (e.g. Swift)
379
683
  $ primitive guides list --guide-version 1 # List guides for client v1
380
684
  $ primitive guides list --guide-version latest # List guides for the latest line
381
685
 
382
686
  Language & platform:
383
- --language <ts|swift|...> and --platform <web|ios|macos|...> let the calling
384
- agent request a specific guide variant. Aliases: typescript/javascript/js -> ts.
385
- These flags are guide-specific and NEVER fail: an unknown value or an
386
- unavailable (language, platform) combination falls back to the guide's default
387
- variant rather than erroring. (This intentionally differs from 'init --platform',
388
- which validates and exits.) Run 'guides list' to see available combinations.
687
+ --language <ts|swift|...> fetches a specific language variant of a guide.
688
+ Aliases: typescript/javascript/js -> ts.
689
+ --platform <web|ios|macos|...> selects the language a platform targets: the
690
+ guides manifest maps each platform to its language (e.g. ios -> swift), so
691
+ 'guides get documents --platform ios' returns the Swift guide. An explicit
692
+ --language always wins the language dimension.
693
+ Unknown --language values (always), and unknown --platform values (once the
694
+ manifest publishes its platform map), are rejected with a clear error listing
695
+ the supported values — mirroring 'init --platform'. Run 'guides list' to see
696
+ the supported languages and platforms.
389
697
 
390
698
  Versioning:
391
699
  By default, guides are fetched for the detected ${CLIENT_PACKAGE_NAME} version.
@@ -405,16 +713,27 @@ Cache:
405
713
  .option("--json", "Output as JSON")
406
714
  .option("--refresh", "Force refresh from network")
407
715
  .option("--guide-version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
408
- .option("--language <lang>", "Language the agent is targeting (ts, swift, ...; informational here)")
409
- .option("--platform <platform>", "Platform the agent is targeting (web, ios, macos, ...; informational here)")
716
+ .option("--language <lang>", "Language to validate (ts, swift, ...; informational on list)")
717
+ .option("--platform <platform>", "Platform to validate (web, ios, macos, ...; informational on list)")
410
718
  .action(async (options) => {
411
719
  try {
412
720
  const requestedVersion = resolveVersion(options.guideVersion);
413
- const { manifest, stale, versionInfo } = await fetchManifest(requestedVersion, options.refresh);
414
- // Normalize requested dimensions (informational on `list` — surfaced so
415
- // the agent can confirm what it asked for; selection happens on `get`).
721
+ const requestedBranch = resolveDocsBranchForInvocation();
722
+ const resolved = await resolveManifest(requestedBranch, requestedVersion, options.refresh);
723
+ const { manifest, stale, versionInfo, branch } = resolved;
724
+ // Validate the requested dimensions against the loaded manifest (same
725
+ // rules as `get`, for one mental model — Q3). They have no per-row
726
+ // effect on `list`, but an unknown value should fail loudly here too.
727
+ const resolution = validateAndResolveRequest(manifest, options.language, options.platform);
728
+ if (resolution.error) {
729
+ error(resolution.error);
730
+ process.exit(1);
731
+ }
416
732
  const reqLanguage = normalizeLanguage(options.language);
417
733
  const reqPlatform = normalizePlatform(options.platform);
734
+ const supportedLanguages = [...deriveLanguages(manifest)].sort();
735
+ // Report the served docs branch (stderr) when alpha/override is involved.
736
+ reportDocsBranch(resolved);
418
737
  if (stale) {
419
738
  warn("Using stale cache (network unavailable)");
420
739
  }
@@ -422,7 +741,10 @@ Cache:
422
741
  json({
423
742
  version: versionInfo.version,
424
743
  versionSource: versionInfo.source,
744
+ guidesBranch: branch,
425
745
  defaults: manifest.defaults ?? null,
746
+ platforms: manifest.platforms ?? null,
747
+ supportedLanguages,
426
748
  language: reqLanguage ?? null,
427
749
  platform: reqPlatform ?? null,
428
750
  guides: manifest.guides.map((g) => ({
@@ -444,20 +766,41 @@ Cache:
444
766
  }
445
767
  keyValue("Client version", formatClientVersion(versionInfo));
446
768
  keyValue("Guides version", formatGuidesVersion(versionInfo));
769
+ // Manifest-level legend: spell out languages and the platform→language
770
+ // map explicitly, so `--language`/`--platform` are no longer ambiguous.
771
+ keyValue("Languages", supportedLanguages.join(", "));
772
+ keyValue("Platforms", formatPlatformsLegend(manifest));
447
773
  console.log("");
448
774
  const rows = manifest.guides.map((g) => ({
449
775
  topic: g.topic,
450
776
  description: g.description,
451
- combinations: formatCombinations(g),
777
+ languages: formatLanguages(g),
452
778
  }));
779
+ // Size DESCRIPTION from the live terminal width so wide terminals show
780
+ // the full description (the guides.json contract budgets descriptions
781
+ // at <=100 chars; DESCRIPTION_MAX mirrors that cap — see
782
+ // primitive-docs `scripts/sync-guides-json.mjs`) while the table never
783
+ // wraps. Floor 48 keeps today's readable width on an 80-col terminal;
784
+ // on a non-TTY (piped) the column is sized to the cap so full
785
+ // descriptions reach `grep`/agents. `truncate` defensively clips a
786
+ // description that violates the upstream contract.
787
+ const DESCRIPTION_MAX = 100;
453
788
  console.log(formatTable(rows, [
454
789
  { header: "TOPIC", key: "topic" },
455
- { header: "DESCRIPTION", key: "description", width: 48, truncate: true },
456
- { header: "COMBINATIONS", key: "combinations" },
790
+ {
791
+ header: "DESCRIPTION",
792
+ key: "description",
793
+ flex: true,
794
+ truncate: true,
795
+ flexFloor: 48,
796
+ flexMax: DESCRIPTION_MAX,
797
+ },
798
+ { header: "LANGUAGES", key: "languages" },
457
799
  ]));
458
800
  console.log("");
459
- keyValue("Cache location", getVersionCacheDir(versionInfo.version));
460
- info("Use 'primitive guides get <topic> [--language <lang>] [--platform <platform>]' to fetch a guide.");
801
+ keyValue("Cache location", getVersionCacheDir(branch, versionInfo.version));
802
+ info("Use 'primitive guides get <topic> --language <ts|swift>' to fetch a guide.");
803
+ info("Example: primitive guides get documents --language swift");
461
804
  }
462
805
  catch (err) {
463
806
  error(err.message);
@@ -473,31 +816,48 @@ Cache:
473
816
  .option("--refresh", "Force refresh from network")
474
817
  .option("--guide-version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
475
818
  .option("--language <lang>", "Language variant to fetch (ts, swift, ...; aliases typescript/javascript/js -> ts)")
476
- .option("--platform <platform>", "Platform variant to fetch (web, ios, macos, ...)")
819
+ .option("--platform <platform>", "Platform whose language to fetch (web, ios, macos, ...; inferred from the manifest)")
477
820
  .action(async (topic, options) => {
478
821
  try {
479
822
  const requestedVersion = resolveVersion(options.guideVersion);
480
- const { manifest, stale: manifestStale, versionInfo } = await fetchManifest(requestedVersion, options.refresh);
823
+ const requestedBranch = resolveDocsBranchForInvocation();
824
+ const resolved = await resolveManifest(requestedBranch, requestedVersion, options.refresh);
825
+ const { manifest, fromCache, stale: manifestStale, versionInfo, branch } = resolved;
481
826
  const guide = manifest.guides.find((g) => g.topic.toLowerCase() === topic.toLowerCase());
482
827
  if (!guide) {
483
828
  const availableTopics = manifest.guides.map((g) => g.topic).join(", ");
484
829
  error(`Guide "${topic}" not found. Available topics: ${availableTopics}`);
485
830
  process.exit(1);
486
831
  }
487
- // Normalize the requested (language, platform). Unknown values pass
488
- // through and fall back to the default variant (never fail — #977).
489
- const reqLanguage = normalizeLanguage(options.language);
490
- const reqPlatform = normalizePlatform(options.platform);
832
+ // Validate both flags against the loaded manifest and resolve the
833
+ // effective language (issue #1219). An unknown value is a hard error;
834
+ // `--platform ios` infers its language (e.g. swift) when the manifest
835
+ // publishes a `platforms` block, while an explicit `--language` wins.
836
+ const resolution = validateAndResolveRequest(manifest, options.language, options.platform);
837
+ if (resolution.error) {
838
+ error(resolution.error);
839
+ process.exit(1);
840
+ }
841
+ // `reqLanguage` is the EFFECTIVE language after platform inference; pass
842
+ // it to both the JSON selection and the content fetch so they can't
843
+ // desynchronize.
844
+ const reqLanguage = resolution.language;
845
+ const reqPlatform = resolution.platform;
846
+ const requestedAnyDimension = normalizeLanguage(options.language) !== undefined ||
847
+ normalizePlatform(options.platform) !== undefined;
491
848
  const selection = selectVariant(guide, {
492
849
  language: reqLanguage,
493
850
  platform: reqPlatform,
494
851
  });
852
+ // Report the served docs branch (stderr) when alpha/override is involved.
853
+ reportDocsBranch(resolved);
495
854
  if (options.json) {
496
855
  // Output just metadata — does NOT fetch content. `selectedFile` is the
497
856
  // file that WOULD be served for this request (see selection above).
498
857
  json({
499
858
  version: versionInfo.version,
500
859
  versionSource: versionInfo.source,
860
+ guidesBranch: branch,
501
861
  topic: guide.topic,
502
862
  description: guide.description,
503
863
  keywords: guide.keywords,
@@ -507,19 +867,77 @@ Cache:
507
867
  relatedGuides: guide.relatedGuides,
508
868
  language: reqLanguage ?? null,
509
869
  platform: reqPlatform ?? null,
870
+ defaultPlatform: manifest.defaults?.platform ?? null,
510
871
  selectedFile: selection.file,
511
872
  matchedOn: selection.matchedOn,
512
873
  availableVariants: normalizeVariants(guide),
513
- cacheLocation: join(getGuidesCacheDir(versionInfo.version), basename(selection.file)),
874
+ cacheLocation: join(getGuidesCacheDir(branch, versionInfo.version), basename(selection.file)),
514
875
  });
515
876
  return;
516
877
  }
517
- const { content, stale: guideStale } = await fetchGuide(guide, versionInfo.version, { language: reqLanguage, platform: reqPlatform }, options.refresh);
518
- if (manifestStale || guideStale) {
878
+ const request = { language: reqLanguage, platform: reqPlatform };
879
+ let result;
880
+ try {
881
+ result = await fetchGuide(guide, branch, versionInfo.version, request, options.refresh);
882
+ }
883
+ catch (err) {
884
+ // Only a 404 is healable here. Transport errors already served stale
885
+ // (or threw) inside fetchGuide; rethrow anything that isn't a 404.
886
+ if (!(err instanceof GuideFetchError && err.status === 404)) {
887
+ throw err;
888
+ }
889
+ // Heal only when the manifest came from cache — then the named file
890
+ // may have been renamed/removed upstream while our manifest is stale.
891
+ // Force a manifest refetch, re-find the topic, and retry the file
892
+ // ONCE. A fresh-from-network manifest (incl. --refresh) makes the 404
893
+ // authoritative, so we skip straight to the last resort below (#1034).
894
+ if (fromCache) {
895
+ try {
896
+ // Heal on the SAME branch the manifest resolved on, so the
897
+ // manifest and guide never disagree (#1463, behavior 4).
898
+ const refreshed = await fetchManifest(branch, requestedVersion, /* forceRefresh */ true);
899
+ const freshGuide = refreshed.manifest.guides.find((g) => g.topic.toLowerCase() === topic.toLowerCase());
900
+ if (freshGuide) {
901
+ // Re-calling fetchGuide recomputes selectVariant + the cache path
902
+ // for the (possibly new) filename — no extra resolve site. If the
903
+ // filename is unchanged it just 404s again and falls through.
904
+ const retried = await fetchGuide(freshGuide, branch, refreshed.versionInfo.version, request, options.refresh);
905
+ if (basename(retried.selectedFile) !== basename(err.selectedFile)) {
906
+ warn(`Guide "${topic}" file moved upstream; refreshed manifest and retried.`);
907
+ }
908
+ result = retried;
909
+ }
910
+ }
911
+ catch {
912
+ // Heal failed (manifest refetch failed / retry 404'd / topic gone)
913
+ // → fall through to the option-(b) last resort on the ORIGINAL error.
914
+ }
915
+ }
916
+ // Option (b) last resort — applies to ALL 404s, not just cached-manifest
917
+ // ones, so `--refresh`-on-404 still serves an old on-disk copy as it
918
+ // does today. Serve the old copy if we have one; otherwise surface the
919
+ // original terminal 404.
920
+ if (!result) {
921
+ if (err.staleContent !== undefined) {
922
+ result = { content: err.staleContent, stale: true, selectedFile: err.selectedFile };
923
+ }
924
+ else {
925
+ throw err;
926
+ }
927
+ }
928
+ }
929
+ if (manifestStale || result.stale) {
519
930
  warn("Using stale cache (network unavailable)");
520
931
  }
932
+ // Transparency note (issue #1219): a language/platform was requested but
933
+ // this topic has no variant pinning it, so the default file was served.
934
+ // This closes the residual silent-fallback footgun beyond `--platform`.
935
+ // The note goes to STDERR (via `info`) so stdout stays clean for piping.
936
+ if (requestedAnyDimension && selection.matchedOn === "default") {
937
+ info(`No ${reqLanguage ?? reqPlatform} variant for "${guide.topic}"; served the default guide.`);
938
+ }
521
939
  // Print the guide content directly to stdout
522
- console.log(content);
940
+ console.log(result.content);
523
941
  }
524
942
  catch (err) {
525
943
  error(err.message);