opstruth 0.1.1
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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/opstruth.js +7 -0
- package/examples/routes.json +12 -0
- package/fixtures/next-app/app/page.tsx +3 -0
- package/fixtures/next-app/next.config.ts +5 -0
- package/fixtures/next-app/package.json +19 -0
- package/fixtures/next-app/tsconfig.json +6 -0
- package/fixtures/non-git-folder/README.md +3 -0
- package/fixtures/non-git-folder/notes.txt +1 -0
- package/fixtures/plain-node-app/package.json +8 -0
- package/fixtures/plain-node-app/src/index.js +3 -0
- package/fixtures/risky-secret-app/package.json +8 -0
- package/fixtures/risky-secret-app/src/config.js +3 -0
- package/fixtures/supabase-cloudflare-app/package.json +16 -0
- package/fixtures/supabase-cloudflare-app/src/supabaseClient.ts +7 -0
- package/fixtures/supabase-cloudflare-app/src/worker.ts +5 -0
- package/fixtures/supabase-cloudflare-app/supabase/migrations/001_init.sql +11 -0
- package/fixtures/supabase-cloudflare-app/wrangler.toml +6 -0
- package/fixtures/vite-react-app/package.json +20 -0
- package/fixtures/vite-react-app/src/App.tsx +3 -0
- package/fixtures/vite-react-app/tsconfig.json +6 -0
- package/fixtures/vite-react-app/vite.config.ts +6 -0
- package/package.json +53 -0
- package/scripts/demo-fixtures.sh +35 -0
- package/scripts/demo-run.sh +32 -0
- package/src/cli.js +254 -0
- package/src/commands/cloudflare.js +51 -0
- package/src/commands/evidence.js +38 -0
- package/src/commands/local.js +43 -0
- package/src/commands/probes.js +68 -0
- package/src/commands/quality.js +66 -0
- package/src/commands/repo.js +30 -0
- package/src/commands/routes.js +49 -0
- package/src/commands/secrets.js +33 -0
- package/src/commands/supabase.js +39 -0
- package/src/lib/boundary.js +74 -0
- package/src/lib/config.js +31 -0
- package/src/lib/detect.js +111 -0
- package/src/lib/exec.js +28 -0
- package/src/lib/fs.js +36 -0
- package/src/lib/git.js +27 -0
- package/src/lib/http.js +14 -0
- package/src/lib/markdown.js +202 -0
- package/src/lib/probes.js +489 -0
- package/src/lib/redact.js +27 -0
- package/src/lib/result.js +63 -0
- package/src/lib/scan.js +53 -0
- package/src/orchestrator.js +106 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
3
|
+
import { readWranglerConfig } from '../lib/config.js';
|
|
4
|
+
import { detectPackageScripts } from '../lib/detect.js';
|
|
5
|
+
import { pathExists, walkFiles, readText } from '../lib/fs.js';
|
|
6
|
+
import { probeUrl } from '../lib/http.js';
|
|
7
|
+
|
|
8
|
+
function extractTomlValue(text, key) {
|
|
9
|
+
const line = text.split(/\r?\n/).find((item) => item.trim().startsWith(key + ' =') || item.trim().startsWith(key + '='));
|
|
10
|
+
if (!line) return null;
|
|
11
|
+
return line.split('=').slice(1).join('=').trim().replace(/^['"]|['"]$/g, '') || null;
|
|
12
|
+
}
|
|
13
|
+
export async function runCloudflare({ cwd = process.cwd(), url, strict = false } = {}) {
|
|
14
|
+
const wrangler = await readWranglerConfig(cwd);
|
|
15
|
+
const scripts = await detectPackageScripts(cwd);
|
|
16
|
+
const warnings = [];
|
|
17
|
+
const verified = [];
|
|
18
|
+
const data = { configFile: wrangler?.file || null, workerName: null, main: null, compatibilityDate: null, routes: [], vars: [], deployScripts: [], workflows: [] };
|
|
19
|
+
if (!wrangler) warnings.push('No wrangler config found');
|
|
20
|
+
else {
|
|
21
|
+
verified.push('Cloudflare config detected: ' + wrangler.file);
|
|
22
|
+
if (wrangler.data) {
|
|
23
|
+
data.workerName = wrangler.data.name || null;
|
|
24
|
+
data.main = wrangler.data.main || null;
|
|
25
|
+
data.compatibilityDate = wrangler.data.compatibility_date || null;
|
|
26
|
+
data.routes = wrangler.data.routes || [];
|
|
27
|
+
data.vars = Object.keys(wrangler.data.vars || {});
|
|
28
|
+
} else {
|
|
29
|
+
data.workerName = extractTomlValue(wrangler.text, 'name');
|
|
30
|
+
data.main = extractTomlValue(wrangler.text, 'main');
|
|
31
|
+
data.compatibilityDate = extractTomlValue(wrangler.text, 'compatibility_date');
|
|
32
|
+
data.vars = [...wrangler.text.matchAll(/^\s*([A-Z0-9_]+)\s*=/gm)].map((match) => match[1]);
|
|
33
|
+
data.routes = [...wrangler.text.matchAll(/route\s*=\s*["']([^"']+)/g)].map((match) => match[1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const [name, script] of Object.entries(scripts)) if (/wrangler\s+deploy/.test(script)) data.deployScripts.push(name);
|
|
37
|
+
const workflowRoot = path.join(cwd, '.github/workflows');
|
|
38
|
+
if (await pathExists(workflowRoot)) {
|
|
39
|
+
for (const file of await walkFiles(workflowRoot)) {
|
|
40
|
+
const text = await readText(file.full);
|
|
41
|
+
if (/wrangler|cloudflare/i.test(text)) data.workflows.push(file.rel);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const checks = [{ name: 'cloudflare config inspection', status: wrangler ? 'pass' : 'warn' }];
|
|
45
|
+
if (url) {
|
|
46
|
+
const probe = await probeUrl(url, { method: 'HEAD' });
|
|
47
|
+
checks.push({ name: 'cloudflare optional route probe', status: probe.status ? 'pass' : 'fail', message: `${url} status=${probe.status || probe.error}`, data: probe });
|
|
48
|
+
if (!probe.status) warnings.push('Optional Cloudflare route probe failed');
|
|
49
|
+
}
|
|
50
|
+
return finalizeStatus(createResult('cloudflare', warnings.length ? 'warn' : 'pass', { summary: 'Cloudflare local configuration truth check completed. No deploy commands were run.', verified, warnings, checks, data, notVerified: ['No Cloudflare deploy was executed', 'Cloudflare dashboard state was not checked'], nextSafeStep: 'Compare declared routes/domains with a read-only route smoke check.' }), { strict });
|
|
51
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
3
|
+
import { readText, writeFileSafe, pathExists } from '../lib/fs.js';
|
|
4
|
+
import { getGitInfo } from '../lib/git.js';
|
|
5
|
+
import { evidenceMarkdown } from '../lib/markdown.js';
|
|
6
|
+
|
|
7
|
+
export async function runEvidence({ cwd = process.cwd(), title = 'opstruth Evidence Pack', phase = 'local proof run', include = [], out = 'evidence/opstruth-report.md', strict = false, aggregate } = {}) {
|
|
8
|
+
const git = await getGitInfo(cwd);
|
|
9
|
+
const included = [];
|
|
10
|
+
for (const item of include) {
|
|
11
|
+
const full = path.isAbsolute(item) ? item : path.join(cwd, item);
|
|
12
|
+
if (await pathExists(full)) included.push({ file: item, content: await readText(full) });
|
|
13
|
+
}
|
|
14
|
+
const commandsRun = aggregate?.checks?.map((check) => check.command).filter(Boolean) || included.map((item) => 'included ' + item.file);
|
|
15
|
+
const checks = aggregate ? aggregate.checks.map((check) => `${check.status}: ${check.name || check.command}`) : included.map((item) => 'included evidence file: ' + item.file);
|
|
16
|
+
const findingEvidence = aggregate?.findings?.flatMap((finding) => [
|
|
17
|
+
`${finding.status}: ${finding.finding}`,
|
|
18
|
+
...(finding.evidence || []).map((item) => ` evidence: ${item}`),
|
|
19
|
+
...(finding.whyItMatters ? [` why it matters: ${finding.whyItMatters}`] : []),
|
|
20
|
+
...(finding.nextSafeStep ? [` next safe step: ${finding.nextSafeStep}`] : [])
|
|
21
|
+
]) || [];
|
|
22
|
+
const risks = aggregate ? [...findingEvidence, ...aggregate.warnings, ...aggregate.failures, ...aggregate.notVerified] : ['Unverified claims remain unless backed by attached command output'];
|
|
23
|
+
const markdown = evidenceMarkdown({
|
|
24
|
+
title,
|
|
25
|
+
status: aggregate?.status || 'not_verified',
|
|
26
|
+
scope: ['Phase: ' + phase, 'Working directory: ' + cwd, 'Git root: ' + (git.root || 'not a git repository')],
|
|
27
|
+
filesChanged: git.changedFiles,
|
|
28
|
+
commandsRun,
|
|
29
|
+
checks,
|
|
30
|
+
liveVerification: aggregate?.verified || [],
|
|
31
|
+
safetyBoundaries: ['Read-only checks only', 'No deploy commands run by opstruth', 'No database mutation commands run by opstruth', 'No OpenAI calls run by opstruth', 'No secrets printed by opstruth'],
|
|
32
|
+
risks,
|
|
33
|
+
nextSafeStep: aggregate?.nextSafeStep || 'Run the narrowest missing read-only check and attach it to this evidence pack.'
|
|
34
|
+
});
|
|
35
|
+
const outputPath = path.isAbsolute(out) ? out : path.join(cwd, out);
|
|
36
|
+
await writeFileSafe(outputPath, markdown);
|
|
37
|
+
return finalizeStatus(createResult('evidence', 'pass', { summary: 'Evidence pack written: ' + outputPath, verified: ['Evidence pack created', 'Safety-sensitive defaults separated from verified facts'], checks: [{ name: 'evidence writer', status: 'pass' }], data: { out: outputPath, included: included.map((item) => item.file), markdown }, nextSafeStep: 'Share the evidence pack with the change or CI artifact.' }), { strict });
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { runCommand } from '../lib/exec.js';
|
|
3
|
+
import { probeUrl } from '../lib/http.js';
|
|
4
|
+
|
|
5
|
+
export async function runLocal({ cwd = process.cwd(), port = [], health = '/', process: processName, service, strict = false } = {}) {
|
|
6
|
+
const ports = Array.isArray(port) ? port : [port].filter(Boolean);
|
|
7
|
+
if (!ports.length && !processName && !service) return createResult('local', 'skipped', { skipped: ['Local runtime checks skipped because no --port, --process, or --service was provided'], notVerified: ['Local runtime liveness'], nextSafeStep: 'Run opstruth local --port 3000 --health /health when the app is running.' });
|
|
8
|
+
const checks = [];
|
|
9
|
+
const warnings = [];
|
|
10
|
+
const verified = [];
|
|
11
|
+
const findings = [];
|
|
12
|
+
for (const item of ports) {
|
|
13
|
+
const portNumber = String(item);
|
|
14
|
+
const ss = await runCommand('sh', ['-lc', `ss -ltnp 2>/dev/null | grep -E '[:.]${portNumber}\\s' || true`], { cwd, timeoutMs: 10000 });
|
|
15
|
+
const listening = Boolean(ss.stdout.trim());
|
|
16
|
+
checks.push({ name: 'port ' + portNumber + ' listening', status: listening ? 'pass' : 'warn', command: 'ss -ltnp', message: listening ? 'listening' : 'not listening' });
|
|
17
|
+
if (listening) verified.push('Port listening: ' + portNumber); else {
|
|
18
|
+
const message = 'Port not listening: ' + portNumber;
|
|
19
|
+
warnings.push(message);
|
|
20
|
+
findings.push(createFinding({ status: 'warn', area: 'local', title: 'Local port not listening', finding: message, evidence: ['port: ' + portNumber, 'probe type: listening port', 'result: not listening'], whyItMatters: 'A local runtime that is not listening cannot provide live confidence for this change.', nextSafeStep: 'Start the runtime yourself and rerun opstruth local.' }));
|
|
21
|
+
}
|
|
22
|
+
if (health) {
|
|
23
|
+
const probe = await probeUrl(`http://127.0.0.1:${portNumber}${health.startsWith('/') ? health : '/' + health}`, { method: 'GET', timeoutMs: 5000 });
|
|
24
|
+
checks.push({ name: 'health ' + portNumber, status: probe.status && probe.status < 500 ? 'pass' : 'warn', message: 'status=' + (probe.status || probe.error), data: probe });
|
|
25
|
+
if (!probe.status || probe.status >= 500) {
|
|
26
|
+
const message = 'Health check failed for port ' + portNumber;
|
|
27
|
+
warnings.push(message);
|
|
28
|
+
findings.push(createFinding({ status: 'warn', area: 'local', title: 'Local health check failed', finding: message, evidence: ['port: ' + portNumber, 'health URL: ' + probe.url, 'probe type: GET', 'result: ' + (probe.status || probe.error)], whyItMatters: 'A failing health endpoint limits confidence that the local runtime is usable.', nextSafeStep: 'Start or repair the local service and rerun the health probe.' }));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (processName) {
|
|
33
|
+
const ps = await runCommand('pgrep', ['-af', processName], { cwd, timeoutMs: 10000 });
|
|
34
|
+
checks.push({ name: 'process match ' + processName, status: ps.exitCode === 0 ? 'pass' : 'warn', command: ps.command });
|
|
35
|
+
if (ps.exitCode === 0) verified.push('Process matched: ' + processName); else warnings.push('No process matched: ' + processName);
|
|
36
|
+
}
|
|
37
|
+
if (service) {
|
|
38
|
+
const svc = await runCommand('systemctl', ['--user', 'status', service, '--no-pager'], { cwd, timeoutMs: 10000 });
|
|
39
|
+
checks.push({ name: 'systemd user service ' + service, status: svc.exitCode === 0 ? 'pass' : 'warn', command: svc.command });
|
|
40
|
+
if (svc.exitCode === 0) verified.push('Systemd user service visible: ' + service); else warnings.push('Systemd user service not active/visible: ' + service);
|
|
41
|
+
}
|
|
42
|
+
return finalizeStatus(createResult('local', warnings.length ? 'warn' : 'pass', { summary: 'Local runtime checks completed without killing or restarting services.', verified, warnings, findings, checks, notVerified: ['Process ownership may be incomplete on restricted systems'], nextSafeStep: warnings.length ? 'Start the runtime yourself and rerun opstruth local.' : 'Attach local liveness evidence if this runtime matters.' }), { strict });
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
3
|
+
import { detectStack } from '../lib/detect.js';
|
|
4
|
+
import { PROBE_CATALOGUE, selectProbes } from '../lib/probes.js';
|
|
5
|
+
|
|
6
|
+
function countBy(items, key) {
|
|
7
|
+
const counts = {};
|
|
8
|
+
for (const item of items) counts[item[key] || 'unknown'] = (counts[item[key] || 'unknown'] || 0) + 1;
|
|
9
|
+
return counts;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summarizeCounts(label, counts) {
|
|
13
|
+
return `${label}: ${Object.entries(counts).map(([name, count]) => `${name}=${count}`).join(', ')}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runProbes({ cwd = process.cwd(), strict = false, skip = [], only = [] } = {}) {
|
|
17
|
+
const boundary = await resolveProjectBoundary(cwd);
|
|
18
|
+
const stack = await detectStack(boundary.root);
|
|
19
|
+
const selection = await selectProbes({ root: boundary.root, stack, boundary, options: { skip, only } });
|
|
20
|
+
const byArea = countBy(PROBE_CATALOGUE, 'area');
|
|
21
|
+
const byMode = countBy(PROBE_CATALOGUE, 'defaultMode');
|
|
22
|
+
const bySafety = countBy(PROBE_CATALOGUE, 'safetyLevel');
|
|
23
|
+
const result = createResult('probes', 'pass', {
|
|
24
|
+
summary: 'Probe catalogue inspected for the current project without running mutating actions.',
|
|
25
|
+
verified: [
|
|
26
|
+
'Total probes: ' + PROBE_CATALOGUE.length,
|
|
27
|
+
summarizeCounts('Probes by area', byArea),
|
|
28
|
+
summarizeCounts('Probes by default mode', byMode),
|
|
29
|
+
summarizeCounts('Probes by safety level', bySafety),
|
|
30
|
+
'Detected safe automatic probes for this project: ' + selection.selected.length,
|
|
31
|
+
'Project boundary: ' + boundary.root,
|
|
32
|
+
'Detected platforms: ' + (stack.platforms.length ? stack.platforms.join(', ') : 'none')
|
|
33
|
+
],
|
|
34
|
+
skipped: [
|
|
35
|
+
...(boundary.message ? [boundary.message] : []),
|
|
36
|
+
...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}`)
|
|
37
|
+
],
|
|
38
|
+
checks: selection.selected.map((probe) => ({
|
|
39
|
+
name: probe.id,
|
|
40
|
+
status: 'pass',
|
|
41
|
+
message: `${probe.area}/${probe.stack}: ${probe.name}`
|
|
42
|
+
})),
|
|
43
|
+
notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.'],
|
|
44
|
+
data: {
|
|
45
|
+
boundary,
|
|
46
|
+
stack,
|
|
47
|
+
total: PROBE_CATALOGUE.length,
|
|
48
|
+
byArea,
|
|
49
|
+
byMode,
|
|
50
|
+
bySafety,
|
|
51
|
+
detected: selection.selected.map((probe) => ({
|
|
52
|
+
id: probe.id,
|
|
53
|
+
name: probe.name,
|
|
54
|
+
area: probe.area,
|
|
55
|
+
stack: probe.stack,
|
|
56
|
+
safetyLevel: probe.safetyLevel,
|
|
57
|
+
defaultMode: probe.defaultMode,
|
|
58
|
+
evidenceCollected: probe.evidenceCollected,
|
|
59
|
+
proves: probe.proves,
|
|
60
|
+
doesNotProve: probe.doesNotProve,
|
|
61
|
+
nextSafeStep: probe.nextSafeStep
|
|
62
|
+
})),
|
|
63
|
+
skipped: selection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
64
|
+
},
|
|
65
|
+
nextSafeStep: 'Run opstruth for selected safe probes, or add explicit route/local inputs for stronger runtime evidence.'
|
|
66
|
+
});
|
|
67
|
+
return finalizeStatus(result, { strict });
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { runCommand, excerpt } from '../lib/exec.js';
|
|
3
|
+
import { detectPackageManager, detectPackageScripts } from '../lib/detect.js';
|
|
4
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SCRIPTS = ['typecheck', 'lint', 'test', 'build', 'ci'];
|
|
7
|
+
|
|
8
|
+
export function isDefaultPlaceholderTestScript(script = '') {
|
|
9
|
+
return /^echo\s+["']?Error:\s*no test specified["']?\s*(?:&&|;)\s*exit\s+1$/i.test(script.trim());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRunnableQualityScript(name, script) {
|
|
13
|
+
return !(name === 'test' && isDefaultPlaceholderTestScript(script));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function runnerFor(manager) {
|
|
17
|
+
if (manager === 'pnpm') return ['pnpm', ['run']];
|
|
18
|
+
if (manager === 'yarn') return ['yarn', []];
|
|
19
|
+
if (manager === 'bun') return ['bun', ['run']];
|
|
20
|
+
return ['npm', ['run']];
|
|
21
|
+
}
|
|
22
|
+
export async function runQuality({ cwd = process.cwd(), continueOnFailure = false, strict = false, scripts: wantedScripts } = {}) {
|
|
23
|
+
const boundary = await resolveProjectBoundary(cwd);
|
|
24
|
+
cwd = boundary.root;
|
|
25
|
+
const packageManager = await detectPackageManager(cwd);
|
|
26
|
+
const availableScripts = await detectPackageScripts(cwd);
|
|
27
|
+
const requested = wantedScripts?.length ? wantedScripts : DEFAULT_SCRIPTS;
|
|
28
|
+
const selected = requested.filter((name) => Object.prototype.hasOwnProperty.call(availableScripts, name) && isRunnableQualityScript(name, availableScripts[name]));
|
|
29
|
+
const skippedScripts = requested.filter((name) => !Object.prototype.hasOwnProperty.call(availableScripts, name) || !isRunnableQualityScript(name, availableScripts[name]));
|
|
30
|
+
const checks = [];
|
|
31
|
+
const warnings = [];
|
|
32
|
+
const failures = [];
|
|
33
|
+
const skipped = skippedScripts.map((name) => {
|
|
34
|
+
if (Object.prototype.hasOwnProperty.call(availableScripts, name) && !isRunnableQualityScript(name, availableScripts[name])) {
|
|
35
|
+
return 'default npm placeholder test script skipped';
|
|
36
|
+
}
|
|
37
|
+
return 'package script not found, skipped: ' + name;
|
|
38
|
+
});
|
|
39
|
+
if (boundary.message) skipped.push(boundary.message);
|
|
40
|
+
if (boundary.isGitRepo) {
|
|
41
|
+
const diff = await runCommand('git', ['diff', '--check'], { cwd, timeoutMs: 60000 });
|
|
42
|
+
checks.push({ name: 'git diff --check', command: diff.command, exitCode: diff.exitCode, durationMs: diff.durationMs, status: diff.exitCode === 0 ? 'pass' : 'fail', logExcerpt: excerpt(diff.stderr || diff.stdout) });
|
|
43
|
+
if (diff.exitCode !== 0) failures.push('git diff --check failed');
|
|
44
|
+
} else {
|
|
45
|
+
skipped.push('git diff --check skipped because no git repository was detected');
|
|
46
|
+
}
|
|
47
|
+
const [runner, baseArgs] = runnerFor(packageManager);
|
|
48
|
+
for (const script of selected) {
|
|
49
|
+
if (failures.length && !continueOnFailure) break;
|
|
50
|
+
const run = await runCommand(runner, [...baseArgs, script], { cwd, timeoutMs: 180000 });
|
|
51
|
+
checks.push({ name: 'package script ' + script, command: run.command, exitCode: run.exitCode, durationMs: run.durationMs, status: run.exitCode === 0 ? 'pass' : 'fail', logExcerpt: excerpt((run.stdout || '') + '\n' + (run.stderr || '')) });
|
|
52
|
+
if (run.exitCode !== 0) failures.push(script + ' failed');
|
|
53
|
+
}
|
|
54
|
+
if (!Object.keys(availableScripts).length) skipped.push('No package.json scripts detected');
|
|
55
|
+
const result = createResult('quality', failures.length ? 'fail' : warnings.length ? 'warn' : 'pass', {
|
|
56
|
+
summary: 'Safe local quality gates ran only scripts that exist in package.json. Missing scripts were skipped, not failed.',
|
|
57
|
+
verified: [boundary.isGitRepo ? 'git diff --check executed' : 'git diff --check skipped outside git repository', 'package.json scripts inspected', 'Existing quality scripts selected: ' + (selected.length ? selected.join(', ') : 'none')],
|
|
58
|
+
warnings,
|
|
59
|
+
failures,
|
|
60
|
+
skipped,
|
|
61
|
+
checks,
|
|
62
|
+
data: { boundary, packageManager, availableScripts, requestedScripts: requested, selectedScripts: selected, skippedScripts },
|
|
63
|
+
nextSafeStep: failures.length ? 'Fix the failing quality command and rerun opstruth quality.' : 'Attach quality output to the evidence pack.'
|
|
64
|
+
});
|
|
65
|
+
return finalizeStatus(result, { strict });
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { getGitInfo } from '../lib/git.js';
|
|
3
|
+
import { detectStack } from '../lib/detect.js';
|
|
4
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
5
|
+
|
|
6
|
+
export async function runRepo({ cwd = process.cwd(), strict = false } = {}) {
|
|
7
|
+
const boundary = await resolveProjectBoundary(cwd);
|
|
8
|
+
const git = await getGitInfo(boundary.root);
|
|
9
|
+
const root = boundary.root;
|
|
10
|
+
const stack = await detectStack(root);
|
|
11
|
+
const detected = [
|
|
12
|
+
'Project language detected: ' + stack.language,
|
|
13
|
+
'Node module mode detected: ' + (stack.isEsm ? 'ESM' : 'not declared ESM'),
|
|
14
|
+
'Package manager detected: ' + (stack.packageManager || 'none'),
|
|
15
|
+
'Platforms detected: ' + (stack.platforms.length ? stack.platforms.join(', ') : 'none')
|
|
16
|
+
];
|
|
17
|
+
if (stack.isTypeScript) detected.push('TypeScript project detected via tsconfig, TypeScript dependency, .ts/.tsx source, or TS config files');
|
|
18
|
+
const configNotes = Object.entries(stack.config)
|
|
19
|
+
.filter(([, values]) => values.length)
|
|
20
|
+
.map(([name, values]) => `${name} config/files: ${values.join(', ')}`);
|
|
21
|
+
const result = createResult('repo', 'pass', {
|
|
22
|
+
summary: 'Read-only repository inspection completed. ' + detected.join(' | '),
|
|
23
|
+
verified: ['Current working directory inspected: ' + cwd, 'Git root checked: ' + (git.root || 'not a git repository'), 'Important repo files checked', ...detected, ...configNotes],
|
|
24
|
+
warnings: git.root ? [] : [boundary.message],
|
|
25
|
+
checks: [{ name: 'repo inspection', status: 'pass' }, { name: 'typescript project detection', status: stack.isTypeScript ? 'pass' : 'not_verified', message: stack.isTypeScript ? 'TypeScript-based project' : 'No TypeScript indicators detected' }],
|
|
26
|
+
data: { cwd, projectRoot: root, gitRoot: git.root, branch: git.branch, latestCommit: git.latestCommit, dirtyFiles: git.dirtyFiles, changedFiles: git.changedFiles, diffStat: git.diffStat, recentCommits: git.recentCommits, packageManager: stack.packageManager, scripts: stack.scripts, importantFiles: stack.files, platforms: stack.platforms, language: stack.language, isTypeScript: stack.isTypeScript, isEsm: stack.isEsm, config: stack.config },
|
|
27
|
+
nextSafeStep: 'Review dirty files and run opstruth quality before trusting the change.'
|
|
28
|
+
});
|
|
29
|
+
return finalizeStatus(result, { strict });
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { probeUrl } from '../lib/http.js';
|
|
3
|
+
import { loadRoutesConfig, findDefaultRoutesConfig } from '../lib/config.js';
|
|
4
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_HEADERS = ['content-security-policy', 'strict-transport-security', 'x-frame-options', 'referrer-policy'];
|
|
7
|
+
export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, strict = false } = {}) {
|
|
8
|
+
const boundary = await resolveProjectBoundary(cwd);
|
|
9
|
+
cwd = boundary.root;
|
|
10
|
+
let config = routesFile ? await loadRoutesConfig(cwd, routesFile) : null;
|
|
11
|
+
if (config?.routes?.baseUrl !== undefined || config?.routes?.paths) config = config.routes;
|
|
12
|
+
if (!config) config = (await findDefaultRoutesConfig(cwd))?.config;
|
|
13
|
+
const finalBase = baseUrl || config?.baseUrl;
|
|
14
|
+
if (!finalBase) return createResult('routes', 'skipped', { skipped: ['Route checks skipped because no --base-url or route config was provided'], notVerified: ['Public route availability'], nextSafeStep: 'Run opstruth routes --base-url https://example.com or provide --routes.' });
|
|
15
|
+
const paths = config?.paths?.length ? config.paths.map((routePath) => ({ path: routePath, method: routePath.includes('health') ? 'GET' : 'HEAD', expectStatus: [200, 301, 302] })) : [];
|
|
16
|
+
const routes = config?.routes?.length ? config.routes : paths.length ? paths : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
17
|
+
const requiredHeaders = config?.requiredHeaders || DEFAULT_HEADERS;
|
|
18
|
+
const checks = [];
|
|
19
|
+
const warnings = [];
|
|
20
|
+
const failures = [];
|
|
21
|
+
const findings = [];
|
|
22
|
+
for (const route of routes) {
|
|
23
|
+
const url = new URL(route.path, finalBase).toString();
|
|
24
|
+
const probe = await probeUrl(url, { method: route.method || 'HEAD' });
|
|
25
|
+
const expectStatus = route.expectStatus || [200, 301, 302];
|
|
26
|
+
const missingHeaders = requiredHeaders.filter((header) => !probe.headers?.[header]);
|
|
27
|
+
const okStatus = probe.status && expectStatus.includes(probe.status);
|
|
28
|
+
const evidence = [
|
|
29
|
+
'url: ' + url,
|
|
30
|
+
'method: ' + (route.method || 'HEAD'),
|
|
31
|
+
'status: ' + (probe.status || probe.error),
|
|
32
|
+
'latency: ' + probe.latencyMs + 'ms',
|
|
33
|
+
'missing headers: ' + (missingHeaders.length ? missingHeaders.join(', ') : 'none'),
|
|
34
|
+
'redirect target: ' + (probe.location || 'none')
|
|
35
|
+
];
|
|
36
|
+
if (!okStatus) {
|
|
37
|
+
const message = `${route.method || 'HEAD'} ${route.path} returned ${probe.status || probe.error}`;
|
|
38
|
+
failures.push(message);
|
|
39
|
+
findings.push(createFinding({ status: 'fail', area: 'routes', title: 'Route status did not match expectation', finding: message, evidence, whyItMatters: 'A route that fails a read-only smoke check may block users or downstream health checks.', nextSafeStep: 'Inspect the route and rerun without deploying from opstruth.' }));
|
|
40
|
+
}
|
|
41
|
+
if (missingHeaders.length) {
|
|
42
|
+
const message = `${route.path} missing headers: ${missingHeaders.join(', ')}`;
|
|
43
|
+
warnings.push(message);
|
|
44
|
+
findings.push(createFinding({ status: 'warn', area: 'routes', title: 'Route security headers missing', finding: message, evidence, whyItMatters: 'Missing browser security headers can weaken runtime protection even when the route is available.', nextSafeStep: 'Add the missing headers in the app or hosting layer and rerun route probes.' }));
|
|
45
|
+
}
|
|
46
|
+
checks.push({ name: `${route.method || 'HEAD'} ${route.path}`, status: okStatus ? missingHeaders.length ? 'warn' : 'pass' : 'fail', message: `status=${probe.status || 'error'} latency=${probe.latencyMs}ms`, data: probe });
|
|
47
|
+
}
|
|
48
|
+
return finalizeStatus(createResult('routes', failures.length ? 'fail' : warnings.length ? 'warn' : 'pass', { summary: 'HEAD/GET public route smoke matrix completed.', verified: ['Project boundary: ' + boundary.root, 'Routes checked with read-only HEAD/GET requests', 'Response status, redirects, headers, and latency captured'], warnings, failures, findings, checks, data: { boundary, baseUrl: finalBase, routes, requiredHeaders }, nextSafeStep: failures.length ? 'Investigate failing routes without deploying from opstruth.' : 'Add missing required headers or attach route evidence.' }), { strict });
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
+
import { scanRiskyReferences } from '../lib/scan.js';
|
|
3
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
4
|
+
|
|
5
|
+
export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
6
|
+
const boundary = await resolveProjectBoundary(cwd);
|
|
7
|
+
const findings = await scanRiskyReferences(boundary.root);
|
|
8
|
+
const findingObjects = findings.map((item) => createFinding({
|
|
9
|
+
status: 'warn',
|
|
10
|
+
area: 'secrets',
|
|
11
|
+
title: 'Risky secret or auth reference',
|
|
12
|
+
finding: `${item.file}:${item.line} matched ${item.pattern}`,
|
|
13
|
+
evidence: [
|
|
14
|
+
'file: ' + item.file,
|
|
15
|
+
'line: ' + item.line,
|
|
16
|
+
'pattern: ' + item.pattern,
|
|
17
|
+
'redacted preview: ' + item.preview
|
|
18
|
+
],
|
|
19
|
+
whyItMatters: 'Secret-like values and service-role references can create account, data, or infrastructure exposure if committed or exposed to browsers.',
|
|
20
|
+
nextSafeStep: 'Confirm whether this is a harmless reference. Move real secrets to secret storage and keep only names/placeholders in source.'
|
|
21
|
+
}));
|
|
22
|
+
const result = createResult('secrets', findings.length ? 'warn' : 'pass', {
|
|
23
|
+
summary: 'Redacted risky secret/reference scan completed. .env file contents are skipped.',
|
|
24
|
+
verified: ['Project boundary scanned: ' + boundary.root, 'Source files scanned with redaction', '.env contents were not printed'],
|
|
25
|
+
warnings: findingObjects.map((finding) => finding.finding),
|
|
26
|
+
findings: findingObjects,
|
|
27
|
+
skipped: boundary.message ? [boundary.message] : [],
|
|
28
|
+
checks: [{ name: 'secret reference scan', status: findings.length ? 'warn' : 'pass', message: findings.length + ' finding(s)' }],
|
|
29
|
+
data: { boundary, findings },
|
|
30
|
+
nextSafeStep: findings.length ? 'Review whether each reference is expected and move real secrets to safe storage.' : 'Keep secrets out of source and rerun before publishing.'
|
|
31
|
+
});
|
|
32
|
+
return finalizeStatus(result, { strict });
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createResult, finalizeStatus } from '../lib/result.js';
|
|
3
|
+
import { walkFiles, readText, pathExists } from '../lib/fs.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PROTECTED = ['agent_jobs', 'platform_credentials', 'worker_logs'];
|
|
6
|
+
export async function runSupabase({ cwd = process.cwd(), protectedTable = DEFAULT_PROTECTED, frontendDir = 'src', migrationsDir = 'supabase/migrations', strict = false } = {}) {
|
|
7
|
+
const protectedTables = Array.isArray(protectedTable) ? protectedTable : [protectedTable];
|
|
8
|
+
const migrationRoot = path.join(cwd, migrationsDir);
|
|
9
|
+
const frontendRoot = path.join(cwd, frontendDir);
|
|
10
|
+
const warnings = [];
|
|
11
|
+
const verified = [];
|
|
12
|
+
const data = { protectedTables, policies: [], frontendAccess: [], riskyWrites: [] };
|
|
13
|
+
if (await pathExists(migrationRoot)) {
|
|
14
|
+
const migrations = await walkFiles(migrationRoot, { skipDirs: [] });
|
|
15
|
+
for (const file of migrations.filter((item) => item.rel.endsWith('.sql'))) {
|
|
16
|
+
const text = await readText(file.full);
|
|
17
|
+
const lower = text.toLowerCase();
|
|
18
|
+
data.policies.push({ file: path.join(migrationsDir, file.rel), createPolicy: /create\s+policy/i.test(text), enableRls: /alter\s+table[\s\S]+enable\s+row\s+level\s+security/i.test(text), grant: /\bgrant\b/i.test(text), revoke: /\brevoke\b/i.test(text), securityDefiner: /security\s+definer/i.test(text), functions: (lower.match(/create\s+(or\s+replace\s+)?function/g) || []).length });
|
|
19
|
+
}
|
|
20
|
+
verified.push('Supabase migrations inspected: ' + data.policies.length);
|
|
21
|
+
} else warnings.push('Supabase migrations directory not found: ' + migrationsDir);
|
|
22
|
+
if (await pathExists(frontendRoot)) {
|
|
23
|
+
const files = await walkFiles(frontendRoot, { skipDirs: ['node_modules', 'dist', 'build', '.next', 'coverage'] });
|
|
24
|
+
for (const file of files.filter((item) => /\.(js|jsx|ts|tsx)$/.test(item.rel))) {
|
|
25
|
+
const text = await readText(file.full);
|
|
26
|
+
for (const table of protectedTables) {
|
|
27
|
+
const fromPattern = new RegExp(`\\.from\\([\"']${table}[\"']\\)`, 'g');
|
|
28
|
+
if (fromPattern.test(text)) data.frontendAccess.push({ file: path.join(frontendDir, file.rel), table });
|
|
29
|
+
}
|
|
30
|
+
if (/\.(insert|update|delete|upsert)\s*\(/.test(text)) data.riskyWrites.push({ file: path.join(frontendDir, file.rel), operation: 'frontend write method' });
|
|
31
|
+
}
|
|
32
|
+
verified.push('Frontend Supabase references inspected');
|
|
33
|
+
} else warnings.push('Frontend directory not found: ' + frontendDir);
|
|
34
|
+
for (const access of data.frontendAccess) warnings.push(`Protected table referenced from frontend: ${access.table} in ${access.file}`);
|
|
35
|
+
for (const write of data.riskyWrites) warnings.push(`Frontend write call found: ${write.file}`);
|
|
36
|
+
const noRls = data.policies.filter((item) => !item.enableRls && !item.createPolicy);
|
|
37
|
+
if (data.policies.length && noRls.length) warnings.push('Some migration files did not include obvious RLS policy statements');
|
|
38
|
+
return finalizeStatus(createResult('supabase', warnings.length ? 'warn' : 'pass', { summary: 'Static Supabase exposure audit completed without connecting to Supabase.', verified, warnings, checks: [{ name: 'supabase static audit', status: warnings.length ? 'warn' : 'pass' }], data, notVerified: ['Live Supabase permissions were not checked', 'No migrations were applied'], nextSafeStep: 'Review protected table findings and run a live permission audit outside opstruth if needed.' }), { strict });
|
|
39
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { runCommand } from './exec.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_IGNORED_DIRS = [
|
|
5
|
+
'.git',
|
|
6
|
+
'node_modules',
|
|
7
|
+
'dist',
|
|
8
|
+
'build',
|
|
9
|
+
'.next',
|
|
10
|
+
'coverage',
|
|
11
|
+
'.cache',
|
|
12
|
+
'.config',
|
|
13
|
+
'.local',
|
|
14
|
+
'.npm',
|
|
15
|
+
'.pnpm-store',
|
|
16
|
+
'.yarn',
|
|
17
|
+
'.bun',
|
|
18
|
+
'.vscode-server',
|
|
19
|
+
'.cursor',
|
|
20
|
+
'.agents',
|
|
21
|
+
'Downloads',
|
|
22
|
+
'Desktop',
|
|
23
|
+
'Documents',
|
|
24
|
+
'Pictures',
|
|
25
|
+
'Videos',
|
|
26
|
+
'Music',
|
|
27
|
+
'evidence'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_BINARY_EXTENSIONS = [
|
|
31
|
+
'.png',
|
|
32
|
+
'.jpg',
|
|
33
|
+
'.jpeg',
|
|
34
|
+
'.gif',
|
|
35
|
+
'.webp',
|
|
36
|
+
'.ico',
|
|
37
|
+
'.pdf',
|
|
38
|
+
'.zip',
|
|
39
|
+
'.gz',
|
|
40
|
+
'.tgz',
|
|
41
|
+
'.br',
|
|
42
|
+
'.sqlite',
|
|
43
|
+
'.db',
|
|
44
|
+
'.wasm',
|
|
45
|
+
'.lockb'
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export async function resolveProjectBoundary(cwd = process.cwd()) {
|
|
49
|
+
const git = await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd, timeoutMs: 10000 });
|
|
50
|
+
if (git.exitCode === 0 && git.stdout.trim()) {
|
|
51
|
+
return {
|
|
52
|
+
cwd,
|
|
53
|
+
root: git.stdout.trim(),
|
|
54
|
+
gitRoot: git.stdout.trim(),
|
|
55
|
+
isGitRepo: true,
|
|
56
|
+
message: ''
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
cwd,
|
|
61
|
+
root: cwd,
|
|
62
|
+
gitRoot: null,
|
|
63
|
+
isGitRepo: false,
|
|
64
|
+
message: 'No git repository detected. opstruth is scanning the current directory with safety ignores. For best results, run inside a project repo.'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function mergeIgnores(extra = []) {
|
|
69
|
+
return [...new Set([...DEFAULT_IGNORED_DIRS, ...(extra || [])])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isIgnoredExtension(file) {
|
|
73
|
+
return DEFAULT_BINARY_EXTENSIONS.includes(path.extname(file).toLowerCase());
|
|
74
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, readJson, readText } from './fs.js';
|
|
3
|
+
|
|
4
|
+
export async function loadRoutesConfig(root, file) {
|
|
5
|
+
if (!file) return null;
|
|
6
|
+
const full = path.isAbsolute(file) ? file : path.join(root, file);
|
|
7
|
+
if (!(await pathExists(full))) return null;
|
|
8
|
+
return readJson(full);
|
|
9
|
+
}
|
|
10
|
+
export async function findDefaultRoutesConfig(root) {
|
|
11
|
+
for (const file of ['opstruth.config.json', 'opstruth.routes.json', 'routes.json']) {
|
|
12
|
+
const full = path.join(root, file);
|
|
13
|
+
if (await pathExists(full)) {
|
|
14
|
+
const config = await readJson(full);
|
|
15
|
+
return { file, config: config.routes?.baseUrl !== undefined || config.routes?.paths ? config.routes : config };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export async function readWranglerConfig(root) {
|
|
21
|
+
for (const file of ['wrangler.json', 'wrangler.jsonc']) {
|
|
22
|
+
const full = path.join(root, file);
|
|
23
|
+
if (await pathExists(full)) {
|
|
24
|
+
const text = await readText(full);
|
|
25
|
+
return { file, text, data: JSON.parse(text.replace(/\/\/.*$/gm, '')) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const toml = path.join(root, 'wrangler.toml');
|
|
29
|
+
if (await pathExists(toml)) return { file: 'wrangler.toml', text: await readText(toml), data: null };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, readJson, listPresent, walkFiles } from './fs.js';
|
|
3
|
+
import { mergeIgnores } from './boundary.js';
|
|
4
|
+
|
|
5
|
+
const IMPORTANT_FILES = [
|
|
6
|
+
'package.json',
|
|
7
|
+
'tsconfig.json',
|
|
8
|
+
'vite.config.js',
|
|
9
|
+
'vite.config.ts',
|
|
10
|
+
'vite.config.mjs',
|
|
11
|
+
'next.config.js',
|
|
12
|
+
'next.config.mjs',
|
|
13
|
+
'next.config.ts',
|
|
14
|
+
'eslint.config.js',
|
|
15
|
+
'eslint.config.mjs',
|
|
16
|
+
'eslint.config.cjs',
|
|
17
|
+
'eslint.config.ts',
|
|
18
|
+
'.eslintrc',
|
|
19
|
+
'.eslintrc.js',
|
|
20
|
+
'.eslintrc.cjs',
|
|
21
|
+
'.eslintrc.json',
|
|
22
|
+
'vitest.config.js',
|
|
23
|
+
'vitest.config.mjs',
|
|
24
|
+
'vitest.config.ts',
|
|
25
|
+
'playwright.config.js',
|
|
26
|
+
'playwright.config.mjs',
|
|
27
|
+
'playwright.config.ts',
|
|
28
|
+
'pnpm-lock.yaml',
|
|
29
|
+
'yarn.lock',
|
|
30
|
+
'package-lock.json',
|
|
31
|
+
'bun.lockb',
|
|
32
|
+
'bun.lock',
|
|
33
|
+
'wrangler.toml',
|
|
34
|
+
'wrangler.json',
|
|
35
|
+
'wrangler.jsonc',
|
|
36
|
+
'Dockerfile',
|
|
37
|
+
'docker-compose.yml',
|
|
38
|
+
'docker-compose.yaml',
|
|
39
|
+
'README.md',
|
|
40
|
+
'AGENTS.md',
|
|
41
|
+
'.github/workflows',
|
|
42
|
+
'supabase',
|
|
43
|
+
'supabase/migrations'
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export async function detectPackageManager(root) {
|
|
47
|
+
if (await pathExists(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
48
|
+
if (await pathExists(path.join(root, 'yarn.lock'))) return 'yarn';
|
|
49
|
+
if (await pathExists(path.join(root, 'bun.lockb')) || await pathExists(path.join(root, 'bun.lock'))) return 'bun';
|
|
50
|
+
if (await pathExists(path.join(root, 'package-lock.json'))) return 'npm';
|
|
51
|
+
if (await pathExists(path.join(root, 'package.json'))) return 'npm';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function detectPackageScripts(root) {
|
|
56
|
+
const file = path.join(root, 'package.json');
|
|
57
|
+
if (!(await pathExists(file))) return {};
|
|
58
|
+
try { return (await readJson(file)).scripts || {}; } catch { return {}; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hasSourceExtension(root, extensions) {
|
|
62
|
+
const files = await walkFiles(root, { skipDirs: mergeIgnores(), maxFiles: 1000 });
|
|
63
|
+
return files.some((file) => extensions.some((extension) => file.rel.endsWith(extension)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function detectStack(root) {
|
|
67
|
+
const packageFile = path.join(root, 'package.json');
|
|
68
|
+
let pkg = {};
|
|
69
|
+
if (await pathExists(packageFile)) {
|
|
70
|
+
try { pkg = await readJson(packageFile); } catch { pkg = {}; }
|
|
71
|
+
}
|
|
72
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
73
|
+
const files = await listPresent(root, IMPORTANT_FILES);
|
|
74
|
+
const hasTsSource = await hasSourceExtension(root, ['.ts', '.tsx']);
|
|
75
|
+
const isTypeScript = Boolean(files.includes('tsconfig.json') || deps.typescript || hasTsSource || files.includes('vite.config.ts') || files.includes('next.config.ts'));
|
|
76
|
+
const isEsm = pkg.type === 'module' || files.some((file) => file.endsWith('.mjs'));
|
|
77
|
+
const config = {
|
|
78
|
+
typescript: files.filter((file) => file === 'tsconfig.json'),
|
|
79
|
+
vite: files.filter((file) => file.startsWith('vite.config')),
|
|
80
|
+
next: files.filter((file) => file.startsWith('next.config')),
|
|
81
|
+
eslint: files.filter((file) => file.includes('eslint')),
|
|
82
|
+
vitest: files.filter((file) => file.startsWith('vitest.config')),
|
|
83
|
+
playwright: files.filter((file) => file.startsWith('playwright.config')),
|
|
84
|
+
lockfiles: files.filter((file) => ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'bun.lockb', 'bun.lock'].includes(file))
|
|
85
|
+
};
|
|
86
|
+
const platforms = [];
|
|
87
|
+
if (isTypeScript) platforms.push('TypeScript');
|
|
88
|
+
if (deps.react) platforms.push('React');
|
|
89
|
+
if (deps.vite || config.vite.length) platforms.push('Vite');
|
|
90
|
+
if (deps.next || config.next.length) platforms.push('Next.js');
|
|
91
|
+
if (isEsm) platforms.push('Node ESM');
|
|
92
|
+
if (files.some((file) => file.startsWith('wrangler'))) platforms.push('Cloudflare');
|
|
93
|
+
if (files.includes('supabase') || files.includes('supabase/migrations')) platforms.push('Supabase');
|
|
94
|
+
if (files.some((file) => file.toLowerCase().includes('docker'))) platforms.push('Docker');
|
|
95
|
+
if (files.includes('.github/workflows')) platforms.push('GitHub Actions');
|
|
96
|
+
return {
|
|
97
|
+
packageName: pkg.name || null,
|
|
98
|
+
packageManager: await detectPackageManager(root),
|
|
99
|
+
scripts: pkg.scripts || {},
|
|
100
|
+
files,
|
|
101
|
+
platforms,
|
|
102
|
+
language: isTypeScript ? 'TypeScript' : 'JavaScript',
|
|
103
|
+
isTypeScript,
|
|
104
|
+
isEsm,
|
|
105
|
+
config,
|
|
106
|
+
dependencies: Object.keys(deps).sort()
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function hasSupabase(root) { return pathExists(path.join(root, 'supabase')); }
|
|
111
|
+
export async function hasCloudflare(root) { return (await pathExists(path.join(root, 'wrangler.toml'))) || (await pathExists(path.join(root, 'wrangler.json'))) || (await pathExists(path.join(root, 'wrangler.jsonc'))); }
|