@knpkv/jira-cli 1.4.0 → 1.5.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 (37) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +4 -1
  3. package/dist/JiraAuth.d.ts +26 -8
  4. package/dist/JiraAuth.d.ts.map +1 -1
  5. package/dist/JiraAuth.js +12 -303
  6. package/dist/JiraAuth.js.map +1 -1
  7. package/dist/commands/auth.d.ts +1 -12
  8. package/dist/commands/auth.d.ts.map +1 -1
  9. package/dist/commands/auth.js +4 -128
  10. package/dist/commands/auth.js.map +1 -1
  11. package/dist/commands/layers.d.ts +1 -1
  12. package/dist/commands/layers.d.ts.map +1 -1
  13. package/package.json +4 -4
  14. package/skills/jira/SKILL.md +1 -0
  15. package/src/JiraAuth.ts +28 -468
  16. package/src/commands/auth.ts +4 -167
  17. package/test/JiraAuth.test.ts +79 -0
  18. package/test/authCommand.test.ts +54 -0
  19. package/vitest.config.integration.ts +4 -1
  20. package/vitest.config.ts +4 -1
  21. package/dist/internal/NodeLayers.d.ts +0 -7
  22. package/dist/internal/NodeLayers.d.ts.map +0 -1
  23. package/dist/internal/NodeLayers.js +0 -15
  24. package/dist/internal/NodeLayers.js.map +0 -1
  25. package/dist/internal/oauthServer.d.ts +0 -72
  26. package/dist/internal/oauthServer.d.ts.map +0 -1
  27. package/dist/internal/oauthServer.js +0 -97
  28. package/dist/internal/oauthServer.js.map +0 -1
  29. package/dist/internal/openBrowser.d.ts +0 -10
  30. package/dist/internal/openBrowser.d.ts.map +0 -1
  31. package/dist/internal/openBrowser.js +0 -17
  32. package/dist/internal/openBrowser.js.map +0 -1
  33. package/src/internal/NodeLayers.ts +0 -17
  34. package/src/internal/oauthServer.ts +0 -187
  35. package/src/internal/openBrowser.ts +0 -31
  36. package/test/JiraAuthRefresh.test.ts +0 -269
  37. package/test/oauthServer.test.ts +0 -172
@@ -1,172 +1,9 @@
1
1
  /**
2
- * Auth subcommands: create, configure, login, logout, status.
3
- *
4
- * **Mental model**
5
- *
6
- * - Each command is a standalone `Command.make` that yields into `JiraAuth` service methods.
7
- * - `create` opens the Atlassian Developer Console; `configure` prompts for client ID/secret.
2
+ * `jira auth`: the shared Atlassian auth commands over {@link JiraAuth}.
8
3
  *
9
4
  * @internal
10
5
  */
11
- import { Argument as Args, Command, Flag as Options, Prompt } from "effect/cli"
12
- import * as Console from "effect/Console"
13
- import * as Effect from "effect/Effect"
14
- import * as Option from "effect/Option"
15
- import { openBrowser } from "../internal/openBrowser.js"
16
- import { JiraAuth } from "../JiraAuth.js"
17
-
18
- // === Auth create command ===
19
- const createCommand = Command.make("create", {}, () =>
20
- Effect.gen(function*() {
21
- yield* Console.log(`
22
- Creating OAuth app in Atlassian Developer Console...
23
-
24
- 1. Browser will open to create a new OAuth 2.0 (3LO) app
25
- 2. Enter app name (e.g., "Jira CLI")
26
- 3. After creation, go to "Permissions" and add:
27
-
28
- Jira API:
29
- - read:jira-work Search and read issues, comments, attachments
30
- - read:jira-user Read user info for assignee/reporter fields
31
-
32
- User Identity API:
33
- - read:me Get your account ID and email for auth
34
-
35
- 4. Go to "Authorization" and set callback URL:
36
- http://localhost:8585/callback
37
- 5. Go to "Settings" and copy Client ID and Secret
38
- 6. Run: jira auth configure --client-id <ID> --client-secret <SECRET>
39
- `)
40
- const url = "https://developer.atlassian.com/console/myapps/create-3lo-app/"
41
- yield* openBrowser(url).pipe(Effect.catch(() => Effect.void))
42
- })).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
43
-
44
- // === Auth configure command ===
45
- const clientIdOption = Options.String("client-id").pipe(
46
- Options.withDescription("OAuth client ID from Atlassian Developer Console"),
47
- Options.optional
48
- )
49
- const clientSecretOption = Options.String("client-secret").pipe(
50
- Options.withDescription("OAuth client secret"),
51
- Options.optional
52
- )
53
-
54
- const configureCommand = Command.make(
55
- "configure",
56
- { clientId: clientIdOption, clientSecret: clientSecretOption },
57
- ({ clientId, clientSecret }) =>
58
- Effect.gen(function*() {
59
- const auth = yield* JiraAuth
60
-
61
- const rawClientId = Option.isSome(clientId)
62
- ? clientId.value
63
- : yield* Prompt.String({ message: "Enter OAuth client ID:" })
64
- const rawClientSecret = Option.isSome(clientSecret)
65
- ? clientSecret.value
66
- : yield* Prompt.String({ message: "Enter OAuth client secret:" })
67
-
68
- yield* auth.configure({ clientId: rawClientId, clientSecret: rawClientSecret })
69
- yield* Console.log("OAuth configured. Run 'jira auth login' to authenticate.")
70
- })
71
- ).pipe(Command.withDescription("Configure OAuth client credentials"))
72
-
73
- // === Auth login command ===
74
- const siteOption = Options.String("site").pipe(
75
- Options.withDescription("Jira site URL to use (for accounts with multiple sites)"),
76
- Options.optional
77
- )
78
-
79
- const loginCommand = Command.make("login", { site: siteOption }, ({ site }) =>
80
- Effect.gen(function*() {
81
- const auth = yield* JiraAuth
82
- const result = yield* auth.login(Option.isSome(site) ? { siteUrl: site.value } : undefined)
83
- if (Array.isArray(result) && result.length > 0) {
84
- yield* Console.log("\nRe-run with --site to select a specific site.")
85
- }
86
- })).pipe(Command.withDescription("Authenticate with Atlassian via OAuth"))
87
-
88
- // === Auth logout command ===
89
- const logoutCommand = Command.make("logout", {}, () =>
90
- Effect.gen(function*() {
91
- const auth = yield* JiraAuth
92
- yield* auth.logout()
93
- yield* Console.log("Logged out")
94
- })).pipe(Command.withDescription("Remove stored authentication"))
95
-
96
- // === Auth status command ===
97
- const statusCommand = Command.make("status", {}, () =>
98
- Effect.gen(function*() {
99
- const auth = yield* JiraAuth
100
- const profile = yield* auth.getActiveProfile()
101
- if (profile) {
102
- const user = profile.token.user
103
- const account = user ? `${user.name} (${user.email})` : "unknown user"
104
- yield* Console.log(`Active profile: ${profile.name}`)
105
- yield* Console.log(`Account: ${account}`)
106
- yield* Console.log(`Site: ${profile.token.site_url}`)
107
- yield* Console.log(`Profile ID: ${profile.id}`)
108
- } else {
109
- yield* Console.log("Not logged in. Use 'jira auth login' to authenticate.")
110
- }
111
- })).pipe(Command.withDescription("Show authentication status"))
112
-
113
- // === Auth profiles command ===
114
- const profilesCommand = Command.make("profiles", {}, () =>
115
- Effect.gen(function*() {
116
- const auth = yield* JiraAuth
117
- const [profiles, active] = yield* Effect.all([auth.listProfiles(), auth.getActiveProfile()])
118
- if (profiles.length === 0) {
119
- yield* Console.log("No auth profiles. Use 'jira auth login' to authenticate.")
120
- return
121
- }
122
-
123
- for (const profile of profiles) {
124
- const marker = active?.id === profile.id ? "*" : " "
125
- yield* Console.log(`${marker} ${profile.id}`)
126
- yield* Console.log(` ${profile.name}`)
127
- yield* Console.log(` ${profile.token.site_url}`)
128
- }
129
- })).pipe(Command.withDescription("List stored auth profiles"))
130
-
131
- const profileArg = Args.String("profile").pipe(
132
- Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
133
- )
134
-
135
- // === Auth use command ===
136
- const useCommand = Command.make("use", { profile: profileArg }, ({ profile }) =>
137
- Effect.gen(function*() {
138
- const auth = yield* JiraAuth
139
- const selected = yield* auth.switchProfile(profile)
140
- if (!selected) {
141
- yield* Console.log(`Profile not found: ${profile}`)
142
- return
143
- }
144
- yield* Console.log(`Active profile: ${selected.name}`)
145
- })).pipe(Command.withDescription("Switch active auth profile"))
146
-
147
- // === Auth remove command ===
148
- const removeCommand = Command.make("remove", { profile: profileArg }, ({ profile }) =>
149
- Effect.gen(function*() {
150
- const auth = yield* JiraAuth
151
- const removed = yield* auth.removeProfile(profile)
152
- if (!removed) {
153
- yield* Console.log(`Profile not found: ${profile}`)
154
- return
155
- }
156
- yield* Console.log(`Removed profile: ${removed.name}`)
157
- })).pipe(Command.withDescription("Remove stored auth profile"))
6
+ import { makeAuthCommand } from "@knpkv/atlassian-common/cli-auth"
7
+ import { JiraAuth, jiraCliDescriptor } from "../JiraAuth.js"
158
8
 
159
- // === Auth command group ===
160
- export const authCommand = Command.make("auth").pipe(
161
- Command.withDescription("Manage OAuth authentication"),
162
- Command.withSubcommands([
163
- createCommand,
164
- configureCommand,
165
- loginCommand,
166
- logoutCommand,
167
- statusCommand,
168
- profilesCommand,
169
- useCommand,
170
- removeCommand
171
- ])
172
- )
9
+ export const authCommand = makeAuthCommand(JiraAuth, jiraCliDescriptor, { apiSection: "Jira API" })
@@ -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
+ })
@@ -0,0 +1,54 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * `jira auth create` must list every scope `jira auth login` requests: Atlassian rejects an
4
+ * authorization request naming a scope the app lacks, so a shorter list is a setup that cannot log in.
5
+ */
6
+ import { NodeServices } from "@effect/platform-node"
7
+ import { expect, it } from "@effect/vitest"
8
+ import { Command } from "effect/cli"
9
+ import * as Console from "effect/Console"
10
+ import * as Effect from "effect/Effect"
11
+ import { FetchHttpClient } from "effect/http"
12
+ import * as Layer from "effect/Layer"
13
+ import { ChildProcessSpawner } from "effect/process"
14
+ import * as Sink from "effect/Sink"
15
+ import * as Stream from "effect/Stream"
16
+ import { authCommand } from "../src/commands/auth.js"
17
+ import { jiraCliDescriptor, layer } from "../src/JiraAuth.js"
18
+
19
+ const browserOpens = ChildProcessSpawner.make(() =>
20
+ Effect.succeed(ChildProcessSpawner.makeHandle({
21
+ all: Stream.empty,
22
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
23
+ getInputFd: () => Sink.drain,
24
+ getOutputFd: () => Stream.empty,
25
+ isRunning: Effect.succeed(false),
26
+ kill: () => Effect.void,
27
+ pid: ChildProcessSpawner.ProcessId(1),
28
+ reref: Effect.void,
29
+ stderr: Stream.empty,
30
+ stdin: Sink.drain,
31
+ stdout: Stream.empty,
32
+ unref: Effect.succeed(Effect.void)
33
+ }))
34
+ )
35
+
36
+ it.effect("auth create lists every scope JiraAuth logs in with", () =>
37
+ Effect.gen(function*() {
38
+ const lines: Array<string> = []
39
+ const capture: Console.Console = Object.assign(Object.create(console), {
40
+ log: (...parts: ReadonlyArray<unknown>) => lines.push(parts.join(" "))
41
+ })
42
+ const cli = Command.runWith(Command.make("jira").pipe(Command.withSubcommands([authCommand])), { version: "0" })
43
+ const exit = yield* cli(["auth", "create"]).pipe(Effect.provideService(Console.Console, capture), Effect.exit)
44
+ expect(exit._tag).toBe("Success")
45
+ const output = lines.join("\n")
46
+ const permissions = jiraCliDescriptor.scopes.filter((scope) => scope !== "offline_access")
47
+ expect(permissions.length).toBeGreaterThan(2)
48
+ for (const scope of permissions) expect(output).toContain(scope)
49
+ expect(output).toContain("jira auth configure")
50
+ }).pipe(
51
+ // The command's browser launch sees the fake; the auth layer is built over real platform services.
52
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, browserOpens),
53
+ Effect.provide(layer.pipe(Layer.provideMerge(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer))))
54
+ ))
@@ -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"}
@@ -1,72 +0,0 @@
1
- /**
2
- * Local HTTP callback server for OAuth2 authorization code capture.
3
- *
4
- * **Mental model**
5
- *
6
- * - **Scope-owned lifecycle**: {@link startCallbackServer} returns a `codePromise`
7
- * (Deferred). The server validates the CSRF `state` parameter, resolves the
8
- * Deferred with the authorization code, and stops when its enclosing scope closes.
9
- * - **Port auto-discovery**: Tries default port 8585, increments on conflict.
10
- *
11
- * @internal
12
- */
13
- import { OAuthError } from "@knpkv/atlassian-common/auth";
14
- import * as Context from "effect/Context";
15
- import * as Effect from "effect/Effect";
16
- import { HttpServer } from "effect/http";
17
- import type * as HttpServerError from "effect/http/HttpServerError";
18
- import * as Layer from "effect/Layer";
19
- import type * as Scope from "effect/Scope";
20
- /**
21
- * Factory service for creating HTTP servers.
22
- * This allows mocking the server creation in tests.
23
- *
24
- * @category Services
25
- */
26
- export interface HttpServerFactory {
27
- readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>;
28
- }
29
- export interface CallbackServerListenOptions {
30
- readonly host: "localhost";
31
- readonly port: number;
32
- }
33
- export declare const callbackServerListenOptions: (port: number) => CallbackServerListenOptions;
34
- export declare const callbackUrl: (port: number) => string;
35
- declare const HttpServerFactoryTag_base: Context.ServiceClass<HttpServerFactoryTag, "@knpkv/jira-cli/HttpServerFactory", HttpServerFactory>;
36
- /**
37
- * Tag for the HttpServerFactory service.
38
- *
39
- * @category Services
40
- */
41
- export declare class HttpServerFactoryTag extends HttpServerFactoryTag_base {
42
- }
43
- /**
44
- * Create a HttpServerFactory layer from a layer factory function.
45
- * This allows injecting platform-specific implementations.
46
- *
47
- * @param createLayerFn - Function that creates HttpServer layer for a given port
48
- * @returns Layer providing HttpServerFactory
49
- *
50
- * @category Layers
51
- */
52
- export declare const makeHttpServerFactory: (createLayerFn: (options: CallbackServerListenOptions) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>) => Layer.Layer<HttpServerFactoryTag>;
53
- /**
54
- * Result from the OAuth callback server.
55
- */
56
- export interface CallbackServerResult {
57
- /** Promise that resolves with the authorization code */
58
- readonly codePromise: Effect.Effect<string, OAuthError>;
59
- /** The port the server is listening on */
60
- readonly port: number;
61
- }
62
- /**
63
- * Start a local HTTP server to receive OAuth callback.
64
- *
65
- * @param expectedState - The state parameter to verify against CSRF
66
- * @returns Server control interface with code promise and port
67
- *
68
- * @category OAuth
69
- */
70
- export declare const startCallbackServer: (expectedState: string) => Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag | Scope.Scope>;
71
- export {};
72
- //# sourceMappingURL=oauthServer.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"oauthServer.d.ts","sourceRoot":"","sources":["../../src/internal/oauthServer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAA;AACzD,OAAO,KAAK,OAAO,MAAM,gBAAgB,CAAA;AAEzC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,EAAc,UAAU,EAAsB,MAAM,aAAa,CAAA;AACxE,OAAO,KAAK,KAAK,eAAe,MAAM,6BAA6B,CAAA;AACnE,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAGrC,OAAO,KAAK,KAAK,KAAK,MAAM,cAAc,CAAA;AAK1C;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,iBAAiB,EAAE,CAAC,OAAO,EAAE,2BAA2B,KAAK,KAAK,CAAC,KAAK,CAC/E,UAAU,CAAC,UAAU,EACrB,eAAe,CAAC,UAAU,EAC1B,KAAK,CACN,CAAA;CACF;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,eAAO,MAAM,2BAA2B,SAAU,MAAM,KAAG,2BAGzD,CAAA;AAEF,eAAO,MAAM,WAAW,SAAU,MAAM,KAAG,MAA6C,CAAA;;AAExF;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,yBAGF;CAAG;AAE3C;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,kBACjB,CACb,OAAO,EAAE,2BAA2B,KACjC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,EAAE,eAAe,CAAC,UAAU,EAAE,KAAK,CAAC,KACzE,KAAK,CAAC,KAAK,CAAC,oBAAoB,CAG/B,CAAA;AAEJ;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,wDAAwD;IACxD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IACvD,0CAA0C;IAC1C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAQD;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,kBACf,MAAM,KACpB,MAAM,CAAC,MAAM,CAAC,oBAAoB,EAAE,UAAU,EAAE,oBAAoB,GAAG,KAAK,CAAC,KAAK,CAgFjF,CAAA"}
@@ -1,97 +0,0 @@
1
- /**
2
- * Local HTTP callback server for OAuth2 authorization code capture.
3
- *
4
- * **Mental model**
5
- *
6
- * - **Scope-owned lifecycle**: {@link startCallbackServer} returns a `codePromise`
7
- * (Deferred). The server validates the CSRF `state` parameter, resolves the
8
- * Deferred with the authorization code, and stops when its enclosing scope closes.
9
- * - **Port auto-discovery**: Tries default port 8585, increments on conflict.
10
- *
11
- * @internal
12
- */
13
- import { OAuthError } from "@knpkv/atlassian-common/auth";
14
- import * as Context from "effect/Context";
15
- import * as Deferred from "effect/Deferred";
16
- import * as Effect from "effect/Effect";
17
- import { HttpRouter, HttpServer, HttpServerResponse } from "effect/http";
18
- import * as Layer from "effect/Layer";
19
- import { NetAddress } from "effect/net";
20
- import * as Schema from "effect/Schema";
21
- const DEFAULT_PORT = 8585;
22
- const MAX_PORT = 8594;
23
- export const callbackServerListenOptions = (port) => ({
24
- host: "localhost",
25
- port
26
- });
27
- export const callbackUrl = (port) => `http://localhost:${port}/callback`;
28
- /**
29
- * Tag for the HttpServerFactory service.
30
- *
31
- * @category Services
32
- */
33
- export class HttpServerFactoryTag extends Context.Service()("@knpkv/jira-cli/HttpServerFactory") {
34
- }
35
- /**
36
- * Create a HttpServerFactory layer from a layer factory function.
37
- * This allows injecting platform-specific implementations.
38
- *
39
- * @param createLayerFn - Function that creates HttpServer layer for a given port
40
- * @returns Layer providing HttpServerFactory
41
- *
42
- * @category Layers
43
- */
44
- export const makeHttpServerFactory = (createLayerFn) => Layer.succeed(HttpServerFactoryTag, {
45
- createServerLayer: createLayerFn
46
- });
47
- const AddressInUseCause = Schema.Struct({
48
- code: Schema.Literal("EADDRINUSE")
49
- });
50
- const isAddressInUse = (error) => Schema.is(AddressInUseCause)(error.cause);
51
- /**
52
- * Start a local HTTP server to receive OAuth callback.
53
- *
54
- * @param expectedState - The state parameter to verify against CSRF
55
- * @returns Server control interface with code promise and port
56
- *
57
- * @category OAuth
58
- */
59
- export const startCallbackServer = (expectedState) => Effect.gen(function* () {
60
- const factory = yield* HttpServerFactoryTag;
61
- const deferred = yield* Deferred.make();
62
- const serverScope = yield* Effect.scope;
63
- const buildServerContext = (port) => Layer.buildWithScope(factory.createServerLayer(callbackServerListenOptions(port)), serverScope).pipe(Effect.map((context) => ({ context, port })), Effect.catchIf((error) => isAddressInUse(error) && port < MAX_PORT, () => buildServerContext(port + 1)), Effect.mapError((cause) => new OAuthError({ step: "authorize", cause })));
64
- const { context: serverContext } = yield* buildServerContext(DEFAULT_PORT);
65
- const server = Context.get(serverContext, HttpServer.HttpServer);
66
- const port = yield* (NetAddress.isInetAddress(server.address)
67
- ? Effect.succeed(server.address.port)
68
- : Effect.fail(new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })));
69
- const router = yield* HttpRouter.make;
70
- yield* router.add("GET", "/callback", (req) => Effect.gen(function* () {
71
- const url = new URL(req.url, callbackUrl(port));
72
- const code = url.searchParams.get("code");
73
- const state = url.searchParams.get("state");
74
- const error = url.searchParams.get("error");
75
- const errorDescription = url.searchParams.get("error_description");
76
- if (state !== expectedState) {
77
- return HttpServerResponse.html("<html><body><h1>Security Error</h1><p>State verification failed.</p></body></html>").pipe(HttpServerResponse.setStatus(403));
78
- }
79
- if (error) {
80
- yield* Deferred.fail(deferred, new OAuthError({ step: "authorize", cause: errorDescription ?? error }));
81
- return HttpServerResponse.html("<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>");
82
- }
83
- if (!code) {
84
- yield* Deferred.fail(deferred, new OAuthError({ step: "authorize", cause: "No authorization code received" }));
85
- return HttpServerResponse.html("<html><body><h1>Error</h1><p>No authorization code received.</p></body></html>");
86
- }
87
- yield* Deferred.succeed(deferred, code);
88
- return HttpServerResponse.html("<html><body><h1>Success!</h1><p>You can close this window and return to the terminal.</p></body></html>");
89
- }));
90
- const app = router.asHttpEffect();
91
- yield* HttpServer.serveEffect(app).pipe(Effect.provide(serverContext), Effect.forkScoped);
92
- return {
93
- codePromise: Deferred.await(deferred),
94
- port
95
- };
96
- });
97
- //# sourceMappingURL=oauthServer.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"oauthServer.js","sourceRoot":"","sources":["../../src/internal/oauthServer.ts"],"names":[],"mappings":"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"}
@@ -1,10 +0,0 @@
1
- /**
2
- * Cross-platform browser launcher backed by Effect v4 child process services.
3
- *
4
- * @internal
5
- */
6
- import * as Effect from "effect/Effect";
7
- import type * as PlatformError from "effect/PlatformError";
8
- import { ChildProcessSpawner } from "effect/process";
9
- export declare const openBrowser: (url: string) => Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
10
- //# sourceMappingURL=openBrowser.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"openBrowser.d.ts","sourceRoot":"","sources":["../../src/internal/openBrowser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,aAAa,MAAM,sBAAsB,CAAA;AAC1D,OAAO,EAAgB,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAiBlE,eAAO,MAAM,WAAW,QACjB,MAAM,KACV,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,aAAa,CAAC,aAAa,EAAE,mBAAmB,CAAC,mBAAmB,CAIxF,CAAA"}
@@ -1,17 +0,0 @@
1
- /**
2
- * Cross-platform browser launcher backed by Effect v4 child process services.
3
- *
4
- * @internal
5
- */
6
- import * as Effect from "effect/Effect";
7
- import { ChildProcess, ChildProcessSpawner } from "effect/process";
8
- const run = (command, args) => Effect.gen(function* () {
9
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10
- yield* spawner.exitCode(ChildProcess.make(command, args, {
11
- stdin: "ignore",
12
- stdout: "ignore",
13
- stderr: "ignore"
14
- }));
15
- });
16
- export const openBrowser = (url) => run("open", [url]).pipe(Effect.catch(() => run("xdg-open", [url])), Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url])));
17
- //# sourceMappingURL=openBrowser.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"openBrowser.js","sourceRoot":"","sources":["../../src/internal/openBrowser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAElE,MAAM,GAAG,GAAG,CACV,OAAe,EACf,IAA2B,EACgE,EAAE,CAC7F,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IAClB,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,mBAAmB,CAAC,mBAAmB,CAAA;IAC9D,KAAK,CAAC,CAAC,OAAO,CAAC,QAAQ,CACrB,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE;QAC/B,KAAK,EAAE,QAAQ;QACf,MAAM,EAAE,QAAQ;QAChB,MAAM,EAAE,QAAQ;KACjB,CAAC,CACH,CAAA;AACH,CAAC,CAAC,CAAA;AAEJ,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,GAAW,EACgF,EAAE,CAC7F,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CACrB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAC1C,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC,CAAC,CAC9E,CAAA"}
@@ -1,17 +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
- /**
11
- * HTTP Server factory layer using Node.js http module.
12
- *
13
- * @category Layers
14
- */
15
- export const HttpServerFactoryLive = makeHttpServerFactory(
16
- (options) => NodeHttpServer.layerServer(createServer, options)
17
- )