@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
@@ -8,25 +8,25 @@
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto';
11
- import { spawn } from 'node:child_process';
12
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
11
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
13
12
  import { tmpdir } from 'node:os';
14
- import { basename, join, relative, resolve } from 'node:path';
13
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
14
 
16
15
  import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
17
16
  import { formatTable } from '../runtime/output.js';
18
17
  import {
18
+ defaultAppProjectDir,
19
19
  resolveProjectDir,
20
20
  loadAppConfig,
21
21
  detectProjectProblems,
22
22
  detectProjectWarnings,
23
23
  buildArtifact,
24
+ appLinkedStateProfileKey,
24
25
  readManifest,
25
26
  readLinkedState,
26
27
  writeLinkedState,
27
28
  requireLinkedAppId,
28
29
  scaffoldProject,
29
- loadScaffoldCatalog,
30
30
  findUnknownScreenshotScenarios,
31
31
  inspectListingReadiness,
32
32
  resolveListingScreenshots,
@@ -34,11 +34,23 @@ import {
34
34
  collectSourceFiles,
35
35
  resolveConfiguredAppSkills,
36
36
  normalizeAppCapabilities,
37
+ normalizeAppToolBindings,
37
38
  normalizeAppSkillManifestPath,
38
39
  directDeploy,
39
40
  pullAppSource,
40
41
  } from '../runtime/app-platform.js';
42
+ import {
43
+ filterScaffoldCatalog,
44
+ loadScaffoldCatalog,
45
+ scaffoldRegistryLabel,
46
+ } from '../runtime/app-registry-scaffolds.js';
41
47
  import { startAppDevServer } from '../runtime/app-dev-server.js';
48
+ import {
49
+ discoverRegisteredAppProjects,
50
+ readAppDevRoots,
51
+ registerAppDevRoot,
52
+ removeAppDevRoot,
53
+ } from '../runtime/app-dev-roots.js';
42
54
  import {
43
55
  captureHarnessScreenshot,
44
56
  closeAgentBrowserSession,
@@ -48,12 +60,22 @@ import {
48
60
  import {
49
61
  getAppDevSessionsFile,
50
62
  heartbeatAppDevSession,
63
+ linkAppDevSessionTarget,
64
+ readAppDevSessions,
51
65
  removeAppDevSession,
52
66
  upsertAppDevSessions,
53
- waitForAppDevSessionMountAcknowledgements,
54
- waitForAppDevSessionRenderAcknowledgements,
55
67
  } from '../runtime/app-dev-sessions.js';
56
- import { getAvailablePort } from '../runtime/ports.js';
68
+ import {
69
+ releaseAppDevHostLock,
70
+ tryAcquireAppDevHostLock,
71
+ } from '../runtime/app-dev-host-lock.js';
72
+ import {
73
+ heartbeatAppDevConsumer,
74
+ hasAppDevConsumer,
75
+ readAppDevConsumers,
76
+ removeAppDevConsumer,
77
+ } from '../runtime/app-dev-consumers.js';
78
+ import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
57
79
  import { getCliMode } from '../runtime/cli-mode.js';
58
80
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
59
81
  import { httpRequest } from '../runtime/transport.js';
@@ -67,14 +89,142 @@ import {
67
89
 
68
90
  const DEFAULT_DEV_PORT = 5173;
69
91
  const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
70
- const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
92
+ const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
93
+ const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
94
+ const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
95
+
96
+ function projectIsWithinRoot(projectDir, rootDir) {
97
+ const nested = relative(rootDir, projectDir);
98
+ return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
99
+ }
100
+
101
+ /**
102
+ * A CLI launch may add one root, but the shared host always serves the complete
103
+ * machine registry. Desktop sets skipRootRegistration because it is reconciling
104
+ * an already-registered snapshot; this must never narrow discovery to the one
105
+ * project path used to start the host.
106
+ */
107
+ export function discoverAppDevLaunchProjects(rootDir, {
108
+ skipRootRegistration = false,
109
+ registerRoot = registerAppDevRoot,
110
+ discoverProjects = discoverRegisteredAppProjects,
111
+ } = {}) {
112
+ if (!skipRootRegistration) registerRoot(rootDir);
113
+ return discoverProjects();
114
+ }
115
+
116
+ /**
117
+ * Collapse duplicate local sources before they reach the shared loopback host.
118
+ * An explicitly registered root is more intentional than the implicit
119
+ * ~/.notis/apps root. If two equally intentional roots claim the same dev
120
+ * slug, omit that slug and keep serving every unrelated app.
121
+ */
122
+ export function selectCanonicalDevApps(candidates, rootsRegistry) {
123
+ const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
124
+ const groups = new Map();
125
+ for (const candidate of candidates) {
126
+ groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
127
+ }
128
+
129
+ const selected = [];
130
+ const warnings = [];
131
+ for (const [devSlug, group] of groups) {
132
+ if (group.length === 1) {
133
+ selected.push(group[0]);
134
+ continue;
135
+ }
136
+ const ranked = group.map((candidate) => {
137
+ const explicitRoots = roots.filter((root) => (
138
+ root?.implicit !== true
139
+ && typeof root?.path === 'string'
140
+ && projectIsWithinRoot(candidate.projectDir, root.path)
141
+ ));
142
+ const newestRegistration = explicitRoots.reduce((latest, root) => {
143
+ const timestamp = Date.parse(root.registeredAt || '');
144
+ return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
145
+ }, 0);
146
+ return {
147
+ candidate,
148
+ rank: explicitRoots.length > 0 ? 1 : 0,
149
+ newestRegistration,
150
+ };
151
+ }).sort((left, right) => (
152
+ right.rank - left.rank
153
+ || right.newestRegistration - left.newestRegistration
154
+ ));
155
+ const winner = ranked[0];
156
+ const runnerUp = ranked[1];
157
+ const unambiguous = winner.rank > runnerUp.rank
158
+ || winner.newestRegistration > runnerUp.newestRegistration;
159
+ if (unambiguous) {
160
+ selected.push(winner.candidate);
161
+ warnings.push(
162
+ `Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
163
+ );
164
+ continue;
165
+ }
166
+ warnings.push(
167
+ `Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
168
+ );
169
+ }
170
+ return { selected, warnings };
171
+ }
172
+
173
+ export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
174
+ const now = Date.now();
175
+ const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
176
+ .filter((session) => {
177
+ if (session.sourceHost !== true) return false;
178
+ const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
179
+ if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
180
+ if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
181
+ try {
182
+ process.kill(session.hostPid, 0);
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ });
188
+ const groups = new Map();
189
+ for (const session of sourceSessions) {
190
+ const key = `${session.hostPid || 0}:${session.sessionId}`;
191
+ groups.set(key, [...(groups.get(key) || []), session]);
192
+ }
193
+ for (const sessions of groups.values()) {
194
+ const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
195
+ const canonicalProjects = sessions.find((session) => (
196
+ Array.isArray(session.canonicalProjects)
197
+ ))?.canonicalProjects;
198
+ const discoveredProjects = sessions.find((session) => (
199
+ Array.isArray(session.discoveredProjects)
200
+ ))?.discoveredProjects;
201
+ const discoveryMatches = discoveredProjects
202
+ && JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
203
+ if (
204
+ discoveryMatches
205
+ && canonicalProjects
206
+ && canonicalProjects.every((projectDir) => byProject.has(projectDir))
207
+ ) {
208
+ return new Map(canonicalProjects.map((projectDir) => [
209
+ projectDir,
210
+ byProject.get(projectDir).bundleBaseUrl,
211
+ ]));
212
+ }
213
+ if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
214
+ return new Map(projectDirs.map((projectDir) => [
215
+ projectDir,
216
+ byProject.get(projectDir).bundleBaseUrl,
217
+ ]));
218
+ }
219
+ return null;
220
+ }
71
221
  const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
72
222
  const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
73
223
  const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
74
224
  const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
75
225
  const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
76
226
  const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
77
- const APP_DEPLOY_TIMEOUT_MS = 120_000;
227
+ export const APP_DEPLOY_TIMEOUT_MS = 600_000;
78
228
 
79
229
  // ---------------------------------------------------------------------------
80
230
  // Formatters
@@ -98,40 +248,12 @@ function scaffoldsTable(scaffolds) {
98
248
  ]);
99
249
  }
100
250
 
101
- function hasNotisConfig(dir) {
102
- return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
103
- }
104
-
105
- function discoverAppDirs(projectDir) {
106
- if (hasNotisConfig(projectDir)) {
107
- return [projectDir];
108
- }
109
-
110
- const appsDir = join(projectDir, 'apps');
111
- if (!existsSync(appsDir)) {
112
- throw usageError(
113
- `No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
114
- );
115
- }
116
-
117
- const entries = readdirSync(appsDir, { withFileTypes: true });
118
- const candidates = [];
119
- for (const entry of entries) {
120
- if (!entry.isDirectory()) continue;
121
- if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
122
- const dir = join(appsDir, entry.name);
123
- if (hasNotisConfig(dir)) {
124
- candidates.push(dir);
125
- }
126
- }
127
-
128
- if (candidates.length === 0) {
129
- throw usageError(
130
- `Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
131
- );
132
- }
133
-
134
- return candidates;
251
+ function appDevRootsTable(roots) {
252
+ return formatTable(roots, [
253
+ { label: 'Folder', value: (root) => root.path },
254
+ { label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
255
+ { label: 'Registered', value: (root) => root.registeredAt || '' },
256
+ ]);
135
257
  }
136
258
 
137
259
  function decodeJwtSub(jwt) {
@@ -146,42 +268,11 @@ function decodeJwtSub(jwt) {
146
268
  }
147
269
  }
148
270
 
149
- export function developmentDesktopOpenCommand(
150
- url,
151
- {
152
- platform = process.platform,
153
- appName = null,
154
- bundleId = null,
155
- scheme = 'notis',
156
- } = {},
157
- ) {
158
- let command;
159
- let args;
160
- if (platform === 'darwin') {
161
- command = 'open';
162
- args = bundleId && scheme === 'notis'
163
- ? ['-b', bundleId, url]
164
- : appName && scheme === 'notis'
165
- ? ['-a', appName, url]
166
- : [url];
167
- } else if (platform === 'win32') {
168
- command = 'cmd';
169
- args = ['/c', 'start', '', url];
170
- } else {
171
- command = 'xdg-open';
172
- args = [url];
173
- }
174
- return { command, args };
175
- }
176
-
177
- function openInBrowser(url, options = {}) {
178
- const { command, args } = developmentDesktopOpenCommand(url, options);
179
- try {
180
- const child = spawn(command, args, { stdio: 'ignore' });
181
- child.on('error', () => {});
182
- } catch {
183
- // Non-fatal. The URL is printed in the CLI output.
184
- }
271
+ function linkedStateProfileKey(runtime) {
272
+ return appLinkedStateProfileKey({
273
+ apiBase: runtime?.apiBase,
274
+ userId: decodeJwtSub(runtime?.jwt),
275
+ });
185
276
  }
186
277
 
187
278
  function slugify(value) {
@@ -192,8 +283,8 @@ function slugify(value) {
192
283
  .replace(/(^-|-$)+/g, '');
193
284
  }
194
285
 
195
- function buildDevInstallSlug(appName) {
196
- const base = slugify(appName);
286
+ function buildDevInstallSlug(appConfig) {
287
+ const base = slugify(appConfig?.devSlug || appConfig?.name);
197
288
  if (!base) {
198
289
  return '';
199
290
  }
@@ -226,61 +317,6 @@ function pickDefaultRouteSlug(manifest) {
226
317
  return firstWithSlug ? firstWithSlug.slug : null;
227
318
  }
228
319
 
229
- const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
230
-
231
- export function resolveDevelopmentDesktopScheme(env = process.env, worktreeRuntime = null) {
232
- // Mirror the desktop app's own scheme resolution (electron main + forge config):
233
- // local dev launches register `notis-dev`, while installed prod/beta builds claim
234
- // `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
235
- // prod/beta app instead of the local dev app.
236
- const scheme = (
237
- worktreeRuntime?.desktop_deep_link_scheme
238
- || env.NOTIS_DESKTOP_DEEP_LINK_SCHEME
239
- || ''
240
- ).trim();
241
- return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
242
- }
243
-
244
- export function resolveDevelopmentDesktopAppName(runtime = {}) {
245
- const explicit = String(runtime.worktreeRuntime?.desktop_app_name || '').trim();
246
- if (explicit) {
247
- return explicit;
248
- }
249
- try {
250
- return new URL(runtime.apiBase).hostname === 'api-beta.notis.ai'
251
- ? 'Notis Beta'
252
- : 'Notis';
253
- } catch {
254
- return 'Notis';
255
- }
256
- }
257
-
258
- export function resolveDevelopmentDesktopBundleId(runtime = {}) {
259
- return resolveDevelopmentDesktopAppName(runtime) === 'Notis Beta'
260
- ? 'ai.notis.desktop.beta'
261
- : 'ai.notis.desktop';
262
- }
263
-
264
- export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
265
- const route = String(appHref || '/store').replace(/^\/+/, '');
266
- const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
267
- return `${normalizedScheme}://${route || 'store'}`;
268
- }
269
-
270
- export function buildMountedDevelopmentDesktopUrl(appHref, scheme, sessionId) {
271
- const url = new URL(buildDevelopmentDesktopUrl(appHref, scheme));
272
- url.searchParams.set('notis_dev_session', sessionId);
273
- return url.toString();
274
- }
275
-
276
- export function shouldOpenDevelopmentTab(options = {}) {
277
- // Commander stores the negatable `--no-open` flag as `options.open === false`;
278
- // it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
279
- // `--no-open` was silently ignored and `apps dev` always ran `open notis://…`,
280
- // which macOS routes to the installed prod/beta desktop app.
281
- return options.open !== false;
282
- }
283
-
284
320
  export function buildDevelopmentAppHref({
285
321
  appSlug,
286
322
  appId,
@@ -561,6 +597,7 @@ function buildManifestForDev(appConfig) {
561
597
  databases: appConfig.databases || [],
562
598
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
563
599
  tools: appConfig.tools || [],
600
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
564
601
  skills: (appConfig.skills || []).map((skill) => ({
565
602
  key: skill.key,
566
603
  path: normalizeAppSkillManifestPath(skill.path),
@@ -579,7 +616,7 @@ export function buildEnsureDevInstallArguments({
579
616
  useInstalledDatabases = false,
580
617
  approvedCapabilities = null,
581
618
  }) {
582
- const devSlug = buildDevInstallSlug(appConfig.name);
619
+ const devSlug = buildDevInstallSlug(appConfig);
583
620
  const arguments_ = {
584
621
  dev_slug: devSlug,
585
622
  name: appConfig.name,
@@ -589,8 +626,12 @@ export function buildEnsureDevInstallArguments({
589
626
  if (linkedState?.dev_app_id) {
590
627
  arguments_.app_id = linkedState.dev_app_id;
591
628
  }
592
- // Sent on every ensure call, including as `false`, so dropping `--live-data`
593
- // puts the next session back on its own dev copies.
629
+ if (useInstalledDatabases && linkedState?.app_id) {
630
+ arguments_.installed_app_id = linkedState.app_id;
631
+ }
632
+ // Sent on every ensure call, including as `false`, so a `--scratch` session
633
+ // puts the app on its own dev copies and the next plain session puts it back
634
+ // on the installed app's databases.
594
635
  arguments_.use_installed_databases = Boolean(useInstalledDatabases);
595
636
  if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
596
637
  arguments_.approved_capabilities = approvedCapabilities;
@@ -614,15 +655,20 @@ export async function resolveCloudShellConsent({
614
655
  projectDir,
615
656
  grantCloudShell = false,
616
657
  logger = console,
658
+ profileKey = null,
617
659
  }) {
618
660
  const capabilities = normalizeAppCapabilities(appConfig.capabilities);
619
661
  if (capabilities.cloudComputer !== 'shell') {
620
662
  return null;
621
663
  }
622
664
  const approved = ['cloud_computer_read', 'cloud_computer_shell'];
623
- const linkedState = readLinkedState(projectDir) || {};
665
+ const linkedState = readLinkedState(projectDir, profileKey) || {};
624
666
  const recordDecision = (decision) => {
625
- writeLinkedState(projectDir, { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision });
667
+ writeLinkedState(
668
+ projectDir,
669
+ { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
670
+ profileKey,
671
+ );
626
672
  };
627
673
 
628
674
  if (grantCloudShell) {
@@ -675,21 +721,22 @@ export async function ensureDevInstall({
675
721
  if (!appConfig.name) {
676
722
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
677
723
  }
678
- const devSlug = buildDevInstallSlug(appConfig.name);
724
+ const devSlug = buildDevInstallSlug(appConfig);
679
725
  if (!devSlug) {
680
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
726
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
681
727
  }
682
728
 
683
729
  const manifest = buildManifestForDev(appConfig);
684
730
  const skills = resolveConfiguredAppSkills(appConfig, projectDir);
685
- let linkedState = readLinkedState(projectDir);
731
+ const profileKey = linkedStateProfileKey(ctx.runtime);
732
+ let linkedState = readLinkedState(projectDir, profileKey);
686
733
  let linkedApp = null;
687
734
  if (linkedState?.dev_app_id) {
688
735
  const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
689
736
  if (!devApp || devApp.manifest?.is_dev !== true) {
690
737
  const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
691
738
  linkedState = rest;
692
- writeLinkedState(projectDir, linkedState);
739
+ writeLinkedState(projectDir, linkedState, profileKey);
693
740
  }
694
741
  }
695
742
  if (linkedState?.app_id) {
@@ -704,25 +751,98 @@ export async function ensureDevInstall({
704
751
  dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
705
752
  } : {}),
706
753
  };
707
- writeLinkedState(projectDir, linkedState);
754
+ writeLinkedState(projectDir, linkedState, profileKey);
708
755
  linkedApp = null;
709
756
  }
710
757
  }
758
+ // A checkout can be shared by Beta and a source-workspace desktop. Its
759
+ // installed app link is valid for the Beta account but intentionally
760
+ // inaccessible to the worktree's test user. Preserve that durable link for
761
+ // Beta, while omitting it from this run so the local dev app uses its own
762
+ // resources instead of sending an unauthorized installed_app_id to ensure.
763
+ if (!linkedState?.app_id) {
764
+ const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
765
+ ctx.runtime,
766
+ devSlug,
767
+ linkedState?.dev_app_id,
768
+ projectDir,
769
+ profileKey,
770
+ runTool,
771
+ );
772
+ const installedCandidates = installedResolution.candidates;
773
+ if (installedCandidates.length > 1) {
774
+ if (installedResolution.source === 'persisted-project-link') {
775
+ throw usageError(
776
+ `Multiple accessible installed apps are persisted for ${projectDir}. `
777
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
778
+ );
779
+ }
780
+ throw usageError(
781
+ `Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
782
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
783
+ );
784
+ }
785
+ if (installedCandidates.length === 1) {
786
+ const now = new Date().toISOString();
787
+ linkedApp = installedCandidates[0];
788
+ linkedState = {
789
+ ...(linkedState || {}),
790
+ app_id: linkedApp.app_id || linkedApp.id,
791
+ linked_at: now,
792
+ auto_linked_at: now,
793
+ };
794
+ writeLinkedState(projectDir, linkedState, profileKey);
795
+ }
796
+ }
797
+ const runtimeLinkedState = linkedState?.app_id && !linkedApp
798
+ ? Object.fromEntries(
799
+ Object.entries(linkedState).filter(([key]) => ![
800
+ 'app_id', 'linked_at', 'deployed_at', 'version',
801
+ ].includes(key)),
802
+ )
803
+ : linkedState;
711
804
  const ensureArguments = buildEnsureDevInstallArguments({
712
805
  appConfig,
713
806
  manifest,
714
- linkedState,
807
+ linkedState: runtimeLinkedState,
715
808
  skills,
716
809
  useInstalledDatabases,
717
810
  approvedCapabilities,
718
811
  });
719
- const ensureResult = await runTool({
720
- runtime: ctx.runtime,
721
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
722
- arguments_: ensureArguments,
723
- mutating: true,
724
- idempotencyKey,
725
- });
812
+ let ensureResult;
813
+ try {
814
+ ensureResult = await runTool({
815
+ runtime: ctx.runtime,
816
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
817
+ arguments_: ensureArguments,
818
+ mutating: true,
819
+ idempotencyKey,
820
+ });
821
+ } catch (error) {
822
+ const backendCode = error?.details?.error?.code;
823
+ if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
824
+ // The authenticated environment can outlive an older local API profile.
825
+ // If that profile remembered a different dev row while this user already
826
+ // owns the exact canonical dev slug, the backend refuses a duplicate.
827
+ // Retry without the stale row id so the backend deterministically adopts
828
+ // the unique slug owner; preserve the installed target and every grant.
829
+ const staleDevAppId = linkedState.dev_app_id;
830
+ const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
831
+ linkedState = rest;
832
+ writeLinkedState(projectDir, linkedState, profileKey);
833
+ const retryArguments = { ...ensureArguments };
834
+ delete retryArguments.app_id;
835
+ ensureResult = await runTool({
836
+ runtime: ctx.runtime,
837
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
838
+ arguments_: retryArguments,
839
+ mutating: true,
840
+ idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
841
+ });
842
+ } else {
843
+ throw error;
844
+ }
845
+ }
726
846
  const appId = ensureResult.payload.app_id;
727
847
  if (!appId) {
728
848
  throw usageError('Dev installation tool did not return an app_id.');
@@ -733,7 +853,7 @@ export async function ensureDevInstall({
733
853
  ...(linkedState || {}),
734
854
  dev_app_id: appId,
735
855
  dev_linked_at: linkedState?.dev_linked_at || now,
736
- });
856
+ }, profileKey);
737
857
  }
738
858
  return {
739
859
  slug: ensureResult.payload.slug || devSlug,
@@ -743,8 +863,8 @@ export async function ensureDevInstall({
743
863
  projectDir,
744
864
  manifest,
745
865
  created: ensureResult.payload.created || false,
746
- linkedAppId: linkedState?.app_id || null,
747
- targetAppId: linkedState?.app_id || null,
866
+ linkedAppId: runtimeLinkedState?.app_id || null,
867
+ targetAppId: runtimeLinkedState?.app_id || null,
748
868
  targetAppSlug: linkedApp?.slug || null,
749
869
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
750
870
  liveData: ensureResult.payload.live_data || null,
@@ -795,6 +915,63 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
795
915
  throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
796
916
  }
797
917
 
918
+ async function findInstalledLinkCandidatesForLegacyDevState(
919
+ runtime,
920
+ devSlug,
921
+ devAppId,
922
+ projectDir,
923
+ profileKey,
924
+ runTool = runToolCommand,
925
+ ) {
926
+ const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
927
+ if (!installedSlug) return { candidates: [], source: null };
928
+
929
+ const result = await runTool({
930
+ runtime,
931
+ toolName: LIST_APPS_TOOL,
932
+ });
933
+ const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
934
+ (app?.app_id || app?.id)
935
+ && (app.app_id || app.id) !== devAppId
936
+ && app?.manifest?.is_dev !== true
937
+ ));
938
+
939
+ // A project-local app id is stronger evidence than a slug. Older CLI runs
940
+ // could persist that explicit link under another API/user profile before
941
+ // the machine-local discovery host mounted the same source here. Migrate it
942
+ // only when exactly one such id is accessible in the current environment;
943
+ // multiple accessible ids remain ambiguous and fail closed below.
944
+ const rawState = readLinkedState(projectDir) || {};
945
+ const persistedAppIds = new Set();
946
+ if (rawState.app_id) persistedAppIds.add(rawState.app_id);
947
+ const profiles = rawState.profiles && typeof rawState.profiles === 'object'
948
+ ? rawState.profiles
949
+ : {};
950
+ for (const [storedProfileKey, state] of Object.entries(profiles)) {
951
+ if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
952
+ if (state.app_id) persistedAppIds.add(state.app_id);
953
+ }
954
+ const persistedCandidates = accessibleInstalledApps.filter((app) => (
955
+ persistedAppIds.has(app.app_id || app.id)
956
+ ));
957
+ if (persistedCandidates.length) {
958
+ return { candidates: persistedCandidates, source: 'persisted-project-link' };
959
+ }
960
+
961
+ // The database row slug can change when a Store app is installed or an old
962
+ // development app is promoted. The shipped source manifest keeps the
963
+ // canonical app slug, so accept either exact field. Display names are never
964
+ // considered.
965
+ const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
966
+ const canonicalSlugs = new Set([
967
+ app?.slug,
968
+ app?.manifest?.app?.slug,
969
+ ].map(slugify).filter(Boolean));
970
+ return canonicalSlugs.has(installedSlug);
971
+ });
972
+ return { candidates: exactSlugCandidates, source: 'exact-slug' };
973
+ }
974
+
798
975
  export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
799
976
  const app = await getAccessibleApp(runtime, appId, runTool);
800
977
  if (!app) {
@@ -803,6 +980,14 @@ export async function assertDirectDeployAccess(runtime, appId, runTool = runTool
803
980
  if (app.apps_access?.has_access !== true) {
804
981
  throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
805
982
  }
983
+ if (app.can_edit !== true) {
984
+ throw usageError(`Direct deploy requires edit access to app ${appId}.`);
985
+ }
986
+ if (app.manifest?.is_dev === true) {
987
+ throw usageError(
988
+ 'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
989
+ );
990
+ }
806
991
  return app;
807
992
  }
808
993
 
@@ -839,10 +1024,12 @@ async function appsListHandler(ctx) {
839
1024
  }
840
1025
 
841
1026
  async function appsInitHandler(ctx) {
842
- const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
1027
+ const projectDir = ctx.args.dir
1028
+ ? resolveProjectDir(ctx.args.dir)
1029
+ : defaultAppProjectDir(slugify(ctx.args.name));
843
1030
  const fromSlug = ctx.options.from || null;
844
1031
 
845
- scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
1032
+ await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
846
1033
 
847
1034
  return ctx.output.emitSuccess({
848
1035
  command: ctx.spec.command_path.join(' '),
@@ -858,14 +1045,20 @@ async function appsInitHandler(ctx) {
858
1045
  }
859
1046
 
860
1047
  async function appsScaffoldsListHandler(ctx) {
861
- const scaffolds = loadScaffoldCatalog();
1048
+ const searchTerm = ctx.options.search || null;
1049
+ const catalog = await loadScaffoldCatalog();
1050
+ const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
1051
+ const registry = scaffoldRegistryLabel();
1052
+ const emptyMessage = searchTerm
1053
+ ? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
1054
+ : `No published scaffolds found in ${registry}.`;
862
1055
  return ctx.output.emitSuccess({
863
1056
  command: ctx.spec.command_path.join(' '),
864
- data: { scaffolds },
1057
+ data: { scaffolds, registry, search: searchTerm },
865
1058
  humanSummary: scaffolds.length
866
- ? `Found ${scaffolds.length} bundled scaffolds`
867
- : 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
868
- renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
1059
+ ? `Found ${scaffolds.length} published scaffolds in ${registry}`
1060
+ : emptyMessage,
1061
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
869
1062
  });
870
1063
  }
871
1064
 
@@ -895,7 +1088,7 @@ async function appsCreateHandler(ctx) {
895
1088
  writeLinkedState(projectDir, {
896
1089
  app_id: app.id,
897
1090
  linked_at: new Date().toISOString(),
898
- });
1091
+ }, linkedStateProfileKey(ctx.runtime));
899
1092
  }
900
1093
 
901
1094
  return ctx.output.emitSuccess({
@@ -918,7 +1111,94 @@ async function appsCreateHandler(ctx) {
918
1111
 
919
1112
  async function appsDevHandler(ctx) {
920
1113
  const rootDir = resolveProjectDir(ctx.args.dir || '.');
921
- const appDirs = discoverAppDirs(rootDir);
1114
+ const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
1115
+ let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
1116
+ const discoveredAppDirs = [...appDirs];
1117
+ const sessionsFilePath = getAppDevSessionsFile();
1118
+ let sharedBundleBaseUrls = null;
1119
+ const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
1120
+ if (sharedBundlesRaw) {
1121
+ let parsed;
1122
+ try {
1123
+ parsed = JSON.parse(sharedBundlesRaw);
1124
+ } catch {
1125
+ throw usageError('The shared app development bundle map is invalid.');
1126
+ }
1127
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1128
+ throw usageError('The shared app development bundle map must be an object.');
1129
+ }
1130
+ const sharedProjectDirs = Object.keys(parsed);
1131
+ const discoveredProjects = new Set(appDirs);
1132
+ for (const projectDir of sharedProjectDirs) {
1133
+ if (!discoveredProjects.has(projectDir)) {
1134
+ throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
1135
+ }
1136
+ }
1137
+ appDirs = sharedProjectDirs;
1138
+ sharedBundleBaseUrls = new Map();
1139
+ for (const projectDir of appDirs) {
1140
+ const value = parsed[projectDir];
1141
+ let url;
1142
+ try {
1143
+ url = new URL(String(value || ''));
1144
+ } catch {
1145
+ throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
1146
+ }
1147
+ if (
1148
+ url.protocol !== 'http:'
1149
+ || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
1150
+ ) {
1151
+ throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
1152
+ }
1153
+ sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
1154
+ }
1155
+ }
1156
+
1157
+ if (appDirs.length === 0) {
1158
+ return ctx.output.emitSuccess({
1159
+ command: ctx.spec.command_path.join(' '),
1160
+ data: {
1161
+ registered_root: skipRootRegistration ? null : rootDir,
1162
+ apps: [],
1163
+ watching_on_desktop_launch: true,
1164
+ },
1165
+ humanSummary: [
1166
+ skipRootRegistration
1167
+ ? 'No registered Notis apps are present yet.'
1168
+ : `Registered app development root: ${rootDir}`,
1169
+ 'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
1170
+ ].join('\n'),
1171
+ hints: [
1172
+ { command: 'notis apps roots list', reason: 'Show every persistent development root' },
1173
+ ],
1174
+ meta: { mutating: true },
1175
+ });
1176
+ }
1177
+
1178
+ if (!sharedBundleBaseUrls) {
1179
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1180
+ }
1181
+ let sourceHostLock = null;
1182
+ if (
1183
+ !sharedBundleBaseUrls
1184
+ && process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
1185
+ ) {
1186
+ sourceHostLock = tryAcquireAppDevHostLock({
1187
+ identity: '__mac_user__',
1188
+ apiBase: 'loopback-source',
1189
+ projectDir: SHARED_APP_DEV_HOST_KEY,
1190
+ });
1191
+ if (!sourceHostLock) {
1192
+ const startedAt = Date.now();
1193
+ while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
1194
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
1195
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1196
+ }
1197
+ if (!sharedBundleBaseUrls) {
1198
+ throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
1199
+ }
1200
+ }
1201
+ }
922
1202
 
923
1203
  for (const projectDir of appDirs) {
924
1204
  const problems = detectProjectProblems(projectDir);
@@ -927,9 +1207,19 @@ async function appsDevHandler(ctx) {
927
1207
  }
928
1208
  }
929
1209
 
930
- const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
931
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
932
- throw usageError('Port must be between 1 and 65535.');
1210
+ let port = null;
1211
+ if (sharedBundleBaseUrls) {
1212
+ // The source host owns the only loopback listener and build watchers.
1213
+ } else if (ctx.options.port) {
1214
+ port = Number.parseInt(ctx.options.port, 10);
1215
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1216
+ throw usageError('Port must be between 1 and 65535.');
1217
+ }
1218
+ } else {
1219
+ // Concurrent sessions must not fight over the default port: the desktop
1220
+ // auto-starts every known project at launch, so only the first one can
1221
+ // have 5173 and the rest fall back to an ephemeral port.
1222
+ port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
933
1223
  }
934
1224
 
935
1225
  const mode = getCliMode();
@@ -938,17 +1228,39 @@ async function appsDevHandler(ctx) {
938
1228
  throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
939
1229
  }
940
1230
  const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
941
- const sessionsFilePath = getAppDevSessionsFile(
942
- ctx.runtime.worktreeRuntime?.app_dev_sessions_file,
943
- );
944
- const desktopScheme = resolveDevelopmentDesktopScheme(
945
- process.env,
946
- ctx.runtime.worktreeRuntime,
947
- );
948
- const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
949
- const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
950
1231
  const sessionId = randomUUID();
951
- const useInstalledDatabases = Boolean(ctx.options?.liveData);
1232
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1233
+ const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
1234
+ const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
1235
+ ? `cli.${process.pid}.${sessionId}`
1236
+ : null;
1237
+ let manualConsumerTimer = null;
1238
+ if (manualConsumerInstanceId) {
1239
+ const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
1240
+ instanceId: manualConsumerInstanceId,
1241
+ userId: identity,
1242
+ apiBase,
1243
+ pid: process.pid,
1244
+ });
1245
+ heartbeatManualConsumer();
1246
+ manualConsumerTimer = setInterval(() => {
1247
+ try {
1248
+ heartbeatManualConsumer();
1249
+ } catch (error) {
1250
+ const message = error instanceof Error ? error.message : String(error);
1251
+ process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
1252
+ }
1253
+ }, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
1254
+ }
1255
+ // One app, one dataset: a dev session runs the app from local source against
1256
+ // the same databases the installed app uses, the way `npm run dev` serves the
1257
+ // same database as the deployed site. `--scratch` is the marked case; the
1258
+ // old `--live-data` flag now names the default and is kept as a no-op so
1259
+ // muscle memory and scripts keep working.
1260
+ if (ctx.options?.scratch && ctx.options?.liveData) {
1261
+ throw usageError('--scratch and --live-data contradict each other; pass at most one.');
1262
+ }
1263
+ const useInstalledDatabases = !ctx.options?.scratch;
952
1264
 
953
1265
  const candidates = [];
954
1266
  for (const projectDir of appDirs) {
@@ -956,147 +1268,206 @@ async function appsDevHandler(ctx) {
956
1268
  if (!appConfig.name) {
957
1269
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
958
1270
  }
959
- const devSlug = buildDevInstallSlug(appConfig.name);
1271
+ const devSlug = buildDevInstallSlug(appConfig);
960
1272
  if (!devSlug) {
961
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
1273
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
962
1274
  }
1275
+ candidates.push({ appConfig, devSlug, projectDir });
1276
+ }
1277
+
1278
+ const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
1279
+ const canonicalCandidates = [];
1280
+ for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
963
1281
  // Sequential on purpose: the shell-consent prompt must never interleave
964
1282
  // with another app's, and the ensure fan-out below runs in parallel.
965
1283
  const approvedCapabilities = await resolveCloudShellConsent({
966
1284
  appConfig,
967
1285
  projectDir,
968
1286
  grantCloudShell: Boolean(ctx.options?.grantCloudShell),
1287
+ profileKey,
1288
+ });
1289
+ canonicalCandidates.push({
1290
+ appConfig,
1291
+ devSlug,
1292
+ projectDir,
1293
+ approvedCapabilities,
1294
+ mountNonce: randomUUID(),
969
1295
  });
970
- candidates.push({ appConfig, devSlug, projectDir, approvedCapabilities });
971
1296
  }
972
1297
 
973
- const seenSlugs = new Map();
974
- for (const app of candidates) {
975
- if (seenSlugs.has(app.devSlug)) {
976
- throw usageError(
977
- `Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
978
- );
1298
+ // Start every build watcher before the backend registrations finish. A
1299
+ // machine with several apps must not show an empty sidebar for minutes while
1300
+ // unrelated registrations run sequentially. Each successful registration is
1301
+ // published below as soon as its own last-known bundle is ready.
1302
+ const devServer = sharedBundleBaseUrls
1303
+ ? {
1304
+ close: async () => {},
1305
+ isBundleReady: () => true,
1306
+ updateApp: () => {},
1307
+ waitForBundle: async () => {},
1308
+ }
1309
+ : await startAppDevServer({
1310
+ apps: canonicalCandidates.map((app) => ({
1311
+ slug: app.devSlug,
1312
+ projectDir: app.projectDir,
1313
+ appId: null,
1314
+ targetAppId: null,
1315
+ userId: identity,
1316
+ profileKey,
1317
+ sessionId,
1318
+ mountNonce: app.mountNonce,
1319
+ })),
1320
+ port,
1321
+ sessionsFilePath,
1322
+ });
1323
+
1324
+ let heartbeatTimer = setInterval(() => {
1325
+ try {
1326
+ heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
1327
+ } catch (error) {
1328
+ const message = error instanceof Error ? error.message : String(error);
1329
+ process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
979
1330
  }
980
- seenSlugs.set(app.devSlug, app);
981
- }
1331
+ }, DEV_HEARTBEAT_INTERVAL_MS);
982
1332
 
983
1333
  const registrationStartedAt = process.hrtime.bigint();
984
- const registered = await Promise.all(
985
- candidates.map(async ({ appConfig, devSlug, projectDir, approvedCapabilities }) => {
986
- const appStartedAt = process.hrtime.bigint();
987
- try {
988
- return await ensureDevInstall({
989
- ctx,
990
- appConfig,
991
- projectDir,
992
- idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
993
- useInstalledDatabases,
994
- approvedCapabilities,
995
- });
996
- } finally {
997
- logAppsTiming('ensure-dev-install', {
998
- slug: devSlug,
999
- ms: timingMs(appStartedAt).toFixed(1),
1000
- });
1001
- }
1002
- }),
1003
- );
1004
- logAppsTiming('ensure-dev-install:all', {
1005
- apps: registered.length,
1006
- ms: timingMs(registrationStartedAt).toFixed(1),
1007
- });
1008
-
1009
- const apps = registered.map((app) => {
1010
- const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
1011
- return {
1012
- ...app,
1013
- bundleBaseUrl,
1014
- mountNonce: randomUUID(),
1015
- appHref: buildDevelopmentAppHref({
1016
- appSlug: app.slug,
1334
+ const apps = [];
1335
+ const registrationWarnings = [];
1336
+ let firstRegistrationError = null;
1337
+ // The local development backend is deliberately a single worker. Register
1338
+ // apps sequentially so a large root cannot queue many long mutations behind
1339
+ // one another. Unlike the old all-or-nothing startup, publish each healthy
1340
+ // app immediately while later registrations continue in the background.
1341
+ for (const {
1342
+ appConfig,
1343
+ devSlug,
1344
+ projectDir,
1345
+ approvedCapabilities,
1346
+ mountNonce,
1347
+ } of canonicalCandidates) {
1348
+ const appStartedAt = process.hrtime.bigint();
1349
+ try {
1350
+ const registeredApp = await ensureDevInstall({
1351
+ ctx,
1352
+ appConfig,
1353
+ projectDir,
1354
+ idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
1355
+ useInstalledDatabases,
1356
+ approvedCapabilities,
1357
+ });
1358
+ const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
1359
+ || `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
1360
+ const app = {
1361
+ ...registeredApp,
1362
+ bundleBaseUrl,
1363
+ mountNonce,
1364
+ appHref: buildDevelopmentAppHref({
1365
+ appSlug: registeredApp.slug,
1366
+ appId: registeredApp.appId,
1367
+ devSlug: registeredApp.devSlug,
1368
+ targetAppId: registeredApp.targetAppId,
1369
+ targetAppSlug: registeredApp.targetAppSlug,
1370
+ manifest: registeredApp.manifest,
1371
+ }),
1372
+ };
1373
+ devServer.updateApp(app.devSlug, {
1374
+ appId: app.appId,
1375
+ targetAppId: app.targetAppId || null,
1376
+ });
1377
+ const now = new Date().toISOString();
1378
+ const sessionRecord = {
1379
+ sessionId,
1380
+ hostPid: process.pid,
1381
+ sourceHost: !sharedBundleBaseUrls,
1382
+ bundleReady: devServer.isBundleReady(app.devSlug),
1383
+ ...(!sharedBundleBaseUrls ? {
1384
+ discoveredProjects: discoveredAppDirs,
1385
+ canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
1386
+ } : {}),
1387
+ userId: identity,
1388
+ apiBase,
1389
+ profileKey,
1017
1390
  appId: app.appId,
1391
+ targetAppId: app.targetAppId || undefined,
1392
+ mountNonce: app.mountNonce,
1018
1393
  devSlug: app.devSlug,
1019
- targetAppId: app.targetAppId,
1020
- targetAppSlug: app.targetAppSlug,
1021
- manifest: app.manifest,
1022
- }),
1023
- };
1024
- });
1025
- const developmentTabUrl = buildDevelopmentDesktopUrl(
1026
- apps[0]?.appHref,
1027
- desktopScheme,
1028
- );
1029
- const desktopWakeUrl = buildDevelopmentDesktopUrl('/manage', desktopScheme);
1030
- const mountedDevelopmentTabUrl = buildMountedDevelopmentDesktopUrl(
1031
- apps[0]?.appHref,
1032
- desktopScheme,
1033
- sessionId,
1034
- );
1035
- const warnings = [...databaseMaterializationWarnings(apps), ...liveDataWarnings(apps)];
1036
-
1037
- const devServer = await startAppDevServer({
1038
- apps: apps.map((app) => ({
1039
- slug: app.devSlug,
1040
- projectDir: app.projectDir,
1041
- appId: app.appId,
1042
- targetAppId: app.targetAppId || null,
1043
- userId: identity,
1044
- })),
1045
- port,
1046
- sessionsFilePath,
1047
- });
1048
-
1049
- try {
1050
- const now = new Date().toISOString();
1051
- upsertAppDevSessions(apps.map((app) => ({
1052
- sessionId,
1053
- userId: identity,
1054
- apiBase,
1055
- appId: app.appId,
1056
- targetAppId: app.targetAppId || undefined,
1057
- mountNonce: app.mountNonce,
1058
- devSlug: app.devSlug,
1059
- bundleBaseUrl: app.bundleBaseUrl,
1060
- projectDir: app.projectDir,
1061
- startedAt: now,
1062
- lastHeartbeatAt: now,
1063
- desktopAppName,
1064
- })), sessionsFilePath);
1065
- } catch (error) {
1394
+ bundleBaseUrl: app.bundleBaseUrl,
1395
+ projectDir: app.projectDir,
1396
+ startedAt: now,
1397
+ lastHeartbeatAt: now,
1398
+ };
1399
+ upsertAppDevSessions(sessionRecord, sessionsFilePath);
1400
+ apps.push(app);
1401
+ if (!sessionRecord.bundleReady) {
1402
+ void devServer.waitForBundle(app.devSlug).then(() => {
1403
+ upsertAppDevSessions({
1404
+ ...sessionRecord,
1405
+ bundleReady: true,
1406
+ lastHeartbeatAt: new Date().toISOString(),
1407
+ }, sessionsFilePath);
1408
+ }).catch((error) => {
1409
+ const message = error instanceof Error ? error.message : String(error);
1410
+ process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
1411
+ });
1412
+ }
1413
+ } catch (error) {
1414
+ firstRegistrationError ||= error;
1415
+ const message = error instanceof Error ? error.message : String(error);
1416
+ registrationWarnings.push(
1417
+ `${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
1418
+ );
1419
+ process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
1420
+ } finally {
1421
+ logAppsTiming('ensure-dev-install', {
1422
+ slug: devSlug,
1423
+ ms: timingMs(appStartedAt).toFixed(1),
1424
+ });
1425
+ }
1426
+ }
1427
+ if (apps.length === 0) {
1428
+ clearInterval(heartbeatTimer);
1429
+ heartbeatTimer = null;
1066
1430
  try {
1067
1431
  await devServer.close();
1068
1432
  } catch {
1069
- // ignore cleanup failure on registry errors
1433
+ // preserve the original registration error
1070
1434
  }
1071
- throw error;
1435
+ if (sourceHostLock) {
1436
+ releaseAppDevHostLock(sourceHostLock);
1437
+ sourceHostLock = null;
1438
+ }
1439
+ if (manualConsumerTimer) clearInterval(manualConsumerTimer);
1440
+ if (manualConsumerInstanceId) {
1441
+ try {
1442
+ removeAppDevConsumer(manualConsumerInstanceId);
1443
+ } catch {
1444
+ // Preserve the registration error; a failed lease cleanup expires.
1445
+ }
1446
+ }
1447
+ throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
1072
1448
  }
1449
+ logAppsTiming('ensure-dev-install:all', {
1450
+ apps: apps.length,
1451
+ ms: timingMs(registrationStartedAt).toFixed(1),
1452
+ });
1073
1453
 
1074
- let heartbeatTimer = setInterval(() => {
1075
- try {
1076
- heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
1077
- } catch (error) {
1078
- const message = error instanceof Error ? error.message : String(error);
1079
- process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
1080
- }
1081
- }, DEV_HEARTBEAT_INTERVAL_MS);
1454
+ const warnings = [
1455
+ ...canonicalSelection.warnings,
1456
+ ...registrationWarnings,
1457
+ ...databaseMaterializationWarnings(apps),
1458
+ ...liveDataWarnings(apps),
1459
+ ];
1082
1460
 
1083
- const expectedMountAcknowledgements = apps.map((app) => ({
1084
- sessionId,
1085
- appId: app.appId,
1086
- devSlug: app.devSlug,
1087
- mountNonce: app.mountNonce,
1088
- }));
1461
+ let consumerTimer = null;
1089
1462
 
1090
1463
  ctx.output.emitSuccess({
1091
1464
  command: ctx.spec.command_path.join(' '),
1092
1465
  data: {
1093
1466
  mode,
1094
1467
  api_base: apiBase,
1095
- development_url: developmentTabUrl,
1096
1468
  session_id: sessionId,
1097
1469
  mount_status: 'serving',
1098
- render_status: 'waiting_for_route',
1099
- desktop_target: desktopAppName,
1470
+ source_host: !sharedBundleBaseUrls,
1100
1471
  identity,
1101
1472
  apps: apps.map((app) => ({
1102
1473
  slug: app.devSlug,
@@ -1115,76 +1486,22 @@ async function appsDevHandler(ctx) {
1115
1486
  warnings,
1116
1487
  humanSummary: [
1117
1488
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
1118
- `Target desktop: ${desktopAppName}`,
1489
+ ...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
1490
+ `Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
1119
1491
  ...(useInstalledDatabases
1120
1492
  ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1121
1493
  : []),
1122
1494
  '',
1123
- `Open in desktop: ${developmentTabUrl}`,
1124
- '',
1125
1495
  ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
1126
1496
  '',
1127
- `Serving locally; waiting for ${desktopAppName} to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
1497
+ sharedBundleBaseUrls
1498
+ ? `Mounted ${apps.length} shared source app${apps.length === 1 ? '' : 's'} for this authenticated environment.`
1499
+ : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
1128
1500
  '',
1129
1501
  'Press Ctrl-C to stop.',
1130
1502
  ].join('\n'),
1131
1503
  });
1132
1504
 
1133
- if (shouldOpenDevelopmentTab(ctx.options)) {
1134
- openInBrowser(desktopWakeUrl, {
1135
- appName: desktopAppName,
1136
- bundleId: desktopBundleId,
1137
- scheme: desktopScheme,
1138
- });
1139
- }
1140
-
1141
- void (async () => {
1142
- let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
1143
- sessionsFilePath,
1144
- });
1145
- if (!result.mounted) {
1146
- process.stderr.write(
1147
- `[notis apps dev] Serving locally, but ${desktopAppName} has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open ${desktopAppName}.\n`,
1148
- );
1149
- }
1150
- while (!result.mounted) {
1151
- result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
1152
- sessionsFilePath,
1153
- timeoutMs: 60_000,
1154
- pollIntervalMs: 250,
1155
- });
1156
- }
1157
- process.stderr.write(
1158
- `[notis apps dev] Mounted in ${desktopAppName}: ${apps.map((app) => app.name).join(', ')}.\n`,
1159
- );
1160
- if (shouldOpenDevelopmentTab(ctx.options)) {
1161
- openInBrowser(mountedDevelopmentTabUrl, {
1162
- appName: desktopAppName,
1163
- bundleId: desktopBundleId,
1164
- scheme: desktopScheme,
1165
- });
1166
- }
1167
-
1168
- const firstAppRender = expectedMountAcknowledgements.slice(0, 1);
1169
- let renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1170
- sessionsFilePath,
1171
- timeoutMs: 0,
1172
- });
1173
- while (!renderResult.mounted) {
1174
- renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1175
- sessionsFilePath,
1176
- timeoutMs: 60_000,
1177
- pollIntervalMs: 250,
1178
- });
1179
- }
1180
- process.stderr.write(
1181
- `[notis apps dev] Rendered in ${desktopAppName}: ${apps[0].name}.\n`,
1182
- );
1183
- })().catch((error) => {
1184
- const message = error instanceof Error ? error.message : String(error);
1185
- process.stderr.write(`[notis apps dev] mount acknowledgement check failed: ${message}\n`);
1186
- });
1187
-
1188
1505
  let shuttingDown = false;
1189
1506
  const shutdown = async (signal) => {
1190
1507
  if (shuttingDown) return;
@@ -1194,6 +1511,21 @@ async function appsDevHandler(ctx) {
1194
1511
  clearInterval(heartbeatTimer);
1195
1512
  heartbeatTimer = null;
1196
1513
  }
1514
+ if (consumerTimer) {
1515
+ clearInterval(consumerTimer);
1516
+ consumerTimer = null;
1517
+ }
1518
+ if (manualConsumerTimer) {
1519
+ clearInterval(manualConsumerTimer);
1520
+ manualConsumerTimer = null;
1521
+ }
1522
+ if (manualConsumerInstanceId) {
1523
+ try {
1524
+ removeAppDevConsumer(manualConsumerInstanceId);
1525
+ } catch {
1526
+ // A crashed CLI lease expires automatically after the heartbeat window.
1527
+ }
1528
+ }
1197
1529
  try {
1198
1530
  removeAppDevSession(sessionId, sessionsFilePath);
1199
1531
  } catch {
@@ -1204,6 +1536,10 @@ async function appsDevHandler(ctx) {
1204
1536
  } catch {
1205
1537
  // ignore cleanup failures during shutdown
1206
1538
  }
1539
+ if (sourceHostLock) {
1540
+ releaseAppDevHostLock(sourceHostLock);
1541
+ sourceHostLock = null;
1542
+ }
1207
1543
  process.exit(EXIT_CODES.ok);
1208
1544
  };
1209
1545
 
@@ -1214,11 +1550,53 @@ async function appsDevHandler(ctx) {
1214
1550
  void shutdown('SIGTERM');
1215
1551
  });
1216
1552
 
1553
+ if (consumerMode === 'machine' || consumerMode === 'environment') {
1554
+ consumerTimer = setInterval(() => {
1555
+ if (!hasAppDevConsumer(readAppDevConsumers(), {
1556
+ mode: consumerMode,
1557
+ userId: identity,
1558
+ apiBase,
1559
+ })) {
1560
+ void shutdown('last consumer detached');
1561
+ }
1562
+ }, DEV_CONSUMER_POLL_INTERVAL_MS);
1563
+ }
1564
+
1217
1565
  await new Promise(() => {});
1218
1566
 
1219
1567
  return EXIT_CODES.ok;
1220
1568
  }
1221
1569
 
1570
+ async function appsRootsListHandler(ctx) {
1571
+ const registry = readAppDevRoots();
1572
+ return ctx.output.emitSuccess({
1573
+ command: ctx.spec.command_path.join(' '),
1574
+ data: { roots: registry.roots },
1575
+ humanSummary: appDevRootsTable(registry.roots),
1576
+ meta: { mutating: false },
1577
+ });
1578
+ }
1579
+
1580
+ async function appsRootsRemoveHandler(ctx) {
1581
+ const rootDir = resolve(ctx.args.dir);
1582
+ const result = removeAppDevRoot(rootDir);
1583
+ return ctx.output.emitSuccess({
1584
+ command: ctx.spec.command_path.join(' '),
1585
+ data: {
1586
+ removed: result.removed,
1587
+ root: result.path,
1588
+ roots: result.registry.roots,
1589
+ },
1590
+ humanSummary: result.removed
1591
+ ? `Removed app development root: ${result.path}`
1592
+ : `App development root was not registered: ${result.path}`,
1593
+ hints: result.removed
1594
+ ? []
1595
+ : [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
1596
+ meta: { mutating: result.removed },
1597
+ });
1598
+ }
1599
+
1222
1600
  async function appsBuildHandler(ctx) {
1223
1601
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1224
1602
  const problems = detectProjectProblems(projectDir);
@@ -1260,7 +1638,7 @@ async function appsVerifyHandler(ctx) {
1260
1638
  if (!ctx.runtime.jwt) {
1261
1639
  throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
1262
1640
  }
1263
- linkedState = readLinkedState(projectDir);
1641
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1264
1642
  if (!linkedState?.app_id) {
1265
1643
  throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
1266
1644
  }
@@ -1493,7 +1871,7 @@ async function appsScreenshotHandler(ctx) {
1493
1871
  if (!ctx.runtime.jwt) {
1494
1872
  throw usageError('Live mode requires CLI auth. Run notis login and retry.');
1495
1873
  }
1496
- linkedState = readLinkedState(projectDir);
1874
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1497
1875
  if (!linkedState?.app_id) {
1498
1876
  throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1499
1877
  }
@@ -1692,10 +2070,15 @@ async function appsScreenshotHandler(ctx) {
1692
2070
  }
1693
2071
  }
1694
2072
 
1695
- export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
2073
+ export function buildLinkedAppState(
2074
+ existingState,
2075
+ appId,
2076
+ linkedAt = new Date().toISOString(),
2077
+ ) {
2078
+ const sameIdPromotion = existingState?.dev_app_id === appId;
1696
2079
  return {
1697
- ...(existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
1698
- ...(existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
2080
+ ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2081
+ ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
1699
2082
  app_id: appId,
1700
2083
  linked_at: linkedAt,
1701
2084
  };
@@ -1709,7 +2092,12 @@ async function appsLinkHandler(ctx) {
1709
2092
 
1710
2093
  writeLinkedState(
1711
2094
  projectDir,
1712
- buildLinkedAppState(readLinkedState(projectDir), appId),
2095
+ buildLinkedAppState(
2096
+ readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2097
+ appId,
2098
+ new Date().toISOString(),
2099
+ ),
2100
+ linkedStateProfileKey(ctx.runtime),
1713
2101
  );
1714
2102
 
1715
2103
  return ctx.output.emitSuccess({
@@ -1736,9 +2124,11 @@ async function appsPullHandler(ctx) {
1736
2124
  throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1737
2125
  }
1738
2126
  const app = result.payload?.app || {};
1739
- const defaultDir = app.slug || slugify(app.name) || appId;
1740
- const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
1741
- const version = ctx.options.version || 'latest';
2127
+ const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
2128
+ const targetDir = ctx.args.dir
2129
+ ? resolveProjectDir(ctx.args.dir)
2130
+ : defaultAppProjectDir(defaultDir);
2131
+ const version = ctx.options.sourceVersion || 'latest';
1742
2132
 
1743
2133
  const pulled = await pullAppSource({
1744
2134
  apiBase: ctx.runtime.apiBase,
@@ -1747,6 +2137,7 @@ async function appsPullHandler(ctx) {
1747
2137
  targetDir,
1748
2138
  version,
1749
2139
  force: Boolean(ctx.options.force),
2140
+ profileKey: linkedStateProfileKey(ctx.runtime),
1750
2141
  });
1751
2142
 
1752
2143
  const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
@@ -1761,7 +2152,7 @@ async function appsPullHandler(ctx) {
1761
2152
  });
1762
2153
  }
1763
2154
 
1764
- function updateLinkedDeployState(projectDir, linkedState, appId, version) {
2155
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
1765
2156
  if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1766
2157
  return;
1767
2158
  }
@@ -1771,14 +2162,29 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
1771
2162
  version,
1772
2163
  linked_at: linkedState.linked_at || new Date().toISOString(),
1773
2164
  deployed_at: new Date().toISOString(),
1774
- });
2165
+ }, profileKey);
2166
+ }
2167
+
2168
+ // Resolve first deploy without mutating. Promotion is part of the final
2169
+ // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2170
+ // cannot leave an empty installed app behind.
2171
+ function resolveDeployTarget(ctx, projectDir) {
2172
+ if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2173
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2174
+ const state = readLinkedState(projectDir, profileKey);
2175
+ if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2176
+ if (!state?.dev_app_id) {
2177
+ return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2178
+ }
2179
+ return { appId: state.dev_app_id, needsPromotion: true };
1775
2180
  }
1776
2181
 
1777
2182
  async function appsDeployHandler(ctx) {
1778
2183
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1779
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2184
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2185
+ const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
1780
2186
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1781
- const linkedState = readLinkedState(projectDir);
2187
+ const linkedState = readLinkedState(projectDir, profileKey);
1782
2188
  const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1783
2189
  ? linkedState.version
1784
2190
  : undefined;
@@ -1792,9 +2198,12 @@ async function appsDeployHandler(ctx) {
1792
2198
 
1793
2199
  // Direct deploy mode: upload to Supabase storage directly
1794
2200
  if (ctx.options.direct) {
2201
+ if (needsPromotion) {
2202
+ throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
2203
+ }
1795
2204
  await assertDirectDeployAccess(ctx.runtime, appId);
1796
2205
  const { version } = await directDeploy(projectDir, appId);
1797
- updateLinkedDeployState(projectDir, linkedState, appId, version);
2206
+ updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
1798
2207
  return ctx.output.emitSuccess({
1799
2208
  command: ctx.spec.command_path.join(' '),
1800
2209
  data: { app_id: appId, version, mode: 'direct' },
@@ -1827,6 +2236,7 @@ async function appsDeployHandler(ctx) {
1827
2236
  manifest,
1828
2237
  ...appRowFieldsFromManifest(manifest),
1829
2238
  ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2239
+ ...(needsPromotion ? { promote_dev_app: true } : {}),
1830
2240
  },
1831
2241
  mutating: true,
1832
2242
  idempotencyKey,
@@ -1868,7 +2278,35 @@ async function appsDeployHandler(ctx) {
1868
2278
  });
1869
2279
  }
1870
2280
 
1871
- updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion);
2281
+ if (needsPromotion) {
2282
+ const now = new Date().toISOString();
2283
+ writeLinkedState(projectDir, {
2284
+ ...linkedState,
2285
+ app_id: appId,
2286
+ linked_at: linkedState?.linked_at || now,
2287
+ version: deployedVersion,
2288
+ deployed_at: now,
2289
+ dev_app_id: undefined,
2290
+ dev_linked_at: undefined,
2291
+ }, profileKey);
2292
+ try {
2293
+ linkAppDevSessionTarget(
2294
+ { appId, targetAppId: appId },
2295
+ getAppDevSessionsFile(),
2296
+ );
2297
+ } catch (error) {
2298
+ ctx.output.emitProgress({
2299
+ phase: 'warning',
2300
+ message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
2301
+ });
2302
+ }
2303
+ ctx.output.emitProgress({
2304
+ phase: 'promote',
2305
+ message: `Promoted development app to installed app ${appId}`,
2306
+ });
2307
+ } else {
2308
+ updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
2309
+ }
1872
2310
  return ctx.output.emitSuccess({
1873
2311
  command: ctx.spec.command_path.join(' '),
1874
2312
  data: {
@@ -1891,7 +2329,7 @@ async function appsDuplicateHandler(ctx) {
1891
2329
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1892
2330
  // Either target an app explicitly, or duplicate whatever this project is
1893
2331
  // linked to, so `notis apps duplicate` works from inside a project.
1894
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2332
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
1895
2333
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1896
2334
 
1897
2335
  const copyDocuments = ctx.options.copyDocuments || 'declared';
@@ -1957,8 +2395,8 @@ async function appsPublishHandler(ctx) {
1957
2395
  }
1958
2396
 
1959
2397
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1960
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
1961
- const linkedState = readLinkedState(projectDir);
2398
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
2399
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1962
2400
  const appConfig = await loadAppConfig(projectDir);
1963
2401
  const readiness = inspectListingReadiness(projectDir, appConfig);
1964
2402
  if (!readiness.ready) {
@@ -2051,7 +2489,7 @@ async function appsDoctorHandler(ctx) {
2051
2489
  }
2052
2490
  }
2053
2491
 
2054
- const linkedState = readLinkedState(projectDir);
2492
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
2055
2493
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
2056
2494
 
2057
2495
  return ctx.output.emitSuccess({
@@ -2094,21 +2532,21 @@ export const appsCommandSpecs = [
2094
2532
  {
2095
2533
  command_path: ['apps', 'init'],
2096
2534
  summary: 'Scaffold a new Notis app project.',
2097
- when_to_use: 'Start a new Notis app. Use --from with a bundled scaffold when one is close to the desired app; otherwise creates the bare Vite + React project.',
2535
+ when_to_use: 'Start a new Notis app. Use --from with a published Store app when one is close to the desired app; otherwise creates the bare Vite + React project.',
2098
2536
  args_schema: {
2099
2537
  arguments: [
2100
2538
  { token: '<name>', description: 'Display name for the app.' },
2101
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
2539
+ { token: '[dir]', key: 'dir', description: 'Target directory, resolved from the current directory. Defaults to ~/.notis/apps/<slug>; pass a path to place the project elsewhere, such as a tracked git repo or an existing monorepo.' },
2102
2540
  ],
2103
2541
  options: [
2104
- { flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
2542
+ { flags: '--from <slug>', description: 'Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.' },
2105
2543
  ],
2106
2544
  },
2107
2545
  examples: [
2108
2546
  'notis apps scaffolds list',
2109
2547
  'notis apps init "Mind the Flo"',
2110
- 'notis apps init "My CRM" --from notis-database',
2111
- 'notis apps init "My App" ./my-app',
2548
+ 'notis apps init "My CRM" --from databases',
2549
+ 'notis apps init "My App" ~/code/my-app',
2112
2550
  ],
2113
2551
  mutates: true,
2114
2552
  idempotent: false,
@@ -2118,10 +2556,19 @@ export const appsCommandSpecs = [
2118
2556
  },
2119
2557
  {
2120
2558
  command_path: ['apps', 'scaffolds', 'list'],
2121
- summary: 'List bundled Notis app scaffolds.',
2122
- when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
2123
- args_schema: { arguments: [], options: [] },
2124
- examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
2559
+ summary: 'List published Store apps available as scaffolds.',
2560
+ when_to_use: 'Discover published Store apps to start from before creating a new app. Every app published to the public Store is automatically a scaffold; use --search to narrow the catalog.',
2561
+ args_schema: {
2562
+ arguments: [],
2563
+ options: [
2564
+ { flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
2565
+ ],
2566
+ },
2567
+ examples: [
2568
+ 'notis apps scaffolds list',
2569
+ 'notis apps scaffolds list --search journal',
2570
+ 'notis apps init "My App" --from databases',
2571
+ ],
2125
2572
  mutates: false,
2126
2573
  idempotent: true,
2127
2574
  require_auth: false,
@@ -2150,21 +2597,26 @@ export const appsCommandSpecs = [
2150
2597
  },
2151
2598
  {
2152
2599
  command_path: ['apps', 'dev'],
2153
- summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
2600
+ summary: 'Register a development root and connect its apps to the shared local development host.',
2154
2601
  when_to_use:
2155
- 'Run this inside a single app or a monorepo root with apps/<name>/notis.config.ts. It discovers every app, starts the local bundle server, registers desktop-local dev sessions, and opens the Electron Portal to the local development app.',
2602
+ 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.',
2156
2603
  args_schema: {
2157
2604
  arguments: [
2158
2605
  { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
2159
2606
  ],
2160
2607
  options: [
2161
2608
  { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
2162
- { flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
2609
+ {
2610
+ flags: '--scratch',
2611
+ description:
2612
+ 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2613
+ + "instead of the installed app's resources. For fixture work and destructive experiments.",
2614
+ },
2163
2615
  {
2164
2616
  flags: '--live-data',
2165
2617
  description:
2166
- "Read and write the installed app's real databases instead of empty dev copies. "
2167
- + 'Applies to this session only; warns and falls back when the app is not installed yet.',
2618
+ 'Deprecated: using the installed app\'s real resources is now the default. '
2619
+ + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2168
2620
  },
2169
2621
  {
2170
2622
  flags: '--grant-cloud-shell',
@@ -2178,7 +2630,7 @@ export const appsCommandSpecs = [
2178
2630
  'notis apps dev',
2179
2631
  'notis apps dev ./my-app',
2180
2632
  'notis apps dev ./workspace --port 5200',
2181
- 'notis apps dev --live-data # iterate on a view over the installed app\'s real rows',
2633
+ 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
2182
2634
  ],
2183
2635
  mutates: true,
2184
2636
  idempotent: true,
@@ -2186,6 +2638,35 @@ export const appsCommandSpecs = [
2186
2638
  backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2187
2639
  handler: appsDevHandler,
2188
2640
  },
2641
+ {
2642
+ command_path: ['apps', 'roots', 'list'],
2643
+ summary: 'List persistent machine-local Notis app development roots.',
2644
+ when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2645
+ args_schema: { arguments: [], options: [] },
2646
+ examples: ['notis apps roots list'],
2647
+ mutates: false,
2648
+ idempotent: true,
2649
+ require_auth: false,
2650
+ backend_call: { type: 'local', name: 'list_app_development_roots' },
2651
+ handler: appsRootsListHandler,
2652
+ },
2653
+ {
2654
+ command_path: ['apps', 'roots', 'remove'],
2655
+ summary: 'Stop watching a registered Notis app development root.',
2656
+ when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2657
+ args_schema: {
2658
+ arguments: [
2659
+ { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2660
+ ],
2661
+ options: [],
2662
+ },
2663
+ examples: ['notis apps roots remove ./old-apps'],
2664
+ mutates: true,
2665
+ idempotent: true,
2666
+ require_auth: false,
2667
+ backend_call: { type: 'local', name: 'remove_app_development_root' },
2668
+ handler: appsRootsRemoveHandler,
2669
+ },
2189
2670
  {
2190
2671
  command_path: ['apps', 'build'],
2191
2672
  summary: 'Build and package the app into .notis/output/.',
@@ -2298,14 +2779,17 @@ export const appsCommandSpecs = [
2298
2779
  args_schema: {
2299
2780
  arguments: [
2300
2781
  { token: '<app-id>', description: 'Remote app ID to pull.' },
2301
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
2782
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
2302
2783
  ],
2303
2784
  options: [
2304
2785
  { flags: '--force', description: 'Overwrite a non-empty target directory.' },
2305
- { flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
2786
+ { flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
2306
2787
  ],
2307
2788
  },
2308
- examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
2789
+ examples: [
2790
+ 'notis apps pull abc123',
2791
+ 'notis apps pull abc123 ./my-app --force --source-version 3',
2792
+ ],
2309
2793
  mutates: true,
2310
2794
  idempotent: true,
2311
2795
  require_auth: true,
@@ -2316,7 +2800,7 @@ export const appsCommandSpecs = [
2316
2800
  command_path: ['apps', 'deploy'],
2317
2801
  summary: 'Build and upload the app to the linked Notis app.',
2318
2802
  when_to_use:
2319
- 'Ship the installed app to production for the linked user/team app. Requires a linked app (notis apps link). This command does not publish to the app store.',
2803
+ 'Ship the installed app to production for the linked user/team app. A project that has only a development app is promoted in place on first deploy: same app id, same databases, dev markers removed. This command does not publish to the app store.',
2320
2804
  args_schema: {
2321
2805
  arguments: [
2322
2806
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -2324,7 +2808,7 @@ export const appsCommandSpecs = [
2324
2808
  options: [
2325
2809
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
2326
2810
  { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
2327
- { flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
2811
+ { flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
2328
2812
  ],
2329
2813
  },
2330
2814
  examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],