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.
- package/CHANGELOG.md +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +23 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +131 -0
- package/src/plans/subagents.ts +190 -0
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- 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 (`&` 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> = { '&': '&', '"': '"', '<': '<', '>': '>', ''': "'" }
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|
package/src/settled/board-row.ts
CHANGED
|
@@ -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,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
|
+
}
|