@heyquilt/seam 0.26.0 → 0.130.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.
Files changed (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +623 -69
  11. package/lib/quilts/client.js +307 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +412 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
package/lib/quilts/cli.js CHANGED
@@ -6,15 +6,22 @@ import path from 'node:path'
6
6
  import fs from 'node:fs'
7
7
  import os from 'node:os'
8
8
  import { quiltClient } from './client.js'
9
- import { runChecks } from './runner.js'
10
- import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, restore, reset, clean, localChanges } from './block.js'
11
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeImportRecord, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken, describeCheckRuns, describeRerun } from './describe.js'
9
+ import { runChecks, readRunnerConfig, listenForWork } from './runner.js'
10
+ import { startDaemon, askDaemon } from './daemon.js'
11
+ import { rebuild as rebuildBlock, conflictStyle } from './block.js'
12
+ import { describeRebuild, describeOpened } from './describe.js'
13
+ import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, pull, toLineTip, restore, reset, clean, localChanges, bytesOf, livePatch } from './block.js'
14
+ import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeImportRecord, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeHandoff, describeFinished, describeResolutions, describeLiveSet, describeLiveJoin, describeMergeBase, describeStack, describeStackDiff, describeGrep, describeBisect, describeCheck, describeReviews, describeInterdiff, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken, describeBlock, describeBlocks, describeProtections, describeTeams, describeAccess, describeRedacted, describeRedactions, describeMetrics, describeSquarePolicy, describePolicyVersions, describeOrg, describeOrgs, describeOrgTeams, describeCheckRuns, describeRerun, describeAncestor, describeContains, describePipeline, describeWatches, describeSearch, describePublicReads } from './describe.js'
15
+ import { verifyQuilt, describeVerify } from './attest.js'
16
+ import { confirmWithLocalKey } from './linepolicy.js'
17
+ import { describeAgentToken, describeAgentTokens } from './agent-tokens.js'
18
+ import { codecFor, catBytes, startSealed, startSealedIfNew, grantSealed, rotateSealed, ensureSealingKey, describeSeal } from './sealed.js'
12
19
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
13
20
 
14
21
  export const API_URL = 'https://api.seam.heyquilt.com'
15
22
 
16
23
  /** The verbs `seam` answers. */
17
- export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'claim', 'unclaim', 'check', 'checks', 'resolutions', 'review', 'sign', 'key', 'token', 'runner'])
24
+ export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'stack', 'claim', 'unclaim', 'check', 'checks', 'resolutions', 'review', 'sign', 'key', 'token', 'block', 'runner', 'protect', 'team', 'verify', 'redact', 'metrics', 'seal', 'policy', 'org', 'watch', 'search', 'contains', 'daemon', 'clone', 'pull'])
18
25
 
19
26
  /** `ci=agent:ci,lint=agent:a+agent:b` → { ci: ['agent:ci'], lint: ['agent:a', 'agent:b'] }; `none` clears them. */
20
27
  export function parseRunners (text) {
@@ -41,8 +48,42 @@ export function parseTiers (text) {
41
48
  }
42
49
 
43
50
  /** `test/**,**\/*.test.js` → a list; `none` clears it. */
51
+ /** --pre-merge-checks on|off */
52
+ export function parseOnOff (text, flag) {
53
+ if (text === 'on' || text === 'true') return true
54
+ if (text === 'off' || text === 'false') return false
55
+ throw new Error(`${flag}: on or off (got ${text})`)
56
+ }
57
+
58
+ /** `db_branch=supabase:abcd,infra=v2` → { db_branch: 'supabase:abcd', infra: 'v2' } (square open --stack). */
59
+ export function parseStackFlag (text) {
60
+ const out = {}
61
+ for (const part of String(text).split(',').map((s) => s.trim()).filter(Boolean)) {
62
+ const i = part.indexOf('=')
63
+ if (i < 1) throw new Error(`--stack: key=value pairs separated by commas, e.g. db_branch=supabase:abcd (got ${part})`)
64
+ out[part.slice(0, i).trim()] = part.slice(i + 1).trim()
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** `10/120` → { patches: 10, seconds: 120 }; `none` → null (a batch per merge). */
70
+ export const parseBatch = (text) => {
71
+ if (text === 'none') return null
72
+ const m = /^(\d+)\/(\d+)$/.exec(text)
73
+ if (!m) throw new Error(`--batch: <patches>/<seconds>, e.g. 10/120, or none (got ${text})`)
74
+ return { patches: Number(m[1]), seconds: Number(m[2]) }
75
+ }
44
76
  export const parseGlobs = (text) => text === 'none' ? null : text.split(',').map((x) => x.trim()).filter(Boolean)
45
- const VALUE_FLAGS = new Set(['-m', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key', '--interval', '--credential', '--as', '--runners', '--run-key', '--state', '--tier', '--tiers', '--tests', '--ci-config', '--commit', '--check', '--log-url'])
77
+ // seam token agent new --checks [true|false] --rate commit_rate=10,merge_rate=5 (11.5, 11.9).
78
+ export function agentTokenExtras (flags) {
79
+ const out = {}
80
+ const c = flags['--checks']
81
+ if (c != null) out.checks = c === true || c === 'true' ? true : c === 'false' ? false : c
82
+ if (flags['--rate']) out.rate = Object.fromEntries(String(flags['--rate']).split(',').map((x) => x.trim()).filter(Boolean).map((x) => { const [k, v] = x.split('='); return [k.trim(), Number(v)] }))
83
+ return out
84
+ }
85
+
86
+ const VALUE_FLAGS = new Set(['--authors', '--debounce', '-m', '--installation', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key', '--interval', '--credential', '--as', '--runners', '--run-key', '--state', '--tier', '--tiers', '--tests', '--ci-config', '--commit', '--check', '--log-url', '--at', '--human-only', '--keys', '--reason', '--without', '--allow', '--rules', '--org', '--role', '--agent', '--paths', '--squares', '--hours', '--pre-merge-checks', '--by', '--mode', '--machine', '--add', '--remove', '--exclude', '--max-fetch-bytes', '--expires', '--out', '--auto-combine', '--needs-stack', '--monotonic-stack', '--migrations-in-order', '--rule', '--bisect-width', '--classes', '--min-lines', '-L', '--ignore-rev', '--reverse', '--grep', '-G', '--author', '--on-behalf-of', '--since', '--until', '--group-by', '--cursor', '-U', '--unified', '--renames', '--config', '--only', '--listen', '--idle-exit', '--wait', '--days', '--with', '--stack', '--scope', '--suggest', '--to-line', '--approval-survives', '--stack-defaults', '--stack-keys', '--lfs-url', '--lfs-threshold', '--offset', '--length', '--rate'])
46
87
 
47
88
  export function parse (argv) {
48
89
  const pos = []; const flags = {}; const paths = []
@@ -53,7 +94,8 @@ export function parse (argv) {
53
94
  if (a === '--') { rest = true; continue }
54
95
  const eq = a.match(/^(--[\w-]+)=(.*)$/)
55
96
  if (eq) { flags[eq[1]] = eq[2]; continue }
56
- if (VALUE_FLAGS.has(a)) { flags[a] = argv[++i]; continue }
97
+ // A value flag never takes the next flag as its value (`protect add … --exclude --quilt q`): it is then on.
98
+ if (VALUE_FLAGS.has(a)) { const v = argv[i + 1]; if (v === undefined || /^--[\w-]/.test(v)) flags[a] = true; else { flags[a] = v; i++ } continue }
57
99
  if (/^-n\d+$/.test(a)) { flags['-n'] = a.slice(2); continue }
58
100
  if (a.startsWith('-') && a.length > 1) { flags[a] = true; continue }
59
101
  pos.push(a)
@@ -70,11 +112,15 @@ export function connect (block, token) {
70
112
  }
71
113
 
72
114
  /** Runs one verb; returns the text to print (or JSON with --json). `token` is the signed-in person's, when there is one. */
73
- export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n') } = {}) {
115
+ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n'), stop = () => false } = {}) {
74
116
  const { pos, flags, paths } = parse(argv)
75
117
  const found = findBlock(cwd)
76
118
  const block = found?.block
77
- const { client, api } = connect(block, token)
119
+ // Reading an archive needs no server and no sign-in (14, exit path).
120
+ const offline = cmd === 'export' && (pos[0] === 'check' || pos[0] === 'extract' || (pos[0] === 'git' && !!flags['--from']))
121
+ const { client, api } = offline ? { client: null, api: null } : connect(block, token)
122
+ // A sealed quilt's block opens and seals its files with the key this computer holds (11.11).
123
+ if (!offline && block?.sealed && !block._codec) block._codec = await codecFor(client, block.quilt)
78
124
  const quilt = flags['--quilt'] || block?.quilt
79
125
  const needQuilt = () => { if (!quilt) throw new Error('Name the quilt with --quilt <slug or id> (or run this inside a block folder).'); return quilt }
80
126
  const needSquare = () => { const s = flags['--square'] || block?.square; if (!s) throw new Error('No square here: run this in a block folder, or pass --square sq:<id>.'); return s }
@@ -84,8 +130,65 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
84
130
 
85
131
  switch (cmd) {
86
132
  case 'quilts': {
87
- if (pos[0] === 'new') { const q = await client.createQuilt({ slug: pos[1], name: flags['--title'] }); return print(q, `Made quilt ${q.slug} (${q.id}).`) }
88
- if (pos[0] === 'add') { const r = await client.addMember(needQuilt(), { member_id: pos[1], level: flags['--level'] || 'write' }); return print(r, `${r.member_id}: ${r.level || 'removed'}`) }
133
+ if (pos[0] === 'new') {
134
+ const q = await client.createQuilt({ slug: pos[1], name: flags['--title'], ...(flags['--sealed'] ? { sealed: true } : {}), ...(flags['--org'] ? { org: flags['--org'] } : {}) })
135
+ if (!flags['--sealed']) return print(q, `Made quilt ${q.slug} (${q.id}).`)
136
+ // Its key is made here and wrapped for this computer; the server never holds it.
137
+ await startSealed(client, q.id, q.owner)
138
+ return print(q, `Made sealed quilt ${q.slug} (${q.id}). Its key is on this computer only: give it to other members with seam seal grant <member id>.`)
139
+ }
140
+ if (pos[0] === 'agents-make-lines') {
141
+ if (!['on', 'off'].includes(pos[1])) throw new Error('seam quilts agents-make-lines on|off --quilt <q>')
142
+ const r = await client.setAgentsMakeLines(needQuilt(), pos[1] === 'on')
143
+ return print(r, r.agents_make_lines ? `Agents may make lines, tags and releases in ${r.quilt}.` : `Only people make lines, tags and releases in ${r.quilt}.`)
144
+ }
145
+ if (pos[0] === 'move') {
146
+ if (!flags['--org']) throw new Error('seam quilts move --quilt <q> --org <org>')
147
+ const r = await client.setQuiltOwner(needQuilt(), flags['--org'])
148
+ return print(r, `${r.quilt} now belongs to ${r.org}.`)
149
+ }
150
+ if (pos[0] === 'sealed') {
151
+ if (!['on', 'off'].includes(pos[1])) throw new Error('seam quilts sealed on|off --quilt <q>')
152
+ const r = await client.setSealed(needQuilt(), pos[1] === 'on')
153
+ if (!r.sealed) return print(r, `${r.quilt} is not sealed: the server reads its files.`)
154
+ // As for seam quilts new --sealed: the key is made here and wrapped for this computer.
155
+ await startSealedIfNew(client, r.quilt_id)
156
+ return print(r, `${r.quilt} is sealed. Its key is on this computer only: give it to other members with seam seal grant <member id>.`)
157
+ }
158
+ if (pos[0] === 'public') {
159
+ if (!['on', 'off'].includes(pos[1])) throw new Error('seam quilts public on|off --quilt <q>')
160
+ const r = await client.setPublic(needQuilt(), pos[1] === 'on')
161
+ return print(r, r.public ? `${r.quilt} is public: anyone can read it, signed in or not. Share ${r.page || `${api}/v1/quilts/${r.quilt_id}`}` : `${r.quilt} is private again.`)
162
+ }
163
+ if (pos[0] === 'infra-paths') {
164
+ // seam quilts infra-paths <glob,glob> | default | none: what seam stack derives infra from.
165
+ if (!pos[1]) throw new Error('seam quilts infra-paths <glob,glob>|default|none --quilt <q>')
166
+ const r = await client.setInfraPaths(needQuilt(), pos[1] === 'none' ? null : pos[1] === 'default' ? 'default' : parseGlobs(pos[1]))
167
+ return print(r, r.infra_paths ? `${r.quilt} derives infra from ${r.infra_paths.join(', ')}.` : `${r.quilt} derives no infra.`)
168
+ }
169
+ if (pos[0] === 'public-reads') {
170
+ // seam quilts public-reads: what readers with no sign-in fetched, per hashed address and 10 minutes (11.10).
171
+ const r = await client.publicReads(needQuilt())
172
+ return print(r, describePublicReads(r))
173
+ }
174
+ if (pos[0] === 'owner-idle-hours') {
175
+ // seam quilts owner-idle-hours <1..24> | default: how long an owner claim holds after its square's last write there.
176
+ if (!pos[1]) throw new Error('seam quilts owner-idle-hours <1..24>|default --quilt <q>')
177
+ const r = await client.setOwnerIdleHours(needQuilt(), pos[1] === 'default' ? null : Number(pos[1]))
178
+ return print(r, `${r.quilt}'s owner claims let go after ${r.owner_idle_hours ?? 2} quiet hours.`)
179
+ }
180
+ if (pos[0] === 'migrations-dir') {
181
+ // seam quilts migrations-dir <folder> | none | default: where seam stack derives db_migration from.
182
+ if (!pos[1]) throw new Error('seam quilts migrations-dir <folder>|none|default --quilt <q>')
183
+ const r = await client.setMigrationsDir(needQuilt(), pos[1] === 'none' ? '' : pos[1] === 'default' ? null : pos[1])
184
+ return print(r, r.migrations_dir === null ? `${r.quilt} derives db_migration from supabase/migrations.` : r.migrations_dir ? `${r.quilt} derives db_migration from ${r.migrations_dir}.` : `${r.quilt} derives no db_migration.`)
185
+ }
186
+ if (pos[0] === 'secret-scanning') {
187
+ if (!pos[1]) { const q = await client.quilt(needQuilt()); return print({ secret_scanning: q.secret_scanning }, `Secret scanning on ${q.slug}: ${q.secret_scanning}.`) }
188
+ const r = await client.setSecretScanning(needQuilt(), pos[1])
189
+ return print(r, `Secret scanning on ${r.quilt}: ${r.secret_scanning}.`)
190
+ }
191
+ if (pos[0] === 'add') { const r = await client.addMember(needQuilt(), { ...(String(pos[1] || '').includes('@') ? { email: pos[1] } : { member_id: pos[1] }), level: flags['--level'] || 'write' }); return print(r, r.invited ? `Invited ${r.email} (${r.level}): ${r.emailed ? 'Seam emailed them a link' : `send them ${r.sign_up}`}. They are in the quilt from their first sign-in.` : `${r.member_id}: ${r.level || 'removed'}`) }
89
192
  if (pos[0] === 'show') { const q = await client.quilt(pos[1] || needQuilt()); return print(q, `${q.slug} (${q.id})\n${q.lines.map((l) => ` ${l.name}: ${l.patch_count} patches, tip ${l.tip ? l.tip.slice(0, 12) : '(empty)'}`).join('\n')}`) }
90
193
  const list = await client.quilts()
91
194
  return print(list, list.length ? list.map((q) => `${q.slug} ${q.id} (${q.level})`).join('\n') : 'No quilts yet.')
@@ -114,12 +217,18 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
114
217
  const tiers = flags['--tiers'] === undefined ? undefined : parseTiers(String(flags['--tiers']))
115
218
  const tests = flags['--tests'] === undefined ? undefined : parseGlobs(String(flags['--tests']))
116
219
  const ciConfig = flags['--ci-config'] === undefined ? undefined : parseGlobs(String(flags['--ci-config']))
117
- const extra = { ...(tiers !== undefined ? { tiers } : {}), ...(tests !== undefined ? { tests } : {}), ...(ciConfig !== undefined ? { ci_config: ciConfig } : {}) }
220
+ // --human-only "supabase/migrations/**": paths agents read and never change on this line
221
+ const humanOnly = flags['--human-only'] === undefined ? undefined : parseGlobs(String(flags['--human-only']))
222
+ // --only deploy=src/**+!src/vault/**: the paths each check is about (it runs only when they change)
223
+ const only = flags['--only'] === undefined ? undefined : parseRunners(String(flags['--only']))
224
+ // --rules <file.json>: { rules, classes?, mode? }, what each kind of change needs (13)
225
+ const ruleFile = flags['--rules'] ? JSON.parse(fs.readFileSync(path.resolve(cwd, flags['--rules']), 'utf8')) : null
226
+ const extra = { ...(tiers !== undefined ? { tiers } : {}), ...(tests !== undefined ? { tests } : {}), ...(ciConfig !== undefined ? { ci_config: ciConfig } : {}), ...(humanOnly !== undefined ? { human_only: humanOnly } : {}), ...(only !== undefined ? { only } : {}), ...(ruleFile ? (Array.isArray(ruleFile) ? { rules: ruleFile } : ruleFile) : {}), ...(flags['--pre-merge-checks'] !== undefined ? { pre_merge_checks: parseOnOff(String(flags['--pre-merge-checks']), '--pre-merge-checks') } : {}), ...(flags['--auto-combine'] !== undefined ? { auto_combine: flags['--auto-combine'] } : {}), ...(flags['--approval-survives'] !== undefined ? { approval_survives: flags['--approval-survives'] === 'default' ? null : String(flags['--approval-survives']) } : {}), ...(flags['--batch'] !== undefined ? { batch: parseBatch(String(flags['--batch'])) } : {}), ...(flags['--bisect-width'] !== undefined ? { bisect_width: Number(flags['--bisect-width']) } : {}), ...(flags['--needs-stack'] !== undefined ? { needs_stack: ['none', true].includes(flags['--needs-stack']) ? null : String(flags['--needs-stack']).split(',').map((s) => s.trim()).filter(Boolean) } : {}), ...(flags['--monotonic-stack'] !== undefined ? { monotonic_stack: ['none', true].includes(flags['--monotonic-stack']) ? null : String(flags['--monotonic-stack']).split(',').map((s) => s.trim()).filter(Boolean) } : {}), ...(flags['--migrations-in-order'] !== undefined ? { migrations_in_order: parseOnOff(String(flags['--migrations-in-order']), '--migrations-in-order') } : {}), ...(flags['--stack-defaults'] !== undefined ? { stack_defaults: ['none', true].includes(flags['--stack-defaults']) ? null : parseStackFlag(flags['--stack-defaults']) } : {}) }
118
227
  if (pos[2] === undefined && checks === undefined && runners === undefined && !Object.keys(extra).length) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
119
228
  const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}), ...(runners !== undefined ? { runners } : {}), ...extra })
120
229
  return print(p, describePolicy(p))
121
230
  }
122
- throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint] [--runners ci=<member>] [--tiers e2e=post_merge] [--tests test/**] [--ci-config ops/**]')
231
+ throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint] [--runners ci=<member>] [--tiers e2e=post_merge] [--tests test/**] [--ci-config ops/**] [--human-only supabase/migrations/**] [--pre-merge-checks on|off] [--approval-survives none|whitespace|comment-only|docs|default] [--auto-combine lines|additive|whitespace] [--batch 10/120] [--bisect-width 8] [--needs-stack db_branch,db_migration|none] [--monotonic-stack db_migration|none] [--migrations-in-order on|off]')
123
232
  }
124
233
  case 'tag':
125
234
  case 'release': {
@@ -135,39 +244,76 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
135
244
  return print(x, `Released ${x.name} at ${where(x)}. Read it as tag:${x.name}.`)
136
245
  }
137
246
  case 'events': {
138
- // seam events [--after n] [--kinds a,b] | --webhook <url> [--secret s] [--kinds a,b] | --webhook off
247
+ // seam events [--after n] [--kinds a,b] [--wait seconds | --follow] | --webhook <url> [--add | --remove] [--secret s] [--kinds a,b] | --webhook off
139
248
  const kinds = flags['--kinds'] ? String(flags['--kinds']).split(',') : undefined
140
249
  if (flags['--webhook']) {
141
250
  const off = flags['--webhook'] === 'off'
142
- const r = await client.subscribe(needQuilt(), off ? { url: null } : { url: flags['--webhook'], secret: flags['--secret'], events: kinds })
143
- return print(r, off ? 'Webhook removed.' : `Events (${r.events.join(', ')}) now POST to ${r.url}${r.secret ? `\nSigning secret (shown once): ${r.secret}` : ''}`)
251
+ if (!off && flags['--remove']) { const r = await client.subscribe(needQuilt(), { url: flags['--webhook'], remove: true }); return print(r, `Webhook to ${r.url} removed.${r.hooks.length ? ` Still posting to ${r.hooks.map((h) => h.url).join(', ')}.` : ''}`) }
252
+ const r = await client.subscribe(needQuilt(), off ? { url: null } : { url: flags['--webhook'], secret: flags['--secret'], events: kinds, ...(flags['--add'] ? { add: true } : {}) })
253
+ return print(r, off ? 'Webhooks removed.' : `Events (${r.events.join(', ')}) now POST to ${r.url}${r.hooks?.length > 1 ? ` (and ${r.hooks.length - 1} more webhook(s) of yours)` : ''}${r.secret ? `\nSigning secret (shown once): ${r.secret}` : ''}`)
254
+ }
255
+ if (flags['--follow']) {
256
+ // Prints each event as it arrives (a loop of 30-second long-polls) until stopped; --json: one line per event.
257
+ let after = flags['--after']
258
+ while (!stop()) {
259
+ const r = await client.events(needQuilt(), { after, kinds, wait: 30 })
260
+ if (r.events.length) out(json ? r.events.map((e) => JSON.stringify(e)).join('\n') : describeEvents(r))
261
+ after = r.after
262
+ }
263
+ return
144
264
  }
145
- const r = await client.events(needQuilt(), { after: flags['--after'], kinds })
265
+ const r = await client.events(needQuilt(), { after: flags['--after'], kinds, ...(flags['--wait'] ? { wait: flags['--wait'] } : {}) })
146
266
  return print(r, describeEvents(r))
147
267
  }
148
268
  case 'square': {
149
269
  const sub = pos[0] || 'list'
150
270
  if (sub === 'open') {
151
- const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'], ...(flags['--on'] ? { on: flags['--on'] } : {}) })
271
+ const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'], ...(flags['--on'] ? { on: flags['--on'] } : {}), ...(flags['--another'] ? { another: true } : {}), ...(flags['--stack-keys'] || typeof flags['--stack'] === 'string' ? { stack: parseStackFlag(flags['--stack-keys'] || flags['--stack']) } : {}) })
152
272
  let text = describeSquare(sq)
153
273
  if (flags['--dir']) {
154
274
  const dir = path.resolve(cwd, flags['--dir'])
155
- await checkout(client, { quilt: needQuilt(), square: sq.id, paths, dir, api })
275
+ await checkout(client, { quilt: needQuilt(), square: sq.id, squareRow: sq, paths, dir, api, codec: await codecFor(client, needQuilt()) })
156
276
  text += `\nChecked out into ${dir}.`
157
277
  }
158
278
  return print(sq, text)
159
279
  }
160
- // seam square handoff [sq] --to <member> [--note text] | abandon [sq] | reopen [sq]
280
+ // seam square handoff [sq] --to <member> [--note text] | abandon [sq] [--cascade] | reopen [sq] | archive [sq] | unarchive [sq] | hide [sq] | unhide [sq] | edit [sq] [--title t] [--task id] [--line name] | share [sq] --with <member> | unshare [sq] --with <member> | reparent [sq] --onto <sq>|--line
161
281
  if (sub === 'handoff') { const sq = await client.handoff(needQuilt(), pos[1] || needSquare(), { to: flags['--to'], note: flags['--note'] }); return print(sq, `Handed ${sq.short || sq.id} to ${sq.holder}.`) }
162
- if (sub === 'abandon') { const sq = await client.abandon(needQuilt(), pos[1] || needSquare()); return print(sq, `Abandoned ${sq.short || sq.id}; seam square reopen brings it back.`) }
282
+ if (sub === 'abandon') { const sq = await client.abandon(needQuilt(), pos[1] || needSquare(), flags['--cascade'] ? { children: 'cascade' } : {}); return print(sq, `Abandoned ${sq.short || sq.id}${sq.cascaded ? ` and the ${sq.cascaded.length} square(s) of yours stacked on it` : ''}; seam square reopen brings it back.`) }
163
283
  // seam square merged [sq] --as <git sha>: closes a square whose work went into git by hand (admins, during a follow)
164
284
  if (sub === 'merged') {
165
285
  if (!flags['--as']) throw new Error('Which git commit holds the work? seam square merged [sq:id] --as <sha>')
166
286
  const sq = await client.squareMerged(needQuilt(), pos[1] || needSquare(), { git_sha: flags['--as'] })
167
287
  return print(sq, `${sq.short || sq.id} is merged by hand: patch ${sq.merged.patch_no} on ${sq.merged.line} (git ${sq.merged.git_sha.slice(0, 12)}).`)
168
288
  }
289
+ if (sub === 'edit') {
290
+ const body = { ...(flags['--title'] !== undefined ? { title: flags['--title'] } : {}), ...(flags['--task'] !== undefined ? { task_id: flags['--task'] === 'none' ? null : flags['--task'] } : {}), ...(flags['--line'] !== undefined ? { line: flags['--line'] } : {}) }
291
+ if (!Object.keys(body).length) throw new Error('What to change? seam square edit [sq:id] --title "…" | --task <id|none> | --line <name>')
292
+ const sq = await client.editSquare(needQuilt(), pos[1] || needSquare(), body)
293
+ return print(sq, `Edited ${describeSquare(sq)}.`)
294
+ }
295
+ if (sub === 'reparent') {
296
+ if (!flags['--onto'] && !flags['--line']) throw new Error('Onto what? seam square reparent [sq:id] --onto <sq:id> | --line')
297
+ const r = await client.reparent(needQuilt(), pos[1] || needSquare(), { to: flags['--line'] ? 'line' : flags['--onto'] })
298
+ return print(r, r.noop ? 'Already there.' : `${r.square.short || r.square.id} now ${r.reparented.to ? `stacks on ${r.reparented.to}` : `sits on ${r.square.line}'s tip`}${r.conflicted ? `, with ${r.conflicted} conflict(s) to settle` : ''}. Run seam checkout ${r.square.short} to bring a folder there.`)
299
+ }
300
+ if (sub === 'share' || sub === 'unshare') {
301
+ if (!flags['--with']) throw new Error(`With whom? seam square ${sub} [sq:id] --with <member>`)
302
+ const sq = await client[sub](needQuilt(), pos[1] || needSquare(), { member: flags['--with'] })
303
+ return print(sq, `${sq.short || sq.id}'s writers: ${(sq.writers || []).join(', ') || 'only its holder'}.`)
304
+ }
305
+ if (sub === 'archive') { const sq = await client.archive(needQuilt(), pos[1] || needSquare()); return print(sq, `Archived ${sq.short || sq.id}: its claims are released and seam squares --archived lists it. A commit to it, or seam square unarchive, wakes it.`) }
306
+ if (sub === 'unarchive') { const sq = await client.unarchive(needQuilt(), pos[1] || needSquare()); return print(sq, `${sq.short || sq.id} is awake again; its claims were not restored.`) }
307
+ if (sub === 'hide') { const sq = await client.hide(needQuilt(), pos[1] || needSquare()); return print(sq, `Hid ${sq.short || sq.id}; it stays open, and seam squares --hidden lists it.`) }
308
+ if (sub === 'unhide') { const sq = await client.unhide(needQuilt(), pos[1] || needSquare()); return print(sq, `${sq.short || sq.id} is listed again.`) }
169
309
  if (sub === 'reopen') { const sq = await client.reopen(needQuilt(), pos[1] || needSquare()); return print(sq, `Reopened ${sq.short || sq.id}; you hold it.`) }
170
- // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> [--title t]
310
+ // seam square absorb [sq]: live work into the commit of the square that last changed each file
311
+ if (sub === 'absorb') {
312
+ const r = await client.absorb(needQuilt(), pos[1] || needSquare(), {})
313
+ const moved = r.amended.filter((a) => a.paths.length).map((a) => `${a.paths.join(', ')} into ${a.commit_before.slice(0, 12)}`)
314
+ 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
+ }
316
+ // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> | --by claim [--stacked] [--title t]
171
317
  if (sub === 'fold') {
172
318
  if (!flags['--into']) throw new Error('Fold into which square? seam square fold [sq:from] --into sq:<id>')
173
319
  const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message })
@@ -176,16 +322,36 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
176
322
  if (sub === 'split') {
177
323
  const own = pos[1] && pos[1].startsWith('sq:')
178
324
  const groups = pos.slice(own ? 2 : 1).map((g) => g.split(',').filter(Boolean))
179
- if (!groups.length) throw new Error('Split by which paths? seam square split [sq] docs,README.md src (one group per new square)')
180
- const r = await client.split(needQuilt(), own ? pos[1] : needSquare(), { by: 'paths', groups, ...(flags['--title'] ? { titles: [flags['--title']] } : {}) })
325
+ const by = flags['--by'] || 'paths'
326
+ if (by === 'paths' && !groups.length) throw new Error('Split by which paths? seam square split [sq] docs,README.md src (one group per new square), or --by claim')
327
+ const r = await client.split(needQuilt(), own ? pos[1] : needSquare(), { by, ...(by === 'paths' ? { groups } : {}), ...(flags['--stacked'] ? { stacked: true } : {}), ...(flags['--title'] ? { titles: [flags['--title']] } : {}) })
181
328
  const text = [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n')
182
329
  if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.`) }
183
330
  return print(r, text)
184
331
  }
185
332
  if (sub === 'show') { const sq = await client.square(needQuilt(), pos[1] || needSquare()); return print(sq, describeSquareStatus(sq)) }
186
- const list = await client.squares(needQuilt(), { state: flags['--all'] ? undefined : (flags['--state'] || 'open'), mine: !!flags['--mine'] })
333
+ const list = await client.squares(needQuilt(), { state: flags['--all'] ? undefined : (flags['--state'] || 'open'), mine: !!flags['--mine'], hidden: !!flags['--hidden'], archived: !!flags['--archived'] })
187
334
  return print(list, list.length ? list.map(describeSquare).join('\n') : 'No open squares.')
188
335
  }
336
+ case 'clone': {
337
+ // seam clone <repo> [dir]: main (or --line), ready to work: the first commit opens a square.
338
+ const repo = pos[0] || flags['--quilt']
339
+ if (!repo) throw new Error('seam clone <repo> [folder] [--line <line>]')
340
+ const line = flags['--line'] || 'main'
341
+ const name = String(repo).split('/').pop()
342
+ const dir = path.resolve(cwd, pos[1] || flags['--dir'] || name)
343
+ const r = await checkout(client, { quilt: repo, rev: line, paths, dir, api, codec: await codecFor(client, repo) })
344
+ return print({ dir, files: r.files, block: { ...r.block, files: undefined } }, `Cloned ${repo} (${line}, ${r.files} file(s)) into ${dir}.\nEdit, then seam commit -m "…": the first commit opens a square for it; seam merge lands it, seam pull brings others' patches in.`)
345
+ }
346
+ case 'pull': {
347
+ if (!found) throw new Error('seam pull works in a block folder: run it in a folder made by seam clone or seam checkout.')
348
+ const r = await pull(client, found.dir, block)
349
+ const kept = r.kept?.length ? `\nKept your own version of ${r.kept.join(', ')} (changed on both sides): compare with seam diff.` : ''
350
+ if (r.noop) return print(r, block.square ? `Already up to date: ${block.square_short || block.square} sits on ${block.line}'s tip.` : `Already up to date with ${block.line}.`)
351
+ if (r.left) return print(r, `${r.left.square} was ${r.left.state}; the folder is now on ${r.line} (${r.at.slice(0, 12)}, ${r.written} file(s) updated).` + kept)
352
+ if (r.rebased) return print(r, describeWrite(r.result, 'Rebased') + `\n${r.written} file(s) updated in the folder.` + kept)
353
+ return print(r, `Pulled ${r.line} (now ${r.at.slice(0, 12)}): ${r.written} file(s) updated.` + kept)
354
+ }
189
355
  case 'checkout': {
190
356
  const target = pos[0]
191
357
  if (!target) throw new Error('seam checkout <sq:id | revision> [--dir folder] [-- paths]')
@@ -201,17 +367,23 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
201
367
  return print(r, `Switched to ${sq ? `${sq.short} "${sq.title}"` : target} (${r.written} file(s) written).`)
202
368
  }
203
369
  const dir = path.resolve(cwd, flags['--dir'] || (isSquare ? target.replace(':', '-') : 'seam-checkout'))
204
- const r = await checkout(client, { quilt: needQuilt(), ...(isSquare ? { square: target } : { rev: target }), paths, dir, api })
370
+ const r = await checkout(client, { quilt: needQuilt(), ...(isSquare ? { square: target } : { rev: target }), paths, dir, api, codec: await codecFor(client, needQuilt()) })
205
371
  return print({ dir, files: r.files, block: { ...r.block, files: undefined } }, `Checked out ${r.files} file(s) of ${isSquare ? target : target} under ${r.block.paths.map((p) => p || '/').join(', ')} into ${dir}.`)
206
372
  }
207
373
  case 'runner': {
208
374
  // seam runner --check ci[,lint] --run "npm test" [--dir <folder>] [--interval <s>] [--log-url <template>] [--once] [--keep]
209
- const checks = String(flags['--check'] || '').split(',').map((c) => c.trim()).filter(Boolean)
210
- if (!checks.length || !flags['--run'] || flags['--run'] === true) throw new Error('seam runner --check <name>[,<name>] --run "<command>" [--dir <folder>] [--interval <seconds>] [--log-url <template>] [--once]')
211
- const q = needQuilt()
375
+ // seam runner --config runner.json: { quilt?, checks: { "<check>": "<command>" } }, one command per check
376
+ const conf = flags['--config'] ? readRunnerConfig(fs.readFileSync(path.resolve(cwd, String(flags['--config'])), 'utf8')) : null
377
+ const checks = conf ? Object.keys(conf.commands) : String(flags['--check'] || '').split(',').map((c) => c.trim()).filter(Boolean)
378
+ if (!conf && (!checks.length || !flags['--run'] || flags['--run'] === true)) throw new Error('seam runner --check <name>[,<name>] --run "<command>" | --config <file> [--dir <folder>] [--interval <seconds>] [--log-url <template>] [--once]')
379
+ const q = flags['--quilt'] || conf?.quilt || needQuilt()
212
380
  const say = (l) => { if (!flags['--json']) console.log(l) }
213
- if (!flags['--once']) say(`seam runner: running ${checks.join(', ')} for ${q} with: ${flags['--run']} (Ctrl-C to stop)`)
214
- const done = await runChecks({ client, quilt: q, checks, run: String(flags['--run']), api, once: !!flags['--once'], keep: !!flags['--keep'], ...(flags['--dir'] ? { dir: path.resolve(cwd, flags['--dir']) } : {}), ...(flags['--interval'] ? { intervalMs: Math.max(1, Number(flags['--interval'])) * 1000 } : {}), ...(flags['--log-url'] ? { logUrl: String(flags['--log-url']) } : {}), say })
381
+ if (!flags['--once']) say(`seam runner: running ${checks.join(', ')} for ${q}${conf ? ` (${flags['--config']})` : ` with: ${flags['--run']}`} (Ctrl-C to stop)`)
382
+ // --listen <port>: Seam's webhook wakes it; --idle-exit <seconds>: it stops when there is nothing to do
383
+ const listener = flags['--listen'] ? listenForWork({ port: Number(flags['--listen']), secret: process.env.SEAM_WEBHOOK_SECRET || null, say }) : null
384
+ const idle = flags['--idle-exit'] ? Number(flags['--idle-exit']) * 1000 : 0
385
+ const done = await runChecks({ client, quilt: q, checks, ...(conf ? { commands: conf.commands } : { run: String(flags['--run']) }), ...(listener ? { wake: listener.wake } : {}), ...(idle ? { idleExitMs: idle } : {}), api, once: !!flags['--once'], keep: !!flags['--keep'], ...(flags['--dir'] ? { dir: path.resolve(cwd, flags['--dir']) } : {}), ...(flags['--interval'] ? { intervalMs: Math.max(1, Number(flags['--interval'])) * 1000 } : {}), ...(flags['--log-url'] ? { logUrl: String(flags['--log-url']) } : {}), say })
386
+ listener?.server.close()
215
387
  return print({ runs: done }, done.length ? `Ran ${done.length} check run(s).` : 'Nothing queued.')
216
388
  }
217
389
  case 'status': {
@@ -221,25 +393,71 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
221
393
  }
222
394
  case 'review': {
223
395
  // seam review [sq] (the reviews and where the square stands)
224
- // seam review approve|request-changes|comment|dismiss [sq] [-m <text>] [--path <file> [--line <n>]]
225
- const VERDICT = { approve: 'approve', 'request-changes': 'request_changes', comment: 'comment', dismiss: 'dismiss' }
396
+ // seam review approve|request-changes|comment|dismiss [sq] [-m <text>] [--path <file> [--line <n>]] [--scope <glob>,<glob> (approve: only these paths)]
397
+ // seam review request [sq] [--to <member>[,<member>]] [-m <why>]: asks them, and whoever the line's unmet rules name, for a review
398
+ const VERDICT = { approve: 'approve', 'request-changes': 'request_changes', comment: 'comment', dismiss: 'dismiss', request: 'request' }
226
399
  const kind = VERDICT[pos[0]]
227
400
  const sq = (kind ? pos[1] : pos[0]) || needSquare()
401
+ // seam review interdiff [sq] [--since <rev>]: what changed since you reviewed (or since <rev>)
402
+ if (pos[0] === 'interdiff') { const r = await client.interdiff(needQuilt(), pos[1] || needSquare(), flags['--since'] ? { since: String(flags['--since']) } : {}); return print(r, describeInterdiff(r)) }
403
+ // seam review apply <review id> [sq]: applies a suggestion as a commit on the square (you must be its holder or a writer)
404
+ if (pos[0] === 'apply') {
405
+ if (!pos[1]) throw new Error('Which suggestion? seam review apply <review id> [square] (the id is in seam review)')
406
+ const r = await client.applySuggestion(needQuilt(), pos[2] || needSquare(), pos[1])
407
+ return print(r, `Applied the suggestion as ${r.result.id.slice(0, 12)} on ${r.square.id || pos[2] || 'the square'}.`)
408
+ }
228
409
  if (!kind) { const r = await client.reviews(needQuilt(), sq); return print(r, describeReviews(r)) }
229
- const r = await client.review(needQuilt(), sq, { kind, body: message || '', ...(flags['--path'] ? { path: flags['--path'] } : {}), ...(flags['--line'] ? { line: Number(flags['--line']) } : {}) })
410
+ const r = await client.review(needQuilt(), sq, { kind, body: message || '', ...(kind === 'request' && flags['--to'] ? { asked: String(flags['--to']).split(',').map((m) => m.trim()).filter(Boolean) } : {}), ...(flags['--path'] ? { path: flags['--path'] } : {}), ...(flags['--line'] ? { line: Number(flags['--line']) } : {}), ...(kind === 'approve' && flags['--scope'] ? { scope: String(flags['--scope']).split(',').map((g) => g.trim()).filter(Boolean) } : {}), ...(flags['--suggest'] != null ? { suggestion: { text: String(flags['--suggest']).replace(/\\n/g, '\n'), ...(flags['--to-line'] ? { to_line: Number(flags['--to-line']) } : {}) } } : {}) })
230
411
  return print(r, describeReviewed(r))
231
412
  }
413
+ case 'watch': {
414
+ // seam watch (yours; --all: everyone's) | seam watch <glob>... [--classes logic,schema] [--min-lines 50] [--line main] [--note why] | seam watch remove <id>
415
+ if (pos[0] === 'remove') {
416
+ if (!pos[1]) throw new Error('Which one? seam watch remove <id>')
417
+ const r = await client.unwatch(needQuilt(), pos[1])
418
+ return print(r, `Stopped watching (${r.removed}).`)
419
+ }
420
+ if (!pos.length) { const ws = await client.watches(needQuilt(), { all: !!flags['--all'] }); return print(ws, describeWatches(ws)) }
421
+ const w = await client.watch(needQuilt(), { paths: pos, ...(flags['--classes'] ? { classes: String(flags['--classes']).split(',').map((c) => c.trim()).filter(Boolean) } : {}), ...(flags['--min-lines'] != null ? { min_lines: Number(flags['--min-lines']) } : {}), ...(flags['--line'] ? { lines: [flags['--line']] } : {}), ...(flags['--note'] ? { note: flags['--note'] } : {}) })
422
+ return print(w, `Watching ${w.paths.join(', ')}${w.classes ? ` for ${w.classes.join(', ')} changes` : ''}${w.min_lines != null ? ` over ${w.min_lines} lines` : ''}: each matching patch sends you watch.triggered (seam events). ${w.id}`)
423
+ }
232
424
  case 'claim': {
233
- // seam claim (lists the quilt's claims) | seam claim <pattern>... [--note <why>] (claims them for your square)
425
+ // seam claim (lists the quilt's claims) | seam claim <pattern>... [--note <why>] [--expires <seconds>] (claims them for your square)
234
426
  if (!pos.length) { const cs = await client.claims(needQuilt(), flags['--square']); return print(cs, describeClaims(cs)) }
427
+ // seam claim request <claim id|pattern> --title <what for> [-m <plan>] | seam claim withdraw <request id> | seam claim handoff <pattern> [--to <request id>] [--note <context>] | seam claim finish [--note <context>]
428
+ if (pos[0] === 'request') {
429
+ if (!pos[1] || !flags['--title']) throw new Error('seam claim request <claim id|pattern> --title <what you need it for> [-m <your plan>]')
430
+ const r = await client.requestClaim(needQuilt(), { claim: pos[1], square: needSquare(), title: flags['--title'], ...(message ? { description: message } : {}) })
431
+ return print(r, `Waiting for ${pos[1]} as request ${r.id}: the holder hands it over when done (seam claim withdraw ${r.id} to stop waiting).`)
432
+ }
433
+ if (pos[0] === 'withdraw') { if (!pos[1]) throw new Error('seam claim withdraw <request id>'); const r = await client.withdrawRequest(needQuilt(), pos[1]); return print(r, `Withdrew request ${pos[1]}.`) }
434
+ if (pos[0] === 'handoff') {
435
+ if (!pos[1]) throw new Error('seam claim handoff <pattern|claim id> [--to <request id>] [--note <what you changed and what is left>]')
436
+ const r = await client.handoffClaim(needQuilt(), needSquare(), { claim: pos[1], ...(flags['--to'] ? { to: flags['--to'] } : {}), ...(flags['--note'] || message ? { note: flags['--note'] || message } : {}) })
437
+ return print(r, describeHandoff(r))
438
+ }
439
+ if (pos[0] === 'finish') {
440
+ const r = await client.finishClaims(needQuilt(), needSquare(), flags['--note'] || message ? { note: flags['--note'] || message } : {})
441
+ return print(r, describeFinished(r))
442
+ }
235
443
  const sq = needSquare()
236
444
  const made = []
237
- for (const p of pos) made.push(await client.claim(needQuilt(), sq, { pattern: p, note: flags['--note'] || message }))
238
- return print(made, 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 you run seam unclaim ${c.pattern}.`).join('\n'))
445
+ for (const p of pos) made.push(await client.claim(needQuilt(), sq, { pattern: p, note: flags['--note'] || message, ...(flags['--expires'] ? { expires_in: Number(flags['--expires']) } : {}) }))
446
+ return print(made, made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until ${c.expires_at ? `${new Date(c.expires_at).toISOString()}, ` : ''}the square merges or you run seam unclaim ${c.pattern}.`).join('\n'))
239
447
  }
240
448
  case 'check': {
241
449
  // seam check <name> red|green|flaky [--line main] [--patch n] | seam check <name> green|red --square <sq>
242
450
  const [name, status] = pos
451
+ if (name === 'pipeline') {
452
+ // seam check pipeline <check> [--line main] [--stop]: the open bisect of a red check and the last one's outcome; --stop ends it (admin)
453
+ if (!status) throw new Error('seam check pipeline <check> [--line <line>] [--stop]')
454
+ if (flags['--stop']) {
455
+ const s = await client.stopPipeline(needQuilt(), flags['--line'] || 'main', status)
456
+ return print(s, s.stopped ? `Stopped the bisect of ${status} on ${flags['--line'] || 'main'} (patches ${s.bisect.range[0] + 1} to ${s.bisect.range[1]}): its waiting runs are cancelled and nothing is reverted.` : `No bisect of ${status} is open on ${flags['--line'] || 'main'}.`)
457
+ }
458
+ const r = await client.pipeline(needQuilt(), flags['--line'] || 'main', status)
459
+ return print(r, describePipeline(r))
460
+ }
243
461
  if (name === 'rerun') {
244
462
  // seam check rerun <check> --square <sq> | --line <line> [--commit <id>]: ask its runners again
245
463
  if (!status) throw new Error('seam check rerun <check> --square <square> | --line <line> [--commit <id>]')
@@ -268,6 +486,27 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
268
486
  for (const p of pos) out.push(await client.unclaim(needQuilt(), sq, p))
269
487
  return print(out, out.map((r) => `Let go of ${r.released.pattern}.`).join('\n'))
270
488
  }
489
+ case 'daemon': {
490
+ // seam daemon (foreground: watches this block folder and shares each change as live work) | seam daemon status | flush | stop
491
+ if (!found) throw new Error('seam daemon watches a block folder: run it in a folder made by seam checkout or seam square open.')
492
+ const sub = pos[0]
493
+ if (sub === 'status' || sub === 'flush' || sub === 'stop' || sub === 'rebuild') {
494
+ const r = await askDaemon(found.dir, sub, sub === 'status' ? 'GET' : 'POST')
495
+ if (!r) return print({ running: false }, `No daemon is running for ${found.dir}: start one with seam daemon.`)
496
+ if (sub === 'stop') return print(r, 'The daemon is stopping.')
497
+ if (sub === 'rebuild') return print(r, describeRebuild(r))
498
+ return print(r, `Watching ${r.dir} on ${String(r.square).startsWith("sq:") ? r.square : `sq:${r.square}`} (${r.state}): sent ${r.sent} time(s)${r.pending.length ? `, ${r.pending.length} path(s) waiting` : ''}${r.last_error ? `. ${r.last_error}` : ''}.`)
499
+ }
500
+ if (sub) throw new Error(`seam daemon ${sub}? seam daemon | daemon status | daemon flush | daemon rebuild | daemon stop`)
501
+ const d = await startDaemon({ client, dir: found.dir, codec: block._codec || null, ...(flags['--debounce'] != null ? { debounceMs: Number(flags['--debounce']) } : {}) })
502
+ if (!json) out(`seam daemon: sharing each change in ${found.dir} as live work on ${block.square} (Ctrl-C to stop; control API on 127.0.0.1:${d.port}).`)
503
+ const onSignal = () => d.stop()
504
+ process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal)
505
+ const wait = setInterval(() => { if (stop()) d.stop() }, 500)
506
+ await d.stopped()
507
+ clearInterval(wait); process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal)
508
+ return print(d.status(), 'The daemon stopped.')
509
+ }
271
510
  case 'live': {
272
511
  // seam live [sq] | live share (a block folder's uncommitted edits) | live clear
273
512
  const sub = pos[0]
@@ -275,10 +514,21 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
275
514
  const sqRef = flags['--square'] || block?.square
276
515
  if (!sqRef) throw new Error(`seam live ${sub} works in a block folder on a square (or pass --square sq:<id>).`)
277
516
  if (sub === 'share' && !found) throw new Error('seam live share sends a block folder\'s uncommitted edits: run it in a folder made by seam checkout.')
278
- const changes = sub === 'clear' ? [] : 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 })
517
+ // seam live share <paths>: only those paths, merged into what the square shares (others' paths stay).
518
+ const named = sub === 'share' ? [...pos.slice(1), ...paths] : []
519
+ if (named.length) {
520
+ const r = await client.patchLive(needQuilt(), sqRef, livePatch(found.dir, block, named.map((p) => path.resolve(cwd, p))))
521
+ return print(r, describeLiveSet(r))
522
+ }
523
+ const changes = sub === 'clear' ? [] : localChanges(found.dir, block).map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: bytesOf(c).toString('base64'), mode: c.mode })
279
524
  const r = await client.setLive(needQuilt(), sqRef, { changes })
280
525
  return print(r, describeLiveSet(r))
281
526
  }
527
+ if (sub === 'join') {
528
+ // seam live join [sq] [--member <member>]: whether a member may load the square's live work whole
529
+ const r = await client.liveJoin(needQuilt(), pos[1] || needSquare(), flags['--member'])
530
+ return print(r, describeLiveJoin(r))
531
+ }
282
532
  const r = await client.live(needQuilt(), sub || needSquare())
283
533
  return print(r, describeLive(r))
284
534
  }
@@ -296,6 +546,8 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
296
546
  if (!found) {
297
547
  throw new Error('seam commit seals a block folder\'s changes; run it in a folder made by seam checkout.')
298
548
  }
549
+ // --allow sf_…,sf_…: secret findings a person has checked and lets through (11.9).
550
+ const allowed = flags['--allow'] ? String(flags['--allow']).split(',').map((x) => x.trim()).filter(Boolean) : []
299
551
  // --sign: signs the new commit with this computer's key (seam key add).
300
552
  const signed = async (r) => {
301
553
  if (!flags['--sign'] || !r?.result?.id) return ''
@@ -304,28 +556,65 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
304
556
  return `\nSigned with ${s.signature.key}.`
305
557
  }
306
558
  if (flags['--amend']) {
307
- const r = await amendFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
559
+ const r = await amendFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)), allow: allowed })
308
560
  const s = await signed(r)
309
561
  return print(r, describeWrite(r, `Amended ${r.amended.slice(0, 12)} as`) + s)
310
562
  }
311
563
  if (!message) throw new Error('Say what changed: seam commit -m "message" [paths...]')
312
- const r = await commitFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
564
+ const r = await commitFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)), allow: allowed })
313
565
  if (r.nothing) return print(r, 'Nothing to commit.')
314
566
  const s = await signed(r)
315
- return print(r, describeWrite(r, 'Committed') + s)
567
+ return print(r, describeOpened(r) + describeWrite(r, 'Committed') + s)
316
568
  }
317
569
  case 'sign': {
318
570
  // seam sign [rev]: signs a commit you made (default: your square's head, or main's tip).
319
571
  const r = await signWithLocalKey(client, needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
320
572
  return print(r, `Signed ${r.short} with ${r.signature.key}.`)
321
573
  }
574
+ case 'block': {
575
+ // seam block [list] [--square <s>] [--mine] | open <square> [--paths a,b] [--mode sparse|virtual|api-only] [--machine <name>] [--at <rev>] [--read-only] [--exclude <globs>] [--max-fetch-bytes <n>] | touch <id> [--add a,b] [--remove a,b] | close <id>
576
+ const sub = pos[0] || 'list'
577
+ const csv = (f) => flags[f] == null ? undefined : String(flags[f]).split(',').map((x) => x.trim()).filter(Boolean)
578
+ const usage = 'seam block [list] | rebuild | open <square> [--paths a,b] [--mode sparse|virtual|api-only] [--machine <name>] [--at <rev>] [--read-only] | touch <id> [--add a,b] [--remove a,b] | close <id>'
579
+ if (sub === 'rebuild') {
580
+ // seam block rebuild: finishes a move of this folder that stopped part way (the journal in .seam)
581
+ if (!found) throw new Error('seam block rebuild works on a block folder; run it in a folder made by seam checkout.')
582
+ const r = await rebuildBlock(client, found.dir, block)
583
+ return print(r, describeRebuild(r))
584
+ }
585
+ if (sub === 'list') { const r = await client.blocks(needQuilt(), { square: flags['--square'], mine: flags['--mine'] ? 1 : undefined }); return print(r, describeBlocks(r)) }
586
+ if (sub === 'open') {
587
+ if (!pos[1]) throw new Error(usage)
588
+ const r = await client.openBlock(needQuilt(), { square: pos[1], paths: csv('--paths'), mode: flags['--mode'], machine: flags['--machine'] || os.hostname(), at: flags['--at'], read_only: flags['--read-only'] === true || undefined, exclude: csv('--exclude'), max_fetch_bytes: flags['--max-fetch-bytes'] == null ? undefined : Number(flags['--max-fetch-bytes']) })
589
+ return print(r, `Opened ${describeBlock(r)}`)
590
+ }
591
+ if (sub === 'touch') { if (!pos[1]) throw new Error(usage); const r = await client.touchBlock(needQuilt(), pos[1], { add: csv('--add'), remove: csv('--remove') }); return print(r, describeBlock(r)) }
592
+ if (sub === 'close') { if (!pos[1]) throw new Error(usage); const r = await client.closeBlock(needQuilt(), pos[1]); return print(r, `Closed block ${r.id}: the square and the folder's files are untouched.`) }
593
+ throw new Error(usage)
594
+ }
322
595
  case 'token': {
323
- // seam token [list] | new <name> [--level read|write] | revoke <id or name>
596
+ // seam token [list] | new <name> [--level read|write] [--days 1-365] | revoke <id or name>
597
+ // seam token agent [list] | new [--agent agent:<id>] [--level read|write|merge] [--squares own|sq:…,…] [--paths src,docs] [--hours 8] [--checks] [--rate commit_rate=10,merge_rate=5] | revoke <id>
324
598
  const sub = pos[0] || 'list'
599
+ if (sub === 'agent') {
600
+ const act = pos[1] || 'list'
601
+ if (act === 'list') { const r = await client.agentTokens(needQuilt()); return print(r, describeAgentTokens(r)) }
602
+ if (act === 'new') {
603
+ const squares = flags['--squares'] && flags['--squares'] !== 'own' ? String(flags['--squares']).split(',').map((x) => x.trim()).filter(Boolean) : 'own'
604
+ const r = await client.createAgentToken(needQuilt(), { ...(flags['--agent'] ? { agent: flags['--agent'] } : {}), max_level: flags['--level'] || 'write', squares, paths: flags['--paths'] ? String(flags['--paths']).split(',').map((x) => x.trim()).filter(Boolean) : [], ...(flags['--hours'] ? { ttl_s: Math.round(Number(flags['--hours']) * 3600) } : {}), ...agentTokenExtras(flags) })
605
+ return print(r, describeAgentToken(r))
606
+ }
607
+ if (act === 'revoke') {
608
+ if (!pos[2]) throw new Error('seam token agent revoke <id>')
609
+ const r = await client.revokeAgentToken(needQuilt(), pos[2])
610
+ return print(r, `Revoked agent token ${r.id}: it no longer reaches the quilt.`)
611
+ }
612
+ throw new Error('seam token agent [list] | new [--agent agent:<id>] [--level read|write|merge] [--squares own|…] [--paths …] [--hours n] | revoke <id>')
613
+ }
325
614
  if (sub === 'list') { const r = await client.tokens(needQuilt()); return print(r, describeTokens(r)) }
326
615
  if (sub === 'new') {
327
- if (!pos[1]) throw new Error('seam token new <name> [--level read|write]')
328
- const r = await client.createToken(needQuilt(), { name: pos[1], level: flags['--level'] || 'read' })
616
+ if (!pos[1]) throw new Error('seam token new <name> [--level read|write] [--days 1-365]')
617
+ const r = await client.createToken(needQuilt(), { name: pos[1], level: flags['--level'] || 'read', ...(flags['--days'] ? { expires_in_days: Number(flags['--days']) } : {}) })
329
618
  return print(r, describeNewToken(r))
330
619
  }
331
620
  if (sub === 'revoke') {
@@ -333,7 +622,137 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
333
622
  const r = await client.revokeToken(needQuilt(), pos[1])
334
623
  return print(r, `Revoked token ${r.name} (${r.id}): it no longer reaches the quilt.`)
335
624
  }
336
- throw new Error('seam token [list] | new <name> [--level read|write] | revoke <id or name>')
625
+ throw new Error('seam token [list] | new <name> [--level read|write] [--days 1-365] | revoke <id or name>')
626
+ }
627
+ case 'org': {
628
+ // seam org [list] | new <slug> [--title <name>] | show <org> | add <org> <member> [--role member|admin|owner] | remove <org> <member>
629
+ const sub = pos[0] || 'list'
630
+ if (sub === 'list') { const r = await client.orgs(); return print(r, describeOrgs(r)) }
631
+ if (sub === 'new') { if (!pos[1]) throw new Error('seam org new <slug>'); const r = await client.createOrg({ slug: pos[1], name: flags['--title'] }); return print(r, `Made org ${r.slug}; you are its owner. Make its quilts with seam quilts new <slug> --org ${r.slug}.`) }
632
+ if (sub === 'show') { if (!pos[1]) throw new Error('seam org show <org>'); const r = await client.org(pos[1]); return print(r, describeOrg(r)) }
633
+ if (sub === 'add' || sub === 'remove') {
634
+ if (!pos[1] || !pos[2]) throw new Error(`seam org ${sub} <org> <member>${sub === 'add' ? ' [--role member|admin|owner]' : ''}`)
635
+ const r = await client.setOrgMember(pos[1], pos[2], sub === 'add' ? (flags['--role'] || 'member') : null)
636
+ return print(r, (r.invited ? `Invited ${r.email}: ${r.emailed ? 'Seam emailed them a link' : `send them ${r.sign_up}`}. They are in the org from their first sign-in.\n` : '') + describeOrg(r))
637
+ }
638
+ if (sub === 'team') {
639
+ // seam org team <org> <name> <member>... : the team's members, replaced whole; none removes it.
640
+ if (!pos[1] || !pos[2]) throw new Error('seam org team <org> <name> [<member>...]')
641
+ const r = await client.setOrgTeam(pos[1], pos[2], pos.slice(3))
642
+ return print(r, describeOrgTeams(r))
643
+ }
644
+ throw new Error('seam org [list] | new <slug> | show <org> | add <org> <member> [--role r] | remove <org> <member> | team <org> <name> [<member>...]')
645
+ }
646
+ case 'policy': {
647
+ // seam policy versions|approve|reject|confirm <line> [<version>]: a line's policy versions (11.4)
648
+ if (['versions', 'approve', 'reject', 'confirm'].includes(pos[0])) {
649
+ if (!pos[1]) throw new Error(`seam policy ${pos[0]} <line>${pos[0] === 'versions' ? '' : ' <version>'}`)
650
+ const q = needQuilt()
651
+ if (pos[0] !== 'versions' && !pos[2]) throw new Error(`seam policy ${pos[0]} ${pos[1]} <version>`)
652
+ let r
653
+ if (pos[0] === 'versions') r = await client.linePolicyVersions(q, pos[1])
654
+ else if (pos[0] === 'approve') r = await client.decideLinePolicy(q, pos[1], pos[2], 'approve')
655
+ else if (pos[0] === 'reject') r = await client.decideLinePolicy(q, pos[1], pos[2], 'reject')
656
+ else r = await confirmWithLocalKey(client, q, pos[1], Number(pos[2]))
657
+ return print(r, describePolicyVersions(r))
658
+ }
659
+ // seam policy [check] [sq] [--line <line>]: the square's change classes and what its line's rules need
660
+ const sq = (pos[0] === 'check' ? pos[1] : pos[0]) || needSquare()
661
+ const r = await client.squarePolicy(needQuilt(), sq, flags['--line'] ? { line: flags['--line'] } : {})
662
+ return print(r, describeSquarePolicy(r))
663
+ }
664
+ case 'seal': {
665
+ // seam seal [status] | key | grant <member> | rotate [--without <member>,...]
666
+ const sub = pos[0] || 'status'
667
+ if (sub === 'key') { const k = await ensureSealingKey(client); return print({ key_id: k.id, public_key: k.public_key }, `This computer's sealing key is ${k.id}. A member who holds a sealed quilt's key gives it to you with seam seal grant <your member id>.`) }
668
+ const q = needQuilt()
669
+ if (sub === 'status') { const r = await client.seal(q); return print(r, describeSeal(r)) }
670
+ if (sub === 'grant') {
671
+ if (!pos[1]) throw new Error('seam seal grant <member id>')
672
+ const r = await grantSealed(client, q, pos[1])
673
+ return print(r, describeSeal(r))
674
+ }
675
+ if (sub === 'rotate') {
676
+ const r = await rotateSealed(client, q, { without: flags['--without'] ? String(flags['--without']).split(',').map((x) => x.trim()).filter(Boolean) : [] })
677
+ return print(r, describeSeal(r))
678
+ }
679
+ throw new Error('seam seal [status] | key | grant <member id> | rotate [--without <member id>,...]')
680
+ }
681
+ case 'metrics': {
682
+ // seam metrics: the numbers Seam keeps for the quilt that ops/alerts.yml names, and which are past their threshold (admin)
683
+ const r = await client.metrics(needQuilt()); return print(r, describeMetrics(r))
684
+ }
685
+ case 'redact': {
686
+ // seam redact <blob id | path[@rev]> --reason "<why>" [--yes] | seam redact list
687
+ const q = needQuilt()
688
+ if (!pos[0] || pos[0] === 'list') { const r = await client.redactions(q); return print(r, describeRedactions(r)) }
689
+ if (!flags['--reason']) throw new Error('seam redact <blob id | path[@rev]> --reason "<why>": the reason is kept in the log')
690
+ let blob = pos[0]
691
+ if (!/^[0-9a-f]{64}$/.test(blob)) {
692
+ const at = blob.lastIndexOf('@')
693
+ const [p, rev] = at > 0 ? [blob.slice(0, at), blob.slice(at + 1)] : [blob, flags['--square'] || block?.square || 'main']
694
+ blob = (await client.cat(q, rev, p)).blob
695
+ if (!blob) throw new Error(`${p} has no content to redact at ${rev}`)
696
+ }
697
+ if (!flags['--yes']) throw new Error(`Redacting ${blob.slice(0, 12)} cannot be undone: every copy of those bytes leaves the quilt. Run it again with --yes.`)
698
+ const r = await client.redact(q, { blob, reason: flags['--reason'] })
699
+ return print(r, describeRedacted(r))
700
+ }
701
+ case 'verify': {
702
+ // seam verify [--after <seq>] [--keys <file.json>]: checks the quilt's log against the server's signatures, on this computer
703
+ const keys = flags['--keys'] ? JSON.parse(fs.readFileSync(path.resolve(cwd, flags['--keys']), 'utf8')) : null
704
+ const v = await verifyQuilt(client, needQuilt(), { after: flags['--after'] || 0, keys: keys ? (Array.isArray(keys) ? keys : keys.keys) : null })
705
+ const r = print(v, describeVerify(v))
706
+ if (v.bad.length || v.gaps.length) process.exitCode = 1
707
+ return r
708
+ }
709
+ case 'protect': {
710
+ // seam protect [list] | add <path> <who> <level> [--exclude] [--at n] [--note why] | remove <n> | set <file.json> | reset | check [<path>]
711
+ const sub = pos[0] || 'list'
712
+ const q = needQuilt()
713
+ if (sub === 'list') { const r = await client.protections(q); return print(r, describeProtections(r)) }
714
+ if (sub === 'check') { const r = await client.access(q, pos[1] || ''); return print(r, describeAccess(r)) }
715
+ if (sub === 'reset') { const r = await client.setProtections(q, null); return print(r, describeProtections(r)) }
716
+ if (sub === 'set') {
717
+ if (!pos[1]) throw new Error('seam protect set <file.json>: a list of { path, principal, level, exclude? }')
718
+ const rows = JSON.parse(fs.readFileSync(path.resolve(cwd, pos[1]), 'utf8'))
719
+ const r = await client.setProtections(q, Array.isArray(rows) ? rows : rows.rows)
720
+ return print(r, describeProtections(r))
721
+ }
722
+ const cur = (await client.protections(q)).rows.map(({ n, ...x }) => x)
723
+ if (sub === 'add') {
724
+ if (pos.length < 4) throw new Error("seam protect add <path> <who> <level> [--exclude] [--at n]: who is everyone, people, agents, role:read|write|admin, team:<name>, person:<id>, agent:<id>, token:<id> or (an org's quilt) org_role:member|admin|owner; path '' is the whole quilt")
725
+ const row = { path: pos[1] === "''" ? '' : pos[1], principal: pos[2], level: pos[3], exclude: !!flags['--exclude'], ...(flags['--note'] ? { note: flags['--note'] } : {}) }
726
+ const at = flags['--at'] ? Number(flags['--at']) : cur.length + 1
727
+ if (!Number.isInteger(at) || at < 1 || at > cur.length + 1) throw new Error(`--at is a position from 1 to ${cur.length + 1}`)
728
+ cur.splice(at - 1, 0, row)
729
+ const r = await client.setProtections(q, cur)
730
+ return print(r, describeProtections(r))
731
+ }
732
+ if (sub === 'remove') {
733
+ const n = Number(pos[1])
734
+ if (!Number.isInteger(n) || n < 1 || n > cur.length) throw new Error(`seam protect remove <n>: a row number from 1 to ${cur.length}`)
735
+ cur.splice(n - 1, 1)
736
+ const r = await client.setProtections(q, cur)
737
+ return print(r, describeProtections(r))
738
+ }
739
+ throw new Error('seam protect [list] | add <path> <who> <level> [--exclude] [--at n] | remove <n> | set <file.json> | reset | check [<path>]')
740
+ }
741
+ case 'team': {
742
+ // seam team [list] | set <name> <member>... | remove <name>
743
+ const sub = pos[0] || 'list'
744
+ if (sub === 'list') { const r = await client.teams(needQuilt()); return print(r, describeTeams(r)) }
745
+ if (sub === 'set') {
746
+ if (!pos[1] || pos.length < 3) throw new Error('seam team set <name> <member> [<member>...]')
747
+ const r = await client.setTeam(needQuilt(), pos[1], pos.slice(2))
748
+ return print(r, describeTeams(r))
749
+ }
750
+ if (sub === 'remove') {
751
+ if (!pos[1]) throw new Error('seam team remove <name>')
752
+ const r = await client.setTeam(needQuilt(), pos[1], [])
753
+ return print(r, describeTeams(r))
754
+ }
755
+ throw new Error('seam team [list] | set <name> <member>... | remove <name>')
337
756
  }
338
757
  case 'key': {
339
758
  // seam key [list [member]] | add [--name <n>] [--public-key <file>] | remove <key>
@@ -353,18 +772,49 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
353
772
  }
354
773
  case 'log': {
355
774
  const rev = pos[0] || (block?.square ? block.square_short : block?.line) || 'main'
356
- const r = await client.log(needQuilt(), { rev, paths, limit: flags['-n'] || flags['--max-count'] || 20, follow: flags['--follow'] ? true : undefined, show_signature: flags['--show-signature'] ? true : undefined, search: typeof flags['-S'] === 'string' ? flags['-S'] : undefined })
357
- return print(r, describeLog(r, { oneline: !!flags['--oneline'] }))
775
+ // Filters: --grep <text> (-E regex, -i any case), -G <regex>, -S <text>, --author, --on-behalf-of, --task, --since, --until.
776
+ const str = (k) => typeof flags[k] === 'string' ? flags[k] : undefined
777
+ const r = await client.log(needQuilt(), {
778
+ rev, paths, limit: flags['-n'] || flags['--max-count'] || 20, follow: flags['--follow'] ? true : undefined, show_signature: flags['--show-signature'] ? true : undefined, search: str('-S'),
779
+ message: str('--grep'), message_regex: str('--grep') && flags['-E'] ? 1 : undefined, message_ignore_case: str('--grep') && flags['-i'] ? 1 : undefined,
780
+ pickaxe: str('-G'), pickaxe_regex: str('-G') ? 1 : undefined, pickaxe_mode: str('-G') ? 'match' : undefined,
781
+ author: str('--author'), on_behalf_of: str('--on-behalf-of'), task: str('--task'), since: str('--since'), until: str('--until'),
782
+ group_by: str('--group-by'), graph: flags['--graph'] ? 1 : undefined, include_hidden: flags['--include-hidden'] ? 1 : undefined, cursor: str('--cursor')
783
+ })
784
+ return print(r, describeLog(r, { oneline: !!flags['--oneline'], graph: !!flags['--graph'] }))
358
785
  }
359
786
  case 'describe': {
360
787
  const r = await client.describe(needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
361
788
  return print(r, r.describe)
362
789
  }
790
+ case 'stack': {
791
+ // seam stack [rev]: the full-stack pointers at a revision (db_migration, db_branch, infra, …).
792
+ const r = await client.stack(needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
793
+ return print(r, describeStack(r))
794
+ }
363
795
  case 'merge-base': {
364
- if (pos.length !== 2) throw new Error('seam merge-base <revision> <revision>')
796
+ if (pos.length !== 2) throw new Error('seam merge-base [--is-ancestor] <revision> <revision>')
797
+ if (flags['--is-ancestor']) {
798
+ // git merge-base --is-ancestor a b: whether a is in b's history.
799
+ const r = await client.ancestor(needQuilt(), pos[0], pos[1])
800
+ return print(r, describeAncestor(r, pos[0], pos[1]))
801
+ }
365
802
  const r = await client.mergeBase(needQuilt(), pos[0], pos[1])
366
803
  return print(r, describeMergeBase(r))
367
804
  }
805
+ case 'contains': {
806
+ // seam contains <rev>: the lines whose history has it (git branch --contains), and where it was cherry-picked or reverted.
807
+ if (pos.length !== 1) throw new Error('seam contains <revision>')
808
+ const r = await client.contains(needQuilt(), pos[0])
809
+ return print(r, describeContains(r))
810
+ }
811
+ case 'search': {
812
+ // seam search <query> [--at <rev>] [--all | --quilts a,b]: words, "phrases", /regex/, path:, lang:, symbol:, class:, -term, OR
813
+ if (!pos.length) throw new Error('seam search <query> [--at <rev>] [--all | --quilts a,b] e.g. seam search "symbol:mergeSquare lang:js"')
814
+ const q = pos.join(' ')
815
+ const r = flags['--all'] || flags['--quilts'] ? await client.search({ q, rev: flags['--at'], quilts: flags['--quilts'] ? String(flags['--quilts']).split(',') : undefined, limit: flags['--max-count'] }) : await client.searchCode(needQuilt(), { q, rev: flags['--at'] || (block?.square ? block.square_short : undefined), limit: flags['--max-count'] })
816
+ return print(r, describeSearch(r))
817
+ }
368
818
  case 'grep': {
369
819
  // seam grep <pattern> [revision] [-- paths]: -i ignores case, -F takes the pattern as plain text, -l lists files.
370
820
  if (!pos[0]) throw new Error('seam grep <pattern> [revision] [-i] [-F] [-l] [--all] [-- paths]')
@@ -379,16 +829,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
379
829
  return print(r, describeGrep(r, { filesOnly: !!flags['-l'] }))
380
830
  }
381
831
  case 'bisect': {
382
- // seam bisect <bad> <good> --run "<check>": exit 0 good, 125 skip, else bad. Without --run, one step (--marks id:good,id:bad).
832
+ // seam bisect <bad> <good> --run "<check>" [-- paths]: exit 0 good, 125 skip, else bad. Without --run, one step (--marks id:good,id:bad). Paths narrow the commits tested.
383
833
  if (pos.length !== 2) throw new Error('seam bisect <bad> <good> --run "<check command>" (or --marks <commit>:good,<commit>:bad for one step)')
384
834
  const marks = Object.fromEntries(String(flags['--marks'] || '').split(',').filter(Boolean).map((x) => x.split(':')))
385
835
  if (typeof flags['--run'] === 'string') {
386
836
  const { bisectRun } = await import('./bisect-run.js')
387
- const r = await bisectRun(client, { quilt: needQuilt(), bad: pos[0], good: pos[1], run: flags['--run'], marks, api, log: (s) => { if (!json) out(s) } })
837
+ const r = await bisectRun(client, { quilt: needQuilt(), bad: pos[0], good: pos[1], run: flags['--run'], marks, only: paths, api, log: (s) => { if (!json) out(s) } })
388
838
  return print(r, describeBisect(r))
389
839
  }
390
- const r = await client.bisect(needQuilt(), { bad: pos[0], good: pos[1], marks })
391
- return print(r, describeBisect(r) + (r.done ? '' : `\nThen: seam bisect ${pos[0]} ${pos[1]} --marks ${[...Object.entries(marks).map(([k, v]) => `${k}:${v}`), `${r.next.short}:<good|bad|skip>`].join(',')}`))
840
+ const r = await client.bisect(needQuilt(), { bad: pos[0], good: pos[1], marks, paths })
841
+ return print(r, describeBisect(r) + (r.done ? '' : `\nThen: seam bisect ${pos[0]} ${pos[1]} --marks ${[...Object.entries(marks).map(([k, v]) => `${k}:${v}`), `${r.next.short}:<good|bad|skip>`].join(',')}${paths.length ? ` -- ${paths.join(' ')}` : ''}`))
392
842
  }
393
843
  case 'show': {
394
844
  const rev = pos[0] || (block?.square ? block.square_short : 'main')
@@ -403,7 +853,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
403
853
  return print(d, format === 'unified' ? (d.text || '') : d.changes.map((c) => `${c.status[0].toUpperCase()}\t${c.path}`).join('\n'))
404
854
  }
405
855
  const [from, to] = pos.length === 1 && pos[0].includes('..') ? pos[0].split('..') : pos.length === 1 ? [pos[0], block?.square ? block.square_short : 'main'] : pos
406
- const r = await client.diff(needQuilt(), { from, to, paths, format })
856
+ // --stack: which full-stack pointers (db_migration, db_branch, …) changed, not the files.
857
+ if (flags['--stack']) { const r = await client.stackDiff(needQuilt(), from, to); return print(r, describeStackDiff(r)) }
858
+ // --renames off|exact|similar (default similar; --no-renames: off), -w / --ignore-space-at-eol, -U <n>; every page unless --cursor or -n.
859
+ const opts = { from, to, paths, format, renames: flags['--no-renames'] ? 'off' : flags['--renames'] || 'similar', ignore_whitespace: flags['-w'] || flags['--ignore-all-space'] ? 'all' : flags['--ignore-space-at-eol'] ? 'trailing' : undefined, context: flags['-U'] ?? flags['--unified'], limit: flags['-n'] || flags['--max-count'], cursor: flags['--cursor'] }
860
+ const r = await client.diff(needQuilt(), opts)
861
+ for (let page = 0; r.cursor && !opts.cursor && !opts.limit && page < 100; page++) {
862
+ const more = await client.diff(needQuilt(), { ...opts, cursor: r.cursor })
863
+ r.files.push(...more.files); r.add += more.add || 0; r.del += more.del || 0; r.renames = (r.renames || 0) + (more.renames || 0)
864
+ r.cursor = more.cursor
865
+ }
407
866
  return print(r, describeDiff(r, { format }))
408
867
  }
409
868
  case 'merge': {
@@ -421,8 +880,13 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
421
880
  return print(r, text)
422
881
  }
423
882
  const sqId = pos[0] && pos[0].startsWith('sq:') ? pos[0] : needSquare()
424
- const r = await client.merge(needQuilt(), into, { square: sqId, ...(found && sqId === block.square ? { expect_head: block.head } : {}), message, dry_run: !!flags['--dry-run'], on_conflict: flags['--on-conflict'], expect_tip: flags['--expect-tip'] })
883
+ const r = await client.merge(needQuilt(), into, { square: sqId, ...(found && sqId === block.square ? { expect_head: block.head } : {}), message, dry_run: !!flags['--dry-run'], on_conflict: flags['--on-conflict'], expect_tip: flags['--expect-tip'], ...(flags['--no-commit-live'] ? { commit_live: false } : {}) })
425
884
  let text = describeMerge(r)
885
+ // Merged from its own folder: the folder moves to the line's new tip, ready for the next change.
886
+ if (found && sqId === block.square && r.ok && r.merged?.commit && !flags['--dry-run']) {
887
+ const s = await toLineTip(client, found.dir, block, r.merged.line || into, r.merged.commit)
888
+ text += `\nThe folder is now on ${r.merged.line || into} (${s.written} file(s) updated); your next commit opens a new square.`
889
+ }
426
890
  // The refused merge left the square rebased onto the tip with its conflicts: bring the folder there.
427
891
  if (found && sqId === block.square && !r.ok && r.head && r.head !== block.head) {
428
892
  const s = await syncTo(client, found.dir, block, r.head, { square: r.square })
@@ -447,12 +911,21 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
447
911
  if (!pos[0]) throw new Error('seam revert <patch:n | revision>')
448
912
  const r = await client.revert(needQuilt(), { rev: pos[0], line: flags['--line'] })
449
913
  let text = describeWrite(r, 'Reverted in a new square') + `\nMerge it with: seam merge ${r.square.short} --into ${r.square.line}`
450
- if (flags['--dir']) { const dir = path.resolve(cwd, flags['--dir']); await checkout(client, { quilt: needQuilt(), square: r.square.id, dir, api }); text += `\nChecked out into ${dir}.` }
914
+ if (flags['--dir']) { const dir = path.resolve(cwd, flags['--dir']); await checkout(client, { quilt: needQuilt(), square: r.square.id, dir, api, codec: await codecFor(client, needQuilt()) }); text += `\nChecked out into ${dir}.` }
451
915
  return print(r, text)
452
916
  }
453
917
  case 'resolve': {
454
- const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--side'] != null ? Number(flags['--side']) : null
455
- if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete [--no-remember] (or edit the file and commit it without the markers)')
918
+ const rule = flags['--rule']
919
+ if (rule !== undefined) {
920
+ if (!pos.length) throw new Error('seam resolve <path>... --rule additive|whitespace|words|union')
921
+ const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
922
+ const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), rule })), ...(flags['--no-remember'] ? { remember: false } : {}) })
923
+ let text = describeWrite(r, 'Resolved')
924
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
925
+ return print(r, text)
926
+ }
927
+ const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--keep-both'] ? 'keep_both' : flags['--side'] != null ? Number(flags['--side']) : null
928
+ if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete | --keep-both | --rule <step> [--no-remember] (or edit the file and commit it without the markers)')
456
929
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
457
930
  const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })), ...(flags['--no-remember'] ? { remember: false } : {}) })
458
931
  let text = describeWrite(r, 'Resolved')
@@ -464,9 +937,9 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
464
937
  if (pos[0] === 'forget') {
465
938
  if (!pos[1]) throw new Error('Which one? seam resolutions forget <key>')
466
939
  const r = await client.forgetResolution(needQuilt(), pos[1])
467
- return print(r, `Forgot ${r.forgotten.short} (${r.forgotten.path}): that conflict will be asked about again.`)
940
+ return print(r, `Forgot ${r.forgotten.short} (${r.forgotten.path}): that conflict will be asked about again${r.hunks ? ` (with the ${r.hunks} region${r.hunks === 1 ? '' : 's'} remembered with it)` : ''}.`)
468
941
  }
469
- const rows = await client.resolutions(needQuilt(), pos[0])
942
+ const rows = await client.resolutions(needQuilt(), pos[0], { hunks: !!flags['--hunks'] })
470
943
  return print(rows, describeResolutions(rows))
471
944
  }
472
945
  case 'blame': {
@@ -475,29 +948,63 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
475
948
  const at = arg.lastIndexOf('@')
476
949
  const [p, rev] = at > 0 ? [arg.slice(0, at), arg.slice(at + 1)] : [arg, pos[1] || (block?.square ? block.square_short : 'main')]
477
950
  const rel = found && at < 0 ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
478
- const r = await client.blame(needQuilt(), rev, rel)
951
+ // -L a,b: a span; --ignore-rev <rev> (again for more; .seam-blame-ignore is read too); -M: moves keep their origin; --reverse <rev>
952
+ const ignore = typeof flags['--ignore-rev'] === 'string' ? flags['--ignore-rev'].split(',').map((x) => x.trim()).filter(Boolean) : []
953
+ const r = await client.blame(needQuilt(), rev, rel, { ...(flags['-L'] ? { range: String(flags['-L']) } : {}), ...(ignore.length ? { ignore } : {}), ...(flags['-M'] ? { moves: true } : {}), ...(flags['--reverse'] ? { reverse: String(flags['--reverse']) } : {}) })
479
954
  return print(r, describeBlame(r))
480
955
  }
481
956
  case 'cat': {
482
957
  const arg = pos[0] || ''
483
958
  const at = arg.lastIndexOf('@')
484
959
  const [p, rev] = at > 0 ? [arg.slice(0, at), arg.slice(at + 1)] : [arg, 'main']
485
- const r = await client.cat(needQuilt(), rev, p, { format: flags['--format'] || 'diff' })
960
+ const ranged = flags['--offset'] != null || flags['--length'] != null
961
+ let r
962
+ try {
963
+ r = await client.cat(needQuilt(), rev, p, { format: flags['--format'] || conflictStyle(), ...(flags['--offset'] != null ? { offset: flags['--offset'] } : {}), ...(flags['--length'] != null ? { length: flags['--length'] } : {}) })
964
+ } catch (err) {
965
+ // A file too large for one JSON answer (10) streams to stdout as it comes.
966
+ if (err.code !== 'too_large' || json || ranged) throw err
967
+ const s = await client.catRaw(needQuilt(), rev, p)
968
+ for await (const piece of s.body) if (!process.stdout.write(piece)) await new Promise((resolve) => process.stdout.once('drain', resolve))
969
+ return
970
+ }
486
971
  if (json) return print(r, '')
487
- process.stdout.write(Buffer.from(r.content, 'base64'))
972
+ process.stdout.write(await catBytes(client, needQuilt(), r, r.sealed ? await codecFor(client, needQuilt()) : null))
488
973
  return
489
974
  }
490
975
  case 'ls': {
491
976
  const r = await client.tree(needQuilt(), pos[0] || 'main', pos.slice(1).concat(paths))
492
977
  return print(r, r.entries.map((e) => `${e.mode}\t${e.blob ? e.blob.slice(0, 12) : e.term.slice(0, 12)}\t${e.path}`).join('\n'))
493
978
  }
979
+ case 'github': {
980
+ // GitHub through the Seam app: what it reaches, and importing (and following) a repository.
981
+ if (pos[0] === 'import') {
982
+ if (!pos[1] || !flags['--installation']) throw new Error('seam github import <owner/name> --installation <id> [--into <slug>] [--no-follow]')
983
+ const r = await client.importGithub({ installation_id: flags['--installation'], repo: pos[1], slug: flags['--into'] || undefined, follow: !flags['--no-follow'] })
984
+ return print(r, `Importing ${pos[1]} (import ${r.id})${r.follow ? ', following it: GitHub tells Seam of every push' : ''}. seam import status ${r.id}`)
985
+ }
986
+ const g = await client.github()
987
+ if (!g.configured) return print(g, 'GitHub is not set up on this Seam server.')
988
+ if (!g.installations.length) return print(g, `No GitHub installations yet: install the Seam app from ${g.app.install_url}`)
989
+ return print(g, g.installations.map((i) => `${i.login} (installation ${i.id})\n${i.repos.map((r) => ` ${r.full_name}${r.private ? ' (private)' : ''}`).join('\n') || ' no repositories'}`).join('\n'))
990
+ }
494
991
  case 'import': {
992
+ // --authors <file>: a JSON object of git author email → member id (14, "Authors to members").
993
+ const readAuthors = () => {
994
+ if (!flags['--authors'] || flags['--authors'] === true) return undefined
995
+ try { return JSON.parse(fs.readFileSync(path.resolve(cwd, flags['--authors']), 'utf8')) } catch (err) { throw new Error(`--authors ${flags['--authors']}: a JSON file of { "email": "member id" } (${err.message})`) }
996
+ }
495
997
  // From a git remote, on the server, and following it (14): status and poke name the import.
496
998
  if (pos[0] === 'cutover') {
497
999
  if (!pos[1]) throw new Error('seam import cutover <import-id> [--credential <name>]')
498
1000
  const r = await client.cutover(pos[1], { credential: flags['--credential'] || undefined })
499
1001
  return print(r, `Cut over: import ${r.import.id} is done for good, its lines take merges in Seam, and main exports to ${r.export.target} after each merge.\nseam export status --quilt ${r.import.id}`)
500
1002
  }
1003
+ if (pos[0] === 'authors') {
1004
+ if (!pos[1] || !flags['--authors']) throw new Error('seam import authors <import-id> --authors <file>')
1005
+ const r = await client.answerImportAuthors(pos[1], { authors: readAuthors() })
1006
+ return print(r, `Import ${r.id} has its authors and runs now.\nseam import status ${r.id}`)
1007
+ }
501
1008
  if (pos[0] === 'status' || pos[0] === 'poke') {
502
1009
  if (!pos[1]) throw new Error(`seam import ${pos[0]} <import-id>`)
503
1010
  const r = pos[0] === 'status' ? await client.importStatus(pos[1]) : await client.pokeImport(pos[1])
@@ -505,28 +1012,75 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
505
1012
  }
506
1013
  if (pos[0] === 'git' && pos[1] && (flags['--follow'] || flags['--remote'] || /^(https?|ssh|git):\/\/|^[\w.-]+@[\w.-]+:/.test(pos[1]))) {
507
1014
  const slug = flags['--into'] || path.basename(String(pos[1]).replace(/\.git$/, '').replace(/\/+$/, '')).toLowerCase().replace(/[^a-z0-9-]/g, '-')
508
- const r = await client.startImport({ url: pos[1], slug, follow: !!flags['--follow'], interval: flags['--interval'] ? Number(flags['--interval']) : undefined, credential: flags['--credential'] || undefined })
509
- return print(r, `Importing ${pos[1]} into ${slug} on the server (import ${r.id}).${r.follow ? ` It follows main and release/* every ${r.interval_s} s; they are frozen until cut-over.\nPoke it from a git hook or cron job with: curl -X POST -H 'Authorization: Bearer ${r.poke_secret}' <server>/v1/imports/${r.id}/poke (the secret is shown once)` : ''}\nseam import status ${r.id}`)
1015
+ const r = await client.startImport({ url: pos[1], slug, authors: readAuthors(), ask_authors: !!flags['--ask-authors'], follow: !!flags['--follow'], interval: flags['--interval'] ? Number(flags['--interval']) : undefined, credential: flags['--credential'] || undefined })
1016
+ return print(r, `Importing ${pos[1]} into ${slug} on the server (import ${r.id}).${r.ask_authors ? ' It waits for you to answer its authors first (seam import status, then seam import authors).' : ''}${r.follow ? ` It follows main and release/* every ${r.interval_s} s; they are frozen until cut-over.\nPoke it from a git hook or cron job with: curl -X POST -H 'Authorization: Bearer ${r.poke_secret}' <server>/v1/imports/${r.id}/poke (the secret is shown once)` : ''}\nseam import status ${r.id}`)
510
1017
  }
511
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate] | seam import git <remote-url> [--into <slug>] [--follow] [--interval <s>] [--credential <name>] | seam import status|poke <import-id>')
1018
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate] [--authors <file>] | seam import git <remote-url> [--into <slug>] [--authors <file>] [--ask-authors] [--follow] [--interval <s>] [--credential <name>] | seam import authors <import-id> --authors <file> | seam import status|poke <import-id>')
512
1019
  const { importGit } = await import('./import-git.js')
513
1020
  const repo = path.resolve(cwd, pos[1])
514
1021
  if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
515
- const r = await importGit(client, { repo, slug: flags['--into'] || path.basename(repo).toLowerCase().replace(/[^a-z0-9-]/g, '-'), log: (s) => { if (!json) out(s) }, validate: !!flags['--validate'], batch: Number(flags['--batch']) || 100 })
1022
+ const r = await importGit(client, { repo, authors: readAuthors(), slug: flags['--into'] || path.basename(repo).toLowerCase().replace(/[^a-z0-9-]/g, '-'), log: (s) => { if (!json) out(s) }, validate: !!flags['--validate'], batch: Number(flags['--batch']) || 100 })
516
1023
  return print(r, describeImport(r))
517
1024
  }
518
1025
  case 'export': {
519
1026
  // To a git remote, on the server, after each merge (14): seam export remote <url> | status.
520
1027
  if (pos[0] === 'remote' && pos[1]) {
521
- 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'] })
522
- return print(r, r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge${r.releases ? ', with release tags' : ''}. seam export status` : `The export to ${r.target} is off.`)
1028
+ 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 })
1029
+ 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.`)
523
1030
  }
524
1031
  if (pos[0] === 'status') {
525
1032
  const r = await client.exportStatus(needQuilt())
526
1033
  const st = r.status || {}
527
- return print(r, [`Export to ${r.target} (${r.lines.join(', ')}${r.releases ? ', releases' : ''}${r.enabled ? '' : ', off'}): ${st.state || 'idle'}${st.error ? ` - ${st.error}` : ''}`, ...Object.entries(st.lines || {}).map(([l, x]) => ` ${l}: patch ${x.patch_no} -> ${String(x.sha || '').slice(0, 12)}`)].join('\n'))
1034
+ return print(r, [`Export to ${r.target} (${r.lines.join(', ')}${r.releases ? ', releases' : ''}${r.enabled ? '' : ', off'}): ${st.state || 'idle'}${st.error ? ` - ${st.error}` : ''}`, ...Object.entries(st.lines || {}).map(([l, x]) => ` ${l}: patch ${x.patch_no} -> ${String(x.sha || '').slice(0, 12)}`), ...(st.signing_key ? [` signed with ${st.signing_key}`] : [])].join('\n'))
1035
+ }
1036
+ // One commit as a .tar.gz (git archive): seam export tarball [rev] [--paths a,b] [--out file].
1037
+ if (pos[0] === 'tarball') {
1038
+ const r = await client.tarball(needQuilt(), pos[1] || 'main', { paths: flags['--paths'] ? String(flags['--paths']).split(',') : undefined })
1039
+ const file = path.resolve(cwd, flags['--out'] || r.name)
1040
+ fs.writeFileSync(file, r.bytes)
1041
+ return print({ file, commit: r.commit, bytes: r.bytes.length }, `Wrote ${pos[1] || 'main'} (${String(r.commit || 'empty').slice(0, 12)}) to ${path.relative(cwd, file) || file}: ${r.bytes.length} bytes.`)
1042
+ }
1043
+ // The whole quilt as one .tar (14, exit path; admin): seam export archive [--out file].
1044
+ if (pos[0] === 'archive') {
1045
+ const r = await client.exitArchive(needQuilt())
1046
+ const file = path.resolve(cwd, flags['--out'] || r.name)
1047
+ fs.writeFileSync(file, r.bytes)
1048
+ return print({ file, bytes: r.bytes.length }, `Wrote the whole quilt to ${path.relative(cwd, file) || file}: ${r.bytes.length} bytes. Check it any time with seam export check ${path.relative(cwd, file) || file}`)
1049
+ }
1050
+ // A commit's files out of an archive, offline: seam export extract <file> [rev] --to <dir>.
1051
+ if (pos[0] === 'extract') {
1052
+ if (!pos[1] || !flags['--to']) throw new Error('seam export extract <file> [rev] --to <dir>')
1053
+ const { readArchive, snapshotIn } = await import('./exit-archive.js')
1054
+ const a = await readArchive(fs.readFileSync(path.resolve(cwd, pos[1])))
1055
+ const s = snapshotIn(a, pos[2] || 'main')
1056
+ const dir = path.resolve(cwd, flags['--to'])
1057
+ for (const f of s.files) {
1058
+ const file = path.resolve(dir, f.path)
1059
+ if (!file.startsWith(dir + path.sep)) throw new Error(`${f.path} would land outside ${flags['--to']}`)
1060
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1061
+ if (f.mode === 'symlink') fs.symlinkSync(f.bytes.toString('utf8'), file)
1062
+ else fs.writeFileSync(file, f.bytes, { mode: f.mode === 'exec' ? 0o755 : 0o644 })
1063
+ }
1064
+ return print({ commit: s.commit, files: s.files.length, dir }, `Took ${s.files.length} files of ${pos[2] || 'main'} (${s.commit.slice(0, 12)}) out of ${pos[1]} into ${path.relative(cwd, dir) || dir}.`)
1065
+ }
1066
+ // A line's git history rebuilt from an archive, offline: seam export git <dir> --from <archive>.
1067
+ if (pos[0] === 'git' && pos[1] && flags['--from']) {
1068
+ const { readArchive, archiveClient } = await import('./exit-archive.js')
1069
+ const { exportGit } = await import('./export-git.js')
1070
+ const a = await readArchive(fs.readFileSync(path.resolve(cwd, flags['--from'])))
1071
+ const r = await exportGit(archiveClient(a), { q: a.manifest.quilt.id, dir: path.resolve(cwd, pos[1]), line: flags['--line'] || 'main', branch: flags['--branch'] || undefined, log: (s) => { if (!json) out(s) } })
1072
+ return print(r, `Rebuilt ${r.exported} patches of ${flags['--line'] || 'main'} from ${flags['--from']} as ${r.branch} in ${pos[1]}: ${r.sha || 'empty'} (patch ${r.patch_no}).`)
1073
+ }
1074
+ // Reads an archive offline and checks it: seam export check <file>.
1075
+ if (pos[0] === 'check') {
1076
+ if (!pos[1]) throw new Error('seam export check <file>')
1077
+ const { readArchive } = await import('./exit-archive.js')
1078
+ const a = await readArchive(fs.readFileSync(path.resolve(cwd, pos[1])))
1079
+ const lines = [...a.lines.values()].map((l) => `${l.name} at patch ${l.patch_count || 0}`)
1080
+ const r = { quilt: a.manifest.quilt, rows: a.log.length, commits: a.commits.size, packs: a.packs.length, members: a.members.length, lines: [...a.lines.values()].map((l) => ({ name: l.name, tip: l.tip, patch_count: l.patch_count || 0 })) }
1081
+ return print(r, `${pos[1]} is whole: quilt ${a.manifest.quilt.slug || a.manifest.quilt.id}, ${r.rows} log rows, ${r.commits} commits, ${r.packs} packs, ${r.members} members. Lines: ${lines.join(', ') || 'none'}.`)
528
1082
  }
529
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
1083
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>] | seam export tarball [rev] [--paths a,b] [--out file] | seam export archive [--out file] | seam export check <file> | seam export extract <file> [rev] --to <dir>')
530
1084
  const { exportGit } = await import('./export-git.js')
531
1085
  const r = await exportGit(client, { q: needQuilt(), dir: path.resolve(cwd, pos[1]), line: flags['--line'] || 'main', branch: flags['--branch'] || undefined, log: (s) => { if (!json) out(s) } })
532
1086
  return print(r, r.exported ? `Exported ${r.exported} patches to ${r.branch} in ${pos[1]}: ${r.branch} is ${r.sha} (patch ${r.patch_no}).` : `Nothing new: ${r.branch} is ${r.sha || 'empty'} (patch ${r.patch_no}).`)