@getopencrawl/cli 0.1.1 → 0.2.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.
Files changed (58) hide show
  1. package/README.md +75 -298
  2. package/bin/opencrawl.cjs +168 -0
  3. package/package.json +20 -14
  4. package/bin/opencrawl.mjs +0 -553
  5. package/browser.mjs +0 -166
  6. package/src/accounts.test.mjs +0 -223
  7. package/src/agent.mjs +0 -95
  8. package/src/agent.test.mjs +0 -58
  9. package/src/answer-contract.mjs +0 -112
  10. package/src/answer-view.mjs +0 -118
  11. package/src/browser.test.mjs +0 -72
  12. package/src/capture.mjs +0 -273
  13. package/src/capture.test.mjs +0 -171
  14. package/src/client.mjs +0 -183
  15. package/src/client.test.mjs +0 -34
  16. package/src/connect.mjs +0 -230
  17. package/src/connect.test.mjs +0 -131
  18. package/src/context.mjs +0 -34
  19. package/src/context.test.mjs +0 -32
  20. package/src/display.mjs +0 -4
  21. package/src/fixtures/letter-connections.json +0 -59
  22. package/src/fixtures/product-monitor-contract.json +0 -312
  23. package/src/fixtures/research-completion-hosted.json +0 -502
  24. package/src/fixtures/research-completion-v1.json +0 -58
  25. package/src/fixtures/research-completion-v2.json +0 -33
  26. package/src/fixtures/research-presentation-hosted.json +0 -720
  27. package/src/jobs.mjs +0 -117
  28. package/src/jobs.test.mjs +0 -67
  29. package/src/mcp-conversations.test.mjs +0 -80
  30. package/src/monitors.mjs +0 -146
  31. package/src/monitors.test.mjs +0 -62
  32. package/src/product-letter-capabilities.mjs +0 -71
  33. package/src/product-letter-read.mjs +0 -178
  34. package/src/product-monitor-login.test.mjs +0 -140
  35. package/src/product-monitors.mjs +0 -321
  36. package/src/product-monitors.test.mjs +0 -69
  37. package/src/research-agent-errors.test.mjs +0 -110
  38. package/src/research-agent-feedback.test.mjs +0 -28
  39. package/src/research-agent.mjs +0 -164
  40. package/src/research-agent.test.mjs +0 -231
  41. package/src/research-answer-contract.mjs +0 -3
  42. package/src/research-answer-contract.test.mjs +0 -14
  43. package/src/research-answer-view.mjs +0 -7
  44. package/src/research-answer-view.test.mjs +0 -27
  45. package/src/research-completion.mjs +0 -94
  46. package/src/research-completion.test.mjs +0 -110
  47. package/src/research-event.mjs +0 -39
  48. package/src/research-event.test.mjs +0 -57
  49. package/src/research-presentation.test.mjs +0 -56
  50. package/src/research.mjs +0 -141
  51. package/src/research.test.mjs +0 -97
  52. package/src/sessions.mjs +0 -165
  53. package/src/sessions.test.mjs +0 -151
  54. package/src/tasks.mjs +0 -115
  55. package/src/tasks.test.mjs +0 -83
  56. package/src/upload-commands.test.mjs +0 -140
  57. package/src/uploads.mjs +0 -141
  58. package/src/uploads.test.mjs +0 -64
@@ -1,83 +0,0 @@
1
- import {test} from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import fs from 'node:fs/promises';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import http from 'node:http';
7
- import {spawn} from 'node:child_process';
8
- import {fileURLToPath} from 'node:url';
9
- import {taskRequest,tasksCommand,validateTaskRun} from './tasks.mjs';
10
- import {loggedInConfig} from './client.mjs';
11
- const id='task_'+'a'.repeat(32);
12
- const task=()=>({id,title:'Launch evidence',query:'What is the current launch?',scope:{mode:'selected',source_ids:['src_alpha']},limit:5,budget:16384,revision:2,created_at:'2026-01-01T00:00:00Z',updated_at:'2026-01-01T00:00:00Z',scope_status:'available'});
13
- const receipt=(text='[S1] Launch\né🌲\u001b[2J\n')=>({task:task(),query:'What is the current launch?',context:text,context_bytes:Buffer.byteLength(text),budget_unit:'utf8_bytes',citations:text?[{citation:'S1',document_id:'doc_alpha',chunk_id:1,source_id:'src_alpha',kind:'document',title:'Launch',context_span:{start_char:12,end_char:Array.from(text).length},excerpt:{start_char:0,end_char:Array.from(text).length-12}}]:[],coverage:{warnings:[]}});
14
- const execute=async(rest,options,data)=>{let stdout='',stderr='';const calls=[];const status=await tasksCommand(rest,options,{request:async(route,opts)=>{calls.push({route,opts});if(data instanceof Error)throw data;return data;}},{stdout:value=>stdout+=value,stderr:value=>stderr+=value});return{stdout,stderr,calls,status};};
15
-
16
- test('tasks preserve stored descriptor and positive revision without run overrides',()=>{
17
- assert.deepEqual(taskRequest(['run',id],{}).options,{method:'POST',body:{}});
18
- assert.deepEqual(taskRequest(['run',id],{revision:'2'}).options,{method:'POST',body:{expected_revision:2}});
19
- assert.equal(taskRequest(['list'],{limit:'20',offset:'40'}).route,'/api/v1/tasks?limit=20&offset=40');
20
- for(const revision of ['0','-1','1e2','+2','2.0',' 2','02','a'.repeat(64),'9007199254740992'])assert.throws(()=>taskRequest(['run',id],{revision}));
21
- for(const key of ['query','scope','source','budget','limit','offset'])assert.throws(()=>taskRequest(['run',id],{[key]:'2'}));
22
- for(const rest of [['run'],['show','../secret'],['list',id],['run',id,'extra'],['create']])assert.throws(()=>taskRequest(rest,{}));
23
- });
24
- test('run stdout preserves exact context bytes and full JSON receipts',async()=>{
25
- const value=receipt();const plain=await execute(['run',id],{revision:'2'},value);
26
- assert.equal(plain.status,0);assert.equal(plain.stdout,value.context);assert.match(plain.stderr,/Task revision 2/);
27
- assert.deepEqual(plain.calls[0].opts.body,{expected_revision:2});
28
- const json=await execute(['run',id],{json:true},value);assert.deepEqual(JSON.parse(json.stdout),value);assert.equal(json.stderr,'');
29
- });
30
- test('empty current evidence succeeds without fabricated answer or silent broader scope',async()=>{
31
- const value=receipt('');const result=await execute(['run',id],{},value);assert.equal(result.status,0);assert.equal(result.stdout,'');assert.match(result.stderr,/No current evidence/);assert.equal(result.calls.length,1);
32
- const json=await execute(['run',id],{json:true},value);assert.equal(JSON.parse(json.stdout).context,'');
33
- });
34
- test('all-accessible tasks can cite standalone documents but cannot carry hidden selected scope',async()=>{
35
- const value=receipt();value.task.scope={mode:'all_accessible'};value.citations[0].source_id=null;
36
- assert.equal((await execute(['run',id],{},value)).stdout,value.context);
37
- value.task.scope={mode:'all_accessible',source_ids:['src_alpha']};
38
- await assert.rejects(execute(['run',id],{},value));
39
- });
40
- test('byte, revision, current scope and exact citation spans fail closed before stdout',async()=>{
41
- const base=receipt();
42
- const cases=[{task:{...task(),revision:3}},{task:{...task(),scope_status:'blocked'}},{context_bytes:1},{budget_unit:'tokens'},
43
- {context:'x'.repeat(16385),context_bytes:16385},{citations:[]},{citations:[{...base.citations[0],source_id:'src_other'}]},
44
- {citations:[{...base.citations[0],context_span:{start_char:0,end_char:99999}}]},
45
- {citations:[base.citations[0],base.citations[0]]},{task:{...task(),budget:16385}},{task:{...task(),scope:{mode:'selected',source_ids:[]}}}];
46
- for(const change of cases){let output='';await assert.rejects(tasksCommand(['run',id],{revision:'2'},{request:async()=>({...base,...change})},{stdout:v=>output+=v,stderr:()=>{}}));assert.equal(output,'');}
47
- const full=receipt('[S1] Launch\n'+'é'.repeat(8186));assert.equal(full.context_bytes,16384);validateTaskRun(full,{id,revision:2});
48
- });
49
- test('task conflicts distinguish changed descriptor from unavailable source without retry',async()=>{
50
- for(const [status,code,pattern]of[[409,'task_changed',/review its current/],[409,'task_scope_unavailable',/No broader search/],[409,'task_evidence_changed',/No automatic retry/],[404,'anything',/tasks list/],[401,null,/Sign in/],[500,'secret',/could not be confirmed/]]){
51
- const error=Object.assign(new Error('RAW SECRET\u001b'),{status,code});const result=await execute(['run',id],{json:true},error);
52
- assert.equal(result.status,1);assert.equal(result.calls.length,1);assert.equal(result.stderr,'');assert.match(JSON.parse(result.stdout).error.message,pattern);assert.ok(!result.stdout.includes('SECRET'));
53
- }
54
- });
55
- test('human task descriptions and warnings escape terminal controls; JSON remains exact',async()=>{
56
- const value={...task(),title:'Name\u001b[2J',query:'Question\n\ttext\u009b31m'};
57
- const shown=await execute(['show',id],{},value);assert.ok(!shown.stdout.includes('\u001b'));assert.match(shown.stdout,/\\u001b/);assert.match(shown.stdout,/Question\n\ttext\\u009b/);
58
- const listed=await execute(['list'],{}, {items:[value],limit:20,offset:0});assert.match(listed.stdout,/\\u001b/);
59
- const pack=receipt();pack.coverage.warnings=['Fallback\u001b[2J'];const result=await execute(['run',id],{},pack);assert.match(result.stderr,/\\u001b/);assert.equal(result.stdout,pack.context);
60
- const json=await execute(['show',id],{json:true},value);assert.deepEqual(JSON.parse(json.stdout),value);
61
- });
62
- const script=fileURLToPath(new URL('../bin/opencrawl.mjs',import.meta.url));
63
- function run(args,env,cwd){return new Promise((resolve,reject)=>{const p=spawn(process.execPath,[script,...args],{env,cwd,stdio:['ignore','pipe','pipe']});let stdout='',stderr='';const timer=setTimeout(()=>p.kill('SIGKILL'),10000);p.stdout.on('data',v=>stdout+=v);p.stderr.on('data',v=>stderr+=v);p.on('error',reject);p.on('close',code=>{clearTimeout(timer);resolve({code,stdout,stderr});});});}
64
- async function listen(){const calls=[];let conflict=null;const server=http.createServer(async(req,res)=>{let raw='';for await(const v of req)raw+=v;const body=raw?JSON.parse(raw):null;calls.push({path:req.url,body,authorization:req.headers.authorization});const failed=req.url.endsWith('/run')&&conflict;res.writeHead(failed?409:200,{'content-type':'application/json'});res.end(JSON.stringify(failed?{code:conflict,detail:'RAW PRIVATE'}:req.url.startsWith('/api/v1/tasks?')?{items:[task()],limit:20,offset:0}:req.url.endsWith('/run')||req.url.endsWith('/context')?receipt():task()));});await new Promise(r=>server.listen(0,'127.0.0.1',r));return{url:'http://127.0.0.1:'+server.address().port,calls,setConflict:value=>conflict=value,close:()=>new Promise(r=>server.close(r))};}
65
- test('real CLI keeps account pins, strict options, fixed JSON conflicts, and default search scope',async()=>{
66
- const dir=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-tasks-'));const a=await listen(),b=await listen();const env={...process.env};for(const key of Object.keys(env))if(key.startsWith('OPENCRAWL_'))delete env[key];env.OPENCRAWL_CONFIG_DIR=dir;
67
- const identity=digit=>({user:{id:'usr_'+digit.repeat(32)},workspace:{id:'ws_'+digit.repeat(32)}});
68
- try{
69
- let config=loggedInConfig({},a.url,'oc_synthetic_a',identity('a'));const account=config.activeProfile;config=loggedInConfig(config,b.url,'oc_synthetic_b',identity('b'));await fs.writeFile(path.join(dir,'config.json'),JSON.stringify(config),{mode:0o600});
70
- const ambient={...env,OPENCRAWL_URL:b.url,OPENCRAWL_API_KEY:'oc_synthetic_b'};
71
- const help=await run(['tasks','--help'],ambient,dir);assert.equal(help.code,0);assert.match(help.stdout,/--revision N/);assert.equal(a.calls.length+b.calls.length,0);
72
- const exact=await run(['tasks','run',id,'--revision','2','--account',account],ambient,dir);assert.equal(exact.code,0,exact.stderr);assert.equal(exact.stdout,receipt().context);assert.deepEqual(a.calls.at(-1).body,{expected_revision:2});
73
- for(const args of [['tasks','list','--account',account],['tasks','show',id,'--account',account],['--account',account,'tasks','run',id,'--json']]){const result=await run(args,ambient,dir);assert.equal(result.code,0,result.stderr);}
74
- assert.equal(b.calls.length,0);assert.ok(a.calls.every(c=>c.authorization==='Bearer oc_synthetic_a'));
75
- const before=a.calls.length;
76
- for(const extra of [['--revision','2','--revision','3'],['--json=false'],['--source','src_other'],['--revision','0']]){const r=await run(['tasks','run',id,'--account',account,...extra],ambient,dir);assert.equal(r.code,1);}
77
- assert.equal(a.calls.length,before);
78
- const wrong=await run(['tasks','show',id,'--account',account,'--server',b.url],ambient,dir);assert.equal(wrong.code,1);assert.equal(b.calls.length,0);
79
- for(const code of ['task_changed','task_scope_unavailable','task_evidence_changed']){a.setConflict(code);const r=await run(['tasks','run',id,'--json','--account',account],ambient,dir);assert.equal(r.code,1);assert.equal(JSON.parse(r.stdout).error.code,code);assert.equal(r.stderr,'');assert.ok(!r.stdout.includes('PRIVATE'));}
80
- a.setConflict(null);const r=await run(['context','launch','--account',account],ambient,dir);assert.equal(r.code,0,r.stderr);assert.equal(Object.hasOwn(a.calls.at(-1).body,'source_ids'),false);
81
- await run(['context','launch','--source','src_alpha','--account',account],ambient,dir);assert.deepEqual(a.calls.at(-1).body.source_ids,['src_alpha']);
82
- }finally{await Promise.all([a.close(),b.close()]);await fs.rm(dir,{recursive:true,force:true});}
83
- });
@@ -1,140 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import http from 'node:http';
4
- import fs from 'node:fs/promises';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import crypto from 'node:crypto';
8
- import { spawn } from 'node:child_process';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- const CLI = fileURLToPath(new URL('../bin/opencrawl.mjs', import.meta.url));
12
- const jobId = 'job_' + 'a'.repeat(32);
13
- async function fixture(t) {
14
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-commands-'));
15
- const receipts = new Map(), calls = [], state = { jobStatus: 'completed', losePrepare: false, loseContent: false, failLookup: 0, holdJob: false };
16
- const server = http.createServer(async (req, res) => {
17
- const chunks = []; for await (const chunk of req) chunks.push(chunk);
18
- const raw = Buffer.concat(chunks), body = req.headers['content-type']?.startsWith('application/json') ? JSON.parse(raw) : raw;
19
- calls.push({ path: req.url, method: req.method, body });
20
- const send = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
21
- const job = () => ({ id: jobId, kind: 'upload', status: state.jobStatus, terminal: ['completed','failed','cancelled','discarded'].includes(state.jobStatus), indexed_documents: state.jobStatus === 'completed' ? 1 : 0, filename: 'test\u001b[2J.pdf', documents: [] });
22
- if (req.url === '/api/auth/me') return send(200, { user: { id: 'usr_test' }, workspace: { id: 'ws_test' } });
23
- if (req.url === '/api/v1/uploads/prepare') {
24
- let receipt = receipts.get(body.request_key);
25
- if (receipt && JSON.stringify(receipt.descriptor) !== JSON.stringify(body)) return send(409, { detail: { code: 'descriptor_mismatch', message: 'raw body must not escape' } });
26
- if (!receipt) { receipt = { id: 'receipt_' + body.request_key.replaceAll('-', ''), request_key: body.request_key, state: 'prepared', operation: body.operation, job: null, descriptor: body }; receipt.upload_url = `/api/v1/uploads/receipts/${receipt.id}/content`; receipts.set(body.request_key, receipt); }
27
- if (receipt.state === 'accepted') receipt.job = job();
28
- if (state.losePrepare) { state.losePrepare = false; res.destroy(); return; }
29
- return send(200, receipt);
30
- }
31
- if (req.url.endsWith('/content')) {
32
- const receipt = [...receipts.values()].find(item => item.upload_url === req.url);
33
- assert(receipt); assert(raw.includes(Buffer.from('synthetic fixture'))); receipt.state = 'accepted'; receipt.job = job();
34
- if (state.loseContent) { state.loseContent = false; res.destroy(); return; }
35
- return send(200, receipt);
36
- }
37
- if (req.url.startsWith('/api/v1/uploads/receipts/by-key/')) {
38
- if (state.failLookup-- > 0) { res.destroy(); return; }
39
- const receipt = receipts.get(req.url.split('/').at(-1)); if (!receipt) return send(404, { detail: 'Unavailable' });
40
- if (receipt.state === 'accepted') receipt.job = job(); return send(200, receipt);
41
- }
42
- if (req.url === `/api/v1/jobs/${jobId}`) { if (state.holdJob) return; return send(200, job()); }
43
- if (req.url === '/api/v1/search') return send(200, { results: [{ title: 'bad\u001b[2J', snippet: 'keep\nline\u001b]52;x\u0007', url: 'a\rb' }] });
44
- if (req.url === '/api/v1/documents') return send(200, { id: 'doc_' + 'c'.repeat(32), title: 'synchronous' });
45
- return send(404, { detail: 'Unknown route' });
46
- });
47
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
48
- t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await fs.rm(dir, { recursive: true, force: true }); });
49
- const env = { PATH: process.env.PATH, HOME: dir, OPENCRAWL_CONFIG_DIR: path.join(dir, 'config'), OPENCRAWL_API_KEY: 'oc_synthetic_test_only', OPENCRAWL_URL: `http://127.0.0.1:${server.address().port}` };
50
- const run = (args, onSpawn) => new Promise((resolve, reject) => {
51
- const child = spawn(process.execPath, [CLI, ...args], { env, cwd: dir }); let stdout = '', stderr = '';
52
- child.stdout.on('data', data => { stdout += data; }); child.stderr.on('data', data => { stderr += data; });
53
- const timer = setTimeout(() => { child.kill('SIGKILL'); reject(Error('CLI test timeout')); }, 10000);
54
- child.on('error', reject); child.on('exit', (code, signal) => { clearTimeout(timer); resolve({ code, signal, stdout, stderr }); });
55
- onSpawn?.(child);
56
- });
57
- const file = path.join(dir, 'fixture.pdf'); await fs.writeFile(file, 'synthetic fixture');
58
- return { dir, file, run, calls, state, receipts, env };
59
- }
60
- test('real CLI waits finitely, preserves job receipt on timeout/failure, and can later inspect completion', async t => {
61
- const f = await fixture(t); f.state.jobStatus = 'queued';
62
- const timed = await f.run(['ingest', f.file, '--wait', '--timeout', '1', '--json']);
63
- assert.equal(timed.code, 1); const receipt = JSON.parse(timed.stdout); assert.equal(receipt.job_id, jobId); assert.equal(receipt.wait.outcome, 'timeout'); assert(timed.stderr.includes(receipt.request_key));
64
- f.state.jobStatus = 'completed';
65
- const completed = await f.run(['jobs', 'wait', jobId, '--timeout', '2', '--json']);
66
- assert.equal(completed.code, 0); assert.equal(JSON.parse(completed.stdout).wait.outcome, 'completed');
67
- f.state.jobStatus = 'failed'; const failed = await f.run(['jobs', 'wait', jobId, '--json']);
68
- assert.equal(failed.code, 1); assert.equal(JSON.parse(failed.stdout).job_id, jobId); assert.equal(JSON.parse(failed.stdout).wait.outcome, 'failed');
69
- assert.equal(f.calls.filter(call => call.path.endsWith('/content')).length, 1);
70
- });
71
- test('real dropped acceptance is looked up, explicit reuse never uploads twice, changed scope conflicts', async t => {
72
- const f = await fixture(t); f.state.loseContent = true;
73
- const first = await f.run(['ingest', f.file, '--json']); assert.equal(first.code, 0); const result = JSON.parse(first.stdout); assert.equal(result.job_id, jobId);
74
- const repeated = await f.run(['ingest', f.file, '--request-key', result.request_key, '--json']); assert.equal(repeated.code, 0); assert.equal(JSON.parse(repeated.stdout).job_id, jobId);
75
- const conflict = await f.run(['ingest', f.file, '--request-key', result.request_key, '--visibility', 'workspace', '--json']);
76
- assert.equal(conflict.code, 1); assert.equal(JSON.parse(conflict.stdout).error.status, 409); assert.equal(f.calls.filter(call => call.path.endsWith('/content')).length, 1);
77
- });
78
- test('real lost prepare response requires explicit same-key retry and never auto-transfers', async t => {
79
- const f = await fixture(t); f.state.losePrepare = true;
80
- const first = await f.run(['ingest', f.file, '--json']); assert.equal(first.code, 1); const result = JSON.parse(first.stdout); assert.equal(result.outcome, 'prepared'); assert.equal(f.calls.filter(call => call.path.endsWith('/content')).length, 0);
81
- const retried = await f.run(['ingest', f.file, '--request-key', result.request_key, '--wait', '--json']); assert.equal(retried.code, 0); assert.equal(JSON.parse(retried.stdout).wait.outcome, 'completed');
82
- });
83
- test('watch persists unknown acceptance across process restart and key rotation without resending pending bytes', async t => {
84
- const f = await fixture(t); const folder = path.join(f.dir, 'exports'); await fs.mkdir(folder); await fs.writeFile(path.join(folder, 'chat.json'), JSON.stringify({ messages: [{ content: 'synthetic fixture' }] }));
85
- f.state.loseContent = true; f.state.failLookup = 1;
86
- const first = await f.run(['watch', folder, '--provider', 'generic', '--once', '--json']); assert.equal(first.code, 1); assert.equal(JSON.parse(first.stdout).outcomes[0].outcome, 'unconfirmed');
87
- const stateFile = (await fs.readdir(path.join(f.dir, 'config'))).find(name => /^watch-.*\.json$/.test(name));
88
- const saved = JSON.parse(await fs.readFile(path.join(f.dir, 'config', stateFile), 'utf8')); assert(Object.values(saved.entries)[0].pending.request_key);
89
- f.env.OPENCRAWL_API_KEY = 'oc_synthetic_rotated_same_account';
90
- const second = await f.run(['watch', folder, '--provider', 'generic', '--once', '--json']); assert.equal(second.code, 0); assert.equal(JSON.parse(second.stdout).outcomes[0].outcome, 'accepted');
91
- const third = await f.run(['watch', folder, '--provider', 'generic', '--once', '--json']); assert.equal(third.code, 0); assert.equal(JSON.parse(third.stdout).outcomes.length, 0);
92
- assert.equal(f.calls.filter(call => call.path.endsWith('/content')).length, 1); assert.equal(f.calls.filter(call => call.path.endsWith('/prepare')).length, 1);
93
- });
94
- test('interrupt aborts an in-flight job request locally and keeps exact ID', async t => {
95
- const f = await fixture(t); f.state.holdJob = true;
96
- const result = await f.run(['jobs', 'wait', jobId, '--json'], child => {
97
- const poll = setInterval(() => { if (f.calls.some(call => call.path === `/api/v1/jobs/${jobId}`)) { clearInterval(poll); child.kill('SIGINT'); } }, 10);
98
- child.on('exit', () => clearInterval(poll));
99
- });
100
- assert.equal(result.code, 1); assert.equal(result.signal, null); assert.equal(JSON.parse(result.stdout).job_id, jobId); assert.equal(JSON.parse(result.stdout).wait.outcome, 'interrupted'); assert.equal(f.calls.length, 1);
101
- });
102
- test('strict flags fail with usable JSON and no network; human search escapes controls while JSON preserves bytes', async t => {
103
- const f = await fixture(t);
104
- for (const args of [['ingest', f.file, '--timeout', '1'], ['ingest', f.file, '--wait=false'], ['ingest', f.file, '--wait', '--wait'], ['jobs', 'wait', jobId, '--retry'], ['ingest', f.file, '--surprise', 'x']]) {
105
- const result = await f.run([...args, '--json']); assert.equal(result.code, 1); assert(JSON.parse(result.stdout).error);
106
- }
107
- assert.equal(f.calls.length, 0);
108
- const human = await f.run(['search', 'test']); assert.equal(human.code, 0); assert(!human.stdout.includes('\u001b')); assert(human.stdout.includes('\\u001b')); assert(human.stdout.includes('keep\nline'));
109
- const json = await f.run(['search', 'test', '--json']); assert.equal(JSON.parse(json.stdout).results[0].title, 'bad\u001b[2J');
110
- const text = path.join(f.dir, 'note.txt'); await fs.writeFile(text, 'synchronous note');
111
- const inline = await f.run(['import', text, '--wait', '--json']); assert.equal(inline.code, 0); assert.equal(JSON.parse(inline.stdout).id, 'doc_' + 'c'.repeat(32)); assert(!JSON.parse(inline.stdout).job_id);
112
- });
113
-
114
-
115
- test('watch treats changed explicit source or visibility as a separate intent and preserves old pending scope', async t => {
116
- const f = await fixture(t); const folder = path.join(f.dir, 'exports'); await fs.mkdir(folder); await fs.writeFile(path.join(folder, 'chat.json'), '{"content":"synthetic fixture"}');
117
- f.state.losePrepare = true;
118
- const first = await f.run(['watch', folder, '--provider', 'generic', '--source', 'src_private', '--once', '--json']);
119
- assert.equal(first.code, 1);
120
- const next = await f.run(['watch', folder, '--provider', 'generic', '--source', 'src_shared', '--visibility', 'workspace', '--once', '--json']);
121
- assert.equal(next.code, 0);
122
- const states = await Promise.all((await fs.readdir(path.join(f.dir, 'config'))).filter(name => name.endsWith('.json')).map(name => fs.readFile(path.join(f.dir, 'config', name), 'utf8').then(JSON.parse)));
123
- assert.equal(states.length, 2);
124
- assert(Object.values(states.find(value => value.scope.source_id === 'src_private').entries)[0].pending);
125
- assert(Object.values(states.find(value => value.scope.source_id === 'src_shared').entries)[0].accepted);
126
- assert.equal(f.calls.filter(call => call.path.endsWith('/prepare')).length, 2);
127
- assert.equal(f.calls.filter(call => call.path.endsWith('/content')).length, 1);
128
- });
129
-
130
- test('unscoped legacy watcher state is preserved and blocks automatic migration or new uploads', async t => {
131
- const f = await fixture(t); const folder = path.join(f.dir, 'exports'); await fs.mkdir(folder); const file = path.join(folder, 'chat.json'); await fs.writeFile(file, '{"content":"synthetic fixture"}');
132
- const root = await fs.realpath(folder), identity = crypto.createHash('sha256').update(f.env.OPENCRAWL_API_KEY).digest('hex').slice(0,16);
133
- const name = crypto.createHash('sha256').update(`${f.env.OPENCRAWL_URL}:${identity}:${root}:generic`).digest('hex').slice(0,24);
134
- const config = path.join(f.dir, 'config'); await fs.mkdir(config);
135
- const legacy = path.join(config, `watch-${name}.json`), original = JSON.stringify({ [file]: '25:1' }); await fs.writeFile(legacy, original);
136
- const result = await f.run(['watch', folder, '--provider', 'generic', '--once', '--json']);
137
- assert.equal(result.code, 1); assert(JSON.parse(result.stdout).error.message.includes('Older watcher state'));
138
- assert.equal(await fs.readFile(legacy, 'utf8'), original);
139
- assert(!f.calls.some(call => call.method === 'POST')); assert.equal((await fs.readdir(config)).length, 1);
140
- });
package/src/uploads.mjs DELETED
@@ -1,141 +0,0 @@
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
- }
@@ -1,64 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import fs from 'node:fs/promises';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import crypto from 'node:crypto';
7
- import { submitUpload, fileDescriptor, followUpload, uploadExitCode, requestKey } from './uploads.mjs';
8
-
9
- const key = 'abcd1234-1234-4234-8234-abcd12345678', id = 'receipt_' + 'b'.repeat(32), jobId = 'job_' + 'a'.repeat(32);
10
- const receipt = (state, overrides = {}) => ({ id, request_key: key, state, operation: 'upload', upload_url: `/api/v1/uploads/receipts/${id}/content`, job: state === 'accepted' ? { id: jobId, kind: 'upload', status: 'queued' } : null, ...overrides });
11
- async function fixture(t) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-upload-')); t.after(() => fs.rm(dir, { recursive: true, force: true })); const file = path.join(dir, 'binary.pdf'); await fs.writeFile(file, Buffer.from([1,2,0,255,127])); return file; }
12
- const quiet = { stderr: () => {} };
13
- test('upload hashes exact bytes then sends one multipart file and no duplicated scope fields', async t => {
14
- const file = await fixture(t); const events = [];
15
- const client = { request: async (route, options) => {
16
- events.push(route);
17
- if (route.endsWith('/prepare')) { assert.equal(options.body.sha256, crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex')); assert.equal(options.body.visibility, 'workspace'); assert.equal(options.body.source_id, 'src_test'); return receipt('prepared'); }
18
- assert.deepEqual([...options.body.keys()], ['file']); assert.deepEqual(Buffer.from(await options.body.get('file').arrayBuffer()), await fs.readFile(file)); return receipt('accepted');
19
- } };
20
- const result = await submitUpload(file, client, { 'request-key': key, source: 'src_test', visibility: 'workspace' }, { ...quiet, onIntent: async () => { events.push('persist-intent'); } });
21
- assert.equal(events[0], 'persist-intent'); assert.equal(events.length, 3); assert.equal(result.job_id, jobId); assert.equal(uploadExitCode(result, false), 0);
22
- });
23
- test('accepted, receiving, and expired prepare receipts never resend file bytes', async t => {
24
- const file = await fixture(t);
25
- for (const state of ['accepted','receiving','expired']) {
26
- const calls = [];
27
- const result = await submitUpload(file, { request: async route => { calls.push(route); return receipt(state); } }, { 'request-key': key }, quiet);
28
- assert.deepEqual(calls, ['/api/v1/uploads/prepare']); assert.equal(result.outcome, state);
29
- }
30
- });
31
- test('lost prepare response only looks up, even when lookup says prepared', async t => {
32
- const file = await fixture(t); const calls = [];
33
- const result = await submitUpload(file, { request: async route => { calls.push(route); if (calls.length === 1) throw Error('dropped'); return receipt('prepared'); } }, { 'request-key': key }, quiet);
34
- assert.deepEqual(calls, ['/api/v1/uploads/prepare', `/api/v1/uploads/receipts/by-key/${key}`]); assert.equal(result.outcome, 'prepared'); assert.equal(uploadExitCode(result, false), 1);
35
- });
36
- test('lost acceptance resolves to original job by lookup without resending', async t => {
37
- const file = await fixture(t); const calls = [];
38
- const result = await submitUpload(file, { request: async route => { calls.push(route); if (route.endsWith('/prepare')) return receipt('prepared'); if (route.endsWith('/content')) throw Error('dropped acceptance'); return receipt('accepted'); } }, { 'request-key': key }, quiet);
39
- assert.equal(calls.length, 3); assert.equal(result.job_id, jobId); assert.equal(result.outcome, 'accepted'); assert.equal(calls.filter(route => route.endsWith('/content')).length, 1);
40
- });
41
- test('descriptor conflicts do not treat the old accepted job as a new successful upload', async t => {
42
- const file = await fixture(t); let calls = 0;
43
- const result = await submitUpload(file, { request: async () => { calls++; const error = Error('private server body'); error.status = 409; throw error; } }, { 'request-key': key }, quiet);
44
- assert.equal(calls, 1); assert.equal(result.request_key, key); assert.equal(result.job_id, null); assert.equal(result.error.status, 409); assert(!JSON.stringify(result).includes('private server'));
45
- });
46
- test('unknown outcomes retain request key and receipt ID; foreign upload routes never receive bytes', async t => {
47
- const file = await fixture(t); const result = await submitUpload(file, { request: async route => { if (route.endsWith('/prepare')) return receipt('prepared'); throw Error('network'); } }, { 'request-key': key }, quiet);
48
- assert.equal(result.request_key, key); assert.equal(result.receipt_id, id); assert.equal(result.job_id, null); assert.equal(result.outcome, 'unconfirmed'); assert.equal(result.receipt.state, 'prepared');
49
- let calls = 0; const foreign = await submitUpload(file, { request: async () => { calls++; return receipt('prepared', { upload_url: 'https://other.example/upload' }); } }, { 'request-key': key }, quiet);
50
- assert.equal(calls, 1); assert.equal(foreign.error.code, 'invalid_upload_route');
51
- });
52
- test('receiving wait checks receipt then existing job with one bounded deadline', async () => {
53
- let time = 0; const calls = [];
54
- const result = await followUpload({ request_key: key, receipt_id: id, receipt: receipt('receiving'), job_id: null, outcome: 'receiving' }, { request: async route => { calls.push(route); return route.includes('/receipts/') ? receipt('accepted') : { id: jobId, status: 'completed', kind: 'upload' }; } }, { timeout: 3, now: () => time, sleep: async ms => { time += ms; } });
55
- assert.equal(result.wait.outcome, 'completed'); assert.deepEqual(calls, [`/api/v1/uploads/receipts/by-key/${key}`, `/api/v1/jobs/${jobId}`]);
56
- const timeout = await followUpload({ request_key: key, receipt: receipt('receiving'), outcome: 'receiving' }, { request: async () => { throw Error('not reached'); } }, { timeout: 1, now: () => time, sleep: async ms => { time += ms; } });
57
- assert.equal(timeout.wait.outcome, 'timeout'); assert.equal(timeout.request_key, key);
58
- });
59
- test('file validation and failed durable intent precede all network activity', async t => {
60
- const file = await fixture(t); let calls = 0; const client = { request: async () => { calls++; } };
61
- await assert.rejects(submitUpload(file, client, {}, { ...quiet, onIntent: async () => { throw Error('disk full'); } }), /disk full/);
62
- await assert.rejects(fileDescriptor(file, { visibility: 'public' }), /visibility/);
63
- assert.throws(() => requestKey('invalid'), /UUID/); await fs.writeFile(file, ''); await assert.rejects(fileDescriptor(file), /nonempty/); assert.equal(calls, 0);
64
- });