jackherd 1.8.0 → 1.9.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 (131) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +41 -60
  4. package/assets/branding/README.md +5 -7
  5. package/assets/branding/jackherd-logo-outline.svg +1 -0
  6. package/assets/branding/outline.test.ts +30 -0
  7. package/assets/icons/bark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide.svg +1 -1
  10. package/assets/icons/ear.svg +1 -1
  11. package/assets/icons/lanes.svg +1 -1
  12. package/assets/icons/plan.svg +1 -1
  13. package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +19 -3
  16. package/dist/plan-format.d.ts +22 -3
  17. package/dist/plan-format.js +21 -7
  18. package/herdr-plugin/herdr-plugin.toml +1 -1
  19. package/install.sh +1 -1
  20. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  21. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  22. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  23. package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
  24. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  25. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  26. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  27. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  28. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  29. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  30. package/kit/hooks/hooks.json +9 -0
  31. package/kit/hooks/statusline.sh +1 -1
  32. package/kit/workflow/skills/implement/SKILL.md +2 -0
  33. package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
  34. package/package.json +3 -1
  35. package/schemas/jackherd.schema.json +26 -2
  36. package/src/cli.ts +3 -0
  37. package/src/config/read-config.ts +5 -1
  38. package/src/config/schema.ts +22 -2
  39. package/src/context/fill.ts +12 -0
  40. package/src/context/session-log.ts +43 -1
  41. package/src/plan-format/clock.ts +4 -0
  42. package/src/plan-format/dependencies.ts +2 -5
  43. package/src/plan-format/lanes.ts +30 -5
  44. package/src/plan-format/terminal-text.ts +4 -0
  45. package/src/plan-format/waiting.ts +48 -0
  46. package/src/plans/active-tasks.ts +60 -0
  47. package/src/plans/current-phase.ts +17 -7
  48. package/src/plans/current-task.ts +63 -5
  49. package/src/plans/lane-standing.ts +12 -6
  50. package/src/plans/lane-summary.ts +141 -74
  51. package/src/plans/lane-tickets.ts +15 -0
  52. package/src/plans/layout-phase.ts +57 -18
  53. package/src/plans/layout-summary.ts +85 -67
  54. package/src/plans/layout.ts +253 -89
  55. package/src/plans/live.ts +22 -0
  56. package/src/plans/phase-models.ts +1 -1
  57. package/src/plans/phase-ranges.ts +45 -0
  58. package/src/plans/pull-requests.ts +6 -0
  59. package/src/plans/selection.ts +19 -16
  60. package/src/plans/sent-files-read.ts +22 -0
  61. package/src/plans/sent-files.ts +121 -0
  62. package/src/plans/signals-read.ts +54 -0
  63. package/src/plans/snapshot.ts +251 -55
  64. package/src/plans/subagents-read.ts +82 -0
  65. package/src/plans/subagents.ts +132 -0
  66. package/src/plans/texts.ts +16 -11
  67. package/src/plans/unlisted-lanes.ts +10 -5
  68. package/src/plans/view.ts +3 -3
  69. package/src/plans/waiting-click.ts +76 -0
  70. package/src/preview/command.ts +83 -0
  71. package/src/preview/embed.ts +209 -0
  72. package/src/preview/images.ts +89 -0
  73. package/src/preview/open.ts +12 -0
  74. package/src/preview/options.ts +32 -0
  75. package/src/preview/page.ts +89 -0
  76. package/src/preview/render.ts +41 -0
  77. package/src/settled/board-row.ts +1 -2
  78. package/src/shared/is-record.ts +2 -0
  79. package/src/shared/private-records.ts +48 -0
  80. package/src/task-signals/agent.ts +15 -0
  81. package/src/task-signals/claude-read.ts +72 -0
  82. package/src/task-signals/claude-signals.ts +112 -0
  83. package/src/task-signals/command.ts +71 -0
  84. package/src/task-signals/fixtures/session.jsonl +7 -0
  85. package/src/task-signals/matcher.ts +96 -0
  86. package/src/task-signals/record-store.ts +40 -0
  87. package/src/task-signals/record.ts +68 -0
  88. package/src/task-signals/text.ts +13 -0
  89. package/src/task-signals/types.ts +14 -0
  90. package/src/terminal/open.ts +13 -6
  91. package/src/terminal/text.ts +8 -1
  92. package/src/terminal/width.ts +2 -1
  93. package/src/update/command.ts +2 -0
  94. package/src/update/config-shares.ts +30 -0
  95. package/src/update/update.ts +12 -1
  96. package/src/vocabulary/index.ts +33 -1
  97. package/src/waiting/command.ts +35 -0
  98. package/src/watch/appointments.ts +5 -5
  99. package/src/watch/approvals.ts +2 -2
  100. package/src/watch/command.ts +91 -5
  101. package/src/watch/failed-tests.ts +56 -10
  102. package/src/watch/failure-read.ts +18 -6
  103. package/src/watch/failures.ts +29 -5
  104. package/src/watch/feedback.ts +1 -1
  105. package/src/watch/gh-process.ts +9 -3
  106. package/src/watch/json-guards.ts +0 -3
  107. package/src/watch/lane-fill.ts +1 -1
  108. package/src/watch/lane-model.ts +1 -1
  109. package/src/watch/lane-parallel.ts +1 -1
  110. package/src/watch/lane-progress.ts +19 -7
  111. package/src/watch/lane-report.ts +5 -1
  112. package/src/watch/lane-rest.ts +28 -0
  113. package/src/watch/plan-badges.ts +9 -2
  114. package/src/watch/previews.ts +1 -1
  115. package/src/watch/pull-requests.ts +2 -1
  116. package/src/watch/source-path.ts +1 -1
  117. package/src/watch/sources.ts +1 -1
  118. package/src/watch/stream-output.ts +12 -7
  119. package/src/watch/task-parallel-input.ts +118 -0
  120. package/src/watch/task-parallel-read.ts +149 -0
  121. package/src/watch/task-parallel-touched.ts +39 -0
  122. package/src/watch/task-parallel.ts +206 -0
  123. package/src/watch/waiting-line.ts +19 -0
  124. package/src/watch/watch-events.ts +13 -1
  125. package/src/watch/workflow-runs.ts +2 -1
  126. package/assets/icons/bark-dark.svg +0 -1
  127. package/assets/icons/bowl-dark.svg +0 -1
  128. package/assets/icons/decide-dark.svg +0 -1
  129. package/assets/icons/ear-dark.svg +0 -1
  130. package/assets/icons/lanes-dark.svg +0 -1
  131. package/assets/icons/plan-dark.svg +0 -1
@@ -3,13 +3,13 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
3
3
  import { shownWithMore } from './shown-with-more'
4
4
  import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
5
5
 
6
- /** The words of an open lane: its section labels, the age of a running phase, the wave, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
6
+ /** The words of an open lane: the age of a running phase, the word before the open phases that wait, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
7
7
  export type HerdTexts = {
8
- sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
9
8
  minutesAgo: (minutes: number) => string
10
- wave: (wave: number) => string
9
+ next: string
11
10
  notMerged: string
12
11
  reviewGate: string
12
+ gate: string
13
13
  }
14
14
 
15
15
  /**
@@ -24,6 +24,8 @@ export type PlanTexts = {
24
24
  noWorktree: string
25
25
  noRow: string
26
26
  notInPlan: string
27
+ /** Before a task an agent reports that no task of the plan names. */
28
+ now: string
27
29
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
28
30
  allButDocs: string
29
31
  tickedSummary: (count: number) => string
@@ -64,6 +66,7 @@ export const boardTexts = (term: string) => ({
64
66
  pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
65
67
  pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
66
68
  notReadable: (reason: string) => `${term} not readable: ${reason}`,
69
+ notOpened: (reason: string) => `could not open: ${reason}`,
67
70
  })
68
71
 
69
72
  const ALL_BUT_DOCS = 'docs open'
@@ -76,17 +79,18 @@ export const TEXTS: PlanTexts = {
76
79
  noWorktree: 'no worktree',
77
80
  noRow: 'no row in lanes',
78
81
  notInPlan: 'not in the plan',
82
+ now: 'now:',
79
83
  allButDocs: ALL_BUT_DOCS,
80
84
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
81
85
  draft: 'draft · ',
82
86
  privateLink: 'private',
83
87
  phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
84
88
  herd: {
85
- sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
86
89
  minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
87
- wave: (wave) => `wave ${wave}:`,
90
+ next: 'next',
88
91
  notMerged: 'not merged',
89
92
  reviewGate: 'Review gate',
93
+ gate: '· gate',
90
94
  },
91
95
  planned: (phases, plans) =>
92
96
  `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
@@ -98,12 +102,13 @@ export const TEXTS: PlanTexts = {
98
102
  },
99
103
  hint: '↑↓ select →← open a show ticked s html q quit ? help',
100
104
  legend: [
101
- 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
102
- '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working and blocked lanes open by themselves, → ← overrule until the status changes',
103
- `▸ ▾ open and close · NOW ▶ the phases whose boxes or task lines moved in the last ${STALE_ACTIVITY_MINUTES} min, the one moved last first, · n min ago since then, → the task each is on (Review gate = only its review gate is open) · NEXT the next wave · LATER the other open phases · DONE ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · the lane line names the phase moved last · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
104
- `◐ progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
105
- '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
106
- "SESSION: the agent's title · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main",
105
+ 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
106
+ '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
107
+ `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, ✓ · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · next = the other open phases, as ranges · ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
108
+ `the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
109
+ '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
110
+ '▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board · it goes when the board row is closed or `jh waiting close <id>` · woof closes the block',
111
+ '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
107
112
  ],
108
113
  }
109
114
 
@@ -2,8 +2,8 @@ import { isAbsolute, relative, sep } from 'node:path'
2
2
  import type { SprintLane } from '../plan-format'
3
3
  import type { LaneAgent } from '../terminal'
4
4
 
5
- /** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
6
- export type UnlistedLane = { name: string; agent: LaneAgent }
5
+ /** A worktree with an agent, or only a subagent of the coordinator, that has no row in the sprint table; `name` is its directory below the worktree directory. */
6
+ export type UnlistedLane = { name: string; agent: LaneAgent | null }
7
7
 
8
8
  type PlacedAgent = { lane: string; agent: LaneAgent }
9
9
 
@@ -25,21 +25,26 @@ const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): Placed
25
25
 
26
26
  /**
27
27
  * The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
28
- * agent in it. A worktree counts as listed when the table names it, or names any agent running in it. The first
29
- * agent of a worktree stands for it; the lanes come in name order.
28
+ * agent in it, or a subagent of the coordinator at work in it (`subagentLanes`, no agent then). A worktree counts as
29
+ * listed when the table names it, or names any agent running in it. The first agent of a worktree stands for it; the
30
+ * lanes come in name order.
30
31
  */
31
32
  export const unlistedLanes = (
32
33
  agents: readonly LaneAgent[],
33
34
  listed: ReadonlySet<string>,
34
35
  worktreeDirectory: string,
36
+ subagentLanes: ReadonlySet<string> = new Set(),
35
37
  ): UnlistedLane[] => {
36
38
  const inWorktrees = placed(agents, worktreeDirectory)
37
39
  const listedWorktrees = new Set(
38
40
  inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
39
41
  )
40
- const byLane = new Map<string, LaneAgent>()
42
+ const byLane = new Map<string, LaneAgent | null>()
41
43
  for (const { lane, agent } of inWorktrees) {
42
44
  if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
43
45
  }
46
+ for (const lane of subagentLanes) {
47
+ if (!listed.has(lane) && !byLane.has(lane)) byLane.set(lane, null)
48
+ }
44
49
  return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
45
50
  }
package/src/plans/view.ts CHANGED
@@ -37,6 +37,8 @@ export const renderSnapshot = (
37
37
  planned: snapshot.planned,
38
38
  watch: snapshot.watch,
39
39
  watchesTickets: snapshot.watchesTickets,
40
+ awaiting: snapshot.waiting,
41
+ unplaced: snapshot.unplaced,
40
42
  },
41
43
  selection,
42
44
  width,
@@ -45,9 +47,7 @@ export const renderSnapshot = (
45
47
  }
46
48
 
47
49
  export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
48
- options.lane
49
- ? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
50
- : overviewSelection()
50
+ options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
51
51
 
52
52
  /** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
53
53
  export const draw = (screen: Screen, top: number): void => {
@@ -0,0 +1,76 @@
1
+ import type { WaitingItem } from '../plan-format/waiting'
2
+ import { fit, wrap } from '../terminal/text'
3
+ import { textWidth } from '../terminal/width'
4
+ import { plain } from './layout-phase'
5
+ import { MOUSE_BUTTON, type MouseEvent } from './selection'
6
+
7
+ /** How many for-you rows the herd draws; the rest is one `+<n>` line. */
8
+ export const SHOWN_WAITING = 5
9
+ /** The bar and the blank that start every line of a for-you row. */
10
+ export const WAITING_MARK = '▌'
11
+
12
+ const FILE_MARK = '📎'
13
+ const QUESTION_MARK = '?'
14
+ const OPENABLE = /^(https?|file):\/\//
15
+
16
+ /**
17
+ * One for-you row as drawn: its plain text lines (more than one while it is expanded), whether its text is wider than
18
+ * the pane (`cut`), whether it is expanded, and the address a click opens.
19
+ */
20
+ export type WaitingRow = { key: string; lines: string[]; cut: boolean; expanded: boolean; url: string | null }
21
+
22
+ /** What a click does: expand or fold the row (its key), open an address, or nothing (null). */
23
+ export type WaitingClick = { toggle: string } | { open: string }
24
+
25
+ /** The key of a row in the herd's expanded set: the record's id, else its text. */
26
+ export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
27
+
28
+ const roomOf = (width: number): number => Math.max(width - textWidth(`${WAITING_MARK} `), 1)
29
+
30
+ const rowOf = (item: WaitingItem, expandedKeys: ReadonlySet<string>, width: number): WaitingRow => {
31
+ const text = `${item.kind === 'file' ? FILE_MARK : QUESTION_MARK} ${plain(item.item)}`
32
+ const room = roomOf(width)
33
+ const cut = textWidth(text) > room
34
+ const key = waitingKey(item)
35
+ const expanded = cut && expandedKeys.has(key)
36
+ const url = item.kind === 'file' && item.url && OPENABLE.test(item.url) ? item.url : null
37
+ return { key, lines: expanded ? wrap(text, room) : [fit(text, room)], cut, expanded, url }
38
+ }
39
+
40
+ /** The rows the herd draws for what waits on the user: the first five, each folded to one line unless expanded. */
41
+ export const waitingRows = (
42
+ items: readonly WaitingItem[],
43
+ expandedKeys: ReadonlySet<string>,
44
+ width: number,
45
+ ): WaitingRow[] => items.slice(0, SHOWN_WAITING).map((item) => rowOf(item, expandedKeys, width))
46
+
47
+ const rowAt = (rows: readonly WaitingRow[], firstLine: number, line: number): WaitingRow | null => {
48
+ let first = firstLine
49
+ for (const row of rows) {
50
+ if (line < first + row.lines.length) return line >= first ? row : null
51
+ first += row.lines.length
52
+ }
53
+ return null
54
+ }
55
+
56
+ const clickOn = ({ key, cut, expanded, url }: WaitingRow): WaitingClick | null => {
57
+ if (url) return cut && !expanded ? { toggle: key } : { open: url }
58
+ return cut ? { toggle: key } : null
59
+ }
60
+
61
+ /**
62
+ * What a plain left click on the for-you block does. A row that is cut off expands on the first click; then a
63
+ * question folds on the next click and a file or Artifact opens. A row that fits opens its file on the first click, a
64
+ * question does nothing. The `+<n>` line, other lines, a click with a modifier (ctrl is left to the terminal's own link
65
+ * opening), a release and the wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as
66
+ * `renderScreen` returns it.
67
+ */
68
+ export const waitingClick = (
69
+ rows: readonly WaitingRow[],
70
+ firstLine: number,
71
+ { button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
72
+ ): WaitingClick | null => {
73
+ if (!pressed || button !== MOUSE_BUTTON.left) return null
74
+ const target = rowAt(rows, firstLine, row - 1)
75
+ return target ? clickOn(target) : null
76
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { basename, dirname, join, resolve } from 'node:path'
4
+ import { openInBrowser } from '../terminal/open'
5
+ import { runGh } from '../watch/gh'
6
+ import { embedExternalImages, fetchImage } from './embed'
7
+ import { rewriteImages } from './images'
8
+ import { mediaTypeOf } from './open'
9
+ import { asksForHelp, PREVIEW_USAGE, parsePreviewOptions } from './options'
10
+ import { previewPage } from './page'
11
+ import { renderMarkdown, repositorySlugOf } from './render'
12
+
13
+ type PackageFacts = { root: string; name: string }
14
+
15
+ const packageOf = (file: string): PackageFacts | null => {
16
+ for (let directory = dirname(file); ; directory = dirname(directory)) {
17
+ const manifest = join(directory, 'package.json')
18
+ if (existsSync(manifest)) {
19
+ const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
20
+ if (typeof name === 'string') return { root: directory, name }
21
+ }
22
+ if (dirname(directory) === directory) return null
23
+ }
24
+ }
25
+
26
+ const remoteSlugOf = (directory: string): string | null => {
27
+ const result = Bun.spawnSync(['git', '-C', directory, 'remote', 'get-url', 'origin'], {
28
+ stdout: 'pipe',
29
+ stderr: 'pipe',
30
+ })
31
+ return result.exitCode === 0 ? repositorySlugOf(result.stdout.toString()) : null
32
+ }
33
+
34
+ /** Whether `file` is a regular file whose real path, links followed, lies under the real path of `root`. */
35
+ const isRegularFileUnder = (root: string, file: string): boolean => {
36
+ if (!existsSync(file)) return false
37
+ return realpathSync(file).startsWith(`${realpathSync(root)}/`) && statSync(file).isFile()
38
+ }
39
+
40
+ /**
41
+ * The image at `path` under `root` as a data address; null when it is missing, no image, no regular file, or outside
42
+ * `root` once links are followed, so a link in the working tree cannot put a file from elsewhere into the page.
43
+ */
44
+ export const inlineImage = (root: string, path: string): string | null => {
45
+ const file = resolve(root, path)
46
+ const type = mediaTypeOf(file)
47
+ if (type === null || !file.startsWith(`${root}/`) || !isRegularFileUnder(root, file)) return null
48
+ return `data:${type};base64,${readFileSync(file).toString('base64')}`
49
+ }
50
+
51
+ /**
52
+ * `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
53
+ * page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
54
+ * from the working tree, so the preview shows what is not published yet. External images (badges) are embedded as data addresses, so the page is complete where it may load nothing from outside; one that cannot be fetched stays linked, with one warning. Prints the path of the page; `--open`
55
+ * opens it. `--help` prints the usage. Exit 0 written, 1 failed (`gh` missing or not signed in, file unreadable).
56
+ */
57
+ export const run = async (args: string[]): Promise<number> => {
58
+ if (asksForHelp(args)) {
59
+ console.log(PREVIEW_USAGE)
60
+ return 0
61
+ }
62
+ try {
63
+ const options = parsePreviewOptions(args)
64
+ const file = resolve(options.file)
65
+ if (!existsSync(file)) throw new Error(`${options.file} does not exist`)
66
+ const facts = packageOf(file)
67
+ const html = await renderMarkdown(readFileSync(file, 'utf8'), remoteSlugOf(dirname(file)), dirname(file), runGh)
68
+ const own = facts ?? { root: dirname(file), name: '' }
69
+ const local = rewriteImages(html, own.name, (path) => inlineImage(own.root, path))
70
+ const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
71
+ if (failed > 0)
72
+ console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
73
+ const out = resolve(options.out ?? join(tmpdir(), `jh-preview-${basename(file, '.md')}.html`))
74
+ writeFileSync(out, previewPage({ title: basename(file), html: rewritten }))
75
+ console.log(out)
76
+ const reason = options.open ? openInBrowser(out) : null
77
+ if (reason) console.error(`could not open the page: ${reason}`)
78
+ return 0
79
+ } catch (error) {
80
+ console.error(error instanceof Error ? error.message : String(error))
81
+ return 1
82
+ }
83
+ }
@@ -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
+ }