@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/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
- const LOCAL_NODE_SECTION_ARTIFACT_VERSION = 2;
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
- throw new Error(`wiki not found: ${wikiRef}`);
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: normalizeRequiredWikiPath(options.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; anything else
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 blob is not inline utf-8 (encoding=${blob.encoding}): ${options.path}`);
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
- const permissions = {
263
- readable_prefixes: ['(all server ACL)'],
264
- writable_prefixes: ['(server ACL)'],
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, { requireWrite: true });
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 existing changeset with updated file_changes
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
- * Reads the local manifest and restores all files from the server.
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
- // Count changes before reset
511
+ // Only touch files that actually diverged from the baseline.
417
512
  const changes = await computeLocalChanges(mount);
418
- const changedCount = changes.length;
419
- if (changedCount === 0) {
420
- return { wiki_id: wiki.id, wiki_slug: wiki.slug, files_restored: 0 };
421
- }
422
- // Re-download all manifest files and overwrite local state
423
- const manifestPaths = Object.keys(manifest.files);
424
- if (manifestPaths.length > 0) {
425
- const downloaded = await bulkDownloadFiles(mount.bulkDownloadUrl, requireMountToken(mount), manifestPaths);
426
- for (const entry of downloaded) {
427
- const fullPath = resolvePathInsideRoot(mount.root, entry.path);
428
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
429
- await fs.writeFile(fullPath, Buffer.from(entry.content_base64, 'base64'));
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: changedCount,
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
- if (filePath?.trim()) {
501
- throw new Error('Per-file wiki history is not implemented yet. Omit the path to see org-wide operations.');
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
- * List accessible wikis for this org, sync each via REST
520
- * (manifest comparison + bulk download), and remove stale directories.
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 wikis = await ctx.client.getWikis(ctx.orgId);
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
- await pruneStaleMounts(mountRoot, activeMountPaths);
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.baseUrl ??
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
- function resolveApiToken(ctx) {
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
- /** Compute list of changed files by comparing local disk to local manifest. */
1015
- async function computeLocalChanges(mount) {
1016
- const manifest = await readLocalManifest(mount.root);
1017
- const diffFiles = [];
1018
- const visited = new Set();
1019
- // Walk local files to find modified/added
1020
- await walkLocalFiles(mount.root, mount.root, async (fullPath, relativePath) => {
1021
- if (!matchesAllowedPrefix(relativePath, [])) {
1022
- return;
1023
- }
1024
- visited.add(relativePath);
1025
- const localContent = await fs.readFile(fullPath);
1026
- const localSha = computeGitBlobSHA(localContent);
1027
- const manifestEntry = manifest.files[relativePath];
1028
- if (!manifestEntry) {
1029
- // New file
1030
- const lines = localContent.toString('utf8').split('\n').length;
1031
- diffFiles.push({ path: relativePath, additions: lines, deletions: 0 });
1032
- }
1033
- else if (localSha !== manifestEntry.sha) {
1034
- // Modified file compute line diff estimate
1035
- const localLines = localContent.toString('utf8').split('\n');
1036
- const additions = localLines.length;
1037
- const deletions = estimateLines(manifestEntry.size);
1038
- diffFiles.push({ path: relativePath, additions, deletions });
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
- // Find deleted files (in manifest but not on disk)
1042
- for (const [filePath, entry] of Object.entries(manifest.files)) {
1043
- if (!visited.has(filePath)) {
1044
- const deletions = estimateLines(entry.size);
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
- diffFiles.sort((a, b) => a.path.localeCompare(b.path));
1049
- return diffFiles;
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
- /** Compute diff including unified patch by comparing local files vs manifest base content. */
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 token = requireMountToken(mount);
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
- const localLines = localText.split('\n');
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 — fetch base content from server for diff
1075
- const localText = localContent.toString('utf8');
1076
- let baseText = '';
1077
- try {
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
- let baseText = '';
1100
- try {
1101
- const baseEntries = await bulkDownloadFiles(mount.bulkDownloadUrl, token, [filePath]);
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
- /** Build a minimal unified diff between two text strings. */
1120
- function buildUnifiedDiff(filePath, oldText, newText) {
1121
- const oldLines = oldText ? oldText.split('\n') : [];
1122
- const newLines = newText ? newText.split('\n') : [];
1123
- const aPath = oldText ? `a/${filePath}` : '/dev/null';
1124
- const bPath = newText ? `b/${filePath}` : '/dev/null';
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 (!oldText) {
1388
+ if (action === 'create') {
1127
1389
  parts.push('new file mode 100644\n');
1128
1390
  }
1129
- else if (!newText) {
1391
+ else if (action === 'delete') {
1130
1392
  parts.push('deleted file mode 100644\n');
1131
1393
  }
1132
- parts.push(`--- ${aPath}\n`);
1133
- parts.push(`+++ ${bPath}\n`);
1134
- parts.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@\n`);
1135
- for (const line of oldLines) {
1136
- parts.push(`-${line}\n`);
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
- for (const line of newLines) {
1139
- parts.push(`+${line}\n`);
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.baseUrl ??
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, options = {}) {
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} is not available in a local agent mount`);
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
- nodes.push(buildIndexedNode(section));
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
- return !docNode.content ? [] : [docNode];
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
- function detectFrontMatterEnd(lines) {
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
- const first = lines[0].trim();
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].trim() === first) {
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
- function normalizeRequiredWikiPath(value) {
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');