@knpkv/atlassian-common 1.3.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -1
  3. package/dist/Brand.d.ts +2 -2
  4. package/dist/Brand.d.ts.map +1 -1
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Hash.d.ts.map +1 -1
  7. package/dist/Hash.js +2 -2
  8. package/dist/Hash.js.map +1 -1
  9. package/dist/ast/Document.d.ts +3 -19
  10. package/dist/ast/Document.d.ts.map +1 -1
  11. package/dist/ast/Document.js +4 -3
  12. package/dist/ast/Document.js.map +1 -1
  13. package/dist/ast/MacroNode.d.ts +3 -3
  14. package/dist/attachments.d.ts.map +1 -1
  15. package/dist/attachments.js +2 -6
  16. package/dist/attachments.js.map +1 -1
  17. package/dist/auth/OAuthEndpoints.d.ts +13 -0
  18. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  19. package/dist/auth/OAuthEndpoints.js +24 -3
  20. package/dist/auth/OAuthEndpoints.js.map +1 -1
  21. package/dist/auth/OAuthErrors.d.ts +22 -0
  22. package/dist/auth/OAuthErrors.d.ts.map +1 -1
  23. package/dist/auth/OAuthErrors.js +1 -1
  24. package/dist/auth/OAuthErrors.js.map +1 -1
  25. package/dist/auth/OAuthOperations.d.ts +8 -2
  26. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  27. package/dist/auth/OAuthOperations.js +18 -8
  28. package/dist/auth/OAuthOperations.js.map +1 -1
  29. package/dist/auth/OAuthResponseSchemas.d.ts +11 -0
  30. package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -1
  31. package/dist/auth/OAuthResponseSchemas.js +11 -0
  32. package/dist/auth/OAuthResponseSchemas.js.map +1 -1
  33. package/dist/auth/index.d.ts +1 -1
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +1 -1
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/bin.js +2 -2
  38. package/dist/bin.js.map +1 -1
  39. package/dist/config/AuthProfiles.d.ts +140 -175
  40. package/dist/config/AuthProfiles.d.ts.map +1 -1
  41. package/dist/config/AuthProfiles.js +2 -1
  42. package/dist/config/AuthProfiles.js.map +1 -1
  43. package/dist/config/ConfigPaths.d.ts.map +1 -1
  44. package/dist/config/ConfigPaths.js +2 -2
  45. package/dist/config/ProfileManager.d.ts +14 -3
  46. package/dist/config/ProfileManager.d.ts.map +1 -1
  47. package/dist/config/ProfileManager.js +33 -5
  48. package/dist/config/ProfileManager.js.map +1 -1
  49. package/dist/config/TokenStorage.d.ts.map +1 -1
  50. package/dist/config/TokenStorage.js +2 -1
  51. package/dist/config/TokenStorage.js.map +1 -1
  52. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
  53. package/dist/serializers/MarkdownSerializer.js.map +1 -1
  54. package/package.json +8 -8
  55. package/src/Brand.ts +5 -5
  56. package/src/Hash.ts +2 -2
  57. package/src/ast/Document.ts +5 -4
  58. package/src/attachments.ts +2 -1
  59. package/src/auth/OAuthEndpoints.ts +25 -3
  60. package/src/auth/OAuthErrors.ts +23 -1
  61. package/src/auth/OAuthOperations.ts +33 -20
  62. package/src/auth/OAuthResponseSchemas.ts +12 -0
  63. package/src/auth/index.ts +1 -0
  64. package/src/bin.ts +5 -3
  65. package/src/config/AuthProfiles.ts +4 -2
  66. package/src/config/ConfigPaths.ts +3 -3
  67. package/src/config/ProfileManager.ts +42 -6
  68. package/src/config/TokenStorage.ts +4 -2
  69. package/test/ApiUpdateWorkflows.test.ts +356 -0
  70. package/test/OAuthEndpoints.test.ts +23 -0
  71. package/test/OAuthOperations.test.ts +2 -2
  72. package/test/ProfileManager.test.ts +61 -0
@@ -20,8 +20,8 @@
20
20
  */
21
21
  import * as Clock from "effect/Clock"
22
22
  import * as Effect from "effect/Effect"
23
+ import { HttpClient, HttpClientRequest } from "effect/http"
23
24
  import * as Schema from "effect/Schema"
24
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
25
25
  import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
26
26
  import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
27
27
  import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
@@ -29,6 +29,7 @@ import { OAuthError } from "./OAuthErrors.js"
29
29
  import {
30
30
  type AccessibleResource,
31
31
  AccessibleResourceSchema,
32
+ TokenErrorSchema,
32
33
  type TokenResponse,
33
34
  TokenResponseSchema,
34
35
  type UserInfo,
@@ -49,6 +50,15 @@ export interface ExchangeCodeOptions {
49
50
  readonly codeVerifier?: string | undefined
50
51
  }
51
52
 
53
+ interface AuthorizationCodeTokenBody {
54
+ readonly grant_type: string
55
+ readonly client_id: string
56
+ readonly client_secret: string
57
+ readonly code: string
58
+ readonly redirect_uri: string
59
+ code_verifier?: string
60
+ }
61
+
52
62
  /**
53
63
  * Exchange authorization code for tokens.
54
64
  *
@@ -58,10 +68,10 @@ export const exchangeCodeForTokens = (
58
68
  code: string,
59
69
  config: OAuthConfig,
60
70
  options: ExchangeCodeOptions
61
- ): Effect.Effect<TokenResponse, OAuthError, HttpClient.HttpClient> =>
71
+ ) =>
62
72
  Effect.gen(function*() {
63
73
  const httpClient = yield* HttpClient.HttpClient
64
- const tokenBody: Record<string, string> = {
74
+ const tokenBody: AuthorizationCodeTokenBody = {
65
75
  grant_type: "authorization_code",
66
76
  client_id: config.clientId,
67
77
  client_secret: config.clientSecret,
@@ -79,9 +89,7 @@ export const exchangeCodeForTokens = (
79
89
  if (response.status >= 400) {
80
90
  const text = yield* response.text
81
91
  yield* Effect.logDebug(`Token exchange failed (${response.status}): ${text}`)
82
- return yield* Effect.fail(
83
- new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
84
- )
92
+ return yield* new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
85
93
  }
86
94
 
87
95
  const body = yield* response.json
@@ -113,9 +121,7 @@ export const getAccessibleResources = (
113
121
  if (response.status >= 400) {
114
122
  const text = yield* response.text
115
123
  yield* Effect.logDebug(`Accessible resources failed (${response.status}): ${text}`)
116
- return yield* Effect.fail(
117
- new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
118
- )
124
+ return yield* new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
119
125
  }
120
126
 
121
127
  const body = yield* response.json
@@ -147,9 +153,7 @@ export const getUserInfo = (
147
153
  if (response.status >= 400) {
148
154
  const text = yield* response.text
149
155
  yield* Effect.logDebug(`User info failed (${response.status}): ${text}`)
150
- return yield* Effect.fail(
151
- new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
152
- )
156
+ return yield* new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
153
157
  }
154
158
 
155
159
  const body = yield* response.json
@@ -192,9 +196,20 @@ export const refreshToken = (
192
196
  const text = yield* response.text.pipe(
193
197
  Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
194
198
  )
195
- return yield* Effect.fail(
196
- new OAuthError({ step: "refresh", cause: `HTTP ${response.status}: ${text}` })
199
+ // The OAuth `error` code is what distinguishes "this grant is spent" from
200
+ // "your request was malformed", and callers key credential deletion off
201
+ // it. A body that is not JSON, or not shaped like an OAuth error, simply
202
+ // leaves it absent — which callers read as "no verdict", not "rejected".
203
+ const errorCode = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TokenErrorSchema))(text).pipe(
204
+ Effect.map((decoded) => decoded.error),
205
+ Effect.catch(() => Effect.succeed(undefined))
197
206
  )
207
+ return yield* new OAuthError({
208
+ step: "refresh",
209
+ cause: `HTTP ${response.status}: ${text}`,
210
+ status: response.status,
211
+ ...(!(errorCode === undefined) && { errorCode })
212
+ })
198
213
  }
199
214
 
200
215
  const body = yield* response.json.pipe(
@@ -243,12 +258,10 @@ export const revokeToken = (
243
258
  )
244
259
 
245
260
  if (response.status >= 400) {
246
- return yield* Effect.fail(
247
- new OAuthError({
248
- step: "revoke",
249
- cause: `Token revocation failed with status ${response.status}`
250
- })
251
- )
261
+ return yield* new OAuthError({
262
+ step: "revoke",
263
+ cause: `Token revocation failed with status ${response.status}`
264
+ })
252
265
  }
253
266
  })
254
267
 
@@ -11,6 +11,18 @@
11
11
  */
12
12
  import * as Schema from "effect/Schema"
13
13
 
14
+ /**
15
+ * OAuth 2.0 error response body (RFC 6749 §5.2).
16
+ *
17
+ * Only `error` is required; providers vary on the rest, so nothing else is
18
+ * modelled — the code is the part callers act on.
19
+ *
20
+ * @category Schema
21
+ */
22
+ export const TokenErrorSchema = Schema.Struct({
23
+ error: Schema.String
24
+ })
25
+
14
26
  /**
15
27
  * Schema for OAuth2 token response from Atlassian.
16
28
  *
package/src/auth/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  buildAuthUrl,
13
13
  type BuildAuthUrlOptions,
14
14
  computeCodeChallenge,
15
+ CONFLUENCE_FOLDER_SCOPES,
15
16
  CONFLUENCE_SCOPES,
16
17
  generateCodeVerifier,
17
18
  JIRA_PROPOSAL_SCOPES,
package/src/bin.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
3
+ import { Argument as Args, Command } from "effect/cli"
3
4
  import * as Console from "effect/Console"
4
5
  import * as Effect from "effect/Effect"
5
6
  import * as Layer from "effect/Layer"
6
7
  import * as Stdio from "effect/Stdio"
7
- import { Argument as Args, Command } from "effect/unstable/cli"
8
8
  import pkg from "../package.json" with { type: "json" }
9
9
  import {
10
10
  HomeDirectoryLive,
@@ -15,7 +15,7 @@ import {
15
15
  useProfileForAllTools
16
16
  } from "./config/index.js"
17
17
 
18
- const profileArg = Args.string("profile").pipe(
18
+ const profileArg = Args.String("profile").pipe(
19
19
  Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
20
20
  )
21
21
 
@@ -106,7 +106,9 @@ const program = Effect.gen(function*() {
106
106
  HomeDirectoryLive
107
107
  )
108
108
  ),
109
- Effect.catch((error: unknown) => Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error))))
109
+ Effect.catch(<UnparsedInput>(error: UnparsedInput) =>
110
+ Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error)))
111
+ )
110
112
  )
111
113
 
112
114
  NodeRuntime.runMain(program, { disableErrorReporting: true })
@@ -71,9 +71,11 @@ export type AuthProfilesFile = Schema.Schema.Type<typeof AuthProfilesFileSchema>
71
71
 
72
72
  const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
73
73
 
74
- const parseJsonOrNull = (content: string): unknown | null => {
74
+ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
75
+
76
+ const parseJsonOrNull = (content: string): Schema.Json | null => {
75
77
  try {
76
- return JSON.parse(content)
78
+ return decodeJson(content)
77
79
  } catch {
78
80
  return null
79
81
  }
@@ -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.string("HOME")).pipe(
65
- Config.orElse(() => Config.option(Config.string("USERPROFILE")))
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.string("XDG_CONFIG_HOME"))
87
+ const XdgConfigHome = Config.option(Config.String("XDG_CONFIG_HOME"))
88
88
 
89
89
  /**
90
90
  * XDG config directory for Atlassian tools.
@@ -7,11 +7,11 @@ 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
- import type { OAuthError } from "../auth/OAuthErrors.js"
14
+ import { OAuthError } from "../auth/OAuthErrors.js"
15
15
  import { refreshToken } from "../auth/OAuthOperations.js"
16
16
  import {
17
17
  type AuthProfile,
@@ -51,6 +51,17 @@ export const JIRA_PROPOSAL_REQUIRED_SCOPES: ReadonlyArray<string> = [
51
51
  "offline_access"
52
52
  ]
53
53
 
54
+ /**
55
+ * Scopes a Confluence profile must hold to be considered valid.
56
+ *
57
+ * Deliberately page-only. The CLI also requests the folder and CQL-search scopes
58
+ * (`CONFLUENCE_FOLDER_SCOPES`), but they are not listed here: this constant also
59
+ * gates control-center's per-site scope check, and a profile that can read and
60
+ * write pages is legitimately valid for everything except `folder`/`search`.
61
+ * The cost is that `atlassian profiles doctor` reports a pre-folder profile as
62
+ * valid and those two commands then fail with 401/403 until the OAuth app is
63
+ * updated and the user logs in again — see the confluence-to-markdown README.
64
+ */
54
65
  export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
55
66
  "read:page:confluence",
56
67
  "write:page:confluence",
@@ -216,7 +227,7 @@ export const useProfileForAllTools = (
216
227
  ).find((profile): profile is AuthProfile =>
217
228
  profile !== null
218
229
  )
219
- if (!selected) return yield* Effect.fail(new ProfileNotFoundError({ selector }))
230
+ if (selected === undefined) return yield* new ProfileNotFoundError({ selector })
220
231
 
221
232
  yield* Effect.forEach(stores, ([tool, store]) => {
222
233
  const matching = findProfile(store.profiles, selector) ?? findProfile(store.profiles, selected.id)
@@ -260,6 +271,9 @@ export const migrateLegacyProfiles = (
260
271
  return yield* inspectAllToolProfiles(tools)
261
272
  })
262
273
 
274
+ /** Matches `JiraAuth`'s refresh deadline; see the rotation comment below. */
275
+ const REFRESH_TIMEOUT = "30 seconds"
276
+
263
277
  /**
264
278
  * Refresh expired active profiles.
265
279
  *
@@ -284,10 +298,32 @@ export const refreshActiveProfiles = (
284
298
  if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
285
299
  const config = yield* loadOAuthConfig(storeName)
286
300
  if (!config) {
287
- return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
301
+ return yield* new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id })
288
302
  }
289
- const refreshed = yield* refreshToken(active.token, config)
290
- yield* saveProfileToken(storeName, refreshed)
303
+ // Rotating refresh tokens: the grant consumes the stored token
304
+ // server-side and the response carries its replacement, so an interrupt
305
+ // between the two spends the credential without persisting what
306
+ // replaced it — the next refresh 4xxs and the user is silently logged
307
+ // out. Keep the grant and the persist atomic, as `JiraAuth` does.
308
+ // The deadline is inside the region on purpose: an uninterruptible
309
+ // region with no bound of its own absorbs SIGINT/SIGTERM entirely
310
+ // (`runMain`'s handlers only interrupt the main fiber), so `atlassian
311
+ // auth refresh` would stop answering Ctrl-C against a stalled token
312
+ // endpoint. A deadline forked inside the region is still interruptible,
313
+ // so it does bound this. Failing here leaves the stored token in place
314
+ // — a refresh that never answered says nothing about its validity.
315
+ yield* Effect.uninterruptible(
316
+ Effect.gen(function*() {
317
+ const refreshed = yield* refreshToken(active.token, config).pipe(
318
+ Effect.timeout(REFRESH_TIMEOUT),
319
+ Effect.catchTag(
320
+ "TimeoutError",
321
+ () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
322
+ )
323
+ )
324
+ yield* saveProfileToken(storeName, refreshed)
325
+ })
326
+ )
291
327
  }))
292
328
  return yield* inspectAllToolProfiles(tools)
293
329
  })
@@ -28,9 +28,11 @@ import { type HomeDirectoryError, type HomeDirectoryTag } from "./ConfigPaths.js
28
28
  import { ensureConfigDir, getAuthPath, getOAuthConfigPath, writeSecureFile } from "./ConfigPaths.js"
29
29
  import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
30
30
 
31
- const parseJsonOrNull = (content: string): unknown | null => {
31
+ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
32
+
33
+ const parseJsonOrNull = (content: string): Schema.Json | null => {
32
34
  try {
33
- return JSON.parse(content)
35
+ return decodeJson(content)
34
36
  } catch {
35
37
  return null
36
38
  }
@@ -0,0 +1,356 @@
1
+ import * as NodeServices from "@effect/platform-node/NodeServices"
2
+ import { expect, layer } from "@effect/vitest"
3
+ import * as Array from "effect/Array"
4
+ import * as Effect from "effect/Effect"
5
+ import * as FileSystem from "effect/FileSystem"
6
+ import * as Path from "effect/Path"
7
+ import * as Predicate from "effect/Predicate"
8
+ import * as Schema from "effect/Schema"
9
+ import { parse } from "yaml"
10
+
11
+ const isRecord = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Record<string, Schema.Json> =>
12
+ Predicate.isObjectOrArray(value) && value !== null && !Array.isArray(value)
13
+
14
+ const buildCommands = (source: string): ReadonlyArray<string> => {
15
+ const workflow: unknown = parse(source)
16
+ if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
17
+
18
+ return Object.values(workflow.jobs).flatMap((job) => {
19
+ if (!isRecord(job) || !Array.isArray(job.steps)) return []
20
+ return job.steps.flatMap((step) => {
21
+ if (!isRecord(step) || !Predicate.isString(step.name) || !step.name.startsWith("Build ")) return []
22
+ return Predicate.isString(step.run) ? [step.run] : []
23
+ })
24
+ })
25
+ }
26
+
27
+ const dependencyClosureDiagnostics = (
28
+ source: string,
29
+ expectedConsumers: ReadonlyArray<string>
30
+ ): ReadonlyArray<string> => {
31
+ const commands = buildCommands(source)
32
+ return expectedConsumers.flatMap((consumer) => {
33
+ const dependencyClosedFilter = `--filter ${consumer}...`
34
+ return commands.some((command) => command.includes(dependencyClosedFilter) && /\bbuild\b/u.test(command))
35
+ ? []
36
+ : [`API update workflow must build ${consumer} with its workspace dependency closure`]
37
+ })
38
+ }
39
+
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
+ }))
323
+
324
+ it("rejects a bare consumer build and accepts a dependency-closed build", () => {
325
+ const invalid = `
326
+ jobs:
327
+ update:
328
+ steps:
329
+ - name: Build generated client and consumer
330
+ run: pnpm --filter @knpkv/confluence-to-markdown build
331
+ `
332
+ const valid = `
333
+ jobs:
334
+ update:
335
+ steps:
336
+ - name: Build generated client and consumer
337
+ run: pnpm --filter @knpkv/confluence-to-markdown... build
338
+ `
339
+
340
+ expect(dependencyClosureDiagnostics(invalid, ["@knpkv/confluence-to-markdown"])).toEqual([
341
+ "API update workflow must build @knpkv/confluence-to-markdown with its workspace dependency closure"
342
+ ])
343
+ expect(dependencyClosureDiagnostics(valid, ["@knpkv/confluence-to-markdown"])).toEqual([])
344
+ })
345
+
346
+ it.effect("builds every API consumer with its workspace dependencies", () =>
347
+ Effect.gen(function*() {
348
+ const confluence = yield* loadWorkflow("confluence-api-update.yml")
349
+ const jira = yield* loadWorkflow("jira-api-update.yml")
350
+ const clockify = yield* loadWorkflow("clockify-api-update.yml")
351
+
352
+ expect(dependencyClosureDiagnostics(confluence, ["@knpkv/confluence-to-markdown"])).toEqual([])
353
+ expect(dependencyClosureDiagnostics(jira, ["@knpkv/jira-cli", "@knpkv/jira-clockify"])).toEqual([])
354
+ expect(dependencyClosureDiagnostics(clockify, ["@knpkv/jira-clockify"])).toEqual([])
355
+ }))
356
+ })