@jtalk22/slack-mcp 1.2.3 → 2.0.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 +23 -7
- package/docs/API.md +11 -4
- package/docs/COMMUNICATION-STYLE.md +1 -0
- package/docs/COMPATIBILITY.md +19 -0
- package/docs/HN-LAUNCH.md +32 -32
- package/docs/INDEX.md +10 -1
- package/docs/INSTALL-PROOF.md +18 -0
- package/docs/LAUNCH-COPY-v2.0.0.md +59 -0
- package/docs/LAUNCH-MATRIX.md +20 -0
- package/docs/LAUNCH-OPS.md +70 -0
- package/docs/RELEASE-HEALTH.md +81 -0
- package/docs/SETUP.md +2 -0
- package/docs/TROUBLESHOOTING.md +6 -4
- package/docs/WEB-API.md +12 -1
- package/docs/release-health/2026-02-25.md +33 -0
- package/docs/release-health/2026-02-26.md +33 -0
- package/docs/release-health/24h-delta.md +21 -0
- package/docs/release-health/24h-end.md +33 -0
- package/docs/release-health/24h-start.md +33 -0
- package/docs/release-health/latest.md +33 -0
- package/docs/release-health/launch-log-template.md +21 -0
- package/docs/release-health/version-parity.md +21 -0
- package/lib/handlers.js +121 -85
- package/lib/slack-client.js +37 -17
- package/lib/token-store.js +103 -30
- package/package.json +26 -39
- package/public/demo-claude.html +4 -4
- package/public/demo-video.html +2 -2
- package/public/demo.html +2 -2
- package/scripts/build-release-health-delta.js +201 -0
- package/scripts/check-public-language.sh +25 -0
- package/scripts/check-version-parity.js +162 -0
- package/scripts/collect-release-health.js +150 -0
- package/scripts/setup-wizard.js +35 -9
- package/scripts/token-cli.js +6 -4
- package/scripts/verify-install-flow.js +107 -2
- package/src/server-http.js +26 -4
- package/src/server.js +23 -7
- package/src/web-server.js +61 -23
package/lib/token-store.js
CHANGED
|
@@ -21,6 +21,7 @@ const IS_MACOS = platform() === 'darwin';
|
|
|
21
21
|
|
|
22
22
|
// Refresh lock to prevent concurrent extraction attempts
|
|
23
23
|
let refreshInProgress = null;
|
|
24
|
+
let lastExtractionError = null;
|
|
24
25
|
|
|
25
26
|
// ============ Keychain Storage (macOS only) ============
|
|
26
27
|
|
|
@@ -62,7 +63,7 @@ export function getFromFile() {
|
|
|
62
63
|
return {
|
|
63
64
|
token: data.SLACK_TOKEN,
|
|
64
65
|
cookie: data.SLACK_COOKIE,
|
|
65
|
-
updatedAt: data.updated_at
|
|
66
|
+
updatedAt: data.updated_at || data.UPDATED_AT || null
|
|
66
67
|
};
|
|
67
68
|
} catch (e) {
|
|
68
69
|
return null;
|
|
@@ -110,13 +111,53 @@ const SLACK_TOKEN_PATHS = [
|
|
|
110
111
|
`window.boot_data?.api_token`,
|
|
111
112
|
];
|
|
112
113
|
|
|
114
|
+
function normalizeExtractionError(error) {
|
|
115
|
+
const raw = String(error?.message || error || "");
|
|
116
|
+
|
|
117
|
+
if (raw.includes("Executing JavaScript through AppleScript is turned off")) {
|
|
118
|
+
return {
|
|
119
|
+
code: "apple_events_javascript_disabled",
|
|
120
|
+
message: "Chrome blocked JavaScript execution from Apple Events.",
|
|
121
|
+
detail: "Enable it in Chrome: View > Developer > Allow JavaScript from Apple Events."
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (raw.includes("Application isn't running") || raw.includes("Google Chrome got an error")) {
|
|
126
|
+
return {
|
|
127
|
+
code: "chrome_not_ready",
|
|
128
|
+
message: "Chrome is not ready for token extraction.",
|
|
129
|
+
detail: "Open Google Chrome with an active Slack tab at app.slack.com."
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (raw.toLowerCase().includes("timed out")) {
|
|
134
|
+
return {
|
|
135
|
+
code: "chrome_extraction_timeout",
|
|
136
|
+
message: "Chrome token extraction timed out.",
|
|
137
|
+
detail: "Ensure Slack is open in Chrome and retry."
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
code: "chrome_extraction_failed",
|
|
143
|
+
message: "Chrome token extraction failed.",
|
|
144
|
+
detail: raw || "Unknown extraction error."
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
/**
|
|
114
149
|
* Extract tokens from Chrome (macOS only, uses AppleScript)
|
|
115
150
|
* Returns null on non-macOS platforms
|
|
116
151
|
*/
|
|
117
152
|
function extractFromChromeInternal() {
|
|
153
|
+
lastExtractionError = null;
|
|
118
154
|
if (!IS_MACOS) {
|
|
119
155
|
// AppleScript/osascript is macOS-only
|
|
156
|
+
lastExtractionError = {
|
|
157
|
+
code: "unsupported_platform",
|
|
158
|
+
message: "Chrome auto-extraction is only available on macOS.",
|
|
159
|
+
detail: "Use manual token setup on this platform."
|
|
160
|
+
};
|
|
120
161
|
return null;
|
|
121
162
|
}
|
|
122
163
|
|
|
@@ -138,7 +179,14 @@ function extractFromChromeInternal() {
|
|
|
138
179
|
encoding: 'utf-8', timeout: 5000
|
|
139
180
|
}).trim();
|
|
140
181
|
|
|
141
|
-
if (!cookie || !cookie.startsWith('xoxd-'))
|
|
182
|
+
if (!cookie || !cookie.startsWith('xoxd-')) {
|
|
183
|
+
lastExtractionError = {
|
|
184
|
+
code: "cookie_not_found",
|
|
185
|
+
message: "Could not extract Slack cookie from Chrome.",
|
|
186
|
+
detail: "Ensure a logged-in Slack tab is open at app.slack.com."
|
|
187
|
+
};
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
142
190
|
|
|
143
191
|
// Try multiple token extraction paths
|
|
144
192
|
const tokenPathsJS = SLACK_TOKEN_PATHS.map((path, i) =>
|
|
@@ -161,10 +209,18 @@ function extractFromChromeInternal() {
|
|
|
161
209
|
encoding: 'utf-8', timeout: 5000
|
|
162
210
|
}).trim();
|
|
163
211
|
|
|
164
|
-
if (!token || !token.startsWith('xoxc-'))
|
|
212
|
+
if (!token || !token.startsWith('xoxc-')) {
|
|
213
|
+
lastExtractionError = {
|
|
214
|
+
code: "token_not_found",
|
|
215
|
+
message: "Could not extract Slack token from Chrome.",
|
|
216
|
+
detail: "Refresh Slack in Chrome and retry extraction."
|
|
217
|
+
};
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
165
220
|
|
|
166
221
|
return { token, cookie };
|
|
167
222
|
} catch (e) {
|
|
223
|
+
lastExtractionError = normalizeExtractionError(e);
|
|
168
224
|
return null;
|
|
169
225
|
}
|
|
170
226
|
}
|
|
@@ -188,6 +244,10 @@ export function extractFromChrome() {
|
|
|
188
244
|
}
|
|
189
245
|
}
|
|
190
246
|
|
|
247
|
+
export function getLastExtractionError() {
|
|
248
|
+
return lastExtractionError;
|
|
249
|
+
}
|
|
250
|
+
|
|
191
251
|
/**
|
|
192
252
|
* Check if auto-refresh is available on this platform
|
|
193
253
|
*/
|
|
@@ -197,9 +257,8 @@ export function isAutoRefreshAvailable() {
|
|
|
197
257
|
|
|
198
258
|
// ============ Main Token Loader ============
|
|
199
259
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (!forceRefresh && process.env.SLACK_TOKEN && process.env.SLACK_COOKIE) {
|
|
260
|
+
function getStoredTokens() {
|
|
261
|
+
if (process.env.SLACK_TOKEN && process.env.SLACK_COOKIE) {
|
|
203
262
|
return {
|
|
204
263
|
token: process.env.SLACK_TOKEN,
|
|
205
264
|
cookie: process.env.SLACK_COOKIE,
|
|
@@ -207,37 +266,46 @@ export function loadTokens(forceRefresh = false, logger = console) {
|
|
|
207
266
|
};
|
|
208
267
|
}
|
|
209
268
|
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
269
|
+
const fileTokens = getFromFile();
|
|
270
|
+
if (fileTokens?.token && fileTokens?.cookie) {
|
|
271
|
+
return {
|
|
272
|
+
token: fileTokens.token,
|
|
273
|
+
cookie: fileTokens.cookie,
|
|
274
|
+
source: "file",
|
|
275
|
+
updatedAt: fileTokens.updatedAt
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const keychainToken = getFromKeychain("token");
|
|
280
|
+
const keychainCookie = getFromKeychain("cookie");
|
|
281
|
+
if (keychainToken && keychainCookie) {
|
|
282
|
+
return {
|
|
283
|
+
token: keychainToken,
|
|
284
|
+
cookie: keychainCookie,
|
|
285
|
+
source: "keychain"
|
|
286
|
+
};
|
|
221
287
|
}
|
|
222
288
|
|
|
223
|
-
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function loadTokensReadOnly() {
|
|
293
|
+
return getStoredTokens();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function loadTokens(forceRefresh = false, logger = console, options = {}) {
|
|
297
|
+
const { autoExtract = true } = options;
|
|
224
298
|
if (!forceRefresh) {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
if (keychainToken && keychainCookie) {
|
|
228
|
-
return {
|
|
229
|
-
token: keychainToken,
|
|
230
|
-
cookie: keychainCookie,
|
|
231
|
-
source: "keychain"
|
|
232
|
-
};
|
|
233
|
-
}
|
|
299
|
+
const storedTokens = getStoredTokens();
|
|
300
|
+
if (storedTokens) return storedTokens;
|
|
234
301
|
}
|
|
235
302
|
|
|
236
|
-
|
|
237
|
-
|
|
303
|
+
if (!autoExtract) return null;
|
|
304
|
+
|
|
305
|
+
logger.error?.("Attempting Chrome auto-extraction...");
|
|
238
306
|
const chromeTokens = extractFromChrome();
|
|
239
307
|
if (chromeTokens) {
|
|
240
|
-
logger.error("Successfully extracted tokens from Chrome!");
|
|
308
|
+
logger.error?.("Successfully extracted tokens from Chrome!");
|
|
241
309
|
saveTokens(chromeTokens.token, chromeTokens.cookie);
|
|
242
310
|
return {
|
|
243
311
|
token: chromeTokens.token,
|
|
@@ -246,6 +314,11 @@ export function loadTokens(forceRefresh = false, logger = console) {
|
|
|
246
314
|
};
|
|
247
315
|
}
|
|
248
316
|
|
|
317
|
+
if (lastExtractionError?.code === "apple_events_javascript_disabled") {
|
|
318
|
+
logger.error?.(lastExtractionError.message);
|
|
319
|
+
logger.error?.(lastExtractionError.detail);
|
|
320
|
+
}
|
|
321
|
+
|
|
249
322
|
return null;
|
|
250
323
|
}
|
|
251
324
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtalk22/slack-mcp",
|
|
3
3
|
"mcpName": "io.github.jtalk22/slack-mcp-server",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"description": "Session-based Slack access for Claude - DMs, channels, search, and threads. Local-first with your existing Slack session.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/server.js",
|
|
8
8
|
"bin": {
|
|
9
|
-
"slack-mcp": "
|
|
10
|
-
"slack-mcp-server": "
|
|
11
|
-
"slack-mcp-http": "
|
|
12
|
-
"slack-mcp-web": "
|
|
13
|
-
"slack-mcp-setup": "
|
|
9
|
+
"slack-mcp": "src/cli.js",
|
|
10
|
+
"slack-mcp-server": "src/server.js",
|
|
11
|
+
"slack-mcp-http": "src/server-http.js",
|
|
12
|
+
"slack-mcp-web": "src/web-server.js",
|
|
13
|
+
"slack-mcp-setup": "scripts/setup-wizard.js"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "node src/server.js",
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"tokens:auto": "node scripts/token-cli.js auto",
|
|
25
25
|
"tokens:clear": "node scripts/token-cli.js clear",
|
|
26
26
|
"screenshot": "node scripts/capture-screenshots.js",
|
|
27
|
-
"record-demo": "node scripts/record-demo.js"
|
|
27
|
+
"record-demo": "node scripts/record-demo.js",
|
|
28
|
+
"metrics:release-health": "node scripts/collect-release-health.js",
|
|
29
|
+
"metrics:release-health:delta": "node scripts/build-release-health-delta.js",
|
|
30
|
+
"verify:version-parity": "node scripts/check-version-parity.js"
|
|
28
31
|
},
|
|
29
32
|
"keywords": [
|
|
30
33
|
"mcp",
|
|
@@ -33,55 +36,39 @@
|
|
|
33
36
|
"slack",
|
|
34
37
|
"slack-api",
|
|
35
38
|
"slack-mcp",
|
|
36
|
-
"slack-bot",
|
|
37
39
|
"slack-integration",
|
|
38
40
|
"claude",
|
|
39
41
|
"claude-desktop",
|
|
40
42
|
"claude-code",
|
|
41
|
-
"
|
|
43
|
+
"cursor",
|
|
44
|
+
"llm",
|
|
42
45
|
"ai",
|
|
43
46
|
"ai-assistant",
|
|
44
|
-
"ai-agent",
|
|
45
|
-
"llm",
|
|
46
|
-
"llm-tools",
|
|
47
|
-
"chatbot",
|
|
48
|
-
"dm",
|
|
49
|
-
"direct-messages",
|
|
50
|
-
"channels",
|
|
51
|
-
"workspace",
|
|
52
47
|
"automation",
|
|
53
48
|
"workflow-automation",
|
|
54
|
-
"browser-tokens",
|
|
55
|
-
"no-oauth",
|
|
56
|
-
"cli",
|
|
57
49
|
"developer-tools",
|
|
58
|
-
"
|
|
50
|
+
"cli",
|
|
59
51
|
"productivity",
|
|
60
52
|
"messaging",
|
|
61
|
-
"chat",
|
|
62
|
-
"model-context-protocol",
|
|
63
|
-
"chatgpt",
|
|
64
|
-
"cursor",
|
|
65
|
-
"open-source",
|
|
66
|
-
"openai",
|
|
67
|
-
"gpt",
|
|
68
|
-
"gemini",
|
|
69
|
-
"copilot",
|
|
70
|
-
"ai-tools",
|
|
71
|
-
"personal-assistant",
|
|
72
53
|
"message-search",
|
|
73
|
-
"
|
|
74
|
-
"
|
|
54
|
+
"direct-messages",
|
|
55
|
+
"channels",
|
|
56
|
+
"workspace",
|
|
57
|
+
"session-based",
|
|
58
|
+
"session-mirroring",
|
|
59
|
+
"slack-dm",
|
|
60
|
+
"slack-threads",
|
|
61
|
+
"workspace-search",
|
|
62
|
+
"mcp-tools",
|
|
63
|
+
"mcp-stdio",
|
|
64
|
+
"mcp-http",
|
|
65
|
+
"open-source"
|
|
75
66
|
],
|
|
76
|
-
"funding": {
|
|
77
|
-
"type": "individual",
|
|
78
|
-
"url": "https://github.com/sponsors/jtalk22"
|
|
79
|
-
},
|
|
80
67
|
"author": "jtalk22",
|
|
81
68
|
"license": "MIT",
|
|
82
69
|
"repository": {
|
|
83
70
|
"type": "git",
|
|
84
|
-
"url": "https://github.com/jtalk22/slack-mcp-server.git"
|
|
71
|
+
"url": "git+https://github.com/jtalk22/slack-mcp-server.git"
|
|
85
72
|
},
|
|
86
73
|
"homepage": "https://jtalk22.github.io/slack-mcp-server/public/demo.html",
|
|
87
74
|
"bugs": {
|
package/public/demo-claude.html
CHANGED
|
@@ -1152,16 +1152,16 @@
|
|
|
1152
1152
|
<div class="links">
|
|
1153
1153
|
<a href="https://www.npmjs.com/package/@jtalk22/slack-mcp" target="_blank" rel="noopener noreferrer">Install</a>
|
|
1154
1154
|
<a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/SETUP.md" target="_blank" rel="noopener noreferrer">Setup Guide</a>
|
|
1155
|
-
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-
|
|
1155
|
+
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-compatibility-check" target="_blank" rel="noopener noreferrer">30-Second Check</a>
|
|
1156
1156
|
</div>
|
|
1157
1157
|
<div class="note">
|
|
1158
|
-
Free local-first path with
|
|
1158
|
+
Free local-first path with team rollout references in <a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/DEPLOYMENT-MODES.md" target="_blank" rel="noopener noreferrer">Deployment Modes</a>.
|
|
1159
1159
|
</div>
|
|
1160
1160
|
</div>
|
|
1161
1161
|
<header class="page-header">
|
|
1162
1162
|
<h1>
|
|
1163
1163
|
<span>Slack MCP Server</span>
|
|
1164
|
-
<span class="badge">🔧 MCP Demo
|
|
1164
|
+
<span class="badge">🔧 MCP Demo v2.0.0</span>
|
|
1165
1165
|
</h1>
|
|
1166
1166
|
<p>See how Claude uses MCP tools to access your Slack workspace</p>
|
|
1167
1167
|
</header>
|
|
@@ -1233,7 +1233,7 @@
|
|
|
1233
1233
|
<div class="title-logo">💬</div>
|
|
1234
1234
|
<h1>Slack MCP Server</h1>
|
|
1235
1235
|
<p class="title-tagline">Full Slack access for Claude Desktop</p>
|
|
1236
|
-
<p class="title-version">
|
|
1236
|
+
<p class="title-version">v2.0.0 • @jtalk22</p>
|
|
1237
1237
|
</div>
|
|
1238
1238
|
|
|
1239
1239
|
<!-- Scenario Caption Overlay -->
|
package/public/demo-video.html
CHANGED
|
@@ -147,10 +147,10 @@
|
|
|
147
147
|
<div class="links">
|
|
148
148
|
<a href="https://www.npmjs.com/package/@jtalk22/slack-mcp" target="_blank" rel="noopener noreferrer">Install</a>
|
|
149
149
|
<a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/SETUP.md" target="_blank" rel="noopener noreferrer">Setup Guide</a>
|
|
150
|
-
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-
|
|
150
|
+
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-compatibility-check" target="_blank" rel="noopener noreferrer">30-Second Check</a>
|
|
151
151
|
</div>
|
|
152
152
|
<div class="note">
|
|
153
|
-
Free local-first path with
|
|
153
|
+
Free local-first path with team rollout references in <a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/DEPLOYMENT-MODES.md" target="_blank" rel="noopener noreferrer">Deployment Modes</a>.
|
|
154
154
|
</div>
|
|
155
155
|
</div>
|
|
156
156
|
|
package/public/demo.html
CHANGED
|
@@ -637,10 +637,10 @@
|
|
|
637
637
|
<div class="cta-links">
|
|
638
638
|
<a href="https://www.npmjs.com/package/@jtalk22/slack-mcp" target="_blank" rel="noopener noreferrer">Install</a>
|
|
639
639
|
<a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/SETUP.md" target="_blank" rel="noopener noreferrer">Setup Guide</a>
|
|
640
|
-
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-
|
|
640
|
+
<a href="https://github.com/jtalk22/slack-mcp-server#30-second-compatibility-check" target="_blank" rel="noopener noreferrer">30-Second Check</a>
|
|
641
641
|
</div>
|
|
642
642
|
<div class="cta-note">
|
|
643
|
-
Free local-first path with
|
|
643
|
+
Free local-first path with team rollout references in <a href="https://github.com/jtalk22/slack-mcp-server/blob/main/docs/DEPLOYMENT-MODES.md" target="_blank" rel="noopener noreferrer">Deployment Modes</a>.
|
|
644
644
|
</div>
|
|
645
645
|
</div>
|
|
646
646
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const METRICS = [
|
|
7
|
+
"npm downloads (last week)",
|
|
8
|
+
"npm downloads (last month)",
|
|
9
|
+
"stars",
|
|
10
|
+
"forks",
|
|
11
|
+
"open issues",
|
|
12
|
+
"14d views",
|
|
13
|
+
"14d unique visitors",
|
|
14
|
+
"14d clones",
|
|
15
|
+
"14d unique cloners",
|
|
16
|
+
"deployment-intake submissions (all-time)",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_AFTER = resolve("docs", "release-health", "latest.md");
|
|
20
|
+
const DEFAULT_OUT = resolve("docs", "release-health", "automation-delta.md");
|
|
21
|
+
const TARGETS = {
|
|
22
|
+
"npm downloads (last week)": { operator: ">=", value: 180 },
|
|
23
|
+
"deployment-intake submissions (all-time)": { operator: ">=", value: 2 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function escapeRegex(input) {
|
|
27
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const args = { before: null, after: DEFAULT_AFTER, out: DEFAULT_OUT };
|
|
32
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
33
|
+
const arg = argv[i];
|
|
34
|
+
if (arg === "--before") {
|
|
35
|
+
if (!argv[i + 1]) throw new Error("Missing value for --before");
|
|
36
|
+
args.before = argv[i + 1];
|
|
37
|
+
i += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg === "--after") {
|
|
41
|
+
if (!argv[i + 1]) throw new Error("Missing value for --after");
|
|
42
|
+
args.after = argv[i + 1];
|
|
43
|
+
i += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === "--out") {
|
|
47
|
+
if (!argv[i + 1]) throw new Error("Missing value for --out");
|
|
48
|
+
args.out = argv[i + 1];
|
|
49
|
+
i += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
53
|
+
}
|
|
54
|
+
return args;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readSnapshot(path) {
|
|
58
|
+
if (!path || !existsSync(path)) {
|
|
59
|
+
return { path, metrics: new Map(), generatedAt: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const content = readFileSync(path, "utf8");
|
|
63
|
+
const generatedAtMatch = content.match(/^- Generated:\s+(.+)$/m);
|
|
64
|
+
const metrics = new Map();
|
|
65
|
+
|
|
66
|
+
for (const label of METRICS) {
|
|
67
|
+
const metricRegex = new RegExp(`^- ${escapeRegex(label)}:\\s+(.+)$`, "m");
|
|
68
|
+
const match = content.match(metricRegex);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
|
|
71
|
+
const raw = match[1].trim();
|
|
72
|
+
if (raw.toLowerCase() === "n/a") {
|
|
73
|
+
metrics.set(label, null);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = Number(raw.replace(/,/g, ""));
|
|
78
|
+
metrics.set(label, Number.isFinite(parsed) ? parsed : null);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
path,
|
|
83
|
+
metrics,
|
|
84
|
+
generatedAt: generatedAtMatch ? generatedAtMatch[1].trim() : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatValue(value) {
|
|
89
|
+
return value === null || value === undefined ? "n/a" : `${value}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDelta(beforeValue, afterValue) {
|
|
93
|
+
if (!Number.isFinite(beforeValue) || !Number.isFinite(afterValue)) {
|
|
94
|
+
return "n/a";
|
|
95
|
+
}
|
|
96
|
+
const delta = afterValue - beforeValue;
|
|
97
|
+
if (delta > 0) return `+${delta}`;
|
|
98
|
+
return `${delta}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function evaluateTarget(metricLabel, value) {
|
|
102
|
+
const target = TARGETS[metricLabel];
|
|
103
|
+
if (!target || !Number.isFinite(value)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (target.operator === ">=") {
|
|
108
|
+
return value >= target.value;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function displayPath(path) {
|
|
114
|
+
if (!path) return "(none found)";
|
|
115
|
+
const relPath = relative(process.cwd(), path);
|
|
116
|
+
if (relPath && !relPath.startsWith("..")) {
|
|
117
|
+
return relPath;
|
|
118
|
+
}
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderMarkdown(beforeSnapshot, afterSnapshot, rows) {
|
|
123
|
+
const lines = [];
|
|
124
|
+
lines.push("# Automated Release Health Delta");
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(
|
|
127
|
+
`- Baseline snapshot: ${
|
|
128
|
+
beforeSnapshot.path && existsSync(beforeSnapshot.path) ? `\`${displayPath(beforeSnapshot.path)}\`` : "`(none found)`"
|
|
129
|
+
}`
|
|
130
|
+
);
|
|
131
|
+
lines.push(`- Current snapshot: \`${displayPath(afterSnapshot.path)}\``);
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("| Metric | Baseline | Current | Delta |");
|
|
134
|
+
lines.push("|---|---:|---:|---:|");
|
|
135
|
+
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
lines.push(
|
|
138
|
+
`| ${row.metric} | ${formatValue(row.beforeValue)} | ${formatValue(row.afterValue)} | ${formatDelta(
|
|
139
|
+
row.beforeValue,
|
|
140
|
+
row.afterValue
|
|
141
|
+
)} |`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push("");
|
|
146
|
+
lines.push("## Target Checks");
|
|
147
|
+
lines.push("");
|
|
148
|
+
|
|
149
|
+
for (const metric of Object.keys(TARGETS)) {
|
|
150
|
+
const value = afterSnapshot.metrics.get(metric) ?? null;
|
|
151
|
+
const passed = evaluateTarget(metric, value);
|
|
152
|
+
const target = TARGETS[metric];
|
|
153
|
+
const status = passed === null ? "n/a" : passed ? "pass" : "miss";
|
|
154
|
+
lines.push(`- ${metric} ${target.operator} ${target.value}: ${status} (current: ${formatValue(value)})`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (beforeSnapshot.generatedAt || afterSnapshot.generatedAt) {
|
|
158
|
+
lines.push("");
|
|
159
|
+
lines.push("## Snapshot Times");
|
|
160
|
+
lines.push("");
|
|
161
|
+
if (beforeSnapshot.generatedAt) {
|
|
162
|
+
lines.push(`- Baseline generated: ${beforeSnapshot.generatedAt}`);
|
|
163
|
+
}
|
|
164
|
+
if (afterSnapshot.generatedAt) {
|
|
165
|
+
lines.push(`- Current generated: ${afterSnapshot.generatedAt}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return `${lines.join("\n")}\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function main() {
|
|
173
|
+
const args = parseArgs(process.argv.slice(2));
|
|
174
|
+
const beforeSnapshot = readSnapshot(args.before ? resolve(args.before) : null);
|
|
175
|
+
const afterPath = resolve(args.after);
|
|
176
|
+
|
|
177
|
+
if (!existsSync(afterPath)) {
|
|
178
|
+
throw new Error(`Current snapshot not found: ${afterPath}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const afterSnapshot = readSnapshot(afterPath);
|
|
182
|
+
const rows = METRICS.map((metric) => ({
|
|
183
|
+
metric,
|
|
184
|
+
beforeValue: beforeSnapshot.metrics.get(metric) ?? null,
|
|
185
|
+
afterValue: afterSnapshot.metrics.get(metric) ?? null,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const outputPath = resolve(args.out);
|
|
189
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
190
|
+
const markdown = renderMarkdown(beforeSnapshot, afterSnapshot, rows);
|
|
191
|
+
writeFileSync(outputPath, markdown);
|
|
192
|
+
|
|
193
|
+
console.log(`Wrote ${outputPath}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
main();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error(error.message);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
|
|
6
|
+
# Terms that tend to read as hype/manipulative for technical audiences.
|
|
7
|
+
DISALLOWED='(?i)(\bstealth\b|\bgrowth\b|social-proof|social proof|share kit|\bviral\b|growth loop|\bgrindset\b|\bdominate\b|hack growth|edge line|launch-ready)'
|
|
8
|
+
|
|
9
|
+
SCAN_PATHS=(
|
|
10
|
+
"$ROOT/README.md"
|
|
11
|
+
"$ROOT/docs"
|
|
12
|
+
"$ROOT/public"
|
|
13
|
+
"$ROOT/.github/ISSUE_REPLY_TEMPLATE.md"
|
|
14
|
+
"$ROOT/.github/RELEASE_NOTES_TEMPLATE.md"
|
|
15
|
+
"$ROOT/docs/COMMUNICATION-STYLE.md"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
echo "Scanning public-facing text for disallowed wording..."
|
|
19
|
+
|
|
20
|
+
if rg -Nni "$DISALLOWED" "${SCAN_PATHS[@]}"; then
|
|
21
|
+
echo "Disallowed public wording found. Use neutral reliability/compatibility language."
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
echo "Public wording check passed."
|