@serverkgg/bridge 0.0.2 → 0.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Serverk game bridge — driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,199 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ realpathSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { checkBridge, linkBridge, unlinkBridge } from "./link";
15
+
16
+ const SOURCE = "../serverk/modules/bridge";
17
+
18
+ describe("link", () => {
19
+ let root = "";
20
+ let cwd = "";
21
+ let source = "";
22
+ let target = "";
23
+
24
+ const writePackage = (dir: string, name: string, version: string) => {
25
+ mkdirSync(dir, {
26
+ recursive: true,
27
+ });
28
+ writeFileSync(
29
+ join(dir, "package.json"),
30
+ JSON.stringify({
31
+ name,
32
+ version,
33
+ }),
34
+ );
35
+ };
36
+
37
+ const link = (env: Record<string, string | undefined> = {}) => {
38
+ return linkBridge({
39
+ cwd,
40
+ source: SOURCE,
41
+ env,
42
+ });
43
+ };
44
+
45
+ beforeEach(() => {
46
+ root = realpathSync(mkdtempSync(`${tmpdir()}/serverk-link-`));
47
+ cwd = join(root, "game");
48
+ source = join(root, "serverk/modules/bridge");
49
+ target = join(cwd, "node_modules/@serverkgg/bridge");
50
+
51
+ mkdirSync(cwd, {
52
+ recursive: true,
53
+ });
54
+ });
55
+
56
+ afterEach(() => {
57
+ rmSync(root, {
58
+ force: true,
59
+ recursive: true,
60
+ });
61
+ });
62
+
63
+ test("skips when CI is set", () => {
64
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
65
+ writePackage(target, "@serverkgg/bridge", "0.0.2");
66
+
67
+ expect(
68
+ link({
69
+ CI: "true",
70
+ }),
71
+ ).toMatchObject({
72
+ kind: "skipped",
73
+ reason: "ci",
74
+ });
75
+
76
+ expect(lstatSync(target).isSymbolicLink()).toBe(false);
77
+ });
78
+
79
+ test("skips when the local checkout is missing", () => {
80
+ expect(link()).toMatchObject({
81
+ kind: "skipped",
82
+ reason: "source-missing",
83
+ });
84
+ });
85
+
86
+ test("skips when the local checkout is another package", () => {
87
+ writePackage(source, "@serverkgg/minecraft", "0.0.0");
88
+
89
+ expect(link()).toMatchObject({
90
+ kind: "skipped",
91
+ reason: "source-mismatch",
92
+ });
93
+ });
94
+
95
+ test("replaces the registry directory with a symlink", () => {
96
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
97
+ writePackage(target, "@serverkgg/bridge", "0.0.2");
98
+ writeFileSync(join(target, "installed.txt"), "registry");
99
+
100
+ expect(link()).toEqual({
101
+ kind: "linked",
102
+ source,
103
+ version: "0.0.3",
104
+ });
105
+
106
+ expect(lstatSync(target).isSymbolicLink()).toBe(true);
107
+ expect(realpathSync(target)).toBe(source);
108
+ expect(existsSync(join(target, "installed.txt"))).toBe(false);
109
+ expect(existsSync(join(source, "package.json"))).toBe(true);
110
+ });
111
+
112
+ test("creates the scope directory when nothing is installed", () => {
113
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
114
+
115
+ expect(link()).toMatchObject({
116
+ kind: "linked",
117
+ });
118
+
119
+ expect(realpathSync(target)).toBe(source);
120
+ });
121
+
122
+ test("is idempotent once linked", () => {
123
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
124
+
125
+ expect(link()).toMatchObject({
126
+ kind: "linked",
127
+ });
128
+
129
+ expect(link()).toEqual({
130
+ kind: "already-linked",
131
+ source,
132
+ });
133
+
134
+ expect(realpathSync(target)).toBe(source);
135
+ });
136
+
137
+ test("relinks a symlink pointing somewhere else", () => {
138
+ const other = join(root, "other");
139
+
140
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
141
+ writePackage(other, "@serverkgg/bridge", "0.0.1");
142
+ mkdirSync(join(cwd, "node_modules/@serverkgg"), {
143
+ recursive: true,
144
+ });
145
+ symlinkSync(other, target, "dir");
146
+
147
+ expect(link()).toMatchObject({
148
+ kind: "linked",
149
+ version: "0.0.3",
150
+ });
151
+
152
+ expect(realpathSync(target)).toBe(source);
153
+ expect(existsSync(join(other, "package.json"))).toBe(true);
154
+ });
155
+
156
+ test("unlink removes the symlink and keeps the checkout", () => {
157
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
158
+ link();
159
+
160
+ expect(unlinkBridge(cwd)).toEqual({
161
+ kind: "unlinked",
162
+ source,
163
+ });
164
+
165
+ expect(existsSync(target)).toBe(false);
166
+ expect(existsSync(join(source, "package.json"))).toBe(true);
167
+ });
168
+
169
+ test("unlink leaves a registry install alone", () => {
170
+ writePackage(target, "@serverkgg/bridge", "0.0.2");
171
+
172
+ expect(unlinkBridge(cwd)).toEqual({
173
+ kind: "not-linked",
174
+ });
175
+
176
+ expect(existsSync(join(target, "package.json"))).toBe(true);
177
+ });
178
+
179
+ test("check reports every state", () => {
180
+ expect(checkBridge(cwd)).toEqual({
181
+ kind: "missing",
182
+ });
183
+
184
+ writePackage(target, "@serverkgg/bridge", "0.0.2");
185
+
186
+ expect(checkBridge(cwd)).toEqual({
187
+ kind: "registry",
188
+ version: "0.0.2",
189
+ });
190
+
191
+ writePackage(source, "@serverkgg/bridge", "0.0.3");
192
+ link();
193
+
194
+ expect(checkBridge(cwd)).toEqual({
195
+ kind: "linked",
196
+ source,
197
+ });
198
+ });
199
+ });
@@ -0,0 +1,319 @@
1
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+
5
+ const PACKAGE_NAME = "@serverkgg/bridge";
6
+
7
+ const DEFAULT_SOURCE = "../serverk/modules/bridge";
8
+
9
+ const INSTALL_PATH = `node_modules/${PACKAGE_NAME}`;
10
+
11
+ const HELP = `serverk-bridge link [options]
12
+
13
+ point ${INSTALL_PATH} at a local bridge checkout without touching package.json or bun.lock.
14
+ does nothing when CI is set or the checkout is absent, so the registry package is used everywhere else.
15
+
16
+ options:
17
+ --source <dir> local bridge checkout (default: ${DEFAULT_SOURCE})
18
+ --check print which bridge is installed, change nothing
19
+ --unlink drop the link so the next install restores the registry package
20
+ --help show this help`;
21
+
22
+ interface PackageFile {
23
+ name: string;
24
+ version: string;
25
+ }
26
+
27
+ interface LinkInput {
28
+ cwd: string;
29
+ source: string;
30
+ env: Record<string, string | undefined>;
31
+ }
32
+
33
+ export type LinkSkip = "ci" | "source-missing" | "source-mismatch";
34
+
35
+ export type LinkResult =
36
+ | {
37
+ kind: "skipped";
38
+ reason: LinkSkip;
39
+ source: string;
40
+ }
41
+ | {
42
+ kind: "already-linked";
43
+ source: string;
44
+ }
45
+ | {
46
+ kind: "linked";
47
+ source: string;
48
+ version: string;
49
+ };
50
+
51
+ export type CheckResult =
52
+ | {
53
+ kind: "linked";
54
+ source: string;
55
+ }
56
+ | {
57
+ kind: "registry";
58
+ version: string;
59
+ }
60
+ | {
61
+ kind: "missing";
62
+ };
63
+
64
+ export type UnlinkResult =
65
+ | {
66
+ kind: "unlinked";
67
+ source: string;
68
+ }
69
+ | {
70
+ kind: "not-linked";
71
+ };
72
+
73
+ const readPackageFile = (dir: string): PackageFile | null => {
74
+ let text: string;
75
+
76
+ try {
77
+ text = readFileSync(join(dir, "package.json"), "utf8");
78
+ } catch {
79
+ return null;
80
+ }
81
+
82
+ let parsed: unknown;
83
+
84
+ try {
85
+ parsed = JSON.parse(text);
86
+ } catch {
87
+ return null;
88
+ }
89
+
90
+ if (typeof parsed !== "object" || parsed === null || !("name" in parsed) || !("version" in parsed)) {
91
+ return null;
92
+ }
93
+
94
+ const { name, version } = parsed;
95
+
96
+ if (typeof name !== "string" || typeof version !== "string") {
97
+ return null;
98
+ }
99
+
100
+ return {
101
+ name,
102
+ version,
103
+ };
104
+ };
105
+
106
+ const linkedSource = (target: string): string | null => {
107
+ try {
108
+ if (!lstatSync(target).isSymbolicLink()) {
109
+ return null;
110
+ }
111
+
112
+ return realpathSync(target);
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+
118
+ const installPath = (cwd: string) => {
119
+ return resolve(cwd, INSTALL_PATH);
120
+ };
121
+
122
+ export const linkBridge = (input: LinkInput): LinkResult => {
123
+ const source = resolve(input.cwd, input.source);
124
+
125
+ if (input.env.CI) {
126
+ return {
127
+ kind: "skipped",
128
+ reason: "ci",
129
+ source,
130
+ };
131
+ }
132
+
133
+ const local = readPackageFile(source);
134
+
135
+ if (!local) {
136
+ return {
137
+ kind: "skipped",
138
+ reason: "source-missing",
139
+ source,
140
+ };
141
+ }
142
+
143
+ if (local.name !== PACKAGE_NAME) {
144
+ return {
145
+ kind: "skipped",
146
+ reason: "source-mismatch",
147
+ source,
148
+ };
149
+ }
150
+
151
+ const target = installPath(input.cwd);
152
+
153
+ if (linkedSource(target) === realpathSync(source)) {
154
+ return {
155
+ kind: "already-linked",
156
+ source,
157
+ };
158
+ }
159
+
160
+ rmSync(target, {
161
+ force: true,
162
+ recursive: true,
163
+ });
164
+ mkdirSync(dirname(target), {
165
+ recursive: true,
166
+ });
167
+ symlinkSync(source, target, "dir");
168
+
169
+ return {
170
+ kind: "linked",
171
+ source,
172
+ version: local.version,
173
+ };
174
+ };
175
+
176
+ export const checkBridge = (cwd: string): CheckResult => {
177
+ const target = installPath(cwd);
178
+ const source = linkedSource(target);
179
+
180
+ if (source) {
181
+ return {
182
+ kind: "linked",
183
+ source,
184
+ };
185
+ }
186
+
187
+ const installed = readPackageFile(target);
188
+
189
+ if (!installed) {
190
+ return {
191
+ kind: "missing",
192
+ };
193
+ }
194
+
195
+ return {
196
+ kind: "registry",
197
+ version: installed.version,
198
+ };
199
+ };
200
+
201
+ export const unlinkBridge = (cwd: string): UnlinkResult => {
202
+ const target = installPath(cwd);
203
+ const source = linkedSource(target);
204
+
205
+ if (!source) {
206
+ return {
207
+ kind: "not-linked",
208
+ };
209
+ }
210
+
211
+ rmSync(target, {
212
+ force: true,
213
+ });
214
+
215
+ return {
216
+ kind: "unlinked",
217
+ source,
218
+ };
219
+ };
220
+
221
+ const linkText = (result: LinkResult): string | null => {
222
+ switch (result.kind) {
223
+ case "skipped": {
224
+ return null;
225
+ }
226
+
227
+ case "already-linked": {
228
+ return `${PACKAGE_NAME} already linked -> ${result.source}`;
229
+ }
230
+
231
+ case "linked": {
232
+ return `${PACKAGE_NAME} linked -> ${result.source} (${result.version})`;
233
+ }
234
+ }
235
+ };
236
+
237
+ const checkText = (result: CheckResult): string => {
238
+ switch (result.kind) {
239
+ case "linked": {
240
+ return `${PACKAGE_NAME} linked -> ${result.source}`;
241
+ }
242
+
243
+ case "registry": {
244
+ return `${PACKAGE_NAME} registry ${result.version}`;
245
+ }
246
+
247
+ case "missing": {
248
+ return `${PACKAGE_NAME} missing`;
249
+ }
250
+ }
251
+ };
252
+
253
+ const unlinkText = (result: UnlinkResult): string => {
254
+ switch (result.kind) {
255
+ case "unlinked": {
256
+ return `${PACKAGE_NAME} unlinked — run "bun install" to restore the registry package`;
257
+ }
258
+
259
+ case "not-linked": {
260
+ return `${PACKAGE_NAME} is not linked`;
261
+ }
262
+ }
263
+ };
264
+
265
+ export const runLink = async (args: string[]) => {
266
+ const { values } = parseArgs({
267
+ args,
268
+ options: {
269
+ source: {
270
+ type: "string",
271
+ default: DEFAULT_SOURCE,
272
+ },
273
+ check: {
274
+ type: "boolean",
275
+ default: false,
276
+ },
277
+ unlink: {
278
+ type: "boolean",
279
+ default: false,
280
+ },
281
+ help: {
282
+ type: "boolean",
283
+ default: false,
284
+ },
285
+ },
286
+ });
287
+
288
+ if (values.help) {
289
+ console.log(HELP);
290
+
291
+ return;
292
+ }
293
+
294
+ const cwd = process.cwd();
295
+
296
+ if (values.check) {
297
+ console.log(checkText(checkBridge(cwd)));
298
+
299
+ return;
300
+ }
301
+
302
+ if (values.unlink) {
303
+ console.log(unlinkText(unlinkBridge(cwd)));
304
+
305
+ return;
306
+ }
307
+
308
+ const message = linkText(
309
+ linkBridge({
310
+ cwd,
311
+ source: values.source,
312
+ env: process.env,
313
+ }),
314
+ );
315
+
316
+ if (message) {
317
+ console.log(message);
318
+ }
319
+ };
package/src/cli/main.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { errorText } from "../runtime/bridgeLog";
4
4
  import { runCompile } from "./compile";
5
+ import { runLink } from "./link";
5
6
  import { runSchema } from "./schema";
6
7
  import { runValidate } from "./validate";
7
8
 
@@ -11,6 +12,7 @@ commands:
11
12
  validate validate serverk.yml, assets, and driver wiring
12
13
  schema write the serverk.yml JSON Schema for editor tooling
13
14
  compile compile the bridge binary for amd64 and arm64
15
+ link use a local bridge checkout while developing
14
16
 
15
17
  run "serverk-bridge <command> --help" for options`;
16
18
 
@@ -36,6 +38,12 @@ try {
36
38
  break;
37
39
  }
38
40
 
41
+ case "link": {
42
+ await runLink(args);
43
+
44
+ break;
45
+ }
46
+
39
47
  case undefined:
40
48
  case "--help": {
41
49
  console.log(USAGE);
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runValidate } from "./validate";
6
+
7
+ describe("validate", () => {
8
+ let root = "";
9
+
10
+ const writePackage = (dependencies: Record<string, string>) => {
11
+ writeFileSync(
12
+ join(root, "package.json"),
13
+ JSON.stringify({
14
+ name: "@serverkgg/minecraft",
15
+ dependencies,
16
+ }),
17
+ );
18
+ };
19
+
20
+ const validate = () => {
21
+ return runValidate([
22
+ "--dir",
23
+ root,
24
+ ]);
25
+ };
26
+
27
+ beforeEach(() => {
28
+ root = mkdtempSync(`${tmpdir()}/serverk-validate-`);
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(root, {
33
+ force: true,
34
+ recursive: true,
35
+ });
36
+ });
37
+
38
+ test("refuses a file: bridge dependency", async () => {
39
+ writePackage({
40
+ "@serverkgg/bridge": "file:../serverk/modules/bridge",
41
+ });
42
+
43
+ await expect(validate()).rejects.toThrow(
44
+ 'package.json depends on @serverkgg/bridge via "file:../serverk/modules/bridge"',
45
+ );
46
+ });
47
+
48
+ test("refuses a link: bridge dependency", async () => {
49
+ writePackage({
50
+ "@serverkgg/bridge": "link:@serverkgg/bridge",
51
+ });
52
+
53
+ await expect(validate()).rejects.toThrow('depends on @serverkgg/bridge via "link:@serverkgg/bridge"');
54
+ });
55
+
56
+ test("refuses a file: bridge devDependency", async () => {
57
+ writeFileSync(
58
+ join(root, "package.json"),
59
+ JSON.stringify({
60
+ name: "@serverkgg/platform",
61
+ devDependencies: {
62
+ "@serverkgg/bridge": "file:../serverk/modules/bridge",
63
+ },
64
+ }),
65
+ );
66
+
67
+ await expect(validate()).rejects.toThrow("pin a published version");
68
+ });
69
+
70
+ test("accepts a published bridge dependency and moves on to the manifest", async () => {
71
+ writePackage({
72
+ "@serverkgg/bridge": "0.0.3",
73
+ });
74
+
75
+ await expect(validate()).rejects.toThrow("missing serverk.yml");
76
+ });
77
+
78
+ test("ignores a package without a bridge dependency", async () => {
79
+ writePackage({
80
+ zod: "4.4.3",
81
+ });
82
+
83
+ await expect(validate()).rejects.toThrow("missing serverk.yml");
84
+ });
85
+
86
+ test("ignores a package.json that is absent", async () => {
87
+ await expect(validate()).rejects.toThrow("missing serverk.yml");
88
+ });
89
+ });