@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,346 @@
1
+ import { lstatSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, relative, sep } from 'node:path'
4
+ import type { DocumentFileChange, Member, RevertResult, Revision, RevisionService } from '@lincyaw/kernel'
5
+ import type { HistoryRevision, UncommittedFile } from '../domain/api.ts'
6
+ import { leavesRoot } from '../domain/paths.ts'
7
+ import { commitMessage, memberAddress, originOf } from '../domain/revisions.ts'
8
+ import type { CommitOrigin } from '../domain/revisions.ts'
9
+ import type { WorkspaceConfig } from './config.ts'
10
+ import { makeDocumentDir, RequestError } from './documents.ts'
11
+ import { documentDirOf, generatedPath, writeChanges } from './files.ts'
12
+ import type { DocumentWriter } from './files.ts'
13
+ import type { Git } from './git.ts'
14
+
15
+ const FIELD = '\x00'
16
+ const RECORD = '\x1e'
17
+ const FORMAT = '--format=%x1e%H%x00%an <%ae>%x00%aI%x00%B%x00'
18
+
19
+ interface LogEntry {
20
+ id: string
21
+ author: string
22
+ date: string
23
+ message: string
24
+ files: string[]
25
+ }
26
+
27
+ function parseLog(output: string): LogEntry[] {
28
+ return output.split(RECORD).filter(Boolean).map((record) => {
29
+ const [id = '', author = '', date = '', message = '', rest = ''] = record.split(FIELD)
30
+ return { id, author, date, message: message.trim(), files: rest.split('\n').map(line => line.trim()).filter(Boolean) }
31
+ })
32
+ }
33
+
34
+ function revertedOf(message: string): string | undefined {
35
+ return originOf(message)?.revert ?? /This reverts commit ([0-9a-f]{7,64})/.exec(message)?.[1]
36
+ }
37
+
38
+ function redoes(entries: LogEntry[]): (entry: LogEntry) => boolean {
39
+ const find = (id: string) => entries.find(entry => entry.id === id || (id.length >= 7 && entry.id.startsWith(id)))
40
+ const known = new Map<string, boolean>()
41
+ const redo = (entry: LogEntry): boolean => {
42
+ let found = known.get(entry.id)
43
+ if (found !== undefined) return found
44
+ known.set(entry.id, false)
45
+ const reverted = revertedOf(entry.message)
46
+ const target = reverted === undefined ? undefined : find(reverted)
47
+ found = target !== undefined && revertedOf(target.message) !== undefined && !redo(target)
48
+ known.set(entry.id, found)
49
+ return found
50
+ }
51
+ return redo
52
+ }
53
+
54
+ function revision(entry: LogEntry, revertedBy?: string, redo = false): HistoryRevision | undefined {
55
+ const origin = originOf(entry.message)
56
+ if (!origin) return undefined
57
+ const { revert, coAuthor: _coAuthor, operation, ...plain } = origin
58
+ return {
59
+ id: entry.id,
60
+ message: entry.message,
61
+ author: entry.author,
62
+ date: entry.date,
63
+ origin: plain,
64
+ files: entry.files,
65
+ ...revertedBy ? { revertedBy } : {},
66
+ ...operation ? { operation } : {},
67
+ ...revert ? { reverts: revert } : {},
68
+ ...revert && redo ? { redo: true as const } : {},
69
+ }
70
+ }
71
+
72
+ interface TreeFile {
73
+ path: string
74
+ mode: string
75
+ oid: string
76
+ }
77
+
78
+ export interface RevisionFiles {
79
+ config: WorkspaceConfig
80
+ writer: DocumentWriter
81
+ }
82
+
83
+ export class Revisions implements RevisionService {
84
+ readonly #git: Git
85
+ readonly #files: RevisionFiles
86
+
87
+ constructor(git: Git, files: RevisionFiles) {
88
+ this.#git = git
89
+ this.#files = files
90
+ }
91
+
92
+ commit(change: { files: string[], message: string, origin: CommitOrigin, trailers?: [string, string][] }): Promise<Revision | undefined> {
93
+ const [summary = '', ...body] = change.message.trim().split('\n')
94
+ return this.#git.exclusive(() => this.commitNow(change.files, summary, body.join('\n'), change.origin, undefined, change.trailers))
95
+ }
96
+
97
+ async commitNow(paths: string[], summary: string, body: string, origin: CommitOrigin, revert?: string, more: [string, string][] = []): Promise<Revision | undefined> {
98
+ const unique = [...new Set(paths.map(path => path.replace(/^\.?\/+|\/+$/g, '')).filter(path => path && !leavesRoot(path)))]
99
+ if (unique.length === 0) return undefined
100
+ for (const path of unique) await this.#git.attempt(['add', '-A', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
101
+ const staged = (await this.#git.raw(['diff', '--cached', '--name-only', '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })).trim()
102
+ if (!staged) return undefined
103
+ const message = withTrailers(commitMessage(summary, body, origin, revert), more)
104
+ await this.#git.raw([...await this.#identity(), 'commit', '-q', '--no-verify', '-m', message, ...this.#author(origin.member), '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })
105
+ return this.#revision('HEAD')
106
+ }
107
+
108
+ async status(paths: string[] = ['']): Promise<{ branch?: string, uncommitted: number }> {
109
+ const branch = await this.#git.attempt(['symbolic-ref', '--quiet', '--short', 'HEAD'])
110
+ return { ...branch.ok ? { branch: branch.output.trim() } : {}, uncommitted: (await this.#statusOf(paths)).length }
111
+ }
112
+
113
+ async uncommittedFiles(paths: string[]): Promise<UncommittedFile[]> {
114
+ const specs = paths.map(path => path || '.')
115
+ const status = await this.#statusOf(paths)
116
+ const counts = new Map<string, [number, number]>()
117
+ const numstat = await this.#git.attempt(['diff', '--numstat', '-z', '--no-renames', 'HEAD', '--', ...specs], { GIT_LITERAL_PATHSPECS: '1' })
118
+ if (numstat.ok) {
119
+ for (const entry of numstat.output.split('\0').filter(Boolean)) {
120
+ const [added = '-', removed = '-', path = ''] = entry.split('\t')
121
+ if (added !== '-') counts.set(this.#fromTop(path), [Number(added), Number(removed)])
122
+ }
123
+ }
124
+ return status.map(({ code, path }): UncommittedFile => {
125
+ const kind = code.includes('D') ? 'deleted' : code === '??' || code[0] === 'A' ? 'added' : 'modified'
126
+ const counted = counts.get(path) ?? (kind === 'added' ? lineCount(join(this.#files.config.root, path)) : undefined)
127
+ return { path, kind, ...counted ? { added: counted[0], removed: counted[1] } : {} }
128
+ }).sort((a, b) => a.path.localeCompare(b.path))
129
+ }
130
+
131
+ discard(paths: string[]): Promise<void> {
132
+ return this.#git.exclusive(async () => {
133
+ const files: TreeFile[] = []
134
+ for (const path of paths) {
135
+ const listed = await this.#git.attempt(['ls-tree', '-z', 'HEAD', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
136
+ const [meta = '', named] = (listed.ok ? listed.output.split('\0')[0] ?? '' : '').split('\t')
137
+ const [mode = '', , oid = ''] = meta.split(' ')
138
+ files.push(named && mode ? { path, mode, oid } : { path, mode: '000000', oid: '' })
139
+ }
140
+ await this.#write(files)
141
+ })
142
+ }
143
+
144
+ async history(filter: { document?: string, task?: string, limit?: number } = {}): Promise<HistoryRevision[]> {
145
+ const log = await this.#git.attempt(['log', FORMAT, '--name-only', '--no-renames', ...filter.document ? ['--', filter.document] : []], { GIT_LITERAL_PATHSPECS: '1' })
146
+ if (!log.ok) return []
147
+ const entries = parseLog(log.output)
148
+ const revertedBy = new Map<string, string>()
149
+ for (const entry of entries) {
150
+ const reverted = revertedOf(entry.message)
151
+ if (reverted && !revertedBy.has(reverted)) revertedBy.set(reverted, entry.id)
152
+ }
153
+ const redo = redoes(entries)
154
+ const revisions = entries.flatMap((entry) => {
155
+ const found = revision(entry, revertedBy.get(entry.id), redo(entry))
156
+ return found && (!filter.task || found.origin.task === filter.task) ? [found] : []
157
+ })
158
+ return filter.limit ? revisions.slice(0, filter.limit) : revisions
159
+ }
160
+
161
+ revert(id: string, member: Member): Promise<RevertResult> {
162
+ return this.#git.exclusive(async () => {
163
+ const verified = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
164
+ if (!verified.ok) throw new Error(`no commit ${id}`)
165
+ const target = (await this.#log(verified.output.trim()))!
166
+ const parent = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${target.id}^1`])
167
+ if (!parent.ok) throw new Error(`commit ${target.id} has no parent to go back to`)
168
+ const merged = await this.#git.run(['merge-tree', '--write-tree', '--no-messages', '--name-only', `--merge-base=${target.id}`, 'HEAD', parent.output.trim()])
169
+ const lines = merged.stdout.toString('utf8').split('\n').map(line => line.trim()).filter(Boolean)
170
+ if (merged.code === 1) return { ok: false, conflicts: [...new Set(lines.slice(1).map(this.#fromTop))] }
171
+ if (merged.code !== 0 || !lines[0]) throw new Error(`git merge-tree failed: ${merged.stderr.trim()}`)
172
+ const changes = await this.#changes('HEAD', lines[0])
173
+ if (changes.length === 0) throw new Error(`commit ${target.id} is undone already`)
174
+ const outside = changes.filter(change => documentDirOf(this.#files.config, change.path) === undefined).map(change => change.path)
175
+ if (outside.length > 0 && !member.roles.includes('owner')) throw new RequestError(403, `only an owner reverts a change of ${outside.join(', ')}, outside the documents`)
176
+ const blocked = await this.#uncommitted(changes.map(change => change.path))
177
+ if (blocked.length > 0) return { ok: false, conflicts: blocked }
178
+ await this.#write(changes)
179
+ const subject = target.message.split('\n')[0] ?? target.id
180
+ const undone = originOf(target.message)
181
+ const origin: CommitOrigin = { kind: undone?.kind ?? 'editor', member, ...undone?.operation ? { operation: undone.operation } : {} }
182
+ const done = await this.commitNow(changes.map(change => change.path), `Revert "${subject}"`, `This reverts commit ${target.id}.`, origin, target.id)
183
+ if (!done) throw new Error(`the revert of ${target.id} committed nothing`)
184
+ return { ok: true, revision: done }
185
+ })
186
+ }
187
+
188
+ async messageOf(id: string): Promise<string | undefined> {
189
+ const shown = await this.#git.attempt(['show', '-s', '--format=%B', `${id}^{commit}`])
190
+ return shown.ok ? shown.output : undefined
191
+ }
192
+
193
+ async filesOf(id: string): Promise<string[]> {
194
+ const shown = await this.#git.attempt(['show', '--no-renames', '--name-only', '-z', '--format=', `${id}^{commit}`])
195
+ return shown.ok ? shown.output.split('\0').filter(Boolean).map(this.#fromTop) : []
196
+ }
197
+
198
+ async documentsOf(id: string): Promise<string[]> {
199
+ const verified = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
200
+ if (!verified.ok) return []
201
+ const files = (await this.#git.raw(['show', '--no-renames', '--name-only', '-z', '--format=', verified.output.trim()])).split('\0').filter(Boolean).map(this.#fromTop)
202
+ return [...new Set(files.flatMap(file => documentDirOf(this.#files.config, file) ?? []))]
203
+ }
204
+
205
+ async #statusOf(paths: string[]): Promise<{ code: string, path: string }[]> {
206
+ const status = await this.#git.attempt(['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all', '--', ...paths.map(path => path || '.')], { GIT_LITERAL_PATHSPECS: '1' })
207
+ if (!status.ok) return []
208
+ return status.output.split('\0').filter(Boolean)
209
+ .map(entry => ({ code: entry.slice(0, 2), path: this.#fromTop(entry.slice(3)) }))
210
+ .filter(({ path }) => !generatedPath(path))
211
+ }
212
+
213
+ #fromTop = (path: string): string => relative(this.#files.config.root, join(this.#git.top, path)).split(sep).join('/')
214
+
215
+ async #changes(from: string, to: string): Promise<TreeFile[]> {
216
+ const fields = (await this.#git.raw(['diff-tree', '-r', '-z', '--no-renames', from, to])).split('\0').filter(Boolean)
217
+ const out: TreeFile[] = []
218
+ for (let i = 0; i + 1 < fields.length; i += 2) {
219
+ const [, mode = '', , oid = ''] = fields[i]!.slice(1).split(' ')
220
+ const path = this.#fromTop(fields[i + 1]!)
221
+ if (leavesRoot(path)) throw new Error(`the revert changes ${fields[i + 1]}, outside the workspace`)
222
+ out.push({ path, mode, oid })
223
+ }
224
+ return out
225
+ }
226
+
227
+ async #uncommitted(paths: string[]): Promise<string[]> {
228
+ const work = mkdtempSync(join(tmpdir(), 'presmium-revert-'))
229
+ const env = { GIT_INDEX_FILE: join(work, 'index'), GIT_LITERAL_PATHSPECS: '1' }
230
+ try {
231
+ await this.#git.raw(['read-tree', 'HEAD'], env)
232
+ const blocked = new Set<string>()
233
+ for (const path of paths) {
234
+ const tracked = (await this.#git.attempt(['ls-files', '--', path], env)).output.trim() !== ''
235
+ let present = true
236
+ try {
237
+ lstatSync(join(this.#files.config.root, path))
238
+ } catch {
239
+ present = false
240
+ }
241
+ if (!tracked && !present) continue
242
+ if (!(await this.#git.attempt(['add', '-A', '--', path], env)).ok) blocked.add(path)
243
+ }
244
+ const differ = (await this.#git.raw(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...paths], env)).split('\0').filter(Boolean).map(this.#fromTop)
245
+ for (const path of differ) blocked.add(path)
246
+ return [...blocked].sort()
247
+ } finally {
248
+ rmSync(work, { recursive: true, force: true })
249
+ }
250
+ }
251
+
252
+ async #write(files: TreeFile[]): Promise<void> {
253
+ const { config, writer } = this.#files
254
+ const changes: { change: DocumentFileChange, dir?: string }[] = []
255
+ for (const file of files) {
256
+ if (file.mode === '160000') throw new Error(`${file.path} is a submodule: not reverted`)
257
+ const dir = documentDirOf(config, file.path)
258
+ if (/^0+$/.test(file.mode)) {
259
+ changes.push({ change: { path: file.path, remove: true }, dir })
260
+ continue
261
+ }
262
+ const blob = (await this.#git.run(['cat-file', 'blob', file.oid])).stdout
263
+ const change: DocumentFileChange = file.mode === '120000' ? { path: file.path, link: blob.toString('utf8') } : { path: file.path, content: blob, ...file.mode === '100755' ? { executable: true } : {} }
264
+ changes.push({ change, dir })
265
+ }
266
+ const inDocuments = changes.filter(({ dir }) => dir !== undefined)
267
+ for (const dir of new Set(inDocuments.filter(({ change }) => !('remove' in change)).map(({ dir }) => dir!))) {
268
+ if (!exists(join(config.root, dir))) makeDocumentDir(config.root, dir)
269
+ }
270
+ if (inDocuments.length > 0) await writer.write(inDocuments.map(({ change }) => change))
271
+ const outside = changes.filter(({ dir }) => dir === undefined).map(({ change }) => change)
272
+ for (const { path } of outside) {
273
+ const open = writableByOthers(config.root, path)
274
+ if (open) throw new Error(`the revert would write ${path}, in ${open}, which more than serve may write`)
275
+ }
276
+ if (outside.length > 0) writeChanges(config, outside, true)
277
+ for (const dir of new Set(inDocuments.map(({ dir }) => dir!))) {
278
+ if (!dir) continue
279
+ try {
280
+ if (readdirSync(join(config.root, dir)).length === 0) rmdirSync(join(config.root, dir))
281
+ } catch {
282
+ }
283
+ }
284
+ }
285
+
286
+ async #log(id: string): Promise<LogEntry | undefined> {
287
+ return parseLog(await this.#git.raw(['show', '--no-renames', '--name-only', FORMAT, id]))[0]
288
+ }
289
+
290
+ async #revision(id: string): Promise<Revision | undefined> {
291
+ const entry = await this.#log(id)
292
+ return entry && revision(entry)
293
+ }
294
+
295
+ #author(member: Member | undefined): string[] {
296
+ return member ? [`--author=${memberAddress(member)}`] : []
297
+ }
298
+
299
+ async #identity(): Promise<string[]> {
300
+ const email = await this.#git.attempt(['config', 'user.email'])
301
+ return email.ok && email.output.trim() ? [] : ['-c', 'user.name=Presmium', '-c', 'user.email=presmium@presmium.invalid']
302
+ }
303
+ }
304
+
305
+ function withTrailers(message: string, more: [string, string][]): string {
306
+ if (more.length === 0) return message
307
+ const lines = message.trimEnd().split('\n')
308
+ const at = lines.at(-1)?.startsWith('Co-authored-by: ') ? lines.length - 1 : lines.length
309
+ lines.splice(at, 0, ...more.map(([key, value]) => `${key}: ${value.replace(/\s+/g, ' ').trim()}`))
310
+ return `${lines.join('\n')}\n`
311
+ }
312
+
313
+ function writableByOthers(root: string, path: string): string | undefined {
314
+ const parts = path.split('/').slice(0, -1)
315
+ for (let i = 0; i <= parts.length; i++) {
316
+ const dir = parts.slice(0, i).join('/')
317
+ let mode: number
318
+ try {
319
+ ({ mode } = lstatSync(join(root, dir)))
320
+ } catch {
321
+ return undefined
322
+ }
323
+ if ((mode & 0o002) !== 0) return dir || '.'
324
+ }
325
+ return undefined
326
+ }
327
+
328
+ function lineCount(path: string): [number, number] | undefined {
329
+ try {
330
+ const bytes = readFileSync(path)
331
+ if (bytes.includes(0)) return undefined
332
+ const text = bytes.toString('utf8')
333
+ return [text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0), 0]
334
+ } catch {
335
+ return undefined
336
+ }
337
+ }
338
+
339
+ function exists(path: string): boolean {
340
+ try {
341
+ lstatSync(path)
342
+ return true
343
+ } catch {
344
+ return false
345
+ }
346
+ }
@@ -0,0 +1,239 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { createRequire } from 'node:module'
3
+ import { eventStream, errorMessage, isRecord, json, problem } from '@lincyaw/kernel'
4
+ import type { Member, PresmiumNodePlugin, RequestContext, Route, ServerContribution } from '@lincyaw/kernel'
5
+ import { bearer } from '@lincyaw/server'
6
+ import type { ChangesAnswer, CommitAnswer, DocumentOperation, DocumentsAnswer, RevertAnswer, RevisionsAnswer, SavedAnswer } from '../domain/api.ts'
7
+ import { WORKSPACE_ID } from '../domain/api.ts'
8
+ import { trailers } from '../domain/revisions.ts'
9
+ import { lockedError, RequestError } from './documents.ts'
10
+ import type { Documents, DocumentsChange } from './documents.ts'
11
+ import { documentDirOf } from './files.ts'
12
+ import type { DocumentLocks } from './locks.ts'
13
+ import type { Revisions } from './revisions.ts'
14
+ import { reviewRoutes } from './review/routes.ts'
15
+ import type { Reviews } from './review/reviews.ts'
16
+ import type { WorkspaceService } from './service.ts'
17
+ import { MAX_SOURCE, SourceFiles } from './source.ts'
18
+
19
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
20
+
21
+ export interface WorkspaceRoutes {
22
+ documents: Documents
23
+ revisions?: Revisions
24
+ locks?: DocumentLocks
25
+ documentsChanged?(change: DocumentsChange): Promise<void> | void
26
+ document?: string
27
+ service?: WorkspaceService
28
+ token?: string
29
+ reviews?: Reviews
30
+ }
31
+
32
+ function authorized(request: Request, token: string): boolean {
33
+ const given = Buffer.from(bearer(request.headers.get('authorization')) ?? '')
34
+ const own = Buffer.from(token)
35
+ return given.length === own.length && timingSafeEqual(given, own)
36
+ }
37
+
38
+ function rendererIds(body: unknown): string[] | undefined {
39
+ if (!isRecord(body) || !Array.isArray(body.to)) return undefined
40
+ const ids = body.to.filter((id): id is string => typeof id === 'string' && /^[\w.-]+$/.test(id))
41
+ return ids.length > 0 ? ids : undefined
42
+ }
43
+
44
+ const NO_GIT = 'the workspace is not a git repository: run git init in it to use the document operations and the history'
45
+
46
+ function operationOf(body: unknown): DocumentOperation | undefined {
47
+ if (!isRecord(body) || typeof body.dir !== 'string') return undefined
48
+ const text = (value: unknown) => (typeof value === 'string' ? value.trim() : undefined)
49
+ const dir = body.dir.trim().replace(/^\/+|\/+$/g, '')
50
+ switch (body.op) {
51
+ case 'create': return { op: 'create', dir, ...text(body.title) ? { title: text(body.title) } : {}, ...text(body.template) ? { template: text(body.template) } : {} }
52
+ case 'rename':
53
+ case 'copy': return typeof body.to === 'string' ? { op: body.op, dir, to: body.to.trim().replace(/^\/+|\/+$/g, '') } : undefined
54
+ case 'delete': return { op: 'delete', dir }
55
+ default: return undefined
56
+ }
57
+ }
58
+
59
+ async function answer(work: () => Promise<Response>): Promise<Response> {
60
+ try {
61
+ return await work()
62
+ } catch (error) {
63
+ if (error instanceof RequestError) return json({ error: error.message, ...error.detail }, error.status)
64
+ return problem(500, errorMessage(error))
65
+ }
66
+ }
67
+
68
+ function difference(before: string[], after: string[]): DocumentsChange {
69
+ return { removed: before.filter(dir => !after.includes(dir)), added: after.filter(dir => !before.includes(dir)) }
70
+ }
71
+
72
+ function chosenPaths(body: unknown, uncommitted: string[]): string[] {
73
+ const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
74
+ return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
75
+ }
76
+
77
+ export function workspaceContribution(options: WorkspaceRoutes): ServerContribution {
78
+ const { documents, revisions, locks } = options
79
+ const listed = () => documents.list().map(doc => doc.dir)
80
+ const source = new SourceFiles({ config: documents.config, writer: documents.writer, ...locks ? { locks } : {} })
81
+ const scopeOf = (ctx: RequestContext) => source.scope(options.document ?? ctx.document)
82
+ const uncommitted = async (ctx: RequestContext) => (revisions ? await revisions.uncommittedFiles(source.roots(scopeOf(ctx))) : [])
83
+ const onChanges = async (request: Request, ctx: RequestContext, holder: string, work: (paths: string[], member: Member, body: Record<string, unknown>) => Promise<Response>) => {
84
+ if (request.method !== 'POST') return problem(405, 'POST')
85
+ if (!revisions) return problem(409, NO_GIT)
86
+ const body: unknown = await request.json().catch(() => ({}))
87
+ const paths = chosenPaths(body, (await uncommitted(ctx)).map(file => file.path))
88
+ if (paths.length === 0) return problem(409, 'no changes to take: every file is as committed')
89
+ const claimed = locks?.claimAll(paths.flatMap(path => documentDirOf(documents.config, path) ?? []), holder)
90
+ if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
91
+ try {
92
+ return await work(paths, ctx.member, isRecord(body) ? body : {})
93
+ } finally {
94
+ claimed?.release()
95
+ }
96
+ }
97
+ const routes: Record<string, Route> = {
98
+ documents: {
99
+ role: 'reader',
100
+ handle: (request, ctx) => answer(async () => {
101
+ if (request.method === 'GET') {
102
+ const status = revisions ? await revisions.status(source.roots(scopeOf(ctx))) : {}
103
+ return json({ title: documents.config.title, documents: documents.list(), templates: documents.templates(), git: Boolean(revisions), ...status, single: documents.config.single || options.document !== undefined } satisfies DocumentsAnswer)
104
+ }
105
+ if (request.method !== 'POST') return problem(405, 'GET or POST')
106
+ if (options.document !== undefined) return problem(409, 'presmium dev serves one deck: the document operations are the workspace service\'s (presmium serve)')
107
+ if (!ctx.member.roles.some(role => role === 'editor' || role === 'owner')) return problem(403, 'document operations need the role editor')
108
+ if (!revisions) return problem(409, NO_GIT)
109
+ const operation = operationOf(await request.json().catch(() => undefined))
110
+ if (!operation) return problem(400, '{ op: create | rename | copy | delete, dir, to?, title?, template? }')
111
+ return json(await documents.apply(operation, ctx.member))
112
+ }),
113
+ },
114
+ revisions: {
115
+ role: 'editor',
116
+ handle: (request, ctx) => answer(async () => {
117
+ if (request.method !== 'GET') return problem(405, 'GET')
118
+ if (!revisions) return json({ revisions: [] } satisfies RevisionsAnswer)
119
+ const params = new URL(request.url).searchParams
120
+ const limit = Number(params.get('limit'))
121
+ const document = params.get('document') ?? ((options.document ?? ctx.document) || (documents.config.single ? '.' : undefined))
122
+ return json({ revisions: await revisions.history({ ...document ? { document } : {}, ...params.get('task') ? { task: params.get('task')! } : {}, ...Number.isInteger(limit) && limit > 0 ? { limit } : {} }) } satisfies RevisionsAnswer)
123
+ }),
124
+ },
125
+ 'revisions/revert': {
126
+ role: 'editor',
127
+ handle: (request, ctx) => answer(async () => {
128
+ if (request.method !== 'POST') return problem(405, 'POST')
129
+ if (!revisions) return problem(409, NO_GIT)
130
+ const body: unknown = await request.json().catch(() => undefined)
131
+ if (!isRecord(body) || typeof body.id !== 'string' || !/^[0-9a-f]{4,64}$/i.test(body.id)) return problem(400, '{ id: <commit> }')
132
+ const claimed = locks?.claimAll(await revisions.documentsOf(body.id), 'revision:revert')
133
+ if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
134
+ try {
135
+ const before = listed()
136
+ const result = await revisions.revert(body.id, ctx.member)
137
+ if (result.ok) {
138
+ await options.documentsChanged?.(difference(before, listed()))
139
+ const undone = trailers(result.revision.message)['Presmium-Revert']?.[0] ?? body.id
140
+ void options.reviews?.reverted(undone, result.revision.id).catch(() => undefined)
141
+ }
142
+ return json(result satisfies RevertAnswer, result.ok ? 200 : 409)
143
+ } finally {
144
+ claimed?.release()
145
+ }
146
+ }),
147
+ },
148
+ 'files': {
149
+ role: 'editor',
150
+ handle: (request, ctx) => answer(async () => {
151
+ if (request.method !== 'GET') return problem(405, 'GET')
152
+ return json(source.files(scopeOf(ctx), ctx.member))
153
+ }),
154
+ },
155
+ 'source': {
156
+ role: 'editor',
157
+ maxBody: MAX_SOURCE * 3,
158
+ handle: (request, ctx) => answer(async () => {
159
+ const scope = scopeOf(ctx)
160
+ if (request.method === 'GET') return json(source.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
161
+ if (request.method !== 'POST') return problem(405, 'GET or POST')
162
+ const body: unknown = await request.json().catch(() => undefined)
163
+ if (!isRecord(body) || typeof body.path !== 'string' || typeof body.content !== 'string' || typeof body.base !== 'string') return problem(400, '{ path, content, base: <the version opened> }')
164
+ return json({ version: await source.save(scope, body.path, body.content, body.base, ctx.member) } satisfies SavedAnswer)
165
+ }),
166
+ },
167
+ 'changes': {
168
+ role: 'editor',
169
+ handle: (request, ctx) => answer(async () => {
170
+ if (request.method !== 'GET') return problem(405, 'GET')
171
+ return json({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
172
+ }),
173
+ },
174
+ 'changes/commit': {
175
+ role: 'editor',
176
+ handle: (request, ctx) => answer(() => onChanges(request, ctx, 'editor:commit', async (paths, member, body) => {
177
+ const message = typeof body.message === 'string' ? body.message.trim().slice(0, 4000) : ''
178
+ if (!message) return problem(400, '{ message: what the change is, paths? }')
179
+ const revision = await revisions!.commit({ files: paths, message, origin: { kind: 'editor', member } })
180
+ return json({ ...revision ? { revision } : {}, paths } satisfies CommitAnswer)
181
+ })),
182
+ },
183
+ ...options.service ? serviceRoutes(options.service) : {},
184
+ ...options.reviews ? reviewRoutes(options.reviews, answer) : {},
185
+ 'changes/discard': {
186
+ role: 'editor',
187
+ handle: (request, ctx) => answer(() => onChanges(request, ctx, 'editor:discard', async (paths) => {
188
+ const before = listed()
189
+ await revisions!.discard(paths)
190
+ await options.documentsChanged?.(difference(before, listed()))
191
+ return json({ paths } satisfies CommitAnswer)
192
+ })),
193
+ },
194
+ }
195
+ const { token } = options
196
+ if (token) {
197
+ for (const [key, route] of Object.entries(routes)) {
198
+ routes[key] = { ...route, handle: (request, ctx) => (authorized(request, token) ? route.handle(request, ctx) : Promise.resolve(problem(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>'))) }
199
+ }
200
+ }
201
+ return { routes, ...options.service ? { proxies: options.service.proxies } : {} }
202
+ }
203
+
204
+ function serviceRoutes(service: WorkspaceService): Record<string, Route> {
205
+ return {
206
+ 'ir': {
207
+ role: 'editor',
208
+ handle: (request, ctx) => answer(async () => (request.method === 'GET' ? json(await service.ir(ctx.document)) : problem(405, 'GET'))),
209
+ },
210
+ 'ir/events': {
211
+ role: 'editor',
212
+ handle: async (request, ctx) => (request.method === 'GET' ? eventStream(service.events(ctx.document, request.signal), request.signal) : problem(405, 'GET')),
213
+ },
214
+ 'preview': {
215
+ role: 'editor',
216
+ handle: (request, ctx) => answer(async () => (request.method === 'GET' ? json(await service.preview(ctx.document)) : problem(405, 'GET'))),
217
+ },
218
+ 'build': {
219
+ role: 'editor',
220
+ handle: (request, ctx) => answer(async () => {
221
+ if (request.method !== 'POST') return problem(405, 'POST')
222
+ return json(await service.build(ctx.document, rendererIds(await request.json().catch(() => undefined))))
223
+ }),
224
+ },
225
+ 'check': {
226
+ role: 'editor',
227
+ handle: (request, ctx) => answer(async () => (request.method === 'POST' ? json(await service.check(ctx.document)) : problem(405, 'POST'))),
228
+ },
229
+ }
230
+ }
231
+
232
+ export function workspacePlugin(options: WorkspaceRoutes): PresmiumNodePlugin {
233
+ return {
234
+ id: WORKSPACE_ID,
235
+ version,
236
+ kernel: '^1.0.0',
237
+ server: () => workspaceContribution(options),
238
+ }
239
+ }