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.
- package/CHANGELOG.md +10 -0
- package/README.md +17 -17
- 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/package.json +1 -1
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- 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/settings.ts +20 -7
- package/src/plans/snapshot.ts +26 -19
- 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/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +19 -2
- 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/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
|
@@ -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 =>
|
package/src/watch/command.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import { basename, dirname, join, relative } from 'node:path'
|
|
34
34
|
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
35
|
+
import { sourcesWithPersonal } from '../config/personal-sources'
|
|
35
36
|
import { readConfig, readResolvedConfig } from '../config/read-config'
|
|
36
37
|
import type { JackherdConfig } from '../config/schema'
|
|
37
38
|
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
@@ -182,9 +183,16 @@ import { withSingleReportNotice } from './single-report'
|
|
|
182
183
|
import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
|
|
183
184
|
import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
|
|
184
185
|
import { sourceDryRun } from './source-dry-run-command'
|
|
186
|
+
import {
|
|
187
|
+
type LoadedSources,
|
|
188
|
+
nextLoadedSources,
|
|
189
|
+
readFileStamps,
|
|
190
|
+
readWatchedSources,
|
|
191
|
+
sourceFilePaths,
|
|
192
|
+
} from './source-reload'
|
|
185
193
|
import { runSource, SOURCE_LIMITS } from './source-run'
|
|
186
|
-
import {
|
|
187
|
-
import { isSourceDue, type SourceRun, type
|
|
194
|
+
import { recordedSourceState } from './source-tickets'
|
|
195
|
+
import { isSourceDue, type SourceRun, type SourceState, sourceEvents } from './sources'
|
|
188
196
|
import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
|
|
189
197
|
import {
|
|
190
198
|
formatTaskParallelLine,
|
|
@@ -257,7 +265,10 @@ type Watch = {
|
|
|
257
265
|
/** The switch `watch.parallelTasks.auto` and the paths that always ask, both from the main checkout's config. */
|
|
258
266
|
parallelAuto: AutoSettings
|
|
259
267
|
previews: PreviewsConfig
|
|
260
|
-
sources
|
|
268
|
+
/** The sources as read at the start; the loop takes up later changes of their files (`Watching.sources`). */
|
|
269
|
+
sources: LoadedSources
|
|
270
|
+
/** The checkouts whose config the sources are read from, as `readResolvedConfig` takes them. */
|
|
271
|
+
configRoots: string[]
|
|
261
272
|
texts: WatchTexts
|
|
262
273
|
/** Where the orchestrate skill is installed, the project's copy first; the events name a reference inside the one found. */
|
|
263
274
|
skillCopies: SkillCopy[]
|
|
@@ -305,6 +316,8 @@ type Watching = {
|
|
|
305
316
|
failureReadings: FailureReading[]
|
|
306
317
|
/** The last preview read that answered; recorded with every state like the feedback read. */
|
|
307
318
|
previewRead: PreviewRead | null
|
|
319
|
+
/** The sources run now, read again whenever one of their files changes (`nextLoadedSources`). */
|
|
320
|
+
sources: LoadedSources
|
|
308
321
|
/** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
|
|
309
322
|
sourceRuns: SourceRun[]
|
|
310
323
|
/** The lanes' fills of the last read; a lane without an entry or with null has no value. */
|
|
@@ -381,7 +394,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
381
394
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
382
395
|
planMissing: watching.planMissing,
|
|
383
396
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
384
|
-
|
|
397
|
+
...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
|
|
385
398
|
appointments: watching.appointments
|
|
386
399
|
? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
|
|
387
400
|
: withReads.appointments,
|
|
@@ -887,15 +900,26 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
|
|
|
887
900
|
* source stops the watcher. The run's start is recorded with the GitHub read's time, which is `now`.
|
|
888
901
|
*/
|
|
889
902
|
const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
|
|
890
|
-
const due =
|
|
903
|
+
const due = watching.sources.sources.filter((source) =>
|
|
891
904
|
isSourceDue(watching.state.sources[source.name]?.ranAt, source.every, now),
|
|
892
905
|
)
|
|
893
906
|
return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
|
|
894
907
|
}
|
|
895
908
|
|
|
896
|
-
|
|
909
|
+
/**
|
|
910
|
+
* Takes up a change of the files the sources come from before the due ones run (`nextLoadedSources`): a source read
|
|
911
|
+
* anew has no `ranAt` and runs at once, its first run a baseline; an invalid file keeps the list and is said once.
|
|
912
|
+
*/
|
|
913
|
+
const reloadSources = (watch: Watch, watching: Watching) => {
|
|
914
|
+
const stamps = readFileStamps(Object.keys(watching.sources.stamps))
|
|
915
|
+
const next = nextLoadedSources(watching.sources, stamps, () => readWatchedSources(watch.configRoots, homedir()))
|
|
916
|
+
watching.sources = next.loaded
|
|
917
|
+
for (const line of next.lines) console.log(line)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const recordSourceRuns = (watching: Watching, runs: SourceRun[]): WatchEvent[] => {
|
|
897
921
|
watching.sourceRuns = runs
|
|
898
|
-
return sourceEvents(watching.state.sources, runs,
|
|
922
|
+
return sourceEvents(watching.state.sources, runs, watching.sources.sources)
|
|
899
923
|
}
|
|
900
924
|
|
|
901
925
|
/** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
|
|
@@ -917,7 +941,8 @@ const syncPlanHeads = (watch: Watch, watching: Watching, pullRequests: readonly
|
|
|
917
941
|
|
|
918
942
|
/**
|
|
919
943
|
* Reads the coordinator's fill, GitHub, the feedback, the previews and the due sources, and stores them only once every read is complete: a SIGTERM
|
|
920
|
-
* while a read is pending saves the state as it was, so no event is taken as reported.
|
|
944
|
+
* while a read is pending saves the state as it was, so no event is taken as reported. Before the sources run, a
|
|
945
|
+
* change of the files they come from is taken up (`reloadSources`), which prints its notes.
|
|
921
946
|
*/
|
|
922
947
|
const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
923
948
|
syncBase(watch, watching)
|
|
@@ -925,6 +950,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
925
950
|
const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
|
|
926
951
|
const laneBranches = laneBranchesOf(watch, watching.lanes)
|
|
927
952
|
const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
|
|
953
|
+
reloadSources(watch, watching)
|
|
928
954
|
const [github, sourceRuns] = await Promise.all([
|
|
929
955
|
readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
|
|
930
956
|
runDueSources(watch, watching, now),
|
|
@@ -940,7 +966,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
940
966
|
...recordGithub(watching, github, laneBranches, now, failureReadings),
|
|
941
967
|
...recordFeedbackRead(watching, feedback?.read ?? null),
|
|
942
968
|
...recordPreviewRead(watching, previews),
|
|
943
|
-
...recordSourceRuns(
|
|
969
|
+
...recordSourceRuns(watching, sourceRuns),
|
|
944
970
|
...recordCoordinator(watching, { name, read: fills.coordinator }),
|
|
945
971
|
...recordLaneFills(watching, fills.lanes),
|
|
946
972
|
...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
|
|
@@ -1355,6 +1381,7 @@ const initialWatching = (
|
|
|
1355
1381
|
feedbackRead: null,
|
|
1356
1382
|
failureReadings: [],
|
|
1357
1383
|
previewRead: null,
|
|
1384
|
+
sources: watch.sources,
|
|
1358
1385
|
sourceRuns: [],
|
|
1359
1386
|
laneReadings: {},
|
|
1360
1387
|
laneModelReadings: {},
|
|
@@ -1458,8 +1485,12 @@ const autoSettingsOf = (root: string, checkoutRoot: string, config: JackherdConf
|
|
|
1458
1485
|
const watchOf = (options: WatchOptions): Watch => {
|
|
1459
1486
|
const root = mainCheckout(process.cwd())
|
|
1460
1487
|
const checkoutRoot = worktreeRoot(process.cwd())
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1488
|
+
const configRoots = [checkoutRoot, root]
|
|
1489
|
+
// Stamped before the read: a change in between is read again at the first poll, never missed.
|
|
1490
|
+
const stamps = readFileStamps(sourceFilePaths(configRoots, homedir()))
|
|
1491
|
+
const { config, notes } = readResolvedConfig(configRoots)
|
|
1492
|
+
const merged = sourcesWithPersonal(config.watch.sources)
|
|
1493
|
+
for (const note of [...notes, ...merged.notes]) console.log(note)
|
|
1463
1494
|
const settings = plansSettingsOf(config)
|
|
1464
1495
|
const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
|
|
1465
1496
|
foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
|
|
@@ -1478,7 +1509,8 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1478
1509
|
taskSignalMinutes: config.watch.taskSignalMinutes,
|
|
1479
1510
|
parallelAuto: autoSettingsOf(root, checkoutRoot, config),
|
|
1480
1511
|
previews: config.watch.previews,
|
|
1481
|
-
sources:
|
|
1512
|
+
sources: { sources: merged.sources, stamps },
|
|
1513
|
+
configRoots,
|
|
1482
1514
|
texts: watchTexts(settings.responseLanguage),
|
|
1483
1515
|
skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
|
|
1484
1516
|
herdr: insideHerdr(settings.adapter, process.env),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path'
|
|
2
|
+
import { type HerdSourceSettings, herdSourceSettingsOf } from '../plans/settings'
|
|
2
3
|
import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
|
|
3
4
|
import {
|
|
4
5
|
sourceTicketsOf,
|
|
5
|
-
type TicketSource,
|
|
6
6
|
WATCH_HEALTH,
|
|
7
|
+
type WatchHealth,
|
|
7
8
|
type WatchReading,
|
|
8
9
|
watchBadgesOf,
|
|
9
10
|
watchHealthOf,
|
|
@@ -22,21 +23,35 @@ const readState = (file: string): WatchState | null => {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** A reading with the sources it was drawn for: those the watcher wrote into its state, else the herd's own. */
|
|
27
|
+
export type SourcedWatchReading = WatchReading & { sourceSettings: HerdSourceSettings }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The sources a live watcher keeps in its state (`watchedSources`); `fallback`, the herd's config, for a watcher that
|
|
31
|
+
* stopped (its state may predate a config change) or a state written before it kept them.
|
|
32
|
+
*/
|
|
33
|
+
export const sourceSettingsOf = (
|
|
34
|
+
state: WatchState | null,
|
|
35
|
+
health: WatchHealth,
|
|
36
|
+
fallback: HerdSourceSettings,
|
|
37
|
+
): HerdSourceSettings =>
|
|
38
|
+
health.kind === WATCH_HEALTH.live && state?.watchedSources ? herdSourceSettingsOf(state.watchedSources) : fallback
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
|
|
27
|
-
* its badges, the tickets of
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
42
|
+
* its badges, the tickets of the ticket sources, the status of the other sources, and its health, so the view can say
|
|
43
|
+
* why there are none. The sources are those the state names, so one the watcher took up since the herd started shows
|
|
44
|
+
* too; `fallback` stands in for a state without them. A missing or damaged file shows no badges; the view goes on
|
|
45
|
+
* without them. Nothing is read unless both levels of the watcher directory are private (`isPrivateLevels`), and
|
|
46
|
+
* only regular files, so a directory, a file or a FIFO someone else planted neither feeds the view nor blocks it.
|
|
31
47
|
*/
|
|
32
48
|
export const readWatchReading = (
|
|
33
49
|
root: string,
|
|
34
50
|
now: number,
|
|
35
|
-
|
|
51
|
+
fallback: HerdSourceSettings,
|
|
36
52
|
lookUp?: CommandLineOf,
|
|
37
53
|
system: LocationSystem = systemLocation(),
|
|
38
|
-
|
|
39
|
-
): WatchReading => {
|
|
54
|
+
): SourcedWatchReading => {
|
|
40
55
|
const directory = watcherDirectoryOf(root, system)
|
|
41
56
|
const isPrivate = isPrivateLevels(dirname(directory), directory, system)
|
|
42
57
|
const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
|
|
@@ -45,11 +60,14 @@ export const readWatchReading = (
|
|
|
45
60
|
isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
46
61
|
now,
|
|
47
62
|
)
|
|
48
|
-
|
|
63
|
+
const sourceSettings = sourceSettingsOf(state, health, fallback)
|
|
64
|
+
if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health, sourceSettings }
|
|
65
|
+
const statusNames = sourceSettings.herdSources.map(({ name }) => name)
|
|
49
66
|
return {
|
|
50
67
|
badges: watchBadgesOf(state, now),
|
|
51
|
-
tickets: sourceTicketsOf(state, ticketSources, now),
|
|
68
|
+
tickets: sourceTicketsOf(state, sourceSettings.ticketSources, now),
|
|
52
69
|
health,
|
|
53
70
|
...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
|
|
71
|
+
sourceSettings,
|
|
54
72
|
}
|
|
55
73
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { personalSourcesPath } from '../config/location'
|
|
3
|
+
import { configuredSources, isNoteAbout } from '../config/personal-sources'
|
|
4
|
+
import { parseSourceDraft } from '../config/schema'
|
|
3
5
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
4
6
|
import type { SourceDryRun } from './options'
|
|
5
7
|
import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
|
|
6
|
-
import {
|
|
8
|
+
import { SOURCE_LIMITS, sourceAnswer, sourceRunOf } from './source-run'
|
|
7
9
|
import type { SourceSettings } from './sources'
|
|
8
10
|
|
|
9
|
-
const settingsOf = async (target: DryRunTarget,
|
|
11
|
+
const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings[]): Promise<SourceSettings> => {
|
|
10
12
|
if (target.kind === 'name') {
|
|
11
|
-
const found =
|
|
12
|
-
if (!found) throw new Error(`no source ${target.name} in watch.sources`)
|
|
13
|
+
const found = sources.find(({ name }) => name === target.name)
|
|
14
|
+
if (!found) throw new Error(`no source ${target.name} in watch.sources or the personal sources file`)
|
|
13
15
|
return found
|
|
14
16
|
}
|
|
15
17
|
const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
|
|
@@ -17,19 +19,28 @@ const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
|
|
21
|
-
* watcher would, and prints what it found and would report; `--raw` adds
|
|
22
|
+
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout (a personal one in `~/.jackherd`), as the
|
|
23
|
+
* watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
|
|
24
|
+
* the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
|
|
22
25
|
* neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
|
|
23
26
|
* `map` field is empty in every entry.
|
|
24
27
|
*/
|
|
25
28
|
export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
|
|
26
29
|
const root = mainCheckout(process.cwd())
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
+
const home = homedir()
|
|
31
|
+
const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
|
|
32
|
+
const settings = await settingsOf(dryRunTarget(target), merged.sources)
|
|
33
|
+
const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
|
|
30
34
|
const run = sourceRunOf(settings, answer)
|
|
31
35
|
const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
|
|
32
36
|
const stdout = raw && answer.ok ? answer.stdout : undefined
|
|
33
|
-
console.log(
|
|
37
|
+
console.log(
|
|
38
|
+
dryRunLines(settings, run, {
|
|
39
|
+
mapCheck,
|
|
40
|
+
stdout,
|
|
41
|
+
personalPath: personalSourcesPath(home),
|
|
42
|
+
notes: merged.notes.filter((note) => isNoteAbout(note, settings.name)),
|
|
43
|
+
}).join('\n'),
|
|
44
|
+
)
|
|
34
45
|
return dryRunExitCode(run, mapCheck)
|
|
35
46
|
}
|