@knpkv/jira-cli 0.1.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 (87) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/dist/IssueService.d.ts +144 -0
  5. package/dist/IssueService.d.ts.map +1 -0
  6. package/dist/IssueService.js +250 -0
  7. package/dist/IssueService.js.map +1 -0
  8. package/dist/JiraAuth.d.ts +84 -0
  9. package/dist/JiraAuth.d.ts.map +1 -0
  10. package/dist/JiraAuth.js +246 -0
  11. package/dist/JiraAuth.js.map +1 -0
  12. package/dist/JiraCliError.d.ts +42 -0
  13. package/dist/JiraCliError.d.ts.map +1 -0
  14. package/dist/JiraCliError.js +35 -0
  15. package/dist/JiraCliError.js.map +1 -0
  16. package/dist/MarkdownWriter.d.ts +56 -0
  17. package/dist/MarkdownWriter.d.ts.map +1 -0
  18. package/dist/MarkdownWriter.js +66 -0
  19. package/dist/MarkdownWriter.js.map +1 -0
  20. package/dist/bin.d.ts +3 -0
  21. package/dist/bin.d.ts.map +1 -0
  22. package/dist/bin.js +39 -0
  23. package/dist/bin.js.map +1 -0
  24. package/dist/commands/auth.d.ts +22 -0
  25. package/dist/commands/auth.d.ts.map +1 -0
  26. package/dist/commands/auth.js +89 -0
  27. package/dist/commands/auth.js.map +1 -0
  28. package/dist/commands/errorHandler.d.ts +13 -0
  29. package/dist/commands/errorHandler.d.ts.map +1 -0
  30. package/dist/commands/errorHandler.js +13 -0
  31. package/dist/commands/errorHandler.js.map +1 -0
  32. package/dist/commands/get.d.ts +13 -0
  33. package/dist/commands/get.d.ts.map +1 -0
  34. package/dist/commands/get.js +25 -0
  35. package/dist/commands/get.js.map +1 -0
  36. package/dist/commands/index.d.ts +11 -0
  37. package/dist/commands/index.d.ts.map +1 -0
  38. package/dist/commands/index.js +11 -0
  39. package/dist/commands/index.js.map +1 -0
  40. package/dist/commands/layers.d.ts +44 -0
  41. package/dist/commands/layers.d.ts.map +1 -0
  42. package/dist/commands/layers.js +100 -0
  43. package/dist/commands/layers.js.map +1 -0
  44. package/dist/commands/search.d.ts +18 -0
  45. package/dist/commands/search.d.ts.map +1 -0
  46. package/dist/commands/search.js +64 -0
  47. package/dist/commands/search.js.map +1 -0
  48. package/dist/index.d.ts +10 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +10 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/internal/NodeLayers.d.ts +7 -0
  53. package/dist/internal/NodeLayers.d.ts.map +1 -0
  54. package/dist/internal/NodeLayers.js +15 -0
  55. package/dist/internal/NodeLayers.js.map +1 -0
  56. package/dist/internal/frontmatter.d.ts +60 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -0
  58. package/dist/internal/frontmatter.js +130 -0
  59. package/dist/internal/frontmatter.js.map +1 -0
  60. package/dist/internal/jqlBuilder.d.ts +39 -0
  61. package/dist/internal/jqlBuilder.d.ts.map +1 -0
  62. package/dist/internal/jqlBuilder.js +47 -0
  63. package/dist/internal/jqlBuilder.js.map +1 -0
  64. package/dist/internal/oauthServer.d.ts +55 -0
  65. package/dist/internal/oauthServer.d.ts.map +1 -0
  66. package/dist/internal/oauthServer.js +113 -0
  67. package/dist/internal/oauthServer.js.map +1 -0
  68. package/package.json +86 -0
  69. package/src/IssueService.ts +378 -0
  70. package/src/JiraAuth.ts +476 -0
  71. package/src/JiraCliError.ts +44 -0
  72. package/src/MarkdownWriter.ts +112 -0
  73. package/src/bin.ts +62 -0
  74. package/src/commands/auth.ts +124 -0
  75. package/src/commands/errorHandler.ts +14 -0
  76. package/src/commands/get.ts +42 -0
  77. package/src/commands/index.ts +11 -0
  78. package/src/commands/layers.ts +142 -0
  79. package/src/commands/search.ts +102 -0
  80. package/src/index.ts +26 -0
  81. package/src/internal/NodeLayers.ts +17 -0
  82. package/src/internal/frontmatter.ts +170 -0
  83. package/src/internal/jqlBuilder.ts +49 -0
  84. package/src/internal/oauthServer.ts +203 -0
  85. package/test/jqlBuilder.test.ts +45 -0
  86. package/tsconfig.json +32 -0
  87. package/vitest.config.ts +12 -0
@@ -0,0 +1,476 @@
1
+ /**
2
+ * OAuth2 authentication service for Jira CLI with token refresh and concurrency locking.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Service pattern**: {@link JiraAuth} is a `Context.Tag` whose layer requires `HttpClient`
7
+ * and `CommandExecutor`. 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.
13
+ *
14
+ * **Common tasks**
15
+ *
16
+ * - Get a valid access token: `auth.getAccessToken()` (auto-refreshes if expired)
17
+ * - Full login flow: `auth.login()`
18
+ * - Check auth state: `auth.isLoggedIn()`
19
+ *
20
+ * @module
21
+ */
22
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
23
+ import * as NodePath from "@effect/platform-node/NodePath"
24
+ import * as Command from "@effect/platform/Command"
25
+ import * as CommandExecutor from "@effect/platform/CommandExecutor"
26
+ import type * as Error from "@effect/platform/Error"
27
+ import * as HttpClient from "@effect/platform/HttpClient"
28
+ import {
29
+ buildAuthUrl,
30
+ buildOAuthToken,
31
+ computeCodeChallenge,
32
+ exchangeCodeForTokens,
33
+ generateCodeVerifier,
34
+ generateUUID,
35
+ getAccessibleResources,
36
+ getUserInfo,
37
+ OAuthError,
38
+ refreshToken,
39
+ revokeToken
40
+ } from "@knpkv/atlassian-common/auth"
41
+ import {
42
+ deleteToken,
43
+ type FileSystemError,
44
+ type HomeDirectoryError,
45
+ HomeDirectoryLive,
46
+ isTokenExpired,
47
+ loadOAuthConfig,
48
+ loadToken,
49
+ type OAuthConfig,
50
+ type OAuthToken,
51
+ type OAuthUser,
52
+ saveOAuthConfig,
53
+ saveToken
54
+ } from "@knpkv/atlassian-common/config"
55
+ import * as Console from "effect/Console"
56
+ import * as Context from "effect/Context"
57
+ import * as Deferred from "effect/Deferred"
58
+ import * as Effect from "effect/Effect"
59
+ import * as Layer from "effect/Layer"
60
+ import * as Option from "effect/Option"
61
+ import * as Redacted from "effect/Redacted"
62
+ import * as Ref from "effect/Ref"
63
+ import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
64
+ import { startCallbackServer } from "./internal/oauthServer.js"
65
+ import type { AuthMissingError } from "./JiraCliError.js"
66
+ import { authMissing } from "./JiraCliError.js"
67
+
68
+ /** Scopes for Jira CLI (includes write:jira-work for worklog) */
69
+ const JIRA_CLI_SCOPES = [
70
+ "read:jira-work",
71
+ "write:jira-work",
72
+ "read:jira-user",
73
+ "read:me",
74
+ "offline_access"
75
+ ]
76
+
77
+ const TOOL_NAME = "jira-cli"
78
+
79
+ // Layer for token storage operations (FileSystem + Path + HomeDirectory)
80
+ const TokenStorageLive = Layer.mergeAll(
81
+ NodeFileSystem.layer,
82
+ NodePath.layer,
83
+ HomeDirectoryLive
84
+ )
85
+
86
+ // Wrap token storage operations with their required layers
87
+ const loadTokenOp = () => loadToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
88
+ const saveTokenOp = (token: OAuthToken) => saveToken(TOOL_NAME, token).pipe(Effect.provide(TokenStorageLive))
89
+ const deleteTokenOp = () => deleteToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
90
+ const loadOAuthConfigOp = () => loadOAuthConfig(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
91
+ const saveOAuthConfigOp = (config: OAuthConfig) =>
92
+ saveOAuthConfig(TOOL_NAME, config).pipe(Effect.provide(TokenStorageLive))
93
+
94
+ /**
95
+ * Options for the login method.
96
+ *
97
+ * @category Types
98
+ */
99
+ export interface LoginOptions {
100
+ /** Site URL to select (for accounts with multiple sites) */
101
+ readonly siteUrl?: string
102
+ }
103
+
104
+ /**
105
+ * Information about an accessible Jira site.
106
+ *
107
+ * @category Types
108
+ */
109
+ export interface AccessibleSite {
110
+ readonly id: string
111
+ readonly name: string
112
+ readonly url: string
113
+ }
114
+
115
+ /**
116
+ * JiraAuth service interface.
117
+ *
118
+ * @category Services
119
+ */
120
+ export interface JiraAuthService {
121
+ /** Configure OAuth client credentials */
122
+ readonly configure: (
123
+ config: OAuthConfig
124
+ ) => Effect.Effect<void, FileSystemError | HomeDirectoryError | Error.PlatformError>
125
+ /** Check if OAuth is configured */
126
+ readonly isConfigured: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError>
127
+ /** Start OAuth login flow. Returns list of sites if multiple are available. */
128
+ readonly login: (
129
+ options?: LoginOptions
130
+ ) => Effect.Effect<
131
+ ReadonlyArray<AccessibleSite> | void,
132
+ OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
133
+ >
134
+ /** Remove stored authentication */
135
+ readonly logout: () => Effect.Effect<void, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError>
136
+ /** Get access token, refreshing if needed */
137
+ readonly getAccessToken: () => Effect.Effect<
138
+ Redacted.Redacted<string>,
139
+ AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
140
+ >
141
+ /** Get cloud ID from stored token */
142
+ readonly getCloudId: () => Effect.Effect<
143
+ string,
144
+ AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
145
+ >
146
+ /** Get site URL from stored token */
147
+ readonly getSiteUrl: () => Effect.Effect<
148
+ string,
149
+ AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
150
+ >
151
+ /** Get current user info from stored token */
152
+ readonly getCurrentUser: () => Effect.Effect<
153
+ OAuthUser | null,
154
+ FileSystemError | HomeDirectoryError | Error.PlatformError
155
+ >
156
+ /** Check if user is logged in */
157
+ readonly isLoggedIn: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError>
158
+ }
159
+
160
+ /**
161
+ * JiraAuth service tag.
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * import { Effect } from "effect"
166
+ * import { JiraAuth } from "@knpkv/jira-cli/JiraAuth"
167
+ *
168
+ * Effect.gen(function* () {
169
+ * const auth = yield* JiraAuth
170
+ * const isLoggedIn = yield* auth.isLoggedIn()
171
+ * if (!isLoggedIn) {
172
+ * yield* auth.login()
173
+ * }
174
+ * })
175
+ * ```
176
+ *
177
+ * @category Services
178
+ */
179
+ export class JiraAuth extends Context.Tag("@knpkv/jira-cli/JiraAuth")<
180
+ JiraAuth,
181
+ JiraAuthService
182
+ >() {}
183
+
184
+ const make = Effect.gen(function*() {
185
+ const httpClient = yield* HttpClient.HttpClient
186
+ const commandExecutor = yield* CommandExecutor.CommandExecutor
187
+
188
+ // Ref to track ongoing refresh operation to prevent concurrent refreshes
189
+ const refreshLock = yield* Ref.make<
190
+ Option.Option<
191
+ Deferred.Deferred<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError>
192
+ >
193
+ >(
194
+ Option.none()
195
+ )
196
+
197
+ const openBrowserImpl = (url: string): Effect.Effect<void, OAuthError> =>
198
+ Command.make("open", url).pipe(
199
+ Command.exitCode,
200
+ Effect.catchAll(() => Command.make("xdg-open", url).pipe(Command.exitCode)),
201
+ Effect.catchAll(() => Command.make("cmd", "/c", "start", "", url).pipe(Command.exitCode)),
202
+ Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, commandExecutor)),
203
+ Effect.asVoid,
204
+ Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
205
+ )
206
+
207
+ const getConfig = (): Effect.Effect<
208
+ OAuthConfig,
209
+ OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
210
+ > =>
211
+ Effect.gen(function*() {
212
+ const config = yield* loadOAuthConfigOp()
213
+ if (config === null) {
214
+ return yield* Effect.fail(
215
+ new OAuthError({
216
+ step: "authorize",
217
+ cause: "OAuth not configured. Run 'jira auth configure' first."
218
+ })
219
+ )
220
+ }
221
+ return config
222
+ })
223
+
224
+ const refreshTokenImpl = (
225
+ token: OAuthToken,
226
+ config: OAuthConfig
227
+ ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError> =>
228
+ Effect.gen(function*() {
229
+ const updated = yield* refreshToken(token, config).pipe(
230
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
231
+ )
232
+ yield* saveTokenOp(updated)
233
+ return updated
234
+ })
235
+
236
+ const revokeTokenImpl = (
237
+ token: OAuthToken,
238
+ config: OAuthConfig
239
+ ): Effect.Effect<void, OAuthError> =>
240
+ revokeToken(token, config).pipe(
241
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
242
+ )
243
+
244
+ const configure = (
245
+ config: OAuthConfig
246
+ ): Effect.Effect<void, FileSystemError | HomeDirectoryError | Error.PlatformError> => saveOAuthConfigOp(config)
247
+
248
+ const isConfigured = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError> =>
249
+ Effect.gen(function*() {
250
+ const config = yield* loadOAuthConfigOp()
251
+ return config !== null
252
+ })
253
+
254
+ const login = (
255
+ options?: LoginOptions
256
+ ): Effect.Effect<
257
+ ReadonlyArray<AccessibleSite> | void,
258
+ OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
259
+ > =>
260
+ Effect.gen(function*() {
261
+ const config = yield* getConfig()
262
+ const state = yield* generateUUID()
263
+ const codeVerifier = generateCodeVerifier()
264
+ const codeChallenge = yield* computeCodeChallenge(codeVerifier)
265
+
266
+ const { codePromise, port, shutdown } = yield* startCallbackServer(state).pipe(
267
+ Effect.provide(HttpServerFactoryLive)
268
+ )
269
+ const authUrl = buildAuthUrl({ clientId: config.clientId, state, port, scopes: JIRA_CLI_SCOPES, codeChallenge })
270
+
271
+ yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
272
+ yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
273
+ yield* openBrowserImpl(authUrl)
274
+ yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
275
+
276
+ const code = yield* codePromise.pipe(
277
+ Effect.timeout("5 minutes"),
278
+ Effect.catchTag(
279
+ "TimeoutException",
280
+ () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
281
+ ),
282
+ Effect.ensuring(shutdown)
283
+ )
284
+
285
+ yield* Console.log("Exchanging code for tokens...")
286
+ const tokens = yield* exchangeCodeForTokens(code, config, { port, codeVerifier }).pipe(
287
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
288
+ )
289
+
290
+ yield* Console.log("Fetching accessible sites...")
291
+ const sites = yield* getAccessibleResources(tokens.access_token).pipe(
292
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
293
+ )
294
+
295
+ if (sites.length === 0) {
296
+ return yield* Effect.fail(
297
+ new OAuthError({
298
+ step: "authorize",
299
+ cause: "No Jira sites found for this account"
300
+ })
301
+ )
302
+ }
303
+
304
+ let site: (typeof sites)[number]
305
+
306
+ if (sites.length > 1) {
307
+ if (options?.siteUrl) {
308
+ const matched = sites.find((s) => s.url === options.siteUrl)
309
+ if (!matched) {
310
+ const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
311
+ return yield* Effect.fail(
312
+ new OAuthError({
313
+ step: "authorize",
314
+ cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
315
+ })
316
+ )
317
+ }
318
+ site = matched
319
+ } else {
320
+ yield* Console.log("Multiple Jira sites found. Please select one:")
321
+ for (const s of sites) {
322
+ yield* Console.log(` - ${s.name}: ${s.url}`)
323
+ }
324
+ yield* Console.log("\nRun 'jira auth login --site <url>' to select a site")
325
+ return sites.map((s) => ({ id: s.id, name: s.name, url: s.url }))
326
+ }
327
+ } else {
328
+ site = sites[0]!
329
+ }
330
+
331
+ yield* Console.log("Fetching user info...")
332
+ const user = yield* getUserInfo(tokens.access_token).pipe(
333
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
334
+ )
335
+
336
+ const tokenData = buildOAuthToken(tokens, site, user)
337
+
338
+ yield* saveTokenOp(tokenData)
339
+ yield* Console.log(`Logged in as ${user.name} (${user.email})`)
340
+ return undefined
341
+ })
342
+
343
+ const logout = (): Effect.Effect<void, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError> =>
344
+ Effect.gen(function*() {
345
+ const token = yield* loadTokenOp()
346
+ if (token === null) {
347
+ yield* Console.log("Not logged in")
348
+ return
349
+ }
350
+
351
+ const config = yield* loadOAuthConfigOp()
352
+ if (config !== null) {
353
+ yield* revokeTokenImpl(token, config).pipe(
354
+ Effect.tap(() => Effect.log("Token revoked with Atlassian")),
355
+ Effect.catchAll((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
356
+ )
357
+ }
358
+
359
+ yield* deleteTokenOp()
360
+ })
361
+
362
+ const getAccessToken = (): Effect.Effect<
363
+ Redacted.Redacted<string>,
364
+ AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
365
+ > =>
366
+ Effect.gen(function*() {
367
+ const token = yield* loadTokenOp()
368
+ if (token === null) {
369
+ return yield* Effect.fail(authMissing())
370
+ }
371
+
372
+ if (!isTokenExpired(token)) {
373
+ return Redacted.make(token.access_token)
374
+ }
375
+
376
+ // Atomically check-then-set refresh lock to avoid TOCTOU race
377
+ const deferred = yield* Deferred.make<
378
+ OAuthToken,
379
+ OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
380
+ >()
381
+ const existing = yield* Ref.modify(refreshLock, (current) =>
382
+ Option.isSome(current)
383
+ ? [current.value, current] as const
384
+ : [deferred, Option.some(deferred)] as const)
385
+
386
+ // Another fiber is already refreshing — just await its result
387
+ if (existing !== deferred) {
388
+ const refreshed = yield* Deferred.await(existing)
389
+ return Redacted.make(refreshed.access_token)
390
+ }
391
+
392
+ // This fiber owns the refresh
393
+ const config = yield* getConfig()
394
+ yield* Console.log("Token expired, refreshing...")
395
+
396
+ const result = yield* refreshTokenImpl(token, config).pipe(
397
+ Effect.tap((refreshed) => Deferred.succeed(deferred, refreshed)),
398
+ Effect.tapError((error) => Deferred.fail(deferred, error)),
399
+ Effect.ensuring(Ref.set(refreshLock, Option.none())),
400
+ Effect.catchTag("OAuthError", (error) => {
401
+ if (error.step === "refresh") {
402
+ return Effect.gen(function*() {
403
+ yield* deleteTokenOp()
404
+ return yield* Effect.fail(
405
+ new OAuthError({
406
+ step: "refresh",
407
+ cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate."
408
+ })
409
+ )
410
+ })
411
+ }
412
+ return Effect.fail(error)
413
+ })
414
+ )
415
+
416
+ return Redacted.make(result.access_token)
417
+ })
418
+
419
+ const getCloudId = (): Effect.Effect<
420
+ string,
421
+ AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
422
+ > =>
423
+ Effect.gen(function*() {
424
+ const token = yield* loadTokenOp()
425
+ if (token === null) {
426
+ return yield* Effect.fail(authMissing())
427
+ }
428
+ return token.cloud_id
429
+ })
430
+
431
+ const getSiteUrl = (): Effect.Effect<
432
+ string,
433
+ AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
434
+ > =>
435
+ Effect.gen(function*() {
436
+ const token = yield* loadTokenOp()
437
+ if (token === null) {
438
+ return yield* Effect.fail(authMissing())
439
+ }
440
+ return token.site_url
441
+ })
442
+
443
+ const getCurrentUser = (): Effect.Effect<
444
+ OAuthUser | null,
445
+ FileSystemError | HomeDirectoryError | Error.PlatformError
446
+ > =>
447
+ Effect.gen(function*() {
448
+ const token = yield* loadTokenOp()
449
+ return token?.user ?? null
450
+ })
451
+
452
+ const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError> =>
453
+ Effect.gen(function*() {
454
+ const token = yield* loadTokenOp()
455
+ return token !== null
456
+ })
457
+
458
+ return JiraAuth.of({
459
+ configure,
460
+ isConfigured,
461
+ login,
462
+ logout,
463
+ getAccessToken,
464
+ getCloudId,
465
+ getSiteUrl,
466
+ getCurrentUser,
467
+ isLoggedIn
468
+ })
469
+ })
470
+
471
+ /**
472
+ * Layer for JiraAuth service.
473
+ *
474
+ * @category Layers
475
+ */
476
+ export const layer = Layer.effect(JiraAuth, make)
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tagged error types for Jira CLI operations.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Three error tags**: {@link AuthMissingError} (not logged in),
7
+ * {@link JiraApiError} (API call failed), {@link WriteError} (file I/O failed).
8
+ * - **Factory helper**: {@link authMissing} creates `AuthMissingError` with the default message.
9
+ *
10
+ * @module
11
+ */
12
+ import * as Data from "effect/Data"
13
+
14
+ /**
15
+ * Error when user is not authenticated.
16
+ *
17
+ * @category Errors
18
+ */
19
+ export class AuthMissingError extends Data.TaggedError("AuthMissingError")<{
20
+ readonly message: string
21
+ }> {}
22
+
23
+ export const authMissing = () => new AuthMissingError({ message: "Not logged in. Run 'jira auth login' first." })
24
+
25
+ /**
26
+ * Error during Jira API operations.
27
+ *
28
+ * @category Errors
29
+ */
30
+ export class JiraApiError extends Data.TaggedError("JiraApiError")<{
31
+ readonly message: string
32
+ readonly cause?: unknown
33
+ }> {}
34
+
35
+ /**
36
+ * Error when writing files.
37
+ *
38
+ * @category Errors
39
+ */
40
+ export class WriteError extends Data.TaggedError("WriteError")<{
41
+ readonly path: string
42
+ readonly message: string
43
+ readonly cause?: unknown
44
+ }> {}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * File I/O service for writing Jira issues as Markdown with YAML frontmatter.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Two output modes**: {@link MarkdownWriterShape.writeMulti} creates one `.md` per issue;
7
+ * {@link MarkdownWriterShape.writeSingle} combines all into `jira-export.md`.
8
+ * - **Effect service**: Requires `FileSystem` and `Path` from `@effect/platform`.
9
+ *
10
+ * @module
11
+ */
12
+ import * as FileSystem from "@effect/platform/FileSystem"
13
+ import * as Path from "@effect/platform/Path"
14
+ import * as Context from "effect/Context"
15
+ import * as Effect from "effect/Effect"
16
+ import * as Layer from "effect/Layer"
17
+ import { buildCombinedMarkdown, serializeIssue } from "./internal/frontmatter.js"
18
+ import type { Issue } from "./IssueService.js"
19
+ import { WriteError } from "./JiraCliError.js"
20
+
21
+ /**
22
+ * MarkdownWriter service interface.
23
+ *
24
+ * @category Services
25
+ */
26
+ export interface MarkdownWriterShape {
27
+ /** Write each issue to a separate markdown file */
28
+ readonly writeMulti: (
29
+ issues: ReadonlyArray<Issue>,
30
+ outputDir: string
31
+ ) => Effect.Effect<void, WriteError>
32
+ /** Write all issues to a single markdown file */
33
+ readonly writeSingle: (
34
+ issues: ReadonlyArray<Issue>,
35
+ outputDir: string,
36
+ jql: string
37
+ ) => Effect.Effect<void, WriteError>
38
+ }
39
+
40
+ /**
41
+ * MarkdownWriter service tag.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { Effect } from "effect"
46
+ * import { MarkdownWriter } from "@knpkv/jira-cli/MarkdownWriter"
47
+ *
48
+ * Effect.gen(function* () {
49
+ * const writer = yield* MarkdownWriter
50
+ * yield* writer.writeMulti(issues, "./output")
51
+ * })
52
+ * ```
53
+ *
54
+ * @category Services
55
+ */
56
+ export class MarkdownWriter extends Context.Tag("@knpkv/jira-cli/MarkdownWriter")<
57
+ MarkdownWriter,
58
+ MarkdownWriterShape
59
+ >() {}
60
+
61
+ const make = Effect.gen(function*() {
62
+ const fs = yield* FileSystem.FileSystem
63
+ const path = yield* Path.Path
64
+
65
+ const ensureDir = (dir: string): Effect.Effect<void, WriteError> =>
66
+ fs.makeDirectory(dir, { recursive: true }).pipe(
67
+ Effect.catchAll((cause) =>
68
+ Effect.fail(new WriteError({ path: dir, message: "Failed to create directory", cause }))
69
+ )
70
+ )
71
+
72
+ const writeFile = (filePath: string, content: string): Effect.Effect<void, WriteError> =>
73
+ fs.writeFileString(filePath, content).pipe(
74
+ Effect.catchAll((cause) =>
75
+ Effect.fail(new WriteError({ path: filePath, message: "Failed to write file", cause }))
76
+ )
77
+ )
78
+
79
+ const writeMulti: MarkdownWriterShape["writeMulti"] = (issues, outputDir) =>
80
+ Effect.gen(function*() {
81
+ yield* ensureDir(outputDir)
82
+
83
+ for (const issue of issues) {
84
+ const filename = `${issue.key}.md`
85
+ const filePath = path.join(outputDir, filename)
86
+ const content = serializeIssue(issue)
87
+ yield* writeFile(filePath, content)
88
+ }
89
+ })
90
+
91
+ const writeSingle: MarkdownWriterShape["writeSingle"] = (issues, outputDir, jql) =>
92
+ Effect.gen(function*() {
93
+ yield* ensureDir(outputDir)
94
+
95
+ const filename = "jira-export.md"
96
+ const filePath = path.join(outputDir, filename)
97
+ const content = buildCombinedMarkdown(issues, jql)
98
+ yield* writeFile(filePath, content)
99
+ })
100
+
101
+ return MarkdownWriter.of({ writeMulti, writeSingle })
102
+ })
103
+
104
+ /**
105
+ * Layer for MarkdownWriter service.
106
+ *
107
+ * @category Layers
108
+ */
109
+ export const layer: Layer.Layer<MarkdownWriter, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
110
+ MarkdownWriter,
111
+ make
112
+ )
package/src/bin.ts ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point — assembles commands, selects layer by subcommand, runs via `NodeRuntime`.
4
+ *
5
+ * `process.argv` is read once at the edge and passed to Effect — no globals in effectful code.
6
+ *
7
+ * @module
8
+ */
9
+ import { Command } from "@effect/cli"
10
+ import { NodeRuntime } from "@effect/platform-node"
11
+ import * as Effect from "effect/Effect"
12
+ import * as Logger from "effect/Logger"
13
+ import * as LogLevel from "effect/LogLevel"
14
+ import pkg from "../package.json" with { type: "json" }
15
+ import {
16
+ AppLayer,
17
+ authCommand,
18
+ AuthOnlyLayer,
19
+ getCommand,
20
+ getLayerType,
21
+ handleError,
22
+ MinimalLayer,
23
+ searchCommand
24
+ } from "./commands/index.js"
25
+
26
+ // === Main command ===
27
+ const jira = Command.make("jira").pipe(
28
+ Command.withDescription("Fetch Jira tickets and export to markdown"),
29
+ Command.withSubcommands([
30
+ authCommand,
31
+ getCommand,
32
+ searchCommand
33
+ ])
34
+ )
35
+
36
+ // === Run CLI ===
37
+ const cli = Command.run(jira, {
38
+ name: pkg.name,
39
+ version: pkg.version
40
+ })
41
+
42
+ // Read argv once at the edge
43
+ const argv = globalThis.process.argv
44
+
45
+ const layerType = getLayerType(argv)
46
+ const layer = layerType === "full"
47
+ ? AppLayer
48
+ : layerType === "auth"
49
+ ? AuthOnlyLayer
50
+ : MinimalLayer
51
+
52
+ // Suppress verbose Effect logs
53
+ const SilentLogger = Logger.replace(Logger.defaultLogger, Logger.none)
54
+
55
+ const program = cli(argv).pipe(
56
+ Effect.provide(layer),
57
+ Effect.provide(SilentLogger),
58
+ Logger.withMinimumLogLevel(LogLevel.None),
59
+ Effect.catchAllCause((cause) => handleError(cause))
60
+ )
61
+
62
+ NodeRuntime.runMain(program)