@moor-sh/cli 0.2.1 → 0.4.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/package.json +1 -1
- package/src/commands/history.test.ts +37 -0
- package/src/commands/history.ts +117 -0
- package/src/commands/stats.ts +6 -1
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// #133: regression test for the history arg parser. The original took the
|
|
2
|
+
// first non-flag token as the project, so `--hours 1 fipe-pg` silently used
|
|
3
|
+
// the hours value ("1") as the project. Parsing must be order-independent.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { parseHistoryArgs } from "./history";
|
|
7
|
+
|
|
8
|
+
describe("parseHistoryArgs", () => {
|
|
9
|
+
test("project then flag", () => {
|
|
10
|
+
expect(parseHistoryArgs(["fipe-pg", "--hours", "1"])).toEqual({ project: "fipe-pg", hours: 1 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("flag then project (regression: was mis-parsed as project '1')", () => {
|
|
14
|
+
expect(parseHistoryArgs(["--hours", "1", "fipe-pg"])).toEqual({ project: "fipe-pg", hours: 1 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("--hours=N form", () => {
|
|
18
|
+
expect(parseHistoryArgs(["--hours=6", "fipe-pg"])).toEqual({ project: "fipe-pg", hours: 6 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("default hours is 24 when omitted", () => {
|
|
22
|
+
expect(parseHistoryArgs(["fipe-pg"])).toEqual({ project: "fipe-pg", hours: 24 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("missing project → error, no project", () => {
|
|
26
|
+
const r = parseHistoryArgs(["--hours", "1"]);
|
|
27
|
+
expect(r.project).toBeUndefined();
|
|
28
|
+
expect(r.error).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("invalid hours → error", () => {
|
|
32
|
+
expect(parseHistoryArgs(["fipe-pg", "--hours", "0"]).error).toBeTruthy();
|
|
33
|
+
expect(parseHistoryArgs(["fipe-pg", "--hours", "-3"]).error).toBeTruthy();
|
|
34
|
+
expect(parseHistoryArgs(["fipe-pg", "--hours", "abc"]).error).toBeTruthy();
|
|
35
|
+
expect(parseHistoryArgs(["fipe-pg", "--hours"]).error).toBeTruthy(); // no value
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { apiGet, resolveProject } from "../client";
|
|
2
|
+
|
|
3
|
+
type HistoryResponse = {
|
|
4
|
+
from_ms: number;
|
|
5
|
+
to_ms: number;
|
|
6
|
+
events: Array<{ occurred_at_ms: number; source: string; action: string }>;
|
|
7
|
+
summary: {
|
|
8
|
+
sample_count: number;
|
|
9
|
+
running_sample_count: number;
|
|
10
|
+
cpu_percent_avg: number | null;
|
|
11
|
+
cpu_percent_max: number | null;
|
|
12
|
+
mem_bytes_max: number | null;
|
|
13
|
+
net_rx_bytes_total: number;
|
|
14
|
+
net_tx_bytes_total: number;
|
|
15
|
+
event_counts: Record<string, number>;
|
|
16
|
+
has_gap: boolean;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatBytes(bytes: number): string {
|
|
21
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
22
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
23
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
24
|
+
const val = bytes / 1024 ** i;
|
|
25
|
+
return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parse `<project> [--hours N]` in any order. Critically, `--hours`'s value is
|
|
29
|
+
// consumed as the flag's argument, not mistaken for the project — so both
|
|
30
|
+
// `history p --hours 1` and `history --hours 1 p` resolve `p` (the old
|
|
31
|
+
// first-non-flag-token approach silently took the hours value as the project).
|
|
32
|
+
// Also accepts `--hours=N`. Pure + exported for tests.
|
|
33
|
+
export function parseHistoryArgs(args: string[]): {
|
|
34
|
+
project?: string;
|
|
35
|
+
hours: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
} {
|
|
38
|
+
let project: string | undefined;
|
|
39
|
+
let hours = 24;
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const a = args[i];
|
|
42
|
+
if (a === "--hours") {
|
|
43
|
+
const n = Number(args[++i]); // consume the next token as the value
|
|
44
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
45
|
+
return { hours, error: "--hours must be a positive number" };
|
|
46
|
+
hours = n;
|
|
47
|
+
} else if (a.startsWith("--hours=")) {
|
|
48
|
+
const n = Number(a.slice("--hours=".length));
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
50
|
+
return { hours, error: "--hours must be a positive number" };
|
|
51
|
+
hours = n;
|
|
52
|
+
} else if (!a.startsWith("-") && project === undefined) {
|
|
53
|
+
project = a;
|
|
54
|
+
}
|
|
55
|
+
// other flags / extra positionals are ignored
|
|
56
|
+
}
|
|
57
|
+
if (!project) return { hours, error: "missing project" };
|
|
58
|
+
return { project, hours };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Stored resource history + lifecycle events for one project (not live — use
|
|
62
|
+
// `moor stats` for the host snapshot). Mirrors the moor_project_history MCP
|
|
63
|
+
// tool: a window summary plus recent events.
|
|
64
|
+
export async function historyCommand(args: string[]) {
|
|
65
|
+
const parsed = parseHistoryArgs(args);
|
|
66
|
+
if (parsed.error || !parsed.project) {
|
|
67
|
+
console.error(
|
|
68
|
+
parsed.error && parsed.error !== "missing project"
|
|
69
|
+
? parsed.error
|
|
70
|
+
: "Usage: moor history <project> [--hours N]",
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const project = await resolveProject(parsed.project);
|
|
76
|
+
const to = Date.now();
|
|
77
|
+
const from = to - parsed.hours * 3_600_000;
|
|
78
|
+
const res = await apiGet(`/api/projects/${project.id}/stats/history?from=${from}&to=${to}`);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
console.error(`Failed to get history: ${res.status} ${await res.text()}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const h = (await res.json()) as HistoryResponse;
|
|
85
|
+
const s = h.summary;
|
|
86
|
+
const windowH = Math.round(((h.to_ms - h.from_ms) / 3_600_000) * 10) / 10;
|
|
87
|
+
|
|
88
|
+
console.log(`${project.name} — history over ~${windowH}h`);
|
|
89
|
+
if (s.has_gap) {
|
|
90
|
+
console.log(" \x1b[33m⚠ event gap recorded: events may be incomplete\x1b[0m");
|
|
91
|
+
}
|
|
92
|
+
console.log(` Samples: ${s.sample_count} total, ${s.running_sample_count} running`);
|
|
93
|
+
console.log(
|
|
94
|
+
` CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`,
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
` Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`,
|
|
98
|
+
);
|
|
99
|
+
console.log(
|
|
100
|
+
` Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`,
|
|
101
|
+
);
|
|
102
|
+
const counts = Object.entries(s.event_counts);
|
|
103
|
+
if (counts.length > 0) {
|
|
104
|
+
console.log(` Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const recent = h.events.slice(-10);
|
|
108
|
+
if (recent.length > 0) {
|
|
109
|
+
console.log("\nRecent events:");
|
|
110
|
+
for (const e of recent) {
|
|
111
|
+
console.log(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (s.sample_count === 0 && h.events.length === 0) {
|
|
115
|
+
console.log(" (no stored history in this window)");
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/commands/stats.ts
CHANGED
|
@@ -7,6 +7,7 @@ type ServerStats = {
|
|
|
7
7
|
cpu: { percent: number; cores: number };
|
|
8
8
|
memory: { total: string; used: string; percent: number };
|
|
9
9
|
disk: { total: string; used: string; percent: number };
|
|
10
|
+
disks?: { mount: string; total: string; used: string; percent: number }[];
|
|
10
11
|
containers: { running: number; total: number };
|
|
11
12
|
};
|
|
12
13
|
|
|
@@ -24,6 +25,10 @@ export async function statsCommand() {
|
|
|
24
25
|
console.log(`Uptime: ${s.uptime}`);
|
|
25
26
|
console.log(`CPU: ${s.cpu.percent}% (${s.cpu.cores} cores)`);
|
|
26
27
|
console.log(`Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`);
|
|
27
|
-
|
|
28
|
+
const disks = s.disks?.length ? s.disks : [{ mount: "/", ...s.disk }];
|
|
29
|
+
disks.forEach((d, i) => {
|
|
30
|
+
const label = i === 0 ? "Disk:" : " ";
|
|
31
|
+
console.log(`${label} ${d.mount} ${d.used} / ${d.total} (${d.percent}%)`);
|
|
32
|
+
});
|
|
28
33
|
console.log(`Containers: ${s.containers.running} running / ${s.containers.total} total`);
|
|
29
34
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { envCommand } from "./commands/env";
|
|
5
5
|
import { execCommand } from "./commands/exec";
|
|
6
|
+
import { historyCommand } from "./commands/history";
|
|
6
7
|
import { logsCommand } from "./commands/logs";
|
|
7
8
|
import { mcpCommand } from "./commands/mcp";
|
|
8
9
|
import { rebuildCommand } from "./commands/rebuild";
|
|
@@ -34,6 +35,7 @@ Commands:
|
|
|
34
35
|
env list <project> List environment variables
|
|
35
36
|
env set <project> K=V [K=V ...] Set environment variables
|
|
36
37
|
stats Show server resource usage
|
|
38
|
+
history <project> [--hours N] Stored resource history + events (default 24h)
|
|
37
39
|
mcp config --client <name> Generate MCP client config snippet
|
|
38
40
|
|
|
39
41
|
Environment:
|
|
@@ -66,6 +68,9 @@ switch (command) {
|
|
|
66
68
|
case "stats":
|
|
67
69
|
await statsCommand();
|
|
68
70
|
break;
|
|
71
|
+
case "history":
|
|
72
|
+
await historyCommand(args.slice(1));
|
|
73
|
+
break;
|
|
69
74
|
case "mcp":
|
|
70
75
|
mcpCommand(args.slice(1));
|
|
71
76
|
break;
|