ctxr-cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +59 -4
  2. package/dist/commands/land.d.ts +50 -0
  3. package/dist/commands/land.d.ts.map +1 -0
  4. package/dist/commands/land.js +414 -0
  5. package/dist/commands/land.js.map +1 -0
  6. package/dist/commands/publish-urls.d.ts +7 -0
  7. package/dist/commands/publish-urls.d.ts.map +1 -1
  8. package/dist/commands/publish-urls.js +13 -5
  9. package/dist/commands/publish-urls.js.map +1 -1
  10. package/dist/commands/review-list.d.ts +34 -0
  11. package/dist/commands/review-list.d.ts.map +1 -0
  12. package/dist/commands/review-list.js +49 -0
  13. package/dist/commands/review-list.js.map +1 -0
  14. package/dist/commands/serve.d.ts +3 -6
  15. package/dist/commands/serve.d.ts.map +1 -1
  16. package/dist/commands/serve.js +66 -47
  17. package/dist/commands/serve.js.map +1 -1
  18. package/dist/config/schema.d.ts +40 -0
  19. package/dist/config/schema.d.ts.map +1 -1
  20. package/dist/config/schema.js +39 -0
  21. package/dist/config/schema.js.map +1 -1
  22. package/dist/core/approval/decision.d.ts +73 -0
  23. package/dist/core/approval/decision.d.ts.map +1 -0
  24. package/dist/core/approval/decision.js +166 -0
  25. package/dist/core/approval/decision.js.map +1 -0
  26. package/dist/core/approval/file-diff.d.ts +32 -0
  27. package/dist/core/approval/file-diff.d.ts.map +1 -0
  28. package/dist/core/approval/file-diff.js +83 -0
  29. package/dist/core/approval/file-diff.js.map +1 -0
  30. package/dist/core/approval/identity.d.ts +34 -0
  31. package/dist/core/approval/identity.d.ts.map +1 -0
  32. package/dist/core/approval/identity.js +105 -0
  33. package/dist/core/approval/identity.js.map +1 -0
  34. package/dist/core/approval/landing-record.d.ts +17 -0
  35. package/dist/core/approval/landing-record.d.ts.map +1 -0
  36. package/dist/core/approval/landing-record.js +30 -0
  37. package/dist/core/approval/landing-record.js.map +1 -0
  38. package/dist/core/approval/resolver.d.ts +15 -0
  39. package/dist/core/approval/resolver.d.ts.map +1 -0
  40. package/dist/core/approval/resolver.js +41 -0
  41. package/dist/core/approval/resolver.js.map +1 -0
  42. package/dist/core/approval/sessions.d.ts +36 -0
  43. package/dist/core/approval/sessions.d.ts.map +1 -0
  44. package/dist/core/approval/sessions.js +76 -0
  45. package/dist/core/approval/sessions.js.map +1 -0
  46. package/dist/core/browse/approve.d.ts +45 -0
  47. package/dist/core/browse/approve.d.ts.map +1 -0
  48. package/dist/core/browse/approve.js +239 -0
  49. package/dist/core/browse/approve.js.map +1 -0
  50. package/dist/core/browse/review.d.ts +21 -0
  51. package/dist/core/browse/review.d.ts.map +1 -0
  52. package/dist/core/browse/review.js +52 -0
  53. package/dist/core/browse/review.js.map +1 -0
  54. package/dist/core/errors.d.ts +24 -0
  55. package/dist/core/errors.d.ts.map +1 -1
  56. package/dist/core/errors.js +55 -0
  57. package/dist/core/errors.js.map +1 -1
  58. package/dist/core/forge/credentials.d.ts +69 -0
  59. package/dist/core/forge/credentials.d.ts.map +1 -0
  60. package/dist/core/forge/credentials.js +197 -0
  61. package/dist/core/forge/credentials.js.map +1 -0
  62. package/dist/core/forge/github.d.ts +73 -0
  63. package/dist/core/forge/github.d.ts.map +1 -0
  64. package/dist/core/forge/github.js +101 -0
  65. package/dist/core/forge/github.js.map +1 -0
  66. package/dist/core/git/exec.d.ts +15 -0
  67. package/dist/core/git/exec.d.ts.map +1 -1
  68. package/dist/core/git/exec.js +49 -1
  69. package/dist/core/git/exec.js.map +1 -1
  70. package/dist/core/graph/diff-block.d.ts.map +1 -1
  71. package/dist/core/graph/diff-block.js +13 -4
  72. package/dist/core/graph/diff-block.js.map +1 -1
  73. package/dist/core/graph/diff-document.d.ts +3 -2
  74. package/dist/core/graph/diff-document.d.ts.map +1 -1
  75. package/dist/core/graph/diff-document.js +2 -4
  76. package/dist/core/graph/diff-document.js.map +1 -1
  77. package/dist/run.d.ts.map +1 -1
  78. package/dist/run.js +26 -0
  79. package/dist/run.js.map +1 -1
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +1 -1
  83. package/templates/approve/approve-button.md +1 -0
  84. package/templates/approve/author-heading.md +1 -0
  85. package/templates/approve/author-note.md +1 -0
  86. package/templates/approve/comment-label.md +1 -0
  87. package/templates/approve/comment-required.md +1 -0
  88. package/templates/approve/commits-heading.md +1 -0
  89. package/templates/approve/decision-heading.md +1 -0
  90. package/templates/approve/decision-note.md +1 -0
  91. package/templates/approve/diff-binary.md +1 -0
  92. package/templates/approve/diff-empty.md +1 -0
  93. package/templates/approve/diff-heading.md +1 -0
  94. package/templates/approve/facts-heading.md +1 -0
  95. package/templates/approve/forge-unreachable.md +1 -0
  96. package/templates/approve/github-app-manifest.json +15 -0
  97. package/templates/approve/head-missing.md +1 -0
  98. package/templates/approve/index-empty.md +1 -0
  99. package/templates/approve/index-heading.md +1 -0
  100. package/templates/approve/index-intro.md +1 -0
  101. package/templates/approve/landed.md +1 -0
  102. package/templates/approve/moved-head.md +1 -0
  103. package/templates/approve/no-pull.md +1 -0
  104. package/templates/approve/not-open.md +1 -0
  105. package/templates/approve/pages-heading.md +1 -0
  106. package/templates/approve/pages-note.md +1 -0
  107. package/templates/approve/record-failed.md +1 -0
  108. package/templates/approve/request-changes-button.md +1 -0
  109. package/templates/approve/state-line.md +1 -0
  110. package/templates/graph/knowledge-changes/archived-item.md +1 -0
  111. package/templates/graph/knowledge-changes/dangling-item.md +1 -0
  112. package/templates/graph/knowledge-changes/reason-ambiguous.md +1 -0
  113. package/templates/graph/knowledge-changes/reason-not_found.md +1 -0
  114. package/templates/graph/review/moved-head.md +1 -0
  115. package/templates/land/resolver.md +12 -0
  116. package/templates/serve/style.css +63 -0
  117. package/templates/skills/ctxr-land.md +37 -31
  118. package/templates/skills/ctxr-second-opinion/run.mjs +28 -17
  119. package/templates/skills/ctxr-second-opinion.md +19 -14
  120. package/templates/skills/ctxr-session-lifecycle.md +9 -0
  121. package/templates/skills/ctxr-submit.md +4 -2
@@ -0,0 +1,73 @@
1
+ import type { IssueComment, Review } from '../forge/github.js';
2
+ /**
3
+ * change-approval spec (design.md D3): a decision lives on the forge, as a
4
+ * review by the recording login whose body carries a sentence for people and
5
+ * a marker for this module. The landing path's own records (a block, a
6
+ * resolved conflict awaiting a new decision) are comments by the same login
7
+ * with a marker of their own. Nothing else — no local file — holds either.
8
+ *
9
+ * Marker values never contain spaces (commit ids, emails, ISO times, slugs),
10
+ * so a marker is `key=value` tokens separated by single spaces.
11
+ */
12
+ export declare const DECISION_MARKER = "ctxr-decision";
13
+ export declare const LANDING_MARKER = "ctxr-land";
14
+ export type DecisionKind = 'approve' | 'request-changes';
15
+ export interface Decision {
16
+ decision: DecisionKind;
17
+ head: string;
18
+ by: string;
19
+ at: string;
20
+ /** Set on a carry-over (design.md D5): the head the person approved, which this head carries over from. */
21
+ carriedFrom: string | null;
22
+ /** The comment of a request for changes; null for an approval. */
23
+ comment: string | null;
24
+ }
25
+ export type LandingRecordState = 'blocked' | 'needs-reapproval';
26
+ export interface LandingMark {
27
+ state: LandingRecordState;
28
+ reason: string;
29
+ head: string;
30
+ at: string;
31
+ }
32
+ /** The review body for a decision: one sentence, the comment when there is one, then the marker. */
33
+ export declare function decisionBody(decision: Decision): string;
34
+ export declare function parseDecision(body: string): Decision | null;
35
+ /** The comment body for a landing record: a sentence for people, then the marker. */
36
+ export declare function landingBody(mark: LandingMark, sentence: string): string;
37
+ export declare function parseLandingMark(body: string): LandingMark | null;
38
+ export type ReviewStateName = 'awaiting' | 'approved' | 'changes-requested' | 'needs-reapproval' | 'blocked';
39
+ export interface ReviewState {
40
+ state: ReviewStateName;
41
+ /** The latest decision by the recording login, if any. */
42
+ decision: Decision | null;
43
+ /** The latest landing record by the recording login, when it is what decides the state. */
44
+ landing: LandingMark | null;
45
+ /**
46
+ * True when the latest decision approves a head other than the current one and no landing record
47
+ * overrides it: the caller decides, by patch identity, whether the current head carries it over.
48
+ */
49
+ approvalNeedsCarryCheck: boolean;
50
+ }
51
+ /**
52
+ * change-approval spec ("Only reviews by the recording identity count"): the
53
+ * latest well-formed decision by `recordingLogin` decides, and the latest
54
+ * landing record by the same login overrides it when it was posted later and
55
+ * is about the current head. Anything by any other login is not read at all.
56
+ *
57
+ * "Latest" is the forge's own posting time (stable-sorted, so the forge's order
58
+ * breaks ties), never the marker's `at`: a carry-over keeps the time the person
59
+ * decided (design.md D5) while being the newer record.
60
+ *
61
+ * An approval of a different head is reported as `needs-reapproval` with
62
+ * `approvalNeedsCarryCheck` set; whether a clean rebase carries it over is a
63
+ * git question this module does not ask (write-lifecycle spec).
64
+ */
65
+ export declare function resolveReviewState(input: {
66
+ reviews: readonly Review[];
67
+ comments: readonly IssueComment[];
68
+ recordingLogin: string;
69
+ head: string;
70
+ }): ReviewState;
71
+ /** change-approval spec: an exact email, or an `@domain` entry matching that domain exactly. */
72
+ export declare function isDecider(email: string, deciders: readonly string[]): boolean;
73
+ //# sourceMappingURL=decision.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decision.d.ts","sourceRoot":"","sources":["../../../src/core/approval/decision.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE/D;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,kBAAkB,CAAC;AAC/C,eAAO,MAAM,cAAc,cAAc,CAAC;AAG1C,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,iBAAiB,CAAC;AAEzD,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,2GAA2G;IAC3G,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,kEAAkE;IAClE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAEhE,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAgCD,oGAAoG;AACpG,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAevD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAe3D;AAED,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAOjE;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,UAAU,GAAG,mBAAmB,GAAG,kBAAkB,GAAG,SAAS,CAAC;AAE7G,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,eAAe,CAAC;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC1B,2FAA2F;IAC3F,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IAC5B;;;OAGG;IACH,uBAAuB,EAAE,OAAO,CAAC;CAClC;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACxC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,QAAQ,EAAE,SAAS,YAAY,EAAE,CAAC;IAClC,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,WAAW,CAiCd;AAED,gGAAgG;AAChG,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAS7E"}
@@ -0,0 +1,166 @@
1
+ /**
2
+ * change-approval spec (design.md D3): a decision lives on the forge, as a
3
+ * review by the recording login whose body carries a sentence for people and
4
+ * a marker for this module. The landing path's own records (a block, a
5
+ * resolved conflict awaiting a new decision) are comments by the same login
6
+ * with a marker of their own. Nothing else — no local file — holds either.
7
+ *
8
+ * Marker values never contain spaces (commit ids, emails, ISO times, slugs),
9
+ * so a marker is `key=value` tokens separated by single spaces.
10
+ */
11
+ export const DECISION_MARKER = 'ctxr-decision';
12
+ export const LANDING_MARKER = 'ctxr-land';
13
+ const MARKER_VERSION = 'v1';
14
+ function marker(name, fields) {
15
+ const tokens = Object.entries(fields).map(([key, value]) => `${key}=${value}`);
16
+ return `<!-- ${name} ${MARKER_VERSION} ${tokens.join(' ')} -->`;
17
+ }
18
+ /**
19
+ * Only a marker that ends the body counts. The body is built with the marker
20
+ * last, and a comment above it is the decider's own text — which must never be
21
+ * able to carry a marker that reads as a different decision, head or decider.
22
+ */
23
+ function parseMarker(name, body) {
24
+ const pattern = new RegExp(`<!-- ${name} ${MARKER_VERSION} ([^>]*?) -->\\s*$`);
25
+ const found = pattern.exec(body);
26
+ if (found === null)
27
+ return null;
28
+ const fields = {};
29
+ for (const token of found[1].split(' ')) {
30
+ const eq = token.indexOf('=');
31
+ if (eq <= 0)
32
+ return null;
33
+ fields[token.slice(0, eq)] = token.slice(eq + 1);
34
+ }
35
+ return fields;
36
+ }
37
+ const SHA = /^[0-9a-f]{40}$/;
38
+ const NO_SPACE = /^\S+$/;
39
+ function neutralizeComment(comment) {
40
+ return comment.replace(/<!--/g, '<! --').replace(/-->/g, '-- >');
41
+ }
42
+ /** The review body for a decision: one sentence, the comment when there is one, then the marker. */
43
+ export function decisionBody(decision) {
44
+ const short = decision.head.slice(0, 7);
45
+ const sentence = decision.carriedFrom !== null
46
+ ? `Carried over from ${decision.carriedFrom.slice(0, 7)} to ${short} by a clean rebase (patches unchanged). Approved by ${decision.by}.`
47
+ : decision.decision === 'approve'
48
+ ? `Approved by ${decision.by} on the review page, for commit ${short}.`
49
+ : `Changes requested by ${decision.by} on the review page, for commit ${short}.`;
50
+ const fields = { decision: decision.decision, head: decision.head, by: decision.by, at: decision.at };
51
+ if (decision.carriedFrom !== null)
52
+ fields['carried-from'] = decision.carriedFrom;
53
+ const parts = [sentence];
54
+ // A comment cannot open an HTML comment of its own, so it can neither hide text on the forge nor imitate a marker.
55
+ if (decision.comment !== null)
56
+ parts.push(neutralizeComment(decision.comment.trim()));
57
+ parts.push(marker(DECISION_MARKER, fields));
58
+ return parts.join('\n\n');
59
+ }
60
+ export function parseDecision(body) {
61
+ const fields = parseMarker(DECISION_MARKER, body);
62
+ if (fields === null)
63
+ return null;
64
+ const { decision, head, by, at } = fields;
65
+ if (decision !== 'approve' && decision !== 'request-changes')
66
+ return null;
67
+ if (head === undefined || !SHA.test(head))
68
+ return null;
69
+ if (by === undefined || !NO_SPACE.test(by) || at === undefined || !NO_SPACE.test(at))
70
+ return null;
71
+ const carriedFrom = fields['carried-from'] ?? null;
72
+ if (carriedFrom !== null && !SHA.test(carriedFrom))
73
+ return null;
74
+ let comment = null;
75
+ if (decision === 'request-changes') {
76
+ const paragraphs = body.slice(0, body.lastIndexOf(`<!-- ${DECISION_MARKER}`)).split('\n\n');
77
+ comment = paragraphs.slice(1).join('\n\n').trim();
78
+ }
79
+ return { decision, head, by, at, carriedFrom, comment };
80
+ }
81
+ /** The comment body for a landing record: a sentence for people, then the marker. */
82
+ export function landingBody(mark, sentence) {
83
+ return `${sentence}\n\n${marker(LANDING_MARKER, { state: mark.state, reason: mark.reason, head: mark.head, at: mark.at })}`;
84
+ }
85
+ export function parseLandingMark(body) {
86
+ const fields = parseMarker(LANDING_MARKER, body);
87
+ if (fields === null)
88
+ return null;
89
+ const { state, reason, head, at } = fields;
90
+ if (state !== 'blocked' && state !== 'needs-reapproval')
91
+ return null;
92
+ if (reason === undefined || head === undefined || !SHA.test(head) || at === undefined)
93
+ return null;
94
+ return { state, reason, head, at };
95
+ }
96
+ function timeOf(value) {
97
+ const t = value === null ? Number.NaN : Date.parse(value);
98
+ return Number.isNaN(t) ? 0 : t;
99
+ }
100
+ /**
101
+ * change-approval spec ("Only reviews by the recording identity count"): the
102
+ * latest well-formed decision by `recordingLogin` decides, and the latest
103
+ * landing record by the same login overrides it when it was posted later and
104
+ * is about the current head. Anything by any other login is not read at all.
105
+ *
106
+ * "Latest" is the forge's own posting time (stable-sorted, so the forge's order
107
+ * breaks ties), never the marker's `at`: a carry-over keeps the time the person
108
+ * decided (design.md D5) while being the newer record.
109
+ *
110
+ * An approval of a different head is reported as `needs-reapproval` with
111
+ * `approvalNeedsCarryCheck` set; whether a clean rebase carries it over is a
112
+ * git question this module does not ask (write-lifecycle spec).
113
+ */
114
+ export function resolveReviewState(input) {
115
+ let decision = null;
116
+ let decisionTime = -1;
117
+ for (const review of input.reviews) {
118
+ if (review.author !== input.recordingLogin)
119
+ continue;
120
+ const parsed = parseDecision(review.body);
121
+ if (parsed === null)
122
+ continue;
123
+ const t = timeOf(review.submittedAt);
124
+ if (t >= decisionTime) {
125
+ decision = parsed;
126
+ decisionTime = t;
127
+ }
128
+ }
129
+ let landing = null;
130
+ let landingTime = -1;
131
+ for (const comment of input.comments) {
132
+ if (comment.author !== input.recordingLogin)
133
+ continue;
134
+ const parsed = parseLandingMark(comment.body);
135
+ if (parsed === null)
136
+ continue;
137
+ const t = timeOf(comment.createdAt);
138
+ if (t >= landingTime) {
139
+ landing = parsed;
140
+ landingTime = t;
141
+ }
142
+ }
143
+ if (landing !== null && landing.head === input.head && landingTime >= decisionTime) {
144
+ return { state: landing.state, decision, landing, approvalNeedsCarryCheck: false };
145
+ }
146
+ if (decision === null)
147
+ return { state: 'awaiting', decision, landing: null, approvalNeedsCarryCheck: false };
148
+ if (decision.decision === 'request-changes')
149
+ return { state: 'changes-requested', decision, landing: null, approvalNeedsCarryCheck: false };
150
+ if (decision.head === input.head)
151
+ return { state: 'approved', decision, landing: null, approvalNeedsCarryCheck: false };
152
+ return { state: 'needs-reapproval', decision, landing: null, approvalNeedsCarryCheck: true };
153
+ }
154
+ /** change-approval spec: an exact email, or an `@domain` entry matching that domain exactly. */
155
+ export function isDecider(email, deciders) {
156
+ const normalized = email.toLowerCase();
157
+ const at = normalized.lastIndexOf('@');
158
+ if (at <= 0)
159
+ return false;
160
+ const domain = normalized.slice(at);
161
+ return deciders.some((entry) => {
162
+ const e = entry.toLowerCase();
163
+ return e.startsWith('@') ? domain === e : normalized === e;
164
+ });
165
+ }
166
+ //# sourceMappingURL=decision.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decision.js","sourceRoot":"","sources":["../../../src/core/approval/decision.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import type { GitRunner } from '../git/exec.js';
2
+ /**
3
+ * change-approval spec ("The approval page shows the change itself"): every
4
+ * file under the store root that differs between the base and the pull
5
+ * request's head, as removed and added lines. Read from git's object store
6
+ * with a three-dot range, so the base is the merge base and the worktree's
7
+ * uncommitted work never enters it; derived cache paths are excluded.
8
+ */
9
+ export interface DiffLine {
10
+ kind: 'add' | 'del' | 'ctx';
11
+ text: string;
12
+ }
13
+ export interface FileDiff {
14
+ path: string;
15
+ /** Set when git detected a rename. */
16
+ oldPath: string | null;
17
+ status: 'added' | 'removed' | 'modified' | 'moved';
18
+ binary: boolean;
19
+ hunks: {
20
+ header: string;
21
+ lines: DiffLine[];
22
+ }[];
23
+ }
24
+ export declare function readFileDiffs(git: GitRunner, storeRoot: string, base: string, head: string, derivedPaths: readonly string[]): Promise<FileDiff[]>;
25
+ /** Parses `git diff` output. Exported for tests. */
26
+ export declare function parseUnifiedDiff(text: string): FileDiff[];
27
+ /** Each file as a heading and a block of marked lines — readable with no script and no colour. */
28
+ export declare function renderFileDiffs(files: readonly FileDiff[], labels: {
29
+ binary: string;
30
+ noFiles: string;
31
+ }): string;
32
+ //# sourceMappingURL=file-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-diff.d.ts","sourceRoot":"","sources":["../../../src/core/approval/file-diff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD;;;;;;GAMG;AACH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC;IAC5B,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;IACnD,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,QAAQ,EAAE,CAAA;KAAE,EAAE,CAAC;CAChD;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAOvJ;AAOD,oDAAoD;AACpD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,EAAE,CAoCzD;AAKD,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,EAAE,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAkB/G"}
@@ -0,0 +1,83 @@
1
+ import { escapeHtml } from '../browse/render.js';
2
+ export async function readFileDiffs(git, storeRoot, base, head, derivedPaths) {
3
+ const excludes = derivedPaths.map((p) => `:(exclude)${p.replace(/\/+$/, '')}`);
4
+ const { stdout } = await git.run(['diff', '--no-color', '--no-ext-diff', '-M', '--relative', `${base}...${head}`, '--', '.', ...excludes], { cwd: storeRoot });
5
+ return parseUnifiedDiff(stdout);
6
+ }
7
+ function unquote(raw) {
8
+ if (!raw.startsWith('"'))
9
+ return raw;
10
+ return JSON.parse(raw.replace(/\\([0-7]{3})/g, (_m, oct) => `\\u00${Number.parseInt(oct, 8).toString(16).padStart(2, '0')}`));
11
+ }
12
+ /** Parses `git diff` output. Exported for tests. */
13
+ export function parseUnifiedDiff(text) {
14
+ const files = [];
15
+ let current = null;
16
+ let hunk = null;
17
+ for (const line of text.split('\n')) {
18
+ if (line.startsWith('diff --git ')) {
19
+ const match = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
20
+ current = { path: unquote(match?.[2] ?? line), oldPath: null, status: 'modified', binary: false, hunks: [] };
21
+ hunk = null;
22
+ files.push(current);
23
+ continue;
24
+ }
25
+ if (current === null)
26
+ continue;
27
+ if (hunk === null) {
28
+ if (line.startsWith('new file mode'))
29
+ current.status = 'added';
30
+ else if (line.startsWith('deleted file mode'))
31
+ current.status = 'removed';
32
+ else if (line.startsWith('rename from ')) {
33
+ current.oldPath = unquote(line.slice('rename from '.length));
34
+ current.status = 'moved';
35
+ }
36
+ else if (line.startsWith('rename to '))
37
+ current.path = unquote(line.slice('rename to '.length));
38
+ else if (line.startsWith('Binary files '))
39
+ current.binary = true;
40
+ else if (line.startsWith('@@')) {
41
+ hunk = { header: line, lines: [] };
42
+ current.hunks.push(hunk);
43
+ }
44
+ continue;
45
+ }
46
+ if (line.startsWith('@@')) {
47
+ hunk = { header: line, lines: [] };
48
+ current.hunks.push(hunk);
49
+ }
50
+ else if (line.startsWith('+'))
51
+ hunk.lines.push({ kind: 'add', text: line.slice(1) });
52
+ else if (line.startsWith('-'))
53
+ hunk.lines.push({ kind: 'del', text: line.slice(1) });
54
+ else if (line.startsWith(' '))
55
+ hunk.lines.push({ kind: 'ctx', text: line.slice(1) });
56
+ // "" and the trailing empty line carry nothing to show.
57
+ }
58
+ return files;
59
+ }
60
+ const MARK = { add: '+', del: '−', ctx: ' ' };
61
+ const LABEL = { add: 'added', del: 'removed', ctx: 'unchanged' };
62
+ /** Each file as a heading and a block of marked lines — readable with no script and no colour. */
63
+ export function renderFileDiffs(files, labels) {
64
+ if (files.length === 0)
65
+ return `<p class="ctxr-empty">${escapeHtml(labels.noFiles)}</p>`;
66
+ return files
67
+ .map((file) => {
68
+ const title = file.oldPath === null ? file.path : `${file.oldPath} → ${file.path}`;
69
+ const body = file.binary
70
+ ? `<p class="ctxr-empty">${escapeHtml(labels.binary)}</p>`
71
+ : file.hunks
72
+ .map((h) => {
73
+ const rows = h.lines
74
+ .map((l) => `<span class="ctxr-diff-${l.kind}" title="${LABEL[l.kind]}">${MARK[l.kind]} ${escapeHtml(l.text)}</span>`)
75
+ .join('\n');
76
+ return `<pre class="ctxr-diff"><span class="ctxr-diff-hunk">${escapeHtml(h.header)}</span>\n${rows}</pre>`;
77
+ })
78
+ .join('\n');
79
+ return `<section class="ctxr-diff-file"><h3><code>${escapeHtml(title)}</code> <span class="ctxr-diff-status">${escapeHtml(file.status)}</span></h3>\n${body}</section>`;
80
+ })
81
+ .join('\n');
82
+ }
83
+ //# sourceMappingURL=file-diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-diff.js","sourceRoot":"","sources":["../../../src/core/approval/file-diff.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type { ChangeApprovalConfig } from '../../config/schema.js';
2
+ /**
3
+ * change-approval spec ("Every approval route requires a verified identity
4
+ * assertion", design.md D2): the signed token the access proxy in front of
5
+ * `ctxr serve` forwards on every request, verified here for every request —
6
+ * signature against the configured key set, then issuer, audience, expiry and
7
+ * an email claim. It fails closed: a missing header, an unknown key, a key set
8
+ * that cannot be fetched, or any claim out of line is a refusal, never a pass.
9
+ *
10
+ * Only RS256 and ES256, the algorithms access proxies sign with. `none` and
11
+ * every HMAC algorithm are refused outright, so a token cannot choose to be
12
+ * checked against a secret this server does not hold.
13
+ */
14
+ export type IdentityResult = {
15
+ ok: true;
16
+ email: string;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ export interface IdentityVerifier {
22
+ verify(headerValue: string | string[] | undefined): Promise<IdentityResult>;
23
+ }
24
+ type FetchLike = (url: string) => Promise<{
25
+ ok: boolean;
26
+ status: number;
27
+ json(): Promise<unknown>;
28
+ }>;
29
+ export declare function createIdentityVerifier(identity: ChangeApprovalConfig['identity'], opts?: {
30
+ fetch?: FetchLike;
31
+ now?: () => Date;
32
+ }): IdentityVerifier;
33
+ export {};
34
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../../src/core/approval/identity.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzF,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC7E;AAED,KAAK,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,CAAC;AAgBrG,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,oBAAoB,CAAC,UAAU,CAAC,EAC1C,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAO,GACjD,gBAAgB,CA+ElB"}
@@ -0,0 +1,105 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ const ALGORITHMS = {
3
+ RS256: { hash: 'sha256' },
4
+ ES256: { hash: 'sha256', dsaEncoding: 'ieee-p1363' },
5
+ };
6
+ /** Seconds of clock skew allowed on `exp` and `nbf`. */
7
+ const SKEW_SECONDS = 30;
8
+ /** An unknown key id re-fetches the key set at most this often, so a stream of forged ids cannot turn into a stream of fetches. */
9
+ const REFETCH_INTERVAL_MS = 10_000;
10
+ function decodeSegment(segment) {
11
+ return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
12
+ }
13
+ export function createIdentityVerifier(identity, opts = {}) {
14
+ const fetchImpl = opts.fetch ?? ((url) => fetch(url));
15
+ const now = opts.now ?? (() => new Date());
16
+ let keys = new Map();
17
+ let lastFetch = 0;
18
+ async function refreshKeys() {
19
+ lastFetch = Date.now();
20
+ const response = await fetchImpl(identity.jwks_url);
21
+ if (!response.ok)
22
+ throw new Error(`key set fetch answered ${response.status}`);
23
+ const body = (await response.json());
24
+ const next = new Map();
25
+ for (const jwk of body.keys ?? []) {
26
+ if (typeof jwk.kid !== 'string')
27
+ continue;
28
+ try {
29
+ next.set(jwk.kid, createPublicKey({ key: jwk, format: 'jwk' }));
30
+ }
31
+ catch {
32
+ // A key this runtime cannot read verifies nothing.
33
+ }
34
+ }
35
+ keys = next;
36
+ }
37
+ async function keyFor(kid) {
38
+ const known = keys.get(kid);
39
+ if (known !== undefined)
40
+ return known;
41
+ if (Date.now() - lastFetch < REFETCH_INTERVAL_MS && keys.size > 0)
42
+ return null;
43
+ await refreshKeys();
44
+ return keys.get(kid) ?? null;
45
+ }
46
+ return {
47
+ async verify(headerValue) {
48
+ const token = Array.isArray(headerValue) ? headerValue[0] : headerValue;
49
+ if (token === undefined || token === '')
50
+ return { ok: false, reason: 'no identity assertion' };
51
+ const parts = token.split('.');
52
+ if (parts.length !== 3)
53
+ return { ok: false, reason: 'malformed assertion' };
54
+ const [headerPart, payloadPart, signaturePart] = parts;
55
+ let header;
56
+ let claims;
57
+ try {
58
+ header = decodeSegment(headerPart);
59
+ claims = decodeSegment(payloadPart);
60
+ }
61
+ catch {
62
+ return { ok: false, reason: 'malformed assertion' };
63
+ }
64
+ const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
65
+ if (algorithm === undefined)
66
+ return { ok: false, reason: 'unsupported signing algorithm' };
67
+ if (typeof header.kid !== 'string')
68
+ return { ok: false, reason: 'assertion names no key' };
69
+ let key;
70
+ try {
71
+ key = await keyFor(header.kid);
72
+ }
73
+ catch (err) {
74
+ return { ok: false, reason: `key set unavailable: ${err instanceof Error ? err.message : String(err)}` };
75
+ }
76
+ if (key === null)
77
+ return { ok: false, reason: 'assertion signed by an unknown key' };
78
+ const signed = Buffer.from(`${headerPart}.${payloadPart}`, 'utf8');
79
+ const signature = Buffer.from(signaturePart, 'base64url');
80
+ let valid = false;
81
+ try {
82
+ valid = verify(algorithm.hash, signed, algorithm.dsaEncoding ? { key, dsaEncoding: algorithm.dsaEncoding } : key, signature);
83
+ }
84
+ catch {
85
+ valid = false;
86
+ }
87
+ if (!valid)
88
+ return { ok: false, reason: 'signature does not verify' };
89
+ if (claims.iss !== identity.issuer)
90
+ return { ok: false, reason: 'wrong issuer' };
91
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
92
+ if (!audiences.includes(identity.audience))
93
+ return { ok: false, reason: 'wrong audience' };
94
+ const nowSeconds = now().getTime() / 1000;
95
+ if (typeof claims.exp !== 'number' || claims.exp + SKEW_SECONDS < nowSeconds)
96
+ return { ok: false, reason: 'expired' };
97
+ if (typeof claims.nbf === 'number' && claims.nbf - SKEW_SECONDS > nowSeconds)
98
+ return { ok: false, reason: 'not yet valid' };
99
+ if (typeof claims.email !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(claims.email))
100
+ return { ok: false, reason: 'no email claim' };
101
+ return { ok: true, email: claims.email };
102
+ },
103
+ };
104
+ }
105
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","sourceRoot":"","sources":["../../../src/core/approval/identity.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * write-lifecycle spec: where `ctxr land` records a landing for the approval
3
+ * page to read once the worktree is gone. A derived cache path, beside the
4
+ * graph document's, written atomically and never staged.
5
+ */
6
+ export declare const LANDINGS_RELATIVE_PATH = ".contexture/cache/landings";
7
+ export interface LandingRecord {
8
+ worktree: string;
9
+ branch: string;
10
+ pull_request: number;
11
+ merged_commit: string | null;
12
+ landed_at: string;
13
+ }
14
+ export declare function writeLandingRecord(storeRoot: string, record: LandingRecord): Promise<void>;
15
+ /** The record for `worktree`, or null — including for a name that is not a plain directory name. */
16
+ export declare function readLandingRecord(storeRoot: string, worktree: string): Promise<LandingRecord | null>;
17
+ //# sourceMappingURL=landing-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"landing-record.d.ts","sourceRoot":"","sources":["../../../src/core/approval/landing-record.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AAEnE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;CACnB;AAMD,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAGhG;AAED,oGAAoG;AACpG,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAQ1G"}
@@ -0,0 +1,30 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { writeFileAtomic } from '../fs/atomic.js';
4
+ /**
5
+ * write-lifecycle spec: where `ctxr land` records a landing for the approval
6
+ * page to read once the worktree is gone. A derived cache path, beside the
7
+ * graph document's, written atomically and never staged.
8
+ */
9
+ export const LANDINGS_RELATIVE_PATH = '.contexture/cache/landings';
10
+ function recordPath(storeRoot, worktree) {
11
+ return path.join(storeRoot, LANDINGS_RELATIVE_PATH, `${worktree}.json`);
12
+ }
13
+ export async function writeLandingRecord(storeRoot, record) {
14
+ await mkdir(path.join(storeRoot, LANDINGS_RELATIVE_PATH), { recursive: true });
15
+ await writeFileAtomic(recordPath(storeRoot, record.worktree), `${JSON.stringify(record, null, 2)}\n`);
16
+ }
17
+ /** The record for `worktree`, or null — including for a name that is not a plain directory name. */
18
+ export async function readLandingRecord(storeRoot, worktree) {
19
+ if (worktree === '' || worktree.includes('/') || worktree.includes('\\') || worktree.startsWith('.'))
20
+ return null;
21
+ try {
22
+ return JSON.parse(await readFile(recordPath(storeRoot, worktree), 'utf8'));
23
+ }
24
+ catch (err) {
25
+ if (err.code === 'ENOENT')
26
+ return null;
27
+ throw err;
28
+ }
29
+ }
30
+ //# sourceMappingURL=landing-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"landing-record.js","sourceRoot":"","sources":["../../../src/core/approval/landing-record.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,4BAA4B,CAAC;AAUnE,SAAS,UAAU,CAAC,SAAiB,EAAE,QAAgB;IACrD,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,sBAAsB,EAAE,GAAG,QAAQ,OAAO,CAAC,CAAC;AAC1E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,SAAiB,EAAE,MAAqB;IAC/E,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,sBAAsB,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/E,MAAM,eAAe,CAAC,UAAU,CAAC,SAAS,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AACxG,CAAC;AAED,oGAAoG;AACpG,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB,EAAE,QAAgB;IACzE,IAAI,QAAQ,KAAK,EAAE,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAClH,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,UAAU,CAAC,SAAS,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,CAAkB,CAAC;IAC9F,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAClE,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC"}
@@ -0,0 +1,15 @@
1
+ export declare function resolverPrompt(pullNumber: number, base: string, defaultBranch: string): string;
2
+ /**
3
+ * design.md D6: the one place `ctxr land` spawns the store's configured
4
+ * conflict resolver — an argument vector, never a shell string, run in the
5
+ * checkout to resolve, with the prompt as its last argument. Its output goes to
6
+ * `output` (the command's stderr), so stdout keeps the one envelope.
7
+ */
8
+ export declare function runResolver(input: {
9
+ argv: readonly string[];
10
+ prompt: string;
11
+ cwd: string;
12
+ env: Readonly<Record<string, string | undefined>>;
13
+ output: NodeJS.WritableStream;
14
+ }): Promise<number>;
15
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../../src/core/approval/resolver.ts"],"names":[],"mappings":"AAmBA,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAK9F;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAClD,MAAM,EAAE,MAAM,CAAC,cAAc,CAAC;CAC/B,GAAG,OAAO,CAAC,MAAM,CAAC,CASlB"}