heyquilt 0.0.1 → 0.3.90
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 +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,46 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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 } 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
|
+
for (const [key, rec] of Object.entries(recs)) {
|
|
65
|
+
if (!rec || rec.repo !== repo || !rec.ref || rec.gone) continue
|
|
66
|
+
const st = room.meta.sync && room.meta.sync[key] ? syncStateOf(room, key) : null
|
|
67
|
+
const sha = (st && st.base) || rec.sha
|
|
68
|
+
if (!/^[0-9a-f]{40}$/.test(sha || '')) continue
|
|
69
|
+
if (!(await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) continue
|
|
70
|
+
want.set(rec.ref, sha)
|
|
71
|
+
}
|
|
72
|
+
// Branches pushed through the view that the session doesn't follow (an agent's own): where GitHub took them.
|
|
73
|
+
for (const [ref, sha] of Object.entries(room.meta.viewBranches || {})) {
|
|
74
|
+
if (want.has(ref) || !/^[0-9a-f]{40}$/.test(sha || '')) continue
|
|
75
|
+
if ((await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) want.set(ref, sha)
|
|
76
|
+
}
|
|
77
|
+
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(' ')))
|
|
78
|
+
for (const [ref, sha] of want) if (have.get(ref) !== sha) await runGit(dir, ['update-ref', `refs/heads/${ref}`, sha])
|
|
79
|
+
for (const ref of have.keys()) if (!want.has(ref)) await runGit(dir, ['update-ref', '-d', `refs/heads/${ref}`])
|
|
80
|
+
const def = recs[room.defaultKey]
|
|
81
|
+
if (def && want.has(def.ref)) await runGit(dir, ['symbolic-ref', 'HEAD', `refs/heads/${def.ref}`])
|
|
82
|
+
return want
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Serves one smart-HTTP request (`pathInfo`: /<room>.git/info/refs, …/git-upload-pack, …/git-receive-pack)
|
|
87
|
+
* with git's own CGI, `git http-backend`, over `root` (the folder of view repositories). `env` reaches
|
|
88
|
+
* the hooks of a push (who pushes, and how to ask the relay). Never throws; answers 500 when git fails.
|
|
89
|
+
*/
|
|
90
|
+
export function serveGit (req, res, { root, pathInfo, query = '', env = {} }) {
|
|
91
|
+
const cgi = spawn('git', ['http-backend'], {
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
...env,
|
|
95
|
+
GIT_PROJECT_ROOT: root,
|
|
96
|
+
GIT_HTTP_EXPORT_ALL: '1',
|
|
97
|
+
PATH_INFO: pathInfo,
|
|
98
|
+
REQUEST_METHOD: req.method,
|
|
99
|
+
QUERY_STRING: query,
|
|
100
|
+
CONTENT_TYPE: req.headers['content-type'] || '',
|
|
101
|
+
CONTENT_LENGTH: req.headers['content-length'] || '',
|
|
102
|
+
HTTP_CONTENT_ENCODING: req.headers['content-encoding'] || '',
|
|
103
|
+
GIT_PROTOCOL: req.headers['git-protocol'] || '',
|
|
104
|
+
REMOTE_ADDR: '127.0.0.1'
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
let head = Buffer.alloc(0)
|
|
108
|
+
let headed = false
|
|
109
|
+
cgi.stdout.on('data', (chunk) => {
|
|
110
|
+
if (headed) return res.write(chunk)
|
|
111
|
+
head = Buffer.concat([head, chunk])
|
|
112
|
+
const at = head.indexOf('\r\n\r\n')
|
|
113
|
+
if (at < 0) return
|
|
114
|
+
headed = true
|
|
115
|
+
const headers = {}
|
|
116
|
+
let status = 200
|
|
117
|
+
for (const line of head.subarray(0, at).toString().split('\r\n')) {
|
|
118
|
+
const i = line.indexOf(':')
|
|
119
|
+
if (i < 0) continue
|
|
120
|
+
const k = line.slice(0, i).trim().toLowerCase()
|
|
121
|
+
const v = line.slice(i + 1).trim()
|
|
122
|
+
if (k === 'status') status = parseInt(v, 10) || 200; else headers[k] = v
|
|
123
|
+
}
|
|
124
|
+
res.writeHead(status, headers)
|
|
125
|
+
res.write(head.subarray(at + 4))
|
|
126
|
+
})
|
|
127
|
+
cgi.stdout.on('end', () => { if (!headed) { res.writeHead(500); } res.end() })
|
|
128
|
+
cgi.stderr.on('data', () => {})
|
|
129
|
+
cgi.on('error', () => { if (!res.headersSent) res.writeHead(500); res.end() })
|
|
130
|
+
req.pipe(cgi.stdin)
|
|
131
|
+
cgi.stdin.on('error', () => {})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A ref update a push asks for: { old, new, ref }, from the hook's stdin lines. */
|
|
135
|
+
export function parseUpdates (text) {
|
|
136
|
+
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 }))
|
|
137
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
}
|