@heyquilt/seam 0.1.1 → 0.17.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 +18 -4
- package/lib/quilts/bisect-run.js +31 -0
- package/lib/quilts/block.js +133 -14
- package/lib/quilts/cli.js +206 -11
- package/lib/quilts/client.js +37 -1
- package/lib/quilts/describe.js +149 -3
- package/lib/quilts/errors.js +8 -0
- package/lib/quilts/export-git.js +5 -1
- package/lib/quilts/hash.js +1 -1
- package/lib/quilts/import-git.js +154 -56
- package/lib/quilts/records.js +2 -1
- package/lib/quilts/signing.js +149 -0
- package/lib/quilts/tools.js +195 -19
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Signed commits: a person or an agent adds an Ed25519 public key to their account, signs a commit
|
|
2
|
+
// with the private key (which never leaves their computer), and anyone reading the log can check
|
|
3
|
+
// the signature (seam log --show-signature). A commit's id is the hash of its whole record (tree,
|
|
4
|
+
// parents, author, message, quilt), so signing the id signs all of it.
|
|
5
|
+
//
|
|
6
|
+
// What is signed is the text `seam-commit <id>\n`. Only the commit's author, or the person it was
|
|
7
|
+
// made for (on_behalf_of), may sign it, with one of their own keys. A key is removed, never
|
|
8
|
+
// deleted: signatures made with it then read as "removed_key", so a stolen key stops vouching.
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { QuiltError } from './errors.js'
|
|
14
|
+
|
|
15
|
+
export const MAX_KEYS = 20
|
|
16
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') // Ed25519 SubjectPublicKeyInfo header
|
|
17
|
+
|
|
18
|
+
/** The bytes a signature covers. */
|
|
19
|
+
export const signedText = (commitId) => `seam-commit ${commitId}\n`
|
|
20
|
+
|
|
21
|
+
/** A public key (SPKI PEM, or base64 of the 32 raw bytes) as base64 of its raw bytes, or null. */
|
|
22
|
+
export function rawPublicKey (input) {
|
|
23
|
+
const s = String(input || '').trim()
|
|
24
|
+
if (!s) return null
|
|
25
|
+
try {
|
|
26
|
+
if (s.includes('BEGIN')) {
|
|
27
|
+
const k = crypto.createPublicKey(s)
|
|
28
|
+
if (k.asymmetricKeyType !== 'ed25519') return null
|
|
29
|
+
return k.export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length).toString('base64')
|
|
30
|
+
}
|
|
31
|
+
const b = Buffer.from(s, 'base64')
|
|
32
|
+
return b.length === 32 ? b.toString('base64') : null
|
|
33
|
+
} catch { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const keyObject = (raw) => crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(raw, 'base64')]), format: 'der', type: 'spki' })
|
|
37
|
+
|
|
38
|
+
/** A key's id: `ed25519:` and the first 16 hex of the sha256 of its raw bytes. */
|
|
39
|
+
export const keyId = (raw) => 'ed25519:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex').slice(0, 16)
|
|
40
|
+
|
|
41
|
+
export function verifySignature (raw, commitId, signature) {
|
|
42
|
+
try { return crypto.verify(null, Buffer.from(signedText(commitId)), keyObject(raw), Buffer.from(String(signature), 'base64')) } catch { return false }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- the client side: the private key on this computer ----
|
|
46
|
+
|
|
47
|
+
/** Where this computer's signing key lives: SEAM_SIGNING_KEY, else ~/.seam/signing-key.pem. */
|
|
48
|
+
export const signingKeyFile = () => process.env.SEAM_SIGNING_KEY || path.join(process.env.SEAM_HOME || path.join(os.homedir(), '.seam'), 'signing-key.pem')
|
|
49
|
+
|
|
50
|
+
/** Makes a new key pair on this computer (refused when one is there). Returns the public half. */
|
|
51
|
+
export function makeLocalKey (file = signingKeyFile()) {
|
|
52
|
+
if (fs.existsSync(file)) throw new Error(`There is already a signing key at ${file}.`)
|
|
53
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
55
|
+
fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
|
|
56
|
+
return publicKey.export({ format: 'pem', type: 'spki' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** This computer's key: { sign(commitId), public_key, key } or null when there is none. */
|
|
60
|
+
export function localKey (file = signingKeyFile()) {
|
|
61
|
+
if (!fs.existsSync(file)) return null
|
|
62
|
+
const priv = crypto.createPrivateKey(fs.readFileSync(file))
|
|
63
|
+
const pub = crypto.createPublicKey(priv).export({ format: 'pem', type: 'spki' })
|
|
64
|
+
const raw = rawPublicKey(pub)
|
|
65
|
+
return { public_key: pub, key: keyId(raw), sign: (commitId) => crypto.sign(null, Buffer.from(signedText(commitId)), priv).toString('base64') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Signs a commit with this computer's key and sends the signature. */
|
|
69
|
+
export async function signWithLocalKey (client, quilt, rev) {
|
|
70
|
+
const k = localKey()
|
|
71
|
+
if (!k) throw new Error('No signing key on this computer: run seam key add first.')
|
|
72
|
+
const id = (await client.describe(quilt, rev || 'main')).commit
|
|
73
|
+
if (!id) throw new Error(`No commit at ${rev || 'main'}.`)
|
|
74
|
+
return client.sign(quilt, id, { key: k.key, signature: k.sign(id) })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- the server side ----
|
|
78
|
+
|
|
79
|
+
export function signingOps (svc) {
|
|
80
|
+
const { store, now, quiltFor, resolve, getCommit, fromStore } = svc.internals
|
|
81
|
+
|
|
82
|
+
const keyView = (r) => ({ key: r.id, name: r.name || '', public_key: r.public_key, added_at: r.added_at, ...(r.removed_at ? { removed_at: r.removed_at } : {}) })
|
|
83
|
+
|
|
84
|
+
/** POST /signing-keys { public_key, name? }: adds one of your keys. */
|
|
85
|
+
async function addKey (me, _ref, { public_key: pk, name = '' } = {}) {
|
|
86
|
+
const raw = rawPublicKey(pk)
|
|
87
|
+
if (!raw) throw new QuiltError(400, 'bad_request', 'public_key: an Ed25519 public key (PEM, or base64 of its 32 bytes)')
|
|
88
|
+
if (String(name).length > 100) throw new QuiltError(400, 'bad_request', 'a key name is up to 100 characters')
|
|
89
|
+
const mine = await store.signingKeys(me.member_id)
|
|
90
|
+
if (mine.filter((k) => !k.removed_at).length >= MAX_KEYS) throw new QuiltError(409, 'too_many_keys', `up to ${MAX_KEYS} keys each`)
|
|
91
|
+
try {
|
|
92
|
+
return keyView(await store.addSigningKey({ id: keyId(raw), member: me.member_id, public_key: raw, name: String(name), added_at: now() }))
|
|
93
|
+
} catch (err) { throw fromStore(err) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** GET /signing-keys: your keys (or a member's, with ?member=). */
|
|
97
|
+
async function keys (me, _ref, { member } = {}) {
|
|
98
|
+
return { member: member || me.member_id, keys: (await store.signingKeys(member || me.member_id)).map(keyView) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** DELETE /signing-keys/:key: removes one of your keys; its signatures stop counting. */
|
|
102
|
+
async function removeKey (me, _ref, key) {
|
|
103
|
+
const row = await store.signingKey(String(key))
|
|
104
|
+
if (!row || row.member !== me.member_id) throw new QuiltError(404, 'not_found', `you have no key ${key}`)
|
|
105
|
+
if (row.removed_at) return keyView(row)
|
|
106
|
+
try { return keyView(await store.removeSigningKey({ id: row.id, member: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** POST /commits/:rev/signatures { key, signature }: signs a commit you made (or that was made for you). */
|
|
110
|
+
async function sign (me, ref, rev, { key, signature } = {}) {
|
|
111
|
+
const q = await quiltFor(me, ref, 'write')
|
|
112
|
+
const id = await resolve(q.id, String(rev || ''))
|
|
113
|
+
if (!id) throw new QuiltError(404, 'not_found', `no commit at ${rev}`)
|
|
114
|
+
const c = await getCommit(q.id, id)
|
|
115
|
+
const by = c.author?.member_id
|
|
116
|
+
if (me.member_id !== by && me.member_id !== c.on_behalf_of) throw new QuiltError(403, 'not_author', 'only the commit\'s author, or whom it was made for, signs it')
|
|
117
|
+
const row = key ? await store.signingKey(String(key)) : null
|
|
118
|
+
if (!row || row.member !== me.member_id || row.removed_at) throw new QuiltError(400, 'bad_key', `${key || 'key'} is not one of your keys (seam key add)`)
|
|
119
|
+
if (!verifySignature(row.public_key, id, signature)) throw new QuiltError(422, 'bad_signature', `the signature does not match ${id.slice(0, 12)} and ${row.id}`)
|
|
120
|
+
try {
|
|
121
|
+
await store.addSignature({ quilt_id: q.id, commit: id, key: row.id, signer: me.member_id, signature: String(signature), at: now() })
|
|
122
|
+
} catch (err) { throw fromStore(err) }
|
|
123
|
+
return { commit: id, short: id.slice(0, 12), signature: { status: 'good', key: row.id, signer: me.member_id } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Each commit's signature, checked now: good, bad, removed_key, or none. */
|
|
127
|
+
async function signaturesFor (q, commits) {
|
|
128
|
+
const rows = await store.signaturesOf(q, commits.map((c) => c.id))
|
|
129
|
+
const keyRows = new Map()
|
|
130
|
+
const out = new Map()
|
|
131
|
+
for (const r of rows) {
|
|
132
|
+
if (!keyRows.has(r.key)) keyRows.set(r.key, await store.signingKey(r.key))
|
|
133
|
+
const k = keyRows.get(r.key)
|
|
134
|
+
const status = !k || !verifySignature(k.public_key, r.commit, r.signature) ? 'bad' : k.removed_at ? 'removed_key' : 'good'
|
|
135
|
+
const prev = out.get(r.commit)
|
|
136
|
+
if (!prev || (status === 'good' && prev.status !== 'good')) out.set(r.commit, { status, key: r.key, signer: r.signer, at: r.at })
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Adds `signature` to each commit of a log answer. */
|
|
142
|
+
async function withSignatures (me, ref, answer) {
|
|
143
|
+
const q = await quiltFor(me, ref, 'read')
|
|
144
|
+
const sigs = await signaturesFor(q.id, answer.commits || [])
|
|
145
|
+
return { ...answer, commits: (answer.commits || []).map((c) => ({ ...c, signature: sigs.get(c.id) || { status: 'none' } })) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { addKey, signingKeys: keys, removeKey, sign, withSignatures }
|
|
149
|
+
}
|
package/lib/quilts/tools.js
CHANGED
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
// Locally, in a folder made by `seam checkout`, the quilt and square come from the folder and
|
|
7
7
|
// seam_commit seals the folder's changes; anywhere else (and always for hosted agents) name the
|
|
8
8
|
// quilt and square, and seam_commit takes the files' new contents.
|
|
9
|
+
import path from 'node:path'
|
|
9
10
|
import { z } from 'zod'
|
|
10
|
-
import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents } from './describe.js'
|
|
11
|
-
import {
|
|
11
|
+
import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
|
|
12
|
+
import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
|
|
13
|
+
import { status as blockStatus, commitFolder, amendFolder, syncTo, restore, reset, clean, localChanges } from './block.js'
|
|
12
14
|
|
|
13
|
-
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_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook']
|
|
15
|
+
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_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_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', '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']
|
|
14
16
|
|
|
15
17
|
/** What every agent is told about source control, in each MCP's instructions. */
|
|
16
18
|
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. ' +
|
|
17
19
|
'Open a square with seam_open_square, commit with seam_commit, merge with seam_merge (into: "main"). A merge never stops half way: a real conflict is refused, kept in your square as conflict markers, and you settle it (edit the file and commit it without the markers, or seam_resolve) and merge again. ' +
|
|
18
|
-
'Read history with seam_log, seam_show, seam_diff, seam_blame and
|
|
20
|
+
'Read history with seam_log, seam_show, seam_diff, seam_blame, seam_cat and seam_grep (seam_bisect finds the commit that broke something); revisions are main, main~3, patch:12, sq:<id>, a commit id or git:<sha>. ' +
|
|
19
21
|
'In a folder checked out from a square (it holds .seam/block.json), edit the files with your own tools and check them with seam_square_status.'
|
|
20
22
|
|
|
21
23
|
const QUILT = z.string().max(100).optional().describe('The quilt (its slug or id); in a folder made by seam checkout, that folder\'s quilt')
|
|
@@ -24,8 +26,36 @@ const REV = z.string().max(200).optional()
|
|
|
24
26
|
const PATHS = z.array(z.string().max(1000)).max(200).optional().describe('Only these paths (files or directories)')
|
|
25
27
|
|
|
26
28
|
export const SEAM_DESCRIPTIONS = {
|
|
29
|
+
seam_live: 'A square\'s uncommitted work its holder shared (sq:<id>:live): each changed file, added, modified or deleted against the square\'s head. Read the files with seam_cat or seam_diff (rev sq:<id>:live).',
|
|
30
|
+
seam_line_require: 'Reads, or (a person who is an admin) sets, what a line needs before a square merges into it: approvals of the square\'s latest commit (0: none) and checks that must be green on it (check names, reported with seam_check and square). Leave both out to read it.',
|
|
31
|
+
seam_reviews: 'A square\'s reviews (comments, approvals, requests for changes) and where it stands: approvals it has against what its line needs, and whether it is ready to merge.',
|
|
32
|
+
seam_review: 'Reviews a square: kind approve, request_changes (say what in body), comment (body; path and line put it on a line of a file at the square\'s head) or dismiss (takes back your verdict). Nobody approves a square they or the person they work for opened or hold. An approval counts for the square\'s latest commit only.',
|
|
33
|
+
seam_claims: 'The claims that hold now: which square is working on which paths, who, and why (square: only that square\'s).',
|
|
34
|
+
seam_claim: 'Claims paths (patterns: files, folders or globs like src/api/**) for your square, so writes to them from other squares on its line are refused until your square merges or you let go. note says what for. Claim before a larger change across several files.',
|
|
35
|
+
seam_check: 'Reports a post-merge check on a line (status red, green or flaky; a red check halves the line\'s merge window, green restores it). With patch (the patch number it failed at, found for example with seam_bisect) a red check reverts that patch on the line by itself and opens a square holding the change again for its holder to fix. With square instead, the result (green or red) is for that square\'s latest commit, which its line may require green before a merge.',
|
|
36
|
+
seam_unclaim: 'Lets go of claims on your square (patterns: the patterns or claim ids).',
|
|
37
|
+
seam_live_set: 'Shares your square\'s uncommitted work so others can read it as sq:<id>:live before you commit: in a block folder the folder\'s changed files; elsewhere files: [{ path, content }] and delete: [paths]. Each call replaces what was shared; none clears it. It is not a commit: seam_commit keeps work.',
|
|
38
|
+
seam_restore: 'In a block folder: puts the named files (paths; "." for all) back as they were at the folder\'s last commit, throwing away their uncommitted edits and deletions. Files nobody committed stay (seam_clean removes them).',
|
|
39
|
+
seam_reset: 'In a block folder: makes the folder\'s files those of a revision (rev). With no rev, throws away every uncommitted edit. The folder stays on its square: what differs from it shows as uncommitted changes, so seam_commit makes the square match rev (to undo its last commit: rev sq:<id>~1).',
|
|
40
|
+
seam_clean: 'In a block folder: removes the files nobody committed and no .gitignore names (under paths, default all). dry_run: true lists them without removing.',
|
|
27
41
|
seam_quilts: 'Lists the quilts (repositories) you can reach, or makes a new one (new: its slug).',
|
|
28
|
-
|
|
42
|
+
seam_line_list: 'The quilt\'s lines: main and release/<name> lines, each with its patch count, tip and whether it is frozen.',
|
|
43
|
+
seam_line_new: 'Makes a line release/<name> starting at a revision (from, default main): a release line takes merges of its own. Open squares on it with seam_open_square (line) and merge with seam_merge (into).',
|
|
44
|
+
seam_line_freeze: 'Freezes a line (admins): merges into it are refused with line_frozen until seam_line_unfreeze.',
|
|
45
|
+
seam_line_unfreeze: 'Unfreezes a line (admins): it takes merges again.',
|
|
46
|
+
seam_tag_list: 'The quilt\'s tags: name, commit, the patch it is, and whether it is a release.',
|
|
47
|
+
seam_import_git: 'Imports a git repository on this computer into a quilt: HEAD\'s branch as main, every other branch as a line of its name, every tag, submodules as gitlink entries. Run it again into the same quilt to add only the new commits.',
|
|
48
|
+
seam_sign: 'Signs a commit you made (rev, default your square\'s head) with your Ed25519 key, so seam_log with show_signature shows it as yours. With no signature, signs with this computer\'s key (seam_key_add makes one); a hosted agent signs the text "seam-commit <commit id>\\n" itself and passes key and signature (base64).',
|
|
49
|
+
seam_keys: 'Your signing keys (or member\'s): their ids, names, and when each was added or removed.',
|
|
50
|
+
seam_key_add: 'Adds an Ed25519 signing key to your account. With no public_key, makes one on this computer (kept in ~/.seam/signing-key.pem) or adds the one already there.',
|
|
51
|
+
seam_token_list: 'The quilt\'s tokens (keys for one quilt, for CI and scripts): id, name, read or write, and whether revoked. Admins only.',
|
|
52
|
+
seam_token_new: 'Makes a quilt token: a key that reaches only this quilt, at level read (default) or write, for a CI job or a script. The secret (sqt_…) is in this answer only; it is used as SEAM_TOKEN or Authorization: Bearer. Admins only.',
|
|
53
|
+
seam_token_revoke: 'Revokes a quilt token (by id, or by name): it stops working at once. Admins only.',
|
|
54
|
+
seam_key_remove: 'Removes one of your signing keys (key: ed25519:…). Commits it signed then show "removed_key" instead of "good".',
|
|
55
|
+
seam_tag: 'Tags a revision (rev: patch:12, a commit, a line, sq:…; default main) with a name that never moves. Read it back as the revision tag:<name>.',
|
|
56
|
+
seam_release_list: 'The quilt\'s releases, newest first, each with its notes.',
|
|
57
|
+
seam_release: 'Makes a release: a tag (name) on a revision (rev, default main) with release notes (notes).',
|
|
58
|
+
seam_open_square: 'Opens a square: your own unit of work, branched from the tip of main (or line). Returns its id (sq:…). Agents open squares, never lines. on: another open square (sq:…) to stack on: yours starts from its head, merges after it, and is rebased onto the line by itself when it merges.',
|
|
29
59
|
seam_squares: 'The open squares of a quilt, with holder, head, how far behind main each is and its conflicted paths. all: true for merged and abandoned ones too.',
|
|
30
60
|
seam_square_status: 'One square as the server sees it (head, behind_by, conflicted paths), the patches merged under it that changed its files, and which files would conflict if merged now; in a block folder also the files changed since the last commit.',
|
|
31
61
|
seam_square_handoff: 'Hands your square to another member (to), with a note on what is done and what is left. You stop being able to commit to it.',
|
|
@@ -35,36 +65,78 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
35
65
|
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 it.',
|
|
36
66
|
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.',
|
|
37
67
|
seam_split: 'Splits your square by paths: groups: [[paths], ...], each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square.',
|
|
38
|
-
seam_log: 'History, newest first: rev (default main, or your square), paths to limit it, limit (default 20). Each patch on main is one line\'s merge.',
|
|
68
|
+
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). Each patch on main is one line\'s merge.',
|
|
69
|
+
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.',
|
|
70
|
+
seam_merge_base: 'Where two revisions (a, b: lines, squares, patches, commits) last shared history: the commit a merge of one into the other starts from.',
|
|
71
|
+
seam_search: 'Code search: the lines matching a pattern in every quilt you can read (or the ones in quilts), at a revision (rev, default main; quilts without it are skipped), under paths. fixed: true for plain text; ignore_case, files_only, limit (default 200, across all quilts).',
|
|
72
|
+
seam_grep: 'The lines matching a pattern (a JavaScript regular expression; fixed: true for plain text) in the files at a revision (rev, default main or your square; sq:<id>:live too), under paths. ignore_case, files_only, limit (default 200).',
|
|
73
|
+
seam_bisect: 'Finds the commit that broke something between good (a revision without the problem) and bad (default main), along first parents (on a line: its patches). Without run, one step: pass marks { <commit>: "good" | "bad" | "skip" } for those tested so far and it names the next to test, or the first bad one. With run (a shell command) it checks out each commit into a fresh folder on this computer and runs it there: exit 0 good, 125 skip, other bad.',
|
|
39
74
|
seam_show: 'One commit or patch: who, when, why, which square, and its diff. rev: patch:12, main~1, sq:…, a commit id, git:<sha>. format: unified (default), stat or names.',
|
|
40
75
|
seam_diff: 'The difference between two revisions (from, to); with only to, against its parent (for a square: against where it branched). format: unified, stat or names.',
|
|
41
76
|
seam_blame: 'Each line of a file at a revision, with the commit and patch that last changed it and who made it. Computed by the server.',
|
|
42
77
|
seam_cat: 'A file\'s content at any revision (path, rev). To change a file, read it from your own square (rev sq:<id>), not from main: main moves while you work, and a commit built from a different version of main than your square started from can undo work that merged in between. A conflicted file comes back with its conflict markers: format diff (default: one side in full after +++++++, each other side as a diff from its base after %%%%%%%), snapshot (every side in full) or git (<<<<<<< ======= >>>>>>>, two sides only).',
|
|
43
78
|
seam_ls: 'The files at a revision, under paths (directories).',
|
|
44
|
-
seam_commit: 'Seals your square\'s changes as a commit (a save point). In a block folder: the folder\'s changed files (paths: only these). Elsewhere: files: [{ path, content }] (text; base64: true for bytes) and delete: [paths]. message says why. Committing a conflicted file without its markers settles the conflict.',
|
|
79
|
+
seam_commit: 'Seals your square\'s changes as a commit (a save point). In a block folder: the folder\'s changed files (paths: only these). Elsewhere: files: [{ path, content }] (text; base64: true for bytes) and delete: [paths]. message says why. Committing a conflicted file without its markers settles the conflict. amend: true changes the square\'s last commit instead (its changes plus these, a new message if given), as git commit --amend does. sign: true signs the new commit with this computer\'s key.',
|
|
45
80
|
seam_merge: 'Merges your square into a line (into: "main") or, with from, merges another square or revision into your square (a two-parent commit; conflicts stay in your square as markers). Into a line: the server rebases it onto the tip as one commit and moves main, retrying lost races itself. The result is a patch. A real conflict is refused: the square is rebased with the conflict kept in it (your folder gets the markers); settle it and merge again. dry_run: true says what would happen.',
|
|
46
81
|
seam_rebase: 'Moves your square onto the tip of its line (or onto a revision). Conflicts become conflict markers in the square, never a stop.',
|
|
47
82
|
seam_revert: 'Undoes a patch (or commit): a new square from the tip whose one commit inverts it. Merge that square with seam_merge.',
|
|
48
83
|
seam_cherry_pick: 'Applies one commit\'s change (from) to your square as a new commit.',
|
|
49
|
-
seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused). (Or edit the file and commit it without the markers.)'
|
|
84
|
+
seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused). (Or edit the file and commit it without the markers.) The answer is remembered: the same conflict met again in a rebase or merge is settled the same way, unless remember: false.',
|
|
85
|
+
seam_resolutions: 'The remembered resolutions: conflicts settled once that seam settles the same way when they come back (path: only that file\'s).',
|
|
86
|
+
seam_forget_resolution: 'Forgets a remembered resolution (key: its key or first 8+ characters), so that conflict is asked about again next time.'
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
export const SEAM_INPUTS = {
|
|
90
|
+
seam_live: { quilt: QUILT, square: SQUARE },
|
|
91
|
+
seam_line_require: { quilt: QUILT, line: z.string().max(120), approvals: z.number().int().min(0).max(20).optional(), checks: z.array(z.string().max(100)).max(20).optional().describe('Check names that must be green on a square\'s latest commit, e.g. ["ci"]; [] for none') },
|
|
92
|
+
seam_reviews: { quilt: QUILT, square: SQUARE },
|
|
93
|
+
seam_review: { quilt: QUILT, square: SQUARE, kind: z.enum(['approve', 'request_changes', 'comment', 'dismiss']), body: z.string().max(20000).optional(), path: z.string().max(1000).optional().describe('A file, for a comment on it'), line: z.number().int().min(1).optional().describe('A line of that file at the square\'s head') },
|
|
94
|
+
seam_claims: { quilt: QUILT, square: SQUARE },
|
|
95
|
+
seam_claim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('Files, folders or globs, e.g. src/api/** or README.md'), note: z.string().max(300).optional().describe('What you are doing with them') },
|
|
96
|
+
seam_check: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), status: z.enum(['red', 'green', 'flaky']), square: z.string().max(100).optional().describe('A square (sq:…): the result is for its latest commit, which a line may require green before a merge'), line: z.string().max(110).optional().describe('Default main'), patch: z.number().int().min(1).optional().describe('The patch number the check failed at: reverts it') },
|
|
97
|
+
seam_unclaim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('The claimed patterns (or claim ids)') },
|
|
98
|
+
seam_live_set: { quilt: QUILT, square: SQUARE, files: z.array(z.object({ path: z.string().max(1000), content: z.string(), base64: z.boolean().optional(), mode: z.enum(['file', 'exec']).optional() })).max(2000).optional(), delete: z.array(z.string().max(1000)).max(2000).optional() },
|
|
99
|
+
seam_restore: { paths: z.array(z.string().max(1000)).min(1).max(200).describe('Files or folders, relative to the block folder ("." for all)') },
|
|
100
|
+
seam_reset: { rev: z.string().max(200).optional().describe('The revision: sq:<id>~1, patch:12, tag:v1.0, a commit; default the folder\'s last commit') },
|
|
101
|
+
seam_clean: { paths: PATHS, dry_run: z.boolean().optional() },
|
|
53
102
|
seam_quilts: { new: z.string().max(63).optional().describe('Make a quilt with this slug') },
|
|
54
|
-
|
|
103
|
+
seam_line_list: { quilt: QUILT },
|
|
104
|
+
seam_line_new: { quilt: QUILT, name: z.string().max(110).describe('release/<name> (or just <name>)'), from: z.string().max(200).optional().describe('The revision it starts at: main, patch:12, a commit, sq:…; default main') },
|
|
105
|
+
seam_line_freeze: { quilt: QUILT, line: z.string().max(110) },
|
|
106
|
+
seam_line_unfreeze: { quilt: QUILT, line: z.string().max(110) },
|
|
107
|
+
seam_tag_list: { quilt: QUILT },
|
|
108
|
+
seam_import_git: { path: z.string().max(1000).describe('The git repository\'s folder'), into: z.string().max(63).optional().describe('The quilt\'s slug (default the folder\'s name); a quilt an import made takes only the new commits'), validate: z.boolean().optional().describe('Compare every patch written with git\'s tree') },
|
|
109
|
+
seam_sign: { quilt: QUILT, rev: REV, key: z.string().max(40).optional().describe('Your key id (ed25519:…), with signature'), signature: z.string().max(200).optional().describe('Base64 Ed25519 signature of "seam-commit <commit id>\\n"') },
|
|
110
|
+
seam_keys: { member: z.string().max(200).optional().describe('Whose keys (default yours)') },
|
|
111
|
+
seam_key_add: { public_key: z.string().max(2000).optional().describe('Ed25519 public key (PEM, or base64 of its 32 bytes); none: this computer\'s'), name: z.string().max(100).optional() },
|
|
112
|
+
seam_token_list: { quilt: QUILT },
|
|
113
|
+
seam_token_new: { quilt: QUILT, name: z.string().max(100).describe('What it is for, e.g. ci'), level: z.enum(['read', 'write']).optional() },
|
|
114
|
+
seam_token_revoke: { quilt: QUILT, token: z.string().max(100).describe('The token\'s id or name') },
|
|
115
|
+
seam_key_remove: { key: z.string().max(40).describe('The key id, ed25519:…') },
|
|
116
|
+
seam_tag: { quilt: QUILT, name: z.string().max(100).describe('The tag, e.g. v1.0'), rev: z.string().max(200).optional().describe('What it names: patch:12, a commit, a line, sq:…; default main') },
|
|
117
|
+
seam_release_list: { quilt: QUILT },
|
|
118
|
+
seam_release: { quilt: QUILT, name: z.string().max(100).describe('The release\'s tag, e.g. v1.0'), rev: z.string().max(200).optional().describe('default main'), notes: z.string().max(20000).describe('Release notes') },
|
|
119
|
+
seam_open_square: { quilt: QUILT, title: z.string().max(200).describe('What the work is'), line: z.string().max(100).optional(), task_id: z.string().max(64).optional(), on: z.string().max(64).optional().describe('A square to stack on (sq:…)') },
|
|
55
120
|
seam_squares: { quilt: QUILT, all: z.boolean().optional(), mine: z.boolean().optional() },
|
|
56
121
|
seam_square_status: { quilt: QUILT, square: SQUARE },
|
|
57
|
-
seam_log: { quilt: QUILT, rev: REV, paths: PATHS, limit: z.number().int().min(1).max(500).optional() },
|
|
122
|
+
seam_log: { quilt: QUILT, rev: REV, paths: PATHS, limit: z.number().int().min(1).max(500).optional(), follow: z.boolean().optional().describe('Follow one file (paths: [it]) through its renames'), search: z.string().max(500).optional().describe('Only commits that changed how many times this text appears'), show_signature: z.boolean().optional().describe('Check and show each commit\'s signature') },
|
|
123
|
+
seam_describe: { quilt: QUILT, rev: REV },
|
|
124
|
+
seam_merge_base: { quilt: QUILT, a: z.string().max(200), b: z.string().max(200) },
|
|
125
|
+
seam_search: { pattern: z.string().max(500), quilts: z.array(z.string().max(200)).max(100).optional(), rev: REV, paths: PATHS, ignore_case: z.boolean().optional(), fixed: z.boolean().optional(), files_only: z.boolean().optional(), limit: z.number().int().min(1).max(2000).optional() },
|
|
126
|
+
seam_grep: { quilt: QUILT, pattern: z.string().max(500), rev: REV, paths: PATHS, ignore_case: z.boolean().optional(), fixed: z.boolean().optional(), files_only: z.boolean().optional(), limit: z.number().int().min(1).max(2000).optional() },
|
|
127
|
+
seam_bisect: { quilt: QUILT, good: z.string().max(200).describe('A revision without the problem'), bad: z.string().max(200).optional().describe('A revision with it; default main'), marks: z.record(z.string().max(100), z.enum(['good', 'bad', 'skip'])).optional().describe('Commits tested so far'), run: z.string().max(4000).optional().describe('A check command, run in each checked-out commit: exit 0 good, 125 skip, other bad') },
|
|
58
128
|
seam_show: { quilt: QUILT, rev: REV, format: z.enum(['unified', 'stat', 'names']).optional(), paths: PATHS },
|
|
59
129
|
seam_diff: { quilt: QUILT, from: REV, to: REV, format: z.enum(['unified', 'stat', 'names']).optional(), paths: PATHS },
|
|
60
130
|
seam_blame: { quilt: QUILT, path: z.string().max(1000), rev: REV },
|
|
61
131
|
seam_cat: { quilt: QUILT, path: z.string().max(1000), rev: REV, format: z.enum(['diff', 'snapshot', 'git']).optional().describe('How a conflicted file is written (default diff)') },
|
|
62
132
|
seam_ls: { quilt: QUILT, rev: REV, paths: PATHS },
|
|
63
133
|
seam_commit: {
|
|
64
|
-
quilt: QUILT, square: SQUARE, message: z.string().max(65536).describe('Why'),
|
|
134
|
+
quilt: QUILT, square: SQUARE, message: z.string().max(65536).optional().describe('Why (with amend, a new message; left out, the old one stays)'),
|
|
135
|
+
amend: z.boolean().optional().describe('Change the square\'s last commit instead of adding one: its changes plus these, under the same parents'),
|
|
65
136
|
files: z.array(z.object({ path: z.string().max(1000), content: z.string(), base64: z.boolean().optional(), mode: z.enum(['file', 'exec']).optional() })).max(2000).optional(),
|
|
66
137
|
delete: z.array(z.string().max(1000)).max(2000).optional(),
|
|
67
|
-
paths: PATHS
|
|
138
|
+
paths: PATHS,
|
|
139
|
+
sign: z.boolean().optional().describe('Sign the new commit with this computer\'s key (seam_key_add)')
|
|
68
140
|
},
|
|
69
141
|
seam_square_handoff: { quilt: QUILT, square: SQUARE, to: z.string().max(200).describe('The member to hand it to'), note: z.string().max(2000).optional() },
|
|
70
142
|
seam_abandon: { quilt: QUILT, square: SQUARE },
|
|
@@ -77,7 +149,9 @@ export const SEAM_INPUTS = {
|
|
|
77
149
|
seam_rebase: { quilt: QUILT, square: SQUARE, onto: REV },
|
|
78
150
|
seam_revert: { quilt: QUILT, rev: z.string().max(200).describe('patch:12, a commit id, …'), line: z.string().max(100).optional() },
|
|
79
151
|
seam_cherry_pick: { quilt: QUILT, square: SQUARE, from: z.string().max(200) },
|
|
80
|
-
seam_resolve: { quilt: QUILT, square: SQUARE, entries: z.array(z.object({ path: z.string().max(1000), choose: z.union([z.enum(['ours', 'theirs', 'base', 'suggestion', 'delete', 'keep_edit', 'keep_delete']), z.number().int().min(0).max(8)]) })).min(1).max(500) }
|
|
152
|
+
seam_resolve: { quilt: QUILT, square: SQUARE, entries: z.array(z.object({ path: z.string().max(1000), choose: z.union([z.enum(['ours', 'theirs', 'base', 'suggestion', 'delete', 'keep_edit', 'keep_delete']), z.number().int().min(0).max(8)]) })).min(1).max(500), remember: z.boolean().optional().describe('Remember the answer for the same conflict next time (default true)') },
|
|
153
|
+
seam_resolutions: { quilt: QUILT, path: z.string().max(1000).optional() },
|
|
154
|
+
seam_forget_resolution: { quilt: QUILT, key: z.string().min(8).max(64) }
|
|
81
155
|
}
|
|
82
156
|
|
|
83
157
|
/**
|
|
@@ -94,13 +168,47 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
94
168
|
const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` } return '' }
|
|
95
169
|
try {
|
|
96
170
|
switch (name) {
|
|
171
|
+
case 'seam_line_require': {
|
|
172
|
+
if (args.approvals === undefined && args.checks === undefined) return describePolicy(await client.linePolicy(needQuilt(), args.line))
|
|
173
|
+
return describePolicy(await client.setLinePolicy(needQuilt(), args.line, { ...(args.approvals !== undefined ? { approvals: args.approvals } : {}), ...(args.checks ? { checks: args.checks } : {}) }))
|
|
174
|
+
}
|
|
175
|
+
case 'seam_reviews': return describeReviews(await client.reviews(needQuilt(), needSquare()))
|
|
176
|
+
case 'seam_review': return describeReviewed(await client.review(needQuilt(), needSquare(), { kind: args.kind, body: args.body || '', ...(args.path ? { path: args.path } : {}), ...(args.line ? { line: args.line } : {}) }))
|
|
177
|
+
case 'seam_claims': return describeClaims(await client.claims(needQuilt(), square))
|
|
178
|
+
case 'seam_claim': {
|
|
179
|
+
const made = []
|
|
180
|
+
for (const p of args.patterns) made.push(await client.claim(needQuilt(), needSquare(), { pattern: p, note: args.note }))
|
|
181
|
+
return made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until the square merges or seam_unclaim.`).join('\n')
|
|
182
|
+
}
|
|
183
|
+
case 'seam_check': if (args.square) return describeSquareCheck(await client.reportSquareCheck(needQuilt(), args.square, { check: args.check, status: args.status }))
|
|
184
|
+
return describeCheck(await client.reportCheck(needQuilt(), args.line || 'main', { check: args.check, status: args.status, ...(args.patch ? { patch: args.patch } : {}) }))
|
|
185
|
+
case 'seam_unclaim': {
|
|
186
|
+
const out = []
|
|
187
|
+
for (const p of args.patterns) out.push(await client.unclaim(needQuilt(), needSquare(), p))
|
|
188
|
+
return out.map((r) => `Let go of ${r.released.pattern}.`).join('\n')
|
|
189
|
+
}
|
|
190
|
+
case 'seam_live': return describeLive(await client.live(needQuilt(), needSquare()))
|
|
191
|
+
case 'seam_live_set': {
|
|
192
|
+
const given = [...(args.files || []).map((f) => ({ path: f.path, op: 'put', content: f.base64 ? f.content : Buffer.from(f.content, 'utf8').toString('base64'), mode: f.mode || 'file' })), ...(args.delete || []).map((p) => ({ path: p, op: 'delete' }))]
|
|
193
|
+
const changes = given.length || !inFolder ? given : localChanges(found.dir, block).map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: c.bytes.toString('base64'), mode: c.mode })
|
|
194
|
+
return describeLiveSet(await client.setLive(needQuilt(), needSquare(), { changes }))
|
|
195
|
+
}
|
|
196
|
+
case 'seam_restore':
|
|
197
|
+
case 'seam_reset':
|
|
198
|
+
case 'seam_clean': {
|
|
199
|
+
if (!found) throw new Error(`${name} works on a block folder: run seam mcp in a folder made by seam checkout.`)
|
|
200
|
+
const files = (args.paths || []).map((p) => path.resolve(found.dir, p))
|
|
201
|
+
if (name === 'seam_restore') return describeRestore(await restore(client, found.dir, block, { paths: files }))
|
|
202
|
+
if (name === 'seam_reset') return describeReset(await reset(client, found.dir, block, { rev: args.rev }), block)
|
|
203
|
+
return describeClean(clean(found.dir, block, { paths: files, dryRun: !!args.dry_run }), 'Call seam_clean again without dry_run to remove them.')
|
|
204
|
+
}
|
|
97
205
|
case 'seam_quilts': {
|
|
98
206
|
if (args.new) { const q = await client.createQuilt({ slug: args.new }); return `Made quilt ${q.slug} (${q.id}).` }
|
|
99
207
|
const list = await client.quilts()
|
|
100
208
|
return list.length ? list.map((q) => `${q.slug} (${q.id}), ${q.level}`).join('\n') : 'No quilts yet.'
|
|
101
209
|
}
|
|
102
210
|
case 'seam_open_square': {
|
|
103
|
-
const sq = await client.openSquare(needQuilt(), { title: args.title, line: args.line, task_id: args.task_id })
|
|
211
|
+
const sq = await client.openSquare(needQuilt(), { title: args.title, line: args.line, task_id: args.task_id, ...(args.on ? { on: args.on } : {}) })
|
|
104
212
|
return `Opened ${describeSquare(sq)}.\nCommit in it with seam_commit (square: "${sq.short}") and merge it with seam_merge.`
|
|
105
213
|
}
|
|
106
214
|
case 'seam_squares': {
|
|
@@ -111,24 +219,45 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
111
219
|
if (inFolder) return describeStatus(await blockStatus(client, found.dir, block))
|
|
112
220
|
return describeSquareStatus(await client.square(needQuilt(), needSquare()))
|
|
113
221
|
}
|
|
114
|
-
case 'seam_log': return describeLog(await client.log(needQuilt(), { rev: args.rev || (block?.square ? block.square_short : 'main'), paths: args.paths, limit: args.limit }), { oneline:
|
|
222
|
+
case 'seam_log': return describeLog(await client.log(needQuilt(), { rev: args.rev || (block?.square ? block.square_short : 'main'), paths: args.paths, limit: args.limit, follow: args.follow || undefined, search: args.search, show_signature: args.show_signature || undefined }), { oneline: !args.show_signature })
|
|
223
|
+
case 'seam_describe': return (await client.describe(needQuilt(), args.rev || (block?.square ? block.square_short : 'main'))).describe
|
|
224
|
+
case 'seam_merge_base': return describeMergeBase(await client.mergeBase(needQuilt(), args.a, args.b))
|
|
225
|
+
case 'seam_search': return describeGrep(await client.search({ pattern: args.pattern, quilts: args.quilts, rev: args.rev, paths: args.paths, ignore_case: args.ignore_case || undefined, fixed: args.fixed || undefined, limit: args.limit }), { filesOnly: !!args.files_only })
|
|
226
|
+
case 'seam_grep': return describeGrep(await client.grep(needQuilt(), { pattern: args.pattern, rev: args.rev || (block?.square ? block.square_short : 'main'), paths: args.paths, ignore_case: args.ignore_case || undefined, fixed: args.fixed || undefined, limit: args.limit }), { filesOnly: !!args.files_only })
|
|
227
|
+
case 'seam_bisect': {
|
|
228
|
+
const o = { bad: args.bad || 'main', good: args.good, marks: args.marks || {} }
|
|
229
|
+
if (!args.run) return describeBisect(await client.bisect(needQuilt(), o))
|
|
230
|
+
const { bisectRun } = await import('./bisect-run.js')
|
|
231
|
+
return describeBisect(await bisectRun(client, { quilt: needQuilt(), ...o, run: args.run }))
|
|
232
|
+
}
|
|
115
233
|
case 'seam_show': return describeShow(await client.show(needQuilt(), args.rev || 'main', { format: args.format || 'unified', paths: args.paths }), { format: args.format || 'unified' })
|
|
116
234
|
case 'seam_diff': return describeDiff(await client.diff(needQuilt(), { from: args.from, to: args.to, paths: args.paths, format: args.format || 'unified' }), { format: args.format || 'unified' })
|
|
117
235
|
case 'seam_blame': return describeBlame(await client.blame(needQuilt(), args.rev || 'main', args.path))
|
|
118
236
|
case 'seam_cat': { const r = await client.cat(needQuilt(), args.rev || 'main', args.path, { format: args.format || 'diff' }); return Buffer.from(r.content, 'base64').toString('utf8') }
|
|
119
237
|
case 'seam_ls': { const r = await client.tree(needQuilt(), args.rev || 'main', args.paths); return r.entries.map((e) => `${e.mode}\t${e.path}`).join('\n') || 'No files.' }
|
|
120
238
|
case 'seam_commit': {
|
|
239
|
+
if (!args.amend && !args.message) return 'Say why: message.'
|
|
240
|
+
const signed = async (r, verb) => {
|
|
241
|
+
const text = describeWrite(r, verb)
|
|
242
|
+
if (!args.sign || !r?.result?.id) return text
|
|
243
|
+
const s = await signWithLocalKey(client, needQuilt(), r.result.id)
|
|
244
|
+
return `${text}\nSigned with ${s.signature.key}.`
|
|
245
|
+
}
|
|
246
|
+
if (args.amend && inFolder && !args.files && !args.delete) {
|
|
247
|
+
return signed(await amendFolder(client, found.dir, block, { message: args.message, paths: (args.paths || []).map((p) => `${found.dir}/${p}`) }), 'Amended the last commit as')
|
|
248
|
+
}
|
|
121
249
|
if (inFolder && !args.files && !args.delete) {
|
|
122
250
|
const r = await commitFolder(client, found.dir, block, { message: args.message, paths: (args.paths || []).map((p) => `${found.dir}/${p}`) })
|
|
123
|
-
return r.nothing ? 'Nothing to commit: no file in this folder changed since the last commit.' :
|
|
251
|
+
return r.nothing ? 'Nothing to commit: no file in this folder changed since the last commit.' : signed(r, 'Committed')
|
|
124
252
|
}
|
|
125
253
|
const changes = [
|
|
126
254
|
...(args.files || []).map((f) => ({ path: f.path, op: 'put', content: f.base64 ? f.content : Buffer.from(f.content, 'utf8').toString('base64'), ...(f.mode ? { mode: f.mode } : {}) })),
|
|
127
255
|
...(args.delete || []).map((p) => ({ path: p, op: 'delete' }))
|
|
128
256
|
]
|
|
129
|
-
if (!changes.length) return 'Nothing to commit: give files (and delete), or run in a block folder.'
|
|
130
257
|
const sq = await client.square(needQuilt(), needSquare())
|
|
131
|
-
return
|
|
258
|
+
if (args.amend) return signed(await client.amend(quilt, sq.id, { expect_head: sq.head, message: args.message, changes }), 'Amended the last commit as')
|
|
259
|
+
if (!changes.length) return 'Nothing to commit: give files (and delete), or run in a block folder.'
|
|
260
|
+
return signed(await client.commit(quilt, sq.id, { expect_head: sq.head, message: args.message, changes }), 'Committed')
|
|
132
261
|
}
|
|
133
262
|
case 'seam_square_handoff': { const sq = await client.handoff(needQuilt(), needSquare(), { to: args.to, note: args.note }); return `Handed ${sq.short} to ${sq.holder}.` }
|
|
134
263
|
case 'seam_abandon': { const sq = await client.abandon(needQuilt(), needSquare(), inFolder ? { expect_head: block.head } : {}); return `Abandoned ${sq.short}; seam_reopen brings it back.` }
|
|
@@ -147,6 +276,48 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
147
276
|
const r = await client.split(needQuilt(), needSquare(), { by: 'paths', groups: args.groups, titles: args.titles, ...(inFolder ? { expect_head: block.head } : {}) })
|
|
148
277
|
return [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n') + await sync(r.square.head, r.square)
|
|
149
278
|
}
|
|
279
|
+
case 'seam_line_list': {
|
|
280
|
+
const ls = await client.lines(needQuilt())
|
|
281
|
+
return ls.map((l) => `${l.name}: ${l.patch_count} patches, tip ${l.tip ? l.tip.slice(0, 12) : '(empty)'}${l.frozen ? ', frozen' : ''}`).join('\n')
|
|
282
|
+
}
|
|
283
|
+
case 'seam_line_new': {
|
|
284
|
+
const l = await client.newLine(needQuilt(), { name: args.name, from: args.from })
|
|
285
|
+
return `Made line ${l.name} at ${l.tip ? l.tip.slice(0, 12) : '(empty)'}. Open a square on it with seam_open_square (line: "${l.name}").`
|
|
286
|
+
}
|
|
287
|
+
case 'seam_line_freeze': { const l = await client.freezeLine(needQuilt(), args.line); return `${l.name} is frozen: merges into it are refused until seam_line_unfreeze.` }
|
|
288
|
+
case 'seam_line_unfreeze': { const l = await client.unfreezeLine(needQuilt(), args.line); return `${l.name} takes merges again.` }
|
|
289
|
+
case 'seam_tag_list': { const ts = await client.tags(needQuilt()); return ts.length ? ts.map(describeTag).join('\n') : 'No tags yet.' }
|
|
290
|
+
case 'seam_release_list': { const rs = await client.releases(needQuilt()); return rs.length ? rs.map(describeRelease).join('\n') : 'No releases yet.' }
|
|
291
|
+
case 'seam_import_git': {
|
|
292
|
+
const { importGit } = await import('./import-git.js')
|
|
293
|
+
const { default: nodePath } = await import('node:path')
|
|
294
|
+
const repo = nodePath.resolve(found?.dir || process.cwd(), args.path)
|
|
295
|
+
const r = await importGit(client, { repo, slug: args.into || nodePath.basename(repo).toLowerCase().replace(/[^a-z0-9-]/g, '-'), validate: !!args.validate })
|
|
296
|
+
return describeImport(r)
|
|
297
|
+
}
|
|
298
|
+
case 'seam_sign': {
|
|
299
|
+
const rev = args.rev || (block?.square ? block.square_short : 'main')
|
|
300
|
+
if (args.signature) { const id = (await client.describe(needQuilt(), rev)).commit; const r = await client.sign(needQuilt(), id, { key: args.key, signature: args.signature }); return `Signed ${r.short} with ${r.signature.key}.` }
|
|
301
|
+
const r = await signWithLocalKey(client, needQuilt(), rev)
|
|
302
|
+
return `Signed ${r.short} with ${r.signature.key}.`
|
|
303
|
+
}
|
|
304
|
+
case 'seam_keys': return describeKeys(await client.signingKeys({ member: args.member }))
|
|
305
|
+
case 'seam_key_add': {
|
|
306
|
+
let pub = args.public_key
|
|
307
|
+
let made = false
|
|
308
|
+
if (!pub) { const k = localKey(); if (k) pub = k.public_key; else { pub = makeLocalKey(); made = true } }
|
|
309
|
+
const r = await client.addKey({ public_key: pub, name: args.name || '' })
|
|
310
|
+
return `${made ? `Made a signing key at ${signingKeyFile()} and added` : 'Added'} ${r.key}. Sign with seam_commit { sign: true } or seam_sign.`
|
|
311
|
+
}
|
|
312
|
+
case 'seam_token_list': return describeTokens(await client.tokens(needQuilt()))
|
|
313
|
+
case 'seam_token_new': return describeNewToken(await client.createToken(needQuilt(), { name: args.name, level: args.level || 'read' }))
|
|
314
|
+
case 'seam_token_revoke': { const r = await client.revokeToken(needQuilt(), args.token); return `Revoked token ${r.name} (${r.id}): it no longer reaches the quilt.` }
|
|
315
|
+
case 'seam_key_remove': { const r = await client.removeKey(args.key); return `Removed ${r.key}: commits it signed now show removed_key.` }
|
|
316
|
+
case 'seam_tag':
|
|
317
|
+
case 'seam_release': {
|
|
318
|
+
const x = name === 'seam_tag' ? await client.createTag(needQuilt(), { name: args.name, rev: args.rev }) : await client.createRelease(needQuilt(), { name: args.name, rev: args.rev, notes: args.notes })
|
|
319
|
+
return `${name === 'seam_tag' ? 'Tagged' : 'Released'} ${x.name} at ${x.short}${x.patch ? ` (${x.patch.line}:${x.patch.patch_no})` : ''}. Read it as tag:${x.name}.`
|
|
320
|
+
}
|
|
150
321
|
case 'seam_merge': {
|
|
151
322
|
if (args.from) {
|
|
152
323
|
const r = await client.mergeSquare(needQuilt(), needSquare(), { from: args.from, message: args.message, ...(inFolder ? { expect_head: block.head } : {}) })
|
|
@@ -168,9 +339,14 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
168
339
|
return describeWrite(r, 'Picked') + await sync(r.square.head, r.square)
|
|
169
340
|
}
|
|
170
341
|
case 'seam_resolve': {
|
|
171
|
-
const r = await client.resolve(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), entries: args.entries })
|
|
342
|
+
const r = await client.resolve(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), entries: args.entries, ...(args.remember === false ? { remember: false } : {}) })
|
|
172
343
|
return describeWrite(r, 'Resolved') + await sync(r.square.head, r.square)
|
|
173
344
|
}
|
|
345
|
+
case 'seam_resolutions': return describeResolutions(await client.resolutions(needQuilt(), args.path))
|
|
346
|
+
case 'seam_forget_resolution': {
|
|
347
|
+
const r = await client.forgetResolution(needQuilt(), args.key)
|
|
348
|
+
return `Forgot ${r.forgotten.short} (${r.forgotten.path}): that conflict will be asked about again.`
|
|
349
|
+
}
|
|
174
350
|
}
|
|
175
351
|
return `Unknown tool ${name}.`
|
|
176
352
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyquilt/seam",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "seam: Quilt Source's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|