@serverkgg/bridge 0.0.12 → 0.0.13

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 (70) hide show
  1. package/package.json +9 -1
  2. package/src/catalogs/curseforge.test.ts +580 -0
  3. package/src/catalogs/curseforge.ts +461 -0
  4. package/src/catalogs/index.ts +2 -0
  5. package/src/catalogs/thunderstore.test.ts +94 -0
  6. package/src/catalogs/thunderstore.ts +239 -0
  7. package/src/codecs/index.ts +3 -0
  8. package/src/codecs/ini.test.ts +152 -0
  9. package/src/codecs/ini.ts +158 -0
  10. package/src/codecs/sourceCfg.test.ts +100 -0
  11. package/src/codecs/sourceCfg.ts +111 -0
  12. package/src/codecs/xmlProperties.test.ts +104 -0
  13. package/src/codecs/xmlProperties.ts +133 -0
  14. package/src/context/bridgeContext.ts +20 -0
  15. package/src/core/bridgeRconError.ts +19 -0
  16. package/src/core/index.ts +1 -0
  17. package/src/install/index.ts +1 -0
  18. package/src/install/installStamp.test.ts +125 -0
  19. package/src/install/installStamp.ts +40 -0
  20. package/src/main.ts +1 -0
  21. package/src/manifest/index.ts +1 -0
  22. package/src/manifest/manifest.test.ts +427 -2
  23. package/src/manifest/manifest.ts +141 -13
  24. package/src/manifest/primitives.ts +6 -0
  25. package/src/manifest/runtime.ts +89 -0
  26. package/src/net/index.ts +2 -0
  27. package/src/net/loopback.test.ts +152 -0
  28. package/src/net/loopback.ts +109 -0
  29. package/src/net/loopbackMethod.ts +7 -0
  30. package/src/presence/index.ts +1 -0
  31. package/src/presence/rosterSync.test.ts +179 -0
  32. package/src/presence/rosterSync.ts +57 -0
  33. package/src/protocol/bridgeEvents.ts +4 -0
  34. package/src/rcon/index.ts +3 -0
  35. package/src/rcon/sourceRcon.test.ts +308 -0
  36. package/src/rcon/sourceRcon.ts +276 -0
  37. package/src/rcon/sourceRconFrame.test.ts +152 -0
  38. package/src/rcon/sourceRconFrame.ts +99 -0
  39. package/src/runtime/bridgeCodec.ts +45 -2
  40. package/src/runtime/bridgeContext.ts +13 -6
  41. package/src/runtime/bridgeDriver.test.ts +1 -0
  42. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  43. package/src/runtime/bridgeLiveConfig.ts +63 -0
  44. package/src/runtime/bridgeRcon.ts +15 -0
  45. package/src/runtime/bridgeRegistry.ts +1 -0
  46. package/src/steam/index.ts +7 -0
  47. package/src/steam/steamApp.test.ts +78 -0
  48. package/src/steam/steamApp.ts +35 -0
  49. package/src/steam/steamcmd.test.ts +157 -0
  50. package/src/steam/steamcmd.ts +273 -0
  51. package/src/steam/steamcmdLogin.ts +34 -0
  52. package/src/steam/steamcmdNarrator.test.ts +90 -0
  53. package/src/steam/steamcmdNarrator.ts +43 -0
  54. package/src/steam/steamcmdPlatform.ts +4 -0
  55. package/src/steam/webApi.ts +70 -0
  56. package/src/steam/workshop.test.ts +45 -0
  57. package/src/steam/workshop.ts +148 -0
  58. package/src/utils/execDetail.ts +16 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/token.test.ts +50 -0
  61. package/src/utils/token.ts +35 -0
  62. package/src/wine/index.ts +3 -0
  63. package/src/wine/protonCommand.test.ts +67 -0
  64. package/src/wine/protonCommand.ts +49 -0
  65. package/src/wine/wineCommand.test.ts +92 -0
  66. package/src/wine/wineCommand.ts +54 -0
  67. package/src/wine/winePrefix.test.ts +111 -0
  68. package/src/wine/winePrefix.ts +88 -0
  69. package/src/runtime/bridgeSecrets.test.ts +0 -121
  70. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -0,0 +1,99 @@
1
+ import { BridgeRconCode, BridgeRconError } from "../core";
2
+
3
+ export enum SourceRconRequest {
4
+ Command = 2,
5
+ Auth = 3,
6
+ }
7
+
8
+ export enum SourceRconResponse {
9
+ Value = 0,
10
+ Auth = 2,
11
+ }
12
+
13
+ export const SOURCE_RCON_BODY_LIMIT = 4096;
14
+
15
+ export const SOURCE_RCON_AUTH_FAILED_ID = -1;
16
+
17
+ const HEADER_LENGTH = 4;
18
+
19
+ const MIN_FRAME_SIZE = 10;
20
+
21
+ const MAX_FRAME_SIZE = 65_536;
22
+
23
+ const ENCODER = new TextEncoder();
24
+
25
+ const DECODER = new TextDecoder();
26
+
27
+ export interface SourceRconFrame {
28
+ id: number;
29
+ type: number;
30
+ body: string;
31
+ }
32
+
33
+ export const encodeSourceRconFrame = (frame: SourceRconFrame) => {
34
+ const body = ENCODER.encode(frame.body);
35
+
36
+ if (body.length > SOURCE_RCON_BODY_LIMIT) {
37
+ throw new BridgeRconError(
38
+ BridgeRconCode.Oversized,
39
+ `an rcon body of ${body.length} bytes exceeds the ${SOURCE_RCON_BODY_LIMIT} byte limit`,
40
+ );
41
+ }
42
+
43
+ const size = 4 + 4 + body.length + 2;
44
+ const packet = new Uint8Array(HEADER_LENGTH + size);
45
+ const view = new DataView(packet.buffer);
46
+
47
+ view.setInt32(0, size, true);
48
+ view.setInt32(4, frame.id, true);
49
+ view.setInt32(8, frame.type, true);
50
+ packet.set(body, 12);
51
+
52
+ return packet;
53
+ };
54
+
55
+ export const decodeSourceRconFrames = (buffer: Uint8Array) => {
56
+ const frames: SourceRconFrame[] = [];
57
+
58
+ let offset = 0;
59
+
60
+ while (buffer.length - offset >= HEADER_LENGTH) {
61
+ const size = new DataView(buffer.buffer, buffer.byteOffset + offset, HEADER_LENGTH).getInt32(0, true);
62
+
63
+ if (size < MIN_FRAME_SIZE || size > MAX_FRAME_SIZE) {
64
+ throw new BridgeRconError(BridgeRconCode.Malformed, `an rcon packet declares an impossible size of ${size}`);
65
+ }
66
+
67
+ if (buffer.length - offset - HEADER_LENGTH < size) {
68
+ break;
69
+ }
70
+
71
+ const head = new DataView(buffer.buffer, buffer.byteOffset + offset + HEADER_LENGTH, 8);
72
+
73
+ frames.push({
74
+ id: head.getInt32(0, true),
75
+ type: head.getInt32(4, true),
76
+ body: DECODER.decode(buffer.subarray(offset + 12, offset + HEADER_LENGTH + size - 2)),
77
+ });
78
+
79
+ offset += HEADER_LENGTH + size;
80
+ }
81
+
82
+ return {
83
+ frames,
84
+ rest: buffer.subarray(offset),
85
+ };
86
+ };
87
+
88
+ export const concatBytes = (left: Uint8Array, right: Uint8Array) => {
89
+ if (left.length === 0) {
90
+ return right;
91
+ }
92
+
93
+ const joined = new Uint8Array(left.length + right.length);
94
+
95
+ joined.set(left);
96
+ joined.set(right, left.length);
97
+
98
+ return joined;
99
+ };
@@ -1,4 +1,17 @@
1
- import { mergeJson, mergeProperties, mergeUeIni, parseJson, parseProperties, parseUeIni } from "../codecs";
1
+ import {
2
+ mergeIni,
3
+ mergeJson,
4
+ mergeProperties,
5
+ mergeSourceCfg,
6
+ mergeUeIni,
7
+ mergeXmlProperties,
8
+ parseIni,
9
+ parseJson,
10
+ parseProperties,
11
+ parseSourceCfg,
12
+ parseUeIni,
13
+ parseXmlProperties,
14
+ } from "../codecs";
2
15
  import type { BridgeCodec, BridgeCodecOptions, BridgeCodecs, BridgeFiles } from "../context";
3
16
  import type { BridgeValue, BridgeValues } from "../core";
4
17
 
@@ -70,9 +83,39 @@ export const createBridgeCodecs = (files: BridgeFiles): BridgeCodecs => {
70
83
  },
71
84
  };
72
85
 
86
+ const ini: BridgeCodec = {
87
+ async read(path, options) {
88
+ return widen(parseIni(await readOr(files, path), options?.section));
89
+ },
90
+ async merge(path, values, options) {
91
+ await files.write(path, mergeIni(await readOr(files, path), toStrings(values), options?.section));
92
+ },
93
+ };
94
+
95
+ const sourceCfg: BridgeCodec = {
96
+ async read(path) {
97
+ return parseSourceCfg(await readOr(files, path));
98
+ },
99
+ async merge(path, values) {
100
+ await files.write(path, mergeSourceCfg(await readOr(files, path), values));
101
+ },
102
+ };
103
+
104
+ const xmlProperties: BridgeCodec = {
105
+ async read(path) {
106
+ return widen(parseXmlProperties(await readOr(files, path)));
107
+ },
108
+ async merge(path, values) {
109
+ await files.write(path, mergeXmlProperties(await readOr(files, path), toStrings(values)));
110
+ },
111
+ };
112
+
73
113
  return {
74
- properties,
114
+ ini,
75
115
  json,
116
+ properties,
117
+ sourceCfg,
76
118
  ueIni,
119
+ xmlProperties,
77
120
  };
78
121
  };
@@ -7,10 +7,11 @@ import { configPath } from "./bridgeConfigReader";
7
7
  import { createBridgeEgress } from "./bridgeEgress";
8
8
  import { createBridgeExec } from "./bridgeExec";
9
9
  import { createBridgeFiles } from "./bridgeFiles";
10
+ import { createBridgeLiveConfig } from "./bridgeLiveConfig";
10
11
  import type { BridgeLeveledLogger } from "./bridgeLog";
11
12
  import { createBridgeNet } from "./bridgeNet";
12
13
  import { createBridgeProbes } from "./bridgeProbe";
13
- import { createBridgeSecrets } from "./bridgeSecrets";
14
+ import { createBridgeRcons } from "./bridgeRcon";
14
15
  import type { BridgeSupervisor } from "./bridgeSupervisor";
15
16
 
16
17
  export interface ContextHooks {
@@ -26,7 +27,7 @@ export const createContext = (
26
27
  ): BridgeContext => {
27
28
  const egress = createBridgeEgress(config);
28
29
 
29
- const secrets = createBridgeSecrets(configPath(), config);
30
+ const live = createBridgeLiveConfig(configPath(), config);
30
31
 
31
32
  const files = createBridgeFiles({
32
33
  root: config.cwd,
@@ -39,19 +40,23 @@ export const createContext = (
39
40
  return {
40
41
  server: {
41
42
  code: config.code,
42
- memoryMb: config.memoryMb,
43
- cpuPercent: config.cpuPercent,
43
+ get memoryMb() {
44
+ return live.memoryMb();
45
+ },
46
+ get cpuPercent() {
47
+ return live.cpuPercent();
48
+ },
44
49
  get running() {
45
50
  return supervisor.running;
46
51
  },
47
52
  },
48
53
 
49
54
  variable(key) {
50
- return config.variables[key] ?? null;
55
+ return live.variable(key);
51
56
  },
52
57
 
53
58
  secret(key) {
54
- return secrets.read(key);
59
+ return live.secret(key);
55
60
  },
56
61
 
57
62
  port(key) {
@@ -72,6 +77,8 @@ export const createContext = (
72
77
 
73
78
  probe: createBridgeProbes(),
74
79
 
80
+ rcon: createBridgeRcons(),
81
+
75
82
  async command(input, options) {
76
83
  return await supervisor.command(input, options);
77
84
  },
@@ -232,6 +232,7 @@ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
232
232
  },
233
233
  join: PortJoin.Combined,
234
234
  activeWhen,
235
+ mirror: false,
235
236
  });
236
237
 
237
238
  const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { BridgeConfig } from "../protocol";
6
+ import { createBridgeLiveConfig } from "./bridgeLiveConfig";
7
+
8
+ const configWith = (secrets: Record<string, string>, variables: Record<string, string> = {}): BridgeConfig => ({
9
+ code: "wsvv8fakxm",
10
+ token: "token",
11
+ cwd: "/data",
12
+ run: "/run/bridge",
13
+ ports: {
14
+ game: 25_565,
15
+ },
16
+ memoryMb: 2048,
17
+ cpuPercent: 100,
18
+ gameUid: 1000,
19
+ gameGid: 1000,
20
+ variables: {
21
+ VERSION: "1.21",
22
+ ...variables,
23
+ },
24
+ secrets,
25
+ });
26
+
27
+ let directory: string;
28
+
29
+ let path: string;
30
+
31
+ const write = async (config: BridgeConfig) => {
32
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
33
+ };
34
+
35
+ beforeEach(async () => {
36
+ directory = await mkdtemp(join(tmpdir(), "bridge-live-config-"));
37
+ path = join(directory, "config.json");
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await rm(directory, {
42
+ force: true,
43
+ recursive: true,
44
+ });
45
+ });
46
+
47
+ describe("the bridge live config reader", () => {
48
+ test("answers from the boot snapshot", async () => {
49
+ const config = configWith({
50
+ CURSEFORGE_API_KEY: "boot",
51
+ });
52
+
53
+ await write(config);
54
+
55
+ const secrets = createBridgeLiveConfig(path, config, 0);
56
+
57
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
58
+ expect(secrets.secret("MODRINTH_API_KEY")).toBeNull();
59
+ });
60
+
61
+ test("sees a value the agent rewrote into the config", async () => {
62
+ const config = configWith({
63
+ CURSEFORGE_API_KEY: "boot",
64
+ });
65
+
66
+ await write(config);
67
+
68
+ const secrets = createBridgeLiveConfig(path, config, 0);
69
+
70
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
71
+
72
+ await write(
73
+ configWith({
74
+ CURSEFORGE_API_KEY: "rotated",
75
+ MODRINTH_API_KEY: "added",
76
+ }),
77
+ );
78
+
79
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("rotated");
80
+ expect(secrets.secret("MODRINTH_API_KEY")).toBe("added");
81
+ });
82
+
83
+ test("keeps the last good values while the config is mid-write", async () => {
84
+ const config = configWith({
85
+ CURSEFORGE_API_KEY: "boot",
86
+ });
87
+
88
+ await write(config);
89
+
90
+ const secrets = createBridgeLiveConfig(path, config, 0);
91
+
92
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
93
+
94
+ await writeFile(path, '{"code": "wsvv8fak');
95
+
96
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
97
+
98
+ await rm(path);
99
+
100
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
101
+ });
102
+
103
+ test("re-reads the file at most once per refresh window", async () => {
104
+ const config = configWith({
105
+ CURSEFORGE_API_KEY: "boot",
106
+ });
107
+
108
+ await write(config);
109
+
110
+ const secrets = createBridgeLiveConfig(path, config, 60_000);
111
+
112
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
113
+
114
+ await write(
115
+ configWith({
116
+ CURSEFORGE_API_KEY: "rotated",
117
+ }),
118
+ );
119
+
120
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
121
+ });
122
+
123
+ test("sees a variable the customer changed after boot, and the new plan resources", async () => {
124
+ const config = configWith({});
125
+
126
+ await write(config);
127
+
128
+ const live = createBridgeLiveConfig(path, config, 0);
129
+
130
+ expect(live.variable("CROSSPLAY")).toBeNull();
131
+ expect(live.memoryMb()).toBe(2048);
132
+
133
+ await write({
134
+ ...configWith(
135
+ {},
136
+ {
137
+ CROSSPLAY: "true",
138
+ },
139
+ ),
140
+ memoryMb: 4096,
141
+ cpuPercent: 200,
142
+ });
143
+
144
+ expect(live.variable("CROSSPLAY")).toBe("true");
145
+ expect(live.variable("VERSION")).toBe("1.21");
146
+ expect(live.memoryMb()).toBe(4096);
147
+ expect(live.cpuPercent()).toBe(200);
148
+ });
149
+ });
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { BridgeConfig } from "../protocol";
3
+ import { parseConfig } from "./bridgeConfigReader";
4
+
5
+ export const BRIDGE_CONFIG_REFRESH_MS = 2000;
6
+
7
+ /**
8
+ * The agent rewrites the bridge config in place when the customer changes a
9
+ * variable, a secret or the plan, and then restarts the game rather than the
10
+ * container. The bridge process outlives that rewrite, so anything answered
11
+ * from the boot snapshot would be stale. Every read here re-checks the file,
12
+ * throttled to one read per refresh window, and keeps the last good values
13
+ * while the agent is mid-write.
14
+ */
15
+ export interface BridgeLiveConfig {
16
+ variable: (key: string) => string | null;
17
+ secret: (key: string) => string | null;
18
+ memoryMb: () => number;
19
+ cpuPercent: () => number;
20
+ }
21
+
22
+ export const createBridgeLiveConfig = (
23
+ path: string,
24
+ config: BridgeConfig,
25
+ refreshMs: number = BRIDGE_CONFIG_REFRESH_MS,
26
+ ): BridgeLiveConfig => {
27
+ let current = config;
28
+
29
+ let checkedAt: number | null = null;
30
+
31
+ const refresh = () => {
32
+ const stamp = performance.now();
33
+
34
+ if (checkedAt !== null && stamp - checkedAt < refreshMs) {
35
+ return current;
36
+ }
37
+
38
+ checkedAt = stamp;
39
+
40
+ try {
41
+ current = parseConfig(readFileSync(path, "utf8"));
42
+ } catch {
43
+ return current;
44
+ }
45
+
46
+ return current;
47
+ };
48
+
49
+ return {
50
+ variable(key) {
51
+ return refresh().variables[key] ?? null;
52
+ },
53
+ secret(key) {
54
+ return refresh().secrets[key] ?? null;
55
+ },
56
+ memoryMb() {
57
+ return refresh().memoryMb;
58
+ },
59
+ cpuPercent() {
60
+ return refresh().cpuPercent;
61
+ },
62
+ };
63
+ };
@@ -0,0 +1,15 @@
1
+ import type { BridgeRcons } from "../context";
2
+ import { sourceRcon } from "../rcon";
3
+
4
+ const RCON_HOST = "127.0.0.1";
5
+
6
+ export const createBridgeRcons = (): BridgeRcons => {
7
+ return {
8
+ async source(options) {
9
+ return await sourceRcon({
10
+ ...options,
11
+ host: RCON_HOST,
12
+ });
13
+ },
14
+ };
15
+ };
@@ -92,6 +92,7 @@ const withVariables = (context: BridgeContext, overlay: Record<string, string>):
92
92
  codec: context.codec,
93
93
  net: context.net,
94
94
  probe: context.probe,
95
+ rcon: context.rcon,
95
96
  command(input, options) {
96
97
  return context.command(input, options);
97
98
  },
@@ -0,0 +1,7 @@
1
+ export * from "./steamApp";
2
+ export * from "./steamcmd";
3
+ export * from "./steamcmdLogin";
4
+ export * from "./steamcmdNarrator";
5
+ export * from "./steamcmdPlatform";
6
+ export * from "./webApi";
7
+ export * from "./workshop";
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import { appManifestPath, buildIdOf, installedBuildId, missingGameRoots } from "./steamApp";
4
+
5
+ const MANIFEST = [
6
+ '"AppState"',
7
+ "{",
8
+ '\t"appid"\t\t"2394010"',
9
+ '\t"buildid"\t\t"19582821"',
10
+ "}",
11
+ ].join("\n");
12
+
13
+ const testContext = (files: Record<string, string>) => {
14
+ return {
15
+ files: {
16
+ async exists(path: string) {
17
+ return path in files;
18
+ },
19
+ async read(path: string) {
20
+ return files[path] ?? "";
21
+ },
22
+ },
23
+ } as unknown as BridgeContext;
24
+ };
25
+
26
+ describe("appManifestPath", () => {
27
+ test("points at the steamapps manifest", () => {
28
+ expect(appManifestPath("2394010")).toBe("steamapps/appmanifest_2394010.acf");
29
+ });
30
+
31
+ test("accepts another steamapps directory", () => {
32
+ expect(appManifestPath("90", ".steam/steamapps")).toBe(".steam/steamapps/appmanifest_90.acf");
33
+ });
34
+ });
35
+
36
+ describe("buildIdOf", () => {
37
+ test("reads the build id out of an acf manifest", () => {
38
+ expect(buildIdOf(MANIFEST)).toBe("19582821");
39
+ });
40
+
41
+ test("returns null when there is no build id", () => {
42
+ expect(buildIdOf('"AppState"\n{\n}\n')).toBeNull();
43
+ expect(buildIdOf("")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("installedBuildId", () => {
48
+ test("returns null when the app was never installed", async () => {
49
+ expect(await installedBuildId(testContext({}), "2394010")).toBeNull();
50
+ });
51
+
52
+ test("reads the installed build id", async () => {
53
+ const context = testContext({
54
+ "steamapps/appmanifest_2394010.acf": MANIFEST,
55
+ });
56
+
57
+ expect(await installedBuildId(context, "2394010")).toBe("19582821");
58
+ });
59
+ });
60
+
61
+ describe("missingGameRoots", () => {
62
+ test("names only the paths that are absent", async () => {
63
+ const context = testContext({
64
+ "PalServer.sh": "",
65
+ Engine: "",
66
+ });
67
+
68
+ expect(
69
+ await missingGameRoots(context, [
70
+ "PalServer.sh",
71
+ "Pal/Content",
72
+ "Engine",
73
+ ]),
74
+ ).toEqual([
75
+ "Pal/Content",
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,35 @@
1
+ import type { BridgeContext } from "../context";
2
+
3
+ export const STEAMAPPS_DIRECTORY = "steamapps";
4
+
5
+ const BUILD_ID = /"buildid"\s+"(?<buildId>\d+)"/;
6
+
7
+ export const appManifestPath = (appId: string, directory: string = STEAMAPPS_DIRECTORY) => {
8
+ return `${directory}/appmanifest_${appId}.acf`;
9
+ };
10
+
11
+ export const buildIdOf = (manifest: string) => {
12
+ return manifest.match(BUILD_ID)?.groups?.buildId ?? null;
13
+ };
14
+
15
+ export const installedBuildId = async (context: BridgeContext, appId: string, directory?: string) => {
16
+ const path = appManifestPath(appId, directory);
17
+
18
+ if (!(await context.files.exists(path))) {
19
+ return null;
20
+ }
21
+
22
+ return buildIdOf(await context.files.read(path));
23
+ };
24
+
25
+ export const missingGameRoots = async (context: BridgeContext, roots: string[]) => {
26
+ const missing: string[] = [];
27
+
28
+ for (const path of roots) {
29
+ if (!(await context.files.exists(path))) {
30
+ missing.push(path);
31
+ }
32
+ }
33
+
34
+ return missing;
35
+ };