@notis_ai/cli 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +83 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +20168 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +167 -72
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +86 -28
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +39 -18
  10. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  11. package/skills/notis-onboarding/BRIEF.md +16 -0
  12. package/src/agent-hook-entry.js +5 -0
  13. package/src/cli.js +23 -14
  14. package/src/command-specs/agents.js +392 -0
  15. package/src/command-specs/apps.js +1021 -424
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/index.js +6 -0
  18. package/src/command-specs/onboarding.js +69 -5
  19. package/src/command-specs/skills.js +56 -0
  20. package/src/runtime/agent-memory-state.js +126 -0
  21. package/src/runtime/agent-setup.js +383 -0
  22. package/src/runtime/app-dev-consumers.js +154 -0
  23. package/src/runtime/app-dev-host-lock.js +80 -0
  24. package/src/runtime/app-dev-process-identity.js +111 -0
  25. package/src/runtime/app-dev-roots.js +284 -0
  26. package/src/runtime/app-dev-server.js +447 -29
  27. package/src/runtime/app-dev-sessions.js +105 -145
  28. package/src/runtime/app-platform.js +1314 -166
  29. package/src/runtime/app-registry-scaffolds.js +367 -0
  30. package/src/runtime/base-skills.d.ts +20 -0
  31. package/src/runtime/base-skills.js +167 -0
  32. package/src/runtime/login-listener.js +15 -0
  33. package/src/runtime/oauth.js +1506 -141
  34. package/src/runtime/output.js +7 -0
  35. package/src/runtime/ports.js +16 -0
  36. package/src/runtime/profiles.js +19 -16
  37. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  38. package/src/runtime/skill-sync/index.ts +644 -0
  39. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  40. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  41. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  42. package/src/runtime/skill-sync/types.ts +103 -0
  43. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  44. package/src/runtime/store-screenshot.js +6 -1
  45. package/src/runtime/sync-skills.d.ts +37 -0
  46. package/src/runtime/sync-skills.js +231 -0
  47. package/template/app/layout.tsx +5 -1
  48. package/template/notis.config.ts +1 -0
  49. package/template/packages/sdk/package.json +2 -1
  50. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  51. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  52. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +43 -119
  53. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  54. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  55. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  56. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  57. package/template/packages/sdk/src/config.ts +25 -0
  58. package/template/packages/sdk/src/documents.ts +7 -1
  59. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  60. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +726 -0
  61. package/template/packages/sdk/src/hooks/useHandover.ts +3 -0
  62. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  63. package/template/packages/sdk/src/hooks/useNotis.ts +3 -0
  64. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +7 -4
  65. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  66. package/template/packages/sdk/src/index.ts +47 -2
  67. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  68. package/template/packages/sdk/src/interactions/shortcuts.tsx +634 -0
  69. package/template/packages/sdk/src/interactions.ts +41 -0
  70. package/template/packages/sdk/src/provider.tsx +2 -1
  71. package/template/packages/sdk/src/runtime.ts +92 -4
  72. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  73. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  74. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  75. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  76. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  77. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  78. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  79. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  80. package/dist/scaffolds/notis-database/components.json +0 -20
  81. package/dist/scaffolds/notis-database/index.html +0 -12
  82. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  83. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  84. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  85. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  86. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  87. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  88. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  89. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  90. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  91. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  92. package/dist/scaffolds/notis-database/package.json +0 -32
  93. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  94. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  95. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  96. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  97. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  98. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  99. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -234
  100. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -250
  101. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  102. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  103. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  104. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  105. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +0 -75
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  115. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -100
  116. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  117. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -351
  118. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  119. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  120. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  121. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  122. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  123. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  124. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  125. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  126. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  127. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  128. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  129. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  130. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  131. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  132. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  133. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  134. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  135. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  136. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  137. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  138. package/dist/scaffolds/notis-journal/components.json +0 -20
  139. package/dist/scaffolds/notis-journal/index.html +0 -12
  140. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  141. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  142. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  148. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  149. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  150. package/dist/scaffolds/notis-journal/package.json +0 -34
  151. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  152. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  153. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -234
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -250
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +0 -75
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -100
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -351
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  178. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  179. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  180. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  181. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  182. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  183. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -199
  184. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  185. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  186. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  187. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  188. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  189. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  190. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  191. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  192. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  193. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  194. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  195. package/dist/scaffolds/notis-notes/components.json +0 -20
  196. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  197. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  203. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  204. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  205. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  206. package/dist/scaffolds/notis-notes/package.json +0 -35
  207. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -234
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -250
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +0 -75
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -100
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  231. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -351
  232. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  233. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  234. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  235. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  236. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  237. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  238. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  239. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  240. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  241. package/dist/scaffolds/notis-random/README.md +0 -33
  242. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  243. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  244. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  245. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  246. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  247. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  248. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  249. package/dist/scaffolds/notis-random/components.json +0 -20
  250. package/dist/scaffolds/notis-random/index.html +0 -12
  251. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  252. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  253. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  254. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  255. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  257. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  258. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  259. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  260. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  261. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  262. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  263. package/dist/scaffolds/notis-random/package.json +0 -36
  264. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  266. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  267. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  268. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  269. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  270. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -234
  271. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -250
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +0 -75
  279. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  280. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  281. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  282. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  283. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  284. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  285. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  286. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -100
  287. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  288. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -351
  289. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  290. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  291. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  292. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  293. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  294. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  295. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  296. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  297. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  298. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  299. package/dist/scaffolds.json +0 -49
@@ -7,13 +7,16 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
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';
11
12
  import { createRequire } from 'node:module';
12
- import { dirname, join, relative, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, relative, resolve } from 'node:path';
13
15
  import { fileURLToPath } from 'node:url';
14
16
  import { gunzipSync } from 'node:zlib';
15
17
 
16
18
  import { usageError } from './errors.js';
19
+ import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
17
20
  import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
18
21
  import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
19
22
 
@@ -24,10 +27,6 @@ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
24
27
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
25
28
  const METADATA_DIR = 'metadata';
26
29
  const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
27
- const MONOREPO_SCAFFOLDS_DIR = resolve(CLI_ROOT, '../..', 'scaffolds');
28
- const DIST_DIR = join(CLI_ROOT, 'dist');
29
- const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
30
- const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
31
30
  const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
32
31
  export const NOTIS_APP_CATEGORIES = [
33
32
  'Productivity',
@@ -48,6 +47,9 @@ const SOURCE_COPY_EXCLUDES = new Set([
48
47
  // scripts/ directory must not ship version-specific bytecode in the bundle.
49
48
  '__pycache__',
50
49
  ]);
50
+ const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
51
+ [...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
52
+ );
51
53
  const SCAFFOLD_COPY_EXCLUDES = new Set([
52
54
  ...SOURCE_COPY_EXCLUDES,
53
55
  'coverage',
@@ -60,6 +62,11 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
60
62
  // not listing media, it is the stub data the dev harness serves, and dropping
61
63
  // it would make every route of a fresh project render its empty state.
62
64
  const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
65
+ const PULL_LOCK_TIMEOUT_MS = 30_000;
66
+ 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));
63
70
  // A directory-declared skill ships every supporting file to the sandbox, so it
64
71
  // needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
65
72
  // handful of scripts always fits, and far below the bundle machinery's own
@@ -71,8 +78,38 @@ let appConfigImportNonce = 0;
71
78
  // Project directory resolution
72
79
  // ---------------------------------------------------------------------------
73
80
 
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
+ // A `~/...` path only expands when a shell is involved. Agents routinely spawn
98
+ // the CLI without one, and the documented default home is written with a tilde,
99
+ // so expand it here instead of creating a literal "~" directory.
100
+ export function expandHomePath(inputPath) {
101
+ const value = String(inputPath ?? '');
102
+ if (value === '~') {
103
+ return homedir();
104
+ }
105
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
106
+ return join(homedir(), value.slice(2));
107
+ }
108
+ return value;
109
+ }
110
+
74
111
  export function resolveProjectDir(inputDir = '.') {
75
- return resolve(process.cwd(), inputDir);
112
+ return resolve(process.cwd(), expandHomePath(inputDir));
76
113
  }
77
114
 
78
115
  export function getBundleDir(projectDir) {
@@ -643,6 +680,9 @@ function validateConfiguredRoutes(routes) {
643
680
  if (route.default) {
644
681
  defaultCount += 1;
645
682
  }
683
+ if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
684
+ throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
685
+ }
646
686
  }
647
687
 
648
688
  if (defaultCount !== 1) {
@@ -762,6 +802,7 @@ export function generateManifest(appConfig, projectDir) {
762
802
  icon: route.icon || null,
763
803
  parentSlug: route.parentSlug || null,
764
804
  default: route.default || false,
805
+ resourceDeepLinks: route.resourceDeepLinks === true,
765
806
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
766
807
  collection: route.collection || null,
767
808
  };
@@ -859,11 +900,43 @@ export function generateManifest(appConfig, projectDir) {
859
900
  databases,
860
901
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
861
902
  tools: appConfig.tools || [],
903
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
862
904
  skills,
863
905
  onboarding: appConfig.onboarding || null,
864
906
  };
865
907
  }
866
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
+
867
940
  /**
868
941
  * Keeps only capabilities the platform actually understands, at the exact
869
942
  * values it accepts. An unknown key or value is dropped rather than passed
@@ -1065,21 +1138,212 @@ function copyMetadataAssets(projectDir) {
1065
1138
  // Linking
1066
1139
  // ---------------------------------------------------------------------------
1067
1140
 
1068
- export function readLinkedState(projectDir) {
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) {
1069
1170
  const statePath = join(projectDir, STATE_FILE);
1171
+ assertLinkedStatePathSafe(projectDir);
1070
1172
  if (!existsSync(statePath)) return null;
1071
- return JSON.parse(readFileSync(statePath, 'utf-8'));
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;
1072
1226
  }
1073
1227
 
1074
- export function writeLinkedState(projectDir, state) {
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
+
1075
1333
  const statePath = join(projectDir, STATE_FILE);
1076
- mkdirSync(dirname(statePath), { recursive: true });
1077
- writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
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;
1341
+ }
1078
1342
  }
1079
1343
 
1080
- export function requireLinkedAppId(projectDir, explicitAppId) {
1344
+ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
1081
1345
  if (explicitAppId) return explicitAppId;
1082
- const state = readLinkedState(projectDir);
1346
+ const state = readLinkedState(projectDir, profileKey);
1083
1347
  if (state?.app_id) return state.app_id;
1084
1348
  throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
1085
1349
  }
@@ -1089,79 +1353,342 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
1089
1353
  // ---------------------------------------------------------------------------
1090
1354
 
1091
1355
  /**
1092
- * Scaffold a new Notis app project from the SDK template.
1356
+ * Scaffold a new Notis app project from the SDK template or from a published
1357
+ * Store app downloaded from the public registry repository.
1093
1358
  */
1094
- export function loadScaffoldCatalog() {
1095
- if (existsSync(SCAFFOLD_CATALOG_FILE)) {
1096
- const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
1097
- const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
1098
- return Array.isArray(scaffolds)
1099
- ? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
1100
- : [];
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
+ }
1101
1367
  }
1102
- return loadMonorepoScaffoldCatalog();
1103
- }
1104
-
1105
- export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1106
- const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
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
+ }
1378
+ }
1379
+
1380
+ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir }) {
1381
+ const sourceDir = resolve(templateDir);
1382
+ const requestedProjectDir = resolve(projectDir);
1383
+ if (!existsSync(sourceDir)) {
1384
+ throw usageError(`SDK template not found at ${sourceDir}. Ensure @notis_ai/cli is installed correctly.`);
1385
+ }
1386
+
1387
+ const parentDir = dirname(requestedProjectDir);
1388
+ const targetName = basename(requestedProjectDir);
1389
+ mkdirSync(parentDir, { recursive: true });
1390
+ const canonicalParent = realpathSync(parentDir);
1391
+ const parentIdentity = capturePullTargetIdentity(canonicalParent);
1392
+ const initialTargetIdentity = captureScaffoldTargetIdentity(requestedProjectDir);
1393
+ const previousCwd = process.cwd();
1394
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1395
+ let cwdPinned = false;
1396
+ let stageName = null;
1397
+ let cleanupStage = true;
1398
+ let activeTargetIdentity = null;
1399
+ const scaffoldLockName = '.notis-app-scaffold-lock';
1400
+ const scaffoldLockOwnerId = `${process.pid}.${randomUUID()}`;
1401
+ try {
1402
+ process.chdir(canonicalParent);
1403
+ cwdPinned = true;
1404
+ assertPullTargetIdentity('.', parentIdentity);
1405
+ const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
1406
+ if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
1407
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1408
+ }
1409
+ if (!lockedTargetIdentity.exists) {
1410
+ try {
1411
+ mkdirSync(targetName);
1412
+ } catch (error) {
1413
+ if (error?.code === 'EEXIST') {
1414
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1415
+ }
1416
+ throw error;
1417
+ }
1418
+ }
1419
+ activeTargetIdentity = captureScaffoldTargetIdentity(targetName);
1420
+ process.chdir(targetName);
1421
+ if (!pullTargetIdentitiesMatch(
1422
+ activeTargetIdentity,
1423
+ captureScaffoldTargetIdentity('.'),
1424
+ )) {
1425
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1426
+ }
1427
+ mkdirSync(scaffoldLockName, { mode: 0o700 });
1428
+ writeFileSync(
1429
+ join(scaffoldLockName, 'owner'),
1430
+ JSON.stringify({ id: scaffoldLockOwnerId }),
1431
+ { mode: 0o600 },
1432
+ );
1107
1433
 
1108
- if (!existsSync(templateDir)) {
1109
- if (fromSlug) {
1110
- const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
1111
- throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
1434
+ stageName = basename(mkdtempSync(join('.', '.notis-app-scaffold-')));
1435
+ projectDir = stageName;
1436
+
1437
+ copyScaffoldSource(sourceDir, projectDir);
1438
+
1439
+ // Update package.json with the app name
1440
+ const pkgPath = join(projectDir, 'package.json');
1441
+ if (existsSync(pkgPath)) {
1442
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1443
+ 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
+ ensureScaffoldLocalSdk(projectDir, pkg);
1450
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1451
+ normalizeScaffoldLockfile(projectDir, pkg);
1112
1452
  }
1113
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
1114
- }
1115
-
1116
- mkdirSync(projectDir, { recursive: true });
1117
- copyScaffoldSource(templateDir, projectDir);
1118
-
1119
- // Update package.json with the app name
1120
- const pkgPath = join(projectDir, 'package.json');
1121
- if (existsSync(pkgPath)) {
1122
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1123
- pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
1124
- // A scaffold creates a new app identity, even when its source comes from a
1125
- // versioned Store example. New apps must therefore start their own release
1126
- // history instead of inheriting the example app's registry version.
1127
- pkg.notisAppVersion = '0.1.0';
1128
- ensureScaffoldLocalSdk(projectDir, pkg);
1129
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1130
- normalizeScaffoldLockfile(projectDir, pkg);
1131
- }
1132
-
1133
- // Update notis.config.ts with the app name
1134
- const configPath = join(projectDir, 'notis.config.ts');
1135
- if (existsSync(configPath)) {
1136
- let config = readFileSync(configPath, 'utf-8');
1137
- const displayName = jsStringLiteral(appName);
1138
- const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1139
- if (fromSlug) {
1140
- if (/name\s*:/.test(config)) {
1141
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1453
+
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
+ }
1142
1478
  }
1143
- if (/title\s*:/.test(config)) {
1144
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1479
+ if (/devSlug\s*:/.test(config)) {
1480
+ config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
1145
1481
  } else {
1146
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
1482
+ config = config.replace(
1483
+ /name\s*:\s*(['"`])[\s\S]*?\1/,
1484
+ (nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
1485
+ );
1147
1486
  }
1148
- } else {
1149
- if (/name\s*:/.test(config)) {
1150
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1487
+ config = removeConfigArrayProperty(config, 'screenshots');
1488
+ writeFileSync(configPath, config);
1489
+ }
1490
+
1491
+ resetScaffoldChangelog(projectDir, appName);
1492
+
1493
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1494
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1495
+ const stageRoot = realpathSync(stageName);
1496
+ const installedEntries = [];
1497
+ const installedDirectoryIdentities = new Map([['', activeTargetIdentity]]);
1498
+ const captureInstalledEntry = (entryPath) => {
1499
+ const entryStat = lstatSync(entryPath, { bigint: true });
1500
+ return {
1501
+ exists: true,
1502
+ path: entryPath,
1503
+ dev: entryStat.dev,
1504
+ ino: entryStat.ino,
1505
+ directory: entryStat.isDirectory(),
1506
+ };
1507
+ };
1508
+ const installExclusive = (sourcePath, destinationName, relativePath, parentIdentity) => {
1509
+ const sourceStat = lstatSync(sourcePath);
1510
+ if (sourceStat.isSymbolicLink()) {
1511
+ throw usageError(`Refusing symlinked scaffold source entry: ${sourcePath}`);
1151
1512
  }
1152
- if (/title\s*:/.test(config)) {
1153
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1154
- } else {
1155
- config = config.replace(/'My Notis App'/, displayName);
1513
+ if (sourceStat.isDirectory()) {
1514
+ mkdirSync(destinationName, { mode: sourceStat.mode & 0o777 });
1515
+ const installedDirectory = captureInstalledEntry(destinationName);
1516
+ installedDirectory.path = relativePath;
1517
+ installedEntries.push(installedDirectory);
1518
+ installedDirectoryIdentities.set(relativePath, installedDirectory);
1519
+ process.chdir(destinationName);
1520
+ if (!pullTargetIdentitiesMatch(
1521
+ installedDirectory,
1522
+ capturePullTargetIdentity('.'),
1523
+ )) {
1524
+ throw usageError(`App scaffold destination changed during installation: ${relativePath}`);
1525
+ }
1526
+ try {
1527
+ for (const name of readdirSync(sourcePath)) {
1528
+ installExclusive(
1529
+ join(sourcePath, name),
1530
+ name,
1531
+ `${relativePath}/${name}`,
1532
+ installedDirectory,
1533
+ );
1534
+ }
1535
+ } finally {
1536
+ const parentRelativePath = dirname(relativePath) === '.' ? '' : dirname(relativePath);
1537
+ process.chdir(parentRelativePath
1538
+ ? join(requestedProjectDir, parentRelativePath)
1539
+ : requestedProjectDir);
1540
+ if (!pullTargetIdentitiesMatch(
1541
+ parentIdentity,
1542
+ capturePullTargetIdentity('.'),
1543
+ )) {
1544
+ throw usageError(`App scaffold destination changed during installation: ${parentRelativePath || '.'}`);
1545
+ }
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (!sourceStat.isFile()) {
1550
+ throw usageError(`Refusing unsupported scaffold source entry: ${sourcePath}`);
1551
+ }
1552
+ copyFileSync(sourcePath, destinationName, fsConstants.COPYFILE_EXCL);
1553
+ const installedFile = captureInstalledEntry(destinationName);
1554
+ installedFile.path = relativePath;
1555
+ installedEntries.push(installedFile);
1556
+ };
1557
+ try {
1558
+ for (const name of readdirSync(stageName)) {
1559
+ installExclusive(join(stageRoot, name), name, name, activeTargetIdentity);
1560
+ }
1561
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1562
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1563
+ } catch (error) {
1564
+ try {
1565
+ for (const installed of installedEntries.reverse()) {
1566
+ const parentRelativePath = dirname(installed.path) === '.' ? '' : dirname(installed.path);
1567
+ const parentPath = parentRelativePath
1568
+ ? join(requestedProjectDir, parentRelativePath)
1569
+ : requestedProjectDir;
1570
+ const expectedParentIdentity = installedDirectoryIdentities.get(parentRelativePath);
1571
+ process.chdir(parentPath);
1572
+ if (!expectedParentIdentity || !pullTargetIdentitiesMatch(
1573
+ expectedParentIdentity,
1574
+ capturePullTargetIdentity('.'),
1575
+ )) {
1576
+ throw usageError(
1577
+ `Refusing to roll back a scaffold directory that changed concurrently: ${parentPath}`,
1578
+ );
1579
+ }
1580
+ const entryName = basename(installed.path);
1581
+ const current = captureInstalledEntry(entryName);
1582
+ if (
1583
+ current.dev !== installed.dev
1584
+ || current.ino !== installed.ino
1585
+ || current.directory !== installed.directory
1586
+ ) {
1587
+ throw usageError(
1588
+ `Refusing to roll back a scaffold entry that changed concurrently: ${installed.path}`,
1589
+ );
1590
+ }
1591
+ if (installed.directory) {
1592
+ rmdirSync(entryName);
1593
+ } else {
1594
+ unlinkSync(entryName);
1595
+ }
1596
+ }
1597
+ } catch (rollbackError) {
1598
+ cleanupStage = false;
1599
+ throw usageError(
1600
+ `${error instanceof Error ? error.message : String(error)} `
1601
+ + `Scaffold rollback failed; recovery files were retained at `
1602
+ + `${join(requestedProjectDir, stageName)}: `
1603
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
1604
+ );
1605
+ }
1606
+ throw error;
1607
+ }
1608
+ try {
1609
+ rmSync(stageName, { recursive: true, force: true });
1610
+ stageName = null;
1611
+ } catch (error) {
1612
+ cleanupStage = false;
1613
+ throw usageError(
1614
+ `App scaffold was installed, but staging cleanup failed; recovery files were retained at `
1615
+ + `${join(requestedProjectDir, stageName)}: `
1616
+ + `${error instanceof Error ? error.message : String(error)}`,
1617
+ );
1618
+ }
1619
+ } finally {
1620
+ try {
1621
+ let targetPinnedForCleanup = false;
1622
+ if (activeTargetIdentity) {
1623
+ try {
1624
+ process.chdir(requestedProjectDir);
1625
+ targetPinnedForCleanup = pullTargetIdentitiesMatch(
1626
+ activeTargetIdentity,
1627
+ capturePullTargetIdentity('.'),
1628
+ );
1629
+ } catch {
1630
+ targetPinnedForCleanup = false;
1631
+ }
1632
+ }
1633
+ if (targetPinnedForCleanup && cleanupStage && stageName) {
1634
+ rmSync(stageName, { recursive: true, force: true });
1635
+ }
1636
+ if (
1637
+ targetPinnedForCleanup
1638
+ && readPullLockOwner(scaffoldLockName)?.id === scaffoldLockOwnerId
1639
+ ) {
1640
+ rmSync(scaffoldLockName, { recursive: true, force: true });
1641
+ }
1642
+ } finally {
1643
+ if (cwdPinned) {
1644
+ const cwdMatchesPrevious = (
1645
+ previousCwdIdentity.exists
1646
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
1647
+ );
1648
+ if (!cwdMatchesPrevious) {
1649
+ process.chdir(previousCwd);
1650
+ }
1156
1651
  }
1157
1652
  }
1158
- config = removeConfigArrayProperty(config, 'screenshots');
1159
- writeFileSync(configPath, config);
1160
1653
  }
1161
1654
 
1162
- resetScaffoldChangelog(projectDir, appName);
1655
+ return { projectDir: requestedProjectDir };
1656
+ }
1657
+
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
+ }
1163
1677
 
1164
- return { projectDir };
1678
+ function captureScaffoldTargetIdentity(targetDir) {
1679
+ try {
1680
+ const targetStat = lstatSync(targetDir, { bigint: true });
1681
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
1682
+ throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
1683
+ }
1684
+ if (readdirSync(targetDir).length > 0) {
1685
+ throw usageError(`App scaffold target must be empty: ${targetDir}`);
1686
+ }
1687
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
1688
+ } catch (error) {
1689
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
1690
+ throw error;
1691
+ }
1165
1692
  }
1166
1693
 
1167
1694
  /**
@@ -1274,89 +1801,189 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
1274
1801
  cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
1275
1802
  }
1276
1803
 
1277
- function resolveScaffoldSourceDir(fromSlug) {
1278
- const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
1279
- if (existsSync(bundledDir)) {
1280
- return bundledDir;
1281
- }
1282
- const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
1283
- if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
1284
- return monorepoDir;
1285
- }
1286
- return bundledDir;
1287
- }
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(\/|$))/;
1288
1808
 
1289
- function loadMonorepoScaffoldCatalog() {
1290
- if (!existsSync(MONOREPO_SCAFFOLDS_DIR)) {
1291
- return [];
1809
+ function readStablePinnedFile(descriptor, expectedStat, label) {
1810
+ const before = fstatSync(descriptor, { bigint: true });
1811
+ if (
1812
+ !before.isFile()
1813
+ || before.dev !== expectedStat.dev
1814
+ || before.ino !== expectedStat.ino
1815
+ ) {
1816
+ throw usageError(`${label} changed before it could be read.`);
1292
1817
  }
1293
- const scaffolds = [];
1294
- for (const entry of readdirSync(MONOREPO_SCAFFOLDS_DIR, { withFileTypes: true })) {
1295
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
1296
- continue;
1297
- }
1298
- const configPath = join(MONOREPO_SCAFFOLDS_DIR, entry.name, 'notis.config.ts');
1299
- if (!existsSync(configPath)) {
1300
- continue;
1301
- }
1302
- const configSource = readFileSync(configPath, 'utf-8');
1303
- const description = readTsStringProperty(configSource, 'description') || '';
1304
- scaffolds.push({
1305
- slug: entry.name,
1306
- name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
1307
- description,
1308
- icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
1309
- categories: readTsStringArrayProperty(configSource, 'categories'),
1310
- tagline: readTsStringProperty(configSource, 'tagline') || description,
1311
- });
1818
+ const content = readFileSync(descriptor);
1819
+ const after = fstatSync(descriptor, { bigint: true });
1820
+ if (
1821
+ after.dev !== before.dev
1822
+ || after.ino !== before.ino
1823
+ || after.size !== before.size
1824
+ || after.mtimeNs !== before.mtimeNs
1825
+ || after.ctimeNs !== before.ctimeNs
1826
+ ) {
1827
+ throw usageError(`${label} changed while it was being read.`);
1312
1828
  }
1313
- return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
1829
+ return content;
1314
1830
  }
1315
1831
 
1316
- function readTsStringProperty(source, propertyName) {
1317
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
1318
- return match ? match[2] : null;
1832
+ function captureDirectoryRevision() {
1833
+ const stat = statSync('.', { bigint: true });
1834
+ return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
1319
1835
  }
1320
1836
 
1321
- function readTsStringArrayProperty(source, propertyName) {
1322
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
1323
- if (!match) {
1324
- return [];
1837
+ function assertDirectoryRevisionUnchanged(before, label) {
1838
+ const after = captureDirectoryRevision();
1839
+ if (
1840
+ after.dev !== before.dev
1841
+ || after.ino !== before.ino
1842
+ || after.mtimeNs !== before.mtimeNs
1843
+ || after.ctimeNs !== before.ctimeNs
1844
+ ) {
1845
+ throw usageError(`${label} changed while it was being read.`);
1325
1846
  }
1326
- return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
1327
1847
  }
1328
1848
 
1329
1849
  function copyScaffoldSource(sourceDir, targetDir) {
1330
1850
  function shouldCopy(path) {
1331
1851
  const name = path.split(/[\\/]/).pop();
1332
1852
  if (!name) return true;
1333
- if (SCAFFOLD_LISTING_MEDIA.test(relative(sourceDir, path).replace(/\\/g, '/'))) {
1853
+ const relPath = relative(sourceDir, path).replace(/\\/g, '/');
1854
+ if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
1334
1855
  return false;
1335
1856
  }
1336
1857
  return !SCAFFOLD_COPY_EXCLUDES.has(name)
1858
+ && !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
1337
1859
  && !name.startsWith('.env')
1338
1860
  && !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
1339
1861
  }
1340
1862
 
1341
- function walk(src, dest) {
1342
- if (!shouldCopy(src)) {
1343
- return;
1863
+ const canonicalSource = realpathSync(sourceDir);
1864
+ const sourceIdentity = capturePullTargetIdentity(canonicalSource);
1865
+ const previousCwd = process.cwd();
1866
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1867
+ const destinationIdentity = capturePullTargetIdentity(targetDir);
1868
+ const snapshot = { directories: [], files: [] };
1869
+
1870
+ function snapshotCurrent(sourcePath, node, expectedDirectoryIdentity) {
1871
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
1872
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
1873
+ }
1874
+ const directoryRevision = captureDirectoryRevision();
1875
+ const parentIdentity = expectedDirectoryIdentity;
1876
+ for (const name of readdirSync('.')) {
1877
+ const logicalSourcePath = join(sourcePath, name);
1878
+ if (!shouldCopy(logicalSourcePath)) {
1879
+ continue;
1880
+ }
1881
+ const sourceStat = lstatSync(name, { bigint: true });
1882
+ if (sourceStat.isSymbolicLink()) {
1883
+ throw usageError(`Refusing symlinked scaffold source entry: ${logicalSourcePath}`);
1884
+ }
1885
+ if (sourceStat.isDirectory()) {
1886
+ const childNode = {
1887
+ name,
1888
+ mode: Number(sourceStat.mode & 0o777n),
1889
+ directories: [],
1890
+ files: [],
1891
+ };
1892
+ node.directories.push(childNode);
1893
+ process.chdir(name);
1894
+ const childIdentity = capturePullTargetIdentity('.');
1895
+ if (!pullTargetIdentitiesMatch(
1896
+ { exists: true, dev: sourceStat.dev, ino: sourceStat.ino },
1897
+ childIdentity,
1898
+ )) {
1899
+ throw usageError(`Scaffold source directory changed while copying: ${logicalSourcePath}`);
1900
+ }
1901
+ try {
1902
+ snapshotCurrent(logicalSourcePath, childNode, childIdentity);
1903
+ } finally {
1904
+ process.chdir('..');
1905
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
1906
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
1907
+ }
1908
+ }
1909
+ continue;
1910
+ }
1911
+ if (!sourceStat.isFile()) {
1912
+ throw usageError(`Refusing unsupported scaffold source entry: ${logicalSourcePath}`);
1913
+ }
1914
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
1915
+ try {
1916
+ node.files.push({
1917
+ name,
1918
+ mode: Number(sourceStat.mode & 0o777n),
1919
+ content: readStablePinnedFile(
1920
+ descriptor,
1921
+ sourceStat,
1922
+ `Scaffold source file ${logicalSourcePath}`,
1923
+ ),
1924
+ });
1925
+ } finally {
1926
+ closeSync(descriptor);
1927
+ }
1928
+ }
1929
+ assertDirectoryRevisionUnchanged(directoryRevision, `Scaffold source directory ${sourcePath}`);
1930
+ }
1931
+
1932
+ try {
1933
+ process.chdir(canonicalSource);
1934
+ snapshotCurrent(canonicalSource, snapshot, sourceIdentity);
1935
+ } finally {
1936
+ process.chdir(previousCwd);
1937
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
1938
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
1344
1939
  }
1345
- const stat = statSync(src);
1346
- if (stat.isDirectory()) {
1347
- mkdirSync(dest, { recursive: true });
1348
- for (const entry of readdirSync(src)) {
1349
- walk(join(src, entry), join(dest, entry));
1940
+ }
1941
+
1942
+ function writeSnapshot(node, logicalPath, expectedDirectoryIdentity) {
1943
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
1944
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
1945
+ }
1946
+ const parentIdentity = expectedDirectoryIdentity;
1947
+ for (const directory of node.directories) {
1948
+ mkdirSync(directory.name, { mode: directory.mode });
1949
+ const directoryStat = lstatSync(directory.name, { bigint: true });
1950
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1951
+ throw usageError(`Refusing unsafe scaffold destination: ${join(logicalPath, directory.name)}`);
1952
+ }
1953
+ process.chdir(directory.name);
1954
+ const childIdentity = capturePullTargetIdentity('.');
1955
+ if (!pullTargetIdentitiesMatch(
1956
+ { exists: true, dev: directoryStat.dev, ino: directoryStat.ino },
1957
+ childIdentity,
1958
+ )) {
1959
+ throw usageError(`Scaffold destination changed while copying: ${join(logicalPath, directory.name)}`);
1960
+ }
1961
+ try {
1962
+ writeSnapshot(directory, join(logicalPath, directory.name), childIdentity);
1963
+ } finally {
1964
+ process.chdir('..');
1965
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
1966
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
1967
+ }
1350
1968
  }
1351
- return;
1352
1969
  }
1353
- if (stat.isFile()) {
1354
- mkdirSync(dirname(dest), { recursive: true });
1355
- cpSync(src, dest);
1970
+ for (const file of node.files) {
1971
+ writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
1356
1972
  }
1357
1973
  }
1358
1974
 
1359
- walk(sourceDir, targetDir);
1975
+ try {
1976
+ process.chdir(targetDir);
1977
+ if (!pullTargetIdentitiesMatch(destinationIdentity, capturePullTargetIdentity('.'))) {
1978
+ throw usageError(`Scaffold destination changed while copying: ${targetDir}`);
1979
+ }
1980
+ writeSnapshot(snapshot, '', destinationIdentity);
1981
+ } finally {
1982
+ process.chdir(previousCwd);
1983
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
1984
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
1985
+ }
1986
+ }
1360
1987
  }
1361
1988
 
1362
1989
  // ---------------------------------------------------------------------------
@@ -1394,34 +2021,81 @@ export function collectArtifactFiles(projectDir) {
1394
2021
  }
1395
2022
 
1396
2023
  function shouldExcludeSourceEntry(name) {
2024
+ const casefolded = String(name || '').toLocaleLowerCase('en-US');
1397
2025
  return (
1398
- SOURCE_COPY_EXCLUDES.has(name)
1399
- || name.startsWith('.env')
1400
- || name.endsWith('.pyc')
1401
- || name.endsWith('.pyo')
2026
+ SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
2027
+ || casefolded.startsWith('.notis-app-pull-')
2028
+ || casefolded.startsWith('.notis-app-scaffold-')
2029
+ || casefolded.startsWith('.env')
2030
+ || casefolded.endsWith('.pyc')
2031
+ || casefolded.endsWith('.pyo')
1402
2032
  );
1403
2033
  }
1404
2034
 
1405
2035
  function readSourceFiles(projectDir) {
1406
2036
  const files = {};
1407
-
1408
- function walk(dir, prefix) {
1409
- const entries = readdirSync(dir, { withFileTypes: true });
1410
- for (const entry of entries) {
1411
- if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
2037
+ const canonicalProjectDir = realpathSync(projectDir);
2038
+ const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
2039
+ const previousCwd = process.cwd();
2040
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2041
+
2042
+ function walkCurrent(prefix, expectedDirectoryIdentity) {
2043
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
2044
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2045
+ }
2046
+ const directoryRevision = captureDirectoryRevision();
2047
+ const parentIdentity = expectedDirectoryIdentity;
2048
+ for (const name of readdirSync('.')) {
2049
+ if (shouldExcludeSourceEntry(name)) {
1412
2050
  continue;
1413
2051
  }
1414
- const fullPath = join(dir, entry.name);
1415
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1416
- if (entry.isDirectory()) {
1417
- walk(fullPath, relPath);
1418
- } else if (entry.isFile()) {
1419
- files[relPath] = readFileSync(fullPath);
2052
+ const entryStat = lstatSync(name, { bigint: true });
2053
+ if (entryStat.isSymbolicLink()) {
2054
+ continue;
2055
+ }
2056
+ const relPath = prefix ? `${prefix}/${name}` : name;
2057
+ if (entryStat.isDirectory()) {
2058
+ process.chdir(name);
2059
+ const childIdentity = capturePullTargetIdentity('.');
2060
+ if (!pullTargetIdentitiesMatch(
2061
+ { exists: true, dev: entryStat.dev, ino: entryStat.ino },
2062
+ childIdentity,
2063
+ )) {
2064
+ throw usageError(`App source directory changed while packaging: ${relPath}`);
2065
+ }
2066
+ try {
2067
+ walkCurrent(relPath, childIdentity);
2068
+ } finally {
2069
+ process.chdir('..');
2070
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
2071
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2072
+ }
2073
+ }
2074
+ } else if (entryStat.isFile()) {
2075
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2076
+ try {
2077
+ files[relPath] = readStablePinnedFile(
2078
+ descriptor,
2079
+ entryStat,
2080
+ `App source file ${relPath}`,
2081
+ );
2082
+ } finally {
2083
+ closeSync(descriptor);
2084
+ }
1420
2085
  }
1421
2086
  }
2087
+ assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
1422
2088
  }
1423
2089
 
1424
- walk(projectDir, '');
2090
+ try {
2091
+ process.chdir(canonicalProjectDir);
2092
+ walkCurrent('', projectIdentity);
2093
+ } finally {
2094
+ process.chdir(previousCwd);
2095
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
2096
+ throw usageError(`Working directory changed while packaging app source: ${previousCwd}`);
2097
+ }
2098
+ }
1425
2099
  return files;
1426
2100
  }
1427
2101
 
@@ -1443,9 +2117,55 @@ function cleanTarPath(name) {
1443
2117
  return parts.join('/');
1444
2118
  }
1445
2119
 
2120
+ function parsePaxPath(data) {
2121
+ if (data.length === 0 || data.length > 64 * 1024) {
2122
+ throw usageError('Refusing to extract an invalid PAX source archive header.');
2123
+ }
2124
+ let offset = 0;
2125
+ let path = null;
2126
+ while (offset < data.length) {
2127
+ const space = data.indexOf(0x20, offset);
2128
+ if (space <= offset) {
2129
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2130
+ }
2131
+ const lengthText = data.subarray(offset, space).toString('ascii');
2132
+ if (!/^\d+$/.test(lengthText)) {
2133
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2134
+ }
2135
+ const recordLength = Number.parseInt(lengthText, 10);
2136
+ const recordEnd = offset + recordLength;
2137
+ if (recordLength <= space - offset + 3 || recordEnd > data.length || data[recordEnd - 1] !== 0x0a) {
2138
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2139
+ }
2140
+ const record = data.subarray(space + 1, recordEnd - 1);
2141
+ const equals = record.indexOf(0x3d);
2142
+ if (equals <= 0) {
2143
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2144
+ }
2145
+ const key = record.subarray(0, equals).toString('ascii');
2146
+ if (key === 'path') {
2147
+ try {
2148
+ path = new TextDecoder('utf-8', { fatal: true }).decode(record.subarray(equals + 1));
2149
+ } catch {
2150
+ throw usageError('Refusing to extract a PAX source path with invalid UTF-8.');
2151
+ }
2152
+ if (!path || Buffer.byteLength(path, 'utf-8') > 4096) {
2153
+ throw usageError('Refusing to extract an invalid PAX source path.');
2154
+ }
2155
+ }
2156
+ offset = recordEnd;
2157
+ }
2158
+ if (!path) {
2159
+ throw usageError('Refusing to extract a PAX source header without a path.');
2160
+ }
2161
+ return path;
2162
+ }
2163
+
1446
2164
  function extractTarGz(buffer, targetDir) {
1447
2165
  const tar = gunzipSync(buffer);
1448
2166
  let offset = 0;
2167
+ let pendingPaxPath = null;
2168
+ const extractedPaths = new Map();
1449
2169
  while (offset + 512 <= tar.length) {
1450
2170
  const header = tar.subarray(offset, offset + 512);
1451
2171
  offset += 512;
@@ -1459,35 +2179,251 @@ function extractTarGz(buffer, targetDir) {
1459
2179
  const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
1460
2180
  const size = Number.parseInt(sizeRaw || '0', 8);
1461
2181
  const typeFlag = header[156];
1462
- const relPath = cleanTarPath(fullName);
2182
+ if (!Number.isSafeInteger(size) || size < 0 || offset + size > tar.length) {
2183
+ throw usageError('Refusing to extract a malformed source archive entry size.');
2184
+ }
2185
+ const data = tar.subarray(offset, offset + size);
2186
+ if (typeFlag === 120) {
2187
+ if (pendingPaxPath !== null) {
2188
+ throw usageError('Refusing to extract stacked PAX source archive headers.');
2189
+ }
2190
+ pendingPaxPath = parsePaxPath(data);
2191
+ offset += Math.ceil(size / 512) * 512;
2192
+ continue;
2193
+ }
2194
+
2195
+ const relPath = cleanTarPath(pendingPaxPath || fullName);
2196
+ pendingPaxPath = null;
2197
+ const parts = relPath.split('/');
2198
+ for (let index = 1; index <= parts.length; index += 1) {
2199
+ const pathPrefix = parts.slice(0, index).join('/');
2200
+ const casefoldedPath = pathPrefix.toLocaleLowerCase('en-US');
2201
+ const priorPath = extractedPaths.get(casefoldedPath);
2202
+ if (priorPath && priorPath !== pathPrefix) {
2203
+ throw usageError(
2204
+ `Refusing to extract case-colliding source archive paths: ${priorPath} and ${pathPrefix}`,
2205
+ );
2206
+ }
2207
+ extractedPaths.set(casefoldedPath, pathPrefix);
2208
+ }
2209
+ if (relPath.split('/').some((part) => shouldExcludeSourceEntry(part))) {
2210
+ throw usageError(`Refusing to extract local-only path from source archive: ${relPath}`);
2211
+ }
1463
2212
  const outputPath = join(targetDir, relPath);
1464
2213
 
1465
2214
  if (typeFlag === 53) {
1466
2215
  mkdirSync(outputPath, { recursive: true });
1467
- } else {
2216
+ } else if (typeFlag === 0 || typeFlag === 48) {
1468
2217
  mkdirSync(dirname(outputPath), { recursive: true });
1469
- writeFileSync(outputPath, tar.subarray(offset, offset + size));
2218
+ writeFileSync(outputPath, data);
2219
+ } else {
2220
+ throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
1470
2221
  }
1471
2222
 
1472
2223
  offset += Math.ceil(size / 512) * 512;
1473
2224
  }
2225
+ if (pendingPaxPath !== null) {
2226
+ throw usageError('Refusing to extract a dangling PAX source archive header.');
2227
+ }
2228
+ }
2229
+
2230
+ function collectPullPreservedPaths(targetDir) {
2231
+ const paths = [];
2232
+
2233
+ function walk(dir, prefix) {
2234
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2235
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2236
+ if (entry.isSymbolicLink() || shouldExcludeSourceEntry(entry.name)) {
2237
+ paths.push(relPath);
2238
+ continue;
2239
+ }
2240
+ if (entry.isDirectory()) {
2241
+ walk(join(dir, entry.name), relPath);
2242
+ }
2243
+ }
2244
+ }
2245
+
2246
+ if (existsSync(targetDir)) {
2247
+ walk(targetDir, '');
2248
+ }
2249
+ return paths;
2250
+ }
2251
+
2252
+ function readPullLockOwner(lockDirectory) {
2253
+ try {
2254
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
2255
+ } catch {
2256
+ return null;
2257
+ }
2258
+ }
2259
+
2260
+ function capturePullTargetIdentity(targetDir) {
2261
+ try {
2262
+ const targetStat = lstatSync(targetDir, { bigint: true });
2263
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
2264
+ throw usageError(`Refusing to pull app source through an unsafe target: ${targetDir}`);
2265
+ }
2266
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
2267
+ } catch (error) {
2268
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
2269
+ throw error;
2270
+ }
2271
+ }
2272
+
2273
+ function assertPullTargetIdentity(targetDir, expected) {
2274
+ const current = capturePullTargetIdentity(targetDir);
2275
+ if (!pullTargetIdentitiesMatch(current, expected)) {
2276
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2277
+ }
2278
+ }
2279
+
2280
+ function pullTargetIdentitiesMatch(left, right) {
2281
+ return (
2282
+ left.exists === right.exists
2283
+ && (!left.exists || (left.dev === right.dev && left.ino === right.ino))
2284
+ );
1474
2285
  }
1475
2286
 
1476
- export async function pullAppSource({
2287
+ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity) {
2288
+ let currentCanonicalParent;
2289
+ try {
2290
+ currentCanonicalParent = realpathSync(parentDir);
2291
+ } catch {
2292
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
2293
+ }
2294
+ if (
2295
+ currentCanonicalParent !== canonicalParent
2296
+ || !pullTargetIdentitiesMatch(
2297
+ capturePullTargetIdentity(canonicalParent),
2298
+ expectedIdentity,
2299
+ )
2300
+ ) {
2301
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
2302
+ }
2303
+ }
2304
+
2305
+ async function withPullTargetLock(targetDir, callback) {
2306
+ const requestedTarget = resolve(targetDir);
2307
+ const parentDir = dirname(requestedTarget);
2308
+ mkdirSync(parentDir, { recursive: true });
2309
+ const canonicalParent = realpathSync(parentDir);
2310
+ const canonicalParentIdentity = capturePullTargetIdentity(canonicalParent);
2311
+ const initialRequestedIdentity = capturePullTargetIdentity(requestedTarget);
2312
+ const canonicalTarget = initialRequestedIdentity.exists
2313
+ ? realpathSync(requestedTarget)
2314
+ : join(canonicalParent, basename(requestedTarget));
2315
+ if (
2316
+ initialRequestedIdentity.exists
2317
+ && !pullTargetIdentitiesMatch(
2318
+ initialRequestedIdentity,
2319
+ capturePullTargetIdentity(canonicalTarget),
2320
+ )
2321
+ ) {
2322
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2323
+ }
2324
+ const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
2325
+ const ownerId = `${process.pid}.${randomUUID()}`;
2326
+ const deadline = Date.now() + PULL_LOCK_TIMEOUT_MS;
2327
+
2328
+ for (;;) {
2329
+ try {
2330
+ mkdirSync(lockDirectory, { mode: 0o700 });
2331
+ writeFileSync(
2332
+ join(lockDirectory, 'owner'),
2333
+ JSON.stringify({ id: ownerId, pid: process.pid, at: Date.now() }),
2334
+ { mode: 0o600 },
2335
+ );
2336
+ break;
2337
+ } catch (error) {
2338
+ if (error?.code !== 'EEXIST') throw error;
2339
+ let lockStat;
2340
+ try {
2341
+ lockStat = lstatSync(lockDirectory);
2342
+ } catch (statError) {
2343
+ if (statError?.code === 'ENOENT') continue;
2344
+ throw statError;
2345
+ }
2346
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
2347
+ throw usageError(`Refusing to use unsafe app source pull lock: ${lockDirectory}`);
2348
+ }
2349
+ if (Date.now() >= deadline) {
2350
+ throw usageError(
2351
+ `Timed out waiting to pull app source into ${targetDir}. `
2352
+ + `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
2353
+ );
2354
+ }
2355
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, PULL_LOCK_POLL_MS));
2356
+ }
2357
+ }
2358
+
2359
+ try {
2360
+ const assertOwnership = () => {
2361
+ if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
2362
+ throw usageError(`Lost the app source pull lock for ${targetDir}.`);
2363
+ }
2364
+ };
2365
+ const assertParentIdentity = () => assertPullParentIdentity(
2366
+ parentDir,
2367
+ canonicalParent,
2368
+ canonicalParentIdentity,
2369
+ );
2370
+ assertParentIdentity();
2371
+ const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
2372
+ if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
2373
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2374
+ }
2375
+ return await callback(
2376
+ assertOwnership,
2377
+ assertParentIdentity,
2378
+ canonicalTarget,
2379
+ lockedTargetIdentity,
2380
+ );
2381
+ } finally {
2382
+ try {
2383
+ if (readPullLockOwner(lockDirectory)?.id === ownerId) {
2384
+ rmSync(lockDirectory, { recursive: true, force: true });
2385
+ }
2386
+ } catch {
2387
+ // A reclaimed lock belongs to its new owner and must not be removed.
2388
+ }
2389
+ }
2390
+ }
2391
+
2392
+ export async function pullAppSource(args) {
2393
+ const targetDir = resolve(args.targetDir);
2394
+ return withPullTargetLock(
2395
+ targetDir,
2396
+ (
2397
+ assertLockOwnership,
2398
+ assertParentIdentity,
2399
+ canonicalTarget,
2400
+ targetIdentity,
2401
+ ) => pullAppSourceUnlocked(
2402
+ { ...args, targetDir: canonicalTarget },
2403
+ assertLockOwnership,
2404
+ assertParentIdentity,
2405
+ targetIdentity,
2406
+ ),
2407
+ );
2408
+ }
2409
+
2410
+ async function pullAppSourceUnlocked({
1477
2411
  apiBase,
1478
2412
  jwt,
1479
2413
  appId,
1480
2414
  targetDir,
1481
2415
  version = 'latest',
1482
2416
  force = false,
1483
- }) {
1484
- if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
2417
+ profileKey = null,
2418
+ }, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
2419
+ assertLockOwnership();
2420
+ assertParentIdentity();
2421
+ const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
2422
+ if (targetWasNonEmpty) {
1485
2423
  if (!force) {
1486
2424
  throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
1487
2425
  }
1488
- rmSync(targetDir, { recursive: true, force: true });
1489
2426
  }
1490
- mkdirSync(targetDir, { recursive: true });
1491
2427
 
1492
2428
  const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
1493
2429
  const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
@@ -1501,17 +2437,229 @@ export async function pullAppSource({
1501
2437
  const contentDisposition = response.headers.get('content-disposition') || '';
1502
2438
  const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
1503
2439
  const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
1504
- extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
1505
- const linkedVersion = pulledVersion || (
1506
- String(version || 'latest') === 'latest'
1507
- ? undefined
1508
- : Number.parseInt(String(version), 10)
1509
- );
1510
- writeLinkedState(targetDir, {
1511
- app_id: appId,
1512
- ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
1513
- linked_at: new Date().toISOString(),
1514
- });
2440
+ const requestedVersion = String(version || 'latest') === 'latest'
2441
+ ? null
2442
+ : Number.parseInt(String(version), 10);
2443
+ if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
2444
+ throw usageError('The app source response did not identify a valid positive version.');
2445
+ }
2446
+ if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
2447
+ throw usageError(`Invalid app source version: ${version}`);
2448
+ }
2449
+ if (
2450
+ requestedVersion !== null
2451
+ && pulledVersion !== requestedVersion
2452
+ ) {
2453
+ throw usageError(
2454
+ `Requested app source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
2455
+ );
2456
+ }
2457
+ const linkedVersion = pulledVersion;
2458
+ const archiveBuffer = Buffer.from(await response.arrayBuffer());
2459
+ assertParentIdentity();
2460
+ assertLockOwnership();
2461
+
2462
+ // A failed download or malformed archive must never damage an existing
2463
+ // checkout. Extract into a transaction directory under the pinned target,
2464
+ // then replace
2465
+ // only source-managed entries. Local-only state such as .git, .env files,
2466
+ // dependencies, symlinks, build output, and .notis runtime directories is
2467
+ // moved aside recursively and restored into the new source tree.
2468
+ let cleanupTransaction = true;
2469
+ let cwdPinned = false;
2470
+ let transactionDir = null;
2471
+ let transactionDisplayPath = null;
2472
+ let operationError = null;
2473
+ const previousCwd = process.cwd();
2474
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2475
+ try {
2476
+ const parentDir = dirname(targetDir);
2477
+ const targetName = basename(targetDir);
2478
+ const parentIdentity = capturePullTargetIdentity(parentDir);
2479
+ assertParentIdentity();
2480
+ process.chdir(parentDir);
2481
+ cwdPinned = true;
2482
+ assertPullTargetIdentity('.', parentIdentity);
2483
+
2484
+ assertParentIdentity();
2485
+ assertPullTargetIdentity(targetName, initialTargetIdentity);
2486
+ if (!initialTargetIdentity.exists) {
2487
+ try {
2488
+ mkdirSync(targetName);
2489
+ } catch (error) {
2490
+ if (error?.code === 'EEXIST') {
2491
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2492
+ }
2493
+ throw error;
2494
+ }
2495
+ }
2496
+ const activeTargetIdentity = capturePullTargetIdentity(targetName);
2497
+ if (
2498
+ initialTargetIdentity.exists
2499
+ && (
2500
+ activeTargetIdentity.dev !== initialTargetIdentity.dev
2501
+ || activeTargetIdentity.ino !== initialTargetIdentity.ino
2502
+ )
2503
+ ) {
2504
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2505
+ }
2506
+ assertLockOwnership();
2507
+ process.chdir(targetName);
2508
+ assertPullTargetIdentity('.', activeTargetIdentity);
2509
+ const targetRoot = '.';
2510
+ assertLinkedStatePathSafe(targetRoot);
2511
+ const preservedPaths = collectPullPreservedPaths(targetRoot);
2512
+
2513
+ const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
2514
+ transactionDir = transactionName;
2515
+ transactionDisplayPath = join(targetDir, transactionName);
2516
+ const stageDir = join(transactionDir, 'stage');
2517
+ const backupDir = join(transactionDir, 'backup');
2518
+ const preservedDir = join(transactionDir, 'preserved');
2519
+ mkdirSync(stageDir, { recursive: true });
2520
+ mkdirSync(backupDir, { recursive: true });
2521
+ mkdirSync(preservedDir, { recursive: true });
2522
+
2523
+ extractTarGz(archiveBuffer, stageDir);
2524
+ const stagedEntries = readdirSync(stageDir);
2525
+ if (stagedEntries.length === 0) {
2526
+ throw usageError('The app source archive did not contain any editable source files.');
2527
+ }
2528
+
2529
+ const previousStatePath = join(targetRoot, STATE_FILE);
2530
+ const backupStatePath = join(backupDir, STATE_FILE);
2531
+ const hadPreviousState = existsSync(previousStatePath);
2532
+ if (hadPreviousState) {
2533
+ mkdirSync(dirname(backupStatePath), { recursive: true });
2534
+ cpSync(previousStatePath, backupStatePath);
2535
+ }
2536
+
2537
+ const movedPreserved = [];
2538
+ const movedOriginal = [];
2539
+ const movedStaged = [];
2540
+ const restoredPreserved = [];
2541
+ try {
2542
+ for (const relPath of preservedPaths) {
2543
+ const preservedPath = join(preservedDir, relPath);
2544
+ mkdirSync(dirname(preservedPath), { recursive: true });
2545
+ renameSync(join(targetRoot, relPath), preservedPath);
2546
+ movedPreserved.push(relPath);
2547
+ }
2548
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2549
+ assertLockOwnership();
2550
+ const managedTargetEntries = readdirSync(targetRoot)
2551
+ .filter((name) => name !== transactionName);
2552
+ for (const name of managedTargetEntries) {
2553
+ renameSync(join(targetRoot, name), join(backupDir, name));
2554
+ movedOriginal.push(name);
2555
+ }
2556
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2557
+ assertLockOwnership();
2558
+ for (const name of stagedEntries) {
2559
+ renameSync(join(stageDir, name), join(targetRoot, name));
2560
+ movedStaged.push(name);
2561
+ }
2562
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2563
+ assertLockOwnership();
2564
+ for (const relPath of movedPreserved) {
2565
+ const targetPath = join(targetRoot, relPath);
2566
+ try {
2567
+ lstatSync(targetPath);
2568
+ throw usageError(
2569
+ `Local-only path conflicts with pulled source: ${relPath}. Move it aside and retry.`,
2570
+ );
2571
+ } catch (error) {
2572
+ if (error?.code !== 'ENOENT') throw error;
2573
+ }
2574
+ mkdirSync(dirname(targetPath), { recursive: true });
2575
+ renameSync(join(preservedDir, relPath), targetPath);
2576
+ restoredPreserved.push(relPath);
2577
+ }
2578
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2579
+ assertLockOwnership();
2580
+ writeLinkedState(targetRoot, {
2581
+ app_id: appId,
2582
+ ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
2583
+ linked_at: new Date().toISOString(),
2584
+ }, profileKey);
2585
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2586
+ assertLockOwnership();
2587
+ } catch (error) {
2588
+ try {
2589
+ for (const relPath of restoredPreserved.reverse()) {
2590
+ const preservedPath = join(preservedDir, relPath);
2591
+ mkdirSync(dirname(preservedPath), { recursive: true });
2592
+ renameSync(join(targetRoot, relPath), preservedPath);
2593
+ }
2594
+ for (const name of movedStaged.reverse()) {
2595
+ renameSync(join(targetRoot, name), join(stageDir, name));
2596
+ }
2597
+ for (const name of movedOriginal.reverse()) {
2598
+ renameSync(join(backupDir, name), join(targetRoot, name));
2599
+ }
2600
+ for (const relPath of movedPreserved.reverse()) {
2601
+ const targetPath = join(targetRoot, relPath);
2602
+ mkdirSync(dirname(targetPath), { recursive: true });
2603
+ renameSync(join(preservedDir, relPath), targetPath);
2604
+ }
2605
+ if (hadPreviousState) {
2606
+ cpSync(backupStatePath, previousStatePath);
2607
+ } else {
2608
+ rmSync(previousStatePath, { force: true });
2609
+ }
2610
+ } catch (rollbackError) {
2611
+ cleanupTransaction = false;
2612
+ throw usageError(
2613
+ `${error instanceof Error ? error.message : String(error)} `
2614
+ + `Rollback failed; recovery files were retained at ${transactionDisplayPath}: `
2615
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
2616
+ );
2617
+ }
2618
+ throw error;
2619
+ }
2620
+ } catch (error) {
2621
+ operationError = error;
2622
+ throw error;
2623
+ } finally {
2624
+ let cleanupError = null;
2625
+ try {
2626
+ if (cleanupTransaction && transactionDir) {
2627
+ rmSync(transactionDir, { recursive: true, force: true });
2628
+ }
2629
+ } catch (error) {
2630
+ cleanupError = error;
2631
+ } finally {
2632
+ if (cwdPinned) {
2633
+ const cwdMatchesTarget = (
2634
+ previousCwdIdentity.exists
2635
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
2636
+ );
2637
+ if (!cwdMatchesTarget) {
2638
+ try {
2639
+ process.chdir(previousCwd);
2640
+ } catch {
2641
+ // The command is about to return; never trade recovered source for a cwd error.
2642
+ }
2643
+ }
2644
+ }
2645
+ }
2646
+ if (cleanupError) {
2647
+ const cleanupMessage = cleanupError instanceof Error
2648
+ ? cleanupError.message
2649
+ : String(cleanupError);
2650
+ if (operationError) {
2651
+ throw usageError(
2652
+ `${operationError instanceof Error ? operationError.message : String(operationError)} `
2653
+ + `Transaction cleanup also failed; recovery files were retained at `
2654
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2655
+ );
2656
+ }
2657
+ throw usageError(
2658
+ `App source was updated, but transaction cleanup failed; recovery files were retained at `
2659
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2660
+ );
2661
+ }
2662
+ }
1515
2663
 
1516
2664
  return { projectDir: targetDir, version: pulledVersion || version };
1517
2665
  }
@@ -1692,7 +2840,7 @@ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, man
1692
2840
  },
1693
2841
  body: JSON.stringify({
1694
2842
  manifest: { ...manifest, version: newVersion },
1695
- accent: manifest?.app?.accent ?? null,
2843
+ ...appRowFieldsFromManifest(manifest),
1696
2844
  updated_at: new Date().toISOString(),
1697
2845
  }),
1698
2846
  });