@doccov/api 0.3.4 → 0.3.6

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/api/index.ts CHANGED
@@ -1,17 +1,36 @@
1
- import { Hono } from 'hono';
2
- import { cors } from 'hono/cors';
3
- import { handle } from 'hono/vercel';
1
+ /**
2
+ * DocCov API - Plain Vercel Node.js handlers
3
+ *
4
+ * No framework, just VercelRequest/VercelResponse for maximum reliability.
5
+ */
6
+
7
+ import { createAnthropic } from '@ai-sdk/anthropic';
8
+ import type {
9
+ BuildPlan,
10
+ BuildPlanEnvironment,
11
+ BuildPlanExecutionResult,
12
+ BuildPlanStep,
13
+ BuildPlanStepResult,
14
+ BuildPlanTarget,
15
+ GitHubProjectContext,
16
+ } from '@doccov/sdk';
17
+ import { fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
18
+ import type { OpenPkg } from '@openpkg-ts/spec';
19
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
20
+ import { Sandbox } from '@vercel/sandbox';
21
+ import { generateObject } from 'ai';
22
+ import ms from 'ms';
23
+ import { z } from 'zod';
4
24
 
5
25
  export const config = {
6
- runtime: 'edge',
26
+ runtime: 'nodejs',
27
+ maxDuration: 300,
7
28
  };
8
29
 
9
- const app = new Hono().basePath('/');
10
-
11
- // Middleware
12
- app.use('*', cors());
30
+ // =============================================================================
31
+ // Types & Helpers
32
+ // =============================================================================
13
33
 
14
- // Types
15
34
  type BadgeColor =
16
35
  | 'brightgreen'
17
36
  | 'green'
@@ -22,13 +41,20 @@ type BadgeColor =
22
41
  | 'lightgrey';
23
42
 
24
43
  interface OpenPkgSpec {
25
- docs?: {
26
- coverageScore?: number;
27
- };
44
+ docs?: { coverageScore?: number };
28
45
  [key: string]: unknown;
29
46
  }
30
47
 
31
- // Badge helpers
48
+ function cors(res: VercelResponse): void {
49
+ res.setHeader('Access-Control-Allow-Origin', '*');
50
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
51
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
52
+ }
53
+
54
+ function json(res: VercelResponse, data: unknown, status = 200): void {
55
+ res.status(status).json(data);
56
+ }
57
+
32
58
  function getColorForScore(score: number): BadgeColor {
33
59
  if (score >= 90) return 'brightgreen';
34
60
  if (score >= 80) return 'green';
@@ -102,117 +128,902 @@ async function fetchSpecFromGitHub(
102
128
  return null;
103
129
  }
104
130
 
105
- // Root
106
- app.get('/', (c) => {
107
- return c.json({
108
- name: 'DocCov API',
109
- version: '0.2.0',
110
- endpoints: {
111
- health: '/health',
112
- badge: '/badge/:owner/:repo',
113
- spec: '/spec/:owner/:repo/:ref?',
114
- specPr: '/spec/:owner/:repo/pr/:pr',
115
- scan: '/scan (POST)',
116
- scanStream: '/scan-stream (GET)',
117
- },
118
- });
131
+ function getSandboxRuntime(_runtime: string): 'node22' {
132
+ return 'node22'; // Always use node22 for Vercel Sandbox
133
+ }
134
+
135
+ /**
136
+ * Common CLI tools that are typically devDependencies (not globally available).
137
+ * These need to be run via package manager exec (npx, pnpm exec, etc.)
138
+ */
139
+ const LOCAL_BINARIES = new Set([
140
+ 'turbo',
141
+ 'tsc',
142
+ 'tsup',
143
+ 'esbuild',
144
+ 'vite',
145
+ 'rollup',
146
+ 'webpack',
147
+ 'parcel',
148
+ 'swc',
149
+ 'bunchee',
150
+ 'unbuild',
151
+ 'microbundle',
152
+ 'preconstruct',
153
+ 'changesets',
154
+ 'eslint',
155
+ 'prettier',
156
+ 'vitest',
157
+ 'jest',
158
+ 'mocha',
159
+ 'ava',
160
+ 'c8',
161
+ 'nyc',
162
+ 'size-limit',
163
+ 'publint',
164
+ 'attw',
165
+ 'are-the-types-wrong',
166
+ ]);
167
+
168
+ /**
169
+ * Wrap local binaries with package manager exec command.
170
+ * Transforms ["turbo", "build"] -> ["pnpm", "exec", "turbo", "build"] (or npx for npm)
171
+ */
172
+ function wrapLocalBinary(
173
+ cmd: string,
174
+ args: string[],
175
+ packageManager: string,
176
+ ): { cmd: string; args: string[] } {
177
+ // If command is a local binary, wrap with package manager exec
178
+ if (LOCAL_BINARIES.has(cmd)) {
179
+ switch (packageManager) {
180
+ case 'pnpm':
181
+ return { cmd: 'pnpm', args: ['exec', cmd, ...args] };
182
+ case 'yarn':
183
+ return { cmd: 'yarn', args: [cmd, ...args] }; // yarn runs local bins directly
184
+ case 'bun':
185
+ return { cmd: 'bunx', args: [cmd, ...args] };
186
+ case 'npm':
187
+ default:
188
+ return { cmd: 'npx', args: [cmd, ...args] };
189
+ }
190
+ }
191
+ return { cmd, args };
192
+ }
193
+
194
+ /**
195
+ * Normalize cwd for sandbox commands.
196
+ * Vercel Sandbox default working directory is /vercel/sandbox (where git repos are cloned).
197
+ * - If cwd is undefined, empty, or '.' - return undefined (use sandbox default)
198
+ * - If cwd is an absolute path - return as-is
199
+ * - If cwd is a relative subdirectory - convert to absolute path under /vercel/sandbox
200
+ */
201
+ function normalizeCwd(cwd: string | undefined): string | undefined {
202
+ if (!cwd || cwd === '.' || cwd === './') return undefined;
203
+ // If already absolute, return as-is
204
+ if (cwd.startsWith('/')) return cwd;
205
+ // Convert relative path to absolute path within sandbox
206
+ return `/vercel/sandbox/${cwd}`;
207
+ }
208
+
209
+ interface SpecSummary {
210
+ name: string;
211
+ version: string;
212
+ coverage: number;
213
+ exports: number;
214
+ types: number;
215
+ documented: number;
216
+ undocumented: number;
217
+ }
218
+
219
+ function createSpecSummary(spec: OpenPkg): SpecSummary {
220
+ const exports = spec.exports?.length ?? 0;
221
+ const types = spec.types?.length ?? 0;
222
+
223
+ // Count documented vs undocumented exports
224
+ const documented =
225
+ spec.exports?.filter((e) => e.description && e.description.trim().length > 0).length ?? 0;
226
+ const undocumented = exports - documented;
227
+
228
+ // Calculate coverage (documented / total * 100)
229
+ const coverage = exports > 0 ? Math.round((documented / exports) * 100) : 0;
230
+
231
+ return {
232
+ name: spec.meta?.name ?? 'unknown',
233
+ version: spec.meta?.version ?? '0.0.0',
234
+ coverage,
235
+ exports,
236
+ types,
237
+ documented,
238
+ undocumented,
239
+ };
240
+ }
241
+
242
+ // =============================================================================
243
+ // Plan Agent (AI-powered build plan generation)
244
+ // =============================================================================
245
+
246
+ const BuildPlanStepSchema = z.object({
247
+ id: z.string().describe('Unique identifier (e.g., "install", "build-types")'),
248
+ name: z.string().describe('Human-readable step name'),
249
+ command: z.string().describe('Command to execute'),
250
+ args: z.array(z.string()).describe('Command arguments'),
251
+ cwd: z.string().optional().describe('Working directory relative to repo root'),
252
+ timeout: z.number().optional().describe('Timeout in milliseconds'),
253
+ optional: z.boolean().optional().describe('If true, failure does not stop execution'),
254
+ });
255
+
256
+ const BuildPlanEnvironmentSchema = z.object({
257
+ runtime: z.literal('node22').describe('Runtime (always node22)'),
258
+ packageManager: z.enum(['npm', 'yarn', 'pnpm', 'bun']).describe('Package manager'),
259
+ requiredTools: z.array(z.string()).optional().describe('Additional required tools'),
260
+ });
261
+
262
+ const BuildPlanReasoningSchema = z.object({
263
+ summary: z.string().describe('Brief summary of the approach (1-2 sentences)'),
264
+ rationale: z.string().describe('Why this approach was chosen'),
265
+ concerns: z.array(z.string()).describe('Potential issues or concerns'),
119
266
  });
120
267
 
121
- // Health check
122
- app.get('/health', (c) => {
123
- return c.json({ status: 'ok', timestamp: new Date().toISOString() });
268
+ const AIBuildPlanSchema = z.object({
269
+ environment: BuildPlanEnvironmentSchema,
270
+ steps: z.array(BuildPlanStepSchema).describe('Steps to execute in order'),
271
+ entryPoints: z.array(z.string()).describe('Entry point files to analyze'),
272
+ reasoning: BuildPlanReasoningSchema,
273
+ confidence: z.enum(['high', 'medium', 'low']).describe('Confidence in this plan'),
124
274
  });
125
275
 
126
- // GET /badge/:owner/:repo
127
- app.get('/badge/:owner/:repo', async (c) => {
128
- const { owner, repo } = c.req.param();
129
- const branch = c.req.query('branch') ?? 'main';
276
+ type AIBuildPlanOutput = z.infer<typeof AIBuildPlanSchema>;
277
+
278
+ interface GenerateBuildPlanOptions {
279
+ targetPackage?: string;
280
+ }
281
+
282
+ function formatContext(context: GitHubProjectContext): string {
283
+ const sections: string[] = [];
284
+
285
+ sections.push(`=== Repository ===
286
+ Owner: ${context.metadata.owner}
287
+ Repo: ${context.metadata.repo}
288
+ Language: ${context.metadata.language ?? 'unknown'}
289
+ Topics: ${context.metadata.topics.join(', ') || 'none'}
290
+ Description: ${context.metadata.description ?? 'none'}`);
291
+
292
+ sections.push(`=== Environment ===
293
+ Package Manager: ${context.packageManager}
294
+ Is Monorepo: ${context.workspace.isMonorepo}
295
+ Workspace Tool: ${context.workspace.tool ?? 'none'}
296
+ Workspace Packages: ${context.workspace.packages?.join(', ') ?? 'none'}`);
297
+
298
+ sections.push(`=== Build Hints ===
299
+ Has TypeScript: ${context.buildHints.hasTypeScript}
300
+ Has WASM: ${context.buildHints.hasWasm}
301
+ Has Native Modules: ${context.buildHints.hasNativeModules}
302
+ Has Build Script: ${context.buildHints.hasBuildScript}
303
+ Build Script: ${context.buildHints.buildScript ?? 'none'}
304
+ Frameworks: ${context.buildHints.frameworks.join(', ') || 'none'}`);
305
+
306
+ if (context.files.packageJson) {
307
+ const truncated =
308
+ context.files.packageJson.length > 3000
309
+ ? `${context.files.packageJson.slice(0, 3000)}\n... (truncated)`
310
+ : context.files.packageJson;
311
+ sections.push(`=== package.json ===\n${truncated}`);
312
+ }
313
+
314
+ if (context.files.tsconfigJson) {
315
+ sections.push(`=== tsconfig.json ===\n${context.files.tsconfigJson}`);
316
+ }
317
+
318
+ if (context.files.lockfile) {
319
+ const preview = context.files.lockfile.content.slice(0, 500);
320
+ sections.push(`=== ${context.files.lockfile.name} (preview) ===\n${preview}\n...`);
321
+ }
322
+
323
+ return sections.join('\n\n');
324
+ }
325
+
326
+ const PLAN_SYSTEM_PROMPT = `You are a build system expert. Your task is to analyze a GitHub repository and generate a build plan to analyze its TypeScript/JavaScript API.
327
+
328
+ The goal is to:
329
+ 1. Install dependencies
330
+ 2. Build the project (if needed) to generate TypeScript declarations
331
+ 3. Identify entry points for API documentation analysis
332
+
333
+ Package Manager Selection:
334
+ - If a lockfile is detected (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb), use that package manager
335
+ - If Package Manager is "unknown" (no lockfile), default to npm with "npm install" (NOT "npm ci")
336
+ - IMPORTANT: "npm ci" and "--frozen-lockfile" flags ONLY work when a lockfile exists
337
+ - When no lockfile: use "npm install", "yarn install", "pnpm install", or "bun install" (without frozen flags)
338
+
339
+ Install Commands by Package Manager:
340
+ - npm with lockfile: ["npm", "ci"]
341
+ - npm without lockfile: ["npm", "install"]
342
+ - yarn with lockfile: ["yarn", "install", "--frozen-lockfile"]
343
+ - yarn without lockfile: ["yarn", "install"]
344
+ - pnpm with lockfile: ["pnpm", "install", "--frozen-lockfile"]
345
+ - pnpm without lockfile: ["pnpm", "install"]
346
+ - bun with lockfile: ["bun", "install", "--frozen-lockfile"]
347
+ - bun without lockfile: ["bun", "install"]
348
+
349
+ General Guidelines:
350
+ - For TypeScript projects, look for "types" or "exports" fields in package.json
351
+ - For monorepos, focus on the target package if specified
352
+ - Common entry points: src/index.ts, dist/index.d.ts, lib/index.ts, distribution/index.d.ts
353
+ - WASM projects may need build steps before .d.ts files exist
354
+ - Be conservative with timeouts (default 60000ms, increase for builds)
355
+ - Installation is usually required first
356
+ - Build step is needed if package.json has a "build" script and the types are in dist/distribution folder
357
+
358
+ Step ID conventions:
359
+ - "install" - install dependencies
360
+ - "build" - main build step
361
+ - "build-types" - generate type declarations
362
+ - "analyze" - run doccov spec (added automatically)`;
363
+
364
+ function generatePlanPrompt(
365
+ context: GitHubProjectContext,
366
+ options: GenerateBuildPlanOptions,
367
+ ): string {
368
+ let prompt = `Analyze this repository and generate a build plan:\n\n${formatContext(context)}`;
369
+
370
+ if (options.targetPackage) {
371
+ prompt += `\n\nTarget Package: ${options.targetPackage}
372
+ Focus the plan on building and analyzing only this package within the monorepo.`;
373
+ }
374
+
375
+ prompt += `\n\nGenerate a build plan with:
376
+ - environment: Runtime and package manager configuration
377
+ - steps: Ordered build steps (install, build, etc.)
378
+ - entryPoints: TypeScript entry files to analyze (relative paths)
379
+ - reasoning: Explain your approach
380
+ - confidence: How confident you are in this plan`;
381
+
382
+ return prompt;
383
+ }
384
+
385
+ function transformToBuildPlan(
386
+ output: AIBuildPlanOutput,
387
+ context: GitHubProjectContext,
388
+ options: GenerateBuildPlanOptions,
389
+ ): BuildPlan {
390
+ const target: BuildPlanTarget = {
391
+ type: 'github',
392
+ repoUrl: `https://github.com/${context.metadata.owner}/${context.metadata.repo}`,
393
+ ref: context.ref,
394
+ rootPath: options.targetPackage,
395
+ entryPoints: output.entryPoints,
396
+ };
397
+
398
+ const environment: BuildPlanEnvironment = {
399
+ runtime: output.environment.runtime,
400
+ packageManager: output.environment.packageManager,
401
+ requiredTools: output.environment.requiredTools,
402
+ };
403
+
404
+ const steps: BuildPlanStep[] = output.steps.map((step) => ({
405
+ id: step.id,
406
+ name: step.name,
407
+ command: step.command,
408
+ args: step.args,
409
+ cwd: step.cwd,
410
+ timeout: step.timeout,
411
+ optional: step.optional,
412
+ }));
413
+
414
+ return {
415
+ version: '1.0.0',
416
+ generatedAt: new Date().toISOString(),
417
+ target,
418
+ environment,
419
+ steps,
420
+ reasoning: {
421
+ summary: output.reasoning.summary,
422
+ rationale: output.reasoning.rationale,
423
+ concerns: output.reasoning.concerns,
424
+ },
425
+ confidence: output.confidence,
426
+ };
427
+ }
428
+
429
+ async function generateBuildPlan(
430
+ context: GitHubProjectContext,
431
+ options: GenerateBuildPlanOptions = {},
432
+ ): Promise<BuildPlan> {
433
+ const anthropic = createAnthropic({
434
+ apiKey: process.env.ANTHROPIC_API_KEY,
435
+ });
436
+ const model = anthropic('claude-sonnet-4-20250514');
437
+ const prompt = generatePlanPrompt(context, options);
438
+
439
+ const { object } = await generateObject({
440
+ model,
441
+ schema: AIBuildPlanSchema,
442
+ system: PLAN_SYSTEM_PROMPT,
443
+ prompt,
444
+ });
445
+
446
+ return transformToBuildPlan(object, context, options);
447
+ }
448
+
449
+ // =============================================================================
450
+ // Route Handlers
451
+ // =============================================================================
452
+
453
+ async function handleRoot(_req: VercelRequest, res: VercelResponse): Promise<void> {
454
+ json(res, {
455
+ name: 'DocCov API',
456
+ version: '0.4.0',
457
+ status: 'ok',
458
+ timestamp: new Date().toISOString(),
459
+ endpoints: [
460
+ { method: 'GET', path: '/', description: 'API info and health status' },
461
+ {
462
+ method: 'GET',
463
+ path: '/badge/:owner/:repo',
464
+ description: 'Get coverage badge for a GitHub repo',
465
+ },
466
+ {
467
+ method: 'GET',
468
+ path: '/spec/:owner/:repo/:ref?',
469
+ description: 'Get OpenPkg spec for a GitHub repo',
470
+ },
471
+ { method: 'POST', path: '/plan', description: 'Generate AI build plan for a GitHub repo' },
472
+ { method: 'POST', path: '/execute', description: 'Execute a build plan and return results' },
473
+ {
474
+ method: 'POST',
475
+ path: '/execute-stream',
476
+ description: 'Execute a build plan with SSE streaming',
477
+ },
478
+ ],
479
+ });
480
+ }
481
+
482
+ async function handleBadge(
483
+ req: VercelRequest,
484
+ res: VercelResponse,
485
+ owner: string,
486
+ repo: string,
487
+ ): Promise<void> {
488
+ const branch = (req.query.branch as string) ?? 'main';
130
489
 
131
490
  try {
132
491
  const spec = await fetchSpecFromGitHub(owner, repo, branch);
133
492
 
134
493
  if (!spec) {
135
494
  const svg = generateBadgeSvg('docs', 'not found', 'lightgrey');
136
- return c.body(svg, 404, {
137
- 'Content-Type': 'image/svg+xml',
138
- 'Cache-Control': 'no-cache',
139
- });
495
+ res.setHeader('Content-Type', 'image/svg+xml');
496
+ res.setHeader('Cache-Control', 'no-cache');
497
+ res.status(404).send(svg);
498
+ return;
140
499
  }
141
500
 
142
501
  const coverageScore = spec.docs?.coverageScore ?? 0;
143
502
  const svg = generateBadgeSvg('docs', `${coverageScore}%`, getColorForScore(coverageScore));
144
-
145
- return c.body(svg, 200, {
146
- 'Content-Type': 'image/svg+xml',
147
- 'Cache-Control': 'public, max-age=300',
148
- });
503
+ res.setHeader('Content-Type', 'image/svg+xml');
504
+ res.setHeader('Cache-Control', 'public, max-age=300');
505
+ res.status(200).send(svg);
149
506
  } catch {
150
507
  const svg = generateBadgeSvg('docs', 'error', 'red');
151
- return c.body(svg, 500, {
152
- 'Content-Type': 'image/svg+xml',
153
- 'Cache-Control': 'no-cache',
154
- });
508
+ res.setHeader('Content-Type', 'image/svg+xml');
509
+ res.setHeader('Cache-Control', 'no-cache');
510
+ res.status(500).send(svg);
155
511
  }
156
- });
512
+ }
157
513
 
158
- // GET /badge/:owner/:repo.svg (alias)
159
- app.get('/badge/:owner/:repo.svg', async (c) => {
160
- const owner = c.req.param('owner');
161
- const repoWithSvg = c.req.param('repo.svg') ?? '';
162
- const repoName = repoWithSvg.replace(/\.svg$/, '');
163
- return c.redirect(`/badge/${owner}/${repoName}`);
164
- });
514
+ async function handleSpec(
515
+ _req: VercelRequest,
516
+ res: VercelResponse,
517
+ owner: string,
518
+ repo: string,
519
+ ref?: string,
520
+ ): Promise<void> {
521
+ const actualRef = ref ?? 'main';
522
+ const spec = await fetchSpecFromGitHub(owner, repo, actualRef);
165
523
 
166
- // GET /spec/:owner/:repo/pr/:pr - Must be before the :ref route
167
- app.get('/spec/:owner/:repo/pr/:pr', async (c) => {
168
- const { owner, repo, pr } = c.req.param();
524
+ if (!spec) {
525
+ json(res, { error: 'Spec not found' }, 404);
526
+ return;
527
+ }
169
528
 
529
+ res.setHeader('Cache-Control', 'public, max-age=300');
530
+ json(res, spec);
531
+ }
532
+
533
+ async function handleSpecPr(
534
+ _req: VercelRequest,
535
+ res: VercelResponse,
536
+ owner: string,
537
+ repo: string,
538
+ pr: string,
539
+ ): Promise<void> {
170
540
  try {
171
- // Get PR head SHA from GitHub API
172
541
  const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}`, {
173
542
  headers: { 'User-Agent': 'DocCov' },
174
543
  });
175
544
 
176
545
  if (!prResponse.ok) {
177
- return c.json({ error: 'PR not found' }, 404);
546
+ json(res, { error: 'PR not found' }, 404);
547
+ return;
178
548
  }
179
549
 
180
550
  const prData = (await prResponse.json()) as { head: { sha: string } };
181
- const headSha = prData.head.sha;
182
-
183
- // Fetch spec from PR head
184
- const specUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${headSha}/openpkg.json`;
551
+ const specUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${prData.head.sha}/openpkg.json`;
185
552
  const specResponse = await fetch(specUrl);
186
553
 
187
554
  if (!specResponse.ok) {
188
- return c.json({ error: 'Spec not found in PR' }, 404);
555
+ json(res, { error: 'Spec not found in PR' }, 404);
556
+ return;
189
557
  }
190
558
 
191
559
  const spec = await specResponse.json();
192
- return c.json(spec, 200, {
193
- 'Cache-Control': 'no-cache',
194
- });
560
+ res.setHeader('Cache-Control', 'no-cache');
561
+ json(res, spec);
195
562
  } catch {
196
- return c.json({ error: 'Failed to fetch PR spec' }, 500);
563
+ json(res, { error: 'Failed to fetch PR spec' }, 500);
197
564
  }
198
- });
565
+ }
199
566
 
200
- // GET /spec/:owner/:repo/:ref? (default ref = main)
201
- app.get('/spec/:owner/:repo/:ref?', async (c) => {
202
- const { owner, repo } = c.req.param();
203
- const ref = c.req.param('ref') ?? 'main';
567
+ async function handlePlan(req: VercelRequest, res: VercelResponse): Promise<void> {
568
+ const body = req.body as { url?: string; ref?: string; package?: string };
204
569
 
205
- const spec = await fetchSpecFromGitHub(owner, repo, ref);
570
+ if (!body.url) {
571
+ json(res, { error: 'url is required' }, 400);
572
+ return;
573
+ }
206
574
 
207
- if (!spec) {
208
- return c.json({ error: 'Spec not found' }, 404);
575
+ let repoUrl: string;
576
+ try {
577
+ const parsed = parseScanGitHubUrl(body.url);
578
+ if (!parsed) {
579
+ json(res, { error: 'Invalid GitHub URL' }, 400);
580
+ return;
581
+ }
582
+ repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
583
+ } catch {
584
+ json(res, { error: 'Invalid GitHub URL' }, 400);
585
+ return;
209
586
  }
210
587
 
211
- return c.json(spec, 200, {
212
- 'Cache-Control': 'public, max-age=300',
213
- });
214
- });
588
+ try {
589
+ const context = await fetchGitHubContext(repoUrl, body.ref);
590
+
591
+ if (context.metadata.isPrivate) {
592
+ json(
593
+ res,
594
+ {
595
+ error: 'Private repositories are not supported',
596
+ hint: 'Use a public repository or run doccov locally',
597
+ },
598
+ 403,
599
+ );
600
+ return;
601
+ }
602
+
603
+ const plan = await generateBuildPlan(context, { targetPackage: body.package });
604
+
605
+ json(res, {
606
+ plan,
607
+ context: {
608
+ owner: context.metadata.owner,
609
+ repo: context.metadata.repo,
610
+ ref: context.ref,
611
+ packageManager: context.packageManager,
612
+ isMonorepo: context.workspace.isMonorepo,
613
+ },
614
+ });
615
+ } catch (error) {
616
+ console.error('Plan generation error:', error);
617
+
618
+ if (error instanceof Error) {
619
+ if (error.message.includes('404') || error.message.includes('not found')) {
620
+ json(res, { error: 'Repository not found' }, 404);
621
+ return;
622
+ }
623
+ if (error.message.includes('rate limit')) {
624
+ json(res, { error: 'GitHub API rate limit exceeded' }, 429);
625
+ return;
626
+ }
627
+ }
628
+
629
+ json(
630
+ res,
631
+ {
632
+ error: 'Failed to generate build plan',
633
+ message: error instanceof Error ? error.message : 'Unknown error',
634
+ },
635
+ 500,
636
+ );
637
+ }
638
+ }
639
+
640
+ async function handleExecute(req: VercelRequest, res: VercelResponse): Promise<void> {
641
+ const includeSpec = req.query.includeSpec === 'true';
642
+ const body = req.body as { plan?: BuildPlan };
643
+
644
+ if (!body.plan) {
645
+ json(res, { error: 'plan is required' }, 400);
646
+ return;
647
+ }
648
+
649
+ const { plan } = body;
650
+
651
+ if (!plan.target?.repoUrl || !plan.steps) {
652
+ json(res, { error: 'Invalid plan structure' }, 400);
653
+ return;
654
+ }
655
+
656
+ const startTime = Date.now();
657
+ const stepResults: BuildPlanStepResult[] = [];
658
+ let sandbox: Awaited<ReturnType<typeof Sandbox.create>> | null = null;
659
+
660
+ try {
661
+ sandbox = await Sandbox.create({
662
+ source: { url: plan.target.repoUrl, type: 'git' as const },
663
+ resources: { vcpus: 4 },
664
+ timeout: ms('5m'),
665
+ runtime: getSandboxRuntime(plan.environment.runtime),
666
+ });
667
+
668
+ for (const step of plan.steps) {
669
+ const stepStart = Date.now();
670
+
671
+ try {
672
+ const normalizedCwd = normalizeCwd(step.cwd);
673
+ const { cmd, args } = wrapLocalBinary(step.command, step.args, plan.environment.packageManager);
674
+ const result = await sandbox.runCommand({
675
+ cmd,
676
+ args,
677
+ ...(normalizedCwd ? { cwd: normalizedCwd } : {}),
678
+ });
679
+
680
+ const stdout = result.stdout ? await result.stdout() : '';
681
+ const stderr = result.stderr ? await result.stderr() : '';
682
+
683
+ stepResults.push({
684
+ stepId: step.id,
685
+ success: result.exitCode === 0,
686
+ duration: Date.now() - stepStart,
687
+ output: stdout.slice(0, 5000),
688
+ error: result.exitCode !== 0 ? stderr.slice(0, 2000) : undefined,
689
+ });
690
+
691
+ if (result.exitCode !== 0 && !step.optional) {
692
+ throw new Error(`Step '${step.name}' failed: ${stderr}`);
693
+ }
694
+ } catch (stepError) {
695
+ stepResults.push({
696
+ stepId: step.id,
697
+ success: false,
698
+ duration: Date.now() - stepStart,
699
+ error: stepError instanceof Error ? stepError.message : 'Unknown error',
700
+ });
701
+
702
+ if (!step.optional) {
703
+ throw stepError;
704
+ }
705
+ }
706
+ }
707
+
708
+ // Run doccov spec
709
+ const specStart = Date.now();
710
+ let entryPoint = plan.target.entryPoints[0] ?? 'src/index.ts';
711
+ const analyzeCwd = normalizeCwd(plan.target.rootPath);
712
+
713
+ // If running in a subdirectory (rootPath), strip the rootPath prefix from entryPoint
714
+ if (plan.target.rootPath && entryPoint.startsWith(plan.target.rootPath + '/')) {
715
+ entryPoint = entryPoint.slice(plan.target.rootPath.length + 1);
716
+ }
717
+
718
+ await sandbox.runCommand({ cmd: 'npm', args: ['install', '-g', '@doccov/cli@latest'] });
719
+
720
+ const specResult = await sandbox.runCommand({
721
+ cmd: 'doccov',
722
+ args: ['spec', entryPoint, '--output', 'openpkg.json'],
723
+ ...(analyzeCwd ? { cwd: analyzeCwd } : {}),
724
+ });
725
+
726
+ const specStdout = specResult.stdout ? await specResult.stdout() : '';
727
+ const specStderr = specResult.stderr ? await specResult.stderr() : '';
728
+
729
+ stepResults.push({
730
+ stepId: 'analyze',
731
+ success: specResult.exitCode === 0,
732
+ duration: Date.now() - specStart,
733
+ output: specStdout.slice(0, 2000),
734
+ error: specResult.exitCode !== 0 ? specStderr.slice(0, 2000) : undefined,
735
+ });
736
+
737
+ if (specResult.exitCode !== 0) {
738
+ throw new Error(`Spec generation failed: ${specStderr}`);
739
+ }
740
+
741
+ // Read openpkg.json from the correct location (rootPath subdirectory if set)
742
+ const specFilePath = plan.target.rootPath
743
+ ? `${plan.target.rootPath}/openpkg.json`
744
+ : 'openpkg.json';
745
+ const specStream = await sandbox.readFile({ path: specFilePath });
746
+ const chunks: Buffer[] = [];
747
+ for await (const chunk of specStream as AsyncIterable<Buffer>) {
748
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
749
+ }
750
+ const specContent = Buffer.concat(chunks).toString('utf-8');
751
+ const spec = JSON.parse(specContent) as OpenPkg;
752
+ const summary = createSpecSummary(spec);
753
+
754
+ const result = {
755
+ success: true,
756
+ summary,
757
+ stepResults,
758
+ totalDuration: Date.now() - startTime,
759
+ ...(includeSpec ? { spec } : {}),
760
+ };
761
+
762
+ json(res, result);
763
+ } catch (error) {
764
+ console.error('Execution error:', error);
765
+
766
+ const result: BuildPlanExecutionResult = {
767
+ success: false,
768
+ stepResults,
769
+ totalDuration: Date.now() - startTime,
770
+ error: error instanceof Error ? error.message : 'Unknown error',
771
+ };
772
+
773
+ json(res, result);
774
+ } finally {
775
+ if (sandbox) {
776
+ try {
777
+ await sandbox.stop();
778
+ } catch {
779
+ // Ignore cleanup errors
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ async function handleExecuteStream(req: VercelRequest, res: VercelResponse): Promise<void> {
786
+ const includeSpec = req.query.includeSpec === 'true';
787
+
788
+ // SSE headers
789
+ res.setHeader('Content-Type', 'text/event-stream');
790
+ res.setHeader('Cache-Control', 'no-cache');
791
+ res.setHeader('Connection', 'keep-alive');
792
+
793
+ const send = (event: string, data: unknown) => {
794
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
795
+ };
796
+
797
+ const body = req.body as { plan?: BuildPlan };
798
+
799
+ if (!body.plan) {
800
+ send('error', { error: 'plan is required' });
801
+ res.end();
802
+ return;
803
+ }
804
+
805
+ const { plan } = body;
806
+
807
+ if (!plan.target?.repoUrl || !plan.steps) {
808
+ send('error', { error: 'Invalid plan structure' });
809
+ res.end();
810
+ return;
811
+ }
812
+
813
+ const startTime = Date.now();
814
+ const stepResults: BuildPlanStepResult[] = [];
815
+ let sandbox: Awaited<ReturnType<typeof Sandbox.create>> | null = null;
816
+
817
+ try {
818
+ send('progress', { stage: 'init', message: 'Creating sandbox...', progress: 5 });
819
+
820
+ sandbox = await Sandbox.create({
821
+ source: { url: plan.target.repoUrl, type: 'git' as const },
822
+ resources: { vcpus: 4 },
823
+ timeout: ms('5m'),
824
+ runtime: getSandboxRuntime(plan.environment.runtime),
825
+ });
215
826
 
216
- // Note: /scan and /scan-stream are handled by separate Node.js functions
827
+ send('progress', { stage: 'cloned', message: 'Repository cloned', progress: 15 });
217
828
 
218
- export default handle(app);
829
+ const totalSteps = plan.steps.length + 1; // +1 for analyze step
830
+ let completedSteps = 0;
831
+
832
+ for (const step of plan.steps) {
833
+ const stepStart = Date.now();
834
+ const progressBase = 15 + (completedSteps / totalSteps) * 70;
835
+
836
+ send('step:start', { stepId: step.id, name: step.name, progress: Math.round(progressBase) });
837
+
838
+ try {
839
+ const normalizedCwd = normalizeCwd(step.cwd);
840
+ const { cmd, args } = wrapLocalBinary(step.command, step.args, plan.environment.packageManager);
841
+ const result = await sandbox.runCommand({
842
+ cmd,
843
+ args,
844
+ ...(normalizedCwd ? { cwd: normalizedCwd } : {}),
845
+ });
846
+
847
+ const stdout = result.stdout ? await result.stdout() : '';
848
+ const stderr = result.stderr ? await result.stderr() : '';
849
+
850
+ const stepResult: BuildPlanStepResult = {
851
+ stepId: step.id,
852
+ success: result.exitCode === 0,
853
+ duration: Date.now() - stepStart,
854
+ output: stdout.slice(0, 5000),
855
+ error: result.exitCode !== 0 ? stderr.slice(0, 2000) : undefined,
856
+ };
857
+
858
+ stepResults.push(stepResult);
859
+ completedSteps++;
860
+
861
+ send('step:complete', {
862
+ ...stepResult,
863
+ progress: Math.round(15 + (completedSteps / totalSteps) * 70),
864
+ });
865
+
866
+ if (result.exitCode !== 0 && !step.optional) {
867
+ throw new Error(`Step '${step.name}' failed: ${stderr}`);
868
+ }
869
+ } catch (stepError) {
870
+ const stepResult: BuildPlanStepResult = {
871
+ stepId: step.id,
872
+ success: false,
873
+ duration: Date.now() - stepStart,
874
+ error: stepError instanceof Error ? stepError.message : 'Unknown error',
875
+ };
876
+
877
+ stepResults.push(stepResult);
878
+ send('step:error', stepResult);
879
+
880
+ if (!step.optional) {
881
+ throw stepError;
882
+ }
883
+ }
884
+ }
885
+
886
+ // Run doccov spec
887
+ send('step:start', { stepId: 'analyze', name: 'Analyzing API', progress: 85 });
888
+ const specStart = Date.now();
889
+ let entryPoint = plan.target.entryPoints[0] ?? 'src/index.ts';
890
+ const analyzeCwd = normalizeCwd(plan.target.rootPath);
891
+
892
+ // If running in a subdirectory (rootPath), strip the rootPath prefix from entryPoint
893
+ // e.g., rootPath="packages/v0-sdk", entryPoint="packages/v0-sdk/src/index.ts" -> "src/index.ts"
894
+ if (plan.target.rootPath && entryPoint.startsWith(plan.target.rootPath + '/')) {
895
+ entryPoint = entryPoint.slice(plan.target.rootPath.length + 1);
896
+ }
897
+
898
+ await sandbox.runCommand({ cmd: 'npm', args: ['install', '-g', '@doccov/cli@latest'] });
899
+
900
+ const specResult = await sandbox.runCommand({
901
+ cmd: 'doccov',
902
+ args: ['spec', entryPoint, '--output', 'openpkg.json'],
903
+ ...(analyzeCwd ? { cwd: analyzeCwd } : {}),
904
+ });
905
+
906
+ const specStdout = specResult.stdout ? await specResult.stdout() : '';
907
+ const specStderr = specResult.stderr ? await specResult.stderr() : '';
908
+
909
+ stepResults.push({
910
+ stepId: 'analyze',
911
+ success: specResult.exitCode === 0,
912
+ duration: Date.now() - specStart,
913
+ output: specStdout.slice(0, 2000),
914
+ error: specResult.exitCode !== 0 ? specStderr.slice(0, 2000) : undefined,
915
+ });
916
+
917
+ if (specResult.exitCode !== 0) {
918
+ throw new Error(`Spec generation failed: ${specStderr}`);
919
+ }
920
+
921
+ send('step:complete', { stepId: 'analyze', success: true, progress: 95 });
922
+
923
+ // Read openpkg.json from the correct location (rootPath subdirectory if set)
924
+ const specFilePath = plan.target.rootPath
925
+ ? `${plan.target.rootPath}/openpkg.json`
926
+ : 'openpkg.json';
927
+ const specStream = await sandbox.readFile({ path: specFilePath });
928
+ const chunks: Buffer[] = [];
929
+ for await (const chunk of specStream as AsyncIterable<Buffer>) {
930
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
931
+ }
932
+ const specContent = Buffer.concat(chunks).toString('utf-8');
933
+ const spec = JSON.parse(specContent) as OpenPkg;
934
+ const summary = createSpecSummary(spec);
935
+
936
+ const result = {
937
+ success: true,
938
+ summary,
939
+ stepResults,
940
+ totalDuration: Date.now() - startTime,
941
+ ...(includeSpec ? { spec } : {}),
942
+ };
943
+
944
+ send('complete', result);
945
+ } catch (error) {
946
+ console.error('Execution error:', error);
947
+
948
+ const result: BuildPlanExecutionResult = {
949
+ success: false,
950
+ stepResults,
951
+ totalDuration: Date.now() - startTime,
952
+ error: error instanceof Error ? error.message : 'Unknown error',
953
+ };
954
+
955
+ send('error', result);
956
+ } finally {
957
+ if (sandbox) {
958
+ try {
959
+ await sandbox.stop();
960
+ } catch {
961
+ // Ignore cleanup errors
962
+ }
963
+ }
964
+ res.end();
965
+ }
966
+ }
967
+
968
+ // =============================================================================
969
+ // Main Router
970
+ // =============================================================================
971
+
972
+ export default async function handler(req: VercelRequest, res: VercelResponse): Promise<void> {
973
+ // CORS
974
+ cors(res);
975
+
976
+ if (req.method === 'OPTIONS') {
977
+ res.status(200).end();
978
+ return;
979
+ }
980
+
981
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
982
+ const path = url.pathname;
983
+
984
+ try {
985
+ // GET /
986
+ if (path === '/' && req.method === 'GET') {
987
+ return handleRoot(req, res);
988
+ }
989
+
990
+ // GET /badge/:owner/:repo
991
+ const badgeMatch = path.match(/^\/badge\/([^/]+)\/([^/]+)$/);
992
+ if (badgeMatch && req.method === 'GET') {
993
+ return handleBadge(req, res, badgeMatch[1], badgeMatch[2]);
994
+ }
995
+
996
+ // GET /spec/:owner/:repo/pr/:pr
997
+ const specPrMatch = path.match(/^\/spec\/([^/]+)\/([^/]+)\/pr\/(\d+)$/);
998
+ if (specPrMatch && req.method === 'GET') {
999
+ return handleSpecPr(req, res, specPrMatch[1], specPrMatch[2], specPrMatch[3]);
1000
+ }
1001
+
1002
+ // GET /spec/:owner/:repo/:ref?
1003
+ const specMatch = path.match(/^\/spec\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/);
1004
+ if (specMatch && req.method === 'GET') {
1005
+ return handleSpec(req, res, specMatch[1], specMatch[2], specMatch[3]);
1006
+ }
1007
+
1008
+ // POST /plan
1009
+ if (path === '/plan' && req.method === 'POST') {
1010
+ return handlePlan(req, res);
1011
+ }
1012
+
1013
+ // POST /execute
1014
+ if (path === '/execute' && req.method === 'POST') {
1015
+ return handleExecute(req, res);
1016
+ }
1017
+
1018
+ // POST /execute-stream (SSE)
1019
+ if (path === '/execute-stream' && req.method === 'POST') {
1020
+ return handleExecuteStream(req, res);
1021
+ }
1022
+
1023
+ // 404
1024
+ json(res, { error: 'Not found' }, 404);
1025
+ } catch (error) {
1026
+ console.error('Handler error:', error);
1027
+ json(res, { error: 'Internal server error' }, 500);
1028
+ }
1029
+ }