@triflare/fx 1.0.3 → 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 -1162
- package/package.json +5 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- 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/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/extensions.js +72 -19
- 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,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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive prompt layer.
|
|
3
|
+
*
|
|
4
|
+
* `@clack/prompts` provides the widgets; this wrapper keeps Fx styling,
|
|
5
|
+
* non-interactive fallbacks, cancellation, and testability in one place.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
cancel,
|
|
10
|
+
confirm as clackConfirm,
|
|
11
|
+
intro as clackIntro,
|
|
12
|
+
isCancel,
|
|
13
|
+
note as clackNote,
|
|
14
|
+
outro as clackOutro,
|
|
15
|
+
select as clackSelect,
|
|
16
|
+
spinner as clackSpinner,
|
|
17
|
+
text as clackText,
|
|
18
|
+
updateSettings,
|
|
19
|
+
} from "@clack/prompts";
|
|
20
|
+
import { CliError, UsageError, EXIT } from "./errors.js";
|
|
21
|
+
|
|
22
|
+
updateSettings({
|
|
23
|
+
messages: {
|
|
24
|
+
cancel: "Cancelled",
|
|
25
|
+
error: "Something went wrong",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const CI_VARS = ["CI", "GITHUB_ACTIONS", "TRAVIS", "JENKINS_URL", "CIRCLECI"];
|
|
30
|
+
|
|
31
|
+
function cancelled() {
|
|
32
|
+
return new CliError("Cancelled", {
|
|
33
|
+
code: "CANCELLED",
|
|
34
|
+
exitCode: EXIT.INTERRUPTED,
|
|
35
|
+
silent: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unwrap(value) {
|
|
40
|
+
if (isCancel(value)) throw cancelled();
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{
|
|
46
|
+
* reporter: ReturnType<import("./reporter.js").createReporter>,
|
|
47
|
+
* product: object,
|
|
48
|
+
* tty?: boolean,
|
|
49
|
+
* noInput?: boolean,
|
|
50
|
+
* json?: boolean,
|
|
51
|
+
* input?: NodeJS.ReadableStream,
|
|
52
|
+
* output?: NodeJS.WritableStream,
|
|
53
|
+
* }} options
|
|
54
|
+
*/
|
|
55
|
+
export function createPrompts({
|
|
56
|
+
reporter,
|
|
57
|
+
product,
|
|
58
|
+
tty = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
59
|
+
noInput = false,
|
|
60
|
+
json = false,
|
|
61
|
+
input = process.stdin,
|
|
62
|
+
output = process.stdout,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const enabled =
|
|
65
|
+
tty && !noInput && !json && !CI_VARS.some((name) => process.env[name]);
|
|
66
|
+
const common = { input, output };
|
|
67
|
+
|
|
68
|
+
const requireFallback = (value, feature) => {
|
|
69
|
+
if (value !== undefined) return value;
|
|
70
|
+
throw new UsageError(
|
|
71
|
+
`${feature} requires an interactive terminal, --yes, or an explicit value`,
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const message = (text) => reporter.accent(String(text));
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
enabled,
|
|
79
|
+
|
|
80
|
+
intro(text) {
|
|
81
|
+
if (!enabled) return;
|
|
82
|
+
clackIntro(
|
|
83
|
+
reporter.brand(`${product?.name ?? "Fx"} ${product?.version ?? ""}`) +
|
|
84
|
+
(text ? `\n${message(text)}` : ""),
|
|
85
|
+
common,
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
outro(text) {
|
|
90
|
+
if (!enabled) return;
|
|
91
|
+
clackOutro(message(text), common);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
note(text, title) {
|
|
95
|
+
if (!enabled) return;
|
|
96
|
+
clackNote(message(text), title ? message(title) : undefined, common);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async text({ message: prompt, placeholder, defaultValue, validate }) {
|
|
100
|
+
if (!enabled) return requireFallback(defaultValue, prompt);
|
|
101
|
+
return unwrap(
|
|
102
|
+
await clackText(
|
|
103
|
+
{
|
|
104
|
+
...common,
|
|
105
|
+
message: message(prompt),
|
|
106
|
+
placeholder,
|
|
107
|
+
defaultValue,
|
|
108
|
+
validate,
|
|
109
|
+
},
|
|
110
|
+
common,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async select({ message: prompt, options, initialValue }) {
|
|
116
|
+
if (!enabled) return requireFallback(initialValue, prompt);
|
|
117
|
+
return unwrap(
|
|
118
|
+
await clackSelect(
|
|
119
|
+
{
|
|
120
|
+
...common,
|
|
121
|
+
message: message(prompt),
|
|
122
|
+
options,
|
|
123
|
+
initialValue,
|
|
124
|
+
},
|
|
125
|
+
common,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async confirm({ message: prompt, initialValue = true }) {
|
|
131
|
+
if (!enabled) return initialValue;
|
|
132
|
+
return unwrap(
|
|
133
|
+
await clackConfirm(
|
|
134
|
+
{
|
|
135
|
+
...common,
|
|
136
|
+
message: message(prompt),
|
|
137
|
+
initialValue,
|
|
138
|
+
},
|
|
139
|
+
common,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async confirmDestructive({ message: prompt, yes = false }) {
|
|
145
|
+
if (yes) return true;
|
|
146
|
+
if (!enabled)
|
|
147
|
+
throw new UsageError(
|
|
148
|
+
`${prompt} is destructive; pass --yes to confirm in non-interactive mode`,
|
|
149
|
+
);
|
|
150
|
+
return unwrap(
|
|
151
|
+
await clackConfirm(
|
|
152
|
+
{
|
|
153
|
+
...common,
|
|
154
|
+
message: message(prompt),
|
|
155
|
+
initialValue: false,
|
|
156
|
+
},
|
|
157
|
+
common,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
cancel(text = "Cancelled") {
|
|
163
|
+
if (!enabled) return;
|
|
164
|
+
cancel(message(text), common);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
spinner() {
|
|
168
|
+
if (!enabled) {
|
|
169
|
+
return {
|
|
170
|
+
start() {},
|
|
171
|
+
stop() {},
|
|
172
|
+
message() {},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const spin = clackSpinner(common);
|
|
176
|
+
return {
|
|
177
|
+
start: (text) => spin.start(message(text)),
|
|
178
|
+
stop: (text) => spin.stop(text ? message(text) : undefined),
|
|
179
|
+
message: (text) => spin.message(message(text)),
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human and JSON output for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Commands call semantic methods (`success`, `detail`, `warn`, ...) and never
|
|
5
|
+
* write to stdout/stderr directly. In JSON mode human output is suppressed and
|
|
6
|
+
* warnings are collected for the command envelope.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { styleText } from "node:util";
|
|
10
|
+
import { brandText } from "../brand.js";
|
|
11
|
+
import { createTheme } from "./theme.js";
|
|
12
|
+
|
|
13
|
+
const STATUS = {
|
|
14
|
+
success: { symbol: "success", tone: "success" },
|
|
15
|
+
error: { symbol: "error", tone: "danger" },
|
|
16
|
+
warn: { symbol: "progress", tone: "warning" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function canColor(stream) {
|
|
20
|
+
return styleText("red", "x", { stream }).includes("\x1b");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createReporter({
|
|
24
|
+
product,
|
|
25
|
+
stdout = process.stdout,
|
|
26
|
+
stderr = process.stderr,
|
|
27
|
+
json = false,
|
|
28
|
+
quiet = false,
|
|
29
|
+
verbose = false,
|
|
30
|
+
color = "auto",
|
|
31
|
+
} = {}) {
|
|
32
|
+
const warnings = [];
|
|
33
|
+
const errors = [];
|
|
34
|
+
const themes = new Map();
|
|
35
|
+
|
|
36
|
+
const themeFor = (stream) => {
|
|
37
|
+
if (!themes.has(stream))
|
|
38
|
+
themes.set(stream, createTheme({ product, color, stream }));
|
|
39
|
+
return themes.get(stream);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const colorEnabled = (stream) => {
|
|
43
|
+
if (color === "never") return false;
|
|
44
|
+
if (color === "always") return true;
|
|
45
|
+
return canColor(stream);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const paint = (text, tone, stream = stdout) => {
|
|
49
|
+
if (!tone || !colorEnabled(stream)) return text;
|
|
50
|
+
const options = { stream };
|
|
51
|
+
if (color === "always") options.validateStream = false;
|
|
52
|
+
return styleText(tone, text, options);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mark = (name) => product?.symbols?.[name] ?? "";
|
|
56
|
+
|
|
57
|
+
const status = (type, message) => {
|
|
58
|
+
const { symbol, tone } = STATUS[type];
|
|
59
|
+
const glyph = mark(symbol);
|
|
60
|
+
const stream = type === "success" ? stdout : stderr;
|
|
61
|
+
const theme = themeFor(stream);
|
|
62
|
+
const text = String(message);
|
|
63
|
+
const output = text
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line, index) => {
|
|
66
|
+
const lead =
|
|
67
|
+
index === 0
|
|
68
|
+
? glyph
|
|
69
|
+
? `${theme.tone(tone, glyph)} `
|
|
70
|
+
: ""
|
|
71
|
+
: glyph
|
|
72
|
+
? " "
|
|
73
|
+
: "";
|
|
74
|
+
const body =
|
|
75
|
+
type === "success" ? theme.bold(line) : theme.tone(tone, line);
|
|
76
|
+
return `${lead}${body}`;
|
|
77
|
+
})
|
|
78
|
+
.join("\n");
|
|
79
|
+
stream.write(`${output}\n`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const human = !json && !quiet;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
json,
|
|
86
|
+
quiet,
|
|
87
|
+
verbose,
|
|
88
|
+
warnings,
|
|
89
|
+
errors,
|
|
90
|
+
paint,
|
|
91
|
+
brand: (text) => brandText(text, stdout, { force: color === "always" }),
|
|
92
|
+
accent: (text) => themeFor(stdout).accent(text),
|
|
93
|
+
muted: (text) => themeFor(stdout).muted(text),
|
|
94
|
+
bold: (text) => themeFor(stdout).bold(text),
|
|
95
|
+
tone: (name, text) => themeFor(stdout).tone(name, text),
|
|
96
|
+
|
|
97
|
+
success(message) {
|
|
98
|
+
if (!human) return;
|
|
99
|
+
status("success", message);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
info(message) {
|
|
103
|
+
if (!human) return;
|
|
104
|
+
stdout.write(`${message}\n`);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
bullet(message) {
|
|
108
|
+
if (!human) return;
|
|
109
|
+
const theme = themeFor(stdout);
|
|
110
|
+
const glyph = mark("bullet");
|
|
111
|
+
stdout.write(` ${glyph ? `${theme.accent(glyph)} ` : ""}${message}\n`);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
step(message) {
|
|
115
|
+
if (!human) return;
|
|
116
|
+
const theme = themeFor(stdout);
|
|
117
|
+
const glyph = mark("progress");
|
|
118
|
+
stdout.write(`${glyph ? `${theme.accent(glyph)} ` : ""}${message}\n`);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
heading(message) {
|
|
122
|
+
if (!human) return;
|
|
123
|
+
stdout.write(`${themeFor(stdout).heading(String(message))}\n`);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** Aligned `label value` row used by command summaries. */
|
|
127
|
+
detail(label, value, { width = 13 } = {}) {
|
|
128
|
+
if (!human) return;
|
|
129
|
+
const theme = themeFor(stdout);
|
|
130
|
+
const text = String(value);
|
|
131
|
+
const rendered = text === "(none)" ? theme.muted(text) : theme.bold(text);
|
|
132
|
+
stdout.write(
|
|
133
|
+
` ${theme.muted(String(label).padEnd(width))} ${rendered}\n`,
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/** Aligned name + description row used by listings. */
|
|
138
|
+
item(label, value = "", { width = 24, suffix = "" } = {}) {
|
|
139
|
+
if (!human) return;
|
|
140
|
+
const theme = themeFor(stdout);
|
|
141
|
+
const text = String(label);
|
|
142
|
+
const padded = text.padEnd(Math.max(width, text.length));
|
|
143
|
+
const detail = value ? ` ${value}` : "";
|
|
144
|
+
const note = suffix ? ` ${theme.muted(suffix)}` : "";
|
|
145
|
+
stdout.write(` ${theme.accent(padded)}${detail}${note}\n`);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** Plain block of text with no decoration. */
|
|
149
|
+
text(message) {
|
|
150
|
+
if (!human) return;
|
|
151
|
+
stdout.write(`${message}\n`);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/** Quiet, de-emphasized line for incidental notes. */
|
|
155
|
+
note(message) {
|
|
156
|
+
if (!human) return;
|
|
157
|
+
stdout.write(`${themeFor(stdout).muted(message)}\n`);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
warn(message) {
|
|
161
|
+
const text = String(message);
|
|
162
|
+
warnings.push(text);
|
|
163
|
+
if (json) return;
|
|
164
|
+
status("warn", text);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
error(message) {
|
|
168
|
+
const text = String(message);
|
|
169
|
+
errors.push(text);
|
|
170
|
+
if (json) return;
|
|
171
|
+
status("error", text);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
hint(message) {
|
|
175
|
+
if (!human) return;
|
|
176
|
+
const theme = themeFor(stdout);
|
|
177
|
+
stdout.write(
|
|
178
|
+
` ${theme.accent("↳")} ${theme.muted(`hint: ${message}`)}\n`,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/** One JSON document, used for the command result envelope. */
|
|
183
|
+
document(value) {
|
|
184
|
+
if (!json) return;
|
|
185
|
+
stdout.write(`${JSON.stringify(value)}\n`);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/** One newline-delimited JSON event, used by long-running `dev`. */
|
|
189
|
+
event(type, data = {}) {
|
|
190
|
+
if (!json) return;
|
|
191
|
+
stdout.write(`${JSON.stringify({ event: type, ...data })}\n`);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|