@golden-frijoles/kit 0.7.0 → 0.8.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.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ // notify-setup.mjs — find your Telegram chat id, and send ONE test message, so notifications can be
3
+ // proven before anything scheduled depends on them (distribute-what-we-use S3.3).
4
+ //
5
+ // node scripts/notify-setup.mjs --chat-id list the chats your bot has seen (getUpdates)
6
+ // node scripts/notify-setup.mjs --test send one test message to every destination that is configured
7
+ // node scripts/notify-setup.mjs --test --telegram ...to Telegram only (needs the token AND a chat id)
8
+ // node scripts/notify-setup.mjs --test --slack ...to Slack only (needs SLACK_WEBHOOK_URL)
9
+ //
10
+ // ── Where the secrets come from ──────────────────────────────────────────────────────────────────
11
+ // TELEGRAM_BOT_TOKEN and SLACK_WEBHOOK_URL are read from the environment, then from `.env.local` at the project
12
+ // root. They are NEVER printed and NEVER written anywhere: not to the screen, not to a file, not into an error
13
+ // message (every message that could echo one is scrubbed first). The Telegram chat id is not a secret, so it is
14
+ // printed. The chat id for `--test` is `telegram.chatId` from `reporting.config.json` (with the gitignored
15
+ // `reporting.config.local.json` laid over it, see lib/reporting-config.mjs) or, failing that, TELEGRAM_CHAT_ID (environment or `.env.local`, for a project with no reporting config yet).
16
+ //
17
+ // ── What the scheduled reports actually do (deviation 5) ─────────────────────────────────────────
18
+ // The standup, weekly recap and PMO report post to TELEGRAM only, and nothing reads `reporting.destination`.
19
+ // Slack here covers this script's test message and ad-hoc sends via slack-notify.mjs — this script does not make
20
+ // the scheduled reports post to Slack, and says so rather than implying it.
21
+ //
22
+ // ── Exit codes (LEARNINGS: "could not look" is its own outcome, never the failure code) ──────────
23
+ // 0 did what was asked 1 the API answered and refused (or --chat-id found no chat)
24
+ // 2 bad usage 3 could not look: a token / webhook / chat id is absent, or the network failed
25
+ //
26
+ // Every network path takes an injectable `fetch`; the specs inject one and never touch a real API.
27
+ // Zero deps — Node 18+.
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join, resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { envFileValue } from './lib/jev.mjs';
33
+ import { projectRoot } from './lib/project-root.mjs';
34
+ import { chatIdFor, loadReportingConfig, ReportingConfigError } from './lib/reporting-config.mjs';
35
+ import { send as sendSlack } from './slack-notify.mjs';
36
+
37
+ export const EXIT = { ok: 0, refused: 1, usage: 2, couldNotLook: 3 };
38
+ export const TEST_TEXT = 'Golden Frijoles test message: notifications are wired up. You can ignore this one.';
39
+ const TELEGRAM_API = 'https://api.telegram.org';
40
+ const TIMEOUT_MS = 10_000;
41
+
42
+ /** Pure — the flags. `{ error }` on anything unknown, so a typo never silently means "do nothing". */
43
+ export function parseArgs(argv) {
44
+ const opts = { chatId: false, test: false, telegram: false, slack: false };
45
+ for (const a of argv) {
46
+ if (a === '--chat-id') opts.chatId = true;
47
+ else if (a === '--test') opts.test = true;
48
+ else if (a === '--telegram') opts.telegram = true;
49
+ else if (a === '--slack') opts.slack = true;
50
+ else return { error: `unknown argument ${a}` };
51
+ }
52
+ if (opts.chatId === opts.test) return { error: 'give exactly one of --chat-id or --test' };
53
+ if ((opts.telegram || opts.slack) && !opts.test) return { error: '--telegram and --slack go with --test' };
54
+ return { opts };
55
+ }
56
+
57
+ /** A secret from the environment, then `.env.local` at the project root (then the cwd). null when absent. */
58
+ export function readEnvValue(
59
+ name,
60
+ {
61
+ env = process.env,
62
+ root = projectRoot(),
63
+ cwd = process.cwd(),
64
+ read = readFileSync,
65
+ exists = existsSync,
66
+ } = {}
67
+ ) {
68
+ if (env[name]?.trim()) return env[name].trim();
69
+ for (const dir of [root, cwd]) {
70
+ const p = join(dir, '.env.local');
71
+ if (!exists(p)) continue;
72
+ try {
73
+ const v = envFileValue(read(p, 'utf8'), name);
74
+ if (v) return v;
75
+ } catch {
76
+ /* an unreadable env file is no value from it */
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** Pure — remove every secret from a string that is about to be shown. */
83
+ export const scrub = (text, secrets) =>
84
+ secrets.filter(Boolean).reduce((t, s) => t.split(s).join('[hidden]'), String(text ?? ''));
85
+
86
+ /** Pure — the distinct chats in a getUpdates result, oldest first: `{ id, type, title }`. */
87
+ export function chatsFromUpdates(updates) {
88
+ const seen = new Map();
89
+ for (const u of updates ?? []) {
90
+ for (const key of [
91
+ 'message',
92
+ 'edited_message',
93
+ 'channel_post',
94
+ 'edited_channel_post',
95
+ 'my_chat_member',
96
+ 'chat_member',
97
+ ]) {
98
+ const chat = u?.[key]?.chat;
99
+ if (!chat || chat.id == null || seen.has(chat.id)) continue;
100
+ const person = [chat.first_name, chat.last_name].filter(Boolean).join(' ');
101
+ seen.set(chat.id, {
102
+ id: chat.id,
103
+ type: chat.type ?? 'unknown',
104
+ title: chat.title || (chat.username ? `@${chat.username}` : person) || '(no title)',
105
+ });
106
+ }
107
+ }
108
+ return [...seen.values()];
109
+ }
110
+
111
+ /**
112
+ * Pure — why `--chat-id` found nothing, given getWebhookInfo's answer. `webhook` is the result object, or null when
113
+ * getWebhookInfo itself could not be read (then both causes are named, since they cannot be told apart).
114
+ */
115
+ export function emptyChatExplanation(webhook) {
116
+ const privacy =
117
+ 'A bot in a GROUP only sees commands and @mentions while group privacy is on: ask @BotFather (/mybots → your bot → ' +
118
+ 'Bot Settings → Group Privacy → Turn off), or send the bot a /command in the group.';
119
+ const webhookCause = (url) =>
120
+ `A webhook is set on this bot (on ${url}), and while one is, getUpdates returns nothing. Remove it with ` +
121
+ `${TELEGRAM_API}/bot<YOUR-TOKEN>/deleteWebhook (open that URL with your real token), message the bot again, ` +
122
+ 'then re-run. Only remove it if nothing else of yours relies on that webhook.';
123
+ const nobody =
124
+ 'Nobody has messaged the bot yet: open a chat with it in Telegram, press Start (or send any message), then re-run.';
125
+ // Host only: a webhook URL often carries a secret path segment (#190 review).
126
+ const host = (u) => {
127
+ try {
128
+ return new URL(u).host;
129
+ } catch {
130
+ return 'a URL';
131
+ }
132
+ };
133
+ if (webhook && webhook.url)
134
+ return `No chats found. Cause: a webhook is set.\n ${webhookCause(host(webhook.url))}\n ${privacy}`;
135
+ if (webhook) return `No chats found. No webhook is set, so: ${nobody}\n ${privacy}`;
136
+ return `No chats found, and getWebhookInfo could not be read, so the cause is one of two.\n 1. ${webhookCause('unknown')}\n 2. ${nobody}\n ${privacy}`;
137
+ }
138
+
139
+ /** POST a Bot API method. Returns { ok, status, body } and never throws. `secrets` are scrubbed from any failure text. */
140
+ export async function telegramCall(method, token, payload, fetchImpl = fetch) {
141
+ try {
142
+ const res = await fetchImpl(`${TELEGRAM_API}/bot${token}/${method}`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(payload ?? {}),
146
+ signal: AbortSignal.timeout(TIMEOUT_MS),
147
+ });
148
+ let body = {};
149
+ try {
150
+ body = await res.json();
151
+ } catch {
152
+ /* a non-JSON error body: the status still says what happened */
153
+ }
154
+ return { ok: res.ok && body?.ok === true, status: res.status, body };
155
+ } catch (err) {
156
+ return {
157
+ ok: false,
158
+ status: 0,
159
+ network: true,
160
+ body: { description: scrub(err?.message ?? err, [token]) },
161
+ };
162
+ }
163
+ }
164
+
165
+ const apiError = (r) =>
166
+ `${r.body?.description ?? `HTTP ${r.status}`}${r.body?.error_code ? ` (error_code ${r.body.error_code})` : ''}`;
167
+
168
+ /** `--chat-id`. */
169
+ async function chatIdCommand({ token, fetchImpl, stdout, stderr }) {
170
+ if (!token) {
171
+ stderr(
172
+ 'could not look: TELEGRAM_BOT_TOKEN is not set. Create a bot with @BotFather (/newbot), then put TELEGRAM_BOT_TOKEN=… in .env.local.\n'
173
+ );
174
+ return EXIT.couldNotLook;
175
+ }
176
+ const updates = await telegramCall('getUpdates', token, { timeout: 0 }, fetchImpl);
177
+ if (updates.network) {
178
+ stderr(`could not look: ${updates.body.description}\n`);
179
+ return EXIT.couldNotLook;
180
+ }
181
+ // Telegram answers 409 to getUpdates while a webhook is active: that is the cause, not an unrelated refusal.
182
+ const webhookActive = updates.status === 409;
183
+ if (!updates.ok && !webhookActive) {
184
+ stderr(`Telegram rejected getUpdates: ${apiError(updates)}\n`);
185
+ return EXIT.refused;
186
+ }
187
+ const chats = webhookActive ? [] : chatsFromUpdates(updates.body?.result);
188
+ if (chats.length) {
189
+ stdout(`Found ${chats.length} chat(s) your bot has seen:\n`);
190
+ for (const c of chats) stdout(` ${String(c.id).padEnd(16)} ${c.type.padEnd(11)} ${c.title}\n`);
191
+ stdout(
192
+ 'Put the id in reporting.config.local.json (a public repo) or reporting.config.json as {"telegram":{"chatId":"<id>"}}.\n'
193
+ );
194
+ return EXIT.ok;
195
+ }
196
+ const info = await telegramCall('getWebhookInfo', token, {}, fetchImpl);
197
+ stdout(`${emptyChatExplanation(info.ok ? (info.body?.result ?? {}) : null)}\n`);
198
+ return EXIT.refused;
199
+ }
200
+
201
+ /** The Telegram chat id `--test` would use, and where it came from — or a reason there is none. */
202
+ export function resolveChatId({ env, load = loadReportingConfig }) {
203
+ try {
204
+ const id = chatIdFor(load(), null, env);
205
+ return id
206
+ ? { id: String(id), from: 'reporting config / TELEGRAM_CHAT_ID' }
207
+ : { reason: 'no telegram.chatId in the reporting config and no TELEGRAM_CHAT_ID' };
208
+ } catch (err) {
209
+ if (!(err instanceof ReportingConfigError)) throw err;
210
+ return env.TELEGRAM_CHAT_ID
211
+ ? { id: String(env.TELEGRAM_CHAT_ID), from: 'TELEGRAM_CHAT_ID' }
212
+ : { reason: err.message.split('\n')[0] };
213
+ }
214
+ }
215
+
216
+ /** `--test`. */
217
+ async function testCommand({ opts, token, webhook, env, chatIdEnv, fetchImpl, load, stdout, stderr }) {
218
+ // TELEGRAM_CHAT_ID may live in .env.local too: a project with no reporting.config.json yet has nowhere else for it.
219
+ const chat = resolveChatId({ env: { ...env, TELEGRAM_CHAT_ID: env.TELEGRAM_CHAT_ID || chatIdEnv }, load });
220
+ // Half-configured Telegram (a token without a chat id, or the reverse) is still WANTED, so the missing half
221
+ // is named — never silently skipped while Slack succeeds (the scheduled reports post to Telegram only).
222
+ const wantTelegram = opts.telegram || (!opts.slack && (Boolean(token) || Boolean(chat.id)));
223
+ const wantSlack = opts.slack || (!opts.telegram && Boolean(webhook));
224
+ const missing = [];
225
+ if (wantTelegram && !token) missing.push('TELEGRAM_BOT_TOKEN is not set (put it in .env.local)');
226
+ if (wantTelegram && !chat.id)
227
+ missing.push(`no Telegram chat id: ${chat.reason} (run --chat-id, then set telegram.chatId)`);
228
+ if (wantSlack && !webhook) missing.push('SLACK_WEBHOOK_URL is not set (put it in .env.local)');
229
+ if (!wantTelegram && !wantSlack)
230
+ missing.push('nothing is configured: set TELEGRAM_BOT_TOKEN + a chat id, and/or SLACK_WEBHOOK_URL');
231
+ if (missing.length) {
232
+ for (const m of missing) stderr(`could not look: ${m}\n`);
233
+ return EXIT.couldNotLook;
234
+ }
235
+
236
+ // One precedence whatever the send order: a refusal (a real configuration answer) outranks could-not-look
237
+ // (weather), which outranks ok (#190 review).
238
+ const RANK = { [EXIT.ok]: 0, [EXIT.couldNotLook]: 1, [EXIT.refused]: 2 };
239
+ const worse = (a, b) => (RANK[b] > RANK[a] ? b : a);
240
+ let code = EXIT.ok;
241
+ if (wantTelegram) {
242
+ const r = await telegramCall(
243
+ 'sendMessage',
244
+ token,
245
+ { chat_id: chat.id, text: TEST_TEXT, disable_web_page_preview: true },
246
+ fetchImpl
247
+ );
248
+ if (r.ok) stdout(`Telegram: sent to chat ${chat.id} (from ${chat.from}): "${TEST_TEXT}"\n`);
249
+ else if (r.network) {
250
+ stderr(`could not look: Telegram unreachable: ${r.body.description}\n`);
251
+ code = worse(code, EXIT.couldNotLook);
252
+ } else {
253
+ stderr(`Telegram rejected the message to chat ${chat.id}: ${apiError(r)}\n`);
254
+ code = worse(code, EXIT.refused);
255
+ }
256
+ }
257
+ if (wantSlack) {
258
+ const r = await sendSlack(webhook, { text: TEST_TEXT }, fetchImpl);
259
+ if (r.ok) stdout(`Slack: sent to the incoming webhook in SLACK_WEBHOOK_URL: "${TEST_TEXT}"\n`);
260
+ else if (r.status === 0) {
261
+ stderr(`could not look: Slack unreachable: ${scrub(r.body, [webhook])}\n`);
262
+ code = worse(code, EXIT.couldNotLook);
263
+ } else {
264
+ stderr(`Slack rejected the message: status=${r.status} body=${scrub(r.body, [webhook])}\n`);
265
+ code = worse(code, EXIT.refused);
266
+ }
267
+ }
268
+ if (code === EXIT.ok)
269
+ stdout('Scheduled reports post to Telegram only today; Slack covers this test and ad-hoc sends.\n');
270
+ return code;
271
+ }
272
+
273
+ /** The CLI with every side effect injected; returns the exit code. */
274
+ export async function run(argv, io = {}) {
275
+ const {
276
+ env = process.env,
277
+ fetchImpl = fetch,
278
+ readValue = (name) => readEnvValue(name, { env }),
279
+ load = loadReportingConfig,
280
+ stdout = (t) => process.stdout.write(t),
281
+ stderr = (t) => process.stderr.write(t),
282
+ } = io;
283
+ const parsed = parseArgs(argv);
284
+ if (parsed.error) {
285
+ stderr(
286
+ `notify-setup: ${parsed.error}\nusage: notify-setup.mjs --chat-id | --test [--telegram] [--slack]\n`
287
+ );
288
+ return EXIT.usage;
289
+ }
290
+ const token = readValue('TELEGRAM_BOT_TOKEN');
291
+ const webhook = readValue('SLACK_WEBHOOK_URL');
292
+ // Everything shown goes through the scrubbing writers, so a secret echoed by any layer cannot reach the screen.
293
+ const secrets = [token, webhook];
294
+ const out = (t) => stdout(scrub(t, secrets));
295
+ const err = (t) => stderr(scrub(t, secrets));
296
+ if (parsed.opts.chatId) return chatIdCommand({ token, fetchImpl, stdout: out, stderr: err });
297
+ return testCommand({
298
+ opts: parsed.opts,
299
+ token,
300
+ webhook,
301
+ env,
302
+ chatIdEnv: readValue('TELEGRAM_CHAT_ID'),
303
+ fetchImpl,
304
+ load,
305
+ stdout: out,
306
+ stderr: err,
307
+ });
308
+ }
309
+
310
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
311
+ if (isMain)
312
+ run(process.argv.slice(2))
313
+ .then((code) => process.exit(code))
314
+ .catch((e) => {
315
+ process.stderr.write(`notify-setup: ${e.message}\n`);
316
+ process.exit(1);
317
+ });
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ // slack-notify.mjs — the CI/CD Slack ping, structured the same way as scripts/telegram-notify.mjs.
3
+ //
4
+ // Used by .github/workflows/notify-telegram.yml's push-notification and deploy-notification jobs,
5
+ // alongside (not instead of) the existing Telegram calls:
6
+ // node scripts/slack-notify.mjs --icon 📦 --sha "$GITHUB_SHA" --subject "$COMMIT_MSG" \
7
+ // --meta "by @$AUTHOR" --url "$DIFF_URL" --link-label "view diff"
8
+ //
9
+ // ── Why Incoming Webhook and not a bot token + chat.postMessage ────────────────────────────────
10
+ // Same reasoning this repo already applied to Telegram (no VERCEL_API_TOKEN when a push-based
11
+ // signal existed instead — see notify-telegram.yml's header): one fewer credential to mint, store
12
+ // and rotate. An Incoming Webhook URL is itself the channel-scoped credential — there is no
13
+ // separate bot token, channel ID, or OAuth scope to manage for this feature. If a future need
14
+ // requires posting to a channel chosen at runtime (rather than the one fixed at webhook-creation
15
+ // time), that is the point to move to `chat.postMessage` with a bot token — not before.
16
+ //
17
+ // ── Response handling is NOT the same shape as Telegram's — this is the one real gotcha ───────
18
+ // Telegram's sendMessage returns JSON ({ok: bool, ...}) regardless of outcome. Slack's Incoming
19
+ // Webhook endpoint does NOT: on success it returns the literal string "ok" (200, `text/plain`); on
20
+ // failure it returns a short plain-text error token (e.g. "invalid_payload", "channel_not_found",
21
+ // "no_service") with a non-200 status. Parsing the body as JSON here would throw on every single
22
+ // call, success or failure. So `send()` reads the body as text and treats HTTP status as the
23
+ // primary success signal — see Slack's own webhook error-handling docs if this ever needs
24
+ // re-verifying against a live change.
25
+ //
26
+ // ── Escaping is mrkdwn, not HTML, but the substitution table is identical ──────────────────────
27
+ // See scripts/lib/slack-text.mjs's header for why escapeMrkdwn and Telegram's escapeHtml do the
28
+ // same three replacements. Links use Slack's `<url|label>` syntax rather than `<a href>`; inline
29
+ // code uses single backticks rather than `<code>`.
30
+ //
31
+ // House style carried over unchanged: secrets only from GitHub Secrets, a 10s request timeout, and
32
+ // a repo with the secret unset gets a clean skip (not a failure) — see main() below.
33
+
34
+ import { escapeMrkdwn, escapeToFit, SLACK_LIMIT } from './lib/slack-text.mjs';
35
+ import { die, need } from './lib/cross-agent-cli.mjs';
36
+ import { resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ /**
40
+ * Assemble the Incoming Webhook payload.
41
+ *
42
+ * The subject is the only field given a length budget, same rule as Telegram's buildNotification:
43
+ * the sha, status, icon and link are structural, and truncating them to make room for a runaway
44
+ * commit message would discard exactly the parts a reader needs.
45
+ *
46
+ * Pure, and exported, so a test exercises the real assembler rather than a paraphrase of it.
47
+ */
48
+ export function buildNotification({ repo, sha, icon, subject, meta, status, url, linkLabel }) {
49
+ const shortSha = String(sha ?? '').slice(0, 7);
50
+
51
+ // Chrome first, measured rather than estimated — same reasoning as Telegram's version: a
52
+ // hardcoded guess here is how a "safe" cap stops being safe once a line is added below.
53
+ const head = `${escapeMrkdwn(repo)} · ${icon} · \`${escapeMrkdwn(shortSha)}\``;
54
+ const tail = `<${escapeMrkdwn(url)}|${escapeMrkdwn(linkLabel)}>`;
55
+ const metaLine = meta ? escapeMrkdwn(meta) : null;
56
+ // `status` is our own literal ("✅ READY"), never external input — not escaped, same as Telegram's
57
+ // version, so the emoji and casing survive.
58
+ const statusLine = status || null;
59
+
60
+ const chrome = [head, metaLine, statusLine, tail].filter(Boolean).join('\n').length + 1;
61
+
62
+ // First line only: a commit body belongs behind the link, not in a notification.
63
+ const firstLine = String(subject ?? '').split('\n')[0];
64
+ const safeSubject = escapeToFit(firstLine, Math.max(1, SLACK_LIMIT - chrome - 16));
65
+
66
+ const lines = [head, safeSubject];
67
+ if (metaLine) lines.push(metaLine);
68
+ if (statusLine) lines.push(statusLine);
69
+ lines.push(tail);
70
+
71
+ return {
72
+ text: lines.join('\n'),
73
+ // Belt-and-suspenders alongside Telegram's disable_web_page_preview: without this a commit or
74
+ // deployment link can unfurl into a big preview card that buries the next message.
75
+ unfurl_links: false,
76
+ unfurl_media: false,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * POST it. Returns { ok, status, body } — never throws.
82
+ *
83
+ * Slack's webhook response is PLAIN TEXT ("ok" on success, a short error token on failure), not
84
+ * JSON like Telegram's — see this file's header. An accepted webhook is the conjunction Slack
85
+ * documents: a 2xx response and the literal plain-text body `ok`.
86
+ */
87
+ export async function send(webhookUrl, payload, fetchImpl = fetch) {
88
+ try {
89
+ const res = await fetchImpl(webhookUrl, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(payload),
93
+ signal: AbortSignal.timeout(10_000),
94
+ });
95
+ const body = await res.text().catch(() => '');
96
+ return { ok: res.ok && body.trim() === 'ok', status: res.status, body };
97
+ } catch (err) {
98
+ return { ok: false, status: 0, body: `request failed: ${err?.message ?? err}` };
99
+ }
100
+ }
101
+
102
+ async function main() {
103
+ const args = process.argv.slice(2);
104
+ const opt = {};
105
+ for (let i = 0; i < args.length; i++) {
106
+ const k = args[i];
107
+ if (!k.startsWith('--')) die(`unexpected argument ${k}`);
108
+ opt[k.slice(2)] = need(args[++i], k);
109
+ }
110
+
111
+ const webhookUrl = process.env.SLACK_WEBHOOK_URL?.trim();
112
+ if (!webhookUrl) {
113
+ // Same clean-skip stance as telegram-notify.mjs: a repo without the secret configured is a
114
+ // configuration state, not a failure.
115
+ process.stderr.write(
116
+ 'SLACK_WEBHOOK_URL not set — skipping cleanly.\n' +
117
+ ' Set it as a GitHub Secret on this repo to turn Slack notifications on.\n'
118
+ );
119
+ return;
120
+ }
121
+
122
+ const payload = buildNotification({
123
+ repo: opt.repo ?? 'golden-beans',
124
+ sha: opt.sha ?? '',
125
+ icon: opt.icon ?? '📦',
126
+ subject: opt.subject ?? '',
127
+ meta: opt.meta ?? '',
128
+ status: opt.status ?? '',
129
+ url: opt.url ?? '',
130
+ linkLabel: opt['link-label'] ?? 'view',
131
+ });
132
+
133
+ if (payload.text.length > SLACK_LIMIT) {
134
+ // Should be unreachable — escapeToFit bounds the only unbounded field. Checked anyway, same
135
+ // stance as telegram-notify.mjs.
136
+ die(`assembled text is ${payload.text.length} chars, over the ${SLACK_LIMIT}-char budget`);
137
+ }
138
+
139
+ const result = await send(webhookUrl, payload);
140
+ if (result.ok) {
141
+ process.stderr.write(`✓ ping delivered (${payload.text.length} chars)\n`);
142
+ return;
143
+ }
144
+
145
+ // Loud, same policy as Telegram: an observer workflow with a rejected message and no visible
146
+ // failure is a rail with no monitoring at all.
147
+ process.stderr.write(
148
+ `::error title=Slack ping NOT delivered::status=${result.status} body=${result.body}\n`
149
+ );
150
+ process.exitCode = 1;
151
+ }
152
+
153
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
154
+ if (isMain) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {