@withtruing/companion 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +329 -3
- 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 +50 -6
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-AI-call local grounding — see the companion plan's Section 4. Phase 1 keeps this to
|
|
3
|
+
* exactly what verify_module needs (a file listing, and the commit the checkout is at); the
|
|
4
|
+
* fuller monorepo-summarization/caching design in that section is scoped to a later phase, not
|
|
5
|
+
* required for the narrowest end-to-end slice this phase ships.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export const REPOSITORY_IDENTITY_MESSAGES = {
|
|
13
|
+
remote_ambiguous: 'Could not choose a git remote. Pass --remote <name>.',
|
|
14
|
+
remote_unsupported: 'This repository uses a remote URL that is not supported.',
|
|
15
|
+
branch_unknown: 'Could not determine the default branch. Pass --branch <name>.',
|
|
16
|
+
};
|
|
17
|
+
export const REMOTE_TROUBLESHOOTING_HINT = 'Run `git remote -v` in this folder to see your remotes.';
|
|
18
|
+
export class RepositoryIdentityError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(code) {
|
|
21
|
+
super(REPOSITORY_IDENTITY_MESSAGES[code]);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.name = 'RepositoryIdentityError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function unsupported() {
|
|
27
|
+
throw new RepositoryIdentityError('remote_unsupported');
|
|
28
|
+
}
|
|
29
|
+
function canonicalPath(rawPath, github) {
|
|
30
|
+
const path = rawPath.replace(/^\/+/, '').replace(/\.git$/, '');
|
|
31
|
+
const segments = path.split('/');
|
|
32
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..'))
|
|
33
|
+
unsupported();
|
|
34
|
+
if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)+$/.test(path))
|
|
35
|
+
unsupported();
|
|
36
|
+
if (github && segments.length !== 2)
|
|
37
|
+
unsupported();
|
|
38
|
+
return github ? path.toLowerCase() : path;
|
|
39
|
+
}
|
|
40
|
+
export function parseRemoteUrl(remoteUrl) {
|
|
41
|
+
let host;
|
|
42
|
+
let port;
|
|
43
|
+
let rawPath;
|
|
44
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remoteUrl)
|
|
45
|
+
&& !remoteUrl.startsWith('https://') && !remoteUrl.startsWith('ssh://'))
|
|
46
|
+
unsupported();
|
|
47
|
+
if (remoteUrl.startsWith('https://') || remoteUrl.startsWith('ssh://')) {
|
|
48
|
+
const rawPathMatch = /^[A-Za-z]+:\/\/[^/]+\/(.*?)(?:[?#]|$)/.exec(remoteUrl);
|
|
49
|
+
if (!rawPathMatch || rawPathMatch[1].split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
50
|
+
unsupported();
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(remoteUrl);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
unsupported();
|
|
58
|
+
}
|
|
59
|
+
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') || parsed.search || parsed.hash)
|
|
60
|
+
unsupported();
|
|
61
|
+
host = parsed.hostname.toLowerCase();
|
|
62
|
+
if (!host)
|
|
63
|
+
unsupported();
|
|
64
|
+
const parsedPort = parsed.port ? Number(parsed.port) : null;
|
|
65
|
+
const defaultPort = parsed.protocol === 'https:' ? 443 : 22;
|
|
66
|
+
port = parsedPort === null || parsedPort === defaultPort ? null : parsedPort;
|
|
67
|
+
rawPath = rawPathMatch[1];
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const match = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(remoteUrl);
|
|
71
|
+
if (!match || /^[A-Za-z]:[\\/]/.test(remoteUrl))
|
|
72
|
+
unsupported();
|
|
73
|
+
host = match[1].toLowerCase();
|
|
74
|
+
port = null;
|
|
75
|
+
rawPath = match[2];
|
|
76
|
+
}
|
|
77
|
+
const github = host === 'github.com';
|
|
78
|
+
return { host, port: github ? null : port, path: canonicalPath(rawPath, github) };
|
|
79
|
+
}
|
|
80
|
+
async function git(repoPath, args) {
|
|
81
|
+
const { stdout } = await execFileAsync('git', args, { cwd: repoPath, windowsHide: true });
|
|
82
|
+
return stdout.trim();
|
|
83
|
+
}
|
|
84
|
+
async function validBranch(repoPath, branch) {
|
|
85
|
+
try {
|
|
86
|
+
await execFileAsync('git', ['check-ref-format', '--branch', branch], { cwd: repoPath, windowsHide: true });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function repositoryIdentity(repoPath, options = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const remotes = (await git(repoPath, ['remote'])).split('\n').filter(Boolean);
|
|
96
|
+
const remote = options.remote
|
|
97
|
+
?? (remotes.includes('origin') ? 'origin' : remotes.length === 1 ? remotes[0] : undefined);
|
|
98
|
+
if (!remote || !remotes.includes(remote))
|
|
99
|
+
throw new RepositoryIdentityError('remote_ambiguous');
|
|
100
|
+
let remoteUrl;
|
|
101
|
+
try {
|
|
102
|
+
remoteUrl = await git(repoPath, ['remote', 'get-url', remote]);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new RepositoryIdentityError('remote_unsupported');
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
108
|
+
let defaultBranch = options.branch;
|
|
109
|
+
if (!defaultBranch) {
|
|
110
|
+
try {
|
|
111
|
+
const symbolic = await git(repoPath, ['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
112
|
+
const prefix = `${remote}/`;
|
|
113
|
+
defaultBranch = symbolic.startsWith(prefix) ? symbolic.slice(prefix.length) : undefined;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Fall through to main/master remote refs.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!defaultBranch) {
|
|
120
|
+
for (const candidate of ['main', 'master']) {
|
|
121
|
+
try {
|
|
122
|
+
await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${candidate}`], { cwd: repoPath, windowsHide: true });
|
|
123
|
+
defaultBranch = candidate;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Try the next fallback.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!defaultBranch || !(await validBranch(repoPath, defaultBranch))) {
|
|
132
|
+
throw new RepositoryIdentityError('branch_unknown');
|
|
133
|
+
}
|
|
134
|
+
return { ...parsed, defaultBranch, remote };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error instanceof RepositoryIdentityError)
|
|
138
|
+
throw error;
|
|
139
|
+
throw new RepositoryIdentityError('remote_unsupported');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function repositoryTopLevel(folder) {
|
|
143
|
+
try {
|
|
144
|
+
const topLevel = await git(folder, ['rev-parse', '--show-toplevel']);
|
|
145
|
+
return topLevel || null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export const GIT_UNAVAILABLE_MESSAGE = 'git is not available on PATH for this companion process — open a new terminal where `git --version` works, then restart the companion.';
|
|
152
|
+
export function repositoryMissingReason(repoPath) {
|
|
153
|
+
return `The project folder \`${repoPath}\` does not exist on this machine. Re-run \`connect\` with \`--path\` pointing at the repository's current location.`;
|
|
154
|
+
}
|
|
155
|
+
export function spawnFailureCode(error) {
|
|
156
|
+
if (!error || typeof error !== 'object')
|
|
157
|
+
return undefined;
|
|
158
|
+
const candidate = error;
|
|
159
|
+
if (typeof candidate.code === 'string')
|
|
160
|
+
return candidate.code;
|
|
161
|
+
return spawnFailureCode(candidate.cause);
|
|
162
|
+
}
|
|
163
|
+
export async function checkRepositoryReadiness(repoPath) {
|
|
164
|
+
try {
|
|
165
|
+
if (!(await stat(repoPath)).isDirectory()) {
|
|
166
|
+
return { state: 'repository_missing', reason: repositoryMissingReason(repoPath) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const code = spawnFailureCode(error);
|
|
171
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
172
|
+
return { state: 'repository_missing', reason: repositoryMissingReason(repoPath) };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
state: 'not_a_repository',
|
|
176
|
+
reason: `${repoPath} doesn't look like a git repository (git rev-parse failed there).`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoPath, windowsHide: true });
|
|
181
|
+
if (stdout.trim() === 'true')
|
|
182
|
+
return { state: 'ready' };
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const code = spawnFailureCode(error);
|
|
186
|
+
if (code === 'ENOENT' || code === 'EACCES') {
|
|
187
|
+
return { state: 'git_unavailable', reason: GIT_UNAVAILABLE_MESSAGE };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
state: 'not_a_repository',
|
|
192
|
+
reason: `${repoPath} doesn't look like a git repository (git rev-parse failed there).`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** NUL-delimited tracked paths preserve unusual filenames exactly. Gitlinks remain visible so
|
|
196
|
+
* the snapshot-boundary check can reject unbound submodules before a repository-read task runs. */
|
|
197
|
+
export async function listRepoFiles(repoPath) {
|
|
198
|
+
const { stdout } = await execFileAsync('git', ['ls-files', '-z'], { cwd: repoPath, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
199
|
+
return parseNullDelimitedPaths(stdout);
|
|
200
|
+
}
|
|
201
|
+
export function parseNullDelimitedPaths(stdout) {
|
|
202
|
+
return stdout.split('\0').filter((path) => path.length > 0);
|
|
203
|
+
}
|
|
204
|
+
export async function currentCommit(repoPath) {
|
|
205
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoPath, windowsHide: true });
|
|
206
|
+
return stdout.trim();
|
|
207
|
+
}
|
|
208
|
+
/** Confirms repoPath is actually inside a git working tree before doing anything else — a clear
|
|
209
|
+
* "this path isn't a git repo" failure beats a confusing ENOENT deep inside listRepoFiles. */
|
|
210
|
+
export async function assertIsRepo(repoPath) {
|
|
211
|
+
try {
|
|
212
|
+
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoPath, windowsHide: true });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new Error(`${repoPath} doesn't look like a git repository (git rev-parse failed there).`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function findFile(paths, basenames) {
|
|
219
|
+
const wanted = new Set(basenames.map((n) => n.toLowerCase()));
|
|
220
|
+
const matches = paths.filter((p) => wanted.has(p.split('/').pop().toLowerCase()));
|
|
221
|
+
matches.sort((a, b) => a.split('/').length - b.split('/').length); // prefer a shallower match, same tie-break as bootstrap.ts's own findFile
|
|
222
|
+
return matches[0];
|
|
223
|
+
}
|
|
224
|
+
async function readTextFile(repoPath, relPath) {
|
|
225
|
+
try {
|
|
226
|
+
return await readFile(join(repoPath, relPath), 'utf8');
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function buildLocalRepoContext(repoPath) {
|
|
233
|
+
const fileList = await listRepoFiles(repoPath);
|
|
234
|
+
const readmePath = findFile(fileList, ['README.md', 'readme.md', 'Readme.md']);
|
|
235
|
+
const changelogPath = findFile(fileList, ['CHANGELOG.md', 'changelog.md']);
|
|
236
|
+
const packagePath = findFile(fileList, ['package.json']);
|
|
237
|
+
const [readme, changelog, packageJson] = await Promise.all([
|
|
238
|
+
readmePath ? readTextFile(repoPath, readmePath) : Promise.resolve(null),
|
|
239
|
+
changelogPath ? readTextFile(repoPath, changelogPath) : Promise.resolve(null),
|
|
240
|
+
packagePath ? readTextFile(repoPath, packagePath) : Promise.resolve(null),
|
|
241
|
+
]);
|
|
242
|
+
return { fileList, readme, changelog, packageJson };
|
|
243
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The companion's own copy of a GitHub repo, for people who don't keep the code on their computer
|
|
3
|
+
* (`connect --repo owner/name`). The copy is an ordinary full clone under <config dir>/repos/, so
|
|
4
|
+
* every existing read path (git-range fetch, sandbox worktrees, shipment reads) serves it exactly
|
|
5
|
+
* as it serves a user's own checkout. This module owns making, checking and deleting that copy,
|
|
6
|
+
* the environment for background network Git calls, and a small per-copy state file that records
|
|
7
|
+
* whether background fetches still reach GitHub.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { parseRemoteUrl } from './local-scan.js';
|
|
13
|
+
export class ManagedRepoError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/** Background Git network calls (fetch) must never open a prompt or a Git Credential Manager
|
|
16
|
+
* window from the hidden service: they either work with the sign-in already saved, or fail. */
|
|
17
|
+
export function networkGitEnvironment(parent = process.env) {
|
|
18
|
+
return { ...parent, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
|
|
19
|
+
}
|
|
20
|
+
/** Supplies a GitHub token to Git for one process only. The URL-scoped header never reaches the
|
|
21
|
+
* remote URL or a config file, and trace variables are removed so Git cannot print the header. */
|
|
22
|
+
export function githubAuthEnvironment(token, parent = process.env) {
|
|
23
|
+
const environment = networkGitEnvironment(parent);
|
|
24
|
+
const countText = environment.GIT_CONFIG_COUNT;
|
|
25
|
+
const parsedCount = countText !== undefined && /^(0|[1-9][0-9]*)$/.test(countText)
|
|
26
|
+
? Number(countText)
|
|
27
|
+
: 0;
|
|
28
|
+
const count = Number.isSafeInteger(parsedCount) ? parsedCount : 0;
|
|
29
|
+
environment.GIT_CONFIG_COUNT = String(count + 1);
|
|
30
|
+
environment[`GIT_CONFIG_KEY_${count}`] = 'http.https://github.com/.extraHeader';
|
|
31
|
+
environment[`GIT_CONFIG_VALUE_${count}`] = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`;
|
|
32
|
+
for (const name of ['GIT_TRACE', 'GIT_TRACE_CURL', 'GIT_TRACE_PACKET', 'GIT_CURL_VERBOSE', 'GIT_TRACE_REDACT']) {
|
|
33
|
+
delete environment[name];
|
|
34
|
+
}
|
|
35
|
+
return environment;
|
|
36
|
+
}
|
|
37
|
+
const OWNER = /^[A-Za-z0-9-]+$/;
|
|
38
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
39
|
+
const NOT_GITHUB_MESSAGE = 'The --repo option works with GitHub repos only, written as owner/name '
|
|
40
|
+
+ '(for example fynchconsulting/product-console) or as the repo\'s https://github.com/... link.';
|
|
41
|
+
/** Accepts `owner/name`, `owner/name.git`, and `https://github.com/owner/name[.git][/]`. */
|
|
42
|
+
export function parseGitHubRepo(input) {
|
|
43
|
+
let value = input.trim();
|
|
44
|
+
const link = /^https:\/\/([^/?#]+)\/(.*)$/i.exec(value);
|
|
45
|
+
if (link) {
|
|
46
|
+
if (link[1].toLowerCase() !== 'github.com')
|
|
47
|
+
throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
|
|
48
|
+
value = link[2];
|
|
49
|
+
}
|
|
50
|
+
else if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.includes('@')) {
|
|
51
|
+
throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
|
|
52
|
+
}
|
|
53
|
+
value = value.replace(/\/$/, '');
|
|
54
|
+
const parts = value.split('/');
|
|
55
|
+
if (parts.length !== 2)
|
|
56
|
+
throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
|
|
57
|
+
const owner = parts[0];
|
|
58
|
+
const name = parts[1].replace(/\.git$/i, '');
|
|
59
|
+
if (!OWNER.test(owner) || !NAME.test(name) || name === '.' || name === '..') {
|
|
60
|
+
throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
|
|
61
|
+
}
|
|
62
|
+
return { owner, name };
|
|
63
|
+
}
|
|
64
|
+
export function githubLabel(repo) {
|
|
65
|
+
return `${repo.owner}/${repo.name}`;
|
|
66
|
+
}
|
|
67
|
+
export function githubCloneUrl(repo) {
|
|
68
|
+
return `https://github.com/${repo.owner}/${repo.name}.git`;
|
|
69
|
+
}
|
|
70
|
+
function sanitizeSegment(value) {
|
|
71
|
+
const cleaned = value.replace(/[^A-Za-z0-9._-]/g, '_').toLowerCase();
|
|
72
|
+
return cleaned === '.' || cleaned === '..' || !cleaned ? '_' : cleaned;
|
|
73
|
+
}
|
|
74
|
+
/** GitHub names are case-insensitive, so the folder is lowercased: `Owner/Repo` and `owner/repo`
|
|
75
|
+
* share one copy on every file system. */
|
|
76
|
+
export function managedCopyFolderName(repo) {
|
|
77
|
+
return `github.com__${sanitizeSegment(repo.owner)}__${sanitizeSegment(repo.name)}`;
|
|
78
|
+
}
|
|
79
|
+
export function managedCopyPath(reposDir, repo) {
|
|
80
|
+
return join(resolve(reposDir), managedCopyFolderName(repo));
|
|
81
|
+
}
|
|
82
|
+
/** Best-effort owner/name for a copy whose config entry has no label. */
|
|
83
|
+
export function labelFromCopyPath(repoPath) {
|
|
84
|
+
const match = /^github\.com__(.+?)__(.+)$/.exec(basename(repoPath));
|
|
85
|
+
return match ? `${match[1]}/${match[2]}` : basename(repoPath);
|
|
86
|
+
}
|
|
87
|
+
const MAX_CAPTURED_STDERR = 16_384;
|
|
88
|
+
/** `stdio: 'inherit'` lets Git (and Git Credential Manager) talk to the person directly; stderr
|
|
89
|
+
* is still teed to the terminal and captured so a failure can be explained in one line. */
|
|
90
|
+
function runGit(cwd, args, options = {}) {
|
|
91
|
+
const interactive = options.stdio === 'inherit';
|
|
92
|
+
options.observeArgs?.(args);
|
|
93
|
+
return new Promise((done) => {
|
|
94
|
+
const child = spawn('git', args, {
|
|
95
|
+
cwd,
|
|
96
|
+
env: options.env ?? process.env,
|
|
97
|
+
stdio: interactive ? ['inherit', 'inherit', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
let stdout = '';
|
|
101
|
+
let stderr = '';
|
|
102
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
|
|
103
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk) => {
|
|
104
|
+
if (interactive)
|
|
105
|
+
process.stderr.write(chunk);
|
|
106
|
+
if (stderr.length < MAX_CAPTURED_STDERR)
|
|
107
|
+
stderr += chunk.slice(0, MAX_CAPTURED_STDERR - stderr.length);
|
|
108
|
+
});
|
|
109
|
+
child.on('error', (error) => done({ code: null, stdout, stderr: stderr || error.message }));
|
|
110
|
+
child.on('close', (code) => done({ code, stdout, stderr }));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function gitAvailable() {
|
|
114
|
+
const result = await runGit(process.cwd(), ['--version']);
|
|
115
|
+
return result.code === 0;
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------------------------
|
|
118
|
+
// Sign-in problems and the per-copy state file
|
|
119
|
+
const SIGN_IN_PATTERNS = [
|
|
120
|
+
/authentication failed/i,
|
|
121
|
+
/could not read (username|password)/i,
|
|
122
|
+
/terminal prompts disabled/i,
|
|
123
|
+
/repository not found/i,
|
|
124
|
+
/returned error: 40[13]\b/i,
|
|
125
|
+
/\bHTTP\/?[\d.]*\s+40[13]\b/i,
|
|
126
|
+
/\b40[13] (unauthorized|forbidden)\b/i,
|
|
127
|
+
/invalid username or (password|token)/i,
|
|
128
|
+
/permission denied \(publickey\)/i,
|
|
129
|
+
];
|
|
130
|
+
export function isSignInProblem(stderr) {
|
|
131
|
+
return SIGN_IN_PATTERNS.some((pattern) => pattern.test(stderr));
|
|
132
|
+
}
|
|
133
|
+
/** One short line for a person: credentials in URLs are redacted, Git's own prefixes dropped. */
|
|
134
|
+
export function shortGitProblem(stderr) {
|
|
135
|
+
if (isSignInProblem(stderr))
|
|
136
|
+
return 'GitHub did not accept the sign-in on this computer for this repo';
|
|
137
|
+
const lines = stderr
|
|
138
|
+
.split(/\r?\n/)
|
|
139
|
+
.map((line) => line.replace(/(https?:\/\/)[^\s/@]+@/gi, '$1[redacted]@').trim())
|
|
140
|
+
.filter((line) => line && !/^(remote:|hint:)/i.test(line));
|
|
141
|
+
const line = lines.at(-1)?.replace(/^(fatal|error):\s*/i, '') ?? '';
|
|
142
|
+
return (line || 'Git could not reach GitHub').slice(0, 200);
|
|
143
|
+
}
|
|
144
|
+
export function managedStatePath(repoPath) {
|
|
145
|
+
return `${resolve(repoPath)}.state.json`;
|
|
146
|
+
}
|
|
147
|
+
export function readManagedState(repoPath) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(readFileSync(managedStatePath(repoPath), 'utf8'));
|
|
150
|
+
return {
|
|
151
|
+
lastUpdatedAt: typeof parsed.lastUpdatedAt === 'string' ? parsed.lastUpdatedAt : undefined,
|
|
152
|
+
signInNeeded: parsed.signInNeeded === true,
|
|
153
|
+
reconnectInConsole: parsed.reconnectInConsole === true,
|
|
154
|
+
lastProblem: typeof parsed.lastProblem === 'string' ? parsed.lastProblem : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Merges one fetch outcome into the state file. A failure that isn't about sign-in (no network,
|
|
162
|
+
* GitHub down) leaves signInNeeded as it was. Never throws: a state write must not fail a task. */
|
|
163
|
+
export function recordManagedFetch(repoPath, outcome, now = new Date()) {
|
|
164
|
+
const previous = readManagedState(repoPath) ?? { signInNeeded: false };
|
|
165
|
+
const signInProblem = isSignInProblem(outcome.stderr ?? '');
|
|
166
|
+
const next = outcome.ok
|
|
167
|
+
? { lastUpdatedAt: now.toISOString(), signInNeeded: false }
|
|
168
|
+
: {
|
|
169
|
+
lastUpdatedAt: previous.lastUpdatedAt,
|
|
170
|
+
signInNeeded: signInProblem || previous.signInNeeded,
|
|
171
|
+
reconnectInConsole: signInProblem
|
|
172
|
+
? outcome.reconnectInConsole === true
|
|
173
|
+
: previous.reconnectInConsole,
|
|
174
|
+
lastProblem: shortGitProblem(outcome.stderr ?? ''),
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(managedStatePath(repoPath), `${JSON.stringify(next, null, 2)}\n`);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Best-effort.
|
|
181
|
+
}
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
const watchedCopies = new Map();
|
|
185
|
+
function watchKey(repoPath) {
|
|
186
|
+
const resolved = resolve(repoPath);
|
|
187
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
188
|
+
}
|
|
189
|
+
/** Called by `run` for each managed connection, so the fetches the read paths already make are
|
|
190
|
+
* recorded for that copy. Fetches in any other folder are not recorded. */
|
|
191
|
+
export function watchManagedCopy(repoPath, label, log, getToken) {
|
|
192
|
+
const existing = watchedCopies.get(watchKey(repoPath));
|
|
193
|
+
watchedCopies.set(watchKey(repoPath), {
|
|
194
|
+
label,
|
|
195
|
+
log,
|
|
196
|
+
getToken,
|
|
197
|
+
lastTokenReason: undefined,
|
|
198
|
+
signInEpisodeLogged: existing?.signInEpisodeLogged ?? false,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export function forgetManagedCopies() {
|
|
202
|
+
watchedCopies.clear();
|
|
203
|
+
}
|
|
204
|
+
async function tokenFrom(getToken) {
|
|
205
|
+
if (!getToken)
|
|
206
|
+
return null;
|
|
207
|
+
try {
|
|
208
|
+
return await getToken();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Returns a fresh environment for each background fetch. Token lookup is best-effort so an old
|
|
215
|
+
* Console, network trouble, or an unwatched checkout keeps the existing local-sign-in fallback. */
|
|
216
|
+
export async function managedFetchEnvironment(repoPath) {
|
|
217
|
+
const watched = watchedCopies.get(watchKey(repoPath));
|
|
218
|
+
if (!watched?.getToken)
|
|
219
|
+
return networkGitEnvironment();
|
|
220
|
+
try {
|
|
221
|
+
const response = await watched.getToken();
|
|
222
|
+
watched.lastTokenReason = response.reason;
|
|
223
|
+
return response.token === null ? networkGitEnvironment() : githubAuthEnvironment(response.token);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
watched.lastTokenReason = undefined;
|
|
227
|
+
return networkGitEnvironment();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Every background fetch reports here. A no-op for folders that aren't a watched managed copy;
|
|
231
|
+
* otherwise records the state file and logs a sign-in problem once per episode. Never throws. */
|
|
232
|
+
export function reportBackgroundFetch(repoPath, outcome) {
|
|
233
|
+
try {
|
|
234
|
+
const watched = watchedCopies.get(watchKey(repoPath));
|
|
235
|
+
if (!watched)
|
|
236
|
+
return;
|
|
237
|
+
const reconnectInConsole = watched.lastTokenReason === 'github_reconnect_needed';
|
|
238
|
+
recordManagedFetch(repoPath, { ...outcome, reconnectInConsole });
|
|
239
|
+
if (outcome.ok) {
|
|
240
|
+
watched.signInEpisodeLogged = false;
|
|
241
|
+
}
|
|
242
|
+
else if (isSignInProblem(outcome.stderr ?? '') && !watched.signInEpisodeLogged) {
|
|
243
|
+
watched.signInEpisodeLogged = true;
|
|
244
|
+
watched.log(reconnectInConsole
|
|
245
|
+
? `GitHub sign-in needed for ${watched.label}. Reconnect GitHub in the Console.`
|
|
246
|
+
: `GitHub sign-in needed for ${watched.label}. On this computer, run: truing signin`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Reporting is best-effort; the read path's own result is what matters.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------------------------------------
|
|
254
|
+
// Making, refreshing and deleting the copy
|
|
255
|
+
function samePath(a, b) {
|
|
256
|
+
const left = resolve(a);
|
|
257
|
+
const right = resolve(b);
|
|
258
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
259
|
+
}
|
|
260
|
+
/** True only when `path` is the top of its own Git repo and its origin is this GitHub repo. */
|
|
261
|
+
export async function isCopyOf(path, repo) {
|
|
262
|
+
const top = await runGit(path, ['rev-parse', '--show-toplevel']);
|
|
263
|
+
if (top.code !== 0)
|
|
264
|
+
return false;
|
|
265
|
+
try {
|
|
266
|
+
if (!samePath(realpathSync(top.stdout.trim()), realpathSync(path)))
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const origin = await runGit(path, ['config', '--get', 'remote.origin.url']);
|
|
273
|
+
if (origin.code !== 0)
|
|
274
|
+
return false;
|
|
275
|
+
try {
|
|
276
|
+
const parsed = parseRemoteUrl(origin.stdout.trim());
|
|
277
|
+
return parsed.host === 'github.com' && parsed.path === `${repo.owner}/${repo.name}`.toLowerCase();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function runGitWithTokenRetry(cwd, args, token, label, options, beforeRetry) {
|
|
284
|
+
const stdio = options.stdio ?? 'inherit';
|
|
285
|
+
const run = options.runGitForTest ?? runGit;
|
|
286
|
+
const initialEnvironment = token === null
|
|
287
|
+
? (options.networkEnvironmentWithoutToken ? networkGitEnvironment() : undefined)
|
|
288
|
+
: githubAuthEnvironment(token);
|
|
289
|
+
let result = await run(cwd, args, {
|
|
290
|
+
stdio,
|
|
291
|
+
...(initialEnvironment ? { env: initialEnvironment } : {}),
|
|
292
|
+
observeArgs: options.observeGitArgs,
|
|
293
|
+
});
|
|
294
|
+
const retry = options.retryWithoutTokenOnSignInProblem ?? stdio === 'inherit';
|
|
295
|
+
if (token !== null && retry && result.code !== 0 && isSignInProblem(result.stderr)) {
|
|
296
|
+
options.output?.log(`The Console's GitHub connection didn't work for ${label}. Trying the sign-in on this computer instead.`);
|
|
297
|
+
beforeRetry?.();
|
|
298
|
+
result = await run(cwd, args, { stdio, observeArgs: options.observeGitArgs });
|
|
299
|
+
}
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
/** A fetch the person is watching: prompts and the GitHub sign-in window are allowed. */
|
|
303
|
+
export async function interactiveFetch(repoPath, getToken, options = {}) {
|
|
304
|
+
const token = await tokenFrom(getToken);
|
|
305
|
+
const result = await runGitWithTokenRetry(repoPath, ['fetch', 'origin'], token, labelFromCopyPath(repoPath), {
|
|
306
|
+
...options,
|
|
307
|
+
output: options.output ?? console,
|
|
308
|
+
networkEnvironmentWithoutToken: (options.stdio ?? 'inherit') === 'quiet',
|
|
309
|
+
});
|
|
310
|
+
const outcome = { ok: result.code === 0, stderr: result.stderr };
|
|
311
|
+
recordManagedFetch(repoPath, outcome);
|
|
312
|
+
return outcome;
|
|
313
|
+
}
|
|
314
|
+
export async function prepareManagedCopy(options) {
|
|
315
|
+
const output = options.output ?? console;
|
|
316
|
+
const label = githubLabel(options.repo);
|
|
317
|
+
const dest = managedCopyPath(options.reposDir, options.repo);
|
|
318
|
+
const stdio = options.stdio ?? 'inherit';
|
|
319
|
+
if (existsSync(dest)) {
|
|
320
|
+
if (!(await isCopyOf(dest, options.repo))) {
|
|
321
|
+
throw new ManagedRepoError(`There's already a folder at ${dest} that isn't the companion's copy of ${label}. `
|
|
322
|
+
+ 'Move or delete that folder yourself, then run this command again.');
|
|
323
|
+
}
|
|
324
|
+
output.log(`Using the companion's existing copy of ${label} and bringing it up to date.`);
|
|
325
|
+
const fetched = await interactiveFetch(dest, options.getToken, {
|
|
326
|
+
stdio,
|
|
327
|
+
output,
|
|
328
|
+
observeGitArgs: options.observeGitArgs,
|
|
329
|
+
retryWithoutTokenOnSignInProblem: options.retryWithoutTokenOnSignInProblem,
|
|
330
|
+
runGitForTest: options.runGitForTest,
|
|
331
|
+
});
|
|
332
|
+
return { path: dest, created: false, fetchOk: fetched.ok };
|
|
333
|
+
}
|
|
334
|
+
mkdirSync(resolve(options.reposDir), { recursive: true });
|
|
335
|
+
const token = await tokenFrom(options.getToken);
|
|
336
|
+
output.log(token === null
|
|
337
|
+
? `Making the companion's own copy of ${label}. If a browser window asks you to sign in to GitHub, do that once.`
|
|
338
|
+
: `Making the companion's own copy of ${label}. Using your GitHub connection from the Console.`);
|
|
339
|
+
const githubUrl = githubCloneUrl(options.repo);
|
|
340
|
+
const cloneArgs = [
|
|
341
|
+
'-c', 'core.longpaths=true', 'clone', '--origin', 'origin',
|
|
342
|
+
...(options.branch ? ['--branch', options.branch] : []),
|
|
343
|
+
'--', options.cloneUrl ?? githubUrl, dest,
|
|
344
|
+
];
|
|
345
|
+
const clone = await runGitWithTokenRetry(resolve(options.reposDir), cloneArgs, token, label, {
|
|
346
|
+
stdio,
|
|
347
|
+
output,
|
|
348
|
+
observeGitArgs: options.observeGitArgs,
|
|
349
|
+
retryWithoutTokenOnSignInProblem: options.retryWithoutTokenOnSignInProblem,
|
|
350
|
+
runGitForTest: options.runGitForTest,
|
|
351
|
+
}, () => {
|
|
352
|
+
// The folder did not exist before this clone, so anything there now is the clone's own.
|
|
353
|
+
rmSync(dest, { recursive: true, force: true, maxRetries: 3 });
|
|
354
|
+
});
|
|
355
|
+
const configured = clone.code === 0
|
|
356
|
+
&& (await runGit(dest, ['config', 'core.longpaths', 'true'])).code === 0
|
|
357
|
+
&& (options.cloneUrl === undefined || options.cloneUrl === githubUrl
|
|
358
|
+
|| (await runGit(dest, ['remote', 'set-url', 'origin', githubUrl])).code === 0);
|
|
359
|
+
if (!configured) {
|
|
360
|
+
// The folder did not exist before this clone, so anything there now is the clone's own.
|
|
361
|
+
rmSync(dest, { recursive: true, force: true, maxRetries: 3 });
|
|
362
|
+
throw new ManagedRepoError(`Couldn't copy ${label} from GitHub. Check that your GitHub account can open this repo in the browser, then run this command again.`);
|
|
363
|
+
}
|
|
364
|
+
recordManagedFetch(dest, { ok: true });
|
|
365
|
+
return { path: dest, created: true };
|
|
366
|
+
}
|
|
367
|
+
/** Deletes a managed copy and its state file. Refuses anything that does not resolve to a folder
|
|
368
|
+
* directly inside reposDir. Returns false when there was nothing to delete. */
|
|
369
|
+
export async function removeManagedCopy(repoPath, reposDir) {
|
|
370
|
+
const root = resolve(reposDir);
|
|
371
|
+
const target = resolve(repoPath);
|
|
372
|
+
const inside = (base, candidate) => {
|
|
373
|
+
const rel = relative(base, candidate);
|
|
374
|
+
return !!rel && !rel.startsWith('..') && !isAbsolute(rel) && !/[\\/]/.test(rel);
|
|
375
|
+
};
|
|
376
|
+
if (!inside(root, target)) {
|
|
377
|
+
throw new ManagedRepoError(`Not removing ${target}: it isn't inside the companion's own repos folder.`);
|
|
378
|
+
}
|
|
379
|
+
if (!existsSync(target)) {
|
|
380
|
+
rmSync(managedStatePath(target), { force: true });
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
if (lstatSync(target).isSymbolicLink()
|
|
384
|
+
|| (existsSync(root) && !inside(realpathSync(root), realpathSync(target)))) {
|
|
385
|
+
throw new ManagedRepoError(`Not removing ${target}: it points outside the companion's own repos folder.`);
|
|
386
|
+
}
|
|
387
|
+
await runGit(target, ['worktree', 'prune']);
|
|
388
|
+
rmSync(target, { recursive: true, force: true, maxRetries: 3 });
|
|
389
|
+
rmSync(managedStatePath(target), { force: true });
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
export function relativeTime(iso, now = new Date()) {
|
|
393
|
+
if (!iso)
|
|
394
|
+
return 'not yet';
|
|
395
|
+
const ms = now.getTime() - Date.parse(iso);
|
|
396
|
+
if (!Number.isFinite(ms))
|
|
397
|
+
return 'not yet';
|
|
398
|
+
const minutes = Math.floor(ms / 60_000);
|
|
399
|
+
if (minutes < 1)
|
|
400
|
+
return 'just now';
|
|
401
|
+
if (minutes < 60)
|
|
402
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
403
|
+
const hours = Math.floor(minutes / 60);
|
|
404
|
+
if (hours < 24)
|
|
405
|
+
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
406
|
+
const days = Math.floor(hours / 24);
|
|
407
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
408
|
+
}
|