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.
- package/RELEASES.md +6 -0
- package/bin/quilt.js +7 -143
- package/package.json +1 -1
- package/src/account.js +0 -29
- package/src/api/memory-store.js +0 -29
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +5 -19
- package/src/api/supabase-store.js +0 -52
- package/src/branch-view.js +6 -131
- package/src/control.js +2 -33
- package/src/duties.js +1 -1
- package/src/github-commit.js +3 -315
- package/src/mcp.js +8 -157
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-mcp.js +6 -418
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/server.js +33 -1331
- package/src/session.js +52 -3526
- package/src/status.js +2 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +0 -1
- package/src/ui/common.js +0 -2
- package/src/ui/git-rule.js +2 -30
- package/src/ui/home.js +3 -76
- package/src/ui/session.js +34 -528
- package/src/ui/tree.js +2 -8
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +1 -38
- package/src/api/routes/github.js +0 -380
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1069
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -923
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/upstream.js +0 -192
package/RELEASES.md
CHANGED
|
@@ -11,6 +11,12 @@ Format: `## <version> — <YYYY-MM-DD>`, an optional one-line summary, then bull
|
|
|
11
11
|
Lead each bullet with a short bold phrase. Inline `code` and **bold** are rendered;
|
|
12
12
|
nothing else is.
|
|
13
13
|
|
|
14
|
+
## 0.3.140 — 2026-11-01
|
|
15
|
+
|
|
16
|
+
0.3.139 was tagged but never published; everything meant for it is here.
|
|
17
|
+
|
|
18
|
+
- **Quilt no longer has git mode.** Every session's code lives in Seam: sessions are Seam repositories, committed and merged there, and Quilt no longer syncs with GitHub, pushes, commits for you or opens merge tasks. A session with a GitHub repository that never moved into Seam keeps its chat, tasks, agent feed and audit trail (its files stay as they were and are no longer shared live), and its owner can still make it a quilt from Settings, Session.
|
|
19
|
+
|
|
14
20
|
## 0.3.138 — 2026-11-01
|
|
15
21
|
|
|
16
22
|
0.3.137 was tagged but never published; everything meant for it is here.
|
package/bin/quilt.js
CHANGED
|
@@ -28,7 +28,7 @@ Usage:
|
|
|
28
28
|
quilt sync [--dir <folder>] Agents in a sandbox: sync this folder with the session once and exit
|
|
29
29
|
(needs QUILT_AGENT_KEY; run it at the start and end of every turn).
|
|
30
30
|
With the key, status, messages, say, tasks, task, history, claim,
|
|
31
|
-
release, focus,
|
|
31
|
+
release, focus, tool and mcp work too
|
|
32
32
|
quilt setup Connect Claude Code / Cursor / others via MCP
|
|
33
33
|
quilt status Show collaborators, claims, activity, chat
|
|
34
34
|
quilt history [path] [--by name] [--since 2h] [--task id] [--diff] [-n 30]
|
|
@@ -47,16 +47,11 @@ Usage:
|
|
|
47
47
|
NAME's base URL (STRIPE_BASE_URL), and its requests go through
|
|
48
48
|
Quilt's vault; it never sees a value
|
|
49
49
|
quilt commit <message> In a session that is a quilt: commit the square this folder follows
|
|
50
|
-
quilt commit-request <message> [--files a,b] [--task id]
|
|
51
|
-
Ask a person to commit for you (when agents may not push here)
|
|
52
|
-
quilt commits Open commit requests and recent commits
|
|
53
|
-
quilt github Whether agents can push here through GitHub, and the link that connects it
|
|
54
50
|
quilt focus <what you're doing> Tell collaborators what you're working on
|
|
55
51
|
quilt claim <path|glob> [reason] Mark files as yours for now
|
|
56
52
|
quilt release <path|glob|*> Release a claim
|
|
57
53
|
quilt chat-link [name] [--minutes N] Owner: a link for a chat-only AI (ChatGPT, claude.ai, Grok); 10 minutes
|
|
58
54
|
quilt chat-link extend <name> <minutes> Owner: keep a chat link working for that long from now
|
|
59
|
-
quilt github-token [--clear] Owner: a read-only GitHub token (read from stdin) so the relay brings in commits while everyone's offline
|
|
60
55
|
quilt invite Print this session's invite code
|
|
61
56
|
quilt workspace list|files|get|put|write|mkdir|mv|rm The workspace library (quilt workspace for help)
|
|
62
57
|
quilt stop Shut down everything quilt is running (relay, app, syncs)
|
|
@@ -110,13 +105,7 @@ async function main () {
|
|
|
110
105
|
case 'wake': return wakeCmd()
|
|
111
106
|
case 'get': return getFile()
|
|
112
107
|
case 'commit': if (!argv.join(' ').trim()) fail('Usage: quilt commit <message>'); return simple('/square-commit', { message: argv.join(' ') }, (r) => r.text)
|
|
113
|
-
case 'commit-request': return commitRequest()
|
|
114
|
-
case 'commits': return commits()
|
|
115
|
-
case 'github': return githubStatusCmd()
|
|
116
|
-
case 'git-token': return gitToken()
|
|
117
108
|
case 'run': return runCmd()
|
|
118
|
-
case 'git-credential': return gitCredential()
|
|
119
|
-
case 'git-pre-push': return gitPrePush()
|
|
120
109
|
case 'chat': return chat()
|
|
121
110
|
case 'focus': return simple('/focus', { text: argv.join(' ') }, () => 'focus updated')
|
|
122
111
|
case 'claim': return simple('/claim', { pattern: argv[0], note: argv.slice(1).join(' ') }, (r) =>
|
|
@@ -132,20 +121,6 @@ async function main () {
|
|
|
132
121
|
const name = argv.filter((a, k) => !(i >= 0 && (k === i || k === i + 1))).join(' ')
|
|
133
122
|
return simple('/chat-link', { name, minutes }, (r) => `Chat link for ${r.name}, until ${new Date(r.expiresAt).toLocaleTimeString()}:\n\n ${r.url}\n\nPaste it into ChatGPT, claude.ai or Grok with "Open this link and follow it to join our Quilt session."\nAnyone with it can act as ${r.name}. Extend it with \`quilt chat-link extend ${r.name} <minutes>\`; once it runs out, make a new one.`)
|
|
134
123
|
}
|
|
135
|
-
case 'github-token': {
|
|
136
|
-
// Read from stdin, never the command line (where shell history and ps would keep it).
|
|
137
|
-
if (argv[0] === '--clear') return simple('/github-token', { token: '' }, () => 'The relay no longer has a GitHub token for this session.')
|
|
138
|
-
if (argv.length) fail('usage: quilt github-token [--clear] (the token is read from stdin: pbpaste | quilt github-token)')
|
|
139
|
-
if (process.stdin.isTTY) process.stderr.write('Paste a read-only GitHub token (contents: read) and press Enter: ')
|
|
140
|
-
const token = await new Promise((resolve) => {
|
|
141
|
-
let buf = ''
|
|
142
|
-
process.stdin.setEncoding('utf8')
|
|
143
|
-
process.stdin.on('data', (d) => { buf += d; if (process.stdin.isTTY && buf.includes('\n')) { process.stdin.pause(); resolve(buf) } })
|
|
144
|
-
process.stdin.on('end', () => resolve(buf))
|
|
145
|
-
})
|
|
146
|
-
if (!token.trim()) fail('no token given')
|
|
147
|
-
return simple('/github-token', { token: token.trim() }, () => 'The relay has the token: it brings in commits from your private repository while nobody\'s folder is online.')
|
|
148
|
-
}
|
|
149
124
|
case 'session': return sessionCmd()
|
|
150
125
|
case 'invite': return invite()
|
|
151
126
|
case 'workspace': case 'workspaces': return workspaceCmd()
|
|
@@ -478,6 +453,12 @@ async function withAgentKey () {
|
|
|
478
453
|
return true
|
|
479
454
|
}
|
|
480
455
|
|
|
456
|
+
/** All of stdin, for `quilt tool <name> -` (the tool's arguments as JSON). */
|
|
457
|
+
const readStdin = () => new Promise((resolve) => {
|
|
458
|
+
let s = ''
|
|
459
|
+
process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', () => resolve(s)); process.stdin.on('error', () => resolve(s))
|
|
460
|
+
})
|
|
461
|
+
|
|
481
462
|
/** The sandbox's QUILT_AGENT_KEY (src/ui/agent-env-key.js), or null; a malformed one stops here. */
|
|
482
463
|
async function envKey () {
|
|
483
464
|
const { agentEnvKeyFromEnv } = await import('../src/ui/agent-env-key.js')
|
|
@@ -779,99 +760,6 @@ async function wakeCmd () {
|
|
|
779
760
|
return simple('/wake', { args }, (r) => r.text)
|
|
780
761
|
}
|
|
781
762
|
|
|
782
|
-
async function commitRequest () {
|
|
783
|
-
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { files: { type: 'string' }, task: { type: 'string' } } })
|
|
784
|
-
const files = values.files ? values.files.split(',').map((f) => f.trim()).filter(Boolean) : undefined
|
|
785
|
-
await simple('/commit-request', { message: positionals.join(' '), files, task: values.task }, (r) =>
|
|
786
|
-
`asked for a commit [${r.id}] of ${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 12).join(', ')}${r.files.length > 12 ? ', …' : ''}\n${r.committer}`)
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// git in an agent's folder runs these (installGitHelpers, gitstate.js); `--dir` is the folder, whose
|
|
790
|
-
// running session answers. They never print anything but what git expects, and never fail a git
|
|
791
|
-
// command when Quilt isn't running: git then behaves as it would without Quilt. Nor do they make git
|
|
792
|
-
// wait: a session that doesn't answer within HELPER_WAIT is taken as no session, and the whole helper
|
|
793
|
-
// exits by HELPER_LIMIT whatever is still pending (the env overrides are for tests).
|
|
794
|
-
const HELPER_WAIT = Number(process.env.QUILT_HELPER_WAIT_MS) || 4000
|
|
795
|
-
const HELPER_LIMIT = Number(process.env.QUILT_HELPER_LIMIT_MS) || 10000
|
|
796
|
-
function helperDeadline () { setTimeout(() => process.exit(0), HELPER_LIMIT).unref() }
|
|
797
|
-
function helperDir () {
|
|
798
|
-
const i = argv.indexOf('--dir')
|
|
799
|
-
const dir = i >= 0 ? argv[i + 1] : process.cwd()
|
|
800
|
-
if (i >= 0) argv.splice(i, 2)
|
|
801
|
-
return dir
|
|
802
|
-
}
|
|
803
|
-
async function helperDaemon (dir) {
|
|
804
|
-
const { findDaemon, call } = await import('../src/control.js')
|
|
805
|
-
const d = findDaemon(dir)
|
|
806
|
-
return d ? { d, call } : null
|
|
807
|
-
}
|
|
808
|
-
// git closes stdin after what it sends; one that stays open is read for HELPER_WAIT at most.
|
|
809
|
-
const readStdin = () => new Promise((resolve) => {
|
|
810
|
-
let s = ''
|
|
811
|
-
const done = () => { clearTimeout(t); process.stdin.pause(); resolve(s) }
|
|
812
|
-
const t = setTimeout(done, HELPER_WAIT)
|
|
813
|
-
process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', done); process.stdin.on('error', done)
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* git's credential helper protocol: `get` answers with the session's token for the host; store and erase
|
|
818
|
-
* are nothing to do. `--fallback` (a person's folder, git for everyone): when no Quilt session answers
|
|
819
|
-
* (the app is closed) or it has no token, the person's own helpers are asked instead, as git would have
|
|
820
|
-
* without Quilt: `git credential fill` from outside the folder, so only their global and system config count.
|
|
821
|
-
*/
|
|
822
|
-
async function gitCredential () {
|
|
823
|
-
const dir = helperDir()
|
|
824
|
-
const fallback = argv.includes('--fallback')
|
|
825
|
-
if (fallback) argv.splice(argv.indexOf('--fallback'), 1)
|
|
826
|
-
if (argv[0] !== 'get') return
|
|
827
|
-
helperDeadline()
|
|
828
|
-
const input = await readStdin()
|
|
829
|
-
const fields = Object.fromEntries(input.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)] }))
|
|
830
|
-
try {
|
|
831
|
-
const h = await helperDaemon(dir)
|
|
832
|
-
if (h) {
|
|
833
|
-
const r = await h.call(h.d, 'POST', '/git/credential', { host: fields.host || '', protocol: fields.protocol || '' }, { timeoutMs: HELPER_WAIT })
|
|
834
|
-
if (r && r.username && r.password) return process.stdout.write(`username=${r.username}\npassword=${r.password}\n`)
|
|
835
|
-
}
|
|
836
|
-
} catch {}
|
|
837
|
-
if (fallback) await ownCredential(input)
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/** The person's own credentials for what git asked (`input`, git's credential fields), or nothing: never a prompt. */
|
|
841
|
-
async function ownCredential (input) {
|
|
842
|
-
const os = await import('node:os')
|
|
843
|
-
const { execFile } = await import('node:child_process')
|
|
844
|
-
const away = os.tmpdir()
|
|
845
|
-
const out = await new Promise((resolve) => {
|
|
846
|
-
const c = execFile('git', ['credential', 'fill'], { cwd: away, encoding: 'utf8', timeout: Math.max(1000, HELPER_LIMIT - HELPER_WAIT - 1000), killSignal: 'SIGKILL', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_CEILING_DIRECTORIES: path.dirname(away) } }, (err, stdout) => resolve(err ? '' : stdout))
|
|
847
|
-
c.stdin.end(input.endsWith('\n\n') ? input : `${input.replace(/\n*$/, '')}\n\n`)
|
|
848
|
-
})
|
|
849
|
-
const lines = out.split('\n').filter((l) => /^(username|password)=/.test(l))
|
|
850
|
-
if (lines.length === 2) process.stdout.write(`${lines.join('\n')}\n`)
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/** The pre-push hook: refs on stdin (local ref, local sha, remote ref, remote sha), the remote's name and URL as arguments. Exits 1 with the reason when the push may not go. */
|
|
854
|
-
async function gitPrePush () {
|
|
855
|
-
const dir = helperDir()
|
|
856
|
-
const [remote = 'origin'] = argv
|
|
857
|
-
helperDeadline()
|
|
858
|
-
const input = await readStdin()
|
|
859
|
-
const refs = input.split('\n').filter(Boolean).map((l) => { const [localRef, localSha, remoteRef, remoteSha] = l.trim().split(/\s+/); return { localRef, localSha, remoteRef, remoteSha } })
|
|
860
|
-
try {
|
|
861
|
-
const h = await helperDaemon(dir)
|
|
862
|
-
if (!h) return
|
|
863
|
-
const r = await h.call(h.d, 'POST', '/git/push-check', { remote, refs }, { timeoutMs: HELPER_WAIT })
|
|
864
|
-
if (r && r.ok === false) { process.stderr.write(`Quilt: ${r.why || 'this push is not allowed here'}\n`); process.exitCode = 1 }
|
|
865
|
-
} catch {}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/** A short-lived token for this folder's repository (agents), for gh or GitHub's API. */
|
|
869
|
-
/** `quilt git-token` no longer prints a token (a value in a terminal is a value in an AI's context). */
|
|
870
|
-
async function gitToken () {
|
|
871
|
-
const { GIT_TOKEN_GONE } = await import('../src/secret-run.js')
|
|
872
|
-
fail(GIT_TOKEN_GONE)
|
|
873
|
-
}
|
|
874
|
-
|
|
875
763
|
/** `quilt run -- <command>`: the command gets its secrets' stand-ins and gateways (src/secret-run.js). */
|
|
876
764
|
async function runCmd () {
|
|
877
765
|
const { RUN_USAGE, runSource, runWithSecrets } = await import('../src/secret-run.js')
|
|
@@ -885,30 +773,6 @@ async function runCmd () {
|
|
|
885
773
|
try { process.exitCode = await runWithSecrets({ command, source }) } catch (err) { fail(err.message) }
|
|
886
774
|
}
|
|
887
775
|
|
|
888
|
-
async function githubStatusCmd () {
|
|
889
|
-
const { d, call } = await daemonOrFail()
|
|
890
|
-
const st = await call(d, 'GET', '/status')
|
|
891
|
-
const mine = (st.branches || []).find((b) => st.git && b.name === st.git.key) || (st.branches || []).find((b) => b.default)
|
|
892
|
-
const g = mine && mine.relay && mine.relay.github
|
|
893
|
-
const repo = mine && mine.relay && mine.relay.repo
|
|
894
|
-
if (!repo) return console.log('This session does not follow a GitHub repository (no member\'s folder is a clone of one), so there is nothing to connect.')
|
|
895
|
-
const words = { ok: `Git is on for everyone here, through @${g && g.login}'s connection: people and agents push with git, nothing to set up`, 'not-connected': 'not connected: nobody in the session has connected GitHub yet', 'not-installed': 'Quilt is not installed on this repository on GitHub yet', 'no-access': `no connected GitHub account (@${g && g.login}) may write to it`, 'not-github': 'not on GitHub' }
|
|
896
|
-
console.log(`${repo}: ${g ? words[g.state] || g.state : 'not checked yet'}`)
|
|
897
|
-
if (g && g.state === 'ok') return
|
|
898
|
-
const { readAccount } = await import('../src/account.js')
|
|
899
|
-
const token = readAccount()?.token
|
|
900
|
-
if (!token) return console.log('Sign in (quilt login), then run this again for the link that connects GitHub.')
|
|
901
|
-
const { githubConnectUrl } = await import('../src/account.js')
|
|
902
|
-
try { console.log(`Connect it (one click, once, by someone whose GitHub account can write to the repository):\n ${await githubConnectUrl({ token, repo })}`) } catch (err) { console.log(`Could not get the link: ${err.message}`) }
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async function commits () {
|
|
906
|
-
const { d, call } = await daemonOrFail()
|
|
907
|
-
const { describeCommitRequests } = await import('../src/commit.js')
|
|
908
|
-
const c = await call(d, 'GET', '/commits')
|
|
909
|
-
console.log(plain(describeCommitRequests(c, { canCommit: !!c.canCommit })))
|
|
910
|
-
}
|
|
911
|
-
|
|
912
776
|
async function getFile () {
|
|
913
777
|
if (!argv[0]) fail('usage: quilt get <message-id> [dest]')
|
|
914
778
|
await simple('/get', { id: argv[0], dest: argv[1] ? path.resolve(argv[1]) : undefined }, (r) => `saved to ${r.path}`)
|
package/package.json
CHANGED
package/src/account.js
CHANGED
|
@@ -224,35 +224,6 @@ export async function removeSandboxKey ({ token, id, keyId, api = apiUrl(), fetc
|
|
|
224
224
|
return call(fetchImpl, api, 'DELETE', sandbox$(id, `/${encodeURIComponent(keyId)}`), null, token)
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/** This account's GitHub connection (Quilt's GitHub App): { available, connected, login? }. */
|
|
228
|
-
export async function githubStatus ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
229
|
-
return call(fetchImpl, api, 'GET', '/v1/me/github', null, token)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Where to send this person to connect GitHub (install Quilt's app on their repositories). */
|
|
233
|
-
export async function githubConnectUrl ({ token, repo = '', scope = '', id = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
234
|
-
const r = await call(fetchImpl, api, 'POST', '/v1/github/connect', { ...(repo ? { repo } : {}), ...(scope ? { scope, id } : {}) }, token)
|
|
235
|
-
if (!r.url) throw new Error(BAD_REPLY)
|
|
236
|
-
return r.url
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** A workspace's GitHub (plan §2): its connections, access mode and repositories, and its org's switch. */
|
|
240
|
-
export async function workspaceGithub ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
241
|
-
return call(fetchImpl, api, 'GET', `/v1/workspaces/${encodeURIComponent(id)}/github`, null, token)
|
|
242
|
-
}
|
|
243
|
-
export async function setWorkspaceGithub ({ token, id, access, repos, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
244
|
-
// Only what is given changes: the access mode (with its repositories) and/or what agents may commit ('' for each session).
|
|
245
|
-
const body = { ...(access !== undefined ? { access, repos } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
|
|
246
|
-
return call(fetchImpl, api, 'PUT', `/v1/workspaces/${encodeURIComponent(id)}/github`, body, token)
|
|
247
|
-
}
|
|
248
|
-
export async function removeWorkspaceGithub ({ token, id, connectedBy = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
249
|
-
return call(fetchImpl, api, 'DELETE', `/v1/workspaces/${encodeURIComponent(id)}/github/connection`, connectedBy ? { connectedBy } : {}, token)
|
|
250
|
-
}
|
|
251
|
-
export async function setOrgGithub ({ token, slug, requireGithub, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
|
|
252
|
-
const body = { ...(requireGithub !== undefined ? { requireGithub: !!requireGithub } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
|
|
253
|
-
return call(fetchImpl, api, 'PUT', `/v1/orgs/${encodeURIComponent(slug)}/github`, body, token)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
227
|
// Access types, grants and session invites, as this computer's account (see
|
|
257
228
|
// docs/superpowers/specs/2026-10-02-access-types-and-invites-design.md). Each throws with
|
|
258
229
|
// .status when the API says no (401 once the token is revoked).
|
package/src/api/memory-store.js
CHANGED
|
@@ -399,35 +399,6 @@ export function createMemoryStore ({ now = Date.now } = {}) {
|
|
|
399
399
|
addAuthSession (userId, s) { authSessions.set(s.id, { userId, createdAt: now(), lastActiveAt: now(), userAgent: '', ip: '', ...s }) },
|
|
400
400
|
async authSessions (userId) { return all(authSessions, (s) => s.userId === userId).sort((a, b) => b.lastActiveAt - a.lastActiveAt).map(({ userId: _, ...s }) => ({ ...s })) },
|
|
401
401
|
async endAuthSession (userId, id) { const s = authSessions.get(id); if (!s || s.userId !== userId) return false; authSessions.delete(id); return true },
|
|
402
|
-
async githubLink (userId) { const g = githubLinks.get(userId); return g ? { ...g } : null },
|
|
403
|
-
async setGithubLink (userId, { githubId, login }) { githubLinks.set(userId, { githubId, login, linkedAt: now() }) },
|
|
404
|
-
async deleteGithubLink (userId) { githubLinks.delete(userId) },
|
|
405
|
-
// GitHub connected for a scope (github_connections): an org, a workspace or a person; newest first.
|
|
406
|
-
async githubConnections ({ scope, scopeId }) { return all(githubConnections, (c) => c.scope === scope && c.scopeId === scopeId).sort((a, b) => b.createdAt - a.createdAt).map(copy) },
|
|
407
|
-
async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) { githubConnections.set(`${scope}\0${scopeId}\0${connectedBy}`, { scope, scopeId, connectedBy, installationId, createdAt: now() }) },
|
|
408
|
-
async removeGithubConnection ({ scope, scopeId, connectedBy }) { githubConnections.delete(`${scope}\0${scopeId}\0${connectedBy}`) },
|
|
409
|
-
// A workspace's access mode (workspace_github): 'open', or 'repos' with the repositories it allows.
|
|
410
|
-
async workspaceGithub (workspaceId) { const w = workspaceGithub.get(workspaceId); return w ? { access: w.access, repos: [...w.repos], agentCommits: w.agentCommits || '' } : { access: 'open', repos: [], agentCommits: '' } },
|
|
411
|
-
// Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
|
|
412
|
-
async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
|
|
413
|
-
const w = workspaceGithub.get(workspaceId) || { access: 'open', repos: [], agentCommits: '' }
|
|
414
|
-
workspaceGithub.set(workspaceId, { access: access ?? w.access, repos: repos ? [...repos] : w.repos, agentCommits: agentCommits ?? w.agentCommits ?? '', updatedAt: now() })
|
|
415
|
-
},
|
|
416
|
-
// An org's GitHub switch (org_github): whether it requires GitHub.
|
|
417
|
-
async teamGithub (teamId) { const r = teamGithub.get(teamId); return { agentCommits: (r && r.agentCommits) || '' } },
|
|
418
|
-
async setTeamGithub (teamId, { agentCommits }) { teamGithub.set(teamId, { agentCommits: agentCommits || '', updatedAt: now() }) },
|
|
419
|
-
// The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
|
|
420
|
-
async teamGitRules (memberId) {
|
|
421
|
-
return all(teamMembers, (tm) => tm.memberId === memberId)
|
|
422
|
-
.map((tm) => ({ team: teams.get(tm.teamId), rule: teamGithub.get(tm.teamId) }))
|
|
423
|
-
.filter((x) => x.team && x.rule && x.rule.agentCommits)
|
|
424
|
-
.map((x) => ({ teamId: x.team.id, name: x.team.name, agentCommits: x.rule.agentCommits }))
|
|
425
|
-
},
|
|
426
|
-
async orgGithub (orgId) { const o = orgGithub.get(orgId); return { requireGithub: !!(o && o.requireGithub), agentCommits: (o && o.agentCommits) || '' } },
|
|
427
|
-
async setOrgGithub (orgId, { requireGithub, agentCommits }) {
|
|
428
|
-
const o = orgGithub.get(orgId) || { requireGithub: false, agentCommits: '' }
|
|
429
|
-
orgGithub.set(orgId, { requireGithub: requireGithub ?? o.requireGithub, agentCommits: agentCommits ?? o.agentCommits ?? '', updatedAt: now() })
|
|
430
|
-
},
|
|
431
402
|
// The API's own settings (app_settings): a value it keeps across starts.
|
|
432
403
|
async getSetting (key) { return settings.has(key) ? settings.get(key) : null },
|
|
433
404
|
// Relay leases (relay_leases; mirrors the SQL functions). `key`: the relay's name for what it holds.
|
package/src/api/routes/teams.js
CHANGED
|
@@ -37,8 +37,6 @@ export function teamRoutes ({ store, user, policyChanged = null }) {
|
|
|
37
37
|
name: t.name,
|
|
38
38
|
createdAt: t.createdAt,
|
|
39
39
|
access: mine.get(t.id) || null,
|
|
40
|
-
// The team's git rule (git for everyone: '' sets nothing, off, branches or any).
|
|
41
|
-
git: (await store.teamGithub(t.id)).agentCommits,
|
|
42
40
|
members: a.can('team_members', 'r') || mine.has(t.id)
|
|
43
41
|
? (await store.listTeamMembers(t.id)).map((m) => ({ memberId: m.memberId, name: m.name, access: m.access, kind: m.kind, scopes: m.scopes || [] }))
|
|
44
42
|
: null
|
|
@@ -98,27 +96,6 @@ export function teamRoutes ({ store, user, policyChanged = null }) {
|
|
|
98
96
|
return { member: memberView(tm) }
|
|
99
97
|
}],
|
|
100
98
|
|
|
101
|
-
// The team's git rule (docs/superpowers/specs/2026-10-17-git-for-everyone.md): whether git is on for
|
|
102
|
-
// everyone in the sessions its people own in the org's workspaces, and what agents may push there.
|
|
103
|
-
// '' sets nothing; off, branches or any is a ceiling for the workspaces' and sessions' own choices.
|
|
104
|
-
['GET', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
|
|
105
|
-
const a = await orgFor(req, slug)
|
|
106
|
-
const team = await teamIn(a, id)
|
|
107
|
-
const mine = (await store.teamsOfMember(a.me.id)).some((x) => x.teamId === team.id)
|
|
108
|
-
if (!a.can('teams', 'r') && !mine) throw new HttpError(404, 'no such team')
|
|
109
|
-
return store.teamGithub(team.id)
|
|
110
|
-
}],
|
|
111
|
-
['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
|
|
112
|
-
const a = await orgFor(req, slug)
|
|
113
|
-
a.need('teams', 'u')
|
|
114
|
-
const team = await teamIn(a, id)
|
|
115
|
-
if (!('agentCommits' in body)) throw new HttpError(400, 'Give agentCommits: "" (the team sets nothing), off, branches or any.')
|
|
116
|
-
const v = body.agentCommits === null ? '' : body.agentCommits
|
|
117
|
-
if (!['', 'off', 'branches', 'any'].includes(v)) throw new HttpError(400, 'agentCommits is "" (the team sets nothing), off, branches or any.')
|
|
118
|
-
await store.setTeamGithub(team.id, { agentCommits: v })
|
|
119
|
-
if (policyChanged) policyChanged()
|
|
120
|
-
return store.teamGithub(team.id)
|
|
121
|
-
}],
|
|
122
99
|
|
|
123
100
|
['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
|
|
124
101
|
const a = await orgFor(req, slug)
|
package/src/api/server.js
CHANGED
|
@@ -25,8 +25,6 @@ import { relayRoutes } from './routes/relay.js'
|
|
|
25
25
|
import { relayClusterRoutes } from './routes/relay-cluster.js'
|
|
26
26
|
import { relayCluster } from './relay-cluster.js'
|
|
27
27
|
import crypto from 'node:crypto'
|
|
28
|
-
import { githubRoutes } from './routes/github.js'
|
|
29
|
-
import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
|
|
30
28
|
import { releaseSource } from './releases.js'
|
|
31
29
|
import { sessionRoutes, nowRoutes, nowAsker, overviewOf } from './routes/sessions.js'
|
|
32
30
|
import { notifier, NOTIFY_EVERY_MS } from './notify.js'
|
|
@@ -361,8 +359,6 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
361
359
|
// The relay on many machines: where each room lives, and heavy rooms' own machines (relay-cluster.js).
|
|
362
360
|
const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...(relayTargets ? { targets: relayTargets } : {}), ...clusterTiming })
|
|
363
361
|
const ctx = { store, user, person, device, bearer, now, site, apiUrl: api, mailer, log, limit: limitInvites, limitSend: limitInviteSend, limitTokens, limitStarts, spendResume, resumeWindowMs: RESUME_WINDOW_MS, limitJoin, agentAuth, reportKey, limitReports, relaySecret, files, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles }
|
|
364
|
-
// GitHub's webhook secret: set on the app by this API at start (github-app.js), kept in the store when the environment gives none.
|
|
365
|
-
const hookSecret = { value: (github && github.webhookSecret) || '' }
|
|
366
362
|
// On several machines, one leads what lives in a machine's memory (leader.js); alone, this one does.
|
|
367
363
|
const lead = leader({ store, machineId, log, ...leaderTiming })
|
|
368
364
|
// Scaling down (autoscale.js): an idle machine shuts itself down cleanly when the others can carry
|
|
@@ -381,27 +377,17 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
381
377
|
exit()
|
|
382
378
|
}
|
|
383
379
|
})
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const app = githubApp(github, { fetch: githubFetch, now })
|
|
387
|
-
await ensureWebhookSecret({ cfg: github, app, store, hook: hookSecret, log })
|
|
388
|
-
// Asking GitHub for failed deliveries again is done once, by the leader, not by every machine.
|
|
389
|
-
await lead.ready
|
|
390
|
-
if (lead.leads()) await redeliverFailed({ app, log })
|
|
391
|
-
})().catch((err) => log(`github at start: ${err.message}`))
|
|
380
|
+
// Sessions are quilts (Seam): nothing waits on GitHub at start.
|
|
381
|
+
const githubReady = Promise.resolve()
|
|
392
382
|
// A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up. Only the leader runs it.
|
|
393
383
|
const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs, leads: lead.leads })
|
|
394
384
|
routes.push(...aiVm.routes)
|
|
395
385
|
// Email notices people turned on in the website's Settings › Notifications (notify.js): the leader sends them.
|
|
396
386
|
const notices = notifier({ store, mailer, ask: nowAsker({ store, cluster, relayOn: !!(relay && relaySecret), log }), overview: (account, t) => overviewOf(store, account, 'UTC', t), now, log, site, leads: lead.leads, everyMs: notifyEveryMs })
|
|
397
387
|
if (notifyEveryMs > 0) notices.start()
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (!relay || !relaySecret) return
|
|
402
|
-
cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
|
|
403
|
-
}
|
|
404
|
-
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...nowRoutes({ ...ctx, cluster, relayOn: !!(relay && relaySecret) }), ...browserRoutes(ctx), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
388
|
+
// Git rules went with git mode (R3): nothing to tell the relay.
|
|
389
|
+
const policyChanged = () => {}
|
|
390
|
+
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...nowRoutes({ ...ctx, cluster, relayOn: !!(relay && relaySecret) }), ...browserRoutes(ctx), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
405
391
|
// Always routed: with the flag off each answers a plain 404 of its own, so the app's
|
|
406
392
|
// check at every launch isn't filed as a missing route.
|
|
407
393
|
// Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
|
|
@@ -298,58 +298,6 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
298
298
|
if (error) throw error
|
|
299
299
|
},
|
|
300
300
|
|
|
301
|
-
// The GitHub account a person connected (routes/github.js): { githubId, login, linkedAt } or null.
|
|
302
|
-
async githubLink (userId) { return rowFrom(await one(db.from('github_links').select('github_id, login, linked_at').eq('user_id', userId).maybeSingle())) },
|
|
303
|
-
async setGithubLink (userId, { githubId, login }) {
|
|
304
|
-
await one(db.from('github_links').upsert({ user_id: userId, github_id: githubId, login, linked_at: new Date().toISOString() }, { onConflict: 'user_id' }))
|
|
305
|
-
},
|
|
306
|
-
async deleteGithubLink (userId) { await one(db.from('github_links').delete().eq('user_id', userId)) },
|
|
307
|
-
// GitHub connected for a scope (github_connections), newest first.
|
|
308
|
-
async githubConnections ({ scope, scopeId }) {
|
|
309
|
-
const rows = await one(db.from('github_connections').select('scope, scope_id, connected_by, installation_id, created_at').eq('scope', scope).eq('scope_id', scopeId).order('created_at', { ascending: false }))
|
|
310
|
-
return rows.map((r) => ({ scope: r.scope, scopeId: r.scope_id, connectedBy: r.connected_by, installationId: r.installation_id == null ? null : Number(r.installation_id), createdAt: Date.parse(r.created_at) }))
|
|
311
|
-
},
|
|
312
|
-
async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) {
|
|
313
|
-
await one(db.from('github_connections').upsert({ scope, scope_id: scopeId, connected_by: connectedBy, installation_id: installationId, created_at: new Date().toISOString() }, { onConflict: 'scope,scope_id,connected_by' }))
|
|
314
|
-
},
|
|
315
|
-
async removeGithubConnection ({ scope, scopeId, connectedBy }) { await one(db.from('github_connections').delete().eq('scope', scope).eq('scope_id', scopeId).eq('connected_by', connectedBy)) },
|
|
316
|
-
async workspaceGithub (workspaceId) {
|
|
317
|
-
const r = await one(db.from('workspace_github').select('access, repos, agent_commits').eq('workspace_id', workspaceId).maybeSingle())
|
|
318
|
-
return r ? { access: r.access, repos: r.repos || [], agentCommits: r.agent_commits || '' } : { access: 'open', repos: [], agentCommits: '' }
|
|
319
|
-
},
|
|
320
|
-
// Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
|
|
321
|
-
async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
|
|
322
|
-
const row = { workspace_id: workspaceId, updated_at: new Date().toISOString() }
|
|
323
|
-
if (access !== undefined) { row.access = access; row.repos = repos || [] }
|
|
324
|
-
if (agentCommits !== undefined) row.agent_commits = agentCommits || null
|
|
325
|
-
await one(db.from('workspace_github').upsert(row, { onConflict: 'workspace_id' }))
|
|
326
|
-
},
|
|
327
|
-
async teamGithub (teamId) {
|
|
328
|
-
const r = await one(db.from('team_github').select('agent_commits').eq('team_id', teamId).maybeSingle())
|
|
329
|
-
return { agentCommits: (r && r.agent_commits) || '' }
|
|
330
|
-
},
|
|
331
|
-
async setTeamGithub (teamId, { agentCommits }) {
|
|
332
|
-
await one(db.from('team_github').upsert({ team_id: teamId, agent_commits: agentCommits || null, updated_at: new Date().toISOString() }, { onConflict: 'team_id' }))
|
|
333
|
-
},
|
|
334
|
-
// The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
|
|
335
|
-
async teamGitRules (memberId) {
|
|
336
|
-
const rows = await one(db.from('team_members').select('team_id, teams (name, team_github (agent_commits))').eq('member_id', memberId))
|
|
337
|
-
return rows.map((r) => {
|
|
338
|
-
const g = r.teams && r.teams.team_github
|
|
339
|
-
const mode = Array.isArray(g) ? (g[0] && g[0].agent_commits) : (g && g.agent_commits)
|
|
340
|
-
return mode ? { teamId: r.team_id, name: (r.teams && r.teams.name) || '', agentCommits: mode } : null
|
|
341
|
-
}).filter(Boolean)
|
|
342
|
-
},
|
|
343
|
-
async orgGithub (orgId) {
|
|
344
|
-
const r = await one(db.from('org_github').select('require_github, agent_commits').eq('org_id', orgId).maybeSingle())
|
|
345
|
-
return { requireGithub: !!(r && r.require_github), agentCommits: (r && r.agent_commits) || '' }
|
|
346
|
-
},
|
|
347
|
-
async setOrgGithub (orgId, { requireGithub, agentCommits }) {
|
|
348
|
-
const row = { org_id: orgId, updated_at: new Date().toISOString() }
|
|
349
|
-
if (requireGithub !== undefined) row.require_github = !!requireGithub
|
|
350
|
-
if (agentCommits !== undefined) row.agent_commits = agentCommits || null
|
|
351
|
-
await one(db.from('org_github').upsert(row, { onConflict: 'org_id' }))
|
|
352
|
-
},
|
|
353
301
|
// The API's own settings (app_settings): a value it keeps across starts, such as the webhook secret it set on GitHub.
|
|
354
302
|
// Relay leases (relay_leases, through its locking functions; 16 R2). `key`: the relay's name for what it holds.
|
|
355
303
|
// Before the migration that makes relay_leases is applied, a git branch's key ("<repo> <branch>") goes to the old functions.
|
package/src/branch-view.js
CHANGED
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
// Everything on the relay that reads a branch's files asks here, for either form.
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
|
-
import { runGit, GitFailed, authEnv } from './relay-git.js'
|
|
16
|
-
import { parseRepo } from './relay-upstream.js'
|
|
17
|
-
import { looksBinary, sha1 } from './fsutil.js'
|
|
18
15
|
|
|
19
16
|
export const DIFF = 'diff'
|
|
20
17
|
/** docKey's answer for a path a diff document leaves to git: never null (a deletion), never undefined (unreadable). */
|
|
@@ -161,84 +158,15 @@ export function baseOf (room, key, e = null) {
|
|
|
161
158
|
|
|
162
159
|
// --------------------------------------------------------------- git --
|
|
163
160
|
|
|
164
|
-
const TREE_CACHE = 16
|
|
165
|
-
/** The relay's mirror for branch `key`'s repository: { dir, repo, token } or null (not on GitHub, or no mirror). */
|
|
166
|
-
export async function mirrorFor (room, key) {
|
|
167
|
-
const ups = (room.meta && room.meta.upstreams) || {}
|
|
168
|
-
const b = room.meta && room.meta.branches && room.meta.branches[key]
|
|
169
|
-
// The branch's own record, the default branch's, or the repository the folder that started it named (a new session).
|
|
170
|
-
const rec = (ups[key] && ups[key].repo && ups[key]) || (ups[room.defaultKey] && ups[room.defaultKey].repo && ups[room.defaultKey]) || (b && b.repo ? { repo: b.repo } : null)
|
|
171
|
-
if (!rec || !room.mirrors) return null
|
|
172
|
-
const repo = parseRepo(`https://${rec.repo}`)
|
|
173
|
-
if (!repo || !repo.github) return null
|
|
174
|
-
const token = room.gitToken ? await room.gitToken(repo) : null
|
|
175
|
-
const { dir } = await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })
|
|
176
|
-
return { dir, repo, token }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Makes sure the mirror has commit `sha`, fetching it from GitHub when it hasn't (a shallow mirror, or a commit it never fetched). */
|
|
180
|
-
export async function ensureCommit (dir, sha, { token = null } = {}) {
|
|
181
|
-
if ((await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) return true
|
|
182
|
-
const r = await runGit(dir, ['fetch', '-q', 'origin', sha], { env: authEnv(token) })
|
|
183
|
-
return r.ok && (await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Commit `sha`'s tree: Map path -> { mode, sha, size } (cached on the room, a few commits). */
|
|
187
|
-
export async function treeAt (room, dir, sha) {
|
|
188
|
-
if (!room.gitTrees) room.gitTrees = new Map()
|
|
189
|
-
const k = `${dir}\0${sha}`
|
|
190
|
-
if (room.gitTrees.has(k)) { const t = room.gitTrees.get(k); room.gitTrees.delete(k); room.gitTrees.set(k, t); return t }
|
|
191
|
-
const r = await runGit(dir, ['ls-tree', '-r', '-z', '-l', sha])
|
|
192
|
-
if (!r.ok) throw new GitFailed(`listing ${sha.slice(0, 7)}`, r.err)
|
|
193
|
-
const out = new Map()
|
|
194
|
-
for (const entry of r.out.split('\0')) {
|
|
195
|
-
if (!entry) continue
|
|
196
|
-
const tab = entry.indexOf('\t')
|
|
197
|
-
const [mode, type, oid, size] = entry.slice(0, tab).split(/\s+/)
|
|
198
|
-
if (type !== 'blob') continue // submodules have no bytes here
|
|
199
|
-
out.set(entry.slice(tab + 1), { mode, sha: oid, size: Number(size) || 0 })
|
|
200
|
-
}
|
|
201
|
-
room.gitTrees.set(k, out)
|
|
202
|
-
while (room.gitTrees.size > TREE_CACHE) room.gitTrees.delete(room.gitTrees.keys().next().value)
|
|
203
|
-
return out
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** The bytes of `paths` at commit `sha`: Map path -> Buffer, or null when the commit hasn't it. */
|
|
207
|
-
export async function bytesAt (dir, sha, paths) {
|
|
208
|
-
const out = new Map()
|
|
209
|
-
if (!paths.length) return out
|
|
210
|
-
const r = await runGit(dir, ['cat-file', '--batch'], { input: paths.map((p) => `${sha}:${p}\n`).join(''), buffer: true })
|
|
211
|
-
if (!r.ok) throw new GitFailed(`reading files at ${sha.slice(0, 7)}`, r.err)
|
|
212
|
-
const buf = r.out
|
|
213
|
-
let at = 0
|
|
214
|
-
for (const rel of paths) {
|
|
215
|
-
const nl = buf.indexOf(0x0a, at)
|
|
216
|
-
if (nl < 0) break
|
|
217
|
-
const line = buf.subarray(at, nl).toString('utf8')
|
|
218
|
-
at = nl + 1
|
|
219
|
-
if (/ missing$/.test(line) || / ambiguous$/.test(line)) { out.set(rel, null); continue }
|
|
220
|
-
const size = Number(line.split(' ')[2])
|
|
221
|
-
out.set(rel, Buffer.from(buf.subarray(at, at + size)))
|
|
222
|
-
at += size + 1
|
|
223
|
-
}
|
|
224
|
-
return out
|
|
225
|
-
}
|
|
226
|
-
|
|
227
161
|
/** Why git can't answer for a diff branch, in words for whoever asked. */
|
|
228
|
-
const noGit = (key) => `the session
|
|
162
|
+
const noGit = (key) => `the session left ${key}'s unchanged files to git, which the relay no longer reads (sessions are quilts now: open the branch's square)`
|
|
229
163
|
|
|
230
164
|
/**
|
|
231
165
|
* What git has of branch `key` at its base, for reading paths that follow git: { base, dir, tree }
|
|
232
166
|
* or throws a plain-words error. Only for a diff document.
|
|
233
167
|
*/
|
|
234
|
-
export async function gitOf (room, key
|
|
235
|
-
|
|
236
|
-
if (!base) throw new Error(noGit(key))
|
|
237
|
-
let m
|
|
238
|
-
try { m = await mirrorFor(room, key) } catch { m = null }
|
|
239
|
-
if (!m) throw new Error(noGit(key))
|
|
240
|
-
if (!(await ensureCommit(m.dir, base, { token: m.token }))) throw new Error(noGit(key))
|
|
241
|
-
return { base, dir: m.dir, tree: await treeAt(room, m.dir, base) }
|
|
168
|
+
export async function gitOf (room, key) {
|
|
169
|
+
throw new Error(noGit(key))
|
|
242
170
|
}
|
|
243
171
|
|
|
244
172
|
/**
|
|
@@ -280,17 +208,7 @@ export async function readPaths (room, key, e, rels) {
|
|
|
280
208
|
else if (s.kind === 'git') fromGit.push(rel)
|
|
281
209
|
else out.set(rel, { kind: 'missing' })
|
|
282
210
|
}
|
|
283
|
-
if (fromGit.length)
|
|
284
|
-
const g = await gitOf(room, key, e)
|
|
285
|
-
const known = fromGit.filter((rel) => g.tree.has(rel))
|
|
286
|
-
const bytes = await bytesAt(g.dir, g.base, known)
|
|
287
|
-
for (const rel of fromGit) {
|
|
288
|
-
const buf = bytes.get(rel)
|
|
289
|
-
if (!buf) out.set(rel, { kind: 'missing' })
|
|
290
|
-
else if (looksBinary(buf)) out.set(rel, { kind: 'binary', buf, git: true })
|
|
291
|
-
else out.set(rel, { kind: 'text', text: buf.toString('utf8'), git: true })
|
|
292
|
-
}
|
|
293
|
-
}
|
|
211
|
+
if (fromGit.length) await gitOf(room, key, e)
|
|
294
212
|
return out
|
|
295
213
|
}
|
|
296
214
|
|
|
@@ -315,44 +233,8 @@ export async function textOf (room, key, e, rel) {
|
|
|
315
233
|
* the contents of `want` (and of every .gitattributes), hashes only for the rest. The hashes of a
|
|
316
234
|
* commit are read once and kept on the room (a few commits).
|
|
317
235
|
*/
|
|
318
|
-
export async function gitForSync (room, key, e
|
|
319
|
-
|
|
320
|
-
if (!room.gitSha1s) room.gitSha1s = new Map()
|
|
321
|
-
const ck = `${g.dir}\0${g.base}`
|
|
322
|
-
let hashes = room.gitSha1s.get(ck)
|
|
323
|
-
if (!hashes) {
|
|
324
|
-
hashes = new Map()
|
|
325
|
-
const rels = [...g.tree.keys()]
|
|
326
|
-
for (let i = 0; i < rels.length; i += 400) {
|
|
327
|
-
const part = rels.slice(i, i + 400)
|
|
328
|
-
const at = await bytesAt(g.dir, g.base, part)
|
|
329
|
-
for (const rel of part) { const b = at.get(rel); if (b) hashes.set(rel, { h: sha1(b), binary: looksBinary(b), size: b.length }) }
|
|
330
|
-
}
|
|
331
|
-
room.gitSha1s.set(ck, hashes)
|
|
332
|
-
while (room.gitSha1s.size > 4) room.gitSha1s.delete(room.gitSha1s.keys().next().value)
|
|
333
|
-
}
|
|
334
|
-
const gone = goneOf(e)
|
|
335
|
-
const files = new Map()
|
|
336
|
-
for (const [rel, x] of hashes) if (!hasEntry(e, rel) && !gone.has(rel)) files.set(rel, { h: x.h, binary: x.binary, size: x.size })
|
|
337
|
-
const read = [...new Set([...want, ...[...files.keys()].filter((rel) => path.posix.basename(rel) === '.gitattributes')])].filter((rel) => files.has(rel))
|
|
338
|
-
const at = await bytesAt(g.dir, g.base, read)
|
|
339
|
-
for (const rel of read) {
|
|
340
|
-
const b = at.get(rel)
|
|
341
|
-
if (!b) continue
|
|
342
|
-
const f = files.get(rel)
|
|
343
|
-
if (f.binary) f.buf = b; else f.text = b.toString('utf8')
|
|
344
|
-
}
|
|
345
|
-
return { base: g.base, files }
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** A diff branch's rule files that only git has (the session never changed them) at commit `sha`, matching `pattern`: [[path, text]]. */
|
|
349
|
-
export async function gitRuleFiles (dir, sha, e, pattern) {
|
|
350
|
-
if (!sha) return []
|
|
351
|
-
const r = await runGit(dir, ['ls-tree', '-r', '-z', '--name-only', sha])
|
|
352
|
-
if (!r.ok) return []
|
|
353
|
-
const rels = r.out.split('\0').filter((rel) => rel && pattern.test(rel) && !hasEntry(e, rel) && !goneOf(e).has(rel))
|
|
354
|
-
const at = await bytesAt(dir, sha, rels)
|
|
355
|
-
return rels.map((rel) => [rel, at.get(rel)]).filter(([, b]) => b && b.length <= 256 * 1024).map(([rel, b]) => [rel, b.toString('utf8')])
|
|
236
|
+
export async function gitForSync (room, key, e) {
|
|
237
|
+
return gitOf(room, key, e)
|
|
356
238
|
}
|
|
357
239
|
|
|
358
240
|
/** The rule files (ignore, attributes) branch `key` has, with their text: Map path -> text, from the session and git. */
|
|
@@ -360,12 +242,5 @@ export async function ruleFiles (room, key, e, names) {
|
|
|
360
242
|
const want = (rel) => names.includes(path.posix.basename(rel))
|
|
361
243
|
const out = new Map()
|
|
362
244
|
for (const [rel, t] of e.files) if (want(rel)) out.set(rel, t.toString())
|
|
363
|
-
if (!isDiff(e)) return out
|
|
364
|
-
let g
|
|
365
|
-
try { g = await gitOf(room, key, e) } catch { return out }
|
|
366
|
-
const gone = goneOf(e)
|
|
367
|
-
const rels = [...g.tree.keys()].filter((rel) => want(rel) && !hasEntry(e, rel) && !gone.has(rel))
|
|
368
|
-
const bytes = await bytesAt(g.dir, g.base, rels)
|
|
369
|
-
for (const rel of rels) { const b = bytes.get(rel); if (b && b.length <= 256 * 1024) out.set(rel, b.toString('utf8')) }
|
|
370
245
|
return out
|
|
371
246
|
}
|