@knpkv/atlassian-common 0.3.0 → 1.0.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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +29 -13
  3. package/dist/Hash.d.ts +4 -4
  4. package/dist/Hash.js +6 -6
  5. package/dist/Hash.js.map +1 -1
  6. package/dist/auth/OAuthEndpoints.d.ts +6 -4
  7. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  8. package/dist/auth/OAuthEndpoints.js +12 -7
  9. package/dist/auth/OAuthEndpoints.js.map +1 -1
  10. package/dist/auth/OAuthErrors.d.ts.map +1 -1
  11. package/dist/auth/OAuthErrors.js +4 -3
  12. package/dist/auth/OAuthErrors.js.map +1 -1
  13. package/dist/auth/OAuthOperations.d.ts +0 -20
  14. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  15. package/dist/auth/OAuthOperations.js +3 -1
  16. package/dist/auth/OAuthOperations.js.map +1 -1
  17. package/dist/auth/uuid.d.ts +5 -3
  18. package/dist/auth/uuid.d.ts.map +1 -1
  19. package/dist/auth/uuid.js +7 -3
  20. package/dist/auth/uuid.js.map +1 -1
  21. package/dist/bin.d.ts +3 -0
  22. package/dist/bin.d.ts.map +1 -0
  23. package/dist/bin.js +58 -0
  24. package/dist/bin.js.map +1 -0
  25. package/dist/config/AuthProfiles.d.ts +342 -0
  26. package/dist/config/AuthProfiles.d.ts.map +1 -0
  27. package/dist/config/AuthProfiles.js +275 -0
  28. package/dist/config/AuthProfiles.js.map +1 -0
  29. package/dist/config/ConfigPaths.d.ts +6 -0
  30. package/dist/config/ConfigPaths.d.ts.map +1 -1
  31. package/dist/config/ConfigPaths.js +10 -0
  32. package/dist/config/ConfigPaths.js.map +1 -1
  33. package/dist/config/ProfileManager.d.ts +92 -0
  34. package/dist/config/ProfileManager.d.ts.map +1 -0
  35. package/dist/config/ProfileManager.js +175 -0
  36. package/dist/config/ProfileManager.js.map +1 -0
  37. package/dist/config/index.d.ts +3 -1
  38. package/dist/config/index.d.ts.map +1 -1
  39. package/dist/config/index.js +5 -1
  40. package/dist/config/index.js.map +1 -1
  41. package/package.json +11 -3
  42. package/src/Hash.ts +6 -6
  43. package/src/auth/OAuthEndpoints.ts +18 -11
  44. package/src/auth/OAuthErrors.ts +4 -3
  45. package/src/auth/OAuthOperations.ts +3 -1
  46. package/src/auth/uuid.ts +9 -3
  47. package/src/bin.ts +115 -0
  48. package/src/config/AuthProfiles.ts +384 -0
  49. package/src/config/ConfigPaths.ts +14 -0
  50. package/src/config/ProfileManager.ts +268 -0
  51. package/src/config/index.ts +42 -0
  52. package/test/AuthProfiles.test.ts +131 -0
  53. package/test/OAuthEndpoints.test.ts +20 -15
  54. package/test/OAuthOperations.test.ts +5 -1
  55. package/test/ProfileManager.test.ts +188 -0
  56. package/tsconfig.tsbuildinfo +1 -1
package/src/bin.ts ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import { NodeFileSystem, NodeHttpClient, NodePath, NodeRuntime, NodeServices, NodeStdio } from "@effect/platform-node"
3
+ import * as Console from "effect/Console"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Layer from "effect/Layer"
6
+ import * as Stdio from "effect/Stdio"
7
+ import { Argument as Args, Command } from "effect/unstable/cli"
8
+ import pkg from "../package.json" with { type: "json" }
9
+ import {
10
+ HomeDirectoryLive,
11
+ inspectAllToolProfiles,
12
+ migrateLegacyProfiles,
13
+ refreshActiveProfiles,
14
+ type ToolProfileStatus,
15
+ useProfileForAllTools
16
+ } from "./config/index.js"
17
+
18
+ const profileArg = Args.string("profile").pipe(
19
+ Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
20
+ )
21
+
22
+ const printStatuses = (statuses: ReadonlyArray<ToolProfileStatus>) =>
23
+ Effect.gen(function*() {
24
+ for (const status of statuses) {
25
+ const active = status.activeProfile
26
+ yield* Console.log(`${status.tool.label} (${status.tool.toolName})`)
27
+ if (status.authStoreName !== status.tool.toolName) {
28
+ yield* Console.log(` Auth store: ${status.authStoreName}`)
29
+ }
30
+ yield* Console.log(` Active: ${active ? active.name : "none"}`)
31
+ if (active) {
32
+ yield* Console.log(` Profile ID: ${active.id}`)
33
+ yield* Console.log(` Site: ${active.token.site_url}`)
34
+ yield* Console.log(` Token: ${status.tokenStatus}`)
35
+ yield* Console.log(
36
+ ` Scopes: ${status.missingScopes.length === 0 ? "ok" : `missing ${status.missingScopes.join(", ")}`}`
37
+ )
38
+ }
39
+ }
40
+ })
41
+
42
+ const profilesList = Command.make("list", {}, () => inspectAllToolProfiles().pipe(Effect.flatMap(printStatuses)))
43
+ .pipe(Command.withDescription("List active Atlassian profiles for all tools"))
44
+
45
+ const profilesUse = Command.make(
46
+ "use",
47
+ { profile: profileArg },
48
+ ({ profile }) => useProfileForAllTools(profile).pipe(Effect.flatMap(printStatuses))
49
+ ).pipe(Command.withDescription("Use a profile across Atlassian tools"))
50
+
51
+ const profilesDoctor = Command.make("doctor", {}, () =>
52
+ inspectAllToolProfiles().pipe(
53
+ Effect.flatMap((statuses) =>
54
+ Effect.gen(function*() {
55
+ yield* printStatuses(statuses)
56
+ for (const status of statuses) {
57
+ if (!status.activeProfile) {
58
+ yield* Console.log(`Suggestion: run '${status.tool.loginHint}' for ${status.tool.label}.`)
59
+ }
60
+ if (status.tokenStatus === "expired") {
61
+ yield* Console.log(
62
+ `Suggestion: run 'atlassian auth refresh' or '${status.tool.loginHint}' for ${status.tool.label}.`
63
+ )
64
+ }
65
+ if (status.missingScopes.length > 0) {
66
+ yield* Console.log(
67
+ `Suggestion: re-login with ${status.tool.label}; missing scopes: ${status.missingScopes.join(", ")}.`
68
+ )
69
+ }
70
+ }
71
+ })
72
+ )
73
+ )).pipe(Command.withDescription("Diagnose tokens, scopes, and active profile usage"))
74
+
75
+ const profilesMigrate = Command.make("migrate", {}, () => migrateLegacyProfiles().pipe(Effect.flatMap(printStatuses)))
76
+ .pipe(Command.withDescription("Migrate legacy auth.json files into shared profiles.json storage"))
77
+
78
+ const profiles = Command.make("profiles").pipe(
79
+ Command.withDescription("Manage shared Atlassian auth profiles"),
80
+ Command.withSubcommands([profilesList, profilesUse, profilesDoctor, profilesMigrate])
81
+ )
82
+
83
+ const authRefresh = Command.make("refresh", {}, () => refreshActiveProfiles().pipe(Effect.flatMap(printStatuses)))
84
+ .pipe(Command.withDescription("Refresh expired active OAuth tokens"))
85
+
86
+ const auth = Command.make("auth").pipe(
87
+ Command.withDescription("Manage Atlassian authentication"),
88
+ Command.withSubcommands([authRefresh])
89
+ )
90
+
91
+ const atlassian = Command.make("atlassian", {}, () => Console.log("Usage: atlassian profiles|auth")).pipe(
92
+ Command.withDescription("Unified Atlassian profile manager"),
93
+ Command.withSubcommands([profiles, auth])
94
+ )
95
+
96
+ const cli = Command.runWith(atlassian, { version: pkg.version })
97
+ const program = Effect.gen(function*() {
98
+ const stdio = yield* Stdio.Stdio
99
+ const args = yield* stdio.args
100
+ return yield* cli(args)
101
+ }).pipe(
102
+ Effect.provide(NodeStdio.layer),
103
+ Effect.provide(
104
+ Layer.mergeAll(
105
+ NodeServices.layer,
106
+ NodeFileSystem.layer,
107
+ NodePath.layer,
108
+ NodeHttpClient.layerFetch,
109
+ HomeDirectoryLive
110
+ )
111
+ ),
112
+ Effect.catch((error: unknown) => Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error))))
113
+ )
114
+
115
+ NodeRuntime.runMain(program)
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Multi-account/site auth profile storage for Atlassian CLIs.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - `profiles.json` is the source of truth for multiple logged-in accounts/sites.
7
+ * - The active profile is mirrored to the legacy `auth.json` token file so older
8
+ * single-profile consumers keep working.
9
+ * - Existing single-token installs are treated as a one-profile store on read.
10
+ *
11
+ * @module
12
+ */
13
+ import * as Clock from "effect/Clock"
14
+ import * as Effect from "effect/Effect"
15
+ import * as FileSystem from "effect/FileSystem"
16
+ import type * as Path from "effect/Path"
17
+ import * as Schema from "effect/Schema"
18
+ import {
19
+ ensureConfigDir,
20
+ getProfilesPath,
21
+ type HomeDirectoryError,
22
+ type HomeDirectoryTag,
23
+ writeSecureFile
24
+ } from "./ConfigPaths.js"
25
+ import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
26
+ import { deleteToken, FileSystemError, loadToken, saveToken } from "./TokenStorage.js"
27
+
28
+ /**
29
+ * A persisted Atlassian auth profile.
30
+ *
31
+ * @category Schema
32
+ */
33
+ export const AuthProfileSchema = Schema.Struct({
34
+ /** Stable identifier derived from account and cloud IDs. */
35
+ id: Schema.String,
36
+ /** Human-readable profile label. */
37
+ name: Schema.String,
38
+ /** OAuth token and selected site metadata. */
39
+ token: OAuthTokenSchema,
40
+ /** ISO timestamp when the profile was first created. */
41
+ created_at: Schema.String,
42
+ /** ISO timestamp when the profile was last updated. */
43
+ updated_at: Schema.String
44
+ })
45
+
46
+ /**
47
+ * Type for a persisted Atlassian auth profile.
48
+ *
49
+ * @category Types
50
+ */
51
+ export type AuthProfile = Schema.Schema.Type<typeof AuthProfileSchema>
52
+
53
+ /**
54
+ * Schema for the profile registry file.
55
+ *
56
+ * @category Schema
57
+ */
58
+ export const AuthProfilesFileSchema = Schema.Struct({
59
+ /** Active profile ID. Omitted when no profile is active. */
60
+ activeProfileId: Schema.optional(Schema.String),
61
+ /** Known auth profiles for this CLI. */
62
+ profiles: Schema.Array(AuthProfileSchema)
63
+ })
64
+
65
+ /**
66
+ * Type for the profile registry file.
67
+ *
68
+ * @category Types
69
+ */
70
+ export type AuthProfilesFile = Schema.Schema.Type<typeof AuthProfilesFileSchema>
71
+
72
+ const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
73
+
74
+ const parseJsonOrNull = (content: string): unknown | null => {
75
+ try {
76
+ return JSON.parse(content) as unknown
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ const hostFromUrl = (url: string): string => {
83
+ try {
84
+ return new URL(url).host
85
+ } catch {
86
+ return url
87
+ }
88
+ }
89
+
90
+ const normalizeProfileIdPart = (value: string): string =>
91
+ value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")
92
+
93
+ /**
94
+ * Build a stable profile ID from token site and user identity.
95
+ *
96
+ * @category Utilities
97
+ */
98
+ export const profileIdFromToken = (token: OAuthToken): string => {
99
+ const account = token.user?.account_id ? normalizeProfileIdPart(token.user.account_id) : "unknown-account"
100
+ const site = normalizeProfileIdPart(token.cloud_id || hostFromUrl(token.site_url))
101
+ return `${account}@${site}`
102
+ }
103
+
104
+ /**
105
+ * Build a readable profile label from token site and user identity.
106
+ *
107
+ * @category Utilities
108
+ */
109
+ export const profileNameFromToken = (token: OAuthToken): string => {
110
+ const site = hostFromUrl(token.site_url)
111
+ if (!token.user) return site
112
+ const email = token.user.email.length > 0 ? ` (${token.user.email})` : ""
113
+ return `${token.user.name}${email} @ ${site}`
114
+ }
115
+
116
+ /**
117
+ * Create a profile record from an OAuth token.
118
+ *
119
+ * @category Utilities
120
+ */
121
+ export const profileFromToken = (token: OAuthToken, now: Date): AuthProfile => {
122
+ const timestamp = now.toISOString()
123
+ return {
124
+ id: profileIdFromToken(token),
125
+ name: profileNameFromToken(token),
126
+ token,
127
+ created_at: timestamp,
128
+ updated_at: timestamp
129
+ }
130
+ }
131
+
132
+ const activeProfileFromStore = (store: AuthProfilesFile): AuthProfile | null => {
133
+ if (store.profiles.length === 0) return null
134
+ if (!store.activeProfileId) return store.profiles[0] ?? null
135
+ return store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ?? null
136
+ }
137
+
138
+ const storeFromLegacyToken = (token: OAuthToken | null, now: Date): AuthProfilesFile =>
139
+ token === null
140
+ ? emptyProfiles()
141
+ : {
142
+ activeProfileId: profileIdFromToken(token),
143
+ profiles: [profileFromToken(token, now)]
144
+ }
145
+
146
+ const readProfilesFile = (
147
+ toolName: string
148
+ ): Effect.Effect<
149
+ AuthProfilesFile | null,
150
+ FileSystemError | HomeDirectoryError,
151
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
152
+ > =>
153
+ Effect.gen(function*() {
154
+ const fs = yield* FileSystem.FileSystem
155
+ const profilesPath = yield* getProfilesPath(toolName)
156
+
157
+ const exists = yield* fs.exists(profilesPath).pipe(
158
+ Effect.catch(() => Effect.succeed(false))
159
+ )
160
+ if (!exists) return null
161
+
162
+ const content = yield* fs.readFileString(profilesPath).pipe(
163
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: profilesPath, cause }))
164
+ )
165
+
166
+ const parsed = parseJsonOrNull(content)
167
+ if (parsed === null) {
168
+ yield* Effect.logWarning(`Corrupted profiles.json at ${profilesPath} - could not parse JSON`)
169
+ return null
170
+ }
171
+
172
+ return yield* Schema.decodeUnknownEffect(AuthProfilesFileSchema)(parsed).pipe(
173
+ Effect.catch((e) =>
174
+ Effect.logWarning(`Invalid profile schema in ${profilesPath}: ${e}`).pipe(
175
+ Effect.map(() => null)
176
+ )
177
+ )
178
+ )
179
+ })
180
+
181
+ /**
182
+ * Load all auth profiles for a tool.
183
+ *
184
+ * Falls back to the legacy single-token file when `profiles.json` does not exist.
185
+ *
186
+ * @category Profile Storage
187
+ */
188
+ export const loadProfiles = (
189
+ toolName: string
190
+ ): Effect.Effect<
191
+ AuthProfilesFile,
192
+ FileSystemError | HomeDirectoryError,
193
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
194
+ > =>
195
+ Effect.gen(function*() {
196
+ const profiles = yield* readProfilesFile(toolName)
197
+ if (profiles !== null) return profiles
198
+ const legacyToken = yield* loadToken(toolName)
199
+ const nowMs = yield* Clock.currentTimeMillis
200
+ return storeFromLegacyToken(legacyToken, new Date(nowMs))
201
+ })
202
+
203
+ /**
204
+ * Save auth profiles and mirror the active profile to `auth.json`.
205
+ *
206
+ * @category Profile Storage
207
+ */
208
+ export const saveProfiles = (
209
+ toolName: string,
210
+ store: AuthProfilesFile
211
+ ) =>
212
+ Effect.gen(function*() {
213
+ const fs = yield* FileSystem.FileSystem
214
+ yield* ensureConfigDir(toolName)
215
+ const profilesPath = yield* getProfilesPath(toolName)
216
+ const active = activeProfileFromStore(store)
217
+ const normalized: AuthProfilesFile = {
218
+ activeProfileId: active?.id,
219
+ profiles: store.profiles
220
+ }
221
+
222
+ yield* writeSecureFile(profilesPath, JSON.stringify(normalized, null, 2)).pipe(
223
+ Effect.provideService(FileSystem.FileSystem, fs)
224
+ )
225
+
226
+ if (active) {
227
+ yield* saveToken(toolName, active.token)
228
+ } else {
229
+ yield* deleteToken(toolName)
230
+ }
231
+ })
232
+
233
+ /**
234
+ * Insert or update a profile from a token and make it active.
235
+ *
236
+ * @category Profile Storage
237
+ */
238
+ export const saveProfileToken = (
239
+ toolName: string,
240
+ token: OAuthToken
241
+ ) =>
242
+ Effect.gen(function*() {
243
+ const store = yield* loadProfiles(toolName)
244
+ const nowMs = yield* Clock.currentTimeMillis
245
+ const next = profileFromToken(token, new Date(nowMs))
246
+ const existing = store.profiles.find((profile) => profile.id === next.id)
247
+ const profile: AuthProfile = existing
248
+ ? { ...next, created_at: existing.created_at }
249
+ : next
250
+ const profiles = [
251
+ profile,
252
+ ...store.profiles.filter((stored) => stored.id !== profile.id)
253
+ ]
254
+
255
+ yield* saveProfiles(toolName, { activeProfileId: profile.id, profiles })
256
+ return profile
257
+ })
258
+
259
+ /**
260
+ * Load the active auth profile.
261
+ *
262
+ * @category Profile Storage
263
+ */
264
+ export const loadActiveProfile = (
265
+ toolName: string
266
+ ) =>
267
+ loadProfiles(toolName).pipe(
268
+ Effect.map(activeProfileFromStore)
269
+ )
270
+
271
+ /**
272
+ * Load the active OAuth token.
273
+ *
274
+ * @category Profile Storage
275
+ */
276
+ export const loadActiveProfileToken = (
277
+ toolName: string
278
+ ) =>
279
+ loadActiveProfile(toolName).pipe(
280
+ Effect.map((profile) => profile?.token ?? null)
281
+ )
282
+
283
+ /**
284
+ * Set the active profile by ID.
285
+ *
286
+ * @category Profile Storage
287
+ */
288
+ export const setActiveProfile = (
289
+ toolName: string,
290
+ profileId: string
291
+ ) =>
292
+ Effect.gen(function*() {
293
+ const store = yield* loadProfiles(toolName)
294
+ const profile = store.profiles.find((stored) => stored.id === profileId)
295
+ if (!profile) return null
296
+ yield* saveProfiles(toolName, { ...store, activeProfileId: profile.id })
297
+ return profile
298
+ })
299
+
300
+ /**
301
+ * Resolve a profile by ID, name, site URL, cloud ID, or account ID.
302
+ *
303
+ * @category Utilities
304
+ */
305
+ export const findProfile = (
306
+ profiles: ReadonlyArray<AuthProfile>,
307
+ selector: string
308
+ ): AuthProfile | null =>
309
+ profiles.find((profile) =>
310
+ profile.id === selector ||
311
+ profile.name === selector ||
312
+ profile.token.site_url === selector ||
313
+ profile.token.cloud_id === selector ||
314
+ profile.token.user?.account_id === selector
315
+ ) ?? null
316
+
317
+ /**
318
+ * Set the active profile using a flexible selector.
319
+ *
320
+ * @category Profile Storage
321
+ */
322
+ export const setActiveProfileBySelector = (
323
+ toolName: string,
324
+ selector: string
325
+ ) =>
326
+ Effect.gen(function*() {
327
+ const store = yield* loadProfiles(toolName)
328
+ const profile = findProfile(store.profiles, selector)
329
+ if (!profile) return null
330
+ yield* saveProfiles(toolName, { ...store, activeProfileId: profile.id })
331
+ return profile
332
+ })
333
+
334
+ /**
335
+ * Remove a profile by ID.
336
+ *
337
+ * @category Profile Storage
338
+ */
339
+ export const deleteProfile = (
340
+ toolName: string,
341
+ profileId: string
342
+ ) =>
343
+ Effect.gen(function*() {
344
+ const store = yield* loadProfiles(toolName)
345
+ const profile = store.profiles.find((stored) => stored.id === profileId)
346
+ if (!profile) return null
347
+ const profiles = store.profiles.filter((stored) => stored.id !== profile.id)
348
+ const activeProfileId = store.activeProfileId === profile.id ? profiles[0]?.id : store.activeProfileId
349
+ yield* saveProfiles(toolName, { activeProfileId, profiles })
350
+ return profile
351
+ })
352
+
353
+ /**
354
+ * Remove a profile using a flexible selector.
355
+ *
356
+ * @category Profile Storage
357
+ */
358
+ export const deleteProfileBySelector = (
359
+ toolName: string,
360
+ selector: string
361
+ ) =>
362
+ Effect.gen(function*() {
363
+ const store = yield* loadProfiles(toolName)
364
+ const profile = findProfile(store.profiles, selector)
365
+ if (!profile) return null
366
+ return yield* deleteProfile(toolName, profile.id)
367
+ })
368
+
369
+ /**
370
+ * Remove the active profile.
371
+ *
372
+ * @category Profile Storage
373
+ */
374
+ export const deleteActiveProfile = (
375
+ toolName: string
376
+ ) =>
377
+ Effect.gen(function*() {
378
+ const active = yield* loadActiveProfile(toolName)
379
+ if (!active) {
380
+ yield* deleteToken(toolName)
381
+ return null
382
+ }
383
+ return yield* deleteProfile(toolName, active.id)
384
+ })
@@ -136,6 +136,20 @@ export const getOAuthConfigPath = (
136
136
  return path.join(configDir, "oauth.json")
137
137
  })
138
138
 
139
+ /**
140
+ * Get auth profiles file path for a specific tool.
141
+ *
142
+ * @category Utilities
143
+ */
144
+ export const getProfilesPath = (
145
+ toolName: string
146
+ ): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
147
+ Effect.gen(function*() {
148
+ const path = yield* Path.Path
149
+ const configDir = yield* getConfigDir(toolName)
150
+ return path.join(configDir, "profiles.json")
151
+ })
152
+
139
153
  /**
140
154
  * Ensure config directory exists with secure permissions.
141
155
  *