llng-mcp 0.1.0

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 (114) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.prettierrc +7 -0
  3. package/LICENSE +661 -0
  4. package/README.md +502 -0
  5. package/dist/__tests__/api-transport.test.d.ts +1 -0
  6. package/dist/__tests__/api-transport.test.js +577 -0
  7. package/dist/__tests__/api-transport.test.js.map +1 -0
  8. package/dist/__tests__/config.test.d.ts +1 -0
  9. package/dist/__tests__/config.test.js +472 -0
  10. package/dist/__tests__/config.test.js.map +1 -0
  11. package/dist/__tests__/integration/api-mode.test.d.ts +1 -0
  12. package/dist/__tests__/integration/api-mode.test.js +199 -0
  13. package/dist/__tests__/integration/api-mode.test.js.map +1 -0
  14. package/dist/__tests__/integration/oidc-rp.test.d.ts +1 -0
  15. package/dist/__tests__/integration/oidc-rp.test.js +120 -0
  16. package/dist/__tests__/integration/oidc-rp.test.js.map +1 -0
  17. package/dist/__tests__/integration/ssh-mode.test.d.ts +1 -0
  18. package/dist/__tests__/integration/ssh-mode.test.js +101 -0
  19. package/dist/__tests__/integration/ssh-mode.test.js.map +1 -0
  20. package/dist/__tests__/k8s-transport.test.d.ts +1 -0
  21. package/dist/__tests__/k8s-transport.test.js +254 -0
  22. package/dist/__tests__/k8s-transport.test.js.map +1 -0
  23. package/dist/__tests__/oidc-tools.test.d.ts +1 -0
  24. package/dist/__tests__/oidc-tools.test.js +457 -0
  25. package/dist/__tests__/oidc-tools.test.js.map +1 -0
  26. package/dist/__tests__/registry.test.d.ts +1 -0
  27. package/dist/__tests__/registry.test.js +96 -0
  28. package/dist/__tests__/registry.test.js.map +1 -0
  29. package/dist/__tests__/ssh-transport.test.d.ts +1 -0
  30. package/dist/__tests__/ssh-transport.test.js +618 -0
  31. package/dist/__tests__/ssh-transport.test.js.map +1 -0
  32. package/dist/__tests__/tools.test.d.ts +1 -0
  33. package/dist/__tests__/tools.test.js +525 -0
  34. package/dist/__tests__/tools.test.js.map +1 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +506 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +42 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/resources/documentation.d.ts +5 -0
  42. package/dist/resources/documentation.js +56 -0
  43. package/dist/resources/documentation.js.map +1 -0
  44. package/dist/tools/cli-utilities.d.ts +3 -0
  45. package/dist/tools/cli-utilities.js +187 -0
  46. package/dist/tools/cli-utilities.js.map +1 -0
  47. package/dist/tools/config.d.ts +6 -0
  48. package/dist/tools/config.js +326 -0
  49. package/dist/tools/config.js.map +1 -0
  50. package/dist/tools/consents.d.ts +3 -0
  51. package/dist/tools/consents.js +39 -0
  52. package/dist/tools/consents.js.map +1 -0
  53. package/dist/tools/instances.d.ts +3 -0
  54. package/dist/tools/instances.js +14 -0
  55. package/dist/tools/instances.js.map +1 -0
  56. package/dist/tools/oidc-rp.d.ts +6 -0
  57. package/dist/tools/oidc-rp.js +246 -0
  58. package/dist/tools/oidc-rp.js.map +1 -0
  59. package/dist/tools/oidc.d.ts +3 -0
  60. package/dist/tools/oidc.js +343 -0
  61. package/dist/tools/oidc.js.map +1 -0
  62. package/dist/tools/secondfactors.d.ts +3 -0
  63. package/dist/tools/secondfactors.js +62 -0
  64. package/dist/tools/secondfactors.js.map +1 -0
  65. package/dist/tools/sessions.d.ts +6 -0
  66. package/dist/tools/sessions.js +300 -0
  67. package/dist/tools/sessions.js.map +1 -0
  68. package/dist/transport/api.d.ts +35 -0
  69. package/dist/transport/api.js +327 -0
  70. package/dist/transport/api.js.map +1 -0
  71. package/dist/transport/interface.d.ts +50 -0
  72. package/dist/transport/interface.js +2 -0
  73. package/dist/transport/interface.js.map +1 -0
  74. package/dist/transport/k8s.d.ts +41 -0
  75. package/dist/transport/k8s.js +303 -0
  76. package/dist/transport/k8s.js.map +1 -0
  77. package/dist/transport/registry.d.ts +20 -0
  78. package/dist/transport/registry.js +91 -0
  79. package/dist/transport/registry.js.map +1 -0
  80. package/dist/transport/ssh.d.ts +37 -0
  81. package/dist/transport/ssh.js +353 -0
  82. package/dist/transport/ssh.js.map +1 -0
  83. package/docker-compose.test.yml +16 -0
  84. package/eslint.config.js +21 -0
  85. package/package.json +38 -0
  86. package/src/__tests__/api-transport.test.ts +746 -0
  87. package/src/__tests__/config.test.ts +587 -0
  88. package/src/__tests__/integration/api-mode.test.ts +229 -0
  89. package/src/__tests__/integration/oidc-rp.test.ts +138 -0
  90. package/src/__tests__/integration/ssh-mode.test.ts +113 -0
  91. package/src/__tests__/k8s-transport.test.ts +342 -0
  92. package/src/__tests__/oidc-tools.test.ts +554 -0
  93. package/src/__tests__/registry.test.ts +110 -0
  94. package/src/__tests__/ssh-transport.test.ts +805 -0
  95. package/src/__tests__/tools.test.ts +735 -0
  96. package/src/config.ts +605 -0
  97. package/src/index.ts +48 -0
  98. package/src/resources/documentation.ts +65 -0
  99. package/src/tools/cli-utilities.ts +207 -0
  100. package/src/tools/config.ts +382 -0
  101. package/src/tools/consents.ts +50 -0
  102. package/src/tools/instances.ts +21 -0
  103. package/src/tools/oidc-rp.ts +299 -0
  104. package/src/tools/oidc.ts +434 -0
  105. package/src/tools/secondfactors.ts +78 -0
  106. package/src/tools/sessions.ts +342 -0
  107. package/src/transport/api.ts +429 -0
  108. package/src/transport/interface.ts +58 -0
  109. package/src/transport/k8s.ts +367 -0
  110. package/src/transport/registry.ts +105 -0
  111. package/src/transport/ssh.ts +430 -0
  112. package/tsconfig.json +16 -0
  113. package/vitest.config.ts +8 -0
  114. package/vitest.integration.config.ts +9 -0
@@ -0,0 +1,587 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { loadConfig, loadMultiConfig, resolvePaths } from "../config.js";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+
7
+ vi.mock("fs");
8
+ vi.mock("os");
9
+ vi.mock("path");
10
+
11
+ const allEnvVars = [
12
+ "LLNG_MODE",
13
+ "LLNG_SSH_HOST",
14
+ "LLNG_SSH_USER",
15
+ "LLNG_SSH_PORT",
16
+ "LLNG_SSH_SUDO",
17
+ "LLNG_SSH_REMOTE_COMMAND",
18
+ "LLNG_SSH_BIN_PREFIX",
19
+ "LLNG_SSH_CLI_PATH",
20
+ "LLNG_SSH_SESSIONS_PATH",
21
+ "LLNG_SSH_CONFIG_EDITOR_PATH",
22
+ "LLNG_SSH_DELETE_SESSION_PATH",
23
+ "LLNG_API_URL",
24
+ "LLNG_API_BASIC_USER",
25
+ "LLNG_API_BASIC_PASSWORD",
26
+ "LLNG_API_VERIFY_SSL",
27
+ "LLNG_OIDC_ISSUER",
28
+ "LLNG_OIDC_CLIENT_ID",
29
+ "LLNG_OIDC_CLIENT_SECRET",
30
+ "LLNG_OIDC_REDIRECT_URI",
31
+ "LLNG_OIDC_SCOPE",
32
+ "LLNG_K8S_CONTEXT",
33
+ "LLNG_K8S_NAMESPACE",
34
+ "LLNG_K8S_DEPLOYMENT",
35
+ "LLNG_K8S_CONTAINER",
36
+ "LLNG_K8S_POD_SELECTOR",
37
+ "LLNG_K8S_BIN_PREFIX",
38
+ ];
39
+
40
+ function clearEnvVars() {
41
+ for (const v of allEnvVars) {
42
+ delete process.env[v];
43
+ }
44
+ }
45
+
46
+ describe("resolvePaths", () => {
47
+ it("uses default binPrefix when nothing provided", () => {
48
+ const paths = resolvePaths();
49
+ expect(paths.cliPath).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
50
+ expect(paths.sessionsPath).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions");
51
+ expect(paths.configEditorPath).toBe("/usr/share/lemonldap-ng/bin/lmConfigEditor");
52
+ });
53
+
54
+ it("uses custom binPrefix", () => {
55
+ const paths = resolvePaths("/opt/llng/bin");
56
+ expect(paths.cliPath).toBe("/opt/llng/bin/lemonldap-ng-cli");
57
+ expect(paths.sessionsPath).toBe("/opt/llng/bin/lemonldap-ng-sessions");
58
+ expect(paths.configEditorPath).toBe("/opt/llng/bin/lmConfigEditor");
59
+ });
60
+
61
+ it("explicit paths override binPrefix", () => {
62
+ const paths = resolvePaths("/opt/llng/bin", "/custom/cli", undefined, "/custom/editor");
63
+ expect(paths.cliPath).toBe("/custom/cli");
64
+ expect(paths.sessionsPath).toBe("/opt/llng/bin/lemonldap-ng-sessions");
65
+ expect(paths.configEditorPath).toBe("/custom/editor");
66
+ });
67
+ });
68
+
69
+ describe("loadConfig", () => {
70
+ beforeEach(() => {
71
+ vi.resetAllMocks();
72
+ vi.mocked(os.homedir).mockReturnValue("/home/testuser");
73
+ vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
74
+ clearEnvVars();
75
+ });
76
+
77
+ afterEach(() => {
78
+ clearEnvVars();
79
+ });
80
+
81
+ it("returns defaults when no file and no env vars", () => {
82
+ const error: any = new Error("ENOENT: no such file or directory");
83
+ error.code = "ENOENT";
84
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
85
+ throw error;
86
+ });
87
+
88
+ const config = loadConfig();
89
+
90
+ expect(config.mode).toBe("ssh");
91
+ expect(config.ssh).toBeDefined();
92
+ });
93
+
94
+ it("loads config from file", () => {
95
+ const fileConfig = {
96
+ mode: "api",
97
+ ssh: {
98
+ host: "server.example.com",
99
+ user: "admin",
100
+ port: 2222,
101
+ cliPath: "/custom/path/cli",
102
+ sessionsPath: "/custom/path/sessions",
103
+ },
104
+ api: {
105
+ baseUrl: "https://api.example.com",
106
+ basicAuth: {
107
+ username: "user",
108
+ password: "pass",
109
+ },
110
+ verifySsl: false,
111
+ },
112
+ };
113
+
114
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
115
+
116
+ const config = loadConfig();
117
+
118
+ expect(config.mode).toBe("api");
119
+ expect(config.ssh?.host).toBe("server.example.com");
120
+ expect(config.ssh?.user).toBe("admin");
121
+ expect(config.ssh?.port).toBe(2222);
122
+ expect(config.ssh?.cliPath).toBe("/custom/path/cli");
123
+ expect(config.ssh?.sessionsPath).toBe("/custom/path/sessions");
124
+ expect(config.api).toEqual({
125
+ baseUrl: "https://api.example.com",
126
+ basicAuth: {
127
+ username: "user",
128
+ password: "pass",
129
+ },
130
+ verifySsl: false,
131
+ });
132
+ });
133
+
134
+ it("env vars override file values", () => {
135
+ const fileConfig = {
136
+ mode: "api",
137
+ ssh: {
138
+ host: "file-server.example.com",
139
+ port: 2222,
140
+ cliPath: "/file/path/cli",
141
+ sessionsPath: "/file/path/sessions",
142
+ },
143
+ };
144
+
145
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
146
+
147
+ // Set env vars to override
148
+ process.env.LLNG_MODE = "ssh";
149
+ process.env.LLNG_SSH_HOST = "env-server.example.com";
150
+ process.env.LLNG_SSH_PORT = "3333";
151
+
152
+ const config = loadConfig();
153
+
154
+ expect(config.mode).toBe("ssh");
155
+ expect(config.ssh?.host).toBe("env-server.example.com");
156
+ expect(config.ssh?.port).toBe(3333);
157
+ // File values should still be present for non-overridden fields
158
+ expect(config.ssh?.cliPath).toBe("/file/path/cli");
159
+ });
160
+
161
+ it("LLNG_MODE env var sets mode", () => {
162
+ const error: any = new Error("ENOENT");
163
+ error.code = "ENOENT";
164
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
165
+ throw error;
166
+ });
167
+
168
+ process.env.LLNG_MODE = "api";
169
+
170
+ const config = loadConfig();
171
+
172
+ expect(config.mode).toBe("api");
173
+ });
174
+
175
+ it("LLNG_SSH_PORT is parsed as integer", () => {
176
+ const error: any = new Error("ENOENT");
177
+ error.code = "ENOENT";
178
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
179
+ throw error;
180
+ });
181
+
182
+ process.env.LLNG_SSH_PORT = "2222";
183
+
184
+ const config = loadConfig();
185
+
186
+ expect(config.ssh?.port).toBe(2222);
187
+ expect(typeof config.ssh?.port).toBe("number");
188
+ });
189
+
190
+ it('LLNG_API_VERIFY_SSL "false" sets verifySsl to false', () => {
191
+ const error: any = new Error("ENOENT");
192
+ error.code = "ENOENT";
193
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
194
+ throw error;
195
+ });
196
+
197
+ process.env.LLNG_API_URL = "https://api.example.com";
198
+ process.env.LLNG_API_VERIFY_SSL = "false";
199
+
200
+ const config = loadConfig();
201
+
202
+ expect(config.api?.verifySsl).toBe(false);
203
+ });
204
+
205
+ it("LLNG_API_VERIFY_SSL with any other value sets verifySsl to true", () => {
206
+ const error: any = new Error("ENOENT");
207
+ error.code = "ENOENT";
208
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
209
+ throw error;
210
+ });
211
+
212
+ process.env.LLNG_API_URL = "https://api.example.com";
213
+ process.env.LLNG_API_VERIFY_SSL = "true";
214
+
215
+ const config = loadConfig();
216
+
217
+ expect(config.api?.verifySsl).toBe(true);
218
+ });
219
+
220
+ it("OIDC env vars populate oidc config", () => {
221
+ const error: any = new Error("ENOENT");
222
+ error.code = "ENOENT";
223
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
224
+ throw error;
225
+ });
226
+
227
+ process.env.LLNG_OIDC_ISSUER = "https://oidc.example.com";
228
+ process.env.LLNG_OIDC_CLIENT_ID = "client123";
229
+ process.env.LLNG_OIDC_CLIENT_SECRET = "secret456";
230
+ process.env.LLNG_OIDC_REDIRECT_URI = "http://localhost:3000/callback";
231
+ process.env.LLNG_OIDC_SCOPE = "openid profile email";
232
+
233
+ const config = loadConfig();
234
+
235
+ expect(config.oidc).toEqual({
236
+ issuer: "https://oidc.example.com",
237
+ clientId: "client123",
238
+ clientSecret: "secret456",
239
+ redirectUri: "http://localhost:3000/callback",
240
+ scope: "openid profile email",
241
+ });
242
+ });
243
+
244
+ it("invalid JSON in file is handled gracefully", () => {
245
+ vi.mocked(fs.readFileSync).mockReturnValue("{ invalid json }");
246
+
247
+ const config = loadConfig();
248
+
249
+ // Should return defaults despite invalid JSON
250
+ expect(config.mode).toBe("ssh");
251
+ expect(config.ssh).toBeDefined();
252
+ });
253
+
254
+ it("partial file config is merged with defaults", () => {
255
+ const fileConfig = {
256
+ ssh: {
257
+ host: "server.example.com",
258
+ },
259
+ };
260
+
261
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
262
+
263
+ const config = loadConfig();
264
+
265
+ expect(config.ssh?.host).toBe("server.example.com");
266
+ });
267
+
268
+ it("env vars create ssh config even when not in file", () => {
269
+ const error: any = new Error("ENOENT");
270
+ error.code = "ENOENT";
271
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
272
+ throw error;
273
+ });
274
+
275
+ process.env.LLNG_SSH_HOST = "server.example.com";
276
+ process.env.LLNG_SSH_USER = "admin";
277
+ process.env.LLNG_SSH_SUDO = "www-data";
278
+
279
+ const config = loadConfig();
280
+
281
+ expect(config.ssh?.host).toBe("server.example.com");
282
+ expect(config.ssh?.user).toBe("admin");
283
+ expect(config.ssh?.sudo).toBe("www-data");
284
+ });
285
+
286
+ it("API basic auth env vars populate basicAuth", () => {
287
+ const error: any = new Error("ENOENT");
288
+ error.code = "ENOENT";
289
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
290
+ throw error;
291
+ });
292
+
293
+ process.env.LLNG_API_URL = "https://api.example.com";
294
+ process.env.LLNG_API_BASIC_USER = "apiuser";
295
+ process.env.LLNG_API_BASIC_PASSWORD = "apipass";
296
+
297
+ const config = loadConfig();
298
+
299
+ expect(config.api).toEqual({
300
+ baseUrl: "https://api.example.com",
301
+ basicAuth: {
302
+ username: "apiuser",
303
+ password: "apipass",
304
+ },
305
+ });
306
+ });
307
+
308
+ it("custom CLI and sessions paths from env vars", () => {
309
+ const error: any = new Error("ENOENT");
310
+ error.code = "ENOENT";
311
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
312
+ throw error;
313
+ });
314
+
315
+ process.env.LLNG_SSH_CLI_PATH = "/custom/cli";
316
+ process.env.LLNG_SSH_SESSIONS_PATH = "/custom/sessions";
317
+
318
+ const config = loadConfig();
319
+
320
+ expect(config.ssh?.cliPath).toBe("/custom/cli");
321
+ expect(config.ssh?.sessionsPath).toBe("/custom/sessions");
322
+ });
323
+
324
+ it("custom config editor path from env var", () => {
325
+ const error: any = new Error("ENOENT");
326
+ error.code = "ENOENT";
327
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
328
+ throw error;
329
+ });
330
+
331
+ process.env.LLNG_SSH_CONFIG_EDITOR_PATH = "/custom/configEditor";
332
+
333
+ const config = loadConfig();
334
+
335
+ expect(config.ssh?.configEditorPath).toBe("/custom/configEditor");
336
+ });
337
+
338
+ describe("binPrefix", () => {
339
+ it("LLNG_SSH_BIN_PREFIX env var is stored", () => {
340
+ const error: any = new Error("ENOENT");
341
+ error.code = "ENOENT";
342
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
343
+ throw error;
344
+ });
345
+
346
+ process.env.LLNG_SSH_BIN_PREFIX = "/opt/llng/bin";
347
+
348
+ const config = loadConfig();
349
+
350
+ expect(config.ssh?.binPrefix).toBe("/opt/llng/bin");
351
+ });
352
+
353
+ it("binPrefix from file config is preserved", () => {
354
+ const fileConfig = {
355
+ ssh: {
356
+ binPrefix: "/opt/llng/bin",
357
+ },
358
+ };
359
+
360
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
361
+
362
+ const config = loadConfig();
363
+
364
+ expect(config.ssh?.binPrefix).toBe("/opt/llng/bin");
365
+ });
366
+ });
367
+
368
+ describe("remoteCommand", () => {
369
+ it("LLNG_SSH_REMOTE_COMMAND env var is stored", () => {
370
+ const error: any = new Error("ENOENT");
371
+ error.code = "ENOENT";
372
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
373
+ throw error;
374
+ });
375
+
376
+ process.env.LLNG_SSH_REMOTE_COMMAND = "docker exec sso-auth-1";
377
+
378
+ const config = loadConfig();
379
+
380
+ expect(config.ssh?.remoteCommand).toBe("docker exec sso-auth-1");
381
+ });
382
+
383
+ it("remoteCommand from file config is preserved", () => {
384
+ const fileConfig = {
385
+ ssh: {
386
+ remoteCommand: "docker exec sso-auth-1",
387
+ },
388
+ };
389
+
390
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
391
+
392
+ const config = loadConfig();
393
+
394
+ expect(config.ssh?.remoteCommand).toBe("docker exec sso-auth-1");
395
+ });
396
+ });
397
+
398
+ describe("k8s config", () => {
399
+ it("loads k8s config from file", () => {
400
+ const fileConfig = {
401
+ mode: "k8s",
402
+ k8s: {
403
+ context: "prod-cluster",
404
+ namespace: "auth",
405
+ deployment: "lemonldap-ng",
406
+ container: "sso",
407
+ },
408
+ };
409
+
410
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
411
+
412
+ const config = loadConfig();
413
+
414
+ expect(config.mode).toBe("k8s");
415
+ expect(config.k8s).toEqual({
416
+ context: "prod-cluster",
417
+ namespace: "auth",
418
+ deployment: "lemonldap-ng",
419
+ container: "sso",
420
+ });
421
+ });
422
+
423
+ it("LLNG_K8S env vars populate k8s config", () => {
424
+ const error: any = new Error("ENOENT");
425
+ error.code = "ENOENT";
426
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
427
+ throw error;
428
+ });
429
+
430
+ process.env.LLNG_MODE = "k8s";
431
+ process.env.LLNG_K8S_CONTEXT = "my-cluster";
432
+ process.env.LLNG_K8S_NAMESPACE = "auth";
433
+ process.env.LLNG_K8S_DEPLOYMENT = "llng";
434
+ process.env.LLNG_K8S_CONTAINER = "sso";
435
+ process.env.LLNG_K8S_POD_SELECTOR = "app=llng";
436
+ process.env.LLNG_K8S_BIN_PREFIX = "/opt/llng/bin";
437
+
438
+ const config = loadConfig();
439
+
440
+ expect(config.mode).toBe("k8s");
441
+ expect(config.k8s).toEqual({
442
+ context: "my-cluster",
443
+ namespace: "auth",
444
+ deployment: "llng",
445
+ container: "sso",
446
+ podSelector: "app=llng",
447
+ binPrefix: "/opt/llng/bin",
448
+ });
449
+ });
450
+
451
+ it("LLNG_MODE=k8s sets mode", () => {
452
+ const error: any = new Error("ENOENT");
453
+ error.code = "ENOENT";
454
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
455
+ throw error;
456
+ });
457
+
458
+ process.env.LLNG_MODE = "k8s";
459
+
460
+ const config = loadConfig();
461
+ expect(config.mode).toBe("k8s");
462
+ });
463
+ });
464
+ });
465
+
466
+ describe("loadMultiConfig", () => {
467
+ beforeEach(() => {
468
+ vi.resetAllMocks();
469
+ vi.mocked(os.homedir).mockReturnValue("/home/testuser");
470
+ vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
471
+ clearEnvVars();
472
+ });
473
+
474
+ afterEach(() => {
475
+ clearEnvVars();
476
+ });
477
+
478
+ it("wraps legacy flat config as default instance", () => {
479
+ const error: any = new Error("ENOENT");
480
+ error.code = "ENOENT";
481
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
482
+ throw error;
483
+ });
484
+
485
+ const multi = loadMultiConfig();
486
+
487
+ expect(multi.default).toBe("default");
488
+ expect(Object.keys(multi.instances)).toEqual(["default"]);
489
+ expect(multi.instances.default.mode).toBe("ssh");
490
+ });
491
+
492
+ it("loads multi-instance config", () => {
493
+ const fileConfig = {
494
+ instances: {
495
+ prod: { mode: "api", api: { baseUrl: "https://prod.example.com" } },
496
+ staging: { mode: "ssh", ssh: { host: "staging.example.com" } },
497
+ },
498
+ default: "prod",
499
+ };
500
+
501
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
502
+
503
+ const multi = loadMultiConfig();
504
+
505
+ expect(multi.default).toBe("prod");
506
+ expect(Object.keys(multi.instances)).toEqual(["prod", "staging"]);
507
+ expect(multi.instances.prod.mode).toBe("api");
508
+ expect(multi.instances.prod.api?.baseUrl).toBe("https://prod.example.com");
509
+ expect(multi.instances.staging.mode).toBe("ssh");
510
+ expect(multi.instances.staging.ssh?.host).toBe("staging.example.com");
511
+ });
512
+
513
+ it("applies SSH defaults to multi-instance configs", () => {
514
+ const fileConfig = {
515
+ instances: {
516
+ local: { mode: "ssh" },
517
+ },
518
+ default: "local",
519
+ };
520
+
521
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
522
+
523
+ const multi = loadMultiConfig();
524
+
525
+ // SSH config should exist (empty object for ssh mode)
526
+ expect(multi.instances.local.ssh).toBeDefined();
527
+ });
528
+
529
+ it("applies env vars to the default instance only", () => {
530
+ const fileConfig = {
531
+ instances: {
532
+ prod: { mode: "api", api: { baseUrl: "https://prod.example.com" } },
533
+ staging: { mode: "api", api: { baseUrl: "https://staging.example.com" } },
534
+ },
535
+ default: "prod",
536
+ };
537
+
538
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
539
+
540
+ process.env.LLNG_MODE = "ssh";
541
+
542
+ const multi = loadMultiConfig();
543
+
544
+ expect(multi.instances.prod.mode).toBe("ssh");
545
+ expect(multi.instances.staging.mode).toBe("api");
546
+ });
547
+
548
+ it("uses first instance as default when default is not specified", () => {
549
+ const fileConfig = {
550
+ instances: {
551
+ alpha: { mode: "ssh" },
552
+ beta: { mode: "api", api: { baseUrl: "https://beta.example.com" } },
553
+ },
554
+ };
555
+
556
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
557
+
558
+ const multi = loadMultiConfig();
559
+
560
+ expect(multi.default).toBe("alpha");
561
+ });
562
+
563
+ it("supports k8s mode in multi-instance config", () => {
564
+ const fileConfig = {
565
+ instances: {
566
+ prod: {
567
+ mode: "k8s",
568
+ k8s: { context: "prod", namespace: "auth", deployment: "llng" },
569
+ },
570
+ dev: {
571
+ mode: "ssh",
572
+ ssh: { remoteCommand: "docker exec llng-1" },
573
+ },
574
+ },
575
+ default: "prod",
576
+ };
577
+
578
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(fileConfig));
579
+
580
+ const multi = loadMultiConfig();
581
+
582
+ expect(multi.instances.prod.mode).toBe("k8s");
583
+ expect(multi.instances.prod.k8s?.namespace).toBe("auth");
584
+ expect(multi.instances.dev.mode).toBe("ssh");
585
+ expect(multi.instances.dev.ssh?.remoteCommand).toBe("docker exec llng-1");
586
+ });
587
+ });