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.
- package/CHANGELOG.md +22 -0
- package/README.md +22 -19
- package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
- package/dist/config.d.ts +47 -2
- package/dist/config.js +65 -4
- package/dist/plan-format.js +3 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +49 -4
- package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
- package/kit/workflow/skills/follow/SKILL.md +18 -10
- package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
- package/package.json +2 -1
- package/recipes/README.md +3 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/sources/google-calendar-today.ts +32 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources.md +15 -3
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- package/src/check/skill-budget-defaults.ts +11 -4
- package/src/cli.ts +1 -0
- package/src/config/json-schema.ts +7 -3
- package/src/config/location.ts +7 -0
- package/src/config/personal-sources.ts +83 -0
- package/src/config/schema.ts +29 -0
- package/src/plans/herd-claim.ts +9 -31
- package/src/plans/layout.ts +135 -18
- package/src/plans/live.ts +54 -7
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/settings.ts +20 -7
- package/src/plans/snapshot.ts +34 -22
- package/src/plans/source-rows.ts +35 -2
- package/src/secrets/command.ts +163 -0
- package/src/secrets/environment.ts +45 -0
- package/src/secrets/hidden-input.ts +51 -0
- package/src/secrets/listing.ts +63 -0
- package/src/secrets/options.ts +45 -0
- package/src/secrets/store.ts +74 -0
- package/src/shared/process-info.ts +89 -0
- package/src/sync/command-context.ts +26 -4
- package/src/sync/render.ts +9 -4
- package/src/watch/command.ts +44 -12
- package/src/watch/lane-report.ts +2 -1
- package/src/watch/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +20 -3
- package/src/watch/source-reload.ts +100 -0
- package/src/watch/source-run.ts +63 -7
- package/src/watch/source-tickets.ts +15 -0
- package/src/watch/sources.ts +21 -0
- package/src/watch/texts.ts +1 -1
- package/src/watch/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
package/src/plans/source-rows.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
package/src/sync/render.ts
CHANGED
|
@@ -11,14 +11,19 @@ import {
|
|
|
11
11
|
|
|
12
12
|
const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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> &&
|
|
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 =>
|