miravo 0.2.3 → 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.
Files changed (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +974 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. package/src/logger.ts +34 -6
@@ -8,7 +8,9 @@ describe("miravo status", () => {
8
8
  test("command is registered with correct name and options", async () => {
9
9
  const { cmd: statusCmd } = await findCliCommand("status");
10
10
  expect(statusCmd).toBeDefined();
11
- expect(statusCmd?.description()).toBe("Show status of a running simulation");
11
+ expect(statusCmd?.description()).toBe(
12
+ "Show live status, metrics, and instances of a running simulation",
13
+ );
12
14
 
13
15
  const optionNames = statusCmd?.options.map((o) => o.long);
14
16
  expect(optionNames).toContain("--name");
@@ -8,7 +8,7 @@ import type { Command } from "commander";
8
8
  import { colors } from "consola/utils";
9
9
  import { getAdminSnapshot, resolveAdminUrl } from "../lib/admin-client.ts";
10
10
  import { connectToBroker, disconnectClient } from "../lib/mqtt-client.ts";
11
- import { parseDurationMs } from "../lib/parse.ts";
11
+ import { parseTimeoutSeconds } from "../lib/parse.ts";
12
12
  import { discoverRunningSimulations } from "../lib/resolve-simulation.ts";
13
13
  import { engineLog, handleCommandError, isPlain, writeOutput } from "../logger.ts";
14
14
  import {
@@ -39,7 +39,7 @@ interface StatusOptions {
39
39
  export function registerStatusCommand(program: Command): void {
40
40
  program
41
41
  .command("status")
42
- .description("Show status of a running simulation")
42
+ .description("Show live status, metrics, and instances of a running simulation")
43
43
  .option("--name <name>", "Target simulation name")
44
44
  .option("--discover", "Discover running simulations and exit")
45
45
  .option("-w, --watch", "Poll the admin server and re-render on updates")
@@ -50,10 +50,16 @@ export function registerStatusCommand(program: Command): void {
50
50
  "--template <path>",
51
51
  "Path to .miravo.yaml for MQTT discovery connection info (env: MIRAVO_TEMPLATE)",
52
52
  )
53
- .option(
54
- "--timeout <ms>",
55
- "Admin request timeout in milliseconds (default: 5000)",
56
- parseDurationMs,
53
+ .option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
54
+ .addHelpText(
55
+ "after",
56
+ `
57
+ Examples:
58
+ $ miravo status Show simulation overview
59
+ $ miravo status -w Watch mode (live updates)
60
+ $ miravo status --instance pump-001 Detail view for one instance
61
+ $ miravo status --json Machine-readable JSON output
62
+ $ miravo status --discover Find running simulations via MQTT`,
57
63
  )
58
64
  .action(async (options: StatusOptions) => {
59
65
  await statusAction(options);
@@ -0,0 +1,84 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { DaemonRegistryEntry } from "../lib/daemon-registry.ts";
6
+ import { readEntry, writeEntry } from "../lib/daemon-registry.ts";
7
+ import { resetExitCode, runCliCommand } from "./cli-test-setup.ts";
8
+
9
+ let testHome: string;
10
+ const originalFetch = globalThis.fetch;
11
+ const originalKill = process.kill;
12
+ const forwardKill = originalKill as (pid: number, signal?: number | NodeJS.Signals) => void;
13
+
14
+ function makeEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
15
+ return {
16
+ name: "test-sim",
17
+ pid: 42_424,
18
+ adminPort: 8080,
19
+ adminHost: "127.0.0.1",
20
+ adapters: {},
21
+ templatePath: "/tmp/test.miravo.yaml",
22
+ startedAt: "2026-03-18T10:00:00Z",
23
+ seed: 42,
24
+ runId: "run-123",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ beforeEach(resetExitCode);
30
+
31
+ afterEach(async () => {
32
+ globalThis.fetch = originalFetch;
33
+ process.kill = originalKill;
34
+ delete Bun.env["MIRAVO_HOME"];
35
+ resetExitCode();
36
+ if (testHome) {
37
+ await rm(testHome, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ describe("miravo stop", () => {
42
+ test("force-kills a verified daemon after /shutdown makes /readyz disappear", async () => {
43
+ testHome = await mkdtemp(join(tmpdir(), "miravo-stop-test-"));
44
+ Bun.env["MIRAVO_HOME"] = testHome;
45
+
46
+ let alive = true;
47
+ process.kill = ((pid: number, signal?: number | NodeJS.Signals) => {
48
+ if (pid !== 42_424) {
49
+ return forwardKill(pid, signal);
50
+ }
51
+ if (signal === 0 || signal === undefined) {
52
+ if (!alive) {
53
+ const error = new Error("ESRCH") as NodeJS.ErrnoException;
54
+ error.code = "ESRCH";
55
+ throw error;
56
+ }
57
+ return;
58
+ }
59
+ if (signal === "SIGKILL") {
60
+ alive = false;
61
+ }
62
+ }) as typeof process.kill;
63
+
64
+ globalThis.fetch = (async (input, init) => {
65
+ const url = String(input);
66
+ if (url.endsWith("/readyz")) {
67
+ return Response.json({ status: "paused", runId: "run-123" }, { status: 503 });
68
+ }
69
+ if (url.endsWith("/shutdown")) {
70
+ expect(init?.method).toBe("POST");
71
+ return Response.json({ status: "shutting_down" }, { status: 200 });
72
+ }
73
+ throw new Error(`Unexpected fetch: ${url}`);
74
+ }) as typeof fetch;
75
+
76
+ await writeEntry(makeEntry());
77
+
78
+ await runCliCommand(["stop", "test-sim", "--timeout", "0.001"]);
79
+
80
+ expect(alive).toBe(false);
81
+ expect(await readEntry("test-sim")).toBeNull();
82
+ expect(process.exitCode).toBe(0);
83
+ });
84
+ });
@@ -0,0 +1,158 @@
1
+ import type { Command } from "commander";
2
+ import { isPidAlive, probeEntryIdentity, readEntry, removeEntry } from "../lib/daemon-registry.ts";
3
+ import { parseTimeoutSeconds } from "../lib/parse.ts";
4
+ import { handleCommandError, writeOutput } from "../logger.ts";
5
+
6
+ interface StopOptions {
7
+ readonly json?: boolean;
8
+ readonly timeout?: number;
9
+ }
10
+
11
+ export function registerStopCommand(program: Command): void {
12
+ program
13
+ .command("stop <name>")
14
+ .description("Stop a running background simulation")
15
+ .option("--json", "Machine-readable JSON output")
16
+ .option("--timeout <seconds>", "Shutdown timeout in seconds (default: 10)", parseTimeoutSeconds)
17
+ .addHelpText(
18
+ "after",
19
+ `
20
+ Examples:
21
+ $ miravo stop my-sim Graceful shutdown
22
+ $ miravo stop my-sim --timeout 30 Wait up to 30s before force kill`,
23
+ )
24
+ .action(async (name: string, options: StopOptions) => {
25
+ try {
26
+ await stopAction(name, options);
27
+ } catch (error) {
28
+ handleCommandError(error);
29
+ }
30
+ });
31
+ }
32
+
33
+ function jsonOut(obj: Record<string, unknown>): void {
34
+ console.log(JSON.stringify(obj, null, 2));
35
+ }
36
+
37
+ async function stopAction(name: string, options: StopOptions): Promise<void> {
38
+ const entry = await readEntry(name);
39
+ if (!entry) {
40
+ const msg = `Simulation "${name}" not found. Use 'miravo ps' to list running simulations.`;
41
+ if (options.json) {
42
+ jsonOut({ success: false, name, message: msg });
43
+ } else {
44
+ writeOutput(msg);
45
+ }
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
50
+ // Verify identity before taking any action — guards against PID reuse
51
+ const identity = await probeEntryIdentity(entry);
52
+ if (identity.status === "dead" || identity.status === "mismatch") {
53
+ await removeEntry(name);
54
+ const msg = `Simulation "${name}" was not running (stale registry entry removed).`;
55
+ if (options.json) {
56
+ jsonOut({ success: true, name, message: msg });
57
+ } else {
58
+ writeOutput(msg);
59
+ }
60
+ return;
61
+ }
62
+ if (identity.status === "unreachable") {
63
+ const msg = `Simulation "${name}" could not be safely verified because its admin server is unreachable while PID ${entry.pid} is still alive.`;
64
+ if (options.json) {
65
+ jsonOut({
66
+ success: false,
67
+ name,
68
+ message: msg,
69
+ error: "Retry later, or inspect the process manually before removing the registry entry.",
70
+ });
71
+ } else {
72
+ writeOutput(
73
+ `${msg} Retry later, or inspect the process manually before removing the registry entry.`,
74
+ );
75
+ }
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+
80
+ // Try graceful shutdown via admin server (identity-safe — admin server belongs to this process)
81
+ let shutdownSent = false;
82
+ let canForceKill = true;
83
+ try {
84
+ const adminUrl = `http://${entry.adminHost}:${entry.adminPort}/shutdown`;
85
+ const headers: Record<string, string> = { "content-type": "application/json" };
86
+ const adminToken = Bun.env["MIRAVO_ADMIN_TOKEN"];
87
+ if (adminToken) {
88
+ headers["authorization"] = `Bearer ${adminToken}`;
89
+ }
90
+ const response = await fetch(adminUrl, {
91
+ method: "POST",
92
+ headers,
93
+ body: JSON.stringify({ reason: "miravo stop" }),
94
+ signal: AbortSignal.timeout(2_000),
95
+ });
96
+ shutdownSent = response.ok;
97
+ } catch {
98
+ // Admin server unreachable — fall back to SIGTERM only if we can re-verify identity
99
+ }
100
+
101
+ if (!shutdownSent) {
102
+ try {
103
+ process.kill(entry.pid, "SIGTERM");
104
+ } catch {
105
+ // Process may have already exited
106
+ }
107
+ }
108
+
109
+ // Poll for process exit
110
+ const timeoutMs = options.timeout ?? 10_000;
111
+ const deadline = Date.now() + timeoutMs;
112
+
113
+ while (isPidAlive(entry.pid) && Date.now() < deadline) {
114
+ await Bun.sleep(500);
115
+ }
116
+
117
+ // After /shutdown succeeds the admin server may exit before engine.stop() finishes,
118
+ // so keep the verified identity from the successful request for the SIGKILL fallback.
119
+ if (shutdownSent) {
120
+ canForceKill = true;
121
+ }
122
+
123
+ // Force kill only if still alive and we already verified identity earlier.
124
+ if (isPidAlive(entry.pid) && canForceKill) {
125
+ try {
126
+ process.kill(entry.pid, "SIGKILL");
127
+ } catch {
128
+ // Ignore — process may have just exited
129
+ }
130
+ await Bun.sleep(200);
131
+ }
132
+
133
+ if (isPidAlive(entry.pid)) {
134
+ const msg = `Simulation "${name}" did not exit within the timeout.`;
135
+ if (options.json) {
136
+ jsonOut({
137
+ success: false,
138
+ name,
139
+ message: msg,
140
+ error: "Inspect the process manually and retry with a longer --timeout if needed.",
141
+ });
142
+ } else {
143
+ writeOutput(
144
+ `${msg} Inspect the process manually and retry with a longer --timeout if needed.`,
145
+ );
146
+ }
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+
151
+ await removeEntry(name);
152
+ const msg = `Simulation "${name}" stopped.`;
153
+ if (options.json) {
154
+ jsonOut({ success: true, name, message: msg });
155
+ } else {
156
+ writeOutput(msg);
157
+ }
158
+ }
@@ -7,7 +7,7 @@ import { configureLogger } from "../logger.ts";
7
7
  import { runCliCommand } from "./cli-test-setup.ts";
8
8
  import { formatTemplateInspect } from "./templates.ts";
9
9
 
10
- configureLogger({ silent: true });
10
+ configureLogger({ quiet: true });
11
11
 
12
12
  const templatesDir = resolve(import.meta.dir, "..", "..", "..", "content", "templates");
13
13
  const { cleanupTmpDir } = createTmpFileUtils(resolve(import.meta.dir, ".tmp-test-templates"));
@@ -20,6 +20,12 @@ export function registerTemplatesCommand(program: Command): void {
20
20
  .command("list")
21
21
  .description("Discover and list available templates")
22
22
  .option("--dir <path>", "Additional directory to search for templates")
23
+ .addHelpText(
24
+ "after",
25
+ `
26
+ Examples:
27
+ $ miravo templates list List all discoverable templates`,
28
+ )
23
29
  .action(async (options: { readonly dir?: string }) => {
24
30
  await templatesListAction(options);
25
31
  });
@@ -28,6 +34,13 @@ export function registerTemplatesCommand(program: Command): void {
28
34
  .command("inspect <idNameOrPath>")
29
35
  .description("Show detailed information about a template by id, name, or path")
30
36
  .option("--raw", "Dump the raw YAML file to stdout")
37
+ .addHelpText(
38
+ "after",
39
+ `
40
+ Examples:
41
+ $ miravo templates inspect water-treatment Show template details
42
+ $ miravo templates inspect water-treatment --raw Dump raw YAML`,
43
+ )
31
44
  .action(
32
45
  async (
33
46
  idNameOrPath: string,
package/src/index.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  import { MIRAVO_VERSION } from "@miravo/shared";
3
3
  import { Command, InvalidOptionArgumentError } from "commander";
4
+ import { registerAdapterCommand } from "./commands/adapter.ts";
4
5
  import { registerForkCommand } from "./commands/fork.ts";
5
6
  import { registerInjectCommand } from "./commands/inject.ts";
7
+ import { registerLoadCommand } from "./commands/load.ts";
8
+ import { registerLogsCommand } from "./commands/logs.ts";
6
9
  import { registerModelsCommand } from "./commands/models.ts";
10
+ import { registerPauseCommand, registerResumeCommand } from "./commands/pause.ts";
11
+ import { registerPsCommand } from "./commands/ps.ts";
12
+ import { registerRemoveCommand } from "./commands/remove.ts";
13
+ import { registerResetCommand } from "./commands/reset.ts";
14
+ import { registerRmCommand } from "./commands/rm.ts";
15
+ import { registerSpawnCommand } from "./commands/spawn.ts";
16
+ import { registerSpeedCommand } from "./commands/speed.ts";
7
17
  import { registerStartCommand } from "./commands/start.ts";
8
18
  import { registerStatusCommand } from "./commands/status.ts";
19
+ import { registerStopCommand } from "./commands/stop.ts";
9
20
  import { registerTemplatesCommand } from "./commands/templates.ts";
10
- import { configureLogger, parseLogFormat, parseLogLevel } from "./logger.ts";
21
+ import { configureLogger, parseLogFormat, parseLogLevel, writeOutput } from "./logger.ts";
11
22
 
12
23
  /**
13
24
  * Create and configure the root Commander.js program.
@@ -20,8 +31,10 @@ export function createProgram(): Command {
20
31
  .name("miravo")
21
32
  .description("Industrial environment platform for realistic software-defined plants")
22
33
  .version(MIRAVO_VERSION, "-V, --version", "Print version")
34
+ .helpOption("-h, --help", "Show help for command")
35
+ .addHelpCommand("help [command]", "Show help for command")
23
36
  .option("-v, --verbose", "Shorthand for --log-level debug")
24
- .option("--silent", "Suppress all output except errors")
37
+ .option("-q, --quiet", "Suppress all output except errors")
25
38
  .option("--plain", "Use stable plain-text output (no ANSI styling)")
26
39
  .option("--log-level <level>", "Log level: debug | info | warn | error", (value: string) => {
27
40
  try {
@@ -44,36 +57,65 @@ export function createProgram(): Command {
44
57
  },
45
58
  "pretty",
46
59
  )
60
+ .addHelpText("afterAll", "\nDocs: https://github.com/amine-amaach/miravo")
61
+ .action(() => {
62
+ writeOutput(`miravo v${MIRAVO_VERSION} — Industrial environment platform
63
+
64
+ Quick start:
65
+ $ miravo start Start with auto-detected template
66
+ $ miravo status View simulation dashboard
67
+ $ miravo ps List background simulations
68
+
69
+ Run miravo --help for all commands.
70
+ Docs: https://github.com/amine-amaach/miravo`);
71
+ })
47
72
  .hook("preAction", (_thisCommand, actionCommand) => {
48
73
  const rootOpts = program.opts<{
49
74
  verbose?: boolean;
50
- silent?: boolean;
75
+ quiet?: boolean;
51
76
  plain?: boolean;
52
77
  logLevel?: "debug" | "info" | "warn" | "error";
53
78
  logFormat?: "pretty" | "json";
54
79
  }>();
55
80
  const cmdOpts = actionCommand.opts<{
56
81
  verbose?: boolean;
57
- silent?: boolean;
82
+ quiet?: boolean;
58
83
  plain?: boolean;
59
84
  logLevel?: "debug" | "info" | "warn" | "error";
60
85
  logFormat?: "pretty" | "json";
61
86
  }>();
62
87
  configureLogger({
63
88
  verbose: rootOpts.verbose ?? cmdOpts.verbose,
64
- silent: rootOpts.silent ?? cmdOpts.silent,
89
+ quiet: rootOpts.quiet ?? cmdOpts.quiet,
65
90
  plain: rootOpts.plain ?? cmdOpts.plain,
66
91
  logLevel: rootOpts.logLevel ?? cmdOpts.logLevel,
67
92
  logFormat: rootOpts.logFormat ?? cmdOpts.logFormat,
68
93
  });
69
94
  });
70
95
 
96
+ // Simulation lifecycle
71
97
  registerStartCommand(program);
98
+ registerStatusCommand(program);
99
+ registerPsCommand(program);
100
+ registerStopCommand(program);
101
+ registerLogsCommand(program);
102
+ registerRmCommand(program);
103
+
104
+ // Runtime control
105
+ registerSpawnCommand(program);
106
+ registerRemoveCommand(program);
107
+ registerPauseCommand(program);
108
+ registerResumeCommand(program);
109
+ registerSpeedCommand(program);
110
+ registerLoadCommand(program);
111
+ registerResetCommand(program);
112
+ registerInjectCommand(program);
113
+ registerAdapterCommand(program);
114
+
115
+ // Content discovery
72
116
  registerModelsCommand(program);
73
117
  registerTemplatesCommand(program);
74
118
  registerForkCommand(program);
75
- registerInjectCommand(program);
76
- registerStatusCommand(program);
77
119
 
78
120
  return program;
79
121
  }
@@ -0,0 +1,35 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { EngineCommand } from "@miravo/shared";
3
+ import { executeAdminCommand } from "./admin-client.ts";
4
+
5
+ const originalFetch = globalThis.fetch;
6
+
7
+ afterEach(() => {
8
+ globalThis.fetch = originalFetch;
9
+ delete Bun.env["MIRAVO_ADMIN_TOKEN"];
10
+ });
11
+
12
+ describe("admin-client", () => {
13
+ test("forwards MIRAVO_ADMIN_TOKEN on command requests", async () => {
14
+ Bun.env["MIRAVO_ADMIN_TOKEN"] = "secret-token";
15
+
16
+ globalThis.fetch = (async (_input, init) => {
17
+ expect(init?.headers).toEqual({
18
+ "content-type": "application/json",
19
+ authorization: "Bearer secret-token",
20
+ });
21
+ return Response.json({
22
+ success: true,
23
+ command: "pause",
24
+ correlationId: "req-1",
25
+ message: "paused",
26
+ });
27
+ }) as typeof fetch;
28
+
29
+ const command: EngineCommand = { type: "pause", correlationId: "req-1" };
30
+ const result = await executeAdminCommand("http://127.0.0.1:8080", command, 1_000);
31
+
32
+ expect(result.success).toBe(true);
33
+ expect(result.command).toBe("pause");
34
+ });
35
+ });
@@ -87,6 +87,18 @@ function parseAdminUrl(input: string): string {
87
87
  return url.toString().replace(/\/$/, "");
88
88
  }
89
89
 
90
+ function buildRequestHeaders(body?: string): Record<string, string> | undefined {
91
+ const headers: Record<string, string> = {};
92
+ if (body) {
93
+ headers["content-type"] = "application/json";
94
+ }
95
+ const adminToken = Bun.env["MIRAVO_ADMIN_TOKEN"];
96
+ if (adminToken && adminToken.trim().length > 0) {
97
+ headers["authorization"] = `Bearer ${adminToken}`;
98
+ }
99
+ return Object.keys(headers).length > 0 ? headers : undefined;
100
+ }
101
+
90
102
  async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
91
103
  const requestUrl = new URL(options.path, `${options.baseUrl}/`).toString();
92
104
  const controller = new AbortController();
@@ -97,7 +109,7 @@ async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
97
109
  try {
98
110
  response = await fetch(requestUrl, {
99
111
  method: options.method ?? "GET",
100
- headers: options.body ? { "content-type": "application/json" } : undefined,
112
+ headers: buildRequestHeaders(options.body),
101
113
  body: options.body,
102
114
  signal: controller.signal,
103
115
  });