@heyquilt/seam 0.0.0-stage → 0.1.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/README.md +25 -2
- package/bin/seam.js +80 -0
- package/lib/account.js +120 -0
- package/lib/quilts/block.js +228 -0
- package/lib/quilts/cbor.js +92 -0
- package/lib/quilts/cli.js +254 -0
- package/lib/quilts/client.js +92 -0
- package/lib/quilts/describe.js +120 -0
- package/lib/quilts/export-git.js +126 -0
- package/lib/quilts/hash.js +44 -0
- package/lib/quilts/ids.js +12 -0
- package/lib/quilts/import-git.js +193 -0
- package/lib/quilts/pack.js +118 -0
- package/lib/quilts/records.js +210 -0
- package/lib/quilts/textdiff.js +96 -0
- package/lib/quilts/tools.js +201 -0
- package/lib/quilts/xdiff.js +435 -0
- package/package.json +27 -4
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# seam
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The command for [Quilt Source](https://frame.heyquilt.com): source control that runs on the
|
|
4
|
+
server, built for many AI agents and people working on one codebase at once. Git's verbs keep
|
|
5
|
+
git's names.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g @heyquilt/seam
|
|
9
|
+
seam login
|
|
10
|
+
seam import git ./my-app
|
|
11
|
+
seam square open "Fix sign-in" --quilt my-app --dir work
|
|
12
|
+
cd work && seam commit -m "Sign-in keeps the session" && seam merge
|
|
13
|
+
seam log --quilt my-app --oneline
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A **quilt** is a repository, a **square** is one agent's or task's work in progress, a **patch**
|
|
17
|
+
is a square once it is merged into `main`, and a **block** is the part of a quilt you check out
|
|
18
|
+
on disk (a folder with `.seam/block.json`).
|
|
19
|
+
|
|
20
|
+
- `seam help` lists every command.
|
|
21
|
+
- `seam mcp` serves the same commands as MCP tools (`seam_log`, `seam_commit`, `seam_merge`, …)
|
|
22
|
+
for your AI tool.
|
|
23
|
+
- Agents set `SEAM_TOKEN` to their key instead of signing in. `SEAM_API_URL` points at another
|
|
24
|
+
server.
|
|
25
|
+
|
|
26
|
+
The docs are at https://frame.heyquilt.com/docs.
|
package/bin/seam.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// seam: Quilt Source's command (frame.heyquilt.com). Git's verbs for source control that runs on
|
|
3
|
+
// the server; its own package and sign-in, apart from the Quilt app's `quilt` command.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { readAccount, login, logout, resume } from '../lib/account.js'
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
// The published package ships the client in lib/quilts (scripts/build.mjs); in the repo it is
|
|
12
|
+
// read straight from the server's src/quilts.
|
|
13
|
+
const QUILTS = fs.existsSync(path.join(here, '../lib/quilts/cli.js')) ? '../lib/quilts' : '../../src/quilts'
|
|
14
|
+
const quilts = (file) => import(`${QUILTS}/${file}`)
|
|
15
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(here, '../package.json'), 'utf8'))
|
|
16
|
+
|
|
17
|
+
const HELP = `seam ${version}: Quilt Source, source control for people and their agents
|
|
18
|
+
Docs: https://frame.heyquilt.com/docs
|
|
19
|
+
|
|
20
|
+
seam login [--no-browser] Sign in with your heyquilt.com account
|
|
21
|
+
seam logout | seam whoami
|
|
22
|
+
seam quilts [new <slug> | show <q>] Your quilts (repositories)
|
|
23
|
+
seam import git <path> [--into <slug>]
|
|
24
|
+
seam square open "<title>" --quilt <q> [--dir <folder>]
|
|
25
|
+
seam checkout <sq:id | revision> --quilt <q> [--dir <folder>] [-- paths]
|
|
26
|
+
seam status | diff | commit -m "<message>" in a block folder
|
|
27
|
+
seam merge [sq:id] [--into main] seam rebase | revert <patch:n> | cherry-pick <rev>
|
|
28
|
+
seam resolve <path>... --ours | --theirs | --base | --side n | --delete
|
|
29
|
+
seam log [rev] [-n 20] [--oneline] seam show <rev> seam blame <path>[@rev] seam cat | ls
|
|
30
|
+
seam events [--webhook <url>] What happened to your squares
|
|
31
|
+
seam export git <dir> Back to git
|
|
32
|
+
seam mcp The same commands as MCP tools, for your AI tool
|
|
33
|
+
|
|
34
|
+
Agents: set SEAM_TOKEN to the agent's key instead of signing in.`
|
|
35
|
+
|
|
36
|
+
const cmd = process.argv[2]
|
|
37
|
+
const argv = process.argv.slice(3)
|
|
38
|
+
const fail = (msg) => { console.error(msg); process.exit(1) }
|
|
39
|
+
const openBrowser = (url) => { try { spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open', [url], { stdio: 'ignore', detached: true }).unref() } catch {} }
|
|
40
|
+
|
|
41
|
+
async function main () {
|
|
42
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return console.log(HELP)
|
|
43
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') return console.log(version)
|
|
44
|
+
if (cmd === 'login') {
|
|
45
|
+
const a = readAccount()
|
|
46
|
+
if (a) return console.log(`Already signed in as ${a.account.name} (${a.account.email}). Run seam logout first to switch accounts.`)
|
|
47
|
+
const r = await login({ open: argv.includes('--no-browser') ? () => {} : openBrowser })
|
|
48
|
+
return console.log(`Signed in as ${r.account.name} (${r.account.email}).`)
|
|
49
|
+
}
|
|
50
|
+
if (cmd === 'logout') { const a = await logout(); return console.log(a ? `Signed out of ${a.account.email}.` : 'Not signed in.') }
|
|
51
|
+
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})`) }
|
|
52
|
+
if (cmd === 'mcp') return mcp()
|
|
53
|
+
const { SEAM_VERBS, runSeamCommand } = await quilts('cli.js')
|
|
54
|
+
if (!SEAM_VERBS.has(cmd)) fail(`seam: '${cmd}' is not a seam command. See seam help.`)
|
|
55
|
+
try {
|
|
56
|
+
return await runSeamCommand(cmd, argv, { token: readAccount()?.token })
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// A lapsed sign-in: renew it with this computer's key and try once more.
|
|
59
|
+
if (err.status === 401 && !process.env.SEAM_TOKEN && readAccount()) {
|
|
60
|
+
const back = await resume().catch(() => null)
|
|
61
|
+
if (back) return runSeamCommand(cmd, argv, { token: back.token })
|
|
62
|
+
fail('Your sign-in has ended. Run seam login.')
|
|
63
|
+
}
|
|
64
|
+
throw err
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// `seam mcp`: the source-control tools for an AI tool, over stdio, as the signed-in person (or SEAM_TOKEN).
|
|
69
|
+
async function mcp () {
|
|
70
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
|
|
71
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
|
|
72
|
+
const { SEAM_GUIDE, registerSeamTools } = await quilts('tools.js')
|
|
73
|
+
const { connect } = await quilts('cli.js')
|
|
74
|
+
const { findBlock } = await quilts('block.js')
|
|
75
|
+
const server = new McpServer({ name: 'seam', version }, { instructions: SEAM_GUIDE })
|
|
76
|
+
registerSeamTools(server, { client: () => connect(findBlock()?.block, readAccount()?.token).client, found: () => findBlock() })
|
|
77
|
+
await server.connect(new StdioServerTransport())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
main().catch((err) => fail(err.message))
|
package/lib/account.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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.
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import crypto from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
export const API_URL = 'https://api.heyquilt.com'
|
|
11
|
+
export const apiUrl = () => String(process.env.SEAM_API_URL || API_URL).replace(/\/+$/, '')
|
|
12
|
+
export const seamHome = () => process.env.SEAM_HOME || path.join(os.homedir(), '.seam')
|
|
13
|
+
const accountFile = () => path.join(seamHome(), 'account.json')
|
|
14
|
+
const identityFile = () => path.join(seamHome(), 'identity.json')
|
|
15
|
+
const signedOutFile = () => path.join(seamHome(), 'signed-out')
|
|
16
|
+
|
|
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'
|
|
20
|
+
|
|
21
|
+
function writePrivate (file, data) {
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
23
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error(`Refusing to write ${file}: it's a symlink`)
|
|
24
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`
|
|
25
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600, flag: 'wx' })
|
|
26
|
+
fs.renameSync(tmp, file)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** This computer's key, made on first use. */
|
|
30
|
+
export function loadIdentity () {
|
|
31
|
+
try {
|
|
32
|
+
const id = JSON.parse(fs.readFileSync(identityFile(), 'utf8'))
|
|
33
|
+
if (id.publicKey && id.privateKey) return id
|
|
34
|
+
} catch {}
|
|
35
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
36
|
+
const id = { publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url') }
|
|
37
|
+
writePrivate(identityFile(), id)
|
|
38
|
+
return id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sign = (identity, text) => crypto.sign(null, Buffer.from(text), crypto.createPrivateKey({ key: Buffer.from(identity.privateKey, 'base64url'), format: 'der', type: 'pkcs8' })).toString('base64url')
|
|
42
|
+
|
|
43
|
+
/** { token, account: { id, name, email } }, or null when signed out. */
|
|
44
|
+
export function readAccount () {
|
|
45
|
+
try {
|
|
46
|
+
const a = JSON.parse(fs.readFileSync(accountFile(), 'utf8'))
|
|
47
|
+
return a && typeof a.token === 'string' && a.account ? a : null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveAccount (token, profile) {
|
|
54
|
+
const data = { token, account: { id: profile?.id, name: profile?.name || '', email: profile?.email || '' }, signedInAt: Date.now() }
|
|
55
|
+
writePrivate(accountFile(), data)
|
|
56
|
+
fs.rmSync(signedOutFile(), { force: true })
|
|
57
|
+
return data
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function call (method, route, body, token, extra = {}) {
|
|
61
|
+
let res
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(apiUrl() + route, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined, ...extra })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Couldn't reach Quilt (${err.cause?.code || err.message}).`)
|
|
66
|
+
}
|
|
67
|
+
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
|
+
return data || {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Signs this computer back in with its key, no browser; null when it isn't linked (or was signed out on purpose, unless `asked`). */
|
|
73
|
+
export async function resume ({ asked = false } = {}) {
|
|
74
|
+
if (!asked && fs.existsSync(signedOutFile())) return null
|
|
75
|
+
const identity = loadIdentity()
|
|
76
|
+
const at = Date.now()
|
|
77
|
+
let r
|
|
78
|
+
try {
|
|
79
|
+
r = await call('POST', '/v1/device/resume', { publicKey: identity.publicKey, at, signature: sign(identity, `${DEVICE_RESUME_CONTEXT}\0${at}`) })
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if ([400, 401, 404].includes(err.status)) return null
|
|
82
|
+
throw err
|
|
83
|
+
}
|
|
84
|
+
return typeof r.token === 'string' ? saveAccount(r.token, r.profile) : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `seam login`: back in with the key if this computer was linked before, else a page to approve in the browser. */
|
|
88
|
+
export async function login ({ say = console.log, open = () => {} } = {}) {
|
|
89
|
+
const back = await resume({ asked: true }).catch(() => null)
|
|
90
|
+
if (back) return back
|
|
91
|
+
const identity = loadIdentity()
|
|
92
|
+
const link = await call('POST', '/v1/device/start', { publicKey: identity.publicKey, deviceName: `${os.hostname().replace(/\.local$/, '')} (seam)`, platform: process.platform })
|
|
93
|
+
say(`To sign in, open this page and approve this computer:\n\n ${link.verificationUrl}\n\nCheck it shows the code ${link.userCode}. Waiting…`)
|
|
94
|
+
open(link.verificationUrl)
|
|
95
|
+
const until = Date.now() + link.expiresIn * 1000
|
|
96
|
+
while (Date.now() < until) {
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, link.interval * 1000))
|
|
98
|
+
let r
|
|
99
|
+
try {
|
|
100
|
+
r = await call('POST', '/v1/device/poll', { deviceCode: link.deviceCode, signature: sign(identity, `${DEVICE_LINK_CONTEXT}\0${link.deviceCode}`) })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err.status === 410) break
|
|
103
|
+
if (err.status === 403) throw new Error('Sign-in was declined in the browser.')
|
|
104
|
+
if (err.status && !(err.status >= 500 || err.status === 429)) throw err
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (r.status === 'approved') return saveAccount(r.token, r.profile)
|
|
108
|
+
}
|
|
109
|
+
throw new Error('The code expired. Run seam login again.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `seam logout`: forgets the sign-in here, then revokes it (best effort). */
|
|
113
|
+
export async function logout () {
|
|
114
|
+
const a = readAccount()
|
|
115
|
+
fs.rmSync(accountFile(), { force: true })
|
|
116
|
+
fs.mkdirSync(seamHome(), { recursive: true })
|
|
117
|
+
fs.writeFileSync(signedOutFile(), '')
|
|
118
|
+
if (a) await call('POST', '/v1/me/signout', {}, a.token, { signal: AbortSignal.timeout(5000) }).catch(() => {})
|
|
119
|
+
return a
|
|
120
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Blocks on disk (plans/quilt-source-control/05): a folder holding part of one square, the
|
|
2
|
+
// subtrees named by `paths`, as of commit `at`. The folder is a projection of a known commit
|
|
3
|
+
// plus the edits made in it since; it is rebuilt from the server, never repaired. Its record is
|
|
4
|
+
// .seam/block.json: the quilt, the square, the paths, `at`, the head it last saw, and a
|
|
5
|
+
// manifest of what was written (blob, mode, size, mtime) so status needs no server call.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import ignore from 'ignore'
|
|
9
|
+
import { blobId, cleanPrefix, under, cmpPath, cleanCommit, commitBytes, commitId } from './records.js'
|
|
10
|
+
import { unified, isText } from './textdiff.js'
|
|
11
|
+
|
|
12
|
+
/** A block folder's own folder (seam's, not the Quilt app's .quilt). */
|
|
13
|
+
export const BLOCK_DIR = '.seam'
|
|
14
|
+
export const BLOCK_FILE = path.join(BLOCK_DIR, 'block.json')
|
|
15
|
+
const ALWAYS_IGNORED = ['.git', BLOCK_DIR, 'node_modules']
|
|
16
|
+
|
|
17
|
+
/** The block a folder (or one of its parents) holds, as { dir, block }, or null. */
|
|
18
|
+
export function findBlock (start = process.cwd()) {
|
|
19
|
+
let dir = path.resolve(start)
|
|
20
|
+
for (;;) {
|
|
21
|
+
const f = path.join(dir, BLOCK_FILE)
|
|
22
|
+
if (fs.existsSync(f)) return { dir, block: JSON.parse(fs.readFileSync(f, 'utf8')) }
|
|
23
|
+
const up = path.dirname(dir)
|
|
24
|
+
if (up === dir) return null
|
|
25
|
+
dir = up
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveBlock (dir, block) {
|
|
30
|
+
fs.mkdirSync(path.join(dir, BLOCK_DIR), { recursive: true })
|
|
31
|
+
const f = path.join(dir, BLOCK_FILE)
|
|
32
|
+
fs.writeFileSync(f + '.tmp', JSON.stringify(block, null, 1))
|
|
33
|
+
fs.renameSync(f + '.tmp', f)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const inBlock = (block, p) => block.paths.some((x) => under(p, x))
|
|
37
|
+
|
|
38
|
+
function ignorer (dir) {
|
|
39
|
+
const ig = ignore().add(ALWAYS_IGNORED)
|
|
40
|
+
try { ig.add(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')) } catch {}
|
|
41
|
+
return ig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every file under the block's paths on disk: Map path → { abs, stat }. */
|
|
45
|
+
function scan (dir, block) {
|
|
46
|
+
const ig = ignorer(dir)
|
|
47
|
+
const out = new Map()
|
|
48
|
+
const walk = (rel) => {
|
|
49
|
+
let names
|
|
50
|
+
try { names = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }) } catch { return }
|
|
51
|
+
for (const d of names) {
|
|
52
|
+
const r = rel ? `${rel}/${d.name}` : d.name
|
|
53
|
+
if (ig.ignores(d.isDirectory() ? r + '/' : r)) continue
|
|
54
|
+
if (d.isDirectory()) { if (block.paths.some((x) => under(r, x) || under(x, r))) walk(r); continue }
|
|
55
|
+
if (!(d.isFile() || d.isSymbolicLink()) || !inBlock(block, r)) continue
|
|
56
|
+
const abs = path.join(dir, r)
|
|
57
|
+
out.set(r.normalize('NFC'), { abs, stat: fs.lstatSync(abs) })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
walk('')
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readFileEntry (abs, stat) {
|
|
65
|
+
if (stat.isSymbolicLink()) return { bytes: Buffer.from(fs.readlinkSync(abs)), mode: 'symlink' }
|
|
66
|
+
return { bytes: fs.readFileSync(abs), mode: (stat.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What changed in the folder since `at`: [{ path, status: 'added' | 'modified' | 'deleted',
|
|
71
|
+
* blob?, mode?, bytes? }]. A conflicted file still carrying its term's marker is unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export function localChanges (dir, block, { paths = [] } = {}) {
|
|
74
|
+
const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
|
|
75
|
+
const wanted = (p) => !only.length || only.some((x) => under(p, x))
|
|
76
|
+
const files = scan(dir, block)
|
|
77
|
+
const out = []
|
|
78
|
+
for (const [p, { abs, stat }] of files) {
|
|
79
|
+
if (!wanted(p)) continue
|
|
80
|
+
const known = block.files[p]
|
|
81
|
+
if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) continue
|
|
82
|
+
const { bytes, mode } = readFileEntry(abs, stat)
|
|
83
|
+
const id = blobId(bytes)
|
|
84
|
+
if (known && known.blob === id && known.mode === mode) continue
|
|
85
|
+
if (known && known.term && bytes.includes(Buffer.from(`quilt-conflict ${known.term.slice(0, 12)}`))) continue
|
|
86
|
+
out.push({ path: p, status: known ? 'modified' : 'added', blob: id, mode, size: bytes.length, bytes })
|
|
87
|
+
}
|
|
88
|
+
for (const p of Object.keys(block.files)) if (!files.has(p) && wanted(p)) out.push({ path: p, status: 'deleted' })
|
|
89
|
+
return out.sort((a, b) => cmpPath(a.path, b.path))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Writes one tree entry to the folder and returns its manifest record. */
|
|
93
|
+
async function writeEntry (dir, e, content) {
|
|
94
|
+
const abs = path.join(dir, ...e.path.split('/'))
|
|
95
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
96
|
+
try { fs.rmSync(abs, { force: true }) } catch {}
|
|
97
|
+
if (e.mode === 'symlink') fs.symlinkSync(content.toString('utf8'), abs)
|
|
98
|
+
else fs.writeFileSync(abs, content, { mode: e.mode === 'exec' ? 0o755 : 0o644 })
|
|
99
|
+
const st = fs.lstatSync(abs)
|
|
100
|
+
return { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: st.size, mtimeMs: st.mtimeMs, ...(e.term ? { term: e.term } : {}) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fetches blobs by id in batches (want-only, decision 31). */
|
|
104
|
+
async function fetchBlobs (client, quilt, ids) {
|
|
105
|
+
const out = new Map()
|
|
106
|
+
const list = [...new Set(ids)]
|
|
107
|
+
for (let i = 0; i < list.length; i += 200) {
|
|
108
|
+
const { blobs } = await client.blobs(quilt, list.slice(i, i + 200))
|
|
109
|
+
for (const [id, b64] of Object.entries(blobs)) out.set(id, Buffer.from(b64, 'base64'))
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function contentsFor (client, quilt, rev, entries) {
|
|
115
|
+
const blobs = await fetchBlobs(client, quilt, entries.filter((e) => e.blob).map((e) => e.blob))
|
|
116
|
+
const get = async (e) => {
|
|
117
|
+
if (e.mode !== 'conflict') return blobs.get(e.blob)
|
|
118
|
+
const r = await client.cat(quilt, rev, e.path, { format: 'diff' })
|
|
119
|
+
return Buffer.from(r.content, 'base64')
|
|
120
|
+
}
|
|
121
|
+
return get
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Checks out `rev` (a square's head, or any revision) under `paths` into `dir` as a new block.
|
|
126
|
+
* Refuses a folder that already holds files, unless it is this block's own folder.
|
|
127
|
+
*/
|
|
128
|
+
export async function checkout (client, { quilt, square = null, rev, paths = [''], dir, api }) {
|
|
129
|
+
const prefixes = (paths.length ? paths : ['']).map(cleanPrefix)
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
131
|
+
const existing = fs.readdirSync(dir).filter((n) => n !== BLOCK_DIR)
|
|
132
|
+
if (existing.length) throw new Error(`${dir} is not empty; check out into an empty folder (or switch an existing block with quilt checkout inside it)`)
|
|
133
|
+
const sq = square ? await client.square(quilt, square) : null
|
|
134
|
+
const at = sq ? (sq.head || sq.base) : rev || 'main'
|
|
135
|
+
const t = await client.tree(quilt, at || 'main', prefixes)
|
|
136
|
+
const get = await contentsFor(client, quilt, at || 'main', t.entries)
|
|
137
|
+
const files = {}
|
|
138
|
+
for (const e of t.entries) files[e.path] = await writeEntry(dir, e, await get(e))
|
|
139
|
+
const block = { api, quilt, square: sq ? sq.id : null, square_short: sq ? sq.short : null, line: sq ? sq.line : (rev || 'main'), paths: prefixes, at: t.commit, head: sq ? sq.head : null, files }
|
|
140
|
+
saveBlock(dir, block)
|
|
141
|
+
return { block, files: t.entries.length }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Moves the folder from `block.at` to commit `to`, file by file: paths changed on the server
|
|
146
|
+
* are rewritten; a path changed both here and there is left as it is here and reported.
|
|
147
|
+
*/
|
|
148
|
+
export async function syncTo (client, dir, block, to, { square } = {}) {
|
|
149
|
+
const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
|
|
150
|
+
const d = await client.diff(block.quilt, { from: block.at ? `c:${block.at}` : undefined, to: to ? `c:${to}` : 'main', paths: block.paths, format: 'names' })
|
|
151
|
+
const t = d.files.length ? await client.tree(block.quilt, to ? `c:${to}` : 'main', block.paths) : { entries: [] }
|
|
152
|
+
const byPath = new Map(t.entries.map((e) => [e.path, e]))
|
|
153
|
+
const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean))
|
|
154
|
+
const kept = []
|
|
155
|
+
let written = 0
|
|
156
|
+
for (const f of d.files) {
|
|
157
|
+
if (local.has(f.path)) { kept.push(f.path); continue }
|
|
158
|
+
const e = byPath.get(f.path)
|
|
159
|
+
if (!e) { fs.rmSync(path.join(dir, ...f.path.split('/')), { force: true }); delete block.files[f.path]; written++; continue }
|
|
160
|
+
block.files[f.path] = await writeEntry(dir, e, await get(e))
|
|
161
|
+
written++
|
|
162
|
+
}
|
|
163
|
+
block.at = to
|
|
164
|
+
if (square) { block.square = square.id; block.square_short = square.short; block.head = square.head; block.line = square.line }
|
|
165
|
+
saveBlock(dir, block)
|
|
166
|
+
return { written, kept }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** status: the folder's changes, and the square as the server sees it. */
|
|
170
|
+
export async function status (client, dir, block) {
|
|
171
|
+
const changes = localChanges(dir, block).map(({ bytes, ...c }) => c)
|
|
172
|
+
const square = block.square ? await client.square(block.quilt, block.square) : null
|
|
173
|
+
const conflicted = Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p)
|
|
174
|
+
return { dir, quilt: block.quilt, square, paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.head !== block.head) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** commit: uploads the changed files' blobs (have?, then a pack) and seals them as a commit. */
|
|
178
|
+
export async function commitFolder (client, dir, block, { message, paths = [] }) {
|
|
179
|
+
if (!block.square) throw new Error('This folder is a read-only checkout of a line; open a square (quilt square open) to commit.')
|
|
180
|
+
const changes = localChanges(dir, block, { paths })
|
|
181
|
+
if (!changes.length) return { nothing: true }
|
|
182
|
+
const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
|
|
183
|
+
const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode })
|
|
184
|
+
// 03: the commit record is built here and travels in the pack with its blobs, so the commit
|
|
185
|
+
// request names it by id. The server refuses a record that is not exactly what it would write
|
|
186
|
+
// (a file that still carries a conflict's markers, say); then the changes go as a list.
|
|
187
|
+
let r
|
|
188
|
+
try {
|
|
189
|
+
if (!block.author || !block.quilt_id) { const info = await client.quilt(block.quilt); block.author = info.you; block.quilt_id = info.id }
|
|
190
|
+
const sq = await client.square(block.quilt, block.square)
|
|
191
|
+
const { on_behalf_of: onBehalf, ...author } = block.author
|
|
192
|
+
const record = cleanCommit({
|
|
193
|
+
quilt_id: block.quilt_id, square_id: sq.id, parents: [block.head || sq.base].filter(Boolean), author,
|
|
194
|
+
...(onBehalf ? { on_behalf_of: onBehalf } : {}), ...(sq.task_id ? { task_id: sq.task_id } : {}),
|
|
195
|
+
message: message || '', authored_at: Date.now(),
|
|
196
|
+
changes: list.map((c) => c.op === 'delete' ? c : { ...c, mode: c.mode || 'file', size: changes.find((x) => x.path === c.path).bytes.length })
|
|
197
|
+
})
|
|
198
|
+
const id = commitId(record)
|
|
199
|
+
await client.upload(block.quilt, [...blobs, { kind: 'commit', id, bytes: commitBytes(record) }])
|
|
200
|
+
r = await client.commit(block.quilt, block.square, { expect_head: block.head, commit: id })
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code !== 'record_mismatch') throw err
|
|
203
|
+
r = await client.commit(block.quilt, block.square, { expect_head: block.head, message, changes: list })
|
|
204
|
+
}
|
|
205
|
+
for (const c of changes) {
|
|
206
|
+
if (c.status === 'deleted') { delete block.files[c.path]; continue }
|
|
207
|
+
const st = fs.lstatSync(path.join(dir, ...c.path.split('/')))
|
|
208
|
+
block.files[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
|
|
209
|
+
}
|
|
210
|
+
block.at = r.square.head
|
|
211
|
+
block.head = r.square.head
|
|
212
|
+
saveBlock(dir, block)
|
|
213
|
+
return r
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
|
|
217
|
+
export async function localDiff (client, dir, block, { paths = [] } = {}) {
|
|
218
|
+
const changes = localChanges(dir, block, { paths })
|
|
219
|
+
const olds = await fetchBlobs(client, block.quilt, changes.map((c) => block.files[c.path]?.blob).filter(Boolean))
|
|
220
|
+
const out = []
|
|
221
|
+
for (const c of changes) {
|
|
222
|
+
const before = block.files[c.path]?.blob ? olds.get(block.files[c.path].blob) : null
|
|
223
|
+
const after = c.bytes || null
|
|
224
|
+
if (!isText(before) || !isText(after)) { out.push(`Binary file ${c.path} ${c.status}\n`); continue }
|
|
225
|
+
out.push(unified(c.path, before ? before.toString('utf8') : null, after ? after.toString('utf8') : null))
|
|
226
|
+
}
|
|
227
|
+
return { changes: changes.map(({ bytes, ...c }) => c), text: out.join('') }
|
|
228
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Deterministic CBOR (RFC 8949 §4.2.1, "core deterministic encoding") for Quilt records: every
|
|
2
|
+
// id is a hash of these bytes, so every client and server must write exactly the same ones.
|
|
3
|
+
// Supported: unsigned and negative integers (safe range), byte strings (Uint8Array/Buffer),
|
|
4
|
+
// text, arrays, maps (plain objects; keys sorted bytewise on their encoded form), true, false,
|
|
5
|
+
// null. No floats, no indefinite lengths. Undefined fields are left out, never encoded.
|
|
6
|
+
|
|
7
|
+
function head (major, n) {
|
|
8
|
+
if (!Number.isSafeInteger(n) || n < 0) throw new Error(`cbor: bad length or integer ${n}`)
|
|
9
|
+
if (n < 24) return Buffer.from([(major << 5) | n])
|
|
10
|
+
if (n < 0x100) return Buffer.from([(major << 5) | 24, n])
|
|
11
|
+
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b }
|
|
12
|
+
if (n < 0x100000000) { const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b }
|
|
13
|
+
const b = Buffer.alloc(9); b[0] = (major << 5) | 27; b.writeBigUInt64BE(BigInt(n), 1); return b
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function encodeInto (v, out) {
|
|
17
|
+
if (v === null) return out.push(Buffer.from([0xf6]))
|
|
18
|
+
if (v === true) return out.push(Buffer.from([0xf5]))
|
|
19
|
+
if (v === false) return out.push(Buffer.from([0xf4]))
|
|
20
|
+
if (typeof v === 'number') {
|
|
21
|
+
if (!Number.isSafeInteger(v)) throw new Error(`cbor: no floats (${v})`)
|
|
22
|
+
return out.push(v >= 0 ? head(0, v) : head(1, -1 - v))
|
|
23
|
+
}
|
|
24
|
+
if (typeof v === 'string') {
|
|
25
|
+
const b = Buffer.from(v, 'utf8')
|
|
26
|
+
out.push(head(3, b.length)); return out.push(b)
|
|
27
|
+
}
|
|
28
|
+
if (v instanceof Uint8Array) { out.push(head(2, v.length)); return out.push(Buffer.from(v)) }
|
|
29
|
+
if (Array.isArray(v)) {
|
|
30
|
+
out.push(head(4, v.length))
|
|
31
|
+
for (const x of v) {
|
|
32
|
+
if (x === undefined) throw new Error('cbor: undefined in an array')
|
|
33
|
+
encodeInto(x, out)
|
|
34
|
+
}
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (typeof v === 'object') {
|
|
38
|
+
const entries = Object.entries(v).filter(([, x]) => x !== undefined)
|
|
39
|
+
.map(([k, x]) => [encode(k), x])
|
|
40
|
+
.sort(([a], [b]) => Buffer.compare(a, b))
|
|
41
|
+
out.push(head(5, entries.length))
|
|
42
|
+
for (const [k, x] of entries) { out.push(k); encodeInto(x, out) }
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`cbor: cannot encode ${typeof v}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The deterministic CBOR bytes of `v`. */
|
|
49
|
+
export function encode (v) {
|
|
50
|
+
const out = []
|
|
51
|
+
encodeInto(v, out)
|
|
52
|
+
return Buffer.concat(out)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decodes one CBOR item (as written by encode); byte strings come back as Buffers. */
|
|
56
|
+
export function decode (buf) {
|
|
57
|
+
const b = Buffer.from(buf.buffer ? buf : Buffer.from(buf))
|
|
58
|
+
let pos = 0
|
|
59
|
+
const arg = (info) => {
|
|
60
|
+
if (info < 24) return info
|
|
61
|
+
if (info === 24) return b[pos++]
|
|
62
|
+
if (info === 25) { const n = b.readUInt16BE(pos); pos += 2; return n }
|
|
63
|
+
if (info === 26) { const n = b.readUInt32BE(pos); pos += 4; return n }
|
|
64
|
+
if (info === 27) { const n = Number(b.readBigUInt64BE(pos)); pos += 8; return n }
|
|
65
|
+
throw new Error('cbor: indefinite or reserved length')
|
|
66
|
+
}
|
|
67
|
+
const item = () => {
|
|
68
|
+
if (pos >= b.length) throw new Error('cbor: truncated')
|
|
69
|
+
const ib = b[pos++]
|
|
70
|
+
const major = ib >> 5
|
|
71
|
+
const info = ib & 31
|
|
72
|
+
if (major === 7) {
|
|
73
|
+
if (info === 20) return false
|
|
74
|
+
if (info === 21) return true
|
|
75
|
+
if (info === 22) return null
|
|
76
|
+
throw new Error('cbor: unsupported simple value or float')
|
|
77
|
+
}
|
|
78
|
+
const n = arg(info)
|
|
79
|
+
switch (major) {
|
|
80
|
+
case 0: return n
|
|
81
|
+
case 1: return -1 - n
|
|
82
|
+
case 2: { const v = b.subarray(pos, pos + n); pos += n; if (v.length !== n) throw new Error('cbor: truncated'); return Buffer.from(v) }
|
|
83
|
+
case 3: { const v = b.toString('utf8', pos, pos + n); pos += n; if (pos > b.length) throw new Error('cbor: truncated'); return v }
|
|
84
|
+
case 4: { const a = []; for (let i = 0; i < n; i++) a.push(item()); return a }
|
|
85
|
+
case 5: { const o = {}; for (let i = 0; i < n; i++) { const k = item(); o[k] = item() } return o }
|
|
86
|
+
default: throw new Error(`cbor: unsupported major type ${major}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const v = item()
|
|
90
|
+
if (pos !== b.length) throw new Error('cbor: trailing bytes')
|
|
91
|
+
return v
|
|
92
|
+
}
|