@knpkv/jira-cli 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +5 -5
  3. package/dist/JiraAuth.d.ts +23 -8
  4. package/dist/JiraAuth.d.ts.map +1 -1
  5. package/dist/JiraAuth.js +8 -303
  6. package/dist/JiraAuth.js.map +1 -1
  7. package/dist/bin.js +1 -1
  8. package/dist/bin.js.map +1 -1
  9. package/dist/commands/auth.d.ts +2 -2
  10. package/dist/commands/auth.d.ts.map +1 -1
  11. package/dist/commands/auth.js +8 -8
  12. package/dist/commands/auth.js.map +1 -1
  13. package/dist/commands/edit.d.ts +10 -1
  14. package/dist/commands/edit.d.ts.map +1 -1
  15. package/dist/commands/edit.js +9 -9
  16. package/dist/commands/edit.js.map +1 -1
  17. package/dist/commands/get.d.ts +6 -1
  18. package/dist/commands/get.d.ts.map +1 -1
  19. package/dist/commands/get.js +3 -3
  20. package/dist/commands/get.js.map +1 -1
  21. package/dist/commands/issue.d.ts +1 -1
  22. package/dist/commands/issue.d.ts.map +1 -1
  23. package/dist/commands/issue.js +1 -1
  24. package/dist/commands/issue.js.map +1 -1
  25. package/dist/commands/issueAttachment.d.ts +1 -1
  26. package/dist/commands/issueAttachment.d.ts.map +1 -1
  27. package/dist/commands/issueAttachment.js +13 -11
  28. package/dist/commands/issueAttachment.js.map +1 -1
  29. package/dist/commands/layers.d.ts +2 -2
  30. package/dist/commands/layers.d.ts.map +1 -1
  31. package/dist/commands/search.d.ts +6 -1
  32. package/dist/commands/search.d.ts.map +1 -1
  33. package/dist/commands/search.js +7 -7
  34. package/dist/commands/search.js.map +1 -1
  35. package/dist/commands/version.d.ts +9 -1
  36. package/dist/commands/version.d.ts.map +1 -1
  37. package/dist/commands/version.js +23 -23
  38. package/dist/commands/version.js.map +1 -1
  39. package/package.json +10 -10
  40. package/skills/jira/SKILL.md +4 -4
  41. package/src/JiraAuth.ts +24 -477
  42. package/src/bin.ts +1 -1
  43. package/src/commands/auth.ts +8 -8
  44. package/src/commands/edit.ts +9 -9
  45. package/src/commands/get.ts +3 -3
  46. package/src/commands/issue.ts +1 -1
  47. package/src/commands/issueAttachment.ts +21 -20
  48. package/src/commands/search.ts +7 -7
  49. package/src/commands/version.ts +26 -29
  50. package/test/IssueService.test.ts +2 -2
  51. package/test/JiraAuth.test.ts +79 -0
  52. package/test/VersionService.test.ts +2 -2
  53. package/test/commandTree.test.ts +1 -1
  54. package/test/integration.test.ts +7 -7
  55. package/test/remoteWrites.test.ts +3 -3
  56. package/vitest.config.integration.ts +4 -1
  57. package/vitest.config.ts +4 -1
  58. package/dist/internal/NodeLayers.d.ts +0 -7
  59. package/dist/internal/NodeLayers.d.ts.map +0 -1
  60. package/dist/internal/NodeLayers.js +0 -15
  61. package/dist/internal/NodeLayers.js.map +0 -1
  62. package/dist/internal/oauthServer.d.ts +0 -72
  63. package/dist/internal/oauthServer.d.ts.map +0 -1
  64. package/dist/internal/oauthServer.js +0 -96
  65. package/dist/internal/oauthServer.js.map +0 -1
  66. package/dist/internal/openBrowser.d.ts +0 -10
  67. package/dist/internal/openBrowser.d.ts.map +0 -1
  68. package/dist/internal/openBrowser.js +0 -17
  69. package/dist/internal/openBrowser.js.map +0 -1
  70. package/src/internal/NodeLayers.ts +0 -17
  71. package/src/internal/oauthServer.ts +0 -186
  72. package/src/internal/openBrowser.ts +0 -31
  73. package/test/JiraAuthRefresh.test.ts +0 -269
  74. package/test/oauthServer.test.ts +0 -171
@@ -7,44 +7,44 @@
7
7
  *
8
8
  * @internal
9
9
  */
10
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
10
11
  import * as Console from "effect/Console"
11
12
  import * as Effect from "effect/Effect"
12
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
13
13
  import { IssueService } from "../IssueService.js"
14
14
 
15
- const keyArg = Args.string("key").pipe(
15
+ const keyArg = Args.String("key").pipe(
16
16
  Args.withDescription("Issue key (e.g., PROJ-123)")
17
17
  )
18
18
 
19
- const addFixVersionOption = Options.string("add-fix-version").pipe(
19
+ const addFixVersionOption = Options.String("add-fix-version").pipe(
20
20
  Options.withDescription("Add a fix version by name, keeping existing ones (repeatable)"),
21
21
  Options.atLeast(0)
22
22
  )
23
- const removeFixVersionOption = Options.string("remove-fix-version").pipe(
23
+ const removeFixVersionOption = Options.String("remove-fix-version").pipe(
24
24
  Options.withDescription("Remove a fix version by name (repeatable)"),
25
25
  Options.atLeast(0)
26
26
  )
27
- const fixVersionOption = Options.string("fix-version").pipe(
27
+ const fixVersionOption = Options.String("fix-version").pipe(
28
28
  Options.withDescription(
29
29
  "Replace the fix versions with exactly these names (repeatable). Drops any not listed — prefer --add-fix-version"
30
30
  ),
31
31
  Options.atLeast(0)
32
32
  )
33
- const addLabelOption = Options.string("add-label").pipe(
33
+ const addLabelOption = Options.String("add-label").pipe(
34
34
  Options.withDescription("Add a label, keeping existing ones (repeatable)"),
35
35
  Options.atLeast(0)
36
36
  )
37
- const removeLabelOption = Options.string("remove-label").pipe(
37
+ const removeLabelOption = Options.String("remove-label").pipe(
38
38
  Options.withDescription("Remove a label (repeatable)"),
39
39
  Options.atLeast(0)
40
40
  )
41
- const labelOption = Options.string("label").pipe(
41
+ const labelOption = Options.String("label").pipe(
42
42
  Options.withDescription(
43
43
  "Replace the labels with exactly these (repeatable). Drops any not listed — prefer --add-label"
44
44
  ),
45
45
  Options.atLeast(0)
46
46
  )
47
- const jsonOption = Options.boolean("json").pipe(
47
+ const jsonOption = Options.Boolean("json").pipe(
48
48
  Options.withDescription("Output as JSON"),
49
49
  Options.withDefault(false)
50
50
  )
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
6
7
  import * as Console from "effect/Console"
7
8
  import * as Effect from "effect/Effect"
8
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
9
9
  import { IssueService } from "../IssueService.js"
10
10
  import { MarkdownWriter } from "../MarkdownWriter.js"
11
11
 
12
- const keyArg = Args.string("key").pipe(
12
+ const keyArg = Args.String("key").pipe(
13
13
  Args.withDescription("Issue key (e.g., PROJ-123)")
14
14
  )
15
15
 
16
- const outputDirOption = Options.directory("output-dir").pipe(
16
+ const outputDirOption = Options.Directory("output-dir").pipe(
17
17
  Options.withAlias("o"),
18
18
  Options.withDescription("Output directory for markdown file"),
19
19
  Options.withDefault("./jira-tickets")
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
- import { Command } from "effect/unstable/cli"
6
+ import { Command } from "effect/cli"
7
7
  import { editCommand } from "./edit.js"
8
8
  import { getCommand } from "./get.js"
9
9
  import { attachmentCommand } from "./issueAttachment.js"
@@ -4,39 +4,42 @@
4
4
  * @internal
5
5
  */
6
6
  import { renderAttachmentMarkdown } from "@knpkv/atlassian-common/attachments"
7
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
7
8
  import * as Console from "effect/Console"
8
9
  import * as Effect from "effect/Effect"
9
10
  import * as FileSystem from "effect/FileSystem"
10
11
  import * as Option from "effect/Option"
11
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
12
12
  import { AttachmentService } from "../AttachmentService.js"
13
13
  import { insertJiraAttachmentReference } from "../internal/attachmentInsertion.js"
14
14
  import { JiraApiError, WriteError } from "../JiraCliError.js"
15
15
 
16
- const issueArg = Args.string("issue").pipe(
16
+ const issueArg = Args.String("issue").pipe(
17
17
  Args.withDescription("Issue key or id, for example PROJ-123")
18
18
  )
19
19
 
20
- const fileArg = Args.string("file").pipe(
20
+ const fileArg = Args.String("file").pipe(
21
21
  Args.withDescription("Local file to upload")
22
22
  )
23
23
 
24
- const documentOption = Options.file("document").pipe(
24
+ const documentOption = Options.File("document").pipe(
25
25
  Options.withDescription("Local Jira Markdown document containing the attachment placeholder"),
26
26
  Options.optional
27
27
  )
28
28
 
29
- const noInsertOption = Options.boolean("no-insert").pipe(
30
- Options.withDescription("Only upload and print the Attachment Reference")
29
+ const noInsertOption = Options.Boolean("no-insert").pipe(
30
+ Options.withDescription("Only upload and print the Attachment Reference"),
31
+ Options.withDefault(false)
31
32
  )
32
33
 
33
- const dryRunOption = Options.boolean("dry-run").pipe(
34
+ const dryRunOption = Options.Boolean("dry-run").pipe(
34
35
  Options.withAlias("n"),
35
- Options.withDescription("Validate local insertion input without uploading")
36
+ Options.withDescription("Validate local insertion input without uploading"),
37
+ Options.withDefault(false)
36
38
  )
37
39
 
38
- const jsonOption = Options.boolean("json").pipe(
39
- Options.withDescription("Write exactly one JSON value to stdout")
40
+ const jsonOption = Options.Boolean("json").pipe(
41
+ Options.withDescription("Write exactly one JSON value to stdout"),
42
+ Options.withDefault(false)
40
43
  )
41
44
 
42
45
  const readDocument = (documentPath: string) =>
@@ -88,16 +91,16 @@ const uploadCommand = Command.make(
88
91
  if (documentInput !== null) {
89
92
  const matches = countPlaceholderMatches(documentInput.content, file)
90
93
  if (matches !== 1) {
91
- return yield* Effect.fail(
92
- new JiraApiError({ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}` })
93
- )
94
+ return yield* new JiraApiError({
95
+ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}`
96
+ })
94
97
  }
95
98
  }
96
99
 
97
100
  if (dryRun) {
98
101
  const exists = yield* fileExists(file)
99
102
  if (!exists) {
100
- return yield* Effect.fail(new JiraApiError({ message: `Attachment file does not exist: ${file}` }))
103
+ return yield* new JiraApiError({ message: `Attachment file does not exist: ${file}` })
101
104
  }
102
105
  const result = { dryRun: true, issue, file, insert: shouldInsert }
103
106
  yield* Console.log(json ? JSON.stringify(result) : `Dry run: ${file} can be uploaded to ${issue}`)
@@ -111,12 +114,10 @@ const uploadCommand = Command.make(
111
114
  if (documentInput !== null) {
112
115
  const result = insertJiraAttachmentReference(documentInput.content, file, attachment)
113
116
  if (result.replacements !== 1) {
114
- return yield* Effect.fail(
115
- new JiraApiError({
116
- message:
117
- `Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
118
- })
119
- )
117
+ return yield* new JiraApiError({
118
+ message:
119
+ `Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
120
+ })
120
121
  }
121
122
  yield* writeDocument(documentInput.path, result.content)
122
123
  inserted = true
@@ -3,45 +3,45 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
6
7
  import * as Console from "effect/Console"
7
8
  import * as Effect from "effect/Effect"
8
9
  import * as Option from "effect/Option"
9
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
10
10
  import { buildByVersionJql } from "../internal/jqlBuilder.js"
11
11
  import { IssueService } from "../IssueService.js"
12
12
  import { MarkdownWriter } from "../MarkdownWriter.js"
13
13
 
14
14
  // === Options ===
15
- const jqlArg = Args.string("jql").pipe(
15
+ const jqlArg = Args.String("jql").pipe(
16
16
  Args.withDescription("JQL query to search for issues"),
17
17
  Args.optional
18
18
  )
19
19
 
20
- const byVersionOption = Options.string("by-version").pipe(
20
+ const byVersionOption = Options.String("by-version").pipe(
21
21
  Options.withAlias("v"),
22
22
  Options.withDescription("Search by fix version (pre-defined query)"),
23
23
  Options.optional
24
24
  )
25
25
 
26
- const projectOption = Options.string("project").pipe(
26
+ const projectOption = Options.String("project").pipe(
27
27
  Options.withAlias("p"),
28
28
  Options.withDescription("Filter by project key"),
29
29
  Options.optional
30
30
  )
31
31
 
32
- const outputDirOption = Options.directory("output-dir").pipe(
32
+ const outputDirOption = Options.Directory("output-dir").pipe(
33
33
  Options.withAlias("o"),
34
34
  Options.withDescription("Output directory for markdown files"),
35
35
  Options.withDefault("./jira-tickets")
36
36
  )
37
37
 
38
- const formatOption = Options.choice("format", ["multi", "single"]).pipe(
38
+ const formatOption = Options.Literals("format", ["multi", "single"]).pipe(
39
39
  Options.withAlias("f"),
40
40
  Options.withDescription("Output format: multi (one file per issue) or single (combined file)"),
41
41
  Options.withDefault("multi")
42
42
  )
43
43
 
44
- const maxResultsOption = Options.integer("max-results").pipe(
44
+ const maxResultsOption = Options.Int("max-results").pipe(
45
45
  Options.withAlias("m"),
46
46
  Options.withDescription("Maximum number of results to fetch"),
47
47
  Options.withDefault(100)
@@ -6,10 +6,10 @@
6
6
  *
7
7
  * @internal
8
8
  */
9
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
9
10
  import * as Console from "effect/Console"
10
11
  import * as Effect from "effect/Effect"
11
12
  import * as Option from "effect/Option"
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
15
  import { planRelatedWorkSync, VersionService } from "../VersionService.js"
@@ -48,27 +48,27 @@ const ensureNumericId = (id: string): Effect.Effect<void, JiraApiError> =>
48
48
  })
49
49
  )
50
50
 
51
- const projectOption = Options.string("project").pipe(
51
+ const projectOption = Options.String("project").pipe(
52
52
  Options.withAlias("p"),
53
53
  Options.withDescription("Jira project key (e.g. RPS)")
54
54
  )
55
- const releasedOption = Options.boolean("released").pipe(
55
+ const releasedOption = Options.Boolean("released").pipe(
56
56
  Options.withDescription("Only list released versions"),
57
57
  Options.withDefault(false)
58
58
  )
59
- const unreleasedOption = Options.boolean("unreleased").pipe(
59
+ const unreleasedOption = Options.Boolean("unreleased").pipe(
60
60
  Options.withDescription("Only list unreleased versions"),
61
61
  Options.withDefault(false)
62
62
  )
63
- const jsonOption = Options.boolean("json").pipe(
63
+ const jsonOption = Options.Boolean("json").pipe(
64
64
  Options.withDescription("Output as JSON"),
65
65
  Options.withDefault(false)
66
66
  )
67
- const emailsOption = Options.boolean("emails").pipe(
67
+ const emailsOption = Options.Boolean("emails").pipe(
68
68
  Options.withDescription("Include resolved user email addresses in --json output"),
69
69
  Options.withDefault(false)
70
70
  )
71
- const customFieldOption = Options.string("custom-field").pipe(
71
+ const customFieldOption = Options.String("custom-field").pipe(
72
72
  Options.withDescription(
73
73
  "Custom field display name to include on each ticket (repeatable, e.g. " +
74
74
  "--custom-field \"Security & Compliance Impact\"). Values are exposed in " +
@@ -76,13 +76,13 @@ const customFieldOption = Options.string("custom-field").pipe(
76
76
  ),
77
77
  Options.atLeast(0)
78
78
  )
79
- const maxOption = Options.integer("max").pipe(
79
+ const maxOption = Options.Int("max").pipe(
80
80
  Options.withAlias("m"),
81
81
  Options.withDescription("Maximum number of versions to fetch (default: all)"),
82
82
  Options.optional
83
83
  )
84
84
 
85
- const idArg = Args.string("id").pipe(Args.withDescription("Version id (numeric)"))
85
+ const idArg = Args.String("id").pipe(Args.withDescription("Version id (numeric)"))
86
86
 
87
87
  const listCommand = Command.make("list", {
88
88
  project: projectOption,
@@ -95,11 +95,9 @@ const listCommand = Command.make("list", {
95
95
  }, ({ customFields, emails, json, max, project, released, unreleased }) =>
96
96
  Effect.gen(function*() {
97
97
  if (released && unreleased) {
98
- return yield* Effect.fail(
99
- new JiraApiError({
100
- message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
101
- })
102
- )
98
+ return yield* new JiraApiError({
99
+ message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
100
+ })
103
101
  }
104
102
  const service = yield* VersionService
105
103
  const versions = yield* service.listProjectVersions(project, {
@@ -169,7 +167,7 @@ const formatTicketKeys = (tickets: Version["tickets"]): string => {
169
167
  return remaining > 0 ? `${shown} (+${remaining} more)` : shown
170
168
  }
171
169
 
172
- const descriptionOption = Options.string("description").pipe(
170
+ const descriptionOption = Options.String("description").pipe(
173
171
  Options.withAlias("d"),
174
172
  Options.withDescription("New version description")
175
173
  )
@@ -197,19 +195,19 @@ const ensureIsoDate = (flag: string, value: string): Effect.Effect<void, JiraApi
197
195
  // No `-n` alias: it means `--dry-run` everywhere else in these CLIs, and both
198
196
  // `getLayerType` implementations route on `argv.includes("-n")`. Reusing it for
199
197
  // a value on a remote-write command is the wrong default.
200
- const nameOption = Options.string("name").pipe(
198
+ const nameOption = Options.String("name").pipe(
201
199
  Options.withDescription("Version name (e.g. \"OOB 100\")")
202
200
  )
203
- const optionalDescriptionOption = Options.string("description").pipe(
201
+ const optionalDescriptionOption = Options.String("description").pipe(
204
202
  Options.withAlias("d"),
205
203
  Options.withDescription("Version description"),
206
204
  Options.optional
207
205
  )
208
- const startDateOption = Options.string("start-date").pipe(
206
+ const startDateOption = Options.String("start-date").pipe(
209
207
  Options.withDescription("Start date, ISO 8601 (yyyy-mm-dd)"),
210
208
  Options.optional
211
209
  )
212
- const releaseDateOption = Options.string("release-date").pipe(
210
+ const releaseDateOption = Options.String("release-date").pipe(
213
211
  Options.withDescription("Release date, ISO 8601 (yyyy-mm-dd)"),
214
212
  Options.optional
215
213
  )
@@ -269,15 +267,15 @@ const updateCommand = Command.make("update", { id: idArg, description: descripti
269
267
 
270
268
  // === related-work ===
271
269
 
272
- const titleOption = Options.string("title").pipe(
270
+ const titleOption = Options.String("title").pipe(
273
271
  Options.withAlias("t"),
274
272
  Options.withDescription("Related-work link title (e.g. \"Release notes\")")
275
273
  )
276
- const urlOption = Options.string("url").pipe(
274
+ const urlOption = Options.String("url").pipe(
277
275
  Options.withAlias("u"),
278
276
  Options.withDescription("Related-work link URL (e.g. a Confluence page)")
279
277
  )
280
- const categoryOption = Options.string("category").pipe(
278
+ const categoryOption = Options.String("category").pipe(
281
279
  Options.withAlias("c"),
282
280
  Options.withDescription("Related-work category (Jira groups by this; e.g. Communication, Testing, Design)"),
283
281
  Options.withDefault("Communication")
@@ -330,14 +328,15 @@ const relatedWorkAddCommand = Command.make("add", {
330
328
  )
331
329
  )
332
330
 
333
- const linkOption = Options.string("link").pipe(
331
+ const linkOption = Options.String("link").pipe(
334
332
  Options.withAlias("l"),
335
333
  Options.withDescription("Desired link as `title=url` (repeatable). Category comes from --category."),
336
334
  Options.atLeast(0)
337
335
  )
338
336
 
339
- const pruneOption = Options.boolean("prune").pipe(
340
- Options.withDescription("Also remove links in the category that are not in the desired set")
337
+ const pruneOption = Options.Boolean("prune").pipe(
338
+ Options.withDescription("Also remove links in the category that are not in the desired set"),
339
+ Options.withDefault(false)
341
340
  )
342
341
 
343
342
  /**
@@ -367,14 +366,12 @@ const relatedWorkSyncCommand = Command.make("sync", {
367
366
  const title = separator < 0 ? "" : raw.slice(0, separator).trim()
368
367
  const url = separator < 0 ? "" : raw.slice(separator + 1).trim()
369
368
  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
- )
369
+ return yield* new JiraApiError({ message: `Invalid --link ${JSON.stringify(raw)}. Expected title=url.` })
373
370
  }
374
371
  desired.push({ title, url })
375
372
  }
376
373
  if (desired.length === 0) {
377
- return yield* Effect.fail(new JiraApiError({ message: "Pass at least one --link title=url." }))
374
+ return yield* new JiraApiError({ message: "Pass at least one --link title=url." })
378
375
  }
379
376
 
380
377
  const service = yield* VersionService
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { JiraApiClient, make } from "@knpkv/jira-api-client"
3
3
  import * as Effect from "effect/Effect"
4
+ import * as HttpClient from "effect/http/HttpClient"
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
4
6
  import * as Layer from "effect/Layer"
5
7
  import * as Redacted from "effect/Redacted"
6
- import * as HttpClient from "effect/unstable/http/HttpClient"
7
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
8
8
  import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
9
9
 
10
10
  const httpClient = HttpClient.make((request) =>
@@ -0,0 +1,79 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * JiraAuth is the shared Atlassian CLI auth bound to Jira. These pin the binding:
4
+ * the `jira-cli` storage namespace, Jira's own "not logged in" error, the `jira`
5
+ * command in re-login hints, and a redacted token. The refresh rules themselves
6
+ * are covered once in `@knpkv/atlassian-common`'s cli-auth tests.
7
+ */
8
+ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
9
+ import { describe, expect, it } from "@effect/vitest"
10
+ import { HomeDirectoryLive, saveOAuthConfig, saveProfileToken } from "@knpkv/atlassian-common/config"
11
+ import { ConfigProvider } from "effect"
12
+ import * as Effect from "effect/Effect"
13
+ import * as FileSystem from "effect/FileSystem"
14
+ import { HttpClient, HttpClientResponse } from "effect/http"
15
+ import * as Layer from "effect/Layer"
16
+ import * as Redacted from "effect/Redacted"
17
+ import { JiraAuth, layer as jiraAuthLayer } from "../src/JiraAuth.js"
18
+
19
+ const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
20
+
21
+ const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
22
+ Effect.gen(function*() {
23
+ const fs = yield* FileSystem.FileSystem
24
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "jira-auth-" })
25
+ return yield* effect.pipe(
26
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
27
+ )
28
+ }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
29
+
30
+ const rejectingClient = Layer.succeed(
31
+ HttpClient.HttpClient,
32
+ HttpClient.make((request) =>
33
+ Effect.succeed(
34
+ HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }))
35
+ )
36
+ )
37
+ )
38
+
39
+ const withAuth = <A, E>(use: (auth: JiraAuth["Service"]) => Effect.Effect<A, E>) =>
40
+ JiraAuth.pipe(
41
+ Effect.flatMap(use),
42
+ Effect.provide(jiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(rejectingClient, NodeServices.layer))))
43
+ )
44
+
45
+ const token = (expiresAt: number) => ({
46
+ access_token: "access-1",
47
+ refresh_token: "refresh-1",
48
+ expires_at: expiresAt,
49
+ token_type: "Bearer",
50
+ scope: "offline_access",
51
+ cloud_id: "cloud-1",
52
+ site_url: "https://example.atlassian.net"
53
+ })
54
+
55
+ describe("JiraAuth", () => {
56
+ it.effect("fails with Jira's own error when nobody is logged in", () =>
57
+ withHome(Effect.gen(function*() {
58
+ const error = yield* withAuth((auth) => auth.getAccessToken()).pipe(Effect.flip)
59
+ expect(error).toMatchObject({ _tag: "AuthMissingError", message: "Not logged in. Run 'jira auth login' first." })
60
+ })))
61
+
62
+ it.effect("reads the jira-cli profile and returns a redacted token", () =>
63
+ withHome(Effect.gen(function*() {
64
+ yield* saveProfileToken("jira-cli", token(Number.MAX_SAFE_INTEGER)).pipe(Effect.provide(storage))
65
+ const accessToken = yield* withAuth((auth) => auth.getAccessToken())
66
+ expect(Redacted.value(accessToken)).toBe("access-1")
67
+ expect(yield* withAuth((auth) => auth.getSiteUrl())).toBe("https://example.atlassian.net")
68
+ })))
69
+
70
+ it.effect("names the jira command when the stored token is spent", () =>
71
+ withHome(Effect.gen(function*() {
72
+ yield* saveOAuthConfig("jira-cli", { clientId: "client-1", clientSecret: "secret-1" }).pipe(
73
+ Effect.provide(storage)
74
+ )
75
+ yield* saveProfileToken("jira-cli", token(0)).pipe(Effect.provide(storage))
76
+ const error = yield* withAuth((auth) => auth.getAccessToken()).pipe(Effect.flip)
77
+ expect(error.message).toContain("Please run 'jira auth login'")
78
+ })))
79
+ })
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { JiraApiClient, make } from "@knpkv/jira-api-client"
3
3
  import * as Effect from "effect/Effect"
4
+ import * as HttpClient from "effect/http/HttpClient"
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
4
6
  import * as Layer from "effect/Layer"
5
7
  import * as Redacted from "effect/Redacted"
6
- import * as HttpClient from "effect/unstable/http/HttpClient"
7
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
8
8
  import { stripEmails } from "../src/commands/version.js"
9
9
  import type { Version } from "../src/VersionService.js"
10
10
  import {
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
+ import { Command } from "effect/cli"
2
3
  import * as Effect from "effect/Effect"
3
4
  import * as Layer from "effect/Layer"
4
5
  import * as Ref from "effect/Ref"
5
6
  import * as Terminal from "effect/Terminal"
6
- import { Command } from "effect/unstable/cli"
7
7
  import { AttachmentService } from "../src/AttachmentService.js"
8
8
  import { issueCommand } from "../src/commands/issue.js"
9
9
  import { versionCommand } from "../src/commands/version.js"
@@ -55,15 +55,15 @@ const requireNonEmptyRedacted = (
55
55
  Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
56
56
 
57
57
  const SHOULD_RUN_INTEGRATION = Effect.runSync(
58
- Config.option(Config.string("JIRA_INTEGRATION")).pipe(
58
+ Config.option(Config.String("JIRA_INTEGRATION")).pipe(
59
59
  Effect.map((enabled) => Option.isSome(enabled) && envFlagEnabled(enabled.value))
60
60
  )
61
61
  )
62
62
 
63
63
  const SHOULD_RUN_ATTACHMENT_INTEGRATION = Effect.runSync(
64
64
  Effect.all([
65
- Config.option(Config.string("JIRA_INTEGRATION")),
66
- Config.option(Config.string("JIRA_ATTACHMENT_INTEGRATION"))
65
+ Config.option(Config.String("JIRA_INTEGRATION")),
66
+ Config.option(Config.String("JIRA_ATTACHMENT_INTEGRATION"))
67
67
  ]).pipe(
68
68
  Effect.map(([integration, attachment]) =>
69
69
  Option.isSome(integration) &&
@@ -75,10 +75,10 @@ const SHOULD_RUN_ATTACHMENT_INTEGRATION = Effect.runSync(
75
75
  )
76
76
 
77
77
  const readIntegrationConfig = Effect.gen(function*() {
78
- const baseUrl = yield* Config.option(Config.string("JIRA_BASE_URL"))
79
- const issueKey = yield* Config.option(Config.string("JIRA_ISSUE_KEY"))
80
- const email = yield* Config.string("JIRA_EMAIL")
81
- const apiKey = yield* Config.redacted("JIRA_API_KEY")
78
+ const baseUrl = yield* Config.option(Config.String("JIRA_BASE_URL"))
79
+ const issueKey = yield* Config.option(Config.String("JIRA_ISSUE_KEY"))
80
+ const email = yield* Config.String("JIRA_EMAIL")
81
+ const apiKey = yield* Config.Redacted("JIRA_API_KEY")
82
82
 
83
83
  return {
84
84
  baseUrl: nonEmptyOrDefault(baseUrl, DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { JiraApiClient, make } from "@knpkv/jira-api-client"
3
3
  import * as Effect from "effect/Effect"
4
+ import * as HttpClient from "effect/http/HttpClient"
5
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest"
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
4
7
  import * as Layer from "effect/Layer"
5
8
  import * as Redacted from "effect/Redacted"
6
9
  import * as Schema from "effect/Schema"
7
- import * as HttpClient from "effect/unstable/http/HttpClient"
8
- import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
9
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
10
10
  import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
11
11
  import { layer as VersionServiceLayer, VersionService } from "../src/VersionService.js"
12
12
 
@@ -3,8 +3,11 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
- "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
6
+ "@knpkv/atlassian-common/attachments":
7
+ new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
8
  "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
9
+ "@knpkv/atlassian-common/cli-auth":
10
+ new URL("../atlassian-common/src/cli-auth/index.ts", import.meta.url).pathname,
8
11
  "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
12
  "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
10
13
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
package/vitest.config.ts CHANGED
@@ -3,8 +3,11 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
- "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
6
+ "@knpkv/atlassian-common/attachments":
7
+ new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
8
  "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
9
+ "@knpkv/atlassian-common/cli-auth":
10
+ new URL("../atlassian-common/src/cli-auth/index.ts", import.meta.url).pathname,
8
11
  "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
12
  "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
10
13
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
@@ -1,7 +0,0 @@
1
- /**
2
- * HTTP Server factory layer using Node.js http module.
3
- *
4
- * @category Layers
5
- */
6
- export declare const HttpServerFactoryLive: import("effect/Layer").Layer<import("./oauthServer.js").HttpServerFactoryTag, never, never>;
7
- //# sourceMappingURL=NodeLayers.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"NodeLayers.d.ts","sourceRoot":"","sources":["../../src/internal/NodeLayers.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,6FAEjC,CAAA"}
@@ -1,15 +0,0 @@
1
- /**
2
- * Node.js-specific HTTP server factory — the only file importing `@effect/platform-node` server.
3
- *
4
- * @internal
5
- */
6
- import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
7
- import { createServer } from "node:http";
8
- import { makeHttpServerFactory } from "./oauthServer.js";
9
- /**
10
- * HTTP Server factory layer using Node.js http module.
11
- *
12
- * @category Layers
13
- */
14
- export const HttpServerFactoryLive = makeHttpServerFactory((options) => NodeHttpServer.layerServer(createServer, options));
15
- //# sourceMappingURL=NodeLayers.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"NodeLayers.js","sourceRoot":"","sources":["../../src/internal/NodeLayers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,cAAc,MAAM,sCAAsC,CAAA;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAExD;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,qBAAqB,CACxD,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,WAAW,CAAC,YAAY,EAAE,OAAO,CAAC,CAC/D,CAAA"}