@optique/discover 1.2.0-dev.2167 → 1.2.0-dev.2169
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 +18 -20
- package/dist/index.cjs +102 -16
- package/dist/index.d.cts +92 -4
- package/dist/index.d.ts +92 -4
- package/dist/index.js +103 -18
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ handler.
|
|
|
14
14
|
> *@optique/discover* reads command files and imports them dynamically at
|
|
15
15
|
> runtime. It is a poor fit for CLIs that rely on aggressive tree shaking,
|
|
16
16
|
> static bundling, or single-file executable packaging. In those cases, use
|
|
17
|
-
>
|
|
17
|
+
> `commandsFromModules()` with a static module map, or manually imported
|
|
18
|
+
> commands with `runProgram({ commands })`.
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
Installation
|
|
@@ -79,32 +80,23 @@ admin --help
|
|
|
79
80
|
admin completion bash
|
|
80
81
|
~~~~
|
|
81
82
|
|
|
82
|
-
For bundlers and single-file packagers,
|
|
83
|
-
|
|
83
|
+
For bundlers and single-file packagers, turn a static module map into command
|
|
84
|
+
entries:
|
|
84
85
|
|
|
85
86
|
~~~~ typescript
|
|
86
87
|
// cli.ts
|
|
87
|
-
import {
|
|
88
|
-
import { object } from "@optique/core/constructs";
|
|
88
|
+
import { commandsFromModules, runProgram } from "@optique/discover";
|
|
89
89
|
import { message } from "@optique/core/message";
|
|
90
|
-
import { option } from "@optique/core/primitives";
|
|
91
|
-
import { string } from "@optique/core/valueparser";
|
|
92
90
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
parser: object({
|
|
96
|
-
name: option("--name", string()),
|
|
97
|
-
}),
|
|
98
|
-
metadata: {
|
|
99
|
-
brief: message`Add a user.`,
|
|
100
|
-
},
|
|
101
|
-
handler(value) {
|
|
102
|
-
console.log(`Adding ${value.name}.`);
|
|
103
|
-
},
|
|
91
|
+
const modules = import.meta.glob("./commands/**/*.ts", {
|
|
92
|
+
eager: true,
|
|
104
93
|
});
|
|
105
94
|
|
|
106
95
|
await runProgram({
|
|
107
|
-
commands:
|
|
96
|
+
commands: commandsFromModules(modules, {
|
|
97
|
+
base: "./commands",
|
|
98
|
+
extensions: [".ts"],
|
|
99
|
+
}),
|
|
108
100
|
metadata: {
|
|
109
101
|
name: "admin",
|
|
110
102
|
version: "1.0.0",
|
|
@@ -113,6 +105,11 @@ await runProgram({
|
|
|
113
105
|
});
|
|
114
106
|
~~~~
|
|
115
107
|
|
|
108
|
+
`commandsFromModules()` preserves the file-based command layout while making
|
|
109
|
+
the module list visible to bundlers. For smaller registries, you can also
|
|
110
|
+
import commands manually, declare `path` in each `defineCommand()` call, and
|
|
111
|
+
pass those commands to `runProgram({ commands })`.
|
|
112
|
+
|
|
116
113
|
By default, Deno and Bun discover `.ts`, `.mts`, `.js`, and `.mjs` files.
|
|
117
114
|
Node.js discovers `.js`, `.mjs`, and `.cjs` files, plus `.ts`, `.mts`, and
|
|
118
115
|
`.cts` when it reports native TypeScript support or runs with a recognized
|
|
@@ -120,7 +117,8 @@ TypeScript loader. TypeScript declaration files such as `.d.ts` are ignored.
|
|
|
120
117
|
Entry files named `index` map to their containing command path, so
|
|
121
118
|
`commands/index.ts` defines the root command and `commands/user/index.ts`
|
|
122
119
|
defines `user`. Use `entryFileName` to choose another entry name or disable
|
|
123
|
-
this rule.
|
|
120
|
+
this rule. `commandsFromModules()` applies the same path rules to module map
|
|
121
|
+
keys after stripping its `base` option.
|
|
124
122
|
|
|
125
123
|
For more resources, see the [docs] and the [*examples/*](/examples/)
|
|
126
124
|
directory.
|
package/dist/index.cjs
CHANGED
|
@@ -85,9 +85,8 @@ async function discoverCommands(options) {
|
|
|
85
85
|
}
|
|
86
86
|
seen.set(key, filePath);
|
|
87
87
|
const mod = await import((0, node_url.pathToFileURL)(filePath).href);
|
|
88
|
-
const commandDefinition =
|
|
89
|
-
|
|
90
|
-
if (commandDefinition.path != null && commandPathKey(commandDefinition.path) !== commandPathKey(path)) throw new TypeError(`Module ${filePath} declares command path "${displayCommandPath(commandDefinition.path)}" but file path defines "${displayCommandPath(path)}".`);
|
|
88
|
+
const commandDefinition = commandFromModuleExport(filePath, mod.default);
|
|
89
|
+
validateDeclaredCommandPath(commandDefinition, path, filePath, "file path");
|
|
91
90
|
discovered.push({
|
|
92
91
|
path,
|
|
93
92
|
filePath,
|
|
@@ -97,6 +96,51 @@ async function discoverCommands(options) {
|
|
|
97
96
|
return sortCommands(discovered);
|
|
98
97
|
}
|
|
99
98
|
/**
|
|
99
|
+
* Converts a static module map into command entries.
|
|
100
|
+
*
|
|
101
|
+
* This is useful for bundlers and single-file packagers that can statically
|
|
102
|
+
* see module maps, such as `import.meta.glob(..., { eager: true })`, while
|
|
103
|
+
* still deriving command paths from file-like module keys.
|
|
104
|
+
*
|
|
105
|
+
* @param modules Static module map keyed by module path.
|
|
106
|
+
* @param options Module path derivation options.
|
|
107
|
+
* @returns Command entries sorted by command path.
|
|
108
|
+
* @throws {TypeError} If options are invalid, no command modules are found,
|
|
109
|
+
* command paths are duplicated, a module does not default-export a
|
|
110
|
+
* command created with `defineCommand()`, or an explicit command
|
|
111
|
+
* `path` does not match the module-derived path.
|
|
112
|
+
* @since 1.2.0
|
|
113
|
+
*/
|
|
114
|
+
function commandsFromModules(modules, options = {}) {
|
|
115
|
+
if (modules == null || typeof modules !== "object") throw new TypeError("commandsFromModules() requires a module map object.");
|
|
116
|
+
const base = normalizeModuleBase(options.base);
|
|
117
|
+
const extensions = normalizeExtensions(options.extensions ?? getDefaultExtensions());
|
|
118
|
+
const entryFileName = normalizeEntryFileName(options.entryFileName);
|
|
119
|
+
const modulePaths = Object.keys(modules).toSorted((a, b) => a.localeCompare(b));
|
|
120
|
+
const seen = /* @__PURE__ */ new Map();
|
|
121
|
+
const discovered = [];
|
|
122
|
+
for (const modulePath of modulePaths) {
|
|
123
|
+
if (isDeclarationFile(node_path.posix.basename(modulePath)) || !extensions.some((ext) => modulePath.endsWith(ext))) continue;
|
|
124
|
+
const path = commandPathFromModulePath(base, modulePath, extensions, entryFileName);
|
|
125
|
+
const key = commandPathKey(path);
|
|
126
|
+
const previous = seen.get(key);
|
|
127
|
+
if (previous != null) {
|
|
128
|
+
const displayPath = displayCommandPath(path);
|
|
129
|
+
throw new TypeError(`Duplicate command path "${displayPath}" from ${previous} and ${modulePath}.`);
|
|
130
|
+
}
|
|
131
|
+
seen.set(key, modulePath);
|
|
132
|
+
const commandDefinition = commandFromModuleExport(modulePath, modules[modulePath]);
|
|
133
|
+
validateDeclaredCommandPath(commandDefinition, path, modulePath, "module path");
|
|
134
|
+
discovered.push({
|
|
135
|
+
path,
|
|
136
|
+
modulePath,
|
|
137
|
+
command: commandDefinition
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (discovered.length < 1) throw new TypeError("No command modules found in module map.");
|
|
141
|
+
return sortCommands(discovered);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
100
144
|
* Builds a parser that dispatches to discovered command handlers.
|
|
101
145
|
*
|
|
102
146
|
* @param commands Commands to compose.
|
|
@@ -171,6 +215,11 @@ function normalizeEntryFileName(entryFileName) {
|
|
|
171
215
|
if (normalized.length < 1 || normalized.includes("/") || normalized.includes("\\")) throw new TypeError(`Command entry file name must be a non-empty file name: ${normalized}`);
|
|
172
216
|
return normalized;
|
|
173
217
|
}
|
|
218
|
+
function normalizeModuleBase(base) {
|
|
219
|
+
if (base === void 0) return ".";
|
|
220
|
+
if (typeof base !== "string" || base.length < 1) throw new TypeError(`Module base path must be a non-empty string: ${base}`);
|
|
221
|
+
return normalizeModulePath(base);
|
|
222
|
+
}
|
|
174
223
|
async function collectCommandFiles(dir, extensions, activeDirs = /* @__PURE__ */ new Set()) {
|
|
175
224
|
const canonicalDir = await (0, node_fs_promises.realpath)(dir);
|
|
176
225
|
if (activeDirs.has(canonicalDir)) return [];
|
|
@@ -203,12 +252,33 @@ function isDeclarationFile(fileName) {
|
|
|
203
252
|
return /\.d\.[cm]?ts$/.test(fileName);
|
|
204
253
|
}
|
|
205
254
|
function commandPathFromFile(rootDir, filePath, extensions, entryFileName) {
|
|
206
|
-
const
|
|
207
|
-
if (matchedExtension == null) throw new TypeError(`No configured extension matches ${filePath}.`);
|
|
208
|
-
const withoutExtension = filePath.slice(0, -matchedExtension.length);
|
|
255
|
+
const withoutExtension = stripCommandExtension(filePath, extensions);
|
|
209
256
|
const relativePath = (0, node_path.relative)(rootDir, withoutExtension);
|
|
210
257
|
const path = relativePath.split(node_path.sep).filter((segment) => segment.length > 0);
|
|
211
|
-
|
|
258
|
+
return commandPathFromSegments(path, filePath, entryFileName);
|
|
259
|
+
}
|
|
260
|
+
function commandPathFromModulePath(base, modulePath, extensions, entryFileName) {
|
|
261
|
+
const withoutExtension = stripCommandExtension(modulePath, extensions);
|
|
262
|
+
const relativePath = relativeModulePath(base, withoutExtension, modulePath);
|
|
263
|
+
const path = relativePath.split("/").filter((segment) => segment.length > 0);
|
|
264
|
+
return commandPathFromSegments(path, modulePath, entryFileName);
|
|
265
|
+
}
|
|
266
|
+
function stripCommandExtension(path, extensions) {
|
|
267
|
+
const matchedExtension = extensions.find((ext) => path.endsWith(ext));
|
|
268
|
+
if (matchedExtension == null) throw new TypeError(`No configured extension matches ${path}.`);
|
|
269
|
+
return path.slice(0, -matchedExtension.length);
|
|
270
|
+
}
|
|
271
|
+
function relativeModulePath(base, modulePath, originalModulePath) {
|
|
272
|
+
const normalizedPath = normalizeModulePath(modulePath);
|
|
273
|
+
const relativePath = node_path.posix.relative(base, normalizedPath);
|
|
274
|
+
if (relativePath.length < 1 || relativePath === ".." || relativePath.startsWith("../") || node_path.posix.isAbsolute(relativePath)) throw new TypeError(`Module path ${originalModulePath} is not under base path ${base}.`);
|
|
275
|
+
return relativePath;
|
|
276
|
+
}
|
|
277
|
+
function normalizeModulePath(path) {
|
|
278
|
+
return node_path.posix.normalize(path.replaceAll("\\", "/"));
|
|
279
|
+
}
|
|
280
|
+
function commandPathFromSegments(path, source, entryFileName) {
|
|
281
|
+
if (path.length < 1) throw new TypeError(`Command module ${source} does not define a path.`);
|
|
212
282
|
if (entryFileName !== false && path[path.length - 1] === entryFileName) return path.slice(0, -1);
|
|
213
283
|
return path;
|
|
214
284
|
}
|
|
@@ -243,23 +313,38 @@ function isStaticRunProgramOptions(options) {
|
|
|
243
313
|
return hasCommands;
|
|
244
314
|
}
|
|
245
315
|
function staticCommandsToEntries(commands) {
|
|
246
|
-
return commands.map((
|
|
247
|
-
if (
|
|
248
|
-
if (!
|
|
316
|
+
return commands.map((entry) => {
|
|
317
|
+
if (isCommandEntry(entry)) return entry;
|
|
318
|
+
if (!require_command.isCommand(entry)) throw new TypeError("Static command entries must be created with defineCommand().");
|
|
319
|
+
if (!isCommandPath(entry.path)) throw new TypeError("Static command entries must declare a path.");
|
|
249
320
|
return {
|
|
250
|
-
path:
|
|
251
|
-
command:
|
|
321
|
+
path: entry.path,
|
|
322
|
+
command: entry
|
|
252
323
|
};
|
|
253
324
|
});
|
|
254
325
|
}
|
|
326
|
+
function isCommandEntry(value) {
|
|
327
|
+
return value != null && typeof value === "object" && isCommandPath(value.path) && require_command.isCommand(value.command);
|
|
328
|
+
}
|
|
255
329
|
function unwrapCommandExport(value) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
330
|
+
let current = value;
|
|
331
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
332
|
+
if (require_command.isCommand(current)) return current;
|
|
333
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
334
|
+
const nestedDefault = current.default;
|
|
335
|
+
if (Object.is(nestedDefault, current)) return void 0;
|
|
336
|
+
current = nestedDefault;
|
|
260
337
|
}
|
|
261
338
|
return void 0;
|
|
262
339
|
}
|
|
340
|
+
function commandFromModuleExport(source, value) {
|
|
341
|
+
const commandDefinition = unwrapCommandExport(value);
|
|
342
|
+
if (commandDefinition == null) throw new TypeError(`Module ${source} default export must be created with defineCommand().`);
|
|
343
|
+
return commandDefinition;
|
|
344
|
+
}
|
|
345
|
+
function validateDeclaredCommandPath(commandDefinition, path, source, sourcePathLabel) {
|
|
346
|
+
if (commandDefinition.path != null && commandPathKey(commandDefinition.path) !== commandPathKey(path)) throw new TypeError(`Module ${source} declares command path "${displayCommandPath(commandDefinition.path)}" but ${sourcePathLabel} defines "${displayCommandPath(path)}".`);
|
|
347
|
+
}
|
|
263
348
|
function buildCommandTree(commands) {
|
|
264
349
|
const root = { children: /* @__PURE__ */ new Map() };
|
|
265
350
|
for (const entry of commands) {
|
|
@@ -689,6 +774,7 @@ function isProgramInvocation(value) {
|
|
|
689
774
|
}
|
|
690
775
|
|
|
691
776
|
//#endregion
|
|
777
|
+
exports.commandsFromModules = commandsFromModules;
|
|
692
778
|
exports.createProgramParser = createProgramParser;
|
|
693
779
|
exports.defineCommand = require_command.defineCommand;
|
|
694
780
|
exports.discoverCommands = discoverCommands;
|
package/dist/index.d.cts
CHANGED
|
@@ -28,12 +28,31 @@ interface ProgramInvocation {
|
|
|
28
28
|
*/
|
|
29
29
|
readonly handler: (value: unknown) => void | Promise<void>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* A command paired with its command path.
|
|
33
|
+
*
|
|
34
|
+
* `createProgramParser()` accepts command entries directly, and
|
|
35
|
+
* `runProgram({ commands })` accepts them alongside commands that declare
|
|
36
|
+
* their own `path` field.
|
|
37
|
+
*
|
|
38
|
+
* @since 1.2.0
|
|
39
|
+
*/
|
|
40
|
+
interface CommandEntry {
|
|
41
|
+
/**
|
|
42
|
+
* Command path used to place the command in the program tree.
|
|
43
|
+
*/
|
|
44
|
+
readonly path: CommandPath;
|
|
45
|
+
/**
|
|
46
|
+
* The command definition.
|
|
47
|
+
*/
|
|
48
|
+
readonly command: AnyCommand;
|
|
49
|
+
}
|
|
31
50
|
/**
|
|
32
51
|
* A command found on disk.
|
|
33
52
|
*
|
|
34
53
|
* @since 1.1.0
|
|
35
54
|
*/
|
|
36
|
-
interface DiscoveredCommand {
|
|
55
|
+
interface DiscoveredCommand extends CommandEntry {
|
|
37
56
|
/**
|
|
38
57
|
* Command path derived from the module's relative path.
|
|
39
58
|
*/
|
|
@@ -47,6 +66,26 @@ interface DiscoveredCommand {
|
|
|
47
66
|
*/
|
|
48
67
|
readonly command: AnyCommand;
|
|
49
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* A command loaded from a static module map.
|
|
71
|
+
*
|
|
72
|
+
* @since 1.2.0
|
|
73
|
+
*/
|
|
74
|
+
interface ModuleCommand extends CommandEntry {
|
|
75
|
+
/**
|
|
76
|
+
* Module map key used to derive the command path.
|
|
77
|
+
*/
|
|
78
|
+
readonly modulePath: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Static module map accepted by {@link commandsFromModules}.
|
|
82
|
+
*
|
|
83
|
+
* This matches eager glob import APIs such as Vite's
|
|
84
|
+
* `import.meta.glob(..., { eager: true })`.
|
|
85
|
+
*
|
|
86
|
+
* @since 1.2.0
|
|
87
|
+
*/
|
|
88
|
+
type ModuleMap = Readonly<Record<string, unknown>>;
|
|
50
89
|
/**
|
|
51
90
|
* Options for {@link discoverCommands}.
|
|
52
91
|
*
|
|
@@ -75,6 +114,35 @@ interface DiscoverCommandsOptions {
|
|
|
75
114
|
*/
|
|
76
115
|
readonly entryFileName?: string | false;
|
|
77
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Options for {@link commandsFromModules}.
|
|
119
|
+
*
|
|
120
|
+
* @since 1.2.0
|
|
121
|
+
*/
|
|
122
|
+
interface CommandsFromModulesOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Base module path to strip before deriving command paths.
|
|
125
|
+
*
|
|
126
|
+
* @default `"."`
|
|
127
|
+
*/
|
|
128
|
+
readonly base?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Module suffixes to include. Compound suffixes such as `.cmd.ts` are
|
|
131
|
+
* supported.
|
|
132
|
+
*
|
|
133
|
+
* @default Runtime-aware extension defaults from {@link getDefaultExtensions}
|
|
134
|
+
*/
|
|
135
|
+
readonly extensions?: readonly string[];
|
|
136
|
+
/**
|
|
137
|
+
* File name that maps to the containing command path after extension
|
|
138
|
+
* stripping. For example, `stash/index.ts` maps to `stash`, and root
|
|
139
|
+
* `index.ts` maps to the root command. Pass `false` to treat matching files
|
|
140
|
+
* as ordinary command names.
|
|
141
|
+
*
|
|
142
|
+
* @default `"index"`
|
|
143
|
+
*/
|
|
144
|
+
readonly entryFileName?: string | false;
|
|
145
|
+
}
|
|
78
146
|
/**
|
|
79
147
|
* Runtime hint for {@link getDefaultExtensions}.
|
|
80
148
|
*
|
|
@@ -165,8 +233,11 @@ interface RunProgramDiscoveryOptions extends RunProgramBaseOptions {
|
|
|
165
233
|
interface RunProgramStaticOptions extends RunProgramBaseOptions {
|
|
166
234
|
/**
|
|
167
235
|
* Commands to compose without file-system discovery.
|
|
236
|
+
*
|
|
237
|
+
* Pass commands that declare their own `path`, or command entries returned
|
|
238
|
+
* by {@link commandsFromModules}.
|
|
168
239
|
*/
|
|
169
|
-
readonly commands: readonly AnyStaticCommand[];
|
|
240
|
+
readonly commands: readonly (AnyStaticCommand | CommandEntry)[];
|
|
170
241
|
/**
|
|
171
242
|
* File-system discovery cannot be used together with `commands`.
|
|
172
243
|
*/
|
|
@@ -205,6 +276,23 @@ declare function getDefaultExtensions(options?: RuntimeExtensionOptions): readon
|
|
|
205
276
|
* @since 1.1.0
|
|
206
277
|
*/
|
|
207
278
|
declare function discoverCommands(options: DiscoverCommandsOptions): Promise<readonly DiscoveredCommand[]>;
|
|
279
|
+
/**
|
|
280
|
+
* Converts a static module map into command entries.
|
|
281
|
+
*
|
|
282
|
+
* This is useful for bundlers and single-file packagers that can statically
|
|
283
|
+
* see module maps, such as `import.meta.glob(..., { eager: true })`, while
|
|
284
|
+
* still deriving command paths from file-like module keys.
|
|
285
|
+
*
|
|
286
|
+
* @param modules Static module map keyed by module path.
|
|
287
|
+
* @param options Module path derivation options.
|
|
288
|
+
* @returns Command entries sorted by command path.
|
|
289
|
+
* @throws {TypeError} If options are invalid, no command modules are found,
|
|
290
|
+
* command paths are duplicated, a module does not default-export a
|
|
291
|
+
* command created with `defineCommand()`, or an explicit command
|
|
292
|
+
* `path` does not match the module-derived path.
|
|
293
|
+
* @since 1.2.0
|
|
294
|
+
*/
|
|
295
|
+
declare function commandsFromModules(modules: ModuleMap, options?: CommandsFromModulesOptions): readonly ModuleCommand[];
|
|
208
296
|
/**
|
|
209
297
|
* Builds a parser that dispatches to discovered command handlers.
|
|
210
298
|
*
|
|
@@ -215,7 +303,7 @@ declare function discoverCommands(options: DiscoverCommandsOptions): Promise<rea
|
|
|
215
303
|
* duplicated.
|
|
216
304
|
* @since 1.1.0
|
|
217
305
|
*/
|
|
218
|
-
declare function createProgramParser(commands: readonly
|
|
306
|
+
declare function createProgramParser(commands: readonly CommandEntry[], metadata?: ProgramHelpMetadata): Parser<Mode, ProgramInvocation, unknown>;
|
|
219
307
|
/**
|
|
220
308
|
* Discovers and runs a command program.
|
|
221
309
|
*
|
|
@@ -246,4 +334,4 @@ interface ProgramHelpMetadata {
|
|
|
246
334
|
readonly footer?: Message;
|
|
247
335
|
}
|
|
248
336
|
//#endregion
|
|
249
|
-
export { type AnyCommand, type AnyStaticCommand, type Command, type CommandDefinition, type CommandMetadata, type CommandPath, DiscoverCommandsOptions, DiscoveredCommand, ProgramHelpMetadata, ProgramInvocation, RunProgramDiscoveryOptions, RunProgramOptions, RunProgramStaticOptions, RuntimeExtensionOptions, type StaticCommand, createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
|
337
|
+
export { type AnyCommand, type AnyStaticCommand, type Command, type CommandDefinition, CommandEntry, type CommandMetadata, type CommandPath, CommandsFromModulesOptions, DiscoverCommandsOptions, DiscoveredCommand, ModuleCommand, ModuleMap, ProgramHelpMetadata, ProgramInvocation, RunProgramDiscoveryOptions, RunProgramOptions, RunProgramStaticOptions, RuntimeExtensionOptions, type StaticCommand, commandsFromModules, createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
package/dist/index.d.ts
CHANGED
|
@@ -28,12 +28,31 @@ interface ProgramInvocation {
|
|
|
28
28
|
*/
|
|
29
29
|
readonly handler: (value: unknown) => void | Promise<void>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* A command paired with its command path.
|
|
33
|
+
*
|
|
34
|
+
* `createProgramParser()` accepts command entries directly, and
|
|
35
|
+
* `runProgram({ commands })` accepts them alongside commands that declare
|
|
36
|
+
* their own `path` field.
|
|
37
|
+
*
|
|
38
|
+
* @since 1.2.0
|
|
39
|
+
*/
|
|
40
|
+
interface CommandEntry {
|
|
41
|
+
/**
|
|
42
|
+
* Command path used to place the command in the program tree.
|
|
43
|
+
*/
|
|
44
|
+
readonly path: CommandPath;
|
|
45
|
+
/**
|
|
46
|
+
* The command definition.
|
|
47
|
+
*/
|
|
48
|
+
readonly command: AnyCommand;
|
|
49
|
+
}
|
|
31
50
|
/**
|
|
32
51
|
* A command found on disk.
|
|
33
52
|
*
|
|
34
53
|
* @since 1.1.0
|
|
35
54
|
*/
|
|
36
|
-
interface DiscoveredCommand {
|
|
55
|
+
interface DiscoveredCommand extends CommandEntry {
|
|
37
56
|
/**
|
|
38
57
|
* Command path derived from the module's relative path.
|
|
39
58
|
*/
|
|
@@ -47,6 +66,26 @@ interface DiscoveredCommand {
|
|
|
47
66
|
*/
|
|
48
67
|
readonly command: AnyCommand;
|
|
49
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* A command loaded from a static module map.
|
|
71
|
+
*
|
|
72
|
+
* @since 1.2.0
|
|
73
|
+
*/
|
|
74
|
+
interface ModuleCommand extends CommandEntry {
|
|
75
|
+
/**
|
|
76
|
+
* Module map key used to derive the command path.
|
|
77
|
+
*/
|
|
78
|
+
readonly modulePath: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Static module map accepted by {@link commandsFromModules}.
|
|
82
|
+
*
|
|
83
|
+
* This matches eager glob import APIs such as Vite's
|
|
84
|
+
* `import.meta.glob(..., { eager: true })`.
|
|
85
|
+
*
|
|
86
|
+
* @since 1.2.0
|
|
87
|
+
*/
|
|
88
|
+
type ModuleMap = Readonly<Record<string, unknown>>;
|
|
50
89
|
/**
|
|
51
90
|
* Options for {@link discoverCommands}.
|
|
52
91
|
*
|
|
@@ -75,6 +114,35 @@ interface DiscoverCommandsOptions {
|
|
|
75
114
|
*/
|
|
76
115
|
readonly entryFileName?: string | false;
|
|
77
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Options for {@link commandsFromModules}.
|
|
119
|
+
*
|
|
120
|
+
* @since 1.2.0
|
|
121
|
+
*/
|
|
122
|
+
interface CommandsFromModulesOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Base module path to strip before deriving command paths.
|
|
125
|
+
*
|
|
126
|
+
* @default `"."`
|
|
127
|
+
*/
|
|
128
|
+
readonly base?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Module suffixes to include. Compound suffixes such as `.cmd.ts` are
|
|
131
|
+
* supported.
|
|
132
|
+
*
|
|
133
|
+
* @default Runtime-aware extension defaults from {@link getDefaultExtensions}
|
|
134
|
+
*/
|
|
135
|
+
readonly extensions?: readonly string[];
|
|
136
|
+
/**
|
|
137
|
+
* File name that maps to the containing command path after extension
|
|
138
|
+
* stripping. For example, `stash/index.ts` maps to `stash`, and root
|
|
139
|
+
* `index.ts` maps to the root command. Pass `false` to treat matching files
|
|
140
|
+
* as ordinary command names.
|
|
141
|
+
*
|
|
142
|
+
* @default `"index"`
|
|
143
|
+
*/
|
|
144
|
+
readonly entryFileName?: string | false;
|
|
145
|
+
}
|
|
78
146
|
/**
|
|
79
147
|
* Runtime hint for {@link getDefaultExtensions}.
|
|
80
148
|
*
|
|
@@ -165,8 +233,11 @@ interface RunProgramDiscoveryOptions extends RunProgramBaseOptions {
|
|
|
165
233
|
interface RunProgramStaticOptions extends RunProgramBaseOptions {
|
|
166
234
|
/**
|
|
167
235
|
* Commands to compose without file-system discovery.
|
|
236
|
+
*
|
|
237
|
+
* Pass commands that declare their own `path`, or command entries returned
|
|
238
|
+
* by {@link commandsFromModules}.
|
|
168
239
|
*/
|
|
169
|
-
readonly commands: readonly AnyStaticCommand[];
|
|
240
|
+
readonly commands: readonly (AnyStaticCommand | CommandEntry)[];
|
|
170
241
|
/**
|
|
171
242
|
* File-system discovery cannot be used together with `commands`.
|
|
172
243
|
*/
|
|
@@ -205,6 +276,23 @@ declare function getDefaultExtensions(options?: RuntimeExtensionOptions): readon
|
|
|
205
276
|
* @since 1.1.0
|
|
206
277
|
*/
|
|
207
278
|
declare function discoverCommands(options: DiscoverCommandsOptions): Promise<readonly DiscoveredCommand[]>;
|
|
279
|
+
/**
|
|
280
|
+
* Converts a static module map into command entries.
|
|
281
|
+
*
|
|
282
|
+
* This is useful for bundlers and single-file packagers that can statically
|
|
283
|
+
* see module maps, such as `import.meta.glob(..., { eager: true })`, while
|
|
284
|
+
* still deriving command paths from file-like module keys.
|
|
285
|
+
*
|
|
286
|
+
* @param modules Static module map keyed by module path.
|
|
287
|
+
* @param options Module path derivation options.
|
|
288
|
+
* @returns Command entries sorted by command path.
|
|
289
|
+
* @throws {TypeError} If options are invalid, no command modules are found,
|
|
290
|
+
* command paths are duplicated, a module does not default-export a
|
|
291
|
+
* command created with `defineCommand()`, or an explicit command
|
|
292
|
+
* `path` does not match the module-derived path.
|
|
293
|
+
* @since 1.2.0
|
|
294
|
+
*/
|
|
295
|
+
declare function commandsFromModules(modules: ModuleMap, options?: CommandsFromModulesOptions): readonly ModuleCommand[];
|
|
208
296
|
/**
|
|
209
297
|
* Builds a parser that dispatches to discovered command handlers.
|
|
210
298
|
*
|
|
@@ -215,7 +303,7 @@ declare function discoverCommands(options: DiscoverCommandsOptions): Promise<rea
|
|
|
215
303
|
* duplicated.
|
|
216
304
|
* @since 1.1.0
|
|
217
305
|
*/
|
|
218
|
-
declare function createProgramParser(commands: readonly
|
|
306
|
+
declare function createProgramParser(commands: readonly CommandEntry[], metadata?: ProgramHelpMetadata): Parser<Mode, ProgramInvocation, unknown>;
|
|
219
307
|
/**
|
|
220
308
|
* Discovers and runs a command program.
|
|
221
309
|
*
|
|
@@ -246,4 +334,4 @@ interface ProgramHelpMetadata {
|
|
|
246
334
|
readonly footer?: Message;
|
|
247
335
|
}
|
|
248
336
|
//#endregion
|
|
249
|
-
export { type AnyCommand, type AnyStaticCommand, type Command, type CommandDefinition, type CommandMetadata, type CommandPath, DiscoverCommandsOptions, DiscoveredCommand, ProgramHelpMetadata, ProgramInvocation, RunProgramDiscoveryOptions, RunProgramOptions, RunProgramStaticOptions, RuntimeExtensionOptions, type StaticCommand, createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
|
337
|
+
export { type AnyCommand, type AnyStaticCommand, type Command, type CommandDefinition, CommandEntry, type CommandMetadata, type CommandPath, CommandsFromModulesOptions, DiscoverCommandsOptions, DiscoveredCommand, ModuleCommand, ModuleMap, ProgramHelpMetadata, ProgramInvocation, RunProgramDiscoveryOptions, RunProgramOptions, RunProgramStaticOptions, RuntimeExtensionOptions, type StaticCommand, commandsFromModules, createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { command } from "@optique/core/primitives";
|
|
|
6
6
|
import { mergeHidden } from "@optique/core/usage";
|
|
7
7
|
import { runAsync } from "@optique/run";
|
|
8
8
|
import { readdir, realpath, stat } from "node:fs/promises";
|
|
9
|
-
import { relative, resolve, sep } from "node:path";
|
|
9
|
+
import { posix, relative, resolve, sep } from "node:path";
|
|
10
10
|
import process from "node:process";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
|
|
@@ -62,9 +62,8 @@ async function discoverCommands(options) {
|
|
|
62
62
|
}
|
|
63
63
|
seen.set(key, filePath);
|
|
64
64
|
const mod = await import(pathToFileURL(filePath).href);
|
|
65
|
-
const commandDefinition =
|
|
66
|
-
|
|
67
|
-
if (commandDefinition.path != null && commandPathKey(commandDefinition.path) !== commandPathKey(path)) throw new TypeError(`Module ${filePath} declares command path "${displayCommandPath(commandDefinition.path)}" but file path defines "${displayCommandPath(path)}".`);
|
|
65
|
+
const commandDefinition = commandFromModuleExport(filePath, mod.default);
|
|
66
|
+
validateDeclaredCommandPath(commandDefinition, path, filePath, "file path");
|
|
68
67
|
discovered.push({
|
|
69
68
|
path,
|
|
70
69
|
filePath,
|
|
@@ -74,6 +73,51 @@ async function discoverCommands(options) {
|
|
|
74
73
|
return sortCommands(discovered);
|
|
75
74
|
}
|
|
76
75
|
/**
|
|
76
|
+
* Converts a static module map into command entries.
|
|
77
|
+
*
|
|
78
|
+
* This is useful for bundlers and single-file packagers that can statically
|
|
79
|
+
* see module maps, such as `import.meta.glob(..., { eager: true })`, while
|
|
80
|
+
* still deriving command paths from file-like module keys.
|
|
81
|
+
*
|
|
82
|
+
* @param modules Static module map keyed by module path.
|
|
83
|
+
* @param options Module path derivation options.
|
|
84
|
+
* @returns Command entries sorted by command path.
|
|
85
|
+
* @throws {TypeError} If options are invalid, no command modules are found,
|
|
86
|
+
* command paths are duplicated, a module does not default-export a
|
|
87
|
+
* command created with `defineCommand()`, or an explicit command
|
|
88
|
+
* `path` does not match the module-derived path.
|
|
89
|
+
* @since 1.2.0
|
|
90
|
+
*/
|
|
91
|
+
function commandsFromModules(modules, options = {}) {
|
|
92
|
+
if (modules == null || typeof modules !== "object") throw new TypeError("commandsFromModules() requires a module map object.");
|
|
93
|
+
const base = normalizeModuleBase(options.base);
|
|
94
|
+
const extensions = normalizeExtensions(options.extensions ?? getDefaultExtensions());
|
|
95
|
+
const entryFileName = normalizeEntryFileName(options.entryFileName);
|
|
96
|
+
const modulePaths = Object.keys(modules).toSorted((a, b) => a.localeCompare(b));
|
|
97
|
+
const seen = /* @__PURE__ */ new Map();
|
|
98
|
+
const discovered = [];
|
|
99
|
+
for (const modulePath of modulePaths) {
|
|
100
|
+
if (isDeclarationFile(posix.basename(modulePath)) || !extensions.some((ext) => modulePath.endsWith(ext))) continue;
|
|
101
|
+
const path = commandPathFromModulePath(base, modulePath, extensions, entryFileName);
|
|
102
|
+
const key = commandPathKey(path);
|
|
103
|
+
const previous = seen.get(key);
|
|
104
|
+
if (previous != null) {
|
|
105
|
+
const displayPath = displayCommandPath(path);
|
|
106
|
+
throw new TypeError(`Duplicate command path "${displayPath}" from ${previous} and ${modulePath}.`);
|
|
107
|
+
}
|
|
108
|
+
seen.set(key, modulePath);
|
|
109
|
+
const commandDefinition = commandFromModuleExport(modulePath, modules[modulePath]);
|
|
110
|
+
validateDeclaredCommandPath(commandDefinition, path, modulePath, "module path");
|
|
111
|
+
discovered.push({
|
|
112
|
+
path,
|
|
113
|
+
modulePath,
|
|
114
|
+
command: commandDefinition
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (discovered.length < 1) throw new TypeError("No command modules found in module map.");
|
|
118
|
+
return sortCommands(discovered);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
77
121
|
* Builds a parser that dispatches to discovered command handlers.
|
|
78
122
|
*
|
|
79
123
|
* @param commands Commands to compose.
|
|
@@ -148,6 +192,11 @@ function normalizeEntryFileName(entryFileName) {
|
|
|
148
192
|
if (normalized.length < 1 || normalized.includes("/") || normalized.includes("\\")) throw new TypeError(`Command entry file name must be a non-empty file name: ${normalized}`);
|
|
149
193
|
return normalized;
|
|
150
194
|
}
|
|
195
|
+
function normalizeModuleBase(base) {
|
|
196
|
+
if (base === void 0) return ".";
|
|
197
|
+
if (typeof base !== "string" || base.length < 1) throw new TypeError(`Module base path must be a non-empty string: ${base}`);
|
|
198
|
+
return normalizeModulePath(base);
|
|
199
|
+
}
|
|
151
200
|
async function collectCommandFiles(dir, extensions, activeDirs = /* @__PURE__ */ new Set()) {
|
|
152
201
|
const canonicalDir = await realpath(dir);
|
|
153
202
|
if (activeDirs.has(canonicalDir)) return [];
|
|
@@ -180,12 +229,33 @@ function isDeclarationFile(fileName) {
|
|
|
180
229
|
return /\.d\.[cm]?ts$/.test(fileName);
|
|
181
230
|
}
|
|
182
231
|
function commandPathFromFile(rootDir, filePath, extensions, entryFileName) {
|
|
183
|
-
const
|
|
184
|
-
if (matchedExtension == null) throw new TypeError(`No configured extension matches ${filePath}.`);
|
|
185
|
-
const withoutExtension = filePath.slice(0, -matchedExtension.length);
|
|
232
|
+
const withoutExtension = stripCommandExtension(filePath, extensions);
|
|
186
233
|
const relativePath = relative(rootDir, withoutExtension);
|
|
187
234
|
const path = relativePath.split(sep).filter((segment) => segment.length > 0);
|
|
188
|
-
|
|
235
|
+
return commandPathFromSegments(path, filePath, entryFileName);
|
|
236
|
+
}
|
|
237
|
+
function commandPathFromModulePath(base, modulePath, extensions, entryFileName) {
|
|
238
|
+
const withoutExtension = stripCommandExtension(modulePath, extensions);
|
|
239
|
+
const relativePath = relativeModulePath(base, withoutExtension, modulePath);
|
|
240
|
+
const path = relativePath.split("/").filter((segment) => segment.length > 0);
|
|
241
|
+
return commandPathFromSegments(path, modulePath, entryFileName);
|
|
242
|
+
}
|
|
243
|
+
function stripCommandExtension(path, extensions) {
|
|
244
|
+
const matchedExtension = extensions.find((ext) => path.endsWith(ext));
|
|
245
|
+
if (matchedExtension == null) throw new TypeError(`No configured extension matches ${path}.`);
|
|
246
|
+
return path.slice(0, -matchedExtension.length);
|
|
247
|
+
}
|
|
248
|
+
function relativeModulePath(base, modulePath, originalModulePath) {
|
|
249
|
+
const normalizedPath = normalizeModulePath(modulePath);
|
|
250
|
+
const relativePath = posix.relative(base, normalizedPath);
|
|
251
|
+
if (relativePath.length < 1 || relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath)) throw new TypeError(`Module path ${originalModulePath} is not under base path ${base}.`);
|
|
252
|
+
return relativePath;
|
|
253
|
+
}
|
|
254
|
+
function normalizeModulePath(path) {
|
|
255
|
+
return posix.normalize(path.replaceAll("\\", "/"));
|
|
256
|
+
}
|
|
257
|
+
function commandPathFromSegments(path, source, entryFileName) {
|
|
258
|
+
if (path.length < 1) throw new TypeError(`Command module ${source} does not define a path.`);
|
|
189
259
|
if (entryFileName !== false && path[path.length - 1] === entryFileName) return path.slice(0, -1);
|
|
190
260
|
return path;
|
|
191
261
|
}
|
|
@@ -220,23 +290,38 @@ function isStaticRunProgramOptions(options) {
|
|
|
220
290
|
return hasCommands;
|
|
221
291
|
}
|
|
222
292
|
function staticCommandsToEntries(commands) {
|
|
223
|
-
return commands.map((
|
|
224
|
-
if (
|
|
225
|
-
if (!
|
|
293
|
+
return commands.map((entry) => {
|
|
294
|
+
if (isCommandEntry(entry)) return entry;
|
|
295
|
+
if (!isCommand(entry)) throw new TypeError("Static command entries must be created with defineCommand().");
|
|
296
|
+
if (!isCommandPath(entry.path)) throw new TypeError("Static command entries must declare a path.");
|
|
226
297
|
return {
|
|
227
|
-
path:
|
|
228
|
-
command:
|
|
298
|
+
path: entry.path,
|
|
299
|
+
command: entry
|
|
229
300
|
};
|
|
230
301
|
});
|
|
231
302
|
}
|
|
303
|
+
function isCommandEntry(value) {
|
|
304
|
+
return value != null && typeof value === "object" && isCommandPath(value.path) && isCommand(value.command);
|
|
305
|
+
}
|
|
232
306
|
function unwrapCommandExport(value) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
307
|
+
let current = value;
|
|
308
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
309
|
+
if (isCommand(current)) return current;
|
|
310
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
311
|
+
const nestedDefault = current.default;
|
|
312
|
+
if (Object.is(nestedDefault, current)) return void 0;
|
|
313
|
+
current = nestedDefault;
|
|
237
314
|
}
|
|
238
315
|
return void 0;
|
|
239
316
|
}
|
|
317
|
+
function commandFromModuleExport(source, value) {
|
|
318
|
+
const commandDefinition = unwrapCommandExport(value);
|
|
319
|
+
if (commandDefinition == null) throw new TypeError(`Module ${source} default export must be created with defineCommand().`);
|
|
320
|
+
return commandDefinition;
|
|
321
|
+
}
|
|
322
|
+
function validateDeclaredCommandPath(commandDefinition, path, source, sourcePathLabel) {
|
|
323
|
+
if (commandDefinition.path != null && commandPathKey(commandDefinition.path) !== commandPathKey(path)) throw new TypeError(`Module ${source} declares command path "${displayCommandPath(commandDefinition.path)}" but ${sourcePathLabel} defines "${displayCommandPath(path)}".`);
|
|
324
|
+
}
|
|
240
325
|
function buildCommandTree(commands) {
|
|
241
326
|
const root = { children: /* @__PURE__ */ new Map() };
|
|
242
327
|
for (const entry of commands) {
|
|
@@ -666,4 +751,4 @@ function isProgramInvocation(value) {
|
|
|
666
751
|
}
|
|
667
752
|
|
|
668
753
|
//#endregion
|
|
669
|
-
export { createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
|
754
|
+
export { commandsFromModules, createProgramParser, defineCommand, discoverCommands, getDefaultExtensions, isCommand, runProgram };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optique/discover",
|
|
3
|
-
"version": "1.2.0-dev.
|
|
3
|
+
"version": "1.2.0-dev.2169",
|
|
4
4
|
"description": "Runtime-aware command discovery for Optique CLI programs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"CLI",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"sideEffects": false,
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@optique/core": "1.2.0-dev.
|
|
72
|
-
"@optique/run": "1.2.0-dev.
|
|
71
|
+
"@optique/core": "1.2.0-dev.2169+209bc0fc",
|
|
72
|
+
"@optique/run": "1.2.0-dev.2169+209bc0fc"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@types/node": "^24.0.0",
|