jackherd 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +22 -19
  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 +17 -2
  10. package/kit/workflow/skills/follow/SKILL.md +18 -10
  11. package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
  12. package/package.json +2 -1
  13. package/recipes/README.md +3 -2
  14. package/recipes/google-calendar.md +30 -0
  15. package/recipes/sources/google-calendar-today.ts +32 -0
  16. package/recipes/sources/google-calendar.ts +368 -0
  17. package/recipes/sources.md +15 -3
  18. package/schemas/jackherd.schema.json +12 -0
  19. package/schemas/sources.schema.json +141 -0
  20. package/src/check/skill-budget-defaults.ts +11 -4
  21. package/src/cli.ts +1 -0
  22. package/src/config/json-schema.ts +7 -3
  23. package/src/config/location.ts +7 -0
  24. package/src/config/personal-sources.ts +83 -0
  25. package/src/config/schema.ts +29 -0
  26. package/src/plans/herd-claim.ts +9 -31
  27. package/src/plans/layout.ts +135 -18
  28. package/src/plans/live.ts +54 -7
  29. package/src/plans/row-actions.ts +17 -10
  30. package/src/plans/settings.ts +20 -7
  31. package/src/plans/snapshot.ts +34 -22
  32. package/src/plans/source-rows.ts +35 -2
  33. package/src/secrets/command.ts +163 -0
  34. package/src/secrets/environment.ts +45 -0
  35. package/src/secrets/hidden-input.ts +51 -0
  36. package/src/secrets/listing.ts +63 -0
  37. package/src/secrets/options.ts +45 -0
  38. package/src/secrets/store.ts +74 -0
  39. package/src/shared/process-info.ts +89 -0
  40. package/src/sync/command-context.ts +26 -4
  41. package/src/sync/render.ts +9 -4
  42. package/src/watch/command.ts +44 -12
  43. package/src/watch/lane-report.ts +2 -1
  44. package/src/watch/plan-badges-read.ts +28 -10
  45. package/src/watch/source-dry-run-command.ts +23 -12
  46. package/src/watch/source-dry-run.ts +20 -3
  47. package/src/watch/source-reload.ts +100 -0
  48. package/src/watch/source-run.ts +63 -7
  49. package/src/watch/source-tickets.ts +15 -0
  50. package/src/watch/sources.ts +21 -0
  51. package/src/watch/texts.ts +1 -1
  52. package/src/watch/watch-events.ts +9 -0
  53. package/src/watch/watched-source.ts +39 -0
  54. package/src/watch/watcher-lock.ts +8 -1
@@ -2,6 +2,7 @@ import { TONE, type ToneName } from '../config/tones'
2
2
  import { padVisible } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
4
  import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
5
+ import { SOURCE_SHOW, type SourceShow } from '../watch/sources'
5
6
  import { ASK_KIND } from './ask-target'
6
7
  import type { AskedCheck } from './source-asked'
7
8
  import { chevronOf } from './symbols'
@@ -9,9 +10,10 @@ import type { BlockItem } from './waiting-click'
9
10
 
10
11
  /**
11
12
  * A source's status with the tone its config names for the mark while its report is new (null where it names none) and
12
- * the label its config gives it, absent where it gives none.
13
+ * the label its config gives it, absent where it gives none, and `show` where it asks for its entries in a block of
14
+ * their own.
13
15
  */
14
- export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
16
+ export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string; show?: SourceShow }
15
17
 
16
18
  /** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
17
19
  export type SourceSummary = { text: string; tone?: ToneName }
@@ -179,6 +181,37 @@ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone:
179
181
  ]
180
182
  }
181
183
 
184
+ /** Whether the source asks for its entries in a block of their own. */
185
+ export const showsEntries = (source: HerdSource): boolean => source.show === SOURCE_SHOW.entries
186
+
187
+ /** The sources that have a block of their own: those that ask for it and have entries; an empty run draws none. */
188
+ export const entryBlockSources = (sources: readonly HerdSource[]): HerdSource[] =>
189
+ sources.filter((source) => showsEntries(source) && source.entries.length > 0)
190
+
191
+ /** The source as the `Sources` block draws it: a source with a block of its own keeps its status, not its entries. */
192
+ export const statusOnly = (source: HerdSource): HerdSource =>
193
+ showsEntries(source) ? { ...source, entries: [], latest: null } : source
194
+
195
+ /**
196
+ * The rows of the block of a source that shows its entries: count, time and title as in the opened source, in the
197
+ * order the state holds them, in the tone of the source, none of them asking or expanding; a row with an address
198
+ * is linked. Only the first `limit` entries become rows, the columns measured over those.
199
+ */
200
+ export const entryBlockItems = (source: HerdSource, now: number, limit: number): BlockItem[] => {
201
+ const shown = source.entries.slice(0, limit)
202
+ const columns = columnsOf(shown, now)
203
+ return shown.map((entry) => ({
204
+ key: entryKey(source.name, entry),
205
+ mark: ENTRY_MARK,
206
+ lead: entryLeadOf(entry, now, columns),
207
+ text: entry.title,
208
+ url: entry.url,
209
+ ask: null,
210
+ expands: false,
211
+ ...(source.tone && { tone: source.tone }),
212
+ }))
213
+ }
214
+
182
215
  const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
183
216
 
184
217
  /**
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { SOURCE_ORIGIN, type SourceSettings } from '../watch/sources'
3
+ import type { Environment } from './environment'
4
+ import { SECRET_LOOKUP, type SecretLookup } from './store'
5
+
6
+ /**
7
+ * A secret name and the sources that need it, in their order; `keychain` when a personal source needs it, the only
8
+ * kind the watcher looks up in the keychain for.
9
+ */
10
+ export type RequiredSecret = { name: string; sources: string[]; keychain: boolean }
11
+
12
+ /** Where a needed secret stands, as `jh secret list` prints it: never its value. */
13
+ export const SECRET_STATUS = {
14
+ set: 'set',
15
+ environment: 'set (environment)',
16
+ missing: 'missing',
17
+ environmentOnly: 'missing (environment only)',
18
+ unavailable: 'secret store unavailable',
19
+ } as const
20
+
21
+ export type SecretStatus = (typeof SECRET_STATUS)[keyof typeof SECRET_STATUS]
22
+
23
+ export type SecretRow = RequiredSecret & { status: SecretStatus }
24
+
25
+ /** Every name the sources list under `secrets`, sorted, each with the sources that need it and whether a personal one does. */
26
+ export const requiredSecrets = (
27
+ sources: readonly Pick<SourceSettings, 'name' | 'secrets' | 'origin'>[],
28
+ ): RequiredSecret[] => {
29
+ const needs = new Map<string, RequiredSecret>()
30
+ for (const source of sources) {
31
+ for (const name of source.secrets ?? []) {
32
+ const known = needs.get(name) ?? { name, sources: [], keychain: false }
33
+ const personal = source.origin === SOURCE_ORIGIN.personal
34
+ needs.set(name, { name, sources: [...known.sources, source.name], keychain: known.keychain || personal })
35
+ }
36
+ }
37
+ return [...needs.values()].sort((left, right) => left.name.localeCompare(right.name))
38
+ }
39
+
40
+ /** The status of a name only repository sources need: the watcher hands them the environment, never the keychain. */
41
+ export const environmentOnlyStatusOf = (name: string, environment: Environment): SecretStatus =>
42
+ environment[name] ? SECRET_STATUS.environment : SECRET_STATUS.environmentOnly
43
+
44
+ /**
45
+ * The status the watcher would meet: in the keychain, else in the environment (this shell's, which a watcher started
46
+ * elsewhere may not share), else missing; a keychain without an answer and no variable is `unavailable`. Takes the
47
+ * lookup and keeps nothing of its value.
48
+ */
49
+ export const secretStatusOf = (name: string, lookup: SecretLookup, environment: Environment): SecretStatus => {
50
+ if (lookup.kind === SECRET_LOOKUP.found) return SECRET_STATUS.set
51
+ if (environment[name]) return SECRET_STATUS.environment
52
+ return lookup.kind === SECRET_LOOKUP.unavailable ? SECRET_STATUS.unavailable : SECRET_STATUS.missing
53
+ }
54
+
55
+ /** The lines of `jh secret list`: name, status and the sources that need it, in columns; a note when no source names one. */
56
+ export const secretListing = (rows: readonly Pick<SecretRow, 'name' | 'status' | 'sources'>[]): string[] => {
57
+ if (rows.length === 0) return ['no source names a secret']
58
+ const nameWidth = Math.max(...rows.map(({ name }) => name.length))
59
+ const statusWidth = Math.max(...rows.map(({ status }) => status.length))
60
+ return rows.map(({ name, status, sources }) =>
61
+ terminalSafe(`${name.padEnd(nameWidth)} ${status.padEnd(statusWidth)} ${sources.join(', ')}`),
62
+ )
63
+ }
@@ -0,0 +1,45 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /** A secret's name, which becomes the name of an environment variable; the same rule as `secrets` in a source. */
4
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/
5
+
6
+ export const SECRET_ACTION = { set: 'set', list: 'list', delete: 'delete' } as const
7
+
8
+ export const SECRET_USAGE = `Usage: ${CLI_NAME} secret set <NAME> store a value in the keychain, typed hidden in a terminal
9
+ ${CLI_NAME} secret list the names the sources need, set or missing, never a value
10
+ ${CLI_NAME} secret delete <NAME> remove the value from the keychain`
11
+
12
+ export type SecretRequest =
13
+ | { kind: typeof SECRET_ACTION.set; name: string }
14
+ | { kind: typeof SECRET_ACTION.list }
15
+ | { kind: typeof SECRET_ACTION.delete; name: string }
16
+ | { kind: 'help' }
17
+ | { kind: 'error'; message: string }
18
+
19
+ const HELP_FLAGS = new Set(['--help', '-h'])
20
+
21
+ const named = (
22
+ kind: typeof SECRET_ACTION.set | typeof SECRET_ACTION.delete,
23
+ rest: readonly string[],
24
+ ): SecretRequest => {
25
+ const [name, ...extra] = rest
26
+ if (!name || extra.length > 0) return { kind: 'error', message: `${CLI_NAME} secret ${kind} takes one name` }
27
+ if (!SECRET_NAME.test(name)) {
28
+ return { kind: 'error', message: 'a secret name is capital letters, digits and `_`, starting with a letter' }
29
+ }
30
+ return { kind, name }
31
+ }
32
+
33
+ /**
34
+ * The command line of `jh secret`. A value is never an argument: `set` takes the name only and asks for the value in
35
+ * the terminal, so it shows up in no shell history and no process list.
36
+ */
37
+ export const parseSecretArgs = (args: readonly string[]): SecretRequest => {
38
+ const [action, ...rest] = args
39
+ if (action === undefined || HELP_FLAGS.has(action)) return { kind: 'help' }
40
+ if (action === SECRET_ACTION.list) {
41
+ return rest.length === 0 ? { kind: SECRET_ACTION.list } : { kind: 'error', message: 'list takes no arguments' }
42
+ }
43
+ if (action === SECRET_ACTION.set || action === SECRET_ACTION.delete) return named(action, rest)
44
+ return { kind: 'error', message: `unknown action: ${action}` }
45
+ }
@@ -0,0 +1,74 @@
1
+ /** The keychain service every jackherd secret is stored under; the entry's name is the environment variable's name. */
2
+ export const SECRET_SERVICE = 'jackherd'
3
+
4
+ /** How long one keychain call may take before the store counts as unavailable (a locked keychain, a missing D-Bus). */
5
+ export const SECRET_TIMEOUT_MS = 10_000
6
+
7
+ /** What a keychain lookup found: the value, no entry, or no answer from the keychain service at all. */
8
+ export const SECRET_LOOKUP = { found: 'found', absent: 'absent', unavailable: 'unavailable' } as const
9
+
10
+ export type SecretLookup =
11
+ | { kind: typeof SECRET_LOOKUP.found; value: string }
12
+ | { kind: typeof SECRET_LOOKUP.absent }
13
+ | { kind: typeof SECRET_LOOKUP.unavailable }
14
+
15
+ /** What a write or a delete did: done, nothing there to delete, or no answer from the keychain service. */
16
+ export const SECRET_WRITE = { done: 'done', absent: 'absent', unavailable: 'unavailable' } as const
17
+
18
+ export type SecretWrite = (typeof SECRET_WRITE)[keyof typeof SECRET_WRITE]
19
+
20
+ /** Looks up one secret by its name. */
21
+ export type SecretLookupFn = (name: string) => Promise<SecretLookup>
22
+
23
+ export type SecretStore = {
24
+ get: SecretLookupFn
25
+ set: (name: string, value: string) => Promise<SecretWrite>
26
+ delete: (name: string) => Promise<SecretWrite>
27
+ }
28
+
29
+ /** The part of `Bun.secrets` the store uses, so a test hands in a stub and never reaches the real keychain. */
30
+ export type KeychainApi = Pick<typeof Bun.secrets, 'get' | 'set' | 'delete'>
31
+
32
+ const TIMED_OUT = Symbol('timed out')
33
+
34
+ const UNAVAILABLE: SecretLookup = { kind: SECRET_LOOKUP.unavailable }
35
+ const ABSENT: SecretLookup = { kind: SECRET_LOOKUP.absent }
36
+
37
+ /** `call`'s result, or `TIMED_OUT` once `timeoutMs` passed or it threw: the error text is dropped, it may name the entry's value. */
38
+ const bounded = async <T>(call: () => Promise<T>, timeoutMs: number): Promise<T | typeof TIMED_OUT> => {
39
+ let timer: ReturnType<typeof setTimeout> | undefined
40
+ const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
41
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs)
42
+ })
43
+ try {
44
+ return await Promise.race([call(), deadline])
45
+ } catch {
46
+ return TIMED_OUT
47
+ } finally {
48
+ clearTimeout(timer)
49
+ }
50
+ }
51
+
52
+ /** An empty value counts as no entry: `Bun.secrets` treats storing one as a delete. */
53
+ const lookupOf = (value: string | null | typeof TIMED_OUT): SecretLookup => {
54
+ if (value === TIMED_OUT) return UNAVAILABLE
55
+ return value ? { kind: SECRET_LOOKUP.found, value } : ABSENT
56
+ }
57
+
58
+ /**
59
+ * The system keychain behind `get`, `set` and `delete`, every entry under the service `jackherd`. Each call is bounded
60
+ * by `timeoutMs`; a keychain that throws or does not answer in time is `unavailable`, never a rejection, so a locked
61
+ * keychain cannot stop the watcher. Nothing here logs or prints.
62
+ */
63
+ export const secretStore = (api: KeychainApi = Bun.secrets, timeoutMs: number = SECRET_TIMEOUT_MS): SecretStore => ({
64
+ get: async (name) => lookupOf(await bounded(() => api.get({ service: SECRET_SERVICE, name }), timeoutMs)),
65
+ set: async (name, value) => {
66
+ const done = await bounded(() => api.set({ service: SECRET_SERVICE, name, value }), timeoutMs)
67
+ return done === TIMED_OUT ? SECRET_WRITE.unavailable : SECRET_WRITE.done
68
+ },
69
+ delete: async (name) => {
70
+ const deleted = await bounded(() => api.delete({ service: SECRET_SERVICE, name }), timeoutMs)
71
+ if (deleted === TIMED_OUT) return SECRET_WRITE.unavailable
72
+ return deleted ? SECRET_WRITE.done : SECRET_WRITE.absent
73
+ },
74
+ })
@@ -0,0 +1,89 @@
1
+ import { readFileSync, readlinkSync } from 'node:fs'
2
+
3
+ /** Longest a `ps` or `tty` call may take; a call past it counts as no answer. */
4
+ const SPAWN_TIMEOUT_MS = 2000
5
+
6
+ export type ProcessInfoSystem = {
7
+ platform: NodeJS.Platform
8
+ readText: (path: string) => string | null
9
+ readLink: (path: string) => string | null
10
+ /** Runs a program and answers its stdout, null when it failed, timed out or is missing. */
11
+ run: (command: string[], stdin: 'inherit' | 'ignore') => string | null
12
+ }
13
+
14
+ export type ProcessInfo = {
15
+ /** The terminal device of stdin (`/dev/…`); null without one. */
16
+ terminalOfStdin: () => string | null
17
+ /** Environment entries and arguments of a process, blank-separated words; null where they cannot be read. */
18
+ wordsOf: (pid: number) => string[] | null
19
+ /** True when the process does not exist or is a zombie. */
20
+ isGone: (pid: number) => boolean
21
+ }
22
+
23
+ const orNull = <T>(read: () => T): T | null => {
24
+ try {
25
+ return read()
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** `/dev/…` as `tty` or `/proc/self/fd/0` names it, null for anything else (`not a tty`). */
32
+ export const deviceOf = (text: string | null): string | null => {
33
+ const line = text?.trim() ?? ''
34
+ return line.startsWith('/dev/') ? line : null
35
+ }
36
+
37
+ /** The words of `ps -E -ww -o command=` (macOS appends the environment to the command line). */
38
+ export const wordsOfPsOutput = (output: string | null): string[] | null => {
39
+ const words = output?.trim().split(/\s+/).filter(Boolean) ?? []
40
+ return words.length === 0 ? null : words
41
+ }
42
+
43
+ /** The `\0`-separated entries of `/proc/<pid>/environ` and `/proc/<pid>/cmdline` as one list; null unless both read. */
44
+ export const wordsOfProcFiles = (environment: string | null, command: string | null): string[] | null =>
45
+ environment === null || command === null ? null : [...environment.split('\0'), ...command.split('\0')].filter(Boolean)
46
+
47
+ /** True for a state column of `ps -o stat=` or `/proc/<pid>/stat` that means the process is a zombie. */
48
+ const isZombieState = (state: string): boolean => state.startsWith('Z')
49
+
50
+ /** The state letter after the parenthesised name in a line of `/proc/<pid>/stat`. */
51
+ export const stateOfProcStat = (stat: string): string => stat.charAt(stat.lastIndexOf(')') + 2)
52
+
53
+ export const goneFromProcStat = (stat: string | null): boolean => stat === null || isZombieState(stateOfProcStat(stat))
54
+
55
+ /** Gone when `ps` found nothing (empty output) or the state is a zombie. */
56
+ export const goneFromPsState = (output: string | null): boolean => {
57
+ const state = output?.trim() ?? ''
58
+ return state === '' || isZombieState(state)
59
+ }
60
+
61
+ const linuxInfo = (system: ProcessInfoSystem): ProcessInfo => ({
62
+ terminalOfStdin: () => deviceOf(system.readLink('/proc/self/fd/0')),
63
+ wordsOf: (pid) => wordsOfProcFiles(system.readText(`/proc/${pid}/environ`), system.readText(`/proc/${pid}/cmdline`)),
64
+ isGone: (pid) => goneFromProcStat(system.readText(`/proc/${pid}/stat`)),
65
+ })
66
+
67
+ const psInfo = (system: ProcessInfoSystem): ProcessInfo => ({
68
+ terminalOfStdin: () => deviceOf(system.run(['tty'], 'inherit')),
69
+ wordsOf: (pid) => wordsOfPsOutput(system.run(['ps', '-E', '-ww', '-o', 'command=', '-p', String(pid)], 'ignore')),
70
+ isGone: (pid) => goneFromPsState(system.run(['ps', '-o', 'stat=', '-p', String(pid)], 'ignore')),
71
+ })
72
+
73
+ /** Process inspection through `/proc` on Linux and through `ps` and `tty` elsewhere (macOS has no `/proc`). */
74
+ export const processInfoFor = (system: ProcessInfoSystem): ProcessInfo =>
75
+ system.platform === 'linux' ? linuxInfo(system) : psInfo(system)
76
+
77
+ export const systemProcessInfoSystem = (): ProcessInfoSystem => ({
78
+ platform: process.platform,
79
+ readText: (path) => orNull(() => readFileSync(path, 'utf8')),
80
+ readLink: (path) => orNull(() => readlinkSync(path)),
81
+ run: (command, stdin) => {
82
+ const result = orNull(() =>
83
+ Bun.spawnSync(command, { stdin, stdout: 'pipe', stderr: 'ignore', timeout: SPAWN_TIMEOUT_MS }),
84
+ )
85
+ return result !== null && result.exitCode === 0 ? result.stdout.toString() : null
86
+ },
87
+ })
88
+
89
+ export const systemProcessInfo = (): ProcessInfo => processInfoFor(systemProcessInfoSystem())
@@ -40,16 +40,38 @@ const YES = /^y(es)?$/i
40
40
 
41
41
  const hasTerminal = (): boolean => Boolean(process.stdin.isTTY && process.stdout.isTTY)
42
42
 
43
- /** One question on the terminal, the trimmed answer. */
44
- const questionOnTerminal = async (text: string): Promise<string> => {
45
- const terminal = createInterface({ input: process.stdin, output: process.stdout })
43
+ const UNREADABLE_NOTE = 'jackherd: the terminal could not be read, using the default answer'
44
+
45
+ /**
46
+ * One question on a stream, the trimmed answer. A read error (macOS kqueue refusing the device with EINVAL, say)
47
+ * reaches readline as an `error` event while the pending question never settles; it is answered with the empty
48
+ * string, so the caller takes its default, and one note goes to `warn`.
49
+ */
50
+ export const questionOnStream = async (
51
+ text: string,
52
+ input: NodeJS.ReadableStream,
53
+ output: NodeJS.WritableStream,
54
+ warn: (line: string) => void,
55
+ ): Promise<string> => {
56
+ const terminal = createInterface({ input, output })
57
+ const unreadable = new Promise<null>((resolve) => terminal.once('error', () => resolve(null)))
58
+ const asked = terminal.question(text)
59
+ asked.catch(() => undefined)
46
60
  try {
47
- return (await terminal.question(text)).trim()
61
+ const answer = await Promise.race([asked, unreadable])
62
+ if (answer !== null) return answer.trim()
63
+ } catch {
64
+ // a rejected question is the same unreadable terminal
48
65
  } finally {
49
66
  terminal.close()
50
67
  }
68
+ warn(UNREADABLE_NOTE)
69
+ return ''
51
70
  }
52
71
 
72
+ const questionOnTerminal = (text: string): Promise<string> =>
73
+ questionOnStream(text, process.stdin, process.stdout, (line) => console.error(line))
74
+
53
75
  /** Asks on the terminal; without one on both stdin and stdout nobody would see or answer the question. */
54
76
  const askOnTerminal = async (question: string): Promise<boolean | null> =>
55
77
  hasTerminal() ? YES.test(await questionOnTerminal(`${question} [y/N] `)) : null
@@ -11,14 +11,19 @@ import {
11
11
 
12
12
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
13
13
 
14
- const WORKTREE_INCLUDE_COPY =
15
- 'cd <main-checkout> && git ls-files -z --others --ignored --exclude-from=.worktreeinclude | grep -zv node_modules | xargs -0 -I{} cp -p --parents {} <worktree>/'
14
+ /** Reads the tar archive from stdin into the worktree, keeping file modes; the same flags work on GNU tar and bsdtar. */
15
+ const UNPACK = 'tar -xpf - -C <worktree>'
16
16
 
17
- /** The command that copies a lane's untracked files; the schema keeps quotes out of every path. */
17
+ const WORKTREE_INCLUDE_COPY = `cd <main-checkout> && git ls-files -z --others --ignored --exclude-from=.worktreeinclude -- ':(exclude)*node_modules*' | tar -cf - --null -T - | ${UNPACK}`
18
+
19
+ /**
20
+ * The command that copies a lane's untracked files, relative paths and modes kept; it uses only what GNU and BSD
21
+ * (macOS) userlands share, no `cp --parents` and no `grep -z`. The schema keeps quotes out of every path.
22
+ */
18
23
  const laneCopyCommand = (copy: readonly string[]): string =>
19
24
  copy.length === 0
20
25
  ? WORKTREE_INCLUDE_COPY
21
- : `cd <main-checkout> && cp -p --parents -- ${copy.map((path) => `'${path}'`).join(' ')} <worktree>/`
26
+ : `cd <main-checkout> && tar -cf - -- ${copy.map((path) => `'${path}'`).join(' ')} | ${UNPACK}`
22
27
 
23
28
  /** The patterns a pull request may match to be merged without checks, as prose for a skill; `none` for an empty list. */
24
29
  const checkFreeList = (patterns: readonly string[]): string =>