@apmantza/greedysearch-pi 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,195 +1,167 @@
1
- #!/usr/bin/env node
2
- // extractors/bing-copilot.mjs
3
- // Navigate copilot.microsoft.com, wait for answer to complete, return clean answer + sources.
4
- //
5
- // Usage:
6
- // node extractors/bing-copilot.mjs "<query>" [--tab <prefix>]
7
- //
8
- // Output (stdout): JSON { answer, sources, query, url }
9
- // Errors go to stderr only stdout is always clean JSON for piping.
10
-
11
- import { readFileSync, existsSync } from 'fs';
12
- import { spawn } from 'child_process';
13
- import { tmpdir } from 'os';
14
- import { join, dirname } from 'path';
15
- import { fileURLToPath } from 'url';
16
- import { dismissConsent, handleVerification } from './consent.mjs';
17
- import { SELECTORS } from './selectors.mjs';
18
-
19
- const __dir = dirname(fileURLToPath(import.meta.url));
20
- const CDP = join(__dir, '..', 'cdp.mjs');
21
- const PAGES_CACHE = `${tmpdir().replace(/\\/g, '/')}/cdp-pages.json`;
22
-
23
- const COPY_POLL_INTERVAL = 700;
24
- const COPY_TIMEOUT = 60000;
25
-
26
- const S = SELECTORS.bing;
27
-
28
- // ---------------------------------------------------------------------------
29
-
30
- function cdp(args, timeoutMs = 30000) {
31
- return new Promise((resolve, reject) => {
32
- const proc = spawn('node', [CDP, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
33
- let out = '';
34
- let err = '';
35
- proc.stdout.on('data', d => out += d);
36
- proc.stderr.on('data', d => err += d);
37
- const timer = setTimeout(() => { proc.kill(); reject(new Error(`cdp timeout: ${args[0]}`)); }, timeoutMs);
38
- proc.on('close', code => {
39
- clearTimeout(timer);
40
- if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
41
- else resolve(out.trim());
42
- });
43
- });
44
- }
45
-
46
- async function getOrOpenTab(tabPrefix) {
47
- if (tabPrefix) return tabPrefix;
48
- // Always open a fresh tab to avoid SPA navigation issues
49
- const list = await cdp(['list']);
50
- const anchor = list.split('\n')[0]?.slice(0, 8);
51
- if (!anchor) throw new Error('No Chrome tabs found. Is Chrome running with --remote-debugging-port=9222?');
52
- const raw = await cdp(['evalraw', anchor, 'Target.createTarget', '{"url":"about:blank"}']);
53
- const { targetId } = JSON.parse(raw);
54
- await cdp(['list']); // refresh cache
55
- return targetId.slice(0, 8);
56
- }
57
-
58
- async function injectClipboardInterceptor(tab) {
59
- await cdp(['eval', tab, `
60
- window.__bingClipboard = null;
61
- const _origWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
62
- navigator.clipboard.writeText = function(text) {
63
- window.__bingClipboard = text;
64
- return _origWriteText(text);
65
- };
66
- const _origWrite = navigator.clipboard.write.bind(navigator.clipboard);
67
- navigator.clipboard.write = async function(items) {
68
- try {
69
- for (const item of items) {
70
- if (item.types && item.types.includes('text/plain')) {
71
- const blob = await item.getType('text/plain');
72
- window.__bingClipboard = await blob.text();
73
- break;
74
- }
75
- }
76
- } catch(e) {}
77
- return _origWrite(items);
78
- };
79
- `]);
80
- }
81
-
82
- async function waitForCopyButton(tab) {
83
- const deadline = Date.now() + COPY_TIMEOUT;
84
- while (Date.now() < deadline) {
85
- await new Promise(r => setTimeout(r, COPY_POLL_INTERVAL));
86
- const found = await cdp(['eval', tab,
87
- `!!document.querySelector('${S.copyButton}')`
88
- ]).catch(() => 'false');
89
- if (found === 'true') return;
90
- }
91
- throw new Error(`Copilot copy button did not appear within ${COPY_TIMEOUT}ms`);
92
- }
93
-
94
- async function extractAnswer(tab) {
95
- await cdp(['eval', tab, `document.querySelector('${S.copyButton}')?.click()`]);
96
- await new Promise(r => setTimeout(r, 400));
97
-
98
- const answer = await cdp(['eval', tab, `window.__bingClipboard || ''`]);
99
- if (!answer) throw new Error('Clipboard interceptor returned empty text');
100
-
101
- // Regex parse Markdown links from clipboard — robust against DOM changes
102
- const sources = Array.from(answer.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s\)]+)\)/g))
103
- .map(m => ({ title: m[1], url: m[2] }))
104
- .filter((v, i, arr) => arr.findIndex(x => x.url === v.url) === i)
105
- .slice(0, 10);
106
-
107
- return { answer: answer.trim(), sources };
108
- }
109
-
110
- // ---------------------------------------------------------------------------
111
-
112
- async function main() {
113
- const args = process.argv.slice(2);
114
- if (!args.length || args[0] === '--help') {
115
- process.stderr.write('Usage: node extractors/bing-copilot.mjs "<query>" [--tab <prefix>]\n');
116
- process.exit(1);
117
- }
118
-
119
- const short = args.includes('--short');
120
- const rest = args.filter(a => a !== '--short');
121
- const tabFlagIdx = rest.indexOf('--tab');
122
- const tabPrefix = tabFlagIdx !== -1 ? rest[tabFlagIdx + 1] : null;
123
- const query = tabFlagIdx !== -1
124
- ? rest.filter((_, i) => i !== tabFlagIdx && i !== tabFlagIdx + 1).join(' ')
125
- : rest.join(' ');
126
-
127
- try {
128
- await cdp(['list']);
129
- const tab = await getOrOpenTab(tabPrefix);
130
-
131
- // Navigate to Copilot homepage and use the chat input
132
- await cdp(['nav', tab, 'https://copilot.microsoft.com/'], 35000);
133
- await new Promise(r => setTimeout(r, 2000));
134
- await dismissConsent(tab, cdp);
135
-
136
- // Handle verification challenges (Cloudflare Turnstile, Microsoft auth, etc.)
137
- const verifyResult = await handleVerification(tab, cdp, 90000);
138
- if (verifyResult === 'needs-human') {
139
- throw new Error('Copilot verification required — please solve it manually in the browser window');
140
- }
141
-
142
- // After verification, page may have redirected or reloaded — wait for it to settle
143
- if (verifyResult === 'clicked') {
144
- await new Promise(r => setTimeout(r, 3000));
145
-
146
- // Re-navigate if we got redirected
147
- const currentUrl = await cdp(['eval', tab, 'document.location.href']).catch(() => '');
148
- if (!currentUrl.includes('copilot.microsoft.com')) {
149
- await cdp(['nav', tab, 'https://copilot.microsoft.com/'], 35000);
150
- await new Promise(r => setTimeout(r, 2000));
151
- await dismissConsent(tab, cdp);
152
- }
153
- }
154
-
155
- // Wait for React app to mount input (up to 15s, longer after verification)
156
- const inputDeadline = Date.now() + 15000;
157
- while (Date.now() < inputDeadline) {
158
- const found = await cdp(['eval', tab, `!!document.querySelector('${S.input}')`]).catch(() => 'false');
159
- if (found === 'true') break;
160
- await new Promise(r => setTimeout(r, 500));
161
- }
162
- await new Promise(r => setTimeout(r, 300));
163
-
164
- // Verify input is actually there before proceeding
165
- const inputReady = await cdp(['eval', tab, `!!document.querySelector('${S.input}')`]).catch(() => 'false');
166
- if (inputReady !== 'true') {
167
- throw new Error('Copilot input not found — verification may have failed or page is in unexpected state');
168
- }
169
-
170
- await injectClipboardInterceptor(tab);
171
- await cdp(['click', tab, S.input]);
172
- await new Promise(r => setTimeout(r, 400));
173
- await cdp(['type', tab, query]);
174
- await new Promise(r => setTimeout(r, 400));
175
-
176
- // Submit with Enter (most reliable across locales and Chrome instances)
177
- await cdp(['eval', tab,
178
- `document.querySelector('${S.input}')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,keyCode:13})), 'ok'`
179
- ]);
180
-
181
- await waitForCopyButton(tab);
182
-
183
- const { answer, sources } = await extractAnswer(tab);
184
- if (!answer) throw new Error('No answer extracted — Copilot may not have responded');
185
- const out = short ? answer.slice(0, 300).replace(/\s+\S*$/, '') + '…' : answer;
186
-
187
- const finalUrl = await cdp(['eval', tab, 'document.location.href']).catch(() => '');
188
- process.stdout.write(JSON.stringify({ query, url: finalUrl, answer: out, sources }, null, 2) + '\n');
189
- } catch (e) {
190
- process.stderr.write(`Error: ${e.message}\n`);
191
- process.exit(1);
192
- }
193
- }
194
-
195
- main();
1
+ #!/usr/bin/env node
2
+
3
+ // extractors/bing-copilot.mjs
4
+ // Navigate copilot.microsoft.com, wait for answer to complete, return clean answer + sources.
5
+ //
6
+ // Usage:
7
+ // node extractors/bing-copilot.mjs "<query>" [--tab <prefix>]
8
+ //
9
+ // Output (stdout): JSON { answer, sources, query, url }
10
+ // Errors go to stderr only — stdout is always clean JSON for piping.
11
+
12
+ import {
13
+ cdp,
14
+ formatAnswer,
15
+ getOrOpenTab,
16
+ handleError,
17
+ injectClipboardInterceptor,
18
+ outputJson,
19
+ parseArgs,
20
+ parseSourcesFromMarkdown,
21
+ validateQuery,
22
+ } from "./common.mjs";
23
+ import { dismissConsent, handleVerification } from "./consent.mjs";
24
+ import { SELECTORS } from "./selectors.mjs";
25
+
26
+ const S = SELECTORS.bing;
27
+ const GLOBAL_VAR = "__bingClipboard";
28
+
29
+ // ============================================================================
30
+ // Bing Copilot-specific helpers
31
+ // ============================================================================
32
+
33
+ async function waitForCopyButton(tab, timeout = 60000) {
34
+ const deadline = Date.now() + timeout;
35
+ while (Date.now() < deadline) {
36
+ await new Promise((r) => setTimeout(r, 700));
37
+ const found = await cdp([
38
+ "eval",
39
+ tab,
40
+ `!!document.querySelector('${S.copyButton}')`,
41
+ ]).catch(() => "false");
42
+ if (found === "true") return;
43
+ }
44
+ throw new Error(`Copilot copy button did not appear within ${timeout}ms`);
45
+ }
46
+
47
+ async function extractAnswer(tab) {
48
+ await cdp([
49
+ "eval",
50
+ tab,
51
+ `document.querySelector('${S.copyButton}')?.click()`,
52
+ ]);
53
+ await new Promise((r) => setTimeout(r, 400));
54
+
55
+ const answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
56
+ if (!answer) throw new Error("Clipboard interceptor returned empty text");
57
+
58
+ const sources = parseSourcesFromMarkdown(answer);
59
+ return { answer: answer.trim(), sources };
60
+ }
61
+
62
+ // ============================================================================
63
+ // Main
64
+ // ============================================================================
65
+
66
+ const USAGE =
67
+ 'Usage: node extractors/bing-copilot.mjs "<query>" [--tab <prefix>]\n';
68
+
69
+ async function main() {
70
+ const args = process.argv.slice(2);
71
+ validateQuery(args, USAGE);
72
+
73
+ const { query, tabPrefix, short } = parseArgs(args);
74
+
75
+ try {
76
+ await cdp(["list"]);
77
+ const tab = await getOrOpenTab(tabPrefix);
78
+
79
+ // Navigate to Copilot homepage and use the chat input
80
+ await cdp(["nav", tab, "https://copilot.microsoft.com/"], 35000);
81
+ await new Promise((r) => setTimeout(r, 2000));
82
+ await dismissConsent(tab, cdp);
83
+
84
+ // Handle verification challenges (Cloudflare Turnstile, Microsoft auth, etc.)
85
+ const verifyResult = await handleVerification(tab, cdp, 90000);
86
+ if (verifyResult === "needs-human") {
87
+ throw new Error(
88
+ "Copilot verification required — please solve it manually in the browser window",
89
+ );
90
+ }
91
+
92
+ // After verification, page may have redirected or reloaded — wait for it to settle
93
+ if (verifyResult === "clicked") {
94
+ await new Promise((r) => setTimeout(r, 3000));
95
+
96
+ // Re-navigate if we got redirected
97
+ const currentUrl = await cdp([
98
+ "eval",
99
+ tab,
100
+ "document.location.href",
101
+ ]).catch(() => "");
102
+ if (!currentUrl.includes("copilot.microsoft.com")) {
103
+ await cdp(["nav", tab, "https://copilot.microsoft.com/"], 35000);
104
+ await new Promise((r) => setTimeout(r, 2000));
105
+ await dismissConsent(tab, cdp);
106
+ }
107
+ }
108
+
109
+ // Wait for React app to mount input (up to 15s, longer after verification)
110
+ const inputDeadline = Date.now() + 15000;
111
+ while (Date.now() < inputDeadline) {
112
+ const found = await cdp([
113
+ "eval",
114
+ tab,
115
+ `!!document.querySelector('${S.input}')`,
116
+ ]).catch(() => "false");
117
+ if (found === "true") break;
118
+ await new Promise((r) => setTimeout(r, 500));
119
+ }
120
+ await new Promise((r) => setTimeout(r, 300));
121
+
122
+ // Verify input is actually there before proceeding
123
+ const inputReady = await cdp([
124
+ "eval",
125
+ tab,
126
+ `!!document.querySelector('${S.input}')`,
127
+ ]).catch(() => "false");
128
+ if (inputReady !== "true") {
129
+ throw new Error(
130
+ "Copilot input not found — verification may have failed or page is in unexpected state",
131
+ );
132
+ }
133
+
134
+ await injectClipboardInterceptor(tab, GLOBAL_VAR);
135
+ await cdp(["click", tab, S.input]);
136
+ await new Promise((r) => setTimeout(r, 400));
137
+ await cdp(["type", tab, query]);
138
+ await new Promise((r) => setTimeout(r, 400));
139
+
140
+ // Submit with Enter (most reliable across locales and Chrome instances)
141
+ await cdp([
142
+ "eval",
143
+ tab,
144
+ `document.querySelector('${S.input}')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,keyCode:13})), 'ok'`,
145
+ ]);
146
+
147
+ await waitForCopyButton(tab);
148
+
149
+ const { answer, sources } = await extractAnswer(tab);
150
+ if (!answer)
151
+ throw new Error("No answer extracted — Copilot may not have responded");
152
+
153
+ const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
154
+ () => "",
155
+ );
156
+ outputJson({
157
+ query,
158
+ url: finalUrl,
159
+ answer: formatAnswer(answer, short),
160
+ sources,
161
+ });
162
+ } catch (e) {
163
+ handleError(e);
164
+ }
165
+ }
166
+
167
+ main();
@@ -0,0 +1,237 @@
1
+ // extractors/common.mjs — shared utilities for CDP-based extractors
2
+ // Extracts common patterns: cdp wrapper, tab management, clipboard interception, source parsing
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dir = dirname(fileURLToPath(import.meta.url));
9
+ const CDP = join(__dir, "..", "cdp.mjs");
10
+
11
+ // ============================================================================
12
+ // CDP wrapper
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Execute a CDP command through the cdp.mjs CLI
17
+ * @param {string[]} args - Command arguments
18
+ * @param {number} [timeoutMs=30000] - Timeout in milliseconds
19
+ * @returns {Promise<string>} Command output
20
+ */
21
+ export function cdp(args, timeoutMs = 30000) {
22
+ return new Promise((resolve, reject) => {
23
+ const proc = spawn("node", [CDP, ...args], {
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ });
26
+ let out = "";
27
+ let err = "";
28
+ proc.stdout.on("data", (d) => (out += d));
29
+ proc.stderr.on("data", (d) => (err += d));
30
+ const timer = setTimeout(() => {
31
+ proc.kill();
32
+ reject(new Error(`cdp timeout: ${args[0]}`));
33
+ }, timeoutMs);
34
+ proc.on("close", (code) => {
35
+ clearTimeout(timer);
36
+ if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
37
+ else resolve(out.trim());
38
+ });
39
+ });
40
+ }
41
+
42
+ // ============================================================================
43
+ // Tab management
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Get an existing tab by prefix or open a new one
48
+ * @param {string|null} tabPrefix - Existing tab prefix, or null to create new
49
+ * @returns {Promise<string>} Tab identifier
50
+ */
51
+ export async function getOrOpenTab(tabPrefix) {
52
+ if (tabPrefix) return tabPrefix;
53
+ // Always open a fresh tab to avoid SPA navigation issues
54
+ const list = await cdp(["list"]);
55
+ const anchor = list.split("\n")[0]?.slice(0, 8);
56
+ if (!anchor)
57
+ throw new Error(
58
+ "No Chrome tabs found. Is Chrome running with --remote-debugging-port=9222?",
59
+ );
60
+ const raw = await cdp([
61
+ "evalraw",
62
+ anchor,
63
+ "Target.createTarget",
64
+ '{"url":"about:blank"}',
65
+ ]);
66
+ const { targetId } = JSON.parse(raw);
67
+ await cdp(["list"]); // refresh cache
68
+ return targetId.slice(0, 8);
69
+ }
70
+
71
+ // ============================================================================
72
+ // Clipboard interception (for extractors that use copy-to-clipboard)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Inject clipboard interceptor to capture text when copy buttons are clicked.
77
+ * Each engine uses a unique global variable to avoid conflicts.
78
+ * @param {string} tab - Tab identifier
79
+ * @param {string} globalVar - Global variable name (e.g., '__pplxClipboard', '__geminiClipboard')
80
+ */
81
+ export async function injectClipboardInterceptor(tab, globalVar) {
82
+ const code = `
83
+ window.${globalVar} = null;
84
+ const _origWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
85
+ navigator.clipboard.writeText = function(text) {
86
+ window.${globalVar} = text;
87
+ return _origWriteText(text);
88
+ };
89
+ const _origWrite = navigator.clipboard.write.bind(navigator.clipboard);
90
+ navigator.clipboard.write = async function(items) {
91
+ try {
92
+ for (const item of items) {
93
+ if (item.types && item.types.includes('text/plain')) {
94
+ const blob = await item.getType('text/plain');
95
+ window.${globalVar} = await blob.text();
96
+ break;
97
+ }
98
+ }
99
+ } catch(e) {}
100
+ return _origWrite(items);
101
+ };
102
+ `;
103
+ await cdp(["eval", tab, code]);
104
+ }
105
+
106
+ // ============================================================================
107
+ // Source extraction from markdown
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Parse Markdown links from text to extract sources
112
+ * @param {string} text - Text containing Markdown links like [title](url)
113
+ * @returns {Array<{title: string, url: string}>} Extracted sources
114
+ */
115
+ export function parseSourcesFromMarkdown(text) {
116
+ return Array.from(text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g))
117
+ .map((m) => ({ title: m[1], url: m[2] }))
118
+ .filter((v, i, arr) => arr.findIndex((x) => x.url === v.url) === i)
119
+ .slice(0, 10);
120
+ }
121
+
122
+ // ============================================================================
123
+ // Stream completion detection
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Wait for generation/streaming to complete by monitoring text length stability
128
+ * @param {string} tab - Tab identifier
129
+ * @param {object} options - Options
130
+ * @param {number} [options.timeout=30000] - Maximum wait time in ms
131
+ * @param {number} [options.interval=600] - Polling interval in ms
132
+ * @param {number} [options.stableRounds=3] - Required stable rounds to consider complete
133
+ * @param {string} [options.selector='document.body'] - Element to monitor (default: body)
134
+ * @returns {Promise<number>} Final text length
135
+ */
136
+ export async function waitForStreamComplete(tab, options = {}) {
137
+ const {
138
+ timeout = 30000,
139
+ interval = 600,
140
+ stableRounds = 3,
141
+ selector = "document.body",
142
+ } = options;
143
+
144
+ const deadline = Date.now() + timeout;
145
+ let lastLen = -1;
146
+ let stableCount = 0;
147
+
148
+ while (Date.now() < deadline) {
149
+ await new Promise((r) => setTimeout(r, interval));
150
+ const lenStr = await cdp([
151
+ "eval",
152
+ tab,
153
+ `${selector}?.innerText?.length ?? 0`,
154
+ ]).catch(() => "0");
155
+ const currentLen = parseInt(lenStr, 10) || 0;
156
+
157
+ if (currentLen > 0) {
158
+ if (currentLen === lastLen) {
159
+ stableCount++;
160
+ if (stableCount >= stableRounds) return currentLen;
161
+ } else {
162
+ lastLen = currentLen;
163
+ stableCount = 0;
164
+ }
165
+ }
166
+ }
167
+
168
+ throw new Error(`Generation did not stabilise within ${timeout}ms`);
169
+ }
170
+
171
+ // ============================================================================
172
+ // CLI argument parsing
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Parse standard extractor CLI arguments
177
+ * @param {string[]} args - process.argv.slice(2)
178
+ * @returns {{query: string, tabPrefix: string|null, short: boolean}}
179
+ */
180
+ export function parseArgs(args) {
181
+ const short = args.includes("--short");
182
+ const rest = args.filter((a) => a !== "--short");
183
+ const tabFlagIdx = rest.indexOf("--tab");
184
+ const tabPrefix = tabFlagIdx !== -1 ? rest[tabFlagIdx + 1] : null;
185
+ const query =
186
+ tabFlagIdx !== -1
187
+ ? rest
188
+ .filter((_, i) => i !== tabFlagIdx && i !== tabFlagIdx + 1)
189
+ .join(" ")
190
+ : rest.join(" ");
191
+ return { query, tabPrefix, short };
192
+ }
193
+
194
+ /**
195
+ * Validate that a query was provided, show usage and exit if not
196
+ * @param {string[]} args - process.argv.slice(2)
197
+ * @param {string} usage - Usage string for error message
198
+ */
199
+ export function validateQuery(args, usage) {
200
+ if (!args.length || args[0] === "--help") {
201
+ process.stderr.write(usage);
202
+ process.exit(1);
203
+ }
204
+ }
205
+
206
+ // ============================================================================
207
+ // Output formatting
208
+ // ============================================================================
209
+
210
+ /**
211
+ * Truncate answer if short mode is enabled
212
+ * @param {string} answer - Full answer text
213
+ * @param {boolean} short - Whether to truncate
214
+ * @param {number} [maxLen=300] - Maximum length in short mode
215
+ * @returns {string} Formatted answer
216
+ */
217
+ export function formatAnswer(answer, short, maxLen = 300) {
218
+ if (!short || answer.length <= maxLen) return answer;
219
+ return `${answer.slice(0, maxLen).replace(/\s+\S*$/, "")}…`;
220
+ }
221
+
222
+ /**
223
+ * Output JSON result to stdout
224
+ * @param {object} data - Data to output
225
+ */
226
+ export function outputJson(data) {
227
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
228
+ }
229
+
230
+ /**
231
+ * Handle and output error, then exit
232
+ * @param {Error} error - Error to handle
233
+ */
234
+ export function handleError(error) {
235
+ process.stderr.write(`Error: ${error.message}\n`);
236
+ process.exit(1);
237
+ }