@knpkv/atlassian-common 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +25 -10
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +58 -0
- package/dist/bin.js.map +1 -0
- package/dist/config/AuthProfiles.d.ts +342 -0
- package/dist/config/AuthProfiles.d.ts.map +1 -0
- package/dist/config/AuthProfiles.js +275 -0
- package/dist/config/AuthProfiles.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +6 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ConfigPaths.js +10 -0
- package/dist/config/ConfigPaths.js.map +1 -1
- package/dist/config/ProfileManager.d.ts +92 -0
- package/dist/config/ProfileManager.d.ts.map +1 -0
- package/dist/config/ProfileManager.js +175 -0
- package/dist/config/ProfileManager.js.map +1 -0
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +5 -1
- package/dist/config/index.js.map +1 -1
- package/package.json +11 -3
- package/src/bin.ts +115 -0
- package/src/config/AuthProfiles.ts +384 -0
- package/src/config/ConfigPaths.ts +14 -0
- package/src/config/ProfileManager.ts +268 -0
- package/src/config/index.ts +42 -0
- package/test/AuthProfiles.test.ts +131 -0
- package/test/ProfileManager.test.ts +188 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Atlassian profile management helpers shared by Atlassian CLIs.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as Data from "effect/Data"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as FileSystem from "effect/FileSystem"
|
|
9
|
+
import * as Path from "effect/Path"
|
|
10
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
11
|
+
import * as Schema from "effect/Schema"
|
|
12
|
+
import type { HttpClient } from "effect/unstable/http"
|
|
13
|
+
import type { OAuthError } from "../auth/OAuthErrors.js"
|
|
14
|
+
import { refreshToken } from "../auth/OAuthOperations.js"
|
|
15
|
+
import { type AuthProfile, findProfile, loadProfiles, saveProfiles, saveProfileToken } from "./AuthProfiles.js"
|
|
16
|
+
import { getProfilesPath, type HomeDirectoryError, HomeDirectoryTag } from "./ConfigPaths.js"
|
|
17
|
+
import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
|
|
18
|
+
import { FileSystemError, isTokenExpired, loadOAuthConfig } from "./TokenStorage.js"
|
|
19
|
+
|
|
20
|
+
export interface AtlassianToolDefinition {
|
|
21
|
+
readonly toolName: string
|
|
22
|
+
readonly authStoreName?: string
|
|
23
|
+
readonly legacyAuthPath?: ReadonlyArray<string>
|
|
24
|
+
readonly label: string
|
|
25
|
+
readonly loginHint: string
|
|
26
|
+
readonly requiredScopes: ReadonlyArray<string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const JIRA_REQUIRED_SCOPES = [
|
|
30
|
+
"read:jira-work",
|
|
31
|
+
"write:jira-work",
|
|
32
|
+
"manage:jira-project",
|
|
33
|
+
"read:jira-user",
|
|
34
|
+
"read:me",
|
|
35
|
+
"offline_access"
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
export const CONFLUENCE_REQUIRED_SCOPES = [
|
|
39
|
+
"read:page:confluence",
|
|
40
|
+
"write:page:confluence",
|
|
41
|
+
"delete:page:confluence",
|
|
42
|
+
"read:me",
|
|
43
|
+
"offline_access"
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
export const ATLASSIAN_TOOLS = [
|
|
47
|
+
{
|
|
48
|
+
toolName: "jira-cli",
|
|
49
|
+
label: "Jira CLI",
|
|
50
|
+
loginHint: "jira auth login",
|
|
51
|
+
requiredScopes: JIRA_REQUIRED_SCOPES
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
toolName: "confluence-to-markdown",
|
|
55
|
+
legacyAuthPath: [".confluence", "auth.json"],
|
|
56
|
+
label: "Confluence to Markdown",
|
|
57
|
+
loginHint: "confluence auth login",
|
|
58
|
+
requiredScopes: CONFLUENCE_REQUIRED_SCOPES
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
toolName: "jira-clockify",
|
|
62
|
+
authStoreName: "jira-cli",
|
|
63
|
+
label: "Jira Clockify",
|
|
64
|
+
loginHint: "jcf auth jira login",
|
|
65
|
+
requiredScopes: JIRA_REQUIRED_SCOPES
|
|
66
|
+
}
|
|
67
|
+
] as const satisfies ReadonlyArray<AtlassianToolDefinition>
|
|
68
|
+
|
|
69
|
+
export type ProfileTokenStatus = "valid" | "expired"
|
|
70
|
+
|
|
71
|
+
export class ProfileNotFoundError extends Data.TaggedError("ProfileNotFoundError")<{
|
|
72
|
+
readonly selector: string
|
|
73
|
+
}> {
|
|
74
|
+
override get message(): string {
|
|
75
|
+
return `Profile not found: ${this.selector}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class MissingOAuthConfigError extends Data.TaggedError("MissingOAuthConfigError")<{
|
|
80
|
+
readonly authStoreName: string
|
|
81
|
+
readonly profileId: string
|
|
82
|
+
}> {
|
|
83
|
+
override get message(): string {
|
|
84
|
+
return `Cannot refresh expired profile ${this.profileId}: missing OAuth config for ${this.authStoreName}`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ToolProfileStatus {
|
|
89
|
+
readonly tool: AtlassianToolDefinition
|
|
90
|
+
readonly authStoreName: string
|
|
91
|
+
readonly activeProfile: AuthProfile | null
|
|
92
|
+
readonly profiles: ReadonlyArray<AuthProfile>
|
|
93
|
+
readonly tokenStatus: ProfileTokenStatus | "missing"
|
|
94
|
+
readonly missingScopes: ReadonlyArray<string>
|
|
95
|
+
readonly oauthConfigured: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const authStoreName = (tool: AtlassianToolDefinition): string => tool.authStoreName ?? tool.toolName
|
|
99
|
+
|
|
100
|
+
const uniqueAuthTools = (tools: ReadonlyArray<AtlassianToolDefinition>): ReadonlyArray<AtlassianToolDefinition> =>
|
|
101
|
+
tools.filter((tool, index) =>
|
|
102
|
+
tools.findIndex((candidate) => authStoreName(candidate) === authStoreName(tool)) === index
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const loadLegacyToken = (
|
|
106
|
+
tool: AtlassianToolDefinition
|
|
107
|
+
): Effect.Effect<
|
|
108
|
+
OAuthToken | null,
|
|
109
|
+
FileSystemError | HomeDirectoryError,
|
|
110
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
111
|
+
> =>
|
|
112
|
+
Effect.gen(function*() {
|
|
113
|
+
if (!tool.legacyAuthPath) return null
|
|
114
|
+
const fs = yield* FileSystem.FileSystem
|
|
115
|
+
const path = yield* Path.Path
|
|
116
|
+
const homeDirectory = yield* HomeDirectoryTag
|
|
117
|
+
const home = yield* homeDirectory.get()
|
|
118
|
+
const authPath = path.join(home, ...tool.legacyAuthPath)
|
|
119
|
+
const exists = yield* fs.exists(authPath).pipe(Effect.catch(() => Effect.succeed(false)))
|
|
120
|
+
if (!exists) return null
|
|
121
|
+
const content = yield* fs.readFileString(authPath).pipe(
|
|
122
|
+
Effect.mapError((cause) => new FileSystemError({ operation: "read", path: authPath, cause }))
|
|
123
|
+
)
|
|
124
|
+
const parsed = yield* Effect.try({
|
|
125
|
+
try: () => JSON.parse(content) as unknown,
|
|
126
|
+
catch: (cause) => cause
|
|
127
|
+
}).pipe(Effect.catch(() => Effect.succeed(null)))
|
|
128
|
+
if (parsed === null) return null
|
|
129
|
+
return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
|
|
130
|
+
Effect.catch(() => Effect.succeed(null))
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export const tokenScopes = (token: OAuthToken): ReadonlySet<string> =>
|
|
135
|
+
new Set(token.scope.split(/\s+/).filter((scope: string) => scope.length > 0))
|
|
136
|
+
|
|
137
|
+
export const missingScopes = (
|
|
138
|
+
token: OAuthToken,
|
|
139
|
+
requiredScopes: ReadonlyArray<string>
|
|
140
|
+
): ReadonlyArray<string> => {
|
|
141
|
+
const granted = tokenScopes(token)
|
|
142
|
+
return requiredScopes.filter((scope) => !granted.has(scope))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const inspectToolProfiles = (
|
|
146
|
+
tool: AtlassianToolDefinition
|
|
147
|
+
): Effect.Effect<
|
|
148
|
+
ToolProfileStatus,
|
|
149
|
+
FileSystemError | HomeDirectoryError,
|
|
150
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
151
|
+
> =>
|
|
152
|
+
Effect.gen(function*() {
|
|
153
|
+
const storeName = authStoreName(tool)
|
|
154
|
+
const [store, config] = yield* Effect.all([loadProfiles(storeName), loadOAuthConfig(storeName)])
|
|
155
|
+
const activeProfile = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
|
|
156
|
+
null
|
|
157
|
+
return {
|
|
158
|
+
tool,
|
|
159
|
+
authStoreName: storeName,
|
|
160
|
+
activeProfile,
|
|
161
|
+
profiles: store.profiles,
|
|
162
|
+
tokenStatus: activeProfile === null ? "missing" : isTokenExpired(activeProfile.token, 0) ? "expired" : "valid",
|
|
163
|
+
missingScopes: activeProfile === null ? [] : missingScopes(activeProfile.token, tool.requiredScopes),
|
|
164
|
+
oauthConfigured: config !== null
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
export const inspectAllToolProfiles = (
|
|
169
|
+
tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
|
|
170
|
+
) => Effect.forEach(tools, inspectToolProfiles)
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Switch matching existing profiles across auth stores.
|
|
174
|
+
*
|
|
175
|
+
* Fails when the selector is not present in any store so callers do not
|
|
176
|
+
* mistake an unchanged profile list for a successful switch.
|
|
177
|
+
*/
|
|
178
|
+
export const useProfileForAllTools = (
|
|
179
|
+
selector: string,
|
|
180
|
+
tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
|
|
181
|
+
): Effect.Effect<
|
|
182
|
+
ReadonlyArray<ToolProfileStatus>,
|
|
183
|
+
ProfileNotFoundError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
|
|
184
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
185
|
+
> =>
|
|
186
|
+
Effect.gen(function*() {
|
|
187
|
+
const authTools = uniqueAuthTools(tools)
|
|
188
|
+
const stores = yield* Effect.forEach(authTools, (tool) =>
|
|
189
|
+
loadProfiles(authStoreName(tool)).pipe(Effect.map((store) => [tool, store] as const)))
|
|
190
|
+
const selected = stores.map(([, store]) =>
|
|
191
|
+
findProfile(store.profiles, selector)
|
|
192
|
+
).find((profile): profile is AuthProfile =>
|
|
193
|
+
profile !== null
|
|
194
|
+
)
|
|
195
|
+
if (!selected) return yield* Effect.fail(new ProfileNotFoundError({ selector }))
|
|
196
|
+
|
|
197
|
+
yield* Effect.forEach(stores, ([tool, store]) => {
|
|
198
|
+
const matching = findProfile(store.profiles, selector) ?? findProfile(store.profiles, selected.id)
|
|
199
|
+
if (!matching) return Effect.void
|
|
200
|
+
return saveProfiles(authStoreName(tool), { ...store, activeProfileId: matching.id })
|
|
201
|
+
})
|
|
202
|
+
return yield* inspectAllToolProfiles(tools)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Persist legacy single-token auth files into shared profile stores.
|
|
207
|
+
*
|
|
208
|
+
* Supports both XDG Atlassian auth files and tool-specific legacy paths such
|
|
209
|
+
* as Confluence's historical `~/.confluence/auth.json`.
|
|
210
|
+
*/
|
|
211
|
+
export const migrateLegacyProfiles = (
|
|
212
|
+
tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
|
|
213
|
+
): Effect.Effect<
|
|
214
|
+
ReadonlyArray<ToolProfileStatus>,
|
|
215
|
+
FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
|
|
216
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
217
|
+
> =>
|
|
218
|
+
Effect.gen(function*() {
|
|
219
|
+
const fs = yield* FileSystem.FileSystem
|
|
220
|
+
yield* Effect.forEach(uniqueAuthTools(tools), (tool) =>
|
|
221
|
+
Effect.gen(function*() {
|
|
222
|
+
const storeName = authStoreName(tool)
|
|
223
|
+
const profilesPath = yield* getProfilesPath(storeName)
|
|
224
|
+
const hasProfilesFile = yield* fs.exists(profilesPath).pipe(Effect.catch(() => Effect.succeed(false)))
|
|
225
|
+
if (hasProfilesFile) return
|
|
226
|
+
const store = yield* loadProfiles(storeName)
|
|
227
|
+
if (store.profiles.length > 0) {
|
|
228
|
+
yield* saveProfiles(storeName, store)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const legacyToken = yield* loadLegacyToken(tool)
|
|
232
|
+
if (legacyToken !== null) {
|
|
233
|
+
yield* saveProfileToken(storeName, legacyToken)
|
|
234
|
+
}
|
|
235
|
+
}))
|
|
236
|
+
return yield* inspectAllToolProfiles(tools)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Refresh expired active profiles.
|
|
241
|
+
*
|
|
242
|
+
* Fails when an expired profile lacks OAuth client configuration so refresh
|
|
243
|
+
* callers cannot report success while leaving a token expired.
|
|
244
|
+
*/
|
|
245
|
+
export const refreshActiveProfiles = (
|
|
246
|
+
tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
|
|
247
|
+
): Effect.Effect<
|
|
248
|
+
ReadonlyArray<ToolProfileStatus>,
|
|
249
|
+
MissingOAuthConfigError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
|
|
250
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag | HttpClient.HttpClient
|
|
251
|
+
> =>
|
|
252
|
+
Effect.gen(function*() {
|
|
253
|
+
yield* Effect.forEach(uniqueAuthTools(tools), (tool) =>
|
|
254
|
+
Effect.gen(function*() {
|
|
255
|
+
const storeName = authStoreName(tool)
|
|
256
|
+
const store = yield* loadProfiles(storeName)
|
|
257
|
+
const active = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
|
|
258
|
+
null
|
|
259
|
+
if (!active || !isTokenExpired(active.token, 0)) return
|
|
260
|
+
const config = yield* loadOAuthConfig(storeName)
|
|
261
|
+
if (!config) {
|
|
262
|
+
return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
|
|
263
|
+
}
|
|
264
|
+
const refreshed = yield* refreshToken(active.token, config)
|
|
265
|
+
yield* saveProfileToken(storeName, refreshed)
|
|
266
|
+
}))
|
|
267
|
+
return yield* inspectAllToolProfiles(tools)
|
|
268
|
+
})
|
package/src/config/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
getAuthPath,
|
|
11
11
|
getConfigDir,
|
|
12
12
|
getOAuthConfigPath,
|
|
13
|
+
getProfilesPath,
|
|
13
14
|
type HomeDirectory,
|
|
14
15
|
HomeDirectoryError,
|
|
15
16
|
HomeDirectoryLive,
|
|
@@ -41,3 +42,44 @@ export {
|
|
|
41
42
|
saveOAuthConfig,
|
|
42
43
|
saveToken
|
|
43
44
|
} from "./TokenStorage.js"
|
|
45
|
+
|
|
46
|
+
// Auth profiles
|
|
47
|
+
export {
|
|
48
|
+
type AuthProfile,
|
|
49
|
+
AuthProfileSchema,
|
|
50
|
+
type AuthProfilesFile,
|
|
51
|
+
AuthProfilesFileSchema,
|
|
52
|
+
deleteActiveProfile,
|
|
53
|
+
deleteProfile,
|
|
54
|
+
deleteProfileBySelector,
|
|
55
|
+
findProfile,
|
|
56
|
+
loadActiveProfile,
|
|
57
|
+
loadActiveProfileToken,
|
|
58
|
+
loadProfiles,
|
|
59
|
+
profileFromToken,
|
|
60
|
+
profileIdFromToken,
|
|
61
|
+
profileNameFromToken,
|
|
62
|
+
saveProfiles,
|
|
63
|
+
saveProfileToken,
|
|
64
|
+
setActiveProfile,
|
|
65
|
+
setActiveProfileBySelector
|
|
66
|
+
} from "./AuthProfiles.js"
|
|
67
|
+
|
|
68
|
+
// Unified profile manager
|
|
69
|
+
export {
|
|
70
|
+
ATLASSIAN_TOOLS,
|
|
71
|
+
type AtlassianToolDefinition,
|
|
72
|
+
CONFLUENCE_REQUIRED_SCOPES,
|
|
73
|
+
inspectAllToolProfiles,
|
|
74
|
+
inspectToolProfiles,
|
|
75
|
+
JIRA_REQUIRED_SCOPES,
|
|
76
|
+
migrateLegacyProfiles,
|
|
77
|
+
MissingOAuthConfigError,
|
|
78
|
+
missingScopes,
|
|
79
|
+
ProfileNotFoundError,
|
|
80
|
+
type ProfileTokenStatus,
|
|
81
|
+
refreshActiveProfiles,
|
|
82
|
+
tokenScopes,
|
|
83
|
+
type ToolProfileStatus,
|
|
84
|
+
useProfileForAllTools
|
|
85
|
+
} from "./ProfileManager.js"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { ConfigProvider, Effect, Layer } from "effect"
|
|
3
|
+
import * as FileSystem from "effect/FileSystem"
|
|
4
|
+
import * as Path from "effect/Path"
|
|
5
|
+
import { SystemError } from "effect/PlatformError"
|
|
6
|
+
import {
|
|
7
|
+
deleteProfileBySelector,
|
|
8
|
+
HomeDirectoryLive,
|
|
9
|
+
loadActiveProfileToken,
|
|
10
|
+
loadProfiles,
|
|
11
|
+
type OAuthToken,
|
|
12
|
+
saveProfileToken,
|
|
13
|
+
setActiveProfileBySelector
|
|
14
|
+
} from "../src/config/index.js"
|
|
15
|
+
|
|
16
|
+
const TEST_HOME = "/tmp/atlassian-common-profile-test"
|
|
17
|
+
const TOOL_NAME = "test-tool"
|
|
18
|
+
const basePath = `${TEST_HOME}/.config/atlassian/${TOOL_NAME}`
|
|
19
|
+
const authPath = `${basePath}/auth.json`
|
|
20
|
+
const profilesPath = `${basePath}/profiles.json`
|
|
21
|
+
|
|
22
|
+
const fsError = (method: string) =>
|
|
23
|
+
Effect.fail(
|
|
24
|
+
new SystemError({ _tag: "NotFound", module: "FileSystem", method, description: "mock not found" })
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const makeToken = (n: number): OAuthToken => ({
|
|
28
|
+
access_token: `access-${n}`,
|
|
29
|
+
refresh_token: `refresh-${n}`,
|
|
30
|
+
expires_at: Date.now() + 60_000,
|
|
31
|
+
scope: "read:me offline_access",
|
|
32
|
+
cloud_id: `cloud-${n}`,
|
|
33
|
+
site_url: `https://site-${n}.atlassian.net`,
|
|
34
|
+
user: {
|
|
35
|
+
account_id: `account-${n}`,
|
|
36
|
+
name: `User ${n}`,
|
|
37
|
+
email: `user-${n}@example.com`
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const makeMockFS = (files: Record<string, string> = {}) => {
|
|
42
|
+
const store = { ...files }
|
|
43
|
+
const partial: Partial<FileSystem.FileSystem> = {
|
|
44
|
+
exists: (path) => Effect.succeed(path in store),
|
|
45
|
+
readFileString: (path) => path in store ? Effect.succeed(store[path]!) : fsError("readFileString"),
|
|
46
|
+
writeFileString: (path: string, content: string) => {
|
|
47
|
+
store[path] = content
|
|
48
|
+
return Effect.void
|
|
49
|
+
},
|
|
50
|
+
makeDirectory: () => Effect.void,
|
|
51
|
+
chmod: () => Effect.void,
|
|
52
|
+
remove: (path: string) => {
|
|
53
|
+
delete store[path]
|
|
54
|
+
return Effect.void
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
store,
|
|
60
|
+
layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ConfigProviderLive = ConfigProvider.layer(
|
|
65
|
+
ConfigProvider.fromEnv({ env: { HOME: TEST_HOME } })
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const run = <A>(
|
|
69
|
+
effect: Effect.Effect<A, unknown, FileSystem.FileSystem | Path.Path>,
|
|
70
|
+
files?: Record<string, string>
|
|
71
|
+
) => {
|
|
72
|
+
const mock = makeMockFS(files)
|
|
73
|
+
const layer = Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive)
|
|
74
|
+
return Effect.runPromise(effect.pipe(Effect.provide(layer))).then((result) => ({ result, store: mock.store }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("AuthProfiles", () => {
|
|
78
|
+
it("upserts profiles and mirrors the active profile to auth.json", async () => {
|
|
79
|
+
const first = makeToken(1)
|
|
80
|
+
const second = makeToken(2)
|
|
81
|
+
|
|
82
|
+
const { result: profiles, store } = await run(
|
|
83
|
+
Effect.gen(function*() {
|
|
84
|
+
yield* saveProfileToken(TOOL_NAME, first)
|
|
85
|
+
yield* saveProfileToken(TOOL_NAME, second)
|
|
86
|
+
return yield* loadProfiles(TOOL_NAME)
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(profiles.profiles.map((profile) => profile.id)).toEqual(["account-2@cloud-2", "account-1@cloud-1"])
|
|
91
|
+
expect(profiles.activeProfileId).toBe("account-2@cloud-2")
|
|
92
|
+
expect(JSON.parse(store[authPath]!)["access_token"]).toBe("access-2")
|
|
93
|
+
expect(JSON.parse(store[profilesPath]!)["profiles"]).toHaveLength(2)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("switches and removes profiles by flexible selector", async () => {
|
|
97
|
+
const first = makeToken(1)
|
|
98
|
+
const second = makeToken(2)
|
|
99
|
+
|
|
100
|
+
const { result, store } = await run(
|
|
101
|
+
Effect.gen(function*() {
|
|
102
|
+
yield* saveProfileToken(TOOL_NAME, first)
|
|
103
|
+
yield* saveProfileToken(TOOL_NAME, second)
|
|
104
|
+
const selected = yield* setActiveProfileBySelector(TOOL_NAME, first.site_url)
|
|
105
|
+
const activeAfterSwitch = yield* loadActiveProfileToken(TOOL_NAME)
|
|
106
|
+
const removed = yield* deleteProfileBySelector(TOOL_NAME, first.user!.account_id)
|
|
107
|
+
const activeAfterRemove = yield* loadActiveProfileToken(TOOL_NAME)
|
|
108
|
+
return { selected, activeAfterSwitch, removed, activeAfterRemove }
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(result.selected?.id).toBe("account-1@cloud-1")
|
|
113
|
+
expect(result.activeAfterSwitch?.access_token).toBe("access-1")
|
|
114
|
+
expect(result.removed?.id).toBe("account-1@cloud-1")
|
|
115
|
+
expect(result.activeAfterRemove?.access_token).toBe("access-2")
|
|
116
|
+
expect(JSON.parse(store[authPath]!)["access_token"]).toBe("access-2")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("loads a legacy auth.json token as a single active profile", async () => {
|
|
120
|
+
const token = makeToken(1)
|
|
121
|
+
|
|
122
|
+
const { result } = await run(
|
|
123
|
+
loadProfiles(TOOL_NAME),
|
|
124
|
+
{ [authPath]: JSON.stringify(token, null, 2) }
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(result.activeProfileId).toBe("account-1@cloud-1")
|
|
128
|
+
expect(result.profiles).toHaveLength(1)
|
|
129
|
+
expect(result.profiles[0]?.token.access_token).toBe("access-1")
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { ConfigProvider, Effect, Layer } from "effect"
|
|
3
|
+
import * as FileSystem from "effect/FileSystem"
|
|
4
|
+
import * as Path from "effect/Path"
|
|
5
|
+
import { SystemError } from "effect/PlatformError"
|
|
6
|
+
import {
|
|
7
|
+
type AtlassianToolDefinition,
|
|
8
|
+
HomeDirectoryLive,
|
|
9
|
+
inspectAllToolProfiles,
|
|
10
|
+
migrateLegacyProfiles,
|
|
11
|
+
MissingOAuthConfigError,
|
|
12
|
+
missingScopes,
|
|
13
|
+
type OAuthToken,
|
|
14
|
+
ProfileNotFoundError,
|
|
15
|
+
refreshActiveProfiles,
|
|
16
|
+
saveProfileToken,
|
|
17
|
+
useProfileForAllTools
|
|
18
|
+
} from "../src/config/index.js"
|
|
19
|
+
|
|
20
|
+
const TEST_HOME = "/tmp/atlassian-common-manager-test"
|
|
21
|
+
const tools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
22
|
+
{ toolName: "tool-a", label: "Tool A", loginHint: "tool-a login", requiredScopes: ["read:me", "offline_access"] },
|
|
23
|
+
{ toolName: "tool-b", label: "Tool B", loginHint: "tool-b login", requiredScopes: ["read:me", "write:jira-work"] }
|
|
24
|
+
]
|
|
25
|
+
const sharedStoreTools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
26
|
+
tools[0]!,
|
|
27
|
+
{
|
|
28
|
+
toolName: "tool-c",
|
|
29
|
+
authStoreName: "tool-a",
|
|
30
|
+
label: "Tool C",
|
|
31
|
+
loginHint: "tool-c login",
|
|
32
|
+
requiredScopes: ["read:me"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const fsError = (method: string) =>
|
|
37
|
+
Effect.fail(new SystemError({ _tag: "NotFound", module: "FileSystem", method, description: "mock not found" }))
|
|
38
|
+
|
|
39
|
+
const makeToken = (n: number, scope = "read:me offline_access", expiresAt = Date.now() + 60_000): OAuthToken => ({
|
|
40
|
+
access_token: `access-${n}`,
|
|
41
|
+
refresh_token: `refresh-${n}`,
|
|
42
|
+
expires_at: expiresAt,
|
|
43
|
+
scope,
|
|
44
|
+
cloud_id: `cloud-${n}`,
|
|
45
|
+
site_url: `https://site-${n}.atlassian.net`,
|
|
46
|
+
user: { account_id: `account-${n}`, name: `User ${n}`, email: `user-${n}@example.com` }
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const makeMockFS = () => {
|
|
50
|
+
const store: Record<string, string> = {}
|
|
51
|
+
const partial: Partial<FileSystem.FileSystem> = {
|
|
52
|
+
exists: (path) => Effect.succeed(path in store),
|
|
53
|
+
readFileString: (path) => path in store ? Effect.succeed(store[path]!) : fsError("readFileString"),
|
|
54
|
+
writeFileString: (path: string, content: string) => {
|
|
55
|
+
store[path] = content
|
|
56
|
+
return Effect.void
|
|
57
|
+
},
|
|
58
|
+
makeDirectory: () => Effect.void,
|
|
59
|
+
chmod: () => Effect.void,
|
|
60
|
+
remove: (path: string) => {
|
|
61
|
+
delete store[path]
|
|
62
|
+
return Effect.void
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
store,
|
|
67
|
+
layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ConfigProviderLive = ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: TEST_HOME } }))
|
|
72
|
+
|
|
73
|
+
const run = async <A>(effect: Effect.Effect<A, unknown, FileSystem.FileSystem | Path.Path>) => {
|
|
74
|
+
const mock = makeMockFS()
|
|
75
|
+
const layer = Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive)
|
|
76
|
+
const result = await Effect.runPromise(effect.pipe(Effect.provide(layer)))
|
|
77
|
+
return { result, store: mock.store }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("ProfileManager", () => {
|
|
81
|
+
it("reports missing scopes from a token", () => {
|
|
82
|
+
expect(missingScopes(makeToken(1), ["read:me", "write:jira-work"])).toEqual(["write:jira-work"])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("inspects active profile usage and scope status per tool", async () => {
|
|
86
|
+
const { result } = await run(
|
|
87
|
+
Effect.gen(function*() {
|
|
88
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
89
|
+
yield* saveProfileToken("tool-b", makeToken(2))
|
|
90
|
+
return yield* inspectAllToolProfiles(tools)
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-2@cloud-2"])
|
|
95
|
+
expect(result[0]!.missingScopes).toEqual([])
|
|
96
|
+
expect(result[1]!.missingScopes).toEqual(["write:jira-work"])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("switches matching profiles without copying tokens between auth stores", async () => {
|
|
100
|
+
const { result, store } = await run(
|
|
101
|
+
Effect.gen(function*() {
|
|
102
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
103
|
+
yield* saveProfileToken("tool-b", makeToken(2, "read:me write:jira-work"))
|
|
104
|
+
return yield* useProfileForAllTools("https://site-2.atlassian.net", tools)
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-2@cloud-2"])
|
|
109
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/tool-a/auth.json`]!)["access_token"]).toBe("access-1")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("reports multiple tools that share one auth store", async () => {
|
|
113
|
+
const { result } = await run(
|
|
114
|
+
Effect.gen(function*() {
|
|
115
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
116
|
+
return yield* inspectAllToolProfiles(sharedStoreTools)
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(result.map((status) => status.authStoreName)).toEqual(["tool-a", "tool-a"])
|
|
121
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-1@cloud-1"])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("migrates XDG legacy auth.json tokens into profile stores", async () => {
|
|
125
|
+
const token = makeToken(3)
|
|
126
|
+
const { result, store } = await run(
|
|
127
|
+
Effect.gen(function*() {
|
|
128
|
+
yield* FileSystem.FileSystem.pipe(
|
|
129
|
+
Effect.flatMap((fs) =>
|
|
130
|
+
fs.writeFileString(`${TEST_HOME}/.config/atlassian/tool-a/auth.json`, JSON.stringify(token, null, 2))
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return yield* migrateLegacyProfiles(tools)
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
expect(result[0]!.activeProfile?.id).toBe("account-3@cloud-3")
|
|
138
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/tool-a/profiles.json`]!)["profiles"]).toHaveLength(1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("migrates tool-specific legacy auth.json tokens into profile stores", async () => {
|
|
142
|
+
const token = makeToken(4)
|
|
143
|
+
const legacyTools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
144
|
+
{
|
|
145
|
+
toolName: "legacy-tool",
|
|
146
|
+
legacyAuthPath: [".legacy-tool", "auth.json"],
|
|
147
|
+
label: "Legacy Tool",
|
|
148
|
+
loginHint: "legacy login",
|
|
149
|
+
requiredScopes: ["read:me"]
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
const { result, store } = await run(
|
|
153
|
+
Effect.gen(function*() {
|
|
154
|
+
yield* FileSystem.FileSystem.pipe(
|
|
155
|
+
Effect.flatMap((fs) =>
|
|
156
|
+
fs.writeFileString(`${TEST_HOME}/.legacy-tool/auth.json`, JSON.stringify(token, null, 2))
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return yield* migrateLegacyProfiles(legacyTools)
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(result[0]!.activeProfile?.id).toBe("account-4@cloud-4")
|
|
164
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/legacy-tool/profiles.json`]!)["profiles"]).toHaveLength(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("fails when selecting a missing profile", async () => {
|
|
168
|
+
await expect(
|
|
169
|
+
run(
|
|
170
|
+
Effect.gen(function*() {
|
|
171
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
172
|
+
return yield* useProfileForAllTools("missing", tools)
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
).rejects.toBeInstanceOf(ProfileNotFoundError)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("fails expired-token refresh when OAuth config is missing", async () => {
|
|
179
|
+
await expect(
|
|
180
|
+
run(
|
|
181
|
+
Effect.gen(function*() {
|
|
182
|
+
yield* saveProfileToken("tool-a", makeToken(1, "read:me offline_access", Date.now() - 1_000))
|
|
183
|
+
return yield* refreshActiveProfiles([tools[0]!])
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
).rejects.toBeInstanceOf(MissingOAuthConfigError)
|
|
187
|
+
})
|
|
188
|
+
})
|