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