berget 2.2.6 → 2.2.7

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 (144) hide show
  1. package/.github/workflows/publish.yml +6 -6
  2. package/.github/workflows/test.yml +11 -5
  3. package/.husky/pre-commit +1 -0
  4. package/.prettierignore +15 -0
  5. package/.prettierrc +5 -3
  6. package/CONTRIBUTING.md +38 -0
  7. package/README.md +2 -148
  8. package/dist/index.js +21 -21
  9. package/dist/package.json +28 -2
  10. package/dist/src/agents/app.js +28 -0
  11. package/dist/src/agents/backend.js +25 -0
  12. package/dist/src/agents/devops.js +34 -0
  13. package/dist/src/agents/frontend.js +25 -0
  14. package/dist/src/agents/fullstack.js +25 -0
  15. package/dist/src/agents/index.js +61 -0
  16. package/dist/src/agents/quality.js +70 -0
  17. package/dist/src/agents/security.js +26 -0
  18. package/dist/src/agents/types.js +2 -0
  19. package/dist/src/client.js +54 -62
  20. package/dist/src/commands/api-keys.js +132 -140
  21. package/dist/src/commands/auth.js +9 -9
  22. package/dist/src/commands/autocomplete.js +9 -9
  23. package/dist/src/commands/billing.js +7 -9
  24. package/dist/src/commands/chat.js +90 -92
  25. package/dist/src/commands/clusters.js +12 -12
  26. package/dist/src/commands/code/__tests__/auth-sync.test.js +348 -0
  27. package/dist/src/commands/code/__tests__/fake-api-key-service.js +23 -0
  28. package/dist/src/commands/code/__tests__/fake-auth-service.js +55 -0
  29. package/dist/src/commands/code/__tests__/fake-command-runner.js +5 -7
  30. package/dist/src/commands/code/__tests__/fake-file-store.js +9 -0
  31. package/dist/src/commands/code/__tests__/fake-prompter.js +60 -18
  32. package/dist/src/commands/code/__tests__/setup-flow.test.js +374 -107
  33. package/dist/src/commands/code/adapters/clack-prompter.js +10 -0
  34. package/dist/src/commands/code/adapters/fs-file-store.js +8 -3
  35. package/dist/src/commands/code/adapters/spawn-command-runner.js +15 -11
  36. package/dist/src/commands/code/auth-sync.js +283 -0
  37. package/dist/src/commands/code/errors.js +4 -4
  38. package/dist/src/commands/code/ports/auth-services.js +2 -0
  39. package/dist/src/commands/code/setup.js +234 -93
  40. package/dist/src/commands/code.js +139 -251
  41. package/dist/src/commands/models.js +13 -15
  42. package/dist/src/commands/users.js +6 -8
  43. package/dist/src/constants/command-structure.js +116 -116
  44. package/dist/src/services/api-key-service.js +43 -48
  45. package/dist/src/services/auth-service.js +60 -299
  46. package/dist/src/services/browser-auth.js +278 -0
  47. package/dist/src/services/chat-service.js +78 -91
  48. package/dist/src/services/cluster-service.js +6 -6
  49. package/dist/src/services/collaborator-service.js +5 -8
  50. package/dist/src/services/flux-service.js +5 -8
  51. package/dist/src/services/helm-service.js +5 -8
  52. package/dist/src/services/kubectl-service.js +7 -10
  53. package/dist/src/utils/config-checker.js +5 -5
  54. package/dist/src/utils/config-loader.js +25 -25
  55. package/dist/src/utils/default-api-key.js +23 -23
  56. package/dist/src/utils/env-manager.js +7 -7
  57. package/dist/src/utils/error-handler.js +60 -61
  58. package/dist/src/utils/logger.js +7 -7
  59. package/dist/src/utils/markdown-renderer.js +2 -2
  60. package/dist/src/utils/opencode-validator.js +17 -20
  61. package/dist/src/utils/token-manager.js +38 -11
  62. package/dist/tests/commands/chat.test.js +24 -24
  63. package/dist/tests/commands/code.test.js +147 -147
  64. package/dist/tests/utils/config-loader.test.js +114 -114
  65. package/dist/tests/utils/env-manager.test.js +57 -57
  66. package/dist/tests/utils/opencode-validator.test.js +33 -33
  67. package/dist/vitest.config.js +1 -1
  68. package/eslint.config.mjs +47 -0
  69. package/index.ts +42 -48
  70. package/package.json +28 -2
  71. package/src/agents/app.ts +27 -0
  72. package/src/agents/backend.ts +24 -0
  73. package/src/agents/devops.ts +33 -0
  74. package/src/agents/frontend.ts +24 -0
  75. package/src/agents/fullstack.ts +24 -0
  76. package/src/agents/index.ts +71 -0
  77. package/src/agents/quality.ts +69 -0
  78. package/src/agents/security.ts +26 -0
  79. package/src/agents/types.ts +17 -0
  80. package/src/client.ts +125 -167
  81. package/src/commands/api-keys.ts +261 -358
  82. package/src/commands/auth.ts +24 -30
  83. package/src/commands/autocomplete.ts +12 -12
  84. package/src/commands/billing.ts +22 -27
  85. package/src/commands/chat.ts +230 -323
  86. package/src/commands/clusters.ts +33 -33
  87. package/src/commands/code/__tests__/auth-sync.test.ts +481 -0
  88. package/src/commands/code/__tests__/fake-api-key-service.ts +13 -0
  89. package/src/commands/code/__tests__/fake-auth-service.ts +50 -0
  90. package/src/commands/code/__tests__/fake-command-runner.ts +39 -42
  91. package/src/commands/code/__tests__/fake-file-store.ts +32 -23
  92. package/src/commands/code/__tests__/fake-prompter.ts +107 -69
  93. package/src/commands/code/__tests__/setup-flow.test.ts +624 -270
  94. package/src/commands/code/adapters/clack-prompter.ts +50 -38
  95. package/src/commands/code/adapters/fs-file-store.ts +31 -27
  96. package/src/commands/code/adapters/spawn-command-runner.ts +33 -29
  97. package/src/commands/code/auth-sync.ts +329 -0
  98. package/src/commands/code/errors.ts +15 -15
  99. package/src/commands/code/ports/auth-services.ts +14 -0
  100. package/src/commands/code/ports/command-runner.ts +8 -4
  101. package/src/commands/code/ports/file-store.ts +5 -4
  102. package/src/commands/code/ports/prompter.ts +24 -18
  103. package/src/commands/code/setup.ts +545 -317
  104. package/src/commands/code.ts +271 -473
  105. package/src/commands/index.ts +19 -19
  106. package/src/commands/models.ts +32 -37
  107. package/src/commands/users.ts +15 -22
  108. package/src/constants/command-structure.ts +119 -142
  109. package/src/services/api-key-service.ts +96 -113
  110. package/src/services/auth-service.ts +92 -339
  111. package/src/services/browser-auth.ts +296 -0
  112. package/src/services/chat-service.ts +246 -279
  113. package/src/services/cluster-service.ts +29 -32
  114. package/src/services/collaborator-service.ts +13 -18
  115. package/src/services/flux-service.ts +16 -18
  116. package/src/services/helm-service.ts +16 -18
  117. package/src/services/kubectl-service.ts +12 -14
  118. package/src/types/api.d.ts +924 -926
  119. package/src/types/json.d.ts +3 -3
  120. package/src/utils/config-checker.ts +10 -10
  121. package/src/utils/config-loader.ts +110 -127
  122. package/src/utils/default-api-key.ts +81 -93
  123. package/src/utils/env-manager.ts +36 -40
  124. package/src/utils/error-handler.ts +83 -78
  125. package/src/utils/logger.ts +41 -41
  126. package/src/utils/markdown-renderer.ts +11 -11
  127. package/src/utils/opencode-validator.ts +51 -56
  128. package/src/utils/token-manager.ts +84 -64
  129. package/templates/agents/app.md +1 -0
  130. package/templates/agents/backend.md +1 -0
  131. package/templates/agents/devops.md +2 -0
  132. package/templates/agents/frontend.md +1 -0
  133. package/templates/agents/fullstack.md +1 -0
  134. package/templates/agents/quality.md +45 -40
  135. package/templates/agents/security.md +1 -0
  136. package/tests/commands/chat.test.ts +60 -70
  137. package/tests/commands/code.test.ts +330 -376
  138. package/tests/utils/config-loader.test.ts +260 -260
  139. package/tests/utils/env-manager.test.ts +127 -134
  140. package/tests/utils/opencode-validator.test.ts +58 -63
  141. package/tsconfig.json +2 -2
  142. package/vitest.config.ts +3 -3
  143. package/AGENTS.md +0 -374
  144. package/TODO.md +0 -19
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const vitest_1 = require("vitest");
13
+ const auth_sync_1 = require("../auth-sync");
14
+ const fake_file_store_1 = require("./fake-file-store");
15
+ const fake_prompter_1 = require("./fake-prompter");
16
+ const fake_auth_service_1 = require("./fake-auth-service");
17
+ const fake_api_key_service_1 = require("./fake-api-key-service");
18
+ function base64urlEncode(data) {
19
+ return Buffer.from(data).toString("base64url");
20
+ }
21
+ function makeJwt(payload) {
22
+ const header = base64urlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
23
+ const body = base64urlEncode(JSON.stringify(payload));
24
+ return `${header}.${body}.signature`;
25
+ }
26
+ const HOME = "/home/user";
27
+ const fakeCliAuth = (overrides = {}) => (Object.assign({ access_token: makeJwt({
28
+ realm_access: { roles: ["default-roles-berget"] },
29
+ exp: 9999999999999, // JWT exp in seconds (different from expires_at in ms)
30
+ }), refresh_token: "refreshtoken", expires_at: 9999999999999 }, overrides));
31
+ (0, vitest_1.describe)("readCliAuth", () => {
32
+ (0, vitest_1.it)("returns null when auth file does not exist", () => __awaiter(void 0, void 0, void 0, function* () {
33
+ const files = new fake_file_store_1.FakeFileStore();
34
+ const result = yield (0, auth_sync_1.readCliAuth)(files, HOME);
35
+ (0, vitest_1.expect)(result).toBeNull();
36
+ }));
37
+ (0, vitest_1.it)("parses valid auth file", () => __awaiter(void 0, void 0, void 0, function* () {
38
+ const files = new fake_file_store_1.FakeFileStore();
39
+ const auth = fakeCliAuth();
40
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify(auth));
41
+ const result = yield (0, auth_sync_1.readCliAuth)(files, HOME);
42
+ // The JWT's exp claim should be extracted and converted to milliseconds
43
+ const jwtPayload = JSON.parse(Buffer.from(auth.access_token.split(".")[1], "base64url").toString());
44
+ const expectedAuth = {
45
+ access_token: auth.access_token,
46
+ refresh_token: auth.refresh_token,
47
+ expires_at: jwtPayload.exp * 1000,
48
+ };
49
+ (0, vitest_1.expect)(result).toEqual(expectedAuth);
50
+ }));
51
+ (0, vitest_1.it)("returns null for malformed JSON", () => __awaiter(void 0, void 0, void 0, function* () {
52
+ const files = new fake_file_store_1.FakeFileStore();
53
+ files.seed(HOME + "/.berget/auth.json", "not json");
54
+ const result = yield (0, auth_sync_1.readCliAuth)(files, HOME);
55
+ (0, vitest_1.expect)(result).toBeNull();
56
+ }));
57
+ (0, vitest_1.it)("returns null when fields are missing", () => __awaiter(void 0, void 0, void 0, function* () {
58
+ const files = new fake_file_store_1.FakeFileStore();
59
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify({ access_token: "only" }));
60
+ const result = yield (0, auth_sync_1.readCliAuth)(files, HOME);
61
+ (0, vitest_1.expect)(result).toBeNull();
62
+ }));
63
+ });
64
+ (0, vitest_1.describe)("isToolAuthenticated", () => {
65
+ (0, vitest_1.it)("returns false when auth file does not exist", () => __awaiter(void 0, void 0, void 0, function* () {
66
+ const files = new fake_file_store_1.FakeFileStore();
67
+ const result = yield (0, auth_sync_1.isToolAuthenticated)(files, HOME, "opencode");
68
+ (0, vitest_1.expect)(result).toBe(false);
69
+ }));
70
+ (0, vitest_1.it)("returns true when berget entry exists", () => __awaiter(void 0, void 0, void 0, function* () {
71
+ const files = new fake_file_store_1.FakeFileStore();
72
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ berget: { type: "oauth", access: "tok" } }));
73
+ const result = yield (0, auth_sync_1.isToolAuthenticated)(files, HOME, "opencode");
74
+ (0, vitest_1.expect)(result).toBe(true);
75
+ }));
76
+ (0, vitest_1.it)("returns false when berget entry is missing", () => __awaiter(void 0, void 0, void 0, function* () {
77
+ const files = new fake_file_store_1.FakeFileStore();
78
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ openai: { type: "api" } }));
79
+ const result = yield (0, auth_sync_1.isToolAuthenticated)(files, HOME, "opencode");
80
+ (0, vitest_1.expect)(result).toBe(false);
81
+ }));
82
+ (0, vitest_1.it)("checks correct path for pi", () => __awaiter(void 0, void 0, void 0, function* () {
83
+ const files = new fake_file_store_1.FakeFileStore();
84
+ files.seed(HOME + "/.pi/agent/auth.json", JSON.stringify({ berget: { type: "oauth" } }));
85
+ const result = yield (0, auth_sync_1.isToolAuthenticated)(files, HOME, "pi");
86
+ (0, vitest_1.expect)(result).toBe(true);
87
+ }));
88
+ });
89
+ (0, vitest_1.describe)("decodeJwtPayload", () => {
90
+ (0, vitest_1.it)("decodes a valid JWT payload", () => {
91
+ const payload = { sub: "123", realm_access: { roles: ["admin"] } };
92
+ const jwt = makeJwt(payload);
93
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)(jwt)).toEqual(payload);
94
+ });
95
+ (0, vitest_1.it)("returns null for invalid format", () => {
96
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)("not.a")).toBeNull();
97
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)("onlyOnePart")).toBeNull();
98
+ });
99
+ (0, vitest_1.it)("returns null for invalid base64", () => {
100
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)("header.bad\.base64.signature")).toBeNull();
101
+ });
102
+ });
103
+ (0, vitest_1.describe)("hasBergetCodeSeat", () => {
104
+ (0, vitest_1.it)("returns true when berget_code_seat is present", () => {
105
+ const token = makeJwt({
106
+ realm_access: { roles: ["berget_code_seat", "default-roles-berget"] },
107
+ });
108
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(true);
109
+ });
110
+ (0, vitest_1.it)("returns false when role is missing", () => {
111
+ const token = makeJwt({
112
+ realm_access: { roles: ["default-roles-berget"] },
113
+ });
114
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(false);
115
+ });
116
+ (0, vitest_1.it)("returns false when realm_access is missing", () => {
117
+ const token = makeJwt({ sub: "123" });
118
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(false);
119
+ });
120
+ (0, vitest_1.it)("returns false for invalid JWT", () => {
121
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)("invalid")).toBe(false);
122
+ });
123
+ });
124
+ (0, vitest_1.describe)("syncOAuthToTool", () => {
125
+ (0, vitest_1.it)("writes oauth tokens to opencode auth file", () => __awaiter(void 0, void 0, void 0, function* () {
126
+ const files = new fake_file_store_1.FakeFileStore();
127
+ const auth = fakeCliAuth();
128
+ yield (0, auth_sync_1.syncOAuthToTool)(files, HOME, "opencode", auth);
129
+ const written = files.getWrittenFiles();
130
+ const content = written.get(HOME + "/.local/share/opencode/auth.json");
131
+ const parsed = JSON.parse(content);
132
+ // The expires field should now use the JWT's exp claim (converted to milliseconds)
133
+ const jwtPayload = JSON.parse(Buffer.from(auth.access_token.split(".")[1], "base64url").toString());
134
+ (0, vitest_1.expect)(parsed.berget).toEqual({
135
+ type: "oauth",
136
+ access: auth.access_token,
137
+ refresh: auth.refresh_token,
138
+ expires: jwtPayload.exp * 1000,
139
+ });
140
+ }));
141
+ (0, vitest_1.it)("writes oauth tokens to pi auth file", () => __awaiter(void 0, void 0, void 0, function* () {
142
+ const files = new fake_file_store_1.FakeFileStore();
143
+ const auth = fakeCliAuth();
144
+ yield (0, auth_sync_1.syncOAuthToTool)(files, HOME, "pi", auth);
145
+ const written = files.getWrittenFiles();
146
+ const content = written.get(HOME + "/.pi/agent/auth.json");
147
+ const parsed = JSON.parse(content);
148
+ (0, vitest_1.expect)(parsed.berget.type).toBe("oauth");
149
+ }));
150
+ (0, vitest_1.it)("merges with existing providers", () => __awaiter(void 0, void 0, void 0, function* () {
151
+ const files = new fake_file_store_1.FakeFileStore();
152
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ openai: { type: "api", key: "sk-openai" } }));
153
+ const auth = fakeCliAuth();
154
+ yield (0, auth_sync_1.syncOAuthToTool)(files, HOME, "opencode", auth);
155
+ const written = files.getWrittenFiles();
156
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
157
+ (0, vitest_1.expect)(parsed.openai).toEqual({ type: "api", key: "sk-openai" });
158
+ (0, vitest_1.expect)(parsed.berget.type).toBe("oauth");
159
+ }));
160
+ (0, vitest_1.it)("sets 0o600 permissions on the auth file", () => __awaiter(void 0, void 0, void 0, function* () {
161
+ const files = new fake_file_store_1.FakeFileStore();
162
+ const auth = fakeCliAuth();
163
+ yield (0, auth_sync_1.syncOAuthToTool)(files, HOME, "opencode", auth);
164
+ const chmodCalls = files.getChmodCalls();
165
+ (0, vitest_1.expect)(chmodCalls).toHaveLength(1);
166
+ (0, vitest_1.expect)(chmodCalls[0]).toEqual({
167
+ path: HOME + "/.local/share/opencode/auth.json",
168
+ mode: 0o600,
169
+ });
170
+ }));
171
+ });
172
+ (0, vitest_1.describe)("syncApiKeyToTool", () => {
173
+ (0, vitest_1.it)('writes api key to opencode auth file with type "api"', () => __awaiter(void 0, void 0, void 0, function* () {
174
+ const files = new fake_file_store_1.FakeFileStore();
175
+ yield (0, auth_sync_1.syncApiKeyToTool)(files, HOME, "opencode", "sk_ber_test");
176
+ const written = files.getWrittenFiles();
177
+ const content = written.get(HOME + "/.local/share/opencode/auth.json");
178
+ const parsed = JSON.parse(content);
179
+ (0, vitest_1.expect)(parsed.berget).toEqual({
180
+ type: "api",
181
+ key: "sk_ber_test",
182
+ });
183
+ }));
184
+ (0, vitest_1.it)('writes api key to pi auth file with type "api_key"', () => __awaiter(void 0, void 0, void 0, function* () {
185
+ const files = new fake_file_store_1.FakeFileStore();
186
+ yield (0, auth_sync_1.syncApiKeyToTool)(files, HOME, "pi", "sk_ber_pi");
187
+ const written = files.getWrittenFiles();
188
+ const content = written.get(HOME + "/.pi/agent/auth.json");
189
+ const parsed = JSON.parse(content);
190
+ (0, vitest_1.expect)(parsed.berget).toEqual({
191
+ type: "api_key",
192
+ key: "sk_ber_pi",
193
+ });
194
+ }));
195
+ (0, vitest_1.it)("merges with existing providers", () => __awaiter(void 0, void 0, void 0, function* () {
196
+ const files = new fake_file_store_1.FakeFileStore();
197
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ anthropic: { type: "api", key: "sk-ant" } }));
198
+ yield (0, auth_sync_1.syncApiKeyToTool)(files, HOME, "opencode", "sk_ber_test");
199
+ const written = files.getWrittenFiles();
200
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
201
+ (0, vitest_1.expect)(parsed.anthropic).toEqual({ type: "api", key: "sk-ant" });
202
+ }));
203
+ (0, vitest_1.it)("sets 0o600 permissions on the auth file", () => __awaiter(void 0, void 0, void 0, function* () {
204
+ const files = new fake_file_store_1.FakeFileStore();
205
+ yield (0, auth_sync_1.syncApiKeyToTool)(files, HOME, "opencode", "sk_ber_test");
206
+ const chmodCalls = files.getChmodCalls();
207
+ (0, vitest_1.expect)(chmodCalls).toHaveLength(1);
208
+ (0, vitest_1.expect)(chmodCalls[0]).toEqual({
209
+ path: HOME + "/.local/share/opencode/auth.json",
210
+ mode: 0o600,
211
+ });
212
+ }));
213
+ });
214
+ (0, vitest_1.describe)("configureAuth", () => {
215
+ const makeAuthDeps = (overrides = {}) => (Object.assign({ prompter: new fake_prompter_1.FakePrompter([]), files: new fake_file_store_1.FakeFileStore(), authService: new fake_auth_service_1.FakeAuthService(true), apiKeyService: new fake_api_key_service_1.FakeApiKeyService("sk_ber_test"), homeDir: HOME }, overrides));
216
+ (0, vitest_1.it)("Case A: already authenticated — chooses keep → skips flow", () => __awaiter(void 0, void 0, void 0, function* () {
217
+ const files = new fake_file_store_1.FakeFileStore();
218
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ berget: { type: "oauth" } }));
219
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("keep")]);
220
+ const deps = makeAuthDeps({ files, prompter });
221
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
222
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
223
+ (0, vitest_1.expect)(deps.prompter.calls.length).toBe(1); // Only the select prompt
224
+ }));
225
+ (0, vitest_1.it)("Case A reconfigure: already authenticated — reconfigure with fresh browser login", () => __awaiter(void 0, void 0, void 0, function* () {
226
+ const files = new fake_file_store_1.FakeFileStore();
227
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ berget: { type: "oauth" } }));
228
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("reconfigure"), (0, fake_prompter_1.select)("subscription")]);
229
+ const deps = makeAuthDeps({ files, prompter });
230
+ const authService = deps.authService;
231
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
232
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
233
+ // Should have called loginInteractive (no token reuse since no ~/.berget/auth.json seeded)
234
+ (0, vitest_1.expect)(authService.loginInteractiveCallCount).toBeGreaterThanOrEqual(1);
235
+ }));
236
+ (0, vitest_1.it)("Case A reconfigure: already authenticated — reconfigure with valid CLI token → skips browser", () => __awaiter(void 0, void 0, void 0, function* () {
237
+ const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000; // 1 year from now
238
+ const files = new fake_file_store_1.FakeFileStore();
239
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ berget: { type: "oauth" } }));
240
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify({
241
+ access_token: makeJwt({ realm_access: { roles: ["berget_code_seat"] }, exp: farFuture }),
242
+ refresh_token: "ref",
243
+ expires_at: farFuture,
244
+ }));
245
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("reconfigure"), (0, fake_prompter_1.select)("subscription")]);
246
+ const authService = new fake_auth_service_1.FakeAuthService(true);
247
+ const deps = makeAuthDeps({ files, prompter, authService });
248
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
249
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
250
+ // Should NOT have called loginInteractive since token was reused
251
+ (0, vitest_1.expect)(authService.loginInteractiveCallCount).toBe(0);
252
+ }));
253
+ (0, vitest_1.it)("Case B: login success + berget_code_seat → chooses subscription", () => __awaiter(void 0, void 0, void 0, function* () {
254
+ const files = new fake_file_store_1.FakeFileStore();
255
+ const jwt = makeJwt({
256
+ realm_access: { roles: ["berget_code_seat"] },
257
+ exp: 9999999999999,
258
+ });
259
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify({
260
+ access_token: jwt,
261
+ refresh_token: "ref",
262
+ expires_at: 9999999999999,
263
+ }));
264
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("subscription")]);
265
+ const deps = makeAuthDeps({ files, prompter });
266
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
267
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
268
+ const written = files.getWrittenFiles();
269
+ (0, vitest_1.expect)(written.has(HOME + "/.local/share/opencode/auth.json")).toBe(true);
270
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
271
+ (0, vitest_1.expect)(parsed.berget.type).toBe("oauth");
272
+ }));
273
+ (0, vitest_1.it)("Case B variant: login success + seat → chooses api_key", () => __awaiter(void 0, void 0, void 0, function* () {
274
+ const files = new fake_file_store_1.FakeFileStore();
275
+ const jwt = makeJwt({
276
+ realm_access: { roles: ["berget_code_seat"] },
277
+ exp: 9999999999999,
278
+ });
279
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify({
280
+ access_token: jwt,
281
+ refresh_token: "ref",
282
+ expires_at: 9999999999999,
283
+ }));
284
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("api_key")]);
285
+ const deps = makeAuthDeps({ files, prompter });
286
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
287
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
288
+ const written = files.getWrittenFiles();
289
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
290
+ (0, vitest_1.expect)(parsed.berget.type).toBe("api");
291
+ (0, vitest_1.expect)(parsed.berget.key).toBe("sk_ber_test");
292
+ }));
293
+ (0, vitest_1.it)("Case C: login success + no seat → creates api key", () => __awaiter(void 0, void 0, void 0, function* () {
294
+ const files = new fake_file_store_1.FakeFileStore();
295
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.confirm)(true)]);
296
+ const deps = makeAuthDeps({ files, prompter, authService: new fake_auth_service_1.FakeAuthService(true, false) });
297
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
298
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
299
+ const written = files.getWrittenFiles();
300
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
301
+ (0, vitest_1.expect)(parsed.berget.type).toBe("api");
302
+ (0, vitest_1.expect)(parsed.berget.key).toBe("sk_ber_test");
303
+ }));
304
+ (0, vitest_1.it)("Case D: login success + no seat → declines api key", () => __awaiter(void 0, void 0, void 0, function* () {
305
+ const files = new fake_file_store_1.FakeFileStore();
306
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.confirm)(false)]);
307
+ const deps = makeAuthDeps({ files, prompter, authService: new fake_auth_service_1.FakeAuthService(true, false) });
308
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
309
+ (0, vitest_1.expect)(result.authenticated).toBe(false);
310
+ (0, vitest_1.expect)(files.getWrittenFiles().has(HOME + "/.local/share/opencode/auth.json")).toBe(false);
311
+ }));
312
+ (0, vitest_1.it)("Case E: login fails", () => __awaiter(void 0, void 0, void 0, function* () {
313
+ const files = new fake_file_store_1.FakeFileStore();
314
+ const authService = new fake_auth_service_1.FakeAuthService(false);
315
+ const deps = makeAuthDeps({ files, authService });
316
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
317
+ (0, vitest_1.expect)(result.authenticated).toBe(false);
318
+ }));
319
+ (0, vitest_1.it)("fails authentication when jwt decode fails", () => __awaiter(void 0, void 0, void 0, function* () {
320
+ const prompter = new fake_prompter_1.FakePrompter([]);
321
+ const deps = makeAuthDeps({
322
+ prompter,
323
+ authService: new fake_auth_service_1.FakeAuthService(true, true, false), // valid login, has seat, but invalid token
324
+ });
325
+ const result = yield (0, auth_sync_1.configureAuth)(deps, "opencode");
326
+ (0, vitest_1.expect)(result.authenticated).toBe(false); // Should fail due to invalid JWT
327
+ const written = deps.files.getWrittenFiles();
328
+ (0, vitest_1.expect)(written.size).toBe(0); // No files should be written
329
+ }));
330
+ (0, vitest_1.it)("preserves existing providers during sync", () => __awaiter(void 0, void 0, void 0, function* () {
331
+ const files = new fake_file_store_1.FakeFileStore();
332
+ files.seed(HOME + "/.local/share/opencode/auth.json", JSON.stringify({ openai: { type: "api", key: "sk-openai" } }));
333
+ files.seed(HOME + "/.berget/auth.json", JSON.stringify({
334
+ access_token: makeJwt({
335
+ realm_access: { roles: ["berget_code_seat"], exp: 9999999999999 },
336
+ }),
337
+ refresh_token: "ref",
338
+ expires_at: 9999999999999,
339
+ }));
340
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)("subscription")]);
341
+ const deps = makeAuthDeps({ files, prompter });
342
+ yield (0, auth_sync_1.configureAuth)(deps, "opencode");
343
+ const written = files.getWrittenFiles();
344
+ const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json"));
345
+ (0, vitest_1.expect)(parsed.openai).toEqual({ type: "api", key: "sk-openai" });
346
+ (0, vitest_1.expect)(parsed.berget).toBeDefined();
347
+ }));
348
+ });
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FakeApiKeyService = void 0;
13
+ class FakeApiKeyService {
14
+ constructor(key) {
15
+ this._key = key;
16
+ }
17
+ create(_options) {
18
+ return __awaiter(this, void 0, void 0, function* () {
19
+ return { key: this._key };
20
+ });
21
+ }
22
+ }
23
+ exports.FakeApiKeyService = FakeApiKeyService;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FakeAuthService = void 0;
13
+ function base64urlEncode(data) {
14
+ return Buffer.from(data).toString("base64url");
15
+ }
16
+ function makeJwt(payload) {
17
+ const header = base64urlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
18
+ const body = base64urlEncode(JSON.stringify(payload));
19
+ return `${header}.${body}.signature`;
20
+ }
21
+ class FakeAuthService {
22
+ constructor(_shouldSucceed, _hasSeat = true, _validToken = true) {
23
+ this._shouldSucceed = _shouldSucceed;
24
+ this._hasSeat = _hasSeat;
25
+ this._validToken = _validToken;
26
+ this.loginCallCount = 0;
27
+ this.loginInteractiveCallCount = 0;
28
+ }
29
+ login() {
30
+ return __awaiter(this, void 0, void 0, function* () {
31
+ this.loginCallCount++;
32
+ return this._shouldSucceed;
33
+ });
34
+ }
35
+ loginInteractive() {
36
+ this.loginInteractiveCallCount++;
37
+ if (!this._shouldSucceed) {
38
+ return Promise.resolve({ success: false, error: "Login failed" });
39
+ }
40
+ const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; // 1 year from now in seconds
41
+ const accessToken = this._validToken
42
+ ? makeJwt({
43
+ realm_access: { roles: this._hasSeat ? ["berget_code_seat"] : ["default-roles-berget"] },
44
+ exp: farFuture,
45
+ })
46
+ : "invalid.token.here";
47
+ return Promise.resolve({
48
+ success: true,
49
+ accessToken,
50
+ refreshToken: "refresh",
51
+ expiresIn: 3600,
52
+ });
53
+ }
54
+ }
55
+ exports.FakeAuthService = FakeAuthService;
@@ -18,8 +18,8 @@ class FakeCommandRunner {
18
18
  handle(match, response) {
19
19
  this.handlers.push({
20
20
  match: (cmd, args) => {
21
- const full = `${cmd} ${args.join(' ')}`;
22
- if (typeof match === 'string')
21
+ const full = `${cmd} ${args.join(" ")}`;
22
+ if (typeof match === "string")
23
23
  return full.startsWith(match);
24
24
  return match.test(full);
25
25
  },
@@ -29,17 +29,15 @@ class FakeCommandRunner {
29
29
  }
30
30
  checkInstalled(binary) {
31
31
  this._calls.push({ command: `check:${binary}`, args: [] });
32
- return Promise.resolve(this.handlers.some(h => h.match(binary, ['--version'])) || false);
32
+ return Promise.resolve(this.handlers.some(h => h.match(binary, ["--version"])) || false);
33
33
  }
34
34
  run(command, args, options) {
35
35
  return __awaiter(this, void 0, void 0, function* () {
36
36
  this._calls.push({ command, args: [...args], options });
37
37
  const handler = this.handlers.find(h => h.match(command, args));
38
38
  if (!handler)
39
- throw new Error(`Unexpected command: ${command} ${args.join(' ')}`);
40
- const result = typeof handler.response === 'function'
41
- ? handler.response(command, args)
42
- : handler.response;
39
+ throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
40
+ const result = typeof handler.response === "function" ? handler.response(command, args) : handler.response;
43
41
  if (result instanceof Error)
44
42
  throw result;
45
43
  return result;
@@ -14,6 +14,7 @@ class FakeFileStore {
14
14
  constructor() {
15
15
  this.files = new Map();
16
16
  this.dirs = new Set();
17
+ this._chmodCalls = [];
17
18
  }
18
19
  seed(path, content) {
19
20
  this.files.set(path, content);
@@ -39,8 +40,16 @@ class FakeFileStore {
39
40
  this.dirs.add(path);
40
41
  });
41
42
  }
43
+ chmod(path, mode) {
44
+ return __awaiter(this, void 0, void 0, function* () {
45
+ this._chmodCalls.push({ path, mode });
46
+ });
47
+ }
42
48
  getWrittenFiles() {
43
49
  return new Map(this.files);
44
50
  }
51
+ getChmodCalls() {
52
+ return this._chmodCalls;
53
+ }
45
54
  }
46
55
  exports.FakeFileStore = FakeFileStore;
@@ -9,21 +9,33 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.FakePrompter = exports.confirm = exports.select = exports.CANCEL = void 0;
12
+ exports.FakePrompter = exports.multiselect = exports.confirm = exports.text = exports.select = exports.CANCEL = void 0;
13
13
  const errors_1 = require("../errors");
14
- exports.CANCEL = Symbol('cancel');
14
+ exports.CANCEL = Symbol("cancel");
15
15
  const select = (value, match) => ({
16
- kind: 'select',
17
- match: typeof match === 'string' ? new RegExp(match) : match,
18
- response: typeof value === 'symbol' ? value : String(value),
16
+ kind: "select",
17
+ match: typeof match === "string" ? new RegExp(match) : match,
18
+ response: typeof value === "symbol" ? value : String(value),
19
19
  });
20
20
  exports.select = select;
21
+ const text = (value, match) => ({
22
+ kind: "text",
23
+ match: typeof match === "string" ? new RegExp(match) : match,
24
+ response: value,
25
+ });
26
+ exports.text = text;
21
27
  const confirm = (value, match) => ({
22
- kind: 'confirm',
23
- match: typeof match === 'string' ? new RegExp(match) : match,
28
+ kind: "confirm",
29
+ match: typeof match === "string" ? new RegExp(match) : match,
24
30
  response: value,
25
31
  });
26
32
  exports.confirm = confirm;
33
+ const multiselect = (values, match) => ({
34
+ kind: "multiselect",
35
+ match: typeof match === "string" ? new RegExp(match) : match,
36
+ response: values === exports.CANCEL ? [exports.CANCEL] : values.map(v => String(v)),
37
+ });
38
+ exports.multiselect = multiselect;
27
39
  class FakePrompter {
28
40
  constructor(_script) {
29
41
  this._script = _script;
@@ -31,32 +43,32 @@ class FakePrompter {
31
43
  this._cursor = 0;
32
44
  }
33
45
  intro(message) {
34
- this._calls.push({ method: 'intro', args: { message } });
46
+ this._calls.push({ method: "intro", args: { message } });
35
47
  }
36
48
  outro(message) {
37
- this._calls.push({ method: 'outro', args: { message } });
49
+ this._calls.push({ method: "outro", args: { message } });
38
50
  }
39
51
  note(message, title) {
40
- this._calls.push({ method: 'note', args: { message, title } });
52
+ this._calls.push({ method: "note", args: { message, title } });
41
53
  }
42
54
  spinner() {
43
55
  return {
44
56
  start: (msg) => {
45
- this._calls.push({ method: 'spinner.start', args: { message: msg } });
57
+ this._calls.push({ method: "spinner.start", args: { message: msg } });
46
58
  },
47
59
  stop: (msg) => {
48
- this._calls.push({ method: 'spinner.stop', args: { message: msg } });
60
+ this._calls.push({ method: "spinner.stop", args: { message: msg } });
49
61
  },
50
62
  };
51
63
  }
52
64
  select(opts) {
53
65
  return __awaiter(this, void 0, void 0, function* () {
54
- this._calls.push({ method: 'select', args: opts });
66
+ this._calls.push({ method: "select", args: opts });
55
67
  const entry = this._script[this._cursor++];
56
68
  if (!entry)
57
69
  throw new Error(`No script entry for select #${this._cursor} (${opts.message})`);
58
- if (entry.kind !== 'select')
59
- throw new Error(`Expected confirm, got select for ${opts.message}`);
70
+ if (entry.kind !== "select")
71
+ throw new Error(`Expected select, got ${entry.kind} for ${opts.message}`);
60
72
  if (entry.match && !entry.match.test(opts.message))
61
73
  throw new Error(`Message mismatch: got "${opts.message}"`);
62
74
  if (entry.response === exports.CANCEL)
@@ -66,12 +78,27 @@ class FakePrompter {
66
78
  }
67
79
  confirm(opts) {
68
80
  return __awaiter(this, void 0, void 0, function* () {
69
- this._calls.push({ method: 'confirm', args: opts });
81
+ this._calls.push({ method: "confirm", args: opts });
70
82
  const entry = this._script[this._cursor++];
71
83
  if (!entry)
72
84
  throw new Error(`No script entry for confirm #${this._cursor} (${opts.message})`);
73
- if (entry.kind !== 'confirm')
74
- throw new Error(`Expected select, got confirm for ${opts.message}`);
85
+ if (entry.kind !== "confirm")
86
+ throw new Error(`Expected confirm, got ${entry.kind} for ${opts.message}`);
87
+ if (entry.match && !entry.match.test(opts.message))
88
+ throw new Error(`Message mismatch: got "${opts.message}"`);
89
+ if (entry.response === exports.CANCEL)
90
+ throw new errors_1.CancelledError();
91
+ return entry.response;
92
+ });
93
+ }
94
+ text(opts) {
95
+ return __awaiter(this, void 0, void 0, function* () {
96
+ this._calls.push({ method: "text", args: opts });
97
+ const entry = this._script[this._cursor++];
98
+ if (!entry)
99
+ throw new Error(`No script entry for text #${this._cursor} (${opts.message})`);
100
+ if (entry.kind !== "text")
101
+ throw new Error(`Expected text, got ${entry.kind} for ${opts.message}`);
75
102
  if (entry.match && !entry.match.test(opts.message))
76
103
  throw new Error(`Message mismatch: got "${opts.message}"`);
77
104
  if (entry.response === exports.CANCEL)
@@ -79,6 +106,21 @@ class FakePrompter {
79
106
  return entry.response;
80
107
  });
81
108
  }
109
+ multiselect(opts) {
110
+ return __awaiter(this, void 0, void 0, function* () {
111
+ this._calls.push({ method: "multiselect", args: opts });
112
+ const entry = this._script[this._cursor++];
113
+ if (!entry)
114
+ throw new Error(`No script entry for multiselect #${this._cursor} (${opts.message})`);
115
+ if (entry.kind !== "multiselect")
116
+ throw new Error(`Expected multiselect, got ${entry.kind} for ${opts.message}`);
117
+ if (entry.match && !entry.match.test(opts.message))
118
+ throw new Error(`Message mismatch: got "${opts.message}"`);
119
+ if (entry.response.includes(exports.CANCEL))
120
+ throw new errors_1.CancelledError();
121
+ return entry.response;
122
+ });
123
+ }
82
124
  get calls() {
83
125
  return this._calls;
84
126
  }