@doccov/api 0.3.4 → 0.3.5

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.
@@ -1,240 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import * as path from 'node:path';
3
- import { Hono } from 'hono';
4
- import { isSandboxAvailable, runScanInSandbox } from '../sandbox-runner';
5
- import { type ScanJob, type ScanResult, scanJobStore } from '../scan-worker';
6
- import { scanRequestSchema } from '../schemas/scan';
7
-
8
- export const scanRoute = new Hono();
9
-
10
- /**
11
- * POST /scan
12
- * Start a new scan job
13
- */
14
- scanRoute.post('/', async (c) => {
15
- // Parse JSON with error handling
16
- let rawBody: unknown;
17
- try {
18
- rawBody = await c.req.json();
19
- } catch {
20
- return c.json({ error: 'Invalid JSON' }, 400);
21
- }
22
-
23
- // Validate request body with Zod schema
24
- const parsed = scanRequestSchema.safeParse(rawBody);
25
- if (!parsed.success) {
26
- return c.json(
27
- {
28
- error: 'Validation failed',
29
- details: parsed.error.issues.map((i) => i.message),
30
- },
31
- 400,
32
- );
33
- }
34
-
35
- const body = parsed.data;
36
-
37
- // Generate job ID
38
- const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
39
-
40
- // Check cache first
41
- const cacheKey = buildCacheKey(body.url, body.ref, body.package);
42
- const cached = scanJobStore.getFromCache(cacheKey);
43
- if (cached) {
44
- return c.json({
45
- jobId,
46
- status: 'complete',
47
- cached: true,
48
- result: cached,
49
- });
50
- }
51
-
52
- // Create job
53
- const job: ScanJob = {
54
- id: jobId,
55
- status: 'pending',
56
- url: body.url,
57
- ref: body.ref,
58
- package: body.package,
59
- createdAt: Date.now(),
60
- };
61
-
62
- scanJobStore.set(jobId, job);
63
-
64
- // Start background worker (sandbox or local based on environment)
65
- startScanWorker(job, cacheKey);
66
-
67
- return c.json({
68
- jobId,
69
- status: 'pending',
70
- pollUrl: `/scan/${jobId}`,
71
- });
72
- });
73
-
74
- /**
75
- * GET /scan/:jobId
76
- * Get scan job status and result
77
- */
78
- scanRoute.get('/:jobId', (c) => {
79
- const jobId = c.req.param('jobId');
80
- const job = scanJobStore.get(jobId);
81
-
82
- if (!job) {
83
- return c.json({ error: 'Job not found' }, 404);
84
- }
85
-
86
- if (job.status === 'complete') {
87
- return c.json({
88
- jobId,
89
- status: 'complete',
90
- result: job.result,
91
- });
92
- }
93
-
94
- if (job.status === 'failed') {
95
- return c.json({
96
- jobId,
97
- status: 'failed',
98
- error: job.error,
99
- });
100
- }
101
-
102
- return c.json({
103
- jobId,
104
- status: job.status,
105
- startedAt: job.startedAt,
106
- });
107
- });
108
-
109
- /**
110
- * Build cache key from scan parameters
111
- */
112
- function buildCacheKey(url: string, ref?: string, pkg?: string): string {
113
- const parts = [url, ref ?? 'main'];
114
- if (pkg) parts.push(pkg);
115
- return parts.join('::');
116
- }
117
-
118
- /**
119
- * Start background scan worker
120
- * Uses Vercel Sandbox in production, local spawn in development
121
- */
122
- function startScanWorker(job: ScanJob, cacheKey: string): void {
123
- if (isSandboxAvailable()) {
124
- runSandboxScan(job, cacheKey);
125
- } else {
126
- runLocalScan(job, cacheKey);
127
- }
128
- }
129
-
130
- /**
131
- * Run scan in Vercel Sandbox (production)
132
- */
133
- async function runSandboxScan(job: ScanJob, cacheKey: string): Promise<void> {
134
- job.status = 'running';
135
- job.startedAt = Date.now();
136
- scanJobStore.set(job.id, job);
137
-
138
- try {
139
- const result = await runScanInSandbox({
140
- url: job.url,
141
- ref: job.ref,
142
- package: job.package,
143
- });
144
-
145
- job.status = 'complete';
146
- job.result = result;
147
- job.completedAt = Date.now();
148
- scanJobStore.setCache(cacheKey, result);
149
- } catch (error) {
150
- job.status = 'failed';
151
- job.error = error instanceof Error ? error.message : String(error);
152
- }
153
-
154
- scanJobStore.set(job.id, job);
155
- }
156
-
157
- /**
158
- * Run scan locally via CLI spawn (development fallback)
159
- */
160
- function runLocalScan(job: ScanJob, cacheKey: string): void {
161
- // Update job status
162
- job.status = 'running';
163
- job.startedAt = Date.now();
164
- scanJobStore.set(job.id, job);
165
-
166
- // Get monorepo root (packages/api/src/routes -> root)
167
- const monorepoRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
168
- const cliPath = path.join(monorepoRoot, 'packages/cli/src/cli.ts');
169
-
170
- // Build command args
171
- const args = [cliPath, 'scan', job.url, '--output', 'json'];
172
- if (job.ref) {
173
- args.push('--ref', job.ref);
174
- }
175
- if (job.package) {
176
- args.push('--package', job.package);
177
- }
178
-
179
- // Spawn doccov scan process
180
- const proc = spawn('bun', args, {
181
- cwd: monorepoRoot,
182
- stdio: ['ignore', 'pipe', 'pipe'],
183
- });
184
-
185
- let stdout = '';
186
- let stderr = '';
187
-
188
- proc.stdout.on('data', (data) => {
189
- stdout += data.toString();
190
- });
191
-
192
- proc.stderr.on('data', (data) => {
193
- stderr += data.toString();
194
- });
195
-
196
- proc.on('close', (code) => {
197
- if (code === 0) {
198
- try {
199
- // Extract JSON from output (skip any non-JSON lines)
200
- const jsonMatch = stdout.match(/\{[\s\S]*\}/);
201
- if (jsonMatch) {
202
- const result = JSON.parse(jsonMatch[0]) as ScanResult;
203
- job.status = 'complete';
204
- job.result = result;
205
- job.completedAt = Date.now();
206
-
207
- // Cache result
208
- scanJobStore.setCache(cacheKey, result);
209
- } else {
210
- job.status = 'failed';
211
- job.error = 'No JSON output from scan';
212
- }
213
- } catch (parseError) {
214
- job.status = 'failed';
215
- job.error = `Failed to parse scan output: ${parseError instanceof Error ? parseError.message : parseError}`;
216
- }
217
- } else {
218
- job.status = 'failed';
219
- job.error = stderr || `Scan exited with code ${code}`;
220
- }
221
-
222
- scanJobStore.set(job.id, job);
223
- });
224
-
225
- proc.on('error', (err) => {
226
- job.status = 'failed';
227
- job.error = `Process error: ${err.message}`;
228
- scanJobStore.set(job.id, job);
229
- });
230
-
231
- // Timeout after 3 minutes
232
- setTimeout(() => {
233
- if (job.status === 'running') {
234
- proc.kill();
235
- job.status = 'failed';
236
- job.error = 'Scan timed out after 3 minutes';
237
- scanJobStore.set(job.id, job);
238
- }
239
- }, 180000);
240
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * Vercel Sandbox runner for isolated repo scanning
3
- */
4
-
5
- import type { ScanResult } from '@doccov/sdk';
6
- import { Sandbox } from '@vercel/sandbox';
7
- import ms from 'ms';
8
-
9
- export interface ScanOptions {
10
- url: string;
11
- ref?: string;
12
- package?: string;
13
- }
14
-
15
- /**
16
- * Run a documentation coverage scan in an isolated Vercel Sandbox.
17
- *
18
- * The sandbox:
19
- * 1. Clones the repository (automatic via source.url)
20
- * 2. Installs dependencies (with --ignore-scripts for security)
21
- * 3. Installs @doccov/cli globally
22
- * 4. Runs doccov scan with --skip-install (deps already installed)
23
- * 5. Returns the scan result
24
- */
25
- export async function runScanInSandbox(options: ScanOptions): Promise<ScanResult> {
26
- const sandbox = await Sandbox.create({
27
- source: {
28
- url: options.url,
29
- type: 'git',
30
- },
31
- resources: { vcpus: 4 },
32
- timeout: ms('5m'),
33
- runtime: 'node22',
34
- });
35
-
36
- try {
37
- // Install project dependencies (with security flags)
38
- await sandbox.runCommand({
39
- cmd: 'npm',
40
- args: ['install', '--ignore-scripts', '--legacy-peer-deps'],
41
- });
42
-
43
- // Install doccov CLI globally
44
- await sandbox.runCommand({
45
- cmd: 'npm',
46
- args: ['install', '-g', '@doccov/cli'],
47
- });
48
-
49
- // Build the scan command args
50
- const scanArgs = ['scan', '.', '--output', 'json', '--skip-install'];
51
- if (options.package) {
52
- scanArgs.push('--package', options.package);
53
- }
54
-
55
- // Run the scan
56
- const result = await sandbox.runCommand({
57
- cmd: 'doccov',
58
- args: scanArgs,
59
- });
60
-
61
- // Parse and return the JSON result
62
- // The output may contain spinner text before the JSON, so extract JSON portion
63
- const stdout = result.stdout ?? '';
64
- const jsonMatch = stdout.match(/\{[\s\S]*\}/);
65
-
66
- if (!jsonMatch) {
67
- throw new Error('No JSON output from scan');
68
- }
69
-
70
- return JSON.parse(jsonMatch[0]) as ScanResult;
71
- } finally {
72
- // Always stop the sandbox to clean up resources
73
- await sandbox.stop();
74
- }
75
- }
76
-
77
- /**
78
- * Check if Vercel Sandbox is available (OIDC token present)
79
- */
80
- export function isSandboxAvailable(): boolean {
81
- return process.env.VERCEL_OIDC_TOKEN !== undefined;
82
- }
@@ -1,122 +0,0 @@
1
- /**
2
- * Scan job store and caching layer
3
- */
4
-
5
- import type { ScanResult } from '@doccov/sdk';
6
- import type { JobStore } from './stores/job-store.interface';
7
-
8
- // Re-export ScanResult for backwards compatibility
9
- export type { ScanResult } from '@doccov/sdk';
10
-
11
- export interface ScanJob {
12
- id: string;
13
- status: 'pending' | 'running' | 'complete' | 'failed';
14
- url: string;
15
- ref?: string;
16
- package?: string;
17
- createdAt: number;
18
- startedAt?: number;
19
- completedAt?: number;
20
- result?: ScanResult;
21
- error?: string;
22
- }
23
-
24
- interface CacheEntry {
25
- result: ScanResult;
26
- cachedAt: number;
27
- }
28
-
29
- const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
30
- const JOB_TTL_MS = 10 * 60 * 1000; // 10 minutes
31
-
32
- /**
33
- * In-memory job store with caching
34
- * Implements JobStore interface for easy swap to Redis/KV
35
- */
36
- class ScanJobStore implements JobStore {
37
- private jobs = new Map<string, ScanJob>();
38
- private cache = new Map<string, CacheEntry>();
39
-
40
- /**
41
- * Get a job by ID
42
- */
43
- get(jobId: string): ScanJob | undefined {
44
- return this.jobs.get(jobId);
45
- }
46
-
47
- /**
48
- * Set/update a job
49
- */
50
- set(jobId: string, job: ScanJob): void {
51
- this.jobs.set(jobId, job);
52
- }
53
-
54
- /**
55
- * Get cached result
56
- */
57
- getFromCache(key: string): ScanResult | undefined {
58
- const entry = this.cache.get(key);
59
- if (!entry) return undefined;
60
-
61
- // Check TTL
62
- if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
63
- this.cache.delete(key);
64
- return undefined;
65
- }
66
-
67
- return entry.result;
68
- }
69
-
70
- /**
71
- * Set cache entry
72
- */
73
- setCache(key: string, result: ScanResult): void {
74
- this.cache.set(key, {
75
- result,
76
- cachedAt: Date.now(),
77
- });
78
- }
79
-
80
- /**
81
- * Clean up old jobs and cache entries
82
- */
83
- cleanup(): void {
84
- const now = Date.now();
85
-
86
- // Clean old jobs
87
- for (const [id, job] of this.jobs.entries()) {
88
- if (now - job.createdAt > JOB_TTL_MS) {
89
- this.jobs.delete(id);
90
- }
91
- }
92
-
93
- // Clean expired cache
94
- for (const [key, entry] of this.cache.entries()) {
95
- if (now - entry.cachedAt > CACHE_TTL_MS) {
96
- this.cache.delete(key);
97
- }
98
- }
99
- }
100
-
101
- /**
102
- * Get store stats for monitoring
103
- */
104
- stats(): { jobs: number; cache: number } {
105
- return {
106
- jobs: this.jobs.size,
107
- cache: this.cache.size,
108
- };
109
- }
110
- }
111
-
112
- // Singleton instance
113
- export const scanJobStore = new ScanJobStore();
114
-
115
- // Periodic cleanup every 5 minutes
116
- // Use .unref() to prevent keeping the process alive
117
- setInterval(
118
- () => {
119
- scanJobStore.cleanup();
120
- },
121
- 5 * 60 * 1000,
122
- ).unref();
@@ -1,25 +0,0 @@
1
- import type { ScanJob, ScanResult } from '../scan-worker';
2
-
3
- /**
4
- * Interface for scan job storage
5
- * Allows swapping in-memory store for Redis/KV in production
6
- */
7
- export interface JobStore {
8
- /** Get a job by ID */
9
- get(jobId: string): ScanJob | undefined | Promise<ScanJob | undefined>;
10
-
11
- /** Set/update a job */
12
- set(jobId: string, job: ScanJob): void | Promise<void>;
13
-
14
- /** Get cached scan result */
15
- getFromCache(key: string): ScanResult | undefined | Promise<ScanResult | undefined>;
16
-
17
- /** Cache a scan result */
18
- setCache(key: string, result: ScanResult): void | Promise<void>;
19
-
20
- /** Cleanup expired entries */
21
- cleanup(): void | Promise<void>;
22
-
23
- /** Get store statistics */
24
- stats(): { jobs: number; cache: number } | Promise<{ jobs: number; cache: number }>;
25
- }