@triflare/fx 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/bin/fx.js +4 -1160
- package/package.json +9 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/build.js +2 -1
- package/src/catalog.js +117 -140
- package/src/cli/commands/build.js +98 -0
- package/src/cli/commands/catalog.js +42 -0
- package/src/cli/commands/check.js +44 -0
- package/src/cli/commands/completion.js +54 -0
- package/src/cli/commands/decompile.js +266 -0
- package/src/cli/commands/dev.js +179 -0
- package/src/cli/commands/extension.js +110 -0
- package/src/cli/commands/index.js +39 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/package.js +137 -0
- package/src/cli/commands/sprite/add.js +85 -0
- package/src/cli/commands/sprite/build.js +62 -0
- package/src/cli/commands/sprite/helpers.js +66 -0
- package/src/cli/commands/sprite/index.js +29 -0
- package/src/cli/commands/sprite/list.js +31 -0
- package/src/cli/commands/sprite/remove.js +51 -0
- package/src/cli/commands/vendor/add.js +98 -0
- package/src/cli/commands/vendor/helpers.js +50 -0
- package/src/cli/commands/vendor/index.js +39 -0
- package/src/cli/commands/vendor/install.js +64 -0
- package/src/cli/commands/vendor/list.js +45 -0
- package/src/cli/commands/vendor/migrate.js +47 -0
- package/src/cli/commands/vendor/outdated.js +51 -0
- package/src/cli/commands/vendor/remove.js +34 -0
- package/src/cli/commands/vendor/update.js +49 -0
- package/src/cli/commands/vendor/verify.js +44 -0
- package/src/cli/completion/bash.js +10 -0
- package/src/cli/completion/fish.js +9 -0
- package/src/cli/completion/index.js +97 -0
- package/src/cli/completion/zsh.js +11 -0
- package/src/cli/context.js +257 -0
- package/src/cli/define-command.js +140 -0
- package/src/cli/errors.js +111 -0
- package/src/cli/format.js +31 -0
- package/src/cli/prompts.js +183 -0
- package/src/cli/reporter.js +194 -0
- package/src/cli/run.js +248 -0
- package/src/cli/templates/index.js +112 -0
- package/src/cli/theme.js +84 -0
- package/src/compiler.js +32 -3
- package/src/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/decompiler.js +12 -2
- package/src/extensions.js +311 -48
- package/src/index.js +21 -0
- package/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completion support.
|
|
3
|
+
*
|
|
4
|
+
* Completion is intentionally data-driven: the hidden `__complete` command
|
|
5
|
+
* walks the live commander tree, so new commands and options are completed
|
|
6
|
+
* without maintaining a second list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VALUE_OPTIONS = {
|
|
10
|
+
"--color": ["auto", "always", "never"],
|
|
11
|
+
"--cwd": [],
|
|
12
|
+
"--config": [],
|
|
13
|
+
"--template": ["cat", "empty", "blank"],
|
|
14
|
+
"--target": ["html", "zip", "windows", "macos", "linux"],
|
|
15
|
+
"--size": ["480x360", "640x360", "1920x1080"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function commandFor(program, words) {
|
|
19
|
+
let command = program;
|
|
20
|
+
for (const word of words) {
|
|
21
|
+
if (word.startsWith("-")) continue;
|
|
22
|
+
const next = command.commands.find(
|
|
23
|
+
(entry) => entry.name() === word || entry.aliases().includes(word),
|
|
24
|
+
);
|
|
25
|
+
if (!next) break;
|
|
26
|
+
command = next;
|
|
27
|
+
}
|
|
28
|
+
return command;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function commandWords(command) {
|
|
32
|
+
const words = new Set();
|
|
33
|
+
for (const entry of command.commands) {
|
|
34
|
+
if (entry._hidden) continue;
|
|
35
|
+
words.add(entry.name());
|
|
36
|
+
for (const alias of entry.aliases()) words.add(alias);
|
|
37
|
+
}
|
|
38
|
+
return [...words];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function optionWords(command) {
|
|
42
|
+
const words = new Set();
|
|
43
|
+
for (let current = command; current; current = current.parent) {
|
|
44
|
+
for (const option of current.options) {
|
|
45
|
+
if (option.hidden) continue;
|
|
46
|
+
if (option.long) words.add(option.long);
|
|
47
|
+
if (option.short) words.add(option.short);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...words];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Candidate completions for the words typed after the command name. */
|
|
54
|
+
export function completionCandidates(program, words) {
|
|
55
|
+
const current = words.at(-1) ?? "";
|
|
56
|
+
const before = words.slice(0, -1).filter((word) => word !== "");
|
|
57
|
+
const command = commandFor(program, before);
|
|
58
|
+
|
|
59
|
+
// `--color=` style value completion.
|
|
60
|
+
if (current.includes("=")) {
|
|
61
|
+
const [flag, prefix] = current.split("=", 2);
|
|
62
|
+
const values = VALUE_OPTIONS[flag] ?? [];
|
|
63
|
+
return values
|
|
64
|
+
.filter((value) => value.startsWith(prefix))
|
|
65
|
+
.map((value) => `${flag}=${value}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A value is expected for the previous token.
|
|
69
|
+
const previous = before.at(-1);
|
|
70
|
+
if (previous && Object.hasOwn(VALUE_OPTIONS, previous)) {
|
|
71
|
+
const values = VALUE_OPTIONS[previous];
|
|
72
|
+
return values.filter((value) => value.startsWith(current));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const candidates = new Set();
|
|
76
|
+
if (current.startsWith("-")) {
|
|
77
|
+
for (const option of optionWords(command)) {
|
|
78
|
+
if (option.startsWith(current)) candidates.add(option);
|
|
79
|
+
}
|
|
80
|
+
return [...candidates].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Groups with subcommands (the root, vendor, sprite, ...) offer their
|
|
84
|
+
// command words; leaf commands have none.
|
|
85
|
+
if (command.commands.length > 0) {
|
|
86
|
+
for (const word of commandWords(command))
|
|
87
|
+
if (word.startsWith(current)) candidates.add(word);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Positional values that are useful and finite.
|
|
91
|
+
if (command.name() === "completion") {
|
|
92
|
+
for (const shell of ["bash", "zsh", "fish"])
|
|
93
|
+
if (shell.startsWith(current)) candidates.add(shell);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [...candidates].sort();
|
|
97
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Generate a zsh completion script. */
|
|
2
|
+
export function zshCompletion(command) {
|
|
3
|
+
return `#compdef ${command}
|
|
4
|
+
_${command}_complete() {
|
|
5
|
+
local -a completions
|
|
6
|
+
completions=("\${(@f)$("${command}" __complete zsh -- "\${words[@]:1}")}")
|
|
7
|
+
_describe '${command}' completions
|
|
8
|
+
}
|
|
9
|
+
compdef _${command}_complete ${command}
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared project discovery for every command.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the input path, the nearest (or explicitly selected) `fx.yml`, and
|
|
5
|
+
* the extension catalog once, so commands do not repeat subtly different
|
|
6
|
+
* versions of the same logic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { configFilename, outputDirectory, sourceExtension } from "../brand.js";
|
|
12
|
+
import { loadConfigFrom, parseConfig } from "../config.js";
|
|
13
|
+
import { loadExtensions } from "../extensions.js";
|
|
14
|
+
import { loadVendorExtensions, migrateProjectExtensions } from "../vendor.js";
|
|
15
|
+
import { UsageError } from "./errors.js";
|
|
16
|
+
|
|
17
|
+
const warnedLegacy = new Set();
|
|
18
|
+
const migrationDeclined = new Set();
|
|
19
|
+
|
|
20
|
+
/** Split a positional input into a file or directory target. */
|
|
21
|
+
export function resolveInput(input, cwd = process.cwd()) {
|
|
22
|
+
if (!input) return { dir: path.resolve(cwd) };
|
|
23
|
+
const resolved = path.resolve(cwd, input);
|
|
24
|
+
let stat;
|
|
25
|
+
try {
|
|
26
|
+
stat = fs.statSync(resolved);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new UsageError(`input not found: ${input}`, {
|
|
29
|
+
hint: `check the path, or run from the project directory`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return stat.isDirectory() ? { dir: resolved } : { file: resolved };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inputSearchDir(target) {
|
|
36
|
+
return target.file ? path.dirname(target.file) : target.dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether `--no-config` disables configuration loading. */
|
|
40
|
+
export function configDisabled(options = {}) {
|
|
41
|
+
return options.config === false || options.noConfig === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Load configuration from an explicit path or by searching upward. */
|
|
45
|
+
export function loadProjectConfig({ cwd = process.cwd(), searchDir, options }) {
|
|
46
|
+
if (configDisabled(options)) {
|
|
47
|
+
return {
|
|
48
|
+
project: null,
|
|
49
|
+
fx: null,
|
|
50
|
+
package: null,
|
|
51
|
+
vendor: [],
|
|
52
|
+
vendorDir: "extensions",
|
|
53
|
+
path: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const explicit = typeof options.config === "string" ? options.config : null;
|
|
58
|
+
if (!explicit) return loadConfigFrom(searchDir);
|
|
59
|
+
const explicitPath = path.resolve(cwd, explicit);
|
|
60
|
+
if (!fs.existsSync(explicitPath))
|
|
61
|
+
throw new UsageError(`config file not found: ${explicitPath}`);
|
|
62
|
+
if (!fs.statSync(explicitPath).isFile())
|
|
63
|
+
throw new UsageError(`config path is not a file: ${explicitPath}`);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const parsed = parseConfig(fs.readFileSync(explicitPath, "utf8"));
|
|
67
|
+
return { ...parsed, path: explicitPath };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof UsageError) throw error;
|
|
70
|
+
throw new UsageError(error.message, { cause: error });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function warnLegacyExtensions(loaded, reporter) {
|
|
75
|
+
if (!loaded?.project?.extensions?.length) return;
|
|
76
|
+
const key = loaded.path ?? "<default>";
|
|
77
|
+
if (warnedLegacy.has(key)) return;
|
|
78
|
+
warnedLegacy.add(key);
|
|
79
|
+
reporter.warn(
|
|
80
|
+
`project.extensions is deprecated and will be removed in a future major version; ` +
|
|
81
|
+
`run "${configFilename()} vendor migrate" to move extensions into vendor:`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Offer to migrate legacy project.extensions before extensions are loaded.
|
|
87
|
+
* `--yes` accepts without a terminal; otherwise the prompt only runs on one.
|
|
88
|
+
*/
|
|
89
|
+
async function upgradeLegacyExtensions({
|
|
90
|
+
loaded,
|
|
91
|
+
cwd,
|
|
92
|
+
searchDir,
|
|
93
|
+
options = {},
|
|
94
|
+
reporter,
|
|
95
|
+
prompts,
|
|
96
|
+
}) {
|
|
97
|
+
if (!loaded?.path || !loaded.project?.extensions?.length) return loaded;
|
|
98
|
+
warnLegacyExtensions(loaded, reporter);
|
|
99
|
+
if (migrationDeclined.has(loaded.path)) return loaded;
|
|
100
|
+
if (options.upgradePrompt === false || process.env.FX_NO_PROMPT === "1")
|
|
101
|
+
return loaded;
|
|
102
|
+
|
|
103
|
+
const auto = Boolean(options.yes);
|
|
104
|
+
const interactive = !auto && Boolean(prompts?.enabled);
|
|
105
|
+
if (!auto && !interactive) return loaded;
|
|
106
|
+
if (!auto) {
|
|
107
|
+
const upgrade = await prompts.confirm({
|
|
108
|
+
message: "Upgrade project.extensions to the vendor system?",
|
|
109
|
+
initialValue: true,
|
|
110
|
+
});
|
|
111
|
+
if (!upgrade) {
|
|
112
|
+
migrationDeclined.add(loaded.path);
|
|
113
|
+
return loaded;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await migrateProjectExtensions({
|
|
119
|
+
configPath: loaded.path,
|
|
120
|
+
configDir: path.dirname(loaded.path),
|
|
121
|
+
config: loaded,
|
|
122
|
+
nameFor: interactive
|
|
123
|
+
? (name, source) =>
|
|
124
|
+
prompts.text({
|
|
125
|
+
message: `Name for ${source}`,
|
|
126
|
+
defaultValue: name,
|
|
127
|
+
})
|
|
128
|
+
: undefined,
|
|
129
|
+
});
|
|
130
|
+
for (const warning of result.warnings) reporter.warn(warning);
|
|
131
|
+
if (result.diff) reporter.text(result.diff);
|
|
132
|
+
if (result.migrated.length > 0)
|
|
133
|
+
reporter.success(
|
|
134
|
+
`Vendored ${result.migrated.length} extension(s); backup: ${path.relative(
|
|
135
|
+
cwd,
|
|
136
|
+
result.backup,
|
|
137
|
+
)}`,
|
|
138
|
+
);
|
|
139
|
+
return loadProjectConfig({ cwd, searchDir, options, reporter });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// A failed migration would otherwise warn (and retry) on every rebuild.
|
|
142
|
+
migrationDeclined.add(loaded.path);
|
|
143
|
+
reporter.warn(`Could not migrate project.extensions: ${error.message}`);
|
|
144
|
+
return loaded;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Load vendored and legacy extensions into one catalog. */
|
|
149
|
+
export async function loadProjectExtensions({ configDir, loaded, reporter }) {
|
|
150
|
+
const catalog = {};
|
|
151
|
+
const extensionUrls = {};
|
|
152
|
+
const unsandboxed = [];
|
|
153
|
+
const config = loaded?.project || {};
|
|
154
|
+
const matchedUnsandboxed = new Set();
|
|
155
|
+
const unnecessaryUnsandboxed = new Set();
|
|
156
|
+
|
|
157
|
+
if (loaded?.vendor && loaded.vendor.length > 0) {
|
|
158
|
+
const vendored = await loadVendorExtensions({ configDir, config: loaded });
|
|
159
|
+
Object.assign(catalog, vendored.catalog);
|
|
160
|
+
Object.assign(extensionUrls, vendored.extensionUrls);
|
|
161
|
+
unsandboxed.push(...vendored.unsandboxed);
|
|
162
|
+
for (const name of vendored.matchedUnsandboxed || [])
|
|
163
|
+
matchedUnsandboxed.add(name);
|
|
164
|
+
for (const name of vendored.unnecessaryUnsandboxed || [])
|
|
165
|
+
unnecessaryUnsandboxed.add(name);
|
|
166
|
+
for (const warning of vendored.warnings) reporter.warn(warning);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (config.extensions && config.extensions.length > 0) {
|
|
170
|
+
warnLegacyExtensions(loaded, reporter);
|
|
171
|
+
const legacy = await loadExtensions(config.extensions, {
|
|
172
|
+
baseDir: configDir,
|
|
173
|
+
expectUnsandboxed: config.unsandboxed,
|
|
174
|
+
reportUnmatchedUnsandboxed: false,
|
|
175
|
+
});
|
|
176
|
+
Object.assign(catalog, legacy.catalog);
|
|
177
|
+
Object.assign(extensionUrls, legacy.extensionUrls);
|
|
178
|
+
unsandboxed.push(...legacy.unsandboxed);
|
|
179
|
+
for (const name of legacy.matchedUnsandboxed || [])
|
|
180
|
+
matchedUnsandboxed.add(name);
|
|
181
|
+
for (const name of legacy.unnecessaryUnsandboxed || [])
|
|
182
|
+
unnecessaryUnsandboxed.add(name);
|
|
183
|
+
for (const warning of legacy.warnings) reporter.warn(warning);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const name of new Set(config.unsandboxed || [])) {
|
|
187
|
+
if (unnecessaryUnsandboxed.has(name)) {
|
|
188
|
+
reporter.warn(
|
|
189
|
+
`project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
|
|
190
|
+
);
|
|
191
|
+
} else if (!matchedUnsandboxed.has(name)) {
|
|
192
|
+
reporter.warn(
|
|
193
|
+
`project.unsandboxed entry "${name}" does not match any loaded extension`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { catalog, extensionUrls, unsandboxed };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve everything a build-like command needs.
|
|
203
|
+
*
|
|
204
|
+
* @param {{
|
|
205
|
+
* input?: string,
|
|
206
|
+
* cwd: string,
|
|
207
|
+
* options: object,
|
|
208
|
+
* reporter: object,
|
|
209
|
+
* prompts?: object,
|
|
210
|
+
* includeExtensions?: boolean,
|
|
211
|
+
* migrateLegacy?: boolean,
|
|
212
|
+
* }} options
|
|
213
|
+
*/
|
|
214
|
+
export async function loadProjectState({
|
|
215
|
+
input,
|
|
216
|
+
cwd,
|
|
217
|
+
options,
|
|
218
|
+
reporter,
|
|
219
|
+
prompts = null,
|
|
220
|
+
includeExtensions = true,
|
|
221
|
+
migrateLegacy = true,
|
|
222
|
+
}) {
|
|
223
|
+
const target = resolveInput(input, cwd);
|
|
224
|
+
const searchDir = inputSearchDir(target);
|
|
225
|
+
let loaded = loadProjectConfig({ cwd, searchDir, options, reporter });
|
|
226
|
+
if (migrateLegacy)
|
|
227
|
+
loaded = await upgradeLegacyExtensions({
|
|
228
|
+
loaded,
|
|
229
|
+
cwd,
|
|
230
|
+
searchDir,
|
|
231
|
+
options,
|
|
232
|
+
reporter,
|
|
233
|
+
prompts,
|
|
234
|
+
});
|
|
235
|
+
const configDir = loaded.path ? path.dirname(loaded.path) : searchDir;
|
|
236
|
+
const extensions = includeExtensions
|
|
237
|
+
? await loadProjectExtensions({ configDir, loaded, reporter })
|
|
238
|
+
: { catalog: {}, extensionUrls: {}, unsandboxed: [] };
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
target,
|
|
242
|
+
searchDir,
|
|
243
|
+
loaded,
|
|
244
|
+
configDir,
|
|
245
|
+
...extensions,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Default output directory for a directory build. */
|
|
250
|
+
export function defaultOutputDirectory(cwd) {
|
|
251
|
+
return path.join(cwd, outputDirectory());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Source extension used in user-facing messages. */
|
|
255
|
+
export function sourceSuffix() {
|
|
256
|
+
return sourceExtension();
|
|
257
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared command registration and result handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { createPrompts } from "./prompts.js";
|
|
8
|
+
import { createReporter } from "./reporter.js";
|
|
9
|
+
|
|
10
|
+
function resolveColor(options) {
|
|
11
|
+
if (options.color === false || options.color === "never") return "never";
|
|
12
|
+
if (options.color === "always") return "always";
|
|
13
|
+
return "auto";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build the per-command context from global and command options. */
|
|
17
|
+
export function createContext({ command, product }) {
|
|
18
|
+
const options = command.optsWithGlobals();
|
|
19
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
20
|
+
const json = Boolean(options.json);
|
|
21
|
+
const noInput = options.input === false || json;
|
|
22
|
+
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
23
|
+
const reporter = createReporter({
|
|
24
|
+
product,
|
|
25
|
+
json,
|
|
26
|
+
quiet: Boolean(options.quiet),
|
|
27
|
+
verbose: Boolean(options.verbose),
|
|
28
|
+
color: resolveColor(options),
|
|
29
|
+
});
|
|
30
|
+
const prompts = createPrompts({
|
|
31
|
+
reporter,
|
|
32
|
+
product,
|
|
33
|
+
tty,
|
|
34
|
+
noInput,
|
|
35
|
+
json,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
command,
|
|
40
|
+
product,
|
|
41
|
+
options,
|
|
42
|
+
args: command.processedArgs,
|
|
43
|
+
cwd,
|
|
44
|
+
json,
|
|
45
|
+
yes: Boolean(options.yes),
|
|
46
|
+
reporter,
|
|
47
|
+
prompts,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mark a command result with an explicit exit status. */
|
|
52
|
+
export function outcome({ data = null, exitCode = 0, error = null } = {}) {
|
|
53
|
+
return {
|
|
54
|
+
__outcome: true,
|
|
55
|
+
ok: exitCode === 0 && !error,
|
|
56
|
+
exitCode,
|
|
57
|
+
data,
|
|
58
|
+
error,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeOutcome(result) {
|
|
63
|
+
if (result && result.__outcome) return result;
|
|
64
|
+
return { ok: true, exitCode: 0, data: result ?? null, error: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configure(command, spec) {
|
|
68
|
+
command.description(spec.summary ?? spec.description ?? "");
|
|
69
|
+
if (spec.aliases?.length) command.aliases(spec.aliases);
|
|
70
|
+
if (spec.group) command.helpGroup(spec.group);
|
|
71
|
+
for (const argument of spec.arguments ?? []) command.argument(argument);
|
|
72
|
+
for (const option of spec.options ?? []) {
|
|
73
|
+
if (option.defaultValue === undefined)
|
|
74
|
+
command.option(option.flags, option.description);
|
|
75
|
+
else command.option(option.flags, option.description, option.defaultValue);
|
|
76
|
+
}
|
|
77
|
+
const details = [];
|
|
78
|
+
if (spec.description && spec.description !== spec.summary)
|
|
79
|
+
details.push(spec.description);
|
|
80
|
+
if (spec.examples?.length)
|
|
81
|
+
details.push(
|
|
82
|
+
`Examples:\n${spec.examples.map((example) => ` ${example}`).join("\n")}`,
|
|
83
|
+
);
|
|
84
|
+
if (details.length)
|
|
85
|
+
command.addHelpText("after", `\n${details.join("\n\n")}\n`);
|
|
86
|
+
return command;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Full command path, such as "vendor list", for diagnostics and JSON. */
|
|
90
|
+
function commandPath(command) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (let current = command; current?.parent; current = current.parent)
|
|
93
|
+
parts.unshift(current.name());
|
|
94
|
+
return parts.join(" ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register one command (or subcommand) with shared error, JSON, and output
|
|
99
|
+
* handling.
|
|
100
|
+
*
|
|
101
|
+
* @param {Command} parent
|
|
102
|
+
* @param {object} spec
|
|
103
|
+
* @param {{state: {exitCode: number, command?: string}, product: object}} options
|
|
104
|
+
*/
|
|
105
|
+
export function defineCommand(parent, spec, { state, product }) {
|
|
106
|
+
const command = configure(new Command(spec.name), spec);
|
|
107
|
+
command.copyInheritedSettings(parent);
|
|
108
|
+
|
|
109
|
+
command.action(async function action() {
|
|
110
|
+
const command = this;
|
|
111
|
+
const name = commandPath(command);
|
|
112
|
+
const context = createContext({ command, product });
|
|
113
|
+
state.command = name;
|
|
114
|
+
try {
|
|
115
|
+
const result = await spec.run(context);
|
|
116
|
+
const normalized = normalizeOutcome(result);
|
|
117
|
+
if (context.options.json && !spec.jsonEvents) {
|
|
118
|
+
const payload = {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
command: name,
|
|
121
|
+
ok: normalized.ok,
|
|
122
|
+
warnings: context.reporter.warnings,
|
|
123
|
+
};
|
|
124
|
+
if (normalized.error) payload.error = normalized.error;
|
|
125
|
+
else payload.data = normalized.data;
|
|
126
|
+
context.reporter.document(payload);
|
|
127
|
+
}
|
|
128
|
+
state.exitCode = normalized.exitCode;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error && typeof error === "object") {
|
|
131
|
+
error.command ??= name;
|
|
132
|
+
error.warnings ??= context.reporter.warnings;
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
parent.addCommand(command, { hidden: Boolean(spec.hidden) });
|
|
139
|
+
return command;
|
|
140
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-specific errors and exit-code mapping.
|
|
3
|
+
*
|
|
4
|
+
* Command handlers throw these instead of writing to a stream and calling
|
|
5
|
+
* `process.exit`, which keeps the CLI testable and lets `run()` decide between
|
|
6
|
+
* human and JSON diagnostics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FxError } from "../errors.js";
|
|
10
|
+
|
|
11
|
+
/** Exit codes shared by every command. */
|
|
12
|
+
export const EXIT = {
|
|
13
|
+
OK: 0,
|
|
14
|
+
FAILURE: 1,
|
|
15
|
+
USAGE: 2,
|
|
16
|
+
INTERRUPTED: 130,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class CliError extends Error {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @param {{
|
|
23
|
+
* code?: string,
|
|
24
|
+
* exitCode?: number,
|
|
25
|
+
* hint?: string,
|
|
26
|
+
* details?: unknown,
|
|
27
|
+
* silent?: boolean,
|
|
28
|
+
* cause?: unknown,
|
|
29
|
+
* }} [options]
|
|
30
|
+
*/
|
|
31
|
+
constructor(message, options = {}) {
|
|
32
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
33
|
+
this.name = "CliError";
|
|
34
|
+
this.code = options.code ?? "CLI_ERROR";
|
|
35
|
+
this.exitCode = options.exitCode ?? EXIT.FAILURE;
|
|
36
|
+
this.hint = options.hint;
|
|
37
|
+
this.details = options.details;
|
|
38
|
+
this.silent = Boolean(options.silent);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class UsageError extends CliError {
|
|
43
|
+
constructor(message, options = {}) {
|
|
44
|
+
super(message, {
|
|
45
|
+
...options,
|
|
46
|
+
code: options.code ?? "USAGE_ERROR",
|
|
47
|
+
exitCode: EXIT.USAGE,
|
|
48
|
+
});
|
|
49
|
+
this.name = "UsageError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Map a thrown value to the CLI's stable JSON error shape. */
|
|
54
|
+
export function errorToJson(error) {
|
|
55
|
+
if (error instanceof CliError) {
|
|
56
|
+
const result = {
|
|
57
|
+
code: error.code,
|
|
58
|
+
message: error.message,
|
|
59
|
+
};
|
|
60
|
+
if (error.hint) result.hint = error.hint;
|
|
61
|
+
if (error.details !== undefined) result.details = error.details;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (error instanceof FxError) {
|
|
66
|
+
const result = {
|
|
67
|
+
code:
|
|
68
|
+
error.name === "LexError"
|
|
69
|
+
? "LEX_ERROR"
|
|
70
|
+
: error.name === "ParseError"
|
|
71
|
+
? "PARSE_ERROR"
|
|
72
|
+
: error.name === "CompileError"
|
|
73
|
+
? "COMPILE_ERROR"
|
|
74
|
+
: "FX_ERROR",
|
|
75
|
+
message: error.message,
|
|
76
|
+
};
|
|
77
|
+
if (error.file != null) result.file = error.file;
|
|
78
|
+
if (error.line != null) result.line = error.line;
|
|
79
|
+
if (error.column != null) result.column = error.column;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
code: "COMMAND_ERROR",
|
|
85
|
+
message:
|
|
86
|
+
error instanceof Error
|
|
87
|
+
? error.message
|
|
88
|
+
: typeof error === "string"
|
|
89
|
+
? error
|
|
90
|
+
: String(error),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Human-readable error body, including a code frame when available. */
|
|
95
|
+
export function formatError(error, { verbose = false } = {}) {
|
|
96
|
+
if (error instanceof FxError) return error.format();
|
|
97
|
+
if (error instanceof CliError) {
|
|
98
|
+
let text = error.message;
|
|
99
|
+
if (Array.isArray(error.details?.problems) && error.details.problems.length)
|
|
100
|
+
text += `\n${error.details.problems.map((line) => ` ${line}`).join("\n")}`;
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
if (verbose && error instanceof Error && error.stack) return error.stack;
|
|
104
|
+
return error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Exit code for an error that escaped a command handler. */
|
|
108
|
+
export function exitCodeFor(error) {
|
|
109
|
+
if (error instanceof CliError) return error.exitCode;
|
|
110
|
+
return EXIT.FAILURE;
|
|
111
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Small formatting helpers shared by CLI commands. */
|
|
2
|
+
|
|
3
|
+
/** Render a byte count using binary units. */
|
|
4
|
+
export function formatBytes(bytes) {
|
|
5
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
6
|
+
const units = ["KB", "MB", "GB"];
|
|
7
|
+
let value = bytes / 1024;
|
|
8
|
+
let unit = units[0];
|
|
9
|
+
for (let i = 1; value >= 1024 && i < units.length; i += 1) {
|
|
10
|
+
value /= 1024;
|
|
11
|
+
unit = units[i];
|
|
12
|
+
}
|
|
13
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Render a duration in milliseconds. */
|
|
17
|
+
export function formatDuration(milliseconds) {
|
|
18
|
+
if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
|
|
19
|
+
return `${(milliseconds / 1000).toFixed(2)}s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pluralize a simple English noun. */
|
|
23
|
+
export function plural(count, singular, pluralForm = `${singular}s`) {
|
|
24
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shorten long text for inline diagnostics. */
|
|
28
|
+
export function truncate(text, max) {
|
|
29
|
+
const value = String(text);
|
|
30
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
31
|
+
}
|