@blackasteroid/kuma-cli 1.1.0 → 1.2.1

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 (3) hide show
  1. package/README.md +68 -24
  2. package/dist/index.js +484 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # kuma-cli
2
2
 
3
- > CLI for managing [Uptime Kuma](https://github.com/louislam/uptime-kuma) via its native Socket.IO API. No more clicking through the web panel — manage monitors, status pages, and heartbeats directly from your terminal.
3
+ > CLI for managing [Uptime Kuma](https://github.com/louislam/uptime-kuma) via its native Socket.IO API. No more clicking through the web panel — manage monitors, status pages, and heartbeats from your terminal.
4
4
 
5
5
  ## Install
6
6
 
@@ -14,7 +14,7 @@ Or use without installing:
14
14
  npx @blackasteroid/kuma-cli login https://kuma.example.com
15
15
  ```
16
16
 
17
- ## Quick Start
17
+ ## Quick start
18
18
 
19
19
  ```bash
20
20
  # 1. Authenticate
@@ -23,8 +23,8 @@ kuma login https://kuma.example.com
23
23
  # 2. List monitors
24
24
  kuma monitors list
25
25
 
26
- # 3. Add a monitor
27
- kuma monitors add --name "My API" --type http --url https://api.example.com
26
+ # 3. Create a monitor
27
+ kuma monitors create --name "My API" --type http --url https://api.example.com
28
28
  ```
29
29
 
30
30
  ## Commands
@@ -43,46 +43,90 @@ kuma monitors add --name "My API" --type http --url https://api.example.com
43
43
  |---------|-------------|
44
44
  | `kuma monitors list` | List all monitors with status + uptime |
45
45
  | `kuma monitors list --json` | Output raw JSON (for scripting) |
46
+ | `kuma monitors list --status down --json` | Filter by status |
47
+ | `kuma monitors list --tag <tag> --json` | Filter by tag |
46
48
  | `kuma monitors add` | Add a monitor interactively |
47
49
  | `kuma monitors add --name <n> --type http --url <url>` | Add non-interactively |
50
+ | `kuma monitors create --type http --name <n> --url <url>` | Create monitor non-interactively (pipeline-safe) |
51
+ | `kuma monitors create --type push --name <n> --json` | Create push monitor, returns pushToken |
48
52
  | `kuma monitors update <id>` | Update name/url/interval of a monitor |
49
53
  | `kuma monitors delete <id>` | Delete a monitor (with confirmation) |
50
54
  | `kuma monitors delete <id> --force` | Delete without confirmation prompt |
51
55
  | `kuma monitors pause <id>` | Pause a monitor |
52
56
  | `kuma monitors resume <id>` | Resume a paused monitor |
57
+ | `kuma monitors bulk-pause --tag <tag>` | Pause all monitors matching tag |
58
+ | `kuma monitors bulk-pause --tag <tag> --dry-run` | Preview without touching anything |
59
+ | `kuma monitors bulk-resume --tag <tag>` | Resume all monitors matching tag |
60
+ | `kuma monitors set-notification <id> --notification-id <id>` | Assign notification to monitor |
53
61
 
54
- #### `monitors add` flags
55
-
56
- | Flag | Description | Default |
57
- |------|-------------|---------|
58
- | `--name <name>` | Monitor name | (prompted) |
59
- | `--type <type>` | Monitor type: `http`, `tcp`, `ping`, `dns`, `push`, ... | (prompted) |
60
- | `--url <url>` | URL or hostname to monitor | (prompted) |
61
- | `--interval <seconds>` | Check interval | `60` |
62
-
63
- #### `monitors update` flags
62
+ ### Heartbeats
64
63
 
65
- | Flag | Description |
66
- |------|-------------|
67
- | `--name <name>` | New monitor name |
68
- | `--url <url>` | New URL or hostname |
69
- | `--interval <seconds>` | New check interval |
64
+ | Command | Description |
65
+ |---------|-------------|
66
+ | `kuma heartbeat view <monitor-id>` | View last 20 heartbeats |
67
+ | `kuma heartbeat view <monitor-id> --limit 50` | Show last N heartbeats |
68
+ | `kuma heartbeat view <monitor-id> --json` | Output raw JSON |
69
+ | `kuma heartbeat send <push-token>` | Send push heartbeat (no auth needed) |
70
+ | `kuma heartbeat send <push-token> --status down --msg "text"` | Send with status/message |
70
71
 
71
- ### Heartbeats
72
+ ### Notifications
72
73
 
73
74
  | Command | Description |
74
75
  |---------|-------------|
75
- | `kuma heartbeat <monitor-id>` | View last 20 heartbeats for a monitor |
76
- | `kuma heartbeat <monitor-id> --limit 50` | Show last N heartbeats |
77
- | `kuma heartbeat <monitor-id> --json` | Output raw JSON |
76
+ | `kuma notifications list` | List all notification channels |
77
+ | `kuma notifications create --type discord --name <n> --url <webhook>` | Create Discord notification channel |
78
78
 
79
- ### Status Pages
79
+ ### Status pages
80
80
 
81
81
  | Command | Description |
82
82
  |---------|-------------|
83
83
  | `kuma status-pages list` | List all status pages |
84
84
  | `kuma status-pages list --json` | Output raw JSON |
85
85
 
86
+ ## Using with AI agents
87
+
88
+ kuma-cli works well in agent and automation contexts. Every command supports `--json` output and exits non-zero on errors, so you can parse results reliably and short-circuit on failure.
89
+
90
+ Set `KUMA_JSON=1` to force JSON output on all commands — useful when you don't control the call site.
91
+
92
+ **Check what's down:**
93
+ ```bash
94
+ kuma monitors list --status down --json
95
+ ```
96
+
97
+ **Pause/resume around a deploy:**
98
+ ```bash
99
+ kuma monitors bulk-pause --tag Production --dry-run # preview first
100
+ kuma monitors bulk-pause --tag Production
101
+ ./deploy.sh
102
+ kuma monitors bulk-resume --tag Production
103
+ ```
104
+
105
+ **Create a monitor and wire up a notification in one shot:**
106
+ ```bash
107
+ MONITOR_ID=$(kuma monitors create --type http --name "my-service" \
108
+ --url https://my-service.com --tag Production --json | jq -r '.data.id')
109
+ kuma monitors set-notification $MONITOR_ID --notification-id 1
110
+ ```
111
+
112
+ **Push monitor for a GitHub Actions runner:**
113
+ ```bash
114
+ # Create the monitor, capture the token
115
+ TOKEN=$(kuma monitors create --type push --name "runner-aang" --json | jq -r '.data.pushToken')
116
+
117
+ # In the workflow:
118
+ - name: Heartbeat
119
+ run: kuma heartbeat send ${{ secrets.RUNNER_PUSH_TOKEN }}
120
+ ```
121
+
122
+ **Connect a notification channel to all production monitors:**
123
+ ```bash
124
+ NOTIF_ID=$(kuma notifications create --type discord --name "alerts" \
125
+ --url $WEBHOOK --json | jq -r '.data.id')
126
+ kuma monitors list --tag Production --json | jq -r '.[].id' | \
127
+ xargs -I{} kuma monitors set-notification {} --notification-id $NOTIF_ID
128
+ ```
129
+
86
130
  ## Config
87
131
 
88
132
  After login, your session is saved automatically — you won't need to re-authenticate on every command:
package/dist/index.js CHANGED
@@ -27808,10 +27808,16 @@ var KumaClient = class {
27808
27808
  // BUG-01 fix: addMonitor uses callback, not a separate event
27809
27809
  // BUG-03 fix: include required fields accepted_statuscodes, maxretries, retryInterval
27810
27810
  async addMonitor(monitor) {
27811
+ const autoToken = monitor.type === "push" && !monitor.pushToken ? Array.from(crypto.getRandomValues(new Uint8Array(24))).map((b) => b.toString(16).padStart(2, "0")).join("") : void 0;
27811
27812
  const payload = {
27812
27813
  accepted_statuscodes: ["200-299"],
27813
27814
  maxretries: 1,
27814
27815
  retryInterval: 60,
27816
+ conditions: [],
27817
+ rabbitmqNodes: [],
27818
+ kafkaProducerBrokers: [],
27819
+ kafkaProducerSaslOptions: { mechanism: "none" },
27820
+ ...autoToken ? { pushToken: autoToken } : {},
27815
27821
  ...monitor
27816
27822
  };
27817
27823
  return new Promise((resolve, reject) => {
@@ -27828,7 +27834,11 @@ var KumaClient = class {
27828
27834
  reject(new Error(result.msg ?? "Failed to add monitor"));
27829
27835
  return;
27830
27836
  }
27831
- resolve({ id: result.monitorID });
27837
+ resolve({
27838
+ id: result.monitorID,
27839
+ // Return the token we generated so the caller has it immediately
27840
+ pushToken: payload.pushToken
27841
+ });
27832
27842
  }
27833
27843
  );
27834
27844
  });
@@ -27969,6 +27979,50 @@ var KumaClient = class {
27969
27979
  });
27970
27980
  }
27971
27981
  // ---------------------------------------------------------------------------
27982
+ // Tags
27983
+ // ---------------------------------------------------------------------------
27984
+ /** Get all tags defined in Kuma. Callback-based event. */
27985
+ async getTags() {
27986
+ return new Promise((resolve, reject) => {
27987
+ const timer = setTimeout(() => reject(new Error("getTags timeout")), 1e4);
27988
+ this.socket.emit(
27989
+ "getTags",
27990
+ (result) => {
27991
+ clearTimeout(timer);
27992
+ if (!result.ok) {
27993
+ reject(new Error(result.msg ?? "Failed to fetch tags"));
27994
+ return;
27995
+ }
27996
+ resolve(result.tags ?? []);
27997
+ }
27998
+ );
27999
+ });
28000
+ }
28001
+ /**
28002
+ * Add a tag to a monitor.
28003
+ * socket.emit("addMonitorTag", tagID, monitorID, value, callback)
28004
+ * value is a user-defined label string (can be empty "").
28005
+ */
28006
+ async addMonitorTag(tagId, monitorId, value2 = "") {
28007
+ return new Promise((resolve, reject) => {
28008
+ const timer = setTimeout(() => reject(new Error("addMonitorTag timeout")), 1e4);
28009
+ this.socket.emit(
28010
+ "addMonitorTag",
28011
+ tagId,
28012
+ monitorId,
28013
+ value2,
28014
+ (result) => {
28015
+ clearTimeout(timer);
28016
+ if (!result.ok) {
28017
+ reject(new Error(result.msg ?? "Failed to add tag to monitor"));
28018
+ return;
28019
+ }
28020
+ resolve();
28021
+ }
28022
+ );
28023
+ });
28024
+ }
28025
+ // ---------------------------------------------------------------------------
27972
28026
  // Notifications
27973
28027
  // ---------------------------------------------------------------------------
27974
28028
  /**
@@ -28072,6 +28126,45 @@ var KumaClient = class {
28072
28126
  );
28073
28127
  });
28074
28128
  }
28129
+ // ---------------------------------------------------------------------------
28130
+ // Bulk operations
28131
+ // ---------------------------------------------------------------------------
28132
+ /**
28133
+ * Pause all monitors matching a filter function.
28134
+ * Returns a list of { id, name, ok, error? } results.
28135
+ */
28136
+ async bulkPause(filter) {
28137
+ const monitorMap = await this.getMonitorList();
28138
+ const targets = Object.values(monitorMap).filter(filter);
28139
+ const results = [];
28140
+ for (const m of targets) {
28141
+ try {
28142
+ await this.pauseMonitor(m.id);
28143
+ results.push({ id: m.id, name: m.name, ok: true });
28144
+ } catch (e) {
28145
+ results.push({ id: m.id, name: m.name, ok: false, error: e.message });
28146
+ }
28147
+ }
28148
+ return results;
28149
+ }
28150
+ /**
28151
+ * Resume all monitors matching a filter function.
28152
+ * Returns a list of { id, name, ok, error? } results.
28153
+ */
28154
+ async bulkResume(filter) {
28155
+ const monitorMap = await this.getMonitorList();
28156
+ const targets = Object.values(monitorMap).filter(filter);
28157
+ const results = [];
28158
+ for (const m of targets) {
28159
+ try {
28160
+ await this.resumeMonitor(m.id);
28161
+ results.push({ id: m.id, name: m.name, ok: true });
28162
+ } catch (e) {
28163
+ results.push({ id: m.id, name: m.name, ok: false, error: e.message });
28164
+ }
28165
+ }
28166
+ return results;
28167
+ }
28075
28168
  disconnect() {
28076
28169
  this.socket.disconnect();
28077
28170
  }
@@ -30204,9 +30297,9 @@ function isJsonMode(opts) {
30204
30297
  const env3 = process.env["KUMA_JSON"];
30205
30298
  return env3 === "1" || env3 === "true" || env3 === "yes";
30206
30299
  }
30207
- function jsonOut(data) {
30300
+ function jsonOut(data, exitCode = 0) {
30208
30301
  console.log(JSON.stringify({ ok: true, data }, null, 2));
30209
- process.exit(0);
30302
+ process.exit(exitCode);
30210
30303
  }
30211
30304
  function jsonError(message, code = 1) {
30212
30305
  console.log(JSON.stringify({ ok: false, error: message, code }, null, 2));
@@ -30273,6 +30366,17 @@ ${source_default.dim("Notes:")}
30273
30366
  const json = isJsonMode(opts);
30274
30367
  try {
30275
30368
  const normalizedUrl = url2.replace(/\/$/, "");
30369
+ if (!normalizedUrl.startsWith("https://")) {
30370
+ if (json) {
30371
+ console.log(JSON.stringify({
30372
+ warning: "Connecting over HTTP. Credentials will be transmitted in cleartext. Use HTTPS in production."
30373
+ }));
30374
+ } else {
30375
+ console.warn(source_default.yellow(
30376
+ "\u26A0\uFE0F Warning: connecting over HTTP. Your credentials will be sent in cleartext.\n Use https:// in production environments."
30377
+ ));
30378
+ }
30379
+ }
30276
30380
  const answers = await prompt([
30277
30381
  {
30278
30382
  type: "input",
@@ -30339,6 +30443,12 @@ ${source_default.dim("Examples:")}
30339
30443
  // src/commands/monitors.ts
30340
30444
  var import_enquirer2 = __toESM(require_enquirer());
30341
30445
  var { prompt: prompt2 } = import_enquirer2.default;
30446
+ function collect(val, prev) {
30447
+ return [...prev, val];
30448
+ }
30449
+ function collectInt(val, prev) {
30450
+ return [...prev, parseInt(val, 10)];
30451
+ }
30342
30452
  var MONITOR_TYPES = [
30343
30453
  "http",
30344
30454
  "tcp",
@@ -30513,6 +30623,90 @@ ${source_default.dim("Examples:")}
30513
30623
  }
30514
30624
  }
30515
30625
  );
30626
+ monitors.command("create").description("Create a monitor non-interactively \u2014 designed for CI/CD pipelines").requiredOption("--name <name>", "Monitor display name").requiredOption("--type <type>", "Monitor type: http, tcp, ping, dns, push, ...").option("--url <url>", "URL or hostname to monitor").option("--interval <seconds>", "Check interval in seconds (default: 60)", "60").option("--tag <tag>", "Assign a tag by name (repeatable \u2014 must already exist in Kuma)", collect, []).option("--notification-id <id>", "Assign a notification channel by ID (repeatable)", collectInt, []).option("--json", "Output as JSON ({ ok, data }) \u2014 prints monitor ID and pushToken to stdout").addHelpText(
30627
+ "after",
30628
+ `
30629
+ ${source_default.dim("Examples:")}
30630
+ ${source_default.cyan('kuma monitors create --type http --name "habitu.ar" --url https://habitu.ar')}
30631
+ ${source_default.cyan('kuma monitors create --type http --name "My API" --url https://api.example.com --tag Production --tag BlackAsteroid')}
30632
+ ${source_default.cyan(`kuma monitors create --type push --name "GH Runner" --json | jq '.data.pushToken'`)}
30633
+ ${source_default.cyan('kuma monitors create --type tcp --name "DB" --url db.host:5432 --interval 30 --notification-id 1')}
30634
+
30635
+ ${source_default.dim("Full pipeline (deploy \u2192 monitor \u2192 heartbeat):")}
30636
+ ${source_default.cyan('RESULT=$(kuma monitors create --type push --name "runner" --json)')}
30637
+ ${source_default.cyan("PUSH_TOKEN=$(echo $RESULT | jq -r '.data.pushToken')")}
30638
+ ${source_default.cyan('kuma heartbeat send $PUSH_TOKEN --msg "Alive"')}
30639
+ `
30640
+ ).action(async (opts) => {
30641
+ const config = getConfig();
30642
+ if (!config) requireAuth(opts);
30643
+ const json = isJsonMode(opts);
30644
+ const interval = parseInt(opts.interval ?? "60", 10);
30645
+ if (["http", "keyword", "tcp", "ping", "dns"].includes(opts.type) && !opts.url) {
30646
+ handleError(new Error(`--url is required for monitor type "${opts.type}"`), opts);
30647
+ }
30648
+ try {
30649
+ const client = await createAuthenticatedClient(config.url, config.token);
30650
+ const result = await client.addMonitor({
30651
+ name: opts.name,
30652
+ type: opts.type,
30653
+ url: opts.url,
30654
+ interval
30655
+ });
30656
+ const monitorId = result.id;
30657
+ let pushToken = result.pushToken ?? null;
30658
+ const tagWarnings = [];
30659
+ if (opts.tag.length > 0) {
30660
+ const allTags = await client.getTags();
30661
+ const tagMap = new Map(allTags.map((t) => [t.name.toLowerCase(), t]));
30662
+ for (const tagName of opts.tag) {
30663
+ const found = tagMap.get(tagName.toLowerCase());
30664
+ if (!found) {
30665
+ const warn2 = `Tag "${tagName}" not found \u2014 skipping. Create it in the Kuma UI first.`;
30666
+ tagWarnings.push(warn2);
30667
+ if (!json) {
30668
+ console.warn(source_default.yellow(`\u26A0\uFE0F ${warn2}`));
30669
+ }
30670
+ continue;
30671
+ }
30672
+ await client.addMonitorTag(found.id, monitorId);
30673
+ }
30674
+ }
30675
+ if (opts.notificationId.length > 0) {
30676
+ const monitorMap = await client.getMonitorList();
30677
+ for (const notifId of opts.notificationId) {
30678
+ await client.setMonitorNotification(monitorId, notifId, true, monitorMap);
30679
+ }
30680
+ }
30681
+ client.disconnect();
30682
+ if (json) {
30683
+ const data = {
30684
+ id: monitorId,
30685
+ name: opts.name,
30686
+ type: opts.type,
30687
+ url: opts.url ?? null,
30688
+ interval
30689
+ };
30690
+ if (pushToken) data.pushToken = pushToken;
30691
+ if (tagWarnings.length > 0) data.warnings = tagWarnings;
30692
+ jsonOut(data, tagWarnings.length > 0 ? 1 : 0);
30693
+ }
30694
+ success(`Monitor "${opts.name}" created (ID: ${monitorId})`);
30695
+ if (pushToken) {
30696
+ console.log(` Push token: ${source_default.cyan(pushToken)}`);
30697
+ console.log(` Push URL: ${source_default.dim(`${config.url}/api/push/${pushToken}`)}`);
30698
+ }
30699
+ if (opts.tag.length > 0) {
30700
+ const applied = opts.tag.filter((t) => !tagWarnings.some((w) => w.includes(t)));
30701
+ if (applied.length > 0) console.log(` Tags: ${applied.join(", ")}`);
30702
+ }
30703
+ if (tagWarnings.length > 0) {
30704
+ process.exit(1);
30705
+ }
30706
+ } catch (err) {
30707
+ handleError(err, opts);
30708
+ }
30709
+ });
30516
30710
  monitors.command("update <id>").description("Update the name, URL, interval, or active state of a monitor").option("--name <name>", "Set a new display name").option("--url <url>", "Set a new URL or hostname").option("--interval <seconds>", "Set a new check interval (seconds)").option("--active", "Resume the monitor (mark as active)").option("--no-active", "Pause the monitor (mark as inactive)").option("--json", "Output as JSON ({ ok, data })").addHelpText(
30517
30711
  "after",
30518
30712
  `
@@ -30684,6 +30878,137 @@ ${source_default.dim("Examples:")}
30684
30878
  handleError(err, opts);
30685
30879
  }
30686
30880
  });
30881
+ monitors.command("bulk-pause").description("Pause all monitors matching a tag or status filter").option("--tag <tag>", "Pause all monitors with this tag").option("--status <status>", "Pause all monitors with this status: up, down, pending, maintenance").option("--dry-run", "Preview which monitors would be paused without pausing them").option("--json", "Output as JSON ({ ok, data })").addHelpText(
30882
+ "after",
30883
+ `
30884
+ ${source_default.dim("Examples:")}
30885
+ ${source_default.cyan("kuma monitors bulk-pause --tag Production")} Pause all Production monitors
30886
+ ${source_default.cyan("kuma monitors bulk-pause --tag Production --dry-run")} Preview without pausing
30887
+ ${source_default.cyan("kuma monitors bulk-pause --tag Production --json")} Machine-readable results
30888
+
30889
+ ${source_default.dim("CI/CD usage:")}
30890
+ ${source_default.cyan("kuma monitors bulk-pause --tag Production && ./deploy.sh && kuma monitors bulk-resume --tag Production")}
30891
+ `
30892
+ ).action(async (opts) => {
30893
+ const config = getConfig();
30894
+ if (!config) requireAuth(opts);
30895
+ const json = isJsonMode(opts);
30896
+ if (!opts.tag && !opts.status) {
30897
+ handleError(new Error("At least one of --tag or --status is required"), opts);
30898
+ }
30899
+ const STATUS_MAP = { down: 0, up: 1, pending: 2, maintenance: 3 };
30900
+ try {
30901
+ const client = await createAuthenticatedClient(config.url, config.token);
30902
+ const monitorMap = await client.getMonitorList();
30903
+ const all = Object.values(monitorMap);
30904
+ let targets = all;
30905
+ if (opts.tag) {
30906
+ const tagName = opts.tag.toLowerCase();
30907
+ targets = targets.filter(
30908
+ (m) => Array.isArray(m.tags) && m.tags.some((t) => t.name.toLowerCase() === tagName)
30909
+ );
30910
+ }
30911
+ if (opts.status) {
30912
+ const statusNum = STATUS_MAP[opts.status.toLowerCase()];
30913
+ if (statusNum === void 0) {
30914
+ client.disconnect();
30915
+ handleError(new Error(`Invalid status "${opts.status}". Valid: up, down, pending, maintenance`), opts);
30916
+ }
30917
+ targets = targets.filter((m) => m.heartbeat?.status === statusNum);
30918
+ }
30919
+ if (targets.length === 0) {
30920
+ client.disconnect();
30921
+ if (json) jsonOut({ affected: 0, results: [] });
30922
+ console.log("No monitors matched the given filters.");
30923
+ return;
30924
+ }
30925
+ if (opts.dryRun) {
30926
+ client.disconnect();
30927
+ const preview = targets.map((m) => ({ id: m.id, name: m.name }));
30928
+ if (json) jsonOut({ dryRun: true, affected: targets.length, monitors: preview });
30929
+ console.log(source_default.yellow(`Dry run \u2014 would pause ${targets.length} monitor(s):`));
30930
+ preview.forEach((m) => console.log(` ${source_default.dim(String(m.id).padStart(4))} ${m.name}`));
30931
+ return;
30932
+ }
30933
+ const results = await client.bulkPause((m) => targets.some((t) => t.id === m.id));
30934
+ client.disconnect();
30935
+ const failed = results.filter((r) => !r.ok);
30936
+ if (json) {
30937
+ jsonOut({ affected: results.length, failed: failed.length, results });
30938
+ }
30939
+ console.log(`Paused ${results.length - failed.length}/${results.length} monitor(s)`);
30940
+ if (failed.length > 0) {
30941
+ failed.forEach((r) => error(` Monitor ${r.id} (${r.name}): ${r.error}`));
30942
+ process.exit(1);
30943
+ }
30944
+ } catch (err) {
30945
+ handleError(err, opts);
30946
+ }
30947
+ });
30948
+ monitors.command("bulk-resume").description("Resume all monitors matching a tag or status filter").option("--tag <tag>", "Resume all monitors with this tag").option("--status <status>", "Resume all monitors with this status: up, down, pending, maintenance").option("--dry-run", "Preview which monitors would be resumed without resuming them").option("--json", "Output as JSON ({ ok, data })").addHelpText(
30949
+ "after",
30950
+ `
30951
+ ${source_default.dim("Examples:")}
30952
+ ${source_default.cyan("kuma monitors bulk-resume --tag Production")}
30953
+ ${source_default.cyan("kuma monitors bulk-resume --tag Production --dry-run")}
30954
+ ${source_default.cyan("kuma monitors bulk-resume --tag Production --json")}
30955
+ `
30956
+ ).action(async (opts) => {
30957
+ const config = getConfig();
30958
+ if (!config) requireAuth(opts);
30959
+ const json = isJsonMode(opts);
30960
+ if (!opts.tag && !opts.status) {
30961
+ handleError(new Error("At least one of --tag or --status is required"), opts);
30962
+ }
30963
+ const STATUS_MAP = { down: 0, up: 1, pending: 2, maintenance: 3 };
30964
+ try {
30965
+ const client = await createAuthenticatedClient(config.url, config.token);
30966
+ const monitorMap = await client.getMonitorList();
30967
+ const all = Object.values(monitorMap);
30968
+ let targets = all;
30969
+ if (opts.tag) {
30970
+ const tagName = opts.tag.toLowerCase();
30971
+ targets = targets.filter(
30972
+ (m) => Array.isArray(m.tags) && m.tags.some((t) => t.name.toLowerCase() === tagName)
30973
+ );
30974
+ }
30975
+ if (opts.status) {
30976
+ const statusNum = STATUS_MAP[opts.status.toLowerCase()];
30977
+ if (statusNum === void 0) {
30978
+ client.disconnect();
30979
+ handleError(new Error(`Invalid status "${opts.status}". Valid: up, down, pending, maintenance`), opts);
30980
+ }
30981
+ targets = targets.filter((m) => m.heartbeat?.status === statusNum);
30982
+ }
30983
+ if (targets.length === 0) {
30984
+ client.disconnect();
30985
+ if (json) jsonOut({ affected: 0, results: [] });
30986
+ console.log("No monitors matched the given filters.");
30987
+ return;
30988
+ }
30989
+ if (opts.dryRun) {
30990
+ client.disconnect();
30991
+ const preview = targets.map((m) => ({ id: m.id, name: m.name }));
30992
+ if (json) jsonOut({ dryRun: true, affected: targets.length, monitors: preview });
30993
+ console.log(source_default.yellow(`Dry run \u2014 would resume ${targets.length} monitor(s):`));
30994
+ preview.forEach((m) => console.log(` ${source_default.dim(String(m.id).padStart(4))} ${m.name}`));
30995
+ return;
30996
+ }
30997
+ const results = await client.bulkResume((m) => targets.some((t) => t.id === m.id));
30998
+ client.disconnect();
30999
+ const failed = results.filter((r) => !r.ok);
31000
+ if (json) {
31001
+ jsonOut({ affected: results.length, failed: failed.length, results });
31002
+ }
31003
+ console.log(`Resumed ${results.length - failed.length}/${results.length} monitor(s)`);
31004
+ if (failed.length > 0) {
31005
+ failed.forEach((r) => error(` Monitor ${r.id} (${r.name}): ${r.error}`));
31006
+ process.exit(1);
31007
+ }
31008
+ } catch (err) {
31009
+ handleError(err, opts);
31010
+ }
31011
+ });
30687
31012
  monitors.command("set-notification <id>").description("Assign or remove a notification channel from a monitor").requiredOption("--notification-id <nid>", "ID of the notification channel to assign").option("--remove", "Remove the notification instead of assigning it").option("--json", "Output as JSON ({ ok, data })").addHelpText(
30688
31013
  "after",
30689
31014
  `
@@ -30730,27 +31055,36 @@ ${source_default.dim("Bulk assign via pipe:")}
30730
31055
 
30731
31056
  // src/commands/heartbeat.ts
30732
31057
  function heartbeatCommand(program3) {
30733
- program3.command("heartbeat <monitor-id>").description("View recent heartbeats (check results) for a monitor").option("--limit <n>", "Maximum number of heartbeats to display (default: 20)", "20").option("--json", "Output as JSON ({ ok, data })").addHelpText(
31058
+ const hb = program3.command("heartbeat").description("View heartbeat history or send push heartbeats to monitors").addHelpText(
31059
+ "after",
31060
+ `
31061
+ ${source_default.dim("Subcommands:")}
31062
+ ${source_default.cyan("heartbeat view <monitor-id>")} View recent heartbeats for a monitor
31063
+ ${source_default.cyan("heartbeat send <push-token>")} Send a push heartbeat (for scripts / GitHub Actions)
31064
+
31065
+ ${source_default.dim("Run")} ${source_default.cyan("kuma heartbeat <subcommand> --help")} ${source_default.dim("for examples.")}
31066
+ `
31067
+ );
31068
+ hb.command("view <monitor-id>").description("View recent heartbeats (check results) for a monitor").option("--limit <n>", "Maximum number of heartbeats to display (default: 20)", "20").option("--json", "Output as JSON ({ ok, data })").addHelpText(
30734
31069
  "after",
30735
31070
  `
30736
31071
  ${source_default.dim("Examples:")}
30737
- ${source_default.cyan("kuma heartbeat 42")} Last 20 heartbeats for monitor 42
30738
- ${source_default.cyan("kuma heartbeat 42 --limit 50")} Last 50 heartbeats
30739
- ${source_default.cyan("kuma heartbeat 42 --json")} Machine-readable output
30740
- ${source_default.cyan("kuma heartbeat 42 --json | jq '.data[] | select(.status == 0)'")} Show failures
31072
+ ${source_default.cyan("kuma heartbeat view 42")}
31073
+ ${source_default.cyan("kuma heartbeat view 42 --limit 50")}
31074
+ ${source_default.cyan("kuma heartbeat view 42 --json")}
31075
+ ${source_default.cyan("kuma heartbeat view 42 --json | jq '.data[] | select(.status == 0)'")}
30741
31076
  `
30742
31077
  ).action(async (monitorId, opts) => {
30743
31078
  const config = getConfig();
30744
31079
  if (!config) requireAuth(opts);
30745
31080
  const json = isJsonMode(opts);
31081
+ const parsedMonitorId = parseInt(monitorId, 10);
31082
+ if (isNaN(parsedMonitorId) || parsedMonitorId <= 0) {
31083
+ handleError(new Error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`), opts);
31084
+ }
30746
31085
  try {
30747
- const client = await createAuthenticatedClient(
30748
- config.url,
30749
- config.token
30750
- );
30751
- const heartbeats = await client.getHeartbeatList(
30752
- parseInt(monitorId, 10)
30753
- );
31086
+ const client = await createAuthenticatedClient(config.url, config.token);
31087
+ const heartbeats = await client.getHeartbeatList(parsedMonitorId);
30754
31088
  client.disconnect();
30755
31089
  const limit = parseInt(opts.limit ?? "20", 10);
30756
31090
  const recent = heartbeats.slice(-limit).reverse();
@@ -30762,12 +31096,12 @@ ${source_default.dim("Examples:")}
30762
31096
  return;
30763
31097
  }
30764
31098
  const table = createTable(["Time", "Status", "Ping", "Message"]);
30765
- recent.forEach((hb) => {
31099
+ recent.forEach((hb2) => {
30766
31100
  table.push([
30767
- formatDate(hb.time),
30768
- statusLabel(hb.status),
30769
- formatPing(hb.ping),
30770
- hb.msg ?? "\u2014"
31101
+ formatDate(hb2.time),
31102
+ statusLabel(hb2.status),
31103
+ formatPing(hb2.ping),
31104
+ hb2.msg ?? "\u2014"
30771
31105
  ]);
30772
31106
  });
30773
31107
  console.log(table.toString());
@@ -30777,6 +31111,87 @@ Showing last ${recent.length} heartbeat(s)`);
30777
31111
  handleError(err, opts);
30778
31112
  }
30779
31113
  });
31114
+ hb.command("send <push-token>").description("Send a push heartbeat to a Kuma push monitor (for scripts and GitHub Actions)").option("--status <status>", "Heartbeat status: up, down, maintenance (default: up)").option("--msg <message>", "Optional status message").option("--ping <ms>", "Optional response time in milliseconds").option("--url <url>", "Kuma base URL (defaults to saved login URL)").option("--json", "Output as JSON ({ ok, data })").addHelpText(
31115
+ "after",
31116
+ `
31117
+ ${source_default.dim("Examples:")}
31118
+ ${source_default.cyan("kuma heartbeat send abc123")}
31119
+ ${source_default.cyan('kuma heartbeat send abc123 --status down --msg "Job failed"')}
31120
+ ${source_default.cyan('kuma heartbeat send abc123 --msg "Deploy complete" --ping 42')}
31121
+ ${source_default.cyan("kuma heartbeat send abc123 --json")}
31122
+
31123
+ ${source_default.dim("GitHub Actions usage:")}
31124
+ ${source_default.cyan("- name: Heartbeat")}
31125
+ ${source_default.cyan(" if: always()")}
31126
+ ${source_default.cyan(" run: kuma heartbeat send ${{ secrets.RUNNER_PUSH_TOKEN }} --status ${{ job.status == 'success' && 'up' || 'down' }}")}
31127
+
31128
+ ${source_default.dim("Finding your push token:")}
31129
+ Create a "Push" monitor in Kuma UI. The push URL is:
31130
+ https://kuma.example.com/api/push/<token>
31131
+ Use only the <token> part.
31132
+
31133
+ Or get it from CLI: kuma monitors create --type push --name "my-runner" --json | jq '.data.pushToken'
31134
+ `
31135
+ ).action(async (pushToken, opts) => {
31136
+ const json = isJsonMode(opts);
31137
+ if (!/^[a-zA-Z0-9_-]+$/.test(pushToken)) {
31138
+ const msg = `Invalid push token format. Tokens must contain only alphanumeric characters, hyphens, and underscores.`;
31139
+ if (json) jsonError(msg, EXIT_CODES.GENERAL);
31140
+ console.error(source_default.red(`\u274C ${msg}`));
31141
+ process.exit(EXIT_CODES.GENERAL);
31142
+ }
31143
+ const VALID_STATUSES = ["up", "down", "maintenance"];
31144
+ const statusKey = (opts.status ?? "up").toLowerCase();
31145
+ if (!VALID_STATUSES.includes(statusKey)) {
31146
+ const msg = `Invalid status "${opts.status}". Valid: up, down, maintenance`;
31147
+ if (json) jsonError(msg, EXIT_CODES.GENERAL);
31148
+ console.error(source_default.red(`\u274C ${msg}`));
31149
+ process.exit(EXIT_CODES.GENERAL);
31150
+ }
31151
+ let baseUrl = opts.url;
31152
+ if (!baseUrl) {
31153
+ const config = getConfig();
31154
+ if (!config) {
31155
+ const msg = "No --url specified and not logged in. Run: kuma login <url> or pass --url";
31156
+ if (json) jsonError(msg, EXIT_CODES.AUTH);
31157
+ console.error(source_default.red(`\u274C ${msg}`));
31158
+ process.exit(EXIT_CODES.AUTH);
31159
+ }
31160
+ baseUrl = config.url;
31161
+ }
31162
+ const pushUrl = new URL(`${baseUrl.replace(/\/$/, "")}/api/push/${pushToken}`);
31163
+ pushUrl.searchParams.set("status", statusKey);
31164
+ if (opts.msg) pushUrl.searchParams.set("msg", opts.msg);
31165
+ if (opts.ping) pushUrl.searchParams.set("ping", opts.ping);
31166
+ try {
31167
+ const res = await fetch(pushUrl.toString(), {
31168
+ signal: AbortSignal.timeout(1e4)
31169
+ });
31170
+ if (!res.ok) {
31171
+ const body = await res.text().catch(() => "");
31172
+ const msg = `Push failed (HTTP ${res.status}): ${body || res.statusText}`;
31173
+ if (json) jsonError(msg, EXIT_CODES.GENERAL);
31174
+ console.error(source_default.red(`\u274C ${msg}`));
31175
+ process.exit(EXIT_CODES.GENERAL);
31176
+ }
31177
+ const data = await res.json().catch(() => ({ ok: true }));
31178
+ if (data.ok === false) {
31179
+ const msg = data.msg ?? "Kuma rejected the push heartbeat";
31180
+ if (json) jsonError(msg, EXIT_CODES.GENERAL);
31181
+ console.error(source_default.red(`\u274C ${msg}`));
31182
+ process.exit(EXIT_CODES.GENERAL);
31183
+ }
31184
+ if (json) {
31185
+ jsonOut({ pushToken, status: statusKey, msg: opts.msg ?? null });
31186
+ }
31187
+ success(`Push heartbeat sent (${statusKey}${opts.msg ? ` \u2014 ${opts.msg}` : ""})`);
31188
+ } catch (e) {
31189
+ const msg = e instanceof Error ? e.message : String(e);
31190
+ if (json) jsonError(msg, EXIT_CODES.CONNECTION);
31191
+ console.error(source_default.red(`\u274C ${msg}`));
31192
+ process.exit(EXIT_CODES.CONNECTION);
31193
+ }
31194
+ });
30780
31195
  }
30781
31196
 
30782
31197
  // src/commands/status-pages.ts
@@ -30930,7 +31345,7 @@ ${source_default.bold(`Upgrading kuma-cli`)} ${source_default.dim(`v${current}`)
30930
31345
  );
30931
31346
  }
30932
31347
  try {
30933
- (0, import_child_process.execSync)("npm install -g @blackasteroid/kuma-cli@latest", {
31348
+ (0, import_child_process.execSync)(`npm install -g @blackasteroid/kuma-cli@${latest}`, {
30934
31349
  stdio: json ? "pipe" : "inherit"
30935
31350
  });
30936
31351
  } catch (err) {
@@ -30963,6 +31378,27 @@ ${source_default.bold(`Upgrading kuma-cli`)} ${source_default.dim(`v${current}`)
30963
31378
  }
30964
31379
 
30965
31380
  // src/commands/notifications.ts
31381
+ function resolveSecret(value2) {
31382
+ if (value2 === void 0) return void 0;
31383
+ if (value2.startsWith("$")) {
31384
+ const varName = value2.slice(1);
31385
+ const resolved = process.env[varName];
31386
+ if (!resolved) {
31387
+ return void 0;
31388
+ }
31389
+ return resolved;
31390
+ }
31391
+ if (value2 === "-") {
31392
+ try {
31393
+ const buf = Buffer.alloc(4096);
31394
+ const n = require("fs").readSync(0, buf, 0, buf.length, null);
31395
+ return buf.toString("utf8", 0, n).trim();
31396
+ } catch {
31397
+ return void 0;
31398
+ }
31399
+ }
31400
+ return value2;
31401
+ }
30966
31402
  function notificationsCommand(program3) {
30967
31403
  const notifications = program3.command("notifications").description("Manage notification channels (Discord, Telegram, webhook, ...)").addHelpText(
30968
31404
  "after",
@@ -31029,14 +31465,18 @@ ${list.length} notification channel(s)`);
31029
31465
  handleError(err, opts);
31030
31466
  }
31031
31467
  });
31032
- notifications.command("create").description("Create a new notification channel").requiredOption("--type <type>", "Notification type: discord, telegram, slack, webhook, ...").requiredOption("--name <name>", "Friendly name for this notification channel").option("--discord-webhook <url>", "Discord webhook URL (required for --type discord)").option("--discord-username <name>", "Discord bot display name (optional)").option("--telegram-token <token>", "Telegram bot token (required for --type telegram)").option("--telegram-chat-id <id>", "Telegram chat ID (required for --type telegram)").option("--slack-webhook <url>", "Slack webhook URL (required for --type slack)").option("--webhook-url <url>", "Webhook URL (required for --type webhook)").option("--webhook-content-type <type>", "Webhook content type (default: application/json)", "application/json").option("--default", "Enable this notification by default on all new monitors").option("--apply-existing", "Apply this notification to all existing monitors immediately").option("--json", "Output as JSON ({ ok, data })").addHelpText(
31468
+ notifications.command("create").description("Create a new notification channel").requiredOption("--type <type>", "Notification type: discord, telegram, slack, webhook, ...").requiredOption("--name <name>", "Friendly name for this notification channel").option("--discord-webhook <url|$VAR>", "Discord webhook URL \u2014 pass value or env var name like '$DISCORD_WEBHOOK'").option("--discord-username <name>", "Discord bot display name (optional)").option("--telegram-token <token|$VAR>", "Telegram bot token \u2014 pass value or env var name like '$TELEGRAM_TOKEN'").option("--telegram-chat-id <id>", "Telegram chat ID (required for --type telegram)").option("--slack-webhook <url|$VAR>", "Slack webhook URL \u2014 pass value or env var name like '$SLACK_WEBHOOK'").option("--webhook-url <url|$VAR>", "Webhook URL \u2014 pass value or env var name like '$WEBHOOK_URL'").option("--webhook-content-type <type>", "Webhook content type (default: application/json)", "application/json").option("--default", "Enable this notification by default on all new monitors").option("--apply-existing", "Apply this notification to all existing monitors immediately").option("--json", "Output as JSON ({ ok, data })").addHelpText(
31033
31469
  "after",
31034
31470
  `
31035
31471
  ${source_default.dim("Examples:")}
31036
- ${source_default.cyan('kuma notifications create --type discord --name "Alerts" --discord-webhook https://discord.com/api/webhooks/...')}
31037
- ${source_default.cyan('kuma notifications create --type telegram --name "TG" --telegram-token 123:ABC --telegram-chat-id -100...')}
31038
- ${source_default.cyan('kuma notifications create --type webhook --name "My Hook" --webhook-url https://example.com/hook')}
31039
- ${source_default.cyan('kuma notifications create --type discord --name "Default" --discord-webhook $URL --default --apply-existing')}
31472
+ ${source_default.cyan(`kuma notifications create --type discord --name "Alerts" --discord-webhook '$DISCORD_WEBHOOK'`)}
31473
+ ${source_default.cyan(`kuma notifications create --type telegram --name "TG" --telegram-token '$TELEGRAM_TOKEN' --telegram-chat-id -100...`)}
31474
+ ${source_default.cyan(`kuma notifications create --type webhook --name "My Hook" --webhook-url '$WEBHOOK_URL'`)}
31475
+ ${source_default.cyan(`kuma notifications create --type discord --name "Default" --discord-webhook '$DISCORD_WEBHOOK' --default --apply-existing`)}
31476
+
31477
+ ${source_default.dim("\u26A0\uFE0F Security: never pass secrets as literal flag values \u2014 use env vars:")}
31478
+ ${source_default.cyan("export DISCORD_WEBHOOK=https://discord.com/api/webhooks/...")}
31479
+ ${source_default.cyan(`kuma notifications create --type discord --name "Alerts" --discord-webhook '\\$DISCORD_WEBHOOK'`)}
31040
31480
 
31041
31481
  ${source_default.dim("Supported types:")}
31042
31482
  discord, telegram, slack, webhook, gotify, ntfy, pushover, matrix, mattermost, teams ...
@@ -31053,32 +31493,36 @@ ${source_default.dim("Supported types:")}
31053
31493
  active: true,
31054
31494
  applyExisting: opts.applyExisting ?? false
31055
31495
  };
31496
+ const discordWebhook = resolveSecret(opts.discordWebhook);
31497
+ const telegramToken = resolveSecret(opts.telegramToken);
31498
+ const slackWebhook = resolveSecret(opts.slackWebhook);
31499
+ const webhookUrl = resolveSecret(opts.webhookUrl);
31056
31500
  switch (opts.type.toLowerCase()) {
31057
31501
  case "discord":
31058
- if (!opts.discordWebhook) {
31059
- handleError(new Error("--discord-webhook is required for --type discord"), opts);
31502
+ if (!discordWebhook) {
31503
+ handleError(new Error("--discord-webhook is required for --type discord (pass value or '$ENV_VAR_NAME')"), opts);
31060
31504
  }
31061
- payload.discordWebhookUrl = opts.discordWebhook;
31505
+ payload.discordWebhookUrl = discordWebhook;
31062
31506
  if (opts.discordUsername) payload.discordUsername = opts.discordUsername;
31063
31507
  break;
31064
31508
  case "telegram":
31065
- if (!opts.telegramToken || !opts.telegramChatId) {
31509
+ if (!telegramToken || !opts.telegramChatId) {
31066
31510
  handleError(new Error("--telegram-token and --telegram-chat-id are required for --type telegram"), opts);
31067
31511
  }
31068
- payload.telegramBotToken = opts.telegramToken;
31512
+ payload.telegramBotToken = telegramToken;
31069
31513
  payload.telegramChatID = opts.telegramChatId;
31070
31514
  break;
31071
31515
  case "slack":
31072
- if (!opts.slackWebhook) {
31073
- handleError(new Error("--slack-webhook is required for --type slack"), opts);
31516
+ if (!slackWebhook) {
31517
+ handleError(new Error("--slack-webhook is required for --type slack (pass value or '$ENV_VAR_NAME')"), opts);
31074
31518
  }
31075
- payload.slackwebhookURL = opts.slackWebhook;
31519
+ payload.slackwebhookURL = slackWebhook;
31076
31520
  break;
31077
31521
  case "webhook":
31078
- if (!opts.webhookUrl) {
31079
- handleError(new Error("--webhook-url is required for --type webhook"), opts);
31522
+ if (!webhookUrl) {
31523
+ handleError(new Error("--webhook-url is required for --type webhook (pass value or '$ENV_VAR_NAME')"), opts);
31080
31524
  }
31081
- payload.webhookURL = opts.webhookUrl;
31525
+ payload.webhookURL = webhookUrl;
31082
31526
  payload.webhookContentType = opts.webhookContentType ?? "application/json";
31083
31527
  break;
31084
31528
  default:
@@ -31114,8 +31558,8 @@ ${source_default.dim("Examples:")}
31114
31558
  if (!config) requireAuth(opts);
31115
31559
  const json = isJsonMode(opts);
31116
31560
  const notifId = parseInt(id, 10);
31117
- if (isNaN(notifId)) {
31118
- handleError(new Error(`Invalid notification ID: ${id}`), opts);
31561
+ if (isNaN(notifId) || notifId <= 0) {
31562
+ handleError(new Error(`Invalid notification ID: "${id}". Must be a positive integer.`), opts);
31119
31563
  }
31120
31564
  if (!opts.force && !json) {
31121
31565
  const enquirer3 = await Promise.resolve().then(() => __toESM(require_enquirer()));
@@ -31160,7 +31604,7 @@ ${source_default.bold("Quick Start:")}
31160
31604
  ${source_default.cyan("kuma login https://kuma.example.com")} Authenticate (saves session)
31161
31605
  ${source_default.cyan("kuma monitors list")} List all monitors + status
31162
31606
  ${source_default.cyan('kuma monitors add --name "My API" --type http --url https://api.example.com')}
31163
- ${source_default.cyan("kuma heartbeat 42")} View recent heartbeats for monitor 42
31607
+ ${source_default.cyan("kuma heartbeat view 42")} View recent heartbeats for monitor 42
31164
31608
  ${source_default.cyan("kuma logout")} Clear saved session
31165
31609
 
31166
31610
  ${source_default.bold("JSON / scripting mode:")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackasteroid/kuma-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "CLI for managing Uptime Kuma via Socket.IO API",
5
5
  "bin": {
6
6
  "kuma": "dist/index.js"