@knpkv/atlassian-common 1.4.1 → 1.6.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.
- package/CHANGELOG.md +14 -0
- package/README.md +32 -1
- package/dist/Brand.d.ts +2 -2
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js.map +1 -1
- package/dist/Hash.js +2 -2
- package/dist/Hash.js.map +1 -1
- package/dist/auth/OAuthEndpoints.js +3 -3
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +1 -1
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +8 -8
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli-auth/AtlassianCliAuth.d.ts +102 -0
- package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
- package/dist/cli-auth/AtlassianCliAuth.js +298 -0
- package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
- package/dist/cli-auth/index.d.ts +13 -0
- package/dist/cli-auth/index.d.ts.map +1 -0
- package/dist/cli-auth/index.js +13 -0
- package/dist/cli-auth/index.js.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.js +17 -0
- package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
- package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
- package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
- package/dist/cli-auth/internal/oauthServer.js +94 -0
- package/dist/cli-auth/internal/oauthServer.js.map +1 -0
- package/dist/cli-auth/openBrowser.d.ts +16 -0
- package/dist/cli-auth/openBrowser.d.ts.map +1 -0
- package/dist/cli-auth/openBrowser.js +30 -0
- package/dist/cli-auth/openBrowser.js.map +1 -0
- package/dist/config/ConfigPaths.js +2 -2
- package/dist/config/ProfileManager.d.ts +1 -1
- package/dist/config/ProfileManager.d.ts.map +1 -1
- package/dist/config/ProfileManager.js +3 -3
- package/dist/config/ProfileManager.js.map +1 -1
- package/package.json +11 -7
- package/src/Brand.ts +5 -5
- package/src/Hash.ts +2 -2
- package/src/auth/OAuthEndpoints.ts +3 -3
- package/src/auth/OAuthOperations.ts +14 -24
- package/src/bin.ts +2 -2
- package/src/cli-auth/AtlassianCliAuth.ts +530 -0
- package/src/cli-auth/index.ts +18 -0
- package/src/cli-auth/internal/NodeLayers.ts +25 -0
- package/src/cli-auth/internal/oauthServer.ts +168 -0
- package/src/cli-auth/openBrowser.ts +47 -0
- package/src/config/ConfigPaths.ts +3 -3
- package/src/config/ProfileManager.ts +3 -3
- package/test/ApiUpdateWorkflows.test.ts +286 -10
- package/test/OAuthOperations.test.ts +2 -2
- package/test/ProfileManager.test.ts +1 -1
- package/test/cliAuth.guardrails.test.ts +76 -0
- package/test/cliAuth.login.test.ts +319 -0
- package/test/cliAuth.oauthServer.test.ts +276 -0
- package/test/cliAuth.openBrowser.test.ts +70 -0
- package/test/cliAuth.refresh.test.ts +304 -0
|
@@ -0,0 +1,168 @@
|
|
|
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 up to 8594.
|
|
10
|
+
* - **Ready before return**: the handler is installed before the port is handed back,
|
|
11
|
+
* so the caller never advertises a callback URL nothing answers.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
import * as Context from "effect/Context"
|
|
16
|
+
import * as Deferred from "effect/Deferred"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/http"
|
|
19
|
+
import type * as HttpServerError from "effect/http/HttpServerError"
|
|
20
|
+
import * as Layer from "effect/Layer"
|
|
21
|
+
import { NetAddress } from "effect/net"
|
|
22
|
+
import * as Schema from "effect/Schema"
|
|
23
|
+
import * as Scope from "effect/Scope"
|
|
24
|
+
import { OAuthError } from "../../auth/OAuthErrors.js"
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PORT = 8585
|
|
27
|
+
const MAX_PORT = 8594
|
|
28
|
+
type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates the platform HTTP server for one listen attempt. The Node adapter is
|
|
32
|
+
* {@link HttpServerFactoryLive}; tests substitute ephemeral or failing servers.
|
|
33
|
+
*/
|
|
34
|
+
export interface HttpServerFactory {
|
|
35
|
+
readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<
|
|
36
|
+
HttpServer.HttpServer,
|
|
37
|
+
HttpServerError.ServeError,
|
|
38
|
+
never
|
|
39
|
+
>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CallbackServerListenOptions {
|
|
43
|
+
readonly host: "localhost"
|
|
44
|
+
readonly port: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const callbackServerListenOptions = (port: number): CallbackServerListenOptions => ({
|
|
48
|
+
host: "localhost",
|
|
49
|
+
port
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const callbackUrl = (port: number): string => `http://localhost:${port}/callback`
|
|
53
|
+
|
|
54
|
+
export class HttpServerFactoryTag extends Context.Service<
|
|
55
|
+
HttpServerFactoryTag,
|
|
56
|
+
HttpServerFactory
|
|
57
|
+
>()("@knpkv/atlassian-common/cli-auth/HttpServerFactory") {}
|
|
58
|
+
|
|
59
|
+
/** Builds a {@link HttpServerFactoryTag} layer from a per-attempt server layer. */
|
|
60
|
+
export const makeHttpServerFactory = (
|
|
61
|
+
createLayerFn: (
|
|
62
|
+
options: CallbackServerListenOptions
|
|
63
|
+
) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
64
|
+
): Layer.Layer<HttpServerFactoryTag> =>
|
|
65
|
+
Layer.succeed(HttpServerFactoryTag, {
|
|
66
|
+
createServerLayer: createLayerFn
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export interface CallbackServerResult {
|
|
70
|
+
/** Resolves with the authorization code, or fails with the provider's error. */
|
|
71
|
+
readonly codePromise: Effect.Effect<string, OAuthError>
|
|
72
|
+
/** The port the server is listening on */
|
|
73
|
+
readonly port: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const AddressInUseCause = Schema.Struct({
|
|
77
|
+
code: Schema.Literal("EADDRINUSE")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const isAddressInUse = (error: HttpServerError.ServeError): boolean => Schema.is(AddressInUseCause)(error.cause)
|
|
81
|
+
|
|
82
|
+
const page = (heading: string, body: string) =>
|
|
83
|
+
HttpServerResponse.html(`<html><body><h1>${heading}</h1><p>${body}</p></body></html>`)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start a local HTTP server to receive the OAuth callback for `expectedState`.
|
|
87
|
+
*
|
|
88
|
+
* Fails with one `OAuthError` (step `authorize`) when no port in range can be
|
|
89
|
+
* bound; the server stops when the enclosing scope closes.
|
|
90
|
+
*/
|
|
91
|
+
export const startCallbackServer = (
|
|
92
|
+
expectedState: string
|
|
93
|
+
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag | Scope.Scope> =>
|
|
94
|
+
Effect.gen(function*() {
|
|
95
|
+
const factory = yield* HttpServerFactoryTag
|
|
96
|
+
const deferred = yield* Deferred.make<string, OAuthError>()
|
|
97
|
+
const scope = yield* Effect.scope
|
|
98
|
+
|
|
99
|
+
// Map to OAuthError once, outside the retry, so a run of occupied ports
|
|
100
|
+
// does not nest one OAuthError inside another.
|
|
101
|
+
const buildServer = (port: number): Effect.Effect<HttpServerInstance, HttpServerError.ServeError> =>
|
|
102
|
+
Layer.build(factory.createServerLayer(callbackServerListenOptions(port))).pipe(
|
|
103
|
+
Scope.provide(scope),
|
|
104
|
+
Effect.map((context) => Context.get(context, HttpServer.HttpServer)),
|
|
105
|
+
Effect.catchIf(
|
|
106
|
+
(error) => isAddressInUse(error) && port < MAX_PORT,
|
|
107
|
+
() => buildServer(port + 1)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const server = yield* buildServer(DEFAULT_PORT).pipe(
|
|
112
|
+
Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!NetAddress.isInetAddress(server.address)) {
|
|
116
|
+
return yield* new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })
|
|
117
|
+
}
|
|
118
|
+
const port = server.address.port
|
|
119
|
+
|
|
120
|
+
const router = yield* HttpRouter.make
|
|
121
|
+
yield* router.add(
|
|
122
|
+
"GET",
|
|
123
|
+
"/callback",
|
|
124
|
+
Effect.gen(function*() {
|
|
125
|
+
const req = yield* HttpServerRequest.HttpServerRequest
|
|
126
|
+
const url = new URL(req.url, callbackUrl(port))
|
|
127
|
+
const code = url.searchParams.get("code")
|
|
128
|
+
const state = url.searchParams.get("state")
|
|
129
|
+
const error = url.searchParams.get("error")
|
|
130
|
+
const errorDescription = url.searchParams.get("error_description")
|
|
131
|
+
|
|
132
|
+
if (state !== expectedState) {
|
|
133
|
+
return page("Security Error", "State verification failed.").pipe(HttpServerResponse.setStatus(403))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (error !== null && error !== "") {
|
|
137
|
+
// An empty description is no description: report the error code instead.
|
|
138
|
+
yield* Deferred.fail(
|
|
139
|
+
deferred,
|
|
140
|
+
new OAuthError({
|
|
141
|
+
step: "authorize",
|
|
142
|
+
cause: errorDescription === null || errorDescription === "" ? error : errorDescription
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
return page("Authorization Failed", "You can close this window.")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (code === null || code === "") {
|
|
149
|
+
yield* Deferred.fail(deferred, new OAuthError({ step: "authorize", cause: "No authorization code received" }))
|
|
150
|
+
return page("Error", "No authorization code received.")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
yield* Deferred.succeed(deferred, code)
|
|
154
|
+
return page("Success!", "You can close this window and return to the terminal.")
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Installs the handler in this scope and returns; a failure to install
|
|
159
|
+
// surfaces here rather than leaving the caller waiting on a dead server.
|
|
160
|
+
yield* HttpServer.serveEffect(router.asHttpEffect()).pipe(
|
|
161
|
+
Effect.provideService(HttpServer.HttpServer, server)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
codePromise: Deferred.await(deferred),
|
|
166
|
+
port
|
|
167
|
+
}
|
|
168
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform browser launcher backed by Effect child process services.
|
|
3
|
+
*
|
|
4
|
+
* Tries `open`, then `xdg-open`, then `rundll32.exe`, stopping at the first that
|
|
5
|
+
* exits 0. A launcher that is missing or exits non-zero falls through to the next;
|
|
6
|
+
* when none succeeds the last failure is reported, typed, so the caller decides
|
|
7
|
+
* whether a browser that never opened is fatal.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import * as Data from "effect/Data"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
14
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
15
|
+
|
|
16
|
+
/** A launcher ran but reported failure (for example `xdg-open` with no browser on a headless host). */
|
|
17
|
+
export class BrowserOpenError extends Data.TaggedError("BrowserOpenError")<{
|
|
18
|
+
readonly command: string
|
|
19
|
+
readonly exitCode: number
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
const run = (
|
|
23
|
+
command: string,
|
|
24
|
+
args: ReadonlyArray<string>
|
|
25
|
+
): Effect.Effect<void, BrowserOpenError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
26
|
+
Effect.gen(function*() {
|
|
27
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
28
|
+
const exitCode = yield* spawner.exitCode(
|
|
29
|
+
ChildProcess.make(command, args, {
|
|
30
|
+
stdin: "ignore",
|
|
31
|
+
stdout: "ignore",
|
|
32
|
+
stderr: "ignore"
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
if (exitCode !== 0) {
|
|
36
|
+
return yield* new BrowserOpenError({ command, exitCode })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** Open `url` in the user's browser. */
|
|
41
|
+
export const openBrowser = (
|
|
42
|
+
url: string
|
|
43
|
+
): Effect.Effect<void, BrowserOpenError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
44
|
+
run("open", [url]).pipe(
|
|
45
|
+
Effect.catch(() => run("xdg-open", [url])),
|
|
46
|
+
Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url]))
|
|
47
|
+
)
|
|
@@ -61,8 +61,8 @@ export class HomeDirectoryTag extends Context.Service<
|
|
|
61
61
|
HomeDirectory
|
|
62
62
|
>()("@knpkv/atlassian-common/HomeDirectory") {}
|
|
63
63
|
|
|
64
|
-
const HomeConfig = Config.option(Config.
|
|
65
|
-
Config.orElse(() => Config.option(Config.
|
|
64
|
+
const HomeConfig = Config.option(Config.String("HOME")).pipe(
|
|
65
|
+
Config.orElse(() => Config.option(Config.String("USERPROFILE")))
|
|
66
66
|
)
|
|
67
67
|
|
|
68
68
|
/**
|
|
@@ -84,7 +84,7 @@ export const HomeDirectoryLive: Layer.Layer<HomeDirectoryTag> = Layer.succeed(
|
|
|
84
84
|
}
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
const XdgConfigHome = Config.option(Config.
|
|
87
|
+
const XdgConfigHome = Config.option(Config.String("XDG_CONFIG_HOME"))
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* XDG config directory for Atlassian tools.
|
|
@@ -7,10 +7,10 @@ import * as Clock from "effect/Clock"
|
|
|
7
7
|
import * as Data from "effect/Data"
|
|
8
8
|
import * as Effect from "effect/Effect"
|
|
9
9
|
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import type { HttpClient } from "effect/http"
|
|
10
11
|
import * as Path from "effect/Path"
|
|
11
12
|
import type * as PlatformError from "effect/PlatformError"
|
|
12
13
|
import * as Schema from "effect/Schema"
|
|
13
|
-
import type { HttpClient } from "effect/unstable/http"
|
|
14
14
|
import { OAuthError } from "../auth/OAuthErrors.js"
|
|
15
15
|
import { refreshToken } from "../auth/OAuthOperations.js"
|
|
16
16
|
import {
|
|
@@ -227,7 +227,7 @@ export const useProfileForAllTools = (
|
|
|
227
227
|
).find((profile): profile is AuthProfile =>
|
|
228
228
|
profile !== null
|
|
229
229
|
)
|
|
230
|
-
if (
|
|
230
|
+
if (selected === undefined) return yield* new ProfileNotFoundError({ selector })
|
|
231
231
|
|
|
232
232
|
yield* Effect.forEach(stores, ([tool, store]) => {
|
|
233
233
|
const matching = findProfile(store.profiles, selector) ?? findProfile(store.profiles, selected.id)
|
|
@@ -298,7 +298,7 @@ export const refreshActiveProfiles = (
|
|
|
298
298
|
if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
|
|
299
299
|
const config = yield* loadOAuthConfig(storeName)
|
|
300
300
|
if (!config) {
|
|
301
|
-
return yield*
|
|
301
|
+
return yield* new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id })
|
|
302
302
|
}
|
|
303
303
|
// Rotating refresh tokens: the grant consumes the stored token
|
|
304
304
|
// server-side and the response carries its replacement, so an interrupt
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
2
|
-
import {
|
|
2
|
+
import { expect, layer } from "@effect/vitest"
|
|
3
|
+
import * as Array from "effect/Array"
|
|
3
4
|
import * as Effect from "effect/Effect"
|
|
4
5
|
import * as FileSystem from "effect/FileSystem"
|
|
5
6
|
import * as Path from "effect/Path"
|
|
6
7
|
import * as Predicate from "effect/Predicate"
|
|
7
|
-
import
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
8
9
|
import { parse } from "yaml"
|
|
9
10
|
|
|
10
11
|
const isRecord = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Record<string, Schema.Json> =>
|
|
@@ -36,15 +37,290 @@ const dependencyClosureDiagnostics = (
|
|
|
36
37
|
})
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const PackageManifest = Schema.Struct({
|
|
41
|
+
name: Schema.String,
|
|
42
|
+
private: Schema.optional(Schema.Boolean),
|
|
43
|
+
exports: Schema.optional(Schema.Json)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Public generated subpaths or generated export targets declare the contract;
|
|
47
|
+
// package names and consumer dependencies do not.
|
|
48
|
+
const exposesGeneratedContract = (exports: Schema.Json): boolean => {
|
|
49
|
+
if (Predicate.isString(exports)) return /(?:^|\/)generated(?:\/|$)/u.test(exports)
|
|
50
|
+
if (Array.isArray(exports)) return exports.some(exposesGeneratedContract)
|
|
51
|
+
if (!isRecord(exports)) return false
|
|
52
|
+
return Object.entries(exports).some(([name, target]) =>
|
|
53
|
+
/^\.\/generated(?:\/|$)/u.test(name) || exposesGeneratedContract(target)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const patchGuidanceDiagnostics = (
|
|
58
|
+
source: string,
|
|
59
|
+
generatedClientNames: ReadonlySet<string>
|
|
60
|
+
): ReadonlyArray<string> => {
|
|
61
|
+
const workflow: unknown = parse(source)
|
|
62
|
+
if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
|
|
63
|
+
|
|
64
|
+
return Object.values(workflow.jobs).flatMap((job) => {
|
|
65
|
+
if (!isRecord(job) || !Array.isArray(job.steps)) return []
|
|
66
|
+
const steps = job.steps.filter(isRecord)
|
|
67
|
+
const bodies = steps.flatMap((step) =>
|
|
68
|
+
isRecord(step.with) && Predicate.isString(step.with.body) ? [step.with.body.replace(/\s+/gu, " ")] : []
|
|
69
|
+
)
|
|
70
|
+
const hasUnchangedContractGuidance = bodies.some((body) => /\bpublic contract is unchanged\b/iu.test(body))
|
|
71
|
+
const guidanceDiagnostics = bodies.flatMap((body) =>
|
|
72
|
+
body.split(/[.!?]/u).flatMap((sentence) =>
|
|
73
|
+
/\bpatch (?:is appropriate|(?:only )?when)\b/iu.test(sentence)
|
|
74
|
+
&& !/\bpublic contract is unchanged\b/iu.test(sentence)
|
|
75
|
+
? ["Patch release guidance must require an unchanged public contract"]
|
|
76
|
+
: []
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
const defaultDiagnostics = steps.flatMap((step) => {
|
|
80
|
+
if (!Predicate.isString(step.run) || !/cat\s+>\s+\.changeset\//u.test(step.run)) return []
|
|
81
|
+
const heredocs = [...step.run.matchAll(
|
|
82
|
+
/cat\s+>\s+\.changeset\/[^\s]+\.md\s+<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1(?:\n|$)/gu
|
|
83
|
+
)]
|
|
84
|
+
if (heredocs.length === 0) return ["Generated changeset heredoc could not be inspected"]
|
|
85
|
+
return heredocs.flatMap((heredoc) => {
|
|
86
|
+
const frontmatter = heredoc[2]?.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u)?.[1]
|
|
87
|
+
if (frontmatter === undefined) return ["Generated changeset must contain release frontmatter"]
|
|
88
|
+
const releases: unknown = parse(frontmatter)
|
|
89
|
+
if (!isRecord(releases)) return ["Generated changeset must contain release frontmatter"]
|
|
90
|
+
return Object.entries(releases).flatMap(([name, release]) =>
|
|
91
|
+
generatedClientNames.has(name) && release === "patch" && !hasUnchangedContractGuidance
|
|
92
|
+
? [`Generated client ${name} defaults to patch without unchanged-contract guidance`]
|
|
93
|
+
: []
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
return [...guidanceDiagnostics, ...defaultDiagnostics]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generated changesets must not reuse a fixed path: the workflow regenerates the PR branch
|
|
102
|
+
// from the base, so a fixed name overwrites a still-pending changeset from an earlier update.
|
|
103
|
+
const changesetPathDiagnostics = (source: string): ReadonlyArray<string> =>
|
|
104
|
+
[...source.matchAll(/cat\s+>\s+(\.changeset\/[^\s]+\.md)/gu)].flatMap(([, changesetPath]) =>
|
|
105
|
+
changesetPath !== undefined && changesetPath.includes("${GITHUB_RUN_ID}")
|
|
106
|
+
? []
|
|
107
|
+
: [`Generated changeset ${changesetPath ?? ""} must be unique per workflow run`]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const releaseWorkflow = (name: string, release: string, guidance = "Review the generated API.") => `
|
|
111
|
+
jobs:
|
|
112
|
+
update:
|
|
113
|
+
steps:
|
|
114
|
+
- name: Create changeset
|
|
115
|
+
run: |
|
|
116
|
+
cat > .changeset/api-update.md <<'CHANGESET'
|
|
117
|
+
---
|
|
118
|
+
"${name}": ${release}
|
|
119
|
+
---
|
|
120
|
+
Update generated API schemas.
|
|
121
|
+
CHANGESET
|
|
122
|
+
- name: Create pull request
|
|
123
|
+
with:
|
|
124
|
+
body: ${guidance}
|
|
125
|
+
`
|
|
126
|
+
|
|
127
|
+
const inspectApiUpdateWorkflows = Effect.fn("ApiUpdateWorkflows.inspectApiUpdateWorkflows")(function*(root: string) {
|
|
128
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
129
|
+
const path = yield* Path.Path
|
|
130
|
+
const packagesRoot = path.join(root, "packages")
|
|
131
|
+
const generatedClientNames = new Set<string>()
|
|
132
|
+
for (const directory of yield* fileSystem.readDirectory(packagesRoot)) {
|
|
133
|
+
if (["vendor", "generated", "node_modules"].includes(directory)) continue
|
|
134
|
+
const packageRoot = path.join(packagesRoot, directory)
|
|
135
|
+
if ((yield* fileSystem.stat(packageRoot)).type !== "Directory") continue
|
|
136
|
+
const manifestPath = path.join(packageRoot, "package.json")
|
|
137
|
+
if (!(yield* fileSystem.exists(manifestPath))) continue
|
|
138
|
+
const manifest = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest))(
|
|
139
|
+
yield* fileSystem.readFileString(manifestPath)
|
|
140
|
+
)
|
|
141
|
+
if (manifest.private !== true && manifest.exports !== undefined && exposesGeneratedContract(manifest.exports)) {
|
|
142
|
+
generatedClientNames.add(manifest.name)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const workflowRoot = path.join(root, ".github/workflows")
|
|
146
|
+
const diagnostics: Array<string> = []
|
|
147
|
+
for (const name of (yield* fileSystem.readDirectory(workflowRoot)).toSorted()) {
|
|
148
|
+
if (!/-api-update\.ya?ml$/u.test(name)) continue
|
|
149
|
+
const source = yield* fileSystem.readFileString(path.join(workflowRoot, name))
|
|
150
|
+
for (const diagnostic of patchGuidanceDiagnostics(source, generatedClientNames)) {
|
|
151
|
+
diagnostics.push(`${name}: ${diagnostic}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return diagnostics
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const loadWorkflow = Effect.fn("ApiUpdateWorkflows.loadWorkflow")(function*(name: string) {
|
|
158
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
159
|
+
const path = yield* Path.Path
|
|
160
|
+
const workflowPath = yield* path.fromFileUrl(new URL(`../../../.github/workflows/${name}`, import.meta.url))
|
|
161
|
+
return yield* fileSystem.readFileString(workflowPath)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
layer(NodeServices.layer)("API update workflow build closure", (it) => {
|
|
165
|
+
const fixtureClients = new Set(["@knpkv/jira-api-client"])
|
|
166
|
+
|
|
167
|
+
it.effect("discovers a fourth API workflow and its exported generated contract", () =>
|
|
168
|
+
Effect.gen(function*() {
|
|
169
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
170
|
+
const path = yield* Path.Path
|
|
171
|
+
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "api-update-discovery-" })
|
|
172
|
+
const workflowRoot = path.join(root, ".github/workflows")
|
|
173
|
+
yield* fileSystem.makeDirectory(workflowRoot, { recursive: true })
|
|
174
|
+
for (const name of ["clockify", "jira", "confluence", "tempo"]) {
|
|
175
|
+
const packageRoot = path.join(root, "packages", name)
|
|
176
|
+
yield* fileSystem.makeDirectory(packageRoot, { recursive: true })
|
|
177
|
+
yield* fileSystem.writeFileString(
|
|
178
|
+
path.join(packageRoot, "package.json"),
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
name: `@fixture/${name}`,
|
|
181
|
+
exports: { "./api": { types: "./dist/generated/Api.d.ts", import: "./dist/generated/Api.js" } }
|
|
182
|
+
})
|
|
183
|
+
)
|
|
184
|
+
yield* fileSystem.writeFileString(
|
|
185
|
+
path.join(workflowRoot, `${name}-api-update.yml`),
|
|
186
|
+
releaseWorkflow(`@fixture/${name}`, name === "tempo" ? "patch" : "minor")
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
for (
|
|
190
|
+
const [directory, manifest] of [
|
|
191
|
+
["private-client", {
|
|
192
|
+
name: "@fixture/private-client",
|
|
193
|
+
private: true,
|
|
194
|
+
exports: { "./generated": "./dist/Api.js" }
|
|
195
|
+
}],
|
|
196
|
+
["vendor", { name: "@fixture/vendor-client", exports: { "./generated": "./dist/Api.js" } }],
|
|
197
|
+
["generated", { name: "@fixture/generated-package", exports: { "./generated": "./dist/Api.js" } }],
|
|
198
|
+
["consumer", {
|
|
199
|
+
name: "@fixture/consumer",
|
|
200
|
+
exports: { ".": "./dist/index.js" },
|
|
201
|
+
dependencies: { "@fixture/tempo": "workspace:*" }
|
|
202
|
+
}]
|
|
203
|
+
] satisfies ReadonlyArray<readonly [string, Schema.Json]>
|
|
204
|
+
) {
|
|
205
|
+
const packageRoot = path.join(root, "packages", directory)
|
|
206
|
+
yield* fileSystem.makeDirectory(packageRoot, { recursive: true })
|
|
207
|
+
yield* fileSystem.writeFileString(path.join(packageRoot, "package.json"), JSON.stringify(manifest))
|
|
208
|
+
const decoded = yield* Schema.decodeUnknownEffect(PackageManifest)(manifest)
|
|
209
|
+
yield* fileSystem.writeFileString(
|
|
210
|
+
path.join(workflowRoot, `${directory}-api-update.yml`),
|
|
211
|
+
releaseWorkflow(decoded.name, "patch")
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([
|
|
215
|
+
"tempo-api-update.yml: Generated client @fixture/tempo defaults to patch without unchanged-contract guidance"
|
|
216
|
+
])
|
|
217
|
+
yield* fileSystem.writeFileString(
|
|
218
|
+
path.join(workflowRoot, "tempo-api-update.yml"),
|
|
219
|
+
releaseWorkflow("@fixture/tempo", "minor")
|
|
220
|
+
)
|
|
221
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([])
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
it("recognizes generated export subpaths and conditional targets without guessing package names", () => {
|
|
225
|
+
expect(exposesGeneratedContract({ "./generated/v1": "./dist/Api.js" })).toBe(true)
|
|
226
|
+
expect(exposesGeneratedContract({ ".": { import: [null, "./dist/generated/Api.js"] } })).toBe(true)
|
|
227
|
+
expect(exposesGeneratedContract({ ".": "./dist/generated-client.js" })).toBe(false)
|
|
228
|
+
expect(exposesGeneratedContract({ ".": "./dist/index.js" })).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("rejects compatible-only patch guidance and accepts unchanged-contract guidance", () => {
|
|
232
|
+
const workflow = (guidance: string) => `
|
|
233
|
+
jobs:
|
|
234
|
+
update:
|
|
235
|
+
steps:
|
|
236
|
+
- name: Create pull request
|
|
237
|
+
with:
|
|
238
|
+
body: ${guidance}
|
|
239
|
+
`
|
|
240
|
+
expect(patchGuidanceDiagnostics(
|
|
241
|
+
workflow(
|
|
242
|
+
"Patch is appropriate only after confirming every public contract remains compatible."
|
|
243
|
+
),
|
|
244
|
+
fixtureClients
|
|
245
|
+
)).toEqual(["Patch release guidance must require an unchanged public contract"])
|
|
246
|
+
expect(patchGuidanceDiagnostics(
|
|
247
|
+
workflow(
|
|
248
|
+
"Patch is appropriate only when the generated public contract is unchanged."
|
|
249
|
+
),
|
|
250
|
+
fixtureClients
|
|
251
|
+
)).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("rejects an unqualified generated-client patch default", () => {
|
|
255
|
+
expect(
|
|
256
|
+
patchGuidanceDiagnostics(
|
|
257
|
+
releaseWorkflow("@knpkv/jira-api-client", "patch", "Review the generated API."),
|
|
258
|
+
fixtureClients
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
.toEqual(["Generated client @knpkv/jira-api-client defaults to patch without unchanged-contract guidance"])
|
|
262
|
+
expect(
|
|
263
|
+
patchGuidanceDiagnostics(
|
|
264
|
+
releaseWorkflow("@knpkv/jira-api-client", "minor", "Review the generated API."),
|
|
265
|
+
fixtureClients
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
.toEqual([])
|
|
269
|
+
expect(patchGuidanceDiagnostics(
|
|
270
|
+
releaseWorkflow(
|
|
271
|
+
"@knpkv/jira-api-client",
|
|
272
|
+
"patch",
|
|
273
|
+
"Patch is appropriate only when the generated public contract is unchanged."
|
|
274
|
+
),
|
|
275
|
+
fixtureClients
|
|
276
|
+
)).toEqual([])
|
|
277
|
+
expect(
|
|
278
|
+
patchGuidanceDiagnostics(
|
|
279
|
+
releaseWorkflow("@fixture/private-client", "patch", "Apply the JSON patch."),
|
|
280
|
+
fixtureClients
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
.toEqual([])
|
|
284
|
+
expect(
|
|
285
|
+
patchGuidanceDiagnostics(
|
|
286
|
+
releaseWorkflow("@knpkv/jira-clockify", "patch", "Consumer dependency update."),
|
|
287
|
+
fixtureClients
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
.toEqual([])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it.effect("checks every discovered API-update workflow in the repository", () =>
|
|
294
|
+
Effect.gen(function*() {
|
|
295
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
296
|
+
const path = yield* Path.Path
|
|
297
|
+
const root = yield* path.fromFileUrl(new URL("../../../", import.meta.url))
|
|
298
|
+
expect(yield* fileSystem.readDirectory(path.join(root, ".github/workflows"))).toEqual(expect.arrayContaining([
|
|
299
|
+
"clockify-api-update.yml",
|
|
300
|
+
"jira-api-update.yml",
|
|
301
|
+
"confluence-api-update.yml"
|
|
302
|
+
]))
|
|
303
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([])
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
it("rejects a fixed generated changeset path and accepts a per-run path", () => {
|
|
307
|
+
expect(changesetPathDiagnostics("run: cat > .changeset/clockify-api-spec-update.md <<'CHANGESET'")).toEqual([
|
|
308
|
+
"Generated changeset .changeset/clockify-api-spec-update.md must be unique per workflow run"
|
|
309
|
+
])
|
|
310
|
+
expect(
|
|
311
|
+
changesetPathDiagnostics("run: cat > .changeset/clockify-api-spec-update-${GITHUB_RUN_ID}.md <<'CHANGESET'")
|
|
312
|
+
).toEqual([])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it.effect("writes every generated changeset to a per-run path", () =>
|
|
316
|
+
Effect.gen(function*() {
|
|
317
|
+
for (const name of ["clockify-api-update.yml", "jira-api-update.yml", "confluence-api-update.yml"]) {
|
|
318
|
+
const source = yield* loadWorkflow(name)
|
|
319
|
+
expect(source).toMatch(/cat\s+>\s+\.changeset\//u)
|
|
320
|
+
expect(changesetPathDiagnostics(source)).toEqual([])
|
|
321
|
+
}
|
|
322
|
+
}))
|
|
46
323
|
|
|
47
|
-
describe("API update workflow build closure", () => {
|
|
48
324
|
it("rejects a bare consumer build and accepts a dependency-closed build", () => {
|
|
49
325
|
const invalid = `
|
|
50
326
|
jobs:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, it } from "@effect/vitest"
|
|
2
2
|
import * as Effect from "effect/Effect"
|
|
3
|
+
import type { HttpClientRequest } from "effect/http"
|
|
4
|
+
import { HttpClient, HttpClientResponse } from "effect/http"
|
|
3
5
|
import * as Layer from "effect/Layer"
|
|
4
6
|
import { TestClock } from "effect/testing"
|
|
5
|
-
import type { HttpClientRequest } from "effect/unstable/http"
|
|
6
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
7
7
|
import { expect } from "vitest"
|
|
8
8
|
import { OAuthError } from "../src/auth/OAuthErrors.js"
|
|
9
9
|
import {
|
|
@@ -4,9 +4,9 @@ import * as Clock from "effect/Clock"
|
|
|
4
4
|
import * as Deferred from "effect/Deferred"
|
|
5
5
|
import * as Fiber from "effect/Fiber"
|
|
6
6
|
import * as FileSystem from "effect/FileSystem"
|
|
7
|
+
import { HttpClient, HttpClientResponse } from "effect/http"
|
|
7
8
|
import * as Path from "effect/Path"
|
|
8
9
|
import { SystemError } from "effect/PlatformError"
|
|
9
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
10
10
|
import {
|
|
11
11
|
type AtlassianToolDefinition,
|
|
12
12
|
HomeDirectoryLive,
|