@knpkv/jira-cli 0.1.1 → 1.0.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 (143) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +66 -4
  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/JiraCliError.d.ts +30 -0
  12. package/dist/JiraCliError.d.ts.map +1 -1
  13. package/dist/JiraCliError.js +14 -0
  14. package/dist/JiraCliError.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +6 -6
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +34 -0
  20. package/dist/SyncWorkspace.d.ts.map +1 -0
  21. package/dist/SyncWorkspace.js +105 -0
  22. package/dist/SyncWorkspace.js.map +1 -0
  23. package/dist/VersionService.d.ts +206 -0
  24. package/dist/VersionService.d.ts.map +1 -0
  25. package/dist/VersionService.js +426 -0
  26. package/dist/VersionService.js.map +1 -0
  27. package/dist/bin.js +29 -22
  28. package/dist/bin.js.map +1 -1
  29. package/dist/commands/auth.d.ts +2 -21
  30. package/dist/commands/auth.d.ts.map +1 -1
  31. package/dist/commands/auth.js +6 -6
  32. package/dist/commands/auth.js.map +1 -1
  33. package/dist/commands/get.d.ts +3 -8
  34. package/dist/commands/get.d.ts.map +1 -1
  35. package/dist/commands/get.js +4 -4
  36. package/dist/commands/get.js.map +1 -1
  37. package/dist/commands/index.d.ts +2 -2
  38. package/dist/commands/index.d.ts.map +1 -1
  39. package/dist/commands/index.js +2 -2
  40. package/dist/commands/index.js.map +1 -1
  41. package/dist/commands/issue.d.ts +8 -0
  42. package/dist/commands/issue.d.ts.map +1 -0
  43. package/dist/commands/issue.js +10 -0
  44. package/dist/commands/issue.js.map +1 -0
  45. package/dist/commands/layers.d.ts +6 -18
  46. package/dist/commands/layers.d.ts.map +1 -1
  47. package/dist/commands/layers.js +35 -24
  48. package/dist/commands/layers.js.map +1 -1
  49. package/dist/commands/search.d.ts +3 -8
  50. package/dist/commands/search.d.ts.map +1 -1
  51. package/dist/commands/search.js +8 -8
  52. package/dist/commands/search.js.map +1 -1
  53. package/dist/commands/version.d.ts +12 -0
  54. package/dist/commands/version.d.ts.map +1 -0
  55. package/dist/commands/version.js +179 -0
  56. package/dist/commands/version.js.map +1 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -1
  58. package/dist/internal/frontmatter.js +14 -1
  59. package/dist/internal/frontmatter.js.map +1 -1
  60. package/dist/internal/oauthServer.d.ts +17 -5
  61. package/dist/internal/oauthServer.d.ts.map +1 -1
  62. package/dist/internal/oauthServer.js +23 -40
  63. package/dist/internal/oauthServer.js.map +1 -1
  64. package/dist/internal/openBrowser.d.ts +10 -0
  65. package/dist/internal/openBrowser.d.ts.map +1 -0
  66. package/dist/internal/openBrowser.js +17 -0
  67. package/dist/internal/openBrowser.js.map +1 -0
  68. package/dist/internal/sync/baseline.d.ts +11 -0
  69. package/dist/internal/sync/baseline.d.ts.map +1 -0
  70. package/dist/internal/sync/baseline.js +18 -0
  71. package/dist/internal/sync/baseline.js.map +1 -0
  72. package/dist/internal/sync/changes.d.ts +15 -0
  73. package/dist/internal/sync/changes.d.ts.map +1 -0
  74. package/dist/internal/sync/changes.js +72 -0
  75. package/dist/internal/sync/changes.js.map +1 -0
  76. package/dist/internal/sync/config.d.ts +12 -0
  77. package/dist/internal/sync/config.d.ts.map +1 -0
  78. package/dist/internal/sync/config.js +53 -0
  79. package/dist/internal/sync/config.js.map +1 -0
  80. package/dist/internal/sync/document.d.ts +9 -0
  81. package/dist/internal/sync/document.d.ts.map +1 -0
  82. package/dist/internal/sync/document.js +173 -0
  83. package/dist/internal/sync/document.js.map +1 -0
  84. package/dist/internal/sync/fieldValues.d.ts +30 -0
  85. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  86. package/dist/internal/sync/fieldValues.js +91 -0
  87. package/dist/internal/sync/fieldValues.js.map +1 -0
  88. package/dist/internal/sync/manifest.d.ts +12 -0
  89. package/dist/internal/sync/manifest.d.ts.map +1 -0
  90. package/dist/internal/sync/manifest.js +23 -0
  91. package/dist/internal/sync/manifest.js.map +1 -0
  92. package/dist/internal/sync/paths.d.ts +26 -0
  93. package/dist/internal/sync/paths.d.ts.map +1 -0
  94. package/dist/internal/sync/paths.js +22 -0
  95. package/dist/internal/sync/paths.js.map +1 -0
  96. package/dist/internal/sync/schemas.d.ts +128 -0
  97. package/dist/internal/sync/schemas.d.ts.map +1 -0
  98. package/dist/internal/sync/schemas.js +82 -0
  99. package/dist/internal/sync/schemas.js.map +1 -0
  100. package/dist/internal/sync/types.d.ts +144 -0
  101. package/dist/internal/sync/types.d.ts.map +1 -0
  102. package/dist/internal/sync/types.js +17 -0
  103. package/dist/internal/sync/types.js.map +1 -0
  104. package/package.json +13 -12
  105. package/skills/jira/SKILL.md +90 -0
  106. package/skills/jira/agents/openai.yaml +4 -0
  107. package/src/IssueService.ts +34 -28
  108. package/src/JiraAuth.ts +53 -39
  109. package/src/JiraCliError.ts +24 -0
  110. package/src/MarkdownWriter.ts +7 -11
  111. package/src/SyncWorkspace.ts +185 -0
  112. package/src/VersionService.ts +647 -0
  113. package/src/bin.ts +39 -29
  114. package/src/commands/auth.ts +6 -12
  115. package/src/commands/get.ts +4 -4
  116. package/src/commands/index.ts +2 -2
  117. package/src/commands/issue.ts +13 -0
  118. package/src/commands/layers.ts +44 -26
  119. package/src/commands/search.ts +8 -8
  120. package/src/commands/version.ts +267 -0
  121. package/src/internal/frontmatter.ts +15 -1
  122. package/src/internal/oauthServer.ts +43 -70
  123. package/src/internal/openBrowser.ts +31 -0
  124. package/src/internal/sync/baseline.ts +27 -0
  125. package/src/internal/sync/changes.ts +118 -0
  126. package/src/internal/sync/config.ts +76 -0
  127. package/src/internal/sync/document.ts +201 -0
  128. package/src/internal/sync/fieldValues.ts +145 -0
  129. package/src/internal/sync/manifest.ts +32 -0
  130. package/src/internal/sync/paths.ts +48 -0
  131. package/src/internal/sync/schemas.ts +103 -0
  132. package/src/internal/sync/types.ts +192 -0
  133. package/test/SyncWorkspace.test.ts +76 -0
  134. package/test/VersionService.test.ts +266 -0
  135. package/test/commandTree.test.ts +266 -0
  136. package/test/frontmatter.test.ts +69 -0
  137. package/test/integration.test.ts +187 -0
  138. package/test/syncChanges.test.ts +106 -0
  139. package/test/syncConfig.test.ts +138 -0
  140. package/test/syncDocument.test.ts +69 -0
  141. package/test/syncFieldValues.test.ts +101 -0
  142. package/vitest.config.integration.ts +17 -0
  143. package/vitest.config.ts +6 -0
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
@@ -42,3 +42,27 @@ export class WriteError extends Data.TaggedError("WriteError")<{
42
42
  readonly message: string
43
43
  readonly cause?: unknown
44
44
  }> {}
45
+
46
+ /**
47
+ * Error when reading or writing Jira Markdown Sync workspace state.
48
+ *
49
+ * @category Errors
50
+ */
51
+ export class SyncWorkspaceError extends Data.TaggedError("SyncWorkspaceError")<{
52
+ readonly message: string
53
+ readonly path?: string | undefined
54
+ readonly cause?: unknown
55
+ }> {}
56
+
57
+ /**
58
+ * Error when local Jira Markdown Sync configuration or data fails validation.
59
+ *
60
+ * @category Errors
61
+ */
62
+ export class SyncValidationError extends Data.TaggedError("SyncValidationError")<{
63
+ readonly message: string
64
+ readonly issueKey?: string | undefined
65
+ readonly field?: string | undefined
66
+ readonly path?: string | undefined
67
+ readonly cause?: unknown
68
+ }> {}
@@ -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) =>
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Local workspace I/O for Jira Markdown Sync.
3
+ *
4
+ * @module
5
+ */
6
+ import * as Context from "effect/Context"
7
+ import * as Effect from "effect/Effect"
8
+ import * as FileSystem from "effect/FileSystem"
9
+ import * as Layer from "effect/Layer"
10
+ import * as Path from "effect/Path"
11
+ import { parseSyncBaseline, serializeSyncBaseline } from "./internal/sync/baseline.js"
12
+ import { makeDefaultWorkspaceConfig, parseWorkspaceConfig, serializeWorkspaceConfig } from "./internal/sync/config.js"
13
+ import { makeEmptyManifest, parseSyncManifest, serializeSyncManifest } from "./internal/sync/manifest.js"
14
+ import {
15
+ baselineFilePath,
16
+ conventionDocumentPath,
17
+ resolveWorkspacePaths,
18
+ type SyncWorkspacePaths
19
+ } from "./internal/sync/paths.js"
20
+ import type { SyncBaseline, SyncManifest, WorkspaceConfig } from "./internal/sync/types.js"
21
+ import { SyncWorkspaceError } from "./JiraCliError.js"
22
+
23
+ export interface InitWorkspaceInput {
24
+ readonly root: string
25
+ readonly siteUrl: string
26
+ }
27
+
28
+ export interface SyncWorkspaceShape {
29
+ readonly init: (input: InitWorkspaceInput) => Effect.Effect<SyncWorkspacePaths, SyncWorkspaceError>
30
+ readonly paths: (root: string, config?: Pick<WorkspaceConfig, "documentsDir">) => Effect.Effect<SyncWorkspacePaths>
31
+ readonly readConfig: (root: string) => Effect.Effect<WorkspaceConfig, SyncWorkspaceError>
32
+ readonly writeConfig: (root: string, config: WorkspaceConfig) => Effect.Effect<void, SyncWorkspaceError>
33
+ readonly readManifest: (root: string) => Effect.Effect<SyncManifest, SyncWorkspaceError>
34
+ readonly writeManifest: (root: string, manifest: SyncManifest) => Effect.Effect<void, SyncWorkspaceError>
35
+ readonly readBaseline: (root: string, issueId: string) => Effect.Effect<SyncBaseline, SyncWorkspaceError>
36
+ readonly writeBaseline: (
37
+ root: string,
38
+ baseline: SyncBaseline
39
+ ) => Effect.Effect<void, SyncWorkspaceError>
40
+ readonly conventionDocumentPath: (root: string, issueKey: string) => Effect.Effect<string, SyncWorkspaceError>
41
+ }
42
+
43
+ export class SyncWorkspace extends Context.Service<
44
+ SyncWorkspace,
45
+ SyncWorkspaceShape
46
+ >()("@knpkv/jira-cli/SyncWorkspace") {}
47
+
48
+ const mapWorkspaceError = (message: string, path?: string | undefined) => (cause: unknown) =>
49
+ new SyncWorkspaceError({ message, path, cause })
50
+
51
+ const make = Effect.gen(function*() {
52
+ const fs = yield* FileSystem.FileSystem
53
+ const path = yield* Path.Path
54
+
55
+ const paths: SyncWorkspaceShape["paths"] = (root, config) => Effect.succeed(resolveWorkspacePaths(path, root, config))
56
+
57
+ const ensureDir = (dir: string): Effect.Effect<void, SyncWorkspaceError> =>
58
+ fs.makeDirectory(dir, { recursive: true }).pipe(
59
+ Effect.mapError(mapWorkspaceError("Failed to create sync workspace directory", dir)),
60
+ Effect.asVoid
61
+ )
62
+
63
+ const writeFile = (filePath: string, content: string): Effect.Effect<void, SyncWorkspaceError> =>
64
+ Effect.gen(function*() {
65
+ const dir = path.dirname(filePath)
66
+ yield* ensureDir(dir)
67
+ const tempPath = `${filePath}.tmp.${Date.now()}`
68
+ yield* fs.writeFileString(tempPath, content).pipe(
69
+ Effect.mapError(mapWorkspaceError("Failed to write sync workspace file", filePath))
70
+ )
71
+ yield* fs.rename(tempPath, filePath).pipe(
72
+ Effect.mapError(mapWorkspaceError("Failed to replace sync workspace file", filePath))
73
+ )
74
+ })
75
+
76
+ const readFile = (filePath: string): Effect.Effect<string, SyncWorkspaceError> =>
77
+ fs.readFileString(filePath).pipe(
78
+ Effect.mapError(mapWorkspaceError("Failed to read sync workspace file", filePath))
79
+ )
80
+
81
+ const init: SyncWorkspaceShape["init"] = ({ root, siteUrl }) =>
82
+ Effect.gen(function*() {
83
+ const config = makeDefaultWorkspaceConfig(siteUrl)
84
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
85
+ yield* ensureDir(workspacePaths.documentsDir)
86
+ yield* ensureDir(workspacePaths.metadataDir)
87
+ yield* ensureDir(workspacePaths.baselinesDir)
88
+ yield* ensureDir(workspacePaths.historyDir)
89
+ yield* writeFile(workspacePaths.configFile, serializeWorkspaceConfig(config))
90
+ yield* writeFile(workspacePaths.manifestFile, serializeSyncManifest(makeEmptyManifest(siteUrl)))
91
+ return workspacePaths
92
+ })
93
+
94
+ const readConfig: SyncWorkspaceShape["readConfig"] = (root) =>
95
+ Effect.gen(function*() {
96
+ const workspacePaths = resolveWorkspacePaths(path, root)
97
+ const content = yield* readFile(workspacePaths.configFile)
98
+ return yield* parseWorkspaceConfig(workspacePaths.configFile, content).pipe(
99
+ Effect.mapError((cause) =>
100
+ new SyncWorkspaceError({
101
+ message: cause.message,
102
+ path: "path" in cause ? cause.path : workspacePaths.configFile,
103
+ cause
104
+ })
105
+ )
106
+ )
107
+ })
108
+
109
+ const writeConfig: SyncWorkspaceShape["writeConfig"] = (root, config) =>
110
+ Effect.gen(function*() {
111
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
112
+ yield* ensureDir(workspacePaths.metadataDir)
113
+ yield* writeFile(workspacePaths.configFile, serializeWorkspaceConfig(config))
114
+ })
115
+
116
+ const readManifest: SyncWorkspaceShape["readManifest"] = (root) =>
117
+ Effect.gen(function*() {
118
+ const workspacePaths = resolveWorkspacePaths(path, root)
119
+ const content = yield* readFile(workspacePaths.manifestFile)
120
+ return yield* parseSyncManifest(workspacePaths.manifestFile, content).pipe(
121
+ Effect.mapError((cause) =>
122
+ new SyncWorkspaceError({
123
+ message: cause.message,
124
+ path: "path" in cause ? cause.path : workspacePaths.manifestFile,
125
+ cause
126
+ })
127
+ )
128
+ )
129
+ })
130
+
131
+ const writeManifest: SyncWorkspaceShape["writeManifest"] = (root, manifest) =>
132
+ Effect.gen(function*() {
133
+ const workspacePaths = resolveWorkspacePaths(path, root)
134
+ yield* ensureDir(workspacePaths.metadataDir)
135
+ yield* writeFile(workspacePaths.manifestFile, serializeSyncManifest(manifest))
136
+ })
137
+
138
+ const readBaseline: SyncWorkspaceShape["readBaseline"] = (root, issueId) =>
139
+ Effect.gen(function*() {
140
+ const workspacePaths = resolveWorkspacePaths(path, root)
141
+ const filePath = baselineFilePath(path, workspacePaths, issueId)
142
+ const content = yield* readFile(filePath)
143
+ return yield* parseSyncBaseline(filePath, content).pipe(
144
+ Effect.mapError((cause) =>
145
+ new SyncWorkspaceError({
146
+ message: cause.message,
147
+ path: "path" in cause ? cause.path : filePath,
148
+ cause
149
+ })
150
+ )
151
+ )
152
+ })
153
+
154
+ const writeBaseline: SyncWorkspaceShape["writeBaseline"] = (root, baseline) =>
155
+ Effect.gen(function*() {
156
+ const workspacePaths = resolveWorkspacePaths(path, root)
157
+ const filePath = baselineFilePath(path, workspacePaths, baseline.issueId)
158
+ yield* ensureDir(workspacePaths.baselinesDir)
159
+ yield* writeFile(filePath, serializeSyncBaseline(baseline))
160
+ })
161
+
162
+ const getConventionDocumentPath: SyncWorkspaceShape["conventionDocumentPath"] = (root, issueKey) =>
163
+ Effect.gen(function*() {
164
+ const config = yield* readConfig(root)
165
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
166
+ return conventionDocumentPath(path, workspacePaths, issueKey)
167
+ })
168
+
169
+ return SyncWorkspace.of({
170
+ init,
171
+ paths,
172
+ readConfig,
173
+ writeConfig,
174
+ readManifest,
175
+ writeManifest,
176
+ readBaseline,
177
+ writeBaseline,
178
+ conventionDocumentPath: getConventionDocumentPath
179
+ })
180
+ })
181
+
182
+ export const layer: Layer.Layer<SyncWorkspace, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
183
+ SyncWorkspace,
184
+ make
185
+ )