@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -2
  3. package/THIRD_PARTY_NOTICES.md +580 -0
  4. package/dist/bin/loopnote.mjs +5 -0
  5. package/dist/cli/confirm.js +38 -0
  6. package/dist/cli/picker.js +342 -0
  7. package/dist/cli/request.js +120 -0
  8. package/dist/cli/run.js +970 -0
  9. package/dist/contracts/api.js +640 -0
  10. package/dist/contracts/comparison.js +178 -0
  11. package/dist/contracts/draft.js +606 -0
  12. package/dist/contracts/request.js +124 -0
  13. package/dist/domain/anchor.js +122 -0
  14. package/dist/domain/comparison-key.js +51 -0
  15. package/dist/domain/errors.js +43 -0
  16. package/dist/domain/path-bytes.js +82 -0
  17. package/dist/domain/product-identity.js +66 -0
  18. package/dist/domain/source.js +4 -0
  19. package/dist/draft/mutate-draft.js +87 -0
  20. package/dist/export/render-review-markdown.js +163 -0
  21. package/dist/export/review-export.js +273 -0
  22. package/dist/git/availability.js +92 -0
  23. package/dist/git/candidates.js +413 -0
  24. package/dist/git/comparison.js +263 -0
  25. package/dist/git/exact-patch.js +702 -0
  26. package/dist/git/ignore-status.js +23 -0
  27. package/dist/git/inventory.js +160 -0
  28. package/dist/git/numstat.js +111 -0
  29. package/dist/git/objects.js +95 -0
  30. package/dist/git/raw-diff.js +84 -0
  31. package/dist/git/repository.js +144 -0
  32. package/dist/git/runner.js +145 -0
  33. package/dist/git/selector-drift.js +169 -0
  34. package/dist/git/standalone-resolution.js +174 -0
  35. package/dist/git/worktree-content.js +97 -0
  36. package/dist/git/worktree-inventory.js +391 -0
  37. package/dist/native/directory_exchange.node +0 -0
  38. package/dist/server/app.js +171 -0
  39. package/dist/server/attached-completion.js +85 -0
  40. package/dist/server/capabilities.js +1572 -0
  41. package/dist/server/draft-loader.js +134 -0
  42. package/dist/server/draft-recovery.js +5 -0
  43. package/dist/server/draft-store.js +380 -0
  44. package/dist/server/export-store.js +291 -0
  45. package/dist/server/gitignore-capability.js +197 -0
  46. package/dist/server/image-content.js +101 -0
  47. package/dist/server/lifecycle.js +72 -0
  48. package/dist/server/native-exchange-capability.js +45 -0
  49. package/dist/server/patch-snapshot.js +500 -0
  50. package/dist/server/routes.js +418 -0
  51. package/dist/server/security.js +123 -0
  52. package/dist/server/snapshot-fs.js +58 -0
  53. package/dist/server/support-client.js +76 -0
  54. package/dist/server/support-store.js +194 -0
  55. package/dist/server/worktree-snapshot.js +553 -0
  56. package/dist/web/assets/abap-08VXUWAP.js +1 -0
  57. package/dist/web/assets/apex-BWPQTe0t.js +1 -0
  58. package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
  59. package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
  60. package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
  61. package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
  62. package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
  63. package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
  64. package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
  65. package/dist/web/assets/cpp-C7h46wYY.js +1 -0
  66. package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
  67. package/dist/web/assets/csp-bTuwJoIa.js +1 -0
  68. package/dist/web/assets/css-DIMkf-bt.js +3 -0
  69. package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
  70. package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
  71. package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
  72. package/dist/web/assets/dart-onAF5SnQ.js +1 -0
  73. package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
  74. package/dist/web/assets/ecl-D05T4iGw.js +1 -0
  75. package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
  76. package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
  77. package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
  78. package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
  79. package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
  80. package/dist/web/assets/go-C-y9NEjX.js +1 -0
  81. package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
  82. package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
  83. package/dist/web/assets/hcl-CpzslTdj.js +1 -0
  84. package/dist/web/assets/html-BDAqstpo.js +1 -0
  85. package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
  86. package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
  87. package/dist/web/assets/index-CCeYBqPo.js +1111 -0
  88. package/dist/web/assets/index-CRj9ojWJ.css +1 -0
  89. package/dist/web/assets/ini-sBoK_t0W.js +1 -0
  90. package/dist/web/assets/java-BEtHBSE6.js +1 -0
  91. package/dist/web/assets/javascript-1Ywytw67.js +1 -0
  92. package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
  93. package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
  94. package/dist/web/assets/julia-Bri6UV-V.js +1 -0
  95. package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
  96. package/dist/web/assets/less-B9JPFI3C.js +2 -0
  97. package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
  98. package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
  99. package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
  100. package/dist/web/assets/lua-CsQS60Ue.js +1 -0
  101. package/dist/web/assets/m3-D-oSqn_W.js +1 -0
  102. package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
  103. package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
  104. package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
  105. package/dist/web/assets/msdax-DauUninz.js +1 -0
  106. package/dist/web/assets/mysql-SOo6toE5.js +1 -0
  107. package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
  108. package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
  109. package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
  110. package/dist/web/assets/perl-oz_6vUea.js +1 -0
  111. package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
  112. package/dist/web/assets/php-nr791fC2.js +1 -0
  113. package/dist/web/assets/pla-CopQ2nXW.js +1 -0
  114. package/dist/web/assets/postiats-43DmfD33.js +1 -0
  115. package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
  116. package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
  117. package/dist/web/assets/protobuf-C531GsRP.js +2 -0
  118. package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
  119. package/dist/web/assets/python-CET4IumD.js +1 -0
  120. package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
  121. package/dist/web/assets/r-BwWrilGY.js +1 -0
  122. package/dist/web/assets/razor-d-reaHeT.js +1 -0
  123. package/dist/web/assets/redis-ClamHrr6.js +1 -0
  124. package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
  125. package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
  126. package/dist/web/assets/ruby-DezsRK8O.js +1 -0
  127. package/dist/web/assets/rust-DdL9SqIa.js +1 -0
  128. package/dist/web/assets/sb-CcwsVR0C.js +1 -0
  129. package/dist/web/assets/scala-DHpiXF5c.js +1 -0
  130. package/dist/web/assets/scheme-BeGwcela.js +1 -0
  131. package/dist/web/assets/scss-gp-XZpBa.js +3 -0
  132. package/dist/web/assets/shell-CC2rA5mh.js +1 -0
  133. package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
  134. package/dist/web/assets/sophia-CRfGWb83.js +1 -0
  135. package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
  136. package/dist/web/assets/sql-NEE52Syq.js +1 -0
  137. package/dist/web/assets/st-DbInun42.js +1 -0
  138. package/dist/web/assets/swift-Bxkupp3x.js +1 -0
  139. package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
  140. package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
  141. package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
  142. package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
  143. package/dist/web/assets/twig-De2hgUGE.js +1 -0
  144. package/dist/web/assets/typescript-DprEVA-S.js +1 -0
  145. package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
  146. package/dist/web/assets/vb-DV3o63ZY.js +1 -0
  147. package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
  148. package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
  149. package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
  150. package/dist/web/index.html +14 -0
  151. package/package.json +87 -4
@@ -0,0 +1,291 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { hashExportBytes, parseCanonicalReviewExport } from '../export/review-export.js';
5
+ import { renderReviewMarkdown } from '../export/render-review-markdown.js';
6
+ import { RENAME_MAP } from '../domain/product-identity.js';
7
+ import { assertAttachedStorageScope } from './draft-loader.js';
8
+ export class ReExportUnsupported extends Error {
9
+ constructor() {
10
+ super('The current packaged target has no observed native directory exchange support.');
11
+ }
12
+ }
13
+ function isMissing(error) {
14
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
15
+ }
16
+ async function syncDirectory(path) {
17
+ try {
18
+ const handle = await open(path, 'r');
19
+ try {
20
+ await handle.sync();
21
+ }
22
+ finally {
23
+ await handle.close();
24
+ }
25
+ }
26
+ catch (error) {
27
+ if (!(typeof error === 'object' && error !== null && 'code' in error && ['EINVAL', 'ENOTSUP', 'EISDIR'].includes(String(error.code)))) {
28
+ throw error;
29
+ }
30
+ }
31
+ }
32
+ async function writeFileExactly(path, bytes) {
33
+ const handle = await open(path, 'wx', 0o600);
34
+ try {
35
+ await handle.writeFile(bytes);
36
+ await handle.sync();
37
+ }
38
+ finally {
39
+ await handle.close();
40
+ }
41
+ }
42
+ async function completePair(path) {
43
+ const directory = await lstat(path);
44
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
45
+ throw new Error('Managed export directory is not a real directory.');
46
+ }
47
+ const names = await readdir(path);
48
+ if (names.length !== 2 || !names.includes('review.json') || !names.includes('review.md')) {
49
+ throw new Error('Managed export directory does not contain exactly the review pair.');
50
+ }
51
+ const [jsonInfo, markdownInfo, json, markdown] = await Promise.all([
52
+ lstat(join(path, 'review.json')),
53
+ lstat(join(path, 'review.md')),
54
+ readFile(join(path, 'review.json')),
55
+ readFile(join(path, 'review.md')),
56
+ ]);
57
+ if (!jsonInfo.isFile() || jsonInfo.isSymbolicLink() || !markdownInfo.isFile() || markdownInfo.isSymbolicLink()) {
58
+ throw new Error('Managed export pair contains an unsupported file type.');
59
+ }
60
+ return Object.freeze({ json, markdown });
61
+ }
62
+ async function stableExists(path) {
63
+ try {
64
+ await completePair(path);
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ if (isMissing(error))
69
+ return false;
70
+ throw error;
71
+ }
72
+ }
73
+ function stableNameFor(identity, storageScope) {
74
+ if (storageScope !== undefined) {
75
+ try {
76
+ return assertAttachedStorageScope(storageScope);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ if (identity.kind === 'range' || identity.kind === 'exact-patch' || identity.kind === 'standalone') {
83
+ return Object.keys(identity).length === 2 && /^[0-9a-f]{64}$/u.test(identity.reviewKey)
84
+ ? identity.reviewKey
85
+ : undefined;
86
+ }
87
+ return Object.keys(identity).length === 3
88
+ && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(identity.baseOid)
89
+ && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(identity.headOid)
90
+ ? `${identity.baseOid}..${identity.headOid}`
91
+ : undefined;
92
+ }
93
+ function matchesPublicationIdentity(document, identity) {
94
+ if (identity.kind === 'standalone') {
95
+ return document.schemaVersion === 4 && document.standalone.reviewKey === identity.reviewKey;
96
+ }
97
+ if (identity.kind === 'exact-patch') {
98
+ return document.schemaVersion === 3 && document.patch.reviewKey === identity.reviewKey;
99
+ }
100
+ return identity.kind === 'range'
101
+ ? document.schemaVersion === 2 && document.range.reviewKey === identity.reviewKey
102
+ : document.schemaVersion === 1
103
+ && document.comparison.selectedBase.launchOid === identity.baseOid
104
+ && document.comparison.selectedHead.launchOid === identity.headOid;
105
+ }
106
+ function receipt(exportsRoot, stable, pair) {
107
+ const stableRelative = relative(exportsRoot, stable).split('\\').join('/');
108
+ const json = hashExportBytes(pair.json);
109
+ const markdown = hashExportBytes(pair.markdown);
110
+ const files = [
111
+ Object.freeze({ path: `${RENAME_MAP.repoStateDir}exports/${stableRelative}/review.json`, ...json }),
112
+ Object.freeze({ path: `${RENAME_MAP.repoStateDir}exports/${stableRelative}/review.md`, ...markdown }),
113
+ ];
114
+ return Object.freeze({ files: Object.freeze(files) });
115
+ }
116
+ function identity(info) {
117
+ if (!info.isDirectory() || info.isSymbolicLink()) {
118
+ throw new Error('Managed export directory is not a real directory.');
119
+ }
120
+ return Object.freeze({ dev: info.dev, ino: info.ino });
121
+ }
122
+ function sameIdentity(left, right) {
123
+ return left.dev === right.dev && left.ino === right.ino;
124
+ }
125
+ async function managedDirectory(component, create) {
126
+ let info;
127
+ try {
128
+ info = await lstat(component);
129
+ }
130
+ catch (error) {
131
+ if (!isMissing(error))
132
+ throw error;
133
+ if (!create)
134
+ return undefined;
135
+ await mkdir(component, { mode: 0o700 });
136
+ info = await lstat(component);
137
+ }
138
+ return identity(info);
139
+ }
140
+ export async function ensureManagedExportsRoot(repositoryRoot, create) {
141
+ const root = resolve(repositoryRoot);
142
+ const stateRoot = join(root, RENAME_MAP.repoStateDir.replace(/\/$/, ''));
143
+ const stateIdentity = await managedDirectory(stateRoot, create);
144
+ if (stateIdentity === undefined)
145
+ return undefined;
146
+ const exportsRoot = join(stateRoot, 'exports');
147
+ const exportsIdentity = await managedDirectory(exportsRoot, create);
148
+ if (exportsIdentity === undefined)
149
+ return undefined;
150
+ return Object.freeze({ stateRoot, exportsRoot, stateIdentity, exportsIdentity });
151
+ }
152
+ export async function assertManagedExportsRoot(managedRoot) {
153
+ const stateIdentity = await managedDirectory(managedRoot.stateRoot, false);
154
+ if (stateIdentity === undefined
155
+ || !sameIdentity(stateIdentity, managedRoot.stateIdentity)) {
156
+ throw new Error('Managed export directory identity changed.');
157
+ }
158
+ const exportsIdentity = await managedDirectory(managedRoot.exportsRoot, false);
159
+ if (exportsIdentity === undefined
160
+ || !sameIdentity(exportsIdentity, managedRoot.exportsIdentity)) {
161
+ throw new Error('Managed export directory identity changed.');
162
+ }
163
+ }
164
+ export async function publishReviewExport(input) {
165
+ const publication = input.identity;
166
+ const stableName = stableNameFor(publication, input.storageScope);
167
+ if (stableName === undefined)
168
+ return Object.freeze({ kind: 'publicationFailed' });
169
+ const repositoryRoot = resolve(input.repositoryRoot);
170
+ let managedRoot;
171
+ let candidate;
172
+ let exportsRoot;
173
+ let stable;
174
+ let publicationMayHaveChanged = false;
175
+ try {
176
+ managedRoot = await ensureManagedExportsRoot(repositoryRoot, true);
177
+ if (managedRoot === undefined) {
178
+ return Object.freeze({ kind: 'publicationFailed' });
179
+ }
180
+ exportsRoot = managedRoot.exportsRoot;
181
+ stable = join(exportsRoot, stableName);
182
+ await assertManagedExportsRoot(managedRoot);
183
+ const stablePresent = await stableExists(stable);
184
+ if (stablePresent && input.reExportCapability.kind === 'reExportUnsupported') {
185
+ return Object.freeze({ kind: 'reExportUnsupported' });
186
+ }
187
+ const candidateName = `.${stableName}.candidate-${randomUUID()}`;
188
+ candidate = join(exportsRoot, candidateName);
189
+ await assertManagedExportsRoot(managedRoot);
190
+ await mkdir(candidate, { mode: 0o700 });
191
+ await assertManagedExportsRoot(managedRoot);
192
+ await writeFileExactly(join(candidate, 'review.json'), input.json);
193
+ await assertManagedExportsRoot(managedRoot);
194
+ await writeFileExactly(join(candidate, 'review.md'), input.markdown);
195
+ await assertManagedExportsRoot(managedRoot);
196
+ const validatedCandidate = await completePair(candidate);
197
+ if (!validatedCandidate.json.equals(input.json) || !validatedCandidate.markdown.equals(input.markdown)) {
198
+ throw new Error('Candidate reread differs from validated export bytes.');
199
+ }
200
+ const document = parseCanonicalReviewExport(validatedCandidate.json);
201
+ if (!matchesPublicationIdentity(document, publication)
202
+ || !Buffer.from(renderReviewMarkdown(validatedCandidate.json), 'utf8').equals(validatedCandidate.markdown)) {
203
+ throw new Error('Candidate export identity or Markdown does not match canonical export JSON.');
204
+ }
205
+ await assertManagedExportsRoot(managedRoot);
206
+ await syncDirectory(candidate);
207
+ if (input.revalidate !== undefined && !(await input.revalidate())) {
208
+ return Object.freeze({ kind: 'publicationFailed' });
209
+ }
210
+ await assertManagedExportsRoot(managedRoot);
211
+ if (!stablePresent) {
212
+ await assertManagedExportsRoot(managedRoot);
213
+ if (await stableExists(stable)) {
214
+ return Object.freeze({ kind: 'publicationFailed' });
215
+ }
216
+ publicationMayHaveChanged = true;
217
+ await rename(candidate, stable);
218
+ }
219
+ else {
220
+ if (input.reExportCapability.kind !== 'observedNativeExchange') {
221
+ throw new ReExportUnsupported();
222
+ }
223
+ const exchanged = input.reExportCapability.exchangeDirectories(exportsRoot, stableName, candidateName);
224
+ if (exchanged.kind === 'unsupported') {
225
+ throw new ReExportUnsupported();
226
+ }
227
+ publicationMayHaveChanged = true;
228
+ if (exchanged.kind !== 'supported') {
229
+ throw new Error('Native exchange did not confirm publication.');
230
+ }
231
+ }
232
+ await assertManagedExportsRoot(managedRoot);
233
+ await syncDirectory(exportsRoot);
234
+ await assertManagedExportsRoot(managedRoot);
235
+ const finalPair = await completePair(stable);
236
+ if (!finalPair.json.equals(input.json) || !finalPair.markdown.equals(input.markdown)) {
237
+ return Object.freeze({ kind: 'publicationFailed' });
238
+ }
239
+ const finalReceipt = receipt(exportsRoot, stable, finalPair);
240
+ return Object.freeze({ kind: 'exported', receipt: finalReceipt });
241
+ }
242
+ catch (error) {
243
+ if (error instanceof ReExportUnsupported) {
244
+ return Object.freeze({ kind: 'reExportUnsupported' });
245
+ }
246
+ if (!publicationMayHaveChanged || managedRoot === undefined || exportsRoot === undefined || stable === undefined) {
247
+ return Object.freeze({ kind: 'publicationFailed' });
248
+ }
249
+ try {
250
+ await assertManagedExportsRoot(managedRoot);
251
+ const recovered = await completePair(stable);
252
+ if (!recovered.json.equals(input.json) || !recovered.markdown.equals(input.markdown)) {
253
+ return Object.freeze({ kind: 'recoveryRequired' });
254
+ }
255
+ return Object.freeze({ kind: 'exported', receipt: receipt(exportsRoot, stable, recovered) });
256
+ }
257
+ catch (recoveryError) {
258
+ return Object.freeze({ kind: isMissing(recoveryError) ? 'publicationFailed' : 'recoveryRequired' });
259
+ }
260
+ }
261
+ finally {
262
+ if (candidate !== undefined && managedRoot !== undefined) {
263
+ try {
264
+ await assertManagedExportsRoot(managedRoot);
265
+ await rm(candidate, { force: true, recursive: true });
266
+ }
267
+ catch {
268
+ // A failed cleanup never changes the complete stable generation.
269
+ }
270
+ }
271
+ }
272
+ }
273
+ export async function recoverReviewExport(repositoryRoot, identity, storageScope) {
274
+ const stableName = stableNameFor(identity, storageScope);
275
+ if (stableName === undefined)
276
+ return undefined;
277
+ const managedRoot = await ensureManagedExportsRoot(repositoryRoot, false);
278
+ if (managedRoot === undefined)
279
+ return undefined;
280
+ await assertManagedExportsRoot(managedRoot);
281
+ const stable = join(managedRoot.exportsRoot, stableName);
282
+ try {
283
+ await assertManagedExportsRoot(managedRoot);
284
+ return await completePair(stable);
285
+ }
286
+ catch (error) {
287
+ if (isMissing(error))
288
+ return undefined;
289
+ throw error;
290
+ }
291
+ }
@@ -0,0 +1,197 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { inspectLoopnoteIgnore } from '../git/ignore-status.js';
5
+ import { RENAME_MAP } from '../domain/product-identity.js';
6
+ const loopnoteIgnoreRule = Buffer.from(`/${RENAME_MAP.repoStateDir}\n`, 'ascii');
7
+ const appendQueues = new Map();
8
+ function isMissingPath(error) {
9
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
10
+ }
11
+ function result(kind) {
12
+ return Object.freeze({ kind });
13
+ }
14
+ async function closeAsUnconfirmed(handle) {
15
+ try {
16
+ await handle.close();
17
+ }
18
+ catch {
19
+ // A failed close cannot make this append safe to claim.
20
+ }
21
+ return result('unconfirmed');
22
+ }
23
+ async function inspectFailedMutation(ignorePath, identity, original, addition) {
24
+ let confirmation;
25
+ let outcome = result('ambiguous');
26
+ try {
27
+ confirmation = await open(ignorePath, constants.O_RDONLY | constants.O_NOFOLLOW);
28
+ const stat = await confirmation.stat();
29
+ if (stat.isFile() && stat.dev === identity.device && stat.ino === identity.inode) {
30
+ const bytes = await confirmation.readFile();
31
+ outcome = bytes.equals(original)
32
+ ? result('unchanged')
33
+ : bytes.equals(Buffer.concat([original, addition]))
34
+ ? result('appendUnconfirmed')
35
+ : result('ambiguous');
36
+ }
37
+ }
38
+ catch {
39
+ outcome = result('ambiguous');
40
+ }
41
+ finally {
42
+ if (confirmation !== undefined) {
43
+ try {
44
+ await confirmation.close();
45
+ }
46
+ catch {
47
+ outcome = result('ambiguous');
48
+ }
49
+ }
50
+ }
51
+ return outcome;
52
+ }
53
+ export async function appendLoopnoteIgnoreRule(options, dependencies = {}) {
54
+ const repositoryRoot = resolve(options.repositoryRoot);
55
+ const previous = appendQueues.get(repositoryRoot) ?? Promise.resolve();
56
+ let release;
57
+ const current = new Promise((resolveCurrent) => {
58
+ release = resolveCurrent;
59
+ });
60
+ const queued = previous.then(() => current);
61
+ appendQueues.set(repositoryRoot, queued);
62
+ await previous;
63
+ try {
64
+ const status = await inspectLoopnoteIgnore({ repositoryRoot });
65
+ if (status.kind === 'ignored') {
66
+ return result('alreadyIgnored');
67
+ }
68
+ if (status.kind === 'unavailable') {
69
+ return result('unconfirmed');
70
+ }
71
+ const ignorePath = join(repositoryRoot, '.gitignore');
72
+ if (dirname(ignorePath) !== repositoryRoot) {
73
+ return result('unconfirmed');
74
+ }
75
+ let handle;
76
+ let original;
77
+ let identity;
78
+ let size;
79
+ try {
80
+ let initial;
81
+ try {
82
+ initial = await lstat(ignorePath);
83
+ }
84
+ catch (error) {
85
+ if (!isMissingPath(error)) {
86
+ return result('unconfirmed');
87
+ }
88
+ }
89
+ if (initial !== undefined) {
90
+ if (!initial.isFile() || initial.isSymbolicLink()) {
91
+ return result('unconfirmed');
92
+ }
93
+ handle = await open(ignorePath, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW);
94
+ const opened = await handle.stat();
95
+ if (!opened.isFile() ||
96
+ opened.dev !== initial.dev ||
97
+ opened.ino !== initial.ino ||
98
+ opened.size !== initial.size) {
99
+ return closeAsUnconfirmed(handle);
100
+ }
101
+ original = await handle.readFile();
102
+ if (original.byteLength !== opened.size) {
103
+ return closeAsUnconfirmed(handle);
104
+ }
105
+ identity = { device: opened.dev, inode: opened.ino };
106
+ size = opened.size;
107
+ }
108
+ else {
109
+ handle = await open(ignorePath, constants.O_CREAT |
110
+ constants.O_EXCL |
111
+ constants.O_RDWR |
112
+ constants.O_APPEND |
113
+ constants.O_NOFOLLOW, 0o600);
114
+ const created = await handle.stat();
115
+ if (!created.isFile() || created.size !== 0) {
116
+ return closeAsUnconfirmed(handle);
117
+ }
118
+ original = Buffer.alloc(0);
119
+ identity = { device: created.dev, inode: created.ino };
120
+ size = created.size;
121
+ }
122
+ }
123
+ catch {
124
+ return handle === undefined ? result('unconfirmed') : closeAsUnconfirmed(handle);
125
+ }
126
+ if (handle === undefined) {
127
+ return result('unconfirmed');
128
+ }
129
+ const addition = original.byteLength === 0 || original.at(-1) === 0x0a
130
+ ? loopnoteIgnoreRule
131
+ : Buffer.concat([Buffer.from('\n', 'ascii'), loopnoteIgnoreRule]);
132
+ let mutationFailed = false;
133
+ try {
134
+ await dependencies.beforeAppend?.();
135
+ const beforeWrite = await handle.stat();
136
+ if (!beforeWrite.isFile() ||
137
+ beforeWrite.dev !== identity.device ||
138
+ beforeWrite.ino !== identity.inode ||
139
+ beforeWrite.size !== size) {
140
+ try {
141
+ await handle.close();
142
+ }
143
+ catch {
144
+ // No append was attempted, so the outcome remains unconfirmed.
145
+ }
146
+ return result('unconfirmed');
147
+ }
148
+ await (dependencies.writeAddition?.(handle, addition) ?? handle.writeFile(addition));
149
+ await (dependencies.sync?.(handle) ?? handle.sync());
150
+ }
151
+ catch {
152
+ mutationFailed = true;
153
+ }
154
+ try {
155
+ await (dependencies.close?.(handle) ?? handle.close());
156
+ }
157
+ catch {
158
+ mutationFailed = true;
159
+ }
160
+ if (mutationFailed) {
161
+ return inspectFailedMutation(ignorePath, identity, original, addition);
162
+ }
163
+ try {
164
+ const confirmation = await open(ignorePath, constants.O_RDONLY | constants.O_NOFOLLOW);
165
+ try {
166
+ const confirmed = await confirmation.stat();
167
+ const bytes = await confirmation.readFile();
168
+ const expected = Buffer.concat([original, addition]);
169
+ if (!confirmed.isFile() ||
170
+ confirmed.dev !== identity.device ||
171
+ confirmed.ino !== identity.inode ||
172
+ confirmed.size !== expected.byteLength ||
173
+ !bytes.equals(expected)) {
174
+ return result('ambiguous');
175
+ }
176
+ }
177
+ finally {
178
+ await confirmation.close();
179
+ }
180
+ }
181
+ catch {
182
+ return result('ambiguous');
183
+ }
184
+ return (await inspectLoopnoteIgnore({ repositoryRoot })).kind === 'ignored'
185
+ ? result('appended')
186
+ : result('appendUnconfirmed');
187
+ }
188
+ catch {
189
+ return result('unconfirmed');
190
+ }
191
+ finally {
192
+ release();
193
+ if (appendQueues.get(repositoryRoot) === queued) {
194
+ appendQueues.delete(repositoryRoot);
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,101 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ import { dirname, join, normalize } from 'node:path/posix';
4
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
5
+ const MAX_LS_TREE_OUTPUT_BYTES = 64 * 1024;
6
+ const modePattern = /^[0-7]{6}$/;
7
+ const oidPattern = /^[0-9a-f]+$/;
8
+ export const IMAGE_CONTENT_TYPE_BY_EXTENSION = {
9
+ png: 'image/png',
10
+ jpg: 'image/jpeg',
11
+ jpeg: 'image/jpeg',
12
+ gif: 'image/gif',
13
+ svg: 'image/svg+xml',
14
+ webp: 'image/webp',
15
+ };
16
+ function isNodeErrorWithCode(error, code) {
17
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
18
+ return false;
19
+ }
20
+ return error.code === code;
21
+ }
22
+ export function resolveRelativeImagePath(markdownDisplayPath, rawSrc) {
23
+ let decoded;
24
+ try {
25
+ decoded = decodeURIComponent(rawSrc);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ if (decoded.length === 0 ||
31
+ decoded.startsWith('/') ||
32
+ /[\u0000-\u001f]/u.test(decoded)) {
33
+ return undefined;
34
+ }
35
+ const normalized = normalize(join(dirname(markdownDisplayPath), decoded));
36
+ if (normalized === '.' ||
37
+ normalized === '..' ||
38
+ normalized.startsWith('../') ||
39
+ normalized.startsWith('/') ||
40
+ normalized.endsWith('/')) {
41
+ return undefined;
42
+ }
43
+ return normalized;
44
+ }
45
+ export async function readWorktreeImageBytes(repositoryRoot, relativePath) {
46
+ const parts = relativePath.split('/');
47
+ let current = repositoryRoot;
48
+ try {
49
+ for (const [index, part] of parts.entries()) {
50
+ current = `${current}/${part}`;
51
+ const entry = await lstat(current);
52
+ if (entry.isSymbolicLink()) {
53
+ return undefined;
54
+ }
55
+ if (index < parts.length - 1) {
56
+ if (!entry.isDirectory()) {
57
+ return undefined;
58
+ }
59
+ continue;
60
+ }
61
+ if (!entry.isFile() || entry.size > MAX_IMAGE_BYTES) {
62
+ return undefined;
63
+ }
64
+ const bytes = await readFile(current);
65
+ return bytes.length > MAX_IMAGE_BYTES ? undefined : bytes;
66
+ }
67
+ return undefined;
68
+ }
69
+ catch (error) {
70
+ if (isNodeErrorWithCode(error, 'ENOENT') || isNodeErrorWithCode(error, 'ENOTDIR')) {
71
+ return undefined;
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+ export async function readCommittedImageBytes(repositoryRoot, commitOid, relativePath, runner, reader) {
77
+ let result;
78
+ try {
79
+ result = await runner.run(['ls-tree', commitOid, '--', relativePath], {
80
+ cwd: repositoryRoot,
81
+ maxStdoutBytes: MAX_LS_TREE_OUTPUT_BYTES,
82
+ });
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ const output = result.stdout.toString('utf8').trim();
88
+ if (output.length === 0) {
89
+ return undefined;
90
+ }
91
+ const tab = output.indexOf('\t');
92
+ if (tab < 0) {
93
+ return undefined;
94
+ }
95
+ const fields = output.slice(0, tab).split(' ');
96
+ if (fields.length !== 3 || !modePattern.test(fields[0]) || fields[1] !== 'blob' || !oidPattern.test(fields[2])) {
97
+ return undefined;
98
+ }
99
+ const content = await reader.read(fields[2], { maxBytes: MAX_IMAGE_BYTES });
100
+ return content.kind === 'missing' ? undefined : content.bytes;
101
+ }
@@ -0,0 +1,72 @@
1
+ import { RENAME_MAP } from '../domain/product-identity.js';
2
+ const signalExitStatus = {
3
+ SIGINT: 130,
4
+ SIGTERM: 143,
5
+ };
6
+ export function createShutdownController(options) {
7
+ const signalSource = options.signalSource ?? process;
8
+ const setExitStatus = options.setExitStatus ??
9
+ ((status) => {
10
+ process.exitCode = status;
11
+ });
12
+ const reportError = options.reportError ??
13
+ ((error) => {
14
+ console.error(`${RENAME_MAP.displayName} shutdown failed.`, error);
15
+ });
16
+ let shutdownPromise;
17
+ const signalHandlers = {
18
+ SIGINT: () => {
19
+ void shutdown(signalExitStatus.SIGINT).catch(reportError);
20
+ },
21
+ SIGTERM: () => {
22
+ void shutdown(signalExitStatus.SIGTERM).catch(reportError);
23
+ },
24
+ };
25
+ function removeSignalHandlers() {
26
+ signalSource.off('SIGINT', signalHandlers.SIGINT);
27
+ signalSource.off('SIGTERM', signalHandlers.SIGTERM);
28
+ }
29
+ function shutdown(exitStatus = 0) {
30
+ if (shutdownPromise !== undefined) {
31
+ return shutdownPromise;
32
+ }
33
+ removeSignalHandlers();
34
+ shutdownPromise = (async () => {
35
+ let abortFailure;
36
+ try {
37
+ options.abortActiveWork();
38
+ }
39
+ catch (error) {
40
+ abortFailure = error;
41
+ }
42
+ let closeFailure;
43
+ try {
44
+ await options.closeListener();
45
+ }
46
+ catch (error) {
47
+ closeFailure = error;
48
+ }
49
+ finally {
50
+ setExitStatus(exitStatus);
51
+ }
52
+ if (abortFailure !== undefined && closeFailure !== undefined) {
53
+ throw new AggregateError([abortFailure, closeFailure], 'Active work abort and listener close both failed');
54
+ }
55
+ if (abortFailure !== undefined) {
56
+ throw abortFailure;
57
+ }
58
+ if (closeFailure !== undefined) {
59
+ throw closeFailure;
60
+ }
61
+ })();
62
+ return shutdownPromise;
63
+ }
64
+ signalSource.on('SIGINT', signalHandlers.SIGINT);
65
+ signalSource.on('SIGTERM', signalHandlers.SIGTERM);
66
+ return {
67
+ get isShuttingDown() {
68
+ return shutdownPromise !== undefined;
69
+ },
70
+ shutdown,
71
+ };
72
+ }