@notis_ai/cli 0.2.0-beta.139.1 → 0.2.0-beta.144.1

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 (270) hide show
  1. package/README.md +44 -20
  2. package/dist/agent-hooks/notis-agent-hook.mjs +4579 -1449
  3. package/dist/base-skills/notis-apps/SKILL.md +83 -55
  4. package/dist/base-skills/notis-cli/SKILL.md +8 -8
  5. package/package.json +2 -2
  6. package/skills/notis-apps/cli.md +38 -17
  7. package/src/command-specs/apps.js +871 -387
  8. package/src/command-specs/auth.js +47 -10
  9. package/src/command-specs/onboarding.js +11 -4
  10. package/src/runtime/app-dev-consumers.js +154 -0
  11. package/src/runtime/app-dev-host-lock.js +80 -0
  12. package/src/runtime/app-dev-roots.js +284 -0
  13. package/src/runtime/app-dev-server.js +212 -22
  14. package/src/runtime/app-dev-sessions.js +105 -145
  15. package/src/runtime/app-platform.js +1291 -165
  16. package/src/runtime/app-registry-scaffolds.js +367 -0
  17. package/src/runtime/login-listener.js +15 -0
  18. package/src/runtime/oauth.js +1506 -141
  19. package/src/runtime/output.js +7 -0
  20. package/src/runtime/ports.js +16 -0
  21. package/src/runtime/profiles.js +0 -5
  22. package/template/app/layout.tsx +5 -1
  23. package/template/notis.config.ts +1 -0
  24. package/template/packages/sdk/package.json +2 -1
  25. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  26. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  27. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  28. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  29. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  30. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  31. package/template/packages/sdk/src/config.ts +19 -0
  32. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  33. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  34. package/template/packages/sdk/src/hooks/useHandover.ts +3 -0
  35. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  36. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  37. package/template/packages/sdk/src/index.ts +47 -2
  38. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  39. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  40. package/template/packages/sdk/src/interactions.ts +41 -0
  41. package/template/packages/sdk/src/provider.tsx +2 -1
  42. package/template/packages/sdk/src/runtime.ts +85 -3
  43. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  44. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  45. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  46. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  47. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  48. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  49. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  50. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  51. package/dist/scaffolds/notis-database/components.json +0 -20
  52. package/dist/scaffolds/notis-database/index.html +0 -12
  53. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  54. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  55. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  56. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  57. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  58. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  59. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  60. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  61. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  62. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  63. package/dist/scaffolds/notis-database/package.json +0 -32
  64. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  65. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  66. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  67. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  68. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  69. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  70. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -234
  71. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -250
  72. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  73. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  74. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  75. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  76. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  77. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  78. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +0 -75
  79. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  80. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  81. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  82. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  83. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  84. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  85. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  86. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -100
  87. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  88. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -351
  89. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  90. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  91. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  92. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  93. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  94. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  95. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  96. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  97. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  98. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  99. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  100. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  101. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  102. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  103. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  104. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  105. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  106. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  107. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  108. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  109. package/dist/scaffolds/notis-journal/components.json +0 -20
  110. package/dist/scaffolds/notis-journal/index.html +0 -12
  111. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  112. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  113. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  114. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  115. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  116. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  117. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  118. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  119. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  120. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  121. package/dist/scaffolds/notis-journal/package.json +0 -34
  122. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  123. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  124. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  125. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  126. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  127. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  128. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -234
  129. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -250
  130. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  131. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  132. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  133. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  134. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  135. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  136. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +0 -75
  137. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  138. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  139. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  140. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  141. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  142. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  143. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  144. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -100
  145. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  146. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -351
  147. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  148. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  149. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  150. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  151. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  152. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  153. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  154. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -199
  155. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  156. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  157. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  158. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  159. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  160. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  161. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  162. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  163. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  164. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  165. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  166. package/dist/scaffolds/notis-notes/components.json +0 -20
  167. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  168. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  169. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  170. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  171. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  172. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  173. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  174. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  175. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  176. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  177. package/dist/scaffolds/notis-notes/package.json +0 -35
  178. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  179. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  180. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  181. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  182. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  183. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  184. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -234
  185. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -250
  186. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  187. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  188. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  189. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  190. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  191. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  192. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +0 -75
  193. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  194. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  195. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  196. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  197. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  198. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  199. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  200. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -100
  201. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  202. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -351
  203. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  204. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  205. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  206. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  207. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  208. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  209. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  210. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  211. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  212. package/dist/scaffolds/notis-random/README.md +0 -33
  213. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  214. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  215. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  216. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  217. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  218. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  219. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  220. package/dist/scaffolds/notis-random/components.json +0 -20
  221. package/dist/scaffolds/notis-random/index.html +0 -12
  222. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  223. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  224. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  225. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  226. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  227. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  228. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  229. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  230. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  231. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  232. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  233. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  234. package/dist/scaffolds/notis-random/package.json +0 -36
  235. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  236. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  237. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  238. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  239. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  240. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  241. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -234
  242. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -250
  243. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  244. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
  245. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  246. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
  247. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  248. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  249. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +0 -75
  250. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  251. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  252. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  253. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  254. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  255. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  256. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  257. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -100
  258. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  259. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -351
  260. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  261. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  262. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  263. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  264. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  265. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  266. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  267. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  268. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  269. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  270. package/dist/scaffolds.json +0 -49
@@ -7,13 +7,16 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
11
12
  import { createRequire } from 'node:module';
12
- import { dirname, join, relative, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, relative, resolve } from 'node:path';
13
15
  import { fileURLToPath } from 'node:url';
14
16
  import { gunzipSync } from 'node:zlib';
15
17
 
16
18
  import { usageError } from './errors.js';
19
+ import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
17
20
  import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
18
21
  import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
19
22
 
@@ -24,10 +27,6 @@ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
24
27
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
25
28
  const METADATA_DIR = 'metadata';
26
29
  const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
27
- const MONOREPO_SCAFFOLDS_DIR = resolve(CLI_ROOT, '../..', 'scaffolds');
28
- const DIST_DIR = join(CLI_ROOT, 'dist');
29
- const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
30
- const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
31
30
  const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
32
31
  export const NOTIS_APP_CATEGORIES = [
33
32
  'Productivity',
@@ -48,6 +47,9 @@ const SOURCE_COPY_EXCLUDES = new Set([
48
47
  // scripts/ directory must not ship version-specific bytecode in the bundle.
49
48
  '__pycache__',
50
49
  ]);
50
+ const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
51
+ [...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
52
+ );
51
53
  const SCAFFOLD_COPY_EXCLUDES = new Set([
52
54
  ...SOURCE_COPY_EXCLUDES,
53
55
  'coverage',
@@ -60,6 +62,11 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
60
62
  // not listing media, it is the stub data the dev harness serves, and dropping
61
63
  // it would make every route of a fresh project render its empty state.
62
64
  const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
65
+ const PULL_LOCK_TIMEOUT_MS = 30_000;
66
+ const PULL_LOCK_POLL_MS = 25;
67
+ const STATE_WRITE_LOCK_TIMEOUT_MS = 5_000;
68
+ const STATE_WRITE_LOCK_STALE_MS = 2_000;
69
+ const stateWriteLockWait = new Int32Array(new SharedArrayBuffer(4));
63
70
  // A directory-declared skill ships every supporting file to the sandbox, so it
64
71
  // needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
65
72
  // handful of scripts always fits, and far below the bundle machinery's own
@@ -71,8 +78,38 @@ let appConfigImportNonce = 0;
71
78
  // Project directory resolution
72
79
  // ---------------------------------------------------------------------------
73
80
 
81
+ // Every Notis app the user did not deliberately place lives here, mirroring the
82
+ // desktop's synced-skills root (~/.notis/skills). A default that depends on the
83
+ // caller's working directory is unusable for agents: the shell sits wherever
84
+ // the previous command left it, so `apps init` would scatter projects into
85
+ // unrelated checkouts. Pass an explicit [dir] to override -- that is how an app
86
+ // ends up in a tracked git repo or an existing monorepo.
87
+ export const DEFAULT_APPS_ROOT = join(homedir(), NOTIS_DIR, 'apps');
88
+
89
+ export function defaultAppProjectDir(slug) {
90
+ const safeSlug = String(slug || '').trim();
91
+ if (!safeSlug || safeSlug.includes('/') || safeSlug.includes('\\') || safeSlug.startsWith('.')) {
92
+ throw usageError(`Cannot derive a default project directory from "${slug}". Pass an explicit target directory.`);
93
+ }
94
+ return join(DEFAULT_APPS_ROOT, safeSlug);
95
+ }
96
+
97
+ // A `~/...` path only expands when a shell is involved. Agents routinely spawn
98
+ // the CLI without one, and the documented default home is written with a tilde,
99
+ // so expand it here instead of creating a literal "~" directory.
100
+ export function expandHomePath(inputPath) {
101
+ const value = String(inputPath ?? '');
102
+ if (value === '~') {
103
+ return homedir();
104
+ }
105
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
106
+ return join(homedir(), value.slice(2));
107
+ }
108
+ return value;
109
+ }
110
+
74
111
  export function resolveProjectDir(inputDir = '.') {
75
- return resolve(process.cwd(), inputDir);
112
+ return resolve(process.cwd(), expandHomePath(inputDir));
76
113
  }
77
114
 
78
115
  export function getBundleDir(projectDir) {
@@ -859,11 +896,25 @@ export function generateManifest(appConfig, projectDir) {
859
896
  databases,
860
897
  capabilities: normalizeAppCapabilities(appConfig.capabilities),
861
898
  tools: appConfig.tools || [],
899
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
862
900
  skills,
863
901
  onboarding: appConfig.onboarding || null,
864
902
  };
865
903
  }
866
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
+
867
918
  /**
868
919
  * Keeps only capabilities the platform actually understands, at the exact
869
920
  * values it accepts. An unknown key or value is dropped rather than passed
@@ -1065,21 +1116,212 @@ function copyMetadataAssets(projectDir) {
1065
1116
  // Linking
1066
1117
  // ---------------------------------------------------------------------------
1067
1118
 
1068
- 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) {
1069
1148
  const statePath = join(projectDir, STATE_FILE);
1149
+ assertLinkedStatePathSafe(projectDir);
1070
1150
  if (!existsSync(statePath)) return null;
1071
- 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
+ };
1160
+ }
1161
+
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;
1072
1204
  }
1073
1205
 
1074
- export function writeLinkedState(projectDir, state) {
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) {
1075
1276
  const statePath = join(projectDir, STATE_FILE);
1076
- mkdirSync(dirname(statePath), { recursive: true });
1077
- 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
+ });
1078
1298
  }
1079
1299
 
1080
- export function requireLinkedAppId(projectDir, explicitAppId) {
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
+ }
1320
+ }
1321
+
1322
+ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
1081
1323
  if (explicitAppId) return explicitAppId;
1082
- const state = readLinkedState(projectDir);
1324
+ const state = readLinkedState(projectDir, profileKey);
1083
1325
  if (state?.app_id) return state.app_id;
1084
1326
  throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
1085
1327
  }
@@ -1089,79 +1331,342 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
1089
1331
  // ---------------------------------------------------------------------------
1090
1332
 
1091
1333
  /**
1092
- * 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.
1093
1336
  */
1094
- export function loadScaffoldCatalog() {
1095
- if (existsSync(SCAFFOLD_CATALOG_FILE)) {
1096
- const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
1097
- const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
1098
- return Array.isArray(scaffolds)
1099
- ? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
1100
- : [];
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
+ }
1101
1345
  }
1102
- return loadMonorepoScaffoldCatalog();
1103
- }
1104
-
1105
- export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1106
- const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
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();
1355
+ }
1356
+ }
1357
+
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
+ );
1107
1411
 
1108
- if (!existsSync(templateDir)) {
1109
- if (fromSlug) {
1110
- const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
1111
- throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
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);
1112
1430
  }
1113
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
1114
- }
1115
-
1116
- mkdirSync(projectDir, { recursive: true });
1117
- copyScaffoldSource(templateDir, projectDir);
1118
-
1119
- // Update package.json with the app name
1120
- const pkgPath = join(projectDir, 'package.json');
1121
- if (existsSync(pkgPath)) {
1122
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1123
- pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
1124
- // A scaffold creates a new app identity, even when its source comes from a
1125
- // versioned Store example. New apps must therefore start their own release
1126
- // history instead of inheriting the example app's registry version.
1127
- pkg.notisAppVersion = '0.1.0';
1128
- ensureScaffoldLocalSdk(projectDir, pkg);
1129
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1130
- normalizeScaffoldLockfile(projectDir, pkg);
1131
- }
1132
-
1133
- // Update notis.config.ts with the app name
1134
- const configPath = join(projectDir, 'notis.config.ts');
1135
- if (existsSync(configPath)) {
1136
- let config = readFileSync(configPath, 'utf-8');
1137
- const displayName = jsStringLiteral(appName);
1138
- const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1139
- if (fromSlug) {
1140
- if (/name\s*:/.test(config)) {
1141
- config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
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
+ }
1142
1456
  }
1143
- if (/title\s*:/.test(config)) {
1144
- 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}`);
1145
1459
  } else {
1146
- 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
+ );
1147
1464
  }
1148
- } else {
1149
- if (/name\s*:/.test(config)) {
1150
- 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}`);
1151
1490
  }
1152
- if (/title\s*:/.test(config)) {
1153
- config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1154
- } else {
1155
- config = config.replace(/'My Notis App'/, displayName);
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}`);
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
+ }
1156
1629
  }
1157
1630
  }
1158
- config = removeConfigArrayProperty(config, 'screenshots');
1159
- writeFileSync(configPath, config);
1160
1631
  }
1161
1632
 
1162
- resetScaffoldChangelog(projectDir, appName);
1633
+ return { projectDir: requestedProjectDir };
1634
+ }
1163
1635
 
1164
- return { projectDir };
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
+ }
1165
1670
  }
1166
1671
 
1167
1672
  /**
@@ -1274,89 +1779,189 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
1274
1779
  cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
1275
1780
  }
1276
1781
 
1277
- function resolveScaffoldSourceDir(fromSlug) {
1278
- const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
1279
- if (existsSync(bundledDir)) {
1280
- return bundledDir;
1281
- }
1282
- const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
1283
- if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
1284
- return monorepoDir;
1285
- }
1286
- return bundledDir;
1287
- }
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(\/|$))/;
1288
1786
 
1289
- function loadMonorepoScaffoldCatalog() {
1290
- if (!existsSync(MONOREPO_SCAFFOLDS_DIR)) {
1291
- 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.`);
1292
1795
  }
1293
- const scaffolds = [];
1294
- for (const entry of readdirSync(MONOREPO_SCAFFOLDS_DIR, { withFileTypes: true })) {
1295
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
1296
- continue;
1297
- }
1298
- const configPath = join(MONOREPO_SCAFFOLDS_DIR, entry.name, 'notis.config.ts');
1299
- if (!existsSync(configPath)) {
1300
- continue;
1301
- }
1302
- const configSource = readFileSync(configPath, 'utf-8');
1303
- const description = readTsStringProperty(configSource, 'description') || '';
1304
- scaffolds.push({
1305
- slug: entry.name,
1306
- name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
1307
- description,
1308
- icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
1309
- categories: readTsStringArrayProperty(configSource, 'categories'),
1310
- tagline: readTsStringProperty(configSource, 'tagline') || description,
1311
- });
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.`);
1312
1806
  }
1313
- return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
1807
+ return content;
1314
1808
  }
1315
1809
 
1316
- function readTsStringProperty(source, propertyName) {
1317
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
1318
- return match ? match[2] : null;
1810
+ function captureDirectoryRevision() {
1811
+ const stat = statSync('.', { bigint: true });
1812
+ return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
1319
1813
  }
1320
1814
 
1321
- function readTsStringArrayProperty(source, propertyName) {
1322
- const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
1323
- if (!match) {
1324
- 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.`);
1325
1824
  }
1326
- return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
1327
1825
  }
1328
1826
 
1329
1827
  function copyScaffoldSource(sourceDir, targetDir) {
1330
1828
  function shouldCopy(path) {
1331
1829
  const name = path.split(/[\\/]/).pop();
1332
1830
  if (!name) return true;
1333
- if (SCAFFOLD_LISTING_MEDIA.test(relative(sourceDir, path).replace(/\\/g, '/'))) {
1831
+ const relPath = relative(sourceDir, path).replace(/\\/g, '/');
1832
+ if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
1334
1833
  return false;
1335
1834
  }
1336
1835
  return !SCAFFOLD_COPY_EXCLUDES.has(name)
1836
+ && !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
1337
1837
  && !name.startsWith('.env')
1338
1838
  && !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
1339
1839
  }
1340
1840
 
1341
- function walk(src, dest) {
1342
- if (!shouldCopy(src)) {
1343
- 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}`);
1344
1851
  }
1345
- const stat = statSync(src);
1346
- if (stat.isDirectory()) {
1347
- mkdirSync(dest, { recursive: true });
1348
- for (const entry of readdirSync(src)) {
1349
- walk(join(src, entry), join(dest, entry));
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);
1350
1905
  }
1351
- return;
1352
1906
  }
1353
- if (stat.isFile()) {
1354
- mkdirSync(dirname(dest), { recursive: true });
1355
- cpSync(src, dest);
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}`);
1356
1917
  }
1357
1918
  }
1358
1919
 
1359
- walk(sourceDir, targetDir);
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
+ }
1946
+ }
1947
+ }
1948
+ for (const file of node.files) {
1949
+ writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
1950
+ }
1951
+ }
1952
+
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
+ }
1360
1965
  }
1361
1966
 
1362
1967
  // ---------------------------------------------------------------------------
@@ -1394,34 +1999,81 @@ export function collectArtifactFiles(projectDir) {
1394
1999
  }
1395
2000
 
1396
2001
  function shouldExcludeSourceEntry(name) {
2002
+ const casefolded = String(name || '').toLocaleLowerCase('en-US');
1397
2003
  return (
1398
- SOURCE_COPY_EXCLUDES.has(name)
1399
- || name.startsWith('.env')
1400
- || name.endsWith('.pyc')
1401
- || name.endsWith('.pyo')
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')
1402
2010
  );
1403
2011
  }
1404
2012
 
1405
2013
  function readSourceFiles(projectDir) {
1406
2014
  const files = {};
1407
-
1408
- function walk(dir, prefix) {
1409
- const entries = readdirSync(dir, { withFileTypes: true });
1410
- for (const entry of entries) {
1411
- if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
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)) {
1412
2028
  continue;
1413
2029
  }
1414
- const fullPath = join(dir, entry.name);
1415
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1416
- if (entry.isDirectory()) {
1417
- walk(fullPath, relPath);
1418
- } else if (entry.isFile()) {
1419
- files[relPath] = readFileSync(fullPath);
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
+ }
1420
2063
  }
1421
2064
  }
2065
+ assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
1422
2066
  }
1423
2067
 
1424
- 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
+ }
1425
2077
  return files;
1426
2078
  }
1427
2079
 
@@ -1443,9 +2095,55 @@ function cleanTarPath(name) {
1443
2095
  return parts.join('/');
1444
2096
  }
1445
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
+
1446
2142
  function extractTarGz(buffer, targetDir) {
1447
2143
  const tar = gunzipSync(buffer);
1448
2144
  let offset = 0;
2145
+ let pendingPaxPath = null;
2146
+ const extractedPaths = new Map();
1449
2147
  while (offset + 512 <= tar.length) {
1450
2148
  const header = tar.subarray(offset, offset + 512);
1451
2149
  offset += 512;
@@ -1459,35 +2157,251 @@ function extractTarGz(buffer, targetDir) {
1459
2157
  const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
1460
2158
  const size = Number.parseInt(sizeRaw || '0', 8);
1461
2159
  const typeFlag = header[156];
1462
- 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
+ }
1463
2190
  const outputPath = join(targetDir, relPath);
1464
2191
 
1465
2192
  if (typeFlag === 53) {
1466
2193
  mkdirSync(outputPath, { recursive: true });
1467
- } else {
2194
+ } else if (typeFlag === 0 || typeFlag === 48) {
1468
2195
  mkdirSync(dirname(outputPath), { recursive: true });
1469
- writeFileSync(outputPath, tar.subarray(offset, offset + size));
2196
+ writeFileSync(outputPath, data);
2197
+ } else {
2198
+ throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
1470
2199
  }
1471
2200
 
1472
2201
  offset += Math.ceil(size / 512) * 512;
1473
2202
  }
2203
+ if (pendingPaxPath !== null) {
2204
+ throw usageError('Refusing to extract a dangling PAX source archive header.');
2205
+ }
1474
2206
  }
1475
2207
 
1476
- export async function pullAppSource({
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;
2228
+ }
2229
+
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({
1477
2389
  apiBase,
1478
2390
  jwt,
1479
2391
  appId,
1480
2392
  targetDir,
1481
2393
  version = 'latest',
1482
2394
  force = false,
1483
- }) {
1484
- 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) {
1485
2401
  if (!force) {
1486
2402
  throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
1487
2403
  }
1488
- rmSync(targetDir, { recursive: true, force: true });
1489
2404
  }
1490
- mkdirSync(targetDir, { recursive: true });
1491
2405
 
1492
2406
  const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
1493
2407
  const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
@@ -1501,17 +2415,229 @@ export async function pullAppSource({
1501
2415
  const contentDisposition = response.headers.get('content-disposition') || '';
1502
2416
  const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
1503
2417
  const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
1504
- extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
1505
- const linkedVersion = pulledVersion || (
1506
- String(version || 'latest') === 'latest'
1507
- ? undefined
1508
- : Number.parseInt(String(version), 10)
1509
- );
1510
- writeLinkedState(targetDir, {
1511
- app_id: appId,
1512
- ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
1513
- linked_at: new Date().toISOString(),
1514
- });
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
+ }
1515
2641
 
1516
2642
  return { projectDir: targetDir, version: pulledVersion || version };
1517
2643
  }