@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/README.md +7 -1
- package/package.json +1 -1
- package/src/args.ts +39 -8
- package/src/ci.ts +132 -0
- package/src/cli.ts +78 -37
- package/src/config.ts +11 -5
- package/src/git.ts +55 -0
- package/src/index.ts +5 -0
- package/src/logs.ts +72 -0
- package/src/process.ts +113 -12
- package/src/secrets.ts +10 -0
- package/src/shell.ts +467 -35
- package/src/text.ts +12 -0
- package/src/tool.ts +71 -0
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
|
|
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
|
-
|
|
115
|
-
|
|
168
|
+
try {
|
|
169
|
+
onChunk?.({ stream: name, data })
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
state.failure = error
|
|
173
|
+
stop()
|
|
116
174
|
|
|
117
|
-
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
118
177
|
|
|
119
|
-
|
|
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
|
+
}
|