@notis_ai/cli 0.2.14 → 0.2.16

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 (299) hide show
  1. package/README.md +83 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +20168 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +167 -72
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +86 -28
  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 +39 -18
  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 +1021 -424
  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-process-identity.js +111 -0
  25. package/src/runtime/app-dev-roots.js +284 -0
  26. package/src/runtime/app-dev-server.js +447 -29
  27. package/src/runtime/app-dev-sessions.js +105 -145
  28. package/src/runtime/app-platform.js +1314 -166
  29. package/src/runtime/app-registry-scaffolds.js +367 -0
  30. package/src/runtime/base-skills.d.ts +20 -0
  31. package/src/runtime/base-skills.js +167 -0
  32. package/src/runtime/login-listener.js +15 -0
  33. package/src/runtime/oauth.js +1506 -141
  34. package/src/runtime/output.js +7 -0
  35. package/src/runtime/ports.js +16 -0
  36. package/src/runtime/profiles.js +19 -16
  37. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  38. package/src/runtime/skill-sync/index.ts +644 -0
  39. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  40. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  41. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  42. package/src/runtime/skill-sync/types.ts +103 -0
  43. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  44. package/src/runtime/store-screenshot.js +6 -1
  45. package/src/runtime/sync-skills.d.ts +37 -0
  46. package/src/runtime/sync-skills.js +231 -0
  47. package/template/app/layout.tsx +5 -1
  48. package/template/notis.config.ts +1 -0
  49. package/template/packages/sdk/package.json +2 -1
  50. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  51. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  52. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +43 -119
  53. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  54. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  55. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  56. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  57. package/template/packages/sdk/src/config.ts +25 -0
  58. package/template/packages/sdk/src/documents.ts +7 -1
  59. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  60. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +726 -0
  61. package/template/packages/sdk/src/hooks/useHandover.ts +3 -0
  62. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  63. package/template/packages/sdk/src/hooks/useNotis.ts +3 -0
  64. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +7 -4
  65. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  66. package/template/packages/sdk/src/index.ts +47 -2
  67. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  68. package/template/packages/sdk/src/interactions/shortcuts.tsx +634 -0
  69. package/template/packages/sdk/src/interactions.ts +41 -0
  70. package/template/packages/sdk/src/provider.tsx +2 -1
  71. package/template/packages/sdk/src/runtime.ts +92 -4
  72. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  73. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  74. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  75. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  76. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  77. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  78. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  79. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  80. package/dist/scaffolds/notis-database/components.json +0 -20
  81. package/dist/scaffolds/notis-database/index.html +0 -12
  82. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  83. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  84. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  85. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  86. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  87. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  88. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  89. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  90. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  91. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  92. package/dist/scaffolds/notis-database/package.json +0 -32
  93. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  94. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  95. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  96. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  97. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  98. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  99. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -234
  100. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -250
  101. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  102. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  103. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  104. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  105. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +0 -75
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  115. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -100
  116. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  117. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -351
  118. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  119. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  120. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  121. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  122. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  123. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  124. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  125. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  126. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  127. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  128. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  129. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  130. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  131. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  132. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  133. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  134. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  135. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  136. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  137. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  138. package/dist/scaffolds/notis-journal/components.json +0 -20
  139. package/dist/scaffolds/notis-journal/index.html +0 -12
  140. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  141. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  142. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  148. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  149. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  150. package/dist/scaffolds/notis-journal/package.json +0 -34
  151. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  152. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  153. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -234
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -250
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +0 -75
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -100
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -351
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  178. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  179. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  180. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  181. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  182. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  183. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -199
  184. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  185. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  186. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  187. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  188. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  189. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  190. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  191. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  192. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  193. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  194. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  195. package/dist/scaffolds/notis-notes/components.json +0 -20
  196. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  197. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  203. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  204. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  205. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  206. package/dist/scaffolds/notis-notes/package.json +0 -35
  207. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -234
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -250
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +0 -75
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -100
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  231. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -351
  232. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  233. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  234. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  235. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  236. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  237. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  238. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  239. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  240. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  241. package/dist/scaffolds/notis-random/README.md +0 -33
  242. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  243. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  244. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  245. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  246. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  247. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  248. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  249. package/dist/scaffolds/notis-random/components.json +0 -20
  250. package/dist/scaffolds/notis-random/index.html +0 -12
  251. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  252. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  253. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  254. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  255. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  257. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  258. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  259. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  260. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  261. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  262. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  263. package/dist/scaffolds/notis-random/package.json +0 -36
  264. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  266. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  267. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  268. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  269. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  270. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -234
  271. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -250
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +0 -75
  279. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  280. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  281. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  282. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  283. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  284. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  285. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  286. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -100
  287. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  288. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -351
  289. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  290. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  291. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  292. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  293. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  294. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  295. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  296. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  297. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  298. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  299. 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, readFileSync, 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,25 @@ import {
34
34
  collectSourceFiles,
35
35
  resolveConfiguredAppSkills,
36
36
  normalizeAppCapabilities,
37
+ normalizeAppToolBindings,
37
38
  normalizeAppSkillManifestPath,
39
+ appRowFieldsFromManifest,
38
40
  directDeploy,
39
41
  pullAppSource,
40
42
  } from '../runtime/app-platform.js';
43
+ import {
44
+ filterScaffoldCatalog,
45
+ loadScaffoldCatalog,
46
+ scaffoldRegistryLabel,
47
+ } from '../runtime/app-registry-scaffolds.js';
41
48
  import { startAppDevServer } from '../runtime/app-dev-server.js';
49
+ import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
50
+ import {
51
+ discoverRegisteredAppProjects,
52
+ readAppDevRoots,
53
+ registerAppDevRoot,
54
+ removeAppDevRoot,
55
+ } from '../runtime/app-dev-roots.js';
42
56
  import {
43
57
  captureHarnessScreenshot,
44
58
  closeAgentBrowserSession,
@@ -48,12 +62,22 @@ import {
48
62
  import {
49
63
  getAppDevSessionsFile,
50
64
  heartbeatAppDevSession,
65
+ linkAppDevSessionTarget,
66
+ readAppDevSessions,
51
67
  removeAppDevSession,
52
68
  upsertAppDevSessions,
53
- waitForAppDevSessionMountAcknowledgements,
54
- waitForAppDevSessionRenderAcknowledgements,
55
69
  } from '../runtime/app-dev-sessions.js';
56
- import { getAvailablePort } from '../runtime/ports.js';
70
+ import {
71
+ releaseAppDevHostLock,
72
+ tryAcquireAppDevHostLock,
73
+ } from '../runtime/app-dev-host-lock.js';
74
+ import {
75
+ heartbeatAppDevConsumer,
76
+ hasAppDevConsumer,
77
+ readAppDevConsumers,
78
+ removeAppDevConsumer,
79
+ } from '../runtime/app-dev-consumers.js';
80
+ import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
57
81
  import { getCliMode } from '../runtime/cli-mode.js';
58
82
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
59
83
  import { httpRequest } from '../runtime/transport.js';
@@ -65,16 +89,212 @@ import {
65
89
  toolConflictToError,
66
90
  } from './helpers.js';
67
91
 
92
+ export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
93
+
68
94
  const DEFAULT_DEV_PORT = 5173;
69
95
  const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
70
- const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
96
+ const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
97
+ const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
98
+ const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
99
+
100
+ function projectIsWithinRoot(projectDir, rootDir) {
101
+ const nested = relative(rootDir, projectDir);
102
+ return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
103
+ }
104
+
105
+ function parseNotisAppVersion(value) {
106
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
107
+ String(value || '').trim(),
108
+ );
109
+ if (!match) return null;
110
+ const prerelease = match[4] ? match[4].split('.') : [];
111
+ if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
112
+ return null;
113
+ }
114
+ return {
115
+ major: match[1],
116
+ minor: match[2],
117
+ patch: match[3],
118
+ prerelease,
119
+ };
120
+ }
121
+
122
+ function compareNumericSemverIdentifiers(left, right) {
123
+ if (left.length !== right.length) return left.length > right.length ? 1 : -1;
124
+ return left === right ? 0 : left > right ? 1 : -1;
125
+ }
126
+
127
+ function compareNotisPrerelease(left, right) {
128
+ if (left.length === 0 || right.length === 0) {
129
+ return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
130
+ }
131
+ const length = Math.max(left.length, right.length);
132
+ for (let index = 0; index < length; index += 1) {
133
+ const leftIdentifier = left[index];
134
+ const rightIdentifier = right[index];
135
+ if (leftIdentifier === undefined || rightIdentifier === undefined) {
136
+ return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
137
+ }
138
+ if (leftIdentifier === rightIdentifier) continue;
139
+ const leftNumeric = /^\d+$/.test(leftIdentifier);
140
+ const rightNumeric = /^\d+$/.test(rightIdentifier);
141
+ if (leftNumeric && rightNumeric) {
142
+ return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
143
+ }
144
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
145
+ return leftIdentifier > rightIdentifier ? 1 : -1;
146
+ }
147
+ return 0;
148
+ }
149
+
150
+ export function compareNotisAppVersions(leftValue, rightValue) {
151
+ const left = parseNotisAppVersion(leftValue);
152
+ const right = parseNotisAppVersion(rightValue);
153
+ if (!left || !right) return null;
154
+ for (const key of ['major', 'minor', 'patch']) {
155
+ const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
156
+ if (comparison !== 0) return comparison;
157
+ }
158
+ return compareNotisPrerelease(left.prerelease, right.prerelease);
159
+ }
160
+
161
+ function readLocalNotisAppVersion(projectDir) {
162
+ try {
163
+ const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
164
+ const version = String(packageJson.notisAppVersion || '').trim();
165
+ return parseNotisAppVersion(version) ? version : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A CLI launch may add one root, but the shared host always serves the complete
173
+ * machine registry. Desktop sets skipRootRegistration because it is reconciling
174
+ * an already-registered snapshot; this must never narrow discovery to the one
175
+ * project path used to start the host.
176
+ */
177
+ export function discoverAppDevLaunchProjects(rootDir, {
178
+ skipRootRegistration = false,
179
+ registerRoot = registerAppDevRoot,
180
+ discoverProjects = discoverRegisteredAppProjects,
181
+ } = {}) {
182
+ if (!skipRootRegistration) registerRoot(rootDir);
183
+ return discoverProjects();
184
+ }
185
+
186
+ /**
187
+ * Collapse duplicate local sources before they reach the shared loopback host.
188
+ * An explicitly registered root is more intentional than the implicit
189
+ * ~/.notis/apps root. If two equally intentional roots claim the same dev
190
+ * slug, omit that slug and keep serving every unrelated app.
191
+ */
192
+ export function selectCanonicalDevApps(candidates, rootsRegistry) {
193
+ const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
194
+ const groups = new Map();
195
+ for (const candidate of candidates) {
196
+ groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
197
+ }
198
+
199
+ const selected = [];
200
+ const warnings = [];
201
+ for (const [devSlug, group] of groups) {
202
+ if (group.length === 1) {
203
+ selected.push(group[0]);
204
+ continue;
205
+ }
206
+ const ranked = group.map((candidate) => {
207
+ const explicitRoots = roots.filter((root) => (
208
+ root?.implicit !== true
209
+ && typeof root?.path === 'string'
210
+ && projectIsWithinRoot(candidate.projectDir, root.path)
211
+ ));
212
+ const newestRegistration = explicitRoots.reduce((latest, root) => {
213
+ const timestamp = Date.parse(root.registeredAt || '');
214
+ return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
215
+ }, 0);
216
+ return {
217
+ candidate,
218
+ rank: explicitRoots.length > 0 ? 1 : 0,
219
+ newestRegistration,
220
+ };
221
+ }).sort((left, right) => (
222
+ right.rank - left.rank
223
+ || right.newestRegistration - left.newestRegistration
224
+ ));
225
+ const winner = ranked[0];
226
+ const runnerUp = ranked[1];
227
+ const unambiguous = winner.rank > runnerUp.rank
228
+ || winner.newestRegistration > runnerUp.newestRegistration;
229
+ if (unambiguous) {
230
+ selected.push(winner.candidate);
231
+ warnings.push(
232
+ `Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
233
+ );
234
+ continue;
235
+ }
236
+ warnings.push(
237
+ `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.`,
238
+ );
239
+ }
240
+ return { selected, warnings };
241
+ }
242
+
243
+ export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
244
+ const now = Date.now();
245
+ const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
246
+ .filter((session) => {
247
+ if (session.sourceHost !== true) return false;
248
+ const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
249
+ if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
250
+ if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
251
+ try {
252
+ process.kill(session.hostPid, 0);
253
+ return true;
254
+ } catch {
255
+ return false;
256
+ }
257
+ });
258
+ const groups = new Map();
259
+ for (const session of sourceSessions) {
260
+ const key = `${session.hostPid || 0}:${session.sessionId}`;
261
+ groups.set(key, [...(groups.get(key) || []), session]);
262
+ }
263
+ for (const sessions of groups.values()) {
264
+ const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
265
+ const canonicalProjects = sessions.find((session) => (
266
+ Array.isArray(session.canonicalProjects)
267
+ ))?.canonicalProjects;
268
+ const discoveredProjects = sessions.find((session) => (
269
+ Array.isArray(session.discoveredProjects)
270
+ ))?.discoveredProjects;
271
+ const discoveryMatches = discoveredProjects
272
+ && JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
273
+ if (
274
+ discoveryMatches
275
+ && canonicalProjects
276
+ && canonicalProjects.every((projectDir) => byProject.has(projectDir))
277
+ ) {
278
+ return new Map(canonicalProjects.map((projectDir) => [
279
+ projectDir,
280
+ byProject.get(projectDir).bundleBaseUrl,
281
+ ]));
282
+ }
283
+ if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
284
+ return new Map(projectDirs.map((projectDir) => [
285
+ projectDir,
286
+ byProject.get(projectDir).bundleBaseUrl,
287
+ ]));
288
+ }
289
+ return null;
290
+ }
71
291
  const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
72
292
  const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
73
293
  const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
74
294
  const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
75
295
  const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
76
296
  const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
77
- const APP_DEPLOY_TIMEOUT_MS = 120_000;
297
+ export const APP_DEPLOY_TIMEOUT_MS = 600_000;
78
298
 
79
299
  // ---------------------------------------------------------------------------
80
300
  // Formatters
@@ -98,40 +318,12 @@ function scaffoldsTable(scaffolds) {
98
318
  ]);
99
319
  }
100
320
 
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;
321
+ function appDevRootsTable(roots) {
322
+ return formatTable(roots, [
323
+ { label: 'Folder', value: (root) => root.path },
324
+ { label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
325
+ { label: 'Registered', value: (root) => root.registeredAt || '' },
326
+ ]);
135
327
  }
136
328
 
137
329
  function decodeJwtSub(jwt) {
@@ -146,42 +338,11 @@ function decodeJwtSub(jwt) {
146
338
  }
147
339
  }
148
340
 
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
- }
341
+ function linkedStateProfileKey(runtime) {
342
+ return appLinkedStateProfileKey({
343
+ apiBase: runtime?.apiBase,
344
+ userId: decodeJwtSub(runtime?.jwt),
345
+ });
185
346
  }
186
347
 
187
348
  function slugify(value) {
@@ -192,8 +353,8 @@ function slugify(value) {
192
353
  .replace(/(^-|-$)+/g, '');
193
354
  }
194
355
 
195
- function buildDevInstallSlug(appName) {
196
- const base = slugify(appName);
356
+ function buildDevInstallSlug(appConfig) {
357
+ const base = slugify(appConfig?.devSlug || appConfig?.name);
197
358
  if (!base) {
198
359
  return '';
199
360
  }
@@ -226,61 +387,6 @@ function pickDefaultRouteSlug(manifest) {
226
387
  return firstWithSlug ? firstWithSlug.slug : null;
227
388
  }
228
389
 
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
390
  export function buildDevelopmentAppHref({
285
391
  appSlug,
286
392
  appId,
@@ -374,13 +480,6 @@ export function screenshotIndexByRouteSlug(manifest) {
374
480
  return new Map(routes.map((route, index) => [route.slug, index + 1]));
375
481
  }
376
482
 
377
- export function appRowFieldsFromManifest(manifest) {
378
- const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
379
- return {
380
- accent: app.accent ?? null,
381
- };
382
- }
383
-
384
483
  export function screenshotExitCode(failedCount) {
385
484
  return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
386
485
  }
@@ -534,7 +633,7 @@ function renderVerifyReport({ summary, results, noBrowser }) {
534
633
  return lines.join('\n');
535
634
  }
536
635
 
537
- function buildManifestForDev(appConfig) {
636
+ function buildManifestForDev(appConfig, projectDir) {
538
637
  const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
539
638
  return {
540
639
  version: 1,
@@ -543,6 +642,7 @@ function buildManifestForDev(appConfig) {
543
642
  name: appConfig.name,
544
643
  description: appConfig.description || null,
545
644
  icon: appConfig.icon || null,
645
+ release_version: readLocalNotisAppVersion(projectDir),
546
646
  },
547
647
  routes: routes.map((route) => ({
548
648
  path: route.path,
@@ -551,6 +651,7 @@ function buildManifestForDev(appConfig) {
551
651
  icon: route.icon || null,
552
652
  parentSlug: route.parentSlug || null,
553
653
  default: route.default || false,
654
+ resourceDeepLinks: route.resourceDeepLinks === true,
554
655
  export_name: route.exportName || route.export_name,
555
656
  collection: route.collection || null,
556
657
  })),
@@ -561,6 +662,7 @@ function buildManifestForDev(appConfig) {
561
662
  databases: appConfig.databases || [],
562
663
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
563
664
  tools: appConfig.tools || [],
665
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
564
666
  skills: (appConfig.skills || []).map((skill) => ({
565
667
  key: skill.key,
566
668
  path: normalizeAppSkillManifestPath(skill.path),
@@ -579,7 +681,7 @@ export function buildEnsureDevInstallArguments({
579
681
  useInstalledDatabases = false,
580
682
  approvedCapabilities = null,
581
683
  }) {
582
- const devSlug = buildDevInstallSlug(appConfig.name);
684
+ const devSlug = buildDevInstallSlug(appConfig);
583
685
  const arguments_ = {
584
686
  dev_slug: devSlug,
585
687
  name: appConfig.name,
@@ -589,8 +691,12 @@ export function buildEnsureDevInstallArguments({
589
691
  if (linkedState?.dev_app_id) {
590
692
  arguments_.app_id = linkedState.dev_app_id;
591
693
  }
592
- // Sent on every ensure call, including as `false`, so dropping `--live-data`
593
- // puts the next session back on its own dev copies.
694
+ if (useInstalledDatabases && linkedState?.app_id) {
695
+ arguments_.installed_app_id = linkedState.app_id;
696
+ }
697
+ // Sent on every ensure call, including as `false`, so a `--scratch` session
698
+ // puts the app on its own dev copies and the next plain session puts it back
699
+ // on the installed app's databases.
594
700
  arguments_.use_installed_databases = Boolean(useInstalledDatabases);
595
701
  if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
596
702
  arguments_.approved_capabilities = approvedCapabilities;
@@ -614,15 +720,20 @@ export async function resolveCloudShellConsent({
614
720
  projectDir,
615
721
  grantCloudShell = false,
616
722
  logger = console,
723
+ profileKey = null,
617
724
  }) {
618
725
  const capabilities = normalizeAppCapabilities(appConfig.capabilities);
619
726
  if (capabilities.cloudComputer !== 'shell') {
620
727
  return null;
621
728
  }
622
729
  const approved = ['cloud_computer_read', 'cloud_computer_shell'];
623
- const linkedState = readLinkedState(projectDir) || {};
730
+ const linkedState = readLinkedState(projectDir, profileKey) || {};
624
731
  const recordDecision = (decision) => {
625
- writeLinkedState(projectDir, { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision });
732
+ writeLinkedState(
733
+ projectDir,
734
+ { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
735
+ profileKey,
736
+ );
626
737
  };
627
738
 
628
739
  if (grantCloudShell) {
@@ -675,21 +786,22 @@ export async function ensureDevInstall({
675
786
  if (!appConfig.name) {
676
787
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
677
788
  }
678
- const devSlug = buildDevInstallSlug(appConfig.name);
789
+ const devSlug = buildDevInstallSlug(appConfig);
679
790
  if (!devSlug) {
680
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
791
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
681
792
  }
682
793
 
683
- const manifest = buildManifestForDev(appConfig);
794
+ const manifest = buildManifestForDev(appConfig, projectDir);
684
795
  const skills = resolveConfiguredAppSkills(appConfig, projectDir);
685
- let linkedState = readLinkedState(projectDir);
796
+ const profileKey = linkedStateProfileKey(ctx.runtime);
797
+ let linkedState = readLinkedState(projectDir, profileKey);
686
798
  let linkedApp = null;
687
799
  if (linkedState?.dev_app_id) {
688
800
  const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
689
801
  if (!devApp || devApp.manifest?.is_dev !== true) {
690
802
  const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
691
803
  linkedState = rest;
692
- writeLinkedState(projectDir, linkedState);
804
+ writeLinkedState(projectDir, linkedState, profileKey);
693
805
  }
694
806
  }
695
807
  if (linkedState?.app_id) {
@@ -704,25 +816,102 @@ export async function ensureDevInstall({
704
816
  dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
705
817
  } : {}),
706
818
  };
707
- writeLinkedState(projectDir, linkedState);
819
+ writeLinkedState(projectDir, linkedState, profileKey);
708
820
  linkedApp = null;
709
821
  }
710
822
  }
823
+ // A checkout can be shared by Beta and a source-workspace desktop. Its
824
+ // installed app link is valid for the Beta account but intentionally
825
+ // inaccessible to the worktree's test user. Preserve that durable link for
826
+ // Beta, while omitting it from this run so the local dev app uses its own
827
+ // resources instead of sending an unauthorized installed_app_id to ensure.
828
+ if (!linkedState?.app_id) {
829
+ const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
830
+ ctx.runtime,
831
+ devSlug,
832
+ linkedState?.dev_app_id,
833
+ projectDir,
834
+ profileKey,
835
+ runTool,
836
+ );
837
+ const installedCandidates = installedResolution.candidates;
838
+ if (installedCandidates.length > 1) {
839
+ if (installedResolution.source === 'persisted-project-link') {
840
+ throw usageError(
841
+ `Multiple accessible installed apps are persisted for ${projectDir}. `
842
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
843
+ );
844
+ }
845
+ throw usageError(
846
+ `Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
847
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
848
+ );
849
+ }
850
+ if (installedCandidates.length === 1) {
851
+ const now = new Date().toISOString();
852
+ linkedApp = installedCandidates[0];
853
+ linkedState = {
854
+ ...(linkedState || {}),
855
+ app_id: linkedApp.app_id || linkedApp.id,
856
+ linked_at: now,
857
+ auto_linked_at: now,
858
+ };
859
+ writeLinkedState(projectDir, linkedState, profileKey);
860
+ }
861
+ }
862
+ const runtimeLinkedState = linkedState?.app_id && !linkedApp
863
+ ? Object.fromEntries(
864
+ Object.entries(linkedState).filter(([key]) => ![
865
+ 'app_id', 'linked_at', 'deployed_at', 'version',
866
+ ].includes(key)),
867
+ )
868
+ : linkedState;
869
+ const localReleaseVersion = manifest.app.release_version || null;
870
+ const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
871
+ const mountEligible = !linkedApp
872
+ || compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
711
873
  const ensureArguments = buildEnsureDevInstallArguments({
712
874
  appConfig,
713
875
  manifest,
714
- linkedState,
876
+ linkedState: runtimeLinkedState,
715
877
  skills,
716
878
  useInstalledDatabases,
717
879
  approvedCapabilities,
718
880
  });
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
- });
881
+ let ensureResult;
882
+ try {
883
+ ensureResult = await runTool({
884
+ runtime: ctx.runtime,
885
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
886
+ arguments_: ensureArguments,
887
+ mutating: true,
888
+ idempotencyKey,
889
+ });
890
+ } catch (error) {
891
+ const backendCode = error?.details?.error?.code;
892
+ if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
893
+ // The authenticated environment can outlive an older local API profile.
894
+ // If that profile remembered a different dev row while this user already
895
+ // owns the exact canonical dev slug, the backend refuses a duplicate.
896
+ // Retry without the stale row id so the backend deterministically adopts
897
+ // the unique slug owner; preserve the installed target and every grant.
898
+ const staleDevAppId = linkedState.dev_app_id;
899
+ const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
900
+ linkedState = rest;
901
+ writeLinkedState(projectDir, linkedState, profileKey);
902
+ const retryArguments = { ...ensureArguments };
903
+ delete retryArguments.app_id;
904
+ ensureResult = await runTool({
905
+ runtime: ctx.runtime,
906
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
907
+ arguments_: retryArguments,
908
+ mutating: true,
909
+ idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
910
+ });
911
+ } else {
912
+ throw error;
913
+ }
914
+ }
726
915
  const appId = ensureResult.payload.app_id;
727
916
  if (!appId) {
728
917
  throw usageError('Dev installation tool did not return an app_id.');
@@ -733,7 +922,7 @@ export async function ensureDevInstall({
733
922
  ...(linkedState || {}),
734
923
  dev_app_id: appId,
735
924
  dev_linked_at: linkedState?.dev_linked_at || now,
736
- });
925
+ }, profileKey);
737
926
  }
738
927
  return {
739
928
  slug: ensureResult.payload.slug || devSlug,
@@ -743,9 +932,12 @@ export async function ensureDevInstall({
743
932
  projectDir,
744
933
  manifest,
745
934
  created: ensureResult.payload.created || false,
746
- linkedAppId: linkedState?.app_id || null,
747
- targetAppId: linkedState?.app_id || null,
935
+ linkedAppId: runtimeLinkedState?.app_id || null,
936
+ targetAppId: runtimeLinkedState?.app_id || null,
748
937
  targetAppSlug: linkedApp?.slug || null,
938
+ localReleaseVersion,
939
+ installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
940
+ mountEligible,
749
941
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
750
942
  liveData: ensureResult.payload.live_data || null,
751
943
  };
@@ -772,6 +964,16 @@ function liveDataWarnings(apps) {
772
964
  .map((app) => `${app.name}: ${app.liveData.warning}`);
773
965
  }
774
966
 
967
+ function versionPrecedenceWarnings(apps) {
968
+ return apps
969
+ .filter((app) => app.targetAppId && app.mountEligible === false)
970
+ .map((app) => {
971
+ const localVersion = app.localReleaseVersion || 'missing or invalid';
972
+ const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
973
+ return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
974
+ });
975
+ }
976
+
775
977
  async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
776
978
  const result = await runTool({
777
979
  runtime,
@@ -795,6 +997,63 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
795
997
  throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
796
998
  }
797
999
 
1000
+ async function findInstalledLinkCandidatesForLegacyDevState(
1001
+ runtime,
1002
+ devSlug,
1003
+ devAppId,
1004
+ projectDir,
1005
+ profileKey,
1006
+ runTool = runToolCommand,
1007
+ ) {
1008
+ const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
1009
+ if (!installedSlug) return { candidates: [], source: null };
1010
+
1011
+ const result = await runTool({
1012
+ runtime,
1013
+ toolName: LIST_APPS_TOOL,
1014
+ });
1015
+ const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
1016
+ (app?.app_id || app?.id)
1017
+ && (app.app_id || app.id) !== devAppId
1018
+ && app?.manifest?.is_dev !== true
1019
+ ));
1020
+
1021
+ // A project-local app id is stronger evidence than a slug. Older CLI runs
1022
+ // could persist that explicit link under another API/user profile before
1023
+ // the machine-local discovery host mounted the same source here. Migrate it
1024
+ // only when exactly one such id is accessible in the current environment;
1025
+ // multiple accessible ids remain ambiguous and fail closed below.
1026
+ const rawState = readLinkedState(projectDir) || {};
1027
+ const persistedAppIds = new Set();
1028
+ if (rawState.app_id) persistedAppIds.add(rawState.app_id);
1029
+ const profiles = rawState.profiles && typeof rawState.profiles === 'object'
1030
+ ? rawState.profiles
1031
+ : {};
1032
+ for (const [storedProfileKey, state] of Object.entries(profiles)) {
1033
+ if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
1034
+ if (state.app_id) persistedAppIds.add(state.app_id);
1035
+ }
1036
+ const persistedCandidates = accessibleInstalledApps.filter((app) => (
1037
+ persistedAppIds.has(app.app_id || app.id)
1038
+ ));
1039
+ if (persistedCandidates.length) {
1040
+ return { candidates: persistedCandidates, source: 'persisted-project-link' };
1041
+ }
1042
+
1043
+ // The database row slug can change when a Store app is installed or an old
1044
+ // development app is promoted. The shipped source manifest keeps the
1045
+ // canonical app slug, so accept either exact field. Display names are never
1046
+ // considered.
1047
+ const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
1048
+ const canonicalSlugs = new Set([
1049
+ app?.slug,
1050
+ app?.manifest?.app?.slug,
1051
+ ].map(slugify).filter(Boolean));
1052
+ return canonicalSlugs.has(installedSlug);
1053
+ });
1054
+ return { candidates: exactSlugCandidates, source: 'exact-slug' };
1055
+ }
1056
+
798
1057
  export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
799
1058
  const app = await getAccessibleApp(runtime, appId, runTool);
800
1059
  if (!app) {
@@ -803,6 +1062,14 @@ export async function assertDirectDeployAccess(runtime, appId, runTool = runTool
803
1062
  if (app.apps_access?.has_access !== true) {
804
1063
  throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
805
1064
  }
1065
+ if (app.can_edit !== true) {
1066
+ throw usageError(`Direct deploy requires edit access to app ${appId}.`);
1067
+ }
1068
+ if (app.manifest?.is_dev === true) {
1069
+ throw usageError(
1070
+ 'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
1071
+ );
1072
+ }
806
1073
  return app;
807
1074
  }
808
1075
 
@@ -839,10 +1106,12 @@ async function appsListHandler(ctx) {
839
1106
  }
840
1107
 
841
1108
  async function appsInitHandler(ctx) {
842
- const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
1109
+ const projectDir = ctx.args.dir
1110
+ ? resolveProjectDir(ctx.args.dir)
1111
+ : defaultAppProjectDir(slugify(ctx.args.name));
843
1112
  const fromSlug = ctx.options.from || null;
844
1113
 
845
- scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
1114
+ await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
846
1115
 
847
1116
  return ctx.output.emitSuccess({
848
1117
  command: ctx.spec.command_path.join(' '),
@@ -858,14 +1127,20 @@ async function appsInitHandler(ctx) {
858
1127
  }
859
1128
 
860
1129
  async function appsScaffoldsListHandler(ctx) {
861
- const scaffolds = loadScaffoldCatalog();
1130
+ const searchTerm = ctx.options.search || null;
1131
+ const catalog = await loadScaffoldCatalog();
1132
+ const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
1133
+ const registry = scaffoldRegistryLabel();
1134
+ const emptyMessage = searchTerm
1135
+ ? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
1136
+ : `No published scaffolds found in ${registry}.`;
862
1137
  return ctx.output.emitSuccess({
863
1138
  command: ctx.spec.command_path.join(' '),
864
- data: { scaffolds },
1139
+ data: { scaffolds, registry, search: searchTerm },
865
1140
  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.'),
1141
+ ? `Found ${scaffolds.length} published scaffolds in ${registry}`
1142
+ : emptyMessage,
1143
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
869
1144
  });
870
1145
  }
871
1146
 
@@ -895,7 +1170,7 @@ async function appsCreateHandler(ctx) {
895
1170
  writeLinkedState(projectDir, {
896
1171
  app_id: app.id,
897
1172
  linked_at: new Date().toISOString(),
898
- });
1173
+ }, linkedStateProfileKey(ctx.runtime));
899
1174
  }
900
1175
 
901
1176
  return ctx.output.emitSuccess({
@@ -918,7 +1193,94 @@ async function appsCreateHandler(ctx) {
918
1193
 
919
1194
  async function appsDevHandler(ctx) {
920
1195
  const rootDir = resolveProjectDir(ctx.args.dir || '.');
921
- const appDirs = discoverAppDirs(rootDir);
1196
+ const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
1197
+ let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
1198
+ const discoveredAppDirs = [...appDirs];
1199
+ const sessionsFilePath = getAppDevSessionsFile();
1200
+ let sharedBundleBaseUrls = null;
1201
+ const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
1202
+ if (sharedBundlesRaw) {
1203
+ let parsed;
1204
+ try {
1205
+ parsed = JSON.parse(sharedBundlesRaw);
1206
+ } catch {
1207
+ throw usageError('The shared app development bundle map is invalid.');
1208
+ }
1209
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1210
+ throw usageError('The shared app development bundle map must be an object.');
1211
+ }
1212
+ const sharedProjectDirs = Object.keys(parsed);
1213
+ const discoveredProjects = new Set(appDirs);
1214
+ for (const projectDir of sharedProjectDirs) {
1215
+ if (!discoveredProjects.has(projectDir)) {
1216
+ throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
1217
+ }
1218
+ }
1219
+ appDirs = sharedProjectDirs;
1220
+ sharedBundleBaseUrls = new Map();
1221
+ for (const projectDir of appDirs) {
1222
+ const value = parsed[projectDir];
1223
+ let url;
1224
+ try {
1225
+ url = new URL(String(value || ''));
1226
+ } catch {
1227
+ throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
1228
+ }
1229
+ if (
1230
+ url.protocol !== 'http:'
1231
+ || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
1232
+ ) {
1233
+ throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
1234
+ }
1235
+ sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
1236
+ }
1237
+ }
1238
+
1239
+ if (appDirs.length === 0) {
1240
+ return ctx.output.emitSuccess({
1241
+ command: ctx.spec.command_path.join(' '),
1242
+ data: {
1243
+ registered_root: skipRootRegistration ? null : rootDir,
1244
+ apps: [],
1245
+ watching_on_desktop_launch: true,
1246
+ },
1247
+ humanSummary: [
1248
+ skipRootRegistration
1249
+ ? 'No registered Notis apps are present yet.'
1250
+ : `Registered app development root: ${rootDir}`,
1251
+ 'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
1252
+ ].join('\n'),
1253
+ hints: [
1254
+ { command: 'notis apps roots list', reason: 'Show every persistent development root' },
1255
+ ],
1256
+ meta: { mutating: true },
1257
+ });
1258
+ }
1259
+
1260
+ if (!sharedBundleBaseUrls) {
1261
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1262
+ }
1263
+ let sourceHostLock = null;
1264
+ if (
1265
+ !sharedBundleBaseUrls
1266
+ && process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
1267
+ ) {
1268
+ sourceHostLock = tryAcquireAppDevHostLock({
1269
+ identity: '__mac_user__',
1270
+ apiBase: 'loopback-source',
1271
+ projectDir: SHARED_APP_DEV_HOST_KEY,
1272
+ });
1273
+ if (!sourceHostLock) {
1274
+ const startedAt = Date.now();
1275
+ while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
1276
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
1277
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1278
+ }
1279
+ if (!sharedBundleBaseUrls) {
1280
+ throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
1281
+ }
1282
+ }
1283
+ }
922
1284
 
923
1285
  for (const projectDir of appDirs) {
924
1286
  const problems = detectProjectProblems(projectDir);
@@ -927,9 +1289,19 @@ async function appsDevHandler(ctx) {
927
1289
  }
928
1290
  }
929
1291
 
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.');
1292
+ let port = null;
1293
+ if (sharedBundleBaseUrls) {
1294
+ // The source host owns the only loopback listener and build watchers.
1295
+ } else if (ctx.options.port) {
1296
+ port = Number.parseInt(ctx.options.port, 10);
1297
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1298
+ throw usageError('Port must be between 1 and 65535.');
1299
+ }
1300
+ } else {
1301
+ // Concurrent sessions must not fight over the default port: the desktop
1302
+ // auto-starts every known project at launch, so only the first one can
1303
+ // have 5173 and the rest fall back to an ephemeral port.
1304
+ port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
933
1305
  }
934
1306
 
935
1307
  const mode = getCliMode();
@@ -938,17 +1310,39 @@ async function appsDevHandler(ctx) {
938
1310
  throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
939
1311
  }
940
1312
  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
1313
  const sessionId = randomUUID();
951
- const useInstalledDatabases = Boolean(ctx.options?.liveData);
1314
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1315
+ const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
1316
+ const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
1317
+ ? `cli.${process.pid}.${sessionId}`
1318
+ : null;
1319
+ let manualConsumerTimer = null;
1320
+ if (manualConsumerInstanceId) {
1321
+ const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
1322
+ instanceId: manualConsumerInstanceId,
1323
+ userId: identity,
1324
+ apiBase,
1325
+ pid: process.pid,
1326
+ });
1327
+ heartbeatManualConsumer();
1328
+ manualConsumerTimer = setInterval(() => {
1329
+ try {
1330
+ heartbeatManualConsumer();
1331
+ } catch (error) {
1332
+ const message = error instanceof Error ? error.message : String(error);
1333
+ process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
1334
+ }
1335
+ }, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
1336
+ }
1337
+ // One app, one dataset: a dev session runs the app from local source against
1338
+ // the same databases the installed app uses, the way `npm run dev` serves the
1339
+ // same database as the deployed site. `--scratch` is the marked case; the
1340
+ // old `--live-data` flag now names the default and is kept as a no-op so
1341
+ // muscle memory and scripts keep working.
1342
+ if (ctx.options?.scratch && ctx.options?.liveData) {
1343
+ throw usageError('--scratch and --live-data contradict each other; pass at most one.');
1344
+ }
1345
+ const useInstalledDatabases = !ctx.options?.scratch;
952
1346
 
953
1347
  const candidates = [];
954
1348
  for (const projectDir of appDirs) {
@@ -956,122 +1350,123 @@ async function appsDevHandler(ctx) {
956
1350
  if (!appConfig.name) {
957
1351
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
958
1352
  }
959
- const devSlug = buildDevInstallSlug(appConfig.name);
1353
+ const devSlug = buildDevInstallSlug(appConfig);
960
1354
  if (!devSlug) {
961
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
1355
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
962
1356
  }
1357
+ candidates.push({ appConfig, devSlug, projectDir });
1358
+ }
1359
+
1360
+ const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
1361
+ const canonicalCandidates = [];
1362
+ for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
963
1363
  // Sequential on purpose: the shell-consent prompt must never interleave
964
1364
  // with another app's, and the ensure fan-out below runs in parallel.
965
1365
  const approvedCapabilities = await resolveCloudShellConsent({
966
1366
  appConfig,
967
1367
  projectDir,
968
1368
  grantCloudShell: Boolean(ctx.options?.grantCloudShell),
1369
+ profileKey,
1370
+ });
1371
+ canonicalCandidates.push({
1372
+ appConfig,
1373
+ devSlug,
1374
+ projectDir,
1375
+ approvedCapabilities,
1376
+ mountNonce: randomUUID(),
969
1377
  });
970
- candidates.push({ appConfig, devSlug, projectDir, approvedCapabilities });
971
- }
972
-
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
- );
979
- }
980
- seenSlugs.set(app.devSlug, app);
981
1378
  }
982
1379
 
983
- 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
- });
1380
+ // Start every build watcher before the backend registrations finish. A
1381
+ // machine with several apps must not show an empty sidebar for minutes while
1382
+ // unrelated registrations run sequentially. Each successful registration is
1383
+ // published below as soon as its own last-known bundle is ready.
1384
+ const devServer = sharedBundleBaseUrls
1385
+ ? {
1386
+ close: async () => {},
1387
+ isBundleReady: () => true,
1388
+ updateApp: () => {},
1389
+ waitForBundle: async () => {},
1390
+ getWatcherOwnership: () => null,
1001
1391
  }
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,
1017
- appId: app.appId,
1018
- 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,
1392
+ : await startAppDevServer({
1393
+ apps: canonicalCandidates.map((app) => ({
1394
+ slug: app.devSlug,
1395
+ projectDir: app.projectDir,
1396
+ appId: null,
1397
+ targetAppId: null,
1398
+ userId: identity,
1399
+ profileKey,
1400
+ sessionId,
1401
+ mountNonce: app.mountNonce,
1402
+ })),
1403
+ port,
1404
+ sessionsFilePath,
1405
+ });
1406
+ const desktopHostOwnership = captureDesktopHostOwnership({
1407
+ desktopOwnerId: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID,
1408
+ desktopOwnerScope: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE,
1047
1409
  });
1048
1410
 
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) {
1411
+ let heartbeatTimer = null;
1412
+ let consumerTimer = null;
1413
+ let shuttingDown = false;
1414
+ const shutdown = async (signal) => {
1415
+ if (shuttingDown) return;
1416
+ shuttingDown = true;
1417
+ process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
1418
+ if (heartbeatTimer) {
1419
+ clearInterval(heartbeatTimer);
1420
+ heartbeatTimer = null;
1421
+ }
1422
+ if (consumerTimer) {
1423
+ clearInterval(consumerTimer);
1424
+ consumerTimer = null;
1425
+ }
1426
+ if (manualConsumerTimer) {
1427
+ clearInterval(manualConsumerTimer);
1428
+ manualConsumerTimer = null;
1429
+ }
1430
+ if (manualConsumerInstanceId) {
1431
+ try {
1432
+ removeAppDevConsumer(manualConsumerInstanceId);
1433
+ } catch {
1434
+ // A crashed CLI lease expires automatically after the heartbeat window.
1435
+ }
1436
+ }
1066
1437
  try {
1067
1438
  await devServer.close();
1068
1439
  } catch {
1069
- // ignore cleanup failure on registry errors
1440
+ // ignore cleanup failures during shutdown
1070
1441
  }
1071
- throw error;
1072
- }
1442
+ // Keep ownership records until every watcher group has stopped. If the
1443
+ // Desktop must force this host down, the next launch can still recover a
1444
+ // verified orphan instead of losing its only ownership proof.
1445
+ try {
1446
+ removeAppDevSession(sessionId, sessionsFilePath);
1447
+ } catch {
1448
+ // ignore cleanup failures during shutdown
1449
+ }
1450
+ if (sourceHostLock) {
1451
+ releaseAppDevHostLock(sourceHostLock);
1452
+ sourceHostLock = null;
1453
+ }
1454
+ process.exit(EXIT_CODES.ok);
1455
+ };
1456
+ const handleSigint = () => {
1457
+ void shutdown('SIGINT');
1458
+ };
1459
+ const handleSigterm = () => {
1460
+ void shutdown('SIGTERM');
1461
+ };
1462
+
1463
+ // Electron can stop a partially registered host. Install cleanup before the
1464
+ // first remote registration so every watcher group is still terminated when
1465
+ // registration is slow or stuck.
1466
+ process.on('SIGINT', handleSigint);
1467
+ process.on('SIGTERM', handleSigterm);
1073
1468
 
1074
- let heartbeatTimer = setInterval(() => {
1469
+ heartbeatTimer = setInterval(() => {
1075
1470
  try {
1076
1471
  heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
1077
1472
  } catch (error) {
@@ -1080,23 +1475,147 @@ async function appsDevHandler(ctx) {
1080
1475
  }
1081
1476
  }, DEV_HEARTBEAT_INTERVAL_MS);
1082
1477
 
1083
- const expectedMountAcknowledgements = apps.map((app) => ({
1084
- sessionId,
1085
- appId: app.appId,
1086
- devSlug: app.devSlug,
1087
- mountNonce: app.mountNonce,
1088
- }));
1478
+ const registrationStartedAt = process.hrtime.bigint();
1479
+ const apps = [];
1480
+ const registrationWarnings = [];
1481
+ let firstRegistrationError = null;
1482
+ // The local development backend is deliberately a single worker. Register
1483
+ // apps sequentially so a large root cannot queue many long mutations behind
1484
+ // one another. Unlike the old all-or-nothing startup, publish each healthy
1485
+ // app immediately while later registrations continue in the background.
1486
+ for (const {
1487
+ appConfig,
1488
+ devSlug,
1489
+ projectDir,
1490
+ approvedCapabilities,
1491
+ mountNonce,
1492
+ } of canonicalCandidates) {
1493
+ const appStartedAt = process.hrtime.bigint();
1494
+ try {
1495
+ const registeredApp = await ensureDevInstall({
1496
+ ctx,
1497
+ appConfig,
1498
+ projectDir,
1499
+ idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
1500
+ useInstalledDatabases,
1501
+ approvedCapabilities,
1502
+ });
1503
+ const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
1504
+ || `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
1505
+ const app = {
1506
+ ...registeredApp,
1507
+ bundleBaseUrl,
1508
+ mountNonce,
1509
+ appHref: buildDevelopmentAppHref({
1510
+ appSlug: registeredApp.slug,
1511
+ appId: registeredApp.appId,
1512
+ devSlug: registeredApp.devSlug,
1513
+ targetAppId: registeredApp.targetAppId,
1514
+ targetAppSlug: registeredApp.targetAppSlug,
1515
+ manifest: registeredApp.manifest,
1516
+ }),
1517
+ };
1518
+ devServer.updateApp(app.devSlug, {
1519
+ appId: app.appId,
1520
+ targetAppId: app.targetAppId || null,
1521
+ });
1522
+ const now = new Date().toISOString();
1523
+ const sessionRecord = {
1524
+ sessionId,
1525
+ hostPid: process.pid,
1526
+ sourceHost: !sharedBundleBaseUrls,
1527
+ ...(desktopHostOwnership || {}),
1528
+ ...(devServer.getWatcherOwnership(app.devSlug) || {}),
1529
+ bundleReady: devServer.isBundleReady(app.devSlug),
1530
+ ...(!sharedBundleBaseUrls ? {
1531
+ discoveredProjects: discoveredAppDirs,
1532
+ canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
1533
+ } : {}),
1534
+ userId: identity,
1535
+ apiBase,
1536
+ profileKey,
1537
+ appId: app.appId,
1538
+ targetAppId: app.targetAppId || undefined,
1539
+ mountNonce: app.mountNonce,
1540
+ devSlug: app.devSlug,
1541
+ bundleBaseUrl: app.bundleBaseUrl,
1542
+ projectDir: app.projectDir,
1543
+ startedAt: now,
1544
+ lastHeartbeatAt: now,
1545
+ };
1546
+ upsertAppDevSessions(sessionRecord, sessionsFilePath);
1547
+ apps.push(app);
1548
+ if (!sessionRecord.bundleReady) {
1549
+ void devServer.waitForBundle(app.devSlug).then(() => {
1550
+ upsertAppDevSessions({
1551
+ ...sessionRecord,
1552
+ bundleReady: true,
1553
+ lastHeartbeatAt: new Date().toISOString(),
1554
+ }, sessionsFilePath);
1555
+ }).catch((error) => {
1556
+ const message = error instanceof Error ? error.message : String(error);
1557
+ process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
1558
+ });
1559
+ }
1560
+ } catch (error) {
1561
+ firstRegistrationError ||= error;
1562
+ const message = error instanceof Error ? error.message : String(error);
1563
+ registrationWarnings.push(
1564
+ `${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
1565
+ );
1566
+ process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
1567
+ } finally {
1568
+ logAppsTiming('ensure-dev-install', {
1569
+ slug: devSlug,
1570
+ ms: timingMs(appStartedAt).toFixed(1),
1571
+ });
1572
+ }
1573
+ }
1574
+ if (apps.length === 0) {
1575
+ process.off('SIGINT', handleSigint);
1576
+ process.off('SIGTERM', handleSigterm);
1577
+ clearInterval(heartbeatTimer);
1578
+ heartbeatTimer = null;
1579
+ try {
1580
+ await devServer.close();
1581
+ } catch {
1582
+ // preserve the original registration error
1583
+ }
1584
+ if (sourceHostLock) {
1585
+ releaseAppDevHostLock(sourceHostLock);
1586
+ sourceHostLock = null;
1587
+ }
1588
+ if (manualConsumerTimer) clearInterval(manualConsumerTimer);
1589
+ if (manualConsumerInstanceId) {
1590
+ try {
1591
+ removeAppDevConsumer(manualConsumerInstanceId);
1592
+ } catch {
1593
+ // Preserve the registration error; a failed lease cleanup expires.
1594
+ }
1595
+ }
1596
+ throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
1597
+ }
1598
+ logAppsTiming('ensure-dev-install:all', {
1599
+ apps: apps.length,
1600
+ ms: timingMs(registrationStartedAt).toFixed(1),
1601
+ });
1602
+
1603
+ const warnings = [
1604
+ ...canonicalSelection.warnings,
1605
+ ...registrationWarnings,
1606
+ ...databaseMaterializationWarnings(apps),
1607
+ ...liveDataWarnings(apps),
1608
+ ...versionPrecedenceWarnings(apps),
1609
+ ];
1089
1610
 
1090
1611
  ctx.output.emitSuccess({
1091
1612
  command: ctx.spec.command_path.join(' '),
1092
1613
  data: {
1093
1614
  mode,
1094
1615
  api_base: apiBase,
1095
- development_url: developmentTabUrl,
1096
1616
  session_id: sessionId,
1097
1617
  mount_status: 'serving',
1098
- render_status: 'waiting_for_route',
1099
- desktop_target: desktopAppName,
1618
+ source_host: !sharedBundleBaseUrls,
1100
1619
  identity,
1101
1620
  apps: apps.map((app) => ({
1102
1621
  slug: app.devSlug,
@@ -1110,113 +1629,79 @@ async function appsDevHandler(ctx) {
1110
1629
  linked_app_id: app.linkedAppId,
1111
1630
  database_materialization: app.databaseMaterialization,
1112
1631
  live_data: app.liveData,
1632
+ local_release_version: app.localReleaseVersion,
1633
+ installed_release_version: app.installedReleaseVersion,
1634
+ mount_eligible: app.mountEligible,
1113
1635
  })),
1114
1636
  },
1115
1637
  warnings,
1116
1638
  humanSummary: [
1117
1639
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
1118
- `Target desktop: ${desktopAppName}`,
1640
+ ...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
1641
+ `Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
1119
1642
  ...(useInstalledDatabases
1120
1643
  ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1121
1644
  : []),
1122
1645
  '',
1123
- `Open in desktop: ${developmentTabUrl}`,
1124
- '',
1125
- ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
1646
+ ...apps.map((app) => (
1647
+ app.mountEligible
1648
+ ? ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`
1649
+ : ` ${app.name.padEnd(24)} online v${app.installedReleaseVersion} (local ${app.localReleaseVersion || 'version missing'})`
1650
+ )),
1126
1651
  '',
1127
- `Serving locally; waiting for ${desktopAppName} to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
1652
+ sharedBundleBaseUrls
1653
+ ? `Attached to the shared source host: ${apps.filter((app) => app.mountEligible).length}/${apps.length} app${apps.length === 1 ? '' : 's'} eligible to substitute.`
1654
+ : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}; ${apps.filter((app) => app.mountEligible).length} eligible to substitute.`,
1128
1655
  '',
1129
1656
  'Press Ctrl-C to stop.',
1130
1657
  ].join('\n'),
1131
1658
  });
1132
1659
 
1133
- if (shouldOpenDevelopmentTab(ctx.options)) {
1134
- openInBrowser(desktopWakeUrl, {
1135
- appName: desktopAppName,
1136
- bundleId: desktopBundleId,
1137
- scheme: desktopScheme,
1138
- });
1660
+ if (consumerMode === 'machine' || consumerMode === 'environment') {
1661
+ consumerTimer = setInterval(() => {
1662
+ if (!hasAppDevConsumer(readAppDevConsumers(), {
1663
+ mode: consumerMode,
1664
+ userId: identity,
1665
+ apiBase,
1666
+ })) {
1667
+ void shutdown('last consumer detached');
1668
+ }
1669
+ }, DEV_CONSUMER_POLL_INTERVAL_MS);
1139
1670
  }
1140
1671
 
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
- });
1672
+ await new Promise(() => {});
1187
1673
 
1188
- let shuttingDown = false;
1189
- const shutdown = async (signal) => {
1190
- if (shuttingDown) return;
1191
- shuttingDown = true;
1192
- process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
1193
- if (heartbeatTimer) {
1194
- clearInterval(heartbeatTimer);
1195
- heartbeatTimer = null;
1196
- }
1197
- try {
1198
- removeAppDevSession(sessionId, sessionsFilePath);
1199
- } catch {
1200
- // ignore cleanup failures during shutdown
1201
- }
1202
- try {
1203
- await devServer.close();
1204
- } catch {
1205
- // ignore cleanup failures during shutdown
1206
- }
1207
- process.exit(EXIT_CODES.ok);
1208
- };
1674
+ return EXIT_CODES.ok;
1675
+ }
1209
1676
 
1210
- process.on('SIGINT', () => {
1211
- void shutdown('SIGINT');
1212
- });
1213
- process.on('SIGTERM', () => {
1214
- void shutdown('SIGTERM');
1677
+ async function appsRootsListHandler(ctx) {
1678
+ const registry = readAppDevRoots();
1679
+ return ctx.output.emitSuccess({
1680
+ command: ctx.spec.command_path.join(' '),
1681
+ data: { roots: registry.roots },
1682
+ humanSummary: appDevRootsTable(registry.roots),
1683
+ meta: { mutating: false },
1215
1684
  });
1685
+ }
1216
1686
 
1217
- await new Promise(() => {});
1218
-
1219
- return EXIT_CODES.ok;
1687
+ async function appsRootsRemoveHandler(ctx) {
1688
+ const rootDir = resolve(ctx.args.dir);
1689
+ const result = removeAppDevRoot(rootDir);
1690
+ return ctx.output.emitSuccess({
1691
+ command: ctx.spec.command_path.join(' '),
1692
+ data: {
1693
+ removed: result.removed,
1694
+ root: result.path,
1695
+ roots: result.registry.roots,
1696
+ },
1697
+ humanSummary: result.removed
1698
+ ? `Removed app development root: ${result.path}`
1699
+ : `App development root was not registered: ${result.path}`,
1700
+ hints: result.removed
1701
+ ? []
1702
+ : [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
1703
+ meta: { mutating: result.removed },
1704
+ });
1220
1705
  }
1221
1706
 
1222
1707
  async function appsBuildHandler(ctx) {
@@ -1260,7 +1745,7 @@ async function appsVerifyHandler(ctx) {
1260
1745
  if (!ctx.runtime.jwt) {
1261
1746
  throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
1262
1747
  }
1263
- linkedState = readLinkedState(projectDir);
1748
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1264
1749
  if (!linkedState?.app_id) {
1265
1750
  throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
1266
1751
  }
@@ -1493,7 +1978,7 @@ async function appsScreenshotHandler(ctx) {
1493
1978
  if (!ctx.runtime.jwt) {
1494
1979
  throw usageError('Live mode requires CLI auth. Run notis login and retry.');
1495
1980
  }
1496
- linkedState = readLinkedState(projectDir);
1981
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1497
1982
  if (!linkedState?.app_id) {
1498
1983
  throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1499
1984
  }
@@ -1692,10 +2177,15 @@ async function appsScreenshotHandler(ctx) {
1692
2177
  }
1693
2178
  }
1694
2179
 
1695
- export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
2180
+ export function buildLinkedAppState(
2181
+ existingState,
2182
+ appId,
2183
+ linkedAt = new Date().toISOString(),
2184
+ ) {
2185
+ const sameIdPromotion = existingState?.dev_app_id === appId;
1696
2186
  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 } : {}),
2187
+ ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2188
+ ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
1699
2189
  app_id: appId,
1700
2190
  linked_at: linkedAt,
1701
2191
  };
@@ -1709,7 +2199,12 @@ async function appsLinkHandler(ctx) {
1709
2199
 
1710
2200
  writeLinkedState(
1711
2201
  projectDir,
1712
- buildLinkedAppState(readLinkedState(projectDir), appId),
2202
+ buildLinkedAppState(
2203
+ readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2204
+ appId,
2205
+ new Date().toISOString(),
2206
+ ),
2207
+ linkedStateProfileKey(ctx.runtime),
1713
2208
  );
1714
2209
 
1715
2210
  return ctx.output.emitSuccess({
@@ -1726,8 +2221,10 @@ async function appsPullHandler(ctx) {
1726
2221
  const appId = ctx.args.appId;
1727
2222
  const result = await runToolCommand({
1728
2223
  runtime: ctx.runtime,
1729
- toolName: GET_APP_TOOL,
1730
- arguments_: { app_id: appId },
2224
+ // Pull is source retrieval plus local link state. LIST_APPS is deliberately
2225
+ // non-materializing; GET_APP hydrates missing declared databases and would
2226
+ // turn a read-only pull into a remote mutation before build/verification.
2227
+ toolName: LIST_APPS_TOOL,
1731
2228
  });
1732
2229
  if (
1733
2230
  ctx.runtime.credentialKind === 'oauth'
@@ -1735,10 +2232,16 @@ async function appsPullHandler(ctx) {
1735
2232
  ) {
1736
2233
  throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1737
2234
  }
1738
- 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';
2235
+ const apps = Array.isArray(result.payload?.apps) ? result.payload.apps : [];
2236
+ const app = apps.find((candidate) => (candidate?.app_id || candidate?.id) === appId);
2237
+ if (!app) {
2238
+ throw usageError(`App ${appId} is not accessible to the active profile.`);
2239
+ }
2240
+ const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
2241
+ const targetDir = ctx.args.dir
2242
+ ? resolveProjectDir(ctx.args.dir)
2243
+ : defaultAppProjectDir(defaultDir);
2244
+ const version = ctx.options.sourceVersion || 'latest';
1742
2245
 
1743
2246
  const pulled = await pullAppSource({
1744
2247
  apiBase: ctx.runtime.apiBase,
@@ -1747,6 +2250,7 @@ async function appsPullHandler(ctx) {
1747
2250
  targetDir,
1748
2251
  version,
1749
2252
  force: Boolean(ctx.options.force),
2253
+ profileKey: linkedStateProfileKey(ctx.runtime),
1750
2254
  });
1751
2255
 
1752
2256
  const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
@@ -1757,11 +2261,11 @@ async function appsPullHandler(ctx) {
1757
2261
  project_dir: pulled.projectDir,
1758
2262
  version: pulled.version,
1759
2263
  },
1760
- humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
2264
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
1761
2265
  });
1762
2266
  }
1763
2267
 
1764
- function updateLinkedDeployState(projectDir, linkedState, appId, version) {
2268
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
1765
2269
  if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1766
2270
  return;
1767
2271
  }
@@ -1771,14 +2275,29 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
1771
2275
  version,
1772
2276
  linked_at: linkedState.linked_at || new Date().toISOString(),
1773
2277
  deployed_at: new Date().toISOString(),
1774
- });
2278
+ }, profileKey);
2279
+ }
2280
+
2281
+ // Resolve first deploy without mutating. Promotion is part of the final
2282
+ // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2283
+ // cannot leave an empty installed app behind.
2284
+ function resolveDeployTarget(ctx, projectDir) {
2285
+ if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2286
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2287
+ const state = readLinkedState(projectDir, profileKey);
2288
+ if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2289
+ if (!state?.dev_app_id) {
2290
+ return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2291
+ }
2292
+ return { appId: state.dev_app_id, needsPromotion: true };
1775
2293
  }
1776
2294
 
1777
2295
  async function appsDeployHandler(ctx) {
1778
2296
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1779
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2297
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2298
+ const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
1780
2299
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1781
- const linkedState = readLinkedState(projectDir);
2300
+ const linkedState = readLinkedState(projectDir, profileKey);
1782
2301
  const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1783
2302
  ? linkedState.version
1784
2303
  : undefined;
@@ -1792,9 +2311,12 @@ async function appsDeployHandler(ctx) {
1792
2311
 
1793
2312
  // Direct deploy mode: upload to Supabase storage directly
1794
2313
  if (ctx.options.direct) {
2314
+ if (needsPromotion) {
2315
+ throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
2316
+ }
1795
2317
  await assertDirectDeployAccess(ctx.runtime, appId);
1796
2318
  const { version } = await directDeploy(projectDir, appId);
1797
- updateLinkedDeployState(projectDir, linkedState, appId, version);
2319
+ updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
1798
2320
  return ctx.output.emitSuccess({
1799
2321
  command: ctx.spec.command_path.join(' '),
1800
2322
  data: { app_id: appId, version, mode: 'direct' },
@@ -1827,6 +2349,7 @@ async function appsDeployHandler(ctx) {
1827
2349
  manifest,
1828
2350
  ...appRowFieldsFromManifest(manifest),
1829
2351
  ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2352
+ ...(needsPromotion ? { promote_dev_app: true } : {}),
1830
2353
  },
1831
2354
  mutating: true,
1832
2355
  idempotencyKey,
@@ -1868,7 +2391,35 @@ async function appsDeployHandler(ctx) {
1868
2391
  });
1869
2392
  }
1870
2393
 
1871
- updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion);
2394
+ if (needsPromotion) {
2395
+ const now = new Date().toISOString();
2396
+ writeLinkedState(projectDir, {
2397
+ ...linkedState,
2398
+ app_id: appId,
2399
+ linked_at: linkedState?.linked_at || now,
2400
+ version: deployedVersion,
2401
+ deployed_at: now,
2402
+ dev_app_id: undefined,
2403
+ dev_linked_at: undefined,
2404
+ }, profileKey);
2405
+ try {
2406
+ linkAppDevSessionTarget(
2407
+ { appId, targetAppId: appId },
2408
+ getAppDevSessionsFile(),
2409
+ );
2410
+ } catch (error) {
2411
+ ctx.output.emitProgress({
2412
+ phase: 'warning',
2413
+ message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
2414
+ });
2415
+ }
2416
+ ctx.output.emitProgress({
2417
+ phase: 'promote',
2418
+ message: `Promoted development app to installed app ${appId}`,
2419
+ });
2420
+ } else {
2421
+ updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
2422
+ }
1872
2423
  return ctx.output.emitSuccess({
1873
2424
  command: ctx.spec.command_path.join(' '),
1874
2425
  data: {
@@ -1891,7 +2442,7 @@ async function appsDuplicateHandler(ctx) {
1891
2442
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1892
2443
  // Either target an app explicitly, or duplicate whatever this project is
1893
2444
  // linked to, so `notis apps duplicate` works from inside a project.
1894
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2445
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
1895
2446
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1896
2447
 
1897
2448
  const copyDocuments = ctx.options.copyDocuments || 'declared';
@@ -1957,8 +2508,8 @@ async function appsPublishHandler(ctx) {
1957
2508
  }
1958
2509
 
1959
2510
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1960
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
1961
- const linkedState = readLinkedState(projectDir);
2511
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
2512
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1962
2513
  const appConfig = await loadAppConfig(projectDir);
1963
2514
  const readiness = inspectListingReadiness(projectDir, appConfig);
1964
2515
  if (!readiness.ready) {
@@ -2051,7 +2602,7 @@ async function appsDoctorHandler(ctx) {
2051
2602
  }
2052
2603
  }
2053
2604
 
2054
- const linkedState = readLinkedState(projectDir);
2605
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
2055
2606
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
2056
2607
 
2057
2608
  return ctx.output.emitSuccess({
@@ -2094,21 +2645,21 @@ export const appsCommandSpecs = [
2094
2645
  {
2095
2646
  command_path: ['apps', 'init'],
2096
2647
  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.',
2648
+ 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
2649
  args_schema: {
2099
2650
  arguments: [
2100
2651
  { token: '<name>', description: 'Display name for the app.' },
2101
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
2652
+ { 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
2653
  ],
2103
2654
  options: [
2104
- { flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
2655
+ { 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
2656
  ],
2106
2657
  },
2107
2658
  examples: [
2108
2659
  'notis apps scaffolds list',
2109
2660
  'notis apps init "Mind the Flo"',
2110
- 'notis apps init "My CRM" --from notis-database',
2111
- 'notis apps init "My App" ./my-app',
2661
+ 'notis apps init "My CRM" --from databases',
2662
+ 'notis apps init "My App" ~/code/my-app',
2112
2663
  ],
2113
2664
  mutates: true,
2114
2665
  idempotent: false,
@@ -2118,10 +2669,19 @@ export const appsCommandSpecs = [
2118
2669
  },
2119
2670
  {
2120
2671
  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'],
2672
+ summary: 'List published Store apps available as scaffolds.',
2673
+ 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.',
2674
+ args_schema: {
2675
+ arguments: [],
2676
+ options: [
2677
+ { flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
2678
+ ],
2679
+ },
2680
+ examples: [
2681
+ 'notis apps scaffolds list',
2682
+ 'notis apps scaffolds list --search journal',
2683
+ 'notis apps init "My App" --from databases',
2684
+ ],
2125
2685
  mutates: false,
2126
2686
  idempotent: true,
2127
2687
  require_auth: false,
@@ -2150,21 +2710,26 @@ export const appsCommandSpecs = [
2150
2710
  },
2151
2711
  {
2152
2712
  command_path: ['apps', 'dev'],
2153
- summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
2713
+ summary: 'Register a development root and connect its apps to the shared local development host.',
2154
2714
  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.',
2715
+ '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. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
2156
2716
  args_schema: {
2157
2717
  arguments: [
2158
2718
  { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
2159
2719
  ],
2160
2720
  options: [
2161
2721
  { 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.' },
2722
+ {
2723
+ flags: '--scratch',
2724
+ description:
2725
+ 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2726
+ + "instead of the installed app's resources. For fixture work and destructive experiments.",
2727
+ },
2163
2728
  {
2164
2729
  flags: '--live-data',
2165
2730
  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.',
2731
+ 'Deprecated: using the installed app\'s real resources is now the default. '
2732
+ + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2168
2733
  },
2169
2734
  {
2170
2735
  flags: '--grant-cloud-shell',
@@ -2178,7 +2743,7 @@ export const appsCommandSpecs = [
2178
2743
  'notis apps dev',
2179
2744
  'notis apps dev ./my-app',
2180
2745
  'notis apps dev ./workspace --port 5200',
2181
- 'notis apps dev --live-data # iterate on a view over the installed app\'s real rows',
2746
+ 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
2182
2747
  ],
2183
2748
  mutates: true,
2184
2749
  idempotent: true,
@@ -2186,6 +2751,35 @@ export const appsCommandSpecs = [
2186
2751
  backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2187
2752
  handler: appsDevHandler,
2188
2753
  },
2754
+ {
2755
+ command_path: ['apps', 'roots', 'list'],
2756
+ summary: 'List persistent machine-local Notis app development roots.',
2757
+ when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2758
+ args_schema: { arguments: [], options: [] },
2759
+ examples: ['notis apps roots list'],
2760
+ mutates: false,
2761
+ idempotent: true,
2762
+ require_auth: false,
2763
+ backend_call: { type: 'local', name: 'list_app_development_roots' },
2764
+ handler: appsRootsListHandler,
2765
+ },
2766
+ {
2767
+ command_path: ['apps', 'roots', 'remove'],
2768
+ summary: 'Stop watching a registered Notis app development root.',
2769
+ when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2770
+ args_schema: {
2771
+ arguments: [
2772
+ { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2773
+ ],
2774
+ options: [],
2775
+ },
2776
+ examples: ['notis apps roots remove ./old-apps'],
2777
+ mutates: true,
2778
+ idempotent: true,
2779
+ require_auth: false,
2780
+ backend_call: { type: 'local', name: 'remove_app_development_root' },
2781
+ handler: appsRootsRemoveHandler,
2782
+ },
2189
2783
  {
2190
2784
  command_path: ['apps', 'build'],
2191
2785
  summary: 'Build and package the app into .notis/output/.',
@@ -2294,18 +2888,21 @@ export const appsCommandSpecs = [
2294
2888
  command_path: ['apps', 'pull'],
2295
2889
  summary: 'Download a Notis app source snapshot into a local project folder.',
2296
2890
  when_to_use:
2297
- 'Edit an installed app locally. Pulls the persisted source, links the directory to the app/version, then continue with npm install, notis apps dev, notis apps build, and notis apps deploy.',
2891
+ 'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
2298
2892
  args_schema: {
2299
2893
  arguments: [
2300
2894
  { token: '<app-id>', description: 'Remote app ID to pull.' },
2301
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
2895
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
2302
2896
  ],
2303
2897
  options: [
2304
2898
  { flags: '--force', description: 'Overwrite a non-empty target directory.' },
2305
- { flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
2899
+ { flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
2306
2900
  ],
2307
2901
  },
2308
- examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
2902
+ examples: [
2903
+ 'notis apps pull abc123',
2904
+ 'notis apps pull abc123 ./my-app --force --source-version 3',
2905
+ ],
2309
2906
  mutates: true,
2310
2907
  idempotent: true,
2311
2908
  require_auth: true,
@@ -2316,7 +2913,7 @@ export const appsCommandSpecs = [
2316
2913
  command_path: ['apps', 'deploy'],
2317
2914
  summary: 'Build and upload the app to the linked Notis app.',
2318
2915
  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.',
2916
+ '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
2917
  args_schema: {
2321
2918
  arguments: [
2322
2919
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -2324,7 +2921,7 @@ export const appsCommandSpecs = [
2324
2921
  options: [
2325
2922
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
2326
2923
  { 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.' },
2924
+ { flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
2328
2925
  ],
2329
2926
  },
2330
2927
  examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],