@notis_ai/cli 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +125 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +44 -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 +1090 -418
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/handover.js +374 -0
  18. package/src/command-specs/index.js +9 -0
  19. package/src/command-specs/onboarding.js +69 -5
  20. package/src/command-specs/skills.js +56 -0
  21. package/src/command-specs/tools.js +6 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/app-dev-consumers.js +154 -0
  25. package/src/runtime/app-dev-host-lock.js +80 -0
  26. package/src/runtime/app-dev-roots.js +284 -0
  27. package/src/runtime/app-dev-server.js +229 -30
  28. package/src/runtime/app-dev-sessions.js +105 -145
  29. package/src/runtime/app-platform.js +1499 -161
  30. package/src/runtime/app-registry-scaffolds.js +367 -0
  31. package/src/runtime/base-skills.d.ts +20 -0
  32. package/src/runtime/base-skills.js +167 -0
  33. package/src/runtime/delegated-context.js +68 -0
  34. package/src/runtime/git.js +233 -0
  35. package/src/runtime/login-listener.js +15 -0
  36. package/src/runtime/oauth.js +1506 -141
  37. package/src/runtime/output.js +7 -0
  38. package/src/runtime/ports.js +16 -0
  39. package/src/runtime/profiles.js +0 -5
  40. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  41. package/src/runtime/skill-sync/index.ts +644 -0
  42. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  43. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  44. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  45. package/src/runtime/skill-sync/types.ts +103 -0
  46. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  47. package/src/runtime/store-screenshot.js +6 -1
  48. package/src/runtime/sync-skills.d.ts +37 -0
  49. package/src/runtime/sync-skills.js +215 -0
  50. package/src/runtime/transport.js +19 -2
  51. package/template/.harness/index.html.tmpl +116 -47
  52. package/template/app/layout.tsx +5 -1
  53. package/template/notis.config.ts +1 -0
  54. package/template/packages/sdk/package.json +2 -1
  55. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  56. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  57. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  58. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  59. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  60. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  61. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  62. package/template/packages/sdk/src/config.ts +71 -0
  63. package/template/packages/sdk/src/documents.ts +28 -1
  64. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  65. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  66. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  67. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  68. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  69. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  70. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  71. package/template/packages/sdk/src/index.ts +64 -2
  72. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  73. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  74. package/template/packages/sdk/src/interactions.ts +41 -0
  75. package/template/packages/sdk/src/provider.tsx +2 -1
  76. package/template/packages/sdk/src/runtime.ts +219 -3
  77. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  78. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  79. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  80. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  81. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  82. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  83. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  84. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  85. package/dist/scaffolds/notis-database/components.json +0 -20
  86. package/dist/scaffolds/notis-database/index.html +0 -12
  87. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  88. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  89. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  91. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  92. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  93. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  94. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  95. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  96. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  97. package/dist/scaffolds/notis-database/package.json +0 -32
  98. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  99. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  100. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  101. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  102. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  103. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  104. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
  105. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  115. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  116. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  117. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
  118. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  119. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
  120. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  121. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  122. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  123. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  124. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  125. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  126. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  127. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  128. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  129. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  130. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  131. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  132. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  133. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  134. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  135. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  136. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  137. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  138. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  139. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  140. package/dist/scaffolds/notis-journal/components.json +0 -20
  141. package/dist/scaffolds/notis-journal/index.html +0 -12
  142. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  148. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  149. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  150. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  151. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  152. package/dist/scaffolds/notis-journal/package.json +0 -34
  153. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  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/useMultiSelect.ts +0 -539
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  178. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  179. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  180. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  181. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  182. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
  183. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  184. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  185. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  186. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  187. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  188. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  189. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  190. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  191. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  192. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  193. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  194. package/dist/scaffolds/notis-notes/components.json +0 -20
  195. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  196. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  197. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  203. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  204. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  205. package/dist/scaffolds/notis-notes/package.json +0 -35
  206. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  207. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  231. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  232. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  233. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  234. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  235. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  236. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  237. package/dist/scaffolds/notis-random/README.md +0 -33
  238. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  239. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  240. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  241. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  242. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  243. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  244. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  245. package/dist/scaffolds/notis-random/components.json +0 -20
  246. package/dist/scaffolds/notis-random/index.html +0 -12
  247. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  248. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  249. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  250. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  251. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  252. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  253. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  254. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  255. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  257. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  258. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  259. package/dist/scaffolds/notis-random/package.json +0 -36
  260. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  261. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  262. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  263. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  264. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  266. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
  267. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
  268. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  269. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  270. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  271. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  279. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
  280. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  281. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
  282. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  283. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  284. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  285. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  286. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  287. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  288. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  289. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  290. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  291. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  292. package/dist/scaffolds.json +0 -49
  293. package/template/metadata/screenshot-1.png +0 -0
  294. package/template/metadata/screenshot-2.png +0 -0
  295. package/template/metadata/screenshot-3.png +0 -0
@@ -8,35 +8,49 @@
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto';
11
- import { spawn } from 'node:child_process';
12
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
11
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
13
12
  import { tmpdir } from 'node:os';
14
- import { basename, join, relative, resolve } from 'node:path';
13
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
14
 
16
- import { EXIT_CODES, usageError } from '../runtime/errors.js';
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
+ findUnknownScreenshotScenarios,
30
31
  inspectListingReadiness,
31
32
  resolveListingScreenshots,
32
33
  collectArtifactFiles,
33
34
  collectSourceFiles,
34
35
  resolveConfiguredAppSkills,
35
36
  normalizeAppCapabilities,
37
+ normalizeAppToolBindings,
38
+ normalizeAppSkillManifestPath,
36
39
  directDeploy,
37
40
  pullAppSource,
38
41
  } from '../runtime/app-platform.js';
42
+ import {
43
+ filterScaffoldCatalog,
44
+ loadScaffoldCatalog,
45
+ scaffoldRegistryLabel,
46
+ } from '../runtime/app-registry-scaffolds.js';
39
47
  import { startAppDevServer } from '../runtime/app-dev-server.js';
48
+ import {
49
+ discoverRegisteredAppProjects,
50
+ readAppDevRoots,
51
+ registerAppDevRoot,
52
+ removeAppDevRoot,
53
+ } from '../runtime/app-dev-roots.js';
40
54
  import {
41
55
  captureHarnessScreenshot,
42
56
  closeAgentBrowserSession,
@@ -46,12 +60,22 @@ import {
46
60
  import {
47
61
  getAppDevSessionsFile,
48
62
  heartbeatAppDevSession,
63
+ linkAppDevSessionTarget,
64
+ readAppDevSessions,
49
65
  removeAppDevSession,
50
66
  upsertAppDevSessions,
51
- waitForAppDevSessionMountAcknowledgements,
52
- waitForAppDevSessionRenderAcknowledgements,
53
67
  } from '../runtime/app-dev-sessions.js';
54
- import { getAvailablePort } from '../runtime/ports.js';
68
+ import {
69
+ releaseAppDevHostLock,
70
+ tryAcquireAppDevHostLock,
71
+ } from '../runtime/app-dev-host-lock.js';
72
+ import {
73
+ heartbeatAppDevConsumer,
74
+ hasAppDevConsumer,
75
+ readAppDevConsumers,
76
+ removeAppDevConsumer,
77
+ } from '../runtime/app-dev-consumers.js';
78
+ import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
55
79
  import { getCliMode } from '../runtime/cli-mode.js';
56
80
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
57
81
  import { httpRequest } from '../runtime/transport.js';
@@ -65,13 +89,142 @@ import {
65
89
 
66
90
  const DEFAULT_DEV_PORT = 5173;
67
91
  const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
68
- const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
92
+ const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
93
+ const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
94
+ const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
95
+
96
+ function projectIsWithinRoot(projectDir, rootDir) {
97
+ const nested = relative(rootDir, projectDir);
98
+ return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
99
+ }
100
+
101
+ /**
102
+ * A CLI launch may add one root, but the shared host always serves the complete
103
+ * machine registry. Desktop sets skipRootRegistration because it is reconciling
104
+ * an already-registered snapshot; this must never narrow discovery to the one
105
+ * project path used to start the host.
106
+ */
107
+ export function discoverAppDevLaunchProjects(rootDir, {
108
+ skipRootRegistration = false,
109
+ registerRoot = registerAppDevRoot,
110
+ discoverProjects = discoverRegisteredAppProjects,
111
+ } = {}) {
112
+ if (!skipRootRegistration) registerRoot(rootDir);
113
+ return discoverProjects();
114
+ }
115
+
116
+ /**
117
+ * Collapse duplicate local sources before they reach the shared loopback host.
118
+ * An explicitly registered root is more intentional than the implicit
119
+ * ~/.notis/apps root. If two equally intentional roots claim the same dev
120
+ * slug, omit that slug and keep serving every unrelated app.
121
+ */
122
+ export function selectCanonicalDevApps(candidates, rootsRegistry) {
123
+ const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
124
+ const groups = new Map();
125
+ for (const candidate of candidates) {
126
+ groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
127
+ }
128
+
129
+ const selected = [];
130
+ const warnings = [];
131
+ for (const [devSlug, group] of groups) {
132
+ if (group.length === 1) {
133
+ selected.push(group[0]);
134
+ continue;
135
+ }
136
+ const ranked = group.map((candidate) => {
137
+ const explicitRoots = roots.filter((root) => (
138
+ root?.implicit !== true
139
+ && typeof root?.path === 'string'
140
+ && projectIsWithinRoot(candidate.projectDir, root.path)
141
+ ));
142
+ const newestRegistration = explicitRoots.reduce((latest, root) => {
143
+ const timestamp = Date.parse(root.registeredAt || '');
144
+ return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
145
+ }, 0);
146
+ return {
147
+ candidate,
148
+ rank: explicitRoots.length > 0 ? 1 : 0,
149
+ newestRegistration,
150
+ };
151
+ }).sort((left, right) => (
152
+ right.rank - left.rank
153
+ || right.newestRegistration - left.newestRegistration
154
+ ));
155
+ const winner = ranked[0];
156
+ const runnerUp = ranked[1];
157
+ const unambiguous = winner.rank > runnerUp.rank
158
+ || winner.newestRegistration > runnerUp.newestRegistration;
159
+ if (unambiguous) {
160
+ selected.push(winner.candidate);
161
+ warnings.push(
162
+ `Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
163
+ );
164
+ continue;
165
+ }
166
+ warnings.push(
167
+ `Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
168
+ );
169
+ }
170
+ return { selected, warnings };
171
+ }
172
+
173
+ export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
174
+ const now = Date.now();
175
+ const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
176
+ .filter((session) => {
177
+ if (session.sourceHost !== true) return false;
178
+ const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
179
+ if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
180
+ if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
181
+ try {
182
+ process.kill(session.hostPid, 0);
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ });
188
+ const groups = new Map();
189
+ for (const session of sourceSessions) {
190
+ const key = `${session.hostPid || 0}:${session.sessionId}`;
191
+ groups.set(key, [...(groups.get(key) || []), session]);
192
+ }
193
+ for (const sessions of groups.values()) {
194
+ const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
195
+ const canonicalProjects = sessions.find((session) => (
196
+ Array.isArray(session.canonicalProjects)
197
+ ))?.canonicalProjects;
198
+ const discoveredProjects = sessions.find((session) => (
199
+ Array.isArray(session.discoveredProjects)
200
+ ))?.discoveredProjects;
201
+ const discoveryMatches = discoveredProjects
202
+ && JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
203
+ if (
204
+ discoveryMatches
205
+ && canonicalProjects
206
+ && canonicalProjects.every((projectDir) => byProject.has(projectDir))
207
+ ) {
208
+ return new Map(canonicalProjects.map((projectDir) => [
209
+ projectDir,
210
+ byProject.get(projectDir).bundleBaseUrl,
211
+ ]));
212
+ }
213
+ if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
214
+ return new Map(projectDirs.map((projectDir) => [
215
+ projectDir,
216
+ byProject.get(projectDir).bundleBaseUrl,
217
+ ]));
218
+ }
219
+ return null;
220
+ }
69
221
  const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
70
222
  const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
71
223
  const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
72
224
  const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
73
225
  const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
74
226
  const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
227
+ export const APP_DEPLOY_TIMEOUT_MS = 600_000;
75
228
 
76
229
  // ---------------------------------------------------------------------------
77
230
  // Formatters
@@ -95,40 +248,12 @@ function scaffoldsTable(scaffolds) {
95
248
  ]);
96
249
  }
97
250
 
98
- function hasNotisConfig(dir) {
99
- return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
100
- }
101
-
102
- function discoverAppDirs(projectDir) {
103
- if (hasNotisConfig(projectDir)) {
104
- return [projectDir];
105
- }
106
-
107
- const appsDir = join(projectDir, 'apps');
108
- if (!existsSync(appsDir)) {
109
- throw usageError(
110
- `No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
111
- );
112
- }
113
-
114
- const entries = readdirSync(appsDir, { withFileTypes: true });
115
- const candidates = [];
116
- for (const entry of entries) {
117
- if (!entry.isDirectory()) continue;
118
- if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
119
- const dir = join(appsDir, entry.name);
120
- if (hasNotisConfig(dir)) {
121
- candidates.push(dir);
122
- }
123
- }
124
-
125
- if (candidates.length === 0) {
126
- throw usageError(
127
- `Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
128
- );
129
- }
130
-
131
- return candidates;
251
+ function appDevRootsTable(roots) {
252
+ return formatTable(roots, [
253
+ { label: 'Folder', value: (root) => root.path },
254
+ { label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
255
+ { label: 'Registered', value: (root) => root.registeredAt || '' },
256
+ ]);
132
257
  }
133
258
 
134
259
  function decodeJwtSub(jwt) {
@@ -143,42 +268,11 @@ function decodeJwtSub(jwt) {
143
268
  }
144
269
  }
145
270
 
146
- export function developmentDesktopOpenCommand(
147
- url,
148
- {
149
- platform = process.platform,
150
- appName = null,
151
- bundleId = null,
152
- scheme = 'notis',
153
- } = {},
154
- ) {
155
- let command;
156
- let args;
157
- if (platform === 'darwin') {
158
- command = 'open';
159
- args = bundleId && scheme === 'notis'
160
- ? ['-b', bundleId, url]
161
- : appName && scheme === 'notis'
162
- ? ['-a', appName, url]
163
- : [url];
164
- } else if (platform === 'win32') {
165
- command = 'cmd';
166
- args = ['/c', 'start', '', url];
167
- } else {
168
- command = 'xdg-open';
169
- args = [url];
170
- }
171
- return { command, args };
172
- }
173
-
174
- function openInBrowser(url, options = {}) {
175
- const { command, args } = developmentDesktopOpenCommand(url, options);
176
- try {
177
- const child = spawn(command, args, { stdio: 'ignore' });
178
- child.on('error', () => {});
179
- } catch {
180
- // Non-fatal. The URL is printed in the CLI output.
181
- }
271
+ function linkedStateProfileKey(runtime) {
272
+ return appLinkedStateProfileKey({
273
+ apiBase: runtime?.apiBase,
274
+ userId: decodeJwtSub(runtime?.jwt),
275
+ });
182
276
  }
183
277
 
184
278
  function slugify(value) {
@@ -189,8 +283,8 @@ function slugify(value) {
189
283
  .replace(/(^-|-$)+/g, '');
190
284
  }
191
285
 
192
- function buildDevInstallSlug(appName) {
193
- const base = slugify(appName);
286
+ function buildDevInstallSlug(appConfig) {
287
+ const base = slugify(appConfig?.devSlug || appConfig?.name);
194
288
  if (!base) {
195
289
  return '';
196
290
  }
@@ -223,61 +317,6 @@ function pickDefaultRouteSlug(manifest) {
223
317
  return firstWithSlug ? firstWithSlug.slug : null;
224
318
  }
225
319
 
226
- const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
227
-
228
- export function resolveDevelopmentDesktopScheme(env = process.env, worktreeRuntime = null) {
229
- // Mirror the desktop app's own scheme resolution (electron main + forge config):
230
- // local dev launches register `notis-dev`, while installed prod/beta builds claim
231
- // `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
232
- // prod/beta app instead of the local dev app.
233
- const scheme = (
234
- worktreeRuntime?.desktop_deep_link_scheme
235
- || env.NOTIS_DESKTOP_DEEP_LINK_SCHEME
236
- || ''
237
- ).trim();
238
- return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
239
- }
240
-
241
- export function resolveDevelopmentDesktopAppName(runtime = {}) {
242
- const explicit = String(runtime.worktreeRuntime?.desktop_app_name || '').trim();
243
- if (explicit) {
244
- return explicit;
245
- }
246
- try {
247
- return new URL(runtime.apiBase).hostname === 'api-beta.notis.ai'
248
- ? 'Notis Beta'
249
- : 'Notis';
250
- } catch {
251
- return 'Notis';
252
- }
253
- }
254
-
255
- export function resolveDevelopmentDesktopBundleId(runtime = {}) {
256
- return resolveDevelopmentDesktopAppName(runtime) === 'Notis Beta'
257
- ? 'ai.notis.desktop.beta'
258
- : 'ai.notis.desktop';
259
- }
260
-
261
- export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
262
- const route = String(appHref || '/store').replace(/^\/+/, '');
263
- const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
264
- return `${normalizedScheme}://${route || 'store'}`;
265
- }
266
-
267
- export function buildMountedDevelopmentDesktopUrl(appHref, scheme, sessionId) {
268
- const url = new URL(buildDevelopmentDesktopUrl(appHref, scheme));
269
- url.searchParams.set('notis_dev_session', sessionId);
270
- return url.toString();
271
- }
272
-
273
- export function shouldOpenDevelopmentTab(options = {}) {
274
- // Commander stores the negatable `--no-open` flag as `options.open === false`;
275
- // it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
276
- // `--no-open` was silently ignored and `apps dev` always ran `open notis://…`,
277
- // which macOS routes to the installed prod/beta desktop app.
278
- return options.open !== false;
279
- }
280
-
281
320
  export function buildDevelopmentAppHref({
282
321
  appSlug,
283
322
  appId,
@@ -404,7 +443,17 @@ function harnessErrorMessage(error) {
404
443
  return error.message || error.reason || error.type || JSON.stringify(error);
405
444
  }
406
445
 
407
- function assertHarnessResult(result, route, databaseSlugs) {
446
+ function runtimeCallLabel(call) {
447
+ if (call?.op === 'callTool') {
448
+ return call?.args?.name || 'callTool';
449
+ }
450
+ if (call?.op === 'request') {
451
+ return `request ${call?.args?.path || ''}`.trim();
452
+ }
453
+ return call?.op || 'runtime call';
454
+ }
455
+
456
+ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
408
457
  const assertions = [];
409
458
  if (result.tool_error) {
410
459
  assertions.push({
@@ -436,8 +485,9 @@ function assertHarnessResult(result, route, databaseSlugs) {
436
485
  details: error,
437
486
  });
438
487
  }
488
+ const runtimeCalls = result.runtimeCalls || [];
439
489
  const declaredDatabaseSet = new Set(databaseSlugs);
440
- const databaseQueries = (result.runtimeCalls || []).filter(
490
+ const databaseQueries = runtimeCalls.filter(
441
491
  (call) =>
442
492
  call?.op === 'callTool'
443
493
  && localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
@@ -465,6 +515,36 @@ function assertHarnessResult(result, route, databaseSlugs) {
465
515
  details: { databaseSlug: collectionDatabase },
466
516
  });
467
517
  }
518
+ if (mode === 'live') {
519
+ // In live mode an app that catches every failed call and renders its error
520
+ // state still mounts cleanly, so the render assertions above all pass. Only
521
+ // the recorded outcomes reveal that nothing real came back.
522
+ if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
523
+ assertions.push({
524
+ ok: false,
525
+ code: 'all_runtime_calls_failed',
526
+ message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
527
+ details: {
528
+ failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
529
+ },
530
+ });
531
+ }
532
+ for (const databaseSlug of databaseSlugs) {
533
+ const queries = databaseQueries.filter(
534
+ (call) => call?.args?.arguments?.database_slug === databaseSlug,
535
+ );
536
+ // A call still in flight when the harness was read has ok === null; only
537
+ // an explicit failure with no successful sibling is a real problem.
538
+ if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
539
+ assertions.push({
540
+ ok: false,
541
+ code: 'failed_database_query',
542
+ message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
543
+ details: { databaseSlug },
544
+ });
545
+ }
546
+ }
547
+ }
468
548
  return assertions;
469
549
  }
470
550
 
@@ -479,6 +559,9 @@ function renderVerifyReport({ summary, results, noBrowser }) {
479
559
  lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
480
560
  for (const assertion of result.assertions || []) {
481
561
  lines.push(` - ${assertion.message}`);
562
+ for (const failure of assertion.details?.failed || []) {
563
+ lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
564
+ }
482
565
  }
483
566
  }
484
567
  if (noBrowser) {
@@ -514,9 +597,10 @@ function buildManifestForDev(appConfig) {
514
597
  databases: appConfig.databases || [],
515
598
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
516
599
  tools: appConfig.tools || [],
600
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
517
601
  skills: (appConfig.skills || []).map((skill) => ({
518
602
  key: skill.key,
519
- path: String(skill.path || '').replace(/^\.\/+/, ''),
603
+ path: normalizeAppSkillManifestPath(skill.path),
520
604
  name: skill.name,
521
605
  description: skill.description || null,
522
606
  })),
@@ -524,8 +608,15 @@ function buildManifestForDev(appConfig) {
524
608
  };
525
609
  }
526
610
 
527
- export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills }) {
528
- const devSlug = buildDevInstallSlug(appConfig.name);
611
+ export function buildEnsureDevInstallArguments({
612
+ appConfig,
613
+ manifest,
614
+ linkedState,
615
+ skills,
616
+ useInstalledDatabases = false,
617
+ approvedCapabilities = null,
618
+ }) {
619
+ const devSlug = buildDevInstallSlug(appConfig);
529
620
  const arguments_ = {
530
621
  dev_slug: devSlug,
531
622
  name: appConfig.name,
@@ -535,34 +626,117 @@ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedStat
535
626
  if (linkedState?.dev_app_id) {
536
627
  arguments_.app_id = linkedState.dev_app_id;
537
628
  }
629
+ if (useInstalledDatabases && linkedState?.app_id) {
630
+ arguments_.installed_app_id = linkedState.app_id;
631
+ }
632
+ // Sent on every ensure call, including as `false`, so a `--scratch` session
633
+ // puts the app on its own dev copies and the next plain session puts it back
634
+ // on the installed app's databases.
635
+ arguments_.use_installed_databases = Boolean(useInstalledDatabases);
636
+ if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
637
+ arguments_.approved_capabilities = approvedCapabilities;
638
+ }
538
639
  return arguments_;
539
640
  }
540
641
 
642
+ const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
643
+
644
+ /**
645
+ * Collect the developer's explicit approval for `cloudComputer: 'shell'`.
646
+ *
647
+ * The server never grants shell from authorship alone — holding an app's source
648
+ * (a scaffold, a pulled repo) is not consent to full-authority commands on the
649
+ * cloud computer — so `apps dev` asks once, records the answer in the linked
650
+ * state, and passes the grant with the ensure call. Runs before the parallel
651
+ * ensure fan-out so the prompt cannot interleave.
652
+ */
653
+ export async function resolveCloudShellConsent({
654
+ appConfig,
655
+ projectDir,
656
+ grantCloudShell = false,
657
+ logger = console,
658
+ profileKey = null,
659
+ }) {
660
+ const capabilities = normalizeAppCapabilities(appConfig.capabilities);
661
+ if (capabilities.cloudComputer !== 'shell') {
662
+ return null;
663
+ }
664
+ const approved = ['cloud_computer_read', 'cloud_computer_shell'];
665
+ const linkedState = readLinkedState(projectDir, profileKey) || {};
666
+ const recordDecision = (decision) => {
667
+ writeLinkedState(
668
+ projectDir,
669
+ { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
670
+ profileKey,
671
+ );
672
+ };
673
+
674
+ if (grantCloudShell) {
675
+ recordDecision('granted');
676
+ return approved;
677
+ }
678
+ const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
679
+ if (recorded === 'granted') {
680
+ return approved;
681
+ }
682
+ const declineHint =
683
+ `${appConfig.name}: cloud computer shell stays denied for this dev session. `
684
+ + 'Re-run with --grant-cloud-shell to approve it.';
685
+ if (recorded === 'declined') {
686
+ logger.warn(declineHint);
687
+ return null;
688
+ }
689
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
690
+ logger.warn(declineHint);
691
+ return null;
692
+ }
693
+ const { createInterface } = await import('node:readline/promises');
694
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
695
+ try {
696
+ const answer = (await prompt.question(
697
+ `${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
698
+ + 'read or write files on your cloud computer with the same authority as your own '
699
+ + 'agent. Allow for this dev app? [y/N] ',
700
+ )).trim().toLowerCase();
701
+ const granted = answer === 'y' || answer === 'yes';
702
+ recordDecision(granted ? 'granted' : 'declined');
703
+ if (!granted) {
704
+ logger.warn(declineHint);
705
+ }
706
+ return granted ? approved : null;
707
+ } finally {
708
+ prompt.close();
709
+ }
710
+ }
711
+
541
712
  export async function ensureDevInstall({
542
713
  ctx,
543
714
  appConfig,
544
715
  projectDir,
545
716
  idempotencyKey,
717
+ useInstalledDatabases = false,
718
+ approvedCapabilities = null,
546
719
  runTool = runToolCommand,
547
720
  }) {
548
721
  if (!appConfig.name) {
549
722
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
550
723
  }
551
- const devSlug = buildDevInstallSlug(appConfig.name);
724
+ const devSlug = buildDevInstallSlug(appConfig);
552
725
  if (!devSlug) {
553
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
726
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
554
727
  }
555
728
 
556
729
  const manifest = buildManifestForDev(appConfig);
557
730
  const skills = resolveConfiguredAppSkills(appConfig, projectDir);
558
- let linkedState = readLinkedState(projectDir);
731
+ const profileKey = linkedStateProfileKey(ctx.runtime);
732
+ let linkedState = readLinkedState(projectDir, profileKey);
559
733
  let linkedApp = null;
560
734
  if (linkedState?.dev_app_id) {
561
735
  const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
562
736
  if (!devApp || devApp.manifest?.is_dev !== true) {
563
737
  const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
564
738
  linkedState = rest;
565
- writeLinkedState(projectDir, linkedState);
739
+ writeLinkedState(projectDir, linkedState, profileKey);
566
740
  }
567
741
  }
568
742
  if (linkedState?.app_id) {
@@ -577,18 +751,98 @@ export async function ensureDevInstall({
577
751
  dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
578
752
  } : {}),
579
753
  };
580
- writeLinkedState(projectDir, linkedState);
754
+ writeLinkedState(projectDir, linkedState, profileKey);
581
755
  linkedApp = null;
582
756
  }
583
757
  }
584
- const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
585
- const ensureResult = await runTool({
586
- runtime: ctx.runtime,
587
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
588
- arguments_: ensureArguments,
589
- mutating: true,
590
- idempotencyKey,
758
+ // A checkout can be shared by Beta and a source-workspace desktop. Its
759
+ // installed app link is valid for the Beta account but intentionally
760
+ // inaccessible to the worktree's test user. Preserve that durable link for
761
+ // Beta, while omitting it from this run so the local dev app uses its own
762
+ // resources instead of sending an unauthorized installed_app_id to ensure.
763
+ if (!linkedState?.app_id) {
764
+ const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
765
+ ctx.runtime,
766
+ devSlug,
767
+ linkedState?.dev_app_id,
768
+ projectDir,
769
+ profileKey,
770
+ runTool,
771
+ );
772
+ const installedCandidates = installedResolution.candidates;
773
+ if (installedCandidates.length > 1) {
774
+ if (installedResolution.source === 'persisted-project-link') {
775
+ throw usageError(
776
+ `Multiple accessible installed apps are persisted for ${projectDir}. `
777
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
778
+ );
779
+ }
780
+ throw usageError(
781
+ `Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
782
+ + `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
783
+ );
784
+ }
785
+ if (installedCandidates.length === 1) {
786
+ const now = new Date().toISOString();
787
+ linkedApp = installedCandidates[0];
788
+ linkedState = {
789
+ ...(linkedState || {}),
790
+ app_id: linkedApp.app_id || linkedApp.id,
791
+ linked_at: now,
792
+ auto_linked_at: now,
793
+ };
794
+ writeLinkedState(projectDir, linkedState, profileKey);
795
+ }
796
+ }
797
+ const runtimeLinkedState = linkedState?.app_id && !linkedApp
798
+ ? Object.fromEntries(
799
+ Object.entries(linkedState).filter(([key]) => ![
800
+ 'app_id', 'linked_at', 'deployed_at', 'version',
801
+ ].includes(key)),
802
+ )
803
+ : linkedState;
804
+ const ensureArguments = buildEnsureDevInstallArguments({
805
+ appConfig,
806
+ manifest,
807
+ linkedState: runtimeLinkedState,
808
+ skills,
809
+ useInstalledDatabases,
810
+ approvedCapabilities,
591
811
  });
812
+ let ensureResult;
813
+ try {
814
+ ensureResult = await runTool({
815
+ runtime: ctx.runtime,
816
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
817
+ arguments_: ensureArguments,
818
+ mutating: true,
819
+ idempotencyKey,
820
+ });
821
+ } catch (error) {
822
+ const backendCode = error?.details?.error?.code;
823
+ if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
824
+ // The authenticated environment can outlive an older local API profile.
825
+ // If that profile remembered a different dev row while this user already
826
+ // owns the exact canonical dev slug, the backend refuses a duplicate.
827
+ // Retry without the stale row id so the backend deterministically adopts
828
+ // the unique slug owner; preserve the installed target and every grant.
829
+ const staleDevAppId = linkedState.dev_app_id;
830
+ const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
831
+ linkedState = rest;
832
+ writeLinkedState(projectDir, linkedState, profileKey);
833
+ const retryArguments = { ...ensureArguments };
834
+ delete retryArguments.app_id;
835
+ ensureResult = await runTool({
836
+ runtime: ctx.runtime,
837
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
838
+ arguments_: retryArguments,
839
+ mutating: true,
840
+ idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
841
+ });
842
+ } else {
843
+ throw error;
844
+ }
845
+ }
592
846
  const appId = ensureResult.payload.app_id;
593
847
  if (!appId) {
594
848
  throw usageError('Dev installation tool did not return an app_id.');
@@ -599,7 +853,7 @@ export async function ensureDevInstall({
599
853
  ...(linkedState || {}),
600
854
  dev_app_id: appId,
601
855
  dev_linked_at: linkedState?.dev_linked_at || now,
602
- });
856
+ }, profileKey);
603
857
  }
604
858
  return {
605
859
  slug: ensureResult.payload.slug || devSlug,
@@ -609,10 +863,11 @@ export async function ensureDevInstall({
609
863
  projectDir,
610
864
  manifest,
611
865
  created: ensureResult.payload.created || false,
612
- linkedAppId: linkedState?.app_id || null,
613
- targetAppId: linkedState?.app_id || null,
866
+ linkedAppId: runtimeLinkedState?.app_id || null,
867
+ targetAppId: runtimeLinkedState?.app_id || null,
614
868
  targetAppSlug: linkedApp?.slug || null,
615
869
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
870
+ liveData: ensureResult.payload.live_data || null,
616
871
  };
617
872
  }
618
873
 
@@ -631,6 +886,12 @@ function databaseMaterializationWarnings(apps) {
631
886
  return warnings;
632
887
  }
633
888
 
889
+ function liveDataWarnings(apps) {
890
+ return apps
891
+ .filter((app) => app.liveData?.warning)
892
+ .map((app) => `${app.name}: ${app.liveData.warning}`);
893
+ }
894
+
634
895
  async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
635
896
  const result = await runTool({
636
897
  runtime,
@@ -654,6 +915,63 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
654
915
  throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
655
916
  }
656
917
 
918
+ async function findInstalledLinkCandidatesForLegacyDevState(
919
+ runtime,
920
+ devSlug,
921
+ devAppId,
922
+ projectDir,
923
+ profileKey,
924
+ runTool = runToolCommand,
925
+ ) {
926
+ const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
927
+ if (!installedSlug) return { candidates: [], source: null };
928
+
929
+ const result = await runTool({
930
+ runtime,
931
+ toolName: LIST_APPS_TOOL,
932
+ });
933
+ const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
934
+ (app?.app_id || app?.id)
935
+ && (app.app_id || app.id) !== devAppId
936
+ && app?.manifest?.is_dev !== true
937
+ ));
938
+
939
+ // A project-local app id is stronger evidence than a slug. Older CLI runs
940
+ // could persist that explicit link under another API/user profile before
941
+ // the machine-local discovery host mounted the same source here. Migrate it
942
+ // only when exactly one such id is accessible in the current environment;
943
+ // multiple accessible ids remain ambiguous and fail closed below.
944
+ const rawState = readLinkedState(projectDir) || {};
945
+ const persistedAppIds = new Set();
946
+ if (rawState.app_id) persistedAppIds.add(rawState.app_id);
947
+ const profiles = rawState.profiles && typeof rawState.profiles === 'object'
948
+ ? rawState.profiles
949
+ : {};
950
+ for (const [storedProfileKey, state] of Object.entries(profiles)) {
951
+ if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
952
+ if (state.app_id) persistedAppIds.add(state.app_id);
953
+ }
954
+ const persistedCandidates = accessibleInstalledApps.filter((app) => (
955
+ persistedAppIds.has(app.app_id || app.id)
956
+ ));
957
+ if (persistedCandidates.length) {
958
+ return { candidates: persistedCandidates, source: 'persisted-project-link' };
959
+ }
960
+
961
+ // The database row slug can change when a Store app is installed or an old
962
+ // development app is promoted. The shipped source manifest keeps the
963
+ // canonical app slug, so accept either exact field. Display names are never
964
+ // considered.
965
+ const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
966
+ const canonicalSlugs = new Set([
967
+ app?.slug,
968
+ app?.manifest?.app?.slug,
969
+ ].map(slugify).filter(Boolean));
970
+ return canonicalSlugs.has(installedSlug);
971
+ });
972
+ return { candidates: exactSlugCandidates, source: 'exact-slug' };
973
+ }
974
+
657
975
  export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
658
976
  const app = await getAccessibleApp(runtime, appId, runTool);
659
977
  if (!app) {
@@ -662,6 +980,14 @@ export async function assertDirectDeployAccess(runtime, appId, runTool = runTool
662
980
  if (app.apps_access?.has_access !== true) {
663
981
  throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
664
982
  }
983
+ if (app.can_edit !== true) {
984
+ throw usageError(`Direct deploy requires edit access to app ${appId}.`);
985
+ }
986
+ if (app.manifest?.is_dev === true) {
987
+ throw usageError(
988
+ 'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
989
+ );
990
+ }
665
991
  return app;
666
992
  }
667
993
 
@@ -698,10 +1024,12 @@ async function appsListHandler(ctx) {
698
1024
  }
699
1025
 
700
1026
  async function appsInitHandler(ctx) {
701
- const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
1027
+ const projectDir = ctx.args.dir
1028
+ ? resolveProjectDir(ctx.args.dir)
1029
+ : defaultAppProjectDir(slugify(ctx.args.name));
702
1030
  const fromSlug = ctx.options.from || null;
703
1031
 
704
- scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
1032
+ await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
705
1033
 
706
1034
  return ctx.output.emitSuccess({
707
1035
  command: ctx.spec.command_path.join(' '),
@@ -717,14 +1045,20 @@ async function appsInitHandler(ctx) {
717
1045
  }
718
1046
 
719
1047
  async function appsScaffoldsListHandler(ctx) {
720
- const scaffolds = loadScaffoldCatalog();
1048
+ const searchTerm = ctx.options.search || null;
1049
+ const catalog = await loadScaffoldCatalog();
1050
+ const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
1051
+ const registry = scaffoldRegistryLabel();
1052
+ const emptyMessage = searchTerm
1053
+ ? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
1054
+ : `No published scaffolds found in ${registry}.`;
721
1055
  return ctx.output.emitSuccess({
722
1056
  command: ctx.spec.command_path.join(' '),
723
- data: { scaffolds },
1057
+ data: { scaffolds, registry, search: searchTerm },
724
1058
  humanSummary: scaffolds.length
725
- ? `Found ${scaffolds.length} bundled scaffolds`
726
- : 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
727
- renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
1059
+ ? `Found ${scaffolds.length} published scaffolds in ${registry}`
1060
+ : emptyMessage,
1061
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
728
1062
  });
729
1063
  }
730
1064
 
@@ -754,7 +1088,7 @@ async function appsCreateHandler(ctx) {
754
1088
  writeLinkedState(projectDir, {
755
1089
  app_id: app.id,
756
1090
  linked_at: new Date().toISOString(),
757
- });
1091
+ }, linkedStateProfileKey(ctx.runtime));
758
1092
  }
759
1093
 
760
1094
  return ctx.output.emitSuccess({
@@ -777,7 +1111,94 @@ async function appsCreateHandler(ctx) {
777
1111
 
778
1112
  async function appsDevHandler(ctx) {
779
1113
  const rootDir = resolveProjectDir(ctx.args.dir || '.');
780
- const appDirs = discoverAppDirs(rootDir);
1114
+ const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
1115
+ let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
1116
+ const discoveredAppDirs = [...appDirs];
1117
+ const sessionsFilePath = getAppDevSessionsFile();
1118
+ let sharedBundleBaseUrls = null;
1119
+ const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
1120
+ if (sharedBundlesRaw) {
1121
+ let parsed;
1122
+ try {
1123
+ parsed = JSON.parse(sharedBundlesRaw);
1124
+ } catch {
1125
+ throw usageError('The shared app development bundle map is invalid.');
1126
+ }
1127
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1128
+ throw usageError('The shared app development bundle map must be an object.');
1129
+ }
1130
+ const sharedProjectDirs = Object.keys(parsed);
1131
+ const discoveredProjects = new Set(appDirs);
1132
+ for (const projectDir of sharedProjectDirs) {
1133
+ if (!discoveredProjects.has(projectDir)) {
1134
+ throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
1135
+ }
1136
+ }
1137
+ appDirs = sharedProjectDirs;
1138
+ sharedBundleBaseUrls = new Map();
1139
+ for (const projectDir of appDirs) {
1140
+ const value = parsed[projectDir];
1141
+ let url;
1142
+ try {
1143
+ url = new URL(String(value || ''));
1144
+ } catch {
1145
+ throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
1146
+ }
1147
+ if (
1148
+ url.protocol !== 'http:'
1149
+ || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
1150
+ ) {
1151
+ throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
1152
+ }
1153
+ sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
1154
+ }
1155
+ }
1156
+
1157
+ if (appDirs.length === 0) {
1158
+ return ctx.output.emitSuccess({
1159
+ command: ctx.spec.command_path.join(' '),
1160
+ data: {
1161
+ registered_root: skipRootRegistration ? null : rootDir,
1162
+ apps: [],
1163
+ watching_on_desktop_launch: true,
1164
+ },
1165
+ humanSummary: [
1166
+ skipRootRegistration
1167
+ ? 'No registered Notis apps are present yet.'
1168
+ : `Registered app development root: ${rootDir}`,
1169
+ 'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
1170
+ ].join('\n'),
1171
+ hints: [
1172
+ { command: 'notis apps roots list', reason: 'Show every persistent development root' },
1173
+ ],
1174
+ meta: { mutating: true },
1175
+ });
1176
+ }
1177
+
1178
+ if (!sharedBundleBaseUrls) {
1179
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1180
+ }
1181
+ let sourceHostLock = null;
1182
+ if (
1183
+ !sharedBundleBaseUrls
1184
+ && process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
1185
+ ) {
1186
+ sourceHostLock = tryAcquireAppDevHostLock({
1187
+ identity: '__mac_user__',
1188
+ apiBase: 'loopback-source',
1189
+ projectDir: SHARED_APP_DEV_HOST_KEY,
1190
+ });
1191
+ if (!sourceHostLock) {
1192
+ const startedAt = Date.now();
1193
+ while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
1194
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
1195
+ sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1196
+ }
1197
+ if (!sharedBundleBaseUrls) {
1198
+ throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
1199
+ }
1200
+ }
1201
+ }
781
1202
 
782
1203
  for (const projectDir of appDirs) {
783
1204
  const problems = detectProjectProblems(projectDir);
@@ -786,9 +1207,19 @@ async function appsDevHandler(ctx) {
786
1207
  }
787
1208
  }
788
1209
 
789
- const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
790
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
791
- throw usageError('Port must be between 1 and 65535.');
1210
+ let port = null;
1211
+ if (sharedBundleBaseUrls) {
1212
+ // The source host owns the only loopback listener and build watchers.
1213
+ } else if (ctx.options.port) {
1214
+ port = Number.parseInt(ctx.options.port, 10);
1215
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1216
+ throw usageError('Port must be between 1 and 65535.');
1217
+ }
1218
+ } else {
1219
+ // Concurrent sessions must not fight over the default port: the desktop
1220
+ // auto-starts every known project at launch, so only the first one can
1221
+ // have 5173 and the rest fall back to an ephemeral port.
1222
+ port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
792
1223
  }
793
1224
 
794
1225
  const mode = getCliMode();
@@ -797,16 +1228,39 @@ async function appsDevHandler(ctx) {
797
1228
  throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
798
1229
  }
799
1230
  const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
800
- const sessionsFilePath = getAppDevSessionsFile(
801
- ctx.runtime.worktreeRuntime?.app_dev_sessions_file,
802
- );
803
- const desktopScheme = resolveDevelopmentDesktopScheme(
804
- process.env,
805
- ctx.runtime.worktreeRuntime,
806
- );
807
- const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
808
- const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
809
1231
  const sessionId = randomUUID();
1232
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1233
+ const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
1234
+ const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
1235
+ ? `cli.${process.pid}.${sessionId}`
1236
+ : null;
1237
+ let manualConsumerTimer = null;
1238
+ if (manualConsumerInstanceId) {
1239
+ const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
1240
+ instanceId: manualConsumerInstanceId,
1241
+ userId: identity,
1242
+ apiBase,
1243
+ pid: process.pid,
1244
+ });
1245
+ heartbeatManualConsumer();
1246
+ manualConsumerTimer = setInterval(() => {
1247
+ try {
1248
+ heartbeatManualConsumer();
1249
+ } catch (error) {
1250
+ const message = error instanceof Error ? error.message : String(error);
1251
+ process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
1252
+ }
1253
+ }, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
1254
+ }
1255
+ // One app, one dataset: a dev session runs the app from local source against
1256
+ // the same databases the installed app uses, the way `npm run dev` serves the
1257
+ // same database as the deployed site. `--scratch` is the marked case; the
1258
+ // old `--live-data` flag now names the default and is kept as a no-op so
1259
+ // muscle memory and scripts keep working.
1260
+ if (ctx.options?.scratch && ctx.options?.liveData) {
1261
+ throw usageError('--scratch and --live-data contradict each other; pass at most one.');
1262
+ }
1263
+ const useInstalledDatabases = !ctx.options?.scratch;
810
1264
 
811
1265
  const candidates = [];
812
1266
  for (const projectDir of appDirs) {
@@ -814,111 +1268,58 @@ async function appsDevHandler(ctx) {
814
1268
  if (!appConfig.name) {
815
1269
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
816
1270
  }
817
- const devSlug = buildDevInstallSlug(appConfig.name);
1271
+ const devSlug = buildDevInstallSlug(appConfig);
818
1272
  if (!devSlug) {
819
- throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
1273
+ throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
820
1274
  }
821
1275
  candidates.push({ appConfig, devSlug, projectDir });
822
1276
  }
823
1277
 
824
- const seenSlugs = new Map();
825
- for (const app of candidates) {
826
- if (seenSlugs.has(app.devSlug)) {
827
- throw usageError(
828
- `Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
829
- );
830
- }
831
- seenSlugs.set(app.devSlug, app);
1278
+ const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
1279
+ const canonicalCandidates = [];
1280
+ for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
1281
+ // Sequential on purpose: the shell-consent prompt must never interleave
1282
+ // with another app's, and the ensure fan-out below runs in parallel.
1283
+ const approvedCapabilities = await resolveCloudShellConsent({
1284
+ appConfig,
1285
+ projectDir,
1286
+ grantCloudShell: Boolean(ctx.options?.grantCloudShell),
1287
+ profileKey,
1288
+ });
1289
+ canonicalCandidates.push({
1290
+ appConfig,
1291
+ devSlug,
1292
+ projectDir,
1293
+ approvedCapabilities,
1294
+ mountNonce: randomUUID(),
1295
+ });
832
1296
  }
833
1297
 
834
- const registrationStartedAt = process.hrtime.bigint();
835
- const registered = await Promise.all(
836
- candidates.map(async ({ appConfig, devSlug, projectDir }) => {
837
- const appStartedAt = process.hrtime.bigint();
838
- try {
839
- return await ensureDevInstall({
840
- ctx,
841
- appConfig,
842
- projectDir,
843
- idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
844
- });
845
- } finally {
846
- logAppsTiming('ensure-dev-install', {
847
- slug: devSlug,
848
- ms: timingMs(appStartedAt).toFixed(1),
849
- });
1298
+ // Start every build watcher before the backend registrations finish. A
1299
+ // machine with several apps must not show an empty sidebar for minutes while
1300
+ // unrelated registrations run sequentially. Each successful registration is
1301
+ // published below as soon as its own last-known bundle is ready.
1302
+ const devServer = sharedBundleBaseUrls
1303
+ ? {
1304
+ close: async () => {},
1305
+ isBundleReady: () => true,
1306
+ updateApp: () => {},
1307
+ waitForBundle: async () => {},
850
1308
  }
851
- }),
852
- );
853
- logAppsTiming('ensure-dev-install:all', {
854
- apps: registered.length,
855
- ms: timingMs(registrationStartedAt).toFixed(1),
856
- });
857
-
858
- const apps = registered.map((app) => {
859
- const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
860
- return {
861
- ...app,
862
- bundleBaseUrl,
863
- mountNonce: randomUUID(),
864
- appHref: buildDevelopmentAppHref({
865
- appSlug: app.slug,
866
- appId: app.appId,
867
- devSlug: app.devSlug,
868
- targetAppId: app.targetAppId,
869
- targetAppSlug: app.targetAppSlug,
870
- manifest: app.manifest,
871
- }),
872
- };
873
- });
874
- const developmentTabUrl = buildDevelopmentDesktopUrl(
875
- apps[0]?.appHref,
876
- desktopScheme,
877
- );
878
- const desktopWakeUrl = buildDevelopmentDesktopUrl('/manage', desktopScheme);
879
- const mountedDevelopmentTabUrl = buildMountedDevelopmentDesktopUrl(
880
- apps[0]?.appHref,
881
- desktopScheme,
882
- sessionId,
883
- );
884
- const warnings = databaseMaterializationWarnings(apps);
885
-
886
- const devServer = await startAppDevServer({
887
- apps: apps.map((app) => ({
888
- slug: app.devSlug,
889
- projectDir: app.projectDir,
890
- appId: app.appId,
891
- targetAppId: app.targetAppId || null,
892
- userId: identity,
893
- })),
894
- port,
895
- sessionsFilePath,
896
- });
897
-
898
- try {
899
- const now = new Date().toISOString();
900
- upsertAppDevSessions(apps.map((app) => ({
901
- sessionId,
902
- userId: identity,
903
- apiBase,
904
- appId: app.appId,
905
- targetAppId: app.targetAppId || undefined,
906
- mountNonce: app.mountNonce,
907
- devSlug: app.devSlug,
908
- bundleBaseUrl: app.bundleBaseUrl,
909
- projectDir: app.projectDir,
910
- startedAt: now,
911
- lastHeartbeatAt: now,
912
- desktopAppName,
913
- })), sessionsFilePath);
914
- } catch (error) {
915
- try {
916
- await devServer.close();
917
- } catch {
918
- // ignore cleanup failure on registry errors
919
- }
920
- throw error;
921
- }
1309
+ : await startAppDevServer({
1310
+ apps: canonicalCandidates.map((app) => ({
1311
+ slug: app.devSlug,
1312
+ projectDir: app.projectDir,
1313
+ appId: null,
1314
+ targetAppId: null,
1315
+ userId: identity,
1316
+ profileKey,
1317
+ sessionId,
1318
+ mountNonce: app.mountNonce,
1319
+ })),
1320
+ port,
1321
+ sessionsFilePath,
1322
+ });
922
1323
 
923
1324
  let heartbeatTimer = setInterval(() => {
924
1325
  try {
@@ -929,23 +1330,144 @@ async function appsDevHandler(ctx) {
929
1330
  }
930
1331
  }, DEV_HEARTBEAT_INTERVAL_MS);
931
1332
 
932
- const expectedMountAcknowledgements = apps.map((app) => ({
933
- sessionId,
934
- appId: app.appId,
935
- devSlug: app.devSlug,
936
- mountNonce: app.mountNonce,
937
- }));
1333
+ const registrationStartedAt = process.hrtime.bigint();
1334
+ const apps = [];
1335
+ const registrationWarnings = [];
1336
+ let firstRegistrationError = null;
1337
+ // The local development backend is deliberately a single worker. Register
1338
+ // apps sequentially so a large root cannot queue many long mutations behind
1339
+ // one another. Unlike the old all-or-nothing startup, publish each healthy
1340
+ // app immediately while later registrations continue in the background.
1341
+ for (const {
1342
+ appConfig,
1343
+ devSlug,
1344
+ projectDir,
1345
+ approvedCapabilities,
1346
+ mountNonce,
1347
+ } of canonicalCandidates) {
1348
+ const appStartedAt = process.hrtime.bigint();
1349
+ try {
1350
+ const registeredApp = await ensureDevInstall({
1351
+ ctx,
1352
+ appConfig,
1353
+ projectDir,
1354
+ idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
1355
+ useInstalledDatabases,
1356
+ approvedCapabilities,
1357
+ });
1358
+ const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
1359
+ || `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
1360
+ const app = {
1361
+ ...registeredApp,
1362
+ bundleBaseUrl,
1363
+ mountNonce,
1364
+ appHref: buildDevelopmentAppHref({
1365
+ appSlug: registeredApp.slug,
1366
+ appId: registeredApp.appId,
1367
+ devSlug: registeredApp.devSlug,
1368
+ targetAppId: registeredApp.targetAppId,
1369
+ targetAppSlug: registeredApp.targetAppSlug,
1370
+ manifest: registeredApp.manifest,
1371
+ }),
1372
+ };
1373
+ devServer.updateApp(app.devSlug, {
1374
+ appId: app.appId,
1375
+ targetAppId: app.targetAppId || null,
1376
+ });
1377
+ const now = new Date().toISOString();
1378
+ const sessionRecord = {
1379
+ sessionId,
1380
+ hostPid: process.pid,
1381
+ sourceHost: !sharedBundleBaseUrls,
1382
+ bundleReady: devServer.isBundleReady(app.devSlug),
1383
+ ...(!sharedBundleBaseUrls ? {
1384
+ discoveredProjects: discoveredAppDirs,
1385
+ canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
1386
+ } : {}),
1387
+ userId: identity,
1388
+ apiBase,
1389
+ profileKey,
1390
+ appId: app.appId,
1391
+ targetAppId: app.targetAppId || undefined,
1392
+ mountNonce: app.mountNonce,
1393
+ devSlug: app.devSlug,
1394
+ bundleBaseUrl: app.bundleBaseUrl,
1395
+ projectDir: app.projectDir,
1396
+ startedAt: now,
1397
+ lastHeartbeatAt: now,
1398
+ };
1399
+ upsertAppDevSessions(sessionRecord, sessionsFilePath);
1400
+ apps.push(app);
1401
+ if (!sessionRecord.bundleReady) {
1402
+ void devServer.waitForBundle(app.devSlug).then(() => {
1403
+ upsertAppDevSessions({
1404
+ ...sessionRecord,
1405
+ bundleReady: true,
1406
+ lastHeartbeatAt: new Date().toISOString(),
1407
+ }, sessionsFilePath);
1408
+ }).catch((error) => {
1409
+ const message = error instanceof Error ? error.message : String(error);
1410
+ process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
1411
+ });
1412
+ }
1413
+ } catch (error) {
1414
+ firstRegistrationError ||= error;
1415
+ const message = error instanceof Error ? error.message : String(error);
1416
+ registrationWarnings.push(
1417
+ `${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
1418
+ );
1419
+ process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
1420
+ } finally {
1421
+ logAppsTiming('ensure-dev-install', {
1422
+ slug: devSlug,
1423
+ ms: timingMs(appStartedAt).toFixed(1),
1424
+ });
1425
+ }
1426
+ }
1427
+ if (apps.length === 0) {
1428
+ clearInterval(heartbeatTimer);
1429
+ heartbeatTimer = null;
1430
+ try {
1431
+ await devServer.close();
1432
+ } catch {
1433
+ // preserve the original registration error
1434
+ }
1435
+ if (sourceHostLock) {
1436
+ releaseAppDevHostLock(sourceHostLock);
1437
+ sourceHostLock = null;
1438
+ }
1439
+ if (manualConsumerTimer) clearInterval(manualConsumerTimer);
1440
+ if (manualConsumerInstanceId) {
1441
+ try {
1442
+ removeAppDevConsumer(manualConsumerInstanceId);
1443
+ } catch {
1444
+ // Preserve the registration error; a failed lease cleanup expires.
1445
+ }
1446
+ }
1447
+ throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
1448
+ }
1449
+ logAppsTiming('ensure-dev-install:all', {
1450
+ apps: apps.length,
1451
+ ms: timingMs(registrationStartedAt).toFixed(1),
1452
+ });
1453
+
1454
+ const warnings = [
1455
+ ...canonicalSelection.warnings,
1456
+ ...registrationWarnings,
1457
+ ...databaseMaterializationWarnings(apps),
1458
+ ...liveDataWarnings(apps),
1459
+ ];
1460
+
1461
+ let consumerTimer = null;
938
1462
 
939
1463
  ctx.output.emitSuccess({
940
1464
  command: ctx.spec.command_path.join(' '),
941
1465
  data: {
942
1466
  mode,
943
1467
  api_base: apiBase,
944
- development_url: developmentTabUrl,
945
1468
  session_id: sessionId,
946
1469
  mount_status: 'serving',
947
- render_status: 'waiting_for_route',
948
- desktop_target: desktopAppName,
1470
+ source_host: !sharedBundleBaseUrls,
949
1471
  identity,
950
1472
  apps: apps.map((app) => ({
951
1473
  slug: app.devSlug,
@@ -958,78 +1480,28 @@ async function appsDevHandler(ctx) {
958
1480
  created: app.created,
959
1481
  linked_app_id: app.linkedAppId,
960
1482
  database_materialization: app.databaseMaterialization,
1483
+ live_data: app.liveData,
961
1484
  })),
962
1485
  },
963
1486
  warnings,
964
1487
  humanSummary: [
965
1488
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
966
- `Target desktop: ${desktopAppName}`,
967
- '',
968
- `Open in desktop: ${developmentTabUrl}`,
1489
+ ...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
1490
+ `Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
1491
+ ...(useInstalledDatabases
1492
+ ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1493
+ : []),
969
1494
  '',
970
1495
  ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
971
1496
  '',
972
- `Serving locally; waiting for ${desktopAppName} to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
1497
+ sharedBundleBaseUrls
1498
+ ? `Mounted ${apps.length} shared source app${apps.length === 1 ? '' : 's'} for this authenticated environment.`
1499
+ : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
973
1500
  '',
974
1501
  'Press Ctrl-C to stop.',
975
1502
  ].join('\n'),
976
1503
  });
977
1504
 
978
- if (shouldOpenDevelopmentTab(ctx.options)) {
979
- openInBrowser(desktopWakeUrl, {
980
- appName: desktopAppName,
981
- bundleId: desktopBundleId,
982
- scheme: desktopScheme,
983
- });
984
- }
985
-
986
- void (async () => {
987
- let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
988
- sessionsFilePath,
989
- });
990
- if (!result.mounted) {
991
- process.stderr.write(
992
- `[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`,
993
- );
994
- }
995
- while (!result.mounted) {
996
- result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
997
- sessionsFilePath,
998
- timeoutMs: 60_000,
999
- pollIntervalMs: 250,
1000
- });
1001
- }
1002
- process.stderr.write(
1003
- `[notis apps dev] Mounted in ${desktopAppName}: ${apps.map((app) => app.name).join(', ')}.\n`,
1004
- );
1005
- if (shouldOpenDevelopmentTab(ctx.options)) {
1006
- openInBrowser(mountedDevelopmentTabUrl, {
1007
- appName: desktopAppName,
1008
- bundleId: desktopBundleId,
1009
- scheme: desktopScheme,
1010
- });
1011
- }
1012
-
1013
- const firstAppRender = expectedMountAcknowledgements.slice(0, 1);
1014
- let renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1015
- sessionsFilePath,
1016
- timeoutMs: 0,
1017
- });
1018
- while (!renderResult.mounted) {
1019
- renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
1020
- sessionsFilePath,
1021
- timeoutMs: 60_000,
1022
- pollIntervalMs: 250,
1023
- });
1024
- }
1025
- process.stderr.write(
1026
- `[notis apps dev] Rendered in ${desktopAppName}: ${apps[0].name}.\n`,
1027
- );
1028
- })().catch((error) => {
1029
- const message = error instanceof Error ? error.message : String(error);
1030
- process.stderr.write(`[notis apps dev] mount acknowledgement check failed: ${message}\n`);
1031
- });
1032
-
1033
1505
  let shuttingDown = false;
1034
1506
  const shutdown = async (signal) => {
1035
1507
  if (shuttingDown) return;
@@ -1039,6 +1511,21 @@ async function appsDevHandler(ctx) {
1039
1511
  clearInterval(heartbeatTimer);
1040
1512
  heartbeatTimer = null;
1041
1513
  }
1514
+ if (consumerTimer) {
1515
+ clearInterval(consumerTimer);
1516
+ consumerTimer = null;
1517
+ }
1518
+ if (manualConsumerTimer) {
1519
+ clearInterval(manualConsumerTimer);
1520
+ manualConsumerTimer = null;
1521
+ }
1522
+ if (manualConsumerInstanceId) {
1523
+ try {
1524
+ removeAppDevConsumer(manualConsumerInstanceId);
1525
+ } catch {
1526
+ // A crashed CLI lease expires automatically after the heartbeat window.
1527
+ }
1528
+ }
1042
1529
  try {
1043
1530
  removeAppDevSession(sessionId, sessionsFilePath);
1044
1531
  } catch {
@@ -1049,6 +1536,10 @@ async function appsDevHandler(ctx) {
1049
1536
  } catch {
1050
1537
  // ignore cleanup failures during shutdown
1051
1538
  }
1539
+ if (sourceHostLock) {
1540
+ releaseAppDevHostLock(sourceHostLock);
1541
+ sourceHostLock = null;
1542
+ }
1052
1543
  process.exit(EXIT_CODES.ok);
1053
1544
  };
1054
1545
 
@@ -1059,11 +1550,53 @@ async function appsDevHandler(ctx) {
1059
1550
  void shutdown('SIGTERM');
1060
1551
  });
1061
1552
 
1553
+ if (consumerMode === 'machine' || consumerMode === 'environment') {
1554
+ consumerTimer = setInterval(() => {
1555
+ if (!hasAppDevConsumer(readAppDevConsumers(), {
1556
+ mode: consumerMode,
1557
+ userId: identity,
1558
+ apiBase,
1559
+ })) {
1560
+ void shutdown('last consumer detached');
1561
+ }
1562
+ }, DEV_CONSUMER_POLL_INTERVAL_MS);
1563
+ }
1564
+
1062
1565
  await new Promise(() => {});
1063
1566
 
1064
1567
  return EXIT_CODES.ok;
1065
1568
  }
1066
1569
 
1570
+ async function appsRootsListHandler(ctx) {
1571
+ const registry = readAppDevRoots();
1572
+ return ctx.output.emitSuccess({
1573
+ command: ctx.spec.command_path.join(' '),
1574
+ data: { roots: registry.roots },
1575
+ humanSummary: appDevRootsTable(registry.roots),
1576
+ meta: { mutating: false },
1577
+ });
1578
+ }
1579
+
1580
+ async function appsRootsRemoveHandler(ctx) {
1581
+ const rootDir = resolve(ctx.args.dir);
1582
+ const result = removeAppDevRoot(rootDir);
1583
+ return ctx.output.emitSuccess({
1584
+ command: ctx.spec.command_path.join(' '),
1585
+ data: {
1586
+ removed: result.removed,
1587
+ root: result.path,
1588
+ roots: result.registry.roots,
1589
+ },
1590
+ humanSummary: result.removed
1591
+ ? `Removed app development root: ${result.path}`
1592
+ : `App development root was not registered: ${result.path}`,
1593
+ hints: result.removed
1594
+ ? []
1595
+ : [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
1596
+ meta: { mutating: result.removed },
1597
+ });
1598
+ }
1599
+
1067
1600
  async function appsBuildHandler(ctx) {
1068
1601
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1069
1602
  const problems = detectProjectProblems(projectDir);
@@ -1105,7 +1638,7 @@ async function appsVerifyHandler(ctx) {
1105
1638
  if (!ctx.runtime.jwt) {
1106
1639
  throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
1107
1640
  }
1108
- linkedState = readLinkedState(projectDir);
1641
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1109
1642
  if (!linkedState?.app_id) {
1110
1643
  throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
1111
1644
  }
@@ -1120,10 +1653,16 @@ async function appsVerifyHandler(ctx) {
1120
1653
  const manifest = readManifest(projectDir);
1121
1654
  const appConfig = await loadAppConfig(projectDir);
1122
1655
  const listing = inspectListingReadiness(projectDir, appConfig);
1123
- if (listing.errors.length) {
1656
+ // Store readiness is a publish concern, not a render concern. Verify reports
1657
+ // it so the gaps stay visible while the app is still being built; only
1658
+ // --listing (and `apps publish`) turn it back into a hard gate.
1659
+ if (listing.errors.length && ctx.options.listing === true) {
1124
1660
  throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
1125
1661
  }
1126
- const listingWarnings = listing.warnings;
1662
+ const listingWarnings = [
1663
+ ...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
1664
+ ...findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig)),
1665
+ ];
1127
1666
  const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
1128
1667
  const port = parsePort(ctx.options.port) || await getAvailablePort();
1129
1668
  const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
@@ -1195,6 +1734,7 @@ async function appsVerifyHandler(ctx) {
1195
1734
  result,
1196
1735
  route,
1197
1736
  declaredDatabaseSlugs(appConfig, manifest, route),
1737
+ mode,
1198
1738
  );
1199
1739
  return {
1200
1740
  ...result,
@@ -1219,6 +1759,7 @@ async function appsVerifyHandler(ctx) {
1219
1759
  result,
1220
1760
  route,
1221
1761
  declaredDatabaseSlugs(appConfig, manifest, route),
1762
+ mode,
1222
1763
  );
1223
1764
  results.push({
1224
1765
  route: route.slug,
@@ -1259,6 +1800,11 @@ async function appsVerifyHandler(ctx) {
1259
1800
  },
1260
1801
  summary,
1261
1802
  results,
1803
+ listing: {
1804
+ ready: listing.ready,
1805
+ gated: ctx.options.listing === true,
1806
+ problems: listing.errors,
1807
+ },
1262
1808
  };
1263
1809
 
1264
1810
  ctx.output.emitSuccess({
@@ -1325,7 +1871,7 @@ async function appsScreenshotHandler(ctx) {
1325
1871
  if (!ctx.runtime.jwt) {
1326
1872
  throw usageError('Live mode requires CLI auth. Run notis login and retry.');
1327
1873
  }
1328
- linkedState = readLinkedState(projectDir);
1874
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1329
1875
  if (!linkedState?.app_id) {
1330
1876
  throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1331
1877
  }
@@ -1360,6 +1906,7 @@ async function appsScreenshotHandler(ctx) {
1360
1906
  const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
1361
1907
  .filter((screenshot) => screenshot.route)
1362
1908
  .filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
1909
+ const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
1363
1910
  const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
1364
1911
  const captures = configuredScreenshots.length > 0
1365
1912
  ? configuredScreenshots.map((screenshot) => {
@@ -1471,7 +2018,7 @@ async function appsScreenshotHandler(ctx) {
1471
2018
 
1472
2019
  const captured = results.filter((r) => r.ok);
1473
2020
  const failed = results.filter((r) => !r.ok);
1474
- const warnings = [];
2021
+ const warnings = [...scenarioWarnings];
1475
2022
 
1476
2023
  // Drop stale screenshots only after a full refresh. A selected-route
1477
2024
  // capture intentionally leaves other listing screenshots untouched.
@@ -1523,10 +2070,15 @@ async function appsScreenshotHandler(ctx) {
1523
2070
  }
1524
2071
  }
1525
2072
 
1526
- export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
2073
+ export function buildLinkedAppState(
2074
+ existingState,
2075
+ appId,
2076
+ linkedAt = new Date().toISOString(),
2077
+ ) {
2078
+ const sameIdPromotion = existingState?.dev_app_id === appId;
1527
2079
  return {
1528
- ...(existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
1529
- ...(existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
2080
+ ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2081
+ ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
1530
2082
  app_id: appId,
1531
2083
  linked_at: linkedAt,
1532
2084
  };
@@ -1540,7 +2092,12 @@ async function appsLinkHandler(ctx) {
1540
2092
 
1541
2093
  writeLinkedState(
1542
2094
  projectDir,
1543
- buildLinkedAppState(readLinkedState(projectDir), appId),
2095
+ buildLinkedAppState(
2096
+ readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2097
+ appId,
2098
+ new Date().toISOString(),
2099
+ ),
2100
+ linkedStateProfileKey(ctx.runtime),
1544
2101
  );
1545
2102
 
1546
2103
  return ctx.output.emitSuccess({
@@ -1567,9 +2124,11 @@ async function appsPullHandler(ctx) {
1567
2124
  throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1568
2125
  }
1569
2126
  const app = result.payload?.app || {};
1570
- const defaultDir = app.slug || slugify(app.name) || appId;
1571
- const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
1572
- const version = ctx.options.version || 'latest';
2127
+ const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
2128
+ const targetDir = ctx.args.dir
2129
+ ? resolveProjectDir(ctx.args.dir)
2130
+ : defaultAppProjectDir(defaultDir);
2131
+ const version = ctx.options.sourceVersion || 'latest';
1573
2132
 
1574
2133
  const pulled = await pullAppSource({
1575
2134
  apiBase: ctx.runtime.apiBase,
@@ -1578,6 +2137,7 @@ async function appsPullHandler(ctx) {
1578
2137
  targetDir,
1579
2138
  version,
1580
2139
  force: Boolean(ctx.options.force),
2140
+ profileKey: linkedStateProfileKey(ctx.runtime),
1581
2141
  });
1582
2142
 
1583
2143
  const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
@@ -1592,7 +2152,7 @@ async function appsPullHandler(ctx) {
1592
2152
  });
1593
2153
  }
1594
2154
 
1595
- function updateLinkedDeployState(projectDir, linkedState, appId, version) {
2155
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
1596
2156
  if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1597
2157
  return;
1598
2158
  }
@@ -1602,14 +2162,29 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
1602
2162
  version,
1603
2163
  linked_at: linkedState.linked_at || new Date().toISOString(),
1604
2164
  deployed_at: new Date().toISOString(),
1605
- });
2165
+ }, profileKey);
2166
+ }
2167
+
2168
+ // Resolve first deploy without mutating. Promotion is part of the final
2169
+ // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2170
+ // cannot leave an empty installed app behind.
2171
+ function resolveDeployTarget(ctx, projectDir) {
2172
+ if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2173
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2174
+ const state = readLinkedState(projectDir, profileKey);
2175
+ if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2176
+ if (!state?.dev_app_id) {
2177
+ return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2178
+ }
2179
+ return { appId: state.dev_app_id, needsPromotion: true };
1606
2180
  }
1607
2181
 
1608
2182
  async function appsDeployHandler(ctx) {
1609
2183
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1610
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2184
+ const profileKey = linkedStateProfileKey(ctx.runtime);
2185
+ const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
1611
2186
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1612
- const linkedState = readLinkedState(projectDir);
2187
+ const linkedState = readLinkedState(projectDir, profileKey);
1613
2188
  const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1614
2189
  ? linkedState.version
1615
2190
  : undefined;
@@ -1623,9 +2198,12 @@ async function appsDeployHandler(ctx) {
1623
2198
 
1624
2199
  // Direct deploy mode: upload to Supabase storage directly
1625
2200
  if (ctx.options.direct) {
2201
+ if (needsPromotion) {
2202
+ throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
2203
+ }
1626
2204
  await assertDirectDeployAccess(ctx.runtime, appId);
1627
2205
  const { version } = await directDeploy(projectDir, appId);
1628
- updateLinkedDeployState(projectDir, linkedState, appId, version);
2206
+ updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
1629
2207
  return ctx.output.emitSuccess({
1630
2208
  command: ctx.spec.command_path.join(' '),
1631
2209
  data: { app_id: appId, version, mode: 'direct' },
@@ -1642,7 +2220,14 @@ async function appsDeployHandler(ctx) {
1642
2220
  let result;
1643
2221
  try {
1644
2222
  result = await runToolCommand({
1645
- runtime: ctx.runtime,
2223
+ // App deploys upload both the built artifact and the editable source
2224
+ // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
2225
+ // and timing out a mutation is ambiguous: the backend may commit after
2226
+ // the client disconnects. Give this operation its real completion window.
2227
+ runtime: {
2228
+ ...ctx.runtime,
2229
+ timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
2230
+ },
1646
2231
  toolName: SAVE_APP_FILES_TOOL,
1647
2232
  arguments_: {
1648
2233
  app_id: appId,
@@ -1651,6 +2236,7 @@ async function appsDeployHandler(ctx) {
1651
2236
  manifest,
1652
2237
  ...appRowFieldsFromManifest(manifest),
1653
2238
  ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2239
+ ...(needsPromotion ? { promote_dev_app: true } : {}),
1654
2240
  },
1655
2241
  mutating: true,
1656
2242
  idempotencyKey,
@@ -1660,51 +2246,75 @@ async function appsDeployHandler(ctx) {
1660
2246
  throw toolConflictToError(error.details, 'Deploy conflict');
1661
2247
  }
1662
2248
 
1663
- // Auto-fallback to direct deploy on network errors
1664
- const isNetworkError = error.code === 'network_error'
1665
- || error.code === 'network_timeout'
1666
- || (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
1667
-
1668
- if (isNetworkError) {
1669
- try {
1670
- await assertDirectDeployAccess(ctx.runtime, appId);
1671
- } catch (accessError) {
1672
- throw usageError(
1673
- `Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
1674
- `could not be verified (${accessError.message}).`,
1675
- );
1676
- }
2249
+ // A timed-out mutation may already have committed on the backend. A direct
2250
+ // upload here would create a second revision, so fail closed and let the
2251
+ // caller inspect/pull the app before deciding whether to retry.
2252
+ if (error.code === 'network_timeout') {
2253
+ error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
2254
+ throw error;
2255
+ }
1677
2256
 
1678
- try {
1679
- const { version } = await directDeploy(projectDir, appId);
1680
- updateLinkedDeployState(projectDir, linkedState, appId, version);
1681
- return ctx.output.emitSuccess({
1682
- command: ctx.spec.command_path.join(' '),
1683
- data: { app_id: appId, version, mode: 'direct-fallback' },
1684
- humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
1685
- warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
1686
- meta: { mutating: true },
1687
- });
1688
- } catch (directError) {
1689
- throw usageError(
1690
- `Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
1691
- 'Check server/.env for Supabase credentials or start the backend server.',
1692
- );
1693
- }
2257
+ // A network failure is ambiguous for a mutation. It can happen after the
2258
+ // backend committed but before undici finished reading the response (for
2259
+ // example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
2260
+ // generic network_error or its message: an automatic direct upload could
2261
+ // create a second revision. Operators who have proved the backend is down
2262
+ // can still choose the explicit --direct mode.
2263
+ if (error.code === 'network_error') {
2264
+ error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
2265
+ throw error;
1694
2266
  }
1695
2267
 
1696
2268
  throw error;
1697
2269
  }
1698
2270
 
1699
- updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
2271
+ const deployedVersion = Number(result?.payload?.version);
2272
+ if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
2273
+ throw new CliError({
2274
+ code: 'network_error',
2275
+ message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
2276
+ exitCode: EXIT_CODES.network,
2277
+ retryable: true,
2278
+ });
2279
+ }
2280
+
2281
+ if (needsPromotion) {
2282
+ const now = new Date().toISOString();
2283
+ writeLinkedState(projectDir, {
2284
+ ...linkedState,
2285
+ app_id: appId,
2286
+ linked_at: linkedState?.linked_at || now,
2287
+ version: deployedVersion,
2288
+ deployed_at: now,
2289
+ dev_app_id: undefined,
2290
+ dev_linked_at: undefined,
2291
+ }, profileKey);
2292
+ try {
2293
+ linkAppDevSessionTarget(
2294
+ { appId, targetAppId: appId },
2295
+ getAppDevSessionsFile(),
2296
+ );
2297
+ } catch (error) {
2298
+ ctx.output.emitProgress({
2299
+ phase: 'warning',
2300
+ message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
2301
+ });
2302
+ }
2303
+ ctx.output.emitProgress({
2304
+ phase: 'promote',
2305
+ message: `Promoted development app to installed app ${appId}`,
2306
+ });
2307
+ } else {
2308
+ updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
2309
+ }
1700
2310
  return ctx.output.emitSuccess({
1701
2311
  command: ctx.spec.command_path.join(' '),
1702
2312
  data: {
1703
2313
  app_id: appId,
1704
- version: result.payload.version,
2314
+ version: deployedVersion,
1705
2315
  idempotency_key: idempotencyKey,
1706
2316
  },
1707
- humanSummary: `Deployed to app ${appId} (version ${result.payload.version})`,
2317
+ humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
1708
2318
  meta: { mutating: true, idempotency_key: idempotencyKey },
1709
2319
  });
1710
2320
  }
@@ -1719,7 +2329,7 @@ async function appsDuplicateHandler(ctx) {
1719
2329
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1720
2330
  // Either target an app explicitly, or duplicate whatever this project is
1721
2331
  // linked to, so `notis apps duplicate` works from inside a project.
1722
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
2332
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
1723
2333
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1724
2334
 
1725
2335
  const copyDocuments = ctx.options.copyDocuments || 'declared';
@@ -1785,8 +2395,8 @@ async function appsPublishHandler(ctx) {
1785
2395
  }
1786
2396
 
1787
2397
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1788
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
1789
- const linkedState = readLinkedState(projectDir);
2398
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
2399
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1790
2400
  const appConfig = await loadAppConfig(projectDir);
1791
2401
  const readiness = inspectListingReadiness(projectDir, appConfig);
1792
2402
  if (!readiness.ready) {
@@ -1879,7 +2489,7 @@ async function appsDoctorHandler(ctx) {
1879
2489
  }
1880
2490
  }
1881
2491
 
1882
- const linkedState = readLinkedState(projectDir);
2492
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1883
2493
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
1884
2494
 
1885
2495
  return ctx.output.emitSuccess({
@@ -1922,21 +2532,21 @@ export const appsCommandSpecs = [
1922
2532
  {
1923
2533
  command_path: ['apps', 'init'],
1924
2534
  summary: 'Scaffold a new Notis app project.',
1925
- when_to_use: 'Start a new Notis app. Use --from with a bundled scaffold when one is close to the desired app; otherwise creates the bare Vite + React project.',
2535
+ when_to_use: 'Start a new Notis app. Use --from with a published Store app when one is close to the desired app; otherwise creates the bare Vite + React project.',
1926
2536
  args_schema: {
1927
2537
  arguments: [
1928
2538
  { token: '<name>', description: 'Display name for the app.' },
1929
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
2539
+ { token: '[dir]', key: 'dir', description: 'Target directory, resolved from the current directory. Defaults to ~/.notis/apps/<slug>; pass a path to place the project elsewhere, such as a tracked git repo or an existing monorepo.' },
1930
2540
  ],
1931
2541
  options: [
1932
- { flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
2542
+ { flags: '--from <slug>', description: 'Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.' },
1933
2543
  ],
1934
2544
  },
1935
2545
  examples: [
1936
2546
  'notis apps scaffolds list',
1937
2547
  'notis apps init "Mind the Flo"',
1938
- 'notis apps init "My CRM" --from notis-database',
1939
- 'notis apps init "My App" ./my-app',
2548
+ 'notis apps init "My CRM" --from databases',
2549
+ 'notis apps init "My App" ~/code/my-app',
1940
2550
  ],
1941
2551
  mutates: true,
1942
2552
  idempotent: false,
@@ -1946,10 +2556,19 @@ export const appsCommandSpecs = [
1946
2556
  },
1947
2557
  {
1948
2558
  command_path: ['apps', 'scaffolds', 'list'],
1949
- summary: 'List bundled Notis app scaffolds.',
1950
- when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
1951
- args_schema: { arguments: [], options: [] },
1952
- examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
2559
+ summary: 'List published Store apps available as scaffolds.',
2560
+ when_to_use: 'Discover published Store apps to start from before creating a new app. Every app published to the public Store is automatically a scaffold; use --search to narrow the catalog.',
2561
+ args_schema: {
2562
+ arguments: [],
2563
+ options: [
2564
+ { flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
2565
+ ],
2566
+ },
2567
+ examples: [
2568
+ 'notis apps scaffolds list',
2569
+ 'notis apps scaffolds list --search journal',
2570
+ 'notis apps init "My App" --from databases',
2571
+ ],
1953
2572
  mutates: false,
1954
2573
  idempotent: true,
1955
2574
  require_auth: false,
@@ -1978,22 +2597,40 @@ export const appsCommandSpecs = [
1978
2597
  },
1979
2598
  {
1980
2599
  command_path: ['apps', 'dev'],
1981
- summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
2600
+ summary: 'Register a development root and connect its apps to the shared local development host.',
1982
2601
  when_to_use:
1983
- 'Run this inside a single app or a monorepo root with apps/<name>/notis.config.ts. It discovers every app, starts the local bundle server, registers desktop-local dev sessions, and opens the Electron Portal to the local development app.',
2602
+ 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.',
1984
2603
  args_schema: {
1985
2604
  arguments: [
1986
2605
  { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
1987
2606
  ],
1988
2607
  options: [
1989
2608
  { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
1990
- { flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
2609
+ {
2610
+ flags: '--scratch',
2611
+ description:
2612
+ 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2613
+ + "instead of the installed app's resources. For fixture work and destructive experiments.",
2614
+ },
2615
+ {
2616
+ flags: '--live-data',
2617
+ description:
2618
+ 'Deprecated: using the installed app\'s real resources is now the default. '
2619
+ + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2620
+ },
2621
+ {
2622
+ flags: '--grant-cloud-shell',
2623
+ description:
2624
+ "Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
2625
+ + 'The grant persists for this dev app; authorship alone never grants it.',
2626
+ },
1991
2627
  ],
1992
2628
  },
1993
2629
  examples: [
1994
2630
  'notis apps dev',
1995
2631
  'notis apps dev ./my-app',
1996
2632
  'notis apps dev ./workspace --port 5200',
2633
+ 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
1997
2634
  ],
1998
2635
  mutates: true,
1999
2636
  idempotent: true,
@@ -2001,6 +2638,35 @@ export const appsCommandSpecs = [
2001
2638
  backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2002
2639
  handler: appsDevHandler,
2003
2640
  },
2641
+ {
2642
+ command_path: ['apps', 'roots', 'list'],
2643
+ summary: 'List persistent machine-local Notis app development roots.',
2644
+ when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2645
+ args_schema: { arguments: [], options: [] },
2646
+ examples: ['notis apps roots list'],
2647
+ mutates: false,
2648
+ idempotent: true,
2649
+ require_auth: false,
2650
+ backend_call: { type: 'local', name: 'list_app_development_roots' },
2651
+ handler: appsRootsListHandler,
2652
+ },
2653
+ {
2654
+ command_path: ['apps', 'roots', 'remove'],
2655
+ summary: 'Stop watching a registered Notis app development root.',
2656
+ when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2657
+ args_schema: {
2658
+ arguments: [
2659
+ { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2660
+ ],
2661
+ options: [],
2662
+ },
2663
+ examples: ['notis apps roots remove ./old-apps'],
2664
+ mutates: true,
2665
+ idempotent: true,
2666
+ require_auth: false,
2667
+ backend_call: { type: 'local', name: 'remove_app_development_root' },
2668
+ handler: appsRootsRemoveHandler,
2669
+ },
2004
2670
  {
2005
2671
  command_path: ['apps', 'build'],
2006
2672
  summary: 'Build and package the app into .notis/output/.',
@@ -2020,10 +2686,11 @@ export const appsCommandSpecs = [
2020
2686
  },
2021
2687
  {
2022
2688
  command_path: ['apps', 'verify'],
2023
- summary: 'Validate every route and the production Store listing contract.',
2689
+ summary: 'Validate that every route renders and reports Store listing readiness.',
2024
2690
  when_to_use:
2025
- 'After notis apps screenshot, before deploy. Catches render-time crashes, ' +
2026
- 'missing runtime calls, and incomplete listing media that the build step cannot detect.',
2691
+ 'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
2692
+ 'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
2693
+ 'to fail on it instead.',
2027
2694
  args_schema: {
2028
2695
  arguments: [
2029
2696
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -2032,7 +2699,8 @@ export const appsCommandSpecs = [
2032
2699
  { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
2033
2700
  { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
2034
2701
  { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
2035
- { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
2702
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT and fails routes whose runtime calls all errored.' },
2703
+ { flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
2036
2704
  { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
2037
2705
  { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
2038
2706
  ],
@@ -2041,6 +2709,7 @@ export const appsCommandSpecs = [
2041
2709
  'notis apps verify',
2042
2710
  'notis apps verify --routes notes',
2043
2711
  'notis apps verify --mode live',
2712
+ 'notis apps verify --listing # gate on Store listing readiness before publish',
2044
2713
  'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
2045
2714
  ],
2046
2715
  mutates: false,
@@ -2110,14 +2779,17 @@ export const appsCommandSpecs = [
2110
2779
  args_schema: {
2111
2780
  arguments: [
2112
2781
  { token: '<app-id>', description: 'Remote app ID to pull.' },
2113
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
2782
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
2114
2783
  ],
2115
2784
  options: [
2116
2785
  { flags: '--force', description: 'Overwrite a non-empty target directory.' },
2117
- { flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
2786
+ { flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
2118
2787
  ],
2119
2788
  },
2120
- examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
2789
+ examples: [
2790
+ 'notis apps pull abc123',
2791
+ 'notis apps pull abc123 ./my-app --force --source-version 3',
2792
+ ],
2121
2793
  mutates: true,
2122
2794
  idempotent: true,
2123
2795
  require_auth: true,
@@ -2128,7 +2800,7 @@ export const appsCommandSpecs = [
2128
2800
  command_path: ['apps', 'deploy'],
2129
2801
  summary: 'Build and upload the app to the linked Notis app.',
2130
2802
  when_to_use:
2131
- 'Ship the installed app to production for the linked user/team app. Requires a linked app (notis apps link). This command does not publish to the app store.',
2803
+ 'Ship the installed app to production for the linked user/team app. A project that has only a development app is promoted in place on first deploy: same app id, same databases, dev markers removed. This command does not publish to the app store.',
2132
2804
  args_schema: {
2133
2805
  arguments: [
2134
2806
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -2136,7 +2808,7 @@ export const appsCommandSpecs = [
2136
2808
  options: [
2137
2809
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
2138
2810
  { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
2139
- { flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
2811
+ { flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
2140
2812
  ],
2141
2813
  },
2142
2814
  examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],