@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 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
- > manually imported commands with `runProgram({ commands })`.
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, import commands manually and declare
83
- their paths in the command definitions:
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 { defineCommand, runProgram } from "@optique/discover";
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 addUser = defineCommand({
94
- path: ["user", "add"],
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: [addUser],
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 = unwrapCommandExport(mod.default);
89
- if (commandDefinition == null) throw new TypeError(`Module ${filePath} default export must be created with defineCommand().`);
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 matchedExtension = extensions.find((ext) => filePath.endsWith(ext));
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
- if (path.length < 1) throw new TypeError(`Command file ${filePath} does not define a path.`);
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((command$1) => {
247
- if (!require_command.isCommand(command$1)) throw new TypeError("Static command entries must be created with defineCommand().");
248
- if (!isCommandPath(command$1.path)) throw new TypeError("Static command entries must declare a path.");
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: command$1.path,
251
- command: command$1
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
- if (require_command.isCommand(value)) return value;
257
- if (value != null && typeof value === "object") {
258
- const nestedDefault = value.default;
259
- if (require_command.isCommand(nestedDefault)) return nestedDefault;
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 Pick<DiscoveredCommand, "path" | "command">[], metadata?: ProgramHelpMetadata): Parser<Mode, ProgramInvocation, unknown>;
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 Pick<DiscoveredCommand, "path" | "command">[], metadata?: ProgramHelpMetadata): Parser<Mode, ProgramInvocation, unknown>;
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 = unwrapCommandExport(mod.default);
66
- if (commandDefinition == null) throw new TypeError(`Module ${filePath} default export must be created with defineCommand().`);
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 matchedExtension = extensions.find((ext) => filePath.endsWith(ext));
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
- if (path.length < 1) throw new TypeError(`Command file ${filePath} does not define a path.`);
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((command$1) => {
224
- if (!isCommand(command$1)) throw new TypeError("Static command entries must be created with defineCommand().");
225
- if (!isCommandPath(command$1.path)) throw new TypeError("Static command entries must declare a path.");
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: command$1.path,
228
- command: command$1
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
- if (isCommand(value)) return value;
234
- if (value != null && typeof value === "object") {
235
- const nestedDefault = value.default;
236
- if (isCommand(nestedDefault)) return nestedDefault;
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.2167",
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.2167+d62b9212",
72
- "@optique/run": "1.2.0-dev.2167+d62b9212"
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",