@jango-blockchained/hoox-cli 0.3.4
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 +403 -0
- package/bin/hoox.js +12 -0
- package/package.json +60 -0
- package/src/commands/check/check-command.test.ts +468 -0
- package/src/commands/check/check-command.ts +1144 -0
- package/src/commands/check/index.ts +10 -0
- package/src/commands/check/prerequisites-command.test.ts +19 -0
- package/src/commands/check/prerequisites-command.ts +92 -0
- package/src/commands/check/types.ts +103 -0
- package/src/commands/clone/clone-command.test.ts +442 -0
- package/src/commands/clone/clone-command.ts +440 -0
- package/src/commands/clone/index.ts +1 -0
- package/src/commands/config/config-command.test.ts +583 -0
- package/src/commands/config/config-command.ts +901 -0
- package/src/commands/config/env-command.test.ts +43 -0
- package/src/commands/config/env-command.ts +314 -0
- package/src/commands/config/index.ts +3 -0
- package/src/commands/config/kv-command.test.ts +14 -0
- package/src/commands/config/kv-command.ts +329 -0
- package/src/commands/dashboard/dashboard-command.test.ts +47 -0
- package/src/commands/dashboard/dashboard-command.ts +127 -0
- package/src/commands/dashboard/index.ts +1 -0
- package/src/commands/db/db-command.test.ts +21 -0
- package/src/commands/db/db-command.ts +314 -0
- package/src/commands/db/index.ts +1 -0
- package/src/commands/deploy/deploy-command.test.ts +304 -0
- package/src/commands/deploy/deploy-command.ts +1053 -0
- package/src/commands/deploy/index.ts +2 -0
- package/src/commands/deploy/telegram-service.ts +61 -0
- package/src/commands/deploy/types.ts +34 -0
- package/src/commands/dev/dev-command.test.ts +383 -0
- package/src/commands/dev/dev-command.ts +407 -0
- package/src/commands/dev/index.ts +1 -0
- package/src/commands/infra/index.ts +5 -0
- package/src/commands/infra/infra-command.test.ts +719 -0
- package/src/commands/infra/infra-command.ts +940 -0
- package/src/commands/infra/types.ts +23 -0
- package/src/commands/init/index.ts +1 -0
- package/src/commands/init/init-command.test.ts +827 -0
- package/src/commands/init/init-command.ts +627 -0
- package/src/commands/init/types.ts +185 -0
- package/src/commands/monitor/index.ts +2 -0
- package/src/commands/monitor/monitor-command.test.ts +235 -0
- package/src/commands/monitor/monitor-command.ts +245 -0
- package/src/commands/monitor/monitor-service.ts +50 -0
- package/src/commands/monitor/types.ts +13 -0
- package/src/commands/repair/index.ts +2 -0
- package/src/commands/repair/repair-command.test.ts +204 -0
- package/src/commands/repair/repair-command.ts +199 -0
- package/src/commands/repair/repair-service.ts +102 -0
- package/src/commands/repair/types.ts +13 -0
- package/src/commands/test/index.ts +2 -0
- package/src/commands/test/test-command.test.ts +319 -0
- package/src/commands/test/test-command.ts +412 -0
- package/src/commands/waf/index.ts +2 -0
- package/src/commands/waf/types.ts +48 -0
- package/src/commands/waf/waf-command.test.ts +506 -0
- package/src/commands/waf/waf-command.ts +548 -0
- package/src/index.ts +198 -0
- package/src/services/cloudflare/cloudflare-service.test.ts +654 -0
- package/src/services/cloudflare/cloudflare-service.ts +435 -0
- package/src/services/cloudflare/index.ts +2 -0
- package/src/services/cloudflare/types.ts +29 -0
- package/src/services/config/config-service.test.ts +395 -0
- package/src/services/config/config-service.ts +207 -0
- package/src/services/config/index.ts +9 -0
- package/src/services/config/types.ts +66 -0
- package/src/services/db/db-service.test.ts +51 -0
- package/src/services/db/db-service.ts +140 -0
- package/src/services/db/index.ts +1 -0
- package/src/services/docker/docker-service.ts +155 -0
- package/src/services/docker/index.ts +1 -0
- package/src/services/env/env-service.test.ts +210 -0
- package/src/services/env/env-service.ts +156 -0
- package/src/services/env/index.ts +1 -0
- package/src/services/kv/index.ts +1 -0
- package/src/services/kv/kv-sync-service.test.ts +38 -0
- package/src/services/kv/kv-sync-service.ts +151 -0
- package/src/services/prerequisites/index.ts +1 -0
- package/src/services/prerequisites/prerequisites-service.test.ts +89 -0
- package/src/services/prerequisites/prerequisites-service.ts +269 -0
- package/src/services/prerequisites/types.ts +48 -0
- package/src/services/secrets/index.ts +12 -0
- package/src/services/secrets/secrets-service.test.ts +486 -0
- package/src/services/secrets/secrets-service.ts +293 -0
- package/src/services/secrets/types.ts +57 -0
- package/src/ui/banner.ts +52 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/menu.ts +473 -0
- package/src/utils/errors.test.ts +69 -0
- package/src/utils/errors.ts +23 -0
- package/src/utils/formatters.test.ts +180 -0
- package/src/utils/formatters.ts +252 -0
- package/src/utils/theme.ts +94 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatSuccess,
|
|
4
|
+
formatError,
|
|
5
|
+
formatTable,
|
|
6
|
+
formatJson,
|
|
7
|
+
formatKeyValue,
|
|
8
|
+
} from "./formatters.js";
|
|
9
|
+
import { CLIError, ExitCode } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Helper: captures everything written to process.stdout.write into a string.
|
|
13
|
+
* Returns a cleanup function that restores the original write.
|
|
14
|
+
*/
|
|
15
|
+
function captureStdout(): { output: () => string; restore: () => void } {
|
|
16
|
+
const chunks: string[] = [];
|
|
17
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
18
|
+
const writeMock = mock((chunk: string | Buffer) => {
|
|
19
|
+
chunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
process.stdout.write = writeMock as unknown as typeof process.stdout.write;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
output: () => chunks.join(""),
|
|
26
|
+
restore: () => {
|
|
27
|
+
process.stdout.write = originalWrite;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("formatSuccess", () => {
|
|
33
|
+
let capture: ReturnType<typeof captureStdout>;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
capture = captureStdout();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
capture.restore();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("outputs human-formatted success", () => {
|
|
44
|
+
formatSuccess("Deploy complete");
|
|
45
|
+
const out = capture.output();
|
|
46
|
+
expect(out).toContain("✓");
|
|
47
|
+
|
|
48
|
+
// Check status indicator is present (just the checkmark)
|
|
49
|
+
expect(out).toContain("✓");
|
|
50
|
+
expect(out).not.toContain("✗");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("formatTable", () => {
|
|
55
|
+
let capture: ReturnType<typeof captureStdout>;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
capture = captureStdout();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
capture.restore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const sampleRows = [
|
|
66
|
+
{ name: "alpha", status: "ok" },
|
|
67
|
+
{ name: "beta-long", status: "error" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
it("outputs human-formatted table with box drawing", () => {
|
|
71
|
+
formatTable(sampleRows);
|
|
72
|
+
const out = capture.output();
|
|
73
|
+
expect(out).toContain("┌");
|
|
74
|
+
expect(out).toContain("┬");
|
|
75
|
+
expect(out).toContain("┐");
|
|
76
|
+
expect(out).toContain("├");
|
|
77
|
+
expect(out).toContain("┼");
|
|
78
|
+
expect(out).toContain("┤");
|
|
79
|
+
expect(out).toContain("└");
|
|
80
|
+
expect(out).toContain("┴");
|
|
81
|
+
expect(out).toContain("┘");
|
|
82
|
+
expect(out).toContain("alpha");
|
|
83
|
+
expect(out).toContain("beta-long");
|
|
84
|
+
expect(out).toContain("ok");
|
|
85
|
+
expect(out).toContain("error");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("outputs JSON array when json=true", () => {
|
|
89
|
+
formatTable(sampleRows, { json: true });
|
|
90
|
+
const out = capture.output();
|
|
91
|
+
const parsed = JSON.parse(out);
|
|
92
|
+
expect(parsed).toEqual(sampleRows);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("outputs nothing when quiet=true", () => {
|
|
96
|
+
formatTable(sampleRows, { quiet: true });
|
|
97
|
+
expect(capture.output()).toBe("");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("outputs (empty) for empty rows", () => {
|
|
101
|
+
formatTable([]);
|
|
102
|
+
const out = capture.output();
|
|
103
|
+
expect(out).toContain("(empty)");
|
|
104
|
+
expect(out).not.toContain("┌");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("formatJson", () => {
|
|
109
|
+
let capture: ReturnType<typeof captureStdout>;
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
capture = captureStdout();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
capture.restore();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("outputs pretty JSON by default", () => {
|
|
120
|
+
formatJson({ key: "value" });
|
|
121
|
+
const out = capture.output();
|
|
122
|
+
const parsed = JSON.parse(out);
|
|
123
|
+
expect(parsed).toEqual({ key: "value" });
|
|
124
|
+
// Pretty-printed: contains newlines and indentation
|
|
125
|
+
expect(out).toContain("\n");
|
|
126
|
+
expect(out).toContain('"key"');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("outputs compact JSON when quiet=true", () => {
|
|
130
|
+
formatJson({ key: "value" }, { quiet: true });
|
|
131
|
+
const out = capture.output();
|
|
132
|
+
const parsed = JSON.parse(out);
|
|
133
|
+
expect(parsed).toEqual({ key: "value" });
|
|
134
|
+
// Compact: single line of content (console.log appends newline)
|
|
135
|
+
expect(out.trim()).not.toContain("\n");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("outputs pretty JSON when json=true", () => {
|
|
139
|
+
formatJson({ key: "value" }, { json: true });
|
|
140
|
+
const out = capture.output();
|
|
141
|
+
expect(out).toContain("\n");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("formatKeyValue", () => {
|
|
146
|
+
let capture: ReturnType<typeof captureStdout>;
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
capture = captureStdout();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
capture.restore();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const pairs = { Name: "alpha", Status: "running", Region: "us-east" };
|
|
157
|
+
|
|
158
|
+
it("outputs human-formatted key-value pairs", () => {
|
|
159
|
+
formatKeyValue(pairs);
|
|
160
|
+
const out = capture.output();
|
|
161
|
+
expect(out).toContain("Name");
|
|
162
|
+
expect(out).toContain("alpha");
|
|
163
|
+
expect(out).toContain("Status");
|
|
164
|
+
expect(out).toContain("running");
|
|
165
|
+
expect(out).toContain("Region");
|
|
166
|
+
expect(out).toContain("us-east");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("outputs JSON object when json=true", () => {
|
|
170
|
+
formatKeyValue(pairs, { json: true });
|
|
171
|
+
const out = capture.output();
|
|
172
|
+
const parsed = JSON.parse(out);
|
|
173
|
+
expect(parsed).toEqual(pairs);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("outputs nothing when quiet=true", () => {
|
|
177
|
+
formatKeyValue(pairs, { quiet: true });
|
|
178
|
+
expect(capture.output()).toBe("");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatters for the Hoox CLI.
|
|
3
|
+
* Each function respects --json / --quiet flags passed via FormatOptions.
|
|
4
|
+
* Human-readable mode uses ansis styling from the theme.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CLIError, ExitCode } from "./errors.js";
|
|
8
|
+
import { theme, icons, stripAnsi, hr } from "./theme.js";
|
|
9
|
+
|
|
10
|
+
export interface FormatOptions {
|
|
11
|
+
json?: boolean;
|
|
12
|
+
quiet?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Progress bar ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const PROGRESS_BAR_WIDTH = 30;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render an inline progress bar string.
|
|
21
|
+
* @param current Current step (0-based)
|
|
22
|
+
* @param total Total steps
|
|
23
|
+
* @param opts Width override
|
|
24
|
+
*/
|
|
25
|
+
export function renderProgressBar(
|
|
26
|
+
current: number,
|
|
27
|
+
total: number,
|
|
28
|
+
opts?: { width?: number }
|
|
29
|
+
): string {
|
|
30
|
+
const width = opts?.width ?? PROGRESS_BAR_WIDTH;
|
|
31
|
+
const ratio = total > 0 ? Math.min(current / total, 1) : 0;
|
|
32
|
+
const filled = Math.round(width * ratio);
|
|
33
|
+
const empty = width - filled;
|
|
34
|
+
const pct = Math.round(ratio * 100);
|
|
35
|
+
|
|
36
|
+
const bar =
|
|
37
|
+
theme.accent("[") +
|
|
38
|
+
theme.success("█".repeat(filled)) +
|
|
39
|
+
theme.dim("░".repeat(empty)) +
|
|
40
|
+
theme.accent("]") +
|
|
41
|
+
` ${pct}%`;
|
|
42
|
+
|
|
43
|
+
return bar;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render a multi-line progress display for sequential steps.
|
|
48
|
+
* Returns lines suitable for overwriting via \r or manual clear.
|
|
49
|
+
*/
|
|
50
|
+
export function renderStepProgress(
|
|
51
|
+
steps: Array<{ name: string; status: "pending" | "running" | "done" | "failed" }>
|
|
52
|
+
): string {
|
|
53
|
+
const iconMap: Record<string, string> = {
|
|
54
|
+
pending: theme.dim("○"),
|
|
55
|
+
running: theme.info("◌"),
|
|
56
|
+
done: theme.success("●"),
|
|
57
|
+
failed: theme.error("✗"),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return steps
|
|
61
|
+
.map((s) => ` ${iconMap[s.status]} ${theme.bold(s.name)}`)
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Basic output ──────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Output a success message.
|
|
69
|
+
* - JSON mode: {"success":true,"message":"..."}
|
|
70
|
+
* - Quiet mode: prints nothing
|
|
71
|
+
* - Human mode: green "✓ message"
|
|
72
|
+
*/
|
|
73
|
+
export function formatSuccess(message: string, opts?: FormatOptions): void {
|
|
74
|
+
if (opts?.quiet) return;
|
|
75
|
+
|
|
76
|
+
if (opts?.json) {
|
|
77
|
+
process.stdout.write(JSON.stringify({ success: true, message }) + "\n");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.stdout.write(`${theme.success(icons.success)} ${message}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Output an error message.
|
|
86
|
+
* - JSON mode: {"success":false,"error":"...","code":1,"details":"..."}
|
|
87
|
+
* - Quiet mode: prints only the error message (no icon)
|
|
88
|
+
* - Human mode: red "✗ message" + optional details indented
|
|
89
|
+
*/
|
|
90
|
+
export function formatError(error: Error | string, opts?: FormatOptions): void {
|
|
91
|
+
const message = typeof error === "string" ? error : error.message;
|
|
92
|
+
const cliError = error instanceof CLIError ? error : null;
|
|
93
|
+
|
|
94
|
+
if (opts?.json) {
|
|
95
|
+
const output: Record<string, unknown> = {
|
|
96
|
+
success: false,
|
|
97
|
+
error: message,
|
|
98
|
+
code: cliError?.code ?? ExitCode.ERROR,
|
|
99
|
+
};
|
|
100
|
+
if (cliError?.details) {
|
|
101
|
+
output.details = cliError.details;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (opts?.quiet) {
|
|
108
|
+
process.stdout.write(`${message}\n`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.stdout.write(`${theme.error(icons.error)} ${message}\n`);
|
|
113
|
+
|
|
114
|
+
if (cliError?.details) {
|
|
115
|
+
process.stdout.write(` ${theme.dim(cliError.details)}\n`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Output tabular data with enhanced box-drawing borders.
|
|
121
|
+
* - JSON mode: JSON array of objects
|
|
122
|
+
* - Quiet mode: prints nothing
|
|
123
|
+
* - Human mode: themed box-drawn table with column headers
|
|
124
|
+
*/
|
|
125
|
+
export function formatTable(
|
|
126
|
+
rows: Record<string, string>[],
|
|
127
|
+
opts?: FormatOptions
|
|
128
|
+
): void {
|
|
129
|
+
if (opts?.quiet) return;
|
|
130
|
+
|
|
131
|
+
if (opts?.json) {
|
|
132
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (rows.length === 0) {
|
|
137
|
+
process.stdout.write(`${theme.dim("(empty)")}\n`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Collect all keys in display order (first row defines column order)
|
|
142
|
+
const keys = Object.keys(rows[0]);
|
|
143
|
+
|
|
144
|
+
// Calculate column widths
|
|
145
|
+
const widths: Record<string, number> = {};
|
|
146
|
+
for (const key of keys) {
|
|
147
|
+
widths[key] = stripAnsi(key).length;
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const value = row[key] ?? "";
|
|
150
|
+
const stripped = stripAnsi(value).length;
|
|
151
|
+
widths[key] = Math.max(widths[key], stripped);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build border segments
|
|
156
|
+
const topParts = keys.map((k) => "─".repeat(widths[k] + 2));
|
|
157
|
+
const topBorder = `┌${topParts.join("┬")}┐`;
|
|
158
|
+
const sepBorder = `├${topParts.join("┼")}┤`;
|
|
159
|
+
const botBorder = `└${topParts.join("┴")}┘`;
|
|
160
|
+
|
|
161
|
+
// Header row with themed styling
|
|
162
|
+
const headerCells = keys.map((k) =>
|
|
163
|
+
theme.bold(k.padEnd(widths[k])).toString()
|
|
164
|
+
);
|
|
165
|
+
const headerRow = `│ ${headerCells.join(" │ ")} │`;
|
|
166
|
+
|
|
167
|
+
// Data rows
|
|
168
|
+
const dataRows = rows.map((row) => {
|
|
169
|
+
const cells = keys.map((k) => {
|
|
170
|
+
const value = row[k] ?? "";
|
|
171
|
+
return value.padEnd(widths[k]);
|
|
172
|
+
});
|
|
173
|
+
return `│ ${cells.join(" │ ")} │`;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`${theme.dim(topBorder)}\n` +
|
|
178
|
+
`${headerRow}\n` +
|
|
179
|
+
`${theme.dim(sepBorder)}\n` +
|
|
180
|
+
`${dataRows.join("\n")}\n` +
|
|
181
|
+
`${theme.dim(botBorder)}\n`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Output arbitrary data as JSON.
|
|
187
|
+
* - JSON / Quiet mode: JSON stringified (compact or pretty)
|
|
188
|
+
* - Human mode: JSON with 2-space indent
|
|
189
|
+
*/
|
|
190
|
+
export function formatJson(data: unknown, opts?: FormatOptions): void {
|
|
191
|
+
const json = opts?.quiet
|
|
192
|
+
? JSON.stringify(data)
|
|
193
|
+
: JSON.stringify(data, null, 2);
|
|
194
|
+
|
|
195
|
+
process.stdout.write(`${json}\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Output key-value pairs.
|
|
200
|
+
* - JSON mode: JSON object
|
|
201
|
+
* - Quiet mode: prints nothing
|
|
202
|
+
* - Human mode: "label: value" with themed labels
|
|
203
|
+
*/
|
|
204
|
+
export function formatKeyValue(
|
|
205
|
+
pairs: Record<string, string>,
|
|
206
|
+
opts?: FormatOptions
|
|
207
|
+
): void {
|
|
208
|
+
if (opts?.quiet) return;
|
|
209
|
+
|
|
210
|
+
if (opts?.json) {
|
|
211
|
+
process.stdout.write(JSON.stringify(pairs, null, 2) + "\n");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const maxKeyLen = Math.max(...Object.keys(pairs).map((k) => stripAnsi(k).length));
|
|
216
|
+
|
|
217
|
+
for (const [key, value] of Object.entries(pairs)) {
|
|
218
|
+
const paddedKey = key.padEnd(maxKeyLen);
|
|
219
|
+
process.stdout.write(` ${theme.key(paddedKey)} ${theme.dim(":")} ${value}\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Output a section header with decorative separator.
|
|
225
|
+
*/
|
|
226
|
+
export function formatHeader(text: string, opts?: FormatOptions): void {
|
|
227
|
+
if (opts?.quiet) return;
|
|
228
|
+
if (opts?.json) return;
|
|
229
|
+
|
|
230
|
+
process.stdout.write(`\n${theme.heading(text)}\n`);
|
|
231
|
+
process.stdout.write(`${hr()}\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Output a bullet-point list with optional icon per item.
|
|
236
|
+
*/
|
|
237
|
+
export function formatList(
|
|
238
|
+
items: string[],
|
|
239
|
+
opts?: { icon?: string; json?: boolean; quiet?: boolean }
|
|
240
|
+
): void {
|
|
241
|
+
if (opts?.quiet) return;
|
|
242
|
+
|
|
243
|
+
if (opts?.json) {
|
|
244
|
+
process.stdout.write(JSON.stringify(items, null, 2) + "\n");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const icon = opts?.icon ?? icons.arrow;
|
|
249
|
+
for (const item of items) {
|
|
250
|
+
process.stdout.write(` ${icon} ${item}\n`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hoox CLI theme — ansis color palette, icons, and visual primitives.
|
|
3
|
+
* Used by formatters and commands for consistent terminal output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ansis from "ansis";
|
|
7
|
+
|
|
8
|
+
// ── Color primitives ──────────────────────────────────────────────
|
|
9
|
+
const green = ansis.green;
|
|
10
|
+
const red = ansis.red;
|
|
11
|
+
const yellow = ansis.yellow;
|
|
12
|
+
const blue = ansis.blue;
|
|
13
|
+
const cyan = ansis.cyan;
|
|
14
|
+
const magenta = ansis.magenta;
|
|
15
|
+
const dim = ansis.dim;
|
|
16
|
+
const bold = ansis.bold;
|
|
17
|
+
const italic = ansis.italic;
|
|
18
|
+
const underline = ansis.underline;
|
|
19
|
+
const gray = ansis.gray;
|
|
20
|
+
const white = ansis.white;
|
|
21
|
+
const black = ansis.black;
|
|
22
|
+
const bgGreen = ansis.bgGreen;
|
|
23
|
+
const bgRed = ansis.bgRed;
|
|
24
|
+
const bgYellow = ansis.bgYellow;
|
|
25
|
+
const bgBlue = ansis.bgBlue;
|
|
26
|
+
|
|
27
|
+
// ── Semantic theme ────────────────────────────────────────────────
|
|
28
|
+
export const theme = {
|
|
29
|
+
success: green,
|
|
30
|
+
error: red,
|
|
31
|
+
warning: yellow,
|
|
32
|
+
info: blue,
|
|
33
|
+
accent: cyan,
|
|
34
|
+
highlight: magenta,
|
|
35
|
+
dim,
|
|
36
|
+
bold,
|
|
37
|
+
italic,
|
|
38
|
+
underline,
|
|
39
|
+
gray,
|
|
40
|
+
white,
|
|
41
|
+
black,
|
|
42
|
+
heading: bold.cyan,
|
|
43
|
+
label: dim,
|
|
44
|
+
subtle: gray,
|
|
45
|
+
muted: dim.gray,
|
|
46
|
+
value: bold.white,
|
|
47
|
+
key: dim.yellow,
|
|
48
|
+
|
|
49
|
+
// Status-specific
|
|
50
|
+
statusOk: bgGreen.black(" OK "),
|
|
51
|
+
statusWarn: bgYellow.black(" WARN "),
|
|
52
|
+
statusError: bgRed.white(" FAIL "),
|
|
53
|
+
statusInfo: bgBlue.white(" INFO "),
|
|
54
|
+
|
|
55
|
+
// Decorations
|
|
56
|
+
separator: dim("─"),
|
|
57
|
+
pipe: dim("│"),
|
|
58
|
+
corner: dim("┌┐└┘"),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── Icons ─────────────────────────────────────────────────────────
|
|
62
|
+
export const icons = {
|
|
63
|
+
success: "✓",
|
|
64
|
+
error: "✗",
|
|
65
|
+
warning: "!",
|
|
66
|
+
info: "i",
|
|
67
|
+
arrow: "->",
|
|
68
|
+
spinner: ["-", "\\", "|", "/"],
|
|
69
|
+
dot: "●",
|
|
70
|
+
check: "v",
|
|
71
|
+
cross: "x",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Display helpers ───────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Strip ANSI escape codes from a string. */
|
|
77
|
+
export function stripAnsi(str: string): string {
|
|
78
|
+
return ansis.strip(str);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Render a horizontal rule using the separator character. */
|
|
82
|
+
export function hr(width: number = 60): string {
|
|
83
|
+
return theme.separator.repeat(width);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Format a key-value pair for aligned output. */
|
|
87
|
+
export function kv(key: string, value: string): string {
|
|
88
|
+
return `${theme.key(key)} ${theme.dim(":")} ${theme.value(value)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Format a labeled value. */
|
|
92
|
+
export function tagged(label: string, value: string): string {
|
|
93
|
+
return `${theme.key(label)} ${theme.dim(":")} ${theme.value(value)}`;
|
|
94
|
+
}
|