jamdesk 1.1.37 → 1.1.39

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 (47) hide show
  1. package/dist/__tests__/integration/init.integration.test.js +44 -0
  2. package/dist/__tests__/integration/init.integration.test.js.map +1 -1
  3. package/dist/__tests__/unit/init.test.js +2 -1
  4. package/dist/__tests__/unit/init.test.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/api-reference/openapi-example.mdx +55 -0
  7. package/templates/api-reference/request-response-examples.mdx +210 -0
  8. package/templates/components/callouts.mdx +56 -0
  9. package/templates/components/cards.mdx +80 -0
  10. package/templates/components/steps.mdx +39 -0
  11. package/templates/components/tabs-and-accordions.mdx +65 -0
  12. package/templates/docs.json +48 -0
  13. package/templates/introduction.mdx +40 -10
  14. package/templates/openapi/example-api.yaml +185 -0
  15. package/templates/quickstart.mdx +98 -9
  16. package/templates/writing/code-blocks.mdx +80 -0
  17. package/templates/writing/components.mdx +78 -0
  18. package/templates/writing/pages.mdx +59 -0
  19. package/vendored/app/[[...slug]]/page.tsx +26 -8
  20. package/vendored/app/api/chat/[project]/route.ts +53 -3
  21. package/vendored/app/api/docs-search/[project]/search/route.ts +48 -3
  22. package/vendored/app/layout.tsx +4 -4
  23. package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
  24. package/vendored/components/navigation/Sidebar.tsx +9 -4
  25. package/vendored/components/search/SearchModal.tsx +13 -20
  26. package/vendored/hooks/useChat.ts +22 -4
  27. package/vendored/lib/chat-prompt.ts +1 -1
  28. package/vendored/lib/chat-tools.ts +3 -0
  29. package/vendored/lib/embedding-chunker.ts +18 -2
  30. package/vendored/lib/language-codes.json +27 -0
  31. package/vendored/lib/language-utils.ts +80 -5
  32. package/vendored/lib/link-rewriter.ts +67 -0
  33. package/vendored/lib/locale-helpers.ts +62 -0
  34. package/vendored/lib/openapi/code-examples.ts +5 -6
  35. package/vendored/lib/openapi/derive-auth.ts +46 -0
  36. package/vendored/lib/openapi/index.ts +7 -0
  37. package/vendored/lib/openapi/parser.ts +7 -2
  38. package/vendored/lib/openapi/resolve-server-url.ts +14 -0
  39. package/vendored/lib/openapi/types.ts +2 -0
  40. package/vendored/lib/path-safety.ts +96 -0
  41. package/vendored/lib/search-client.ts +117 -12
  42. package/vendored/lib/static-artifacts.ts +25 -1
  43. package/vendored/lib/static-file-route.ts +13 -0
  44. package/vendored/lib/vector-store.ts +70 -17
  45. package/vendored/scripts/build-search-index.cjs +91 -24
  46. package/vendored/themes/base.css +5 -0
  47. package/vendored/workspace-package-lock.json +6 -6
@@ -5,6 +5,21 @@
5
5
  */
6
6
 
7
7
  import type { LanguageCode, LanguageConfig } from './docs-types';
8
+ import LANGUAGE_CODES_JSON from './language-codes.json';
9
+
10
+ /**
11
+ * Canonical language code list, shared with the CJS build script via
12
+ * `lib/language-codes.json`. Updates to LANGUAGE_DISPLAY_NAMES below MUST
13
+ * also update language-codes.json. The sync test in
14
+ * __tests__/lib/language-codes-sync.test.ts catches drift.
15
+ */
16
+ export const LANGUAGE_CODES = LANGUAGE_CODES_JSON as readonly LanguageCode[];
17
+
18
+ /** BCP-47 syntax check (2-3 letter primary tag + optional 2-4 letter
19
+ * region/script). Shared by the chat and docs-search REST endpoints to
20
+ * keep their request-validation contracts identical. Limitation:
21
+ * 3-segment tags like `zh-Hant-HK` are rejected. */
22
+ export const BCP47_LANGUAGE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
8
23
 
9
24
  /**
10
25
  * Display names for each supported language code
@@ -275,21 +290,81 @@ export function isValidLanguageCode(code: string): code is LanguageCode {
275
290
  return code in LANGUAGE_DISPLAY_NAMES;
276
291
  }
277
292
 
293
+ /** Lowercase → canonical lookup, built once. Lets `extractLanguageFromPath`
294
+ * match URL segments regardless of case (`/pt-br/...` returns `'pt-BR'`).
295
+ * Vercel and some CDN configs serve mixed-case folder paths under
296
+ * lowercased URLs, so a strict case-sensitive match silently breaks
297
+ * every locale-aware feature on those URL forms.
298
+ * First insertion wins — iteration order MUST match the CJS map in
299
+ * `scripts/build-search-index.cjs`, so we iterate the shared JSON list
300
+ * (single source of truth) instead of `LANGUAGE_DISPLAY_NAMES` keys. */
301
+ const LANGUAGE_CODE_BY_LOWER: Map<string, LanguageCode> = (() => {
302
+ const m = new Map<string, LanguageCode>();
303
+ for (const k of LANGUAGE_CODES) {
304
+ const lower = k.toLowerCase();
305
+ if (!m.has(lower)) m.set(lower, k);
306
+ }
307
+ return m;
308
+ })();
309
+
278
310
  /**
279
311
  * Extract language code from a pathname
280
312
  *
281
313
  * @param pathname - The URL pathname (e.g., '/es/introduction' or '/docs/es/introduction')
282
- * @returns The language code if found, undefined otherwise
314
+ * @returns The canonical-cased language code if found, undefined otherwise
283
315
  */
284
316
  export function extractLanguageFromPath(pathname: string): LanguageCode | undefined {
285
317
  // Remove /docs prefix if present (for backward compatibility)
286
318
  const parts = pathname.replace(/^\/docs\/?/, '').replace(/^\//, '').split('/').filter(Boolean);
287
319
 
288
- if (parts.length > 0 && isValidLanguageCode(parts[0])) {
289
- return parts[0] as LanguageCode;
290
- }
320
+ if (parts.length === 0) return undefined;
321
+ return LANGUAGE_CODE_BY_LOWER.get(parts[0].toLowerCase());
322
+ }
291
323
 
292
- return undefined;
324
+ /**
325
+ * Resolve the active locale from a pathname, gated by the project's declared
326
+ * language whitelist.
327
+ *
328
+ * Returns:
329
+ * - `''` when the path has no language prefix, OR the prefix is not in the
330
+ * project's `navigation.languages[].language` whitelist (e.g. `dodo/de/foo`
331
+ * where dodo only declares `en`).
332
+ * - The canonical language code when the prefix matches the whitelist.
333
+ *
334
+ * Use this — not `extractLanguageFromPath` directly — for any feature that
335
+ * filters by language (search, language selector). The whitelist prevents
336
+ * false-positives on directories named after language codes but not actually
337
+ * translations.
338
+ */
339
+ export function resolveLocaleFromPath(
340
+ pathname: string,
341
+ projectLanguages: readonly string[],
342
+ ): string {
343
+ return resolveLocaleWithLoweredSet(pathname, buildLoweredLocaleSet(projectLanguages));
344
+ }
345
+
346
+ /**
347
+ * Build the lowercase whitelist Set used by `resolveLocaleWithLoweredSet`.
348
+ * Hoist this out of hot paths (build-time per-page loops, runtime per-keystroke)
349
+ * to avoid re-allocating on every call.
350
+ */
351
+ export function buildLoweredLocaleSet(
352
+ projectLanguages: readonly string[],
353
+ ): ReadonlySet<string> {
354
+ return new Set(projectLanguages.map((l) => l.toLowerCase()));
355
+ }
356
+
357
+ /**
358
+ * Hot-path variant of `resolveLocaleFromPath`: caller supplies a pre-built
359
+ * lowered Set. Same semantics; avoids per-call Set construction.
360
+ */
361
+ export function resolveLocaleWithLoweredSet(
362
+ pathname: string,
363
+ loweredLanguageSet: ReadonlySet<string>,
364
+ ): string {
365
+ const candidate = extractLanguageFromPath(pathname);
366
+ if (!candidate) return '';
367
+ return loweredLanguageSet.has(candidate.toLowerCase()) ? candidate : '';
293
368
  }
294
369
 
295
370
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Markdown link rewriter for chat responses.
3
+ *
4
+ * Rewrites two forms to absolute URLs from a `pageSlug → absoluteUrl` map:
5
+ *
6
+ * 1. Bare-slug: `[Anchor](api-reference/foo)` → `[Anchor](https://x/docs/api-reference/foo)`
7
+ * 2. Absolute URL: `[Anchor](https://anything/.../api-reference/foo)` → canonical absolute URL
8
+ *
9
+ * Both forms are needed because Haiku at temp 0.3 produces a mix:
10
+ * - Sometimes copies the `URL:` line from chunk context verbatim (form 2, correct host)
11
+ * - Sometimes emits the pageSlug (form 1)
12
+ * - Sometimes hallucinates a host (form 2, wrong host) — the slug suffix still resolves
13
+ * - Sometimes lowercases the leading segment of mixed-case locale folders
14
+ * (`pt-BR/foo` → `pt-br/foo`) — hence the lowercase lookup map.
15
+ *
16
+ * Streaming note: this is NOT streaming-safe. Apply ONLY to a complete markdown
17
+ * string (the final tool input). The chat route streams the raw markdown live
18
+ * for typing-effect UX, then sends a `text_replace` SSE event with the
19
+ * rewritten string at end-of-stream.
20
+ */
21
+
22
+ const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
23
+
24
+ export function rewriteSlugLinks(
25
+ text: string,
26
+ slugToUrl: Record<string, string>,
27
+ ): string {
28
+ const lookup: Record<string, string> = {};
29
+ for (const [key, value] of Object.entries(slugToUrl)) lookup[key.toLowerCase()] = value;
30
+
31
+ return text.replace(MARKDOWN_LINK_RE, (full, anchor: string, url: string, offset: number) => {
32
+ // Skip image syntax (![alt](url)). A leading "!" turns the same bracket
33
+ // pattern into an image — rewriting it would point the <img src> at a docs
34
+ // page if the URL happens to match a known slug.
35
+ if (offset > 0 && text[offset - 1] === '!') return full;
36
+ const resolved = resolveSlugFromUrl(url, lookup);
37
+ return resolved ? `[${anchor}](${resolved})` : full;
38
+ });
39
+ }
40
+
41
+ function resolveSlugFromUrl(
42
+ url: string,
43
+ lookup: Record<string, string>,
44
+ ): string | null {
45
+ let path = url;
46
+ let hash = '';
47
+
48
+ if (/^https?:\/\//i.test(url)) {
49
+ try {
50
+ const parsed = new URL(url);
51
+ path = parsed.pathname;
52
+ hash = parsed.hash;
53
+ } catch {
54
+ return null;
55
+ }
56
+ } else {
57
+ const hashIdx = path.indexOf('#');
58
+ if (hashIdx !== -1) {
59
+ hash = path.slice(hashIdx);
60
+ path = path.slice(0, hashIdx);
61
+ }
62
+ }
63
+
64
+ path = path.replace(/^\.?\/+/, '').toLowerCase();
65
+ const hit = lookup[path] ?? lookup[path.replace(/^docs\//, '')];
66
+ return hit ? hit + hash : null;
67
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Locale helpers for AI chat embedding + retrieval.
3
+ *
4
+ * The chunker uses these at build time to tag each EmbeddingChunk with the
5
+ * locale derived from the file path (using the project's i18n config). The
6
+ * chat API uses the same shape at query time to filter Upstash Vector
7
+ * results to a single locale, preventing cross-language pollution.
8
+ */
9
+
10
+ export interface LanguageEntry {
11
+ code: string;
12
+ isDefault: boolean;
13
+ }
14
+
15
+ interface RawLanguageEntry {
16
+ language: string;
17
+ default?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Normalize the raw `navigation.languages` array into a stable, lowercased shape.
22
+ *
23
+ * - If no entry has `default: true`, the first entry is treated as default
24
+ * (matches resolveLanguageWithFallback() in language-utils.ts).
25
+ * - Codes are lowercased so `pt-BR` from docs.json matches `pt-br/...` paths.
26
+ */
27
+ export function normalizeLanguageList(
28
+ raw: RawLanguageEntry[] | undefined,
29
+ ): LanguageEntry[] {
30
+ if (!raw || raw.length === 0) return [];
31
+ const hasExplicitDefault = raw.some((l) => l.default === true);
32
+ return raw.map((l, i) => ({
33
+ code: l.language.toLowerCase(),
34
+ isDefault: hasExplicitDefault ? l.default === true : i === 0,
35
+ }));
36
+ }
37
+
38
+ /**
39
+ * Derive the locale for a chunk from its file path.
40
+ *
41
+ * Returns:
42
+ * - `null` when no i18n config is present (single-language project) — the
43
+ * filter must NOT be applied to chunks with no locale metadata, and a
44
+ * blanket "en" default would silently mislabel Spanish-only docs sites.
45
+ * - The matching language code when the leading path segment is in the
46
+ * configured allowlist.
47
+ * - The configured default language code otherwise.
48
+ *
49
+ * Critically, ONLY exact membership in the configured language list counts.
50
+ * Two-letter folders like `ai/`, `cn/`, `ui/`, `snippets/` are NOT treated as
51
+ * locales unless they are explicitly listed in `navigation.languages`.
52
+ */
53
+ export function deriveChunkLocale(
54
+ filePath: string,
55
+ languages: LanguageEntry[],
56
+ ): string | null {
57
+ if (languages.length === 0) return null;
58
+ const defaultCode = languages.find((l) => l.isDefault)?.code ?? languages[0].code;
59
+ const firstSegment = filePath.replace(/\\/g, '/').split('/')[0].toLowerCase();
60
+ const match = languages.find((l) => l.code === firstSegment);
61
+ return match ? match.code : defaultCode;
62
+ }
@@ -11,11 +11,10 @@ import type {
11
11
  CodeExample,
12
12
  JsonSchema,
13
13
  } from './types';
14
+ import type { ApiAuthMethod } from '../docs-types';
15
+ import { resolveServerUrl } from './resolve-server-url';
14
16
 
15
- /**
16
- * Supported auth methods from docs.json
17
- */
18
- export type AuthMethod = 'bearer' | 'basic' | 'key' | 'cobo';
17
+ export type AuthMethod = ApiAuthMethod;
19
18
 
20
19
  /**
21
20
  * Configuration for code example generation
@@ -194,7 +193,7 @@ export function buildGeneratorContext(
194
193
  config: CodeExampleConfig
195
194
  ): GeneratorContext {
196
195
  const { method, path, parameters, requestBody } = endpoint;
197
- const baseUrl = endpoint.servers[0]?.url || DEFAULT_BASE_URL;
196
+ const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
198
197
  const formattedPath = formatPathWithParams(path, parameters);
199
198
  const queryString = formatQueryParams(parameters);
200
199
  const fullUrl = `${baseUrl}${formattedPath}${queryString}`;
@@ -257,7 +256,7 @@ export function generatePythonExample(
257
256
 
258
257
  // Python handles query params separately (params=params in request call),
259
258
  // so we build the URL without query string
260
- const baseUrl = endpoint.servers[0]?.url || DEFAULT_BASE_URL;
259
+ const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
261
260
  const formattedPath = formatPathWithParams(endpoint.path, parameters);
262
261
  const pythonUrl = `${baseUrl}${formattedPath}`;
263
262
 
@@ -0,0 +1,46 @@
1
+ import type { AuthMethod } from './code-examples';
2
+ import type { SecurityRequirement } from './types';
3
+
4
+ export interface DerivedAuth {
5
+ method?: AuthMethod;
6
+ headerName?: string;
7
+ }
8
+
9
+ /**
10
+ * Derive playground auth config from a parsed OpenAPI security list.
11
+ *
12
+ * Returns `{}` when no scheme is renderable in the playground (empty list,
13
+ * apiKey-in-query/cookie, etc). docs.json `api.mdx.auth` should still take
14
+ * precedence over this — callers in page.tsx merge with the docs.json value first.
15
+ *
16
+ * Falls through past schemes the playground can't render, so a spec offering
17
+ * "either apiKey-in-query or bearer" still works.
18
+ */
19
+ export function deriveAuthFromSecurity(security: SecurityRequirement[]): DerivedAuth {
20
+ for (const req of security) {
21
+ const mapped = mapSingle(req);
22
+ if (mapped.method) return mapped;
23
+ }
24
+ return {};
25
+ }
26
+
27
+ function mapSingle(req: SecurityRequirement): DerivedAuth {
28
+ if (req.type === 'http') {
29
+ if (req.scheme === 'bearer') return { method: 'bearer' };
30
+ if (req.scheme === 'basic') return { method: 'basic' };
31
+ return {};
32
+ }
33
+
34
+ if (req.type === 'apiKey') {
35
+ if (req.in === 'header' && req.parameterName) {
36
+ return { method: 'key', headerName: req.parameterName };
37
+ }
38
+ return {};
39
+ }
40
+
41
+ if (req.type === 'oauth2' || req.type === 'openIdConnect') {
42
+ return { method: 'bearer' };
43
+ }
44
+
45
+ return {};
46
+ }
@@ -90,3 +90,10 @@ export {
90
90
  generateNavigationGroups,
91
91
  getSpecStats,
92
92
  } from './generator';
93
+
94
+ // Auth derivation
95
+ export type { DerivedAuth } from './derive-auth';
96
+ export { deriveAuthFromSecurity } from './derive-auth';
97
+
98
+ // Server URL resolution
99
+ export { resolveServerUrl } from './resolve-server-url';
@@ -5,7 +5,7 @@
5
5
  * Extracts parameters, request bodies, responses, and security info.
6
6
  */
7
7
 
8
- import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
8
+ import type { OpenAPI, OpenAPIV3 } from 'openapi-types';
9
9
  import type {
10
10
  HttpMethod,
11
11
  ParsedOpenApiFrontmatter,
@@ -219,10 +219,14 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
219
219
  scheme: 'scheme' in s ? s.scheme : undefined,
220
220
  in: 'in' in s ? s.in as SecurityRequirement['in'] : undefined,
221
221
  bearerFormat: 'bearerFormat' in s ? s.bearerFormat : undefined,
222
+ parameterName:
223
+ s.type === 'apiKey' && 'name' in s
224
+ ? (s as OpenAPIV3.ApiKeySecurityScheme).name
225
+ : undefined,
222
226
  });
223
227
  }
224
228
  }
225
-
229
+
226
230
  // Swagger 2.0
227
231
  if ('securityDefinitions' in api) {
228
232
  const defs = (api as any).securityDefinitions;
@@ -233,6 +237,7 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
233
237
  type: s.type,
234
238
  scheme: s.scheme,
235
239
  in: s.in,
240
+ parameterName: s.type === 'apiKey' ? s.name : undefined,
236
241
  });
237
242
  }
238
243
  }
@@ -0,0 +1,14 @@
1
+ import type { ServerInfo } from './types';
2
+
3
+ /**
4
+ * Substitute `{varname}` placeholders in an OpenAPI server URL with the
5
+ * defaults declared in `server.variables`. Unknown placeholders are left
6
+ * intact so they remain visible to the user rather than producing a broken
7
+ * URL fragment.
8
+ */
9
+ export function resolveServerUrl(server: ServerInfo | undefined): string | undefined {
10
+ if (!server) return undefined;
11
+ return server.url.replace(/\{(\w+)\}/g, (_, key: string) => {
12
+ return server.variables?.[key]?.default ?? `{${key}}`;
13
+ });
14
+ }
@@ -120,6 +120,8 @@ export interface SecurityRequirement {
120
120
  scheme?: string; // For http type: 'bearer', 'basic'
121
121
  in?: 'query' | 'header' | 'cookie'; // For apiKey type
122
122
  bearerFormat?: string;
123
+ /** For apiKey schemes: the header / query / cookie parameter name (e.g. 'X-Api-Key'). */
124
+ parameterName?: string;
123
125
  }
124
126
 
125
127
  /**
@@ -97,6 +97,102 @@ export function validateDocsPath(
97
97
  return { valid: true, resolvedPath: resolved };
98
98
  }
99
99
 
100
+ /**
101
+ * Validates a git ref (branch name) for use in `git clone --branch <ref>`.
102
+ *
103
+ * Combines two rule sets:
104
+ * 1. git's `check-ref-format` constraints (forbids `..`, `@{`, trailing `.lock`,
105
+ * leading/trailing `/`, control chars, and a handful of ASCII punctuation).
106
+ * 2. A strict shell-safety allowlist — even though `cloneRepo` now uses
107
+ * `execFileSync` with an argv array, this validator is defense-in-depth so
108
+ * a future refactor that reintroduces a shell cannot be silently exploited.
109
+ *
110
+ * Allowed characters: A–Z, a–z, 0–9, and `._-/`.
111
+ *
112
+ * @param ref - Git ref received from a client (webhook payload / API request)
113
+ * @returns `{ valid: true }` or `{ valid: false, error }`
114
+ */
115
+ export function validateGitRef(ref: unknown): SlugValidationResult {
116
+ if (typeof ref !== 'string') {
117
+ return { valid: false, error: 'Invalid git ref: must be a string' };
118
+ }
119
+
120
+ // Deliberately do NOT trim. The caller passes `ref` verbatim into argv, so
121
+ // the validator must judge the exact string the caller will use. If a
122
+ // consumer wants to allow trimming, they must trim before calling.
123
+ if (ref.length === 0) {
124
+ return { valid: false, error: 'Invalid git ref: cannot be empty' };
125
+ }
126
+ if (ref.length > 255) {
127
+ return { valid: false, error: 'Invalid git ref: exceeds 255 characters' };
128
+ }
129
+
130
+ // Allowlist: alphanumerics plus `._-/` only.
131
+ // Everything outside this set — including every shell metacharacter
132
+ // (`;`, backtick, `$`, `|`, `&`, `<`, `>`, `(`, `)`, `{`, `}`, `[`, `]`,
133
+ // single/double quotes, `\`, `#`, `!`, `*`, `?`, `~`, `^`, `:`, spaces,
134
+ // tabs, newlines, null bytes, and all other control chars) — is rejected.
135
+ if (!/^[A-Za-z0-9._\-\/]+$/.test(ref)) {
136
+ return { valid: false, error: 'Invalid git ref: contains disallowed characters' };
137
+ }
138
+
139
+ // The following enforce git check-ref-format rules that the allowlist
140
+ // cannot express positionally — characters like `.`, `/`, and `-` are
141
+ // permitted in general but disallowed in specific positions or sequences.
142
+ if (ref.includes('..')) {
143
+ return { valid: false, error: 'Invalid git ref: cannot contain ".." sequences' };
144
+ }
145
+ if (ref.startsWith('/') || ref.endsWith('/')) {
146
+ return { valid: false, error: 'Invalid git ref: cannot start or end with "/"' };
147
+ }
148
+ if (ref.startsWith('.') || ref.endsWith('.')) {
149
+ return { valid: false, error: 'Invalid git ref: cannot start or end with "."' };
150
+ }
151
+ if (ref.endsWith('.lock')) {
152
+ return { valid: false, error: 'Invalid git ref: cannot end with ".lock"' };
153
+ }
154
+ // Block leading `-` so git does not parse the ref as a CLI flag
155
+ // (e.g. `-oProxyCommand=...`, `--upload-pack=...`).
156
+ if (ref.startsWith('-')) {
157
+ return { valid: false, error: 'Invalid git ref: cannot start with "-"' };
158
+ }
159
+
160
+ return { valid: true };
161
+ }
162
+
163
+ /**
164
+ * Validates a GitHub `owner/repo` full-name string.
165
+ *
166
+ * Allowlist per segment: `[A-Za-z0-9][A-Za-z0-9_.-]*` with 1–100 chars each.
167
+ * Exactly one `/` separating owner and repo. Leading `-` is rejected so the
168
+ * value cannot be mistaken for a CLI flag even if it ever escapes argv to a
169
+ * shell (defense-in-depth).
170
+ *
171
+ * @param repoFullName - Full repo name from a webhook/API payload
172
+ * @returns `{ valid: true }` or `{ valid: false, error }`
173
+ */
174
+ export function validateRepoFullName(repoFullName: unknown): SlugValidationResult {
175
+ if (typeof repoFullName !== 'string') {
176
+ return { valid: false, error: 'Invalid repoFullName: must be a string' };
177
+ }
178
+
179
+ // No trim: validate the exact string the caller will pass into argv.
180
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(repoFullName)) {
181
+ return {
182
+ valid: false,
183
+ error: 'Invalid repoFullName: must match owner/repo with alphanumerics, "_", ".", or "-"',
184
+ };
185
+ }
186
+
187
+ // Defense in depth against path traversal: `..` segments could resolve
188
+ // outside the intended repos dir if this value ever flows into a path-join.
189
+ if (repoFullName.includes('..')) {
190
+ return { valid: false, error: 'Invalid repoFullName: cannot contain ".." sequences' };
191
+ }
192
+
193
+ return { valid: true };
194
+ }
195
+
100
196
  /**
101
197
  * Validates a project slug for use in file paths.
102
198
  * Defense-in-depth: slugs come from Firestore but should still be validated