@web42/stask 0.1.5

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * stask heartbeat — Get pending work for an agent (JSON output).
3
+ *
4
+ * Usage: stask heartbeat <agent-name>
5
+ *
6
+ * Session-aware: skips tasks claimed by other live sessions.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { execFileSync, execSync } from 'child_process';
12
+ import { CONFIG, LIB_DIR, getWorkspaceLibs } from '../lib/env.mjs';
13
+ import { withDb } from '../lib/tx.mjs';
14
+ import { syncTaskToSlack } from '../lib/slack-row.mjs';
15
+ import { isTaskClaimable } from '../lib/session-tracker.mjs';
16
+ import { getLeadAgent } from '../lib/roles.mjs';
17
+
18
+ // ─── PR Status Report Generation ───────────────────────────────────
19
+
20
+ const PR_STATUS_DIR = path.resolve(CONFIG.staskHome, 'pr-status');
21
+
22
+ function fetchCheckRuns(owner, repo, headSha) {
23
+ try {
24
+ const result = JSON.parse(execSync(
25
+ `gh api repos/${owner}/${repo}/commits/${headSha}/check-runs`,
26
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
27
+ ));
28
+ return (result.check_runs || []).map(cr => ({
29
+ name: cr.name, status: cr.status, conclusion: cr.conclusion,
30
+ }));
31
+ } catch { return []; }
32
+ }
33
+
34
+ function fetchAllPrComments(owner, repo, prNumber) {
35
+ try {
36
+ const issueComments = JSON.parse(execSync(
37
+ `gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`,
38
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
39
+ ));
40
+ const reviewComments = JSON.parse(execSync(
41
+ `gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`,
42
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
43
+ ));
44
+ const reviews = JSON.parse(execSync(
45
+ `gh api repos/${owner}/${repo}/pulls/${prNumber}/reviews --paginate`,
46
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
47
+ ));
48
+ return [
49
+ ...issueComments.map(c => ({
50
+ author: c.user?.login || 'unknown', body: c.body, path: null, line: null, createdAt: c.created_at,
51
+ })),
52
+ ...reviewComments.map(c => ({
53
+ author: c.user?.login || 'unknown', body: c.body, path: c.path, line: c.line || c.original_line, createdAt: c.created_at,
54
+ })),
55
+ ...reviews.filter(r => r.body && r.body.trim()).map(r => ({
56
+ author: r.user?.login || 'unknown', body: r.body, path: null, line: null,
57
+ createdAt: r.submitted_at, reviewState: r.state,
58
+ })),
59
+ ].sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
60
+ } catch { return []; }
61
+ }
62
+
63
+ function generatePrStatusMarkdown(task, prState, checkRuns, comments) {
64
+ const taskId = task['Task ID'];
65
+ const now = new Date().toISOString();
66
+
67
+ const lines = [
68
+ `# PR Status: ${taskId} — ${task['Task Name']}`,
69
+ '',
70
+ `**PR:** ${task['PR']}`,
71
+ `**State:** ${prState}`,
72
+ `**Assigned To:** ${task['Assigned To']}`,
73
+ `**Last Updated:** ${now}`,
74
+ '',
75
+ '## CI/CD Status', '',
76
+ ];
77
+
78
+ if (checkRuns.length === 0) {
79
+ lines.push('No check runs found.', '');
80
+ } else {
81
+ lines.push('| Check | Status | Conclusion |', '|---|---|---|');
82
+ for (const cr of checkRuns) {
83
+ const conclusion = cr.conclusion || 'pending';
84
+ const icon = conclusion === 'success' ? 'pass' : conclusion === 'failure' ? 'FAIL' : conclusion;
85
+ lines.push(`| ${cr.name} | ${cr.status} | ${icon} |`);
86
+ }
87
+ lines.push('');
88
+ }
89
+
90
+ lines.push('## Review Comments', '');
91
+ if (comments.length === 0) {
92
+ lines.push('No review comments yet.', '');
93
+ } else {
94
+ const byAuthor = {};
95
+ for (const c of comments) {
96
+ const a = c.author || 'unknown';
97
+ if (!byAuthor[a]) byAuthor[a] = [];
98
+ byAuthor[a].push(c);
99
+ }
100
+ for (const [author, authorComments] of Object.entries(byAuthor)) {
101
+ const isHuman = author === CONFIG.human.githubUsername;
102
+ lines.push(`### ${author}${isHuman ? ' (Human Reviewer)' : ''}`, '');
103
+ for (const c of authorComments) {
104
+ if (c.path) lines.push(`**${c.path}${c.line ? `:${c.line}` : ''}**`);
105
+ lines.push(`> ${c.body.replace(/\n/g, '\n> ')}`, '');
106
+ }
107
+ }
108
+ }
109
+
110
+ return lines.join('\n');
111
+ }
112
+
113
+ async function generateAndUploadPrStatus(task, prData, libs) {
114
+ const prMatch = task['PR'].match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
115
+ if (!prMatch) return;
116
+ const [, owner, repo, prNumber] = prMatch;
117
+
118
+ // Fetch head SHA for CI/CD check runs
119
+ let headSha = null;
120
+ let prState = prData.state || 'open';
121
+ try {
122
+ const prInfo = JSON.parse(execSync(
123
+ `gh api repos/${owner}/${repo}/pulls/${prNumber}`,
124
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
125
+ ));
126
+ headSha = prInfo.head?.sha;
127
+ prState = prInfo.state + (prInfo.draft ? ' (draft)' : '');
128
+ } catch {}
129
+
130
+ const checkRuns = headSha ? fetchCheckRuns(owner, repo, headSha) : [];
131
+ const allComments = fetchAllPrComments(owner, repo, prNumber);
132
+
133
+ const markdown = generatePrStatusMarkdown(task, prState, checkRuns, allComments);
134
+
135
+ // Write to disk
136
+ if (!fs.existsSync(PR_STATUS_DIR)) fs.mkdirSync(PR_STATUS_DIR, { recursive: true });
137
+ const fileName = `${task['Task ID']}.md`;
138
+ fs.writeFileSync(path.join(PR_STATUS_DIR, fileName), markdown);
139
+
140
+ // Upload to Slack and get file ID
141
+ const fileId = await libs.slackApi.uploadFile(fileName, markdown, 'text/plain');
142
+
143
+ // Update task with path + file ID (same format as Spec)
144
+ libs.trackerDb.updateTask(task['Task ID'], { pr_status: `pr-status/${fileName} (${fileId})` });
145
+
146
+ return fileId;
147
+ }
148
+
149
+ export async function run(argv) {
150
+ const agentName = argv[0];
151
+
152
+ if (!agentName) {
153
+ console.error('Usage: stask heartbeat <agent-name>');
154
+ process.exit(1);
155
+ }
156
+
157
+ const agentDisplayName = agentName.charAt(0).toUpperCase() + agentName.slice(1).toLowerCase();
158
+ const agentConfig = CONFIG.agents[agentName.toLowerCase()];
159
+
160
+ if (!agentConfig) {
161
+ console.error(`ERROR: Unknown agent "${agentName}". Known agents: ${Object.keys(CONFIG.agents).join(', ')}`);
162
+ process.exit(1);
163
+ }
164
+
165
+ const agentRole = agentConfig.role;
166
+ const leadName = getLeadAgent();
167
+
168
+ // Collect tasks that need PR status updates (async, done after withDb)
169
+ const prStatusQueue = [];
170
+
171
+ const result = await withDb((db, libs) => {
172
+ const allTasks = libs.trackerDb.getAllTasks();
173
+ const myTasks = allTasks.filter(t => t['Assigned To'] === agentDisplayName);
174
+
175
+ // Lead also monitors all RHR parent tasks (PR comment polling)
176
+ let rhrTasks = [];
177
+ if (agentRole === 'lead') {
178
+ rhrTasks = allTasks.filter(t =>
179
+ t['Status'] === 'Ready for Human Review' &&
180
+ t['Parent'] === 'None' &&
181
+ t['PR'] !== 'None' &&
182
+ t['Assigned To'] !== agentDisplayName // avoid duplicates
183
+ );
184
+ }
185
+
186
+ const tasksToCheck = [...myTasks, ...rhrTasks];
187
+
188
+ if (tasksToCheck.length === 0) {
189
+ return { agent: agentName, pendingTasks: [], config: { staleSessionMinutes: CONFIG.staleSessionMinutes } };
190
+ }
191
+
192
+ const pendingTasks = [];
193
+
194
+ for (const task of tasksToCheck) {
195
+ const taskId = task['Task ID'];
196
+ const status = task['Status'];
197
+ const isSubtask = task['Parent'] !== 'None';
198
+ const hasSubtasks = libs.trackerDb.getSubtasks(taskId).length > 0;
199
+
200
+ if (status === 'Done' || status === 'Blocked') continue;
201
+
202
+ // Session awareness: skip if claimed by another agent's live session
203
+ if (!isTaskClaimable(db, taskId, agentName)) continue;
204
+
205
+ const specParsed = libs.validate.parseSpecValue(task['Spec']);
206
+ const specFileId = specParsed?.fileId || 'unknown';
207
+
208
+ let action = null;
209
+ let prompt = null;
210
+
211
+ // ─── Lead actions ──────────────────────────────────────────
212
+ if (agentRole === 'lead') {
213
+ if (status === 'To-Do' && !hasSubtasks) {
214
+ action = 'delegate';
215
+ const workers = Object.entries(CONFIG.agents)
216
+ .filter(([, a]) => a.role === 'worker')
217
+ .map(([n]) => n.charAt(0).toUpperCase() + n.slice(1));
218
+ prompt = `Task ${taskId} "${task['Task Name']}" spec has been approved. Create subtasks and delegate to the appropriate builders (${workers.join(', ')}). Spec file ID: ${specFileId}. After creating subtasks, transition the parent to In-Progress.`;
219
+ } else if (status === 'Testing' && !isSubtask) {
220
+ // Richard is assigned a Testing task → QA passed, he needs to create PR
221
+ action = 'create-pr';
222
+ const wt = libs.trackerDb.getParentWorktree(taskId);
223
+ const wtInstruction = wt ? ` Worktree: ${wt.path} (branch: ${wt.branch}).` : '';
224
+
225
+ const qaReports = [task['QA Report 1'], task['QA Report 2'], task['QA Report 3']]
226
+ .filter(r => r !== 'None');
227
+
228
+ const subtasksList = libs.trackerDb.getSubtasks(taskId)
229
+ .map(s => `- ${s['Task ID']}: ${s['Task Name']} (${s['Assigned To']})`)
230
+ .join('\n');
231
+
232
+ prompt = `Task ${taskId} "${task['Task Name']}" has PASSED QA. Create a pull request and transition to Ready for Human Review.
233
+
234
+ SPEC: File ID ${specFileId}. Read it for the full context of what was built.
235
+
236
+ SUBTASKS COMPLETED:
237
+ ${subtasksList}
238
+
239
+ QA REPORT(S): ${qaReports.join(', ')}
240
+ Download and read the QA report(s) from Slack. Include test results and reference screenshots in the PR description.
241
+
242
+ WORKTREE:${wtInstruction}
243
+
244
+ YOUR JOB:
245
+ 1. Read the spec — understand what was built and why
246
+ 2. Read the QA report — what was tested, results, screenshots
247
+ 3. Review the git log: cd to worktree, run \`git log --oneline main..HEAD\`
248
+ 4. Review the diff: \`git diff main..HEAD --stat\`
249
+ 5. Write a rich PR description:
250
+ - **Summary:** what was built and why (from spec)
251
+ - **Changes:** key files changed and what each does (from diff)
252
+ - **Testing:** QA results — which ACs passed, reference screenshots (from QA report)
253
+ - **Acceptance Criteria:** checklist from spec, all checked off
254
+ 6. Create the draft PR:
255
+ \`gh pr create --base main --head <branch> --title "<concise title>" --body "<your description>" --draft\`
256
+ 7. Run: \`stask transition ${taskId} "Ready for Human Review"\`
257
+
258
+ The PR description is what Yan sees first. Make it count.`;
259
+ } else if (status === 'In-Progress' && !isSubtask && hasSubtasks) {
260
+ const taskLog = libs.trackerDb.getLogForTask(taskId);
261
+ const hasQaFail = taskLog.some(e => e.message.includes('QA FAIL'));
262
+ if (hasQaFail) {
263
+ action = 'review-qa-failure';
264
+ const wt = libs.trackerDb.getParentWorktree(taskId);
265
+ const wtInstruction = wt ? ` Worktree: ${wt.path} (branch: ${wt.branch}).` : '';
266
+ prompt = `Task ${taskId} "${task['Task Name']}" returned from QA failure. Review the latest QA report. Identify what failed, then re-delegate fixes. Spec file ID: ${specFileId}.${wtInstruction}`;
267
+ }
268
+ } else if (status === 'Ready for Human Review' && task['PR'] !== 'None') {
269
+ try {
270
+ const result = execFileSync(process.execPath, [path.join(LIB_DIR, 'pr-status.mjs'), taskId], {
271
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
272
+ });
273
+ const prData = JSON.parse(result);
274
+
275
+ // Queue PR status report generation (async, runs after withDb)
276
+ prStatusQueue.push({ task, prData });
277
+
278
+ if (prData.isMerged) {
279
+ // PR merged → auto-transition to Done
280
+ action = 'pr-merged';
281
+ prompt = `Task ${taskId} "${task['Task Name']}" PR has been merged: ${task['PR']}.\n\nRun: stask transition ${taskId} Done`;
282
+ } else if (prData.yanComments?.length > 0) {
283
+ // Yan has comments → always actionable, address the feedback
284
+ const wt = libs.trackerDb.getParentWorktree(taskId);
285
+ const wtInstruction = wt ? ` Work in worktree: ${wt.path} (branch: ${wt.branch}).` : '';
286
+ action = 'address-pr-feedback';
287
+ const commentSummary = prData.yanComments.map(c =>
288
+ `- ${c.path ? `${c.path}:${c.line}` : 'General'}: "${c.body.slice(0, 200)}"`
289
+ ).join('\n');
290
+
291
+ const workers = Object.entries(CONFIG.agents)
292
+ .filter(([, a]) => a.role === 'worker')
293
+ .map(([n]) => n.charAt(0).toUpperCase() + n.slice(1));
294
+
295
+ prompt = `Task ${taskId} "${task['Task Name']}" has ${prData.yanComments.length} PR feedback from ${CONFIG.human.name}:
296
+
297
+ ${commentSummary}
298
+
299
+ PR: ${task['PR']}${wtInstruction}
300
+
301
+ THIS IS A REVIEW CYCLE. Follow these steps exactly:
302
+
303
+ 1. Run: \`stask transition ${taskId} In-Progress\`
304
+ This moves the task back into the build cycle. The existing worktree, branch, and PR are preserved.
305
+
306
+ 2. Read each comment carefully. For each fix needed, create a subtask:
307
+ \`stask subtask create --parent ${taskId} --name "Fix: <description>" --assign <Worker>\`
308
+ Assign to the right builder: ${workers.join(' or ')}.
309
+
310
+ 3. Workers will pick up their subtasks on heartbeat.
311
+
312
+ 4. When all fix subtasks are Done, the task auto-transitions to Testing.
313
+ Jared will re-test — he can see the existing PR and prior QA report for context.
314
+
315
+ 5. After QA passes again, you create an updated PR description and transition to Ready for Human Review.
316
+
317
+ The PR stays open. The branch stays the same. This is a fix cycle, not a restart.`;
318
+ } else if (prData.otherComments?.length > 0) {
319
+ // External comments only → notify Yan, don't act
320
+ action = 'notify-external-comments';
321
+ const commentSummary = prData.otherComments.map(c =>
322
+ `- @${c.author}: "${c.body.slice(0, 100)}"`
323
+ ).join('\n');
324
+ prompt = `Task ${taskId} "${task['Task Name']}" has ${prData.otherComments.length} external comment(s) on PR ${task['PR']}:
325
+
326
+ ${commentSummary}
327
+
328
+ These are NOT from ${CONFIG.human.name}. Send a Slack DM to ${CONFIG.human.name} summarizing these comments and asking how to handle them. Do NOT act on them yourself.`;
329
+ }
330
+ } catch {}
331
+ }
332
+ }
333
+
334
+ // ─── Worker actions ────────────────────────────────────────
335
+ if (agentRole === 'worker' && status === 'In-Progress' && isSubtask) {
336
+ action = 'build';
337
+ const wt = libs.trackerDb.getParentWorktree(taskId);
338
+ const wtInstruction = wt ? `\nIMPORTANT: Work in the task worktree at: ${wt.path} (branch: ${wt.branch}). cd to that directory before making any changes.` : '';
339
+ prompt = `Build subtask ${taskId}: "${task['Task Name']}". Spec file ID: ${specFileId}. Read the spec from shared/specs/ for full details.${wtInstruction}\nWhen complete, run: stask subtask done ${taskId}`;
340
+ }
341
+
342
+ // ─── QA actions ────────────────────────────────────────────
343
+ if (agentRole === 'qa' && status === 'Testing' && !isSubtask) {
344
+ action = 'qa';
345
+ const wt = libs.trackerDb.getParentWorktree(taskId);
346
+ const wtInstruction = wt ? `\nIMPORTANT: The code to test is in the task worktree at: ${wt.path} (branch: ${wt.branch}).` : '';
347
+ prompt = `QA task ${taskId}: "${task['Task Name']}". Spec file ID: ${specFileId}. Read the spec for acceptance criteria.${wtInstruction}\nTest each AC via browser at http://localhost:3000. Write QA report to shared/qa-reports/. Submit via: stask qa ${taskId} --report shared/qa-reports/<your-report>.md --verdict <PASS|FAIL>`;
348
+ }
349
+
350
+ if (action && prompt) {
351
+ pendingTasks.push({ taskId, taskName: task['Task Name'], status, parent: task['Parent'], specFileId, action, prompt });
352
+ }
353
+ }
354
+
355
+ return { agent: agentName, pendingTasks, config: { staleSessionMinutes: CONFIG.staleSessionMinutes } };
356
+ });
357
+
358
+ // Process PR status reports (async — uploads to Slack, updates DB)
359
+ if (prStatusQueue.length > 0) {
360
+ const libs = await getWorkspaceLibs();
361
+ const db = libs.trackerDb.getDb();
362
+ for (const { task, prData } of prStatusQueue) {
363
+ try {
364
+ await generateAndUploadPrStatus(task, prData, libs);
365
+ const updatedTask = libs.trackerDb.findTask(task['Task ID']);
366
+ await syncTaskToSlack(db, updatedTask);
367
+ console.error(`PR status report updated for ${task['Task ID']}`);
368
+ } catch (err) {
369
+ console.error(`WARNING: PR status report failed for ${task['Task ID']}: ${err.message}`);
370
+ }
371
+ }
372
+ }
373
+
374
+ console.log(JSON.stringify(result, null, 2));
375
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * stask list — List tasks (filterable, table or JSON output).
3
+ *
4
+ * Usage: stask list [--status X] [--assignee Y] [--parent Z] [--json]
5
+ */
6
+
7
+ import { withDb } from '../lib/tx.mjs';
8
+
9
+ function parseArgs(argv) {
10
+ const args = {};
11
+ for (let i = 0; i < argv.length; i++) {
12
+ if (argv[i] === '--status' && argv[i + 1]) args.status = argv[++i];
13
+ else if (argv[i] === '--assignee' && argv[i + 1]) args.assignee = argv[++i];
14
+ else if (argv[i] === '--parent' && argv[i + 1]) args.parent = argv[++i];
15
+ else if (argv[i] === '--json') args.json = true;
16
+ }
17
+ return args;
18
+ }
19
+
20
+ export async function run(argv) {
21
+ const args = parseArgs(argv);
22
+
23
+ const tasks = await withDb((db, libs) => {
24
+ let rows = libs.trackerDb.getAllTasks();
25
+
26
+ if (args.status) rows = rows.filter(r => r['Status'] === args.status);
27
+ if (args.assignee) rows = rows.filter(r => r['Assigned To'].toLowerCase() === args.assignee.toLowerCase());
28
+ if (args.parent) rows = rows.filter(r => r['Parent'] === args.parent);
29
+
30
+ return rows;
31
+ });
32
+
33
+ if (args.json) {
34
+ console.log(JSON.stringify(tasks, null, 2));
35
+ return;
36
+ }
37
+
38
+ if (tasks.length === 0) {
39
+ console.log('No tasks found.');
40
+ return;
41
+ }
42
+
43
+ // Table output
44
+ const header = ['Task ID', 'Task Name', 'Status', 'Assigned To', 'Type', 'Parent'];
45
+ const widths = header.map((h, i) => {
46
+ const vals = tasks.map(t => String(t[h] || '').length);
47
+ return Math.max(h.length, ...vals);
48
+ });
49
+
50
+ const line = header.map((h, i) => h.padEnd(widths[i])).join(' ');
51
+ const sep = widths.map(w => '─'.repeat(w)).join('──');
52
+
53
+ console.log(line);
54
+ console.log(sep);
55
+ for (const t of tasks) {
56
+ const row = header.map((h, i) => String(t[h] || '').padEnd(widths[i])).join(' ');
57
+ console.log(row);
58
+ }
59
+ console.log(`\n${tasks.length} task(s)`);
60
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * stask log — View audit log.
3
+ *
4
+ * Usage: stask log [<task-id>] [--limit N]
5
+ */
6
+
7
+ import { withDb } from '../lib/tx.mjs';
8
+
9
+ export async function run(argv) {
10
+ let taskId = null;
11
+ let limit = 50;
12
+
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === '--limit' && argv[i + 1]) limit = parseInt(argv[++i], 10);
15
+ else if (!argv[i].startsWith('-')) taskId = argv[i];
16
+ }
17
+
18
+ await withDb((db, libs) => {
19
+ const entries = taskId
20
+ ? libs.trackerDb.getLogForTask(taskId)
21
+ : libs.trackerDb.getLog(limit);
22
+
23
+ if (entries.length === 0) {
24
+ console.log(taskId ? `No log entries for ${taskId}.` : 'No log entries.');
25
+ return;
26
+ }
27
+
28
+ const display = taskId ? entries.reverse() : entries;
29
+ for (const e of display) {
30
+ console.log(`[${e.created_at}] ${e.message}`);
31
+ }
32
+ console.log(`\n${display.length} entries`);
33
+ });
34
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * stask pr-status — Query PR status (comments, merge state) from GitHub.
3
+ *
4
+ * Usage: stask pr-status <task-id>
5
+ */
6
+
7
+ import path from 'path';
8
+ import { execFileSync } from 'child_process';
9
+ import { CONFIG, LIB_DIR, getWorkspaceLibs } from '../lib/env.mjs';
10
+
11
+ export async function run(argv) {
12
+ const taskId = argv[0];
13
+
14
+ if (!taskId) {
15
+ console.error('Usage: stask pr-status <task-id>');
16
+ process.exit(1);
17
+ }
18
+
19
+ const libs = await getWorkspaceLibs();
20
+ const task = libs.trackerDb.findTask(taskId);
21
+ if (!task) { console.error(`ERROR: Task ${taskId} not found`); process.exit(1); }
22
+ if (task['PR'] === 'None') { console.error(`ERROR: Task ${taskId} has no PR`); process.exit(1); }
23
+
24
+ try {
25
+ const result = execFileSync(process.execPath, [path.join(LIB_DIR, 'pr-status.mjs'), taskId], {
26
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
27
+ });
28
+ console.log(result.trim());
29
+ } catch (err) {
30
+ console.error(`ERROR: ${err.stderr || err.message}`);
31
+ process.exit(1);
32
+ }
33
+ }