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
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ export const COMMANDS: CommandTable = {
28
28
  mermaid: () => import('./mermaid/command'),
29
29
  plan: () => import('./plan-contract/command'),
30
30
  preview: () => import('./preview/command'),
31
+ secret: () => import('./secrets/command'),
31
32
  settled: () => import('./settled/command'),
32
33
  sync: () => import('./sync/command'),
33
34
  task: () => import('./task-signals/command'),
@@ -1,5 +1,9 @@
1
1
  import { z } from 'zod'
2
- import { jackherdConfigSchema } from './schema'
2
+ import { jackherdConfigSchema, personalSourcesSchema } from './schema'
3
3
 
4
- export const renderConfigJsonSchema = (): string =>
5
- `${JSON.stringify(z.toJSONSchema(jackherdConfigSchema, { io: 'input' }), null, 2)}\n`
4
+ const renderJsonSchema = (schema: z.ZodType): string =>
5
+ `${JSON.stringify(z.toJSONSchema(schema, { io: 'input' }), null, 2)}\n`
6
+
7
+ export const renderConfigJsonSchema = (): string => renderJsonSchema(jackherdConfigSchema)
8
+
9
+ export const renderSourcesJsonSchema = (): string => renderJsonSchema(personalSourcesSchema)
@@ -50,3 +50,10 @@ export const findProjectRoot = (start: string, home: string, exists: (path: stri
50
50
  */
51
51
  export const locateConfigFile = (roots: string[], home: string, exists: (path: string) => boolean): string | null =>
52
52
  [...roots, home].flatMap(configCandidates).find(exists) ?? null
53
+
54
+ export const PERSONAL_SOURCES_FILE = 'sources.json'
55
+
56
+ /** The directory a personal source runs in, and holds the personal sources file. */
57
+ export const personalSourcesDirectory = (home: string): string => join(home, TOOL_DIRECTORY)
58
+
59
+ export const personalSourcesPath = (home: string): string => join(personalSourcesDirectory(home), PERSONAL_SOURCES_FILE)
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { errorMessage } from '../sync/error-message'
4
+ import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
5
+ import { personalSourcesPath } from './location'
6
+ import { readResolvedConfig } from './read-config'
7
+ import { parsePersonalSourcesFile } from './schema'
8
+
9
+ export type MergedSources = { sources: SourceSettings[]; notes: string[] }
10
+
11
+ /** What a note starts with when an invalid personal file was left out. */
12
+ export const PERSONAL_IGNORED = 'personal sources ignored:'
13
+
14
+ const skippedPrefix = (name: string): string => `personal source ${name} in `
15
+
16
+ const skippedNote = (name: string, path: string): string =>
17
+ `${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
18
+
19
+ /** Whether a merge note concerns the source `name`: the personal source of that name was skipped, or the file was ignored. */
20
+ export const isNoteAbout = (note: string, name: string): boolean =>
21
+ note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
22
+
23
+ const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
24
+ sources.map((source) => ({ ...source, origin }))
25
+
26
+ /**
27
+ * The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
28
+ * repository source has is left out and named in a note; `path` is how the notes spell the personal file.
29
+ */
30
+ export const mergedSources = (
31
+ repo: readonly SourceSettings[],
32
+ personal: readonly SourceSettings[],
33
+ path: string,
34
+ ): MergedSources => {
35
+ const repoNames = new Set(repo.map(({ name }) => name))
36
+ const kept = personal.filter(({ name }) => !repoNames.has(name))
37
+ const skipped = personal.filter(({ name }) => repoNames.has(name))
38
+ return {
39
+ sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
40
+ notes: skipped.map(({ name }) => skippedNote(name, path)),
41
+ }
42
+ }
43
+
44
+ /** The personal sources of `~/.jackherd/sources.json`, none when the file is missing; an invalid file throws with its path and the issues. */
45
+ export const readPersonalSources = (
46
+ home: string = homedir(),
47
+ exists: (path: string) => boolean = existsSync,
48
+ readText: (path: string) => string = (path) => readFileSync(path, 'utf8'),
49
+ ): SourceSettings[] => {
50
+ const path = personalSourcesPath(home)
51
+ if (!exists(path)) return []
52
+ let raw: unknown
53
+ try {
54
+ raw = JSON.parse(readText(path))
55
+ } catch (error) {
56
+ throw new Error(`${path} is invalid: ${errorMessage(error)}`)
57
+ }
58
+ return parsePersonalSourcesFile(raw, path).sources
59
+ }
60
+
61
+ /**
62
+ * The repository's sources with the personal ones of `home` merged in (`mergedSources`). An invalid personal file
63
+ * never stops the caller: its error becomes a note and the repository's sources stand alone.
64
+ */
65
+ export const sourcesWithPersonal = (
66
+ repo: readonly SourceSettings[],
67
+ home: string = homedir(),
68
+ exists: (path: string) => boolean = existsSync,
69
+ readText?: (path: string) => string,
70
+ ): MergedSources => {
71
+ try {
72
+ return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
73
+ } catch (error) {
74
+ return { sources: withOrigin(repo, SOURCE_ORIGIN.repo), notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`] }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
80
+ * ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
81
+ */
82
+ export const configuredSources = (roots: string[], home: string = homedir()): MergedSources =>
83
+ sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, home)
@@ -1,9 +1,11 @@
1
1
  import { z } from 'zod'
2
2
  import { checkConfigSchema } from '../check/schema'
3
3
  import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
4
+ import { SECRET_NAME } from '../secrets/options'
4
5
  import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
5
6
  import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
7
  import { isSourcePath } from '../watch/source-path'
8
+ import { SOURCE_SHOW } from '../watch/sources'
7
9
  import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
8
10
  import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
9
11
  import { DEFAULT_BASE_BRANCH } from './base-branch'
@@ -289,6 +291,17 @@ const sourceSchema = z
289
291
  * `author`, `body` and `url`; without it a change of that content reports.
290
292
  */
291
293
  reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
294
+ /**
295
+ * Names of secrets the command needs, handed to it as environment variables of the same name; each is looked up
296
+ * in the keychain, then in the watcher's environment.
297
+ */
298
+ secrets: z
299
+ .array(z.string().regex(SECRET_NAME, 'capital letters, digits and `_`, starting with a letter'))
300
+ .max(10)
301
+ .refine((names) => new Set(names).size === names.length, 'secret names must be unique')
302
+ .optional(),
303
+ /** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
304
+ show: z.literal(SOURCE_SHOW.entries).optional(),
292
305
  })
293
306
  .refine(canChange, {
294
307
  message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
@@ -298,6 +311,10 @@ const sourceSchema = z
298
311
  message: 'ignoreAuthors needs map.author',
299
312
  path: ['ignoreAuthors'],
300
313
  })
314
+ .refine((source) => source.show === undefined || source.ticket !== true, {
315
+ message: 'show cannot be set on a ticket source',
316
+ path: ['show'],
317
+ })
301
318
  .refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
302
319
  message: 'reportOn cannot name id',
303
320
  path: ['reportOn'],
@@ -592,6 +609,18 @@ const parseLabelled = <T extends z.ZodType>(schema: T, raw: unknown, label: stri
592
609
  export const parseConfigFile = (raw: unknown, label: string): JackherdConfig =>
593
610
  parseLabelled(jackherdConfigSchema, raw, label)
594
611
 
612
+ /** The personal sources file (`~/.jackherd/sources.json`): the same entries as `watch.sources`. */
613
+ export const personalSourcesSchema = z.strictObject({
614
+ $schema: z.string().optional(),
615
+ sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
616
+ })
617
+
618
+ export type PersonalSourcesFile = z.infer<typeof personalSourcesSchema>
619
+
620
+ /** The personal sources file as read from JSON; invalid throws with `label` (its path) and the issues in words. */
621
+ export const parsePersonalSourcesFile = (raw: unknown, label: string): PersonalSourcesFile =>
622
+ parseLabelled(personalSourcesSchema, raw, label)
623
+
595
624
  /** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
596
625
  export const parseSourceDraft = (raw: unknown, label: string): z.infer<typeof sourceSchema> =>
597
626
  parseLabelled(sourceSchema, raw, label)
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { readFileSync, readlinkSync, unlinkSync } from 'node:fs'
2
+ import { unlinkSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
5
5
  import {
@@ -9,6 +9,7 @@ import {
9
9
  systemLocation,
10
10
  userDirectoryOf,
11
11
  } from '../shared/private-location'
12
+ import { systemProcessInfo } from '../shared/process-info'
12
13
  import { HERD_COMMAND } from './herd-command'
13
14
  import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
14
15
 
@@ -96,36 +97,13 @@ export const startExclusive = async (
96
97
  start()
97
98
  }
98
99
 
99
- const terminalOfStdin = (): string | null => {
100
- try {
101
- const target = readlinkSync('/proc/self/fd/0')
102
- return target.startsWith('/dev/') ? target : null
103
- } catch {
104
- return null
105
- }
106
- }
100
+ /** True when the words of a process hold the herd child's marker and the herd command. */
101
+ export const isHerdViewWords = (words: readonly string[] | null): boolean =>
102
+ Boolean(words?.includes(`${CHILD_MARKER}=1`) && words.includes(HERD_COMMAND.name))
107
103
 
108
- const readText = (path: string): string | null => {
109
- try {
110
- return readFileSync(path, 'utf8')
111
- } catch {
112
- return null
113
- }
114
- }
104
+ const processInfo = systemProcessInfo()
115
105
 
116
- const isHerdViewProcess = (pid: number): boolean => {
117
- const environment = readText(`/proc/${pid}/environ`)
118
- const command = readText(`/proc/${pid}/cmdline`)
119
- if (environment === null || command === null) return false
120
- return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
121
- }
122
-
123
- const isGoneProcess = (pid: number): boolean => {
124
- const stat = readText(`/proc/${pid}/stat`)
125
- if (stat === null) return true
126
- const stateIndex = stat.lastIndexOf(')') + 2
127
- return stat.charAt(stateIndex) === 'Z'
128
- }
106
+ const isHerdViewProcess = (pid: number): boolean => isHerdViewWords(processInfo.wordsOf(pid))
129
107
 
130
108
  const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
131
109
  try {
@@ -158,7 +136,7 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
158
136
  }
159
137
  },
160
138
  isHerdView: isHerdViewProcess,
161
- isGone: isGoneProcess,
139
+ isGone: processInfo.isGone,
162
140
  signal: signalProcess,
163
141
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
164
142
  onExit: (run) => {
@@ -168,6 +146,6 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
168
146
 
169
147
  /** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
170
148
  export const claimOwnTerminal = async (): Promise<void> => {
171
- const terminal = terminalOfStdin()
149
+ const terminal = processInfo.terminalOfStdin()
172
150
  if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
173
151
  }
@@ -5,6 +5,8 @@ import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
6
  import type { ToneName } from '../config/tones'
7
7
  import type { TicketSource } from '../watch/plan-badges'
8
+ import type { SourceShow } from '../watch/sources'
9
+ import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
8
10
 
9
11
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
10
12
 
@@ -20,14 +22,29 @@ export type PlansSettings = Pick<
20
22
  context: JackherdConfig['plans']['context']
21
23
  /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
22
24
  ticketSources: TicketSource[]
23
- /** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
24
- herdSources: { name: string; tone: ToneName | null }[]
25
+ /**
26
+ * The other `watch.sources` by name, with the tone each names for a new report and, when set, how it shows its
27
+ * entries; the herd lists them in a block of their own.
28
+ */
29
+ herdSources: { name: string; tone: ToneName | null; show?: SourceShow }[]
25
30
  /** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
26
31
  sourceLabels: Record<string, string>
27
32
  /** The tone of each role in the herd. */
28
33
  tones: JackherdConfig['plans']['tones']
29
34
  }
30
35
 
36
+ /** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
37
+ export type HerdSourceSettings = Pick<PlansSettings, 'ticketSources' | 'herdSources' | 'sourceLabels'>
38
+
39
+ /** The herd's view of `sources`, in their order: the tickets, the others with their tone and `show`, every label. */
40
+ export const herdSourceSettingsOf = (sources: readonly WatchedSource[]): HerdSourceSettings => ({
41
+ ticketSources: sources.flatMap(({ name, lane, ticket }) => (ticket ? [{ name, lane }] : [])),
42
+ herdSources: sources.flatMap(({ name, ticket, tone, show }) =>
43
+ ticket ? [] : [{ name, tone, ...(show === null ? {} : { show }) }],
44
+ ),
45
+ sourceLabels: Object.fromEntries(sources.map(({ name, label }) => [name, label ?? name])),
46
+ })
47
+
31
48
  /** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
32
49
  export const CLOCK_LOCALE = 'en-GB'
33
50
 
@@ -45,11 +62,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
45
62
  shimmer: config.plans.shimmer,
46
63
  linkKinds: config.plans.linkKinds,
47
64
  context: config.plans.context,
48
- ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
49
- ticket ? [{ name, lane: lane ?? null }] : [],
50
- ),
51
- herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
52
- sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
65
+ ...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
53
66
  tones: config.plans.tones,
54
67
  })
55
68
 
@@ -23,7 +23,7 @@ import { isRestingAgent } from '../terminal/types'
23
23
  import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
24
24
  import { restNote } from '../watch/lane-rest'
25
25
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
26
- import { readWatchReading } from '../watch/plan-badges-read'
26
+ import { readWatchReading, type SourcedWatchReading } from '../watch/plan-badges-read'
27
27
  import type { SourceStatus } from '../watch/source-statuses'
28
28
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
29
29
  import { activityLineOf, type LaneSubagents } from './activity-read'
@@ -65,7 +65,7 @@ import { markStale, type PlannedPlan, plannedPlans } from './planned'
65
65
  import type { PullRequest } from './pull-requests'
66
66
  import type { ReadCache } from './read-cache'
67
67
  import { type SentChip, sentChipsByLane } from './sent-files'
68
- import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
68
+ import { baseRefOf, boardRepoPathOf, type HerdSourceSettings, type PlansSettings, worktreeOf } from './settings'
69
69
  import { signalledTitle } from './signalled-phase'
70
70
  import { readAllSignalsSliced } from './signals-read'
71
71
  import { createSliceGate, type SliceGate } from './slice-gate'
@@ -194,7 +194,7 @@ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSou
194
194
 
195
195
  /** What a lane's view is decorated with after it was read. */
196
196
  type Decorations = {
197
- reading: ReturnType<typeof readWatchReading> | null
197
+ reading: SourcedWatchReading | null
198
198
  phaseModels: readonly PhaseModelRecord[]
199
199
  sprintDirectory: string
200
200
  sentChips: ReadonlyMap<string | null, SentChip[]>
@@ -638,7 +638,7 @@ const decoratedViews = (
638
638
  /** Each source with the tone and the label its config gives it. */
639
639
  const withSourceStyle = (
640
640
  statuses: readonly SourceStatus[],
641
- { herdSources, sourceLabels }: Pick<PlansSettings, 'herdSources' | 'sourceLabels'>,
641
+ { herdSources, sourceLabels }: HerdSourceSettings,
642
642
  ): HerdSource[] =>
643
643
  statuses.map((status) => {
644
644
  const label = sourceLabels[status.name]
@@ -646,6 +646,24 @@ const withSourceStyle = (
646
646
  return { ...status, tone, ...(label === undefined ? {} : { label }) }
647
647
  })
648
648
 
649
+ /**
650
+ * The watcher's reading and the sources the view draws: those the watcher's state names, else the config's. The
651
+ * state is read every time, so a source the watcher took up since the herd started shows; the reading is null while
652
+ * the view follows no source and no pull request.
653
+ */
654
+ const watchReadingOf = (
655
+ root: string,
656
+ now: number,
657
+ fallback: HerdSourceSettings,
658
+ results: readonly LaneResult[],
659
+ ): { reading: SourcedWatchReading | null; sourceSettings: HerdSourceSettings } => {
660
+ const read = readWatchReading(root, now, fallback)
661
+ const { ticketSources, herdSources } = read.sourceSettings
662
+ const watched =
663
+ ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
664
+ return { reading: watched ? read : null, sourceSettings: read.sourceSettings }
665
+ }
666
+
649
667
  /**
650
668
  * The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
651
669
  * view shows one lane.
@@ -712,25 +730,14 @@ export const loadSnapshot = async (
712
730
  const { parsed } = laneSet.sources
713
731
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
714
732
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
715
- const { ticketSources, herdSources } = settings
716
- const watched =
717
- ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
718
733
  await gate.pass()
719
- const reading = watched
720
- ? readWatchReading(
721
- root,
722
- now,
723
- ticketSources,
724
- undefined,
725
- undefined,
726
- herdSources.map(({ name }) => name),
727
- )
728
- : null
734
+ const { reading, sourceSettings } = watchReadingOf(root, now, settings, results)
735
+ const { ticketSources } = sourceSettings
729
736
  await gate.pass()
730
737
  const waitingRecords = readWaitingRecords(root)
731
738
  const waiting = withSourcePrefix(
732
739
  waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
733
- settings.sourceLabels,
740
+ sourceSettings.sourceLabels,
734
741
  )
735
742
  const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
736
743
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
@@ -782,7 +789,7 @@ export const loadSnapshot = async (
782
789
  waiting,
783
790
  groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
784
791
  yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
785
- ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
792
+ ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, sourceSettings), now } } : {}),
786
793
  tones: settings.tones,
787
794
  ...(coordinator ? { coordinator: coordinator.view } : {}),
788
795
  unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),
@@ -0,0 +1,163 @@
1
+ import { configuredSources } from '../config/personal-sources'
2
+ import { errorText } from '../plans/error-text'
3
+ import { mainCheckout, worktreeRoot } from '../plans/git'
4
+ import { CLI_NAME } from '../version/release'
5
+ import type { SourceSettings } from '../watch/sources'
6
+ import type { Environment } from './environment'
7
+ import { hiddenInputStep } from './hidden-input'
8
+ import { environmentOnlyStatusOf, requiredSecrets, secretListing, secretStatusOf } from './listing'
9
+ import { parseSecretArgs, SECRET_ACTION, SECRET_USAGE } from './options'
10
+ import { SECRET_LOOKUP, SECRET_SERVICE, SECRET_WRITE, type SecretStore, secretStore } from './store'
11
+
12
+ /** What `jh secret` reaches outside itself, so a test runs it against stubs and never against the real keychain. */
13
+ export type SecretPorts = {
14
+ store: SecretStore
15
+ /** Whether stdin is a terminal: `set` reads from one only. */
16
+ isTerminal: () => boolean
17
+ /** The value typed after `prompt`, hidden; null when cancelled. */
18
+ readHidden: (prompt: string) => Promise<string | null>
19
+ /** The sources of the repository and the personal file, with the notes of their merge. */
20
+ sources: () => { sources: SourceSettings[]; notes: string[] }
21
+ environment: Environment
22
+ out: (line: string) => void
23
+ err: (line: string) => void
24
+ }
25
+
26
+ /** Exit code of `set` without a terminal, apart from 1 for a wrong command line or a failed write. */
27
+ export const NO_TERMINAL_EXIT = 2
28
+
29
+ const ENDING_SIGNALS = ['SIGTERM', 'SIGHUP'] as const
30
+
31
+ /** Exit code after a signal ended the hidden input: 128 + SIGINT, as a cancel. */
32
+ const SIGNAL_EXIT = 130
33
+
34
+ const NO_TERMINAL = `${CLI_NAME} secret set reads the value from a terminal only, never from a pipe: run it in a terminal of your own`
35
+
36
+ /**
37
+ * Reads one hidden value from the terminal: raw mode, nothing echoed, each chunk applied by `hiddenInputStep`. The
38
+ * terminal is given back on every end, the process's `exit` included.
39
+ */
40
+ const readHiddenFromTerminal = (prompt: string): Promise<string | null> =>
41
+ new Promise((resolve) => {
42
+ const stdin = process.stdin
43
+ let typed = ''
44
+ const restore = () => {
45
+ stdin.off('data', onData)
46
+ stdin.off('error', onError)
47
+ process.off('exit', restore)
48
+ for (const signal of ENDING_SIGNALS) process.off(signal, onSignal)
49
+ if (stdin.isTTY) stdin.setRawMode(false)
50
+ stdin.pause()
51
+ }
52
+ // A closed pane or a kill ends the process without `exit`: give the terminal back first.
53
+ const onSignal = () => {
54
+ restore()
55
+ process.exit(SIGNAL_EXIT)
56
+ }
57
+ // A terminal that goes away (EIO) ends the input as cancelled instead of leaving it open.
58
+ const onError = () => {
59
+ restore()
60
+ resolve(null)
61
+ }
62
+ const onData = (chunk: Buffer | string) => {
63
+ const step = hiddenInputStep(typed, chunk.toString())
64
+ if (step.kind === 'typing') {
65
+ typed = step.value
66
+ return
67
+ }
68
+ restore()
69
+ process.stderr.write('\n')
70
+ resolve(step.kind === 'done' ? step.value : null)
71
+ }
72
+ process.stderr.write(prompt)
73
+ process.on('exit', restore)
74
+ for (const signal of ENDING_SIGNALS) process.once(signal, onSignal)
75
+ stdin.setRawMode(true)
76
+ stdin.resume()
77
+ stdin.on('data', onData)
78
+ stdin.on('error', onError)
79
+ })
80
+
81
+ const terminalPorts = (): SecretPorts => ({
82
+ store: secretStore(),
83
+ isTerminal: () => process.stdin.isTTY === true,
84
+ readHidden: readHiddenFromTerminal,
85
+ sources: () => configuredSources([worktreeRoot(process.cwd()), mainCheckout(process.cwd())]),
86
+ environment: process.env,
87
+ out: (line) => console.log(line),
88
+ err: (line) => console.error(line),
89
+ })
90
+
91
+ const setSecret = async (name: string, ports: SecretPorts): Promise<number> => {
92
+ if (!ports.isTerminal()) {
93
+ ports.err(NO_TERMINAL)
94
+ return NO_TERMINAL_EXIT
95
+ }
96
+ const value = await ports.readHidden(`${name} (hidden, Enter stores, Ctrl-C cancels): `)
97
+ if (value === null || value === '') {
98
+ ports.err(value === null ? 'cancelled, nothing stored' : 'empty value, nothing stored')
99
+ return 1
100
+ }
101
+ if ((await ports.store.set(name, value)) === SECRET_WRITE.unavailable) {
102
+ ports.err('secret store unavailable, nothing stored')
103
+ return 1
104
+ }
105
+ ports.out(`stored ${name} in the keychain (service ${SECRET_SERVICE})`)
106
+ return 0
107
+ }
108
+
109
+ const listSecrets = async (ports: SecretPorts): Promise<number> => {
110
+ const { sources, notes } = ports.sources()
111
+ for (const note of notes) ports.err(note)
112
+ const required = requiredSecrets(sources)
113
+ const lookups = await Promise.all(required.map(({ name, keychain }) => (keychain ? ports.store.get(name) : null)))
114
+ const rows = required.map((secret, index) => {
115
+ const lookup = lookups[index] ?? { kind: SECRET_LOOKUP.absent }
116
+ const status = secret.keychain
117
+ ? secretStatusOf(secret.name, lookup, ports.environment)
118
+ : environmentOnlyStatusOf(secret.name, ports.environment)
119
+ return { ...secret, status }
120
+ })
121
+ for (const line of secretListing(rows)) ports.out(line)
122
+ return 0
123
+ }
124
+
125
+ const deleteSecret = async (name: string, ports: SecretPorts): Promise<number> => {
126
+ const deleted = await ports.store.delete(name)
127
+ if (deleted === SECRET_WRITE.unavailable) {
128
+ ports.err('secret store unavailable, nothing deleted')
129
+ return 1
130
+ }
131
+ ports.out(deleted === SECRET_WRITE.done ? `deleted ${name}` : `${name} was not set`)
132
+ return 0
133
+ }
134
+
135
+ /**
136
+ * `jh secret set <NAME> | list | delete <NAME>`: the keychain entries (service `jackherd`) the sources' `secrets`
137
+ * name. `set` asks for the value hidden in a terminal and exits 2 without one, never reading a pipe; `list` prints
138
+ * the names the sources need with their status and never a value. Exit 0, 1 on a wrong command line or a keychain
139
+ * without an answer.
140
+ */
141
+ export const run = async (args: string[], ports: SecretPorts = terminalPorts()): Promise<number> => {
142
+ const request = parseSecretArgs(args)
143
+ if (request.kind === 'help') {
144
+ if (args.length === 0) {
145
+ ports.err(SECRET_USAGE)
146
+ return 1
147
+ }
148
+ ports.out(SECRET_USAGE)
149
+ return 0
150
+ }
151
+ if (request.kind === 'error') {
152
+ ports.err(`${request.message}\n${SECRET_USAGE}`)
153
+ return 1
154
+ }
155
+ try {
156
+ if (request.kind === SECRET_ACTION.set) return await setSecret(request.name, ports)
157
+ if (request.kind === SECRET_ACTION.delete) return await deleteSecret(request.name, ports)
158
+ return await listSecrets(ports)
159
+ } catch (error) {
160
+ ports.err(errorText(error))
161
+ return 1
162
+ }
163
+ }
@@ -0,0 +1,45 @@
1
+ import { SECRET_LOOKUP, type SecretLookup, type SecretLookupFn } from './store'
2
+
3
+ /** Environment variables as a process sees them; `process.env` fits. */
4
+ export type Environment = Record<string, string | undefined>
5
+
6
+ /** The environment a source's command starts with, or the class of reason it cannot start: never a value. */
7
+ export type SourceEnvironment = { ok: true; environment: Environment } | { ok: false; reason: string }
8
+
9
+ /** Why a source's secrets could not be resolved; `secret missing: <NAME>` comes from `secretMissing`. */
10
+ export const SECRET_FAILURE = { unavailable: 'secret store unavailable' } as const
11
+
12
+ export const secretMissing = (name: string): string => `secret missing: ${name}`
13
+
14
+ type Resolved = { value: string } | { reason: string }
15
+
16
+ /** Keychain first, then the environment; without either the reason names the keychain when it gave no answer. */
17
+ const resolved = (name: string, lookup: SecretLookup, environment: Environment): Resolved => {
18
+ if (lookup.kind === SECRET_LOOKUP.found) return { value: lookup.value }
19
+ const fromEnvironment = environment[name]
20
+ if (fromEnvironment) return { value: fromEnvironment }
21
+ return { reason: lookup.kind === SECRET_LOOKUP.unavailable ? SECRET_FAILURE.unavailable : secretMissing(name) }
22
+ }
23
+
24
+ /**
25
+ * The environment of a source's command: `environment` with each of `names` set, looked up in the keychain first,
26
+ * then taken from `environment` itself. A name in neither fails the run with `secret missing: <NAME>`, or with
27
+ * `secret store unavailable` when the keychain gave no answer; the first such name in `names` decides. The lookups
28
+ * run side by side, so a keychain that hangs costs one lookup's time limit, not one per name. `environment` is not
29
+ * changed, and the answer is kept by no one: the caller hands it to the command and drops it.
30
+ */
31
+ export const sourceEnvironment = async (
32
+ names: readonly string[],
33
+ lookup: SecretLookupFn,
34
+ environment: Environment,
35
+ ): Promise<SourceEnvironment> => {
36
+ if (names.length === 0) return { ok: true, environment: { ...environment } }
37
+ const lookups = await Promise.all(names.map((name) => lookup(name)))
38
+ const values: Record<string, string> = {}
39
+ for (const [index, name] of names.entries()) {
40
+ const entry = resolved(name, lookups[index] ?? { kind: SECRET_LOOKUP.absent }, environment)
41
+ if ('reason' in entry) return { ok: false, reason: entry.reason }
42
+ values[name] = entry.value
43
+ }
44
+ return { ok: true, environment: { ...environment, ...values } }
45
+ }
@@ -0,0 +1,51 @@
1
+ /** Where typing a hidden value stands: still typing, ended with Enter, or cancelled (nothing is written then). */
2
+ export type HiddenInput = { kind: 'typing'; value: string } | { kind: 'done'; value: string } | { kind: 'cancelled' }
3
+
4
+ const ENTER = new Set(['\r', '\n'])
5
+ const CANCEL = new Set(['\x03', '\x04'])
6
+ const BACKSPACE = new Set(['\x7f', '\b'])
7
+ const CLEAR_LINE = '\x15'
8
+ const ESCAPE = '\x1b'
9
+ const CSI_START = '['
10
+ const SS3_START = 'O'
11
+
12
+ const isControl = (character: string): boolean => (character.codePointAt(0) ?? 0) < 0x20
13
+
14
+ /** The index after an escape sequence starting at `start` (the escape character): a CSI up to its final byte, SS3 and others one character. */
15
+ const afterEscape = (characters: readonly string[], start: number): number => {
16
+ const introducer = characters[start + 1]
17
+ if (introducer === SS3_START) return start + 3
18
+ if (introducer !== CSI_START) return start + 2
19
+ let index = start + 2
20
+ while (index < characters.length) {
21
+ const code = characters[index]?.codePointAt(0) ?? 0
22
+ index += 1
23
+ if (code >= 0x40 && code <= 0x7e) return index
24
+ }
25
+ return index
26
+ }
27
+
28
+ /**
29
+ * One chunk of raw terminal input applied to the hidden value typed so far. Enter ends it, Ctrl-C or Ctrl-D cancel
30
+ * it, Backspace removes the last character and Ctrl-U the whole line; arrow keys and other escape sequences and
31
+ * further control characters are dropped. Whatever follows Enter in the same chunk is ignored.
32
+ */
33
+ export const hiddenInputStep = (typed: string, chunk: string): HiddenInput => {
34
+ const characters = Array.from(chunk)
35
+ let value = Array.from(typed)
36
+ let index = 0
37
+ while (index < characters.length) {
38
+ const character = characters[index] ?? ''
39
+ if (ENTER.has(character)) return { kind: 'done', value: value.join('') }
40
+ if (CANCEL.has(character)) return { kind: 'cancelled' }
41
+ if (character === ESCAPE) {
42
+ index = afterEscape(characters, index)
43
+ continue
44
+ }
45
+ if (BACKSPACE.has(character)) value = value.slice(0, -1)
46
+ else if (character === CLEAR_LINE) value = []
47
+ else if (!isControl(character)) value.push(character)
48
+ index += 1
49
+ }
50
+ return { kind: 'typing', value: value.join('') }
51
+ }