@kaynooo/cli-kit 0.9.0 → 0.11.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/src/logs.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { lstatSync, openSync } from 'node:fs'
2
+ import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import process from 'node:process'
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000
8
+
9
+ // Whether the folder is a real directory (not a link) that only this user can use.
10
+ function isPrivateDirectory(path: string): boolean {
11
+ try {
12
+ const info = lstatSync(path, { throwIfNoEntry: false })
13
+
14
+ return info !== undefined && info.isDirectory() && info.uid === (process.getuid?.() ?? info.uid) && (info.mode & 0o077) === 0
15
+ }
16
+ catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ // A folder for log files that only this user can read: `<name>` under $XDG_RUNTIME_DIR, else ~/.cache/agent-tools. When one already exists it must be a private directory of this user, otherwise a fresh private folder in the temp directory is used instead of trusting it.
22
+ export async function privateLogDirectory(name: string, env: Record<string, string | undefined> = process.env): Promise<string> {
23
+ const runtime = env.XDG_RUNTIME_DIR
24
+ const base = runtime === undefined || runtime === '' ? join(homedir(), '.cache', 'agent-tools') : join(runtime, 'agent-tools')
25
+ const path = join(base, name)
26
+
27
+ try {
28
+ await mkdir(path, { recursive: true, mode: 0o700 })
29
+
30
+ if (isPrivateDirectory(path))
31
+ return path
32
+ }
33
+ catch {
34
+ // Falls through to the temp folder below.
35
+ }
36
+
37
+ return mkdtemp(join(tmpdir(), `${name.replaceAll('/', '-')}-`))
38
+ }
39
+
40
+ // Removes what is older than `maxAgeMs` in the folder, and the empty subfolders it leaves, best effort since another run may be doing the same.
41
+ export async function sweepLogs(dir: string, maxAgeMs = DAY_MS, now = Date.now()): Promise<void> {
42
+ let names: string[]
43
+
44
+ try {
45
+ names = await readdir(dir)
46
+ }
47
+ catch {
48
+ return
49
+ }
50
+
51
+ for (const name of names) {
52
+ const path = join(dir, name)
53
+
54
+ try {
55
+ const info = await stat(path)
56
+
57
+ if (info.isDirectory())
58
+ await sweepLogs(path, maxAgeMs, now)
59
+
60
+ if (now - info.mtimeMs > maxAgeMs)
61
+ await rm(path, { force: true, recursive: info.isDirectory() })
62
+ }
63
+ catch {
64
+ continue
65
+ }
66
+ }
67
+ }
68
+
69
+ // Creates a log file nobody else can read and that must not exist yet, so a planted link is never followed; returns its descriptor, for writeSync.
70
+ export function createLog(path: string): number {
71
+ return openSync(path, 'wx', 0o600)
72
+ }
package/src/process.ts CHANGED
@@ -14,12 +14,26 @@ export interface Ran {
14
14
  // The bytes of every chunk joined.
15
15
  output: Buffer
16
16
  text: string
17
+ // The command was ended because `timeoutMs` passed, its code then being the signal's.
18
+ timedOut: boolean
19
+ // More than `maxBytes` came, and the rest was left out of chunks, output and text.
20
+ truncated: boolean
17
21
  }
18
22
 
19
23
  export interface RunSettings {
20
24
  cwd?: string
21
25
  env?: Record<string, string | undefined>
22
26
  stdin?: 'inherit' | 'ignore'
27
+ // Ends the command and its children after this long.
28
+ timeoutMs?: number
29
+ // Ends the command and its children when aborted.
30
+ signal?: AbortSignal
31
+ // Bytes kept in the result; the pipes are still read to the end.
32
+ maxBytes?: number
33
+ // Called with each chunk as it arrives; throwing ends the command and rethrows once the pipes are drained.
34
+ onChunk?: (chunk: Chunk) => void
35
+ // Called once the command runs, with a handle that sends a signal to its whole process group, for `holdSignals().forward`.
36
+ onStart?: (child: { kill: (signal: NodeJS.Signals) => void }) => void
23
37
  }
24
38
 
25
39
  export interface HeldSignals {
@@ -31,16 +45,12 @@ export interface HeldSignals {
31
45
  release: () => void
32
46
  }
33
47
 
48
+ const KILL_GRACE_MS = 2000
34
49
  const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
35
50
  // A scheme of two letters or more, so a Windows drive is read as a path.
36
51
  const SCHEME = /^[a-z][\w+.-]+:/i
37
52
  const OPENERS: Partial<Record<NodeJS.Platform, string[]>> = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] }
38
53
 
39
- async function collect(stream: ReadableStream<Uint8Array>, name: Chunk['stream'], chunks: Chunk[]): Promise<void> {
40
- for await (const data of stream)
41
- chunks.push({ stream: name, data })
42
- }
43
-
44
54
  // The exit code a shell would report: the code, or 128 plus the signal.
45
55
  export function exitCodeOf(code: number | null, signal: NodeJS.Signals | number | null): number {
46
56
  if (code !== null)
@@ -106,15 +116,106 @@ export function openUrl(target: string, env: Record<string, string | undefined>
106
116
  }
107
117
  }
108
118
 
109
- // Runs a command with stdout and stderr captured; throws when it cannot be started.
110
- export async function runCommand(argv: string[], { cwd, env, stdin = 'ignore' }: RunSettings = {}): Promise<Ran> {
119
+ // Runs a command with stdout and stderr captured; throws when it cannot be started. With `timeoutMs`, `signal`, `onStart` or `onChunk` the child leads its own process group, and the whole group is ended (SIGTERM, then SIGKILL after a short grace) on the timeout, an abort or a throwing `onChunk`; the pipes are drained to their end either way.
120
+ export async function runCommand(argv: string[], { cwd, env, stdin = 'ignore', timeoutMs, signal, maxBytes = Infinity, onChunk, onStart }: RunSettings = {}): Promise<Ran> {
111
121
  const chunks: Chunk[] = []
112
- const proc = Bun.spawn(argv, { ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), stdin, stdout: 'pipe', stderr: 'pipe' })
122
+ const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined
123
+ const proc = Bun.spawn(argv, { ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), stdin, stdout: 'pipe', stderr: 'pipe', ...(grouped ? { detached: true } : {}) })
124
+ const state = { kept: 0, truncated: false, timedOut: false, failure: undefined as unknown, done: false }
125
+ const timers: ReturnType<typeof setTimeout>[] = []
126
+
127
+ const killGroup = (killing: NodeJS.Signals) => {
128
+ if (state.done)
129
+ return
130
+
131
+ try {
132
+ if (grouped)
133
+ process.kill(-proc.pid, killing)
134
+ else
135
+ proc.kill(killing)
136
+ }
137
+ catch {
138
+ // The group is already gone.
139
+ }
140
+ }
141
+
142
+ const stop = () => {
143
+ killGroup('SIGTERM')
144
+ timers.push(setTimeout(killGroup, KILL_GRACE_MS, 'SIGKILL'))
145
+ }
146
+
147
+ const onAbort = () => stop()
148
+
149
+ if (timeoutMs !== undefined) {
150
+ timers.push(setTimeout(() => {
151
+ state.timedOut = true
152
+ stop()
153
+ }, timeoutMs))
154
+ }
155
+
156
+ if (signal?.aborted === true)
157
+ onAbort()
158
+ else
159
+ signal?.addEventListener('abort', onAbort, { once: true })
160
+
161
+ onStart?.({ kill: killing => killGroup(killing) })
162
+
163
+ const drain = async (stream: ReadableStream<Uint8Array>, name: Chunk['stream']) => {
164
+ for await (const data of stream) {
165
+ if (state.failure !== undefined)
166
+ continue
113
167
 
114
- await Promise.all([collect(proc.stdout, 'stdout', chunks), collect(proc.stderr, 'stderr', chunks)])
115
- await proc.exited
168
+ try {
169
+ onChunk?.({ stream: name, data })
170
+ }
171
+ catch (error) {
172
+ state.failure = error
173
+ stop()
116
174
 
117
- const output = Buffer.concat(chunks.map(({ data }) => data))
175
+ continue
176
+ }
118
177
 
119
- return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() }
178
+ if (state.kept + data.length > maxBytes) {
179
+ state.truncated = true
180
+
181
+ continue
182
+ }
183
+
184
+ state.kept += data.length
185
+ chunks.push({ stream: name, data })
186
+ }
187
+ }
188
+
189
+ try {
190
+ await Promise.all([drain(proc.stdout, 'stdout'), drain(proc.stderr, 'stderr')])
191
+ await proc.exited
192
+ }
193
+ finally {
194
+ state.done = true
195
+
196
+ for (const timer of timers)
197
+ clearTimeout(timer)
198
+
199
+ signal?.removeEventListener('abort', onAbort)
200
+ }
201
+
202
+ if (state.failure !== undefined)
203
+ throw state.failure
204
+
205
+ const joined = () => Buffer.concat(chunks.map(({ data }) => data))
206
+ let output: Buffer | undefined
207
+ let text: string | undefined
208
+
209
+ return {
210
+ code: exitCodeOf(proc.exitCode, proc.signalCode),
211
+ chunks,
212
+ timedOut: state.timedOut,
213
+ truncated: state.truncated,
214
+ get output() {
215
+ return output ??= joined()
216
+ },
217
+ get text() {
218
+ return text ??= (output ??= joined()).toString()
219
+ },
220
+ }
120
221
  }
package/src/secrets.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Parts of a name that make it secret wherever they sit in it (`DB_PASSWORD`, `PGPASSWORD`, `clientSecret`).
2
+ const SECRET_PART = /token|secret|passw|credential|private|dsn|apikey|accesskey|key$/i
3
+ // Whole words of a name that make it secret (`DB_PASS`, `SMTP_PWD`, `encryptionKey`, `session_id`), so `compass`, `author` or `keyword` stay plain.
4
+ const SECRET_WORDS = new Set(['key', 'pass', 'pwd', 'auth', 'salt', 'cookie', 'session', 'jwt', 'ssn', 'iban', 'card', 'cvv', 'otp'])
5
+ const WORD_BREAKS = /[^a-zA-Z\d]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/
6
+
7
+ // True for the name of a variable, a column or a JSON key that holds a secret, by one definition shared by ev, redact and db.
8
+ export function isSecretName(name: string): boolean {
9
+ return SECRET_PART.test(name) || name.split(WORD_BREAKS).some(word => SECRET_WORDS.has(word.toLowerCase()))
10
+ }