@kaynooo/cli-kit 0.19.0 → 0.21.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 +30 -1
- package/package.json +3 -2
- package/src/advisory.ts +22 -0
- package/src/args.ts +31 -136
- package/src/ci.ts +10 -4
- package/src/cli.ts +79 -7
- package/src/compose.ts +43 -0
- package/src/fzf.ts +186 -0
- package/src/help.ts +91 -0
- package/src/index.ts +5 -0
- package/src/input.ts +64 -0
- package/src/shell.ts +73 -18
- package/src/size.ts +14 -0
- package/src/tool.ts +5 -2
- package/src/usage.ts +129 -0
- package/src/user-config.ts +34 -0
package/src/usage.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { CLI_FOLDER } from './logs'
|
|
6
|
+
|
|
7
|
+
// One run of a bin: which command it ran, the names of the flags it saw (never a value or an argument), how it exited and, on a usage error, the option it did not know.
|
|
8
|
+
export interface UsageEntry {
|
|
9
|
+
at: string
|
|
10
|
+
bin: string
|
|
11
|
+
command?: string
|
|
12
|
+
flags: string[]
|
|
13
|
+
code: number
|
|
14
|
+
unknown?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UNKNOWN_OPTION = /unknown option '(--[a-z][a-z0-9-]*)[=']/
|
|
18
|
+
// A long name, or a single letter: anything else is an argument that happens to start with a dash, such as `-secret`.
|
|
19
|
+
const FLAG_NAME = /^(?:--[a-z][a-z0-9-]*|-[a-zA-Z])$/
|
|
20
|
+
const LONGEST_NAME = 40
|
|
21
|
+
|
|
22
|
+
// Set when a run reported a usage error, so the entry of that run can name the option.
|
|
23
|
+
let usageError: string | undefined
|
|
24
|
+
|
|
25
|
+
export function noteUsageError(message: string): void {
|
|
26
|
+
usageError = message
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// XDG_STATE_HOME when it is an absolute path, else ~/.local/state, as the spec says an empty or relative value is ignored.
|
|
30
|
+
export function stateHome(env: Record<string, string | undefined> = process.env): string {
|
|
31
|
+
const dir = env.XDG_STATE_HOME
|
|
32
|
+
|
|
33
|
+
return dir !== undefined && isAbsolute(dir) ? dir : join(homedir(), '.local', 'state')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function usagePath(env: Record<string, string | undefined> = process.env): string {
|
|
37
|
+
return join(stateHome(env), CLI_FOLDER, 'usage.jsonl')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether runs are recorded: never under `bun test`, and not once KAYNOOO_CLI_NO_USAGE is set to anything but 0 or an empty value.
|
|
41
|
+
export function usageRecorded(env: Record<string, string | undefined> = process.env): boolean {
|
|
42
|
+
const off = env.KAYNOOO_CLI_NO_USAGE
|
|
43
|
+
|
|
44
|
+
return env.NODE_ENV !== 'test' && (off === undefined || off === '' || off === '0')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The flags among the arguments by name, `--limit=5` as `--limit`, stopping at `--`.
|
|
48
|
+
export function flagNames(argv: readonly string[]): string[] {
|
|
49
|
+
const end = argv.indexOf('--')
|
|
50
|
+
const names = (end === -1 ? argv : argv.slice(0, end)).filter(word => word.startsWith('-') && word !== '-').map(word => word.includes('=') ? word.slice(0, word.indexOf('=')) : word)
|
|
51
|
+
|
|
52
|
+
return [...new Set(names)].filter(name => name.length <= LONGEST_NAME && FLAG_NAME.test(name))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The entry for a run that exited with `code`, with the unknown option a usage error named.
|
|
56
|
+
export function usageEntry(bin: string, command: string | undefined, argv: readonly string[], code: number, now = new Date()): UsageEntry {
|
|
57
|
+
const unknown = code === 2 && usageError !== undefined ? UNKNOWN_OPTION.exec(usageError)?.[1] : undefined
|
|
58
|
+
|
|
59
|
+
usageError = undefined
|
|
60
|
+
|
|
61
|
+
return { at: now.toISOString(), bin, ...(command === undefined ? {} : { command }), flags: flagNames(argv), code, ...(unknown === undefined ? {} : { unknown }) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Appends the run to the usage log, best effort: a log that cannot be written never changes what a bin prints or exits with.
|
|
65
|
+
export function recordUsage(entry: UsageEntry, env: Record<string, string | undefined> = process.env): void {
|
|
66
|
+
if (!usageRecorded(env))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const path = usagePath(env)
|
|
71
|
+
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
73
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 })
|
|
74
|
+
}
|
|
75
|
+
catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readUsage(path = usagePath(), since = 0): Promise<UsageEntry[]> {
|
|
79
|
+
const file = Bun.file(path)
|
|
80
|
+
|
|
81
|
+
if (!await file.exists())
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
return (await file.text()).split('\n').flatMap((line) => {
|
|
85
|
+
try {
|
|
86
|
+
const entry = JSON.parse(line) as UsageEntry
|
|
87
|
+
|
|
88
|
+
return Date.parse(entry.at) >= since ? [entry] : []
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UsageSummary {
|
|
97
|
+
runs: number
|
|
98
|
+
bins: { bin: string, runs: number, failed: number, usageErrors: number }[]
|
|
99
|
+
usageErrors: { bin: string, command?: string, unknown: string, count: number }[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Runs per bin, and the usage errors by the option that was not known, most common first.
|
|
103
|
+
export function summarizeUsage(entries: readonly UsageEntry[]): UsageSummary {
|
|
104
|
+
const bins = new Map<string, { runs: number, failed: number, usageErrors: number }>()
|
|
105
|
+
const errors = new Map<string, { bin: string, command?: string, unknown: string, count: number }>()
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const bin = bins.get(entry.bin) ?? { runs: 0, failed: 0, usageErrors: 0 }
|
|
109
|
+
|
|
110
|
+
bin.runs++
|
|
111
|
+
bin.failed += entry.code === 0 ? 0 : 1
|
|
112
|
+
bin.usageErrors += entry.code === 2 ? 1 : 0
|
|
113
|
+
bins.set(entry.bin, bin)
|
|
114
|
+
|
|
115
|
+
if (entry.code === 2 && entry.unknown !== undefined) {
|
|
116
|
+
const key = `${entry.bin} ${entry.command ?? ''} ${entry.unknown}`
|
|
117
|
+
const error = errors.get(key) ?? { bin: entry.bin, ...(entry.command === undefined ? {} : { command: entry.command }), unknown: entry.unknown, count: 0 }
|
|
118
|
+
|
|
119
|
+
error.count++
|
|
120
|
+
errors.set(key, error)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
runs: entries.length,
|
|
126
|
+
bins: [...bins].map(([bin, counts]) => ({ bin, ...counts })).toSorted((a, b) => b.runs - a.runs),
|
|
127
|
+
usageErrors: [...errors.values()].toSorted((a, b) => b.count - a.count),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
export interface UserConfigLocation {
|
|
7
|
+
// The home folder; `$XDG_CONFIG_HOME` is then ignored unless `xdg` is given too.
|
|
8
|
+
home?: string | undefined
|
|
9
|
+
// The config base folder, `$XDG_CONFIG_HOME` or `<home>/.config` by default.
|
|
10
|
+
xdg?: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function configBase(where: UserConfigLocation = {}): string {
|
|
14
|
+
const xdg = where.xdg ?? (where.home === undefined ? process.env.XDG_CONFIG_HOME : undefined)
|
|
15
|
+
|
|
16
|
+
return xdg !== undefined && xdg !== '' ? xdg : join(where.home ?? homedir(), '.config')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The one config file of a bin, `~/.config/kaynooo-cli/<bin>.json`: where it is written.
|
|
20
|
+
export function userConfigPath(bin: string, where: UserConfigLocation = {}): string {
|
|
21
|
+
return join(configBase(where), 'kaynooo-cli', `${bin}.json`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Where to read a bin's config: the new file, else the first of `legacy` (paths under the config base, such as `['ft', 'config.json']`) that exists, else the new path. A legacy file is never moved or deleted.
|
|
25
|
+
export function userConfigReadPath(bin: string, legacy: string[][] = [], where: UserConfigLocation = {}): string {
|
|
26
|
+
const current = userConfigPath(bin, where)
|
|
27
|
+
|
|
28
|
+
if (existsSync(current))
|
|
29
|
+
return current
|
|
30
|
+
|
|
31
|
+
const base = configBase(where)
|
|
32
|
+
|
|
33
|
+
return legacy.map(parts => join(base, ...parts)).find(path => existsSync(path)) ?? current
|
|
34
|
+
}
|