shoud-cli 1.0.11 → 3.0.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.
@@ -1,143 +1,121 @@
1
- const open = require('open');
1
+ const axios = require('axios');
2
2
  const chalk = require('chalk');
3
+ const open = require('open');
3
4
  const inquirer = require('inquirer');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
-
8
- // Use a dedicated config directory
9
- const CONFIG_DIR = path.join(os.homedir(), '.shoud');
10
- const SESSION_FILE = path.join(CONFIG_DIR, 'session.json');
11
-
12
- // Ensure config directory exists with secure permissions
13
- if (!fs.existsSync(CONFIG_DIR)) {
14
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
15
- }
5
+ const ora = require('ora');
6
+ const { loadCredentials, saveCredentials, clearCredentials } = require('./credentials');
7
+ const { loadSettings } = require('../utils/config');
16
8
 
17
- /**
18
- * Decode a JWT token (without verification) to extract payload
19
- */
20
9
  function decodeJWT(token) {
21
10
  try {
22
11
  const parts = token.split('.');
23
12
  if (parts.length !== 3) return null;
24
- const payload = Buffer.from(parts[1], 'base64').toString('utf-8');
25
- return JSON.parse(payload);
26
- } catch (_) {
27
- return null;
28
- }
13
+ return JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
14
+ } catch (_) { return null; }
29
15
  }
30
16
 
31
17
  /**
32
- * Write token to session file with expiry
18
+ * Proper device flow: request device code from backend, open browser,
19
+ * poll for completion. Falls back to manual paste if backend doesn't
20
+ * support device flow yet (HTTP 404).
33
21
  */
34
- function saveToken(token) {
35
- const payload = decodeJWT(token);
36
- const expiresAt = payload?.exp ? payload.exp * 1000 : null; // convert to milliseconds
37
- const data = {
38
- token,
39
- expiresAt,
40
- createdAt: Date.now()
41
- };
22
+ async function login() {
23
+ const settings = loadSettings();
24
+ const apiUrl = settings.apiUrl;
25
+ const spinner = ora('Requesting device code...').start();
26
+
27
+ let deviceCode = null;
42
28
  try {
43
- fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), {
44
- encoding: 'utf-8',
45
- mode: 0o600
46
- });
29
+ const res = await axios.post(`${apiUrl}/auth/device/start`, {}, { timeout: 10000 });
30
+ deviceCode = res.data;
31
+ spinner.stop();
47
32
  } catch (err) {
48
- console.error(chalk.red(`Failed to save session: ${err.message}`));
49
- throw err;
33
+ spinner.stop();
34
+ // Backend not ready — fall back to manual paste
35
+ return manualLogin(apiUrl);
50
36
  }
51
- }
52
37
 
53
- /**
54
- * Retrieve token if present and not expired
55
- */
56
- function getToken() {
57
- if (!fs.existsSync(SESSION_FILE)) {
58
- return null;
59
- }
60
- try {
61
- const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
62
- const data = JSON.parse(raw);
63
- const { token, expiresAt } = data;
38
+ const { user_code, verification_uri, device_code, interval = 5, expires_in = 600 } = deviceCode;
64
39
 
65
- if (!token) return null;
40
+ console.log(chalk.cyan('\nTo authenticate, open this URL in your browser:'));
41
+ console.log(chalk.white.bold(` ${verification_uri}`));
42
+ console.log(chalk.cyan('\nAnd enter this code:'));
43
+ console.log(chalk.white.bold(` ${user_code}\n`));
66
44
 
67
- // If we have an expiry and it's in the past, token is invalid
68
- if (expiresAt && Date.now() >= expiresAt) {
69
- // Optionally delete the stale file
70
- fs.unlinkSync(SESSION_FILE);
71
- return null;
72
- }
45
+ try { await open(verification_uri); } catch (_) {}
73
46
 
74
- // If we don't have expiry, we can still try to decode now to check
75
- if (!expiresAt) {
76
- const payload = decodeJWT(token);
77
- if (payload?.exp) {
78
- const expMs = payload.exp * 1000;
79
- if (Date.now() >= expMs) {
80
- fs.unlinkSync(SESSION_FILE);
81
- return null;
82
- }
83
- }
84
- }
47
+ const pollSpinner = ora('Waiting for authentication...').start();
48
+ const deadline = Date.now() + expires_in * 1000;
85
49
 
86
- return token;
87
- } catch (_) {
88
- // Corrupted or invalid file, delete it
89
- try { fs.unlinkSync(SESSION_FILE); } catch (_) {}
90
- return null;
50
+ while (Date.now() < deadline) {
51
+ await new Promise(r => setTimeout(r, interval * 1000));
52
+ try {
53
+ const res = await axios.post(`${apiUrl}/auth/device/poll`, { device_code });
54
+ if (res.data?.token) {
55
+ pollSpinner.stop();
56
+ const payload = decodeJWT(res.data.token) || {};
57
+ await saveCredentials({
58
+ token: res.data.token,
59
+ email: payload.email,
60
+ expiresAt: payload.exp ? payload.exp * 1000 : null,
61
+ createdAt: Date.now(),
62
+ kind: 'device',
63
+ });
64
+ console.log(chalk.green(`\n✓ Signed in as ${payload.email || 'user'}`));
65
+ return true;
66
+ }
67
+ if (res.data?.error === 'access_denied') {
68
+ pollSpinner.fail('Authorization denied.');
69
+ return false;
70
+ }
71
+ } catch (_) { /* keep polling */ }
91
72
  }
73
+
74
+ pollSpinner.fail('Device code expired. Please try again.');
75
+ return false;
92
76
  }
93
77
 
94
- /**
95
- * Login flow: open browser, ask for token, save it
96
- */
97
- async function login() {
98
- console.log(chalk.cyan('Opening browser to sign in via shoud.online...'));
78
+ async function manualLogin(apiUrl) {
79
+ console.log(chalk.cyan('Opening browser to sign in...'));
99
80
  await open('https://shoud.online/login');
100
-
101
81
  console.log(chalk.gray('\nOnce signed in, copy your session token from the browser.'));
102
82
 
103
- const { token } = await inquirer.prompt([
104
- {
105
- type: 'password',
106
- name: 'token',
107
- message: 'Paste your session token:',
108
- mask: '*',
109
- validate: (input) => {
110
- if (!input || input.trim().length === 0) {
111
- return 'Token cannot be empty.';
112
- }
113
- // Simple validation: check if it looks like a JWT (three parts)
114
- const parts = input.trim().split('.');
115
- if (parts.length !== 3) {
116
- return 'Invalid token format. Please paste the full token from the browser.';
117
- }
118
- return true;
119
- }
83
+ const { token } = await inquirer.prompt([{
84
+ type: 'password', name: 'token',
85
+ message: 'Paste your session token:',
86
+ mask: '*',
87
+ validate: (input) => {
88
+ if (!input?.trim()) return 'Token cannot be empty.';
89
+ if (input.trim().split('.').length !== 3) return 'Invalid token format.';
90
+ return true;
120
91
  }
121
- ]);
92
+ }]);
93
+
94
+ const payload = decodeJWT(token.trim());
95
+ await saveCredentials({
96
+ token: token.trim(),
97
+ email: payload?.email,
98
+ expiresAt: payload?.exp ? payload.exp * 1000 : null,
99
+ createdAt: Date.now(),
100
+ kind: 'manual',
101
+ });
102
+ console.log(chalk.green('\n✓ Signed in successfully.'));
103
+ return true;
104
+ }
122
105
 
123
- if (token) {
124
- saveToken(token.trim());
125
- console.log(chalk.green('\n✓ Signed in successfully. Device connected.'));
126
- } else {
127
- console.log(chalk.red('\nLogin cancelled.'));
106
+ async function getToken() {
107
+ const creds = await loadCredentials();
108
+ if (!creds?.token) return null;
109
+ if (creds.expiresAt && Date.now() >= creds.expiresAt) {
110
+ await clearCredentials();
111
+ return null;
128
112
  }
113
+ return creds.token;
129
114
  }
130
115
 
131
- /**
132
- * Validate token by checking with the backend (optional, can be used before API calls)
133
- * For now, we just rely on getToken() returning null if expired.
134
- */
135
- async function validateToken() {
136
- const token = getToken();
137
- if (!token) return false;
138
- // Could make a lightweight request to backend to verify (e.g., /auth/verify)
139
- // but we assume token is valid if not expired.
140
- return true;
116
+ async function getEmail() {
117
+ const creds = await loadCredentials();
118
+ return creds?.email || null;
141
119
  }
142
120
 
143
- module.exports = { login, getToken, validateToken, saveToken };
121
+ module.exports = { login, getToken, getEmail, clearCredentials };
@@ -0,0 +1,82 @@
1
+ const { execSync } = require('child_process');
2
+ const chalk = require('chalk');
3
+
4
+ function tryExec(cmd, opts = {}) {
5
+ try {
6
+ const out = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', timeout: 60000, ...opts });
7
+ return { ok: true, output: out.trim() };
8
+ } catch (err) {
9
+ return {
10
+ ok: false,
11
+ output: (err.stdout || '').toString().trim(),
12
+ stderr: (err.stderr || '').toString().trim(),
13
+ code: err.status,
14
+ };
15
+ }
16
+ }
17
+
18
+ function gitState() {
19
+ const status = tryExec('git status --porcelain');
20
+ const diff = tryExec('git diff --stat');
21
+ const head = tryExec('git rev-parse HEAD');
22
+ const untracked = (status.ok ? status.output.split('\n') : [])
23
+ .filter(l => l.startsWith('?? '))
24
+ .map(l => l.slice(3).trim());
25
+ const modified = (status.ok ? status.output.split('\n') : [])
26
+ .filter(l => l.match(/^ ?M /))
27
+ .map(l => l.slice(3).trim());
28
+
29
+ return {
30
+ head: head.ok ? head.output : null,
31
+ dirty: status.ok && status.output.length > 0,
32
+ modifiedFiles: modified,
33
+ untrackedFiles: untracked,
34
+ diffStat: diff.ok ? diff.output : '',
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Collect the before-state of the project. Runs verification commands
40
+ * (from SHOUD.md / discovery) and records pass/fail.
41
+ * Intentionally tolerant: a failing baseline is not an error.
42
+ */
43
+ async function collectBaseline(project, verificationCommands, { onProgress } = {}) {
44
+ const baseline = {
45
+ collectedAt: new Date().toISOString(),
46
+ git: gitState(),
47
+ checks: [],
48
+ };
49
+
50
+ for (const cmd of verificationCommands || []) {
51
+ if (onProgress) onProgress(cmd);
52
+ const result = tryExec(cmd, { timeout: 300000 });
53
+ baseline.checks.push({
54
+ command: cmd,
55
+ ok: result.ok,
56
+ exitCode: result.ok ? 0 : result.code,
57
+ summary: summarize(result),
58
+ });
59
+ }
60
+
61
+ return baseline;
62
+ }
63
+
64
+ function summarize(result) {
65
+ const text = (result.output || result.stderr || '').split('\n').slice(-20).join('\n');
66
+ return text.length > 2000 ? text.slice(-2000) : text;
67
+ }
68
+
69
+ function formatBaseline(baseline) {
70
+ const lines = [chalk.bold('Baseline collected')];
71
+ if (baseline.git.dirty) {
72
+ lines.push(` Git: ${baseline.git.modifiedFiles.length} modified, ${baseline.git.untrackedFiles.length} untracked`);
73
+ } else {
74
+ lines.push(' Git: clean');
75
+ }
76
+ for (const c of baseline.checks) {
77
+ lines.push(` ${c.ok ? chalk.green('✓') : chalk.red('✗')} ${c.command}`);
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+
82
+ module.exports = { collectBaseline, gitState, formatBaseline, tryExec };
@@ -0,0 +1,61 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const { paths } = require('../utils/config');
5
+
6
+ /**
7
+ * A code checkpoint is a git patch file stored inside the job directory.
8
+ * It captures the *delta introduced by SHOUD* so `shoud undo` can reverse it
9
+ * without touching pre-existing user modifications.
10
+ */
11
+ function createCheckpoint(jobId, projectRoot) {
12
+ const dir = paths.jobDir(jobId);
13
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
14
+
15
+ const beforePath = path.join(dir, 'before.patch');
16
+ const headPath = path.join(dir, 'head.txt');
17
+ const untrackedPath = path.join(dir, 'untracked.json');
18
+
19
+ const run = (cmd) => {
20
+ try { return execSync(cmd, { cwd: projectRoot, encoding: 'utf-8', stdio: 'pipe' }).trim(); }
21
+ catch (err) { return (err.stdout || '').toString().trim(); }
22
+ };
23
+
24
+ const head = run('git rev-parse HEAD');
25
+ fs.writeFileSync(headPath, head, { mode: 0o600 });
26
+
27
+ // Save tracked changes as a patch
28
+ const diff = run('git diff HEAD');
29
+ fs.writeFileSync(beforePath, diff, { mode: 0o600 });
30
+
31
+ // Save list of untracked files so we know what to remove on undo
32
+ const status = run('git status --porcelain');
33
+ const untracked = status.split('\n')
34
+ .filter(l => l.startsWith('?? '))
35
+ .map(l => l.slice(3).trim());
36
+ fs.writeFileSync(untrackedPath, JSON.stringify(untracked, null, 2), { mode: 0o600 });
37
+
38
+ return {
39
+ head,
40
+ beforePatch: beforePath,
41
+ untrackedList: untrackedPath,
42
+ hadUserChanges: diff.length > 0 || untracked.length > 0,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Captures the state *after* SHOUD's work, as a patch we can apply/invert.
48
+ */
49
+ function finalizeCheckpoint(jobId, projectRoot) {
50
+ const dir = paths.jobDir(jobId);
51
+ const afterPath = path.join(dir, 'after.patch');
52
+ const run = (cmd) => {
53
+ try { return execSync(cmd, { cwd: projectRoot, encoding: 'utf-8', stdio: 'pipe' }).trim(); }
54
+ catch (err) { return (err.stdout || '').toString().trim(); }
55
+ };
56
+ const diff = run('git diff HEAD');
57
+ fs.writeFileSync(afterPath, diff, { mode: 0o600 });
58
+ return { afterPatch: afterPath };
59
+ }
60
+
61
+ module.exports = { createCheckpoint, finalizeCheckpoint };
@@ -0,0 +1,89 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { paths, initDirs } = require('../utils/config');
4
+ const { createJob, JobStatus } = require('./model');
5
+
6
+ initDirs();
7
+
8
+ function jobExists(id) {
9
+ return fs.existsSync(paths.jobDir(id));
10
+ }
11
+
12
+ function saveJob(job) {
13
+ const dir = paths.jobDir(job.id);
14
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
15
+
16
+ // state.json holds the structured job (no messages, no actions)
17
+ const { messages, actions, ...state } = job;
18
+ fs.writeFileSync(paths.jobFile(job.id, 'state.json'),
19
+ JSON.stringify(state, null, 2), { mode: 0o600 });
20
+
21
+ if (messages) {
22
+ fs.writeFileSync(paths.jobFile(job.id, 'messages.json'),
23
+ JSON.stringify(messages, null, 2), { mode: 0o600 });
24
+ }
25
+ return job;
26
+ }
27
+
28
+ function appendAction(jobId, action) {
29
+ const file = paths.jobFile(jobId, 'actions.jsonl');
30
+ fs.appendFileSync(file, JSON.stringify(action) + '\n', { mode: 0o600 });
31
+ }
32
+
33
+ function appendMessage(jobId, message) {
34
+ const file = paths.jobFile(jobId, 'messages.jsonl');
35
+ fs.appendFileSync(file, JSON.stringify(message) + '\n', { mode: 0o600 });
36
+ }
37
+
38
+ function loadJob(id) {
39
+ const dir = paths.jobDir(id);
40
+ if (!fs.existsSync(dir)) return null;
41
+
42
+ const stateRaw = fs.readFileSync(path.join(dir, 'state.json'), 'utf-8');
43
+ const state = JSON.parse(stateRaw);
44
+
45
+ let messages = [];
46
+ const msgFile = path.join(dir, 'messages.json');
47
+ if (fs.existsSync(msgFile)) {
48
+ try { messages = JSON.parse(fs.readFileSync(msgFile, 'utf-8')); } catch (_) { messages = []; }
49
+ }
50
+
51
+ let actions = [];
52
+ const actFile = path.join(dir, 'actions.jsonl');
53
+ if (fs.existsSync(actFile)) {
54
+ actions = fs.readFileSync(actFile, 'utf-8')
55
+ .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
56
+ }
57
+
58
+ return { ...state, messages, actions };
59
+ }
60
+
61
+ function listJobs() {
62
+ if (!fs.existsSync(paths.JOBS_DIR)) return [];
63
+ return fs.readdirSync(paths.JOBS_DIR)
64
+ .filter(name => name.startsWith('job_'))
65
+ .map(id => {
66
+ try {
67
+ const s = JSON.parse(fs.readFileSync(paths.jobFile(id, 'state.json'), 'utf-8'));
68
+ return {
69
+ id, prompt: s.prompt, status: s.status,
70
+ startedAt: s.startedAt, completedAt: s.completedAt,
71
+ spent: s.spent,
72
+ };
73
+ } catch { return null; }
74
+ })
75
+ .filter(Boolean)
76
+ .sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || ''));
77
+ }
78
+
79
+ function findResumableJob() {
80
+ return listJobs().find(j => ![
81
+ JobStatus.VERIFIED, JobStatus.FAILED, JobStatus.CANCELLED,
82
+ JobStatus.BLOCKED, JobStatus.BUDGET_EXCEEDED
83
+ ].includes(j.status));
84
+ }
85
+
86
+ module.exports = {
87
+ saveJob, loadJob, listJobs, findResumableJob,
88
+ appendAction, appendMessage, jobExists,
89
+ };
@@ -0,0 +1,54 @@
1
+ const crypto = require('crypto');
2
+
3
+ const JobStatus = Object.freeze({
4
+ PENDING: 'pending',
5
+ DISCOVERING: 'discovering',
6
+ BASELINING: 'baselining',
7
+ RUNNING: 'running',
8
+ VERIFYING: 'verifying',
9
+ REVIEWING: 'reviewing',
10
+ VERIFIED: 'verified',
11
+ IMPLEMENTED_NOT_VERIFIED: 'implemented_not_verified',
12
+ BLOCKED: 'blocked',
13
+ FAILED: 'failed',
14
+ CANCELLED: 'cancelled',
15
+ BUDGET_EXCEEDED: 'budget_exceeded',
16
+ PERMISSION_DENIED: 'permission_denied',
17
+ });
18
+
19
+ function newJobId() {
20
+ return 'job_' + crypto.randomBytes(4).toString('hex');
21
+ }
22
+
23
+ function createJob({ prompt, projectRoot, budget, verification }) {
24
+ return {
25
+ id: newJobId(),
26
+ version: 2,
27
+ prompt,
28
+ status: JobStatus.PENDING,
29
+ startedAt: new Date().toISOString(),
30
+ completedAt: null,
31
+ projectRoot,
32
+ budget: budget ?? 2.0,
33
+ spent: 0,
34
+ project: null, // filled by discovery
35
+ baseline: null, // filled by baseline
36
+ plan: [],
37
+ messages: [],
38
+ actions: [], // one entry per tool execution
39
+ changedFiles: [],
40
+ verification: {
41
+ commands: verification || [],
42
+ results: [],
43
+ },
44
+ permissions: {
45
+ persistentAllow: { commands: [], paths: [] },
46
+ sessionAllow: { commands: [], paths: [] },
47
+ },
48
+ checkpoint: null, // path to git patch or stash ref
49
+ completion: null, // final receipt object
50
+ error: null,
51
+ };
52
+ }
53
+
54
+ module.exports = { JobStatus, newJobId, createJob };
@@ -0,0 +1,92 @@
1
+ const chalk = require('chalk');
2
+ const { JobStatus } = require('./model');
3
+
4
+ const STATUS_LABEL = {
5
+ [JobStatus.VERIFIED]: chalk.green('✓ VERIFIED'),
6
+ [JobStatus.IMPLEMENTED_NOT_VERIFIED]: chalk.yellow('⚠ IMPLEMENTED (not verified)'),
7
+ [JobStatus.BLOCKED]: chalk.yellow('■ BLOCKED'),
8
+ [JobStatus.FAILED]: chalk.red('✗ FAILED'),
9
+ [JobStatus.CANCELLED]: chalk.gray('· CANCELLED'),
10
+ [JobStatus.BUDGET_EXCEEDED]: chalk.magenta('$ BUDGET EXCEEDED'),
11
+ [JobStatus.PERMISSION_DENIED]: chalk.red('✗ PERMISSION DENIED'),
12
+ };
13
+
14
+ function delta(before, after) {
15
+ if (!before || !after) return '';
16
+ if (before === after) return chalk.gray(`${before} → ${after}`);
17
+ const improved = isImprovement(before, after);
18
+ const arrow = improved ? chalk.green : chalk.red;
19
+ return `${chalk.gray(before)} ${arrow('→')} ${arrow(after)}`;
20
+ }
21
+
22
+ function isImprovement(before, after) {
23
+ // Heuristic: "pass", "0 errors", "✓" are good
24
+ const good = /^(0|pass|passed|ok|✓|clean)/i.test(String(after));
25
+ const bad = /^(fail|failed|error|✗|/i.test(String(before)) && /^\d+/.test(String(before));
26
+ return good || bad;
27
+ }
28
+
29
+ function formatReceipt(job) {
30
+ const lines = [];
31
+ const W = 60;
32
+ const bar = '─'.repeat(W);
33
+
34
+ lines.push(chalk.bold(bar));
35
+ lines.push(chalk.bold(`SHOUD ${STATUS_LABEL[job.status] || job.status} ${chalk.gray(job.id)}`));
36
+ lines.push('');
37
+ lines.push(chalk.white(job.prompt));
38
+ lines.push('');
39
+
40
+ // Results
41
+ if (job.baseline && job.baseline.checks?.length) {
42
+ lines.push(chalk.bold('RESULT'));
43
+ for (const b of job.baseline.checks) {
44
+ const after = job.verification.results.find(r => r.command === b.command);
45
+ const beforeStr = b.ok ? 'pass' : 'fail';
46
+ const afterStr = after ? (after.ok ? 'pass' : 'fail') : '—';
47
+ lines.push(` ${b.command.padEnd(30)} ${delta(beforeStr, afterStr)}`);
48
+ }
49
+ lines.push('');
50
+ }
51
+
52
+ // Changes
53
+ const changed = job.changedFiles || [];
54
+ if (changed.length) {
55
+ lines.push(chalk.bold('CHANGES'));
56
+ lines.push(` ${changed.length} file${changed.length === 1 ? '' : 's'}`);
57
+ for (const f of changed.slice(0, 12)) lines.push(chalk.gray(` ${f}`));
58
+ if (changed.length > 12) lines.push(chalk.gray(` ... and ${changed.length - 12} more`));
59
+ lines.push('');
60
+ }
61
+
62
+ // Verification
63
+ if (job.verification.results.length) {
64
+ lines.push(chalk.bold('VERIFICATION'));
65
+ for (const r of job.verification.results) {
66
+ lines.push(` ${r.ok ? chalk.green('✓') : chalk.red('✗')} ${r.command}`);
67
+ }
68
+ lines.push('');
69
+ }
70
+
71
+ // Safety
72
+ const safety = [];
73
+ if (job.baseline?.git?.modifiedFiles?.length) safety.push('Pre-existing changes preserved');
74
+ safety.push('No external writes performed');
75
+ lines.push(chalk.bold('SAFETY'));
76
+ for (const s of safety) lines.push(` ${chalk.green('✓')} ${s}`);
77
+ lines.push('');
78
+
79
+ // Time and cost
80
+ const elapsed = job.completedAt
81
+ ? ((new Date(job.completedAt) - new Date(job.startedAt)) / 1000).toFixed(0) + 's'
82
+ : '—';
83
+ lines.push(chalk.bold('TIME ') + chalk.gray(elapsed) +
84
+ ' ' + chalk.bold('COST ') + chalk.gray(`$${(job.spent || 0).toFixed(4)}`));
85
+ lines.push('');
86
+ lines.push(chalk.gray(`Undo with: shoud undo ${job.id}`));
87
+ lines.push(chalk.bold(bar));
88
+
89
+ return lines.join('\n');
90
+ }
91
+
92
+ module.exports = { formatReceipt };