@serverkgg/bridge 0.1.1 → 0.1.3

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 (121) hide show
  1. package/package.json +3 -2
  2. package/rules/package.md +60 -0
  3. package/src/blog/articleFrontmatter.test.ts +10 -10
  4. package/src/blog/compile.test.ts +9 -9
  5. package/src/bot/botDeploy.test.ts +16 -16
  6. package/src/bot/botEnvironment.test.ts +8 -8
  7. package/src/bot/botIdentity.test.ts +12 -12
  8. package/src/bot/botPanel.test.ts +41 -11
  9. package/src/bot/botPanel.ts +13 -5
  10. package/src/bot/botState.test.ts +3 -3
  11. package/src/bot/botTerminal.test.ts +5 -5
  12. package/src/bot/botVariables.test.ts +8 -8
  13. package/src/bot/detect/botToken.test.ts +6 -6
  14. package/src/bot/detect/detect.test.ts +21 -21
  15. package/src/bot/detect/detectTable.test.ts +5 -5
  16. package/src/bot/detect/envExample.test.ts +2 -2
  17. package/src/bot/detect/envScan.test.ts +4 -4
  18. package/src/bot/detect/inferBotPlatform.test.ts +4 -4
  19. package/src/bot/detect/keys.test.ts +3 -3
  20. package/src/bot/detect/sensitiveKey.test.ts +5 -5
  21. package/src/bot/detect/serverkJson.test.ts +5 -5
  22. package/src/catalogs/curseforge.test.ts +35 -35
  23. package/src/catalogs/thunderstore.test.ts +9 -9
  24. package/src/cli/detectTable.test.ts +3 -3
  25. package/src/cli/link.test.ts +11 -11
  26. package/src/cli/main.ts +8 -0
  27. package/src/cli/rules.test.ts +198 -0
  28. package/src/cli/rules.ts +188 -0
  29. package/src/cli/validate.test.ts +123 -29
  30. package/src/cli/validate.ts +37 -1
  31. package/src/codecs/ini.test.ts +13 -13
  32. package/src/codecs/javaProperties.test.ts +11 -11
  33. package/src/codecs/sourceCfg.test.ts +14 -14
  34. package/src/codecs/xmlProperties.test.ts +10 -10
  35. package/src/context/bridgeContext.ts +6 -1
  36. package/src/core/bridgeAction.ts +2 -0
  37. package/src/core/bridgeActivity.ts +9 -0
  38. package/src/core/bridgeAvailability.ts +10 -0
  39. package/src/core/bridgePort.test.ts +6 -6
  40. package/src/core/bridgeUserError.test.ts +13 -3
  41. package/src/core/bridgeUserError.ts +6 -2
  42. package/src/core/bridgeValue.test.ts +5 -5
  43. package/src/core/describeDownloadFailure.test.ts +58 -0
  44. package/src/core/describeDownloadFailure.ts +69 -0
  45. package/src/core/index.ts +3 -0
  46. package/src/guides/markdown.test.ts +31 -31
  47. package/src/install/installStamp.test.ts +9 -9
  48. package/src/manifest/extension.ts +36 -0
  49. package/src/manifest/index.ts +1 -0
  50. package/src/manifest/manifest.test.ts +208 -90
  51. package/src/manifest/manifest.ts +34 -0
  52. package/src/modules/bridge.ts +8 -1
  53. package/src/modules/bridgeModule.ts +13 -1
  54. package/src/net/loopback.test.ts +10 -10
  55. package/src/panel/bridgeDetail.ts +2 -1
  56. package/src/presence/rosterSync.test.ts +7 -7
  57. package/src/probes/a2s.test.ts +5 -5
  58. package/src/probes/minecraftPing.test.ts +17 -17
  59. package/src/probes/raknetPing.test.ts +13 -13
  60. package/src/protocol/bridgeActivity.ts +54 -0
  61. package/src/protocol/bridgeEvents.ts +30 -0
  62. package/src/protocol/bridgeFrame.ts +10 -1
  63. package/src/protocol/index.ts +1 -0
  64. package/src/rcon/rconAccess.test.ts +20 -20
  65. package/src/rcon/rconAccess.ts +13 -13
  66. package/src/rcon/rconClient.test.ts +49 -49
  67. package/src/rcon/rconFrame.test.ts +14 -14
  68. package/src/rcon/rconIds.test.ts +4 -4
  69. package/src/rcon/rconWriter.test.ts +6 -6
  70. package/src/runtime/bridgeActions.ts +19 -2
  71. package/src/runtime/bridgeActivity.test.ts +318 -0
  72. package/src/runtime/bridgeActivity.ts +268 -0
  73. package/src/runtime/bridgeBackupLease.test.ts +9 -9
  74. package/src/runtime/bridgeConfigReader.test.ts +5 -5
  75. package/src/runtime/bridgeConsoleHandler.test.ts +8 -8
  76. package/src/runtime/bridgeContext.test.ts +11 -5
  77. package/src/runtime/bridgeContext.ts +8 -1
  78. package/src/runtime/bridgeDriver.test.ts +114 -93
  79. package/src/runtime/bridgeDriver.ts +10 -7
  80. package/src/runtime/bridgeEvents.test.ts +15 -15
  81. package/src/runtime/bridgeFiles.test.ts +54 -54
  82. package/src/runtime/bridgeInstall.test.ts +10 -10
  83. package/src/runtime/bridgeLiveConfig.test.ts +6 -6
  84. package/src/runtime/bridgeLog.ts +10 -0
  85. package/src/runtime/bridgePending.test.ts +6 -6
  86. package/src/runtime/bridgeRedaction.test.ts +16 -16
  87. package/src/runtime/bridgeRefusalError.ts +13 -0
  88. package/src/runtime/bridgeRegistry.test.ts +1077 -34
  89. package/src/runtime/bridgeRegistry.ts +464 -65
  90. package/src/runtime/bridgeScopedContext.ts +55 -0
  91. package/src/runtime/bridgeServer.test.ts +424 -24
  92. package/src/runtime/bridgeServer.ts +125 -35
  93. package/src/runtime/bridgeSetup.test.ts +27 -27
  94. package/src/runtime/bridgeSetup.ts +1 -1
  95. package/src/runtime/bridgeSetupHandlers.test.ts +80 -17
  96. package/src/runtime/bridgeSetupHandlers.ts +45 -10
  97. package/src/runtime/bridgeSignal.test.ts +7 -7
  98. package/src/runtime/bridgeSupervisor.test.ts +42 -42
  99. package/src/runtime/serve.ts +45 -3
  100. package/src/steam/avatars.test.ts +13 -13
  101. package/src/steam/steamApp.test.ts +8 -8
  102. package/src/steam/steamcmd.test.ts +44 -13
  103. package/src/steam/steamcmd.ts +27 -4
  104. package/src/steam/steamcmdNarrator.test.ts +4 -4
  105. package/src/steam/workshop.test.ts +5 -5
  106. package/src/utils/changedSettingKeys.test.ts +65 -0
  107. package/src/utils/changedSettingKeys.ts +6 -0
  108. package/src/utils/describeExecFailure.test.ts +34 -0
  109. package/src/utils/describeExecFailure.ts +9 -0
  110. package/src/utils/index.ts +3 -0
  111. package/src/utils/sameSettingValue.test.ts +35 -0
  112. package/src/utils/sameSettingValue.ts +40 -0
  113. package/src/utils/token.test.ts +7 -7
  114. package/src/wine/protonCommand.test.ts +5 -5
  115. package/src/wine/wineCommand.test.ts +6 -6
  116. package/src/wine/winePrefix.test.ts +6 -6
  117. package/src/wire/activity.test.ts +95 -0
  118. package/src/wire/activity.ts +41 -0
  119. package/src/wire/index.ts +1 -0
  120. package/src/wire/panel.test.ts +91 -25
  121. package/src/wire/panel.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -14,7 +14,8 @@
14
14
  "serverk-bridge": "src/cli/main.ts"
15
15
  },
16
16
  "files": [
17
- "src"
17
+ "src",
18
+ "rules"
18
19
  ],
19
20
  "exports": {
20
21
  ".": "./src/main.ts",
@@ -0,0 +1,60 @@
1
+ # Serverk package
2
+
3
+ Generated by `serverk-bridge rules` from the pinned `@serverkgg/bridge`. Do not edit it here: every install rewrites it. The source is `modules/bridge/rules/package.md` in `serverk`.
4
+
5
+ ## This repository
6
+
7
+ One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pipeline: validated with `serverk-bridge validate`, released only from `../serverk`, the monorepo beside it.
8
+
9
+ - When `../serverk` exists, read `../serverk/CLAUDE.md` first; its instructions apply here.
10
+ - Before matching work, read its skill in `../serverk/.claude/skills/`: `serverk-copy` (every string a customer reads), `serverk-game-package` (a new game, its manifest, image or control channel), `serverk-game-art` (logo and banner), `serverk-deploy` (publishing to dev in `dev.md`, releasing in `bridge.md`).
11
+
12
+ ## The driver
13
+
14
+ - `src/main.ts` is only `await serve(driver)`. `src/driver.ts` exports `driver: BridgeDriver` with the slots in this order (`install`, `lifecycle`, `events`, `query`, `backup`, `announce`, `pending`, `setup`, `terminal`, `panel`), then the panel modules under `modules`.
15
+ - Key a bridge-owned module by its constant (`[RCON_ACCESS_MODULE]: rconAccess`); any other key is its export name, which a panel section names (`module: "settings"`).
16
+ - Type each module you write with its `Bridge` alias and `kind` from `BridgeKind` (`export const settings: Bridge.Settings = { kind: BridgeKind.Settings, ... }`), never a `Bridge*Module` name. Export a helper-built module as returned (`export const deploy = createBotDeployModule()`).
17
+
18
+ ## `src/` folders
19
+
20
+ - A driver slot lives in the folder of its name, exported under that name.
21
+ - Group panel modules by kind in `actions/`, `collections/`, `details/`, `options/` and `settings/` (`Bridge.Actions`, `Bridge.Collection`, `Bridge.Detail`, `Bridge.Options`, `Bridge.Settings`). A feature spanning kinds gets its own folder (`worlds/`, `modpacks/`).
22
+ - `shared/` holds parsers, file formats and control-channel calls several folders use; `testing/` holds test fakes. Every folder has an `index.ts` re-exporting each sibling, and other folders import only that barrel.
23
+ - Use a bridge helper (`@serverkgg/bridge/steam`, `rcon`, `install`, `catalogs`, `presence`, `probes`, `utils`, `wine`, `net`, `bot`) before writing one. A helper a second package needs moves into the bridge, not a copy.
24
+
25
+ ## Code
26
+
27
+ - Functions are `const` arrows. No classes, `function` declarations, default exports or comments.
28
+ - Touch the server only through `Bridge.Context` (`files`, `exec`, `net`, `codec`, `rcon`, `variable`, `secret`), never `node:fs`, `fetch`, `Bun.spawn` or `process.env`.
29
+ - Log through `context.log`: a lowercase message, details second. Never `console` in `src/`.
30
+ - Refuse a customer's request with `new BridgeUserError({ ar, en })`; a plain lowercase English `Error` is for faults the customer cannot fix.
31
+ - A timeout, limit, pattern or file name is a `SCREAMING_SNAKE_CASE` constant (`STOP_TIMEOUT_SECONDS`), exported when a test or another folder needs it.
32
+
33
+ ## Panel, setup and events
34
+
35
+ - A game declares each tab as `const <name>Tab: Bridge.Tab`, listed in `export const panel: Bridge.Panel`; a bot's panel is the tab `createBotPanel()` from `@serverkgg/bridge/bot` builds. Reusable copy is a `Bridge.Text` constant.
36
+ - Every label, title, help text and message is `{ ar, en }`, Arabic first.
37
+ - A settings form targets `BridgeFormTarget.Settings` and module `settings`, with a one-sentence `help` and `restartHint: true`.
38
+ - In a game, the roster and moderation tables go on `place: BridgePlace.Players`, metrics on `BridgePlace.Overview`.
39
+ - A setup `Form` step points at an existing panel form by `tab`, `section` and field keys, never redeclaring a field; `Form` and `Open` steps state `required`. A game's setup ends with the `Open` step `invite` targeting `GuideOpenTab.Access`.
40
+ - Emit event names from `BridgeEventName`, never strings, and list every event raised from code in `emits` (`patterns: []` when none match the log). A bot matches deployments with `DEPLOYMENT_EVENT_PATTERN`.
41
+ - `install.run` runs on every boot: keep it idempotent with `readStamp` and `writeStamp` (`@serverkgg/bridge/install`), the stamp in `files.protected`. A game protects the default `.serverk-install.json`; a bot writes `.serverk/install.json` (`INSTALL_STAMP`, `shared/botRuntime.ts`) and protects `.serverk/**`.
42
+
43
+ ## `serverk.yml`
44
+
45
+ - Line one is `# yaml-language-server: $schema=./serverk.schema.json`; rerun `bun run schema` when the bridge pin moves.
46
+ - Write `ar:` before `en:` in every localized field.
47
+ - `secrets` are platform API keys the game can use, `required: false`; customer settings are a panel form, and a bot customer's own values go through `environment.vault`.
48
+ - Change an outcome by changing the declared value (`reset.keep: []`, `backup.only`), never by asking the platform to ignore a field.
49
+
50
+ ## Guides and image
51
+
52
+ - A guide is `guides/<id>/ar.md` and `en.md`, starting at `##` with no `#` title; its title and summary live in `serverk.yml`. Every package has a `getting-started` guide tagged `start`.
53
+ - `image/Dockerfile` keeps the shared contract: `ARG BRIDGE_VERSION` with `LABEL gg.serverk.bridge.version`, a `container` user with uid 1000 and home `/home/container`, `COPY dist/bridge /serverk/bridge`, `ENTRYPOINT ["/usr/bin/tini", "--", "/serverk/bridge"]`, and no `USER` line. Game files download at install, a bot's code arrives with a deploy; neither is baked in.
54
+ - The outermost start wrapper ends in `exec`, with no `setsid`, `nohup` or double fork. Why: the bridge checks the uid of the pid it spawned, and stop escalation signals the process group, so an orphan outside it keeps the port.
55
+
56
+ ## Tests and the pin
57
+
58
+ - Tests sit beside the file as `<name>.test.ts`, use `it` from `bun:test` (never `test`), and are named as a plain statement of behaviour, never `should`. `driver.test.ts` asserts the assembled driver's tab and section wiring, and in a game its setup targets and event names.
59
+ - `package.json` pins `@serverkgg/bridge` to an exact published version, bumped only after a bridge publish from `serverk`, never within other work. `serverk-bridge link` points `node_modules` at `../serverk/modules/bridge` without touching the pin.
60
+ - The two bot packages stay identical except where the runtime differs (`shared/botRuntime.ts`, `shared/botProcess.ts`, `terminal/terminal.ts`, the image's launcher and `Dockerfile`, and the tests asserting those). A change to any other file lands in both; behaviour both need belongs in `@serverkgg/bridge/bot`.
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import { parseArticleFrontmatter, parseArticleTranslationFrontmatter, splitFrontmatter } from "./articleFrontmatter";
3
3
 
4
4
  const article = (front: string, body = "## عنوان\n\nنص.\n") => `---\n${front}\n---\n\n${body}`;
@@ -10,7 +10,7 @@ tags:
10
10
  - minecraft`;
11
11
 
12
12
  describe("splitFrontmatter", () => {
13
- test("blanks the frontmatter lines so body line numbers stay true", () => {
13
+ it("blanks the frontmatter lines so body line numbers stay true", () => {
14
14
  const split = splitFrontmatter("blog/x/ar.md", article(VALID));
15
15
 
16
16
  expect(split.body.split("\n").slice(0, 7)).toEqual([
@@ -25,17 +25,17 @@ describe("splitFrontmatter", () => {
25
25
  expect(split.body).toContain("## عنوان");
26
26
  });
27
27
 
28
- test("rejects a file that does not open with a fence", () => {
28
+ it("rejects a file that does not open with a fence", () => {
29
29
  expect(() => splitFrontmatter("blog/x/ar.md", "## عنوان\n")).toThrow('must open with a "---" frontmatter block');
30
30
  });
31
31
 
32
- test("rejects a frontmatter block that is never closed", () => {
32
+ it("rejects a frontmatter block that is never closed", () => {
33
33
  expect(() => splitFrontmatter("blog/x/ar.md", `---\n${VALID}\n`)).toThrow("never closed");
34
34
  });
35
35
  });
36
36
 
37
37
  describe("parseArticleFrontmatter", () => {
38
- test("reads every declared field", () => {
38
+ it("reads every declared field", () => {
39
39
  const parsed = parseArticleFrontmatter(
40
40
  "blog/x/ar.md",
41
41
  article(`${VALID}\nupdatedAt: 2026-09-06\nvideo: dQw4w9WgXcQ`),
@@ -50,7 +50,7 @@ describe("parseArticleFrontmatter", () => {
50
50
  ]);
51
51
  });
52
52
 
53
- test("defaults tags to an empty list", () => {
53
+ it("defaults tags to an empty list", () => {
54
54
  const parsed = parseArticleFrontmatter(
55
55
  "blog/x/ar.md",
56
56
  article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05"),
@@ -60,13 +60,13 @@ describe("parseArticleFrontmatter", () => {
60
60
  expect(parsed.front.updatedAt).toBeUndefined();
61
61
  });
62
62
 
63
- test("rejects a missing title", () => {
63
+ it("rejects a missing title", () => {
64
64
  expect(() => parseArticleFrontmatter("blog/x/ar.md", article("summary: ملخص.\npublishedAt: 2026-09-05"))).toThrow(
65
65
  "frontmatter is invalid",
66
66
  );
67
67
  });
68
68
 
69
- test("rejects a publishedAt that is not a calendar date", () => {
69
+ it("rejects a publishedAt that is not a calendar date", () => {
70
70
  expect(() =>
71
71
  parseArticleFrontmatter(
72
72
  "blog/x/ar.md",
@@ -75,7 +75,7 @@ describe("parseArticleFrontmatter", () => {
75
75
  ).toThrow("must be a calendar date");
76
76
  });
77
77
 
78
- test("rejects an unknown key", () => {
78
+ it("rejects an unknown key", () => {
79
79
  expect(() => parseArticleFrontmatter("blog/x/ar.md", article(`${VALID}\nauthor: mohammed`))).toThrow(
80
80
  "frontmatter is invalid",
81
81
  );
@@ -83,7 +83,7 @@ describe("parseArticleFrontmatter", () => {
83
83
  });
84
84
 
85
85
  describe("parseArticleTranslationFrontmatter", () => {
86
- test("lets a translation omit the language-independent fields", () => {
86
+ it("lets a translation omit the language-independent fields", () => {
87
87
  const parsed = parseArticleTranslationFrontmatter(
88
88
  "blog/x/en.md",
89
89
  article("title: How to make a server\nsummary: The three ways."),
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } 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";
@@ -33,11 +33,11 @@ describe("compileArticles", () => {
33
33
  });
34
34
  });
35
35
 
36
- test("returns nothing when the package has no blog directory", () => {
36
+ it("returns nothing when the package has no blog directory", () => {
37
37
  expect(compile().drafts).toEqual([]);
38
38
  });
39
39
 
40
- test("compiles both languages into one article", () => {
40
+ it("compiles both languages into one article", () => {
41
41
  write(
42
42
  "make-a-server",
43
43
  article("title: كيف تسوي سيرفر\nsummary: ثلاث طرق.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
@@ -63,7 +63,7 @@ describe("compileArticles", () => {
63
63
  });
64
64
  });
65
65
 
66
- test("records both markdown files so the bundle digest covers them", () => {
66
+ it("records both markdown files so the bundle digest covers them", () => {
67
67
  write(
68
68
  "one",
69
69
  article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
@@ -76,7 +76,7 @@ describe("compileArticles", () => {
76
76
  ]);
77
77
  });
78
78
 
79
- test("orders newest first and breaks ties by id", () => {
79
+ it("orders newest first and breaks ties by id", () => {
80
80
  write(
81
81
  "beta",
82
82
  article("title: ب\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
@@ -100,7 +100,7 @@ describe("compileArticles", () => {
100
100
  ]);
101
101
  });
102
102
 
103
- test("rejects a translation that contradicts a language-independent field", () => {
103
+ it("rejects a translation that contradicts a language-independent field", () => {
104
104
  write(
105
105
  "one",
106
106
  article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
@@ -110,7 +110,7 @@ describe("compileArticles", () => {
110
110
  expect(compile).toThrow("a language-independent field must match");
111
111
  });
112
112
 
113
- test("accepts a translation that repeats a language-independent field with the same value", () => {
113
+ it("accepts a translation that repeats a language-independent field with the same value", () => {
114
114
  write(
115
115
  "one",
116
116
  article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
@@ -120,7 +120,7 @@ describe("compileArticles", () => {
120
120
  expect(compile().drafts).toHaveLength(1);
121
121
  });
122
122
 
123
- test("reports a missing english translation", () => {
123
+ it("reports a missing english translation", () => {
124
124
  mkdirSync(join(root, "blog", "one"), {
125
125
  recursive: true,
126
126
  });
@@ -132,7 +132,7 @@ describe("compileArticles", () => {
132
132
  expect(compile).toThrow("missing article body blog/one/en.md");
133
133
  });
134
134
 
135
- test("resolves into a compiled article the contract accepts", () => {
135
+ it("resolves into a compiled article the contract accepts", () => {
136
136
  write(
137
137
  "one",
138
138
  article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\nupdatedAt: 2026-09-06", "عنوان"),
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import type { BridgeValues } from "../core";
3
3
  import { BridgeEventName } from "../protocol";
4
4
  import {
@@ -121,7 +121,7 @@ const liveState = (deployment: string, source: BotDeploymentSource = BotDeployme
121
121
  };
122
122
 
123
123
  describe("deploymentLine", () => {
124
- test("announces a ready deployment with the short commit", () => {
124
+ it("announces a ready deployment with the short commit", () => {
125
125
  expect(
126
126
  deploymentLine({
127
127
  deployment: "dep_a1b2c3d4",
@@ -131,7 +131,7 @@ describe("deploymentLine", () => {
131
131
  ).toBe("serverk: deployment dep_a1b2c3d4 ready at 0123456789ab");
132
132
  });
133
133
 
134
- test("announces a ready deployment with no commit for files and snapshots", () => {
134
+ it("announces a ready deployment with no commit for files and snapshots", () => {
135
135
  expect(
136
136
  deploymentLine({
137
137
  deployment: "dep_a1b2c3d4",
@@ -141,7 +141,7 @@ describe("deploymentLine", () => {
141
141
  ).toBe("serverk: deployment dep_a1b2c3d4 ready");
142
142
  });
143
143
 
144
- test("names the stage and one line of detail for a failure", () => {
144
+ it("names the stage and one line of detail for a failure", () => {
145
145
  expect(
146
146
  deploymentLine({
147
147
  deployment: "dep_a1b2c3d4",
@@ -154,7 +154,7 @@ describe("deploymentLine", () => {
154
154
  );
155
155
  });
156
156
 
157
- test("round-trips through the line pattern", () => {
157
+ it("round-trips through the line pattern", () => {
158
158
  const ready = DEPLOYMENT_LINE.exec(
159
159
  deploymentLine({
160
160
  deployment: "dep_a1b2c3d4",
@@ -186,7 +186,7 @@ describe("deploymentLine", () => {
186
186
  });
187
187
  });
188
188
 
189
- test("cuts the detail at 200 characters, counting an emoji as one the way the Python launcher does", () => {
189
+ it("cuts the detail at 200 characters, counting an emoji as one the way the Python launcher does", () => {
190
190
  const line = deploymentLine({
191
191
  deployment: "dep_a1b2c3d4",
192
192
  outcome: BotDeploymentOutcome.Failed,
@@ -198,12 +198,12 @@ describe("deploymentLine", () => {
198
198
  expect(DEPLOYMENT_LINE.exec(line)?.groups?.detail).toBe("🤖".repeat(200));
199
199
  });
200
200
 
201
- test("reads only deployment ids of at most 64 characters", () => {
201
+ it("reads only deployment ids of at most 64 characters", () => {
202
202
  expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(64)} ready`)).toBe(true);
203
203
  expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(65)} ready`)).toBe(false);
204
204
  });
205
205
 
206
- test("is reported as a server update tagged as a deployment", () => {
206
+ it("is reported as a server update tagged as a deployment", () => {
207
207
  expect(DEPLOYMENT_EVENT_PATTERN.emit).toBe(BridgeEventName.ServerUpdated);
208
208
  expect(DEPLOYMENT_EVENT_PATTERN.payload).toEqual({
209
209
  kind: "deployment",
@@ -214,7 +214,7 @@ describe("deploymentLine", () => {
214
214
  });
215
215
 
216
216
  describe("requestDeploy", () => {
217
- test("writes the request as JSON and restarts a running bot", async () => {
217
+ it("writes the request as JSON and restarts a running bot", async () => {
218
218
  const { context, stored, events } = deployContext(true);
219
219
 
220
220
  await requestDeploy(context, {
@@ -239,7 +239,7 @@ describe("requestDeploy", () => {
239
239
  expect(Number.isNaN(Date.parse(markerOf(stored).requestedAt))).toBe(false);
240
240
  });
241
241
 
242
- test("records the request without a restart while the bot is stopped", async () => {
242
+ it("records the request without a restart while the bot is stopped", async () => {
243
243
  const { context, stored, events } = deployContext(false);
244
244
 
245
245
  await requestDeploy(context, {
@@ -259,7 +259,7 @@ describe("requestDeploy", () => {
259
259
  });
260
260
  });
261
261
 
262
- test("does nothing for a deployment that is already live and reports it again", async () => {
262
+ it("does nothing for a deployment that is already live and reports it again", async () => {
263
263
  const { context, stored, events, emitted } = deployContext(true, liveState("dep_a1b2c3d4"));
264
264
 
265
265
  await requestDeploy(context, {
@@ -286,7 +286,7 @@ describe("requestDeploy", () => {
286
286
  ]);
287
287
  });
288
288
 
289
- test("reports no commit again for a live deployment of uploaded code", async () => {
289
+ it("reports no commit again for a live deployment of uploaded code", async () => {
290
290
  const { context, emitted } = deployContext(true, liveState("dep_a1b2c3d4", BotDeploymentSource.Snapshot));
291
291
 
292
292
  await requestDeploy(context, {
@@ -302,7 +302,7 @@ describe("requestDeploy", () => {
302
302
  });
303
303
  });
304
304
 
305
- test("replaces a request the bot has not taken yet", async () => {
305
+ it("replaces a request the bot has not taken yet", async () => {
306
306
  const { context, stored } = deployContext(false, {
307
307
  [DEPLOY_MARKER]: "{}",
308
308
  });
@@ -317,7 +317,7 @@ describe("requestDeploy", () => {
317
317
  expect(markerOf(stored).deployment).toBe("dep_newer000");
318
318
  });
319
319
 
320
- test("still reinstalls a deployment that is already live", async () => {
320
+ it("still reinstalls a deployment that is already live", async () => {
321
321
  const { context, events } = deployContext(true, liveState("dep_a1b2c3d4"));
322
322
 
323
323
  await requestDeploy(context, {
@@ -330,7 +330,7 @@ describe("requestDeploy", () => {
330
330
  expect(events.at(-1)).toBe("restart");
331
331
  });
332
332
 
333
- test("runs a new deployment when another one is live", async () => {
333
+ it("runs a new deployment when another one is live", async () => {
334
334
  const { context, events } = deployContext(true, liveState("dep_old00000"));
335
335
 
336
336
  await requestDeploy(context, {
@@ -345,7 +345,7 @@ describe("requestDeploy", () => {
345
345
  });
346
346
 
347
347
  describe("deployPending", () => {
348
- test("is true only while a running bot has not taken the marker yet", async () => {
348
+ it("is true only while a running bot has not taken the marker yet", async () => {
349
349
  const running = deployContext(true);
350
350
 
351
351
  expect(await deployPending(running.context)).toBe(false);
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  botProcessEnvironment,
4
4
  customerEnvironment,
@@ -36,7 +36,7 @@ const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
36
36
  });
37
37
 
38
38
  describe("customerEnvironment", () => {
39
- test("hands the process every vault key and nothing the platform or the runtime owns", () => {
39
+ it("hands the process every vault key and nothing the platform or the runtime owns", () => {
40
40
  expect(
41
41
  customerEnvironment(
42
42
  {
@@ -71,7 +71,7 @@ describe("customerEnvironment", () => {
71
71
  });
72
72
  });
73
73
 
74
- test("lets a secret win over a plain value under the same key", () => {
74
+ it("lets a secret win over a plain value under the same key", () => {
75
75
  expect(
76
76
  customerEnvironment(
77
77
  {
@@ -88,7 +88,7 @@ describe("customerEnvironment", () => {
88
88
  });
89
89
 
90
90
  describe("launcherEnvironment", () => {
91
- test("maps the platform-owned keys onto the launcher internals", () => {
91
+ it("maps the platform-owned keys onto the launcher internals", () => {
92
92
  expect(
93
93
  launcherEnvironment(
94
94
  config({
@@ -116,7 +116,7 @@ describe("launcherEnvironment", () => {
116
116
  });
117
117
  });
118
118
 
119
- test("keeps the git keys out when no repository is linked", () => {
119
+ it("keeps the git keys out when no repository is linked", () => {
120
120
  expect(
121
121
  launcherEnvironment(
122
122
  config({
@@ -132,7 +132,7 @@ describe("launcherEnvironment", () => {
132
132
  });
133
133
  });
134
134
 
135
- test("owns only SERVERK_ names the launcher strips before the bot starts", () => {
135
+ it("owns only SERVERK_ names the launcher strips before the bot starts", () => {
136
136
  expect(LAUNCHER_KEYS.every((key) => key.startsWith("SERVERK_"))).toBe(true);
137
137
  expect(LAUNCHER_KEYS).toContain(LAUNCHER_REPO_REF);
138
138
  expect(LAUNCHER_KEYS).toContain(LAUNCHER_INSTALL_COMMAND);
@@ -141,7 +141,7 @@ describe("launcherEnvironment", () => {
141
141
  });
142
142
 
143
143
  describe("platformEnvironment", () => {
144
- test("marks the process as a Serverk bot and names the platform when known", () => {
144
+ it("marks the process as a Serverk bot and names the platform when known", () => {
145
145
  expect(platformEnvironment("abc", BotPlatform.Discord)).toEqual({
146
146
  SERVERK_BOT: "1",
147
147
  SERVERK_SERVER_CODE: "abc",
@@ -155,7 +155,7 @@ describe("platformEnvironment", () => {
155
155
  });
156
156
 
157
157
  describe("botProcessEnvironment", () => {
158
- test("layers tool defaults under the customer, then the launcher and the platform", () => {
158
+ it("layers tool defaults under the customer, then the launcher and the platform", () => {
159
159
  expect(
160
160
  botProcessEnvironment({
161
161
  tool: {
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  botNameOf,
4
4
  IDENTITY_CACHE_FILE,
@@ -40,7 +40,7 @@ const memoryFiles = (initial: Record<string, string> = {}) => {
40
40
  };
41
41
 
42
42
  describe("LOGIN_LINE", () => {
43
- test("reads the name from the login lines the popular libraries and our templates print", () => {
43
+ it("reads the name from the login lines the popular libraries and our templates print", () => {
44
44
  const names = [
45
45
  "Logged in as MyBot#1234, /ping is registered",
46
46
  "Logged in as MyBot (ID: 1136470478027245683)",
@@ -56,19 +56,19 @@ describe("LOGIN_LINE", () => {
56
56
  ]);
57
57
  });
58
58
 
59
- test("ignores lines that only mention logging", () => {
59
+ it("ignores lines that only mention logging", () => {
60
60
  expect(LOGIN_LINE.test("serverk: bot process starting")).toBe(false);
61
61
  expect(LOGIN_LINE.test("user logged out")).toBe(false);
62
62
  });
63
63
  });
64
64
 
65
65
  describe("identityFromToken", () => {
66
- test("reads a Telegram bot id and a Discord application id without the network", () => {
66
+ it("reads a Telegram bot id and a Discord application id without the network", () => {
67
67
  expect(identityFromToken(BotPlatform.Telegram, TELEGRAM_TOKEN)).toBe("7123456789");
68
68
  expect(identityFromToken(BotPlatform.Discord, DISCORD_TOKEN)).toBe(DISCORD_ID);
69
69
  });
70
70
 
71
- test("refuses anything that is not shaped like the platform's token", () => {
71
+ it("refuses anything that is not shaped like the platform's token", () => {
72
72
  expect(identityFromToken(BotPlatform.Discord, JWT)).toBeNull();
73
73
  expect(identityFromToken(BotPlatform.Discord, TELEGRAM_TOKEN)).toBeNull();
74
74
  expect(identityFromToken(BotPlatform.Telegram, DISCORD_TOKEN)).toBeNull();
@@ -77,7 +77,7 @@ describe("identityFromToken", () => {
77
77
  });
78
78
 
79
79
  describe("the name cache", () => {
80
- test("keeps only the name the bot printed, per platform", async () => {
80
+ it("keeps only the name the bot printed, per platform", async () => {
81
81
  const { files, stored } = memoryFiles();
82
82
 
83
83
  await writeIdentityCache(files, {
@@ -95,7 +95,7 @@ describe("the name cache", () => {
95
95
  expect(await readIdentityCache(files, BotPlatform.Discord)).toBeNull();
96
96
  });
97
97
 
98
- test("reads nothing from a broken or older cache", async () => {
98
+ it("reads nothing from a broken or older cache", async () => {
99
99
  expect(
100
100
  await readIdentityCache(
101
101
  memoryFiles({
@@ -119,7 +119,7 @@ describe("the name cache", () => {
119
119
  });
120
120
 
121
121
  describe("inviteLink", () => {
122
- test("invites a Discord bot by id and opens a Telegram bot by name", () => {
122
+ it("invites a Discord bot by id and opens a Telegram bot by name", () => {
123
123
  expect(
124
124
  inviteLink({
125
125
  platform: BotPlatform.Discord,
@@ -138,7 +138,7 @@ describe("inviteLink", () => {
138
138
  ).toBe("https://t.me/serverk_test_bot");
139
139
  });
140
140
 
141
- test("offers nothing it cannot build", () => {
141
+ it("offers nothing it cannot build", () => {
142
142
  expect(
143
143
  inviteLink({
144
144
  platform: BotPlatform.Discord,
@@ -159,7 +159,7 @@ describe("inviteLink", () => {
159
159
  });
160
160
 
161
161
  describe("watchLoginLine", () => {
162
- test("remembers the first login name and stops listening", async () => {
162
+ it("remembers the first login name and stops listening", async () => {
163
163
  const { files, stored } = memoryFiles();
164
164
  const watchers: ((match: RegExpMatchArray) => void)[] = [];
165
165
  let unsubscribed = 0;
@@ -210,7 +210,7 @@ describe("watchLoginLine", () => {
210
210
  });
211
211
 
212
212
  describe("resolveBotIdentity", () => {
213
- test("combines the inferred platform, the id from the token and the remembered name", async () => {
213
+ it("combines the inferred platform, the id from the token and the remembered name", async () => {
214
214
  const { files } = memoryFiles({
215
215
  [IDENTITY_CACHE_FILE]: JSON.stringify({
216
216
  platform: BotPlatform.Discord,
@@ -237,7 +237,7 @@ describe("resolveBotIdentity", () => {
237
237
  });
238
238
  });
239
239
 
240
- test("knows nothing about a bot with no platform hint", async () => {
240
+ it("knows nothing about a bot with no platform hint", async () => {
241
241
  expect(
242
242
  await resolveBotIdentity({
243
243
  files: memoryFiles().files,
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import { BridgeKind, BridgeUserError, type BridgeValues } from "../core";
3
3
  import { BridgeLayout, BridgePlace } from "../panel";
4
4
  import { bridgePanelSchema } from "../wire";
@@ -19,7 +19,7 @@ import {
19
19
  const SHA = "0123456789abcdef0123456789abcdef01234567";
20
20
 
21
21
  describe("createBotPanel", () => {
22
- test("is one bot tab holding only the identity card on the overview", () => {
22
+ it("is one bot tab holding only the identity card on the overview", () => {
23
23
  const tab = createBotPanel();
24
24
 
25
25
  expect(tab.id).toBe(BOT_TAB);
@@ -33,7 +33,7 @@ describe("createBotPanel", () => {
33
33
  expect(identity?.layout === BridgeLayout.Detail && identity.module).toBe(BOT_IDENTITY_MODULE);
34
34
  });
35
35
 
36
- test("parses through the panel mirror the agent and the api read", () => {
36
+ it("parses through the panel mirror the agent and the api read", () => {
37
37
  expect(
38
38
  bridgePanelSchema.safeParse({
39
39
  tabs: [
@@ -43,7 +43,7 @@ describe("createBotPanel", () => {
43
43
  ).toBe(true);
44
44
  });
45
45
 
46
- test("names the deploy module the platform invokes", () => {
46
+ it("names the deploy module the platform invokes", () => {
47
47
  expect(BOT_DEPLOY_MODULE).toBe("botDeploy");
48
48
  expect(Object.values(BotDeployAction).map(String)).toEqual([
49
49
  "redeploy",
@@ -53,7 +53,7 @@ describe("createBotPanel", () => {
53
53
  });
54
54
 
55
55
  describe("deployRequestOf", () => {
56
- test("takes the deployment, the pinned ref and the reinstall flag from the invoke values", () => {
56
+ it("takes the deployment, the pinned ref and the reinstall flag from the invoke values", () => {
57
57
  expect(
58
58
  deployRequestOf(
59
59
  {
@@ -71,7 +71,7 @@ describe("deployRequestOf", () => {
71
71
  });
72
72
  });
73
73
 
74
- test("treats a bare press as a redeploy of whatever is configured", () => {
74
+ it("treats a bare press as a redeploy of whatever is configured", () => {
75
75
  expect(deployRequestOf({}, false)).toEqual({
76
76
  deployment: null,
77
77
  ref: null,
@@ -81,7 +81,7 @@ describe("deployRequestOf", () => {
81
81
  expect(deployRequestOf({}, true).reinstall).toBe(true);
82
82
  });
83
83
 
84
- test("refuses a short ref, a bad id and a ref beside a snapshot", () => {
84
+ it("refuses a short ref, a bad id and a ref beside a snapshot", () => {
85
85
  const refusals: BridgeValues[] = [
86
86
  {
87
87
  ref: "0123456",
@@ -111,7 +111,7 @@ describe("deployRequestOf", () => {
111
111
  });
112
112
 
113
113
  describe("the bot modules", () => {
114
- test("read the identity while the bot is stopped and refresh it every minute", () => {
114
+ it("read the identity while the bot is stopped and refresh it every minute", () => {
115
115
  const read = async () => null;
116
116
 
117
117
  expect(createBotIdentityModule(read)).toEqual({
@@ -174,7 +174,7 @@ describe("the bot modules", () => {
174
174
  };
175
175
  };
176
176
 
177
- test("deploy while the bot is stopped too, so the platform can start the new code", async () => {
177
+ it("deploy while the bot is stopped too, so the platform can start the new code", async () => {
178
178
  const module = createBotDeployModule();
179
179
  const { context, stored, restarts } = deployContext(false);
180
180
 
@@ -193,7 +193,7 @@ describe("the bot modules", () => {
193
193
  expect(restarts()).toBe(0);
194
194
  });
195
195
 
196
- test("reinstall forces the flag and restarts a running bot", async () => {
196
+ it("reinstall forces the flag and restarts a running bot", async () => {
197
197
  const module = createBotDeployModule();
198
198
  const { context, stored, restarts } = deployContext(true);
199
199
 
@@ -205,7 +205,7 @@ describe("the bot modules", () => {
205
205
  expect(restarts()).toBe(1);
206
206
  });
207
207
 
208
- test("refuses a second press while a running bot has not taken the first", async () => {
208
+ it("refuses a second press while a running bot has not taken the first", async () => {
209
209
  const module = createBotDeployModule();
210
210
  const { context } = deployContext(true, [
211
211
  DEPLOY_MARKER,
@@ -213,4 +213,34 @@ describe("the bot modules", () => {
213
213
 
214
214
  await expect(module.actions[BotDeployAction.Redeploy]?.(context, {})).rejects.toThrow(BridgeUserError);
215
215
  });
216
+
217
+ it("greys out both deploy buttons with the refusal's words while a running bot has not taken a deploy", async () => {
218
+ const module = createBotDeployModule();
219
+ const pending = deployContext(true, [
220
+ DEPLOY_MARKER,
221
+ ]);
222
+
223
+ const unavailable = await module.unavailable?.(pending.context);
224
+
225
+ expect(Object.keys(unavailable ?? {}).sort()).toEqual([
226
+ BotDeployAction.Redeploy,
227
+ BotDeployAction.Reinstall,
228
+ ]);
229
+ await expect(module.actions[BotDeployAction.Redeploy]?.(pending.context, {})).rejects.toThrow(
230
+ unavailable?.[BotDeployAction.Redeploy]?.en,
231
+ );
232
+ });
233
+
234
+ it("offers both deploy buttons when nothing is pending or the bot is stopped", async () => {
235
+ const module = createBotDeployModule();
236
+
237
+ expect(await module.unavailable?.(deployContext(true).context)).toEqual({});
238
+ expect(
239
+ await module.unavailable?.(
240
+ deployContext(false, [
241
+ DEPLOY_MARKER,
242
+ ]).context,
243
+ ),
244
+ ).toEqual({});
245
+ });
216
246
  });