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.
- package/.github/workflows/publish.yml +6 -6
- package/.github/workflows/test.yml +1 -1
- package/.prettierrc +5 -3
- package/dist/index.js +24 -25
- package/dist/package.json +5 -3
- package/dist/src/agents/app.js +8 -8
- package/dist/src/agents/backend.js +3 -3
- package/dist/src/agents/devops.js +8 -8
- package/dist/src/agents/frontend.js +3 -3
- package/dist/src/agents/fullstack.js +3 -3
- package/dist/src/agents/index.js +18 -18
- package/dist/src/agents/quality.js +8 -8
- package/dist/src/agents/security.js +8 -8
- package/dist/src/client.js +115 -127
- package/dist/src/commands/api-keys.js +195 -202
- package/dist/src/commands/auth.js +16 -25
- package/dist/src/commands/autocomplete.js +8 -8
- package/dist/src/commands/billing.js +10 -19
- package/dist/src/commands/chat.js +139 -170
- package/dist/src/commands/clusters.js +21 -30
- package/dist/src/commands/code/__tests__/auth-sync.test.js +189 -186
- package/dist/src/commands/code/__tests__/fake-api-key-service.js +3 -13
- package/dist/src/commands/code/__tests__/fake-auth-service.js +21 -29
- package/dist/src/commands/code/__tests__/fake-command-runner.js +22 -33
- package/dist/src/commands/code/__tests__/fake-file-store.js +19 -41
- package/dist/src/commands/code/__tests__/fake-prompter.js +81 -97
- package/dist/src/commands/code/__tests__/setup-flow.test.js +295 -295
- package/dist/src/commands/code/adapters/clack-prompter.js +15 -32
- package/dist/src/commands/code/adapters/fs-file-store.js +25 -44
- package/dist/src/commands/code/adapters/spawn-command-runner.js +27 -41
- package/dist/src/commands/code/auth-sync.js +215 -228
- package/dist/src/commands/code/errors.js +15 -12
- package/dist/src/commands/code/setup.js +390 -425
- package/dist/src/commands/code.js +279 -294
- package/dist/src/commands/index.js +5 -5
- package/dist/src/commands/models.js +16 -25
- package/dist/src/commands/users.js +9 -18
- package/dist/src/constants/command-structure.js +138 -138
- package/dist/src/services/api-key-service.js +132 -152
- package/dist/src/services/auth-service.js +81 -95
- package/dist/src/services/browser-auth.js +121 -131
- package/dist/src/services/chat-service.js +369 -386
- package/dist/src/services/cluster-service.js +47 -62
- package/dist/src/services/collaborator-service.js +9 -21
- package/dist/src/services/flux-service.js +13 -25
- package/dist/src/services/helm-service.js +9 -21
- package/dist/src/services/kubectl-service.js +15 -29
- package/dist/src/utils/config-checker.js +7 -7
- package/dist/src/utils/config-loader.js +109 -109
- package/dist/src/utils/default-api-key.js +129 -139
- package/dist/src/utils/env-manager.js +55 -66
- package/dist/src/utils/error-handler.js +62 -62
- package/dist/src/utils/logger.js +74 -67
- package/dist/src/utils/markdown-renderer.js +28 -28
- package/dist/src/utils/opencode-validator.js +67 -69
- package/dist/src/utils/token-manager.js +67 -65
- package/dist/tests/commands/chat.test.js +30 -39
- package/dist/tests/commands/code.test.js +186 -195
- package/dist/tests/utils/config-loader.test.js +107 -107
- package/dist/tests/utils/env-manager.test.js +81 -90
- package/dist/tests/utils/opencode-validator.test.js +42 -41
- package/dist/vitest.config.js +1 -1
- package/eslint.config.mjs +50 -30
- package/index.ts +30 -31
- package/package.json +5 -3
- package/src/agents/app.ts +9 -9
- package/src/agents/backend.ts +4 -4
- package/src/agents/devops.ts +9 -9
- package/src/agents/frontend.ts +4 -4
- package/src/agents/fullstack.ts +4 -4
- package/src/agents/index.ts +27 -25
- package/src/agents/quality.ts +9 -9
- package/src/agents/security.ts +9 -9
- package/src/agents/types.ts +10 -10
- package/src/client.ts +85 -77
- package/src/commands/api-keys.ts +190 -185
- package/src/commands/auth.ts +15 -14
- package/src/commands/autocomplete.ts +10 -10
- package/src/commands/billing.ts +13 -12
- package/src/commands/chat.ts +145 -142
- package/src/commands/clusters.ts +20 -19
- package/src/commands/code/__tests__/auth-sync.test.ts +176 -175
- package/src/commands/code/__tests__/fake-api-key-service.ts +2 -2
- package/src/commands/code/__tests__/fake-auth-service.ts +18 -18
- package/src/commands/code/__tests__/fake-command-runner.ts +28 -22
- package/src/commands/code/__tests__/fake-file-store.ts +15 -15
- package/src/commands/code/__tests__/fake-prompter.ts +86 -85
- package/src/commands/code/__tests__/setup-flow.test.ts +253 -251
- package/src/commands/code/adapters/clack-prompter.ts +32 -30
- package/src/commands/code/adapters/fs-file-store.ts +18 -17
- package/src/commands/code/adapters/spawn-command-runner.ts +20 -15
- package/src/commands/code/auth-sync.ts +210 -210
- package/src/commands/code/errors.ts +11 -11
- package/src/commands/code/ports/auth-services.ts +7 -7
- package/src/commands/code/ports/command-runner.ts +2 -2
- package/src/commands/code/ports/file-store.ts +3 -3
- package/src/commands/code/ports/prompter.ts +13 -13
- package/src/commands/code/setup.ts +408 -406
- package/src/commands/code.ts +288 -287
- package/src/commands/index.ts +11 -10
- package/src/commands/models.ts +19 -18
- package/src/commands/users.ts +11 -10
- package/src/constants/command-structure.ts +159 -159
- package/src/services/api-key-service.ts +85 -85
- package/src/services/auth-service.ts +55 -54
- package/src/services/browser-auth.ts +62 -62
- package/src/services/chat-service.ts +169 -170
- package/src/services/cluster-service.ts +28 -28
- package/src/services/collaborator-service.ts +6 -6
- package/src/services/flux-service.ts +17 -17
- package/src/services/helm-service.ts +11 -11
- package/src/services/kubectl-service.ts +12 -12
- package/src/types/api.d.ts +1933 -1933
- package/src/types/json.d.ts +1 -1
- package/src/utils/config-checker.ts +6 -6
- package/src/utils/config-loader.ts +130 -129
- package/src/utils/default-api-key.ts +81 -80
- package/src/utils/env-manager.ts +37 -37
- package/src/utils/error-handler.ts +64 -64
- package/src/utils/logger.ts +72 -66
- package/src/utils/markdown-renderer.ts +28 -28
- package/src/utils/opencode-validator.ts +72 -71
- package/src/utils/token-manager.ts +69 -68
- package/tests/commands/chat.test.ts +32 -31
- package/tests/commands/code.test.ts +182 -181
- package/tests/utils/config-loader.test.ts +111 -110
- package/tests/utils/env-manager.test.ts +83 -79
- package/tests/utils/opencode-validator.test.ts +43 -42
- package/tsconfig.json +2 -1
- package/vitest.config.ts +2 -2
|
@@ -1,481 +1,482 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
type AuthDeps,
|
|
5
|
+
type CliAuth,
|
|
6
|
+
configureAuth,
|
|
5
7
|
decodeJwtPayload,
|
|
6
8
|
hasBergetCodeSeat,
|
|
7
|
-
|
|
9
|
+
isToolAuthenticated,
|
|
10
|
+
readCliAuth,
|
|
8
11
|
syncApiKeyToTool,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from
|
|
13
|
-
import { FakeFileStore } from
|
|
14
|
-
import { FakePrompter, select
|
|
15
|
-
import { FakeAuthService } from "./fake-auth-service";
|
|
16
|
-
import { FakeApiKeyService } from "./fake-api-key-service";
|
|
12
|
+
syncOAuthToTool,
|
|
13
|
+
} from '../auth-sync';
|
|
14
|
+
import { FakeApiKeyService } from './fake-api-key-service';
|
|
15
|
+
import { FakeAuthService } from './fake-auth-service';
|
|
16
|
+
import { FakeFileStore } from './fake-file-store';
|
|
17
|
+
import { confirm, FakePrompter, select } from './fake-prompter';
|
|
17
18
|
|
|
18
19
|
function base64urlEncode(data: string): string {
|
|
19
|
-
return Buffer.from(data).toString(
|
|
20
|
+
return Buffer.from(data).toString('base64url');
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function makeJwt(payload: Record<string, unknown>): string {
|
|
23
|
-
const header = base64urlEncode(JSON.stringify({ alg:
|
|
24
|
+
const header = base64urlEncode(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
|
24
25
|
const body = base64urlEncode(JSON.stringify(payload));
|
|
25
26
|
return `${header}.${body}.signature`;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const HOME =
|
|
29
|
+
const HOME = '/home/user';
|
|
29
30
|
|
|
30
31
|
const fakeCliAuth = (overrides: Partial<CliAuth> = {}): CliAuth => ({
|
|
31
32
|
access_token: makeJwt({
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
exp: 9_999_999_999_999, // JWT exp in seconds (different from expires_at in ms)
|
|
34
|
+
realm_access: { roles: ['default-roles-berget'] },
|
|
34
35
|
}),
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
expires_at: 9_999_999_999_999,
|
|
37
|
+
refresh_token: 'refreshtoken',
|
|
37
38
|
...overrides,
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
describe(
|
|
41
|
-
it(
|
|
41
|
+
describe('readCliAuth', () => {
|
|
42
|
+
it('returns null when auth file does not exist', async () => {
|
|
42
43
|
const files = new FakeFileStore();
|
|
43
44
|
const result = await readCliAuth(files, HOME);
|
|
44
45
|
expect(result).toBeNull();
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
it(
|
|
48
|
+
it('parses valid auth file', async () => {
|
|
48
49
|
const files = new FakeFileStore();
|
|
49
50
|
const auth: CliAuth = fakeCliAuth();
|
|
50
|
-
files.seed(HOME +
|
|
51
|
+
files.seed(HOME + '/.berget/auth.json', JSON.stringify(auth));
|
|
51
52
|
|
|
52
53
|
const result = await readCliAuth(files, HOME);
|
|
53
54
|
// The JWT's exp claim should be extracted and converted to milliseconds
|
|
54
55
|
const jwtPayload = JSON.parse(
|
|
55
|
-
Buffer.from(auth.access_token.split(
|
|
56
|
+
Buffer.from(auth.access_token.split('.')[1], 'base64url').toString(),
|
|
56
57
|
);
|
|
57
58
|
const expectedAuth = {
|
|
58
59
|
access_token: auth.access_token,
|
|
59
|
-
refresh_token: auth.refresh_token,
|
|
60
60
|
expires_at: (jwtPayload.exp as number) * 1000,
|
|
61
|
+
refresh_token: auth.refresh_token,
|
|
61
62
|
};
|
|
62
63
|
expect(result).toEqual(expectedAuth);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
it(
|
|
66
|
+
it('returns null for malformed JSON', async () => {
|
|
66
67
|
const files = new FakeFileStore();
|
|
67
|
-
files.seed(HOME +
|
|
68
|
+
files.seed(HOME + '/.berget/auth.json', 'not json');
|
|
68
69
|
const result = await readCliAuth(files, HOME);
|
|
69
70
|
expect(result).toBeNull();
|
|
70
71
|
});
|
|
71
72
|
|
|
72
|
-
it(
|
|
73
|
+
it('returns null when fields are missing', async () => {
|
|
73
74
|
const files = new FakeFileStore();
|
|
74
|
-
files.seed(HOME +
|
|
75
|
+
files.seed(HOME + '/.berget/auth.json', JSON.stringify({ access_token: 'only' }));
|
|
75
76
|
const result = await readCliAuth(files, HOME);
|
|
76
77
|
expect(result).toBeNull();
|
|
77
78
|
});
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
describe(
|
|
81
|
-
it(
|
|
81
|
+
describe('isToolAuthenticated', () => {
|
|
82
|
+
it('returns false when auth file does not exist', async () => {
|
|
82
83
|
const files = new FakeFileStore();
|
|
83
|
-
const result = await isToolAuthenticated(files, HOME,
|
|
84
|
+
const result = await isToolAuthenticated(files, HOME, 'opencode');
|
|
84
85
|
expect(result).toBe(false);
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
it(
|
|
88
|
+
it('returns true when berget entry exists', async () => {
|
|
88
89
|
const files = new FakeFileStore();
|
|
89
90
|
files.seed(
|
|
90
|
-
HOME +
|
|
91
|
-
JSON.stringify({ berget: {
|
|
91
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
92
|
+
JSON.stringify({ berget: { access: 'tok', type: 'oauth' } }),
|
|
92
93
|
);
|
|
93
|
-
const result = await isToolAuthenticated(files, HOME,
|
|
94
|
+
const result = await isToolAuthenticated(files, HOME, 'opencode');
|
|
94
95
|
expect(result).toBe(true);
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
it(
|
|
98
|
+
it('returns false when berget entry is missing', async () => {
|
|
98
99
|
const files = new FakeFileStore();
|
|
99
100
|
files.seed(
|
|
100
|
-
HOME +
|
|
101
|
-
JSON.stringify({ openai: { type:
|
|
101
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
102
|
+
JSON.stringify({ openai: { type: 'api' } }),
|
|
102
103
|
);
|
|
103
|
-
const result = await isToolAuthenticated(files, HOME,
|
|
104
|
+
const result = await isToolAuthenticated(files, HOME, 'opencode');
|
|
104
105
|
expect(result).toBe(false);
|
|
105
106
|
});
|
|
106
107
|
|
|
107
|
-
it(
|
|
108
|
+
it('checks correct path for pi', async () => {
|
|
108
109
|
const files = new FakeFileStore();
|
|
109
|
-
files.seed(HOME +
|
|
110
|
-
const result = await isToolAuthenticated(files, HOME,
|
|
110
|
+
files.seed(HOME + '/.pi/agent/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
|
|
111
|
+
const result = await isToolAuthenticated(files, HOME, 'pi');
|
|
111
112
|
expect(result).toBe(true);
|
|
112
113
|
});
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
describe(
|
|
116
|
-
it(
|
|
117
|
-
const payload = {
|
|
116
|
+
describe('decodeJwtPayload', () => {
|
|
117
|
+
it('decodes a valid JWT payload', () => {
|
|
118
|
+
const payload = { realm_access: { roles: ['admin'] }, sub: '123' };
|
|
118
119
|
const jwt = makeJwt(payload);
|
|
119
120
|
expect(decodeJwtPayload(jwt)).toEqual(payload);
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
it(
|
|
123
|
-
expect(decodeJwtPayload(
|
|
124
|
-
expect(decodeJwtPayload(
|
|
123
|
+
it('returns null for invalid format', () => {
|
|
124
|
+
expect(decodeJwtPayload('not.a')).toBeNull();
|
|
125
|
+
expect(decodeJwtPayload('onlyOnePart')).toBeNull();
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
it(
|
|
128
|
-
expect(decodeJwtPayload(
|
|
128
|
+
it('returns null for invalid base64', () => {
|
|
129
|
+
expect(decodeJwtPayload('header.bad\.base64.signature')).toBeNull();
|
|
129
130
|
});
|
|
130
131
|
});
|
|
131
132
|
|
|
132
|
-
describe(
|
|
133
|
-
it(
|
|
133
|
+
describe('hasBergetCodeSeat', () => {
|
|
134
|
+
it('returns true when berget_code_seat is present', () => {
|
|
134
135
|
const token = makeJwt({
|
|
135
|
-
realm_access: { roles: [
|
|
136
|
+
realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] },
|
|
136
137
|
});
|
|
137
138
|
expect(hasBergetCodeSeat(token)).toBe(true);
|
|
138
139
|
});
|
|
139
140
|
|
|
140
|
-
it(
|
|
141
|
+
it('returns false when role is missing', () => {
|
|
141
142
|
const token = makeJwt({
|
|
142
|
-
realm_access: { roles: [
|
|
143
|
+
realm_access: { roles: ['default-roles-berget'] },
|
|
143
144
|
});
|
|
144
145
|
expect(hasBergetCodeSeat(token)).toBe(false);
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
it(
|
|
148
|
-
const token = makeJwt({ sub:
|
|
148
|
+
it('returns false when realm_access is missing', () => {
|
|
149
|
+
const token = makeJwt({ sub: '123' });
|
|
149
150
|
expect(hasBergetCodeSeat(token)).toBe(false);
|
|
150
151
|
});
|
|
151
152
|
|
|
152
|
-
it(
|
|
153
|
-
expect(hasBergetCodeSeat(
|
|
153
|
+
it('returns false for invalid JWT', () => {
|
|
154
|
+
expect(hasBergetCodeSeat('invalid')).toBe(false);
|
|
154
155
|
});
|
|
155
156
|
});
|
|
156
157
|
|
|
157
|
-
describe(
|
|
158
|
-
it(
|
|
158
|
+
describe('syncOAuthToTool', () => {
|
|
159
|
+
it('writes oauth tokens to opencode auth file', async () => {
|
|
159
160
|
const files = new FakeFileStore();
|
|
160
161
|
const auth = fakeCliAuth();
|
|
161
162
|
|
|
162
|
-
await syncOAuthToTool(files, HOME,
|
|
163
|
+
await syncOAuthToTool(files, HOME, 'opencode', auth);
|
|
163
164
|
|
|
164
165
|
const written = files.getWrittenFiles();
|
|
165
|
-
const content = written.get(HOME +
|
|
166
|
+
const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
|
|
166
167
|
const parsed = JSON.parse(content);
|
|
167
168
|
// The expires field should now use the JWT's exp claim (converted to milliseconds)
|
|
168
169
|
const jwtPayload = JSON.parse(
|
|
169
|
-
Buffer.from(auth.access_token.split(
|
|
170
|
+
Buffer.from(auth.access_token.split('.')[1], 'base64url').toString(),
|
|
170
171
|
);
|
|
171
172
|
expect(parsed.berget).toEqual({
|
|
172
|
-
type: "oauth",
|
|
173
173
|
access: auth.access_token,
|
|
174
|
-
refresh: auth.refresh_token,
|
|
175
174
|
expires: (jwtPayload.exp as number) * 1000,
|
|
175
|
+
refresh: auth.refresh_token,
|
|
176
|
+
type: 'oauth',
|
|
176
177
|
});
|
|
177
178
|
});
|
|
178
179
|
|
|
179
|
-
it(
|
|
180
|
+
it('writes oauth tokens to pi auth file', async () => {
|
|
180
181
|
const files = new FakeFileStore();
|
|
181
182
|
const auth = fakeCliAuth();
|
|
182
183
|
|
|
183
|
-
await syncOAuthToTool(files, HOME,
|
|
184
|
+
await syncOAuthToTool(files, HOME, 'pi', auth);
|
|
184
185
|
|
|
185
186
|
const written = files.getWrittenFiles();
|
|
186
|
-
const content = written.get(HOME +
|
|
187
|
+
const content = written.get(HOME + '/.pi/agent/auth.json')!;
|
|
187
188
|
const parsed = JSON.parse(content);
|
|
188
|
-
expect(parsed.berget.type).toBe(
|
|
189
|
+
expect(parsed.berget.type).toBe('oauth');
|
|
189
190
|
});
|
|
190
191
|
|
|
191
|
-
it(
|
|
192
|
+
it('merges with existing providers', async () => {
|
|
192
193
|
const files = new FakeFileStore();
|
|
193
194
|
files.seed(
|
|
194
|
-
HOME +
|
|
195
|
-
JSON.stringify({ openai: {
|
|
195
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
196
|
+
JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
|
|
196
197
|
);
|
|
197
198
|
|
|
198
199
|
const auth = fakeCliAuth();
|
|
199
|
-
await syncOAuthToTool(files, HOME,
|
|
200
|
+
await syncOAuthToTool(files, HOME, 'opencode', auth);
|
|
200
201
|
|
|
201
202
|
const written = files.getWrittenFiles();
|
|
202
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
203
|
-
expect(parsed.openai).toEqual({
|
|
204
|
-
expect(parsed.berget.type).toBe(
|
|
203
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
204
|
+
expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
|
|
205
|
+
expect(parsed.berget.type).toBe('oauth');
|
|
205
206
|
});
|
|
206
207
|
|
|
207
|
-
it(
|
|
208
|
+
it('sets 0o600 permissions on the auth file', async () => {
|
|
208
209
|
const files = new FakeFileStore();
|
|
209
210
|
const auth = fakeCliAuth();
|
|
210
211
|
|
|
211
|
-
await syncOAuthToTool(files, HOME,
|
|
212
|
+
await syncOAuthToTool(files, HOME, 'opencode', auth);
|
|
212
213
|
|
|
213
214
|
const chmodCalls = files.getChmodCalls();
|
|
214
215
|
expect(chmodCalls).toHaveLength(1);
|
|
215
216
|
expect(chmodCalls[0]).toEqual({
|
|
216
|
-
path: HOME + "/.local/share/opencode/auth.json",
|
|
217
217
|
mode: 0o600,
|
|
218
|
+
path: HOME + '/.local/share/opencode/auth.json',
|
|
218
219
|
});
|
|
219
220
|
});
|
|
220
221
|
});
|
|
221
222
|
|
|
222
|
-
describe(
|
|
223
|
+
describe('syncApiKeyToTool', () => {
|
|
223
224
|
it('writes api key to opencode auth file with type "api"', async () => {
|
|
224
225
|
const files = new FakeFileStore();
|
|
225
226
|
|
|
226
|
-
await syncApiKeyToTool(files, HOME,
|
|
227
|
+
await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
|
|
227
228
|
|
|
228
229
|
const written = files.getWrittenFiles();
|
|
229
|
-
const content = written.get(HOME +
|
|
230
|
+
const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
|
|
230
231
|
const parsed = JSON.parse(content);
|
|
231
232
|
expect(parsed.berget).toEqual({
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
key: 'sk_ber_test',
|
|
234
|
+
type: 'api',
|
|
234
235
|
});
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
it('writes api key to pi auth file with type "api_key"', async () => {
|
|
238
239
|
const files = new FakeFileStore();
|
|
239
240
|
|
|
240
|
-
await syncApiKeyToTool(files, HOME,
|
|
241
|
+
await syncApiKeyToTool(files, HOME, 'pi', 'sk_ber_pi');
|
|
241
242
|
|
|
242
243
|
const written = files.getWrittenFiles();
|
|
243
|
-
const content = written.get(HOME +
|
|
244
|
+
const content = written.get(HOME + '/.pi/agent/auth.json')!;
|
|
244
245
|
const parsed = JSON.parse(content);
|
|
245
246
|
expect(parsed.berget).toEqual({
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
key: 'sk_ber_pi',
|
|
248
|
+
type: 'api_key',
|
|
248
249
|
});
|
|
249
250
|
});
|
|
250
251
|
|
|
251
|
-
it(
|
|
252
|
+
it('merges with existing providers', async () => {
|
|
252
253
|
const files = new FakeFileStore();
|
|
253
254
|
files.seed(
|
|
254
|
-
HOME +
|
|
255
|
-
JSON.stringify({ anthropic: {
|
|
255
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
256
|
+
JSON.stringify({ anthropic: { key: 'sk-ant', type: 'api' } }),
|
|
256
257
|
);
|
|
257
258
|
|
|
258
|
-
await syncApiKeyToTool(files, HOME,
|
|
259
|
+
await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
|
|
259
260
|
|
|
260
261
|
const written = files.getWrittenFiles();
|
|
261
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
262
|
-
expect(parsed.anthropic).toEqual({
|
|
262
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
263
|
+
expect(parsed.anthropic).toEqual({ key: 'sk-ant', type: 'api' });
|
|
263
264
|
});
|
|
264
265
|
|
|
265
|
-
it(
|
|
266
|
+
it('sets 0o600 permissions on the auth file', async () => {
|
|
266
267
|
const files = new FakeFileStore();
|
|
267
268
|
|
|
268
|
-
await syncApiKeyToTool(files, HOME,
|
|
269
|
+
await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
|
|
269
270
|
|
|
270
271
|
const chmodCalls = files.getChmodCalls();
|
|
271
272
|
expect(chmodCalls).toHaveLength(1);
|
|
272
273
|
expect(chmodCalls[0]).toEqual({
|
|
273
|
-
path: HOME + "/.local/share/opencode/auth.json",
|
|
274
274
|
mode: 0o600,
|
|
275
|
+
path: HOME + '/.local/share/opencode/auth.json',
|
|
275
276
|
});
|
|
276
277
|
});
|
|
277
278
|
});
|
|
278
279
|
|
|
279
|
-
describe(
|
|
280
|
+
describe('configureAuth', () => {
|
|
280
281
|
const makeAuthDeps = (overrides: Partial<AuthDeps> = {}): AuthDeps =>
|
|
281
282
|
({
|
|
282
|
-
|
|
283
|
-
files: new FakeFileStore(),
|
|
283
|
+
apiKeyService: new FakeApiKeyService('sk_ber_test'),
|
|
284
284
|
authService: new FakeAuthService(true),
|
|
285
|
-
|
|
285
|
+
files: new FakeFileStore(),
|
|
286
286
|
homeDir: HOME,
|
|
287
|
+
prompter: new FakePrompter([]),
|
|
287
288
|
...overrides,
|
|
288
289
|
}) as AuthDeps;
|
|
289
290
|
|
|
290
|
-
it(
|
|
291
|
+
it('Case A: already authenticated — chooses keep → skips flow', async () => {
|
|
291
292
|
const files = new FakeFileStore();
|
|
292
293
|
files.seed(
|
|
293
|
-
HOME +
|
|
294
|
-
JSON.stringify({ berget: { type:
|
|
294
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
295
|
+
JSON.stringify({ berget: { type: 'oauth' } }),
|
|
295
296
|
);
|
|
296
297
|
|
|
297
|
-
const prompter = new FakePrompter([select(
|
|
298
|
+
const prompter = new FakePrompter([select('keep')]);
|
|
298
299
|
|
|
299
300
|
const deps = makeAuthDeps({ files, prompter });
|
|
300
|
-
const result = await configureAuth(deps,
|
|
301
|
+
const result = await configureAuth(deps, 'opencode');
|
|
301
302
|
|
|
302
303
|
expect(result.authenticated).toBe(true);
|
|
303
304
|
expect((deps.prompter as FakePrompter).calls.length).toBe(1); // Only the select prompt
|
|
304
305
|
});
|
|
305
306
|
|
|
306
|
-
it(
|
|
307
|
+
it('Case A reconfigure: already authenticated — reconfigure with fresh browser login', async () => {
|
|
307
308
|
const files = new FakeFileStore();
|
|
308
309
|
files.seed(
|
|
309
|
-
HOME +
|
|
310
|
-
JSON.stringify({ berget: { type:
|
|
310
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
311
|
+
JSON.stringify({ berget: { type: 'oauth' } }),
|
|
311
312
|
);
|
|
312
313
|
|
|
313
|
-
const prompter = new FakePrompter([select(
|
|
314
|
+
const prompter = new FakePrompter([select('reconfigure'), select('subscription')]);
|
|
314
315
|
|
|
315
316
|
const deps = makeAuthDeps({ files, prompter });
|
|
316
317
|
const authService = deps.authService as FakeAuthService;
|
|
317
|
-
const result = await configureAuth(deps,
|
|
318
|
+
const result = await configureAuth(deps, 'opencode');
|
|
318
319
|
|
|
319
320
|
expect(result.authenticated).toBe(true);
|
|
320
321
|
// Should have called loginInteractive (no token reuse since no ~/.berget/auth.json seeded)
|
|
321
322
|
expect(authService.loginInteractiveCallCount).toBeGreaterThanOrEqual(1);
|
|
322
323
|
});
|
|
323
324
|
|
|
324
|
-
it(
|
|
325
|
+
it('Case A reconfigure: already authenticated — reconfigure with valid CLI token → skips browser', async () => {
|
|
325
326
|
const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000; // 1 year from now
|
|
326
327
|
const files = new FakeFileStore();
|
|
327
328
|
files.seed(
|
|
328
|
-
HOME +
|
|
329
|
-
JSON.stringify({ berget: { type:
|
|
329
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
330
|
+
JSON.stringify({ berget: { type: 'oauth' } }),
|
|
330
331
|
);
|
|
331
332
|
files.seed(
|
|
332
|
-
HOME +
|
|
333
|
+
HOME + '/.berget/auth.json',
|
|
333
334
|
JSON.stringify({
|
|
334
|
-
access_token: makeJwt({ realm_access: { roles: [
|
|
335
|
-
refresh_token: "ref",
|
|
335
|
+
access_token: makeJwt({ exp: farFuture, realm_access: { roles: ['berget_code_seat'] } }),
|
|
336
336
|
expires_at: farFuture,
|
|
337
|
-
|
|
337
|
+
refresh_token: 'ref',
|
|
338
|
+
}),
|
|
338
339
|
);
|
|
339
340
|
|
|
340
|
-
const prompter = new FakePrompter([select(
|
|
341
|
+
const prompter = new FakePrompter([select('reconfigure'), select('subscription')]);
|
|
341
342
|
|
|
342
343
|
const authService = new FakeAuthService(true);
|
|
343
|
-
const deps = makeAuthDeps({ files, prompter
|
|
344
|
-
const result = await configureAuth(deps,
|
|
344
|
+
const deps = makeAuthDeps({ authService, files, prompter });
|
|
345
|
+
const result = await configureAuth(deps, 'opencode');
|
|
345
346
|
|
|
346
347
|
expect(result.authenticated).toBe(true);
|
|
347
348
|
// Should NOT have called loginInteractive since token was reused
|
|
348
349
|
expect(authService.loginInteractiveCallCount).toBe(0);
|
|
349
350
|
});
|
|
350
351
|
|
|
351
|
-
it(
|
|
352
|
+
it('Case B: login success + berget_code_seat → chooses subscription', async () => {
|
|
352
353
|
const files = new FakeFileStore();
|
|
353
354
|
const jwt = makeJwt({
|
|
354
|
-
|
|
355
|
-
|
|
355
|
+
exp: 9_999_999_999_999,
|
|
356
|
+
realm_access: { roles: ['berget_code_seat'] },
|
|
356
357
|
});
|
|
357
358
|
files.seed(
|
|
358
|
-
HOME +
|
|
359
|
+
HOME + '/.berget/auth.json',
|
|
359
360
|
JSON.stringify({
|
|
360
361
|
access_token: jwt,
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
})
|
|
362
|
+
expires_at: 9_999_999_999_999,
|
|
363
|
+
refresh_token: 'ref',
|
|
364
|
+
}),
|
|
364
365
|
);
|
|
365
366
|
|
|
366
|
-
const prompter = new FakePrompter([select(
|
|
367
|
+
const prompter = new FakePrompter([select('subscription')]);
|
|
367
368
|
|
|
368
369
|
const deps = makeAuthDeps({ files, prompter });
|
|
369
|
-
const result = await configureAuth(deps,
|
|
370
|
+
const result = await configureAuth(deps, 'opencode');
|
|
370
371
|
|
|
371
372
|
expect(result.authenticated).toBe(true);
|
|
372
373
|
const written = files.getWrittenFiles();
|
|
373
|
-
expect(written.has(HOME +
|
|
374
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
375
|
-
expect(parsed.berget.type).toBe(
|
|
374
|
+
expect(written.has(HOME + '/.local/share/opencode/auth.json')).toBe(true);
|
|
375
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
376
|
+
expect(parsed.berget.type).toBe('oauth');
|
|
376
377
|
});
|
|
377
378
|
|
|
378
|
-
it(
|
|
379
|
+
it('Case B variant: login success + seat → chooses api_key', async () => {
|
|
379
380
|
const files = new FakeFileStore();
|
|
380
381
|
const jwt = makeJwt({
|
|
381
|
-
|
|
382
|
-
|
|
382
|
+
exp: 9_999_999_999_999,
|
|
383
|
+
realm_access: { roles: ['berget_code_seat'] },
|
|
383
384
|
});
|
|
384
385
|
files.seed(
|
|
385
|
-
HOME +
|
|
386
|
+
HOME + '/.berget/auth.json',
|
|
386
387
|
JSON.stringify({
|
|
387
388
|
access_token: jwt,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
})
|
|
389
|
+
expires_at: 9_999_999_999_999,
|
|
390
|
+
refresh_token: 'ref',
|
|
391
|
+
}),
|
|
391
392
|
);
|
|
392
393
|
|
|
393
|
-
const prompter = new FakePrompter([select(
|
|
394
|
+
const prompter = new FakePrompter([select('api_key')]);
|
|
394
395
|
|
|
395
396
|
const deps = makeAuthDeps({ files, prompter });
|
|
396
|
-
const result = await configureAuth(deps,
|
|
397
|
+
const result = await configureAuth(deps, 'opencode');
|
|
397
398
|
|
|
398
399
|
expect(result.authenticated).toBe(true);
|
|
399
400
|
const written = files.getWrittenFiles();
|
|
400
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
401
|
-
expect(parsed.berget.type).toBe(
|
|
402
|
-
expect(parsed.berget.key).toBe(
|
|
401
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
402
|
+
expect(parsed.berget.type).toBe('api');
|
|
403
|
+
expect(parsed.berget.key).toBe('sk_ber_test');
|
|
403
404
|
});
|
|
404
405
|
|
|
405
|
-
it(
|
|
406
|
+
it('Case C: login success + no seat → creates api key', async () => {
|
|
406
407
|
const files = new FakeFileStore();
|
|
407
408
|
const prompter = new FakePrompter([confirm(true)]);
|
|
408
409
|
|
|
409
|
-
const deps = makeAuthDeps({
|
|
410
|
-
const result = await configureAuth(deps,
|
|
410
|
+
const deps = makeAuthDeps({ authService: new FakeAuthService(true, false), files, prompter });
|
|
411
|
+
const result = await configureAuth(deps, 'opencode');
|
|
411
412
|
|
|
412
413
|
expect(result.authenticated).toBe(true);
|
|
413
414
|
const written = files.getWrittenFiles();
|
|
414
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
415
|
-
expect(parsed.berget.type).toBe(
|
|
416
|
-
expect(parsed.berget.key).toBe(
|
|
415
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
416
|
+
expect(parsed.berget.type).toBe('api');
|
|
417
|
+
expect(parsed.berget.key).toBe('sk_ber_test');
|
|
417
418
|
});
|
|
418
419
|
|
|
419
|
-
it(
|
|
420
|
+
it('Case D: login success + no seat → declines api key', async () => {
|
|
420
421
|
const files = new FakeFileStore();
|
|
421
422
|
const prompter = new FakePrompter([confirm(false)]);
|
|
422
423
|
|
|
423
|
-
const deps = makeAuthDeps({
|
|
424
|
-
const result = await configureAuth(deps,
|
|
424
|
+
const deps = makeAuthDeps({ authService: new FakeAuthService(true, false), files, prompter });
|
|
425
|
+
const result = await configureAuth(deps, 'opencode');
|
|
425
426
|
|
|
426
427
|
expect(result.authenticated).toBe(false);
|
|
427
|
-
expect(files.getWrittenFiles().has(HOME +
|
|
428
|
+
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
|
|
428
429
|
});
|
|
429
430
|
|
|
430
|
-
it(
|
|
431
|
+
it('Case E: login fails', async () => {
|
|
431
432
|
const files = new FakeFileStore();
|
|
432
433
|
const authService = new FakeAuthService(false);
|
|
433
434
|
|
|
434
|
-
const deps = makeAuthDeps({
|
|
435
|
-
const result = await configureAuth(deps,
|
|
435
|
+
const deps = makeAuthDeps({ authService, files });
|
|
436
|
+
const result = await configureAuth(deps, 'opencode');
|
|
436
437
|
|
|
437
438
|
expect(result.authenticated).toBe(false);
|
|
438
439
|
});
|
|
439
440
|
|
|
440
|
-
it(
|
|
441
|
+
it('fails authentication when jwt decode fails', async () => {
|
|
441
442
|
const prompter = new FakePrompter([]);
|
|
442
443
|
|
|
443
444
|
const deps = makeAuthDeps({
|
|
444
|
-
prompter,
|
|
445
445
|
authService: new FakeAuthService(true, true, false), // valid login, has seat, but invalid token
|
|
446
|
+
prompter,
|
|
446
447
|
});
|
|
447
|
-
const result = await configureAuth(deps,
|
|
448
|
+
const result = await configureAuth(deps, 'opencode');
|
|
448
449
|
|
|
449
450
|
expect(result.authenticated).toBe(false); // Should fail due to invalid JWT
|
|
450
451
|
const written = (deps.files as FakeFileStore).getWrittenFiles();
|
|
451
452
|
expect(written.size).toBe(0); // No files should be written
|
|
452
453
|
});
|
|
453
454
|
|
|
454
|
-
it(
|
|
455
|
+
it('preserves existing providers during sync', async () => {
|
|
455
456
|
const files = new FakeFileStore();
|
|
456
457
|
files.seed(
|
|
457
|
-
HOME +
|
|
458
|
-
JSON.stringify({ openai: {
|
|
458
|
+
HOME + '/.local/share/opencode/auth.json',
|
|
459
|
+
JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
|
|
459
460
|
);
|
|
460
461
|
files.seed(
|
|
461
|
-
HOME +
|
|
462
|
+
HOME + '/.berget/auth.json',
|
|
462
463
|
JSON.stringify({
|
|
463
464
|
access_token: makeJwt({
|
|
464
|
-
realm_access: { roles: [
|
|
465
|
+
realm_access: { exp: 9_999_999_999_999, roles: ['berget_code_seat'] },
|
|
465
466
|
}),
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
})
|
|
467
|
+
expires_at: 9_999_999_999_999,
|
|
468
|
+
refresh_token: 'ref',
|
|
469
|
+
}),
|
|
469
470
|
);
|
|
470
471
|
|
|
471
|
-
const prompter = new FakePrompter([select(
|
|
472
|
+
const prompter = new FakePrompter([select('subscription')]);
|
|
472
473
|
|
|
473
474
|
const deps = makeAuthDeps({ files, prompter });
|
|
474
|
-
await configureAuth(deps,
|
|
475
|
+
await configureAuth(deps, 'opencode');
|
|
475
476
|
|
|
476
477
|
const written = files.getWrittenFiles();
|
|
477
|
-
const parsed = JSON.parse(written.get(HOME +
|
|
478
|
-
expect(parsed.openai).toEqual({
|
|
478
|
+
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
|
|
479
|
+
expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
|
|
479
480
|
expect(parsed.berget).toBeDefined();
|
|
480
481
|
});
|
|
481
482
|
});
|