@knpkv/jira-cli 0.1.1 → 0.3.0

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 (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +63 -1
  3. package/dist/IssueService.d.ts +2 -2
  4. package/dist/IssueService.d.ts.map +1 -1
  5. package/dist/IssueService.js +3 -3
  6. package/dist/IssueService.js.map +1 -1
  7. package/dist/JiraAuth.d.ts +14 -14
  8. package/dist/JiraAuth.d.ts.map +1 -1
  9. package/dist/JiraAuth.js +18 -10
  10. package/dist/JiraAuth.js.map +1 -1
  11. package/dist/MarkdownWriter.d.ts +4 -4
  12. package/dist/MarkdownWriter.d.ts.map +1 -1
  13. package/dist/MarkdownWriter.js +6 -6
  14. package/dist/MarkdownWriter.js.map +1 -1
  15. package/dist/VersionService.d.ts +206 -0
  16. package/dist/VersionService.d.ts.map +1 -0
  17. package/dist/VersionService.js +426 -0
  18. package/dist/VersionService.js.map +1 -0
  19. package/dist/bin.js +28 -20
  20. package/dist/bin.js.map +1 -1
  21. package/dist/commands/auth.d.ts +2 -21
  22. package/dist/commands/auth.d.ts.map +1 -1
  23. package/dist/commands/auth.js +6 -6
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/get.d.ts +3 -8
  26. package/dist/commands/get.d.ts.map +1 -1
  27. package/dist/commands/get.js +2 -2
  28. package/dist/commands/get.js.map +1 -1
  29. package/dist/commands/index.d.ts +1 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +1 -0
  32. package/dist/commands/index.js.map +1 -1
  33. package/dist/commands/layers.d.ts +6 -18
  34. package/dist/commands/layers.d.ts.map +1 -1
  35. package/dist/commands/layers.js +31 -23
  36. package/dist/commands/layers.js.map +1 -1
  37. package/dist/commands/search.d.ts +3 -8
  38. package/dist/commands/search.d.ts.map +1 -1
  39. package/dist/commands/search.js +4 -4
  40. package/dist/commands/search.js.map +1 -1
  41. package/dist/commands/version.d.ts +12 -0
  42. package/dist/commands/version.d.ts.map +1 -0
  43. package/dist/commands/version.js +179 -0
  44. package/dist/commands/version.js.map +1 -0
  45. package/dist/internal/oauthServer.d.ts +17 -5
  46. package/dist/internal/oauthServer.d.ts.map +1 -1
  47. package/dist/internal/oauthServer.js +23 -40
  48. package/dist/internal/oauthServer.js.map +1 -1
  49. package/dist/internal/openBrowser.d.ts +10 -0
  50. package/dist/internal/openBrowser.d.ts.map +1 -0
  51. package/dist/internal/openBrowser.js +17 -0
  52. package/dist/internal/openBrowser.js.map +1 -0
  53. package/package.json +10 -12
  54. package/skills/jira/SKILL.md +90 -0
  55. package/skills/jira/agents/openai.yaml +4 -0
  56. package/src/IssueService.ts +34 -28
  57. package/src/JiraAuth.ts +53 -39
  58. package/src/MarkdownWriter.ts +7 -11
  59. package/src/VersionService.ts +647 -0
  60. package/src/bin.ts +38 -26
  61. package/src/commands/auth.ts +6 -12
  62. package/src/commands/get.ts +2 -2
  63. package/src/commands/index.ts +1 -0
  64. package/src/commands/layers.ts +40 -25
  65. package/src/commands/search.ts +4 -4
  66. package/src/commands/version.ts +267 -0
  67. package/src/internal/oauthServer.ts +43 -70
  68. package/src/internal/openBrowser.ts +31 -0
  69. package/test/VersionService.test.ts +266 -0
  70. package/vitest.config.ts +5 -0
@@ -29,7 +29,7 @@ import { JiraApiError } from "./JiraCliError.js"
29
29
  *
30
30
  * @category Config
31
31
  */
32
- export class SiteUrl extends Context.Tag("@knpkv/jira-cli/SiteUrl")<SiteUrl, string>() {}
32
+ export class SiteUrl extends Context.Service<SiteUrl, string>()("@knpkv/jira-cli/SiteUrl") {}
33
33
 
34
34
  /**
35
35
  * Attachment metadata.
@@ -104,6 +104,12 @@ export interface SearchResult {
104
104
  readonly maxResults: number
105
105
  }
106
106
 
107
+ interface SearchJqlResponse {
108
+ readonly issues?: ReadonlyArray<unknown>
109
+ readonly isLast?: boolean
110
+ readonly nextPageToken?: string
111
+ }
112
+
107
113
  /**
108
114
  * IssueService interface.
109
115
  *
@@ -138,10 +144,10 @@ export interface IssueServiceShape {
138
144
  *
139
145
  * @category Services
140
146
  */
141
- export class IssueService extends Context.Tag("@knpkv/jira-cli/IssueService")<
147
+ export class IssueService extends Context.Service<
142
148
  IssueService,
143
149
  IssueServiceShape
144
- >() {}
150
+ >()("@knpkv/jira-cli/IssueService") {}
145
151
 
146
152
  const FIELDS = [
147
153
  "summary",
@@ -299,7 +305,7 @@ const make = Effect.gen(function*() {
299
305
  jql: string,
300
306
  maxResults: number,
301
307
  nextPageToken?: string
302
- ) =>
308
+ ): Effect.Effect<SearchJqlResponse, JiraApiError> =>
303
309
  toEffect(client.v3.client.GET("/rest/api/3/search/jql", {
304
310
  params: {
305
311
  query: {
@@ -311,6 +317,7 @@ const make = Effect.gen(function*() {
311
317
  }
312
318
  }
313
319
  })).pipe(
320
+ Effect.map((result) => result as SearchJqlResponse),
314
321
  Effect.mapError((cause) => new JiraApiError({ message: "Failed to search issues", cause }))
315
322
  )
316
323
 
@@ -336,36 +343,35 @@ const make = Effect.gen(function*() {
336
343
  const searchAll = (
337
344
  jql: string,
338
345
  options?: { readonly maxResults?: number }
339
- ): Effect.Effect<ReadonlyArray<Issue>, JiraApiError> =>
340
- Effect.gen(function*() {
341
- const allIssues: Array<Issue> = []
342
- const maxResults = options?.maxResults ?? 100
343
- let nextPageToken: string | undefined = undefined
344
- let pageCount = 0
345
-
346
- // Fetch first page
347
- let result = yield* searchJql(jql, maxResults, nextPageToken)
348
- let issues = result.issues ?? []
346
+ ): Effect.Effect<ReadonlyArray<Issue>, JiraApiError> => (Effect.gen(function*() {
347
+ const allIssues: Array<Issue> = []
348
+ const maxResults = options?.maxResults ?? 100
349
+ let nextPageToken: string | undefined = undefined
350
+ let pageCount = 0
351
+
352
+ // Fetch first page
353
+ let result = yield* searchJql(jql, maxResults, nextPageToken)
354
+ let issues = result.issues ?? []
355
+ pageCount++
356
+
357
+ for (const bean of issues) {
358
+ allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
359
+ }
360
+
361
+ // Fetch remaining pages with iteration guard
362
+ while (!result.isLast && result.nextPageToken && pageCount < MAX_PAGES) {
363
+ nextPageToken = result.nextPageToken
364
+ result = yield* searchJql(jql, maxResults, nextPageToken)
365
+ issues = result.issues ?? []
349
366
  pageCount++
350
367
 
351
368
  for (const bean of issues) {
352
369
  allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
353
370
  }
371
+ }
354
372
 
355
- // Fetch remaining pages with iteration guard
356
- while (!result.isLast && result.nextPageToken && pageCount < MAX_PAGES) {
357
- nextPageToken = result.nextPageToken
358
- result = yield* searchJql(jql, maxResults, nextPageToken)
359
- issues = result.issues ?? []
360
- pageCount++
361
-
362
- for (const bean of issues) {
363
- allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
364
- }
365
- }
366
-
367
- return allIssues
368
- })
373
+ return allIssues
374
+ }) as Effect.Effect<ReadonlyArray<Issue>, JiraApiError>)
369
375
 
370
376
  return IssueService.of({ getByKey, search, searchAll })
371
377
  })
package/src/JiraAuth.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * **Mental model**
5
5
  *
6
6
  * - **Service pattern**: {@link JiraAuth} is a `Context.Tag` whose layer requires `HttpClient`
7
- * and `CommandExecutor`. All token storage operations are pre-bound to
7
+ * and `ChildProcessSpawner`. All token storage operations are pre-bound to
8
8
  * `@knpkv/atlassian-common/config` with a `"jira-cli"` tool name.
9
9
  * - **Refresh lock**: A `Ref<Option<Deferred>>` prevents concurrent token refreshes — the
10
10
  * first caller refreshes, others await the same Deferred.
@@ -21,10 +21,6 @@
21
21
  */
22
22
  import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
23
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
24
  import {
29
25
  buildAuthUrl,
30
26
  buildOAuthToken,
@@ -58,19 +54,31 @@ import * as Deferred from "effect/Deferred"
58
54
  import * as Effect from "effect/Effect"
59
55
  import * as Layer from "effect/Layer"
60
56
  import * as Option from "effect/Option"
57
+ import type * as PlatformError from "effect/PlatformError"
61
58
  import * as Redacted from "effect/Redacted"
62
59
  import * as Ref from "effect/Ref"
60
+ import * as HttpClient from "effect/unstable/http/HttpClient"
61
+ import { ChildProcessSpawner } from "effect/unstable/process"
63
62
  import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
64
63
  import { startCallbackServer } from "./internal/oauthServer.js"
64
+ import { openBrowser } from "./internal/openBrowser.js"
65
65
  import type { AuthMissingError } from "./JiraCliError.js"
66
66
  import { authMissing } from "./JiraCliError.js"
67
67
 
68
- /** Scopes for Jira CLI (includes write:jira-work for worklog) */
68
+ /** OAuth scopes for the Jira CLI. Granular scopes must also be enabled on the app in the developer console. */
69
69
  const JIRA_CLI_SCOPES = [
70
+ // Read issues, search, and read versions.
70
71
  "read:jira-work",
72
+ // Edit issues and write worklogs.
71
73
  "write:jira-work",
74
+ // Edit a version (e.g. its description) via `PUT /rest/api/3/version/{id}` and
75
+ // manage version "Related work" links (`/rest/api/3/version/{id}/relatedwork`).
76
+ "manage:jira-project",
77
+ // Resolve account IDs to display names for Driver/Contributors/Approvers.
72
78
  "read:jira-user",
79
+ // Read the authenticated user's own profile (`/rest/api/3/myself`).
73
80
  "read:me",
81
+ // Issue a refresh token so the CLI stays logged in across runs.
74
82
  "offline_access"
75
83
  ]
76
84
 
@@ -121,40 +129,46 @@ export interface JiraAuthService {
121
129
  /** Configure OAuth client credentials */
122
130
  readonly configure: (
123
131
  config: OAuthConfig
124
- ) => Effect.Effect<void, FileSystemError | HomeDirectoryError | Error.PlatformError>
132
+ ) => Effect.Effect<void, FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
125
133
  /** Check if OAuth is configured */
126
- readonly isConfigured: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError>
134
+ readonly isConfigured: () => Effect.Effect<
135
+ boolean,
136
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
137
+ >
127
138
  /** Start OAuth login flow. Returns list of sites if multiple are available. */
128
139
  readonly login: (
129
140
  options?: LoginOptions
130
141
  ) => Effect.Effect<
131
142
  ReadonlyArray<AccessibleSite> | void,
132
- OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
143
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
133
144
  >
134
145
  /** Remove stored authentication */
135
- readonly logout: () => Effect.Effect<void, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError>
146
+ readonly logout: () => Effect.Effect<
147
+ void,
148
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
149
+ >
136
150
  /** Get access token, refreshing if needed */
137
151
  readonly getAccessToken: () => Effect.Effect<
138
152
  Redacted.Redacted<string>,
139
- AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
153
+ AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
140
154
  >
141
155
  /** Get cloud ID from stored token */
142
156
  readonly getCloudId: () => Effect.Effect<
143
157
  string,
144
- AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
158
+ AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
145
159
  >
146
160
  /** Get site URL from stored token */
147
161
  readonly getSiteUrl: () => Effect.Effect<
148
162
  string,
149
- AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
163
+ AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
150
164
  >
151
165
  /** Get current user info from stored token */
152
166
  readonly getCurrentUser: () => Effect.Effect<
153
167
  OAuthUser | null,
154
- FileSystemError | HomeDirectoryError | Error.PlatformError
168
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
155
169
  >
156
170
  /** Check if user is logged in */
157
- readonly isLoggedIn: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError>
171
+ readonly isLoggedIn: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
158
172
  }
159
173
 
160
174
  /**
@@ -176,37 +190,33 @@ export interface JiraAuthService {
176
190
  *
177
191
  * @category Services
178
192
  */
179
- export class JiraAuth extends Context.Tag("@knpkv/jira-cli/JiraAuth")<
193
+ export class JiraAuth extends Context.Service<
180
194
  JiraAuth,
181
195
  JiraAuthService
182
- >() {}
196
+ >()("@knpkv/jira-cli/JiraAuth") {}
183
197
 
184
198
  const make = Effect.gen(function*() {
185
199
  const httpClient = yield* HttpClient.HttpClient
186
- const commandExecutor = yield* CommandExecutor.CommandExecutor
200
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner
187
201
 
188
202
  // Ref to track ongoing refresh operation to prevent concurrent refreshes
189
203
  const refreshLock = yield* Ref.make<
190
204
  Option.Option<
191
- Deferred.Deferred<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError>
205
+ Deferred.Deferred<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
192
206
  >
193
207
  >(
194
208
  Option.none()
195
209
  )
196
210
 
197
211
  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,
212
+ openBrowser(url).pipe(
213
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
204
214
  Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
205
215
  )
206
216
 
207
217
  const getConfig = (): Effect.Effect<
208
218
  OAuthConfig,
209
- OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
219
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
210
220
  > =>
211
221
  Effect.gen(function*() {
212
222
  const config = yield* loadOAuthConfigOp()
@@ -224,7 +234,7 @@ const make = Effect.gen(function*() {
224
234
  const refreshTokenImpl = (
225
235
  token: OAuthToken,
226
236
  config: OAuthConfig
227
- ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError> =>
237
+ ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
228
238
  Effect.gen(function*() {
229
239
  const updated = yield* refreshToken(token, config).pipe(
230
240
  Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
@@ -243,9 +253,10 @@ const make = Effect.gen(function*() {
243
253
 
244
254
  const configure = (
245
255
  config: OAuthConfig
246
- ): Effect.Effect<void, FileSystemError | HomeDirectoryError | Error.PlatformError> => saveOAuthConfigOp(config)
256
+ ): Effect.Effect<void, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
257
+ saveOAuthConfigOp(config)
247
258
 
248
- const isConfigured = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError> =>
259
+ const isConfigured = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
249
260
  Effect.gen(function*() {
250
261
  const config = yield* loadOAuthConfigOp()
251
262
  return config !== null
@@ -255,7 +266,7 @@ const make = Effect.gen(function*() {
255
266
  options?: LoginOptions
256
267
  ): Effect.Effect<
257
268
  ReadonlyArray<AccessibleSite> | void,
258
- OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
269
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
259
270
  > =>
260
271
  Effect.gen(function*() {
261
272
  const config = yield* getConfig()
@@ -276,7 +287,7 @@ const make = Effect.gen(function*() {
276
287
  const code = yield* codePromise.pipe(
277
288
  Effect.timeout("5 minutes"),
278
289
  Effect.catchTag(
279
- "TimeoutException",
290
+ "TimeoutError",
280
291
  () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
281
292
  ),
282
293
  Effect.ensuring(shutdown)
@@ -340,7 +351,10 @@ const make = Effect.gen(function*() {
340
351
  return undefined
341
352
  })
342
353
 
343
- const logout = (): Effect.Effect<void, OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError> =>
354
+ const logout = (): Effect.Effect<
355
+ void,
356
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
357
+ > =>
344
358
  Effect.gen(function*() {
345
359
  const token = yield* loadTokenOp()
346
360
  if (token === null) {
@@ -352,7 +366,7 @@ const make = Effect.gen(function*() {
352
366
  if (config !== null) {
353
367
  yield* revokeTokenImpl(token, config).pipe(
354
368
  Effect.tap(() => Effect.log("Token revoked with Atlassian")),
355
- Effect.catchAll((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
369
+ Effect.catch((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
356
370
  )
357
371
  }
358
372
 
@@ -361,7 +375,7 @@ const make = Effect.gen(function*() {
361
375
 
362
376
  const getAccessToken = (): Effect.Effect<
363
377
  Redacted.Redacted<string>,
364
- AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
378
+ AuthMissingError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
365
379
  > =>
366
380
  Effect.gen(function*() {
367
381
  const token = yield* loadTokenOp()
@@ -376,7 +390,7 @@ const make = Effect.gen(function*() {
376
390
  // Atomically check-then-set refresh lock to avoid TOCTOU race
377
391
  const deferred = yield* Deferred.make<
378
392
  OAuthToken,
379
- OAuthError | FileSystemError | HomeDirectoryError | Error.PlatformError
393
+ OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
380
394
  >()
381
395
  const existing = yield* Ref.modify(refreshLock, (current) =>
382
396
  Option.isSome(current)
@@ -418,7 +432,7 @@ const make = Effect.gen(function*() {
418
432
 
419
433
  const getCloudId = (): Effect.Effect<
420
434
  string,
421
- AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
435
+ AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
422
436
  > =>
423
437
  Effect.gen(function*() {
424
438
  const token = yield* loadTokenOp()
@@ -430,7 +444,7 @@ const make = Effect.gen(function*() {
430
444
 
431
445
  const getSiteUrl = (): Effect.Effect<
432
446
  string,
433
- AuthMissingError | FileSystemError | HomeDirectoryError | Error.PlatformError
447
+ AuthMissingError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
434
448
  > =>
435
449
  Effect.gen(function*() {
436
450
  const token = yield* loadTokenOp()
@@ -442,14 +456,14 @@ const make = Effect.gen(function*() {
442
456
 
443
457
  const getCurrentUser = (): Effect.Effect<
444
458
  OAuthUser | null,
445
- FileSystemError | HomeDirectoryError | Error.PlatformError
459
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
446
460
  > =>
447
461
  Effect.gen(function*() {
448
462
  const token = yield* loadTokenOp()
449
463
  return token?.user ?? null
450
464
  })
451
465
 
452
- const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | Error.PlatformError> =>
466
+ const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
453
467
  Effect.gen(function*() {
454
468
  const token = yield* loadTokenOp()
455
469
  return token !== null
@@ -5,15 +5,15 @@
5
5
  *
6
6
  * - **Two output modes**: {@link MarkdownWriterShape.writeMulti} creates one `.md` per issue;
7
7
  * {@link MarkdownWriterShape.writeSingle} combines all into `jira-export.md`.
8
- * - **Effect service**: Requires `FileSystem` and `Path` from `@effect/platform`.
8
+ * - **Effect service**: Requires `FileSystem` and `Path` from `effect`.
9
9
  *
10
10
  * @module
11
11
  */
12
- import * as FileSystem from "@effect/platform/FileSystem"
13
- import * as Path from "@effect/platform/Path"
14
12
  import * as Context from "effect/Context"
15
13
  import * as Effect from "effect/Effect"
14
+ import * as FileSystem from "effect/FileSystem"
16
15
  import * as Layer from "effect/Layer"
16
+ import * as Path from "effect/Path"
17
17
  import { buildCombinedMarkdown, serializeIssue } from "./internal/frontmatter.js"
18
18
  import type { Issue } from "./IssueService.js"
19
19
  import { WriteError } from "./JiraCliError.js"
@@ -53,10 +53,10 @@ export interface MarkdownWriterShape {
53
53
  *
54
54
  * @category Services
55
55
  */
56
- export class MarkdownWriter extends Context.Tag("@knpkv/jira-cli/MarkdownWriter")<
56
+ export class MarkdownWriter extends Context.Service<
57
57
  MarkdownWriter,
58
58
  MarkdownWriterShape
59
- >() {}
59
+ >()("@knpkv/jira-cli/MarkdownWriter") {}
60
60
 
61
61
  const make = Effect.gen(function*() {
62
62
  const fs = yield* FileSystem.FileSystem
@@ -64,16 +64,12 @@ const make = Effect.gen(function*() {
64
64
 
65
65
  const ensureDir = (dir: string): Effect.Effect<void, WriteError> =>
66
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
- )
67
+ Effect.catch((cause) => Effect.fail(new WriteError({ path: dir, message: "Failed to create directory", cause })))
70
68
  )
71
69
 
72
70
  const writeFile = (filePath: string, content: string): Effect.Effect<void, WriteError> =>
73
71
  fs.writeFileString(filePath, content).pipe(
74
- Effect.catchAll((cause) =>
75
- Effect.fail(new WriteError({ path: filePath, message: "Failed to write file", cause }))
76
- )
72
+ Effect.catch((cause) => Effect.fail(new WriteError({ path: filePath, message: "Failed to write file", cause })))
77
73
  )
78
74
 
79
75
  const writeMulti: MarkdownWriterShape["writeMulti"] = (issues, outputDir) =>