jackherd 1.15.0 → 1.15.2

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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +17 -17
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
  10. package/kit/rules/rules/kit/pull-requests.md +1 -1
  11. package/package.json +1 -1
  12. package/schemas/jackherd.schema.json +12 -0
  13. package/schemas/sources.schema.json +141 -0
  14. package/src/cli.ts +1 -0
  15. package/src/config/json-schema.ts +7 -3
  16. package/src/config/location.ts +7 -0
  17. package/src/config/personal-sources.ts +83 -0
  18. package/src/config/schema.ts +29 -0
  19. package/src/plans/herd-claim.ts +9 -31
  20. package/src/plans/settings.ts +20 -7
  21. package/src/plans/snapshot.ts +26 -19
  22. package/src/secrets/command.ts +163 -0
  23. package/src/secrets/environment.ts +45 -0
  24. package/src/secrets/hidden-input.ts +51 -0
  25. package/src/secrets/listing.ts +63 -0
  26. package/src/secrets/options.ts +45 -0
  27. package/src/secrets/store.ts +74 -0
  28. package/src/shared/process-info.ts +89 -0
  29. package/src/sync/command-context.ts +26 -4
  30. package/src/sync/render.ts +9 -4
  31. package/src/watch/command.ts +44 -12
  32. package/src/watch/plan-badges-read.ts +28 -10
  33. package/src/watch/source-dry-run-command.ts +23 -12
  34. package/src/watch/source-dry-run.ts +19 -2
  35. package/src/watch/source-reload.ts +100 -0
  36. package/src/watch/source-run.ts +63 -7
  37. package/src/watch/source-tickets.ts +15 -0
  38. package/src/watch/sources.ts +21 -0
  39. package/src/watch/watch-events.ts +9 -0
  40. package/src/watch/watched-source.ts +39 -0
  41. package/src/watch/watcher-lock.ts +8 -1
@@ -32,6 +32,7 @@ import { existsSync, readFileSync } from 'node:fs'
32
32
  import { homedir } from 'node:os'
33
33
  import { basename, dirname, join, relative } from 'node:path'
34
34
  import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
35
+ import { sourcesWithPersonal } from '../config/personal-sources'
35
36
  import { readConfig, readResolvedConfig } from '../config/read-config'
36
37
  import type { JackherdConfig } from '../config/schema'
37
38
  import { resolveCoordinatorName } from '../context/coordinator-name'
@@ -182,9 +183,16 @@ import { withSingleReportNotice } from './single-report'
182
183
  import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
183
184
  import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
184
185
  import { sourceDryRun } from './source-dry-run-command'
186
+ import {
187
+ type LoadedSources,
188
+ nextLoadedSources,
189
+ readFileStamps,
190
+ readWatchedSources,
191
+ sourceFilePaths,
192
+ } from './source-reload'
185
193
  import { runSource, SOURCE_LIMITS } from './source-run'
186
- import { recordWatchedSources } from './source-tickets'
187
- import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
194
+ import { recordedSourceState } from './source-tickets'
195
+ import { isSourceDue, type SourceRun, type SourceState, sourceEvents } from './sources'
188
196
  import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
189
197
  import {
190
198
  formatTaskParallelLine,
@@ -257,7 +265,10 @@ type Watch = {
257
265
  /** The switch `watch.parallelTasks.auto` and the paths that always ask, both from the main checkout's config. */
258
266
  parallelAuto: AutoSettings
259
267
  previews: PreviewsConfig
260
- sources: SourceSettings[]
268
+ /** The sources as read at the start; the loop takes up later changes of their files (`Watching.sources`). */
269
+ sources: LoadedSources
270
+ /** The checkouts whose config the sources are read from, as `readResolvedConfig` takes them. */
271
+ configRoots: string[]
261
272
  texts: WatchTexts
262
273
  /** Where the orchestrate skill is installed, the project's copy first; the events name a reference inside the one found. */
263
274
  skillCopies: SkillCopy[]
@@ -305,6 +316,8 @@ type Watching = {
305
316
  failureReadings: FailureReading[]
306
317
  /** The last preview read that answered; recorded with every state like the feedback read. */
307
318
  previewRead: PreviewRead | null
319
+ /** The sources run now, read again whenever one of their files changes (`nextLoadedSources`). */
320
+ sources: LoadedSources
308
321
  /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
309
322
  sourceRuns: SourceRun[]
310
323
  /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
@@ -381,7 +394,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
381
394
  unlinkedArtifacts: watching.unlinkedArtifacts,
382
395
  planMissing: watching.planMissing,
383
396
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
384
- sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
397
+ ...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
385
398
  appointments: watching.appointments
386
399
  ? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
387
400
  : withReads.appointments,
@@ -887,15 +900,26 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
887
900
  * source stops the watcher. The run's start is recorded with the GitHub read's time, which is `now`.
888
901
  */
889
902
  const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
890
- const due = watch.sources.filter((source) =>
903
+ const due = watching.sources.sources.filter((source) =>
891
904
  isSourceDue(watching.state.sources[source.name]?.ranAt, source.every, now),
892
905
  )
893
906
  return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
894
907
  }
895
908
 
896
- const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]): WatchEvent[] => {
909
+ /**
910
+ * Takes up a change of the files the sources come from before the due ones run (`nextLoadedSources`): a source read
911
+ * anew has no `ranAt` and runs at once, its first run a baseline; an invalid file keeps the list and is said once.
912
+ */
913
+ const reloadSources = (watch: Watch, watching: Watching) => {
914
+ const stamps = readFileStamps(Object.keys(watching.sources.stamps))
915
+ const next = nextLoadedSources(watching.sources, stamps, () => readWatchedSources(watch.configRoots, homedir()))
916
+ watching.sources = next.loaded
917
+ for (const line of next.lines) console.log(line)
918
+ }
919
+
920
+ const recordSourceRuns = (watching: Watching, runs: SourceRun[]): WatchEvent[] => {
897
921
  watching.sourceRuns = runs
898
- return sourceEvents(watching.state.sources, runs, watch.sources)
922
+ return sourceEvents(watching.state.sources, runs, watching.sources.sources)
899
923
  }
900
924
 
901
925
  /** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
@@ -917,7 +941,8 @@ const syncPlanHeads = (watch: Watch, watching: Watching, pullRequests: readonly
917
941
 
918
942
  /**
919
943
  * Reads the coordinator's fill, GitHub, the feedback, the previews and the due sources, and stores them only once every read is complete: a SIGTERM
920
- * while a read is pending saves the state as it was, so no event is taken as reported.
944
+ * while a read is pending saves the state as it was, so no event is taken as reported. Before the sources run, a
945
+ * change of the files they come from is taken up (`reloadSources`), which prints its notes.
921
946
  */
922
947
  const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
923
948
  syncBase(watch, watching)
@@ -925,6 +950,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
925
950
  const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
926
951
  const laneBranches = laneBranchesOf(watch, watching.lanes)
927
952
  const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
953
+ reloadSources(watch, watching)
928
954
  const [github, sourceRuns] = await Promise.all([
929
955
  readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
930
956
  runDueSources(watch, watching, now),
@@ -940,7 +966,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
940
966
  ...recordGithub(watching, github, laneBranches, now, failureReadings),
941
967
  ...recordFeedbackRead(watching, feedback?.read ?? null),
942
968
  ...recordPreviewRead(watching, previews),
943
- ...recordSourceRuns(watch, watching, sourceRuns),
969
+ ...recordSourceRuns(watching, sourceRuns),
944
970
  ...recordCoordinator(watching, { name, read: fills.coordinator }),
945
971
  ...recordLaneFills(watching, fills.lanes),
946
972
  ...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
@@ -1355,6 +1381,7 @@ const initialWatching = (
1355
1381
  feedbackRead: null,
1356
1382
  failureReadings: [],
1357
1383
  previewRead: null,
1384
+ sources: watch.sources,
1358
1385
  sourceRuns: [],
1359
1386
  laneReadings: {},
1360
1387
  laneModelReadings: {},
@@ -1458,8 +1485,12 @@ const autoSettingsOf = (root: string, checkoutRoot: string, config: JackherdConf
1458
1485
  const watchOf = (options: WatchOptions): Watch => {
1459
1486
  const root = mainCheckout(process.cwd())
1460
1487
  const checkoutRoot = worktreeRoot(process.cwd())
1461
- const { config, notes } = readResolvedConfig([checkoutRoot, root])
1462
- for (const note of notes) console.log(note)
1488
+ const configRoots = [checkoutRoot, root]
1489
+ // Stamped before the read: a change in between is read again at the first poll, never missed.
1490
+ const stamps = readFileStamps(sourceFilePaths(configRoots, homedir()))
1491
+ const { config, notes } = readResolvedConfig(configRoots)
1492
+ const merged = sourcesWithPersonal(config.watch.sources)
1493
+ for (const note of [...notes, ...merged.notes]) console.log(note)
1463
1494
  const settings = plansSettingsOf(config)
1464
1495
  const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
1465
1496
  foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
@@ -1478,7 +1509,8 @@ const watchOf = (options: WatchOptions): Watch => {
1478
1509
  taskSignalMinutes: config.watch.taskSignalMinutes,
1479
1510
  parallelAuto: autoSettingsOf(root, checkoutRoot, config),
1480
1511
  previews: config.watch.previews,
1481
- sources: config.watch.sources,
1512
+ sources: { sources: merged.sources, stamps },
1513
+ configRoots,
1482
1514
  texts: watchTexts(settings.responseLanguage),
1483
1515
  skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
1484
1516
  herdr: insideHerdr(settings.adapter, process.env),
@@ -1,9 +1,10 @@
1
1
  import { dirname, join } from 'node:path'
2
+ import { type HerdSourceSettings, herdSourceSettingsOf } from '../plans/settings'
2
3
  import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
3
4
  import {
4
5
  sourceTicketsOf,
5
- type TicketSource,
6
6
  WATCH_HEALTH,
7
+ type WatchHealth,
7
8
  type WatchReading,
8
9
  watchBadgesOf,
9
10
  watchHealthOf,
@@ -22,21 +23,35 @@ const readState = (file: string): WatchState | null => {
22
23
  }
23
24
  }
24
25
 
26
+ /** A reading with the sources it was drawn for: those the watcher wrote into its state, else the herd's own. */
27
+ export type SourcedWatchReading = WatchReading & { sourceSettings: HerdSourceSettings }
28
+
29
+ /**
30
+ * The sources a live watcher keeps in its state (`watchedSources`); `fallback`, the herd's config, for a watcher that
31
+ * stopped (its state may predate a config change) or a state written before it kept them.
32
+ */
33
+ export const sourceSettingsOf = (
34
+ state: WatchState | null,
35
+ health: WatchHealth,
36
+ fallback: HerdSourceSettings,
37
+ ): HerdSourceSettings =>
38
+ health.kind === WATCH_HEALTH.live && state?.watchedSources ? herdSourceSettingsOf(state.watchedSources) : fallback
39
+
25
40
  /**
26
41
  * What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
27
- * its badges, the tickets of `ticketSources`, the status of the sources in `statusNames`, and its health, so the view can say why there are none. A missing or
28
- * damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
29
- * directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
30
- * planted neither feeds the view nor blocks it.
42
+ * its badges, the tickets of the ticket sources, the status of the other sources, and its health, so the view can say
43
+ * why there are none. The sources are those the state names, so one the watcher took up since the herd started shows
44
+ * too; `fallback` stands in for a state without them. A missing or damaged file shows no badges; the view goes on
45
+ * without them. Nothing is read unless both levels of the watcher directory are private (`isPrivateLevels`), and
46
+ * only regular files, so a directory, a file or a FIFO someone else planted neither feeds the view nor blocks it.
31
47
  */
32
48
  export const readWatchReading = (
33
49
  root: string,
34
50
  now: number,
35
- ticketSources: readonly TicketSource[],
51
+ fallback: HerdSourceSettings,
36
52
  lookUp?: CommandLineOf,
37
53
  system: LocationSystem = systemLocation(),
38
- statusNames: readonly string[] = [],
39
- ): WatchReading => {
54
+ ): SourcedWatchReading => {
40
55
  const directory = watcherDirectoryOf(root, system)
41
56
  const isPrivate = isPrivateLevels(dirname(directory), directory, system)
42
57
  const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
@@ -45,11 +60,14 @@ export const readWatchReading = (
45
60
  isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
46
61
  now,
47
62
  )
48
- if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
63
+ const sourceSettings = sourceSettingsOf(state, health, fallback)
64
+ if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health, sourceSettings }
65
+ const statusNames = sourceSettings.herdSources.map(({ name }) => name)
49
66
  return {
50
67
  badges: watchBadgesOf(state, now),
51
- tickets: sourceTicketsOf(state, ticketSources, now),
68
+ tickets: sourceTicketsOf(state, sourceSettings.ticketSources, now),
52
69
  health,
53
70
  ...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
71
+ sourceSettings,
54
72
  }
55
73
  }
@@ -1,15 +1,17 @@
1
- import { readResolvedConfig } from '../config/read-config'
2
- import { type JackherdConfig, parseSourceDraft } from '../config/schema'
1
+ import { homedir } from 'node:os'
2
+ import { personalSourcesPath } from '../config/location'
3
+ import { configuredSources, isNoteAbout } from '../config/personal-sources'
4
+ import { parseSourceDraft } from '../config/schema'
3
5
  import { mainCheckout, worktreeRoot } from '../plans/git'
4
6
  import type { SourceDryRun } from './options'
5
7
  import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
6
- import { runSourceCommand, SOURCE_LIMITS, sourceRunOf } from './source-run'
8
+ import { SOURCE_LIMITS, sourceAnswer, sourceRunOf } from './source-run'
7
9
  import type { SourceSettings } from './sources'
8
10
 
9
- const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise<SourceSettings> => {
11
+ const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings[]): Promise<SourceSettings> => {
10
12
  if (target.kind === 'name') {
11
- const found = config.watch.sources.find(({ name }) => name === target.name)
12
- if (!found) throw new Error(`no source ${target.name} in watch.sources`)
13
+ const found = sources.find(({ name }) => name === target.name)
14
+ if (!found) throw new Error(`no source ${target.name} in watch.sources or the personal sources file`)
13
15
  return found
14
16
  }
15
17
  const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
@@ -17,19 +19,28 @@ const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise
17
19
  }
18
20
 
19
21
  /**
20
- * `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
21
- * watcher would, and prints what it found and would report; `--raw` adds the command's output. Takes no lock and
22
+ * `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout (a personal one in `~/.jackherd`), as the
23
+ * watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
24
+ * the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
22
25
  * neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
23
26
  * `map` field is empty in every entry.
24
27
  */
25
28
  export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
26
29
  const root = mainCheckout(process.cwd())
27
- const { config } = readResolvedConfig([worktreeRoot(process.cwd()), root])
28
- const settings = await settingsOf(dryRunTarget(target), config)
29
- const answer = await runSourceCommand(settings.command, root, SOURCE_LIMITS)
30
+ const home = homedir()
31
+ const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
32
+ const settings = await settingsOf(dryRunTarget(target), merged.sources)
33
+ const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
30
34
  const run = sourceRunOf(settings, answer)
31
35
  const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
32
36
  const stdout = raw && answer.ok ? answer.stdout : undefined
33
- console.log(dryRunLines(settings, run, { mapCheck, stdout }).join('\n'))
37
+ console.log(
38
+ dryRunLines(settings, run, {
39
+ mapCheck,
40
+ stdout,
41
+ personalPath: personalSourcesPath(home),
42
+ notes: merged.notes.filter((note) => isNoteAbout(note, settings.name)),
43
+ }).join('\n'),
44
+ )
34
45
  return dryRunExitCode(run, mapCheck)
35
46
  }
@@ -3,6 +3,7 @@ import { sourceEventLines } from './lane-report'
3
3
  import {
4
4
  isIgnoredAuthor,
5
5
  listAt,
6
+ SOURCE_ORIGIN,
6
7
  type SourceItem,
7
8
  type SourceRun,
8
9
  type SourceSettings,
@@ -17,7 +18,14 @@ export type DryRunTarget = { kind: 'name'; name: string } | { kind: 'file'; path
17
18
  export type MapCheck = { total: number; empty: { field: string; path: string }[] }
18
19
 
19
20
  /** What one dry run shows besides the run: the map check (null when the output has no list) and the raw output. */
20
- export type DryRunExtras = { mapCheck: MapCheck | null; stdout?: string }
21
+ export type DryRunExtras = {
22
+ mapCheck: MapCheck | null
23
+ stdout?: string
24
+ /** The personal sources file, named for a personal source. */
25
+ personalPath?: string
26
+ /** Notes of the merge that concern this source, such as the repository source that ran in place of a personal one. */
27
+ notes?: readonly string[]
28
+ }
21
29
 
22
30
  const STDIN = '-'
23
31
  const LISTED_ENTRIES = 10
@@ -118,7 +126,16 @@ const exampleLines = (settings: SourceSettings, run: SourceRun): string[] => {
118
126
  */
119
127
  export const dryRunLines = (settings: SourceSettings, run: SourceRun, extras: DryRunExtras): string[] => {
120
128
  const head = `dry run source ${terminalSafe(settings.name)}: ${terminalSafe(JSON.stringify(settings.command))}`
121
- const top = [head, ...(extras.stdout === undefined ? [] : rawLines(extras.stdout)), ...mapCheckLines(extras.mapCheck)]
129
+ const origin =
130
+ settings.origin === SOURCE_ORIGIN.personal ? [`origin: personal, ${terminalSafe(extras.personalPath ?? '')}`] : []
131
+ const notes = (extras.notes ?? []).map((note) => `note: ${terminalSafe(note)}`)
132
+ const top = [
133
+ head,
134
+ ...origin,
135
+ ...notes,
136
+ ...(extras.stdout === undefined ? [] : rawLines(extras.stdout)),
137
+ ...mapCheckLines(extras.mapCheck),
138
+ ]
122
139
  if (!run.result.ok) return [...top, ...eventsOf(settings, run).flatMap(sourceEventLines), FOOTER]
123
140
  return [...top, ...entryLines(run.result.items, settings), ...exampleLines(settings, run), FOOTER]
124
141
  }
@@ -0,0 +1,100 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { locateConfigFile, PROJECT_CONFIG_PATH, personalSourcesPath } from '../config/location'
4
+ import { mergedSources, readPersonalSources } from '../config/personal-sources'
5
+ import { readResolvedConfig } from '../config/read-config'
6
+ import { errorText } from '../plans/error-text'
7
+ import type { SourceSettings } from './sources'
8
+
9
+ /** A file's change time in epoch milliseconds, null while it is missing; by path. */
10
+ export type FileStamps = Record<string, number | null>
11
+
12
+ /** The sources the watcher runs and the change times of the files they were read from. */
13
+ export type LoadedSources = { sources: SourceSettings[]; stamps: FileStamps }
14
+
15
+ /** A file the sources come from and how to read them; `read` throws for an invalid file. */
16
+ export type SourceFileReader = { path: string; read: () => readonly SourceSettings[] }
17
+
18
+ /** A file that could not be read, as the line the watcher prints. */
19
+ type ReadFailure = { ok: false; line: string }
20
+
21
+ /** A reading of both files: the merged sources with the notes of the skipped ones, or the line naming the file that failed. */
22
+ export type SourceRead = { ok: true; sources: SourceSettings[]; notes: string[] } | ReadFailure
23
+
24
+ /** What a line starts with when a changed file left the sources as they were. */
25
+ export const NOT_RELOADED = 'sources not reloaded:'
26
+
27
+ const sameStamps = (previous: FileStamps, current: FileStamps): boolean => {
28
+ const paths = new Set([...Object.keys(previous), ...Object.keys(current)])
29
+ return [...paths].every((path) => previous[path] === current[path])
30
+ }
31
+
32
+ /** The error of `path` on one line, without the `<path> is invalid:` the config readers put before the issues. */
33
+ const reasonOf = (path: string, error: unknown): string =>
34
+ errorText(error).replace(`${path} is invalid:`, '').replace(/\s+/g, ' ').trim()
35
+
36
+ const readOne = (reader: SourceFileReader): { ok: true; sources: readonly SourceSettings[] } | ReadFailure => {
37
+ try {
38
+ return { ok: true, sources: reader.read() }
39
+ } catch (error) {
40
+ return { ok: false, line: `${NOT_RELOADED} ${reader.path}: ${reasonOf(reader.path, error)}` }
41
+ }
42
+ }
43
+
44
+ /** Reads the repository's sources, then the personal ones, and merges them (`mergedSources`); the first file that fails is named. */
45
+ export const readSourceFiles = (repo: SourceFileReader, personal: SourceFileReader): SourceRead => {
46
+ const repoRead = readOne(repo)
47
+ if (!repoRead.ok) return repoRead
48
+ const personalRead = readOne(personal)
49
+ if (!personalRead.ok) return personalRead
50
+ return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path) }
51
+ }
52
+
53
+ /**
54
+ * The sources after one cycle: with the stamps as they were, the same list without a read; after a change, the list
55
+ * `read` gives and its notes, or, when a changed file is invalid, the list as it was and the line naming the file.
56
+ * The new stamps are taken either way, so one change reports once and a source leaves the list only after a valid read.
57
+ */
58
+ export const nextLoadedSources = (
59
+ previous: LoadedSources,
60
+ stamps: FileStamps,
61
+ read: () => SourceRead,
62
+ ): { loaded: LoadedSources; lines: string[] } => {
63
+ if (sameStamps(previous.stamps, stamps)) return { loaded: previous, lines: [] }
64
+ const result = read()
65
+ return result.ok
66
+ ? { loaded: { sources: result.sources, stamps }, lines: result.notes }
67
+ : { loaded: { sources: previous.sources, stamps }, lines: [result.line] }
68
+ }
69
+
70
+ /** The files the watcher's sources come from: the project config of each checkout in `roots`, once, then the personal file. */
71
+ export const sourceFilePaths = (roots: readonly string[], home: string): string[] => [
72
+ ...new Set(roots.map((root) => join(root, PROJECT_CONFIG_PATH))),
73
+ personalSourcesPath(home),
74
+ ]
75
+
76
+ const changeTimeOf = (path: string): number | null => {
77
+ try {
78
+ return statSync(path).mtimeMs
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ export const readFileStamps = (paths: readonly string[]): FileStamps =>
85
+ Object.fromEntries(paths.map((path) => [path, changeTimeOf(path)]))
86
+
87
+ /**
88
+ * Reads the sources again: `watch.sources` of the config `readResolvedConfig` finds in `roots` (the rest of the config
89
+ * is not taken over), and those of the personal file of `home`. Unlike the start (`sourcesWithPersonal`), where an
90
+ * invalid personal file leaves the repository's sources alone, here it keeps the list the watcher runs: both keep
91
+ * what ran before, which at the start is no personal source.
92
+ */
93
+ export const readWatchedSources = (roots: string[], home: string): SourceRead =>
94
+ readSourceFiles(
95
+ {
96
+ path: locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH),
97
+ read: () => readResolvedConfig(roots, home).config.watch.sources,
98
+ },
99
+ { path: personalSourcesPath(home), read: (): SourceSettings[] => readPersonalSources(home) },
100
+ )
@@ -1,5 +1,16 @@
1
+ import { homedir } from 'node:os'
2
+ import { personalSourcesDirectory } from '../config/location'
3
+ import { type Environment, sourceEnvironment } from '../secrets/environment'
4
+ import { SECRET_LOOKUP, SECRET_TIMEOUT_MS, type SecretLookupFn, secretStore } from '../secrets/store'
1
5
  import { resolveSourceCommand } from './source-command'
2
- import { exitFailure, parseSourceOutput, SOURCE_FAILURE, type SourceRun, type SourceSettings } from './sources'
6
+ import {
7
+ exitFailure,
8
+ parseSourceOutput,
9
+ SOURCE_FAILURE,
10
+ SOURCE_ORIGIN,
11
+ type SourceRun,
12
+ type SourceSettings,
13
+ } from './sources'
3
14
 
4
15
  /** How long a source's command may run and how much it may print before it is stopped. */
5
16
  export type SourceLimits = { timeoutMs: number; maxBytes: number }
@@ -38,10 +49,11 @@ const readCapped = async (child: Child, maxBytes: number): Promise<string | null
38
49
  }
39
50
 
40
51
  /** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
41
- const spawned = (command: readonly string[], cwd: string): Child | null => {
52
+ const spawned = (command: readonly string[], cwd: string, env: Environment | undefined): Child | null => {
42
53
  try {
43
54
  return Bun.spawn(resolveSourceCommand(command, process.execPath), {
44
55
  cwd,
56
+ env,
45
57
  stdin: 'ignore',
46
58
  stdout: 'pipe',
47
59
  stderr: 'ignore',
@@ -62,7 +74,8 @@ const killGroup = (child: Child) => {
62
74
  }
63
75
 
64
76
  /**
65
- * Runs `command` as given, without a shell, in `cwd` with the user's environment; never rejects. stderr is not read,
77
+ * Runs `command` as given, without a shell, in `cwd` with `env` (the user's environment without it); never rejects.
78
+ * `env` reaches the command only: it is neither an argument nor printed. stderr is not read,
66
79
  * so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. Past `timeoutMs` or
67
80
  * `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
68
81
  * process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
@@ -71,8 +84,9 @@ export const runSourceCommand = async (
71
84
  command: readonly string[],
72
85
  cwd: string,
73
86
  limits: SourceLimits,
87
+ env?: Environment,
74
88
  ): Promise<SourceAnswer> => {
75
- const child = spawned(command, cwd)
89
+ const child = spawned(command, cwd, env)
76
90
  if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
77
91
  let timer: ReturnType<typeof setTimeout> | undefined
78
92
  const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
@@ -92,6 +106,48 @@ export const runSourceCommand = async (
92
106
  }
93
107
  }
94
108
 
95
- /** One run of a source: its command, then its output over `items` and `map`. */
96
- export const runSource = async (settings: SourceSettings, cwd: string, limits: SourceLimits): Promise<SourceRun> =>
97
- sourceRunOf(settings, await runSourceCommand(settings.command, cwd, limits))
109
+ /** Where a source runs: a personal one in the personal directory under `home`, a repository one in `root`, the main checkout. */
110
+ export const sourceDirectory = (settings: Pick<SourceSettings, 'origin'>, root: string, home: string): string =>
111
+ settings.origin === SOURCE_ORIGIN.personal ? personalSourcesDirectory(home) : root
112
+
113
+ /**
114
+ * The keychain lookup a run uses unless it is handed another: bounded by the store's own limit and never past the
115
+ * run's, so a keychain that hangs costs each run at most that long.
116
+ */
117
+ const keychainLookup = (limits: SourceLimits): SecretLookupFn =>
118
+ secretStore(Bun.secrets, Math.min(SECRET_TIMEOUT_MS, limits.timeoutMs)).get
119
+
120
+ const noKeychain: SecretLookupFn = async () => ({ kind: SECRET_LOOKUP.absent })
121
+
122
+ /**
123
+ * The lookup a source's secrets go through: the keychain for a personal source only. A repository source, which
124
+ * anyone with a commit can write, gets the watcher's environment and never a keychain value.
125
+ */
126
+ export const secretLookupFor = (settings: Pick<SourceSettings, 'origin'>, keychain: SecretLookupFn): SecretLookupFn =>
127
+ settings.origin === SOURCE_ORIGIN.personal ? keychain : noKeychain
128
+
129
+ /**
130
+ * The answer of a source's command in `sourceDirectory`, its `secrets` resolved first (`sourceEnvironment` over
131
+ * `secretLookupFor`, looked up afresh on each run and dropped after it). A secret that cannot be resolved fails the run
132
+ * before the command starts.
133
+ */
134
+ export const sourceAnswer = async (
135
+ settings: SourceSettings,
136
+ root: string,
137
+ limits: SourceLimits,
138
+ home: string = homedir(),
139
+ lookup: SecretLookupFn = keychainLookup(limits),
140
+ ): Promise<SourceAnswer> => {
141
+ const environment = await sourceEnvironment(settings.secrets ?? [], secretLookupFor(settings, lookup), process.env)
142
+ if (!environment.ok) return environment
143
+ return runSourceCommand(settings.command, sourceDirectory(settings, root, home), limits, environment.environment)
144
+ }
145
+
146
+ /** One run of a source in `sourceDirectory`: its secrets and command (`sourceAnswer`), then its output over `items` and `map`. */
147
+ export const runSource = async (
148
+ settings: SourceSettings,
149
+ root: string,
150
+ limits: SourceLimits,
151
+ home: string = homedir(),
152
+ lookup: SecretLookupFn = keychainLookup(limits),
153
+ ): Promise<SourceRun> => sourceRunOf(settings, await sourceAnswer(settings, root, limits, home, lookup))
@@ -15,6 +15,7 @@ import {
15
15
  storedValue,
16
16
  type TicketRecord,
17
17
  } from './sources'
18
+ import { type WatchedSource, watchedSourceOf } from './watched-source'
18
19
 
19
20
  const ticketOf = (
20
21
  item: SourceItem,
@@ -117,3 +118,17 @@ export const recordWatchedSources = (
117
118
  settings,
118
119
  now,
119
120
  )
121
+
122
+ /**
123
+ * The two parts of the watcher's state the sources write after `runs`: their records (`recordWatchedSources`) and
124
+ * what the herd draws them with (`watchedSources`), both from the sources the watcher runs now.
125
+ */
126
+ export const recordedSourceState = (
127
+ previous: SourceState,
128
+ runs: readonly SourceRun[],
129
+ settings: readonly SourceSettings[],
130
+ now: number,
131
+ ): { sources: SourceState; watchedSources: WatchedSource[] } => ({
132
+ sources: recordWatchedSources(previous, runs, settings, now),
133
+ watchedSources: settings.map(watchedSourceOf),
134
+ })
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import type { ToneName } from '../config/tones'
2
3
  import { terminalSafe } from '../plan-format/terminal-text'
3
4
  import { isRecord } from '../shared/is-record'
4
5
  import { previewUrlOf } from './previews'
@@ -35,6 +36,16 @@ export type SourceMap = {
35
36
  status?: SourcePath
36
37
  } & Record<string, SourcePath | undefined>
37
38
 
39
+ /** Where a source is defined: the repository's config or the user's `~/.jackherd/sources.json`. */
40
+ export const SOURCE_ORIGIN = { repo: 'repo', personal: 'personal' } as const
41
+
42
+ export type SourceOrigin = (typeof SOURCE_ORIGIN)[keyof typeof SOURCE_ORIGIN]
43
+
44
+ /** How the herd shows a source's entries: `entries` shows them open, in a block of their own. */
45
+ export const SOURCE_SHOW = { entries: 'entries' } as const
46
+
47
+ export type SourceShow = (typeof SOURCE_SHOW)[keyof typeof SOURCE_SHOW]
48
+
38
49
  /**
39
50
  * One `watch.sources` entry as the watcher uses it; `every` is in minutes, `items` the path of the list (the root
40
51
  * without it), `reportLimit` the most entries one report shows of it.
@@ -52,6 +63,16 @@ export type SourceSettings = {
52
63
  reportOn?: readonly string[]
53
64
  /** Its entries are tickets, which the state keeps for the herd's badges (`TicketRecord`). */
54
65
  ticket?: boolean
66
+ /** Names of the secrets the command gets as environment variables. */
67
+ secrets?: readonly string[]
68
+ /** `entries` shows its entries open, in a herd block of their own. */
69
+ show?: SourceShow
70
+ /** Where the source is defined; without it the repository's config. */
71
+ origin?: SourceOrigin
72
+ /** Shown before the source's rows in the herd; without it its name. */
73
+ label?: string
74
+ /** The tone of its mark in the herd while its newest report is new. */
75
+ tone?: ToneName
55
76
  }
56
77
 
57
78
  /**
@@ -38,6 +38,7 @@ import {
38
38
  type UnlinkedArtifactEvent,
39
39
  type UnlinkedState,
40
40
  } from './unlinked-artifacts'
41
+ import { isWatchedSource, type WatchedSource } from './watched-source'
41
42
  import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
42
43
 
43
44
  /** The event `updateAvailableLine` prints; it is no `WatchEvent`, the announcer logs it on its own. */
@@ -130,6 +131,11 @@ export type WatchState = {
130
131
  approvals: string[]
131
132
  /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
132
133
  sources: SourceState
134
+ /**
135
+ * The sources the watcher runs, of the repository and the personal file, in its order and as the herd draws them;
136
+ * null in a state written before the watcher kept them, so the herd reads its own config.
137
+ */
138
+ watchedSources: WatchedSource[] | null
133
139
  /** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
134
140
  reportMtimes: Record<string, number | null>
135
141
  /** Lane → the questions of its report last shown or seen (`questionsFingerprint`); a lane without an entry is unseen. */
@@ -232,6 +238,7 @@ export const emptyWatchState = (): WatchState => ({
232
238
  planMissing: {},
233
239
  approvals: [],
234
240
  sources: emptySourceState(),
241
+ watchedSources: null,
235
242
  reportMtimes: {},
236
243
  reportQuestions: {},
237
244
  statusLines: null,
@@ -287,6 +294,8 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
287
294
  Array.isArray(value.approvals) &&
288
295
  value.approvals.every((id) => typeof id === 'string') &&
289
296
  isSourceState(value.sources) &&
297
+ (value.watchedSources === null ||
298
+ (Array.isArray(value.watchedSources) && value.watchedSources.every(isWatchedSource))) &&
290
299
  everyValue(value.reportMtimes, isSeq) &&
291
300
  everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
292
301
  (value.statusLines === null ||