@magic-markdown/cli 0.3.12 → 0.3.17
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 +1117 -408
- 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
|
|
|
@@ -918,7 +919,8 @@ function selectorFromRange(markdown, range) {
|
|
|
918
919
|
function remapAnchor(markdown, anchor) {
|
|
919
920
|
const lines = getLines(markdown);
|
|
920
921
|
const quoteLines = anchor.selector.quote.split(/\r?\n/);
|
|
921
|
-
const
|
|
922
|
+
const candidates = findQuoteCandidates(lines, quoteLines);
|
|
923
|
+
const best = bestCandidate(lines, candidates, anchor.selector);
|
|
922
924
|
if (!best) {
|
|
923
925
|
return {
|
|
924
926
|
...anchor,
|
|
@@ -939,284 +941,494 @@ function remapAnchor(markdown, anchor) {
|
|
|
939
941
|
function remapAnchors(markdown, anchors) {
|
|
940
942
|
return anchors.map((anchor) => remapAnchor(markdown, anchor));
|
|
941
943
|
}
|
|
942
|
-
function
|
|
943
|
-
if (quoteLines.length === 0) return
|
|
944
|
+
function findQuoteCandidates(lines, quoteLines) {
|
|
945
|
+
if (quoteLines.length === 0) return [];
|
|
946
|
+
const ranges = [];
|
|
947
|
+
const quoteText = quoteLines.join("\n");
|
|
944
948
|
for (let index = 0; index <= lines.length - quoteLines.length; index += 1) {
|
|
945
949
|
const candidate = lines.slice(index, index + quoteLines.length);
|
|
946
|
-
if (candidate.join("\n") === quoteLines.
|
|
947
|
-
return { startLine: index + 1, endLine: index + quoteLines.length };
|
|
948
|
-
}
|
|
950
|
+
if (candidate.join("\n") === quoteText) ranges.push({ startLine: index + 1, endLine: index + quoteLines.length });
|
|
949
951
|
}
|
|
950
|
-
return
|
|
952
|
+
return ranges;
|
|
953
|
+
}
|
|
954
|
+
function bestCandidate(lines, candidates, selector) {
|
|
955
|
+
if (candidates.length <= 1) return candidates[0];
|
|
956
|
+
const scored = candidates.map((range) => ({ range, score: scoreContext(lines, range.startLine, range.endLine, selector) })).sort((left, right) => right.score - left.score);
|
|
957
|
+
const best = scored[0];
|
|
958
|
+
if (!best) return void 0;
|
|
959
|
+
const tied = scored.filter((candidate) => candidate.score === best.score);
|
|
960
|
+
return tied.length === 1 ? best.range : void 0;
|
|
951
961
|
}
|
|
952
962
|
function scoreContext(lines, startLine, endLine, selector) {
|
|
953
963
|
let score = 0.7;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const actualPrefix = lines.slice(Math.max(0, startLine - 4), startLine - 1).join("\n");
|
|
964
|
+
const expectedPrefix = contextPrefix(selector.prefix);
|
|
965
|
+
if (expectedPrefix) {
|
|
966
|
+
const actualPrefix = contextPrefix(lines.slice(Math.max(0, startLine - 4), startLine - 1).join("\n"));
|
|
957
967
|
if (expectedPrefix && actualPrefix.endsWith(expectedPrefix)) score += 0.15;
|
|
958
968
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const actualSuffix = lines.slice(endLine, endLine + 3).join("\n");
|
|
969
|
+
const expectedSuffix = contextSuffix(selector.suffix);
|
|
970
|
+
if (expectedSuffix) {
|
|
971
|
+
const actualSuffix = contextSuffix(lines.slice(endLine, endLine + 3).join("\n"));
|
|
962
972
|
if (expectedSuffix && actualSuffix.startsWith(expectedSuffix)) score += 0.15;
|
|
963
973
|
}
|
|
964
974
|
return Math.min(1, score);
|
|
965
975
|
}
|
|
976
|
+
function contextPrefix(value) {
|
|
977
|
+
return value?.split(/\r?\n/).filter(Boolean).slice(-3).join("\n") ?? "";
|
|
978
|
+
}
|
|
979
|
+
function contextSuffix(value) {
|
|
980
|
+
return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") ?? "";
|
|
981
|
+
}
|
|
966
982
|
|
|
967
|
-
// ../core/src/
|
|
968
|
-
var
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
const
|
|
994
|
-
if (
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const regions = [];
|
|
1023
|
-
let row = 0;
|
|
1024
|
-
let col = 0;
|
|
1025
|
-
let pendingBase = -1;
|
|
1026
|
-
let pendingSide = -1;
|
|
1027
|
-
const flush = (baseEnd, sideEnd) => {
|
|
1028
|
-
if (pendingBase < 0) return;
|
|
1029
|
-
regions.push({
|
|
1030
|
-
baseStart: prefix + pendingBase,
|
|
1031
|
-
baseLength: baseEnd - pendingBase,
|
|
1032
|
-
sideStart: prefix + pendingSide,
|
|
1033
|
-
sideLength: sideEnd - pendingSide
|
|
1034
|
-
});
|
|
1035
|
-
pendingBase = -1;
|
|
1036
|
-
pendingSide = -1;
|
|
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;
|
|
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;
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
reviewMarkdown,
|
|
1035
|
+
canonicalMarkdown: canonicalFromSegments(segments, statusLookup(void 0)),
|
|
1036
|
+
segments,
|
|
1037
|
+
suggestions
|
|
1037
1038
|
};
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1039
|
+
}
|
|
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);
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
...sidecar,
|
|
1098
|
+
reviewMarkdown,
|
|
1099
|
+
anchors,
|
|
1100
|
+
suggestions,
|
|
1101
|
+
updatedAt: now
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
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) {
|
|
1110
|
+
throw new MdocsError(
|
|
1111
|
+
"conflict",
|
|
1112
|
+
"Suggestion target does not match the review document projection.",
|
|
1113
|
+
"Re-read the document and create the suggestion against the current canonical text."
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
const [canonicalStart, canonicalEnd] = lineRangeReplacementOffsets(parsed.canonicalMarkdown, patch.range);
|
|
1117
|
+
if (selectionTouchesSuggestion(parsed.suggestions, canonicalStart, canonicalEnd)) {
|
|
1118
|
+
throw new MdocsError(
|
|
1119
|
+
"conflict",
|
|
1120
|
+
"Suggestion overlaps an unresolved suggestion.",
|
|
1121
|
+
"Accept or reject the existing suggestion before creating another edit over the same text."
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
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 {
|
|
1053
1147
|
}
|
|
1054
1148
|
}
|
|
1055
|
-
|
|
1056
|
-
return regions;
|
|
1149
|
+
return reviewMarkdown;
|
|
1057
1150
|
}
|
|
1058
|
-
function
|
|
1059
|
-
const
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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.");
|
|
1156
|
+
}
|
|
1157
|
+
const resolved = resolutionText(token, resolution);
|
|
1158
|
+
return `${reviewMarkdown.slice(0, token.markerStart)}${resolved}${reviewMarkdown.slice(token.markerEnd)}`;
|
|
1159
|
+
}
|
|
1160
|
+
function escapeReviewSuggestionText(value) {
|
|
1161
|
+
return value.replaceAll("\\", "\\\\").replaceAll("{++", "\\{++").replaceAll("++}", "\\++}").replaceAll("{--", "\\{--").replaceAll("--}", "\\--}").replaceAll("{~~", "\\{~~").replaceAll("~>", "\\~>").replaceAll("~~}", "\\~~}");
|
|
1162
|
+
}
|
|
1163
|
+
function lineRangeForCanonicalSpan(markdown, start, end) {
|
|
1164
|
+
assertCanonicalSpan(markdown, start, end);
|
|
1165
|
+
return lineRangeForOffsets(markdown, start, end);
|
|
1166
|
+
}
|
|
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
|
+
};
|
|
1177
|
+
}
|
|
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] ?? "");
|
|
1067
1205
|
}
|
|
1068
1206
|
}
|
|
1069
|
-
return
|
|
1207
|
+
return attrs2;
|
|
1070
1208
|
}
|
|
1071
|
-
function
|
|
1072
|
-
|
|
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
|
+
};
|
|
1073
1218
|
}
|
|
1074
|
-
function
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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.`);
|
|
1081
1228
|
}
|
|
1082
|
-
|
|
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
|
+
};
|
|
1083
1236
|
}
|
|
1084
|
-
function
|
|
1085
|
-
|
|
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));
|
|
1086
1242
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
);
|
|
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;
|
|
1096
1283
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
"Review the current text around the suggestion, then accept it manually or ask the agent for a fresh suggestion."
|
|
1105
|
-
);
|
|
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);
|
|
1106
1291
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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";
|
|
1114
1309
|
}
|
|
1115
|
-
const
|
|
1116
|
-
|
|
1117
|
-
return next;
|
|
1310
|
+
const record = statuses;
|
|
1311
|
+
return (suggestionId) => record[suggestionId] ?? "open";
|
|
1118
1312
|
}
|
|
1119
|
-
function
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
function rebaseSuggestionPatch(baseMarkdown, markdown, suggestion) {
|
|
1140
|
-
const baseLines = getLines(baseMarkdown);
|
|
1141
|
-
const currentLines = getLines(markdown);
|
|
1142
|
-
let regions;
|
|
1143
|
-
try {
|
|
1144
|
-
regions = diffTextRegions(baseLines, currentLines);
|
|
1145
|
-
} catch {
|
|
1146
|
-
return void 0;
|
|
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.");
|
|
1147
1333
|
}
|
|
1148
|
-
const baseStart = suggestion.patch.range.startLine - 1;
|
|
1149
|
-
const baseEnd = suggestion.patch.range.endLine;
|
|
1150
|
-
if (regions.some((region) => regionTouchesSuggestionRange(region, baseStart, baseEnd))) return void 0;
|
|
1151
|
-
const currentStart = baseStart + sideDeltaBefore(regions, baseStart, true);
|
|
1152
|
-
const currentEnd = baseEnd + sideDeltaBefore(regions, baseEnd, false);
|
|
1153
|
-
const currentRange = { startLine: currentStart + 1, endLine: currentEnd };
|
|
1154
|
-
if (!rangeIsWithin(markdown, currentRange)) return void 0;
|
|
1155
|
-
if (extractLineRange(markdown, currentRange) !== suggestion.patch.before) return void 0;
|
|
1156
|
-
const next = replaceLineRange(markdown, currentRange, suggestion.patch.after);
|
|
1157
|
-
assertCleanMarkdown(next);
|
|
1158
|
-
return next;
|
|
1159
1334
|
}
|
|
1160
|
-
function
|
|
1161
|
-
if (
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
function currentSuggestionRange(markdown, suggestion, anchor) {
|
|
1166
|
-
const hintedRange = suggestion.patch.range;
|
|
1167
|
-
if (rangeIsWithin(markdown, hintedRange) && extractLineRange(markdown, hintedRange) === suggestion.patch.before) return hintedRange;
|
|
1168
|
-
const candidates = findBeforeCandidates(markdown, suggestion.patch.before);
|
|
1169
|
-
if (candidates.length === 0) return void 0;
|
|
1170
|
-
if (candidates.length === 1) return candidates[0];
|
|
1171
|
-
if (!anchor) return void 0;
|
|
1172
|
-
const scored = candidates.map((range) => ({ range, score: scoreContext2(markdown, range, anchor) })).sort((left, right) => right.score - left.score);
|
|
1173
|
-
const best = scored[0];
|
|
1174
|
-
if (!best) return void 0;
|
|
1175
|
-
const tied = scored.filter((candidate) => candidate.score === best.score);
|
|
1176
|
-
if (tied.length > 1) return void 0;
|
|
1177
|
-
if (best.score < 0.7) return void 0;
|
|
1178
|
-
return best.range;
|
|
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
|
+
}
|
|
1179
1339
|
}
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
+
);
|
|
1189
1361
|
}
|
|
1190
1362
|
}
|
|
1191
|
-
return
|
|
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.");
|
|
1192
1365
|
}
|
|
1193
|
-
function
|
|
1194
|
-
const
|
|
1195
|
-
|
|
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;
|
|
1196
1370
|
}
|
|
1197
|
-
function
|
|
1198
|
-
|
|
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;
|
|
1199
1375
|
}
|
|
1200
|
-
function
|
|
1376
|
+
function lineRangeOffsets(markdown, range) {
|
|
1201
1377
|
const lines = getLines(markdown);
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
if (expectedPrefix) {
|
|
1205
|
-
const actualPrefix = contextPrefix(lines.slice(Math.max(0, range.startLine - 4), range.startLine - 1).join("\n"));
|
|
1206
|
-
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.");
|
|
1207
1380
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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);
|
|
1212
1413
|
}
|
|
1213
|
-
return
|
|
1414
|
+
return starts;
|
|
1214
1415
|
}
|
|
1215
|
-
function
|
|
1216
|
-
|
|
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;
|
|
1217
1423
|
}
|
|
1218
|
-
function
|
|
1219
|
-
return value
|
|
1424
|
+
function encodeAttribute(value) {
|
|
1425
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1426
|
+
}
|
|
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.");
|
|
1220
1432
|
}
|
|
1221
1433
|
|
|
1222
1434
|
// ../core/src/graph.ts
|
|
@@ -1521,6 +1733,7 @@ async function getDocumentState(io, pathOrDocId) {
|
|
|
1521
1733
|
path: entry.path,
|
|
1522
1734
|
title: entry.title,
|
|
1523
1735
|
markdown,
|
|
1736
|
+
reviewMarkdown: nextSidecar.reviewMarkdown ?? markdown,
|
|
1524
1737
|
sidecar: nextSidecar,
|
|
1525
1738
|
anchors: remappedAnchors.map((anchor) => ({
|
|
1526
1739
|
id: anchor.id,
|
|
@@ -1608,6 +1821,7 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
|
|
|
1608
1821
|
id: createId("suggestion"),
|
|
1609
1822
|
anchorId: anchor.id,
|
|
1610
1823
|
status: "open",
|
|
1824
|
+
kind: replacement.length === 0 ? "delete" : before.length === 0 ? "insert" : "replace",
|
|
1611
1825
|
author,
|
|
1612
1826
|
message,
|
|
1613
1827
|
patch: {
|
|
@@ -1624,53 +1838,72 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
|
|
|
1624
1838
|
createdAt: now,
|
|
1625
1839
|
updatedAt: now
|
|
1626
1840
|
};
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1841
|
+
const review = createSuggestionMarkup(state.sidecar.reviewMarkdown ?? state.markdown, suggestion.patch, {
|
|
1842
|
+
suggestionId: suggestion.id,
|
|
1843
|
+
author,
|
|
1844
|
+
message,
|
|
1845
|
+
createdAt: now,
|
|
1631
1846
|
updatedAt: now
|
|
1632
1847
|
});
|
|
1633
|
-
|
|
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;
|
|
1634
1857
|
}
|
|
1635
|
-
async function
|
|
1858
|
+
async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
|
|
1636
1859
|
const manifest = await indexWorkspace(io);
|
|
1637
1860
|
for (const entry of manifest.docs) {
|
|
1861
|
+
const markdown = await io.readText(entry.path);
|
|
1638
1862
|
const sidecar = await readSidecar(io, entry.docId);
|
|
1639
1863
|
const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
|
|
1640
1864
|
if (!suggestion) continue;
|
|
1641
1865
|
const now = nowIso();
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
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);
|
|
1655
1878
|
}
|
|
1656
1879
|
throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
|
|
1657
1880
|
}
|
|
1658
|
-
async function
|
|
1881
|
+
async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
|
|
1659
1882
|
const manifest = await indexWorkspace(io);
|
|
1660
1883
|
for (const entry of manifest.docs) {
|
|
1661
|
-
const markdown = await io.readText(entry.path);
|
|
1662
1884
|
const sidecar = await readSidecar(io, entry.docId);
|
|
1663
1885
|
const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
|
|
1664
1886
|
if (!suggestion) continue;
|
|
1665
|
-
const
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
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;
|
|
1669
1896
|
}
|
|
1670
1897
|
throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
|
|
1671
1898
|
}
|
|
1672
|
-
|
|
1673
|
-
return
|
|
1899
|
+
function resolvedSuggestion(suggestion, status, actor, now) {
|
|
1900
|
+
return {
|
|
1901
|
+
...suggestion,
|
|
1902
|
+
status,
|
|
1903
|
+
updatedAt: now,
|
|
1904
|
+
resolvedAt: now,
|
|
1905
|
+
resolvedBy: actor
|
|
1906
|
+
};
|
|
1674
1907
|
}
|
|
1675
1908
|
async function resolveComment(io, commentId, actor = defaultActor()) {
|
|
1676
1909
|
const manifest = await indexWorkspace(io);
|
|
@@ -4606,44 +4839,46 @@ function gatherMarks(schema2, marks) {
|
|
|
4606
4839
|
}
|
|
4607
4840
|
|
|
4608
4841
|
// ../core/src/pm-schema.ts
|
|
4842
|
+
var blockAttrs = { blockId: { default: null } };
|
|
4609
4843
|
var canonicalSchema = new Schema({
|
|
4610
4844
|
nodes: {
|
|
4611
4845
|
// The doc allows suggestion marks on its block children so pending
|
|
4612
4846
|
// block-level suggestions (node marks) survive fromJSON and can be
|
|
4613
4847
|
// structurally reverted before serialization.
|
|
4614
4848
|
doc: { content: "block+", marks: "insertion deletion modification" },
|
|
4615
|
-
paragraph: { group: "block", content: "inline*" },
|
|
4849
|
+
paragraph: { group: "block", content: "inline*", attrs: blockAttrs },
|
|
4616
4850
|
heading: {
|
|
4617
4851
|
group: "block",
|
|
4618
4852
|
content: "inline*",
|
|
4619
|
-
attrs: { level: { default: 1 } }
|
|
4853
|
+
attrs: { ...blockAttrs, level: { default: 1 } }
|
|
4620
4854
|
},
|
|
4621
|
-
blockquote: { group: "block", content: "block+" },
|
|
4855
|
+
blockquote: { group: "block", content: "block+", attrs: blockAttrs },
|
|
4622
4856
|
codeBlock: {
|
|
4623
4857
|
group: "block",
|
|
4624
4858
|
content: "text*",
|
|
4625
4859
|
marks: "",
|
|
4626
4860
|
code: true,
|
|
4627
|
-
attrs: { language: { default: null } }
|
|
4861
|
+
attrs: { ...blockAttrs, language: { default: null } }
|
|
4628
4862
|
},
|
|
4629
|
-
horizontalRule: { group: "block" },
|
|
4630
|
-
bulletList: { group: "block", content: "listItem+" },
|
|
4863
|
+
horizontalRule: { group: "block", attrs: blockAttrs },
|
|
4864
|
+
bulletList: { group: "block", content: "listItem+", attrs: blockAttrs },
|
|
4631
4865
|
orderedList: {
|
|
4632
4866
|
group: "block",
|
|
4633
4867
|
content: "listItem+",
|
|
4634
|
-
attrs: { start: { default: 1 } }
|
|
4868
|
+
attrs: { ...blockAttrs, start: { default: 1 } }
|
|
4635
4869
|
},
|
|
4636
|
-
listItem: { content: "paragraph block*" },
|
|
4637
|
-
taskList: { group: "block", content: "taskItem+" },
|
|
4870
|
+
listItem: { content: "paragraph block*", attrs: blockAttrs },
|
|
4871
|
+
taskList: { group: "block", content: "taskItem+", attrs: blockAttrs },
|
|
4638
4872
|
taskItem: {
|
|
4639
4873
|
content: "paragraph block*",
|
|
4640
|
-
attrs: { checked: { default: false } }
|
|
4874
|
+
attrs: { ...blockAttrs, checked: { default: false } }
|
|
4641
4875
|
},
|
|
4642
|
-
table: { group: "block", content: "tableRow+" },
|
|
4643
|
-
tableRow: { content: "(tableCell | tableHeader)+" },
|
|
4876
|
+
table: { group: "block", content: "tableRow+", attrs: blockAttrs },
|
|
4877
|
+
tableRow: { content: "(tableCell | tableHeader)+", attrs: blockAttrs },
|
|
4644
4878
|
tableHeader: {
|
|
4645
4879
|
content: "block+",
|
|
4646
4880
|
attrs: {
|
|
4881
|
+
...blockAttrs,
|
|
4647
4882
|
colspan: { default: 1 },
|
|
4648
4883
|
rowspan: { default: 1 },
|
|
4649
4884
|
colwidth: { default: null }
|
|
@@ -4652,6 +4887,7 @@ var canonicalSchema = new Schema({
|
|
|
4652
4887
|
tableCell: {
|
|
4653
4888
|
content: "block+",
|
|
4654
4889
|
attrs: {
|
|
4890
|
+
...blockAttrs,
|
|
4655
4891
|
colspan: { default: 1 },
|
|
4656
4892
|
rowspan: { default: 1 },
|
|
4657
4893
|
colwidth: { default: null }
|
|
@@ -10950,6 +11186,36 @@ function backticksFor2(node, side) {
|
|
|
10950
11186
|
return result;
|
|
10951
11187
|
}
|
|
10952
11188
|
|
|
11189
|
+
// ../core/src/pm-parse.ts
|
|
11190
|
+
var parser = new MarkdownParser(canonicalSchema, new lib_default(), {
|
|
11191
|
+
blockquote: { block: "blockquote" },
|
|
11192
|
+
paragraph: { block: "paragraph" },
|
|
11193
|
+
list_item: { block: "listItem" },
|
|
11194
|
+
bullet_list: { block: "bulletList" },
|
|
11195
|
+
ordered_list: {
|
|
11196
|
+
block: "orderedList",
|
|
11197
|
+
getAttrs: (tok) => ({ start: Number(tok.attrGet("start")) || 1 })
|
|
11198
|
+
},
|
|
11199
|
+
heading: { block: "heading", getAttrs: (tok) => ({ level: Number(tok.tag.slice(1)) || 1 }) },
|
|
11200
|
+
code_block: { block: "codeBlock", noCloseToken: true },
|
|
11201
|
+
fence: { block: "codeBlock", getAttrs: (tok) => ({ language: tok.info || null }), noCloseToken: true },
|
|
11202
|
+
hr: { node: "horizontalRule" },
|
|
11203
|
+
hardbreak: { node: "hardBreak" },
|
|
11204
|
+
em: { mark: "italic" },
|
|
11205
|
+
strong: { mark: "bold" },
|
|
11206
|
+
s: { mark: "strike" },
|
|
11207
|
+
link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href") }) },
|
|
11208
|
+
code_inline: { mark: "code", noCloseToken: true },
|
|
11209
|
+
image: {
|
|
11210
|
+
node: "image",
|
|
11211
|
+
getAttrs: (tok) => ({
|
|
11212
|
+
alt: tok.content,
|
|
11213
|
+
src: tok.attrGet("src"),
|
|
11214
|
+
title: tok.attrGet("title")
|
|
11215
|
+
})
|
|
11216
|
+
}
|
|
11217
|
+
});
|
|
11218
|
+
|
|
10953
11219
|
// ../core/src/remote-document-io.ts
|
|
10954
11220
|
var RemoteDocumentIO = class {
|
|
10955
11221
|
constructor(document) {
|
|
@@ -10997,7 +11263,7 @@ var RemoteDocumentIO = class {
|
|
|
10997
11263
|
};
|
|
10998
11264
|
|
|
10999
11265
|
// src/agent.ts
|
|
11000
|
-
var CLI_VERSION = "0.3.
|
|
11266
|
+
var CLI_VERSION = "0.3.17";
|
|
11001
11267
|
var CLI_PACKAGE_NAME = "@magic-markdown/cli";
|
|
11002
11268
|
var AGENT_COMMANDS = [
|
|
11003
11269
|
{
|
|
@@ -11058,10 +11324,11 @@ var AGENT_COMMANDS = [
|
|
|
11058
11324
|
{
|
|
11059
11325
|
name: "remote",
|
|
11060
11326
|
summary: "Read, organize, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
|
|
11061
|
-
usage: "mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
|
|
11327
|
+
usage: "mdocs remote status|map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
|
|
11062
11328
|
output: "json",
|
|
11063
11329
|
mutates: true,
|
|
11064
11330
|
examples: [
|
|
11331
|
+
"mdocs remote status --expect-scope file --expect-root root_abc --expect-doc doc_abc --json",
|
|
11065
11332
|
"mdocs remote context --summary --json",
|
|
11066
11333
|
"mdocs remote context --start-line 1 --end-line 100 --no-review --json",
|
|
11067
11334
|
"mdocs remote context --start-line 101 --end-line 200 --no-review --json",
|
|
@@ -11081,6 +11348,7 @@ var AGENT_COMMANDS = [
|
|
|
11081
11348
|
"mdocs remote invite-folder fold_abc123 --email person@example.com --role edit --json"
|
|
11082
11349
|
],
|
|
11083
11350
|
notes: [
|
|
11351
|
+
"Use remote status --json immediately after joining when the handoff includes expected scope/root/doc values. If it reports wrong_binding, stop and ask for the correct share or connector.",
|
|
11084
11352
|
"Use remote context --summary --json first on file-scoped joins and before reading large documents. It returns metadata, heading line numbers, current head, review counts, and suggested next commands without dumping Markdown.",
|
|
11085
11353
|
"remote context accepts --start-line and --end-line (1-based, inclusive) to page through large documents. The response always includes totalLines, startLine, and endLine \u2014 if totalLines > endLine, request the next page with --start-line <endLine+1>.",
|
|
11086
11354
|
"Use remote context --no-review when reading document content only; use remote review to list open comments, open suggestions, and anchors separately.",
|
|
@@ -11172,7 +11440,7 @@ var AGENT_COMMANDS = [
|
|
|
11172
11440
|
},
|
|
11173
11441
|
{
|
|
11174
11442
|
name: "suggest",
|
|
11175
|
-
summary: "Create a
|
|
11443
|
+
summary: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown.",
|
|
11176
11444
|
usage: "mdocs suggest <path|docId> --range <start:end> --with <markdown> --message <text> --json",
|
|
11177
11445
|
output: "json",
|
|
11178
11446
|
mutates: true,
|
|
@@ -11282,20 +11550,39 @@ var AGENT_COMMANDS = [
|
|
|
11282
11550
|
{
|
|
11283
11551
|
name: "bridge resume",
|
|
11284
11552
|
summary: "Backfill missed Magic changes after an agent restart, then keep the bridge running.",
|
|
11285
|
-
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]",
|
|
11286
11554
|
output: "long-running",
|
|
11287
11555
|
mutates: true,
|
|
11288
11556
|
examples: [
|
|
11289
|
-
"mdocs bridge resume --root . --request-token",
|
|
11557
|
+
"mdocs bridge resume --root . --forever --request-token",
|
|
11290
11558
|
"mdocs bridge resume --root . --once --request-token"
|
|
11291
11559
|
],
|
|
11292
11560
|
notes: [
|
|
11293
11561
|
"Reads non-secret connection defaults from .mdocs/bridge.json written by bridge setup.",
|
|
11294
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.",
|
|
11295
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.",
|
|
11296
11565
|
"--once performs only the backfill/reconcile step and exits."
|
|
11297
11566
|
]
|
|
11298
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
|
+
},
|
|
11299
11586
|
{
|
|
11300
11587
|
name: "bridge",
|
|
11301
11588
|
summary: "Sync a local Markdown root with a Magic workspace over WebSocket (long-running).",
|
|
@@ -11358,11 +11645,12 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
|
|
|
11358
11645
|
## Start Here
|
|
11359
11646
|
|
|
11360
11647
|
1. If given a Magic Markdown share URL, run \`mdocs join <share-url> --json\` first, and always include \`--name "<your agent name>"\` (for example \`--name "Claude Code"\`) so collaborators can see which agent is connected. Use \`mdocs remote ...\` commands after joining.
|
|
11361
|
-
2. If
|
|
11362
|
-
3.
|
|
11363
|
-
4. Run \`mdocs
|
|
11364
|
-
5.
|
|
11365
|
-
6.
|
|
11648
|
+
2. If the handoff includes expected binding values, run \`mdocs remote status --expect-scope <file|project> --expect-root <rootId> --expect-doc <docId> --json\` after joining. If it reports \`wrong_binding\`, stop and ask for the correct share or connector.
|
|
11649
|
+
3. If working in a local workspace, run \`mdocs doctor --json\` to validate the workspace and learn recommended next commands.
|
|
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>\`.
|
|
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.
|
|
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.
|
|
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.
|
|
11366
11654
|
|
|
11367
11655
|
## Filesystem Bridge / Resume
|
|
11368
11656
|
|
|
@@ -11383,16 +11671,24 @@ Return the Magic approval link and keep the bridge process running after approva
|
|
|
11383
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:
|
|
11384
11672
|
|
|
11385
11673
|
\`\`\`bash
|
|
11386
|
-
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
|
|
11675
|
+
\`\`\`
|
|
11676
|
+
|
|
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
|
|
11387
11683
|
\`\`\`
|
|
11388
11684
|
|
|
11389
|
-
|
|
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.
|
|
11390
11686
|
|
|
11391
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.
|
|
11392
11688
|
|
|
11393
11689
|
## Editing Rules
|
|
11394
11690
|
|
|
11395
|
-
- 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.
|
|
11396
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.
|
|
11397
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.
|
|
11398
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.
|
|
@@ -11407,7 +11703,7 @@ If Magic and the local root both changed the same document while you were offlin
|
|
|
11407
11703
|
## Errors, Conflicts, and Exit Codes
|
|
11408
11704
|
|
|
11409
11705
|
- Errors go to stderr. With \`--json\` they are structured: \`{ "ok": false, "error": { "code", "message", "hint" }, "cliVersion" }\`. Follow the \`hint\`.
|
|
11410
|
-
- Exit codes: 0 ok, 1 internal, 2 usage/invalid range, 3 conflict, 4 not found, 5 network, 6 unauthorized (share link revoked or expired).
|
|
11706
|
+
- Exit codes: 0 ok, 1 internal, 2 usage/invalid range, 3 conflict, 4 not found or wrong binding, 5 network, 6 unauthorized (share link revoked or expired).
|
|
11411
11707
|
- \`remote comment\` and \`remote suggest\` are concurrency-safe: the server merges review additions without clobbering concurrent edits, and the CLI rebases and retries automatically. A surviving \`conflict\` error means the document is changing rapidly \u2014 refetch context and retry.
|
|
11412
11708
|
- \`remote events --json\` may return \`"truncated": true\` when older events were dropped from the bounded event log. Do not assume nothing happened; refetch with \`mdocs remote context --summary --json\`, then read needed pages and review state.
|
|
11413
11709
|
- Retries are safe: remote writes carry a changeId and the server deduplicates replays.
|
|
@@ -11431,7 +11727,7 @@ Start the local stdio MCP server with:
|
|
|
11431
11727
|
mdocs serve-mcp --cwd /path/to/workspace
|
|
11432
11728
|
\`\`\`
|
|
11433
11729
|
|
|
11434
|
-
The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt. Use \`mdocs_context\` / \`magic_context\` with \`summary: true\` before dumping Markdown; use \`includeReview: false\` for content-only reads and \`mdocs_review\` / \`magic_review\` for review state.
|
|
11730
|
+
The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt. Use \`magic_status\` first when expected binding values are available; stop if it returns \`wrong_binding\`. Then use \`mdocs_context\` / \`magic_context\` with \`summary: true\` before dumping Markdown; use \`includeReview: false\` for content-only reads and \`mdocs_review\` / \`magic_review\` for review state.
|
|
11435
11731
|
`;
|
|
11436
11732
|
}
|
|
11437
11733
|
function formatCommandReference() {
|
|
@@ -11459,6 +11755,7 @@ var CLI_EXIT_CODES = {
|
|
|
11459
11755
|
invalid_range: 2,
|
|
11460
11756
|
conflict: 3,
|
|
11461
11757
|
not_found: 4,
|
|
11758
|
+
wrong_binding: 4,
|
|
11462
11759
|
network_error: 5,
|
|
11463
11760
|
unauthorized: 6
|
|
11464
11761
|
};
|
|
@@ -12022,7 +12319,7 @@ var tools = [
|
|
|
12022
12319
|
},
|
|
12023
12320
|
{
|
|
12024
12321
|
name: "mdocs_suggest",
|
|
12025
|
-
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.",
|
|
12026
12323
|
inputSchema: {
|
|
12027
12324
|
type: "object",
|
|
12028
12325
|
properties: {
|
|
@@ -12511,6 +12808,16 @@ async function postReview(record, docId, additions, actor) {
|
|
|
12511
12808
|
);
|
|
12512
12809
|
return response.document;
|
|
12513
12810
|
}
|
|
12811
|
+
async function postReviewOperation(record, docId, operation, actor) {
|
|
12812
|
+
return fetchJson(
|
|
12813
|
+
`${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots/${encodeURIComponent(record.rootId)}/documents/${encodeURIComponent(docId)}/review-ops`,
|
|
12814
|
+
{
|
|
12815
|
+
method: "POST",
|
|
12816
|
+
headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
|
|
12817
|
+
body: JSON.stringify({ actor, operation })
|
|
12818
|
+
}
|
|
12819
|
+
);
|
|
12820
|
+
}
|
|
12514
12821
|
async function pushDocument(record, baseDocument, markdown, sidecar) {
|
|
12515
12822
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
12516
12823
|
const payload = {
|
|
@@ -12769,7 +13076,6 @@ function joinStoreDir(root) {
|
|
|
12769
13076
|
}
|
|
12770
13077
|
|
|
12771
13078
|
// src/remote.ts
|
|
12772
|
-
var PUSH_REBASE_ATTEMPTS = 3;
|
|
12773
13079
|
async function runJoinCommand(root, parsed) {
|
|
12774
13080
|
const target = parsed.command[1];
|
|
12775
13081
|
const existingId = typeof parsed.flags.id === "string" ? parsed.flags.id : void 0;
|
|
@@ -12808,6 +13114,8 @@ async function runJoinsCommand(root) {
|
|
|
12808
13114
|
async function runRemoteCommand(root, subcommand, parsed) {
|
|
12809
13115
|
const record = await normalizedSelectedJoin(root, parsed.flags);
|
|
12810
13116
|
switch (subcommand) {
|
|
13117
|
+
case "status":
|
|
13118
|
+
return remoteStatus(record, parsed.flags);
|
|
12811
13119
|
case "map":
|
|
12812
13120
|
case void 0: {
|
|
12813
13121
|
if (record.scope === "file") {
|
|
@@ -12861,10 +13169,53 @@ async function runRemoteCommand(root, subcommand, parsed) {
|
|
|
12861
13169
|
return rejoin(root, record.joinId, parsed.flags);
|
|
12862
13170
|
default:
|
|
12863
13171
|
throw new CliError("usage_error", `Unknown remote subcommand: ${subcommand}`, {
|
|
12864
|
-
hint: "Use mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
|
|
13172
|
+
hint: "Use mdocs remote status|map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
|
|
12865
13173
|
});
|
|
12866
13174
|
}
|
|
12867
13175
|
}
|
|
13176
|
+
async function remoteStatus(record, flags) {
|
|
13177
|
+
const document = record.rootId && record.docId ? (await fetchState(record)).document : void 0;
|
|
13178
|
+
const expected = expectedBinding(flags);
|
|
13179
|
+
const actual = {
|
|
13180
|
+
joinId: record.joinId,
|
|
13181
|
+
scope: record.scope,
|
|
13182
|
+
workspaceId: record.workspaceId,
|
|
13183
|
+
rootId: record.rootId,
|
|
13184
|
+
docId: document?.docId ?? record.docId,
|
|
13185
|
+
path: document?.path,
|
|
13186
|
+
currentHead: document?.currentSha ?? record.currentHead,
|
|
13187
|
+
agent: { id: record.agentId, name: record.agentName },
|
|
13188
|
+
currentDocument: document ? {
|
|
13189
|
+
docId: document.docId,
|
|
13190
|
+
path: document.path,
|
|
13191
|
+
title: document.title,
|
|
13192
|
+
currentSha: document.currentSha
|
|
13193
|
+
} : void 0
|
|
13194
|
+
};
|
|
13195
|
+
const mismatches = bindingMismatches(expected, actual);
|
|
13196
|
+
if (mismatches.length > 0) {
|
|
13197
|
+
throw new CliError("wrong_binding", "This Magic binding is for a different document or scope.", {
|
|
13198
|
+
hint: "Use the connector URL or CLI join command from the handoff copy.",
|
|
13199
|
+
details: {
|
|
13200
|
+
expected,
|
|
13201
|
+
actual,
|
|
13202
|
+
verification: { matches: false, mismatches }
|
|
13203
|
+
}
|
|
13204
|
+
});
|
|
13205
|
+
}
|
|
13206
|
+
return {
|
|
13207
|
+
ok: true,
|
|
13208
|
+
connected: true,
|
|
13209
|
+
...actual,
|
|
13210
|
+
expected,
|
|
13211
|
+
verification: { matches: true, mismatches: [] },
|
|
13212
|
+
nextCommands: [
|
|
13213
|
+
record.scope === "workspace" ? "mdocs remote map --json" : void 0,
|
|
13214
|
+
record.scope === "project" ? "mdocs remote map --json" : void 0,
|
|
13215
|
+
record.scope === "workspace" ? void 0 : "mdocs remote context --summary --json"
|
|
13216
|
+
].filter(Boolean)
|
|
13217
|
+
};
|
|
13218
|
+
}
|
|
12868
13219
|
async function remoteGraph(record) {
|
|
12869
13220
|
assertProjectScope(record, "graph");
|
|
12870
13221
|
const rootRecord = assertRootScoped(record, "graph");
|
|
@@ -13054,6 +13405,7 @@ async function remoteCreateFile(root, record, parsed) {
|
|
|
13054
13405
|
path: normalizedPath,
|
|
13055
13406
|
title,
|
|
13056
13407
|
markdown,
|
|
13408
|
+
reviewMarkdown: markdown,
|
|
13057
13409
|
sidecar,
|
|
13058
13410
|
anchors: [],
|
|
13059
13411
|
images: [],
|
|
@@ -13108,13 +13460,24 @@ async function remoteSuggest(root, record, parsed) {
|
|
|
13108
13460
|
const replacement = await readRequiredTextFlag(parsed.flags, root, ["with", "replacement"]);
|
|
13109
13461
|
const message = await readOptionalTextFlag(parsed.flags, root, ["message"]) ?? "Suggested edit";
|
|
13110
13462
|
const range = parseRange(requiredFlag(parsed.flags, "range"));
|
|
13111
|
-
const
|
|
13112
|
-
|
|
13113
|
-
|
|
13114
|
-
|
|
13115
|
-
|
|
13463
|
+
const pathOrDocId = parsed.command[2] ?? record.docId;
|
|
13464
|
+
if (!pathOrDocId) {
|
|
13465
|
+
throw new CliError("usage_error", "Missing document path or docId.", {
|
|
13466
|
+
hint: "Pass a document path or docId, or join with --doc <docId>."
|
|
13467
|
+
});
|
|
13468
|
+
}
|
|
13469
|
+
const document = await fetchDocument(record, pathOrDocId);
|
|
13470
|
+
const documentRecord = rootScopedRecordFor(record, document.rootId);
|
|
13471
|
+
await refreshPresence(documentRecord, document.docId);
|
|
13472
|
+
const result = await postReviewOperation(
|
|
13473
|
+
documentRecord,
|
|
13474
|
+
document.docId,
|
|
13475
|
+
{ kind: "create_suggestion", baseHead: document.currentSha, payload: { ...range, replacement, message } },
|
|
13476
|
+
actorForRecord(record)
|
|
13116
13477
|
);
|
|
13117
|
-
|
|
13478
|
+
if (result.document) await recordHead(root, record, result.document);
|
|
13479
|
+
const placementStatus = result.reviewRecord?.placement?.status;
|
|
13480
|
+
return { suggestion: result.suggestion, reviewRecord: result.reviewRecord, placementStatus, projectionStatus: result.projectionStatus, document: result.document };
|
|
13118
13481
|
}
|
|
13119
13482
|
async function remoteCreateFolder(record, parsed) {
|
|
13120
13483
|
assertProjectScope(record, "create-folder");
|
|
@@ -13204,44 +13567,14 @@ async function remoteReject(root, record, parsed) {
|
|
|
13204
13567
|
hint: "Only open suggestions can be rejected."
|
|
13205
13568
|
});
|
|
13206
13569
|
}
|
|
13207
|
-
|
|
13208
|
-
|
|
13209
|
-
|
|
13210
|
-
|
|
13211
|
-
|
|
13212
|
-
|
|
13213
|
-
|
|
13214
|
-
|
|
13215
|
-
}
|
|
13216
|
-
if (suggestion.author.id !== actor.id) {
|
|
13217
|
-
throw new CliError("unauthorized", "You can only withdraw your own suggestions; rejecting another author's requires edit access.", {
|
|
13218
|
-
hint: "Ask a human collaborator to reject it from the editor."
|
|
13219
|
-
});
|
|
13220
|
-
}
|
|
13221
|
-
for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
|
|
13222
|
-
if (index > 0) document = await fetchDocument(record, document.docId);
|
|
13223
|
-
documentRecord = rootScopedRecordFor(record, document.rootId);
|
|
13224
|
-
const io = new RemoteDocumentIO(document);
|
|
13225
|
-
const rejected = await rejectSuggestion(io, suggestionId, actor);
|
|
13226
|
-
const state = await getDocumentState(io, document.docId);
|
|
13227
|
-
try {
|
|
13228
|
-
const pushed = await pushDocument(documentRecord, document, state.markdown, state.sidecar);
|
|
13229
|
-
await recordHead(root, record, pushed);
|
|
13230
|
-
return { suggestion: rejected, document: pushed };
|
|
13231
|
-
} catch (error) {
|
|
13232
|
-
const conflicted = isCliError(error) && error.code === "conflict";
|
|
13233
|
-
if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
|
|
13234
|
-
}
|
|
13235
|
-
}
|
|
13236
|
-
throw new CliError("conflict", "Remote change was not accepted after retries.", {
|
|
13237
|
-
hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
|
|
13238
|
-
});
|
|
13239
|
-
}
|
|
13240
|
-
function isWithdrawalUnsupported(error) {
|
|
13241
|
-
if (isReviewEndpointUnsupported(error)) return true;
|
|
13242
|
-
if (!isCliError(error)) return false;
|
|
13243
|
-
const details = error.details ?? {};
|
|
13244
|
-
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 };
|
|
13245
13578
|
}
|
|
13246
13579
|
async function submitReview(root, record, pathOrDocId, mutate) {
|
|
13247
13580
|
if (!pathOrDocId) {
|
|
@@ -13262,43 +13595,14 @@ async function submitReview(root, record, pathOrDocId, mutate) {
|
|
|
13262
13595
|
additions: {
|
|
13263
13596
|
anchors: state.sidecar.anchors.filter((anchor) => anchor.id === created.anchorId),
|
|
13264
13597
|
comments: state.sidecar.comments.filter((comment2) => comment2.id === created.id),
|
|
13265
|
-
|
|
13598
|
+
reviewMarkdown: state.sidecar.reviewMarkdown
|
|
13266
13599
|
}
|
|
13267
13600
|
};
|
|
13268
13601
|
};
|
|
13269
|
-
|
|
13270
|
-
|
|
13271
|
-
|
|
13272
|
-
|
|
13273
|
-
return { created: attempt.created, document: pushed };
|
|
13274
|
-
} catch (error) {
|
|
13275
|
-
if (!isReviewEndpointUnsupported(error)) throw error;
|
|
13276
|
-
}
|
|
13277
|
-
for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
|
|
13278
|
-
if (index > 0) {
|
|
13279
|
-
document = await fetchDocument(record, document.docId);
|
|
13280
|
-
documentRecord = rootScopedRecordFor(record, document.rootId);
|
|
13281
|
-
attempt = await build(document);
|
|
13282
|
-
}
|
|
13283
|
-
try {
|
|
13284
|
-
const pushed = await pushDocument(documentRecord, document, attempt.state.markdown, attempt.state.sidecar);
|
|
13285
|
-
await recordHead(root, record, pushed);
|
|
13286
|
-
return { created: attempt.created, document: pushed };
|
|
13287
|
-
} catch (error) {
|
|
13288
|
-
const conflicted = isCliError(error) && error.code === "conflict";
|
|
13289
|
-
if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
|
|
13290
|
-
}
|
|
13291
|
-
}
|
|
13292
|
-
throw new CliError("conflict", "Remote change was not accepted after retries.", {
|
|
13293
|
-
hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
|
|
13294
|
-
});
|
|
13295
|
-
}
|
|
13296
|
-
function isReviewEndpointUnsupported(error) {
|
|
13297
|
-
if (!isCliError(error)) return false;
|
|
13298
|
-
if (error.code === "unauthorized") return true;
|
|
13299
|
-
if (error.code !== "not_found") return false;
|
|
13300
|
-
const details = error.details ?? {};
|
|
13301
|
-
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 };
|
|
13302
13606
|
}
|
|
13303
13607
|
async function recordHead(root, record, document) {
|
|
13304
13608
|
await writeJoinRecord(root, { ...record, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), currentHead: document.currentSha });
|
|
@@ -13379,6 +13683,33 @@ function joinSummary(record, document) {
|
|
|
13379
13683
|
].filter(Boolean)
|
|
13380
13684
|
};
|
|
13381
13685
|
}
|
|
13686
|
+
function expectedBinding(flags) {
|
|
13687
|
+
const scope = stringFlag(flags, "expect-scope", "expected-scope");
|
|
13688
|
+
const rootId = stringFlag(flags, "expect-root", "expected-root");
|
|
13689
|
+
const docId = stringFlag(flags, "expect-doc", "expected-doc");
|
|
13690
|
+
const path = stringFlag(flags, "expect-path", "expected-path");
|
|
13691
|
+
return {
|
|
13692
|
+
...scope ? { scope } : {},
|
|
13693
|
+
...rootId ? { rootId } : {},
|
|
13694
|
+
...docId ? { docId } : {},
|
|
13695
|
+
...path ? { path } : {}
|
|
13696
|
+
};
|
|
13697
|
+
}
|
|
13698
|
+
function stringFlag(flags, primary, alias) {
|
|
13699
|
+
const value = flags[primary] ?? flags[alias];
|
|
13700
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
13701
|
+
}
|
|
13702
|
+
function bindingMismatches(expected, actual) {
|
|
13703
|
+
const checks = [
|
|
13704
|
+
["scope", expected.scope, actual.scope],
|
|
13705
|
+
["rootId", expected.rootId, actual.rootId],
|
|
13706
|
+
["docId", expected.docId, actual.docId],
|
|
13707
|
+
["path", expected.path, actual.path]
|
|
13708
|
+
];
|
|
13709
|
+
return checks.flatMap(
|
|
13710
|
+
([field, expectedValue, actualValue]) => expectedValue && expectedValue !== actualValue ? [{ field, expected: expectedValue, actual: actualValue }] : []
|
|
13711
|
+
);
|
|
13712
|
+
}
|
|
13382
13713
|
function assertProjectScope(record, command) {
|
|
13383
13714
|
if (record.scope === "project") return;
|
|
13384
13715
|
throw new CliError("usage_error", `remote ${command} requires a project-scoped join.`, {
|
|
@@ -13446,6 +13777,8 @@ function optionalFolderTarget(value) {
|
|
|
13446
13777
|
import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
|
|
13447
13778
|
import { basename as basename2, resolve as resolve4 } from "node:path";
|
|
13448
13779
|
var BRIDGE_CONFIG_PATH = ".mdocs/bridge.json";
|
|
13780
|
+
var BRIDGE_STATUS_PATH = ".mdocs/bridge-status.json";
|
|
13781
|
+
var BRIDGE_HEARTBEAT_INTERVAL_MS = 1e4;
|
|
13449
13782
|
function bridgeSetupIdentity(input) {
|
|
13450
13783
|
const actorName = input.actorName?.trim() || "Agent";
|
|
13451
13784
|
return {
|
|
@@ -13511,9 +13844,11 @@ async function runBridgeResume(options) {
|
|
|
13511
13844
|
}
|
|
13512
13845
|
async function runBridge(options) {
|
|
13513
13846
|
const root = resolve4(options.root);
|
|
13847
|
+
if (options.once && options.forever) {
|
|
13848
|
+
throw new Error("Use either --once or --forever, not both.");
|
|
13849
|
+
}
|
|
13514
13850
|
const localManifestSource = await readLocalSource(root);
|
|
13515
13851
|
const rootId = options.rootId ?? options.sourceId ?? localManifestSource?.sourceId ?? rootIdForPath(root);
|
|
13516
|
-
const token = options.token ?? (options.requestToken ? await requestBridgeToken(options, rootId) : void 0);
|
|
13517
13852
|
const replicaId = `replica_${createHash2("sha256").update(`${root}:${options.actorId}`).digest("hex").slice(0, 12)}`;
|
|
13518
13853
|
const replicaKind = actorKindForBridge(options.actorId) === "agent" ? "agent_runtime" : "local";
|
|
13519
13854
|
const mapping = createSourceMapping({
|
|
@@ -13536,7 +13871,29 @@ async function runBridge(options) {
|
|
|
13536
13871
|
const claimMode = Boolean(options.claimToken || localManifestSource?.canonicalHead);
|
|
13537
13872
|
let lastAppliedHead = localManifestSource?.canonicalHead;
|
|
13538
13873
|
let socket;
|
|
13539
|
-
|
|
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
|
+
}
|
|
13540
13897
|
lastAppliedHead = lastAppliedHead ?? registeredRoot?.canonical.head;
|
|
13541
13898
|
await writeSourceState(lastAppliedHead);
|
|
13542
13899
|
await writeBridgeConfig(root, {
|
|
@@ -13554,20 +13911,30 @@ async function runBridge(options) {
|
|
|
13554
13911
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13555
13912
|
});
|
|
13556
13913
|
const backfilled = await resumeFromCanonical();
|
|
13557
|
-
if (options.once)
|
|
13914
|
+
if (options.once) {
|
|
13915
|
+
lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
13916
|
+
await writeCurrentBridgeStatus("once_complete");
|
|
13917
|
+
return;
|
|
13918
|
+
}
|
|
13558
13919
|
connect();
|
|
13559
13920
|
if (claimMode && !backfilled) await primeLocalSignatures();
|
|
13560
13921
|
else await publishSnapshot("initial");
|
|
13561
13922
|
const timer = setInterval(() => {
|
|
13562
13923
|
void publishSnapshot("poll").catch((error) => {
|
|
13563
|
-
|
|
13924
|
+
lastError = errorMessage(error);
|
|
13925
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
13926
|
+
sendHeartbeat("error", lastError);
|
|
13927
|
+
process.stderr.write(`bridge poll failed: ${lastError}
|
|
13564
13928
|
`);
|
|
13565
13929
|
});
|
|
13566
13930
|
}, options.intervalMs);
|
|
13567
|
-
process.
|
|
13931
|
+
process.once("SIGINT", () => {
|
|
13568
13932
|
clearInterval(timer);
|
|
13569
|
-
|
|
13570
|
-
|
|
13933
|
+
sendHeartbeat("offline", void 0, false);
|
|
13934
|
+
void writeCurrentBridgeStatus("stopped").catch(() => void 0).finally(() => {
|
|
13935
|
+
socket?.close();
|
|
13936
|
+
process.exit(0);
|
|
13937
|
+
});
|
|
13571
13938
|
});
|
|
13572
13939
|
await new Promise(() => void 0);
|
|
13573
13940
|
function connect() {
|
|
@@ -13578,8 +13945,11 @@ async function runBridge(options) {
|
|
|
13578
13945
|
if (token) url.searchParams.set("token", token);
|
|
13579
13946
|
socket = new WebSocket(url);
|
|
13580
13947
|
socket.addEventListener("open", () => {
|
|
13948
|
+
lastError = void 0;
|
|
13581
13949
|
process.stdout.write(`mdocs bridge connected ${root} -> ${options.workspaceId}/${rootId}
|
|
13582
13950
|
`);
|
|
13951
|
+
sendHeartbeat("synced");
|
|
13952
|
+
void writeCurrentBridgeStatus("connected").catch(() => void 0);
|
|
13583
13953
|
});
|
|
13584
13954
|
socket.addEventListener("message", (event) => {
|
|
13585
13955
|
if (typeof event.data !== "string") return;
|
|
@@ -13592,9 +13962,12 @@ async function runBridge(options) {
|
|
|
13592
13962
|
});
|
|
13593
13963
|
socket.addEventListener("close", () => {
|
|
13594
13964
|
socket = void 0;
|
|
13965
|
+
void writeCurrentBridgeStatus("offline").catch(() => void 0);
|
|
13595
13966
|
setTimeout(connect, 1e3);
|
|
13596
13967
|
});
|
|
13597
13968
|
socket.addEventListener("error", () => {
|
|
13969
|
+
lastError = "WebSocket error";
|
|
13970
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
13598
13971
|
socket?.close();
|
|
13599
13972
|
});
|
|
13600
13973
|
}
|
|
@@ -13603,7 +13976,7 @@ async function runBridge(options) {
|
|
|
13603
13976
|
const currentDocIds = new Set(map.docs.map((doc) => doc.docId));
|
|
13604
13977
|
const treeSignature = hashJson(map.docs.map((doc) => ({ docId: doc.docId, path: doc.path })));
|
|
13605
13978
|
if (treeSignature !== lastTreeSignature) {
|
|
13606
|
-
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 })) {
|
|
13607
13980
|
lastTreeSignature = treeSignature;
|
|
13608
13981
|
}
|
|
13609
13982
|
}
|
|
@@ -13631,6 +14004,9 @@ async function runBridge(options) {
|
|
|
13631
14004
|
pendingDeletes.add(docId);
|
|
13632
14005
|
}
|
|
13633
14006
|
}
|
|
14007
|
+
lastError = void 0;
|
|
14008
|
+
lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14009
|
+
maybeSendHeartbeat("synced");
|
|
13634
14010
|
}
|
|
13635
14011
|
async function resumeFromCanonical() {
|
|
13636
14012
|
if (!options.resume && !token) return false;
|
|
@@ -13743,7 +14119,9 @@ async function runBridge(options) {
|
|
|
13743
14119
|
}
|
|
13744
14120
|
if (message.type === "sync-error") {
|
|
13745
14121
|
const payload = message.payload;
|
|
13746
|
-
|
|
14122
|
+
lastError = `sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}`;
|
|
14123
|
+
void writeCurrentBridgeStatus("error").catch(() => void 0);
|
|
14124
|
+
process.stderr.write(`${lastError}
|
|
13747
14125
|
`);
|
|
13748
14126
|
if (!payload.docId) return;
|
|
13749
14127
|
pendingDocs.delete(payload.docId);
|
|
@@ -13782,6 +14160,12 @@ async function runBridge(options) {
|
|
|
13782
14160
|
await io.writeTextAtomic(payload.path, payload.markdown);
|
|
13783
14161
|
await io.writeTextAtomic(sidecarPath(payload.docId), `${JSON.stringify(payload.sidecar, null, 2)}
|
|
13784
14162
|
`);
|
|
14163
|
+
const reviewMarkdown = projectReviewMarkdown(payload.markdown, payload.sidecar.suggestions);
|
|
14164
|
+
if (reviewMarkdown === payload.markdown) {
|
|
14165
|
+
await io.deleteFile(reviewFilePath(payload.path)).catch(() => void 0);
|
|
14166
|
+
} else {
|
|
14167
|
+
await io.writeTextAtomic(reviewFilePath(payload.path), reviewMarkdown);
|
|
14168
|
+
}
|
|
13785
14169
|
await upsertManifestDocument(payload);
|
|
13786
14170
|
signatures.set(payload.docId, remoteSignature);
|
|
13787
14171
|
deniedSignatures.delete(payload.docId);
|
|
@@ -13862,6 +14246,63 @@ async function runBridge(options) {
|
|
|
13862
14246
|
);
|
|
13863
14247
|
return true;
|
|
13864
14248
|
}
|
|
14249
|
+
function maybeSendHeartbeat(status) {
|
|
14250
|
+
if (Date.now() - lastHeartbeatSentMs < BRIDGE_HEARTBEAT_INTERVAL_MS) return;
|
|
14251
|
+
sendHeartbeat(status);
|
|
14252
|
+
}
|
|
14253
|
+
function sendHeartbeat(status, error, writeLocalStatus = true) {
|
|
14254
|
+
const heartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14255
|
+
const sent = send("bridge-heartbeat", {
|
|
14256
|
+
reason: "heartbeat",
|
|
14257
|
+
canonicalHead: lastAppliedHead,
|
|
14258
|
+
mapping: bridgeReplicaMetadata(status, error, heartbeatAt)
|
|
14259
|
+
});
|
|
14260
|
+
if (sent) {
|
|
14261
|
+
lastHeartbeatAt = heartbeatAt;
|
|
14262
|
+
lastHeartbeatSentMs = Date.now();
|
|
14263
|
+
if (writeLocalStatus) {
|
|
14264
|
+
void writeCurrentBridgeStatus(status === "error" ? "error" : status === "offline" ? "offline" : "connected").catch(() => void 0);
|
|
14265
|
+
}
|
|
14266
|
+
}
|
|
14267
|
+
return sent;
|
|
14268
|
+
}
|
|
14269
|
+
function bridgeReplicaMetadata(status, error, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
14270
|
+
return {
|
|
14271
|
+
...mapping,
|
|
14272
|
+
lastAppliedHead,
|
|
14273
|
+
status,
|
|
14274
|
+
updatedAt: now,
|
|
14275
|
+
lastSeenAt: now,
|
|
14276
|
+
bridgeVersion: CLI_VERSION,
|
|
14277
|
+
pendingDocuments: pendingDocs.size,
|
|
14278
|
+
pendingDeletes: pendingDeletes.size,
|
|
14279
|
+
...error ? { lastError: error } : {}
|
|
14280
|
+
};
|
|
14281
|
+
}
|
|
14282
|
+
function currentBridgeStatus(state) {
|
|
14283
|
+
return {
|
|
14284
|
+
schemaVersion: 1,
|
|
14285
|
+
workspaceId: options.workspaceId,
|
|
14286
|
+
rootId,
|
|
14287
|
+
actorId: options.actorId,
|
|
14288
|
+
actorName: options.actorName,
|
|
14289
|
+
sourceName: options.sourceName,
|
|
14290
|
+
baseUrl: options.baseUrl,
|
|
14291
|
+
state,
|
|
14292
|
+
connected: state === "connected",
|
|
14293
|
+
bridgeVersion: CLI_VERSION,
|
|
14294
|
+
lastAppliedHead,
|
|
14295
|
+
lastSuccessfulSyncAt,
|
|
14296
|
+
lastHeartbeatAt,
|
|
14297
|
+
lastError,
|
|
14298
|
+
pendingDocuments: pendingDocs.size,
|
|
14299
|
+
pendingDeletes: pendingDeletes.size,
|
|
14300
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
14301
|
+
};
|
|
14302
|
+
}
|
|
14303
|
+
async function writeCurrentBridgeStatus(state) {
|
|
14304
|
+
await writeBridgeStatus(root, currentBridgeStatus(state));
|
|
14305
|
+
}
|
|
13865
14306
|
async function primeLocalSignatures() {
|
|
13866
14307
|
const map = await getWorkspaceMap(io);
|
|
13867
14308
|
for (const doc of map.docs) {
|
|
@@ -13989,6 +14430,12 @@ async function writeBridgeConfig(root, config2) {
|
|
|
13989
14430
|
await io.writeTextAtomic(BRIDGE_CONFIG_PATH, `${JSON.stringify(config2, null, 2)}
|
|
13990
14431
|
`);
|
|
13991
14432
|
}
|
|
14433
|
+
async function writeBridgeStatus(root, status) {
|
|
14434
|
+
const io = new NodeWorkspaceIO(root);
|
|
14435
|
+
await io.mkdir(".mdocs");
|
|
14436
|
+
await io.writeTextAtomic(BRIDGE_STATUS_PATH, `${JSON.stringify(status, null, 2)}
|
|
14437
|
+
`);
|
|
14438
|
+
}
|
|
13992
14439
|
async function fetchCanonicalSnapshot(options, rootId) {
|
|
13993
14440
|
const response = await fetch(new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}/sync`, options.baseUrl), {
|
|
13994
14441
|
headers: authHeaders(options.token)
|
|
@@ -14059,11 +14506,14 @@ function parseBridgeSyncMessage(data) {
|
|
|
14059
14506
|
}
|
|
14060
14507
|
}
|
|
14061
14508
|
function delay(ms) {
|
|
14062
|
-
return new Promise((
|
|
14509
|
+
return new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
14063
14510
|
}
|
|
14064
14511
|
function authHeaders(token) {
|
|
14065
14512
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
14066
14513
|
}
|
|
14514
|
+
function errorMessage(error) {
|
|
14515
|
+
return error instanceof Error ? error.message : String(error);
|
|
14516
|
+
}
|
|
14067
14517
|
async function fetchScopedRoot(options, rootId) {
|
|
14068
14518
|
const response = await fetch(
|
|
14069
14519
|
new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}`, options.baseUrl),
|
|
@@ -14167,6 +14617,9 @@ function conflictCopyPath(path) {
|
|
|
14167
14617
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "").slice(0, 15);
|
|
14168
14618
|
return path.replace(/(\.[^./]+)?$/, (extension) => `.conflict-${stamp}${extension || ".md"}`);
|
|
14169
14619
|
}
|
|
14620
|
+
function reviewFilePath(path) {
|
|
14621
|
+
return path.replace(/(\.md)?$/i, ".review.md");
|
|
14622
|
+
}
|
|
14170
14623
|
function hashJson(value) {
|
|
14171
14624
|
return createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14172
14625
|
}
|
|
@@ -14198,10 +14651,246 @@ function readDocumentPayload(payload) {
|
|
|
14198
14651
|
};
|
|
14199
14652
|
}
|
|
14200
14653
|
|
|
14654
|
+
// src/bridge-startup.ts
|
|
14655
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
14656
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
14657
|
+
import { chmod, copyFile, mkdir as mkdir4, readFile as readFile6, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
|
|
14658
|
+
import { homedir, platform } from "node:os";
|
|
14659
|
+
import { dirname as dirname5, join as join4, resolve as resolve5 } from "node:path";
|
|
14660
|
+
import { promisify as promisify2 } from "node:util";
|
|
14661
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
14662
|
+
async function runBridgeStartup(options) {
|
|
14663
|
+
const root = resolve5(options.root);
|
|
14664
|
+
await assertBridgeConfigured(root);
|
|
14665
|
+
const requestedMode = readBridgeStartupMode(options.mode);
|
|
14666
|
+
const mode = requestedMode === "auto" ? await autoBridgeStartupMode() : requestedMode;
|
|
14667
|
+
const startupDir = join4(root, ".mdocs", "startup");
|
|
14668
|
+
await mkdir4(startupDir, { recursive: true });
|
|
14669
|
+
const runnerPath = join4(startupDir, "bridge-runner.sh");
|
|
14670
|
+
const runnerArgs = bridgeResumeArgs(root, options);
|
|
14671
|
+
const runnerScript = bridgeRunnerScript(root, runnerArgs);
|
|
14672
|
+
await writeFile4(runnerPath, runnerScript, "utf8");
|
|
14673
|
+
await chmod(runnerPath, 493);
|
|
14674
|
+
const nativePlan = mode === "launchd" ? await writeLaunchdPlan(root, startupDir, runnerPath) : mode === "systemd" ? await writeSystemdPlan(root, startupDir, runnerPath) : void 0;
|
|
14675
|
+
if (options.install && !nativePlan) {
|
|
14676
|
+
throw new CliError("usage_error", "Shell startup mode does not have a native service to install.", {
|
|
14677
|
+
hint: "Use the printed startupCommand in your sandbox/provider startup hook, or pass --mode launchd/systemd on a supported host."
|
|
14678
|
+
});
|
|
14679
|
+
}
|
|
14680
|
+
if (options.install && nativePlan) await nativePlan.install();
|
|
14681
|
+
const result = {
|
|
14682
|
+
ok: true,
|
|
14683
|
+
root,
|
|
14684
|
+
mode,
|
|
14685
|
+
runnerPath,
|
|
14686
|
+
startupCommand: shellCommand(["sh", runnerPath]),
|
|
14687
|
+
servicePath: nativePlan?.servicePath,
|
|
14688
|
+
installPath: nativePlan?.installPath,
|
|
14689
|
+
installed: Boolean(options.install && nativePlan),
|
|
14690
|
+
installCommands: nativePlan?.installCommands ?? [],
|
|
14691
|
+
notes: [
|
|
14692
|
+
"Sandbox providers such as Daytona, Sprites, and Modal should run startupCommand from their on-start hook.",
|
|
14693
|
+
"The runner stores no bridge token. If you use manual tokens, provide MDOCS_BRIDGE_TOKEN through the platform secret environment.",
|
|
14694
|
+
"If no MDOCS_BRIDGE_TOKEN is present, the runner uses --request-token and logs a Magic approval URL."
|
|
14695
|
+
]
|
|
14696
|
+
};
|
|
14697
|
+
await writeFile4(join4(startupDir, "bridge-startup.json"), `${JSON.stringify(result, null, 2)}
|
|
14698
|
+
`, "utf8");
|
|
14699
|
+
return result;
|
|
14700
|
+
}
|
|
14701
|
+
function formatBridgeStartupResult(result) {
|
|
14702
|
+
const lines = [
|
|
14703
|
+
`Wrote Magic Markdown bridge runner: ${result.runnerPath}`,
|
|
14704
|
+
`Startup command: ${result.startupCommand}`
|
|
14705
|
+
];
|
|
14706
|
+
if (result.servicePath) lines.push(`Wrote ${result.mode} service file: ${result.servicePath}`);
|
|
14707
|
+
if (result.installed && result.installPath) {
|
|
14708
|
+
lines.push(`Installed ${result.mode} service: ${result.installPath}`);
|
|
14709
|
+
} else if (result.installCommands.length > 0) {
|
|
14710
|
+
lines.push("", "To install and start it manually:", ...result.installCommands.map((command) => ` ${command}`));
|
|
14711
|
+
}
|
|
14712
|
+
lines.push("", ...result.notes.map((note) => `Note: ${note}`));
|
|
14713
|
+
return lines.join("\n");
|
|
14714
|
+
}
|
|
14715
|
+
function bridgeRunnerScript(root, args) {
|
|
14716
|
+
return `#!/usr/bin/env sh
|
|
14717
|
+
set -eu
|
|
14718
|
+
cd ${shellQuote(root)}
|
|
14719
|
+
exec npx --yes --package=@magic-markdown/cli@latest mdocs ${shellCommand(args)} "$@"
|
|
14720
|
+
`;
|
|
14721
|
+
}
|
|
14722
|
+
function bridgeResumeArgs(root, options) {
|
|
14723
|
+
const args = ["bridge", "resume", "--root", root, "--forever"];
|
|
14724
|
+
if (options.requestToken !== false) args.push("--request-token");
|
|
14725
|
+
if (options.baseUrl) args.push("--url", options.baseUrl);
|
|
14726
|
+
if (options.intervalMs) args.push("--interval", String(options.intervalMs));
|
|
14727
|
+
return args;
|
|
14728
|
+
}
|
|
14729
|
+
function readBridgeStartupMode(value) {
|
|
14730
|
+
if (!value || value === "auto" || value === "shell" || value === "launchd" || value === "systemd") {
|
|
14731
|
+
return value || "auto";
|
|
14732
|
+
}
|
|
14733
|
+
throw new CliError("usage_error", `Unknown bridge startup mode: ${value}`, {
|
|
14734
|
+
hint: "Use --mode auto, shell, launchd, or systemd."
|
|
14735
|
+
});
|
|
14736
|
+
}
|
|
14737
|
+
async function autoBridgeStartupMode() {
|
|
14738
|
+
if (platform() === "darwin") return "launchd";
|
|
14739
|
+
if (platform() === "linux" && await pathExists("/run/systemd/system")) return "systemd";
|
|
14740
|
+
return "shell";
|
|
14741
|
+
}
|
|
14742
|
+
async function assertBridgeConfigured(root) {
|
|
14743
|
+
const configPath = join4(root, ".mdocs", "bridge.json");
|
|
14744
|
+
try {
|
|
14745
|
+
const config2 = JSON.parse(await readFile6(configPath, "utf8"));
|
|
14746
|
+
if (config2.schemaVersion !== 1) throw new Error("invalid schema");
|
|
14747
|
+
} catch {
|
|
14748
|
+
throw new CliError("usage_error", "This root does not have a bridge configuration yet.", {
|
|
14749
|
+
hint: "Run mdocs bridge setup first, then run mdocs bridge startup from the same root."
|
|
14750
|
+
});
|
|
14751
|
+
}
|
|
14752
|
+
}
|
|
14753
|
+
async function writeLaunchdPlan(root, startupDir, runnerPath) {
|
|
14754
|
+
const label = serviceLabel(root);
|
|
14755
|
+
const servicePath = join4(startupDir, `${label}.plist`);
|
|
14756
|
+
const installPath = join4(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
14757
|
+
const outLog = join4(root, ".mdocs", "bridge.log");
|
|
14758
|
+
const errLog = join4(root, ".mdocs", "bridge.err.log");
|
|
14759
|
+
await writeFile4(servicePath, launchdPlist(label, root, runnerPath, outLog, errLog), "utf8");
|
|
14760
|
+
const target = `gui/$(id -u)`;
|
|
14761
|
+
return {
|
|
14762
|
+
servicePath,
|
|
14763
|
+
installPath,
|
|
14764
|
+
installCommands: [
|
|
14765
|
+
shellCommand(["mkdir", "-p", dirname5(installPath)]),
|
|
14766
|
+
shellCommand(["cp", servicePath, installPath]),
|
|
14767
|
+
`launchctl bootout ${target} ${shellQuote(installPath)} 2>/dev/null || true`,
|
|
14768
|
+
`launchctl bootstrap ${target} ${shellQuote(installPath)}`,
|
|
14769
|
+
`launchctl enable ${target}/${label}`,
|
|
14770
|
+
`launchctl kickstart -k ${target}/${label}`
|
|
14771
|
+
],
|
|
14772
|
+
install: async () => {
|
|
14773
|
+
await mkdir4(dirname5(installPath), { recursive: true });
|
|
14774
|
+
await copyFile(servicePath, installPath);
|
|
14775
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
14776
|
+
if (uid === void 0) return;
|
|
14777
|
+
const launchTarget = `gui/${uid}`;
|
|
14778
|
+
await runOptionalCommand("launchctl", ["bootout", launchTarget, installPath]);
|
|
14779
|
+
await runCommand("launchctl", ["bootstrap", launchTarget, installPath]);
|
|
14780
|
+
await runCommand("launchctl", ["enable", `${launchTarget}/${label}`]);
|
|
14781
|
+
await runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${label}`]);
|
|
14782
|
+
}
|
|
14783
|
+
};
|
|
14784
|
+
}
|
|
14785
|
+
async function writeSystemdPlan(root, startupDir, runnerPath) {
|
|
14786
|
+
const unitName = `${serviceLabel(root)}.service`;
|
|
14787
|
+
const servicePath = join4(startupDir, unitName);
|
|
14788
|
+
const installPath = join4(homedir(), ".config", "systemd", "user", unitName);
|
|
14789
|
+
await writeFile4(servicePath, systemdUnit(unitName, root, runnerPath), "utf8");
|
|
14790
|
+
return {
|
|
14791
|
+
servicePath,
|
|
14792
|
+
installPath,
|
|
14793
|
+
installCommands: [
|
|
14794
|
+
shellCommand(["mkdir", "-p", dirname5(installPath)]),
|
|
14795
|
+
shellCommand(["cp", servicePath, installPath]),
|
|
14796
|
+
"systemctl --user daemon-reload",
|
|
14797
|
+
shellCommand(["systemctl", "--user", "enable", "--now", unitName])
|
|
14798
|
+
],
|
|
14799
|
+
install: async () => {
|
|
14800
|
+
await mkdir4(dirname5(installPath), { recursive: true });
|
|
14801
|
+
await copyFile(servicePath, installPath);
|
|
14802
|
+
await runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
14803
|
+
await runCommand("systemctl", ["--user", "enable", "--now", unitName]);
|
|
14804
|
+
}
|
|
14805
|
+
};
|
|
14806
|
+
}
|
|
14807
|
+
function launchdPlist(label, root, runnerPath, outLog, errLog) {
|
|
14808
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
14809
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
14810
|
+
<plist version="1.0">
|
|
14811
|
+
<dict>
|
|
14812
|
+
<key>Label</key>
|
|
14813
|
+
<string>${escapeXml(label)}</string>
|
|
14814
|
+
<key>ProgramArguments</key>
|
|
14815
|
+
<array>
|
|
14816
|
+
<string>/bin/sh</string>
|
|
14817
|
+
<string>${escapeXml(runnerPath)}</string>
|
|
14818
|
+
</array>
|
|
14819
|
+
<key>WorkingDirectory</key>
|
|
14820
|
+
<string>${escapeXml(root)}</string>
|
|
14821
|
+
<key>RunAtLoad</key>
|
|
14822
|
+
<true/>
|
|
14823
|
+
<key>KeepAlive</key>
|
|
14824
|
+
<true/>
|
|
14825
|
+
<key>StandardOutPath</key>
|
|
14826
|
+
<string>${escapeXml(outLog)}</string>
|
|
14827
|
+
<key>StandardErrorPath</key>
|
|
14828
|
+
<string>${escapeXml(errLog)}</string>
|
|
14829
|
+
</dict>
|
|
14830
|
+
</plist>
|
|
14831
|
+
`;
|
|
14832
|
+
}
|
|
14833
|
+
function systemdUnit(unitName, root, runnerPath) {
|
|
14834
|
+
return `[Unit]
|
|
14835
|
+
Description=Magic Markdown bridge (${unitName})
|
|
14836
|
+
After=network-online.target
|
|
14837
|
+
Wants=network-online.target
|
|
14838
|
+
|
|
14839
|
+
[Service]
|
|
14840
|
+
Type=simple
|
|
14841
|
+
WorkingDirectory=${systemdQuote(root)}
|
|
14842
|
+
ExecStart=/bin/sh ${systemdQuote(runnerPath)}
|
|
14843
|
+
Restart=always
|
|
14844
|
+
RestartSec=5
|
|
14845
|
+
Environment=PATH=${systemdQuote(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}
|
|
14846
|
+
|
|
14847
|
+
[Install]
|
|
14848
|
+
WantedBy=default.target
|
|
14849
|
+
`;
|
|
14850
|
+
}
|
|
14851
|
+
function serviceLabel(root) {
|
|
14852
|
+
const digest = createHash3("sha256").update(root).digest("hex").slice(0, 12);
|
|
14853
|
+
return `com.magic-markdown.bridge.${digest}`;
|
|
14854
|
+
}
|
|
14855
|
+
async function runOptionalCommand(command, args) {
|
|
14856
|
+
try {
|
|
14857
|
+
await execFileAsync2(command, args);
|
|
14858
|
+
} catch {
|
|
14859
|
+
}
|
|
14860
|
+
}
|
|
14861
|
+
async function runCommand(command, args) {
|
|
14862
|
+
try {
|
|
14863
|
+
await execFileAsync2(command, args);
|
|
14864
|
+
} catch (error) {
|
|
14865
|
+
const detail = error;
|
|
14866
|
+
throw new CliError("internal_error", `${command} ${args.join(" ")} failed: ${detail.stderr || detail.message || String(error)}`);
|
|
14867
|
+
}
|
|
14868
|
+
}
|
|
14869
|
+
async function pathExists(path) {
|
|
14870
|
+
try {
|
|
14871
|
+
await stat3(path);
|
|
14872
|
+
return true;
|
|
14873
|
+
} catch {
|
|
14874
|
+
return false;
|
|
14875
|
+
}
|
|
14876
|
+
}
|
|
14877
|
+
function shellCommand(args) {
|
|
14878
|
+
return args.map(shellQuote).join(" ");
|
|
14879
|
+
}
|
|
14880
|
+
function shellQuote(value) {
|
|
14881
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
14882
|
+
}
|
|
14883
|
+
function systemdQuote(value) {
|
|
14884
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
14885
|
+
}
|
|
14886
|
+
function escapeXml(value) {
|
|
14887
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
14888
|
+
}
|
|
14889
|
+
|
|
14201
14890
|
// src/index.ts
|
|
14202
14891
|
async function main() {
|
|
14203
14892
|
const parsed = parseArgs(process.argv.slice(2));
|
|
14204
|
-
const cwd =
|
|
14893
|
+
const cwd = resolve6(String(parsed.flags.cwd ?? process.cwd()));
|
|
14205
14894
|
const io = new NodeWorkspaceIO(cwd);
|
|
14206
14895
|
const [command, subcommand] = parsed.command;
|
|
14207
14896
|
if (parsed.flags.version || command === "version") {
|
|
@@ -14216,7 +14905,7 @@ async function main() {
|
|
|
14216
14905
|
}
|
|
14217
14906
|
switch (command) {
|
|
14218
14907
|
case "init": {
|
|
14219
|
-
const target =
|
|
14908
|
+
const target = resolve6(parsed.command[1] ?? cwd);
|
|
14220
14909
|
const targetIo = new NodeWorkspaceIO(target);
|
|
14221
14910
|
const manifest = await indexWorkspace(targetIo);
|
|
14222
14911
|
print(manifest, parsed.flags);
|
|
@@ -14253,7 +14942,7 @@ async function main() {
|
|
|
14253
14942
|
markdown,
|
|
14254
14943
|
images: state.images.map((image2) => ({
|
|
14255
14944
|
...image2,
|
|
14256
|
-
...image2.workspacePath ? { absolutePath:
|
|
14945
|
+
...image2.workspacePath ? { absolutePath: resolve6(cwd, image2.workspacePath) } : {}
|
|
14257
14946
|
})),
|
|
14258
14947
|
links: state.links,
|
|
14259
14948
|
...parsed.flags["no-review"] ? {} : {
|
|
@@ -14357,7 +15046,7 @@ async function main() {
|
|
|
14357
15046
|
return;
|
|
14358
15047
|
}
|
|
14359
15048
|
case "bridge": {
|
|
14360
|
-
const root =
|
|
15049
|
+
const root = resolve6(String(parsed.flags.root ?? cwd));
|
|
14361
15050
|
const baseOptions = {
|
|
14362
15051
|
root,
|
|
14363
15052
|
rootId: typeof parsed.flags["root-id"] === "string" ? parsed.flags["root-id"] : void 0,
|
|
@@ -14373,8 +15062,21 @@ async function main() {
|
|
|
14373
15062
|
token: typeof parsed.flags.token === "string" ? parsed.flags.token : process.env.MDOCS_BRIDGE_TOKEN || void 0,
|
|
14374
15063
|
requestToken: Boolean(parsed.flags["request-token"] || parsed.flags.pair),
|
|
14375
15064
|
pairingTimeoutMs: typeof parsed.flags["pairing-timeout-ms"] === "string" ? Number(parsed.flags["pairing-timeout-ms"]) : void 0,
|
|
14376
|
-
once: Boolean(parsed.flags.once)
|
|
15065
|
+
once: Boolean(parsed.flags.once),
|
|
15066
|
+
forever: Boolean(parsed.flags.forever)
|
|
14377
15067
|
};
|
|
15068
|
+
if (subcommand === "startup" || subcommand === "daemon") {
|
|
15069
|
+
const result = await runBridgeStartup({
|
|
15070
|
+
root,
|
|
15071
|
+
mode: typeof parsed.flags.mode === "string" ? parsed.flags.mode : void 0,
|
|
15072
|
+
install: Boolean(parsed.flags.install),
|
|
15073
|
+
requestToken: !parsed.flags["no-request-token"],
|
|
15074
|
+
baseUrl: optionalBridgeBaseUrl(parsed.flags),
|
|
15075
|
+
intervalMs: typeof parsed.flags.interval === "string" ? Number(parsed.flags.interval) : void 0
|
|
15076
|
+
});
|
|
15077
|
+
print(parsed.flags.json ? result : formatBridgeStartupResult(result), parsed.flags);
|
|
15078
|
+
return;
|
|
15079
|
+
}
|
|
14378
15080
|
if (subcommand === "resume") {
|
|
14379
15081
|
await runBridgeResume({
|
|
14380
15082
|
...baseOptions,
|
|
@@ -14513,7 +15215,7 @@ async function readRequiredTextFlag2(flags, cwd, names) {
|
|
|
14513
15215
|
async function readOptionalTextFlag2(flags, cwd, names) {
|
|
14514
15216
|
for (const name of names) {
|
|
14515
15217
|
const fileValue = flags[`${name}-file`];
|
|
14516
|
-
if (typeof fileValue === "string") return
|
|
15218
|
+
if (typeof fileValue === "string") return readFile7(resolve6(cwd, fileValue), "utf8");
|
|
14517
15219
|
const value = flags[name];
|
|
14518
15220
|
if (typeof value === "string") return value;
|
|
14519
15221
|
}
|
|
@@ -14561,9 +15263,9 @@ Commands:
|
|
|
14561
15263
|
comments <path> --json List comments
|
|
14562
15264
|
comment <path> --range 3:5 --body Add a sidecar comment
|
|
14563
15265
|
suggestions <path> --json List suggestions
|
|
14564
|
-
suggest <path> --range 3:5 --with Add a
|
|
15266
|
+
suggest <path> --range 3:5 --with Add a reviewMarkdown suggestion
|
|
14565
15267
|
suggest <path> --range 3:5 --with-file replacement.md
|
|
14566
|
-
Add a multiline
|
|
15268
|
+
Add a multiline reviewMarkdown suggestion
|
|
14567
15269
|
accept <suggestionId> Apply and accept a suggestion
|
|
14568
15270
|
reject <suggestionId> Reject a suggestion
|
|
14569
15271
|
resolve-comment <commentId> Resolve a comment thread
|
|
@@ -14575,6 +15277,7 @@ Commands:
|
|
|
14575
15277
|
checkpoint create|list|restore Manage local reversible checkpoints
|
|
14576
15278
|
join <share-url> --json Join a Magic Markdown share through the CLI
|
|
14577
15279
|
joins --json List saved Magic Markdown remote joins
|
|
15280
|
+
remote status --json Verify the active remote join binding
|
|
14578
15281
|
remote map|graph|context|review|create-file|move-file
|
|
14579
15282
|
Work with documents in the active remote join
|
|
14580
15283
|
remote comment|suggest Add remote review comments and suggestions
|
|
@@ -14585,9 +15288,12 @@ Commands:
|
|
|
14585
15288
|
bridge setup --workspace <id> --root . --url <base-url> [--folder-id <id>] --request-token
|
|
14586
15289
|
Initialize, validate, request approval,
|
|
14587
15290
|
and start an agent filesystem bridge
|
|
14588
|
-
bridge resume --root . --request-token [--once]
|
|
15291
|
+
bridge resume --root . --forever --request-token [--once]
|
|
14589
15292
|
Backfill missed Magic changes, then keep
|
|
14590
15293
|
the bridge running unless --once is set
|
|
15294
|
+
bridge startup|daemon --root . [--mode auto|shell|launchd|systemd] [--install]
|
|
15295
|
+
Write a bridge runner and startup hook
|
|
15296
|
+
for sandboxes, launchd, or systemd
|
|
14591
15297
|
bridge --workspace <id> --root . --url <base-url> --request-token
|
|
14592
15298
|
Request human approval, then sync an
|
|
14593
15299
|
approved local root with the workspace
|
|
@@ -14607,7 +15313,8 @@ Exit codes: 0 ok, 1 internal, 2 usage, 3 conflict, 4 not found, 5 network, 6 una
|
|
|
14607
15313
|
`);
|
|
14608
15314
|
}
|
|
14609
15315
|
function commandHelp(commandPath) {
|
|
14610
|
-
const
|
|
15316
|
+
const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
|
|
15317
|
+
const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
|
|
14611
15318
|
const spec = AGENT_COMMANDS.find((command) => candidates.includes(command.name));
|
|
14612
15319
|
if (!spec) return false;
|
|
14613
15320
|
const lines = [
|
|
@@ -14628,7 +15335,8 @@ function commandHelp(commandPath) {
|
|
|
14628
15335
|
return true;
|
|
14629
15336
|
}
|
|
14630
15337
|
function usageFor(commandPath) {
|
|
14631
|
-
const
|
|
15338
|
+
const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
|
|
15339
|
+
const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
|
|
14632
15340
|
return AGENT_COMMANDS.find((command) => candidates.includes(command.name))?.usage;
|
|
14633
15341
|
}
|
|
14634
15342
|
main().catch((error) => {
|
|
@@ -14643,7 +15351,8 @@ main().catch((error) => {
|
|
|
14643
15351
|
code: cliError.code,
|
|
14644
15352
|
message: cliError.message,
|
|
14645
15353
|
...hint ? { hint } : {},
|
|
14646
|
-
...usage && cliError.hint ? { usage } : {}
|
|
15354
|
+
...usage && cliError.hint ? { usage } : {},
|
|
15355
|
+
...cliError.details !== void 0 ? { details: cliError.details } : {}
|
|
14647
15356
|
},
|
|
14648
15357
|
cliVersion: CLI_VERSION
|
|
14649
15358
|
};
|