@notis_ai/cli 0.2.18 → 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 +42325 -18273
  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 -1570
  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 -3011
  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,45 +1,40 @@
1
1
  /**
2
2
  * App platform utilities for the Notis CLI.
3
3
  *
4
- * Handles project scaffolding, validation, building, and linking. This is the
5
- * CLI-side counterpart to @notis/sdk -- it reads notis.config.ts, runs the
6
- * Vite build, and packages the bundle for deployment.
4
+ * Handles project scaffolding, validation, building, and Space source pulls.
5
+ * This is the CLI-side counterpart to @notis/sdk -- it reads notis.config.ts,
6
+ * runs the Vite build, and packages the bundle for deployment.
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process';
10
- import { randomUUID } from 'node:crypto';
11
- import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
12
12
  import { createRequire } from 'node:module';
13
13
  import { homedir } from 'node:os';
14
- import { basename, dirname, join, relative, resolve } from 'node:path';
14
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { gunzipSync } from 'node:zlib';
17
17
 
18
18
  import { usageError } from './errors.js';
19
- import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
20
- import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
21
- import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
19
+ import { validateArtifactBoundary } from './app-boundary-validator.js';
20
+ import { HOST_ONLY_SDK_FILES, RETIRED_SDK_FILES } from './retired-sdk-files.js';
21
+ import { assertSourceRoot } from './source-root.js';
22
22
 
23
23
  const NOTIS_DIR = '.notis';
24
- const STATE_FILE = join(NOTIS_DIR, 'state.json');
24
+ const SPACE_SOURCE_FILE = join(NOTIS_DIR, 'space-source.json');
25
25
  const OUTPUT_DIR = join(NOTIS_DIR, 'output');
26
26
  const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
27
27
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
28
- const METADATA_DIR = 'metadata';
29
28
  const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
30
- const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
31
- export const NOTIS_APP_CATEGORIES = [
32
- 'Productivity',
33
- 'Sales & Marketing',
34
- 'Operations',
35
- 'Product & Engineering',
36
- 'Personal',
37
- ];
38
- export const MIN_LISTING_SCREENSHOTS = 3;
29
+ const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'dist', 'sdk');
30
+ // Spaces install only types and build-time entries; the host provides the SDK
31
+ // at runtime (scripts/build-space-sdk.js).
32
+ const SPACE_SDK_DIR = join(CLI_ROOT, 'dist', 'space-sdk');
39
33
  const SOURCE_COPY_EXCLUDES = new Set([
40
34
  'node_modules',
41
35
  '.notis',
42
36
  '.git',
37
+ '.context',
43
38
  'dist',
44
39
  'tsconfig.tsbuildinfo',
45
40
  '.DS_Store',
@@ -55,45 +50,17 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
55
50
  'coverage',
56
51
  '.next',
57
52
  '.turbo',
53
+ // The CLI reads its own template/.harness theme; projects never use a copy.
54
+ '.harness',
58
55
  ]);
59
- // Listing media describes the scaffold's own Store entry, so a new project must
60
- // never inherit it. Scaffold packaging still ships these files -- only the
61
- // `apps init` copy drops them. Screenshots only: `screenshot-fixtures.json` is
62
- // not listing media, it is the stub data the dev harness serves, and dropping
63
- // it would make every route of a fresh project render its empty state.
64
- const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
65
56
  const PULL_LOCK_TIMEOUT_MS = 30_000;
66
57
  const PULL_LOCK_POLL_MS = 25;
67
- const STATE_WRITE_LOCK_TIMEOUT_MS = 5_000;
68
- const STATE_WRITE_LOCK_STALE_MS = 2_000;
69
- const stateWriteLockWait = new Int32Array(new SharedArrayBuffer(4));
70
- // A directory-declared skill ships every supporting file to the sandbox, so it
71
- // needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
72
- // handful of scripts always fits, and far below the bundle machinery's own
73
- // limits so an accidental asset dump fails on the client with a clear message.
74
- export const MAX_APP_SKILL_BUNDLE_BYTES = 5 * 1024 * 1024;
75
58
  let appConfigImportNonce = 0;
76
59
 
77
60
  // ---------------------------------------------------------------------------
78
61
  // Project directory resolution
79
62
  // ---------------------------------------------------------------------------
80
63
 
81
- // Every Notis app the user did not deliberately place lives here, mirroring the
82
- // desktop's synced-skills root (~/.notis/skills). A default that depends on the
83
- // caller's working directory is unusable for agents: the shell sits wherever
84
- // the previous command left it, so `apps init` would scatter projects into
85
- // unrelated checkouts. Pass an explicit [dir] to override -- that is how an app
86
- // ends up in a tracked git repo or an existing monorepo.
87
- export const DEFAULT_APPS_ROOT = join(homedir(), NOTIS_DIR, 'apps');
88
-
89
- export function defaultAppProjectDir(slug) {
90
- const safeSlug = String(slug || '').trim();
91
- if (!safeSlug || safeSlug.includes('/') || safeSlug.includes('\\') || safeSlug.startsWith('.')) {
92
- throw usageError(`Cannot derive a default project directory from "${slug}". Pass an explicit target directory.`);
93
- }
94
- return join(DEFAULT_APPS_ROOT, safeSlug);
95
- }
96
-
97
64
  // A `~/...` path only expands when a shell is involved. Agents routinely spawn
98
65
  // the CLI without one, and the documented default home is written with a tilde,
99
66
  // so expand it here instead of creating a literal "~" directory.
@@ -124,17 +91,72 @@ export function resolveBuiltBundleDir(projectDir) {
124
91
  return null;
125
92
  }
126
93
 
127
- function normalizeShadowScopedCss(css) {
128
- return css
129
- // Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
130
- // the shadow host itself to carry those defaults.
131
- .replace(/html\s*,\s*:host\s*\{/g, ':host{')
132
- .replace(/:root\s*,\s*:host\s*\{/g, ':host{')
133
- .replace(/:root\s*\{/g, ':host{')
134
- .replace(/html\s*\{/g, ':host{')
94
+ /**
95
+ * Same-length copy of `css` in which comments become spaces and quoted strings
96
+ * and attribute selectors become word characters, so selector matching never
97
+ * sees text inside them and a word written there still blocks a match after it.
98
+ */
99
+ function maskCssForSelectorMatching(css) {
100
+ let masked = '';
101
+ let index = 0;
102
+ const skipString = (from) => {
103
+ const quote = css[from];
104
+ let end = from + 1;
105
+ while (end < css.length && css[end] !== quote && css[end] !== '\n') end += css[end] === '\\' ? 2 : 1;
106
+ return Math.min(end + 1, css.length);
107
+ };
108
+ while (index < css.length) {
109
+ const char = css[index];
110
+ if (char === '\\') {
111
+ // An escaped character (`.w-\\[100px\\]`) is part of a name, never a string or attribute opener.
112
+ masked += css.slice(index, index + 2);
113
+ index += 2;
114
+ } else if (char === '/' && css[index + 1] === '*') {
115
+ const close = css.indexOf('*/', index + 2);
116
+ const end = close === -1 ? css.length : close + 2;
117
+ masked += css.slice(index, end).replace(/[^\n]/g, ' ');
118
+ index = end;
119
+ } else if (char === '"' || char === "'") {
120
+ const end = skipString(index);
121
+ masked += 'x'.repeat(end - index);
122
+ index = end;
123
+ } else if (char === '[') {
124
+ let end = index + 1;
125
+ while (end < css.length && css[end] !== ']') {
126
+ end = css[end] === '"' || css[end] === "'" ? skipString(end) : end + (css[end] === '\\' ? 2 : 1);
127
+ }
128
+ end = Math.min(end + 1, css.length);
129
+ masked += 'x'.repeat(end - index);
130
+ index = end;
131
+ } else {
132
+ masked += char;
133
+ index += 1;
134
+ }
135
+ }
136
+ return masked;
137
+ }
138
+
139
+ // Standalone `html`, `:root` and `body` type selectors right before a block.
140
+ // A name inside a class, id, custom property or escaped utility (`.sl-kind-html`,
141
+ // `#body`, `--html`, `.md\:root`) never matches, nor does text in a comment,
142
+ // string or attribute selector.
143
+ const SHADOW_GLOBAL_SELECTOR = /(?:(?<![\w\-.#:\\])html|(?<![\w\-\\]):root)\s*(?:,\s*:host\s*)?\{|(?<![\w\-.#:\\])body\s*\{/g;
144
+
145
+ export function normalizeShadowScopedCss(css) {
146
+ const masked = maskCssForSelectorMatching(css);
147
+ let output = '';
148
+ let last = 0;
149
+ for (const match of masked.matchAll(SHADOW_GLOBAL_SELECTOR)) {
150
+ output += css.slice(last, match.index);
151
+ // Keep defaults on both the Portal shadow host and the app root used
152
+ // by the standalone harness. Removing :root erases Tailwind v4 theme
153
+ // variables in previews, collapsing spacing, type sizes and controls.
135
154
  // Shadow trees do not contain a body element. Route those defaults to the
136
155
  // app root contract instead so authors still get the expected reset.
137
- .replace(/body\s*\{/g, '[data-notis-app-root]{');
156
+ output += match[0].startsWith('body') ? '[data-notis-app-root]{' : '[data-notis-app-root],:host{';
157
+ last = match.index + match[0].length;
158
+ }
159
+ return output + css.slice(last);
138
160
  }
139
161
 
140
162
  function normalizeBundleStylesheets(projectDir) {
@@ -165,35 +187,38 @@ function normalizeBundleStylesheets(projectDir) {
165
187
  function stripNotisSdkImports(source) {
166
188
  return source
167
189
  .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
168
- .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
169
- .replace(/defineNotisApp\s*\(/g, '(');
190
+ .replace(/import\s*{([^}]+)}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, (_statement, names) => {
191
+ const declarations = [];
192
+ for (const imported of names.split(',').map(value => value.trim()).filter(Boolean)) {
193
+ if (imported.startsWith('type ')) continue;
194
+ const match = imported.match(/^(defineNotisApp|defineSpace|defineSpaces)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
195
+ if (!match) throw usageError(`Unsupported configuration helper: ${imported}`);
196
+ declarations.push(`const ${match[2] || match[1]} = (value) => value;`);
197
+ }
198
+ return declarations.join('\n');
199
+ });
170
200
  }
171
201
 
172
202
  function transpileTsConfigSource(source, configPath) {
173
203
  // Strip the @notis/sdk/config import unconditionally. The SDK package often
174
204
  // points its `exports` at raw .ts source, which Node cannot import from the
175
- // temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
176
- // so removing the import and replacing the call with a parens-wrapped
177
- // expression preserves the config value without ever resolving the SDK.
205
+ // temp .mjs file we emit below. The config helpers are identity functions,
206
+ // so replacing the import with local identity functions preserves the
207
+ // config value without ever resolving the SDK.
178
208
  const stripped = stripNotisSdkImports(source);
179
- const requireFromConfig = createRequire(`file://${configPath}`);
180
209
  try {
181
- const ts = requireFromConfig('typescript');
182
- const transpiled = ts.transpileModule(stripped, {
183
- compilerOptions: {
184
- module: ts.ModuleKind.ESNext,
185
- target: ts.ScriptTarget.ES2020,
186
- },
187
- fileName: configPath,
188
- });
189
- return transpiled.outputText;
190
- } catch {
191
- return stripped;
210
+ const { transformSync } = createRequire(import.meta.url)('esbuild');
211
+ return transformSync(stripped, { loader: 'ts', format: 'esm', target: 'es2020', sourcefile: configPath }).code;
212
+ } catch (error) {
213
+ throw usageError(`Failed to parse ${configPath}: ${error.message}`);
192
214
  }
193
215
  }
194
216
 
195
- export async function loadAppConfig(projectDir) {
196
- const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
217
+ export async function loadAppConfig(projectDir, { file = null } = {}) {
218
+ const configPaths = file ? [file] : ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
219
+ if (file && (!Object.hasOwn(collectSourceFiles(projectDir), file) || !/\.(?:ts|js|mjs)$/.test(file))) {
220
+ throw usageError('Choose an existing configuration file inside this source project.');
221
+ }
197
222
  let configPath = null;
198
223
 
199
224
  for (const name of configPaths) {
@@ -208,12 +233,13 @@ export async function loadAppConfig(projectDir) {
208
233
  throw usageError('No notis.config.ts found in project directory.');
209
234
  }
210
235
 
211
- // For .js/.mjs files, import directly. For .ts, we need transpilation.
212
- if (configPath.endsWith('.ts')) {
236
+ // Identity config helpers work for every extension without asking Node to
237
+ // execute raw TypeScript from an embedded SDK package.
238
+ {
213
239
  const source = readFileSync(configPath, 'utf-8');
214
- const jsSource = transpileTsConfigSource(source, configPath);
240
+ const jsSource = configPath.endsWith('.ts') ? transpileTsConfigSource(source, configPath) : stripNotisSdkImports(source);
215
241
 
216
- const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
242
+ const tmpPath = join(dirname(configPath), `._notis_config_${randomUUID()}.mjs`);
217
243
  mkdirSync(dirname(tmpPath), { recursive: true });
218
244
  writeFileSync(tmpPath, jsSource);
219
245
  try {
@@ -235,347 +261,15 @@ export async function loadAppConfig(projectDir) {
235
261
  }
236
262
  }
237
263
 
238
- const mod = await import(`file://${configPath}`);
239
- return mod.default || mod;
240
- }
241
-
242
- // ---------------------------------------------------------------------------
243
- // Project validation
244
- // ---------------------------------------------------------------------------
245
-
246
- export function detectProjectProblems(projectDir) {
247
- const problems = [];
248
-
249
- if (!existsSync(join(projectDir, 'package.json'))) {
250
- problems.push('Missing package.json');
251
- }
252
-
253
- const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
254
- .some((name) => existsSync(join(projectDir, name)));
255
- if (!hasViteConfig) {
256
- problems.push('Missing vite.config file');
257
- }
258
-
259
- if (!existsSync(join(projectDir, 'app'))) {
260
- problems.push('Missing app/ directory');
261
- }
262
-
263
- const hasNotisConfig = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs']
264
- .some((name) => existsSync(join(projectDir, name)));
265
- if (!hasNotisConfig) {
266
- problems.push('Missing notis.config.ts');
267
- }
268
-
269
- return problems;
270
- }
271
-
272
- export function detectProjectWarnings(projectDir, appConfig = null) {
273
- const warnings = [];
274
-
275
- if (!existsSync(join(projectDir, 'components.json'))) {
276
- warnings.push('Missing components.json -- shadcn is not configured.');
277
- }
278
-
279
- const hasTailwind = ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.cjs']
280
- .some((name) => existsSync(join(projectDir, name)));
281
- if (!hasTailwind) {
282
- warnings.push('Missing Tailwind config.');
283
- }
284
-
285
- const readsWorkspaceDatabases =
286
- appConfig?.capabilities?.workspaceDatabases === 'read';
287
- if (
288
- appConfig
289
- && !readsWorkspaceDatabases
290
- && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)
291
- ) {
292
- warnings.push('No database references declared in notis.config.ts.');
293
- }
294
-
295
- if (appConfig) {
296
- try {
297
- const listing = inspectListingReadiness(projectDir, appConfig);
298
- warnings.push(...listing.errors, ...listing.warnings);
299
- } catch (error) {
300
- warnings.push(error instanceof Error ? error.message : String(error));
301
- }
302
- }
303
-
304
- return warnings;
305
- }
306
-
307
- function safeKebab(value) {
308
- return String(value || '')
309
- .trim()
310
- .toLowerCase()
311
- .replace(/[^a-z0-9]+/g, '-')
312
- .replace(/(^-|-$)+/g, '');
313
- }
314
-
315
- function jsStringLiteral(value) {
316
- return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
317
- }
318
-
319
- function normalizeCategories(categories = []) {
320
- if (!Array.isArray(categories)) {
321
- return [];
322
- }
323
- const allowed = new Set(NOTIS_APP_CATEGORIES);
324
- const normalized = [];
325
- for (const category of categories) {
326
- if (typeof category !== 'string' || !category.trim()) {
327
- continue;
328
- }
329
- const trimmed = category.trim();
330
- if (!allowed.has(trimmed)) {
331
- throw usageError(
332
- `Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
333
- );
334
- }
335
- if (!normalized.includes(trimmed)) {
336
- normalized.push(trimmed);
337
- }
338
- }
339
- return normalized;
340
- }
341
-
342
- function pngDimensions(buffer) {
343
- const signature = '89504e470d0a1a0a';
344
- if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
345
- return null;
346
- }
347
- return {
348
- width: buffer.readUInt32BE(16),
349
- height: buffer.readUInt32BE(20),
350
- };
351
- }
352
-
353
- function imageAssetMeta(projectDir, relPath, { maxBytes }) {
354
- const fullPath = join(projectDir, relPath);
355
- const content = readFileSync(fullPath);
356
- const dimensions = pngDimensions(content);
357
- const errors = [];
358
- const warnings = [];
359
-
360
- if (!relPath.toLowerCase().endsWith('.png')) {
361
- errors.push(`${relPath} must be a PNG file.`);
362
- }
363
- if (content.length > maxBytes) {
364
- errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
365
- }
366
- if (!dimensions) {
367
- errors.push(`${relPath} is not a valid PNG file.`);
368
- } else if (dimensions.width !== 2000 || dimensions.height !== 1250) {
369
- errors.push(`${relPath} must be exactly 2000x1250 pixels.`);
370
- }
371
-
372
- return {
373
- path: relPath,
374
- content_type: 'image/png',
375
- bytes: content.length,
376
- width: dimensions?.width ?? null,
377
- height: dimensions?.height ?? null,
378
- errors,
379
- warnings,
380
- };
381
- }
382
-
383
- export function discoverMetadataAssets(projectDir) {
384
- const metadataDir = join(projectDir, METADATA_DIR);
385
- const screenshots = [];
386
-
387
- if (existsSync(metadataDir)) {
388
- for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
389
- if (!entry.isFile()) continue;
390
- const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
391
- if (!match) continue;
392
- screenshots.push({
393
- index: Number.parseInt(match[1], 10),
394
- ...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
395
- maxBytes: 2 * 1024 * 1024,
396
- }),
397
- });
398
- }
399
- }
400
-
401
- screenshots.sort((a, b) => a.index - b.index);
402
- return {
403
- screenshots: screenshots.slice(0, 6),
404
- };
405
- }
406
-
407
- function normalizeConfiguredScreenshots(appConfig = null) {
408
- if (!Array.isArray(appConfig?.screenshots) || appConfig.screenshots.length === 0) {
409
- return null;
410
- }
411
- if (appConfig.screenshots.length > 6) {
412
- throw usageError('Configure at most six listing screenshots.');
413
- }
414
-
415
- const seenPaths = new Set();
416
- return appConfig.screenshots.map((entry, index) => {
417
- const path = typeof entry?.path === 'string' ? entry.path.trim().replace(/\\/g, '/') : '';
418
- const alt = typeof entry?.alt === 'string' ? entry.alt.trim() : '';
419
- const route = typeof entry?.route === 'string' ? entry.route.trim() : '';
420
- const scenario = typeof entry?.scenario === 'string' ? entry.scenario.trim() : '';
421
- const focus = typeof entry?.focus === 'string' ? entry.focus.trim() : '';
422
- const theme = typeof entry?.theme === 'string' ? entry.theme.trim().toLowerCase() : '';
423
- if (!/^metadata\/screenshot-\d+\.png$/i.test(path)) {
424
- throw usageError(`screenshots[${index}].path must match metadata/screenshot-N.png.`);
425
- }
426
- const expectedPath = `metadata/screenshot-${index + 1}.png`;
427
- if (path !== expectedPath) {
428
- throw usageError(`screenshots[${index}].path must be ${expectedPath} so capture order stays stable.`);
429
- }
430
- if (seenPaths.has(path)) {
431
- throw usageError(`Duplicate listing screenshot path: ${path}`);
432
- }
433
- if (theme && theme !== 'light' && theme !== 'dark') {
434
- throw usageError(`screenshots[${index}].theme must be light or dark.`);
435
- }
436
- seenPaths.add(path);
437
- return {
438
- path,
439
- alt,
440
- route: route || null,
441
- scenario: scenario || null,
442
- focus: focus || null,
443
- theme: theme || 'light',
444
- };
445
- });
446
- }
447
-
448
- /** Resolve listing screenshot files in their configured editorial order. */
449
- export function resolveListingScreenshots(projectDir, appConfig = null) {
450
- const discovered = discoverMetadataAssets(projectDir).screenshots;
451
- const configured = normalizeConfiguredScreenshots(appConfig);
452
- if (!configured) {
453
- return discovered.map((asset) => ({
454
- ...asset,
455
- alt: null,
456
- route: null,
457
- scenario: null,
458
- focus: null,
459
- theme: 'light',
460
- }));
461
- }
462
-
463
- const byPath = new Map(discovered.map((asset) => [asset.path.replace(/\\/g, '/'), asset]));
464
- return configured.map((entry) => {
465
- const asset = byPath.get(entry.path);
466
- if (!asset) {
467
- return {
468
- ...entry,
469
- content_type: 'image/png',
470
- bytes: null,
471
- width: null,
472
- height: null,
473
- errors: [`${entry.path} is configured but the file does not exist.`],
474
- warnings: [],
475
- };
476
- }
477
- return { ...asset, ...entry };
478
- });
479
- }
480
-
481
- /**
482
- * Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
483
- * does not define. A missing scenario is silent at capture time -- the harness
484
- * simply falls back to the default fixtures -- so it is reported as a warning.
485
- */
486
- export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
487
- const named = screenshots.filter((entry) => entry?.scenario);
488
- if (named.length === 0) {
489
- return [];
490
- }
491
- const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
492
- if (!existsSync(fixturesPath)) {
493
- return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
494
- }
495
- let defined;
496
- try {
497
- const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
498
- const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
499
- defined = new Set(Object.keys(scenarios));
500
- } catch {
501
- return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
502
- }
503
- const warnings = [];
504
- for (const entry of named) {
505
- if (!defined.has(entry.scenario)) {
506
- warnings.push(
507
- `${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
508
- );
509
- }
510
- }
511
- return warnings;
512
- }
513
-
514
- export function inspectListingReadiness(projectDir, appConfig = null) {
515
- const config = appConfig || {};
516
- const warnings = [];
517
- const errors = [];
518
- const screenshots = resolveListingScreenshots(projectDir, config);
519
- const metadata = { screenshots };
520
- const categories = normalizeCategories(config.categories || []);
521
- const changelog = readAppChangelog(projectDir);
522
- errors.push(...changelog.errors);
523
-
524
- const packagePath = join(projectDir, 'package.json');
525
- if (!existsSync(packagePath)) {
526
- errors.push('package.json is required for Store publication.');
527
- } else {
528
- try {
529
- const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
530
- const notisAppVersion = String(packageJson.notisAppVersion || '').trim();
531
- if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(notisAppVersion)) {
532
- errors.push('package.json must include a semver `notisAppVersion` (for example, "0.1.0").');
533
- }
534
- } catch {
535
- errors.push('package.json must contain valid JSON for Store publication.');
536
- }
537
- }
538
-
539
- if (!String(config.tagline || '').trim()) {
540
- errors.push('Listing tagline missing in notis.config.ts.');
541
- }
542
- if (categories.length === 0) {
543
- errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
544
- }
545
- if (metadata.screenshots.length < MIN_LISTING_SCREENSHOTS) {
546
- errors.push(`Listing screenshots: ${metadata.screenshots.length}/${MIN_LISTING_SCREENSHOTS} minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
547
- }
548
- for (const screenshot of metadata.screenshots) {
549
- errors.push(...screenshot.errors);
550
- warnings.push(...screenshot.warnings);
551
- if (!screenshot.alt) {
552
- errors.push(`${screenshot.path} is missing descriptive alt text in notis.config.ts -> screenshots.`);
553
- }
554
- }
555
-
556
- return {
557
- ready:
558
- errors.length === 0 &&
559
- Boolean(String(config.tagline || '').trim()) &&
560
- categories.length > 0 &&
561
- metadata.screenshots.length >= MIN_LISTING_SCREENSHOTS &&
562
- metadata.screenshots.every((screenshot) => Boolean(screenshot.alt)) &&
563
- changelog.entries.length > 0,
564
- warnings,
565
- errors,
566
- metadata,
567
- categories,
568
- changelog,
569
- };
570
264
  }
571
265
 
572
266
  // ---------------------------------------------------------------------------
573
267
  // Build
574
268
  // ---------------------------------------------------------------------------
575
269
 
576
- export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
270
+ export async function runProjectScript({ projectDir, scriptName, args = [], env = {}, stdio = 'inherit' }) {
577
271
  await new Promise((resolvePromise, rejectPromise) => {
578
- const child = spawn('npm', ['run', scriptName], {
272
+ const child = spawn('npm', ['run', scriptName, ...(args.length ? ['--', ...args] : [])], {
579
273
  cwd: projectDir,
580
274
  stdio,
581
275
  env: { ...process.env, ...env },
@@ -603,749 +297,78 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
603
297
  }
604
298
 
605
299
  /**
606
- * Derive an export name from a route path.
607
- * '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
608
- */
609
- export function exportNameFromPath(routePath) {
610
- if (routePath === '/') return 'index';
611
- const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
612
- const identifier = slug
613
- .split(/[^A-Za-z0-9_$]+/)
614
- .filter(Boolean)
615
- .map((part, index) => {
616
- const lower = part.toLowerCase();
617
- if (index === 0) return lower;
618
- return lower.charAt(0).toUpperCase() + lower.slice(1);
619
- })
620
- .join('');
621
- if (!identifier) return 'route';
622
- return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
623
- }
624
-
625
- function slugFromPath(routePath) {
626
- if (routePath === '/') return 'index';
627
- return routePath.replace(/^\//, '').replace(/\//g, '-');
628
- }
629
-
630
- /**
631
- * Auto-detect routes from the app/ directory by scanning for page.tsx files.
632
- */
633
- function autoDetectRoutes(projectDir) {
634
- const appDir = join(projectDir, 'app');
635
- const routes = [];
636
-
637
- function scan(dir, pathPrefix) {
638
- if (!existsSync(dir)) return;
639
- const entries = readdirSync(dir, { withFileTypes: true });
640
-
641
- for (const entry of entries) {
642
- if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
643
-
644
- if (entry.isDirectory()) {
645
- scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
646
- } else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
647
- const routePath = pathPrefix || '/';
648
- const slug = slugFromPath(routePath);
649
- routes.push({
650
- path: routePath,
651
- slug,
652
- name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
653
- default: routePath === '/',
654
- export_name: exportNameFromPath(routePath),
655
- });
656
- }
657
- }
658
- }
659
-
660
- scan(appDir, '');
661
- return routes;
662
- }
663
-
664
- function validateConfiguredRoutes(routes) {
665
- if (routes.length === 0) {
666
- return;
667
- }
668
-
669
- const slugSet = new Set();
670
- let defaultCount = 0;
671
-
672
- for (const route of routes) {
673
- if (!route.slug || typeof route.slug !== 'string') {
674
- throw usageError(`Route "${route.path}" must define a slug.`);
675
- }
676
- if (slugSet.has(route.slug)) {
677
- throw usageError(`Duplicate route slug "${route.slug}".`);
678
- }
679
- slugSet.add(route.slug);
680
- if (route.default) {
681
- defaultCount += 1;
682
- }
683
- if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
684
- throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
685
- }
686
- }
687
-
688
- if (defaultCount !== 1) {
689
- throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
690
- }
691
-
692
- for (const route of routes) {
693
- if (route.parentSlug && !slugSet.has(route.parentSlug)) {
694
- throw usageError(
695
- `Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
696
- );
697
- }
698
- const collection = route.collection || null;
699
- if (
700
- collection?.sidebar?.mode === 'tree' &&
701
- (!collection.parentProperty || typeof collection.parentProperty !== 'string')
702
- ) {
703
- throw usageError(
704
- `Tree collection route "${route.slug}" must define collection.parentProperty.`,
705
- );
706
- }
707
- }
708
-
709
- const childrenByParent = new Map();
710
- for (const route of routes) {
711
- if (!route.parentSlug) {
712
- continue;
713
- }
714
- const children = childrenByParent.get(route.parentSlug) || [];
715
- children.push(route.slug);
716
- childrenByParent.set(route.parentSlug, children);
717
- }
718
-
719
- for (const route of routes) {
720
- const seen = new Set([route.slug]);
721
- let current = route.parentSlug || null;
722
- while (current) {
723
- if (seen.has(current)) {
724
- throw usageError(`Route parent cycle detected at "${route.slug}".`);
725
- }
726
- seen.add(current);
727
- current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
728
- }
729
- }
730
-
731
- for (const route of routes) {
732
- if (
733
- route.collection?.sidebar?.mode === 'tree' &&
734
- (childrenByParent.get(route.slug) || []).length > 0
735
- ) {
736
- throw usageError(
737
- `Tree collection route "${route.slug}" cannot also define static child routes.`,
738
- );
739
- }
740
- }
741
- }
742
-
743
- function resolveConfiguredRoutes(appConfig, projectDir) {
744
- const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
745
- validateConfiguredRoutes(configuredRoutes);
746
- const routes = configuredRoutes.map((route) => ({
747
- ...route,
748
- slug: route.slug,
749
- export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
750
- }));
751
- return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
752
- }
753
-
754
- /**
755
- * Generate the _entry.tsx file that re-exports each route's page component.
756
- */
757
- function generateEntryFile(projectDir, routes) {
758
- const entryDir = join(projectDir, NOTIS_DIR);
759
- mkdirSync(entryDir, { recursive: true });
760
-
761
- // Also re-export the layout if it exists
762
- const lines = [];
763
- const layoutPath = join(projectDir, 'app', 'layout.tsx');
764
- if (existsSync(layoutPath)) {
765
- lines.push(`export { default as __AppShell } from '../app/layout';`);
766
- }
767
-
768
- for (const route of routes) {
769
- const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
770
- lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
771
- }
772
-
773
- const entryPath = join(entryDir, '_entry.tsx');
774
- writeFileSync(entryPath, lines.join('\n') + '\n');
775
- return entryPath;
776
- }
777
-
778
- export async function prepareArtifactBuild(projectDir) {
779
- validateProjectBoundary(projectDir);
780
- const appConfig = await loadAppConfig(projectDir);
781
- const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
782
-
783
- generateEntryFile(projectDir, detectedRoutes);
784
-
785
- const manifest = generateManifest(appConfig, projectDir);
786
- const manifestPath = join(projectDir, MANIFEST_FILE);
787
- mkdirSync(dirname(manifestPath), { recursive: true });
788
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
789
-
790
- return { appConfig, manifest, routes: detectedRoutes };
791
- }
792
-
793
- /**
794
- * Generate the manifest from app config and build output.
795
- */
796
- export function generateManifest(appConfig, projectDir) {
797
- const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
798
- const entry = {
799
- path: route.path,
800
- slug: route.slug,
801
- name: route.name,
802
- icon: route.icon || null,
803
- parentSlug: route.parentSlug || null,
804
- default: route.default || false,
805
- resourceDeepLinks: route.resourceDeepLinks === true,
806
- export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
807
- collection: route.collection || null,
808
- };
809
- if (route.tool_access) {
810
- entry.tool_access = route.tool_access;
811
- }
812
- return entry;
813
- });
814
-
815
- // Database entries may be a bare slug (structure only) or an object opting
816
- // into shipping rows to installers. Normalize to the manifest's snake_case.
817
- const databases = (appConfig.databases || []).map((entry) => {
818
- if (typeof entry === 'string') return entry;
819
- if (!entry || typeof entry !== 'object' || !entry.slug) return entry;
820
- const seedDocuments = entry.seedDocuments ?? entry.seed_documents;
821
- return seedDocuments === true ? { slug: entry.slug, seed_documents: true } : entry.slug;
822
- });
823
- const categories = normalizeCategories(appConfig.categories || []);
824
- const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
825
- const appSlug = safeKebab(appConfig.name);
826
- const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
827
- const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
828
- key: skill.key,
829
- path: normalizeAppSkillManifestPath(skill.path),
830
- name: skill.name,
831
- description: skill.description || null,
832
- }));
833
- const listingMedia = {
834
- screenshots: metadata.screenshots.map((screenshot) => ({
835
- path: screenshot.path,
836
- content_type: screenshot.content_type,
837
- width: screenshot.width,
838
- height: screenshot.height,
839
- bytes: screenshot.bytes,
840
- alt: screenshot.alt || null,
841
- theme: screenshot.theme || 'light',
842
- })),
843
- };
844
- const changelog = readAppChangelog(projectDir);
845
- if (changelog.exists && changelog.errors.length > 0) {
846
- throw usageError(changelog.errors.join('\n'));
847
- }
848
- const latestChangelogEntry = changelog.entries[0] || null;
849
- const legacyVersionNotes = latestChangelogEntry?.body
850
- || appConfig.versionNotes
851
- || appConfig.version_notes
852
- || null;
853
- let releaseVersion = null;
854
- try {
855
- const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
856
- releaseVersion = String(packageJson.notisAppVersion || '').trim() || null;
857
- } catch {
858
- // Store readiness reports missing or malformed package metadata. Ordinary
859
- // development builds remain usable without a Store release version.
860
- }
861
-
862
- return {
863
- version: 1,
864
- spec_version: 4,
865
- app: {
866
- name: displayTitle,
867
- slug: appSlug || null,
868
- description: appConfig.description || null,
869
- icon: appConfig.icon || null,
870
- accent: appConfig.accent || null,
871
- title: displayTitle,
872
- tagline: appConfig.tagline || null,
873
- categories,
874
- author: appConfig.author || null,
875
- release_version: releaseVersion,
876
- version_notes: legacyVersionNotes,
877
- },
878
- listing: {
879
- title: displayTitle,
880
- tagline: appConfig.tagline || null,
881
- categories,
882
- author: appConfig.author || null,
883
- version_notes: legacyVersionNotes,
884
- ...(changelog.exists
885
- ? {
886
- changelog: {
887
- source_path: changelog.source_path,
888
- entries: changelog.entries,
889
- },
890
- }
891
- : {}),
892
- media: listingMedia,
893
- },
894
- metadata: listingMedia,
895
- routes,
896
- bundle: {
897
- js: 'bundle/app.js',
898
- css: 'bundle/app.css',
899
- },
900
- databases,
901
- capabilities: normalizeAppCapabilities(appConfig.capabilities),
902
- tools: appConfig.tools || [],
903
- tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
904
- skills,
905
- onboarding: appConfig.onboarding || null,
906
- };
907
- }
908
-
909
- /**
910
- * Keep the persisted app row's presentation metadata aligned with the bundle
911
- * manifest. The app slug remains the stable identity; title/name is only the
912
- * user-facing label.
913
- */
914
- export function appRowFieldsFromManifest(manifest) {
915
- const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
916
- const displayName = typeof app.title === 'string' && app.title.trim()
917
- ? app.title.trim()
918
- : typeof app.name === 'string' && app.name.trim()
919
- ? app.name.trim()
920
- : null;
921
- return {
922
- ...(displayName ? { name: displayName } : {}),
923
- accent: app.accent ?? null,
924
- };
925
- }
926
-
927
- export function normalizeAppToolBindings(bindings) {
928
- return (Array.isArray(bindings) ? bindings : [])
929
- .map((binding) => {
930
- const name = typeof binding?.name === 'string' ? binding.name.trim() : '';
931
- const providerToolName =
932
- typeof binding?.providerToolName === 'string' ? binding.providerToolName.trim() : '';
933
- return name && providerToolName
934
- ? { name, provider_tool_name: providerToolName }
935
- : null;
936
- })
937
- .filter(Boolean);
938
- }
939
-
940
- /**
941
- * Keeps only capabilities the platform actually understands, at the exact
942
- * values it accepts. An unknown key or value is dropped rather than passed
943
- * through, so a typo can never reach the server as a permission grant.
944
- */
945
- export function normalizeAppCapabilities(capabilities) {
946
- if (!capabilities || typeof capabilities !== 'object') {
947
- return {};
948
- }
949
- const normalized = {};
950
- if (capabilities.workspaceDatabases === 'read') {
951
- normalized.workspaceDatabases = 'read';
952
- }
953
- if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
954
- normalized.cloudComputer = capabilities.cloudComputer;
955
- }
956
- return normalized;
957
- }
958
-
959
- /**
960
- * Manifest form of a declared skill path: no leading `./`, no trailing slash.
961
- * A directory declaration is the same string as the source-tree prefix the
962
- * server matches the uploaded source files against.
963
- */
964
- export function normalizeAppSkillManifestPath(sourcePath) {
965
- return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
966
- }
967
-
968
- /**
969
- * Every packageable file under a declared skill directory, relative to that
970
- * directory, in stable order. Excludes match `readSourceFiles` so the files a
971
- * dev session sends inline are exactly the ones a deploy uploads as source.
972
- */
973
- function readAppSkillDirectoryFiles(skillDir) {
974
- const entries = [];
975
-
976
- function walk(dir, prefix) {
977
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
978
- if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
979
- continue;
980
- }
981
- const fullPath = join(dir, entry.name);
982
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
983
- if (entry.isDirectory()) {
984
- walk(fullPath, relPath);
985
- } else if (entry.isFile()) {
986
- entries.push({ path: relPath, absolutePath: fullPath });
987
- }
988
- }
989
- }
990
-
991
- walk(skillDir, '');
992
- // Byte order, not locale order: the server sorts the same file set the same
993
- // way before hashing it, so a dev session and a deploy agree on the hash.
994
- return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
995
- }
996
-
997
- export function resolveConfiguredAppSkills(appConfig, projectDir) {
998
- const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
999
- const projectRoot = resolve(projectDir);
1000
- const realProjectRoot = realpathSync(projectRoot);
1001
- const seenKeys = new Set();
1002
-
1003
- return configured.map((skill, index) => {
1004
- const key = typeof skill?.key === 'string' ? skill.key.trim() : '';
1005
- const sourcePath = typeof skill?.path === 'string' ? skill.path.trim() : '';
1006
- const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
1007
- if (!key || !sourcePath || !name) {
1008
- throw usageError(`skills[${index}] must define non-empty key, path, and name values.`);
1009
- }
1010
- if (seenKeys.has(key)) {
1011
- throw usageError(`Duplicate app skill key: ${key}`);
1012
- }
1013
- seenKeys.add(key);
1014
-
1015
- const absolutePath = resolve(projectRoot, sourcePath);
1016
- const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
1017
- if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
1018
- throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
1019
- }
1020
- if (!existsSync(absolutePath)) {
1021
- throw usageError(`App skill entrypoint not found: ${sourcePath}`);
1022
- }
1023
- if (lstatSync(absolutePath).isSymbolicLink()) {
1024
- throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
1025
- }
1026
- const realAbsolutePath = realpathSync(absolutePath);
1027
- const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
1028
- if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
1029
- throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
1030
- }
1031
-
1032
- const description = typeof skill.description === 'string' ? skill.description.trim() : null;
1033
- const stats = statSync(absolutePath);
1034
- if (stats.isDirectory()) {
1035
- const entries = readAppSkillDirectoryFiles(absolutePath);
1036
- if (!entries.some((entry) => entry.path === 'SKILL.md')) {
1037
- throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
1038
- }
1039
- const bundleFiles = entries.map((entry) => ({
1040
- path: entry.path,
1041
- content: readFileSync(entry.absolutePath),
1042
- }));
1043
- const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
1044
- if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
1045
- throw usageError(
1046
- `App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
1047
- );
1048
- }
1049
- const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
1050
- return {
1051
- key,
1052
- path: relativePath,
1053
- name,
1054
- description,
1055
- skill_md: skillMd.content.toString('utf8'),
1056
- bundle_files: bundleFiles.map((entry) => ({
1057
- path: entry.path,
1058
- content_b64: entry.content.toString('base64'),
1059
- })),
1060
- };
1061
- }
1062
- if (!stats.isFile()) {
1063
- throw usageError(`App skill entrypoint not found: ${sourcePath}`);
1064
- }
1065
-
1066
- return {
1067
- key,
1068
- path: relativePath,
1069
- name,
1070
- description,
1071
- skill_md: readFileSync(absolutePath, 'utf8'),
1072
- };
1073
- });
1074
- }
1075
-
1076
- /**
1077
- * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
300
+ * Build a bundle in place: `prepare` writes the entry file and manifest, the
301
+ * build script packages into .notis/output/, then the build receipt is saved.
302
+ * Space builds supply `prepare` (see space-platform.js).
1078
303
  */
1079
- export async function buildArtifact(projectDir, { stdio = 'inherit' } = {}) {
1080
- await prepareArtifactBuild(projectDir);
1081
-
1082
- // Run Vite build
1083
- await runProjectScript({
1084
- projectDir,
1085
- scriptName: 'build',
1086
- stdio,
1087
- });
304
+ export async function buildArtifact(projectDir, { stdio = 'inherit', prepare,
305
+ finalize = null, executable = true, refreshSdk = true, runBuild = runProjectScript } = {}) {
306
+ if (typeof prepare !== 'function') {
307
+ throw new TypeError('buildArtifact needs a prepare step that writes the entry file and manifest.');
308
+ }
309
+ projectDir = realpathSync(assertSourceRoot(projectDir));
310
+ const workspace = withAppReleaseWorkspace(projectDir, (identity) => {
311
+ rmSync('build-receipt.json', { force: true });
312
+ return identity;
313
+ }, { create: true });
314
+ // SDK refresh is intentional source preparation, before the immutable build receipt.
315
+ if (refreshSdk) syncEmbeddedSdk(projectDir);
316
+ const sourceHash = appFilesDigest(collectSourceFiles(projectDir));
317
+ await prepare(projectDir);
318
+
319
+ // Historical source must round-trip byte-for-byte. Supply the compatible
320
+ // config loader at execution for the old canonical script, without editing
321
+ // pulled package.json files or interpreting custom shell commands.
322
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
323
+ const args = String(pkg.scripts?.build || '').trim() === 'vite build'
324
+ ? ['--configLoader', 'runner']
325
+ : [];
326
+ // runBuild defaults to the PATH npm script. A confined caller supplies a pinned runner instead.
327
+ if (executable) await runBuild({ projectDir, scriptName: 'build', args, stdio });
328
+
329
+ withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
1088
330
 
1089
331
  // Verify the canonical `.notis/output/bundle` packaging contract.
1090
332
  const builtBundleDir = resolveBuiltBundleDir(projectDir);
1091
- if (!builtBundleDir) {
333
+ if (executable && !builtBundleDir) {
1092
334
  throw usageError(
1093
335
  'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
1094
336
  );
1095
337
  }
1096
338
  normalizeBundleStylesheets(projectDir);
1097
- copyMetadataAssets(projectDir);
339
+ if (finalize) await finalize(projectDir);
340
+ withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
1098
341
 
1099
342
  validateArtifactBoundary(readArtifactFiles(projectDir));
1100
343
 
1101
344
  const manifest = readManifest(projectDir);
1102
345
 
346
+ if (sourceHash !== appFilesDigest(collectSourceFiles(projectDir))) {
347
+ throw usageError('App source changed during the build. Build again before deploying.');
348
+ }
349
+ const receipt = JSON.stringify({
350
+ source_hash: sourceHash,
351
+ artifact_hash: appFilesDigest(collectArtifactFiles(projectDir)),
352
+ });
353
+ withAppReleaseWorkspace(projectDir, () => {
354
+ const temporary = `.build-receipt-${randomUUID()}`;
355
+ try {
356
+ writeFileSync(temporary, receipt, { flag: 'wx' });
357
+ renameSync(temporary, 'build-receipt.json');
358
+ } finally { rmSync(temporary, { force: true }); }
359
+ }, { expected: workspace });
1103
360
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
1104
361
  }
1105
362
 
1106
363
  /**
1107
364
  * Read the manifest from a built project.
1108
- */
1109
- export function readManifest(projectDir) {
1110
- const manifestPath = join(projectDir, MANIFEST_FILE);
1111
- if (!existsSync(manifestPath)) {
1112
- throw usageError('No manifest found. Run "notis apps build" first.');
1113
- }
1114
- return JSON.parse(readFileSync(manifestPath, 'utf-8'));
1115
- }
1116
-
1117
- function copyMetadataAssets(projectDir) {
1118
- const metadataDir = join(projectDir, METADATA_DIR);
1119
- const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
1120
- // Vite clears the bundle directory, not sibling listing media. Always make
1121
- // the packaged metadata an exact reflection of the source tree so removed
1122
- // placeholders can never survive into a later deploy.
1123
- rmSync(outputMetadataDir, { recursive: true, force: true });
1124
- if (!existsSync(metadataDir)) {
1125
- return;
1126
- }
1127
- mkdirSync(outputMetadataDir, { recursive: true });
1128
- for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
1129
- if (!entry.isFile()) continue;
1130
- if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
1131
- continue;
1132
- }
1133
- cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
1134
- }
1135
- }
1136
-
1137
- // ---------------------------------------------------------------------------
1138
- // Linking
1139
- // ---------------------------------------------------------------------------
1140
-
1141
- const PROFILE_LINK_FIELDS = new Set([
1142
- 'app_id',
1143
- 'linked_at',
1144
- 'deployed_at',
1145
- 'version',
1146
- 'dev_app_id',
1147
- 'dev_linked_at',
1148
- 'auto_linked_at',
1149
- 'cloud_computer_shell_consent',
1150
- ]);
1151
-
1152
- function splitLinkedState(state) {
1153
- const base = {};
1154
- const link = {};
1155
- for (const [key, value] of Object.entries(state || {})) {
1156
- if (key === 'profiles') continue;
1157
- (PROFILE_LINK_FIELDS.has(key) ? link : base)[key] = value;
1158
- }
1159
- return { base, link };
1160
- }
1161
-
1162
- export function appLinkedStateProfileKey({ apiBase, userId }) {
1163
- const normalizedApi = String(apiBase || '').trim().replace(/\/$/, '');
1164
- const normalizedUser = String(userId || '').trim();
1165
- if (!normalizedApi || !normalizedUser) return null;
1166
- return Buffer.from(`${normalizedApi}\0${normalizedUser}`).toString('base64url');
1167
- }
1168
-
1169
- export function readLinkedState(projectDir, profileKey = null) {
1170
- const statePath = join(projectDir, STATE_FILE);
1171
- assertLinkedStatePathSafe(projectDir);
1172
- if (!existsSync(statePath)) return null;
1173
- const raw = JSON.parse(readFileSync(statePath, 'utf-8'));
1174
- if (!profileKey) return raw;
1175
- const { base, link: legacyLink } = splitLinkedState(raw);
1176
- const profiles = raw?.profiles && typeof raw.profiles === 'object' ? raw.profiles : {};
1177
- const scoped = profiles[profileKey];
1178
- return {
1179
- ...base,
1180
- ...(scoped && typeof scoped === 'object' ? scoped : legacyLink),
1181
- };
1182
- }
1183
-
1184
- function readStateWriteLockOwner(lockPath) {
1185
- try {
1186
- return readFileSync(join(lockPath, 'owner'), 'utf8').trim();
1187
- } catch {
1188
- return null;
1189
- }
1190
- }
1191
-
1192
- function reclaimStaleStateWriteLock(lockPath, observedOwner, observedMtimeMs) {
1193
- const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
1194
- try {
1195
- renameSync(lockPath, quarantinePath);
1196
- } catch (error) {
1197
- if (error?.code === 'ENOENT') return false;
1198
- throw error;
1199
- }
1200
-
1201
- let unchanged = false;
1202
- try {
1203
- const quarantinedStat = lstatSync(quarantinePath);
1204
- unchanged = readStateWriteLockOwner(quarantinePath) === observedOwner
1205
- && quarantinedStat.mtimeMs === observedMtimeMs;
1206
- } catch (error) {
1207
- try {
1208
- renameSync(quarantinePath, lockPath);
1209
- } catch {
1210
- // Fail closed below if the quarantined lock cannot be restored.
1211
- }
1212
- throw error;
1213
- }
1214
-
1215
- if (!unchanged) {
1216
- try {
1217
- renameSync(quarantinePath, lockPath);
1218
- } catch {
1219
- throw usageError(`Notis app state lock changed while it was being recovered: ${lockPath}`);
1220
- }
1221
- return false;
1222
- }
1223
-
1224
- rmSync(quarantinePath, { recursive: true, force: true });
1225
- return true;
1226
- }
1227
-
1228
- function withStateWriteLock(statePath, callback) {
1229
- mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
1230
- const lockPath = `${statePath}.write-lock`;
1231
- const ownerId = `${process.pid}.${randomUUID()}`;
1232
- const startedAt = Date.now();
1233
- while (true) {
1234
- try {
1235
- mkdirSync(lockPath, { mode: 0o700 });
1236
- try {
1237
- writeFileSync(join(lockPath, 'owner'), ownerId, { mode: 0o600, flag: 'wx' });
1238
- } catch (error) {
1239
- // A stale-lock recovery may have moved this candidate while the owner
1240
- // record was being written. Never remove a replacement writer's lock.
1241
- if (readStateWriteLockOwner(lockPath) === ownerId) {
1242
- rmSync(lockPath, { recursive: true, force: true });
1243
- }
1244
- throw error;
1245
- }
1246
- break;
1247
- } catch (error) {
1248
- if (error?.code !== 'EEXIST') throw error;
1249
- try {
1250
- const lockStat = lstatSync(lockPath);
1251
- if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
1252
- throw usageError(`Refusing unsafe Notis app state lock: ${lockPath}`);
1253
- }
1254
- const observedOwner = readStateWriteLockOwner(lockPath);
1255
- const ownerPidText = String(observedOwner || '').split('.')[0];
1256
- const ownerPid = /^\d+$/.test(ownerPidText) ? Number.parseInt(ownerPidText, 10) : null;
1257
- // PID liveness alone is insufficient: operating systems can reuse a
1258
- // crashed writer's PID for an unrelated process. A linked-state write
1259
- // is synchronous and normally holds this lock only for milliseconds,
1260
- // so an aged lock is stale even when that numeric PID now exists.
1261
- let stale = Date.now() - lockStat.mtimeMs >= STATE_WRITE_LOCK_STALE_MS;
1262
- if (!stale && Number.isInteger(ownerPid) && ownerPid > 0) {
1263
- try {
1264
- process.kill(ownerPid, 0);
1265
- } catch (ownerError) {
1266
- if (ownerError?.code === 'ESRCH') {
1267
- stale = true;
1268
- }
1269
- }
1270
- }
1271
- if (stale && reclaimStaleStateWriteLock(lockPath, observedOwner, lockStat.mtimeMs)) {
1272
- continue;
1273
- }
1274
- } catch (statError) {
1275
- if (statError?.code === 'ENOENT') continue;
1276
- throw statError;
1277
- }
1278
- if (Date.now() - startedAt >= STATE_WRITE_LOCK_TIMEOUT_MS) {
1279
- throw usageError(`Timed out waiting to update Notis app state: ${statePath}`);
1280
- }
1281
- Atomics.wait(stateWriteLockWait, 0, 0, 10);
1282
- }
1283
- }
1284
- try {
1285
- return callback();
1286
- } finally {
1287
- try {
1288
- if (readStateWriteLockOwner(lockPath) === ownerId) {
1289
- rmSync(lockPath, { recursive: true, force: true });
1290
- }
1291
- } catch {
1292
- // Never remove a lock that no longer belongs to this writer.
1293
- }
1294
- }
1295
- }
1296
-
1297
- export function writeLinkedState(projectDir, state, profileKey = null) {
1298
- const statePath = join(projectDir, STATE_FILE);
1299
- assertLinkedStatePathSafe(projectDir);
1300
- return withStateWriteLock(statePath, () => {
1301
- let nextState = state;
1302
- if (profileKey) {
1303
- let current = {};
1304
- if (existsSync(statePath)) {
1305
- current = JSON.parse(readFileSync(statePath, 'utf-8'));
1306
- }
1307
- const { base: currentBase } = splitLinkedState(current);
1308
- const { base } = splitLinkedState(state);
1309
- const { link } = splitLinkedState(state);
1310
- const profiles = current?.profiles && typeof current.profiles === 'object'
1311
- ? { ...current.profiles }
1312
- : {};
1313
- profiles[profileKey] = link;
1314
- nextState = { ...currentBase, ...base, profiles };
1315
- }
1316
- const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1317
- writeFileSync(temporary, JSON.stringify(nextState, null, 2) + '\n');
1318
- renameSync(temporary, statePath);
1319
- });
1320
- }
1321
-
1322
- function assertLinkedStatePathSafe(projectDir) {
1323
- const notisDir = join(projectDir, NOTIS_DIR);
1324
- try {
1325
- const directoryStat = lstatSync(notisDir);
1326
- if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1327
- throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
1328
- }
1329
- } catch (error) {
1330
- if (error?.code !== 'ENOENT') throw error;
1331
- }
1332
-
1333
- const statePath = join(projectDir, STATE_FILE);
1334
- try {
1335
- const stateStat = lstatSync(statePath);
1336
- if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
1337
- throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
1338
- }
1339
- } catch (error) {
1340
- if (error?.code !== 'ENOENT') throw error;
365
+ */
366
+ export function readManifest(projectDir) {
367
+ const manifestPath = join(projectDir, MANIFEST_FILE);
368
+ if (!existsSync(manifestPath)) {
369
+ throw usageError('No manifest found. Run "notis spaces build" first.');
1341
370
  }
1342
- }
1343
-
1344
- export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
1345
- if (explicitAppId) return explicitAppId;
1346
- const state = readLinkedState(projectDir, profileKey);
1347
- if (state?.app_id) return state.app_id;
1348
- throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
371
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
1349
372
  }
1350
373
 
1351
374
  // ---------------------------------------------------------------------------
@@ -1353,31 +376,17 @@ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null)
1353
376
  // ---------------------------------------------------------------------------
1354
377
 
1355
378
  /**
1356
- * Scaffold a new Notis app project from the SDK template or from a published
1357
- * Store app downloaded from the public registry repository.
379
+ * Scaffold a new project from the SDK template.
1358
380
  */
1359
- export async function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1360
- let scaffoldSource = null;
1361
- if (fromSlug) {
1362
- scaffoldSource = await acquireScaffoldSource(fromSlug);
1363
- if (!scaffoldSource) {
1364
- const known = (await loadScaffoldCatalog()).map((entry) => entry.slug).join(', ') || 'none';
1365
- throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
1366
- }
1367
- }
1368
- try {
1369
- return scaffoldProjectFromDir({
1370
- projectDir,
1371
- appName,
1372
- fromSlug,
1373
- templateDir: scaffoldSource ? scaffoldSource.dir : join(CLI_ROOT, 'template'),
1374
- });
1375
- } finally {
1376
- scaffoldSource?.cleanup();
1377
- }
381
+ export async function scaffoldProject({ projectDir, appName }) {
382
+ return scaffoldProjectFromDir({
383
+ projectDir,
384
+ appName,
385
+ templateDir: join(CLI_ROOT, 'template'),
386
+ });
1378
387
  }
1379
388
 
1380
- function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir }) {
389
+ function scaffoldProjectFromDir({ projectDir, appName, templateDir }) {
1381
390
  const sourceDir = resolve(templateDir);
1382
391
  const requestedProjectDir = resolve(projectDir);
1383
392
  if (!existsSync(sourceDir)) {
@@ -1404,14 +413,14 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1404
413
  assertPullTargetIdentity('.', parentIdentity);
1405
414
  const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
1406
415
  if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
1407
- throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
416
+ throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
1408
417
  }
1409
418
  if (!lockedTargetIdentity.exists) {
1410
419
  try {
1411
420
  mkdirSync(targetName);
1412
421
  } catch (error) {
1413
422
  if (error?.code === 'EEXIST') {
1414
- throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
423
+ throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
1415
424
  }
1416
425
  throw error;
1417
426
  }
@@ -1422,7 +431,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1422
431
  activeTargetIdentity,
1423
432
  captureScaffoldTargetIdentity('.'),
1424
433
  )) {
1425
- throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
434
+ throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
1426
435
  }
1427
436
  mkdirSync(scaffoldLockName, { mode: 0o700 });
1428
437
  writeFileSync(
@@ -1441,55 +450,11 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1441
450
  if (existsSync(pkgPath)) {
1442
451
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1443
452
  pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
1444
- // A scaffold creates a new app identity, even when its source comes from a
1445
- // versioned Store example. New apps must therefore start their own release
1446
- // history instead of inheriting the example app's registry version.
1447
- pkg.notisAppVersion = '0.1.0';
1448
- normalizeScaffoldPackageScripts(pkg);
1449
453
  ensureScaffoldLocalSdk(projectDir, pkg);
1450
454
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1451
455
  normalizeScaffoldLockfile(projectDir, pkg);
1452
456
  }
1453
457
 
1454
- // Update notis.config.ts with the app name
1455
- const configPath = join(projectDir, 'notis.config.ts');
1456
- if (existsSync(configPath)) {
1457
- let config = readFileSync(configPath, 'utf-8');
1458
- const displayName = jsStringLiteral(appName);
1459
- const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1460
- if (fromSlug) {
1461
- if (/name\s*:/.test(config)) {
1462
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1463
- }
1464
- if (/title\s*:/.test(config)) {
1465
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1466
- } else {
1467
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
1468
- }
1469
- } else {
1470
- if (/name\s*:/.test(config)) {
1471
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1472
- }
1473
- if (/title\s*:/.test(config)) {
1474
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1475
- } else {
1476
- config = config.replace(/'My Notis App'/, displayName);
1477
- }
1478
- }
1479
- if (/devSlug\s*:/.test(config)) {
1480
- config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
1481
- } else {
1482
- config = config.replace(
1483
- /name\s*:\s*(['"`])[\s\S]*?\1/,
1484
- (nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
1485
- );
1486
- }
1487
- config = removeConfigArrayProperty(config, 'screenshots');
1488
- writeFileSync(configPath, config);
1489
- }
1490
-
1491
- resetScaffoldChangelog(projectDir, appName);
1492
-
1493
458
  assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1494
459
  assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1495
460
  const stageRoot = realpathSync(stageName);
@@ -1521,7 +486,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1521
486
  installedDirectory,
1522
487
  capturePullTargetIdentity('.'),
1523
488
  )) {
1524
- throw usageError(`App scaffold destination changed during installation: ${relativePath}`);
489
+ throw usageError(`Scaffold destination changed during installation: ${relativePath}`);
1525
490
  }
1526
491
  try {
1527
492
  for (const name of readdirSync(sourcePath)) {
@@ -1541,7 +506,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1541
506
  parentIdentity,
1542
507
  capturePullTargetIdentity('.'),
1543
508
  )) {
1544
- throw usageError(`App scaffold destination changed during installation: ${parentRelativePath || '.'}`);
509
+ throw usageError(`Scaffold destination changed during installation: ${parentRelativePath || '.'}`);
1545
510
  }
1546
511
  }
1547
512
  return;
@@ -1611,7 +576,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1611
576
  } catch (error) {
1612
577
  cleanupStage = false;
1613
578
  throw usageError(
1614
- `App scaffold was installed, but staging cleanup failed; recovery files were retained at `
579
+ `Scaffold was installed, but staging cleanup failed; recovery files were retained at `
1615
580
  + `${join(requestedProjectDir, stageName)}: `
1616
581
  + `${error instanceof Error ? error.message : String(error)}`,
1617
582
  );
@@ -1655,26 +620,6 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1655
620
  return { projectDir: requestedProjectDir };
1656
621
  }
1657
622
 
1658
- function normalizeScaffoldPackageScripts(pkg) {
1659
- const scripts = pkg?.scripts;
1660
- if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
1661
- return;
1662
- }
1663
- const generateEntry = String(scripts['generate-entry'] || '').trim();
1664
- if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
1665
- return;
1666
- }
1667
- for (const [name, command] of Object.entries(scripts)) {
1668
- if (name === 'generate-entry' || typeof command !== 'string') {
1669
- continue;
1670
- }
1671
- if (command.trim() === 'npm run generate-entry') {
1672
- delete scripts[name];
1673
- }
1674
- }
1675
- scripts['generate-entry'] = 'node -e ""';
1676
- }
1677
-
1678
623
  function captureScaffoldTargetIdentity(targetDir) {
1679
624
  try {
1680
625
  const targetStat = lstatSync(targetDir, { bigint: true });
@@ -1682,7 +627,7 @@ function captureScaffoldTargetIdentity(targetDir) {
1682
627
  throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
1683
628
  }
1684
629
  if (readdirSync(targetDir).length > 0) {
1685
- throw usageError(`App scaffold target must be empty: ${targetDir}`);
630
+ throw usageError(`Scaffold target must be empty: ${targetDir}`);
1686
631
  }
1687
632
  return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
1688
633
  } catch (error) {
@@ -1691,76 +636,6 @@ function captureScaffoldTargetIdentity(targetDir) {
1691
636
  }
1692
637
  }
1693
638
 
1694
- /**
1695
- * Drop `property: [ ... ]` from a notis.config.ts source.
1696
- *
1697
- * The scan tracks bracket depth while skipping string literals, so entries
1698
- * whose text contains a bracket (alt text, selectors) cannot end the array
1699
- * early. When the array cannot be resolved the source is returned untouched --
1700
- * a stale screenshots list is a warning at verify time, a broken config is not.
1701
- */
1702
- function removeConfigArrayProperty(source, property) {
1703
- const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
1704
- if (start === -1) {
1705
- return source;
1706
- }
1707
- let index = source.indexOf('[', start);
1708
- let depth = 0;
1709
- let quote = null;
1710
- for (; index < source.length; index += 1) {
1711
- const char = source[index];
1712
- if (quote) {
1713
- if (char === '\\') {
1714
- index += 1;
1715
- } else if (char === quote) {
1716
- quote = null;
1717
- }
1718
- continue;
1719
- }
1720
- if (char === '\'' || char === '"' || char === '`') {
1721
- quote = char;
1722
- continue;
1723
- }
1724
- if (char === '[') {
1725
- depth += 1;
1726
- } else if (char === ']') {
1727
- depth -= 1;
1728
- if (depth === 0) {
1729
- break;
1730
- }
1731
- }
1732
- }
1733
- if (depth !== 0) {
1734
- return source;
1735
- }
1736
- let end = index + 1;
1737
- if (source[end] === ',') {
1738
- end += 1;
1739
- }
1740
- while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
1741
- end += 1;
1742
- }
1743
- if (source[end] === '\n') {
1744
- end += 1;
1745
- }
1746
- return source.slice(0, start) + source.slice(end);
1747
- }
1748
-
1749
- /**
1750
- * A new project starts its own release history: the scaffold's entries describe
1751
- * releases of a different app.
1752
- */
1753
- function resetScaffoldChangelog(projectDir, appName) {
1754
- const changelogPath = join(projectDir, 'CHANGELOG.md');
1755
- if (!existsSync(changelogPath)) {
1756
- return;
1757
- }
1758
- writeFileSync(
1759
- changelogPath,
1760
- `# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
1761
- );
1762
- }
1763
-
1764
639
  function normalizeScaffoldLockfile(projectDir, pkg) {
1765
640
  const lockPath = join(projectDir, 'package-lock.json');
1766
641
  if (!existsSync(lockPath)) {
@@ -1790,21 +665,315 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
1790
665
  }
1791
666
 
1792
667
  const localSdkDir = join(projectDir, 'packages', 'sdk');
1793
- if (existsSync(join(localSdkDir, 'package.json'))) {
1794
- return;
1795
- }
1796
668
  if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
1797
669
  throw usageError(`SDK template not found at ${TEMPLATE_SDK_DIR}. Ensure @notis_ai/cli is installed correctly.`);
1798
670
  }
1799
671
 
1800
672
  mkdirSync(dirname(localSdkDir), { recursive: true });
1801
- cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
673
+ cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true,
674
+ filter: (source) => !HOST_ONLY_SDK_FILES.includes(relative(TEMPLATE_SDK_DIR, source).split(sep).join('/')) });
675
+ }
676
+
677
+ function listFilesRecursive(dir, base = dir, results = []) {
678
+ if (!existsSync(dir)) return results;
679
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
680
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
681
+ const fullPath = join(dir, entry.name);
682
+ if (entry.isDirectory()) {
683
+ listFilesRecursive(fullPath, base, results);
684
+ } else {
685
+ results.push(relative(base, fullPath).split(sep).join('/'));
686
+ }
687
+ }
688
+ return results;
689
+ }
690
+
691
+ /**
692
+ * Keep an app's embedded public SDK identical to the SDK this CLI ships,
693
+ * excluding host-only mounting code. The copy is a mirror by contract (the parity test in the CLI keeps
694
+ * the bundled artifact generated from packages/sdk), so an app never edits it; before
695
+ * this sync existed every app silently ran whatever SDK snapshot it was
696
+ * scaffolded with and never received hook or style updates.
697
+ *
698
+ * Files that exist locally but not in the template are left alone with a
699
+ * warning so a deliberate local addition is never destroyed. The refresh owns
700
+ * only packages/sdk/{package.json,tsconfig.json,src/<template files>} and the
701
+ * exact retired copies of a real source; a folder without a source marker
702
+ * (the Notis repository's own packages/sdk, for one) is refused untouched.
703
+ *
704
+ * `prune` (Spaces) makes the mirror exact instead: every owned file the
705
+ * template does not ship (packages/sdk/tsconfig.json, src/* and dist/*, the
706
+ * Space SDK's Node test runtime) is removed.
707
+ */
708
+ export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPLATE_SDK_DIR, retiredFiles = RETIRED_SDK_FILES, prune = false } = {}) {
709
+ const canonicalProjectDir = realpathSync(assertSourceRoot(projectDir));
710
+ const canonicalTemplateDir = resolve(templateSdkDir);
711
+ if (!existsSync(join(canonicalTemplateDir, 'package.json'))) {
712
+ return { updated: false, reason: 'no-template-sdk' };
713
+ }
714
+ const previousCwd = process.cwd();
715
+ const previousIdentity = capturePullTargetIdentity(previousCwd);
716
+ const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
717
+ // listFilesRecursive skips every dist/ folder; an exact mirror owns the top-level one.
718
+ const listOwned = (dir) => [...listFilesRecursive(dir),
719
+ ...(prune ? listFilesRecursive(join(dir, 'dist')).map((relPath) => `dist/${relPath}`) : [])];
720
+ const templateFiles = listOwned(canonicalTemplateDir).filter(
721
+ (relPath) => !HOST_ONLY_SDK_FILES.includes(relPath)
722
+ && (relPath === 'package.json' || relPath === 'tsconfig.json' || relPath.startsWith('src/') || relPath.startsWith('dist/')),
723
+ );
724
+ const changed = [];
725
+ let foreign = [];
726
+
727
+ // Pin each app-controlled parent as cwd before touching a child. Absolute
728
+ // path writes would follow a replaced packages/sdk or src directory link.
729
+ function inDirectory(name, callback, { create = false } = {}) {
730
+ const parentIdentity = capturePullTargetIdentity('.');
731
+ let identity = capturePullTargetIdentity(name);
732
+ if (!identity.exists) {
733
+ if (!create) return { updated: false, reason: 'no-embedded-sdk' };
734
+ mkdirSync(name);
735
+ identity = capturePullTargetIdentity(name);
736
+ }
737
+ process.chdir(name);
738
+ try {
739
+ assertPullTargetIdentity('.', identity);
740
+ return callback();
741
+ } finally {
742
+ process.chdir('..');
743
+ assertPullTargetIdentity('.', parentIdentity);
744
+ }
745
+ }
746
+
747
+ function mirrorFile(segments, source) {
748
+ if (segments.length > 1) {
749
+ return inDirectory(segments[0], () => mirrorFile(segments.slice(1), source), { create: true });
750
+ }
751
+ const name = segments[0];
752
+ let before;
753
+ try { before = lstatSync(name, { bigint: true }); }
754
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
755
+ if (before) {
756
+ if (before.isSymbolicLink() || !before.isFile()) {
757
+ throw usageError(`Refusing to refresh the SDK through an unsafe target: ${name}`);
758
+ }
759
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
760
+ try {
761
+ if (Buffer.compare(readStablePinnedFile(descriptor, before, `SDK file ${name}`), source) === 0) return false;
762
+ } finally { closeSync(descriptor); }
763
+ }
764
+ // Replace, never truncate: an app-controlled hard link must not modify
765
+ // another file. O_EXCL staging and rename are relative to the pinned cwd.
766
+ const temporary = `.notis-sdk-sync-${randomUUID()}`;
767
+ try {
768
+ writeFileSync(temporary, source, { flag: 'wx' });
769
+ renameSync(temporary, name);
770
+ } finally { rmSync(temporary, { force: true }); }
771
+ return true;
772
+ }
773
+
774
+ function retireFile(segments, digests) {
775
+ if (segments.length > 1) return inDirectory(segments[0], () => retireFile(segments.slice(1), digests));
776
+ const name = segments[0];
777
+ let before;
778
+ try { before = lstatSync(name, { bigint: true }); }
779
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
780
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Refusing an unsafe retired SDK target: ${name}`);
781
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
782
+ try {
783
+ const bytes = readStablePinnedFile(descriptor, before, `Retired SDK file ${name}`);
784
+ if (!digests.includes(createHash('sha256').update(bytes).digest('hex'))) return false;
785
+ // Capture the exact directory entry before removing anything. A newly
786
+ // created replacement at the original path is never overwritten.
787
+ const temporary = `.notis-sdk-retired-${randomUUID()}`;
788
+ renameSync(name, temporary);
789
+ try {
790
+ const captured = lstatSync(temporary, { bigint: true });
791
+ if (captured.dev !== before.dev || captured.ino !== before.ino) {
792
+ throw usageError(`SDK file changed during retirement: ${name}`);
793
+ }
794
+ const capturedDescriptor = openSync(temporary, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
795
+ try {
796
+ if (!readStablePinnedFile(capturedDescriptor, captured, `Retired SDK file ${name}`).equals(bytes)) {
797
+ throw usageError(`SDK file changed during retirement: ${name}`);
798
+ }
799
+ } finally { closeSync(capturedDescriptor); }
800
+ unlinkSync(temporary);
801
+ } catch (error) {
802
+ // O_EXCL hard-link restoration preserves an independently-created
803
+ // replacement as well as the captured local bytes.
804
+ try { linkSync(temporary, name); unlinkSync(temporary); } catch { /* Retain the captured file for reconciliation. */ }
805
+ throw error;
806
+ }
807
+ return true;
808
+ } finally { closeSync(descriptor); }
809
+ }
810
+
811
+ // Unlink one directory entry relative to the pinned cwd; never follows a link.
812
+ function removeFile(segments) {
813
+ if (segments.length > 1) return inDirectory(segments[0], () => removeFile(segments.slice(1)));
814
+ try { unlinkSync(segments[0]); return true; }
815
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
816
+ }
817
+
818
+ function removeEmptyDirectories() {
819
+ for (const entry of readdirSync('.', { withFileTypes: true })) {
820
+ if (!entry.isDirectory()) continue;
821
+ inDirectory(entry.name, removeEmptyDirectories);
822
+ try { rmdirSync(entry.name); }
823
+ catch (error) { if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST' && error.code !== 'ENOENT') throw error; }
824
+ }
825
+ }
826
+
827
+ try {
828
+ process.chdir(canonicalProjectDir);
829
+ assertPullTargetIdentity('.', projectIdentity);
830
+ const result = inDirectory('packages', () => inDirectory('sdk', () => {
831
+ let packageStat;
832
+ try { packageStat = lstatSync('package.json'); }
833
+ catch (error) {
834
+ if (error.code === 'ENOENT') return { updated: false, reason: 'no-embedded-sdk' };
835
+ throw error;
836
+ }
837
+ if (packageStat.isSymbolicLink() || !packageStat.isFile()) {
838
+ throw usageError('Refusing to refresh the SDK through an unsafe package.json target.');
839
+ }
840
+ for (const relPath of templateFiles) {
841
+ const source = readFileSync(join(canonicalTemplateDir, relPath));
842
+ if (mirrorFile(relPath.split('/'), source)) changed.push(relPath);
843
+ }
844
+ const templateSet = new Set(templateFiles);
845
+ for (const [relPath, digests] of Object.entries(retiredFiles)) {
846
+ if (!templateSet.has(relPath) && retireFile(relPath.split('/'), digests) === true) changed.push(relPath);
847
+ }
848
+ foreign = listOwned('.').filter((relPath) => (relPath.startsWith('src/') || (prune && (relPath === 'tsconfig.json' || relPath.startsWith('dist/'))))
849
+ && !templateSet.has(relPath));
850
+ if (prune) {
851
+ for (const relPath of foreign) {
852
+ if (removeFile(relPath.split('/'))) changed.push(relPath);
853
+ }
854
+ foreign = [];
855
+ inDirectory('src', removeEmptyDirectories);
856
+ inDirectory('dist', removeEmptyDirectories);
857
+ try { rmdirSync('dist'); }
858
+ catch (error) { if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST' && error.code !== 'ENOENT') throw error; }
859
+ }
860
+ return null;
861
+ }));
862
+ if (result) return result;
863
+ assertPullTargetIdentity('.', projectIdentity);
864
+ } finally {
865
+ process.chdir(previousCwd);
866
+ assertPullTargetIdentity('.', previousIdentity);
867
+ }
868
+
869
+ const templateVersion = JSON.parse(readFileSync(join(canonicalTemplateDir, 'package.json'), 'utf-8')).version;
870
+ const emit = typeof log === 'function' ? log : (message) => process.stderr.write(`${message}\n`);
871
+ if (changed.length > 0) {
872
+ emit(`Updated embedded @notis/sdk to ${templateVersion} (${changed.length} file${changed.length === 1 ? '' : 's'}).`);
873
+ }
874
+ for (const relPath of foreign) {
875
+ emit(`Warning: packages/sdk/${relPath} is not part of the @notis/sdk template and was left unchanged.`);
876
+ }
877
+ return { updated: changed.length > 0, changed, foreign, templateVersion };
878
+ }
879
+
880
+ /**
881
+ * A Space's packages/sdk is exactly the package this CLI ships for Spaces
882
+ * (dist/space-sdk): host-module types, the build-time and pure entries, and a
883
+ * Node-only runtime for the Space's own tests. A Space view never loads SDK
884
+ * code of its own, so the mirror is pruned, not merged.
885
+ */
886
+ export function syncSpaceSdk(projectDir, { log = null, templateSdkDir = SPACE_SDK_DIR, updateLock = updatePackageLock } = {}) {
887
+ if (!existsSync(join(templateSdkDir, 'package.json'))) {
888
+ throw usageError(`Space SDK not found at ${templateSdkDir}. Ensure @notis_ai/cli is installed correctly.`);
889
+ }
890
+ const result = syncEmbeddedSdk(projectDir, { log, templateSdkDir, retiredFiles: {}, prune: true });
891
+ if (result.reason) return result;
892
+ const manifest = JSON.parse(readFileSync(join(templateSdkDir, 'package.json'), 'utf-8'));
893
+ return { ...result, lockfile: reconcileSdkLockfile(realpathSync(projectDir), { manifest, log, updateLock }) };
894
+ }
895
+
896
+ // The package.json fields npm records for a linked package. `npm ci` checks a
897
+ // lock against the linked package.json, so a changed SDK dependency makes it
898
+ // refuse a lock written for the previous SDK.
899
+ const LOCKED_MANIFEST_FIELDS = ['name', 'version', 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta'];
900
+
901
+ function canonicalJson(value) {
902
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
903
+ if (value && typeof value === 'object') {
904
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
905
+ }
906
+ return JSON.stringify(value ?? null);
907
+ }
908
+
909
+ /** True when a lock entry records exactly this manifest (an absent map equals an empty one). */
910
+ export function lockRecordsManifest(entry, manifest) {
911
+ if (!entry || typeof entry !== 'object') return false;
912
+ return LOCKED_MANIFEST_FIELDS.every((field) => {
913
+ const absent = field === 'name' || field === 'version' ? null : {};
914
+ return canonicalJson(entry[field] ?? absent) === canonicalJson(manifest[field] ?? absent);
915
+ });
916
+ }
917
+
918
+ // The lock only: no install scripts, node_modules untouched. One quick retry
919
+ // keeps an offline machine from waiting out npm's minute-long default backoff,
920
+ // and npm still answers from its cache when the network is down.
921
+ const LOCK_UPDATE_ARGS = Object.freeze(['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund',
922
+ '--fetch-retries=1', '--fetch-retry-mintimeout=1000']);
923
+
924
+ function updatePackageLock(projectDir) {
925
+ const result = spawnSync('npm', [...LOCK_UPDATE_ARGS], {
926
+ cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 180_000, shell: process.platform === 'win32',
927
+ });
928
+ if (result.error) {
929
+ return { ok: false, detail: result.error.code === 'ETIMEDOUT' ? 'npm did not finish within 3 minutes' : result.error.message };
930
+ }
931
+ if (result.status === 0) return { ok: true };
932
+ return { ok: false, detail: npmFailureReason(`${result.stderr || ''}\n${result.stdout || ''}`) || `npm exited with code ${result.status}` };
933
+ }
934
+
935
+ /** npm's reason in one line: its first error line that is not code/syscall/errno metadata, a stack frame or the log pointer. */
936
+ export function npmFailureReason(output) {
937
+ const lines = String(output || '').split('\n').map((line) => line.trim()).filter(Boolean);
938
+ const meaningful = (line) => line && !/^(code|syscall|errno)\s/.test(line) && !/^at\s/.test(line) && !/complete log of this run/i.test(line);
939
+ const errors = lines.filter((line) => /^npm (error|ERR!)/.test(line)).map((line) => line.replace(/^npm (error|ERR!)\s*/, ''));
940
+ return errors.find(meaningful) || lines.find(meaningful) || '';
1802
941
  }
1803
942
 
1804
- // Registry bookkeeping shipped alongside a published app's source: the listing
1805
- // descriptor and the rendered Store gallery describe the published app, so a
1806
- // scaffold copy must not inherit them.
1807
- const REGISTRY_ARTIFACTS = /^(notis-listing\.json$|screenshots(\/|$))/;
943
+ /**
944
+ * Keep package-lock.json in step with the mirrored @notis/sdk so `npm ci`
945
+ * accepts it. npm runs only when the lock's packages/sdk record differs from
946
+ * the mirrored manifest (`npm install --package-lock-only`: the lock only,
947
+ * no scripts, node_modules untouched). A failure, such as an offline
948
+ * machine, is a warning: the sync and the build or pull that ran it continue,
949
+ * and the next sync retries.
950
+ */
951
+ export function reconcileSdkLockfile(projectDir, { manifest, log = null, updateLock = updatePackageLock } = {}) {
952
+ const emit = typeof log === 'function' ? log : (message) => process.stderr.write(`${message}\n`);
953
+ const lockPath = join(projectDir, 'package-lock.json');
954
+ const readLock = () => JSON.parse(readFileSync(lockPath, 'utf-8'));
955
+ let project, lock;
956
+ try {
957
+ project = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
958
+ lock = readLock();
959
+ } catch {
960
+ return 'absent'; // No npm lock to keep, or one npm itself reports as unreadable.
961
+ }
962
+ const spec = project?.dependencies?.['@notis/sdk'] ?? project?.devDependencies?.['@notis/sdk'];
963
+ if (typeof spec !== 'string' || !/^file:(\.\/)?packages\/sdk\/?$/.test(spec)) return 'unlinked';
964
+ if (!lock?.packages || typeof lock.packages !== 'object') return 'unsupported'; // lockfileVersion 1
965
+ if (lockRecordsManifest(lock.packages['packages/sdk'], manifest)) return 'current';
966
+ const outcome = updateLock(projectDir);
967
+ let current = false;
968
+ try { current = lockRecordsManifest(readLock().packages?.['packages/sdk'], manifest); } catch { /* Reported below. */ }
969
+ if (outcome.ok && current) {
970
+ emit(`Updated package-lock.json for @notis/sdk ${manifest.version}. Run npm install to bring node_modules up to date.`);
971
+ return 'updated';
972
+ }
973
+ emit(`Warning: package-lock.json still records the previous @notis/sdk dependencies, so npm ci will refuse it${outcome.detail ? ` (${outcome.detail})` : ''}. `
974
+ + 'Everything else continues; run npm install in this project once the npm registry is reachable.');
975
+ return 'stale';
976
+ }
1808
977
 
1809
978
  function readStablePinnedFile(descriptor, expectedStat, label) {
1810
979
  const before = fstatSync(descriptor, { bigint: true });
@@ -1850,10 +1019,6 @@ function copyScaffoldSource(sourceDir, targetDir) {
1850
1019
  function shouldCopy(path) {
1851
1020
  const name = path.split(/[\\/]/).pop();
1852
1021
  if (!name) return true;
1853
- const relPath = relative(sourceDir, path).replace(/\\/g, '/');
1854
- if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
1855
- return false;
1856
- }
1857
1022
  return !SCAFFOLD_COPY_EXCLUDES.has(name)
1858
1023
  && !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
1859
1024
  && !name.startsWith('.env')
@@ -1996,7 +1161,7 @@ function copyScaffoldSource(sourceDir, targetDir) {
1996
1161
  export function collectArtifactFiles(projectDir) {
1997
1162
  const outputDir = join(projectDir, OUTPUT_DIR);
1998
1163
  if (!existsSync(outputDir)) {
1999
- throw usageError('No build output found. Run "notis apps build" first.');
1164
+ throw usageError('No build output found. Run "notis spaces build" first.');
2000
1165
  }
2001
1166
 
2002
1167
  validateArtifactBoundary(readArtifactFiles(projectDir));
@@ -2006,6 +1171,7 @@ export function collectArtifactFiles(projectDir) {
2006
1171
  function walk(dir, prefix) {
2007
1172
  const entries = readdirSync(dir, { withFileTypes: true });
2008
1173
  for (const entry of entries) {
1174
+ if (entry.name.startsWith('.') || (!prefix && entry.name === 'verify.json')) continue;
2009
1175
  const fullPath = join(dir, entry.name);
2010
1176
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2011
1177
  if (entry.isDirectory()) {
@@ -2026,6 +1192,7 @@ function shouldExcludeSourceEntry(name) {
2026
1192
  SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
2027
1193
  || casefolded.startsWith('.notis-app-pull-')
2028
1194
  || casefolded.startsWith('.notis-app-scaffold-')
1195
+ || casefolded.startsWith('._notis_config_')
2029
1196
  || casefolded.startsWith('.env')
2030
1197
  || casefolded.endsWith('.pyc')
2031
1198
  || casefolded.endsWith('.pyo')
@@ -2162,7 +1329,8 @@ function parsePaxPath(data) {
2162
1329
  }
2163
1330
 
2164
1331
  function extractTarGz(buffer, targetDir) {
2165
- const tar = gunzipSync(buffer);
1332
+ // Source snapshots allow 256 MiB of files plus bounded tar/PAX metadata.
1333
+ const tar = gunzipSync(buffer, { maxOutputLength: 320 * 1024 * 1024 });
2166
1334
  let offset = 0;
2167
1335
  let pendingPaxPath = null;
2168
1336
  const extractedPaths = new Map();
@@ -2261,7 +1429,7 @@ function capturePullTargetIdentity(targetDir) {
2261
1429
  try {
2262
1430
  const targetStat = lstatSync(targetDir, { bigint: true });
2263
1431
  if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
2264
- throw usageError(`Refusing to pull app source through an unsafe target: ${targetDir}`);
1432
+ throw usageError(`Refusing to pull Space source through an unsafe target: ${targetDir}`);
2265
1433
  }
2266
1434
  return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
2267
1435
  } catch (error) {
@@ -2273,7 +1441,7 @@ function capturePullTargetIdentity(targetDir) {
2273
1441
  function assertPullTargetIdentity(targetDir, expected) {
2274
1442
  const current = capturePullTargetIdentity(targetDir);
2275
1443
  if (!pullTargetIdentitiesMatch(current, expected)) {
2276
- throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
1444
+ throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
2277
1445
  }
2278
1446
  }
2279
1447
 
@@ -2289,7 +1457,7 @@ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity)
2289
1457
  try {
2290
1458
  currentCanonicalParent = realpathSync(parentDir);
2291
1459
  } catch {
2292
- throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
1460
+ throw usageError(`Space source pull parent changed while the download was in progress: ${parentDir}`);
2293
1461
  }
2294
1462
  if (
2295
1463
  currentCanonicalParent !== canonicalParent
@@ -2298,7 +1466,7 @@ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity)
2298
1466
  expectedIdentity,
2299
1467
  )
2300
1468
  ) {
2301
- throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
1469
+ throw usageError(`Space source pull parent changed while the download was in progress: ${parentDir}`);
2302
1470
  }
2303
1471
  }
2304
1472
 
@@ -2319,7 +1487,7 @@ async function withPullTargetLock(targetDir, callback) {
2319
1487
  capturePullTargetIdentity(canonicalTarget),
2320
1488
  )
2321
1489
  ) {
2322
- throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
1490
+ throw usageError(`Space source pull target changed before locking: ${requestedTarget}`);
2323
1491
  }
2324
1492
  const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
2325
1493
  const ownerId = `${process.pid}.${randomUUID()}`;
@@ -2344,11 +1512,11 @@ async function withPullTargetLock(targetDir, callback) {
2344
1512
  throw statError;
2345
1513
  }
2346
1514
  if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
2347
- throw usageError(`Refusing to use unsafe app source pull lock: ${lockDirectory}`);
1515
+ throw usageError(`Refusing to use unsafe Space source pull lock: ${lockDirectory}`);
2348
1516
  }
2349
1517
  if (Date.now() >= deadline) {
2350
1518
  throw usageError(
2351
- `Timed out waiting to pull app source into ${targetDir}. `
1519
+ `Timed out waiting to pull Space source into ${targetDir}. `
2352
1520
  + `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
2353
1521
  );
2354
1522
  }
@@ -2359,7 +1527,7 @@ async function withPullTargetLock(targetDir, callback) {
2359
1527
  try {
2360
1528
  const assertOwnership = () => {
2361
1529
  if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
2362
- throw usageError(`Lost the app source pull lock for ${targetDir}.`);
1530
+ throw usageError(`Lost the Space source pull lock for ${targetDir}.`);
2363
1531
  }
2364
1532
  };
2365
1533
  const assertParentIdentity = () => assertPullParentIdentity(
@@ -2370,7 +1538,7 @@ async function withPullTargetLock(targetDir, callback) {
2370
1538
  assertParentIdentity();
2371
1539
  const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
2372
1540
  if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
2373
- throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
1541
+ throw usageError(`Space source pull target changed before locking: ${requestedTarget}`);
2374
1542
  }
2375
1543
  return await callback(
2376
1544
  assertOwnership,
@@ -2389,6 +1557,28 @@ async function withPullTargetLock(targetDir, callback) {
2389
1557
  }
2390
1558
  }
2391
1559
 
1560
+ function assertPullStatePathSafe(projectDir) {
1561
+ const notisDir = join(projectDir, NOTIS_DIR);
1562
+ try {
1563
+ const directoryStat = lstatSync(notisDir);
1564
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1565
+ throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
1566
+ }
1567
+ } catch (error) {
1568
+ if (error?.code !== 'ENOENT') throw error;
1569
+ }
1570
+
1571
+ const statePath = join(projectDir, SPACE_SOURCE_FILE);
1572
+ try {
1573
+ const stateStat = lstatSync(statePath);
1574
+ if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
1575
+ throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
1576
+ }
1577
+ } catch (error) {
1578
+ if (error?.code !== 'ENOENT') throw error;
1579
+ }
1580
+ }
1581
+
2392
1582
  export async function pullAppSource(args) {
2393
1583
  const targetDir = resolve(args.targetDir);
2394
1584
  return withPullTargetLock(
@@ -2408,55 +1598,39 @@ export async function pullAppSource(args) {
2408
1598
  }
2409
1599
 
2410
1600
  async function pullAppSourceUnlocked({
2411
- apiBase,
2412
- jwt,
2413
- appId,
2414
1601
  targetDir,
2415
1602
  version = 'latest',
2416
1603
  force = false,
2417
- profileKey = null,
2418
- expectedUpdatedAt = null,
1604
+ downloadSource,
1605
+ sourceLink,
2419
1606
  }, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
2420
1607
  assertLockOwnership();
2421
1608
  assertParentIdentity();
2422
1609
  const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
2423
1610
  if (targetWasNonEmpty) {
2424
1611
  if (!force) {
2425
- throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
1612
+ throw usageError(`Target directory is not empty: ${targetDir}. Pull into a new or empty directory.`);
2426
1613
  }
2427
1614
  }
2428
1615
 
2429
- const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
2430
- const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
2431
- headers: { Authorization: `Bearer ${jwt}` },
2432
- });
2433
- if (!response.ok) {
2434
- const data = await response.json().catch(() => ({}));
2435
- throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
2436
- }
2437
-
2438
- const contentDisposition = response.headers.get('content-disposition') || '';
2439
- const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
2440
- const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
1616
+ const { archiveBuffer, revision: pulledVersion } = await downloadSource();
2441
1617
  const requestedVersion = String(version || 'latest') === 'latest'
2442
1618
  ? null
2443
1619
  : Number.parseInt(String(version), 10);
2444
1620
  if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
2445
- throw usageError('The app source response did not identify a valid positive version.');
1621
+ throw usageError('The Space source response did not identify a valid positive version.');
2446
1622
  }
2447
1623
  if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
2448
- throw usageError(`Invalid app source version: ${version}`);
1624
+ throw usageError(`Invalid Space source version: ${version}`);
2449
1625
  }
2450
1626
  if (
2451
1627
  requestedVersion !== null
2452
1628
  && pulledVersion !== requestedVersion
2453
1629
  ) {
2454
1630
  throw usageError(
2455
- `Requested app source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
1631
+ `Requested Space source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
2456
1632
  );
2457
1633
  }
2458
- const linkedVersion = pulledVersion;
2459
- const archiveBuffer = Buffer.from(await response.arrayBuffer());
2460
1634
  assertParentIdentity();
2461
1635
  assertLockOwnership();
2462
1636
 
@@ -2489,7 +1663,7 @@ async function pullAppSourceUnlocked({
2489
1663
  mkdirSync(targetName);
2490
1664
  } catch (error) {
2491
1665
  if (error?.code === 'EEXIST') {
2492
- throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
1666
+ throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
2493
1667
  }
2494
1668
  throw error;
2495
1669
  }
@@ -2502,13 +1676,13 @@ async function pullAppSourceUnlocked({
2502
1676
  || activeTargetIdentity.ino !== initialTargetIdentity.ino
2503
1677
  )
2504
1678
  ) {
2505
- throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
1679
+ throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
2506
1680
  }
2507
1681
  assertLockOwnership();
2508
1682
  process.chdir(targetName);
2509
1683
  assertPullTargetIdentity('.', activeTargetIdentity);
2510
1684
  const targetRoot = '.';
2511
- assertLinkedStatePathSafe(targetRoot);
1685
+ assertPullStatePathSafe(targetRoot);
2512
1686
  const preservedPaths = collectPullPreservedPaths(targetRoot);
2513
1687
 
2514
1688
  const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
@@ -2524,11 +1698,11 @@ async function pullAppSourceUnlocked({
2524
1698
  extractTarGz(archiveBuffer, stageDir);
2525
1699
  const stagedEntries = readdirSync(stageDir);
2526
1700
  if (stagedEntries.length === 0) {
2527
- throw usageError('The app source archive did not contain any editable source files.');
1701
+ throw usageError('The Space source archive did not contain any editable source files.');
2528
1702
  }
2529
1703
 
2530
- const previousStatePath = join(targetRoot, STATE_FILE);
2531
- const backupStatePath = join(backupDir, STATE_FILE);
1704
+ const previousStatePath = join(targetRoot, SPACE_SOURCE_FILE);
1705
+ const backupStatePath = join(backupDir, SPACE_SOURCE_FILE);
2532
1706
  const hadPreviousState = existsSync(previousStatePath);
2533
1707
  if (hadPreviousState) {
2534
1708
  mkdirSync(dirname(backupStatePath), { recursive: true });
@@ -2578,12 +1752,13 @@ async function pullAppSourceUnlocked({
2578
1752
  }
2579
1753
  assertPullTargetIdentity(targetDir, activeTargetIdentity);
2580
1754
  assertLockOwnership();
2581
- writeLinkedState(targetRoot, {
2582
- app_id: appId,
2583
- ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
2584
- expected_updated_at: expectedUpdatedAt,
2585
- linked_at: new Date().toISOString(),
2586
- }, profileKey);
1755
+ withAppReleaseWorkspace(targetRoot, () => {
1756
+ const temporary = `.space-source-${randomUUID()}.json`;
1757
+ try {
1758
+ writeFileSync(temporary, JSON.stringify({ ...sourceLink, revision: pulledVersion }), { flag: 'wx', mode: 0o600 });
1759
+ renameSync(temporary, basename(SPACE_SOURCE_FILE));
1760
+ } finally { rmSync(temporary, { force: true }); }
1761
+ }, { create: true });
2587
1762
  assertPullTargetIdentity(targetDir, activeTargetIdentity);
2588
1763
  assertLockOwnership();
2589
1764
  } catch (error) {
@@ -2657,19 +1832,19 @@ async function pullAppSourceUnlocked({
2657
1832
  );
2658
1833
  }
2659
1834
  throw usageError(
2660
- `App source was updated, but transaction cleanup failed; recovery files were retained at `
1835
+ `Space source was updated, but transaction cleanup failed; recovery files were retained at `
2661
1836
  + `${transactionDisplayPath}: ${cleanupMessage}`,
2662
1837
  );
2663
1838
  }
2664
1839
  }
2665
1840
 
2666
- return { projectDir: targetDir, version: pulledVersion || version };
1841
+ return { projectDir: targetDir, version: pulledVersion };
2667
1842
  }
2668
1843
 
2669
1844
  function readArtifactFiles(projectDir) {
2670
1845
  const outputDir = join(projectDir, OUTPUT_DIR);
2671
1846
  if (!existsSync(outputDir)) {
2672
- throw usageError('No build output found. Run "notis apps build" first.');
1847
+ throw usageError('No build output found. Run "notis spaces build" first.');
2673
1848
  }
2674
1849
 
2675
1850
  const files = {};
@@ -2691,233 +1866,238 @@ function readArtifactFiles(projectDir) {
2691
1866
  return files;
2692
1867
  }
2693
1868
 
2694
- // ---------------------------------------------------------------------------
2695
- // Direct deploy (bypasses backend server)
2696
- // ---------------------------------------------------------------------------
1869
+ /** Fingerprint exact source/artifact bytes, independent of directory enumeration order. */
1870
+ export function appFilesDigest(files) {
1871
+ return createHash('sha256').update(JSON.stringify(Object.keys(files).sort().map((key) => [key, files[key]]))).digest('hex');
1872
+ }
2697
1873
 
2698
- /**
2699
- * Resolve Supabase credentials from the server/.env file in the repo workspace.
2700
- * Falls back to environment variables.
2701
- */
2702
- function resolveSupabaseCredentials() {
2703
- const envPaths = [
2704
- resolve(process.cwd(), 'server/.env'),
2705
- resolve(process.cwd(), '../server/.env'),
2706
- resolve(process.cwd(), '../../server/.env'),
2707
- ];
2708
-
2709
- let supabaseUrl = process.env.SUPABASE_URL;
2710
- let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
2711
- let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
2712
-
2713
- for (const envPath of envPaths) {
2714
- if (existsSync(envPath)) {
2715
- const content = readFileSync(envPath, 'utf-8');
2716
- for (const line of content.split('\n')) {
2717
- const trimmed = line.trim();
2718
- if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
2719
- const eqIdx = trimmed.indexOf('=');
2720
- const key = trimmed.slice(0, eqIdx).trim();
2721
- let value = trimmed.slice(eqIdx + 1).trim();
2722
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
2723
- value = value.slice(1, -1);
2724
- }
2725
- if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
2726
- if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
2727
- if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
2728
- }
2729
- break;
1874
+ /** Run synchronous release bookkeeping relative to a pinned, real .notis directory. */
1875
+ function withAppReleaseWorkspace(projectDir, operation, { create = false, expected = null } = {}) {
1876
+ const canonicalProjectDir = expected?.projectDir || realpathSync(projectDir);
1877
+ const projectIdentity = expected?.projectIdentity || capturePullTargetIdentity(canonicalProjectDir);
1878
+ const previousCwd = process.cwd();
1879
+ const previousIdentity = capturePullTargetIdentity(previousCwd);
1880
+ try {
1881
+ process.chdir(canonicalProjectDir);
1882
+ assertPullTargetIdentity('.', projectIdentity);
1883
+ let notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
1884
+ if (!notisIdentity.exists && create) {
1885
+ mkdirSync(NOTIS_DIR);
1886
+ notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
1887
+ }
1888
+ if (!notisIdentity.exists) throw usageError('No build workspace found. Run notis spaces build first.');
1889
+ if (expected) assertPullTargetIdentity(NOTIS_DIR, expected.notisIdentity);
1890
+ process.chdir(NOTIS_DIR);
1891
+ assertPullTargetIdentity('.', notisIdentity);
1892
+ const value = operation({ projectDir: canonicalProjectDir, projectIdentity, notisIdentity });
1893
+ assertPullTargetIdentity('.', notisIdentity);
1894
+ assertPullTargetIdentity(canonicalProjectDir, projectIdentity);
1895
+ assertPullTargetIdentity(join(canonicalProjectDir, NOTIS_DIR), notisIdentity);
1896
+ return value;
1897
+ } finally {
1898
+ process.chdir(previousCwd);
1899
+ assertPullTargetIdentity('.', previousIdentity);
1900
+ }
1901
+ }
1902
+
1903
+ function freezePinnedArtifactSnapshot(workspace, files, sourceFiles) {
1904
+ for (const [source, entries] of [[true, sourceFiles], [false, files]]) {
1905
+ for (const [path, encoded] of Object.entries(entries)) {
1906
+ const segments = path.split('/');
1907
+ if (!path || path.includes('\\') || path.includes('\0') || segments.some(part => !part || part === '.' || part === '..')
1908
+ || (source && segments.some(part => ['.notis', '.context', '.git', 'node_modules'].includes(part) || part.startsWith('.env')))
1909
+ || typeof encoded !== 'string') throw usageError('Unsafe frozen source or artifact path.');
1910
+ }
1911
+ }
1912
+ // Nest under the source project so imports resolve its installed dependencies.
1913
+ // Every staging write is relative to pinned directories, never a replaced parent path.
1914
+ const frozenName = mkdtempSync('release-');
1915
+ const frozenIdentity = capturePullTargetIdentity(frozenName);
1916
+ const frozenDir = join(workspace.projectDir, NOTIS_DIR, frozenName);
1917
+ let closed = false;
1918
+ const close = () => {
1919
+ process.removeListener('exit', close);
1920
+ if (closed) return;
1921
+ withAppReleaseWorkspace(workspace.projectDir, () => {
1922
+ assertPullTargetIdentity(frozenName, frozenIdentity);
1923
+ rmSync(frozenName, { recursive: true, force: true });
1924
+ }, { expected: workspace });
1925
+ closed = true;
1926
+ };
1927
+ function writeEntry(segments, content) {
1928
+ const name = segments[0];
1929
+ if (segments.length === 1) {
1930
+ writeFileSync(name, content, { flag: 'wx' });
1931
+ return;
1932
+ }
1933
+ const parentIdentity = capturePullTargetIdentity('.');
1934
+ let identity = capturePullTargetIdentity(name);
1935
+ if (!identity.exists) {
1936
+ mkdirSync(name);
1937
+ identity = capturePullTargetIdentity(name);
1938
+ }
1939
+ process.chdir(name);
1940
+ try {
1941
+ assertPullTargetIdentity('.', identity);
1942
+ writeEntry(segments.slice(1), content);
1943
+ } finally {
1944
+ process.chdir('..');
1945
+ assertPullTargetIdentity('.', parentIdentity);
2730
1946
  }
2731
1947
  }
2732
-
2733
- // Derive URL from subdomain if needed
2734
- if (!supabaseUrl && supabaseSubdomain) {
2735
- supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
2736
- }
2737
-
2738
- if (!supabaseUrl || !supabaseKey) {
2739
- throw usageError(
2740
- 'Cannot resolve Supabase credentials for direct deploy. ' +
2741
- 'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
2742
- 'or set them as environment variables.',
2743
- );
1948
+ try {
1949
+ process.chdir(frozenName);
1950
+ try {
1951
+ assertPullTargetIdentity('.', frozenIdentity);
1952
+ for (const [path, encoded] of Object.entries(sourceFiles)) {
1953
+ writeEntry(path.split('/'), Buffer.from(encoded, 'base64'));
1954
+ }
1955
+ for (const [path, encoded] of Object.entries(files)) {
1956
+ writeEntry([NOTIS_DIR, 'output', ...path.split('/')], Buffer.from(encoded, 'base64'));
1957
+ }
1958
+ } finally {
1959
+ process.chdir('..');
1960
+ assertPullTargetIdentity('.', workspace.notisIdentity);
1961
+ }
1962
+ const manifest = files['manifest.json'] ? JSON.parse(Buffer.from(files['manifest.json'], 'base64').toString('utf8')) : null;
1963
+ process.once('exit', close);
1964
+ return { projectDir: frozenDir, files, sourceFiles, manifest, close };
1965
+ } catch (error) {
1966
+ try { close(); } catch { /* Retain owned staging if its parent identity changed. */ }
1967
+ throw error;
2744
1968
  }
2745
-
2746
- return { supabaseUrl, supabaseKey };
2747
1969
  }
2748
1970
 
2749
- /**
2750
- * Upload a file to Supabase Storage with upsert.
2751
- */
2752
- async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
2753
- const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
2754
- const response = await fetch(url, {
2755
- method: 'POST',
2756
- headers: {
2757
- 'Authorization': `Bearer ${supabaseKey}`,
2758
- 'Content-Type': contentType,
2759
- 'x-upsert': 'true',
2760
- },
2761
- body: content,
2762
- });
1971
+ /** Freeze a selected source slice using the same pinned-directory writer as releases. */
1972
+ export function freezeSourceWorkspace(projectDir, sourceFiles) {
1973
+ return withAppReleaseWorkspace(projectDir, workspace => freezePinnedArtifactSnapshot(workspace, {}, sourceFiles), { create: true });
1974
+ }
2763
1975
 
2764
- if (!response.ok) {
2765
- const text = await response.text().catch(() => '');
2766
- throw new Error(`Storage upload failed (${response.status}): ${text}`);
2767
- }
1976
+ function selectedBuildName(key) {
1977
+ if (typeof key !== 'string' || !/^[a-z][a-z0-9_-]{0,99}$/.test(key)) throw usageError('Choose a valid selected build key.');
1978
+ return `space-build-${key}.json`;
2768
1979
  }
2769
1980
 
2770
- async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
2771
- const encodedBucket = encodeURIComponent(bucket);
2772
- const headers = {
2773
- 'Authorization': `Bearer ${supabaseKey}`,
2774
- 'apikey': supabaseKey,
2775
- };
2776
- const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
2777
- headers,
2778
- });
2779
- if (inspectResponse.ok) {
2780
- return;
2781
- }
2782
- if (inspectResponse.status !== 404) {
2783
- const text = await inspectResponse.text().catch(() => '');
2784
- throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
2785
- }
2786
-
2787
- const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
2788
- method: 'POST',
2789
- headers: {
2790
- ...headers,
2791
- 'Content-Type': 'application/json',
2792
- },
2793
- body: JSON.stringify({
2794
- id: bucket,
2795
- name: bucket,
2796
- public: Boolean(options.public),
2797
- }),
1981
+ /** Immutable bytes, not another authoring project or installed dependency tree. */
1982
+ export function saveSelectedBuild(projectDir, key, release) {
1983
+ return withAppReleaseWorkspace(projectDir, () => {
1984
+ const name = selectedBuildName(key), temporary = `.${name}-${randomUUID()}`;
1985
+ const snapshot = { version: 1, key, files: release.files, sourceFiles: release.sourceFiles,
1986
+ source_hash: appFilesDigest(release.sourceFiles), artifact_hash: appFilesDigest(release.files) };
1987
+ try {
1988
+ writeFileSync(temporary, JSON.stringify(snapshot), { flag: 'wx', mode: 0o600 });
1989
+ renameSync(temporary, name);
1990
+ } finally { rmSync(temporary, { force: true }); }
1991
+ return join(projectDir, NOTIS_DIR, name);
1992
+ }, { create: true });
1993
+ }
1994
+
1995
+ export function prepareSelectedBuild(projectDir, key, sourceFiles) {
1996
+ return withAppReleaseWorkspace(projectDir, workspace => {
1997
+ const name = selectedBuildName(key);
1998
+ let before;
1999
+ try { before = lstatSync(name, { bigint: true }); } catch (error) {
2000
+ if (error.code === 'ENOENT') throw usageError('Selected Space build is missing or stale. Build it again.');
2001
+ throw error;
2002
+ }
2003
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe selected build receipt.');
2004
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2005
+ let snapshot;
2006
+ try { snapshot = JSON.parse(readStablePinnedFile(descriptor, before, 'Selected build receipt').toString('utf8')); }
2007
+ finally { closeSync(descriptor); }
2008
+ if (snapshot.version !== 1 || snapshot.key !== key || !snapshot.files || !snapshot.sourceFiles
2009
+ || snapshot.source_hash !== appFilesDigest(sourceFiles) || snapshot.source_hash !== appFilesDigest(snapshot.sourceFiles)
2010
+ || snapshot.artifact_hash !== appFilesDigest(snapshot.files)) throw usageError('Selected Space build is missing or stale. Build it again.');
2011
+ validateArtifactBoundary(Object.fromEntries(Object.entries(snapshot.files).map(([path, encoded]) => [path, Buffer.from(encoded, 'base64')])));
2012
+ return freezePinnedArtifactSnapshot(workspace, snapshot.files, snapshot.sourceFiles);
2798
2013
  });
2799
- if (createResponse.ok) {
2800
- return;
2801
- }
2802
-
2803
- const text = await createResponse.text().catch(() => '');
2804
- if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
2805
- return;
2806
- }
2807
- throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
2808
2014
  }
2809
2015
 
2810
- /**
2811
- * Get the current app manifest version from the apps table.
2812
- */
2813
- async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
2814
- const encodedAppId = encodeURIComponent(appId);
2815
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
2816
- const response = await fetch(url, {
2817
- headers: {
2818
- 'Authorization': `Bearer ${supabaseKey}`,
2819
- 'apikey': supabaseKey,
2820
- },
2016
+ /** A diagnostic, never an approval stamp or a replacement for exact-byte checks. */
2017
+ export function saveSpaceVerification(projectDir, diagnostic) {
2018
+ return withAppReleaseWorkspace(projectDir, (workspace) => {
2019
+ const name = `space-verification-${randomUUID()}.json`;
2020
+ writeFileSync(name, JSON.stringify(diagnostic, null, 2), { flag: 'wx', mode: 0o600 });
2021
+ return join(workspace.projectDir, '.notis', name);
2821
2022
  });
2822
- if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
2823
- const rows = await response.json();
2824
- if (!rows.length) throw usageError(`App ${appId} not found.`);
2825
- const manifest = rows[0].manifest || {};
2826
- return { currentVersion: manifest.version || 0, manifest };
2827
2023
  }
2828
2024
 
2829
- /**
2830
- * Update the app manifest in the apps table.
2831
- */
2832
- async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
2833
- const encodedAppId = encodeURIComponent(appId);
2834
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
2835
- const response = await fetch(url, {
2836
- method: 'PATCH',
2837
- headers: {
2838
- 'Authorization': `Bearer ${supabaseKey}`,
2839
- 'apikey': supabaseKey,
2840
- 'Content-Type': 'application/json',
2841
- 'Prefer': 'return=minimal',
2842
- },
2843
- body: JSON.stringify({
2844
- manifest: { ...manifest, version: newVersion },
2845
- ...appRowFieldsFromManifest(manifest),
2846
- updated_at: new Date().toISOString(),
2847
- }),
2025
+ /** Retain the original CAS after an uncertain publish response, across CLI exits. */
2026
+ export function spacePublicationIntent(projectDir, identity, create = null) {
2027
+ const name = `space-publish-${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}.json`;
2028
+ return withAppReleaseWorkspace(projectDir, () => {
2029
+ if (create) {
2030
+ const temporary = `.${name}-${randomUUID()}`;
2031
+ try {
2032
+ writeFileSync(temporary, JSON.stringify(create), { flag: 'wx', mode: 0o600 });
2033
+ try { linkSync(temporary, name); } catch (error) { if (error.code !== 'EEXIST') throw error; }
2034
+ } finally { rmSync(temporary, { force: true }); }
2035
+ }
2036
+ let before;
2037
+ try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
2038
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe Space publication receipt.');
2039
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2040
+ try { return JSON.parse(readStablePinnedFile(descriptor, before, 'Space publication receipt').toString('utf8')); }
2041
+ finally { closeSync(descriptor); }
2848
2042
  });
2849
- if (!response.ok) {
2850
- const text = await response.text().catch(() => '');
2851
- throw new Error(`Failed to update app version: ${response.status} ${text}`);
2852
- }
2853
2043
  }
2854
2044
 
2855
- const CONTENT_TYPE_MAP = {
2856
- '.js': 'application/javascript',
2857
- '.css': 'text/css',
2858
- '.json': 'application/json',
2859
- '.html': 'text/html',
2860
- '.png': 'image/png',
2861
- '.jpg': 'image/jpeg',
2862
- '.jpeg': 'image/jpeg',
2863
- };
2864
-
2865
- /**
2866
- * Deploy app bundle directly to Supabase storage, bypassing the backend server.
2867
- */
2868
- export async function directDeploy(projectDir, appId) {
2869
- const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
2870
- const manifest = readManifest(projectDir);
2871
- const artifactFiles = readArtifactFiles(projectDir);
2872
- const sourceFiles = readSourceFiles(projectDir);
2873
- validateArtifactBoundary(artifactFiles);
2045
+ function spaceStateFileName(name) {
2046
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*\.json$/.test(name)) throw usageError('Unsafe local Space state file name.');
2047
+ return name;
2048
+ }
2874
2049
 
2875
- // Get current version and increment
2876
- const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
2877
- const newVersion = currentVersion + 1;
2050
+ /** Read one local-only JSON state file under the pinned .notis directory; null when absent. */
2051
+ export function readSpaceStateFile(projectDir, name) {
2052
+ spaceStateFileName(name);
2053
+ if (!existsSync(join(resolve(projectDir), NOTIS_DIR))) return null;
2054
+ return withAppReleaseWorkspace(projectDir, () => {
2055
+ let before;
2056
+ try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
2057
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Unsafe local Space state file: ${name}.`);
2058
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2059
+ try { return JSON.parse(readStablePinnedFile(descriptor, before, `Space state file ${name}`).toString('utf8')); }
2060
+ finally { closeSync(descriptor); }
2061
+ });
2062
+ }
2878
2063
 
2879
- // Upload all files from the output directory
2880
- const bucket = 'app-code';
2881
- await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
2882
- await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
2064
+ /** Replace one local-only JSON state file atomically inside the pinned .notis directory. */
2065
+ export function writeSpaceStateFile(projectDir, name, value) {
2066
+ spaceStateFileName(name);
2067
+ return withAppReleaseWorkspace(projectDir, (workspace) => {
2068
+ const temporary = `.${name}-${randomUUID()}`;
2069
+ try {
2070
+ writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
2071
+ renameSync(temporary, name);
2072
+ } finally { rmSync(temporary, { force: true }); }
2073
+ return join(workspace.projectDir, NOTIS_DIR, name);
2074
+ }, { create: true });
2075
+ }
2883
2076
 
2884
- async function uploadDir(dir, prefix) {
2885
- const entries = readdirSync(dir, { withFileTypes: true });
2886
- for (const entry of entries) {
2887
- const fullPath = join(dir, entry.name);
2888
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2889
- if (entry.isDirectory()) {
2890
- await uploadDir(fullPath, relPath);
2891
- } else {
2892
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
2893
- const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
2894
- const content = readFileSync(fullPath);
2895
- const storagePath = `${appId}/v${newVersion}/${relPath}`;
2896
- await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
2077
+ /** Capture one checked build. Verification and upload must use these same bytes. */
2078
+ export function prepareAppRelease(projectDir) {
2079
+ let release;
2080
+ try {
2081
+ return withAppReleaseWorkspace(projectDir, (workspace) => {
2082
+ const sourceFiles = collectSourceFiles(workspace.projectDir);
2083
+ const files = collectArtifactFiles(workspace.projectDir);
2084
+ let receipt;
2085
+ try {
2086
+ const before = lstatSync('build-receipt.json', { bigint: true });
2087
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe build receipt.');
2088
+ const descriptor = openSync('build-receipt.json', fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2089
+ try { receipt = JSON.parse(readStablePinnedFile(descriptor, before, 'Build receipt').toString('utf8')); }
2090
+ finally { closeSync(descriptor); }
2091
+ } catch (error) {
2092
+ if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
2897
2093
  }
2898
- }
2899
- }
2900
-
2901
- await uploadDir(join(projectDir, OUTPUT_DIR), '');
2902
-
2903
- for (const [relPath, content] of Object.entries(sourceFiles)) {
2904
- const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
2905
- const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
2906
- const storagePath = `${appId}/v${newVersion}/${relPath}`;
2907
- await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
2094
+ if (receipt?.source_hash !== appFilesDigest(sourceFiles) || receipt?.artifact_hash !== appFilesDigest(files)) {
2095
+ throw usageError('Build output is missing or stale. Run notis spaces build before deploying.');
2096
+ }
2097
+ return release = freezePinnedArtifactSnapshot(workspace, files, sourceFiles);
2098
+ });
2099
+ } catch (error) {
2100
+ try { release?.close(); } catch { /* Parent replacement retains staging, never follows it. */ }
2101
+ throw error;
2908
2102
  }
2909
-
2910
- // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
2911
- const storagePrefix = `${appId}/v${newVersion}/`;
2912
- const deployManifest = {
2913
- ...manifest,
2914
- version: newVersion,
2915
- storage_bucket: bucket,
2916
- storage_prefix: storagePrefix,
2917
- source_storage_bucket: 'app-source',
2918
- source_storage_prefix: storagePrefix,
2919
- };
2920
- await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
2921
-
2922
- return { version: newVersion };
2923
2103
  }