jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -0,0 +1,209 @@
1
+ import { lookup } from 'node:dns/promises'
2
+ import { isIPv4, isIPv6 } from 'node:net'
3
+ import { decodedAttribute } from './images'
4
+
5
+ /** An image as fetched: its media type and bytes; null when it cannot be had. */
6
+ type FetchedImage = { type: string; bytes: Uint8Array }
7
+
8
+ /** The seam the page's external images are fetched through; a test replaces it. */
9
+ export type ImageFetch = (url: string) => Promise<FetchedImage | null>
10
+
11
+ /** What fetching an image needs from the network: the addresses of a host, and one request. Tests pass their own. */
12
+ export type ImageSeams = {
13
+ resolve: (host: string) => Promise<string[]>
14
+ request: (url: string, init: RequestInit) => Promise<Response>
15
+ }
16
+
17
+ const MAX_BYTES = 5 * 1024 * 1024
18
+ const TIMEOUT_MS = 10_000
19
+ const MAX_REDIRECTS = 3
20
+ const EXTERNAL_IMAGE = /<img\b[^>]*?\ssrc="(https:\/\/[^"]+)"/g
21
+ const REDIRECT = new Set([301, 302, 303, 307, 308])
22
+ /** A plain image media type; anything else would be written into the page's `src` attribute unchecked. */
23
+ const IMAGE_TYPE = /^image\/[a-z0-9.+-]+$/i
24
+
25
+ /**
26
+ * IPv4 ranges no public image lives in: this network, private, shared (carrier NAT), loopback, link-local (cloud
27
+ * metadata), IETF protocol assignments, benchmarking, multicast and reserved. As [first octets, prefix length].
28
+ */
29
+ const NON_PUBLIC_V4: [number, number, number][] = [
30
+ [0, 0, 8],
31
+ [10, 0, 8],
32
+ [100, 64, 10],
33
+ [127, 0, 8],
34
+ [169, 254, 16],
35
+ [172, 16, 12],
36
+ [192, 0, 24],
37
+ [192, 168, 16],
38
+ [198, 18, 15],
39
+ [224, 0, 3],
40
+ ]
41
+
42
+ const v4Number = (address: string): number => address.split('.').reduce((value, part) => value * 256 + Number(part), 0)
43
+
44
+ const isPublicV4 = (address: string): boolean => {
45
+ const value = v4Number(address)
46
+ return NON_PUBLIC_V4.every(([first, second, prefix]) => {
47
+ const network = (first * 256 + second) * 65_536
48
+ const size = 2 ** (32 - prefix)
49
+ return value < network || value >= network + size
50
+ })
51
+ }
52
+
53
+ /** An IPv4 address as the last two groups of an IPv6 address: `127.0.0.1` → `7f00:1`. */
54
+ const v4AsGroups = (address: string): string => {
55
+ const value = v4Number(address)
56
+ return `${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`
57
+ }
58
+
59
+ const groupsOf = (part: string | undefined): number[] =>
60
+ part ? part.split(':').map((group) => Number.parseInt(group, 16)) : []
61
+
62
+ /** The eight 16-bit groups of an IPv6 address, an embedded IPv4 tail included. */
63
+ const v6Groups = (address: string): number[] => {
64
+ const tail = /\d+\.\d+\.\d+\.\d+$/.exec(address)?.[0]
65
+ const text = tail ? `${address.slice(0, -tail.length)}${v4AsGroups(tail)}` : address
66
+ const [head, rest] = text.split('::')
67
+ const left = groupsOf(head)
68
+ const right = groupsOf(rest)
69
+ return rest === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right]
70
+ }
71
+
72
+ const v4Of = (high: number, low: number): string => [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.')
73
+
74
+ const isPublicV6 = (address: string): boolean => {
75
+ const groups = v6Groups(address)
76
+ const [first] = groups
77
+ const zeroPrefix = groups.slice(0, 5).every((group) => group === 0)
78
+ if (zeroPrefix && (groups[5] === 0xffff || groups[5] === 0)) {
79
+ return groups[5] === 0xffff || groups[6] !== 0 ? isPublicV4(v4Of(groups[6], groups[7])) : false
80
+ }
81
+ // Unique local fc00::/7, link-local fe80::/10, site-local fec0::/10, multicast ff00::/8.
82
+ return (first & 0xfe00) !== 0xfc00 && (first & 0xffc0) !== 0xfe80 && (first & 0xffc0) !== 0xfec0 && first < 0xff00
83
+ }
84
+
85
+ /** Whether an IP address is one a public host answers on; loopback, private, link-local and the like are not. */
86
+ export const isPublicAddress = (address: string): boolean => {
87
+ if (isIPv4(address)) return isPublicV4(address)
88
+ if (isIPv6(address)) return isPublicV6(address)
89
+ return false
90
+ }
91
+
92
+ /** Whether the address is https and every address its host resolves to is public; none resolved is no. */
93
+ const isFetchable = async (url: URL, resolve: ImageSeams['resolve']): Promise<boolean> => {
94
+ if (url.protocol !== 'https:') return false
95
+ const host = url.hostname.replace(/^\[(.*)\]$/, '$1')
96
+ const addresses = isIPv4(host) || isIPv6(host) ? [host] : await resolve(host)
97
+ return addresses.length > 0 && addresses.every(isPublicAddress)
98
+ }
99
+
100
+ /** The body up to `limit` bytes, read as a stream; null once it grows past it. */
101
+ const readCapped = async (body: ReadableStream<Uint8Array> | null, limit: number): Promise<Uint8Array | null> => {
102
+ if (!body) return new Uint8Array()
103
+ const reader = body.getReader()
104
+ const chunks: Uint8Array[] = []
105
+ let size = 0
106
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
107
+ size += read.value.length
108
+ if (size > limit) {
109
+ await reader.cancel()
110
+ return null
111
+ }
112
+ chunks.push(read.value)
113
+ }
114
+ const bytes = new Uint8Array(size)
115
+ let offset = 0
116
+ for (const chunk of chunks) {
117
+ bytes.set(chunk, offset)
118
+ offset += chunk.length
119
+ }
120
+ return bytes
121
+ }
122
+
123
+ /**
124
+ * The image an answer carries: a successful answer with a plain `image/<subtype>` type within the size limit, refused by its
125
+ * announced length before anything is read, else while it streams; null otherwise.
126
+ */
127
+ export const imageFromResponse = async (response: Response): Promise<FetchedImage | null> => {
128
+ const type = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()
129
+ if (!response.ok || type === undefined || !IMAGE_TYPE.test(type)) return null
130
+ if (Number(response.headers.get('content-length') ?? 0) > MAX_BYTES) return null
131
+ const bytes = await readCapped(response.body, MAX_BYTES)
132
+ return bytes ? { type, bytes } : null
133
+ }
134
+
135
+ /**
136
+ * Fetches an image from a public https host: the host is resolved first and refused when any of its addresses is
137
+ * loopback, private or link-local, redirects are followed by hand (at most three), each target checked the same way.
138
+ * Null for any refusal or failure. Resolving and requesting are two lookups, so a host that changes its answer between
139
+ * them (DNS rebinding) is not caught; the page is a local preview, which bounds what such a host could read.
140
+ */
141
+ export const fetchImageWith = async (
142
+ address: string,
143
+ { resolve, request }: ImageSeams,
144
+ ): Promise<FetchedImage | null> => {
145
+ let url = new URL(address)
146
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
147
+ if (!(await isFetchable(url, resolve))) return null
148
+ const response = await request(url.href, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS) })
149
+ const location = response.headers.get('location')
150
+ if (!REDIRECT.has(response.status)) return imageFromResponse(response)
151
+ if (!location) return null
152
+ url = new URL(location, url)
153
+ }
154
+ return null
155
+ }
156
+
157
+ const systemSeams: ImageSeams = {
158
+ resolve: async (host) => (await lookup(host, { all: true, verbatim: true })).map(({ address }) => address),
159
+ request: (url, init) => fetch(url, init),
160
+ }
161
+
162
+ /** Fetches an image over the network with a time limit; null for any failure. */
163
+ export const fetchImage: ImageFetch = async (url) => {
164
+ try {
165
+ return await fetchImageWith(url, systemSeams)
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ const dataAddressOf = ({ type, bytes }: FetchedImage): string =>
172
+ `data:${type};base64,${Buffer.from(bytes).toString('base64')}`
173
+
174
+ const safely = async (fetchOne: ImageFetch, url: string): Promise<FetchedImage | null> => {
175
+ try {
176
+ return await fetchOne(url)
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The page with every external image (`https://…` in an `<img src>`) shown from a data address, so it is complete where
184
+ * a page may load nothing from outside (an Artifact's content security policy). Each address is fetched once, as the
185
+ * address its attribute text stands for (`&amp;` read as `&`), and replaced by its text as written. One that
186
+ * cannot be fetched, or answers with no plain image type, keeps its address; `failed` counts those addresses. Plain
187
+ * http images are left as they are.
188
+ */
189
+ export const embedExternalImages = async (
190
+ html: string,
191
+ fetchOne: ImageFetch,
192
+ ): Promise<{ html: string; failed: number }> => {
193
+ const addresses = [...new Set([...html.matchAll(EXTERNAL_IMAGE)].map(([, address]) => address))]
194
+ const fetched = await Promise.all(
195
+ addresses.map(async (address) => [address, await safely(fetchOne, decodedAttribute(address))] as const),
196
+ )
197
+ const embedded = new Map(
198
+ fetched.flatMap(([address, image]) =>
199
+ image && IMAGE_TYPE.test(image.type) ? [[address, dataAddressOf(image)] as const] : [],
200
+ ),
201
+ )
202
+ return {
203
+ html: html.replace(EXTERNAL_IMAGE, (tag, address: string) => {
204
+ const data = embedded.get(address)
205
+ return data ? tag.replace(`src="${address}"`, `src="${data}"`) : tag
206
+ }),
207
+ failed: addresses.length - embedded.size,
208
+ }
209
+ }
@@ -0,0 +1,89 @@
1
+ import { posix } from 'node:path'
2
+
3
+ /** Reads the file at a path relative to the package root as an address the page can show; null when it cannot. */
4
+ export type LocalImage = (relativePath: string) => string | null
5
+
6
+ const CAMO = /^https:\/\/camo\.githubusercontent\.com\/[0-9a-f]+\/([0-9a-f]+)$/
7
+ const SCHEME = /^[a-z][a-z0-9+.-]*:|^\/\//i
8
+
9
+ /** The address GitHub's image proxy stands for; any other address as it is. */
10
+ const canonicalOf = (src: string): string => {
11
+ const hex = CAMO.exec(src)?.[1]
12
+ return hex ? Buffer.from(hex, 'hex').toString('utf8') : src
13
+ }
14
+
15
+ /** The path below `<package>[@version]/` of an unpkg address of the package; null for any other address. */
16
+ const unpkgPathOf = (address: string, packageName: string): string | null => {
17
+ if (!URL.canParse(address)) return null
18
+ const { host, pathname } = new URL(address)
19
+ const [, name, ...rest] = pathname.split('/')
20
+ if (host !== 'unpkg.com' || name?.split('@')[0] !== packageName || rest.length === 0) return null
21
+ return posix.normalize(rest.join('/'))
22
+ }
23
+
24
+ /**
25
+ * The path inside the package an image address means: a relative path, or an `unpkg.com/<package>/…` address of the
26
+ * package itself (any version, `?` part cut off). Null for every other address.
27
+ */
28
+ export const packagePathOf = (address: string, packageName: string): string | null => {
29
+ const clean = address.split(/[?#]/)[0]
30
+ const unpkg = unpkgPathOf(clean, packageName)
31
+ if (unpkg !== null) return unpkg
32
+ if (SCHEME.test(clean) || clean.startsWith('/') || clean === '') return null
33
+ const normalized = posix.normalize(clean)
34
+ return normalized.startsWith('..') ? null : normalized
35
+ }
36
+
37
+ const ENTITIES: Record<string, string> = { '&amp;': '&', '&quot;': '"', '&lt;': '<', '&gt;': '>', '&#39;': "'" }
38
+
39
+ /** An attribute's text as the address it stands for, the entities GitHub's renderer writes undone. */
40
+ export const decodedAttribute = (text: string): string =>
41
+ text.replace(/&(amp|quot|lt|gt|#39);/g, (entity) => ENTITIES[entity])
42
+
43
+ /** An address as attribute text: nothing in it can close the attribute or open a tag. */
44
+ const escapedAttribute = (text: string): string =>
45
+ text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
46
+
47
+ const attributeOf = (tag: string, name: string): string | null => {
48
+ const text = new RegExp(`\\s${name}="([^"]*)"`).exec(tag)?.[1]
49
+ return text === undefined ? null : decodedAttribute(text)
50
+ }
51
+
52
+ const withoutAttributes = (tag: string, names: readonly string[]): string =>
53
+ names.reduce((rest, name) => rest.replace(new RegExp(`\\s${name}="[^"]*"`, 'g'), ''), tag)
54
+
55
+ /** The tag with `source` as its only address; `source` is the address itself, not attribute text, and is escaped here. */
56
+ const withSource = (tag: string, source: string): string =>
57
+ withoutAttributes(tag, ['src', 'data-canonical-src']).replace(/^<img/, () => `<img src="${escapedAttribute(source)}"`)
58
+
59
+ const localOrKept = (address: string, packageName: string, read: LocalImage): string => {
60
+ const path = packagePathOf(address, packageName)
61
+ return (path === null ? null : read(path)) ?? address
62
+ }
63
+
64
+ /** One `<img>` tag with its address resolved: the working tree's file for the package's own images, the original address otherwise. */
65
+ const resolveImage = (tag: string, packageName: string, read: LocalImage): string => {
66
+ const address = canonicalOf(attributeOf(tag, 'data-canonical-src') ?? attributeOf(tag, 'src') ?? '')
67
+ return address === '' ? tag : withSource(tag, localOrKept(address, packageName, read))
68
+ }
69
+
70
+ const withClass = (tag: string, className: string): string => tag.replace(/^<img/, `<img class="${className}"`)
71
+
72
+ /** `<picture>` with a dark source as two images, one shown per theme; the page switches themes itself, the browser's media query would not follow it. */
73
+ const themedPicture = (picture: string): string => {
74
+ const dark = /<source[^>]*\bsrcset="([^"]+)"/.exec(picture)?.[1]
75
+ const image = /<img[^>]*>/.exec(picture)?.[0]
76
+ if (dark === undefined || image === undefined) return picture
77
+ const darkImage = withSource(image, decodedAttribute(dark))
78
+ return `${withClass(image, 'only-light')}${withClass(darkImage, 'only-dark')}`
79
+ }
80
+
81
+ /**
82
+ * The rendered HTML with every image shown from the working tree where it is the package's own (relative paths and
83
+ * `unpkg.com/<package>/…`, so an unpublished README shows its unpublished pictures) and every `<picture>` split
84
+ * into a light and a dark image. Other addresses stay as they are.
85
+ */
86
+ export const rewriteImages = (html: string, packageName: string, read: LocalImage): string =>
87
+ html
88
+ .replace(/<picture>[\s\S]*?<\/picture>/g, themedPicture)
89
+ .replace(/<img\b[^>]*>/g, (tag) => resolveImage(tag, packageName, read))
@@ -0,0 +1,12 @@
1
+ const MEDIA_TYPES: Record<string, string> = {
2
+ svg: 'image/svg+xml',
3
+ png: 'image/png',
4
+ jpg: 'image/jpeg',
5
+ jpeg: 'image/jpeg',
6
+ gif: 'image/gif',
7
+ webp: 'image/webp',
8
+ }
9
+
10
+ /** The media type of an image file by its extension; null when the preview does not inline that kind of file. */
11
+ export const mediaTypeOf = (path: string): string | null =>
12
+ MEDIA_TYPES[path.split('.').pop()?.toLowerCase() ?? ''] ?? null
@@ -0,0 +1,32 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /** What `jh preview` was asked for: the Markdown file, where to write the page (null: the temporary directory) and whether to open it. */
4
+ export type PreviewOptions = { file: string; out: string | null; open: boolean }
5
+
6
+ export const PREVIEW_USAGE = `usage: ${CLI_NAME} preview <markdown-file> [--out <path>] [--open]`
7
+
8
+ /** Whether the command line asks for the usage: `--help` or `-h` anywhere, as the top level of `jh` reads it. */
9
+ export const asksForHelp = (args: readonly string[]): boolean => args.includes('--help') || args.includes('-h')
10
+
11
+ const usageError = (message: string): Error => new Error(`${message}\n${PREVIEW_USAGE}`)
12
+
13
+ /** Reads `<file> [--out <path>] [--open]` in any order; throws on an unknown option, a missing value or a missing or second file. */
14
+ export const parsePreviewOptions = (args: readonly string[]): PreviewOptions => {
15
+ const words: string[] = []
16
+ let out: string | null = null
17
+ let open = false
18
+ for (let i = 0; i < args.length; i += 1) {
19
+ const arg = args[i]
20
+ if (arg === '--open') open = true
21
+ else if (arg === '--out') {
22
+ const value = args[i + 1]
23
+ if (value === undefined || value.startsWith('--')) throw usageError('--out needs a path')
24
+ out = value
25
+ i += 1
26
+ } else if (arg.startsWith('--')) throw usageError(`unknown option ${arg}`)
27
+ else words.push(arg)
28
+ }
29
+ if (words.length === 0) throw usageError('the Markdown file is required')
30
+ if (words.length > 1) throw usageError(`one file at most, got ${words.join(' ')}`)
31
+ return { file: words[0], out, open }
32
+ }
@@ -0,0 +1,89 @@
1
+ export type PreviewPage = { title: string; html: string }
2
+
3
+ const escapeHtml = (text: string): string =>
4
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
5
+
6
+ const PREVIEW_STYLE = {
7
+ github: {
8
+ name: 'GitHub',
9
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
10
+ size: '16px',
11
+ line: '1.5',
12
+ },
13
+ npm: { name: 'npm', font: '"Source Sans Pro", "Source Sans 3", sans-serif', size: '18px', line: '29.7px' },
14
+ } as const
15
+
16
+ const FONT_LINK = 'https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600;700&display=swap'
17
+
18
+ const STYLE = `
19
+ :root { color-scheme: light dark; }
20
+ body { margin: 0; font: 14px system-ui, sans-serif; background: #6e7781; }
21
+ header { display: flex; gap: 16px; align-items: center; padding: 8px 16px; background: #24292f; color: #fff; }
22
+ header label { display: flex; gap: 4px; align-items: center; }
23
+ main { display: flex; gap: 16px; align-items: flex-start; padding: 16px; }
24
+ section.pane { flex: 1 1 0; min-width: 0; border-radius: 6px; overflow: hidden; }
25
+ body.phone section.pane { flex: 0 0 390px; }
26
+ .pane-bar { display: flex; justify-content: space-between; padding: 4px 12px; background: #d0d7de; color: #1f2328; }
27
+ .pane[data-theme="dark"] .pane-bar { background: #30363d; color: #e6edf3; }
28
+ .doc { padding: 32px; --fg: #1f2328; --bg: #fff; --muted: #656d76; --line: #d0d7de; --code: #f6f8fa; --link: #0969da; color: var(--fg); background: var(--bg); }
29
+ .pane[data-theme="dark"] .doc { --fg: #e6edf3; --bg: #0d1117; --muted: #8d96a0; --line: #30363d; --code: #161b22; --link: #4493f8; }
30
+ .pane[data-theme="light"] .only-dark, .pane[data-theme="dark"] .only-light { display: none; }
31
+ .doc a { color: var(--link); }
32
+ .doc h1, .doc h2 { padding-bottom: .3em; border-bottom: 1px solid var(--line); }
33
+ .doc h1, .doc h2, .doc h3, .doc h4 { margin: 24px 0 16px; font-weight: 600; line-height: 1.25; }
34
+ .doc p, .doc ul, .doc ol, .doc table, .doc pre, .doc details { margin: 0 0 16px; }
35
+ .doc img { max-width: 100%; }
36
+ .doc code { padding: .2em .4em; border-radius: 6px; background: var(--code); font-size: 85%; }
37
+ .doc pre { padding: 16px; overflow: auto; border-radius: 6px; background: var(--code); }
38
+ .doc pre code { padding: 0; background: none; }
39
+ .doc table { border-collapse: collapse; display: block; overflow: auto; }
40
+ .doc th, .doc td { padding: 6px 13px; border: 1px solid var(--line); }
41
+ .doc tr:nth-child(2n) { background: var(--code); }
42
+ .doc blockquote { margin: 0 0 16px; padding: 0 1em; color: var(--muted); border-left: .25em solid var(--line); }
43
+ .doc hr { border: 0; height: 1px; background: var(--line); }
44
+ .doc .anchor { display: none; }
45
+ @media (max-width: 900px) { main { flex-direction: column; } }
46
+ `
47
+
48
+ const SCRIPT = `
49
+ for (const button of document.querySelectorAll('[data-theme-for]')) {
50
+ button.addEventListener('click', () => {
51
+ const pane = document.getElementById(button.dataset.themeFor)
52
+ pane.dataset.theme = pane.dataset.theme === 'dark' ? 'light' : 'dark'
53
+ button.textContent = pane.dataset.theme
54
+ })
55
+ }
56
+ document.getElementById('phone').addEventListener('change', (event) => document.body.classList.toggle('phone', event.target.checked))
57
+ `
58
+
59
+ const paneOf = (id: keyof typeof PREVIEW_STYLE, html: string): string => {
60
+ const { name, font, size, line } = PREVIEW_STYLE[id]
61
+ return `<section class="pane" id="${id}" data-theme="light">
62
+ <div class="pane-bar"><b>${name} style</b><button data-theme-for="${id}">light</button></div>
63
+ <article class="doc" style="font: ${size}/${line} ${font}">${html}</article>
64
+ </section>`
65
+ }
66
+
67
+ /**
68
+ * The preview page: the rendered README twice, in GitHub style (16 px system font) and in npm style (Source Sans
69
+ * 18 px, line height 29.7 px), each pane with its own light/dark switch, and a switch to phone width.
70
+ */
71
+ export const previewPage = ({ title, html }: PreviewPage): string => `<!doctype html>
72
+ <html lang="en">
73
+ <head>
74
+ <meta charset="utf-8">
75
+ <meta name="viewport" content="width=device-width, initial-scale=1">
76
+ <title>${escapeHtml(title)} (preview)</title>
77
+ <link rel="stylesheet" href="${FONT_LINK}">
78
+ <style>${STYLE}</style>
79
+ </head>
80
+ <body>
81
+ <header><b>${escapeHtml(title)}</b><label><input type="checkbox" id="phone"> phone width</label></header>
82
+ <main>
83
+ ${paneOf('github', html)}
84
+ ${paneOf('npm', html)}
85
+ </main>
86
+ <script>${SCRIPT}</script>
87
+ </body>
88
+ </html>
89
+ `
@@ -0,0 +1,41 @@
1
+ import type { GhAnswer, GhOptions } from '../watch/gh'
2
+
3
+ export type GhRunner = (args: string[], cwd: string, options?: GhOptions) => Promise<GhAnswer>
4
+
5
+ /** Names the `owner/repo` of a git remote address (ssh or https, `.git` optional); null for anything else. */
6
+ export const repositorySlugOf = (remote: string): string | null =>
7
+ /^git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote.trim())?.[1] ??
8
+ /^https?:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1] ??
9
+ null
10
+
11
+ const MISSING_GH =
12
+ 'the GitHub CLI `gh` is not installed: install it from https://cli.github.com, then run `gh auth login`'
13
+ const NOT_SIGNED_IN = 'the GitHub CLI is not signed in: run `gh auth login`, then try again'
14
+
15
+ /** The reason a `gh` call failed, with the step that fixes it when it is the missing CLI or the missing login. */
16
+ export const ghFailure = (error: string): string => {
17
+ if (/ENOENT|not found|not installed|No such file/i.test(error)) return MISSING_GH
18
+ if (/gh auth login|HTTP 401|not logged|authentication/i.test(error)) return NOT_SIGNED_IN
19
+ return `GitHub's markdown API failed: ${error}`
20
+ }
21
+
22
+ /**
23
+ * The HTML GitHub renders for `markdown` (flavor `gfm`, with the repository as context when known). The request body
24
+ * goes in through standard input: a long document as an argument exceeds the operating system's limit (`E2BIG`).
25
+ * Throws with the missing step.
26
+ */
27
+ export const renderMarkdown = async (
28
+ markdown: string,
29
+ repositorySlug: string | null,
30
+ cwd: string,
31
+ gh: GhRunner,
32
+ ): Promise<string> => {
33
+ const body = JSON.stringify({
34
+ text: markdown,
35
+ mode: 'gfm',
36
+ ...(repositorySlug === null ? {} : { context: repositorySlug }),
37
+ })
38
+ const answer = await gh(['api', '/markdown', '--input', '-'], cwd, { stdin: body })
39
+ if (!answer.ok) throw new Error(ghFailure(answer.error))
40
+ return answer.stdout
41
+ }
@@ -1,3 +1,4 @@
1
+ import { twoDigits } from '../plan-format/clock'
1
2
  import { sectionStart } from '../plan-format/markdown'
2
3
  import { terminalSafe } from '../plan-format/terminal-text'
3
4
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
@@ -50,8 +51,6 @@ export const isInSettledSection = (lines: readonly string[], index: number): boo
50
51
  return above !== undefined && headings.includes(above.trim())
51
52
  }
52
53
 
53
- const twoDigits = (value: number): string => String(value).padStart(2, '0')
54
-
55
54
  /** Local time as the board writes it: `DD.MM. hh:mm` in German, `YYYY-MM-DD hh:mm` in English. */
56
55
  export const settledTime = (time: Date, language: PlanLanguage): string =>
57
56
  BOARD_TEXTS[language].time({
@@ -0,0 +1,2 @@
1
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
2
+ typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -0,0 +1,89 @@
1
+ import { lstatSync, readFileSync, type Stats } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import { ensurePrivateDirectory, isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
5
+
6
+ type Entry = Pick<Stats, 'uid' | 'mode' | 'isDirectory' | 'isSymbolicLink'>
7
+
8
+ /** What choosing a private location reads from the system; the default is the real one (`systemLocation`). */
9
+ export type LocationSystem = {
10
+ env: Record<string, string | undefined>
11
+ temporaryDirectory: string
12
+ uid: number | undefined
13
+ /** What sits at the path, without following a link; null for nothing. */
14
+ entryAt: (path: string) => Entry | null
15
+ }
16
+
17
+ const PAGE_DIRECTORY_PREFIX = 'jackherd'
18
+ const GROUP_OR_OTHER = 0o077
19
+
20
+ const isPrivateDirectoryEntry = (entry: Entry | null, uid: number | undefined): boolean =>
21
+ entry !== null &&
22
+ !entry.isSymbolicLink() &&
23
+ entry.isDirectory() &&
24
+ isOwnedBy(entry, uid) &&
25
+ (entry.mode & GROUP_OR_OTHER) === 0
26
+
27
+ /**
28
+ * Where files only the user may read are kept: `$XDG_RUNTIME_DIR` when it is an absolute path to a directory of the
29
+ * user, no link, that neither group nor others may enter; else the temporary directory, which may be the shared `/tmp`
30
+ * and so needs a private level of its own below it (`userDirectoryOf`, `ensurePrivateLevels`).
31
+ */
32
+ export const runtimeBaseOf = ({ env, temporaryDirectory, uid, entryAt }: LocationSystem): string => {
33
+ const runtime = env.XDG_RUNTIME_DIR
34
+ return runtime && isAbsolute(runtime) && isPrivateDirectoryEntry(entryAt(runtime), uid) ? runtime : temporaryDirectory
35
+ }
36
+
37
+ /** The user's own level below `base`: `<base>/<prefix>-<uid>`, with `user` where the platform has no user ids. */
38
+ export const userDirectoryOf = (base: string, prefix: string, uid: number | undefined): string =>
39
+ join(base, `${prefix}-${uid ?? 'user'}`)
40
+
41
+ /**
42
+ * The user's directory for pages a browser opens: below the temporary directory, never the runtime directory, which a
43
+ * sandboxed browser may not reach.
44
+ */
45
+ export const pageDirectoryOf = (system: LocationSystem): string =>
46
+ userDirectoryOf(system.temporaryDirectory, PAGE_DIRECTORY_PREFIX, system.uid)
47
+
48
+ const entryAt = (path: string): Entry | null => {
49
+ try {
50
+ return lstatSync(path)
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ export const systemLocation = (): LocationSystem => ({
57
+ env: process.env,
58
+ temporaryDirectory: tmpdir(),
59
+ uid: process.getuid?.(),
60
+ entryAt,
61
+ })
62
+
63
+ /**
64
+ * Whether `userDirectory` and `directory` (itself or a directory right below it) both exist as directories of the user,
65
+ * no link, closed to group and others; creates nothing, for readers that must not read from a level someone else
66
+ * planted.
67
+ */
68
+ export const isPrivateLevels = (userDirectory: string, directory: string, system: LocationSystem): boolean =>
69
+ [userDirectory, directory].every((level) => isPrivateDirectoryEntry(system.entryAt(level), system.uid))
70
+
71
+ /** The text of a regular file; null for nothing, a link or anything else (a FIFO would block the read). */
72
+ export const readPrivateText = (path: string): string | null => {
73
+ try {
74
+ return lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Creates `directory`, the user level `userDirectory` itself or a directory right below it, and verifies each level
82
+ * with `ensurePrivateDirectory`, the user level first: nothing is created inside a level someone else owns or planted
83
+ * as a link, and this throws before anything is written there.
84
+ */
85
+ export const ensurePrivateLevels = (userDirectory: string, directory: string): string => {
86
+ ensurePrivateDirectory(userDirectory)
87
+ if (directory !== userDirectory) ensurePrivateDirectory(directory)
88
+ return directory
89
+ }
@@ -0,0 +1,48 @@
1
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const RECORD_FILE = /^[0-9a-f]{12}\.json$/
5
+ const MAX_RECORD_BYTES = 16 * 1024
6
+ const PRIVATE_FILE = 0o600
7
+ const PRIVATE_DIRECTORY = 0o700
8
+
9
+ const recordAt = <T>(path: string, isRecordOfKind: (value: unknown) => value is T): T[] => {
10
+ try {
11
+ const entry = lstatSync(path)
12
+ if (!entry.isFile() || entry.size > MAX_RECORD_BYTES) return []
13
+ const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
14
+ return isRecordOfKind(record) ? [record] : []
15
+ } catch {
16
+ return []
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Every well-formed `<id>.json` record in `directory` (a symbolic link, an oversized file and one the guard refuses are
22
+ * skipped), ascending by `sortKey`; nothing when the directory is missing or unreadable.
23
+ */
24
+ export const readRecords = <T>(
25
+ directory: string,
26
+ isRecordOfKind: (value: unknown) => value is T,
27
+ sortKey: (record: T) => number,
28
+ ): T[] => {
29
+ try {
30
+ return readdirSync(directory)
31
+ .filter((name) => RECORD_FILE.test(name))
32
+ .flatMap((name) => recordAt(join(directory, name), isRecordOfKind))
33
+ .sort((left, right) => sortKey(left) - sortKey(right))
34
+ } catch {
35
+ return []
36
+ }
37
+ }
38
+
39
+ /** Writes `<id>.json` through a temporary file, `0600` in a `0700` directory, so a reader never sees half of it. */
40
+ export const writeRecordAtomically = <T extends { id: string }>(directory: string, record: T): void => {
41
+ mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
42
+ chmodSync(directory, PRIVATE_DIRECTORY)
43
+ const path = join(directory, `${record.id}.json`)
44
+ const temporary = `${path}.${process.pid}.tmp`
45
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
46
+ chmodSync(temporary, PRIVATE_FILE)
47
+ renameSync(temporary, path)
48
+ }