@notis_ai/cli 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +125 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +44 -18
  10. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  11. package/skills/notis-onboarding/BRIEF.md +16 -0
  12. package/src/agent-hook-entry.js +5 -0
  13. package/src/cli.js +23 -14
  14. package/src/command-specs/agents.js +392 -0
  15. package/src/command-specs/apps.js +1090 -418
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/handover.js +374 -0
  18. package/src/command-specs/index.js +9 -0
  19. package/src/command-specs/onboarding.js +69 -5
  20. package/src/command-specs/skills.js +56 -0
  21. package/src/command-specs/tools.js +6 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/app-dev-consumers.js +154 -0
  25. package/src/runtime/app-dev-host-lock.js +80 -0
  26. package/src/runtime/app-dev-roots.js +284 -0
  27. package/src/runtime/app-dev-server.js +229 -30
  28. package/src/runtime/app-dev-sessions.js +105 -145
  29. package/src/runtime/app-platform.js +1499 -161
  30. package/src/runtime/app-registry-scaffolds.js +367 -0
  31. package/src/runtime/base-skills.d.ts +20 -0
  32. package/src/runtime/base-skills.js +167 -0
  33. package/src/runtime/delegated-context.js +68 -0
  34. package/src/runtime/git.js +233 -0
  35. package/src/runtime/login-listener.js +15 -0
  36. package/src/runtime/oauth.js +1506 -141
  37. package/src/runtime/output.js +7 -0
  38. package/src/runtime/ports.js +16 -0
  39. package/src/runtime/profiles.js +0 -5
  40. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  41. package/src/runtime/skill-sync/index.ts +644 -0
  42. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  43. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  44. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  45. package/src/runtime/skill-sync/types.ts +103 -0
  46. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  47. package/src/runtime/store-screenshot.js +6 -1
  48. package/src/runtime/sync-skills.d.ts +37 -0
  49. package/src/runtime/sync-skills.js +215 -0
  50. package/src/runtime/transport.js +19 -2
  51. package/template/.harness/index.html.tmpl +116 -47
  52. package/template/app/layout.tsx +5 -1
  53. package/template/notis.config.ts +1 -0
  54. package/template/packages/sdk/package.json +2 -1
  55. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  56. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  57. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  58. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  59. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  60. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  61. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  62. package/template/packages/sdk/src/config.ts +71 -0
  63. package/template/packages/sdk/src/documents.ts +28 -1
  64. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  65. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  66. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  67. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  68. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  69. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  70. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  71. package/template/packages/sdk/src/index.ts +64 -2
  72. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  73. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  74. package/template/packages/sdk/src/interactions.ts +41 -0
  75. package/template/packages/sdk/src/provider.tsx +2 -1
  76. package/template/packages/sdk/src/runtime.ts +219 -3
  77. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  78. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  79. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  80. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  81. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  82. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  83. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  84. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  85. package/dist/scaffolds/notis-database/components.json +0 -20
  86. package/dist/scaffolds/notis-database/index.html +0 -12
  87. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  88. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  89. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  91. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  92. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  93. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  94. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  95. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  96. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  97. package/dist/scaffolds/notis-database/package.json +0 -32
  98. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  99. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  100. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  101. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  102. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  103. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  104. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
  105. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  115. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  116. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  117. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
  118. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  119. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
  120. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  121. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  122. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  123. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  124. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  125. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  126. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  127. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  128. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  129. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  130. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  131. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  132. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  133. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  134. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  135. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  136. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  137. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  138. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  139. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  140. package/dist/scaffolds/notis-journal/components.json +0 -20
  141. package/dist/scaffolds/notis-journal/index.html +0 -12
  142. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  148. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  149. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  150. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  151. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  152. package/dist/scaffolds/notis-journal/package.json +0 -34
  153. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  178. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  179. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  180. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  181. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  182. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
  183. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  184. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  185. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  186. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  187. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  188. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  189. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  190. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  191. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  192. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  193. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  194. package/dist/scaffolds/notis-notes/components.json +0 -20
  195. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  196. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  197. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  203. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  204. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  205. package/dist/scaffolds/notis-notes/package.json +0 -35
  206. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  207. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  231. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  232. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  233. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  234. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  235. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  236. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  237. package/dist/scaffolds/notis-random/README.md +0 -33
  238. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  239. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  240. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  241. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  242. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  243. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  244. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  245. package/dist/scaffolds/notis-random/components.json +0 -20
  246. package/dist/scaffolds/notis-random/index.html +0 -12
  247. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  248. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  249. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  250. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  251. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  252. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  253. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  254. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  255. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  257. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  258. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  259. package/dist/scaffolds/notis-random/package.json +0 -36
  260. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  261. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  262. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  263. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  264. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  266. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
  267. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
  268. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  269. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  270. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  271. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  279. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
  280. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  281. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
  282. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  283. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  284. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  285. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  286. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  287. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  288. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  289. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  290. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  291. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  292. package/dist/scaffolds.json +0 -49
  293. package/template/metadata/screenshot-1.png +0 -0
  294. package/template/metadata/screenshot-2.png +0 -0
  295. package/template/metadata/screenshot-3.png +0 -0
@@ -7,15 +7,18 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { cpSync, existsSync, mkdirSync, readFileSync, 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
- import { readAppChangelog } from './app-changelog.js';
21
+ import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
19
22
 
20
23
  const NOTIS_DIR = '.notis';
21
24
  const STATE_FILE = join(NOTIS_DIR, 'state.json');
@@ -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',
@@ -44,21 +43,73 @@ const SOURCE_COPY_EXCLUDES = new Set([
44
43
  'dist',
45
44
  'tsconfig.tsbuildinfo',
46
45
  '.DS_Store',
46
+ // Interpreter droppings: a stray `python -m py_compile` in a skill's
47
+ // scripts/ directory must not ship version-specific bytecode in the bundle.
48
+ '__pycache__',
47
49
  ]);
50
+ const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
51
+ [...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
52
+ );
48
53
  const SCAFFOLD_COPY_EXCLUDES = new Set([
49
54
  ...SOURCE_COPY_EXCLUDES,
50
55
  'coverage',
51
56
  '.next',
52
57
  '.turbo',
53
58
  ]);
59
+ // Listing media describes the scaffold's own Store entry, so a new project must
60
+ // never inherit it. Scaffold packaging still ships these files -- only the
61
+ // `apps init` copy drops them. Screenshots only: `screenshot-fixtures.json` is
62
+ // not listing media, it is the stub data the dev harness serves, and dropping
63
+ // it would make every route of a fresh project render its empty state.
64
+ const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
65
+ 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));
70
+ // A directory-declared skill ships every supporting file to the sandbox, so it
71
+ // needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
72
+ // handful of scripts always fits, and far below the bundle machinery's own
73
+ // limits so an accidental asset dump fails on the client with a clear message.
74
+ export const MAX_APP_SKILL_BUNDLE_BYTES = 5 * 1024 * 1024;
54
75
  let appConfigImportNonce = 0;
55
76
 
56
77
  // ---------------------------------------------------------------------------
57
78
  // Project directory resolution
58
79
  // ---------------------------------------------------------------------------
59
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
+
60
111
  export function resolveProjectDir(inputDir = '.') {
61
- return resolve(process.cwd(), inputDir);
112
+ return resolve(process.cwd(), expandHomePath(inputDir));
62
113
  }
63
114
 
64
115
  export function getBundleDir(projectDir) {
@@ -427,6 +478,39 @@ export function resolveListingScreenshots(projectDir, appConfig = null) {
427
478
  });
428
479
  }
429
480
 
481
+ /**
482
+ * Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
483
+ * does not define. A missing scenario is silent at capture time -- the harness
484
+ * simply falls back to the default fixtures -- so it is reported as a warning.
485
+ */
486
+ export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
487
+ const named = screenshots.filter((entry) => entry?.scenario);
488
+ if (named.length === 0) {
489
+ return [];
490
+ }
491
+ const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
492
+ if (!existsSync(fixturesPath)) {
493
+ return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
494
+ }
495
+ let defined;
496
+ try {
497
+ const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
498
+ const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
499
+ defined = new Set(Object.keys(scenarios));
500
+ } catch {
501
+ return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
502
+ }
503
+ const warnings = [];
504
+ for (const entry of named) {
505
+ if (!defined.has(entry.scenario)) {
506
+ warnings.push(
507
+ `${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
508
+ );
509
+ }
510
+ }
511
+ return warnings;
512
+ }
513
+
430
514
  export function inspectListingReadiness(projectDir, appConfig = null) {
431
515
  const config = appConfig || {};
432
516
  const warnings = [];
@@ -738,7 +822,7 @@ export function generateManifest(appConfig, projectDir) {
738
822
  const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
739
823
  const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
740
824
  key: skill.key,
741
- path: String(skill.path || '').replace(/^\.\/+/, ''),
825
+ path: normalizeAppSkillManifestPath(skill.path),
742
826
  name: skill.name,
743
827
  description: skill.description || null,
744
828
  }));
@@ -812,11 +896,25 @@ export function generateManifest(appConfig, projectDir) {
812
896
  databases,
813
897
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
814
898
  tools: appConfig.tools || [],
899
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
815
900
  skills,
816
901
  onboarding: appConfig.onboarding || null,
817
902
  };
818
903
  }
819
904
 
905
+ export function normalizeAppToolBindings(bindings) {
906
+ return (Array.isArray(bindings) ? bindings : [])
907
+ .map((binding) => {
908
+ const name = typeof binding?.name === 'string' ? binding.name.trim() : '';
909
+ const providerToolName =
910
+ typeof binding?.providerToolName === 'string' ? binding.providerToolName.trim() : '';
911
+ return name && providerToolName
912
+ ? { name, provider_tool_name: providerToolName }
913
+ : null;
914
+ })
915
+ .filter(Boolean);
916
+ }
917
+
820
918
  /**
821
919
  * Keeps only capabilities the platform actually understands, at the exact
822
920
  * values it accepts. An unknown key or value is dropped rather than passed
@@ -830,12 +928,54 @@ export function normalizeAppCapabilities(capabilities) {
830
928
  if (capabilities.workspaceDatabases === 'read') {
831
929
  normalized.workspaceDatabases = 'read';
832
930
  }
931
+ if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
932
+ normalized.cloudComputer = capabilities.cloudComputer;
933
+ }
833
934
  return normalized;
834
935
  }
835
936
 
937
+ /**
938
+ * Manifest form of a declared skill path: no leading `./`, no trailing slash.
939
+ * A directory declaration is the same string as the source-tree prefix the
940
+ * server matches the uploaded source files against.
941
+ */
942
+ export function normalizeAppSkillManifestPath(sourcePath) {
943
+ return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
944
+ }
945
+
946
+ /**
947
+ * Every packageable file under a declared skill directory, relative to that
948
+ * directory, in stable order. Excludes match `readSourceFiles` so the files a
949
+ * dev session sends inline are exactly the ones a deploy uploads as source.
950
+ */
951
+ function readAppSkillDirectoryFiles(skillDir) {
952
+ const entries = [];
953
+
954
+ function walk(dir, prefix) {
955
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
956
+ if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
957
+ continue;
958
+ }
959
+ const fullPath = join(dir, entry.name);
960
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
961
+ if (entry.isDirectory()) {
962
+ walk(fullPath, relPath);
963
+ } else if (entry.isFile()) {
964
+ entries.push({ path: relPath, absolutePath: fullPath });
965
+ }
966
+ }
967
+ }
968
+
969
+ walk(skillDir, '');
970
+ // Byte order, not locale order: the server sorts the same file set the same
971
+ // way before hashing it, so a dev session and a deploy agree on the hash.
972
+ return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
973
+ }
974
+
836
975
  export function resolveConfiguredAppSkills(appConfig, projectDir) {
837
976
  const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
838
977
  const projectRoot = resolve(projectDir);
978
+ const realProjectRoot = realpathSync(projectRoot);
839
979
  const seenKeys = new Set();
840
980
 
841
981
  return configured.map((skill, index) => {
@@ -855,7 +995,49 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
855
995
  if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
856
996
  throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
857
997
  }
858
- if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
998
+ if (!existsSync(absolutePath)) {
999
+ throw usageError(`App skill entrypoint not found: ${sourcePath}`);
1000
+ }
1001
+ if (lstatSync(absolutePath).isSymbolicLink()) {
1002
+ throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
1003
+ }
1004
+ const realAbsolutePath = realpathSync(absolutePath);
1005
+ const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
1006
+ if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
1007
+ throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
1008
+ }
1009
+
1010
+ const description = typeof skill.description === 'string' ? skill.description.trim() : null;
1011
+ const stats = statSync(absolutePath);
1012
+ if (stats.isDirectory()) {
1013
+ const entries = readAppSkillDirectoryFiles(absolutePath);
1014
+ if (!entries.some((entry) => entry.path === 'SKILL.md')) {
1015
+ throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
1016
+ }
1017
+ const bundleFiles = entries.map((entry) => ({
1018
+ path: entry.path,
1019
+ content: readFileSync(entry.absolutePath),
1020
+ }));
1021
+ const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
1022
+ if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
1023
+ throw usageError(
1024
+ `App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
1025
+ );
1026
+ }
1027
+ const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
1028
+ return {
1029
+ key,
1030
+ path: relativePath,
1031
+ name,
1032
+ description,
1033
+ skill_md: skillMd.content.toString('utf8'),
1034
+ bundle_files: bundleFiles.map((entry) => ({
1035
+ path: entry.path,
1036
+ content_b64: entry.content.toString('base64'),
1037
+ })),
1038
+ };
1039
+ }
1040
+ if (!stats.isFile()) {
859
1041
  throw usageError(`App skill entrypoint not found: ${sourcePath}`);
860
1042
  }
861
1043
 
@@ -863,7 +1045,7 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
863
1045
  key,
864
1046
  path: relativePath,
865
1047
  name,
866
- description: typeof skill.description === 'string' ? skill.description.trim() : null,
1048
+ description,
867
1049
  skill_md: readFileSync(absolutePath, 'utf8'),
868
1050
  };
869
1051
  });
@@ -934,21 +1116,212 @@ function copyMetadataAssets(projectDir) {
934
1116
  // Linking
935
1117
  // ---------------------------------------------------------------------------
936
1118
 
937
- export function readLinkedState(projectDir) {
1119
+ const PROFILE_LINK_FIELDS = new Set([
1120
+ 'app_id',
1121
+ 'linked_at',
1122
+ 'deployed_at',
1123
+ 'version',
1124
+ 'dev_app_id',
1125
+ 'dev_linked_at',
1126
+ 'auto_linked_at',
1127
+ 'cloud_computer_shell_consent',
1128
+ ]);
1129
+
1130
+ function splitLinkedState(state) {
1131
+ const base = {};
1132
+ const link = {};
1133
+ for (const [key, value] of Object.entries(state || {})) {
1134
+ if (key === 'profiles') continue;
1135
+ (PROFILE_LINK_FIELDS.has(key) ? link : base)[key] = value;
1136
+ }
1137
+ return { base, link };
1138
+ }
1139
+
1140
+ export function appLinkedStateProfileKey({ apiBase, userId }) {
1141
+ const normalizedApi = String(apiBase || '').trim().replace(/\/$/, '');
1142
+ const normalizedUser = String(userId || '').trim();
1143
+ if (!normalizedApi || !normalizedUser) return null;
1144
+ return Buffer.from(`${normalizedApi}\0${normalizedUser}`).toString('base64url');
1145
+ }
1146
+
1147
+ export function readLinkedState(projectDir, profileKey = null) {
938
1148
  const statePath = join(projectDir, STATE_FILE);
1149
+ assertLinkedStatePathSafe(projectDir);
939
1150
  if (!existsSync(statePath)) return null;
940
- return JSON.parse(readFileSync(statePath, 'utf-8'));
1151
+ const raw = JSON.parse(readFileSync(statePath, 'utf-8'));
1152
+ if (!profileKey) return raw;
1153
+ const { base, link: legacyLink } = splitLinkedState(raw);
1154
+ const profiles = raw?.profiles && typeof raw.profiles === 'object' ? raw.profiles : {};
1155
+ const scoped = profiles[profileKey];
1156
+ return {
1157
+ ...base,
1158
+ ...(scoped && typeof scoped === 'object' ? scoped : legacyLink),
1159
+ };
941
1160
  }
942
1161
 
943
- export function writeLinkedState(projectDir, state) {
1162
+ function readStateWriteLockOwner(lockPath) {
1163
+ try {
1164
+ return readFileSync(join(lockPath, 'owner'), 'utf8').trim();
1165
+ } catch {
1166
+ return null;
1167
+ }
1168
+ }
1169
+
1170
+ function reclaimStaleStateWriteLock(lockPath, observedOwner, observedMtimeMs) {
1171
+ const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
1172
+ try {
1173
+ renameSync(lockPath, quarantinePath);
1174
+ } catch (error) {
1175
+ if (error?.code === 'ENOENT') return false;
1176
+ throw error;
1177
+ }
1178
+
1179
+ let unchanged = false;
1180
+ try {
1181
+ const quarantinedStat = lstatSync(quarantinePath);
1182
+ unchanged = readStateWriteLockOwner(quarantinePath) === observedOwner
1183
+ && quarantinedStat.mtimeMs === observedMtimeMs;
1184
+ } catch (error) {
1185
+ try {
1186
+ renameSync(quarantinePath, lockPath);
1187
+ } catch {
1188
+ // Fail closed below if the quarantined lock cannot be restored.
1189
+ }
1190
+ throw error;
1191
+ }
1192
+
1193
+ if (!unchanged) {
1194
+ try {
1195
+ renameSync(quarantinePath, lockPath);
1196
+ } catch {
1197
+ throw usageError(`Notis app state lock changed while it was being recovered: ${lockPath}`);
1198
+ }
1199
+ return false;
1200
+ }
1201
+
1202
+ rmSync(quarantinePath, { recursive: true, force: true });
1203
+ return true;
1204
+ }
1205
+
1206
+ function withStateWriteLock(statePath, callback) {
1207
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
1208
+ const lockPath = `${statePath}.write-lock`;
1209
+ const ownerId = `${process.pid}.${randomUUID()}`;
1210
+ const startedAt = Date.now();
1211
+ while (true) {
1212
+ try {
1213
+ mkdirSync(lockPath, { mode: 0o700 });
1214
+ try {
1215
+ writeFileSync(join(lockPath, 'owner'), ownerId, { mode: 0o600, flag: 'wx' });
1216
+ } catch (error) {
1217
+ // A stale-lock recovery may have moved this candidate while the owner
1218
+ // record was being written. Never remove a replacement writer's lock.
1219
+ if (readStateWriteLockOwner(lockPath) === ownerId) {
1220
+ rmSync(lockPath, { recursive: true, force: true });
1221
+ }
1222
+ throw error;
1223
+ }
1224
+ break;
1225
+ } catch (error) {
1226
+ if (error?.code !== 'EEXIST') throw error;
1227
+ try {
1228
+ const lockStat = lstatSync(lockPath);
1229
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
1230
+ throw usageError(`Refusing unsafe Notis app state lock: ${lockPath}`);
1231
+ }
1232
+ const observedOwner = readStateWriteLockOwner(lockPath);
1233
+ const ownerPidText = String(observedOwner || '').split('.')[0];
1234
+ const ownerPid = /^\d+$/.test(ownerPidText) ? Number.parseInt(ownerPidText, 10) : null;
1235
+ // PID liveness alone is insufficient: operating systems can reuse a
1236
+ // crashed writer's PID for an unrelated process. A linked-state write
1237
+ // is synchronous and normally holds this lock only for milliseconds,
1238
+ // so an aged lock is stale even when that numeric PID now exists.
1239
+ let stale = Date.now() - lockStat.mtimeMs >= STATE_WRITE_LOCK_STALE_MS;
1240
+ if (!stale && Number.isInteger(ownerPid) && ownerPid > 0) {
1241
+ try {
1242
+ process.kill(ownerPid, 0);
1243
+ } catch (ownerError) {
1244
+ if (ownerError?.code === 'ESRCH') {
1245
+ stale = true;
1246
+ }
1247
+ }
1248
+ }
1249
+ if (stale && reclaimStaleStateWriteLock(lockPath, observedOwner, lockStat.mtimeMs)) {
1250
+ continue;
1251
+ }
1252
+ } catch (statError) {
1253
+ if (statError?.code === 'ENOENT') continue;
1254
+ throw statError;
1255
+ }
1256
+ if (Date.now() - startedAt >= STATE_WRITE_LOCK_TIMEOUT_MS) {
1257
+ throw usageError(`Timed out waiting to update Notis app state: ${statePath}`);
1258
+ }
1259
+ Atomics.wait(stateWriteLockWait, 0, 0, 10);
1260
+ }
1261
+ }
1262
+ try {
1263
+ return callback();
1264
+ } finally {
1265
+ try {
1266
+ if (readStateWriteLockOwner(lockPath) === ownerId) {
1267
+ rmSync(lockPath, { recursive: true, force: true });
1268
+ }
1269
+ } catch {
1270
+ // Never remove a lock that no longer belongs to this writer.
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ export function writeLinkedState(projectDir, state, profileKey = null) {
944
1276
  const statePath = join(projectDir, STATE_FILE);
945
- mkdirSync(dirname(statePath), { recursive: true });
946
- writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
1277
+ assertLinkedStatePathSafe(projectDir);
1278
+ return withStateWriteLock(statePath, () => {
1279
+ let nextState = state;
1280
+ if (profileKey) {
1281
+ let current = {};
1282
+ if (existsSync(statePath)) {
1283
+ current = JSON.parse(readFileSync(statePath, 'utf-8'));
1284
+ }
1285
+ const { base: currentBase } = splitLinkedState(current);
1286
+ const { base } = splitLinkedState(state);
1287
+ const { link } = splitLinkedState(state);
1288
+ const profiles = current?.profiles && typeof current.profiles === 'object'
1289
+ ? { ...current.profiles }
1290
+ : {};
1291
+ profiles[profileKey] = link;
1292
+ nextState = { ...currentBase, ...base, profiles };
1293
+ }
1294
+ const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1295
+ writeFileSync(temporary, JSON.stringify(nextState, null, 2) + '\n');
1296
+ renameSync(temporary, statePath);
1297
+ });
1298
+ }
1299
+
1300
+ function assertLinkedStatePathSafe(projectDir) {
1301
+ const notisDir = join(projectDir, NOTIS_DIR);
1302
+ try {
1303
+ const directoryStat = lstatSync(notisDir);
1304
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1305
+ throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
1306
+ }
1307
+ } catch (error) {
1308
+ if (error?.code !== 'ENOENT') throw error;
1309
+ }
1310
+
1311
+ const statePath = join(projectDir, STATE_FILE);
1312
+ try {
1313
+ const stateStat = lstatSync(statePath);
1314
+ if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
1315
+ throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
1316
+ }
1317
+ } catch (error) {
1318
+ if (error?.code !== 'ENOENT') throw error;
1319
+ }
947
1320
  }
948
1321
 
949
- export function requireLinkedAppId(projectDir, explicitAppId) {
1322
+ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
950
1323
  if (explicitAppId) return explicitAppId;
951
- const state = readLinkedState(projectDir);
1324
+ const state = readLinkedState(projectDir, profileKey);
952
1325
  if (state?.app_id) return state.app_id;
953
1326
  throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
954
1327
  }
@@ -958,76 +1331,412 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
958
1331
  // ---------------------------------------------------------------------------
959
1332
 
960
1333
  /**
961
- * Scaffold a new Notis app project from the SDK template.
1334
+ * Scaffold a new Notis app project from the SDK template or from a published
1335
+ * Store app downloaded from the public registry repository.
962
1336
  */
963
- export function loadScaffoldCatalog() {
964
- if (existsSync(SCAFFOLD_CATALOG_FILE)) {
965
- const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
966
- const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
967
- return Array.isArray(scaffolds)
968
- ? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
969
- : [];
1337
+ export async function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1338
+ let scaffoldSource = null;
1339
+ if (fromSlug) {
1340
+ scaffoldSource = await acquireScaffoldSource(fromSlug);
1341
+ if (!scaffoldSource) {
1342
+ const known = (await loadScaffoldCatalog()).map((entry) => entry.slug).join(', ') || 'none';
1343
+ throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
1344
+ }
1345
+ }
1346
+ try {
1347
+ return scaffoldProjectFromDir({
1348
+ projectDir,
1349
+ appName,
1350
+ fromSlug,
1351
+ templateDir: scaffoldSource ? scaffoldSource.dir : join(CLI_ROOT, 'template'),
1352
+ });
1353
+ } finally {
1354
+ scaffoldSource?.cleanup();
970
1355
  }
971
- return loadMonorepoScaffoldCatalog();
972
1356
  }
973
1357
 
974
- export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
975
- const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
1358
+ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir }) {
1359
+ const sourceDir = resolve(templateDir);
1360
+ const requestedProjectDir = resolve(projectDir);
1361
+ if (!existsSync(sourceDir)) {
1362
+ throw usageError(`SDK template not found at ${sourceDir}. Ensure @notis_ai/cli is installed correctly.`);
1363
+ }
1364
+
1365
+ const parentDir = dirname(requestedProjectDir);
1366
+ const targetName = basename(requestedProjectDir);
1367
+ mkdirSync(parentDir, { recursive: true });
1368
+ const canonicalParent = realpathSync(parentDir);
1369
+ const parentIdentity = capturePullTargetIdentity(canonicalParent);
1370
+ const initialTargetIdentity = captureScaffoldTargetIdentity(requestedProjectDir);
1371
+ const previousCwd = process.cwd();
1372
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1373
+ let cwdPinned = false;
1374
+ let stageName = null;
1375
+ let cleanupStage = true;
1376
+ let activeTargetIdentity = null;
1377
+ const scaffoldLockName = '.notis-app-scaffold-lock';
1378
+ const scaffoldLockOwnerId = `${process.pid}.${randomUUID()}`;
1379
+ try {
1380
+ process.chdir(canonicalParent);
1381
+ cwdPinned = true;
1382
+ assertPullTargetIdentity('.', parentIdentity);
1383
+ const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
1384
+ if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
1385
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1386
+ }
1387
+ if (!lockedTargetIdentity.exists) {
1388
+ try {
1389
+ mkdirSync(targetName);
1390
+ } catch (error) {
1391
+ if (error?.code === 'EEXIST') {
1392
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1393
+ }
1394
+ throw error;
1395
+ }
1396
+ }
1397
+ activeTargetIdentity = captureScaffoldTargetIdentity(targetName);
1398
+ process.chdir(targetName);
1399
+ if (!pullTargetIdentitiesMatch(
1400
+ activeTargetIdentity,
1401
+ captureScaffoldTargetIdentity('.'),
1402
+ )) {
1403
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1404
+ }
1405
+ mkdirSync(scaffoldLockName, { mode: 0o700 });
1406
+ writeFileSync(
1407
+ join(scaffoldLockName, 'owner'),
1408
+ JSON.stringify({ id: scaffoldLockOwnerId }),
1409
+ { mode: 0o600 },
1410
+ );
976
1411
 
977
- if (!existsSync(templateDir)) {
978
- if (fromSlug) {
979
- const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
980
- throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
1412
+ stageName = basename(mkdtempSync(join('.', '.notis-app-scaffold-')));
1413
+ projectDir = stageName;
1414
+
1415
+ copyScaffoldSource(sourceDir, projectDir);
1416
+
1417
+ // Update package.json with the app name
1418
+ const pkgPath = join(projectDir, 'package.json');
1419
+ if (existsSync(pkgPath)) {
1420
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1421
+ pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
1422
+ // A scaffold creates a new app identity, even when its source comes from a
1423
+ // versioned Store example. New apps must therefore start their own release
1424
+ // history instead of inheriting the example app's registry version.
1425
+ pkg.notisAppVersion = '0.1.0';
1426
+ normalizeScaffoldPackageScripts(pkg);
1427
+ ensureScaffoldLocalSdk(projectDir, pkg);
1428
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1429
+ normalizeScaffoldLockfile(projectDir, pkg);
981
1430
  }
982
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
983
- }
984
-
985
- mkdirSync(projectDir, { recursive: true });
986
- copyScaffoldSource(templateDir, projectDir);
987
-
988
- // Update package.json with the app name
989
- const pkgPath = join(projectDir, 'package.json');
990
- if (existsSync(pkgPath)) {
991
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
992
- pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
993
- // A scaffold creates a new app identity, even when its source comes from a
994
- // versioned Store example. New apps must therefore start their own release
995
- // history instead of inheriting the example app's registry version.
996
- pkg.notisAppVersion = '0.1.0';
997
- ensureScaffoldLocalSdk(projectDir, pkg);
998
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
999
- normalizeScaffoldLockfile(projectDir, pkg);
1000
- }
1001
-
1002
- // Update notis.config.ts with the app name
1003
- const configPath = join(projectDir, 'notis.config.ts');
1004
- if (existsSync(configPath)) {
1005
- let config = readFileSync(configPath, 'utf-8');
1006
- const displayName = jsStringLiteral(appName);
1007
- const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1008
- if (fromSlug) {
1009
- if (/name\s*:/.test(config)) {
1010
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1431
+
1432
+ // Update notis.config.ts with the app name
1433
+ const configPath = join(projectDir, 'notis.config.ts');
1434
+ if (existsSync(configPath)) {
1435
+ let config = readFileSync(configPath, 'utf-8');
1436
+ const displayName = jsStringLiteral(appName);
1437
+ const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1438
+ if (fromSlug) {
1439
+ if (/name\s*:/.test(config)) {
1440
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1441
+ }
1442
+ if (/title\s*:/.test(config)) {
1443
+ config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1444
+ } else {
1445
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
1446
+ }
1447
+ } else {
1448
+ if (/name\s*:/.test(config)) {
1449
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1450
+ }
1451
+ if (/title\s*:/.test(config)) {
1452
+ config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1453
+ } else {
1454
+ config = config.replace(/'My Notis App'/, displayName);
1455
+ }
1011
1456
  }
1012
- if (/title\s*:/.test(config)) {
1013
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1457
+ if (/devSlug\s*:/.test(config)) {
1458
+ config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
1014
1459
  } else {
1015
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
1460
+ config = config.replace(
1461
+ /name\s*:\s*(['"`])[\s\S]*?\1/,
1462
+ (nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
1463
+ );
1016
1464
  }
1017
- } else {
1018
- if (/name\s*:/.test(config)) {
1019
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1465
+ config = removeConfigArrayProperty(config, 'screenshots');
1466
+ writeFileSync(configPath, config);
1467
+ }
1468
+
1469
+ resetScaffoldChangelog(projectDir, appName);
1470
+
1471
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1472
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1473
+ const stageRoot = realpathSync(stageName);
1474
+ const installedEntries = [];
1475
+ const installedDirectoryIdentities = new Map([['', activeTargetIdentity]]);
1476
+ const captureInstalledEntry = (entryPath) => {
1477
+ const entryStat = lstatSync(entryPath, { bigint: true });
1478
+ return {
1479
+ exists: true,
1480
+ path: entryPath,
1481
+ dev: entryStat.dev,
1482
+ ino: entryStat.ino,
1483
+ directory: entryStat.isDirectory(),
1484
+ };
1485
+ };
1486
+ const installExclusive = (sourcePath, destinationName, relativePath, parentIdentity) => {
1487
+ const sourceStat = lstatSync(sourcePath);
1488
+ if (sourceStat.isSymbolicLink()) {
1489
+ throw usageError(`Refusing symlinked scaffold source entry: ${sourcePath}`);
1020
1490
  }
1021
- if (/title\s*:/.test(config)) {
1022
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1023
- } else {
1024
- config = config.replace(/'My Notis App'/, displayName);
1491
+ if (sourceStat.isDirectory()) {
1492
+ mkdirSync(destinationName, { mode: sourceStat.mode & 0o777 });
1493
+ const installedDirectory = captureInstalledEntry(destinationName);
1494
+ installedDirectory.path = relativePath;
1495
+ installedEntries.push(installedDirectory);
1496
+ installedDirectoryIdentities.set(relativePath, installedDirectory);
1497
+ process.chdir(destinationName);
1498
+ if (!pullTargetIdentitiesMatch(
1499
+ installedDirectory,
1500
+ capturePullTargetIdentity('.'),
1501
+ )) {
1502
+ throw usageError(`App scaffold destination changed during installation: ${relativePath}`);
1503
+ }
1504
+ try {
1505
+ for (const name of readdirSync(sourcePath)) {
1506
+ installExclusive(
1507
+ join(sourcePath, name),
1508
+ name,
1509
+ `${relativePath}/${name}`,
1510
+ installedDirectory,
1511
+ );
1512
+ }
1513
+ } finally {
1514
+ const parentRelativePath = dirname(relativePath) === '.' ? '' : dirname(relativePath);
1515
+ process.chdir(parentRelativePath
1516
+ ? join(requestedProjectDir, parentRelativePath)
1517
+ : requestedProjectDir);
1518
+ if (!pullTargetIdentitiesMatch(
1519
+ parentIdentity,
1520
+ capturePullTargetIdentity('.'),
1521
+ )) {
1522
+ throw usageError(`App scaffold destination changed during installation: ${parentRelativePath || '.'}`);
1523
+ }
1524
+ }
1525
+ return;
1526
+ }
1527
+ if (!sourceStat.isFile()) {
1528
+ throw usageError(`Refusing unsupported scaffold source entry: ${sourcePath}`);
1025
1529
  }
1530
+ copyFileSync(sourcePath, destinationName, fsConstants.COPYFILE_EXCL);
1531
+ const installedFile = captureInstalledEntry(destinationName);
1532
+ installedFile.path = relativePath;
1533
+ installedEntries.push(installedFile);
1534
+ };
1535
+ try {
1536
+ for (const name of readdirSync(stageName)) {
1537
+ installExclusive(join(stageRoot, name), name, name, activeTargetIdentity);
1538
+ }
1539
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1540
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1541
+ } catch (error) {
1542
+ try {
1543
+ for (const installed of installedEntries.reverse()) {
1544
+ const parentRelativePath = dirname(installed.path) === '.' ? '' : dirname(installed.path);
1545
+ const parentPath = parentRelativePath
1546
+ ? join(requestedProjectDir, parentRelativePath)
1547
+ : requestedProjectDir;
1548
+ const expectedParentIdentity = installedDirectoryIdentities.get(parentRelativePath);
1549
+ process.chdir(parentPath);
1550
+ if (!expectedParentIdentity || !pullTargetIdentitiesMatch(
1551
+ expectedParentIdentity,
1552
+ capturePullTargetIdentity('.'),
1553
+ )) {
1554
+ throw usageError(
1555
+ `Refusing to roll back a scaffold directory that changed concurrently: ${parentPath}`,
1556
+ );
1557
+ }
1558
+ const entryName = basename(installed.path);
1559
+ const current = captureInstalledEntry(entryName);
1560
+ if (
1561
+ current.dev !== installed.dev
1562
+ || current.ino !== installed.ino
1563
+ || current.directory !== installed.directory
1564
+ ) {
1565
+ throw usageError(
1566
+ `Refusing to roll back a scaffold entry that changed concurrently: ${installed.path}`,
1567
+ );
1568
+ }
1569
+ if (installed.directory) {
1570
+ rmdirSync(entryName);
1571
+ } else {
1572
+ unlinkSync(entryName);
1573
+ }
1574
+ }
1575
+ } catch (rollbackError) {
1576
+ cleanupStage = false;
1577
+ throw usageError(
1578
+ `${error instanceof Error ? error.message : String(error)} `
1579
+ + `Scaffold rollback failed; recovery files were retained at `
1580
+ + `${join(requestedProjectDir, stageName)}: `
1581
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
1582
+ );
1583
+ }
1584
+ throw error;
1585
+ }
1586
+ try {
1587
+ rmSync(stageName, { recursive: true, force: true });
1588
+ stageName = null;
1589
+ } catch (error) {
1590
+ cleanupStage = false;
1591
+ throw usageError(
1592
+ `App scaffold was installed, but staging cleanup failed; recovery files were retained at `
1593
+ + `${join(requestedProjectDir, stageName)}: `
1594
+ + `${error instanceof Error ? error.message : String(error)}`,
1595
+ );
1596
+ }
1597
+ } finally {
1598
+ try {
1599
+ let targetPinnedForCleanup = false;
1600
+ if (activeTargetIdentity) {
1601
+ try {
1602
+ process.chdir(requestedProjectDir);
1603
+ targetPinnedForCleanup = pullTargetIdentitiesMatch(
1604
+ activeTargetIdentity,
1605
+ capturePullTargetIdentity('.'),
1606
+ );
1607
+ } catch {
1608
+ targetPinnedForCleanup = false;
1609
+ }
1610
+ }
1611
+ if (targetPinnedForCleanup && cleanupStage && stageName) {
1612
+ rmSync(stageName, { recursive: true, force: true });
1613
+ }
1614
+ if (
1615
+ targetPinnedForCleanup
1616
+ && readPullLockOwner(scaffoldLockName)?.id === scaffoldLockOwnerId
1617
+ ) {
1618
+ rmSync(scaffoldLockName, { recursive: true, force: true });
1619
+ }
1620
+ } finally {
1621
+ if (cwdPinned) {
1622
+ const cwdMatchesPrevious = (
1623
+ previousCwdIdentity.exists
1624
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
1625
+ );
1626
+ if (!cwdMatchesPrevious) {
1627
+ process.chdir(previousCwd);
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ return { projectDir: requestedProjectDir };
1634
+ }
1635
+
1636
+ function normalizeScaffoldPackageScripts(pkg) {
1637
+ const scripts = pkg?.scripts;
1638
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
1639
+ return;
1640
+ }
1641
+ const generateEntry = String(scripts['generate-entry'] || '').trim();
1642
+ if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
1643
+ return;
1644
+ }
1645
+ for (const [name, command] of Object.entries(scripts)) {
1646
+ if (name === 'generate-entry' || typeof command !== 'string') {
1647
+ continue;
1648
+ }
1649
+ if (command.trim() === 'npm run generate-entry') {
1650
+ delete scripts[name];
1651
+ }
1652
+ }
1653
+ scripts['generate-entry'] = 'node -e ""';
1654
+ }
1655
+
1656
+ function captureScaffoldTargetIdentity(targetDir) {
1657
+ try {
1658
+ const targetStat = lstatSync(targetDir, { bigint: true });
1659
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
1660
+ throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
1661
+ }
1662
+ if (readdirSync(targetDir).length > 0) {
1663
+ throw usageError(`App scaffold target must be empty: ${targetDir}`);
1664
+ }
1665
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
1666
+ } catch (error) {
1667
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
1668
+ throw error;
1669
+ }
1670
+ }
1671
+
1672
+ /**
1673
+ * Drop `property: [ ... ]` from a notis.config.ts source.
1674
+ *
1675
+ * The scan tracks bracket depth while skipping string literals, so entries
1676
+ * whose text contains a bracket (alt text, selectors) cannot end the array
1677
+ * early. When the array cannot be resolved the source is returned untouched --
1678
+ * a stale screenshots list is a warning at verify time, a broken config is not.
1679
+ */
1680
+ function removeConfigArrayProperty(source, property) {
1681
+ const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
1682
+ if (start === -1) {
1683
+ return source;
1684
+ }
1685
+ let index = source.indexOf('[', start);
1686
+ let depth = 0;
1687
+ let quote = null;
1688
+ for (; index < source.length; index += 1) {
1689
+ const char = source[index];
1690
+ if (quote) {
1691
+ if (char === '\\') {
1692
+ index += 1;
1693
+ } else if (char === quote) {
1694
+ quote = null;
1695
+ }
1696
+ continue;
1697
+ }
1698
+ if (char === '\'' || char === '"' || char === '`') {
1699
+ quote = char;
1700
+ continue;
1026
1701
  }
1027
- writeFileSync(configPath, config);
1702
+ if (char === '[') {
1703
+ depth += 1;
1704
+ } else if (char === ']') {
1705
+ depth -= 1;
1706
+ if (depth === 0) {
1707
+ break;
1708
+ }
1709
+ }
1710
+ }
1711
+ if (depth !== 0) {
1712
+ return source;
1713
+ }
1714
+ let end = index + 1;
1715
+ if (source[end] === ',') {
1716
+ end += 1;
1028
1717
  }
1718
+ while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
1719
+ end += 1;
1720
+ }
1721
+ if (source[end] === '\n') {
1722
+ end += 1;
1723
+ }
1724
+ return source.slice(0, start) + source.slice(end);
1725
+ }
1029
1726
 
1030
- return { projectDir };
1727
+ /**
1728
+ * A new project starts its own release history: the scaffold's entries describe
1729
+ * releases of a different app.
1730
+ */
1731
+ function resetScaffoldChangelog(projectDir, appName) {
1732
+ const changelogPath = join(projectDir, 'CHANGELOG.md');
1733
+ if (!existsSync(changelogPath)) {
1734
+ return;
1735
+ }
1736
+ writeFileSync(
1737
+ changelogPath,
1738
+ `# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
1739
+ );
1031
1740
  }
1032
1741
 
1033
1742
  function normalizeScaffoldLockfile(projectDir, pkg) {
@@ -1070,86 +1779,189 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
1070
1779
  cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
1071
1780
  }
1072
1781
 
1073
- function resolveScaffoldSourceDir(fromSlug) {
1074
- const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
1075
- if (existsSync(bundledDir)) {
1076
- return bundledDir;
1077
- }
1078
- const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
1079
- if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
1080
- return monorepoDir;
1081
- }
1082
- return bundledDir;
1083
- }
1782
+ // Registry bookkeeping shipped alongside a published app's source: the listing
1783
+ // descriptor and the rendered Store gallery describe the published app, so a
1784
+ // scaffold copy must not inherit them.
1785
+ const REGISTRY_ARTIFACTS = /^(notis-listing\.json$|screenshots(\/|$))/;
1084
1786
 
1085
- function loadMonorepoScaffoldCatalog() {
1086
- if (!existsSync(MONOREPO_SCAFFOLDS_DIR)) {
1087
- return [];
1787
+ function readStablePinnedFile(descriptor, expectedStat, label) {
1788
+ const before = fstatSync(descriptor, { bigint: true });
1789
+ if (
1790
+ !before.isFile()
1791
+ || before.dev !== expectedStat.dev
1792
+ || before.ino !== expectedStat.ino
1793
+ ) {
1794
+ throw usageError(`${label} changed before it could be read.`);
1088
1795
  }
1089
- const scaffolds = [];
1090
- for (const entry of readdirSync(MONOREPO_SCAFFOLDS_DIR, { withFileTypes: true })) {
1091
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
1092
- continue;
1093
- }
1094
- const configPath = join(MONOREPO_SCAFFOLDS_DIR, entry.name, 'notis.config.ts');
1095
- if (!existsSync(configPath)) {
1096
- continue;
1097
- }
1098
- const configSource = readFileSync(configPath, 'utf-8');
1099
- const description = readTsStringProperty(configSource, 'description') || '';
1100
- scaffolds.push({
1101
- slug: entry.name,
1102
- name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
1103
- description,
1104
- icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
1105
- categories: readTsStringArrayProperty(configSource, 'categories'),
1106
- tagline: readTsStringProperty(configSource, 'tagline') || description,
1107
- });
1796
+ const content = readFileSync(descriptor);
1797
+ const after = fstatSync(descriptor, { bigint: true });
1798
+ if (
1799
+ after.dev !== before.dev
1800
+ || after.ino !== before.ino
1801
+ || after.size !== before.size
1802
+ || after.mtimeNs !== before.mtimeNs
1803
+ || after.ctimeNs !== before.ctimeNs
1804
+ ) {
1805
+ throw usageError(`${label} changed while it was being read.`);
1108
1806
  }
1109
- return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
1807
+ return content;
1110
1808
  }
1111
1809
 
1112
- function readTsStringProperty(source, propertyName) {
1113
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
1114
- return match ? match[2] : null;
1810
+ function captureDirectoryRevision() {
1811
+ const stat = statSync('.', { bigint: true });
1812
+ return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
1115
1813
  }
1116
1814
 
1117
- function readTsStringArrayProperty(source, propertyName) {
1118
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
1119
- if (!match) {
1120
- return [];
1815
+ function assertDirectoryRevisionUnchanged(before, label) {
1816
+ const after = captureDirectoryRevision();
1817
+ if (
1818
+ after.dev !== before.dev
1819
+ || after.ino !== before.ino
1820
+ || after.mtimeNs !== before.mtimeNs
1821
+ || after.ctimeNs !== before.ctimeNs
1822
+ ) {
1823
+ throw usageError(`${label} changed while it was being read.`);
1121
1824
  }
1122
- return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
1123
1825
  }
1124
1826
 
1125
1827
  function copyScaffoldSource(sourceDir, targetDir) {
1126
1828
  function shouldCopy(path) {
1127
1829
  const name = path.split(/[\\/]/).pop();
1128
1830
  if (!name) return true;
1831
+ const relPath = relative(sourceDir, path).replace(/\\/g, '/');
1832
+ if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
1833
+ return false;
1834
+ }
1129
1835
  return !SCAFFOLD_COPY_EXCLUDES.has(name)
1836
+ && !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
1130
1837
  && !name.startsWith('.env')
1131
1838
  && !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
1132
1839
  }
1133
1840
 
1134
- function walk(src, dest) {
1135
- if (!shouldCopy(src)) {
1136
- return;
1841
+ const canonicalSource = realpathSync(sourceDir);
1842
+ const sourceIdentity = capturePullTargetIdentity(canonicalSource);
1843
+ const previousCwd = process.cwd();
1844
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1845
+ const destinationIdentity = capturePullTargetIdentity(targetDir);
1846
+ const snapshot = { directories: [], files: [] };
1847
+
1848
+ function snapshotCurrent(sourcePath, node, expectedDirectoryIdentity) {
1849
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
1850
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
1137
1851
  }
1138
- const stat = statSync(src);
1139
- if (stat.isDirectory()) {
1140
- mkdirSync(dest, { recursive: true });
1141
- for (const entry of readdirSync(src)) {
1142
- walk(join(src, entry), join(dest, entry));
1852
+ const directoryRevision = captureDirectoryRevision();
1853
+ const parentIdentity = expectedDirectoryIdentity;
1854
+ for (const name of readdirSync('.')) {
1855
+ const logicalSourcePath = join(sourcePath, name);
1856
+ if (!shouldCopy(logicalSourcePath)) {
1857
+ continue;
1858
+ }
1859
+ const sourceStat = lstatSync(name, { bigint: true });
1860
+ if (sourceStat.isSymbolicLink()) {
1861
+ throw usageError(`Refusing symlinked scaffold source entry: ${logicalSourcePath}`);
1862
+ }
1863
+ if (sourceStat.isDirectory()) {
1864
+ const childNode = {
1865
+ name,
1866
+ mode: Number(sourceStat.mode & 0o777n),
1867
+ directories: [],
1868
+ files: [],
1869
+ };
1870
+ node.directories.push(childNode);
1871
+ process.chdir(name);
1872
+ const childIdentity = capturePullTargetIdentity('.');
1873
+ if (!pullTargetIdentitiesMatch(
1874
+ { exists: true, dev: sourceStat.dev, ino: sourceStat.ino },
1875
+ childIdentity,
1876
+ )) {
1877
+ throw usageError(`Scaffold source directory changed while copying: ${logicalSourcePath}`);
1878
+ }
1879
+ try {
1880
+ snapshotCurrent(logicalSourcePath, childNode, childIdentity);
1881
+ } finally {
1882
+ process.chdir('..');
1883
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
1884
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
1885
+ }
1886
+ }
1887
+ continue;
1888
+ }
1889
+ if (!sourceStat.isFile()) {
1890
+ throw usageError(`Refusing unsupported scaffold source entry: ${logicalSourcePath}`);
1891
+ }
1892
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
1893
+ try {
1894
+ node.files.push({
1895
+ name,
1896
+ mode: Number(sourceStat.mode & 0o777n),
1897
+ content: readStablePinnedFile(
1898
+ descriptor,
1899
+ sourceStat,
1900
+ `Scaffold source file ${logicalSourcePath}`,
1901
+ ),
1902
+ });
1903
+ } finally {
1904
+ closeSync(descriptor);
1905
+ }
1906
+ }
1907
+ assertDirectoryRevisionUnchanged(directoryRevision, `Scaffold source directory ${sourcePath}`);
1908
+ }
1909
+
1910
+ try {
1911
+ process.chdir(canonicalSource);
1912
+ snapshotCurrent(canonicalSource, snapshot, sourceIdentity);
1913
+ } finally {
1914
+ process.chdir(previousCwd);
1915
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
1916
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
1917
+ }
1918
+ }
1919
+
1920
+ function writeSnapshot(node, logicalPath, expectedDirectoryIdentity) {
1921
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
1922
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
1923
+ }
1924
+ const parentIdentity = expectedDirectoryIdentity;
1925
+ for (const directory of node.directories) {
1926
+ mkdirSync(directory.name, { mode: directory.mode });
1927
+ const directoryStat = lstatSync(directory.name, { bigint: true });
1928
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1929
+ throw usageError(`Refusing unsafe scaffold destination: ${join(logicalPath, directory.name)}`);
1930
+ }
1931
+ process.chdir(directory.name);
1932
+ const childIdentity = capturePullTargetIdentity('.');
1933
+ if (!pullTargetIdentitiesMatch(
1934
+ { exists: true, dev: directoryStat.dev, ino: directoryStat.ino },
1935
+ childIdentity,
1936
+ )) {
1937
+ throw usageError(`Scaffold destination changed while copying: ${join(logicalPath, directory.name)}`);
1938
+ }
1939
+ try {
1940
+ writeSnapshot(directory, join(logicalPath, directory.name), childIdentity);
1941
+ } finally {
1942
+ process.chdir('..');
1943
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
1944
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
1945
+ }
1143
1946
  }
1144
- return;
1145
1947
  }
1146
- if (stat.isFile()) {
1147
- mkdirSync(dirname(dest), { recursive: true });
1148
- cpSync(src, dest);
1948
+ for (const file of node.files) {
1949
+ writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
1149
1950
  }
1150
1951
  }
1151
1952
 
1152
- walk(sourceDir, targetDir);
1953
+ try {
1954
+ process.chdir(targetDir);
1955
+ if (!pullTargetIdentitiesMatch(destinationIdentity, capturePullTargetIdentity('.'))) {
1956
+ throw usageError(`Scaffold destination changed while copying: ${targetDir}`);
1957
+ }
1958
+ writeSnapshot(snapshot, '', destinationIdentity);
1959
+ } finally {
1960
+ process.chdir(previousCwd);
1961
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
1962
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
1963
+ }
1964
+ }
1153
1965
  }
1154
1966
 
1155
1967
  // ---------------------------------------------------------------------------
@@ -1187,29 +1999,81 @@ export function collectArtifactFiles(projectDir) {
1187
1999
  }
1188
2000
 
1189
2001
  function shouldExcludeSourceEntry(name) {
1190
- return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
2002
+ const casefolded = String(name || '').toLocaleLowerCase('en-US');
2003
+ return (
2004
+ SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
2005
+ || casefolded.startsWith('.notis-app-pull-')
2006
+ || casefolded.startsWith('.notis-app-scaffold-')
2007
+ || casefolded.startsWith('.env')
2008
+ || casefolded.endsWith('.pyc')
2009
+ || casefolded.endsWith('.pyo')
2010
+ );
1191
2011
  }
1192
2012
 
1193
2013
  function readSourceFiles(projectDir) {
1194
2014
  const files = {};
1195
-
1196
- function walk(dir, prefix) {
1197
- const entries = readdirSync(dir, { withFileTypes: true });
1198
- for (const entry of entries) {
1199
- if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
2015
+ const canonicalProjectDir = realpathSync(projectDir);
2016
+ const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
2017
+ const previousCwd = process.cwd();
2018
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2019
+
2020
+ function walkCurrent(prefix, expectedDirectoryIdentity) {
2021
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
2022
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2023
+ }
2024
+ const directoryRevision = captureDirectoryRevision();
2025
+ const parentIdentity = expectedDirectoryIdentity;
2026
+ for (const name of readdirSync('.')) {
2027
+ if (shouldExcludeSourceEntry(name)) {
1200
2028
  continue;
1201
2029
  }
1202
- const fullPath = join(dir, entry.name);
1203
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1204
- if (entry.isDirectory()) {
1205
- walk(fullPath, relPath);
1206
- } else if (entry.isFile()) {
1207
- files[relPath] = readFileSync(fullPath);
2030
+ const entryStat = lstatSync(name, { bigint: true });
2031
+ if (entryStat.isSymbolicLink()) {
2032
+ continue;
2033
+ }
2034
+ const relPath = prefix ? `${prefix}/${name}` : name;
2035
+ if (entryStat.isDirectory()) {
2036
+ process.chdir(name);
2037
+ const childIdentity = capturePullTargetIdentity('.');
2038
+ if (!pullTargetIdentitiesMatch(
2039
+ { exists: true, dev: entryStat.dev, ino: entryStat.ino },
2040
+ childIdentity,
2041
+ )) {
2042
+ throw usageError(`App source directory changed while packaging: ${relPath}`);
2043
+ }
2044
+ try {
2045
+ walkCurrent(relPath, childIdentity);
2046
+ } finally {
2047
+ process.chdir('..');
2048
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
2049
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2050
+ }
2051
+ }
2052
+ } else if (entryStat.isFile()) {
2053
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2054
+ try {
2055
+ files[relPath] = readStablePinnedFile(
2056
+ descriptor,
2057
+ entryStat,
2058
+ `App source file ${relPath}`,
2059
+ );
2060
+ } finally {
2061
+ closeSync(descriptor);
2062
+ }
1208
2063
  }
1209
2064
  }
2065
+ assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
1210
2066
  }
1211
2067
 
1212
- walk(projectDir, '');
2068
+ try {
2069
+ process.chdir(canonicalProjectDir);
2070
+ walkCurrent('', projectIdentity);
2071
+ } finally {
2072
+ process.chdir(previousCwd);
2073
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
2074
+ throw usageError(`Working directory changed while packaging app source: ${previousCwd}`);
2075
+ }
2076
+ }
1213
2077
  return files;
1214
2078
  }
1215
2079
 
@@ -1231,9 +2095,55 @@ function cleanTarPath(name) {
1231
2095
  return parts.join('/');
1232
2096
  }
1233
2097
 
2098
+ function parsePaxPath(data) {
2099
+ if (data.length === 0 || data.length > 64 * 1024) {
2100
+ throw usageError('Refusing to extract an invalid PAX source archive header.');
2101
+ }
2102
+ let offset = 0;
2103
+ let path = null;
2104
+ while (offset < data.length) {
2105
+ const space = data.indexOf(0x20, offset);
2106
+ if (space <= offset) {
2107
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2108
+ }
2109
+ const lengthText = data.subarray(offset, space).toString('ascii');
2110
+ if (!/^\d+$/.test(lengthText)) {
2111
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2112
+ }
2113
+ const recordLength = Number.parseInt(lengthText, 10);
2114
+ const recordEnd = offset + recordLength;
2115
+ if (recordLength <= space - offset + 3 || recordEnd > data.length || data[recordEnd - 1] !== 0x0a) {
2116
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2117
+ }
2118
+ const record = data.subarray(space + 1, recordEnd - 1);
2119
+ const equals = record.indexOf(0x3d);
2120
+ if (equals <= 0) {
2121
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2122
+ }
2123
+ const key = record.subarray(0, equals).toString('ascii');
2124
+ if (key === 'path') {
2125
+ try {
2126
+ path = new TextDecoder('utf-8', { fatal: true }).decode(record.subarray(equals + 1));
2127
+ } catch {
2128
+ throw usageError('Refusing to extract a PAX source path with invalid UTF-8.');
2129
+ }
2130
+ if (!path || Buffer.byteLength(path, 'utf-8') > 4096) {
2131
+ throw usageError('Refusing to extract an invalid PAX source path.');
2132
+ }
2133
+ }
2134
+ offset = recordEnd;
2135
+ }
2136
+ if (!path) {
2137
+ throw usageError('Refusing to extract a PAX source header without a path.');
2138
+ }
2139
+ return path;
2140
+ }
2141
+
1234
2142
  function extractTarGz(buffer, targetDir) {
1235
2143
  const tar = gunzipSync(buffer);
1236
2144
  let offset = 0;
2145
+ let pendingPaxPath = null;
2146
+ const extractedPaths = new Map();
1237
2147
  while (offset + 512 <= tar.length) {
1238
2148
  const header = tar.subarray(offset, offset + 512);
1239
2149
  offset += 512;
@@ -1247,35 +2157,251 @@ function extractTarGz(buffer, targetDir) {
1247
2157
  const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
1248
2158
  const size = Number.parseInt(sizeRaw || '0', 8);
1249
2159
  const typeFlag = header[156];
1250
- const relPath = cleanTarPath(fullName);
2160
+ if (!Number.isSafeInteger(size) || size < 0 || offset + size > tar.length) {
2161
+ throw usageError('Refusing to extract a malformed source archive entry size.');
2162
+ }
2163
+ const data = tar.subarray(offset, offset + size);
2164
+ if (typeFlag === 120) {
2165
+ if (pendingPaxPath !== null) {
2166
+ throw usageError('Refusing to extract stacked PAX source archive headers.');
2167
+ }
2168
+ pendingPaxPath = parsePaxPath(data);
2169
+ offset += Math.ceil(size / 512) * 512;
2170
+ continue;
2171
+ }
2172
+
2173
+ const relPath = cleanTarPath(pendingPaxPath || fullName);
2174
+ pendingPaxPath = null;
2175
+ const parts = relPath.split('/');
2176
+ for (let index = 1; index <= parts.length; index += 1) {
2177
+ const pathPrefix = parts.slice(0, index).join('/');
2178
+ const casefoldedPath = pathPrefix.toLocaleLowerCase('en-US');
2179
+ const priorPath = extractedPaths.get(casefoldedPath);
2180
+ if (priorPath && priorPath !== pathPrefix) {
2181
+ throw usageError(
2182
+ `Refusing to extract case-colliding source archive paths: ${priorPath} and ${pathPrefix}`,
2183
+ );
2184
+ }
2185
+ extractedPaths.set(casefoldedPath, pathPrefix);
2186
+ }
2187
+ if (relPath.split('/').some((part) => shouldExcludeSourceEntry(part))) {
2188
+ throw usageError(`Refusing to extract local-only path from source archive: ${relPath}`);
2189
+ }
1251
2190
  const outputPath = join(targetDir, relPath);
1252
2191
 
1253
2192
  if (typeFlag === 53) {
1254
2193
  mkdirSync(outputPath, { recursive: true });
1255
- } else {
2194
+ } else if (typeFlag === 0 || typeFlag === 48) {
1256
2195
  mkdirSync(dirname(outputPath), { recursive: true });
1257
- writeFileSync(outputPath, tar.subarray(offset, offset + size));
2196
+ writeFileSync(outputPath, data);
2197
+ } else {
2198
+ throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
1258
2199
  }
1259
2200
 
1260
2201
  offset += Math.ceil(size / 512) * 512;
1261
2202
  }
2203
+ if (pendingPaxPath !== null) {
2204
+ throw usageError('Refusing to extract a dangling PAX source archive header.');
2205
+ }
2206
+ }
2207
+
2208
+ function collectPullPreservedPaths(targetDir) {
2209
+ const paths = [];
2210
+
2211
+ function walk(dir, prefix) {
2212
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2213
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2214
+ if (entry.isSymbolicLink() || shouldExcludeSourceEntry(entry.name)) {
2215
+ paths.push(relPath);
2216
+ continue;
2217
+ }
2218
+ if (entry.isDirectory()) {
2219
+ walk(join(dir, entry.name), relPath);
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ if (existsSync(targetDir)) {
2225
+ walk(targetDir, '');
2226
+ }
2227
+ return paths;
1262
2228
  }
1263
2229
 
1264
- export async function pullAppSource({
2230
+ function readPullLockOwner(lockDirectory) {
2231
+ try {
2232
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
2233
+ } catch {
2234
+ return null;
2235
+ }
2236
+ }
2237
+
2238
+ function capturePullTargetIdentity(targetDir) {
2239
+ try {
2240
+ const targetStat = lstatSync(targetDir, { bigint: true });
2241
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
2242
+ throw usageError(`Refusing to pull app source through an unsafe target: ${targetDir}`);
2243
+ }
2244
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
2245
+ } catch (error) {
2246
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
2247
+ throw error;
2248
+ }
2249
+ }
2250
+
2251
+ function assertPullTargetIdentity(targetDir, expected) {
2252
+ const current = capturePullTargetIdentity(targetDir);
2253
+ if (!pullTargetIdentitiesMatch(current, expected)) {
2254
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2255
+ }
2256
+ }
2257
+
2258
+ function pullTargetIdentitiesMatch(left, right) {
2259
+ return (
2260
+ left.exists === right.exists
2261
+ && (!left.exists || (left.dev === right.dev && left.ino === right.ino))
2262
+ );
2263
+ }
2264
+
2265
+ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity) {
2266
+ let currentCanonicalParent;
2267
+ try {
2268
+ currentCanonicalParent = realpathSync(parentDir);
2269
+ } catch {
2270
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
2271
+ }
2272
+ if (
2273
+ currentCanonicalParent !== canonicalParent
2274
+ || !pullTargetIdentitiesMatch(
2275
+ capturePullTargetIdentity(canonicalParent),
2276
+ expectedIdentity,
2277
+ )
2278
+ ) {
2279
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
2280
+ }
2281
+ }
2282
+
2283
+ async function withPullTargetLock(targetDir, callback) {
2284
+ const requestedTarget = resolve(targetDir);
2285
+ const parentDir = dirname(requestedTarget);
2286
+ mkdirSync(parentDir, { recursive: true });
2287
+ const canonicalParent = realpathSync(parentDir);
2288
+ const canonicalParentIdentity = capturePullTargetIdentity(canonicalParent);
2289
+ const initialRequestedIdentity = capturePullTargetIdentity(requestedTarget);
2290
+ const canonicalTarget = initialRequestedIdentity.exists
2291
+ ? realpathSync(requestedTarget)
2292
+ : join(canonicalParent, basename(requestedTarget));
2293
+ if (
2294
+ initialRequestedIdentity.exists
2295
+ && !pullTargetIdentitiesMatch(
2296
+ initialRequestedIdentity,
2297
+ capturePullTargetIdentity(canonicalTarget),
2298
+ )
2299
+ ) {
2300
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2301
+ }
2302
+ const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
2303
+ const ownerId = `${process.pid}.${randomUUID()}`;
2304
+ const deadline = Date.now() + PULL_LOCK_TIMEOUT_MS;
2305
+
2306
+ for (;;) {
2307
+ try {
2308
+ mkdirSync(lockDirectory, { mode: 0o700 });
2309
+ writeFileSync(
2310
+ join(lockDirectory, 'owner'),
2311
+ JSON.stringify({ id: ownerId, pid: process.pid, at: Date.now() }),
2312
+ { mode: 0o600 },
2313
+ );
2314
+ break;
2315
+ } catch (error) {
2316
+ if (error?.code !== 'EEXIST') throw error;
2317
+ let lockStat;
2318
+ try {
2319
+ lockStat = lstatSync(lockDirectory);
2320
+ } catch (statError) {
2321
+ if (statError?.code === 'ENOENT') continue;
2322
+ throw statError;
2323
+ }
2324
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
2325
+ throw usageError(`Refusing to use unsafe app source pull lock: ${lockDirectory}`);
2326
+ }
2327
+ if (Date.now() >= deadline) {
2328
+ throw usageError(
2329
+ `Timed out waiting to pull app source into ${targetDir}. `
2330
+ + `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
2331
+ );
2332
+ }
2333
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, PULL_LOCK_POLL_MS));
2334
+ }
2335
+ }
2336
+
2337
+ try {
2338
+ const assertOwnership = () => {
2339
+ if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
2340
+ throw usageError(`Lost the app source pull lock for ${targetDir}.`);
2341
+ }
2342
+ };
2343
+ const assertParentIdentity = () => assertPullParentIdentity(
2344
+ parentDir,
2345
+ canonicalParent,
2346
+ canonicalParentIdentity,
2347
+ );
2348
+ assertParentIdentity();
2349
+ const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
2350
+ if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
2351
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2352
+ }
2353
+ return await callback(
2354
+ assertOwnership,
2355
+ assertParentIdentity,
2356
+ canonicalTarget,
2357
+ lockedTargetIdentity,
2358
+ );
2359
+ } finally {
2360
+ try {
2361
+ if (readPullLockOwner(lockDirectory)?.id === ownerId) {
2362
+ rmSync(lockDirectory, { recursive: true, force: true });
2363
+ }
2364
+ } catch {
2365
+ // A reclaimed lock belongs to its new owner and must not be removed.
2366
+ }
2367
+ }
2368
+ }
2369
+
2370
+ export async function pullAppSource(args) {
2371
+ const targetDir = resolve(args.targetDir);
2372
+ return withPullTargetLock(
2373
+ targetDir,
2374
+ (
2375
+ assertLockOwnership,
2376
+ assertParentIdentity,
2377
+ canonicalTarget,
2378
+ targetIdentity,
2379
+ ) => pullAppSourceUnlocked(
2380
+ { ...args, targetDir: canonicalTarget },
2381
+ assertLockOwnership,
2382
+ assertParentIdentity,
2383
+ targetIdentity,
2384
+ ),
2385
+ );
2386
+ }
2387
+
2388
+ async function pullAppSourceUnlocked({
1265
2389
  apiBase,
1266
2390
  jwt,
1267
2391
  appId,
1268
2392
  targetDir,
1269
2393
  version = 'latest',
1270
2394
  force = false,
1271
- }) {
1272
- if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
2395
+ profileKey = null,
2396
+ }, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
2397
+ assertLockOwnership();
2398
+ assertParentIdentity();
2399
+ const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
2400
+ if (targetWasNonEmpty) {
1273
2401
  if (!force) {
1274
2402
  throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
1275
2403
  }
1276
- rmSync(targetDir, { recursive: true, force: true });
1277
2404
  }
1278
- mkdirSync(targetDir, { recursive: true });
1279
2405
 
1280
2406
  const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
1281
2407
  const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
@@ -1289,17 +2415,229 @@ export async function pullAppSource({
1289
2415
  const contentDisposition = response.headers.get('content-disposition') || '';
1290
2416
  const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
1291
2417
  const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
1292
- extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
1293
- const linkedVersion = pulledVersion || (
1294
- String(version || 'latest') === 'latest'
1295
- ? undefined
1296
- : Number.parseInt(String(version), 10)
1297
- );
1298
- writeLinkedState(targetDir, {
1299
- app_id: appId,
1300
- ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
1301
- linked_at: new Date().toISOString(),
1302
- });
2418
+ const requestedVersion = String(version || 'latest') === 'latest'
2419
+ ? null
2420
+ : Number.parseInt(String(version), 10);
2421
+ if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
2422
+ throw usageError('The app source response did not identify a valid positive version.');
2423
+ }
2424
+ if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
2425
+ throw usageError(`Invalid app source version: ${version}`);
2426
+ }
2427
+ if (
2428
+ requestedVersion !== null
2429
+ && pulledVersion !== requestedVersion
2430
+ ) {
2431
+ throw usageError(
2432
+ `Requested app source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
2433
+ );
2434
+ }
2435
+ const linkedVersion = pulledVersion;
2436
+ const archiveBuffer = Buffer.from(await response.arrayBuffer());
2437
+ assertParentIdentity();
2438
+ assertLockOwnership();
2439
+
2440
+ // A failed download or malformed archive must never damage an existing
2441
+ // checkout. Extract into a transaction directory under the pinned target,
2442
+ // then replace
2443
+ // only source-managed entries. Local-only state such as .git, .env files,
2444
+ // dependencies, symlinks, build output, and .notis runtime directories is
2445
+ // moved aside recursively and restored into the new source tree.
2446
+ let cleanupTransaction = true;
2447
+ let cwdPinned = false;
2448
+ let transactionDir = null;
2449
+ let transactionDisplayPath = null;
2450
+ let operationError = null;
2451
+ const previousCwd = process.cwd();
2452
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2453
+ try {
2454
+ const parentDir = dirname(targetDir);
2455
+ const targetName = basename(targetDir);
2456
+ const parentIdentity = capturePullTargetIdentity(parentDir);
2457
+ assertParentIdentity();
2458
+ process.chdir(parentDir);
2459
+ cwdPinned = true;
2460
+ assertPullTargetIdentity('.', parentIdentity);
2461
+
2462
+ assertParentIdentity();
2463
+ assertPullTargetIdentity(targetName, initialTargetIdentity);
2464
+ if (!initialTargetIdentity.exists) {
2465
+ try {
2466
+ mkdirSync(targetName);
2467
+ } catch (error) {
2468
+ if (error?.code === 'EEXIST') {
2469
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2470
+ }
2471
+ throw error;
2472
+ }
2473
+ }
2474
+ const activeTargetIdentity = capturePullTargetIdentity(targetName);
2475
+ if (
2476
+ initialTargetIdentity.exists
2477
+ && (
2478
+ activeTargetIdentity.dev !== initialTargetIdentity.dev
2479
+ || activeTargetIdentity.ino !== initialTargetIdentity.ino
2480
+ )
2481
+ ) {
2482
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2483
+ }
2484
+ assertLockOwnership();
2485
+ process.chdir(targetName);
2486
+ assertPullTargetIdentity('.', activeTargetIdentity);
2487
+ const targetRoot = '.';
2488
+ assertLinkedStatePathSafe(targetRoot);
2489
+ const preservedPaths = collectPullPreservedPaths(targetRoot);
2490
+
2491
+ const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
2492
+ transactionDir = transactionName;
2493
+ transactionDisplayPath = join(targetDir, transactionName);
2494
+ const stageDir = join(transactionDir, 'stage');
2495
+ const backupDir = join(transactionDir, 'backup');
2496
+ const preservedDir = join(transactionDir, 'preserved');
2497
+ mkdirSync(stageDir, { recursive: true });
2498
+ mkdirSync(backupDir, { recursive: true });
2499
+ mkdirSync(preservedDir, { recursive: true });
2500
+
2501
+ extractTarGz(archiveBuffer, stageDir);
2502
+ const stagedEntries = readdirSync(stageDir);
2503
+ if (stagedEntries.length === 0) {
2504
+ throw usageError('The app source archive did not contain any editable source files.');
2505
+ }
2506
+
2507
+ const previousStatePath = join(targetRoot, STATE_FILE);
2508
+ const backupStatePath = join(backupDir, STATE_FILE);
2509
+ const hadPreviousState = existsSync(previousStatePath);
2510
+ if (hadPreviousState) {
2511
+ mkdirSync(dirname(backupStatePath), { recursive: true });
2512
+ cpSync(previousStatePath, backupStatePath);
2513
+ }
2514
+
2515
+ const movedPreserved = [];
2516
+ const movedOriginal = [];
2517
+ const movedStaged = [];
2518
+ const restoredPreserved = [];
2519
+ try {
2520
+ for (const relPath of preservedPaths) {
2521
+ const preservedPath = join(preservedDir, relPath);
2522
+ mkdirSync(dirname(preservedPath), { recursive: true });
2523
+ renameSync(join(targetRoot, relPath), preservedPath);
2524
+ movedPreserved.push(relPath);
2525
+ }
2526
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2527
+ assertLockOwnership();
2528
+ const managedTargetEntries = readdirSync(targetRoot)
2529
+ .filter((name) => name !== transactionName);
2530
+ for (const name of managedTargetEntries) {
2531
+ renameSync(join(targetRoot, name), join(backupDir, name));
2532
+ movedOriginal.push(name);
2533
+ }
2534
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2535
+ assertLockOwnership();
2536
+ for (const name of stagedEntries) {
2537
+ renameSync(join(stageDir, name), join(targetRoot, name));
2538
+ movedStaged.push(name);
2539
+ }
2540
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2541
+ assertLockOwnership();
2542
+ for (const relPath of movedPreserved) {
2543
+ const targetPath = join(targetRoot, relPath);
2544
+ try {
2545
+ lstatSync(targetPath);
2546
+ throw usageError(
2547
+ `Local-only path conflicts with pulled source: ${relPath}. Move it aside and retry.`,
2548
+ );
2549
+ } catch (error) {
2550
+ if (error?.code !== 'ENOENT') throw error;
2551
+ }
2552
+ mkdirSync(dirname(targetPath), { recursive: true });
2553
+ renameSync(join(preservedDir, relPath), targetPath);
2554
+ restoredPreserved.push(relPath);
2555
+ }
2556
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2557
+ assertLockOwnership();
2558
+ writeLinkedState(targetRoot, {
2559
+ app_id: appId,
2560
+ ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
2561
+ linked_at: new Date().toISOString(),
2562
+ }, profileKey);
2563
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2564
+ assertLockOwnership();
2565
+ } catch (error) {
2566
+ try {
2567
+ for (const relPath of restoredPreserved.reverse()) {
2568
+ const preservedPath = join(preservedDir, relPath);
2569
+ mkdirSync(dirname(preservedPath), { recursive: true });
2570
+ renameSync(join(targetRoot, relPath), preservedPath);
2571
+ }
2572
+ for (const name of movedStaged.reverse()) {
2573
+ renameSync(join(targetRoot, name), join(stageDir, name));
2574
+ }
2575
+ for (const name of movedOriginal.reverse()) {
2576
+ renameSync(join(backupDir, name), join(targetRoot, name));
2577
+ }
2578
+ for (const relPath of movedPreserved.reverse()) {
2579
+ const targetPath = join(targetRoot, relPath);
2580
+ mkdirSync(dirname(targetPath), { recursive: true });
2581
+ renameSync(join(preservedDir, relPath), targetPath);
2582
+ }
2583
+ if (hadPreviousState) {
2584
+ cpSync(backupStatePath, previousStatePath);
2585
+ } else {
2586
+ rmSync(previousStatePath, { force: true });
2587
+ }
2588
+ } catch (rollbackError) {
2589
+ cleanupTransaction = false;
2590
+ throw usageError(
2591
+ `${error instanceof Error ? error.message : String(error)} `
2592
+ + `Rollback failed; recovery files were retained at ${transactionDisplayPath}: `
2593
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
2594
+ );
2595
+ }
2596
+ throw error;
2597
+ }
2598
+ } catch (error) {
2599
+ operationError = error;
2600
+ throw error;
2601
+ } finally {
2602
+ let cleanupError = null;
2603
+ try {
2604
+ if (cleanupTransaction && transactionDir) {
2605
+ rmSync(transactionDir, { recursive: true, force: true });
2606
+ }
2607
+ } catch (error) {
2608
+ cleanupError = error;
2609
+ } finally {
2610
+ if (cwdPinned) {
2611
+ const cwdMatchesTarget = (
2612
+ previousCwdIdentity.exists
2613
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
2614
+ );
2615
+ if (!cwdMatchesTarget) {
2616
+ try {
2617
+ process.chdir(previousCwd);
2618
+ } catch {
2619
+ // The command is about to return; never trade recovered source for a cwd error.
2620
+ }
2621
+ }
2622
+ }
2623
+ }
2624
+ if (cleanupError) {
2625
+ const cleanupMessage = cleanupError instanceof Error
2626
+ ? cleanupError.message
2627
+ : String(cleanupError);
2628
+ if (operationError) {
2629
+ throw usageError(
2630
+ `${operationError instanceof Error ? operationError.message : String(operationError)} `
2631
+ + `Transaction cleanup also failed; recovery files were retained at `
2632
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2633
+ );
2634
+ }
2635
+ throw usageError(
2636
+ `App source was updated, but transaction cleanup failed; recovery files were retained at `
2637
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2638
+ );
2639
+ }
2640
+ }
1303
2641
 
1304
2642
  return { projectDir: targetDir, version: pulledVersion || version };
1305
2643
  }