cronfish 0.14.0 → 0.15.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 +6 -0
- package/examples/.cronfish.json +1 -0
- package/package.json +1 -1
- package/src/alerts/index.ts +1 -0
- package/src/alerts/registry.ts +2 -0
- package/src/alerts/slack-bot.ts +80 -0
- package/src/alerts/types.ts +8 -0
- package/src/runner.ts +23 -2
package/README.md
CHANGED
|
@@ -260,6 +260,7 @@ as a single job instead of many fast fires.
|
|
|
260
260
|
"on_failure": { "notify": "slack" },
|
|
261
261
|
"default": "slack",
|
|
262
262
|
"slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
|
|
263
|
+
"slack_bot": { "bot_token_env": "CRONFISH_SLACK_BOT_TOKEN", "channel": "C0123456789" },
|
|
263
264
|
"shell": { "command": "/Users/you/bin/cronfish-pushover.sh" }
|
|
264
265
|
}
|
|
265
266
|
}
|
|
@@ -305,6 +306,7 @@ Every failed (`fail` / `timeout` / `crashed`) scheduled run pings the configured
|
|
|
305
306
|
Adapters ship with cronfish:
|
|
306
307
|
|
|
307
308
|
- **`slack`** — POSTs Block Kit to an incoming webhook. Reads the URL from the env var named in `alerts.slack.webhook_url_env` (default `CRONFISH_SLACK_WEBHOOK`).
|
|
309
|
+
- **`slack_bot`** — posts the same Block Kit via `chat.postMessage` with a bot token instead of a webhook. One token reaches any channel (with `chat:write.public`, no invite needed), so you skip the per-channel browser-OAuth webhook mint. Reads the token from `alerts.slack_bot.bot_token_env` (default `CRONFISH_SLACK_BOT_TOKEN`) and the target channel from `alerts.slack_bot.channel` (a `C…` id or `#name`) or `alerts.slack_bot.channel_env`. Unlike a webhook, `chat.postMessage` returns HTTP 200 on logical errors, so the adapter inspects the JSON `ok` field and fails on `ok:false` (e.g. `channel_not_found`, `not_in_channel`).
|
|
308
310
|
- **`shell`** — runs an arbitrary command from `alerts.shell.command` with the payload as env vars (`CRONFISH_ALERT_SLUG`, `…_STATUS`, `…_EXIT_CODE`, `…_DURATION_MS`, `…_STARTED_AT`, `…_UI_URL`, `…_LOG_TAIL`) plus the JSON payload on stdin. Use this for Pushover/ntfy/osascript.
|
|
309
311
|
|
|
310
312
|
Two knobs in `.cronfish.json`, two distinct jobs:
|
|
@@ -335,6 +337,10 @@ Sanity check:
|
|
|
335
337
|
```
|
|
336
338
|
export CRONFISH_SLACK_WEBHOOK=https://hooks.slack.com/services/...
|
|
337
339
|
cronfish alerts test slack
|
|
340
|
+
|
|
341
|
+
# or, bot-token path (no webhook to mint):
|
|
342
|
+
export CRONFISH_SLACK_BOT_TOKEN=xoxb-...
|
|
343
|
+
cronfish alerts test slack_bot
|
|
338
344
|
```
|
|
339
345
|
|
|
340
346
|
## Always-on dashboard
|
package/examples/.cronfish.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"on_failure": { "notify": "slack" },
|
|
13
13
|
"default": "slack",
|
|
14
14
|
"slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
|
|
15
|
+
"slack_bot": { "bot_token_env": "CRONFISH_SLACK_BOT_TOKEN", "channel": "C0123456789" },
|
|
15
16
|
"shell": { "command": "/usr/local/bin/my-notify.sh" }
|
|
16
17
|
}
|
|
17
18
|
}
|
package/package.json
CHANGED
package/src/alerts/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildRegistry, type AdapterRegistry } from "./registry.ts";
|
|
2
2
|
export { safeNotify, type AlertOutcome, type AlertOutcomeStatus } from "./safe.ts";
|
|
3
3
|
export { createSlackAdapter, buildSlackBlocks } from "./slack.ts";
|
|
4
|
+
export { createSlackBotAdapter, type SlackBotAdapterOptions } from "./slack-bot.ts";
|
|
4
5
|
export { createShellAdapter, payloadEnv } from "./shell.ts";
|
|
5
6
|
export {
|
|
6
7
|
alertStatusFor,
|
package/src/alerts/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createShellAdapter } from "./shell.ts";
|
|
2
|
+
import { createSlackBotAdapter } from "./slack-bot.ts";
|
|
2
3
|
import { createSlackAdapter } from "./slack.ts";
|
|
3
4
|
import type { Adapter, AlertsConfig } from "./types.ts";
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ export interface AdapterRegistry {
|
|
|
11
12
|
export function buildRegistry(cfg: AlertsConfig = {}): AdapterRegistry {
|
|
12
13
|
const map = new Map<string, Adapter>();
|
|
13
14
|
map.set("slack", createSlackAdapter(cfg.slack));
|
|
15
|
+
map.set("slack_bot", createSlackBotAdapter(cfg.slack_bot));
|
|
14
16
|
map.set("shell", createShellAdapter(cfg.shell));
|
|
15
17
|
return {
|
|
16
18
|
get(name) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Bot-token Slack adapter — the API-driveable twin of the webhook `slack`
|
|
2
|
+
// adapter. Where `slack` POSTs Block Kit to a per-channel incoming webhook (URL
|
|
3
|
+
// bound to one channel at mint time, only creatable via browser OAuth), this
|
|
4
|
+
// posts via `chat.postMessage` with a bot token: one credential reaches any
|
|
5
|
+
// channel by id/name (with `chat:write.public`, without being invited). Same
|
|
6
|
+
// Block Kit body — it reuses `buildSlackBlocks`, so alerts render identically.
|
|
7
|
+
//
|
|
8
|
+
// Unlike an incoming webhook (which signals failure with a non-2xx status),
|
|
9
|
+
// `chat.postMessage` returns HTTP 200 even on logical errors and carries the
|
|
10
|
+
// real outcome in the JSON `ok`/`error` fields — so this adapter parses the
|
|
11
|
+
// body and throws on `ok:false`, not just on a bad status.
|
|
12
|
+
|
|
13
|
+
import { buildSlackBlocks } from "./slack.ts";
|
|
14
|
+
import type { Adapter, AlertPayload, AlertsConfig } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TOKEN_ENV = "CRONFISH_SLACK_BOT_TOKEN";
|
|
17
|
+
const POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
|
|
18
|
+
|
|
19
|
+
export interface SlackBotAdapterOptions {
|
|
20
|
+
botToken?: string;
|
|
21
|
+
channel?: string;
|
|
22
|
+
fetchFn?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createSlackBotAdapter(
|
|
26
|
+
cfg: AlertsConfig["slack_bot"] = {},
|
|
27
|
+
opts: SlackBotAdapterOptions = {},
|
|
28
|
+
): Adapter {
|
|
29
|
+
const tokenEnv = cfg.bot_token_env ?? DEFAULT_TOKEN_ENV;
|
|
30
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
31
|
+
return {
|
|
32
|
+
name: "slack_bot",
|
|
33
|
+
async notify(payload: AlertPayload): Promise<void> {
|
|
34
|
+
const token = opts.botToken ?? process.env[tokenEnv];
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new Error(`slack-bot adapter: env var ${tokenEnv} not set`);
|
|
37
|
+
}
|
|
38
|
+
const channel =
|
|
39
|
+
opts.channel ??
|
|
40
|
+
(cfg.channel_env ? process.env[cfg.channel_env] : undefined) ??
|
|
41
|
+
cfg.channel;
|
|
42
|
+
if (!channel) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"slack-bot adapter: no channel configured " +
|
|
45
|
+
"(set alerts.slack_bot.channel or alerts.slack_bot.channel_env)",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const blocks = buildSlackBlocks(payload);
|
|
49
|
+
const res = await fetchFn(POST_MESSAGE_URL, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"content-type": "application/json; charset=utf-8",
|
|
53
|
+
authorization: `Bearer ${token}`,
|
|
54
|
+
},
|
|
55
|
+
// `text` is the notification/accessibility fallback Slack shows when
|
|
56
|
+
// blocks can't render (push notifications, screen readers).
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
channel,
|
|
59
|
+
text: `${payload.status.toUpperCase()} — ${payload.slug}`,
|
|
60
|
+
blocks,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const body = await res.text().catch(() => "");
|
|
65
|
+
throw new Error(
|
|
66
|
+
`slack-bot adapter: chat.postMessage returned ${res.status} ${body.slice(0, 200)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
70
|
+
ok?: boolean;
|
|
71
|
+
error?: string;
|
|
72
|
+
};
|
|
73
|
+
if (!data.ok) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`slack-bot adapter: chat.postMessage failed: ${data.error ?? "unknown error"}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/alerts/types.ts
CHANGED
|
@@ -28,6 +28,14 @@ export interface AlertsConfig {
|
|
|
28
28
|
slack?: {
|
|
29
29
|
webhook_url_env?: string;
|
|
30
30
|
};
|
|
31
|
+
slack_bot?: {
|
|
32
|
+
// Env var holding the bot token (xoxb-…). Default CRONFISH_SLACK_BOT_TOKEN.
|
|
33
|
+
bot_token_env?: string;
|
|
34
|
+
// Target channel — id (C…) or #name. Literal value…
|
|
35
|
+
channel?: string;
|
|
36
|
+
// …or the env var to read it from (takes precedence over `channel`).
|
|
37
|
+
channel_env?: string;
|
|
38
|
+
};
|
|
31
39
|
shell?: {
|
|
32
40
|
command?: string;
|
|
33
41
|
};
|
package/src/runner.ts
CHANGED
|
@@ -708,6 +708,23 @@ async function main(): Promise<void> {
|
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
// Install lock-release signal handlers the instant a lock exists. There is
|
|
712
|
+
// real setup below (DB init, log open) before the full `cleanup` handler is
|
|
713
|
+
// registered, and a SIGTERM in that window would otherwise kill the process
|
|
714
|
+
// by default disposition — no handler runs, and the concurrency lockfile is
|
|
715
|
+
// orphaned (unlike the flock, it is not reclaimed by the OS on exit). This
|
|
716
|
+
// minimal handler is superseded by `cleanup` once the invocation state exists.
|
|
717
|
+
let releasing = false;
|
|
718
|
+
const earlyCleanup = (sig: NodeJS.Signals): void => {
|
|
719
|
+
if (releasing) return;
|
|
720
|
+
releasing = true;
|
|
721
|
+
if (job.concurrency) releaseLock(job.slug);
|
|
722
|
+
if (oneTimeLock) releaseFlock(oneTimeLock);
|
|
723
|
+
process.exit(sig === "SIGTERM" ? 143 : 130);
|
|
724
|
+
};
|
|
725
|
+
process.on("SIGTERM", earlyCleanup);
|
|
726
|
+
process.on("SIGINT", earlyCleanup);
|
|
727
|
+
|
|
711
728
|
const db = tryOpenDb();
|
|
712
729
|
|
|
713
730
|
// Open the log file BEFORE we know the invocation id (we need a real
|
|
@@ -739,8 +756,12 @@ async function main(): Promise<void> {
|
|
|
739
756
|
appendLog(fd, `[runner] invocation_id=${invocationId} trigger=${trigger}`);
|
|
740
757
|
}
|
|
741
758
|
|
|
742
|
-
// Signal handlers — release lock + record crash on launchd shutdown.
|
|
743
|
-
|
|
759
|
+
// Signal handlers — release lock + record crash on launchd shutdown. This
|
|
760
|
+
// supersedes earlyCleanup now that the invocation row exists, so a SIGTERM
|
|
761
|
+
// also records the run as crashed. The off→on swap is synchronous (no await
|
|
762
|
+
// between), so no signal can slip through with zero handlers installed.
|
|
763
|
+
process.off("SIGTERM", earlyCleanup);
|
|
764
|
+
process.off("SIGINT", earlyCleanup);
|
|
744
765
|
const cleanup = (sig: NodeJS.Signals): void => {
|
|
745
766
|
if (releasing) return;
|
|
746
767
|
releasing = true;
|