openmerit 0.3.10 → 0.4.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 (69) hide show
  1. package/README.md +74 -22
  2. package/data/eci-estimates.csv +179 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +180 -0
  5. package/dist/connectors/langfuse/client.d.ts +42 -0
  6. package/dist/connectors/langfuse/client.js +141 -0
  7. package/dist/connectors/langfuse/index.d.ts +163 -0
  8. package/dist/connectors/langfuse/index.js +378 -0
  9. package/dist/connectors/langfuse/otlp.d.ts +60 -0
  10. package/dist/connectors/langfuse/otlp.js +40 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.js +11 -0
  13. package/dist/pi/extension.d.ts +13 -0
  14. package/dist/pi/extension.js +148 -0
  15. package/dist/pi/harness.d.ts +20 -0
  16. package/dist/pi/harness.js +49 -0
  17. package/dist/pi/instructions.d.ts +2 -0
  18. package/dist/pi/instructions.js +12 -0
  19. package/dist/scout/candidates.d.ts +62 -0
  20. package/dist/scout/candidates.js +90 -0
  21. package/dist/scout/eci.d.ts +42 -0
  22. package/dist/scout/eci.js +84 -0
  23. package/dist/scout/evaluators/module.d.ts +24 -0
  24. package/dist/scout/evaluators/module.js +19 -0
  25. package/dist/scout/evaluators/pi.d.ts +53 -0
  26. package/dist/scout/evaluators/pi.js +117 -0
  27. package/dist/scout/pipeline.d.ts +111 -0
  28. package/dist/scout/pipeline.js +157 -0
  29. package/dist/scout/report.d.ts +3 -0
  30. package/dist/scout/report.js +42 -0
  31. package/dist/scout/search.d.ts +110 -0
  32. package/dist/scout/search.js +205 -0
  33. package/dist/scout/service.d.ts +148 -0
  34. package/dist/scout/service.js +143 -0
  35. package/examples/health-declaration-codes/dataset.jsonl +14 -0
  36. package/examples/sahra-enrollment/evaluator.ts +151 -0
  37. package/package.json +34 -78
  38. package/CHANGELOG.md +0 -250
  39. package/dist/pi/scout-extension.mjs +0 -287
  40. package/dist/pi/src/index.d.ts +0 -6
  41. package/dist/pi/src/index.js +0 -56
  42. package/dist/terminal/scout-update.json +0 -1
  43. package/dist/terminal/src/browser.d.mts +0 -9
  44. package/dist/terminal/src/browser.mjs +0 -28
  45. package/dist/terminal/src/cli.mjs +0 -28
  46. package/dist/terminal/src/connections.js +0 -151
  47. package/dist/terminal/src/install.d.mts +0 -2
  48. package/dist/terminal/src/install.mjs +0 -524
  49. package/dist/terminal/src/scout-claude.js +0 -25
  50. package/dist/terminal/src/scout-credentials.js +0 -41
  51. package/dist/terminal/src/scout-instructions.js +0 -213
  52. package/dist/terminal/src/scout-pi.js +0 -84
  53. package/dist/terminal/src/scout.js +0 -229
  54. package/dist/terminal/src/telemetry.d.mts +0 -30
  55. package/dist/terminal/src/telemetry.mjs +0 -138
  56. package/dist/terminal/src/updates.js +0 -397
  57. package/docs/agent-evaluations.md +0 -15
  58. package/docs/commands.md +0 -9
  59. package/docs/getting-started.md +0 -37
  60. package/docs/index.md +0 -25
  61. package/docs/inference-terminal.md +0 -25
  62. package/docs/pi-extension.md +0 -58
  63. package/docs/roadmap.md +0 -16
  64. package/docs/scout-close-loop.md +0 -359
  65. package/docs/scout-data-policy.md +0 -87
  66. package/docs/scout.md +0 -190
  67. package/docs/security.md +0 -107
  68. package/docs/testing.md +0 -114
  69. package/docs/troubleshooting.md +0 -50
@@ -1,524 +0,0 @@
1
- import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
2
- import { dirname, join, resolve } from 'node:path';
3
- import { homedir, hostname } from 'node:os';
4
- import { createHash, randomUUID } from 'node:crypto';
5
- import { execFile } from 'node:child_process';
6
- import { promisify } from 'node:util';
7
- import { openBrowser } from './browser.mjs';
8
- import { clientVersion, setupHeaders, setupUUID, sendInstallationEvent, setupCorrelationEnabled, telemetryEnabled } from './telemetry.mjs';
9
- import { configureUpdates, inspectUpdates, recoverUpdates, removeUpdates, runUpdate, withUpdateLock } from './updates.js';
10
- import { credentialsPath as deviceCredentialsPath, pendingPath as devicePendingPath } from './scout-credentials.js';
11
- import { rule, skill } from './scout-instructions.js';
12
- import { piPaths, inspectPi, configurePi, removePi, piConflicts, piVersion } from './scout-pi.js';
13
- import { claudeProject } from './scout-claude.js';
14
- export { rule, skill } from './scout-instructions.js';
15
-
16
- // One-command coding-agent setup: device sign-in in the browser, then the MCP
17
- // server, startup rule and Scout skill. Built to be run by a person or by a coding agent:
18
- // no interactive prompts, bounded waits, resumable logins and explicit exit codes.
19
- const exitCodes = { ok: 0, error: 1, pending: 10, denied: 11, conflict: 20, unreachable: 30 };
20
- const exec = promisify(execFile);
21
- const hash = value => createHash('sha256').update(value).digest('hex');
22
- const policyPath = '.openmerit/scout.json';
23
- const clients = { 'claude-code': 'Claude Code', pi: 'Pi', connector: 'OpenMerit connection manager' };
24
-
25
- const help = `OpenMerit · coding-agent setup
26
-
27
- openmerit install [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
28
- openmerit status [--client claude-code|pi] [--scope user|project] [--check] [--json]
29
- openmerit remove [--client claude-code|pi] [--scope user|project] [--plan] [--json]
30
- openmerit login [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--json] [--no-open]
31
- openmerit logout [--client claude-code|pi] [--scope user|project] [--json]
32
- openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
33
- openmerit doctor [--client claude-code|pi] [--scope user|project] [--json]
34
- openmerit update [--client claude-code|pi] [--scope user|project] [--auto on|off] [--json]
35
- openmerit scout --help Ask Scout from the terminal
36
-
37
- install signs this device in through the browser (one Approve click), adds the
38
- OpenMerit MCP server, startup rule and Scout skill, then verifies the connection.
39
- Automatic Scout instruction updates default on; --auto-update off opts out.
40
- Background SessionStart/Stop hooks check npm at most daily (15-minute failure
41
- retry), verify package integrity, and preserve edited files and credentials.
42
- Pi uses its native MCP extension; run install again to update its managed adapter.
43
- Pi does not install Claude hooks or a background updater. Start a new session after setup.
44
- It opens the approval page and waits up to 9 minutes for the Approve click
45
- (30 seconds when no browser could be opened). If it exits with approval
46
- pending, rerun the same command: it resumes the same request.
47
- --client defaults to the coding agent running the command. --scope defaults to user.
48
- --plan shows the changes without making them. Keys are never printed.
49
- login/logout also accept --client connector for a separate connection-management
50
- key. This grants no model recommendations and changes no coding-agent install.
51
-
52
- Exit codes: 0 done, 10 approval pending, 11 denied or expired, 20 conflict,
53
- 30 OpenMerit unreachable, 1 other error.
54
- `;
55
-
56
- class Stop extends Error { constructor(message, code, next, details) { super(message); this.exit = code; this.next = next; this.details = details; } }
57
- // An approval deadline people in any timezone, and agents relaying it, read the same way.
58
- const deadlineText = at => `${new Date(at).toISOString().slice(11, 16)} UTC, in ${Math.max(0, Math.round((at - Date.now()) / 60_000))} min`;
59
-
60
- const server = () => (process.env.OPENMERIT_URL ?? 'https://openmerit.ai').replace(/\/+$/, '');
61
- const credentialsPath = telemetry => deviceCredentialsPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
62
- const pendingPath = telemetry => devicePendingPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
63
-
64
- async function optional(path) {
65
- try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
66
- }
67
- async function object(path) {
68
- const text = await optional(path);
69
- if (text === null) return {};
70
- let value;
71
- try { value = JSON.parse(text); } catch { throw new Stop(`Invalid JSON in ${path}; nothing was changed.`, exitCodes.conflict); }
72
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Stop(`Expected a JSON object in ${path}.`, exitCodes.conflict);
73
- return value;
74
- }
75
- async function save(path, value) {
76
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
77
- await writeFile(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
78
- }
79
-
80
- async function request(path, { method = 'GET', body, key, telemetry } = {}) {
81
- let response;
82
- try {
83
- response = await fetch(`${server()}${path}`, {
84
- method, redirect: 'error', signal: AbortSignal.timeout(15_000),
85
- headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}), ...setupHeaders(telemetry?.properties) },
86
- ...(body ? { body: JSON.stringify(body) } : {}),
87
- });
88
- } catch { throw new Stop(`Could not reach ${server()}. Check your connection and retry.`, exitCodes.unreachable); }
89
- const data = await response.json().catch(() => ({}));
90
- return { status: response.status, data };
91
- }
92
-
93
- function detectClient() {
94
- if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
95
- if (process.env.PI_CODING_AGENT_DIR) return 'pi';
96
- return null;
97
- }
98
-
99
- // Claude Code locations. Project scope keeps the key out of the repository:
100
- // the MCP entry uses Claude's private "local" scope; rule and skill are in the project.
101
- function claudePaths(root, scope) {
102
- const config = resolve(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'));
103
- const base = scope === 'user' ? config : join(root, '.claude');
104
- return {
105
- skill: join(base, 'skills/openmerit-scout/SKILL.md'),
106
- rule: join(base, 'rules/openmerit-scout.md'),
107
- legacyRule: join(base, 'rules/openmerit.md'),
108
- record: join(base, 'openmerit-install.json'),
109
- mcpFile: process.env.CLAUDE_CONFIG_DIR ? join(config, '.claude.json') : join(homedir(), '.claude.json'),
110
- mcpScope: scope === 'user' ? 'user' : 'local',
111
- };
112
- }
113
- const configuredServer = (config, project, scope) => scope === 'user' ? config.mcpServers?.openmerit : config.projects?.[project]?.mcpServers?.openmerit;
114
- const serverConfig = (key, telemetry) => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`, ...setupHeaders(telemetry.properties, 'agent') } });
115
- const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
116
- const serverHash = value => value ? hash(JSON.stringify(canonical(value))) : null;
117
-
118
- async function claude(args, root, key) {
119
- try { return await exec('claude', args, { cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024, windowsHide: true }); }
120
- catch (error) {
121
- if (error.code === 'ENOENT') throw new Stop('Claude Code (the claude command) was not found on PATH. Install it, then retry.', exitCodes.error);
122
- // Claude echoes headers on some failures: scrub the key, and show only a short excerpt.
123
- const detail = `${error.stderr ?? ''}${error.stdout ?? ''}`.split(key || '\u0000').join('[key]').replace(/om_[A-Za-z0-9_-]{20,}/g, '[key]').trim().slice(0, 300);
124
- throw new Stop(`Claude Code could not complete "claude ${args.slice(0, 2).join(' ')}"${detail ? `: ${detail}` : '.'}`, exitCodes.error);
125
- }
126
- }
127
-
128
- // Inspect the current Claude Code installation without changing anything.
129
- async function inspect(root, scope, client = 'claude-code') {
130
- if (client === 'pi') return { ...await inspectPi(root, scope), client };
131
- const paths = { ...claudePaths(root, scope), project: await claudeProject(root) };
132
- const [record, config, installedSkill, installedRule, legacyRule] = await Promise.all([object(paths.record), object(paths.mcpFile), optional(paths.skill), optional(paths.rule), optional(paths.legacyRule)]);
133
- const updates = await inspectUpdates(dirname(paths.record), scope, record);
134
- const owned = [2, 3].includes(record.version) && record.scope === scope;
135
- const current = configuredServer(config, paths.project, scope);
136
- const otherScope = configuredServer(config, paths.project, scope === 'user' ? 'project' : 'user');
137
- const projectFile = await object(join(root, '.mcp.json'));
138
- return {
139
- paths, record, owned, current, installedSkill, installedRule, updates, client,
140
- // A full pending hash covers interruption without accepting edited headers.
141
- // Keep pendingAuth compatibility for records from the earlier installer.
142
- serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
143
- skillOwned: installedSkill === null || owned && [record.skillHash, record.pendingSkill].includes(hash(installedSkill)),
144
- ruleOwned: installedRule === null || owned && record.version === 3 && [record.ruleHash, record.pendingRule].includes(hash(installedRule)),
145
- // The pre-release installer recorded its rule hash; only an unchanged rule is removed.
146
- legacyRule: legacyRule !== null && record.version === 1 && hash(legacyRule) === record.ruleHash ? 'remove' : legacyRule !== null ? 'keep' : 'none',
147
- shadowed: !!(otherScope || projectFile.mcpServers?.openmerit),
148
- };
149
- }
150
-
151
- function conflicts(state) {
152
- if (state.client === 'pi') return piConflicts(state);
153
- const found = [...state.updates.conflicts];
154
- if (!state.serverOwned) found.push('Claude Code already has an MCP server named "openmerit" that this installer did not create or that was edited. Remove it with "claude mcp remove openmerit", then retry.');
155
- if (!state.skillOwned) found.push(`${state.paths.skill} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
156
- if (!state.ruleOwned) found.push(`${state.paths.rule} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
157
- if (state.shadowed) found.push('An "openmerit" MCP server exists in another Claude Code scope (or in .mcp.json). Remove it, then retry.');
158
- return found;
159
- }
160
-
161
- function diagnostics(state) {
162
- return {
163
- installed: state.owned && !!state.current && state.serverOwned && state.installedSkill !== null && state.skillOwned && state.installedRule !== null && state.ruleOwned && !state.shadowed,
164
- server_status: state.current ? state.serverOwned ? 'matches' : 'modified_or_unowned' : 'missing',
165
- skill_status: state.installedSkill === null ? 'missing' : state.skillOwned ? 'matches' : 'modified_or_unowned',
166
- rule_status: state.installedRule === null ? 'missing' : state.ruleOwned ? 'matches' : 'modified_or_unowned',
167
- };
168
- }
169
-
170
- async function credentials(telemetry) {
171
- const saved = await object(credentialsPath(telemetry));
172
- return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
173
- }
174
-
175
- // Device login. Starts or resumes a login, waits a bounded time, and stores the
176
- // key only on disk. Never prints the key.
177
- async function login({ client, scope, open, log, wait, telemetry }) {
178
- await telemetry.step('login', 'started');
179
- let pending = await object(pendingPath(telemetry));
180
- if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
181
- const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
182
- const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName }, telemetry });
183
- if (status !== 200 || !data.device_code) throw new Stop(data.error ?? `Could not start sign-in (HTTP ${status}).`, status === 429 ? exitCodes.unreachable : exitCodes.error);
184
- pending = { server: server(), deviceCode: data.device_code, userCode: data.user_code, url: data.verification_uri_complete, interval: data.interval, expiresAt: Date.now() + data.expires_in * 1000, opened: false, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
185
- await save(pendingPath(telemetry), pending);
186
- }
187
- const approvalURL = new URL(pending.url);
188
- // Rebuild consent on every resume, so old query parameters cannot override a
189
- // later refusal. Only analytics-enabled, linked setup IDs reach the page.
190
- for (const key of ['setup_id', 'analytics', 'analytics_account']) approvalURL.searchParams.delete(key);
191
- if (setupCorrelationEnabled() && setupUUID(telemetry.properties.setup_id)) approvalURL.searchParams.set('setup_id', telemetry.properties.setup_id);
192
- // Preserve explicit refusals across surfaces; unlinking must not disable usage.
193
- if (!telemetryEnabled(true)) approvalURL.searchParams.set('analytics', 'off');
194
- if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true)) approvalURL.searchParams.set('analytics_account', 'off');
195
- pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
196
- await save(pendingPath(telemetry), pending);
197
- log(`Approve this device in your browser (opening it now):\n\n ${pending.url}\n\n Code: ${pending.userCode} (expires ${deadlineText(pending.expiresAt)})\n`);
198
- await telemetry.step('browser_open', 'started');
199
- if (open && !pending.opened && !pending.openFailed) {
200
- // Only the approval page on the configured OpenMerit server may be opened.
201
- const approvalPage = url => {
202
- const u = new URL(url);
203
- return u.origin === server() && u.pathname === '/connect' && !!u.searchParams.get('code')
204
- && [...u.searchParams.keys()].every(key => ['code', 'setup_id', 'analytics', 'analytics_account'].includes(key) && u.searchParams.getAll(key).length === 1)
205
- && (!u.searchParams.has('setup_id') || setupUUID(u.searchParams.get('setup_id')))
206
- && ['analytics', 'analytics_account'].every(key => !u.searchParams.has(key) || u.searchParams.get(key) === 'off')
207
- && !u.username && !u.password && !u.hash;
208
- };
209
- const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
210
- pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
211
- await save(pendingPath(telemetry), pending);
212
- }
213
- await telemetry.step('browser_open', pending.opened ? 'ok' : pending.openFailed ? 'error' : 'pending');
214
- await telemetry.step('login', 'started');
215
- // With the approval page open, keep waiting for the Approve click (inside a
216
- // coding agent's 10-minute command limit) so nobody has to rerun anything.
217
- // Without a browser, return soon so the agent can show the link.
218
- const deadline = Date.now() + (wait ?? (pending.opened ? 540 : 30)) * 1000;
219
- let interval = (pending.interval ?? 5) * 1000;
220
- for (;;) {
221
- const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode }, telemetry });
222
- if (status === 200 && data.access_token) {
223
- await save(credentialsPath(telemetry), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, setup_id: telemetry.properties.setup_id, headers: setupHeaders(telemetry.properties, 'agent'), createdAt: new Date().toISOString() });
224
- await rm(pendingPath(telemetry), { force: true });
225
- await telemetry.step('login', 'ok');
226
- return data.access_token;
227
- }
228
- if (data.error === 'slow_down') interval += 5000;
229
- else if (data.error !== 'authorization_pending') {
230
- await rm(pendingPath(telemetry), { force: true });
231
- if (data.error === 'access_denied') throw new Stop('The sign-in request was denied. Nothing was installed.', exitCodes.denied, 'Rerun the command to start a new request.');
232
- throw new Stop('The sign-in code expired or was already used. Nothing was installed.', exitCodes.denied, 'Rerun the command to get a new code.');
233
- }
234
- if (Date.now() + interval > deadline) throw new Stop(`Waiting for approval of code ${pending.userCode}: ${pending.url} (expires ${deadlineText(pending.expiresAt)}).`, exitCodes.pending,
235
- `Show the person approving this link and its expiry in your reply. Once they click Approve, rerun the same command: it resumes this request.`,
236
- { approvalUrl: pending.url, userCode: pending.userCode, expiresAt: new Date(pending.expiresAt).toISOString() });
237
- await new Promise(resolve => setTimeout(resolve, interval));
238
- }
239
- }
240
-
241
- // A real MCP initialize and tool discovery with the stored key; no search.
242
- async function verify(key, telemetry) {
243
- await telemetry.step('verification', 'started');
244
- const call = (id, method, params = {}) => request('/scout/mcp', { method: 'POST', key, telemetry, body: { jsonrpc: '2.0', id, method, params } });
245
- const init = await call(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'openmerit-cli', version: clientVersion ?? 'unknown' } });
246
- let connection = init.status === 401 ? 'key_rejected' : 'failed';
247
- if (init.status === 200 && init.data.result) {
248
- const tools = await call(2, 'tools/list');
249
- connection = tools.status === 401 ? 'key_rejected' : tools.status === 200 && tools.data.result?.tools?.some(tool => tool.name === 'scout_models') ? 'connected' : 'failed';
250
- }
251
- await telemetry.step('verification', connection === 'connected' ? 'ok' : 'error', { connection, ...(connection === 'connected' ? {} : { code: connection === 'key_rejected' ? 'access_denied' : 'command_error' }) });
252
- return connection;
253
- }
254
-
255
- async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
256
- const state = await inspect(root, scope, client);
257
- const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
258
- const found = conflicts(state);
259
- if (client === 'pi' && autoUpdate === 'on') throw new Stop('Pi updates are explicit: rerun install --client pi. Automatic hooks are Claude Code only.', exitCodes.error);
260
- const changes = [
261
- `${state.current ? 'Update' : 'Add'} ${client === 'pi' ? 'native Pi MCP extension' : 'MCP server "openmerit" in Claude Code'} (${scope} scope)${client !== 'pi' && scope === 'project' && state.paths.project !== root ? `; Claude Code applies it to the whole git repository at ${state.paths.project}` : ''}`,
262
- `${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
263
- `${state.installedRule === null ? 'Create' : 'Update'} ${state.paths.rule} (loaded at session startup)`,
264
- ...(client === 'pi' ? [] : [
265
- `Automatic Scout instruction updates ${enabled ? 'on' : 'off'} (SessionStart/Stop background hooks; disable with openmerit update --auto off)`,
266
- `Write the owned updater at ${state.updates.paths.runner}; merge its hooks in ${state.updates.paths.settings}`,
267
- ]),
268
- ...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
269
- ...(await credentials(telemetry) ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath(telemetry)} (mode 600)`]),
270
- ];
271
- if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
272
- if (found.length) throw new Stop(found.join('\n'), exitCodes.conflict, 'Nothing was changed.');
273
- if (!json && client !== 'pi') log(changes.find(change => change.startsWith('Automatic Scout')));
274
- if (client === 'pi') {
275
- const { stdout } = await exec('pi', ['--version'], { cwd: root, timeout: 10_000 });
276
- if (stdout.trim() !== piVersion) throw new Stop(`Pi ${piVersion} (latest stable supported) is required; found ${stdout.trim()}. Update Pi first.`, exitCodes.error);
277
- } else await claude(['--version'], root);
278
- if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
279
- await telemetry.step('preflight', 'ok');
280
- let key = (await credentials(telemetry))?.key;
281
- // A revoked or expired key gets a fresh sign-in instead of a broken install.
282
- if (key && await verify(key, telemetry) === 'key_rejected') { await rm(credentialsPath(telemetry), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
283
- key ??= await login({ client, scope, open, log, wait, telemetry });
284
- await telemetry.step('configuration', 'started');
285
- const ids = { setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
286
- if (client === 'pi') {
287
- await save(credentialsPath(telemetry), { ...await credentials(telemetry), headers: setupHeaders(telemetry.properties, 'agent') });
288
- await configurePi(root, scope, credentialsPath(telemetry), ids);
289
- telemetry.properties.applied = true;
290
- } else {
291
- const next = serverConfig(key, telemetry);
292
- // Record intent first so an interrupted install is repaired by rerunning.
293
- await save(state.paths.record, { version: 3, scope, client, ...ids, updates: state.record.updates, pendingUpdates: state.record.pendingUpdates, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), ruleHash: state.installedRule === null ? null : hash(state.installedRule), pendingServerHash: serverHash(next), pendingSkill: hash(skill), pendingRule: hash(rule) });
294
- telemetry.properties.applied = true;
295
- if (serverHash(state.current) !== serverHash(next)) {
296
- if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
297
- await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
298
- }
299
- await save(state.paths.skill, skill);
300
- await save(state.paths.rule, rule);
301
- if (state.legacyRule === 'remove') await rm(state.paths.legacyRule, { force: true });
302
- // Record what Claude actually wrote, so later edits are detected precisely.
303
- const written = configuredServer(await object(state.paths.mcpFile), state.paths.project, scope);
304
- if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
305
- const updates = await configureUpdates(dirname(state.paths.record), scope, state.record, clientVersion, enabled);
306
- await save(state.paths.record, { version: 3, scope, client, ...ids, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), updates, installedAt: new Date().toISOString() });
307
- }
308
- await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope, client)));
309
- const connection = await verify(key, telemetry);
310
- if (connection !== 'connected') telemetry.outcome = 'error';
311
- if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
312
- return { action: 'install', applied: true, client, scope, changes, connection,
313
- next: connection === 'connected'
314
- ? `Scout is ready in new ${clients[client]} sessions. Start a new session to load ${client === 'pi' ? 'the extension, instructions and skill' : 'the MCP server, startup rule and skill'}.`
315
- : `Installed, but the connection check failed. Run "openmerit doctor --client ${client} --scope ${scope}".` };
316
- }
317
-
318
- async function status(root, { client, scope, check, telemetry }) {
319
- await telemetry.step('status', 'started');
320
- const state = await inspect(root, scope, client), saved = await credentials(telemetry);
321
- const details = diagnostics(state), found = conflicts(state);
322
- const connection = check && saved ? await verify(saved.key, telemetry) : 'not_checked';
323
- telemetry.outcome = ['failed', 'key_rejected'].includes(connection) ? 'error' : found.length ? 'conflict' : details.installed ? 'ok' : 'not_installed';
324
- await telemetry.step('status', telemetry.outcome, { ...details, connection });
325
- return { action: 'status', client, scope, installed: details.installed, signedIn: !!saved,
326
- server: details.server_status, skill: details.skill_status, rule: details.rule_status,
327
- autoUpdates: state.updates.saved?.enabled ?? false, instructionVersion: state.updates.saved?.version ?? null,
328
- updateHooks: state.updates.saved?.enabled ? state.updates.active && !state.updates.conflicts.length ? 'ready' : 'incomplete' : 'disabled',
329
- conflicts: found, connection };
330
- }
331
-
332
- async function revokeKey(telemetry) {
333
- const saved = await credentials(telemetry);
334
- if (!saved) return false;
335
- await telemetry.step('remove', 'started');
336
- const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key, telemetry }).catch(() => ({ status: 0 }));
337
- await rm(credentialsPath(telemetry), { force: true });
338
- if (status !== 200) telemetry.outcome = 'error';
339
- await telemetry.step('remove', status === 200 ? 'ok' : 'error', status === 200 ? {} : {
340
- code: status === 0 ? 'openmerit_unreachable' : status === 401 || status === 403 ? 'access_denied' : 'command_error',
341
- });
342
- return status === 200;
343
- }
344
-
345
- async function remove(root, { client, scope, plan, telemetry }) {
346
- await telemetry.step('remove', 'started');
347
- const state = await inspect(root, scope, client);
348
- if (!state.owned) {
349
- telemetry.outcome = 'not_installed';
350
- await telemetry.step('remove', 'not_installed');
351
- return { action: 'remove', applied: false, client, scope, changes: [], note: 'No OpenMerit installation by this installer in this scope.' };
352
- }
353
- const changes = [...(state.current && state.serverOwned ? [`Remove MCP server "openmerit" (${state.paths.mcpScope} scope)`] : []),
354
- ...(state.installedSkill !== null && state.skillOwned ? [`Delete ${state.paths.skill}`] : []),
355
- ...(state.installedRule !== null && state.ruleOwned ? [`Delete ${state.paths.rule}`] : []),
356
- ...(state.updates.saved ? ['Remove unchanged owned update hooks and updater; retain edited hooks and their runner'] : []), 'Revoke and delete this device key'];
357
- const kept = [...(state.serverOwned ? [] : ['the edited "openmerit" MCP server']), ...(state.skillOwned ? [] : [state.paths.skill]), ...(state.ruleOwned ? [] : [state.paths.rule]), policyPath];
358
- kept.push(...state.updates.conflicts);
359
- if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
360
- telemetry.properties.applied = true;
361
- if (client === 'pi') await removePi(state);
362
- else {
363
- if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
364
- if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
365
- if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
366
- await removeUpdates(dirname(state.paths.record), scope, state.record);
367
- await rm(state.paths.record, { force: true });
368
- }
369
- const revoked = await revokeKey(telemetry);
370
- await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope, client)));
371
- return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
372
- }
373
-
374
- async function project(root, { policy, ids, plan }) {
375
- if (!['existing-only', 'existing-first'].includes(policy)) throw new Stop('Choose --policy existing-only or existing-first.', exitCodes.error);
376
- if (ids.length > 100 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string' || id.includes('://') || !/^[a-zA-Z0-9][a-zA-Z0-9/_.:+-]{0,159}$/.test(id))) throw new Stop('Use up to 100 unique public catalogue endpoint IDs, never credentials or URLs.', exitCodes.error);
377
- const value = { version: 1, policy, endpointIds: ids }, file = join(root, policyPath), existing = await object(file);
378
- if (Object.keys(existing).some(key => !['version', 'policy', 'endpointIds'].includes(key))) throw new Stop(`${policyPath} has unrecognized fields; edit it yourself instead.`, exitCodes.conflict);
379
- if (!plan) await save(file, value);
380
- return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
381
- }
382
-
383
- async function doctor(root, { client, scope, telemetry }) {
384
- await telemetry.step('status', 'started');
385
- const checks = [];
386
- const add = (name, ok, detail) => checks.push({ name, ok, detail });
387
- const [major, minor] = process.versions.node.split('.').map(Number);
388
- add('node', major > 22 || major === 22 && minor >= 19, `Node.js ${process.versions.node} (22.19+ required)`);
389
- const binary = client === 'pi' ? 'pi' : 'claude';
390
- try { const { stdout } = await exec(binary, ['--version'], { timeout: 10_000 }); add(binary, client !== 'pi' || stdout.trim() === piVersion, stdout.trim().slice(0, 80)); }
391
- catch { add(binary, false, `${clients[client]} (${binary}) is not on PATH`); }
392
- const reach = await request('/scout/api/status', { telemetry }).catch(error => ({ status: 0, error }));
393
- add('openmerit', reach.status === 200, reach.status === 200 ? `${server()} reachable` : `${server()} unreachable`);
394
- const saved = await credentials(telemetry);
395
- add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath(telemetry)}` : 'Not signed in. Run targeted install or login.');
396
- if (saved && reach.status === 200) { const connection = await verify(saved.key, telemetry); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
397
- const state = await inspect(root, scope, client).catch(() => null);
398
- if (state) add('install', state.owned && !!state.current && state.installedSkill !== null && state.installedRule !== null && !conflicts(state).length, state.owned ? conflicts(state).join(' ') || (state.current && state.installedSkill !== null && state.installedRule !== null ? `Installed (${scope} scope)` : 'Installation incomplete. Rerun "openmerit install".') : `Not installed in ${scope} scope`);
399
- if (state?.updates.saved?.enabled) add('updater', state.updates.active && !state.updates.conflicts.length, state.updates.active && !state.updates.conflicts.length ? 'Automatic instruction hooks installed' : 'Automatic instruction hooks missing or edited. Rerun install after reviewing conflicts.');
400
- telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
401
- await telemetry.step('status', telemetry.outcome, { ...(state ? diagnostics(state) : {}), ...(reach.status === 200 ? {} : { code: 'openmerit_unreachable', connection: 'failed' }) });
402
- return { action: 'doctor', ok: checks.every(check => check.ok), checks };
403
- }
404
-
405
- function render(result) {
406
- if (result.action === 'doctor') return result.checks.map(c => `${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`).join('\n');
407
- if (result.action === 'status') return [`OpenMerit in ${clients[result.client]} (${result.scope} scope): ${result.installed ? 'installed' : 'not installed'}`, `Signed in: ${result.signedIn ? 'yes' : 'no'} · MCP server: ${result.server} · skill: ${result.skill} · rule: ${result.rule} · connection: ${result.connection}`, `Automatic instruction updates: ${result.autoUpdates ? 'on' : 'off'} · hooks: ${result.updateHooks} · instruction version: ${result.instructionVersion ?? 'bootstrap required'}`, ...result.conflicts].join('\n');
408
- if (result.action === 'update') return result.message;
409
- if (result.action === 'project') return `${result.applied ? 'Saved' : 'Would save'} ${result.path}: ${result.policy.policy}, ${result.policy.endpointIds.length} endpoint ID(s). ${result.note}`;
410
- if (result.action === 'login') return result.note;
411
- if (result.action === 'logout') return result.note;
412
- const lines = [result.applied ? `Done (${result.action}):` : result.changes.length ? `Plan (${result.action}, nothing changed):` : result.note, ...result.changes.map(c => ` • ${c}`)];
413
- if (result.conflicts?.length) lines.push('Conflicts (install would stop):', ...result.conflicts.map(c => ` • ${c}`));
414
- if (result.connection) lines.push(`Connection check: ${result.connection}`);
415
- if (result.next) lines.push('', result.next);
416
- return lines.join('\n');
417
- }
418
-
419
- export async function main(args, dependencies = {}) {
420
- const telemetry = {
421
- stage: 'started', outcome: 'ok',
422
- properties: { command: ['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'].includes(args[0]) ? args[0] : 'unknown', agent: 'claude-code', scope: 'user', applied: false, installed: false, connection: 'not_checked' },
423
- async step(stage, outcome, properties = {}) {
424
- this.stage = stage;
425
- if (outcome === 'ok') delete this.properties.code;
426
- Object.assign(this.properties, properties);
427
- if (args[0] !== 'update') await sendInstallationEvent({ ...this.properties, stage, outcome });
428
- },
429
- async fail(error) {
430
- this.outcome = ({ 10: 'pending', 11: 'denied', 20: 'conflict' })[error.exit] ?? 'error';
431
- const code = ({ 10: 'approval_pending', 11: 'access_denied', 20: 'install_conflict', 30: 'openmerit_unreachable' })[error.exit] ?? 'command_error';
432
- await this.step(this.stage, this.outcome, { code, ...(this.stage === 'verification' ? { connection: error.exit === exitCodes.denied ? 'key_rejected' : 'failed' } : {}) });
433
- },
434
- };
435
- try {
436
- const code = await runMain(args, dependencies, telemetry);
437
- await telemetry.step('completed', telemetry.outcome);
438
- return code;
439
- } catch (error) {
440
- await telemetry.fail(error);
441
- await telemetry.step('completed', telemetry.outcome);
442
- throw error;
443
- }
444
- }
445
-
446
- async function runMain(args, { root = process.cwd(), print = console.log, printError = console.error }, telemetry) {
447
- const [action, ...rest] = args;
448
- if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
449
- const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
450
- const allowed = { install: ['--client', '--scope', '--setup-id', '--plan', '--json', '--no-open', '--auto-update'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
451
- login: ['--client', '--scope', '--setup-id', '--json', '--no-open'], logout: ['--client', '--scope', '--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--client', '--scope', '--json'], update: ['--client', '--scope', '--auto', '--json'] }[action];
452
- if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
453
- for (let i = 0; i < rest.length; i++) {
454
- const option = rest[i];
455
- if (!allowed.includes(option)) throw new Error(`Unknown option for ${action}: ${option}`);
456
- const value = () => { const v = rest[++i]; if (v === undefined || v.startsWith('--')) throw new Error(`${option} needs a value.`); return v; };
457
- if (option === '--client') options.client = value();
458
- else if (option === '--scope') options.scope = value();
459
- else if (option === '--setup-id') { options.setup_id = value(); if (!setupUUID(options.setup_id)) throw new Error('--setup-id must be a UUID v4.'); }
460
- else if (option === '--policy') options.policy = value();
461
- else if (option === '--endpoint-id') options.ids.push(value());
462
- else if (option === '--auto-update' || option === '--auto') { const setting = value(); if (!['on', 'off'].includes(setting)) throw new Error(`${option} needs on or off.`); options.autoUpdate = setting; }
463
- else if (option === '--no-open') options.open = false;
464
- else options[option.slice(2)] = true;
465
- }
466
- if (!['user', 'project'].includes(options.scope)) throw new Error('Use --scope user or --scope project.');
467
- if (action !== 'project') {
468
- options.client ??= detectClient() ?? (['install', 'status', 'remove'].includes(action) ? undefined : 'claude-code');
469
- if (!options.client) throw new Error('Choose the coding agent with --client claude-code or --client pi.');
470
- if (!clients[options.client]) throw new Error(`Unsupported client "${options.client}". Supported: ${Object.keys(clients).join(', ')}.`);
471
- if (options.client === 'connector' && !['login', 'logout'].includes(action)) throw new Error('The connector client supports login/logout only. Use openmerit connect langfuse --help.');
472
- }
473
- root = resolve(root);
474
- telemetry.root = root;
475
- telemetry.properties.agent = options.client ?? 'claude-code';
476
- // Agents run commands with a few-minute timeout; wait well inside it, then resume.
477
- const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
478
- const log = text => (options.json ? printError : print)(text);
479
- try {
480
- telemetry.properties.scope = options.scope;
481
- // Hydrate correlation without creating identities or writing anything. A
482
- // pending approval owns its setup ID even when the resumed command omits it.
483
- if (['install', 'status', 'remove', 'doctor'].includes(action)) {
484
- telemetry.stage = 'preflight';
485
- const state = await inspect(root, options.scope, options.client);
486
- Object.assign(telemetry.properties, diagnostics(state));
487
- if (state.owned) for (const key of ['setup_id', 'installation_id']) if (setupUUID(state.record[key])) telemetry.properties[key] = state.record[key];
488
- }
489
- if (options.setup_id) telemetry.properties.setup_id = options.setup_id;
490
- if (['install', 'login'].includes(action)) {
491
- const saved = await credentials(telemetry);
492
- if (!telemetry.properties.setup_id && setupUUID(saved?.setup_id)) telemetry.properties.setup_id = saved.setup_id;
493
- const pending = await object(pendingPath(telemetry));
494
- if (pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string') {
495
- if (setupUUID(pending.setup_id)) telemetry.properties.setup_id = pending.setup_id;
496
- if (!telemetry.properties.installation_id && setupUUID(pending.installation_id)) telemetry.properties.installation_id = pending.installation_id;
497
- }
498
- }
499
- await telemetry.step('started', 'started');
500
- await telemetry.step('preflight', 'started');
501
- const base = dirname((options.client === 'pi' ? piPaths : claudePaths)(root, options.scope).record);
502
- const maintenance = work => options.plan ? work() : withUpdateLock(base, async () => { if (options.client !== 'pi') await recoverUpdates(base, options.scope); return work(); });
503
- if (action === 'update' && options.client === 'pi') throw new Stop('Update Pi Scout by running npx -y openmerit@latest install --client pi with the same --scope. Automatic hooks are Claude Code only.', exitCodes.error);
504
- const result = action === 'install' ? await maintenance(() => install(root, { ...options, log, wait }))
505
- : action === 'status' ? await status(root, options)
506
- : action === 'remove' ? await maintenance(() => remove(root, options))
507
- : action === 'update' ? { action, ...await runUpdate(base, options.scope, options.autoUpdate === undefined ? {} : { enabled: options.autoUpdate === 'on' }) }
508
- : action === 'project' ? await project(root, options)
509
- : action === 'doctor' ? await doctor(root, options)
510
- : action === 'logout' ? { action, revoked: await revokeKey(telemetry), note: 'Signed out. This device key was revoked and deleted. Agents configured with it stop working until you run install again.' }
511
- : (await revokeKey(telemetry), await login({ client: options.client, scope: options.scope, open: options.open, log, wait, telemetry }), { action, signedIn: true, note: options.client === 'connector' ? 'Connection management authorized. Recommendation keys and coding-agent installations are unchanged.' : `Signed in. Run "openmerit install --client ${options.client} --scope ${options.scope}" to refresh this installation.` });
512
- if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
513
- if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
514
- print(options.json ? JSON.stringify({ ok: action !== 'update' || !['conflict', 'retry', 'manual_required'].includes(result.status), ...result }, null, 2) : render(result));
515
- if (action === 'update') return result.status === 'conflict' ? exitCodes.conflict : result.status === 'retry' ? exitCodes.unreachable : result.status === 'manual_required' ? exitCodes.error : exitCodes.ok;
516
- return action === 'doctor' && !result.ok ? exitCodes.error : exitCodes.ok;
517
- } catch (error) {
518
- if (!(error instanceof Stop)) throw error;
519
- await telemetry.fail(error);
520
- if (options.json) print(JSON.stringify({ ok: false, action, code: Object.keys(exitCodes).find(k => exitCodes[k] === error.exit), message: error.message, next: error.next, ...error.details }, null, 2));
521
- else printError([error.message, error.next].filter(Boolean).join('\n'));
522
- return error.exit;
523
- }
524
- }
@@ -1,25 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- const exec = promisify(execFile);
4
- // Claude's private local scope belongs to the repository, not its subdirectories.
5
- export async function claudeProject(root) {
6
- const options = { cwd: root, timeout: 10_000, windowsHide: true };
7
- let project;
8
- try {
9
- project = (await exec('git', ['rev-parse', '--show-toplevel'], options)).stdout.trim() || root;
10
- }
11
- catch {
12
- return root;
13
- }
14
- const { stdout: worktrees } = await exec('git', ['worktree', 'list', '--porcelain', '-z'], options);
15
- const main = worktrees.split('\0')[0]?.slice('worktree '.length);
16
- if (!main || main === project)
17
- return project;
18
- const { stdout } = await exec('claude', ['--version'], options);
19
- const version = stdout.match(/^(\d+)\.(\d+)\.(\d+)/);
20
- if (!version)
21
- throw new Error('Could not determine Claude Code version for worktree setup. Check claude --version, then retry.');
22
- // Claude Code 2.1.63 began sharing project configuration with the main checkout.
23
- const major = Number(version[1]), minor = Number(version[2]), patch = Number(version[3]);
24
- return major > 2 || major === 2 && (minor > 1 || minor === 1 && patch >= 63) ? main : project;
25
- }
@@ -1,41 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
5
- // Retain the existing Claude user key in place. Other installations own their
6
- // own key and approval, so removing one cannot revoke another's connection.
7
- export function credentialsPath(client, scope, root) {
8
- const base = resolve(process.env.OPENMERIT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'openmerit'));
9
- if (client === 'claude-code' && scope === 'user')
10
- return join(base, 'credentials.json');
11
- const installation = scope === 'project' ? createHash('sha256').update(resolve(root)).digest('hex').slice(0, 24) : 'user';
12
- return join(base, client, installation, 'credentials.json');
13
- }
14
- export function pendingPath(client, scope, root) {
15
- return join(dirname(credentialsPath(client, scope, root)), 'login.json');
16
- }
17
- export function readCredentials(path) {
18
- let value;
19
- try {
20
- value = JSON.parse(readFileSync(path, 'utf8'));
21
- }
22
- catch (error) {
23
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
24
- return null;
25
- throw new Error('Scout credentials could not be read. Run the targeted install command again.');
26
- }
27
- if (!value || typeof value !== 'object' || !('version' in value) || value.version !== 1
28
- || !('server' in value) || typeof value.server !== 'string'
29
- || !('key' in value) || typeof value.key !== 'string' || !/^om_[A-Za-z0-9_-]{43}$/.test(value.key))
30
- return null;
31
- const url = new URL(value.server);
32
- if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
33
- return null;
34
- const result = { version: 1, server: value.server.replace(/\/+$/, ''), key: value.key };
35
- if ('keyId' in value && typeof value.keyId === 'string')
36
- result.keyId = value.keyId;
37
- if ('headers' in value && value.headers && typeof value.headers === 'object') {
38
- result.headers = Object.fromEntries(Object.entries(value.headers).filter(([key, v]) => /^X-Scout-[A-Za-z-]+$/.test(key) && typeof v === 'string'));
39
- }
40
- return result;
41
- }