@knpkv/jira-cli 1.4.0 → 1.4.1

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/src/JiraAuth.ts CHANGED
@@ -3,13 +3,10 @@
3
3
  *
4
4
  * **Mental model**
5
5
  *
6
- * - **Service pattern**: {@link JiraAuth} is a `Context.Tag` whose layer requires `HttpClient`
7
- * and `ChildProcessSpawner`. All token storage operations are pre-bound to
8
- * `@knpkv/atlassian-common/config` with a `"jira-cli"` tool name.
9
- * - **Refresh lock**: A `Ref<Option<Deferred>>` prevents concurrent token refreshes — the
10
- * first caller refreshes, others await the same Deferred.
11
- * - **Browser-based login**: {@link JiraAuthService.login} starts a local callback server,
12
- * opens the browser, and awaits the OAuth code with a 5-minute timeout.
6
+ * - **Service pattern**: {@link JiraAuth} is a `Context.Tag` whose layer requires `HttpClient`,
7
+ * `ChildProcessSpawner` and `Crypto`. The flow — refresh lock, rotating-token persistence,
8
+ * browser login, profiles — is `@knpkv/atlassian-common/cli-auth`'s, bound to the
9
+ * `"jira-cli"` storage namespace, Jira's scopes and Jira's names.
13
10
  *
14
11
  * **Common tasks**
15
12
  *
@@ -19,56 +16,20 @@
19
16
  *
20
17
  * @module
21
18
  */
22
- import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
23
- import * as NodePath from "@effect/platform-node/NodePath"
24
- import {
25
- buildAuthUrl,
26
- buildOAuthTokenAt,
27
- computeCodeChallenge,
28
- exchangeCodeForTokens,
29
- generateCodeVerifier,
30
- generateUUID,
31
- getAccessibleResources,
32
- getUserInfo,
33
- OAuthError,
34
- refreshToken,
35
- revokeToken
36
- } from "@knpkv/atlassian-common/auth"
37
- import {
38
- type AuthProfile,
39
- deleteActiveProfile,
40
- deleteProfileBySelector,
41
- type FileSystemError,
42
- type HomeDirectoryError,
43
- HomeDirectoryLive,
44
- isTokenExpiredAt,
45
- loadActiveProfile,
46
- loadActiveProfileToken,
47
- loadOAuthConfig,
48
- loadProfiles,
49
- type OAuthConfig,
50
- type OAuthToken,
51
- type OAuthUser,
52
- saveOAuthConfig,
53
- saveProfileToken,
54
- setActiveProfileBySelector
19
+ import type { OAuthError } from "@knpkv/atlassian-common/auth"
20
+ import { makeAtlassianCliAuth, NodeCliAuthLive } from "@knpkv/atlassian-common/cli-auth"
21
+ import type {
22
+ AuthProfile,
23
+ FileSystemError,
24
+ HomeDirectoryError,
25
+ OAuthConfig,
26
+ OAuthUser
55
27
  } from "@knpkv/atlassian-common/config"
56
- import * as Clock from "effect/Clock"
57
- import * as Console from "effect/Console"
58
28
  import * as Context from "effect/Context"
59
- import * as Crypto from "effect/Crypto"
60
- import * as Deferred from "effect/Deferred"
61
- import * as Effect from "effect/Effect"
62
- import * as HttpClient from "effect/http/HttpClient"
29
+ import type * as Effect from "effect/Effect"
63
30
  import * as Layer from "effect/Layer"
64
- import * as Option from "effect/Option"
65
31
  import type * as PlatformError from "effect/PlatformError"
66
- import { ChildProcessSpawner } from "effect/process"
67
- import * as Redacted from "effect/Redacted"
68
- import * as Ref from "effect/Ref"
69
- import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
70
- import { callbackUrl, startCallbackServer } from "./internal/oauthServer.js"
71
- import { openBrowser } from "./internal/openBrowser.js"
32
+ import type * as Redacted from "effect/Redacted"
72
33
  import type { AuthMissingError } from "./JiraCliError.js"
73
34
  import { authMissing } from "./JiraCliError.js"
74
35
 
@@ -90,30 +51,6 @@ const JIRA_CLI_SCOPES = [
90
51
  "offline_access"
91
52
  ]
92
53
 
93
- const TOOL_NAME = "jira-cli"
94
-
95
- // Layer for token storage operations (FileSystem + Path + HomeDirectory)
96
- const TokenStorageLive = Layer.mergeAll(
97
- NodeFileSystem.layer,
98
- NodePath.layer,
99
- HomeDirectoryLive
100
- )
101
-
102
- // Wrap token storage operations with their required layers
103
- const loadTokenOp = () => loadActiveProfileToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
104
- const saveTokenOp = (token: OAuthToken) => saveProfileToken(TOOL_NAME, token).pipe(Effect.provide(TokenStorageLive))
105
- const deleteTokenOp = () => deleteActiveProfile(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
106
- const listProfilesOp = () =>
107
- loadProfiles(TOOL_NAME).pipe(Effect.map((store) => store.profiles), Effect.provide(TokenStorageLive))
108
- const loadActiveProfileOp = () => loadActiveProfile(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
109
- const switchProfileOp = (selector: string) =>
110
- setActiveProfileBySelector(TOOL_NAME, selector).pipe(Effect.provide(TokenStorageLive))
111
- const removeProfileOp = (selector: string) =>
112
- deleteProfileBySelector(TOOL_NAME, selector).pipe(Effect.provide(TokenStorageLive))
113
- const loadOAuthConfigOp = () => loadOAuthConfig(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
114
- const saveOAuthConfigOp = (config: OAuthConfig) =>
115
- saveOAuthConfig(TOOL_NAME, config).pipe(Effect.provide(TokenStorageLive))
116
-
117
54
  /**
118
55
  * Options for the login method.
119
56
  *
@@ -228,400 +165,18 @@ export class JiraAuth extends Context.Service<
228
165
  JiraAuthService
229
166
  >()("@knpkv/jira-cli/JiraAuth") {}
230
167
 
231
- type RefreshError = OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
232
- type RefreshDeferred = Deferred.Deferred<OAuthToken, RefreshError>
233
-
234
- const make = Effect.gen(function*() {
235
- const httpClient = yield* HttpClient.HttpClient
236
- const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner
237
- const cryptoService = yield* Crypto.Crypto
238
-
239
- // Ref to track ongoing refresh operation to prevent concurrent refreshes
240
- const refreshLock = yield* Ref.make<Option.Option<RefreshDeferred>>(
241
- Option.none()
242
- )
243
-
244
- const openBrowserImpl = (url: string): Effect.Effect<void, OAuthError> =>
245
- openBrowser(url).pipe(
246
- Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
247
- Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
248
- )
249
-
250
- const getConfig = (): Effect.Effect<
251
- OAuthConfig,
252
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
253
- > =>
254
- Effect.gen(function*() {
255
- const config = yield* loadOAuthConfigOp()
256
- if (config === null) {
257
- return yield* new OAuthError({
258
- step: "authorize",
259
- cause: "OAuth not configured. Run 'jira auth configure' first."
260
- })
261
- }
262
- return config
263
- })
264
-
265
- // Atlassian rotates refresh tokens: the response carries a replacement and
266
- // the one we sent is consumed server-side. Interrupting between the
267
- // round-trip and `saveTokenOp` therefore destroys the credential — the next
268
- // refresh fails and `getAccessToken` below reacts by deleting the token file,
269
- // so the user is silently logged out and has to run `jira auth login` again.
270
- //
271
- // Interruption is routine here, not hypothetical: this runs during layer
272
- // construction on every CLI invocation, and jcf's nvim statusline kills the
273
- // process on `VimLeave`. So the grant and the persist are atomic.
274
- //
275
- // The region carries its own deadline rather than relying on a caller's.
276
- // `Effect.timeout` is a race, and racing an uninterruptible loser means
277
- // waiting for it — an outer bound would go inert and, worse, an
278
- // uninterruptible region with no deadline of its own absorbs SIGINT/SIGTERM
279
- // entirely (`NodeRuntime.runMain`'s signal handlers only interrupt the main
280
- // fiber), leaving a process that ignores Ctrl-C. The deadline forked inside
281
- // the region is itself interruptible, so it does bound this.
282
- //
283
- // Abandoning the round-trip cannot prove the grant did not land: Atlassian
284
- // may consume the refresh token and rotate it after we have stopped
285
- // listening, and no client-side deadline changes that. So the deadline is
286
- // paired with the rule in `getAccessToken` below — a refresh that fails
287
- // without a 4xx never deletes the stored token. The credential survives to be
288
- // retried, and only a real rejection ends the session.
289
- const REFRESH_TIMEOUT = "30 seconds"
290
-
291
- const refreshTokenImpl = (
292
- token: OAuthToken,
293
- config: OAuthConfig
294
- ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
295
- Effect.uninterruptible(
296
- Effect.gen(function*() {
297
- const updated = yield* refreshToken(token, config).pipe(
298
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
299
- Effect.timeout(REFRESH_TIMEOUT),
300
- Effect.catchTag(
301
- "TimeoutError",
302
- () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
303
- )
304
- )
305
- yield* saveTokenOp(updated)
306
- return updated
307
- })
308
- )
309
-
310
- const revokeTokenImpl = (
311
- token: OAuthToken,
312
- config: OAuthConfig
313
- ): Effect.Effect<void, OAuthError> =>
314
- revokeToken(token, config).pipe(
315
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
316
- )
317
-
318
- const configure = (
319
- config: OAuthConfig
320
- ): Effect.Effect<void, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
321
- saveOAuthConfigOp(config)
322
-
323
- const isConfigured = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
324
- Effect.gen(function*() {
325
- const config = yield* loadOAuthConfigOp()
326
- return config !== null
327
- })
328
-
329
- const login = (
330
- options?: LoginOptions
331
- ): Effect.Effect<
332
- ReadonlyArray<AccessibleSite> | void,
333
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
334
- > =>
335
- Effect.gen(function*() {
336
- const config = yield* getConfig()
337
- const state = yield* generateUUID().pipe(Effect.provideService(Crypto.Crypto, cryptoService))
338
- const codeVerifier = yield* generateCodeVerifier().pipe(Effect.provideService(Crypto.Crypto, cryptoService))
339
- const codeChallenge = yield* computeCodeChallenge(codeVerifier).pipe(
340
- Effect.provideService(Crypto.Crypto, cryptoService)
341
- )
342
-
343
- const { code, port } = yield* Effect.scoped(
344
- Effect.gen(function*() {
345
- const { codePromise, port } = yield* startCallbackServer(state).pipe(
346
- Effect.provide(HttpServerFactoryLive)
347
- )
348
- const authUrl = buildAuthUrl({
349
- clientId: config.clientId,
350
- state,
351
- port,
352
- redirectUri: callbackUrl(port),
353
- scopes: JIRA_CLI_SCOPES,
354
- codeChallenge
355
- })
356
-
357
- yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
358
- yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
359
- yield* openBrowserImpl(authUrl)
360
- yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
361
-
362
- const code = yield* codePromise.pipe(
363
- Effect.timeout("5 minutes"),
364
- Effect.catchTag(
365
- "TimeoutError",
366
- () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
367
- )
368
- )
369
- return { code, port }
370
- })
371
- )
372
-
373
- yield* Console.log("Exchanging code for tokens...")
374
- const tokens = yield* exchangeCodeForTokens(code, config, {
375
- port,
376
- redirectUri: callbackUrl(port),
377
- codeVerifier
378
- }).pipe(
379
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
380
- )
381
-
382
- yield* Console.log("Fetching accessible sites...")
383
- const sites = yield* getAccessibleResources(tokens.access_token).pipe(
384
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
385
- )
386
-
387
- if (sites.length === 0) {
388
- return yield* new OAuthError({
389
- step: "authorize",
390
- cause: "No Jira sites found for this account"
391
- })
392
- }
393
-
394
- let site: (typeof sites)[number]
395
-
396
- if (sites.length > 1) {
397
- if (options?.siteUrl) {
398
- const matched = sites.find((s) => s.url === options.siteUrl)
399
- if (!matched) {
400
- const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
401
- return yield* new OAuthError({
402
- step: "authorize",
403
- cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
404
- })
405
- }
406
- site = matched
407
- } else {
408
- yield* Console.log("Multiple Jira sites found. Please select one:")
409
- for (const s of sites) {
410
- yield* Console.log(` - ${s.name}: ${s.url}`)
411
- }
412
- yield* Console.log("\nRun 'jira auth login --site <url>' to select a site")
413
- return sites.map((s) => ({ id: s.id, name: s.name, url: s.url }))
414
- }
415
- } else {
416
- site = sites[0]!
417
- }
418
-
419
- yield* Console.log("Fetching user info...")
420
- const user = yield* getUserInfo(tokens.access_token).pipe(
421
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
422
- )
423
-
424
- const nowMs = yield* Clock.currentTimeMillis
425
- const tokenData = buildOAuthTokenAt(tokens, site, user, nowMs)
426
-
427
- yield* saveTokenOp(tokenData)
428
- yield* Console.log(`Logged in as ${user.name} (${user.email})`)
429
- return undefined
430
- })
431
-
432
- const logout = (): Effect.Effect<
433
- void,
434
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
435
- > =>
436
- Effect.gen(function*() {
437
- const token = yield* loadTokenOp()
438
- if (token === null) {
439
- yield* Console.log("Not logged in")
440
- return
441
- }
442
-
443
- const config = yield* loadOAuthConfigOp()
444
- if (config !== null) {
445
- yield* revokeTokenImpl(token, config).pipe(
446
- Effect.tap(() => Effect.log("Token revoked with Atlassian")),
447
- Effect.catch((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
448
- )
449
- }
450
-
451
- yield* deleteTokenOp()
452
- })
453
-
454
- const getAccessToken = (): Effect.Effect<
455
- Redacted.Redacted<string>,
456
- AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
457
- > =>
458
- Effect.gen(function*() {
459
- const token = yield* loadTokenOp()
460
- if (token === null) {
461
- return yield* authMissing()
462
- }
463
-
464
- const nowMs = yield* Clock.currentTimeMillis
465
- if (!isTokenExpiredAt(token, nowMs)) {
466
- return Redacted.make(token.access_token)
467
- }
468
-
469
- // Atomically check-then-set refresh lock to avoid TOCTOU race
470
- const deferred = yield* Deferred.make<OAuthToken, RefreshError>()
471
- const existing = yield* Ref.modify(refreshLock, (current) =>
472
- Option.isSome(current)
473
- ? ([current.value, current] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>])
474
- : ([deferred, Option.some(deferred)] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>]))
475
-
476
- // Another fiber is already refreshing — just await its result
477
- if (existing !== deferred) {
478
- const refreshed = yield* Deferred.await(existing)
479
- return Redacted.make(refreshed.access_token)
480
- }
481
-
482
- const refresh = Effect.gen(function*() {
483
- const config = yield* getConfig()
484
- // stderr, not stdout: this fires from inside layer construction, so it
485
- // lands ahead of whatever the command prints. On `--json` that used to
486
- // put a line of prose in front of the document, and the release
487
- // automation parsing it failed *after* the remote writes had happened.
488
- yield* Console.error("Token expired, refreshing...")
489
- return yield* refreshTokenImpl(token, config)
490
- }).pipe(
491
- Effect.catchTag("OAuthError", (error) => {
492
- // Discard the stored credential only when Atlassian actually rejected
493
- // it. A refresh can fail without saying anything about the token —
494
- // transport error, timeout, interruption — and in those cases the
495
- // grant may even have been consumed server-side, so the one thing we
496
- // must not do is delete the replacement's only trail. Deleting on any
497
- // `step === "refresh"` failure turned a flaky network into a silent
498
- // logout, which is worse than retrying and is unrecoverable. On a 4xx
499
- // the token is genuinely dead and re-login is the only way forward.
500
- // Not every failure is a verdict on the token, and not even every
501
- // 4xx is. `429` is the one this most needs to survive — several
502
- // `jira`/`jcf` processes on an expired token hit the endpoint
503
- // together, one wins the rotation and the rest are rate-limited —
504
- // while `408`/`425` restate the timeout case and `407` and other
505
- // middlebox replies never came from Atlassian at all.
506
- //
507
- // Status alone is still too coarse. `refreshToken` sends the client
508
- // credentials in the body, so a wrong or rotated `clientSecret` comes
509
- // back as `400 invalid_client`; deleting the token there destroys a
510
- // working credential over a config problem, and the re-login it forces
511
- // would fail the same way until `jira auth configure` is run. Only
512
- // `invalid_grant` means the stored token itself is spent, and that is
513
- // the only thing that ends the session — on a `403` too, since a bare
514
- // 403 is just as likely to come from a proxy or WAF as from Atlassian
515
- // revoking anything. An unparseable body leaves `errorCode` absent and
516
- // the token in place.
517
- const { errorCode, status } = error
518
- const rejected = errorCode === "invalid_grant" && (status === 400 || status === 403)
519
- if (error.step === "refresh" && rejected) {
520
- return Effect.gen(function*() {
521
- yield* deleteTokenOp()
522
- return yield* new OAuthError({
523
- step: "refresh",
524
- cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate.",
525
- status,
526
- errorCode
527
- })
528
- })
529
- }
530
- return Effect.fail(error)
531
- })
532
- )
533
-
534
- // This fiber owns the refresh. Complete the shared Deferred with the final
535
- // transformed exit so waiters observe the same success or failure.
536
- const exit = yield* refresh.pipe(
537
- Effect.exit,
538
- Effect.ensuring(Ref.set(refreshLock, Option.none()))
539
- )
540
- yield* Deferred.done(deferred, exit)
541
- const result = yield* Deferred.await(deferred)
542
-
543
- return Redacted.make(result.access_token)
544
- })
545
-
546
- const getCloudId = (): Effect.Effect<
547
- string,
548
- AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
549
- > =>
550
- Effect.gen(function*() {
551
- const token = yield* loadTokenOp()
552
- if (token === null) {
553
- return yield* authMissing()
554
- }
555
- return token.cloud_id
556
- })
557
-
558
- const getSiteUrl = (): Effect.Effect<
559
- string,
560
- AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
561
- > =>
562
- Effect.gen(function*() {
563
- const token = yield* loadTokenOp()
564
- if (token === null) {
565
- return yield* authMissing()
566
- }
567
- return token.site_url
568
- })
569
-
570
- const getCurrentUser = (): Effect.Effect<
571
- OAuthUser | null,
572
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
573
- > =>
574
- Effect.gen(function*() {
575
- const token = yield* loadTokenOp()
576
- return token?.user ?? null
577
- })
578
-
579
- const getActiveProfile = (): Effect.Effect<
580
- AuthProfile | null,
581
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
582
- > => loadActiveProfileOp()
583
-
584
- const listProfiles = (): Effect.Effect<
585
- ReadonlyArray<AuthProfile>,
586
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
587
- > => listProfilesOp()
588
-
589
- const switchProfile = (
590
- selector: string
591
- ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
592
- switchProfileOp(selector)
593
-
594
- const removeProfile = (
595
- selector: string
596
- ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
597
- removeProfileOp(selector)
598
-
599
- const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
600
- Effect.gen(function*() {
601
- const token = yield* loadTokenOp()
602
- return token !== null
603
- })
604
-
605
- return JiraAuth.of({
606
- configure,
607
- isConfigured,
608
- login,
609
- logout,
610
- getAccessToken,
611
- getCloudId,
612
- getSiteUrl,
613
- getCurrentUser,
614
- getActiveProfile,
615
- listProfiles,
616
- switchProfile,
617
- removeProfile,
618
- isLoggedIn
619
- })
620
- })
621
-
622
168
  /**
623
169
  * Layer for JiraAuth service.
624
170
  *
625
171
  * @category Layers
626
172
  */
627
- export const layer = Layer.effect(JiraAuth, make)
173
+ export const layer = Layer.effect(
174
+ JiraAuth,
175
+ makeAtlassianCliAuth({
176
+ toolName: "jira-cli",
177
+ commandName: "jira",
178
+ productName: "Jira",
179
+ scopes: JIRA_CLI_SCOPES,
180
+ authMissing
181
+ })
182
+ ).pipe(Layer.provide(NodeCliAuthLive))
@@ -8,11 +8,11 @@
8
8
  *
9
9
  * @internal
10
10
  */
11
+ import { openBrowser } from "@knpkv/atlassian-common/cli-auth"
11
12
  import { Argument as Args, Command, Flag as Options, Prompt } from "effect/cli"
12
13
  import * as Console from "effect/Console"
13
14
  import * as Effect from "effect/Effect"
14
15
  import * as Option from "effect/Option"
15
- import { openBrowser } from "../internal/openBrowser.js"
16
16
  import { JiraAuth } from "../JiraAuth.js"
17
17
 
18
18
  // === Auth create command ===
@@ -0,0 +1,79 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * JiraAuth is the shared Atlassian CLI auth bound to Jira. These pin the binding:
4
+ * the `jira-cli` storage namespace, Jira's own "not logged in" error, the `jira`
5
+ * command in re-login hints, and a redacted token. The refresh rules themselves
6
+ * are covered once in `@knpkv/atlassian-common`'s cli-auth tests.
7
+ */
8
+ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
9
+ import { describe, expect, it } from "@effect/vitest"
10
+ import { HomeDirectoryLive, saveOAuthConfig, saveProfileToken } from "@knpkv/atlassian-common/config"
11
+ import { ConfigProvider } from "effect"
12
+ import * as Effect from "effect/Effect"
13
+ import * as FileSystem from "effect/FileSystem"
14
+ import { HttpClient, HttpClientResponse } from "effect/http"
15
+ import * as Layer from "effect/Layer"
16
+ import * as Redacted from "effect/Redacted"
17
+ import { JiraAuth, layer as jiraAuthLayer } from "../src/JiraAuth.js"
18
+
19
+ const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
20
+
21
+ const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
22
+ Effect.gen(function*() {
23
+ const fs = yield* FileSystem.FileSystem
24
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "jira-auth-" })
25
+ return yield* effect.pipe(
26
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
27
+ )
28
+ }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
29
+
30
+ const rejectingClient = Layer.succeed(
31
+ HttpClient.HttpClient,
32
+ HttpClient.make((request) =>
33
+ Effect.succeed(
34
+ HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }))
35
+ )
36
+ )
37
+ )
38
+
39
+ const withAuth = <A, E>(use: (auth: JiraAuth["Service"]) => Effect.Effect<A, E>) =>
40
+ JiraAuth.pipe(
41
+ Effect.flatMap(use),
42
+ Effect.provide(jiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(rejectingClient, NodeServices.layer))))
43
+ )
44
+
45
+ const token = (expiresAt: number) => ({
46
+ access_token: "access-1",
47
+ refresh_token: "refresh-1",
48
+ expires_at: expiresAt,
49
+ token_type: "Bearer",
50
+ scope: "offline_access",
51
+ cloud_id: "cloud-1",
52
+ site_url: "https://example.atlassian.net"
53
+ })
54
+
55
+ describe("JiraAuth", () => {
56
+ it.effect("fails with Jira's own error when nobody is logged in", () =>
57
+ withHome(Effect.gen(function*() {
58
+ const error = yield* withAuth((auth) => auth.getAccessToken()).pipe(Effect.flip)
59
+ expect(error).toMatchObject({ _tag: "AuthMissingError", message: "Not logged in. Run 'jira auth login' first." })
60
+ })))
61
+
62
+ it.effect("reads the jira-cli profile and returns a redacted token", () =>
63
+ withHome(Effect.gen(function*() {
64
+ yield* saveProfileToken("jira-cli", token(Number.MAX_SAFE_INTEGER)).pipe(Effect.provide(storage))
65
+ const accessToken = yield* withAuth((auth) => auth.getAccessToken())
66
+ expect(Redacted.value(accessToken)).toBe("access-1")
67
+ expect(yield* withAuth((auth) => auth.getSiteUrl())).toBe("https://example.atlassian.net")
68
+ })))
69
+
70
+ it.effect("names the jira command when the stored token is spent", () =>
71
+ withHome(Effect.gen(function*() {
72
+ yield* saveOAuthConfig("jira-cli", { clientId: "client-1", clientSecret: "secret-1" }).pipe(
73
+ Effect.provide(storage)
74
+ )
75
+ yield* saveProfileToken("jira-cli", token(0)).pipe(Effect.provide(storage))
76
+ const error = yield* withAuth((auth) => auth.getAccessToken()).pipe(Effect.flip)
77
+ expect(error.message).toContain("Please run 'jira auth login'")
78
+ })))
79
+ })
@@ -3,8 +3,11 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
- "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
6
+ "@knpkv/atlassian-common/attachments":
7
+ new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
8
  "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
9
+ "@knpkv/atlassian-common/cli-auth":
10
+ new URL("../atlassian-common/src/cli-auth/index.ts", import.meta.url).pathname,
8
11
  "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
12
  "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
10
13
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
package/vitest.config.ts CHANGED
@@ -3,8 +3,11 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
- "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
6
+ "@knpkv/atlassian-common/attachments":
7
+ new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
8
  "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
9
+ "@knpkv/atlassian-common/cli-auth":
10
+ new URL("../atlassian-common/src/cli-auth/index.ts", import.meta.url).pathname,
8
11
  "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
12
  "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
10
13
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
@@ -1,7 +0,0 @@
1
- /**
2
- * HTTP Server factory layer using Node.js http module.
3
- *
4
- * @category Layers
5
- */
6
- export declare const HttpServerFactoryLive: import("effect/Layer").Layer<import("./oauthServer.js").HttpServerFactoryTag, never, never>;
7
- //# sourceMappingURL=NodeLayers.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"NodeLayers.d.ts","sourceRoot":"","sources":["../../src/internal/NodeLayers.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,6FAEjC,CAAA"}
@@ -1,15 +0,0 @@
1
- /**
2
- * Node.js-specific HTTP server factory — the only file importing `@effect/platform-node` server.
3
- *
4
- * @internal
5
- */
6
- import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
7
- import { createServer } from "node:http";
8
- import { makeHttpServerFactory } from "./oauthServer.js";
9
- /**
10
- * HTTP Server factory layer using Node.js http module.
11
- *
12
- * @category Layers
13
- */
14
- export const HttpServerFactoryLive = makeHttpServerFactory((options) => NodeHttpServer.layerServer(createServer, options));
15
- //# sourceMappingURL=NodeLayers.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"NodeLayers.js","sourceRoot":"","sources":["../../src/internal/NodeLayers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,cAAc,MAAM,sCAAsC,CAAA;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAExD;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,qBAAqB,CACxD,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,WAAW,CAAC,YAAY,EAAE,OAAO,CAAC,CAC/D,CAAA"}