@knpkv/jira-cli 1.3.1 → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +5 -5
  3. package/dist/JiraAuth.d.ts +23 -8
  4. package/dist/JiraAuth.d.ts.map +1 -1
  5. package/dist/JiraAuth.js +8 -303
  6. package/dist/JiraAuth.js.map +1 -1
  7. package/dist/bin.js +1 -1
  8. package/dist/bin.js.map +1 -1
  9. package/dist/commands/auth.d.ts +2 -2
  10. package/dist/commands/auth.d.ts.map +1 -1
  11. package/dist/commands/auth.js +8 -8
  12. package/dist/commands/auth.js.map +1 -1
  13. package/dist/commands/edit.d.ts +10 -1
  14. package/dist/commands/edit.d.ts.map +1 -1
  15. package/dist/commands/edit.js +9 -9
  16. package/dist/commands/edit.js.map +1 -1
  17. package/dist/commands/get.d.ts +6 -1
  18. package/dist/commands/get.d.ts.map +1 -1
  19. package/dist/commands/get.js +3 -3
  20. package/dist/commands/get.js.map +1 -1
  21. package/dist/commands/issue.d.ts +1 -1
  22. package/dist/commands/issue.d.ts.map +1 -1
  23. package/dist/commands/issue.js +1 -1
  24. package/dist/commands/issue.js.map +1 -1
  25. package/dist/commands/issueAttachment.d.ts +1 -1
  26. package/dist/commands/issueAttachment.d.ts.map +1 -1
  27. package/dist/commands/issueAttachment.js +13 -11
  28. package/dist/commands/issueAttachment.js.map +1 -1
  29. package/dist/commands/layers.d.ts +2 -2
  30. package/dist/commands/layers.d.ts.map +1 -1
  31. package/dist/commands/search.d.ts +6 -1
  32. package/dist/commands/search.d.ts.map +1 -1
  33. package/dist/commands/search.js +7 -7
  34. package/dist/commands/search.js.map +1 -1
  35. package/dist/commands/version.d.ts +9 -1
  36. package/dist/commands/version.d.ts.map +1 -1
  37. package/dist/commands/version.js +23 -23
  38. package/dist/commands/version.js.map +1 -1
  39. package/package.json +10 -10
  40. package/skills/jira/SKILL.md +4 -4
  41. package/src/JiraAuth.ts +24 -477
  42. package/src/bin.ts +1 -1
  43. package/src/commands/auth.ts +8 -8
  44. package/src/commands/edit.ts +9 -9
  45. package/src/commands/get.ts +3 -3
  46. package/src/commands/issue.ts +1 -1
  47. package/src/commands/issueAttachment.ts +21 -20
  48. package/src/commands/search.ts +7 -7
  49. package/src/commands/version.ts +26 -29
  50. package/test/IssueService.test.ts +2 -2
  51. package/test/JiraAuth.test.ts +79 -0
  52. package/test/VersionService.test.ts +2 -2
  53. package/test/commandTree.test.ts +1 -1
  54. package/test/integration.test.ts +7 -7
  55. package/test/remoteWrites.test.ts +3 -3
  56. package/vitest.config.integration.ts +4 -1
  57. package/vitest.config.ts +4 -1
  58. package/dist/internal/NodeLayers.d.ts +0 -7
  59. package/dist/internal/NodeLayers.d.ts.map +0 -1
  60. package/dist/internal/NodeLayers.js +0 -15
  61. package/dist/internal/NodeLayers.js.map +0 -1
  62. package/dist/internal/oauthServer.d.ts +0 -72
  63. package/dist/internal/oauthServer.d.ts.map +0 -1
  64. package/dist/internal/oauthServer.js +0 -96
  65. package/dist/internal/oauthServer.js.map +0 -1
  66. package/dist/internal/openBrowser.d.ts +0 -10
  67. package/dist/internal/openBrowser.d.ts.map +0 -1
  68. package/dist/internal/openBrowser.js +0 -17
  69. package/dist/internal/openBrowser.js.map +0 -1
  70. package/src/internal/NodeLayers.ts +0 -17
  71. package/src/internal/oauthServer.ts +0 -186
  72. package/src/internal/openBrowser.ts +0 -31
  73. package/test/JiraAuthRefresh.test.ts +0 -269
  74. package/test/oauthServer.test.ts +0 -171
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "CLI tool to fetch Jira tickets and export to markdown",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -54,23 +54,23 @@
54
54
  "access": "public"
55
55
  },
56
56
  "peerDependencies": {
57
- "effect": "4.0.0-rc.109"
57
+ "effect": "4.0.0"
58
58
  },
59
59
  "dependencies": {
60
- "@effect/platform-node": "4.0.0-rc.109",
60
+ "@effect/platform-node": "4.0.0",
61
61
  "gray-matter": "^4.0.3",
62
- "js-yaml": "5.2.3",
63
- "@knpkv/agent-skills": "^0.3.1",
64
- "@knpkv/atlassian-common": "1.4.1",
65
- "@knpkv/jira-api-client": "1.1.1"
62
+ "js-yaml": "5.4.2",
63
+ "@knpkv/agent-skills": "^0.3.2",
64
+ "@knpkv/atlassian-common": "1.6.0",
65
+ "@knpkv/jira-api-client": "2.0.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@effect/vitest": "4.0.0-rc.109",
68
+ "@effect/vitest": "4.0.0",
69
69
  "@types/js-yaml": "^4.0.9",
70
70
  "@types/node": "latest",
71
- "effect": "4.0.0-rc.109",
71
+ "effect": "4.0.0",
72
72
  "typescript": "npm:@typescript/typescript6@^6.0.2",
73
- "vitest": "^4.1.10"
73
+ "vitest": "^5.0.3"
74
74
  },
75
75
  "keywords": [
76
76
  "effect",
@@ -84,8 +84,8 @@ jira version get 10042 --json
84
84
  Open a new unreleased version:
85
85
 
86
86
  ```bash
87
- jira version create --project PROJ --name "OOB 100"
88
- jira version create --project PROJ --name "OOB 100" --description "Q3 release" --start-date 2026-01-05 --release-date 2026-01-19 --json
87
+ jira version create --project PROJ --name "REL 100"
88
+ jira version create --project PROJ --name "REL 100" --description "Q3 release" --start-date 2026-01-05 --release-date 2026-01-19 --json
89
89
  ```
90
90
 
91
91
  Update a version description:
@@ -105,8 +105,8 @@ jira version related-work sync 10042 --link "Release notes=https://example.atlas
105
105
  Set an issue's fix versions and labels:
106
106
 
107
107
  ```bash
108
- jira issue edit PROJ-123 --add-fix-version "OOB 100" --add-label release-notes
109
- jira issue edit PROJ-123 --remove-fix-version "OOB 99" --json
108
+ jira issue edit PROJ-123 --add-fix-version "REL 100" --add-label release-notes
109
+ jira issue edit PROJ-123 --remove-fix-version "REL 99" --json
110
110
  ```
111
111
 
112
112
  ## Agent Workflow
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"
29
+ import type * as Effect from "effect/Effect"
62
30
  import * as Layer from "effect/Layer"
63
- import * as Option from "effect/Option"
64
31
  import type * as PlatformError from "effect/PlatformError"
65
- import * as Redacted from "effect/Redacted"
66
- import * as Ref from "effect/Ref"
67
- import * as HttpClient from "effect/unstable/http/HttpClient"
68
- import { ChildProcessSpawner } from "effect/unstable/process"
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,408 +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* Effect.fail(
258
- new OAuthError({
259
- step: "authorize",
260
- cause: "OAuth not configured. Run 'jira auth configure' first."
261
- })
262
- )
263
- }
264
- return config
265
- })
266
-
267
- // Atlassian rotates refresh tokens: the response carries a replacement and
268
- // the one we sent is consumed server-side. Interrupting between the
269
- // round-trip and `saveTokenOp` therefore destroys the credential — the next
270
- // refresh fails and `getAccessToken` below reacts by deleting the token file,
271
- // so the user is silently logged out and has to run `jira auth login` again.
272
- //
273
- // Interruption is routine here, not hypothetical: this runs during layer
274
- // construction on every CLI invocation, and jcf's nvim statusline kills the
275
- // process on `VimLeave`. So the grant and the persist are atomic.
276
- //
277
- // The region carries its own deadline rather than relying on a caller's.
278
- // `Effect.timeout` is a race, and racing an uninterruptible loser means
279
- // waiting for it — an outer bound would go inert and, worse, an
280
- // uninterruptible region with no deadline of its own absorbs SIGINT/SIGTERM
281
- // entirely (`NodeRuntime.runMain`'s signal handlers only interrupt the main
282
- // fiber), leaving a process that ignores Ctrl-C. The deadline forked inside
283
- // the region is itself interruptible, so it does bound this.
284
- //
285
- // Abandoning the round-trip cannot prove the grant did not land: Atlassian
286
- // may consume the refresh token and rotate it after we have stopped
287
- // listening, and no client-side deadline changes that. So the deadline is
288
- // paired with the rule in `getAccessToken` below — a refresh that fails
289
- // without a 4xx never deletes the stored token. The credential survives to be
290
- // retried, and only a real rejection ends the session.
291
- const REFRESH_TIMEOUT = "30 seconds"
292
-
293
- const refreshTokenImpl = (
294
- token: OAuthToken,
295
- config: OAuthConfig
296
- ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
297
- Effect.uninterruptible(
298
- Effect.gen(function*() {
299
- const updated = yield* refreshToken(token, config).pipe(
300
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
301
- Effect.timeout(REFRESH_TIMEOUT),
302
- Effect.catchTag(
303
- "TimeoutError",
304
- () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
305
- )
306
- )
307
- yield* saveTokenOp(updated)
308
- return updated
309
- })
310
- )
311
-
312
- const revokeTokenImpl = (
313
- token: OAuthToken,
314
- config: OAuthConfig
315
- ): Effect.Effect<void, OAuthError> =>
316
- revokeToken(token, config).pipe(
317
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
318
- )
319
-
320
- const configure = (
321
- config: OAuthConfig
322
- ): Effect.Effect<void, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
323
- saveOAuthConfigOp(config)
324
-
325
- const isConfigured = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
326
- Effect.gen(function*() {
327
- const config = yield* loadOAuthConfigOp()
328
- return config !== null
329
- })
330
-
331
- const login = (
332
- options?: LoginOptions
333
- ): Effect.Effect<
334
- ReadonlyArray<AccessibleSite> | void,
335
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
336
- > =>
337
- Effect.gen(function*() {
338
- const config = yield* getConfig()
339
- const state = yield* generateUUID().pipe(Effect.provideService(Crypto.Crypto, cryptoService))
340
- const codeVerifier = yield* generateCodeVerifier().pipe(Effect.provideService(Crypto.Crypto, cryptoService))
341
- const codeChallenge = yield* computeCodeChallenge(codeVerifier).pipe(
342
- Effect.provideService(Crypto.Crypto, cryptoService)
343
- )
344
-
345
- const { code, port } = yield* Effect.scoped(
346
- Effect.gen(function*() {
347
- const { codePromise, port } = yield* startCallbackServer(state).pipe(
348
- Effect.provide(HttpServerFactoryLive)
349
- )
350
- const authUrl = buildAuthUrl({
351
- clientId: config.clientId,
352
- state,
353
- port,
354
- redirectUri: callbackUrl(port),
355
- scopes: JIRA_CLI_SCOPES,
356
- codeChallenge
357
- })
358
-
359
- yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
360
- yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
361
- yield* openBrowserImpl(authUrl)
362
- yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
363
-
364
- const code = yield* codePromise.pipe(
365
- Effect.timeout("5 minutes"),
366
- Effect.catchTag(
367
- "TimeoutError",
368
- () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
369
- )
370
- )
371
- return { code, port }
372
- })
373
- )
374
-
375
- yield* Console.log("Exchanging code for tokens...")
376
- const tokens = yield* exchangeCodeForTokens(code, config, {
377
- port,
378
- redirectUri: callbackUrl(port),
379
- codeVerifier
380
- }).pipe(
381
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
382
- )
383
-
384
- yield* Console.log("Fetching accessible sites...")
385
- const sites = yield* getAccessibleResources(tokens.access_token).pipe(
386
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
387
- )
388
-
389
- if (sites.length === 0) {
390
- return yield* Effect.fail(
391
- new OAuthError({
392
- step: "authorize",
393
- cause: "No Jira sites found for this account"
394
- })
395
- )
396
- }
397
-
398
- let site: (typeof sites)[number]
399
-
400
- if (sites.length > 1) {
401
- if (options?.siteUrl) {
402
- const matched = sites.find((s) => s.url === options.siteUrl)
403
- if (!matched) {
404
- const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
405
- return yield* Effect.fail(
406
- new OAuthError({
407
- step: "authorize",
408
- cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
409
- })
410
- )
411
- }
412
- site = matched
413
- } else {
414
- yield* Console.log("Multiple Jira sites found. Please select one:")
415
- for (const s of sites) {
416
- yield* Console.log(` - ${s.name}: ${s.url}`)
417
- }
418
- yield* Console.log("\nRun 'jira auth login --site <url>' to select a site")
419
- return sites.map((s) => ({ id: s.id, name: s.name, url: s.url }))
420
- }
421
- } else {
422
- site = sites[0]!
423
- }
424
-
425
- yield* Console.log("Fetching user info...")
426
- const user = yield* getUserInfo(tokens.access_token).pipe(
427
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
428
- )
429
-
430
- const nowMs = yield* Clock.currentTimeMillis
431
- const tokenData = buildOAuthTokenAt(tokens, site, user, nowMs)
432
-
433
- yield* saveTokenOp(tokenData)
434
- yield* Console.log(`Logged in as ${user.name} (${user.email})`)
435
- return undefined
436
- })
437
-
438
- const logout = (): Effect.Effect<
439
- void,
440
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
441
- > =>
442
- Effect.gen(function*() {
443
- const token = yield* loadTokenOp()
444
- if (token === null) {
445
- yield* Console.log("Not logged in")
446
- return
447
- }
448
-
449
- const config = yield* loadOAuthConfigOp()
450
- if (config !== null) {
451
- yield* revokeTokenImpl(token, config).pipe(
452
- Effect.tap(() => Effect.log("Token revoked with Atlassian")),
453
- Effect.catch((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
454
- )
455
- }
456
-
457
- yield* deleteTokenOp()
458
- })
459
-
460
- const getAccessToken = (): Effect.Effect<
461
- Redacted.Redacted<string>,
462
- AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
463
- > =>
464
- Effect.gen(function*() {
465
- const token = yield* loadTokenOp()
466
- if (token === null) {
467
- return yield* Effect.fail(authMissing())
468
- }
469
-
470
- const nowMs = yield* Clock.currentTimeMillis
471
- if (!isTokenExpiredAt(token, nowMs)) {
472
- return Redacted.make(token.access_token)
473
- }
474
-
475
- // Atomically check-then-set refresh lock to avoid TOCTOU race
476
- const deferred = yield* Deferred.make<OAuthToken, RefreshError>()
477
- const existing = yield* Ref.modify(refreshLock, (current) =>
478
- Option.isSome(current)
479
- ? ([current.value, current] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>])
480
- : ([deferred, Option.some(deferred)] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>]))
481
-
482
- // Another fiber is already refreshing — just await its result
483
- if (existing !== deferred) {
484
- const refreshed = yield* Deferred.await(existing)
485
- return Redacted.make(refreshed.access_token)
486
- }
487
-
488
- const refresh = Effect.gen(function*() {
489
- const config = yield* getConfig()
490
- // stderr, not stdout: this fires from inside layer construction, so it
491
- // lands ahead of whatever the command prints. On `--json` that used to
492
- // put a line of prose in front of the document, and the release
493
- // automation parsing it failed *after* the remote writes had happened.
494
- yield* Console.error("Token expired, refreshing...")
495
- return yield* refreshTokenImpl(token, config)
496
- }).pipe(
497
- Effect.catchTag("OAuthError", (error) => {
498
- // Discard the stored credential only when Atlassian actually rejected
499
- // it. A refresh can fail without saying anything about the token —
500
- // transport error, timeout, interruption — and in those cases the
501
- // grant may even have been consumed server-side, so the one thing we
502
- // must not do is delete the replacement's only trail. Deleting on any
503
- // `step === "refresh"` failure turned a flaky network into a silent
504
- // logout, which is worse than retrying and is unrecoverable. On a 4xx
505
- // the token is genuinely dead and re-login is the only way forward.
506
- // Not every failure is a verdict on the token, and not even every
507
- // 4xx is. `429` is the one this most needs to survive — several
508
- // `jira`/`jcf` processes on an expired token hit the endpoint
509
- // together, one wins the rotation and the rest are rate-limited —
510
- // while `408`/`425` restate the timeout case and `407` and other
511
- // middlebox replies never came from Atlassian at all.
512
- //
513
- // Status alone is still too coarse. `refreshToken` sends the client
514
- // credentials in the body, so a wrong or rotated `clientSecret` comes
515
- // back as `400 invalid_client`; deleting the token there destroys a
516
- // working credential over a config problem, and the re-login it forces
517
- // would fail the same way until `jira auth configure` is run. Only
518
- // `invalid_grant` means the stored token itself is spent, and that is
519
- // the only thing that ends the session — on a `403` too, since a bare
520
- // 403 is just as likely to come from a proxy or WAF as from Atlassian
521
- // revoking anything. An unparseable body leaves `errorCode` absent and
522
- // the token in place.
523
- const { errorCode, status } = error
524
- const rejected = errorCode === "invalid_grant" && (status === 400 || status === 403)
525
- if (error.step === "refresh" && rejected) {
526
- return Effect.gen(function*() {
527
- yield* deleteTokenOp()
528
- return yield* Effect.fail(
529
- new OAuthError({
530
- step: "refresh",
531
- cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate.",
532
- status,
533
- errorCode
534
- })
535
- )
536
- })
537
- }
538
- return Effect.fail(error)
539
- })
540
- )
541
-
542
- // This fiber owns the refresh. Complete the shared Deferred with the final
543
- // transformed exit so waiters observe the same success or failure.
544
- const exit = yield* refresh.pipe(
545
- Effect.exit,
546
- Effect.ensuring(Ref.set(refreshLock, Option.none()))
547
- )
548
- yield* Deferred.done(deferred, exit)
549
- const result = yield* Deferred.await(deferred)
550
-
551
- return Redacted.make(result.access_token)
552
- })
553
-
554
- const getCloudId = (): Effect.Effect<
555
- string,
556
- AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
557
- > =>
558
- Effect.gen(function*() {
559
- const token = yield* loadTokenOp()
560
- if (token === null) {
561
- return yield* Effect.fail(authMissing())
562
- }
563
- return token.cloud_id
564
- })
565
-
566
- const getSiteUrl = (): Effect.Effect<
567
- string,
568
- AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
569
- > =>
570
- Effect.gen(function*() {
571
- const token = yield* loadTokenOp()
572
- if (token === null) {
573
- return yield* Effect.fail(authMissing())
574
- }
575
- return token.site_url
576
- })
577
-
578
- const getCurrentUser = (): Effect.Effect<
579
- OAuthUser | null,
580
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
581
- > =>
582
- Effect.gen(function*() {
583
- const token = yield* loadTokenOp()
584
- return token?.user ?? null
585
- })
586
-
587
- const getActiveProfile = (): Effect.Effect<
588
- AuthProfile | null,
589
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
590
- > => loadActiveProfileOp()
591
-
592
- const listProfiles = (): Effect.Effect<
593
- ReadonlyArray<AuthProfile>,
594
- FileSystemError | HomeDirectoryError | PlatformError.PlatformError
595
- > => listProfilesOp()
596
-
597
- const switchProfile = (
598
- selector: string
599
- ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
600
- switchProfileOp(selector)
601
-
602
- const removeProfile = (
603
- selector: string
604
- ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
605
- removeProfileOp(selector)
606
-
607
- const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
608
- Effect.gen(function*() {
609
- const token = yield* loadTokenOp()
610
- return token !== null
611
- })
612
-
613
- return JiraAuth.of({
614
- configure,
615
- isConfigured,
616
- login,
617
- logout,
618
- getAccessToken,
619
- getCloudId,
620
- getSiteUrl,
621
- getCurrentUser,
622
- getActiveProfile,
623
- listProfiles,
624
- switchProfile,
625
- removeProfile,
626
- isLoggedIn
627
- })
628
- })
629
-
630
168
  /**
631
169
  * Layer for JiraAuth service.
632
170
  *
633
171
  * @category Layers
634
172
  */
635
- 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))
package/src/bin.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  */
9
9
  import { NodeRuntime, NodeStdio } from "@effect/platform-node"
10
10
  import { makeInstallCommand } from "@knpkv/agent-skills"
11
+ import { Command } from "effect/cli"
11
12
  import * as Console from "effect/Console"
12
13
  import * as Effect from "effect/Effect"
13
14
  import * as Stdio from "effect/Stdio"
14
- import { Command } from "effect/unstable/cli"
15
15
  import pkg from "../package.json" with { type: "json" }
16
16
  import {
17
17
  AppLayer,
@@ -8,11 +8,11 @@
8
8
  *
9
9
  * @internal
10
10
  */
11
+ import { openBrowser } from "@knpkv/atlassian-common/cli-auth"
12
+ import { Argument as Args, Command, Flag as Options, Prompt } from "effect/cli"
11
13
  import * as Console from "effect/Console"
12
14
  import * as Effect from "effect/Effect"
13
15
  import * as Option from "effect/Option"
14
- import { Argument as Args, Command, Flag as Options, Prompt } from "effect/unstable/cli"
15
- import { openBrowser } from "../internal/openBrowser.js"
16
16
  import { JiraAuth } from "../JiraAuth.js"
17
17
 
18
18
  // === Auth create command ===
@@ -42,11 +42,11 @@ Creating OAuth app in Atlassian Developer Console...
42
42
  })).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
43
43
 
44
44
  // === Auth configure command ===
45
- const clientIdOption = Options.string("client-id").pipe(
45
+ const clientIdOption = Options.String("client-id").pipe(
46
46
  Options.withDescription("OAuth client ID from Atlassian Developer Console"),
47
47
  Options.optional
48
48
  )
49
- const clientSecretOption = Options.string("client-secret").pipe(
49
+ const clientSecretOption = Options.String("client-secret").pipe(
50
50
  Options.withDescription("OAuth client secret"),
51
51
  Options.optional
52
52
  )
@@ -60,10 +60,10 @@ const configureCommand = Command.make(
60
60
 
61
61
  const rawClientId = Option.isSome(clientId)
62
62
  ? clientId.value
63
- : yield* Prompt.text({ message: "Enter OAuth client ID:" })
63
+ : yield* Prompt.String({ message: "Enter OAuth client ID:" })
64
64
  const rawClientSecret = Option.isSome(clientSecret)
65
65
  ? clientSecret.value
66
- : yield* Prompt.text({ message: "Enter OAuth client secret:" })
66
+ : yield* Prompt.String({ message: "Enter OAuth client secret:" })
67
67
 
68
68
  yield* auth.configure({ clientId: rawClientId, clientSecret: rawClientSecret })
69
69
  yield* Console.log("OAuth configured. Run 'jira auth login' to authenticate.")
@@ -71,7 +71,7 @@ const configureCommand = Command.make(
71
71
  ).pipe(Command.withDescription("Configure OAuth client credentials"))
72
72
 
73
73
  // === Auth login command ===
74
- const siteOption = Options.string("site").pipe(
74
+ const siteOption = Options.String("site").pipe(
75
75
  Options.withDescription("Jira site URL to use (for accounts with multiple sites)"),
76
76
  Options.optional
77
77
  )
@@ -128,7 +128,7 @@ const profilesCommand = Command.make("profiles", {}, () =>
128
128
  }
129
129
  })).pipe(Command.withDescription("List stored auth profiles"))
130
130
 
131
- const profileArg = Args.string("profile").pipe(
131
+ const profileArg = Args.String("profile").pipe(
132
132
  Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
133
133
  )
134
134