@oddessentials/agent-guild 0.5.0 → 0.7.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 (86) hide show
  1. package/README.md +182 -135
  2. package/package.json +1 -1
  3. package/src/manager/changelog.mjs +195 -0
  4. package/src/manager/config.mjs +1 -0
  5. package/src/manager/github.mjs +842 -0
  6. package/src/manager/main.mjs +10 -3
  7. package/src/manager/news.mjs +5 -5
  8. package/src/manager/server.mjs +70 -8
  9. package/src/manager/session-manager.mjs +32 -3
  10. package/src/manager/session.mjs +4 -1
  11. package/web/app.js +1005 -11
  12. package/web/art/art.css +374 -159
  13. package/web/art/characters/anthropic/idle.avif +0 -0
  14. package/web/art/characters/anthropic/idle.webp +0 -0
  15. package/web/art/characters/anthropic/locked.avif +0 -0
  16. package/web/art/characters/anthropic/locked.webp +0 -0
  17. package/web/art/characters/anthropic/working.avif +0 -0
  18. package/web/art/characters/anthropic/working.webp +0 -0
  19. package/web/art/characters/google/idle.avif +0 -0
  20. package/web/art/characters/google/idle.webp +0 -0
  21. package/web/art/characters/google/locked.avif +0 -0
  22. package/web/art/characters/google/locked.webp +0 -0
  23. package/web/art/characters/google/working.avif +0 -0
  24. package/web/art/characters/google/working.webp +0 -0
  25. package/web/art/characters/openai/idle.avif +0 -0
  26. package/web/art/characters/openai/idle.webp +0 -0
  27. package/web/art/characters/openai/locked.avif +0 -0
  28. package/web/art/characters/openai/locked.webp +0 -0
  29. package/web/art/characters/openai/working.avif +0 -0
  30. package/web/art/characters/openai/working.webp +0 -0
  31. package/web/art/characters/shell/idle.avif +0 -0
  32. package/web/art/characters/shell/idle.webp +0 -0
  33. package/web/art/characters/shell/locked.avif +0 -0
  34. package/web/art/characters/shell/locked.webp +0 -0
  35. package/web/art/characters/shell/working.avif +0 -0
  36. package/web/art/characters/shell/working.webp +0 -0
  37. package/web/art/characters/xai/idle.avif +0 -0
  38. package/web/art/characters/xai/idle.webp +0 -0
  39. package/web/art/characters/xai/locked.avif +0 -0
  40. package/web/art/characters/xai/locked.webp +0 -0
  41. package/web/art/characters/xai/working.avif +0 -0
  42. package/web/art/characters/xai/working.webp +0 -0
  43. package/web/art/icons/anthropic.avif +0 -0
  44. package/web/art/icons/anthropic.webp +0 -0
  45. package/web/art/icons/google.avif +0 -0
  46. package/web/art/icons/google.webp +0 -0
  47. package/web/art/icons/openai.avif +0 -0
  48. package/web/art/icons/openai.webp +0 -0
  49. package/web/art/icons/shell.avif +0 -0
  50. package/web/art/icons/shell.webp +0 -0
  51. package/web/art/icons/xai.avif +0 -0
  52. package/web/art/icons/xai.webp +0 -0
  53. package/web/art/page-light.avif +0 -0
  54. package/web/art/page-light.webp +0 -0
  55. package/web/art/page.avif +0 -0
  56. package/web/art/page.webp +0 -0
  57. package/web/art/ui/empty-state.avif +0 -0
  58. package/web/art/ui/empty-state.webp +0 -0
  59. package/web/art/ui/favicon.png +0 -0
  60. package/web/art/ui/frame-ornate.png +0 -0
  61. package/web/art/ui/guild-crest.png +0 -0
  62. package/web/fonts/OFL.txt +93 -0
  63. package/web/fonts/cinzel.woff2 +0 -0
  64. package/web/index.html +101 -15
  65. package/web/styles.css +240 -71
  66. package/web/art/characters/anthropic/idle.png +0 -0
  67. package/web/art/characters/anthropic/locked.png +0 -0
  68. package/web/art/characters/anthropic/working.png +0 -0
  69. package/web/art/characters/google/idle.png +0 -0
  70. package/web/art/characters/google/locked.png +0 -0
  71. package/web/art/characters/google/working.png +0 -0
  72. package/web/art/characters/openai/idle.png +0 -0
  73. package/web/art/characters/openai/locked.png +0 -0
  74. package/web/art/characters/openai/working.png +0 -0
  75. package/web/art/characters/shell/idle.png +0 -0
  76. package/web/art/characters/shell/locked.png +0 -0
  77. package/web/art/characters/shell/working.png +0 -0
  78. package/web/art/characters/xai/idle.png +0 -0
  79. package/web/art/characters/xai/locked.png +0 -0
  80. package/web/art/characters/xai/working.png +0 -0
  81. package/web/art/icons/anthropic.png +0 -0
  82. package/web/art/icons/google.png +0 -0
  83. package/web/art/icons/openai.png +0 -0
  84. package/web/art/icons/shell.png +0 -0
  85. package/web/art/icons/xai.png +0 -0
  86. package/web/art/page.png +0 -0
@@ -0,0 +1,842 @@
1
+ // Agent Guild owns every piece of GitHub state in <data>/github: tokens, keys,
2
+ // GitHub's host keys and the SSH and Git configuration a clone runs with. The
3
+ // user's own ~/.ssh and Git configuration are never read or written.
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { EventEmitter } from 'node:events';
10
+ import { resolveCommand, buildSpawnSpec, runSpec } from './command-resolver.mjs';
11
+ import { USER_AGENT } from './news.mjs';
12
+
13
+ export const GITHUB_CLIENT_ID = 'Ov23lif6qqYKtXZTb130';
14
+ export const GITHUB_SCOPES = ['repo', 'write:public_key'];
15
+ const API_URL = 'https://api.github.com';
16
+ const WEB_URL = 'https://github.com';
17
+ const SSH_HOST = 'git@github.com';
18
+
19
+ /** GitHub's published SSH host keys (docs: "GitHub's SSH key fingerprints"). */
20
+ export const GITHUB_HOST_KEYS = [
21
+ 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl',
22
+ 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=',
23
+ 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=',
24
+ ];
25
+
26
+ export const GITHUB_PROVIDER = Object.freeze({
27
+ id: 'github',
28
+ vendor: 'GitHub',
29
+ tool: 'Git',
30
+ color: '#24292f',
31
+ monogram: 'GH',
32
+ iconUrl: null,
33
+ modelPattern: null,
34
+ env: {},
35
+ });
36
+
37
+ const FETCH_TIMEOUT_MS = 15000;
38
+ const REPOS_TTL_MS = 5 * 60 * 1000;
39
+ const MAX_REPO_PAGES = 10;
40
+ const REFRESH_MARGIN_MS = 60 * 1000;
41
+ const MAX_AVATAR_BYTES = 100 * 1024;
42
+ const SSH_TIMEOUT_MS = 30000;
43
+ const REPO_RE = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/;
44
+ const ACCOUNT_ID_RE = /^[1-9]\d{0,15}$/;
45
+ const GREETING = /Hi ([^!\s]+)! You've successfully authenticated/;
46
+ const KEY_FAILURES = new Set(['ssh_key_manual', 'ssh_wrong_account', 'ssh_host_key', 'ssh_key_in_use', 'ssh_key_failed', 'ssh_unavailable', 'ssh_path_unsupported']);
47
+
48
+ /** Every inherited GIT_* variable is dropped: several (GIT_DIR, GIT_COMMON_DIR, GIT_SSH_COMMAND, ...) redirect a clone. */
49
+ export function dropsFromCloneEnv(key) {
50
+ return key.toUpperCase().startsWith('GIT_');
51
+ }
52
+
53
+ function refusal(status, code, message, extra = {}) {
54
+ return Object.assign(new Error(message), { status, code, ...extra });
55
+ }
56
+
57
+ export function parseRepo(value) {
58
+ const match = REPO_RE.exec(String(value ?? '').trim());
59
+ if (!match || match[2] === '.' || match[2] === '..' || match[2].endsWith('.git')) {
60
+ throw refusal(400, 'bad_repo', 'repo must be a GitHub repository written as owner/name');
61
+ }
62
+ return { owner: match[1], name: match[2], fullName: `${match[1]}/${match[2]}` };
63
+ }
64
+
65
+ export function sshUrl(fullName) {
66
+ return `${SSH_HOST}:${fullName}.git`;
67
+ }
68
+
69
+ export function remoteRepo(url) {
70
+ const text = String(url ?? '').trim();
71
+ const match = /^(?:git@github\.com:|ssh:\/\/git@github\.com(?::22)?\/|https:\/\/(?:[^@/]+@)?github\.com\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(text);
72
+ return match ? match[1].toLowerCase() : null;
73
+ }
74
+
75
+ export function originUrl(configText) {
76
+ let inOrigin = false;
77
+ for (const raw of String(configText).split(/\r?\n/)) {
78
+ const line = raw.trim();
79
+ const section = /^\[\s*([^\]\s"]+)(?:\s+"([^"]*)")?\s*\]/.exec(line);
80
+ if (section) {
81
+ inOrigin = section[1].toLowerCase() === 'remote' && section[2] === 'origin';
82
+ continue;
83
+ }
84
+ const entry = inOrigin && /^url\s*=\s*(.*)$/i.exec(line);
85
+ if (entry) return entry[1].replace(/^"(.*)"$/, '$1');
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** `absent` also covers an empty folder, which git clones into. */
91
+ export function localState(target, fullName) {
92
+ let stat;
93
+ try { stat = fs.statSync(target); } catch { return 'absent'; }
94
+ if (!stat.isDirectory()) return 'conflict';
95
+ try {
96
+ if (fs.readdirSync(target).length === 0) return 'absent';
97
+ const url = originUrl(fs.readFileSync(path.join(target, '.git', 'config'), 'utf8'));
98
+ return remoteRepo(url) === fullName.toLowerCase() ? 'cloned' : 'conflict';
99
+ } catch {
100
+ return 'conflict';
101
+ }
102
+ }
103
+
104
+ /** Quote one word for the POSIX shell Git runs core.sshCommand with (Git for Windows ships one too). */
105
+ export function shellQuote(word) {
106
+ return `'${String(word).replaceAll('\'', '\'\\\'\'')}'`;
107
+ }
108
+
109
+ /** Paths for OpenSSH: forward slashes on Windows, where both Win32-OpenSSH and Git's ssh accept them. */
110
+ export function sshPath(file, platform = process.platform) {
111
+ return platform === 'win32' ? file.replaceAll('\\', '/') : file;
112
+ }
113
+
114
+ /**
115
+ * An -o value naming a file: quoted for OpenSSH's own word splitting, with its
116
+ * %-tokens escaped. OpenSSH also expands ${VAR} and offers no escape for it.
117
+ * The key goes through -o IdentityFile, not -i, because -i checks the raw
118
+ * path before the tokens are expanded.
119
+ */
120
+ function sshFileOption(name, file, platform) {
121
+ const value = sshPath(file, platform);
122
+ if (value.includes('${')) throw refusal(409, 'ssh_path_unsupported', `OpenSSH cannot use a path containing "\${": ${value}. Move the Agent Guild data folder with AGENT_GUILD_HOME.`);
123
+ return `${name}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
124
+ }
125
+
126
+ export function sshArgs({ key, knownHosts, config, platform = process.platform }) {
127
+ return [
128
+ '-F', sshPath(config, platform),
129
+ '-o', sshFileOption('IdentityFile', key, platform),
130
+ '-o', 'IdentitiesOnly=yes',
131
+ '-o', 'BatchMode=yes',
132
+ '-o', 'StrictHostKeyChecking=yes',
133
+ '-o', 'GlobalKnownHostsFile=none',
134
+ '-o', sshFileOption('UserKnownHostsFile', knownHosts, platform),
135
+ ];
136
+ }
137
+
138
+ export function sshCommand({ ssh, platform = process.platform, ...files }) {
139
+ return [sshPath(ssh, platform), ...sshArgs({ ...files, platform })].map(shellQuote).join(' ');
140
+ }
141
+
142
+ function keyBlob(line) {
143
+ const [type, blob] = String(line ?? '').trim().split(/\s+/);
144
+ return type && blob ? `${type} ${blob}` : null;
145
+ }
146
+
147
+ export function nextLink(header) {
148
+ for (const part of String(header ?? '').split(',')) {
149
+ const match = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
150
+ if (match) return match[1];
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export function cleanRepo(raw) {
156
+ if (!raw || typeof raw !== 'object' || typeof raw.full_name !== 'string') return null;
157
+ let repo;
158
+ try { repo = parseRepo(raw.full_name); } catch { return null; }
159
+ const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);
160
+ const time = (value) => (Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null);
161
+ return {
162
+ fullName: repo.fullName,
163
+ owner: repo.owner,
164
+ ownerType: raw.owner?.type === 'Organization' ? 'Organization' : 'User',
165
+ name: repo.name,
166
+ private: raw.private === true,
167
+ fork: raw.fork === true,
168
+ archived: raw.archived === true,
169
+ description: text(raw.description, 300),
170
+ language: text(raw.language, 40),
171
+ pushedAt: time(raw.pushed_at),
172
+ url: `${WEB_URL}/${repo.fullName}`,
173
+ };
174
+ }
175
+
176
+ export function parseScopes(value) {
177
+ return String(value ?? '').split(/[\s,]+/).filter(Boolean);
178
+ }
179
+
180
+ function canWriteKeys(scopes) {
181
+ return scopes.includes('write:public_key') || scopes.includes('admin:public_key');
182
+ }
183
+
184
+ function writeAtomic(file, contents) {
185
+ const temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
186
+ fs.writeFileSync(temp, contents, { mode: 0o600 });
187
+ try {
188
+ fs.renameSync(temp, file);
189
+ } catch (err) {
190
+ fs.rmSync(temp, { force: true });
191
+ throw err;
192
+ }
193
+ }
194
+
195
+ function writeIfChanged(file, contents) {
196
+ let current = null;
197
+ try { current = fs.readFileSync(file, 'utf8'); } catch { /* written below */ }
198
+ if (current !== contents) writeAtomic(file, contents);
199
+ }
200
+
201
+ /** A device-flow or token endpoint answers with an `error` field whatever its status. */
202
+ async function oauthJson(res) {
203
+ const body = await res.json().catch(() => null);
204
+ if (!body || typeof body !== 'object') throw new Error(`GitHub answered HTTP ${res.status}`);
205
+ return body;
206
+ }
207
+
208
+ function failure(err) {
209
+ if (err?.name === 'TimeoutError') return 'GitHub did not answer in time';
210
+ if (err?.status) return err.message;
211
+ return `GitHub could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
212
+ }
213
+
214
+ export class GitHub extends EventEmitter {
215
+ constructor({
216
+ dir, registry, clientId = GITHUB_CLIENT_ID, apiUrl = API_URL, webUrl = WEB_URL,
217
+ fetchImpl = (...args) => fetch(...args), run = runSpec, hostname = os.hostname(), timeoutMs = FETCH_TIMEOUT_MS,
218
+ }) {
219
+ super();
220
+ this.dir = dir;
221
+ this.registry = registry;
222
+ this.clientId = clientId;
223
+ this.apiUrl = apiUrl.replace(/\/+$/, '');
224
+ this.webUrl = webUrl.replace(/\/+$/, '');
225
+ this.fetchImpl = fetchImpl;
226
+ this.run = run;
227
+ this.hostname = hostname;
228
+ this.timeoutMs = timeoutMs;
229
+ this.signIn = null;
230
+ this._signInTimer = null;
231
+ this._signInGeneration = 0;
232
+ this._refreshes = new Map();
233
+ this._setups = new Map();
234
+ this._sshErrors = new Map();
235
+ this._repos = new Map();
236
+ this.accounts = this._load();
237
+ }
238
+
239
+ get file() { return path.join(this.dir, 'accounts.json'); }
240
+ get keysDir() { return path.join(this.dir, 'keys'); }
241
+ get knownHostsFile() { return path.join(this.dir, 'known_hosts'); }
242
+ get sshConfigFile() { return path.join(this.dir, 'ssh_config'); }
243
+ get gitConfigFile() { return path.join(this.dir, 'clone.gitconfig'); }
244
+
245
+ keyFile(id) { return path.join(this.keysDir, `agent-guild-github-${id}`); }
246
+
247
+ _load() {
248
+ let raw;
249
+ try { raw = fs.readFileSync(this.file, 'utf8'); } catch { return []; }
250
+ try {
251
+ const list = JSON.parse(raw)?.accounts;
252
+ const valid = Array.isArray(list) ? list.filter((a) => Number.isSafeInteger(a?.id) && typeof a.login === 'string' && a.token?.access) : [];
253
+ return [...new Map(valid.map((a) => [a.id, a])).values()];
254
+ } catch (err) {
255
+ console.warn(`[github] ${this.file} could not be read (${err.message}); starting without GitHub accounts`);
256
+ return [];
257
+ }
258
+ }
259
+
260
+ _save() {
261
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
262
+ writeAtomic(this.file, JSON.stringify({ accounts: this.accounts }, null, 2) + '\n');
263
+ }
264
+
265
+ _changed() {
266
+ this.emit('updated');
267
+ }
268
+
269
+ close() {
270
+ this._stopPolling();
271
+ }
272
+
273
+ _resolve(command) {
274
+ return resolveCommand(command, this.registry.env, this.registry.platform);
275
+ }
276
+
277
+ /** ssh, and the ssh-keygen beside it when there is one, so a key is made and used by the same OpenSSH. */
278
+ tools() {
279
+ const ssh = this._resolve('ssh');
280
+ const exe = this.registry.platform === 'win32' && ssh && path.win32.extname(ssh).toLowerCase() === '.exe' ? '.exe' : '';
281
+ const sibling = ssh ? path.join(path.dirname(ssh), `ssh-keygen${exe}`) : null;
282
+ const sshKeygen = sibling && fs.existsSync(sibling) ? sibling : this._resolve('ssh-keygen');
283
+ return { git: this._resolve('git'), ssh, sshKeygen };
284
+ }
285
+
286
+ account(id) {
287
+ const wanted = String(id ?? '');
288
+ const found = ACCOUNT_ID_RE.test(wanted) ? this.accounts.find((a) => a.id === Number(wanted)) : null;
289
+ if (!found) throw refusal(404, 'unknown_account', `no GitHub account with id "${wanted}" is signed in`);
290
+ return found;
291
+ }
292
+
293
+ describeAccount(account) {
294
+ const key = this.keyFile(account.id);
295
+ let publicKey = null;
296
+ try { publicKey = fs.readFileSync(`${key}.pub`, 'utf8').trim(); } catch { /* none yet */ }
297
+ const hasKey = Boolean(publicKey) && fs.existsSync(key);
298
+ const error = this._sshErrors.get(account.id) ?? null;
299
+ return {
300
+ id: account.id,
301
+ login: account.login,
302
+ name: account.name ?? null,
303
+ avatar: account.avatar ?? null,
304
+ scopes: account.scopes ?? [],
305
+ needsSignIn: account.needsSignIn === true,
306
+ addedAt: account.addedAt,
307
+ ssh: {
308
+ status: !hasKey ? 'none' : account.ssh?.verifiedAt ? 'ready' : 'unverified',
309
+ key: hasKey ? key : null,
310
+ publicKey: hasKey ? publicKey : null,
311
+ verifiedAt: hasKey ? account.ssh?.verifiedAt ?? null : null,
312
+ settingUp: this._setups.has(account.id),
313
+ error,
314
+ },
315
+ };
316
+ }
317
+
318
+ snapshot() {
319
+ const tools = this.tools();
320
+ return {
321
+ scopes: GITHUB_SCOPES,
322
+ appUrl: `${this.webUrl}/settings/connections/applications/${this.clientId}`,
323
+ keysUrl: `${this.webUrl}/settings/keys`,
324
+ newKeyUrl: `${this.webUrl}/settings/ssh/new`,
325
+ tools: { git: Boolean(tools.git), ssh: Boolean(tools.ssh), sshKeygen: Boolean(tools.sshKeygen) },
326
+ signIn: this.signIn && {
327
+ status: this.signIn.status,
328
+ userCode: this.signIn.userCode ?? null,
329
+ verificationUri: this.signIn.verificationUri ?? null,
330
+ expiresAt: this.signIn.expiresAt ?? null,
331
+ accountId: this.signIn.accountId ?? null,
332
+ again: this.signIn.again === true,
333
+ error: this.signIn.error ?? null,
334
+ },
335
+ accounts: this.accounts.map((a) => this.describeAccount(a)),
336
+ };
337
+ }
338
+
339
+ _fetch(url, init = {}) {
340
+ return this.fetchImpl(url, {
341
+ ...init,
342
+ headers: { 'User-Agent': USER_AGENT, ...init.headers },
343
+ signal: AbortSignal.timeout(this.timeoutMs),
344
+ });
345
+ }
346
+
347
+ _oauth(route, params) {
348
+ return this._fetch(`${this.webUrl}${route}`, {
349
+ method: 'POST',
350
+ headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
351
+ body: new URLSearchParams({ client_id: this.clientId, ...params }).toString(),
352
+ }).then(oauthJson);
353
+ }
354
+
355
+ _plain(route, token, init = {}) {
356
+ const url = /^https?:/.test(route) ? route : `${this.apiUrl}${route}`;
357
+ return this._fetch(url, {
358
+ ...init,
359
+ headers: {
360
+ Accept: 'application/vnd.github+json',
361
+ 'X-GitHub-Api-Version': '2022-11-28',
362
+ Authorization: `Bearer ${token}`,
363
+ ...(init.body ? { 'Content-Type': 'application/json' } : {}),
364
+ ...init.headers,
365
+ },
366
+ });
367
+ }
368
+
369
+ async _api(account, route, init = {}) {
370
+ const send = (token) => this._plain(route, token, init).catch((err) => {
371
+ throw refusal(502, 'github_unreachable', failure(err));
372
+ });
373
+ const used = await this._accessToken(account);
374
+ let res = await send(used);
375
+ if (res.status === 401 && account.token.refresh) {
376
+ res = await send(await this._accessToken(account, { stale: used }));
377
+ }
378
+ if (res.status === 401) {
379
+ this._needsSignIn(account);
380
+ throw refusal(409, 'github_sign_in', `GitHub no longer accepts the sign-in for @${account.login}. Sign in again.`);
381
+ }
382
+ if (res.headers.has('x-oauth-scopes')) {
383
+ const scopes = parseScopes(res.headers.get('x-oauth-scopes'));
384
+ if (scopes.join(' ') !== (account.scopes ?? []).join(' ')) {
385
+ account.scopes = scopes;
386
+ this._save();
387
+ this._changed();
388
+ }
389
+ }
390
+ return res;
391
+ }
392
+
393
+ async _apiJson(account, route, init) {
394
+ const res = await this._api(account, route, init);
395
+ if (!res.ok) {
396
+ const body = await res.json().catch(() => null);
397
+ const limited = (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0';
398
+ throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status });
399
+ }
400
+ return { body: await res.json(), res };
401
+ }
402
+
403
+ _needsSignIn(account) {
404
+ if (account.needsSignIn) return;
405
+ account.needsSignIn = true;
406
+ this._save();
407
+ this._changed();
408
+ }
409
+
410
+ /**
411
+ * The account's access token. An expiring token is refreshed shortly
412
+ * before it runs out. GitHub rotates the refresh token on every use, so
413
+ * concurrent callers share one refresh, and the new pair is saved before
414
+ * anyone uses it.
415
+ */
416
+ _accessToken(account, { stale = null } = {}) {
417
+ if (this._refreshes.has(account.id)) return this._refreshes.get(account.id);
418
+ if (account.needsSignIn) {
419
+ return Promise.reject(refusal(409, 'github_sign_in', `GitHub no longer accepts the sign-in for @${account.login}. Sign in again.`));
420
+ }
421
+ const { token } = account;
422
+ const due = token.expiresAt && Date.parse(token.expiresAt) - REFRESH_MARGIN_MS <= Date.now();
423
+ // A token refused by GitHub is refreshed only if nobody has replaced it since.
424
+ if (stale !== null ? token.access !== stale : !due) return Promise.resolve(token.access);
425
+ const pending = this._refresh(account).finally(() => this._refreshes.delete(account.id));
426
+ this._refreshes.set(account.id, pending);
427
+ return pending;
428
+ }
429
+
430
+ async _refresh(account) {
431
+ if (!account.token.refresh) {
432
+ this._needsSignIn(account);
433
+ throw refusal(409, 'github_sign_in', `GitHub no longer accepts the sign-in for @${account.login}. Sign in again.`);
434
+ }
435
+ const body = await this._oauth('/login/oauth/access_token', { grant_type: 'refresh_token', refresh_token: account.token.refresh }).catch((err) => {
436
+ throw refusal(502, 'github_unreachable', failure(err));
437
+ });
438
+ if (!body.access_token) {
439
+ this._needsSignIn(account);
440
+ throw refusal(409, 'github_sign_in', `GitHub no longer accepts the sign-in for @${account.login} (${body.error || 'refresh refused'}). Sign in again.`);
441
+ }
442
+ account.token = tokenFrom(body, account.token);
443
+ this._save();
444
+ return account.token.access;
445
+ }
446
+
447
+ _stopPolling() {
448
+ clearTimeout(this._signInTimer);
449
+ this._signInTimer = null;
450
+ this._signInGeneration++;
451
+ }
452
+
453
+ async startSignIn() {
454
+ this._stopPolling();
455
+ if (this.signIn) {
456
+ this.signIn = null;
457
+ this._changed();
458
+ }
459
+ const generation = this._signInGeneration;
460
+ let body;
461
+ try {
462
+ body = await this._oauth('/login/device/code', { scope: GITHUB_SCOPES.join(' ') });
463
+ } catch (err) {
464
+ throw refusal(502, 'github_unreachable', `Could not start the GitHub sign-in: ${failure(err)}`);
465
+ }
466
+ if (!body.device_code || !body.user_code) {
467
+ throw refusal(502, 'github_error', `GitHub refused the sign-in: ${body.error_description || body.error || 'no device code'}`);
468
+ }
469
+ let verificationUri = null;
470
+ try {
471
+ const url = new URL(body.verification_uri);
472
+ if (url.protocol === 'https:' || url.origin === new URL(this.webUrl).origin) verificationUri = url.href;
473
+ } catch { /* refused below */ }
474
+ if (!verificationUri) throw refusal(502, 'github_error', 'GitHub sent no sign-in page to visit');
475
+ if (generation !== this._signInGeneration) return this.snapshot();
476
+ this.signIn = {
477
+ status: 'pending',
478
+ deviceCode: body.device_code,
479
+ userCode: String(body.user_code),
480
+ verificationUri,
481
+ expiresAt: new Date(Date.now() + (Number(body.expires_in) || 900) * 1000).toISOString(),
482
+ interval: Math.max(1, Number(body.interval) || 5),
483
+ };
484
+ this._schedulePoll(generation);
485
+ this._changed();
486
+ return this.snapshot();
487
+ }
488
+
489
+ cancelSignIn() {
490
+ this._stopPolling();
491
+ this.signIn = null;
492
+ this._changed();
493
+ return this.snapshot();
494
+ }
495
+
496
+ _schedulePoll(generation) {
497
+ this._signInTimer = setTimeout(() => this._poll(generation), this.signIn.interval * 1000);
498
+ this._signInTimer.unref?.();
499
+ }
500
+
501
+ _endSignIn(fields) {
502
+ this._stopPolling();
503
+ const { userCode, verificationUri, expiresAt } = this.signIn;
504
+ this.signIn = { userCode, verificationUri, expiresAt, ...fields };
505
+ this._changed();
506
+ }
507
+
508
+ async _poll(generation) {
509
+ if (generation !== this._signInGeneration || this.signIn?.status !== 'pending') return;
510
+ if (Date.parse(this.signIn.expiresAt) <= Date.now()) return this._endSignIn({ status: 'expired' });
511
+ let body;
512
+ try {
513
+ body = await this._oauth('/login/oauth/access_token', {
514
+ device_code: this.signIn.deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
515
+ });
516
+ } catch {
517
+ if (generation === this._signInGeneration) this._schedulePoll(generation);
518
+ return;
519
+ }
520
+ if (generation !== this._signInGeneration) return;
521
+ if (body.access_token) {
522
+ try {
523
+ const { account, again } = await this._addAccount(body);
524
+ if (generation === this._signInGeneration) this._endSignIn({ status: 'done', accountId: account.id, again });
525
+ else this._changed();
526
+ } catch (err) {
527
+ if (generation === this._signInGeneration) this._endSignIn({ status: 'failed', error: failure(err) });
528
+ }
529
+ return;
530
+ }
531
+ if (body.error === 'authorization_pending') return this._schedulePoll(generation);
532
+ if (body.error === 'slow_down') {
533
+ this.signIn.interval = Math.max(this.signIn.interval + 5, Number(body.interval) || 0);
534
+ return this._schedulePoll(generation);
535
+ }
536
+ if (body.error === 'expired_token' || body.error === 'token_expired') return this._endSignIn({ status: 'expired' });
537
+ if (body.error === 'access_denied') return this._endSignIn({ status: 'denied' });
538
+ this._endSignIn({ status: 'failed', error: body.error_description || body.error || 'GitHub refused the sign-in' });
539
+ }
540
+
541
+ /** Accounts are keyed by GitHub's immutable user id; the login is only shown. */
542
+ async _addAccount(tokenBody) {
543
+ const token = tokenFrom(tokenBody);
544
+ const res = await this._plain('/user', token.access);
545
+ if (!res.ok) throw refusal(502, 'github_error', `GitHub answered HTTP ${res.status} for the signed-in user`);
546
+ const user = await res.json();
547
+ if (!Number.isSafeInteger(user?.id) || typeof user.login !== 'string') throw refusal(502, 'github_error', 'GitHub sent no user for the sign-in');
548
+ const scopes = res.headers.has('x-oauth-scopes') ? parseScopes(res.headers.get('x-oauth-scopes')) : parseScopes(tokenBody.scope);
549
+ const avatar = await this._avatar(user.avatar_url).catch(() => null);
550
+ const existing = this.accounts.find((a) => a.id === user.id);
551
+ const fields = { login: user.login, name: typeof user.name === 'string' && user.name.trim() ? user.name.trim().slice(0, 100) : null, avatar: avatar ?? existing?.avatar ?? null, scopes, token, needsSignIn: false };
552
+ let account;
553
+ if (existing) {
554
+ account = Object.assign(existing, fields);
555
+ } else {
556
+ account = { id: user.id, ...fields, addedAt: new Date().toISOString(), ssh: { verifiedAt: null } };
557
+ this.accounts.push(account);
558
+ }
559
+ this._repos.delete(account.id);
560
+ this._save();
561
+ return { account, again: Boolean(existing) };
562
+ }
563
+
564
+ async _avatar(url) {
565
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url)) return null;
566
+ const sized = new URL(url);
567
+ sized.searchParams.set('s', '64');
568
+ const res = await this._fetch(sized.href);
569
+ const type = (res.headers.get('content-type') || '').split(';')[0].trim();
570
+ if (!res.ok || !/^image\/(png|jpeg|gif|webp)$/.test(type)) return null;
571
+ const bytes = Buffer.from(await res.arrayBuffer());
572
+ return bytes.length <= MAX_AVATAR_BYTES ? `data:${type};base64,${bytes.toString('base64')}` : null;
573
+ }
574
+
575
+ signOut(id) {
576
+ const account = this.account(id);
577
+ this.accounts = this.accounts.filter((a) => a !== account);
578
+ this._repos.delete(account.id);
579
+ this._sshErrors.delete(account.id);
580
+ this._save();
581
+ this._changed();
582
+ return this.snapshot();
583
+ }
584
+
585
+ async repos(id, { parent = null, refresh = false } = {}) {
586
+ const account = this.account(id);
587
+ let cached = this._repos.get(account.id);
588
+ if (refresh || !cached?.repos || Date.now() - cached.at > REPOS_TTL_MS) {
589
+ if (!cached?.loading) {
590
+ const loading = this._loadRepos(account).then((loaded) => {
591
+ if (this._repos.get(account.id)?.loading === loading) this._repos.set(account.id, loaded);
592
+ return loaded;
593
+ }, (err) => {
594
+ if (this._repos.get(account.id)?.loading === loading) this._repos.delete(account.id);
595
+ throw err;
596
+ });
597
+ this._repos.set(account.id, { ...cached, loading });
598
+ }
599
+ cached = await this._repos.get(account.id).loading;
600
+ }
601
+ const orgs = new Map();
602
+ for (const repo of cached.repos) if (repo.ownerType === 'Organization') orgs.set(repo.owner.toLowerCase(), repo.owner);
603
+ return {
604
+ accountId: account.id,
605
+ fetchedAt: new Date(cached.at).toISOString(),
606
+ truncated: cached.truncated,
607
+ owners: [account.login, ...[...orgs.values()].sort((a, b) => a.localeCompare(b))],
608
+ parent,
609
+ repos: cached.repos.map((repo) => {
610
+ const target = parent ? path.join(parent, repo.name) : null;
611
+ return { ...repo, target, local: target ? localState(target, repo.fullName) : null };
612
+ }),
613
+ };
614
+ }
615
+
616
+ async _loadRepos(account) {
617
+ const seen = new Map();
618
+ let url = '/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
619
+ let pages = 0;
620
+ try {
621
+ while (url && pages < MAX_REPO_PAGES) {
622
+ const { body, res } = await this._apiJson(account, url);
623
+ for (const raw of Array.isArray(body) ? body : []) {
624
+ const repo = cleanRepo(raw);
625
+ if (repo && !seen.has(repo.fullName.toLowerCase())) seen.set(repo.fullName.toLowerCase(), repo);
626
+ }
627
+ url = nextLink(res.headers.get('link'));
628
+ pages++;
629
+ }
630
+ } catch (err) {
631
+ if (err.status) throw err;
632
+ throw refusal(502, 'github_unreachable', `Could not list repositories: ${failure(err)}`);
633
+ }
634
+ const repos = [...seen.values()].sort((a, b) => (Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0));
635
+ return { repos, truncated: Boolean(url), at: Date.now() };
636
+ }
637
+
638
+ async createRepo(id, { owner, name, description = null, private: isPrivate = true, readme = true } = {}) {
639
+ const account = this.account(id);
640
+ const { fullName } = parseRepo(`${owner ?? ''}/${name ?? ''}`);
641
+ const repoOwner = fullName.split('/')[0];
642
+ if (description !== null && typeof description !== 'string') throw refusal(400, 'bad_description', 'description must be a string');
643
+ const personal = repoOwner.toLowerCase() === account.login.toLowerCase();
644
+ const route = personal ? '/user/repos' : `/orgs/${encodeURIComponent(repoOwner)}/repos`;
645
+ const res = await this._api(account, route, {
646
+ method: 'POST',
647
+ body: JSON.stringify({
648
+ name: fullName.split('/')[1],
649
+ description: description?.trim().slice(0, 350) || undefined,
650
+ private: isPrivate !== false,
651
+ auto_init: readme !== false,
652
+ }),
653
+ });
654
+ if (!res.ok) {
655
+ const body = await res.json().catch(() => null);
656
+ const detail = [body?.message, ...(Array.isArray(body?.errors) ? body.errors.map((e) => e?.message) : [])].filter(Boolean).join(' ');
657
+ if (res.status === 422 && /already exists/i.test(detail)) throw refusal(409, 'repo_exists', `${fullName} already exists on GitHub`);
658
+ if (res.status === 403 || res.status === 404) {
659
+ throw refusal(403, 'repo_forbidden', `GitHub did not let @${account.login} create repositories in ${repoOwner}${detail ? `: ${detail}` : ''}`);
660
+ }
661
+ throw refusal(502, 'github_error', `GitHub answered HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
662
+ }
663
+ const repo = cleanRepo(await res.json());
664
+ if (!repo) throw refusal(502, 'github_error', 'GitHub did not describe the new repository');
665
+ const cached = this._repos.get(account.id);
666
+ if (cached?.repos) {
667
+ this._repos.set(account.id, { ...cached, repos: [repo, ...cached.repos.filter((r) => r.fullName.toLowerCase() !== repo.fullName.toLowerCase())] });
668
+ }
669
+ return repo;
670
+ }
671
+
672
+ _ensureFiles() {
673
+ fs.mkdirSync(this.keysDir, { recursive: true, mode: 0o700 });
674
+ writeIfChanged(this.knownHostsFile, GITHUB_HOST_KEYS.join('\n') + '\n');
675
+ writeIfChanged(this.sshConfigFile, '');
676
+ writeIfChanged(this.gitConfigFile, gitConfig({ lfs: Boolean(this._resolve('git-lfs')), platform: this.registry.platform }));
677
+ }
678
+
679
+ setupSsh(id) {
680
+ const account = this.account(id);
681
+ if (!this._setups.has(account.id)) {
682
+ const pending = this._setupSsh(account).finally(() => {
683
+ this._setups.delete(account.id);
684
+ this._changed();
685
+ });
686
+ this._setups.set(account.id, pending);
687
+ this._changed();
688
+ }
689
+ return this._setups.get(account.id).then(() => this.describeAccount(account));
690
+ }
691
+
692
+ async _setupSsh(account) {
693
+ try {
694
+ const { ssh, sshKeygen } = this.tools();
695
+ if (!ssh || !sshKeygen) {
696
+ throw refusal(409, 'ssh_unavailable', 'OpenSSH (ssh and ssh-keygen) was not found on PATH.');
697
+ }
698
+ this._ensureFiles();
699
+ await this._refreshUser(account);
700
+ const key = this.keyFile(account.id);
701
+ if (!fs.existsSync(key) || !fs.existsSync(`${key}.pub`)) await this._makeKey(sshKeygen, key, account);
702
+ const publicKey = fs.readFileSync(`${key}.pub`, 'utf8').trim();
703
+ const { problem } = await this._addKey(account, publicKey);
704
+ const login = await this._testKey(ssh, key, problem);
705
+ if (login.toLowerCase() !== account.login.toLowerCase()) {
706
+ throw refusal(409, 'ssh_wrong_account', `GitHub signs this key in as ${login}, not @${account.login}. Remove it from ${login}'s SSH keys on GitHub, then check again.`);
707
+ }
708
+ account.ssh = { verifiedAt: new Date().toISOString() };
709
+ this._sshErrors.delete(account.id);
710
+ this._save();
711
+ } catch (err) {
712
+ if (account.ssh?.verifiedAt && KEY_FAILURES.has(err.code)) {
713
+ account.ssh = { verifiedAt: null };
714
+ this._save();
715
+ }
716
+ this._sshErrors.set(account.id, { code: err.code || 'ssh_failed', message: err.status ? err.message : `SSH setup failed: ${err.message}`, manual: err.manual === true });
717
+ if (!err.status) throw err;
718
+ }
719
+ }
720
+
721
+ async _refreshUser(account) {
722
+ const { body } = await this._apiJson(account, '/user');
723
+ if (body?.id === account.id && typeof body.login === 'string' && body.login !== account.login) {
724
+ account.login = body.login;
725
+ this._save();
726
+ }
727
+ }
728
+
729
+ /** Ed25519, or RSA when this ssh-keygen cannot make Ed25519. Never overwrites: the files are known to be absent. */
730
+ async _makeKey(sshKeygen, key, account) {
731
+ for (const file of [key, `${key}.pub`]) fs.rmSync(file, { force: true });
732
+ const comment = `agent-guild github ${account.login} (${account.id})`;
733
+ const env = this.registry.env;
734
+ const attempt = (type) => this.run(buildSpawnSpec(sshKeygen, ['-q', '-t', ...type, '-N', '', '-C', comment, '-f', key], env, this.registry.platform), { env, timeoutMs: SSH_TIMEOUT_MS });
735
+ try {
736
+ await attempt(['ed25519']);
737
+ } catch {
738
+ for (const file of [key, `${key}.pub`]) fs.rmSync(file, { force: true });
739
+ try {
740
+ await attempt(['rsa', '-b', '4096']);
741
+ } catch (err) {
742
+ throw refusal(500, 'ssh_key_failed', `ssh-keygen could not make a key: ${String(err.stderr || err.message).trim().split('\n').pop()}`);
743
+ }
744
+ }
745
+ if (!fs.existsSync(key) || !fs.existsSync(`${key}.pub`)) throw refusal(500, 'ssh_key_failed', 'ssh-keygen did not write the key');
746
+ }
747
+
748
+ /**
749
+ * Add the public key to the account unless it is there. When Agent Guild
750
+ * cannot, `problem` says why; the user may have added the key themselves,
751
+ * so the connection test still decides.
752
+ */
753
+ async _addKey(account, publicKey) {
754
+ if (!canWriteKeys(account.scopes ?? [])) return { added: false, problem: `Agent Guild may not add SSH keys to @${account.login}.` };
755
+ const blob = keyBlob(publicKey);
756
+ let url = '/user/keys?per_page=100';
757
+ for (let page = 0; url && page < MAX_REPO_PAGES; page++) {
758
+ const res = await this._api(account, url);
759
+ if (!res.ok) return { added: false, problem: `GitHub did not list @${account.login}'s SSH keys (HTTP ${res.status}).` };
760
+ const keys = await res.json();
761
+ if (Array.isArray(keys) && keys.some((k) => keyBlob(k?.key) === blob)) return { added: false, problem: null };
762
+ url = nextLink(res.headers.get('link'));
763
+ }
764
+ const res = await this._api(account, '/user/keys', { method: 'POST', body: JSON.stringify({ title: `Agent Guild (${this.hostname})`, key: publicKey }) });
765
+ if (res.ok) return { added: true, problem: null };
766
+ const body = await res.json().catch(() => null);
767
+ const detail = [body?.message, ...(Array.isArray(body?.errors) ? body.errors.map((e) => e?.message) : [])].filter(Boolean).join(' ');
768
+ if (res.status === 422 && /already in use/i.test(detail)) {
769
+ throw refusal(409, 'ssh_key_in_use', 'GitHub says this key is already in use by another account or as a deploy key.');
770
+ }
771
+ return { added: false, problem: `GitHub did not accept the key (HTTP ${res.status}${detail ? `: ${detail}` : ''}).` };
772
+ }
773
+
774
+ async _testKey(ssh, key, problem = null) {
775
+ const args = ['-T', '-o', 'ConnectTimeout=15', ...sshArgs({ key, knownHosts: this.knownHostsFile, config: this.sshConfigFile, platform: this.registry.platform }), SSH_HOST];
776
+ let output;
777
+ try {
778
+ const { stdout, stderr } = await this.run(buildSpawnSpec(ssh, args, this.registry.env, this.registry.platform), { env: this.registry.env, timeoutMs: SSH_TIMEOUT_MS });
779
+ output = `${stdout}\n${stderr}`;
780
+ } catch (err) {
781
+ output = `${err.stdout ?? ''}\n${err.stderr ?? ''}`;
782
+ if (!GREETING.test(output)) {
783
+ if (/host key verification failed|remote host identification has changed|host key for .* has changed/i.test(output)) {
784
+ throw refusal(409, 'ssh_host_key', 'GitHub\'s SSH host key does not match the one Agent Guild ships. Update Agent Guild, then check again.');
785
+ }
786
+ if (/permission denied/i.test(output)) {
787
+ throw refusal(409, 'ssh_key_manual', `${problem ?? 'GitHub does not accept this key yet.'} Add the key on GitHub yourself, then check again.`, { manual: true });
788
+ }
789
+ const last = output.trim().split(/\r?\n/).filter(Boolean).pop();
790
+ throw refusal(502, 'ssh_failed', `ssh could not reach GitHub: ${err.killed ? 'it did not answer in time' : last || err.message}`);
791
+ }
792
+ }
793
+ const match = GREETING.exec(output);
794
+ if (!match) throw refusal(502, 'ssh_failed', 'GitHub did not greet the key');
795
+ return match[1];
796
+ }
797
+
798
+ cloneSpec({ accountId, repo, parent }) {
799
+ const account = this.account(accountId);
800
+ const { fullName, name } = parseRepo(repo);
801
+ const target = path.join(parent, name);
802
+ const state = localState(target, fullName);
803
+ if (state === 'cloned') throw refusal(409, 'clone_exists', `${target} is already a clone of ${fullName}`, { target });
804
+ if (state === 'conflict') throw refusal(409, 'folder_conflict', `${target} already exists and is not a clone of ${fullName}`, { target });
805
+ const { git, ssh } = this.tools();
806
+ if (!git) throw refusal(409, 'git_unavailable', 'git was not found on PATH. Install Git from https://git-scm.com.');
807
+ const described = this.describeAccount(account);
808
+ if (!ssh || described.ssh.status !== 'ready') throw refusal(409, 'ssh_not_ready', `Set up SSH for @${account.login} before cloning`);
809
+ this._ensureFiles();
810
+ const command = sshCommand({ ssh, key: described.ssh.key, knownHosts: this.knownHostsFile, config: this.sshConfigFile, platform: this.registry.platform });
811
+ const args = ['clone', '--config', `core.sshCommand=${command}`, sshUrl(fullName), target];
812
+ return {
813
+ spawnSpec: buildSpawnSpec(git, args, this.registry.env, this.registry.platform),
814
+ target,
815
+ fullName,
816
+ account,
817
+ env: { GIT_CONFIG_GLOBAL: this.gitConfigFile, GIT_CONFIG_SYSTEM: this.gitConfigFile, GIT_TERMINAL_PROMPT: '0' },
818
+ };
819
+ }
820
+ }
821
+
822
+ /**
823
+ * The only Git configuration a clone sees. It keeps what a correct checkout
824
+ * needs from what `git lfs install` and Git for Windows would set, and nothing
825
+ * that could redirect the clone.
826
+ */
827
+ export function gitConfig({ lfs, platform }) {
828
+ const lines = [];
829
+ if (platform === 'win32') lines.push('[core]', '\tlongpaths = true');
830
+ if (lfs) lines.push('[filter "lfs"]', '\tclean = git-lfs clean -- %f', '\tsmudge = git-lfs smudge -- %f', '\tprocess = git-lfs filter-process', '\trequired = true');
831
+ return lines.length ? `${lines.join('\n')}\n` : '';
832
+ }
833
+
834
+ function tokenFrom(body, previous = null) {
835
+ const at = (seconds) => (Number(seconds) > 0 ? new Date(Date.now() + Number(seconds) * 1000).toISOString() : null);
836
+ return {
837
+ access: String(body.access_token),
838
+ expiresAt: at(body.expires_in),
839
+ refresh: typeof body.refresh_token === 'string' && body.refresh_token ? body.refresh_token : previous?.refresh ?? null,
840
+ refreshExpiresAt: at(body.refresh_token_expires_in) ?? previous?.refreshExpiresAt ?? null,
841
+ };
842
+ }