@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/.vercelignore +2 -0
- package/CHANGELOG.md +28 -0
- package/api/index.ts +894 -83
- package/package.json +8 -7
- package/src/index.ts +9 -7
- package/src/routes/plan.ts +81 -0
- package/tsconfig.json +2 -2
- package/vercel.json +3 -5
- package/api/scan/detect.ts +0 -118
- package/api/scan-stream.ts +0 -460
- package/api/scan.ts +0 -63
- package/src/routes/scan.ts +0 -240
- package/src/sandbox-runner.ts +0 -82
- package/src/scan-worker.ts +0 -122
- package/src/stores/job-store.interface.ts +0 -25
package/src/routes/scan.ts
DELETED
|
@@ -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
|
-
}
|
package/src/sandbox-runner.ts
DELETED
|
@@ -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
|
-
}
|
package/src/scan-worker.ts
DELETED
|
@@ -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
|
-
}
|