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
package/src/merge3.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Three-way merge of text, line by line, the way git does it: changes to
|
|
2
|
+
// different lines combine on their own; the same lines changed on both sides
|
|
3
|
+
// are a conflict. Quilt never uses it to combine two different changes on its
|
|
4
|
+
// own (a person settles those): it writes the markers for Edit by hand, and
|
|
5
|
+
// lays out a settled clash with the commits around it (relay-upstream.js).
|
|
6
|
+
import { diff3Merge } from 'node-diff3'
|
|
7
|
+
|
|
8
|
+
export const MARK = { start: '<<<<<<< mine', mid: '=======', end: '>>>>>>> session' }
|
|
9
|
+
|
|
10
|
+
const lines = (s) => s.split('\n')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merges ours and theirs against base. `conflicts` is empty when the merge
|
|
14
|
+
* is clean; `text` then holds the merged file. With conflicts, `text` holds
|
|
15
|
+
* the merge with ours taken for each conflicted region (callers decide what
|
|
16
|
+
* to do with it; see withMarkers).
|
|
17
|
+
*/
|
|
18
|
+
export function merge3 (base, ours, theirs) {
|
|
19
|
+
const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
|
|
20
|
+
const out = []
|
|
21
|
+
const conflicts = []
|
|
22
|
+
for (const r of regions) {
|
|
23
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
24
|
+
const c = r.conflict
|
|
25
|
+
conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
|
|
26
|
+
out.push(...c.a)
|
|
27
|
+
}
|
|
28
|
+
return { text: out.join('\n'), conflicts }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The merge written out with git-style markers around each conflict, labelled with people's names. */
|
|
32
|
+
export function withMarkers (base, ours, theirs, names) {
|
|
33
|
+
const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
|
|
34
|
+
const out = []
|
|
35
|
+
for (const r of regions) {
|
|
36
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
37
|
+
out.push(`${MARK.start} (${names.mine})`, ...r.conflict.a, MARK.mid, ...r.conflict.b, `${MARK.end} (${names.theirs})`)
|
|
38
|
+
}
|
|
39
|
+
return out.join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when the text still holds markers that withMarkers put there. */
|
|
43
|
+
export function hasMarkers (text) {
|
|
44
|
+
return typeof text === 'string' && text.split('\n').some((l) => l.startsWith(`${MARK.start} (`) || l.startsWith(`${MARK.end} (`))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* merge3, plus the clashes where neither side lost anything, resolved the way a person would:
|
|
49
|
+
* - both sides only added lines in the same place (base had nothing there): ours, then theirs,
|
|
50
|
+
* with lines both start (or end) with written once, so two new sections under the same
|
|
51
|
+
* heading become one heading with both sections' lines;
|
|
52
|
+
* - both sides changed the same one-line `import { … } from '…'` of the same module: one import
|
|
53
|
+
* with every name either side has (a name either side removed stays removed).
|
|
54
|
+
* Anything else stays a conflict. Used where commits from elsewhere come in (upstream.js).
|
|
55
|
+
*/
|
|
56
|
+
export function mergeAdditive (base, ours, theirs) {
|
|
57
|
+
const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
|
|
58
|
+
const out = []
|
|
59
|
+
const conflicts = []
|
|
60
|
+
for (const r of regions) {
|
|
61
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
62
|
+
const c = r.conflict
|
|
63
|
+
const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
|
|
64
|
+
if (both) { out.push(...both); continue }
|
|
65
|
+
conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
|
|
66
|
+
out.push(...c.a)
|
|
67
|
+
}
|
|
68
|
+
return { text: out.join('\n'), conflicts }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
|
|
72
|
+
function addedBoth (a, b) {
|
|
73
|
+
let pre = 0
|
|
74
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
|
|
75
|
+
let post = 0
|
|
76
|
+
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
|
|
77
|
+
return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
|
|
81
|
+
|
|
82
|
+
/** Both sides changed the same one-line import of the same module: the union of its names, or null. */
|
|
83
|
+
function sameImport (o, a, b) {
|
|
84
|
+
if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
|
|
85
|
+
const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
|
|
86
|
+
if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
|
|
87
|
+
const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
|
|
88
|
+
const [no, na, nb] = [names(mo), names(ma), names(mb)]
|
|
89
|
+
const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
|
|
90
|
+
const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
|
|
91
|
+
return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const normWs = (l) => l.replace(/\s+/g, ' ').trim()
|
|
95
|
+
const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
|
|
96
|
+
const TOKEN = /\s+|\w+|[^\w\s]/g
|
|
97
|
+
|
|
98
|
+
/** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
|
|
99
|
+
function mergeWords (o, a, b) {
|
|
100
|
+
const tok = (ls) => ls.join('\n').match(TOKEN) || []
|
|
101
|
+
const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
|
|
102
|
+
if (regions.some((r) => !r.ok)) return null
|
|
103
|
+
return regions.map((r) => r.ok.join('')).join('').split('\n')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
|
|
108
|
+
* each region still clashing is tried again ignoring whitespace (a side that only reindented loses
|
|
109
|
+
* to the side that changed something), then word by word within the clashing lines (two edits to
|
|
110
|
+
* different words of one line both land); a file marked merge=union takes both sides, ours first.
|
|
111
|
+
* Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
|
|
112
|
+
* 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
|
|
113
|
+
*/
|
|
114
|
+
export function mergeSmart (base, ours, theirs, { union = false } = {}) {
|
|
115
|
+
const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
|
|
116
|
+
const out = []
|
|
117
|
+
const conflicts = []
|
|
118
|
+
let how = 'lines'
|
|
119
|
+
const rank = { lines: 0, whitespace: 1, words: 2, union: 3 }
|
|
120
|
+
const step = (h) => { if (rank[h] > rank[how]) how = h }
|
|
121
|
+
for (const r of regions) {
|
|
122
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
123
|
+
const c = r.conflict
|
|
124
|
+
if (union) { out.push(...c.a, ...c.b); step('union'); continue }
|
|
125
|
+
const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
|
|
126
|
+
if (both) { out.push(...both); continue }
|
|
127
|
+
if (sameIgnoringWs(c.a, c.b)) { out.push(...c.a); step('whitespace'); continue } // the same change, spaced differently
|
|
128
|
+
if (sameIgnoringWs(c.a, c.o)) { out.push(...c.b); step('whitespace'); continue } // ours only reindented
|
|
129
|
+
if (sameIgnoringWs(c.b, c.o)) { out.push(...c.a); step('whitespace'); continue } // theirs only reindented
|
|
130
|
+
const words = mergeWords(c.o, c.a, c.b)
|
|
131
|
+
if (words) { out.push(...words); step('words'); continue }
|
|
132
|
+
conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
|
|
133
|
+
out.push(...c.a)
|
|
134
|
+
}
|
|
135
|
+
return { text: out.join('\n'), conflicts, how }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The paths a .gitattributes text marks merge=union, as a matcher: (rel) => boolean. */
|
|
139
|
+
export function unionMatcher (texts) {
|
|
140
|
+
const patterns = []
|
|
141
|
+
for (const [dir, text] of texts) {
|
|
142
|
+
for (const line of String(text || '').split('\n')) {
|
|
143
|
+
const t = line.trim()
|
|
144
|
+
if (!t || t.startsWith('#')) continue
|
|
145
|
+
const [pat, ...attrs] = t.split(/\s+/)
|
|
146
|
+
if (!attrs.includes('merge=union')) continue
|
|
147
|
+
patterns.push({ dir, pat })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!patterns.length) return () => false
|
|
151
|
+
return (rel) => patterns.some(({ dir, pat }) => {
|
|
152
|
+
if (dir && !rel.startsWith(`${dir}/`)) return false
|
|
153
|
+
const local = dir ? rel.slice(dir.length + 1) : rel
|
|
154
|
+
const name = local.includes('/') ? local.slice(local.lastIndexOf('/') + 1) : local
|
|
155
|
+
const re = new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$')
|
|
156
|
+
return pat.includes('/') ? re.test(local.replace(/^\//, '')) : re.test(name)
|
|
157
|
+
})
|
|
158
|
+
}
|
package/src/merges.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Merge records: a file whose offline and in-session edits could not be
|
|
2
|
+
// combined on their own. Kept as plain objects in the shared doc so everyone
|
|
3
|
+
// in the session (and every AI) sees the same list and anyone can settle it.
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { isSafeRelPath } from './pathrules.js'
|
|
6
|
+
|
|
7
|
+
export const MAX_RECORD_TEXT = 200_000
|
|
8
|
+
export const MAX_MERGES = 50
|
|
9
|
+
export const DONE_TTL_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
export const KINDS = ['conflict', 'ai', 'claimed']
|
|
11
|
+
export const STATES = ['open', 'editing', 'done']
|
|
12
|
+
export const HOWS = ['mine', 'theirs', 'hand', 'agent', 'review']
|
|
13
|
+
// How the opener's side came about: pulled commits, an edit during a git command, or (null) offline.
|
|
14
|
+
export const VIAS = ['pull', 'hold']
|
|
15
|
+
|
|
16
|
+
/** What the opener did to the file, for agents and status text ("changed it offline"…). */
|
|
17
|
+
export function mergeAction (m, deleted = !!m.oursDeleted) {
|
|
18
|
+
if (m.via === 'pull') return deleted ? 'pulled commits that delete it' : 'pulled commits that change it'
|
|
19
|
+
if (m.via === 'hold') return deleted ? 'deleted it during a git command' : 'changed it during a git command'
|
|
20
|
+
return deleted ? 'deleted it offline' : 'changed it offline'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HEX_ID = /^[0-9a-f]{16}$/i
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const CONTROL_RUN = /[\u0000-\u001f\u007f]+/g
|
|
28
|
+
const str = (v, max) => typeof v === 'string' && v.length <= max
|
|
29
|
+
const optStr = (v, max) => v == null || str(v, max)
|
|
30
|
+
// Names, paths and reasons go verbatim into logs and into the prompt a coding
|
|
31
|
+
// tool gets for Send to…: one line each, no control characters.
|
|
32
|
+
const line = (v, max) => str(v, max) && !CONTROL.test(v)
|
|
33
|
+
const optLine = (v, max) => v == null || line(v, max)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A peer-written name (from an activity entry or a claim), made safe to put
|
|
37
|
+
* in a merge record: control characters and newlines become a space,
|
|
38
|
+
* whitespace is collapsed, and it's trimmed and capped at 80 characters.
|
|
39
|
+
* Falls back to 'someone' when that leaves nothing, so a stray control
|
|
40
|
+
* character in a name never suppresses the merge record itself.
|
|
41
|
+
*/
|
|
42
|
+
export function cleanName (name) {
|
|
43
|
+
const flat = String(name == null ? '' : name).replace(CONTROL_RUN, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
44
|
+
return flat || 'someone'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A record we'll show. Anything a modified client pushed that isn't this shape is ignored. */
|
|
48
|
+
export function publicMerge (v) {
|
|
49
|
+
if (!v || typeof v !== 'object') return null
|
|
50
|
+
if (typeof v.id !== 'string' || !HEX_ID.test(v.id)) return null
|
|
51
|
+
// A path any member could be made to write: inside the project, never .git or .quilt.
|
|
52
|
+
if (!line(v.path, 1024) || !isSafeRelPath(v.path)) return null
|
|
53
|
+
if (!line(v.by, 80) || !optStr(v.byId, 128)) return null
|
|
54
|
+
if (!Array.isArray(v.others) || v.others.length > 20 || !v.others.every((n) => line(n, 80))) return null
|
|
55
|
+
if (typeof v.ts !== 'number' || !Number.isFinite(v.ts)) return null
|
|
56
|
+
if (!KINDS.includes(v.kind) || !STATES.includes(v.state)) return null
|
|
57
|
+
if (!optStr(v.ours, MAX_RECORD_TEXT) || !optStr(v.base, MAX_RECORD_TEXT) || !optStr(v.theirsHash, 64)) return null
|
|
58
|
+
if (!optLine(v.claimedBy, 80) || !optLine(v.resolvedBy, 80) || !optLine(v.reason, 500)) return null
|
|
59
|
+
if (v.how != null && !HOWS.includes(v.how)) return null
|
|
60
|
+
if (v.doneTs != null && typeof v.doneTs !== 'number') return null
|
|
61
|
+
// Deleted offline: ours is gone, not merely too big to share (records from before this field have none).
|
|
62
|
+
if (v.oursDeleted != null && typeof v.oursDeleted !== 'boolean') return null
|
|
63
|
+
if (v.oursDeleted && v.ours != null) return null
|
|
64
|
+
if (v.via != null && !VIAS.includes(v.via)) return null
|
|
65
|
+
return {
|
|
66
|
+
id: v.id, path: v.path, by: v.by, byId: v.byId ?? null, others: v.others, ts: v.ts,
|
|
67
|
+
kind: v.kind, state: v.state, ours: v.ours ?? null, base: v.base ?? null, theirsHash: v.theirsHash ?? null,
|
|
68
|
+
binary: !!v.binary, local: !!v.local, oursDeleted: !!v.oursDeleted, claimedBy: v.claimedBy ?? null, resolvedBy: v.resolvedBy ?? null,
|
|
69
|
+
how: v.how ?? null, doneTs: v.doneTs ?? null, reason: v.reason ?? null, via: v.via ?? null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rank = (s) => (s === 'done' ? 1 : 0)
|
|
74
|
+
const order = (a, b) => rank(a.state) - rank(b.state) || a.ts - b.ts || (a.id < b.id ? -1 : 1)
|
|
75
|
+
|
|
76
|
+
function split (map) {
|
|
77
|
+
const valid = []
|
|
78
|
+
const junk = []
|
|
79
|
+
map.forEach((v, key) => {
|
|
80
|
+
const m = publicMerge(v)
|
|
81
|
+
if (m && m.id === key) valid.push(m)
|
|
82
|
+
else junk.push(key)
|
|
83
|
+
})
|
|
84
|
+
valid.sort(order)
|
|
85
|
+
return { valid, junk }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readMerges (map) {
|
|
89
|
+
return split(map).valid
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Opens a record. Text beyond the cap stays on the opener's disk only (`local`); `oursDeleted`: the opener deleted the file. */
|
|
93
|
+
export function openMerge (doc, map, { path, by, byId = null, others = [], kind, ours = null, oursDeleted = false, base = null, theirsHash = null, binary = false, claimedBy = null, reason = null, via = null }, origin) {
|
|
94
|
+
if (!KINDS.includes(kind)) throw new Error('bad merge kind')
|
|
95
|
+
const fits = (t) => t == null || t.length <= MAX_RECORD_TEXT
|
|
96
|
+
const local = !fits(ours) || !fits(base)
|
|
97
|
+
const rec = {
|
|
98
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
99
|
+
path, by, byId, others: others.filter((n) => n && n !== by).slice(0, 20), ts: Date.now(),
|
|
100
|
+
kind, state: 'open',
|
|
101
|
+
ours: fits(ours) ? ours : null, base: fits(base) ? base : null, theirsHash, binary: !!binary, local, oursDeleted: !!oursDeleted,
|
|
102
|
+
claimedBy, resolvedBy: null, how: null, doneTs: null, reason, via
|
|
103
|
+
}
|
|
104
|
+
const out = publicMerge(rec)
|
|
105
|
+
if (!out) throw new Error('bad merge record') // never write what readers would drop as junk
|
|
106
|
+
doc.transact(() => {
|
|
107
|
+
for (const key of split(map).junk) map.delete(key)
|
|
108
|
+
map.set(rec.id, rec)
|
|
109
|
+
}, origin)
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Changes state, how, resolvedBy or reason. Setting state to done stamps doneTs. */
|
|
114
|
+
export function updateMerge (doc, map, id, patch, origin) {
|
|
115
|
+
const cur = publicMerge(map.get(id))
|
|
116
|
+
if (!cur) throw new Error('no such merge')
|
|
117
|
+
const next = { ...cur }
|
|
118
|
+
if (patch.state !== undefined) {
|
|
119
|
+
if (!STATES.includes(patch.state)) throw new Error('bad merge state')
|
|
120
|
+
next.state = patch.state
|
|
121
|
+
if (patch.state === 'done') next.doneTs = Date.now()
|
|
122
|
+
}
|
|
123
|
+
if (patch.how !== undefined) {
|
|
124
|
+
if (patch.how != null && !HOWS.includes(patch.how)) throw new Error('bad merge how')
|
|
125
|
+
next.how = patch.how
|
|
126
|
+
}
|
|
127
|
+
if (patch.resolvedBy !== undefined) next.resolvedBy = patch.resolvedBy
|
|
128
|
+
if (patch.reason !== undefined) next.reason = patch.reason
|
|
129
|
+
const out = publicMerge(next)
|
|
130
|
+
if (!out) throw new Error('bad merge record')
|
|
131
|
+
doc.transact(() => map.set(id, next), origin)
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Drops done records older than a day, and the oldest done ones beyond MAX_MERGES. Open ones are kept. */
|
|
136
|
+
export function pruneMerges (doc, map, origin, now = Date.now()) {
|
|
137
|
+
const { valid, junk } = split(map)
|
|
138
|
+
const drop = new Set(junk)
|
|
139
|
+
const done = valid.filter((m) => m.state === 'done').sort((a, b) => (a.doneTs || 0) - (b.doneTs || 0))
|
|
140
|
+
for (const m of done) if (m.doneTs && now - m.doneTs > DONE_TTL_MS) drop.add(m.id)
|
|
141
|
+
let count = valid.length - drop.size
|
|
142
|
+
for (const m of done) {
|
|
143
|
+
if (count <= MAX_MERGES) break
|
|
144
|
+
if (!drop.has(m.id)) { drop.add(m.id); count-- }
|
|
145
|
+
}
|
|
146
|
+
if (!drop.size) return
|
|
147
|
+
doc.transact(() => { for (const id of drop) map.delete(id) }, origin)
|
|
148
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Session passes from the accounts API. A pass proves who you are to the relay
|
|
2
|
+
// for 10 minutes. Clients keep one until 2 minutes before it runs out, and while
|
|
3
|
+
// connected they fetch a fresh one every 5 minutes (see connection.js). A session
|
|
4
|
+
// asks for passes for its own room: those carry what you may do there.
|
|
5
|
+
import { readPass } from './passes.js'
|
|
6
|
+
import { apiUrl, accountFile, readAccount, resumeAccount, NOT_SIGNED_IN, SIGNED_OUT } from './account.js'
|
|
7
|
+
import { agentAccess, agentResume, readAgent } from './agent-join.js'
|
|
8
|
+
|
|
9
|
+
export const PASS_EARLY_MS = 2 * 60 * 1000
|
|
10
|
+
export const PASS_REFRESH_MS = 5 * 60 * 1000
|
|
11
|
+
// A pass request with no answer gives up after this, so the retry runs: right after a computer
|
|
12
|
+
// wakes, the network may be gone for a while and the system's own timeout can take minutes.
|
|
13
|
+
// Passes are cheap to ask for again, unlike an agent's key refresh.
|
|
14
|
+
export const PASS_TIMEOUT_MS = 10 * 1000
|
|
15
|
+
const AGENT_SIGNED_OUT = "This agent's keys stopped working. Invite it again."
|
|
16
|
+
|
|
17
|
+
/** The API turned the token away: this computer (or agent) is signed out for good. */
|
|
18
|
+
export class SignedOutError extends Error {
|
|
19
|
+
constructor (message) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.signedOut = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PassSource {
|
|
26
|
+
/** `fetchPass(room)` resolves to { pass, expiresAt }; `room` is '' for a pass that isn't for one room. */
|
|
27
|
+
constructor ({ fetchPass, now = Date.now, earlyMs = PASS_EARLY_MS, room = '' }) {
|
|
28
|
+
this.fetchPass = fetchPass
|
|
29
|
+
this.now = now
|
|
30
|
+
this.earlyMs = earlyMs
|
|
31
|
+
this.room = room
|
|
32
|
+
this.rooms = new Map() // room -> PassSource, for forRoom
|
|
33
|
+
this.current = null // { pass, expiresAt, payload }
|
|
34
|
+
this.pending = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The same account's passes for one room (kept, one per room). They carry its access there. */
|
|
38
|
+
forRoom (room) {
|
|
39
|
+
if (!room || room === this.room) return this
|
|
40
|
+
if (!this.rooms.has(room)) this.rooms.set(room, new PassSource({ fetchPass: this.fetchPass, now: this.now, earlyMs: this.earlyMs, room }))
|
|
41
|
+
return this.rooms.get(room)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A pass with at least `earlyMs` left, fetching one when needed. */
|
|
45
|
+
get () {
|
|
46
|
+
if (this.current && this.now() < this.current.expiresAt - this.earlyMs) return Promise.resolve(this.current.pass)
|
|
47
|
+
return this.fresh()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Always asks for a new pass. Calls made while one is on its way share it. */
|
|
51
|
+
fresh () {
|
|
52
|
+
if (!this.pending) {
|
|
53
|
+
this.pending = Promise.resolve()
|
|
54
|
+
.then(() => this.fetchPass(this.room))
|
|
55
|
+
.then(({ pass, expiresAt }) => {
|
|
56
|
+
this.current = { pass, expiresAt, payload: readPass(pass) }
|
|
57
|
+
return pass
|
|
58
|
+
})
|
|
59
|
+
.finally(() => { this.pending = null })
|
|
60
|
+
}
|
|
61
|
+
return this.pending
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A pass issued after this call. One already on its way may have been issued before
|
|
66
|
+
* something changed (the relay asking for a fresh pass after the owner changed our
|
|
67
|
+
* access): wait for it, then ask again.
|
|
68
|
+
*/
|
|
69
|
+
newer () {
|
|
70
|
+
return this.pending ? this.pending.catch(() => {}).then(() => this.fresh()) : this.fresh()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Forgets the cached pass (the relay turned it away), so the next get() fetches one. */
|
|
74
|
+
forget () {
|
|
75
|
+
this.current = null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Who the passes are for ({ sub, kind, name, key, … }), once one has been fetched. */
|
|
79
|
+
get payload () {
|
|
80
|
+
return this.current ? this.current.payload : null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function requestPass (fetchImpl, api, bearer, signedOutMessage, room = '', timeoutMs = PASS_TIMEOUT_MS) {
|
|
85
|
+
let res
|
|
86
|
+
try {
|
|
87
|
+
const body = room ? { headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' }, body: JSON.stringify({ room }) } : { headers: { authorization: `Bearer ${bearer}` } }
|
|
88
|
+
res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/passes`, { method: 'POST', ...body, signal: AbortSignal.timeout(timeoutMs) })
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw new Error(`Couldn't reach Quilt (${err.name === 'TimeoutError' ? 'ETIMEDOUT' : err.cause?.code || err.message}).`)
|
|
91
|
+
}
|
|
92
|
+
const body = await res.json().catch(() => null)
|
|
93
|
+
if (res.status === 401) throw new SignedOutError(signedOutMessage)
|
|
94
|
+
if (!res.ok || !body || typeof body.pass !== 'string') throw Object.assign(new Error(body?.error || `Quilt answered ${res.status}.`), { status: res.status })
|
|
95
|
+
return body
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Passes for this computer's account, from its qd_ token. A token turned away isn't the
|
|
100
|
+
* end: another app on this computer may have signed in again since (its token is in
|
|
101
|
+
* account.json), or the computer signs back in with its key. Only a computer that's no
|
|
102
|
+
* longer linked gets SignedOutError.
|
|
103
|
+
*/
|
|
104
|
+
export function personPasses ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch, now, file = accountFile(), resume = resumeAccount, timeoutMs } = {}) {
|
|
105
|
+
let current = token
|
|
106
|
+
// Whose sign-in this is: a sign-in for someone else since (a different account) doesn't carry on these passes.
|
|
107
|
+
const first = readAccount(file)
|
|
108
|
+
const who = first && first.token === token ? first.account.id : null
|
|
109
|
+
const same = (a) => a && (!who || a.account.id === who)
|
|
110
|
+
return new PassSource({
|
|
111
|
+
now,
|
|
112
|
+
fetchPass: async (room) => {
|
|
113
|
+
try {
|
|
114
|
+
return await requestPass(fetchImpl, api, current, SIGNED_OUT, room, timeoutMs)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (!err.signedOut) throw err
|
|
117
|
+
const saved = readAccount(file)
|
|
118
|
+
if (saved && saved.token !== current) {
|
|
119
|
+
if (!same(saved)) throw err
|
|
120
|
+
current = saved.token
|
|
121
|
+
} else {
|
|
122
|
+
const back = await resume({ api, fetch: fetchImpl, file })
|
|
123
|
+
if (!same(back)) throw err
|
|
124
|
+
current = back.token
|
|
125
|
+
}
|
|
126
|
+
return requestPass(fetchImpl, api, current, SIGNED_OUT, room, timeoutMs)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Passes for a saved agent, from its access key (refreshed with its refresh key when it runs out). */
|
|
133
|
+
export function agentPasses ({ name, dir, fetch: fetchImpl = globalThis.fetch, now } = {}) {
|
|
134
|
+
return new PassSource({
|
|
135
|
+
now,
|
|
136
|
+
fetchPass: async (room) => {
|
|
137
|
+
let saved
|
|
138
|
+
try {
|
|
139
|
+
saved = await agentAccess({ name, dir, fetch: fetchImpl, now })
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err.status === 401) throw new SignedOutError(err.message)
|
|
142
|
+
throw err
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return await requestPass(fetchImpl, saved.api, saved.accessKey, AGENT_SIGNED_OUT, room)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (!err.signedOut) throw err
|
|
148
|
+
// Keys revoked before the access key ran out: the agent signs back in with its resume key.
|
|
149
|
+
let back
|
|
150
|
+
try {
|
|
151
|
+
back = await agentResume({ name, dir, accessKey: saved.accessKey, fetch: fetchImpl })
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e.status === 401) throw new SignedOutError(e.message)
|
|
154
|
+
throw e
|
|
155
|
+
}
|
|
156
|
+
if (!back) throw err
|
|
157
|
+
return requestPass(fetchImpl, back.api, back.accessKey, AGENT_SIGNED_OUT, room)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What a session started here signs in with: a saved agent's passes and key, or
|
|
165
|
+
* this computer's account. `name` is the name saved on this computer, so a
|
|
166
|
+
* session can start before (or without, when offline) its first pass.
|
|
167
|
+
*/
|
|
168
|
+
export function sessionPasses ({ agent = null, dir } = {}) {
|
|
169
|
+
if (agent) {
|
|
170
|
+
const saved = readAgent({ name: agent, dir })
|
|
171
|
+
return { passes: agentPasses({ name: agent, dir }), identity: saved.identity, kind: 'agent', name: saved.name || agent }
|
|
172
|
+
}
|
|
173
|
+
const account = readAccount()
|
|
174
|
+
if (!account) throw new Error(NOT_SIGNED_IN)
|
|
175
|
+
return { passes: personPasses({ token: account.token }), identity: null, kind: 'human', name: account.account.name || null }
|
|
176
|
+
}
|
package/src/passes.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Session passes: short-lived tickets the accounts API signs and the relay checks.
|
|
2
|
+
// A pass says who you are (an account or an agent), your name, and the identity
|
|
3
|
+
// key you'll prove when you connect (empty for a hosted agent, which only ever
|
|
4
|
+
// talks to the relay over HTTPS and never proves a key). Its format is
|
|
5
|
+
// base64url(JSON payload) + "." + base64url(Ed25519 signature of the first part).
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import { generateIdentity, parsePublicKey } from './identity.js'
|
|
8
|
+
|
|
9
|
+
export const PASS_VERSION = 1
|
|
10
|
+
export const PASS_TTL_MS = 10 * 60 * 1000
|
|
11
|
+
const MAX_NAME = 64
|
|
12
|
+
const KINDS = ['person', 'agent']
|
|
13
|
+
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
14
|
+
|
|
15
|
+
/** A new signing pair, encoded like identity keys: SPKI and PKCS8 DER, in base64url. */
|
|
16
|
+
export function newPassKeys () {
|
|
17
|
+
return generateIdentity()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const privateKeyOf = (k) => typeof k === 'string' ? crypto.createPrivateKey({ key: Buffer.from(k, 'base64url'), format: 'der', type: 'pkcs8' }) : k
|
|
21
|
+
const publicKeyOf = (k) => typeof k === 'string' ? parsePublicKey(k) : k
|
|
22
|
+
|
|
23
|
+
/** The public half of a PASS_SIGNING_KEY, as QUILT_PASS_PUBLIC_KEY takes it. */
|
|
24
|
+
export function passPublicKey (signingKey) {
|
|
25
|
+
return crypto.createPublicKey(privateKeyOf(signingKey)).export({ type: 'spki', format: 'der' }).toString('base64url')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function signPass (payload, signingKey) {
|
|
29
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
30
|
+
const sig = crypto.sign(null, Buffer.from(body), privateKeyOf(signingKey)).toString('base64url')
|
|
31
|
+
return `${body}.${sig}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A pass's payload without checking it (clients read their name from it). Null if malformed. */
|
|
35
|
+
export function readPass (pass) {
|
|
36
|
+
const [body, sig, extra] = String(pass ?? '').split('.')
|
|
37
|
+
if (!body || !sig || extra !== undefined) return null
|
|
38
|
+
try {
|
|
39
|
+
const p = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
|
|
40
|
+
return p && typeof p === 'object' && !Array.isArray(p) ? p : null
|
|
41
|
+
} catch { return null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The payload when the signature verifies, it's version 1, it hasn't expired and
|
|
46
|
+
* its fields make sense; otherwise null.
|
|
47
|
+
*/
|
|
48
|
+
export function verifyPass (pass, key, { now = Date.now() } = {}) {
|
|
49
|
+
const k = publicKeyOf(key)
|
|
50
|
+
const p = readPass(pass)
|
|
51
|
+
if (!k || !p) return null
|
|
52
|
+
const [body, sig] = String(pass).split('.')
|
|
53
|
+
let signed = false
|
|
54
|
+
try { signed = crypto.verify(null, Buffer.from(body), k, Buffer.from(sig, 'base64url')) } catch {}
|
|
55
|
+
if (!signed || p.v !== PASS_VERSION) return null
|
|
56
|
+
if (typeof p.exp !== 'number' || p.exp <= now) return null
|
|
57
|
+
if (typeof p.sub !== 'string' || !p.sub || !KINDS.includes(p.kind)) return null
|
|
58
|
+
if (typeof p.name !== 'string' || !p.name.trim() || p.name.length > MAX_NAME) return null
|
|
59
|
+
// A room pass is good in that room only (see access in session-access.js).
|
|
60
|
+
if (p.room !== undefined && (typeof p.room !== 'string' || !ROOM.test(p.room))) return null
|
|
61
|
+
// No key: an HTTP-only pass (a hosted agent, see relay-mcp.js); it can't open a WebSocket.
|
|
62
|
+
if (typeof p.key !== 'string' || (p.key && !parsePublicKey(p.key))) return null
|
|
63
|
+
return p
|
|
64
|
+
}
|
package/src/pathrules.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Which paths are synced, and which are safe to accept from a peer. No Node
|
|
2
|
+
// APIs here: the web app uses the same rules as the CLI.
|
|
3
|
+
import ignore from 'ignore'
|
|
4
|
+
|
|
5
|
+
export const MAX_TEXT_BYTES = 2 * 1024 * 1024
|
|
6
|
+
export const MAX_BINARY_BYTES = 8 * 1024 * 1024
|
|
7
|
+
// Binary files this big or bigger are stored encrypted outside the session document.
|
|
8
|
+
export const LARGE_FILE_BYTES = 256 * 1024
|
|
9
|
+
// The most a stored file may be (the relay may allow less).
|
|
10
|
+
export const MAX_STORED_BINARY_BYTES = 100 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
// Never synced, regardless of .gitignore. .env files are excluded so secrets
|
|
13
|
+
// stay on each person's machine; Claude Code worktrees are whole private
|
|
14
|
+
// copies of the project.
|
|
15
|
+
export const ALWAYS_IGNORED = [
|
|
16
|
+
'.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
|
|
17
|
+
// Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
|
|
18
|
+
'.claude/settings.local.json',
|
|
19
|
+
// Build output and caches: large, machine-specific, and rebuilt by each person.
|
|
20
|
+
'.next', '.turbo', '.nuxt', '.svelte-kit', '.parcel-cache', '.vercel',
|
|
21
|
+
'.env', '.env.*', '!.env.example',
|
|
22
|
+
// A .quiltignore is each person's own list of what stays on their machine, so it stays there too.
|
|
23
|
+
'.quiltignore', '.cowoveignore',
|
|
24
|
+
'*.swp', '*.swo', '*~', '.#*'
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Files whose patterns say what isn't synced, in the folder they sit in. */
|
|
28
|
+
export const IGNORE_FILES = ['.gitignore', '.quiltignore', '.cowoveignore']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rewrites an ignore file found in subfolder `dir` so its patterns apply from
|
|
32
|
+
* the project root, the way git scopes a nested .gitignore to its folder.
|
|
33
|
+
*/
|
|
34
|
+
export function scopeIgnore (text, dir) {
|
|
35
|
+
if (!dir) return text
|
|
36
|
+
return text.split(/\r?\n/).map((line) => {
|
|
37
|
+
if (!line.trim() || line.startsWith('#')) return line
|
|
38
|
+
const neg = line.startsWith('!')
|
|
39
|
+
const body = neg ? line.slice(1) : line
|
|
40
|
+
// A slash anywhere but the end anchors the pattern to this folder.
|
|
41
|
+
const anchored = body.replace(/\/+\s*$/, '').includes('/')
|
|
42
|
+
const scoped = anchored ? `${dir}/${body.replace(/^\//, '')}` : `${dir}/**/${body}`
|
|
43
|
+
return (neg ? '!' : '') + scoped
|
|
44
|
+
}).join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* An ignore matcher from the built-ins plus the text of .gitignore / .quiltignore
|
|
49
|
+
* files. The built-ins are kept apart from the project patterns, so a shared
|
|
50
|
+
* .gitignore saying "!.env" can't make anyone's secrets sync.
|
|
51
|
+
*/
|
|
52
|
+
export function makeIgnore (texts = []) {
|
|
53
|
+
const builtin = ignore().add(ALWAYS_IGNORED)
|
|
54
|
+
const project = ignore()
|
|
55
|
+
for (const t of texts) if (t) project.add(t)
|
|
56
|
+
return {
|
|
57
|
+
add (text) { project.add(text); return this },
|
|
58
|
+
ignores (p) { return builtin.ignores(p) || project.ignores(p) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isIgnored (ig, rel) {
|
|
63
|
+
if (!rel || rel === '.') return false
|
|
64
|
+
// Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
|
|
65
|
+
const parts = rel.split('/')
|
|
66
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
67
|
+
const sub = parts.slice(0, i).join('/')
|
|
68
|
+
if (ig.ignores(sub)) return true
|
|
69
|
+
if (i < parts.length && ig.ignores(sub + '/')) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Names Windows reserves for devices, with any extension: a member there could never write them.
|
|
75
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
|
76
|
+
const utf8Length = (s) => new TextEncoder().encode(s).length
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validates a path received from a peer. Rejects anything that could escape
|
|
80
|
+
* the project folder or touch git internals, and names that some member's
|
|
81
|
+
* disk could not hold (a change nobody can write is a change nobody should
|
|
82
|
+
* share).
|
|
83
|
+
*/
|
|
84
|
+
export function isSafeRelPath (rel) {
|
|
85
|
+
if (typeof rel !== 'string' || !rel || rel.length > 1024) return false
|
|
86
|
+
if (rel.includes('\\') || rel.includes('\0')) return false
|
|
87
|
+
if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false
|
|
88
|
+
const parts = rel.split('/')
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
if (part === '' || part === '.' || part === '..') return false
|
|
91
|
+
if (WINDOWS_DEVICE.test(part) || part.endsWith('.') || part.endsWith(' ')) return false
|
|
92
|
+
if (part.length > 255 || utf8Length(part) > 255) return false
|
|
93
|
+
}
|
|
94
|
+
const first = parts[0].toLowerCase()
|
|
95
|
+
if (first === '.git' || first === '.quilt' || first === '.cowove') return false
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether two claim patterns can cover the same file (paths: files that exist now). */
|
|
100
|
+
export function patternsOverlap (a, b, paths = []) {
|
|
101
|
+
if (a === b) return true
|
|
102
|
+
const ma = globMatcher(a)
|
|
103
|
+
const mb = globMatcher(b)
|
|
104
|
+
return ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Converts a simple glob (*, **, ?) or a plain path/folder into a matcher. */
|
|
108
|
+
export function globMatcher (pattern) {
|
|
109
|
+
let p = pattern.trim().replace(/^\.\//, '')
|
|
110
|
+
if (!/[*?]/.test(p)) {
|
|
111
|
+
const base = p.replace(/\/+$/, '')
|
|
112
|
+
return (rel) => rel === base || rel.startsWith(base + '/')
|
|
113
|
+
}
|
|
114
|
+
let re = ''
|
|
115
|
+
for (let i = 0; i < p.length; i++) {
|
|
116
|
+
const c = p[i]
|
|
117
|
+
if (c === '*' && p[i + 1] === '*') {
|
|
118
|
+
re += '.*'
|
|
119
|
+
i++
|
|
120
|
+
if (p[i + 1] === '/') i++
|
|
121
|
+
} else if (c === '*') re += '[^/]*'
|
|
122
|
+
else if (c === '?') re += '[^/]'
|
|
123
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
124
|
+
}
|
|
125
|
+
const rx = new RegExp(`^${re}$`)
|
|
126
|
+
return (rel) => rx.test(rel)
|
|
127
|
+
}
|