@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.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.
@@ -0,0 +1,126 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { DECK_ENTRY, readFrontmatter, WORKSPACE_CONFIG } from '@lincyaw/compiler'
4
+ import type { OperatorSettings } from '@lincyaw/compiler'
5
+ import { errorMessage, isRecord } from '@lincyaw/kernel'
6
+ import type { ApprovalKind } from '@lincyaw/kernel'
7
+ import type { ApprovalPolicy } from '@lincyaw/server'
8
+ import { parse as parseYaml } from 'yaml'
9
+ import type { WorkspaceDocument } from '../domain/api.ts'
10
+ import { matchesPattern, patternSegments } from '../domain/paths.ts'
11
+
12
+ export const DEFAULT_DOCUMENTS = [`*/${DECK_ENTRY}`]
13
+
14
+ const SKIPPED = new Set(['node_modules', '.git', '.presmium', 'dist'])
15
+
16
+ const KINDS: ApprovalKind[] = ['operate', 'spend', 'write', 'agent']
17
+ const POLICIES = new Set<ApprovalPolicy>(['off', 'ask', 'allow'])
18
+
19
+ export interface WorkspaceConfig {
20
+ root: string
21
+ title: string
22
+ documents: string[]
23
+ single: boolean
24
+ site: Record<string, unknown>
25
+ approvals: Partial<Record<ApprovalKind, ApprovalPolicy>>
26
+ templates?: string
27
+ settings: OperatorSettings
28
+ }
29
+
30
+ function strings(value: unknown): string[] {
31
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => v.trim()) : typeof value === 'string' && value.trim() ? [value.trim()] : []
32
+ }
33
+
34
+ export function readWorkspace(root: string): WorkspaceConfig {
35
+ const dir = realpathSync(resolve(root))
36
+ const file = join(dir, WORKSPACE_CONFIG)
37
+ let data: Record<string, unknown> = {}
38
+ if (existsSync(file)) {
39
+ try {
40
+ const parsed: unknown = parseYaml(readFileSync(file, 'utf8'))
41
+ data = isRecord(parsed) ? parsed : {}
42
+ } catch (error) {
43
+ throw new Error(`${file}: ${errorMessage(error)}`)
44
+ }
45
+ }
46
+ const declared = strings(data.documents)
47
+ const single = declared.length === 0 && existsSync(join(dir, DECK_ENTRY))
48
+ const approvals = isRecord(data.approvals) ? data.approvals : {}
49
+ const settings = isRecord(data.settings) ? data.settings : {}
50
+ return {
51
+ root: dir,
52
+ title: typeof data.title === 'string' && data.title.trim() ? data.title.trim() : dir.split('/').at(-1) ?? 'Presmium',
53
+ documents: single ? [DECK_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
54
+ single,
55
+ site: isRecord(data.site) ? data.site : {},
56
+ approvals: Object.fromEntries(KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
57
+ ...typeof data.templates === 'string' && data.templates.trim() ? { templates: resolve(dir, data.templates.trim()) } : {},
58
+ settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, isRecord(value) ? value : {}])),
59
+ }
60
+ }
61
+
62
+ export function isWorkspaceRoot(dir: string): boolean {
63
+ if (!existsSync(join(dir, WORKSPACE_CONFIG))) return false
64
+ try {
65
+ return !readWorkspace(dir).single
66
+ } catch {
67
+ return true
68
+ }
69
+ }
70
+
71
+ function entries(config: WorkspaceConfig): string[] {
72
+ const found: string[] = []
73
+ for (const pattern of config.documents) {
74
+ const parts = patternSegments(pattern)
75
+ const walk = (dir: string, rel: string, i: number): string[] => {
76
+ const part = parts[i]!
77
+ const last = i === parts.length - 1
78
+ const names = typeof part === 'string'
79
+ ? [part]
80
+ : (() => {
81
+ try {
82
+ return readdirSync(dir).filter(name => !SKIPPED.has(name) && !name.startsWith('.') && part.test(name)).sort()
83
+ } catch {
84
+ return []
85
+ }
86
+ })()
87
+ return names.flatMap((name) => {
88
+ const path = join(dir, name)
89
+ const next = rel ? `${rel}/${name}` : name
90
+ if (last) return existsSync(path) && statSync(path).isFile() ? [next] : []
91
+ return existsSync(path) && statSync(path).isDirectory() ? walk(path, next, i + 1) : []
92
+ })
93
+ }
94
+ for (const entry of walk(config.root, '', 0)) if (!found.includes(entry)) found.push(entry)
95
+ }
96
+ return found
97
+ }
98
+
99
+ export interface FoundDocument extends WorkspaceDocument {
100
+ entry: string
101
+ }
102
+
103
+ function documentOf(config: WorkspaceConfig, entry: string): FoundDocument {
104
+ const dir = entry.split('/').slice(0, -1).join('/')
105
+ let headmatter: Record<string, unknown> = {}
106
+ try {
107
+ headmatter = readFrontmatter(readFileSync(join(config.root, entry), 'utf8'))
108
+ } catch {
109
+ }
110
+ const tags = strings(headmatter.tags)
111
+ return {
112
+ dir,
113
+ entry,
114
+ title: typeof headmatter.title === 'string' && headmatter.title.trim() ? headmatter.title.trim() : dir || config.title,
115
+ ...typeof headmatter.lang === 'string' && headmatter.lang.trim() ? { lang: headmatter.lang.trim() } : {},
116
+ ...tags.length > 0 ? { tags } : {},
117
+ }
118
+ }
119
+
120
+ export function listDocuments(config: WorkspaceConfig): FoundDocument[] {
121
+ return entries(config).map(entry => documentOf(config, entry))
122
+ }
123
+
124
+ export function namedByPatterns(config: WorkspaceConfig, dir: string): boolean {
125
+ return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DECK_ENTRY}` : DECK_ENTRY))
126
+ }
@@ -0,0 +1,185 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { Member } from '@lincyaw/kernel'
4
+ import type { DocumentOperation, LockedAnswer, OperationAnswer, WorkspaceDocument } from '../domain/api.ts'
5
+ import { invalidDocumentDir, within } from '../domain/paths.ts'
6
+ import type { CommitOrigin, RevisionOperation } from '../domain/revisions.ts'
7
+ import { listDocuments, namedByPatterns } from './config.ts'
8
+ import type { WorkspaceConfig } from './config.ts'
9
+ import { templatesDir } from './files.ts'
10
+ import type { DocumentWriter } from './files.ts'
11
+ import type { Git } from './git.ts'
12
+ import { lockedMessage } from './locks.ts'
13
+ import type { DocumentLocks } from './locks.ts'
14
+ import type { Revisions } from './revisions.ts'
15
+
16
+ export { BUILTIN_TEMPLATES } from './files.ts'
17
+ export const DEFAULT_TEMPLATE = 'default'
18
+
19
+ export class RequestError extends Error {
20
+ readonly status: number
21
+ readonly detail?: Record<string, unknown>
22
+ constructor(status: number, message: string, detail?: Record<string, unknown>) {
23
+ super(message)
24
+ this.status = status
25
+ this.detail = detail
26
+ }
27
+ }
28
+
29
+ export function lockedError(document: string, holder: string): RequestError {
30
+ return new RequestError(409, lockedMessage(document, holder), { locked: { document, holder } } satisfies Omit<LockedAnswer, 'error'>)
31
+ }
32
+
33
+ export function makeDocumentDir(root: string, dir: string): void {
34
+ const path = join(root, dir)
35
+ mkdirSync(dirname(path), { recursive: true })
36
+ mkdirSync(path)
37
+ }
38
+
39
+ export interface DocumentsChange {
40
+ removed: string[]
41
+ added: string[]
42
+ }
43
+
44
+ export interface DocumentsOptions {
45
+ config: WorkspaceConfig
46
+ git: Git
47
+ revisions: Revisions
48
+ writer: DocumentWriter
49
+ changed?: (change: DocumentsChange) => void | Promise<void>
50
+ locks?: DocumentLocks
51
+ }
52
+
53
+ function touched(operation: DocumentOperation): string[] {
54
+ return operation.op === 'create' || operation.op === 'delete' ? [operation.dir] : [operation.dir, operation.to]
55
+ }
56
+
57
+ export class Documents {
58
+ readonly config: WorkspaceConfig
59
+ readonly #git: Git
60
+ readonly #revisions: Revisions
61
+ readonly #writer: DocumentWriter
62
+ readonly #changed: (change: DocumentsChange) => void | Promise<void>
63
+ readonly #locks?: DocumentLocks
64
+
65
+ constructor(options: DocumentsOptions) {
66
+ this.config = options.config
67
+ this.#git = options.git
68
+ this.#revisions = options.revisions
69
+ this.#writer = options.writer
70
+ this.#changed = options.changed ?? (() => {})
71
+ this.#locks = options.locks
72
+ }
73
+
74
+ get writer(): DocumentWriter {
75
+ return this.#writer
76
+ }
77
+
78
+ get locks(): DocumentLocks | undefined {
79
+ return this.#locks
80
+ }
81
+
82
+ list(): WorkspaceDocument[] {
83
+ return listDocuments(this.config).map(({ entry: _entry, ...document }) => document)
84
+ }
85
+
86
+ get templatesDir(): string {
87
+ return templatesDir(this.config)
88
+ }
89
+
90
+ templates(): string[] {
91
+ try {
92
+ return readdirSync(this.templatesDir).filter(name => statSync(join(this.templatesDir, name)).isDirectory()).sort()
93
+ } catch {
94
+ return []
95
+ }
96
+ }
97
+
98
+ async apply(operation: DocumentOperation, member: Member): Promise<OperationAnswer> {
99
+ if (this.config.single) throw new RequestError(409, 'this workspace is a single deck: it has no document operations')
100
+ const claimed = this.#locks?.claimAll(touched(operation), `document:${operation.op}`)
101
+ if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
102
+ try {
103
+ const answer = await this.#git.exclusive(() => this.#apply(operation, member))
104
+ const added = operation.op === 'delete' ? [] : [answer.dir!]
105
+ const removed = operation.op === 'rename' || operation.op === 'delete' ? [operation.dir] : []
106
+ await this.#changed({ removed, added })
107
+ return answer
108
+ } finally {
109
+ claimed?.release()
110
+ }
111
+ }
112
+
113
+ async #fill(dir: string, fill: () => Promise<void>): Promise<void> {
114
+ makeDocumentDir(this.config.root, dir)
115
+ try {
116
+ await fill()
117
+ } catch (error) {
118
+ await this.#remove(dir).catch(() => undefined)
119
+ throw error
120
+ }
121
+ }
122
+
123
+ async #remove(dir: string): Promise<void> {
124
+ for (let attempt = 0; ; attempt++) {
125
+ await this.#writer.empty(dir)
126
+ try {
127
+ rmdirSync(join(this.config.root, dir))
128
+ return
129
+ } catch (error) {
130
+ if (attempt > 0 || (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY') throw new RequestError(409, `${dir} could not be removed: ${error instanceof Error ? error.message : String(error)}`)
131
+ }
132
+ }
133
+ }
134
+
135
+ async #apply(operation: DocumentOperation, member: Member): Promise<OperationAnswer> {
136
+ const origin = (done: RevisionOperation): CommitOrigin => ({ kind: 'document', member, operation: done })
137
+ const known = listDocuments(this.config).map(doc => doc.dir)
138
+ const existing = (dir: string) => {
139
+ if (!known.includes(dir)) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
140
+ return dir
141
+ }
142
+ const fresh = (dir: string) => {
143
+ const invalid = invalidDocumentDir(dir)
144
+ if (invalid) throw new RequestError(400, invalid)
145
+ if (!namedByPatterns(this.config, dir)) throw new RequestError(400, `"${dir}" is not where the workspace configuration's documents are (${this.config.documents.join(', ')})`)
146
+ const holder = known.find(doc => doc !== '' && (within(dir, doc) || within(doc, dir)))
147
+ if (holder !== undefined) throw new RequestError(409, `${dir} would hold or be inside the document ${holder}`)
148
+ if (existsSync(join(this.config.root, dir))) throw new RequestError(409, `${dir} exists already`)
149
+ return dir
150
+ }
151
+ switch (operation.op) {
152
+ case 'create': {
153
+ const dir = fresh(operation.dir)
154
+ const name = operation.template ?? (this.templates().includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : this.templates()[0])
155
+ if (!name || !this.templates().includes(name)) throw new RequestError(400, `no document template ${JSON.stringify(name ?? '')}`)
156
+ const title = operation.title?.trim() || dir.split('/').at(-1)!
157
+ await this.#fill(dir, () => this.#writer.template(dir, name, title))
158
+ const revision = await this.#revisions.commitNow([dir], `Create document ${dir}`, `Title: ${title}\nTemplate: ${name}`, origin({ op: 'create', dir }))
159
+ return { revision, dir }
160
+ }
161
+ case 'rename': {
162
+ const from = existing(operation.dir)
163
+ const to = fresh(operation.to)
164
+ mkdirSync(dirname(join(this.config.root, to)), { recursive: true })
165
+ renameSync(join(this.config.root, from), join(this.config.root, to))
166
+ const rewritten = (await this.#writer.relink(from, to)).filter(path => !within(path, to))
167
+ const revision = await this.#revisions.commitNow([from, to, ...rewritten], `Rename document ${from} to ${to}`, rewritten.length ? `Links rewritten in: ${rewritten.join(', ')}` : '', origin({ op: 'rename', dir: from, to }))
168
+ return { revision, dir: to }
169
+ }
170
+ case 'copy': {
171
+ const from = existing(operation.dir)
172
+ const to = fresh(operation.to)
173
+ await this.#fill(to, () => this.#writer.copy(from, to))
174
+ const revision = await this.#revisions.commitNow([to], `Copy document ${from} to ${to}`, '', origin({ op: 'copy', dir: from, to }))
175
+ return { revision, dir: to }
176
+ }
177
+ case 'delete': {
178
+ const dir = existing(operation.dir)
179
+ await this.#remove(dir)
180
+ const revision = await this.#revisions.commitNow([dir], `Delete document ${dir}`, '', origin({ op: 'delete', dir }))
181
+ return { revision }
182
+ }
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,252 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync, writeFileSync, writeSync } from 'node:fs'
3
+ import type { Stats } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import type { DocumentFileChange, DocumentFiles } from '@lincyaw/kernel'
7
+ import { rewriteLinks, within } from '../domain/paths.ts'
8
+ import { listDocuments, namedByPatterns } from './config.ts'
9
+ import type { WorkspaceConfig } from './config.ts'
10
+
11
+ export const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
12
+
13
+ const NOT_SOURCE = new Set(['node_modules', 'dist', '.git'])
14
+
15
+ export class FilesError extends Error {}
16
+
17
+ export interface DocumentWriter extends DocumentFiles {
18
+ template(dir: string, template: string, title: string): Promise<void>
19
+ copy(from: string, to: string): Promise<void>
20
+ relink(from: string, to: string): Promise<string[]>
21
+ empty(dir: string): Promise<void>
22
+ }
23
+
24
+ function partsOf(path: unknown): string[] {
25
+ if (typeof path !== 'string' || !path || path.includes('\0') || path.startsWith('/')) throw new FilesError(`not a path in the workspace: ${JSON.stringify(path)}`)
26
+ const parts = path.split('/')
27
+ if (parts.some(part => part === '' || part === '.' || part === '..')) throw new FilesError(`not a plain path: ${path}`)
28
+ if (parts.some(part => part.toLowerCase() === '.git')) throw new FilesError(`a path in a git directory: ${path}`)
29
+ return parts
30
+ }
31
+
32
+ function stat(path: string): Stats | undefined {
33
+ try {
34
+ return lstatSync(path)
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ function realDirectory(root: string, dir: string): boolean {
41
+ let current = root
42
+ for (const part of dir ? dir.split('/') : []) {
43
+ current = join(current, part)
44
+ if (!stat(current)?.isDirectory()) return false
45
+ }
46
+ return stat(current)?.isDirectory() ?? false
47
+ }
48
+
49
+ export function documentDirOf(config: WorkspaceConfig, path: string): string | undefined {
50
+ const parts = path.split('/')
51
+ for (let i = parts.length - 1; i >= 0; i--) {
52
+ const dir = parts.slice(0, i).join('/')
53
+ if (namedByPatterns(config, dir)) return dir
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ function documentDir(config: WorkspaceConfig, dir: string): string {
59
+ if (dir !== '') partsOf(dir)
60
+ if (!namedByPatterns(config, dir)) throw new FilesError(`${dir || '.'} is not a document directory of this workspace`)
61
+ if (!realDirectory(config.root, dir)) throw new FilesError(`the document directory ${dir || '.'} is not there`)
62
+ return dir
63
+ }
64
+
65
+ function madeParent(root: string, base: string, parts: string[]): string {
66
+ let current = join(root, base)
67
+ const start = base ? base.split('/').length : 0
68
+ for (const part of parts.slice(start, -1)) {
69
+ current = join(current, part)
70
+ const found = stat(current)
71
+ if (found?.isDirectory()) continue
72
+ if (found) rmSync(current, { force: true })
73
+ mkdirSync(current)
74
+ }
75
+ return current
76
+ }
77
+
78
+ function existingParent(root: string, base: string, parts: string[]): string | undefined {
79
+ let current = join(root, base)
80
+ const start = base ? base.split('/').length : 0
81
+ for (const part of parts.slice(start, -1)) {
82
+ current = join(current, part)
83
+ if (!stat(current)?.isDirectory()) return undefined
84
+ }
85
+ return current
86
+ }
87
+
88
+ function replace(parent: string, target: string, make: (temporary: string) => void): void {
89
+ const temporary = join(parent, `.presmium-write-${randomBytes(6).toString('hex')}`)
90
+ make(temporary)
91
+ try {
92
+ const found = stat(target)
93
+ if (found?.isDirectory()) rmSync(target, { recursive: true, force: true })
94
+ renameSync(temporary, target)
95
+ } catch (error) {
96
+ rmSync(temporary, { force: true })
97
+ throw error
98
+ }
99
+ }
100
+
101
+ function prune(root: string, base: string, from: string): void {
102
+ const top = join(root, base)
103
+ for (let dir = from; dir !== top && dir.startsWith(`${top}/`); dir = join(dir, '..')) {
104
+ try {
105
+ if (readdirSync(dir).length > 0) return
106
+ rmdirSync(dir)
107
+ } catch {
108
+ return
109
+ }
110
+ }
111
+ }
112
+
113
+ function apply(root: string, base: string, change: DocumentFileChange): void {
114
+ const parts = partsOf(change.path)
115
+ const name = parts.at(-1)!
116
+ if ('remove' in change) {
117
+ const parent = existingParent(root, base, parts)
118
+ if (!parent || !stat(join(parent, name))) return
119
+ rmSync(join(parent, name), { recursive: true, force: true })
120
+ prune(root, base, parent)
121
+ return
122
+ }
123
+ const parent = madeParent(root, base, parts)
124
+ const target = join(parent, name)
125
+ if ('link' in change) {
126
+ if (typeof change.link !== 'string' || !change.link || change.link.includes('\0')) throw new FilesError(`not a link target: ${change.path}`)
127
+ replace(parent, target, temporary => symlinkSync(change.link, temporary))
128
+ return
129
+ }
130
+ if (change.at !== undefined) {
131
+ writeAt(parent, target, change.content, change.at)
132
+ return
133
+ }
134
+ replace(parent, target, temporary => writeFileSync(temporary, change.content, { flag: 'wx', mode: change.executable ? 0o777 : 0o666 }))
135
+ }
136
+
137
+ function writeAt(parent: string, target: string, content: Uint8Array, at: number): void {
138
+ if (!Number.isSafeInteger(at) || at < 0) throw new FilesError(`not a position in a file: ${at}`)
139
+ if (!stat(target)?.isFile()) replace(parent, target, temporary => writeFileSync(temporary, '', { flag: 'wx', mode: 0o666 }))
140
+ const fd = openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW)
141
+ try {
142
+ const size = fstatSync(fd).size
143
+ if (at > size) throw new FilesError(`position ${at} is past the end of the file (${size} bytes)`)
144
+ writeSync(fd, content, 0, content.byteLength, at)
145
+ } finally {
146
+ closeSync(fd)
147
+ }
148
+ }
149
+
150
+ export function writeChanges(config: WorkspaceConfig, changes: DocumentFileChange[], outside = false): void {
151
+ const targets = changes.map((change) => {
152
+ const parts = partsOf(change.path)
153
+ const dir = documentDirOf(config, parts.join('/'))
154
+ if (dir === undefined && !outside) throw new FilesError(`${change.path} is in no document directory`)
155
+ if (dir !== undefined) documentDir(config, dir)
156
+ return { change, base: dir ?? '' }
157
+ })
158
+ for (const { change, base } of targets) if ('remove' in change) apply(config.root, base, change)
159
+ for (const { change, base } of targets) if (!('remove' in change)) apply(config.root, base, change)
160
+ }
161
+
162
+ const DECK_CONTENT = new Set(['freeze', 'drawings', 'assets', 'speech', 'speech.lock.json', 'anchors.lock.json'])
163
+
164
+ export function generatedPath(path: string): boolean {
165
+ const parts = path.split('/')
166
+ const at = parts.indexOf('.presmium')
167
+ return at >= 0 && !DECK_CONTENT.has(parts[at + 1] ?? '')
168
+ }
169
+
170
+ function sourceEntries(dir: string): { path: string, link?: string, executable?: boolean }[] {
171
+ const out: { path: string, link?: string, executable?: boolean }[] = []
172
+ const walk = (current: string, rel: string) => {
173
+ for (const name of readdirSync(current).sort()) {
174
+ if (NOT_SOURCE.has(name)) continue
175
+ if (rel === '.presmium' && !DECK_CONTENT.has(name)) continue
176
+ const path = join(current, name)
177
+ const entry = rel ? `${rel}/${name}` : name
178
+ const found = stat(path)
179
+ if (!found) continue
180
+ if (found.isSymbolicLink()) out.push({ path: entry, link: readlinkSync(path) })
181
+ else if (found.isDirectory()) walk(path, entry)
182
+ else if (found.isFile()) out.push({ path: entry, executable: (found.mode & 0o111) !== 0 })
183
+ }
184
+ }
185
+ if (stat(dir)?.isDirectory()) walk(dir, '')
186
+ return out
187
+ }
188
+
189
+ export function templatesDir(config: WorkspaceConfig): string {
190
+ return config.templates ?? BUILTIN_TEMPLATES
191
+ }
192
+
193
+ export function templateOf(config: WorkspaceConfig, name: string): string | undefined {
194
+ if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
195
+ const dir = join(templatesDir(config), name)
196
+ return existsSync(dir) && stat(dir)?.isDirectory() ? dir : undefined
197
+ }
198
+
199
+ export function fromTemplate(config: WorkspaceConfig, dir: string, template: string, title: string): void {
200
+ documentDir(config, dir)
201
+ const source = templateOf(config, template)
202
+ if (!source) throw new FilesError(`no document template ${JSON.stringify(template)}`)
203
+ const quoted = JSON.stringify(title).slice(1, -1)
204
+ writeChanges(config, sourceEntries(source).filter(entry => entry.link === undefined).map(({ path, executable }) => {
205
+ const content = readFileSync(join(source, path))
206
+ return { path: `${dir}/${path}`, content: path.endsWith('.md') ? Buffer.from(content.toString('utf8').replaceAll('{{title}}', () => quoted)) : content, ...executable ? { executable } : {} }
207
+ }))
208
+ }
209
+
210
+ export function copyDocument(config: WorkspaceConfig, from: string, to: string): void {
211
+ documentDir(config, from)
212
+ documentDir(config, to)
213
+ writeChanges(config, sourceEntries(join(config.root, from)).map(({ path, link, executable }): DocumentFileChange => {
214
+ if (link !== undefined) return { path: `${to}/${path}`, link }
215
+ const content = readFileSync(join(config.root, from, path))
216
+ if (path.endsWith('.md')) return { path: `${to}/${path}`, content: Buffer.from(rewriteLinks(content.toString('utf8'), `${to}/${path}`, `${from}/${path}`, from, to)) }
217
+ return { path: `${to}/${path}`, content, ...executable ? { executable } : {} }
218
+ }))
219
+ }
220
+
221
+ export function relinkDocuments(config: WorkspaceConfig, from: string, to: string): string[] {
222
+ const changes: DocumentFileChange[] = []
223
+ for (const doc of listDocuments(config)) {
224
+ if (!realDirectory(config.root, doc.dir)) continue
225
+ for (const entry of sourceEntries(join(config.root, doc.dir))) {
226
+ if (entry.link !== undefined || !entry.path.endsWith('.md')) continue
227
+ const path = doc.dir ? `${doc.dir}/${entry.path}` : entry.path
228
+ const was = within(path, to) ? `${from}${path.slice(to.length)}` : path
229
+ const text = readFileSync(join(config.root, path), 'utf8')
230
+ const next = rewriteLinks(text, path, was, from, to)
231
+ if (next !== text) changes.push({ path, content: Buffer.from(next) })
232
+ }
233
+ }
234
+ writeChanges(config, changes)
235
+ return changes.map(change => change.path)
236
+ }
237
+
238
+ export function emptyDocument(config: WorkspaceConfig, dir: string): void {
239
+ documentDir(config, dir)
240
+ const path = join(config.root, dir)
241
+ for (const name of readdirSync(path)) rmSync(join(path, name), { recursive: true, force: true })
242
+ }
243
+
244
+ export function localWriter(config: WorkspaceConfig): DocumentWriter {
245
+ return {
246
+ write: async changes => writeChanges(config, changes),
247
+ template: async (dir, template, title) => fromTemplate(config, dir, template, title),
248
+ copy: async (from, to) => copyDocument(config, from, to),
249
+ relink: async (from, to) => relinkDocuments(config, from, to),
250
+ empty: async dir => emptyDocument(config, dir),
251
+ }
252
+ }
@@ -0,0 +1,68 @@
1
+ import { execFile, execFileSync } from 'node:child_process'
2
+ import { realpathSync } from 'node:fs'
3
+ import { gitConfigArgs, gitEnvironment } from '@lincyaw/kernel'
4
+
5
+ export interface GitResult {
6
+ code: number
7
+ stdout: Buffer
8
+ stderr: string
9
+ }
10
+
11
+ export class Git {
12
+ readonly root: string
13
+ readonly top: string
14
+ #queue: Promise<unknown> = Promise.resolve()
15
+
16
+ constructor(root: string) {
17
+ this.root = root
18
+ this.top = realpathSync(execFileSync('git', [...gitConfigArgs(), 'rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', env: gitEnvironment(process.env) }).trim())
19
+ }
20
+
21
+ static isRepository(root: string): boolean {
22
+ try {
23
+ return execFileSync('git', [...gitConfigArgs(), 'rev-parse', '--is-inside-work-tree'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnvironment(process.env) }).trim() === 'true'
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ static ignores(dir: string, path: string): boolean {
30
+ try {
31
+ execFileSync('git', [...gitConfigArgs(), 'check-ignore', '-q', '--', path], { cwd: dir, stdio: 'ignore', env: gitEnvironment(process.env) })
32
+ return true
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ run(command: string[], env: Record<string, string> = {}): Promise<GitResult> {
39
+ return new Promise((done) => {
40
+ execFile('git', [...gitConfigArgs(), ...command], {
41
+ cwd: this.root,
42
+ encoding: 'buffer',
43
+ maxBuffer: 1 << 30,
44
+ env: { ...gitEnvironment(process.env), ...env },
45
+ }, (error, stdout, stderr) => {
46
+ const code = !error ? 0 : typeof error.code === 'number' ? error.code : 1
47
+ done({ code, stdout, stderr: stderr.toString('utf8') })
48
+ })
49
+ })
50
+ }
51
+
52
+ async raw(command: string[], env?: Record<string, string>): Promise<string> {
53
+ const result = await this.run(command, env)
54
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `git ${command[0]} exited with ${result.code}`)
55
+ return result.stdout.toString('utf8')
56
+ }
57
+
58
+ async attempt(command: string[], env?: Record<string, string>): Promise<{ ok: boolean, output: string }> {
59
+ const result = await this.run(command, env)
60
+ return { ok: result.code === 0, output: result.code === 0 ? result.stdout.toString('utf8') : `${result.stderr}${result.stdout.toString('utf8')}` }
61
+ }
62
+
63
+ exclusive<T>(work: () => Promise<T>): Promise<T> {
64
+ const next = this.#queue.then(work, work)
65
+ this.#queue = next.catch(() => undefined)
66
+ return next
67
+ }
68
+ }
@@ -0,0 +1,41 @@
1
+ import type { DocumentLockService } from '@lincyaw/kernel'
2
+ import { lockKind } from '../domain/api.ts'
3
+
4
+ export class DocumentLocks implements DocumentLockService {
5
+ readonly #held = new Map<string, string>()
6
+
7
+ claim(document: string, holder: string): (() => void) | undefined {
8
+ if (this.#held.has(document)) return undefined
9
+ this.#held.set(document, holder)
10
+ let released = false
11
+ return () => {
12
+ if (released) return
13
+ released = true
14
+ this.#held.delete(document)
15
+ }
16
+ }
17
+
18
+ holder(document: string): string | undefined {
19
+ return this.#held.get(document)
20
+ }
21
+
22
+ claimAll(documents: string[], holder: string): { release(): void } | { document: string, holder: string } {
23
+ const releases: (() => void)[] = []
24
+ for (const document of new Set(documents)) {
25
+ const release = this.claim(document, holder)
26
+ if (release) {
27
+ releases.push(release)
28
+ continue
29
+ }
30
+ for (const taken of releases) taken()
31
+ return { document, holder: this.holder(document)! }
32
+ }
33
+ return { release: () => releases.forEach(release => release()) }
34
+ }
35
+ }
36
+
37
+ export function lockedMessage(document: string, holder: string): string {
38
+ const kind = lockKind(holder)
39
+ const what = kind === 'agent' ? 'an agent\'s change task runs or waits for its review' : kind === 'revert' ? 'a change is being undone' : 'a document operation is under way'
40
+ return `${document || 'the document'} cannot change now: ${what} (${holder}); try again once it is done`
41
+ }