@pulsarinteractive/geniustasker 0.1.0-beta.4 → 0.1.0-beta.6
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/CHANGELOG.md +20 -0
- package/README.md +10 -9
- package/docs/ARCHITECTURE.md +18 -0
- package/docs/CLI.md +30 -5
- package/docs/DOCUMENTATION_SYNC.md +144 -0
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/LIMITS.md +15 -2
- package/docs/PROJECT_FILTERS.md +77 -0
- package/examples/codex.toml +1 -1
- package/examples/mcp.json +1 -1
- package/mcp.json +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/geniustasker/SKILL.md +16 -0
- package/skills/geniustasker-documentation/SKILL.md +39 -0
- package/skills/geniustasker-documentation/agents/openai.yaml +4 -0
- package/skills/geniustasker-documentation/references/structure.md +29 -0
- package/skills/geniustasker-graph/SKILL.md +31 -0
- package/skills/geniustasker-graph/agents/openai.yaml +4 -0
- package/skills/geniustasker-media/SKILL.md +33 -0
- package/skills/geniustasker-media/agents/openai.yaml +4 -0
- package/skills/geniustasker-memory/SKILL.md +9 -2
- package/src/cli.mjs +78 -1
- package/src/client.mjs +2 -0
- package/src/command-catalog.mjs +48 -0
- package/src/documentation-graph.mjs +251 -0
- package/src/documentation-store.mjs +589 -0
- package/src/documentation-sync.mjs +517 -0
- package/src/documentation.mjs +136 -0
- package/src/mcp.mjs +47 -1
- package/src/media.mjs +65 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat, mkdir, readdir, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import { resolve, join, parse, dirname, relative, sep } from 'node:path';
|
|
6
|
+
import { fail } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
const manifestName = '.geniustasker-manifest.json';
|
|
9
|
+
const journalName = '.geniustasker-swap.json';
|
|
10
|
+
const lockName = '.geniustasker-sync.lock';
|
|
11
|
+
export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
export function safeRelative(value) {
|
|
13
|
+
if (
|
|
14
|
+
typeof value !== 'string' ||
|
|
15
|
+
!value ||
|
|
16
|
+
value.length > 2048 ||
|
|
17
|
+
value.includes('\\') ||
|
|
18
|
+
value.split('/').some((part) => !part || part === '.' || part === '..' || part.length > 240) ||
|
|
19
|
+
// eslint-disable-next-line no-control-regex -- Control bytes cannot be generated paths.
|
|
20
|
+
/[\u0000-\u001f\u007f:]/u.test(value)
|
|
21
|
+
) {
|
|
22
|
+
fail(
|
|
23
|
+
'unsafe_documentation_path',
|
|
24
|
+
'Generated paths must remain inside the managed content directory.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
async function stat(path) {
|
|
30
|
+
try {
|
|
31
|
+
return await lstat(path);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Check each existing component before creating a missing one. A destination
|
|
40
|
+
* containing symlinks is refused, including a symlink in an ancestor directory. */
|
|
41
|
+
async function directory(path, create = false) {
|
|
42
|
+
const absolute = resolve(path),
|
|
43
|
+
root = parse(absolute).root;
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
|
|
46
|
+
current = join(current, part);
|
|
47
|
+
if (!(await stat(current)) && create) {
|
|
48
|
+
await mkdir(current, { mode: 0o700 });
|
|
49
|
+
}
|
|
50
|
+
const info = await stat(current);
|
|
51
|
+
if (!info || info.isSymbolicLink() || !info.isDirectory()) {
|
|
52
|
+
fail(
|
|
53
|
+
'unsafe_documentation_path',
|
|
54
|
+
'Documentation directories must be real directories, without symlinks.',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return absolute;
|
|
59
|
+
}
|
|
60
|
+
async function bytesAt(path, maximum = 33554432) {
|
|
61
|
+
await directory(dirname(path));
|
|
62
|
+
const info = await stat(path);
|
|
63
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > maximum) {
|
|
64
|
+
fail(
|
|
65
|
+
'unsafe_documentation_file',
|
|
66
|
+
'A managed file is missing, oversized or not a regular file.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
70
|
+
try {
|
|
71
|
+
return await file.readFile();
|
|
72
|
+
} finally {
|
|
73
|
+
await file.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function exclusive(path, bytes) {
|
|
77
|
+
const file = await open(path, 'wx', 0o600);
|
|
78
|
+
try {
|
|
79
|
+
await file.writeFile(bytes);
|
|
80
|
+
await file.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await file.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function syncDirectory(path) {
|
|
86
|
+
const file = await open(path, 'r');
|
|
87
|
+
try {
|
|
88
|
+
await file.sync();
|
|
89
|
+
} finally {
|
|
90
|
+
await file.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function jsonAt(path) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse((await bytesAt(path)).toString('utf8'));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof SyntaxError) {
|
|
98
|
+
fail('invalid_documentation_manifest', 'The local documentation metadata is not valid JSON.');
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validateManifest(value) {
|
|
104
|
+
if (
|
|
105
|
+
value?.formatVersion !== 1 ||
|
|
106
|
+
!value.files ||
|
|
107
|
+
typeof value.files !== 'object' ||
|
|
108
|
+
Array.isArray(value.files) ||
|
|
109
|
+
typeof value.realm !== 'string' ||
|
|
110
|
+
!Array.isArray(value.sources)
|
|
111
|
+
) {
|
|
112
|
+
fail(
|
|
113
|
+
'invalid_documentation_manifest',
|
|
114
|
+
'The directory is not a recognized generated documentation snapshot.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
for (const [name, file] of Object.entries(value.files)) {
|
|
118
|
+
safeRelative(name);
|
|
119
|
+
if (
|
|
120
|
+
name.startsWith('.') ||
|
|
121
|
+
!/^[a-f0-9]{64}$/.test(file.sha256 ?? '') ||
|
|
122
|
+
!Number.isSafeInteger(file.size) ||
|
|
123
|
+
file.size < 0
|
|
124
|
+
) {
|
|
125
|
+
fail('invalid_documentation_manifest', 'A tracked file has invalid metadata.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
async function inventory(root, prefix = '') {
|
|
131
|
+
const found = [];
|
|
132
|
+
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
|
|
133
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
134
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
|
135
|
+
fail(
|
|
136
|
+
'unsafe_documentation_file',
|
|
137
|
+
'Symlinks and special files cannot be managed documentation.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
found.push(...(await inventory(root, path)));
|
|
142
|
+
} else {
|
|
143
|
+
found.push(path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** All generated files live under content/. Other files in the chosen root are
|
|
150
|
+
* untouched. A staged directory and recoverable rename journal publish the
|
|
151
|
+
* manifest and its files together; interrupted downloads never look deleted. */
|
|
152
|
+
export class DocumentationStore {
|
|
153
|
+
constructor(path, { maxFiles = 20000, maxBytes = 268435456 } = {}) {
|
|
154
|
+
if (
|
|
155
|
+
!Number.isSafeInteger(maxFiles) ||
|
|
156
|
+
maxFiles < 1 ||
|
|
157
|
+
maxFiles > 100000 ||
|
|
158
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
159
|
+
maxBytes < 1024 ||
|
|
160
|
+
maxBytes > 4294967296
|
|
161
|
+
) {
|
|
162
|
+
fail('invalid_arguments', 'Use documented file and byte limits for repository sync.');
|
|
163
|
+
}
|
|
164
|
+
this.root = resolve(path);
|
|
165
|
+
this.current = join(this.root, 'content');
|
|
166
|
+
this.maxFiles = maxFiles;
|
|
167
|
+
this.maxBytes = maxBytes;
|
|
168
|
+
}
|
|
169
|
+
async status() {
|
|
170
|
+
if (!(await stat(this.root))) {
|
|
171
|
+
return { configured: false };
|
|
172
|
+
}
|
|
173
|
+
await directory(this.root);
|
|
174
|
+
if (await stat(join(this.root, journalName))) {
|
|
175
|
+
return { interrupted: true };
|
|
176
|
+
}
|
|
177
|
+
if (!(await stat(this.current))) {
|
|
178
|
+
return { configured: false };
|
|
179
|
+
}
|
|
180
|
+
await directory(this.current);
|
|
181
|
+
return validateManifest(await jsonAt(join(this.current, manifestName)));
|
|
182
|
+
}
|
|
183
|
+
async recover() {
|
|
184
|
+
const path = join(this.root, journalName);
|
|
185
|
+
if (!(await stat(path))) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const journal = await jsonAt(path);
|
|
189
|
+
for (const [name, prefix] of [
|
|
190
|
+
[journal.stage, '.stage-'],
|
|
191
|
+
[journal.previous, '.previous-'],
|
|
192
|
+
]) {
|
|
193
|
+
if (
|
|
194
|
+
typeof name !== 'string' ||
|
|
195
|
+
!name.startsWith(prefix) ||
|
|
196
|
+
!/^[a-f0-9-]{36}$/.test(name.slice(prefix.length))
|
|
197
|
+
) {
|
|
198
|
+
fail('invalid_documentation_manifest', 'The interrupted sync journal is invalid.');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const previous = join(this.root, journal.previous),
|
|
202
|
+
stage = join(this.root, journal.stage);
|
|
203
|
+
for (const candidate of [this.current, previous, stage]) {
|
|
204
|
+
if (await stat(candidate)) {
|
|
205
|
+
await directory(candidate);
|
|
206
|
+
validateManifest(await jsonAt(join(candidate, manifestName)));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Never publish a staged download after a crash without fresh authorization.
|
|
210
|
+
// If the old snapshot was moved aside, restore it and perform a fresh sync.
|
|
211
|
+
if (!(await stat(this.current)) && (await stat(previous))) {
|
|
212
|
+
await rename(previous, this.current);
|
|
213
|
+
}
|
|
214
|
+
if (await stat(stage)) {
|
|
215
|
+
await rm(stage, { recursive: true });
|
|
216
|
+
}
|
|
217
|
+
if (await stat(previous)) {
|
|
218
|
+
await rm(previous, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
await unlink(path);
|
|
221
|
+
await syncDirectory(this.root);
|
|
222
|
+
}
|
|
223
|
+
async unlock() {
|
|
224
|
+
await directory(this.root);
|
|
225
|
+
const path = join(this.root, lockName);
|
|
226
|
+
if (!(await stat(path))) {
|
|
227
|
+
return { unlocked: false };
|
|
228
|
+
}
|
|
229
|
+
const lock = await jsonAt(path);
|
|
230
|
+
if (lock.hostname !== hostname() || !Number.isSafeInteger(lock.pid) || lock.pid < 1) {
|
|
231
|
+
fail('documentation_locked', 'The lock belongs to another host or has invalid ownership.');
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
process.kill(lock.pid, 0);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error.code === 'ESRCH') {
|
|
237
|
+
if (
|
|
238
|
+
typeof lock.stage === 'string' &&
|
|
239
|
+
/^\.stage-[a-f0-9-]{36}$/.test(lock.stage) &&
|
|
240
|
+
!(await stat(join(this.root, journalName)))
|
|
241
|
+
) {
|
|
242
|
+
const stage = join(this.root, lock.stage);
|
|
243
|
+
if (await stat(stage)) {
|
|
244
|
+
await directory(stage);
|
|
245
|
+
await inventory(stage);
|
|
246
|
+
await rm(stage, { recursive: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await unlink(path);
|
|
250
|
+
return { unlocked: true };
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
fail('documentation_locked', 'The exporting process is still running.');
|
|
255
|
+
}
|
|
256
|
+
async run(callback) {
|
|
257
|
+
await directory(this.root, true);
|
|
258
|
+
this.stageName = '.stage-' + randomUUID();
|
|
259
|
+
const lockPath = join(this.root, lockName);
|
|
260
|
+
try {
|
|
261
|
+
await exclusive(
|
|
262
|
+
lockPath,
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
pid: process.pid,
|
|
265
|
+
hostname: hostname(),
|
|
266
|
+
startedAt: new Date().toISOString(),
|
|
267
|
+
stage: this.stageName,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code === 'EEXIST') {
|
|
272
|
+
fail(
|
|
273
|
+
'documentation_locked',
|
|
274
|
+
'Another sync owns this directory. Use docs unlock only after its process has stopped.',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await this.recover();
|
|
281
|
+
this.previousManifest = (await stat(this.current))
|
|
282
|
+
? validateManifest(await jsonAt(join(this.current, manifestName)))
|
|
283
|
+
: null;
|
|
284
|
+
await this.assertUnchanged();
|
|
285
|
+
this.stage = join(this.root, this.stageName);
|
|
286
|
+
await mkdir(this.stage, { mode: 0o700 });
|
|
287
|
+
this.files = Object.create(null);
|
|
288
|
+
this.size = 0;
|
|
289
|
+
this.fileCount = 0;
|
|
290
|
+
return await callback(this);
|
|
291
|
+
} finally {
|
|
292
|
+
// A rename journal takes ownership during the commit window. Recovery
|
|
293
|
+
// must retain its directories if a filesystem operation was interrupted.
|
|
294
|
+
if (this.stage && !(await stat(join(this.root, journalName)))) {
|
|
295
|
+
await rm(this.stage, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
await unlink(lockPath);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async assertUnchanged() {
|
|
301
|
+
if (!this.previousManifest) {
|
|
302
|
+
if (await stat(this.current)) {
|
|
303
|
+
fail(
|
|
304
|
+
'documentation_conflict',
|
|
305
|
+
'An existing content directory is not managed by this exporter.',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await directory(this.current);
|
|
311
|
+
const expected = this.previousManifest.files;
|
|
312
|
+
const actual = (await inventory(this.current)).filter((name) => name !== manifestName);
|
|
313
|
+
if (
|
|
314
|
+
actual.length !== Object.keys(expected).length ||
|
|
315
|
+
actual.some((name) => !Object.hasOwn(expected, name))
|
|
316
|
+
) {
|
|
317
|
+
fail(
|
|
318
|
+
'documentation_conflict',
|
|
319
|
+
'The generated content directory contains local additions or removals. Preserve them outside content/ before syncing.',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
for (const name of actual) {
|
|
323
|
+
const bytes = await bytesAt(join(this.current, name));
|
|
324
|
+
if (digest(bytes) !== expected[name].sha256 || bytes.length !== expected[name].size) {
|
|
325
|
+
fail(
|
|
326
|
+
'documentation_conflict',
|
|
327
|
+
'A generated file was edited locally. Apply the change through MCP and preserve the local edit before syncing.',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async readPrevious(name) {
|
|
333
|
+
safeRelative(name);
|
|
334
|
+
if (!Object.hasOwn(this.previousManifest?.files ?? {}, name)) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const bytes = await bytesAt(join(this.current, name));
|
|
338
|
+
if (digest(bytes) !== this.previousManifest.files[name].sha256) {
|
|
339
|
+
fail('documentation_conflict', 'A generated file changed during sync.');
|
|
340
|
+
}
|
|
341
|
+
return bytes;
|
|
342
|
+
}
|
|
343
|
+
async put(name, input) {
|
|
344
|
+
safeRelative(name);
|
|
345
|
+
if (name.startsWith('.')) {
|
|
346
|
+
fail('unsafe_documentation_path', 'Generated content cannot overwrite sync metadata.');
|
|
347
|
+
}
|
|
348
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
349
|
+
const sha256 = digest(bytes);
|
|
350
|
+
if (this.files[name]) {
|
|
351
|
+
if (this.files[name].sha256 !== sha256) {
|
|
352
|
+
fail('documentation_conflict', 'Two sources produced the same generated path.');
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
this.fileCount >= this.maxFiles ||
|
|
358
|
+
this.size + bytes.length > this.maxBytes ||
|
|
359
|
+
bytes.length > 33554432
|
|
360
|
+
) {
|
|
361
|
+
fail(
|
|
362
|
+
'documentation_capacity',
|
|
363
|
+
'This export exceeds its file or byte budget. Narrow the selection or explicitly increase the documented sync limits.',
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
await directory(dirname(join(this.stage, name)), true);
|
|
367
|
+
await exclusive(join(this.stage, name), bytes);
|
|
368
|
+
this.files[name] = { sha256, size: bytes.length };
|
|
369
|
+
this.size += bytes.length;
|
|
370
|
+
this.fileCount++;
|
|
371
|
+
}
|
|
372
|
+
async discardPrefix(prefix) {
|
|
373
|
+
safeRelative(prefix);
|
|
374
|
+
const files = Object.keys(this.files).filter((name) => name.startsWith(prefix + '/'));
|
|
375
|
+
for (const name of files) {
|
|
376
|
+
this.size -= this.files[name].size;
|
|
377
|
+
this.fileCount--;
|
|
378
|
+
delete this.files[name];
|
|
379
|
+
}
|
|
380
|
+
const path = join(this.stage, prefix);
|
|
381
|
+
if (await stat(path)) {
|
|
382
|
+
await directory(path);
|
|
383
|
+
await inventory(path);
|
|
384
|
+
await rm(path, { recursive: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async commit(metadata) {
|
|
388
|
+
await this.assertUnchanged();
|
|
389
|
+
const manifest = { ...metadata, formatVersion: 1, files: this.files };
|
|
390
|
+
validateManifest(manifest);
|
|
391
|
+
const body = JSON.stringify(manifest, null, 2) + '\n';
|
|
392
|
+
if (Buffer.byteLength(body) > 33554432) {
|
|
393
|
+
fail('documentation_capacity', 'The export manifest exceeds its size budget.');
|
|
394
|
+
}
|
|
395
|
+
await exclusive(join(this.stage, manifestName), body);
|
|
396
|
+
await syncDirectory(this.stage);
|
|
397
|
+
const previousName = '.previous-' + randomUUID(),
|
|
398
|
+
previous = join(this.root, previousName);
|
|
399
|
+
await exclusive(
|
|
400
|
+
join(this.root, journalName),
|
|
401
|
+
JSON.stringify({ stage: this.stageName, previous: previousName }),
|
|
402
|
+
);
|
|
403
|
+
await syncDirectory(this.root);
|
|
404
|
+
if (await stat(this.current)) {
|
|
405
|
+
await rename(this.current, previous);
|
|
406
|
+
}
|
|
407
|
+
await rename(this.stage, this.current);
|
|
408
|
+
await syncDirectory(this.root);
|
|
409
|
+
if (await stat(previous)) {
|
|
410
|
+
await rm(previous, { recursive: true });
|
|
411
|
+
}
|
|
412
|
+
await unlink(join(this.root, journalName));
|
|
413
|
+
await syncDirectory(this.root);
|
|
414
|
+
return manifest;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Interrupted downloads live in the private credential directory, outside the
|
|
419
|
+
* repository. Callers must prove current access and a matching source revision
|
|
420
|
+
* before choosing a cache key. The cache is not an offline authorization path. */
|
|
421
|
+
export class DocumentationDownloads {
|
|
422
|
+
constructor(credentialDirectory, outputDirectory, realm, { maxBytes = 268435456 } = {}) {
|
|
423
|
+
if (typeof credentialDirectory !== 'string' || !credentialDirectory) {
|
|
424
|
+
fail(
|
|
425
|
+
'invalid_arguments',
|
|
426
|
+
'A private client credential directory is required for resumable documentation.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
this.root = join(
|
|
430
|
+
resolve(credentialDirectory),
|
|
431
|
+
'documentation-cache',
|
|
432
|
+
digest(resolve(outputDirectory)),
|
|
433
|
+
);
|
|
434
|
+
this.realm = realm;
|
|
435
|
+
this.maxBytes = maxBytes * 2;
|
|
436
|
+
}
|
|
437
|
+
async begin(sources) {
|
|
438
|
+
await directory(this.root, true);
|
|
439
|
+
const marker = join(this.root, 'owner.json');
|
|
440
|
+
if (await stat(marker)) {
|
|
441
|
+
if ((await jsonAt(marker)).realm !== this.realm) {
|
|
442
|
+
fail(
|
|
443
|
+
'documentation_account_mismatch',
|
|
444
|
+
'A pending export belongs to another account or environment.',
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
await exclusive(marker, JSON.stringify({ realm: this.realm }));
|
|
449
|
+
}
|
|
450
|
+
const allowed = new Set(sources.map((source) => digest(source.scope)));
|
|
451
|
+
for (const entry of await readdir(this.root, { withFileTypes: true })) {
|
|
452
|
+
if (entry.name === 'owner.json') {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[a-f0-9]{64}$/.test(entry.name)) {
|
|
456
|
+
fail(
|
|
457
|
+
'unsafe_documentation_file',
|
|
458
|
+
'The private download cache contains an unexpected entry.',
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (!allowed.has(entry.name)) {
|
|
462
|
+
await this.removeDirectory(join(this.root, entry.name));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.size = 0;
|
|
466
|
+
for (const name of await inventory(this.root)) {
|
|
467
|
+
this.size += (await stat(join(this.root, name))).size;
|
|
468
|
+
}
|
|
469
|
+
if (this.size > this.maxBytes) {
|
|
470
|
+
fail(
|
|
471
|
+
'documentation_capacity',
|
|
472
|
+
'Pending downloads exceed this export budget. Use its previous budget or clear the pending cache.',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
source(scope) {
|
|
477
|
+
return join(this.root, digest(scope));
|
|
478
|
+
}
|
|
479
|
+
async removeDirectory(path) {
|
|
480
|
+
await directory(path);
|
|
481
|
+
await inventory(path);
|
|
482
|
+
await rm(path, { recursive: true });
|
|
483
|
+
}
|
|
484
|
+
async prepare(scope, pages, tree) {
|
|
485
|
+
const root = this.source(scope);
|
|
486
|
+
await directory(root, true);
|
|
487
|
+
const allowed = new Set(
|
|
488
|
+
Object.values(pages).map((page) => digest(this.pageKey(scope, page, tree))),
|
|
489
|
+
);
|
|
490
|
+
const pageRoot = join(root, 'pages');
|
|
491
|
+
await directory(pageRoot, true);
|
|
492
|
+
for (const entry of await readdir(pageRoot, { withFileTypes: true })) {
|
|
493
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !/^[a-f0-9]{64}\.json$/.test(entry.name)) {
|
|
494
|
+
fail('unsafe_documentation_file', 'The private page cache is invalid.');
|
|
495
|
+
}
|
|
496
|
+
if (!allowed.has(entry.name.slice(0, -5))) {
|
|
497
|
+
await this.removeFile(join(pageRoot, entry.name));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Graph inventories carry a scope checkpoint. A changed checkpoint requires
|
|
501
|
+
// a new initial snapshot; unchanged inventories can resume after throttling.
|
|
502
|
+
const snapshots = join(root, 'snapshots');
|
|
503
|
+
await directory(snapshots, true);
|
|
504
|
+
const context = digest(JSON.stringify([tree.epoch, tree.accessRevision, tree.checkpoint]));
|
|
505
|
+
for (const entry of await readdir(snapshots, { withFileTypes: true })) {
|
|
506
|
+
if (entry.name !== context) {
|
|
507
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
508
|
+
fail('unsafe_documentation_file', 'The private Graph cache is invalid.');
|
|
509
|
+
}
|
|
510
|
+
const path = join(snapshots, entry.name);
|
|
511
|
+
for (const name of await inventory(path)) {
|
|
512
|
+
this.size -= (await stat(join(path, name))).size;
|
|
513
|
+
}
|
|
514
|
+
await this.removeDirectory(path);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
await directory(join(snapshots, context), true);
|
|
518
|
+
}
|
|
519
|
+
pageKey(scope, page, tree) {
|
|
520
|
+
return JSON.stringify([scope, tree.epoch, tree.accessRevision, page.id, page.revision]);
|
|
521
|
+
}
|
|
522
|
+
graphBucket(tree) {
|
|
523
|
+
return `snapshots/${digest(JSON.stringify([tree.epoch, tree.accessRevision, tree.checkpoint]))}/graph`;
|
|
524
|
+
}
|
|
525
|
+
mediaBucket(tree) {
|
|
526
|
+
return `snapshots/${digest(JSON.stringify([tree.epoch, tree.accessRevision, tree.checkpoint]))}/media`;
|
|
527
|
+
}
|
|
528
|
+
async removeFile(path) {
|
|
529
|
+
const info = await stat(path);
|
|
530
|
+
if (info) {
|
|
531
|
+
this.size -= info.size;
|
|
532
|
+
await unlink(path);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async read(scope, bucket, key, maximum = 262144) {
|
|
536
|
+
const path = join(this.source(scope), safeRelative(bucket), digest(key) + '.json');
|
|
537
|
+
if (!(await stat(path))) {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
let record;
|
|
541
|
+
try {
|
|
542
|
+
record = JSON.parse(
|
|
543
|
+
(await bytesAt(path, Math.ceil((maximum * 4) / 3) + 1024)).toString('utf8'),
|
|
544
|
+
);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (error instanceof SyntaxError) {
|
|
547
|
+
await this.removeFile(path);
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
if (typeof record?.data !== 'string' || !/^[a-f0-9]{64}$/.test(record.sha256 ?? '')) {
|
|
553
|
+
await this.removeFile(path);
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const bytes = Buffer.from(record.data, 'base64');
|
|
557
|
+
if (bytes.length > maximum || digest(bytes) !== record.sha256) {
|
|
558
|
+
await this.removeFile(path);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return bytes;
|
|
562
|
+
}
|
|
563
|
+
async put(scope, bucket, key, bytes) {
|
|
564
|
+
const path = join(this.source(scope), safeRelative(bucket), digest(key) + '.json');
|
|
565
|
+
if (await stat(path)) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const encoded = JSON.stringify({ sha256: digest(bytes), data: bytes.toString('base64') });
|
|
569
|
+
if (this.size + Buffer.byteLength(encoded) > this.maxBytes) {
|
|
570
|
+
fail('documentation_capacity', 'Resumable downloads exceed the explicit export budget.');
|
|
571
|
+
}
|
|
572
|
+
await directory(dirname(path), true);
|
|
573
|
+
await exclusive(path, encoded);
|
|
574
|
+
this.size += Buffer.byteLength(encoded);
|
|
575
|
+
}
|
|
576
|
+
async clearSource(scope) {
|
|
577
|
+
const path = this.source(scope);
|
|
578
|
+
if (!(await stat(path))) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
for (const name of await inventory(path)) {
|
|
582
|
+
this.size -= (await stat(join(path, name))).size;
|
|
583
|
+
}
|
|
584
|
+
await this.removeDirectory(path);
|
|
585
|
+
}
|
|
586
|
+
async finish() {
|
|
587
|
+
await this.removeDirectory(this.root);
|
|
588
|
+
}
|
|
589
|
+
}
|