clap-ts 0.3.0 → 0.4.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/dist/parser.js +5 -5
- package/dist/types.d.ts +14 -1
- package/package.json +17 -1
- package/src/__tests__/arg-options.test.ts +687 -0
- package/src/__tests__/argfile.test.ts +127 -0
- package/src/__tests__/clap-parity.test.ts +682 -0
- package/src/__tests__/command-options.test.ts +713 -0
- package/src/__tests__/completions.test.ts +423 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/deprecation.test.ts +104 -0
- package/src/__tests__/help.test.ts +312 -0
- package/src/__tests__/install.test.ts +120 -0
- package/src/__tests__/log.test.ts +189 -0
- package/src/__tests__/man.test.ts +135 -0
- package/src/__tests__/markdown.test.ts +114 -0
- package/src/__tests__/output.test.ts +249 -0
- package/src/__tests__/parser.test.ts +627 -0
- package/src/__tests__/plugins.test.ts +182 -0
- package/src/__tests__/progress.test.ts +221 -0
- package/src/__tests__/prompt.test.ts +265 -0
- package/src/__tests__/runner.test.ts +459 -0
- package/src/__tests__/spec.test.ts +107 -0
- package/src/__tests__/testing.test.ts +93 -0
- package/src/__tests__/validation.test.ts +267 -0
- package/src/argfile.ts +188 -0
- package/src/completions.ts +865 -0
- package/src/config.ts +184 -0
- package/src/help.ts +779 -0
- package/src/index.ts +58 -0
- package/src/install.ts +226 -0
- package/src/log.ts +225 -0
- package/src/man.ts +289 -0
- package/src/markdown.ts +210 -0
- package/src/output.ts +453 -0
- package/src/parser.ts +1240 -0
- package/src/plugins.ts +193 -0
- package/src/progress.ts +295 -0
- package/src/prompt.ts +388 -0
- package/src/runner.ts +769 -0
- package/src/spec.ts +197 -0
- package/src/testing.ts +159 -0
- package/src/types.ts +618 -0
- package/src/validation.ts +627 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration file loading, layered under the command line and environment.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { loadConfig } from 'clap-ts/config';
|
|
6
|
+
*
|
|
7
|
+
* const config = loadConfig('mytool');
|
|
8
|
+
* await runMain(main, { config: config?.values });
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Precedence ends up as command line, then environment, then config file, then
|
|
12
|
+
* the argument's own default. `ctx.valueSources` reports which one won.
|
|
13
|
+
*
|
|
14
|
+
* Only JSON is understood out of the box, which keeps this dependency-free.
|
|
15
|
+
* Point `parse` at a TOML or YAML reader to accept those.
|
|
16
|
+
*
|
|
17
|
+
* The search costs one `existsSync` per candidate per directory, so it is
|
|
18
|
+
* O(directories x candidates) and independent of how many files those
|
|
19
|
+
* directories hold. Listing each directory once instead would be one syscall
|
|
20
|
+
* per level, but `readdirSync` is O(entries): measured against a 2000-entry
|
|
21
|
+
* directory it took 117us where four `existsSync` calls took 2.4us. Walking up
|
|
22
|
+
* through a large directory is exactly the case that has to stay cheap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
26
|
+
import { dirname, resolve } from 'node:path';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
|
|
29
|
+
/** A loaded configuration file. */
|
|
30
|
+
export interface LoadedConfig {
|
|
31
|
+
/** The parsed contents, ready for `RunOptions.config`. */
|
|
32
|
+
readonly values: Record<string, unknown>;
|
|
33
|
+
/** Absolute path of the file the values came from. */
|
|
34
|
+
readonly path: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConfigOptions {
|
|
38
|
+
/**
|
|
39
|
+
* File names to look for, in order of preference. Defaults to
|
|
40
|
+
* `.<name>rc`, `.<name>rc.json`, `<name>.config.json` and `.config/<name>.json`.
|
|
41
|
+
*/
|
|
42
|
+
readonly files?: readonly string[];
|
|
43
|
+
/** Directory to start searching from (default: `process.cwd()`). */
|
|
44
|
+
readonly cwd?: string;
|
|
45
|
+
/** Stop searching at this directory, inclusive (default: the home directory). */
|
|
46
|
+
readonly stopAt?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Read this key out of a `package.json` found during the walk. Defaults to
|
|
49
|
+
* the tool name; pass `null` to skip package.json entirely.
|
|
50
|
+
*/
|
|
51
|
+
readonly packageJsonKey?: string | null;
|
|
52
|
+
/** Parse a file's text. Defaults to `JSON.parse`. */
|
|
53
|
+
readonly parse?: (text: string, path: string) => unknown;
|
|
54
|
+
/** Load exactly this file and skip the search. */
|
|
55
|
+
readonly path?: string;
|
|
56
|
+
/** Search parent directories as well as `cwd` (default: true). */
|
|
57
|
+
readonly searchParents?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Stop once a directory holding `package.json` or `.git` has been examined.
|
|
60
|
+
* Cuts the walk to the project, which is where a project's config lives.
|
|
61
|
+
*/
|
|
62
|
+
readonly stopAtProjectRoot?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The home directory never changes within a process. */
|
|
66
|
+
const HOME = homedir();
|
|
67
|
+
|
|
68
|
+
const defaultFileCache = new Map<string, readonly string[]>();
|
|
69
|
+
|
|
70
|
+
function defaultFiles(name: string): readonly string[] {
|
|
71
|
+
let files = defaultFileCache.get(name);
|
|
72
|
+
if (files === undefined) {
|
|
73
|
+
files = [`.${name}rc`, `.${name}rc.json`, `${name}.config.json`, `.config/${name}.json`];
|
|
74
|
+
defaultFileCache.set(name, files);
|
|
75
|
+
}
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
80
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
81
|
+
throw new Error(`config at ${path} must be an object`);
|
|
82
|
+
}
|
|
83
|
+
return value as Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readOne(path: string, parse: (text: string, path: string) => unknown): LoadedConfig {
|
|
87
|
+
let text: string;
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(path, 'utf8');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
throw new Error(`cannot read config at ${path}: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return { values: asRecord(parse(text, path), path), path };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
throw new Error(`cannot parse config at ${path}: ${message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find and read the nearest configuration file, walking up from `cwd`.
|
|
104
|
+
*
|
|
105
|
+
* Returns `undefined` when nothing is found, and throws only when a file exists
|
|
106
|
+
* but cannot be read or parsed: a broken config should be loud, a missing one
|
|
107
|
+
* should not.
|
|
108
|
+
*/
|
|
109
|
+
export function loadConfig(name: string, opts?: ConfigOptions): LoadedConfig | undefined {
|
|
110
|
+
const parse = opts?.parse ?? ((text: string) => JSON.parse(text) as unknown);
|
|
111
|
+
|
|
112
|
+
if (opts?.path !== undefined) {
|
|
113
|
+
return readOne(resolve(opts.path), parse);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const files = opts?.files ?? defaultFiles(name);
|
|
117
|
+
const packageKey = opts?.packageJsonKey === undefined ? name : opts.packageJsonKey;
|
|
118
|
+
const stopAt = resolve(opts?.stopAt ?? HOME);
|
|
119
|
+
const searchParents = opts?.searchParents !== false;
|
|
120
|
+
|
|
121
|
+
let dir = resolve(opts?.cwd ?? process.cwd());
|
|
122
|
+
for (;;) {
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
// Template concatenation rather than join(): join normalises, which this
|
|
125
|
+
// does not need, and the walk runs this on every candidate at every level.
|
|
126
|
+
const candidate = `${dir}/${file}`;
|
|
127
|
+
if (existsSync(candidate)) {
|
|
128
|
+
return readOne(candidate, parse);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let atProjectRoot = false;
|
|
133
|
+
if (packageKey !== null || opts?.stopAtProjectRoot === true) {
|
|
134
|
+
const pkgPath = `${dir}/package.json`;
|
|
135
|
+
if (existsSync(pkgPath)) {
|
|
136
|
+
atProjectRoot = true;
|
|
137
|
+
if (packageKey !== null) {
|
|
138
|
+
const section = readOne(pkgPath, parse).values[packageKey];
|
|
139
|
+
if (section !== undefined) {
|
|
140
|
+
return { values: asRecord(section, pkgPath), path: pkgPath };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!searchParents) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (opts?.stopAtProjectRoot === true && (atProjectRoot || existsSync(`${dir}/.git`))) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const parent = dirname(dir);
|
|
153
|
+
if (parent === dir || dir === stopAt) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
dir = parent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Load a config and hand back the run options to spread into `runMain`.
|
|
162
|
+
*
|
|
163
|
+
* ```ts
|
|
164
|
+
* await runMain(main, { ...configOptions('mytool') });
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export function configOptions(
|
|
168
|
+
name: string,
|
|
169
|
+
opts?: ConfigOptions,
|
|
170
|
+
): { config: () => Record<string, unknown> | undefined } {
|
|
171
|
+
// A thunk, so runMain only searches the filesystem when some argument is
|
|
172
|
+
// still on its default.
|
|
173
|
+
let cached: Record<string, unknown> | undefined;
|
|
174
|
+
let loadedOnce = false;
|
|
175
|
+
return {
|
|
176
|
+
config: () => {
|
|
177
|
+
if (!loadedOnce) {
|
|
178
|
+
cached = loadConfig(name, opts)?.values;
|
|
179
|
+
loadedOnce = true;
|
|
180
|
+
}
|
|
181
|
+
return cached;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|