@heyquilt/seam 0.142.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/seam.js +20 -1
- package/lib/quilts/attest.js +22 -8
- package/lib/quilts/block.js +43 -8
- package/lib/quilts/claims.js +39 -5
- package/lib/quilts/cli.js +15 -4
- package/lib/quilts/client.js +2 -0
- package/lib/quilts/conflict-format.js +27 -2
- package/lib/quilts/describe.js +4 -1
- package/lib/quilts/export-git.js +5 -3
- package/lib/quilts/tools.js +10 -7
- package/package.json +1 -1
package/bin/seam.js
CHANGED
|
@@ -20,9 +20,11 @@ Docs: https://seam.heyquilt.com/docs
|
|
|
20
20
|
seam login [--no-browser] Sign in with your Seam account (seam.heyquilt.com)
|
|
21
21
|
seam logout | seam whoami
|
|
22
22
|
seam computers [list | signout <id or name>] computers signed in to your account (seam login)
|
|
23
|
+
seam handle [<new>] your name in your repos' addresses (<handle>/<repo>); with <new>, change it
|
|
23
24
|
seam account delete --confirm <your email> delete your Seam account (your patches stay)
|
|
24
25
|
seam agent new <name> | list | key <name> | revoke <name> | relink [agent:<id>] your agents and their keys
|
|
25
26
|
seam oauth client new <name> --redirect <uri> | list | revoke <client_id> apps that ask people for access (OAuth 2, PKCE)
|
|
27
|
+
seam oauth allowed [list | revoke <grant id>] the apps you allowed into your account, and ending one
|
|
26
28
|
seam quilts [new <slug> | show <q>] Your quilts (repositories)
|
|
27
29
|
seam import git <path> [--into <slug>] [--authors <file>]
|
|
28
30
|
seam github [import <owner/name> --installation <id> [--into <slug>]] from GitHub, followed
|
|
@@ -97,6 +99,7 @@ Docs: https://seam.heyquilt.com/docs
|
|
|
97
99
|
seam org log <org> [-n <count>] [--before <id>] what happened in an org: members, roles, teams, quilts (owners and admins)
|
|
98
100
|
seam org team <org> <name> [<member>...] an org's team, named org_team:<name> in its quilts' protections
|
|
99
101
|
seam quilts new <slug> --org <org> | quilts move --quilt <q> --org <org> a quilt that belongs to an org
|
|
102
|
+
seam quilts rename <new-slug> --quilt <q> [--title <name>] a new address (<owner>/<new-slug>); the old one stays an alias
|
|
100
103
|
seam quilts agents-make-lines on|off whether agents may make lines, tags and releases
|
|
101
104
|
seam quilts public on|off anyone may read the quilt, signed in or not
|
|
102
105
|
seam quilts add <email|member> [--level read|write|admin] add a member; an email with no account yet is invited
|
|
@@ -131,6 +134,13 @@ async function main () {
|
|
|
131
134
|
if (cmd === 'whoami') { const a = readAccount(); if (!a) { process.exitCode = 1; return console.log('Not signed in.') } return console.log(`${a.account.name} (${a.account.email})`) }
|
|
132
135
|
if (cmd === 'agent') return agentCommand(argv[0], argv[1])
|
|
133
136
|
if (cmd === 'computers') return computersCommand(argv[0], argv[1])
|
|
137
|
+
// Your handle (11.2, 2026-10-29): your repos are <handle>/<repo>, on the site and in every command.
|
|
138
|
+
if (cmd === 'handle') {
|
|
139
|
+
const go = signedIn()
|
|
140
|
+
if (!argv[0]) { const me = await go('GET', '/v1/me'); return console.log(me.account?.handle ? `${me.account.handle}: your repos are ${me.account.handle}/<repo>.` : 'Your account has no handle on this Seam server.') }
|
|
141
|
+
const r = await go('PUT', '/v1/me/handle', { handle: argv[0] })
|
|
142
|
+
return console.log(`Your handle is ${r.handle}: your repos are ${r.handle}/<repo> now, and their old addresses stop working.`)
|
|
143
|
+
}
|
|
134
144
|
if (cmd === 'account') {
|
|
135
145
|
const i = argv.indexOf('--confirm')
|
|
136
146
|
if (argv[0] !== 'delete' || i < 0 || !argv[i + 1]) fail('seam account delete --confirm <your email>')
|
|
@@ -177,7 +187,16 @@ async function oauthCommand (argv) {
|
|
|
177
187
|
}
|
|
178
188
|
}
|
|
179
189
|
const [what, sub, ...rest] = argv
|
|
180
|
-
|
|
190
|
+
// The apps you allowed (GET /v1/me/grants): what each may do; revoking one ends its tokens.
|
|
191
|
+
if (what === 'allowed') {
|
|
192
|
+
if (sub === 'revoke') { if (!rest[0]) fail('seam oauth allowed revoke <grant id>'); await go('DELETE', `/v1/me/grants/${encodeURIComponent(rest[0])}`); return console.log(`Revoked: the app's tokens from that approval have ended. It asks you again to come back.`) }
|
|
193
|
+
if (sub && sub !== 'list') fail(`seam oauth allowed: '${sub}' is not a command (list, revoke).`)
|
|
194
|
+
const { grants } = await go('GET', '/v1/me/grants')
|
|
195
|
+
if (!grants.length) return console.log('You have allowed no apps.')
|
|
196
|
+
for (const g of grants) console.log(`${g.id} ${g.client.name} ${g.scopes.map((x) => { const q = g.quilts.find((y) => x === `quilt:${y.id}:${y.level}`); return q ? `${q.slug || q.id}:${q.level}` : x }).join(' ')}`)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (what !== 'client') fail('seam oauth client new <name> --redirect <uri> | list | revoke <client_id> | seam oauth allowed [list | revoke <grant id>]')
|
|
181
200
|
if (sub === 'new') {
|
|
182
201
|
const redirects = []; const words = []
|
|
183
202
|
for (let i = 0; i < rest.length; i++) { if (rest[i] === '--redirect') redirects.push(rest[++i]); else words.push(rest[i]) }
|
package/lib/quilts/attest.js
CHANGED
|
@@ -53,27 +53,40 @@ const keyIdOf = (raw) => 'ed25519:' + crypto.createHash('sha256').update(raw).di
|
|
|
53
53
|
* The server's attestation key from SEAM_ATTESTATION_KEY (an Ed25519 private key: PKCS8 PEM, or
|
|
54
54
|
* base64 of its PKCS8 DER), or a new one for this process when none is given.
|
|
55
55
|
*/
|
|
56
|
-
export
|
|
56
|
+
export const attestationKey = (secret) => ed25519Key(secret, 'SEAM_ATTESTATION_KEY')
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An Ed25519 signing key from a setting's value (PKCS8 PEM, or base64 of its PKCS8 DER), or a new
|
|
60
|
+
* one for this process when none is given: { id, public_key, ephemeral, sign(text) → base64 }.
|
|
61
|
+
*/
|
|
62
|
+
export function ed25519Key (secret, setting) {
|
|
57
63
|
let key
|
|
58
64
|
if (secret) {
|
|
59
65
|
const s = String(secret).trim()
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
try {
|
|
67
|
+
key = s.includes('BEGIN') ? crypto.createPrivateKey(s) : crypto.createPrivateKey({ key: Buffer.from(s, 'base64'), format: 'der', type: 'pkcs8' })
|
|
68
|
+
} catch { throw new Error(`${setting} is not an Ed25519 private key (PKCS8 PEM or base64 DER)`) }
|
|
69
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new Error(`${setting} is not an Ed25519 private key`)
|
|
62
70
|
} else key = crypto.generateKeyPairSync('ed25519').privateKey
|
|
63
71
|
const raw = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length)
|
|
64
72
|
return { id: keyIdOf(raw), public_key: raw.toString('base64'), ephemeral: !secret, sign: (text) => crypto.sign(null, Buffer.from(text), key).toString('base64') }
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
/** Whether `sig` (bytes) is an Ed25519 signature of `text` by the raw public key `publicKey` (base64). */
|
|
76
|
+
export function ed25519Verify (publicKey, text, sig) {
|
|
77
|
+
try {
|
|
78
|
+
const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(String(publicKey), 'base64')]), format: 'der', type: 'spki' })
|
|
79
|
+
return crypto.verify(null, Buffer.from(text), pub, sig)
|
|
80
|
+
} catch { return false }
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
/** Whether `a` is a good attestation of `row` under one of `keys` ([{ id, public_key }]). */
|
|
68
84
|
export function verifyAttestation (a, row, keys) {
|
|
69
85
|
if (!a) return 'unsigned'
|
|
70
86
|
if (a.seq !== row.seq || a.kind !== row.kind || a.row_hash !== rowHash(row)) return 'mismatch'
|
|
71
87
|
const k = keys.find((x) => x.id === a.key_id)
|
|
72
88
|
if (!k) return 'unknown_key'
|
|
73
|
-
|
|
74
|
-
const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(k.public_key, 'base64')]), format: 'der', type: 'spki' })
|
|
75
|
-
if (!crypto.verify(null, Buffer.from(attestationText(a)), pub, Buffer.from(String(a.sig), 'base64'))) return 'bad_signature'
|
|
76
|
-
} catch { return 'bad_signature' }
|
|
89
|
+
if (!ed25519Verify(k.public_key, attestationText(a), Buffer.from(String(a.sig), 'base64'))) return 'bad_signature'
|
|
77
90
|
return a.late ? 'late' : 'ok'
|
|
78
91
|
}
|
|
79
92
|
|
|
@@ -188,7 +201,8 @@ export function attestationOps (svc, attester) {
|
|
|
188
201
|
* against `keys` (pinned by the caller) or the server's published keys.
|
|
189
202
|
*/
|
|
190
203
|
export async function verifyQuilt (client, quilt, { after = 0, keys = null } = {}) {
|
|
191
|
-
|
|
204
|
+
// The published keys also hold the want-ticket key (tickets.js), which never signs a log row.
|
|
205
|
+
const pinned = keys || (await client.attestationKeys()).keys.filter((k) => !k.use || k.use === 'attestation')
|
|
192
206
|
const rows = []; const atts = []
|
|
193
207
|
let from = Number(after) || 0
|
|
194
208
|
for (;;) {
|
package/lib/quilts/block.js
CHANGED
|
@@ -18,7 +18,7 @@ import { TreeReader, decodeNode, EMPTY_TREE } from './tree.js'
|
|
|
18
18
|
import { globMatcher } from './claims.js'
|
|
19
19
|
import { theCas } from './cas.js'
|
|
20
20
|
import { fastMerge } from './fastmerge.js'
|
|
21
|
-
import { diffMarkers, gitMarkers } from './conflict-format.js'
|
|
21
|
+
import { diffMarkers, gitMarkers, findMarkers } from './conflict-format.js'
|
|
22
22
|
|
|
23
23
|
/** How much of a large file is read at a time (10, streaming the cut). */
|
|
24
24
|
export const READ_PIECE = 1024 * 1024
|
|
@@ -590,7 +590,8 @@ function settleRecord (block, m) {
|
|
|
590
590
|
/**
|
|
591
591
|
* The files a move left conflicted (planMerges) that are not settled yet, sorted. A file is settled
|
|
592
592
|
* once its markers are gone (edited, deleted, restored), or, for one kept whole, once it matches
|
|
593
|
-
* the folder's record; a settled one leaves block.conflicts (and block.json is saved).
|
|
593
|
+
* the folder's record; a settled one leaves block.conflicts (and block.json is saved). Markers of
|
|
594
|
+
* any kind left in it (its tag edited away, git's typed by hand) keep it open (07 ruling 2026-10-29).
|
|
594
595
|
*/
|
|
595
596
|
export function pullConflicts (dir, block) {
|
|
596
597
|
const c = block.conflicts
|
|
@@ -600,7 +601,7 @@ export function pullConflicts (dir, block) {
|
|
|
600
601
|
let dropped = false
|
|
601
602
|
for (const [p, x] of Object.entries(c)) {
|
|
602
603
|
let still = false
|
|
603
|
-
if (x.tags?.length) { let bytes = null; try { bytes = fs.readFileSync(absOf(dir, p)) } catch {} still = !!bytes && x.tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`))) }
|
|
604
|
+
if (x.tags?.length) { let bytes = null; try { bytes = fs.readFileSync(absOf(dir, p)) } catch {} still = !!bytes && (x.tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`))) || !!findMarkers(bytes)) }
|
|
604
605
|
if (!still && x.kind === 'whole') { changed ??= new Set(localChanges(dir, block).map((ch) => ch.path)); still = changed.has(p) }
|
|
605
606
|
if (still) { open.push(p); continue }
|
|
606
607
|
for (const t of x.tags || []) fs.rmSync(savedOurs(dir, t), { force: true })
|
|
@@ -612,6 +613,34 @@ export function pullConflicts (dir, block) {
|
|
|
612
613
|
return open.sort(cmpPath)
|
|
613
614
|
}
|
|
614
615
|
|
|
616
|
+
/**
|
|
617
|
+
* As the server refuses it (service.js, 07 ruling 2026-10-29): a conflicted file among `paths` (a
|
|
618
|
+
* term from the server, or a conflict a move left) that no longer carries its tag but still holds
|
|
619
|
+
* conflict markers is refused, `markers_left`, naming the file and line. Here too for a sealed
|
|
620
|
+
* quilt, whose server cannot read the file.
|
|
621
|
+
*/
|
|
622
|
+
function refuseMarkersLeft (dir, block, paths, verb) {
|
|
623
|
+
for (const p of paths) {
|
|
624
|
+
const term = block.files[p]?.term
|
|
625
|
+
const tags = [...(block.conflicts?.[p]?.tags || []), ...(term ? [term.slice(0, 12)] : [])]
|
|
626
|
+
if (!tags.length) continue
|
|
627
|
+
let bytes = null
|
|
628
|
+
try { bytes = fs.readFileSync(absOf(dir, p)) } catch { continue }
|
|
629
|
+
if (tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`)))) continue
|
|
630
|
+
const m = findMarkers(bytes)
|
|
631
|
+
if (m) throw Object.assign(new Error(`${p}:${m.line} still holds conflict markers (${m.text}), so it does not settle the conflict: edit every marker block out, or seam resolve ${p} --ours | --theirs, then ${verb}.`), { code: 'markers_left', path: p, line: m.line })
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** 07 ruling 2026-10-29: markers in a file with no conflict are committed as written, with a warning. */
|
|
636
|
+
function withLocalMarkers (block, changes, r) {
|
|
637
|
+
const found = changes.filter((c) => c.bytes && !block.files[c.path]?.term).map((c) => ({ c, m: findMarkers(c.bytes) })).filter((x) => x.m)
|
|
638
|
+
if (!found.length) return r
|
|
639
|
+
const have = new Set((r.warnings || []).filter((w) => w.kind === 'markers_in_file').map((w) => w.path))
|
|
640
|
+
const more = found.filter((x) => !have.has(x.c.path)).map((x) => ({ kind: 'markers_in_file', path: x.c.path, line: x.m.line, local: true }))
|
|
641
|
+
return more.length ? { ...r, warnings: [...(r.warnings || []), ...more] } : r
|
|
642
|
+
}
|
|
643
|
+
|
|
615
644
|
/** Refuses to commit (or move) while `paths` hold a conflict a move left. */
|
|
616
645
|
function refuseConflicted (open, verb) {
|
|
617
646
|
if (!open.length) return
|
|
@@ -782,7 +811,9 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
|
|
|
782
811
|
if (!block.square && !isLineCheckout(block)) throw new Error('This folder holds one revision, not a line; open a square (seam square open) to commit.')
|
|
783
812
|
const changes = localChanges(dir, block, { paths })
|
|
784
813
|
if (!changes.length) return { nothing: true }
|
|
785
|
-
|
|
814
|
+
const open = pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p))
|
|
815
|
+
refuseMarkersLeft(dir, block, changes.filter((c) => c.status !== 'deleted').map((c) => c.path), 'commit')
|
|
816
|
+
refuseConflicted(open, 'commit')
|
|
786
817
|
// A clone (a checkout of a line): the first commit opens a square by itself, titled by the
|
|
787
818
|
// message and based on the commit the folder holds, so it undoes nothing main gained since.
|
|
788
819
|
const opened = block.square ? null : await openForCommit(client, dir, block, message)
|
|
@@ -820,7 +851,7 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
|
|
|
820
851
|
block.at = r.square.head
|
|
821
852
|
block.head = r.square.head
|
|
822
853
|
saveBlock(dir, block)
|
|
823
|
-
const out = withLocalScan(block, changes, allow, r)
|
|
854
|
+
const out = withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
|
|
824
855
|
if (opened) out.opened = opened
|
|
825
856
|
return out
|
|
826
857
|
}
|
|
@@ -849,7 +880,9 @@ async function openForCommit (client, dir, block, message) {
|
|
|
849
880
|
*/
|
|
850
881
|
export async function pull (client, dir, block) {
|
|
851
882
|
// As git refuses to pull over unmerged files: the last pull's conflicts are settled first.
|
|
852
|
-
|
|
883
|
+
const open = pullConflicts(dir, block)
|
|
884
|
+
refuseMarkersLeft(dir, block, open, 'pull again')
|
|
885
|
+
refuseConflicted(open, 'pull again')
|
|
853
886
|
if (block.square) {
|
|
854
887
|
// The square was merged (or abandoned) from elsewhere: the folder goes to its line's tip.
|
|
855
888
|
const sq = await client.square(block.quilt, block.square)
|
|
@@ -884,7 +917,9 @@ export async function toLineTip (client, dir, block, line, tip) {
|
|
|
884
917
|
export async function amendFolder (client, dir, block, { message, paths = [], allow = [] }) {
|
|
885
918
|
if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
|
|
886
919
|
const changes = localChanges(dir, block, { paths })
|
|
887
|
-
|
|
920
|
+
const open = pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p))
|
|
921
|
+
refuseMarkersLeft(dir, block, changes.filter((c) => c.status !== 'deleted').map((c) => c.path), 'amend')
|
|
922
|
+
refuseConflicted(open, 'amend')
|
|
888
923
|
const blobs = changes.filter((c) => c.bytes || c.open).map((c) => c.open ? { kind: 'blob', id: c.blob, size: c.size, open: c.open } : { kind: 'blob', id: c.blob, bytes: block.sealed ? block._codec.seal(c.bytes) : c.bytes })
|
|
889
924
|
if (blobs.length) await client.upload(block.quilt, blobs)
|
|
890
925
|
const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
|
|
@@ -897,7 +932,7 @@ export async function amendFolder (client, dir, block, { message, paths = [], al
|
|
|
897
932
|
block.at = r.square.head
|
|
898
933
|
block.head = r.square.head
|
|
899
934
|
saveBlock(dir, block)
|
|
900
|
-
return withLocalScan(block, changes, allow, r)
|
|
935
|
+
return withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
|
|
901
936
|
}
|
|
902
937
|
|
|
903
938
|
/** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
|
package/lib/quilts/claims.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// "this square is working on these paths" so nobody else spends work on them. A write under a
|
|
3
3
|
// claimed pattern from another open square on the same line is refused (412 claim_refused) and the
|
|
4
4
|
// writer is told who holds it and why. A claim lasts while its square is open: merging, abandoning
|
|
5
|
-
// or archiving the square lets go of it. Merges never check claims
|
|
6
|
-
// written before
|
|
5
|
+
// or archiving the square lets go of it. Merges never check claims, and a claim never refuses work
|
|
6
|
+
// written before it existed: that work commits, and overlapping work still meets at the merge (07).
|
|
7
7
|
import { QuiltError, squareShort } from './errors.js'
|
|
8
8
|
import { uuidv7 } from './ids.js'
|
|
9
9
|
|
|
@@ -200,20 +200,54 @@ export async function liveClaims (store, q, at = Date.now(), idleMs = 0) {
|
|
|
200
200
|
return out
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** Whether live entry or tree entry `e` holds the same change as `ch` (a put of the same blob and mode, or a delete). */
|
|
204
|
+
const sameChange = (e, ch) => ch.op === 'delete' ? !!e && e.op === 'delete' : !!e && e.op === ch.op && e.blob === ch.blob && (e.mode ?? null) === (ch.mode ?? null)
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 06 "Where a claim is checked" (ruling 2026-10-29): whether square `sq`'s change `ch` was written
|
|
208
|
+
* before claim `c` was granted, so the claim does not refuse it. It was when the square's live work
|
|
209
|
+
* already held the same change from before the claim's `at` (each live entry carries the time it was
|
|
210
|
+
* written, `at`; older rows only the row's), or when the square's head already holds it (`held`:
|
|
211
|
+
* resending it is not a new write).
|
|
212
|
+
*/
|
|
213
|
+
async function writtenBefore (ch, c, live, held) {
|
|
214
|
+
if (!ch) return false
|
|
215
|
+
const e = (live?.changes || []).find((x) => x.path === ch.path)
|
|
216
|
+
if (sameChange(e, ch) && Number(e.at ?? live.at ?? Infinity) < Number(c.at || 0)) return true
|
|
217
|
+
if (held) {
|
|
218
|
+
const h = await held(ch.path)
|
|
219
|
+
if (ch.op === 'delete' ? !h : (h && h.blob === ch.blob && h.mode === ch.mode)) return true
|
|
220
|
+
}
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
/**
|
|
204
225
|
* Refuses a write to `paths` from square `sq` when another open square on its line claims one.
|
|
205
226
|
* Given the `writer` (a commit, not live work, which repeats), the refusal is also written to the
|
|
206
|
-
* square's log so the writer hears claim.refused (12) wherever it reads events.
|
|
227
|
+
* square's log so the writer hears claim.refused (12) wherever it reads events. Given the write's
|
|
228
|
+
* `changes`, a path whose change was written before the claim existed is not refused (06): the
|
|
229
|
+
* square's live work held it from before the claim's `at`, or (`held(path)`, the head's entry) the
|
|
230
|
+
* head already has it. `live` is the square's live row when the caller has read it already.
|
|
207
231
|
*/
|
|
208
|
-
export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null) {
|
|
232
|
+
export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null, { changes = null, held = null, live } = {}) {
|
|
209
233
|
if (!paths.length) return
|
|
210
234
|
// A stacked square writes under its parents' claims (06): it builds on their work.
|
|
211
235
|
const mine = new Set([sq.id])
|
|
212
236
|
for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
|
|
213
237
|
const others = (await liveClaims(store, q, at, idleMs)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
|
|
238
|
+
const byPath = changes ? new Map(changes.map((ch) => [ch.path, ch])) : null
|
|
214
239
|
for (const c of others) {
|
|
215
240
|
const m = globMatcher(c.pattern)
|
|
216
|
-
|
|
241
|
+
let hit = null
|
|
242
|
+
for (const p of paths) {
|
|
243
|
+
if (!m(p)) continue
|
|
244
|
+
if (byPath) {
|
|
245
|
+
if (live === undefined) live = store.getLive ? await store.getLive(q, sq.id) : null
|
|
246
|
+
if (await writtenBefore(byPath.get(p), c, live, held)) continue
|
|
247
|
+
}
|
|
248
|
+
hit = p
|
|
249
|
+
break
|
|
250
|
+
}
|
|
217
251
|
if (hit) {
|
|
218
252
|
if (writer) {
|
|
219
253
|
const pos = queueOf(await store.claimRequests(q), c).findIndex((r) => r.by_square === sq.id)
|
package/lib/quilts/cli.js
CHANGED
|
@@ -147,6 +147,12 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
|
|
|
147
147
|
const r = await client.setQuiltOwner(needQuilt(), flags['--org'])
|
|
148
148
|
return print(r, `${r.quilt} now belongs to ${r.org}.`)
|
|
149
149
|
}
|
|
150
|
+
if (pos[0] === 'rename') {
|
|
151
|
+
// seam quilts rename <new-slug> --quilt <q> [--title <name>]: its address is <owner>/<new-slug> after.
|
|
152
|
+
if (!pos[1] && !flags['--title']) throw new Error('seam quilts rename <new-slug> --quilt <q> [--title <name>]')
|
|
153
|
+
const r = await client.renameQuilt(needQuilt(), { ...(pos[1] ? { slug: pos[1] } : {}), ...(flags['--title'] ? { name: flags['--title'] } : {}) })
|
|
154
|
+
return print(r, r.slug === r.was ? `${r.slug} is now called ${r.name}.` : `${r.was} is now ${r.path || r.slug}${r.name !== r.slug ? ` (${r.name})` : ''}. Links by its id keep working; its old address doesn't. In a block folder of it, nothing changes: the block names it by id.`)
|
|
155
|
+
}
|
|
150
156
|
if (pos[0] === 'sealed') {
|
|
151
157
|
if (!['on', 'off'].includes(pos[1])) throw new Error('seam quilts sealed on|off --quilt <q>')
|
|
152
158
|
const r = await client.setSealed(needQuilt(), pos[1] === 'on')
|
|
@@ -313,10 +319,10 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
|
|
|
313
319
|
const moved = r.amended.filter((a) => a.paths.length).map((a) => `${a.paths.join(', ')} into ${a.commit_before.slice(0, 12)}`)
|
|
314
320
|
return print(r, `${moved.length ? `Absorbed ${moved.join('; ')} (${r.amended.length} commit(s) rewritten).` : 'Nothing to absorb.'}${r.left_live ? ` Still live: ${r.left_paths.join(', ')}.` : ''}`)
|
|
315
321
|
}
|
|
316
|
-
// seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> | --by claim [--stacked] [--title t]
|
|
322
|
+
// seam square fold [sq] --into sq:<id> [--no-commit-live] | split [sq] <paths,...> <paths,...> | --by claim [--stacked] [--title t]
|
|
317
323
|
if (sub === 'fold') {
|
|
318
324
|
if (!flags['--into']) throw new Error('Fold into which square? seam square fold [sq:from] --into sq:<id>')
|
|
319
|
-
const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message })
|
|
325
|
+
const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message, ...(flags['--no-commit-live'] ? { commit_live: false } : {}) })
|
|
320
326
|
return print(r, describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`))
|
|
321
327
|
}
|
|
322
328
|
if (sub === 'split') {
|
|
@@ -1097,8 +1103,13 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
|
|
|
1097
1103
|
case 'export': {
|
|
1098
1104
|
// To a git remote, on the server, after each merge (14): seam export remote <url> | status.
|
|
1099
1105
|
if (pos[0] === 'remote' && pos[1]) {
|
|
1100
|
-
const r = await client.setExport(needQuilt(), { target: pos[1], lines: flags['--line'] ? String(flags['--line']).split(',') : undefined, releases: !!flags['--releases'], credential: flags['--credential'] || undefined, scratch: !!flags['--scratch'], enabled: !flags['--off'], lfs: flags['--lfs-url'] ? { url: String(flags['--lfs-url']), ...(flags['--lfs-threshold'] != null ? { threshold_bytes: Number(flags['--lfs-threshold']) } : {}) } : undefined })
|
|
1101
|
-
return print(r, r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge${r.releases ? ', with release tags' : ''}${r.lfs ? `, files over ${r.lfs.threshold_bytes} bytes to LFS at ${r.lfs.url}` : ''}. seam export status` : `The export to ${r.target} is off.`)
|
|
1106
|
+
const r = await client.setExport(needQuilt(), { target: pos[1], lines: flags['--line'] ? String(flags['--line']).split(',') : undefined, releases: !!flags['--releases'], credential: flags['--credential'] || undefined, scratch: !!flags['--scratch'], enabled: !flags['--off'], lfs: flags['--lfs-url'] ? { url: String(flags['--lfs-url']), ...(flags['--lfs-threshold'] != null ? { threshold_bytes: Number(flags['--lfs-threshold']) } : {}) } : undefined, main_as: flags['--main-as'] || undefined, push: flags['--push'] || undefined, exclude: flags['--exclude'] ? String(flags['--exclude']).split(',').map((x) => x.trim()).filter(Boolean) : undefined })
|
|
1107
|
+
return print(r, r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} ${r.push === 'tag' ? 'up to the newest tagged patch' : 'after each merge'}${r.main_as && r.main_as !== 'main' ? `, main as ${r.main_as}` : ''}${r.releases ? ', with release tags' : ''}${r.exclude?.length ? `, without ${r.exclude.join(', ')}` : ''}${r.lfs ? `, files over ${r.lfs.threshold_bytes} bytes to LFS at ${r.lfs.url}` : ''}. seam export status` : `The export to ${r.target} is off.`)
|
|
1108
|
+
}
|
|
1109
|
+
// Runs the export now (admins): after fixing a remote, or without waiting for the next retry.
|
|
1110
|
+
if (pos[0] === 'push') {
|
|
1111
|
+
const r = await client.exportPush(needQuilt())
|
|
1112
|
+
return print(r, `Pushing to ${r.target} now. seam export status`)
|
|
1102
1113
|
}
|
|
1103
1114
|
if (pos[0] === 'status') {
|
|
1104
1115
|
const r = await client.exportStatus(needQuilt())
|
package/lib/quilts/client.js
CHANGED
|
@@ -345,6 +345,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
345
345
|
setOrgTeam: (o, name, members) => call('PUT', `/v1/orgs/${enc(o)}/teams/${enc(name)}`, { members }),
|
|
346
346
|
setOrgMember: (o, member, role) => String(member).includes('@') ? call('POST', `/v1/orgs/${enc(o)}/members`, { email: member, role }) : call('PUT', `/v1/orgs/${enc(o)}/members/${enc(member)}`, { role }),
|
|
347
347
|
setQuiltOwner: (q, org) => call('PUT', `${Q(q)}/owner`, { org }),
|
|
348
|
+
renameQuilt: (q, body) => call('PATCH', Q(q), body),
|
|
348
349
|
setAgentsMakeLines: (q, allow) => call('PUT', `${Q(q)}/agents-make-lines`, { allow }),
|
|
349
350
|
setPublic: (q, value) => call('PUT', `${Q(q)}/public`, { public: value }),
|
|
350
351
|
setSealed: (q, value) => call('PUT', `${Q(q)}/sealed`, { sealed: value }),
|
|
@@ -372,6 +373,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
372
373
|
importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
|
|
373
374
|
setExport: (q, b) => call('PUT', `${Q(q)}/export`, b),
|
|
374
375
|
exportStatus: (q) => call('GET', `${Q(q)}/export`),
|
|
376
|
+
exportPush: (q) => call('POST', `${Q(q)}/export/push`, {}),
|
|
375
377
|
startImport: (b) => call('POST', '/v1/imports', b),
|
|
376
378
|
github: () => call('GET', '/v1/github'),
|
|
377
379
|
importGithub: (b) => call('POST', '/v1/github/import', b),
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// side's change laid onto the first side, `ontoFirst`) only the clashing regions are marked and the
|
|
5
5
|
// rest of the file is merged text; a deleted side is an empty side labelled "(deleted by <who>)". Also the `git` format (diff3 markers with the `|||||||` base section, two sides
|
|
6
6
|
// only). Every block's first line carries the term's tag, so a
|
|
7
|
-
// file committed with these markers is still conflicted (service.js, block.js).
|
|
7
|
+
// file committed with these markers is still conflicted (service.js, block.js). findMarkers finds
|
|
8
|
+
// markers of any kind (no tag, an older tag, git's), which never settle a conflict (07 ruling 2026-10-29).
|
|
8
9
|
import { diff3Merge } from 'node-diff3'
|
|
9
|
-
import { hunks, splitLines } from './textdiff.js'
|
|
10
|
+
import { hunks, splitLines, isText } from './textdiff.js'
|
|
10
11
|
|
|
11
12
|
/** `old` → `now` as `-`/`+`/` ` lines (every line of both, in order). */
|
|
12
13
|
export function lineDiff (old, now) {
|
|
@@ -264,3 +265,27 @@ export function resolveMarkers (regions, k) {
|
|
|
264
265
|
const lines = regions.flatMap((r) => r.ok || r.sides[k].lines)
|
|
265
266
|
return lines.length ? lines.join('\n') + '\n' : ''
|
|
266
267
|
}
|
|
268
|
+
|
|
269
|
+
// A conflict marker line: exactly seven `<` or `>` (Seam's and git's opening and closing lines), or
|
|
270
|
+
// git's `|||||||` base line, then a space or the line's end. `=======`, `+++++++` and `%%%%%%%`
|
|
271
|
+
// alone are not markers: a markdown underline or a diff's own lines look like them.
|
|
272
|
+
const MARKER = /^(?:<{7}|>{7}|\|{7})(?: |$)/
|
|
273
|
+
const NEEDLES = ['<<<<<<<', '>>>>>>>', '|||||||'].map((s) => Buffer.from(s))
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The first conflict marker in a file, as `{ line (1-based), text }`, or null. Markers count only
|
|
277
|
+
* at the start of a line (a CR before the newline is ignored); a file that is not text (binary,
|
|
278
|
+
* over 8 MiB) has none. Whatever their tag (the current term's, an older one's, none), markers in
|
|
279
|
+
* a conflicted file mean it is not settled (07 ruling 2026-10-29; service.js, block.js).
|
|
280
|
+
*/
|
|
281
|
+
export function findMarkers (bytes) {
|
|
282
|
+
if (!bytes || !bytes.length) return null
|
|
283
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
284
|
+
if (!NEEDLES.some((n) => buf.includes(n)) || !isText(buf)) return null
|
|
285
|
+
const lines = buf.toString('utf8').split('\n')
|
|
286
|
+
for (let i = 0; i < lines.length; i++) {
|
|
287
|
+
const l = lines[i].endsWith('\r') ? lines[i].slice(0, -1) : lines[i]
|
|
288
|
+
if (MARKER.test(l)) return { line: i + 1, text: l.length > 80 ? l.slice(0, 80) + '…' : l }
|
|
289
|
+
}
|
|
290
|
+
return null
|
|
291
|
+
}
|
package/lib/quilts/describe.js
CHANGED
|
@@ -140,7 +140,10 @@ export function describeWrite (r, verb = 'Done') {
|
|
|
140
140
|
if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
|
|
141
141
|
const reused = (r.combined || []).filter((c) => c.how === 'reuse')
|
|
142
142
|
if (reused.length) bits.push(`Settled the way it was settled before: ${reused.map((c) => `${c.path} (by ${c.by})`).join(', ')}`)
|
|
143
|
-
for (const w of r.warnings || [])
|
|
143
|
+
for (const w of r.warnings || []) {
|
|
144
|
+
if (w.kind === 'markers_in_file') bits.push(`Warning: ${w.path}:${w.line} holds conflict markers; it was committed as written${w.local ? ' (found on this computer)' : ''}`)
|
|
145
|
+
else bits.push(`Warning: ${w.path}${w.line ? `:${w.line}` : ''} looks like a secret (${w.rule}, ${w.id})${w.local ? ', found on this computer' : ''}`)
|
|
146
|
+
}
|
|
144
147
|
return bits.join('\n')
|
|
145
148
|
}
|
|
146
149
|
|
package/lib/quilts/export-git.js
CHANGED
|
@@ -61,9 +61,10 @@ async function fetchBlobs (client, q, ids) {
|
|
|
61
61
|
* missing). Returns { exported, patch_no, sha, branch }. With `sign` (export-sign.js exportKey), every
|
|
62
62
|
* commit carries an SSH signature with that key. With `lfs` ({ threshold, upload(bytes) → pointer
|
|
63
63
|
* text }, export-lfs.js), a file over the threshold or named by a `filter=lfs` line of the root
|
|
64
|
-
* `.gitattributes` is written as an LFS pointer and its bytes uploaded.
|
|
64
|
+
* `.gitattributes` is written as an LFS pointer and its bytes uploaded. With `exclude` (a function
|
|
65
|
+
* of a path), the paths it names are left out of every exported tree (14, `exclude`).
|
|
65
66
|
*/
|
|
66
|
-
export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, sign = null, lfs = null, log = () => {} }) {
|
|
67
|
+
export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, sign = null, lfs = null, exclude = null, log = () => {} }) {
|
|
67
68
|
branch = branch || line
|
|
68
69
|
const gitDir = openRepo(dir)
|
|
69
70
|
const stateFile = path.join(gitDir, 'quilt-export.json')
|
|
@@ -89,7 +90,7 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
|
|
|
89
90
|
if (version !== null && version !== r.version) throw new Error(`export format ${r.version} differs from the ${version} this branch was exported with: export to a new branch`)
|
|
90
91
|
version = r.version; quiltId = r.quilt
|
|
91
92
|
if (!r.patches.length) break
|
|
92
|
-
const blobs = await fetchBlobs(client, q, r.patches.flatMap((p) => p.changes.filter((c) => c.op === 'put').map((c) => c.blob)))
|
|
93
|
+
const blobs = await fetchBlobs(client, q, r.patches.flatMap((p) => p.changes.filter((c) => c.op === 'put' && !(exclude && exclude(c.path))).map((c) => c.blob)))
|
|
93
94
|
const marksFile = path.join(gitDir, 'quilt-export.marks')
|
|
94
95
|
const fi = fastImport(dir, marksFile)
|
|
95
96
|
const send = async (s) => { const w = fi.write(s); if (w) await w }
|
|
@@ -116,6 +117,7 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
|
|
|
116
117
|
if (attrs) lfsMatch = attrs.op === 'delete' ? null : lfsPatterns(blobs.get(attrs.blob)?.toString('utf8'))
|
|
117
118
|
}
|
|
118
119
|
for (const c of p.changes) {
|
|
120
|
+
if (exclude && exclude(c.path)) continue
|
|
119
121
|
const bytes = lfs && c.op === 'put' && (!c.mode || c.mode === 'file' || c.mode === 'exec') && c.path !== '.gitattributes' ? blobs.get(c.blob) : null
|
|
120
122
|
if (bytes && (bytes.length > lfs.threshold || lfsMatch?.ignores(c.path))) {
|
|
121
123
|
const pointer = await lfs.upload(bytes)
|
package/lib/quilts/tools.js
CHANGED
|
@@ -19,7 +19,7 @@ import { rebuild as rebuildBlock, conflictStyle } from './block.js'
|
|
|
19
19
|
import { describeRebuild, describeOpened, describeBoth } from './describe.js'
|
|
20
20
|
import { status as blockStatus, commitFolder, amendFolder, syncTo, pull, toLineTip, checkout, restore, reset, clean, localChanges, bytesOf, livePatch, resolvePulled } from './block.js'
|
|
21
21
|
|
|
22
|
-
export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_github', 'seam_github_import', 'seam_import_status', 'seam_import_authors', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_join', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_stack', 'seam_migrations_dir', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_protections', 'seam_protections_set', 'seam_team_set', 'seam_verify', 'seam_redact', 'seam_redactions', 'seam_seal', 'seam_secret_scanning', 'seam_public', 'seam_sealed', 'seam_policy', 'seam_line_policy_versions', 'seam_orgs', 'seam_org_log', 'seam_org_domain', 'seam_apps', 'seam_app', 'seam_app_key', 'seam_app_manager', 'seam_app_install', 'seam_app_request', 'seam_org_member', 'seam_org_team', 'seam_agent_token_new', 'seam_agent_token_list', 'seam_agent_token_revoke', 'seam_agents_make_lines', 'seam_checks', 'seam_check_rerun', 'seam_reparent', 'seam_archive', 'seam_unarchive', 'seam_hide', 'seam_unhide', 'seam_square_edit', 'seam_share', 'seam_unshare', 'seam_export_tarball', 'seam_claim_request', 'seam_claim_withdraw', 'seam_claim_handoff', 'seam_claim_finish', 'seam_blocks', 'seam_block_open', 'seam_block_touch', 'seam_block_close', 'seam_ancestor', 'seam_contains', 'seam_pipeline', 'seam_watch', 'seam_unwatch', 'seam_absorb', 'seam_interdiff', 'seam_apply_suggestion', 'seam_infra_paths', 'seam_owner_idle_hours', 'seam_public_reads', 'seam_export_archive', 'seam_metrics', 'seam_daemon', 'seam_block_rebuild', 'seam_clone', 'seam_pull', 'seam_member_add']
|
|
22
|
+
export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_github', 'seam_github_import', 'seam_import_status', 'seam_import_authors', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_export_push', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_join', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_stack', 'seam_migrations_dir', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_protections', 'seam_protections_set', 'seam_team_set', 'seam_verify', 'seam_redact', 'seam_redactions', 'seam_seal', 'seam_secret_scanning', 'seam_public', 'seam_sealed', 'seam_policy', 'seam_line_policy_versions', 'seam_orgs', 'seam_org_log', 'seam_org_domain', 'seam_apps', 'seam_app', 'seam_app_key', 'seam_app_manager', 'seam_app_install', 'seam_app_request', 'seam_org_member', 'seam_org_team', 'seam_agent_token_new', 'seam_agent_token_list', 'seam_agent_token_revoke', 'seam_agents_make_lines', 'seam_checks', 'seam_check_rerun', 'seam_reparent', 'seam_archive', 'seam_unarchive', 'seam_hide', 'seam_unhide', 'seam_square_edit', 'seam_share', 'seam_unshare', 'seam_export_tarball', 'seam_claim_request', 'seam_claim_withdraw', 'seam_claim_handoff', 'seam_claim_finish', 'seam_blocks', 'seam_block_open', 'seam_block_touch', 'seam_block_close', 'seam_ancestor', 'seam_contains', 'seam_pipeline', 'seam_watch', 'seam_unwatch', 'seam_absorb', 'seam_interdiff', 'seam_apply_suggestion', 'seam_infra_paths', 'seam_owner_idle_hours', 'seam_public_reads', 'seam_export_archive', 'seam_metrics', 'seam_daemon', 'seam_block_rebuild', 'seam_clone', 'seam_pull', 'seam_member_add']
|
|
23
23
|
|
|
24
24
|
/** What every agent is told about source control, in each MCP's instructions. */
|
|
25
25
|
export const SEAM_GUIDE = 'Quilt source control (quilts): a quilt is a repository on the Quilt server; you work in a square (your own branch and working copy), commit save points in it, and merge it into main, where it becomes a patch. ' +
|
|
@@ -71,6 +71,7 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
71
71
|
seam_export_archive: 'Writes the whole quilt as one .tar (admin) to a file on this computer: every log segment, every pack and the members, with a manifest of sizes and BLAKE3s; out (default <quilt>-<row>.seam.tar). seam export check <file> checks it offline. Over HTTP: POST /v1/quilts/:q/exit/archive.',
|
|
72
72
|
seam_export_tarball: 'Writes one commit as a .tar.gz (what git archive makes) to a file on this computer: rev (default main), paths to take only part, out (default <quilt>-<commit>.tar.gz in this folder). The same commit always gives the same bytes. Over HTTP: GET /v1/quilts/:q/tarball/:rev?paths=.',
|
|
73
73
|
seam_export_status: 'The export to a git remote: its target, lines, state (idle, ok, retrying, refused), each line\'s last exported patch and sha, and the last error.',
|
|
74
|
+
seam_export_push: 'Runs the export to a git remote now (admins), without waiting for the next merge or retry.',
|
|
74
75
|
seam_import_cutover: 'Ends a following import for good (admins): a last pass, a check that the remote\'s tips equal the imported ones, then main and release/* take merges in Seam and main exports to the source after each merge. Refuses with remote_moved if git moved.',
|
|
75
76
|
seam_import_status: 'An import from a git remote: its state (reading, awaiting_authors, following, done, failed), its lines with their git tips, its last error, and while it waits the authors with the proposed member of each.',
|
|
76
77
|
seam_import_authors: 'Answers an import waiting in awaiting_authors (admins): a map of git author email → member id of the quilt; the import then runs. An email left out becomes a stub member. Refuses an id that is not a member (unknown_author).',
|
|
@@ -138,7 +139,7 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
138
139
|
seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff, square.restacked (your stacked square was moved onto its parent\'s new head or the line, with how many conflicts it now holds), check.requested (you run a check a line names you for: run it on the commit and report with seam_check and its run_key). Pass after (the cursor from the last answer) to get only new ones. Pass wait (seconds, up to 30) to wait for the next one instead of answering at once.',
|
|
139
140
|
seam_events_webhook: 'Has the quilt server POST your events to a URL (signed with HMAC-SHA256 over <timestamp>.<body>, retried 3 times), so you are woken instead of polling: url, events (default all), secret (made for you if left out). url: "off" removes them all. add: true keeps your other webhooks (up to 5); remove: true removes the one at url.',
|
|
140
141
|
seam_absorb: 'Puts your square\'s uncommitted live work into the commit of the square that last changed each file (amending it and rewriting the commits above it), so a fix to commit 2 of 5 needs no manual rebase. Files no commit of the square changed stay live.',
|
|
141
|
-
seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Overlapping changes become conflict markers in the target.',
|
|
142
|
+
seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Live work it shared (seam_live_set) is committed on it first and goes along (commit_live: false refuses with live_work instead). Overlapping changes become conflict markers in the target.',
|
|
142
143
|
seam_split: 'Splits your square: by paths (groups: [[paths], ...]) or by claim (one group per claim on the square that covers a changed path; the claim and its holder go with it). Each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square. stacked: true chains the new squares on this one in order instead of opening them side by side.',
|
|
143
144
|
seam_log: 'History, newest first: rev (default main, or your square, or a..b), paths to limit it, limit (default 20). follow: true with one path follows the file through its renames; search: "<text>" keeps only commits that changed how many times the text appears (git log -S); match: "<regex>" keeps commits whose added or removed lines match (git log -G). message (with message_regex, ignore_case) filters on the commit message (git log --grep); author (member id or part of a name), on_behalf_of, task, since and until (an ISO date, or 2h, 3d, 1w) filter on who, what for and when. group_by (author, on_behalf_of, task, tool) gives counts with lines added and removed instead of commits; graph: true names the square each merge brought in; include_hidden: true shows a square\'s commits replaced by an amend. When there is more, pass the answer\'s cursor for the next page. Each patch on main is one line\'s merge. With one path on a line, existed says whether it was ever there and, if it is gone now, last_present is the last patch it was there in.',
|
|
144
145
|
seam_describe: 'Names a revision (rev, default main) by the nearest tag behind it: v1.0 on the tag itself, v1.0-3-<commit> three commits past it, the commit alone when no tag is behind it.',
|
|
@@ -200,10 +201,11 @@ export const SEAM_INPUTS = {
|
|
|
200
201
|
seam_github: {},
|
|
201
202
|
seam_github_import: { repo: z.string().max(201).describe('owner/name, as seam_github lists it'), installation: z.string().max(20).describe('The installation id seam_github shows for it'), into: z.string().max(63).optional().describe('The new quilt\'s slug (default the repo\'s name)'), follow: z.boolean().optional().describe('Keep following it (default true)') },
|
|
202
203
|
seam_import_remote: { url: z.string().max(2000).describe('The git remote: https://, ssh:// or user@host:path'), into: z.string().max(63).describe('The new quilt\'s slug'), follow: z.boolean().optional().describe('Keep following main and release/* until cut-over'), interval: z.number().int().min(60).optional().describe('Seconds between follow passes (default 600)'), credential: z.string().max(100).optional().describe('The name of the server secret that reads the remote (user:token or a token)'), authors: z.record(z.string(), z.string()).optional().describe('Git author email → member id of the quilt'), ask_authors: z.boolean().optional().describe('Wait in awaiting_authors with the authors and a proposed map until seam_import_authors answers') },
|
|
203
|
-
seam_export_remote: { quilt: z.string().max(100).optional().describe('The quilt'), target: z.string().max(2000).describe('The git remote'), lines: z.array(z.string().max(100)).max(20).optional().describe('Lines to export (default ["main"]); release/* for every release line'), releases: z.boolean().optional().describe('Push release tags too'), credential: z.string().max(100).optional().describe('The name of the server secret that may push'), scratch: z.boolean().optional().describe('A scratch remote nothing pins a sha from'), enabled: z.boolean().optional().describe('false turns the export off'), lfs: z.object({ url: z.string().max(2000).describe('The LFS endpoint (…/info/lfs)'), threshold_bytes: z.number().int().min(0).optional().describe('Files over this many bytes go to LFS (default 50 MiB)') }).optional().describe('Git LFS on the remote: big files and filter=lfs paths go as LFS pointers. Set before the first export') },
|
|
204
|
+
seam_export_remote: { quilt: z.string().max(100).optional().describe('The quilt'), target: z.string().max(2000).describe('The git remote'), lines: z.array(z.string().max(100)).max(20).optional().describe('Lines to export (default ["main"]); release/* for every release line'), releases: z.boolean().optional().describe('Push release tags too'), credential: z.string().max(100).optional().describe('The name of the server secret that may push'), scratch: z.boolean().optional().describe('A scratch remote nothing pins a sha from'), enabled: z.boolean().optional().describe('false turns the export off'), lfs: z.object({ url: z.string().max(2000).describe('The LFS endpoint (…/info/lfs)'), threshold_bytes: z.number().int().min(0).optional().describe('Files over this many bytes go to LFS (default 50 MiB)') }).optional().describe('Git LFS on the remote: big files and filter=lfs paths go as LFS pointers. Set before the first export'), main_as: z.string().max(100).optional().describe('The branch name main has on the remote (default main), such as master or trunk'), push: z.enum(['patch', 'tag']).optional().describe('patch (default): after every merged patch; tag: only up to the newest patch a tag or release points at'), exclude: z.array(z.string().max(500)).max(100).optional().describe('Paths left out of the exported tree: a folder ending in /, a file or a glob. Set before the first export') },
|
|
204
205
|
seam_export_archive: { quilt: z.string().max(100).optional().describe('The quilt'), out: z.string().max(1000).optional() },
|
|
205
206
|
seam_export_tarball: { quilt: z.string().max(100).optional().describe('The quilt'), rev: z.string().max(200).optional(), paths: z.array(z.string().max(1000)).max(100).optional(), out: z.string().max(1000).optional() },
|
|
206
207
|
seam_export_status: { quilt: z.string().max(100).optional().describe('The quilt') },
|
|
208
|
+
seam_export_push: { quilt: z.string().max(100).optional().describe('The quilt') },
|
|
207
209
|
seam_import_cutover: { id: z.string().max(100).describe('The import id'), credential: z.string().max(100).optional().describe('The server secret that may push to the source (default the import\'s)') },
|
|
208
210
|
seam_import_status: { id: z.string().max(100).describe('The import id') },
|
|
209
211
|
seam_import_authors: { id: z.string().max(100).describe('The import id'), authors: z.record(z.string(), z.string()).describe('Git author email → member id of the quilt') },
|
|
@@ -304,7 +306,7 @@ export const SEAM_INPUTS = {
|
|
|
304
306
|
seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), wait: z.number().int().min(0).max(30).optional(), kinds: z.array(z.enum(['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'square.restacked', 'check.requested', 'check.cancelled', 'check.culprit', 'check.verdict', 'watch.triggered', 'policy.needed', 'review.comment', 'review.decision', 'review.requested', 'review.stale', 'claim.granted', 'claim.refused', 'claim.expired', 'claim.handoff', 'export.state'])).optional() },
|
|
305
307
|
seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'square.restacked', 'check.requested', 'check.cancelled', 'check.culprit', 'check.verdict', 'watch.triggered', 'policy.needed', 'review.comment', 'review.decision', 'review.requested', 'review.stale', 'claim.granted', 'claim.refused', 'claim.expired', 'claim.handoff', 'export.state'])).optional(), secret: z.string().min(16).max(200).optional(), add: z.boolean().optional(), remove: z.boolean().optional() },
|
|
306
308
|
seam_absorb: { quilt: QUILT, square: SQUARE },
|
|
307
|
-
seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional() },
|
|
309
|
+
seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional(), commit_live: z.boolean().optional().describe('Default true: the square\'s shared live work is committed on it first and folded with it; false refuses (live_work) while it has any') },
|
|
308
310
|
seam_split: { quilt: QUILT, square: SQUARE, by: z.enum(['paths', 'claim']).optional().describe('paths (the default) or claim'), groups: z.array(z.array(z.string().max(1000)).min(1).max(2000)).min(1).max(20).optional().describe('Paths (files or directories) per new square, for by: paths'), titles: z.array(z.string().max(200)).max(20).optional(), stacked: z.boolean().optional().describe('Chain the new squares on this one in order') },
|
|
309
311
|
seam_merge: { quilt: QUILT, square: SQUARE, from: z.string().max(200).optional().describe('Merge this square (sq:…) or revision into your square instead of merging your square into a line'), into: z.string().max(100).optional().describe('The line, default main'), message: z.string().max(65536).optional(), dry_run: z.boolean().optional(), on_conflict: z.enum(['rebase', 'leave']).optional(), commit_live: z.boolean().optional().describe('Default true: your square\'s shared live work (seam_live_set) is committed first and merged with it; false merges the committed head as it stands') },
|
|
310
312
|
seam_rebase: { quilt: QUILT, square: SQUARE, onto: REV },
|
|
@@ -503,7 +505,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
|
|
|
503
505
|
return `${moved.length ? `Absorbed ${moved.join('; ')} (${r.amended.length} commit(s) rewritten).` : 'Nothing to absorb.'}${r.left_live ? ` Still live: ${r.left_paths.join(', ')}.` : ''}` + (r.amended.length ? await sync(r.square.head, r.square) : '')
|
|
504
506
|
}
|
|
505
507
|
case 'seam_fold': {
|
|
506
|
-
const r = await client.fold(needQuilt(), needSquare(), { into: args.into, message: args.message, ...(inFolder ? { expect_head: block.head } : {}) })
|
|
508
|
+
const r = await client.fold(needQuilt(), needSquare(), { into: args.into, message: args.message, ...(args.commit_live === false ? { commit_live: false } : {}), ...(inFolder ? { expect_head: block.head } : {}) })
|
|
507
509
|
return describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`)
|
|
508
510
|
}
|
|
509
511
|
case 'seam_split': {
|
|
@@ -530,7 +532,8 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
|
|
|
530
532
|
}
|
|
531
533
|
case 'seam_github_import': { const r = await client.importGithub({ installation_id: args.installation, repo: args.repo, slug: args.into, follow: args.follow !== false }); return `Importing ${args.repo} into ${r.id ? (args.into || args.repo.split('/')[1]) : '?'} (import ${r.id})${r.follow ? ', following it' : ''}.` }
|
|
532
534
|
case 'seam_import_remote': { const r = await client.startImport({ url: args.url, slug: args.into, follow: !!args.follow, interval: args.interval, credential: args.credential, authors: args.authors, ask_authors: !!args.ask_authors }); return `Importing ${args.url} into ${args.into} (import ${r.id})${r.ask_authors ? ', waiting for its authors first (seam_import_status, then seam_import_authors)' : ''}${r.follow ? `, following every ${r.interval_s} s; poke secret ${r.poke_secret} (shown once)` : ''}.` }
|
|
533
|
-
case 'seam_export_remote': { const r = await client.setExport(needQuilt(), { target: args.target, lines: args.lines, releases: args.releases, credential: args.credential, scratch: args.scratch, enabled: args.enabled !== false, lfs: args.lfs }); return r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge.` : `The export to ${r.target} is off.` }
|
|
535
|
+
case 'seam_export_remote': { const r = await client.setExport(needQuilt(), { target: args.target, lines: args.lines, releases: args.releases, credential: args.credential, scratch: args.scratch, enabled: args.enabled !== false, lfs: args.lfs, main_as: args.main_as, push: args.push, exclude: args.exclude }); return r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} ${r.push === 'tag' ? 'up to the newest tagged patch' : 'after each merge'}.` : `The export to ${r.target} is off.` }
|
|
536
|
+
case 'seam_export_push': { const r = await client.exportPush(needQuilt()); return `Pushing to ${r.target} now; seam_export_status shows how it went.` }
|
|
534
537
|
case 'seam_export_archive': {
|
|
535
538
|
if (hosted) return `This server cannot write files to your computer. Fetch the archive over HTTP with your token: POST /v1/quilts/${encodeURIComponent(needQuilt())}/exit/archive`
|
|
536
539
|
const r = await client.exitArchive(needQuilt())
|
|
@@ -548,7 +551,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
|
|
|
548
551
|
writeFileSync(file, r.bytes)
|
|
549
552
|
return `Wrote ${args.rev || 'main'} (${String(r.commit || 'empty').slice(0, 12)}) to ${file}: ${r.bytes.length} bytes.`
|
|
550
553
|
}
|
|
551
|
-
case 'seam_export_status': { const r = await client.exportStatus(needQuilt()); return JSON.stringify({ target: r.target, lines: r.lines, releases: r.releases, enabled: r.enabled, status: r.status }, null, 2) }
|
|
554
|
+
case 'seam_export_status': { const r = await client.exportStatus(needQuilt()); return JSON.stringify({ target: r.target, lines: r.lines, main_as: r.main_as, push: r.push, exclude: r.exclude, releases: r.releases, enabled: r.enabled, status: r.status }, null, 2) }
|
|
552
555
|
case 'seam_import_cutover': { const r = await client.cutover(args.id, { credential: args.credential }); return `Cut over: import ${r.import.id} is done; main exports to ${r.export.target} after each merge.` }
|
|
553
556
|
case 'seam_import_status': return describeImportRecord(await client.importStatus(args.id))
|
|
554
557
|
case 'seam_import_authors': { const r = await client.answerImportAuthors(args.id, { authors: args.authors }); return `Import ${r.id} has its authors and runs now.` }
|
package/package.json
CHANGED