expandai 0.0.4 → 0.0.6

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 (321) hide show
  1. package/README.md +2 -2
  2. package/build/{esm/Install/Opencode → assets/opencode}/expandai-webfetch.js +22 -4
  3. package/build/dts/Mcp/RequestHeaders.d.ts +0 -1
  4. package/build/dts/Mcp/Server.d.ts +1 -2
  5. package/build/dts/index.d.ts +0 -1
  6. package/build/esm/index.js +193 -2
  7. package/build/esm/main.js +691 -200
  8. package/build/skills/expandai/SKILL.md +100 -8
  9. package/package.json +20 -12
  10. package/build/dts/ApiClient.d.ts +0 -165
  11. package/build/dts/ApiClient.d.ts.map +0 -1
  12. package/build/dts/Auth/Domain.d.ts +0 -95
  13. package/build/dts/Auth/Domain.d.ts.map +0 -1
  14. package/build/dts/Auth/ManualAuthorization.d.ts +0 -107
  15. package/build/dts/Auth/ManualAuthorization.d.ts.map +0 -1
  16. package/build/dts/Auth/PendingInit.d.ts +0 -35
  17. package/build/dts/Auth/PendingInit.d.ts.map +0 -1
  18. package/build/dts/Auth/Pkce.d.ts +0 -10
  19. package/build/dts/Auth/Pkce.d.ts.map +0 -1
  20. package/build/dts/Auth/SetupGrantAuthorization.d.ts +0 -34
  21. package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +0 -1
  22. package/build/dts/Authentication.d.ts +0 -24
  23. package/build/dts/Authentication.d.ts.map +0 -1
  24. package/build/dts/BrowserOAuth.d.ts +0 -99
  25. package/build/dts/BrowserOAuth.d.ts.map +0 -1
  26. package/build/dts/CommandPath.d.ts +0 -2
  27. package/build/dts/CommandPath.d.ts.map +0 -1
  28. package/build/dts/Commands/Fetch.d.ts +0 -30
  29. package/build/dts/Commands/Fetch.d.ts.map +0 -1
  30. package/build/dts/Commands/Hook.d.ts +0 -17
  31. package/build/dts/Commands/Hook.d.ts.map +0 -1
  32. package/build/dts/Commands/Init.d.ts +0 -55
  33. package/build/dts/Commands/Init.d.ts.map +0 -1
  34. package/build/dts/Commands/Mcp.d.ts +0 -11
  35. package/build/dts/Commands/Mcp.d.ts.map +0 -1
  36. package/build/dts/Commands/McpRun.d.ts +0 -5
  37. package/build/dts/Commands/McpRun.d.ts.map +0 -1
  38. package/build/dts/Commands/Search.d.ts +0 -21
  39. package/build/dts/Commands/Search.d.ts.map +0 -1
  40. package/build/dts/Commands/Skill.d.ts +0 -12
  41. package/build/dts/Commands/Skill.d.ts.map +0 -1
  42. package/build/dts/Commands/Status.d.ts +0 -27
  43. package/build/dts/Commands/Status.d.ts.map +0 -1
  44. package/build/dts/Commands/Telemetry.d.ts +0 -7
  45. package/build/dts/Commands/Telemetry.d.ts.map +0 -1
  46. package/build/dts/Commands/Uninstall.d.ts +0 -9
  47. package/build/dts/Commands/Uninstall.d.ts.map +0 -1
  48. package/build/dts/Config.d.ts +0 -196
  49. package/build/dts/Config.d.ts.map +0 -1
  50. package/build/dts/Hook/Claude.d.ts +0 -13
  51. package/build/dts/Hook/Claude.d.ts.map +0 -1
  52. package/build/dts/Hook/Errors.d.ts +0 -20
  53. package/build/dts/Hook/Errors.d.ts.map +0 -1
  54. package/build/dts/Hook/Opencode.d.ts +0 -13
  55. package/build/dts/Hook/Opencode.d.ts.map +0 -1
  56. package/build/dts/Hook/SnapshotNote.d.ts +0 -12
  57. package/build/dts/Hook/SnapshotNote.d.ts.map +0 -1
  58. package/build/dts/Init/ClaudeAdapter.d.ts +0 -61
  59. package/build/dts/Init/ClaudeAdapter.d.ts.map +0 -1
  60. package/build/dts/Init/ClaudeProfile.d.ts +0 -77
  61. package/build/dts/Init/ClaudeProfile.d.ts.map +0 -1
  62. package/build/dts/Init/Domain.d.ts +0 -139
  63. package/build/dts/Init/Domain.d.ts.map +0 -1
  64. package/build/dts/Init/HarnessAdapter.d.ts +0 -33
  65. package/build/dts/Init/HarnessAdapter.d.ts.map +0 -1
  66. package/build/dts/Init/HarnessDetector.d.ts +0 -19
  67. package/build/dts/Init/HarnessDetector.d.ts.map +0 -1
  68. package/build/dts/Init/HarnessRegistry.d.ts +0 -50
  69. package/build/dts/Init/HarnessRegistry.d.ts.map +0 -1
  70. package/build/dts/Init/MutationTransaction.d.ts +0 -96
  71. package/build/dts/Init/MutationTransaction.d.ts.map +0 -1
  72. package/build/dts/Init/Service.d.ts +0 -72
  73. package/build/dts/Init/Service.d.ts.map +0 -1
  74. package/build/dts/Init/StandardAdapters.d.ts +0 -42
  75. package/build/dts/Init/StandardAdapters.d.ts.map +0 -1
  76. package/build/dts/Init/Status.d.ts +0 -149
  77. package/build/dts/Init/Status.d.ts.map +0 -1
  78. package/build/dts/Install/Claude/Hook.d.ts +0 -88
  79. package/build/dts/Install/Claude/Hook.d.ts.map +0 -1
  80. package/build/dts/Install/Claude/Mcp.d.ts +0 -48
  81. package/build/dts/Install/Claude/Mcp.d.ts.map +0 -1
  82. package/build/dts/Install/Claude/Skill.d.ts +0 -23
  83. package/build/dts/Install/Claude/Skill.d.ts.map +0 -1
  84. package/build/dts/Install/Codex/Mcp.d.ts +0 -45
  85. package/build/dts/Install/Codex/Mcp.d.ts.map +0 -1
  86. package/build/dts/Install/Codex/Skill.d.ts +0 -22
  87. package/build/dts/Install/Codex/Skill.d.ts.map +0 -1
  88. package/build/dts/Install/Cursor/Mcp.d.ts +0 -45
  89. package/build/dts/Install/Cursor/Mcp.d.ts.map +0 -1
  90. package/build/dts/Install/Cursor/Skill.d.ts +0 -29
  91. package/build/dts/Install/Cursor/Skill.d.ts.map +0 -1
  92. package/build/dts/Install/Domain.d.ts +0 -103
  93. package/build/dts/Install/Domain.d.ts.map +0 -1
  94. package/build/dts/Install/Hook.d.ts +0 -30
  95. package/build/dts/Install/Hook.d.ts.map +0 -1
  96. package/build/dts/Install/Mcp.d.ts +0 -28
  97. package/build/dts/Install/Mcp.d.ts.map +0 -1
  98. package/build/dts/Install/Opencode/Hook.d.ts +0 -24
  99. package/build/dts/Install/Opencode/Hook.d.ts.map +0 -1
  100. package/build/dts/Install/Opencode/Mcp.d.ts +0 -47
  101. package/build/dts/Install/Opencode/Mcp.d.ts.map +0 -1
  102. package/build/dts/Install/Opencode/Skill.d.ts +0 -26
  103. package/build/dts/Install/Opencode/Skill.d.ts.map +0 -1
  104. package/build/dts/Install/Skill.d.ts +0 -32
  105. package/build/dts/Install/Skill.d.ts.map +0 -1
  106. package/build/dts/Install/Uninstall.d.ts +0 -32
  107. package/build/dts/Install/Uninstall.d.ts.map +0 -1
  108. package/build/dts/MarkdownAnsi.d.ts +0 -29
  109. package/build/dts/MarkdownAnsi.d.ts.map +0 -1
  110. package/build/dts/Mcp/ExpandDocs.d.ts +0 -19
  111. package/build/dts/Mcp/ExpandDocs.d.ts.map +0 -1
  112. package/build/dts/Mcp/RequestHeaders.d.ts.map +0 -1
  113. package/build/dts/Mcp/Server.d.ts.map +0 -1
  114. package/build/dts/Mcp/Toolkit.d.ts +0 -3
  115. package/build/dts/Mcp/Toolkit.d.ts.map +0 -1
  116. package/build/dts/Mcp/tools/Citations.d.ts +0 -266
  117. package/build/dts/Mcp/tools/Citations.d.ts.map +0 -1
  118. package/build/dts/Mcp/tools/Fetch.d.ts +0 -168
  119. package/build/dts/Mcp/tools/Fetch.d.ts.map +0 -1
  120. package/build/dts/SecureFile.d.ts +0 -23
  121. package/build/dts/SecureFile.d.ts.map +0 -1
  122. package/build/dts/Snippets.d.ts +0 -18
  123. package/build/dts/Snippets.d.ts.map +0 -1
  124. package/build/dts/Telemetry.d.ts +0 -30
  125. package/build/dts/Telemetry.d.ts.map +0 -1
  126. package/build/dts/Version.d.ts +0 -2
  127. package/build/dts/Version.d.ts.map +0 -1
  128. package/build/dts/Xdg.d.ts +0 -11
  129. package/build/dts/Xdg.d.ts.map +0 -1
  130. package/build/dts/index.d.ts.map +0 -1
  131. package/build/dts/main.d.ts +0 -3
  132. package/build/dts/main.d.ts.map +0 -1
  133. package/build/esm/ApiClient.js +0 -305
  134. package/build/esm/ApiClient.js.map +0 -1
  135. package/build/esm/Auth/Domain.js +0 -56
  136. package/build/esm/Auth/Domain.js.map +0 -1
  137. package/build/esm/Auth/ManualAuthorization.js +0 -253
  138. package/build/esm/Auth/ManualAuthorization.js.map +0 -1
  139. package/build/esm/Auth/PendingInit.js +0 -267
  140. package/build/esm/Auth/PendingInit.js.map +0 -1
  141. package/build/esm/Auth/Pkce.js +0 -23
  142. package/build/esm/Auth/Pkce.js.map +0 -1
  143. package/build/esm/Auth/SetupGrantAuthorization.js +0 -91
  144. package/build/esm/Auth/SetupGrantAuthorization.js.map +0 -1
  145. package/build/esm/Authentication.js +0 -143
  146. package/build/esm/Authentication.js.map +0 -1
  147. package/build/esm/BrowserOAuth.js +0 -251
  148. package/build/esm/BrowserOAuth.js.map +0 -1
  149. package/build/esm/CommandPath.js +0 -31
  150. package/build/esm/CommandPath.js.map +0 -1
  151. package/build/esm/Commands/Fetch.js +0 -119
  152. package/build/esm/Commands/Fetch.js.map +0 -1
  153. package/build/esm/Commands/Hook.js +0 -20
  154. package/build/esm/Commands/Hook.js.map +0 -1
  155. package/build/esm/Commands/Init.js +0 -371
  156. package/build/esm/Commands/Init.js.map +0 -1
  157. package/build/esm/Commands/Mcp.js +0 -19
  158. package/build/esm/Commands/Mcp.js.map +0 -1
  159. package/build/esm/Commands/McpRun.js +0 -15
  160. package/build/esm/Commands/McpRun.js.map +0 -1
  161. package/build/esm/Commands/Search.js +0 -46
  162. package/build/esm/Commands/Search.js.map +0 -1
  163. package/build/esm/Commands/Skill.js +0 -17
  164. package/build/esm/Commands/Skill.js.map +0 -1
  165. package/build/esm/Commands/Status.js +0 -186
  166. package/build/esm/Commands/Status.js.map +0 -1
  167. package/build/esm/Commands/Telemetry.js +0 -23
  168. package/build/esm/Commands/Telemetry.js.map +0 -1
  169. package/build/esm/Commands/Uninstall.js +0 -28
  170. package/build/esm/Commands/Uninstall.js.map +0 -1
  171. package/build/esm/Config.js +0 -619
  172. package/build/esm/Config.js.map +0 -1
  173. package/build/esm/Hook/Claude.js +0 -74
  174. package/build/esm/Hook/Claude.js.map +0 -1
  175. package/build/esm/Hook/Errors.js +0 -18
  176. package/build/esm/Hook/Errors.js.map +0 -1
  177. package/build/esm/Hook/Opencode.js +0 -50
  178. package/build/esm/Hook/Opencode.js.map +0 -1
  179. package/build/esm/Hook/SnapshotNote.js +0 -65
  180. package/build/esm/Hook/SnapshotNote.js.map +0 -1
  181. package/build/esm/Init/ClaudeAdapter.js +0 -137
  182. package/build/esm/Init/ClaudeAdapter.js.map +0 -1
  183. package/build/esm/Init/ClaudeProfile.js +0 -344
  184. package/build/esm/Init/ClaudeProfile.js.map +0 -1
  185. package/build/esm/Init/Domain.js +0 -118
  186. package/build/esm/Init/Domain.js.map +0 -1
  187. package/build/esm/Init/HarnessAdapter.js +0 -21
  188. package/build/esm/Init/HarnessAdapter.js.map +0 -1
  189. package/build/esm/Init/HarnessDetector.js +0 -65
  190. package/build/esm/Init/HarnessDetector.js.map +0 -1
  191. package/build/esm/Init/HarnessRegistry.js +0 -58
  192. package/build/esm/Init/HarnessRegistry.js.map +0 -1
  193. package/build/esm/Init/MutationTransaction.js +0 -494
  194. package/build/esm/Init/MutationTransaction.js.map +0 -1
  195. package/build/esm/Init/Service.js +0 -319
  196. package/build/esm/Init/Service.js.map +0 -1
  197. package/build/esm/Init/StandardAdapters.js +0 -116
  198. package/build/esm/Init/StandardAdapters.js.map +0 -1
  199. package/build/esm/Init/Status.js +0 -215
  200. package/build/esm/Init/Status.js.map +0 -1
  201. package/build/esm/Install/Claude/Hook.js +0 -77
  202. package/build/esm/Install/Claude/Hook.js.map +0 -1
  203. package/build/esm/Install/Claude/Mcp.js +0 -101
  204. package/build/esm/Install/Claude/Mcp.js.map +0 -1
  205. package/build/esm/Install/Claude/Skill.js +0 -56
  206. package/build/esm/Install/Claude/Skill.js.map +0 -1
  207. package/build/esm/Install/Codex/Mcp.js +0 -97
  208. package/build/esm/Install/Codex/Mcp.js.map +0 -1
  209. package/build/esm/Install/Codex/Skill.js +0 -33
  210. package/build/esm/Install/Codex/Skill.js.map +0 -1
  211. package/build/esm/Install/Cursor/Mcp.js +0 -74
  212. package/build/esm/Install/Cursor/Mcp.js.map +0 -1
  213. package/build/esm/Install/Cursor/Skill.js +0 -40
  214. package/build/esm/Install/Cursor/Skill.js.map +0 -1
  215. package/build/esm/Install/Domain.js +0 -162
  216. package/build/esm/Install/Domain.js.map +0 -1
  217. package/build/esm/Install/Hook.js +0 -38
  218. package/build/esm/Install/Hook.js.map +0 -1
  219. package/build/esm/Install/Mcp.js +0 -40
  220. package/build/esm/Install/Mcp.js.map +0 -1
  221. package/build/esm/Install/Opencode/Hook.js +0 -44
  222. package/build/esm/Install/Opencode/Hook.js.map +0 -1
  223. package/build/esm/Install/Opencode/Mcp.js +0 -76
  224. package/build/esm/Install/Opencode/Mcp.js.map +0 -1
  225. package/build/esm/Install/Opencode/Skill.js +0 -37
  226. package/build/esm/Install/Opencode/Skill.js.map +0 -1
  227. package/build/esm/Install/Skill.js +0 -41
  228. package/build/esm/Install/Skill.js.map +0 -1
  229. package/build/esm/Install/Uninstall.js +0 -49
  230. package/build/esm/Install/Uninstall.js.map +0 -1
  231. package/build/esm/MarkdownAnsi.js +0 -142
  232. package/build/esm/MarkdownAnsi.js.map +0 -1
  233. package/build/esm/Mcp/ExpandDocs.js +0 -56
  234. package/build/esm/Mcp/ExpandDocs.js.map +0 -1
  235. package/build/esm/Mcp/RequestHeaders.js +0 -13
  236. package/build/esm/Mcp/RequestHeaders.js.map +0 -1
  237. package/build/esm/Mcp/Server.js +0 -12
  238. package/build/esm/Mcp/Server.js.map +0 -1
  239. package/build/esm/Mcp/Toolkit.js +0 -16
  240. package/build/esm/Mcp/Toolkit.js.map +0 -1
  241. package/build/esm/Mcp/tools/Citations.js +0 -193
  242. package/build/esm/Mcp/tools/Citations.js.map +0 -1
  243. package/build/esm/Mcp/tools/Fetch.js +0 -165
  244. package/build/esm/Mcp/tools/Fetch.js.map +0 -1
  245. package/build/esm/SecureFile.js +0 -36
  246. package/build/esm/SecureFile.js.map +0 -1
  247. package/build/esm/Snippets.js +0 -29
  248. package/build/esm/Snippets.js.map +0 -1
  249. package/build/esm/Telemetry.js +0 -157
  250. package/build/esm/Telemetry.js.map +0 -1
  251. package/build/esm/Version.js +0 -4
  252. package/build/esm/Version.js.map +0 -1
  253. package/build/esm/Xdg.js +0 -15
  254. package/build/esm/Xdg.js.map +0 -1
  255. package/build/esm/index.js.map +0 -1
  256. package/build/esm/main.js.map +0 -1
  257. package/src/ApiClient.ts +0 -450
  258. package/src/Auth/Domain.ts +0 -63
  259. package/src/Auth/ManualAuthorization.ts +0 -296
  260. package/src/Auth/PendingInit.ts +0 -338
  261. package/src/Auth/Pkce.ts +0 -26
  262. package/src/Auth/SetupGrantAuthorization.ts +0 -116
  263. package/src/Authentication.ts +0 -202
  264. package/src/BrowserOAuth.ts +0 -359
  265. package/src/CommandPath.ts +0 -30
  266. package/src/Commands/Fetch.ts +0 -188
  267. package/src/Commands/Hook.ts +0 -48
  268. package/src/Commands/Init.ts +0 -489
  269. package/src/Commands/Mcp.ts +0 -30
  270. package/src/Commands/McpRun.ts +0 -25
  271. package/src/Commands/Search.ts +0 -97
  272. package/src/Commands/Skill.ts +0 -30
  273. package/src/Commands/Status.ts +0 -228
  274. package/src/Commands/Telemetry.ts +0 -42
  275. package/src/Commands/Uninstall.ts +0 -38
  276. package/src/Config.ts +0 -840
  277. package/src/Hook/Claude.ts +0 -81
  278. package/src/Hook/Errors.ts +0 -19
  279. package/src/Hook/Opencode.ts +0 -58
  280. package/src/Hook/SnapshotNote.ts +0 -66
  281. package/src/Init/ClaudeAdapter.ts +0 -191
  282. package/src/Init/ClaudeProfile.ts +0 -453
  283. package/src/Init/Domain.ts +0 -153
  284. package/src/Init/HarnessAdapter.ts +0 -35
  285. package/src/Init/HarnessDetector.ts +0 -77
  286. package/src/Init/HarnessRegistry.ts +0 -87
  287. package/src/Init/MutationTransaction.ts +0 -747
  288. package/src/Init/Service.ts +0 -385
  289. package/src/Init/StandardAdapters.ts +0 -141
  290. package/src/Init/Status.ts +0 -253
  291. package/src/Install/Claude/Hook.ts +0 -123
  292. package/src/Install/Claude/Mcp.ts +0 -129
  293. package/src/Install/Claude/Skill.ts +0 -77
  294. package/src/Install/Codex/Mcp.ts +0 -114
  295. package/src/Install/Codex/Skill.ts +0 -44
  296. package/src/Install/Cursor/Mcp.ts +0 -85
  297. package/src/Install/Cursor/Skill.ts +0 -51
  298. package/src/Install/Domain.ts +0 -221
  299. package/src/Install/Hook.ts +0 -50
  300. package/src/Install/Mcp.ts +0 -71
  301. package/src/Install/Opencode/Hook.ts +0 -55
  302. package/src/Install/Opencode/Mcp.ts +0 -87
  303. package/src/Install/Opencode/Skill.ts +0 -48
  304. package/src/Install/Opencode/expandai-webfetch.d.ts +0 -23
  305. package/src/Install/Opencode/expandai-webfetch.js +0 -56
  306. package/src/Install/Skill.ts +0 -57
  307. package/src/Install/Uninstall.ts +0 -82
  308. package/src/MarkdownAnsi.ts +0 -162
  309. package/src/Mcp/ExpandDocs.ts +0 -62
  310. package/src/Mcp/RequestHeaders.ts +0 -16
  311. package/src/Mcp/Server.ts +0 -15
  312. package/src/Mcp/Toolkit.ts +0 -21
  313. package/src/Mcp/tools/Citations.ts +0 -219
  314. package/src/Mcp/tools/Fetch.ts +0 -203
  315. package/src/SecureFile.ts +0 -70
  316. package/src/Snippets.ts +0 -39
  317. package/src/Telemetry.ts +0 -186
  318. package/src/Version.ts +0 -3
  319. package/src/Xdg.ts +0 -18
  320. package/src/index.ts +0 -1
  321. package/src/main.ts +0 -305
package/src/Config.ts DELETED
@@ -1,840 +0,0 @@
1
- /**
2
- * CLI configuration. Persisted at `$XDG_CONFIG_HOME/expandai/config.json`
3
- * (default `~/.config/expandai/config.json`). `EXPAND_API_URL` overrides the
4
- * file. `EXPAND_API_KEY` overrides an API key stored in the file, but not
5
- * credentials established by `expandai login` or agent setup — signing in is a
6
- * deliberate act, and an exported API key usually is not. `EXPAND_APP_URL`
7
- * points guide links at the web app serving them.
8
- */
9
- import { Command, CommandExecutor, FileSystem, Path, Error as PlatformError } from '@effect/platform'
10
- import { NodeContext } from '@effect/platform-node'
11
- import { DEFAULT_PLAYGROUND_HOST } from '@expandai/sdk/Playground'
12
- import { Config, DateTime, Effect, Either, Exit, Option, Random, Redacted, Schema } from 'effect'
13
- import { type CreatedFileIdentity, createExclusiveFile, matchesCreatedFile } from './SecureFile.js'
14
- import { xdgConfigDirectory } from './Xdg.js'
15
-
16
- /** Production API endpoint used when neither `EXPAND_API_URL` nor the config file overrides it. */
17
- export const DEFAULT_API_URL = 'https://api.expand.ai'
18
-
19
- /** Production web app used when `EXPAND_APP_URL` does not override it. */
20
- export const DEFAULT_APP_URL = 'https://expand.ai'
21
-
22
- /**
23
- * Lenient env readers. `Config.option` / `Config.withDefault` only absorb
24
- * missing data — a present-but-malformed value (`CLAUDECODE=banana`,
25
- * `EXPANDAI_HARNESS=bogus`) fails as InvalidData and would crash the CLI at
26
- * layer construction. These read the raw string and degrade gracefully:
27
- * flags follow the DO_NOT_TRACK convention (set unless an explicit off),
28
- * literals decode to None instead of failing.
29
- */
30
- export const envFlag = (name: string) =>
31
- Config.string(name).pipe(
32
- Config.option,
33
- Config.map(
34
- Option.match({
35
- onNone: () => false,
36
- onSome: (value) => value !== '' && value !== '0' && value !== 'false',
37
- }),
38
- ),
39
- )
40
-
41
- export const envLiteral = <A extends string>(name: string, schema: Schema.Schema<A, A>) =>
42
- Config.string(name).pipe(Config.option, Config.map(Option.flatMap(Schema.decodeUnknownOption(schema))))
43
-
44
- export const ApiKey = Schema.Redacted(Schema.NonEmptyTrimmedString.pipe(Schema.brand('ApiKey')))
45
-
46
- export const ApiKeyConfig = Schema.TaggedStruct('ApiKey', { apiKey: ApiKey })
47
- export const OAuthConfig = Schema.TaggedStruct('OAuth', {
48
- accessToken: Schema.Redacted(Schema.String),
49
- refreshToken: Schema.optional(Schema.Redacted(Schema.String)),
50
- expiresAt: Schema.DateTimeUtc,
51
- // Refresh tokens are bound to their issuing client, so a credential minted by
52
- // browser-approved setup cannot be refreshed as `expandai-cli`. Optional because
53
- // configs written before this existed all came from `expandai login`.
54
- clientId: Schema.optional(Schema.String),
55
- })
56
-
57
- export const TelemetrySettings = Schema.Struct({
58
- enabled: Schema.optional(Schema.Boolean),
59
- distinctId: Schema.optional(Schema.String),
60
- noticeShown: Schema.optional(Schema.Boolean),
61
- })
62
-
63
- export class ExpandCliConfig extends Schema.Class<ExpandCliConfig>('ExpandCliConfig')({
64
- apiUrl: Schema.optional(Schema.URL),
65
- authMethod: Schema.optional(Schema.Union(ApiKeyConfig, OAuthConfig)),
66
- telemetry: Schema.optional(TelemetrySettings),
67
- }) {
68
- static readonly Json = Schema.parseJson(ExpandCliConfig)
69
- }
70
-
71
- export type AuthMethod = typeof ApiKeyConfig.Type | typeof OAuthConfig.Type
72
-
73
- /**
74
- * How a stored credential stands right now.
75
- *
76
- * `refresh_required` is deliberately distinct from `healthy`: an expired access
77
- * token with a refresh token on disk is only a credential we *could* renew, and
78
- * renewal can fail. Reporting it as healthy told agents a machine was authorized
79
- * when the next call would fail.
80
- */
81
- export type AuthMethodState = 'healthy' | 'refresh_required' | 'unusable'
82
-
83
- export function authMethodState(authMethod: AuthMethod, now: DateTime.Utc): AuthMethodState {
84
- if (authMethod._tag === 'ApiKey') return 'healthy'
85
- if (DateTime.lessThan(now, authMethod.expiresAt)) {
86
- return Redacted.value(authMethod.accessToken).trim().length > 0 ? 'healthy' : 'unusable'
87
- }
88
- const refreshToken = authMethod.refreshToken
89
- return refreshToken !== undefined && Redacted.value(refreshToken).trim().length > 0 ? 'refresh_required' : 'unusable'
90
- }
91
-
92
- /** Returns whether a local credential can be used now or renewed on first API use. */
93
- export function isAuthMethodLocallyUsable(authMethod: AuthMethod, now: DateTime.Utc): boolean {
94
- return authMethodState(authMethod, now) !== 'unusable'
95
- }
96
-
97
- const RawConfigRecord = Schema.Record({ key: Schema.String, value: Schema.Unknown })
98
- const RawConfigJson = Schema.parseJson(RawConfigRecord)
99
-
100
- // Unknown top-level keys survive a write, so a config written by a newer CLI
101
- // is not silently truncated by an older one.
102
- function mergeRawConfig(
103
- existing: typeof RawConfigRecord.Type,
104
- known: typeof RawConfigRecord.Type,
105
- ): typeof RawConfigRecord.Type {
106
- return { ...existing, ...known }
107
- }
108
-
109
- interface ConfigFileContents {
110
- readonly config: ExpandCliConfig
111
- readonly raw: typeof RawConfigRecord.Type
112
- }
113
-
114
- interface PathIdentity {
115
- readonly exists: boolean
116
- readonly dev?: number
117
- readonly ino?: number
118
- readonly realPath?: string
119
- readonly type?: FileSystem.File.Type
120
- }
121
-
122
- function isErrnoCode(input: unknown, code: string): boolean {
123
- return typeof input === 'object' && input !== null && 'code' in input && input.code === code
124
- }
125
-
126
- function powerShellLiteral(value: string): string {
127
- return `'${value.replaceAll("'", "''")}'`
128
- }
129
-
130
- function scriptWithTargetPaths(script: string, targetPaths: ReadonlyArray<string>): string {
131
- return `$targetPaths = @(${targetPaths.map((targetPath) => powerShellLiteral(targetPath)).join(', ')})
132
- try {
133
- Import-Module (Join-Path $PSHOME 'Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1') -ErrorAction Stop
134
- $null = & {
135
- ${script}
136
- }
137
- exit 0
138
- } catch {
139
- [Console]::Error.WriteLine($_.Exception.Message)
140
- exit 1
141
- }`
142
- }
143
-
144
- function sameIdentity(left: PathIdentity, right: PathIdentity): boolean {
145
- return (
146
- left.exists === right.exists &&
147
- left.dev === right.dev &&
148
- left.ino === right.ino &&
149
- left.realPath === right.realPath &&
150
- left.type === right.type
151
- )
152
- }
153
-
154
- export class ConfigNotWritable extends Schema.TaggedError<ConfigNotWritable>()('ConfigNotWritable', {
155
- path: Schema.String,
156
- }) {
157
- get message() {
158
- return `Cannot write to ${this.path}. Check filesystem permissions on the config directory.`
159
- }
160
- }
161
-
162
- export class ConfigCleanupError extends Schema.TaggedError<ConfigCleanupError>()('ConfigCleanupError', {
163
- path: Schema.String,
164
- temporaryPath: Schema.String,
165
- error: Schema.Defect,
166
- operationError: Schema.Defect,
167
- }) {
168
- get message() {
169
- return `Could not safely clean up config temporary state at ${this.temporaryPath}; it was left unchanged.`
170
- }
171
- }
172
-
173
- export class ConfigParseError extends Schema.TaggedError<ConfigParseError>()('ConfigParseError', {
174
- path: Schema.String,
175
- error: Schema.Defect,
176
- }) {
177
- get message() {
178
- return `Could not parse ${this.path}. Delete the file and run \`expandai login\` to recreate it.`
179
- }
180
- }
181
-
182
- export class CredentialStoreInsecure extends Schema.TaggedError<CredentialStoreInsecure>()('CredentialStoreInsecure', {
183
- path: Schema.String,
184
- reason: Schema.String,
185
- error: Schema.Defect,
186
- }) {
187
- get message() {
188
- return `Credential storage at ${this.path} is insecure: ${this.reason}`
189
- }
190
- }
191
-
192
- export class NotAuthenticated extends Schema.TaggedError<NotAuthenticated>()('NotAuthenticated', {}) {
193
- get message() {
194
- return 'Not authenticated. Run `expandai login` to authenticate.'
195
- }
196
- }
197
-
198
- export class OAuthTokenExpired extends Schema.TaggedError<OAuthTokenExpired>()('OAuthTokenExpired', {
199
- expiredAt: Schema.DateTimeUtc,
200
- }) {
201
- get message() {
202
- return `Your OAuth token expired at ${DateTime.formatIso(this.expiredAt)}. Run \`expandai login\` to re-authenticate.`
203
- }
204
- }
205
-
206
- const Environment = Effect.gen(function* () {
207
- const apiKey = yield* Schema.Config('EXPAND_API_KEY', ApiKey).pipe(Config.option)
208
- const apiUrl = yield* Config.url('EXPAND_API_URL').pipe(Config.option)
209
-
210
- return ExpandCliConfig.make({
211
- apiUrl: Option.getOrUndefined(apiUrl),
212
- authMethod: Option.getOrUndefined(Option.map(apiKey, (apiKey) => ({ _tag: 'ApiKey' as const, apiKey }))),
213
- })
214
- })
215
-
216
- export class ConfigManager extends Effect.Service<ConfigManager>()('@expandai/cli/Config/ConfigManager', {
217
- dependencies: [NodeContext.layer],
218
- effect: Effect.gen(function* () {
219
- const commandExecutor = yield* CommandExecutor.CommandExecutor
220
- const fs = yield* FileSystem.FileSystem
221
- const path = yield* Path.Path
222
-
223
- const configPath = path.join(yield* xdgConfigDirectory, 'expandai', 'config.json')
224
- const configDirectory = path.dirname(configPath)
225
-
226
- const insecure = (targetPath: string, reason: string, error: unknown) =>
227
- new CredentialStoreInsecure({ path: targetPath, reason, error })
228
-
229
- const assertNotSymbolicLink = Effect.fnUntraced(function* (targetPath: string) {
230
- const result = yield* Effect.either(fs.readLink(targetPath))
231
- if (Either.isRight(result)) {
232
- return yield* insecure(
233
- targetPath,
234
- 'symbolic links are not allowed for credential storage',
235
- new Error(result.right),
236
- )
237
- }
238
- const error = result.left
239
- if (
240
- error instanceof PlatformError.SystemError &&
241
- (error.reason === 'InvalidData' ||
242
- error.reason === 'NotFound' ||
243
- (error.method === 'readLink' && isErrnoCode(error.cause, 'EINVAL')))
244
- ) {
245
- return
246
- }
247
- return yield* insecure(targetPath, 'could not inspect the credential path without following links', error)
248
- })
249
-
250
- const inspectPath = Effect.fnUntraced(function* (targetPath: string) {
251
- yield* assertNotSymbolicLink(targetPath)
252
- const exists = yield* fs
253
- .exists(targetPath)
254
- .pipe(Effect.mapError((error) => insecure(targetPath, 'could not inspect the credential path', error)))
255
- if (!exists) {
256
- const missing: PathIdentity = { exists: false }
257
- return missing
258
- }
259
- const info = yield* fs
260
- .stat(targetPath)
261
- .pipe(Effect.mapError((error) => insecure(targetPath, 'could not inspect the credential path', error)))
262
- const realPath = yield* fs
263
- .realPath(targetPath)
264
- .pipe(Effect.mapError((error) => insecure(targetPath, 'could not resolve the credential path', error)))
265
- const identity: PathIdentity = {
266
- exists: true,
267
- dev: info.dev,
268
- ino: Option.getOrUndefined(info.ino),
269
- realPath,
270
- type: info.type,
271
- }
272
- return identity
273
- })
274
-
275
- const assertStable = (targetPath: string, before: PathIdentity, after: PathIdentity) =>
276
- sameIdentity(before, after)
277
- ? Effect.void
278
- : Effect.fail(
279
- insecure(
280
- targetPath,
281
- 'credential path identity changed during the operation',
282
- new Error('Path swap detected'),
283
- ),
284
- )
285
-
286
- const inspectParentAndFile = Effect.fnUntraced(function* (targetPath: string) {
287
- const parentPath = path.dirname(targetPath)
288
- const parent = yield* inspectPath(parentPath)
289
- if (!parent.exists || parent.type !== 'Directory') {
290
- return yield* insecure(parentPath, 'credential parent is not a directory', new Error('Invalid parent'))
291
- }
292
- return { parentPath, parent, file: yield* inspectPath(targetPath) } as const
293
- })
294
-
295
- const readCredentialFile = Effect.fnUntraced(function* (targetPath: string) {
296
- const before = yield* inspectParentAndFile(targetPath)
297
- if (!before.file.exists || before.file.type !== 'File') {
298
- return yield* insecure(targetPath, 'credential path is not a regular file', new Error('Invalid file'))
299
- }
300
- const contents = yield* fs
301
- .readFileString(targetPath)
302
- .pipe(Effect.mapError((error) => insecure(targetPath, 'could not read the credential file', error)))
303
- const after = yield* inspectParentAndFile(targetPath)
304
- yield* assertStable(before.parentPath, before.parent, after.parent)
305
- yield* assertStable(targetPath, before.file, after.file)
306
- return contents
307
- })
308
-
309
- const ensurePosixMode = Effect.fnUntraced(function* (
310
- targetPath: string,
311
- mode: number,
312
- expectedType: 'Directory' | 'File',
313
- ) {
314
- const before = yield* inspectPath(targetPath)
315
- if (!before.exists || before.type !== expectedType) {
316
- return yield* insecure(targetPath, `expected a ${expectedType.toLowerCase()}`, new Error('Invalid path type'))
317
- }
318
- yield* fs
319
- .chmod(targetPath, mode)
320
- .pipe(Effect.mapError((error) => insecure(targetPath, `could not set mode ${mode.toString(8)}`, error)))
321
- const info = yield* fs
322
- .stat(targetPath)
323
- .pipe(Effect.mapError((error) => insecure(targetPath, 'could not verify permissions', error)))
324
- const actualMode = info.mode & 0o7777
325
- if (info.type !== expectedType || actualMode !== mode) {
326
- return yield* insecure(
327
- targetPath,
328
- `expected ${expectedType.toLowerCase()} mode ${mode.toString(8)}, found ${info.type.toLowerCase()} mode ${actualMode.toString(8)}`,
329
- new Error('Permission verification failed'),
330
- )
331
- }
332
- const after = yield* inspectPath(targetPath)
333
- yield* assertStable(targetPath, before, after)
334
- })
335
-
336
- const WINDOWS_ACL_SCRIPT = String.raw`
337
- $ErrorActionPreference = 'Stop'
338
- $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
339
- $allowedSids = @($currentSid, 'S-1-5-18', 'S-1-5-32-544')
340
- foreach ($targetPath in $targetPaths) {
341
- if (-not (Test-Path -LiteralPath $targetPath)) { throw "Missing credential path: $targetPath" }
342
- $item = Get-Item -LiteralPath $targetPath -Force
343
- $acl = Get-Acl -LiteralPath $targetPath
344
- $acl.SetAccessRuleProtection($true, $false)
345
- foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
346
- $inheritance = if ($item.PSIsContainer) {
347
- [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
348
- } else {
349
- [System.Security.AccessControl.InheritanceFlags]::None
350
- }
351
- foreach ($sidValue in $allowedSids) {
352
- $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue)
353
- $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
354
- $sid,
355
- [System.Security.AccessControl.FileSystemRights]::FullControl,
356
- $inheritance,
357
- [System.Security.AccessControl.PropagationFlags]::None,
358
- [System.Security.AccessControl.AccessControlType]::Allow
359
- )
360
- [void]$acl.AddAccessRule($rule)
361
- }
362
- Set-Acl -LiteralPath $targetPath -AclObject $acl
363
-
364
- $verified = Get-Acl -LiteralPath $targetPath
365
- if (-not $verified.AreAccessRulesProtected) { throw "Inherited ACL remains enabled: $targetPath" }
366
- $seenSids = @{}
367
- foreach ($rule in @($verified.Access)) {
368
- $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
369
- if ($allowedSids -notcontains $sid) { throw "Unexpected ACL principal $sid on $targetPath" }
370
- if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
371
- throw "Unexpected deny ACL for $sid on $targetPath"
372
- }
373
- $requiredRights = [System.Security.AccessControl.FileSystemRights]::FullControl
374
- if (($rule.FileSystemRights -band $requiredRights) -ne $requiredRights) {
375
- throw "ACL principal $sid lacks FullControl on $targetPath"
376
- }
377
- $seenSids[$sid] = $true
378
- }
379
- foreach ($allowedSid in $allowedSids) {
380
- if (-not $seenSids.ContainsKey($allowedSid)) { throw "Missing ACL principal $allowedSid on $targetPath" }
381
- }
382
- }
383
- 'SECURE'
384
- `
385
-
386
- const WINDOWS_ACL_INSPECTION_SCRIPT = String.raw`
387
- $ErrorActionPreference = 'Stop'
388
- $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
389
- $allowedSids = @($currentSid, 'S-1-5-18', 'S-1-5-32-544')
390
- foreach ($targetPath in $targetPaths) {
391
- if (-not (Test-Path -LiteralPath $targetPath)) { throw "Missing credential path: $targetPath" }
392
- $acl = Get-Acl -LiteralPath $targetPath
393
- if (-not $acl.AreAccessRulesProtected) { throw "Inherited ACL remains enabled: $targetPath" }
394
- $seenSids = @{}
395
- foreach ($rule in @($acl.Access)) {
396
- $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
397
- if ($allowedSids -notcontains $sid) { throw "Unexpected ACL principal $sid on $targetPath" }
398
- if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
399
- throw "Unexpected deny ACL for $sid on $targetPath"
400
- }
401
- $requiredRights = [System.Security.AccessControl.FileSystemRights]::FullControl
402
- if (($rule.FileSystemRights -band $requiredRights) -ne $requiredRights) {
403
- throw "ACL principal $sid lacks FullControl on $targetPath"
404
- }
405
- $seenSids[$sid] = $true
406
- }
407
- foreach ($allowedSid in $allowedSids) {
408
- if (-not $seenSids.ContainsKey($allowedSid)) { throw "Missing ACL principal $allowedSid on $targetPath" }
409
- }
410
- }
411
- 'SECURE'
412
- `
413
-
414
- const ensureWindowsAcl = Effect.fnUntraced(function* (targetPaths: ReadonlyArray<string>) {
415
- const exitCode = yield* Command.make(
416
- 'powershell.exe',
417
- '-NoLogo',
418
- '-NoProfile',
419
- '-NonInteractive',
420
- '-Command',
421
- scriptWithTargetPaths(WINDOWS_ACL_SCRIPT, targetPaths),
422
- ).pipe(
423
- Command.stderr('inherit'),
424
- Command.exitCode,
425
- Effect.provideService(CommandExecutor.CommandExecutor, commandExecutor),
426
- Effect.mapError((error) =>
427
- insecure(configDirectory, 'could not apply or verify the Windows ACL allowlist', error),
428
- ),
429
- )
430
- if (exitCode !== 0) {
431
- return yield* insecure(
432
- configDirectory,
433
- 'Windows ACL verification failed',
434
- new Error(`PowerShell exited with status ${exitCode}`),
435
- )
436
- }
437
- })
438
-
439
- const inspectWindowsAcl = Effect.fnUntraced(function* (targetPaths: ReadonlyArray<string>) {
440
- const exitCode = yield* Command.make(
441
- 'powershell.exe',
442
- '-NoLogo',
443
- '-NoProfile',
444
- '-NonInteractive',
445
- '-Command',
446
- scriptWithTargetPaths(WINDOWS_ACL_INSPECTION_SCRIPT, targetPaths),
447
- ).pipe(
448
- Command.stderr('inherit'),
449
- Command.exitCode,
450
- Effect.provideService(CommandExecutor.CommandExecutor, commandExecutor),
451
- Effect.mapError((error) => insecure(configDirectory, 'could not inspect the Windows ACL allowlist', error)),
452
- )
453
- if (exitCode !== 0) {
454
- return yield* insecure(
455
- configDirectory,
456
- 'Windows ACL inspection failed',
457
- new Error(`PowerShell exited with status ${exitCode}`),
458
- )
459
- }
460
- })
461
-
462
- const inspectSecureStorage = Effect.fn('ConfigManager.inspectSecureStorage')(function* () {
463
- const directory = yield* inspectPath(configDirectory)
464
- if (!directory.exists) return yield* Effect.void
465
- if (directory.type !== 'Directory') {
466
- return yield* insecure(
467
- configDirectory,
468
- 'config directory is not a regular directory',
469
- new Error('Invalid config directory'),
470
- )
471
- }
472
-
473
- const file = yield* inspectPath(configPath)
474
- if (file.exists && file.type !== 'File') {
475
- return yield* insecure(configPath, 'credential path is not a regular file', new Error('Invalid file'))
476
- }
477
-
478
- if (process.platform === 'win32') {
479
- const targets = file.exists ? [configDirectory, configPath] : [configDirectory]
480
- const before = yield* Effect.forEach(targets, inspectPath)
481
- yield* inspectWindowsAcl(targets)
482
- const after = yield* Effect.forEach(targets, inspectPath)
483
- yield* Effect.forEach(
484
- targets,
485
- (targetPath, index) =>
486
- assertStable(targetPath, before[index] ?? { exists: false }, after[index] ?? { exists: false }),
487
- { discard: true },
488
- )
489
- return yield* Effect.void
490
- }
491
-
492
- const directoryInfo = yield* fs
493
- .stat(configDirectory)
494
- .pipe(Effect.mapError((error) => insecure(configDirectory, 'could not inspect directory mode', error)))
495
- if ((directoryInfo.mode & 0o7777) !== 0o700) {
496
- return yield* insecure(configDirectory, 'config directory mode is not 700', new Error('Invalid directory mode'))
497
- }
498
- if (file.exists) {
499
- const fileInfo = yield* fs
500
- .stat(configPath)
501
- .pipe(Effect.mapError((error) => insecure(configPath, 'could not inspect credential file mode', error)))
502
- if ((fileInfo.mode & 0o7777) !== 0o600) {
503
- return yield* insecure(configPath, 'credential file mode is not 600', new Error('Invalid file mode'))
504
- }
505
- }
506
- return yield* Effect.void
507
- })
508
-
509
- const ensureSecureStorage = Effect.fn('ConfigManager.ensureSecureStorage')(function* () {
510
- yield* fs
511
- .makeDirectory(configDirectory, { recursive: true, mode: 0o700 })
512
- .pipe(Effect.mapError((error) => insecure(configDirectory, 'could not create the config directory', error)))
513
- const directory = yield* inspectPath(configDirectory)
514
- if (!directory.exists || directory.type !== 'Directory') {
515
- return yield* insecure(
516
- configDirectory,
517
- 'config directory is not a regular directory',
518
- new Error('Invalid config directory'),
519
- )
520
- }
521
-
522
- const exists = yield* fs
523
- .exists(configPath)
524
- .pipe(Effect.mapError((error) => insecure(configPath, 'could not inspect the credential file', error)))
525
- if (!exists) {
526
- const creation = yield* Effect.either(fs.writeFileString(configPath, '{}', { flag: 'wx', mode: 0o600 }))
527
- if (
528
- Either.isLeft(creation) &&
529
- (!(creation.left instanceof PlatformError.SystemError) || creation.left.reason !== 'AlreadyExists')
530
- ) {
531
- return yield* insecure(configPath, 'could not create the credential file', creation.left)
532
- }
533
- }
534
- yield* inspectParentAndFile(configPath)
535
-
536
- if (process.platform === 'win32') {
537
- const targets = [configDirectory, configPath]
538
- const before = yield* Effect.forEach(targets, (targetPath) => inspectPath(targetPath))
539
- yield* ensureWindowsAcl(targets)
540
- const after = yield* Effect.forEach(targets, (targetPath) => inspectPath(targetPath))
541
- yield* Effect.forEach(
542
- targets,
543
- (targetPath, index) =>
544
- assertStable(targetPath, before[index] ?? { exists: false }, after[index] ?? { exists: false }),
545
- { discard: true },
546
- )
547
- } else {
548
- yield* ensurePosixMode(configDirectory, 0o700, 'Directory')
549
- yield* ensurePosixMode(configPath, 0o600, 'File')
550
- }
551
- return yield* Effect.void
552
- })
553
-
554
- const atomicWriteConfig = Effect.fnUntraced(function* (encoded: string) {
555
- yield* ensureSecureStorage()
556
- const before = yield* inspectParentAndFile(configPath)
557
- const nonce = yield* Random.nextIntBetween(0, 0x7fffffff)
558
- const temporaryPath = path.join(configDirectory, `.config.json.${process.pid}.${nonce.toString(16)}.tmp`)
559
-
560
- const cleanupTemporary = Effect.fnUntraced(function* (created: CreatedFileIdentity, operationError: unknown) {
561
- const inspected = yield* Effect.either(inspectParentAndFile(temporaryPath))
562
- if (Either.isLeft(inspected)) {
563
- return yield* new ConfigCleanupError({
564
- path: configPath,
565
- temporaryPath,
566
- error: inspected.left,
567
- operationError,
568
- })
569
- }
570
- if (!sameIdentity(before.parent, inspected.right.parent)) {
571
- return yield* new ConfigCleanupError({
572
- path: configPath,
573
- temporaryPath,
574
- error: new Error('Config temp parent identity changed; refusing cleanup'),
575
- operationError,
576
- })
577
- }
578
- if (!matchesCreatedFile(created, inspected.right.file)) {
579
- return yield* new ConfigCleanupError({
580
- path: configPath,
581
- temporaryPath,
582
- error: new Error('Config temp identity changed or disappeared; refusing cleanup'),
583
- operationError,
584
- })
585
- }
586
- const removed = yield* Effect.either(fs.remove(temporaryPath))
587
- if (Either.isLeft(removed)) {
588
- return yield* new ConfigCleanupError({
589
- path: configPath,
590
- temporaryPath,
591
- error: removed.left,
592
- operationError,
593
- })
594
- }
595
- })
596
-
597
- const creation = yield* createExclusiveFile(fs, temporaryPath, encoded, 0o600).pipe(
598
- Effect.mapError(() => new ConfigNotWritable({ path: temporaryPath })),
599
- )
600
- if (Exit.isFailure(creation.writeExit)) {
601
- const primary = new ConfigNotWritable({ path: temporaryPath })
602
- yield* cleanupTemporary(creation.identity, primary)
603
- return yield* primary
604
- }
605
- if (!creation.pathMatchesHandle) {
606
- const primary = new ConfigNotWritable({ path: temporaryPath })
607
- yield* cleanupTemporary(creation.identity, primary)
608
- return yield* primary
609
- }
610
-
611
- const installResult = yield* Effect.either(
612
- Effect.gen(function* () {
613
- const temporary = yield* inspectParentAndFile(temporaryPath)
614
- yield* assertStable(before.parentPath, before.parent, temporary.parent)
615
- if (!matchesCreatedFile(creation.identity, temporary.file)) {
616
- return yield* insecure(
617
- temporaryPath,
618
- 'config temp identity differs from its creating handle',
619
- new Error('Config temp replacement detected'),
620
- )
621
- }
622
- if (process.platform === 'win32') {
623
- yield* ensureWindowsAcl([temporaryPath])
624
- const hardened = yield* inspectParentAndFile(temporaryPath)
625
- yield* assertStable(before.parentPath, before.parent, hardened.parent)
626
- if (!matchesCreatedFile(creation.identity, hardened.file)) {
627
- return yield* insecure(
628
- temporaryPath,
629
- 'config temp changed while applying its ACL',
630
- new Error('Config temp replacement detected'),
631
- )
632
- }
633
- } else {
634
- const info = yield* fs
635
- .stat(temporaryPath)
636
- .pipe(Effect.mapError((error) => insecure(temporaryPath, 'could not verify config temp mode', error)))
637
- if ((info.mode & 0o7777) !== 0o600) {
638
- return yield* insecure(
639
- temporaryPath,
640
- 'config temp mode is not 600',
641
- new Error('Invalid config temp mode'),
642
- )
643
- }
644
- }
645
- const current = yield* inspectParentAndFile(configPath)
646
- yield* assertStable(before.parentPath, before.parent, current.parent)
647
- yield* assertStable(configPath, before.file, current.file)
648
- yield* fs
649
- .rename(temporaryPath, configPath)
650
- .pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })))
651
- }),
652
- )
653
- if (Either.isLeft(installResult)) {
654
- yield* cleanupTemporary(creation.identity, installResult.left)
655
- return yield* installResult.left
656
- }
657
-
658
- const installed = yield* inspectParentAndFile(configPath)
659
- yield* assertStable(before.parentPath, before.parent, installed.parent)
660
- if (!matchesCreatedFile(creation.identity, installed.file)) {
661
- return yield* insecure(
662
- configPath,
663
- 'atomic credential installation identity could not be verified',
664
- new Error('Installed file identity mismatch'),
665
- )
666
- }
667
- })
668
-
669
- const ConfigFileFromDisk = Effect.gen(function* () {
670
- const exists = yield* fs.exists(configPath)
671
- if (!exists) {
672
- const empty: ConfigFileContents = { config: ExpandCliConfig.make({}), raw: {} }
673
- return empty
674
- }
675
-
676
- const string = yield* readCredentialFile(configPath)
677
- const raw = yield* Schema.decode(RawConfigJson)(string).pipe(
678
- Effect.mapError((error) => new ConfigParseError({ path: configPath, error })),
679
- )
680
- const config = yield* Schema.decodeUnknown(ExpandCliConfig)(raw).pipe(
681
- Effect.mapError((error) => new ConfigParseError({ path: configPath, error })),
682
- )
683
- const contents: ConfigFileContents = { config, raw }
684
- return contents
685
- })
686
-
687
- const EnvironmentFromConfigFile = ConfigFileFromDisk.pipe(Effect.map(({ config }) => config))
688
-
689
- const loadConfig = Effect.fn('loadConfig')(function* () {
690
- const env = yield* Environment
691
- const file = yield* EnvironmentFromConfigFile
692
-
693
- const apiUrl = {
694
- env: Option.fromNullable(env.apiUrl),
695
- file: Option.fromNullable(file.apiUrl),
696
- }
697
- const authMethod = {
698
- env: Option.fromNullable(env.authMethod),
699
- file: Option.fromNullable(file.authMethod),
700
- }
701
-
702
- yield* Effect.logWarning(`EXPAND_API_URL overrides apiUrl from ${configPath}`).pipe(
703
- Effect.when(() =>
704
- Option.zipWith(apiUrl.env, apiUrl.file, (env, file) => env.toString() !== file.toString()).pipe(
705
- Option.getOrElse(() => false),
706
- ),
707
- ),
708
- )
709
- // Credentials signed in through Expand's own authorization flow win over
710
- // an ambient `EXPAND_API_KEY`. Someone who just approved this machine in
711
- // a browser means those credentials; the environment variable is a
712
- // default for shells and CI, and it is frequently exported machine-wide
713
- // by tooling nobody is thinking about at the time. It still beats an API
714
- // key stored in the file, which is the same kind of credential.
715
- // Usable, not merely present. A stored OAuth credential that expired with
716
- // no refresh token can never succeed, and outranking the environment with
717
- // it left `EXPAND_API_KEY` permanently shadowed on exactly the CI and
718
- // agent machines this is meant to serve, with `expandai logout` the only
719
- // way out.
720
- const now = yield* DateTime.now
721
- const storedOAuth = authMethod.file.pipe(
722
- Option.filter((method) => method._tag === 'OAuth' && isAuthMethodLocallyUsable(method, now)),
723
- )
724
- const effectiveAuthMethod = Option.firstSomeOf([storedOAuth, authMethod.env, authMethod.file])
725
-
726
- yield* Effect.logWarning(
727
- `EXPAND_API_KEY is set but ${configPath} holds credentials from \`expandai login\` or agent setup, which take precedence. Unset EXPAND_API_KEY to silence this, or run \`expandai logout\` to use the API key.`,
728
- ).pipe(Effect.when(() => Option.isSome(authMethod.env) && Option.isSome(storedOAuth)))
729
- yield* Effect.logWarning(`EXPAND_API_KEY overrides the API key stored in ${configPath}`).pipe(
730
- Effect.when(
731
- () => Option.isSome(authMethod.env) && Option.isSome(authMethod.file) && Option.isNone(storedOAuth),
732
- ),
733
- )
734
-
735
- return yield* Option.match(effectiveAuthMethod, {
736
- onNone: () => Effect.fail(new NotAuthenticated()),
737
- onSome: (authMethod) =>
738
- Effect.succeed({
739
- apiUrl: Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(
740
- Option.getOrElse(() => new URL(DEFAULT_API_URL)),
741
- ),
742
- authMethod,
743
- }),
744
- })
745
- })
746
-
747
- const saveConfig = Effect.fn('saveConfig')(function* (config: ExpandCliConfig) {
748
- const encoded = yield* Schema.encode(ExpandCliConfig.Json)(config)
749
- yield* atomicWriteConfig(encoded)
750
- })
751
-
752
- // Propagates ConfigParseError: merging onto an empty fallback would
753
- // serialize `{}` over a file that failed to decode (corrupt, or written
754
- // by a newer CLI version) and destroy the credentials stored in it. No
755
- // write may ever follow a parse failure.
756
- const updateConfig = Effect.fn('updateConfig')(function* (patch: Partial<ExpandCliConfig>) {
757
- const existing = yield* ConfigFileFromDisk
758
- const updated = ExpandCliConfig.make({ ...existing.config, ...patch })
759
- const known = yield* Schema.encode(ExpandCliConfig.Json)(updated).pipe(
760
- Effect.flatMap(Schema.decode(RawConfigJson)),
761
- )
762
- const encoded = yield* Schema.encode(RawConfigJson)(mergeRawConfig(existing.raw, known))
763
- yield* atomicWriteConfig(encoded)
764
- })
765
-
766
- // The config file alone, without the env overrides or the NotAuthenticated
767
- // failure of loadConfig — telemetry settings must be readable before the
768
- // user has ever logged in. An unreadable or undecodable file is tolerated
769
- // but surfaced via `parseFailed` so callers never mistake it for an empty
770
- // one (and never write over it).
771
- const readConfigFile = Effect.fn('readConfigFile')(function* () {
772
- return yield* ConfigFileFromDisk.pipe(
773
- Effect.map(({ config }) => ({ config, parseFailed: false })),
774
- Effect.orElseSucceed(() => ({ config: ExpandCliConfig.make({}), parseFailed: true })),
775
- )
776
- })
777
-
778
- // Clears only authentication state: telemetry preferences (an explicit
779
- // opt-out, the distinct id, the notice flag) and a configured apiUrl
780
- // survive logout. An undecodable file is deleted instead of rewritten —
781
- // nothing in it can be preserved, and writing `{}` would break the
782
- // no-write-after-parse-failure rule updateConfig upholds.
783
- const clearConfig = Effect.fn('clearConfig')(function* () {
784
- const result = yield* Effect.either(ConfigFileFromDisk)
785
- if (Either.isLeft(result)) {
786
- if (!(result.left instanceof ConfigParseError)) return yield* result.left
787
- yield* Effect.logWarning(
788
- `Could not parse ${configPath}; removing it. Telemetry preferences and apiUrl were not preserved.`,
789
- )
790
- yield* fs.remove(configPath).pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })))
791
- return
792
- }
793
- const raw = { ...result.right.raw }
794
- delete raw.authMethod
795
- const encoded = yield* Schema.encode(RawConfigJson)(raw)
796
- yield* atomicWriteConfig(encoded)
797
- })
798
-
799
- const getApiUrl = Effect.fn('getApiUrl')(function* () {
800
- const env = yield* Environment
801
- const file = yield* EnvironmentFromConfigFile
802
-
803
- const apiUrl = {
804
- env: Option.fromNullable(env.apiUrl),
805
- file: Option.fromNullable(file.apiUrl),
806
- }
807
-
808
- return Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(Option.getOrElse(() => new URL(DEFAULT_API_URL)))
809
- })
810
-
811
- // Web app origin for guide links the CLI hands to an agent. Reads `EXPAND_APP_URL` — the
812
- // counterpart to `EXPAND_API_URL` — so a local or self-hosted stack points agents at the
813
- // app actually serving `/start.md` instead of production.
814
- const getAppUrl = Effect.fn('getAppUrl')(function* () {
815
- return yield* Config.string('EXPAND_APP_URL').pipe(Config.withDefault(DEFAULT_APP_URL))
816
- })
817
-
818
- // Public playground host for citation links. Reads `EXPAND_PLAYGROUND_HOST` — the same env
819
- // var the API / cdp-server bake into `meta.playground` — so local/staging set one host for
820
- // both server and CLI. The fetch path prefers the server-resolved `meta.playground` origin;
821
- // only the snapshot-search path (no `meta.playground`) falls back to this.
822
- const getPlaygroundHost = Effect.fn('getPlaygroundHost')(function* () {
823
- return yield* Config.string('EXPAND_PLAYGROUND_HOST').pipe(Config.withDefault(DEFAULT_PLAYGROUND_HOST))
824
- })
825
-
826
- return {
827
- configPath,
828
- inspectSecureStorage,
829
- ensureSecureStorage,
830
- loadConfig,
831
- saveConfig,
832
- updateConfig,
833
- clearConfig,
834
- readConfigFile,
835
- getApiUrl,
836
- getAppUrl,
837
- getPlaygroundHost,
838
- } as const
839
- }),
840
- }) {}