expandai 0.0.2 → 0.0.4

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 (271) hide show
  1. package/README.md +42 -5
  2. package/build/dts/ApiClient.d.ts +128 -194
  3. package/build/dts/ApiClient.d.ts.map +1 -1
  4. package/build/dts/Auth/Domain.d.ts +95 -0
  5. package/build/dts/Auth/Domain.d.ts.map +1 -0
  6. package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
  7. package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
  8. package/build/dts/Auth/PendingInit.d.ts +35 -0
  9. package/build/dts/Auth/PendingInit.d.ts.map +1 -0
  10. package/build/dts/Auth/Pkce.d.ts +10 -0
  11. package/build/dts/Auth/Pkce.d.ts.map +1 -0
  12. package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
  13. package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
  14. package/build/dts/Authentication.d.ts +10 -9
  15. package/build/dts/Authentication.d.ts.map +1 -1
  16. package/build/dts/BrowserOAuth.d.ts +7 -7
  17. package/build/dts/BrowserOAuth.d.ts.map +1 -1
  18. package/build/dts/CommandPath.d.ts +2 -0
  19. package/build/dts/CommandPath.d.ts.map +1 -0
  20. package/build/dts/Commands/Fetch.d.ts.map +1 -1
  21. package/build/dts/Commands/Hook.d.ts +5 -5
  22. package/build/dts/Commands/Init.d.ts +55 -0
  23. package/build/dts/Commands/Init.d.ts.map +1 -0
  24. package/build/dts/Commands/Mcp.d.ts +6 -6
  25. package/build/dts/Commands/Search.d.ts.map +1 -1
  26. package/build/dts/Commands/Skill.d.ts +4 -4
  27. package/build/dts/Commands/Status.d.ts +27 -0
  28. package/build/dts/Commands/Status.d.ts.map +1 -0
  29. package/build/dts/Commands/Telemetry.d.ts +7 -0
  30. package/build/dts/Commands/Telemetry.d.ts.map +1 -0
  31. package/build/dts/Commands/Uninstall.d.ts +4 -4
  32. package/build/dts/Config.d.ts +108 -17
  33. package/build/dts/Config.d.ts.map +1 -1
  34. package/build/dts/Hook/SnapshotNote.d.ts +8 -4
  35. package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
  36. package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
  37. package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
  38. package/build/dts/Init/ClaudeProfile.d.ts +77 -0
  39. package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
  40. package/build/dts/Init/Domain.d.ts +139 -0
  41. package/build/dts/Init/Domain.d.ts.map +1 -0
  42. package/build/dts/Init/HarnessAdapter.d.ts +33 -0
  43. package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
  44. package/build/dts/Init/HarnessDetector.d.ts +19 -0
  45. package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
  46. package/build/dts/Init/HarnessRegistry.d.ts +50 -0
  47. package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
  48. package/build/dts/Init/MutationTransaction.d.ts +96 -0
  49. package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
  50. package/build/dts/Init/Service.d.ts +72 -0
  51. package/build/dts/Init/Service.d.ts.map +1 -0
  52. package/build/dts/Init/StandardAdapters.d.ts +42 -0
  53. package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
  54. package/build/dts/Init/Status.d.ts +149 -0
  55. package/build/dts/Init/Status.d.ts.map +1 -0
  56. package/build/dts/Install/Claude/Hook.d.ts +57 -29
  57. package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
  58. package/build/dts/Install/Claude/Mcp.d.ts +21 -18
  59. package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
  60. package/build/dts/Install/Claude/Skill.d.ts +14 -15
  61. package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
  62. package/build/dts/Install/Codex/Mcp.d.ts +14 -8
  63. package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
  64. package/build/dts/Install/Codex/Skill.d.ts +4 -2
  65. package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
  66. package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
  67. package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
  68. package/build/dts/Install/Cursor/Skill.d.ts +4 -2
  69. package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
  70. package/build/dts/Install/Domain.d.ts +35 -5
  71. package/build/dts/Install/Domain.d.ts.map +1 -1
  72. package/build/dts/Install/Hook.d.ts +4 -4
  73. package/build/dts/Install/Hook.d.ts.map +1 -1
  74. package/build/dts/Install/Mcp.d.ts +5 -5
  75. package/build/dts/Install/Mcp.d.ts.map +1 -1
  76. package/build/dts/Install/Opencode/Hook.d.ts +4 -3
  77. package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
  78. package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
  79. package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
  80. package/build/dts/Install/Opencode/Skill.d.ts +4 -2
  81. package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
  82. package/build/dts/Install/Skill.d.ts +3 -3
  83. package/build/dts/Install/Uninstall.d.ts +6 -6
  84. package/build/dts/Install/Uninstall.d.ts.map +1 -1
  85. package/build/dts/MarkdownAnsi.d.ts +29 -0
  86. package/build/dts/MarkdownAnsi.d.ts.map +1 -0
  87. package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
  88. package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
  89. package/build/dts/Mcp/Server.d.ts +1 -1
  90. package/build/dts/Mcp/Server.d.ts.map +1 -1
  91. package/build/dts/Mcp/tools/Citations.d.ts +100 -71
  92. package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
  93. package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
  94. package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
  95. package/build/dts/SecureFile.d.ts +23 -0
  96. package/build/dts/SecureFile.d.ts.map +1 -0
  97. package/build/dts/Snippets.d.ts +11 -12
  98. package/build/dts/Snippets.d.ts.map +1 -1
  99. package/build/dts/Telemetry.d.ts +30 -0
  100. package/build/dts/Telemetry.d.ts.map +1 -0
  101. package/build/dts/Version.d.ts +2 -0
  102. package/build/dts/Version.d.ts.map +1 -0
  103. package/build/dts/Xdg.d.ts +11 -0
  104. package/build/dts/Xdg.d.ts.map +1 -0
  105. package/build/esm/ApiClient.js +133 -10
  106. package/build/esm/ApiClient.js.map +1 -1
  107. package/build/esm/Auth/Domain.js +56 -0
  108. package/build/esm/Auth/Domain.js.map +1 -0
  109. package/build/esm/Auth/ManualAuthorization.js +253 -0
  110. package/build/esm/Auth/ManualAuthorization.js.map +1 -0
  111. package/build/esm/Auth/PendingInit.js +267 -0
  112. package/build/esm/Auth/PendingInit.js.map +1 -0
  113. package/build/esm/Auth/Pkce.js +23 -0
  114. package/build/esm/Auth/Pkce.js.map +1 -0
  115. package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
  116. package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
  117. package/build/esm/Authentication.js +38 -7
  118. package/build/esm/Authentication.js.map +1 -1
  119. package/build/esm/BrowserOAuth.js +7 -10
  120. package/build/esm/BrowserOAuth.js.map +1 -1
  121. package/build/esm/CommandPath.js +31 -0
  122. package/build/esm/CommandPath.js.map +1 -0
  123. package/build/esm/Commands/Fetch.js +30 -6
  124. package/build/esm/Commands/Fetch.js.map +1 -1
  125. package/build/esm/Commands/Init.js +371 -0
  126. package/build/esm/Commands/Init.js.map +1 -0
  127. package/build/esm/Commands/Search.js +6 -3
  128. package/build/esm/Commands/Search.js.map +1 -1
  129. package/build/esm/Commands/Status.js +186 -0
  130. package/build/esm/Commands/Status.js.map +1 -0
  131. package/build/esm/Commands/Telemetry.js +23 -0
  132. package/build/esm/Commands/Telemetry.js.map +1 -0
  133. package/build/esm/Config.js +502 -28
  134. package/build/esm/Config.js.map +1 -1
  135. package/build/esm/Hook/Claude.js +1 -1
  136. package/build/esm/Hook/Claude.js.map +1 -1
  137. package/build/esm/Hook/SnapshotNote.js +54 -7
  138. package/build/esm/Hook/SnapshotNote.js.map +1 -1
  139. package/build/esm/Init/ClaudeAdapter.js +137 -0
  140. package/build/esm/Init/ClaudeAdapter.js.map +1 -0
  141. package/build/esm/Init/ClaudeProfile.js +344 -0
  142. package/build/esm/Init/ClaudeProfile.js.map +1 -0
  143. package/build/esm/Init/Domain.js +118 -0
  144. package/build/esm/Init/Domain.js.map +1 -0
  145. package/build/esm/Init/HarnessAdapter.js +21 -0
  146. package/build/esm/Init/HarnessAdapter.js.map +1 -0
  147. package/build/esm/Init/HarnessDetector.js +65 -0
  148. package/build/esm/Init/HarnessDetector.js.map +1 -0
  149. package/build/esm/Init/HarnessRegistry.js +58 -0
  150. package/build/esm/Init/HarnessRegistry.js.map +1 -0
  151. package/build/esm/Init/MutationTransaction.js +494 -0
  152. package/build/esm/Init/MutationTransaction.js.map +1 -0
  153. package/build/esm/Init/Service.js +319 -0
  154. package/build/esm/Init/Service.js.map +1 -0
  155. package/build/esm/Init/StandardAdapters.js +116 -0
  156. package/build/esm/Init/StandardAdapters.js.map +1 -0
  157. package/build/esm/Init/Status.js +215 -0
  158. package/build/esm/Init/Status.js.map +1 -0
  159. package/build/esm/Install/Claude/Hook.js +52 -64
  160. package/build/esm/Install/Claude/Hook.js.map +1 -1
  161. package/build/esm/Install/Claude/Mcp.js +56 -26
  162. package/build/esm/Install/Claude/Mcp.js.map +1 -1
  163. package/build/esm/Install/Claude/Skill.js +49 -27
  164. package/build/esm/Install/Claude/Skill.js.map +1 -1
  165. package/build/esm/Install/Codex/Mcp.js +9 -4
  166. package/build/esm/Install/Codex/Mcp.js.map +1 -1
  167. package/build/esm/Install/Codex/Skill.js +4 -1
  168. package/build/esm/Install/Codex/Skill.js.map +1 -1
  169. package/build/esm/Install/Cursor/Mcp.js +7 -4
  170. package/build/esm/Install/Cursor/Mcp.js.map +1 -1
  171. package/build/esm/Install/Cursor/Skill.js +4 -1
  172. package/build/esm/Install/Cursor/Skill.js.map +1 -1
  173. package/build/esm/Install/Domain.js +60 -3
  174. package/build/esm/Install/Domain.js.map +1 -1
  175. package/build/esm/Install/Hook.js.map +1 -1
  176. package/build/esm/Install/Mcp.js +1 -1
  177. package/build/esm/Install/Mcp.js.map +1 -1
  178. package/build/esm/Install/Opencode/Hook.js +1 -1
  179. package/build/esm/Install/Opencode/Hook.js.map +1 -1
  180. package/build/esm/Install/Opencode/Mcp.js +6 -3
  181. package/build/esm/Install/Opencode/Mcp.js.map +1 -1
  182. package/build/esm/Install/Opencode/Skill.js +4 -1
  183. package/build/esm/Install/Opencode/Skill.js.map +1 -1
  184. package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
  185. package/build/esm/Install/Skill.js.map +1 -1
  186. package/build/esm/Install/Uninstall.js +2 -2
  187. package/build/esm/Install/Uninstall.js.map +1 -1
  188. package/build/esm/MarkdownAnsi.js +142 -0
  189. package/build/esm/MarkdownAnsi.js.map +1 -0
  190. package/build/esm/Mcp/ExpandDocs.js +44 -52
  191. package/build/esm/Mcp/ExpandDocs.js.map +1 -1
  192. package/build/esm/Mcp/RequestHeaders.js.map +1 -1
  193. package/build/esm/Mcp/Server.js +2 -1
  194. package/build/esm/Mcp/Server.js.map +1 -1
  195. package/build/esm/Mcp/tools/Citations.js +109 -8
  196. package/build/esm/Mcp/tools/Citations.js.map +1 -1
  197. package/build/esm/Mcp/tools/Fetch.js +36 -12
  198. package/build/esm/Mcp/tools/Fetch.js.map +1 -1
  199. package/build/esm/SecureFile.js +36 -0
  200. package/build/esm/SecureFile.js.map +1 -0
  201. package/build/esm/Snippets.js +12 -1
  202. package/build/esm/Snippets.js.map +1 -1
  203. package/build/esm/Telemetry.js +157 -0
  204. package/build/esm/Telemetry.js.map +1 -0
  205. package/build/esm/Version.js +4 -0
  206. package/build/esm/Version.js.map +1 -0
  207. package/build/esm/Xdg.js +15 -0
  208. package/build/esm/Xdg.js.map +1 -0
  209. package/build/esm/main.js +122 -14
  210. package/build/esm/main.js.map +1 -1
  211. package/build/skills/expandai/SKILL.md +37 -8
  212. package/package.json +4 -4
  213. package/src/ApiClient.ts +206 -13
  214. package/src/Auth/Domain.ts +63 -0
  215. package/src/Auth/ManualAuthorization.ts +296 -0
  216. package/src/Auth/PendingInit.ts +338 -0
  217. package/src/Auth/Pkce.ts +26 -0
  218. package/src/Auth/SetupGrantAuthorization.ts +116 -0
  219. package/src/Authentication.ts +49 -11
  220. package/src/BrowserOAuth.ts +10 -12
  221. package/src/CommandPath.ts +30 -0
  222. package/src/Commands/Fetch.ts +42 -11
  223. package/src/Commands/Init.ts +489 -0
  224. package/src/Commands/Search.ts +8 -3
  225. package/src/Commands/Status.ts +228 -0
  226. package/src/Commands/Telemetry.ts +42 -0
  227. package/src/Config.ts +695 -50
  228. package/src/Hook/Claude.ts +1 -1
  229. package/src/Hook/SnapshotNote.ts +55 -7
  230. package/src/Init/ClaudeAdapter.ts +191 -0
  231. package/src/Init/ClaudeProfile.ts +453 -0
  232. package/src/Init/Domain.ts +153 -0
  233. package/src/Init/HarnessAdapter.ts +35 -0
  234. package/src/Init/HarnessDetector.ts +77 -0
  235. package/src/Init/HarnessRegistry.ts +87 -0
  236. package/src/Init/MutationTransaction.ts +747 -0
  237. package/src/Init/Service.ts +385 -0
  238. package/src/Init/StandardAdapters.ts +141 -0
  239. package/src/Init/Status.ts +253 -0
  240. package/src/Install/Claude/Hook.ts +81 -91
  241. package/src/Install/Claude/Mcp.ts +80 -33
  242. package/src/Install/Claude/Skill.ts +70 -37
  243. package/src/Install/Codex/Mcp.ts +14 -9
  244. package/src/Install/Codex/Skill.ts +9 -5
  245. package/src/Install/Cursor/Mcp.ts +12 -9
  246. package/src/Install/Cursor/Skill.ts +9 -5
  247. package/src/Install/Domain.ts +77 -4
  248. package/src/Install/Hook.ts +3 -3
  249. package/src/Install/Mcp.ts +6 -6
  250. package/src/Install/Opencode/Hook.ts +5 -5
  251. package/src/Install/Opencode/Mcp.ts +11 -8
  252. package/src/Install/Opencode/Skill.ts +9 -5
  253. package/src/Install/Opencode/expandai-webfetch.js +27 -9
  254. package/src/Install/Skill.ts +3 -3
  255. package/src/Install/Uninstall.ts +3 -3
  256. package/src/MarkdownAnsi.ts +162 -0
  257. package/src/Mcp/ExpandDocs.ts +46 -55
  258. package/src/Mcp/Server.ts +2 -1
  259. package/src/Mcp/tools/Citations.ts +117 -8
  260. package/src/Mcp/tools/Fetch.ts +40 -14
  261. package/src/SecureFile.ts +70 -0
  262. package/src/Snippets.ts +19 -12
  263. package/src/Telemetry.ts +186 -0
  264. package/src/Version.ts +3 -0
  265. package/src/Xdg.ts +18 -0
  266. package/src/main.ts +171 -12
  267. package/build/dts/DeviceAuth.d.ts +0 -87
  268. package/build/dts/DeviceAuth.d.ts.map +0 -1
  269. package/build/esm/DeviceAuth.js +0 -127
  270. package/build/esm/DeviceAuth.js.map +0 -1
  271. package/src/DeviceAuth.ts +0 -193
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expandai",
3
3
  "type": "module",
4
- "version": "0.0.2",
4
+ "version": "0.0.4",
5
5
  "private": false,
6
6
  "description": "Unified CLI and MCP server for ExpandAI",
7
7
  "author": "ExpandAI",
@@ -30,13 +30,13 @@
30
30
  "import": "./build/esm/index.js"
31
31
  },
32
32
  "./*": {
33
- "types": "./build/dts/*.d.ts",
33
+ "types": "./src/*.ts",
34
34
  "import": "./build/esm/*.js"
35
35
  }
36
36
  },
37
37
  "scripts": {
38
38
  "dev": "tsx --watch src/main.ts",
39
- "build": "tsc -b tsconfig.build.json && cp -R skills build/skills && cp src/Install/Opencode/expandai-webfetch.js build/esm/Install/Opencode/expandai-webfetch.js && chmod +x build/esm/main.js",
39
+ "build": "tsc -b tsconfig.build.json && rm -rf build/skills && cp -R skills build/skills && cp src/Install/Opencode/expandai-webfetch.js build/esm/Install/Opencode/expandai-webfetch.js && chmod +x build/esm/main.js",
40
40
  "build:bin": "bun ./scripts/build.ts --single",
41
41
  "build:bin:all": "bun ./scripts/build.ts",
42
42
  "check": "tsc -b tsconfig.json",
@@ -54,7 +54,7 @@
54
54
  "@effect/cli": "0.75.1",
55
55
  "@effect/platform": "0.96.1",
56
56
  "@effect/platform-node": "0.106.0",
57
- "@expandai/sdk": "0.17.2",
57
+ "@expandai/sdk": "0.18.0",
58
58
  "effect": "3.21.2",
59
59
  "open": "^11.0.0",
60
60
  "smol-toml": "^1.6.1"
package/src/ApiClient.ts CHANGED
@@ -4,10 +4,76 @@ import { fetchObjectModeJson, fetchSnapshotSearch } from '@expandai/sdk/FetchJso
4
4
  import * as Generated from '@expandai/sdk/Generated'
5
5
  import { decodeError, unexpectedStatus } from '@expandai/sdk/HttpClientHelpers'
6
6
  import { Data, DateTime, Duration, Effect, flow, Match, Option, Redacted, Schema } from 'effect'
7
+ import { CLI_AUTHORIZATION_CLIENT_ID } from './Auth/Domain.js'
7
8
  import { BrowserOAuthService } from './BrowserOAuth.js'
8
9
  import { ConfigManager, OAuthConfig, OAuthTokenExpired } from './Config.js'
9
10
  import { CurrentRequestHeaders } from './Mcp/RequestHeaders.js'
10
11
 
12
+ export class DeviceAuthorizationCreated extends Schema.Class<DeviceAuthorizationCreated>('DeviceAuthorizationCreated')({
13
+ deviceCode: Schema.Redacted(Schema.NonEmptyString),
14
+ userCode: Schema.NonEmptyString,
15
+ // `Schema.URL` decodes to a `URL`; the wire form stays a string. The CLI
16
+ // hands this to the user to open, so a malformed `appUrls.app` should fail
17
+ // here rather than in a browser.
18
+ verificationUri: Schema.URL,
19
+ // Whole seconds. A zero or negative `expiresIn` is a window that has already
20
+ // closed, so it must be positive.
21
+ expiresIn: Schema.Int.pipe(Schema.positive()),
22
+ // `interval` is RFC 8628's *minimum* wait between polls, so zero is
23
+ // meaningful — poll without delay. Only negative is nonsense, and it would
24
+ // make `nextPollAt` drift backwards forever.
25
+ interval: Schema.Int.pipe(Schema.nonNegative()),
26
+ }) {}
27
+
28
+ const ManualAuthorizationTokenResponse = Schema.Struct({
29
+ access_token: Schema.Redacted(Schema.NonEmptyString),
30
+ refresh_token: Schema.Redacted(Schema.NonEmptyString),
31
+ token_type: Schema.NonEmptyString,
32
+ expires_in: Schema.Number.pipe(Schema.positive()),
33
+ scope: Schema.optional(Schema.String),
34
+ })
35
+
36
+ export type ManualAuthorizationTokenResponse = typeof ManualAuthorizationTokenResponse.Type
37
+
38
+ /**
39
+ * RFC 8628 §3.5 poll outcomes. These are results, not errors: an agent-driven
40
+ * CLI returns immediately on every one of them, and only a transport failure
41
+ * is exceptional.
42
+ */
43
+ export type DevicePollOutcome =
44
+ | { readonly _tag: 'Tokens'; readonly token: ManualAuthorizationTokenResponse }
45
+ | { readonly _tag: 'Pending' }
46
+ | { readonly _tag: 'SlowDown' }
47
+ | { readonly _tag: 'Denied' }
48
+ | { readonly _tag: 'Expired' }
49
+ | { readonly _tag: 'Invalid' }
50
+ /**
51
+ * The server refused for a reason a fresh code cannot fix — a misconfigured
52
+ * client, resource, or scope. Distinct from `Invalid` because the recovery is
53
+ * opposite: `Invalid` means start over, this means stop and report. Carries
54
+ * the server's own `error` so the message names the actual cause instead of
55
+ * guessing.
56
+ */
57
+ | { readonly _tag: 'Refused'; readonly error: string; readonly description?: string }
58
+
59
+ const ManualAuthorizationOperation = Schema.Literal('create', 'poll')
60
+
61
+ export class ManualAuthorizationApiError extends Schema.TaggedError<ManualAuthorizationApiError>()(
62
+ 'ManualAuthorizationApiError',
63
+ {
64
+ operation: ManualAuthorizationOperation,
65
+ kind: Schema.Literal('request_failed', 'rejected', 'invalid_response'),
66
+ status: Schema.optional(Schema.Number),
67
+ recoverable: Schema.Boolean,
68
+ },
69
+ ) {
70
+ get message() {
71
+ return this.kind === 'rejected'
72
+ ? `Manual authorization ${this.operation} was rejected. Restart authorization if the problem persists.`
73
+ : `Manual authorization ${this.operation} could not be completed safely. Try again.`
74
+ }
75
+ }
76
+
11
77
  /**
12
78
  * Single user-facing error for any failure crossing the API boundary. The
13
79
  * generated client raises a half-dozen status-specific tagged classes plus
@@ -56,18 +122,18 @@ function getUnsupportedFields(value: unknown): string[] {
56
122
  return Array.isArray(unsupportedFields) ? unsupportedFields.filter((field) => typeof field === 'string') : []
57
123
  }
58
124
 
59
- const formatIssue = Effect.fn('ApiClient.formatIssue')(function* ({ path, message }: Issue) {
125
+ function formatIssue({ path, message }: Issue): string {
60
126
  const prefix = path.map((segment) => (typeof segment === 'object' ? segment.key : String(segment))).join('.')
61
127
  return prefix ? `${prefix}: ${message}` : message
62
- })
128
+ }
63
129
 
64
- const formatApiFailure = Effect.fn('ApiClient.formatApiFailure')(function* (error: ClientError) {
130
+ function formatApiFailure(error: ClientError): string {
65
131
  switch (getStringField(error, '_tag')) {
66
132
  case 'HttpApiDecodeError': {
67
133
  const issues = getIssueList(error)
68
- return yield* issues.length === 0
69
- ? Effect.succeed(getStringField(error, 'message') ?? 'Invalid request')
70
- : Effect.forEach(issues, formatIssue).pipe(Effect.map((parts) => parts.join('; ')))
134
+ return issues.length === 0
135
+ ? (getStringField(error, 'message') ?? 'Invalid request')
136
+ : issues.map(formatIssue).join('; ')
71
137
  }
72
138
  case 'AuthFailed':
73
139
  return `Authentication failed (${getStringField(error, 'reason') ?? 'unknown'}). Run \`expandai login\` to re-authenticate.`
@@ -88,14 +154,139 @@ const formatApiFailure = Effect.fn('ApiClient.formatApiFailure')(function* (erro
88
154
  default:
89
155
  return error instanceof Error ? error.message : (getStringField(error, 'message') ?? 'Expand API request failed.')
90
156
  }
91
- })
157
+ }
92
158
 
93
159
  const liftApiError = <A, R>(self: Effect.Effect<A, ClientError, R>) =>
94
- self.pipe(
95
- Effect.catchAll((error) =>
96
- formatApiFailure(error).pipe(Effect.flatMap((detail) => Effect.fail(new ApiRequestFailed({ detail })))),
97
- ),
98
- )
160
+ self.pipe(Effect.catchAll((error) => new ApiRequestFailed({ detail: formatApiFailure(error) })))
161
+
162
+ /** Public, unauthenticated HTTP boundary for device-flow CLI authorization. */
163
+ export class ManualAuthorizationApiClient extends Effect.Service<ManualAuthorizationApiClient>()(
164
+ '@expandai/cli/ManualAuthorizationApiClient',
165
+ {
166
+ dependencies: [NodeHttpClient.layer, ConfigManager.Default],
167
+ effect: Effect.gen(function* () {
168
+ const httpClient = yield* HttpClient.HttpClient
169
+ const configManager = yield* ConfigManager
170
+
171
+ const apiError = (
172
+ operation: typeof ManualAuthorizationOperation.Type,
173
+ kind: 'request_failed' | 'rejected' | 'invalid_response',
174
+ status?: number,
175
+ ) =>
176
+ new ManualAuthorizationApiError({
177
+ operation,
178
+ kind,
179
+ status,
180
+ recoverable: kind !== 'invalid_response',
181
+ })
182
+
183
+ const create = Effect.fn('ManualAuthorizationApiClient.create')(function* (input: {
184
+ readonly harness: 'agent-harnesses'
185
+ readonly scope: 'project' | 'global'
186
+ readonly platform: string
187
+ readonly machineLabel: string
188
+ readonly cliVersion: string
189
+ }) {
190
+ const apiUrl = yield* configManager.getApiUrl()
191
+ const request = yield* HttpClientRequest.post(new URL('/v1/cli-authorization/device', apiUrl)).pipe(
192
+ HttpClientRequest.bodyJson(input),
193
+ Effect.mapError(() => apiError('create', 'request_failed')),
194
+ )
195
+ return yield* httpClient.execute(request).pipe(
196
+ Effect.flatMap((response) =>
197
+ response.status >= 200 && response.status < 300
198
+ ? HttpClientResponse.schemaBodyJson(DeviceAuthorizationCreated)(response).pipe(
199
+ Effect.mapError(() => apiError('create', 'invalid_response')),
200
+ )
201
+ : Effect.fail(apiError('create', 'rejected', response.status)),
202
+ ),
203
+ Effect.catchAll((error) =>
204
+ error instanceof ManualAuthorizationApiError
205
+ ? Effect.fail(error)
206
+ : Effect.fail(apiError('create', 'request_failed')),
207
+ ),
208
+ )
209
+ })
210
+
211
+ const PollErrorBody = Schema.Struct({
212
+ error: Schema.String,
213
+ error_description: Schema.optional(Schema.String),
214
+ })
215
+
216
+ /**
217
+ * RFC 6749/8628 errors that describe the *request* rather than the state
218
+ * of this particular code. Re-minting produces a code that fails
219
+ * identically, so treating these as "expired" is what turns a
220
+ * misconfiguration into an endless approve loop.
221
+ */
222
+ const PERMANENT_REFUSALS = new Set([
223
+ 'invalid_target',
224
+ 'invalid_client',
225
+ 'invalid_scope',
226
+ 'unsupported_grant_type',
227
+ 'unauthorized_client',
228
+ 'invalid_request',
229
+ ])
230
+
231
+ /**
232
+ * One poll of the OAuth token endpoint (RFC 8628). Form-encoded, and
233
+ * always against `/oauth2/token`: `/device/token` is the first-party
234
+ * session path and the server rejects our client there by design.
235
+ */
236
+ const poll = Effect.fn('ManualAuthorizationApiClient.poll')(function* (deviceCode: Redacted.Redacted<string>) {
237
+ const apiUrl = yield* configManager.getApiUrl()
238
+ const request = HttpClientRequest.post(new URL('/api/auth/oauth2/token', apiUrl)).pipe(
239
+ HttpClientRequest.bodyUrlParams({
240
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
241
+ device_code: Redacted.value(deviceCode),
242
+ client_id: CLI_AUTHORIZATION_CLIENT_ID,
243
+ }),
244
+ )
245
+ return yield* httpClient.execute(request).pipe(
246
+ Effect.flatMap(
247
+ Effect.fnUntraced(function* (response) {
248
+ if (response.status >= 200 && response.status < 300) {
249
+ const token = yield* HttpClientResponse.schemaBodyJson(ManualAuthorizationTokenResponse)(response).pipe(
250
+ Effect.mapError(() => apiError('poll', 'invalid_response')),
251
+ )
252
+ return { _tag: 'Tokens', token } satisfies DevicePollOutcome
253
+ }
254
+ if (response.status >= 500) return yield* apiError('poll', 'rejected', response.status)
255
+ const body = yield* HttpClientResponse.schemaBodyJson(PollErrorBody)(response).pipe(
256
+ Effect.mapError(() => apiError('poll', 'invalid_response')),
257
+ )
258
+ switch (body.error) {
259
+ case 'authorization_pending':
260
+ return { _tag: 'Pending' } satisfies DevicePollOutcome
261
+ case 'slow_down':
262
+ return { _tag: 'SlowDown' } satisfies DevicePollOutcome
263
+ case 'access_denied':
264
+ return { _tag: 'Denied' } satisfies DevicePollOutcome
265
+ case 'expired_token':
266
+ return { _tag: 'Expired' } satisfies DevicePollOutcome
267
+ default:
268
+ return PERMANENT_REFUSALS.has(body.error)
269
+ ? ({
270
+ _tag: 'Refused',
271
+ error: body.error,
272
+ ...(body.error_description === undefined ? {} : { description: body.error_description }),
273
+ } satisfies DevicePollOutcome)
274
+ : ({ _tag: 'Invalid' } satisfies DevicePollOutcome)
275
+ }
276
+ }),
277
+ ),
278
+ Effect.catchAll((error) =>
279
+ error instanceof ManualAuthorizationApiError
280
+ ? Effect.fail(error)
281
+ : Effect.fail(apiError('poll', 'request_failed')),
282
+ ),
283
+ )
284
+ })
285
+
286
+ return { create, poll } as const
287
+ }),
288
+ },
289
+ ) {}
99
290
 
100
291
  /**
101
292
  * Resolves outgoing API auth per call. When the current request scope carries
@@ -126,13 +317,14 @@ export class ApiClient extends Effect.Service<ApiClient>()('@expandai/cli/ApiCli
126
317
  const refreshOAuthToken = Effect.fn('ApiClient.refreshOAuthToken')(function* ({
127
318
  refreshToken,
128
319
  expiresAt,
320
+ clientId,
129
321
  }: OAuthAuthMethod) {
130
322
  if (refreshToken === undefined) {
131
323
  return yield* new OAuthTokenExpired({ expiredAt: expiresAt })
132
324
  }
133
325
 
134
326
  const now = yield* DateTime.now
135
- const token = yield* browserOAuth.refreshToken(Redacted.value(refreshToken))
327
+ const token = yield* browserOAuth.refreshToken(Redacted.value(refreshToken), clientId)
136
328
  if (token === undefined) {
137
329
  return yield* new OAuthTokenExpired({ expiredAt: expiresAt })
138
330
  }
@@ -143,6 +335,7 @@ export class ApiClient extends Effect.Service<ApiClient>()('@expandai/cli/ApiCli
143
335
  accessToken: Redacted.make(token.access_token),
144
336
  refreshToken: nextRefreshToken,
145
337
  expiresAt: refreshedExpiresAt,
338
+ clientId,
146
339
  }),
147
340
  })
148
341
 
@@ -0,0 +1,63 @@
1
+ import { Schema } from 'effect'
2
+
3
+ /**
4
+ * The OAuth client for browser-approved CLI setup. Distinct from the
5
+ * `expandai-cli` client that `expandai login` uses: that one keeps loopback
6
+ * redirect URIs and must keep showing a consent screen, because Better Auth
7
+ * matches loopback URIs without comparing the port.
8
+ */
9
+ export const CLI_AUTHORIZATION_CLIENT_ID = 'expandai-cli-browser'
10
+
11
+ export const PendingInitKey = Schema.Struct({
12
+ apiOrigin: Schema.String,
13
+ clientId: Schema.Literal(CLI_AUTHORIZATION_CLIENT_ID),
14
+ harness: Schema.Literal('agent-harnesses'),
15
+ scope: Schema.Literal('project', 'global'),
16
+ canonicalScopeRoot: Schema.String,
17
+ })
18
+
19
+ export type PendingInitKey = typeof PendingInitKey.Type
20
+
21
+ /**
22
+ * One pending RFC 8628 device authorization. The `deviceCode` is the whole
23
+ * credential — better-auth binds it to our client id server-side and it never
24
+ * leaves this machine — so the record keeps the same 0600/no-symlink storage
25
+ * discipline the PKCE verifier had.
26
+ */
27
+ export class PendingInitRecord extends Schema.Class<PendingInitRecord>('PendingInitRecord')({
28
+ key: PendingInitKey,
29
+ deviceCode: Schema.Redacted(Schema.String),
30
+ userCode: Schema.String,
31
+ verificationUri: Schema.String,
32
+ // RFC 8628 §3.5: current polling interval in seconds. `slow_down` adds five,
33
+ // permanently, so the backoff has to be persisted — each `init` invocation
34
+ // polls at most once and exits.
35
+ intervalSeconds: Schema.Number,
36
+ // Earliest wall-clock time the next poll may hit the network. An agent
37
+ // re-running `init` faster than the interval gets a local "still pending"
38
+ // instead of a request the server would answer with `slow_down`.
39
+ nextPollAt: Schema.DateTimeUtc,
40
+ approvalExpiresAt: Schema.DateTimeUtc,
41
+ }) {}
42
+
43
+ export class PendingInitCorrupt extends Schema.TaggedError<PendingInitCorrupt>()('PendingInitCorrupt', {
44
+ path: Schema.String,
45
+ error: Schema.Defect,
46
+ }) {
47
+ readonly recoverable = true
48
+
49
+ get message() {
50
+ return `Pending authorization state at ${this.path} is corrupt and was left unchanged.`
51
+ }
52
+ }
53
+
54
+ export class PendingInitStorageError extends Schema.TaggedError<PendingInitStorageError>()('PendingInitStorageError', {
55
+ path: Schema.String,
56
+ error: Schema.Defect,
57
+ }) {
58
+ readonly recoverable = true
59
+
60
+ get message() {
61
+ return `Could not access pending authorization state at ${this.path}.`
62
+ }
63
+ }
@@ -0,0 +1,296 @@
1
+ import { DateTime, Duration, Effect, Either, Option, Schema } from 'effect'
2
+ import { ManualAuthorizationApiClient, ManualAuthorizationApiError } from '../ApiClient.js'
3
+ import { ConfigManager, OAuthConfig } from '../Config.js'
4
+ import { InitScope } from '../Init/Domain.js'
5
+ import { CLI_VERSION } from '../Version.js'
6
+ import { CLI_AUTHORIZATION_CLIENT_ID, type PendingInitKey, PendingInitRecord } from './Domain.js'
7
+ import { PendingInitStore } from './PendingInit.js'
8
+
9
+ export {
10
+ ALREADY_AUTHENTICATED_MESSAGE,
11
+ AUTHORIZATION_CONSUMED_MESSAGE,
12
+ AUTHORIZATION_REQUIRED_MESSAGE,
13
+ } from '../Init/Domain.js'
14
+
15
+ const CLIENT_ID = CLI_AUTHORIZATION_CLIENT_ID
16
+
17
+ /** RFC 8628 §3.5: `slow_down` permanently adds five seconds to the interval. */
18
+ const SLOW_DOWN_INCREMENT_SECONDS = 5
19
+
20
+ export class ManualAuthorizationInput extends Schema.Class<ManualAuthorizationInput>('ManualAuthorizationInput')({
21
+ harness: Schema.Literal('agent-harnesses'),
22
+ scope: InitScope,
23
+ cwd: Schema.optional(Schema.String),
24
+ platform: Schema.optional(Schema.String),
25
+ }) {}
26
+
27
+ /** What the agent relays: where to approve, and the code to type there. */
28
+ export class ManualAuthorizationRequired extends Schema.Class<ManualAuthorizationRequired>(
29
+ 'ManualAuthorizationRequired',
30
+ )({
31
+ verificationUri: Schema.String,
32
+ userCode: Schema.NonEmptyString,
33
+ approvalExpiresAt: Schema.DateTimeUtc,
34
+ }) {}
35
+
36
+ /**
37
+ * One `advance` call moves the flow as far as it can without blocking, then
38
+ * returns. RFC 8628's polling loop is inverted: the agent re-invokes the CLI,
39
+ * and each invocation polls the token endpoint at most once — or not at all,
40
+ * when the persisted interval says the server would answer `slow_down`.
41
+ */
42
+ export class TokensSaved extends Schema.TaggedClass<TokensSaved>()('TokensSaved', {}) {}
43
+
44
+ export class AuthorizationPending extends Schema.TaggedClass<AuthorizationPending>()('AuthorizationPending', {
45
+ pending: ManualAuthorizationRequired,
46
+ }) {}
47
+
48
+ export const ManualAuthorizationOutcome = Schema.Union(TokensSaved, AuthorizationPending)
49
+ export type ManualAuthorizationOutcome = typeof ManualAuthorizationOutcome.Type
50
+
51
+ export class ManualAuthorizationDenied extends Schema.TaggedError<ManualAuthorizationDenied>()(
52
+ 'ManualAuthorizationDenied',
53
+ {
54
+ recoverable: Schema.Literal(true),
55
+ },
56
+ ) {
57
+ get message() {
58
+ return 'The authorization request was denied in the browser. Run the same command again to start a new one.'
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The server refused redemption for a reason a fresh code cannot fix. Kept
64
+ * separate from every other failure because the correct response is the
65
+ * opposite of the usual one: do not start over, stop and report. Re-minting
66
+ * here is what produced an endless approve loop — each new code is approved by
67
+ * the user and then refused identically, with nothing surfaced.
68
+ */
69
+ export class ManualAuthorizationRefused extends Schema.TaggedError<ManualAuthorizationRefused>()(
70
+ 'ManualAuthorizationRefused',
71
+ {
72
+ error: Schema.String,
73
+ description: Schema.optional(Schema.String),
74
+ recoverable: Schema.Literal(false),
75
+ },
76
+ ) {
77
+ get message() {
78
+ const detail = this.description === undefined ? '' : ` — ${this.description}`
79
+ return `Expand refused to complete this authorization: ${this.error}${detail}. This is a server-side configuration problem, not something a new code can fix. Do not start setup again; report this error.`
80
+ }
81
+ }
82
+
83
+ export class ManualAuthorizationCredentialSaveFailed extends Schema.TaggedError<ManualAuthorizationCredentialSaveFailed>()(
84
+ 'ManualAuthorizationCredentialSaveFailed',
85
+ {
86
+ recoverable: Schema.Literal(false),
87
+ },
88
+ ) {
89
+ get message() {
90
+ return 'Authorization succeeded, but credentials could not be saved. Start authorization again.'
91
+ }
92
+ }
93
+
94
+ function platformName(input: ManualAuthorizationInput): string {
95
+ return input.platform ?? process.platform
96
+ }
97
+
98
+ function required(record: PendingInitRecord): ManualAuthorizationRequired {
99
+ return new ManualAuthorizationRequired({
100
+ verificationUri: record.verificationUri,
101
+ userCode: record.userCode,
102
+ approvalExpiresAt: record.approvalExpiresAt,
103
+ })
104
+ }
105
+
106
+ export class ManualAuthorization extends Effect.Service<ManualAuthorization>()(
107
+ '@expandai/cli/Auth/ManualAuthorization',
108
+ {
109
+ dependencies: [ConfigManager.Default, PendingInitStore.Default, ManualAuthorizationApiClient.Default],
110
+ effect: Effect.gen(function* () {
111
+ const configManager = yield* ConfigManager
112
+ const pendingStore = yield* PendingInitStore
113
+ const apiClient = yield* ManualAuthorizationApiClient
114
+
115
+ const preflight = Effect.fnUntraced(function* (input: ManualAuthorizationInput) {
116
+ yield* configManager.ensureSecureStorage()
117
+ yield* pendingStore.pruneExpired()
118
+ const apiUrl = yield* configManager.getApiUrl()
119
+ const canonicalScopeRoot = yield* pendingStore.canonicalScopeRoot(input.scope, input.cwd)
120
+ const key: PendingInitKey = {
121
+ apiOrigin: apiUrl.origin,
122
+ clientId: CLIENT_ID,
123
+ harness: input.harness,
124
+ scope: input.scope,
125
+ canonicalScopeRoot,
126
+ }
127
+ return key
128
+ })
129
+
130
+ const create = Effect.fnUntraced(function* (input: ManualAuthorizationInput, key: PendingInitKey) {
131
+ const created = yield* apiClient.create({
132
+ harness: input.harness,
133
+ scope: input.scope,
134
+ platform: platformName(input),
135
+ machineLabel: `${platformName(input)}-${process.arch}`,
136
+ cliVersion: CLI_VERSION,
137
+ })
138
+ const now = yield* DateTime.now
139
+ const record = new PendingInitRecord({
140
+ key,
141
+ deviceCode: created.deviceCode,
142
+ userCode: created.userCode,
143
+ // The stored record is JSON on disk, so the decoded `URL` goes back to
144
+ // its string form here rather than at the schema boundary.
145
+ verificationUri: created.verificationUri.toString(),
146
+ intervalSeconds: created.interval,
147
+ nextPollAt: DateTime.addDuration(now, Duration.seconds(created.interval)),
148
+ approvalExpiresAt: DateTime.addDuration(now, Duration.seconds(created.expiresIn)),
149
+ })
150
+ const stored = yield* Effect.either(pendingStore.putIfAbsent(record))
151
+ if (Either.isLeft(stored)) return yield* stored.left
152
+ // A concurrent process may have won the write; its record is the live one.
153
+ return stored.right
154
+ })
155
+
156
+ const saveTokens = Effect.fnUntraced(function* (token: {
157
+ readonly access_token: Parameters<typeof OAuthConfig.make>[0]['accessToken']
158
+ readonly refresh_token: Parameters<typeof OAuthConfig.make>[0]['refreshToken']
159
+ readonly expires_in: number
160
+ }) {
161
+ const now = yield* DateTime.now
162
+ const saved = yield* Effect.either(
163
+ configManager.updateConfig({
164
+ authMethod: OAuthConfig.make({
165
+ accessToken: token.access_token,
166
+ refreshToken: token.refresh_token,
167
+ expiresAt: DateTime.addDuration(now, Duration.seconds(token.expires_in)),
168
+ clientId: CLIENT_ID,
169
+ }),
170
+ }),
171
+ )
172
+ if (Either.isLeft(saved)) {
173
+ return yield* new ManualAuthorizationCredentialSaveFailed({ recoverable: false })
174
+ }
175
+ })
176
+
177
+ const updateAfterPoll = Effect.fnUntraced(function* (record: PendingInitRecord, slowDown: boolean) {
178
+ const now = yield* DateTime.now
179
+ const intervalSeconds = record.intervalSeconds + (slowDown ? SLOW_DOWN_INCREMENT_SECONDS : 0)
180
+ const next = new PendingInitRecord({
181
+ ...record,
182
+ intervalSeconds,
183
+ nextPollAt: DateTime.addDuration(now, Duration.seconds(intervalSeconds)),
184
+ })
185
+ // One atomic publish, not delete-then-create: an interrupt between those
186
+ // two lost the device code entirely, and this runs on every poll.
187
+ const replaced = yield* Effect.either(pendingStore.replaceIfMatch(record, next))
188
+ if (Either.isLeft(replaced)) return record
189
+ return replaced.right ? next : record
190
+ })
191
+
192
+ const advance = Effect.fn('ManualAuthorization.advance')(function* (input: ManualAuthorizationInput) {
193
+ const key = yield* preflight(input)
194
+ const existing = yield* pendingStore.get(key)
195
+ if (Option.isNone(existing)) {
196
+ const record = yield* create(input, key)
197
+ return new AuthorizationPending({ pending: required(record) })
198
+ }
199
+ const record = existing.value
200
+ const now = yield* DateTime.now
201
+
202
+ if (DateTime.greaterThan(now, record.approvalExpiresAt)) {
203
+ // Expired locally: start over transparently. The user sees a fresh
204
+ // code, not an error about a code they never got to use.
205
+ yield* pendingStore.removeIfMatch(record)
206
+ const fresh = yield* create(input, key)
207
+ return new AuthorizationPending({ pending: required(fresh) })
208
+ }
209
+
210
+ if (DateTime.lessThan(now, record.nextPollAt)) {
211
+ // Inside the polling interval — answering locally is what keeps an
212
+ // eagerly re-running agent from earning `slow_down` penalties.
213
+ return new AuthorizationPending({ pending: required(record) })
214
+ }
215
+
216
+ const outcome = yield* apiClient.poll(record.deviceCode)
217
+ switch (outcome._tag) {
218
+ case 'Tokens': {
219
+ yield* saveTokens(outcome.token)
220
+ yield* pendingStore.removeIfMatch(record)
221
+ return new TokensSaved()
222
+ }
223
+ case 'Pending': {
224
+ const next = yield* updateAfterPoll(record, false)
225
+ return new AuthorizationPending({ pending: required(next) })
226
+ }
227
+ case 'SlowDown': {
228
+ const next = yield* updateAfterPoll(record, true)
229
+ return new AuthorizationPending({ pending: required(next) })
230
+ }
231
+ case 'Denied': {
232
+ yield* pendingStore.removeIfMatch(record)
233
+ return yield* new ManualAuthorizationDenied({ recoverable: true })
234
+ }
235
+ case 'Refused': {
236
+ // Deliberately keeps the pending record. Discarding it would mint a
237
+ // fresh code on the next run, which the server refuses identically —
238
+ // so the user approves again, and again. Holding the record means a
239
+ // re-run reports the same error instead of asking for more approvals.
240
+ return yield* new ManualAuthorizationRefused({
241
+ error: outcome.error,
242
+ ...(outcome.description === undefined ? {} : { description: outcome.description }),
243
+ recoverable: false,
244
+ })
245
+ }
246
+ case 'Expired':
247
+ case 'Invalid': {
248
+ // The server no longer honours this code — expired, already consumed
249
+ // by a response we never received, or unknown. All three recover the
250
+ // same way: a fresh request and a fresh approval.
251
+ yield* pendingStore.removeIfMatch(record)
252
+ const fresh = yield* create(input, key)
253
+ return new AuthorizationPending({ pending: required(fresh) })
254
+ }
255
+ }
256
+ })
257
+
258
+ /**
259
+ * Redeems a device code that arrived out of band — the dashboard's
260
+ * pre-approved setup token. No pending record is kept: the code is
261
+ * single-use and either redeems on this first poll or never will.
262
+ */
263
+ const redeemPreApproved = Effect.fn('ManualAuthorization.redeemPreApproved')(function* (
264
+ deviceCode: Parameters<typeof apiClient.poll>[0],
265
+ ) {
266
+ const outcome = yield* apiClient.poll(deviceCode)
267
+ switch (outcome._tag) {
268
+ case 'Tokens':
269
+ return yield* saveTokens(outcome.token)
270
+ case 'Denied':
271
+ return yield* new ManualAuthorizationDenied({ recoverable: true })
272
+ case 'Refused':
273
+ return yield* new ManualAuthorizationRefused({
274
+ error: outcome.error,
275
+ ...(outcome.description === undefined ? {} : { description: outcome.description }),
276
+ recoverable: false,
277
+ })
278
+ case 'Pending':
279
+ case 'SlowDown':
280
+ case 'Expired':
281
+ case 'Invalid':
282
+ // A dashboard token is minted approved; anything else means it was
283
+ // not produced by that flow, expired, or is already spent.
284
+ return yield* new ManualAuthorizationApiError({
285
+ operation: 'poll',
286
+ kind: 'rejected',
287
+ status: 400,
288
+ recoverable: false,
289
+ })
290
+ }
291
+ })
292
+
293
+ return { advance, redeemPreApproved } as const
294
+ }),
295
+ },
296
+ ) {}