primitive-admin 1.1.0-alpha.4 → 1.1.0-alpha.40

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 (216) hide show
  1. package/README.md +325 -31
  2. package/assets/skill/skills/primitive-platform/SKILL.md +281 -0
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +266 -16
  5. package/dist/bin/primitive.js.map +1 -1
  6. package/dist/src/commands/admins.d.ts +2 -0
  7. package/dist/src/commands/admins.js +123 -18
  8. package/dist/src/commands/admins.js.map +1 -1
  9. package/dist/src/commands/analytics.d.ts +2 -0
  10. package/dist/src/commands/analytics.js +464 -55
  11. package/dist/src/commands/analytics.js.map +1 -1
  12. package/dist/src/commands/apps.d.ts +2 -0
  13. package/dist/src/commands/apps.js +55 -13
  14. package/dist/src/commands/apps.js.map +1 -1
  15. package/dist/src/commands/auth.d.ts +2 -0
  16. package/dist/src/commands/auth.js +177 -7
  17. package/dist/src/commands/auth.js.map +1 -1
  18. package/dist/src/commands/blob-buckets.d.ts +2 -0
  19. package/dist/src/commands/blob-buckets.js +422 -0
  20. package/dist/src/commands/blob-buckets.js.map +1 -0
  21. package/dist/src/commands/catalog.d.ts +2 -0
  22. package/dist/src/commands/catalog.js +34 -35
  23. package/dist/src/commands/catalog.js.map +1 -1
  24. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  25. package/dist/src/commands/collection-type-configs.js +178 -0
  26. package/dist/src/commands/collection-type-configs.js.map +1 -0
  27. package/dist/src/commands/collections.d.ts +2 -0
  28. package/dist/src/commands/collections.js +515 -0
  29. package/dist/src/commands/collections.js.map +1 -0
  30. package/dist/src/commands/comparisons.d.ts +2 -0
  31. package/dist/src/commands/comparisons.js +6 -6
  32. package/dist/src/commands/comparisons.js.map +1 -1
  33. package/dist/src/commands/cron-triggers.d.ts +2 -0
  34. package/dist/src/commands/cron-triggers.js +357 -0
  35. package/dist/src/commands/cron-triggers.js.map +1 -0
  36. package/dist/src/commands/database-types.d.ts +2 -0
  37. package/dist/src/commands/database-types.js +471 -0
  38. package/dist/src/commands/database-types.js.map +1 -0
  39. package/dist/src/commands/databases.d.ts +2 -0
  40. package/dist/src/commands/databases.js +1462 -83
  41. package/dist/src/commands/databases.js.map +1 -1
  42. package/dist/src/commands/documents.d.ts +2 -0
  43. package/dist/src/commands/documents.js +518 -11
  44. package/dist/src/commands/documents.js.map +1 -1
  45. package/dist/src/commands/email-templates.d.ts +2 -0
  46. package/dist/src/commands/email-templates.js +281 -0
  47. package/dist/src/commands/email-templates.js.map +1 -0
  48. package/dist/src/commands/env.d.ts +12 -0
  49. package/dist/src/commands/env.js +260 -0
  50. package/dist/src/commands/env.js.map +1 -0
  51. package/dist/src/commands/group-type-configs.d.ts +2 -0
  52. package/dist/src/commands/group-type-configs.js +181 -0
  53. package/dist/src/commands/group-type-configs.js.map +1 -0
  54. package/dist/src/commands/groups.d.ts +2 -0
  55. package/dist/src/commands/groups.js +45 -43
  56. package/dist/src/commands/groups.js.map +1 -1
  57. package/dist/src/commands/guides.d.ts +159 -0
  58. package/dist/src/commands/guides.js +449 -35
  59. package/dist/src/commands/guides.js.map +1 -1
  60. package/dist/src/commands/init.d.ts +17 -0
  61. package/dist/src/commands/init.js +811 -201
  62. package/dist/src/commands/init.js.map +1 -1
  63. package/dist/src/commands/integrations.d.ts +2 -0
  64. package/dist/src/commands/integrations.js +533 -46
  65. package/dist/src/commands/integrations.js.map +1 -1
  66. package/dist/src/commands/llm.d.ts +2 -0
  67. package/dist/src/commands/llm.js +4 -2
  68. package/dist/src/commands/llm.js.map +1 -1
  69. package/dist/src/commands/prompts.d.ts +2 -0
  70. package/dist/src/commands/prompts.js +50 -52
  71. package/dist/src/commands/prompts.js.map +1 -1
  72. package/dist/src/commands/rule-sets.d.ts +3 -0
  73. package/dist/src/commands/rule-sets.js +388 -0
  74. package/dist/src/commands/rule-sets.js.map +1 -0
  75. package/dist/src/commands/scripts.d.ts +2 -0
  76. package/dist/src/commands/scripts.js +667 -0
  77. package/dist/src/commands/scripts.js.map +1 -0
  78. package/dist/src/commands/secrets.d.ts +2 -0
  79. package/dist/src/commands/secrets.js +108 -0
  80. package/dist/src/commands/secrets.js.map +1 -0
  81. package/dist/src/commands/skill.d.ts +2 -0
  82. package/dist/src/commands/skill.js +29 -0
  83. package/dist/src/commands/skill.js.map +1 -0
  84. package/dist/src/commands/sync.d.ts +174 -0
  85. package/dist/src/commands/sync.js +4392 -235
  86. package/dist/src/commands/sync.js.map +1 -1
  87. package/dist/src/commands/tokens.d.ts +2 -0
  88. package/dist/src/commands/tokens.js +122 -19
  89. package/dist/src/commands/tokens.js.map +1 -1
  90. package/dist/src/commands/users.d.ts +2 -0
  91. package/dist/src/commands/users.js +440 -22
  92. package/dist/src/commands/users.js.map +1 -1
  93. package/dist/src/commands/waitlist.d.ts +2 -0
  94. package/dist/src/commands/waitlist.js +10 -10
  95. package/dist/src/commands/waitlist.js.map +1 -1
  96. package/dist/src/commands/webhooks.d.ts +2 -0
  97. package/dist/src/commands/webhooks.js +391 -0
  98. package/dist/src/commands/webhooks.js.map +1 -0
  99. package/dist/src/commands/workflows.d.ts +49 -0
  100. package/dist/src/commands/workflows.js +1009 -123
  101. package/dist/src/commands/workflows.js.map +1 -1
  102. package/dist/src/lib/api-client.d.ts +1272 -0
  103. package/dist/src/lib/api-client.js +1115 -83
  104. package/dist/src/lib/api-client.js.map +1 -1
  105. package/dist/src/lib/auth-flow.d.ts +8 -0
  106. package/dist/src/lib/block-layout.d.ts +160 -0
  107. package/dist/src/lib/block-layout.js +451 -0
  108. package/dist/src/lib/block-layout.js.map +1 -0
  109. package/dist/src/lib/cli-manifest.d.ts +60 -0
  110. package/dist/src/lib/cli-manifest.js +70 -0
  111. package/dist/src/lib/cli-manifest.js.map +1 -0
  112. package/dist/src/lib/config.d.ts +53 -0
  113. package/dist/src/lib/config.js +92 -53
  114. package/dist/src/lib/config.js.map +1 -1
  115. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  116. package/dist/src/lib/confirm-prompt.js +110 -0
  117. package/dist/src/lib/confirm-prompt.js.map +1 -0
  118. package/dist/src/lib/constants.d.ts +2 -0
  119. package/dist/src/lib/constants.js +3 -0
  120. package/dist/src/lib/constants.js.map +1 -0
  121. package/dist/src/lib/crash-handlers.d.ts +20 -0
  122. package/dist/src/lib/crash-handlers.js +49 -0
  123. package/dist/src/lib/crash-handlers.js.map +1 -0
  124. package/dist/src/lib/credentials-store.d.ts +79 -0
  125. package/dist/src/lib/credentials-store.js +307 -0
  126. package/dist/src/lib/credentials-store.js.map +1 -0
  127. package/dist/src/lib/csv.d.ts +48 -0
  128. package/dist/src/lib/csv.js +177 -0
  129. package/dist/src/lib/csv.js.map +1 -0
  130. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  131. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  132. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  133. package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
  134. package/dist/src/lib/db-codegen/dbGenerator.js +371 -0
  135. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  136. package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
  137. package/dist/src/lib/db-codegen/dbNaming.js +104 -0
  138. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  139. package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
  140. package/dist/src/lib/db-codegen/dbTemplates.js +159 -0
  141. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  142. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
  143. package/dist/src/lib/db-codegen/dbTsTypes.js +112 -0
  144. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  145. package/dist/src/lib/env-resolver.d.ts +62 -0
  146. package/dist/src/lib/env-resolver.js +121 -0
  147. package/dist/src/lib/env-resolver.js.map +1 -0
  148. package/dist/src/lib/fetch.d.ts +5 -0
  149. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  150. package/dist/src/lib/generated-allowlist.js +213 -0
  151. package/dist/src/lib/generated-allowlist.js.map +1 -0
  152. package/dist/src/lib/init-config.d.ts +59 -0
  153. package/dist/src/lib/init-config.js +113 -0
  154. package/dist/src/lib/init-config.js.map +1 -0
  155. package/dist/src/lib/migration-nag.d.ts +49 -0
  156. package/dist/src/lib/migration-nag.js +163 -0
  157. package/dist/src/lib/migration-nag.js.map +1 -0
  158. package/dist/src/lib/output.d.ts +86 -0
  159. package/dist/src/lib/output.js +150 -8
  160. package/dist/src/lib/output.js.map +1 -1
  161. package/dist/src/lib/paginate.d.ts +33 -0
  162. package/dist/src/lib/paginate.js +42 -0
  163. package/dist/src/lib/paginate.js.map +1 -0
  164. package/dist/src/lib/project-config.d.ts +97 -0
  165. package/dist/src/lib/project-config.js +209 -0
  166. package/dist/src/lib/project-config.js.map +1 -0
  167. package/dist/src/lib/query-operators.d.ts +43 -0
  168. package/dist/src/lib/query-operators.js +80 -0
  169. package/dist/src/lib/query-operators.js.map +1 -0
  170. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  171. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  172. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  173. package/dist/src/lib/resolve-platform.d.ts +45 -0
  174. package/dist/src/lib/resolve-platform.js +43 -0
  175. package/dist/src/lib/resolve-platform.js.map +1 -0
  176. package/dist/src/lib/skill-installer.d.ts +23 -0
  177. package/dist/src/lib/skill-installer.js +135 -0
  178. package/dist/src/lib/skill-installer.js.map +1 -0
  179. package/dist/src/lib/snapshots.d.ts +99 -0
  180. package/dist/src/lib/snapshots.js +357 -0
  181. package/dist/src/lib/snapshots.js.map +1 -0
  182. package/dist/src/lib/sync-paths.d.ts +72 -0
  183. package/dist/src/lib/sync-paths.js +130 -0
  184. package/dist/src/lib/sync-paths.js.map +1 -0
  185. package/dist/src/lib/template.d.ts +97 -0
  186. package/dist/src/lib/template.js +336 -62
  187. package/dist/src/lib/template.js.map +1 -1
  188. package/dist/src/lib/token-inject.d.ts +56 -0
  189. package/dist/src/lib/token-inject.js +204 -0
  190. package/dist/src/lib/token-inject.js.map +1 -0
  191. package/dist/src/lib/toml-database-config.d.ts +152 -0
  192. package/dist/src/lib/toml-database-config.js +600 -0
  193. package/dist/src/lib/toml-database-config.js.map +1 -0
  194. package/dist/src/lib/toml-metadata-config.d.ts +108 -0
  195. package/dist/src/lib/toml-metadata-config.js +371 -0
  196. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  197. package/dist/src/lib/toml-params-validator.d.ts +129 -0
  198. package/dist/src/lib/toml-params-validator.js +298 -0
  199. package/dist/src/lib/toml-params-validator.js.map +1 -0
  200. package/dist/src/lib/version-check.d.ts +10 -0
  201. package/dist/src/lib/version-check.js +172 -0
  202. package/dist/src/lib/version-check.js.map +1 -0
  203. package/dist/src/lib/workflow-apply.d.ts +66 -0
  204. package/dist/src/lib/workflow-apply.js +117 -0
  205. package/dist/src/lib/workflow-apply.js.map +1 -0
  206. package/dist/src/lib/workflow-fragments.d.ts +41 -0
  207. package/dist/src/lib/workflow-fragments.js +121 -0
  208. package/dist/src/lib/workflow-fragments.js.map +1 -0
  209. package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
  210. package/dist/src/lib/workflow-toml-validator.js +323 -0
  211. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  212. package/dist/src/types/index.d.ts +520 -0
  213. package/dist/src/validators.d.ts +64 -0
  214. package/dist/src/validators.js +63 -0
  215. package/dist/src/validators.js.map +1 -0
  216. package/package.json +18 -4
@@ -9,6 +9,187 @@ const CACHE_META_FILE = join(GUIDES_CACHE_DIR, "cache-meta.json");
9
9
  const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/Primitive-Labs/primitive-docs/main/guides";
10
10
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
11
  const CLIENT_PACKAGE_NAME = "js-bao-wss-client";
12
+ // Language aliases — map the agent's natural vocabulary onto canonical values.
13
+ const LANGUAGE_ALIASES = {
14
+ typescript: "ts",
15
+ javascript: "ts",
16
+ js: "ts",
17
+ };
18
+ /**
19
+ * Normalize a `--language` flag value: lowercase, trim, and map known aliases
20
+ * (typescript/javascript/js → ts). Unknown values pass through unchanged.
21
+ *
22
+ * NOTE: guide flags are intentionally NOT validated against a fixed enum. An
23
+ * unknown/typo'd value simply matches no variant and falls back to the guide's
24
+ * default `file` (the "never fail" behavior from #977). This DIVERGES from
25
+ * `init --platform`, which validates and exits for unsupported values
26
+ * (cli/src/commands/init.ts) — that divergence is deliberate and guide-specific.
27
+ */
28
+ export function normalizeLanguage(value) {
29
+ if (value === undefined)
30
+ return undefined;
31
+ const lower = value.trim().toLowerCase();
32
+ if (lower === "")
33
+ return undefined;
34
+ return LANGUAGE_ALIASES[lower] ?? lower;
35
+ }
36
+ /**
37
+ * Normalize a `--platform` flag value: lowercase + trim. No aliases for now.
38
+ * Like `normalizeLanguage`, unknown values pass through and fall back to the
39
+ * default `file` rather than erroring (guide-specific "never fail" behavior).
40
+ */
41
+ export function normalizePlatform(value) {
42
+ if (value === undefined)
43
+ return undefined;
44
+ const lower = value.trim().toLowerCase();
45
+ if (lower === "")
46
+ return undefined;
47
+ return lower;
48
+ }
49
+ /**
50
+ * Select the guide variant filename for a requested (language, platform).
51
+ *
52
+ * Implemented as ordered `find()` passes — NOT a wildcard scoring system —
53
+ * with the no-flags case special-cased to the default `file`:
54
+ *
55
+ * 0. No flags at all → guide.file (deterministic "default")
56
+ * 1. Exact pair → variant pins BOTH requested dims
57
+ * 2. Language-only → variant pins language; platform agnostic
58
+ * 3. Platform-only → variant pins platform; language agnostic
59
+ * 4. Default → guide.file
60
+ *
61
+ * An omitted REQUEST dimension acts as a wildcard during matching (a
62
+ * `--language swift` request matches a swift variant regardless of platform).
63
+ * This function NEVER throws — an unknown/typo'd/unavailable combination simply
64
+ * falls through to the guide's default `file`.
65
+ */
66
+ export function selectVariant(guide, request) {
67
+ const reqLang = request.language;
68
+ const reqPlat = request.platform;
69
+ const variants = guide.variants ?? [];
70
+ // 0. No flags supplied at all → deterministically serve the default file.
71
+ if (reqLang === undefined && reqPlat === undefined) {
72
+ return { file: guide.file, matchedOn: "default" };
73
+ }
74
+ // A variant "matches" a dimension if the request omits it (wildcard) OR the
75
+ // variant pins it to exactly the requested value. A variant that omits a
76
+ // dimension is agnostic and matches any requested value for that dimension.
77
+ const langMatches = (v) => reqLang === undefined || v.language === undefined || v.language === reqLang;
78
+ const platMatches = (v) => reqPlat === undefined || v.platform === undefined || v.platform === reqPlat;
79
+ // 1. Exact pair: variant pins both requested dimensions to the request.
80
+ if (reqLang !== undefined && reqPlat !== undefined) {
81
+ const exact = variants.find((v) => v.language === reqLang && v.platform === reqPlat);
82
+ if (exact)
83
+ return { file: exact.file, matchedOn: "exact" };
84
+ }
85
+ // 2. Language requested. Prefer a platform-agnostic variant that pins the
86
+ // language (reported as "language" — pinned one dim, agnostic on the
87
+ // other). If none exists, honor the omitted-platform wildcard and fall
88
+ // back to a pair-specific variant that still satisfies the request
89
+ // (e.g. only `{swift,ios}` published + `--language swift`). That fallback
90
+ // pins BOTH dimensions to satisfy the request, so it's reported as
91
+ // "exact" — the variant is as specific as an exact-pair hit.
92
+ if (reqLang !== undefined) {
93
+ const langAgnostic = variants.find((v) => v.language === reqLang && v.platform === undefined);
94
+ if (langAgnostic)
95
+ return { file: langAgnostic.file, matchedOn: "language" };
96
+ const langWildcard = variants.find((v) => v.language === reqLang && platMatches(v));
97
+ if (langWildcard)
98
+ return { file: langWildcard.file, matchedOn: "exact" };
99
+ }
100
+ // 3. Platform requested (symmetric to pass 2). Prefer a language-agnostic
101
+ // variant that pins the platform ("platform"); otherwise honor the
102
+ // omitted-language wildcard and fall back to a pair-specific variant
103
+ // satisfying the request, reported as "exact".
104
+ if (reqPlat !== undefined) {
105
+ const platAgnostic = variants.find((v) => v.platform === reqPlat && v.language === undefined);
106
+ if (platAgnostic)
107
+ return { file: platAgnostic.file, matchedOn: "platform" };
108
+ const platWildcard = variants.find((v) => v.platform === reqPlat && langMatches(v));
109
+ if (platWildcard)
110
+ return { file: platWildcard.file, matchedOn: "exact" };
111
+ }
112
+ // 4. Nothing matched → the guide's default file (never throws).
113
+ return { file: guide.file, matchedOn: "default" };
114
+ }
115
+ /**
116
+ * The global set of languages a manifest supports. Derived (never hardcoded)
117
+ * from: every guide variant's `language`, the manifest-level `defaults.language`
118
+ * (falling back to `"ts"` when absent), and every `platforms[*].language`. Used
119
+ * to validate `--language` and to render the `guides list` legend / `--json`.
120
+ *
121
+ * Derived per-request from the manifest ACTUALLY loaded (so it stays correct
122
+ * for stale-cache / `--guide-version` fallbacks), and grows automatically as
123
+ * new languages appear in the manifest — no CLI change needed.
124
+ */
125
+ export function deriveLanguages(manifest) {
126
+ const languages = new Set();
127
+ for (const guide of manifest.guides ?? []) {
128
+ for (const variant of guide.variants ?? []) {
129
+ if (variant.language !== undefined)
130
+ languages.add(variant.language);
131
+ }
132
+ }
133
+ languages.add(manifest.defaults?.language ?? "ts");
134
+ for (const entry of Object.values(manifest.platforms ?? {})) {
135
+ if (entry.language)
136
+ languages.add(entry.language);
137
+ }
138
+ return languages;
139
+ }
140
+ /**
141
+ * Validate the requested `(language, platform)` against the loaded manifest and
142
+ * resolve the effective language (issue #1219). Behavior:
143
+ *
144
+ * - Normalizes both values first (lowercase + trim, aliases, `"" → undefined`)
145
+ * via the existing `normalizeLanguage`/`normalizePlatform` helpers, then
146
+ * validates the NORMALIZED value.
147
+ * - `--language` is validated against `deriveLanguages(manifest)` whether or
148
+ * not a `platforms` block exists (the set is derivable from any manifest).
149
+ * - `--platform` is validated + inferred ONLY when `manifest.platforms` is
150
+ * present. With the block, an unknown platform is a hard error and a known
151
+ * platform infers `language = platforms[p].language` (an explicit
152
+ * `--language` always wins the language dimension). WITHOUT the block, the
153
+ * platform passes through unvalidated and uninferred — today's non-enforcing
154
+ * behavior, so older manifests / stale cache don't start hard-failing.
155
+ * - Unknown values produce a hard-error message mirroring `init --platform`'s
156
+ * style, plus a cross-hint when the bad value names the other dimension
157
+ * (`--platform swift` → "Did you mean --language swift?", and vice versa).
158
+ */
159
+ export function validateAndResolveRequest(manifest, rawLanguage, rawPlatform) {
160
+ const language = normalizeLanguage(rawLanguage);
161
+ const platform = normalizePlatform(rawPlatform);
162
+ const supportedLanguages = deriveLanguages(manifest);
163
+ const platforms = manifest.platforms; // may be undefined (back-compat)
164
+ // Validate --language (independent of the platforms block).
165
+ if (language !== undefined && !supportedLanguages.has(language)) {
166
+ const supported = [...supportedLanguages].sort().join(", ");
167
+ let message = `Unknown language "${language}". Supported languages: ${supported}`;
168
+ // Cross-hint: the value names a known platform, not a language.
169
+ if (platforms && platforms[language]) {
170
+ message += `. Did you mean --platform ${language}?`;
171
+ }
172
+ return { error: message };
173
+ }
174
+ let effectiveLanguage = language;
175
+ // Validate + infer --platform — active ONLY when the platforms block exists.
176
+ if (platform !== undefined && platforms !== undefined) {
177
+ if (!platforms[platform]) {
178
+ const supported = Object.keys(platforms).sort().join(", ");
179
+ let message = `Unknown platform "${platform}". Supported platforms: ${supported}`;
180
+ // Cross-hint: the value names a known language, not a platform
181
+ // (the reporter's exact `--platform swift` case).
182
+ if (supportedLanguages.has(platform)) {
183
+ message += `. Did you mean --language ${platform}?`;
184
+ }
185
+ return { error: message };
186
+ }
187
+ // Infer the language from the platform; an explicit --language wins.
188
+ effectiveLanguage = language ?? platforms[platform].language;
189
+ }
190
+ // else: no platforms block → back-compat pass-through (no validate, no infer).
191
+ return { language: effectiveLanguage, platform };
192
+ }
12
193
  function detectClientVersion() {
13
194
  const clientPkgPath = join(process.cwd(), "node_modules", CLIENT_PACKAGE_NAME, "package.json");
14
195
  try {
@@ -184,28 +365,71 @@ async function fetchManifest(versionInfo, forceRefresh = false) {
184
365
  }
185
366
  throw new Error(`Failed to fetch guides manifest for ${versionInfo.version}`);
186
367
  }
187
- async function fetchGuide(guide, version, forceRefresh = false) {
368
+ /**
369
+ * Internal error thrown by `fetchGuide` on a non-ok guide-file response. NOT
370
+ * exported. `status` lets the `get` action distinguish a 404 (the cached
371
+ * manifest may name a file that was renamed/removed upstream — heal by
372
+ * refetching the manifest) from a transport error (keep the stale-cache
373
+ * fallback). `staleContent` carries the on-disk copy for the failed filename,
374
+ * if any, so the action can serve it as the option-(b) last resort (#1034).
375
+ */
376
+ class GuideFetchError extends Error {
377
+ status;
378
+ selectedFile;
379
+ staleContent;
380
+ constructor(status, message, selectedFile, staleContent) {
381
+ super(message);
382
+ this.status = status;
383
+ this.selectedFile = selectedFile;
384
+ this.staleContent = staleContent;
385
+ this.name = "GuideFetchError";
386
+ }
387
+ }
388
+ /** Read an on-disk cached guide file, or undefined if absent/corrupted. */
389
+ function readCachedGuide(cachedPath) {
390
+ if (!existsSync(cachedPath))
391
+ return undefined;
392
+ try {
393
+ return readFileSync(cachedPath, "utf-8");
394
+ }
395
+ catch {
396
+ return undefined; // corrupted cache
397
+ }
398
+ }
399
+ async function fetchGuide(guide, version, request = {}, forceRefresh = false) {
188
400
  ensureCacheDir(version);
189
401
  const meta = loadCacheMeta();
190
- const guideFileName = basename(guide.file);
402
+ // Resolve the variant filename for the requested (language, platform).
403
+ const { file: selectedFile } = selectVariant(guide, request);
404
+ const guideFileName = basename(selectedFile);
191
405
  const cachedPath = join(getGuidesCacheDir(version), guideFileName);
192
- const fetchedAt = meta.guidesFetchedAt?.[version]?.[guide.topic];
406
+ // Freshness is keyed by the RESOLVED filename (not topic) so a `ts` fetch
407
+ // cannot mark the `swift` variant fresh — and vice versa (#977).
408
+ const fetchedAt = meta.guidesFetchedAt?.[version]?.[guideFileName];
193
409
  const cacheExpired = isCacheExpired(fetchedAt);
194
410
  const hasCachedGuide = existsSync(cachedPath);
195
411
  // If cache is valid and not forcing refresh, use it
196
412
  if (!forceRefresh && !cacheExpired && hasCachedGuide) {
197
413
  try {
198
414
  const content = readFileSync(cachedPath, "utf-8");
199
- return { content, fromCache: true, stale: false };
415
+ return { content, fromCache: true, stale: false, selectedFile };
200
416
  }
201
417
  catch {
202
418
  // Cache corrupted, will fetch fresh
203
419
  }
204
420
  }
205
421
  // Try to fetch from network
206
- const guideUrl = getGuideUrl(version, guide.file);
422
+ const guideUrl = getGuideUrl(version, selectedFile);
207
423
  try {
208
424
  const response = await fetchWithTimeout(guideUrl);
425
+ if (response.status === 404) {
426
+ // The cache-derived filename no longer exists upstream (a docs rename /
427
+ // removal). Let the 404 escape to the `get` action so it can refetch the
428
+ // manifest and re-resolve — heal must take priority over serving stale.
429
+ // Attach the on-disk copy (if any) so the action can serve it as the
430
+ // option-(b) last resort when the heal can't help (#1034).
431
+ throw new GuideFetchError(404, `Failed to fetch guide "${guide.topic}": HTTP 404: ${response.statusText}`, selectedFile, readCachedGuide(cachedPath));
432
+ }
209
433
  if (!response.ok) {
210
434
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
211
435
  }
@@ -214,39 +438,99 @@ async function fetchGuide(guide, version, forceRefresh = false) {
214
438
  writeFileSync(cachedPath, content);
215
439
  meta.guidesFetchedAt = meta.guidesFetchedAt || {};
216
440
  meta.guidesFetchedAt[version] = meta.guidesFetchedAt[version] || {};
217
- meta.guidesFetchedAt[version][guide.topic] = new Date().toISOString();
441
+ meta.guidesFetchedAt[version][guideFileName] = new Date().toISOString();
218
442
  saveCacheMeta(meta);
219
- return { content, fromCache: false, stale: false };
443
+ return { content, fromCache: false, stale: false, selectedFile };
220
444
  }
221
445
  catch (err) {
222
- // Network failed, try stale cache
223
- if (hasCachedGuide) {
224
- try {
225
- const content = readFileSync(cachedPath, "utf-8");
226
- return { content, fromCache: true, stale: true };
227
- }
228
- catch {
229
- // Cache corrupted
230
- }
446
+ // A 404 must reach the action (heal takes priority over stale) — re-throw it
447
+ // unchanged rather than serving the stale copy here.
448
+ if (err instanceof GuideFetchError && err.status === 404) {
449
+ throw err;
450
+ }
451
+ // Non-404 (transport error / 5xx): serve the stale on-disk copy if present
452
+ // (Fork F — transport problems are not manifest skew), else throw.
453
+ const stale = readCachedGuide(cachedPath);
454
+ if (stale !== undefined) {
455
+ return { content: stale, fromCache: true, stale: true, selectedFile };
231
456
  }
232
457
  throw new Error(`Failed to fetch guide "${guide.topic}": ${err.message}`);
233
458
  }
234
459
  }
460
+ /** Normalize a guide's variants for display / --json output. */
461
+ function normalizeVariants(guide) {
462
+ return (guide.variants ?? []).map((v) => ({
463
+ ...(v.language !== undefined ? { language: v.language } : {}),
464
+ ...(v.platform !== undefined ? { platform: v.platform } : {}),
465
+ file: v.file,
466
+ }));
467
+ }
468
+ /**
469
+ * Compact rendering of the LANGUAGES a guide offers, for the `guides list`
470
+ * table (issue #1219 renamed the ambiguous `COMBINATIONS` column to
471
+ * `LANGUAGES`). Lists each variant's `language`, de-duplicated and order-
472
+ * preserving; a guide with no language-pinned variants renders `default`.
473
+ * Platforms are surfaced separately as a manifest-level legend, since they are
474
+ * a manifest-wide map rather than a per-guide attribute.
475
+ */
476
+ export function formatLanguages(guide) {
477
+ const variants = guide.variants ?? [];
478
+ if (variants.length === 0)
479
+ return "default";
480
+ const languages = variants
481
+ .map((v) => v.language)
482
+ .filter((l) => l !== undefined);
483
+ if (languages.length === 0)
484
+ return "default";
485
+ return [...new Set(languages)].join("; ");
486
+ }
487
+ /**
488
+ * Render the manifest-level platform→language legend for `guides list`, e.g.
489
+ * `web -> ts, ios -> swift, macos -> swift`. When the manifest has no
490
+ * `platforms` block (older manifests / stale cache, before
491
+ * Primitive-Labs/primitive-docs#194), platforms aren't modeled yet, so this
492
+ * reports `none defined yet` rather than implying a map that doesn't exist.
493
+ */
494
+ function formatPlatformsLegend(manifest) {
495
+ const platforms = manifest.platforms;
496
+ if (!platforms || Object.keys(platforms).length === 0) {
497
+ return "none defined yet";
498
+ }
499
+ return Object.entries(platforms)
500
+ .map(([platform, entry]) => `${platform} -> ${entry.language}`)
501
+ .join(", ");
502
+ }
235
503
  export function registerGuidesCommands(program) {
236
504
  const guides = program
237
505
  .command("guides")
238
506
  .description("Access Primitive how-to guides for building apps")
239
507
  .addHelpText("after", `
240
508
  Examples:
241
- $ primitive guides list # List available guides
242
- $ primitive guides list --json # List as JSON for programmatic use
243
- $ primitive guides get documents # Fetch and display the documents guide
244
- $ primitive guides get workflows # Fetch and display the workflows guide
245
- $ primitive guides list --version 1 # List guides for client v1
509
+ $ primitive guides list # List available guides + languages
510
+ $ primitive guides list --json # List as JSON for programmatic use
511
+ $ primitive guides get documents # Fetch the default (TS) documents guide
512
+ $ primitive guides get documents --language swift # Fetch the Swift variant
513
+ $ primitive guides get documents --platform ios # Fetch the platform's language (e.g. Swift)
514
+ $ primitive guides list --guide-version 1 # List guides for client v1
515
+ $ primitive guides list --guide-version latest # List guides for the latest line
516
+
517
+ Language & platform:
518
+ --language <ts|swift|...> fetches a specific language variant of a guide.
519
+ Aliases: typescript/javascript/js -> ts.
520
+ --platform <web|ios|macos|...> selects the language a platform targets: the
521
+ guides manifest maps each platform to its language (e.g. ios -> swift), so
522
+ 'guides get documents --platform ios' returns the Swift guide. An explicit
523
+ --language always wins the language dimension.
524
+ Unknown --language values (always), and unknown --platform values (once the
525
+ manifest publishes its platform map), are rejected with a clear error listing
526
+ the supported values — mirroring 'init --platform'. Run 'guides list' to see
527
+ the supported languages and platforms.
246
528
 
247
529
  Versioning:
248
530
  By default, guides are fetched for the detected ${CLIENT_PACKAGE_NAME} version.
249
- Use --version to explicitly request a specific major version.
531
+ Use --guide-version to explicitly request a specific major version (e.g. 1, 2,
532
+ or 'latest'). (The flag is --guide-version, not --version, because the root
533
+ 'primitive --version' prints the CLI version and would otherwise swallow it.)
250
534
  Falls back to 'latest' if the version is not found.
251
535
 
252
536
  Cache:
@@ -259,11 +543,24 @@ Cache:
259
543
  .description("List available guides")
260
544
  .option("--json", "Output as JSON")
261
545
  .option("--refresh", "Force refresh from network")
262
- .option("--version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
546
+ .option("--guide-version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
547
+ .option("--language <lang>", "Language to validate (ts, swift, ...; informational on list)")
548
+ .option("--platform <platform>", "Platform to validate (web, ios, macos, ...; informational on list)")
263
549
  .action(async (options) => {
264
550
  try {
265
- const requestedVersion = resolveVersion(options.version);
551
+ const requestedVersion = resolveVersion(options.guideVersion);
266
552
  const { manifest, stale, versionInfo } = await fetchManifest(requestedVersion, options.refresh);
553
+ // Validate the requested dimensions against the loaded manifest (same
554
+ // rules as `get`, for one mental model — Q3). They have no per-row
555
+ // effect on `list`, but an unknown value should fail loudly here too.
556
+ const resolution = validateAndResolveRequest(manifest, options.language, options.platform);
557
+ if (resolution.error) {
558
+ error(resolution.error);
559
+ process.exit(1);
560
+ }
561
+ const reqLanguage = normalizeLanguage(options.language);
562
+ const reqPlatform = normalizePlatform(options.platform);
563
+ const supportedLanguages = [...deriveLanguages(manifest)].sort();
267
564
  if (stale) {
268
565
  warn("Using stale cache (network unavailable)");
269
566
  }
@@ -271,6 +568,11 @@ Cache:
271
568
  json({
272
569
  version: versionInfo.version,
273
570
  versionSource: versionInfo.source,
571
+ defaults: manifest.defaults ?? null,
572
+ platforms: manifest.platforms ?? null,
573
+ supportedLanguages,
574
+ language: reqLanguage ?? null,
575
+ platform: reqPlatform ?? null,
274
576
  guides: manifest.guides.map((g) => ({
275
577
  topic: g.topic,
276
578
  description: g.description,
@@ -279,6 +581,7 @@ Cache:
279
581
  concepts: g.concepts,
280
582
  prerequisites: g.prerequisites,
281
583
  relatedGuides: g.relatedGuides,
584
+ availableVariants: normalizeVariants(g),
282
585
  })),
283
586
  });
284
587
  return;
@@ -289,14 +592,41 @@ Cache:
289
592
  }
290
593
  keyValue("Client version", formatClientVersion(versionInfo));
291
594
  keyValue("Guides version", formatGuidesVersion(versionInfo));
595
+ // Manifest-level legend: spell out languages and the platform→language
596
+ // map explicitly, so `--language`/`--platform` are no longer ambiguous.
597
+ keyValue("Languages", supportedLanguages.join(", "));
598
+ keyValue("Platforms", formatPlatformsLegend(manifest));
292
599
  console.log("");
293
- console.log(formatTable(manifest.guides, [
600
+ const rows = manifest.guides.map((g) => ({
601
+ topic: g.topic,
602
+ description: g.description,
603
+ languages: formatLanguages(g),
604
+ }));
605
+ // Size DESCRIPTION from the live terminal width so wide terminals show
606
+ // the full description (the guides.json contract budgets descriptions
607
+ // at <=100 chars; DESCRIPTION_MAX mirrors that cap — see
608
+ // primitive-docs `scripts/sync-guides-json.mjs`) while the table never
609
+ // wraps. Floor 48 keeps today's readable width on an 80-col terminal;
610
+ // on a non-TTY (piped) the column is sized to the cap so full
611
+ // descriptions reach `grep`/agents. `truncate` defensively clips a
612
+ // description that violates the upstream contract.
613
+ const DESCRIPTION_MAX = 100;
614
+ console.log(formatTable(rows, [
294
615
  { header: "TOPIC", key: "topic" },
295
- { header: "DESCRIPTION", key: "description", width: 60 },
616
+ {
617
+ header: "DESCRIPTION",
618
+ key: "description",
619
+ flex: true,
620
+ truncate: true,
621
+ flexFloor: 48,
622
+ flexMax: DESCRIPTION_MAX,
623
+ },
624
+ { header: "LANGUAGES", key: "languages" },
296
625
  ]));
297
626
  console.log("");
298
627
  keyValue("Cache location", getVersionCacheDir(versionInfo.version));
299
- info("Use 'primitive guides get <topic>' to fetch a guide.");
628
+ info("Use 'primitive guides get <topic> --language <ts|swift>' to fetch a guide.");
629
+ info("Example: primitive guides get documents --language swift");
300
630
  }
301
631
  catch (err) {
302
632
  error(err.message);
@@ -310,19 +640,42 @@ Cache:
310
640
  .argument("<topic>", "Guide topic (e.g., documents, workflows, prompts)")
311
641
  .option("--json", "Output metadata as JSON instead of content")
312
642
  .option("--refresh", "Force refresh from network")
313
- .option("--version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
643
+ .option("--guide-version <version>", "Fetch guides for a specific major version (e.g., 1, 2, or 'latest')")
644
+ .option("--language <lang>", "Language variant to fetch (ts, swift, ...; aliases typescript/javascript/js -> ts)")
645
+ .option("--platform <platform>", "Platform whose language to fetch (web, ios, macos, ...; inferred from the manifest)")
314
646
  .action(async (topic, options) => {
315
647
  try {
316
- const requestedVersion = resolveVersion(options.version);
317
- const { manifest, stale: manifestStale, versionInfo } = await fetchManifest(requestedVersion, options.refresh);
648
+ const requestedVersion = resolveVersion(options.guideVersion);
649
+ const { manifest, fromCache, stale: manifestStale, versionInfo } = await fetchManifest(requestedVersion, options.refresh);
318
650
  const guide = manifest.guides.find((g) => g.topic.toLowerCase() === topic.toLowerCase());
319
651
  if (!guide) {
320
652
  const availableTopics = manifest.guides.map((g) => g.topic).join(", ");
321
653
  error(`Guide "${topic}" not found. Available topics: ${availableTopics}`);
322
654
  process.exit(1);
323
655
  }
656
+ // Validate both flags against the loaded manifest and resolve the
657
+ // effective language (issue #1219). An unknown value is a hard error;
658
+ // `--platform ios` infers its language (e.g. swift) when the manifest
659
+ // publishes a `platforms` block, while an explicit `--language` wins.
660
+ const resolution = validateAndResolveRequest(manifest, options.language, options.platform);
661
+ if (resolution.error) {
662
+ error(resolution.error);
663
+ process.exit(1);
664
+ }
665
+ // `reqLanguage` is the EFFECTIVE language after platform inference; pass
666
+ // it to both the JSON selection and the content fetch so they can't
667
+ // desynchronize.
668
+ const reqLanguage = resolution.language;
669
+ const reqPlatform = resolution.platform;
670
+ const requestedAnyDimension = normalizeLanguage(options.language) !== undefined ||
671
+ normalizePlatform(options.platform) !== undefined;
672
+ const selection = selectVariant(guide, {
673
+ language: reqLanguage,
674
+ platform: reqPlatform,
675
+ });
324
676
  if (options.json) {
325
- // Output just metadata
677
+ // Output just metadata — does NOT fetch content. `selectedFile` is the
678
+ // file that WOULD be served for this request (see selection above).
326
679
  json({
327
680
  version: versionInfo.version,
328
681
  versionSource: versionInfo.source,
@@ -333,16 +686,77 @@ Cache:
333
686
  concepts: guide.concepts,
334
687
  prerequisites: guide.prerequisites,
335
688
  relatedGuides: guide.relatedGuides,
336
- cacheLocation: join(getGuidesCacheDir(versionInfo.version), basename(guide.file)),
689
+ language: reqLanguage ?? null,
690
+ platform: reqPlatform ?? null,
691
+ defaultPlatform: manifest.defaults?.platform ?? null,
692
+ selectedFile: selection.file,
693
+ matchedOn: selection.matchedOn,
694
+ availableVariants: normalizeVariants(guide),
695
+ cacheLocation: join(getGuidesCacheDir(versionInfo.version), basename(selection.file)),
337
696
  });
338
697
  return;
339
698
  }
340
- const { content, stale: guideStale } = await fetchGuide(guide, versionInfo.version, options.refresh);
341
- if (manifestStale || guideStale) {
699
+ const request = { language: reqLanguage, platform: reqPlatform };
700
+ let result;
701
+ try {
702
+ result = await fetchGuide(guide, versionInfo.version, request, options.refresh);
703
+ }
704
+ catch (err) {
705
+ // Only a 404 is healable here. Transport errors already served stale
706
+ // (or threw) inside fetchGuide; rethrow anything that isn't a 404.
707
+ if (!(err instanceof GuideFetchError && err.status === 404)) {
708
+ throw err;
709
+ }
710
+ // Heal only when the manifest came from cache — then the named file
711
+ // may have been renamed/removed upstream while our manifest is stale.
712
+ // Force a manifest refetch, re-find the topic, and retry the file
713
+ // ONCE. A fresh-from-network manifest (incl. --refresh) makes the 404
714
+ // authoritative, so we skip straight to the last resort below (#1034).
715
+ if (fromCache) {
716
+ try {
717
+ const refreshed = await fetchManifest(requestedVersion, /* forceRefresh */ true);
718
+ const freshGuide = refreshed.manifest.guides.find((g) => g.topic.toLowerCase() === topic.toLowerCase());
719
+ if (freshGuide) {
720
+ // Re-calling fetchGuide recomputes selectVariant + the cache path
721
+ // for the (possibly new) filename — no extra resolve site. If the
722
+ // filename is unchanged it just 404s again and falls through.
723
+ const retried = await fetchGuide(freshGuide, refreshed.versionInfo.version, request, options.refresh);
724
+ if (basename(retried.selectedFile) !== basename(err.selectedFile)) {
725
+ warn(`Guide "${topic}" file moved upstream; refreshed manifest and retried.`);
726
+ }
727
+ result = retried;
728
+ }
729
+ }
730
+ catch {
731
+ // Heal failed (manifest refetch failed / retry 404'd / topic gone)
732
+ // → fall through to the option-(b) last resort on the ORIGINAL error.
733
+ }
734
+ }
735
+ // Option (b) last resort — applies to ALL 404s, not just cached-manifest
736
+ // ones, so `--refresh`-on-404 still serves an old on-disk copy as it
737
+ // does today. Serve the old copy if we have one; otherwise surface the
738
+ // original terminal 404.
739
+ if (!result) {
740
+ if (err.staleContent !== undefined) {
741
+ result = { content: err.staleContent, stale: true, selectedFile: err.selectedFile };
742
+ }
743
+ else {
744
+ throw err;
745
+ }
746
+ }
747
+ }
748
+ if (manifestStale || result.stale) {
342
749
  warn("Using stale cache (network unavailable)");
343
750
  }
751
+ // Transparency note (issue #1219): a language/platform was requested but
752
+ // this topic has no variant pinning it, so the default file was served.
753
+ // This closes the residual silent-fallback footgun beyond `--platform`.
754
+ // The note goes to STDERR (via `info`) so stdout stays clean for piping.
755
+ if (requestedAnyDimension && selection.matchedOn === "default") {
756
+ info(`No ${reqLanguage ?? reqPlatform} variant for "${guide.topic}"; served the default guide.`);
757
+ }
344
758
  // Print the guide content directly to stdout
345
- console.log(content);
759
+ console.log(result.content);
346
760
  }
347
761
  catch (err) {
348
762
  error(err.message);