@heyquilt/seam 0.17.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/seam.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
- import { readAccount, login, logout, resume } from '../lib/account.js'
8
+ import { readAccount, login, logout, resume, call } from '../lib/account.js'
9
9
 
10
10
  const here = path.dirname(fileURLToPath(import.meta.url))
11
11
  // In the repo the client is read straight from the server's src/quilts (never a stale build);
@@ -17,10 +17,15 @@ const { version } = JSON.parse(fs.readFileSync(path.join(here, '../package.json'
17
17
  const HELP = `seam ${version}: Seam, source control for people and their agents
18
18
  Docs: https://seam.heyquilt.com/docs
19
19
 
20
- seam login [--no-browser] Sign in with your heyquilt.com account
20
+ seam login [--no-browser] Sign in with your Seam account (seam.heyquilt.com)
21
21
  seam logout | seam whoami
22
+ seam agent new <name> | list | key <name> | revoke <name> | relink [agent:<id>] your agents and their keys
23
+ seam oauth client new <name> --redirect <uri> | list | revoke <client_id> apps that ask people for access (OAuth 2, PKCE)
22
24
  seam quilts [new <slug> | show <q>] Your quilts (repositories)
23
25
  seam import git <path> [--into <slug>]
26
+ seam import git <remote-url> --into <slug> [--follow] [--interval <s>] [--credential <name>]
27
+ seam import status <id> | seam import poke <id> | seam import cutover <id> an import that follows git
28
+ seam export remote <url> --quilt <q> [--line main,release/*] [--releases] [--credential <name>] | seam export status
24
29
  seam square open "<title>" --quilt <q> [--dir <folder>]
25
30
  seam checkout <sq:id | revision> --quilt <q> [--dir <folder>] [-- paths]
26
31
  seam status | diff | commit -m "<message>" in a block folder
@@ -35,8 +40,10 @@ Docs: https://seam.heyquilt.com/docs
35
40
  seam commit --amend [-m "<message>"] change your square's last commit
36
41
  seam square open "<title>" --on sq:<id> a square stacked on another
37
42
  seam claim <path|glob> [--note why] | unclaim <id> files your square is working on
38
- seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b]
39
- seam check <name> green|red [--square sq | --patch n] report a check (a red one on a patch reverts it)
43
+ seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b] [--runners ci=<member>]
44
+ seam check <name> green|red [--square sq | --patch n] [--run-key k] report a check (a red one on a patch reverts it)
45
+ seam checks [--state queued] [--name ci] [--tier pre_merge|post_merge] check runs Seam asked runners for
46
+ seam runner --check ci --run "npm test" [--quilt <q>] [--once] run the checks Seam asks you for, on this machine
40
47
  seam resolutions [forget <key>] conflicts settled before, settled the same way again
41
48
  seam bisect <bad> <good> --run "<check>" seam grep <pattern> [rev] [--all] seam describe [rev] seam merge-base <a> <b>
42
49
  seam log --follow <path> | log -S <text>
@@ -44,6 +51,7 @@ Docs: https://seam.heyquilt.com/docs
44
51
  seam events [--webhook <url>] What happened to your squares
45
52
  seam export git <dir> Back to git
46
53
  seam mcp The same commands as MCP tools, for your AI tool
54
+ seam serve Run a Seam server (SEAM_AUTH_URL: its own accounts; SEAM_IDENTITY_URL: a sign-in service)
47
55
 
48
56
  Agents: set SEAM_TOKEN to the agent's key instead of signing in.`
49
57
 
@@ -63,7 +71,14 @@ async function main () {
63
71
  }
64
72
  if (cmd === 'logout') { const a = await logout(); return console.log(a ? `Signed out of ${a.account.email}.` : 'Not signed in.') }
65
73
  if (cmd === 'whoami') { const a = readAccount(); if (!a) { process.exitCode = 1; return console.log('Not signed in.') } return console.log(`${a.account.name} (${a.account.email})`) }
74
+ if (cmd === 'agent') return agentCommand(argv[0], argv[1])
75
+ if (cmd === 'oauth') return oauthCommand(argv)
66
76
  if (cmd === 'mcp') return mcp()
77
+ // The server is not in the package (it ships the client): it runs from Seam's source or its image.
78
+ if (cmd === 'serve') {
79
+ if (QUILTS === '../lib/quilts') fail('seam serve runs from Seam\'s source: node src/quilts/server.js in the repository, or the Dockerfile.seam image (docs/seam-service.md).')
80
+ return (await quilts('server.js')).serveFromEnv(process.env)
81
+ }
67
82
  const { SEAM_VERBS, runSeamCommand } = await quilts('cli.js')
68
83
  if (!SEAM_VERBS.has(cmd)) fail(`seam: '${cmd}' is not a seam command. See seam help.`)
69
84
  try {
@@ -79,6 +94,71 @@ async function main () {
79
94
  }
80
95
  }
81
96
 
97
+ // `seam oauth client …`: OAuth apps the account registers (public clients: PKCE, no secret).
98
+ async function oauthCommand (argv) {
99
+ const a = readAccount()
100
+ if (!a) fail('Not signed in: run seam login.')
101
+ const go = async (method, route, body) => {
102
+ try { return await call(method, route, body, a.token) } catch (err) {
103
+ if (err.status !== 401) throw err
104
+ const back = await resume().catch(() => null)
105
+ if (!back) fail('Your sign-in has ended. Run seam login.')
106
+ return call(method, route, body, back.token)
107
+ }
108
+ }
109
+ const [what, sub, ...rest] = argv
110
+ if (what !== 'client') fail('seam oauth client new <name> --redirect <uri> | list | revoke <client_id>')
111
+ if (sub === 'new') {
112
+ const redirects = []; const words = []
113
+ for (let i = 0; i < rest.length; i++) { if (rest[i] === '--redirect') redirects.push(rest[++i]); else words.push(rest[i]) }
114
+ if (!words.length || !redirects.length) fail('seam oauth client new <name> --redirect <uri> [--redirect <uri>]')
115
+ const c = await go('POST', '/v1/oauth/clients', { name: words.join(' '), redirect_uris: redirects })
116
+ return console.log(`${c.name}\n\n client_id=${c.client_id}\n\nA public client: no secret, the app uses PKCE (S256). Send people to https://seam.heyquilt.com/oauth/authorize; trade the code at POST /oauth/token on Seam's API.`)
117
+ }
118
+ if (sub === 'revoke') { if (!rest[0]) fail('seam oauth client revoke <client_id>'); const c = await go('DELETE', `/v1/oauth/clients/${encodeURIComponent(rest[0])}`); return console.log(`Revoked ${c.name}: every token it was given has ended.`) }
119
+ if (!sub || sub === 'list') {
120
+ const { clients } = await go('GET', '/v1/oauth/clients')
121
+ if (!clients.length) return console.log('No apps yet: seam oauth client new <name> --redirect <uri>.')
122
+ for (const c of clients) console.log(`${c.client_id} ${c.name} ${c.redirect_uris.join(' ')}${c.revoked_at ? ' (revoked)' : ''}`)
123
+ return
124
+ }
125
+ fail(`seam oauth client: '${sub}' is not a command (new, list, revoke).`)
126
+ }
127
+
128
+ // `seam agent …`: an account's agents, each with its own key (SEAM_TOKEN for that agent).
129
+ async function agentCommand (sub, name) {
130
+ const a = readAccount()
131
+ if (!a) fail('Not signed in: run seam login.')
132
+ const go = async (method, route, body) => {
133
+ try { return await call(method, route, body, a.token) } catch (err) {
134
+ if (err.status !== 401) throw err
135
+ const back = await resume().catch(() => null)
136
+ if (!back) fail('Your sign-in has ended. Run seam login.')
137
+ return call(method, route, body, back.token)
138
+ }
139
+ }
140
+ const showKey = (r) => console.log(`${r.name} (${r.member_id})\n\n SEAM_TOKEN=${r.key}\n\nThis key is shown once. Give it to the agent as SEAM_TOKEN; seam agent key ${r.name} makes a new one and ends this one.`)
141
+ if (sub === 'new') { if (!name) fail('seam agent new <name>'); return showKey(await go('POST', '/v1/agents', { name })) }
142
+ if (sub === 'key') { if (!name) fail('seam agent key <name>'); return showKey(await go('POST', `/v1/agents/${encodeURIComponent(name)}/key`)) }
143
+ if (sub === 'revoke') { if (!name) fail('seam agent revoke <name>'); const r = await go('DELETE', `/v1/agents/${encodeURIComponent(name)}`); return console.log(`Revoked ${r.name}: its key no longer works. Its patches stay its own.`) }
144
+ if (sub === 'relink') {
145
+ if (!name) {
146
+ const { relink } = await go('GET', '/v1/agents')
147
+ if (!relink.length) return console.log('No old agents to re-link.')
148
+ for (const x of relink) console.log(`${x.member_id} ${x.name || ''} (seam agent relink ${x.member_id})`)
149
+ return
150
+ }
151
+ return showKey(await go('POST', '/v1/agents', { claim: name }))
152
+ }
153
+ if (!sub || sub === 'list') {
154
+ const { agents } = await go('GET', '/v1/agents')
155
+ if (!agents.length) return console.log('No agents yet: seam agent new <name>.')
156
+ for (const x of agents) console.log(`${x.name} ${x.member_id}${x.revoked_at ? ' (revoked)' : ''}`)
157
+ return
158
+ }
159
+ fail(`seam agent: '${sub}' is not an agent command (new, list, key, revoke, relink).`)
160
+ }
161
+
82
162
  // `seam mcp`: the source-control tools for an AI tool, over stdio, as the signed-in person (or SEAM_TOKEN).
83
163
  async function mcp () {
84
164
  const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
package/lib/account.js CHANGED
@@ -1,22 +1,23 @@
1
- // seam's own sign-in, kept apart from the Quilt app's: an Ed25519 key for this computer in
2
- // ~/.seam/identity.json and the account token in ~/.seam/account.json (both private, 0600). It
3
- // signs in to the same heyquilt.com account through the API's device links: `seam login` shows a
4
- // page to approve, and later a lapsed token is renewed with the key, no browser.
1
+ // seam's own sign-in to a Seam account: an Ed25519 key for this computer in ~/.seam/identity.json
2
+ // and the computer's Seam key in ~/.seam/account.json (both private, 0600), through Seam's own
3
+ // device flow: `seam login` shows a page on seam.heyquilt.com to approve, and later a lapsed key
4
+ // is renewed with the computer's key, no browser.
5
5
  import fs from 'node:fs'
6
6
  import os from 'node:os'
7
7
  import path from 'node:path'
8
8
  import crypto from 'node:crypto'
9
9
 
10
- export const API_URL = 'https://api.heyquilt.com'
10
+ export const API_URL = 'https://api.seam.heyquilt.com'
11
+ // Sign-in is on Seam's own server, like everything else.
11
12
  export const apiUrl = () => String(process.env.SEAM_API_URL || API_URL).replace(/\/+$/, '')
12
13
  export const seamHome = () => process.env.SEAM_HOME || path.join(os.homedir(), '.seam')
13
14
  const accountFile = () => path.join(seamHome(), 'account.json')
14
15
  const identityFile = () => path.join(seamHome(), 'identity.json')
15
16
  const signedOutFile = () => path.join(seamHome(), 'signed-out')
16
17
 
17
- // The API's signing contexts for linking a computer and signing it back in (src/identity.js).
18
- const DEVICE_LINK_CONTEXT = 'quilt-device-link-v1'
19
- const DEVICE_RESUME_CONTEXT = 'quilt-device-resume-v1'
18
+ // Seam's signing contexts for linking a computer and signing it back in (src/quilts/keys.js).
19
+ const DEVICE_LINK_CONTEXT = 'seam-device-link-v1'
20
+ const DEVICE_RESUME_CONTEXT = 'seam-device-resume-v1'
20
21
 
21
22
  function writePrivate (file, data) {
22
23
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
@@ -57,15 +58,15 @@ function saveAccount (token, profile) {
57
58
  return data
58
59
  }
59
60
 
60
- async function call (method, route, body, token, extra = {}) {
61
+ export async function call (method, route, body, token, extra = {}) {
61
62
  let res
62
63
  try {
63
64
  res = await fetch(apiUrl() + route, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined, ...extra })
64
65
  } catch (err) {
65
- throw new Error(`Couldn't reach Quilt (${err.cause?.code || err.message}).`)
66
+ throw new Error(`Couldn't reach Seam (${err.cause?.code || err.message}).`)
66
67
  }
67
68
  const data = await res.json().catch(() => null)
68
- if (!res.ok) throw Object.assign(new Error(data?.error || `Quilt answered ${res.status}.`), { status: res.status })
69
+ if (!res.ok) throw Object.assign(new Error(data?.error || `Seam answered ${res.status}.`), { status: res.status })
69
70
  return data || {}
70
71
  }
71
72
 
package/lib/quilts/cli.js CHANGED
@@ -6,16 +6,43 @@ 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'
9
10
  import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, restore, reset, clean, localChanges } from './block.js'
10
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
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'
11
12
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
12
13
 
13
- export const API_URL = 'https://api.heyquilt.com'
14
+ export const API_URL = 'https://api.seam.heyquilt.com'
14
15
 
15
16
  /** The verbs `seam` answers. */
16
- 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', 'resolutions', 'review', 'sign', 'key', 'token'])
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'])
17
18
 
18
- 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'])
19
+ /** `ci=agent:ci,lint=agent:a+agent:b` → { ci: ['agent:ci'], lint: ['agent:a', 'agent:b'] }; `none` clears them. */
20
+ export function parseRunners (text) {
21
+ if (text === 'none') return null
22
+ const out = {}
23
+ for (const part of text.split(',').map((x) => x.trim()).filter(Boolean)) {
24
+ const i = part.indexOf('=')
25
+ if (i < 1) throw new Error(`--runners: <check>=<member>[+<member>], e.g. ci=agent:ci (got ${part})`)
26
+ out[part.slice(0, i)] = part.slice(i + 1).split('+').map((m) => m.trim()).filter(Boolean)
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** `e2e=post_merge,ci=pre_merge+post_merge` → { e2e: ['post_merge'], ci: ['pre_merge', 'post_merge'] }; `none` clears them. */
32
+ export function parseTiers (text) {
33
+ if (text === 'none') return null
34
+ const out = {}
35
+ for (const part of text.split(',').map((x) => x.trim()).filter(Boolean)) {
36
+ const i = part.indexOf('=')
37
+ if (i < 1) throw new Error(`--tiers: <check>=<tier>[+<tier>], e.g. e2e=post_merge (got ${part})`)
38
+ out[part.slice(0, i)] = part.slice(i + 1).split('+').map((m) => m.trim()).filter(Boolean)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /** `test/**,**\/*.test.js` → a list; `none` clears it. */
44
+ 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'])
19
46
 
20
47
  export function parse (argv) {
21
48
  const pos = []; const flags = {}; const paths = []
@@ -81,11 +108,18 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
81
108
  if (sub === 'require') {
82
109
  if (!pos[1]) throw new Error('Which line? seam line require <line> [<approvals>] [--checks ci,lint]')
83
110
  const checks = flags['--checks'] === undefined ? undefined : String(flags['--checks']).split(',').map((c) => c.trim()).filter(Boolean)
84
- if (pos[2] === undefined && checks === undefined) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
85
- const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}) })
111
+ // --runners ci=agent:ci,lint=agent:ci+agent:lint: who runs each check (their verdicts count; anyone else's is advisory)
112
+ const runners = flags['--runners'] === undefined ? undefined : parseRunners(String(flags['--runners']))
113
+ // --tiers e2e=post_merge: where each check runs; --tests / --ci-config: globs for the affected set
114
+ const tiers = flags['--tiers'] === undefined ? undefined : parseTiers(String(flags['--tiers']))
115
+ const tests = flags['--tests'] === undefined ? undefined : parseGlobs(String(flags['--tests']))
116
+ 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 } : {}) }
118
+ 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
+ const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}), ...(runners !== undefined ? { runners } : {}), ...extra })
86
120
  return print(p, describePolicy(p))
87
121
  }
88
- throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint]')
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/**]')
89
123
  }
90
124
  case 'tag':
91
125
  case 'release': {
@@ -126,6 +160,12 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
126
160
  // seam square handoff [sq] --to <member> [--note text] | abandon [sq] | reopen [sq]
127
161
  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}.`) }
128
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.`) }
163
+ // seam square merged [sq] --as <git sha>: closes a square whose work went into git by hand (admins, during a follow)
164
+ if (sub === 'merged') {
165
+ if (!flags['--as']) throw new Error('Which git commit holds the work? seam square merged [sq:id] --as <sha>')
166
+ const sq = await client.squareMerged(needQuilt(), pos[1] || needSquare(), { git_sha: flags['--as'] })
167
+ 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
+ }
129
169
  if (sub === 'reopen') { const sq = await client.reopen(needQuilt(), pos[1] || needSquare()); return print(sq, `Reopened ${sq.short || sq.id}; you hold it.`) }
130
170
  // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> [--title t]
131
171
  if (sub === 'fold') {
@@ -164,6 +204,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
164
204
  const r = await checkout(client, { quilt: needQuilt(), ...(isSquare ? { square: target } : { rev: target }), paths, dir, api })
165
205
  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}.`)
166
206
  }
207
+ case 'runner': {
208
+ // 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()
212
+ 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 })
215
+ return print({ runs: done }, done.length ? `Ran ${done.length} check run(s).` : 'Nothing queued.')
216
+ }
167
217
  case 'status': {
168
218
  if (!found) throw new Error('Not in a block folder (no .seam/block.json here or above).')
169
219
  const st = await blockStatus(client, found.dir, block)
@@ -190,15 +240,26 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
190
240
  case 'check': {
191
241
  // seam check <name> red|green|flaky [--line main] [--patch n] | seam check <name> green|red --square <sq>
192
242
  const [name, status] = pos
243
+ if (name === 'rerun') {
244
+ // seam check rerun <check> --square <sq> | --line <line> [--commit <id>]: ask its runners again
245
+ if (!status) throw new Error('seam check rerun <check> --square <square> | --line <line> [--commit <id>]')
246
+ const r = await client.rerunCheck(needQuilt(), { check: status, ...(flags['--square'] ? { square: flags['--square'] } : { line: flags['--line'] || 'main' }), ...(flags['--commit'] ? { commit: flags['--commit'] } : {}) })
247
+ return print(r, describeRerun(r))
248
+ }
193
249
  if (!name || !['red', 'green', 'flaky'].includes(status)) throw new Error('seam check <name> red|green|flaky [--line <line>] [--patch <n>]')
194
250
  if (flags['--square']) {
195
251
  // seam check <name> green|red --square <sq>: a result for a square's latest commit (a line may require it before a merge)
196
- const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status })
252
+ const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status, ...(flags['--run-key'] ? { run_key: flags['--run-key'] } : {}) })
197
253
  return print(r, describeSquareCheck(r))
198
254
  }
199
- const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}) })
255
+ const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}), ...(flags['--run-key'] ? { run_key: flags['--run-key'] } : {}) })
200
256
  return print(r, describeCheck(r))
201
257
  }
258
+ case 'checks': {
259
+ // seam checks [--state queued] [--name ci] [--tier pre_merge|post_merge] [--line main] [--square sq]: the quilt's check runs
260
+ const r = await client.checkRuns(needQuilt(), { state: flags['--state'], name: flags['--name'], tier: flags['--tier'], line: flags['--line'], square: flags['--square'] })
261
+ return print(r, describeCheckRuns(r))
262
+ }
202
263
  case 'unclaim': {
203
264
  // seam unclaim <pattern|claim id>...
204
265
  if (!pos.length) throw new Error('Which claim? seam unclaim <pattern>')
@@ -431,7 +492,23 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
431
492
  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'))
432
493
  }
433
494
  case 'import': {
434
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate]')
495
+ // From a git remote, on the server, and following it (14): status and poke name the import.
496
+ if (pos[0] === 'cutover') {
497
+ if (!pos[1]) throw new Error('seam import cutover <import-id> [--credential <name>]')
498
+ const r = await client.cutover(pos[1], { credential: flags['--credential'] || undefined })
499
+ 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
+ }
501
+ if (pos[0] === 'status' || pos[0] === 'poke') {
502
+ if (!pos[1]) throw new Error(`seam import ${pos[0]} <import-id>`)
503
+ const r = pos[0] === 'status' ? await client.importStatus(pos[1]) : await client.pokeImport(pos[1])
504
+ return print(r, pos[0] === 'poke' ? `Poked: import ${r.id} fetches the remote now.` : describeImportRecord(r))
505
+ }
506
+ if (pos[0] === 'git' && pos[1] && (flags['--follow'] || flags['--remote'] || /^(https?|ssh|git):\/\/|^[\w.-]+@[\w.-]+:/.test(pos[1]))) {
507
+ 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}`)
510
+ }
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>')
435
512
  const { importGit } = await import('./import-git.js')
436
513
  const repo = path.resolve(cwd, pos[1])
437
514
  if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
@@ -439,6 +516,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
439
516
  return print(r, describeImport(r))
440
517
  }
441
518
  case 'export': {
519
+ // To a git remote, on the server, after each merge (14): seam export remote <url> | status.
520
+ 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.`)
523
+ }
524
+ if (pos[0] === 'status') {
525
+ const r = await client.exportStatus(needQuilt())
526
+ 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'))
528
+ }
442
529
  if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
443
530
  const { exportGit } = await import('./export-git.js')
444
531
  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) } })
@@ -48,6 +48,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
48
48
  amend: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/amend`, b),
49
49
  handoff: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/handoff`, b),
50
50
  abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
51
+ squareMerged: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merged`, b),
51
52
  reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
52
53
  mergeSquare: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merge`, b),
53
54
  fold: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/fold`, b),
@@ -91,6 +92,8 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
91
92
  reviews: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/reviews`),
92
93
  review: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews`, b),
93
94
  reportSquareCheck: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/checks`, b),
95
+ rerunCheck: (q, { check, square, line, commit }) => call('POST', square ? `${Q(q)}/squares/${enc(square)}/checks/${enc(check)}/rerun` : `${Q(q)}/lines/${enc(line || 'main')}/checks/${enc(check)}/rerun`, commit ? { commit } : {}),
96
+ checkRuns: (q, o = {}) => call('GET', `${Q(q)}/checks${qs(o)}`),
94
97
  linePolicy: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy`),
95
98
  setLinePolicy: (q, line, b) => call('PUT', `${Q(q)}/lines/${enc(line)}/policy`, b),
96
99
  claims: async (q, square) => (await call('GET', `${Q(q)}/claims${square ? `?square=${enc(square)}` : ''}`)).claims,
@@ -106,6 +109,12 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
106
109
  uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
107
110
  importCommits: (q, items, lines = []) => call('POST', `${Q(q)}/import/commits`, { items, ...(lines.length ? { lines } : {}) }),
108
111
  importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
112
+ setExport: (q, b) => call('PUT', `${Q(q)}/export`, b),
113
+ exportStatus: (q) => call('GET', `${Q(q)}/export`),
114
+ startImport: (b) => call('POST', '/v1/imports', b),
115
+ importStatus: (id) => call('GET', `/v1/imports/${enc(id)}`),
116
+ cutover: (id, b = {}) => call('POST', `/v1/imports/${enc(id)}/cutover`, b),
117
+ pokeImport: (id) => call('POST', `/v1/imports/${enc(id)}/poke`),
109
118
 
110
119
  /**
111
120
  * Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most
@@ -117,6 +117,8 @@ export function describeEvents (r) {
117
117
  const p = e.payload || {}
118
118
  if (e.kind === 'patch.merged') return `#${e.event_seq} patch.merged: patch ${e.patch_no} by ${e.by.member_id} "${first(p.message || '')}" changed ${e.paths.join(', ')} in your square ${e.square_short}${p.would_conflict ? `; would conflict in ${p.would_conflict_paths.join(', ')}` : '; merges cleanly'}`
119
119
  if (e.kind === 'conflict.added') return `#${e.event_seq} conflict.added: ${p.path} (${p.kind}) in ${e.square_short}${p.caused_by && p.caused_by.patch_no ? `, against patch ${p.caused_by.patch_no}` : ''}; settle it and merge again`
120
+ if (e.kind === 'check.cancelled') return `#${e.event_seq} check.cancelled: stop ${p.check} (${p.tier}) on ${p.commit.slice(0, 12)}, run_key ${p.run_key}: ${p.superseded_by} replaces it`
121
+ if (e.kind === 'check.requested') return `#${e.event_seq} check.requested: run ${p.check} (${p.tier}) on ${p.commit.slice(0, 12)}${e.square_short ? ` in ${e.square_short}` : ` at ${e.line}:${p.patch_no}`}; report with run_key ${p.run_key}`
120
122
  if (e.kind === 'square.handoff') return `#${e.event_seq} square.handoff: ${e.square_short} from ${p.from} to ${p.to}${p.note ? `: ${p.note}` : ''}`
121
123
  return `#${e.event_seq} ${e.kind}`
122
124
  }
@@ -195,6 +197,7 @@ export function describeClaims (claims) {
195
197
 
196
198
  export function describeCheck (r) {
197
199
  const lines = [`${r.line}: merge window ${r.window} an hour${r.red.length ? ` (red: ${r.red.join(', ')})` : ''}.`]
200
+ if (r.advisory) lines.push('Your verdict is advisory: you are not one of the check\'s runners, so it changes nothing.')
198
201
  const v = r.revert
199
202
  if (v && v.reverted) lines.push(`Reverted patch ${r.line}:${v.patch_no} as patch ${r.line}:${v.revert_patch_no}.${v.fix_square ? ` The change is again in square ${v.fix_square.short}, held by ${v.fix_square.holder}: fix it there, commit, merge.` : ''}`)
200
203
  else if (v) lines.push(`Did not revert patch ${r.line}:${v.patch_no}: ${(v.refused && (v.refused.detail || v.refused.code)) || 'refused'}.${v.square ? ` The revert waits in square ${v.square.short}.` : ''}`)
@@ -238,11 +241,32 @@ export function describePolicy (p) {
238
241
  const parts = []
239
242
  if (p.approvals) parts.push(`${p.approvals} approval${p.approvals === 1 ? '' : 's'} of a square's latest commit from someone other than its holder`)
240
243
  if (p.checks?.length) parts.push(`check${p.checks.length === 1 ? '' : 's'} ${p.checks.join(', ')} green on it`)
241
- return parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`
244
+ const run = Object.entries(p.runners || {}).map(([c, ms]) => `${c} by ${ms.join(', ')}`)
245
+ const runs = run.length ? ` Runners: ${run.join('; ')} (asked to run their checks; anyone else's verdict is advisory).` : ''
246
+ const tiers = Object.entries(p.tiers || {}).map(([c, ts]) => `${c} ${ts.length ? ts.join(' and ') : 'never'}`)
247
+ const tg = (tiers.length ? ` Tiers: ${tiers.join('; ')}.` : '') +
248
+ (p.tests?.length ? ` Tests: ${p.tests.join(', ')} (a pre-merge run names the ones a change reaches).` : '') +
249
+ (p.ci_config?.length ? ` CI config: ${p.ci_config.join(', ')} (a change there runs everything).` : '')
250
+ return (parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`) + runs + tg
251
+ }
252
+
253
+ /** A run's affected set in a few words. */
254
+ const affectedWords = (a) => !a ? '' : a.confidence === 'all' ? ' runs all' : ` runs ${a.tests.length} test${a.tests.length === 1 ? '' : 's'}`
255
+
256
+ /** Check runs, newest first: one per line. */
257
+ export function describeCheckRuns (r) {
258
+ if (!r.runs.length) return 'No check runs.'
259
+ return r.runs.map((x) => `${x.name} ${x.tier} ${x.state}${x.verdict ? ` ${x.verdict}` : ''}${x.advisory ? ' (advisory)' : ''}${affectedWords(x.affected)} ${x.commit.slice(0, 12)}${x.square ? ` ${x.square}` : ` ${x.line}${x.patch_no != null ? `:${x.patch_no}` : ''}`} run_key ${x.run_key}${x.by ? ` by ${x.by}` : ''}`).join('\n')
260
+ }
261
+
262
+ /** A check asked to run again. */
263
+ export function describeRerun (r) {
264
+ return `Asked ${r.check}'s runners to run it again (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at the tip of ${r.line}`}: run_key ${r.run_key}. An open run of it there is cancelled (check.cancelled).`
242
265
  }
243
266
 
244
267
  /** A check reported on a square. */
245
268
  export function describeSquareCheck (r) {
269
+ if (r.advisory) return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}), advisory: you are not one of its runners (${(r.runners || []).join(', ')}), so it gates nothing.`
246
270
  const req = r.checks.filter((c) => c.required)
247
271
  const left = req.filter((c) => c.status !== 'green').map((c) => `${c.name} ${c.status}`)
248
272
  return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}).${req.length ? (left.length ? ` Its line still needs: ${left.join(', ')}.` : ' Every check its line requires is green.') : ''}`
@@ -264,3 +288,9 @@ export function describeTokens (r) {
264
288
  }
265
289
 
266
290
  export const describeNewToken = (t) => `Made token ${t.name} (${t.level} on ${t.quilt}, id ${t.id}). Its secret, shown only this once:\n\n ${t.token}\n\nSet it as SEAM_TOKEN (or send it as Authorization: Bearer <token>).`
291
+
292
+ /** An import record (14) in a few lines. */
293
+ export function describeImportRecord (r) {
294
+ const lines = (r.lines || []).map((l) => ` ${l.line}: ${l.patches} patches, git ${l.tip_sha ? l.tip_sha.slice(0, 12) : '(none)'}`)
295
+ return [`Import ${r.id} of ${r.source?.url}: ${r.state}${r.follow ? `, following every ${r.interval_s} s` : ''}${r.error ? ` (${r.error})` : ''}`, ...lines].join('\n')
296
+ }
@@ -115,9 +115,10 @@ function gitTags (repo) {
115
115
  * where only the commits it does not have yet are added. Returns { quilt, slug, commits,
116
116
  * patches, lines, tags, report, validated? }.
117
117
  */
118
- export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0, onlyHead = false, fresh = false }) {
118
+ export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0, onlyHead = false, fresh = false, follow = false }) {
119
119
  const head = git(repo, ['rev-parse', 'HEAD']).toString().trim()
120
- const refs = onlyHead ? [{ name: 'main', sha: head, git: 'HEAD' }] : branches(repo, head)
120
+ // follow: only main and release/* (14: the lines an import follows).
121
+ const refs = onlyHead ? [{ name: 'main', sha: head, git: 'HEAD' }] : branches(repo, head).filter((r) => !follow || r.name === 'main' || r.name.startsWith('release/'))
121
122
  const all = git(repo, ['rev-list', ...new Set(refs.map((r) => r.sha))]).toString().split('\n').filter(Boolean)
122
123
  const cat = catFile(repo)
123
124
  const report = []
@@ -150,6 +151,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
150
151
  let pending = []
151
152
  let commits = 0; let patches = 0
152
153
  const lines = []
154
+ const rewritten = [] // git branches whose line no longer follows them (a rewritten ref)
153
155
 
154
156
  const flush = async () => {
155
157
  if (!items.length && !newLines.length) return
@@ -211,6 +213,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
211
213
  lineTips.set(ref.name, base)
212
214
  } else if ((lineTips.get(ref.name) || null) !== base) {
213
215
  // The line moved on in the quilt (merges since the import), or git rewrote the branch.
216
+ rewritten.push(ref.name)
214
217
  if (chain.length) report.push({ kind: 'branch', ref: ref.git, note: `line ${ref.name} no longer follows git's branch; its ${chain.length} new commits were left out` })
215
218
  return
216
219
  }
@@ -282,7 +285,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
282
285
  validated++
283
286
  }
284
287
  }
285
- return { quilt: quilt.id, slug: quilt.slug, again, commits, patches, lines, tags: tagged, report, ...(validate ? { validated } : {}), tip: lineTips.get('main') }
288
+ return { quilt: quilt.id, slug: quilt.slug, again, commits, patches, lines, tags: tagged, report, rewritten, ...(validate ? { validated } : {}), tip: lineTips.get('main') }
286
289
  } finally {
287
290
  cat.close()
288
291
  }
@@ -0,0 +1,95 @@
1
+ // seam runner (plans/quilt-source-control/13 "Checks"): a ready-made check runner anyone starts on
2
+ // their own machine. It polls the runs Seam asked its member to run (GET /checks?state=queued),
3
+ // checks each run's commit out into a fresh block folder, runs the command there, and reports the
4
+ // verdict (exit 0 green, anything else red) on the run's run_key with a link to the log. A run
5
+ // Seam cancels (check.cancelled: a newer head superseded it) is stopped and not reported.
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { spawn } from 'node:child_process'
10
+ import { checkout } from './block.js'
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
13
+ const safe = (key) => key.replace(/[^\w.-]+/g, '_').slice(0, 180)
14
+
15
+ /** The log link a verdict carries: --log-url with {run_key} and {file}, else the log file's own address. */
16
+ export function logLink (template, runKey, file) {
17
+ if (!template) return 'file://' + file
18
+ return template.replaceAll('{run_key}', encodeURIComponent(runKey)).replaceAll('{file}', encodeURIComponent(path.basename(file)))
19
+ }
20
+
21
+ /** The environment a run's command gets: what to run and, when Seam knows, which tests the change reaches. */
22
+ export function runEnv (run, base = process.env) {
23
+ const a = run.affected || { confidence: 'all' }
24
+ return {
25
+ ...base,
26
+ SEAM_CHECK: run.name, SEAM_TIER: run.tier, SEAM_COMMIT: run.commit, SEAM_RUN_KEY: run.run_key,
27
+ SEAM_LINE: run.line || '', SEAM_SQUARE: run.square || '',
28
+ SEAM_AFFECTED_CONFIDENCE: a.confidence, SEAM_AFFECTED_TESTS: a.confidence === 'all' ? '' : (a.tests || []).join('\n')
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Runs checks for one quilt. { client, quilt, checks: ['ci'], run: 'npm test', dir (where block
34
+ * folders and logs go), api, once (handle what is queued now, then return), intervalMs, logUrl,
35
+ * say(line) }. Returns the runs it finished: [{ run_key, status | 'cancelled', url }].
36
+ */
37
+ export async function runChecks ({ client, quilt, checks, run: command, dir = path.join(os.tmpdir(), 'seam-runner'), api, once = false, intervalMs = 5000, logUrl, say = () => {}, keep = false }) {
38
+ if (!checks?.length) throw new Error('seam runner needs --check <name>[,<name>]')
39
+ if (!command) throw new Error('seam runner needs --run "<command>"')
40
+ const logs = path.join(dir, 'logs')
41
+ fs.mkdirSync(logs, { recursive: true })
42
+ const done = []
43
+ const seen = new Set()
44
+ for (;;) {
45
+ const queued = []
46
+ for (const name of checks) queued.push(...(await client.checkRuns(quilt, { state: 'queued', name })).runs)
47
+ const todo = queued.filter((r) => !seen.has(r.run_key)).sort((a, b) => a.at - b.at)
48
+ for (const r of todo) {
49
+ seen.add(r.run_key)
50
+ done.push(await runOne(r))
51
+ }
52
+ if (once) return done
53
+ await sleep(intervalMs)
54
+ }
55
+
56
+ async function runOne (r) {
57
+ const work = fs.mkdtempSync(path.join(dir, 'run-'))
58
+ const file = path.join(logs, safe(r.run_key) + '.log')
59
+ const url = logLink(logUrl, r.run_key, file)
60
+ say(`${r.name} (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at ${r.line}`}: running`)
61
+ const out = fs.openSync(file, 'w')
62
+ let status
63
+ try {
64
+ fs.writeSync(out, `seam runner: ${r.name} ${r.tier} ${r.commit} run_key ${r.run_key}\n$ ${command}\n`)
65
+ await checkout(client, { quilt, rev: r.commit, dir: work, api })
66
+ const child = spawn(command, { cwd: work, shell: true, env: runEnv(r), stdio: ['ignore', out, out], detached: process.platform !== 'win32' })
67
+ const exited = new Promise((resolve) => child.on('close', (code) => resolve(code)))
68
+ let cancelled = false
69
+ for (;;) {
70
+ const code = await Promise.race([exited, sleep(Math.min(intervalMs, 2000)).then(() => undefined)])
71
+ if (code !== undefined) { status = code === 0 ? 'green' : 'red'; break }
72
+ const gone = (await client.checkRuns(quilt, { state: 'cancelled', name: r.name, limit: 50 })).runs.some((x) => x.run_key === r.run_key)
73
+ if (gone && !cancelled) {
74
+ cancelled = true
75
+ try { process.kill(process.platform !== 'win32' ? -child.pid : child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') }
76
+ }
77
+ if (cancelled) { await exited; status = 'cancelled'; break }
78
+ }
79
+ } catch (err) {
80
+ fs.writeSync(out, `\nseam runner: ${err.message}\n`)
81
+ status = 'red'
82
+ } finally {
83
+ fs.writeSync(out, `\nseam runner: ${status}\n`)
84
+ fs.closeSync(out)
85
+ if (!keep) fs.rmSync(work, { recursive: true, force: true })
86
+ }
87
+ if (status !== 'cancelled') {
88
+ const body = { check: r.name, status, run_key: r.run_key, url }
89
+ if (r.tier === 'pre_merge' && r.square) await client.reportSquareCheck(quilt, r.square, { ...body, head: r.commit })
90
+ else await client.reportCheck(quilt, r.line, body)
91
+ }
92
+ say(`${r.name} on ${r.commit.slice(0, 12)}: ${status} (log ${url})`)
93
+ return { run_key: r.run_key, status, url }
94
+ }
95
+ }
@@ -8,11 +8,11 @@
8
8
  // quilt and square, and seam_commit takes the files' new contents.
9
9
  import path from 'node:path'
10
10
  import { z } from 'zod'
11
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
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'
12
12
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
13
13
  import { status as blockStatus, commitFolder, amendFolder, syncTo, restore, reset, clean, localChanges } from './block.js'
14
14
 
15
- export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke']
15
+ export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_import_status', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_checks', 'seam_check_rerun']
16
16
 
17
17
  /** What every agent is told about source control, in each MCP's instructions. */
18
18
  export const SEAM_GUIDE = 'Quilt source control (quilts): a quilt is a repository on the Quilt server; you work in a square (your own branch and working copy), commit save points in it, and merge it into main, where it becomes a patch. ' +
@@ -32,6 +32,8 @@ export const SEAM_DESCRIPTIONS = {
32
32
  seam_review: 'Reviews a square: kind approve, request_changes (say what in body), comment (body; path and line put it on a line of a file at the square\'s head) or dismiss (takes back your verdict). Nobody approves a square they or the person they work for opened or hold. An approval counts for the square\'s latest commit only.',
33
33
  seam_claims: 'The claims that hold now: which square is working on which paths, who, and why (square: only that square\'s).',
34
34
  seam_claim: 'Claims paths (patterns: files, folders or globs like src/api/**) for your square, so writes to them from other squares on its line are refused until your square merges or you let go. note says what for. Claim before a larger change across several files.',
35
+ seam_check_rerun: 'Asks a check\'s runners to run it again: with square, on that square\'s latest commit (pre_merge); with line, on the line\'s tip (post_merge); or on commit. A run of it still open there is cancelled (check.cancelled to its runners). Returns the new run_key.',
36
+ seam_checks: 'The quilt\'s check runs, newest first (filter by state, name, tier, line, square). A runner polls state "queued" for the checks its line\'s policy names it for (or gets check.requested events), runs each on its commit and reports with seam_check and the run\'s run_key.',
35
37
  seam_check: 'Reports a post-merge check on a line (status red, green or flaky; a red check halves the line\'s merge window, green restores it). With patch (the patch number it failed at, found for example with seam_bisect) a red check reverts that patch on the line by itself and opens a square holding the change again for its holder to fix. With square instead, the result (green or red) is for that square\'s latest commit, which its line may require green before a merge.',
36
38
  seam_unclaim: 'Lets go of claims on your square (patterns: the patterns or claim ids).',
37
39
  seam_live_set: 'Shares your square\'s uncommitted work so others can read it as sq:<id>:live before you commit: in a block folder the folder\'s changed files; elsewhere files: [{ path, content }] and delete: [paths]. Each call replaces what was shared; none clears it. It is not a commit: seam_commit keeps work.',
@@ -44,6 +46,12 @@ export const SEAM_DESCRIPTIONS = {
44
46
  seam_line_freeze: 'Freezes a line (admins): merges into it are refused with line_frozen until seam_line_unfreeze.',
45
47
  seam_line_unfreeze: 'Unfreezes a line (admins): it takes merges again.',
46
48
  seam_tag_list: 'The quilt\'s tags: name, commit, the patch it is, and whether it is a release.',
49
+ seam_import_remote: 'Imports a git remote (https:// or ssh) into a new quilt on the server. With follow, it keeps taking new commits on main and release/* every interval (and on seam_import_poke) and freezes those lines until cut-over, because git stays the authority. Returns the import id and its poke secret (shown once).',
50
+ seam_export_remote: 'Exports lines of a quilt to a git remote (admins) after each merge on them, as fast-forwards that are never forced; releases: true pushes release tags too. While an import follows git, only to a scratch remote (scratch: true).',
51
+ seam_export_status: 'The export to a git remote: its target, lines, state (idle, ok, retrying, refused), each line\'s last exported patch and sha, and the last error.',
52
+ seam_import_cutover: 'Ends a following import for good (admins): a last pass, a check that the remote\'s tips equal the imported ones, then main and release/* take merges in Seam and main exports to the source after each merge. Refuses with remote_moved if git moved.',
53
+ seam_import_status: 'An import from a git remote: its state (reading, following, done, failed), its lines with their git tips, and its last error.',
54
+ seam_import_poke: 'Makes a following import fetch its git remote now instead of at its next interval.',
47
55
  seam_import_git: 'Imports a git repository on this computer into a quilt: HEAD\'s branch as main, every other branch as a line of its name, every tag, submodules as gitlink entries. Run it again into the same quilt to add only the new commits.',
48
56
  seam_sign: 'Signs a commit you made (rev, default your square\'s head) with your Ed25519 key, so seam_log with show_signature shows it as yours. With no signature, signs with this computer\'s key (seam_key_add makes one); a hosted agent signs the text "seam-commit <commit id>\\n" itself and passes key and signature (base64).',
49
57
  seam_keys: 'Your signing keys (or member\'s): their ids, names, and when each was added or removed.',
@@ -59,9 +67,10 @@ export const SEAM_DESCRIPTIONS = {
59
67
  seam_squares: 'The open squares of a quilt, with holder, head, how far behind main each is and its conflicted paths. all: true for merged and abandoned ones too.',
60
68
  seam_square_status: 'One square as the server sees it (head, behind_by, conflicted paths), the patches merged under it that changed its files, and which files would conflict if merged now; in a block folder also the files changed since the last commit.',
61
69
  seam_square_handoff: 'Hands your square to another member (to), with a note on what is done and what is left. You stop being able to commit to it.',
70
+ seam_square_merged: 'Closes an open square whose work a person put into git by hand while an import follows git, when the follower could not match it (the diff was edited on the way). Admins; git_sha is the imported git commit that holds the work.',
62
71
  seam_abandon: 'Abandons a square whose work is not wanted. Its commits stay readable and seam_reopen brings it back.',
63
72
  seam_reopen: 'Reopens an abandoned square; you become its holder.',
64
- seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff. Pass after (the cursor from the last answer) to get only new ones.',
73
+ seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff, check.requested (you run a check a line names you for: run it on the commit and report with seam_check and its run_key). Pass after (the cursor from the last answer) to get only new ones.',
65
74
  seam_events_webhook: 'Has the quilt server POST your events to a URL (signed with HMAC-SHA256 over <timestamp>.<body>, retried 3 times), so you are woken instead of polling: url, events (default all), secret (made for you if left out). url: "off" removes it.',
66
75
  seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Overlapping changes become conflict markers in the target.',
67
76
  seam_split: 'Splits your square by paths: groups: [[paths], ...], each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square.',
@@ -88,12 +97,12 @@ export const SEAM_DESCRIPTIONS = {
88
97
 
89
98
  export const SEAM_INPUTS = {
90
99
  seam_live: { quilt: QUILT, square: SQUARE },
91
- seam_line_require: { quilt: QUILT, line: z.string().max(120), approvals: z.number().int().min(0).max(20).optional(), checks: z.array(z.string().max(100)).max(20).optional().describe('Check names that must be green on a square\'s latest commit, e.g. ["ci"]; [] for none') },
100
+ seam_line_require: { quilt: QUILT, line: z.string().max(120), approvals: z.number().int().min(0).max(20).optional(), checks: z.array(z.string().max(100)).max(20).optional().describe('Check names that must be green on a square\'s latest commit, e.g. ["ci"]; [] for none'), runners: z.record(z.string().max(100), z.array(z.string().max(200)).max(20)).optional().describe('Who runs each check, e.g. {"ci": ["agent:ci"]}: they are asked to run it (check.requested) and only their verdicts count'), tiers: z.record(z.string().max(100), z.array(z.enum(['pre_merge', 'post_merge'])).max(2)).optional().describe('Where each check runs, e.g. {"e2e": ["post_merge"]}; default: a required check runs pre_merge on the square, a check with runners runs post_merge on the line'), tests: z.array(z.string().max(300)).max(50).optional().describe('Globs of the line\'s test files, e.g. ["test/**"]: a pre-merge check.requested names the tests a change reaches (affected)'), ci_config: z.array(z.string().max(300)).max(50).optional().describe('Globs of ci config: a change there makes affected confidence "all"') },
92
101
  seam_reviews: { quilt: QUILT, square: SQUARE },
93
102
  seam_review: { quilt: QUILT, square: SQUARE, kind: z.enum(['approve', 'request_changes', 'comment', 'dismiss']), body: z.string().max(20000).optional(), path: z.string().max(1000).optional().describe('A file, for a comment on it'), line: z.number().int().min(1).optional().describe('A line of that file at the square\'s head') },
94
103
  seam_claims: { quilt: QUILT, square: SQUARE },
95
104
  seam_claim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('Files, folders or globs, e.g. src/api/** or README.md'), note: z.string().max(300).optional().describe('What you are doing with them') },
96
- seam_check: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), status: z.enum(['red', 'green', 'flaky']), square: z.string().max(100).optional().describe('A square (sq:…): the result is for its latest commit, which a line may require green before a merge'), line: z.string().max(110).optional().describe('Default main'), patch: z.number().int().min(1).optional().describe('The patch number the check failed at: reverts it') },
105
+ seam_check: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), status: z.enum(['red', 'green', 'flaky']), run_key: z.string().max(600).optional().describe('The run_key of the check.requested event or run being answered'), url: z.string().max(2000).optional().describe('A link to the run\'s log (http(s) or file://), kept on the run'), square: z.string().max(100).optional().describe('A square (sq:…): the result is for its latest commit, which a line may require green before a merge'), line: z.string().max(110).optional().describe('Default main'), patch: z.number().int().min(1).optional().describe('The patch number the check failed at: reverts it') },
97
106
  seam_unclaim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('The claimed patterns (or claim ids)') },
98
107
  seam_live_set: { quilt: QUILT, square: SQUARE, files: z.array(z.object({ path: z.string().max(1000), content: z.string(), base64: z.boolean().optional(), mode: z.enum(['file', 'exec']).optional() })).max(2000).optional(), delete: z.array(z.string().max(1000)).max(2000).optional() },
99
108
  seam_restore: { paths: z.array(z.string().max(1000)).min(1).max(200).describe('Files or folders, relative to the block folder ("." for all)') },
@@ -105,6 +114,12 @@ export const SEAM_INPUTS = {
105
114
  seam_line_freeze: { quilt: QUILT, line: z.string().max(110) },
106
115
  seam_line_unfreeze: { quilt: QUILT, line: z.string().max(110) },
107
116
  seam_tag_list: { quilt: QUILT },
117
+ seam_import_remote: { url: z.string().max(2000).describe('The git remote: https://, ssh:// or user@host:path'), into: z.string().max(63).describe('The new quilt\'s slug'), follow: z.boolean().optional().describe('Keep following main and release/* until cut-over'), interval: z.number().int().min(60).optional().describe('Seconds between follow passes (default 600)'), credential: z.string().max(100).optional().describe('The name of the server secret that reads the remote (user:token or a token)') },
118
+ seam_export_remote: { quilt: z.string().max(100).optional().describe('The quilt'), target: z.string().max(2000).describe('The git remote'), lines: z.array(z.string().max(100)).max(20).optional().describe('Lines to export (default ["main"]); release/* for every release line'), releases: z.boolean().optional().describe('Push release tags too'), credential: z.string().max(100).optional().describe('The name of the server secret that may push'), scratch: z.boolean().optional().describe('A scratch remote nothing pins a sha from'), enabled: z.boolean().optional().describe('false turns the export off') },
119
+ seam_export_status: { quilt: z.string().max(100).optional().describe('The quilt') },
120
+ seam_import_cutover: { id: z.string().max(100).describe('The import id'), credential: z.string().max(100).optional().describe('The server secret that may push to the source (default the import\'s)') },
121
+ seam_import_status: { id: z.string().max(100).describe('The import id') },
122
+ seam_import_poke: { id: z.string().max(100).describe('The import id') },
108
123
  seam_import_git: { path: z.string().max(1000).describe('The git repository\'s folder'), into: z.string().max(63).optional().describe('The quilt\'s slug (default the folder\'s name); a quilt an import made takes only the new commits'), validate: z.boolean().optional().describe('Compare every patch written with git\'s tree') },
109
124
  seam_sign: { quilt: QUILT, rev: REV, key: z.string().max(40).optional().describe('Your key id (ed25519:…), with signature'), signature: z.string().max(200).optional().describe('Base64 Ed25519 signature of "seam-commit <commit id>\\n"') },
110
125
  seam_keys: { member: z.string().max(200).optional().describe('Whose keys (default yours)') },
@@ -140,9 +155,12 @@ export const SEAM_INPUTS = {
140
155
  },
141
156
  seam_square_handoff: { quilt: QUILT, square: SQUARE, to: z.string().max(200).describe('The member to hand it to'), note: z.string().max(2000).optional() },
142
157
  seam_abandon: { quilt: QUILT, square: SQUARE },
158
+ seam_square_merged: { quilt: QUILT, square: SQUARE, git_sha: z.string().max(64).describe('The git commit (full sha) that holds the square\'s work') },
143
159
  seam_reopen: { quilt: QUILT, square: SQUARE },
144
- seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), kinds: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff'])).optional() },
145
- seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff'])).optional(), secret: z.string().min(16).max(200).optional() },
160
+ seam_check_rerun: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), square: z.string().max(100).optional().describe('A square (sq:…): re-run on its latest commit'), line: z.string().max(110).optional().describe('A line: re-run on its tip (default main when no square)'), commit: z.string().max(80).optional().describe('A commit instead of the head or tip') },
161
+ seam_checks: { quilt: QUILT, state: z.enum(['queued', 'running', 'done', 'cancelled']).optional(), name: z.string().max(100).optional(), tier: z.enum(['pre_merge', 'post_merge']).optional(), line: z.string().max(120).optional(), square: z.string().max(100).optional() },
162
+ seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), kinds: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff', 'check.requested'])).optional() },
163
+ seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff', 'check.requested'])).optional(), secret: z.string().min(16).max(200).optional() },
146
164
  seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional() },
147
165
  seam_split: { quilt: QUILT, square: SQUARE, groups: z.array(z.array(z.string().max(1000)).min(1).max(2000)).min(1).max(20).describe('Paths (files or directories) per new square'), titles: z.array(z.string().max(200)).max(20).optional() },
148
166
  seam_merge: { quilt: QUILT, square: SQUARE, from: z.string().max(200).optional().describe('Merge this square (sq:…) or revision into your square instead of merging your square into a line'), into: z.string().max(100).optional().describe('The line, default main'), message: z.string().max(65536).optional(), dry_run: z.boolean().optional(), on_conflict: z.enum(['rebase', 'leave']).optional() },
@@ -169,8 +187,9 @@ export async function runSeamTool (name, args, { client, found = null }) {
169
187
  try {
170
188
  switch (name) {
171
189
  case 'seam_line_require': {
172
- if (args.approvals === undefined && args.checks === undefined) return describePolicy(await client.linePolicy(needQuilt(), args.line))
173
- return describePolicy(await client.setLinePolicy(needQuilt(), args.line, { ...(args.approvals !== undefined ? { approvals: args.approvals } : {}), ...(args.checks ? { checks: args.checks } : {}) }))
190
+ const extra = Object.fromEntries(['tiers', 'tests', 'ci_config'].filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
191
+ if (args.approvals === undefined && args.checks === undefined && args.runners === undefined && !Object.keys(extra).length) return describePolicy(await client.linePolicy(needQuilt(), args.line))
192
+ return describePolicy(await client.setLinePolicy(needQuilt(), args.line, { ...(args.approvals !== undefined ? { approvals: args.approvals } : {}), ...(args.checks ? { checks: args.checks } : {}), ...(args.runners ? { runners: args.runners } : {}), ...extra }))
174
193
  }
175
194
  case 'seam_reviews': return describeReviews(await client.reviews(needQuilt(), needSquare()))
176
195
  case 'seam_review': return describeReviewed(await client.review(needQuilt(), needSquare(), { kind: args.kind, body: args.body || '', ...(args.path ? { path: args.path } : {}), ...(args.line ? { line: args.line } : {}) }))
@@ -180,8 +199,10 @@ export async function runSeamTool (name, args, { client, found = null }) {
180
199
  for (const p of args.patterns) made.push(await client.claim(needQuilt(), needSquare(), { pattern: p, note: args.note }))
181
200
  return made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until the square merges or seam_unclaim.`).join('\n')
182
201
  }
183
- case 'seam_check': if (args.square) return describeSquareCheck(await client.reportSquareCheck(needQuilt(), args.square, { check: args.check, status: args.status }))
184
- return describeCheck(await client.reportCheck(needQuilt(), args.line || 'main', { check: args.check, status: args.status, ...(args.patch ? { patch: args.patch } : {}) }))
202
+ case 'seam_check': if (args.square) return describeSquareCheck(await client.reportSquareCheck(needQuilt(), args.square, { check: args.check, status: args.status, ...(args.run_key ? { run_key: args.run_key } : {}), ...(args.url ? { url: args.url } : {}) }))
203
+ return describeCheck(await client.reportCheck(needQuilt(), args.line || 'main', { check: args.check, status: args.status, ...(args.patch ? { patch: args.patch } : {}), ...(args.run_key ? { run_key: args.run_key } : {}), ...(args.url ? { url: args.url } : {}) }))
204
+ case 'seam_check_rerun': return describeRerun(await client.rerunCheck(needQuilt(), { check: args.check, ...(args.square ? { square: args.square } : { line: args.line || 'main' }), ...(args.commit ? { commit: args.commit } : {}) }))
205
+ case 'seam_checks': return describeCheckRuns(await client.checkRuns(needQuilt(), { state: args.state, name: args.name, tier: args.tier, line: args.line, square: args.square }))
185
206
  case 'seam_unclaim': {
186
207
  const out = []
187
208
  for (const p of args.patterns) out.push(await client.unclaim(needQuilt(), needSquare(), p))
@@ -260,6 +281,7 @@ export async function runSeamTool (name, args, { client, found = null }) {
260
281
  return signed(await client.commit(quilt, sq.id, { expect_head: sq.head, message: args.message, changes }), 'Committed')
261
282
  }
262
283
  case 'seam_square_handoff': { const sq = await client.handoff(needQuilt(), needSquare(), { to: args.to, note: args.note }); return `Handed ${sq.short} to ${sq.holder}.` }
284
+ case 'seam_square_merged': { const sq = await client.squareMerged(needQuilt(), needSquare(), { git_sha: args.git_sha }); return `${sq.short} is merged by hand: its work is patch ${sq.merged.patch_no} on ${sq.merged.line} (git ${sq.merged.git_sha.slice(0, 12)}).` }
263
285
  case 'seam_abandon': { const sq = await client.abandon(needQuilt(), needSquare(), inFolder ? { expect_head: block.head } : {}); return `Abandoned ${sq.short}; seam_reopen brings it back.` }
264
286
  case 'seam_reopen': { const sq = await client.reopen(needQuilt(), needSquare()); return `Reopened ${describeSquare(sq)}.` }
265
287
  case 'seam_events': return describeEvents(await client.events(needQuilt(), { after: args.after, kinds: args.kinds }))
@@ -288,6 +310,12 @@ export async function runSeamTool (name, args, { client, found = null }) {
288
310
  case 'seam_line_unfreeze': { const l = await client.unfreezeLine(needQuilt(), args.line); return `${l.name} takes merges again.` }
289
311
  case 'seam_tag_list': { const ts = await client.tags(needQuilt()); return ts.length ? ts.map(describeTag).join('\n') : 'No tags yet.' }
290
312
  case 'seam_release_list': { const rs = await client.releases(needQuilt()); return rs.length ? rs.map(describeRelease).join('\n') : 'No releases yet.' }
313
+ case 'seam_import_remote': { const r = await client.startImport({ url: args.url, slug: args.into, follow: !!args.follow, interval: args.interval, credential: args.credential }); return `Importing ${args.url} into ${args.into} (import ${r.id})${r.follow ? `, following every ${r.interval_s} s; poke secret ${r.poke_secret} (shown once)` : ''}.` }
314
+ case 'seam_export_remote': { const r = await client.setExport(needQuilt(), { target: args.target, lines: args.lines, releases: args.releases, credential: args.credential, scratch: args.scratch, enabled: args.enabled !== false }); return r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge.` : `The export to ${r.target} is off.` }
315
+ case 'seam_export_status': { const r = await client.exportStatus(needQuilt()); return JSON.stringify({ target: r.target, lines: r.lines, releases: r.releases, enabled: r.enabled, status: r.status }, null, 2) }
316
+ case 'seam_import_cutover': { const r = await client.cutover(args.id, { credential: args.credential }); return `Cut over: import ${r.import.id} is done; main exports to ${r.export.target} after each merge.` }
317
+ case 'seam_import_status': return describeImportRecord(await client.importStatus(args.id))
318
+ case 'seam_import_poke': { const r = await client.pokeImport(args.id); return `Poked: import ${r.id} fetches the remote now.` }
291
319
  case 'seam_import_git': {
292
320
  const { importGit } = await import('./import-git.js')
293
321
  const { default: nodePath } = await import('node:path')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.17.0",
3
+ "version": "0.26.0",
4
4
  "description": "seam: Quilt Source's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",