cloneproof 0.1.0

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/dist/cli.js ADDED
@@ -0,0 +1,1052 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createRequire } from "module";
5
+ import { resolve as resolve2 } from "path";
6
+ import { Command, InvalidArgumentError } from "commander";
7
+
8
+ // src/core/runCloneproof.ts
9
+ import { mkdir as mkdir2 } from "fs/promises";
10
+
11
+ // src/detectors/detectProject.ts
12
+ import { join as join7 } from "path";
13
+
14
+ // src/utils/fs.ts
15
+ import { constants } from "fs";
16
+ import { access, cp, mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
17
+ import { basename, dirname, join, relative } from "path";
18
+ var COPY_EXCLUDE_NAMES = /* @__PURE__ */ new Set([
19
+ ".git",
20
+ "node_modules",
21
+ "dist",
22
+ "coverage",
23
+ ".next",
24
+ ".nuxt",
25
+ ".turbo",
26
+ ".cache",
27
+ ".pytest_cache",
28
+ "target",
29
+ "__pycache__"
30
+ ]);
31
+ async function pathExists(path) {
32
+ try {
33
+ await access(path, constants.F_OK);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+ async function readTextIfExists(path) {
40
+ if (!await pathExists(path)) {
41
+ return void 0;
42
+ }
43
+ return readFile(path, "utf8");
44
+ }
45
+ async function readJsonIfExists(path) {
46
+ const text = await readTextIfExists(path);
47
+ if (!text) {
48
+ return void 0;
49
+ }
50
+ return JSON.parse(text);
51
+ }
52
+ async function copyFreshWorkspace(source, destination) {
53
+ await mkdir(destination, { recursive: true });
54
+ await cp(source, destination, {
55
+ recursive: true,
56
+ force: true,
57
+ filter: (entry) => {
58
+ const name = basename(entry);
59
+ if ((name === ".env" || name.startsWith(".env.")) && name !== ".env.example") {
60
+ return false;
61
+ }
62
+ if (COPY_EXCLUDE_NAMES.has(name)) {
63
+ return false;
64
+ }
65
+ const rel = relative(source, entry).replace(/\\/g, "/");
66
+ if (rel === "") {
67
+ return true;
68
+ }
69
+ return !rel.startsWith(".git/") && !rel.includes("/node_modules/");
70
+ }
71
+ });
72
+ }
73
+ async function listFilesRecursive(root, options) {
74
+ const extensions = options?.extensions;
75
+ const maxFiles = options?.maxFiles ?? 2e3;
76
+ const excludeDirectories = /* @__PURE__ */ new Set([
77
+ ".git",
78
+ "node_modules",
79
+ "dist",
80
+ "coverage",
81
+ ".next",
82
+ ".nuxt",
83
+ ".turbo",
84
+ ".cache",
85
+ ".pytest_cache",
86
+ "target",
87
+ "__pycache__",
88
+ ...options?.excludeDirectories ?? []
89
+ ]);
90
+ const files = [];
91
+ async function walk(directory) {
92
+ if (files.length >= maxFiles) {
93
+ return;
94
+ }
95
+ const entries = await readdir(directory, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ if (files.length >= maxFiles) {
98
+ return;
99
+ }
100
+ const fullPath = join(directory, entry.name);
101
+ if (entry.isDirectory()) {
102
+ if (!excludeDirectories.has(entry.name)) {
103
+ await walk(fullPath);
104
+ }
105
+ continue;
106
+ }
107
+ if (!entry.isFile()) {
108
+ continue;
109
+ }
110
+ if (!extensions || extensions.some((extension) => entry.name.endsWith(extension))) {
111
+ files.push(fullPath);
112
+ }
113
+ }
114
+ }
115
+ const rootStat = await stat(root);
116
+ if (rootStat.isDirectory()) {
117
+ await walk(root);
118
+ }
119
+ return files;
120
+ }
121
+
122
+ // src/detectors/env.ts
123
+ import { basename as basename2, join as join2 } from "path";
124
+ var SOURCE_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"];
125
+ var IGNORED_ENV_NAMES = /* @__PURE__ */ new Set([
126
+ "CI",
127
+ "NODE_ENV",
128
+ "PATH",
129
+ "HOME",
130
+ "PWD",
131
+ "SHELL",
132
+ "TEMP",
133
+ "TMP",
134
+ "USER",
135
+ "USERNAME"
136
+ ]);
137
+ function isTestFile(path) {
138
+ const normalized = path.replace(/\\/g, "/");
139
+ return /(^|\/)(test|tests|__tests__)\//.test(normalized) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(normalized);
140
+ }
141
+ function extractEnvKeysFromExample(content) {
142
+ if (!content) {
143
+ return [];
144
+ }
145
+ const keys = /* @__PURE__ */ new Set();
146
+ for (const line of content.split(/\r?\n/)) {
147
+ const trimmed = line.trim();
148
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
149
+ continue;
150
+ }
151
+ const key = trimmed.split("=")[0]?.trim();
152
+ if (key && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
153
+ keys.add(key);
154
+ }
155
+ }
156
+ return [...keys].sort();
157
+ }
158
+ function extractEnvVarsFromSource(content) {
159
+ const keys = /* @__PURE__ */ new Set();
160
+ const dotAccess = /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
161
+ const bracketAccess = /process\.env\[['"]([A-Za-z_][A-Za-z0-9_]*)['"]\]/g;
162
+ for (const regex of [dotAccess, bracketAccess]) {
163
+ for (const match of content.matchAll(regex)) {
164
+ const key = match[1];
165
+ if (key && !IGNORED_ENV_NAMES.has(key) && !key.startsWith("npm_")) {
166
+ keys.add(key);
167
+ }
168
+ }
169
+ }
170
+ return [...keys];
171
+ }
172
+ async function detectEnvWarnings(cwd) {
173
+ const files = await listFilesRecursive(cwd, {
174
+ extensions: SOURCE_EXTENSIONS,
175
+ maxFiles: 2e3
176
+ });
177
+ const used = /* @__PURE__ */ new Set();
178
+ for (const file of files) {
179
+ if (basename2(file).endsWith(".d.ts") || isTestFile(file)) {
180
+ continue;
181
+ }
182
+ const content = await readTextIfExists(file);
183
+ if (!content) {
184
+ continue;
185
+ }
186
+ for (const key of extractEnvVarsFromSource(content)) {
187
+ used.add(key);
188
+ }
189
+ }
190
+ const envExample = await readTextIfExists(join2(cwd, ".env.example"));
191
+ const documented = new Set(extractEnvKeysFromExample(envExample));
192
+ const missing = [...used].filter((key) => !documented.has(key)).sort();
193
+ return {
194
+ usedEnvVars: [...used].sort(),
195
+ documentedEnvVars: [...documented].sort(),
196
+ warnings: missing.map((key) => ({
197
+ code: "MISSING_ENV_EXAMPLE",
198
+ message: `${key} is used but missing from .env.example`
199
+ })),
200
+ suggestions: missing.map((key) => `Add ${key} to .env.example`)
201
+ };
202
+ }
203
+
204
+ // src/detectors/go.ts
205
+ import { join as join3 } from "path";
206
+ async function detectGoProject(cwd) {
207
+ const hasGoMod = await pathExists(join3(cwd, "go.mod"));
208
+ if (!hasGoMod) {
209
+ return void 0;
210
+ }
211
+ return {
212
+ type: "go",
213
+ runtimePinned: true
214
+ };
215
+ }
216
+
217
+ // src/detectors/node.ts
218
+ import { join as join4 } from "path";
219
+ function parsePackageManagerField(value) {
220
+ if (!value) {
221
+ return void 0;
222
+ }
223
+ const name = value.split("@")[0];
224
+ if (name === "npm" || name === "pnpm" || name === "yarn") {
225
+ return name;
226
+ }
227
+ return void 0;
228
+ }
229
+ function isYarnBerry(packageManagerField, hasYarnRc) {
230
+ if (hasYarnRc) {
231
+ return true;
232
+ }
233
+ const versionMatch = packageManagerField?.match(/^yarn@(\d+)/);
234
+ if (!versionMatch) {
235
+ return false;
236
+ }
237
+ return Number.parseInt(versionMatch[1] ?? "1", 10) >= 2;
238
+ }
239
+ function scriptCommand(packageManager, script) {
240
+ if (packageManager === "yarn") {
241
+ return {
242
+ command: "yarn",
243
+ args: [script],
244
+ label: `yarn ${script}`
245
+ };
246
+ }
247
+ return {
248
+ command: packageManager,
249
+ args: ["run", script],
250
+ label: `${packageManager} run ${script}`
251
+ };
252
+ }
253
+ async function detectNodeProject(cwd) {
254
+ const packageJsonPath = join4(cwd, "package.json");
255
+ const packageJson2 = await readJsonIfExists(packageJsonPath);
256
+ if (!packageJson2) {
257
+ return void 0;
258
+ }
259
+ const hasPnpmLock = await pathExists(join4(cwd, "pnpm-lock.yaml"));
260
+ const hasYarnLock = await pathExists(join4(cwd, "yarn.lock"));
261
+ const hasPackageLock = await pathExists(join4(cwd, "package-lock.json"));
262
+ const hasYarnRc = await pathExists(join4(cwd, ".yarnrc.yml"));
263
+ let packageManager = parsePackageManagerField(packageJson2.packageManager);
264
+ let packageManagerSource = "packageManager";
265
+ if (!packageManager) {
266
+ packageManagerSource = "lockfile";
267
+ if (hasPnpmLock) {
268
+ packageManager = "pnpm";
269
+ } else if (hasYarnLock) {
270
+ packageManager = "yarn";
271
+ } else if (hasPackageLock) {
272
+ packageManager = "npm";
273
+ } else {
274
+ packageManagerSource = "default";
275
+ packageManager = "npm";
276
+ }
277
+ }
278
+ const yarnBerry = packageManager === "yarn" && isYarnBerry(packageJson2.packageManager, hasYarnRc);
279
+ const installCommands = [];
280
+ if (packageManager === "pnpm") {
281
+ installCommands.push({ command: "corepack", args: ["enable"], label: "corepack enable" });
282
+ installCommands.push({ command: "pnpm", args: ["install", "--frozen-lockfile"], label: "pnpm install --frozen-lockfile" });
283
+ } else if (packageManager === "yarn") {
284
+ installCommands.push({ command: "corepack", args: ["enable"], label: "corepack enable" });
285
+ installCommands.push({
286
+ command: "yarn",
287
+ args: yarnBerry ? ["install", "--immutable"] : ["install", "--frozen-lockfile"],
288
+ label: yarnBerry ? "yarn install --immutable" : "yarn install --frozen-lockfile"
289
+ });
290
+ } else {
291
+ installCommands.push(hasPackageLock ? { command: "npm", args: ["ci"], label: "npm ci" } : { command: "npm", args: ["install"], label: "npm install" });
292
+ }
293
+ const scripts = packageJson2.scripts ?? {};
294
+ const usefulScripts = ["dev", "lint", "typecheck"].filter((script) => script in scripts);
295
+ const runtimePinned = Boolean(
296
+ packageJson2.engines?.node || await pathExists(join4(cwd, ".nvmrc")) || await pathExists(join4(cwd, ".node-version"))
297
+ );
298
+ return {
299
+ type: "node",
300
+ packageJson: packageJson2,
301
+ packageManager,
302
+ packageManagerSource,
303
+ hasPackageLock,
304
+ hasPnpmLock,
305
+ hasYarnLock,
306
+ yarnBerry,
307
+ scripts,
308
+ usefulScripts,
309
+ runtimePinned,
310
+ installCommands,
311
+ buildCommand: "build" in scripts ? scriptCommand(packageManager, "build") : void 0,
312
+ testCommand: "test" in scripts ? scriptCommand(packageManager, "test") : void 0
313
+ };
314
+ }
315
+
316
+ // src/detectors/python.ts
317
+ import { join as join5 } from "path";
318
+ async function pyprojectPinsPython(cwd) {
319
+ const pyproject = await readTextIfExists(join5(cwd, "pyproject.toml"));
320
+ return Boolean(pyproject && /requires-python\s*=/.test(pyproject));
321
+ }
322
+ async function detectPythonProject(cwd) {
323
+ const hasPyproject = await pathExists(join5(cwd, "pyproject.toml"));
324
+ const hasRequirements = await pathExists(join5(cwd, "requirements.txt"));
325
+ const hasPipfile = await pathExists(join5(cwd, "Pipfile"));
326
+ if (!hasPyproject && !hasRequirements && !hasPipfile) {
327
+ return void 0;
328
+ }
329
+ const hasTests = Boolean(
330
+ await pathExists(join5(cwd, "tests")) || await pathExists(join5(cwd, "pytest.ini")) || await pathExists(join5(cwd, "tox.ini")) || await pathExists(join5(cwd, "setup.cfg"))
331
+ );
332
+ const runtimePinned = Boolean(
333
+ await pathExists(join5(cwd, ".python-version")) || await pyprojectPinsPython(cwd)
334
+ );
335
+ const installCommand = hasRequirements ? { command: "python", args: ["-m", "pip", "install", "-r", "requirements.txt"], label: "python -m pip install -r requirements.txt" } : hasPyproject ? { command: "python", args: ["-m", "pip", "install", "."], label: "python -m pip install ." } : void 0;
336
+ return {
337
+ type: "python",
338
+ hasPyproject,
339
+ hasRequirements,
340
+ hasPipfile,
341
+ hasTests,
342
+ runtimePinned,
343
+ installCommand
344
+ };
345
+ }
346
+
347
+ // src/detectors/rust.ts
348
+ import { join as join6 } from "path";
349
+ async function detectRustProject(cwd) {
350
+ const hasCargoToml = await pathExists(join6(cwd, "Cargo.toml"));
351
+ if (!hasCargoToml) {
352
+ return void 0;
353
+ }
354
+ return {
355
+ type: "rust",
356
+ runtimePinned: Boolean(
357
+ await pathExists(join6(cwd, "rust-toolchain.toml")) || await pathExists(join6(cwd, "rust-toolchain"))
358
+ )
359
+ };
360
+ }
361
+
362
+ // src/detectors/detectProject.ts
363
+ async function detectDocker(cwd) {
364
+ const candidates = [
365
+ "docker-compose.yml",
366
+ "docker-compose.yaml",
367
+ "compose.yml",
368
+ "compose.yaml",
369
+ "Dockerfile"
370
+ ];
371
+ const files = [];
372
+ for (const candidate of candidates) {
373
+ if (await pathExists(join7(cwd, candidate))) {
374
+ files.push(candidate);
375
+ }
376
+ }
377
+ return { files };
378
+ }
379
+ async function hasReadme(cwd) {
380
+ const candidates = ["README.md", "README", "README.txt", "Readme.md"];
381
+ for (const candidate of candidates) {
382
+ if (await pathExists(join7(cwd, candidate))) {
383
+ return true;
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+ async function detectProject(cwd) {
389
+ const [node, python, go, rust, docker, readmeExists] = await Promise.all([
390
+ detectNodeProject(cwd),
391
+ detectPythonProject(cwd),
392
+ detectGoProject(cwd),
393
+ detectRustProject(cwd),
394
+ detectDocker(cwd),
395
+ hasReadme(cwd)
396
+ ]);
397
+ const projectTypes = [];
398
+ if (node) {
399
+ projectTypes.push("node");
400
+ }
401
+ if (python) {
402
+ projectTypes.push("python");
403
+ }
404
+ if (go) {
405
+ projectTypes.push("go");
406
+ }
407
+ if (rust) {
408
+ projectTypes.push("rust");
409
+ }
410
+ const env = node ? await detectEnvWarnings(cwd) : {
411
+ usedEnvVars: [],
412
+ documentedEnvVars: [],
413
+ warnings: [],
414
+ suggestions: []
415
+ };
416
+ const warnings = [];
417
+ const suggestions = [];
418
+ if (!readmeExists) {
419
+ warnings.push({
420
+ code: "MISSING_README",
421
+ message: "README is missing"
422
+ });
423
+ suggestions.push("Add a README with first-time setup instructions");
424
+ }
425
+ if (projectTypes.length === 0) {
426
+ warnings.push({
427
+ code: "UNSUPPORTED_PROJECT_TYPE",
428
+ message: "No supported project type was detected"
429
+ });
430
+ suggestions.push("Add package metadata or setup instructions for a supported ecosystem");
431
+ }
432
+ if (node && !node.runtimePinned) {
433
+ warnings.push({
434
+ code: "MISSING_RUNTIME_PIN",
435
+ message: "Node version is not pinned"
436
+ });
437
+ suggestions.push("Pin Node version with .nvmrc, .node-version, or package.json engines.node");
438
+ }
439
+ if (python && !python.runtimePinned) {
440
+ warnings.push({
441
+ code: "MISSING_RUNTIME_PIN",
442
+ message: "Python version is not pinned"
443
+ });
444
+ suggestions.push("Pin Python version with .python-version or pyproject.toml requires-python");
445
+ }
446
+ if (rust && !rust.runtimePinned) {
447
+ warnings.push({
448
+ code: "MISSING_RUNTIME_PIN",
449
+ message: "Rust toolchain version is not pinned"
450
+ });
451
+ suggestions.push("Pin Rust with rust-toolchain.toml");
452
+ }
453
+ if (docker.files.length > 0) {
454
+ warnings.push({
455
+ code: "DOCKER_DETECTED",
456
+ message: `Docker files detected: ${docker.files.join(", ")}. Services are not started in Cloneproof v0.1.0`
457
+ });
458
+ suggestions.push("Document any required Docker services in setup instructions");
459
+ }
460
+ warnings.push(...env.warnings);
461
+ suggestions.push(...env.suggestions);
462
+ return {
463
+ projectTypes,
464
+ node,
465
+ python,
466
+ go,
467
+ rust,
468
+ docker,
469
+ readmeExists,
470
+ env,
471
+ warnings,
472
+ suggestions: [...new Set(suggestions)]
473
+ };
474
+ }
475
+
476
+ // src/core/commandRunner.ts
477
+ import { execa } from "execa";
478
+ var LOG_LIMIT = 8e3;
479
+ function redactSecrets(input) {
480
+ return input.replace(/\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g, "[REDACTED]").replace(/\b(sk-[A-Za-z0-9_-]{20,})\b/g, "[REDACTED]").replace(/\b((?:token|secret|password|passwd|api[_-]?key|authorization)\s*[:=]\s*)([^\s"'`]+)/gi, "$1[REDACTED]").replace(/\b(Bearer\s+)([A-Za-z0-9._~+/-]+=*)/gi, "$1[REDACTED]");
481
+ }
482
+ function truncateLog(input) {
483
+ if (input.length <= LOG_LIMIT) {
484
+ return input;
485
+ }
486
+ const omitted = input.length - LOG_LIMIT;
487
+ return `${input.slice(0, LOG_LIMIT)}
488
+ ...[truncated ${omitted} characters]`;
489
+ }
490
+ function formatCommand(command, args = []) {
491
+ const quote = (part) => {
492
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(part)) {
493
+ return part;
494
+ }
495
+ return JSON.stringify(part);
496
+ };
497
+ return redactSecrets([command, ...args].map(quote).join(" "));
498
+ }
499
+ function logValueToString(value) {
500
+ if (value === void 0 || value === null) {
501
+ return void 0;
502
+ }
503
+ if (typeof value === "string") {
504
+ return value;
505
+ }
506
+ if (value instanceof Uint8Array) {
507
+ return Buffer.from(value).toString("utf8");
508
+ }
509
+ if (Array.isArray(value)) {
510
+ return value.map((item) => String(item)).join("\n");
511
+ }
512
+ return String(value);
513
+ }
514
+ function sanitizeLog(value) {
515
+ const text = logValueToString(value);
516
+ if (!text) {
517
+ return void 0;
518
+ }
519
+ return truncateLog(redactSecrets(text));
520
+ }
521
+ async function runCommand(options) {
522
+ const args = options.args ?? [];
523
+ const commandText = formatCommand(options.command, args);
524
+ const startedAt = Date.now();
525
+ if (options.verbose) {
526
+ process.stderr.write(`$ ${commandText}
527
+ `);
528
+ }
529
+ const execaOptions = {
530
+ cwd: options.cwd,
531
+ reject: false,
532
+ timeout: options.timeoutSeconds * 1e3,
533
+ env: {
534
+ CI: "true"
535
+ }
536
+ };
537
+ try {
538
+ const result = await execa(options.command, args, execaOptions);
539
+ const durationMs = Date.now() - startedAt;
540
+ const failed = result.exitCode !== 0;
541
+ const summary = failed ? `${commandText} failed with exit code ${result.exitCode}` : `${commandText} completed successfully`;
542
+ if (options.verbose && result.stdout) {
543
+ process.stderr.write(`${sanitizeLog(result.stdout) ?? ""}
544
+ `);
545
+ }
546
+ if (options.verbose && result.stderr) {
547
+ process.stderr.write(`${sanitizeLog(result.stderr) ?? ""}
548
+ `);
549
+ }
550
+ return {
551
+ exitCode: result.exitCode,
552
+ timedOut: false,
553
+ step: {
554
+ name: options.stepName,
555
+ status: failed ? "failed" : "passed",
556
+ durationMs,
557
+ command: commandText,
558
+ summary,
559
+ stdout: sanitizeLog(result.stdout),
560
+ stderr: sanitizeLog(result.stderr)
561
+ }
562
+ };
563
+ } catch (error) {
564
+ const durationMs = Date.now() - startedAt;
565
+ const maybeError = error;
566
+ const timedOut = Boolean(maybeError.timedOut);
567
+ const summary = timedOut ? `${commandText} timed out after ${options.timeoutSeconds} seconds` : `${commandText} failed: ${maybeError.message ?? "unknown error"}`;
568
+ return {
569
+ exitCode: maybeError.exitCode,
570
+ timedOut,
571
+ step: {
572
+ name: options.stepName,
573
+ status: "failed",
574
+ durationMs,
575
+ command: commandText,
576
+ summary,
577
+ stdout: sanitizeLog(maybeError.stdout),
578
+ stderr: sanitizeLog(maybeError.stderr ?? maybeError.message)
579
+ }
580
+ };
581
+ }
582
+ }
583
+
584
+ // src/utils/git.ts
585
+ async function cloneGitRepository(options) {
586
+ const result = await runCommand({
587
+ cwd: options.destination,
588
+ command: "git",
589
+ args: ["clone", "--depth", "1", options.url, "."],
590
+ timeoutSeconds: options.timeoutSeconds,
591
+ stepName: "clone",
592
+ verbose: options.verbose
593
+ });
594
+ return result.step;
595
+ }
596
+
597
+ // src/utils/path.ts
598
+ import { isAbsolute, resolve } from "path";
599
+ var GITHUB_SHORTHAND_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
600
+ function isGitHubHttpsUrl(target) {
601
+ return /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?\/?$/.test(target);
602
+ }
603
+ async function resolveTarget(target) {
604
+ if (isGitHubHttpsUrl(target)) {
605
+ return {
606
+ kind: "remote",
607
+ display: target,
608
+ source: target
609
+ };
610
+ }
611
+ const localPath = isAbsolute(target) ? target : resolve(process.cwd(), target);
612
+ if (await pathExists(localPath)) {
613
+ return {
614
+ kind: "local",
615
+ display: target,
616
+ source: localPath
617
+ };
618
+ }
619
+ if (GITHUB_SHORTHAND_RE.test(target)) {
620
+ return {
621
+ kind: "remote",
622
+ display: target,
623
+ source: `https://github.com/${target}.git`
624
+ };
625
+ }
626
+ return {
627
+ kind: "local",
628
+ display: target,
629
+ source: localPath
630
+ };
631
+ }
632
+
633
+ // src/core/scoring.ts
634
+ function hasFailedStep(report, name) {
635
+ return report.steps.some((step) => step.name === name && step.status === "failed");
636
+ }
637
+ function hasWarning(report, code) {
638
+ return report.warnings.some((warning) => warning.code === code);
639
+ }
640
+ function calculateScore(report) {
641
+ if (hasFailedStep(report, "clone")) {
642
+ return 0;
643
+ }
644
+ let score = 100;
645
+ if (hasFailedStep(report, "install")) {
646
+ score = Math.min(score, 40);
647
+ }
648
+ if (hasFailedStep(report, "build")) {
649
+ score = Math.min(score, 60);
650
+ }
651
+ if (hasFailedStep(report, "test")) {
652
+ score = Math.min(score, 70);
653
+ }
654
+ if (hasWarning(report, "UNSUPPORTED_PROJECT_TYPE")) {
655
+ score = Math.min(score, 50);
656
+ }
657
+ if (hasWarning(report, "MISSING_README")) {
658
+ score -= 10;
659
+ }
660
+ const missingEnvCount = report.warnings.filter((warning) => warning.code === "MISSING_ENV_EXAMPLE").length;
661
+ score -= Math.min(missingEnvCount * 10, 30);
662
+ if (hasWarning(report, "MISSING_RUNTIME_PIN")) {
663
+ score -= 10;
664
+ }
665
+ return Math.max(0, Math.min(100, score));
666
+ }
667
+
668
+ // src/core/tempWorkspace.ts
669
+ import { mkdtemp, rm } from "fs/promises";
670
+ import { tmpdir } from "os";
671
+ import { join as join8 } from "path";
672
+ async function createTempWorkspace() {
673
+ const root = await mkdtemp(join8(tmpdir(), "cloneproof-"));
674
+ const checkoutPath = join8(root, "repo");
675
+ return {
676
+ root,
677
+ checkoutPath,
678
+ cleanup: async () => {
679
+ await rm(root, { recursive: true, force: true });
680
+ }
681
+ };
682
+ }
683
+
684
+ // src/core/runCloneproof.ts
685
+ var DEFAULT_TIMEOUT_SECONDS = 600;
686
+ function createStep(name, status, startedAt, details) {
687
+ return {
688
+ name,
689
+ status,
690
+ durationMs: Date.now() - startedAt,
691
+ ...details
692
+ };
693
+ }
694
+ function dedupeWarnings(warnings) {
695
+ const seen = /* @__PURE__ */ new Set();
696
+ const result = [];
697
+ for (const warning of warnings) {
698
+ const key = `${warning.code}:${warning.message}`;
699
+ if (!seen.has(key)) {
700
+ seen.add(key);
701
+ result.push(warning);
702
+ }
703
+ }
704
+ return result;
705
+ }
706
+ function finishReport(report) {
707
+ report.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
708
+ report.durationMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt);
709
+ report.warnings = dedupeWarnings(report.warnings);
710
+ report.suggestions = [...new Set(report.suggestions)];
711
+ const failedStep = report.steps.some((step) => step.status === "failed");
712
+ const unsupported = report.warnings.some((warning) => warning.code === "UNSUPPORTED_PROJECT_TYPE");
713
+ report.ok = !failedStep && !unsupported;
714
+ report.score = calculateScore(report);
715
+ return report;
716
+ }
717
+ async function runLogicalStep(options) {
718
+ const startedAt = Date.now();
719
+ const stdout = [];
720
+ const stderr = [];
721
+ const labels = options.commands.map((command) => command.label).join(" && ");
722
+ for (const command of options.commands) {
723
+ const result = await runCommand({
724
+ cwd: options.cwd,
725
+ command: command.command,
726
+ args: command.args,
727
+ timeoutSeconds: options.timeoutSeconds,
728
+ stepName: options.stepName,
729
+ verbose: options.verbose
730
+ });
731
+ if (result.step.stdout) {
732
+ stdout.push(result.step.stdout);
733
+ }
734
+ if (result.step.stderr) {
735
+ stderr.push(result.step.stderr);
736
+ }
737
+ if (result.step.status === "failed") {
738
+ return {
739
+ ...result.step,
740
+ durationMs: Date.now() - startedAt,
741
+ command: labels,
742
+ summary: result.step.summary,
743
+ stdout: stdout.join("\n\n") || void 0,
744
+ stderr: stderr.join("\n\n") || void 0
745
+ };
746
+ }
747
+ }
748
+ return {
749
+ name: options.stepName,
750
+ status: "passed",
751
+ durationMs: Date.now() - startedAt,
752
+ command: labels,
753
+ summary: `${labels} completed successfully`,
754
+ stdout: stdout.join("\n\n") || void 0,
755
+ stderr: stderr.join("\n\n") || void 0
756
+ };
757
+ }
758
+ async function pythonHasPytest(cwd, timeoutSeconds, verbose) {
759
+ const result = await runCommand({
760
+ cwd,
761
+ command: "python",
762
+ args: ["-c", "import pytest"],
763
+ timeoutSeconds,
764
+ stepName: "detect pytest",
765
+ verbose
766
+ });
767
+ return result.step.status === "passed";
768
+ }
769
+ async function runCloneproof(target = ".", options = {}) {
770
+ const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
771
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
772
+ const report = {
773
+ ok: false,
774
+ target,
775
+ startedAt,
776
+ finishedAt: startedAt,
777
+ durationMs: 0,
778
+ score: 0,
779
+ projectTypes: [],
780
+ steps: [],
781
+ warnings: [],
782
+ suggestions: []
783
+ };
784
+ const tempWorkspace = await createTempWorkspace();
785
+ try {
786
+ const resolvedTarget = await resolveTarget(target);
787
+ report.target = resolvedTarget.display;
788
+ if (resolvedTarget.kind === "local") {
789
+ const cloneStartedAt = Date.now();
790
+ try {
791
+ await copyFreshWorkspace(resolvedTarget.source, tempWorkspace.checkoutPath);
792
+ report.steps.push(createStep("clone", "passed", cloneStartedAt, {
793
+ command: `copy ${resolvedTarget.source}`,
794
+ summary: "Local repository copied into a fresh temporary workspace"
795
+ }));
796
+ } catch (error) {
797
+ const message = error instanceof Error ? error.message : String(error);
798
+ report.steps.push(createStep("clone", "failed", cloneStartedAt, {
799
+ command: `copy ${resolvedTarget.source}`,
800
+ summary: message
801
+ }));
802
+ return finishReport(report);
803
+ }
804
+ } else {
805
+ await mkdir2(tempWorkspace.checkoutPath, { recursive: true });
806
+ const cloneStep = await cloneGitRepository({
807
+ url: resolvedTarget.source,
808
+ destination: tempWorkspace.checkoutPath,
809
+ timeoutSeconds,
810
+ verbose: options.verbose
811
+ });
812
+ report.steps.push(cloneStep);
813
+ if (cloneStep.status === "failed") {
814
+ return finishReport(report);
815
+ }
816
+ }
817
+ const detectStartedAt = Date.now();
818
+ const detection = await detectProject(tempWorkspace.checkoutPath);
819
+ report.projectTypes = detection.projectTypes;
820
+ report.packageManager = detection.node?.packageManager;
821
+ report.docker = detection.docker.files.length > 0 ? detection.docker : void 0;
822
+ report.warnings.push(...detection.warnings);
823
+ report.suggestions.push(...detection.suggestions);
824
+ report.steps.push(createStep("detect project", detection.projectTypes.length > 0 ? "passed" : "warned", detectStartedAt, {
825
+ summary: detection.projectTypes.length > 0 ? `Detected ${detection.projectTypes.join(", ")} project` : "No supported project type was detected"
826
+ }));
827
+ const setupStatus = detection.warnings.length > 0 ? "warned" : "passed";
828
+ report.steps.push(createStep("setup checks", setupStatus, Date.now(), {
829
+ summary: setupStatus === "passed" ? "Setup metadata checks passed" : "Setup metadata warnings found"
830
+ }));
831
+ if (detection.projectTypes.length === 0) {
832
+ return finishReport(report);
833
+ }
834
+ if (detection.node) {
835
+ const installStep = await runLogicalStep({
836
+ cwd: tempWorkspace.checkoutPath,
837
+ stepName: "install",
838
+ commands: detection.node.installCommands,
839
+ timeoutSeconds,
840
+ verbose: options.verbose
841
+ });
842
+ report.steps.push(installStep);
843
+ if (installStep.status === "failed") {
844
+ return finishReport(report);
845
+ }
846
+ if (options.skipBuild) {
847
+ report.steps.push(createStep("build", "skipped", Date.now(), { summary: "Build skipped by --skip-build" }));
848
+ } else if (detection.node.buildCommand) {
849
+ const buildStep = await runLogicalStep({
850
+ cwd: tempWorkspace.checkoutPath,
851
+ stepName: "build",
852
+ commands: [detection.node.buildCommand],
853
+ timeoutSeconds,
854
+ verbose: options.verbose
855
+ });
856
+ report.steps.push(buildStep);
857
+ } else {
858
+ report.steps.push(createStep("build", "skipped", Date.now(), { summary: "No build script found" }));
859
+ }
860
+ if (options.skipTests) {
861
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "Tests skipped by --skip-tests" }));
862
+ } else if (detection.node.testCommand) {
863
+ const testStep = await runLogicalStep({
864
+ cwd: tempWorkspace.checkoutPath,
865
+ stepName: "test",
866
+ commands: [detection.node.testCommand],
867
+ timeoutSeconds,
868
+ verbose: options.verbose
869
+ });
870
+ report.steps.push(testStep);
871
+ } else {
872
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "No test script found" }));
873
+ }
874
+ }
875
+ if (detection.python) {
876
+ if (detection.python.installCommand) {
877
+ const installStep = await runLogicalStep({
878
+ cwd: tempWorkspace.checkoutPath,
879
+ stepName: "install",
880
+ commands: [detection.python.installCommand],
881
+ timeoutSeconds,
882
+ verbose: options.verbose
883
+ });
884
+ report.steps.push(installStep);
885
+ if (installStep.status === "failed") {
886
+ return finishReport(report);
887
+ }
888
+ } else {
889
+ report.steps.push(createStep("install", "skipped", Date.now(), {
890
+ summary: "Pipfile detected, but Pipenv installation is not run in Cloneproof v0.1.0"
891
+ }));
892
+ report.warnings.push({
893
+ code: "PYTHON_PIPFILE_STATIC_ONLY",
894
+ message: "Pipfile detected, but Pipenv installation is not run in Cloneproof v0.1.0"
895
+ });
896
+ }
897
+ if (options.skipTests) {
898
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "Tests skipped by --skip-tests" }));
899
+ } else if (detection.python.hasTests) {
900
+ if (!await pythonHasPytest(tempWorkspace.checkoutPath, timeoutSeconds, options.verbose)) {
901
+ report.steps.push(createStep("test", "skipped", Date.now(), {
902
+ summary: "pytest is not installed"
903
+ }));
904
+ report.warnings.push({
905
+ code: "PYTEST_MISSING",
906
+ message: "Python tests were detected, but pytest is not installed"
907
+ });
908
+ report.suggestions.push("Install pytest or document the Python test command");
909
+ } else {
910
+ const testStep = await runLogicalStep({
911
+ cwd: tempWorkspace.checkoutPath,
912
+ stepName: "test",
913
+ commands: [{ command: "python", args: ["-m", "pytest"], label: "python -m pytest" }],
914
+ timeoutSeconds,
915
+ verbose: options.verbose
916
+ });
917
+ report.steps.push(testStep);
918
+ }
919
+ } else {
920
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "No Python tests detected" }));
921
+ }
922
+ }
923
+ if (detection.go) {
924
+ const installStep = await runLogicalStep({
925
+ cwd: tempWorkspace.checkoutPath,
926
+ stepName: "install",
927
+ commands: [{ command: "go", args: ["mod", "download"], label: "go mod download" }],
928
+ timeoutSeconds,
929
+ verbose: options.verbose
930
+ });
931
+ report.steps.push(installStep);
932
+ if (installStep.status === "failed") {
933
+ return finishReport(report);
934
+ }
935
+ if (options.skipTests) {
936
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "Tests skipped by --skip-tests" }));
937
+ } else {
938
+ const testStep = await runLogicalStep({
939
+ cwd: tempWorkspace.checkoutPath,
940
+ stepName: "test",
941
+ commands: [{ command: "go", args: ["test", "./..."], label: "go test ./..." }],
942
+ timeoutSeconds,
943
+ verbose: options.verbose
944
+ });
945
+ report.steps.push(testStep);
946
+ }
947
+ }
948
+ if (detection.rust) {
949
+ if (options.skipTests) {
950
+ report.steps.push(createStep("test", "skipped", Date.now(), { summary: "Tests skipped by --skip-tests" }));
951
+ } else {
952
+ const testStep = await runLogicalStep({
953
+ cwd: tempWorkspace.checkoutPath,
954
+ stepName: "test",
955
+ commands: [{ command: "cargo", args: ["test"], label: "cargo test" }],
956
+ timeoutSeconds,
957
+ verbose: options.verbose
958
+ });
959
+ report.steps.push(testStep);
960
+ }
961
+ }
962
+ return finishReport(report);
963
+ } finally {
964
+ await tempWorkspace.cleanup();
965
+ }
966
+ }
967
+
968
+ // src/output/human.ts
969
+ import pc from "picocolors";
970
+ function formatStep(step) {
971
+ if (step.summary && step.status === "failed") {
972
+ return `${step.name}: ${step.summary}`;
973
+ }
974
+ return step.name;
975
+ }
976
+ function section(title, items, formatter) {
977
+ if (items.length === 0) {
978
+ return [];
979
+ }
980
+ return ["", `${title}:`, ...items.map(formatter)];
981
+ }
982
+ function renderHumanReport(report, options = {}) {
983
+ const colors = pc.createColors(options.color !== false);
984
+ const passed = report.steps.filter((step) => step.status === "passed").map(formatStep);
985
+ const failed = report.steps.filter((step) => step.status === "failed").map(formatStep);
986
+ const skipped = report.steps.filter((step) => step.status === "skipped").map(formatStep);
987
+ const warnings = report.warnings.map((warning) => warning.message);
988
+ const lines = [
989
+ colors.bold("Cloneproof report"),
990
+ "",
991
+ `Target: ${report.target}`,
992
+ `Fresh clone: ${report.ok ? colors.green("passed") : colors.red("failed")}`,
993
+ `Score: ${report.score}/100`,
994
+ ...section("Passed", passed, (item) => `- ${colors.green(item)}`),
995
+ ...section("Failed", failed, (item) => `- ${colors.red(item)}`),
996
+ ...section("Skipped", skipped, (item) => `- ${colors.dim(item)}`),
997
+ ...section("Warnings", warnings, (item) => `- ${colors.yellow(item)}`),
998
+ ...section("Suggested fixes", report.suggestions, (item, index) => `${index + 1}. ${item}`)
999
+ ];
1000
+ return lines.join("\n");
1001
+ }
1002
+
1003
+ // src/output/json.ts
1004
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1005
+ import { dirname as dirname2 } from "path";
1006
+ function renderJsonReport(report) {
1007
+ return JSON.stringify(report, null, 2);
1008
+ }
1009
+ async function writeJsonReport(report, path) {
1010
+ await mkdir3(dirname2(path), { recursive: true });
1011
+ await writeFile2(path, `${renderJsonReport(report)}
1012
+ `, "utf8");
1013
+ }
1014
+
1015
+ // src/cli.ts
1016
+ var require2 = createRequire(import.meta.url);
1017
+ var packageJson = require2("../package.json");
1018
+ function parseTimeout(value) {
1019
+ const parsed = Number.parseInt(value, 10);
1020
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1021
+ throw new InvalidArgumentError("timeout must be a positive number of seconds");
1022
+ }
1023
+ return parsed;
1024
+ }
1025
+ var program = new Command();
1026
+ program.name("cloneproof").description("CI for first-time setup. Test your repo like a first-time contributor.").version(packageJson.version);
1027
+ program.command("run").argument("[target]", "local path, GitHub HTTPS URL, or org/repo shorthand", ".").option("--json", "print the structured JSON report").option("--write-report <path>", "write the JSON report to a file").option("--timeout <seconds>", "per-command timeout in seconds", parseTimeout, 600).option("--soft-fail", "always exit 0 while still reporting failures").option("--skip-tests", "do not run detected test commands").option("--skip-build", "do not run detected build commands").option("--verbose", "print command progress to stderr").option("--no-color", "disable colored terminal output").action(async (target, options) => {
1028
+ const report = await runCloneproof(target, {
1029
+ timeoutSeconds: options.timeout,
1030
+ skipTests: Boolean(options.skipTests),
1031
+ skipBuild: Boolean(options.skipBuild),
1032
+ verbose: Boolean(options.verbose)
1033
+ });
1034
+ if (options.writeReport) {
1035
+ await writeJsonReport(report, resolve2(process.cwd(), options.writeReport));
1036
+ }
1037
+ if (options.json) {
1038
+ process.stdout.write(`${renderJsonReport(report)}
1039
+ `);
1040
+ } else {
1041
+ process.stdout.write(`${renderHumanReport(report, { color: options.color !== false })}
1042
+ `);
1043
+ }
1044
+ process.exitCode = options.softFail || report.ok ? 0 : 1;
1045
+ });
1046
+ program.parseAsync().catch((error) => {
1047
+ const message = error instanceof Error ? error.message : String(error);
1048
+ process.stderr.write(`cloneproof: ${message}
1049
+ `);
1050
+ process.exitCode = 1;
1051
+ });
1052
+ //# sourceMappingURL=cli.js.map