@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.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +36 -3
- package/src/domain/api.ts +102 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/paths.ts +98 -0
- package/src/domain/revisions.ts +80 -0
- package/src/index.ts +6 -0
- package/src/node/build.ts +42 -0
- package/src/node/config.ts +126 -0
- package/src/node/documents.ts +185 -0
- package/src/node/files.ts +252 -0
- package/src/node/git.ts +68 -0
- package/src/node/locks.ts +41 -0
- package/src/node/review/git-guard.ts +103 -0
- package/src/node/review/git-review.ts +416 -0
- package/src/node/review/review-record.ts +73 -0
- package/src/node/review/reviews.ts +422 -0
- package/src/node/review/routes.ts +72 -0
- package/src/node/revisions.ts +346 -0
- package/src/node/routes.ts +239 -0
- package/src/node/serve.ts +302 -0
- package/src/node/service.ts +237 -0
- package/src/node/session.ts +123 -0
- package/src/node/site.ts +121 -0
- package/src/node/source.ts +148 -0
- package/templates/document/default/lecture.md +18 -0
|
@@ -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
|
+
}
|
package/src/node/git.ts
ADDED
|
@@ -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
|
+
}
|