@getopencrawl/cli 0.0.0-stage → 0.1.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.
- package/README.md +304 -2
- package/bin/opencrawl.mjs +528 -0
- package/browser.mjs +166 -0
- package/package.json +29 -4
- package/src/answer-contract.mjs +112 -0
- package/src/answer-view.mjs +118 -0
- package/src/capture.mjs +273 -0
- package/src/client.mjs +175 -0
- package/src/connect.mjs +230 -0
- package/src/context.mjs +34 -0
- package/src/display.mjs +4 -0
- package/src/fixtures/letter-connections.json +59 -0
- package/src/fixtures/product-monitor-contract.json +312 -0
- package/src/fixtures/research-completion-hosted.json +502 -0
- package/src/fixtures/research-completion-v1.json +58 -0
- package/src/fixtures/research-completion-v2.json +33 -0
- package/src/fixtures/research-presentation-hosted.json +720 -0
- package/src/jobs.mjs +108 -0
- package/src/monitors.mjs +146 -0
- package/src/product-letter-capabilities.mjs +71 -0
- package/src/product-letter-read.mjs +178 -0
- package/src/product-monitors.mjs +313 -0
- package/src/research-agent.mjs +164 -0
- package/src/research-answer-contract.mjs +3 -0
- package/src/research-answer-view.mjs +7 -0
- package/src/research-completion.mjs +94 -0
- package/src/research-event.mjs +39 -0
- package/src/research.mjs +133 -0
- package/src/sessions.mjs +165 -0
- package/src/tasks.mjs +115 -0
- package/src/uploads.mjs +141 -0
package/src/uploads.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { createReadStream, openAsBlob } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { allowedOptions, identifier, requestFailure, waitForJob, validateJob, withInterrupt, waitOptions, renderJob } from './jobs.mjs';
|
|
6
|
+
import { displayLine } from './display.mjs';
|
|
7
|
+
|
|
8
|
+
export const UPLOADS_HELP = `OpenCrawl · recover an upload receipt
|
|
9
|
+
|
|
10
|
+
opencrawl uploads show RECEIPT_ID [--json]
|
|
11
|
+
opencrawl uploads lookup REQUEST_KEY [--json]
|
|
12
|
+
opencrawl ingest FILE [--wait] [--timeout 300] [--request-key UUID]
|
|
13
|
+
opencrawl import FILE [--provider P] [--wait] [--timeout 300] [--request-key UUID]
|
|
14
|
+
|
|
15
|
+
Every queued upload prints its request key before sending. Keep that key if the
|
|
16
|
+
connection drops. Lookup is read-only and never resends a file. To explicitly
|
|
17
|
+
retry a prepared/retryable receipt, rerun the original command with the same
|
|
18
|
+
file, source, visibility, provider, and --request-key. Changed details are rejected.
|
|
19
|
+
Accepted receipts return their existing job; receiving receipts are only checked.
|
|
20
|
+
Expired receipts require an explicit new upload without the old request key.
|
|
21
|
+
--wait follows acceptance and job completion for at most 1–3600 seconds (default
|
|
22
|
+
300). Ctrl+C stops local waiting. No automatic retry, reupload, or cancellation.
|
|
23
|
+
Upload options: --source ID --visibility private|workspace (default private)
|
|
24
|
+
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
25
|
+
`;
|
|
26
|
+
export const PROVIDERS = ['auto', 'chatgpt', 'claude', 'claude-code', 'codex', 'generic'];
|
|
27
|
+
const STATES = new Set(['prepared', 'receiving', 'retryable', 'accepted', 'expired']);
|
|
28
|
+
export function requestKey(value) {
|
|
29
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new Error('--request-key must be the UUID printed before the original upload.');
|
|
30
|
+
return value.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
export function validateReceipt(value, { key, id } = {}) {
|
|
33
|
+
if (!value || typeof value.id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(value.id) || !STATES.has(value.state) || id && value.id !== id || key && value.request_key !== key || !['upload', 'chat_import'].includes(value.operation)) throw new Error('Server returned an invalid upload receipt. No file was resent.');
|
|
34
|
+
requestKey(value.request_key);
|
|
35
|
+
if (value.state === 'accepted') validateJob(value.job);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
export async function lookupReceipt(client, key, options = {}) {
|
|
39
|
+
key = requestKey(key);
|
|
40
|
+
return validateReceipt(await client.request(`/api/v1/uploads/receipts/by-key/${key}`, { maxResponseBytes: 512 * 1024, ...options }), { key });
|
|
41
|
+
}
|
|
42
|
+
export async function fileDescriptor(file, { chat = false, provider = 'auto', source, visibility = 'private' } = {}) {
|
|
43
|
+
if (!['private', 'workspace'].includes(visibility)) throw new Error('--visibility must be private or workspace.');
|
|
44
|
+
if (!PROVIDERS.includes(provider)) throw new Error('Choose a supported --provider from import --help.');
|
|
45
|
+
if (source !== undefined) identifier(source, 'source');
|
|
46
|
+
const before = await fs.stat(file); const filename = path.basename(file);
|
|
47
|
+
if (!before.isFile() || !before.size || before.size > 200 * 1024 * 1024) throw new Error('Upload a nonempty regular file of at most 200 MB.');
|
|
48
|
+
if (/[\\\u0000-\u001f\u007f]/u.test(filename)) throw new Error('Use a filename without backslashes or control characters.');
|
|
49
|
+
if (Array.from(filename).length > 200) throw new Error('The upload filename must be at most 200 characters.');
|
|
50
|
+
const hash = crypto.createHash('sha256');
|
|
51
|
+
let size = 0;
|
|
52
|
+
for await (const chunk of createReadStream(file)) {
|
|
53
|
+
size += chunk.length;
|
|
54
|
+
if (size > before.size) throw new Error('The file grew while its checksum was being read. Wait for it to finish writing, then explicitly import it again.');
|
|
55
|
+
hash.update(chunk);
|
|
56
|
+
}
|
|
57
|
+
const after = await fs.stat(file);
|
|
58
|
+
if (['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].some(key => before[key] !== after[key])) throw new Error('The file changed while its checksum was being read. Wait for it to finish writing, then explicitly import it again.');
|
|
59
|
+
return { operation: chat ? 'chat_import' : 'upload', filename, size_bytes: before.size, sha256: hash.digest('hex'), source_id: source || null, visibility, ...(chat ? { provider } : {}) };
|
|
60
|
+
}
|
|
61
|
+
const snapshot = (key, receipt, error) => ({ request_key: key, receipt_id: receipt?.id || null, receipt: receipt || null,
|
|
62
|
+
job_id: receipt?.job?.id || null, job: receipt?.job || null, outcome: receipt?.state || 'unconfirmed', ...(error ? { error } : {}) });
|
|
63
|
+
|
|
64
|
+
/** Sends file bytes at most once. A failed response is reconciled only by GET. */
|
|
65
|
+
export async function submitUpload(file, client, options = {}, { onIntent = async () => {}, onReceipt = async () => {}, stderr = text => process.stderr.write(text), signal } = {}) {
|
|
66
|
+
const descriptor = await fileDescriptor(file, options);
|
|
67
|
+
const key = options['request-key'] === undefined ? crypto.randomUUID() : requestKey(options['request-key']);
|
|
68
|
+
await onIntent(key, descriptor);
|
|
69
|
+
stderr(`Upload request key: ${key}\n`);
|
|
70
|
+
let receipt;
|
|
71
|
+
const recover = async error => {
|
|
72
|
+
let confirmed = false;
|
|
73
|
+
try { receipt = await lookupReceipt(client, key, { timeout: 10000, signal }); confirmed = true; await onReceipt(receipt); }
|
|
74
|
+
catch { /* Acceptance remains unknown; keep the key rather than retransmitting. */ }
|
|
75
|
+
return { ...snapshot(key, receipt, requestFailure(error)), ...(!confirmed && receipt?.state !== 'accepted' ? { outcome: 'unconfirmed' } : {}) };
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
// Idempotent prepare validates the entire descriptor even for an existing key.
|
|
79
|
+
receipt = validateReceipt(await client.request('/api/v1/uploads/prepare', { method: 'POST', body: { request_key: key, ...descriptor }, timeout: 30000, signal, maxResponseBytes: 512 * 1024 }), { key });
|
|
80
|
+
await onReceipt(receipt);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// A definite descriptor conflict must not be disguised as successful reuse.
|
|
83
|
+
if (Number.isInteger(error.status) && error.status >= 400 && error.status < 500) return snapshot(key, null, requestFailure(error));
|
|
84
|
+
return recover(error);
|
|
85
|
+
}
|
|
86
|
+
if (receipt.state === 'accepted' || receipt.state === 'receiving' || receipt.state === 'expired') return snapshot(key, receipt);
|
|
87
|
+
const route = `/api/v1/uploads/receipts/${encodeURIComponent(receipt.id)}/content`;
|
|
88
|
+
if (receipt.upload_url !== route) return snapshot(key, receipt, { code: 'invalid_upload_route', message: 'The receipt returned an unexpected upload route. No file was sent.' });
|
|
89
|
+
try {
|
|
90
|
+
const form = new FormData();
|
|
91
|
+
const type = /\.zip$/i.test(file) ? 'application/zip' : /\.json$/i.test(file) ? 'application/json' : /\.jsonl$/i.test(file) ? 'application/x-ndjson' : 'application/octet-stream';
|
|
92
|
+
form.set('file', await openAsBlob(file, { type }), descriptor.filename);
|
|
93
|
+
receipt = validateReceipt(await client.request(route, { method: 'POST', body: form, timeout: 300000, signal, maxResponseBytes: 512 * 1024 }), { key, id: receipt.id });
|
|
94
|
+
await onReceipt(receipt);
|
|
95
|
+
return snapshot(key, receipt);
|
|
96
|
+
} catch (error) { return recover(error); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function followUpload(result, client, { timeout = 300, signal, now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
100
|
+
const deadline = now() + timeout * 1000;
|
|
101
|
+
while (!result.job_id && result.receipt?.state === 'receiving') {
|
|
102
|
+
if (signal?.aborted || now() >= deadline) return { ...result, wait: { outcome: signal?.aborted ? 'interrupted' : 'timeout' } };
|
|
103
|
+
const delay = Number.isInteger(result.receipt.retry_after_seconds) ? Math.max(1, Math.min(10, result.receipt.retry_after_seconds)) * 1000 : 2000;
|
|
104
|
+
const next = Math.min(deadline, now() + delay);
|
|
105
|
+
while (!signal?.aborted && now() < next) await sleep(Math.min(100, next - now()));
|
|
106
|
+
if (signal?.aborted || now() >= deadline) continue;
|
|
107
|
+
try { result = snapshot(result.request_key, await lookupReceipt(client, result.request_key, { timeout: Math.max(1, Math.min(30000, Math.ceil(deadline - now()))), signal })); }
|
|
108
|
+
catch (error) { return { ...result, wait: { outcome: signal?.aborted ? 'interrupted' : now() >= deadline ? 'timeout' : 'unavailable', error: requestFailure(error) } }; }
|
|
109
|
+
}
|
|
110
|
+
if (!result.job_id) return { ...result, wait: { outcome: signal?.aborted ? 'interrupted' : 'not_accepted' } };
|
|
111
|
+
const waited = await waitForJob(client, result.job_id, { timeout, signal, now, sleep, deadline, initial: result.job });
|
|
112
|
+
return { ...result, ...waited };
|
|
113
|
+
}
|
|
114
|
+
export function renderUpload(result) {
|
|
115
|
+
let text = `Upload request key: ${displayLine(result.request_key)}\n`;
|
|
116
|
+
if (result.receipt_id) text += `Receipt: ${displayLine(result.receipt_id)}\n`;
|
|
117
|
+
text += `Upload: ${displayLine(result.outcome)}\n`;
|
|
118
|
+
if (result.job) text += renderJob(result.job);
|
|
119
|
+
if (result.wait) text += `Wait: ${displayLine(result.wait.outcome)}. No job was cancelled or resubmitted.\n`;
|
|
120
|
+
if (result.receipt?.error?.message) text += `Receipt issue: ${displayLine(result.receipt.error.message)}\n`;
|
|
121
|
+
if (result.error || result.wait?.error) text += displayLine((result.wait?.error || result.error).message) + '\n';
|
|
122
|
+
if (result.outcome !== 'accepted') text += `Inspect with: opencrawl uploads lookup ${displayLine(result.request_key)}\n`;
|
|
123
|
+
if (['prepared', 'retryable'].includes(result.outcome)) text += 'Retry only explicitly with the original file and scope plus --request-key.\n';
|
|
124
|
+
if (result.outcome === 'receiving') text += 'The server is still receiving or reconciling this request. Lookup only; do not upload it again.\n';
|
|
125
|
+
if (result.outcome === 'expired') text += 'This receipt expired. Start a new upload explicitly if it is still needed.\n';
|
|
126
|
+
return text;
|
|
127
|
+
}
|
|
128
|
+
export function uploadExitCode(result, wait) { return wait ? result.wait?.outcome === 'completed' ? 0 : 1 : result.outcome === 'accepted' ? 0 : 1; }
|
|
129
|
+
export async function finishUpload(result, options, client, { stdout = text => process.stdout.write(text) } = {}) {
|
|
130
|
+
if (options.wait) result = await withInterrupt(signal => followUpload(result, client, { timeout: waitOptions(options), signal }));
|
|
131
|
+
stdout(options.json ? JSON.stringify(result, null, 2) + '\n' : renderUpload(result));
|
|
132
|
+
return uploadExitCode(result, options.wait);
|
|
133
|
+
}
|
|
134
|
+
export async function uploadsCommand(rest, options, client, { stdout = text => process.stdout.write(text) } = {}) {
|
|
135
|
+
const [command, id, ...extra] = rest;
|
|
136
|
+
allowedOptions(options, [], 'uploads');
|
|
137
|
+
if (!['show', 'lookup'].includes(command) || extra.length) throw new Error('Choose uploads show RECEIPT_ID or uploads lookup REQUEST_KEY.');
|
|
138
|
+
const receipt = command === 'lookup' ? await lookupReceipt(client, id) : validateReceipt(await client.request(`/api/v1/uploads/receipts/${encodeURIComponent(identifier(id, 'receipt'))}`, { maxResponseBytes: 512 * 1024 }), { id });
|
|
139
|
+
stdout(options.json ? JSON.stringify(receipt, null, 2) + '\n' : renderUpload(snapshot(receipt.request_key, receipt)));
|
|
140
|
+
return 0;
|
|
141
|
+
}
|