@reliverse/rempts-core 1.6.1 → 2.3.1
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 +398 -102
- package/dist/cli.d.ts +32 -0
- package/dist/cli.js +731 -0
- package/dist/config-loader.d.ts +42 -0
- package/dist/config-loader.js +20 -0
- package/dist/config.d.ts +99 -0
- package/dist/config.js +188 -0
- package/dist/file-loader.d.ts +43 -0
- package/dist/file-loader.js +199 -0
- package/dist/global-flags.d.ts +36 -0
- package/dist/global-flags.js +36 -0
- package/dist/mod.d.ts +13 -0
- package/dist/mod.js +19 -0
- package/dist/parser.d.ts +6 -0
- package/dist/parser.js +137 -0
- package/dist/plugin/context.d.ts +13 -0
- package/dist/plugin/context.js +53 -0
- package/dist/plugin/create.d.ts +92 -0
- package/dist/plugin/create.js +61 -0
- package/dist/plugin/loader.d.ts +12 -0
- package/dist/plugin/loader.js +65 -0
- package/dist/plugin/manager.d.ts +53 -0
- package/dist/plugin/manager.js +135 -0
- package/dist/plugin/mod.d.ts +10 -0
- package/dist/plugin/mod.js +27 -0
- package/dist/plugin/store.d.ts +45 -0
- package/dist/plugin/store.js +60 -0
- package/dist/plugin/testing.d.ts +38 -0
- package/dist/plugin/testing.js +175 -0
- package/dist/plugin/types.d.ts +146 -0
- package/dist/tui/registry.d.ts +8 -0
- package/dist/tui/registry.js +10 -0
- package/dist/tui/types.d.ts +58 -0
- package/dist/tui/types.js +10 -0
- package/dist/types.d.ts +178 -0
- package/dist/types.js +25 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.js +27 -0
- package/dist/utils/merge.d.ts +13 -0
- package/dist/utils/merge.js +25 -0
- package/dist/utils/mod.d.ts +6 -0
- package/dist/utils/mod.js +2 -0
- package/dist/utils/type-helpers.d.ts +41 -0
- package/dist/utils/type-helpers.js +0 -0
- package/dist/validation.d.ts +30 -0
- package/dist/validation.js +121 -0
- package/package.json +47 -44
- package/src/cli.ts +1049 -0
- package/src/config-loader.ts +71 -0
- package/src/config.ts +270 -0
- package/src/file-loader.ts +346 -0
- package/src/global-flags.ts +50 -0
- package/src/mod.ts +74 -0
- package/src/parser.ts +212 -0
- package/src/plugin/context.ts +88 -0
- package/src/plugin/create.ts +174 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +244 -0
- package/src/plugin/mod.ts +51 -0
- package/src/plugin/store.ts +124 -0
- package/src/plugin/testing.ts +236 -0
- package/src/plugin/types.ts +206 -0
- package/src/tui/registry.ts +22 -0
- package/src/tui/types.ts +79 -0
- package/src/types.ts +285 -0
- package/src/utils/logger.ts +43 -0
- package/src/utils/merge.ts +54 -0
- package/src/utils/mod.ts +7 -0
- package/src/utils/type-helpers.ts +151 -0
- package/src/validation.ts +177 -0
- package/LICENSE +0 -21
- package/bin/core-impl/anykey/anykey-mod.d.ts +0 -12
- package/bin/core-impl/anykey/anykey-mod.js +0 -125
- package/bin/core-impl/date/date.d.ts +0 -2
- package/bin/core-impl/date/date.js +0 -236
- package/bin/core-impl/editor/editor-mod.d.ts +0 -25
- package/bin/core-impl/editor/editor-mod.js +0 -896
- package/bin/core-impl/figures/figures-mod.d.ts +0 -233
- package/bin/core-impl/figures/figures-mod.js +0 -286
- package/bin/core-impl/figures/figures.test.d.ts +0 -1
- package/bin/core-impl/figures/figures.test.js +0 -474
- package/bin/core-impl/input/confirm-prompt.d.ts +0 -5
- package/bin/core-impl/input/confirm-prompt.js +0 -173
- package/bin/core-impl/input/input-prompt.d.ts +0 -16
- package/bin/core-impl/input/input-prompt.js +0 -370
- package/bin/core-impl/launcher/_parser.d.ts +0 -2
- package/bin/core-impl/launcher/_parser.js +0 -122
- package/bin/core-impl/launcher/_utils.d.ts +0 -8
- package/bin/core-impl/launcher/_utils.js +0 -29
- package/bin/core-impl/launcher/args.d.ts +0 -3
- package/bin/core-impl/launcher/args.js +0 -89
- package/bin/core-impl/launcher/command.d.ts +0 -8
- package/bin/core-impl/launcher/command.js +0 -68
- package/bin/core-impl/launcher/launcher-mod.d.ts +0 -8
- package/bin/core-impl/launcher/launcher-mod.js +0 -34
- package/bin/core-impl/launcher/usage.d.ts +0 -3
- package/bin/core-impl/launcher/usage.js +0 -104
- package/bin/core-impl/msg-fmt/colors.d.ts +0 -30
- package/bin/core-impl/msg-fmt/colors.js +0 -42
- package/bin/core-impl/msg-fmt/logger.d.ts +0 -17
- package/bin/core-impl/msg-fmt/logger.js +0 -106
- package/bin/core-impl/msg-fmt/mapping.d.ts +0 -3
- package/bin/core-impl/msg-fmt/mapping.js +0 -49
- package/bin/core-impl/msg-fmt/messages.d.ts +0 -35
- package/bin/core-impl/msg-fmt/messages.js +0 -314
- package/bin/core-impl/msg-fmt/terminal.d.ts +0 -15
- package/bin/core-impl/msg-fmt/terminal.js +0 -59
- package/bin/core-impl/msg-fmt/variants.d.ts +0 -11
- package/bin/core-impl/msg-fmt/variants.js +0 -52
- package/bin/core-impl/next-steps/next-steps.d.ts +0 -14
- package/bin/core-impl/next-steps/next-steps.js +0 -24
- package/bin/core-impl/number/number-mod.d.ts +0 -28
- package/bin/core-impl/number/number-mod.js +0 -197
- package/bin/core-impl/results/results.d.ts +0 -7
- package/bin/core-impl/results/results.js +0 -27
- package/bin/core-impl/select/multiselect-prompt.d.ts +0 -2
- package/bin/core-impl/select/multiselect-prompt.js +0 -341
- package/bin/core-impl/select/nummultiselect-prompt.d.ts +0 -6
- package/bin/core-impl/select/nummultiselect-prompt.js +0 -105
- package/bin/core-impl/select/numselect-prompt.d.ts +0 -7
- package/bin/core-impl/select/numselect-prompt.js +0 -115
- package/bin/core-impl/select/select-prompt.d.ts +0 -33
- package/bin/core-impl/select/select-prompt.js +0 -302
- package/bin/core-impl/select/toggle-prompt.d.ts +0 -5
- package/bin/core-impl/select/toggle-prompt.js +0 -208
- package/bin/core-impl/st-end/end.d.ts +0 -2
- package/bin/core-impl/st-end/end.js +0 -42
- package/bin/core-impl/st-end/start.d.ts +0 -17
- package/bin/core-impl/st-end/start.js +0 -66
- package/bin/core-impl/task/progress.d.ts +0 -2
- package/bin/core-impl/task/progress.js +0 -57
- package/bin/core-impl/task/spinner.d.ts +0 -15
- package/bin/core-impl/task/spinner.js +0 -110
- package/bin/core-impl/utils/colorize.d.ts +0 -2
- package/bin/core-impl/utils/colorize.js +0 -134
- package/bin/core-impl/utils/errors.d.ts +0 -1
- package/bin/core-impl/utils/errors.js +0 -15
- package/bin/core-impl/utils/prevent.d.ts +0 -10
- package/bin/core-impl/utils/prevent.js +0 -69
- package/bin/core-impl/utils/prompt-end.d.ts +0 -8
- package/bin/core-impl/utils/prompt-end.js +0 -33
- package/bin/core-impl/utils/stream-text.d.ts +0 -18
- package/bin/core-impl/utils/stream-text.js +0 -136
- package/bin/core-impl/utils/system.d.ts +0 -6
- package/bin/core-impl/utils/system.js +0 -7
- package/bin/core-impl/utils/validate.d.ts +0 -22
- package/bin/core-impl/utils/validate.js +0 -17
- package/bin/core-impl/visual/animate/animate.d.ts +0 -14
- package/bin/core-impl/visual/animate/animate.js +0 -64
- package/bin/core-impl/visual/ascii-art/ascii-art.d.ts +0 -6
- package/bin/core-impl/visual/ascii-art/ascii-art.js +0 -12
- package/bin/core-types.d.ts +0 -434
- package/bin/main.d.ts +0 -41
- package/bin/main.js +0 -96
- /package/{bin/core-types.js → dist/plugin/types.js} +0 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { relico } from "@reliverse/relico";
|
|
4
|
+
import { getDotPath, SchemaError } from "@standard-schema/utils";
|
|
5
|
+
import { type RemptsConfigStrict, remptsConfigSchema, remptsConfigStrictSchema } from "./config";
|
|
6
|
+
import { type LoadedConfig, loadConfig } from "./config-loader";
|
|
7
|
+
import { createFileCommandLoader } from "./file-loader";
|
|
8
|
+
import { GLOBAL_FLAGS, type GlobalFlags } from "./global-flags";
|
|
9
|
+
import { parseArgs } from "./parser";
|
|
10
|
+
import {
|
|
11
|
+
createPluginManager,
|
|
12
|
+
loadPlugins,
|
|
13
|
+
runAfterCommand,
|
|
14
|
+
runBeforeCommand,
|
|
15
|
+
runConfigResolved,
|
|
16
|
+
runSetup,
|
|
17
|
+
} from "./plugin/manager";
|
|
18
|
+
import type { CommandContext, MergePluginStores, Plugin, PluginConfig } from "./plugin/types";
|
|
19
|
+
import { getTuiRenderer } from "./tui/registry";
|
|
20
|
+
import type {
|
|
21
|
+
AfterHook,
|
|
22
|
+
BeforeHook,
|
|
23
|
+
CLI,
|
|
24
|
+
CLIOption,
|
|
25
|
+
Command,
|
|
26
|
+
HookContext,
|
|
27
|
+
InferMergedOptions,
|
|
28
|
+
MergedOptions,
|
|
29
|
+
Options,
|
|
30
|
+
RemptsConfig,
|
|
31
|
+
ResolvedConfig,
|
|
32
|
+
RuntimeInfo,
|
|
33
|
+
TerminalInfo,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
export async function createApp<
|
|
37
|
+
TPlugins extends readonly Plugin[] = [],
|
|
38
|
+
TDefaultCommand extends string | undefined = undefined,
|
|
39
|
+
>(
|
|
40
|
+
options: {
|
|
41
|
+
/**
|
|
42
|
+
* CLI configuration override
|
|
43
|
+
*/
|
|
44
|
+
config?: Partial<RemptsConfig> & {
|
|
45
|
+
plugins?: TPlugins;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Default command to run when no arguments are provided
|
|
49
|
+
*/
|
|
50
|
+
defaultCommand?: TDefaultCommand;
|
|
51
|
+
/**
|
|
52
|
+
* Whether to auto-initialize commands from config
|
|
53
|
+
* @default true
|
|
54
|
+
*/
|
|
55
|
+
autoInit?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Custom config directory (overrides --cwd detection)
|
|
58
|
+
*/
|
|
59
|
+
configDir?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Entry file path (e.g., import.meta.path or __filename)
|
|
62
|
+
* If not provided, will be auto-detected from call stack
|
|
63
|
+
* Commands directory will be <entry-file-dir>/cmds
|
|
64
|
+
*/
|
|
65
|
+
entryFile?: string;
|
|
66
|
+
} = {}
|
|
67
|
+
): Promise<CLI<MergePluginStores<TPlugins>>> {
|
|
68
|
+
const {
|
|
69
|
+
config: configOverride,
|
|
70
|
+
defaultCommand,
|
|
71
|
+
autoInit = true,
|
|
72
|
+
configDir: customConfigDir,
|
|
73
|
+
entryFile,
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
// Auto-detect config directory from --cwd flag or current directory
|
|
77
|
+
let configDir = customConfigDir || process.cwd();
|
|
78
|
+
|
|
79
|
+
if (!customConfigDir) {
|
|
80
|
+
// Parse --cwd flag before loading config
|
|
81
|
+
const args = process.argv.slice(2);
|
|
82
|
+
const cwdIndex = args.indexOf("--cwd");
|
|
83
|
+
if (cwdIndex !== -1 && cwdIndex + 1 < args.length && args[cwdIndex + 1]) {
|
|
84
|
+
const cwdArg = args[cwdIndex + 1] as string;
|
|
85
|
+
configDir = cwdArg.startsWith("/") ? cwdArg : resolve(process.cwd(), cwdArg);
|
|
86
|
+
// Remove --cwd and its value from args
|
|
87
|
+
args.splice(cwdIndex, 2);
|
|
88
|
+
// Update process.argv to reflect the changes
|
|
89
|
+
process.argv = [process.argv[0] || "", process.argv[1] || "", ...args];
|
|
90
|
+
} else {
|
|
91
|
+
// No --cwd provided, try to auto-discover config location
|
|
92
|
+
try {
|
|
93
|
+
// First try current directory
|
|
94
|
+
await loadConfig(process.cwd());
|
|
95
|
+
configDir = process.cwd();
|
|
96
|
+
} catch {
|
|
97
|
+
// If not found, try apps/dler relative to current directory (monorepo support)
|
|
98
|
+
const monorepoConfigDir = resolve(process.cwd(), "apps/dler");
|
|
99
|
+
try {
|
|
100
|
+
await loadConfig(monorepoConfigDir);
|
|
101
|
+
configDir = monorepoConfigDir;
|
|
102
|
+
} catch {
|
|
103
|
+
// If still not found, keep original cwd (will fail with proper error message)
|
|
104
|
+
configDir = process.cwd();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Load config from the detected directory
|
|
111
|
+
let loadedConfigData: LoadedConfig | null = null;
|
|
112
|
+
try {
|
|
113
|
+
loadedConfigData = await loadConfig(configDir);
|
|
114
|
+
} catch {
|
|
115
|
+
// Config not found, will use override only
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Create final config by merging loaded config with override
|
|
119
|
+
const finalConfigOverride = (() => {
|
|
120
|
+
// Start with loaded config or override
|
|
121
|
+
let baseConfig = loadedConfigData || configOverride;
|
|
122
|
+
|
|
123
|
+
if (loadedConfigData && configOverride) {
|
|
124
|
+
// Merge loaded config with override, ensuring plugins type compatibility
|
|
125
|
+
const { plugins: overridePlugins, ...overrideRest } = configOverride;
|
|
126
|
+
baseConfig = {
|
|
127
|
+
...loadedConfigData,
|
|
128
|
+
...overrideRest,
|
|
129
|
+
// Override plugins only if explicitly provided in configOverride
|
|
130
|
+
...(overridePlugins !== undefined ? { plugins: overridePlugins as any } : {}),
|
|
131
|
+
} as any;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve relative paths in config to absolute paths based on config directory
|
|
135
|
+
function resolveConfigPaths(obj: any, baseDir: string): any {
|
|
136
|
+
if (typeof obj === "string" && obj.startsWith("./")) {
|
|
137
|
+
return resolve(baseDir, obj);
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(obj)) {
|
|
140
|
+
return obj.map((item) => resolveConfigPaths(item, baseDir));
|
|
141
|
+
}
|
|
142
|
+
if (obj && typeof obj === "object") {
|
|
143
|
+
const result: any = {};
|
|
144
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
145
|
+
result[key] = resolveConfigPaths(value, baseDir);
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
return obj;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (baseConfig) {
|
|
153
|
+
baseConfig = resolveConfigPaths(baseConfig, configDir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Ensure commands directory is resolved relative to configDir if it exists
|
|
157
|
+
if (baseConfig?.commands?.directory && typeof baseConfig.commands.directory === "string") {
|
|
158
|
+
(baseConfig as any).commands.directory = baseConfig.commands.directory.startsWith(".")
|
|
159
|
+
? resolve(configDir, baseConfig.commands.directory)
|
|
160
|
+
: baseConfig.commands.directory;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return baseConfig as
|
|
164
|
+
| (Partial<RemptsConfig> & {
|
|
165
|
+
plugins?: TPlugins;
|
|
166
|
+
})
|
|
167
|
+
| undefined;
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
const cli = await createCLI(finalConfigOverride || {}, entryFile);
|
|
171
|
+
|
|
172
|
+
// Load commands from directory (if autoInit)
|
|
173
|
+
if (autoInit) {
|
|
174
|
+
await cli.init();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle default command injection
|
|
178
|
+
if (defaultCommand) {
|
|
179
|
+
const originalRun = cli.run.bind(cli);
|
|
180
|
+
cli.run = async (argv = process.argv.slice(2)) => {
|
|
181
|
+
// If no arguments or only flags, inject default command
|
|
182
|
+
if (argv.length === 0 || argv[0]?.startsWith("-")) {
|
|
183
|
+
process.argv.splice(2, 0, defaultCommand);
|
|
184
|
+
argv = [defaultCommand, ...argv];
|
|
185
|
+
}
|
|
186
|
+
// If first arg is not a flag and not the default command, it's a project name - inject default command
|
|
187
|
+
else if (argv[0] && !argv[0].startsWith("-") && argv[0] !== defaultCommand) {
|
|
188
|
+
process.argv.splice(2, 0, defaultCommand);
|
|
189
|
+
argv = [defaultCommand, ...argv];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return originalRun(argv);
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return cli;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get entry file path from call stack
|
|
201
|
+
* Tries to find the first file that's not in rempts-core
|
|
202
|
+
*/
|
|
203
|
+
function getEntryFileFromStack(): string | undefined {
|
|
204
|
+
try {
|
|
205
|
+
const stack = new Error("Stack trace").stack;
|
|
206
|
+
if (!stack) return undefined;
|
|
207
|
+
|
|
208
|
+
const lines = stack.split("\n");
|
|
209
|
+
// Skip first line (Error message) and second line (this function)
|
|
210
|
+
for (let i = 2; i < lines.length; i++) {
|
|
211
|
+
const line = lines[i];
|
|
212
|
+
if (!line) continue;
|
|
213
|
+
|
|
214
|
+
// Match file paths in stack traces
|
|
215
|
+
// Bun format: at function (file:///path/to/file.ts:line:col)
|
|
216
|
+
// Node format: at function (/path/to/file.ts:line:col)
|
|
217
|
+
const match = line.match(/\(?(file:\/\/\/?|)([^:]+\.(ts|js|mjs)):\d+:\d+\)?/);
|
|
218
|
+
if (match) {
|
|
219
|
+
const filePath = match[2];
|
|
220
|
+
if (filePath && !filePath.includes("rempts-core") && !filePath.includes("node_modules")) {
|
|
221
|
+
// Convert file:// URL to path if needed
|
|
222
|
+
if (filePath.startsWith("file://")) {
|
|
223
|
+
return fileURLToPath(filePath);
|
|
224
|
+
}
|
|
225
|
+
return filePath;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (_error) {
|
|
230
|
+
// Ignore errors in stack parsing
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function createCLI<TPlugins extends readonly Plugin[] = []>(
|
|
236
|
+
configOverride?: Partial<RemptsConfig> & {
|
|
237
|
+
plugins?: TPlugins;
|
|
238
|
+
},
|
|
239
|
+
entryFile?: string
|
|
240
|
+
): Promise<CLI<MergePluginStores<TPlugins>>> {
|
|
241
|
+
type TStore = MergePluginStores<TPlugins>;
|
|
242
|
+
|
|
243
|
+
// Auto-load config from dler.config.ts (optional)
|
|
244
|
+
let loadedConfigData: LoadedConfig | null = null;
|
|
245
|
+
try {
|
|
246
|
+
loadedConfigData = await loadConfig();
|
|
247
|
+
} catch {
|
|
248
|
+
// Config file is optional - if not found, use override or defaults
|
|
249
|
+
loadedConfigData = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Use loaded config or create from override
|
|
253
|
+
let baseConfig =
|
|
254
|
+
loadedConfigData || (remptsConfigSchema.assert(configOverride || {}) as LoadedConfig);
|
|
255
|
+
|
|
256
|
+
// Determine commands directory from entry file
|
|
257
|
+
// Commands directory is always <entry-file-dir>/cmds
|
|
258
|
+
let cmdsDir: string;
|
|
259
|
+
const detectedEntryFile = entryFile || getEntryFileFromStack();
|
|
260
|
+
|
|
261
|
+
if (detectedEntryFile) {
|
|
262
|
+
// Resolve entry file to absolute path
|
|
263
|
+
const entryFilePath = detectedEntryFile.startsWith("file://")
|
|
264
|
+
? fileURLToPath(detectedEntryFile)
|
|
265
|
+
: resolve(detectedEntryFile);
|
|
266
|
+
const entryDir = dirname(entryFilePath);
|
|
267
|
+
cmdsDir = join(entryDir, "cmds");
|
|
268
|
+
} else {
|
|
269
|
+
// Fallback: use config if provided, otherwise use process.cwd()/cmds
|
|
270
|
+
if (baseConfig.commands?.directory) {
|
|
271
|
+
cmdsDir = resolve(baseConfig.commands.directory);
|
|
272
|
+
} else {
|
|
273
|
+
cmdsDir = join(process.cwd(), "cmds");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Override commands directory in config (entry file takes precedence)
|
|
278
|
+
baseConfig = {
|
|
279
|
+
...baseConfig,
|
|
280
|
+
commands: {
|
|
281
|
+
...baseConfig.commands,
|
|
282
|
+
directory: cmdsDir,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const loadedConfig: RemptsConfig = baseConfig;
|
|
287
|
+
|
|
288
|
+
// Merge override config on top of loaded config
|
|
289
|
+
const mergedConfig = {
|
|
290
|
+
...loadedConfig,
|
|
291
|
+
...configOverride,
|
|
292
|
+
// Deep merge plugins arrays
|
|
293
|
+
plugins: configOverride?.plugins || loadedConfig.plugins || [],
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Validate and coerce config - only require name/version if they are explicitly provided
|
|
297
|
+
let fullConfig: RemptsConfigStrict;
|
|
298
|
+
try {
|
|
299
|
+
// If name and version are not provided, create a minimal config with defaults
|
|
300
|
+
if (mergedConfig.name && mergedConfig.version) {
|
|
301
|
+
fullConfig = remptsConfigStrictSchema.assert(mergedConfig);
|
|
302
|
+
} else {
|
|
303
|
+
const minimalConfig = {
|
|
304
|
+
name: mergedConfig.name || "cli",
|
|
305
|
+
version: mergedConfig.version || "1.0.0",
|
|
306
|
+
...mergedConfig,
|
|
307
|
+
};
|
|
308
|
+
fullConfig = remptsConfigStrictSchema.assert(minimalConfig);
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
"[rempts] Invalid config: " + (error instanceof Error ? error.message : String(error))
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const commands = new Map<string, Command<any, any>>();
|
|
317
|
+
const commandSources = new Map<string, "directory">(); // Track where commands come from
|
|
318
|
+
// Prefix tree for fast subcommand discovery: parentName -> Set<subcommandFullName>
|
|
319
|
+
const subcommandIndex = new Map<string, Set<string>>();
|
|
320
|
+
|
|
321
|
+
// Global before/after hooks
|
|
322
|
+
const beforeHooks: BeforeHook<TStore>[] = [];
|
|
323
|
+
const afterHooks: AfterHook<TStore>[] = [];
|
|
324
|
+
|
|
325
|
+
// Global hook context storage (shared across commands)
|
|
326
|
+
let globalHookContext: Record<string, any> = {};
|
|
327
|
+
|
|
328
|
+
// Helper to get terminal information
|
|
329
|
+
function getTerminalInfo(): TerminalInfo {
|
|
330
|
+
const isInteractive = process.stdout.isTTY;
|
|
331
|
+
const isCI = !!(
|
|
332
|
+
process.env.CI ||
|
|
333
|
+
process.env.CONTINUOUS_INTEGRATION ||
|
|
334
|
+
process.env.GITHUB_ACTIONS ||
|
|
335
|
+
process.env.GITLAB_CI ||
|
|
336
|
+
process.env.CIRCLECI ||
|
|
337
|
+
process.env.TRAVIS
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
width: process.stdout.columns || 80,
|
|
342
|
+
height: process.stdout.rows || 24,
|
|
343
|
+
isInteractive,
|
|
344
|
+
isCI,
|
|
345
|
+
supportsColor: isInteractive && !isCI && process.env.TERM !== "dumb",
|
|
346
|
+
supportsMouse: isInteractive && !isCI && process.env.TERM_PROGRAM !== "Apple_Terminal",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const pluginManagerState = createPluginManager<TStore>();
|
|
350
|
+
|
|
351
|
+
// Load plugins if configured
|
|
352
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0) {
|
|
353
|
+
await loadPlugins(pluginManagerState, mergedConfig.plugins as any as PluginConfig[]);
|
|
354
|
+
|
|
355
|
+
// Run setup hooks - this may modify config
|
|
356
|
+
const { config: updatedConfig, commands: pluginCommands } = await runSetup(
|
|
357
|
+
pluginManagerState,
|
|
358
|
+
fullConfig
|
|
359
|
+
);
|
|
360
|
+
// Re-validate after plugins potentially modified config
|
|
361
|
+
fullConfig = remptsConfigStrictSchema.assert(updatedConfig);
|
|
362
|
+
|
|
363
|
+
// Register plugin commands (if any)
|
|
364
|
+
// Note: Since @reliverse/rempts is file-based only, plugins should register commands from files
|
|
365
|
+
// Plugin commands are deprecated - use file-based commands instead
|
|
366
|
+
if (pluginCommands.length > 0) {
|
|
367
|
+
console.warn(
|
|
368
|
+
"Warning: Plugin command registration is deprecated. " +
|
|
369
|
+
"Rempts is file-based only - register commands via file structure: <cmds-dir>/<cmd-name>/cmd.{ts,js,mjs}"
|
|
370
|
+
);
|
|
371
|
+
// For backward compatibility, try to extract name from command metadata
|
|
372
|
+
// But this won't work reliably since commands don't have names anymore
|
|
373
|
+
pluginCommands.forEach((cmd) => {
|
|
374
|
+
// Try to get name from command metadata or use a fallback
|
|
375
|
+
const cmdName = (cmd as any).name || "unknown";
|
|
376
|
+
if (cmdName === "unknown") {
|
|
377
|
+
console.warn("Skipping plugin command without name - use file-based commands instead");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
registerCommand(cmdName, cmd, [], "directory");
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Create resolved config with defaults
|
|
386
|
+
const resolvedConfig: ResolvedConfig = {
|
|
387
|
+
name: fullConfig.name,
|
|
388
|
+
version: fullConfig.version,
|
|
389
|
+
description: fullConfig.description || "",
|
|
390
|
+
commands: fullConfig.commands || {},
|
|
391
|
+
build: fullConfig.build || {
|
|
392
|
+
targets: ["native"],
|
|
393
|
+
compress: false,
|
|
394
|
+
minify: false,
|
|
395
|
+
sourcemap: true,
|
|
396
|
+
},
|
|
397
|
+
dev: fullConfig.dev || {
|
|
398
|
+
watch: true,
|
|
399
|
+
inspect: false,
|
|
400
|
+
},
|
|
401
|
+
test: fullConfig.test || {
|
|
402
|
+
pattern: ["**/*.test.ts", "**/*.spec.ts"],
|
|
403
|
+
coverage: false,
|
|
404
|
+
watch: false,
|
|
405
|
+
},
|
|
406
|
+
workspace: fullConfig.workspace || {
|
|
407
|
+
versionStrategy: "fixed",
|
|
408
|
+
},
|
|
409
|
+
release: fullConfig.release || {
|
|
410
|
+
npm: true,
|
|
411
|
+
github: false,
|
|
412
|
+
tagFormat: "v{{version}}",
|
|
413
|
+
conventionalCommits: true,
|
|
414
|
+
},
|
|
415
|
+
plugins: fullConfig.plugins || [],
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Run configResolved hooks
|
|
419
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0) {
|
|
420
|
+
await runConfigResolved(pluginManagerState, resolvedConfig);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Helper to register a command and its aliases
|
|
424
|
+
// Commands are file-based only - name is always inferred from file path
|
|
425
|
+
function registerCommand(
|
|
426
|
+
name: string,
|
|
427
|
+
cmd: Command<any, any>,
|
|
428
|
+
path: string[] = [],
|
|
429
|
+
source: "directory" = "directory"
|
|
430
|
+
) {
|
|
431
|
+
const fullName = [...path, name].join(" ");
|
|
432
|
+
|
|
433
|
+
// Skip if command already exists
|
|
434
|
+
// This prevents conflicts when the same command is registered from multiple sources
|
|
435
|
+
// (e.g., file loading or duplicate files)
|
|
436
|
+
// File-loaded commands take precedence
|
|
437
|
+
if (commands.has(fullName)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
commands.set(fullName, cmd);
|
|
442
|
+
commandSources.set(fullName, source);
|
|
443
|
+
|
|
444
|
+
// Update subcommand index for fast subcommand discovery
|
|
445
|
+
// Performance: O(depth) where depth is command nesting level
|
|
446
|
+
// For "a b c", add it to indices for "a" and "a b"
|
|
447
|
+
// This enables O(1) lookup of parent -> Set<subcommands> instead of O(n) scan
|
|
448
|
+
const nameParts = fullName.split(" ");
|
|
449
|
+
// Skip empty parts (shouldn't happen, but be defensive)
|
|
450
|
+
const validParts = nameParts.filter((part) => part.length > 0);
|
|
451
|
+
for (let i = 0; i < validParts.length - 1; i++) {
|
|
452
|
+
const parentName = validParts.slice(0, i + 1).join(" ");
|
|
453
|
+
if (!subcommandIndex.has(parentName)) {
|
|
454
|
+
subcommandIndex.set(parentName, new Set());
|
|
455
|
+
}
|
|
456
|
+
subcommandIndex.get(parentName)!.add(fullName);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Register aliases
|
|
460
|
+
if (cmd.alias) {
|
|
461
|
+
const aliases = Array.isArray(cmd.alias) ? cmd.alias : [cmd.alias];
|
|
462
|
+
aliases.forEach((alias) => {
|
|
463
|
+
const aliasPath = [...path, alias].join(" ");
|
|
464
|
+
// Skip if alias already exists (prevents duplicate registration)
|
|
465
|
+
if (commands.has(aliasPath)) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
commands.set(aliasPath, cmd);
|
|
469
|
+
commandSources.set(aliasPath, source);
|
|
470
|
+
|
|
471
|
+
// Update subcommand index for aliases so they can discover subcommands too
|
|
472
|
+
// If this command has subcommands, make them discoverable via alias
|
|
473
|
+
const aliasParts = aliasPath.split(" ").filter((part) => part.length > 0);
|
|
474
|
+
for (let i = 0; i < aliasParts.length - 1; i++) {
|
|
475
|
+
const parentName = aliasParts.slice(0, i + 1).join(" ");
|
|
476
|
+
if (!subcommandIndex.has(parentName)) {
|
|
477
|
+
subcommandIndex.set(parentName, new Set());
|
|
478
|
+
}
|
|
479
|
+
// Add the original command name to the alias parent's index
|
|
480
|
+
subcommandIndex.get(parentName)!.add(fullName);
|
|
481
|
+
}
|
|
482
|
+
// If this command itself has subcommands, add them to alias index
|
|
483
|
+
const subcommandNames = subcommandIndex.get(fullName);
|
|
484
|
+
if (subcommandNames) {
|
|
485
|
+
if (!subcommandIndex.has(aliasPath)) {
|
|
486
|
+
subcommandIndex.set(aliasPath, new Set());
|
|
487
|
+
}
|
|
488
|
+
// Copy all subcommands to the alias index
|
|
489
|
+
for (const subCmdName of subcommandNames) {
|
|
490
|
+
subcommandIndex.get(aliasPath)!.add(subCmdName);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Note: Nested commands are already registered individually from files
|
|
497
|
+
// Parent commands just group them together - no need to re-register
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Helper to find command by path
|
|
501
|
+
// Returns command name (from map key) and command object
|
|
502
|
+
// Performance: O(depth) where depth is the number of command parts
|
|
503
|
+
// Uses Map.get() which is O(1), so total is O(depth) - optimal for arbitrary nesting
|
|
504
|
+
function findCommand(args: string[]): {
|
|
505
|
+
command: Command<any, any> | undefined;
|
|
506
|
+
commandName: string | undefined;
|
|
507
|
+
remainingArgs: string[];
|
|
508
|
+
} {
|
|
509
|
+
// Try to find the deepest matching command
|
|
510
|
+
// Start from longest path (most specific) and work backwards
|
|
511
|
+
// This ensures we match "a b c" before "a b" when args = ["a", "b", "c"]
|
|
512
|
+
for (let i = args.length; i > 0; i--) {
|
|
513
|
+
const cmdPath = args.slice(0, i).join(" ");
|
|
514
|
+
const command = commands.get(cmdPath);
|
|
515
|
+
if (command) {
|
|
516
|
+
return { command, commandName: cmdPath, remainingArgs: args.slice(i) };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { command: undefined, commandName: undefined, remainingArgs: args };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Helper to show help for a command
|
|
523
|
+
// Name comes from the commands map key (inferred from file path)
|
|
524
|
+
function showHelp(cmdName?: string, cmd?: Command<any, TStore>, path: string[] = []) {
|
|
525
|
+
if (cmd && cmdName) {
|
|
526
|
+
// Show command-specific help
|
|
527
|
+
const fullPath = [...path, cmdName].join(" ");
|
|
528
|
+
console.log(relico.bold(`Usage: ${fullConfig.name} ${fullPath} [options]`));
|
|
529
|
+
console.log(`\n${relico.dim(cmd.description)}`);
|
|
530
|
+
|
|
531
|
+
if (cmd.options && Object.keys(cmd.options).length > 0) {
|
|
532
|
+
console.log(`\n${relico.bold("Options:")}`);
|
|
533
|
+
for (const [name, opt] of Object.entries(cmd.options)) {
|
|
534
|
+
const option = opt as CLIOption<any>;
|
|
535
|
+
const flag = `--${name}${option.short ? `, -${option.short}` : ""}`;
|
|
536
|
+
const description = option.description || "";
|
|
537
|
+
console.log(` ${relico.yellow(flag.padEnd(20))} ${relico.dim(description)}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Discover subcommands using prefix tree index
|
|
542
|
+
// Performance: O(k) where k is the number of direct subcommands (not all commands)
|
|
543
|
+
// Much faster than scanning all commands O(n) where n is total command count
|
|
544
|
+
const subcommandNames = subcommandIndex.get(fullPath);
|
|
545
|
+
const subCommands: Array<{ name: string; command: Command<any, any> }> = [];
|
|
546
|
+
if (subcommandNames) {
|
|
547
|
+
const parentDepth = fullPath.split(" ").length;
|
|
548
|
+
for (const subCmdFullName of subcommandNames) {
|
|
549
|
+
// Only include direct children (depth = parentDepth + 1)
|
|
550
|
+
// This filters out grandchildren like "a b c d" when parent is "a b"
|
|
551
|
+
if (subCmdFullName.split(" ").length === parentDepth + 1) {
|
|
552
|
+
const subCmdName = subCmdFullName.slice(fullPath.length + 1);
|
|
553
|
+
const command = commands.get(subCmdFullName);
|
|
554
|
+
if (command) {
|
|
555
|
+
subCommands.push({ name: subCmdName, command });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (subCommands.length > 0) {
|
|
562
|
+
console.log(`\n${relico.bold("Subcommands:")}`);
|
|
563
|
+
for (const { name: subCmdName, command: subCmd } of subCommands) {
|
|
564
|
+
console.log(` ${relico.green(subCmdName.padEnd(20))} ${relico.dim(subCmd.description)}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
// Show root help
|
|
569
|
+
console.log(relico.bold(relico.cyan(`${fullConfig.name} v${fullConfig.version}`)));
|
|
570
|
+
if (fullConfig.description) {
|
|
571
|
+
console.log(relico.dim(fullConfig.description));
|
|
572
|
+
}
|
|
573
|
+
console.log(`\n${relico.bold("Commands:")}`);
|
|
574
|
+
|
|
575
|
+
// Show only top-level commands (names that don't contain spaces)
|
|
576
|
+
for (const [name, command] of commands) {
|
|
577
|
+
if (!(name.includes(" ") || command.alias?.includes(name))) {
|
|
578
|
+
console.log(` ${relico.green(name.padEnd(20))} ${relico.dim(command.description)}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function ensureRenderAvailable(commandName: string, command: Command<any, any>) {
|
|
585
|
+
if (!command.render) {
|
|
586
|
+
throw new Error(`Command ${commandName} does not support TUI rendering.`);
|
|
587
|
+
}
|
|
588
|
+
if (!getTuiRenderer()) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`TUI renderer not registered. Import '@reliverse/rempts-tui/register' or call registerTuiRenderer before running commands with render.`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Auto-load commands from config if specified
|
|
596
|
+
async function loadFromConfig() {
|
|
597
|
+
// Load from directory if specified
|
|
598
|
+
if (fullConfig.commands?.directory) {
|
|
599
|
+
try {
|
|
600
|
+
// Use the already resolved commands directory
|
|
601
|
+
const cmdsDir = fullConfig.commands.directory;
|
|
602
|
+
|
|
603
|
+
const fileLoader = createFileCommandLoader();
|
|
604
|
+
const commandTree = await fileLoader.loadFromDirectory(cmdsDir);
|
|
605
|
+
const fileCommands = await fileLoader.loadCommandsFromTree(commandTree);
|
|
606
|
+
|
|
607
|
+
// Register all commands from the directory structure
|
|
608
|
+
// Name is inferred from file path: <cmds-dir>/<cmd-name>/cmd.{ts,js,mjs}
|
|
609
|
+
fileCommands.forEach(({ name, command }) => {
|
|
610
|
+
registerCommand(name, command, [], "directory");
|
|
611
|
+
});
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.error(
|
|
614
|
+
`Failed to load commands from directory ${fullConfig.commands.directory}:`,
|
|
615
|
+
error
|
|
616
|
+
);
|
|
617
|
+
throw error; // Re-throw to prevent CLI from starting with invalid config
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function runCommandInternal(
|
|
623
|
+
commandName: string,
|
|
624
|
+
command: Command<any, any>,
|
|
625
|
+
argv: string[],
|
|
626
|
+
providedFlags?: Record<string, unknown>
|
|
627
|
+
) {
|
|
628
|
+
let context: CommandContext<any> | undefined;
|
|
629
|
+
let resultParsed: { flags: unknown; positional: string[] } | undefined;
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const mergedOptions = {
|
|
633
|
+
...GLOBAL_FLAGS,
|
|
634
|
+
...(command.options || {}),
|
|
635
|
+
} as MergedOptions<(typeof command.options & Options) | Options>;
|
|
636
|
+
const parsed = providedFlags
|
|
637
|
+
? (() => {
|
|
638
|
+
// Parse with empty args for defaults, then overlay provided flags
|
|
639
|
+
// This keeps behavior consistent with execute(options)
|
|
640
|
+
return parseArgs([], mergedOptions, commandName).then(
|
|
641
|
+
(p) => (Object.assign(p.flags, providedFlags), p)
|
|
642
|
+
);
|
|
643
|
+
})()
|
|
644
|
+
: parseArgs(argv, mergedOptions, commandName);
|
|
645
|
+
resultParsed = await parsed;
|
|
646
|
+
const { prompt, spinner } = await import("@reliverse/rempts-utils");
|
|
647
|
+
|
|
648
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0) {
|
|
649
|
+
context = await runBeforeCommand(
|
|
650
|
+
pluginManagerState,
|
|
651
|
+
commandName,
|
|
652
|
+
command,
|
|
653
|
+
providedFlags ? [] : resultParsed.positional,
|
|
654
|
+
resultParsed.flags as Record<string, any>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Run global before hooks
|
|
659
|
+
if (beforeHooks.length > 0) {
|
|
660
|
+
// Reset global hook context for this command
|
|
661
|
+
globalHookContext = {};
|
|
662
|
+
|
|
663
|
+
const hookContext: HookContext<TStore> = {
|
|
664
|
+
flags: resultParsed.flags as Record<string, unknown>,
|
|
665
|
+
store: context?.store?.getState() || ({} as TStore),
|
|
666
|
+
env: process.env,
|
|
667
|
+
cwd: process.cwd(),
|
|
668
|
+
set: (key: string, value: any) => {
|
|
669
|
+
globalHookContext[key] = value;
|
|
670
|
+
},
|
|
671
|
+
get: (key: string) => {
|
|
672
|
+
return globalHookContext[key];
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
for (const hook of beforeHooks) {
|
|
677
|
+
await hook(hookContext);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const terminalInfo = getTerminalInfo();
|
|
682
|
+
const globalFlags = resultParsed.flags as GlobalFlags & Record<string, unknown>;
|
|
683
|
+
const runtimeInfo: RuntimeInfo = {
|
|
684
|
+
startTime: Date.now(),
|
|
685
|
+
args: providedFlags ? [] : argv,
|
|
686
|
+
command: commandName,
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
let render = false;
|
|
690
|
+
if (command.render) {
|
|
691
|
+
if ((globalFlags as Record<string, unknown>)["no-tui"]) {
|
|
692
|
+
render = false;
|
|
693
|
+
} else if (
|
|
694
|
+
(globalFlags as Record<string, unknown>).tui ||
|
|
695
|
+
(globalFlags as Record<string, unknown>).interactive
|
|
696
|
+
) {
|
|
697
|
+
render = true;
|
|
698
|
+
} else {
|
|
699
|
+
render = terminalInfo.isInteractive && !terminalInfo.isCI;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (render) {
|
|
704
|
+
ensureRenderAvailable(commandName, command);
|
|
705
|
+
await getTuiRenderer<Record<string, unknown>, TStore>()?.({
|
|
706
|
+
command,
|
|
707
|
+
flags: resultParsed.flags as Record<string, unknown>,
|
|
708
|
+
positional: resultParsed.positional,
|
|
709
|
+
shell: Bun.$,
|
|
710
|
+
env: process.env,
|
|
711
|
+
cwd: process.cwd(),
|
|
712
|
+
prompt,
|
|
713
|
+
spinner,
|
|
714
|
+
colors: relico,
|
|
715
|
+
terminal: terminalInfo,
|
|
716
|
+
runtime: runtimeInfo,
|
|
717
|
+
...(context ? { context } : {}),
|
|
718
|
+
...(Object.keys(globalHookContext).length > 0 ? { hooks: globalHookContext } : {}),
|
|
719
|
+
});
|
|
720
|
+
} else {
|
|
721
|
+
if (!command.handler) {
|
|
722
|
+
throw new Error("Command does not provide a handler for non-TUI execution");
|
|
723
|
+
}
|
|
724
|
+
// Type assertion: flags are validated and typed by parseArgs
|
|
725
|
+
const typedFlags = resultParsed.flags as InferMergedOptions<Options>;
|
|
726
|
+
await command.handler({
|
|
727
|
+
flags: typedFlags,
|
|
728
|
+
positional: resultParsed.positional,
|
|
729
|
+
shell: Bun.$,
|
|
730
|
+
env: process.env,
|
|
731
|
+
cwd: process.cwd(),
|
|
732
|
+
prompt,
|
|
733
|
+
spinner,
|
|
734
|
+
colors: relico,
|
|
735
|
+
terminal: terminalInfo,
|
|
736
|
+
runtime: runtimeInfo,
|
|
737
|
+
...(context ? { context } : {}),
|
|
738
|
+
...(Object.keys(globalHookContext).length > 0 ? { hooks: globalHookContext } : {}),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0 && context) {
|
|
743
|
+
await runAfterCommand(pluginManagerState, context, { exitCode: 0 });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Run global after hooks
|
|
747
|
+
if (afterHooks.length > 0) {
|
|
748
|
+
const hookContext: HookContext<TStore> & { exitCode: number } = {
|
|
749
|
+
flags: resultParsed.flags as Record<string, unknown>,
|
|
750
|
+
store: context?.store?.getState() || ({} as TStore),
|
|
751
|
+
env: process.env,
|
|
752
|
+
cwd: process.cwd(),
|
|
753
|
+
set: () => {}, // Not used in after hooks
|
|
754
|
+
get: () => undefined, // Not used in after hooks
|
|
755
|
+
exitCode: 0,
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
for (const hook of afterHooks) {
|
|
759
|
+
await hook(hookContext);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
} catch (error) {
|
|
763
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0 && context) {
|
|
764
|
+
await runAfterCommand(pluginManagerState, context, { exitCode: 1 });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Run global after hooks on error
|
|
768
|
+
if (afterHooks.length > 0) {
|
|
769
|
+
const hookContext: HookContext<TStore> & {
|
|
770
|
+
exitCode: number;
|
|
771
|
+
error?: Error;
|
|
772
|
+
} = {
|
|
773
|
+
flags: (resultParsed?.flags as Record<string, unknown> | undefined) || {},
|
|
774
|
+
store: context?.store?.getState() || ({} as TStore),
|
|
775
|
+
env: process.env,
|
|
776
|
+
cwd: process.cwd(),
|
|
777
|
+
set: () => {}, // Not used in after hooks
|
|
778
|
+
get: () => undefined, // Not used in after hooks
|
|
779
|
+
exitCode: 1,
|
|
780
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
for (const hook of afterHooks) {
|
|
784
|
+
await hook(hookContext);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (error instanceof SchemaError) {
|
|
789
|
+
console.error(relico.red("Validation Error:"));
|
|
790
|
+
const generalErrors: string[] = [];
|
|
791
|
+
const fieldErrors: Record<string, string[]> = {};
|
|
792
|
+
for (const issue of error.issues) {
|
|
793
|
+
const path = getDotPath(issue);
|
|
794
|
+
if (path) {
|
|
795
|
+
if (!fieldErrors[path]) {
|
|
796
|
+
fieldErrors[path] = [];
|
|
797
|
+
}
|
|
798
|
+
fieldErrors[path].push(issue.message);
|
|
799
|
+
} else {
|
|
800
|
+
generalErrors.push(issue.message);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
for (const [field, messages] of Object.entries(fieldErrors)) {
|
|
804
|
+
console.error(relico.dim(` ${field}:`));
|
|
805
|
+
for (const message of messages) {
|
|
806
|
+
console.error(relico.dim(` • ${message}`));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (const message of generalErrors) {
|
|
810
|
+
console.error(relico.dim(` • ${message}`));
|
|
811
|
+
}
|
|
812
|
+
process.exit(1);
|
|
813
|
+
} else if (error instanceof Error) {
|
|
814
|
+
console.error(relico.red(`Error: ${error.message}`));
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const api = {
|
|
822
|
+
// Internal method for command registration (not part of public API)
|
|
823
|
+
command<TCommandStore = any>(name: string, cmd: Command<any, TCommandStore>) {
|
|
824
|
+
registerCommand(name, cmd, [], "directory");
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
async init() {
|
|
828
|
+
await loadFromConfig();
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
async run(argv = process.argv.slice(2)) {
|
|
832
|
+
if (argv.length === 0) {
|
|
833
|
+
showHelp(undefined, undefined, []);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Handle -- separator: split args before and after --
|
|
838
|
+
const separatorIndex = argv.indexOf("--");
|
|
839
|
+
const commandArgs = separatorIndex >= 0 ? argv.slice(0, separatorIndex) : argv;
|
|
840
|
+
const passthroughArgs = separatorIndex >= 0 ? argv.slice(separatorIndex + 1) : [];
|
|
841
|
+
|
|
842
|
+
// Handle version flag (only check before -- separator)
|
|
843
|
+
if (commandArgs.includes("--version") || commandArgs.includes("-v")) {
|
|
844
|
+
console.log(relico.bold(relico.cyan(`${fullConfig.name} v${fullConfig.version}`)));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Handle help flags (only check before -- separator)
|
|
849
|
+
if (commandArgs.includes("--help") || commandArgs.includes("-h")) {
|
|
850
|
+
const helpIndex = Math.max(commandArgs.indexOf("--help"), commandArgs.indexOf("-h"));
|
|
851
|
+
const cmdArgs = commandArgs.slice(0, helpIndex);
|
|
852
|
+
|
|
853
|
+
if (cmdArgs.length === 0) {
|
|
854
|
+
showHelp(undefined, undefined, []);
|
|
855
|
+
} else {
|
|
856
|
+
const { command, commandName, remainingArgs: _remainingArgs } = findCommand(cmdArgs);
|
|
857
|
+
if (command && commandName) {
|
|
858
|
+
const pathParts = commandName.split(" ").slice(0, -1);
|
|
859
|
+
showHelp(commandName, command, pathParts);
|
|
860
|
+
} else {
|
|
861
|
+
console.error(`Unknown command: ${cmdArgs.join(" ")}`);
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Find and execute command
|
|
869
|
+
const { command, commandName, remainingArgs } = findCommand(commandArgs);
|
|
870
|
+
|
|
871
|
+
if (!(command && commandName)) {
|
|
872
|
+
console.error(`Unknown command: ${commandArgs[0]}`);
|
|
873
|
+
process.exit(1);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// If command has subcommands but no handler, show help
|
|
877
|
+
// Use prefix tree index for O(1) lookup instead of scanning all commands
|
|
878
|
+
const hasSubcommands =
|
|
879
|
+
subcommandIndex.has(commandName) &&
|
|
880
|
+
Array.from(subcommandIndex.get(commandName)!).some(
|
|
881
|
+
(name) => name.split(" ").length === commandName.split(" ").length + 1
|
|
882
|
+
);
|
|
883
|
+
if (!(command.handler || command.render) && hasSubcommands) {
|
|
884
|
+
const pathParts = commandName.split(" ").slice(0, -1);
|
|
885
|
+
showHelp(commandName, command, pathParts);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (command.handler || command.render) {
|
|
890
|
+
// Combine remaining args from command parsing with passthrough args
|
|
891
|
+
const allArgs = [...remainingArgs, ...passthroughArgs];
|
|
892
|
+
await runCommandInternal(commandName, command, allArgs);
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
async execute(
|
|
897
|
+
commandName: string,
|
|
898
|
+
argsOrOptions?: string[] | Record<string, any>,
|
|
899
|
+
options?: Record<string, any>
|
|
900
|
+
) {
|
|
901
|
+
// Parse command name to handle nested commands (git/sync -> git sync)
|
|
902
|
+
const commandPath = commandName.replace(/\//g, " ").split(" ");
|
|
903
|
+
const { command, commandName: foundCommandName, remainingArgs } = findCommand(commandPath);
|
|
904
|
+
if (!(command && foundCommandName)) {
|
|
905
|
+
throw new Error(`Command '${commandName}' not found`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Handle different overload patterns
|
|
909
|
+
let finalArgs: string[] = [];
|
|
910
|
+
let finalOptions: Record<string, any> = {};
|
|
911
|
+
|
|
912
|
+
if (argsOrOptions && !Array.isArray(argsOrOptions)) {
|
|
913
|
+
// Pattern: execute(commandName, options)
|
|
914
|
+
finalOptions = argsOrOptions as Record<string, any>;
|
|
915
|
+
} else if (Array.isArray(argsOrOptions) && options) {
|
|
916
|
+
// Pattern: execute(commandName, args, options)
|
|
917
|
+
finalArgs = argsOrOptions;
|
|
918
|
+
finalOptions = options;
|
|
919
|
+
} else if (Array.isArray(argsOrOptions)) {
|
|
920
|
+
// Pattern: execute(commandName, args)
|
|
921
|
+
finalArgs = argsOrOptions;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// If options object provided, use directly as flags
|
|
925
|
+
if (Object.keys(finalOptions).length > 0) {
|
|
926
|
+
// Merge global flags with command options
|
|
927
|
+
const mergedOptions = {
|
|
928
|
+
...GLOBAL_FLAGS,
|
|
929
|
+
...(command.options || {}),
|
|
930
|
+
} as MergedOptions<(typeof command.options & Options) | Options>;
|
|
931
|
+
|
|
932
|
+
// Parse with empty args to get defaults, then merge options
|
|
933
|
+
const parsed = await parseArgs([], mergedOptions, foundCommandName);
|
|
934
|
+
Object.assign(parsed.flags, finalOptions);
|
|
935
|
+
|
|
936
|
+
const { prompt, spinner } = await import("@reliverse/rempts-utils");
|
|
937
|
+
|
|
938
|
+
// Run beforeCommand hooks if plugins are loaded
|
|
939
|
+
let context: CommandContext<TStore> | undefined;
|
|
940
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0) {
|
|
941
|
+
context = await runBeforeCommand(
|
|
942
|
+
pluginManagerState,
|
|
943
|
+
foundCommandName,
|
|
944
|
+
command,
|
|
945
|
+
[],
|
|
946
|
+
parsed.flags
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Run global before hooks
|
|
951
|
+
if (beforeHooks.length > 0) {
|
|
952
|
+
// Reset global hook context for this command
|
|
953
|
+
globalHookContext = {};
|
|
954
|
+
|
|
955
|
+
const hookContext: HookContext<TStore> = {
|
|
956
|
+
flags: parsed.flags,
|
|
957
|
+
store: context?.store?.getState() || ({} as TStore),
|
|
958
|
+
env: process.env,
|
|
959
|
+
cwd: process.cwd(),
|
|
960
|
+
set: (key: string, value: any) => {
|
|
961
|
+
globalHookContext[key] = value;
|
|
962
|
+
},
|
|
963
|
+
get: (key: string) => {
|
|
964
|
+
return globalHookContext[key];
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
for (const hook of beforeHooks) {
|
|
969
|
+
await hook(hookContext);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Create runtime info
|
|
974
|
+
const runtimeInfo: RuntimeInfo = {
|
|
975
|
+
startTime: Date.now(),
|
|
976
|
+
args: [],
|
|
977
|
+
command: foundCommandName,
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const terminalInfo = getTerminalInfo();
|
|
981
|
+
|
|
982
|
+
if (command.handler) {
|
|
983
|
+
// Type assertion: flags are validated and typed by parseArgs
|
|
984
|
+
const typedFlags = parsed.flags as InferMergedOptions<
|
|
985
|
+
(typeof command.options & Options) | Options
|
|
986
|
+
>;
|
|
987
|
+
await command.handler({
|
|
988
|
+
flags: typedFlags,
|
|
989
|
+
positional: [],
|
|
990
|
+
shell: Bun.$,
|
|
991
|
+
env: process.env,
|
|
992
|
+
cwd: process.cwd(),
|
|
993
|
+
prompt,
|
|
994
|
+
spinner,
|
|
995
|
+
colors: relico,
|
|
996
|
+
terminal: terminalInfo,
|
|
997
|
+
runtime: runtimeInfo,
|
|
998
|
+
...(context ? { context } : {}),
|
|
999
|
+
...(Object.keys(globalHookContext).length > 0 ? { hooks: globalHookContext } : {}),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Run afterCommand hooks if plugins are loaded
|
|
1004
|
+
if (mergedConfig.plugins && mergedConfig.plugins.length > 0 && context) {
|
|
1005
|
+
await runAfterCommand(pluginManagerState, context, { exitCode: 0 });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Run global after hooks
|
|
1009
|
+
if (afterHooks.length > 0) {
|
|
1010
|
+
const hookContext: HookContext<TStore> & { exitCode: number } = {
|
|
1011
|
+
flags: parsed.flags,
|
|
1012
|
+
store: context?.store?.getState() || ({} as TStore),
|
|
1013
|
+
env: process.env,
|
|
1014
|
+
cwd: process.cwd(),
|
|
1015
|
+
set: () => {}, // Not used in after hooks
|
|
1016
|
+
get: () => undefined, // Not used in after hooks
|
|
1017
|
+
exitCode: 0,
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
for (const hook of afterHooks) {
|
|
1021
|
+
await hook(hookContext);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Parse string args normally
|
|
1028
|
+
const args = finalArgs.length > 0 ? finalArgs : (argsOrOptions as string[] | undefined) || [];
|
|
1029
|
+
// Use the already found command and remaining args
|
|
1030
|
+
const foundCommand = command;
|
|
1031
|
+
const finalArgsToUse = [...remainingArgs, ...args];
|
|
1032
|
+
|
|
1033
|
+
// Execute the command using the same logic as the run method
|
|
1034
|
+
if (foundCommand.handler || foundCommand.render) {
|
|
1035
|
+
await runCommandInternal(foundCommandName, foundCommand, finalArgsToUse);
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
before(hook: BeforeHook<TStore>) {
|
|
1040
|
+
beforeHooks.push(hook);
|
|
1041
|
+
},
|
|
1042
|
+
|
|
1043
|
+
after(hook: AfterHook<TStore>) {
|
|
1044
|
+
afterHooks.push(hook);
|
|
1045
|
+
},
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
return api;
|
|
1049
|
+
}
|