@magic-markdown/cli 0.3.13 → 0.3.18
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/index.js +940 -379
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -270,8 +270,8 @@ var require_punycode = __commonJS({
|
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
// src/index.ts
|
|
273
|
-
import { readFile as
|
|
274
|
-
import { resolve as
|
|
273
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
274
|
+
import { resolve as resolve6 } from "node:path";
|
|
275
275
|
|
|
276
276
|
// ../core/src/types.ts
|
|
277
277
|
var SIDECAR_SCHEMA_VERSION = 1;
|
|
@@ -339,6 +339,7 @@ var POLLUTING_PATTERNS = [
|
|
|
339
339
|
{ name: "CriticMarkup substitution", pattern: /\{~~[\s\S]*?~~\}/ },
|
|
340
340
|
{ name: "CriticMarkup comment", pattern: /\{>>[\s\S]*?<<\}/ },
|
|
341
341
|
{ name: "mdocs HTML marker", pattern: /<!--\s*mdocs:/i },
|
|
342
|
+
{ name: "mdocs suggestion marker", pattern: /<!--\s*\/?\s*mdocs-suggestion\b/i },
|
|
342
343
|
{ name: "mdocs directive", pattern: /:{1,3}(?:comment|suggestion|anchor)\b/i }
|
|
343
344
|
];
|
|
344
345
|
function findCleanMarkdownViolations(markdown) {
|
|
@@ -405,7 +406,7 @@ function replacementLinesForRange(replacement) {
|
|
|
405
406
|
function extractMarkdownImages(markdown, documentPath) {
|
|
406
407
|
const masked = maskCode(markdown);
|
|
407
408
|
const referenceDefinitions = collectReferenceDefinitions(markdown, masked);
|
|
408
|
-
const
|
|
409
|
+
const lineStarts2 = getLineStarts(markdown);
|
|
409
410
|
const images = [];
|
|
410
411
|
let index = 0;
|
|
411
412
|
while (index < masked.length) {
|
|
@@ -423,7 +424,7 @@ function extractMarkdownImages(markdown, documentPath) {
|
|
|
423
424
|
}
|
|
424
425
|
const alt = markdown.slice(labelOpen + 1, labelClose);
|
|
425
426
|
const afterLabel = labelClose + 1;
|
|
426
|
-
const position = positionAt(
|
|
427
|
+
const position = positionAt(lineStarts2, start);
|
|
427
428
|
if (masked[afterLabel] === "(") {
|
|
428
429
|
const destinationClose = findClosingParen(markdown, afterLabel + 1);
|
|
429
430
|
if (destinationClose === -1) {
|
|
@@ -491,7 +492,7 @@ function extractMarkdownLinks(markdown) {
|
|
|
491
492
|
const codeMasked = maskCode(markdown);
|
|
492
493
|
const referenceDefinitions = collectReferenceDefinitions(markdown, codeMasked);
|
|
493
494
|
const masked = maskReferenceDefinitions(markdown, codeMasked);
|
|
494
|
-
const
|
|
495
|
+
const lineStarts2 = getLineStarts(markdown);
|
|
495
496
|
const links = [];
|
|
496
497
|
let index = 0;
|
|
497
498
|
while (index < masked.length) {
|
|
@@ -508,7 +509,7 @@ function extractMarkdownLinks(markdown) {
|
|
|
508
509
|
}
|
|
509
510
|
const label = markdown.slice(start + 1, labelClose);
|
|
510
511
|
const afterLabel = labelClose + 1;
|
|
511
|
-
const position = positionAt(
|
|
512
|
+
const position = positionAt(lineStarts2, start);
|
|
512
513
|
if (masked[afterLabel] === "(") {
|
|
513
514
|
const destinationClose = findClosingParen(markdown, afterLabel + 1);
|
|
514
515
|
if (destinationClose === -1) {
|
|
@@ -564,7 +565,7 @@ function extractMarkdownLinks(markdown) {
|
|
|
564
565
|
}
|
|
565
566
|
index = labelClose + 1;
|
|
566
567
|
}
|
|
567
|
-
extractWikilinks(markdown, masked,
|
|
568
|
+
extractWikilinks(markdown, masked, lineStarts2).forEach((link2) => links.push(link2));
|
|
568
569
|
return links.sort((left, right) => left.line - right.line || left.column - right.column);
|
|
569
570
|
}
|
|
570
571
|
function toImageReference(image2) {
|
|
@@ -591,7 +592,7 @@ function toLinkReference(link2) {
|
|
|
591
592
|
syntax: link2.syntax
|
|
592
593
|
};
|
|
593
594
|
}
|
|
594
|
-
function extractWikilinks(markdown, masked,
|
|
595
|
+
function extractWikilinks(markdown, masked, lineStarts2) {
|
|
595
596
|
const links = [];
|
|
596
597
|
let index = 0;
|
|
597
598
|
while (index < masked.length) {
|
|
@@ -608,7 +609,7 @@ function extractWikilinks(markdown, masked, lineStarts) {
|
|
|
608
609
|
}
|
|
609
610
|
const parsed = parseWikilinkBody(markdown.slice(start + 2, close2));
|
|
610
611
|
if (parsed) {
|
|
611
|
-
const position = positionAt(
|
|
612
|
+
const position = positionAt(lineStarts2, start);
|
|
612
613
|
links.push({
|
|
613
614
|
label: parsed.label ?? parsed.target,
|
|
614
615
|
target: parsed.target,
|
|
@@ -888,12 +889,12 @@ function getLineStarts(value) {
|
|
|
888
889
|
}
|
|
889
890
|
return starts;
|
|
890
891
|
}
|
|
891
|
-
function positionAt(
|
|
892
|
+
function positionAt(lineStarts2, index) {
|
|
892
893
|
let low = 0;
|
|
893
|
-
let high =
|
|
894
|
+
let high = lineStarts2.length - 1;
|
|
894
895
|
while (low <= high) {
|
|
895
896
|
const middle = Math.floor((low + high) / 2);
|
|
896
|
-
const start =
|
|
897
|
+
const start = lineStarts2[middle] ?? 0;
|
|
897
898
|
if (start <= index) {
|
|
898
899
|
low = middle + 1;
|
|
899
900
|
} else {
|
|
@@ -903,7 +904,7 @@ function positionAt(lineStarts, index) {
|
|
|
903
904
|
const lineIndex = Math.max(0, high);
|
|
904
905
|
return {
|
|
905
906
|
line: lineIndex + 1,
|
|
906
|
-
column: index - (
|
|
907
|
+
column: index - (lineStarts2[lineIndex] ?? 0) + 1
|
|
907
908
|
};
|
|
908
909
|
}
|
|
909
910
|
|
|
@@ -979,270 +980,455 @@ function contextSuffix(value) {
|
|
|
979
980
|
return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") ?? "";
|
|
980
981
|
}
|
|
981
982
|
|
|
982
|
-
// ../core/src/
|
|
983
|
-
var
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
regionsB = diffTextRegions(baseLines, theirLines);
|
|
996
|
-
} catch {
|
|
997
|
-
return { ok: false, reason: "too_large" };
|
|
998
|
-
}
|
|
999
|
-
const clusters = clusterRegions(regionsA, regionsB);
|
|
1000
|
-
const merged = [];
|
|
1001
|
-
let baseCursor = 0;
|
|
1002
|
-
for (const cluster of clusters) {
|
|
1003
|
-
for (let line = baseCursor; line < cluster.lo; line += 1) merged.push(baseLines[line]);
|
|
1004
|
-
const baseText = baseLines.slice(cluster.lo, cluster.hi);
|
|
1005
|
-
const ourText = sideSlice(ourLines, regionsA, cluster.lo, cluster.hi);
|
|
1006
|
-
const theirText = sideSlice(theirLines, regionsB, cluster.lo, cluster.hi);
|
|
1007
|
-
const oursChanged = !linesEqual(ourText, baseText);
|
|
1008
|
-
const theirsChanged = !linesEqual(theirText, baseText);
|
|
1009
|
-
if (oursChanged && theirsChanged && !linesEqual(ourText, theirText)) {
|
|
1010
|
-
return { ok: false, reason: "overlapping_changes" };
|
|
1011
|
-
}
|
|
1012
|
-
merged.push(...oursChanged ? ourText : theirText);
|
|
1013
|
-
baseCursor = cluster.hi;
|
|
1014
|
-
}
|
|
1015
|
-
for (let line = baseCursor; line < baseLines.length; line += 1) merged.push(baseLines[line]);
|
|
1016
|
-
return { ok: true, text: merged.join("\n") };
|
|
1017
|
-
}
|
|
1018
|
-
function diffTextRegions(base, side) {
|
|
1019
|
-
let prefix = 0;
|
|
1020
|
-
while (prefix < base.length && prefix < side.length && base[prefix] === side[prefix]) prefix += 1;
|
|
1021
|
-
let suffix = 0;
|
|
1022
|
-
while (suffix < base.length - prefix && suffix < side.length - prefix && base[base.length - 1 - suffix] === side[side.length - 1 - suffix]) {
|
|
1023
|
-
suffix += 1;
|
|
1024
|
-
}
|
|
1025
|
-
const baseCore = base.slice(prefix, base.length - suffix);
|
|
1026
|
-
const sideCore = side.slice(prefix, side.length - suffix);
|
|
1027
|
-
if (!baseCore.length && !sideCore.length) return [];
|
|
1028
|
-
if ((baseCore.length + 1) * (sideCore.length + 1) > MAX_DIFF_CELLS) throw new Error("diff too large");
|
|
1029
|
-
const rows = baseCore.length + 1;
|
|
1030
|
-
const cols = sideCore.length + 1;
|
|
1031
|
-
const lcs = new Uint32Array(rows * cols);
|
|
1032
|
-
for (let row2 = baseCore.length - 1; row2 >= 0; row2 -= 1) {
|
|
1033
|
-
for (let col2 = sideCore.length - 1; col2 >= 0; col2 -= 1) {
|
|
1034
|
-
lcs[row2 * cols + col2] = baseCore[row2] === sideCore[col2] ? lcs[(row2 + 1) * cols + col2 + 1] + 1 : Math.max(lcs[(row2 + 1) * cols + col2], lcs[row2 * cols + col2 + 1]);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
const regions = [];
|
|
1038
|
-
let row = 0;
|
|
1039
|
-
let col = 0;
|
|
1040
|
-
let pendingBase = -1;
|
|
1041
|
-
let pendingSide = -1;
|
|
1042
|
-
const flush = (baseEnd, sideEnd) => {
|
|
1043
|
-
if (pendingBase < 0) return;
|
|
1044
|
-
regions.push({
|
|
1045
|
-
baseStart: prefix + pendingBase,
|
|
1046
|
-
baseLength: baseEnd - pendingBase,
|
|
1047
|
-
sideStart: prefix + pendingSide,
|
|
1048
|
-
sideLength: sideEnd - pendingSide
|
|
1049
|
-
});
|
|
1050
|
-
pendingBase = -1;
|
|
1051
|
-
pendingSide = -1;
|
|
1052
|
-
};
|
|
1053
|
-
while (row < baseCore.length || col < sideCore.length) {
|
|
1054
|
-
if (row < baseCore.length && col < sideCore.length && baseCore[row] === sideCore[col]) {
|
|
1055
|
-
flush(row, col);
|
|
1056
|
-
row += 1;
|
|
1057
|
-
col += 1;
|
|
1058
|
-
} else {
|
|
1059
|
-
if (pendingBase < 0) {
|
|
1060
|
-
pendingBase = row;
|
|
1061
|
-
pendingSide = col;
|
|
1062
|
-
}
|
|
1063
|
-
if (col >= sideCore.length || row < baseCore.length && lcs[(row + 1) * cols + col] >= lcs[row * cols + col + 1]) {
|
|
1064
|
-
row += 1;
|
|
1065
|
-
} else {
|
|
1066
|
-
col += 1;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
flush(row, col);
|
|
1071
|
-
return regions;
|
|
1072
|
-
}
|
|
1073
|
-
function clusterRegions(regionsA, regionsB) {
|
|
1074
|
-
const spans = [...regionsA, ...regionsB].map((region) => ({ lo: region.baseStart, hi: region.baseStart + region.baseLength })).sort((left, right) => left.lo - right.lo || left.hi - right.hi);
|
|
1075
|
-
const clusters = [];
|
|
1076
|
-
for (const span of spans) {
|
|
1077
|
-
const last = clusters.at(-1);
|
|
1078
|
-
if (last && span.lo <= last.hi) {
|
|
1079
|
-
last.hi = Math.max(last.hi, span.hi);
|
|
1080
|
-
} else {
|
|
1081
|
-
clusters.push({ lo: span.lo, hi: span.hi });
|
|
983
|
+
// ../core/src/review-markdown.ts
|
|
984
|
+
var START_MARKER = "<!--mdocs-suggestion";
|
|
985
|
+
var END_MARKER = "<!--/mdocs-suggestion-->";
|
|
986
|
+
function parseReviewMarkdown(reviewMarkdown) {
|
|
987
|
+
const segments = [];
|
|
988
|
+
const suggestions = [];
|
|
989
|
+
let cursor = 0;
|
|
990
|
+
let canonicalOffset = 0;
|
|
991
|
+
while (cursor < reviewMarkdown.length) {
|
|
992
|
+
const markerStart = reviewMarkdown.indexOf(START_MARKER, cursor);
|
|
993
|
+
if (markerStart === -1) {
|
|
994
|
+
appendTextSegment(reviewMarkdown, segments, cursor, reviewMarkdown.length, canonicalOffset);
|
|
995
|
+
break;
|
|
1082
996
|
}
|
|
997
|
+
appendTextSegment(reviewMarkdown, segments, cursor, markerStart, canonicalOffset);
|
|
998
|
+
canonicalOffset += markerStart - cursor;
|
|
999
|
+
const startClose = reviewMarkdown.indexOf("-->", markerStart);
|
|
1000
|
+
if (startClose === -1) throw invalidReviewMarkdown("Unclosed mdocs suggestion marker.");
|
|
1001
|
+
const attrs2 = parseMarkerAttributes(reviewMarkdown.slice(markerStart + START_MARKER.length, startClose));
|
|
1002
|
+
if (!attrs2.id) throw invalidReviewMarkdown("Suggestion marker is missing an id.");
|
|
1003
|
+
if (attrs2.kind !== "insert" && attrs2.kind !== "delete" && attrs2.kind !== "replace") {
|
|
1004
|
+
throw invalidReviewMarkdown(`Suggestion ${attrs2.id} has an invalid kind.`);
|
|
1005
|
+
}
|
|
1006
|
+
const bodyStart = startClose + 3;
|
|
1007
|
+
const markerEnd = reviewMarkdown.indexOf(END_MARKER, bodyStart);
|
|
1008
|
+
if (markerEnd === -1) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} is missing its closing marker.`);
|
|
1009
|
+
const body = reviewMarkdown.slice(bodyStart, markerEnd);
|
|
1010
|
+
if (body.includes(START_MARKER)) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} contains a nested suggestion.`);
|
|
1011
|
+
const parsedBody = parseSuggestionBody(attrs2.kind, body, attrs2.id);
|
|
1012
|
+
const token = {
|
|
1013
|
+
id: attrs2.id,
|
|
1014
|
+
kind: attrs2.kind,
|
|
1015
|
+
form: parsedBody.before.includes("\n") || parsedBody.after.includes("\n") ? "block" : "inline",
|
|
1016
|
+
author: actorFromAttributes(attrs2),
|
|
1017
|
+
message: attrs2.message,
|
|
1018
|
+
createdAt: attrs2.createdAt,
|
|
1019
|
+
updatedAt: attrs2.updatedAt,
|
|
1020
|
+
before: parsedBody.before,
|
|
1021
|
+
after: parsedBody.after,
|
|
1022
|
+
markerStart,
|
|
1023
|
+
markerEnd: markerEnd + END_MARKER.length,
|
|
1024
|
+
canonicalStart: canonicalOffset,
|
|
1025
|
+
canonicalEnd: canonicalOffset + parsedBody.before.length,
|
|
1026
|
+
raw: reviewMarkdown.slice(markerStart, markerEnd + END_MARKER.length)
|
|
1027
|
+
};
|
|
1028
|
+
segments.push({ type: "suggestion", token });
|
|
1029
|
+
suggestions.push(token);
|
|
1030
|
+
canonicalOffset += token.before.length;
|
|
1031
|
+
cursor = token.markerEnd;
|
|
1083
1032
|
}
|
|
1084
|
-
return
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1033
|
+
return {
|
|
1034
|
+
reviewMarkdown,
|
|
1035
|
+
canonicalMarkdown: canonicalFromSegments(segments, statusLookup(void 0)),
|
|
1036
|
+
segments,
|
|
1037
|
+
suggestions
|
|
1038
|
+
};
|
|
1088
1039
|
}
|
|
1089
|
-
function
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1040
|
+
function projectCanonicalMarkdown(reviewMarkdown, sidecarStatuses) {
|
|
1041
|
+
return canonicalFromSegments(parseReviewMarkdown(reviewMarkdown).segments, statusLookup(sidecarStatuses));
|
|
1042
|
+
}
|
|
1043
|
+
function deriveSidecarFromReviewMarkdown(sidecar, reviewMarkdown, options) {
|
|
1044
|
+
const parsed = parseReviewMarkdown(reviewMarkdown);
|
|
1045
|
+
const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1046
|
+
const existingSuggestions = new Map(sidecar.suggestions.map((suggestion) => [suggestion.id, suggestion]));
|
|
1047
|
+
const existingAnchors = new Map(sidecar.anchors.map((anchor) => [anchor.id, anchor]));
|
|
1048
|
+
const anchors = sidecar.anchors.filter((anchor) => anchor.kind !== "suggestion");
|
|
1049
|
+
const suggestions = [];
|
|
1050
|
+
const base = { contentHash: contentHashForText(parsed.canonicalMarkdown), ...options.baseHead ? { head: options.baseHead } : {} };
|
|
1051
|
+
for (const token of parsed.suggestions) {
|
|
1052
|
+
const existing = existingSuggestions.get(token.id);
|
|
1053
|
+
const anchorId = existing?.anchorId ?? stableAnchorIdForSuggestion(token.id);
|
|
1054
|
+
const range = lineRangeForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
|
|
1055
|
+
const selector = selectorForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
|
|
1056
|
+
const anchor = {
|
|
1057
|
+
...existingAnchors.get(anchorId) ?? {},
|
|
1058
|
+
id: anchorId,
|
|
1059
|
+
kind: "suggestion",
|
|
1060
|
+
status: "mapped",
|
|
1061
|
+
selector,
|
|
1062
|
+
range,
|
|
1063
|
+
confidence: 1,
|
|
1064
|
+
updatedAt: now
|
|
1065
|
+
};
|
|
1066
|
+
const author = token.author ?? existing?.author ?? options.author;
|
|
1067
|
+
const message = token.message ?? existing?.message ?? "Suggested edit";
|
|
1068
|
+
const suggestion = {
|
|
1069
|
+
...existing ?? {},
|
|
1070
|
+
id: token.id,
|
|
1071
|
+
anchorId,
|
|
1072
|
+
status: "open",
|
|
1073
|
+
kind: token.kind,
|
|
1074
|
+
author,
|
|
1075
|
+
message,
|
|
1076
|
+
patch: {
|
|
1077
|
+
type: "replace_lines",
|
|
1078
|
+
range,
|
|
1079
|
+
before: token.before,
|
|
1080
|
+
after: token.after
|
|
1081
|
+
},
|
|
1082
|
+
base: existing?.base ?? base,
|
|
1083
|
+
diff: {
|
|
1084
|
+
before: token.before,
|
|
1085
|
+
after: token.after,
|
|
1086
|
+
summary: diffSummary(token.before, token.after)
|
|
1087
|
+
},
|
|
1088
|
+
projectionStatus: "clean",
|
|
1089
|
+
reviewRange: { start: token.markerStart, end: token.markerEnd },
|
|
1090
|
+
createdAt: token.createdAt ?? existing?.createdAt ?? now,
|
|
1091
|
+
updatedAt: token.updatedAt ?? now
|
|
1092
|
+
};
|
|
1093
|
+
anchors.push(anchor);
|
|
1094
|
+
suggestions.push(suggestion);
|
|
1096
1095
|
}
|
|
1097
|
-
return
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1096
|
+
return {
|
|
1097
|
+
...sidecar,
|
|
1098
|
+
reviewMarkdown,
|
|
1099
|
+
anchors,
|
|
1100
|
+
suggestions,
|
|
1101
|
+
updatedAt: now
|
|
1102
|
+
};
|
|
1101
1103
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
`Suggestion ${suggestion.id} is not open (status: ${suggestion.status}).`,
|
|
1109
|
-
"Only open suggestions can be applied. List open suggestions with the suggestions command."
|
|
1110
|
-
);
|
|
1111
|
-
}
|
|
1112
|
-
const context = applySuggestionContext(input);
|
|
1113
|
-
const baseResult = applySuggestionFromBase(markdown, suggestion, context.baseMarkdown);
|
|
1114
|
-
if (baseResult.kind === "applied") return baseResult.markdown;
|
|
1115
|
-
if (baseResult.kind === "conflict") {
|
|
1104
|
+
function createSuggestionMarkup(reviewMarkdown, patch, metadata) {
|
|
1105
|
+
const parsed = parseReviewMarkdown(reviewMarkdown);
|
|
1106
|
+
assertNoDuplicateSuggestionId(parsed, metadata.suggestionId);
|
|
1107
|
+
const [contentStart, contentEnd] = lineRangeOffsets(parsed.canonicalMarkdown, patch.range);
|
|
1108
|
+
const selected = parsed.canonicalMarkdown.slice(contentStart, contentEnd);
|
|
1109
|
+
if (selected !== patch.before) {
|
|
1116
1110
|
throw new MdocsError(
|
|
1117
1111
|
"conflict",
|
|
1118
|
-
|
|
1119
|
-
"
|
|
1112
|
+
"Suggestion target does not match the review document projection.",
|
|
1113
|
+
"Re-read the document and create the suggestion against the current canonical text."
|
|
1120
1114
|
);
|
|
1121
1115
|
}
|
|
1122
|
-
const
|
|
1123
|
-
if (
|
|
1116
|
+
const [canonicalStart, canonicalEnd] = lineRangeReplacementOffsets(parsed.canonicalMarkdown, patch.range);
|
|
1117
|
+
if (selectionTouchesSuggestion(parsed.suggestions, canonicalStart, canonicalEnd)) {
|
|
1124
1118
|
throw new MdocsError(
|
|
1125
1119
|
"conflict",
|
|
1126
|
-
|
|
1127
|
-
"
|
|
1120
|
+
"Suggestion overlaps an unresolved suggestion.",
|
|
1121
|
+
"Accept or reject the existing suggestion before creating another edit over the same text."
|
|
1128
1122
|
);
|
|
1129
1123
|
}
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1124
|
+
const rawStart = rawOffsetForCanonicalOffset(parsed.segments, canonicalStart);
|
|
1125
|
+
const rawEnd = rawOffsetForCanonicalOffset(parsed.segments, canonicalEnd);
|
|
1126
|
+
const before = parsed.canonicalMarkdown.slice(canonicalStart, canonicalEnd);
|
|
1127
|
+
const after = replacementTextForSpan(parsed.canonicalMarkdown, patch, canonicalStart, canonicalEnd);
|
|
1128
|
+
const marker = formatSuggestionMarker(metadata, kindForText(before, after), before, after);
|
|
1129
|
+
const nextReviewMarkdown = `${reviewMarkdown.slice(0, rawStart)}${marker}${reviewMarkdown.slice(rawEnd)}`;
|
|
1130
|
+
const token = parseReviewMarkdown(nextReviewMarkdown).suggestions.find((candidate) => candidate.id === metadata.suggestionId);
|
|
1131
|
+
if (!token) throw invalidReviewMarkdown(`Failed to create suggestion ${metadata.suggestionId}.`);
|
|
1132
|
+
return { reviewMarkdown: nextReviewMarkdown, token };
|
|
1133
|
+
}
|
|
1134
|
+
function projectReviewMarkdown(canonicalMarkdown, suggestions) {
|
|
1135
|
+
let reviewMarkdown = canonicalMarkdown;
|
|
1136
|
+
const open = suggestions.filter((suggestion) => suggestion.status === "open" && suggestion.patch?.type === "replace_lines").slice().sort((left, right) => left.patch.range.startLine - right.patch.range.startLine);
|
|
1137
|
+
for (const suggestion of open) {
|
|
1138
|
+
try {
|
|
1139
|
+
reviewMarkdown = createSuggestionMarkup(reviewMarkdown, suggestion.patch, {
|
|
1140
|
+
suggestionId: suggestion.id,
|
|
1141
|
+
author: suggestion.author,
|
|
1142
|
+
message: suggestion.message,
|
|
1143
|
+
createdAt: suggestion.createdAt,
|
|
1144
|
+
updatedAt: suggestion.updatedAt
|
|
1145
|
+
}).reviewMarkdown;
|
|
1146
|
+
} catch {
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return reviewMarkdown;
|
|
1133
1150
|
}
|
|
1134
|
-
function
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
function applySuggestionFromBase(markdown, suggestion, baseMarkdown) {
|
|
1140
|
-
if (!baseMarkdown || !suggestion.base?.contentHash) return { kind: "unavailable" };
|
|
1141
|
-
if (contentHashForText(baseMarkdown) !== suggestion.base.contentHash) return { kind: "unavailable" };
|
|
1142
|
-
if (!rangeIsWithin(baseMarkdown, suggestion.patch.range) || extractLineRange(baseMarkdown, suggestion.patch.range) !== suggestion.patch.before) {
|
|
1143
|
-
return { kind: "conflict" };
|
|
1144
|
-
}
|
|
1145
|
-
const rebased = rebaseSuggestionPatch(baseMarkdown, markdown, suggestion);
|
|
1146
|
-
if (rebased !== void 0) return { kind: "applied", markdown: rebased };
|
|
1147
|
-
const suggestedMarkdown = replaceLineRange(baseMarkdown, suggestion.patch.range, suggestion.patch.after);
|
|
1148
|
-
assertCleanMarkdown(suggestedMarkdown);
|
|
1149
|
-
const merged = mergeText(baseMarkdown, markdown, suggestedMarkdown);
|
|
1150
|
-
if (!merged.ok) return { kind: "conflict" };
|
|
1151
|
-
assertCleanMarkdown(merged.text);
|
|
1152
|
-
return { kind: "applied", markdown: merged.text };
|
|
1153
|
-
}
|
|
1154
|
-
function rebaseSuggestionPatch(baseMarkdown, markdown, suggestion) {
|
|
1155
|
-
const baseLines = getLines(baseMarkdown);
|
|
1156
|
-
const currentLines = getLines(markdown);
|
|
1157
|
-
let regions;
|
|
1158
|
-
try {
|
|
1159
|
-
regions = diffTextRegions(baseLines, currentLines);
|
|
1160
|
-
} catch {
|
|
1161
|
-
return void 0;
|
|
1151
|
+
function resolveSuggestionMarkup(reviewMarkdown, suggestionId, resolution) {
|
|
1152
|
+
const parsed = parseReviewMarkdown(reviewMarkdown);
|
|
1153
|
+
const token = parsed.suggestions.find((candidate) => candidate.id === suggestionId);
|
|
1154
|
+
if (!token) {
|
|
1155
|
+
throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "Refresh review state and try again.");
|
|
1162
1156
|
}
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
if (regions.some((region) => regionTouchesSuggestionRange(region, baseStart, baseEnd))) return void 0;
|
|
1166
|
-
const currentStart = baseStart + sideDeltaBefore(regions, baseStart, true);
|
|
1167
|
-
const currentEnd = baseEnd + sideDeltaBefore(regions, baseEnd, false);
|
|
1168
|
-
const currentRange = { startLine: currentStart + 1, endLine: currentEnd };
|
|
1169
|
-
if (!rangeIsWithin(markdown, currentRange)) return void 0;
|
|
1170
|
-
if (extractLineRange(markdown, currentRange) !== suggestion.patch.before) return void 0;
|
|
1171
|
-
const next = replaceLineRange(markdown, currentRange, suggestion.patch.after);
|
|
1172
|
-
assertCleanMarkdown(next);
|
|
1173
|
-
return next;
|
|
1157
|
+
const resolved = resolutionText(token, resolution);
|
|
1158
|
+
return `${reviewMarkdown.slice(0, token.markerStart)}${resolved}${reviewMarkdown.slice(token.markerEnd)}`;
|
|
1174
1159
|
}
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1177
|
-
const regionEnd = region.baseStart + region.baseLength;
|
|
1178
|
-
return region.baseStart < baseEnd && regionEnd > baseStart;
|
|
1179
|
-
}
|
|
1180
|
-
function currentSuggestionRange(markdown, suggestion, anchor) {
|
|
1181
|
-
const anchoredRange = currentAnchorRange(markdown, suggestion, anchor);
|
|
1182
|
-
if (anchoredRange) return anchoredRange;
|
|
1183
|
-
if (anchor && !anchorIsReliable(anchor)) return void 0;
|
|
1184
|
-
const hintedRange = suggestion.patch.range;
|
|
1185
|
-
if (rangeIsWithin(markdown, hintedRange) && extractLineRange(markdown, hintedRange) === suggestion.patch.before) return hintedRange;
|
|
1186
|
-
const candidates = findBeforeCandidates(markdown, suggestion.patch.before);
|
|
1187
|
-
if (candidates.length === 0) return void 0;
|
|
1188
|
-
if (candidates.length === 1) return candidates[0];
|
|
1189
|
-
if (!anchor) return void 0;
|
|
1190
|
-
const scored = candidates.map((range) => ({ range, score: scoreContext2(markdown, range, anchor) })).sort((left, right) => right.score - left.score);
|
|
1191
|
-
const best = scored[0];
|
|
1192
|
-
if (!best) return void 0;
|
|
1193
|
-
const tied = scored.filter((candidate) => candidate.score === best.score);
|
|
1194
|
-
if (tied.length > 1) return void 0;
|
|
1195
|
-
if (best.score < 0.7) return void 0;
|
|
1196
|
-
return best.range;
|
|
1160
|
+
function escapeReviewSuggestionText(value) {
|
|
1161
|
+
return value.replaceAll("\\", "\\\\").replaceAll("{++", "\\{++").replaceAll("++}", "\\++}").replaceAll("{--", "\\{--").replaceAll("--}", "\\--}").replaceAll("{~~", "\\{~~").replaceAll("~>", "\\~>").replaceAll("~~}", "\\~~}");
|
|
1197
1162
|
}
|
|
1198
|
-
function
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
return extractLineRange(markdown, anchor.range) === suggestion.patch.before ? anchor.range : void 0;
|
|
1163
|
+
function lineRangeForCanonicalSpan(markdown, start, end) {
|
|
1164
|
+
assertCanonicalSpan(markdown, start, end);
|
|
1165
|
+
return lineRangeForOffsets(markdown, start, end);
|
|
1202
1166
|
}
|
|
1203
|
-
function
|
|
1204
|
-
|
|
1167
|
+
function selectorForCanonicalSpan(markdown, start, end) {
|
|
1168
|
+
assertCanonicalSpan(markdown, start, end);
|
|
1169
|
+
const context = 160;
|
|
1170
|
+
const prefix = markdown.slice(Math.max(0, start - context), start);
|
|
1171
|
+
const suffix = markdown.slice(end, Math.min(markdown.length, end + context));
|
|
1172
|
+
return {
|
|
1173
|
+
quote: markdown.slice(start, end),
|
|
1174
|
+
...prefix ? { prefix } : {},
|
|
1175
|
+
...suffix ? { suffix } : {}
|
|
1176
|
+
};
|
|
1205
1177
|
}
|
|
1206
|
-
function
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1178
|
+
function appendTextSegment(source, segments, start, end, canonicalStart) {
|
|
1179
|
+
if (end <= start) return;
|
|
1180
|
+
segments.push({
|
|
1181
|
+
type: "text",
|
|
1182
|
+
text: source.slice(start, end),
|
|
1183
|
+
start,
|
|
1184
|
+
end,
|
|
1185
|
+
canonicalStart,
|
|
1186
|
+
canonicalEnd: canonicalStart + (end - start)
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
function canonicalFromSegments(segments, statuses) {
|
|
1190
|
+
return segments.map((segment) => {
|
|
1191
|
+
if (segment.type === "text") return segment.text;
|
|
1192
|
+
const status = statuses(segment.token.id);
|
|
1193
|
+
if (status === "accepted") return acceptedText(segment.token);
|
|
1194
|
+
return originalText(segment.token);
|
|
1195
|
+
}).join("");
|
|
1196
|
+
}
|
|
1197
|
+
function parseMarkerAttributes(raw) {
|
|
1198
|
+
const attrs2 = {};
|
|
1199
|
+
const pattern = /\s+([a-zA-Z][\w:-]*)="([^"]*)"/g;
|
|
1200
|
+
let match2;
|
|
1201
|
+
while ((match2 = pattern.exec(raw)) !== null) {
|
|
1202
|
+
const key = match2[1];
|
|
1203
|
+
if (key === "id" || key === "kind" || key === "authorId" || key === "authorKind" || key === "authorName" || key === "authorEmail" || key === "message" || key === "createdAt" || key === "updatedAt") {
|
|
1204
|
+
attrs2[key] = decodeAttribute(match2[2] ?? "");
|
|
1215
1205
|
}
|
|
1216
1206
|
}
|
|
1217
|
-
return
|
|
1207
|
+
return attrs2;
|
|
1218
1208
|
}
|
|
1219
|
-
function
|
|
1220
|
-
|
|
1221
|
-
|
|
1209
|
+
function actorFromAttributes(attrs2) {
|
|
1210
|
+
if (!attrs2.authorId || !attrs2.authorName) return void 0;
|
|
1211
|
+
const kind = attrs2.authorKind === "agent" || attrs2.authorKind === "system" || attrs2.authorKind === "human" ? attrs2.authorKind : "human";
|
|
1212
|
+
return {
|
|
1213
|
+
id: attrs2.authorId,
|
|
1214
|
+
kind,
|
|
1215
|
+
name: attrs2.authorName,
|
|
1216
|
+
...attrs2.authorEmail ? { email: attrs2.authorEmail } : {}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
function parseSuggestionBody(kind, body, id) {
|
|
1220
|
+
if (kind === "insert") {
|
|
1221
|
+
return { before: "", after: parseWrappedBody(body, "{++", "++}", id) };
|
|
1222
|
+
}
|
|
1223
|
+
if (kind === "delete") {
|
|
1224
|
+
return { before: parseWrappedBody(body, "{--", "--}", id), after: "" };
|
|
1225
|
+
}
|
|
1226
|
+
if (!body.startsWith("{~~") || !body.endsWith("~~}")) {
|
|
1227
|
+
throw invalidReviewMarkdown(`Replacement suggestion ${id} has invalid CriticMarkup payload.`);
|
|
1228
|
+
}
|
|
1229
|
+
const inner = body.slice(3, -3);
|
|
1230
|
+
const separator = findUnescaped2(inner, "~>");
|
|
1231
|
+
if (separator === -1) throw invalidReviewMarkdown(`Replacement suggestion ${id} is missing a separator.`);
|
|
1232
|
+
return {
|
|
1233
|
+
before: unescapeReviewSuggestionText(inner.slice(0, separator)),
|
|
1234
|
+
after: unescapeReviewSuggestionText(inner.slice(separator + 2))
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
function parseWrappedBody(body, open, close2, id) {
|
|
1238
|
+
if (!body.startsWith(open) || !body.endsWith(close2) || isEscapedAt(body, body.length - close2.length)) {
|
|
1239
|
+
throw invalidReviewMarkdown(`Suggestion ${id} has invalid CriticMarkup payload.`);
|
|
1240
|
+
}
|
|
1241
|
+
return unescapeReviewSuggestionText(body.slice(open.length, -close2.length));
|
|
1242
|
+
}
|
|
1243
|
+
function formatSuggestionMarker(metadata, kind, before, after) {
|
|
1244
|
+
const attrs2 = [
|
|
1245
|
+
["id", metadata.suggestionId],
|
|
1246
|
+
["kind", kind]
|
|
1247
|
+
];
|
|
1248
|
+
if (metadata.author) {
|
|
1249
|
+
attrs2.push(["authorId", metadata.author.id]);
|
|
1250
|
+
attrs2.push(["authorKind", metadata.author.kind]);
|
|
1251
|
+
attrs2.push(["authorName", metadata.author.name]);
|
|
1252
|
+
if (metadata.author.email) attrs2.push(["authorEmail", metadata.author.email]);
|
|
1253
|
+
}
|
|
1254
|
+
if (metadata.message) attrs2.push(["message", metadata.message]);
|
|
1255
|
+
if (metadata.createdAt) attrs2.push(["createdAt", metadata.createdAt]);
|
|
1256
|
+
if (metadata.updatedAt) attrs2.push(["updatedAt", metadata.updatedAt]);
|
|
1257
|
+
const attrText = attrs2.map(([key, value]) => `${key}="${encodeAttribute(value)}"`).join(" ");
|
|
1258
|
+
const body = kind === "insert" ? `{++${escapeReviewSuggestionText(after)}++}` : kind === "delete" ? `{--${escapeReviewSuggestionText(before)}--}` : `{~~${escapeReviewSuggestionText(before)}~>${escapeReviewSuggestionText(after)}~~}`;
|
|
1259
|
+
return `${START_MARKER} ${attrText} -->${body}${END_MARKER}`;
|
|
1260
|
+
}
|
|
1261
|
+
function stableAnchorIdForSuggestion(suggestionId) {
|
|
1262
|
+
return `anchor_${suggestionId.replace(/[^a-zA-Z0-9_:-]/g, "_")}`;
|
|
1263
|
+
}
|
|
1264
|
+
function diffSummary(before, after) {
|
|
1265
|
+
if (!before && after) return "Inserted text";
|
|
1266
|
+
if (before && !after) return "Deleted text";
|
|
1267
|
+
return "Replaced text";
|
|
1268
|
+
}
|
|
1269
|
+
function unescapeReviewSuggestionText(value) {
|
|
1270
|
+
let result = "";
|
|
1271
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1272
|
+
if (value[index] !== "\\") {
|
|
1273
|
+
result += value[index];
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const next = value[index + 1];
|
|
1277
|
+
if (next === void 0) {
|
|
1278
|
+
result += "\\";
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
result += next;
|
|
1282
|
+
index += 1;
|
|
1283
|
+
}
|
|
1284
|
+
return result;
|
|
1285
|
+
}
|
|
1286
|
+
function findUnescaped2(value, needle, from = 0) {
|
|
1287
|
+
let index = value.indexOf(needle, from);
|
|
1288
|
+
while (index !== -1) {
|
|
1289
|
+
if (!isEscapedAt(value, index)) return index;
|
|
1290
|
+
index = value.indexOf(needle, index + needle.length);
|
|
1291
|
+
}
|
|
1292
|
+
return -1;
|
|
1293
|
+
}
|
|
1294
|
+
function isEscapedAt(value, index) {
|
|
1295
|
+
let slashes = 0;
|
|
1296
|
+
for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) slashes += 1;
|
|
1297
|
+
return slashes % 2 === 1;
|
|
1298
|
+
}
|
|
1299
|
+
function statusLookup(statuses) {
|
|
1300
|
+
if (!statuses) return () => "open";
|
|
1301
|
+
if (isStatusMap(statuses)) return (suggestionId) => statuses.get(suggestionId) ?? "open";
|
|
1302
|
+
if (Array.isArray(statuses)) {
|
|
1303
|
+
const map = /* @__PURE__ */ new Map();
|
|
1304
|
+
for (const item of statuses) {
|
|
1305
|
+
const id = item.id ?? item.suggestionId;
|
|
1306
|
+
if (id && item.status) map.set(id, item.status);
|
|
1307
|
+
}
|
|
1308
|
+
return (suggestionId) => map.get(suggestionId) ?? "open";
|
|
1309
|
+
}
|
|
1310
|
+
const record = statuses;
|
|
1311
|
+
return (suggestionId) => record[suggestionId] ?? "open";
|
|
1312
|
+
}
|
|
1313
|
+
function isStatusMap(value) {
|
|
1314
|
+
return typeof value.get === "function";
|
|
1315
|
+
}
|
|
1316
|
+
function acceptedText(token) {
|
|
1317
|
+
return token.kind === "delete" ? "" : token.after;
|
|
1318
|
+
}
|
|
1319
|
+
function originalText(token) {
|
|
1320
|
+
return token.kind === "insert" ? "" : token.before;
|
|
1321
|
+
}
|
|
1322
|
+
function resolutionText(token, resolution) {
|
|
1323
|
+
return resolution === "accept" ? acceptedText(token) : originalText(token);
|
|
1324
|
+
}
|
|
1325
|
+
function kindForText(before, after) {
|
|
1326
|
+
if (before.length === 0) return "insert";
|
|
1327
|
+
if (after.length === 0) return "delete";
|
|
1328
|
+
return "replace";
|
|
1329
|
+
}
|
|
1330
|
+
function assertNoDuplicateSuggestionId(parsed, suggestionId) {
|
|
1331
|
+
if (parsed.suggestions.some((suggestion) => suggestion.id === suggestionId)) {
|
|
1332
|
+
throw new MdocsError("validation_error", `Duplicate suggestion id: ${suggestionId}`, "Suggestion ids must be stable and unique per document.");
|
|
1333
|
+
}
|
|
1222
1334
|
}
|
|
1223
|
-
function
|
|
1224
|
-
|
|
1335
|
+
function assertCanonicalSpan(markdown, start, end) {
|
|
1336
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > markdown.length) {
|
|
1337
|
+
throw new MdocsError("invalid_range", `Invalid canonical span ${start}:${end}.`, "Re-read the document and try again.");
|
|
1338
|
+
}
|
|
1225
1339
|
}
|
|
1226
|
-
function
|
|
1340
|
+
function selectionTouchesSuggestion(tokens, start, end) {
|
|
1341
|
+
return tokens.some((token) => {
|
|
1342
|
+
if (token.canonicalStart === token.canonicalEnd) return start <= token.canonicalStart && end >= token.canonicalEnd;
|
|
1343
|
+
return start < token.canonicalEnd && end > token.canonicalStart;
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
function rawOffsetForCanonicalOffset(segments, offset) {
|
|
1347
|
+
for (const segment of segments) {
|
|
1348
|
+
if (segment.type === "text") {
|
|
1349
|
+
if (offset >= segment.canonicalStart && offset <= segment.canonicalEnd) {
|
|
1350
|
+
return segment.start + (offset - segment.canonicalStart);
|
|
1351
|
+
}
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const token = segment.token;
|
|
1355
|
+
if (offset > token.canonicalStart && offset < token.canonicalEnd) {
|
|
1356
|
+
throw new MdocsError(
|
|
1357
|
+
"conflict",
|
|
1358
|
+
"Selection falls inside an unresolved suggestion.",
|
|
1359
|
+
"Accept or reject the existing suggestion before editing inside it."
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (offset === segmentsEndCanonicalOffset(segments)) return segmentsEndRawOffset(segments);
|
|
1364
|
+
throw new MdocsError("invalid_range", "Could not map canonical offset into review Markdown.", "Re-read the document and try again.");
|
|
1365
|
+
}
|
|
1366
|
+
function segmentsEndCanonicalOffset(segments) {
|
|
1367
|
+
const last = segments[segments.length - 1];
|
|
1368
|
+
if (!last) return 0;
|
|
1369
|
+
return last.type === "text" ? last.canonicalEnd : last.token.canonicalEnd;
|
|
1370
|
+
}
|
|
1371
|
+
function segmentsEndRawOffset(segments) {
|
|
1372
|
+
const last = segments[segments.length - 1];
|
|
1373
|
+
if (!last) return 0;
|
|
1374
|
+
return last.type === "text" ? last.end : last.token.markerEnd;
|
|
1375
|
+
}
|
|
1376
|
+
function lineRangeOffsets(markdown, range) {
|
|
1227
1377
|
const lines = getLines(markdown);
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
if (expectedPrefix) {
|
|
1231
|
-
const actualPrefix = contextPrefix2(lines.slice(Math.max(0, range.startLine - 4), range.startLine - 1).join("\n"));
|
|
1232
|
-
if (actualPrefix?.endsWith(expectedPrefix)) score += 0.15;
|
|
1378
|
+
if (range.startLine < 1 || range.endLine < range.startLine || range.endLine > lines.length) {
|
|
1379
|
+
throw new MdocsError("invalid_range", `Invalid line range ${range.startLine}:${range.endLine}.`, "Re-read the document and try again.");
|
|
1233
1380
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1381
|
+
let start = 0;
|
|
1382
|
+
for (let index = 0; index < range.startLine - 1; index += 1) start += lines[index].length + 1;
|
|
1383
|
+
let end = start;
|
|
1384
|
+
for (let index = range.startLine - 1; index < range.endLine; index += 1) {
|
|
1385
|
+
if (index > range.startLine - 1) end += 1;
|
|
1386
|
+
end += lines[index].length;
|
|
1387
|
+
}
|
|
1388
|
+
return [start, end];
|
|
1389
|
+
}
|
|
1390
|
+
function lineRangeReplacementOffsets(markdown, range) {
|
|
1391
|
+
const [start, contentEnd] = lineRangeOffsets(markdown, range);
|
|
1392
|
+
if (contentEnd < markdown.length && markdown[contentEnd] === "\n") return [start, contentEnd + 1];
|
|
1393
|
+
return [start, contentEnd];
|
|
1394
|
+
}
|
|
1395
|
+
function replacementTextForSpan(markdown, patch, start, end) {
|
|
1396
|
+
const applied = replaceLineRange(markdown, patch.range, patch.after);
|
|
1397
|
+
const suffixLength = markdown.length - end;
|
|
1398
|
+
const replacementEnd = applied.length - suffixLength;
|
|
1399
|
+
if (replacementEnd < start) return "";
|
|
1400
|
+
return applied.slice(start, replacementEnd);
|
|
1401
|
+
}
|
|
1402
|
+
function lineRangeForOffsets(markdown, start, end) {
|
|
1403
|
+
const starts = lineStarts(markdown);
|
|
1404
|
+
return {
|
|
1405
|
+
startLine: lineNumberAt(starts, start),
|
|
1406
|
+
endLine: lineNumberAt(starts, Math.max(start, end - 1))
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
function lineStarts(markdown) {
|
|
1410
|
+
const starts = [0];
|
|
1411
|
+
for (let index = 0; index < markdown.length; index += 1) {
|
|
1412
|
+
if (markdown[index] === "\n") starts.push(index + 1);
|
|
1238
1413
|
}
|
|
1239
|
-
return
|
|
1414
|
+
return starts;
|
|
1415
|
+
}
|
|
1416
|
+
function lineNumberAt(starts, offset) {
|
|
1417
|
+
let line = 1;
|
|
1418
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
1419
|
+
if (starts[index] <= offset) line = index + 1;
|
|
1420
|
+
else break;
|
|
1421
|
+
}
|
|
1422
|
+
return line;
|
|
1240
1423
|
}
|
|
1241
|
-
function
|
|
1242
|
-
return value
|
|
1424
|
+
function encodeAttribute(value) {
|
|
1425
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1243
1426
|
}
|
|
1244
|
-
function
|
|
1245
|
-
return value
|
|
1427
|
+
function decodeAttribute(value) {
|
|
1428
|
+
return value.replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1429
|
+
}
|
|
1430
|
+
function invalidReviewMarkdown(message) {
|
|
1431
|
+
return new MdocsError("validation_error", `Invalid review Markdown: ${message}`, "Repair or remove the malformed suggestion markup.");
|
|
1246
1432
|
}
|
|
1247
1433
|
|
|
1248
1434
|
// ../core/src/graph.ts
|
|
@@ -1547,6 +1733,7 @@ async function getDocumentState(io, pathOrDocId) {
|
|
|
1547
1733
|
path: entry.path,
|
|
1548
1734
|
title: entry.title,
|
|
1549
1735
|
markdown,
|
|
1736
|
+
reviewMarkdown: nextSidecar.reviewMarkdown ?? markdown,
|
|
1550
1737
|
sidecar: nextSidecar,
|
|
1551
1738
|
anchors: remappedAnchors.map((anchor) => ({
|
|
1552
1739
|
id: anchor.id,
|
|
@@ -1634,6 +1821,7 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
|
|
|
1634
1821
|
id: createId("suggestion"),
|
|
1635
1822
|
anchorId: anchor.id,
|
|
1636
1823
|
status: "open",
|
|
1824
|
+
kind: replacement.length === 0 ? "delete" : before.length === 0 ? "insert" : "replace",
|
|
1637
1825
|
author,
|
|
1638
1826
|
message,
|
|
1639
1827
|
patch: {
|
|
@@ -1650,53 +1838,72 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
|
|
|
1650
1838
|
createdAt: now,
|
|
1651
1839
|
updatedAt: now
|
|
1652
1840
|
};
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1841
|
+
const review = createSuggestionMarkup(state.sidecar.reviewMarkdown ?? state.markdown, suggestion.patch, {
|
|
1842
|
+
suggestionId: suggestion.id,
|
|
1843
|
+
author,
|
|
1844
|
+
message,
|
|
1845
|
+
createdAt: now,
|
|
1657
1846
|
updatedAt: now
|
|
1658
1847
|
});
|
|
1659
|
-
|
|
1848
|
+
const nextSidecar = deriveSidecarFromReviewMarkdown(
|
|
1849
|
+
{ ...state.sidecar, reviewMarkdown: review.reviewMarkdown, anchors: [...state.sidecar.anchors, anchor] },
|
|
1850
|
+
review.reviewMarkdown,
|
|
1851
|
+
{ author, baseHead: entry?.currentSha, now }
|
|
1852
|
+
);
|
|
1853
|
+
await writeSidecar(io, nextSidecar);
|
|
1854
|
+
const enriched = nextSidecar.suggestions.find((candidate) => candidate.id === suggestion.id);
|
|
1855
|
+
if (!enriched) throw new MdocsError("validation_error", `Could not derive suggestion ${suggestion.id}.`, "Refresh review state and try again.");
|
|
1856
|
+
return enriched;
|
|
1660
1857
|
}
|
|
1661
|
-
async function
|
|
1858
|
+
async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
|
|
1662
1859
|
const manifest = await indexWorkspace(io);
|
|
1663
1860
|
for (const entry of manifest.docs) {
|
|
1861
|
+
const markdown = await io.readText(entry.path);
|
|
1664
1862
|
const sidecar = await readSidecar(io, entry.docId);
|
|
1665
1863
|
const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
|
|
1666
1864
|
if (!suggestion) continue;
|
|
1667
1865
|
const now = nowIso();
|
|
1668
|
-
const
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
return updated;
|
|
1866
|
+
const reviewMarkdown = sidecar.reviewMarkdown ?? markdown;
|
|
1867
|
+
const resolvedReviewMarkdown = resolveSuggestionMarkup(reviewMarkdown, suggestionId, "accept");
|
|
1868
|
+
const nextMarkdown = projectCanonicalMarkdown(resolvedReviewMarkdown);
|
|
1869
|
+
assertCleanMarkdown(nextMarkdown);
|
|
1870
|
+
const nextSidecar = deriveSidecarFromReviewMarkdown(
|
|
1871
|
+
{ ...sidecar, reviewMarkdown: resolvedReviewMarkdown },
|
|
1872
|
+
resolvedReviewMarkdown,
|
|
1873
|
+
{ author: suggestion.author, baseHead: entry.currentSha, now }
|
|
1874
|
+
);
|
|
1875
|
+
await io.writeText(entry.path, nextMarkdown);
|
|
1876
|
+
await writeSidecar(io, nextSidecar);
|
|
1877
|
+
return resolvedSuggestion(suggestion, "accepted", actor, now);
|
|
1681
1878
|
}
|
|
1682
1879
|
throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
|
|
1683
1880
|
}
|
|
1684
|
-
async function
|
|
1881
|
+
async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
|
|
1685
1882
|
const manifest = await indexWorkspace(io);
|
|
1686
1883
|
for (const entry of manifest.docs) {
|
|
1687
|
-
const markdown = await io.readText(entry.path);
|
|
1688
1884
|
const sidecar = await readSidecar(io, entry.docId);
|
|
1689
1885
|
const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
|
|
1690
1886
|
if (!suggestion) continue;
|
|
1691
|
-
const
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1887
|
+
const now = nowIso();
|
|
1888
|
+
const updated = resolvedSuggestion(suggestion, "rejected", actor, now);
|
|
1889
|
+
const reviewMarkdown = resolveSuggestionMarkup(sidecar.reviewMarkdown ?? "", suggestionId, "reject");
|
|
1890
|
+
const nextSidecar = deriveSidecarFromReviewMarkdown({ ...sidecar, reviewMarkdown }, reviewMarkdown, {
|
|
1891
|
+
author: suggestion.author,
|
|
1892
|
+
now
|
|
1893
|
+
});
|
|
1894
|
+
await writeSidecar(io, nextSidecar);
|
|
1895
|
+
return updated;
|
|
1695
1896
|
}
|
|
1696
1897
|
throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
|
|
1697
1898
|
}
|
|
1698
|
-
|
|
1699
|
-
return
|
|
1899
|
+
function resolvedSuggestion(suggestion, status, actor, now) {
|
|
1900
|
+
return {
|
|
1901
|
+
...suggestion,
|
|
1902
|
+
status,
|
|
1903
|
+
updatedAt: now,
|
|
1904
|
+
resolvedAt: now,
|
|
1905
|
+
resolvedBy: actor
|
|
1906
|
+
};
|
|
1700
1907
|
}
|
|
1701
1908
|
async function resolveComment(io, commentId, actor = defaultActor()) {
|
|
1702
1909
|
const manifest = await indexWorkspace(io);
|
|
@@ -11056,7 +11263,7 @@ var RemoteDocumentIO = class {
|
|
|
11056
11263
|
};
|
|
11057
11264
|
|
|
11058
11265
|
// src/agent.ts
|
|
11059
|
-
var CLI_VERSION = "0.3.
|
|
11266
|
+
var CLI_VERSION = "0.3.18";
|
|
11060
11267
|
var CLI_PACKAGE_NAME = "@magic-markdown/cli";
|
|
11061
11268
|
var AGENT_COMMANDS = [
|
|
11062
11269
|
{
|
|
@@ -11233,7 +11440,7 @@ var AGENT_COMMANDS = [
|
|
|
11233
11440
|
},
|
|
11234
11441
|
{
|
|
11235
11442
|
name: "suggest",
|
|
11236
|
-
summary: "Create a
|
|
11443
|
+
summary: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown.",
|
|
11237
11444
|
usage: "mdocs suggest <path|docId> --range <start:end> --with <markdown> --message <text> --json",
|
|
11238
11445
|
output: "json",
|
|
11239
11446
|
mutates: true,
|
|
@@ -11343,20 +11550,39 @@ var AGENT_COMMANDS = [
|
|
|
11343
11550
|
{
|
|
11344
11551
|
name: "bridge resume",
|
|
11345
11552
|
summary: "Backfill missed Magic changes after an agent restart, then keep the bridge running.",
|
|
11346
|
-
usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--once]",
|
|
11553
|
+
usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--forever] [--once]",
|
|
11347
11554
|
output: "long-running",
|
|
11348
11555
|
mutates: true,
|
|
11349
11556
|
examples: [
|
|
11350
|
-
"mdocs bridge resume --root . --request-token",
|
|
11557
|
+
"mdocs bridge resume --root . --forever --request-token",
|
|
11351
11558
|
"mdocs bridge resume --root . --once --request-token"
|
|
11352
11559
|
],
|
|
11353
11560
|
notes: [
|
|
11354
11561
|
"Reads non-secret connection defaults from .mdocs/bridge.json written by bridge setup.",
|
|
11355
11562
|
"Use this after a sandbox/container/agent session restarts. It backfills canonical Magic changes before publishing local edits.",
|
|
11563
|
+
"--forever is the intended startup/sandbox command: keep it running for live collaboration and liveness heartbeats.",
|
|
11356
11564
|
"--request-token opens a human approval URL when no MDOCS_BRIDGE_TOKEN is available; do not ask users to paste bridge tokens by default.",
|
|
11357
11565
|
"--once performs only the backfill/reconcile step and exits."
|
|
11358
11566
|
]
|
|
11359
11567
|
},
|
|
11568
|
+
{
|
|
11569
|
+
name: "bridge startup",
|
|
11570
|
+
summary: "Write a bridge runner and optional launchd/systemd startup hook for long-lived bridge environments.",
|
|
11571
|
+
usage: "mdocs bridge startup --root <path> [--mode auto|shell|launchd|systemd] [--install] [--json]",
|
|
11572
|
+
output: "json",
|
|
11573
|
+
mutates: true,
|
|
11574
|
+
examples: [
|
|
11575
|
+
"mdocs bridge startup --root . --json",
|
|
11576
|
+
"mdocs bridge startup --root . --mode shell --json",
|
|
11577
|
+
"mdocs bridge startup --root . --mode systemd --install --json"
|
|
11578
|
+
],
|
|
11579
|
+
notes: [
|
|
11580
|
+
"Run this after bridge setup has written .mdocs/bridge.json.",
|
|
11581
|
+
"Sandbox providers should put the returned startupCommand in their on-start hook.",
|
|
11582
|
+
"--install installs and starts the generated launchd/systemd user service when the host supports it.",
|
|
11583
|
+
"The generated runner stores no bridge token; provide MDOCS_BRIDGE_TOKEN through platform secrets if you use manual tokens."
|
|
11584
|
+
]
|
|
11585
|
+
},
|
|
11360
11586
|
{
|
|
11361
11587
|
name: "bridge",
|
|
11362
11588
|
summary: "Sync a local Markdown root with a Magic workspace over WebSocket (long-running).",
|
|
@@ -11424,7 +11650,7 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
|
|
|
11424
11650
|
4. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --summary --json\` instead. Project-scoped joins cover one root; workspace-scoped joins cover Home and can target duplicate paths as \`<rootId>:<path-or-docId>\`.
|
|
11425
11651
|
5. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
|
|
11426
11652
|
6. For a document, run \`mdocs context <path|docId> --summary --json\` locally or \`mdocs remote context <path|docId> --summary --json\` for a joined share before reading full content. Then page Markdown with \`--start-line\` / \`--end-line\` and \`--no-review\` when you only need document text.
|
|
11427
|
-
7. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not
|
|
11653
|
+
7. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not hand-edit canonical Markdown files or \`.mdocs\` reviewMarkdown syntax directly.
|
|
11428
11654
|
|
|
11429
11655
|
## Filesystem Bridge / Resume
|
|
11430
11656
|
|
|
@@ -11445,16 +11671,24 @@ Return the Magic approval link and keep the bridge process running after approva
|
|
|
11445
11671
|
If your sandbox, container, terminal, or agent session restarts after setup, do not ask for a raw bridge token. Start the latest CLI again and resume from the same root:
|
|
11446
11672
|
|
|
11447
11673
|
\`\`\`bash
|
|
11448
|
-
npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --request-token
|
|
11674
|
+
npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --forever --request-token
|
|
11449
11675
|
\`\`\`
|
|
11450
11676
|
|
|
11451
|
-
\`bridge resume\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
|
|
11677
|
+
\`bridge resume --forever\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching with liveness heartbeats. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
|
|
11678
|
+
|
|
11679
|
+
For sandboxes, VPSs, or local machines that support startup hooks, generate a durable runner after setup:
|
|
11680
|
+
|
|
11681
|
+
\`\`\`bash
|
|
11682
|
+
npx --yes --package=@magic-markdown/cli@latest mdocs bridge startup --root . --json
|
|
11683
|
+
\`\`\`
|
|
11684
|
+
|
|
11685
|
+
Put the returned \`startupCommand\` into the provider's on-start hook. On local macOS or systemd Linux hosts, \`mdocs bridge startup --root . --install\` writes and starts a user service. The generated runner stores no bridge token; use platform secrets for \`MDOCS_BRIDGE_TOKEN\` if you manage tokens manually.
|
|
11452
11686
|
|
|
11453
11687
|
If Magic and the local root both changed the same document while you were offline, the bridge keeps the Magic canonical path, saves your local divergent version as a \`.conflict-...\` Markdown copy, and lets the conflict copy sync as a normal file. If Magic reports a server-side conflict, wait for the human to resolve it in Magic Markdown before retrying content pushes.
|
|
11454
11688
|
|
|
11455
11689
|
## Editing Rules
|
|
11456
11690
|
|
|
11457
|
-
- Comments
|
|
11691
|
+
- Comments are sidecar operations; suggestions are reviewMarkdown operations derived into sidecar state. They should not modify canonical Markdown until a suggestion is accepted.
|
|
11458
11692
|
- \`--range <start:end>\` is 1-based and inclusive, and validated against the current document: read the document first and quote line numbers from what you just read.
|
|
11459
11693
|
- Prefer the smallest coherent suggestion range that contains the actual change. If one sentence, list item, table row, or short paragraph changes, suggest that unit rather than replacing surrounding unchanged paragraphs or sections.
|
|
11460
11694
|
- Broader paragraph, section, or multi-section rewrites are appropriate when the edit genuinely changes structure, ordering, transitions, or multiple interdependent ideas. Do not avoid a long edit when it is the clearest correct revision.
|
|
@@ -12085,7 +12319,7 @@ var tools = [
|
|
|
12085
12319
|
},
|
|
12086
12320
|
{
|
|
12087
12321
|
name: "mdocs_suggest",
|
|
12088
|
-
description: "Create a
|
|
12322
|
+
description: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
|
|
12089
12323
|
inputSchema: {
|
|
12090
12324
|
type: "object",
|
|
12091
12325
|
properties: {
|
|
@@ -12842,7 +13076,6 @@ function joinStoreDir(root) {
|
|
|
12842
13076
|
}
|
|
12843
13077
|
|
|
12844
13078
|
// src/remote.ts
|
|
12845
|
-
var PUSH_REBASE_ATTEMPTS = 3;
|
|
12846
13079
|
async function runJoinCommand(root, parsed) {
|
|
12847
13080
|
const target = parsed.command[1];
|
|
12848
13081
|
const existingId = typeof parsed.flags.id === "string" ? parsed.flags.id : void 0;
|
|
@@ -13172,6 +13405,7 @@ async function remoteCreateFile(root, record, parsed) {
|
|
|
13172
13405
|
path: normalizedPath,
|
|
13173
13406
|
title,
|
|
13174
13407
|
markdown,
|
|
13408
|
+
reviewMarkdown: markdown,
|
|
13175
13409
|
sidecar,
|
|
13176
13410
|
anchors: [],
|
|
13177
13411
|
images: [],
|
|
@@ -13238,7 +13472,7 @@ async function remoteSuggest(root, record, parsed) {
|
|
|
13238
13472
|
const result = await postReviewOperation(
|
|
13239
13473
|
documentRecord,
|
|
13240
13474
|
document.docId,
|
|
13241
|
-
{ kind: "create_suggestion", payload: { ...range, replacement, message } },
|
|
13475
|
+
{ kind: "create_suggestion", baseHead: document.currentSha, payload: { ...range, replacement, message } },
|
|
13242
13476
|
actorForRecord(record)
|
|
13243
13477
|
);
|
|
13244
13478
|
if (result.document) await recordHead(root, record, result.document);
|
|
@@ -13333,44 +13567,14 @@ async function remoteReject(root, record, parsed) {
|
|
|
13333
13567
|
hint: "Only open suggestions can be rejected."
|
|
13334
13568
|
});
|
|
13335
13569
|
}
|
|
13336
|
-
|
|
13337
|
-
|
|
13338
|
-
|
|
13339
|
-
|
|
13340
|
-
|
|
13341
|
-
|
|
13342
|
-
|
|
13343
|
-
|
|
13344
|
-
}
|
|
13345
|
-
if (suggestion.author.id !== actor.id) {
|
|
13346
|
-
throw new CliError("unauthorized", "You can only withdraw your own suggestions; rejecting another author's requires edit access.", {
|
|
13347
|
-
hint: "Ask a human collaborator to reject it from the editor."
|
|
13348
|
-
});
|
|
13349
|
-
}
|
|
13350
|
-
for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
|
|
13351
|
-
if (index > 0) document = await fetchDocument(record, document.docId);
|
|
13352
|
-
documentRecord = rootScopedRecordFor(record, document.rootId);
|
|
13353
|
-
const io = new RemoteDocumentIO(document);
|
|
13354
|
-
const rejected = await rejectSuggestion(io, suggestionId, actor);
|
|
13355
|
-
const state = await getDocumentState(io, document.docId);
|
|
13356
|
-
try {
|
|
13357
|
-
const pushed = await pushDocument(documentRecord, document, state.markdown, state.sidecar);
|
|
13358
|
-
await recordHead(root, record, pushed);
|
|
13359
|
-
return { suggestion: rejected, document: pushed };
|
|
13360
|
-
} catch (error) {
|
|
13361
|
-
const conflicted = isCliError(error) && error.code === "conflict";
|
|
13362
|
-
if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
|
|
13363
|
-
}
|
|
13364
|
-
}
|
|
13365
|
-
throw new CliError("conflict", "Remote change was not accepted after retries.", {
|
|
13366
|
-
hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
|
|
13367
|
-
});
|
|
13368
|
-
}
|
|
13369
|
-
function isWithdrawalUnsupported(error) {
|
|
13370
|
-
if (isReviewEndpointUnsupported(error)) return true;
|
|
13371
|
-
if (!isCliError(error)) return false;
|
|
13372
|
-
const details = error.details ?? {};
|
|
13373
|
-
return details.error === "empty_review";
|
|
13570
|
+
const result = await postReviewOperation(
|
|
13571
|
+
documentRecord,
|
|
13572
|
+
document.docId,
|
|
13573
|
+
{ kind: "reject_suggestion", baseHead: document.currentSha, payload: { suggestionId } },
|
|
13574
|
+
actor
|
|
13575
|
+
);
|
|
13576
|
+
if (result.document) await recordHead(root, record, result.document);
|
|
13577
|
+
return { suggestion: result.suggestion ?? { ...suggestion, status: "rejected" }, reviewRecord: result.reviewRecord, document: result.document };
|
|
13374
13578
|
}
|
|
13375
13579
|
async function submitReview(root, record, pathOrDocId, mutate) {
|
|
13376
13580
|
if (!pathOrDocId) {
|
|
@@ -13391,43 +13595,14 @@ async function submitReview(root, record, pathOrDocId, mutate) {
|
|
|
13391
13595
|
additions: {
|
|
13392
13596
|
anchors: state.sidecar.anchors.filter((anchor) => anchor.id === created.anchorId),
|
|
13393
13597
|
comments: state.sidecar.comments.filter((comment2) => comment2.id === created.id),
|
|
13394
|
-
|
|
13598
|
+
reviewMarkdown: state.sidecar.reviewMarkdown
|
|
13395
13599
|
}
|
|
13396
13600
|
};
|
|
13397
13601
|
};
|
|
13398
|
-
|
|
13399
|
-
|
|
13400
|
-
|
|
13401
|
-
|
|
13402
|
-
return { created: attempt.created, document: pushed };
|
|
13403
|
-
} catch (error) {
|
|
13404
|
-
if (!isReviewEndpointUnsupported(error)) throw error;
|
|
13405
|
-
}
|
|
13406
|
-
for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
|
|
13407
|
-
if (index > 0) {
|
|
13408
|
-
document = await fetchDocument(record, document.docId);
|
|
13409
|
-
documentRecord = rootScopedRecordFor(record, document.rootId);
|
|
13410
|
-
attempt = await build(document);
|
|
13411
|
-
}
|
|
13412
|
-
try {
|
|
13413
|
-
const pushed = await pushDocument(documentRecord, document, attempt.state.markdown, attempt.state.sidecar);
|
|
13414
|
-
await recordHead(root, record, pushed);
|
|
13415
|
-
return { created: attempt.created, document: pushed };
|
|
13416
|
-
} catch (error) {
|
|
13417
|
-
const conflicted = isCliError(error) && error.code === "conflict";
|
|
13418
|
-
if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
|
|
13419
|
-
}
|
|
13420
|
-
}
|
|
13421
|
-
throw new CliError("conflict", "Remote change was not accepted after retries.", {
|
|
13422
|
-
hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
|
|
13423
|
-
});
|
|
13424
|
-
}
|
|
13425
|
-
function isReviewEndpointUnsupported(error) {
|
|
13426
|
-
if (!isCliError(error)) return false;
|
|
13427
|
-
if (error.code === "unauthorized") return true;
|
|
13428
|
-
if (error.code !== "not_found") return false;
|
|
13429
|
-
const details = error.details ?? {};
|
|
13430
|
-
return details.error !== "document_not_found";
|
|
13602
|
+
const attempt = await build(document);
|
|
13603
|
+
const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
|
|
13604
|
+
await recordHead(root, record, pushed);
|
|
13605
|
+
return { created: attempt.created, document: pushed };
|
|
13431
13606
|
}
|
|
13432
13607
|
async function recordHead(root, record, document) {
|
|
13433
13608
|
await writeJoinRecord(root, { ...record, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), currentHead: document.currentSha });
|
|
@@ -13602,6 +13777,8 @@ function optionalFolderTarget(value) {
|
|
|
13602
13777
|
import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
|
|
13603
13778
|
import { basename as basename2, resolve as resolve4 } from "node:path";
|
|
13604
13779
|
var BRIDGE_CONFIG_PATH = ".mdocs/bridge.json";
|
|
13780
|
+
var BRIDGE_STATUS_PATH = ".mdocs/bridge-status.json";
|
|
13781
|
+
var BRIDGE_HEARTBEAT_INTERVAL_MS = 1e4;
|
|
13605
13782
|
function bridgeSetupIdentity(input) {
|
|
13606
13783
|
const actorName = input.actorName?.trim() || "Agent";
|
|
13607
13784
|
return {
|
|
@@ -13667,9 +13844,11 @@ async function runBridgeResume(options) {
|
|
|
13667
13844
|
}
|
|
13668
13845
|
async function runBridge(options) {
|
|
13669
13846
|
const root = resolve4(options.root);
|
|
13847
|
+
if (options.once && options.forever) {
|
|
13848
|
+
throw new Error("Use either --once or --forever, not both.");
|
|
13849
|
+
}
|
|
13670
13850
|
const localManifestSource = await readLocalSource(root);
|
|
13671
13851
|
const rootId = options.rootId ?? options.sourceId ?? localManifestSource?.sourceId ?? rootIdForPath(root);
|
|
13672
|
-
const token = options.token ?? (options.requestToken ? await requestBridgeToken(options, rootId) : void 0);
|
|
13673
13852
|
const replicaId = `replica_${createHash2("sha256").update(`${root}:${options.actorId}`).digest("hex").slice(0, 12)}`;
|
|
13674
13853
|
const replicaKind = actorKindForBridge(options.actorId) === "agent" ? "agent_runtime" : "local";
|
|
13675
13854
|
const mapping = createSourceMapping({
|
|
@@ -13692,7 +13871,29 @@ async function runBridge(options) {
|
|
|
13692
13871
|
const claimMode = Boolean(options.claimToken || localManifestSource?.canonicalHead);
|
|
13693
13872
|
let lastAppliedHead = localManifestSource?.canonicalHead;
|
|
13694
13873
|
let socket;
|
|
13695
|
-
|
|
13874
|
+
let lastSuccessfulSyncAt;
|
|
13875
|
+
let lastHeartbeatAt;
|
|
13876
|
+
let lastHeartbeatSentMs = 0;
|
|
13877
|
+
let lastError;
|
|
13878
|
+
await writeCurrentBridgeStatus("starting");
|
|
13879
|
+
let token = options.token;
|
|
13880
|
+
if (!token && options.requestToken) {
|
|
13881
|
+
try {
|
|
13882
|
+
token = await requestBridgeToken(options, rootId);
|
|
13883
|
+
} catch (error) {
|
|
13884
|
+
lastError = errorMessage(error);
|
|
13885
|
+
await writeCurrentBridgeStatus("error");
|
|
13886
|
+
throw error;
|
|
13887
|
+
}
|
|
13888
|
+
}
|
|
13889
|
+
let registeredRoot;
|
|
13890
|
+
try {
|
|
13891
|
+
registeredRoot = token ? await fetchScopedRoot({ ...options, token }, rootId) : await registerRoot(options, root, rootId, mapping);
|
|
13892
|
+
} catch (error) {
|
|
13893
|
+
lastError = errorMessage(error);
|
|
13894
|
+
await writeCurrentBridgeStatus("error");
|
|
13895
|
+
throw error;
|
|
13896
|
+
}
|
|
13696
13897
|
lastAppliedHead = lastAppliedHead ?? registeredRoot?.canonical.head;
|
|
13697
13898
|
await writeSourceState(lastAppliedHead);
|
|
13698
13899
|
await writeBridgeConfig(root, {
|
|
@@ -13710,20 +13911,30 @@ async function runBridge(options) {
|
|
|
13710
13911
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13711
13912
|
});
|
|
13712
13913
|
const backfilled = await resumeFromCanonical();
|
|
13713
|
-
if (options.once)
|
|
13914
|
+
if (options.once) {
|
|
13915
|
+
lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
13916
|
+
await writeCurrentBridgeStatus("once_complete");
|
|
13917
|
+
return;
|
|
13918
|
+
}
|
|
13714
13919
|
connect();
|
|
13715
13920
|
if (claimMode && !backfilled) await primeLocalSignatures();
|
|
13716
13921
|
else await publishSnapshot("initial");
|
|
13717
13922
|
const timer = setInterval(() => {
|
|
13718
13923
|
void publishSnapshot("poll").catch((error) => {
|
|
13719
|
-
|
|
13924
|
+
lastError = errorMessage(error);
|
|
13925
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
13926
|
+
sendHeartbeat("error", lastError);
|
|
13927
|
+
process.stderr.write(`bridge poll failed: ${lastError}
|
|
13720
13928
|
`);
|
|
13721
13929
|
});
|
|
13722
13930
|
}, options.intervalMs);
|
|
13723
|
-
process.
|
|
13931
|
+
process.once("SIGINT", () => {
|
|
13724
13932
|
clearInterval(timer);
|
|
13725
|
-
|
|
13726
|
-
|
|
13933
|
+
sendHeartbeat("offline", void 0, false);
|
|
13934
|
+
void writeCurrentBridgeStatus("stopped").catch(() => void 0).finally(() => {
|
|
13935
|
+
socket?.close();
|
|
13936
|
+
process.exit(0);
|
|
13937
|
+
});
|
|
13727
13938
|
});
|
|
13728
13939
|
await new Promise(() => void 0);
|
|
13729
13940
|
function connect() {
|
|
@@ -13734,8 +13945,11 @@ async function runBridge(options) {
|
|
|
13734
13945
|
if (token) url.searchParams.set("token", token);
|
|
13735
13946
|
socket = new WebSocket(url);
|
|
13736
13947
|
socket.addEventListener("open", () => {
|
|
13948
|
+
lastError = void 0;
|
|
13737
13949
|
process.stdout.write(`mdocs bridge connected ${root} -> ${options.workspaceId}/${rootId}
|
|
13738
13950
|
`);
|
|
13951
|
+
sendHeartbeat("synced");
|
|
13952
|
+
void writeCurrentBridgeStatus("connected").catch(() => void 0);
|
|
13739
13953
|
});
|
|
13740
13954
|
socket.addEventListener("message", (event) => {
|
|
13741
13955
|
if (typeof event.data !== "string") return;
|
|
@@ -13748,9 +13962,12 @@ async function runBridge(options) {
|
|
|
13748
13962
|
});
|
|
13749
13963
|
socket.addEventListener("close", () => {
|
|
13750
13964
|
socket = void 0;
|
|
13965
|
+
void writeCurrentBridgeStatus("offline").catch(() => void 0);
|
|
13751
13966
|
setTimeout(connect, 1e3);
|
|
13752
13967
|
});
|
|
13753
13968
|
socket.addEventListener("error", () => {
|
|
13969
|
+
lastError = "WebSocket error";
|
|
13970
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
13754
13971
|
socket?.close();
|
|
13755
13972
|
});
|
|
13756
13973
|
}
|
|
@@ -13759,7 +13976,7 @@ async function runBridge(options) {
|
|
|
13759
13976
|
const currentDocIds = new Set(map.docs.map((doc) => doc.docId));
|
|
13760
13977
|
const treeSignature = hashJson(map.docs.map((doc) => ({ docId: doc.docId, path: doc.path })));
|
|
13761
13978
|
if (treeSignature !== lastTreeSignature) {
|
|
13762
|
-
if (send("file-tree", { reason, docs: map.docs, mapping, canonicalHead: lastAppliedHead })) {
|
|
13979
|
+
if (send("file-tree", { reason, docs: map.docs, mapping: bridgeReplicaMetadata("synced"), canonicalHead: lastAppliedHead })) {
|
|
13763
13980
|
lastTreeSignature = treeSignature;
|
|
13764
13981
|
}
|
|
13765
13982
|
}
|
|
@@ -13787,6 +14004,9 @@ async function runBridge(options) {
|
|
|
13787
14004
|
pendingDeletes.add(docId);
|
|
13788
14005
|
}
|
|
13789
14006
|
}
|
|
14007
|
+
lastError = void 0;
|
|
14008
|
+
lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14009
|
+
maybeSendHeartbeat("synced");
|
|
13790
14010
|
}
|
|
13791
14011
|
async function resumeFromCanonical() {
|
|
13792
14012
|
if (!options.resume && !token) return false;
|
|
@@ -13870,6 +14090,16 @@ async function runBridge(options) {
|
|
|
13870
14090
|
return localSignature === baseSignature ? "applied" : "conflict";
|
|
13871
14091
|
}
|
|
13872
14092
|
async function handleIncoming(message) {
|
|
14093
|
+
if (message.type === "agent-unbound") {
|
|
14094
|
+
const payload = message.payload;
|
|
14095
|
+
const targetsUs = !payload.agentIds || payload.agentIds.length === 0 || payload.agentIds.includes(options.actorId);
|
|
14096
|
+
if (!targetsUs) return;
|
|
14097
|
+
process.stdout.write(`mdocs bridge unbound by workspace; stopping ${root}
|
|
14098
|
+
`);
|
|
14099
|
+
await writeCurrentBridgeStatus("stopped").catch(() => void 0);
|
|
14100
|
+
socket?.close();
|
|
14101
|
+
process.exit(0);
|
|
14102
|
+
}
|
|
13873
14103
|
if (message.type === "file-changed") {
|
|
13874
14104
|
await applyCanonicalDocument(readDocumentPayload(message.payload));
|
|
13875
14105
|
}
|
|
@@ -13899,7 +14129,9 @@ async function runBridge(options) {
|
|
|
13899
14129
|
}
|
|
13900
14130
|
if (message.type === "sync-error") {
|
|
13901
14131
|
const payload = message.payload;
|
|
13902
|
-
|
|
14132
|
+
lastError = `sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}`;
|
|
14133
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
14134
|
+
process.stderr.write(`${lastError}
|
|
13903
14135
|
`);
|
|
13904
14136
|
if (!payload.docId) return;
|
|
13905
14137
|
pendingDocs.delete(payload.docId);
|
|
@@ -13938,6 +14170,12 @@ async function runBridge(options) {
|
|
|
13938
14170
|
await io.writeTextAtomic(payload.path, payload.markdown);
|
|
13939
14171
|
await io.writeTextAtomic(sidecarPath(payload.docId), `${JSON.stringify(payload.sidecar, null, 2)}
|
|
13940
14172
|
`);
|
|
14173
|
+
const reviewMarkdown = projectReviewMarkdown(payload.markdown, payload.sidecar.suggestions);
|
|
14174
|
+
if (reviewMarkdown === payload.markdown) {
|
|
14175
|
+
await io.deleteFile(reviewFilePath(payload.path)).catch(() => void 0);
|
|
14176
|
+
} else {
|
|
14177
|
+
await io.writeTextAtomic(reviewFilePath(payload.path), reviewMarkdown);
|
|
14178
|
+
}
|
|
13941
14179
|
await upsertManifestDocument(payload);
|
|
13942
14180
|
signatures.set(payload.docId, remoteSignature);
|
|
13943
14181
|
deniedSignatures.delete(payload.docId);
|
|
@@ -14018,6 +14256,63 @@ async function runBridge(options) {
|
|
|
14018
14256
|
);
|
|
14019
14257
|
return true;
|
|
14020
14258
|
}
|
|
14259
|
+
function maybeSendHeartbeat(status) {
|
|
14260
|
+
if (Date.now() - lastHeartbeatSentMs < BRIDGE_HEARTBEAT_INTERVAL_MS) return;
|
|
14261
|
+
sendHeartbeat(status);
|
|
14262
|
+
}
|
|
14263
|
+
function sendHeartbeat(status, error, writeLocalStatus = true) {
|
|
14264
|
+
const heartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14265
|
+
const sent = send("bridge-heartbeat", {
|
|
14266
|
+
reason: "heartbeat",
|
|
14267
|
+
canonicalHead: lastAppliedHead,
|
|
14268
|
+
mapping: bridgeReplicaMetadata(status, error, heartbeatAt)
|
|
14269
|
+
});
|
|
14270
|
+
if (sent) {
|
|
14271
|
+
lastHeartbeatAt = heartbeatAt;
|
|
14272
|
+
lastHeartbeatSentMs = Date.now();
|
|
14273
|
+
if (writeLocalStatus) {
|
|
14274
|
+
void writeCurrentBridgeStatus(status === "error" ? "error" : status === "offline" ? "offline" : "connected").catch(() => void 0);
|
|
14275
|
+
}
|
|
14276
|
+
}
|
|
14277
|
+
return sent;
|
|
14278
|
+
}
|
|
14279
|
+
function bridgeReplicaMetadata(status, error, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
14280
|
+
return {
|
|
14281
|
+
...mapping,
|
|
14282
|
+
lastAppliedHead,
|
|
14283
|
+
status,
|
|
14284
|
+
updatedAt: now,
|
|
14285
|
+
lastSeenAt: now,
|
|
14286
|
+
bridgeVersion: CLI_VERSION,
|
|
14287
|
+
pendingDocuments: pendingDocs.size,
|
|
14288
|
+
pendingDeletes: pendingDeletes.size,
|
|
14289
|
+
...error ? { lastError: error } : {}
|
|
14290
|
+
};
|
|
14291
|
+
}
|
|
14292
|
+
function currentBridgeStatus(state) {
|
|
14293
|
+
return {
|
|
14294
|
+
schemaVersion: 1,
|
|
14295
|
+
workspaceId: options.workspaceId,
|
|
14296
|
+
rootId,
|
|
14297
|
+
actorId: options.actorId,
|
|
14298
|
+
actorName: options.actorName,
|
|
14299
|
+
sourceName: options.sourceName,
|
|
14300
|
+
baseUrl: options.baseUrl,
|
|
14301
|
+
state,
|
|
14302
|
+
connected: state === "connected",
|
|
14303
|
+
bridgeVersion: CLI_VERSION,
|
|
14304
|
+
lastAppliedHead,
|
|
14305
|
+
lastSuccessfulSyncAt,
|
|
14306
|
+
lastHeartbeatAt,
|
|
14307
|
+
lastError,
|
|
14308
|
+
pendingDocuments: pendingDocs.size,
|
|
14309
|
+
pendingDeletes: pendingDeletes.size,
|
|
14310
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
14311
|
+
};
|
|
14312
|
+
}
|
|
14313
|
+
async function writeCurrentBridgeStatus(state) {
|
|
14314
|
+
await writeBridgeStatus(root, currentBridgeStatus(state));
|
|
14315
|
+
}
|
|
14021
14316
|
async function primeLocalSignatures() {
|
|
14022
14317
|
const map = await getWorkspaceMap(io);
|
|
14023
14318
|
for (const doc of map.docs) {
|
|
@@ -14145,6 +14440,12 @@ async function writeBridgeConfig(root, config2) {
|
|
|
14145
14440
|
await io.writeTextAtomic(BRIDGE_CONFIG_PATH, `${JSON.stringify(config2, null, 2)}
|
|
14146
14441
|
`);
|
|
14147
14442
|
}
|
|
14443
|
+
async function writeBridgeStatus(root, status) {
|
|
14444
|
+
const io = new NodeWorkspaceIO(root);
|
|
14445
|
+
await io.mkdir(".mdocs");
|
|
14446
|
+
await io.writeTextAtomic(BRIDGE_STATUS_PATH, `${JSON.stringify(status, null, 2)}
|
|
14447
|
+
`);
|
|
14448
|
+
}
|
|
14148
14449
|
async function fetchCanonicalSnapshot(options, rootId) {
|
|
14149
14450
|
const response = await fetch(new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}/sync`, options.baseUrl), {
|
|
14150
14451
|
headers: authHeaders(options.token)
|
|
@@ -14215,11 +14516,14 @@ function parseBridgeSyncMessage(data) {
|
|
|
14215
14516
|
}
|
|
14216
14517
|
}
|
|
14217
14518
|
function delay(ms) {
|
|
14218
|
-
return new Promise((
|
|
14519
|
+
return new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
14219
14520
|
}
|
|
14220
14521
|
function authHeaders(token) {
|
|
14221
14522
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
14222
14523
|
}
|
|
14524
|
+
function errorMessage(error) {
|
|
14525
|
+
return error instanceof Error ? error.message : String(error);
|
|
14526
|
+
}
|
|
14223
14527
|
async function fetchScopedRoot(options, rootId) {
|
|
14224
14528
|
const response = await fetch(
|
|
14225
14529
|
new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}`, options.baseUrl),
|
|
@@ -14323,6 +14627,9 @@ function conflictCopyPath(path) {
|
|
|
14323
14627
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "").slice(0, 15);
|
|
14324
14628
|
return path.replace(/(\.[^./]+)?$/, (extension) => `.conflict-${stamp}${extension || ".md"}`);
|
|
14325
14629
|
}
|
|
14630
|
+
function reviewFilePath(path) {
|
|
14631
|
+
return path.replace(/(\.md)?$/i, ".review.md");
|
|
14632
|
+
}
|
|
14326
14633
|
function hashJson(value) {
|
|
14327
14634
|
return createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14328
14635
|
}
|
|
@@ -14354,10 +14661,246 @@ function readDocumentPayload(payload) {
|
|
|
14354
14661
|
};
|
|
14355
14662
|
}
|
|
14356
14663
|
|
|
14664
|
+
// src/bridge-startup.ts
|
|
14665
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
14666
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
14667
|
+
import { chmod, copyFile, mkdir as mkdir4, readFile as readFile6, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
|
|
14668
|
+
import { homedir, platform } from "node:os";
|
|
14669
|
+
import { dirname as dirname5, join as join4, resolve as resolve5 } from "node:path";
|
|
14670
|
+
import { promisify as promisify2 } from "node:util";
|
|
14671
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
14672
|
+
async function runBridgeStartup(options) {
|
|
14673
|
+
const root = resolve5(options.root);
|
|
14674
|
+
await assertBridgeConfigured(root);
|
|
14675
|
+
const requestedMode = readBridgeStartupMode(options.mode);
|
|
14676
|
+
const mode = requestedMode === "auto" ? await autoBridgeStartupMode() : requestedMode;
|
|
14677
|
+
const startupDir = join4(root, ".mdocs", "startup");
|
|
14678
|
+
await mkdir4(startupDir, { recursive: true });
|
|
14679
|
+
const runnerPath = join4(startupDir, "bridge-runner.sh");
|
|
14680
|
+
const runnerArgs = bridgeResumeArgs(root, options);
|
|
14681
|
+
const runnerScript = bridgeRunnerScript(root, runnerArgs);
|
|
14682
|
+
await writeFile4(runnerPath, runnerScript, "utf8");
|
|
14683
|
+
await chmod(runnerPath, 493);
|
|
14684
|
+
const nativePlan = mode === "launchd" ? await writeLaunchdPlan(root, startupDir, runnerPath) : mode === "systemd" ? await writeSystemdPlan(root, startupDir, runnerPath) : void 0;
|
|
14685
|
+
if (options.install && !nativePlan) {
|
|
14686
|
+
throw new CliError("usage_error", "Shell startup mode does not have a native service to install.", {
|
|
14687
|
+
hint: "Use the printed startupCommand in your sandbox/provider startup hook, or pass --mode launchd/systemd on a supported host."
|
|
14688
|
+
});
|
|
14689
|
+
}
|
|
14690
|
+
if (options.install && nativePlan) await nativePlan.install();
|
|
14691
|
+
const result = {
|
|
14692
|
+
ok: true,
|
|
14693
|
+
root,
|
|
14694
|
+
mode,
|
|
14695
|
+
runnerPath,
|
|
14696
|
+
startupCommand: shellCommand(["sh", runnerPath]),
|
|
14697
|
+
servicePath: nativePlan?.servicePath,
|
|
14698
|
+
installPath: nativePlan?.installPath,
|
|
14699
|
+
installed: Boolean(options.install && nativePlan),
|
|
14700
|
+
installCommands: nativePlan?.installCommands ?? [],
|
|
14701
|
+
notes: [
|
|
14702
|
+
"Sandbox providers such as Daytona, Sprites, and Modal should run startupCommand from their on-start hook.",
|
|
14703
|
+
"The runner stores no bridge token. If you use manual tokens, provide MDOCS_BRIDGE_TOKEN through the platform secret environment.",
|
|
14704
|
+
"If no MDOCS_BRIDGE_TOKEN is present, the runner uses --request-token and logs a Magic approval URL."
|
|
14705
|
+
]
|
|
14706
|
+
};
|
|
14707
|
+
await writeFile4(join4(startupDir, "bridge-startup.json"), `${JSON.stringify(result, null, 2)}
|
|
14708
|
+
`, "utf8");
|
|
14709
|
+
return result;
|
|
14710
|
+
}
|
|
14711
|
+
function formatBridgeStartupResult(result) {
|
|
14712
|
+
const lines = [
|
|
14713
|
+
`Wrote Magic Markdown bridge runner: ${result.runnerPath}`,
|
|
14714
|
+
`Startup command: ${result.startupCommand}`
|
|
14715
|
+
];
|
|
14716
|
+
if (result.servicePath) lines.push(`Wrote ${result.mode} service file: ${result.servicePath}`);
|
|
14717
|
+
if (result.installed && result.installPath) {
|
|
14718
|
+
lines.push(`Installed ${result.mode} service: ${result.installPath}`);
|
|
14719
|
+
} else if (result.installCommands.length > 0) {
|
|
14720
|
+
lines.push("", "To install and start it manually:", ...result.installCommands.map((command) => ` ${command}`));
|
|
14721
|
+
}
|
|
14722
|
+
lines.push("", ...result.notes.map((note) => `Note: ${note}`));
|
|
14723
|
+
return lines.join("\n");
|
|
14724
|
+
}
|
|
14725
|
+
function bridgeRunnerScript(root, args) {
|
|
14726
|
+
return `#!/usr/bin/env sh
|
|
14727
|
+
set -eu
|
|
14728
|
+
cd ${shellQuote(root)}
|
|
14729
|
+
exec npx --yes --package=@magic-markdown/cli@latest mdocs ${shellCommand(args)} "$@"
|
|
14730
|
+
`;
|
|
14731
|
+
}
|
|
14732
|
+
function bridgeResumeArgs(root, options) {
|
|
14733
|
+
const args = ["bridge", "resume", "--root", root, "--forever"];
|
|
14734
|
+
if (options.requestToken !== false) args.push("--request-token");
|
|
14735
|
+
if (options.baseUrl) args.push("--url", options.baseUrl);
|
|
14736
|
+
if (options.intervalMs) args.push("--interval", String(options.intervalMs));
|
|
14737
|
+
return args;
|
|
14738
|
+
}
|
|
14739
|
+
function readBridgeStartupMode(value) {
|
|
14740
|
+
if (!value || value === "auto" || value === "shell" || value === "launchd" || value === "systemd") {
|
|
14741
|
+
return value || "auto";
|
|
14742
|
+
}
|
|
14743
|
+
throw new CliError("usage_error", `Unknown bridge startup mode: ${value}`, {
|
|
14744
|
+
hint: "Use --mode auto, shell, launchd, or systemd."
|
|
14745
|
+
});
|
|
14746
|
+
}
|
|
14747
|
+
async function autoBridgeStartupMode() {
|
|
14748
|
+
if (platform() === "darwin") return "launchd";
|
|
14749
|
+
if (platform() === "linux" && await pathExists("/run/systemd/system")) return "systemd";
|
|
14750
|
+
return "shell";
|
|
14751
|
+
}
|
|
14752
|
+
async function assertBridgeConfigured(root) {
|
|
14753
|
+
const configPath = join4(root, ".mdocs", "bridge.json");
|
|
14754
|
+
try {
|
|
14755
|
+
const config2 = JSON.parse(await readFile6(configPath, "utf8"));
|
|
14756
|
+
if (config2.schemaVersion !== 1) throw new Error("invalid schema");
|
|
14757
|
+
} catch {
|
|
14758
|
+
throw new CliError("usage_error", "This root does not have a bridge configuration yet.", {
|
|
14759
|
+
hint: "Run mdocs bridge setup first, then run mdocs bridge startup from the same root."
|
|
14760
|
+
});
|
|
14761
|
+
}
|
|
14762
|
+
}
|
|
14763
|
+
async function writeLaunchdPlan(root, startupDir, runnerPath) {
|
|
14764
|
+
const label = serviceLabel(root);
|
|
14765
|
+
const servicePath = join4(startupDir, `${label}.plist`);
|
|
14766
|
+
const installPath = join4(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
14767
|
+
const outLog = join4(root, ".mdocs", "bridge.log");
|
|
14768
|
+
const errLog = join4(root, ".mdocs", "bridge.err.log");
|
|
14769
|
+
await writeFile4(servicePath, launchdPlist(label, root, runnerPath, outLog, errLog), "utf8");
|
|
14770
|
+
const target = `gui/$(id -u)`;
|
|
14771
|
+
return {
|
|
14772
|
+
servicePath,
|
|
14773
|
+
installPath,
|
|
14774
|
+
installCommands: [
|
|
14775
|
+
shellCommand(["mkdir", "-p", dirname5(installPath)]),
|
|
14776
|
+
shellCommand(["cp", servicePath, installPath]),
|
|
14777
|
+
`launchctl bootout ${target} ${shellQuote(installPath)} 2>/dev/null || true`,
|
|
14778
|
+
`launchctl bootstrap ${target} ${shellQuote(installPath)}`,
|
|
14779
|
+
`launchctl enable ${target}/${label}`,
|
|
14780
|
+
`launchctl kickstart -k ${target}/${label}`
|
|
14781
|
+
],
|
|
14782
|
+
install: async () => {
|
|
14783
|
+
await mkdir4(dirname5(installPath), { recursive: true });
|
|
14784
|
+
await copyFile(servicePath, installPath);
|
|
14785
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
14786
|
+
if (uid === void 0) return;
|
|
14787
|
+
const launchTarget = `gui/${uid}`;
|
|
14788
|
+
await runOptionalCommand("launchctl", ["bootout", launchTarget, installPath]);
|
|
14789
|
+
await runCommand("launchctl", ["bootstrap", launchTarget, installPath]);
|
|
14790
|
+
await runCommand("launchctl", ["enable", `${launchTarget}/${label}`]);
|
|
14791
|
+
await runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${label}`]);
|
|
14792
|
+
}
|
|
14793
|
+
};
|
|
14794
|
+
}
|
|
14795
|
+
async function writeSystemdPlan(root, startupDir, runnerPath) {
|
|
14796
|
+
const unitName = `${serviceLabel(root)}.service`;
|
|
14797
|
+
const servicePath = join4(startupDir, unitName);
|
|
14798
|
+
const installPath = join4(homedir(), ".config", "systemd", "user", unitName);
|
|
14799
|
+
await writeFile4(servicePath, systemdUnit(unitName, root, runnerPath), "utf8");
|
|
14800
|
+
return {
|
|
14801
|
+
servicePath,
|
|
14802
|
+
installPath,
|
|
14803
|
+
installCommands: [
|
|
14804
|
+
shellCommand(["mkdir", "-p", dirname5(installPath)]),
|
|
14805
|
+
shellCommand(["cp", servicePath, installPath]),
|
|
14806
|
+
"systemctl --user daemon-reload",
|
|
14807
|
+
shellCommand(["systemctl", "--user", "enable", "--now", unitName])
|
|
14808
|
+
],
|
|
14809
|
+
install: async () => {
|
|
14810
|
+
await mkdir4(dirname5(installPath), { recursive: true });
|
|
14811
|
+
await copyFile(servicePath, installPath);
|
|
14812
|
+
await runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
14813
|
+
await runCommand("systemctl", ["--user", "enable", "--now", unitName]);
|
|
14814
|
+
}
|
|
14815
|
+
};
|
|
14816
|
+
}
|
|
14817
|
+
function launchdPlist(label, root, runnerPath, outLog, errLog) {
|
|
14818
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
14819
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
14820
|
+
<plist version="1.0">
|
|
14821
|
+
<dict>
|
|
14822
|
+
<key>Label</key>
|
|
14823
|
+
<string>${escapeXml(label)}</string>
|
|
14824
|
+
<key>ProgramArguments</key>
|
|
14825
|
+
<array>
|
|
14826
|
+
<string>/bin/sh</string>
|
|
14827
|
+
<string>${escapeXml(runnerPath)}</string>
|
|
14828
|
+
</array>
|
|
14829
|
+
<key>WorkingDirectory</key>
|
|
14830
|
+
<string>${escapeXml(root)}</string>
|
|
14831
|
+
<key>RunAtLoad</key>
|
|
14832
|
+
<true/>
|
|
14833
|
+
<key>KeepAlive</key>
|
|
14834
|
+
<true/>
|
|
14835
|
+
<key>StandardOutPath</key>
|
|
14836
|
+
<string>${escapeXml(outLog)}</string>
|
|
14837
|
+
<key>StandardErrorPath</key>
|
|
14838
|
+
<string>${escapeXml(errLog)}</string>
|
|
14839
|
+
</dict>
|
|
14840
|
+
</plist>
|
|
14841
|
+
`;
|
|
14842
|
+
}
|
|
14843
|
+
function systemdUnit(unitName, root, runnerPath) {
|
|
14844
|
+
return `[Unit]
|
|
14845
|
+
Description=Magic Markdown bridge (${unitName})
|
|
14846
|
+
After=network-online.target
|
|
14847
|
+
Wants=network-online.target
|
|
14848
|
+
|
|
14849
|
+
[Service]
|
|
14850
|
+
Type=simple
|
|
14851
|
+
WorkingDirectory=${systemdQuote(root)}
|
|
14852
|
+
ExecStart=/bin/sh ${systemdQuote(runnerPath)}
|
|
14853
|
+
Restart=always
|
|
14854
|
+
RestartSec=5
|
|
14855
|
+
Environment=PATH=${systemdQuote(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}
|
|
14856
|
+
|
|
14857
|
+
[Install]
|
|
14858
|
+
WantedBy=default.target
|
|
14859
|
+
`;
|
|
14860
|
+
}
|
|
14861
|
+
function serviceLabel(root) {
|
|
14862
|
+
const digest = createHash3("sha256").update(root).digest("hex").slice(0, 12);
|
|
14863
|
+
return `com.magic-markdown.bridge.${digest}`;
|
|
14864
|
+
}
|
|
14865
|
+
async function runOptionalCommand(command, args) {
|
|
14866
|
+
try {
|
|
14867
|
+
await execFileAsync2(command, args);
|
|
14868
|
+
} catch {
|
|
14869
|
+
}
|
|
14870
|
+
}
|
|
14871
|
+
async function runCommand(command, args) {
|
|
14872
|
+
try {
|
|
14873
|
+
await execFileAsync2(command, args);
|
|
14874
|
+
} catch (error) {
|
|
14875
|
+
const detail = error;
|
|
14876
|
+
throw new CliError("internal_error", `${command} ${args.join(" ")} failed: ${detail.stderr || detail.message || String(error)}`);
|
|
14877
|
+
}
|
|
14878
|
+
}
|
|
14879
|
+
async function pathExists(path) {
|
|
14880
|
+
try {
|
|
14881
|
+
await stat3(path);
|
|
14882
|
+
return true;
|
|
14883
|
+
} catch {
|
|
14884
|
+
return false;
|
|
14885
|
+
}
|
|
14886
|
+
}
|
|
14887
|
+
function shellCommand(args) {
|
|
14888
|
+
return args.map(shellQuote).join(" ");
|
|
14889
|
+
}
|
|
14890
|
+
function shellQuote(value) {
|
|
14891
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
14892
|
+
}
|
|
14893
|
+
function systemdQuote(value) {
|
|
14894
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
14895
|
+
}
|
|
14896
|
+
function escapeXml(value) {
|
|
14897
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
14898
|
+
}
|
|
14899
|
+
|
|
14357
14900
|
// src/index.ts
|
|
14358
14901
|
async function main() {
|
|
14359
14902
|
const parsed = parseArgs(process.argv.slice(2));
|
|
14360
|
-
const cwd =
|
|
14903
|
+
const cwd = resolve6(String(parsed.flags.cwd ?? process.cwd()));
|
|
14361
14904
|
const io = new NodeWorkspaceIO(cwd);
|
|
14362
14905
|
const [command, subcommand] = parsed.command;
|
|
14363
14906
|
if (parsed.flags.version || command === "version") {
|
|
@@ -14372,7 +14915,7 @@ async function main() {
|
|
|
14372
14915
|
}
|
|
14373
14916
|
switch (command) {
|
|
14374
14917
|
case "init": {
|
|
14375
|
-
const target =
|
|
14918
|
+
const target = resolve6(parsed.command[1] ?? cwd);
|
|
14376
14919
|
const targetIo = new NodeWorkspaceIO(target);
|
|
14377
14920
|
const manifest = await indexWorkspace(targetIo);
|
|
14378
14921
|
print(manifest, parsed.flags);
|
|
@@ -14409,7 +14952,7 @@ async function main() {
|
|
|
14409
14952
|
markdown,
|
|
14410
14953
|
images: state.images.map((image2) => ({
|
|
14411
14954
|
...image2,
|
|
14412
|
-
...image2.workspacePath ? { absolutePath:
|
|
14955
|
+
...image2.workspacePath ? { absolutePath: resolve6(cwd, image2.workspacePath) } : {}
|
|
14413
14956
|
})),
|
|
14414
14957
|
links: state.links,
|
|
14415
14958
|
...parsed.flags["no-review"] ? {} : {
|
|
@@ -14513,7 +15056,7 @@ async function main() {
|
|
|
14513
15056
|
return;
|
|
14514
15057
|
}
|
|
14515
15058
|
case "bridge": {
|
|
14516
|
-
const root =
|
|
15059
|
+
const root = resolve6(String(parsed.flags.root ?? cwd));
|
|
14517
15060
|
const baseOptions = {
|
|
14518
15061
|
root,
|
|
14519
15062
|
rootId: typeof parsed.flags["root-id"] === "string" ? parsed.flags["root-id"] : void 0,
|
|
@@ -14529,8 +15072,21 @@ async function main() {
|
|
|
14529
15072
|
token: typeof parsed.flags.token === "string" ? parsed.flags.token : process.env.MDOCS_BRIDGE_TOKEN || void 0,
|
|
14530
15073
|
requestToken: Boolean(parsed.flags["request-token"] || parsed.flags.pair),
|
|
14531
15074
|
pairingTimeoutMs: typeof parsed.flags["pairing-timeout-ms"] === "string" ? Number(parsed.flags["pairing-timeout-ms"]) : void 0,
|
|
14532
|
-
once: Boolean(parsed.flags.once)
|
|
15075
|
+
once: Boolean(parsed.flags.once),
|
|
15076
|
+
forever: Boolean(parsed.flags.forever)
|
|
14533
15077
|
};
|
|
15078
|
+
if (subcommand === "startup" || subcommand === "daemon") {
|
|
15079
|
+
const result = await runBridgeStartup({
|
|
15080
|
+
root,
|
|
15081
|
+
mode: typeof parsed.flags.mode === "string" ? parsed.flags.mode : void 0,
|
|
15082
|
+
install: Boolean(parsed.flags.install),
|
|
15083
|
+
requestToken: !parsed.flags["no-request-token"],
|
|
15084
|
+
baseUrl: optionalBridgeBaseUrl(parsed.flags),
|
|
15085
|
+
intervalMs: typeof parsed.flags.interval === "string" ? Number(parsed.flags.interval) : void 0
|
|
15086
|
+
});
|
|
15087
|
+
print(parsed.flags.json ? result : formatBridgeStartupResult(result), parsed.flags);
|
|
15088
|
+
return;
|
|
15089
|
+
}
|
|
14534
15090
|
if (subcommand === "resume") {
|
|
14535
15091
|
await runBridgeResume({
|
|
14536
15092
|
...baseOptions,
|
|
@@ -14669,7 +15225,7 @@ async function readRequiredTextFlag2(flags, cwd, names) {
|
|
|
14669
15225
|
async function readOptionalTextFlag2(flags, cwd, names) {
|
|
14670
15226
|
for (const name of names) {
|
|
14671
15227
|
const fileValue = flags[`${name}-file`];
|
|
14672
|
-
if (typeof fileValue === "string") return
|
|
15228
|
+
if (typeof fileValue === "string") return readFile7(resolve6(cwd, fileValue), "utf8");
|
|
14673
15229
|
const value = flags[name];
|
|
14674
15230
|
if (typeof value === "string") return value;
|
|
14675
15231
|
}
|
|
@@ -14717,9 +15273,9 @@ Commands:
|
|
|
14717
15273
|
comments <path> --json List comments
|
|
14718
15274
|
comment <path> --range 3:5 --body Add a sidecar comment
|
|
14719
15275
|
suggestions <path> --json List suggestions
|
|
14720
|
-
suggest <path> --range 3:5 --with Add a
|
|
15276
|
+
suggest <path> --range 3:5 --with Add a reviewMarkdown suggestion
|
|
14721
15277
|
suggest <path> --range 3:5 --with-file replacement.md
|
|
14722
|
-
Add a multiline
|
|
15278
|
+
Add a multiline reviewMarkdown suggestion
|
|
14723
15279
|
accept <suggestionId> Apply and accept a suggestion
|
|
14724
15280
|
reject <suggestionId> Reject a suggestion
|
|
14725
15281
|
resolve-comment <commentId> Resolve a comment thread
|
|
@@ -14742,9 +15298,12 @@ Commands:
|
|
|
14742
15298
|
bridge setup --workspace <id> --root . --url <base-url> [--folder-id <id>] --request-token
|
|
14743
15299
|
Initialize, validate, request approval,
|
|
14744
15300
|
and start an agent filesystem bridge
|
|
14745
|
-
bridge resume --root . --request-token [--once]
|
|
15301
|
+
bridge resume --root . --forever --request-token [--once]
|
|
14746
15302
|
Backfill missed Magic changes, then keep
|
|
14747
15303
|
the bridge running unless --once is set
|
|
15304
|
+
bridge startup|daemon --root . [--mode auto|shell|launchd|systemd] [--install]
|
|
15305
|
+
Write a bridge runner and startup hook
|
|
15306
|
+
for sandboxes, launchd, or systemd
|
|
14748
15307
|
bridge --workspace <id> --root . --url <base-url> --request-token
|
|
14749
15308
|
Request human approval, then sync an
|
|
14750
15309
|
approved local root with the workspace
|
|
@@ -14764,7 +15323,8 @@ Exit codes: 0 ok, 1 internal, 2 usage, 3 conflict, 4 not found, 5 network, 6 una
|
|
|
14764
15323
|
`);
|
|
14765
15324
|
}
|
|
14766
15325
|
function commandHelp(commandPath) {
|
|
14767
|
-
const
|
|
15326
|
+
const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
|
|
15327
|
+
const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
|
|
14768
15328
|
const spec = AGENT_COMMANDS.find((command) => candidates.includes(command.name));
|
|
14769
15329
|
if (!spec) return false;
|
|
14770
15330
|
const lines = [
|
|
@@ -14785,7 +15345,8 @@ function commandHelp(commandPath) {
|
|
|
14785
15345
|
return true;
|
|
14786
15346
|
}
|
|
14787
15347
|
function usageFor(commandPath) {
|
|
14788
|
-
const
|
|
15348
|
+
const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
|
|
15349
|
+
const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
|
|
14789
15350
|
return AGENT_COMMANDS.find((command) => candidates.includes(command.name))?.usage;
|
|
14790
15351
|
}
|
|
14791
15352
|
main().catch((error) => {
|