expandai 0.0.2 → 0.0.3

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 +78 -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 +85 -25
  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 +380 -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 +182 -50
  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 +26 -3
  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 +496 -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 +234 -81
  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
@@ -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
+ ) {}
@@ -0,0 +1,338 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { FileSystem, Path, Error as PlatformError } from '@effect/platform'
3
+ import { NodeContext } from '@effect/platform-node'
4
+ import { DateTime, Effect, Either, Option, Schema } from 'effect'
5
+ import { xdgConfigDirectory } from '../Xdg.js'
6
+ import {
7
+ CLI_AUTHORIZATION_CLIENT_ID,
8
+ PendingInitCorrupt,
9
+ type PendingInitKey,
10
+ PendingInitRecord,
11
+ PendingInitStorageError,
12
+ } from './Domain.js'
13
+
14
+ const PendingInitRecordJson = Schema.parseJson(PendingInitRecord)
15
+ const COMPLETION_CODE_RETENTION_GRACE_MS = 5 * 60 * 1_000
16
+
17
+ const PendingInitKeyTupleJson = Schema.parseJson(
18
+ Schema.Tuple(
19
+ Schema.String,
20
+ Schema.Literal(CLI_AUTHORIZATION_CLIENT_ID),
21
+ Schema.Literal('agent-harnesses'),
22
+ Schema.Literal('project', 'global'),
23
+ Schema.String,
24
+ ),
25
+ )
26
+
27
+ function isErrnoCode(input: unknown, code: string): boolean {
28
+ return typeof input === 'object' && input !== null && 'code' in input && input.code === code
29
+ }
30
+
31
+ function isAlreadyExists(error: unknown): boolean {
32
+ return (
33
+ error instanceof PlatformError.SystemError &&
34
+ (error.reason === 'AlreadyExists' || isErrnoCode(error.cause, 'EEXIST'))
35
+ )
36
+ }
37
+
38
+ /** Canonical, collision-free serialization of all pending authorization key fields. */
39
+ export function serializePendingInitKey(key: PendingInitKey): string {
40
+ return Schema.encodeSync(PendingInitKeyTupleJson)([
41
+ key.apiOrigin,
42
+ key.clientId,
43
+ key.harness,
44
+ key.scope,
45
+ key.canonicalScopeRoot,
46
+ ])
47
+ }
48
+
49
+ function ancestorPaths(path: Path.Path, current: string): string[] {
50
+ const parent = path.dirname(current)
51
+ return parent === current ? [current] : [current, ...ancestorPaths(path, parent)]
52
+ }
53
+
54
+ export class PendingInitStore extends Effect.Service<PendingInitStore>()('@expandai/cli/Auth/PendingInitStore', {
55
+ dependencies: [NodeContext.layer],
56
+ effect: Effect.gen(function* () {
57
+ const fileSystem = yield* FileSystem.FileSystem
58
+ const path = yield* Path.Path
59
+ const configDirectory = path.join(yield* xdgConfigDirectory, 'expandai')
60
+ const pendingPath = path.join(configDirectory, 'pending-init')
61
+
62
+ const storageError = (targetPath: string, error: unknown) =>
63
+ new PendingInitStorageError({ path: targetPath, error })
64
+
65
+ /**
66
+ * Maps a platform failure to the domain error for `targetPath`.
67
+ *
68
+ * Deliberately applied per call rather than once around each function: a
69
+ * blanket `mapError` over a whole `Effect.fn` body would also swallow
70
+ * `PendingInitCorrupt`, which `decodeRecord` raises and which `Status.ts`
71
+ * reports differently — corrupt state is recoverable by deleting the file,
72
+ * a storage failure is not.
73
+ */
74
+ const onStorage = <A>(targetPath: string, effect: Effect.Effect<A, PlatformError.PlatformError>) =>
75
+ effect.pipe(Effect.mapError((error) => storageError(targetPath, error)))
76
+
77
+ const assertNotSymbolicLink = Effect.fnUntraced(function* (targetPath: string) {
78
+ const result = yield* Effect.either(fileSystem.readLink(targetPath))
79
+ if (Either.isRight(result)) {
80
+ return yield* storageError(targetPath, new Error('Symbolic links are not allowed for pending authorization'))
81
+ }
82
+ const error = result.left
83
+ if (
84
+ error instanceof PlatformError.SystemError &&
85
+ (error.reason === 'InvalidData' ||
86
+ error.reason === 'NotFound' ||
87
+ (error.method === 'readLink' && isErrnoCode(error.cause, 'EINVAL')))
88
+ ) {
89
+ return
90
+ }
91
+ return yield* storageError(targetPath, error)
92
+ })
93
+
94
+ const ensureDirectory = Effect.fnUntraced(function* () {
95
+ yield* assertNotSymbolicLink(configDirectory)
96
+ yield* assertNotSymbolicLink(pendingPath)
97
+ yield* onStorage(pendingPath, fileSystem.makeDirectory(pendingPath, { recursive: true, mode: 0o700 }))
98
+ yield* assertNotSymbolicLink(configDirectory)
99
+ yield* assertNotSymbolicLink(pendingPath)
100
+ const directoryInfo = yield* onStorage(pendingPath, fileSystem.stat(pendingPath))
101
+ if (directoryInfo.type !== 'Directory') {
102
+ return yield* storageError(pendingPath, new Error('Pending authorization path is not a directory'))
103
+ }
104
+ if (process.platform !== 'win32') {
105
+ yield* onStorage(configDirectory, fileSystem.chmod(configDirectory, 0o700))
106
+ yield* onStorage(pendingPath, fileSystem.chmod(pendingPath, 0o700))
107
+ }
108
+ })
109
+
110
+ const recordPath = (key: PendingInitKey) => {
111
+ const digest = createHash('sha256').update(serializePendingInitKey(key)).digest('hex')
112
+ return path.join(pendingPath, `${digest}.json`)
113
+ }
114
+
115
+ const decodeRecord = Effect.fnUntraced(function* (targetPath: string, encoded: string) {
116
+ const record = yield* Schema.decode(PendingInitRecordJson)(encoded).pipe(
117
+ Effect.mapError(
118
+ () =>
119
+ new PendingInitCorrupt({
120
+ path: targetPath,
121
+ error: new Error('Stored pending authorization did not match the expected schema'),
122
+ }),
123
+ ),
124
+ )
125
+ if (recordPath(record.key) !== targetPath) {
126
+ return yield* new PendingInitCorrupt({
127
+ path: targetPath,
128
+ error: new Error('Stored pending authorization key did not match its file'),
129
+ })
130
+ }
131
+ return record
132
+ })
133
+
134
+ const readRecordPath = Effect.fnUntraced(function* (targetPath: string) {
135
+ yield* assertNotSymbolicLink(targetPath)
136
+ const exists = yield* onStorage(targetPath, fileSystem.exists(targetPath))
137
+ if (!exists) return Option.none<PendingInitRecord>()
138
+ const info = yield* onStorage(targetPath, fileSystem.stat(targetPath))
139
+ if (info.type !== 'File') return yield* storageError(targetPath, new Error('Pending state is not a regular file'))
140
+ if (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600) {
141
+ return yield* storageError(targetPath, new Error('Pending state file mode is not 600'))
142
+ }
143
+ const encoded = yield* onStorage(targetPath, fileSystem.readFileString(targetPath))
144
+ return Option.some(yield* decodeRecord(targetPath, encoded))
145
+ })
146
+
147
+ const readRecord = (key: PendingInitKey) => readRecordPath(recordPath(key))
148
+
149
+ const isExpired = Effect.fnUntraced(function* (record: PendingInitRecord) {
150
+ const now = DateTime.toEpochMillis(yield* DateTime.now)
151
+ return DateTime.toEpochMillis(record.approvalExpiresAt) + COMPLETION_CODE_RETENTION_GRACE_MS <= now
152
+ })
153
+
154
+ const writeExclusive = Effect.fnUntraced(function* (record: PendingInitRecord) {
155
+ yield* ensureDirectory()
156
+ const targetPath = recordPath(record.key)
157
+ const encoded = yield* Schema.encode(PendingInitRecordJson)(record).pipe(
158
+ Effect.mapError((error) => storageError(targetPath, error)),
159
+ )
160
+ const created = yield* Effect.either(
161
+ Effect.scoped(
162
+ Effect.gen(function* () {
163
+ const file = yield* fileSystem.open(targetPath, { flag: 'wx', mode: 0o600 })
164
+ yield* file.writeAll(new TextEncoder().encode(encoded))
165
+ yield* file.sync
166
+ }),
167
+ ),
168
+ )
169
+ if (Either.isLeft(created)) {
170
+ // `wx` means an existing file belongs to another attempt — leave it.
171
+ if (isAlreadyExists(created.left)) return false
172
+ // Anything else failed *after* we created the file, so a zero-byte or
173
+ // half-written record is now sitting at the final path. Left there it
174
+ // fails every later read of this key, and interrupting between `open`
175
+ // and `writeAll` is enough to cause it.
176
+ yield* fileSystem.remove(targetPath).pipe(Effect.ignore)
177
+ return yield* storageError(targetPath, created.left)
178
+ }
179
+ if (process.platform !== 'win32') {
180
+ const chmodResult = yield* Effect.either(fileSystem.chmod(targetPath, 0o600))
181
+ if (Either.isLeft(chmodResult)) {
182
+ yield* fileSystem.remove(targetPath).pipe(Effect.ignore)
183
+ return yield* storageError(targetPath, chmodResult.left)
184
+ }
185
+ }
186
+ return true
187
+ })
188
+
189
+ /**
190
+ * Publishes `next` over the record for the same key, atomically.
191
+ *
192
+ * The caller used to do `removeIfMatch` then `putIfAbsent`. An interrupt
193
+ * between those two — Ctrl-C during the poll that runs on *every* `expandai
194
+ * init` while authorization is pending — deleted the pending record and
195
+ * wrote nothing back, so the next run minted a fresh code and asked the user
196
+ * to approve again with no error to explain it.
197
+ *
198
+ * `rename` over the target is atomic on POSIX, so an interrupt either leaves
199
+ * the old record or completes the new one. It is **not** a cross-process
200
+ * compare-and-swap: another process can publish between our read and our
201
+ * rename, and ours wins. That race is pre-existing and unaddressed here —
202
+ * closing it needs a per-key lock, and Windows replacement semantics need
203
+ * checking first. The name says `IfMatch` because it verifies before
204
+ * writing, not because it is a CAS.
205
+ */
206
+ const replaceIfMatch = Effect.fn('PendingInitStore.replaceIfMatch')(function* (
207
+ previous: PendingInitRecord,
208
+ next: PendingInitRecord,
209
+ ) {
210
+ yield* ensureDirectory()
211
+ const targetPath = recordPath(next.key)
212
+ if (recordPath(previous.key) !== targetPath) {
213
+ return yield* storageError(targetPath, new Error('Pending authorization replacement changed its key'))
214
+ }
215
+ const current = yield* readRecordPath(targetPath)
216
+ if (Option.isNone(current) || current.value.userCode !== previous.userCode) return false
217
+
218
+ const encoded = yield* Schema.encode(PendingInitRecordJson)(next).pipe(
219
+ Effect.mapError((error) => storageError(targetPath, error)),
220
+ )
221
+ // Same directory, so `rename` stays on one filesystem and is atomic.
222
+ //
223
+ // `acquireRelease` rather than manual cleanup on the typed-failure branches:
224
+ // a defect or an interrupt bypasses those branches entirely, and an
225
+ // abandoned `.tmp` is worse than it looks — it is a second copy of a live
226
+ // credential, `pruneExpired` only scans `.json` records so it never reaps
227
+ // one, and a fixed name made the next attempt fail on its own leftovers.
228
+ const staging = `${targetPath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`
229
+ const published = yield* Effect.scoped(
230
+ Effect.acquireRelease(Effect.succeed(staging), () => fileSystem.remove(staging).pipe(Effect.ignore)).pipe(
231
+ Effect.zipRight(
232
+ Effect.gen(function* () {
233
+ yield* assertNotSymbolicLink(staging)
234
+ yield* Effect.scoped(
235
+ Effect.gen(function* () {
236
+ const file = yield* fileSystem.open(staging, { flag: 'wx', mode: 0o600 })
237
+ yield* file.writeAll(new TextEncoder().encode(encoded))
238
+ yield* file.sync
239
+ }),
240
+ )
241
+ yield* fileSystem.rename(staging, targetPath)
242
+ }),
243
+ ),
244
+ ),
245
+ ).pipe(Effect.either)
246
+ if (Either.isLeft(published)) return yield* storageError(targetPath, published.left)
247
+ return true
248
+ })
249
+
250
+ const removeIfCurrent = Effect.fnUntraced(function* (record: PendingInitRecord) {
251
+ const targetPath = recordPath(record.key)
252
+ const current = yield* readRecordPath(targetPath)
253
+ if (Option.isNone(current) || current.value.userCode !== record.userCode) return false
254
+ yield* onStorage(targetPath, fileSystem.remove(targetPath))
255
+ return true
256
+ })
257
+
258
+ const get = Effect.fn('PendingInitStore.get')(function* (key: PendingInitKey) {
259
+ const record = yield* readRecord(key)
260
+ if (Option.isNone(record) || !(yield* isExpired(record.value))) return record
261
+ yield* removeIfCurrent(record.value)
262
+ return Option.none<PendingInitRecord>()
263
+ })
264
+
265
+ const peek = Effect.fn('PendingInitStore.peek')(function* (key: PendingInitKey) {
266
+ return yield* readRecord(key)
267
+ })
268
+
269
+ const putIfAbsent = Effect.fn('PendingInitStore.putIfAbsent')(function* (record: PendingInitRecord) {
270
+ const existing = yield* readRecord(record.key)
271
+ if (Option.isSome(existing) && !(yield* isExpired(existing.value))) return existing.value
272
+ if (Option.isSome(existing)) yield* removeIfCurrent(existing.value)
273
+ if (yield* writeExclusive(record)) return record
274
+ const winner = yield* readRecord(record.key)
275
+ return yield* Option.match(winner, {
276
+ onNone: () => storageError(recordPath(record.key), new Error('Pending record creation lost without a winner')),
277
+ onSome: Effect.succeed,
278
+ })
279
+ })
280
+
281
+ const removeIfMatch = Effect.fn('PendingInitStore.removeIfMatch')((record: PendingInitRecord) =>
282
+ removeIfCurrent(record),
283
+ )
284
+
285
+ const pruneExpired = Effect.fn('PendingInitStore.pruneExpired')(function* () {
286
+ yield* assertNotSymbolicLink(pendingPath)
287
+ const exists = yield* onStorage(pendingPath, fileSystem.exists(pendingPath))
288
+ if (!exists) {
289
+ return 0
290
+ }
291
+ const entries = yield* onStorage(pendingPath, fileSystem.readDirectory(pendingPath))
292
+ const removed = yield* Effect.forEach(
293
+ entries.filter((entry) => entry.endsWith('.json')),
294
+ (entry) =>
295
+ Effect.gen(function* () {
296
+ const record = yield* readRecordPath(path.join(pendingPath, entry))
297
+ if (Option.isNone(record) || !(yield* isExpired(record.value))) {
298
+ return 0
299
+ }
300
+ return (yield* removeIfCurrent(record.value)) ? 1 : 0
301
+ // One unreadable entry must not fail the sweep. Pruning runs
302
+ // on every begin, resume and refreshStatus, so a single
303
+ // corrupt or newer-format file would otherwise block all
304
+ // three — including the resume that would have recovered.
305
+ }).pipe(Effect.orElseSucceed(() => 0)),
306
+ )
307
+ return removed.reduce<number>((total, count) => total + count, 0)
308
+ })
309
+
310
+ const canonicalScopeRoot = Effect.fn('PendingInitStore.canonicalScopeRoot')(function* (
311
+ scope: PendingInitKey['scope'],
312
+ cwd = process.cwd(),
313
+ ) {
314
+ if (scope === 'global') return 'global'
315
+ const canonicalCwd = yield* onStorage(cwd, fileSystem.realPath(cwd))
316
+ const inspected = yield* Effect.forEach(ancestorPaths(path, canonicalCwd), (candidate) =>
317
+ onStorage(candidate, fileSystem.exists(path.join(candidate, '.git'))).pipe(
318
+ Effect.map((exists) => ({ candidate, exists })),
319
+ ),
320
+ )
321
+ const repositoryRoot = inspected.find(({ exists }) => exists)?.candidate
322
+ return repositoryRoot === undefined
323
+ ? canonicalCwd
324
+ : yield* onStorage(repositoryRoot, fileSystem.realPath(repositoryRoot))
325
+ })
326
+
327
+ return {
328
+ pendingPath,
329
+ get,
330
+ peek,
331
+ putIfAbsent,
332
+ replaceIfMatch,
333
+ removeIfMatch,
334
+ pruneExpired,
335
+ canonicalScopeRoot,
336
+ } as const
337
+ }),
338
+ }) {}