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.
Files changed (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. 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
+ }