@withtruing/companion 0.0.0-stage → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +48 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findDesignFiles is copied VERBATIM from server/design-context.ts, per the companion plan's
|
|
3
|
+
* own Section 2 item 5: "findDesignFiles(paths: string[]): string[] has zero GitHub-specific
|
|
4
|
+
* code — copy it verbatim into the companion package (genuinely shared pure logic)." Keep the
|
|
5
|
+
* two copies in sync by hand if the pattern list ever changes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { listRepoFiles } from './local-scan.js';
|
|
10
|
+
const CONFIG_NAMES = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs', 'tailwind.config.cjs'];
|
|
11
|
+
const TOKEN_NAMES = ['theme.css', 'tokens.css', 'design-tokens.css', 'design-tokens.json'];
|
|
12
|
+
const GUIDELINES_NAMES = ['guidelines.md', 'design-guidelines.md', 'style-guide.md'];
|
|
13
|
+
const COMPONENT_DIR_HINTS = ['components/ui/', 'app/components/', 'src/components/', 'src/ui/'];
|
|
14
|
+
const MAX_FILES = 8;
|
|
15
|
+
function basename(path) {
|
|
16
|
+
return path.split('/').pop() ?? path;
|
|
17
|
+
}
|
|
18
|
+
/** Given the repo's full list of blob paths, pick candidate design-signal files. */
|
|
19
|
+
export function findDesignFiles(paths) {
|
|
20
|
+
const named = paths.filter((p) => {
|
|
21
|
+
const b = basename(p).toLowerCase();
|
|
22
|
+
return CONFIG_NAMES.includes(b) || TOKEN_NAMES.includes(b) || GUIDELINES_NAMES.includes(b);
|
|
23
|
+
});
|
|
24
|
+
const componentFiles = paths
|
|
25
|
+
.filter((p) => COMPONENT_DIR_HINTS.some((hint) => p.toLowerCase().includes(hint)))
|
|
26
|
+
.filter((p) => /\.(tsx|jsx|css)$/.test(p))
|
|
27
|
+
.sort((a, b) => a.length - b.length)
|
|
28
|
+
.slice(0, MAX_FILES - named.length);
|
|
29
|
+
return [...named, ...componentFiles].slice(0, MAX_FILES);
|
|
30
|
+
}
|
|
31
|
+
/** The local-read counterpart to server/design-context.ts's buildDesignContext — same
|
|
32
|
+
* findDesignFiles selection, but reading the local checkout with no MAX_CHARS_PER_FILE-style
|
|
33
|
+
* truncation (the server's own cap exists only because a GitHub file read is a metered,
|
|
34
|
+
* rate-limited network call; a local read isn't). Empty string if nothing found — the caller
|
|
35
|
+
* (api-key-caller.ts's runDesignPromptTask) treats that as "propose a visual language from
|
|
36
|
+
* scratch instead", same fallback server/index.ts's route already uses today. */
|
|
37
|
+
export async function buildLocalDesignContext(repoPath) {
|
|
38
|
+
const paths = await listRepoFiles(repoPath);
|
|
39
|
+
const files = findDesignFiles(paths);
|
|
40
|
+
if (!files.length)
|
|
41
|
+
return '';
|
|
42
|
+
const sections = [];
|
|
43
|
+
for (const path of files) {
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = await readFile(join(repoPath, path), 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
sections.push(`### ${path}\n\`\`\`\n${text}\n\`\`\``);
|
|
52
|
+
}
|
|
53
|
+
return sections.join('\n\n');
|
|
54
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import spawn from 'cross-spawn';
|
|
3
|
+
import { detectSchemaImpact, validSchemaProbe, } from './schema-entities.js';
|
|
4
|
+
import { managedFetchEnvironment, networkGitEnvironment, reportBackgroundFetch } from './managed-repo.js';
|
|
5
|
+
export const GIT_RANGE_MAX_HTTP_BYTES = 1_572_864;
|
|
6
|
+
export const GIT_RANGE_MAX_COMMITS = 1_000;
|
|
7
|
+
export const GIT_RANGE_MAX_PATHS = 3_000;
|
|
8
|
+
export const GIT_RANGE_COMMAND_TIMEOUT_MS = 60_000;
|
|
9
|
+
export const GIT_RANGE_METADATA_OUTPUT_BYTES = 1_048_576;
|
|
10
|
+
export const GIT_RANGE_ENUMERATION_OUTPUT_BYTES = 16_777_216;
|
|
11
|
+
export const GIT_RANGE_OBJECT_OUTPUT_BYTES = 25_165_824;
|
|
12
|
+
export const GIT_RANGE_PATH_OUTPUT_BYTES = 16_777_216;
|
|
13
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
14
|
+
const TASK_ID = /^[0-9a-f-]{36}$/;
|
|
15
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
16
|
+
class GitCommandFailure extends Error {
|
|
17
|
+
kind;
|
|
18
|
+
exitCode;
|
|
19
|
+
constructor(kind, exitCode = null) {
|
|
20
|
+
super(kind);
|
|
21
|
+
this.kind = kind;
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const GIT_RANGE_CAPTURED_STDERR_BYTES = 8_192;
|
|
26
|
+
export function gitRangeEnvironment(parent = process.env) {
|
|
27
|
+
const environment = networkGitEnvironment(parent);
|
|
28
|
+
delete environment.GIT_CONFIG_NOSYSTEM;
|
|
29
|
+
return environment;
|
|
30
|
+
}
|
|
31
|
+
function runGit(repoPath, args, outputLimit, input, environment) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn('git', ['--no-replace-objects', ...args], {
|
|
34
|
+
cwd: repoPath,
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
env: environment ?? gitRangeEnvironment(),
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
const stdout = [];
|
|
40
|
+
const stderr = [];
|
|
41
|
+
let stdoutBytes = 0;
|
|
42
|
+
let stderrBytes = 0;
|
|
43
|
+
let settled = false;
|
|
44
|
+
const finish = (error, value) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (error)
|
|
50
|
+
reject(error);
|
|
51
|
+
else
|
|
52
|
+
resolve(value);
|
|
53
|
+
};
|
|
54
|
+
const overflow = () => {
|
|
55
|
+
child.kill('SIGKILL');
|
|
56
|
+
finish(new GitCommandFailure('resource'));
|
|
57
|
+
};
|
|
58
|
+
child.stdout?.on('data', (chunk) => {
|
|
59
|
+
stdoutBytes += chunk.length;
|
|
60
|
+
if (stdoutBytes + stderrBytes > outputLimit)
|
|
61
|
+
return overflow();
|
|
62
|
+
stdout.push(chunk);
|
|
63
|
+
});
|
|
64
|
+
child.stderr?.on('data', (chunk) => {
|
|
65
|
+
if (stderrBytes < GIT_RANGE_CAPTURED_STDERR_BYTES)
|
|
66
|
+
stderr.push(chunk.subarray(0, GIT_RANGE_CAPTURED_STDERR_BYTES - stderrBytes));
|
|
67
|
+
stderrBytes += chunk.length;
|
|
68
|
+
if (stdoutBytes + stderrBytes > outputLimit)
|
|
69
|
+
overflow();
|
|
70
|
+
});
|
|
71
|
+
child.on('error', () => finish(new GitCommandFailure('resource')));
|
|
72
|
+
child.on('close', (code, signal) => {
|
|
73
|
+
if (signal || code === null)
|
|
74
|
+
return finish(new GitCommandFailure('resource'));
|
|
75
|
+
finish(undefined, { stdout: Buffer.concat(stdout), exitCode: code, stderr: Buffer.concat(stderr).toString('utf8') });
|
|
76
|
+
});
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
finish(new GitCommandFailure('resource'));
|
|
80
|
+
}, GIT_RANGE_COMMAND_TIMEOUT_MS);
|
|
81
|
+
if (input)
|
|
82
|
+
child.stdin?.end(input);
|
|
83
|
+
else
|
|
84
|
+
child.stdin?.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function strictText(bytes) {
|
|
88
|
+
try {
|
|
89
|
+
return decoder.decode(bytes);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new GitCommandFailure('resource');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function exactLine(bytes) {
|
|
96
|
+
const text = strictText(bytes);
|
|
97
|
+
if (!text.endsWith('\n') || text.endsWith('\n\n') || text.includes('\r'))
|
|
98
|
+
throw new GitCommandFailure('resource');
|
|
99
|
+
return text.slice(0, -1);
|
|
100
|
+
}
|
|
101
|
+
function truncateUtf16(value, limit) {
|
|
102
|
+
if (value.length <= limit)
|
|
103
|
+
return value;
|
|
104
|
+
let end = limit;
|
|
105
|
+
const last = value.charCodeAt(end - 1);
|
|
106
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
107
|
+
end--;
|
|
108
|
+
return value.slice(0, end);
|
|
109
|
+
}
|
|
110
|
+
function parseCommitObjects(bytes, requested) {
|
|
111
|
+
const commits = [];
|
|
112
|
+
let offset = 0;
|
|
113
|
+
for (const expected of requested) {
|
|
114
|
+
const headerEnd = bytes.indexOf(0x0a, offset);
|
|
115
|
+
if (headerEnd === -1)
|
|
116
|
+
throw new GitCommandFailure('resource');
|
|
117
|
+
const header = strictText(bytes.subarray(offset, headerEnd));
|
|
118
|
+
const match = /^([0-9a-f]{40}) commit ([0-9]+)$/.exec(header);
|
|
119
|
+
if (!match || match[1] !== expected)
|
|
120
|
+
throw new GitCommandFailure('resource');
|
|
121
|
+
const size = Number(match[2]);
|
|
122
|
+
if (!Number.isSafeInteger(size) || size < 0 || headerEnd + 1 + size >= bytes.length)
|
|
123
|
+
throw new GitCommandFailure('resource');
|
|
124
|
+
const object = bytes.subarray(headerEnd + 1, headerEnd + 1 + size);
|
|
125
|
+
if (bytes[headerEnd + 1 + size] !== 0x0a)
|
|
126
|
+
throw new GitCommandFailure('resource');
|
|
127
|
+
offset = headerEnd + 1 + size + 1;
|
|
128
|
+
const boundary = object.indexOf(Buffer.from('\n\n'));
|
|
129
|
+
if (boundary === -1)
|
|
130
|
+
throw new GitCommandFailure('resource');
|
|
131
|
+
const message = strictText(object.subarray(boundary + 2));
|
|
132
|
+
const [first = '', ...rest] = message.split('\n');
|
|
133
|
+
commits.push({
|
|
134
|
+
sha: expected,
|
|
135
|
+
title: truncateUtf16(first.trim(), 500),
|
|
136
|
+
message: truncateUtf16(rest.join('\n').trim(), 20_000),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (offset !== bytes.length)
|
|
140
|
+
throw new GitCommandFailure('resource');
|
|
141
|
+
return commits;
|
|
142
|
+
}
|
|
143
|
+
export function parseNameStatus(bytes) {
|
|
144
|
+
if (bytes.length === 0)
|
|
145
|
+
return { changedPaths: [], truncatedFiles: false };
|
|
146
|
+
if (bytes[bytes.length - 1] !== 0)
|
|
147
|
+
throw new GitCommandFailure('resource');
|
|
148
|
+
const fields = bytes.subarray(0, -1).toString('binary').split('\0').map((field) => Buffer.from(field, 'binary'));
|
|
149
|
+
const paths = new Set();
|
|
150
|
+
for (let index = 0; index < fields.length;) {
|
|
151
|
+
const status = strictText(fields[index++]);
|
|
152
|
+
const scored = /^([RC])([0-9]{1,3})$/.exec(status);
|
|
153
|
+
const twoPaths = !!scored && Number(scored[2]) <= 100;
|
|
154
|
+
if ((!twoPaths && scored) || (!twoPaths && !/^[ACDMRTUXB]$/.test(status)))
|
|
155
|
+
throw new GitCommandFailure('resource');
|
|
156
|
+
const required = twoPaths ? 2 : 1;
|
|
157
|
+
if (index + required > fields.length)
|
|
158
|
+
throw new GitCommandFailure('resource');
|
|
159
|
+
for (let count = 0; count < required; count++)
|
|
160
|
+
paths.add(strictText(fields[index++]));
|
|
161
|
+
}
|
|
162
|
+
const all = [...paths].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
163
|
+
return { changedPaths: all.slice(0, GIT_RANGE_MAX_PATHS), truncatedFiles: all.length > GIT_RANGE_MAX_PATHS };
|
|
164
|
+
}
|
|
165
|
+
export function fitsGitRangeTransport(result) {
|
|
166
|
+
return Buffer.byteLength(JSON.stringify({ result, filesRead: [] }), 'utf8') <= GIT_RANGE_MAX_HTTP_BYTES;
|
|
167
|
+
}
|
|
168
|
+
const failure = (code) => ({ ok: false, code });
|
|
169
|
+
export async function runGitRangeTask(task, repoPath, connection) {
|
|
170
|
+
const payload = task.payload;
|
|
171
|
+
const branch = payload.branch;
|
|
172
|
+
const base = payload.base;
|
|
173
|
+
const remote = connection.remote ?? 'origin';
|
|
174
|
+
let destination = null;
|
|
175
|
+
try {
|
|
176
|
+
if (task.maxBudgetUsd !== 0 || !TASK_ID.test(task.id) || typeof branch !== 'string'
|
|
177
|
+
|| !(base === null || (typeof base === 'string' && SHA.test(base))) || typeof remote !== 'string' || !remote
|
|
178
|
+
|| (payload.schemaProbe !== undefined && !validSchemaProbe(payload.schemaProbe))) {
|
|
179
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
180
|
+
}
|
|
181
|
+
const branchCheck = await runGit(repoPath, ['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
182
|
+
const remoteList = await runGit(repoPath, ['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
183
|
+
if (branchCheck.exitCode !== 0)
|
|
184
|
+
return { result: failure('branch_missing'), filesRead: [] };
|
|
185
|
+
const remotes = exactLine(Buffer.concat([remoteList.stdout, remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
|
|
186
|
+
if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote))
|
|
187
|
+
return { result: failure('branch_missing'), filesRead: [] };
|
|
188
|
+
destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
|
|
189
|
+
const destinationCheck = await runGit(repoPath, ['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
190
|
+
const sourceCheck = await runGit(repoPath, ['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
191
|
+
if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
|
|
192
|
+
return { result: failure('branch_missing'), filesRead: [] };
|
|
193
|
+
const fetchEnvironment = await managedFetchEnvironment(repoPath);
|
|
194
|
+
delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
|
|
195
|
+
const fetched = await runGit(repoPath, [
|
|
196
|
+
'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
|
|
197
|
+
remote, `+refs/heads/${branch}:${destination}`,
|
|
198
|
+
], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
|
|
199
|
+
reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
|
|
200
|
+
if (fetched.exitCode !== 0)
|
|
201
|
+
return { result: failure('fetch_failed'), filesRead: [] };
|
|
202
|
+
const resolved = await runGit(repoPath, ['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
203
|
+
if (resolved.exitCode !== 0)
|
|
204
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
205
|
+
const head = exactLine(resolved.stdout);
|
|
206
|
+
if (!SHA.test(head))
|
|
207
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
208
|
+
const shallowOutput = await runGit(repoPath, ['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
209
|
+
if (shallowOutput.exitCode !== 0)
|
|
210
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
211
|
+
const shallowText = exactLine(shallowOutput.stdout);
|
|
212
|
+
if (shallowText !== 'true' && shallowText !== 'false')
|
|
213
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
214
|
+
const historyShallow = shallowText === 'true';
|
|
215
|
+
if (base === null) {
|
|
216
|
+
const result = { ok: true, mode: 'head_only', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: false, truncatedFiles: false, historyShallow };
|
|
217
|
+
return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
|
|
218
|
+
}
|
|
219
|
+
if (base === head) {
|
|
220
|
+
const result = { ok: true, mode: 'range', head, base, commits: [], changedPaths: [], totalCommits: 0, baseUnavailable: false, truncatedFiles: false, historyShallow };
|
|
221
|
+
return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
|
|
222
|
+
}
|
|
223
|
+
const baseCheck = await runGit(repoPath, ['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
|
|
224
|
+
if (baseCheck.exitCode !== 0)
|
|
225
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
226
|
+
const baseDescription = exactLine(baseCheck.stdout);
|
|
227
|
+
if (baseDescription === `${base} missing`) {
|
|
228
|
+
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'object_missing', truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
229
|
+
}
|
|
230
|
+
if (!new RegExp(`^${base} commit [0-9]+$`).test(baseDescription))
|
|
231
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
232
|
+
if (historyShallow) {
|
|
233
|
+
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'shallow_history', truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
234
|
+
}
|
|
235
|
+
const ancestor = await runGit(repoPath, ['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
236
|
+
if (ancestor.exitCode === 1) {
|
|
237
|
+
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'not_ancestor', truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
238
|
+
}
|
|
239
|
+
if (ancestor.exitCode !== 0)
|
|
240
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
241
|
+
const [enumerated, counted, diff] = await Promise.all([
|
|
242
|
+
runGit(repoPath, ['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
|
|
243
|
+
runGit(repoPath, ['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
|
|
244
|
+
runGit(repoPath, ['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
|
|
245
|
+
]);
|
|
246
|
+
if (enumerated.exitCode !== 0 || counted.exitCode !== 0 || diff.exitCode !== 0)
|
|
247
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
248
|
+
const totalText = exactLine(counted.stdout);
|
|
249
|
+
if (!/^(0|[1-9][0-9]*)$/.test(totalText))
|
|
250
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
251
|
+
const totalCommits = Number(totalText);
|
|
252
|
+
if (!Number.isSafeInteger(totalCommits) || totalCommits <= 0)
|
|
253
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
254
|
+
const oidText = strictText(enumerated.stdout);
|
|
255
|
+
if (!oidText.endsWith('\n'))
|
|
256
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
257
|
+
const oids = oidText.slice(0, -1).split('\n');
|
|
258
|
+
if (oids.length !== totalCommits || new Set(oids).size !== oids.length || oids.some((oid) => !SHA.test(oid))) {
|
|
259
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
260
|
+
}
|
|
261
|
+
const selected = oids.slice(0, GIT_RANGE_MAX_COMMITS);
|
|
262
|
+
const objects = await runGit(repoPath, ['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
|
|
263
|
+
if (objects.exitCode !== 0)
|
|
264
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
265
|
+
const commits = parseCommitObjects(objects.stdout, selected);
|
|
266
|
+
const paths = parseNameStatus(diff.stdout);
|
|
267
|
+
const schemaImpact = payload.schemaProbe === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: await detectSchemaImpact({
|
|
270
|
+
repoPath,
|
|
271
|
+
base,
|
|
272
|
+
head,
|
|
273
|
+
changedPaths: paths.changedPaths,
|
|
274
|
+
probe: payload.schemaProbe,
|
|
275
|
+
});
|
|
276
|
+
const result = {
|
|
277
|
+
ok: true, mode: 'range', head, base, commits, changedPaths: paths.changedPaths,
|
|
278
|
+
totalCommits, baseUnavailable: false, truncatedFiles: paths.truncatedFiles, historyShallow,
|
|
279
|
+
...(schemaImpact ? { schemaImpact } : {}),
|
|
280
|
+
};
|
|
281
|
+
return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
if (destination) {
|
|
288
|
+
await runGit(repoPath, ['update-ref', '-d', destination], GIT_RANGE_METADATA_OUTPUT_BYTES).catch(() => undefined);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Thin wrapper over the requireCompanionAuth-gated routes in server/index.ts. Uses global
|
|
2
|
+
* fetch (Node 20+, no dependency needed) — see package.json's engines field. */
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { manifestHash } from './surface-mapper.js';
|
|
5
|
+
const WHOAMI_TIMEOUT_MS = 15_000;
|
|
6
|
+
const CLAIM_TIMEOUT_MS = 10_000;
|
|
7
|
+
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
8
|
+
const RESULT_TIMEOUT_MS = 60_000;
|
|
9
|
+
const ERROR_REPORT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const PAIRING_TIMEOUT_MS = 15_000;
|
|
11
|
+
const READINESS_TIMEOUT_MS = 10_000;
|
|
12
|
+
const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
13
|
+
if (typeof packageVersion !== 'string' || !packageVersion) {
|
|
14
|
+
throw new Error('companion/package.json does not contain a version');
|
|
15
|
+
}
|
|
16
|
+
export const COMPANION_VERSION = packageVersion;
|
|
17
|
+
export const COMPANION_CAPABILITIES = [
|
|
18
|
+
'auto-surface-map@1',
|
|
19
|
+
'auto-surface-mapper@1',
|
|
20
|
+
'auto-surface-mapper@2',
|
|
21
|
+
'snapshot-read@1',
|
|
22
|
+
'local-git-range@1',
|
|
23
|
+
'schema-entities@1',
|
|
24
|
+
'verify-automatic-surface@1',
|
|
25
|
+
'map-module-expansion@1',
|
|
26
|
+
];
|
|
27
|
+
const capabilityHeaders = () => ({
|
|
28
|
+
'X-Companion-Capabilities': COMPANION_CAPABILITIES.join(','),
|
|
29
|
+
});
|
|
30
|
+
export class CompanionHttpError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const PAIRING_ERROR_MESSAGES = {
|
|
38
|
+
registration_failed: 'Could not register this repository with the console.',
|
|
39
|
+
connection_ambiguous: 'More than one connection matches this repository. Remove the duplicate connection and try again.',
|
|
40
|
+
branch_conflict: 'This repository is already connected with a different default branch.',
|
|
41
|
+
connection_limit: 'This workspace already has the maximum of 20 connected repositories.',
|
|
42
|
+
pairing_outcome_failed: 'Setup completion could not be confirmed. Retry this command or return to the console.',
|
|
43
|
+
};
|
|
44
|
+
export class PairingHttpError extends Error {
|
|
45
|
+
code;
|
|
46
|
+
constructor(code) {
|
|
47
|
+
super(PAIRING_ERROR_MESSAGES[code]);
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.name = 'PairingHttpError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class CompanionClient {
|
|
53
|
+
url;
|
|
54
|
+
token;
|
|
55
|
+
constructor(url, token) {
|
|
56
|
+
this.url = url;
|
|
57
|
+
this.token = token;
|
|
58
|
+
}
|
|
59
|
+
request(path, timeoutMs, init) {
|
|
60
|
+
return fetch(`${this.url}${path}`, {
|
|
61
|
+
...init,
|
|
62
|
+
headers: {
|
|
63
|
+
...init?.headers,
|
|
64
|
+
Authorization: `Bearer ${this.token}`,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async readResponse(response) {
|
|
71
|
+
const body = (await response.json().catch(() => ({})));
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new CompanionHttpError(response.status, typeof body.error === 'string'
|
|
74
|
+
? body.error
|
|
75
|
+
: `${response.status} ${response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
async call(path, timeoutMs, init) {
|
|
80
|
+
return this.readResponse(await this.request(path, timeoutMs, init));
|
|
81
|
+
}
|
|
82
|
+
whoami() {
|
|
83
|
+
return this.call('/api/companion/whoami', WHOAMI_TIMEOUT_MS);
|
|
84
|
+
}
|
|
85
|
+
async githubToken(repo) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.call('/api/companion/github-token', CLAIM_TIMEOUT_MS, { method: 'POST', body: JSON.stringify({ repo }) });
|
|
88
|
+
if (typeof response.token !== 'string' && response.token !== null) {
|
|
89
|
+
return { token: null, reason: 'unavailable' };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
token: response.token,
|
|
93
|
+
...(typeof response.reason === 'string' ? { reason: response.reason } : {}),
|
|
94
|
+
...(typeof response.message === 'string' ? { message: response.message } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { token: null, reason: 'unavailable' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
claimNext(connectionId) {
|
|
102
|
+
return this.call(connectionId
|
|
103
|
+
? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}`
|
|
104
|
+
: '/api/companion/tasks/next', CLAIM_TIMEOUT_MS, { headers: capabilityHeaders() });
|
|
105
|
+
}
|
|
106
|
+
async claimNextInvestigation() {
|
|
107
|
+
const response = await this.request('/api/companion/investigations/next', CLAIM_TIMEOUT_MS, {
|
|
108
|
+
headers: {
|
|
109
|
+
'X-Companion-Version': COMPANION_VERSION,
|
|
110
|
+
...capabilityHeaders(),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (response.status === 204)
|
|
114
|
+
return null;
|
|
115
|
+
return this.readResponse(response);
|
|
116
|
+
}
|
|
117
|
+
async registerConnection(identity) {
|
|
118
|
+
try {
|
|
119
|
+
return await this.call('/api/companion/connections/register', PAIRING_TIMEOUT_MS, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body: JSON.stringify(identity),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error instanceof CompanionHttpError) {
|
|
126
|
+
const code = error.message;
|
|
127
|
+
if (code === 'connection_ambiguous' || code === 'branch_conflict' || code === 'connection_limit') {
|
|
128
|
+
throw new PairingHttpError(code);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new PairingHttpError('registration_failed');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async recordPairingOutcome(outcome) {
|
|
135
|
+
try {
|
|
136
|
+
return await this.call('/api/companion/pairing/outcome', PAIRING_TIMEOUT_MS, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
body: JSON.stringify({ outcome }),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new PairingHttpError('pairing_outcome_failed');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
claimNextSurfaceMapping(connectionId) {
|
|
146
|
+
return this.call(`/api/companion/surface-mappings/next?connectionId=${encodeURIComponent(connectionId)}`, CLAIM_TIMEOUT_MS, {
|
|
147
|
+
headers: {
|
|
148
|
+
'X-Companion-Version': COMPANION_VERSION,
|
|
149
|
+
...capabilityHeaders(),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
reportSurfaceMappingResult(job, manifest) {
|
|
154
|
+
return this.call(`/api/companion/surface-mappings/${encodeURIComponent(job.jobId)}/result`, RESULT_TIMEOUT_MS, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'X-Companion-Version': COMPANION_VERSION,
|
|
158
|
+
...capabilityHeaders(),
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
attempt: job.attempt,
|
|
162
|
+
claimToken: job.claimToken,
|
|
163
|
+
resultKey: job.resultKey,
|
|
164
|
+
manifestHash: manifestHash(manifest),
|
|
165
|
+
manifest,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
reportSurfaceMappingError(job, error) {
|
|
170
|
+
return this.call(`/api/companion/surface-mappings/${encodeURIComponent(job.jobId)}/error`, ERROR_REPORT_TIMEOUT_MS, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'X-Companion-Version': COMPANION_VERSION,
|
|
174
|
+
...capabilityHeaders(),
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
attempt: job.attempt,
|
|
178
|
+
claimToken: job.claimToken,
|
|
179
|
+
kind: error.kind,
|
|
180
|
+
code: error.code,
|
|
181
|
+
detail: error.detail,
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
heartbeat(taskId) {
|
|
186
|
+
return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST' });
|
|
187
|
+
}
|
|
188
|
+
reportResult(taskId, result, filesRead, snapshot) {
|
|
189
|
+
return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: capabilityHeaders(),
|
|
192
|
+
body: JSON.stringify({ result, filesRead, ...(snapshot ? { snapshot } : {}) }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
reportGitRangeResult(taskId, result) {
|
|
196
|
+
const body = JSON.stringify({ result, filesRead: [] });
|
|
197
|
+
if (Buffer.byteLength(body, 'utf8') > 1_572_864) {
|
|
198
|
+
throw new Error('history_failed');
|
|
199
|
+
}
|
|
200
|
+
return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body });
|
|
201
|
+
}
|
|
202
|
+
reportError(taskId, error) {
|
|
203
|
+
return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({ error }) });
|
|
204
|
+
}
|
|
205
|
+
reportInvestigationResult(investigationId, result) {
|
|
206
|
+
return this.call(`/api/companion/investigations/${encodeURIComponent(investigationId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify(result) });
|
|
207
|
+
}
|
|
208
|
+
reportReadiness(report) {
|
|
209
|
+
return this.call('/api/companion/readiness', READINESS_TIMEOUT_MS, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
body: JSON.stringify(report),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|