@notis_ai/cli 0.2.14 → 0.2.15

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 (296) hide show
  1. package/README.md +82 -20
  2. package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +84 -53
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +38 -17
  10. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  11. package/skills/notis-onboarding/BRIEF.md +16 -0
  12. package/src/agent-hook-entry.js +5 -0
  13. package/src/cli.js +23 -14
  14. package/src/command-specs/agents.js +392 -0
  15. package/src/command-specs/apps.js +871 -387
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/index.js +6 -0
  18. package/src/command-specs/onboarding.js +69 -5
  19. package/src/command-specs/skills.js +56 -0
  20. package/src/runtime/agent-memory-state.js +126 -0
  21. package/src/runtime/agent-setup.js +383 -0
  22. package/src/runtime/app-dev-consumers.js +154 -0
  23. package/src/runtime/app-dev-host-lock.js +80 -0
  24. package/src/runtime/app-dev-roots.js +284 -0
  25. package/src/runtime/app-dev-server.js +212 -22
  26. package/src/runtime/app-dev-sessions.js +105 -145
  27. package/src/runtime/app-platform.js +1291 -165
  28. package/src/runtime/app-registry-scaffolds.js +367 -0
  29. package/src/runtime/base-skills.d.ts +20 -0
  30. package/src/runtime/base-skills.js +167 -0
  31. package/src/runtime/login-listener.js +15 -0
  32. package/src/runtime/oauth.js +1506 -141
  33. package/src/runtime/output.js +7 -0
  34. package/src/runtime/ports.js +16 -0
  35. package/src/runtime/profiles.js +0 -5
  36. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  37. package/src/runtime/skill-sync/index.ts +644 -0
  38. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  39. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  40. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  41. package/src/runtime/skill-sync/types.ts +103 -0
  42. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  43. package/src/runtime/store-screenshot.js +6 -1
  44. package/src/runtime/sync-skills.d.ts +37 -0
  45. package/src/runtime/sync-skills.js +215 -0
  46. package/template/app/layout.tsx +5 -1
  47. package/template/notis.config.ts +1 -0
  48. package/template/packages/sdk/package.json +2 -1
  49. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  50. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  51. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  52. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  53. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  54. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  55. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  56. package/template/packages/sdk/src/config.ts +19 -0
  57. package/template/packages/sdk/src/documents.ts +7 -1
  58. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  59. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  60. package/template/packages/sdk/src/hooks/useHandover.ts +3 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  62. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  63. package/template/packages/sdk/src/index.ts +47 -2
  64. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  65. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  66. package/template/packages/sdk/src/interactions.ts +41 -0
  67. package/template/packages/sdk/src/provider.tsx +2 -1
  68. package/template/packages/sdk/src/runtime.ts +89 -4
  69. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  70. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  71. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  72. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  73. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  74. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  75. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  76. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  77. package/dist/scaffolds/notis-database/components.json +0 -20
  78. package/dist/scaffolds/notis-database/index.html +0 -12
  79. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  80. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  81. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  82. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  83. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  84. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  85. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  86. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  87. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  88. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  89. package/dist/scaffolds/notis-database/package.json +0 -32
  90. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  91. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  92. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  93. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  94. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  95. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  96. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -234
  97. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -250
  98. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  99. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  100. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  101. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  102. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  103. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  104. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +0 -75
  105. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  112. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -100
  113. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  114. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -351
  115. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  116. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  117. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  118. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  119. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  120. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  121. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  122. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  123. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  124. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  125. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  126. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  127. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  128. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  129. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  130. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  131. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  132. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  133. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  134. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  135. package/dist/scaffolds/notis-journal/components.json +0 -20
  136. package/dist/scaffolds/notis-journal/index.html +0 -12
  137. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  138. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  139. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  140. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  141. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  142. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  145. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  146. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  147. package/dist/scaffolds/notis-journal/package.json +0 -34
  148. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  149. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  150. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  151. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  152. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  153. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -234
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -250
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +0 -75
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -100
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -351
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  176. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  177. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  178. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  179. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  180. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -199
  181. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  182. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  183. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  184. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  185. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  186. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  187. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  188. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  189. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  190. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  191. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  192. package/dist/scaffolds/notis-notes/components.json +0 -20
  193. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  194. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  195. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  196. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  197. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  201. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  202. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  203. package/dist/scaffolds/notis-notes/package.json +0 -35
  204. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  205. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  206. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  207. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -234
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -250
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +0 -75
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -100
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -351
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  231. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  232. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  233. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  234. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  235. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  236. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  237. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  238. package/dist/scaffolds/notis-random/README.md +0 -33
  239. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  240. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  241. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  242. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  243. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  244. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  245. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  246. package/dist/scaffolds/notis-random/components.json +0 -20
  247. package/dist/scaffolds/notis-random/index.html +0 -12
  248. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  249. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  250. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  251. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  252. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  253. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  254. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  255. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  257. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  258. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  259. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  260. package/dist/scaffolds/notis-random/package.json +0 -36
  261. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  262. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  263. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  264. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  266. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  267. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -234
  268. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -250
  269. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  270. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  271. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +0 -75
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  279. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  280. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  281. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  282. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  283. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -100
  284. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  285. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -351
  286. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  287. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  288. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  289. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  290. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  291. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  292. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  293. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  294. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  295. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  296. package/dist/scaffolds.json +0 -49
@@ -5,7 +5,9 @@ import {
5
5
  } from 'node:crypto';
6
6
  import {
7
7
  mkdirSync,
8
+ readdirSync,
8
9
  readFileSync,
10
+ renameSync,
9
11
  rmdirSync,
10
12
  rmSync,
11
13
  statSync,
@@ -13,11 +15,12 @@ import {
13
15
  } from 'node:fs';
14
16
  import { createServer } from 'node:http';
15
17
  import { homedir } from 'node:os';
16
- import { dirname, join } from 'node:path';
17
- import { spawn } from 'node:child_process';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { execFileSync, spawn } from 'node:child_process';
18
21
  import { createInterface } from 'node:readline/promises';
19
22
 
20
- import { CliError, EXIT_CODES } from './errors.js';
23
+ import { CliError, EXIT_CODES, usageError } from './errors.js';
21
24
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
22
25
  import {
23
26
  channelFromProfile,
@@ -49,6 +52,7 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
49
52
  const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
50
53
  const OAUTH_HTTP_TIMEOUT_MS = 10_000;
51
54
  const RESPONSE_FLUSH_GRACE_MS = 2_000;
55
+ const MAX_NODE_TIMER_MS = 2_147_483_647;
52
56
 
53
57
  function oauthError(code, message, hints = null, details = {}) {
54
58
  return new CliError({
@@ -515,17 +519,17 @@ export async function createLoopbackReceiver({
515
519
  endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
516
520
  return;
517
521
  }
518
- consumed = true;
519
-
520
522
  const returnedState = url.searchParams.get('state') || '';
521
523
  const code = url.searchParams.get('code');
522
524
  const error = url.searchParams.get('error');
523
525
  if (!stateMatches(state, returnedState)) {
524
526
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
525
527
  endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
526
- rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
527
528
  return;
528
529
  }
530
+ // A stray or malicious request must not burn the one legitimate callback.
531
+ // Only a request carrying this authorization's state consumes the receiver.
532
+ consumed = true;
529
533
  if (error || !code) {
530
534
  const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
531
535
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -606,15 +610,20 @@ export async function createLoopbackReceiver({
606
610
  endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
607
611
  pendingResponse = null;
608
612
  },
609
- fail: () => {
613
+ fail: ({ detached = false } = {}) => {
610
614
  if (!pendingResponse || pendingResponse.writableEnded) return;
611
615
  pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
612
616
  endResponse(pendingResponse, callbackHtml(
613
617
  'Authorization failed',
614
- 'The CLI could not finish signing in. Return to the terminal for details.',
618
+ detached
619
+ ? 'The CLI could not finish signing in. Start sign in again from your agent or terminal.'
620
+ : 'The CLI could not finish signing in. Return to the terminal for details.',
615
621
  ));
616
622
  pendingResponse = null;
617
623
  },
624
+ cancel: () => {
625
+ rejectCode(oauthError('oauth_listener_cancelled', 'This browser authorization is no longer active.'));
626
+ },
618
627
  close,
619
628
  };
620
629
  }
@@ -775,7 +784,7 @@ function savePendingAuthorization(runtime, pending) {
775
784
  writeFileSync(file, JSON.stringify(pending, null, 2), { mode: 0o600 });
776
785
  }
777
786
 
778
- function readPendingAuthorization(runtime) {
787
+ function readPendingAuthorization(runtime, { includeExpired = false } = {}) {
779
788
  for (const file of [
780
789
  pendingAuthorizationFile(runtime),
781
790
  legacyPendingAuthorizationFile(runtime),
@@ -787,7 +796,7 @@ function readPendingAuthorization(runtime) {
787
796
  continue;
788
797
  }
789
798
  if (!pending?.verifier || !pending?.redirect_uri) continue;
790
- if (Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
799
+ if (!includeExpired && Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
791
800
  if (pending.profile !== runtime.profileName) continue;
792
801
  return { ...pending, pending_file: file };
793
802
  }
@@ -802,6 +811,869 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
802
811
  }
803
812
  }
804
813
 
814
+ function clearPendingAuthorizations(runtime) {
815
+ clearPendingAuthorization(runtime);
816
+ const legacyFile = legacyPendingAuthorizationFile(runtime);
817
+ try {
818
+ const pending = JSON.parse(readFileSync(legacyFile, 'utf-8'));
819
+ if (pending?.profile === runtime.profileName) rmSync(legacyFile);
820
+ } catch {
821
+ // Missing, malformed, or owned by another profile.
822
+ }
823
+ }
824
+
825
+ function pendingAuthorizationOwnedBy(pending, owner) {
826
+ return Boolean(
827
+ pending
828
+ && pending.hand_off === owner.hand_off
829
+ && pending.state === owner.state
830
+ && pending.verifier === owner.verifier
831
+ && pending.redirect_uri === owner.redirect_uri
832
+ && pending.api_base === owner.api_base
833
+ );
834
+ }
835
+
836
+ function clearPendingAuthorizationIfOwner(runtime, owner) {
837
+ // Cleanup must still remove an owner that expired at the same instant as a
838
+ // receiver timeout; ordinary reads continue to ignore expired grants.
839
+ const pending = readPendingAuthorization(runtime, { includeExpired: true });
840
+ if (!pendingAuthorizationOwnedBy(pending, owner)) return false;
841
+ clearPendingAuthorization(runtime, pending.pending_file);
842
+ return true;
843
+ }
844
+
845
+ async function publishForegroundAuthorization(runtime, authorization) {
846
+ const globalLock = await acquireListenerGlobalLock();
847
+ let lockDir = null;
848
+ try {
849
+ lockDir = await acquireListenerStartLock(runtime);
850
+ stopPendingListener(runtime);
851
+ clearPendingAuthorizations(runtime);
852
+ savePendingAuthorization(runtime, authorization);
853
+ } finally {
854
+ releaseListenerStartLock(lockDir);
855
+ releaseListenerGlobalLock(globalLock);
856
+ }
857
+ }
858
+
859
+ const LISTENER_SCRIPT = fileURLToPath(new URL('./login-listener.js', import.meta.url));
860
+ const LISTENER_SCRIPT_NAME = 'login-listener.js';
861
+ // How long the parent waits for the detached child to bind and report its port.
862
+ // Only a bind, so this is generous; exceeding it means the child is not coming.
863
+ const LISTENER_HANDSHAKE_TIMEOUT_MS = 10_000;
864
+ const LISTENER_START_LOCK_STALE_MS = LISTENER_HANDSHAKE_TIMEOUT_MS * 2;
865
+ const LISTENER_GLOBAL_LOCK_HEARTBEAT_MS = 5_000;
866
+ const LISTENER_CANCELLATION_POLL_MS = 100;
867
+ const LISTENER_IDENTITY_PROBE_TIMEOUT_MS = 2_000;
868
+
869
+ const LISTENER_CHILD_ENV_KEYS = [
870
+ 'HOME',
871
+ 'USERPROFILE',
872
+ 'APPDATA',
873
+ 'LOCALAPPDATA',
874
+ 'TMPDIR',
875
+ 'TEMP',
876
+ 'TMP',
877
+ 'SystemRoot',
878
+ 'WINDIR',
879
+ 'ComSpec',
880
+ 'PATH',
881
+ 'Path',
882
+ 'PATHEXT',
883
+ 'NODE_EXTRA_CA_CERTS',
884
+ 'SSL_CERT_FILE',
885
+ 'SSL_CERT_DIR',
886
+ ];
887
+
888
+ /**
889
+ * A detached listener outlives the command that launched it. Give it only the
890
+ * OS/runtime values needed to start Node and find its private config, never the
891
+ * caller's unrelated API keys, service credentials, or inherited NOTIS_JWT.
892
+ */
893
+ export function listenerChildEnvironment(env = process.env) {
894
+ const childEnv = {};
895
+ for (const key of LISTENER_CHILD_ENV_KEYS) {
896
+ if (typeof env[key] === 'string' && env[key]) childEnv[key] = env[key];
897
+ }
898
+ childEnv.NOTIS_CLI_CONFIG_FILE = resolveConfigFile();
899
+ return childEnv;
900
+ }
901
+
902
+ function listenerStateFile(runtime) {
903
+ const profileKey = createHash('sha256')
904
+ .update(String(runtime.profileName || 'default'))
905
+ .digest('hex')
906
+ .slice(0, 16);
907
+ return `${resolveConfigFile()}.login-listener.${profileKey}`;
908
+ }
909
+
910
+ function listenerStartLockDir(runtime) {
911
+ return `${listenerStateFile(runtime)}.lock`;
912
+ }
913
+
914
+ function listenerGlobalLockDir() {
915
+ return `${resolveConfigFile()}.oauth-listener-global.lock`;
916
+ }
917
+
918
+ function listenerGlobalLockOwnerFile(lockDir) {
919
+ return join(lockDir, 'owner.json');
920
+ }
921
+
922
+ function readListenerGlobalLockOwner(lockDir) {
923
+ try {
924
+ return JSON.parse(readFileSync(listenerGlobalLockOwnerFile(lockDir), 'utf-8'));
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ function writeListenerGlobalLockOwner(lock) {
931
+ const current = readListenerGlobalLockOwner(lock.lockDir);
932
+ if (current && current.owner_token !== lock.ownerToken) return false;
933
+ writeFileSync(listenerGlobalLockOwnerFile(lock.lockDir), JSON.stringify({
934
+ owner_token: lock.ownerToken,
935
+ owner_pid: process.pid,
936
+ updated_at: Date.now(),
937
+ }), { mode: 0o600 });
938
+ return true;
939
+ }
940
+
941
+ function listenerGlobalLockIsStale(lockDir, staleMs) {
942
+ try {
943
+ return Date.now() - statSync(listenerGlobalLockOwnerFile(lockDir)).mtimeMs > staleMs;
944
+ } catch {
945
+ try {
946
+ return Date.now() - statSync(lockDir).mtimeMs > staleMs;
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+ }
952
+
953
+ function retireStaleListenerGlobalLock(lockDir) {
954
+ const retiredDir = `${lockDir}.stale-${process.pid}-${base64url(randomBytes(8))}`;
955
+ try {
956
+ renameSync(lockDir, retiredDir);
957
+ } catch {
958
+ return false;
959
+ }
960
+ rmSync(retiredDir, { recursive: true, force: true });
961
+ return true;
962
+ }
963
+
964
+ async function acquireListenerStartLock(runtime) {
965
+ const lockDir = listenerStartLockDir(runtime);
966
+ mkdirSync(dirname(lockDir), { recursive: true });
967
+ const deadline = Date.now() + LISTENER_START_LOCK_STALE_MS * 2;
968
+ for (;;) {
969
+ try {
970
+ mkdirSync(lockDir);
971
+ return lockDir;
972
+ } catch (error) {
973
+ if (error?.code !== 'EEXIST') throw error;
974
+ try {
975
+ if (Date.now() - statSync(lockDir).mtimeMs > LISTENER_START_LOCK_STALE_MS) {
976
+ rmdirSync(lockDir);
977
+ continue;
978
+ }
979
+ } catch {
980
+ // The owner may have released the lock between stat and removal.
981
+ }
982
+ if (Date.now() >= deadline) {
983
+ throw oauthError(
984
+ 'oauth_listener_lock_timeout',
985
+ 'Timed out waiting for another CLI process to start browser authorization.',
986
+ );
987
+ }
988
+ await new Promise((resolve) => setTimeout(resolve, 50));
989
+ }
990
+ }
991
+ }
992
+
993
+ function releaseListenerStartLock(lockDir) {
994
+ if (!lockDir) return;
995
+ try {
996
+ rmdirSync(lockDir);
997
+ } catch {
998
+ // A crashed owner or stale-lock cleanup may already have removed it.
999
+ }
1000
+ }
1001
+
1002
+ export async function acquireListenerGlobalLock({
1003
+ staleMs = LISTENER_START_LOCK_STALE_MS,
1004
+ waitMs = LISTENER_START_LOCK_STALE_MS * 2,
1005
+ heartbeatMs = LISTENER_GLOBAL_LOCK_HEARTBEAT_MS,
1006
+ } = {}) {
1007
+ const lockDir = listenerGlobalLockDir();
1008
+ mkdirSync(dirname(lockDir), { recursive: true });
1009
+ const deadline = Date.now() + waitMs;
1010
+ for (;;) {
1011
+ try {
1012
+ mkdirSync(lockDir);
1013
+ const lock = {
1014
+ lockDir,
1015
+ ownerToken: base64url(randomBytes(24)),
1016
+ heartbeat: null,
1017
+ };
1018
+ writeListenerGlobalLockOwner(lock);
1019
+ lock.heartbeat = setInterval(() => {
1020
+ try {
1021
+ writeListenerGlobalLockOwner(lock);
1022
+ } catch {
1023
+ // A stale-lock recovery may have retired this directory. Ownership-
1024
+ // qualified release below must not disturb the successor.
1025
+ }
1026
+ }, heartbeatMs);
1027
+ lock.heartbeat.unref?.();
1028
+ return lock;
1029
+ } catch (error) {
1030
+ if (error?.code !== 'EEXIST') throw error;
1031
+ if (
1032
+ listenerGlobalLockIsStale(lockDir, staleMs)
1033
+ && retireStaleListenerGlobalLock(lockDir)
1034
+ ) {
1035
+ continue;
1036
+ }
1037
+ if (Date.now() >= deadline) {
1038
+ throw oauthError(
1039
+ 'oauth_listener_global_lock_timeout',
1040
+ 'Timed out waiting for another CLI process to finish OAuth account changes.',
1041
+ );
1042
+ }
1043
+ await new Promise((resolve) => setTimeout(resolve, 50));
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ export function releaseListenerGlobalLock(lock) {
1049
+ if (!lock) return;
1050
+ clearInterval(lock.heartbeat);
1051
+ const owner = readListenerGlobalLockOwner(lock.lockDir);
1052
+ if (owner?.owner_token !== lock.ownerToken) return;
1053
+ const releasedDir = `${lock.lockDir}.released-${process.pid}-${lock.ownerToken}`;
1054
+ try {
1055
+ renameSync(lock.lockDir, releasedDir);
1056
+ } catch {
1057
+ return;
1058
+ }
1059
+ const movedOwner = readListenerGlobalLockOwner(releasedDir);
1060
+ if (movedOwner?.owner_token !== lock.ownerToken) {
1061
+ try { renameSync(releasedDir, lock.lockDir); } catch { /* successor already owns the path */ }
1062
+ return;
1063
+ }
1064
+ rmSync(releasedDir, { recursive: true, force: true });
1065
+ }
1066
+
1067
+ function saveListenerState(runtime, state) {
1068
+ const file = listenerStateFile(runtime);
1069
+ mkdirSync(dirname(file), { recursive: true });
1070
+ writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
1071
+ }
1072
+
1073
+ function readListenerState(runtime) {
1074
+ try {
1075
+ const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
1076
+ return state?.profile === runtime.profileName ? state : null;
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1081
+
1082
+ function listenerStateAllowsPersistence(runtime, ownerPid) {
1083
+ const state = readListenerState(runtime);
1084
+ return Boolean(
1085
+ state
1086
+ && Number(state.pid) === Number(ownerPid)
1087
+ && state.cancelled !== true
1088
+ && Number(state.expires_at) > Math.floor(Date.now() / 1000),
1089
+ );
1090
+ }
1091
+
1092
+ /**
1093
+ * Drop the listener record, optionally only when it still describes `ownerPid`.
1094
+ *
1095
+ * A child that times out must not delete a record a newer child already wrote:
1096
+ * losing it orphans the live listener, because every later stop and reuse finds
1097
+ * the profile through this file.
1098
+ */
1099
+ export function clearListenerState(runtime, { ownerPid = null } = {}) {
1100
+ if (ownerPid !== null) {
1101
+ try {
1102
+ const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
1103
+ if (Number(state?.pid) !== Number(ownerPid)) return;
1104
+ } catch {
1105
+ return;
1106
+ }
1107
+ }
1108
+ try {
1109
+ rmSync(listenerStateFile(runtime));
1110
+ } catch {
1111
+ // Nothing to clear.
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * A listener from an earlier run that is still waiting for the same browser.
1117
+ *
1118
+ * Re-running `notis start` is routine — an agent does it to check whether the
1119
+ * user has finished — and each run would otherwise strand another detached
1120
+ * process holding another port. Handing back the authorization URL the user was
1121
+ * already given is also the only answer that stays true: the earlier URL is the
1122
+ * one whose PKCE verifier the live listener holds.
1123
+ */
1124
+ function readLiveListener(runtime, { sameApiBase = true } = {}) {
1125
+ const state = readListenerState(runtime);
1126
+ if (!state?.pid || !state?.authorize_url) return null;
1127
+ if (state.cancelled === true) return null;
1128
+ // Reuse needs the endpoint to match, because a grant belongs to the API that
1129
+ // issued it. Stopping one does not: a listener for any endpoint is still
1130
+ // about to write this profile.
1131
+ if (sameApiBase && state.api_base !== runtime.apiBase) return null;
1132
+ if (Number(state.expires_at) <= Math.floor(Date.now() / 1000)) return null;
1133
+ if (!listenerProcessIsAlive(state.pid, {
1134
+ expectedIdentity: state.identity_token,
1135
+ expectedScriptPath: state.listener_script,
1136
+ })) return null;
1137
+ return state;
1138
+ }
1139
+
1140
+ /**
1141
+ * Is this pid still *our* listener?
1142
+ *
1143
+ * A bare `kill(pid, 0)` only proves some process holds the number. State files
1144
+ * outlive reboots and SIGKILLs, and pids are recycled, so that test eventually
1145
+ * reports a stranger as the listener — which would hand out an authorize URL
1146
+ * whose loopback port is dead, and let `logout` signal an unrelated process.
1147
+ * Matching the command line costs one `ps` on a rare path and rules both out.
1148
+ */
1149
+ export function listenerProcessIsAlive(
1150
+ pid,
1151
+ {
1152
+ platform = process.platform,
1153
+ run = execFileSync,
1154
+ signal = process.kill.bind(process),
1155
+ expectedIdentity = null,
1156
+ expectedScriptPath = null,
1157
+ } = {},
1158
+ ) {
1159
+ const numericPid = Number(pid);
1160
+ if (!Number.isSafeInteger(numericPid) || numericPid <= 0) return false;
1161
+ try {
1162
+ // Signal 0 tests for a live process without touching it.
1163
+ signal(numericPid, 0);
1164
+ } catch {
1165
+ return false;
1166
+ }
1167
+ // tasklist's verbose view does not contain a process command line, so it can
1168
+ // never identify the script behind node.exe. CIM exposes the actual command
1169
+ // line and is available through Windows PowerShell and modern PowerShell.
1170
+ const windowsCommand = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}").CommandLine`;
1171
+ const probes = platform === 'win32'
1172
+ ? [
1173
+ ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
1174
+ ['pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
1175
+ ]
1176
+ : [['ps', ['-o', 'command=', '-p', String(numericPid)]]];
1177
+ for (const [command, args] of probes) {
1178
+ try {
1179
+ const output = run(command, args, {
1180
+ encoding: 'utf-8',
1181
+ stdio: ['ignore', 'pipe', 'ignore'],
1182
+ timeout: LISTENER_IDENTITY_PROBE_TIMEOUT_MS,
1183
+ });
1184
+ return Boolean(
1185
+ expectedIdentity
1186
+ && expectedScriptPath
1187
+ && output.includes(expectedScriptPath)
1188
+ && output.includes(LISTENER_SCRIPT_NAME)
1189
+ && output.includes(expectedIdentity),
1190
+ );
1191
+ } catch {
1192
+ // Try the next probe.
1193
+ }
1194
+ }
1195
+ // Nothing here can tell this pid apart from a stranger that inherited the
1196
+ // number. Fail closed: the cost is a listener we stop reusing, against
1197
+ // signalling an unrelated process, which is the failure that cannot be undone.
1198
+ return false;
1199
+ }
1200
+
1201
+ /**
1202
+ * End an authorization that is still in flight for this profile.
1203
+ *
1204
+ * Best effort by design: the listener may already have exited, and failing to
1205
+ * reach it is never a reason to fail the command that asked for this.
1206
+ */
1207
+ export function stopPendingListener(
1208
+ runtime,
1209
+ {
1210
+ platform = process.platform,
1211
+ signal = process.kill.bind(process),
1212
+ } = {},
1213
+ ) {
1214
+ const state = readListenerState(runtime);
1215
+ const live = readLiveListener(runtime, { sameApiBase: false });
1216
+ if (live) {
1217
+ // Persist cancellation before notifying the child. POSIX can deliver a
1218
+ // catchable SIGTERM, but Windows terminates Node immediately for that
1219
+ // signal. On Windows the child observes this tombstone through its polling
1220
+ // channel and stays alive long enough to revoke an in-flight exchange.
1221
+ saveListenerState(runtime, { ...live, cancelled: true });
1222
+ if (platform === 'win32') return;
1223
+ try {
1224
+ signal(live.pid, 'SIGTERM');
1225
+ clearListenerState(runtime, { ownerPid: live.pid });
1226
+ } catch {
1227
+ // Already gone, or owned by another user. Keep the tombstone so a child
1228
+ // that is still alive cannot persist this authorization.
1229
+ }
1230
+ return;
1231
+ }
1232
+ if (
1233
+ state?.pid
1234
+ && Number(state.expires_at) > Math.floor(Date.now() / 1000)
1235
+ ) {
1236
+ // Signal 0 may prove the PID exists while ps/CIM cannot prove it is ours.
1237
+ // Never signal that process, but keep a cancellation tombstone the child
1238
+ // must observe under the start lock before it can persist credentials.
1239
+ saveListenerState(runtime, { ...state, cancelled: true });
1240
+ return;
1241
+ }
1242
+ clearListenerState(runtime);
1243
+ }
1244
+
1245
+ async function stopPendingListenerAfterStart(runtime) {
1246
+ const lockDir = await acquireListenerStartLock(runtime);
1247
+ try {
1248
+ stopPendingListener(runtime);
1249
+ } finally {
1250
+ releaseListenerStartLock(lockDir);
1251
+ }
1252
+ }
1253
+
1254
+ async function clearListenerStateAfterStart(runtime, { ownerPid }) {
1255
+ const lockDir = await acquireListenerStartLock(runtime);
1256
+ try {
1257
+ clearListenerState(runtime, { ownerPid });
1258
+ } finally {
1259
+ releaseListenerStartLock(lockDir);
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Hand the loopback callback to a process that outlives this command.
1265
+ *
1266
+ * An agent reads a command's output only once the command exits, so a login
1267
+ * that waited in-process would hold the authorization URL hostage inside a
1268
+ * command that cannot finish until the user opens the URL they were never
1269
+ * shown. Detaching breaks that deadlock: this process prints the URL and exits
1270
+ * while the child keeps the listener, so an agent-driven signup gets the same
1271
+ * no-copy browser hand-off a human at a terminal gets.
1272
+ *
1273
+ * Returns null rather than throwing when the child cannot be started or cannot
1274
+ * bind — a sandbox that forbids either is a reason to fall back to the code
1275
+ * flow, not to fail the login.
1276
+ */
1277
+ async function startDetachedLoopbackListener(runtime, {
1278
+ metadata,
1279
+ verifier,
1280
+ state,
1281
+ scopes,
1282
+ timeoutMs,
1283
+ portalOrigin,
1284
+ }) {
1285
+ // A retry can start in another process before this child consumes its input.
1286
+ // Keep each verifier/state payload private to exactly one child.
1287
+ const identityToken = randomBytes(24).toString('base64url');
1288
+ const payloadFile = `${listenerStateFile(runtime)}.payload.${process.pid}.${randomBytes(8).toString('hex')}`;
1289
+ try {
1290
+ mkdirSync(dirname(payloadFile), { recursive: true });
1291
+ writeFileSync(payloadFile, JSON.stringify({
1292
+ profile: runtime.profileName,
1293
+ api_base: runtime.apiBase,
1294
+ verifier,
1295
+ state,
1296
+ scopes,
1297
+ timeout_ms: timeoutMs,
1298
+ portal_origin: portalOrigin,
1299
+ metadata,
1300
+ }), { mode: 0o600, flag: 'wx' });
1301
+ } catch {
1302
+ return null;
1303
+ }
1304
+
1305
+ let child;
1306
+ try {
1307
+ child = spawn(process.execPath, [LISTENER_SCRIPT, payloadFile, identityToken], {
1308
+ detached: true,
1309
+ windowsHide: true,
1310
+ // An IPC channel only so the child can report the port it bound. Every
1311
+ // other stream is dropped: the child must not write to a terminal the
1312
+ // parent no longer owns.
1313
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
1314
+ env: listenerChildEnvironment(),
1315
+ });
1316
+ } catch {
1317
+ try { rmSync(payloadFile); } catch { /* best effort */ }
1318
+ return null;
1319
+ }
1320
+
1321
+ const port = await new Promise((resolve) => {
1322
+ let settled = false;
1323
+ const finish = (value) => {
1324
+ if (settled) return;
1325
+ settled = true;
1326
+ clearTimeout(timer);
1327
+ child.off('message', onMessage);
1328
+ child.off('error', onFailure);
1329
+ child.off('exit', onFailure);
1330
+ resolve(value);
1331
+ };
1332
+ const onMessage = (message) => finish(Number(message?.port) || null);
1333
+ const onFailure = () => finish(null);
1334
+ const timer = setTimeout(() => finish(null), LISTENER_HANDSHAKE_TIMEOUT_MS);
1335
+ child.once('message', onMessage);
1336
+ child.once('error', onFailure);
1337
+ child.once('exit', onFailure);
1338
+ });
1339
+
1340
+ if (!port) {
1341
+ try { child.kill(); } catch { /* already gone */ }
1342
+ try { rmSync(payloadFile); } catch { /* the child may have consumed it */ }
1343
+ return null;
1344
+ }
1345
+
1346
+ // The child owns its lifetime from here. Disconnecting drops the only handle
1347
+ // keeping this process's event loop alive on its behalf.
1348
+ try { child.disconnect(); } catch { /* already disconnected */ }
1349
+ child.unref();
1350
+ return {
1351
+ pid: child.pid,
1352
+ port,
1353
+ redirectUri: `http://127.0.0.1:${port}/callback`,
1354
+ identityToken,
1355
+ scriptPath: LISTENER_SCRIPT,
1356
+ };
1357
+ }
1358
+
1359
+ async function revokeCancelledToken(metadata, tokenResponse, fetchImpl) {
1360
+ const token = tokenResponse?.refresh_token || tokenResponse?.access_token;
1361
+ if (!token || !metadata?.revocationEndpoint || !metadata?.clientId) return;
1362
+ try {
1363
+ await fetchJson(
1364
+ metadata.revocationEndpoint,
1365
+ {
1366
+ method: 'POST',
1367
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1368
+ body: new URLSearchParams({ token, client_id: metadata.clientId }),
1369
+ },
1370
+ fetchImpl,
1371
+ );
1372
+ } catch {
1373
+ // The child still refuses local persistence. Revocation is compensating
1374
+ // cleanup and must not turn a cancelled browser page into a credential.
1375
+ }
1376
+ }
1377
+
1378
+ /**
1379
+ * The detached child's whole life: bind, report the port, wait, persist.
1380
+ *
1381
+ * Runs in its own process with no terminal, so nothing here may write to stdout
1382
+ * or throw past the top level — a crash would leave the user staring at a
1383
+ * browser page that never resolves.
1384
+ */
1385
+ export async function runDetachedLoginListener(
1386
+ payloadFile,
1387
+ fetchImpl = fetch,
1388
+ identityToken = process.argv[3] || null,
1389
+ ) {
1390
+ let payload;
1391
+ try {
1392
+ payload = JSON.parse(readFileSync(payloadFile, 'utf-8'));
1393
+ } catch {
1394
+ return 1;
1395
+ }
1396
+ // The verifier is a bearer secret for this authorization. It has been read;
1397
+ // it should not outlive the read.
1398
+ try { rmSync(payloadFile); } catch { /* best effort */ }
1399
+
1400
+ const runtime = { profileName: payload.profile, apiBase: payload.api_base };
1401
+ let receiver;
1402
+ try {
1403
+ receiver = await createLoopbackReceiver({
1404
+ state: payload.state,
1405
+ timeoutMs: Number(payload.timeout_ms) || DEFAULT_LOGIN_TIMEOUT_MS,
1406
+ portalOrigin: payload.portal_origin,
1407
+ });
1408
+ } catch {
1409
+ return 1;
1410
+ }
1411
+
1412
+ process.send?.({ port: receiver.port });
1413
+
1414
+ // Logout signals the child instead of killing it blindly. Before exchange,
1415
+ // cancellation closes the wait immediately. During exchange, the child stays
1416
+ // alive long enough to revoke any grant the server may already have issued.
1417
+ let terminationRequested = false;
1418
+ const requestTermination = () => {
1419
+ if (terminationRequested) return;
1420
+ terminationRequested = true;
1421
+ receiver.cancel();
1422
+ };
1423
+ process.once('SIGTERM', requestTermination);
1424
+ // Windows cannot deliver a catchable SIGTERM to another Node process. The
1425
+ // state file is therefore also a cross-platform cancellation channel. A
1426
+ // replacement listener changes the owner pid; logout marks this owner as
1427
+ // cancelled. Either transition must stop this child before it can persist.
1428
+ let ownPublicationObserved = false;
1429
+ const cancellationPoll = setInterval(() => {
1430
+ const state = readListenerState(runtime);
1431
+ if (!state) return;
1432
+ const stateBelongsToThisChild = Boolean(
1433
+ Number(state.pid) === Number(process.pid)
1434
+ && (!identityToken || state.identity_token === identityToken),
1435
+ );
1436
+ // The parent publishes this child's ownership only after the child has
1437
+ // bound and reported its port. Until that publication is visible, an old
1438
+ // Windows/unverifiable cancellation tombstone still belongs to the
1439
+ // predecessor and must not make the replacement cancel itself.
1440
+ if (!ownPublicationObserved) {
1441
+ if (!stateBelongsToThisChild) return;
1442
+ ownPublicationObserved = true;
1443
+ }
1444
+ if (
1445
+ !stateBelongsToThisChild
1446
+ || state.cancelled === true
1447
+ || Number(state.expires_at) <= Math.floor(Date.now() / 1000)
1448
+ ) {
1449
+ requestTermination();
1450
+ }
1451
+ }, LISTENER_CANCELLATION_POLL_MS);
1452
+ cancellationPoll.unref?.();
1453
+
1454
+ let tokenResponse = null;
1455
+ let tokenPersisted = false;
1456
+ try {
1457
+ const code = await receiver.waitForCode();
1458
+ // Keep replacement publication and logout serialized across the whole
1459
+ // exchange. Revoking an old refresh token revokes the CLI grant, not just
1460
+ // one token family, so compensation must complete before a successor can
1461
+ // exchange and persist under that same grant.
1462
+ const globalLock = await acquireListenerGlobalLock();
1463
+ let lockDir = null;
1464
+ try {
1465
+ lockDir = await acquireListenerStartLock(runtime);
1466
+ if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
1467
+ throw oauthError(
1468
+ 'oauth_listener_cancelled',
1469
+ 'This browser authorization is no longer active.',
1470
+ );
1471
+ }
1472
+ tokenResponse = await exchangeCode(
1473
+ payload.metadata,
1474
+ { code, redirectUri: receiver.redirectUri, verifier: payload.verifier },
1475
+ fetchImpl,
1476
+ );
1477
+ if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
1478
+ throw oauthError(
1479
+ 'oauth_listener_cancelled',
1480
+ 'This browser authorization is no longer active.',
1481
+ );
1482
+ }
1483
+ persistOAuthTokenResponse(runtime, payload.metadata, tokenResponse);
1484
+ tokenPersisted = true;
1485
+ clearListenerState(runtime, { ownerPid: process.pid });
1486
+ receiver.complete();
1487
+ } catch (error) {
1488
+ if (tokenResponse && !tokenPersisted) {
1489
+ await revokeCancelledToken(payload.metadata, tokenResponse, fetchImpl);
1490
+ }
1491
+ throw error;
1492
+ } finally {
1493
+ releaseListenerStartLock(lockDir);
1494
+ releaseListenerGlobalLock(globalLock);
1495
+ }
1496
+ return 0;
1497
+ } catch {
1498
+ receiver.fail({ detached: true });
1499
+ return 1;
1500
+ } finally {
1501
+ clearInterval(cancellationPoll);
1502
+ process.off('SIGTERM', requestTermination);
1503
+ await receiver.close();
1504
+ try {
1505
+ await clearListenerStateAfterStart(runtime, { ownerPid: process.pid });
1506
+ } catch {
1507
+ // The child is exiting and can no longer authorize anything. A stale
1508
+ // owner-qualified record is safe for the next login to replace.
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * Would a `127.0.0.1` callback on this host reach the browser the user is in?
1515
+ *
1516
+ * Over SSH or inside a container it usually would not: the listener binds fine,
1517
+ * so nothing fails, and the user's own machine answers the callback URL with a
1518
+ * connection refused. The Portal code hand-off works from any browser, so an
1519
+ * uncertain answer has to resolve to `false` — the cost of choosing it wrongly
1520
+ * is one copied code, against a login that cannot be completed at all.
1521
+ *
1522
+ * `--mode browser` still forces the loopback for anyone who has forwarded the
1523
+ * port and knows better.
1524
+ */
1525
+ export function loopbackReachesTheUsersBrowser(
1526
+ env = process.env,
1527
+ pathExists = (path) => {
1528
+ try {
1529
+ statSync(path);
1530
+ return true;
1531
+ } catch {
1532
+ return false;
1533
+ }
1534
+ },
1535
+ platform = process.platform,
1536
+ ) {
1537
+ // Connection markers carry identity/address data, so their presence is the
1538
+ // signal. CI/provider flags are booleans encoded as strings; conventional
1539
+ // disabled values must not turn a local machine into a remote host merely
1540
+ // because non-empty strings are truthy in JavaScript.
1541
+ const remoteConnectionMarkers = [
1542
+ 'SSH_CONNECTION',
1543
+ 'SSH_CLIENT',
1544
+ 'SSH_TTY',
1545
+ 'JENKINS_URL',
1546
+ 'CODEBUILD_BUILD_ID',
1547
+ ];
1548
+ const remoteBooleanMarkers = [
1549
+ 'CODESPACES',
1550
+ 'REMOTE_CONTAINERS',
1551
+ 'DEVCONTAINER',
1552
+ 'CI',
1553
+ 'GITHUB_ACTIONS',
1554
+ 'GITLAB_CI',
1555
+ 'BUILDKITE',
1556
+ 'CIRCLECI',
1557
+ 'TF_BUILD',
1558
+ 'RENDER',
1559
+ 'VERCEL',
1560
+ ];
1561
+ const disabledFlagValues = new Set(['', '0', 'false', 'no', 'off']);
1562
+ const remoteFlagEnabled = (name) => {
1563
+ if (env[name] === undefined || env[name] === null) return false;
1564
+ return !disabledFlagValues.has(String(env[name]).trim().toLowerCase());
1565
+ };
1566
+ if (
1567
+ remoteConnectionMarkers.some((name) => Boolean(env[name]))
1568
+ || remoteBooleanMarkers.some(remoteFlagEnabled)
1569
+ ) return false;
1570
+ // Notis cloud agents run under this canonical root. Their browser belongs to
1571
+ // the user, not the Vercel VM, so a listener on the VM's loopback is unreachable.
1572
+ if (pathExists('/vercel/sandbox') || pathExists('/.dockerenv')) return false;
1573
+ // macOS and Windows browser launches are local unless one of the remote
1574
+ // markers above says otherwise. On Linux, require a graphical session:
1575
+ // marker-free cloud workers are commonly plain VMs where 127.0.0.1 belongs
1576
+ // to the worker rather than to the user's browser.
1577
+ if (platform === 'darwin' || platform === 'win32') return true;
1578
+ if (platform === 'linux') {
1579
+ return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.MIR_SOCKET);
1580
+ }
1581
+ return false;
1582
+ }
1583
+
1584
+ const LOGIN_MODES = new Set(['auto', 'browser', 'code']);
1585
+
1586
+ function requestedLoginMode(options) {
1587
+ const requested = options.mode ? String(options.mode).toLowerCase() : null;
1588
+ if (requested && !LOGIN_MODES.has(requested)) {
1589
+ throw usageError(
1590
+ `Unknown login mode "${requested}". Use auto, browser, or code.`,
1591
+ { code: 'oauth_login_mode_invalid', mode: requested },
1592
+ );
1593
+ }
1594
+ return requested;
1595
+ }
1596
+
1597
+ function requestedAuthorizationTimeoutMs(options) {
1598
+ const hasSeconds = options.timeoutSeconds !== undefined && options.timeoutSeconds !== null;
1599
+ const hasMilliseconds = options.timeoutMs !== undefined && options.timeoutMs !== null;
1600
+ if (!hasSeconds && !hasMilliseconds) return null;
1601
+ const raw = hasSeconds ? options.timeoutSeconds : options.timeoutMs;
1602
+ const numeric = Number(raw);
1603
+ const milliseconds = hasSeconds ? numeric * 1000 : numeric;
1604
+ if (
1605
+ !Number.isFinite(numeric)
1606
+ || numeric <= 0
1607
+ || !Number.isInteger(numeric)
1608
+ || !Number.isSafeInteger(milliseconds)
1609
+ || milliseconds > MAX_NODE_TIMER_MS
1610
+ ) {
1611
+ throw usageError(
1612
+ '--timeout-seconds must be a positive whole number within the supported timer range.',
1613
+ { timeout_seconds: raw },
1614
+ );
1615
+ }
1616
+ return milliseconds;
1617
+ }
1618
+
1619
+ /**
1620
+ * Which hand-off the browser uses to return the authorization code.
1621
+ *
1622
+ * `auto`, the default, always tries the loopback hand-off first so nobody has
1623
+ * to copy a code. Whether this process can wait for the browser decides only
1624
+ * *who* holds the listener: a terminal login keeps it in-process, and a login
1625
+ * that has to return immediately hands it to a detached child. The Portal code
1626
+ * flow is the fallback when no listener can start or the user's browser cannot
1627
+ * safely reach this machine's loopback callback.
1628
+ *
1629
+ * `browser` additionally insists on waiting in-process, which is what a piped
1630
+ * but human-driven run (CI, `| tee`) wants. `code` forces the Portal flow for
1631
+ * an SSH session where no browser on this machine can reach 127.0.0.1.
1632
+ */
1633
+ function resolveLoginMode(runtime, options) {
1634
+ const requested = requestedLoginMode(options);
1635
+ // --paste-code predates --mode and stays an alias so published commands and
1636
+ // documented recipes keep working unchanged.
1637
+ const mode = requested || (options.pasteCode ? 'code' : 'auto');
1638
+ const wantsNonBlocking = Boolean(
1639
+ runtime.agentMode
1640
+ || ['json', 'yaml', 'ndjson'].includes(runtime.outputMode)
1641
+ || runtime.nonInteractive,
1642
+ );
1643
+
1644
+ if (mode === 'browser' && runtime.agentMode) {
1645
+ throw oauthError(
1646
+ 'oauth_login_mode_unavailable',
1647
+ 'Agent mode cannot block on a browser callback: the authorization URL only reaches the user once this command exits.',
1648
+ [
1649
+ {
1650
+ command: 'notis login',
1651
+ reason: 'The default already hands the browser callback to a background listener, so no code is copied',
1652
+ },
1653
+ { command: 'notis login --mode code', reason: 'Show a code the agent can hand to the user instead' },
1654
+ ],
1655
+ );
1656
+ }
1657
+
1658
+ const loopbackReachable = loopbackReachesTheUsersBrowser(
1659
+ runtime.hostEnvironment ?? process.env,
1660
+ undefined,
1661
+ runtime.hostPlatform ?? process.platform,
1662
+ );
1663
+ return {
1664
+ automaticMode: mode === 'auto',
1665
+ wantsNonBlocking,
1666
+ // An explicit --mode browser is a promise to wait, so it overrides the
1667
+ // non-blocking default that a piped stdout would otherwise imply.
1668
+ nonBlocking: wantsNonBlocking && mode !== 'browser',
1669
+ // Only an explicit request starts on the code flow. Every other mode earns
1670
+ // its way there by failing to bind a loopback port -- except on a host whose
1671
+ // loopback the user's browser cannot reach, where binding succeeds and the
1672
+ // callback is unreachable anyway.
1673
+ usePasteCode: mode === 'code' || (mode === 'auto' && !loopbackReachable),
1674
+ };
1675
+ }
1676
+
805
1677
  function redeemCommand(profileName, channel) {
806
1678
  return [
807
1679
  cliCommandForChannel(channel),
@@ -810,6 +1682,22 @@ function redeemCommand(profileName, channel) {
810
1682
  ].join(' ');
811
1683
  }
812
1684
 
1685
+ /**
1686
+ * What to run once the browser has finished, when there is no code to redeem.
1687
+ *
1688
+ * The detached listener writes the credential itself, so the only thing left is
1689
+ * to observe that it landed. `start` is idempotent and reports the account, so
1690
+ * it doubles as the confirmation step.
1691
+ */
1692
+ function confirmCommand(profileName, channel, apiBase) {
1693
+ return [
1694
+ cliCommandForChannel(channel),
1695
+ `--profile ${quoteShellArgument(profileName || 'default')}`,
1696
+ `--api-base ${quoteShellArgument(apiBase)}`,
1697
+ 'start',
1698
+ ].join(' ');
1699
+ }
1700
+
813
1701
  function authorizationChannel(metadata, runtime, pending = null) {
814
1702
  return metadata.channel
815
1703
  || pending?.channel
@@ -882,43 +1770,174 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
882
1770
  if (!code) {
883
1771
  throw oauthError('oauth_code_missing', 'No authorization code was provided.');
884
1772
  }
885
- const pending = readPendingAuthorization(runtime);
886
- if (!pending) {
887
- throw oauthError(
888
- 'oauth_pending_login_missing',
889
- 'No pending authorization for this profile. Run notis login again to start one.',
1773
+ const globalLock = await acquireListenerGlobalLock();
1774
+ let lockDir = null;
1775
+ let metadata = null;
1776
+ let tokenResponse = null;
1777
+ let tokenPersisted = false;
1778
+ try {
1779
+ lockDir = await acquireListenerStartLock(runtime);
1780
+ const pending = readPendingAuthorization(runtime);
1781
+ if (!pending) {
1782
+ throw oauthError(
1783
+ 'oauth_pending_login_missing',
1784
+ 'No pending authorization for this profile. Run notis login again to start one.',
1785
+ );
1786
+ }
1787
+ // The grant belongs to the environment that issued it. Redeeming without
1788
+ // repeating --api-base must not persist a token under a different backend.
1789
+ if (pending.api_base) {
1790
+ runtime.apiBase = pending.api_base;
1791
+ }
1792
+ metadata = {
1793
+ apiBase: pending.api_base,
1794
+ issuer: pending.issuer,
1795
+ resource: pending.resource,
1796
+ clientId: pending.client_id,
1797
+ tokenEndpoint: pending.token_endpoint,
1798
+ revocationEndpoint: pending.revocation_endpoint
1799
+ || `${String(pending.issuer || '').replace(/\/+$/, '')}/oauth/revoke`,
1800
+ channel: pending.channel,
1801
+ };
1802
+ if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
1803
+ throw oauthError(
1804
+ 'oauth_pending_login_invalid',
1805
+ 'The pending authorization is incomplete. Run notis login again.',
1806
+ );
1807
+ }
1808
+ // Hold publication ownership through exchange and persistence. Otherwise a
1809
+ // new browser start can retire this verifier while its request is in flight
1810
+ // and the stale result can overwrite the newer authorization afterward.
1811
+ tokenResponse = await exchangeCode(
1812
+ metadata,
1813
+ { code, redirectUri: pending.redirect_uri, verifier: pending.verifier },
1814
+ fetchImpl,
890
1815
  );
1816
+ stopPendingListener(runtime);
1817
+ const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
1818
+ tokenPersisted = true;
1819
+ clearPendingAuthorization(runtime, pending.pending_file);
1820
+ updateRuntimeFromOAuthProfile(runtime, profile);
1821
+ return { profile, metadata };
1822
+ } catch (error) {
1823
+ if (tokenResponse && !tokenPersisted) {
1824
+ await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
1825
+ }
1826
+ throw error;
1827
+ } finally {
1828
+ releaseListenerStartLock(lockDir);
1829
+ releaseListenerGlobalLock(globalLock);
891
1830
  }
892
- // The grant belongs to the environment that issued it. Redeeming without
893
- // repeating --api-base must not persist a token under a different backend.
894
- if (pending.api_base) {
895
- runtime.apiBase = pending.api_base;
896
- }
897
- const metadata = {
898
- issuer: pending.issuer,
899
- resource: pending.resource,
900
- clientId: pending.client_id,
901
- tokenEndpoint: pending.token_endpoint,
902
- channel: pending.channel,
903
- };
904
- if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
905
- throw oauthError(
906
- 'oauth_pending_login_invalid',
907
- 'The pending authorization is incomplete. Run notis login again.',
1831
+ }
1832
+
1833
+ async function exchangeAndPersistForegroundAuthorization(
1834
+ runtime,
1835
+ metadata,
1836
+ authorization,
1837
+ fetchImpl,
1838
+ ) {
1839
+ const globalLock = await acquireListenerGlobalLock();
1840
+ let lockDir = null;
1841
+ let tokenResponse = null;
1842
+ let tokenPersisted = false;
1843
+ try {
1844
+ lockDir = await acquireListenerStartLock(runtime);
1845
+ const pending = readPendingAuthorization(runtime);
1846
+ if (!pendingAuthorizationOwnedBy(pending, authorization)) {
1847
+ throw oauthError(
1848
+ 'oauth_listener_cancelled',
1849
+ 'This browser authorization is no longer active.',
1850
+ );
1851
+ }
1852
+ // Linearize exchange and persistence with both detached publication and
1853
+ // logout. If logout won first, it removed the owner-qualified pending
1854
+ // record above and no grant is minted. If it starts later, it waits and
1855
+ // clears the credential after this operation completes.
1856
+ stopPendingListener(runtime);
1857
+ tokenResponse = await exchangeCode(
1858
+ metadata,
1859
+ {
1860
+ code: authorization.code,
1861
+ redirectUri: authorization.redirect_uri,
1862
+ verifier: authorization.verifier,
1863
+ },
1864
+ fetchImpl,
908
1865
  );
1866
+ const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
1867
+ tokenPersisted = true;
1868
+ clearPendingAuthorizationIfOwner(runtime, authorization);
1869
+ return profile;
1870
+ } catch (error) {
1871
+ if (tokenResponse && !tokenPersisted) {
1872
+ await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
1873
+ }
1874
+ throw error;
1875
+ } finally {
1876
+ releaseListenerStartLock(lockDir);
1877
+ releaseListenerGlobalLock(globalLock);
909
1878
  }
910
- const tokenResponse = await exchangeCode(
911
- metadata,
912
- { code, redirectUri: pending.redirect_uri, verifier: pending.verifier },
913
- fetchImpl,
1879
+ }
1880
+
1881
+ /**
1882
+ * The parked authorization this profile is already waiting on, if this run is
1883
+ * asking for the same thing.
1884
+ *
1885
+ * Minting a fresh verifier instead would silently invalidate the URL the user
1886
+ * was already handed: the code it returns can only be redeemed against the
1887
+ * verifier that was parked with it. Re-running a login is routine -- an agent
1888
+ * does it to check progress -- so the answer has to stay the same URL.
1889
+ */
1890
+ function reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs = null) {
1891
+ const pending = readPendingAuthorization(runtime);
1892
+ const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
1893
+ ? pending.scopes
1894
+ : DEFAULT_CLI_OAUTH_SCOPES;
1895
+ const sameAuthorization = Boolean(
1896
+ pending
1897
+ && pending.state
1898
+ && pending.api_base === runtime.apiBase
1899
+ && pending.issuer === metadata.issuer
1900
+ && pending.resource === metadata.resource
1901
+ && pending.client_id === metadata.clientId
1902
+ && pending.token_endpoint === metadata.tokenEndpoint
1903
+ && pending.redirect_uri === metadata.copyPasteRedirectUri
1904
+ && (
1905
+ requestedTimeoutMs === null
1906
+ || Number(pending.authorization_timeout_ms) === requestedTimeoutMs
1907
+ )
1908
+ && JSON.stringify(pendingScopes) === JSON.stringify(scopes),
914
1909
  );
915
- clearPendingAuthorization(runtime, pending.pending_file);
916
- const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
917
- updateRuntimeFromOAuthProfile(runtime, profile);
918
- return { profile, metadata };
1910
+ if (!sameAuthorization) return null;
1911
+ const challenge = createHash('sha256')
1912
+ .update(pending.verifier, 'ascii')
1913
+ .digest('base64url');
1914
+ return {
1915
+ agentAuthorization: {
1916
+ authorize_url: buildAuthorizeUrl(metadata, {
1917
+ redirectUri: pending.redirect_uri,
1918
+ challenge,
1919
+ state: pending.state,
1920
+ scopes: pendingScopes,
1921
+ }),
1922
+ expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
1923
+ hand_off: 'code',
1924
+ redeem_command: redeemCommand(
1925
+ runtime.profileName,
1926
+ authorizationChannel(metadata, runtime, pending),
1927
+ ),
1928
+ },
1929
+ };
919
1930
  }
920
1931
 
921
- export async function loginWithOAuth(runtime, options = {}, output, fetchImpl = fetch) {
1932
+ export async function loginWithOAuth(
1933
+ runtime,
1934
+ options = {},
1935
+ output,
1936
+ fetchImpl = fetch,
1937
+ createReceiver = createLoopbackReceiver,
1938
+ spawnListener = startDetachedLoopbackListener,
1939
+ readCode = readPastedCode,
1940
+ ) {
922
1941
  // A worktree profile is authenticated by the running `./dev.sh`, not by a
923
1942
  // browser grant. Authorizing over it would replace a scoped test identity
924
1943
  // with a real account and quietly point local testing at the wrong user.
@@ -937,73 +1956,166 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
937
1956
  ],
938
1957
  );
939
1958
  }
1959
+ // Even redemption is a mutating invocation, so malformed local options must
1960
+ // fail before reading pending state or contacting the token endpoint.
1961
+ requestedLoginMode(options);
1962
+ const requestedTimeoutMs = requestedAuthorizationTimeoutMs(options);
940
1963
  if (options.code) {
941
1964
  return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
942
1965
  }
943
1966
 
1967
+ // Reject a malformed local invocation before making discovery requests.
1968
+ const loginMode = resolveLoginMode(runtime, options);
944
1969
  const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
945
1970
  const scopes = options.scope?.length
946
1971
  ? [...new Set(options.scope)]
947
1972
  : DEFAULT_CLI_OAUTH_SCOPES;
948
- const timeoutMs = Number(options.timeoutSeconds) > 0
949
- ? Number(options.timeoutSeconds) * 1000
950
- : Number(options.timeoutMs) > 0
951
- ? Number(options.timeoutMs)
952
- : DEFAULT_LOGIN_TIMEOUT_MS;
953
- const nonBlockingAgent = Boolean(
954
- runtime.agentMode || runtime.outputMode === 'json',
955
- );
956
- const pasteCode = Boolean(options.pasteCode || nonBlockingAgent);
1973
+ const timeoutMs = requestedTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
1974
+ // A detached listener is not a terminal waiting on a prompt: nobody is held
1975
+ // up by it, and the user still has to sign up and verify an email, so its
1976
+ // default is the parked-authorization window rather than the blocking one.
1977
+ // An explicit --timeout-seconds still wins; the flag documents how long the
1978
+ // authorization stays open, and silently ignoring it here made it a lie.
1979
+ const parkedAuthorizationTimeoutMs = requestedTimeoutMs ?? PENDING_LOGIN_TTL_SECONDS * 1000;
1980
+ const {
1981
+ automaticMode,
1982
+ wantsNonBlocking,
1983
+ nonBlocking,
1984
+ usePasteCode,
1985
+ } = loginMode;
1986
+ let nonBlockingAgent = nonBlocking;
1987
+ let listenerGlobalLock = null;
1988
+ let listenerStartLock = null;
957
1989
  let receiver;
1990
+ let detachedListener = null;
1991
+ let detachedListenerPublished = false;
958
1992
  let redirectUri;
1993
+ let foregroundAuthorization = null;
959
1994
 
960
- if (pasteCode) {
961
- const pending = readPendingAuthorization(runtime);
962
- const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
963
- ? pending.scopes
964
- : DEFAULT_CLI_OAUTH_SCOPES;
965
- const sameAuthorization = Boolean(
966
- pending
967
- && pending.state
968
- && pending.api_base === runtime.apiBase
969
- && pending.issuer === metadata.issuer
970
- && pending.resource === metadata.resource
971
- && pending.client_id === metadata.clientId
972
- && pending.token_endpoint === metadata.tokenEndpoint
973
- && pending.redirect_uri === metadata.copyPasteRedirectUri
974
- && JSON.stringify(pendingScopes) === JSON.stringify(scopes),
1995
+ if (automaticMode || nonBlockingAgent || usePasteCode || options.reusePersistedCredential) {
1996
+ listenerGlobalLock = await acquireListenerGlobalLock();
1997
+ try {
1998
+ listenerStartLock = await acquireListenerStartLock(runtime);
1999
+ } catch (error) {
2000
+ releaseListenerGlobalLock(listenerGlobalLock);
2001
+ listenerGlobalLock = null;
2002
+ throw error;
2003
+ }
2004
+ }
2005
+
2006
+ try {
2007
+ if (options.reusePersistedCredential) {
2008
+ // `start` doubles as the confirmation command for a detached login. The
2009
+ // child can persist its token while this invocation is doing discovery;
2010
+ // re-read under the publication lock so an idempotent confirmation cannot
2011
+ // mint a second grant from a stale runtime snapshot.
2012
+ const storedProfile = getProfile(loadConfig(), runtime.profileName);
2013
+ if (
2014
+ storedProfile.oauth_access_token
2015
+ && !credentialIsExpired({ credentialKind: 'oauth' }, storedProfile)
2016
+ ) {
2017
+ assertOAuthApiTarget(runtime, storedProfile);
2018
+ updateRuntimeFromOAuthProfile(runtime, storedProfile);
2019
+ return { profile: storedProfile, metadata, reusedPersistedCredential: true };
2020
+ }
2021
+ }
2022
+ // A listener spawned by an earlier run is still holding the browser hand-off,
2023
+ // and its URL is the only one whose verifier that process knows.
2024
+ if (automaticMode && !usePasteCode) {
2025
+ // Auto mode may have degraded to the copy-code hand-off on an earlier run.
2026
+ // That parked verifier owns the URL the user already received, so keep the
2027
+ // hand-off sticky instead of publishing a competing loopback authorization.
2028
+ const parked = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2029
+ if (parked) return parked;
2030
+ // A non-reusable sidecar belongs to a different/expired authorization. It
2031
+ // must not survive beside the listener this run is about to publish.
2032
+ clearPendingAuthorizations(runtime);
2033
+ const live = readLiveListener(runtime, { sameApiBase: false });
2034
+ // Reuse is only safe when the live listener is authorizing the *same*
2035
+ // thing. The copy-paste path below already compares the full authorization;
2036
+ // matching only the profile here would hand back a URL carrying the scopes
2037
+ // of the earlier run, silently ignoring this one's --scope.
2038
+ const sameListenerAuthorization = Boolean(
2039
+ live
2040
+ && live.api_base === runtime.apiBase
2041
+ && live.issuer === metadata.issuer
2042
+ && live.resource === metadata.resource
2043
+ && live.client_id === metadata.clientId
2044
+ && live.token_endpoint === metadata.tokenEndpoint
2045
+ && (
2046
+ requestedTimeoutMs === null
2047
+ || Number(live.authorization_timeout_ms) === requestedTimeoutMs
2048
+ )
2049
+ && JSON.stringify(live.scopes || []) === JSON.stringify(scopes),
975
2050
  );
976
- if (sameAuthorization) {
977
- const challenge = createHash('sha256')
978
- .update(pending.verifier, 'ascii')
979
- .digest('base64url');
2051
+ if (live && !sameListenerAuthorization) {
2052
+ // A different authorization is being requested, so the old listener is
2053
+ // now unreachable work holding a port. Stop it before spawning another.
2054
+ stopPendingListener(runtime);
2055
+ }
2056
+ if (live && sameListenerAuthorization) {
980
2057
  return {
981
2058
  agentAuthorization: {
982
- authorize_url: buildAuthorizeUrl(metadata, {
983
- redirectUri: pending.redirect_uri,
984
- challenge,
985
- state: pending.state,
986
- scopes: pendingScopes,
987
- }),
988
- expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
989
- redeem_command: redeemCommand(
2059
+ authorize_url: live.authorize_url,
2060
+ expires_in: Math.max(0, Number(live.expires_at) - Math.floor(Date.now() / 1000)),
2061
+ hand_off: 'browser_callback',
2062
+ confirm_command: confirmCommand(
990
2063
  runtime.profileName,
991
- authorizationChannel(metadata, runtime, pending),
2064
+ authorizationChannel(metadata, runtime),
2065
+ live.api_base,
992
2066
  ),
993
2067
  },
994
2068
  };
995
2069
  }
996
2070
  }
997
2071
 
2072
+ if (!nonBlockingAgent && !usePasteCode) {
2073
+ // A foreground browser flow never holds publication locks while a person
2074
+ // completes authorization. This also covers explicit browser mode and a
2075
+ // start command checking for an already-persisted credential.
2076
+ releaseListenerStartLock(listenerStartLock);
2077
+ listenerStartLock = null;
2078
+ releaseListenerGlobalLock(listenerGlobalLock);
2079
+ listenerGlobalLock = null;
2080
+ }
2081
+
2082
+ if (usePasteCode) {
2083
+ const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2084
+ if (reused) return reused;
2085
+ // Code and loopback hand-offs are mutually exclusive for one profile. This
2086
+ // runs under the same publication lock as detached starts so a code request
2087
+ // cannot leave a second valid authorization beside a listener that is
2088
+ // still being published.
2089
+ stopPendingListener(runtime);
2090
+ }
2091
+
998
2092
  const { verifier, challenge } = createPkce();
999
2093
  const state = base64url(randomBytes(32));
1000
2094
 
1001
- if (pasteCode) {
1002
- redirectUri = metadata.copyPasteRedirectUri;
1003
- if (!redirectUri) {
1004
- throw oauthError('oauth_metadata_invalid', 'Notis did not advertise a copy-paste callback.');
1005
- }
1006
- } else {
2095
+ // May flip to true below: the browser hand-off is a preference, not a
2096
+ // guarantee, and the redirect URI is signed into the authorize URL before the
2097
+ // user ever sees it. Deciding here is the last moment a fallback is free.
2098
+ let pasteCode = usePasteCode;
2099
+
2100
+ const degradeToCode = (error) => {
2101
+ // A locked-down machine that cannot bind 127.0.0.1 used to dead-end here
2102
+ // with no way forward, even though the Portal hand-off would have worked.
2103
+ // Only a missing copy-paste callback is genuinely unrecoverable.
2104
+ if (!metadata.copyPasteRedirectUri) throw error;
2105
+ receiver = undefined;
2106
+ pasteCode = true;
2107
+ // --mode browser promised to wait for a browser callback, not for someone
2108
+ // to type into a pipe. Once no listener exists, returning the URL beats
2109
+ // prompting on a stdout nobody is reading. A caller that declared itself
2110
+ // non-interactive is in the same position: `readPastedCode` would block on
2111
+ // a stdin prompt it has already said it cannot answer.
2112
+ nonBlockingAgent = wantsNonBlocking || Boolean(runtime.nonInteractive);
2113
+ output?.note?.(
2114
+ 'The local callback listener could not start, so this login switched to the copy-paste code flow.',
2115
+ );
2116
+ };
2117
+
2118
+ if (!pasteCode) {
1007
2119
  let portalOrigin = 'https://app.notis.ai';
1008
2120
  try {
1009
2121
  portalOrigin = new URL(metadata.copyPasteRedirectUri).origin;
@@ -1011,8 +2123,62 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1011
2123
  // The OAuth server owns this metadata. Keep the public Portal fallback if
1012
2124
  // a development server omits or returns an invalid copy-paste URL.
1013
2125
  }
1014
- receiver = await createLoopbackReceiver({ state, timeoutMs, portalOrigin });
1015
- redirectUri = receiver.redirectUri;
2126
+ if (nonBlockingAgent) {
2127
+ // This command has to return before the user has even opened the URL, so
2128
+ // the listener has to belong to something else.
2129
+ detachedListener = await spawnListener(runtime, {
2130
+ metadata,
2131
+ verifier,
2132
+ state,
2133
+ scopes,
2134
+ timeoutMs: parkedAuthorizationTimeoutMs,
2135
+ portalOrigin,
2136
+ });
2137
+ if (detachedListener) {
2138
+ redirectUri = detachedListener.redirectUri;
2139
+ } else {
2140
+ degradeToCode(oauthError(
2141
+ 'oauth_loopback_failed',
2142
+ 'Could not start a background OAuth callback listener.',
2143
+ ));
2144
+ }
2145
+ } else {
2146
+ try {
2147
+ receiver = await createReceiver({ state, timeoutMs, portalOrigin });
2148
+ redirectUri = receiver.redirectUri;
2149
+ } catch (error) {
2150
+ degradeToCode(error);
2151
+ }
2152
+ }
2153
+ }
2154
+
2155
+ if (pasteCode) {
2156
+ // A loopback bind may have degraded after the initial mode decision. Code
2157
+ // publication still has to join the same serialization protocol as every
2158
+ // detached start before it writes a verifier sidecar.
2159
+ if (!listenerStartLock) {
2160
+ listenerGlobalLock = await acquireListenerGlobalLock();
2161
+ try {
2162
+ listenerStartLock = await acquireListenerStartLock(runtime);
2163
+ } catch (error) {
2164
+ releaseListenerGlobalLock(listenerGlobalLock);
2165
+ listenerGlobalLock = null;
2166
+ throw error;
2167
+ }
2168
+ const live = readLiveListener(runtime, { sameApiBase: false });
2169
+ if (live) stopPendingListener(runtime);
2170
+ }
2171
+ if (!usePasteCode) {
2172
+ // This run degraded into the code flow rather than starting there, so it
2173
+ // has not yet checked for a parked authorization. Overwriting one would
2174
+ // make the URL an earlier run already handed the user unredeemable.
2175
+ const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2176
+ if (reused) return reused;
2177
+ }
2178
+ redirectUri = metadata.copyPasteRedirectUri;
2179
+ if (!redirectUri) {
2180
+ throw oauthError('oauth_metadata_invalid', 'Notis did not advertise a copy-paste callback.');
2181
+ }
1016
2182
  }
1017
2183
 
1018
2184
  const authorizeUrl = buildAuthorizeUrl(metadata, {
@@ -1037,19 +2203,81 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1037
2203
  resource: metadata.resource,
1038
2204
  client_id: metadata.clientId,
1039
2205
  token_endpoint: metadata.tokenEndpoint,
2206
+ revocation_endpoint: metadata.revocationEndpoint,
1040
2207
  authorization_endpoint: metadata.authorizationEndpoint,
1041
2208
  channel: authorizationChannel(metadata, runtime),
1042
2209
  scopes,
1043
- expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
2210
+ authorization_timeout_ms: parkedAuthorizationTimeoutMs,
2211
+ expires_at: Math.floor(Date.now() / 1000) + Math.ceil(parkedAuthorizationTimeoutMs / 1000),
1044
2212
  });
2213
+ } else if (!nonBlockingAgent) {
2214
+ foregroundAuthorization = {
2215
+ profile: runtime.profileName,
2216
+ api_base: runtime.apiBase,
2217
+ verifier,
2218
+ state,
2219
+ redirect_uri: redirectUri,
2220
+ hand_off: 'browser_callback',
2221
+ issuer: metadata.issuer,
2222
+ resource: metadata.resource,
2223
+ client_id: metadata.clientId,
2224
+ token_endpoint: metadata.tokenEndpoint,
2225
+ revocation_endpoint: metadata.revocationEndpoint,
2226
+ authorization_endpoint: metadata.authorizationEndpoint,
2227
+ channel: authorizationChannel(metadata, runtime),
2228
+ scopes,
2229
+ authorization_timeout_ms: timeoutMs,
2230
+ expires_at: Math.floor(Date.now() / 1000) + Math.ceil(timeoutMs / 1000),
2231
+ };
2232
+ // Logout and competing logins use this owner-qualified sidecar to cancel
2233
+ // a foreground authorization before it can exchange or persist a grant.
2234
+ await publishForegroundAuthorization(runtime, foregroundAuthorization);
1045
2235
  }
1046
2236
 
1047
2237
  if (nonBlockingAgent) {
1048
2238
  await receiver?.close();
2239
+ if (detachedListener) {
2240
+ const listenerTtlSeconds = Math.round(parkedAuthorizationTimeoutMs / 1000);
2241
+ saveListenerState(runtime, {
2242
+ profile: runtime.profileName,
2243
+ api_base: runtime.apiBase,
2244
+ pid: detachedListener.pid,
2245
+ port: detachedListener.port,
2246
+ authorize_url: authorizeUrl,
2247
+ issuer: metadata.issuer,
2248
+ resource: metadata.resource,
2249
+ client_id: metadata.clientId,
2250
+ token_endpoint: metadata.tokenEndpoint,
2251
+ scopes,
2252
+ authorization_timeout_ms: parkedAuthorizationTimeoutMs,
2253
+ identity_token: detachedListener.identityToken,
2254
+ listener_script: detachedListener.scriptPath,
2255
+ // The record must not outlive the child it points at, or reuse hands
2256
+ // back a URL whose listener has already given up.
2257
+ expires_at: Math.floor(Date.now() / 1000) + listenerTtlSeconds,
2258
+ });
2259
+ detachedListenerPublished = true;
2260
+ return {
2261
+ agentAuthorization: {
2262
+ authorize_url: authorizeUrl,
2263
+ expires_in: listenerTtlSeconds,
2264
+ // Nothing to copy: the browser returns the code to the listener the
2265
+ // child is holding, and the credential is written before the user is
2266
+ // told they are connected.
2267
+ hand_off: 'browser_callback',
2268
+ confirm_command: confirmCommand(
2269
+ runtime.profileName,
2270
+ authorizationChannel(metadata, runtime),
2271
+ runtime.apiBase,
2272
+ ),
2273
+ },
2274
+ };
2275
+ }
1049
2276
  return {
1050
2277
  agentAuthorization: {
1051
2278
  authorize_url: authorizeUrl,
1052
- expires_in: PENDING_LOGIN_TTL_SECONDS,
2279
+ expires_in: Math.round(parkedAuthorizationTimeoutMs / 1000),
2280
+ hand_off: 'code',
1053
2281
  redeem_command: redeemCommand(
1054
2282
  runtime.profileName,
1055
2283
  authorizationChannel(metadata, runtime),
@@ -1058,21 +2286,35 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1058
2286
  };
1059
2287
  }
1060
2288
 
2289
+ if (pasteCode) {
2290
+ // Do not hold publication locks while a human finds and pastes a code. The
2291
+ // parked verifier is now authoritative; redemption reacquires both locks
2292
+ // and validates that it still owns the profile before exchanging.
2293
+ releaseListenerStartLock(listenerStartLock);
2294
+ listenerStartLock = null;
2295
+ releaseListenerGlobalLock(listenerGlobalLock);
2296
+ listenerGlobalLock = null;
2297
+ }
2298
+
1061
2299
  // Always surface the URL: opening the browser can fail silently, and the
1062
2300
  // wait below is useless without something the user can paste themselves.
1063
- output?.note?.(`Authorize Notis CLI: ${authorizeUrl}`);
2301
+ const announceAuthorization = output?.notice || output?.note;
2302
+ announceAuthorization?.call(output, `Authorize Notis CLI: ${authorizeUrl}`);
1064
2303
  if (options.browser !== false && !pasteCode) {
1065
2304
  openBrowser(authorizeUrl);
1066
2305
  }
1067
2306
 
1068
2307
  try {
1069
- const code = pasteCode ? await readPastedCode() : await receiver.waitForCode();
1070
- const tokenResponse = await exchangeCode(
2308
+ if (pasteCode) {
2309
+ return await redeemAuthorizationCode(runtime, await readCode(), fetchImpl);
2310
+ }
2311
+ const code = await receiver.waitForCode();
2312
+ const profile = await exchangeAndPersistForegroundAuthorization(
2313
+ runtime,
1071
2314
  metadata,
1072
- { code, redirectUri, verifier },
2315
+ { ...foregroundAuthorization, code },
1073
2316
  fetchImpl,
1074
2317
  );
1075
- const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
1076
2318
  updateRuntimeFromOAuthProfile(runtime, profile);
1077
2319
  receiver?.complete();
1078
2320
  return { profile, metadata };
@@ -1081,9 +2323,74 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1081
2323
  throw error;
1082
2324
  } finally {
1083
2325
  await receiver?.close();
2326
+ if (foregroundAuthorization) {
2327
+ const globalLock = await acquireListenerGlobalLock();
2328
+ let lockDir = null;
2329
+ try {
2330
+ lockDir = await acquireListenerStartLock(runtime);
2331
+ clearPendingAuthorizationIfOwner(runtime, foregroundAuthorization);
2332
+ } finally {
2333
+ releaseListenerStartLock(lockDir);
2334
+ releaseListenerGlobalLock(globalLock);
2335
+ }
2336
+ }
2337
+ }
2338
+ } finally {
2339
+ if (detachedListener && !detachedListenerPublished) {
2340
+ try { process.kill(detachedListener.pid); } catch { /* already gone */ }
2341
+ clearListenerState(runtime, { ownerPid: detachedListener.pid });
2342
+ }
2343
+ releaseListenerStartLock(listenerStartLock);
2344
+ releaseListenerGlobalLock(listenerGlobalLock);
1084
2345
  }
1085
2346
  }
1086
2347
 
2348
+ function pendingListenerProfiles() {
2349
+ const configFile = resolveConfigFile();
2350
+ const prefix = `${basename(configFile)}.login-listener.`;
2351
+ let entries;
2352
+ try {
2353
+ entries = readdirSync(dirname(configFile));
2354
+ } catch {
2355
+ return [];
2356
+ }
2357
+ const profiles = new Set();
2358
+ for (const entry of entries) {
2359
+ if (!entry.startsWith(prefix) || entry.includes('.payload') || entry.endsWith('.lock')) continue;
2360
+ try {
2361
+ const state = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
2362
+ if (typeof state?.profile === 'string' && state.profile) profiles.add(state.profile);
2363
+ } catch {
2364
+ // Ignore malformed or concurrently removed sidecars.
2365
+ }
2366
+ }
2367
+ return [...profiles];
2368
+ }
2369
+
2370
+ function pendingAuthorizationProfiles() {
2371
+ const configFile = resolveConfigFile();
2372
+ const configName = basename(configFile);
2373
+ const prefix = `${configName}.pending-login.`;
2374
+ const legacyName = `${configName}.pending-login`;
2375
+ let entries;
2376
+ try {
2377
+ entries = readdirSync(dirname(configFile));
2378
+ } catch {
2379
+ return [];
2380
+ }
2381
+ const profiles = new Set();
2382
+ for (const entry of entries) {
2383
+ if (entry !== legacyName && !entry.startsWith(prefix)) continue;
2384
+ try {
2385
+ const pending = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
2386
+ if (typeof pending?.profile === 'string' && pending.profile) profiles.add(pending.profile);
2387
+ } catch {
2388
+ // Ignore malformed or concurrently removed sidecars.
2389
+ }
2390
+ }
2391
+ return [...profiles];
2392
+ }
2393
+
1087
2394
  function lockIsStale() {
1088
2395
  try {
1089
2396
  return Date.now() - statSync(OAUTH_LOCK_DIR).mtimeMs > 45_000;
@@ -1127,8 +2434,17 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
1127
2434
  }
1128
2435
 
1129
2436
  export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
2437
+ // Refresh rotates the same stored grant that login publishes and logout
2438
+ // removes. Join their global -> profile lock order so a token rotation can
2439
+ // never be mistaken for a successor authorization by a concurrent logout.
2440
+ const globalLock = await acquireListenerGlobalLock();
2441
+ let profileLock = null;
1130
2442
  let ownsLock = false;
2443
+ let metadata = null;
2444
+ let rotatedResponse = null;
2445
+ let rotatedPersisted = false;
1131
2446
  try {
2447
+ profileLock = await acquireListenerStartLock(runtime);
1132
2448
  ownsLock = await acquireRefreshLock(runtime);
1133
2449
  if (!ownsLock) return true;
1134
2450
  const config = loadConfig();
@@ -1146,8 +2462,8 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1146
2462
  return false;
1147
2463
  }
1148
2464
 
1149
- const metadata = storedOAuthMetadata(runtime, profile);
1150
- const response = await fetchJson(
2465
+ metadata = storedOAuthMetadata(runtime, profile);
2466
+ rotatedResponse = await fetchJson(
1151
2467
  metadata.tokenEndpoint,
1152
2468
  {
1153
2469
  method: 'POST',
@@ -1161,10 +2477,14 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1161
2477
  },
1162
2478
  fetchImpl,
1163
2479
  );
1164
- const updated = persistOAuthTokenResponse(runtime, metadata, response);
2480
+ const updated = persistOAuthTokenResponse(runtime, metadata, rotatedResponse);
2481
+ rotatedPersisted = true;
1165
2482
  updateRuntimeFromOAuthProfile(runtime, updated);
1166
2483
  return true;
1167
2484
  } catch (error) {
2485
+ if (rotatedResponse && !rotatedPersisted) {
2486
+ await revokeCancelledToken(metadata, rotatedResponse, fetchImpl);
2487
+ }
1168
2488
  if (error instanceof CliError) {
1169
2489
  throw new CliError({
1170
2490
  code: error.code,
@@ -1186,6 +2506,8 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1186
2506
  // A process exit or external cleanup may already have removed the lock.
1187
2507
  }
1188
2508
  }
2509
+ releaseListenerStartLock(profileLock);
2510
+ releaseListenerGlobalLock(globalLock);
1189
2511
  }
1190
2512
  }
1191
2513
 
@@ -1203,55 +2525,98 @@ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchIm
1203
2525
  ],
1204
2526
  );
1205
2527
  }
1206
- const config = loadConfig();
1207
- const profileNames = allProfiles
1208
- ? Object.keys(config.profiles)
1209
- : [runtime.profileName];
1210
- for (const profileName of profileNames) {
1211
- const profile = config.profiles[profileName] || {};
1212
- if (
1213
- profile.oauth_refresh_token
1214
- && profile.oauth_client_id
1215
- && profile.oauth_issuer
1216
- ) {
2528
+ // A single-profile logout mutates the same state as login publication and
2529
+ // refresh, so it needs the global lock just as much as --all-profiles does.
2530
+ const globalLock = await acquireListenerGlobalLock();
2531
+ try {
2532
+ const config = loadConfig();
2533
+ const profileNames = allProfiles
2534
+ ? [...new Set([
2535
+ ...Object.keys(config.profiles),
2536
+ ...pendingListenerProfiles(),
2537
+ ...pendingAuthorizationProfiles(),
2538
+ ])]
2539
+ : [runtime.profileName];
2540
+ const clearedProfiles = [];
2541
+ for (const profileName of profileNames) {
2542
+ // An unfinished login is still holding a listener that would write this
2543
+ // profile back in the moment the old URL is opened. Signing out has to end
2544
+ // the authorization in flight, not just the one already stored.
2545
+ const profileRuntime = { ...runtime, profileName };
2546
+ const profileLock = await acquireListenerStartLock(profileRuntime);
1217
2547
  try {
1218
- const metadata = storedOAuthMetadata(runtime, profile);
1219
- await fetchJson(
1220
- metadata.revocationEndpoint,
1221
- {
1222
- method: 'POST',
1223
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1224
- body: new URLSearchParams({
1225
- token: profile.oauth_refresh_token,
1226
- client_id: profile.oauth_client_id,
1227
- }),
1228
- },
1229
- fetchImpl,
2548
+ const latestBeforeClear = loadConfig();
2549
+ const storedProfile = latestBeforeClear.profiles[profileName] || {};
2550
+ const hadStoredGrant = Boolean(
2551
+ storedProfile.oauth_access_token
2552
+ || storedProfile.oauth_refresh_token
2553
+ || storedProfile.oauth_client_id
2554
+ || storedProfile.oauth_issuer
2555
+ || storedProfile.oauth_resource
2556
+ || storedProfile.oauth_user_id
1230
2557
  );
1231
- } catch {
1232
- // Local credential removal still succeeds when the remote grant is
1233
- // already gone or the network is unavailable.
2558
+ const hadPendingAuthorization = Boolean(readPendingAuthorization(profileRuntime));
2559
+ const hadListener = Boolean(readListenerState(profileRuntime));
2560
+ if (hadStoredGrant || hadPendingAuthorization || hadListener) {
2561
+ clearedProfiles.push(profileName);
2562
+ }
2563
+ clearPendingAuthorizations(profileRuntime);
2564
+ stopPendingListener(profileRuntime);
2565
+ // Read only after both locks are held. A publication or refresh that
2566
+ // won first is part of this logout; one that starts later observes the
2567
+ // cleared profile instead of resurrecting it afterward.
2568
+ const profile = loadConfig().profiles[profileName] || {};
2569
+ const revocationToken = profile.oauth_refresh_token || profile.oauth_access_token;
2570
+ if (
2571
+ revocationToken
2572
+ && profile.oauth_client_id
2573
+ && profile.oauth_issuer
2574
+ ) {
2575
+ try {
2576
+ const metadata = storedOAuthMetadata(runtime, profile);
2577
+ await fetchJson(
2578
+ metadata.revocationEndpoint,
2579
+ {
2580
+ method: 'POST',
2581
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2582
+ body: new URLSearchParams({
2583
+ token: revocationToken,
2584
+ client_id: profile.oauth_client_id,
2585
+ }),
2586
+ },
2587
+ fetchImpl,
2588
+ );
2589
+ } catch {
2590
+ // Local credential removal still succeeds when the remote grant is
2591
+ // already gone or the network is unavailable.
2592
+ }
2593
+ }
2594
+ updateConfig((latest) => {
2595
+ const current = latest.profiles[profileName];
2596
+ // A named pending login has no stored profile yet. Cancelling it must
2597
+ // not create an empty profile as a side effect of logout.
2598
+ if (!current) return latest;
2599
+ latest.profiles[profileName] = {
2600
+ ...current,
2601
+ oauth_access_token: undefined,
2602
+ oauth_refresh_token: undefined,
2603
+ oauth_access_expires_at: undefined,
2604
+ oauth_refresh_expires_at: undefined,
2605
+ oauth_client_id: undefined,
2606
+ oauth_issuer: undefined,
2607
+ oauth_api_base: undefined,
2608
+ oauth_resource: undefined,
2609
+ oauth_scopes: undefined,
2610
+ oauth_user_id: undefined,
2611
+ };
2612
+ return latest;
2613
+ });
2614
+ } finally {
2615
+ releaseListenerStartLock(profileLock);
1234
2616
  }
1235
2617
  }
2618
+ return { profiles: clearedProfiles };
2619
+ } finally {
2620
+ releaseListenerGlobalLock(globalLock);
1236
2621
  }
1237
- updateConfig((latest) => {
1238
- for (const profileName of profileNames) {
1239
- const profile = latest.profiles[profileName] || {};
1240
- latest.profiles[profileName] = {
1241
- ...profile,
1242
- oauth_access_token: undefined,
1243
- oauth_refresh_token: undefined,
1244
- oauth_access_expires_at: undefined,
1245
- oauth_refresh_expires_at: undefined,
1246
- oauth_client_id: undefined,
1247
- oauth_issuer: undefined,
1248
- oauth_api_base: undefined,
1249
- oauth_resource: undefined,
1250
- oauth_scopes: undefined,
1251
- oauth_user_id: undefined,
1252
- };
1253
- }
1254
- return latest;
1255
- });
1256
- return { profiles: profileNames };
1257
2622
  }