@knpkv/atlassian-common 1.5.0 → 1.7.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 (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +31 -0
  3. package/dist/cli-auth/AtlassianCliAuth.d.ts +110 -0
  4. package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
  5. package/dist/cli-auth/AtlassianCliAuth.js +298 -0
  6. package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
  7. package/dist/cli-auth/authCommand.d.ts +34 -0
  8. package/dist/cli-auth/authCommand.d.ts.map +1 -0
  9. package/dist/cli-auth/authCommand.js +127 -0
  10. package/dist/cli-auth/authCommand.js.map +1 -0
  11. package/dist/cli-auth/index.d.ts +14 -0
  12. package/dist/cli-auth/index.d.ts.map +1 -0
  13. package/dist/cli-auth/index.js +14 -0
  14. package/dist/cli-auth/index.js.map +1 -0
  15. package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
  16. package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
  17. package/dist/cli-auth/internal/NodeLayers.js +17 -0
  18. package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
  19. package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
  20. package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
  21. package/dist/cli-auth/internal/oauthServer.js +94 -0
  22. package/dist/cli-auth/internal/oauthServer.js.map +1 -0
  23. package/dist/cli-auth/openBrowser.d.ts +16 -0
  24. package/dist/cli-auth/openBrowser.d.ts.map +1 -0
  25. package/dist/cli-auth/openBrowser.js +30 -0
  26. package/dist/cli-auth/openBrowser.js.map +1 -0
  27. package/package.json +5 -1
  28. package/src/cli-auth/AtlassianCliAuth.ts +539 -0
  29. package/src/cli-auth/authCommand.ts +227 -0
  30. package/src/cli-auth/index.ts +20 -0
  31. package/src/cli-auth/internal/NodeLayers.ts +25 -0
  32. package/src/cli-auth/internal/oauthServer.ts +168 -0
  33. package/src/cli-auth/openBrowser.ts +47 -0
  34. package/test/ApiUpdateWorkflows.test.ts +78 -0
  35. package/test/cliAuth.authCommand.test.ts +153 -0
  36. package/test/cliAuth.guardrails.test.ts +76 -0
  37. package/test/cliAuth.login.test.ts +319 -0
  38. package/test/cliAuth.oauthServer.test.ts +276 -0
  39. package/test/cliAuth.openBrowser.test.ts +70 -0
  40. package/test/cliAuth.refresh.test.ts +304 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The `auth` command group every Atlassian CLI exposes, built once over its {@link AtlassianCliAuth}.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **One descriptor, two readers.** The CLI passes the same {@link AtlassianCliDescriptor} it built
7
+ * its auth from, so the setup instructions list exactly the scopes `auth login` requests. A second,
8
+ * hand-written list is how a setup that cannot log in happens: Atlassian rejects an authorization
9
+ * request naming a scope the app lacks.
10
+ * - **Only presentation is configured.** The heading the product's scopes sit under in the
11
+ * developer console, and what to call a site in `--site`.
12
+ * - **A browser that does not open is an error.** The URL is printed first, so a headless or SSH
13
+ * user can still finish by hand; the exit code just stops claiming it worked.
14
+ *
15
+ * @module
16
+ */
17
+ import { Argument, Command, Flag, Prompt } from "effect/cli"
18
+ import * as Console from "effect/Console"
19
+ import type * as Context from "effect/Context"
20
+ import * as Effect from "effect/Effect"
21
+ import * as Option from "effect/Option"
22
+ import type { AtlassianCliAuth, AtlassianCliDescriptor } from "./AtlassianCliAuth.js"
23
+ import { openBrowser } from "./openBrowser.js"
24
+
25
+ const CREATE_APP_URL = "https://developer.atlassian.com/console/myapps/create-3lo-app/"
26
+ const CONSOLE_APPS_URL = "https://developer.atlassian.com/console/myapps/"
27
+ const CALLBACK_URL = "http://localhost:8585/callback"
28
+
29
+ /** The part of an Atlassian CLI's auth the `auth` commands drive. */
30
+ export type AuthCommandService = Pick<
31
+ AtlassianCliAuth<never>,
32
+ | "configure"
33
+ | "login"
34
+ | "logout"
35
+ | "getActiveProfile"
36
+ | "listProfiles"
37
+ | "switchProfile"
38
+ | "removeProfile"
39
+ >
40
+
41
+ /** What only the product decides about how its `auth` commands read. */
42
+ export interface AuthCommandOptions {
43
+ /** Developer-console heading the product's scopes live under, e.g. `"Jira API"`. */
44
+ readonly apiSection: string
45
+ }
46
+
47
+ /**
48
+ * The scopes to enable on the OAuth app, by console section. `read:me` is a User Identity scope;
49
+ * `offline_access` is requested at authorize time but is not an app permission, so it is omitted.
50
+ */
51
+ export const scopeInstructions = (descriptor: AtlassianCliDescriptor, apiSection: string): string => {
52
+ const section = (heading: string, scopes: ReadonlyArray<string>): ReadonlyArray<string> =>
53
+ scopes.length === 0 ? [] : [` - ${heading}:`, ...scopes.map((scope) => ` ${scope}`)]
54
+ const permissions = descriptor.scopes.filter((scope) => scope !== "offline_access")
55
+ return [
56
+ ...section(apiSection, permissions.filter((scope) => scope !== "read:me")),
57
+ ...section("User Identity API", permissions.filter((scope) => scope === "read:me"))
58
+ ].join("\n")
59
+ }
60
+
61
+ /** Print the URL, then try to open it; a browser that never opened fails the command. */
62
+ const visit = (url: string) => Console.log(`Opening ${url}`).pipe(Effect.andThen(openBrowser(url)))
63
+
64
+ /** Build `<cli> auth` with create, manage, configure, login, logout, status, profiles, use, remove. */
65
+ export const makeAuthCommand = <I, S extends AuthCommandService>(
66
+ service: Context.Key<I, S>,
67
+ descriptor: AtlassianCliDescriptor,
68
+ options: AuthCommandOptions
69
+ ) => {
70
+ const withAuth = <A, Err, R>(f: (auth: S) => Effect.Effect<A, Err, R>) => Effect.flatMap(Effect.service(service), f)
71
+ const loginHint = `${descriptor.commandName} auth login`
72
+
73
+ const create = Command.make("create", {}, () =>
74
+ Effect.gen(function*() {
75
+ yield* Console.log(`
76
+ Creating OAuth app in Atlassian Developer Console...
77
+
78
+ 1. Browser will open to create a new OAuth 2.0 (3LO) app
79
+ 2. Enter app name (e.g., "${descriptor.productName} CLI")
80
+ 3. After creation, go to "Permissions" and add:
81
+ ${scopeInstructions(descriptor, options.apiSection)}
82
+ 4. Go to "Authorization" and set callback URL:
83
+ ${CALLBACK_URL}
84
+ 5. Go to "Settings" and copy Client ID and Secret
85
+ 6. Run: ${descriptor.commandName} auth configure --client-id <ID> --client-secret <SECRET>
86
+ `)
87
+ yield* visit(CREATE_APP_URL)
88
+ })).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
89
+
90
+ // Opens the app list, not the app: the console addresses an app by an id that is not the OAuth
91
+ // client id, and the client id is the only app identifier the CLI stores.
92
+ const manage = Command.make("manage", {}, () =>
93
+ Effect.gen(function*() {
94
+ yield* Console.log(`
95
+ Opening the Atlassian Developer Console app list...
96
+
97
+ Select your OAuth app, then under "Permissions" make sure every scope below is
98
+ enabled. \`${loginHint}\` requests all of them, and Atlassian rejects an
99
+ authorization request naming a scope the app does not have — so a missing scope
100
+ here fails the login itself, not just the command that needed it.
101
+
102
+ ${scopeInstructions(descriptor, options.apiSection)}
103
+
104
+ Under "Authorization", the callback URL must be:
105
+ ${CALLBACK_URL}
106
+
107
+ After adding scopes, run: ${loginHint}
108
+ `)
109
+ yield* visit(CONSOLE_APPS_URL)
110
+ })).pipe(Command.withDescription("Open the Atlassian Developer Console to edit the OAuth app's scopes"))
111
+
112
+ const configure = Command.make(
113
+ "configure",
114
+ {
115
+ clientId: Flag.String("client-id").pipe(
116
+ Flag.withDescription("OAuth client ID from Atlassian Developer Console"),
117
+ Flag.optional
118
+ ),
119
+ clientSecret: Flag.String("client-secret").pipe(Flag.withDescription("OAuth client secret"), Flag.optional)
120
+ },
121
+ ({ clientId, clientSecret }) =>
122
+ withAuth((auth) =>
123
+ Effect.gen(function*() {
124
+ const id = Option.isSome(clientId)
125
+ ? clientId.value
126
+ : yield* Prompt.String({ message: "Enter OAuth client ID:" })
127
+ const secret = Option.isSome(clientSecret)
128
+ ? clientSecret.value
129
+ : yield* Prompt.String({ message: "Enter OAuth client secret:" })
130
+ yield* auth.configure({ clientId: id, clientSecret: secret })
131
+ yield* Console.log(`OAuth configured. Run '${loginHint}' to authenticate.`)
132
+ })
133
+ )
134
+ ).pipe(Command.withDescription("Configure OAuth client credentials"))
135
+
136
+ const login = Command.make(
137
+ "login",
138
+ {
139
+ site: Flag.String("site").pipe(
140
+ Flag.withDescription(`${descriptor.productName} site URL to use (for accounts with multiple sites)`),
141
+ Flag.optional
142
+ )
143
+ },
144
+ ({ site }) =>
145
+ withAuth((auth) =>
146
+ Effect.gen(function*() {
147
+ const sites = yield* auth.login(Option.isSome(site) ? { siteUrl: site.value } : undefined)
148
+ if (Array.isArray(sites) && sites.length > 0) {
149
+ yield* Console.log("\nRe-run with --site to select a specific site.")
150
+ }
151
+ })
152
+ )
153
+ ).pipe(Command.withDescription("Authenticate with Atlassian via OAuth"))
154
+
155
+ const logout = Command.make(
156
+ "logout",
157
+ {},
158
+ () => withAuth((auth) => auth.logout().pipe(Effect.andThen(Console.log("Logged out"))))
159
+ ).pipe(
160
+ Command.withDescription("Remove stored authentication")
161
+ )
162
+
163
+ const status = Command.make("status", {}, () =>
164
+ withAuth((auth) =>
165
+ Effect.gen(function*() {
166
+ const profile = yield* auth.getActiveProfile()
167
+ if (profile === null) {
168
+ return yield* Console.log(`Not logged in. Use '${loginHint}' to authenticate.`)
169
+ }
170
+ const user = profile.token.user
171
+ yield* Console.log(`Active profile: ${profile.name}`)
172
+ yield* Console.log(`Account: ${user !== undefined ? `${user.name} (${user.email})` : "unknown user"}`)
173
+ yield* Console.log(`Site: ${profile.token.site_url}`)
174
+ yield* Console.log(`Profile ID: ${profile.id}`)
175
+ })
176
+ )).pipe(Command.withDescription("Show authentication status"))
177
+
178
+ const profiles = Command.make("profiles", {}, () =>
179
+ withAuth((auth) =>
180
+ Effect.gen(function*() {
181
+ const [stored, active] = yield* Effect.all([auth.listProfiles(), auth.getActiveProfile()])
182
+ if (stored.length === 0) {
183
+ return yield* Console.log(`No auth profiles. Use '${loginHint}' to authenticate.`)
184
+ }
185
+ for (const profile of stored) {
186
+ yield* Console.log(`${active?.id === profile.id ? "*" : " "} ${profile.id}`)
187
+ yield* Console.log(` ${profile.name}`)
188
+ yield* Console.log(` ${profile.token.site_url}`)
189
+ }
190
+ })
191
+ )).pipe(Command.withDescription("List stored auth profiles"))
192
+
193
+ const profile = Argument.String("profile").pipe(
194
+ Argument.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
195
+ )
196
+
197
+ const use = Command.make(
198
+ "use",
199
+ { profile },
200
+ ({ profile }) =>
201
+ withAuth((auth) =>
202
+ Effect.flatMap(
203
+ auth.switchProfile(profile),
204
+ (selected) =>
205
+ Console.log(selected === null ? `Profile not found: ${profile}` : `Active profile: ${selected.name}`)
206
+ )
207
+ )
208
+ ).pipe(Command.withDescription("Switch active auth profile"))
209
+
210
+ const remove = Command.make(
211
+ "remove",
212
+ { profile },
213
+ ({ profile }) =>
214
+ withAuth((auth) =>
215
+ Effect.flatMap(
216
+ auth.removeProfile(profile),
217
+ (removed) =>
218
+ Console.log(removed === null ? `Profile not found: ${profile}` : `Removed profile: ${removed.name}`)
219
+ )
220
+ )
221
+ ).pipe(Command.withDescription("Remove stored auth profile"))
222
+
223
+ return Command.make("auth").pipe(
224
+ Command.withDescription("Manage OAuth authentication"),
225
+ Command.withSubcommands([create, manage, configure, login, logout, status, profiles, use, remove])
226
+ )
227
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OAuth2 login and token refresh for Atlassian command-line tools.
3
+ *
4
+ * Separate from `./auth` because it carries a Node callback server and a
5
+ * browser launcher; `./auth` stays platform-neutral for server-side consumers.
6
+ *
7
+ * @module
8
+ */
9
+ export {
10
+ type AccessibleSite,
11
+ type AtlassianCliAuth,
12
+ type AtlassianCliAuthOptions,
13
+ type AtlassianCliDescriptor,
14
+ type LoginOptions,
15
+ makeAtlassianCliAuth
16
+ } from "./AtlassianCliAuth.js"
17
+ export { type AuthCommandOptions, type AuthCommandService, makeAuthCommand, scopeInstructions } from "./authCommand.js"
18
+ export { HttpServerFactoryLive, NodeCliAuthLive } from "./internal/NodeLayers.js"
19
+ export { type HttpServerFactory, HttpServerFactoryTag, makeHttpServerFactory } from "./internal/oauthServer.js"
20
+ export { BrowserOpenError, openBrowser } from "./openBrowser.js"
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Node.js adapters for cli-auth: the OAuth callback server and token storage — the
3
+ * only cli-auth file importing `@effect/platform-node`.
4
+ *
5
+ * @internal
6
+ */
7
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
8
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
9
+ import * as NodePath from "@effect/platform-node/NodePath"
10
+ import * as Layer from "effect/Layer"
11
+ import { createServer } from "node:http"
12
+ import { HomeDirectoryLive } from "../../config/ConfigPaths.js"
13
+ import { makeHttpServerFactory } from "./oauthServer.js"
14
+
15
+ export const HttpServerFactoryLive = makeHttpServerFactory(
16
+ (options) => NodeHttpServer.layerServer(createServer, options)
17
+ )
18
+
19
+ /** Everything `makeAtlassianCliAuth` needs on Node besides HTTP client, spawner and Crypto. */
20
+ export const NodeCliAuthLive = Layer.mergeAll(
21
+ HttpServerFactoryLive,
22
+ NodeFileSystem.layer,
23
+ NodePath.layer,
24
+ HomeDirectoryLive
25
+ )
@@ -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
+ )
@@ -107,6 +107,40 @@ const changesetPathDiagnostics = (source: string): ReadonlyArray<string> =>
107
107
  : [`Generated changeset ${changesetPath ?? ""} must be unique per workflow run`]
108
108
  )
109
109
 
110
+ // A regenerated-client-only diff is generator churn, not an upstream change: the
111
+ // detection step must compare only the workflow's own client `specsDir`, and the
112
+ // pull request must be gated on it.
113
+ const specOnlyGuardDiagnostics = (source: string, specsDir: string): ReadonlyArray<string> => {
114
+ const workflow: unknown = parse(source)
115
+ if (!isRecord(workflow) || !isRecord(workflow.jobs)) return ["API update workflow could not be inspected"]
116
+
117
+ return Object.values(workflow.jobs).flatMap((job) => {
118
+ if (!isRecord(job) || !Array.isArray(job.steps)) return []
119
+ const steps = job.steps.filter(isRecord)
120
+ const detection = steps.find((step) => Predicate.isString(step.run) && step.run.includes("updated=true"))
121
+ if (detection === undefined || !Predicate.isString(detection.id) || !Predicate.isString(detection.run)) {
122
+ return ["API update workflow must detect upstream spec changes"]
123
+ }
124
+ const pathspecs = detection.run.replace(/\\\n\s*/gu, " ").match(/git diff --quiet -- ([^;\n]+)/u)?.[1]
125
+ ?.trim().split(/\s+/u) ?? []
126
+ const pathspecDiagnostics = pathspecs.length === 0
127
+ ? ["API update workflow must compare spec paths with git diff --quiet"]
128
+ : pathspecs.flatMap((pathspec) =>
129
+ pathspec === specsDir || pathspec.startsWith(`${specsDir}/`)
130
+ ? []
131
+ : [`API update workflow must open a pull request only for spec changes, not ${pathspec}`]
132
+ )
133
+ const gate = `steps.${detection.id}.outputs.updated == 'true'`
134
+ const pullRequestDiagnostics = steps.flatMap((step) =>
135
+ Predicate.isString(step.uses) && step.uses.startsWith("peter-evans/create-pull-request@")
136
+ && !(Predicate.isString(step.if) && step.if.includes(gate))
137
+ ? ["API update pull request must be gated on the spec-change detection step"]
138
+ : []
139
+ )
140
+ return [...pathspecDiagnostics, ...pullRequestDiagnostics]
141
+ })
142
+ }
143
+
110
144
  const releaseWorkflow = (name: string, release: string, guidance = "Review the generated API.") => `
111
145
  jobs:
112
146
  update:
@@ -321,6 +355,50 @@ jobs:
321
355
  }
322
356
  }))
323
357
 
358
+ it("rejects generated-code detection and accepts a spec-only gate", () => {
359
+ const workflow = (pathspecs: string, gate: string) => `
360
+ jobs:
361
+ update:
362
+ steps:
363
+ - name: Detect spec changes
364
+ id: check
365
+ run: |
366
+ if git diff --quiet -- \\
367
+ ${pathspecs}; then
368
+ echo "updated=false" >> "$GITHUB_OUTPUT"
369
+ else
370
+ echo "updated=true" >> "$GITHUB_OUTPUT"
371
+ fi
372
+ - name: Create pull request
373
+ if: ${gate}
374
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
375
+ `
376
+ const gate = "steps.check.outputs.updated == 'true'"
377
+ const specs = "packages/x/.specs"
378
+ expect(specOnlyGuardDiagnostics(workflow("packages/x/.specs packages/x/src/generated", gate), specs)).toEqual([
379
+ "API update workflow must open a pull request only for spec changes, not packages/x/src/generated"
380
+ ])
381
+ expect(specOnlyGuardDiagnostics(workflow("packages/y/.specs", gate), specs)).toEqual([
382
+ "API update workflow must open a pull request only for spec changes, not packages/y/.specs"
383
+ ])
384
+ expect(specOnlyGuardDiagnostics(workflow("packages/x/.specs-old", gate), specs)).toEqual([
385
+ "API update workflow must open a pull request only for spec changes, not packages/x/.specs-old"
386
+ ])
387
+ expect(specOnlyGuardDiagnostics(workflow("packages/x/.specs", "always()"), specs)).toEqual([
388
+ "API update pull request must be gated on the spec-change detection step"
389
+ ])
390
+ expect(specOnlyGuardDiagnostics(workflow("packages/x/.specs/x-v1.json", gate), specs)).toEqual([])
391
+ expect(specOnlyGuardDiagnostics(workflow("packages/x/.specs", gate), specs)).toEqual([])
392
+ })
393
+
394
+ it.effect("opens API update pull requests only for spec changes", () =>
395
+ Effect.gen(function*() {
396
+ for (const client of ["clockify", "jira", "confluence"]) {
397
+ const source = yield* loadWorkflow(`${client}-api-update.yml`)
398
+ expect(specOnlyGuardDiagnostics(source, `packages/${client}-api-client/.specs`)).toEqual([])
399
+ }
400
+ }))
401
+
324
402
  it("rejects a bare consumer build and accepts a dependency-closed build", () => {
325
403
  const invalid = `
326
404
  jobs: