@notis_ai/cli 0.2.17 → 0.2.19

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 (395) hide show
  1. package/README.md +542 -146
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_design_rules.json +320 -0
  5. package/config/portal-route-roots.json +45 -0
  6. package/dist/agent-hooks/notis-agent-hook.mjs +42323 -18258
  7. package/dist/base-skills/notis-cli/.notis-base-skill.json +1 -0
  8. package/dist/base-skills/notis-cli/SKILL.md +51 -280
  9. package/dist/base-skills/notis-cli/references/app-delivery.md +17 -0
  10. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  11. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  12. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  13. package/dist/base-skills/notis-cli/references/troubleshooting.md +67 -0
  14. package/dist/base-skills/notis-query/.notis-base-skill.json +1 -0
  15. package/dist/base-skills/notis-query/SKILL.md +35 -699
  16. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  17. package/dist/base-skills/notis-query/references/documents.md +74 -0
  18. package/dist/base-skills/notis-query/references/query.md +76 -0
  19. package/dist/base-skills/notis-spaces/.notis-base-skill.json +1 -0
  20. package/dist/base-skills/notis-spaces/SKILL.md +218 -0
  21. package/dist/base-skills/notis-spaces/references/architecture.md +116 -0
  22. package/dist/base-skills/notis-spaces/references/context.md +81 -0
  23. package/dist/base-skills/notis-spaces/references/design.md +141 -0
  24. package/dist/base-skills/notis-spaces/references/fixtures.md +29 -0
  25. package/dist/base-skills/notis-spaces/references/memory.md +58 -0
  26. package/dist/base-skills/notis-spaces/references/reading.md +73 -0
  27. package/dist/base-skills/notis-spaces/references/release.md +120 -0
  28. package/dist/base-skills/notis-spaces/references/reports.md +77 -0
  29. package/dist/base-skills/notis-spaces/references/sdk.md +273 -0
  30. package/dist/base-skills/notis-spaces/references/store.md +118 -0
  31. package/dist/base-skills/notis-spaces/references/troubleshooting.md +28 -0
  32. package/dist/base-skills/notis-spaces/references/views.md +229 -0
  33. package/{template/packages → dist}/sdk/package.json +9 -10
  34. package/dist/sdk/src/agentContext.ts +36 -0
  35. package/dist/sdk/src/bundleModule.ts +175 -0
  36. package/dist/sdk/src/components/Dialog.tsx +103 -0
  37. package/dist/sdk/src/components/DocumentEditor.tsx +113 -0
  38. package/dist/sdk/src/components/DocumentPage.tsx +184 -0
  39. package/{template/packages → dist}/sdk/src/components/Markdown.tsx +9 -3
  40. package/{template/packages → dist}/sdk/src/components/MarkdownEditor.tsx +26 -5
  41. package/{template/packages → dist}/sdk/src/components/MultiSelectActionBar.tsx +95 -12
  42. package/{template/packages → dist}/sdk/src/components/MultiSelectCheckbox.tsx +4 -2
  43. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  44. package/{template/packages → dist}/sdk/src/components/NotisSelectionBoundary.tsx +9 -5
  45. package/dist/sdk/src/components/RecordComponents.tsx +111 -0
  46. package/dist/sdk/src/components/Skeleton.tsx +38 -0
  47. package/dist/sdk/src/config.ts +31 -0
  48. package/{template/packages → dist}/sdk/src/documents.ts +21 -6
  49. package/dist/sdk/src/hooks/useAction.ts +28 -0
  50. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  51. package/{template/packages → dist}/sdk/src/hooks/useBackend.ts +3 -2
  52. package/dist/sdk/src/hooks/useCloudComputer.ts +94 -0
  53. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  54. package/{template/packages → dist}/sdk/src/hooks/useCollectionInteractions.ts +157 -147
  55. package/dist/sdk/src/hooks/useDatabaseSchema.ts +50 -0
  56. package/{template/packages → dist}/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  57. package/dist/sdk/src/hooks/useDocument.ts +51 -0
  58. package/{template/packages → dist}/sdk/src/hooks/useDocuments.ts +25 -59
  59. package/{template/packages → dist}/sdk/src/hooks/useHandover.ts +7 -7
  60. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  61. package/dist/sdk/src/hooks/useMarqueeSelection.ts +194 -0
  62. package/{template/packages → dist}/sdk/src/hooks/useMultiSelect.ts +2 -8
  63. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  64. package/dist/sdk/src/hooks/useNativeDocuments.ts +143 -0
  65. package/{template/packages → dist}/sdk/src/hooks/useNotis.ts +6 -1
  66. package/dist/sdk/src/hooks/useNotisNavigation.ts +70 -0
  67. package/dist/sdk/src/hooks/useQuery.ts +81 -0
  68. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  69. package/dist/sdk/src/hooks/useTopBarSearch.ts +102 -0
  70. package/dist/sdk/src/hooks/useTopBarTitleLeaf.ts +75 -0
  71. package/{template/packages → dist}/sdk/src/hooks/useUpsertDocument.ts +1 -1
  72. package/dist/sdk/src/hooks/useViewParams.ts +195 -0
  73. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  74. package/{template/packages → dist}/sdk/src/index.ts +64 -1
  75. package/{template/packages → dist}/sdk/src/interactions/actions.ts +14 -1
  76. package/{template/packages → dist}/sdk/src/interactions/shortcuts.tsx +129 -23
  77. package/dist/sdk/src/interactions/visibility.ts +13 -0
  78. package/{template/packages → dist}/sdk/src/interactions.ts +5 -1
  79. package/dist/sdk/src/nativeData.ts +39 -0
  80. package/{template/packages → dist}/sdk/src/provider.tsx +9 -4
  81. package/dist/sdk/src/queryCache.ts +292 -0
  82. package/dist/sdk/src/queryErrors.ts +51 -0
  83. package/dist/sdk/src/runtime.ts +675 -0
  84. package/dist/sdk/src/runtimeModules.ts +33 -0
  85. package/dist/sdk/src/space.ts +442 -0
  86. package/dist/sdk/src/standardViewParams.ts +36 -0
  87. package/{template/packages → dist}/sdk/src/styles.css +81 -16
  88. package/dist/sdk/src/tailwind.ts +66 -0
  89. package/dist/sdk/src/ui/charts/RenderChartData.tsx +25 -0
  90. package/dist/sdk/src/ui/engine/core.ts +392 -0
  91. package/dist/sdk/src/ui/engine/dom.ts +183 -0
  92. package/dist/sdk/src/ui/engine/hooks.ts +195 -0
  93. package/dist/sdk/src/ui/engine/instance.ts +63 -0
  94. package/dist/sdk/src/ui/engine/keycapContext.ts +2 -0
  95. package/dist/sdk/src/ui/engine/registry.ts +262 -0
  96. package/dist/sdk/src/ui/engine/types.ts +62 -0
  97. package/dist/sdk/src/ui/icons.tsx +91 -0
  98. package/dist/sdk/src/ui/util.ts +83 -0
  99. package/dist/sdk/src/viewPeriod.ts +26 -0
  100. package/dist/sdk/src/vite.ts +92 -0
  101. package/dist/sdk/src/words/actions.ts +295 -0
  102. package/dist/sdk/src/words/clashes.ts +158 -0
  103. package/dist/sdk/src/words/format.ts +136 -0
  104. package/dist/sdk/src/words/index.ts +13 -0
  105. package/dist/sdk/src/words/keymap.ts +72 -0
  106. package/dist/sdk/src/words/keys.ts +297 -0
  107. package/dist/sdk/src/words/shell.ts +46 -0
  108. package/dist/sdk/src/words/types.ts +65 -0
  109. package/dist/skill-sync/index.js +832 -151
  110. package/dist/skill-sync/index.js.map +4 -4
  111. package/dist/skill-sync-worker.mjs +3941 -0
  112. package/dist/space-harness/host.js +1460 -0
  113. package/dist/space-sdk/dist/chunk-ZSN57ATH.js +5 -0
  114. package/dist/space-sdk/dist/index.js +15 -0
  115. package/dist/space-sdk/dist/ui.js +18 -0
  116. package/dist/space-sdk/package.json +33 -0
  117. package/dist/space-sdk/src/agentContext.d.ts +36 -0
  118. package/dist/space-sdk/src/components/Dialog.d.ts +20 -0
  119. package/dist/space-sdk/src/components/DocumentEditor.d.ts +46 -0
  120. package/dist/space-sdk/src/components/DocumentPage.d.ts +53 -0
  121. package/dist/space-sdk/src/components/Markdown.d.ts +18 -0
  122. package/dist/space-sdk/src/components/MarkdownEditor.d.ts +11 -0
  123. package/dist/space-sdk/src/components/MultiSelectActionBar.d.ts +31 -0
  124. package/dist/space-sdk/src/components/MultiSelectCheckbox.d.ts +14 -0
  125. package/dist/space-sdk/src/components/MultiSelectDragOverlay.d.ts +8 -0
  126. package/dist/space-sdk/src/components/NotisCommentBoundary.d.ts +36 -0
  127. package/dist/space-sdk/src/components/NotisSelectionBoundary.d.ts +16 -0
  128. package/dist/space-sdk/src/components/RecordComponents.d.ts +64 -0
  129. package/dist/space-sdk/src/components/ShortcutHints.d.ts +12 -0
  130. package/dist/space-sdk/src/components/Skeleton.d.ts +23 -0
  131. package/dist/space-sdk/src/config.ts +31 -0
  132. package/dist/space-sdk/src/documents.d.ts +59 -0
  133. package/dist/space-sdk/src/hooks/useAction.d.ts +8 -0
  134. package/dist/space-sdk/src/hooks/useActiveResource.d.ts +3 -0
  135. package/dist/space-sdk/src/hooks/useAgentContext.d.ts +7 -0
  136. package/dist/space-sdk/src/hooks/useBackend.d.ts +23 -0
  137. package/dist/space-sdk/src/hooks/useCloudComputer.d.ts +45 -0
  138. package/dist/space-sdk/src/hooks/useCloudComputerShell.d.ts +49 -0
  139. package/dist/space-sdk/src/hooks/useCollectionInteractions.d.ts +118 -0
  140. package/dist/space-sdk/src/hooks/useDatabaseSchema.d.ts +14 -0
  141. package/dist/space-sdk/src/hooks/useDatabaseSubscription.d.ts +26 -0
  142. package/dist/space-sdk/src/hooks/useDocument.d.ts +22 -0
  143. package/dist/space-sdk/src/hooks/useDocuments.d.ts +31 -0
  144. package/dist/space-sdk/src/hooks/useHandover.d.ts +43 -0
  145. package/dist/space-sdk/src/hooks/useLongPressSelection.d.ts +9 -0
  146. package/dist/space-sdk/src/hooks/useMarqueeSelection.d.ts +57 -0
  147. package/dist/space-sdk/src/hooks/useMultiSelect.d.ts +51 -0
  148. package/dist/space-sdk/src/hooks/useNativeDocumentBody.d.ts +24 -0
  149. package/dist/space-sdk/src/hooks/useNativeDocuments.d.ts +55 -0
  150. package/dist/space-sdk/src/hooks/useNotis.d.ts +25 -0
  151. package/dist/space-sdk/src/hooks/useNotisNavigation.d.ts +33 -0
  152. package/dist/space-sdk/src/hooks/useQuery.d.ts +22 -0
  153. package/dist/space-sdk/src/hooks/useTool.d.ts +20 -0
  154. package/dist/space-sdk/src/hooks/useToolQuery.d.ts +3 -0
  155. package/dist/space-sdk/src/hooks/useTools.d.ts +15 -0
  156. package/dist/space-sdk/src/hooks/useTopBarSearch.d.ts +48 -0
  157. package/dist/space-sdk/src/hooks/useTopBarTitleLeaf.d.ts +24 -0
  158. package/dist/space-sdk/src/hooks/useUpsertDocument.d.ts +27 -0
  159. package/dist/space-sdk/src/hooks/useViewParams.d.ts +76 -0
  160. package/dist/space-sdk/src/hooks/useViewerReads.d.ts +38 -0
  161. package/dist/space-sdk/src/index.d.ts +98 -0
  162. package/dist/space-sdk/src/interactions/actions.d.ts +33 -0
  163. package/dist/space-sdk/src/interactions/shortcuts.d.ts +42 -0
  164. package/dist/space-sdk/src/interactions/visibility.d.ts +2 -0
  165. package/dist/space-sdk/src/nativeData.d.ts +66 -0
  166. package/dist/space-sdk/src/provider.d.ts +17 -0
  167. package/dist/space-sdk/src/queryCache.d.ts +45 -0
  168. package/dist/space-sdk/src/queryErrors.ts +51 -0
  169. package/dist/space-sdk/src/runtime.d.ts +615 -0
  170. package/dist/space-sdk/src/runtimeModules.ts +33 -0
  171. package/dist/space-sdk/src/space.ts +442 -0
  172. package/dist/space-sdk/src/standardViewParams.d.ts +15 -0
  173. package/dist/space-sdk/src/tailwind-candidates.d.ts +3418 -0
  174. package/dist/space-sdk/src/tailwind.ts +66 -0
  175. package/dist/space-sdk/src/ui/actions/ActionToast.d.ts +15 -0
  176. package/dist/space-sdk/src/ui/actions/actionWords.d.ts +16 -0
  177. package/dist/space-sdk/src/ui/actions/index.d.ts +7 -0
  178. package/dist/space-sdk/src/ui/actions/useActions.d.ts +69 -0
  179. package/dist/space-sdk/src/ui/actions/useToast.d.ts +41 -0
  180. package/dist/space-sdk/src/ui/blocks/Tabs.d.ts +139 -0
  181. package/dist/space-sdk/src/ui/blocks/blocks.d.ts +217 -0
  182. package/dist/space-sdk/src/ui/blocks/index.d.ts +4 -0
  183. package/dist/space-sdk/src/ui/board/BoardView.d.ts +36 -0
  184. package/dist/space-sdk/src/ui/board/boardModel.d.ts +46 -0
  185. package/dist/space-sdk/src/ui/board/boardWords.d.ts +9 -0
  186. package/dist/space-sdk/src/ui/board/index.d.ts +5 -0
  187. package/dist/space-sdk/src/ui/calendar/CalendarView.d.ts +49 -0
  188. package/dist/space-sdk/src/ui/calendar/DayPicker.d.ts +23 -0
  189. package/dist/space-sdk/src/ui/calendar/calendarMath.d.ts +81 -0
  190. package/dist/space-sdk/src/ui/charts/Chart.d.ts +203 -0
  191. package/dist/space-sdk/src/ui/charts/ChartTable.d.ts +37 -0
  192. package/dist/space-sdk/src/ui/charts/RenderChartData.d.ts +16 -0
  193. package/dist/space-sdk/src/ui/charts/StatRow.d.ts +59 -0
  194. package/dist/space-sdk/src/ui/charts/chartData.d.ts +84 -0
  195. package/dist/space-sdk/src/ui/comments/CommentPane.d.ts +58 -0
  196. package/dist/space-sdk/src/ui/comments/commentModel.d.ts +157 -0
  197. package/dist/space-sdk/src/ui/comments/commentWords.d.ts +60 -0
  198. package/dist/space-sdk/src/ui/comments/index.d.ts +4 -0
  199. package/dist/space-sdk/src/ui/comments/useComments.d.ts +146 -0
  200. package/dist/space-sdk/src/ui/engine/HelpSheet.d.ts +12 -0
  201. package/dist/space-sdk/src/ui/engine/KeyboardEngine.d.ts +27 -0
  202. package/dist/space-sdk/src/ui/engine/core.d.ts +73 -0
  203. package/dist/space-sdk/src/ui/engine/hooks.d.ts +64 -0
  204. package/dist/space-sdk/src/ui/engine/index.d.ts +14 -0
  205. package/dist/space-sdk/src/ui/engine/instance.d.ts +21 -0
  206. package/dist/space-sdk/src/ui/engine/keycapContext.d.ts +2 -0
  207. package/dist/space-sdk/src/ui/engine/registry.d.ts +77 -0
  208. package/dist/space-sdk/src/ui/engine/step.d.ts +11 -0
  209. package/dist/space-sdk/src/ui/engine/types.d.ts +54 -0
  210. package/dist/space-sdk/src/ui/fields/fieldWords.d.ts +56 -0
  211. package/dist/space-sdk/src/ui/fields/fields.d.ts +162 -0
  212. package/dist/space-sdk/src/ui/fields/index.d.ts +5 -0
  213. package/dist/space-sdk/src/ui/fields/useFieldDraft.d.ts +22 -0
  214. package/dist/space-sdk/src/ui/gallery/AttachmentGallery.d.ts +53 -0
  215. package/dist/space-sdk/src/ui/gallery/ImageViewer.d.ts +27 -0
  216. package/dist/space-sdk/src/ui/gallery/galleryWords.d.ts +95 -0
  217. package/dist/space-sdk/src/ui/gallery/index.d.ts +5 -0
  218. package/dist/space-sdk/src/ui/icons.d.ts +13 -0
  219. package/dist/space-sdk/src/ui/index.d.ts +54 -0
  220. package/dist/space-sdk/src/ui/keycap/Keycap.d.ts +22 -0
  221. package/dist/space-sdk/src/ui/keycap/index.d.ts +2 -0
  222. package/dist/space-sdk/src/ui/layout/Panels.d.ts +50 -0
  223. package/dist/space-sdk/src/ui/layout/chatPanelEdge.d.ts +77 -0
  224. package/dist/space-sdk/src/ui/layout/index.d.ts +5 -0
  225. package/dist/space-sdk/src/ui/layout/panelMath.d.ts +91 -0
  226. package/dist/space-sdk/src/ui/list/ListReader.d.ts +380 -0
  227. package/dist/space-sdk/src/ui/list/Row.d.ts +145 -0
  228. package/dist/space-sdk/src/ui/list/SelectionBar.d.ts +65 -0
  229. package/dist/space-sdk/src/ui/list/index.d.ts +14 -0
  230. package/dist/space-sdk/src/ui/list/rowState.d.ts +22 -0
  231. package/dist/space-sdk/src/ui/list/useListKeys.d.ts +89 -0
  232. package/dist/space-sdk/src/ui/list/useReorder.d.ts +86 -0
  233. package/dist/space-sdk/src/ui/list/useSelection.d.ts +90 -0
  234. package/dist/space-sdk/src/ui/open/OpenAsMenu.d.ts +15 -0
  235. package/dist/space-sdk/src/ui/open/index.d.ts +6 -0
  236. package/dist/space-sdk/src/ui/open/openModeStore.d.ts +39 -0
  237. package/dist/space-sdk/src/ui/open/useOpenMode.d.ts +17 -0
  238. package/dist/space-sdk/src/ui/overlay/Menu.d.ts +88 -0
  239. package/dist/space-sdk/src/ui/overlay/Popover.d.ts +28 -0
  240. package/dist/space-sdk/src/ui/overlay/Tooltip.d.ts +19 -0
  241. package/dist/space-sdk/src/ui/overlay/index.d.ts +9 -0
  242. package/dist/space-sdk/src/ui/overlay/layerRoot.d.ts +24 -0
  243. package/dist/space-sdk/src/ui/overlay/position.d.ts +31 -0
  244. package/dist/space-sdk/src/ui/period/PeriodPicker.d.ts +30 -0
  245. package/dist/space-sdk/src/ui/period/dateFormat.d.ts +50 -0
  246. package/dist/space-sdk/src/ui/period/dateWords.d.ts +88 -0
  247. package/dist/space-sdk/src/ui/period/periods.d.ts +70 -0
  248. package/dist/space-sdk/src/ui/period/usePeriod.d.ts +23 -0
  249. package/dist/space-sdk/src/ui/present/index.d.ts +3 -0
  250. package/dist/space-sdk/src/ui/present/presentWords.d.ts +27 -0
  251. package/dist/space-sdk/src/ui/present/screens.d.ts +36 -0
  252. package/dist/space-sdk/src/ui/present/useAudienceScreen.d.ts +38 -0
  253. package/dist/space-sdk/src/ui/styles.d.ts +21 -0
  254. package/dist/space-sdk/src/ui/timeline/TimelineView.d.ts +44 -0
  255. package/dist/space-sdk/src/ui/timeline/timelineMath.d.ts +123 -0
  256. package/dist/space-sdk/src/viewPeriod.d.ts +8 -0
  257. package/dist/space-sdk/src/vite.ts +92 -0
  258. package/dist/space-sdk/src/words/actions.ts +295 -0
  259. package/dist/space-sdk/src/words/clashes.ts +158 -0
  260. package/dist/space-sdk/src/words/format.ts +136 -0
  261. package/dist/space-sdk/src/words/index.ts +13 -0
  262. package/dist/space-sdk/src/words/keymap.ts +72 -0
  263. package/dist/space-sdk/src/words/keys.ts +297 -0
  264. package/dist/space-sdk/src/words/shell.ts +46 -0
  265. package/dist/space-sdk/src/words/types.ts +65 -0
  266. package/dist/view-renderer/dist/host.css +1 -0
  267. package/dist/view-renderer/dist/host.js +2374 -0
  268. package/dist/view-renderer/dist/theme.css +73 -0
  269. package/dist/view-renderer/src/dom.js +212 -0
  270. package/dist/view-renderer/src/errors.js +8 -0
  271. package/dist/view-renderer/src/host.tsx +250 -0
  272. package/dist/view-renderer/src/index.js +4 -0
  273. package/dist/view-renderer/src/png.js +213 -0
  274. package/dist/view-renderer/src/pool.js +110 -0
  275. package/dist/view-renderer/src/remote-broker.js +59 -0
  276. package/dist/view-renderer/src/renderer.js +271 -0
  277. package/package.json +21 -11
  278. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +63 -12
  279. package/skills/notis-onboarding/BRIEF.md +14 -9
  280. package/skills/notis-query/cli.md +1 -1
  281. package/skills/notis-spaces/cli.md +540 -0
  282. package/src/cli.js +8 -8
  283. package/src/command-specs/agents.js +121 -8
  284. package/src/command-specs/auth.js +3 -3
  285. package/src/command-specs/diagnostics.js +97 -20
  286. package/src/command-specs/documents.js +158 -0
  287. package/src/command-specs/files.js +29 -0
  288. package/src/command-specs/handover.js +331 -79
  289. package/src/command-specs/helpers.js +50 -40
  290. package/src/command-specs/index.js +14 -5
  291. package/src/command-specs/meta.js +38 -9
  292. package/src/command-specs/onboarding.js +1 -1
  293. package/src/command-specs/profile.js +42 -1
  294. package/src/command-specs/skills.js +314 -9
  295. package/src/command-specs/smoke.js +16 -4
  296. package/src/command-specs/spaces.js +783 -0
  297. package/src/command-specs/tools.js +118 -21
  298. package/src/command-specs/version-history.js +20 -0
  299. package/src/command-specs/views.js +69 -0
  300. package/src/runtime/agent-browser.js +178 -267
  301. package/src/runtime/agent-setup.js +172 -13
  302. package/src/runtime/app-boundary-validator.js +338 -2
  303. package/src/runtime/app-data-literals.js +82 -0
  304. package/src/runtime/app-platform.js +750 -1568
  305. package/src/runtime/auth-recovery.js +17 -0
  306. package/src/runtime/base-skills.d.ts +11 -0
  307. package/src/runtime/base-skills.js +100 -3
  308. package/src/runtime/computer-identity.d.ts +30 -0
  309. package/src/runtime/computer-identity.js +151 -0
  310. package/src/runtime/config-lock.js +135 -0
  311. package/src/runtime/confined-space-build.js +74 -0
  312. package/src/runtime/confined-space-verify.js +49 -0
  313. package/src/runtime/delegated-context.js +5 -21
  314. package/src/runtime/native-record-files.js +47 -0
  315. package/src/runtime/oauth.js +342 -31
  316. package/src/runtime/profiles.js +19 -112
  317. package/src/runtime/retired-sdk-files.js +24 -0
  318. package/src/runtime/retry.js +150 -0
  319. package/src/runtime/skill-sync/cloud-client.ts +33 -8
  320. package/src/runtime/skill-sync/index.ts +443 -106
  321. package/src/runtime/skill-sync/local-scanner.ts +248 -32
  322. package/src/runtime/skill-sync/native-identity.ts +102 -0
  323. package/src/runtime/skill-sync/native-plan.ts +70 -0
  324. package/src/runtime/skill-sync/symlink-manager.ts +66 -16
  325. package/src/runtime/skill-sync/sync-plan.ts +34 -2
  326. package/src/runtime/skill-sync/types.ts +54 -0
  327. package/src/runtime/skill-sync/write-cloud-skill.ts +4 -4
  328. package/src/runtime/skill-sync-service.js +109 -0
  329. package/src/runtime/source-root.js +32 -0
  330. package/src/runtime/space-collection-scaffold.js +107 -0
  331. package/src/runtime/space-database-structures.js +229 -0
  332. package/src/runtime/space-fixture-broker.js +68 -0
  333. package/src/runtime/space-lock.js +337 -0
  334. package/src/runtime/space-platform.js +133 -0
  335. package/src/runtime/space-source.js +290 -0
  336. package/src/runtime/space-test-server.js +354 -0
  337. package/src/runtime/space-verification.js +94 -0
  338. package/src/runtime/space-verify-browser-worker.js +51 -0
  339. package/src/runtime/space-view-manifest.js +368 -0
  340. package/src/runtime/sync-skills.d.ts +5 -1
  341. package/src/runtime/sync-skills.js +11 -2
  342. package/src/runtime/tool-confirmation.js +138 -0
  343. package/src/runtime/transport.js +118 -24
  344. package/src/runtime/upsell.js +219 -0
  345. package/src/runtime/view-render.js +49 -0
  346. package/src/skill-sync-worker-entry.js +2 -0
  347. package/src/skill-sync-worker.js +114 -0
  348. package/src/space-harness-host.ts +122 -0
  349. package/template/.harness/index.html.tmpl +9 -352
  350. package/template/app/globals.css +29 -3
  351. package/template/app/layout.tsx +2 -6
  352. package/template/components/ui/badge.tsx +7 -4
  353. package/template/components/ui/button.tsx +1 -1
  354. package/template/components/ui/native-select.tsx +24 -0
  355. package/template/notis.config.ts +4 -36
  356. package/template/package-lock.json +1800 -1867
  357. package/template/package.json +16 -14
  358. package/template/postcss.config.mjs +1 -1
  359. package/template/tailwind.config.ts +1 -6
  360. package/template/tsconfig.json +1 -0
  361. package/template/vite.config.ts +2 -2
  362. package/dist/base-skills/notis-apps/SKILL.md +0 -613
  363. package/skills/notis-apps/cli.md +0 -285
  364. package/src/command-specs/apps.js +0 -3000
  365. package/src/runtime/app-changelog.js +0 -79
  366. package/src/runtime/app-dev-consumers.js +0 -154
  367. package/src/runtime/app-dev-host-lock.js +0 -80
  368. package/src/runtime/app-dev-process-identity.js +0 -111
  369. package/src/runtime/app-dev-roots.js +0 -284
  370. package/src/runtime/app-dev-server.js +0 -1140
  371. package/src/runtime/app-dev-sessions.js +0 -185
  372. package/src/runtime/app-registry-scaffolds.js +0 -367
  373. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  374. package/src/runtime/cli-mode.generated.js +0 -5
  375. package/src/runtime/cli-mode.js +0 -34
  376. package/src/runtime/store-screenshot.js +0 -143
  377. package/template/CHANGELOG.md +0 -5
  378. package/template/app/page.tsx +0 -60
  379. package/template/components/ui/card.tsx +0 -56
  380. package/template/packages/sdk/src/components/DocumentEditor.tsx +0 -103
  381. package/template/packages/sdk/src/config.ts +0 -259
  382. package/template/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  383. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  384. package/template/packages/sdk/src/hooks/useDocument.ts +0 -78
  385. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +0 -52
  386. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  387. package/template/packages/sdk/src/runtime.ts +0 -439
  388. package/template/packages/sdk/src/vite.ts +0 -56
  389. package/template/packages/sdk/tsconfig.json +0 -15
  390. /package/{template/packages → dist}/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
  391. /package/{template/packages → dist}/sdk/src/components/ShortcutHints.tsx +0 -0
  392. /package/{template/packages → dist}/sdk/src/hooks/useActiveResource.ts +0 -0
  393. /package/{template/packages → dist}/sdk/src/hooks/useTool.ts +0 -0
  394. /package/{template/packages → dist}/sdk/src/hooks/useTools.ts +0 -0
  395. /package/{template/packages → dist}/sdk/src/ui.ts +0 -0
@@ -1,3000 +0,0 @@
1
- /**
2
- * Notis apps CLI commands.
3
- *
4
- * Canonical Notis app workflow:
5
- * init -> dev -> build -> deploy -> publish (after explicit approval)
6
- *
7
- * Supporting commands: list, link, doctor, pull.
8
- */
9
-
10
- import { randomUUID } from 'node:crypto';
11
- import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
-
15
- import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
16
- import { formatTable } from '../runtime/output.js';
17
- import {
18
- defaultAppProjectDir,
19
- resolveProjectDir,
20
- loadAppConfig,
21
- detectProjectProblems,
22
- detectProjectWarnings,
23
- buildArtifact,
24
- appLinkedStateProfileKey,
25
- readManifest,
26
- readLinkedState,
27
- writeLinkedState,
28
- requireLinkedAppId,
29
- scaffoldProject,
30
- findUnknownScreenshotScenarios,
31
- inspectListingReadiness,
32
- resolveListingScreenshots,
33
- collectArtifactFiles,
34
- collectSourceFiles,
35
- resolveConfiguredAppSkills,
36
- normalizeAppCapabilities,
37
- normalizeAppToolBindings,
38
- normalizeAppSkillManifestPath,
39
- appRowFieldsFromManifest,
40
- directDeploy,
41
- pullAppSource,
42
- } from '../runtime/app-platform.js';
43
- import {
44
- filterScaffoldCatalog,
45
- loadScaffoldCatalog,
46
- scaffoldRegistryLabel,
47
- } from '../runtime/app-registry-scaffolds.js';
48
- import { startAppDevServer } from '../runtime/app-dev-server.js';
49
- import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
50
- import {
51
- discoverRegisteredAppProjects,
52
- readAppDevRoots,
53
- registerAppDevRoot,
54
- removeAppDevRoot,
55
- } from '../runtime/app-dev-roots.js';
56
- import {
57
- captureHarnessScreenshot,
58
- closeAgentBrowserSession,
59
- isAgentBrowserAvailable,
60
- runHarnessRoute,
61
- } from '../runtime/agent-browser.js';
62
- import {
63
- getAppDevSessionsFile,
64
- heartbeatAppDevSession,
65
- linkAppDevSessionTarget,
66
- readAppDevSessions,
67
- removeAppDevSession,
68
- upsertAppDevSessions,
69
- } from '../runtime/app-dev-sessions.js';
70
- import {
71
- releaseAppDevHostLock,
72
- tryAcquireAppDevHostLock,
73
- } from '../runtime/app-dev-host-lock.js';
74
- import {
75
- heartbeatAppDevConsumer,
76
- hasAppDevConsumer,
77
- readAppDevConsumers,
78
- removeAppDevConsumer,
79
- } from '../runtime/app-dev-consumers.js';
80
- import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
81
- import { getCliMode } from '../runtime/cli-mode.js';
82
- import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
83
- import { httpRequest } from '../runtime/transport.js';
84
- import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
85
- import {
86
- localNotisToolSlug,
87
- nextIdempotencyKey,
88
- runToolCommand,
89
- toolConflictToError,
90
- } from './helpers.js';
91
-
92
- export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
93
-
94
- const DEFAULT_DEV_PORT = 5173;
95
- const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
96
- const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
97
- const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
98
- const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
99
-
100
- function projectIsWithinRoot(projectDir, rootDir) {
101
- const nested = relative(rootDir, projectDir);
102
- return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
103
- }
104
-
105
- function parseNotisAppVersion(value) {
106
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
107
- String(value || '').trim(),
108
- );
109
- if (!match) return null;
110
- const prerelease = match[4] ? match[4].split('.') : [];
111
- if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
112
- return null;
113
- }
114
- return {
115
- major: match[1],
116
- minor: match[2],
117
- patch: match[3],
118
- prerelease,
119
- };
120
- }
121
-
122
- function compareNumericSemverIdentifiers(left, right) {
123
- if (left.length !== right.length) return left.length > right.length ? 1 : -1;
124
- return left === right ? 0 : left > right ? 1 : -1;
125
- }
126
-
127
- function compareNotisPrerelease(left, right) {
128
- if (left.length === 0 || right.length === 0) {
129
- return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
130
- }
131
- const length = Math.max(left.length, right.length);
132
- for (let index = 0; index < length; index += 1) {
133
- const leftIdentifier = left[index];
134
- const rightIdentifier = right[index];
135
- if (leftIdentifier === undefined || rightIdentifier === undefined) {
136
- return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
137
- }
138
- if (leftIdentifier === rightIdentifier) continue;
139
- const leftNumeric = /^\d+$/.test(leftIdentifier);
140
- const rightNumeric = /^\d+$/.test(rightIdentifier);
141
- if (leftNumeric && rightNumeric) {
142
- return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
143
- }
144
- if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
145
- return leftIdentifier > rightIdentifier ? 1 : -1;
146
- }
147
- return 0;
148
- }
149
-
150
- export function compareNotisAppVersions(leftValue, rightValue) {
151
- const left = parseNotisAppVersion(leftValue);
152
- const right = parseNotisAppVersion(rightValue);
153
- if (!left || !right) return null;
154
- for (const key of ['major', 'minor', 'patch']) {
155
- const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
156
- if (comparison !== 0) return comparison;
157
- }
158
- return compareNotisPrerelease(left.prerelease, right.prerelease);
159
- }
160
-
161
- function readLocalNotisAppVersion(projectDir) {
162
- try {
163
- const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
164
- const version = String(packageJson.notisAppVersion || '').trim();
165
- return parseNotisAppVersion(version) ? version : null;
166
- } catch {
167
- return null;
168
- }
169
- }
170
-
171
- /**
172
- * A CLI launch may add one root, but the shared host always serves the complete
173
- * machine registry. Desktop sets skipRootRegistration because it is reconciling
174
- * an already-registered snapshot; this must never narrow discovery to the one
175
- * project path used to start the host.
176
- */
177
- export function discoverAppDevLaunchProjects(rootDir, {
178
- skipRootRegistration = false,
179
- registerRoot = registerAppDevRoot,
180
- discoverProjects = discoverRegisteredAppProjects,
181
- } = {}) {
182
- if (!skipRootRegistration) registerRoot(rootDir);
183
- return discoverProjects();
184
- }
185
-
186
- /**
187
- * Collapse duplicate local sources before they reach the shared loopback host.
188
- * An explicitly registered root is more intentional than the implicit
189
- * ~/.notis/apps root. If two equally intentional roots claim the same dev
190
- * slug, omit that slug and keep serving every unrelated app.
191
- */
192
- export function selectCanonicalDevApps(candidates, rootsRegistry) {
193
- const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
194
- const groups = new Map();
195
- for (const candidate of candidates) {
196
- groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
197
- }
198
-
199
- const selected = [];
200
- const warnings = [];
201
- for (const [devSlug, group] of groups) {
202
- if (group.length === 1) {
203
- selected.push(group[0]);
204
- continue;
205
- }
206
- const ranked = group.map((candidate) => {
207
- const explicitRoots = roots.filter((root) => (
208
- root?.implicit !== true
209
- && typeof root?.path === 'string'
210
- && projectIsWithinRoot(candidate.projectDir, root.path)
211
- ));
212
- const newestRegistration = explicitRoots.reduce((latest, root) => {
213
- const timestamp = Date.parse(root.registeredAt || '');
214
- return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
215
- }, 0);
216
- return {
217
- candidate,
218
- rank: explicitRoots.length > 0 ? 1 : 0,
219
- newestRegistration,
220
- };
221
- }).sort((left, right) => (
222
- right.rank - left.rank
223
- || right.newestRegistration - left.newestRegistration
224
- ));
225
- const winner = ranked[0];
226
- const runnerUp = ranked[1];
227
- const unambiguous = winner.rank > runnerUp.rank
228
- || winner.newestRegistration > runnerUp.newestRegistration;
229
- if (unambiguous) {
230
- selected.push(winner.candidate);
231
- warnings.push(
232
- `Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
233
- );
234
- continue;
235
- }
236
- warnings.push(
237
- `Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
238
- );
239
- }
240
- return { selected, warnings };
241
- }
242
-
243
- export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
244
- const now = Date.now();
245
- const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
246
- .filter((session) => {
247
- if (session.sourceHost !== true) return false;
248
- const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
249
- if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
250
- if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
251
- try {
252
- process.kill(session.hostPid, 0);
253
- return true;
254
- } catch {
255
- return false;
256
- }
257
- });
258
- const groups = new Map();
259
- for (const session of sourceSessions) {
260
- const key = `${session.hostPid || 0}:${session.sessionId}`;
261
- groups.set(key, [...(groups.get(key) || []), session]);
262
- }
263
- for (const sessions of groups.values()) {
264
- const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
265
- const canonicalProjects = sessions.find((session) => (
266
- Array.isArray(session.canonicalProjects)
267
- ))?.canonicalProjects;
268
- const discoveredProjects = sessions.find((session) => (
269
- Array.isArray(session.discoveredProjects)
270
- ))?.discoveredProjects;
271
- const discoveryMatches = discoveredProjects
272
- && JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
273
- if (
274
- discoveryMatches
275
- && canonicalProjects
276
- && canonicalProjects.every((projectDir) => byProject.has(projectDir))
277
- ) {
278
- return new Map(canonicalProjects.map((projectDir) => [
279
- projectDir,
280
- byProject.get(projectDir).bundleBaseUrl,
281
- ]));
282
- }
283
- if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
284
- return new Map(projectDirs.map((projectDir) => [
285
- projectDir,
286
- byProject.get(projectDir).bundleBaseUrl,
287
- ]));
288
- }
289
- return null;
290
- }
291
- const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
292
- const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
293
- const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
294
- const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
295
- const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
296
- const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
297
- export const APP_DEPLOY_TIMEOUT_MS = 600_000;
298
-
299
- // ---------------------------------------------------------------------------
300
- // Formatters
301
- // ---------------------------------------------------------------------------
302
-
303
- function appsTable(apps) {
304
- return formatTable(apps, [
305
- { label: 'ID', value: (app) => app.app_id || app.id || '' },
306
- { label: 'Name', value: (app) => app.name || 'Untitled' },
307
- { label: 'Version', value: (app) => app.current_version || app.manifest?.version || 0 },
308
- { label: 'Status', value: (app) => app.status || '' },
309
- ]);
310
- }
311
-
312
- function scaffoldsTable(scaffolds) {
313
- return formatTable(scaffolds, [
314
- { label: 'Slug', value: (scaffold) => scaffold.slug || '' },
315
- { label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
316
- { label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
317
- { label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
318
- ]);
319
- }
320
-
321
- function appDevRootsTable(roots) {
322
- return formatTable(roots, [
323
- { label: 'Folder', value: (root) => root.path },
324
- { label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
325
- { label: 'Registered', value: (root) => root.registeredAt || '' },
326
- ]);
327
- }
328
-
329
- function decodeJwtSub(jwt) {
330
- if (!jwt) return null;
331
- try {
332
- const parts = jwt.split('.');
333
- if (parts.length !== 3) return null;
334
- const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
335
- return decoded.sub || decoded.email || null;
336
- } catch {
337
- return null;
338
- }
339
- }
340
-
341
- function linkedStateProfileKey(runtime) {
342
- return appLinkedStateProfileKey({
343
- apiBase: runtime?.apiBase,
344
- userId: decodeJwtSub(runtime?.jwt),
345
- });
346
- }
347
-
348
- function slugify(value) {
349
- return String(value || '')
350
- .trim()
351
- .toLowerCase()
352
- .replace(/[^a-z0-9]+/g, '-')
353
- .replace(/(^-|-$)+/g, '');
354
- }
355
-
356
- function buildDevInstallSlug(appConfig) {
357
- const base = slugify(appConfig?.devSlug || appConfig?.name);
358
- if (!base) {
359
- return '';
360
- }
361
- return base.endsWith('-dev') ? base : `${base}-dev`;
362
- }
363
-
364
- function timingMs(startedAt) {
365
- return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
366
- }
367
-
368
- function logAppsTiming(label, details = {}) {
369
- const suffix = Object.entries(details)
370
- .map(([key, value]) => `${key}=${value}`)
371
- .join(' ');
372
- process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
373
- }
374
-
375
- function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
376
- if (globalOptions.idempotencyKey) {
377
- return `${globalOptions.idempotencyKey}:${devSlug}`;
378
- }
379
- return nextIdempotencyKey(globalOptions);
380
- }
381
-
382
- function pickDefaultRouteSlug(manifest) {
383
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
384
- const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
385
- if (explicit) return explicit.slug;
386
- const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
387
- return firstWithSlug ? firstWithSlug.slug : null;
388
- }
389
-
390
- export function buildDevelopmentAppHref({
391
- appSlug,
392
- appId,
393
- devSlug,
394
- targetAppId = null,
395
- targetAppSlug = null,
396
- manifest,
397
- }) {
398
- const routeAppId = `${targetAppId || appId}__local_dev__${devSlug}`;
399
- const routeAppSlug = targetAppSlug || devSlug || appSlug;
400
- const originlessBase = `/apps/${routeAppSlug}-${routeAppId}`;
401
- const routeSlug = pickDefaultRouteSlug(manifest);
402
- return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
403
- }
404
-
405
- function parsePort(value) {
406
- if (!value) return null;
407
- const port = Number.parseInt(value, 10);
408
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
409
- throw usageError('Port must be between 1 and 65535.');
410
- }
411
- return port;
412
- }
413
-
414
- function parsePositiveInt(value) {
415
- if (!value) return null;
416
- const parsed = Number.parseInt(value, 10);
417
- if (!Number.isInteger(parsed) || parsed <= 0) {
418
- throw usageError('Expected a positive integer.');
419
- }
420
- return parsed;
421
- }
422
-
423
- function parseRouteSlugs(value) {
424
- if (!value) return null;
425
- const slugs = String(value)
426
- .split(',')
427
- .map((entry) => entry.trim())
428
- .filter(Boolean);
429
- if (slugs.length === 0) {
430
- throw usageError('--routes must include at least one route slug.');
431
- }
432
- return slugs;
433
- }
434
-
435
- function routeSelection(manifest, rawRouteSlugs) {
436
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
437
- if (routes.length === 0) {
438
- throw usageError('Manifest has no routes to verify.');
439
- }
440
- if (!rawRouteSlugs) {
441
- return routes;
442
- }
443
-
444
- const bySlug = new Map(routes.map((route) => [route.slug, route]));
445
- const selected = [];
446
- const missing = [];
447
- for (const slug of rawRouteSlugs) {
448
- const route = bySlug.get(slug);
449
- if (route) {
450
- selected.push(route);
451
- } else {
452
- missing.push(slug);
453
- }
454
- }
455
- if (missing.length) {
456
- throw usageError(
457
- `Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
458
- { available_routes: routes.map((route) => route.slug) },
459
- );
460
- }
461
- return selected;
462
- }
463
-
464
- export function pruneStaleScreenshotFiles(outputDir, keepCount) {
465
- for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
466
- if (!entry.isFile()) continue;
467
- const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
468
- if (match && Number.parseInt(match[1], 10) > keepCount) {
469
- rmSync(join(outputDir, entry.name), { force: true });
470
- }
471
- }
472
- }
473
-
474
- export function shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failedCount) {
475
- return failedCount === 0 && !selectedRouteSlugs;
476
- }
477
-
478
- export function screenshotIndexByRouteSlug(manifest) {
479
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
480
- return new Map(routes.map((route, index) => [route.slug, index + 1]));
481
- }
482
-
483
- export function screenshotExitCode(failedCount) {
484
- return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
485
- }
486
-
487
- function declaredDatabaseSlugs(appConfig, manifest, route) {
488
- const slugs = new Set();
489
- for (const entry of appConfig?.databases || manifest?.databases || []) {
490
- if (typeof entry === 'string' && entry) {
491
- slugs.add(entry);
492
- } else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
493
- slugs.add(entry.slug);
494
- }
495
- }
496
- if (route?.collection?.database) {
497
- slugs.add(route.collection.database);
498
- }
499
- return Array.from(slugs);
500
- }
501
-
502
- function harnessErrorMessage(error) {
503
- if (!error || typeof error !== 'object') {
504
- return String(error);
505
- }
506
- return error.message || error.reason || error.type || JSON.stringify(error);
507
- }
508
-
509
- function runtimeCallLabel(call) {
510
- if (call?.op === 'callTool') {
511
- return call?.args?.name || 'callTool';
512
- }
513
- if (call?.op === 'request') {
514
- return `request ${call?.args?.path || ''}`.trim();
515
- }
516
- return call?.op || 'runtime call';
517
- }
518
-
519
- function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
520
- const assertions = [];
521
- if (result.tool_error) {
522
- assertions.push({
523
- ok: false,
524
- code: 'tool_error',
525
- message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
526
- details: result.tool_error,
527
- });
528
- }
529
- if (result.mounted !== true) {
530
- assertions.push({
531
- ok: false,
532
- code: 'not_mounted',
533
- message: 'Harness did not report mounted === true.',
534
- });
535
- }
536
- if (result.timed_out) {
537
- assertions.push({
538
- ok: false,
539
- code: 'timeout',
540
- message: 'Timed out waiting for window.__harness.mounted.',
541
- });
542
- }
543
- for (const error of result.errors || []) {
544
- assertions.push({
545
- ok: false,
546
- code: 'render_error',
547
- message: harnessErrorMessage(error),
548
- details: error,
549
- });
550
- }
551
- const runtimeCalls = result.runtimeCalls || [];
552
- const declaredDatabaseSet = new Set(databaseSlugs);
553
- const databaseQueries = runtimeCalls.filter(
554
- (call) =>
555
- call?.op === 'callTool'
556
- && localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
557
- );
558
- for (const call of databaseQueries) {
559
- const databaseSlug = call?.args?.arguments?.database_slug;
560
- if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
561
- assertions.push({
562
- ok: false,
563
- code: 'undeclared_database_query',
564
- message: `Route "${route.slug}" queried undeclared database "${databaseSlug}".`,
565
- details: { databaseSlug },
566
- });
567
- }
568
- }
569
- const collectionDatabase = route?.collection?.database;
570
- if (
571
- collectionDatabase
572
- && !databaseQueries.some((call) => call?.args?.arguments?.database_slug === collectionDatabase)
573
- ) {
574
- assertions.push({
575
- ok: false,
576
- code: 'missing_collection_database_query',
577
- message: `Collection route "${route.slug}" did not query "${collectionDatabase}".`,
578
- details: { databaseSlug: collectionDatabase },
579
- });
580
- }
581
- if (mode === 'live') {
582
- // In live mode an app that catches every failed call and renders its error
583
- // state still mounts cleanly, so the render assertions above all pass. Only
584
- // the recorded outcomes reveal that nothing real came back.
585
- if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
586
- assertions.push({
587
- ok: false,
588
- code: 'all_runtime_calls_failed',
589
- message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
590
- details: {
591
- failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
592
- },
593
- });
594
- }
595
- for (const databaseSlug of databaseSlugs) {
596
- const queries = databaseQueries.filter(
597
- (call) => call?.args?.arguments?.database_slug === databaseSlug,
598
- );
599
- // A call still in flight when the harness was read has ok === null; only
600
- // an explicit failure with no successful sibling is a real problem.
601
- if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
602
- assertions.push({
603
- ok: false,
604
- code: 'failed_database_query',
605
- message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
606
- details: { databaseSlug },
607
- });
608
- }
609
- }
610
- }
611
- return assertions;
612
- }
613
-
614
- function renderVerifyReport({ summary, results, noBrowser }) {
615
- const lines = [
616
- noBrowser
617
- ? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
618
- : `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
619
- ];
620
- for (const result of results) {
621
- const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
622
- lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
623
- for (const assertion of result.assertions || []) {
624
- lines.push(` - ${assertion.message}`);
625
- for (const failure of assertion.details?.failed || []) {
626
- lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
627
- }
628
- }
629
- }
630
- if (noBrowser) {
631
- lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
632
- }
633
- return lines.join('\n');
634
- }
635
-
636
- function buildManifestForDev(appConfig, projectDir) {
637
- const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
638
- return {
639
- version: 1,
640
- spec_version: 3,
641
- app: {
642
- name: appConfig.name,
643
- description: appConfig.description || null,
644
- icon: appConfig.icon || null,
645
- release_version: readLocalNotisAppVersion(projectDir),
646
- },
647
- routes: routes.map((route) => ({
648
- path: route.path,
649
- slug: route.slug,
650
- name: route.name,
651
- icon: route.icon || null,
652
- parentSlug: route.parentSlug || null,
653
- default: route.default || false,
654
- resourceDeepLinks: route.resourceDeepLinks === true,
655
- export_name: route.exportName || route.export_name,
656
- collection: route.collection || null,
657
- })),
658
- bundle: {
659
- js: 'bundle/app.js',
660
- css: 'bundle/app.css',
661
- },
662
- databases: appConfig.databases || [],
663
- capabilities: normalizeAppCapabilities(appConfig.capabilities),
664
- tools: appConfig.tools || [],
665
- tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
666
- skills: (appConfig.skills || []).map((skill) => ({
667
- key: skill.key,
668
- path: normalizeAppSkillManifestPath(skill.path),
669
- name: skill.name,
670
- description: skill.description || null,
671
- })),
672
- onboarding: appConfig.onboarding || null,
673
- };
674
- }
675
-
676
- export function buildEnsureDevInstallArguments({
677
- appConfig,
678
- manifest,
679
- linkedState,
680
- skills,
681
- useInstalledDatabases = false,
682
- approvedCapabilities = null,
683
- }) {
684
- const devSlug = buildDevInstallSlug(appConfig);
685
- const arguments_ = {
686
- dev_slug: devSlug,
687
- name: appConfig.name,
688
- manifest,
689
- skills,
690
- };
691
- if (linkedState?.dev_app_id) {
692
- arguments_.app_id = linkedState.dev_app_id;
693
- }
694
- if (useInstalledDatabases && linkedState?.app_id) {
695
- arguments_.installed_app_id = linkedState.app_id;
696
- }
697
- // Sent on every ensure call, including as `false`, so a `--scratch` session
698
- // puts the app on its own dev copies and the next plain session puts it back
699
- // on the installed app's databases.
700
- arguments_.use_installed_databases = Boolean(useInstalledDatabases);
701
- if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
702
- arguments_.approved_capabilities = approvedCapabilities;
703
- }
704
- return arguments_;
705
- }
706
-
707
- const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
708
-
709
- /**
710
- * Collect the developer's explicit approval for `cloudComputer: 'shell'`.
711
- *
712
- * The server never grants shell from authorship alone — holding an app's source
713
- * (a scaffold, a pulled repo) is not consent to full-authority commands on the
714
- * cloud computer — so `apps dev` asks once, records the answer in the linked
715
- * state, and passes the grant with the ensure call. Runs before the parallel
716
- * ensure fan-out so the prompt cannot interleave.
717
- */
718
- export async function resolveCloudShellConsent({
719
- appConfig,
720
- projectDir,
721
- grantCloudShell = false,
722
- logger = console,
723
- profileKey = null,
724
- }) {
725
- const capabilities = normalizeAppCapabilities(appConfig.capabilities);
726
- if (capabilities.cloudComputer !== 'shell') {
727
- return null;
728
- }
729
- const approved = ['cloud_computer_read', 'cloud_computer_shell'];
730
- const linkedState = readLinkedState(projectDir, profileKey) || {};
731
- const recordDecision = (decision) => {
732
- writeLinkedState(
733
- projectDir,
734
- { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
735
- profileKey,
736
- );
737
- };
738
-
739
- if (grantCloudShell) {
740
- recordDecision('granted');
741
- return approved;
742
- }
743
- const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
744
- if (recorded === 'granted') {
745
- return approved;
746
- }
747
- const declineHint =
748
- `${appConfig.name}: cloud computer shell stays denied for this dev session. `
749
- + 'Re-run with --grant-cloud-shell to approve it.';
750
- if (recorded === 'declined') {
751
- logger.warn(declineHint);
752
- return null;
753
- }
754
- if (!process.stdin.isTTY || !process.stderr.isTTY) {
755
- logger.warn(declineHint);
756
- return null;
757
- }
758
- const { createInterface } = await import('node:readline/promises');
759
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
760
- try {
761
- const answer = (await prompt.question(
762
- `${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
763
- + 'read or write files on your cloud computer with the same authority as your own '
764
- + 'agent. Allow for this dev app? [y/N] ',
765
- )).trim().toLowerCase();
766
- const granted = answer === 'y' || answer === 'yes';
767
- recordDecision(granted ? 'granted' : 'declined');
768
- if (!granted) {
769
- logger.warn(declineHint);
770
- }
771
- return granted ? approved : null;
772
- } finally {
773
- prompt.close();
774
- }
775
- }
776
-
777
- export async function ensureDevInstall({
778
- ctx,
779
- appConfig,
780
- projectDir,
781
- idempotencyKey,
782
- useInstalledDatabases = false,
783
- approvedCapabilities = null,
784
- runTool = runToolCommand,
785
- }) {
786
- if (!appConfig.name) {
787
- throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
788
- }
789
- const devSlug = buildDevInstallSlug(appConfig);
790
- if (!devSlug) {
791
- throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
792
- }
793
-
794
- const manifest = buildManifestForDev(appConfig, projectDir);
795
- const skills = resolveConfiguredAppSkills(appConfig, projectDir);
796
- const profileKey = linkedStateProfileKey(ctx.runtime);
797
- let linkedState = readLinkedState(projectDir, profileKey);
798
- let linkedApp = null;
799
- if (linkedState?.dev_app_id) {
800
- const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
801
- if (!devApp || devApp.manifest?.is_dev !== true) {
802
- const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
803
- linkedState = rest;
804
- writeLinkedState(projectDir, linkedState, profileKey);
805
- }
806
- }
807
- if (linkedState?.app_id) {
808
- linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
809
- if (linkedApp?.manifest?.is_dev === true) {
810
- const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
811
- const devAppId = linkedState.dev_app_id || legacyDevAppId;
812
- linkedState = {
813
- ...rest,
814
- ...(devAppId ? { dev_app_id: devAppId } : {}),
815
- ...(devAppId ? {
816
- dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
817
- } : {}),
818
- };
819
- writeLinkedState(projectDir, linkedState, profileKey);
820
- linkedApp = null;
821
- }
822
- }
823
- // A checkout can be shared by Beta and a source-workspace desktop. Its
824
- // installed app link is valid for the Beta account but intentionally
825
- // inaccessible to the worktree's test user. Preserve that durable link for
826
- // Beta, while omitting it from this run so the local dev app uses its own
827
- // resources instead of sending an unauthorized installed_app_id to ensure.
828
- if (!linkedState?.app_id) {
829
- const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
830
- ctx.runtime,
831
- devSlug,
832
- linkedState?.dev_app_id,
833
- projectDir,
834
- profileKey,
835
- runTool,
836
- );
837
- const installedCandidates = installedResolution.candidates;
838
- if (installedCandidates.length > 1) {
839
- if (installedResolution.source === 'persisted-project-link') {
840
- throw usageError(
841
- `Multiple accessible installed apps are persisted for ${projectDir}. `
842
- + `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
843
- );
844
- }
845
- throw usageError(
846
- `Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
847
- + `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
848
- );
849
- }
850
- if (installedCandidates.length === 1) {
851
- const now = new Date().toISOString();
852
- linkedApp = installedCandidates[0];
853
- linkedState = {
854
- ...(linkedState || {}),
855
- app_id: linkedApp.app_id || linkedApp.id,
856
- linked_at: now,
857
- auto_linked_at: now,
858
- };
859
- writeLinkedState(projectDir, linkedState, profileKey);
860
- }
861
- }
862
- const runtimeLinkedState = linkedState?.app_id && !linkedApp
863
- ? Object.fromEntries(
864
- Object.entries(linkedState).filter(([key]) => ![
865
- 'app_id', 'linked_at', 'deployed_at', 'version',
866
- ].includes(key)),
867
- )
868
- : linkedState;
869
- const localReleaseVersion = manifest.app.release_version || null;
870
- const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
871
- const mountEligible = !linkedApp
872
- || compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
873
- const ensureArguments = buildEnsureDevInstallArguments({
874
- appConfig,
875
- manifest,
876
- linkedState: runtimeLinkedState,
877
- skills,
878
- useInstalledDatabases,
879
- approvedCapabilities,
880
- });
881
- let ensureResult;
882
- try {
883
- ensureResult = await runTool({
884
- runtime: ctx.runtime,
885
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
886
- arguments_: ensureArguments,
887
- mutating: true,
888
- idempotencyKey,
889
- });
890
- } catch (error) {
891
- const backendCode = error?.details?.error?.code;
892
- if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
893
- // The authenticated environment can outlive an older local API profile.
894
- // If that profile remembered a different dev row while this user already
895
- // owns the exact canonical dev slug, the backend refuses a duplicate.
896
- // Retry without the stale row id so the backend deterministically adopts
897
- // the unique slug owner; preserve the installed target and every grant.
898
- const staleDevAppId = linkedState.dev_app_id;
899
- const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
900
- linkedState = rest;
901
- writeLinkedState(projectDir, linkedState, profileKey);
902
- const retryArguments = { ...ensureArguments };
903
- delete retryArguments.app_id;
904
- ensureResult = await runTool({
905
- runtime: ctx.runtime,
906
- toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
907
- arguments_: retryArguments,
908
- mutating: true,
909
- idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
910
- });
911
- } else {
912
- throw error;
913
- }
914
- }
915
- const appId = ensureResult.payload.app_id;
916
- if (!appId) {
917
- throw usageError('Dev installation tool did not return an app_id.');
918
- }
919
- if (linkedState?.dev_app_id !== appId) {
920
- const now = new Date().toISOString();
921
- writeLinkedState(projectDir, {
922
- ...(linkedState || {}),
923
- dev_app_id: appId,
924
- dev_linked_at: linkedState?.dev_linked_at || now,
925
- }, profileKey);
926
- }
927
- return {
928
- slug: ensureResult.payload.slug || devSlug,
929
- devSlug,
930
- name: appConfig.name,
931
- appId,
932
- projectDir,
933
- manifest,
934
- created: ensureResult.payload.created || false,
935
- linkedAppId: runtimeLinkedState?.app_id || null,
936
- targetAppId: runtimeLinkedState?.app_id || null,
937
- targetAppSlug: linkedApp?.slug || null,
938
- localReleaseVersion,
939
- installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
940
- mountEligible,
941
- databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
942
- liveData: ensureResult.payload.live_data || null,
943
- };
944
- }
945
-
946
- function databaseMaterializationWarnings(apps) {
947
- const warnings = [];
948
- for (const app of apps) {
949
- const unresolved = app.databaseMaterialization?.unresolved || [];
950
- if (!unresolved.length) {
951
- continue;
952
- }
953
- warnings.push(
954
- `${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
955
- 'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
956
- );
957
- }
958
- return warnings;
959
- }
960
-
961
- function liveDataWarnings(apps) {
962
- return apps
963
- .filter((app) => app.liveData?.warning)
964
- .map((app) => `${app.name}: ${app.liveData.warning}`);
965
- }
966
-
967
- function versionPrecedenceWarnings(apps) {
968
- return apps
969
- .filter((app) => app.targetAppId && app.mountEligible === false)
970
- .map((app) => {
971
- const localVersion = app.localReleaseVersion || 'missing or invalid';
972
- const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
973
- return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
974
- });
975
- }
976
-
977
- async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
978
- const result = await runTool({
979
- runtime,
980
- toolName: GET_APP_TOOL,
981
- arguments_: { app_id: appId, include_documents: false },
982
- });
983
- if (result.payload?.app) {
984
- return {
985
- ...result.payload.app,
986
- apps_access: result.payload.apps_access,
987
- };
988
- }
989
- const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
990
- const errorCode = result.payload?.code || result.payload?.error?.code;
991
- if (
992
- result.payload?.status === 'error'
993
- && (errorCode === 'app_not_found' || /^App not found\.?$/i.test(message.trim()))
994
- ) {
995
- return null;
996
- }
997
- throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
998
- }
999
-
1000
- async function findInstalledLinkCandidatesForLegacyDevState(
1001
- runtime,
1002
- devSlug,
1003
- devAppId,
1004
- projectDir,
1005
- profileKey,
1006
- runTool = runToolCommand,
1007
- ) {
1008
- const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
1009
- if (!installedSlug) return { candidates: [], source: null };
1010
-
1011
- const result = await runTool({
1012
- runtime,
1013
- toolName: LIST_APPS_TOOL,
1014
- });
1015
- const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
1016
- (app?.app_id || app?.id)
1017
- && (app.app_id || app.id) !== devAppId
1018
- && app?.manifest?.is_dev !== true
1019
- ));
1020
-
1021
- // A project-local app id is stronger evidence than a slug. Older CLI runs
1022
- // could persist that explicit link under another API/user profile before
1023
- // the machine-local discovery host mounted the same source here. Migrate it
1024
- // only when exactly one such id is accessible in the current environment;
1025
- // multiple accessible ids remain ambiguous and fail closed below.
1026
- const rawState = readLinkedState(projectDir) || {};
1027
- const persistedAppIds = new Set();
1028
- if (rawState.app_id) persistedAppIds.add(rawState.app_id);
1029
- const profiles = rawState.profiles && typeof rawState.profiles === 'object'
1030
- ? rawState.profiles
1031
- : {};
1032
- for (const [storedProfileKey, state] of Object.entries(profiles)) {
1033
- if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
1034
- if (state.app_id) persistedAppIds.add(state.app_id);
1035
- }
1036
- const persistedCandidates = accessibleInstalledApps.filter((app) => (
1037
- persistedAppIds.has(app.app_id || app.id)
1038
- ));
1039
- if (persistedCandidates.length) {
1040
- return { candidates: persistedCandidates, source: 'persisted-project-link' };
1041
- }
1042
-
1043
- // The database row slug can change when a Store app is installed or an old
1044
- // development app is promoted. The shipped source manifest keeps the
1045
- // canonical app slug, so accept either exact field. Display names are never
1046
- // considered.
1047
- const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
1048
- const canonicalSlugs = new Set([
1049
- app?.slug,
1050
- app?.manifest?.app?.slug,
1051
- ].map(slugify).filter(Boolean));
1052
- return canonicalSlugs.has(installedSlug);
1053
- });
1054
- return { candidates: exactSlugCandidates, source: 'exact-slug' };
1055
- }
1056
-
1057
- export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
1058
- const app = await getAccessibleApp(runtime, appId, runTool);
1059
- if (!app) {
1060
- throw usageError(`Direct deploy requires access to app ${appId}.`);
1061
- }
1062
- if (app.apps_access?.has_access !== true) {
1063
- throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
1064
- }
1065
- if (app.can_edit !== true) {
1066
- throw usageError(`Direct deploy requires edit access to app ${appId}.`);
1067
- }
1068
- if (app.manifest?.is_dev === true) {
1069
- throw usageError(
1070
- 'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
1071
- );
1072
- }
1073
- return app;
1074
- }
1075
-
1076
- export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
1077
- const app = await getAccessibleApp(runtime, appId, runTool);
1078
- if (!app) {
1079
- throw usageError(`Cannot link to inaccessible app ${appId}.`);
1080
- }
1081
- if (app.manifest?.is_dev === true) {
1082
- throw usageError(
1083
- `Cannot link to development runtime app ${appId}. ` +
1084
- 'Link to an installed workspace app, or run `notis apps dev` without a target.',
1085
- );
1086
- }
1087
- return app;
1088
- }
1089
-
1090
- // ---------------------------------------------------------------------------
1091
- // Handlers
1092
- // ---------------------------------------------------------------------------
1093
-
1094
- async function appsListHandler(ctx) {
1095
- const result = await runToolCommand({
1096
- runtime: ctx.runtime,
1097
- toolName: LIST_APPS_TOOL,
1098
- });
1099
- const apps = result.payload.apps || [];
1100
- return ctx.output.emitSuccess({
1101
- command: ctx.spec.command_path.join(' '),
1102
- data: { apps },
1103
- humanSummary: apps.length ? `Found ${apps.length} apps` : 'No apps found.',
1104
- renderHuman: () => (apps.length ? appsTable(apps) : 'No apps found.'),
1105
- });
1106
- }
1107
-
1108
- async function appsInitHandler(ctx) {
1109
- const projectDir = ctx.args.dir
1110
- ? resolveProjectDir(ctx.args.dir)
1111
- : defaultAppProjectDir(slugify(ctx.args.name));
1112
- const fromSlug = ctx.options.from || null;
1113
-
1114
- await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
1115
-
1116
- return ctx.output.emitSuccess({
1117
- command: ctx.spec.command_path.join(' '),
1118
- data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
1119
- humanSummary: fromSlug
1120
- ? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
1121
- : `Scaffolded "${ctx.args.name}" in ${projectDir}`,
1122
- hints: [
1123
- { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
1124
- { command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
1125
- ],
1126
- });
1127
- }
1128
-
1129
- async function appsScaffoldsListHandler(ctx) {
1130
- const searchTerm = ctx.options.search || null;
1131
- const catalog = await loadScaffoldCatalog();
1132
- const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
1133
- const registry = scaffoldRegistryLabel();
1134
- const emptyMessage = searchTerm
1135
- ? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
1136
- : `No published scaffolds found in ${registry}.`;
1137
- return ctx.output.emitSuccess({
1138
- command: ctx.spec.command_path.join(' '),
1139
- data: { scaffolds, registry, search: searchTerm },
1140
- humanSummary: scaffolds.length
1141
- ? `Found ${scaffolds.length} published scaffolds in ${registry}`
1142
- : emptyMessage,
1143
- renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
1144
- });
1145
- }
1146
-
1147
- async function appsCreateHandler(ctx) {
1148
- const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
1149
- const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
1150
- const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1151
- const result = await runToolCommand({
1152
- runtime: ctx.runtime,
1153
- toolName: CREATE_APP_TOOL,
1154
- arguments_: {
1155
- name: ctx.args.name,
1156
- description: appConfig?.description || undefined,
1157
- icon: appConfig?.icon || undefined,
1158
- accent: appConfig?.accent ?? undefined,
1159
- },
1160
- mutating: true,
1161
- idempotencyKey,
1162
- });
1163
-
1164
- const app = result.payload.app || result.payload;
1165
- if (!app?.id) {
1166
- throw usageError('Create app did not return an app id.');
1167
- }
1168
-
1169
- if (projectDir) {
1170
- writeLinkedState(projectDir, {
1171
- app_id: app.id,
1172
- linked_at: new Date().toISOString(),
1173
- }, linkedStateProfileKey(ctx.runtime));
1174
- }
1175
-
1176
- return ctx.output.emitSuccess({
1177
- command: ctx.spec.command_path.join(' '),
1178
- data: {
1179
- app,
1180
- project_dir: projectDir,
1181
- linked: Boolean(projectDir),
1182
- idempotency_key: idempotencyKey,
1183
- },
1184
- humanSummary: projectDir
1185
- ? `Created app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
1186
- : `Created app ${app.name || ctx.args.name} (${app.id})`,
1187
- hints: projectDir
1188
- ? [{ command: `cd ${projectDir} && notis apps deploy .`, reason: 'Deploy the linked project' }]
1189
- : [{ command: `notis apps link ${app.id} .`, reason: 'Link a local project before deploying' }],
1190
- meta: { mutating: true, idempotency_key: idempotencyKey },
1191
- });
1192
- }
1193
-
1194
- async function appsDevHandler(ctx) {
1195
- const rootDir = resolveProjectDir(ctx.args.dir || '.');
1196
- const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
1197
- let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
1198
- const discoveredAppDirs = [...appDirs];
1199
- const sessionsFilePath = getAppDevSessionsFile();
1200
- let sharedBundleBaseUrls = null;
1201
- const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
1202
- if (sharedBundlesRaw) {
1203
- let parsed;
1204
- try {
1205
- parsed = JSON.parse(sharedBundlesRaw);
1206
- } catch {
1207
- throw usageError('The shared app development bundle map is invalid.');
1208
- }
1209
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1210
- throw usageError('The shared app development bundle map must be an object.');
1211
- }
1212
- const sharedProjectDirs = Object.keys(parsed);
1213
- const discoveredProjects = new Set(appDirs);
1214
- for (const projectDir of sharedProjectDirs) {
1215
- if (!discoveredProjects.has(projectDir)) {
1216
- throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
1217
- }
1218
- }
1219
- appDirs = sharedProjectDirs;
1220
- sharedBundleBaseUrls = new Map();
1221
- for (const projectDir of appDirs) {
1222
- const value = parsed[projectDir];
1223
- let url;
1224
- try {
1225
- url = new URL(String(value || ''));
1226
- } catch {
1227
- throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
1228
- }
1229
- if (
1230
- url.protocol !== 'http:'
1231
- || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
1232
- ) {
1233
- throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
1234
- }
1235
- sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
1236
- }
1237
- }
1238
-
1239
- if (appDirs.length === 0) {
1240
- return ctx.output.emitSuccess({
1241
- command: ctx.spec.command_path.join(' '),
1242
- data: {
1243
- registered_root: skipRootRegistration ? null : rootDir,
1244
- apps: [],
1245
- watching_on_desktop_launch: true,
1246
- },
1247
- humanSummary: [
1248
- skipRootRegistration
1249
- ? 'No registered Notis apps are present yet.'
1250
- : `Registered app development root: ${rootDir}`,
1251
- 'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
1252
- ].join('\n'),
1253
- hints: [
1254
- { command: 'notis apps roots list', reason: 'Show every persistent development root' },
1255
- ],
1256
- meta: { mutating: true },
1257
- });
1258
- }
1259
-
1260
- if (!sharedBundleBaseUrls) {
1261
- sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1262
- }
1263
- let sourceHostLock = null;
1264
- if (
1265
- !sharedBundleBaseUrls
1266
- && process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
1267
- ) {
1268
- sourceHostLock = tryAcquireAppDevHostLock({
1269
- identity: '__mac_user__',
1270
- apiBase: 'loopback-source',
1271
- projectDir: SHARED_APP_DEV_HOST_KEY,
1272
- });
1273
- if (!sourceHostLock) {
1274
- const startedAt = Date.now();
1275
- while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
1276
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
1277
- sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
1278
- }
1279
- if (!sharedBundleBaseUrls) {
1280
- throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
1281
- }
1282
- }
1283
- }
1284
-
1285
- for (const projectDir of appDirs) {
1286
- const problems = detectProjectProblems(projectDir);
1287
- if (problems.length) {
1288
- throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1289
- }
1290
- }
1291
-
1292
- let port = null;
1293
- if (sharedBundleBaseUrls) {
1294
- // The source host owns the only loopback listener and build watchers.
1295
- } else if (ctx.options.port) {
1296
- port = Number.parseInt(ctx.options.port, 10);
1297
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1298
- throw usageError('Port must be between 1 and 65535.');
1299
- }
1300
- } else {
1301
- // Concurrent sessions must not fight over the default port: the desktop
1302
- // auto-starts every known project at launch, so only the first one can
1303
- // have 5173 and the rest fall back to an ephemeral port.
1304
- port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
1305
- }
1306
-
1307
- const mode = getCliMode();
1308
- const identity = decodeJwtSub(ctx.runtime.jwt);
1309
- if (!identity) {
1310
- throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
1311
- }
1312
- const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
1313
- const sessionId = randomUUID();
1314
- const profileKey = linkedStateProfileKey(ctx.runtime);
1315
- const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
1316
- const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
1317
- ? `cli.${process.pid}.${sessionId}`
1318
- : null;
1319
- let manualConsumerTimer = null;
1320
- if (manualConsumerInstanceId) {
1321
- const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
1322
- instanceId: manualConsumerInstanceId,
1323
- userId: identity,
1324
- apiBase,
1325
- pid: process.pid,
1326
- });
1327
- heartbeatManualConsumer();
1328
- manualConsumerTimer = setInterval(() => {
1329
- try {
1330
- heartbeatManualConsumer();
1331
- } catch (error) {
1332
- const message = error instanceof Error ? error.message : String(error);
1333
- process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
1334
- }
1335
- }, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
1336
- }
1337
- // One app, one dataset: a dev session runs the app from local source against
1338
- // the same databases the installed app uses, the way `npm run dev` serves the
1339
- // same database as the deployed site. `--scratch` is the marked case; the
1340
- // old `--live-data` flag now names the default and is kept as a no-op so
1341
- // muscle memory and scripts keep working.
1342
- if (ctx.options?.scratch && ctx.options?.liveData) {
1343
- throw usageError('--scratch and --live-data contradict each other; pass at most one.');
1344
- }
1345
- const useInstalledDatabases = !ctx.options?.scratch;
1346
-
1347
- const candidates = [];
1348
- for (const projectDir of appDirs) {
1349
- const appConfig = await loadAppConfig(projectDir);
1350
- if (!appConfig.name) {
1351
- throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
1352
- }
1353
- const devSlug = buildDevInstallSlug(appConfig);
1354
- if (!devSlug) {
1355
- throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
1356
- }
1357
- candidates.push({ appConfig, devSlug, projectDir });
1358
- }
1359
-
1360
- const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
1361
- const canonicalCandidates = [];
1362
- for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
1363
- // Sequential on purpose: the shell-consent prompt must never interleave
1364
- // with another app's, and the ensure fan-out below runs in parallel.
1365
- const approvedCapabilities = await resolveCloudShellConsent({
1366
- appConfig,
1367
- projectDir,
1368
- grantCloudShell: Boolean(ctx.options?.grantCloudShell),
1369
- profileKey,
1370
- });
1371
- canonicalCandidates.push({
1372
- appConfig,
1373
- devSlug,
1374
- projectDir,
1375
- approvedCapabilities,
1376
- mountNonce: randomUUID(),
1377
- });
1378
- }
1379
-
1380
- // Start every build watcher before the backend registrations finish. A
1381
- // machine with several apps must not show an empty sidebar for minutes while
1382
- // unrelated registrations run sequentially. Each successful registration is
1383
- // published below as soon as its own last-known bundle is ready.
1384
- const devServer = sharedBundleBaseUrls
1385
- ? {
1386
- close: async () => {},
1387
- isBundleReady: () => true,
1388
- updateApp: () => {},
1389
- waitForBundle: async () => {},
1390
- getWatcherOwnership: () => null,
1391
- }
1392
- : await startAppDevServer({
1393
- apps: canonicalCandidates.map((app) => ({
1394
- slug: app.devSlug,
1395
- projectDir: app.projectDir,
1396
- appId: null,
1397
- targetAppId: null,
1398
- userId: identity,
1399
- profileKey,
1400
- sessionId,
1401
- mountNonce: app.mountNonce,
1402
- })),
1403
- port,
1404
- sessionsFilePath,
1405
- });
1406
- const desktopHostOwnership = captureDesktopHostOwnership({
1407
- desktopOwnerId: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID,
1408
- desktopOwnerScope: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE,
1409
- });
1410
-
1411
- let heartbeatTimer = null;
1412
- let consumerTimer = null;
1413
- let shuttingDown = false;
1414
- const shutdown = async (signal) => {
1415
- if (shuttingDown) return;
1416
- shuttingDown = true;
1417
- process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
1418
- if (heartbeatTimer) {
1419
- clearInterval(heartbeatTimer);
1420
- heartbeatTimer = null;
1421
- }
1422
- if (consumerTimer) {
1423
- clearInterval(consumerTimer);
1424
- consumerTimer = null;
1425
- }
1426
- if (manualConsumerTimer) {
1427
- clearInterval(manualConsumerTimer);
1428
- manualConsumerTimer = null;
1429
- }
1430
- if (manualConsumerInstanceId) {
1431
- try {
1432
- removeAppDevConsumer(manualConsumerInstanceId);
1433
- } catch {
1434
- // A crashed CLI lease expires automatically after the heartbeat window.
1435
- }
1436
- }
1437
- try {
1438
- await devServer.close();
1439
- } catch {
1440
- // ignore cleanup failures during shutdown
1441
- }
1442
- // Keep ownership records until every watcher group has stopped. If the
1443
- // Desktop must force this host down, the next launch can still recover a
1444
- // verified orphan instead of losing its only ownership proof.
1445
- try {
1446
- removeAppDevSession(sessionId, sessionsFilePath);
1447
- } catch {
1448
- // ignore cleanup failures during shutdown
1449
- }
1450
- if (sourceHostLock) {
1451
- releaseAppDevHostLock(sourceHostLock);
1452
- sourceHostLock = null;
1453
- }
1454
- process.exit(EXIT_CODES.ok);
1455
- };
1456
- const handleSigint = () => {
1457
- void shutdown('SIGINT');
1458
- };
1459
- const handleSigterm = () => {
1460
- void shutdown('SIGTERM');
1461
- };
1462
-
1463
- // Electron can stop a partially registered host. Install cleanup before the
1464
- // first remote registration so every watcher group is still terminated when
1465
- // registration is slow or stuck.
1466
- process.on('SIGINT', handleSigint);
1467
- process.on('SIGTERM', handleSigterm);
1468
-
1469
- heartbeatTimer = setInterval(() => {
1470
- try {
1471
- heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
1472
- } catch (error) {
1473
- const message = error instanceof Error ? error.message : String(error);
1474
- process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
1475
- }
1476
- }, DEV_HEARTBEAT_INTERVAL_MS);
1477
-
1478
- const registrationStartedAt = process.hrtime.bigint();
1479
- const apps = [];
1480
- const registrationWarnings = [];
1481
- let firstRegistrationError = null;
1482
- // The local development backend is deliberately a single worker. Register
1483
- // apps sequentially so a large root cannot queue many long mutations behind
1484
- // one another. Unlike the old all-or-nothing startup, publish each healthy
1485
- // app immediately while later registrations continue in the background.
1486
- for (const {
1487
- appConfig,
1488
- devSlug,
1489
- projectDir,
1490
- approvedCapabilities,
1491
- mountNonce,
1492
- } of canonicalCandidates) {
1493
- const appStartedAt = process.hrtime.bigint();
1494
- try {
1495
- const registeredApp = await ensureDevInstall({
1496
- ctx,
1497
- appConfig,
1498
- projectDir,
1499
- idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
1500
- useInstalledDatabases,
1501
- approvedCapabilities,
1502
- });
1503
- const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
1504
- || `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
1505
- const app = {
1506
- ...registeredApp,
1507
- bundleBaseUrl,
1508
- mountNonce,
1509
- appHref: buildDevelopmentAppHref({
1510
- appSlug: registeredApp.slug,
1511
- appId: registeredApp.appId,
1512
- devSlug: registeredApp.devSlug,
1513
- targetAppId: registeredApp.targetAppId,
1514
- targetAppSlug: registeredApp.targetAppSlug,
1515
- manifest: registeredApp.manifest,
1516
- }),
1517
- };
1518
- devServer.updateApp(app.devSlug, {
1519
- appId: app.appId,
1520
- targetAppId: app.targetAppId || null,
1521
- });
1522
- const now = new Date().toISOString();
1523
- const sessionRecord = {
1524
- sessionId,
1525
- hostPid: process.pid,
1526
- sourceHost: !sharedBundleBaseUrls,
1527
- ...(desktopHostOwnership || {}),
1528
- ...(devServer.getWatcherOwnership(app.devSlug) || {}),
1529
- bundleReady: devServer.isBundleReady(app.devSlug),
1530
- ...(!sharedBundleBaseUrls ? {
1531
- discoveredProjects: discoveredAppDirs,
1532
- canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
1533
- } : {}),
1534
- userId: identity,
1535
- apiBase,
1536
- profileKey,
1537
- appId: app.appId,
1538
- targetAppId: app.targetAppId || undefined,
1539
- mountNonce: app.mountNonce,
1540
- devSlug: app.devSlug,
1541
- bundleBaseUrl: app.bundleBaseUrl,
1542
- projectDir: app.projectDir,
1543
- startedAt: now,
1544
- lastHeartbeatAt: now,
1545
- };
1546
- upsertAppDevSessions(sessionRecord, sessionsFilePath);
1547
- apps.push(app);
1548
- if (!sessionRecord.bundleReady) {
1549
- void devServer.waitForBundle(app.devSlug).then(() => {
1550
- upsertAppDevSessions({
1551
- ...sessionRecord,
1552
- bundleReady: true,
1553
- lastHeartbeatAt: new Date().toISOString(),
1554
- }, sessionsFilePath);
1555
- }).catch((error) => {
1556
- const message = error instanceof Error ? error.message : String(error);
1557
- process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
1558
- });
1559
- }
1560
- } catch (error) {
1561
- firstRegistrationError ||= error;
1562
- const message = error instanceof Error ? error.message : String(error);
1563
- registrationWarnings.push(
1564
- `${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
1565
- );
1566
- process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
1567
- } finally {
1568
- logAppsTiming('ensure-dev-install', {
1569
- slug: devSlug,
1570
- ms: timingMs(appStartedAt).toFixed(1),
1571
- });
1572
- }
1573
- }
1574
- if (apps.length === 0) {
1575
- process.off('SIGINT', handleSigint);
1576
- process.off('SIGTERM', handleSigterm);
1577
- clearInterval(heartbeatTimer);
1578
- heartbeatTimer = null;
1579
- try {
1580
- await devServer.close();
1581
- } catch {
1582
- // preserve the original registration error
1583
- }
1584
- if (sourceHostLock) {
1585
- releaseAppDevHostLock(sourceHostLock);
1586
- sourceHostLock = null;
1587
- }
1588
- if (manualConsumerTimer) clearInterval(manualConsumerTimer);
1589
- if (manualConsumerInstanceId) {
1590
- try {
1591
- removeAppDevConsumer(manualConsumerInstanceId);
1592
- } catch {
1593
- // Preserve the registration error; a failed lease cleanup expires.
1594
- }
1595
- }
1596
- throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
1597
- }
1598
- logAppsTiming('ensure-dev-install:all', {
1599
- apps: apps.length,
1600
- ms: timingMs(registrationStartedAt).toFixed(1),
1601
- });
1602
-
1603
- const warnings = [
1604
- ...canonicalSelection.warnings,
1605
- ...registrationWarnings,
1606
- ...databaseMaterializationWarnings(apps),
1607
- ...liveDataWarnings(apps),
1608
- ...versionPrecedenceWarnings(apps),
1609
- ];
1610
-
1611
- ctx.output.emitSuccess({
1612
- command: ctx.spec.command_path.join(' '),
1613
- data: {
1614
- mode,
1615
- api_base: apiBase,
1616
- session_id: sessionId,
1617
- mount_status: 'serving',
1618
- source_host: !sharedBundleBaseUrls,
1619
- identity,
1620
- apps: apps.map((app) => ({
1621
- slug: app.devSlug,
1622
- app_id: app.appId,
1623
- target_app_id: app.targetAppId,
1624
- name: app.name,
1625
- project_dir: app.projectDir,
1626
- bundle_base_url: app.bundleBaseUrl,
1627
- app_href: app.appHref,
1628
- created: app.created,
1629
- linked_app_id: app.linkedAppId,
1630
- database_materialization: app.databaseMaterialization,
1631
- live_data: app.liveData,
1632
- local_release_version: app.localReleaseVersion,
1633
- installed_release_version: app.installedReleaseVersion,
1634
- mount_eligible: app.mountEligible,
1635
- })),
1636
- },
1637
- warnings,
1638
- humanSummary: [
1639
- `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
1640
- ...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
1641
- `Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
1642
- ...(useInstalledDatabases
1643
- ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1644
- : []),
1645
- '',
1646
- ...apps.map((app) => (
1647
- app.mountEligible
1648
- ? ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`
1649
- : ` ${app.name.padEnd(24)} online v${app.installedReleaseVersion} (local ${app.localReleaseVersion || 'version missing'})`
1650
- )),
1651
- '',
1652
- sharedBundleBaseUrls
1653
- ? `Attached to the shared source host: ${apps.filter((app) => app.mountEligible).length}/${apps.length} app${apps.length === 1 ? '' : 's'} eligible to substitute.`
1654
- : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}; ${apps.filter((app) => app.mountEligible).length} eligible to substitute.`,
1655
- '',
1656
- 'Press Ctrl-C to stop.',
1657
- ].join('\n'),
1658
- });
1659
-
1660
- if (consumerMode === 'machine' || consumerMode === 'environment') {
1661
- consumerTimer = setInterval(() => {
1662
- if (!hasAppDevConsumer(readAppDevConsumers(), {
1663
- mode: consumerMode,
1664
- userId: identity,
1665
- apiBase,
1666
- })) {
1667
- void shutdown('last consumer detached');
1668
- }
1669
- }, DEV_CONSUMER_POLL_INTERVAL_MS);
1670
- }
1671
-
1672
- await new Promise(() => {});
1673
-
1674
- return EXIT_CODES.ok;
1675
- }
1676
-
1677
- async function appsRootsListHandler(ctx) {
1678
- const registry = readAppDevRoots();
1679
- return ctx.output.emitSuccess({
1680
- command: ctx.spec.command_path.join(' '),
1681
- data: { roots: registry.roots },
1682
- humanSummary: appDevRootsTable(registry.roots),
1683
- meta: { mutating: false },
1684
- });
1685
- }
1686
-
1687
- async function appsRootsRemoveHandler(ctx) {
1688
- const rootDir = resolve(ctx.args.dir);
1689
- const result = removeAppDevRoot(rootDir);
1690
- return ctx.output.emitSuccess({
1691
- command: ctx.spec.command_path.join(' '),
1692
- data: {
1693
- removed: result.removed,
1694
- root: result.path,
1695
- roots: result.registry.roots,
1696
- },
1697
- humanSummary: result.removed
1698
- ? `Removed app development root: ${result.path}`
1699
- : `App development root was not registered: ${result.path}`,
1700
- hints: result.removed
1701
- ? []
1702
- : [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
1703
- meta: { mutating: result.removed },
1704
- });
1705
- }
1706
-
1707
- async function appsBuildHandler(ctx) {
1708
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
1709
- const problems = detectProjectProblems(projectDir);
1710
- if (problems.length) {
1711
- throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1712
- }
1713
-
1714
- const { manifest } = await buildArtifact(projectDir, {
1715
- stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
1716
- });
1717
-
1718
- return ctx.output.emitSuccess({
1719
- command: ctx.spec.command_path.join(' '),
1720
- data: { manifest },
1721
- humanSummary: `Built ${manifest.routes.length} routes into .notis/output/`,
1722
- });
1723
- }
1724
-
1725
- async function appsVerifyHandler(ctx) {
1726
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
1727
- const problems = detectProjectProblems(projectDir);
1728
- if (problems.length) {
1729
- throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1730
- }
1731
-
1732
- const mode = ctx.options.mode || 'stub';
1733
- if (!['stub', 'live'].includes(mode)) {
1734
- throw usageError('--mode must be either "stub" or "live".');
1735
- }
1736
-
1737
- let linkedState = null;
1738
- if (mode === 'live') {
1739
- if (
1740
- ctx.runtime.credentialKind === 'oauth'
1741
- && !await ensureFreshOAuthCredential(ctx.runtime)
1742
- ) {
1743
- throw usageError('Live verify mode requires a current OAuth grant. Run `notis login` and retry.');
1744
- }
1745
- if (!ctx.runtime.jwt) {
1746
- throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
1747
- }
1748
- linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1749
- if (!linkedState?.app_id) {
1750
- throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
1751
- }
1752
- }
1753
-
1754
- if (!ctx.options.skipBuild) {
1755
- await buildArtifact(projectDir, {
1756
- stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
1757
- });
1758
- }
1759
-
1760
- const manifest = readManifest(projectDir);
1761
- const appConfig = await loadAppConfig(projectDir);
1762
- const listing = inspectListingReadiness(projectDir, appConfig);
1763
- // Store readiness is a publish concern, not a render concern. Verify reports
1764
- // it so the gaps stay visible while the app is still being built; only
1765
- // --listing (and `apps publish`) turn it back into a hard gate.
1766
- if (listing.errors.length && ctx.options.listing === true) {
1767
- throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
1768
- }
1769
- const listingWarnings = [
1770
- ...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
1771
- ...findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig)),
1772
- ];
1773
- const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
1774
- const port = parsePort(ctx.options.port) || await getAvailablePort();
1775
- const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
1776
- const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
1777
- const browserSessionName = `notis-verify-${process.pid}`;
1778
- const noBrowser = ctx.options.browser === false;
1779
- const keepOpen = Boolean(ctx.options.keepOpen);
1780
- let devServer = null;
1781
- let browserTouched = false;
1782
-
1783
- try {
1784
- devServer = await startAppDevServer({
1785
- apps: [{
1786
- slug: appSlug,
1787
- projectDir,
1788
- appId: linkedState?.app_id || 'harness-app',
1789
- }],
1790
- port,
1791
- watch: false,
1792
- harness: {
1793
- mode,
1794
- apiBase: ctx.runtime.apiBase,
1795
- jwt: mode === 'live' ? ctx.runtime.jwt : null,
1796
- },
1797
- log: () => {},
1798
- logError: (message) => process.stderr.write(`${message}\n`),
1799
- });
1800
-
1801
- const urls = routes.map((route) => ({
1802
- route,
1803
- url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
1804
- }));
1805
-
1806
- let results;
1807
- const warnings = [...listingWarnings];
1808
- if (noBrowser) {
1809
- results = urls.map(({ route, url }) => ({
1810
- route: route.slug,
1811
- path: route.path,
1812
- url,
1813
- ok: true,
1814
- status: 'manual',
1815
- mounted: null,
1816
- errors: [],
1817
- runtimeCalls: [],
1818
- assertions: [],
1819
- snapshot_path: null,
1820
- tool_error: null,
1821
- }));
1822
- } else if (!isAgentBrowserAvailable()) {
1823
- warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
1824
- results = urls.map(({ route, url }) => {
1825
- const toolError = {
1826
- phase: 'available',
1827
- message: 'agent-browser is not available on PATH',
1828
- };
1829
- const result = {
1830
- route: route.slug,
1831
- path: route.path,
1832
- url,
1833
- mounted: false,
1834
- renderStarted: false,
1835
- errors: [],
1836
- runtimeCalls: [],
1837
- snapshotPath: null,
1838
- tool_error: toolError,
1839
- };
1840
- const assertions = assertHarnessResult(
1841
- result,
1842
- route,
1843
- declaredDatabaseSlugs(appConfig, manifest, route),
1844
- mode,
1845
- );
1846
- return {
1847
- ...result,
1848
- ok: false,
1849
- status: 'failed',
1850
- assertions,
1851
- snapshot_path: null,
1852
- };
1853
- });
1854
- } else {
1855
- browserTouched = true;
1856
- results = [];
1857
- for (const { route, url } of urls) {
1858
- const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
1859
- const result = await runHarnessRoute({
1860
- url,
1861
- sessionName: browserSessionName,
1862
- timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
1863
- snapshotPath,
1864
- });
1865
- const assertions = assertHarnessResult(
1866
- result,
1867
- route,
1868
- declaredDatabaseSlugs(appConfig, manifest, route),
1869
- mode,
1870
- );
1871
- results.push({
1872
- route: route.slug,
1873
- path: route.path,
1874
- url,
1875
- ok: assertions.length === 0,
1876
- status: assertions.length === 0 ? 'passed' : 'failed',
1877
- mounted: result.mounted,
1878
- renderStarted: result.renderStarted,
1879
- errors: result.errors,
1880
- runtimeCalls: result.runtimeCalls,
1881
- assertions,
1882
- snapshot_path: result.snapshotPath,
1883
- timed_out: Boolean(result.timed_out),
1884
- tool_error: result.tool_error,
1885
- });
1886
- }
1887
- }
1888
-
1889
- const summary = {
1890
- total: results.length,
1891
- passed: results.filter((result) => result.status === 'passed').length,
1892
- failed: results.filter((result) => result.status === 'failed').length,
1893
- manual: results.filter((result) => result.status === 'manual').length,
1894
- };
1895
- const overallOk = summary.failed === 0;
1896
- const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
1897
- const data = {
1898
- status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
1899
- project_dir: projectDir,
1900
- app_slug: appSlug,
1901
- mode,
1902
- browser_session: noBrowser ? null : browserSessionName,
1903
- server: {
1904
- port,
1905
- base_url: baseUrl,
1906
- urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
1907
- },
1908
- summary,
1909
- results,
1910
- listing: {
1911
- ready: listing.ready,
1912
- gated: ctx.options.listing === true,
1913
- problems: listing.errors,
1914
- },
1915
- };
1916
-
1917
- ctx.output.emitSuccess({
1918
- ok: overallOk,
1919
- command: ctx.spec.command_path.join(' '),
1920
- data,
1921
- humanSummary: overallOk
1922
- ? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
1923
- : `Verification failed for ${summary.failed} routes.`,
1924
- warnings,
1925
- renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
1926
- });
1927
-
1928
- if (keepOpen) {
1929
- process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
1930
- await new Promise(() => {});
1931
- }
1932
-
1933
- return exitCode;
1934
- } finally {
1935
- if (!keepOpen) {
1936
- if (browserTouched) {
1937
- try {
1938
- await closeAgentBrowserSession(browserSessionName);
1939
- } catch {
1940
- // Ignore browser cleanup failures.
1941
- }
1942
- }
1943
- if (devServer) {
1944
- try {
1945
- await devServer.close();
1946
- } catch {
1947
- // Ignore server cleanup failures.
1948
- }
1949
- }
1950
- }
1951
- }
1952
- }
1953
-
1954
- async function appsScreenshotHandler(ctx) {
1955
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
1956
- const problems = detectProjectProblems(projectDir);
1957
- if (problems.length) {
1958
- throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1959
- }
1960
-
1961
- if (!isAgentBrowserAvailable()) {
1962
- throw usageError('agent-browser is not available on PATH. It ships with the Notis desktop app; open it once, then retry.');
1963
- }
1964
-
1965
- const mode = ctx.options.mode || 'stub';
1966
- if (!['stub', 'live'].includes(mode)) {
1967
- throw usageError('--mode must be either "stub" or "live".');
1968
- }
1969
-
1970
- let linkedState = null;
1971
- if (mode === 'live') {
1972
- if (
1973
- ctx.runtime.credentialKind === 'oauth'
1974
- && !await ensureFreshOAuthCredential(ctx.runtime)
1975
- ) {
1976
- throw usageError('Live mode requires a current OAuth grant. Run `notis login` and retry.');
1977
- }
1978
- if (!ctx.runtime.jwt) {
1979
- throw usageError('Live mode requires CLI auth. Run notis login and retry.');
1980
- }
1981
- linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1982
- if (!linkedState?.app_id) {
1983
- throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1984
- }
1985
- }
1986
-
1987
- if (!ctx.options.skipBuild) {
1988
- await buildArtifact(projectDir, {
1989
- stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
1990
- });
1991
- }
1992
-
1993
- const manifest = readManifest(projectDir);
1994
- const appConfig = await loadAppConfig(projectDir);
1995
- const selectedRouteSlugs = parseRouteSlugs(ctx.options.routes);
1996
- const routes = routeSelection(manifest, selectedRouteSlugs);
1997
- const screenshotSlots = screenshotIndexByRouteSlug(manifest);
1998
- if (!routes.length) {
1999
- throw usageError('No routes to screenshot.');
2000
- }
2001
-
2002
- const width = parsePositiveInt(ctx.options.width) || 2000;
2003
- const height = parsePositiveInt(ctx.options.height) || 1250;
2004
- const outputDir = ctx.options.outputDir
2005
- ? resolveProjectDir(ctx.options.outputDir)
2006
- : join(projectDir, 'metadata');
2007
-
2008
- const port = parsePort(ctx.options.port) || await getAvailablePort();
2009
- const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
2010
- const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
2011
- const browserSessionName = `notis-screenshot-${process.pid}`;
2012
- const browserSessionNames = [];
2013
- const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
2014
- .filter((screenshot) => screenshot.route)
2015
- .filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
2016
- const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
2017
- const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
2018
- const captures = configuredScreenshots.length > 0
2019
- ? configuredScreenshots.map((screenshot) => {
2020
- const route = routeBySlug.get(screenshot.route);
2021
- if (!route) {
2022
- throw usageError(
2023
- `Screenshot ${screenshot.path} references unavailable route slug "${screenshot.route}".`,
2024
- { available_routes: routes.map((entry) => entry.slug) },
2025
- );
2026
- }
2027
- return {
2028
- route,
2029
- scenario: screenshot.scenario,
2030
- focus: screenshot.focus,
2031
- theme: screenshot.theme || 'light',
2032
- fileName: basename(screenshot.path),
2033
- };
2034
- })
2035
- : routes.map((route, index) => ({
2036
- route,
2037
- scenario: null,
2038
- focus: null,
2039
- theme: 'light',
2040
- fileName: `screenshot-${screenshotSlots.get(route.slug) || index + 1}.png`,
2041
- }));
2042
- const rawOutputDir = ctx.options.raw
2043
- ? null
2044
- : mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
2045
- let devServer = null;
2046
- let browserTouched = false;
2047
-
2048
- try {
2049
- devServer = await startAppDevServer({
2050
- apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
2051
- port,
2052
- watch: false,
2053
- harness: {
2054
- mode,
2055
- apiBase: ctx.runtime.apiBase,
2056
- jwt: mode === 'live' ? ctx.runtime.jwt : null,
2057
- },
2058
- log: () => {},
2059
- logError: (message) => process.stderr.write(`${message}\n`),
2060
- });
2061
- browserTouched = true;
2062
-
2063
- mkdirSync(outputDir, { recursive: true });
2064
- const results = [];
2065
- for (const capture of captures) {
2066
- const { route, scenario, focus, theme, fileName } = capture;
2067
- const screenshotPath = join(outputDir, fileName);
2068
- const browserScreenshotPath = rawOutputDir
2069
- ? join(rawOutputDir, fileName)
2070
- : screenshotPath;
2071
- const scenarioParam = scenario ? `&scenario=${encodeURIComponent(scenario)}` : '';
2072
- const themeParam = `&theme=${encodeURIComponent(theme || 'light')}`;
2073
- // Keep scenario captures on independent pages. Chromium can otherwise
2074
- // reuse stale compositor layers when the next screenshot changes the
2075
- // same app route into a substantially different state.
2076
- const captureSessionName = `${browserSessionName}-${results.length + 1}`;
2077
- browserSessionNames.push(captureSessionName);
2078
- let result = await captureHarnessScreenshot({
2079
- url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}${scenarioParam}${themeParam}`,
2080
- sessionName: captureSessionName,
2081
- screenshotPath: browserScreenshotPath,
2082
- focusSelector: ctx.options.raw ? null : focus,
2083
- width,
2084
- height,
2085
- timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
2086
- });
2087
- let presentation = { mode: 'raw' };
2088
- if (result.ok && rawOutputDir) {
2089
- try {
2090
- presentation = await composeStoreScreenshot({
2091
- inputPath: browserScreenshotPath,
2092
- outputPath: screenshotPath,
2093
- width,
2094
- height,
2095
- accent: appConfig.accent,
2096
- seed: appConfig.name || manifest.app?.name || appSlug,
2097
- focused: Boolean(focus),
2098
- theme: theme || 'light',
2099
- });
2100
- } catch (error) {
2101
- result = {
2102
- ...result,
2103
- ok: false,
2104
- screenshotPath: null,
2105
- tool_error: {
2106
- phase: 'compose',
2107
- message: error instanceof Error ? error.message : String(error),
2108
- },
2109
- };
2110
- }
2111
- }
2112
- results.push({
2113
- route: route.slug,
2114
- path: route.path,
2115
- file: relative(projectDir, screenshotPath),
2116
- ok: result.ok,
2117
- errors: result.errors || [],
2118
- timed_out: Boolean(result.timed_out),
2119
- tool_error: result.tool_error,
2120
- framing: result.framing || null,
2121
- theme: theme || 'light',
2122
- presentation,
2123
- });
2124
- }
2125
-
2126
- const captured = results.filter((r) => r.ok);
2127
- const failed = results.filter((r) => !r.ok);
2128
- const warnings = [...scenarioWarnings];
2129
-
2130
- // Drop stale screenshots only after a full refresh. A selected-route
2131
- // capture intentionally leaves other listing screenshots untouched.
2132
- if (shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failed.length)) {
2133
- pruneStaleScreenshotFiles(outputDir, captures.length);
2134
- }
2135
- if (failed.length) {
2136
- warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
2137
- }
2138
-
2139
- ctx.output.emitSuccess({
2140
- ok: failed.length === 0,
2141
- command: ctx.spec.command_path.join(' '),
2142
- data: {
2143
- project_dir: projectDir,
2144
- output_dir: outputDir,
2145
- mode,
2146
- presentation: ctx.options.raw ? 'raw' : 'framed',
2147
- viewport: { width, height },
2148
- summary: { total: results.length, captured: captured.length, failed: failed.length },
2149
- results,
2150
- },
2151
- humanSummary: failed.length === 0
2152
- ? `Captured ${captured.length} screenshot(s) to ${relative(projectDir, outputDir) || 'metadata'}/.`
2153
- : `Captured ${captured.length}/${results.length} screenshots; ${failed.length} failed.`,
2154
- warnings,
2155
- });
2156
- return screenshotExitCode(failed.length);
2157
- } finally {
2158
- if (browserTouched) {
2159
- await Promise.all(browserSessionNames.map(async (sessionName) => {
2160
- try {
2161
- await closeAgentBrowserSession(sessionName);
2162
- } catch {
2163
- // Ignore browser cleanup failures.
2164
- }
2165
- }));
2166
- }
2167
- if (devServer) {
2168
- try {
2169
- await devServer.close();
2170
- } catch {
2171
- // Ignore server cleanup failures.
2172
- }
2173
- }
2174
- if (rawOutputDir) {
2175
- rmSync(rawOutputDir, { recursive: true, force: true });
2176
- }
2177
- }
2178
- }
2179
-
2180
- export function buildLinkedAppState(
2181
- existingState,
2182
- appId,
2183
- linkedAt = new Date().toISOString(),
2184
- ) {
2185
- const sameIdPromotion = existingState?.dev_app_id === appId;
2186
- return {
2187
- ...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
2188
- ...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
2189
- app_id: appId,
2190
- linked_at: linkedAt,
2191
- };
2192
- }
2193
-
2194
- async function appsLinkHandler(ctx) {
2195
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
2196
- const appId = ctx.args.appId;
2197
-
2198
- await assertLinkTarget(ctx.runtime, appId);
2199
-
2200
- writeLinkedState(
2201
- projectDir,
2202
- buildLinkedAppState(
2203
- readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
2204
- appId,
2205
- new Date().toISOString(),
2206
- ),
2207
- linkedStateProfileKey(ctx.runtime),
2208
- );
2209
-
2210
- return ctx.output.emitSuccess({
2211
- command: ctx.spec.command_path.join(' '),
2212
- data: { app_id: appId, project_dir: projectDir },
2213
- humanSummary: `Linked to app ${appId}`,
2214
- hints: [
2215
- { command: 'notis apps deploy .', reason: 'Deploy the app' },
2216
- ],
2217
- });
2218
- }
2219
-
2220
- async function appsPullHandler(ctx) {
2221
- const appId = ctx.args.appId;
2222
- const result = await runToolCommand({
2223
- runtime: ctx.runtime,
2224
- // Pull is source retrieval plus local link state. LIST_APPS is deliberately
2225
- // non-materializing; GET_APP hydrates missing declared databases and would
2226
- // turn a read-only pull into a remote mutation before build/verification.
2227
- toolName: LIST_APPS_TOOL,
2228
- });
2229
- if (
2230
- ctx.runtime.credentialKind === 'oauth'
2231
- && !await ensureFreshOAuthCredential(ctx.runtime)
2232
- ) {
2233
- throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
2234
- }
2235
- const apps = Array.isArray(result.payload?.apps) ? result.payload.apps : [];
2236
- const app = apps.find((candidate) => (candidate?.app_id || candidate?.id) === appId);
2237
- if (!app) {
2238
- throw usageError(`App ${appId} is not accessible to the active profile.`);
2239
- }
2240
- const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
2241
- const targetDir = ctx.args.dir
2242
- ? resolveProjectDir(ctx.args.dir)
2243
- : defaultAppProjectDir(defaultDir);
2244
- const version = ctx.options.sourceVersion || 'latest';
2245
-
2246
- const pulled = await pullAppSource({
2247
- apiBase: ctx.runtime.apiBase,
2248
- jwt: ctx.runtime.jwt,
2249
- appId,
2250
- targetDir,
2251
- version,
2252
- force: Boolean(ctx.options.force),
2253
- profileKey: linkedStateProfileKey(ctx.runtime),
2254
- });
2255
-
2256
- const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
2257
- return ctx.output.emitSuccess({
2258
- command: ctx.spec.command_path.join(' '),
2259
- data: {
2260
- app_id: appId,
2261
- project_dir: pulled.projectDir,
2262
- version: pulled.version,
2263
- },
2264
- humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
2265
- });
2266
- }
2267
-
2268
- function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
2269
- if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
2270
- return;
2271
- }
2272
- writeLinkedState(projectDir, {
2273
- ...linkedState,
2274
- app_id: appId,
2275
- version,
2276
- linked_at: linkedState.linked_at || new Date().toISOString(),
2277
- deployed_at: new Date().toISOString(),
2278
- }, profileKey);
2279
- }
2280
-
2281
- // Resolve first deploy without mutating. Promotion is part of the final
2282
- // SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
2283
- // cannot leave an empty installed app behind.
2284
- function resolveDeployTarget(ctx, projectDir) {
2285
- if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
2286
- const profileKey = linkedStateProfileKey(ctx.runtime);
2287
- const state = readLinkedState(projectDir, profileKey);
2288
- if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
2289
- if (!state?.dev_app_id) {
2290
- return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
2291
- }
2292
- return { appId: state.dev_app_id, needsPromotion: true };
2293
- }
2294
-
2295
- async function appsDeployHandler(ctx) {
2296
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
2297
- const profileKey = linkedStateProfileKey(ctx.runtime);
2298
- const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
2299
- const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
2300
- const linkedState = readLinkedState(projectDir, profileKey);
2301
- const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
2302
- ? linkedState.version
2303
- : undefined;
2304
-
2305
- // Build if needed
2306
- if (!ctx.options.skipBuild) {
2307
- await buildArtifact(projectDir, {
2308
- stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
2309
- });
2310
- }
2311
-
2312
- // Direct deploy mode: upload to Supabase storage directly
2313
- if (ctx.options.direct) {
2314
- if (needsPromotion) {
2315
- throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
2316
- }
2317
- await assertDirectDeployAccess(ctx.runtime, appId);
2318
- const { version } = await directDeploy(projectDir, appId);
2319
- updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
2320
- return ctx.output.emitSuccess({
2321
- command: ctx.spec.command_path.join(' '),
2322
- data: { app_id: appId, version, mode: 'direct' },
2323
- humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
2324
- meta: { mutating: true },
2325
- });
2326
- }
2327
-
2328
- // Standard deploy via backend server, with auto-fallback to direct
2329
- const files = collectArtifactFiles(projectDir);
2330
- const sourceFiles = collectSourceFiles(projectDir);
2331
- const manifest = readManifest(projectDir);
2332
-
2333
- let result;
2334
- try {
2335
- result = await runToolCommand({
2336
- // App deploys upload both the built artifact and the editable source
2337
- // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
2338
- // and timing out a mutation is ambiguous: the backend may commit after
2339
- // the client disconnects. Give this operation its real completion window.
2340
- runtime: {
2341
- ...ctx.runtime,
2342
- timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
2343
- },
2344
- toolName: SAVE_APP_FILES_TOOL,
2345
- arguments_: {
2346
- app_id: appId,
2347
- files,
2348
- source_files: sourceFiles,
2349
- manifest,
2350
- ...appRowFieldsFromManifest(manifest),
2351
- ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
2352
- ...(needsPromotion ? { promote_dev_app: true } : {}),
2353
- },
2354
- mutating: true,
2355
- idempotencyKey,
2356
- });
2357
- } catch (error) {
2358
- if (error.code === 'conflict') {
2359
- throw toolConflictToError(error.details, 'Deploy conflict');
2360
- }
2361
-
2362
- // A timed-out mutation may already have committed on the backend. A direct
2363
- // upload here would create a second revision, so fail closed and let the
2364
- // caller inspect/pull the app before deciding whether to retry.
2365
- if (error.code === 'network_timeout') {
2366
- 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.`;
2367
- throw error;
2368
- }
2369
-
2370
- // A network failure is ambiguous for a mutation. It can happen after the
2371
- // backend committed but before undici finished reading the response (for
2372
- // example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
2373
- // generic network_error or its message: an automatic direct upload could
2374
- // create a second revision. Operators who have proved the backend is down
2375
- // can still choose the explicit --direct mode.
2376
- if (error.code === 'network_error') {
2377
- 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.`;
2378
- throw error;
2379
- }
2380
-
2381
- throw error;
2382
- }
2383
-
2384
- const deployedVersion = Number(result?.payload?.version);
2385
- if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
2386
- throw new CliError({
2387
- code: 'network_error',
2388
- 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.',
2389
- exitCode: EXIT_CODES.network,
2390
- retryable: true,
2391
- });
2392
- }
2393
-
2394
- if (needsPromotion) {
2395
- const now = new Date().toISOString();
2396
- writeLinkedState(projectDir, {
2397
- ...linkedState,
2398
- app_id: appId,
2399
- linked_at: linkedState?.linked_at || now,
2400
- version: deployedVersion,
2401
- deployed_at: now,
2402
- dev_app_id: undefined,
2403
- dev_linked_at: undefined,
2404
- }, profileKey);
2405
- try {
2406
- linkAppDevSessionTarget(
2407
- { appId, targetAppId: appId },
2408
- getAppDevSessionsFile(),
2409
- );
2410
- } catch (error) {
2411
- ctx.output.emitProgress({
2412
- phase: 'warning',
2413
- message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
2414
- });
2415
- }
2416
- ctx.output.emitProgress({
2417
- phase: 'promote',
2418
- message: `Promoted development app to installed app ${appId}`,
2419
- });
2420
- } else {
2421
- updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
2422
- }
2423
- return ctx.output.emitSuccess({
2424
- command: ctx.spec.command_path.join(' '),
2425
- data: {
2426
- app_id: appId,
2427
- version: deployedVersion,
2428
- idempotency_key: idempotencyKey,
2429
- },
2430
- humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
2431
- meta: { mutating: true, idempotency_key: idempotencyKey },
2432
- });
2433
- }
2434
-
2435
- function deployedAppVersion(app) {
2436
- const value = app?.current_version ?? app?.manifest?.version;
2437
- const parsed = Number(value);
2438
- return Number.isFinite(parsed) ? parsed : 0;
2439
- }
2440
-
2441
- async function appsDuplicateHandler(ctx) {
2442
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
2443
- // Either target an app explicitly, or duplicate whatever this project is
2444
- // linked to, so `notis apps duplicate` works from inside a project.
2445
- const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
2446
- const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
2447
-
2448
- const copyDocuments = ctx.options.copyDocuments || 'declared';
2449
- if (!['declared', 'all', 'none'].includes(copyDocuments)) {
2450
- throw usageError("--copy-documents must be one of: declared, all, none.");
2451
- }
2452
-
2453
- const result = await runToolCommand({
2454
- runtime: ctx.runtime,
2455
- toolName: DUPLICATE_APP_TOOL,
2456
- arguments_: {
2457
- app_id: appId,
2458
- ...(ctx.options.name ? { name: ctx.options.name } : {}),
2459
- copy_documents: copyDocuments,
2460
- },
2461
- mutating: true,
2462
- idempotencyKey,
2463
- });
2464
-
2465
- const payload = result.payload || {};
2466
- if (payload.status === 'error') {
2467
- throw usageError(`Could not duplicate app ${appId}: ${payload.message || 'unknown error'}`);
2468
- }
2469
-
2470
- const duplicated = payload.app || {};
2471
- if (!duplicated.id) {
2472
- throw usageError(`Could not duplicate app ${appId}: the backend did not return an app id.`);
2473
- }
2474
-
2475
- const data = {
2476
- app_id: duplicated.id,
2477
- name: duplicated.name,
2478
- slug: duplicated.slug,
2479
- duplicated_from_app_id: payload.duplicated_from_app_id || appId,
2480
- copied_document_count: payload.copied_document_count ?? 0,
2481
- portal_url: payload.portal_url,
2482
- idempotency_key: idempotencyKey,
2483
- // The duplicate owns brand new databases; nothing is shared with the source.
2484
- databases: (payload.databases || []).map((database) => ({
2485
- id: database.id,
2486
- slug: database.slug,
2487
- name: database.name,
2488
- })),
2489
- };
2490
-
2491
- return ctx.output.emitSuccess({
2492
- command: ctx.spec.command_path.join(' '),
2493
- data,
2494
- humanSummary: `Duplicated app ${appId} as ${duplicated.name || duplicated.id}`,
2495
- hints: payload.portal_url
2496
- ? [{ command: payload.portal_url, reason: 'Open the duplicated app in Portal' }]
2497
- : [],
2498
- meta: { mutating: true, idempotency_key: idempotencyKey },
2499
- });
2500
- }
2501
-
2502
- async function appsPublishHandler(ctx) {
2503
- if (ctx.options.confirmReady !== true) {
2504
- throw usageError(
2505
- 'Store submission requires explicit user confirmation that App Details is ready. ' +
2506
- 'After confirmation, rerun with --confirm-ready.',
2507
- );
2508
- }
2509
-
2510
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
2511
- const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
2512
- const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
2513
- const appConfig = await loadAppConfig(projectDir);
2514
- const readiness = inspectListingReadiness(projectDir, appConfig);
2515
- if (!readiness.ready) {
2516
- throw usageError(
2517
- `Store listing is not ready:\n${readiness.errors.map((error) => ` - ${error}`).join('\n')}`,
2518
- );
2519
- }
2520
-
2521
- const detailResult = await runToolCommand({
2522
- runtime: ctx.runtime,
2523
- toolName: GET_APP_TOOL,
2524
- arguments_: { app_id: appId },
2525
- });
2526
- const detail = detailResult.payload || {};
2527
- const app = detail.app || {};
2528
- if (!app.id) {
2529
- throw usageError(`Could not load deployed app ${appId}.`);
2530
- }
2531
- if (!['team', 'public_store_hidden'].includes(app.visibility)) {
2532
- throw usageError('Set the app visibility to Team or Public before Store submission.');
2533
- }
2534
-
2535
- const remoteVersion = deployedAppVersion(app);
2536
- if (remoteVersion <= 0) {
2537
- throw usageError('App has no deployed source. Run `notis apps deploy` first.');
2538
- }
2539
- if (
2540
- linkedState?.app_id !== appId
2541
- || !Number.isFinite(linkedState?.version)
2542
- || linkedState.version !== remoteVersion
2543
- ) {
2544
- throw usageError(
2545
- `Local project is not confirmed at deployed version ${remoteVersion}. ` +
2546
- 'Run `notis apps deploy` from this project before submitting it.',
2547
- );
2548
- }
2549
-
2550
- const activeSubmission = detail.active_submission || app.active_submission || null;
2551
- if (activeSubmission?.status === 'pending_review') {
2552
- throw usageError(
2553
- `A Store submission is already in review${activeSubmission.github_pr_url ? `: ${activeSubmission.github_pr_url}` : '.'}`,
2554
- );
2555
- }
2556
- if (activeSubmission?.status === 'removal_pending_review') {
2557
- throw usageError('Store removal is currently in review. Wait for it to finish before submitting an update.');
2558
- }
2559
-
2560
- const result = await httpRequest({
2561
- runtime: ctx.runtime,
2562
- method: 'POST',
2563
- path: '/portal_apps/publish',
2564
- body: { app_id: appId },
2565
- });
2566
- const submission = result.payload.submission || result.payload;
2567
- const reviewStatus = submission.status || 'pending_review';
2568
- return ctx.output.emitSuccess({
2569
- command: ctx.spec.command_path.join(' '),
2570
- data: {
2571
- app_id: appId,
2572
- source_version: submission.source_version || remoteVersion,
2573
- submission,
2574
- },
2575
- humanSummary: reviewStatus === 'merged'
2576
- ? `Published app ${appId} to the Store at version ${submission.source_version || remoteVersion}`
2577
- : `Submitted app ${appId} version ${submission.source_version || remoteVersion} for Store review`,
2578
- hints: submission.github_pr_url
2579
- ? [{ command: submission.github_pr_url, reason: 'Review the Store registry pull request' }]
2580
- : [],
2581
- meta: { mutating: true, request_id: result.requestId },
2582
- });
2583
- }
2584
-
2585
- async function appsDoctorHandler(ctx) {
2586
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
2587
- const problems = detectProjectProblems(projectDir);
2588
-
2589
- let appConfig = null;
2590
- try {
2591
- appConfig = await loadAppConfig(projectDir);
2592
- } catch {
2593
- problems.push('Failed to load notis.config.ts');
2594
- }
2595
- const warnings = detectProjectWarnings(projectDir, appConfig);
2596
- let listing = null;
2597
- if (appConfig) {
2598
- try {
2599
- listing = inspectListingReadiness(projectDir, appConfig);
2600
- } catch (error) {
2601
- warnings.push(error instanceof Error ? error.message : String(error));
2602
- }
2603
- }
2604
-
2605
- const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
2606
- const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
2607
-
2608
- return ctx.output.emitSuccess({
2609
- command: ctx.spec.command_path.join(' '),
2610
- data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
2611
- humanSummary: problems.length
2612
- ? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
2613
- : warnings.length
2614
- ? `Healthy with ${warnings.length} warnings:\n${warnings.map((w) => ` - ${w}`).join('\n')}`
2615
- : `Project is healthy.${doctorLinkSummary(linkedState)}`,
2616
- });
2617
- }
2618
-
2619
- export function doctorLinkSummary(linkedState) {
2620
- if (linkedState?.app_id) {
2621
- return ` Linked to app ${linkedState.app_id}.`;
2622
- }
2623
- if (linkedState?.dev_app_id) {
2624
- return ` Local development app ${linkedState.dev_app_id} is active.`;
2625
- }
2626
- return ' Not linked.';
2627
- }
2628
-
2629
- // ---------------------------------------------------------------------------
2630
- // Command specs
2631
- // ---------------------------------------------------------------------------
2632
-
2633
- export const appsCommandSpecs = [
2634
- {
2635
- command_path: ['apps', 'list'],
2636
- summary: 'List apps the current profile can access.',
2637
- when_to_use: 'Discover existing apps before linking or deploying.',
2638
- args_schema: { arguments: [], options: [] },
2639
- examples: ['notis apps list', 'notis apps list --json'],
2640
- mutates: false,
2641
- idempotent: true,
2642
- backend_call: { type: 'tool', name: LIST_APPS_TOOL },
2643
- handler: appsListHandler,
2644
- },
2645
- {
2646
- command_path: ['apps', 'init'],
2647
- summary: 'Scaffold a new Notis app project.',
2648
- when_to_use: 'Start a new Notis app. Use --from with a published Store app when one is close to the desired app; otherwise creates the bare Vite + React project.',
2649
- args_schema: {
2650
- arguments: [
2651
- { token: '<name>', description: 'Display name for the app.' },
2652
- { token: '[dir]', key: 'dir', description: 'Target directory, resolved from the current directory. Defaults to ~/.notis/apps/<slug>; pass a path to place the project elsewhere, such as a tracked git repo or an existing monorepo.' },
2653
- ],
2654
- options: [
2655
- { flags: '--from <slug>', description: 'Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.' },
2656
- ],
2657
- },
2658
- examples: [
2659
- 'notis apps scaffolds list',
2660
- 'notis apps init "Mind the Flo"',
2661
- 'notis apps init "My CRM" --from databases',
2662
- 'notis apps init "My App" ~/code/my-app',
2663
- ],
2664
- mutates: true,
2665
- idempotent: false,
2666
- require_auth: false,
2667
- backend_call: { type: 'local', name: 'scaffold_project' },
2668
- handler: appsInitHandler,
2669
- },
2670
- {
2671
- command_path: ['apps', 'scaffolds', 'list'],
2672
- summary: 'List published Store apps available as scaffolds.',
2673
- when_to_use: 'Discover published Store apps to start from before creating a new app. Every app published to the public Store is automatically a scaffold; use --search to narrow the catalog.',
2674
- args_schema: {
2675
- arguments: [],
2676
- options: [
2677
- { flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
2678
- ],
2679
- },
2680
- examples: [
2681
- 'notis apps scaffolds list',
2682
- 'notis apps scaffolds list --search journal',
2683
- 'notis apps init "My App" --from databases',
2684
- ],
2685
- mutates: false,
2686
- idempotent: true,
2687
- require_auth: false,
2688
- backend_call: { type: 'local', name: 'list_scaffolds' },
2689
- handler: appsScaffoldsListHandler,
2690
- },
2691
- {
2692
- command_path: ['apps', 'create'],
2693
- summary: 'Create a new remote Notis app and optionally link a local project to it.',
2694
- when_to_use: 'Provision a fresh remote app before the first deploy. Pass a project directory to link it immediately.',
2695
- args_schema: {
2696
- arguments: [
2697
- { token: '<name>', description: 'Display name for the remote app.' },
2698
- { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
2699
- ],
2700
- options: [],
2701
- },
2702
- examples: [
2703
- 'notis apps create "My App"',
2704
- 'notis apps create "My App" .',
2705
- ],
2706
- mutates: true,
2707
- idempotent: false,
2708
- backend_call: { type: 'tool', name: CREATE_APP_TOOL },
2709
- handler: appsCreateHandler,
2710
- },
2711
- {
2712
- command_path: ['apps', 'dev'],
2713
- summary: 'Register a development root and connect its apps to the shared local development host.',
2714
- when_to_use:
2715
- 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
2716
- args_schema: {
2717
- arguments: [
2718
- { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
2719
- ],
2720
- options: [
2721
- { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
2722
- {
2723
- flags: '--scratch',
2724
- description:
2725
- 'Use isolated empty databases, bundled skills, and bundled automations for this session '
2726
- + "instead of the installed app's resources. For fixture work and destructive experiments.",
2727
- },
2728
- {
2729
- flags: '--live-data',
2730
- description:
2731
- 'Deprecated: using the installed app\'s real resources is now the default. '
2732
- + 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
2733
- },
2734
- {
2735
- flags: '--grant-cloud-shell',
2736
- description:
2737
- "Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
2738
- + 'The grant persists for this dev app; authorship alone never grants it.',
2739
- },
2740
- ],
2741
- },
2742
- examples: [
2743
- 'notis apps dev',
2744
- 'notis apps dev ./my-app',
2745
- 'notis apps dev ./workspace --port 5200',
2746
- 'notis apps dev --scratch # isolated resources for fixture or schema experiments',
2747
- ],
2748
- mutates: true,
2749
- idempotent: true,
2750
- require_auth: true,
2751
- backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
2752
- handler: appsDevHandler,
2753
- },
2754
- {
2755
- command_path: ['apps', 'roots', 'list'],
2756
- summary: 'List persistent machine-local Notis app development roots.',
2757
- when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
2758
- args_schema: { arguments: [], options: [] },
2759
- examples: ['notis apps roots list'],
2760
- mutates: false,
2761
- idempotent: true,
2762
- require_auth: false,
2763
- backend_call: { type: 'local', name: 'list_app_development_roots' },
2764
- handler: appsRootsListHandler,
2765
- },
2766
- {
2767
- command_path: ['apps', 'roots', 'remove'],
2768
- summary: 'Stop watching a registered Notis app development root.',
2769
- when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
2770
- args_schema: {
2771
- arguments: [
2772
- { token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
2773
- ],
2774
- options: [],
2775
- },
2776
- examples: ['notis apps roots remove ./old-apps'],
2777
- mutates: true,
2778
- idempotent: true,
2779
- require_auth: false,
2780
- backend_call: { type: 'local', name: 'remove_app_development_root' },
2781
- handler: appsRootsRemoveHandler,
2782
- },
2783
- {
2784
- command_path: ['apps', 'build'],
2785
- summary: 'Build and package the app into .notis/output/.',
2786
- when_to_use: 'Prepare the app for verification or deployment.',
2787
- args_schema: {
2788
- arguments: [
2789
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2790
- ],
2791
- options: [],
2792
- },
2793
- examples: ['notis apps build', 'notis apps build ./my-app'],
2794
- mutates: true,
2795
- idempotent: true,
2796
- require_auth: false,
2797
- backend_call: { type: 'local', name: 'next_build_and_package' },
2798
- handler: appsBuildHandler,
2799
- },
2800
- {
2801
- command_path: ['apps', 'verify'],
2802
- summary: 'Validate that every route renders and reports Store listing readiness.',
2803
- when_to_use:
2804
- 'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
2805
- 'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
2806
- 'to fail on it instead.',
2807
- args_schema: {
2808
- arguments: [
2809
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2810
- ],
2811
- options: [
2812
- { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
2813
- { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
2814
- { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
2815
- { 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.' },
2816
- { flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
2817
- { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
2818
- { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
2819
- ],
2820
- },
2821
- examples: [
2822
- 'notis apps verify',
2823
- 'notis apps verify --routes notes',
2824
- 'notis apps verify --mode live',
2825
- 'notis apps verify --listing # gate on Store listing readiness before publish',
2826
- 'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
2827
- ],
2828
- mutates: false,
2829
- idempotent: true,
2830
- require_auth: false,
2831
- backend_call: { type: 'local', name: 'verify_harness' },
2832
- handler: appsVerifyHandler,
2833
- },
2834
- {
2835
- command_path: ['apps', 'screenshot'],
2836
- summary: 'Capture configured listing route/scenario states via the headless harness.',
2837
- when_to_use:
2838
- 'Generate the 3–6 declared metadata/screenshot-N.png files for the App Store listing. Apps are ' +
2839
- 'icon-led (like Raycast) — there is no cover image, only these screenshots. ' +
2840
- 'Each screenshot may set a focus selector to remove empty canvas and a light or dark theme that also controls its Store frame. ' +
2841
- 'Run before notis apps verify / deploy / publish.',
2842
- args_schema: {
2843
- arguments: [
2844
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2845
- ],
2846
- options: [
2847
- { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every configured screenshot state.' },
2848
- { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
2849
- { flags: '--width <px>', description: 'Viewport width. Default: 2000.' },
2850
- { flags: '--height <px>', description: 'Viewport height. Default: 1250 (16:10).' },
2851
- { flags: '--output-dir <dir>', description: 'Where to write screenshot-N.png. Default: metadata/.' },
2852
- { flags: '--mode <mode>', description: 'stub | live. Default stub. Live renders against real data via the CLI JWT (requires a linked app), so screenshots show actual content instead of empty states.' },
2853
- { flags: '--raw', description: 'Write the unframed harness capture instead of the default Store presentation.' },
2854
- { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
2855
- ],
2856
- },
2857
- examples: [
2858
- 'notis apps screenshot # honors notis.config.ts screenshot scenarios',
2859
- 'notis apps screenshot --routes home,history',
2860
- 'notis apps screenshot --mode live # populated screenshots from real data',
2861
- 'notis apps screenshot --raw # diagnostic capture without Store framing',
2862
- ],
2863
- mutates: true,
2864
- idempotent: true,
2865
- require_auth: false,
2866
- backend_call: { type: 'local', name: 'screenshot_routes' },
2867
- handler: appsScreenshotHandler,
2868
- },
2869
- {
2870
- command_path: ['apps', 'link'],
2871
- summary: 'Link a local project to a remote Notis app.',
2872
- when_to_use: 'Connect a local project to an existing app for deployment.',
2873
- args_schema: {
2874
- arguments: [
2875
- { token: '<app-id>', description: 'Remote app ID to link to.' },
2876
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2877
- ],
2878
- options: [],
2879
- },
2880
- examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
2881
- mutates: true,
2882
- idempotent: true,
2883
- require_auth: false,
2884
- backend_call: { type: 'local', name: 'write_link_state' },
2885
- handler: appsLinkHandler,
2886
- },
2887
- {
2888
- command_path: ['apps', 'pull'],
2889
- summary: 'Download a Notis app source snapshot into a local project folder.',
2890
- when_to_use:
2891
- 'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
2892
- args_schema: {
2893
- arguments: [
2894
- { token: '<app-id>', description: 'Remote app ID to pull.' },
2895
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
2896
- ],
2897
- options: [
2898
- { flags: '--force', description: 'Overwrite a non-empty target directory.' },
2899
- { flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
2900
- ],
2901
- },
2902
- examples: [
2903
- 'notis apps pull abc123',
2904
- 'notis apps pull abc123 ./my-app --force --source-version 3',
2905
- ],
2906
- mutates: true,
2907
- idempotent: true,
2908
- require_auth: true,
2909
- backend_call: { type: 'http', name: 'portal_apps/source' },
2910
- handler: appsPullHandler,
2911
- },
2912
- {
2913
- command_path: ['apps', 'deploy'],
2914
- summary: 'Build and upload the app to the linked Notis app.',
2915
- when_to_use:
2916
- 'Ship the installed app to production for the linked user/team app. A project that has only a development app is promoted in place on first deploy: same app id, same databases, dev markers removed. This command does not publish to the app store.',
2917
- args_schema: {
2918
- arguments: [
2919
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2920
- ],
2921
- options: [
2922
- { flags: '--app-id <id>', description: 'Override linked app ID.' },
2923
- { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
2924
- { flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
2925
- ],
2926
- },
2927
- examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
2928
- mutates: true,
2929
- idempotent: true,
2930
- backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
2931
- handler: appsDeployHandler,
2932
- },
2933
- {
2934
- command_path: ['apps', 'publish'],
2935
- summary: 'Submit the deployed app for Store review.',
2936
- when_to_use:
2937
- 'After the user explicitly confirms the App Details page and Store listing are ready. Requires the current local project to match the latest deployed version.',
2938
- args_schema: {
2939
- arguments: [
2940
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2941
- ],
2942
- options: [
2943
- { flags: '--app-id <id>', description: 'Override linked app ID.' },
2944
- { flags: '--confirm-ready', description: 'Confirm the user approved the current App Details page for Store submission.' },
2945
- ],
2946
- },
2947
- examples: ['notis apps publish --confirm-ready', 'notis apps publish ./my-app --confirm-ready'],
2948
- mutates: true,
2949
- idempotent: false,
2950
- require_auth: true,
2951
- backend_call: { type: 'http', name: 'portal_apps/publish' },
2952
- handler: appsPublishHandler,
2953
- },
2954
- {
2955
- command_path: ['apps', 'duplicate'],
2956
- summary: 'Duplicate an app into an independent copy with its own databases.',
2957
- when_to_use:
2958
- 'When the same app should run for a second purpose - a notes app for blog drafts alongside one for bookmarks. The copy shares no data with the source.',
2959
- args_schema: {
2960
- arguments: [
2961
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2962
- ],
2963
- options: [
2964
- { flags: '--app-id <id>', description: 'App to duplicate. Defaults to the app this project is linked to.' },
2965
- { flags: '--name <name>', description: 'Name for the duplicate (default: the source name followed by "copy").' },
2966
- {
2967
- flags: '--copy-documents <mode>',
2968
- description:
2969
- "Which rows to copy: 'declared' (default, the starter content a fresh install would have), 'all', or 'none'.",
2970
- },
2971
- ],
2972
- },
2973
- examples: [
2974
- 'notis apps duplicate --name "Blog"',
2975
- 'notis apps duplicate --app-id abc123 --name "Bookmarks" --copy-documents none',
2976
- ],
2977
- mutates: true,
2978
- idempotent: false,
2979
- require_auth: true,
2980
- backend_call: { type: 'tool', name: DUPLICATE_APP_TOOL },
2981
- handler: appsDuplicateHandler,
2982
- },
2983
- {
2984
- command_path: ['apps', 'doctor'],
2985
- summary: 'Check project health and readiness.',
2986
- when_to_use: 'Diagnose issues with a Notis app project.',
2987
- args_schema: {
2988
- arguments: [
2989
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
2990
- ],
2991
- options: [],
2992
- },
2993
- examples: ['notis apps doctor', 'notis apps doctor ./my-app'],
2994
- mutates: false,
2995
- idempotent: true,
2996
- require_auth: false,
2997
- backend_call: { type: 'local', name: 'project_health_check' },
2998
- handler: appsDoctorHandler,
2999
- },
3000
- ];