@serverkgg/bridge 0.0.11 → 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.
- package/package.json +11 -1
- package/src/blog/articleFrontmatter.test.ts +95 -0
- package/src/blog/articleFrontmatter.ts +99 -0
- package/src/blog/compile.test.ts +148 -0
- package/src/blog/compile.ts +172 -0
- package/src/blog/compiled.ts +22 -0
- package/src/blog/index.ts +3 -0
- package/src/catalogs/curseforge.test.ts +580 -0
- package/src/catalogs/curseforge.ts +461 -0
- package/src/catalogs/index.ts +2 -0
- package/src/catalogs/thunderstore.test.ts +94 -0
- package/src/catalogs/thunderstore.ts +239 -0
- package/src/cli/validate.ts +7 -0
- package/src/codecs/index.ts +3 -0
- package/src/codecs/ini.test.ts +152 -0
- package/src/codecs/ini.ts +158 -0
- package/src/codecs/sourceCfg.test.ts +100 -0
- package/src/codecs/sourceCfg.ts +111 -0
- package/src/codecs/xmlProperties.test.ts +104 -0
- package/src/codecs/xmlProperties.ts +133 -0
- package/src/context/bridgeContext.ts +40 -0
- package/src/core/bridgeRconError.ts +19 -0
- package/src/core/index.ts +1 -0
- package/src/guides/compile.ts +70 -54
- package/src/install/index.ts +1 -0
- package/src/install/installStamp.test.ts +125 -0
- package/src/install/installStamp.ts +40 -0
- package/src/main.ts +1 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +460 -2
- package/src/manifest/manifest.ts +141 -13
- package/src/manifest/primitives.ts +6 -0
- package/src/manifest/runtime.ts +89 -0
- package/src/net/index.ts +2 -0
- package/src/net/loopback.test.ts +152 -0
- package/src/net/loopback.ts +109 -0
- package/src/net/loopbackMethod.ts +7 -0
- package/src/presence/index.ts +1 -0
- package/src/presence/rosterSync.test.ts +179 -0
- package/src/presence/rosterSync.ts +57 -0
- package/src/probes/index.ts +9 -0
- package/src/probes/minecraftPing.test.ts +344 -0
- package/src/probes/minecraftPing.ts +283 -0
- package/src/probes/raknetPing.test.ts +197 -0
- package/src/probes/raknetPing.ts +228 -0
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/rcon/index.ts +3 -0
- package/src/rcon/sourceRcon.test.ts +308 -0
- package/src/rcon/sourceRcon.ts +276 -0
- package/src/rcon/sourceRconFrame.test.ts +152 -0
- package/src/rcon/sourceRconFrame.ts +99 -0
- package/src/runtime/bridgeCodec.ts +45 -2
- package/src/runtime/bridgeContext.ts +13 -6
- package/src/runtime/bridgeDriver.test.ts +1 -0
- package/src/runtime/bridgeLiveConfig.test.ts +149 -0
- package/src/runtime/bridgeLiveConfig.ts +63 -0
- package/src/runtime/bridgeProbe.ts +13 -1
- package/src/runtime/bridgeRcon.ts +15 -0
- package/src/runtime/bridgeRegistry.ts +1 -0
- package/src/steam/index.ts +7 -0
- package/src/steam/steamApp.test.ts +78 -0
- package/src/steam/steamApp.ts +35 -0
- package/src/steam/steamcmd.test.ts +157 -0
- package/src/steam/steamcmd.ts +273 -0
- package/src/steam/steamcmdLogin.ts +34 -0
- package/src/steam/steamcmdNarrator.test.ts +90 -0
- package/src/steam/steamcmdNarrator.ts +43 -0
- package/src/steam/steamcmdPlatform.ts +4 -0
- package/src/steam/webApi.ts +70 -0
- package/src/steam/workshop.test.ts +45 -0
- package/src/steam/workshop.ts +148 -0
- package/src/utils/execDetail.ts +16 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/token.test.ts +50 -0
- package/src/utils/token.ts +35 -0
- package/src/wine/index.ts +3 -0
- package/src/wine/protonCommand.test.ts +67 -0
- package/src/wine/protonCommand.ts +49 -0
- package/src/wine/wineCommand.test.ts +92 -0
- package/src/wine/wineCommand.ts +54 -0
- package/src/wine/winePrefix.test.ts +111 -0
- package/src/wine/winePrefix.ts +88 -0
- package/src/runtime/bridgeSecrets.test.ts +0 -121
- package/src/runtime/bridgeSecrets.ts +0 -43
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import { BridgeFailureCode, BridgeFailureError, BridgeNetError } from "../core";
|
|
3
|
+
|
|
4
|
+
export const THUNDERSTORE_API = "https://thunderstore.io";
|
|
5
|
+
|
|
6
|
+
export const THUNDERSTORE_CDN = "https://gcdn.thunderstore.io";
|
|
7
|
+
|
|
8
|
+
export const THUNDERSTORE_PAGE_SIZE = 20;
|
|
9
|
+
|
|
10
|
+
const LISTING_CACHE_SECONDS = 60;
|
|
11
|
+
|
|
12
|
+
const PACKAGE_CACHE_SECONDS = 600;
|
|
13
|
+
|
|
14
|
+
const THROTTLED_STATUSES = [
|
|
15
|
+
429,
|
|
16
|
+
503,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export enum ThunderstoreSort {
|
|
20
|
+
LastUpdated = "last-updated",
|
|
21
|
+
MostDownloaded = "most-downloaded",
|
|
22
|
+
Newest = "newest",
|
|
23
|
+
TopRated = "top-rated",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ThunderstoreCategory {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ThunderstoreListing {
|
|
33
|
+
namespace: string;
|
|
34
|
+
name: string;
|
|
35
|
+
description: string;
|
|
36
|
+
icon_url: string | null;
|
|
37
|
+
download_count: number;
|
|
38
|
+
rating_count: number;
|
|
39
|
+
size: number;
|
|
40
|
+
last_updated: string | null;
|
|
41
|
+
datetime_created: string | null;
|
|
42
|
+
is_deprecated: boolean;
|
|
43
|
+
is_nsfw: boolean;
|
|
44
|
+
is_pinned: boolean;
|
|
45
|
+
categories: ThunderstoreCategory[];
|
|
46
|
+
community_identifier: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ThunderstoreVersion {
|
|
50
|
+
namespace: string;
|
|
51
|
+
name: string;
|
|
52
|
+
version_number: string;
|
|
53
|
+
full_name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
icon: string | null;
|
|
56
|
+
dependencies: string[];
|
|
57
|
+
download_url: string;
|
|
58
|
+
downloads: number;
|
|
59
|
+
date_created: string | null;
|
|
60
|
+
website_url: string | null;
|
|
61
|
+
is_active: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ThunderstorePackage {
|
|
65
|
+
namespace: string;
|
|
66
|
+
name: string;
|
|
67
|
+
full_name: string;
|
|
68
|
+
owner: string;
|
|
69
|
+
package_url: string;
|
|
70
|
+
date_created: string | null;
|
|
71
|
+
date_updated: string | null;
|
|
72
|
+
rating_score: number;
|
|
73
|
+
is_pinned: boolean;
|
|
74
|
+
is_deprecated: boolean;
|
|
75
|
+
total_downloads: number;
|
|
76
|
+
latest: ThunderstoreVersion;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ThunderstoreSearchQuery {
|
|
80
|
+
query?: string;
|
|
81
|
+
page?: number;
|
|
82
|
+
sort?: ThunderstoreSort;
|
|
83
|
+
includeDeprecated?: boolean;
|
|
84
|
+
includeNsfw?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ThunderstoreSearchPage {
|
|
88
|
+
count: number;
|
|
89
|
+
results: ThunderstoreListing[];
|
|
90
|
+
hasMore: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ThunderstoreManifest {
|
|
94
|
+
name: string;
|
|
95
|
+
version_number: string;
|
|
96
|
+
website_url?: string;
|
|
97
|
+
description?: string;
|
|
98
|
+
dependencies: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ThunderstoreCatalogOptions {
|
|
102
|
+
community: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ThunderstoreCatalog {
|
|
106
|
+
search(query?: ThunderstoreSearchQuery): Promise<ThunderstoreSearchPage>;
|
|
107
|
+
resolve(namespace: string, name: string): Promise<ThunderstorePackage>;
|
|
108
|
+
version(namespace: string, name: string, version: string): Promise<ThunderstoreVersion>;
|
|
109
|
+
download(version: ThunderstoreVersion): string;
|
|
110
|
+
dependencies(manifest: string): string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const thunderstoreDownloadUrl = (fullName: string) => {
|
|
114
|
+
return `${THUNDERSTORE_CDN}/live/repository/packages/${encodeURIComponent(fullName)}.zip`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const parseThunderstoreManifest = (manifest: string): ThunderstoreManifest | null => {
|
|
118
|
+
try {
|
|
119
|
+
const parsed: unknown = JSON.parse(manifest);
|
|
120
|
+
|
|
121
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const shape = parsed as Partial<ThunderstoreManifest>;
|
|
126
|
+
|
|
127
|
+
if (typeof shape.name !== "string" || typeof shape.version_number !== "string") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...shape,
|
|
133
|
+
name: shape.name,
|
|
134
|
+
version_number: shape.version_number,
|
|
135
|
+
dependencies: Array.isArray(shape.dependencies)
|
|
136
|
+
? shape.dependencies.filter((entry): entry is string => typeof entry === "string")
|
|
137
|
+
: [],
|
|
138
|
+
};
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const thunderstoreDependency = (reference: string) => {
|
|
145
|
+
const parts = reference.split("-");
|
|
146
|
+
const version = parts.at(2);
|
|
147
|
+
const name = parts.at(1);
|
|
148
|
+
const namespace = parts.at(0);
|
|
149
|
+
|
|
150
|
+
if (parts.length !== 3 || !namespace || !name || !version) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
namespace,
|
|
156
|
+
name,
|
|
157
|
+
version,
|
|
158
|
+
fullName: reference,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const listingQuery = (query: ThunderstoreSearchQuery) => {
|
|
163
|
+
const search = new URLSearchParams({
|
|
164
|
+
page: String(Math.max(query.page ?? 1, 1)),
|
|
165
|
+
deprecated: query.includeDeprecated === true ? "True" : "False",
|
|
166
|
+
nsfw: query.includeNsfw === true ? "True" : "False",
|
|
167
|
+
ordering: query.sort ?? ThunderstoreSort.MostDownloaded,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (query.query !== undefined && query.query.length > 0) {
|
|
171
|
+
search.set("q", query.query);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return search.toString();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const thunderstoreCatalog = (
|
|
178
|
+
context: BridgeContext,
|
|
179
|
+
options: ThunderstoreCatalogOptions,
|
|
180
|
+
): ThunderstoreCatalog => {
|
|
181
|
+
const community = encodeURIComponent(options.community);
|
|
182
|
+
|
|
183
|
+
const request = async <Result>(url: string, cacheSeconds: number): Promise<Result> => {
|
|
184
|
+
try {
|
|
185
|
+
return await context.net.json<Result>(url, {
|
|
186
|
+
cacheSeconds,
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof BridgeNetError && error.status !== null && THROTTLED_STATUSES.includes(error.status)) {
|
|
190
|
+
throw new BridgeFailureError(
|
|
191
|
+
BridgeFailureCode.CatalogRateLimited,
|
|
192
|
+
"thunderstore asked us to slow down, wait a moment and try again",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
async search(query = {}) {
|
|
202
|
+
const page = await request<{
|
|
203
|
+
count: number;
|
|
204
|
+
next: string | null;
|
|
205
|
+
results: ThunderstoreListing[];
|
|
206
|
+
}>(`${THUNDERSTORE_API}/api/cyberstorm/listing/${community}/?${listingQuery(query)}`, LISTING_CACHE_SECONDS);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
count: page.count,
|
|
210
|
+
results: page.results,
|
|
211
|
+
hasMore: page.next !== null,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async resolve(namespace, name) {
|
|
216
|
+
return await request<ThunderstorePackage>(
|
|
217
|
+
`${THUNDERSTORE_API}/api/experimental/package/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/`,
|
|
218
|
+
PACKAGE_CACHE_SECONDS,
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async version(namespace, name, version) {
|
|
223
|
+
return await request<ThunderstoreVersion>(
|
|
224
|
+
`${THUNDERSTORE_API}/api/experimental/package/${encodeURIComponent(namespace)}/${encodeURIComponent(
|
|
225
|
+
name,
|
|
226
|
+
)}/${encodeURIComponent(version)}/`,
|
|
227
|
+
PACKAGE_CACHE_SECONDS,
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
download(version) {
|
|
232
|
+
return thunderstoreDownloadUrl(version.full_name);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
dependencies(manifest) {
|
|
236
|
+
return parseThunderstoreManifest(manifest)?.dependencies ?? [];
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
};
|
package/src/cli/validate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { type ArticlePack, compileArticles } from "../blog";
|
|
6
7
|
import { GuideOpenTab, type GuideOpenTarget, type GuidePack, readGuidePack, readGuidePackage } from "../guides";
|
|
7
8
|
import {
|
|
8
9
|
declaredFiles,
|
|
@@ -131,6 +132,10 @@ const reportGuides = (pack: GuidePack | null) => {
|
|
|
131
132
|
}
|
|
132
133
|
};
|
|
133
134
|
|
|
135
|
+
const reportArticles = (pack: ArticlePack) => {
|
|
136
|
+
console.log(` articles: ${pack.drafts.length} article(s), ${pack.images.length} image(s)`);
|
|
137
|
+
};
|
|
138
|
+
|
|
134
139
|
const validateGuidePackage = (source: PackageSource, document: GuidePackage) => {
|
|
135
140
|
const guideFiles = document.guides.flatMap((guide) => [
|
|
136
141
|
guide.bodyPath.ar,
|
|
@@ -144,9 +149,11 @@ const validateGuidePackage = (source: PackageSource, document: GuidePackage) =>
|
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
const pack = readGuidePackage(source, document);
|
|
152
|
+
const articles = compileArticles(source);
|
|
147
153
|
|
|
148
154
|
console.log(`${document.metadata.id} is valid`);
|
|
149
155
|
reportGuides(pack);
|
|
156
|
+
reportArticles(articles);
|
|
150
157
|
};
|
|
151
158
|
|
|
152
159
|
const ACTION_TARGET_BUDGET = 80;
|
package/src/codecs/index.ts
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeIni, parseIni } from "./ini";
|
|
3
|
+
|
|
4
|
+
const SAMPLE = [
|
|
5
|
+
"; the server",
|
|
6
|
+
"[Server]",
|
|
7
|
+
"name=serverk",
|
|
8
|
+
"# a note",
|
|
9
|
+
"maxPlayers = 10",
|
|
10
|
+
"",
|
|
11
|
+
"[Admin]",
|
|
12
|
+
"password=secret",
|
|
13
|
+
"",
|
|
14
|
+
].join("\n");
|
|
15
|
+
|
|
16
|
+
describe("parseIni", () => {
|
|
17
|
+
test("reads the keys of one section", () => {
|
|
18
|
+
expect(parseIni(SAMPLE, "Server")).toEqual({
|
|
19
|
+
name: "serverk",
|
|
20
|
+
maxPlayers: "10",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reads another section without leaking keys", () => {
|
|
25
|
+
expect(parseIni(SAMPLE, "Admin")).toEqual({
|
|
26
|
+
password: "secret",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("reads the keys before the first header when no section is asked for", () => {
|
|
31
|
+
expect(parseIni("welcome=hello\n[Server]\nname=x\n")).toEqual({
|
|
32
|
+
welcome: "hello",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("keeps the last of a repeated key", () => {
|
|
37
|
+
expect(parseIni("[S]\nmod=a\nmod=b\n", "S")).toEqual({
|
|
38
|
+
mod: "b",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns nothing for an unknown section", () => {
|
|
43
|
+
expect(parseIni(SAMPLE, "Nothing")).toEqual({});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("mergeIni", () => {
|
|
48
|
+
test("rewrites a value in place and keeps comments and order", () => {
|
|
49
|
+
expect(
|
|
50
|
+
mergeIni(
|
|
51
|
+
SAMPLE,
|
|
52
|
+
{
|
|
53
|
+
name: "بيتنا",
|
|
54
|
+
},
|
|
55
|
+
"Server",
|
|
56
|
+
),
|
|
57
|
+
).toBe(
|
|
58
|
+
[
|
|
59
|
+
"; the server",
|
|
60
|
+
"[Server]",
|
|
61
|
+
"name=بيتنا",
|
|
62
|
+
"# a note",
|
|
63
|
+
"maxPlayers = 10",
|
|
64
|
+
"",
|
|
65
|
+
"[Admin]",
|
|
66
|
+
"password=secret",
|
|
67
|
+
"",
|
|
68
|
+
].join("\n"),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("appends an unknown key at the end of its section", () => {
|
|
73
|
+
expect(
|
|
74
|
+
mergeIni(
|
|
75
|
+
SAMPLE,
|
|
76
|
+
{
|
|
77
|
+
motd: "hi",
|
|
78
|
+
},
|
|
79
|
+
"Server",
|
|
80
|
+
),
|
|
81
|
+
).toBe(
|
|
82
|
+
[
|
|
83
|
+
"; the server",
|
|
84
|
+
"[Server]",
|
|
85
|
+
"name=serverk",
|
|
86
|
+
"# a note",
|
|
87
|
+
"maxPlayers = 10",
|
|
88
|
+
"motd=hi",
|
|
89
|
+
"[Admin]",
|
|
90
|
+
"password=secret",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n"),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("creates a section that does not exist yet", () => {
|
|
97
|
+
expect(
|
|
98
|
+
mergeIni(
|
|
99
|
+
"[Server]\nname=x\n",
|
|
100
|
+
{
|
|
101
|
+
level: "3",
|
|
102
|
+
},
|
|
103
|
+
"Game",
|
|
104
|
+
),
|
|
105
|
+
).toBe("[Server]\nname=x\n\n[Game]\nlevel=3\n");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("drops repeated occurrences of a key it rewrites", () => {
|
|
109
|
+
expect(
|
|
110
|
+
mergeIni(
|
|
111
|
+
"[S]\nmod=a\nmod=b\nother=1\n",
|
|
112
|
+
{
|
|
113
|
+
mod: "c",
|
|
114
|
+
},
|
|
115
|
+
"S",
|
|
116
|
+
),
|
|
117
|
+
).toBe("[S]\nmod=c\nother=1\n");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("writes into an empty file", () => {
|
|
121
|
+
expect(
|
|
122
|
+
mergeIni(
|
|
123
|
+
"",
|
|
124
|
+
{
|
|
125
|
+
name: "x",
|
|
126
|
+
},
|
|
127
|
+
"Server",
|
|
128
|
+
),
|
|
129
|
+
).toBe("[Server]\nname=x\n");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("writes preamble keys when no section is asked for", () => {
|
|
133
|
+
expect(
|
|
134
|
+
mergeIni("welcome=hello\n[Server]\nname=x\n", {
|
|
135
|
+
welcome: "salam",
|
|
136
|
+
extra: "1",
|
|
137
|
+
}),
|
|
138
|
+
).toBe("welcome=salam\nextra=1\n[Server]\nname=x\n");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("leaves other sections untouched", () => {
|
|
142
|
+
expect(
|
|
143
|
+
mergeIni(
|
|
144
|
+
"[A]\nkey=1\n[B]\nkey=2\n",
|
|
145
|
+
{
|
|
146
|
+
key: "9",
|
|
147
|
+
},
|
|
148
|
+
"B",
|
|
149
|
+
),
|
|
150
|
+
).toBe("[A]\nkey=1\n[B]\nkey=9\n");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const COMMENT_MARKERS = [
|
|
2
|
+
";",
|
|
3
|
+
"#",
|
|
4
|
+
];
|
|
5
|
+
|
|
6
|
+
const SEPARATOR = "=";
|
|
7
|
+
|
|
8
|
+
const PREAMBLE = "";
|
|
9
|
+
|
|
10
|
+
const isComment = (line: string) => {
|
|
11
|
+
const trimmed = line.trimStart();
|
|
12
|
+
|
|
13
|
+
return trimmed.length === 0 || COMMENT_MARKERS.some((marker) => trimmed.startsWith(marker));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isSectionHeader = (line: string) => {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
|
|
19
|
+
return trimmed.length > 2 && trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const sectionName = (line: string) => {
|
|
23
|
+
return line.trim().slice(1, -1).trim();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const entryKey = (line: string) => {
|
|
27
|
+
if (isComment(line) || isSectionHeader(line)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const separator = line.indexOf(SEPARATOR);
|
|
32
|
+
|
|
33
|
+
if (separator === -1) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const key = line.slice(0, separator).trim();
|
|
38
|
+
|
|
39
|
+
return key.length > 0 ? key : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const entryValue = (line: string) => {
|
|
43
|
+
return line.slice(line.indexOf(SEPARATOR) + 1).trim();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const trimTrailingBlanks = (lines: string[]) => {
|
|
47
|
+
while (lines.length > 0 && (lines.at(-1) ?? "").trim().length === 0) {
|
|
48
|
+
lines.pop();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const parseIni = (contents: string, section: string = PREAMBLE) => {
|
|
53
|
+
const values: Record<string, string> = {};
|
|
54
|
+
|
|
55
|
+
let current = PREAMBLE;
|
|
56
|
+
|
|
57
|
+
for (const line of contents.split("\n")) {
|
|
58
|
+
if (isSectionHeader(line)) {
|
|
59
|
+
current = sectionName(line);
|
|
60
|
+
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (current !== section) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const key = entryKey(line);
|
|
69
|
+
|
|
70
|
+
if (key !== null) {
|
|
71
|
+
values[key] = entryValue(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return values;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const mergeIni = (contents: string, values: Record<string, string>, section: string = PREAMBLE) => {
|
|
79
|
+
const pending = new Map(Object.entries(values));
|
|
80
|
+
const written = new Set<string>();
|
|
81
|
+
const lines = contents.length === 0 ? [] : contents.split("\n");
|
|
82
|
+
const output: string[] = [];
|
|
83
|
+
|
|
84
|
+
let current = PREAMBLE;
|
|
85
|
+
let inserted = false;
|
|
86
|
+
|
|
87
|
+
const appendPending = () => {
|
|
88
|
+
const missing = [
|
|
89
|
+
...pending,
|
|
90
|
+
].filter(([key]) => !written.has(key));
|
|
91
|
+
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
trimTrailingBlanks(output);
|
|
94
|
+
|
|
95
|
+
for (const [key, value] of missing) {
|
|
96
|
+
output.push(`${key}${SEPARATOR}${value}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
inserted = true;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (isSectionHeader(line)) {
|
|
105
|
+
if (current === section && !inserted) {
|
|
106
|
+
appendPending();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
current = sectionName(line);
|
|
110
|
+
output.push(line);
|
|
111
|
+
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (current !== section) {
|
|
116
|
+
output.push(line);
|
|
117
|
+
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const key = entryKey(line);
|
|
122
|
+
|
|
123
|
+
if (key === null || !pending.has(key)) {
|
|
124
|
+
output.push(line);
|
|
125
|
+
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (written.has(key)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
written.add(key);
|
|
134
|
+
output.push(`${key}${SEPARATOR}${pending.get(key)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (current === section && !inserted) {
|
|
138
|
+
appendPending();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!inserted) {
|
|
142
|
+
trimTrailingBlanks(output);
|
|
143
|
+
|
|
144
|
+
if (output.length > 0) {
|
|
145
|
+
output.push("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
output.push(`[${section}]`);
|
|
149
|
+
|
|
150
|
+
for (const [key, value] of pending) {
|
|
151
|
+
output.push(`${key}${SEPARATOR}${value}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
trimTrailingBlanks(output);
|
|
156
|
+
|
|
157
|
+
return `${output.join("\n")}\n`;
|
|
158
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSourceCfgValue,
|
|
4
|
+
encodeSourceCfgValue,
|
|
5
|
+
mergeSourceCfg,
|
|
6
|
+
parseSourceCfg,
|
|
7
|
+
readSourceCfgEntry,
|
|
8
|
+
} from "./sourceCfg";
|
|
9
|
+
|
|
10
|
+
describe("readSourceCfgEntry", () => {
|
|
11
|
+
test("splits a key from its value", () => {
|
|
12
|
+
expect(readSourceCfgEntry('hostname "serverk"')).toEqual({
|
|
13
|
+
key: "hostname",
|
|
14
|
+
value: '"serverk"',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("accepts a key with no value", () => {
|
|
19
|
+
expect(readSourceCfgEntry("sv_lan")).toEqual({
|
|
20
|
+
key: "sv_lan",
|
|
21
|
+
value: "",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("ignores comments and blank lines", () => {
|
|
26
|
+
expect(readSourceCfgEntry("// a comment")).toBeNull();
|
|
27
|
+
expect(readSourceCfgEntry("# a comment")).toBeNull();
|
|
28
|
+
expect(readSourceCfgEntry(" ")).toBeNull();
|
|
29
|
+
expect(readSourceCfgEntry('"quoted" 1')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("decodeSourceCfgValue", () => {
|
|
34
|
+
test("unquotes a string and unescapes it", () => {
|
|
35
|
+
expect(decodeSourceCfgValue('"a \\"b\\" c"')).toBe('a "b" c');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("reads booleans and numbers", () => {
|
|
39
|
+
expect(decodeSourceCfgValue("true")).toBe(true);
|
|
40
|
+
expect(decodeSourceCfgValue("false")).toBe(false);
|
|
41
|
+
expect(decodeSourceCfgValue("42")).toBe(42);
|
|
42
|
+
expect(decodeSourceCfgValue("-1.5")).toBe(-1.5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("leaves a bare word alone", () => {
|
|
46
|
+
expect(decodeSourceCfgValue("de_dust2")).toBe("de_dust2");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("encodeSourceCfgValue", () => {
|
|
51
|
+
test("quotes and escapes text", () => {
|
|
52
|
+
expect(encodeSourceCfgValue('a "b"')).toBe('"a \\"b\\""');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("flattens newlines", () => {
|
|
56
|
+
expect(encodeSourceCfgValue("a\nb")).toBe('"a b"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("writes booleans and numbers bare", () => {
|
|
60
|
+
expect(encodeSourceCfgValue(true)).toBe("true");
|
|
61
|
+
expect(encodeSourceCfgValue(7)).toBe("7");
|
|
62
|
+
expect(encodeSourceCfgValue(null)).toBe('""');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("parseSourceCfg", () => {
|
|
67
|
+
test("reads a source cfg file", () => {
|
|
68
|
+
expect(parseSourceCfg('// header\nhostname "serverk"\nsv_maxplayers 24\nsv_cheats false\n')).toEqual({
|
|
69
|
+
hostname: "serverk",
|
|
70
|
+
sv_maxplayers: 24,
|
|
71
|
+
sv_cheats: false,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("mergeSourceCfg", () => {
|
|
77
|
+
test("rewrites a value and keeps the comments", () => {
|
|
78
|
+
expect(
|
|
79
|
+
mergeSourceCfg('// header\nhostname "old"\nsv_maxplayers 24\n', {
|
|
80
|
+
hostname: "new",
|
|
81
|
+
}),
|
|
82
|
+
).toBe('// header\nhostname "new"\nsv_maxplayers 24\n');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("appends an unknown key", () => {
|
|
86
|
+
expect(
|
|
87
|
+
mergeSourceCfg('hostname "x"\n', {
|
|
88
|
+
rcon_password: "abc",
|
|
89
|
+
}),
|
|
90
|
+
).toBe('hostname "x"\nrcon_password "abc"\n');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("collapses a repeated key it rewrites", () => {
|
|
94
|
+
expect(
|
|
95
|
+
mergeSourceCfg("map de_dust2\nmap de_nuke\n", {
|
|
96
|
+
map: "de_inferno",
|
|
97
|
+
}),
|
|
98
|
+
).toBe('map "de_inferno"\n');
|
|
99
|
+
});
|
|
100
|
+
});
|