@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
@@ -1,269 +0,0 @@
1
- /**
2
- * What a failed token refresh is allowed to do to the stored credential.
3
- *
4
- * Atlassian rotates refresh tokens, so a refresh that does not complete leaves
5
- * the client unable to tell whether the grant landed. Deleting the profile in
6
- * that state is unrecoverable and unattended — `JiraApiConfigLive` builds on
7
- * every CLI invocation, and jcf's statusline runs one every 30 seconds — so a
8
- * bad network window could silently log the user out with nobody watching.
9
- *
10
- * The rule: discard the stored token only when Atlassian actually rejected it
11
- * (a 4xx). Anything else — timeout, transport failure — keeps the credential so
12
- * the next run can retry.
13
- */
14
- import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
15
- import { describe, expect, it } from "@effect/vitest"
16
- import {
17
- HomeDirectoryLive,
18
- loadActiveProfileToken,
19
- saveOAuthConfig,
20
- saveProfileToken
21
- } from "@knpkv/atlassian-common/config"
22
- import { ConfigProvider } from "effect"
23
- import * as Deferred from "effect/Deferred"
24
- import * as Effect from "effect/Effect"
25
- import * as Fiber from "effect/Fiber"
26
- import * as FileSystem from "effect/FileSystem"
27
- import * as Layer from "effect/Layer"
28
- import { TestClock } from "effect/testing"
29
- import { HttpClient, HttpClientResponse } from "effect/unstable/http"
30
- import { JiraAuth, layer as jiraAuthLayer } from "../src/JiraAuth.js"
31
-
32
- const TOOL = "jira-cli"
33
-
34
- const expiredToken = {
35
- access_token: "expired-access",
36
- refresh_token: "rotating-refresh",
37
- expires_at: 0,
38
- token_type: "Bearer",
39
- scope: "offline_access",
40
- cloud_id: "cloud-1",
41
- site_url: "https://example.atlassian.net"
42
- }
43
-
44
- const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
45
-
46
- // JiraAuth also needs a spawner (browser launch during login) and Crypto (PKCE).
47
- // Without them the layer dies as a defect, which no `catchTag` would see — and
48
- // the assertions below would then pass without the refresh ever running.
49
- const authLayerWith = (client: Layer.Layer<HttpClient.HttpClient>) =>
50
- jiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(client, NodeServices.layer)))
51
-
52
- // A client that never answers — the stall case, driven by TestClock. It reports
53
- // when the request has actually been issued so the test can advance the clock at
54
- // a point where the refresh deadline is registered; adjusting before that would
55
- // move past the deadline before it exists and hang.
56
- const stalledClient = (issued: Deferred.Deferred<void>) =>
57
- Layer.succeed(
58
- HttpClient.HttpClient,
59
- HttpClient.make(() => Effect.flatMap(Deferred.succeed(issued, undefined), () => Effect.never))
60
- )
61
-
62
- const respondingClient = (status: number, body: string) =>
63
- Layer.succeed(
64
- HttpClient.HttpClient,
65
- HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))))
66
- )
67
-
68
- // A client that answers with the one thing that does prove the token is dead.
69
- const rejectingClient = respondingClient(400, JSON.stringify({ error: "invalid_grant" }))
70
-
71
- // A rotation that only completes once the test says so, so interruption can be
72
- // aimed at the exact window between the grant and the token being persisted.
73
- const gatedClient = (issued: Deferred.Deferred<void>, release: Deferred.Deferred<void>) =>
74
- Layer.succeed(
75
- HttpClient.HttpClient,
76
- HttpClient.make((request) =>
77
- Effect.gen(function*() {
78
- yield* Deferred.succeed(issued, undefined)
79
- yield* Deferred.await(release)
80
- return HttpClientResponse.fromWeb(
81
- request,
82
- new Response(
83
- JSON.stringify({
84
- access_token: "fresh-access",
85
- refresh_token: "rotated-refresh",
86
- expires_in: 3600,
87
- scope: "offline_access",
88
- token_type: "Bearer"
89
- }),
90
- { status: 200, headers: { "content-type": "application/json" } }
91
- )
92
- )
93
- })
94
- )
95
- )
96
-
97
- // `HomeDirectoryLive` resolves HOME through the ambient ConfigProvider, so the
98
- // test home can be injected rather than set on `process.env` — a global that
99
- // parallel tests would race each other to overwrite and restore. The directory
100
- // is scoped, so it is removed when the effect finishes either way.
101
- const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
102
- Effect.gen(function*() {
103
- const fs = yield* FileSystem.FileSystem
104
- const home = yield* fs.makeTempDirectoryScoped({ prefix: "jcf-auth-" })
105
- return yield* effect.pipe(
106
- Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
107
- )
108
- }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
109
-
110
- const storedToken = loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
111
-
112
- // Both the token and the client config have to be on disk, or `getConfig`
113
- // short-circuits the refresh before any request is made and the assertions
114
- // below would pass without exercising anything.
115
- const seedExpiredToken = Effect.gen(function*() {
116
- yield* saveOAuthConfig(TOOL, { clientId: "client-1", clientSecret: "secret-1" })
117
- yield* saveProfileToken(TOOL, expiredToken)
118
- }).pipe(Effect.provide(storage))
119
-
120
- describe("JiraAuth token refresh", () => {
121
- it.effect("keeps the stored credential when the refresh never answers", () =>
122
- withHome(Effect.gen(function*() {
123
- yield* seedExpiredToken
124
-
125
- const issued = yield* Deferred.make<void>()
126
- const attempt = JiraAuth.pipe(
127
- Effect.flatMap((auth) => auth.getAccessToken()),
128
- Effect.provide(authLayerWith(stalledClient(issued))),
129
- Effect.exit
130
- )
131
- const fiber = yield* attempt.pipe(Effect.forkChild({ startImmediately: true }))
132
-
133
- yield* Deferred.await(issued)
134
- yield* TestClock.adjust("31 seconds")
135
- const exit = yield* Fiber.join(fiber)
136
-
137
- expect(exit._tag).toBe("Failure")
138
- // The point of the test: a stall is not evidence, so the token survives.
139
- const after = yield* storedToken
140
- expect(after?.refresh_token).toBe("rotating-refresh")
141
- })))
142
-
143
- // Rate limiting is the case this most needs to survive: several `jira`/`jcf`
144
- // processes on one expired token hit the endpoint together, one wins the
145
- // rotation and the rest get 429. Treating that as a verdict on the token
146
- // would log the user out precisely when things are busiest.
147
- it.effect("keeps the stored credential when the provider rate-limits", () =>
148
- withHome(Effect.gen(function*() {
149
- yield* seedExpiredToken
150
-
151
- const exit = yield* JiraAuth.pipe(
152
- Effect.flatMap((auth) => auth.getAccessToken()),
153
- Effect.provide(authLayerWith(respondingClient(429, "Too Many Requests"))),
154
- Effect.exit
155
- )
156
-
157
- expect(exit._tag).toBe("Failure")
158
- const after = yield* storedToken
159
- expect(after?.refresh_token).toBe("rotating-refresh")
160
- })))
161
-
162
- // `refreshToken` sends the client credentials in the body, so a wrong or
163
- // rotated secret comes back as `400 invalid_client` — a config problem, not a
164
- // dead token. Deleting here would destroy a working credential and force a
165
- // re-login that fails identically until `jira auth configure` is run.
166
- it.effect("keeps the stored credential when the client credentials are rejected", () =>
167
- withHome(Effect.gen(function*() {
168
- yield* seedExpiredToken
169
-
170
- const exit = yield* JiraAuth.pipe(
171
- Effect.flatMap((auth) => auth.getAccessToken()),
172
- Effect.provide(authLayerWith(respondingClient(400, JSON.stringify({ error: "invalid_client" })))),
173
- Effect.exit
174
- )
175
-
176
- expect(exit._tag).toBe("Failure")
177
- const after = yield* storedToken
178
- expect(after?.refresh_token).toBe("rotating-refresh")
179
- })))
180
-
181
- // A bare 403 is as likely to be a proxy or WAF as Atlassian revoking
182
- // anything, so the code has to say so explicitly.
183
- it.effect("keeps the stored credential on a 403 that does not say invalid_grant", () =>
184
- withHome(Effect.gen(function*() {
185
- yield* seedExpiredToken
186
-
187
- const exit = yield* JiraAuth.pipe(
188
- Effect.flatMap((auth) => auth.getAccessToken()),
189
- Effect.provide(authLayerWith(respondingClient(403, "<html>Forbidden</html>"))),
190
- Effect.exit
191
- )
192
-
193
- expect(exit._tag).toBe("Failure")
194
- const after = yield* storedToken
195
- expect(after?.refresh_token).toBe("rotating-refresh")
196
- })))
197
-
198
- it.effect("discards the stored credential on a 403 revocation", () =>
199
- withHome(Effect.gen(function*() {
200
- yield* seedExpiredToken
201
-
202
- const exit = yield* JiraAuth.pipe(
203
- Effect.flatMap((auth) => auth.getAccessToken()),
204
- Effect.provide(authLayerWith(respondingClient(403, JSON.stringify({ error: "invalid_grant" })))),
205
- Effect.exit
206
- )
207
-
208
- expect(exit._tag).toBe("Failure")
209
- const after = yield* storedToken
210
- expect(after).toBeNull()
211
- })))
212
-
213
- // An unparseable body is no verdict at all, so the conservative reading wins.
214
- it.effect("keeps the stored credential when the error body is unparseable", () =>
215
- withHome(Effect.gen(function*() {
216
- yield* seedExpiredToken
217
-
218
- const exit = yield* JiraAuth.pipe(
219
- Effect.flatMap((auth) => auth.getAccessToken()),
220
- Effect.provide(authLayerWith(respondingClient(400, "not json at all"))),
221
- Effect.exit
222
- )
223
-
224
- expect(exit._tag).toBe("Failure")
225
- const after = yield* storedToken
226
- expect(after?.refresh_token).toBe("rotating-refresh")
227
- })))
228
-
229
- // The atomicity half: once the grant is out, the replacement must reach disk
230
- // even though the caller has already given up on the fiber.
231
- it.effect("persists the rotated token even when interrupted mid-rotation", () =>
232
- withHome(Effect.gen(function*() {
233
- yield* seedExpiredToken
234
- const issued = yield* Deferred.make<void>()
235
- const release = yield* Deferred.make<void>()
236
-
237
- const fiber = yield* JiraAuth.pipe(
238
- Effect.flatMap((auth) => auth.getAccessToken()),
239
- Effect.provide(authLayerWith(gatedClient(issued, release))),
240
- Effect.exit,
241
- Effect.forkChild({ startImmediately: true })
242
- )
243
-
244
- yield* Deferred.await(issued) // the grant is in flight
245
- const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
246
- yield* Deferred.succeed(release, undefined) // now let it answer
247
- yield* Fiber.join(interrupting)
248
-
249
- // The interrupt could not tear the rotation in half, so the replacement
250
- // is on disk rather than lost with the token it consumed.
251
- const after = yield* storedToken
252
- expect(after?.refresh_token).toBe("rotated-refresh")
253
- })))
254
-
255
- it.effect("discards the stored credential only when the provider rejects it", () =>
256
- withHome(Effect.gen(function*() {
257
- yield* seedExpiredToken
258
-
259
- const exit = yield* JiraAuth.pipe(
260
- Effect.flatMap((auth) => auth.getAccessToken()),
261
- Effect.provide(authLayerWith(rejectingClient)),
262
- Effect.exit
263
- )
264
-
265
- expect(exit._tag).toBe("Failure")
266
- const after = yield* storedToken
267
- expect(after).toBeNull()
268
- })))
269
- })
@@ -1,171 +0,0 @@
1
- import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
2
- import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
3
- import { describe, expect, it } from "@effect/vitest"
4
- import * as Effect from "effect/Effect"
5
- import * as Fiber from "effect/Fiber"
6
- import * as Layer from "effect/Layer"
7
- import * as Ref from "effect/Ref"
8
- import { HttpClient, HttpClientRequest, HttpServer, HttpServerError } from "effect/unstable/http"
9
- import { createServer } from "node:http"
10
- import { HttpServerFactoryLive } from "../src/internal/NodeLayers.js"
11
- import {
12
- callbackServerListenOptions,
13
- callbackUrl,
14
- HttpServerFactoryTag,
15
- makeHttpServerFactory,
16
- startCallbackServer
17
- } from "../src/internal/oauthServer.js"
18
-
19
- const EphemeralHttpServerFactoryLive = makeHttpServerFactory(
20
- () => NodeHttpServer.layerServer(createServer, { port: 0 })
21
- )
22
- const HttpClientLive = NodeHttpClient.layerUndici
23
- const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
24
- HttpServer.make({
25
- address: { _tag: "TcpAddress", hostname: "127.0.0.1", port },
26
- serve: () => Effect.void
27
- })
28
-
29
- describe("oauth callback server lifecycle", () => {
30
- it.effect("binds the same localhost name advertised by the callback URL", () =>
31
- Effect.scoped(
32
- Effect.gen(function*() {
33
- const listenOptions = callbackServerListenOptions(0)
34
- const factory = yield* HttpServerFactoryTag
35
- const server = yield* HttpServer.HttpServer.pipe(
36
- Effect.provide(factory.createServerLayer(listenOptions))
37
- )
38
- const advertisedUrl = new URL(callbackUrl(8585))
39
- const ipv6OnlyLocalhost = (hostname: string) => (hostname === "localhost" ? "::1" : undefined)
40
- expect(server.address._tag).toBe("TcpAddress")
41
- expect(listenOptions.host).toBe(advertisedUrl.hostname)
42
- expect(ipv6OnlyLocalhost(listenOptions.host)).toBe("::1")
43
- expect(ipv6OnlyLocalhost(advertisedUrl.hostname)).toBe("::1")
44
- }).pipe(Effect.provide(HttpServerFactoryLive))
45
- ))
46
-
47
- it.effect("releases the port when authorization exits before receiving a code", () =>
48
- Effect.gen(function*() {
49
- yield* Effect.scoped(
50
- startCallbackServer("first").pipe(
51
- Effect.provide(EphemeralHttpServerFactoryLive),
52
- Effect.andThen(Effect.fail("browser-open-failed")),
53
- Effect.flip
54
- )
55
- )
56
-
57
- const port = yield* Effect.scoped(
58
- startCallbackServer("second").pipe(
59
- Effect.provide(EphemeralHttpServerFactoryLive),
60
- Effect.map((server) => server.port)
61
- )
62
- )
63
- expect(port).toBeGreaterThan(0)
64
- }))
65
-
66
- it.effect("starts concurrent real callback servers on distinct OS-assigned ports", () =>
67
- Effect.scoped(
68
- Effect.gen(function*() {
69
- const [first, second] = yield* Effect.all(
70
- [startCallbackServer("first"), startCallbackServer("second")],
71
- { concurrency: "unbounded" }
72
- )
73
- expect(first.port).toBeGreaterThan(0)
74
- expect(second.port).toBeGreaterThan(0)
75
- expect(first.port).not.toBe(second.port)
76
- }).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
77
- ))
78
-
79
- it.effect("retries occupied fixed-range ports with the injected factory", () => {
80
- const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
81
- const occupiedFactory = makeHttpServerFactory(({ port }) =>
82
- Layer.effect(
83
- HttpServer.HttpServer,
84
- Ref.update(attempts, (ports) => [...ports, port]).pipe(
85
- Effect.andThen(
86
- port < 8587
87
- ? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
88
- : Effect.succeed(fakeHttpServer(port))
89
- )
90
- )
91
- )
92
- )
93
-
94
- return Effect.scoped(
95
- Effect.gen(function*() {
96
- const server = yield* startCallbackServer("state")
97
- expect(server.port).toBe(8587)
98
- expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
99
- }).pipe(Effect.provide(occupiedFactory))
100
- )
101
- })
102
-
103
- it.effect("does not retry a non-address-in-use server failure", () => {
104
- const attempts = Ref.makeUnsafe(0)
105
- const failingFactory = makeHttpServerFactory(() =>
106
- Layer.effect(
107
- HttpServer.HttpServer,
108
- Ref.update(attempts, (count) => count + 1).pipe(
109
- Effect.andThen(Effect.fail(
110
- new HttpServerError.ServeError({
111
- cause: { code: "ECONNREFUSED" }
112
- })
113
- ))
114
- )
115
- )
116
- )
117
-
118
- return Effect.scoped(
119
- startCallbackServer("state").pipe(
120
- Effect.provide(failingFactory),
121
- Effect.flip,
122
- Effect.tap(() => Ref.get(attempts).pipe(Effect.map((count) => expect(count).toBe(1))))
123
- )
124
- )
125
- })
126
-
127
- it.effect("ignores a forged error callback before accepting the legitimate state", () =>
128
- Effect.scoped(
129
- Effect.gen(function*() {
130
- const expectedState = "expected-state"
131
- const { codePromise, port } = yield* startCallbackServer(expectedState)
132
- const codeReceiver = yield* Effect.forkChild(codePromise)
133
- const client = yield* HttpClient.HttpClient
134
-
135
- const forgedResponse = yield* client.execute(
136
- HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
137
- HttpClientRequest.setUrlParam("error", "access_denied")
138
- )
139
- )
140
- expect(forgedResponse.status).toBe(403)
141
- yield* client.execute(
142
- HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
143
- HttpClientRequest.setUrlParam("code", "legitimate-code"),
144
- HttpClientRequest.setUrlParam("state", expectedState)
145
- )
146
- )
147
-
148
- expect(yield* Fiber.join(codeReceiver)).toBe("legitimate-code")
149
- }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
150
- ))
151
-
152
- it.effect("accepts a provider error only after validating state", () =>
153
- Effect.scoped(
154
- Effect.gen(function*() {
155
- const expectedState = "expected-state"
156
- const { codePromise, port } = yield* startCallbackServer(expectedState)
157
- const codeReceiver = yield* Effect.forkChild(codePromise)
158
- const client = yield* HttpClient.HttpClient
159
-
160
- yield* client.execute(
161
- HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
162
- HttpClientRequest.setUrlParam("error", "access_denied"),
163
- HttpClientRequest.setUrlParam("state", expectedState)
164
- )
165
- )
166
-
167
- const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
168
- expect(String(error.cause)).toContain("access_denied")
169
- }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
170
- ))
171
- })