@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.
- package/CHANGELOG.md +31 -0
- package/README.md +5 -5
- package/dist/JiraAuth.d.ts +23 -8
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +8 -303
- package/dist/JiraAuth.js.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +8 -8
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/edit.d.ts +10 -1
- package/dist/commands/edit.d.ts.map +1 -1
- package/dist/commands/edit.js +9 -9
- package/dist/commands/edit.js.map +1 -1
- package/dist/commands/get.d.ts +6 -1
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +3 -3
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/issue.d.ts +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +1 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/issueAttachment.d.ts +1 -1
- package/dist/commands/issueAttachment.d.ts.map +1 -1
- package/dist/commands/issueAttachment.js +13 -11
- package/dist/commands/issueAttachment.js.map +1 -1
- package/dist/commands/layers.d.ts +2 -2
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/search.d.ts +6 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +7 -7
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +9 -1
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +23 -23
- package/dist/commands/version.js.map +1 -1
- package/package.json +10 -10
- package/skills/jira/SKILL.md +4 -4
- package/src/JiraAuth.ts +24 -477
- package/src/bin.ts +1 -1
- package/src/commands/auth.ts +8 -8
- package/src/commands/edit.ts +9 -9
- package/src/commands/get.ts +3 -3
- package/src/commands/issue.ts +1 -1
- package/src/commands/issueAttachment.ts +21 -20
- package/src/commands/search.ts +7 -7
- package/src/commands/version.ts +26 -29
- package/test/IssueService.test.ts +2 -2
- package/test/JiraAuth.test.ts +79 -0
- package/test/VersionService.test.ts +2 -2
- package/test/commandTree.test.ts +1 -1
- package/test/integration.test.ts +7 -7
- package/test/remoteWrites.test.ts +3 -3
- package/vitest.config.integration.ts +4 -1
- package/vitest.config.ts +4 -1
- package/dist/internal/NodeLayers.d.ts +0 -7
- package/dist/internal/NodeLayers.d.ts.map +0 -1
- package/dist/internal/NodeLayers.js +0 -15
- package/dist/internal/NodeLayers.js.map +0 -1
- package/dist/internal/oauthServer.d.ts +0 -72
- package/dist/internal/oauthServer.d.ts.map +0 -1
- package/dist/internal/oauthServer.js +0 -96
- package/dist/internal/oauthServer.js.map +0 -1
- package/dist/internal/openBrowser.d.ts +0 -10
- package/dist/internal/openBrowser.d.ts.map +0 -1
- package/dist/internal/openBrowser.js +0 -17
- package/dist/internal/openBrowser.js.map +0 -1
- package/src/internal/NodeLayers.ts +0 -17
- package/src/internal/oauthServer.ts +0 -186
- package/src/internal/openBrowser.ts +0 -31
- package/test/JiraAuthRefresh.test.ts +0 -269
- 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
|
+
"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
|
|
57
|
+
"effect": "4.0.0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@effect/platform-node": "4.0.0
|
|
60
|
+
"@effect/platform-node": "4.0.0",
|
|
61
61
|
"gray-matter": "^4.0.3",
|
|
62
|
-
"js-yaml": "5.2
|
|
63
|
-
"@knpkv/agent-skills": "^0.3.
|
|
64
|
-
"@knpkv/atlassian-common": "1.
|
|
65
|
-
"@knpkv/jira-api-client": "
|
|
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
|
|
68
|
+
"@effect/vitest": "4.0.0",
|
|
69
69
|
"@types/js-yaml": "^4.0.9",
|
|
70
70
|
"@types/node": "latest",
|
|
71
|
-
"effect": "4.0.0
|
|
71
|
+
"effect": "4.0.0",
|
|
72
72
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
73
|
-
"vitest": "^
|
|
73
|
+
"vitest": "^5.0.3"
|
|
74
74
|
},
|
|
75
75
|
"keywords": [
|
|
76
76
|
"effect",
|
package/skills/jira/SKILL.md
CHANGED
|
@@ -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 "
|
|
88
|
-
jira version create --project PROJ --name "
|
|
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 "
|
|
109
|
-
jira issue edit PROJ-123 --remove-fix-version "
|
|
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 `
|
|
8
|
-
* `@knpkv/atlassian-common/
|
|
9
|
-
*
|
|
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
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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(
|
|
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,
|
package/src/commands/auth.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
63
|
+
: yield* Prompt.String({ message: "Enter OAuth client ID:" })
|
|
64
64
|
const rawClientSecret = Option.isSome(clientSecret)
|
|
65
65
|
? clientSecret.value
|
|
66
|
-
: yield* Prompt.
|
|
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.
|
|
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.
|
|
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
|
|