miravo 0.2.2 → 0.3.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 +116 -30
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-BXtW73cF.css +2 -0
- package/console-dist/assets/index-ChSocZFW.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +20 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +10 -7
- package/src/__tests__/integration/executable-methods-runner.ts +52 -45
- package/src/__tests__/integration/executable-methods.integration.ts +609 -47
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +1 -1
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +22 -1
- package/src/commands/models.ts +14 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +976 -192
- package/src/commands/status.test.ts +3 -1
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { EventBus, MiravoEvents } from "@miravo/core";
|
|
2
|
+
import { subscribeToDomainEvents } from "@miravo/core";
|
|
3
|
+
import type { EngineRunState, WSMessage } from "@miravo/shared";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
|
|
6
|
+
/** Channels a client can subscribe to. */
|
|
7
|
+
type Channel = "tick" | "faults" | "lifecycle" | "instances" | "engine" | "adapters";
|
|
8
|
+
|
|
9
|
+
/** All channels enabled by default. */
|
|
10
|
+
const ALL_CHANNELS: ReadonlySet<Channel> = new Set([
|
|
11
|
+
"tick",
|
|
12
|
+
"faults",
|
|
13
|
+
"lifecycle",
|
|
14
|
+
"instances",
|
|
15
|
+
"engine",
|
|
16
|
+
"adapters",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function isChannel(value: string): value is Channel {
|
|
20
|
+
return (ALL_CHANNELS as ReadonlySet<string>).has(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Per-connection state stored via Bun's WebSocket data field. */
|
|
24
|
+
interface WSClientData {
|
|
25
|
+
channels: Set<Channel>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WSHubOptions {
|
|
29
|
+
readonly bus: EventBus;
|
|
30
|
+
readonly getEngineState: () => EngineRunState;
|
|
31
|
+
readonly getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
|
|
32
|
+
readonly maxConnections?: number;
|
|
33
|
+
readonly backpressureThreshold?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_CONNECTIONS = 10;
|
|
37
|
+
const DEFAULT_BACKPRESSURE_THRESHOLD = 1024 * 64; // 64KB send buffer
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* WebSocket hub that fans out engine events to connected clients.
|
|
41
|
+
* Read-only: clients can only subscribe to channels, not send commands.
|
|
42
|
+
*/
|
|
43
|
+
class WSHub {
|
|
44
|
+
readonly #clients = new Set<ServerWebSocket<WSClientData>>();
|
|
45
|
+
readonly #maxConnections: number;
|
|
46
|
+
readonly #backpressureThreshold: number;
|
|
47
|
+
readonly #getEngineState: () => EngineRunState;
|
|
48
|
+
readonly #getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
|
|
49
|
+
readonly #bus: EventBus;
|
|
50
|
+
readonly #tickHandler: (evt: MiravoEvents["tick:complete"]) => void;
|
|
51
|
+
readonly #adapterEnabledHandler: (evt: MiravoEvents["adapter:enabled"]) => void;
|
|
52
|
+
readonly #adapterDisabledHandler: (evt: MiravoEvents["adapter:disabled"]) => void;
|
|
53
|
+
readonly #unsubscribeDomain: () => void;
|
|
54
|
+
#revision = 0;
|
|
55
|
+
|
|
56
|
+
constructor(options: WSHubOptions) {
|
|
57
|
+
this.#maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
58
|
+
this.#backpressureThreshold = options.backpressureThreshold ?? DEFAULT_BACKPRESSURE_THRESHOLD;
|
|
59
|
+
this.#getEngineState = options.getEngineState;
|
|
60
|
+
this.#getTickInfo = options.getTickInfo;
|
|
61
|
+
this.#bus = options.bus;
|
|
62
|
+
|
|
63
|
+
// Tick events are on their own channel (highest frequency)
|
|
64
|
+
this.#tickHandler = (evt) => {
|
|
65
|
+
this.#broadcast("tick", {
|
|
66
|
+
type: "tick",
|
|
67
|
+
revision: this.#nextRevision(),
|
|
68
|
+
tick: evt.tick,
|
|
69
|
+
simulationTime: evt.simulationTime,
|
|
70
|
+
delta: evt.delta.memberChanged,
|
|
71
|
+
instanceCount: evt.graph.assets.size,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
this.#bus.on("tick:complete", this.#tickHandler);
|
|
75
|
+
|
|
76
|
+
// Adapter lifecycle events
|
|
77
|
+
this.#adapterEnabledHandler = (evt) => {
|
|
78
|
+
this.#broadcast("adapters", {
|
|
79
|
+
type: "adapter:enabled",
|
|
80
|
+
revision: this.#nextRevision(),
|
|
81
|
+
name: evt.name,
|
|
82
|
+
endpoint: evt.endpoint,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
this.#adapterDisabledHandler = (evt) => {
|
|
86
|
+
this.#broadcast("adapters", {
|
|
87
|
+
type: "adapter:disabled",
|
|
88
|
+
revision: this.#nextRevision(),
|
|
89
|
+
name: evt.name,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
this.#bus.on("adapter:enabled", this.#adapterEnabledHandler);
|
|
93
|
+
this.#bus.on("adapter:disabled", this.#adapterDisabledHandler);
|
|
94
|
+
|
|
95
|
+
// Domain events use the shared subscriber (stays in sync with EventLog)
|
|
96
|
+
this.#unsubscribeDomain = subscribeToDomainEvents(options.bus, {
|
|
97
|
+
"fault:triggered": (evt) => {
|
|
98
|
+
this.#broadcast("faults", {
|
|
99
|
+
type: "fault:triggered",
|
|
100
|
+
revision: this.#nextRevision(),
|
|
101
|
+
instanceId: evt.instanceId,
|
|
102
|
+
fault: evt.fault,
|
|
103
|
+
effects: evt.effects,
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
"fault:cleared": (evt) => {
|
|
107
|
+
this.#broadcast("faults", {
|
|
108
|
+
type: "fault:cleared",
|
|
109
|
+
revision: this.#nextRevision(),
|
|
110
|
+
instanceId: evt.instanceId,
|
|
111
|
+
fault: evt.fault,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
"lifecycle:changed": (evt) => {
|
|
115
|
+
this.#broadcast("lifecycle", {
|
|
116
|
+
type: "lifecycle:changed",
|
|
117
|
+
revision: this.#nextRevision(),
|
|
118
|
+
instanceId: evt.instanceId,
|
|
119
|
+
from: evt.from,
|
|
120
|
+
to: evt.to,
|
|
121
|
+
operatingHours: evt.operatingHours,
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
"instance:created": (evt) => {
|
|
125
|
+
this.#broadcast("instances", {
|
|
126
|
+
type: "instance:created",
|
|
127
|
+
revision: this.#nextRevision(),
|
|
128
|
+
id: evt.id,
|
|
129
|
+
assetType: evt.assetType,
|
|
130
|
+
browsePath: evt.browsePath,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
"instance:removed": (evt) => {
|
|
134
|
+
this.#broadcast("instances", {
|
|
135
|
+
type: "instance:removed",
|
|
136
|
+
revision: this.#nextRevision(),
|
|
137
|
+
id: evt.id,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
"engine:state-changed": (evt) => {
|
|
141
|
+
this.#broadcast("engine", {
|
|
142
|
+
type: "engine:state",
|
|
143
|
+
revision: this.#nextRevision(),
|
|
144
|
+
from: evt.from,
|
|
145
|
+
to: evt.to,
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#nextRevision(): number {
|
|
152
|
+
return ++this.#revision;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#broadcast(channel: Channel, message: WSMessage): void {
|
|
156
|
+
const json = JSON.stringify(message);
|
|
157
|
+
for (const ws of this.#clients) {
|
|
158
|
+
if (!ws.data.channels.has(channel)) continue;
|
|
159
|
+
|
|
160
|
+
// Backpressure: close congested connections to prevent unbounded buffer growth.
|
|
161
|
+
// The client should reconnect and will receive a fresh snapshot.
|
|
162
|
+
if (ws.getBufferedAmount() > this.#backpressureThreshold) {
|
|
163
|
+
try {
|
|
164
|
+
ws.close(4008, "backpressure");
|
|
165
|
+
} catch {
|
|
166
|
+
// Client may have already disconnected
|
|
167
|
+
}
|
|
168
|
+
this.#clients.delete(ws);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
ws.send(json);
|
|
174
|
+
} catch {
|
|
175
|
+
// Client disconnected — will be cleaned up in onClose
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Called when a new WebSocket connection opens. */
|
|
181
|
+
onOpen(ws: ServerWebSocket<WSClientData>): void {
|
|
182
|
+
if (this.#clients.size >= this.#maxConnections) {
|
|
183
|
+
ws.close(1013, "Max connections exceeded");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.#clients.add(ws);
|
|
188
|
+
|
|
189
|
+
// Send initial snapshot
|
|
190
|
+
const tickInfo = this.#getTickInfo();
|
|
191
|
+
const snapshot: WSMessage = {
|
|
192
|
+
type: "snapshot",
|
|
193
|
+
revision: this.#nextRevision(),
|
|
194
|
+
state: this.#getEngineState(),
|
|
195
|
+
tick: tickInfo.tick,
|
|
196
|
+
simulationTime: tickInfo.simulationTime,
|
|
197
|
+
instanceCount: tickInfo.instanceCount,
|
|
198
|
+
};
|
|
199
|
+
ws.send(JSON.stringify(snapshot));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Called when a WebSocket message is received from a client. */
|
|
203
|
+
onMessage(ws: ServerWebSocket<WSClientData>, message: string | Buffer): void {
|
|
204
|
+
const text = typeof message === "string" ? message : message.toString();
|
|
205
|
+
let parsed: unknown;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(text);
|
|
208
|
+
} catch {
|
|
209
|
+
return; // Ignore invalid JSON
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
213
|
+
const msg = parsed as Record<string, unknown>;
|
|
214
|
+
|
|
215
|
+
if (msg["type"] === "subscribe" && Array.isArray(msg["channels"])) {
|
|
216
|
+
const newChannels = new Set<Channel>();
|
|
217
|
+
for (const ch of msg["channels"]) {
|
|
218
|
+
if (typeof ch === "string" && isChannel(ch)) {
|
|
219
|
+
newChannels.add(ch);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
ws.data.channels = newChannels;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Called when a WebSocket connection closes. */
|
|
227
|
+
onClose(ws: ServerWebSocket<WSClientData>): void {
|
|
228
|
+
this.#clients.delete(ws);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Number of currently connected clients. */
|
|
232
|
+
get connectionCount(): number {
|
|
233
|
+
return this.#clients.size;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Close all connections and unsubscribe from bus events. */
|
|
237
|
+
close(): void {
|
|
238
|
+
this.#bus.off("tick:complete", this.#tickHandler);
|
|
239
|
+
this.#bus.off("adapter:enabled", this.#adapterEnabledHandler);
|
|
240
|
+
this.#bus.off("adapter:disabled", this.#adapterDisabledHandler);
|
|
241
|
+
this.#unsubscribeDomain();
|
|
242
|
+
|
|
243
|
+
for (const ws of this.#clients) {
|
|
244
|
+
try {
|
|
245
|
+
ws.close(1001, "Server shutting down");
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.#clients.clear();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export type { Channel, WSClientData, WSHubOptions };
|
|
255
|
+
export { ALL_CHANNELS, WSHub };
|
package/src/logger.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
engineLog,
|
|
6
6
|
isJsonLogFormat,
|
|
7
7
|
isPlain,
|
|
8
|
-
|
|
8
|
+
isQuiet,
|
|
9
9
|
lockLogger,
|
|
10
10
|
logger,
|
|
11
11
|
parseLogFormat,
|
|
@@ -19,8 +19,8 @@ beforeEach(() => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
afterEach(() => {
|
|
22
|
-
// Restore
|
|
23
|
-
configureLogger({
|
|
22
|
+
// Restore quiet + locked for other test files
|
|
23
|
+
configureLogger({ quiet: true });
|
|
24
24
|
lockLogger();
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -41,35 +41,35 @@ async function captureConsoleLogs<T>(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("configureLogger", () => {
|
|
44
|
-
test("--
|
|
45
|
-
configureLogger({ verbose: true,
|
|
44
|
+
test("--quiet trumps --verbose", () => {
|
|
45
|
+
configureLogger({ verbose: true, quiet: true });
|
|
46
46
|
expect(logger.level).toBe(0);
|
|
47
|
-
expect(
|
|
47
|
+
expect(isQuiet()).toBe(true);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test("--
|
|
51
|
-
configureLogger({
|
|
50
|
+
test("--quiet sets output suppressed", () => {
|
|
51
|
+
configureLogger({ quiet: true });
|
|
52
52
|
expect(logger.level).toBe(0);
|
|
53
|
-
expect(
|
|
53
|
+
expect(isQuiet()).toBe(true);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
test("--verbose sets debug level", () => {
|
|
57
57
|
configureLogger({ verbose: true });
|
|
58
58
|
expect(logger.level).toBe(4);
|
|
59
|
-
expect(
|
|
59
|
+
expect(isQuiet()).toBe(false);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("default sets info level", () => {
|
|
63
63
|
configureLogger({});
|
|
64
64
|
expect(logger.level).toBe(3);
|
|
65
|
-
expect(
|
|
65
|
+
expect(isQuiet()).toBe(false);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
test("--verbose after --
|
|
69
|
-
configureLogger({
|
|
70
|
-
expect(
|
|
68
|
+
test("--verbose after --quiet restores output", () => {
|
|
69
|
+
configureLogger({ quiet: true });
|
|
70
|
+
expect(isQuiet()).toBe(true);
|
|
71
71
|
configureLogger({ verbose: true });
|
|
72
|
-
expect(
|
|
72
|
+
expect(isQuiet()).toBe(false);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
test("--plain enables plain output mode", () => {
|
|
@@ -148,18 +148,18 @@ describe("configureLogger", () => {
|
|
|
148
148
|
expect(calls[0]).toEqual(["red"]);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test("writeOutput is suppressed by --
|
|
151
|
+
test("writeOutput is suppressed by --quiet even in plain mode", async () => {
|
|
152
152
|
const { calls } = await captureConsoleLogs(() => {
|
|
153
|
-
configureLogger({
|
|
153
|
+
configureLogger({ quiet: true, plain: true });
|
|
154
154
|
writeOutput("hidden");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
expect(calls.length).toBe(0);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
test("--
|
|
160
|
+
test("--quiet still emits error logs", async () => {
|
|
161
161
|
const { calls } = await captureConsoleLogs(() => {
|
|
162
|
-
configureLogger({
|
|
162
|
+
configureLogger({ quiet: true, logFormat: "json" });
|
|
163
163
|
engineLog.error("still visible");
|
|
164
164
|
});
|
|
165
165
|
|
package/src/logger.ts
CHANGED
|
@@ -195,7 +195,7 @@ function applyLogReporter(format: LogFormat): void {
|
|
|
195
195
|
/** Options for configuring log verbosity from CLI flags. */
|
|
196
196
|
export interface LoggerOptions {
|
|
197
197
|
readonly verbose?: boolean | undefined;
|
|
198
|
-
readonly
|
|
198
|
+
readonly quiet?: boolean | undefined;
|
|
199
199
|
readonly plain?: boolean | undefined;
|
|
200
200
|
readonly logFormat?: LogFormat | undefined;
|
|
201
201
|
readonly logLevel?: LogLevel | undefined;
|
|
@@ -239,7 +239,7 @@ export function clearRuntimeLogContext(): void {
|
|
|
239
239
|
|
|
240
240
|
/**
|
|
241
241
|
* Set the global log level based on CLI flags.
|
|
242
|
-
* Priority: --
|
|
242
|
+
* Priority: --quiet > --log-level > --verbose > default (info).
|
|
243
243
|
*
|
|
244
244
|
* Consola levels: 0 = errors/fatals, 2 = warn, 3 = info, 4 = debug.
|
|
245
245
|
*/
|
|
@@ -249,8 +249,13 @@ export function configureLogger(opts: LoggerOptions): void {
|
|
|
249
249
|
applyLogReporter(_logFormat);
|
|
250
250
|
_plainOutput = opts.plain ?? false;
|
|
251
251
|
|
|
252
|
+
// NO_COLOR convention: disable colors when set to any non-empty value
|
|
253
|
+
if (!_plainOutput && Bun.env["NO_COLOR"] !== undefined && Bun.env["NO_COLOR"] !== "") {
|
|
254
|
+
_plainOutput = true;
|
|
255
|
+
}
|
|
256
|
+
|
|
252
257
|
let level: number;
|
|
253
|
-
if (opts.
|
|
258
|
+
if (opts.quiet) {
|
|
254
259
|
level = 0;
|
|
255
260
|
_outputSuppressed = true;
|
|
256
261
|
} else if (opts.logLevel) {
|
|
@@ -275,7 +280,7 @@ export function isJsonLogFormat(): boolean {
|
|
|
275
280
|
}
|
|
276
281
|
|
|
277
282
|
/**
|
|
278
|
-
* Write informational output to stdout, respecting --
|
|
283
|
+
* Write informational output to stdout, respecting --quiet.
|
|
279
284
|
* Use this for primary formatted output (tables, boxes, status renders).
|
|
280
285
|
* Do NOT use for machine-readable output (--json, --raw) — those should
|
|
281
286
|
* always go through `process.stdout.write()` or `console.log()` directly.
|
|
@@ -305,8 +310,8 @@ export function writeOutput(...args: unknown[]): void {
|
|
|
305
310
|
console.log(...args);
|
|
306
311
|
}
|
|
307
312
|
|
|
308
|
-
/** Returns true when --
|
|
309
|
-
export function
|
|
313
|
+
/** Returns true when --quiet is active. */
|
|
314
|
+
export function isQuiet(): boolean {
|
|
310
315
|
return _outputSuppressed;
|
|
311
316
|
}
|
|
312
317
|
|
|
@@ -347,3 +352,26 @@ export function handleCommandError(error: unknown, log: ConsolaInstance = config
|
|
|
347
352
|
throw error;
|
|
348
353
|
}
|
|
349
354
|
}
|
|
355
|
+
|
|
356
|
+
interface CommandResultLike {
|
|
357
|
+
readonly success: boolean;
|
|
358
|
+
readonly message: string;
|
|
359
|
+
readonly error?: string | undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Handle the result of an admin command, respecting --json output.
|
|
364
|
+
* Writes success messages to stdout and errors to the control log.
|
|
365
|
+
* Sets process.exitCode = 1 on failure.
|
|
366
|
+
*/
|
|
367
|
+
export function handleCommandResult(result: CommandResultLike, json?: boolean): void {
|
|
368
|
+
if (json) {
|
|
369
|
+
console.log(JSON.stringify(result, null, 2));
|
|
370
|
+
} else if (result.success) {
|
|
371
|
+
writeOutput(result.message);
|
|
372
|
+
} else {
|
|
373
|
+
controlLog.error(result.message);
|
|
374
|
+
if (result.error) controlLog.error(`Fix: ${result.error}`);
|
|
375
|
+
}
|
|
376
|
+
if (!result.success) process.exitCode = 1;
|
|
377
|
+
}
|