@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
package/browser.mjs DELETED
@@ -1,166 +0,0 @@
1
- /** Explicitly scoped, read-only browser capture using an isolated OpenCrawl profile. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import { createInterface } from 'node:readline/promises';
6
- import { configDir } from './src/client.mjs';
7
-
8
- export function validateCaptureUrl(value) {
9
- let url;
10
- try { url = new URL(value); } catch { throw new Error('Browser capture needs an absolute URL.'); }
11
- if (url.username || url.password) throw new Error('Browser URLs cannot contain credentials.');
12
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
13
- throw new Error('Browser capture requires HTTPS, or HTTP on localhost.');
14
- }
15
- const mutationPath = /(?:^|\/)(?:logout|log-out|signout|sign-out|delete|remove|destroy|unsubscribe|deactivate|terminate)(?:\/|$)/i;
16
- if (mutationPath.test(decodeURIComponent(url.pathname)) || [...url.searchParams.keys()].some(key => /^(action|delete|remove|logout|unsubscribe)$/i.test(key))) {
17
- throw new Error('This URL appears to perform an action. Select a page that displays records.');
18
- }
19
- url.hash = '';
20
- return url;
21
- }
22
-
23
- export function isReadOnlyRequest(method, postData) {
24
- if (['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) return true;
25
- if (method.toUpperCase() !== 'POST' || !postData) return false;
26
- try {
27
- const payload = JSON.parse(postData);
28
- const list = Array.isArray(payload) ? payload : [payload];
29
- return list.length > 0 && list.every(item => typeof item.query === 'string' && /^\s*(?:#[^\n]*\n\s*)*(?:query\b|\{)/.test(item.query) && !/\bmutation\b/.test(item.query));
30
- } catch { return false; }
31
- }
32
-
33
- async function playwrightModule() {
34
- try { return await import('playwright'); }
35
- catch { throw new Error('Browser capture needs the optional Playwright package. Install the CLI with optional dependencies, then run: opencrawl browser setup'); }
36
- }
37
- async function profile(name, origin) {
38
- if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error('Choose a simple profile name using letters, digits, dashes, or underscores.');
39
- const directory = path.join(configDir(), 'browser-profiles', name);
40
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
41
- await fs.chmod(directory, 0o700);
42
- const metadataFile = path.join(directory, 'profile.json');
43
- let metadata;
44
- try { metadata = JSON.parse(await fs.readFile(metadataFile, 'utf8')); }
45
- catch (error) { if (error.code !== 'ENOENT') throw new Error('The OpenCrawl browser profile is invalid. Choose a new profile name.'); }
46
- if (metadata && metadata.origin !== origin) throw new Error(`Profile ${name} is scoped to ${metadata.origin}. Choose a separate --profile for another origin.`);
47
- if (!metadata) await fs.writeFile(metadataFile, JSON.stringify({ origin, created_at: new Date().toISOString() }), { mode: 0o600 });
48
- return directory;
49
- }
50
-
51
- export async function browserCommand(action, target, options, client, overrides = {}) {
52
- if (!['setup', 'login', 'capture', 'crawl'].includes(action)) throw new Error('Use browser setup, login URL, capture URL, or crawl URL.');
53
- const playwright = overrides.playwright || await playwrightModule();
54
- if (action === 'setup') {
55
- // Invoke Playwright's own browser installer without a shell.
56
- const { spawn } = await import('node:child_process');
57
- const { createRequire } = await import('node:module');
58
- const require = createRequire(import.meta.url);
59
- const entry = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
60
- await new Promise((resolve, reject) => {
61
- const child = spawn(process.execPath, [entry, 'install', 'chromium'], { stdio: 'inherit' });
62
- child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error('Browser installation failed.')));
63
- });
64
- return { installed: 'chromium' };
65
- }
66
- const selected = validateCaptureUrl(target);
67
- const directory = await profile(options.profile || 'default', selected.origin);
68
- const maxPages = action === 'crawl' ? Number(options['max-pages'] || 10) : 1;
69
- if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100) throw new Error('--max-pages must be between 1 and 100.');
70
- const prefix = options['path-prefix'] || (selected.pathname.endsWith('/') ? selected.pathname : selected.pathname.slice(0, selected.pathname.lastIndexOf('/') + 1));
71
- if (!prefix.startsWith('/')) throw new Error('--path-prefix must start with /.');
72
- const inScope = value => {
73
- try { const url = validateCaptureUrl(value); return url.origin === selected.origin && (action !== 'crawl' || url.pathname.startsWith(prefix)); }
74
- catch { return false; }
75
- };
76
- const queue = [selected.href];
77
- if (options.urls) {
78
- if (action !== 'capture') throw new Error('--urls can be used with browser capture only.');
79
- const content = await fs.readFile(path.resolve(options.urls), 'utf8');
80
- let urls;
81
- try { urls = JSON.parse(content); } catch { urls = content.split('\n').map(line => line.trim()).filter(Boolean); }
82
- if (!Array.isArray(urls) || urls.length > 100 || !urls.every(url => typeof url === 'string' && inScope(url))) {
83
- throw new Error('--urls must contain at most 100 explicit page URLs on the selected origin.');
84
- }
85
- queue.push(...urls);
86
- }
87
- let context;
88
- try {
89
- context = await playwright.chromium.launchPersistentContext(path.join(directory, 'chromium'), {
90
- headless: action === 'login' || options.interactive ? false : Boolean(options.headless), acceptDownloads: false,
91
- permissions: [], viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block',
92
- });
93
- } catch { throw new Error('Could not launch the isolated OpenCrawl browser. Run opencrawl browser setup; close any other command using this profile.'); }
94
- const results = [], warnings = [], seen = new Set();
95
- let blocked = 0;
96
- try {
97
- const page = context.pages()[0] || await context.newPage();
98
- if (action === 'login' || options.interactive) {
99
- await page.goto(selected.href, { waitUntil: 'domcontentloaded', timeout: 45000 });
100
- process.stderr.write(`Sign in manually in the isolated OpenCrawl browser. This profile is reserved for ${selected.origin}.\n`);
101
- if (!process.stdin.isTTY && !overrides.waitForLogin) throw new Error('Browser login requires an interactive terminal.');
102
- if (overrides.waitForLogin) await overrides.waitForLogin(page);
103
- else { const input = createInterface({ input: process.stdin, output: process.stderr }); try { await input.question('After signing in, press Enter here to save the isolated session: '); } finally { input.close(); } }
104
- if (action === 'login') return { profile: options.profile || 'default', origin: selected.origin, message: 'Session saved locally. Run browser capture with the page URL you want to index.' };
105
- }
106
- if (!client.configured) throw new Error('Run opencrawl login before capturing pages.');
107
- await context.route('**/*', async route => {
108
- const request = route.request();
109
- if (!isReadOnlyRequest(request.method(), request.postData())) { blocked++; await route.abort('blockedbyclient'); return; }
110
- if (request.isNavigationRequest() && request.frame() === page.mainFrame() && !inScope(request.url())) { blocked++; await route.abort('blockedbyclient'); return; }
111
- await route.continue();
112
- });
113
- // Browser popups are not part of the selected capture scope.
114
- context.on('page', popup => { if (popup !== page) popup.close().catch(() => {}); });
115
- while (queue.length && seen.size < (action === 'crawl' ? maxPages : 101)) {
116
- const url = queue.shift();
117
- if (seen.has(url) || !inScope(url)) continue;
118
- seen.add(url);
119
- try {
120
- const response = options.interactive && page.url() === url ? null : await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
121
- if (response && response.status() >= 400) throw new Error(`HTTP ${response.status()}`);
122
- await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
123
- if (!inScope(page.url())) throw new Error('The page redirected outside the selected capture scope.');
124
- const extracted = await page.evaluate(() => {
125
- const root = (document.querySelector('main, [role="main"], article') || document.body).cloneNode(true);
126
- for (const element of root.querySelectorAll('script,style,noscript,template,input,textarea,select,[contenteditable="true"],[aria-hidden="true"],[hidden]')) element.remove();
127
- const blocks = new Set(['P','DIV','SECTION','ARTICLE','H1','H2','H3','H4','LI','TR','BR']);
128
- const readable = node => node.nodeType === Node.TEXT_NODE ? node.textContent : [...node.childNodes].map(readable).join('') + (blocks.has(node.nodeName) ? '\n' : node.nodeName === 'TD' ? ' | ' : '');
129
- const text = readable(root).replace(/\s+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
130
- return { title: document.title, text, links: [...document.querySelectorAll('a[href]')].map(link => link.href),
131
- headings: [...document.querySelectorAll('h1,h2,h3')].map(heading => heading.textContent.trim()).filter(Boolean) };
132
- });
133
- if (!extracted.text || extracted.text.length < 30) throw new Error('No readable page content. Sign in with browser login, then capture the record page.');
134
- const document = { title: (extracted.title || url).slice(0, 500), content: extracted.text.slice(0, 2_000_000),
135
- external_id: `browser:${url}`, kind: 'web', url, visibility: 'private', metadata: {
136
- capture: 'isolated-browser', origin: selected.origin, captured_at: new Date().toISOString(), headings: extracted.headings,
137
- content_complete: extracted.text.length <= 2_000_000, blocked_mutating_requests: blocked,
138
- } };
139
- const imported = await client.request('/api/v1/documents', { method: 'POST', body: document });
140
- let screenshot, ocrJob;
141
- if (options.screenshots || options.ocr) {
142
- const output = options.screenshots ? path.resolve(options.screenshots) : path.join(directory, 'captures'); await fs.mkdir(output, { recursive: true, mode: 0o700 });
143
- screenshot = path.join(output, crypto.createHash('sha256').update(url).digest('hex').slice(0, 16) + '.png');
144
- await page.screenshot({ path: screenshot, fullPage: true, mask: [page.locator('input,textarea,[contenteditable="true"]')] });
145
- await fs.chmod(screenshot, 0o600);
146
- if (options.ocr) {
147
- const form = new FormData();
148
- form.append('file', new Blob([await fs.readFile(screenshot)], { type: 'image/png' }), path.basename(screenshot));
149
- form.append('visibility', 'private');
150
- const uploaded = await client.request('/api/v1/uploads', { method: 'POST', body: form });
151
- ocrJob = uploaded.job?.id;
152
- }
153
- }
154
- results.push({ url, title: document.title, document_id: imported.id || imported.document?.id, characters: document.content.length, screenshot, ocr_job_id: ocrJob });
155
- if (action === 'crawl') {
156
- for (const link of extracted.links) {
157
- if (queue.length >= 1000) break;
158
- if (inScope(link)) { const canonical = validateCaptureUrl(link).href; if (!seen.has(canonical)) queue.push(canonical); }
159
- }
160
- }
161
- } catch (error) { warnings.push({ url, error: String(error.message).split('\n')[0].slice(0, 300) }); }
162
- }
163
- if (blocked) warnings.push({ message: `${blocked} requests outside read-only capture scope were blocked. Some applications require additional supported read endpoints to render fully.` });
164
- return { imported: results.length, pages: results, warnings, origin: selected.origin, profile: options.profile || 'default' };
165
- } finally { await context.close(); }
166
- }
@@ -1,223 +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 { validateBaseUrl, resolveCredentials, loggedInConfig, loggedOutConfig, profileSummaries, createClient } from './client.mjs';
10
-
11
- const identity = (digit = 'a') => ({ user: { id: 'usr_' + digit.repeat(32), name: 'Synthetic user' },
12
- workspace: { id: 'ws_' + digit.repeat(32), name: 'Synthetic workspace' } });
13
- const saved = () => loggedInConfig({}, 'https://a.example', 'oc_synthetic_a', identity());
14
-
15
- test('origins canonicalize standard ports and reject path/credential surprises', () => {
16
- for (const value of ['https://A.example:443/', 'https://a.example', ' https://a.example/ '])
17
- assert.equal(validateBaseUrl(value), 'https://a.example');
18
- assert.equal(validateBaseUrl('http://localhost:80/'), 'http://localhost');
19
- for (const value of ['https://a.example/api', 'https://a.example/..', 'https://a.example/%2e/',
20
- 'https://a.example\\anything', 'https://user:pass@a.example', 'https://a.example?token=x',
21
- 'https://a.example/#fragment', 'file:///tmp', 'http://remote.example'])
22
- assert.throws(() => validateBaseUrl(value));
23
- });
24
-
25
- test('saved credentials are origin-bound across arguments, environment, and configured defaults', () => {
26
- const config = saved();
27
- assert.equal(resolveCredentials(config, { server: 'https://A.example:443/' }, {}).token, 'oc_synthetic_a');
28
- for (const [value, overrides, environment] of [
29
- [config, { server: 'https://b.example' }, {}], [config, {}, { OPENCRAWL_URL: 'https://b.example' }],
30
- [{ ...config, server: 'https://b.example' }, {}, {}], [config, { server: 'https://a.example:444' }, {}]]) {
31
- const choice = resolveCredentials(value, overrides, environment);
32
- assert.equal(choice.token, null);assert.match(choice.credentialError, /another server/);
33
- }
34
- });
35
-
36
- test('legacy unbound keys never migrate by guessing their current destination', () => {
37
- const config = { server: 'https://b.example', apiKey: 'oc_old_key', otherSetting: 'keep' };
38
- for (const overrides of [{}, { server: 'https://a.example' }]) {
39
- const choice = resolveCredentials(config, overrides, {});
40
- assert.equal(choice.token, null);assert.match(choice.credentialError, /predates server binding/);
41
- }
42
- const upgraded = loggedInConfig(config, 'https://b.example', 'oc_explicit_new', identity('b'));
43
- assert.equal(upgraded.version, 2);assert.equal(upgraded.otherSetting, 'keep');assert.equal(upgraded.apiKey, undefined);
44
- assert.equal(config.apiKey, 'oc_old_key');
45
- });
46
-
47
- test('intentional explicit credentials stay supported without empty-value saved fallback', () => {
48
- const config = saved();
49
- assert.equal(resolveCredentials(config, { server: 'https://b.example', token: 'oc_explicit_b' }, {}).token, 'oc_explicit_b');
50
- assert.equal(resolveCredentials(config, {}, { OPENCRAWL_URL: 'https://b.example', OPENCRAWL_API_KEY: 'oc_explicit_b' }).token, 'oc_explicit_b');
51
- for (const token of ['', ' ', 'bad\nvalue']) {
52
- assert.throws(() => resolveCredentials(config, { token }, {}));
53
- assert.throws(() => resolveCredentials(config, {}, { OPENCRAWL_API_KEY: token }));
54
- }
55
- });
56
-
57
- test('explicit account pins win over global defaults and ambient URL/key changes', () => {
58
- const a = saved(), account = a.activeProfile;
59
- const config = loggedInConfig(a, 'https://b.example', 'oc_synthetic_b', identity('b'));
60
- const choice = resolveCredentials(config, { account }, { OPENCRAWL_URL: 'https://c.example', OPENCRAWL_API_KEY: 'oc_ambient' });
61
- assert.equal(choice.baseUrl, 'https://a.example');assert.equal(choice.token, 'oc_synthetic_a');
62
- assert.throws(() => resolveCredentials(config, { account, server: 'https://b.example' }, {}), /bound to another server/);
63
- assert.throws(() => resolveCredentials(config, { account: 'missing' }, {}), /not found/);
64
- });
65
-
66
- test('key rotation preserves profile identity and logout leaves other accounts alone', () => {
67
- let config = saved();const first = config.activeProfile;
68
- config = loggedInConfig(config, 'https://b.example', 'oc_synthetic_b', identity('b'));const second = config.activeProfile;
69
- config = loggedInConfig(config, 'https://a.example:443/', 'oc_rotated_a', identity());
70
- assert.equal(config.activeProfile, first);assert.equal(Object.keys(config.profiles).length, 2);
71
- const removed = loggedOutConfig(config, first);
72
- assert.equal(resolveCredentials(removed, { account: first }, {}).token, null);
73
- assert.equal(resolveCredentials(removed, { account: second }, {}).token, 'oc_synthetic_b');
74
- assert.equal(JSON.stringify(profileSummaries(config)).includes('oc_rotated_a'), false);
75
- assert.equal(JSON.stringify(profileSummaries(config)).includes('apiKey'), false);
76
- });
77
-
78
- test('invalid or tampered identity bindings fail before credential selection', () => {
79
- assert.throws(() => loggedInConfig({}, 'https://a.example', 'oc_key', { user: { id: 'bad' }, workspace: {} }), /valid workspace identity/);
80
- const config = saved();config.profiles[config.activeProfile].origin = 'https://b.example';
81
- assert.throws(() => resolveCredentials(config, {}, {}), /binding is invalid/);
82
- });
83
-
84
- test('exported client rejects route-based host and port changes before fetch', async t => {
85
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-route-'));
86
- const previous = process.env.OPENCRAWL_CONFIG_DIR;
87
- process.env.OPENCRAWL_CONFIG_DIR = directory;
88
- const calls = [];
89
- t.mock.method(globalThis, 'fetch', async (url, options) => {
90
- calls.push({ url, options });return new Response('{}', { status: 200 });
91
- });
92
- try {
93
- const client = await createClient({ server: 'https://a.example', token: 'oc_synthetic_a' });
94
- for (const route of ['@b.example/path', ':444/path', 'https://b.example/path', '//b.example/path',
95
- '/\\b.example/path', '\\b.example/path', ' /api/v1/usage', '/\n/b.example/path', null])
96
- await assert.rejects(client.request(route), /routes must be absolute paths/);
97
- assert.equal(calls.length, 0);
98
- await client.request('/api/v1/usage?query=synthetic%20value');
99
- assert.equal(calls.length, 1);
100
- assert.equal(calls[0].url, 'https://a.example/api/v1/usage?query=synthetic%20value');
101
- assert.equal(calls[0].options.headers.Authorization, 'Bearer oc_synthetic_a');
102
- assert.equal(calls[0].options.redirect, 'error');
103
- } finally {
104
- if (previous === undefined) delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR = previous;
105
- await fs.rm(directory, { recursive: true, force: true });
106
- }
107
- });
108
-
109
- const script = fileURLToPath(new URL('../bin/opencrawl.mjs', import.meta.url));
110
- function run(args, env, cwd, input = '') {
111
- return new Promise((resolve, reject) => {
112
- const child = spawn(process.execPath, [script, ...args], { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
113
- let stdout = '', stderr = '';const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
114
- child.stdout.on('data', value => { stdout += value });child.stderr.on('data', value => { stderr += value });
115
- child.on('error', reject);child.on('close', code => { clearTimeout(timer);resolve({ code, stdout, stderr }) });child.stdin.end(input);
116
- });
117
- }
118
- async function fixtureServer(digit) {
119
- const calls = [], key = 'oc_synthetic_' + digit;
120
- const server = http.createServer(async (request, response) => {
121
- let raw = '';for await (const part of request) raw += part;
122
- calls.push({ path: request.url, authorization: request.headers.authorization });
123
- let result = { status: 'ok' };
124
- if (request.url !== '/healthz' && request.headers.authorization !== 'Bearer ' + key) {
125
- response.writeHead(401, { 'Content-Type': 'application/json' });response.end('{"detail":"Synthetic key rejected"}');return;
126
- }
127
- if (request.url === '/api/auth/me') result = identity(digit);
128
- else if (request.url === '/mcp') { const message = JSON.parse(raw);result = { jsonrpc: '2.0', id: message.id, result: { tools: [], workspace: digit } }; }
129
- else if (request.url === '/api/v1/context') result = { context: '[S1] Synthetic workspace ' + digit };
130
- else result = { workspace: digit };
131
- response.writeHead(200, { 'Content-Type': 'application/json' });response.end(JSON.stringify(result));
132
- });
133
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
134
- return { calls, key, url: 'http://127.0.0.1:' + server.address().port,
135
- close: () => new Promise(resolve => server.close(resolve)) };
136
- }
137
-
138
- test('real two-origin commands never send a saved key to a changed destination', async () => {
139
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-accounts-'));
140
- const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
141
- env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');
142
- const a = await fixtureServer('a'), b = await fixtureServer('b');
143
- const configPath = path.join(env.OPENCRAWL_CONFIG_DIR, 'config.json');
144
- try {
145
- let result = await run(['login', '--server', a.url], env, directory, a.key + '\n');
146
- assert.equal(result.code, 0, result.stderr);assert.equal(result.stdout.includes(a.key), false);
147
- const account = JSON.parse(await fs.readFile(configPath)).activeProfile;
148
- assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600);
149
- for (const [args, extra] of [[['status', '--server', b.url], {}], [['status'], { OPENCRAWL_URL: b.url }]]) {
150
- result = await run(args, { ...env, ...extra }, directory);
151
- assert.equal(result.code, 1);assert.equal(b.calls.length, 0);
152
- }
153
- assert.equal((await run(['configure', '--server', b.url], env, directory)).code, 0);
154
- assert.equal((await run(['status'], env, directory)).code, 1);assert.equal(b.calls.length, 0);
155
- const doctor = await run(['doctor'], env, directory);
156
- assert.equal(doctor.code, 1);assert.equal(b.calls.at(-1).path, '/healthz');assert.equal(b.calls.at(-1).authorization, undefined);
157
- result = await run(['status'], { ...env, OPENCRAWL_API_KEY: b.key }, directory);
158
- assert.equal(result.code, 0);assert.equal(b.calls.at(-1).authorization, 'Bearer ' + b.key);
159
- result = await run(['login', '--account', account], { ...env, OPENCRAWL_URL: b.url }, directory, a.key + '\n');
160
- assert.equal(result.code, 0, result.stderr);assert.equal(a.calls.at(-1).path, '/api/auth/me');
161
- assert.equal(b.calls.some(call => call.authorization === 'Bearer ' + a.key), false);
162
- const before = await fs.readFile(configPath, 'utf8');
163
- result = await run(['login', '--account', account, '--server', b.url], env, directory, a.key + '\n');
164
- assert.equal(result.code, 1);assert.equal(await fs.readFile(configPath, 'utf8'), before);
165
- } finally { await Promise.all([a.close(), b.close()]);await fs.rm(directory, { recursive: true, force: true }); }
166
- });
167
-
168
- test('installed MCP and hook stay pinned after another login and ambient configuration changes', async () => {
169
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-pins-'));
170
- const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
171
- env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');
172
- const a = await fixtureServer('a'), b = await fixtureServer('b');
173
- try {
174
- assert.equal((await run(['login', '--server', a.url], env, directory, a.key + '\n')).code, 0);
175
- for (const target of ['claude-code', 'cursor', 'codex']) assert.equal((await run(['install', target], env, directory)).code, 0);
176
- const mcp = JSON.parse(await fs.readFile(path.join(directory, '.mcp.json'), 'utf8')).mcpServers.opencrawl;
177
- const settings = await fs.readFile(path.join(directory, '.claude/settings.local.json'), 'utf8');
178
- const codex = await fs.readFile(path.join(directory, '.codex/config.toml'), 'utf8');
179
- assert.ok(mcp.args.includes('--account') && mcp.args.includes(a.url) && mcp.args.includes('--config-dir'));
180
- for (const value of [JSON.stringify(mcp), settings, codex]) assert.ok(!value.includes(a.key) && value.includes('--account'));
181
- assert.equal((await run(['login', '--server', b.url], env, directory, b.key + '\n')).code, 0);
182
- const before = b.calls.length;
183
- const legacyBridge = await run(['mcp'], env, directory, '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n');
184
- assert.equal(legacyBridge.code, 1);assert.match(legacyBridge.stderr, /pinned account/);assert.equal(b.calls.length, before);
185
- assert.equal((await run(['hook'], env, directory, '{"prompt":"Synthetic question"}')).stdout, '');
186
- const changedEnv = { ...env, OPENCRAWL_URL: b.url, OPENCRAWL_API_KEY: b.key, OPENCRAWL_CONFIG_DIR: path.join(directory, 'unrelated') };
187
- const argumentsAfterScript = mcp.args.slice(1);
188
- const response = await run(argumentsAfterScript, changedEnv, directory, '{"jsonrpc":"2.0","id":7,"method":"tools/list"}\n');
189
- assert.equal(response.code, 0, response.stderr);assert.equal(JSON.parse(response.stdout).result.workspace, 'a');
190
- const hooked = await run(['hook', ...argumentsAfterScript.slice(1)], changedEnv, directory, '{"prompt":"Synthetic question"}');
191
- assert.equal(hooked.code, 0);assert.match(JSON.parse(hooked.stdout).hookSpecificOutput.additionalContext, /workspace a/);
192
- assert.equal(b.calls.some(call => call.path === '/mcp' || call.path === '/api/v1/context'), false);
193
- const account = argumentsAfterScript[argumentsAfterScript.indexOf('--account') + 1];
194
- assert.equal((await run(['logout', '--account', account], env, directory)).code, 0);
195
- const disconnected = await run(argumentsAfterScript, changedEnv, directory, '{"jsonrpc":"2.0","id":8,"method":"tools/list"}\n');
196
- assert.ok(JSON.parse(disconnected.stdout).error);assert.equal(b.calls.some(call => call.path === '/mcp'), false);
197
- } finally { await Promise.all([a.close(), b.close()]);await fs.rm(directory, { recursive: true, force: true }); }
198
- });
199
-
200
- test('legacy migration requires explicit login and failed login leaves the old file untouched', async () => {
201
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-legacy-'));
202
- const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
203
- env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');const server = await fixtureServer('a');
204
- try {
205
- await fs.mkdir(env.OPENCRAWL_CONFIG_DIR);
206
- const file = path.join(env.OPENCRAWL_CONFIG_DIR, 'config.json');
207
- const legacy = JSON.stringify({ server: server.url, apiKey: 'oc_unknown_origin', unrelated: 'preserved' });
208
- await fs.writeFile(file, legacy, { mode: 0o600 });
209
- const denied = await run(['status'], env, directory);
210
- assert.equal(denied.code, 1);assert.match(denied.stderr, /predates server binding/);assert.equal(server.calls.length, 0);
211
- assert.equal((await run(['install', 'claude-code'], env, directory)).code, 1);
212
- await assert.rejects(fs.stat(path.join(directory, '.mcp.json')), { code: 'ENOENT' });
213
- const failed = await run(['login', '--server', server.url], env, directory, 'oc_explicit_invalid\n');
214
- assert.equal(failed.code, 1);assert.equal(await fs.readFile(file, 'utf8'), legacy);
215
- assert.equal((await run(['login', '--server', server.url], env, directory, server.key + '\n')).code, 0);
216
- const upgraded = JSON.parse(await fs.readFile(file));
217
- assert.equal(upgraded.version, 2);assert.equal(upgraded.apiKey, undefined);assert.equal(upgraded.unrelated, 'preserved');
218
- assert.equal((await run(['status'], env, directory)).code, 0);
219
- const inventory = await run(['profiles'], env, directory);
220
- assert.equal(JSON.parse(inventory.stdout).accounts.length, 1);assert.equal(inventory.stdout.includes(server.key), false);
221
- assert.equal(server.calls.some(call => call.authorization === 'Bearer oc_unknown_origin'), false);
222
- } finally { await server.close();await fs.rm(directory, { recursive: true, force: true }); }
223
- });
package/src/agent.mjs DELETED
@@ -1,95 +0,0 @@
1
- // Talk to the OpenCrawl agent from a terminal or a coding agent. Output is JSON for agents.
2
- import {settle, transient} from './jobs.mjs';
3
-
4
- export const AGENT_HELP = `Usage:
5
- opencrawl agent signup --organization NAME --email EMAIL [--create-new]
6
- opencrawl agent join POLL_URL after signup asks for approval: wait for an admin to approve, then save the key
7
- opencrawl agent "what you want to build" start a session
8
- opencrawl agent "answer" --session SESSION_ID continue it
9
- opencrawl agent --session SESSION_ID read the session (customer discovery progress, spec, project)
10
- opencrawl agent --session SESSION_ID --build build the project, or apply changes described on its session
11
- opencrawl agent --session SESSION_ID --new-key a new project key (the previous one stops working), if a key was lost
12
- opencrawl agent --session SESSION_ID --check verify app authorizations
13
- opencrawl agent --session SESSION_ID --connect APP add gmail, googledrive, notion, hubspot or granola to a built project
14
- opencrawl agent projects list built projects
15
- opencrawl agent memory what the OpenCrawl agent remembers for this organization
16
- opencrawl agent remember "note" add to that memory (or say "remember ..." in any message)
17
- Use an organization admin key (OPENCRAWL_API_KEY or opencrawl login). Signup needs no key.
18
- Signup and build save the keys they return as CLI accounts and owner-only key files, and print where
19
- instead of the key: use --account ACCOUNT on any command, or read key_file for MCP or your server.
20
- Signup makes the organization account the default; a project account is used with --account.
21
- Use the project account to read a project's watches, jobs and research. --print-keys prints keys instead.`;
22
-
23
- const SESSION = /^ags_[a-f0-9]{32}$/;
24
-
25
- export function agentRequest(rest, options) {
26
- const [first, ...more] = rest;
27
- if (first === 'signup') {
28
- if (!options.organization || !options.email) throw new Error('Use agent signup --organization NAME --email EMAIL.');
29
- return {route: '/api/v1/agent/signup', method: 'POST', authenticated: false,
30
- body: {organization: String(options.organization), email: String(options.email), agent: 'OpenCrawl CLI', ...(options['create-new'] ? {create_new: true} : {})}};
31
- }
32
- if (first === 'join') {
33
- const token = /^https?:\/\/[^/]+\/api\/v1\/agent\/signup\/([A-Za-z0-9_-]{20,64})$/.exec(String(more[0] ?? ''))?.[1];
34
- if (!token || more.length > 1) throw new Error('Use agent join POLL_URL, with the poll_url agent signup returned.');
35
- return {route: `/api/v1/agent/signup/${token}`, method: 'GET', authenticated: false, poll: true};
36
- }
37
- if (first === 'projects' && !more.length) return {route: '/api/v1/agent/projects', method: 'GET'};
38
- if (first === 'memory' && !more.length) return {route: '/api/v1/agent/memory', method: 'GET'};
39
- if (first === 'remember') {
40
- if (!more.join(' ').trim()) throw new Error('Use agent remember "note".');
41
- return {route: '/api/v1/agent/memory', method: 'POST', body: {note: more.join(' ').trim()}};
42
- }
43
- const message = rest.join(' ').trim();
44
- const session = options.session;
45
- if (session !== undefined && !SESSION.test(String(session))) throw new Error('--session must be a session ID returned by opencrawl agent.');
46
- if ([options.build, options.check, options.connect, options['new-key']].filter(Boolean).length > 1) throw new Error('Choose one of --build, --check, --connect or --new-key.');
47
- const action = options.build ? 'build' : options.check ? 'check_connections' : options.connect ? 'connect' : options['new-key'] ? 'issue_key' : undefined;
48
- if (action && !session) throw new Error(`--${options.build ? 'build' : options.check ? 'check' : options.connect ? 'connect' : 'new-key'} needs --session.`);
49
- if (options.connect === true) throw new Error('--connect needs an app name, such as granola.');
50
- if (!action && !message && session) return {route: `/api/v1/agent/sessions/${session}`, method: 'GET'};
51
- if (!action && !message) throw new Error('Say what you want to build, or use --session with --build or --check.');
52
- const body = {...(message ? {message} : {}), ...(action ? {action} : {}), ...(action === 'connect' ? {toolkit: String(options.connect)} : {})};
53
- return session ? {route: `/api/v1/agent/sessions/${session}/messages`, method: 'POST', body}
54
- : {route: '/api/v1/agent/sessions', method: 'POST', body};
55
- }
56
-
57
- // Keys are shown once. Keep them out of terminal output (and an agent's chat) unless asked: save them instead.
58
- export async function keepKeys(result, saveKey) {
59
- if (!result || typeof result !== 'object') return result;
60
- let next = result;
61
- if (typeof result.api_key === 'string') {
62
- const {api_key: key, ...rest} = result;
63
- next = {...rest, api_key_saved: {...await saveKey(key, {activate: true}), kind: 'organization'}};
64
- }
65
- if (typeof next.project?.api_key === 'string') {
66
- const {api_key: key, ...project} = next.project;
67
- next = {...next, project: {...project, api_key_saved: {...await saveKey(key, {activate: false}), kind: 'project'}}};
68
- }
69
- return next;
70
- }
71
-
72
- export async function agentCommand(rest, options, client, out = value => process.stdout.write(JSON.stringify(value, null, 2) + '\n'), saveKey,
73
- {now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
74
- const request = agentRequest(rest, options);
75
- // A join request waits up to the hour an approval link stays valid.
76
- const pollUntil = now() + 3600000;
77
- let deadline = now() + 120000, result, failures = 0;
78
- while (true) {
79
- try {
80
- result = await client.request(request.route, {method: request.method, body: request.body, authenticated: request.authenticated !== false, timeout: 120000});
81
- if (!request.poll || result?.status !== 'pending' || now() >= pollUntil) break;
82
- await sleep(10000); deadline = now() + 120000; failures = 0; continue;
83
- }
84
- catch (error) {
85
- // Reads are safe to repeat while the server restarts. A write's outcome is unknown, so say how to find out.
86
- if (request.method === 'GET' && transient(error) && now() < deadline) { await settle(++failures, {now, sleep, deadline}); continue; }
87
- if (transient(error) && request.method === 'POST' && options.session)
88
- error.message += ` The outcome is unknown: read the session (opencrawl agent --session ${options.session}) before sending again.` +
89
- (request.body?.action === 'build' ? ' If it shows the project built but you did not receive its key, use --new-key.' : '');
90
- throw error;
91
- }
92
- }
93
- out(saveKey && !options['print-keys'] ? await keepKeys(result, saveKey) : result);
94
- return 0;
95
- }
@@ -1,58 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import {test} from 'node:test';
3
- import {agentRequest, agentCommand} from './agent.mjs';
4
-
5
- const session = 'ags_' + 'a'.repeat(32);
6
- test('agent requests map messages, actions and signup to the OpenCrawl agent API', () => {
7
- assert.deepEqual(agentRequest(['watch', 'rival', 'changelogs'], {}), {route: '/api/v1/agent/sessions', method: 'POST', body: {message: 'watch rival changelogs'}});
8
- assert.deepEqual(agentRequest(['hourly'], {session}), {route: `/api/v1/agent/sessions/${session}/messages`, method: 'POST', body: {message: 'hourly'}});
9
- assert.deepEqual(agentRequest([], {session, build: true}).body, {action: 'build'});
10
- assert.deepEqual(agentRequest([], {session, check: true}).body, {action: 'check_connections'});
11
- assert.deepEqual(agentRequest([], {session, connect: 'granola'}).body, {action: 'connect', toolkit: 'granola'});
12
- assert.deepEqual(agentRequest(['projects'], {}), {route: '/api/v1/agent/projects', method: 'GET'});
13
- assert.deepEqual(agentRequest([], {session}), {route: `/api/v1/agent/sessions/${session}`, method: 'GET'});
14
- assert.deepEqual(agentRequest(['memory'], {}), {route: '/api/v1/agent/memory', method: 'GET'});
15
- assert.deepEqual(agentRequest(['remember', 'Lantern', 'is', 'lantern.fm'], {}), {route: '/api/v1/agent/memory', method: 'POST', body: {note: 'Lantern is lantern.fm'}});
16
- const signup = agentRequest(['signup'], {organization: 'Acme', email: 'a@acme.example', 'create-new': true});
17
- assert.equal(signup.authenticated, false); assert.deepEqual(signup.body, {organization: 'Acme', email: 'a@acme.example', agent: 'OpenCrawl CLI', create_new: true});
18
- for (const [rest, options] of [[[], {}], [[], {build: true}], [['x'], {session: 'bad'}], [['signup'], {email: 'a@b.c'}], [[], {session, build: true, check: true}], [[], {session, connect: true}], [[], {connect: 'gmail'}], [['remember'], {}]]) assert.throws(() => agentRequest(rest, options));
19
- });
20
- test('agent command prints the server response as JSON', async () => {
21
- const calls = [], printed = [];
22
- await agentCommand(['hello'], {}, {request: async (route, init) => { calls.push({route, init}); return {session_id: session, reply: 'Hi'}; }}, v => printed.push(v));
23
- assert.equal(calls[0].init.timeout, 120000); assert.equal(calls[0].init.authenticated, true); assert.deepEqual(printed, [{session_id: session, reply: 'Hi'}]);
24
- });
25
- test('research feed and subscription commands map to the project signal routes', async () => {
26
- const {researchRequest} = await import('./research.mjs');
27
- assert.deepEqual(researchRequest(['feed'], {customer: 'Basedash', after: '7'}), {command: 'feed', route: '/api/research/feed?after=7&limit=50&customer=Basedash'});
28
- assert.deepEqual(researchRequest(['subscribe', 'https://hooks.example/oc'], {customer: 'Basedash'}).body, {endpoint: 'https://hooks.example/oc', customer: 'Basedash'});
29
- assert.equal(researchRequest(['unsubscribe', 'rsub_' + 'a'.repeat(32)], {}).method, 'DELETE');
30
- for (const [rest, options] of [[['subscribe', 'http://insecure.example'], {}], [['unsubscribe', 'bad'], {}], [['feed', 'extra'], {}]]) assert.throws(() => researchRequest(rest, options));
31
- });
32
- test('session reads ride out a server restart; a lost build says how to recover the key', async () => {
33
- assert.deepEqual(agentRequest([], {session, 'new-key': true}).body, {action: 'issue_key'});
34
- let time = 0, calls = 0;
35
- const clock = {now: () => time, sleep: async ms => { time += ms; }};
36
- const printed = [];
37
- await agentCommand([], {session}, {request: async () => { if (++calls < 3) { const e = Error('OpenCrawl 502'); e.status = 502; throw e; } return {state: 'ready'}; }}, v => printed.push(v), undefined, clock);
38
- assert.equal(calls, 3); assert.deepEqual(printed, [{state: 'ready'}]);
39
- calls = 0;
40
- await assert.rejects(agentCommand([], {session, build: true}, {request: async () => { calls++; const e = Error('OpenCrawl 502.'); e.status = 502; throw e; }}, () => {}, undefined, clock),
41
- error => error.message.includes('--new-key') && error.message.includes(`--session ${session}`));
42
- assert.equal(calls, 1); // a build is never sent twice on its own
43
- await assert.rejects(agentCommand([], {session}, {request: async () => { const e = Error('OpenCrawl 404'); e.status = 404; throw e; }}, () => {}, undefined, clock));
44
- });
45
- test('join waits for an admin to approve, then saves the organization key', async () => {
46
- const poll = 'https://app.getopencrawl.com/api/v1/agent/signup/' + 'T'.repeat(43);
47
- assert.deepEqual(agentRequest(['join', poll], {}), {route: '/api/v1/agent/signup/' + 'T'.repeat(43), method: 'GET', authenticated: false, poll: true});
48
- for (const rest of [['join'], ['join', 'https://app.getopencrawl.com/api/v1/agent/sessions'], ['join', poll, 'extra']]) assert.throws(() => agentRequest(rest, {}));
49
- let time = 0, calls = 0;
50
- const clock = {now: () => time, sleep: async ms => { time += ms; }}, printed = [], saved = [];
51
- await agentCommand(['join', poll], {}, {request: async (route, init) => { assert.equal(init.authenticated, false); return ++calls < 3 ? {status: 'pending'} : {status: 'approved', organization: {name: 'Acme'}, api_key: 'oc_secret'}; }},
52
- v => printed.push(v), async (key, options) => { saved.push([key, options]); return {account: 'acc_1'}; }, clock);
53
- assert.equal(calls, 3); assert.equal(time, 20000); assert.deepEqual(saved, [['oc_secret', {activate: true}]]);
54
- assert.deepEqual(printed, [{status: 'approved', organization: {name: 'Acme'}, api_key_saved: {account: 'acc_1', kind: 'organization'}}]);
55
- time = 0; printed.length = 0;
56
- await agentCommand(['join', poll], {}, {request: async () => ({status: 'pending'})}, v => printed.push(v), undefined, clock);
57
- assert.ok(time >= 3600000); assert.deepEqual(printed, [{status: 'pending'}]);
58
- });