primitive-admin 1.0.53 → 1.0.54
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.
- package/README.md +38 -10
- package/dist/bin/primitive.js +6 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/auth.js +16 -6
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +84 -7
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +3 -3
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collections.js +12 -9
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/databases.js +191 -74
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +1 -9
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +12 -6
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +161 -0
- package/dist/src/commands/guides.js +502 -84
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/integrations.js +2 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +344 -0
- package/dist/src/commands/metadata.js.map +1 -0
- package/dist/src/commands/prompts.js +2 -2
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -0
- package/dist/src/commands/rule-sets.js +25 -3
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +2 -0
- package/dist/src/commands/scripts.js +667 -0
- package/dist/src/commands/scripts.js.map +1 -0
- package/dist/src/commands/sync.d.ts +189 -9
- package/dist/src/commands/sync.js +1418 -256
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/vars.d.ts +8 -0
- package/dist/src/commands/vars.js +110 -0
- package/dist/src/commands/vars.js.map +1 -0
- package/dist/src/commands/webhooks.js +17 -3
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +30 -0
- package/dist/src/commands/workflows.js +645 -59
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +120 -18
- package/dist/src/lib/api-client.js +125 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/batch.d.ts +26 -0
- package/dist/src/lib/batch.js +32 -0
- package/dist/src/lib/batch.js.map +1 -0
- package/dist/src/lib/block-layout.d.ts +160 -0
- package/dist/src/lib/block-layout.js +451 -0
- package/dist/src/lib/block-layout.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
- package/dist/src/lib/config-toml.d.ts +10 -0
- package/dist/src/lib/config-toml.js +42 -0
- package/dist/src/lib/config-toml.js.map +1 -0
- package/dist/src/lib/config.d.ts +16 -0
- package/dist/src/lib/config.js +41 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/constants.d.ts +9 -0
- package/dist/src/lib/constants.js +9 -0
- package/dist/src/lib/constants.js.map +1 -1
- package/dist/src/lib/csv.d.ts +1 -2
- package/dist/src/lib/csv.js +3 -8
- package/dist/src/lib/csv.js.map +1 -1
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
- package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
- package/dist/src/lib/db-codegen/dbNaming.js +58 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
- package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
- package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +43 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/init-config.js +2 -2
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/output.d.ts +25 -5
- package/dist/src/lib/output.js +32 -4
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +10 -19
- package/dist/src/lib/toml-database-config.js +28 -59
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.d.ts +147 -0
- package/dist/src/lib/toml-metadata-config.js +463 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-native-form.d.ts +46 -0
- package/dist/src/lib/toml-native-form.js +78 -0
- package/dist/src/lib/toml-native-form.js.map +1 -0
- package/dist/src/lib/toml-params-validator.d.ts +34 -0
- package/dist/src/lib/toml-params-validator.js +118 -3
- package/dist/src/lib/toml-params-validator.js.map +1 -1
- package/dist/src/lib/workflow-apply.d.ts +86 -0
- package/dist/src/lib/workflow-apply.js +127 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
- package/dist/src/lib/workflow-codegen/generator.js +215 -0
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
- package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
- package/dist/src/lib/workflow-codegen/naming.js +81 -0
- package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
- package/dist/src/lib/workflow-fragments.js +3 -3
- package/dist/src/lib/workflow-fragments.js.map +1 -1
- package/dist/src/lib/workflow-payload.d.ts +68 -0
- package/dist/src/lib/workflow-payload.js +150 -0
- package/dist/src/lib/workflow-payload.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
- package/dist/src/lib/workflow-toml-validator.js +37 -1
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +11 -0
- package/dist/src/validators.d.ts +4 -3
- package/dist/src/validators.js +6 -5
- package/dist/src/validators.js.map +1 -1
- package/package.json +5 -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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
208
|
-
return `${
|
|
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
|
-
|
|
222
|
-
|
|
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 =
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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)
|
|
298
|
-
// cannot mark the `swift` variant fresh
|
|
299
|
-
|
|
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 =
|
|
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[
|
|
324
|
-
meta.guidesFetchedAt[
|
|
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
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
/**
|
|
351
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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 +
|
|
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 --
|
|
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|...>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
409
|
-
.option("--platform <platform>", "Platform
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
456
|
-
|
|
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>
|
|
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
|
|
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
|
|
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
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
518
|
-
|
|
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);
|