@knpkv/confluence-to-markdown 0.2.0 → 0.4.2

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 (336) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +21 -0
  3. package/README.md +282 -14
  4. package/dist/ConfluenceAuth.d.ts +76 -0
  5. package/dist/ConfluenceAuth.d.ts.map +1 -0
  6. package/dist/ConfluenceAuth.js +366 -0
  7. package/dist/ConfluenceAuth.js.map +1 -0
  8. package/dist/ConfluenceClient.d.ts +26 -12
  9. package/dist/ConfluenceClient.d.ts.map +1 -1
  10. package/dist/ConfluenceClient.js +139 -97
  11. package/dist/ConfluenceClient.js.map +1 -1
  12. package/dist/ConfluenceConfig.d.ts +4 -24
  13. package/dist/ConfluenceConfig.d.ts.map +1 -1
  14. package/dist/ConfluenceConfig.js +45 -7
  15. package/dist/ConfluenceConfig.js.map +1 -1
  16. package/dist/ConfluenceError.d.ts +99 -16
  17. package/dist/ConfluenceError.d.ts.map +1 -1
  18. package/dist/ConfluenceError.js +88 -5
  19. package/dist/ConfluenceError.js.map +1 -1
  20. package/dist/GitError.d.ts +103 -0
  21. package/dist/GitError.d.ts.map +1 -0
  22. package/dist/GitError.js +85 -0
  23. package/dist/GitError.js.map +1 -0
  24. package/dist/GitService.d.ts +175 -0
  25. package/dist/GitService.d.ts.map +1 -0
  26. package/dist/GitService.js +431 -0
  27. package/dist/GitService.js.map +1 -0
  28. package/dist/LocalFileSystem.d.ts +29 -4
  29. package/dist/LocalFileSystem.d.ts.map +1 -1
  30. package/dist/LocalFileSystem.js +80 -6
  31. package/dist/LocalFileSystem.js.map +1 -1
  32. package/dist/MarkdownConverter.d.ts +49 -2
  33. package/dist/MarkdownConverter.d.ts.map +1 -1
  34. package/dist/MarkdownConverter.js +73 -111
  35. package/dist/MarkdownConverter.js.map +1 -1
  36. package/dist/SchemaConverterError.d.ts +108 -0
  37. package/dist/SchemaConverterError.d.ts.map +1 -0
  38. package/dist/SchemaConverterError.js +84 -0
  39. package/dist/SchemaConverterError.js.map +1 -0
  40. package/dist/Schemas.d.ts +225 -1
  41. package/dist/Schemas.d.ts.map +1 -1
  42. package/dist/Schemas.js +155 -6
  43. package/dist/Schemas.js.map +1 -1
  44. package/dist/SyncEngine.d.ts +30 -20
  45. package/dist/SyncEngine.d.ts.map +1 -1
  46. package/dist/SyncEngine.js +566 -117
  47. package/dist/SyncEngine.js.map +1 -1
  48. package/dist/ast/BlockNode.d.ts +468 -0
  49. package/dist/ast/BlockNode.d.ts.map +1 -0
  50. package/dist/ast/BlockNode.js +319 -0
  51. package/dist/ast/BlockNode.js.map +1 -0
  52. package/dist/ast/Document.d.ts +244 -0
  53. package/dist/ast/Document.d.ts.map +1 -0
  54. package/dist/ast/Document.js +69 -0
  55. package/dist/ast/Document.js.map +1 -0
  56. package/dist/ast/InlineNode.d.ts +477 -0
  57. package/dist/ast/InlineNode.d.ts.map +1 -0
  58. package/dist/ast/InlineNode.js +263 -0
  59. package/dist/ast/InlineNode.js.map +1 -0
  60. package/dist/ast/MacroNode.d.ts +267 -0
  61. package/dist/ast/MacroNode.d.ts.map +1 -0
  62. package/dist/ast/MacroNode.js +164 -0
  63. package/dist/ast/MacroNode.js.map +1 -0
  64. package/dist/ast/index.d.ts +10 -0
  65. package/dist/ast/index.d.ts.map +1 -0
  66. package/dist/ast/index.js +14 -0
  67. package/dist/ast/index.js.map +1 -0
  68. package/dist/bin.js +33 -149
  69. package/dist/bin.js.map +1 -1
  70. package/dist/commands/auth.d.ts +15 -0
  71. package/dist/commands/auth.d.ts.map +1 -0
  72. package/dist/commands/auth.js +86 -0
  73. package/dist/commands/auth.js.map +1 -0
  74. package/dist/commands/clone.d.ts +12 -0
  75. package/dist/commands/clone.d.ts.map +1 -0
  76. package/dist/commands/clone.js +93 -0
  77. package/dist/commands/clone.js.map +1 -0
  78. package/dist/commands/delete.d.ts +13 -0
  79. package/dist/commands/delete.d.ts.map +1 -0
  80. package/dist/commands/delete.js +48 -0
  81. package/dist/commands/delete.js.map +1 -0
  82. package/dist/commands/errorHandler.d.ts +14 -0
  83. package/dist/commands/errorHandler.d.ts.map +1 -0
  84. package/dist/commands/errorHandler.js +33 -0
  85. package/dist/commands/errorHandler.js.map +1 -0
  86. package/dist/commands/git.d.ts +22 -0
  87. package/dist/commands/git.d.ts.map +1 -0
  88. package/dist/commands/git.js +72 -0
  89. package/dist/commands/git.js.map +1 -0
  90. package/dist/commands/index.d.ts +11 -0
  91. package/dist/commands/index.d.ts.map +1 -0
  92. package/dist/commands/index.js +11 -0
  93. package/dist/commands/index.js.map +1 -0
  94. package/dist/commands/layers.d.ts +31 -0
  95. package/dist/commands/layers.d.ts.map +1 -0
  96. package/dist/commands/layers.js +137 -0
  97. package/dist/commands/layers.js.map +1 -0
  98. package/dist/commands/new.d.ts +9 -0
  99. package/dist/commands/new.d.ts.map +1 -0
  100. package/dist/commands/new.js +80 -0
  101. package/dist/commands/new.js.map +1 -0
  102. package/dist/commands/pageTree.d.ts +18 -0
  103. package/dist/commands/pageTree.d.ts.map +1 -0
  104. package/dist/commands/pageTree.js +20 -0
  105. package/dist/commands/pageTree.js.map +1 -0
  106. package/dist/commands/shared.d.ts +15 -0
  107. package/dist/commands/shared.d.ts.map +1 -0
  108. package/dist/commands/shared.js +27 -0
  109. package/dist/commands/shared.js.map +1 -0
  110. package/dist/commands/sync.d.ts +15 -0
  111. package/dist/commands/sync.d.ts.map +1 -0
  112. package/dist/commands/sync.js +101 -0
  113. package/dist/commands/sync.js.map +1 -0
  114. package/dist/index.d.ts +10 -1
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +14 -0
  117. package/dist/index.js.map +1 -1
  118. package/dist/internal/NodeLayers.d.ts +7 -0
  119. package/dist/internal/NodeLayers.d.ts.map +1 -0
  120. package/dist/internal/NodeLayers.js +19 -0
  121. package/dist/internal/NodeLayers.js.map +1 -0
  122. package/dist/internal/frontmatter.d.ts +10 -0
  123. package/dist/internal/frontmatter.d.ts.map +1 -1
  124. package/dist/internal/frontmatter.js +16 -0
  125. package/dist/internal/frontmatter.js.map +1 -1
  126. package/dist/internal/gitCommands.d.ts +78 -0
  127. package/dist/internal/gitCommands.d.ts.map +1 -0
  128. package/dist/internal/gitCommands.js +156 -0
  129. package/dist/internal/gitCommands.js.map +1 -0
  130. package/dist/internal/hashUtils.d.ts +42 -1
  131. package/dist/internal/hashUtils.d.ts.map +1 -1
  132. package/dist/internal/hashUtils.js +38 -2
  133. package/dist/internal/hashUtils.js.map +1 -1
  134. package/dist/internal/oauthServer.d.ts +55 -0
  135. package/dist/internal/oauthServer.d.ts.map +1 -0
  136. package/dist/internal/oauthServer.js +110 -0
  137. package/dist/internal/oauthServer.js.map +1 -0
  138. package/dist/internal/pathUtils.d.ts +21 -4
  139. package/dist/internal/pathUtils.d.ts.map +1 -1
  140. package/dist/internal/pathUtils.js +24 -13
  141. package/dist/internal/pathUtils.js.map +1 -1
  142. package/dist/internal/tokenStorage.d.ts +75 -0
  143. package/dist/internal/tokenStorage.d.ts.map +1 -0
  144. package/dist/internal/tokenStorage.js +149 -0
  145. package/dist/internal/tokenStorage.js.map +1 -0
  146. package/dist/internal/userCache.d.ts +42 -0
  147. package/dist/internal/userCache.d.ts.map +1 -0
  148. package/dist/internal/userCache.js +51 -0
  149. package/dist/internal/userCache.js.map +1 -0
  150. package/dist/parsers/ConfluenceParser.d.ts +26 -0
  151. package/dist/parsers/ConfluenceParser.d.ts.map +1 -0
  152. package/dist/parsers/ConfluenceParser.js +792 -0
  153. package/dist/parsers/ConfluenceParser.js.map +1 -0
  154. package/dist/parsers/MarkdownParser.d.ts +26 -0
  155. package/dist/parsers/MarkdownParser.d.ts.map +1 -0
  156. package/dist/parsers/MarkdownParser.js +873 -0
  157. package/dist/parsers/MarkdownParser.js.map +1 -0
  158. package/dist/parsers/index.d.ts +8 -0
  159. package/dist/parsers/index.d.ts.map +1 -0
  160. package/dist/parsers/index.js +8 -0
  161. package/dist/parsers/index.js.map +1 -0
  162. package/dist/schemas/ConfluenceSchema.d.ts +21 -0
  163. package/dist/schemas/ConfluenceSchema.d.ts.map +1 -0
  164. package/dist/schemas/ConfluenceSchema.js +38 -0
  165. package/dist/schemas/ConfluenceSchema.js.map +1 -0
  166. package/dist/schemas/ConversionSchema.d.ts +35 -0
  167. package/dist/schemas/ConversionSchema.d.ts.map +1 -0
  168. package/dist/schemas/ConversionSchema.js +208 -0
  169. package/dist/schemas/ConversionSchema.js.map +1 -0
  170. package/dist/schemas/MarkdownSchema.d.ts +21 -0
  171. package/dist/schemas/MarkdownSchema.d.ts.map +1 -0
  172. package/dist/schemas/MarkdownSchema.js +38 -0
  173. package/dist/schemas/MarkdownSchema.js.map +1 -0
  174. package/dist/schemas/hast/HastFromHtml.d.ts +27 -0
  175. package/dist/schemas/hast/HastFromHtml.d.ts.map +1 -0
  176. package/dist/schemas/hast/HastFromHtml.js +107 -0
  177. package/dist/schemas/hast/HastFromHtml.js.map +1 -0
  178. package/dist/schemas/hast/HastSchema.d.ts +195 -0
  179. package/dist/schemas/hast/HastSchema.d.ts.map +1 -0
  180. package/dist/schemas/hast/HastSchema.js +183 -0
  181. package/dist/schemas/hast/HastSchema.js.map +1 -0
  182. package/dist/schemas/hast/index.d.ts +9 -0
  183. package/dist/schemas/hast/index.d.ts.map +1 -0
  184. package/dist/schemas/hast/index.js +3 -0
  185. package/dist/schemas/hast/index.js.map +1 -0
  186. package/dist/schemas/index.d.ts +14 -0
  187. package/dist/schemas/index.d.ts.map +1 -0
  188. package/dist/schemas/index.js +16 -0
  189. package/dist/schemas/index.js.map +1 -0
  190. package/dist/schemas/mdast/MdastFromMarkdown.d.ts +30 -0
  191. package/dist/schemas/mdast/MdastFromMarkdown.d.ts.map +1 -0
  192. package/dist/schemas/mdast/MdastFromMarkdown.js +79 -0
  193. package/dist/schemas/mdast/MdastFromMarkdown.js.map +1 -0
  194. package/dist/schemas/mdast/MdastSchema.d.ts +385 -0
  195. package/dist/schemas/mdast/MdastSchema.d.ts.map +1 -0
  196. package/dist/schemas/mdast/MdastSchema.js +266 -0
  197. package/dist/schemas/mdast/MdastSchema.js.map +1 -0
  198. package/dist/schemas/mdast/index.d.ts +10 -0
  199. package/dist/schemas/mdast/index.d.ts.map +1 -0
  200. package/dist/schemas/mdast/index.js +4 -0
  201. package/dist/schemas/mdast/index.js.map +1 -0
  202. package/dist/schemas/mdast/mdastToString.d.ts +13 -0
  203. package/dist/schemas/mdast/mdastToString.d.ts.map +1 -0
  204. package/dist/schemas/mdast/mdastToString.js +85 -0
  205. package/dist/schemas/mdast/mdastToString.js.map +1 -0
  206. package/dist/schemas/nodes/block/BlockSchema.d.ts +43 -0
  207. package/dist/schemas/nodes/block/BlockSchema.d.ts.map +1 -0
  208. package/dist/schemas/nodes/block/BlockSchema.js +634 -0
  209. package/dist/schemas/nodes/block/BlockSchema.js.map +1 -0
  210. package/dist/schemas/nodes/block/index.d.ts +7 -0
  211. package/dist/schemas/nodes/block/index.d.ts.map +1 -0
  212. package/dist/schemas/nodes/block/index.js +7 -0
  213. package/dist/schemas/nodes/block/index.js.map +1 -0
  214. package/dist/schemas/nodes/index.d.ts +9 -0
  215. package/dist/schemas/nodes/index.d.ts.map +1 -0
  216. package/dist/schemas/nodes/index.js +12 -0
  217. package/dist/schemas/nodes/index.js.map +1 -0
  218. package/dist/schemas/nodes/inline/InlineSchema.d.ts +48 -0
  219. package/dist/schemas/nodes/inline/InlineSchema.d.ts.map +1 -0
  220. package/dist/schemas/nodes/inline/InlineSchema.js +436 -0
  221. package/dist/schemas/nodes/inline/InlineSchema.js.map +1 -0
  222. package/dist/schemas/nodes/inline/index.d.ts +7 -0
  223. package/dist/schemas/nodes/inline/index.d.ts.map +1 -0
  224. package/dist/schemas/nodes/inline/index.js +7 -0
  225. package/dist/schemas/nodes/inline/index.js.map +1 -0
  226. package/dist/schemas/nodes/macro/MacroSchema.d.ts +27 -0
  227. package/dist/schemas/nodes/macro/MacroSchema.d.ts.map +1 -0
  228. package/dist/schemas/nodes/macro/MacroSchema.js +162 -0
  229. package/dist/schemas/nodes/macro/MacroSchema.js.map +1 -0
  230. package/dist/schemas/nodes/macro/index.d.ts +7 -0
  231. package/dist/schemas/nodes/macro/index.d.ts.map +1 -0
  232. package/dist/schemas/nodes/macro/index.js +7 -0
  233. package/dist/schemas/nodes/macro/index.js.map +1 -0
  234. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +24 -0
  235. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -0
  236. package/dist/schemas/preprocessing/ConfluencePreprocessor.js +351 -0
  237. package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -0
  238. package/dist/schemas/preprocessing/index.d.ts +8 -0
  239. package/dist/schemas/preprocessing/index.d.ts.map +1 -0
  240. package/dist/schemas/preprocessing/index.js +2 -0
  241. package/dist/schemas/preprocessing/index.js.map +1 -0
  242. package/dist/serializers/ConfluenceSerializer.d.ts +30 -0
  243. package/dist/serializers/ConfluenceSerializer.d.ts.map +1 -0
  244. package/dist/serializers/ConfluenceSerializer.js +551 -0
  245. package/dist/serializers/ConfluenceSerializer.js.map +1 -0
  246. package/dist/serializers/MarkdownSerializer.d.ts +34 -0
  247. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
  248. package/dist/serializers/MarkdownSerializer.js +355 -0
  249. package/dist/serializers/MarkdownSerializer.js.map +1 -0
  250. package/dist/serializers/index.d.ts +8 -0
  251. package/dist/serializers/index.d.ts.map +1 -0
  252. package/dist/serializers/index.js +8 -0
  253. package/dist/serializers/index.js.map +1 -0
  254. package/package.json +32 -21
  255. package/src/ConfluenceAuth.ts +581 -0
  256. package/src/ConfluenceClient.ts +230 -165
  257. package/src/ConfluenceConfig.ts +63 -7
  258. package/src/ConfluenceError.ts +110 -14
  259. package/src/GitError.ts +92 -0
  260. package/src/GitService.ts +859 -0
  261. package/src/LocalFileSystem.ts +179 -9
  262. package/src/MarkdownConverter.ts +126 -122
  263. package/src/SchemaConverterError.ts +108 -0
  264. package/src/Schemas.ts +223 -6
  265. package/src/SyncEngine.ts +745 -162
  266. package/src/ast/BlockNode.ts +425 -0
  267. package/src/ast/Document.ts +90 -0
  268. package/src/ast/InlineNode.ts +323 -0
  269. package/src/ast/MacroNode.ts +245 -0
  270. package/src/ast/index.ts +83 -0
  271. package/src/bin.ts +50 -249
  272. package/src/commands/auth.ts +117 -0
  273. package/src/commands/clone.ts +145 -0
  274. package/src/commands/delete.ts +57 -0
  275. package/src/commands/errorHandler.ts +32 -0
  276. package/src/commands/git.ts +114 -0
  277. package/src/commands/index.ts +10 -0
  278. package/src/commands/layers.ts +211 -0
  279. package/src/commands/new.ts +99 -0
  280. package/src/commands/pageTree.ts +40 -0
  281. package/src/commands/shared.ts +35 -0
  282. package/src/commands/sync.ts +129 -0
  283. package/src/index.ts +21 -1
  284. package/src/internal/NodeLayers.ts +21 -0
  285. package/src/internal/frontmatter.ts +21 -0
  286. package/src/internal/gitCommands.ts +229 -0
  287. package/src/internal/hashUtils.ts +65 -3
  288. package/src/internal/oauthServer.ts +199 -0
  289. package/src/internal/pathUtils.ts +34 -17
  290. package/src/internal/tokenStorage.ts +240 -0
  291. package/src/internal/userCache.ts +90 -0
  292. package/src/parsers/ConfluenceParser.ts +950 -0
  293. package/src/parsers/MarkdownParser.ts +1198 -0
  294. package/src/parsers/index.ts +8 -0
  295. package/src/schemas/ConfluenceSchema.ts +56 -0
  296. package/src/schemas/ConversionSchema.ts +318 -0
  297. package/src/schemas/MarkdownSchema.ts +56 -0
  298. package/src/schemas/hast/HastFromHtml.ts +153 -0
  299. package/src/schemas/hast/HastSchema.ts +274 -0
  300. package/src/schemas/hast/index.ts +35 -0
  301. package/src/schemas/index.ts +20 -0
  302. package/src/schemas/mdast/MdastFromMarkdown.ts +118 -0
  303. package/src/schemas/mdast/MdastSchema.ts +566 -0
  304. package/src/schemas/mdast/index.ts +59 -0
  305. package/src/schemas/mdast/mdastToString.ts +102 -0
  306. package/src/schemas/nodes/block/BlockSchema.ts +773 -0
  307. package/src/schemas/nodes/block/index.ts +13 -0
  308. package/src/schemas/nodes/index.ts +20 -0
  309. package/src/schemas/nodes/inline/InlineSchema.ts +523 -0
  310. package/src/schemas/nodes/inline/index.ts +14 -0
  311. package/src/schemas/nodes/macro/MacroSchema.ts +226 -0
  312. package/src/schemas/nodes/macro/index.ts +6 -0
  313. package/src/schemas/preprocessing/ConfluencePreprocessor.ts +446 -0
  314. package/src/schemas/preprocessing/index.ts +8 -0
  315. package/src/serializers/ConfluenceSerializer.ts +717 -0
  316. package/src/serializers/MarkdownSerializer.ts +493 -0
  317. package/src/serializers/index.ts +8 -0
  318. package/test/GitService.test.ts +209 -0
  319. package/test/MarkdownConverter.test.ts +37 -3
  320. package/test/Schemas.test.ts +97 -2
  321. package/test/ast/BlockNode.test.ts +265 -0
  322. package/test/ast/Document.test.ts +126 -0
  323. package/test/ast/InlineNode.test.ts +161 -0
  324. package/test/fixtures/integration-test.html.fixture +103 -0
  325. package/test/fixtures/integration-test.md.expected +257 -0
  326. package/test/integration.test.ts +269 -0
  327. package/test/oauthServer.test.ts +50 -0
  328. package/test/parsers/ConfluenceParser.test.ts +283 -0
  329. package/test/schemas/ConfluencePreprocessor.test.ts +180 -0
  330. package/test/schemas/ConversionSchema.test.ts +159 -0
  331. package/test/schemas/HastSchema.test.ts +138 -0
  332. package/test/schemas/MdastSchema.test.ts +145 -0
  333. package/test/schemas/nodes/block/BlockSchema.test.ts +173 -0
  334. package/test/schemas/nodes/inline/InlineSchema.test.ts +198 -0
  335. package/test/schemas/nodes/macro/MacroSchema.test.ts +142 -0
  336. package/test/tokenStorage.test.ts +99 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * OAuth2 authentication service for Confluence with browser-based login and token refresh.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Shared OAuth infra**: Delegates to `@knpkv/atlassian-common/auth` for token exchange,
7
+ * refresh, and revocation. This module adds Confluence-specific scopes and the local
8
+ * callback server flow.
9
+ * - **Refresh lock**: Same `Ref<Option<Deferred>>` pattern as Jira CLI to prevent
10
+ * concurrent refresh races.
11
+ *
12
+ * @module
13
+ */
14
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
15
+ import * as NodePath from "@effect/platform-node/NodePath"
16
+ import * as Command from "@effect/platform/Command"
17
+ import * as CommandExecutor from "@effect/platform/CommandExecutor"
18
+ import * as HttpClient from "@effect/platform/HttpClient"
19
+ import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
20
+ import * as Console from "effect/Console"
21
+ import * as Context from "effect/Context"
22
+ import * as Deferred from "effect/Deferred"
23
+ import * as Effect from "effect/Effect"
24
+ import * as Layer from "effect/Layer"
25
+ import * as Option from "effect/Option"
26
+ import * as Ref from "effect/Ref"
27
+ import * as Schema from "effect/Schema"
28
+ import type { FileSystemError } from "./ConfluenceError.js"
29
+ import { AuthMissingError, OAuthError } from "./ConfluenceError.js"
30
+ import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
31
+ import { startCallbackServer } from "./internal/oauthServer.js"
32
+ import {
33
+ deleteToken,
34
+ HomeDirectoryLive,
35
+ loadOAuthConfig,
36
+ loadToken,
37
+ saveOAuthConfig,
38
+ saveToken
39
+ } from "./internal/tokenStorage.js"
40
+ import type { OAuthConfig, OAuthToken, OAuthUser } from "./Schemas.js"
41
+
42
+ // Layer for token storage operations (FileSystem + Path + HomeDirectory)
43
+ const TokenStorageLive = Layer.mergeAll(
44
+ NodeFileSystem.layer,
45
+ NodePath.layer,
46
+ HomeDirectoryLive
47
+ )
48
+
49
+ /**
50
+ * Generate a cryptographically secure UUID v4.
51
+ * Uses Web Crypto API (available in all modern runtimes).
52
+ * Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
53
+ */
54
+ const generateUUID = (): Effect.Effect<string> =>
55
+ Effect.sync(() => {
56
+ const bytes = new Uint8Array(16)
57
+ globalThis.crypto.getRandomValues(bytes)
58
+
59
+ // Set version (4) and variant bits per RFC 4122
60
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40 // version 4
61
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80 // variant 10xx
62
+
63
+ const hex = Array.from(bytes)
64
+ .map((b) => b.toString(16).padStart(2, "0"))
65
+ .join("")
66
+
67
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
68
+ })
69
+
70
+ const OAUTH_SCOPES = [
71
+ // Granular scopes (v2 API)
72
+ "read:page:confluence",
73
+ "write:page:confluence",
74
+ "delete:page:confluence",
75
+ // User identity
76
+ "read:me",
77
+ "offline_access"
78
+ ].join(" ")
79
+
80
+ // API endpoints
81
+ const AUTH_URL = "https://auth.atlassian.com/authorize"
82
+ const TOKEN_URL = "https://auth.atlassian.com/oauth/token"
83
+ const REVOKE_URL = "https://auth.atlassian.com/oauth/revoke"
84
+ const RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
85
+ const ME_URL = "https://api.atlassian.com/me"
86
+
87
+ // Response schemas
88
+ const TokenResponseSchema = Schema.Struct({
89
+ access_token: Schema.String,
90
+ refresh_token: Schema.String,
91
+ expires_in: Schema.Number,
92
+ scope: Schema.String,
93
+ token_type: Schema.String
94
+ })
95
+
96
+ const AccessibleResourceSchema = Schema.Struct({
97
+ id: Schema.String,
98
+ name: Schema.String,
99
+ url: Schema.String,
100
+ scopes: Schema.Array(Schema.String)
101
+ })
102
+
103
+ const UserInfoSchema = Schema.Struct({
104
+ account_id: Schema.String,
105
+ name: Schema.String,
106
+ email: Schema.String
107
+ })
108
+
109
+ /**
110
+ * Options for the login method.
111
+ */
112
+ export interface LoginOptions {
113
+ /** Site URL to select (for accounts with multiple sites) */
114
+ readonly siteUrl?: string
115
+ }
116
+
117
+ /**
118
+ * Information about an accessible Confluence site.
119
+ */
120
+ export interface AccessibleSite {
121
+ readonly id: string
122
+ readonly name: string
123
+ readonly url: string
124
+ }
125
+
126
+ /**
127
+ * ConfluenceAuth service interface.
128
+ *
129
+ * @category Services
130
+ */
131
+ export interface ConfluenceAuthService {
132
+ /** Configure OAuth client credentials */
133
+ readonly configure: (config: OAuthConfig) => Effect.Effect<void, FileSystemError>
134
+ /** Check if OAuth is configured */
135
+ readonly isConfigured: () => Effect.Effect<boolean, FileSystemError>
136
+ /** Start OAuth login flow. Returns list of sites if multiple are available. */
137
+ readonly login: (
138
+ options?: LoginOptions
139
+ ) => Effect.Effect<ReadonlyArray<AccessibleSite> | void, OAuthError | FileSystemError>
140
+ /** Remove stored authentication */
141
+ readonly logout: () => Effect.Effect<void, OAuthError | FileSystemError>
142
+ /** Get access token, refreshing if needed */
143
+ readonly getAccessToken: () => Effect.Effect<string, AuthMissingError | OAuthError | FileSystemError>
144
+ /** Get cloud ID from stored token */
145
+ readonly getCloudId: () => Effect.Effect<string, AuthMissingError | FileSystemError>
146
+ /** Get current user info from stored token */
147
+ readonly getCurrentUser: () => Effect.Effect<OAuthUser | null, FileSystemError>
148
+ /** Check if user is logged in */
149
+ readonly isLoggedIn: () => Effect.Effect<boolean, FileSystemError>
150
+ }
151
+
152
+ /**
153
+ * ConfluenceAuth service tag.
154
+ *
155
+ * @example
156
+ * ```typescript
157
+ * import { Effect } from "effect"
158
+ * import { ConfluenceAuth } from "@knpkv/confluence-to-markdown/ConfluenceAuth"
159
+ *
160
+ * Effect.gen(function* () {
161
+ * const auth = yield* ConfluenceAuth
162
+ * const isLoggedIn = yield* auth.isLoggedIn()
163
+ * if (!isLoggedIn) {
164
+ * yield* auth.login()
165
+ * }
166
+ * })
167
+ * ```
168
+ *
169
+ * @category Services
170
+ */
171
+ export class ConfluenceAuth extends Context.Tag("@knpkv/confluence-to-markdown/ConfluenceAuth")<
172
+ ConfluenceAuth,
173
+ ConfluenceAuthService
174
+ >() {}
175
+
176
+ const buildAuthUrl = (clientId: string, state: string, port: number): string => {
177
+ const params = new URLSearchParams({
178
+ audience: "api.atlassian.com",
179
+ client_id: clientId,
180
+ scope: OAUTH_SCOPES,
181
+ redirect_uri: `http://localhost:${port}/callback`,
182
+ state,
183
+ response_type: "code",
184
+ prompt: "consent"
185
+ })
186
+ return `${AUTH_URL}?${params.toString()}`
187
+ }
188
+
189
+ // Wrap token storage operations with their required layers
190
+ const loadTokenOp = () => loadToken().pipe(Effect.provide(TokenStorageLive))
191
+ const saveTokenOp = (token: OAuthToken) => saveToken(token).pipe(Effect.provide(TokenStorageLive))
192
+ const deleteTokenOp = () => deleteToken().pipe(Effect.provide(TokenStorageLive))
193
+ const loadOAuthConfigOp = () => loadOAuthConfig().pipe(Effect.provide(TokenStorageLive))
194
+ const saveOAuthConfigOp = (config: OAuthConfig) => saveOAuthConfig(config).pipe(Effect.provide(TokenStorageLive))
195
+
196
+ const make = Effect.gen(function*() {
197
+ const httpClient = yield* HttpClient.HttpClient
198
+ const commandExecutor = yield* CommandExecutor.CommandExecutor
199
+
200
+ // Ref to track ongoing refresh operation to prevent concurrent refreshes
201
+ const refreshLock = yield* Ref.make<Option.Option<Deferred.Deferred<OAuthToken, OAuthError | FileSystemError>>>(
202
+ Option.none()
203
+ )
204
+
205
+ const openBrowserImpl = (url: string): Effect.Effect<void, OAuthError> =>
206
+ Effect.gen(function*() {
207
+ const platform = process.platform
208
+ let command: Command.Command
209
+
210
+ if (platform === "darwin") {
211
+ command = Command.make("open", url)
212
+ } else if (platform === "win32") {
213
+ command = Command.make("cmd", "/c", "start", "", url)
214
+ } else {
215
+ command = Command.make("xdg-open", url)
216
+ }
217
+
218
+ yield* Command.exitCode(command).pipe(
219
+ Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, commandExecutor))
220
+ )
221
+ }).pipe(
222
+ Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
223
+ )
224
+
225
+ const getConfig = (): Effect.Effect<OAuthConfig, OAuthError | FileSystemError> =>
226
+ Effect.gen(function*() {
227
+ const config = yield* loadOAuthConfigOp()
228
+ if (config === null) {
229
+ return yield* Effect.fail(
230
+ new OAuthError({
231
+ step: "authorize",
232
+ cause: "OAuth not configured. Run 'confluence auth configure' first."
233
+ })
234
+ )
235
+ }
236
+ return config
237
+ })
238
+
239
+ const exchangeCodeForTokens = (
240
+ code: string,
241
+ config: OAuthConfig,
242
+ port: number
243
+ ): Effect.Effect<Schema.Schema.Type<typeof TokenResponseSchema>, OAuthError> =>
244
+ Effect.gen(function*() {
245
+ const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
246
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
247
+ HttpClientRequest.bodyJson({
248
+ grant_type: "authorization_code",
249
+ client_id: config.clientId,
250
+ client_secret: config.clientSecret,
251
+ code,
252
+ redirect_uri: `http://localhost:${port}/callback`
253
+ })
254
+ )
255
+
256
+ const response = yield* httpClient.execute(request)
257
+ const body = yield* response.json
258
+
259
+ return yield* Schema.decodeUnknown(TokenResponseSchema)(body)
260
+ }).pipe(
261
+ Effect.mapError((cause) => new OAuthError({ step: "token", cause }))
262
+ )
263
+
264
+ const getAccessibleResources = (
265
+ accessToken: string
266
+ ): Effect.Effect<ReadonlyArray<Schema.Schema.Type<typeof AccessibleResourceSchema>>, OAuthError> =>
267
+ Effect.gen(function*() {
268
+ const request = HttpClientRequest.get(RESOURCES_URL).pipe(
269
+ HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
270
+ HttpClientRequest.setHeader("Accept", "application/json")
271
+ )
272
+
273
+ const response = yield* httpClient.execute(request)
274
+ const body = yield* response.json
275
+
276
+ return yield* Schema.decodeUnknown(Schema.Array(AccessibleResourceSchema))(body)
277
+ }).pipe(
278
+ Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
279
+ )
280
+
281
+ const getUserInfo = (
282
+ accessToken: string
283
+ ): Effect.Effect<OAuthUser, OAuthError> =>
284
+ Effect.gen(function*() {
285
+ const request = HttpClientRequest.get(ME_URL).pipe(
286
+ HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
287
+ HttpClientRequest.setHeader("Accept", "application/json")
288
+ )
289
+
290
+ const response = yield* httpClient.execute(request)
291
+ const body = yield* response.json
292
+
293
+ return yield* Schema.decodeUnknown(UserInfoSchema)(body)
294
+ }).pipe(
295
+ Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
296
+ )
297
+
298
+ const refreshToken = (
299
+ token: OAuthToken,
300
+ config: OAuthConfig
301
+ ): Effect.Effect<OAuthToken, OAuthError | FileSystemError> =>
302
+ Effect.gen(function*() {
303
+ const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
304
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
305
+ HttpClientRequest.bodyJson({
306
+ grant_type: "refresh_token",
307
+ client_id: config.clientId,
308
+ client_secret: config.clientSecret,
309
+ refresh_token: token.refresh_token
310
+ }),
311
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
312
+ )
313
+
314
+ const response = yield* httpClient.execute(request).pipe(
315
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
316
+ )
317
+ const body = yield* response.json.pipe(
318
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
319
+ )
320
+ const tokenResponse = yield* Schema.decodeUnknown(TokenResponseSchema)(body).pipe(
321
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
322
+ )
323
+
324
+ const updated: OAuthToken = {
325
+ ...token,
326
+ access_token: tokenResponse.access_token,
327
+ refresh_token: tokenResponse.refresh_token,
328
+ expires_at: Date.now() + tokenResponse.expires_in * 1000,
329
+ scope: tokenResponse.scope
330
+ }
331
+
332
+ yield* saveTokenOp(updated)
333
+ return updated
334
+ })
335
+
336
+ const revokeToken = (
337
+ token: OAuthToken,
338
+ config: OAuthConfig
339
+ ): Effect.Effect<void, OAuthError> =>
340
+ Effect.gen(function*() {
341
+ // Revoke refresh token (this also invalidates access token)
342
+ const request = yield* HttpClientRequest.post(REVOKE_URL).pipe(
343
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
344
+ HttpClientRequest.bodyJson({
345
+ client_id: config.clientId,
346
+ client_secret: config.clientSecret,
347
+ token: token.refresh_token
348
+ }),
349
+ Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
350
+ )
351
+
352
+ const response = yield* httpClient.execute(request).pipe(
353
+ Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
354
+ )
355
+
356
+ // Validate response status
357
+ if (response.status >= 400) {
358
+ return yield* Effect.fail(
359
+ new OAuthError({
360
+ step: "revoke",
361
+ cause: `Token revocation failed with status ${response.status}`
362
+ })
363
+ )
364
+ }
365
+ })
366
+
367
+ const configure: ConfluenceAuthService["configure"] = (config) => saveOAuthConfigOp(config)
368
+
369
+ const isConfigured: ConfluenceAuthService["isConfigured"] = () =>
370
+ Effect.gen(function*() {
371
+ const config = yield* loadOAuthConfigOp()
372
+ return config !== null
373
+ })
374
+
375
+ const login: ConfluenceAuthService["login"] = (options) =>
376
+ Effect.gen(function*() {
377
+ const config = yield* getConfig()
378
+ const state = yield* generateUUID()
379
+
380
+ const { codePromise, port, shutdown } = yield* startCallbackServer(state).pipe(
381
+ Effect.provide(HttpServerFactoryLive)
382
+ )
383
+ const authUrl = buildAuthUrl(config.clientId, state, port)
384
+
385
+ yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
386
+ yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
387
+ yield* openBrowserImpl(authUrl)
388
+ yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
389
+
390
+ const code = yield* codePromise.pipe(
391
+ Effect.timeout("5 minutes"),
392
+ Effect.catchTag("TimeoutException", () =>
393
+ Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" })))
394
+ )
395
+
396
+ yield* shutdown
397
+
398
+ yield* Console.log("Exchanging code for tokens...")
399
+ const tokens = yield* exchangeCodeForTokens(code, config, port)
400
+
401
+ yield* Console.log("Fetching accessible sites...")
402
+ const sites = yield* getAccessibleResources(tokens.access_token)
403
+
404
+ if (sites.length === 0) {
405
+ return yield* Effect.fail(
406
+ new OAuthError({
407
+ step: "authorize",
408
+ cause: "No Confluence sites found for this account"
409
+ })
410
+ )
411
+ }
412
+
413
+ let site: Schema.Schema.Type<typeof AccessibleResourceSchema>
414
+
415
+ if (sites.length > 1) {
416
+ // If siteUrl provided, try to match it
417
+ if (options?.siteUrl) {
418
+ const matched = sites.find((s) =>
419
+ s.url === options.siteUrl
420
+ )
421
+ if (!matched) {
422
+ const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
423
+ return yield* Effect.fail(
424
+ new OAuthError({
425
+ step: "authorize",
426
+ cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
427
+ })
428
+ )
429
+ }
430
+ site = matched
431
+ } else {
432
+ // Return sites list for user to choose
433
+ yield* Console.log("Multiple Confluence sites found. Please select one:")
434
+ for (const s of sites) {
435
+ yield* Console.log(` - ${s.name}: ${s.url}`)
436
+ }
437
+ yield* Console.log("\nRun 'confluence auth login --site <url>' to select a site")
438
+ return sites.map((s) => ({ id: s.id, name: s.name, url: s.url }))
439
+ }
440
+ } else {
441
+ site = sites[0]!
442
+ }
443
+
444
+ yield* Console.log("Fetching user info...")
445
+ const user = yield* getUserInfo(tokens.access_token)
446
+
447
+ const tokenData: OAuthToken = {
448
+ access_token: tokens.access_token,
449
+ refresh_token: tokens.refresh_token,
450
+ expires_at: Date.now() + tokens.expires_in * 1000,
451
+ scope: tokens.scope,
452
+ cloud_id: site.id,
453
+ site_url: site.url,
454
+ user: {
455
+ account_id: user.account_id,
456
+ name: user.name,
457
+ email: user.email
458
+ }
459
+ }
460
+
461
+ yield* saveTokenOp(tokenData)
462
+ yield* Console.log(`Logged in as ${user.name} (${user.email})`)
463
+ return undefined
464
+ })
465
+
466
+ const logout: ConfluenceAuthService["logout"] = () =>
467
+ Effect.gen(function*() {
468
+ const token = yield* loadTokenOp()
469
+ if (token === null) {
470
+ yield* Console.log("Not logged in")
471
+ return
472
+ }
473
+
474
+ // Try to revoke the token with Atlassian (best effort)
475
+ const config = yield* loadOAuthConfigOp()
476
+ if (config !== null) {
477
+ yield* revokeToken(token, config).pipe(
478
+ Effect.tap(() => Effect.log("Token revoked with Atlassian")),
479
+ Effect.catchAll((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
480
+ )
481
+ }
482
+
483
+ yield* deleteTokenOp()
484
+ })
485
+
486
+ const getAccessToken: ConfluenceAuthService["getAccessToken"] = () =>
487
+ Effect.gen(function*() {
488
+ const token = yield* loadTokenOp()
489
+ if (token === null) {
490
+ return yield* Effect.fail(new AuthMissingError())
491
+ }
492
+
493
+ const now = Date.now()
494
+ const buffer = 5 * 60 * 1000 // 5 minutes
495
+
496
+ if (token.expires_at - buffer > now) {
497
+ return token.access_token
498
+ }
499
+
500
+ // Check if refresh is already in progress
501
+ const existing = yield* Ref.get(refreshLock)
502
+ if (Option.isSome(existing)) {
503
+ // Wait for existing refresh to complete
504
+ const refreshed = yield* Deferred.await(existing.value)
505
+ return refreshed.access_token
506
+ }
507
+
508
+ // Start new refresh operation
509
+ const deferred = yield* Deferred.make<OAuthToken, OAuthError | FileSystemError>()
510
+ yield* Ref.set(refreshLock, Option.some(deferred))
511
+
512
+ const config = yield* getConfig()
513
+ yield* Console.log("Token expired, refreshing...")
514
+
515
+ const result = yield* refreshToken(token, config).pipe(
516
+ Effect.tap((refreshed) => Deferred.succeed(deferred, refreshed)),
517
+ Effect.tapError((error) => Deferred.fail(deferred, error)),
518
+ Effect.ensuring(Ref.set(refreshLock, Option.none())),
519
+ Effect.catchTag("OAuthError", (error) => {
520
+ // If refresh fails (e.g., refresh token expired), clear tokens and prompt re-login
521
+ if (error.step === "refresh") {
522
+ return Effect.gen(function*() {
523
+ yield* deleteTokenOp()
524
+ return yield* Effect.fail(
525
+ new OAuthError({
526
+ step: "refresh",
527
+ cause: "Refresh token expired. Please run 'confluence auth login' to re-authenticate."
528
+ })
529
+ )
530
+ })
531
+ }
532
+ return Effect.fail(error)
533
+ })
534
+ )
535
+
536
+ return result.access_token
537
+ })
538
+
539
+ const getCloudId: ConfluenceAuthService["getCloudId"] = () =>
540
+ Effect.gen(function*() {
541
+ const token = yield* loadTokenOp()
542
+ if (token === null) {
543
+ return yield* Effect.fail(new AuthMissingError())
544
+ }
545
+ return token.cloud_id
546
+ })
547
+
548
+ const getCurrentUser: ConfluenceAuthService["getCurrentUser"] = () =>
549
+ Effect.gen(function*() {
550
+ const token = yield* loadTokenOp()
551
+ return token?.user ?? null
552
+ })
553
+
554
+ const isLoggedIn: ConfluenceAuthService["isLoggedIn"] = () =>
555
+ Effect.gen(function*() {
556
+ const token = yield* loadTokenOp()
557
+ return token !== null
558
+ })
559
+
560
+ return ConfluenceAuth.of({
561
+ configure,
562
+ isConfigured,
563
+ login,
564
+ logout,
565
+ getAccessToken,
566
+ getCloudId,
567
+ getCurrentUser,
568
+ isLoggedIn
569
+ })
570
+ })
571
+
572
+ /**
573
+ * Layer for ConfluenceAuth service.
574
+ *
575
+ * @category Layers
576
+ */
577
+ export const layer: Layer.Layer<
578
+ ConfluenceAuth,
579
+ never,
580
+ HttpClient.HttpClient | CommandExecutor.CommandExecutor
581
+ > = Layer.effect(ConfluenceAuth, make)