@serverkgg/bridge 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bot/botCauses.test.ts +81 -0
- package/src/bot/botCauses.ts +5 -4
- package/src/bot/detect/botInspectWarning.ts +1 -0
- package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +3 -2
- package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +3 -1
- package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +3 -1
- package/src/bot/detect/corpus/serverk-discord-node.json +3 -2
- package/src/bot/detect/corpus/serverk-discord-python.json +3 -2
- package/src/bot/detect/corpus/serverk-telegram-node.json +3 -2
- package/src/bot/detect/corpus/serverk-telegram-python.json +3 -2
- package/src/bot/detect/detect.test.ts +285 -0
- package/src/bot/detect/detect.ts +39 -14
- package/src/bot/detect/detectTable.ts +4 -0
- package/src/bot/detect/detectedVersion.test.ts +160 -0
- package/src/bot/detect/detectedVersion.ts +55 -0
- package/src/bot/detect/envExample.test.ts +4 -0
- package/src/bot/detect/envExample.ts +1 -0
- package/src/bot/detect/envScan.test.ts +110 -0
- package/src/bot/detect/envScan.ts +7 -3
- package/src/protocol/bridgeCrash.ts +5 -1
- package/src/protocol/bridgeEvents.ts +1 -0
- package/src/runtime/bridgeEvents.test.ts +52 -1
package/package.json
CHANGED
|
@@ -25,6 +25,26 @@ const CASES: readonly {
|
|
|
25
25
|
line: "GrammyError: Call to 'getMe' failed! (401: Unauthorized)",
|
|
26
26
|
cause: BridgeCrashCause.TokenInvalid,
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
line: "TelegramError: 401: Unauthorized",
|
|
30
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
line: 'error: [polling_error] {"code":"ETELEGRAM","message":"ETELEGRAM: 401 Unauthorized"}',
|
|
34
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
line: "TelegramApiError: 401: Unauthorized",
|
|
38
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
line: "DiscordAPIError[0]: 401: Unauthorized",
|
|
42
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
line: "httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.telegram.org/bot***/getMe'",
|
|
46
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
47
|
+
},
|
|
28
48
|
{
|
|
29
49
|
line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
|
|
30
50
|
cause: BridgeCrashCause.TokenInvalid,
|
|
@@ -101,6 +121,18 @@ const CASES: readonly {
|
|
|
101
121
|
line: "discord.errors.PrivilegedIntentsRequired: Shard ID None is requesting privileged intents that have not been explicitly enabled in the developer portal.",
|
|
102
122
|
cause: BridgeCrashCause.IntentsDisallowed,
|
|
103
123
|
},
|
|
124
|
+
{
|
|
125
|
+
line: "Error: Disallowed intents specified",
|
|
126
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
line: "GatewayError: Disallowed intents specified. Make sure any privileged intents you're trying to access have been enabled in the developer portal.",
|
|
130
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
line: "hikari.errors.GatewayServerClosedConnectionError: Server closed connection with code 4014 (Disallowed intent(s))",
|
|
134
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
135
|
+
},
|
|
104
136
|
{
|
|
105
137
|
line: "telegram.error.Conflict: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
|
|
106
138
|
cause: BridgeCrashCause.TokenInUse,
|
|
@@ -109,6 +141,22 @@ const CASES: readonly {
|
|
|
109
141
|
line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
|
|
110
142
|
cause: BridgeCrashCause.TokenInUse,
|
|
111
143
|
},
|
|
144
|
+
{
|
|
145
|
+
line: "TelegramError: 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
|
|
146
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
line: 'error: [polling_error] {"code":"ETELEGRAM","message":"ETELEGRAM: 409 Conflict: terminated by other getUpdates request; make sure that only one bot instance is running"}',
|
|
150
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
line: "TelegramApiError: 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
|
|
154
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
line: "TelegramError: 409: Conflict: terminated by setWebhook request",
|
|
158
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
159
|
+
},
|
|
112
160
|
{
|
|
113
161
|
line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first)",
|
|
114
162
|
cause: BridgeCrashCause.WebhookActive,
|
|
@@ -145,6 +193,10 @@ const CASES: readonly {
|
|
|
145
193
|
line: "ERROR: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use",
|
|
146
194
|
cause: BridgeCrashCause.PortBusy,
|
|
147
195
|
},
|
|
196
|
+
{
|
|
197
|
+
line: "OSError: [Errno 48] Address already in use",
|
|
198
|
+
cause: BridgeCrashCause.PortBusy,
|
|
199
|
+
},
|
|
148
200
|
{
|
|
149
201
|
line: "Error: Cannot find module 'discord.js'",
|
|
150
202
|
cause: BridgeCrashCause.MissingDependency,
|
|
@@ -221,6 +273,14 @@ const CASES: readonly {
|
|
|
221
273
|
line: "error code: 1015",
|
|
222
274
|
cause: BridgeCrashCause.RateLimited,
|
|
223
275
|
},
|
|
276
|
+
{
|
|
277
|
+
line: "Error 1015: You are being rate limited",
|
|
278
|
+
cause: BridgeCrashCause.RateLimited,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
line: '"message": "You are being rate limited.",',
|
|
282
|
+
cause: BridgeCrashCause.RateLimited,
|
|
283
|
+
},
|
|
224
284
|
{
|
|
225
285
|
line: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
|
|
226
286
|
cause: BridgeCrashCause.OutOfMemory,
|
|
@@ -401,6 +461,27 @@ describe("BOT_CRASH_EVIDENCE", () => {
|
|
|
401
461
|
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
|
|
402
462
|
});
|
|
403
463
|
|
|
464
|
+
for (const line of [
|
|
465
|
+
"AxiosError: Request failed with status code 401",
|
|
466
|
+
"AxiosError: Request failed with status code 409",
|
|
467
|
+
"requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://api.spotify.com/v1/me",
|
|
468
|
+
"requests.exceptions.HTTPError: 409 Client Error: Conflict for url: https://api.github.com/repos/owner/repo/merges",
|
|
469
|
+
"aiohttp.client_exceptions.ClientResponseError: 401, message='Unauthorized', url='https://api.spotify.com/v1/me'",
|
|
470
|
+
"httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.spotify.com/v1/me'",
|
|
471
|
+
"httpx.HTTPStatusError: Client error '409 Conflict' for url 'https://api.github.com/repos/owner/repo/merges'",
|
|
472
|
+
"urllib.error.HTTPError: HTTP Error 401: Unauthorized",
|
|
473
|
+
"urllib.error.HTTPError: HTTP Error 409: Conflict",
|
|
474
|
+
"openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-proj-****. You can find your API key at https://platform.openai.com/account/api-keys.', 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_api_key'}}",
|
|
475
|
+
]) {
|
|
476
|
+
it(`leaves a refusal from an API other than the bot's platform unexplained from "${line}"`, () => {
|
|
477
|
+
const { ring, print } = botRing();
|
|
478
|
+
|
|
479
|
+
print(line);
|
|
480
|
+
|
|
481
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
404
485
|
it("calls exit 137 out of memory only when the record carries the flag", () => {
|
|
405
486
|
const unflagged = botRing();
|
|
406
487
|
|
package/src/bot/botCauses.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { BridgeEventName } from "../protocol";
|
|
|
4
4
|
export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
5
5
|
{
|
|
6
6
|
match:
|
|
7
|
-
/\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|\b401:? Unauthorized\b|\bInvalidToken\b|TelegramUnauthorizedError|\bACCESS_TOKEN_(?:INVALID|EXPIRED)\b/,
|
|
7
|
+
/\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|(?:Telegram|ETELEGRAM|Call to '|\/bot[^/\s]*\/|DiscordAPIError\[).*\b401:? Unauthorized\b|\b401:? Unauthorized\b.*(?:Telegram|ETELEGRAM|\/bot[^/\s]*\/)|\bInvalidToken\b|TelegramUnauthorizedError|\bACCESS_TOKEN_(?:INVALID|EXPIRED)\b/,
|
|
8
8
|
emit: BridgeEventName.TokenInvalid,
|
|
9
9
|
},
|
|
10
10
|
{
|
|
@@ -19,7 +19,7 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
match:
|
|
22
|
-
/Used disallowed intents|\[DisallowedIntents\]|Privileged intent provided is not enabled or whitelisted|PrivilegedIntentsRequired|close code:? 4014\b/,
|
|
22
|
+
/Used disallowed intents|\[DisallowedIntents\]|Privileged intent provided is not enabled or whitelisted|PrivilegedIntentsRequired|Disallowed intents specified|Server closed connection with code 4014\b|close code:? 4014\b/,
|
|
23
23
|
emit: BridgeEventName.IntentsDisallowed,
|
|
24
24
|
},
|
|
25
25
|
{
|
|
@@ -27,7 +27,8 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
|
27
27
|
emit: BridgeEventName.WebhookActive,
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
|
-
match:
|
|
30
|
+
match:
|
|
31
|
+
/terminated by other getUpdates request|(?:Telegram|ETELEGRAM|Call to '|\/bot[^/\s]*\/).*\b409:? Conflict\b|\b409:? Conflict\b.*(?:Telegram|ETELEGRAM|\/bot[^/\s]*\/)/,
|
|
31
32
|
emit: BridgeEventName.TokenInUse,
|
|
32
33
|
},
|
|
33
34
|
{
|
|
@@ -71,6 +72,6 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
|
71
72
|
{
|
|
72
73
|
match:
|
|
73
74
|
/FATAL ERROR: (?:Reached heap limit|Ineffective mark-compacts near heap limit) Allocation failed - JavaScript heap out of memory|^MemoryError\b/,
|
|
74
|
-
emit: BridgeEventName.
|
|
75
|
+
emit: BridgeEventName.OutOfMemory,
|
|
75
76
|
},
|
|
76
77
|
];
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
".python-version": "3.13\n",
|
|
28
28
|
"Procfile": "worker: bash start\n",
|
|
29
29
|
"pyproject.toml": "[project]\nname = \"anonxmusic\"\nversion = \"3.0.3\"\ndescription = \"Telegram Group Calls Streaming bot with some useful features.\"\nreadme = \".github/README.md\"\nrequires-python = \">=3.10\"\n\ndependencies = [\n \"aiohttp~=3.14.3\",\n \"kurigram>=2.2.25\",\n \"ntgcalls==3.0.0rc1\",\n \"pillow~=12.3.0\",\n \"psutil~=7.2.2\",\n \"py-tgcalls==3.0.0rc1\",\n \"py-yt-search~=0.8.0\",\n \"pymongo>=4.17.0\",\n \"pytgcrypto>=1.2.12\",\n \"python-dotenv~=1.2.3\",\n \"yt-dlp[default]>=2026.8.19\",\n]\n",
|
|
30
|
-
"requirements.txt": "aiohttp~=3.14.3\nkurigram>=2.2.25\nntgcalls==3.0.0rc1\npillow~=12.3.0\npsutil~=7.2.2\npy-tgcalls==3.0.0rc1\npy-yt-search~=0.8.0\npymongo>=4.17.0\npytgcrypto>=1.2.12\npython-dotenv~=1.2.3\nyt-dlp[default]>=2026.8.19\n"
|
|
30
|
+
"requirements.txt": "aiohttp~=3.14.3\nkurigram>=2.2.25\nntgcalls==3.0.0rc1\npillow~=12.3.0\npsutil~=7.2.2\npy-tgcalls==3.0.0rc1\npy-yt-search~=0.8.0\npymongo>=4.17.0\npytgcrypto>=1.2.12\npython-dotenv~=1.2.3\nyt-dlp[default]>=2026.8.19\n",
|
|
31
|
+
"sample.env": "# from my.telegram.org/apps\nAPI_ID=\nAPI_HASH=\n\n# from @BotFather on telegram\nBOT_TOKEN=\nLOGGER_ID=\n\n# mongo url from cloud.mongodb.com\nMONGO_URL=\n\nOWNER_ID=\n\n# pyrogram session from @StringFatherBot on telegram\nSESSION=\n"
|
|
31
32
|
},
|
|
32
33
|
"expected": {
|
|
33
34
|
"runtime": "python",
|
|
@@ -39,7 +40,7 @@
|
|
|
39
40
|
"start": "bash start",
|
|
40
41
|
"nodeVersion": null,
|
|
41
42
|
"pythonVersion": "3.13",
|
|
42
|
-
"tokenKey":
|
|
43
|
+
"tokenKey": "BOT_TOKEN",
|
|
43
44
|
"warnings": []
|
|
44
45
|
}
|
|
45
46
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": {
|
|
3
|
-
"url": "https://github.com/serverkgg/bot-
|
|
3
|
+
"url": "https://github.com/serverkgg/bot-template-discord-node",
|
|
4
4
|
"branch": "main",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "0c950227f6c2ab97b610080f2e9c9f60ff8c6069",
|
|
6
6
|
"stars": null
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
".env.example",
|
|
10
10
|
".gitignore",
|
|
11
|
+
"AGENTS.md",
|
|
11
12
|
"LICENSE",
|
|
12
13
|
"README.md",
|
|
13
14
|
"index.js",
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": {
|
|
3
|
-
"url": "https://github.com/serverkgg/bot-
|
|
3
|
+
"url": "https://github.com/serverkgg/bot-template-discord-python",
|
|
4
4
|
"branch": "main",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "e520ae1dfb07bb1e8a1eba73c5651a3ad3d39ad1",
|
|
6
6
|
"stars": null
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
".env.example",
|
|
10
10
|
".gitignore",
|
|
11
|
+
"AGENTS.md",
|
|
11
12
|
"LICENSE",
|
|
12
13
|
"README.md",
|
|
13
14
|
"main.py",
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": {
|
|
3
|
-
"url": "https://github.com/serverkgg/bot-
|
|
3
|
+
"url": "https://github.com/serverkgg/bot-template-telegram-node",
|
|
4
4
|
"branch": "main",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "49a55385c7336551dbb50b6ebc99a4e18743418f",
|
|
6
6
|
"stars": null
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
".env.example",
|
|
10
10
|
".gitignore",
|
|
11
|
+
"AGENTS.md",
|
|
11
12
|
"LICENSE",
|
|
12
13
|
"README.md",
|
|
13
14
|
"index.js",
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": {
|
|
3
|
-
"url": "https://github.com/serverkgg/bot-
|
|
3
|
+
"url": "https://github.com/serverkgg/bot-template-telegram-python",
|
|
4
4
|
"branch": "main",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "342e61c43f02f469c572f43cd3ed1e95ba85a130",
|
|
6
6
|
"stars": null
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
".env.example",
|
|
10
10
|
".gitignore",
|
|
11
|
+
"AGENTS.md",
|
|
11
12
|
"LICENSE",
|
|
12
13
|
"README.md",
|
|
13
14
|
"main.py",
|
|
@@ -51,6 +51,16 @@ const TELEGRAM_PYTHON = {
|
|
|
51
51
|
".env.example": "TELEGRAM_TOKEN=\n",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
const NPM_INSTALLED_PACKAGE = `${JSON.stringify(
|
|
55
|
+
{
|
|
56
|
+
dependencies: {
|
|
57
|
+
"discord.js": "^14.27.0",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
)}\n`;
|
|
63
|
+
|
|
54
64
|
describe("detect on the templates", () => {
|
|
55
65
|
it("reads the discord.js template end to end", () => {
|
|
56
66
|
expect(
|
|
@@ -371,6 +381,89 @@ describe("detect on the templates", () => {
|
|
|
371
381
|
expect(project(TELEGRAM_PYTHON).pythonVersion).toBeNull();
|
|
372
382
|
});
|
|
373
383
|
|
|
384
|
+
const NODE_VERSION_CASES: readonly {
|
|
385
|
+
first: string;
|
|
386
|
+
second: string;
|
|
387
|
+
files: Record<string, string>;
|
|
388
|
+
version: string;
|
|
389
|
+
}[] = [
|
|
390
|
+
{
|
|
391
|
+
first: "engines.node",
|
|
392
|
+
second: ".nvmrc",
|
|
393
|
+
files: {
|
|
394
|
+
"package.json": JSON.stringify({
|
|
395
|
+
...JSON.parse(DISCORD_NODE["package.json"]),
|
|
396
|
+
engines: {
|
|
397
|
+
node: ">=18",
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
".nvmrc": "v20.11.1\n",
|
|
401
|
+
},
|
|
402
|
+
version: "18",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
first: ".nvmrc",
|
|
406
|
+
second: ".node-version",
|
|
407
|
+
files: {
|
|
408
|
+
".nvmrc": "v22.11.0\n",
|
|
409
|
+
".node-version": "20.11.1\n",
|
|
410
|
+
},
|
|
411
|
+
version: "22",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
first: ".node-version",
|
|
415
|
+
second: ".tool-versions",
|
|
416
|
+
files: {
|
|
417
|
+
".node-version": "20.11.1\n",
|
|
418
|
+
".tool-versions": "nodejs 22.3.0\n",
|
|
419
|
+
},
|
|
420
|
+
version: "20",
|
|
421
|
+
},
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
for (const item of NODE_VERSION_CASES) {
|
|
425
|
+
it(`reads the Node version from ${item.first} before ${item.second}`, () => {
|
|
426
|
+
const result = project({
|
|
427
|
+
...DISCORD_NODE,
|
|
428
|
+
...item.files,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(result.nodeVersion).toBe(item.version);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
it("reads the Python version from runtime.txt before the Pipfile's python_version", () => {
|
|
436
|
+
const result = project({
|
|
437
|
+
Pipfile:
|
|
438
|
+
'[[source]]\nurl = "https://pypi.org/simple"\nverify_ssl = true\nname = "pypi"\n\n[packages]\n"discord.py" = "*"\n\n[dev-packages]\n\n[requires]\npython_version = "3.10"\n',
|
|
439
|
+
"runtime.txt": "python-3.11.9\n",
|
|
440
|
+
"bot.py": "import discord\n",
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(result.packageManager).toBe(BotPackageManager.Pipenv);
|
|
444
|
+
expect(result.pythonVersion).toBe("3.11");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("installs with yarn when only engines.yarn names a manager and no lockfile ships", () => {
|
|
448
|
+
const result = project({
|
|
449
|
+
...DISCORD_NODE,
|
|
450
|
+
"package.json": JSON.stringify({
|
|
451
|
+
...JSON.parse(DISCORD_NODE["package.json"]),
|
|
452
|
+
engines: {
|
|
453
|
+
node: ">=18",
|
|
454
|
+
yarn: ">=1.22",
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(result.packageManager).toBe(BotPackageManager.Yarn);
|
|
460
|
+
expect(result.install).toEqual({
|
|
461
|
+
command: "corepack yarn install",
|
|
462
|
+
source: "package.json",
|
|
463
|
+
});
|
|
464
|
+
expect(result.warnings).toContain(BotInspectWarning.NoLockfile);
|
|
465
|
+
});
|
|
466
|
+
|
|
374
467
|
it("installs from the Python lockfiles, honouring uv and warning about the rest", () => {
|
|
375
468
|
const locked = (lockfile: string) => {
|
|
376
469
|
return project(
|
|
@@ -484,6 +577,107 @@ describe("detect start commands", () => {
|
|
|
484
577
|
});
|
|
485
578
|
});
|
|
486
579
|
|
|
580
|
+
const NODE_START_CASES: readonly {
|
|
581
|
+
file: string;
|
|
582
|
+
beside: string[];
|
|
583
|
+
}[] = [
|
|
584
|
+
{
|
|
585
|
+
file: "index.js",
|
|
586
|
+
beside: [
|
|
587
|
+
"src/index.js",
|
|
588
|
+
"bot.js",
|
|
589
|
+
"src/bot.js",
|
|
590
|
+
"index.ts",
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
file: "src/index.js",
|
|
595
|
+
beside: [
|
|
596
|
+
"bot.js",
|
|
597
|
+
"src/bot.js",
|
|
598
|
+
"index.ts",
|
|
599
|
+
],
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
file: "bot.js",
|
|
603
|
+
beside: [
|
|
604
|
+
"src/bot.js",
|
|
605
|
+
"index.ts",
|
|
606
|
+
],
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
file: "src/bot.js",
|
|
610
|
+
beside: [
|
|
611
|
+
"index.ts",
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
file: "index.ts",
|
|
616
|
+
beside: [],
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
for (const item of NODE_START_CASES) {
|
|
621
|
+
it(`starts ${item.file} when package.json names no start script and no main, ahead of [${item.beside.join(", ")}]`, () => {
|
|
622
|
+
const result = project(
|
|
623
|
+
{
|
|
624
|
+
"package.json": NPM_INSTALLED_PACKAGE,
|
|
625
|
+
[item.file]: "",
|
|
626
|
+
},
|
|
627
|
+
item.beside,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
expect(result.start).toEqual({
|
|
631
|
+
command: `node ${item.file}`,
|
|
632
|
+
source: item.file,
|
|
633
|
+
});
|
|
634
|
+
expect(result.warnings).toEqual([
|
|
635
|
+
BotInspectWarning.NoLockfile,
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const PYTHON_START_CASES: readonly {
|
|
641
|
+
file: string;
|
|
642
|
+
beside: string[];
|
|
643
|
+
}[] = [
|
|
644
|
+
{
|
|
645
|
+
file: "app.py",
|
|
646
|
+
beside: [
|
|
647
|
+
"src/main.py",
|
|
648
|
+
"src/bot.py",
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
file: "src/main.py",
|
|
653
|
+
beside: [
|
|
654
|
+
"src/bot.py",
|
|
655
|
+
],
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
file: "src/bot.py",
|
|
659
|
+
beside: [],
|
|
660
|
+
},
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
for (const item of PYTHON_START_CASES) {
|
|
664
|
+
it(`starts ${item.file} when no main.py or bot.py exists, ahead of [${item.beside.join(", ")}]`, () => {
|
|
665
|
+
const result = project(
|
|
666
|
+
{
|
|
667
|
+
"requirements.txt": "python-telegram-bot==21.11.1\n",
|
|
668
|
+
[item.file]: "",
|
|
669
|
+
},
|
|
670
|
+
item.beside,
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
expect(result.start).toEqual({
|
|
674
|
+
command: `python ${item.file}`,
|
|
675
|
+
source: item.file,
|
|
676
|
+
});
|
|
677
|
+
expect(result.warnings).toEqual([]);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
487
681
|
it("starts the one Python package that has a __main__ module when no start file exists", () => {
|
|
488
682
|
expect(
|
|
489
683
|
project(
|
|
@@ -700,6 +894,68 @@ describe("detect warnings", () => {
|
|
|
700
894
|
]);
|
|
701
895
|
expect(loud.warnings).toContain(BotInspectWarning.ReservedEnvKey);
|
|
702
896
|
});
|
|
897
|
+
|
|
898
|
+
it("warns that a node bot reading PORT needs the web address, and keeps PORT among the variables", () => {
|
|
899
|
+
const result = project({
|
|
900
|
+
...DISCORD_NODE,
|
|
901
|
+
"index.js": "const token = process.env.DISCORD_TOKEN;\napp.listen(process.env.PORT);\n",
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
expect(result.env.map((entry) => entry.key)).toEqual([
|
|
905
|
+
"DISCORD_TOKEN",
|
|
906
|
+
"PORT",
|
|
907
|
+
]);
|
|
908
|
+
expect(result.warnings).toContain(BotInspectWarning.WebPort);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("warns that a python bot whose example file lists a webhook needs the web address", () => {
|
|
912
|
+
const result = project({
|
|
913
|
+
...TELEGRAM_PYTHON,
|
|
914
|
+
".env.example": "TELEGRAM_TOKEN=\nWEBHOOK_URL=\n",
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
expect(result.env.map((entry) => entry.key)).toEqual([
|
|
918
|
+
"TELEGRAM_TOKEN",
|
|
919
|
+
"WEBHOOK_URL",
|
|
920
|
+
]);
|
|
921
|
+
expect(result.warnings).toContain(BotInspectWarning.WebPort);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("reads a webhook key on a discord bot as an outgoing log webhook, and on a telegram bot as one it receives", () => {
|
|
925
|
+
const discord = project({
|
|
926
|
+
...DISCORD_NODE,
|
|
927
|
+
".env.example": "DISCORD_TOKEN=\nLOG_WEBHOOK_URL=\n",
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
expect(discord.platform).toBe(BotPlatform.Discord);
|
|
931
|
+
expect(discord.env.map((entry) => entry.key)).toEqual([
|
|
932
|
+
"DISCORD_TOKEN",
|
|
933
|
+
"LOG_WEBHOOK_URL",
|
|
934
|
+
]);
|
|
935
|
+
expect(discord.warnings).not.toContain(BotInspectWarning.WebPort);
|
|
936
|
+
|
|
937
|
+
const telegram = project({
|
|
938
|
+
...TELEGRAM_PYTHON,
|
|
939
|
+
".env.example": "TELEGRAM_TOKEN=\nLOG_WEBHOOK_URL=\n",
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
expect(telegram.platform).toBe(BotPlatform.Telegram);
|
|
943
|
+
expect(telegram.warnings).toContain(BotInspectWarning.WebPort);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("keeps quiet about the web address for a bot that reads neither PORT nor a webhook", () => {
|
|
947
|
+
const result = project({
|
|
948
|
+
...DISCORD_NODE,
|
|
949
|
+
"index.js": "const token = process.env.DISCORD_TOKEN;\nconst port = process.env.SERVER_PORT;\n",
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
expect(result.env.map((entry) => entry.key)).toEqual([
|
|
953
|
+
"DISCORD_TOKEN",
|
|
954
|
+
"SERVER_PORT",
|
|
955
|
+
]);
|
|
956
|
+
expect(result.warnings).not.toContain(BotInspectWarning.WebPort);
|
|
957
|
+
expect(project(TELEGRAM_PYTHON).warnings).not.toContain(BotInspectWarning.WebPort);
|
|
958
|
+
});
|
|
703
959
|
});
|
|
704
960
|
|
|
705
961
|
describe("detect with serverk.json", () => {
|
|
@@ -733,6 +989,32 @@ describe("detect with serverk.json", () => {
|
|
|
733
989
|
expect(result.warnings).toContain(BotInspectWarning.ManifestDisagrees);
|
|
734
990
|
});
|
|
735
991
|
|
|
992
|
+
it("says when the file names a runtime the code is not written in", () => {
|
|
993
|
+
const disagrees = project({
|
|
994
|
+
...TELEGRAM_PYTHON,
|
|
995
|
+
"serverk.json": JSON.stringify({
|
|
996
|
+
runtime: "node",
|
|
997
|
+
}),
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
expect(disagrees.runtime).toBe(BotRuntime.Node);
|
|
1001
|
+
expect(disagrees.warnings).toEqual([
|
|
1002
|
+
BotInspectWarning.NoStart,
|
|
1003
|
+
BotInspectWarning.NoManifest,
|
|
1004
|
+
BotInspectWarning.ManifestDisagrees,
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
const agrees = project({
|
|
1008
|
+
...TELEGRAM_PYTHON,
|
|
1009
|
+
"serverk.json": JSON.stringify({
|
|
1010
|
+
runtime: "python",
|
|
1011
|
+
}),
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
expect(agrees.runtime).toBe(BotRuntime.Python);
|
|
1015
|
+
expect(agrees.warnings).toEqual([]);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
736
1018
|
it("installs with the file's own install command over the lockfile's", () => {
|
|
737
1019
|
const result = project({
|
|
738
1020
|
...DISCORD_NODE,
|
|
@@ -847,6 +1129,9 @@ describe("detect env suggestions", () => {
|
|
|
847
1129
|
{
|
|
848
1130
|
file: ".env.dist",
|
|
849
1131
|
},
|
|
1132
|
+
{
|
|
1133
|
+
file: "sample.env",
|
|
1134
|
+
},
|
|
850
1135
|
];
|
|
851
1136
|
|
|
852
1137
|
for (const item of CASES) {
|
package/src/bot/detect/detect.ts
CHANGED
|
@@ -2,15 +2,16 @@ import type { BotFramework } from "./botFramework";
|
|
|
2
2
|
import { BOT_FRAMEWORKS, type BotFrameworkSpec, botFrameworkSpec } from "./botFrameworks";
|
|
3
3
|
import { BotInspectWarning } from "./botInspectWarning";
|
|
4
4
|
import { BotPackageManager } from "./botPackageManager";
|
|
5
|
-
import
|
|
5
|
+
import { BotPlatform } from "./botPlatform";
|
|
6
6
|
import { BOT_RUNTIME_PACKAGES, BotRuntime } from "./botRuntime";
|
|
7
7
|
import { BOT_TOKEN_KEYS, platformOfKey } from "./botToken";
|
|
8
|
+
import { nodeLtsMajorOf } from "./detectedVersion";
|
|
8
9
|
import type { DetectEntry, DetectedCommand, DetectedEnvKey, DetectInput, DetectResult } from "./detectResult";
|
|
9
10
|
import { DETECT_TABLE, type DetectVersionFile } from "./detectTable";
|
|
10
11
|
import { ENV_EXAMPLE_FILES, ENV_FILE, isSecretEnvFile, parseEnvExample } from "./envExample";
|
|
11
12
|
import { isEnvKey } from "./envKey";
|
|
12
13
|
import { isNodeSource, isPythonSource, scanEnvKeys } from "./envScan";
|
|
13
|
-
import { BOT_PROTECTED_KEYS, BOT_PROVIDED_KEYS, isReservedBotKey } from "./keys";
|
|
14
|
+
import { BOT_PROTECTED_KEYS, BOT_PROVIDED_KEYS, isReservedBotKey, PORT } from "./keys";
|
|
14
15
|
import { isSensitiveKey } from "./sensitiveKey";
|
|
15
16
|
import { parseServerkJson, SERVERK_JSON, type ServerkJson } from "./serverkJson";
|
|
16
17
|
|
|
@@ -79,6 +80,8 @@ const TOKEN_KEY = /TOKEN/i;
|
|
|
79
80
|
|
|
80
81
|
const BOT_KEY = /BOT/i;
|
|
81
82
|
|
|
83
|
+
const WEBHOOK_KEY = /WEBHOOK/i;
|
|
84
|
+
|
|
82
85
|
const NODE_IMPORT = /(?:\brequire\s*\(\s*|\bfrom\s+|\bimport\s*\(\s*|\bimport\s+)(["'])([^"'\n]+)\1/g;
|
|
83
86
|
|
|
84
87
|
const PYTHON_IMPORT = /^[ \t]*(?:from[ \t]+([A-Za-z_]\w*)|import[ \t]+([A-Za-z_]\w*))/gm;
|
|
@@ -192,14 +195,16 @@ const START_FILES: readonly string[] = [
|
|
|
192
195
|
];
|
|
193
196
|
|
|
194
197
|
const FIXED_FILES: readonly string[] = [
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
...new Set([
|
|
199
|
+
DETECT_TABLE.node.manifest,
|
|
200
|
+
REQUIREMENTS_FILE,
|
|
201
|
+
PYPROJECT_FILE,
|
|
202
|
+
DETECT_TABLE.procfile.file,
|
|
203
|
+
SERVERK_JSON,
|
|
204
|
+
...ENV_EXAMPLE_FILES,
|
|
205
|
+
...DETECT_TABLE.node.versionFiles.map((entry) => entry.file),
|
|
206
|
+
...DETECT_TABLE.python.versionFiles.map((entry) => entry.file),
|
|
207
|
+
]),
|
|
203
208
|
];
|
|
204
209
|
|
|
205
210
|
const sourceRank = (path: string) => {
|
|
@@ -587,7 +592,11 @@ const nodeBuildOf = (packageJson: PackageJson | null, manager: string): Detected
|
|
|
587
592
|
};
|
|
588
593
|
};
|
|
589
594
|
|
|
590
|
-
const versionFromFile = (
|
|
595
|
+
const versionFromFile = (
|
|
596
|
+
text: string,
|
|
597
|
+
spec: DetectVersionFile,
|
|
598
|
+
aliasOf: (value: string) => string | null,
|
|
599
|
+
): string | null => {
|
|
591
600
|
for (const raw of text.split("\n")) {
|
|
592
601
|
let line = raw.trim();
|
|
593
602
|
|
|
@@ -623,7 +632,7 @@ const versionFromFile = (text: string, spec: DetectVersionFile): string | null =
|
|
|
623
632
|
line = line.slice(spec.prefix.length);
|
|
624
633
|
}
|
|
625
634
|
|
|
626
|
-
return VERSION_NUMBER.exec(line)?.[1] ??
|
|
635
|
+
return VERSION_NUMBER.exec(line)?.[1] ?? aliasOf(line);
|
|
627
636
|
}
|
|
628
637
|
|
|
629
638
|
return null;
|
|
@@ -633,6 +642,7 @@ const versionFromFiles = (
|
|
|
633
642
|
specs: readonly DetectVersionFile[],
|
|
634
643
|
read: (path: string) => string | null,
|
|
635
644
|
parts: number,
|
|
645
|
+
aliasOf: (value: string) => string | null = () => null,
|
|
636
646
|
): string | null => {
|
|
637
647
|
for (const spec of specs) {
|
|
638
648
|
const text = read(spec.file);
|
|
@@ -641,7 +651,7 @@ const versionFromFiles = (
|
|
|
641
651
|
continue;
|
|
642
652
|
}
|
|
643
653
|
|
|
644
|
-
const version = versionFromFile(text, spec);
|
|
654
|
+
const version = versionFromFile(text, spec, aliasOf);
|
|
645
655
|
|
|
646
656
|
if (version !== null) {
|
|
647
657
|
return version.split(".").slice(0, parts).join(".");
|
|
@@ -731,7 +741,7 @@ const nodeRequirementOf = (
|
|
|
731
741
|
}
|
|
732
742
|
}
|
|
733
743
|
|
|
734
|
-
const version = versionFromFiles(DETECT_TABLE.node.versionFiles, read, 1);
|
|
744
|
+
const version = versionFromFiles(DETECT_TABLE.node.versionFiles, read, 1, nodeLtsMajorOf);
|
|
735
745
|
|
|
736
746
|
return version === null
|
|
737
747
|
? null
|
|
@@ -845,6 +855,19 @@ const committedWarnings = (paths: ReadonlySet<string>, warnings: Set<BotInspectW
|
|
|
845
855
|
}
|
|
846
856
|
};
|
|
847
857
|
|
|
858
|
+
const webPortWarning = (
|
|
859
|
+
platform: BotPlatform | null,
|
|
860
|
+
env: readonly DetectedEnvKey[],
|
|
861
|
+
warnings: Set<BotInspectWarning>,
|
|
862
|
+
) => {
|
|
863
|
+
const keys = env.map((entry) => entry.key);
|
|
864
|
+
const webhook = platform !== BotPlatform.Discord && keys.some((key) => WEBHOOK_KEY.test(key));
|
|
865
|
+
|
|
866
|
+
if (keys.includes(PORT) || webhook) {
|
|
867
|
+
warnings.add(BotInspectWarning.WebPort);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
848
871
|
interface EnvCollector {
|
|
849
872
|
add(key: string, source: string, sensitive: boolean | null, example: string | null): void;
|
|
850
873
|
entries: DetectedEnvKey[];
|
|
@@ -1165,6 +1188,8 @@ export const detect = (input: DetectInput): DetectResult => {
|
|
|
1165
1188
|
);
|
|
1166
1189
|
const platform = platformOf(serverk, framework, candidates, keyPlatforms, warnings);
|
|
1167
1190
|
|
|
1191
|
+
webPortWarning(platform, env.entries, warnings);
|
|
1192
|
+
|
|
1168
1193
|
return {
|
|
1169
1194
|
runtime,
|
|
1170
1195
|
package: runtime ? BOT_RUNTIME_PACKAGES[runtime] : null,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { BotInspectWarning } from "./botInspectWarning";
|
|
3
|
+
import { BotRuntime } from "./botRuntime";
|
|
4
|
+
import { detect } from "./detect";
|
|
5
|
+
import { nodeLtsMajorOf } from "./detectedVersion";
|
|
6
|
+
import { DETECT_TABLE } from "./detectTable";
|
|
7
|
+
|
|
8
|
+
const DISCORD_NODE = {
|
|
9
|
+
"package.json": JSON.stringify({
|
|
10
|
+
name: "discord-bot",
|
|
11
|
+
main: "index.js",
|
|
12
|
+
dependencies: {
|
|
13
|
+
"discord.js": "^14.27.0",
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
"index.js": 'import { Client } from "discord.js";\nawait client.login(process.env.DISCORD_TOKEN);\n',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const resultOf = (files: Record<string, string>) => {
|
|
20
|
+
const contents = {
|
|
21
|
+
...DISCORD_NODE,
|
|
22
|
+
...files,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return detect({
|
|
26
|
+
files: Object.keys(contents).map((path) => ({
|
|
27
|
+
path,
|
|
28
|
+
})),
|
|
29
|
+
contents,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const LTS_CASES: readonly {
|
|
34
|
+
alias: string;
|
|
35
|
+
major: string | null;
|
|
36
|
+
}[] = [
|
|
37
|
+
{
|
|
38
|
+
alias: "lts/iron",
|
|
39
|
+
major: "20",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
alias: "LTS/Hydrogen",
|
|
43
|
+
major: "18",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
alias: "lts/jod",
|
|
47
|
+
major: "22",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
alias: "lts-krypton",
|
|
51
|
+
major: "24",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
alias: " lts/argon ",
|
|
55
|
+
major: "4",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
alias: "lts/*",
|
|
59
|
+
major: null,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
alias: "lts/-1",
|
|
63
|
+
major: null,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
alias: "lts",
|
|
67
|
+
major: null,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
alias: "lts/unknown",
|
|
71
|
+
major: null,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
alias: "lts/constructor",
|
|
75
|
+
major: null,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
alias: "node",
|
|
79
|
+
major: null,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
describe("nodeLtsMajorOf", () => {
|
|
84
|
+
for (const item of LTS_CASES) {
|
|
85
|
+
it(`reads ${item.alias} as ${item.major ?? "no fixed major"}`, () => {
|
|
86
|
+
expect(nodeLtsMajorOf(item.alias)).toBe(item.major);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("detect", () => {
|
|
92
|
+
it("reads the Node major an .nvmrc names by its LTS codename", () => {
|
|
93
|
+
expect(
|
|
94
|
+
resultOf({
|
|
95
|
+
".nvmrc": "lts/iron\n",
|
|
96
|
+
}).nodeVersion,
|
|
97
|
+
).toBe("20");
|
|
98
|
+
expect(
|
|
99
|
+
resultOf({
|
|
100
|
+
".nvmrc": "LTS/Hydrogen\n",
|
|
101
|
+
}).nodeVersion,
|
|
102
|
+
).toBe("18");
|
|
103
|
+
expect(
|
|
104
|
+
resultOf({
|
|
105
|
+
".nvmrc": "lts/*\n",
|
|
106
|
+
}).nodeVersion,
|
|
107
|
+
).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("warns when an LTS codename names a major the image does not run", () => {
|
|
111
|
+
const result = resultOf({
|
|
112
|
+
".nvmrc": "lts/hydrogen\n",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(DETECT_TABLE.node.defaultVersion).not.toBe("18");
|
|
116
|
+
expect(result.warnings).toContain(BotInspectWarning.NodeVersionUnmet);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("reads the Node version from .tool-versions under mise's node and asdf's nodejs", () => {
|
|
120
|
+
expect(
|
|
121
|
+
resultOf({
|
|
122
|
+
".tool-versions": "node 20.11.0\n",
|
|
123
|
+
}).nodeVersion,
|
|
124
|
+
).toBe("20");
|
|
125
|
+
expect(
|
|
126
|
+
resultOf({
|
|
127
|
+
".tool-versions": "nodejs 20.11.0\n",
|
|
128
|
+
}).nodeVersion,
|
|
129
|
+
).toBe("20");
|
|
130
|
+
expect(
|
|
131
|
+
resultOf({
|
|
132
|
+
".tool-versions": "python 3.12.1\nnode lts/jod\n",
|
|
133
|
+
}).nodeVersion,
|
|
134
|
+
).toBe("22");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("leaves the Python version untouched by a Node LTS codename", () => {
|
|
138
|
+
const result = detect({
|
|
139
|
+
files: [
|
|
140
|
+
{
|
|
141
|
+
path: "requirements.txt",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
path: "main.py",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
path: ".python-version",
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
contents: {
|
|
151
|
+
"requirements.txt": "python-telegram-bot==21.11.1\n",
|
|
152
|
+
"main.py": "from telegram.ext import Application\n",
|
|
153
|
+
".python-version": "lts/iron\n",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.runtime).toBe(BotRuntime.Python);
|
|
158
|
+
expect(result.pythonVersion).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
export const DETECTED_VERSION_PATTERN = /^\d+(?:\.\d+)?$/;
|
|
2
2
|
|
|
3
|
+
const NODE_LTS_MAJORS: ReadonlyMap<string, string> = new Map([
|
|
4
|
+
[
|
|
5
|
+
"argon",
|
|
6
|
+
"4",
|
|
7
|
+
],
|
|
8
|
+
[
|
|
9
|
+
"boron",
|
|
10
|
+
"6",
|
|
11
|
+
],
|
|
12
|
+
[
|
|
13
|
+
"carbon",
|
|
14
|
+
"8",
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
"dubnium",
|
|
18
|
+
"10",
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
"erbium",
|
|
22
|
+
"12",
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
"fermium",
|
|
26
|
+
"14",
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"gallium",
|
|
30
|
+
"16",
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
"hydrogen",
|
|
34
|
+
"18",
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
"iron",
|
|
38
|
+
"20",
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"jod",
|
|
42
|
+
"22",
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"krypton",
|
|
46
|
+
"24",
|
|
47
|
+
],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const NODE_LTS_ALIAS = /^lts[/-]([a-z]+)$/i;
|
|
51
|
+
|
|
3
52
|
export const isDetectedVersion = (value: string | null): value is string => {
|
|
4
53
|
return value !== null && DETECTED_VERSION_PATTERN.test(value);
|
|
5
54
|
};
|
|
55
|
+
|
|
56
|
+
export const nodeLtsMajorOf = (alias: string): string | null => {
|
|
57
|
+
const codename = NODE_LTS_ALIAS.exec(alias.trim())?.[1];
|
|
58
|
+
|
|
59
|
+
return codename === undefined ? null : (NODE_LTS_MAJORS.get(codename.toLowerCase()) ?? null);
|
|
60
|
+
};
|
|
@@ -33,6 +33,116 @@ describe("scanEnvKeys", () => {
|
|
|
33
33
|
]);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it("finds a Bun.env property read", () => {
|
|
37
|
+
expect(scanEnvKeys("src/index.ts", "const token = Bun.env.DISCORD_TOKEN;")).toEqual([
|
|
38
|
+
"DISCORD_TOKEN",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("finds a Bun.env bracket read", () => {
|
|
43
|
+
expect(scanEnvKeys("src/index.ts", 'const guildId = Bun.env["GUILD_ID"] ?? "";')).toEqual([
|
|
44
|
+
"GUILD_ID",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("finds names destructured from Bun.env", () => {
|
|
49
|
+
expect(scanEnvKeys("src/config.ts", "const { DISCORD_TOKEN, CLIENT_ID } = Bun.env;")).toEqual([
|
|
50
|
+
"DISCORD_TOKEN",
|
|
51
|
+
"CLIENT_ID",
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("finds environs reads, bare and typed", () => {
|
|
56
|
+
expect(
|
|
57
|
+
scanEnvKeys(
|
|
58
|
+
"tgbot/config.py",
|
|
59
|
+
[
|
|
60
|
+
"from environs import Env",
|
|
61
|
+
"",
|
|
62
|
+
"env = Env()",
|
|
63
|
+
"env.read_env(path)",
|
|
64
|
+
'BOT_TOKEN = env.str("BOT_TOKEN")',
|
|
65
|
+
'ADMIN_ID = env.int("ADMIN_ID")',
|
|
66
|
+
'admin_ids = env.list("ADMINS", subcast=int)',
|
|
67
|
+
'use_redis = env.bool("USE_REDIS")',
|
|
68
|
+
'rate = env.float("RATE_LIMIT", 1.5)',
|
|
69
|
+
'webhook = env.url("WEBHOOK_URL")',
|
|
70
|
+
'extra = env.json("EXTRA_OPTIONS")',
|
|
71
|
+
"SECRET_KEY = env('SECRET_KEY')",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
),
|
|
74
|
+
).toEqual([
|
|
75
|
+
"BOT_TOKEN",
|
|
76
|
+
"ADMIN_ID",
|
|
77
|
+
"ADMINS",
|
|
78
|
+
"USE_REDIS",
|
|
79
|
+
"RATE_LIMIT",
|
|
80
|
+
"WEBHOOK_URL",
|
|
81
|
+
"EXTRA_OPTIONS",
|
|
82
|
+
"SECRET_KEY",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("finds python-decouple reads", () => {
|
|
87
|
+
expect(
|
|
88
|
+
scanEnvKeys(
|
|
89
|
+
"bot/settings.py",
|
|
90
|
+
[
|
|
91
|
+
"import decouple",
|
|
92
|
+
"from decouple import config",
|
|
93
|
+
"",
|
|
94
|
+
'TOKEN = config("TOKEN")',
|
|
95
|
+
'ADMIN_ID = config("ADMIN_ID", cast=int)',
|
|
96
|
+
'DEBUG = decouple.config("DEBUG", default=False, cast=bool)',
|
|
97
|
+
].join("\n"),
|
|
98
|
+
),
|
|
99
|
+
).toEqual([
|
|
100
|
+
"TOKEN",
|
|
101
|
+
"ADMIN_ID",
|
|
102
|
+
"DEBUG",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("orders names by where the file reads them across os and environs", () => {
|
|
107
|
+
expect(
|
|
108
|
+
scanEnvKeys(
|
|
109
|
+
"bot/config.py",
|
|
110
|
+
[
|
|
111
|
+
"import os",
|
|
112
|
+
"from environs import Env",
|
|
113
|
+
"",
|
|
114
|
+
"env = Env()",
|
|
115
|
+
'BOT_TOKEN = env.str("BOT_TOKEN")',
|
|
116
|
+
'REDIS_URL = os.getenv("REDIS_URL")',
|
|
117
|
+
'ADMIN_ID = env.int("ADMIN_ID")',
|
|
118
|
+
].join("\n"),
|
|
119
|
+
),
|
|
120
|
+
).toEqual([
|
|
121
|
+
"BOT_TOKEN",
|
|
122
|
+
"REDIS_URL",
|
|
123
|
+
"ADMIN_ID",
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reads no key from calls that only look like environs or decouple", () => {
|
|
128
|
+
expect(
|
|
129
|
+
scanEnvKeys(
|
|
130
|
+
"bot/main.py",
|
|
131
|
+
[
|
|
132
|
+
"env = {}",
|
|
133
|
+
'env["x"] = 1',
|
|
134
|
+
'value = env["x"]',
|
|
135
|
+
'logger.config("level")',
|
|
136
|
+
'logger.config("LEVEL")',
|
|
137
|
+
'config("level")',
|
|
138
|
+
'self.env.str("NOPE")',
|
|
139
|
+
'client.getenv("NOPE")',
|
|
140
|
+
'cfg.environ.get("NOPE")',
|
|
141
|
+
].join("\n"),
|
|
142
|
+
),
|
|
143
|
+
).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
36
146
|
it("reads nothing from files that are not code", () => {
|
|
37
147
|
expect(scanEnvKeys("README.md", "process.env.A")).toEqual([]);
|
|
38
148
|
});
|
|
@@ -13,12 +13,14 @@ export const PYTHON_SOURCE_EXTENSIONS: readonly string[] = [
|
|
|
13
13
|
|
|
14
14
|
const NAME = "([A-Za-z_][A-Za-z0-9_]*)";
|
|
15
15
|
|
|
16
|
+
const UPPER_NAME = "([A-Z_][A-Z0-9_]*)";
|
|
17
|
+
|
|
16
18
|
const NODE_PATTERNS: readonly RegExp[] = [
|
|
17
|
-
new RegExp(`\\
|
|
18
|
-
new RegExp(`\\
|
|
19
|
+
new RegExp(`\\b(?:process|Bun)\\.env\\.${NAME}`, "g"),
|
|
20
|
+
new RegExp(`\\b(?:process|Bun)\\.env\\[\\s*["'\`]${NAME}["'\`]\\s*\\]`, "g"),
|
|
19
21
|
];
|
|
20
22
|
|
|
21
|
-
const NODE_DESTRUCTURE = /\{([^{}]{1,2000})\}\s*=\s*process\.env\b/g;
|
|
23
|
+
const NODE_DESTRUCTURE = /\{([^{}]{1,2000})\}\s*=\s*(?:process|Bun)\.env\b/g;
|
|
22
24
|
|
|
23
25
|
const DESTRUCTURED_NAME = /^\s*([A-Za-z_][A-Za-z0-9_]*)/;
|
|
24
26
|
|
|
@@ -26,6 +28,8 @@ const PYTHON_PATTERNS: readonly RegExp[] = [
|
|
|
26
28
|
new RegExp(`(?<![\\w.])(?:os\\.)?environ\\[\\s*["']${NAME}["']\\s*\\]`, "g"),
|
|
27
29
|
new RegExp(`(?<![\\w.])(?:os\\.)?environ\\.get\\(\\s*["']${NAME}["']`, "g"),
|
|
28
30
|
new RegExp(`(?<![\\w.])(?:os\\.)?getenv\\(\\s*["']${NAME}["']`, "g"),
|
|
31
|
+
new RegExp(`(?<![\\w.])env(?:\\.\\w+)?\\(\\s*["']${UPPER_NAME}["']`, "g"),
|
|
32
|
+
new RegExp(`(?<![\\w.])(?:decouple\\.)?config\\(\\s*["']${UPPER_NAME}["']`, "g"),
|
|
29
33
|
];
|
|
30
34
|
|
|
31
35
|
interface Found {
|
|
@@ -34,7 +34,7 @@ export interface BridgeCrashEvidence {
|
|
|
34
34
|
|
|
35
35
|
export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
36
36
|
{
|
|
37
|
-
name: BridgeEventName.
|
|
37
|
+
name: BridgeEventName.OutOfMemory,
|
|
38
38
|
cause: BridgeCrashCause.OutOfMemory,
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -101,6 +101,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
|
101
101
|
name: BridgeEventName.RateLimited,
|
|
102
102
|
cause: BridgeCrashCause.RateLimited,
|
|
103
103
|
},
|
|
104
|
+
{
|
|
105
|
+
name: BridgeEventName.ServerCrashed,
|
|
106
|
+
cause: BridgeCrashCause.Unknown,
|
|
107
|
+
},
|
|
104
108
|
];
|
|
105
109
|
|
|
106
110
|
const BRIDGE_CRASH_EVIDENCE_NAMES: ReadonlySet<string> = new Set(BRIDGE_CRASH_EVIDENCE.map((entry) => entry.name));
|
|
@@ -23,6 +23,7 @@ export enum BridgeEventName {
|
|
|
23
23
|
ModpackInstalled = "ModpackInstalled",
|
|
24
24
|
ModpackRemoved = "ModpackRemoved",
|
|
25
25
|
ModpackSelected = "ModpackSelected",
|
|
26
|
+
OutOfMemory = "OutOfMemory",
|
|
26
27
|
PlayerAdvanced = "PlayerAdvanced",
|
|
27
28
|
PlayerBanned = "PlayerBanned",
|
|
28
29
|
PlayerChanged = "PlayerChanged",
|
|
@@ -291,7 +291,7 @@ describe("the crash record the ring builds", () => {
|
|
|
291
291
|
detail: "class file version 65",
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
-
ring.emit(BridgeEventName.
|
|
294
|
+
ring.emit(BridgeEventName.OutOfMemory, {
|
|
295
295
|
error: "java.lang.OutOfMemoryError",
|
|
296
296
|
});
|
|
297
297
|
|
|
@@ -303,6 +303,57 @@ describe("the crash record the ring builds", () => {
|
|
|
303
303
|
});
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
+
it("ends a lone fatal crash line as unknown, in one record that carries the last line", () => {
|
|
307
|
+
const ring = idleRing();
|
|
308
|
+
|
|
309
|
+
ring.emit(BridgeEventName.ServerCrashed);
|
|
310
|
+
|
|
311
|
+
ring.crash(
|
|
312
|
+
crashRecord({
|
|
313
|
+
exitCode: 139,
|
|
314
|
+
lastLine: "Segmentation fault (core dumped)",
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(ring.drain(0).events).toEqual([
|
|
319
|
+
{
|
|
320
|
+
seq: 1,
|
|
321
|
+
event: BridgeEventName.ServerCrashed,
|
|
322
|
+
payload: {
|
|
323
|
+
cause: "unknown",
|
|
324
|
+
phase: "running",
|
|
325
|
+
exitCode: "139",
|
|
326
|
+
restarting: "no",
|
|
327
|
+
looping: "no",
|
|
328
|
+
restarts: "0",
|
|
329
|
+
uptimeSeconds: "12",
|
|
330
|
+
lastLine: "Segmentation fault (core dumped)",
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("names a corrupt world over the fatal crash line of the same run", () => {
|
|
337
|
+
const ring = idleRing();
|
|
338
|
+
|
|
339
|
+
ring.emit(BridgeEventName.WorldCorrupt);
|
|
340
|
+
ring.emit(BridgeEventName.ServerCrashed);
|
|
341
|
+
|
|
342
|
+
ring.crash(
|
|
343
|
+
crashRecord({
|
|
344
|
+
lastLine: "Aborted (core dumped)",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const crashed = ring.drain(0).events;
|
|
349
|
+
|
|
350
|
+
expect(crashed).toHaveLength(1);
|
|
351
|
+
expect(crashed.at(0)?.payload).toMatchObject({
|
|
352
|
+
cause: "world-corrupt",
|
|
353
|
+
});
|
|
354
|
+
expect(crashed.at(0)?.payload).not.toHaveProperty("lastLine");
|
|
355
|
+
});
|
|
356
|
+
|
|
306
357
|
it("never lets the evidence overwrite the record keys", () => {
|
|
307
358
|
const ring = idleRing();
|
|
308
359
|
|