@parall/cli 1.34.0 → 1.36.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/dist/commands/external-triggers.d.ts +19 -0
- package/dist/commands/external-triggers.d.ts.map +1 -0
- package/dist/commands/external-triggers.js +488 -0
- package/dist/commands/tasks.d.ts.map +1 -1
- package/dist/commands/tasks.js +15 -0
- package/dist/commands/wiki.d.ts.map +1 -1
- package/dist/commands/wiki.js +128 -22
- package/dist/index.js +2 -0
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +6 -0
- package/dist/lib/wiki-files.d.ts +76 -0
- package/dist/lib/wiki-files.d.ts.map +1 -0
- package/dist/lib/wiki-files.js +169 -0
- package/dist/lib/wiki-frontmatter.d.ts +9 -0
- package/dist/lib/wiki-frontmatter.d.ts.map +1 -0
- package/dist/lib/wiki-frontmatter.js +64 -0
- package/dist/lib/wiki-tools.d.ts +3 -2
- package/dist/lib/wiki-tools.d.ts.map +1 -1
- package/dist/lib/wiki-tools.js +171 -2
- package/dist/lib/wiki.d.ts +45 -15
- package/dist/lib/wiki.d.ts.map +1 -1
- package/dist/lib/wiki.js +461 -159
- package/package.json +7 -3
package/dist/lib/wiki.js
CHANGED
|
@@ -2,17 +2,28 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ApiError, } from '@parall/sdk';
|
|
5
|
+
import { structuredPatch } from 'diff';
|
|
6
|
+
import { hasFrontMatter, parseFrontMatter } from './wiki-frontmatter.js';
|
|
7
|
+
// Re-exported so consumers (and tests) can reach the frontmatter parser via the
|
|
8
|
+
// wiki module surface alongside detectFrontMatterEnd.
|
|
9
|
+
export { parseFrontMatter } from './wiki-frontmatter.js';
|
|
5
10
|
// ---------------------------------------------------------------------------
|
|
6
11
|
// Constants
|
|
7
12
|
// ---------------------------------------------------------------------------
|
|
8
13
|
const MARKDOWN_HEADING_RE = /^\s{0,3}(#{1,6})[ \t]+(.+?)\s*$/;
|
|
9
14
|
const TRAILING_FENCE_RE = /\s+#+\s*$/;
|
|
10
|
-
|
|
15
|
+
// Bumped 2 → 3 for the frontmatter (type/description/tags) fields: invalidates
|
|
16
|
+
// caches built before frontmatter parsing so existing local mounts re-parse and
|
|
17
|
+
// pick up the metadata even when a file's size / mtime is unchanged.
|
|
18
|
+
const LOCAL_NODE_SECTION_ARTIFACT_VERSION = 3;
|
|
11
19
|
const LOCAL_MANIFEST_FILE = 'manifest.json';
|
|
12
20
|
const PRLL_WIKI_DIR = '.parall-wiki';
|
|
13
21
|
const CONFLICTS_DIR = 'conflicts';
|
|
22
|
+
const OBJECTS_DIR = 'objects';
|
|
14
23
|
const REMOTE_CONFLICT_SUFFIX = '.remote';
|
|
15
24
|
const REMOTE_DELETED_MARKER_SUFFIX = '.remote-deleted';
|
|
25
|
+
/** Server-side text ceiling for changeset file content (mirrors filetype.TextMaxBytes). */
|
|
26
|
+
export const TEXT_MAX_BYTES = 10 * 1024 * 1024;
|
|
16
27
|
// ---------------------------------------------------------------------------
|
|
17
28
|
// Wiki resolution helpers
|
|
18
29
|
// ---------------------------------------------------------------------------
|
|
@@ -34,7 +45,10 @@ export async function resolveWikiRef(ctx, wikiRef) {
|
|
|
34
45
|
if (fuzzy) {
|
|
35
46
|
return fuzzy;
|
|
36
47
|
}
|
|
37
|
-
|
|
48
|
+
const available = wikis.length > 0
|
|
49
|
+
? ` Available wikis: ${wikis.map((w) => w.slug).join(', ')}`
|
|
50
|
+
: ' This organization has no wikis yet.';
|
|
51
|
+
throw new Error(`wiki not found: ${wikiRef}.${available}`);
|
|
38
52
|
}
|
|
39
53
|
/**
|
|
40
54
|
* Resolve wiki reference. If wikiRef is omitted or empty, auto-resolves to
|
|
@@ -70,22 +84,55 @@ export async function getWikiTree(ctx, wikiRef, options = {}) {
|
|
|
70
84
|
}
|
|
71
85
|
export async function getWikiBlob(ctx, wikiRef, options) {
|
|
72
86
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
87
|
+
const normalizedPath = normalizeRequiredWikiPath(options.path);
|
|
88
|
+
// Local mount first (same data source as search/section/outline) so reads
|
|
89
|
+
// reflect the agent's own in-progress edits. Files absent locally AND
|
|
90
|
+
// absent from the manifest (never synced, or ACL-filtered) fall through to
|
|
91
|
+
// the API; files in the manifest but missing on disk are pending local
|
|
92
|
+
// deletes — surfacing the server copy there would make a delete look like
|
|
93
|
+
// it failed.
|
|
94
|
+
if (!options.remote) {
|
|
95
|
+
const mount = await resolveLocalWikiMount(ctx, wiki);
|
|
96
|
+
if (mount) {
|
|
97
|
+
const target = resolvePathInsideRoot(mount.root, normalizedPath);
|
|
98
|
+
const stat = await fs.stat(target).catch(() => null);
|
|
99
|
+
if (stat?.isFile()) {
|
|
100
|
+
const content = await fs.readFile(target);
|
|
101
|
+
if (looksBinary(content)) {
|
|
102
|
+
throw new Error(`wiki file is binary, not printable text: ${normalizedPath}. ` +
|
|
103
|
+
'Binary files are viewable in the web UI.');
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
wiki,
|
|
107
|
+
content: content.toString('utf8'),
|
|
108
|
+
size: content.length,
|
|
109
|
+
source: 'local_mount',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const manifest = await readLocalManifest(mount.root);
|
|
113
|
+
if (manifest.files[normalizedPath]) {
|
|
114
|
+
throw new Error(`${normalizedPath} is deleted in your local workspace (pending delete). ` +
|
|
115
|
+
'Run `parall wiki changeset create` to propose the deletion, ' +
|
|
116
|
+
'`parall wiki reset` to restore it, or `parall wiki cat --remote` to read the server version.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
73
120
|
const blob = await ctx.client.getWikiBlob(ctx.orgId, wiki.id, {
|
|
74
|
-
path:
|
|
121
|
+
path: normalizedPath,
|
|
75
122
|
});
|
|
76
123
|
// Phase 2 widened WikiBlob to allow encoding='signed_url' (binary served
|
|
77
124
|
// via preview URL) and still admits the legacy 'base64' inline shape,
|
|
78
125
|
// both of which omit or encode `content` in ways the CLI's string surface
|
|
79
|
-
// cannot use. Only utf-8 text is safe to return verbatim
|
|
80
|
-
// gets a clear error — callers that want bytes should fetch via the
|
|
81
|
-
// signed_url flow.
|
|
126
|
+
// cannot use. Only utf-8 text is safe to return verbatim.
|
|
82
127
|
if (blob.encoding !== 'utf-8' || blob.content == null) {
|
|
83
|
-
throw new Error(`wiki
|
|
128
|
+
throw new Error(`wiki file is binary, not printable text (encoding=${blob.encoding}): ${normalizedPath}. ` +
|
|
129
|
+
'Binary files are viewable in the web UI.');
|
|
84
130
|
}
|
|
85
131
|
return {
|
|
86
132
|
wiki,
|
|
87
133
|
content: blob.content,
|
|
88
134
|
size: blob.size,
|
|
135
|
+
source: 'api',
|
|
89
136
|
};
|
|
90
137
|
}
|
|
91
138
|
// ---------------------------------------------------------------------------
|
|
@@ -259,20 +306,17 @@ export async function getAfcsStatus(ctx, wikiRef) {
|
|
|
259
306
|
catch {
|
|
260
307
|
// Changesets API may fail — non-fatal for status
|
|
261
308
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
};
|
|
309
|
+
// No permissions block: per-path access is queryable via `parall wiki
|
|
310
|
+
// access <path>`; a whole-tree writability summary needs the path-scope
|
|
311
|
+
// semantics still moving in the restriction-layer work.
|
|
266
312
|
return {
|
|
267
313
|
wiki_id: wiki.id,
|
|
268
314
|
wiki_slug: wiki.slug,
|
|
269
315
|
wiki_name: wiki.name,
|
|
270
316
|
mount_path: mount.root,
|
|
271
|
-
mode: 'read_write',
|
|
272
317
|
default_ref: wiki.default_branch,
|
|
273
318
|
local_changes: localChanges,
|
|
274
319
|
changesets,
|
|
275
|
-
permissions,
|
|
276
320
|
};
|
|
277
321
|
}
|
|
278
322
|
export async function getAfcsDiff(ctx, wikiRef) {
|
|
@@ -295,7 +339,7 @@ export async function getAfcsDiff(ctx, wikiRef) {
|
|
|
295
339
|
// ---------------------------------------------------------------------------
|
|
296
340
|
export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
297
341
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
298
|
-
const mount = await requireLocalWikiMount(ctx, wiki
|
|
342
|
+
const mount = await requireLocalWikiMount(ctx, wiki);
|
|
299
343
|
const title = options.title.trim();
|
|
300
344
|
if (!title) {
|
|
301
345
|
throw new Error('title is required');
|
|
@@ -303,7 +347,38 @@ export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
|
303
347
|
const manifest = await readLocalManifest(mount.root);
|
|
304
348
|
const diff = await computeLocalDiff(mount, manifest);
|
|
305
349
|
if (diff.changedPaths.length === 0) {
|
|
306
|
-
throw new Error(`no changes to propose for wiki ${wiki.slug}`
|
|
350
|
+
throw new Error(`no changes to propose for wiki ${wiki.slug} — edit files under ${mount.root} first, ` +
|
|
351
|
+
'then run `parall wiki diff` to confirm what will be proposed');
|
|
352
|
+
}
|
|
353
|
+
// Stale-base precheck (fail-closed): compare each changed path's sync
|
|
354
|
+
// baseline against the server's CURRENT manifest. Proposing from a stale
|
|
355
|
+
// baseline would upload full content that silently rolls back concurrent
|
|
356
|
+
// edits. The server re-validates via base_sha (authoritative — this check
|
|
357
|
+
// just fails earlier with the complete file list).
|
|
358
|
+
const serverManifest = (await wikiServiceFetch(mount.manifestUrl, requireMountToken(mount)));
|
|
359
|
+
const serverShaByPath = new Map(serverManifest.data.map((e) => [e.path, e.sha]));
|
|
360
|
+
const staleLines = [];
|
|
361
|
+
for (const filePath of diff.changedPaths) {
|
|
362
|
+
const baseSha = diff.baseShaByPath.get(filePath);
|
|
363
|
+
const serverSha = serverShaByPath.get(filePath);
|
|
364
|
+
if (baseSha === undefined) {
|
|
365
|
+
// Locally created file — stale if the server got one in the meantime.
|
|
366
|
+
if (serverSha !== undefined) {
|
|
367
|
+
staleLines.push(`${filePath} (created on the server since your last sync)`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else if (serverSha === undefined) {
|
|
371
|
+
staleLines.push(`${filePath} (deleted on the server since your last sync)`);
|
|
372
|
+
}
|
|
373
|
+
else if (serverSha !== baseSha) {
|
|
374
|
+
staleLines.push(`${filePath} (modified on the server since your last sync)`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (staleLines.length > 0) {
|
|
378
|
+
throw new Error(`cannot propose: ${staleLines.length} file(s) changed on the server since your last sync:\n` +
|
|
379
|
+
staleLines.map((l) => ` ${l}`).join('\n') +
|
|
380
|
+
'\nRun `parall wiki sync` to pull the latest content (your local edits are preserved; ' +
|
|
381
|
+
'conflicting files get a marker under .parall-wiki/conflicts/), resolve if needed, then propose again.');
|
|
307
382
|
}
|
|
308
383
|
// Build file_changes for the changeset API
|
|
309
384
|
const fileChanges = [];
|
|
@@ -316,25 +391,43 @@ export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
|
316
391
|
fileChanges.push({
|
|
317
392
|
path: filePath,
|
|
318
393
|
action: 'delete',
|
|
394
|
+
base_sha: baseEntry?.sha,
|
|
319
395
|
});
|
|
320
396
|
}
|
|
321
397
|
else {
|
|
322
398
|
const content = await fs.readFile(localPath);
|
|
399
|
+
// The changeset API is text-only (the server rejects binary with 422
|
|
400
|
+
// USE_UPLOAD, pointing at an endpoint the CLI does not wrap). Reject
|
|
401
|
+
// locally with a clear boundary statement instead.
|
|
402
|
+
if (looksBinary(content)) {
|
|
403
|
+
throw new Error(`cannot propose ${filePath}: wiki changesets support text files only. ` +
|
|
404
|
+
'Remove the binary from the workspace (binary uploads go through the web UI).');
|
|
405
|
+
}
|
|
406
|
+
if (content.length > TEXT_MAX_BYTES) {
|
|
407
|
+
throw new Error(`cannot propose ${filePath}: file is ${content.length} bytes, ` +
|
|
408
|
+
`over the ${TEXT_MAX_BYTES}-byte changeset limit. Split the file or remove it from the workspace.`);
|
|
409
|
+
}
|
|
323
410
|
fileChanges.push({
|
|
324
411
|
path: filePath,
|
|
325
412
|
action: baseEntry ? 'update' : 'create',
|
|
326
413
|
content_base64: content.toString('base64'),
|
|
414
|
+
base_sha: baseEntry?.sha,
|
|
327
415
|
});
|
|
328
416
|
}
|
|
329
417
|
}
|
|
330
418
|
let changeset;
|
|
331
419
|
const existingChangesetId = normalizeOptionalString(options.changesetId);
|
|
332
420
|
if (existingChangesetId) {
|
|
333
|
-
// Re-propose
|
|
421
|
+
// Re-propose: replace_files makes the workspace diff define the
|
|
422
|
+
// changeset's FULL content — the server resets the feature branch to
|
|
423
|
+
// default HEAD first, so a change withdrawn locally (file reverted to
|
|
424
|
+
// baseline) actually disappears from the proposal instead of silently
|
|
425
|
+
// surviving on the branch.
|
|
334
426
|
changeset = await ctx.client.updateWikiChangeset(ctx.orgId, wiki.id, existingChangesetId, {
|
|
335
427
|
title,
|
|
336
428
|
message: normalizeOptionalString(options.message),
|
|
337
429
|
file_changes: fileChanges,
|
|
430
|
+
replace_files: true,
|
|
338
431
|
});
|
|
339
432
|
}
|
|
340
433
|
else {
|
|
@@ -407,34 +500,52 @@ function describeProposeNextAction(changeset, postMergeSync, postMergeSyncError)
|
|
|
407
500
|
// ---------------------------------------------------------------------------
|
|
408
501
|
/**
|
|
409
502
|
* Discard all local changes and restore files to the last synced state.
|
|
410
|
-
*
|
|
503
|
+
* Restores from the local baseline object store (network only as a
|
|
504
|
+
* SHA-verified fallback for pre-objects mounts), so the workspace lands
|
|
505
|
+
* exactly on the manifest baseline — `wiki status` reports clean afterwards.
|
|
411
506
|
*/
|
|
412
507
|
export async function resetWikiWorkspace(ctx, wikiRef) {
|
|
413
508
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
414
509
|
const mount = await requireLocalWikiMount(ctx, wiki);
|
|
415
510
|
const manifest = await readLocalManifest(mount.root);
|
|
416
|
-
//
|
|
511
|
+
// Only touch files that actually diverged from the baseline.
|
|
417
512
|
const changes = await computeLocalChanges(mount);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
await fs.
|
|
429
|
-
await
|
|
513
|
+
if (changes.length === 0) {
|
|
514
|
+
return { wiki_id: wiki.id, wiki_slug: wiki.slug, files_restored: 0, unrestorable_paths: [] };
|
|
515
|
+
}
|
|
516
|
+
let restored = 0;
|
|
517
|
+
const unrestorable = [];
|
|
518
|
+
for (const change of changes) {
|
|
519
|
+
const entry = manifest.files[change.path];
|
|
520
|
+
const fullPath = resolvePathInsideRoot(mount.root, change.path);
|
|
521
|
+
if (!entry) {
|
|
522
|
+
// Locally created file — discard.
|
|
523
|
+
await fs.rm(fullPath, { force: true });
|
|
524
|
+
await removeEmptyParents(mount.root, path.dirname(fullPath));
|
|
525
|
+
restored++;
|
|
526
|
+
continue;
|
|
430
527
|
}
|
|
528
|
+
const baseline = await loadBaselineContent(mount, change.path, entry.sha);
|
|
529
|
+
if (!baseline) {
|
|
530
|
+
// No baseline available (pre-objects mount, server moved on). Leave the
|
|
531
|
+
// local file untouched rather than overwriting it with non-baseline
|
|
532
|
+
// content; `parall wiki sync` is the path that reconciles this state.
|
|
533
|
+
unrestorable.push(change.path);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
537
|
+
await fs.writeFile(fullPath, baseline);
|
|
538
|
+
restored++;
|
|
539
|
+
}
|
|
540
|
+
if (unrestorable.length > 0) {
|
|
541
|
+
console.warn(`[wiki reset] ${unrestorable.length} file(s) had no recoverable baseline and were left as-is: ` +
|
|
542
|
+
`${unrestorable.join(', ')} — run \`parall wiki sync\` to reconcile`);
|
|
431
543
|
}
|
|
432
|
-
// Remove any local file not in manifest
|
|
433
|
-
await removeFilesNotInManifest(mount.root, manifest);
|
|
434
544
|
return {
|
|
435
545
|
wiki_id: wiki.id,
|
|
436
546
|
wiki_slug: wiki.slug,
|
|
437
|
-
files_restored:
|
|
547
|
+
files_restored: restored,
|
|
548
|
+
unrestorable_paths: unrestorable,
|
|
438
549
|
};
|
|
439
550
|
}
|
|
440
551
|
export async function listWikiChangesets(ctx, wikiRef) {
|
|
@@ -495,10 +606,32 @@ export async function requestWikiAccess(ctx, wikiRef, targetPath, reason) {
|
|
|
495
606
|
message: 'Access request submitted. Waiting for approval.',
|
|
496
607
|
};
|
|
497
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* Wiki history. Without a path: recent wiki operations (audit log). With a
|
|
611
|
+
* path: that file's commit history from the default branch.
|
|
612
|
+
*/
|
|
498
613
|
export async function getWikiLog(ctx, wikiRef, filePath) {
|
|
499
614
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
500
|
-
|
|
501
|
-
|
|
615
|
+
// Same normalization as every other path entry point — `./docs/a.md` or
|
|
616
|
+
// `/docs/a.md` would otherwise reach the server un-normalized and read
|
|
617
|
+
// back as an empty history.
|
|
618
|
+
const normalizedPath = filePath?.trim() ? normalizeRequiredWikiPath(filePath) : undefined;
|
|
619
|
+
if (normalizedPath) {
|
|
620
|
+
const commits = await ctx.client.getWikiFileCommits(ctx.orgId, wiki.id, normalizedPath);
|
|
621
|
+
return {
|
|
622
|
+
wiki_id: wiki.id,
|
|
623
|
+
wiki_slug: wiki.slug,
|
|
624
|
+
wiki_name: wiki.name,
|
|
625
|
+
path: normalizedPath,
|
|
626
|
+
entries: commits.data.map((c) => ({
|
|
627
|
+
type: 'commit',
|
|
628
|
+
id: c.sha,
|
|
629
|
+
path: normalizedPath,
|
|
630
|
+
action: c.title,
|
|
631
|
+
author_name: c.author_name,
|
|
632
|
+
created_at: c.date,
|
|
633
|
+
})),
|
|
634
|
+
};
|
|
502
635
|
}
|
|
503
636
|
const ops = await ctx.client.getWikiOperations(ctx.orgId, wiki.id);
|
|
504
637
|
const entries = ops.data.map((op) => ({
|
|
@@ -516,12 +649,16 @@ export async function getWikiLog(ctx, wikiRef, filePath) {
|
|
|
516
649
|
};
|
|
517
650
|
}
|
|
518
651
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
652
|
+
* Sync wiki workspaces via REST (manifest comparison + bulk download).
|
|
653
|
+
* Without `wikiRef`: every accessible wiki is synced and stale mount
|
|
654
|
+
* directories are pruned. With `wikiRef`: only that wiki is synced and
|
|
655
|
+
* pruning is skipped — other wikis' workspaces must survive a scoped sync.
|
|
521
656
|
* Mount path is determined client-side: {mountRoot}/{slug}/
|
|
522
657
|
*/
|
|
523
|
-
export async function syncAllMounts(ctx) {
|
|
524
|
-
const
|
|
658
|
+
export async function syncAllMounts(ctx, wikiRef) {
|
|
659
|
+
const allWikis = await ctx.client.getWikis(ctx.orgId);
|
|
660
|
+
const target = wikiRef?.trim() ? await resolveWikiRef(ctx, wikiRef) : undefined;
|
|
661
|
+
const wikis = target ? allWikis.filter((w) => w.id === target.id) : allWikis;
|
|
525
662
|
const mountRoot = ctx.mountRoot?.trim() || process.env.PRLL_WIKI_MOUNT_ROOT?.trim();
|
|
526
663
|
const activeMountPaths = new Set();
|
|
527
664
|
const synced = [];
|
|
@@ -538,7 +675,9 @@ export async function syncAllMounts(ctx) {
|
|
|
538
675
|
failed: outcome.failed,
|
|
539
676
|
});
|
|
540
677
|
}
|
|
541
|
-
|
|
678
|
+
if (!target) {
|
|
679
|
+
await pruneStaleMounts(mountRoot, activeMountPaths);
|
|
680
|
+
}
|
|
542
681
|
const ok = synced.every((s) => s.failed.length === 0);
|
|
543
682
|
return { ok, mounts: wikis.length, synced };
|
|
544
683
|
}
|
|
@@ -623,10 +762,7 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
623
762
|
const metaDir = path.join(mountPath, PRLL_WIKI_DIR);
|
|
624
763
|
await fs.mkdir(metaDir, { recursive: true });
|
|
625
764
|
const token = resolveApiToken(ctx);
|
|
626
|
-
const baseUrl = (ctx
|
|
627
|
-
process.env.PRLL_WIKI_URL ??
|
|
628
|
-
process.env.PRLL_API_URL ??
|
|
629
|
-
'').replace(/\/+$/, '');
|
|
765
|
+
const baseUrl = resolveWikiServiceBaseUrl(ctx);
|
|
630
766
|
const manifestUrl = `${baseUrl}/wiki/v1/orgs/${ctx.orgId}/wikis/${wiki.id}/manifest`;
|
|
631
767
|
const bulkDownloadUrl = `${baseUrl}/wiki/v1/orgs/${ctx.orgId}/wikis/${wiki.id}/files/bulk-download`;
|
|
632
768
|
// 1. Fetch server manifest + read local manifest.
|
|
@@ -695,6 +831,7 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
695
831
|
}
|
|
696
832
|
// 5. Apply actions and start with the previous manifest, mutating per action.
|
|
697
833
|
const newFiles = { ...localManifest.files };
|
|
834
|
+
const downloadedBySha = new Map();
|
|
698
835
|
const conflicts = [];
|
|
699
836
|
const failed = [];
|
|
700
837
|
let applied = 0;
|
|
@@ -763,6 +900,7 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
763
900
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
764
901
|
await fs.writeFile(fullPath, buf);
|
|
765
902
|
newFiles[action.path] = { sha: action.remoteSha, size: action.remoteSize };
|
|
903
|
+
downloadedBySha.set(action.remoteSha, buf);
|
|
766
904
|
applied++;
|
|
767
905
|
break;
|
|
768
906
|
}
|
|
@@ -853,13 +991,16 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
853
991
|
}
|
|
854
992
|
}
|
|
855
993
|
}
|
|
856
|
-
// 6. Persist new manifest
|
|
994
|
+
// 6. Persist new manifest + refresh the baseline object store so diff and
|
|
995
|
+
// reset can always reproduce the exact synced content locally.
|
|
857
996
|
const newManifest = {
|
|
858
997
|
wiki_id: wiki.id,
|
|
859
998
|
synced_at: new Date().toISOString(),
|
|
860
999
|
files: newFiles,
|
|
861
1000
|
};
|
|
862
1001
|
await writeLocalManifest(mountPath, newManifest);
|
|
1002
|
+
await ensureBaselineObjects(mountPath, newFiles, downloadedBySha);
|
|
1003
|
+
await gcBaselineObjects(mountPath, newFiles);
|
|
863
1004
|
if (conflicts.length > 0) {
|
|
864
1005
|
for (const c of conflicts) {
|
|
865
1006
|
const where = c.remoteCopyPath ? ` → ${path.relative(mountPath, c.remoteCopyPath)}` : '';
|
|
@@ -949,15 +1090,125 @@ async function writeLocalManifest(mountRoot, manifest) {
|
|
|
949
1090
|
await fs.writeFile(path.join(metaDir, LOCAL_MANIFEST_FILE), JSON.stringify(manifest, null, 2), 'utf8');
|
|
950
1091
|
}
|
|
951
1092
|
// ---------------------------------------------------------------------------
|
|
1093
|
+
// Baseline object store (.parall-wiki/objects/<sha>)
|
|
1094
|
+
//
|
|
1095
|
+
// Content-addressed copies of the last-synced version of every manifest file.
|
|
1096
|
+
// diff/status/reset read the true sync baseline from here instead of fetching
|
|
1097
|
+
// the server's CURRENT content (which is the wrong base whenever the server
|
|
1098
|
+
// has moved past the local sync point).
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
function baselineObjectPath(mountRoot, sha) {
|
|
1101
|
+
return path.join(mountRoot, PRLL_WIKI_DIR, OBJECTS_DIR, sha);
|
|
1102
|
+
}
|
|
1103
|
+
async function readBaselineObject(mountRoot, sha) {
|
|
1104
|
+
try {
|
|
1105
|
+
return await fs.readFile(baselineObjectPath(mountRoot, sha));
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
async function writeBaselineObject(mountRoot, sha, content) {
|
|
1112
|
+
const target = baselineObjectPath(mountRoot, sha);
|
|
1113
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
1114
|
+
await fs.writeFile(target, content);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Make sure every manifest entry has its baseline object on disk.
|
|
1118
|
+
* Content sources, in order: bytes downloaded during this sync
|
|
1119
|
+
* (`contentBySha`), then the local working-tree file when its blob SHA still
|
|
1120
|
+
* matches the manifest (clean file — typical for pre-objects mounts being
|
|
1121
|
+
* upgraded). Files that are locally dirty AND missing an object stay absent;
|
|
1122
|
+
* diff falls back per-file (see loadBaselineContent).
|
|
1123
|
+
*/
|
|
1124
|
+
async function ensureBaselineObjects(mountRoot, files, contentBySha) {
|
|
1125
|
+
for (const [relPath, entry] of Object.entries(files)) {
|
|
1126
|
+
if (await pathExists(baselineObjectPath(mountRoot, entry.sha))) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const downloaded = contentBySha?.get(entry.sha);
|
|
1130
|
+
if (downloaded) {
|
|
1131
|
+
await writeBaselineObject(mountRoot, entry.sha, downloaded);
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
const probe = await probeLocalGitBlob(mountRoot, relPath);
|
|
1135
|
+
if (probe.kind === 'file' && probe.sha === entry.sha) {
|
|
1136
|
+
const fullPath = resolvePathInsideRoot(mountRoot, relPath);
|
|
1137
|
+
await writeBaselineObject(mountRoot, entry.sha, await fs.readFile(fullPath));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/** Drop baseline objects no longer referenced by any manifest entry. */
|
|
1142
|
+
async function gcBaselineObjects(mountRoot, files) {
|
|
1143
|
+
const objectsDir = path.join(mountRoot, PRLL_WIKI_DIR, OBJECTS_DIR);
|
|
1144
|
+
let names;
|
|
1145
|
+
try {
|
|
1146
|
+
names = await fs.readdir(objectsDir);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const live = new Set(Object.values(files).map((e) => e.sha));
|
|
1152
|
+
for (const name of names) {
|
|
1153
|
+
if (!live.has(name)) {
|
|
1154
|
+
await fs.rm(path.join(objectsDir, name), { force: true });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Load the baseline (last-synced) content for a manifest entry. Objects store
|
|
1160
|
+
* first; when absent (mount predates the objects store and the file is
|
|
1161
|
+
* locally dirty), fall back to downloading the server's current content and
|
|
1162
|
+
* accept it ONLY if its blob SHA matches the manifest base — otherwise the
|
|
1163
|
+
* server has moved and no true baseline is available (returns null).
|
|
1164
|
+
*/
|
|
1165
|
+
async function loadBaselineContent(mount, relPath, expectedSha) {
|
|
1166
|
+
const cached = await readBaselineObject(mount.root, expectedSha);
|
|
1167
|
+
if (cached) {
|
|
1168
|
+
return cached;
|
|
1169
|
+
}
|
|
1170
|
+
if (!mount.token) {
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
const entries = await bulkDownloadFiles(mount.bulkDownloadUrl, mount.token, [relPath]);
|
|
1175
|
+
const entry = entries[0];
|
|
1176
|
+
if (!entry || entry.error) {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
const content = Buffer.from(entry.content_base64, 'base64');
|
|
1180
|
+
if (computeGitBlobSHA(content) !== expectedSha) {
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
await writeBaselineObject(mount.root, expectedSha, content);
|
|
1184
|
+
return content;
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
952
1191
|
// REST helpers for wiki-service
|
|
953
1192
|
// ---------------------------------------------------------------------------
|
|
954
|
-
|
|
1193
|
+
// Exported so the binary-file raw-byte download (wiki-files.ts) authenticates
|
|
1194
|
+
// the same way as sync/bulk-download — bearer from the client or PRLL_API_KEY.
|
|
1195
|
+
export function resolveApiToken(ctx) {
|
|
955
1196
|
const token = ctx?.client.getToken()?.trim() || process.env.PRLL_API_KEY?.trim();
|
|
956
1197
|
if (!token) {
|
|
957
1198
|
throw new Error('API token is required via ctx.client.getToken() or PRLL_API_KEY for wiki operations');
|
|
958
1199
|
}
|
|
959
1200
|
return token;
|
|
960
1201
|
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Resolve the wiki-service base URL (trailing slashes trimmed): explicit ctx
|
|
1204
|
+
* override, then PRLL_WIKI_URL, then PRLL_API_URL. SSOT for the raw-fetch wiki
|
|
1205
|
+
* endpoints (sync / bulk-download / binary file download). Hosted/staging/prod
|
|
1206
|
+
* collapse these to one gateway host; local dev can split api (:8080) and wiki
|
|
1207
|
+
* (:8090). Returns '' when none is set — callers decide whether that's fatal.
|
|
1208
|
+
*/
|
|
1209
|
+
export function resolveWikiServiceBaseUrl(ctx) {
|
|
1210
|
+
return (ctx.baseUrl ?? process.env.PRLL_WIKI_URL ?? process.env.PRLL_API_URL ?? '').replace(/\/+$/, '');
|
|
1211
|
+
}
|
|
961
1212
|
function tryResolveApiToken(ctx) {
|
|
962
1213
|
return ctx?.client.getToken()?.trim() || process.env.PRLL_API_KEY?.trim() || undefined;
|
|
963
1214
|
}
|
|
@@ -1011,54 +1262,92 @@ export function computeGitBlobSHA(content) {
|
|
|
1011
1262
|
const header = Buffer.from(`blob ${content.length}\0`, 'utf8');
|
|
1012
1263
|
return createHash('sha1').update(header).update(content).digest('hex');
|
|
1013
1264
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1265
|
+
// Exact mirror of the magic signatures where the server's classifier
|
|
1266
|
+
// (filetype.IsText → Go http.DetectContentType → isBinaryContentType)
|
|
1267
|
+
// rejects content that could still be NUL-free, UTF-8-decodable bytes.
|
|
1268
|
+
// Signatures whose match requires NUL or non-UTF-8 bytes (PNG, JPEG, OGG,
|
|
1269
|
+
// MIDI, ICO, …) are deliberately omitted — the UTF-8 + NUL checks below
|
|
1270
|
+
// already cover them. Keep this table in lockstep with the Go sniffing
|
|
1271
|
+
// rules; test/testdata/text-classifier-cases.json is the shared contract
|
|
1272
|
+
// both sides verify against.
|
|
1273
|
+
const BINARY_MAGIC_PREFIXES = [
|
|
1274
|
+
Buffer.from('GIF87a'), // image/gif
|
|
1275
|
+
Buffer.from('GIF89a'), // image/gif
|
|
1276
|
+
Buffer.from('%PDF-'), // application/pdf
|
|
1277
|
+
Buffer.from('BM'), // image/bmp (yes, bare "BM" — Go sniffs it as BMP)
|
|
1278
|
+
Buffer.from('ID3'), // audio/mpeg
|
|
1279
|
+
Buffer.from([0x50, 0x4b, 0x03, 0x04]), // application/zip
|
|
1280
|
+
Buffer.from([0x1f, 0x8b, 0x08]), // application/x-gzip
|
|
1281
|
+
];
|
|
1282
|
+
// Container formats Go sniffs via offset-masked signatures: 4-byte container
|
|
1283
|
+
// tag at offset 0, format tag at offset 8.
|
|
1284
|
+
const BINARY_CONTAINER_SIGS = [
|
|
1285
|
+
{ head: 'RIFF', at8: 'WAVE' }, // audio/wave
|
|
1286
|
+
{ head: 'RIFF', at8: 'AVI ' }, // video/avi
|
|
1287
|
+
{ head: 'RIFF', at8: 'WEBPVP' }, // image/webp
|
|
1288
|
+
{ head: 'FORM', at8: 'AIFF' }, // audio/aiff
|
|
1289
|
+
];
|
|
1290
|
+
const utf8Strict = new TextDecoder('utf-8', { fatal: true });
|
|
1291
|
+
/**
|
|
1292
|
+
* Mirror of the server's text classifier (filetype.IsText): content-type
|
|
1293
|
+
* magic sniff, valid UTF-8, and no NUL bytes — kept aligned so content the
|
|
1294
|
+
* CLI accepts cannot bounce off the server's 422 USE_UPLOAD later. The
|
|
1295
|
+
* shared fixture table (test/testdata/text-classifier-cases.json) is
|
|
1296
|
+
* verified by both this implementation's tests and the Go package's tests.
|
|
1297
|
+
*/
|
|
1298
|
+
export function isWikiTextContent(content) {
|
|
1299
|
+
for (const magic of BINARY_MAGIC_PREFIXES) {
|
|
1300
|
+
if (content.subarray(0, magic.length).equals(magic)) {
|
|
1301
|
+
return false;
|
|
1039
1302
|
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
diffFiles.push({ path: filePath, additions: 0, deletions });
|
|
1303
|
+
}
|
|
1304
|
+
for (const sig of BINARY_CONTAINER_SIGS) {
|
|
1305
|
+
if (content.subarray(0, 4).equals(Buffer.from(sig.head)) &&
|
|
1306
|
+
content.subarray(8, 8 + sig.at8.length).equals(Buffer.from(sig.at8))) {
|
|
1307
|
+
return false;
|
|
1046
1308
|
}
|
|
1047
1309
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1310
|
+
if (content.includes(0)) {
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
utf8Strict.decode(content);
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
return true;
|
|
1050
1320
|
}
|
|
1051
|
-
|
|
1321
|
+
function looksBinary(content) {
|
|
1322
|
+
return !isWikiTextContent(content);
|
|
1323
|
+
}
|
|
1324
|
+
/** Compute list of changed files by comparing local disk to local manifest. */
|
|
1325
|
+
async function computeLocalChanges(mount) {
|
|
1326
|
+
const manifest = await readLocalManifest(mount.root);
|
|
1327
|
+
const diff = await computeLocalDiff(mount, manifest);
|
|
1328
|
+
return diff.diffFiles;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Compute the local workspace diff against the last-synced baseline. Base
|
|
1332
|
+
* content comes from the local objects store (with a SHA-verified network
|
|
1333
|
+
* fallback for pre-objects mounts), so the diff base is always the true sync
|
|
1334
|
+
* point — never the server's current content.
|
|
1335
|
+
*/
|
|
1052
1336
|
async function computeLocalDiff(mount, manifest) {
|
|
1053
1337
|
const diffFiles = [];
|
|
1054
1338
|
const patchParts = [];
|
|
1055
1339
|
const visited = new Set();
|
|
1056
|
-
const
|
|
1340
|
+
const baseShaByPath = new Map();
|
|
1341
|
+
const loadBase = async (relPath, entry) => {
|
|
1342
|
+
const base = await loadBaselineContent(mount, relPath, entry.sha);
|
|
1343
|
+
// A missing baseline (pre-objects mount with a locally dirty file and a
|
|
1344
|
+
// server that has since moved) degrades to an empty base: the patch shows
|
|
1345
|
+
// the full local content as additions. propose() still carries the right
|
|
1346
|
+
// base_sha, so staleness is caught server-side regardless.
|
|
1347
|
+
return base ? base.toString('utf8') : '';
|
|
1348
|
+
};
|
|
1057
1349
|
// Walk local files to find modified/added
|
|
1058
1350
|
await walkLocalFiles(mount.root, mount.root, async (fullPath, relativePath) => {
|
|
1059
|
-
if (!matchesAllowedPrefix(relativePath, [])) {
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
1351
|
visited.add(relativePath);
|
|
1063
1352
|
const localContent = await fs.readFile(fullPath);
|
|
1064
1353
|
const localSha = computeGitBlobSHA(localContent);
|
|
@@ -1066,83 +1355,62 @@ async function computeLocalDiff(mount, manifest) {
|
|
|
1066
1355
|
if (!manifestEntry) {
|
|
1067
1356
|
// New file
|
|
1068
1357
|
const localText = localContent.toString('utf8');
|
|
1069
|
-
|
|
1070
|
-
diffFiles.push({ path: relativePath, additions: localLines.length, deletions: 0 });
|
|
1071
|
-
patchParts.push(buildUnifiedDiff(relativePath, '', localText));
|
|
1358
|
+
appendFileDiff(diffFiles, patchParts, relativePath, '', localText, 'create');
|
|
1072
1359
|
}
|
|
1073
1360
|
else if (localSha !== manifestEntry.sha) {
|
|
1074
|
-
// Modified file
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const baseEntries = await bulkDownloadFiles(mount.bulkDownloadUrl, token, [relativePath]);
|
|
1079
|
-
if (baseEntries.length > 0) {
|
|
1080
|
-
baseText = Buffer.from(baseEntries[0].content_base64, 'base64').toString('utf8');
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
catch {
|
|
1084
|
-
// If we can't fetch base, treat as full addition
|
|
1085
|
-
}
|
|
1086
|
-
const localLines = localText.split('\n');
|
|
1087
|
-
const baseLines = baseText.split('\n');
|
|
1088
|
-
diffFiles.push({
|
|
1089
|
-
path: relativePath,
|
|
1090
|
-
additions: localLines.length,
|
|
1091
|
-
deletions: baseLines.length,
|
|
1092
|
-
});
|
|
1093
|
-
patchParts.push(buildUnifiedDiff(relativePath, baseText, localText));
|
|
1361
|
+
// Modified file
|
|
1362
|
+
baseShaByPath.set(relativePath, manifestEntry.sha);
|
|
1363
|
+
const baseText = await loadBase(relativePath, manifestEntry);
|
|
1364
|
+
appendFileDiff(diffFiles, patchParts, relativePath, baseText, localContent.toString('utf8'), 'update');
|
|
1094
1365
|
}
|
|
1095
1366
|
});
|
|
1096
1367
|
// Find deleted files
|
|
1097
1368
|
for (const [filePath, entry] of Object.entries(manifest.files)) {
|
|
1098
1369
|
if (!visited.has(filePath)) {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if (baseEntries.length > 0) {
|
|
1103
|
-
baseText = Buffer.from(baseEntries[0].content_base64, 'base64').toString('utf8');
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
catch {
|
|
1107
|
-
// Estimate
|
|
1108
|
-
}
|
|
1109
|
-
const baseLines = baseText.split('\n');
|
|
1110
|
-
diffFiles.push({ path: filePath, additions: 0, deletions: baseLines.length });
|
|
1111
|
-
patchParts.push(buildUnifiedDiff(filePath, baseText, ''));
|
|
1370
|
+
baseShaByPath.set(filePath, entry.sha);
|
|
1371
|
+
const baseText = await loadBase(filePath, entry);
|
|
1372
|
+
appendFileDiff(diffFiles, patchParts, filePath, baseText, '', 'delete');
|
|
1112
1373
|
}
|
|
1113
1374
|
}
|
|
1114
1375
|
diffFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
1115
1376
|
const changedPaths = diffFiles.map((f) => f.path);
|
|
1116
1377
|
const patch = patchParts.join('');
|
|
1117
|
-
return { changedPaths, diffFiles, patch };
|
|
1118
|
-
}
|
|
1119
|
-
/**
|
|
1120
|
-
function
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1378
|
+
return { changedPaths, diffFiles, patch, baseShaByPath };
|
|
1379
|
+
}
|
|
1380
|
+
/** Append one file's real (Myers) unified diff and +/- counts. */
|
|
1381
|
+
function appendFileDiff(diffFiles, patchParts, filePath, oldText, newText, action) {
|
|
1382
|
+
const hunks = structuredPatch(filePath, filePath, oldText, newText, undefined, undefined, {
|
|
1383
|
+
context: 3,
|
|
1384
|
+
}).hunks;
|
|
1385
|
+
let additions = 0;
|
|
1386
|
+
let deletions = 0;
|
|
1125
1387
|
const parts = [`diff --git a/${filePath} b/${filePath}\n`];
|
|
1126
|
-
if (
|
|
1388
|
+
if (action === 'create') {
|
|
1127
1389
|
parts.push('new file mode 100644\n');
|
|
1128
1390
|
}
|
|
1129
|
-
else if (
|
|
1391
|
+
else if (action === 'delete') {
|
|
1130
1392
|
parts.push('deleted file mode 100644\n');
|
|
1131
1393
|
}
|
|
1132
|
-
parts.push(`--- ${
|
|
1133
|
-
parts.push(`+++ ${
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1394
|
+
parts.push(`--- ${action === 'create' ? '/dev/null' : `a/${filePath}`}\n`);
|
|
1395
|
+
parts.push(`+++ ${action === 'delete' ? '/dev/null' : `b/${filePath}`}\n`);
|
|
1396
|
+
for (const hunk of hunks) {
|
|
1397
|
+
parts.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`);
|
|
1398
|
+
for (const line of hunk.lines) {
|
|
1399
|
+
parts.push(`${line}\n`);
|
|
1400
|
+
if (line.startsWith('+'))
|
|
1401
|
+
additions++;
|
|
1402
|
+
else if (line.startsWith('-'))
|
|
1403
|
+
deletions++;
|
|
1404
|
+
}
|
|
1137
1405
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1406
|
+
diffFiles.push({ path: filePath, action, additions, deletions });
|
|
1407
|
+
// An empty-file create/delete produces zero hunks but is still a real
|
|
1408
|
+
// change — emit the header-only entry so the patch lists every changed
|
|
1409
|
+
// path. Only a hunk-less UPDATE (identical content, e.g. mode-only noise)
|
|
1410
|
+
// is genuinely nothing to show.
|
|
1411
|
+
if (hunks.length > 0 || action !== 'update') {
|
|
1412
|
+
patchParts.push(parts.join(''));
|
|
1140
1413
|
}
|
|
1141
|
-
return parts.join('');
|
|
1142
|
-
}
|
|
1143
|
-
/** Estimate number of lines from file size (rough: ~40 bytes per line). */
|
|
1144
|
-
function estimateLines(sizeBytes) {
|
|
1145
|
-
return Math.max(1, Math.round(sizeBytes / 40));
|
|
1146
1414
|
}
|
|
1147
1415
|
// ---------------------------------------------------------------------------
|
|
1148
1416
|
// File system helpers
|
|
@@ -1229,10 +1497,7 @@ async function resolveLocalWikiMount(ctx, wiki) {
|
|
|
1229
1497
|
if (!(await pathExists(root))) {
|
|
1230
1498
|
return null;
|
|
1231
1499
|
}
|
|
1232
|
-
const baseUrl = (ctx
|
|
1233
|
-
process.env.PRLL_WIKI_URL ??
|
|
1234
|
-
process.env.PRLL_API_URL ??
|
|
1235
|
-
'').replace(/\/+$/, '');
|
|
1500
|
+
const baseUrl = resolveWikiServiceBaseUrl(ctx);
|
|
1236
1501
|
const wikiBase = `${baseUrl}/wiki/v1/orgs/${ctx.orgId}/wikis/${wiki.id}`;
|
|
1237
1502
|
return {
|
|
1238
1503
|
root,
|
|
@@ -1244,13 +1509,11 @@ async function resolveLocalWikiMount(ctx, wiki) {
|
|
|
1244
1509
|
changesetsUrl: `${wikiBase}/changesets`,
|
|
1245
1510
|
};
|
|
1246
1511
|
}
|
|
1247
|
-
async function requireLocalWikiMount(ctx, wiki
|
|
1512
|
+
async function requireLocalWikiMount(ctx, wiki) {
|
|
1248
1513
|
const mount = await resolveLocalWikiMount(ctx, wiki);
|
|
1249
1514
|
if (!mount) {
|
|
1250
|
-
throw new Error(`wiki ${wiki.slug}
|
|
1251
|
-
|
|
1252
|
-
if (options.requireWrite && 'read_write' !== 'read_write') {
|
|
1253
|
-
throw new Error(`wiki ${wiki.slug} mount is read-only`);
|
|
1515
|
+
throw new Error(`wiki ${wiki.slug} has no local workspace yet — run \`parall wiki sync\` to download it ` +
|
|
1516
|
+
'(the sync output prints the workspace path)');
|
|
1254
1517
|
}
|
|
1255
1518
|
return mount;
|
|
1256
1519
|
}
|
|
@@ -1539,14 +1802,21 @@ function stripNodeSectionContent(section) {
|
|
|
1539
1802
|
}
|
|
1540
1803
|
function buildIndexNodes(sections) {
|
|
1541
1804
|
const nodes = [];
|
|
1805
|
+
// File-level frontmatter folds into only the first section of each file
|
|
1806
|
+
// (matching the indexed search-indexer's section-0 gating) so a
|
|
1807
|
+
// frontmatter-only query returns one hit per document, not one per section.
|
|
1808
|
+
const seenFile = new Set();
|
|
1542
1809
|
for (const section of sections) {
|
|
1543
|
-
|
|
1810
|
+
const includeFrontmatter = !seenFile.has(section.path);
|
|
1811
|
+
seenFile.add(section.path);
|
|
1812
|
+
nodes.push(buildIndexedNode(section, includeFrontmatter));
|
|
1544
1813
|
}
|
|
1545
1814
|
return nodes;
|
|
1546
1815
|
}
|
|
1547
1816
|
function parseMarkdownFile(wiki, file) {
|
|
1548
1817
|
const lines = file.content.split('\n');
|
|
1549
1818
|
const frontMatterEnd = detectFrontMatterEnd(lines);
|
|
1819
|
+
const fm = parseFrontMatter(lines, frontMatterEnd);
|
|
1550
1820
|
const headings = extractMarkdownHeadings(lines, frontMatterEnd);
|
|
1551
1821
|
const fileTitle = defaultFileTitle(file.path);
|
|
1552
1822
|
if (headings.length === 0) {
|
|
@@ -1560,7 +1830,11 @@ function parseMarkdownFile(wiki, file) {
|
|
|
1560
1830
|
endLine: lines.length,
|
|
1561
1831
|
content: lines.slice(frontMatterEnd).join('\n').trim(),
|
|
1562
1832
|
});
|
|
1563
|
-
|
|
1833
|
+
// Keep an empty-body doc node when it carries frontmatter, so a
|
|
1834
|
+
// frontmatter-only page still exposes its metadata — matching the Go
|
|
1835
|
+
// parser, which always returns the doc section for a no-heading file.
|
|
1836
|
+
const keep = !!docNode.content || hasFrontMatter(fm);
|
|
1837
|
+
return applyFrontMatter(keep ? [docNode] : [], fm);
|
|
1564
1838
|
}
|
|
1565
1839
|
const nodes = [];
|
|
1566
1840
|
const firstHeadingLine = headings[0].line;
|
|
@@ -1597,7 +1871,24 @@ function parseMarkdownFile(wiki, file) {
|
|
|
1597
1871
|
.trim(),
|
|
1598
1872
|
}));
|
|
1599
1873
|
}
|
|
1600
|
-
return nodes;
|
|
1874
|
+
return applyFrontMatter(nodes, fm);
|
|
1875
|
+
}
|
|
1876
|
+
// applyFrontMatter copies file-level frontmatter onto every section parsed from
|
|
1877
|
+
// the file (frontmatter is a document-level property in OKF). Mirrors the Go
|
|
1878
|
+
// implementation in server/pkg/markdown/section.go. No-op when empty.
|
|
1879
|
+
function applyFrontMatter(sections, fm) {
|
|
1880
|
+
if (!hasFrontMatter(fm)) {
|
|
1881
|
+
return sections;
|
|
1882
|
+
}
|
|
1883
|
+
for (const section of sections) {
|
|
1884
|
+
if (fm.type !== undefined)
|
|
1885
|
+
section.type = fm.type;
|
|
1886
|
+
if (fm.description !== undefined)
|
|
1887
|
+
section.description = fm.description;
|
|
1888
|
+
if (fm.tags !== undefined)
|
|
1889
|
+
section.tags = fm.tags;
|
|
1890
|
+
}
|
|
1891
|
+
return sections;
|
|
1601
1892
|
}
|
|
1602
1893
|
function buildNodeSection(wiki, input) {
|
|
1603
1894
|
const content = input.content.trim();
|
|
@@ -1617,11 +1908,14 @@ function buildNodeSection(wiki, input) {
|
|
|
1617
1908
|
content,
|
|
1618
1909
|
};
|
|
1619
1910
|
}
|
|
1620
|
-
function buildIndexedNode(section) {
|
|
1911
|
+
function buildIndexedNode(section, includeFrontmatter) {
|
|
1621
1912
|
const searchText = [
|
|
1622
1913
|
section.path,
|
|
1623
1914
|
section.title,
|
|
1624
1915
|
section.heading_path?.join(' ') ?? '',
|
|
1916
|
+
...(includeFrontmatter
|
|
1917
|
+
? [section.type ?? '', section.description ?? '', section.tags?.join(' ') ?? '']
|
|
1918
|
+
: []),
|
|
1625
1919
|
section.content,
|
|
1626
1920
|
]
|
|
1627
1921
|
.join('\n')
|
|
@@ -1965,16 +2259,22 @@ function extractMarkdownHeadings(lines, startIndex) {
|
|
|
1965
2259
|
}
|
|
1966
2260
|
return headings;
|
|
1967
2261
|
}
|
|
1968
|
-
|
|
2262
|
+
// Exported for unit testing; mirrors server/pkg/markdown/section.go.
|
|
2263
|
+
export function detectFrontMatterEnd(lines) {
|
|
1969
2264
|
if (lines.length === 0) {
|
|
1970
2265
|
return 0;
|
|
1971
2266
|
}
|
|
1972
|
-
|
|
2267
|
+
// Fences must sit at the line start; only trailing spaces / tabs / CR are
|
|
2268
|
+
// allowed (right-trim, NOT trim). An indented ` ---` is legal content — e.g.
|
|
2269
|
+
// inside a YAML block scalar — and must not open or close the block. Mirrors
|
|
2270
|
+
// server/pkg/markdown/section.go and the micromark frontmatter grammar.
|
|
2271
|
+
const rtrim = (s) => s.replace(/[ \t\r]+$/, '');
|
|
2272
|
+
const first = rtrim(lines[0]);
|
|
1973
2273
|
if (first !== '---' && first !== '+++') {
|
|
1974
2274
|
return 0;
|
|
1975
2275
|
}
|
|
1976
2276
|
for (let i = 1; i < lines.length; i += 1) {
|
|
1977
|
-
if (lines[i]
|
|
2277
|
+
if (rtrim(lines[i]) === first) {
|
|
1978
2278
|
return i + 1;
|
|
1979
2279
|
}
|
|
1980
2280
|
}
|
|
@@ -2043,7 +2343,9 @@ function normalizeWikiPath(value) {
|
|
|
2043
2343
|
.replace(/^\/+/, '');
|
|
2044
2344
|
return normalized === '.' ? '' : normalized.replace(/\/+$/, '');
|
|
2045
2345
|
}
|
|
2046
|
-
|
|
2346
|
+
// Exported so the binary-file helpers (wiki-files.ts) normalize repo paths
|
|
2347
|
+
// through the exact same rules as every other CLI path entry point.
|
|
2348
|
+
export function normalizeRequiredWikiPath(value) {
|
|
2047
2349
|
const normalized = normalizeWikiPath(value);
|
|
2048
2350
|
if (!normalized) {
|
|
2049
2351
|
throw new Error('path is required');
|