@serverkgg/bridge 0.1.9 → 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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/bot/botCauses.test.ts +137 -0
  3. package/src/bot/botCauses.ts +13 -4
  4. package/src/bot/botEnvironment.test.ts +218 -1
  5. package/src/bot/botEnvironment.ts +85 -3
  6. package/src/bot/botVariables.test.ts +32 -0
  7. package/src/bot/botVariables.ts +9 -0
  8. package/src/bot/detect/botInspectWarning.ts +1 -0
  9. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +3 -2
  10. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +3 -1
  11. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +3 -1
  12. package/src/bot/detect/corpus/serverk-discord-node.json +3 -2
  13. package/src/bot/detect/corpus/serverk-discord-python.json +3 -2
  14. package/src/bot/detect/corpus/serverk-telegram-node.json +3 -2
  15. package/src/bot/detect/corpus/serverk-telegram-python.json +3 -2
  16. package/src/bot/detect/detect.test.ts +307 -0
  17. package/src/bot/detect/detect.ts +39 -14
  18. package/src/bot/detect/detectTable.ts +4 -0
  19. package/src/bot/detect/detectedVersion.test.ts +160 -0
  20. package/src/bot/detect/detectedVersion.ts +55 -0
  21. package/src/bot/detect/envExample.test.ts +4 -0
  22. package/src/bot/detect/envExample.ts +1 -0
  23. package/src/bot/detect/envScan.test.ts +110 -0
  24. package/src/bot/detect/envScan.ts +7 -3
  25. package/src/bot/detect/keys.test.ts +50 -0
  26. package/src/bot/detect/keys.ts +45 -1
  27. package/src/cli/validate.test.ts +33 -3
  28. package/src/cli/validate.ts +13 -5
  29. package/src/manifest/manifest.test.ts +229 -0
  30. package/src/manifest/manifest.ts +109 -1
  31. package/src/protocol/bridgeCrash.ts +10 -1
  32. package/src/protocol/bridgeEvents.ts +2 -0
  33. package/src/runtime/bridgeDriver.test.ts +24 -0
  34. package/src/runtime/bridgeDriver.ts +6 -2
  35. package/src/runtime/bridgeEvents.test.ts +65 -1
@@ -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
+ };
@@ -69,6 +69,10 @@ describe("isSecretEnvFile", () => {
69
69
  name: ".env.dist",
70
70
  secret: false,
71
71
  },
72
+ {
73
+ name: "sample.env",
74
+ secret: false,
75
+ },
72
76
  {
73
77
  name: ".env.defaults",
74
78
  secret: false,
@@ -7,6 +7,7 @@ export const ENV_EXAMPLE_FILES: readonly string[] = [
7
7
  ".env-example",
8
8
  ".env-sample",
9
9
  ".env.dist",
10
+ "sample.env",
10
11
  ];
11
12
 
12
13
  const ENV_TEMPLATE_FILES: ReadonlySet<string> = new Set([
@@ -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(`\\bprocess\\.env\\.${NAME}`, "g"),
18
- new RegExp(`\\bprocess\\.env\\[\\s*["'\`]${NAME}["'\`]\\s*\\]`, "g"),
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 {
@@ -3,9 +3,21 @@ import {
3
3
  BOT_AMBIENT_KEYS,
4
4
  BOT_PLATFORM_KEYS,
5
5
  BOT_PLATFORM_SECRET_KEYS,
6
+ BOT_PORT_1,
7
+ BOT_PORT_1_KEY,
8
+ BOT_PORT_2,
9
+ BOT_PORT_2_KEY,
6
10
  BOT_PROTECTED_KEYS,
7
11
  BOT_PROVIDED_KEYS,
12
+ BOT_RAW_PORT_KEYS,
13
+ BOT_WEB,
14
+ BOT_WEB_PORT_KEY,
8
15
  isReservedBotKey,
16
+ PORT,
17
+ SERVERK_PORT_1,
18
+ SERVERK_PORT_2,
19
+ SERVERK_WEB_HOST,
20
+ SERVERK_WEB_URL,
9
21
  } from "./keys";
10
22
 
11
23
  describe("isReservedBotKey", () => {
@@ -21,6 +33,21 @@ describe("isReservedBotKey", () => {
21
33
  }
22
34
  });
23
35
 
36
+ it("reserves the web and port switches the platform sets", () => {
37
+ for (const key of [
38
+ BOT_WEB,
39
+ BOT_PORT_1,
40
+ BOT_PORT_2,
41
+ ]) {
42
+ expect(BOT_PLATFORM_KEYS).toContain(key);
43
+ expect(isReservedBotKey(key)).toBe(true);
44
+ }
45
+ });
46
+
47
+ it("leaves PORT to the customer, so a bot without the web port keeps its own", () => {
48
+ expect(isReservedBotKey(PORT)).toBe(false);
49
+ });
50
+
24
51
  it("leaves the customer's own keys alone, case included", () => {
25
52
  for (const key of [
26
53
  "DISCORD_TOKEN",
@@ -47,6 +74,19 @@ describe("BOT_AMBIENT_KEYS", () => {
47
74
  }
48
75
  });
49
76
 
77
+ it("holds the web address and raw port variables, so detection never asks for them", () => {
78
+ for (const key of [
79
+ SERVERK_WEB_URL,
80
+ SERVERK_WEB_HOST,
81
+ SERVERK_PORT_1,
82
+ SERVERK_PORT_2,
83
+ ]) {
84
+ expect(BOT_PROVIDED_KEYS).toContain(key);
85
+ expect(BOT_AMBIENT_KEYS).toContain(key);
86
+ expect(isReservedBotKey(key)).toBe(true);
87
+ }
88
+ });
89
+
50
90
  it("leaves a bot's own keys out", () => {
51
91
  for (const key of [
52
92
  "DISCORD_TOKEN",
@@ -57,3 +97,13 @@ describe("BOT_AMBIENT_KEYS", () => {
57
97
  }
58
98
  });
59
99
  });
100
+
101
+ describe("BOT_RAW_PORT_KEYS", () => {
102
+ it("names the two raw ports apart from the web port", () => {
103
+ expect(BOT_RAW_PORT_KEYS).toEqual([
104
+ BOT_PORT_1_KEY,
105
+ BOT_PORT_2_KEY,
106
+ ]);
107
+ expect(BOT_RAW_PORT_KEYS).not.toContain(BOT_WEB_PORT_KEY);
108
+ });
109
+ });
@@ -18,12 +18,49 @@ export const SNAPSHOT_ID = "SNAPSHOT_ID";
18
18
 
19
19
  export const BOT_PLATFORM = "BOT_PLATFORM";
20
20
 
21
+ export const BOT_WEB = "BOT_WEB";
22
+
23
+ export const BOT_PORT_1 = "BOT_PORT_1";
24
+
25
+ export const BOT_PORT_2 = "BOT_PORT_2";
26
+
27
+ export const BOT_SWITCH_ON = "on";
28
+
21
29
  export const GIT_TOKEN = "GIT_TOKEN";
22
30
 
23
31
  export const SERVERK_BOT = "SERVERK_BOT";
24
32
 
25
33
  export const SERVERK_SERVER_CODE = "SERVERK_SERVER_CODE";
26
34
 
35
+ export const SERVERK_WEB_URL = "SERVERK_WEB_URL";
36
+
37
+ export const SERVERK_WEB_HOST = "SERVERK_WEB_HOST";
38
+
39
+ export const SERVERK_PORT_1 = "SERVERK_PORT_1";
40
+
41
+ export const SERVERK_PORT_2 = "SERVERK_PORT_2";
42
+
43
+ export const PORT = "PORT";
44
+
45
+ export const BOT_WEB_PORT_KEY = "web";
46
+
47
+ export const BOT_PORT_1_KEY = "port1";
48
+
49
+ export const BOT_PORT_2_KEY = "port2";
50
+
51
+ export const BOT_RAW_PORT_KEYS = [
52
+ BOT_PORT_1_KEY,
53
+ BOT_PORT_2_KEY,
54
+ ] as const;
55
+
56
+ export type BotRawPortKey = (typeof BOT_RAW_PORT_KEYS)[number];
57
+
58
+ export const BOT_PORT_SWITCHES = {
59
+ [BOT_WEB_PORT_KEY]: BOT_WEB,
60
+ [BOT_PORT_1_KEY]: BOT_PORT_1,
61
+ [BOT_PORT_2_KEY]: BOT_PORT_2,
62
+ } as const;
63
+
27
64
  export const BOT_PLATFORM_KEYS: readonly string[] = [
28
65
  REPO_URL,
29
66
  REPO_REF,
@@ -34,6 +71,9 @@ export const BOT_PLATFORM_KEYS: readonly string[] = [
34
71
  DEPLOYMENT_ID,
35
72
  SNAPSHOT_ID,
36
73
  BOT_PLATFORM,
74
+ BOT_WEB,
75
+ BOT_PORT_1,
76
+ BOT_PORT_2,
37
77
  ];
38
78
 
39
79
  export const BOT_PLATFORM_SECRET_KEYS: readonly string[] = [
@@ -52,6 +92,10 @@ export const BOT_PROVIDED_KEYS: readonly string[] = [
52
92
  SERVERK_BOT,
53
93
  SERVERK_SERVER_CODE,
54
94
  BOT_PLATFORM,
95
+ SERVERK_WEB_URL,
96
+ SERVERK_WEB_HOST,
97
+ SERVERK_PORT_1,
98
+ SERVERK_PORT_2,
55
99
  ];
56
100
 
57
101
  export const BOT_AMBIENT_KEYS: readonly string[] = [
@@ -59,7 +103,7 @@ export const BOT_AMBIENT_KEYS: readonly string[] = [
59
103
  ...BOT_PROVIDED_KEYS,
60
104
  "NODE_ENV",
61
105
  "NODE_OPTIONS",
62
- "PORT",
106
+ PORT,
63
107
  "HOST",
64
108
  "HOSTNAME",
65
109
  "DEBUG",
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { renderDetectTable } from "../bot/detect";
5
+ import { BOT_SWITCH_ON, BOT_WEB, BOT_WEB_PORT_KEY, renderDetectTable } from "../bot/detect";
6
6
  import { BridgeKind, BridgeSetupStepKind } from "../core";
7
7
  import { GuideOpenTab, type GuidePack, type ParsedBlock, type ParsedLeafBlock } from "../guides";
8
8
  import { MANIFEST_SCHEMA_VERSION, PackageKind, type RunnableManifest } from "../manifest";
@@ -600,7 +600,7 @@ describe("validating a bot package", () => {
600
600
  );
601
601
  });
602
602
 
603
- it("warns about a port on a bot but still validates it", async () => {
603
+ it("warns about a bot port with no activeWhen but still validates it", async () => {
604
604
  writeBot(
605
605
  botManifest({
606
606
  container: {
@@ -616,10 +616,40 @@ describe("validating a bot package", () => {
616
616
 
617
617
  expect(lines).toContain("bot-node is valid");
618
618
  expect(lines).toContain(
619
- " warning: a bot package declares 1 port(s); bots need none — remove them unless a webhook needs a public port",
619
+ " warning: a bot package declares 1 port(s) with no activeWhen; a bot publishes a port only while its author turns it on",
620
620
  );
621
621
  });
622
622
 
623
+ it("validates a bot web port its author turns on without a warning", async () => {
624
+ writeBot(
625
+ botManifest({
626
+ container: {
627
+ image: "serverkgg/bot-node",
628
+ ports: [
629
+ {
630
+ ...WEBHOOK_PORT,
631
+ key: BOT_WEB_PORT_KEY,
632
+ web: true,
633
+ activeWhen: [
634
+ {
635
+ variable: BOT_WEB,
636
+ values: [
637
+ BOT_SWITCH_ON,
638
+ ],
639
+ },
640
+ ],
641
+ },
642
+ ],
643
+ },
644
+ }),
645
+ );
646
+
647
+ await validate();
648
+
649
+ expect(lines).toContain("bot-node is valid");
650
+ expect(lines.some((line) => line.includes("warning:"))).toBe(false);
651
+ });
652
+
623
653
  it("fails a bot that declares platform secrets", async () => {
624
654
  writeBot(
625
655
  botManifest({
@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
  import { parseArgs } from "node:util";
5
5
  import { z } from "zod";
6
6
  import { type ArticlePack, compileArticles } from "../blog";
7
- import { DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
7
+ import { BOT_PORT_SWITCHES, DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
8
8
  import { BridgeKind, PortProtocol } from "../core";
9
9
  import {
10
10
  GuideOpenTab,
@@ -599,14 +599,18 @@ export const setupReportLines = (setup: BridgeSetupModule | undefined): string[]
599
599
  };
600
600
 
601
601
  export const botPortLines = (manifest: RunnableManifest): string[] => {
602
- const ports = manifest.container.ports.length;
602
+ if (!isBotManifest(manifest)) {
603
+ return [];
604
+ }
603
605
 
604
- if (!isBotManifest(manifest) || ports === 0) {
606
+ const ungated = manifest.container.ports.filter((port) => port.activeWhen.length === 0).length;
607
+
608
+ if (ungated === 0) {
605
609
  return [];
606
610
  }
607
611
 
608
612
  return [
609
- ` warning: a bot package declares ${ports} port(s); bots need none — remove them unless a webhook needs a public port`,
613
+ ` warning: a bot package declares ${ungated} port(s) with no activeWhen; a bot publishes a port only while its author turns it on`,
610
614
  ];
611
615
  };
612
616
 
@@ -680,7 +684,11 @@ const validateRunnablePackage = async (
680
684
  }
681
685
 
682
686
  validateDriver(driver);
683
- validatePortActivation(manifest.container.ports, driver);
687
+ validatePortActivation(
688
+ manifest.container.ports,
689
+ driver,
690
+ isBotManifest(manifest) ? Object.values(BOT_PORT_SWITCHES) : [],
691
+ );
684
692
 
685
693
  const confirmLines = confirmReportLines(driver);
686
694