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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/opstruth.js +7 -0
  4. package/examples/routes.json +12 -0
  5. package/fixtures/next-app/app/page.tsx +3 -0
  6. package/fixtures/next-app/next.config.ts +5 -0
  7. package/fixtures/next-app/package.json +19 -0
  8. package/fixtures/next-app/tsconfig.json +6 -0
  9. package/fixtures/non-git-folder/README.md +3 -0
  10. package/fixtures/non-git-folder/notes.txt +1 -0
  11. package/fixtures/plain-node-app/package.json +8 -0
  12. package/fixtures/plain-node-app/src/index.js +3 -0
  13. package/fixtures/risky-secret-app/package.json +8 -0
  14. package/fixtures/risky-secret-app/src/config.js +3 -0
  15. package/fixtures/supabase-cloudflare-app/package.json +16 -0
  16. package/fixtures/supabase-cloudflare-app/src/supabaseClient.ts +7 -0
  17. package/fixtures/supabase-cloudflare-app/src/worker.ts +5 -0
  18. package/fixtures/supabase-cloudflare-app/supabase/migrations/001_init.sql +11 -0
  19. package/fixtures/supabase-cloudflare-app/wrangler.toml +6 -0
  20. package/fixtures/vite-react-app/package.json +20 -0
  21. package/fixtures/vite-react-app/src/App.tsx +3 -0
  22. package/fixtures/vite-react-app/tsconfig.json +6 -0
  23. package/fixtures/vite-react-app/vite.config.ts +6 -0
  24. package/package.json +53 -0
  25. package/scripts/demo-fixtures.sh +35 -0
  26. package/scripts/demo-run.sh +32 -0
  27. package/src/cli.js +254 -0
  28. package/src/commands/cloudflare.js +51 -0
  29. package/src/commands/evidence.js +38 -0
  30. package/src/commands/local.js +43 -0
  31. package/src/commands/probes.js +68 -0
  32. package/src/commands/quality.js +66 -0
  33. package/src/commands/repo.js +30 -0
  34. package/src/commands/routes.js +49 -0
  35. package/src/commands/secrets.js +33 -0
  36. package/src/commands/supabase.js +39 -0
  37. package/src/lib/boundary.js +74 -0
  38. package/src/lib/config.js +31 -0
  39. package/src/lib/detect.js +111 -0
  40. package/src/lib/exec.js +28 -0
  41. package/src/lib/fs.js +36 -0
  42. package/src/lib/git.js +27 -0
  43. package/src/lib/http.js +14 -0
  44. package/src/lib/markdown.js +202 -0
  45. package/src/lib/probes.js +489 -0
  46. package/src/lib/redact.js +27 -0
  47. package/src/lib/result.js +63 -0
  48. package/src/lib/scan.js +53 -0
  49. package/src/orchestrator.js +106 -0
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { redact } from './redact.js';
3
+
4
+ export function runCommand(command, args = [], { cwd = process.cwd(), timeoutMs = 120000 } = {}) {
5
+ const started = Date.now();
6
+ return new Promise((resolve) => {
7
+ const child = spawn(command, args, { cwd, shell: false, windowsHide: true });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
11
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
12
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
13
+ child.on('error', (error) => {
14
+ clearTimeout(timer);
15
+ resolve({ command: [command, ...args].join(' '), exitCode: 127, durationMs: Date.now() - started, stdout: '', stderr: redact(error.message) });
16
+ });
17
+ child.on('close', (code, signal) => {
18
+ clearTimeout(timer);
19
+ resolve({ command: [command, ...args].join(' '), exitCode: code ?? (signal ? 124 : 1), signal, durationMs: Date.now() - started, stdout: redact(stdout), stderr: redact(stderr) });
20
+ });
21
+ });
22
+ }
23
+
24
+ export function excerpt(text = '', lines = 30) {
25
+ const clean = redact(text).trim();
26
+ if (!clean) return '';
27
+ return clean.split(/\r?\n/).slice(-lines).join('\n');
28
+ }
package/src/lib/fs.js ADDED
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isIgnoredExtension } from './boundary.js';
4
+
5
+ export async function pathExists(filePath) {
6
+ try { await fs.access(filePath); return true; } catch { return false; }
7
+ }
8
+ export async function readText(filePath) { return fs.readFile(filePath, 'utf8'); }
9
+ export async function readJson(filePath) { return JSON.parse(await readText(filePath)); }
10
+ export async function writeFileSafe(filePath, content) {
11
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
12
+ await fs.writeFile(filePath, content, 'utf8');
13
+ }
14
+ export async function listPresent(root, files) {
15
+ const present = [];
16
+ for (const file of files) if (await pathExists(path.join(root, file))) present.push(file);
17
+ return present;
18
+ }
19
+ export async function walkFiles(root, { skipDirs = [], maxFiles = 5000, skipBinary = true } = {}) {
20
+ const results = [];
21
+ async function walk(dir) {
22
+ if (results.length >= maxFiles) return;
23
+ let entries = [];
24
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
25
+ for (const entry of entries) {
26
+ const full = path.join(dir, entry.name);
27
+ const rel = path.relative(root, full).replaceAll('\\', '/');
28
+ if (entry.isDirectory()) {
29
+ if (!skipDirs.includes(entry.name) && !skipDirs.includes(rel)) await walk(full);
30
+ } else if (entry.isFile() && (!skipBinary || !isIgnoredExtension(entry.name))) results.push({ full, rel });
31
+ if (results.length >= maxFiles) return;
32
+ }
33
+ }
34
+ await walk(root);
35
+ return results;
36
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,27 @@
1
+ import { runCommand } from './exec.js';
2
+
3
+ export async function git(args, cwd) { return runCommand('git', args, { cwd, timeoutMs: 30000 }); }
4
+ export async function gitText(args, cwd) {
5
+ const result = await git(args, cwd);
6
+ return result.exitCode === 0 ? result.stdout.trim() : '';
7
+ }
8
+ export async function getGitInfo(cwd) {
9
+ const root = await gitText(['rev-parse', '--show-toplevel'], cwd);
10
+ const workingRoot = root || cwd;
11
+ const [branch, latestCommit, status, diffStat, recentCommits] = await Promise.all([
12
+ gitText(['branch', '--show-current'], workingRoot),
13
+ gitText(['log', '-1', '--pretty=%h %s'], workingRoot),
14
+ gitText(['status', '--short'], workingRoot),
15
+ gitText(['diff', '--stat'], workingRoot),
16
+ gitText(['log', '--oneline', '-5'], workingRoot)
17
+ ]);
18
+ return {
19
+ root: root || null,
20
+ branch: branch || null,
21
+ latestCommit: latestCommit || null,
22
+ dirtyFiles: status ? status.split(/\r?\n/).filter(Boolean) : [],
23
+ changedFiles: status ? status.split(/\r?\n/).map((line) => line.slice(3).trim()).filter(Boolean) : [],
24
+ diffStat: diffStat || '',
25
+ recentCommits: recentCommits ? recentCommits.split(/\r?\n/).filter(Boolean) : []
26
+ };
27
+ }
@@ -0,0 +1,14 @@
1
+ export async function probeUrl(url, { method = 'HEAD', timeoutMs = 15000 } = {}) {
2
+ const started = Date.now();
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
5
+ try {
6
+ const response = await fetch(url, { method, redirect: 'manual', signal: controller.signal });
7
+ const headers = Object.fromEntries(response.headers.entries());
8
+ return { url, method, status: response.status, ok: response.ok, redirected: response.status >= 300 && response.status < 400, location: headers.location || null, headers, latencyMs: Date.now() - started };
9
+ } catch (error) {
10
+ return { url, method, status: null, ok: false, error: error.message, latencyMs: Date.now() - started };
11
+ } finally {
12
+ clearTimeout(timer);
13
+ }
14
+ }
@@ -0,0 +1,202 @@
1
+ import { redactObject } from './redact.js';
2
+
3
+ const ASCII_HEADER = ` ____ _______ __ __
4
+ / __ \\____ / ____(_)___ ____/ /_/ /
5
+ / / / / __ \\/ /_ / / __ \\/ __ / __/
6
+ / /_/ / /_/ / __/ / / / / / /_/ / /_
7
+ \\____/ .___/_/ /_/_/ /_/\\__,_/\\__/
8
+ /_/
9
+
10
+ Operational truth checks for AI-assisted engineering.`;
11
+
12
+ function statusLabel(status) {
13
+ if (status === 'pass') return 'Pass';
14
+ if (status === 'warn') return 'Partial pass';
15
+ if (status === 'fail') return 'Fail';
16
+ if (status === 'skipped') return 'Skipped';
17
+ return 'Not verified';
18
+ }
19
+
20
+ function list(items = [], fallback = '- None', limit = 12) {
21
+ if (!items.length) return fallback;
22
+ const shown = items.slice(0, limit).map((item) => '- ' + String(item).replace(/\n/g, ' '));
23
+ const remaining = items.length - shown.length;
24
+ if (remaining > 0) shown.push('- +' + remaining + ' more');
25
+ return shown.join('\n');
26
+ }
27
+
28
+ function findingList(findings = [], limit = 8) {
29
+ if (!findings.length) return '- None';
30
+ const lines = [];
31
+ for (const finding of findings.slice(0, limit)) {
32
+ lines.push('- [' + (finding.status || 'warn') + '] ' + finding.title + ': ' + finding.finding);
33
+ for (const evidence of (finding.evidence || []).slice(0, 6)) lines.push(' evidence: ' + String(evidence).replace(/\n/g, ' '));
34
+ if (finding.whyItMatters) lines.push(' why it matters: ' + finding.whyItMatters);
35
+ if (finding.nextSafeStep) lines.push(' next safe step: ' + finding.nextSafeStep);
36
+ }
37
+ if (findings.length > limit) lines.push('- +' + (findings.length - limit) + ' more findings in JSON output/evidence data');
38
+ return lines.join('\n');
39
+ }
40
+
41
+ function table(headers, rows) {
42
+ if (!rows.length) return '- None';
43
+ const head = '| ' + headers.join(' | ') + ' |';
44
+ const divider = '| ' + headers.map(() => '---').join(' | ') + ' |';
45
+ const body = rows.map((row) => '| ' + row.map((cell) => String(cell ?? '').replace(/\n/g, ' ')).join(' | ') + ' |');
46
+ return [head, divider, ...body].join('\n');
47
+ }
48
+
49
+ function checkList(checks = [], limit = 12) {
50
+ if (!checks.length) return '- None';
51
+ return table(['Status', 'Check', 'Detail'], checks.slice(0, limit).map((check) => [
52
+ check.status || 'not_verified',
53
+ check.name || check.command || 'check',
54
+ [Number.isFinite(check.exitCode) ? 'exit ' + check.exitCode : '', Number.isFinite(check.durationMs) ? check.durationMs + 'ms' : '', check.message || ''].filter(Boolean).join(', ')
55
+ ])).concat(checks.length > limit ? '\n- +' + (checks.length - limit) + ' more checks in JSON output/evidence data' : '');
56
+ }
57
+
58
+ function confidenceFor(result) {
59
+ if (result.failures?.length) return 'Blocked: at least one check failed. Fix failures before trusting this change.';
60
+ if (result.warnings?.length) return 'Good for local iteration. Some proof gaps remain before production confidence.';
61
+ if (result.skipped?.length || result.notVerified?.length) return 'Basic checks passed. Runtime or production verification may still be incomplete.';
62
+ return 'Strong local confidence for the checks opstruth can perform read-only.';
63
+ }
64
+
65
+ function summarizeChildren(result) {
66
+ const children = result.data?.childResults || [];
67
+ return table(['Area', 'Status', 'What happened'], children.map((child) => [
68
+ child.command,
69
+ statusLabel(child.status),
70
+ child.failures?.[0] || child.warnings?.[0] || child.skipped?.[0] || child.verified?.[0] || child.summary || 'Completed'
71
+ ]));
72
+ }
73
+
74
+ function orchestratorMarkdown(raw) {
75
+ const result = redactObject(raw);
76
+ const evidencePath = result.data?.evidence?.out;
77
+ return [
78
+ ASCII_HEADER,
79
+ '',
80
+ 'Welcome to opstruth.',
81
+ '',
82
+ 'This tool runs read-only operational checks to help you understand:',
83
+ '- what changed',
84
+ '- what is configured',
85
+ '- what looks risky',
86
+ '- what was verified',
87
+ '- what was not verified',
88
+ '',
89
+ 'It will not deploy, mutate databases, trigger jobs, publish content, restart services, call OpenAI, or print raw secrets.',
90
+ '',
91
+ '# opstruth',
92
+ '',
93
+ 'STATUS: ' + statusLabel(result.status),
94
+ '',
95
+ 'One-command operational truth check completed. opstruth only ran read-only checks.',
96
+ '',
97
+ '## What Matters Most',
98
+ list(result.failures, '- No failures', 5),
99
+ '',
100
+ '## Verified',
101
+ list(result.verified, '- None', 8),
102
+ '',
103
+ '## Warnings',
104
+ list(result.warnings, '- None', 8),
105
+ '',
106
+ '## Failures',
107
+ list(result.failures, '- None', 8),
108
+ '',
109
+ '## Skipped Or Not Configured',
110
+ list(result.skipped, '- None', 8),
111
+ '',
112
+ '## Not Verified',
113
+ list(result.notVerified, '- None', 8),
114
+ '',
115
+ '## Check Summary',
116
+ summarizeChildren(result),
117
+ '',
118
+ '## Why You Can Trust This Result',
119
+ '- Project boundary was resolved before stack detection',
120
+ '- Probes are read-only by default',
121
+ '- Warnings and failures include evidence where available',
122
+ '- Skipped checks are reported separately from failures',
123
+ '- Not verified is reported as a proof gap, not as safe',
124
+ '',
125
+ '## Evidence Available',
126
+ list((result.data?.probes?.selected || []).slice(0, 12).map((probe) => `${probe.id}: ${probe.evidenceCollected?.join(', ') || 'metadata'}`), '- None'),
127
+ '',
128
+ '## What opstruth Did Not Do',
129
+ '- No deploys were run',
130
+ '- No database mutations were run',
131
+ '- No queues or jobs were triggered',
132
+ '- No OpenAI or external AI API calls were made',
133
+ '- No raw secrets were printed',
134
+ '',
135
+ '## Overall Confidence',
136
+ confidenceFor(result),
137
+ '',
138
+ '## Evidence',
139
+ findingList(result.findings),
140
+ '',
141
+ evidencePath ? '- Evidence written to: ' + evidencePath : '- Evidence file was not written',
142
+ '',
143
+ '## Next Safe Step',
144
+ result.nextSafeStep || 'Run the narrowest missing read-only check with explicit inputs.'
145
+ ].join('\n') + '\n';
146
+ }
147
+
148
+ export function resultToMarkdown(raw) {
149
+ const result = redactObject(raw);
150
+ if (result.command === 'opstruth') return orchestratorMarkdown(result);
151
+ const title = 'opstruth ' + result.command;
152
+ const lines = ['# ' + title, '', 'STATUS: ' + statusLabel(result.status)];
153
+ if (result.summary) lines.push('', result.summary, '');
154
+ lines.push('## Verified', list(result.verified, '- None', 8), '', '## Warnings', list(result.warnings, '- None', 8), '', '## Failures', list(result.failures, '- None', 8), '', '## Skipped', list(result.skipped, '- None', 8), '', '## Not Verified', list(result.notVerified, '- None', 8), '', '## Checks', checkList(result.checks), '', '## Evidence', findingList(result.findings), '', '## Overall Confidence', confidenceFor(result), '', '## Next Safe Step', result.nextSafeStep || 'Review warnings and run the narrowest missing read-only check.');
155
+ return lines.join('\n') + '\n';
156
+ }
157
+
158
+ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'not_verified', scope = [], filesChanged = [], commandsRun = [], checks = [], liveVerification = [], safetyBoundaries = [], risks = [], nextSafeStep = '' } = {}) {
159
+ const riskRows = risks.length ? risks.map((risk) => ['Review', risk]) : [['None recorded', 'No warnings or failures were provided to this evidence pack']];
160
+ return [
161
+ '# ' + title,
162
+ '',
163
+ '## Status',
164
+ statusLabel(status),
165
+ '',
166
+ '## Operator Summary',
167
+ 'This evidence pack separates verified facts from unverified claims. opstruth is read-only and does not prove production state unless route or runtime checks were explicitly configured.',
168
+ '',
169
+ '## Scope',
170
+ list(scope, '- Not specified'),
171
+ '',
172
+ '## Files Changed',
173
+ list(filesChanged, '- No changed files detected', 20),
174
+ '',
175
+ '## Commands Run',
176
+ list(commandsRun, '- No command evidence attached', 20),
177
+ '',
178
+ '## Check Results',
179
+ list(checks, '- No checks attached', 25),
180
+ '',
181
+ '## Verified Facts',
182
+ list(liveVerification, '- No live verification evidence attached', 20),
183
+ '',
184
+ '## Risks And Gaps',
185
+ table(['Severity', 'Finding'], riskRows.slice(0, 25)),
186
+ '',
187
+ '## What Was Not Done',
188
+ '| Area | opstruth result |',
189
+ '| --- | --- |',
190
+ '| Jobs or queues | Not triggered by opstruth; not verified unless separate evidence is attached |',
191
+ '| OpenAI or external AI calls | Not called by opstruth; usage not monitored |',
192
+ '| Publishing | Not changed by opstruth |',
193
+ '| Deploys | No deploy command run by opstruth |',
194
+ '| Database mutations | No database mutation command run by opstruth |',
195
+ '',
196
+ '## Safety Boundaries',
197
+ list(safetyBoundaries, '- Read-only checks only'),
198
+ '',
199
+ '## Next Safe Step',
200
+ nextSafeStep || 'Run the narrowest missing read-only verification and attach the result.'
201
+ ].join('\n') + '\n';
202
+ }