jackherd 1.15.1 → 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 (39) hide show
  1. package/CHANGELOG.md +10 -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/package.json +1 -1
  10. package/schemas/jackherd.schema.json +12 -0
  11. package/schemas/sources.schema.json +141 -0
  12. package/src/cli.ts +1 -0
  13. package/src/config/json-schema.ts +7 -3
  14. package/src/config/location.ts +7 -0
  15. package/src/config/personal-sources.ts +83 -0
  16. package/src/config/schema.ts +29 -0
  17. package/src/plans/herd-claim.ts +9 -31
  18. package/src/plans/settings.ts +20 -7
  19. package/src/plans/snapshot.ts +26 -19
  20. package/src/secrets/command.ts +163 -0
  21. package/src/secrets/environment.ts +45 -0
  22. package/src/secrets/hidden-input.ts +51 -0
  23. package/src/secrets/listing.ts +63 -0
  24. package/src/secrets/options.ts +45 -0
  25. package/src/secrets/store.ts +74 -0
  26. package/src/shared/process-info.ts +89 -0
  27. package/src/sync/command-context.ts +26 -4
  28. package/src/sync/render.ts +9 -4
  29. package/src/watch/command.ts +44 -12
  30. package/src/watch/plan-badges-read.ts +28 -10
  31. package/src/watch/source-dry-run-command.ts +23 -12
  32. package/src/watch/source-dry-run.ts +19 -2
  33. package/src/watch/source-reload.ts +100 -0
  34. package/src/watch/source-run.ts +63 -7
  35. package/src/watch/source-tickets.ts +15 -0
  36. package/src/watch/sources.ts +21 -0
  37. package/src/watch/watch-events.ts +9 -0
  38. package/src/watch/watched-source.ts +39 -0
  39. package/src/watch/watcher-lock.ts +8 -1
@@ -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 ||
@@ -0,0 +1,39 @@
1
+ import { TONE_NAMES, type ToneName } from '../config/tones'
2
+ import { isRecord } from '../shared/is-record'
3
+ import { SOURCE_SHOW, type SourceSettings, type SourceShow } from './sources'
4
+
5
+ /**
6
+ * What the herd needs of a source, as the watcher writes it into its state: its name, the `label` and `tone` it is
7
+ * drawn with, whether its entries are tickets, its lane and how it shows its entries; null where the config sets none.
8
+ */
9
+ export type WatchedSource = {
10
+ name: string
11
+ label: string | null
12
+ tone: ToneName | null
13
+ ticket: boolean
14
+ lane: string | null
15
+ show: SourceShow | null
16
+ }
17
+
18
+ export const watchedSourceOf = ({ name, label, tone, ticket, lane, show }: SourceSettings): WatchedSource => ({
19
+ name,
20
+ label: label ?? null,
21
+ tone: tone ?? null,
22
+ ticket: ticket === true,
23
+ lane: lane ?? null,
24
+ show: show ?? null,
25
+ })
26
+
27
+ const isTextOrNull = (value: unknown): boolean => value === null || typeof value === 'string'
28
+
29
+ const isOneOfOrNull = (values: readonly string[], value: unknown): boolean =>
30
+ value === null || (typeof value === 'string' && values.includes(value))
31
+
32
+ export const isWatchedSource = (value: unknown): value is WatchedSource =>
33
+ isRecord(value) &&
34
+ typeof value.name === 'string' &&
35
+ isTextOrNull(value.label) &&
36
+ isOneOfOrNull(TONE_NAMES, value.tone) &&
37
+ typeof value.ticket === 'boolean' &&
38
+ isTextOrNull(value.lane) &&
39
+ isOneOfOrNull(Object.values(SOURCE_SHOW), value.show)
@@ -85,6 +85,9 @@ export const repositoryKey = (root: string): string => {
85
85
  /** How long the herd trusts a command line `ps` gave for a process ID that is still alive. */
86
86
  const PS_CACHE_MS = 30_000
87
87
 
88
+ /** Longest a `ps` call may take before it counts as no answer. */
89
+ const PS_TIMEOUT_MS = 2000
90
+
88
91
  /** The command line from `/proc/<pid>/cmdline`, its arguments blank-separated; null where there is no `/proc` or no such process. */
89
92
  export const procCommandLine: CommandLineOf = (pid) => {
90
93
  try {
@@ -95,7 +98,11 @@ export const procCommandLine: CommandLineOf = (pid) => {
95
98
  }
96
99
 
97
100
  const psCommandLine: CommandLineOf = (pid) => {
98
- const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
101
+ const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], {
102
+ stdout: 'pipe',
103
+ stderr: 'ignore',
104
+ timeout: PS_TIMEOUT_MS,
105
+ })
99
106
  return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
100
107
  }
101
108