@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +25 -10
  3. package/dist/Brand.d.ts +15 -15
  4. package/dist/Brand.d.ts.map +1 -1
  5. package/dist/Brand.js +5 -5
  6. package/dist/Brand.js.map +1 -1
  7. package/dist/Hash.d.ts +12 -14
  8. package/dist/Hash.d.ts.map +1 -1
  9. package/dist/Hash.js +18 -24
  10. package/dist/Hash.js.map +1 -1
  11. package/dist/ast/BlockNode.d.ts +120 -177
  12. package/dist/ast/BlockNode.d.ts.map +1 -1
  13. package/dist/ast/BlockNode.js +28 -8
  14. package/dist/ast/BlockNode.js.map +1 -1
  15. package/dist/ast/Document.d.ts +114 -156
  16. package/dist/ast/Document.d.ts.map +1 -1
  17. package/dist/ast/Document.js +1 -1
  18. package/dist/ast/Document.js.map +1 -1
  19. package/dist/ast/InlineNode.d.ts +59 -247
  20. package/dist/ast/InlineNode.d.ts.map +1 -1
  21. package/dist/ast/InlineNode.js +28 -3
  22. package/dist/ast/InlineNode.js.map +1 -1
  23. package/dist/ast/MacroNode.d.ts +62 -102
  24. package/dist/ast/MacroNode.d.ts.map +1 -1
  25. package/dist/ast/MacroNode.js +12 -5
  26. package/dist/ast/MacroNode.js.map +1 -1
  27. package/dist/auth/OAuthOperations.d.ts +1 -1
  28. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  29. package/dist/auth/OAuthOperations.js +5 -6
  30. package/dist/auth/OAuthOperations.js.map +1 -1
  31. package/dist/auth/OAuthResponseSchemas.d.ts +13 -13
  32. package/dist/bin.d.ts +3 -0
  33. package/dist/bin.d.ts.map +1 -0
  34. package/dist/bin.js +58 -0
  35. package/dist/bin.js.map +1 -0
  36. package/dist/config/AuthProfiles.d.ts +342 -0
  37. package/dist/config/AuthProfiles.d.ts.map +1 -0
  38. package/dist/config/AuthProfiles.js +275 -0
  39. package/dist/config/AuthProfiles.js.map +1 -0
  40. package/dist/config/ConfigPaths.d.ts +12 -26
  41. package/dist/config/ConfigPaths.d.ts.map +1 -1
  42. package/dist/config/ConfigPaths.js +33 -3
  43. package/dist/config/ConfigPaths.js.map +1 -1
  44. package/dist/config/OAuthSchemas.d.ts +24 -24
  45. package/dist/config/ProfileManager.d.ts +92 -0
  46. package/dist/config/ProfileManager.d.ts.map +1 -0
  47. package/dist/config/ProfileManager.js +175 -0
  48. package/dist/config/ProfileManager.js.map +1 -0
  49. package/dist/config/TokenStorage.d.ts +5 -24
  50. package/dist/config/TokenStorage.d.ts.map +1 -1
  51. package/dist/config/TokenStorage.js +35 -22
  52. package/dist/config/TokenStorage.js.map +1 -1
  53. package/dist/config/index.d.ts +3 -1
  54. package/dist/config/index.d.ts.map +1 -1
  55. package/dist/config/index.js +5 -1
  56. package/dist/config/index.js.map +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/package.json +17 -11
  62. package/src/Brand.ts +5 -17
  63. package/src/Hash.ts +27 -27
  64. package/src/ast/BlockNode.ts +13 -13
  65. package/src/ast/Document.ts +1 -1
  66. package/src/ast/InlineNode.ts +5 -5
  67. package/src/ast/MacroNode.ts +7 -6
  68. package/src/auth/OAuthOperations.ts +5 -6
  69. package/src/bin.ts +115 -0
  70. package/src/config/AuthProfiles.ts +384 -0
  71. package/src/config/ConfigPaths.ts +21 -7
  72. package/src/config/ProfileManager.ts +268 -0
  73. package/src/config/TokenStorage.ts +23 -27
  74. package/src/config/index.ts +42 -0
  75. package/src/index.ts +1 -1
  76. package/test/AuthProfiles.test.ts +131 -0
  77. package/test/Hash.test.ts +52 -0
  78. package/test/OAuthOperations.test.ts +2 -3
  79. package/test/ProfileManager.test.ts +188 -0
  80. 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.decodeUnknown` —
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.catchAll(() => Effect.succeed(false))
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.decodeUnknown(OAuthTokenSchema)(parsed).pipe(
88
- Effect.catchAll((e) =>
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 | Error.PlatformError,
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.catchAll(() => Effect.void)
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.catchAll(() => Effect.succeed(false))
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.decodeUnknown(OAuthConfigSchema)(parsed).pipe(
184
- Effect.catchAll((e) =>
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 | Error.PlatformError,
201
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
206
202
  FileSystem.FileSystem | Path.Path | HomeDirectoryTag
207
203
  > =>
208
204
  Effect.gen(function*() {
@@ -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, hashContentSync, hashEquals } from "./Hash.js"
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 {