heyquilt 0.3.138 → 0.3.140

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 (52) hide show
  1. package/RELEASES.md +6 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session.js +52 -3526
  21. package/src/status.js +2 -58
  22. package/src/turn-sync.js +15 -2
  23. package/src/ui/changes.js +5 -14
  24. package/src/ui/chat.js +0 -1
  25. package/src/ui/common.js +0 -2
  26. package/src/ui/git-rule.js +2 -30
  27. package/src/ui/home.js +3 -76
  28. package/src/ui/session.js +34 -528
  29. package/src/ui/tree.js +2 -8
  30. package/src/ui/workspaces.js +2 -122
  31. package/src/ui-server.js +1 -38
  32. package/src/api/routes/github.js +0 -380
  33. package/src/branches.js +0 -194
  34. package/src/clash.js +0 -128
  35. package/src/commit-request.js +0 -59
  36. package/src/commit.js +0 -213
  37. package/src/git-hook.js +0 -48
  38. package/src/gitignore.js +0 -49
  39. package/src/gitstate.js +0 -1069
  40. package/src/relay-ci.js +0 -111
  41. package/src/relay-clash.js +0 -214
  42. package/src/relay-commit.js +0 -923
  43. package/src/relay-filekey.js +0 -46
  44. package/src/relay-git.js +0 -274
  45. package/src/relay-githttp.js +0 -166
  46. package/src/relay-github.js +0 -109
  47. package/src/relay-outbound.js +0 -1295
  48. package/src/relay-repo-moves.js +0 -43
  49. package/src/relay-sync.js +0 -795
  50. package/src/relay-upstream.js +0 -889
  51. package/src/sync-words.js +0 -42
  52. package/src/upstream.js +0 -192
@@ -1,46 +0,0 @@
1
- // The relay's own key for large files (plan §10): an X25519 key pair kept in its data folder, made
2
- // once; on many machines, one key they all share (QUILT_RELAY_FILE_KEY, a Fly secret). Apps wrap
3
- // the file keys of branches that sync with GitHub for its public key (largefiles.js wrapForRelay),
4
- // once the session owner agrees, so the relay can read those files to commit them.
5
- import fs from 'node:fs'
6
- import path from 'node:path'
7
- import crypto from 'node:crypto'
8
- import { relayKeyId } from './largefiles.js'
9
-
10
- /** The key from QUILT_RELAY_FILE_KEY: the PEM itself, or base64 of it (a Fly secret is one line). */
11
- export function parseFileKey (value) {
12
- const v = String(value || '').trim()
13
- if (!v) return null
14
- const pem = v.includes('-----BEGIN') ? v.replace(/\\n/g, '\n') : Buffer.from(v, 'base64').toString('utf8')
15
- let key
16
- try { key = crypto.createPrivateKey(pem) } catch { throw new Error('QUILT_RELAY_FILE_KEY is not a private key (PEM, or base64 of the PEM)') }
17
- if (key.asymmetricKeyType !== 'x25519') throw new Error('QUILT_RELAY_FILE_KEY is not an X25519 key')
18
- return key
19
- }
20
-
21
- /**
22
- * { publicKey (spki DER, base64url), id, privateKey (KeyObject), shared }: the one every relay
23
- * machine shares (`shared`, from QUILT_RELAY_FILE_KEY), else from `dataDir`, or made now (and
24
- * saved there). A shared key that differs from the one on disk is logged: files wrapped for the
25
- * disk's key can't be read until the owner agrees again.
26
- */
27
- export function relayFileKey (dataDir, { pem = process.env.QUILT_RELAY_FILE_KEY || '', log = () => {} } = {}) {
28
- const file = dataDir ? path.join(dataDir, 'relay-file-key.pem') : null
29
- let privateKey = parseFileKey(pem)
30
- const shared = !!privateKey
31
- let onDisk = null
32
- if (file) { try { onDisk = crypto.createPrivateKey(fs.readFileSync(file, 'utf8')) } catch {} }
33
- if (shared) {
34
- if (onDisk && !onDisk.equals(privateKey)) log(`relay file key: QUILT_RELAY_FILE_KEY differs from ${file}; using QUILT_RELAY_FILE_KEY`)
35
- } else privateKey = onDisk
36
- if (!privateKey || privateKey.asymmetricKeyType !== 'x25519') {
37
- privateKey = crypto.generateKeyPairSync('x25519').privateKey
38
- if (file) {
39
- fs.mkdirSync(dataDir, { recursive: true })
40
- fs.writeFileSync(file + '.tmp', privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
41
- fs.renameSync(file + '.tmp', file)
42
- }
43
- }
44
- const publicKey = crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).toString('base64url')
45
- return { publicKey, id: relayKeyId(publicKey), privateKey, shared }
46
- }
package/src/relay-git.js DELETED
@@ -1,274 +0,0 @@
1
- // The relay's git mirror: one bare repository per GitHub repository on the relay's volume
2
- // (data/git/<host>/<owner>/<repo>.git). Every piece of git data moves over the git protocol
3
- // (fetch and push), never GitHub's REST API, so a 300-file commit is one push and no
4
- // content quota is spent.
5
- import fs from 'node:fs'
6
- import os from 'node:os'
7
- import path from 'node:path'
8
- import { execFile } from 'node:child_process'
9
-
10
- const GIT_TIMEOUT_MS = 60 * 1000
11
- const SAFE = /^[A-Za-z0-9._-]{1,100}$/
12
-
13
- /** Where `repo` ({ host, owner, name }, relay-upstream.js parseRepo) is mirrored under `dataDir`. */
14
- export function mirrorDir (dataDir, repo) {
15
- for (const part of [repo.host, repo.owner, repo.name]) if (!SAFE.test(String(part || '')) || part === '.' || part === '..') throw new Error(`not a repository name: ${part}`)
16
- return path.join(dataDir, 'git', repo.host, repo.owner, `${repo.name}.git`)
17
- }
18
-
19
- /** Runs git in `dir`: { ok, out, err }. Never rejects. `env` is added to the process's. */
20
- export function runGit (dir, args, { env = {}, timeout = GIT_TIMEOUT_MS, input, buffer = false } = {}) {
21
- return new Promise((resolve) => {
22
- let child
23
- try {
24
- child = execFile(process.env.QUILT_GIT || 'git', args, { cwd: dir, encoding: buffer ? 'buffer' : 'utf8', timeout, maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...env } },
25
- (err, stdout, stderr) => resolve({ ok: !err, out: buffer ? (stdout || Buffer.alloc(0)) : String(stdout || ''), err: String(stderr || (err && err.message) || '') }))
26
- } catch (err) { return resolve({ ok: false, out: '', err: err.message }) }
27
- child.stdin.on('error', () => {})
28
- child.stdin.end(input)
29
- })
30
- }
31
-
32
- /**
33
- * The mirror of `repo`, made if it isn't there yet: a bare repository whose `origin` is
34
- * `url` (https://<host>/<owner>/<name>.git by default). Resolves { dir, created }.
35
- */
36
- export async function ensureMirror (dataDir, repo, { url = `https://${repo.host}/${repo.owner}/${repo.name}.git` } = {}) {
37
- const dir = mirrorDir(dataDir, repo)
38
- if (fs.existsSync(path.join(dir, 'HEAD'))) return { dir, created: false }
39
- fs.mkdirSync(dir, { recursive: true })
40
- const init = await runGit(dir, ['init', '-q', '--bare'])
41
- if (!init.ok) throw new Error(`could not make the mirror of ${repo.owner}/${repo.name}: ${init.err.trim()}`)
42
- const remote = await runGit(dir, ['remote', 'add', 'origin', url])
43
- if (!remote.ok) throw new Error(`could not set the mirror's origin: ${remote.err.trim()}`)
44
- return { dir, created: true }
45
- }
46
-
47
- /** A git call that failed, with `kind`: 'auth' (refused sign-in), 'unreachable' (no answer), 'missing' (no such repository) or 'other'. */
48
- export class GitFailed extends Error {
49
- constructor (what, err) {
50
- const text = String(err || '').trim()
51
- super(`${what}: ${text.split('\n').filter(Boolean).pop() || 'git failed'}`)
52
- this.kind = /Authentication failed|returned error: 40[13]\b|could not read Username|Permission denied/i.test(text) ? 'auth'
53
- : /Could not resolve host|Connection refused|Connection timed out|unable to access|timed out|Operation timed out|Empty reply/i.test(text) ? 'unreachable'
54
- : /returned error: 404|repository .* not found|does not appear to be a git repository/i.test(text) ? 'missing' : 'other'
55
- this.stderr = text
56
- }
57
- }
58
-
59
- /**
60
- * The environment that signs git in to the repository with `token`: GitHub takes an
61
- * installation token as HTTP basic auth (x-access-token), given here in a header through
62
- * git's environment, so it is never in a URL, on the command line or in the mirror's config.
63
- */
64
- export function authEnv (token) {
65
- if (!token) return {}
66
- return { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraHeader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` }
67
- }
68
-
69
- const remoteRef = (branch) => `refs/remotes/origin/${branch}`
70
-
71
- /**
72
- * Fetches `branch` from origin into refs/remotes/origin/<branch>: { sha }, or { sha: null }
73
- * when the remote has no such branch. Throws GitFailed otherwise. `depth` makes it a
74
- * shallow fetch (a mirror over its size cap).
75
- */
76
- export async function fetchBranch (dir, branch, { token = null, depth = 0 } = {}) {
77
- const args = ['fetch', '-q', ...(depth > 0 ? [`--depth=${depth}`] : []), 'origin', `+refs/heads/${branch}:${remoteRef(branch)}`]
78
- const r = await runGit(dir, args, { env: authEnv(token) })
79
- if (!r.ok) {
80
- if (/couldn't find remote ref/i.test(r.err)) return { sha: null }
81
- throw new GitFailed(`fetching ${branch}`, r.err)
82
- }
83
- const sha = (await runGit(dir, ['rev-parse', '--verify', '-q', remoteRef(branch)])).out.trim()
84
- return { sha: sha || null }
85
- }
86
-
87
- /**
88
- * Pushes `sha` to origin's `branch`, forward only and never forced: { state: 'accepted', sha };
89
- * { state: 'moved', head } when the remote isn't at `expected` any more (nothing is pushed over
90
- * it: the caller brings the new head in first); { state: 'refused', why } when `sha` doesn't
91
- * descend from `expected` (asked locally, no request made); { state: 'protected', why } when
92
- * the remote's rules refuse direct pushes. Throws GitFailed for sign-in or network trouble.
93
- */
94
- export async function pushBranch (dir, { branch, sha, expected = null, token = null }) {
95
- if (expected) {
96
- const anc = await runGit(dir, ['merge-base', '--is-ancestor', expected, sha])
97
- if (!anc.ok) return { state: 'refused', why: 'not a descendant of the branch head' }
98
- }
99
- const r = await runGit(dir, ['push', '-q', 'origin', `${sha}:refs/heads/${branch}`], { env: authEnv(token) })
100
- if (r.ok) return { state: 'accepted', sha }
101
- if (/rate limit|too many requests|returned error: 429|secondary rate/i.test(r.err)) return { state: 'slow', why: r.err.trim().split('\n').pop() }
102
- if (/protected branch|GH006|pre-receive hook declined|rules? (violation|prevent)/i.test(r.err)) return { state: 'protected', why: r.err.trim().split('\n').filter((l) => /remote:/.test(l)).map((l) => l.replace(/^\s*remote:\s*/, '').trim()).filter(Boolean).join(' ').slice(0, 300) || 'the branch is protected' }
103
- if (/\[rejected\]|non-fast-forward|fetch first|stale info/i.test(r.err)) {
104
- const { sha: head } = await fetchBranch(dir, branch, { token })
105
- return { state: 'moved', head }
106
- }
107
- throw new GitFailed(`pushing ${branch}`, r.err)
108
- }
109
-
110
- /**
111
- * One writer per branch: `queue(repoSlug, branch, fn)` runs `fn` after every operation
112
- * queued before it on that branch (outbound commits, inbound bring-ins, clash steps never
113
- * interleave), while other branches run alongside. Resolves or rejects with `fn`'s result;
114
- * a failure never blocks the next. `queue.pending(repo, branch)` counts what is waiting or running.
115
- */
116
- export function branchQueue () {
117
- const tails = new Map() // "<repo>\0<branch>" -> { tail: Promise, n }
118
- const queue = (repo, branch, fn) => {
119
- const k = `${repo}\0${branch}`
120
- const cur = tails.get(k) || { tail: Promise.resolve(), n: 0 }
121
- cur.n++
122
- const run = cur.tail.then(fn, fn)
123
- cur.tail = run.catch(() => {}).then(() => { cur.n--; if (cur.n === 0 && tails.get(k) === cur) tails.delete(k) })
124
- tails.set(k, cur)
125
- return run
126
- }
127
- queue.pending = (repo, branch) => (tails.get(`${repo}\0${branch}`) || { n: 0 }).n
128
- return queue
129
- }
130
-
131
- const emptyState = () => ({ base: null, pushed: null, pending: {}, held: [], lease: null })
132
-
133
- /**
134
- * The sync state of session branch `key`, kept in room meta (`meta.sync[key]`): `base`, the
135
- * GitHub commit the session's content is measured from; `pushed`, the last commit the relay
136
- * pushed; `pending`, the files changed since `base` and not pushed yet (path -> when);
137
- * `held`, files in a clash; `lease`, see §8. Made empty on first use; anything that isn't a
138
- * record is replaced.
139
- */
140
- export function syncStateOf (room, key) {
141
- if (!room.meta.sync || typeof room.meta.sync !== 'object' || Array.isArray(room.meta.sync)) room.meta.sync = {}
142
- const s = room.meta.sync[key]
143
- if (!s || typeof s !== 'object' || Array.isArray(s) || !s.pending || typeof s.pending !== 'object' || !Array.isArray(s.held)) room.meta.sync[key] = emptyState()
144
- return room.meta.sync[key]
145
- }
146
-
147
- /** Pushes to one repository are at least this far apart (addendum 16.11)… */
148
- export const PUSH_GAP_MS = 10 * 1000
149
- /** …and at most this many an hour, across all its branches. */
150
- export const PUSHES_PER_HOUR = 240
151
-
152
- /** A mirror unused this long after the last session on its repository ended is removed. */
153
- export const MIRROR_KEEP_MS = 7 * 24 * 60 * 60 * 1000
154
- /** Over this size a mirror is fetched shallow. */
155
- export const MIRROR_CAP_BYTES = 2 * 1024 * 1024 * 1024
156
- const STAMP = 'quilt-last-used'
157
-
158
- /** Records that the mirror was used now (`at`, for tests). */
159
- export function touchMirror (dir, at = Date.now()) {
160
- try { fs.writeFileSync(path.join(dir, STAMP), String(at)) } catch {}
161
- }
162
-
163
- /** When the mirror was last used: its stamp, else when it was made. */
164
- function lastUsed (dir) {
165
- try { const n = Number(fs.readFileSync(path.join(dir, STAMP), 'utf8')); if (Number.isFinite(n)) return n } catch {}
166
- try { return fs.statSync(path.join(dir, 'HEAD')).mtimeMs } catch { return Date.now() }
167
- }
168
-
169
- /** Removes every mirror under `dataDir` unused for MIRROR_KEEP_MS (`keepMs`): the repositories removed, as host/owner/name. */
170
- export function pruneMirrors (dataDir, { now = Date.now(), keepMs = MIRROR_KEEP_MS } = {}) {
171
- const removed = []
172
- const root = path.join(dataDir, 'git')
173
- let hosts = []
174
- try { hosts = fs.readdirSync(root) } catch { return removed }
175
- for (const host of hosts) {
176
- let owners = []
177
- try { owners = fs.readdirSync(path.join(root, host)) } catch { continue }
178
- for (const owner of owners) {
179
- let names = []
180
- try { names = fs.readdirSync(path.join(root, host, owner)) } catch { continue }
181
- for (const name of names) {
182
- if (!name.endsWith('.git')) continue
183
- const dir = path.join(root, host, owner, name)
184
- if (now - lastUsed(dir) < keepMs) continue
185
- try { fs.rmSync(dir, { recursive: true, force: true }); removed.push(`${host}/${owner}/${name.slice(0, -4)}`) } catch {}
186
- }
187
- }
188
- }
189
- return removed
190
- }
191
-
192
- /** git's own housekeeping, when git thinks it is due: whether it ran without error. */
193
- export async function gcMirror (dir) {
194
- return (await runGit(dir, ['gc', '--auto', '-q'], { timeout: 10 * 60 * 1000 })).ok
195
- }
196
-
197
- /** How many bytes the mirror takes on disk. */
198
- export function mirrorBytes (dir) {
199
- let total = 0
200
- const walk = (d) => {
201
- let entries = []
202
- try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
203
- for (const e of entries) {
204
- const p = path.join(d, e.name)
205
- if (e.isDirectory()) walk(p)
206
- else if (e.isFile()) { try { total += fs.statSync(p).size } catch {} }
207
- }
208
- }
209
- walk(dir)
210
- return total
211
- }
212
-
213
- /**
214
- * The relay's mirrors as one thing: `ensure(repo, { url })` the mirror of a repository (touched
215
- * as used), `queue(repoSlug, branch, fn)` the one writer per branch, `prune()` what the sweep
216
- * runs. `dataDir` null (a relay with no data folder) keeps mirrors in a temporary folder.
217
- */
218
- export function mirrorManager ({ dataDir, log = () => {}, capBytes = MIRROR_CAP_BYTES, pushGapMs = PUSH_GAP_MS, pushesPerHour = PUSHES_PER_HOUR, now = () => Date.now() }) {
219
- const base = dataDir || fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-mirrors-'))
220
- const queue = branchQueue()
221
- const budgets = new Map() // repo slug -> { last, hour: [times], until }
222
- const budgetOf = (slug) => { if (!budgets.has(slug)) budgets.set(slug, { last: 0, hour: [], until: 0 }); return budgets.get(slug) }
223
- return {
224
- dataDir: base,
225
- queue,
226
- /**
227
- * Addendum 16.11: every push to a repository, whichever branch and whoever asked (a batch, or a
228
- * named commit), shares one budget: at least `pushGapMs` apart, at most `pushesPerHour` an hour,
229
- * and nothing while GitHub asked to slow down. How long a push must wait now (0: go).
230
- */
231
- pushWait (slug) {
232
- const b = budgetOf(slug)
233
- const t = now()
234
- b.hour = b.hour.filter((x) => t - x < 60 * 60 * 1000)
235
- const gap = Math.max(0, b.last + pushGapMs - t)
236
- const cap = b.hour.length >= pushesPerHour ? b.hour[0] + 60 * 60 * 1000 - t : 0
237
- return Math.max(gap, cap, Math.max(0, b.until - t))
238
- },
239
- /** A push to `slug` is going out now. */
240
- spendPush (slug) { const b = budgetOf(slug); b.last = now(); b.hour.push(b.last) },
241
- /** GitHub asked this repository's pushes to slow down: none for `ms`. */
242
- slowDown (slug, ms) { const b = budgetOf(slug); b.until = Math.max(b.until, now() + ms) },
243
- /** fetchBranch, shallow once the mirror is over its size cap. */
244
- async fetch (dir, branch, { token = null } = {}) {
245
- touchMirror(dir)
246
- return fetchBranch(dir, branch, { token, depth: mirrorBytes(dir) > capBytes ? 1 : 0 })
247
- },
248
- /** pushBranch, then git's housekeeping when due. */
249
- async push (dir, opts) {
250
- touchMirror(dir)
251
- const r = await pushBranch(dir, opts)
252
- if (r.state === 'accepted') gcMirror(dir).catch(() => {})
253
- return r
254
- },
255
- async ensure (repo, opts) {
256
- const m = await ensureMirror(base, repo, opts)
257
- touchMirror(m.dir)
258
- if (m.created) log(`mirror: made ${repo.host}/${repo.owner}/${repo.name}`)
259
- return m
260
- },
261
- prune (opts) {
262
- const gone = pruneMirrors(base, opts)
263
- if (gone.length) log(`mirror: removed ${gone.length} unused for ${MIRROR_KEEP_MS / 86400000} days (${gone.join(', ')})`)
264
- return gone
265
- }
266
- }
267
- }
268
-
269
- /** Deletes origin's `branch` (a live or agent branch after its pull request merged; never history on the branch itself). */
270
- export async function deleteRemoteBranch (dir, branch, { token = null } = {}) {
271
- const r = await runGit(dir, ['push', '-q', 'origin', `:refs/heads/${branch}`], { env: authEnv(token) })
272
- if (r.ok || /remote ref does not exist|unable to delete/i.test(r.err)) { await runGit(dir, ['update-ref', '-d', `refs/remotes/origin/${branch}`]); return true }
273
- throw new GitFailed(`deleting ${branch}`, r.err)
274
- }
@@ -1,166 +0,0 @@
1
- // Agents' folders use git through the relay (plan §9): the relay serves git's smart HTTP protocol at
2
- // /git/<room>.git. Each session has a bare "view" repository of its own whose objects come from the
3
- // relay's mirror of the GitHub repository (objects/info/alternates) and whose branches are only the
4
- // session's synced branches, at the GitHub commit the session is measured from. So a folder clones
5
- // and fetches what the session follows and nothing another session keeps in the shared mirror. A push
6
- // is checked by a pre-receive hook that asks the relay (relay-outbound.js receiveAgentPush): the owner's
7
- // policy, the workspace's access mode, no force-push, no ignored files; the relay then puts the commits
8
- // on GitHub (replayed onto its head when it moved) before the push is accepted. No GitHub token ever
9
- // reaches the agent's machine: git signs in to the relay with the agent's Quilt pass.
10
- import fs from 'node:fs'
11
- import path from 'node:path'
12
- import { spawn, spawnSync } from 'node:child_process'
13
- import { fileURLToPath } from 'node:url'
14
- import { runGit, syncStateOf } from './relay-git.js'
15
- import { upstreamsOf } from './relay-upstream.js'
16
-
17
- const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'git-hook.js')
18
- const ZERO = /^0{40}$/
19
-
20
- /** Where room `room`'s view repository lives. */
21
- export const viewDir = (dataDir, room) => path.join(dataDir, 'git-rooms', `${room}.git`)
22
-
23
- /** The GitHub repository a room's git view serves (its default branch's), as { rec, repo slug }, or null. */
24
- export function viewRepoOf (room) {
25
- const recs = upstreamsOf(room)
26
- const def = recs[room.defaultKey] || Object.values(recs).find((r) => r && r.repo)
27
- return def && def.repo && /^github\.com\//.test(def.repo) ? def : null
28
- }
29
-
30
- /**
31
- * Makes (or keeps) room `room`'s view repository over the mirror at `mirror`: bare, objects borrowed
32
- * from the mirror, pushes refused when they would rewrite or delete a branch, and Quilt's hooks in place.
33
- */
34
- export async function ensureView (dataDir, room, mirror) {
35
- const dir = viewDir(dataDir, room)
36
- if (!fs.existsSync(path.join(dir, 'HEAD'))) {
37
- fs.mkdirSync(dir, { recursive: true })
38
- const r = await runGit(dir, ['init', '-q', '--bare'])
39
- if (!r.ok) throw new Error(`could not make the git view: ${r.err.trim()}`)
40
- }
41
- fs.mkdirSync(path.join(dir, 'objects', 'info'), { recursive: true })
42
- fs.writeFileSync(path.join(dir, 'objects', 'info', 'alternates'), `${path.join(mirror, 'objects')}\n`)
43
- for (const [k, v] of [['http.receivepack', 'true'], ['http.uploadpack', 'true'], ['receive.denyNonFastForwards', 'true'], ['receive.denyDeletes', 'true'], ['uploadpack.allowFilter', 'true'], ['gc.auto', '0']]) {
44
- await runGit(dir, ['config', k, v])
45
- }
46
- const hooks = path.join(dir, 'hooks')
47
- fs.mkdirSync(hooks, { recursive: true })
48
- for (const phase of ['pre-receive', 'post-receive']) {
49
- const file = path.join(hooks, phase)
50
- const body = `#!/bin/sh\n# Quilt: pushes from agents' folders are checked and published by the relay.\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(HOOK)} ${phase}\n`
51
- if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== body) fs.writeFileSync(file, body, { mode: 0o755 })
52
- }
53
- return dir
54
- }
55
-
56
- /**
57
- * The view's branches are the session's synced branches on this repository, each at the commit the
58
- * session is measured from (its sync base, else the branch record's); HEAD is the default branch.
59
- * Branches the session no longer has go. A commit the mirror doesn't have yet is left out.
60
- */
61
- export async function syncViewRefs (room, dir, repo) {
62
- const recs = upstreamsOf(room)
63
- const want = new Map()
64
- // Several of the session's branches can follow one GitHub branch (branches started from main all
65
- // follow main): the view's branch is the one of that name, else the default branch, else the first.
66
- const rank = (key, rec) => (key === rec.ref ? 2 : key === room.defaultKey ? 1 : 0)
67
- const ranked = new Map()
68
- for (const [key, rec] of Object.entries(recs)) {
69
- if (!rec || rec.repo !== repo || !rec.ref || rec.gone) continue
70
- if (ranked.has(rec.ref) && ranked.get(rec.ref) >= rank(key, rec)) continue
71
- const st = room.meta.sync && room.meta.sync[key] ? syncStateOf(room, key) : null
72
- const sha = (st && st.base) || rec.sha
73
- if (!/^[0-9a-f]{40}$/.test(sha || '')) continue
74
- if (!(await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) continue
75
- want.set(rec.ref, sha)
76
- ranked.set(rec.ref, rank(key, rec))
77
- }
78
- // Branches pushed through the view that the session doesn't follow (an agent's own): where GitHub took them.
79
- for (const [ref, sha] of Object.entries(room.meta.viewBranches || {})) {
80
- if (want.has(ref) || !/^[0-9a-f]{40}$/.test(sha || '')) continue
81
- if ((await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) want.set(ref, sha)
82
- }
83
- const have = new Map((await runGit(dir, ['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/heads/'])).out.split('\n').filter(Boolean).map((l) => l.split(' ')))
84
- for (const [ref, sha] of want) if (have.get(ref) !== sha) await runGit(dir, ['update-ref', `refs/heads/${ref}`, sha])
85
- for (const ref of have.keys()) if (!want.has(ref)) await runGit(dir, ['update-ref', '-d', `refs/heads/${ref}`])
86
- const def = recs[room.defaultKey]
87
- if (def && want.has(def.ref)) await runGit(dir, ['symbolic-ref', 'HEAD', `refs/heads/${def.ref}`])
88
- return want
89
- }
90
-
91
- /**
92
- * Whether `git http-backend` runs here: it is a separate package on some systems (Alpine's git-daemon),
93
- * and without it every request to /git/<room>.git would fail. Asked without a request, the CGI answers
94
- * one of its own (a 500 with headers); a git without it says it has no such command and writes nothing.
95
- */
96
- export function gitHttpBackendWorks (git = 'git') {
97
- const r = spawnSync(git, ['http-backend'], { env: { ...process.env, GIT_PROJECT_ROOT: '', REQUEST_METHOD: '' }, encoding: 'utf8', timeout: 10000 })
98
- return !r.error && /^(Status|Content-Type|Expires):/im.test(r.stdout || '')
99
- }
100
-
101
- /**
102
- * Serves one smart-HTTP request (`pathInfo`: /<room>.git/info/refs, …/git-upload-pack, …/git-receive-pack)
103
- * with git's own CGI, `git http-backend`, over `root` (the folder of view repositories). `env` reaches
104
- * the hooks of a push (who pushes, and how to ask the relay). Never throws; answers 500 when git fails,
105
- * and tells `onError` why (what git said), so the relay's log has it.
106
- */
107
- export function serveGit (req, res, { root, pathInfo, query = '', env = {}, git = 'git', onError = () => {} }) {
108
- const cgi = spawn(git, ['http-backend'], {
109
- env: {
110
- ...process.env,
111
- ...env,
112
- GIT_PROJECT_ROOT: root,
113
- GIT_HTTP_EXPORT_ALL: '1',
114
- PATH_INFO: pathInfo,
115
- REQUEST_METHOD: req.method,
116
- QUERY_STRING: query,
117
- CONTENT_TYPE: req.headers['content-type'] || '',
118
- CONTENT_LENGTH: req.headers['content-length'] || '',
119
- HTTP_CONTENT_ENCODING: req.headers['content-encoding'] || '',
120
- GIT_PROTOCOL: req.headers['git-protocol'] || '',
121
- REMOTE_ADDR: '127.0.0.1'
122
- }
123
- })
124
- let head = Buffer.alloc(0)
125
- let headed = false
126
- let said = ''
127
- cgi.stdout.on('data', (chunk) => {
128
- if (headed) return res.write(chunk)
129
- head = Buffer.concat([head, chunk])
130
- const at = head.indexOf('\r\n\r\n')
131
- if (at < 0) return
132
- headed = true
133
- const headers = {}
134
- let status = 200
135
- for (const line of head.subarray(0, at).toString().split('\r\n')) {
136
- const i = line.indexOf(':')
137
- if (i < 0) continue
138
- const k = line.slice(0, i).trim().toLowerCase()
139
- const v = line.slice(i + 1).trim()
140
- if (k === 'status') status = parseInt(v, 10) || 200; else headers[k] = v
141
- }
142
- if (status >= 500) onError(`git http-backend answered ${status}`)
143
- res.writeHead(status, headers)
144
- res.write(head.subarray(at + 4))
145
- })
146
- let done = false
147
- cgi.stdout.on('end', () => { if (headed) res.end() })
148
- // With no answer, wait for git to exit, not just for its stdout to end: what it said on stderr
149
- // can come after (on a busy machine it did, and the log said "nothing said").
150
- cgi.on('close', () => {
151
- if (headed || done) return
152
- done = true
153
- onError(`git http-backend gave no answer: ${said.trim() || 'nothing said'}`)
154
- if (!res.headersSent) res.writeHead(500)
155
- res.end()
156
- })
157
- cgi.stderr.on('data', (chunk) => { if (said.length < 2000) said += chunk.toString() })
158
- cgi.on('error', (err) => { if (done) return; done = true; onError(`git http-backend did not start: ${err.message}`); if (!res.headersSent) res.writeHead(500); res.end() })
159
- req.pipe(cgi.stdin)
160
- cgi.stdin.on('error', () => {})
161
- }
162
-
163
- /** A ref update a push asks for: { old, new, ref }, from the hook's stdin lines. */
164
- export function parseUpdates (text) {
165
- return String(text || '').split('\n').map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 3 && /^[0-9a-f]{40}$/.test(p[0]) && /^[0-9a-f]{40}$/.test(p[1])).map(([o, n, ref]) => ({ old: ZERO.test(o) ? null : o, new: ZERO.test(n) ? null : n, ref }))
166
- }
@@ -1,109 +0,0 @@
1
- // The relay's GitHub credentials for a session, from its members' GitHub connections: the API
2
- // mints them with Quilt's GitHub App for one repository at a time, for the first member of the
3
- // session whose linked GitHub account may do what is asked (write, to commit and merge; read, to
4
- // fetch or clone), whether or not that member is online (src/api/routes/github.js). One person
5
- // connecting covers everyone in the session, agents included, so nobody else makes or pastes a
6
- // token. Kept until a few minutes before they run out.
7
-
8
- /** The accounts of the session's people, the owner first: whose connections may cover a repository. */
9
- export function memberAccounts (room) {
10
- const out = []
11
- const owner = room.meta && room.meta.ownerSub
12
- if (/^person:/.test(owner || '')) out.push(owner)
13
- for (const id of Object.keys((room.meta && room.meta.members) || {})) if (/^person:/.test(id) && !out.includes(id)) out.push(id)
14
- return out
15
- }
16
-
17
- /**
18
- * { token, expiresAt, login } for owner/name (`full`) with `access` ('write' or 'read'), or { state }
19
- * saying why not: 'not-connected' (nobody in the session has connected GitHub), 'not-installed'
20
- * (Quilt's app isn't on that repository), 'no-access' (with `login`: no connected member's GitHub
21
- * account may do that there), 'off' (this relay has no API, or the session has no people) or
22
- * 'error'. Never throws. A "no" is remembered a minute (`fresh`) or ten (the relay's own looks).
23
- */
24
- export async function appCredentials (room, full, { access = 'write', fresh = true, share = true, actingFor = '' } = {}) {
25
- const cfg = room.cfg || {}
26
- const accounts = memberAccounts(room)
27
- if (!cfg.apiUrl || !cfg.relayApiSecret || !accounts.length) return { state: 'off' }
28
- // `actingFor` (person:<id> or agent:<id>): who the token is for; with the org's Require GitHub, only the person that account works for counts (plan §2).
29
- const key = `${access}\0${full}\0${actingFor}`.toLowerCase()
30
- if (!room.appTokens) room.appTokens = new Map()
31
- const hit = room.appTokens.get(key)
32
- // `share`: the relay's own token for this repository, kept and reused. Without it, a token of its
33
- // own for whoever asked (an agent's folder): minted anew, never the kept one, and not kept here.
34
- if (share && hit && hit.token && hit.expiresAt - Date.now() > 5 * 60 * 1000) return { token: hit.token, expiresAt: hit.expiresAt, login: hit.login }
35
- if (hit && !hit.token && Date.now() - hit.at < (fresh ? 60 * 1000 : 10 * 60 * 1000)) return hit.no
36
- try {
37
- const res = await (room.apiFetch || globalThis.fetch)(`${String(cfg.apiUrl).replace(/\/+$/, '')}/v1/relay/github-token`, {
38
- method: 'POST',
39
- headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.relayApiSecret}` },
40
- body: JSON.stringify({ accounts, repo: full, access, room: room.name, ...(actingFor ? { actingFor } : {}), ...(share ? {} : { fresh: true }) })
41
- })
42
- const r = res.ok ? await res.json() : { state: 'error' }
43
- if (r.state === 'ok' && r.token) {
44
- const out = { token: r.token, expiresAt: Number(r.expiresAt) || Date.now() + 30 * 60 * 1000, login: r.login || '' }
45
- if (share) room.appTokens.set(key, out)
46
- return out
47
- }
48
- const no = { state: r.state || 'error', ...(r.login ? { login: r.login } : {}), ...(r.why ? { why: r.why } : {}), ...(r.require ? { require: true } : {}) }
49
- room.appTokens.set(key, { no, at: Date.now() })
50
- return no
51
- } catch { return { state: 'error' } }
52
- }
53
-
54
- /** Revokes an installation token at GitHub (an agent's, when it leaves or may no longer push). Never throws. */
55
- export async function revokeGitHubToken (token, { fetch = globalThis.fetch } = {}) {
56
- if (!token) return false
57
- try {
58
- const res = await fetch('https://api.github.com/installation/token', { method: 'DELETE', headers: { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}` } })
59
- return res.status === 204
60
- } catch { return false }
61
- }
62
-
63
- /** Why the relay can't act on `full` for this session, in words for whoever asked, with what to do. */
64
- export function noCredentialsText (r, full, { verb = 'commit to' } = {}) {
65
- const connect = 'Connect GitHub in the app\'s commit panel (one click, by any member whose GitHub account can write to the repository: it then covers everyone here, agents included)'
66
- if (r.state === 'not-allowed') return `Not committed: ${r.why || `this session's workspace doesn't allow ${full}`}. Its admins list the repositories it may use (workspace settings, GitHub).`
67
- if (r.state === 'not-connected' && r.require) return `This org requires GitHub: the person ${verb === 'commit to' ? 'the commit is for' : 'asking'} must connect a GitHub account that may write to ${full} (Connect GitHub in the app). Until then ask a person for the commit (quilt_request_commit).`
68
- if (r.state === 'not-installed') return `Quilt's GitHub app isn't installed on ${full}: ${connect}, and pick that repository. Until then ask a person for the commit (quilt_request_commit).`
69
- if (r.state === 'no-access') return `No connected member's GitHub account (${r.login ? `@${r.login}` : 'the ones connected'}) may ${verb} ${full}, so Quilt won't for this session. Ask a person for the commit (quilt_request_commit).`
70
- if (r.state === 'not-connected') return `Nobody in this session has connected GitHub yet: ${connect}. Until then ask a person for the commit (quilt_request_commit).`
71
- return `Quilt can't ${verb} ${full} right now${r.state === 'error' ? ' (GitHub or the accounts API did not answer)' : ''}: ${connect}. Until then ask a person for the commit (quilt_request_commit).`
72
- }
73
-
74
- /**
75
- * Notes on the room's member records which GitHub account each person linked ({ login, id }, from
76
- * the API), so their commits carry their GitHub noreply address (relay-outbound.js authorOf).
77
- * Asked at most once an hour per room unless `force`. Resolves whether anything changed. Never throws.
78
- */
79
- export async function noteMemberLogins (room, { force = false, fetch = room.apiFetch || globalThis.fetch } = {}) {
80
- const cfg = room.cfg || {}
81
- const accounts = memberAccounts(room)
82
- if (!cfg.apiUrl || !cfg.relayApiSecret || !accounts.length) return false
83
- if (!force && room.loginsAt && Date.now() - room.loginsAt < 60 * 60 * 1000) return false
84
- room.loginsAt = Date.now()
85
- try {
86
- const res = await fetch(`${String(cfg.apiUrl).replace(/\/+$/, '')}/v1/relay/github-logins`, {
87
- method: 'POST',
88
- headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.relayApiSecret}` },
89
- body: JSON.stringify({ accounts })
90
- })
91
- if (!res.ok) return false
92
- const { logins = {} } = await res.json()
93
- let changed = false
94
- for (const id of accounts) {
95
- if (id === room.meta.ownerSub) {
96
- const l = logins[id]
97
- const next = l && l.login ? { login: String(l.login), id: Number(l.id) || null } : null
98
- if (JSON.stringify(room.meta.ownerGithub || null) !== JSON.stringify(next)) { if (next) room.meta.ownerGithub = next; else delete room.meta.ownerGithub; changed = true }
99
- }
100
- const m = room.meta.members && room.meta.members[id]
101
- if (!m) continue
102
- const l = logins[id]
103
- const next = l && l.login ? { login: String(l.login), id: Number(l.id) || null } : null
104
- if (JSON.stringify(m.github || null) !== JSON.stringify(next)) { if (next) m.github = next; else delete m.github; changed = true }
105
- }
106
- if (changed) room.saveMeta()
107
- return changed
108
- } catch { return false }
109
- }