@knpkv/atlassian-common 0.2.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 +31 -0
- package/README.md +25 -10
- package/dist/Brand.d.ts +15 -15
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js +5 -5
- package/dist/Brand.js.map +1 -1
- package/dist/Hash.d.ts +12 -14
- package/dist/Hash.d.ts.map +1 -1
- package/dist/Hash.js +18 -24
- package/dist/Hash.js.map +1 -1
- package/dist/ast/BlockNode.d.ts +120 -177
- package/dist/ast/BlockNode.d.ts.map +1 -1
- package/dist/ast/BlockNode.js +28 -8
- package/dist/ast/BlockNode.js.map +1 -1
- package/dist/ast/Document.d.ts +114 -156
- package/dist/ast/Document.d.ts.map +1 -1
- package/dist/ast/Document.js +1 -1
- package/dist/ast/Document.js.map +1 -1
- package/dist/ast/InlineNode.d.ts +59 -247
- package/dist/ast/InlineNode.d.ts.map +1 -1
- package/dist/ast/InlineNode.js +28 -3
- package/dist/ast/InlineNode.js.map +1 -1
- package/dist/ast/MacroNode.d.ts +62 -102
- package/dist/ast/MacroNode.d.ts.map +1 -1
- package/dist/ast/MacroNode.js +12 -5
- package/dist/ast/MacroNode.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +1 -1
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +5 -6
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/OAuthResponseSchemas.d.ts +13 -13
- 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 +12 -26
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ConfigPaths.js +33 -3
- package/dist/config/ConfigPaths.js.map +1 -1
- package/dist/config/OAuthSchemas.d.ts +24 -24
- 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/TokenStorage.d.ts +5 -24
- package/dist/config/TokenStorage.d.ts.map +1 -1
- package/dist/config/TokenStorage.js +35 -22
- package/dist/config/TokenStorage.js.map +1 -1
- 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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +17 -11
- package/src/Brand.ts +5 -17
- package/src/Hash.ts +27 -27
- package/src/ast/BlockNode.ts +13 -13
- package/src/ast/Document.ts +1 -1
- package/src/ast/InlineNode.ts +5 -5
- package/src/ast/MacroNode.ts +7 -6
- package/src/auth/OAuthOperations.ts +5 -6
- package/src/bin.ts +115 -0
- package/src/config/AuthProfiles.ts +384 -0
- package/src/config/ConfigPaths.ts +21 -7
- package/src/config/ProfileManager.ts +268 -0
- package/src/config/TokenStorage.ts +23 -27
- package/src/config/index.ts +42 -0
- package/src/index.ts +1 -1
- package/test/AuthProfiles.test.ts +131 -0
- package/test/Hash.test.ts +52 -0
- package/test/OAuthOperations.test.ts +2 -3
- 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
|
+
})
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - **Load returns null, not error**: {@link loadToken} and {@link loadOAuthConfig} return
|
|
7
7
|
* `null` for missing/corrupt files — callers decide whether absence is an error.
|
|
8
8
|
* - **Save is atomic**: Writes go through {@link writeSecureFile} with `0o600` permissions.
|
|
9
|
-
* - **Schema-gated reads**: JSON is parsed then validated via `Schema.
|
|
9
|
+
* - **Schema-gated reads**: JSON is parsed then validated via `Schema.decodeUnknownEffect` —
|
|
10
10
|
* corrupt data is treated as absent rather than crashing.
|
|
11
11
|
*
|
|
12
12
|
* **Common tasks**
|
|
@@ -17,16 +17,24 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
-
import type * as Error from "@effect/platform/Error"
|
|
21
|
-
import * as FileSystem from "@effect/platform/FileSystem"
|
|
22
|
-
import type * as Path from "@effect/platform/Path"
|
|
23
20
|
import * as Data from "effect/Data"
|
|
24
21
|
import * as Effect from "effect/Effect"
|
|
22
|
+
import * as FileSystem from "effect/FileSystem"
|
|
23
|
+
import type * as Path from "effect/Path"
|
|
24
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
25
25
|
import * as Schema from "effect/Schema"
|
|
26
26
|
import { type HomeDirectoryError, type HomeDirectoryTag } from "./ConfigPaths.js"
|
|
27
27
|
import { ensureConfigDir, getAuthPath, getOAuthConfigPath, writeSecureFile } from "./ConfigPaths.js"
|
|
28
28
|
import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
|
|
29
29
|
|
|
30
|
+
const parseJsonOrNull = (content: string): unknown | null => {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(content) as unknown
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* Error during file system operations.
|
|
32
40
|
*
|
|
@@ -62,7 +70,7 @@ export const loadToken = (
|
|
|
62
70
|
const tokenPath = yield* getAuthPath(toolName)
|
|
63
71
|
|
|
64
72
|
const exists = yield* fs.exists(tokenPath).pipe(
|
|
65
|
-
Effect.
|
|
73
|
+
Effect.catchCause(() => Effect.succeed(false))
|
|
66
74
|
)
|
|
67
75
|
if (!exists) {
|
|
68
76
|
return null
|
|
@@ -72,20 +80,14 @@ export const loadToken = (
|
|
|
72
80
|
Effect.mapError((cause) => new FileSystemError({ operation: "read", path: tokenPath, cause }))
|
|
73
81
|
)
|
|
74
82
|
|
|
75
|
-
const parsed = yield* Effect.sync(() =>
|
|
76
|
-
try {
|
|
77
|
-
return JSON.parse(content) as unknown
|
|
78
|
-
} catch {
|
|
79
|
-
return null
|
|
80
|
-
}
|
|
81
|
-
})
|
|
83
|
+
const parsed = yield* Effect.sync(() => parseJsonOrNull(content))
|
|
82
84
|
if (parsed === null) {
|
|
83
85
|
yield* Effect.logWarning(`Corrupted auth.json at ${tokenPath} — could not parse JSON`)
|
|
84
86
|
return null
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
return yield* Schema.
|
|
88
|
-
Effect.
|
|
89
|
+
return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
|
|
90
|
+
Effect.catchCause((e) =>
|
|
89
91
|
Effect.logWarning(`Invalid token schema in ${tokenPath}: ${e}`).pipe(
|
|
90
92
|
Effect.map(() => null)
|
|
91
93
|
)
|
|
@@ -106,7 +108,7 @@ export const saveToken = (
|
|
|
106
108
|
token: OAuthToken
|
|
107
109
|
): Effect.Effect<
|
|
108
110
|
void,
|
|
109
|
-
FileSystemError | HomeDirectoryError |
|
|
111
|
+
FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
|
|
110
112
|
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
111
113
|
> =>
|
|
112
114
|
Effect.gen(function*() {
|
|
@@ -134,7 +136,7 @@ export const deleteToken = (
|
|
|
134
136
|
const tokenPath = yield* getAuthPath(toolName)
|
|
135
137
|
|
|
136
138
|
yield* fs.remove(tokenPath).pipe(
|
|
137
|
-
Effect.
|
|
139
|
+
Effect.catchCause(() => Effect.void)
|
|
138
140
|
)
|
|
139
141
|
})
|
|
140
142
|
|
|
@@ -158,7 +160,7 @@ export const loadOAuthConfig = (
|
|
|
158
160
|
const configPath = yield* getOAuthConfigPath(toolName)
|
|
159
161
|
|
|
160
162
|
const exists = yield* fs.exists(configPath).pipe(
|
|
161
|
-
Effect.
|
|
163
|
+
Effect.catchCause(() => Effect.succeed(false))
|
|
162
164
|
)
|
|
163
165
|
if (!exists) {
|
|
164
166
|
return null
|
|
@@ -168,20 +170,14 @@ export const loadOAuthConfig = (
|
|
|
168
170
|
Effect.mapError((cause) => new FileSystemError({ operation: "read", path: configPath, cause }))
|
|
169
171
|
)
|
|
170
172
|
|
|
171
|
-
const parsed = yield* Effect.sync(() =>
|
|
172
|
-
try {
|
|
173
|
-
return JSON.parse(content) as unknown
|
|
174
|
-
} catch {
|
|
175
|
-
return null
|
|
176
|
-
}
|
|
177
|
-
})
|
|
173
|
+
const parsed = yield* Effect.sync(() => parseJsonOrNull(content))
|
|
178
174
|
if (parsed === null) {
|
|
179
175
|
yield* Effect.logWarning(`Corrupted oauth config at ${configPath} — could not parse JSON`)
|
|
180
176
|
return null
|
|
181
177
|
}
|
|
182
178
|
|
|
183
|
-
return yield* Schema.
|
|
184
|
-
Effect.
|
|
179
|
+
return yield* Schema.decodeUnknownEffect(OAuthConfigSchema)(parsed).pipe(
|
|
180
|
+
Effect.catchCause((e) =>
|
|
185
181
|
Effect.logWarning(`Invalid OAuth config schema in ${configPath}: ${e}`).pipe(
|
|
186
182
|
Effect.map(() => null)
|
|
187
183
|
)
|
|
@@ -202,7 +198,7 @@ export const saveOAuthConfig = (
|
|
|
202
198
|
config: OAuthConfig
|
|
203
199
|
): Effect.Effect<
|
|
204
200
|
void,
|
|
205
|
-
FileSystemError | HomeDirectoryError |
|
|
201
|
+
FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
|
|
206
202
|
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
207
203
|
> =>
|
|
208
204
|
Effect.gen(function*() {
|
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"
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export {
|
|
|
19
19
|
} from "./Brand.js"
|
|
20
20
|
|
|
21
21
|
// Hash utilities
|
|
22
|
-
export { hashBuffer, hashContent,
|
|
22
|
+
export { hashBuffer, hashContent, hashEquals } from "./Hash.js"
|
|
23
23
|
|
|
24
24
|
// Error types
|
|
25
25
|
export { ParseError, SerializeError } from "./SerializeError.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,52 @@
|
|
|
1
|
+
import { it as effectIt } from "@effect/vitest"
|
|
2
|
+
import * as Crypto from "effect/Crypto"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { ContentHash } from "../src/Brand.js"
|
|
6
|
+
import { hashBuffer, hashContent, hashEquals } from "../src/Hash.js"
|
|
7
|
+
|
|
8
|
+
const byteSequenceDigest = Uint8Array.from({ length: 32 }, (_, index) => index)
|
|
9
|
+
const byteSequenceHash = ContentHash("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
|
10
|
+
|
|
11
|
+
describe("Hash", () => {
|
|
12
|
+
effectIt.effect("delegates SHA-256 hashing to the provided Crypto service", () =>
|
|
13
|
+
Effect.gen(function*() {
|
|
14
|
+
const hash = yield* hashContent("hello")
|
|
15
|
+
expect(hash).toBe(byteSequenceHash)
|
|
16
|
+
}).pipe(
|
|
17
|
+
Effect.provideService(
|
|
18
|
+
Crypto.Crypto,
|
|
19
|
+
Crypto.make({
|
|
20
|
+
randomBytes: (size) => new Uint8Array(size),
|
|
21
|
+
digest: (algorithm, data) => {
|
|
22
|
+
expect(algorithm).toBe("SHA-256")
|
|
23
|
+
expect(Array.from(data)).toEqual([104, 101, 108, 108, 111])
|
|
24
|
+
return Effect.succeed(byteSequenceDigest)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
effectIt.effect("hashes binary content through the provided Crypto service", () =>
|
|
31
|
+
Effect.gen(function*() {
|
|
32
|
+
const hash = yield* hashBuffer(Uint8Array.of(1, 2, 3))
|
|
33
|
+
expect(hash).toBe(byteSequenceHash)
|
|
34
|
+
}).pipe(
|
|
35
|
+
Effect.provideService(
|
|
36
|
+
Crypto.Crypto,
|
|
37
|
+
Crypto.make({
|
|
38
|
+
randomBytes: (size) => new Uint8Array(size),
|
|
39
|
+
digest: (algorithm, data) => {
|
|
40
|
+
expect(algorithm).toBe("SHA-256")
|
|
41
|
+
expect(Array.from(data)).toEqual([1, 2, 3])
|
|
42
|
+
return Effect.succeed(byteSequenceDigest)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
it("compares content hashes by value", () => {
|
|
49
|
+
expect(hashEquals(byteSequenceHash, byteSequenceHash)).toBe(true)
|
|
50
|
+
expect(hashEquals(byteSequenceHash, ContentHash("f".repeat(64)))).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import * as HttpClient from "@effect/platform/HttpClient"
|
|
2
|
-
import type * as HttpClientRequest from "@effect/platform/HttpClientRequest"
|
|
3
|
-
import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
|
|
4
1
|
import { describe, it } from "@effect/vitest"
|
|
5
2
|
import * as Effect from "effect/Effect"
|
|
6
3
|
import * as Layer from "effect/Layer"
|
|
4
|
+
import type { HttpClientRequest } from "effect/unstable/http"
|
|
5
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
7
6
|
import { expect } from "vitest"
|
|
8
7
|
import { OAuthError } from "../src/auth/OAuthErrors.js"
|
|
9
8
|
import {
|