@knpkv/jira-cli 1.2.3 → 1.3.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 (120) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +64 -0
  3. package/dist/AttachmentService.d.ts +2 -2
  4. package/dist/AttachmentService.d.ts.map +1 -1
  5. package/dist/AttachmentService.js +1 -1
  6. package/dist/AttachmentService.js.map +1 -1
  7. package/dist/IssueService.d.ts +82 -3
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +111 -16
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +1 -1
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +74 -10
  14. package/dist/JiraAuth.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 +2 -2
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +2 -2
  20. package/dist/SyncWorkspace.d.ts.map +1 -1
  21. package/dist/SyncWorkspace.js.map +1 -1
  22. package/dist/VersionService.d.ts +81 -9
  23. package/dist/VersionService.d.ts.map +1 -1
  24. package/dist/VersionService.js +113 -17
  25. package/dist/VersionService.js.map +1 -1
  26. package/dist/commands/auth.d.ts +3 -2
  27. package/dist/commands/auth.d.ts.map +1 -1
  28. package/dist/commands/edit.d.ts +13 -0
  29. package/dist/commands/edit.d.ts.map +1 -0
  30. package/dist/commands/edit.js +49 -0
  31. package/dist/commands/edit.js.map +1 -0
  32. package/dist/commands/errorHandler.d.ts.map +1 -1
  33. package/dist/commands/issue.d.ts +1 -1
  34. package/dist/commands/issue.d.ts.map +1 -1
  35. package/dist/commands/issue.js +2 -1
  36. package/dist/commands/issue.js.map +1 -1
  37. package/dist/commands/layers.d.ts +3 -3
  38. package/dist/commands/layers.d.ts.map +1 -1
  39. package/dist/commands/layers.js +5 -2
  40. package/dist/commands/layers.js.map +1 -1
  41. package/dist/commands/version.d.ts +8 -1
  42. package/dist/commands/version.d.ts.map +1 -1
  43. package/dist/commands/version.js +116 -4
  44. package/dist/commands/version.js.map +1 -1
  45. package/dist/index.d.ts +4 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/NodeLayers.js +1 -1
  50. package/dist/internal/NodeLayers.js.map +1 -1
  51. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  52. package/dist/internal/frontmatter.d.ts.map +1 -1
  53. package/dist/internal/frontmatter.js +2 -1
  54. package/dist/internal/frontmatter.js.map +1 -1
  55. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  56. package/dist/internal/oauthServer.d.ts +8 -2
  57. package/dist/internal/oauthServer.d.ts.map +1 -1
  58. package/dist/internal/oauthServer.js +10 -6
  59. package/dist/internal/oauthServer.js.map +1 -1
  60. package/dist/internal/openBrowser.d.ts.map +1 -1
  61. package/dist/internal/sync/baseline.d.ts.map +1 -1
  62. package/dist/internal/sync/baseline.js +2 -2
  63. package/dist/internal/sync/baseline.js.map +1 -1
  64. package/dist/internal/sync/changes.d.ts.map +1 -1
  65. package/dist/internal/sync/config.d.ts.map +1 -1
  66. package/dist/internal/sync/config.js +1 -1
  67. package/dist/internal/sync/config.js.map +1 -1
  68. package/dist/internal/sync/document.d.ts.map +1 -1
  69. package/dist/internal/sync/document.js +8 -8
  70. package/dist/internal/sync/document.js.map +1 -1
  71. package/dist/internal/sync/fieldValues.d.ts +9 -9
  72. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  73. package/dist/internal/sync/fieldValues.js +12 -16
  74. package/dist/internal/sync/fieldValues.js.map +1 -1
  75. package/dist/internal/sync/manifest.d.ts.map +1 -1
  76. package/dist/internal/sync/manifest.js +1 -1
  77. package/dist/internal/sync/manifest.js.map +1 -1
  78. package/dist/internal/sync/paths.d.ts.map +1 -1
  79. package/dist/internal/sync/schemas.d.ts +17 -9
  80. package/dist/internal/sync/schemas.d.ts.map +1 -1
  81. package/dist/internal/sync/schemas.js +6 -6
  82. package/dist/internal/sync/schemas.js.map +1 -1
  83. package/dist/internal/sync/types.d.ts +4 -4
  84. package/dist/internal/sync/types.d.ts.map +1 -1
  85. package/dist/internal/sync/types.js +1 -1
  86. package/dist/internal/sync/types.js.map +1 -1
  87. package/package.json +14 -10
  88. package/skills/jira/SKILL.md +17 -1
  89. package/src/AttachmentService.ts +6 -6
  90. package/src/IssueService.ts +214 -27
  91. package/src/JiraAuth.ts +86 -14
  92. package/src/MarkdownWriter.ts +6 -6
  93. package/src/SyncWorkspace.ts +12 -11
  94. package/src/VersionService.ts +199 -27
  95. package/src/commands/edit.ts +88 -0
  96. package/src/commands/issue.ts +2 -1
  97. package/src/commands/layers.ts +5 -2
  98. package/src/commands/version.ts +161 -5
  99. package/src/index.ts +13 -3
  100. package/src/internal/NodeLayers.ts +1 -1
  101. package/src/internal/frontmatter.ts +3 -2
  102. package/src/internal/oauthServer.ts +25 -13
  103. package/src/internal/sync/baseline.ts +3 -2
  104. package/src/internal/sync/config.ts +2 -1
  105. package/src/internal/sync/document.ts +15 -14
  106. package/src/internal/sync/fieldValues.ts +21 -22
  107. package/src/internal/sync/manifest.ts +1 -1
  108. package/src/internal/sync/schemas.ts +6 -6
  109. package/src/internal/sync/types.ts +4 -4
  110. package/test/JiraAuthRefresh.test.ts +269 -0
  111. package/test/commandTree.test.ts +157 -1
  112. package/test/editIssuePayload.test.ts +103 -0
  113. package/test/frontmatter.test.ts +4 -2
  114. package/test/integration.test.ts +5 -3
  115. package/test/oauthServer.test.ts +75 -3
  116. package/test/publicApi.test.ts +34 -0
  117. package/test/relatedWorkSync.test.ts +179 -0
  118. package/test/remoteWrites.test.ts +220 -0
  119. package/test/syncChanges.test.ts +2 -2
  120. package/test/syncConfig.test.ts +2 -2
@@ -12,7 +12,7 @@ import * as Option from "effect/Option"
12
12
  import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
13
13
  import { JiraApiError } from "../JiraCliError.js"
14
14
  import type { Person, Version } from "../VersionService.js"
15
- import { VersionService } from "../VersionService.js"
15
+ import { planRelatedWorkSync, VersionService } from "../VersionService.js"
16
16
 
17
17
  /**
18
18
  * Return a copy of `version` with every resolved {@link Person.emailAddress}
@@ -105,7 +105,7 @@ const listCommand = Command.make("list", {
105
105
  const versions = yield* service.listProjectVersions(project, {
106
106
  released,
107
107
  unreleased,
108
- ...(Option.isSome(max) ? { maxResults: max.value } : {}),
108
+ ...((Option.isSome(max)) && { maxResults: max.value }),
109
109
  customFieldNames: customFields
110
110
  })
111
111
  if (json) {
@@ -174,6 +174,80 @@ const descriptionOption = Options.string("description").pipe(
174
174
  Options.withDescription("New version description")
175
175
  )
176
176
 
177
+ // === create ===
178
+
179
+ /**
180
+ * Jira accepts version dates only as ISO 8601 `yyyy-mm-dd`. Anything else — a
181
+ * locale format, a timestamp — comes back as a generic 400 that does not name the
182
+ * offending field, so reject it locally where the message can.
183
+ */
184
+ export const isIsoDate = (value: string): boolean => {
185
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
186
+ // Reject real-looking but non-existent dates (2026-02-30): round-tripping
187
+ // through Date is the cheapest calendar check.
188
+ const parsed = new Date(`${value}T00:00:00Z`)
189
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
190
+ }
191
+
192
+ const ensureIsoDate = (flag: string, value: string): Effect.Effect<void, JiraApiError> =>
193
+ isIsoDate(value) ? Effect.void : Effect.fail(
194
+ new JiraApiError({ message: `Invalid --${flag} "${value}". Expected an ISO 8601 date (yyyy-mm-dd).` })
195
+ )
196
+
197
+ // No `-n` alias: it means `--dry-run` everywhere else in these CLIs, and both
198
+ // `getLayerType` implementations route on `argv.includes("-n")`. Reusing it for
199
+ // a value on a remote-write command is the wrong default.
200
+ const nameOption = Options.string("name").pipe(
201
+ Options.withDescription("Version name (e.g. \"OOB 100\")")
202
+ )
203
+ const optionalDescriptionOption = Options.string("description").pipe(
204
+ Options.withAlias("d"),
205
+ Options.withDescription("Version description"),
206
+ Options.optional
207
+ )
208
+ const startDateOption = Options.string("start-date").pipe(
209
+ Options.withDescription("Start date, ISO 8601 (yyyy-mm-dd)"),
210
+ Options.optional
211
+ )
212
+ const releaseDateOption = Options.string("release-date").pipe(
213
+ Options.withDescription("Release date, ISO 8601 (yyyy-mm-dd)"),
214
+ Options.optional
215
+ )
216
+
217
+ const createCommand = Command.make("create", {
218
+ project: projectOption,
219
+ name: nameOption,
220
+ description: optionalDescriptionOption,
221
+ startDate: startDateOption,
222
+ releaseDate: releaseDateOption,
223
+ json: jsonOption
224
+ }, ({ description, json, name, project, releaseDate, startDate }) =>
225
+ Effect.gen(function*() {
226
+ if (Option.isSome(startDate)) yield* ensureIsoDate("start-date", startDate.value)
227
+ if (Option.isSome(releaseDate)) yield* ensureIsoDate("release-date", releaseDate.value)
228
+
229
+ const service = yield* VersionService
230
+ const version = yield* service.createVersion({
231
+ projectKey: project,
232
+ name,
233
+ ...((Option.isSome(description)) && { description: description.value }),
234
+ ...((Option.isSome(startDate)) && { startDate: startDate.value }),
235
+ ...((Option.isSome(releaseDate)) && { releaseDate: releaseDate.value })
236
+ })
237
+ if (json) {
238
+ yield* Console.log(JSON.stringify(version, null, 2))
239
+ return
240
+ }
241
+ yield* Console.log(`Created version ${version.name} (${version.id}) on ${project}`)
242
+ yield* Console.log(`startDate: ${version.startDate ?? "-"}`)
243
+ yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
244
+ yield* Console.log(`description: ${version.description ?? "-"}`)
245
+ })).pipe(
246
+ Command.withDescription(
247
+ "Remote write: create a new unreleased version on a project (requires manage:jira-project scope)"
248
+ )
249
+ )
250
+
177
251
  const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
178
252
  description,
179
253
  id,
@@ -256,12 +330,94 @@ const relatedWorkAddCommand = Command.make("add", {
256
330
  )
257
331
  )
258
332
 
333
+ const linkOption = Options.string("link").pipe(
334
+ Options.withAlias("l"),
335
+ Options.withDescription("Desired link as `title=url` (repeatable). Category comes from --category."),
336
+ Options.atLeast(0)
337
+ )
338
+
339
+ const pruneOption = Options.boolean("prune").pipe(
340
+ Options.withDescription("Also remove links in the category that are not in the desired set")
341
+ )
342
+
343
+ /**
344
+ * Reconcile a version's related-work links against a desired set.
345
+ *
346
+ * Re-running a release scaffold should not pile up duplicate "Release notes"
347
+ * links, which is what repeated `add` calls produce. Matching is by URL, the
348
+ * only stable identity a link has — Jira assigns the id and the title is
349
+ * editable.
350
+ */
351
+ const relatedWorkSyncCommand = Command.make("sync", {
352
+ id: idArg,
353
+ link: linkOption,
354
+ category: categoryOption,
355
+ prune: pruneOption,
356
+ json: jsonOption
357
+ }, ({ category, id, json, link, prune }) =>
358
+ Effect.gen(function*() {
359
+ yield* ensureNumericId(id)
360
+
361
+ const desired: Array<{ readonly title: string; readonly url: string }> = []
362
+ for (const raw of link) {
363
+ const separator = raw.indexOf("=")
364
+ // Check the trimmed halves, not the separator position: `" =url"` and
365
+ // `"title= "` both put a non-empty span either side of the `=` and would
366
+ // otherwise reach Jira as a link with an empty title or url.
367
+ const title = separator < 0 ? "" : raw.slice(0, separator).trim()
368
+ const url = separator < 0 ? "" : raw.slice(separator + 1).trim()
369
+ if (separator < 0 || title.length === 0 || url.length === 0) {
370
+ return yield* Effect.fail(
371
+ new JiraApiError({ message: `Invalid --link ${JSON.stringify(raw)}. Expected title=url.` })
372
+ )
373
+ }
374
+ desired.push({ title, url })
375
+ }
376
+ if (desired.length === 0) {
377
+ return yield* Effect.fail(new JiraApiError({ message: "Pass at least one --link title=url." }))
378
+ }
379
+
380
+ const service = yield* VersionService
381
+ const existing = yield* service.listRelatedWork(id)
382
+ const plan = planRelatedWorkSync(existing, desired, { category, prune })
383
+
384
+ for (const item of plan.toAdd) {
385
+ yield* service.addRelatedWork(id, { title: item.title, category, url: item.url })
386
+ }
387
+ for (const item of plan.toRemove) {
388
+ yield* service.deleteRelatedWork(id, item.relatedWorkId)
389
+ }
390
+
391
+ const added = plan.toAdd.map((d) => d.url)
392
+ const removed = plan.toRemove.map((d) => d.url)
393
+ if (json) {
394
+ yield* Console.log(JSON.stringify({ added, kept: plan.kept, removed }, null, 2))
395
+ return
396
+ }
397
+ yield* Console.log(
398
+ `Version ${id} (${category}): ${added.length} added, ${plan.kept.length} unchanged, ${removed.length} removed`
399
+ )
400
+ for (const url of added) yield* Console.log(` + ${url}`)
401
+ for (const url of removed) yield* Console.log(` - ${url}`)
402
+ })).pipe(
403
+ // The help text is what a user reads before a remote write, so it has to
404
+ // say that pruning is opt-in: without --prune nothing is ever removed, and
405
+ // "reconcile to exactly the given set" reads as a promise that stale links
406
+ // were cleaned up.
407
+ Command.withDescription(
408
+ "Remote write: add any missing related-work links in a category, matched by URL (idempotent); " +
409
+ "pass --prune to also remove links that are not in the given set"
410
+ )
411
+ )
412
+
259
413
  const relatedWorkCommand = Command.make("related-work").pipe(
260
- Command.withDescription("List or attach version related-work links (Confluence pages on the release report)"),
261
- Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand])
414
+ Command.withDescription(
415
+ "List, attach or reconcile version related-work links (Confluence pages on the release report)"
416
+ ),
417
+ Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand, relatedWorkSyncCommand])
262
418
  )
263
419
 
264
420
  export const versionCommand = Command.make("version").pipe(
265
421
  Command.withDescription("Jira version commands"),
266
- Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
422
+ Command.withSubcommands([listCommand, getCommand, createCommand, updateCommand, relatedWorkCommand])
267
423
  )
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export {
8
8
  AttachmentService,
9
- type AttachmentServiceShape,
9
+ type AttachmentServiceContract,
10
10
  layer as AttachmentServiceLayer,
11
11
  type UploadAttachmentInput
12
12
  } from "./AttachmentService.js"
@@ -15,7 +15,7 @@ export {
15
15
  type Comment,
16
16
  type Issue,
17
17
  IssueService,
18
- type IssueServiceShape,
18
+ type IssueServiceContract,
19
19
  layer as IssueServiceLayer,
20
20
  type SearchOptions,
21
21
  type SearchResult,
@@ -29,4 +29,14 @@ export {
29
29
  type LoginOptions
30
30
  } from "./JiraAuth.js"
31
31
  export * from "./JiraCliError.js"
32
- export { layer as MarkdownWriterLayer, MarkdownWriter, type MarkdownWriterShape } from "./MarkdownWriter.js"
32
+ export { layer as MarkdownWriterLayer, MarkdownWriter, type MarkdownWriterContract } from "./MarkdownWriter.js"
33
+ export {
34
+ type DesiredRelatedWork,
35
+ layer as VersionServiceLayer,
36
+ planRelatedWorkSync,
37
+ type RelatedWork,
38
+ type RelatedWorkSyncPlan,
39
+ type Version,
40
+ VersionService,
41
+ type VersionServiceContract
42
+ } from "./VersionService.js"
@@ -13,5 +13,5 @@ import { makeHttpServerFactory } from "./oauthServer.js"
13
13
  * @category Layers
14
14
  */
15
15
  export const HttpServerFactoryLive = makeHttpServerFactory(
16
- (port) => NodeHttpServer.layerServer(createServer, { port })
16
+ (options) => NodeHttpServer.layerServer(createServer, options)
17
17
  )
@@ -9,6 +9,7 @@
9
9
  * @internal
10
10
  */
11
11
  import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
12
+ import * as Predicate from "effect/Predicate"
12
13
  import matter from "gray-matter"
13
14
  import * as yaml from "js-yaml"
14
15
  import type { Issue } from "../IssueService.js"
@@ -24,9 +25,9 @@ import type { Issue } from "../IssueService.js"
24
25
  const yamlEngine = {
25
26
  parse: (str: string): object => {
26
27
  const parsed = yaml.load(str)
27
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
28
+ return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed) ? parsed : {}
28
29
  },
29
- stringify: (data: object): string => yaml.dump(data)
30
+ stringify: <Data extends object>(data: Data): string => yaml.dump(data)
30
31
  }
31
32
 
32
33
  /**
@@ -30,13 +30,25 @@ type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
30
30
  * @category Services
31
31
  */
32
32
  export interface HttpServerFactory {
33
- readonly createServerLayer: (port: number) => Layer.Layer<
33
+ readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<
34
34
  HttpServer.HttpServer,
35
35
  HttpServerError.ServeError,
36
36
  never
37
37
  >
38
38
  }
39
39
 
40
+ export interface CallbackServerListenOptions {
41
+ readonly host: "localhost"
42
+ readonly port: number
43
+ }
44
+
45
+ export const callbackServerListenOptions = (port: number): CallbackServerListenOptions => ({
46
+ host: "localhost",
47
+ port
48
+ })
49
+
50
+ export const callbackUrl = (port: number): string => `http://localhost:${port}/callback`
51
+
40
52
  /**
41
53
  * Tag for the HttpServerFactory service.
42
54
  *
@@ -57,7 +69,9 @@ export class HttpServerFactoryTag extends Context.Service<
57
69
  * @category Layers
58
70
  */
59
71
  export const makeHttpServerFactory = (
60
- createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
72
+ createLayerFn: (
73
+ options: CallbackServerListenOptions
74
+ ) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
61
75
  ): Layer.Layer<HttpServerFactoryTag> =>
62
76
  Layer.succeed(HttpServerFactoryTag, {
63
77
  createServerLayer: createLayerFn
@@ -98,7 +112,7 @@ export const startCallbackServer = (
98
112
  { readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
99
113
  OAuthError
100
114
  > =>
101
- Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
115
+ Layer.buildWithScope(factory.createServerLayer(callbackServerListenOptions(port)), serverScope).pipe(
102
116
  Effect.map((context) => ({ context, port })),
103
117
  Effect.catchIf(
104
118
  (error) => isAddressInUse(error) && port < MAX_PORT,
@@ -118,29 +132,27 @@ export const startCallbackServer = (
118
132
  "/callback",
119
133
  (req) =>
120
134
  Effect.gen(function*() {
121
- const url = new URL(req.url, `http://localhost:${port}`)
135
+ const url = new URL(req.url, callbackUrl(port))
122
136
  const code = url.searchParams.get("code")
123
137
  const state = url.searchParams.get("state")
124
138
  const error = url.searchParams.get("error")
125
139
  const errorDescription = url.searchParams.get("error_description")
126
140
 
127
- if (error) {
128
- yield* Deferred.fail(
129
- deferred,
130
- new OAuthError({ step: "authorize", cause: errorDescription ?? error })
131
- )
141
+ if (state !== expectedState) {
132
142
  return HttpServerResponse.html(
133
- "<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>"
143
+ "<html><body><h1>Security Error</h1><p>State verification failed.</p></body></html>"
144
+ ).pipe(
145
+ HttpServerResponse.setStatus(403)
134
146
  )
135
147
  }
136
148
 
137
- if (state !== expectedState) {
149
+ if (error) {
138
150
  yield* Deferred.fail(
139
151
  deferred,
140
- new OAuthError({ step: "authorize", cause: "State mismatch - possible CSRF attack" })
152
+ new OAuthError({ step: "authorize", cause: errorDescription ?? error })
141
153
  )
142
154
  return HttpServerResponse.html(
143
- "<html><body><h1>Security Error</h1><p>State verification failed.</p></body></html>"
155
+ "<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>"
144
156
  )
145
157
  }
146
158
 
@@ -15,7 +15,7 @@ export const parseSyncBaseline = (
15
15
  ): Effect.Effect<SyncBaseline, SyncWorkspaceError | SyncValidationError> =>
16
16
  Effect.gen(function*() {
17
17
  const raw = yield* Effect.try({
18
- try: (): unknown => JSON.parse(content),
18
+ try: () => Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(content),
19
19
  catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
20
20
  })
21
21
  const baseline: SyncBaseline = yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
@@ -24,4 +24,5 @@ export const parseSyncBaseline = (
24
24
  return baseline
25
25
  })
26
26
 
27
- export const serializeSyncBaseline = (baseline: SyncBaseline): string => `${JSON.stringify(baseline, null, 2)}\n`
27
+ export const serializeSyncBaseline = (baseline: SyncBaseline): string =>
28
+ `${JSON.stringify(Schema.encodeSync(SyncBaselineSchema)(baseline), null, 2)}\n`
@@ -28,7 +28,8 @@ export const parseWorkspaceConfig = (
28
28
  return config
29
29
  })
30
30
 
31
- export const serializeWorkspaceConfig = (config: WorkspaceConfig): string => yaml.dump(config, { lineWidth: 100 })
31
+ export const serializeWorkspaceConfig = (config: WorkspaceConfig): string =>
32
+ yaml.dump(Schema.encodeSync(WorkspaceConfigSchema)(config), { lineWidth: 100 })
32
33
 
33
34
  export const makeDefaultWorkspaceConfig = (siteUrl: string): WorkspaceConfig => ({
34
35
  version: 1,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
7
7
  import * as Predicate from "effect/Predicate"
8
+ import type * as Schema from "effect/Schema"
8
9
  import matter from "gray-matter"
9
10
  import * as yaml from "js-yaml"
10
11
  import { SyncValidationError } from "../../JiraCliError.js"
@@ -17,19 +18,19 @@ import type {
17
18
  UserFieldValue
18
19
  } from "./types.js"
19
20
 
20
- const isStringArray = (value: unknown): value is ReadonlyArray<string> =>
21
- Array.isArray(value) && value.every((item) => typeof item === "string")
21
+ const isStringArray = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & ReadonlyArray<string> =>
22
+ Array.isArray(value) && value.every((item) => Predicate.isString(item))
22
23
 
23
- const isFrontMatterCustomFields = (
24
- value: unknown
25
- ): value is IssueDocumentFrontMatter["customFields"] => Predicate.isReadonlyObject(value)
24
+ const isFrontMatterCustomFields = <UnparsedInput>(
25
+ value: UnparsedInput
26
+ ): value is UnparsedInput & IssueDocumentFrontMatter["customFields"] => Predicate.isReadonlyObject(value)
26
27
 
27
28
  const yamlEngine = {
28
29
  parse: (str: string): object => {
29
30
  const value = yaml.load(str)
30
31
  return Predicate.isReadonlyObject(value) ? value : {}
31
32
  },
32
- stringify: (data: object): string => yaml.dump(data)
33
+ stringify: <Data extends object>(data: Data): string => yaml.dump(data)
33
34
  }
34
35
 
35
36
  export const DESCRIPTION_SECTION = "Description"
@@ -88,17 +89,17 @@ const section = (name: string, content: string): string => {
88
89
  return normalized.length > 0 ? `## ${name}\n\n${normalized}\n` : `## ${name}\n`
89
90
  }
90
91
 
91
- const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDocumentFrontMatter => {
92
+ const parseFrontMatter = (path: string, data: Record<string, Schema.Json>): IssueDocumentFrontMatter => {
92
93
  const requiredString = (key: string): string => {
93
94
  const value = data[key]
94
- if (typeof value === "string") return value
95
+ if (Predicate.isString(value)) return value
95
96
  return fail(path, `Missing or invalid front matter field "${key}"`)
96
97
  }
97
98
 
98
99
  const nullableString = (key: string): string | null => {
99
100
  const value = data[key]
100
101
  if (value === null || value === undefined) return null
101
- if (typeof value === "string") return value
102
+ if (Predicate.isString(value)) return value
102
103
  return fail(path, `Invalid front matter field "${key}"`)
103
104
  }
104
105
 
@@ -108,7 +109,7 @@ const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDoc
108
109
  const record = Predicate.isReadonlyObject(value) ? value : fail(path, `Invalid user field "${key}"`)
109
110
  const accountId = record["accountId"]
110
111
  const displayName = record["displayName"]
111
- if (typeof accountId === "string" && typeof displayName === "string") {
112
+ if (Predicate.isString(accountId) && Predicate.isString(displayName)) {
112
113
  return {
113
114
  accountId,
114
115
  displayName
@@ -244,13 +245,13 @@ const parseAttachmentMetadata = (
244
245
  const parsed = JSON.parse(raw)
245
246
  if (!Predicate.isReadonlyObject(parsed)) return {}
246
247
  return {
247
- id: typeof parsed["jiraAttachmentId"] === "string"
248
+ id: Predicate.isString(parsed["jiraAttachmentId"])
248
249
  ? parsed["jiraAttachmentId"]
249
- : typeof parsed["id"] === "string"
250
+ : Predicate.isString(parsed["id"])
250
251
  ? parsed["id"]
251
252
  : "",
252
- mediaType: typeof parsed["mediaType"] === "string" ? parsed["mediaType"] : null,
253
- size: typeof parsed["size"] === "number" ? parsed["size"] : null
253
+ mediaType: Predicate.isString(parsed["mediaType"]) ? parsed["mediaType"] : null,
254
+ size: Predicate.isNumber(parsed["size"]) ? parsed["size"] : null
254
255
  }
255
256
  } catch {
256
257
  return {}
@@ -16,7 +16,7 @@ export interface CanonicalFieldValueOptions {
16
16
 
17
17
  export const explicitClear = null
18
18
 
19
- export const isExplicitClear = (value: unknown): value is null => value === null
19
+ export const isExplicitClear = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & null => value === null
20
20
 
21
21
  export const userFieldValue = (accountId: string, displayName: string): UserFieldValue => ({
22
22
  accountId,
@@ -44,24 +44,26 @@ export const completeListValue = (
44
44
 
45
45
  export const makeCompleteListValue = completeListValue
46
46
 
47
- export const isUserFieldValue = (value: unknown): value is UserFieldValue => {
47
+ export const isUserFieldValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & UserFieldValue => {
48
48
  if (!Predicate.isReadonlyObject(value)) return false
49
- return typeof value["accountId"] === "string" && typeof value["displayName"] === "string"
49
+ return Predicate.isString(value["accountId"]) && Predicate.isString(value["displayName"])
50
50
  }
51
51
 
52
- export const isOptionFieldValue = (value: unknown): value is OptionFieldValue => {
53
- if (!Predicate.isReadonlyObject(value) || typeof value["value"] !== "string") return false
52
+ export const isOptionFieldValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & OptionFieldValue => {
53
+ if (!Predicate.isReadonlyObject(value) || !Predicate.isString(value["value"])) return false
54
54
  const id = value["id"]
55
- return id === undefined || typeof id === "string"
55
+ return id === undefined || Predicate.isString(id)
56
56
  }
57
57
 
58
- export const isCascadingFieldValue = (value: unknown): value is CascadingFieldValue => {
58
+ export const isCascadingFieldValue = <UnparsedInput>(
59
+ value: UnparsedInput
60
+ ): value is UnparsedInput & CascadingFieldValue => {
59
61
  if (!Predicate.isReadonlyObject(value) || !isOptionFieldValue(value["parent"])) return false
60
62
  const child = value["child"]
61
63
  return child === undefined || isOptionFieldValue(child)
62
64
  }
63
65
 
64
- export const isCompleteListValue = (value: unknown): value is CompleteListValue =>
66
+ export const isCompleteListValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & CompleteListValue =>
65
67
  Array.isArray(value) && value.every(isCompleteListItem)
66
68
 
67
69
  export const canonicalFieldOrder = <A extends CompleteListItem>(
@@ -104,7 +106,7 @@ const canonicalFieldValueKey = (
104
106
  options: CanonicalFieldValueOptions
105
107
  ): string => {
106
108
  if (value === null) return "clear:"
107
- if (Array.isArray(value)) {
109
+ if (isCompleteListValue(value)) {
108
110
  return `list:${canonicalFieldOrder(value, options).map(completeListItemOrderKey).join("\u0000")}`
109
111
  }
110
112
  if (isCascadingFieldValue(value)) {
@@ -114,13 +116,13 @@ const canonicalFieldValueKey = (
114
116
  }
115
117
  if (isUserFieldValue(value)) return userOrderKey(value)
116
118
  if (isOptionFieldValue(value)) return optionOrderKey(value)
117
- return `${typeof value}:${String(value)}`
119
+ return scalarOrderKey(value)
118
120
  }
119
121
 
120
122
  const completeListItemOrderKey = (value: CompleteListItem): string => {
121
123
  if (isUserFieldValue(value)) return userOrderKey(value)
122
124
  if (isOptionFieldValue(value)) return optionOrderKey(value)
123
- return `${typeof value}:${String(value)}`
125
+ return scalarOrderKey(value)
124
126
  }
125
127
 
126
128
  const userOrderKey = (value: UserFieldValue): string =>
@@ -129,15 +131,12 @@ const userOrderKey = (value: UserFieldValue): string =>
129
131
  const optionOrderKey = (value: OptionFieldValue): string =>
130
132
  `option:${value.value.toLocaleLowerCase("en")}\u0000${value.id ?? ""}`
131
133
 
132
- const isCompleteListItem = (value: unknown): value is CompleteListItem => {
133
- switch (typeof value) {
134
- case "string":
135
- case "number":
136
- case "boolean":
137
- return true
138
- case "object":
139
- return isUserFieldValue(value) || isOptionFieldValue(value)
140
- default:
141
- return false
142
- }
134
+ const scalarOrderKey = (value: string | number | boolean): string => {
135
+ const kind = Predicate.isString(value) ? "string" : Predicate.isNumber(value) ? "number" : "boolean"
136
+ return `${kind}:${String(value)}`
137
+ }
138
+
139
+ const isCompleteListItem = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & CompleteListItem => {
140
+ return Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value) ||
141
+ isUserFieldValue(value) || isOptionFieldValue(value)
143
142
  }
@@ -21,7 +21,7 @@ export const parseSyncManifest = (
21
21
  ): Effect.Effect<SyncManifest, SyncWorkspaceError | SyncValidationError> =>
22
22
  Effect.gen(function*() {
23
23
  const raw = yield* Effect.try({
24
- try: (): unknown => JSON.parse(content),
24
+ try: () => Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(content),
25
25
  catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Manifest JSON", path, cause })
26
26
  })
27
27
  return yield* Schema.decodeUnknownEffect(SyncManifestSchema)(raw).pipe(
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as Effect from "effect/Effect"
7
7
  import * as Schema from "effect/Schema"
8
- import { FIELD_SHAPES } from "./types.js"
8
+ import { FIELD_CONTRACTS } from "./types.js"
9
9
 
10
10
  const SiteUrl = Schema.String.pipe(
11
11
  Schema.check(Schema.isPattern(/^https:\/\/[a-z0-9][a-z0-9-]*\.atlassian\.net$/))
@@ -14,14 +14,14 @@ const SiteUrl = Schema.String.pipe(
14
14
  const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1)))
15
15
  const FilenameModes: readonly ["convention", "custom"] = ["convention", "custom"]
16
16
 
17
- export const FieldShapeSchema = Schema.Literals(FIELD_SHAPES)
17
+ export const FieldContractSchema = Schema.Literals(FIELD_CONTRACTS)
18
18
 
19
19
  export const RequestedCustomFieldSchema = Schema.Struct({
20
20
  displayName: NonEmptyString,
21
21
  fieldId: Schema.optional(NonEmptyString),
22
- shape: FieldShapeSchema,
22
+ form: FieldContractSchema,
23
23
  ordered: Schema.optional(Schema.Boolean)
24
- })
24
+ }).pipe(Schema.encodeKeys({ form: "shape" }))
25
25
 
26
26
  export const WorkspaceConfigSchema = Schema.Struct({
27
27
  version: Schema.Literal(1).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(1))),
@@ -82,9 +82,9 @@ export const SyncFieldValueSchema = Schema.Union([
82
82
  export const BaselineCustomFieldSchema = Schema.Struct({
83
83
  fieldId: NonEmptyString,
84
84
  displayName: NonEmptyString,
85
- shape: FieldShapeSchema,
85
+ form: FieldContractSchema,
86
86
  value: SyncFieldValueSchema
87
- })
87
+ }).pipe(Schema.encodeKeys({ form: "shape" }))
88
88
 
89
89
  export const SyncBaselineSchema = Schema.Struct({
90
90
  version: Schema.Literal(1),
@@ -4,7 +4,7 @@
4
4
  * @internal
5
5
  */
6
6
 
7
- export type FieldShape =
7
+ export type FieldContract =
8
8
  | "text"
9
9
  | "multilineText"
10
10
  | "number"
@@ -15,7 +15,7 @@ export type FieldShape =
15
15
  | "user"
16
16
  | "cascadingSelect"
17
17
 
18
- export const FIELD_SHAPES: ReadonlyArray<FieldShape> = [
18
+ export const FIELD_CONTRACTS: ReadonlyArray<FieldContract> = [
19
19
  "text",
20
20
  "multilineText",
21
21
  "number",
@@ -30,7 +30,7 @@ export const FIELD_SHAPES: ReadonlyArray<FieldShape> = [
30
30
  export interface RequestedCustomField {
31
31
  readonly displayName: string
32
32
  readonly fieldId?: string | undefined
33
- readonly shape: FieldShape
33
+ readonly form: FieldContract
34
34
  readonly ordered?: boolean | undefined
35
35
  }
36
36
 
@@ -83,7 +83,7 @@ export type SyncFieldValue =
83
83
  export interface BaselineCustomField {
84
84
  readonly fieldId: string
85
85
  readonly displayName: string
86
- readonly shape: FieldShape
86
+ readonly form: FieldContract
87
87
  readonly value: SyncFieldValue
88
88
  }
89
89