berget 2.2.7 → 2.2.8

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 (130) hide show
  1. package/.github/workflows/publish.yml +6 -6
  2. package/.github/workflows/test.yml +1 -1
  3. package/.prettierrc +5 -3
  4. package/dist/index.js +24 -25
  5. package/dist/package.json +5 -3
  6. package/dist/src/agents/app.js +8 -8
  7. package/dist/src/agents/backend.js +3 -3
  8. package/dist/src/agents/devops.js +8 -8
  9. package/dist/src/agents/frontend.js +3 -3
  10. package/dist/src/agents/fullstack.js +3 -3
  11. package/dist/src/agents/index.js +18 -18
  12. package/dist/src/agents/quality.js +8 -8
  13. package/dist/src/agents/security.js +8 -8
  14. package/dist/src/client.js +115 -127
  15. package/dist/src/commands/api-keys.js +195 -202
  16. package/dist/src/commands/auth.js +16 -25
  17. package/dist/src/commands/autocomplete.js +8 -8
  18. package/dist/src/commands/billing.js +10 -19
  19. package/dist/src/commands/chat.js +139 -170
  20. package/dist/src/commands/clusters.js +21 -30
  21. package/dist/src/commands/code/__tests__/auth-sync.test.js +189 -186
  22. package/dist/src/commands/code/__tests__/fake-api-key-service.js +3 -13
  23. package/dist/src/commands/code/__tests__/fake-auth-service.js +21 -29
  24. package/dist/src/commands/code/__tests__/fake-command-runner.js +22 -33
  25. package/dist/src/commands/code/__tests__/fake-file-store.js +19 -41
  26. package/dist/src/commands/code/__tests__/fake-prompter.js +81 -97
  27. package/dist/src/commands/code/__tests__/setup-flow.test.js +295 -295
  28. package/dist/src/commands/code/adapters/clack-prompter.js +15 -32
  29. package/dist/src/commands/code/adapters/fs-file-store.js +25 -44
  30. package/dist/src/commands/code/adapters/spawn-command-runner.js +27 -41
  31. package/dist/src/commands/code/auth-sync.js +215 -228
  32. package/dist/src/commands/code/errors.js +15 -12
  33. package/dist/src/commands/code/setup.js +390 -425
  34. package/dist/src/commands/code.js +279 -294
  35. package/dist/src/commands/index.js +5 -5
  36. package/dist/src/commands/models.js +16 -25
  37. package/dist/src/commands/users.js +9 -18
  38. package/dist/src/constants/command-structure.js +138 -138
  39. package/dist/src/services/api-key-service.js +132 -152
  40. package/dist/src/services/auth-service.js +81 -95
  41. package/dist/src/services/browser-auth.js +121 -131
  42. package/dist/src/services/chat-service.js +369 -386
  43. package/dist/src/services/cluster-service.js +47 -62
  44. package/dist/src/services/collaborator-service.js +9 -21
  45. package/dist/src/services/flux-service.js +13 -25
  46. package/dist/src/services/helm-service.js +9 -21
  47. package/dist/src/services/kubectl-service.js +15 -29
  48. package/dist/src/utils/config-checker.js +7 -7
  49. package/dist/src/utils/config-loader.js +109 -109
  50. package/dist/src/utils/default-api-key.js +129 -139
  51. package/dist/src/utils/env-manager.js +55 -66
  52. package/dist/src/utils/error-handler.js +62 -62
  53. package/dist/src/utils/logger.js +74 -67
  54. package/dist/src/utils/markdown-renderer.js +28 -28
  55. package/dist/src/utils/opencode-validator.js +67 -69
  56. package/dist/src/utils/token-manager.js +67 -65
  57. package/dist/tests/commands/chat.test.js +30 -39
  58. package/dist/tests/commands/code.test.js +186 -195
  59. package/dist/tests/utils/config-loader.test.js +107 -107
  60. package/dist/tests/utils/env-manager.test.js +81 -90
  61. package/dist/tests/utils/opencode-validator.test.js +42 -41
  62. package/dist/vitest.config.js +1 -1
  63. package/eslint.config.mjs +50 -30
  64. package/index.ts +30 -31
  65. package/package.json +5 -3
  66. package/src/agents/app.ts +9 -9
  67. package/src/agents/backend.ts +4 -4
  68. package/src/agents/devops.ts +9 -9
  69. package/src/agents/frontend.ts +4 -4
  70. package/src/agents/fullstack.ts +4 -4
  71. package/src/agents/index.ts +27 -25
  72. package/src/agents/quality.ts +9 -9
  73. package/src/agents/security.ts +9 -9
  74. package/src/agents/types.ts +10 -10
  75. package/src/client.ts +85 -77
  76. package/src/commands/api-keys.ts +190 -185
  77. package/src/commands/auth.ts +15 -14
  78. package/src/commands/autocomplete.ts +10 -10
  79. package/src/commands/billing.ts +13 -12
  80. package/src/commands/chat.ts +145 -142
  81. package/src/commands/clusters.ts +20 -19
  82. package/src/commands/code/__tests__/auth-sync.test.ts +176 -175
  83. package/src/commands/code/__tests__/fake-api-key-service.ts +2 -2
  84. package/src/commands/code/__tests__/fake-auth-service.ts +18 -18
  85. package/src/commands/code/__tests__/fake-command-runner.ts +28 -22
  86. package/src/commands/code/__tests__/fake-file-store.ts +15 -15
  87. package/src/commands/code/__tests__/fake-prompter.ts +86 -85
  88. package/src/commands/code/__tests__/setup-flow.test.ts +253 -251
  89. package/src/commands/code/adapters/clack-prompter.ts +32 -30
  90. package/src/commands/code/adapters/fs-file-store.ts +18 -17
  91. package/src/commands/code/adapters/spawn-command-runner.ts +20 -15
  92. package/src/commands/code/auth-sync.ts +210 -210
  93. package/src/commands/code/errors.ts +11 -11
  94. package/src/commands/code/ports/auth-services.ts +7 -7
  95. package/src/commands/code/ports/command-runner.ts +2 -2
  96. package/src/commands/code/ports/file-store.ts +3 -3
  97. package/src/commands/code/ports/prompter.ts +13 -13
  98. package/src/commands/code/setup.ts +408 -406
  99. package/src/commands/code.ts +288 -287
  100. package/src/commands/index.ts +11 -10
  101. package/src/commands/models.ts +19 -18
  102. package/src/commands/users.ts +11 -10
  103. package/src/constants/command-structure.ts +159 -159
  104. package/src/services/api-key-service.ts +85 -85
  105. package/src/services/auth-service.ts +55 -54
  106. package/src/services/browser-auth.ts +62 -62
  107. package/src/services/chat-service.ts +169 -170
  108. package/src/services/cluster-service.ts +28 -28
  109. package/src/services/collaborator-service.ts +6 -6
  110. package/src/services/flux-service.ts +17 -17
  111. package/src/services/helm-service.ts +11 -11
  112. package/src/services/kubectl-service.ts +12 -12
  113. package/src/types/api.d.ts +1933 -1933
  114. package/src/types/json.d.ts +1 -1
  115. package/src/utils/config-checker.ts +6 -6
  116. package/src/utils/config-loader.ts +130 -129
  117. package/src/utils/default-api-key.ts +81 -80
  118. package/src/utils/env-manager.ts +37 -37
  119. package/src/utils/error-handler.ts +64 -64
  120. package/src/utils/logger.ts +72 -66
  121. package/src/utils/markdown-renderer.ts +28 -28
  122. package/src/utils/opencode-validator.ts +72 -71
  123. package/src/utils/token-manager.ts +69 -68
  124. package/tests/commands/chat.test.ts +32 -31
  125. package/tests/commands/code.test.ts +182 -181
  126. package/tests/utils/config-loader.test.ts +111 -110
  127. package/tests/utils/env-manager.test.ts +83 -79
  128. package/tests/utils/opencode-validator.test.ts +43 -42
  129. package/tsconfig.json +2 -1
  130. package/vitest.config.ts +2 -2
@@ -1,205 +1,57 @@
1
- import type { FileStore } from "./ports/file-store";
2
- import type { Prompter } from "./ports/prompter";
3
- import type { AuthServicePort, ApiKeyServicePort } from "./ports/auth-services";
1
+ import type { ApiKeyServicePort, AuthServicePort } from './ports/auth-services';
2
+ import type { FileStore } from './ports/file-store';
3
+ import type { Prompter } from './ports/prompter';
4
4
 
5
- export interface CliAuth {
6
- access_token: string;
7
- refresh_token: string;
8
- expires_at: number;
5
+ export interface AuthDeps {
6
+ apiKeyService: ApiKeyServicePort;
7
+ authService: AuthServicePort;
8
+ files: FileStore;
9
+ homeDir: string;
10
+ prompter: Prompter;
9
11
  }
10
12
 
11
13
  export interface AuthResult {
12
14
  authenticated: boolean;
13
15
  }
14
16
 
15
- export interface AuthDeps {
16
- prompter: Prompter;
17
- files: FileStore;
18
- authService: AuthServicePort;
19
- apiKeyService: ApiKeyServicePort;
20
- homeDir: string;
17
+ export interface CliAuth {
18
+ access_token: string;
19
+ expires_at: number;
20
+ refresh_token: string;
21
21
  }
22
22
 
23
- const CLI_AUTH_PATH = (homeDir: string) => homeDir + "/.berget/auth.json";
23
+ const CLI_AUTH_PATH = (homeDir: string) => homeDir + '/.berget/auth.json';
24
24
 
25
25
  const TOOL_AUTH_PATHS = {
26
- opencode: (homeDir: string) => homeDir + "/.local/share/opencode/auth.json",
27
- pi: (homeDir: string) => homeDir + "/.pi/agent/auth.json",
26
+ opencode: (homeDir: string) => homeDir + '/.local/share/opencode/auth.json',
27
+ pi: (homeDir: string) => homeDir + '/.pi/agent/auth.json',
28
28
  } as const;
29
29
 
30
- const TOOL_API_KEY_TYPES: Record<"opencode" | "pi", string> = {
31
- opencode: "api",
32
- pi: "api_key",
30
+ const TOOL_API_KEY_TYPES: Record<'opencode' | 'pi', string> = {
31
+ opencode: 'api',
32
+ pi: 'api_key',
33
33
  };
34
34
 
35
- function extractJwtExpiresAt(accessToken: string): number {
36
- try {
37
- const parts = accessToken.split(".");
38
- if (parts.length !== 3) return 0;
39
- const payload = Buffer.from(parts[1], "base64url").toString("utf8");
40
- const decoded = JSON.parse(payload);
41
- if (typeof decoded.exp === "number") {
42
- return decoded.exp * 1000; // JWT exp is in seconds, convert to milliseconds
43
- }
44
- } catch {
45
- // If decoding fails, return 0 (treated as expired)
46
- }
47
- return 0;
48
- }
49
-
50
- export async function readCliAuth(files: FileStore, homeDir: string): Promise<CliAuth | null> {
51
- const content = await files.readFile(CLI_AUTH_PATH(homeDir));
52
- if (!content) return null;
53
- try {
54
- const parsed = JSON.parse(content);
55
- if (parsed.access_token && parsed.refresh_token) {
56
- // Extract the actual expiry time from the JWT token instead of using the stored expires_at
57
- const jwtExpiresAt = extractJwtExpiresAt(parsed.access_token);
58
- if (jwtExpiresAt === 0) {
59
- // Invalid token, return null
60
- return null;
61
- }
62
- return {
63
- access_token: parsed.access_token,
64
- refresh_token: parsed.refresh_token,
65
- expires_at: jwtExpiresAt,
66
- };
67
- }
68
- return null;
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- export async function isToolAuthenticated(
75
- files: FileStore,
76
- homeDir: string,
77
- tool: "opencode" | "pi"
78
- ): Promise<boolean> {
79
- const content = await files.readFile(TOOL_AUTH_PATHS[tool](homeDir));
80
- if (!content) return false;
81
- try {
82
- const parsed = JSON.parse(content);
83
- return typeof parsed.berget === "object" && parsed.berget !== null;
84
- } catch {
85
- return false;
86
- }
87
- }
88
-
89
- export function decodeJwtPayload(token: string): unknown | null {
90
- try {
91
- const parts = token.split(".");
92
- if (parts.length !== 3) return null;
93
- const payload = Buffer.from(parts[1], "base64url").toString("utf8");
94
- return JSON.parse(payload);
95
- } catch {
96
- return null;
97
- }
98
- }
99
-
100
- export function hasBergetCodeSeat(accessToken: string): boolean {
101
- const payload = decodeJwtPayload(accessToken);
102
- if (!payload || typeof payload !== "object") return false;
103
- const p = payload as Record<string, unknown>;
104
- const realmAccess = p.realm_access as Record<string, unknown> | undefined;
105
- if (!realmAccess) return false;
106
- const roles = realmAccess.roles as string[] | undefined;
107
- if (!Array.isArray(roles)) return false;
108
- return roles.includes("berget_code_seat");
109
- }
110
-
111
- export async function syncOAuthToTool(
112
- files: FileStore,
113
- homeDir: string,
114
- tool: "opencode" | "pi",
115
- cliAuth: CliAuth
116
- ): Promise<void> {
117
- const authPath = TOOL_AUTH_PATHS[tool](homeDir);
118
- let existing: Record<string, unknown> = {};
119
-
120
- const content = await files.readFile(authPath);
121
- if (content) {
122
- try {
123
- existing = JSON.parse(content) as Record<string, unknown>;
124
- } catch {
125
- existing = {};
126
- }
127
- }
128
-
129
- // Use the JWT's actual expiry time for consistency
130
- const jwtExpiresAt = extractJwtExpiresAt(cliAuth.access_token);
131
-
132
- const updated = {
133
- ...existing,
134
- berget: {
135
- type: "oauth",
136
- access: cliAuth.access_token,
137
- refresh: cliAuth.refresh_token,
138
- expires: jwtExpiresAt,
139
- },
140
- };
141
-
142
- await files.writeFile(authPath, JSON.stringify(updated, null, 2) + "\n");
143
- await files.chmod(authPath, 0o600);
144
- }
145
-
146
- export async function syncApiKeyToTool(
147
- files: FileStore,
148
- homeDir: string,
149
- tool: "opencode" | "pi",
150
- apiKey: string
151
- ): Promise<void> {
152
- const authPath = TOOL_AUTH_PATHS[tool](homeDir);
153
- let existing: Record<string, unknown> = {};
154
-
155
- const content = await files.readFile(authPath);
156
- if (content) {
157
- try {
158
- existing = JSON.parse(content) as Record<string, unknown>;
159
- } catch {
160
- existing = {};
161
- }
162
- }
163
-
164
- const updated = {
165
- ...existing,
166
- berget: {
167
- type: TOOL_API_KEY_TYPES[tool],
168
- key: apiKey,
169
- },
170
- };
171
-
172
- await files.writeFile(authPath, JSON.stringify(updated, null, 2) + "\n");
173
- await files.chmod(authPath, 0o600);
174
- }
175
-
176
- export function isTokenExpired(expiresAt: number): boolean {
177
- const now = Date.now();
178
- const timeUntilExpiry = expiresAt - now;
179
- const buffer = Math.min(30 * 1000, timeUntilExpiry * 0.1);
180
- return now + buffer >= expiresAt;
181
- }
182
-
183
- export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Promise<AuthResult> {
184
- const { prompter, files, authService, apiKeyService, homeDir } = deps;
35
+ export async function configureAuth(deps: AuthDeps, tool: 'opencode' | 'pi'): Promise<AuthResult> {
36
+ const { apiKeyService, authService, files, homeDir, prompter } = deps;
185
37
 
186
38
  const alreadyAuth = await isToolAuthenticated(files, homeDir, tool);
187
39
 
188
40
  if (alreadyAuth) {
189
- const choice = await prompter.select<"keep" | "reconfigure">({
190
- message: `Account is already connected to Berget AI (${tool === "opencode" ? "OpenCode" : "Pi"}). How do you want to proceed?`,
41
+ const choice = await prompter.select<'keep' | 'reconfigure'>({
42
+ message: `Account is already connected to Berget AI (${tool === 'opencode' ? 'OpenCode' : 'Pi'}). How do you want to proceed?`,
191
43
  options: [
192
- { value: "keep", label: "Keep existing authentication" },
193
- { value: "reconfigure", label: "Reconfigure — choose a different method" },
44
+ { label: 'Keep existing authentication', value: 'keep' },
45
+ { label: 'Reconfigure — choose a different method', value: 'reconfigure' },
194
46
  ],
195
47
  });
196
48
 
197
- if (choice === "keep") {
49
+ if (choice === 'keep') {
198
50
  return { authenticated: true };
199
51
  }
200
52
  // Fall through to reconfigure
201
53
  } else {
202
- prompter.note("Authentication required to use Berget AI.", "Connect your account");
54
+ prompter.note('Authentication required to use Berget AI.', 'Connect your account');
203
55
  }
204
56
 
205
57
  // Try to reuse existing CLI tokens (from ~/.berget/auth.json)
@@ -208,31 +60,31 @@ export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Pr
208
60
  if (!cliAuth || isTokenExpired(cliAuth.expires_at)) {
209
61
  // No valid tokens → full browser login
210
62
  const s = prompter.spinner();
211
- s.start("Waiting for browser login...");
63
+ s.start('Waiting for browser login...');
212
64
 
213
65
  const loginResult = await authService.loginInteractive();
214
66
  if (!loginResult.success) {
215
- s.stop("Login failed.");
67
+ s.stop('Login failed.');
216
68
  prompter.note(
217
- `${loginResult.error || "Login timed out or was cancelled."}\n\nPlease run \`berget auth login\` manually, then run \`berget code setup\` again.`,
218
- "Authentication Failed"
69
+ `${loginResult.error || 'Login timed out or was cancelled.'}\n\nPlease run \`berget auth login\` manually, then run \`berget code setup\` again.`,
70
+ 'Authentication Failed',
219
71
  );
220
72
  return { authenticated: false };
221
73
  }
222
74
 
223
- s.stop("Successfully logged in to Berget.");
75
+ s.stop('Successfully logged in to Berget.');
224
76
 
225
77
  const jwtExpiresAt = extractJwtExpiresAt(loginResult.accessToken!);
226
78
  if (jwtExpiresAt === 0) {
227
- s.stop("Login succeeded but received invalid token.");
228
- prompter.note("Please try logging in again or contact support.", "Authentication Error");
79
+ s.stop('Login succeeded but received invalid token.');
80
+ prompter.note('Please try logging in again or contact support.', 'Authentication Error');
229
81
  return { authenticated: false };
230
82
  }
231
83
 
232
84
  cliAuth = {
233
85
  access_token: loginResult.accessToken!,
234
- refresh_token: loginResult.refreshToken!,
235
86
  expires_at: jwtExpiresAt,
87
+ refresh_token: loginResult.refreshToken!,
236
88
  };
237
89
  }
238
90
 
@@ -244,50 +96,50 @@ export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Pr
244
96
  // we can't verify the subscription role. Let the tool handle authorization.
245
97
  if (!jwtPayload) {
246
98
  const s = prompter.spinner();
247
- s.start("Authenticating with Berget AI...");
99
+ s.start('Authenticating with Berget AI...');
248
100
  await syncOAuthToTool(files, homeDir, tool, cliAuth);
249
- s.stop("Authenticated.");
101
+ s.stop('Authenticated.');
250
102
  prompter.note(
251
- "Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.",
252
- "Authentication"
103
+ 'Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.',
104
+ 'Authentication',
253
105
  );
254
106
  return { authenticated: true };
255
107
  }
256
108
 
257
109
  if (hasSeat) {
258
110
  // Case B: Has seat — ask how to authenticate
259
- const method = await prompter.select<"subscription" | "api_key">({
260
- message: "You have a Berget Code subscription. How do you want to authenticate?",
111
+ const method = await prompter.select<'api_key' | 'subscription'>({
112
+ message: 'You have a Berget Code subscription. How do you want to authenticate?',
261
113
  options: [
262
- { value: "subscription", label: "Use my Berget Code subscription" },
263
- { value: "api_key", label: "Use an API key instead" },
114
+ { label: 'Use my Berget Code subscription', value: 'subscription' },
115
+ { label: 'Use an API key instead', value: 'api_key' },
264
116
  ],
265
117
  });
266
118
 
267
- if (method === "subscription") {
119
+ if (method === 'subscription') {
268
120
  const s = prompter.spinner();
269
- s.start("Authenticating with Berget AI via subscription...");
121
+ s.start('Authenticating with Berget AI via subscription...');
270
122
  await syncOAuthToTool(files, homeDir, tool, cliAuth);
271
- s.stop("Authenticated.");
123
+ s.stop('Authenticated.');
272
124
  return { authenticated: true };
273
125
  }
274
126
 
275
127
  // Create API key instead
276
128
  const s = prompter.spinner();
277
- s.start("Creating API key...");
129
+ s.start('Creating API key...');
278
130
  try {
279
131
  const { key } = await apiKeyService.create({
280
- name: `${tool === "opencode" ? "OpenCode" : "Pi"} (created by berget CLI)`,
281
- description: "Created by berget code setup",
132
+ description: 'Created by berget code setup',
133
+ name: `${tool === 'opencode' ? 'OpenCode' : 'Pi'} (created by berget CLI)`,
282
134
  });
283
135
  await syncApiKeyToTool(files, homeDir, tool, key);
284
- s.stop("API key created and saved.");
136
+ s.stop('API key created and saved.');
285
137
  return { authenticated: true };
286
138
  } catch {
287
- s.stop("API key creation failed.");
139
+ s.stop('API key creation failed.');
288
140
  prompter.note(
289
- "Could not create API key. Please create one manually with `berget api-keys create`.",
290
- "Error"
141
+ 'Could not create API key. Please create one manually with `berget api-keys create`.',
142
+ 'Error',
291
143
  );
292
144
  return { authenticated: false };
293
145
  }
@@ -295,26 +147,26 @@ export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Pr
295
147
 
296
148
  // No Berget Code seat — prompt for API key creation
297
149
  const shouldCreate = await prompter.confirm({
298
- message: "You do not have a Berget Code subscription. Would you like to create a new API key?",
299
150
  initialValue: true,
151
+ message: 'You do not have a Berget Code subscription. Would you like to create a new API key?',
300
152
  });
301
153
 
302
154
  if (shouldCreate) {
303
155
  const s = prompter.spinner();
304
- s.start("Creating API key...");
156
+ s.start('Creating API key...');
305
157
  try {
306
158
  const { key } = await apiKeyService.create({
307
- name: `${tool === "opencode" ? "OpenCode" : "Pi"} (created by berget CLI)`,
308
- description: "Created by berget code setup",
159
+ description: 'Created by berget code setup',
160
+ name: `${tool === 'opencode' ? 'OpenCode' : 'Pi'} (created by berget CLI)`,
309
161
  });
310
162
  await syncApiKeyToTool(files, homeDir, tool, key);
311
- s.stop("API key created and saved.");
163
+ s.stop('API key created and saved.');
312
164
  return { authenticated: true };
313
165
  } catch {
314
- s.stop("API key creation failed.");
166
+ s.stop('API key creation failed.');
315
167
  prompter.note(
316
- "Could not create API key. Please create one manually with `berget api-keys create`.",
317
- "Error"
168
+ 'Could not create API key. Please create one manually with `berget api-keys create`.',
169
+ 'Error',
318
170
  );
319
171
  return { authenticated: false };
320
172
  }
@@ -323,7 +175,155 @@ export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Pr
323
175
  // Case D: Declined
324
176
  prompter.note(
325
177
  'Authentication skipped. You\'ll need to set up authentication manually:\n1. Run: berget api-keys create --name "My Key"\n2. Set BERGET_API_KEY environment variable, or\n3. Run `berget auth login` and try again',
326
- "Authentication"
178
+ 'Authentication',
327
179
  );
328
180
  return { authenticated: false };
329
181
  }
182
+
183
+ export function decodeJwtPayload(token: string): null | unknown {
184
+ try {
185
+ const parts = token.split('.');
186
+ if (parts.length !== 3) return null;
187
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
188
+ return JSON.parse(payload);
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ export function hasBergetCodeSeat(accessToken: string): boolean {
195
+ const payload = decodeJwtPayload(accessToken);
196
+ if (!payload || typeof payload !== 'object') return false;
197
+ const p = payload as Record<string, unknown>;
198
+ const realmAccess = p.realm_access as Record<string, unknown> | undefined;
199
+ if (!realmAccess) return false;
200
+ const roles = realmAccess.roles as string[] | undefined;
201
+ if (!Array.isArray(roles)) return false;
202
+ return roles.includes('berget_code_seat');
203
+ }
204
+
205
+ export function isTokenExpired(expiresAt: number): boolean {
206
+ const now = Date.now();
207
+ const timeUntilExpiry = expiresAt - now;
208
+ const buffer = Math.min(30 * 1000, timeUntilExpiry * 0.1);
209
+ return now + buffer >= expiresAt;
210
+ }
211
+
212
+ export async function isToolAuthenticated(
213
+ files: FileStore,
214
+ homeDir: string,
215
+ tool: 'opencode' | 'pi',
216
+ ): Promise<boolean> {
217
+ const content = await files.readFile(TOOL_AUTH_PATHS[tool](homeDir));
218
+ if (!content) return false;
219
+ try {
220
+ const parsed = JSON.parse(content);
221
+ return typeof parsed.berget === 'object' && parsed.berget !== null;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export async function readCliAuth(files: FileStore, homeDir: string): Promise<CliAuth | null> {
228
+ const content = await files.readFile(CLI_AUTH_PATH(homeDir));
229
+ if (!content) return null;
230
+ try {
231
+ const parsed = JSON.parse(content);
232
+ if (parsed.access_token && parsed.refresh_token) {
233
+ // Extract the actual expiry time from the JWT token instead of using the stored expires_at
234
+ const jwtExpiresAt = extractJwtExpiresAt(parsed.access_token);
235
+ if (jwtExpiresAt === 0) {
236
+ // Invalid token, return null
237
+ return null;
238
+ }
239
+ return {
240
+ access_token: parsed.access_token,
241
+ expires_at: jwtExpiresAt,
242
+ refresh_token: parsed.refresh_token,
243
+ };
244
+ }
245
+ return null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ export async function syncApiKeyToTool(
252
+ files: FileStore,
253
+ homeDir: string,
254
+ tool: 'opencode' | 'pi',
255
+ apiKey: string,
256
+ ): Promise<void> {
257
+ const authPath = TOOL_AUTH_PATHS[tool](homeDir);
258
+ let existing: Record<string, unknown> = {};
259
+
260
+ const content = await files.readFile(authPath);
261
+ if (content) {
262
+ try {
263
+ existing = JSON.parse(content) as Record<string, unknown>;
264
+ } catch {
265
+ existing = {};
266
+ }
267
+ }
268
+
269
+ const updated = {
270
+ ...existing,
271
+ berget: {
272
+ key: apiKey,
273
+ type: TOOL_API_KEY_TYPES[tool],
274
+ },
275
+ };
276
+
277
+ await files.writeFile(authPath, JSON.stringify(updated, null, 2) + '\n');
278
+ await files.chmod(authPath, 0o600);
279
+ }
280
+
281
+ export async function syncOAuthToTool(
282
+ files: FileStore,
283
+ homeDir: string,
284
+ tool: 'opencode' | 'pi',
285
+ cliAuth: CliAuth,
286
+ ): Promise<void> {
287
+ const authPath = TOOL_AUTH_PATHS[tool](homeDir);
288
+ let existing: Record<string, unknown> = {};
289
+
290
+ const content = await files.readFile(authPath);
291
+ if (content) {
292
+ try {
293
+ existing = JSON.parse(content) as Record<string, unknown>;
294
+ } catch {
295
+ existing = {};
296
+ }
297
+ }
298
+
299
+ // Use the JWT's actual expiry time for consistency
300
+ const jwtExpiresAt = extractJwtExpiresAt(cliAuth.access_token);
301
+
302
+ const updated = {
303
+ ...existing,
304
+ berget: {
305
+ access: cliAuth.access_token,
306
+ expires: jwtExpiresAt,
307
+ refresh: cliAuth.refresh_token,
308
+ type: 'oauth',
309
+ },
310
+ };
311
+
312
+ await files.writeFile(authPath, JSON.stringify(updated, null, 2) + '\n');
313
+ await files.chmod(authPath, 0o600);
314
+ }
315
+
316
+ function extractJwtExpiresAt(accessToken: string): number {
317
+ try {
318
+ const parts = accessToken.split('.');
319
+ if (parts.length !== 3) return 0;
320
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
321
+ const decoded = JSON.parse(payload);
322
+ if (typeof decoded.exp === 'number') {
323
+ return decoded.exp * 1000; // JWT exp is in seconds, convert to milliseconds
324
+ }
325
+ } catch {
326
+ // If decoding fails, return 0 (treated as expired)
327
+ }
328
+ return 0;
329
+ }
@@ -1,23 +1,23 @@
1
- export class PrerequisiteError extends Error {
2
- constructor(public readonly binary: string) {
3
- super(`Required binary not found: ${binary}`);
4
- this.name = "PrerequisiteError";
5
- }
6
- }
7
-
8
1
  export class CancelledError extends Error {
9
2
  constructor() {
10
- super("Wizard cancelled");
11
- this.name = "CancelledError";
3
+ super('Wizard cancelled');
4
+ this.name = 'CancelledError';
12
5
  }
13
6
  }
14
7
 
15
8
  export class CommandFailedError extends Error {
16
9
  constructor(
17
10
  public readonly command: string,
18
- public readonly exitCode: number
11
+ public readonly exitCode: number,
19
12
  ) {
20
13
  super(`Command "${command}" failed with exit code ${exitCode}`);
21
- this.name = "CommandFailedError";
14
+ this.name = 'CommandFailedError';
15
+ }
16
+ }
17
+
18
+ export class PrerequisiteError extends Error {
19
+ constructor(public readonly binary: string) {
20
+ super(`Required binary not found: ${binary}`);
21
+ this.name = 'PrerequisiteError';
22
22
  }
23
23
  }
@@ -1,14 +1,14 @@
1
+ export interface ApiKeyServicePort {
2
+ create(options: { description?: string; name: string }): Promise<{ key: string }>;
3
+ }
4
+
1
5
  export interface AuthServicePort {
2
6
  login(): Promise<boolean>;
3
7
  loginInteractive(): Promise<{
4
- success: boolean;
5
8
  accessToken?: string;
6
- refreshToken?: string;
7
- expiresIn?: number;
8
9
  error?: string;
10
+ expiresIn?: number;
11
+ refreshToken?: string;
12
+ success: boolean;
9
13
  }>;
10
14
  }
11
-
12
- export interface ApiKeyServicePort {
13
- create(options: { name: string; description?: string }): Promise<{ key: string }>;
14
- }
@@ -2,9 +2,9 @@ export interface CommandRunner {
2
2
  checkInstalled(binary: string): Promise<boolean>;
3
3
  run(
4
4
  command: string,
5
- args: readonly string[],
5
+ arguments_: readonly string[],
6
6
  options?: {
7
7
  cwd?: string;
8
- }
8
+ },
9
9
  ): Promise<string>;
10
10
  }
@@ -1,7 +1,7 @@
1
1
  export interface FileStore {
2
+ chmod(path: string, mode: number): Promise<void>;
2
3
  exists(path: string): Promise<boolean>;
3
- readFile(path: string): Promise<string | null>;
4
- writeFile(path: string, content: string): Promise<void>;
5
4
  mkdir(path: string): Promise<void>;
6
- chmod(path: string, mode: number): Promise<void>;
5
+ readFile(path: string): Promise<null | string>;
6
+ writeFile(path: string, content: string): Promise<void>;
7
7
  }
@@ -1,26 +1,26 @@
1
1
  export interface Prompter {
2
+ confirm(options: { initialValue?: boolean; message: string }): Promise<boolean>;
2
3
  intro(message: string): void;
3
- outro(message: string): void;
4
- note(message: string, title?: string): void;
5
- spinner(): Spinner;
6
- select<T>(opts: {
4
+ multiselect<T>(options: {
7
5
  message: string;
8
6
  options: ReadonlyArray<{
9
- value: T;
10
- label: string;
11
7
  hint?: string;
8
+ label: string;
9
+ value: T;
12
10
  }>;
13
- }): Promise<T>;
14
- multiselect<T>(opts: {
11
+ }): Promise<T[]>;
12
+ note(message: string, title?: string): void;
13
+ outro(message: string): void;
14
+ select<T>(options: {
15
15
  message: string;
16
16
  options: ReadonlyArray<{
17
- value: T;
18
- label: string;
19
17
  hint?: string;
18
+ label: string;
19
+ value: T;
20
20
  }>;
21
- }): Promise<T[]>;
22
- confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean>;
23
- text(opts: { message: string; placeholder?: string }): Promise<string>;
21
+ }): Promise<T>;
22
+ spinner(): Spinner;
23
+ text(options: { message: string; placeholder?: string }): Promise<string>;
24
24
  }
25
25
 
26
26
  export interface Spinner {