@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,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
|
+
}
|