@superblocksteam/vite-plugin-file-sync 2.0.123 → 2.0.124-next.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 (271) hide show
  1. package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts +16 -1
  2. package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts.map +1 -1
  3. package/dist/ai-service/agent/prompts/build-base-system-prompt.js +32 -1
  4. package/dist/ai-service/agent/prompts/build-base-system-prompt.js.map +1 -1
  5. package/dist/ai-service/agent/prompts/build-security-scan-prompt.d.ts.map +1 -1
  6. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js +3 -1
  7. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js.map +1 -1
  8. package/dist/ai-service/agent/tools/apis/api-comparator.d.ts.map +1 -1
  9. package/dist/ai-service/agent/tools/apis/api-comparator.js +1 -4
  10. package/dist/ai-service/agent/tools/apis/api-comparator.js.map +1 -1
  11. package/dist/ai-service/agent/tools/apis/api-testing-state.js +7 -0
  12. package/dist/ai-service/agent/tools/apis/api-testing-state.js.map +1 -1
  13. package/dist/ai-service/agent/tools/apis/get-api-docs.d.ts +1 -1
  14. package/dist/ai-service/agent/tools/apis/write-api.d.ts +2 -2
  15. package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts.map +1 -1
  16. package/dist/ai-service/agent/tools/build-capture-screenshot.js +11 -33
  17. package/dist/ai-service/agent/tools/build-capture-screenshot.js.map +1 -1
  18. package/dist/ai-service/agent/tools/build-copy-directory.d.ts +1 -1
  19. package/dist/ai-service/agent/tools/build-finalize.d.ts.map +1 -1
  20. package/dist/ai-service/agent/tools/build-finalize.js +11 -2
  21. package/dist/ai-service/agent/tools/build-finalize.js.map +1 -1
  22. package/dist/ai-service/agent/tools/build-install-packages.d.ts +41 -1
  23. package/dist/ai-service/agent/tools/build-install-packages.d.ts.map +1 -1
  24. package/dist/ai-service/agent/tools/build-install-packages.js +217 -0
  25. package/dist/ai-service/agent/tools/build-install-packages.js.map +1 -1
  26. package/dist/ai-service/agent/tools/build-lookup-npm-package.d.ts +28 -0
  27. package/dist/ai-service/agent/tools/build-lookup-npm-package.d.ts.map +1 -0
  28. package/dist/ai-service/agent/tools/build-lookup-npm-package.js +78 -0
  29. package/dist/ai-service/agent/tools/build-lookup-npm-package.js.map +1 -0
  30. package/dist/ai-service/agent/tools/build-manage-checklist.d.ts +4 -4
  31. package/dist/ai-service/agent/tools/build-navigate-preview.d.ts +16 -0
  32. package/dist/ai-service/agent/tools/build-navigate-preview.d.ts.map +1 -0
  33. package/dist/ai-service/agent/tools/build-navigate-preview.js +68 -0
  34. package/dist/ai-service/agent/tools/build-navigate-preview.js.map +1 -0
  35. package/dist/ai-service/agent/tools/build-write-file.d.ts +1 -1
  36. package/dist/ai-service/agent/tools/databases/dev-database.d.ts +9 -9
  37. package/dist/ai-service/agent/tools/index.d.ts +2 -0
  38. package/dist/ai-service/agent/tools/index.d.ts.map +1 -1
  39. package/dist/ai-service/agent/tools/index.js +2 -0
  40. package/dist/ai-service/agent/tools/index.js.map +1 -1
  41. package/dist/ai-service/agent/tools/integrations/execute-request.d.ts +3 -3
  42. package/dist/ai-service/agent/tools/report-security-findings.d.ts +11 -5
  43. package/dist/ai-service/agent/tools/report-security-findings.d.ts.map +1 -1
  44. package/dist/ai-service/agent/tools/report-security-findings.js +1 -0
  45. package/dist/ai-service/agent/tools/report-security-findings.js.map +1 -1
  46. package/dist/ai-service/agent/tools.d.ts.map +1 -1
  47. package/dist/ai-service/agent/tools.js +3 -1
  48. package/dist/ai-service/agent/tools.js.map +1 -1
  49. package/dist/ai-service/agent/tools2/tools/git.d.ts +2 -2
  50. package/dist/ai-service/agent/tools2/tools/grep.d.ts +2 -2
  51. package/dist/ai-service/agent/tools2/tools/update-test-case-status.d.ts +2 -2
  52. package/dist/ai-service/app-interface/npm-error-parser.d.ts +161 -0
  53. package/dist/ai-service/app-interface/npm-error-parser.d.ts.map +1 -0
  54. package/dist/ai-service/app-interface/npm-error-parser.js +510 -0
  55. package/dist/ai-service/app-interface/npm-error-parser.js.map +1 -0
  56. package/dist/ai-service/app-interface/npm-package-lookup.d.ts +286 -0
  57. package/dist/ai-service/app-interface/npm-package-lookup.d.ts.map +1 -0
  58. package/dist/ai-service/app-interface/npm-package-lookup.js +793 -0
  59. package/dist/ai-service/app-interface/npm-package-lookup.js.map +1 -0
  60. package/dist/ai-service/app-interface/npm-registry.d.ts +735 -47
  61. package/dist/ai-service/app-interface/npm-registry.d.ts.map +1 -1
  62. package/dist/ai-service/app-interface/npm-registry.js +1882 -153
  63. package/dist/ai-service/app-interface/npm-registry.js.map +1 -1
  64. package/dist/ai-service/app-interface/shell.d.ts +118 -1
  65. package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
  66. package/dist/ai-service/app-interface/shell.js +590 -157
  67. package/dist/ai-service/app-interface/shell.js.map +1 -1
  68. package/dist/ai-service/app-skills/manager.d.ts +1 -1
  69. package/dist/ai-service/app-skills/manager.d.ts.map +1 -1
  70. package/dist/ai-service/app-skills/manager.js +39 -7
  71. package/dist/ai-service/app-skills/manager.js.map +1 -1
  72. package/dist/ai-service/checklist/api-migration-checklist-gate.d.ts +9 -0
  73. package/dist/ai-service/checklist/api-migration-checklist-gate.d.ts.map +1 -0
  74. package/dist/ai-service/checklist/api-migration-checklist-gate.js +30 -0
  75. package/dist/ai-service/checklist/api-migration-checklist-gate.js.map +1 -0
  76. package/dist/ai-service/checklist/api-migration-origins.d.ts +4 -0
  77. package/dist/ai-service/checklist/api-migration-origins.d.ts.map +1 -0
  78. package/dist/ai-service/checklist/api-migration-origins.js +8 -0
  79. package/dist/ai-service/checklist/api-migration-origins.js.map +1 -0
  80. package/dist/ai-service/checklist/persisted-checklist-store.d.ts +2 -3
  81. package/dist/ai-service/checklist/persisted-checklist-store.d.ts.map +1 -1
  82. package/dist/ai-service/checklist/persisted-checklist-store.js +6 -7
  83. package/dist/ai-service/checklist/persisted-checklist-store.js.map +1 -1
  84. package/dist/ai-service/context-archive-paths.d.ts +4 -0
  85. package/dist/ai-service/context-archive-paths.d.ts.map +1 -0
  86. package/dist/ai-service/context-archive-paths.js +28 -0
  87. package/dist/ai-service/context-archive-paths.js.map +1 -0
  88. package/dist/ai-service/context-download.d.ts +3 -1
  89. package/dist/ai-service/context-download.d.ts.map +1 -1
  90. package/dist/ai-service/context-download.js +48 -9
  91. package/dist/ai-service/context-download.js.map +1 -1
  92. package/dist/ai-service/context-upload.d.ts +3 -2
  93. package/dist/ai-service/context-upload.d.ts.map +1 -1
  94. package/dist/ai-service/context-upload.js +44 -26
  95. package/dist/ai-service/context-upload.js.map +1 -1
  96. package/dist/ai-service/dev-database-client.d.ts +3 -11
  97. package/dist/ai-service/dev-database-client.d.ts.map +1 -1
  98. package/dist/ai-service/dev-database-client.js.map +1 -1
  99. package/dist/ai-service/features.d.ts +4 -0
  100. package/dist/ai-service/features.d.ts.map +1 -1
  101. package/dist/ai-service/features.js +8 -0
  102. package/dist/ai-service/features.js.map +1 -1
  103. package/dist/ai-service/filter-disabled-tools-for-migration.d.ts.map +1 -1
  104. package/dist/ai-service/filter-disabled-tools-for-migration.js +4 -0
  105. package/dist/ai-service/filter-disabled-tools-for-migration.js.map +1 -1
  106. package/dist/ai-service/index.d.ts +103 -0
  107. package/dist/ai-service/index.d.ts.map +1 -1
  108. package/dist/ai-service/index.js +610 -25
  109. package/dist/ai-service/index.js.map +1 -1
  110. package/dist/ai-service/llm/client.d.ts +38 -3
  111. package/dist/ai-service/llm/client.d.ts.map +1 -1
  112. package/dist/ai-service/llm/client.js +73 -22
  113. package/dist/ai-service/llm/client.js.map +1 -1
  114. package/dist/ai-service/llm/context-v2/adapter.d.ts +3 -1
  115. package/dist/ai-service/llm/context-v2/adapter.d.ts.map +1 -1
  116. package/dist/ai-service/llm/context-v2/adapter.js +11 -11
  117. package/dist/ai-service/llm/context-v2/adapter.js.map +1 -1
  118. package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts +44 -0
  119. package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts.map +1 -0
  120. package/dist/ai-service/llm/context-v2/compaction/client-side.js +170 -0
  121. package/dist/ai-service/llm/context-v2/compaction/client-side.js.map +1 -0
  122. package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.d.ts +56 -0
  123. package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.d.ts.map +1 -0
  124. package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.js +9 -0
  125. package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.js.map +1 -0
  126. package/dist/ai-service/llm/context-v2/compaction/index.d.ts +5 -0
  127. package/dist/ai-service/llm/context-v2/compaction/index.d.ts.map +1 -0
  128. package/dist/ai-service/llm/context-v2/compaction/index.js +3 -0
  129. package/dist/ai-service/llm/context-v2/compaction/index.js.map +1 -0
  130. package/dist/ai-service/llm/context-v2/compaction/server-side.d.ts +30 -0
  131. package/dist/ai-service/llm/context-v2/compaction/server-side.d.ts.map +1 -0
  132. package/dist/ai-service/llm/context-v2/compaction/server-side.js +130 -0
  133. package/dist/ai-service/llm/context-v2/compaction/server-side.js.map +1 -0
  134. package/dist/ai-service/llm/context-v2/context-management.d.ts +203 -0
  135. package/dist/ai-service/llm/context-v2/context-management.d.ts.map +1 -0
  136. package/dist/ai-service/llm/context-v2/context-management.js +183 -0
  137. package/dist/ai-service/llm/context-v2/context-management.js.map +1 -0
  138. package/dist/ai-service/llm/context-v2/context.d.ts +64 -22
  139. package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
  140. package/dist/ai-service/llm/context-v2/context.js +233 -157
  141. package/dist/ai-service/llm/context-v2/context.js.map +1 -1
  142. package/dist/ai-service/llm/context-v2/conversation-context.d.ts +24 -7
  143. package/dist/ai-service/llm/context-v2/conversation-context.d.ts.map +1 -1
  144. package/dist/ai-service/llm/context-v2/conversation-context.js +1 -1
  145. package/dist/ai-service/llm/context-v2/index.d.ts +0 -4
  146. package/dist/ai-service/llm/context-v2/index.d.ts.map +1 -1
  147. package/dist/ai-service/llm/context-v2/index.js +0 -4
  148. package/dist/ai-service/llm/context-v2/index.js.map +1 -1
  149. package/dist/ai-service/llm/context-v2/manager.d.ts +24 -3
  150. package/dist/ai-service/llm/context-v2/manager.d.ts.map +1 -1
  151. package/dist/ai-service/llm/context-v2/manager.js +146 -4
  152. package/dist/ai-service/llm/context-v2/manager.js.map +1 -1
  153. package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.d.ts +2 -7
  154. package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.d.ts.map +1 -1
  155. package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.js +5 -73
  156. package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.js.map +1 -1
  157. package/dist/ai-service/llm/context-v2/storage/event-store.d.ts +57 -0
  158. package/dist/ai-service/llm/context-v2/storage/event-store.d.ts.map +1 -0
  159. package/dist/ai-service/llm/context-v2/storage/event-store.js +10 -0
  160. package/dist/ai-service/llm/context-v2/storage/event-store.js.map +1 -0
  161. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts +50 -0
  162. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts.map +1 -0
  163. package/dist/ai-service/llm/context-v2/storage/event-types.js +10 -0
  164. package/dist/ai-service/llm/context-v2/storage/event-types.js.map +1 -0
  165. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts +87 -0
  166. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts.map +1 -0
  167. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js +184 -0
  168. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js.map +1 -0
  169. package/dist/ai-service/llm/context-v2/storage/migration.d.ts +49 -0
  170. package/dist/ai-service/llm/context-v2/storage/migration.d.ts.map +1 -0
  171. package/dist/ai-service/llm/context-v2/storage/migration.js +126 -0
  172. package/dist/ai-service/llm/context-v2/storage/migration.js.map +1 -0
  173. package/dist/ai-service/llm/context-v2/types.d.ts +16 -11
  174. package/dist/ai-service/llm/context-v2/types.d.ts.map +1 -1
  175. package/dist/ai-service/llm/context-v2/types.js +2 -2
  176. package/dist/ai-service/llm/context-v2/types.js.map +1 -1
  177. package/dist/ai-service/llm/impl/clark.d.ts +8 -0
  178. package/dist/ai-service/llm/impl/clark.d.ts.map +1 -1
  179. package/dist/ai-service/llm/impl/clark.js +8 -0
  180. package/dist/ai-service/llm/impl/clark.js.map +1 -1
  181. package/dist/ai-service/llm/interaction/adapters/vercel.d.ts.map +1 -1
  182. package/dist/ai-service/llm/interaction/adapters/vercel.js +17 -1
  183. package/dist/ai-service/llm/interaction/adapters/vercel.js.map +1 -1
  184. package/dist/ai-service/llm/provider.d.ts.map +1 -1
  185. package/dist/ai-service/llm/provider.js +1 -3
  186. package/dist/ai-service/llm/provider.js.map +1 -1
  187. package/dist/ai-service/llm/stream/observers/context.d.ts +23 -0
  188. package/dist/ai-service/llm/stream/observers/context.d.ts.map +1 -1
  189. package/dist/ai-service/llm/stream/observers/context.js +62 -0
  190. package/dist/ai-service/llm/stream/observers/context.js.map +1 -1
  191. package/dist/ai-service/llm/tool-context-integrity.d.ts +25 -0
  192. package/dist/ai-service/llm/tool-context-integrity.d.ts.map +1 -0
  193. package/dist/ai-service/llm/tool-context-integrity.js +141 -0
  194. package/dist/ai-service/llm/tool-context-integrity.js.map +1 -0
  195. package/dist/ai-service/skills/system/superblocks-migration/skill.generated.d.ts +1 -1
  196. package/dist/ai-service/skills/system/superblocks-migration/skill.generated.d.ts.map +1 -1
  197. package/dist/ai-service/skills/system/superblocks-migration/skill.generated.js +6 -1
  198. package/dist/ai-service/skills/system/superblocks-migration/skill.generated.js.map +1 -1
  199. package/dist/ai-service/skills/system/third-party-migration/skill.generated.d.ts +1 -1
  200. package/dist/ai-service/skills/system/third-party-migration/skill.generated.d.ts.map +1 -1
  201. package/dist/ai-service/skills/system/third-party-migration/skill.generated.js +3 -2
  202. package/dist/ai-service/skills/system/third-party-migration/skill.generated.js.map +1 -1
  203. package/dist/ai-service/state-machine/clark-fsm.d.ts +23 -2
  204. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  205. package/dist/ai-service/state-machine/clark-fsm.js +37 -2
  206. package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
  207. package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
  208. package/dist/ai-service/state-machine/handlers/agent-planning.js +29 -2
  209. package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
  210. package/dist/ai-service/state-machine/handlers/awaiting-user.d.ts.map +1 -1
  211. package/dist/ai-service/state-machine/handlers/awaiting-user.js +9 -0
  212. package/dist/ai-service/state-machine/handlers/awaiting-user.js.map +1 -1
  213. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
  214. package/dist/ai-service/state-machine/handlers/llm-generating.js +25 -2
  215. package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
  216. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.d.ts +6 -0
  217. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.d.ts.map +1 -0
  218. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js +36 -0
  219. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js.map +1 -0
  220. package/dist/ai-service/state-machine/helpers/stable-peer.d.ts +30 -1
  221. package/dist/ai-service/state-machine/helpers/stable-peer.d.ts.map +1 -1
  222. package/dist/ai-service/state-machine/helpers/stable-peer.js +85 -2
  223. package/dist/ai-service/state-machine/helpers/stable-peer.js.map +1 -1
  224. package/dist/ai-service/state-machine/helpers/user-preferences.d.ts +8 -0
  225. package/dist/ai-service/state-machine/helpers/user-preferences.d.ts.map +1 -0
  226. package/dist/ai-service/state-machine/helpers/user-preferences.js +11 -0
  227. package/dist/ai-service/state-machine/helpers/user-preferences.js.map +1 -0
  228. package/dist/ai-service/template-renderer.d.ts +18 -1
  229. package/dist/ai-service/template-renderer.d.ts.map +1 -1
  230. package/dist/ai-service/template-renderer.js +85 -20
  231. package/dist/ai-service/template-renderer.js.map +1 -1
  232. package/dist/ai-service/types.d.ts +157 -0
  233. package/dist/ai-service/types.d.ts.map +1 -1
  234. package/dist/ai-service/types.js +137 -0
  235. package/dist/ai-service/types.js.map +1 -1
  236. package/dist/ai-service/util/llm-config-utils.d.ts +21 -22
  237. package/dist/ai-service/util/llm-config-utils.d.ts.map +1 -1
  238. package/dist/ai-service/util/llm-config-utils.js +40 -67
  239. package/dist/ai-service/util/llm-config-utils.js.map +1 -1
  240. package/dist/file-sync-vite-plugin.d.ts.map +1 -1
  241. package/dist/file-sync-vite-plugin.js +5 -0
  242. package/dist/file-sync-vite-plugin.js.map +1 -1
  243. package/dist/file-system-helpers.d.ts +17 -0
  244. package/dist/file-system-helpers.d.ts.map +1 -1
  245. package/dist/file-system-helpers.js +22 -0
  246. package/dist/file-system-helpers.js.map +1 -1
  247. package/dist/git-service/index.d.ts.map +1 -1
  248. package/dist/git-service/index.js +15 -1
  249. package/dist/git-service/index.js.map +1 -1
  250. package/dist/migration/migration-routes.d.ts.map +1 -1
  251. package/dist/migration/migration-routes.js +26 -3
  252. package/dist/migration/migration-routes.js.map +1 -1
  253. package/dist/migration/translation-prompt.d.ts.map +1 -1
  254. package/dist/migration/translation-prompt.js +5 -0
  255. package/dist/migration/translation-prompt.js.map +1 -1
  256. package/dist/migration-templates/app-fullstack/package.json +2 -2
  257. package/dist/policy-gate-callback-mapper.d.ts +24 -0
  258. package/dist/policy-gate-callback-mapper.d.ts.map +1 -0
  259. package/dist/policy-gate-callback-mapper.js +61 -0
  260. package/dist/policy-gate-callback-mapper.js.map +1 -0
  261. package/dist/policy-gate-runner.d.ts +32 -0
  262. package/dist/policy-gate-runner.d.ts.map +1 -0
  263. package/dist/policy-gate-runner.js +191 -0
  264. package/dist/policy-gate-runner.js.map +1 -0
  265. package/dist/router-parser.d.ts.map +1 -1
  266. package/dist/router-parser.js +107 -11
  267. package/dist/router-parser.js.map +1 -1
  268. package/dist/sync-service/list-dir.d.ts.map +1 -1
  269. package/dist/sync-service/list-dir.js +13 -3
  270. package/dist/sync-service/list-dir.js.map +1 -1
  271. package/package.json +28 -10
@@ -1,6 +1,82 @@
1
- import { chmod, mkdir, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
1
+ import { execFile } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
2
4
  import * as path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import ini from "ini";
7
+ import { z } from "zod";
3
8
  import { getLogger } from "../../util/logger.js";
9
+ const execFileAsync = promisify(execFile);
10
+ /**
11
+ * Wire schema for the `{ registries: [...] }` envelope returned by
12
+ * `GET /api/v1/organizations/:orgId/npm-registry`. Used by the client's
13
+ * defensive parse so a malformed payload routes to `serveStale…` instead
14
+ * of crashing the install path.
15
+ *
16
+ * The refinements (no embedded C0 control chars in any string that flows
17
+ * into `.npmrc`, scope matches npm's grammar, registry URL is https) are
18
+ * the CLI's last line of defense against a malicious admin or buggy server.
19
+ * The server validates the same shape on write, but `.npmrc` synthesis is
20
+ * security-sensitive enough that we re-validate at read time too.
21
+ */
22
+ // eslint-disable-next-line no-control-regex
23
+ const noControlChars = (s) => !/[\x00-\x1f]/.test(s);
24
+ // Mirrors the server's npm scope grammar: must start with `@`, then a
25
+ // lowercase letter/digit, then up to 254 lowercase letters/digits/`._-`.
26
+ const NPM_SCOPE_RE = /^@[a-z0-9][a-z0-9._-]{0,254}$/;
27
+ const registryEntrySchema = z.object({
28
+ id: z.string().uuid(),
29
+ scope: z
30
+ .string()
31
+ .refine(noControlChars, "scope contains a C0 control character")
32
+ .refine((s) => NPM_SCOPE_RE.test(s), "scope is not a valid npm scope")
33
+ .nullable(),
34
+ registryUrl: z
35
+ .string()
36
+ .url()
37
+ .refine(noControlChars, "registryUrl contains a C0 control character")
38
+ .refine((s) => s.startsWith("https://"), "registryUrl must be https"),
39
+ // Matches the server's `OrgNpmRegistryDto.token` (see
40
+ // `packages/server/src/controllers/v1/orgNpmRegistry/handlers.ts`),
41
+ // which is documented as `string | null` but in practice may also be
42
+ // omitted from the wire payload entirely on older server builds.
43
+ // `.nullish()` accepts `null` AND missing/undefined; the transform
44
+ // coalesces both to `null` so downstream code keeps the canonical
45
+ // `NpmRegistryEntry.token: string | null` shape and never sees
46
+ // `undefined`. An absent token is a legitimate "unauthenticated
47
+ // registry" state, NOT wire-shape drift — treating it as drift would
48
+ // route through `serveStaleOrUnconfigured` and brick the install
49
+ // path on orgs whose registry intentionally has no token.
50
+ token: z
51
+ .string()
52
+ .refine(noControlChars, "token contains a C0 control character")
53
+ .nullish()
54
+ .transform((v) => v ?? null),
55
+ });
56
+ const registryListSchema = z.object({
57
+ registries: z.array(registryEntrySchema),
58
+ /**
59
+ * Org-level npm policy field from APPS-4250. Optional on the wire because
60
+ * the server only includes it when the `superblocks.npm-registry.enabled`
61
+ * LD flag is on; an older server or a flag-off response just omits the
62
+ * field. Treated as `undefined` everywhere downstream so consumers can
63
+ * default to "scripts allowed" (today's behaviour) without a special
64
+ * code path.
65
+ */
66
+ npmAllowInstallScripts: z.boolean().optional(),
67
+ });
68
+ /**
69
+ * Wire envelope every Superblocks server endpoint wraps its JSON response in
70
+ * (`ResponseDto` in `packages/server/src/dto/response.ts`). We only need
71
+ * `data` here; `responseMeta` is unused for this endpoint and intentionally
72
+ * passes through unparsed. The outer `passthrough()` lets future
73
+ * `responseMeta`-shape drift not break the npm-registry path.
74
+ */
75
+ const envelopeSchema = z
76
+ .object({
77
+ data: z.unknown(),
78
+ })
79
+ .passthrough();
4
80
  /**
5
81
  * Scopes whose existing `<scope>:registry=` lines in a project / pod `.npmrc`
6
82
  * must survive `writeNpmrc` calls. Shared between AppShell, TemplateRenderer,
@@ -12,123 +88,605 @@ import { getLogger } from "../../util/logger.js";
12
88
  */
13
89
  export const PRESERVE_NPMRC_SCOPES = ["@superblocksteam"];
14
90
  /**
15
- * Returns true iff the value contains an embedded CR or LF, which would inject
16
- * additional directives into the rendered `.npmrc`.
91
+ * Resolves the org `allow_install_scripts` policy from a `prepareForPrivateRegistry`
92
+ * (or `maybeWriteNpmrcForDir`) result. `false` means "the org has explicitly
93
+ * disabled npm install scripts" — install entrypoints append `--ignore-scripts`.
94
+ * `true` or `undefined` (server omitted the field, LD flag off, no client wired
95
+ * up) keeps today's behaviour.
96
+ *
97
+ * Shared by AppShell install entrypoints (shell.ts) and the dev-server startup
98
+ * install (sdk/cli-replacement/dev.mts) so both paths interpret the policy
99
+ * identically.
17
100
  */
18
- function hasEmbeddedNewline(value) {
19
- return /[\r\n]/.test(value);
101
+ export function shouldIgnoreInstallScripts(result) {
102
+ return result?.config.allowInstallScripts === false;
20
103
  }
21
104
  /**
22
- * Returns a log-safe rendering of a registry URL. Strips userinfo so an
23
- * operator who accidentally inlined credentials in `SUPERBLOCKS_NPM_REGISTRY`
24
- * (`https://user:pass@host/...`) doesn't have those credentials surfaced to
25
- * centralized logging when validation rejects the value. Best-effort: if the
26
- * input is unparseable we still return a placeholder rather than the raw
27
- * value.
105
+ * Translates a list of server-shaped registry entries into the renderable
106
+ * `NpmRegistryConfig`. Centralised so `writeNpmrc`, tests, and any future
107
+ * consumer all interpret the wire shape the same way.
108
+ *
109
+ * Multiple `scope === null` entries should never happen the server's
110
+ * UNIQUE (organization_id, scope) constraint forbids it — but we are
111
+ * defensive: the FIRST default wins, subsequent ones are logged and
112
+ * discarded so a server-side bug never silently rotates the default URL
113
+ * mid-fetch.
114
+ *
115
+ * Duplicate scoped entries (same `scope` string) follow the same rule:
116
+ * first wins.
28
117
  */
29
- function redactRegistryForLog(value) {
30
- try {
31
- const u = new URL(value);
32
- if (u.username || u.password) {
33
- u.username = "";
34
- u.password = "";
35
- return `${u.toString()} (userinfo redacted)`;
118
+ export function registriesToConfig(entries, allowInstallScripts) {
119
+ if (entries.length === 0) {
120
+ return {
121
+ configured: false,
122
+ ...(allowInstallScripts !== undefined ? { allowInstallScripts } : {}),
123
+ };
124
+ }
125
+ let defaultReg;
126
+ const scopes = {};
127
+ for (const entry of entries) {
128
+ const value = {
129
+ url: entry.registryUrl,
130
+ ...(entry.token ? { token: entry.token } : {}),
131
+ };
132
+ if (entry.scope === null) {
133
+ if (defaultReg) {
134
+ getLogger().warn("[npm-registry] server returned multiple default registry entries; keeping the first", { ignoredId: entry.id });
135
+ continue;
136
+ }
137
+ defaultReg = value;
138
+ }
139
+ else {
140
+ if (scopes[entry.scope]) {
141
+ getLogger().warn("[npm-registry] server returned multiple entries for the same scope; keeping the first", { scope: entry.scope, ignoredId: entry.id });
142
+ continue;
143
+ }
144
+ scopes[entry.scope] = value;
36
145
  }
37
- return u.toString();
38
146
  }
39
- catch {
40
- return "<unparseable URL — value redacted>";
147
+ return {
148
+ configured: true,
149
+ ...(defaultReg ? { default: defaultReg } : {}),
150
+ ...(Object.keys(scopes).length > 0 ? { scopes } : {}),
151
+ ...(allowInstallScripts !== undefined ? { allowInstallScripts } : {}),
152
+ };
153
+ }
154
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
155
+ // Deep-frozen so the `return NOT_CONFIGURED` sites can hand back the same
156
+ // reference without risking cross-caller contamination. Mutation attempts
157
+ // (e.g., a future caller doing `result.config.scopes = {...}` to "augment"
158
+ // before forwarding) throw a TypeError in strict mode rather than silently
159
+ // corrupting every subsequent caller for the process lifetime.
160
+ //
161
+ // Used only when the policy field is unknown (LD flag off on the client OR
162
+ // no cache to fall back to). When the policy IS known (e.g. server returned
163
+ // `npmAllowInstallScripts: false` alongside an empty `registries` list), the
164
+ // helper below builds a fresh result so the policy still reaches AppShell.
165
+ const NOT_CONFIGURED = Object.freeze({
166
+ source: "not-configured",
167
+ config: Object.freeze({ configured: false }),
168
+ });
169
+ // Same deep-freeze rationale as NOT_CONFIGURED. Returned only when the
170
+ // server is unreachable AND we have no usable cache, so the caller can
171
+ // distinguish "we don't know" from a deliberate `not-configured`
172
+ // observation.
173
+ const UNREACHABLE = Object.freeze({
174
+ source: "unreachable",
175
+ config: Object.freeze({ configured: false }),
176
+ });
177
+ /**
178
+ * Build a "not-configured" result that still carries the org policy when
179
+ * the server returned one. Falls back to the frozen `NOT_CONFIGURED`
180
+ * singleton when the policy is unknown so cold-path callers keep allocating
181
+ * zero objects.
182
+ *
183
+ * The result and its `config` are both frozen for the same reason as
184
+ * `NOT_CONFIGURED`: a downstream caller doing `result.config.scopes = {...}`
185
+ * to "augment before forwarding" throws a TypeError in strict mode rather
186
+ * than silently producing a result that doesn't match what we returned.
187
+ */
188
+ function notConfigured(allowInstallScripts) {
189
+ if (allowInstallScripts === undefined) {
190
+ return NOT_CONFIGURED;
41
191
  }
192
+ return Object.freeze({
193
+ source: "not-configured",
194
+ config: Object.freeze({ configured: false, allowInstallScripts }),
195
+ });
42
196
  }
43
197
  /**
44
- * Parses the `SUPERBLOCKS_REQUIRE_PRIVATE_REGISTRY` truthy-flag value.
198
+ * Same shape as `notConfigured(policy)` but for the "unreachable" branch:
199
+ * server didn't respond AND we have no usable cache. Destructive cleanup
200
+ * paths (the home-npmrc writer in `cli-replacement/dev.mts`) branch on the
201
+ * `unreachable` discriminator to refuse acting on a transient outage. The
202
+ * optional `allowInstallScripts` mirrors the `notConfigured` policy
203
+ * preservation invariant so AppShell still honors a known `--ignore-scripts`
204
+ * policy during the outage.
45
205
  */
46
- function parseRequireFlag(env) {
47
- const raw = env.SUPERBLOCKS_REQUIRE_PRIVATE_REGISTRY?.trim().toLowerCase();
48
- return raw === "true" || raw === "1" || raw === "yes" || raw === "on";
206
+ function unreachable(allowInstallScripts) {
207
+ if (allowInstallScripts === undefined) {
208
+ return UNREACHABLE;
209
+ }
210
+ return Object.freeze({
211
+ source: "unreachable",
212
+ config: Object.freeze({ configured: false, allowInstallScripts }),
213
+ });
49
214
  }
50
215
  /**
51
- * Reads the npm registry env contract and returns a typed config, or undefined
52
- * when `SUPERBLOCKS_NPM_REGISTRY` is unset or invalid (preserves SaaS / local
53
- * behavior). Invalid values are logged as warnings and treated as unset rather
54
- * than half-broken (avoids landing a `.npmrc` with a malformed `registry=`
55
- * line and no `_authToken=`).
56
- *
57
- * Env contract:
58
- * - SUPERBLOCKS_NPM_REGISTRY: full registry URL
59
- * - SUPERBLOCKS_NPM_REGISTRY_TOKEN: optional auth token
60
- * - SUPERBLOCKS_NPM_REGISTRY_SCOPES_JSON: optional JSON map of scope -> URL
61
- * - SUPERBLOCKS_REQUIRE_PRIVATE_REGISTRY: opt-in strict mode (default off)
216
+ * Fetches per-org npm registry configuration from the server, caching the
217
+ * result in-memory with a short TTL. Designed so callers do not have to
218
+ * think about the LaunchDarkly flag, JWT lifecycle, or transient server
219
+ * outages they just `await client.getConfig()` and get back a renderable
220
+ * shape (or "not configured").
221
+ *
222
+ * Lifecycle invariants (see APPS-4230 scope):
223
+ *
224
+ * 1. **Flag off → short-circuit**: returns `{ configured: false }` with
225
+ * no network I/O. Lets us dark-ship LinkedIn first without hammering
226
+ * the API for the rest of the fleet.
227
+ * 2. **Cache hit within TTL → no fetch**: avoids per-install round-trips
228
+ * against the server.
229
+ * 3. **401 → await `refreshJwt` then retry once with a fresh
230
+ * `getJwt()` read**: any push-based JWT refresh (auth-hot-reload)
231
+ * that landed between the original request and the retry is picked
232
+ * up. The cache is preserved across the retry so a failed retry
233
+ * can still fall back to last-known-good. Two disjoint failure
234
+ * modes:
235
+ * - The retry itself returns 401 → hard throw. We deliberately do
236
+ * not keep retrying because the only way a freshly-read JWT also
237
+ * 401s is a true re-auth failure.
238
+ * - `refreshJwt` itself throws or times out (e.g. the push channel
239
+ * hung) → route through `serveStaleOrUnconfigured` (same as a 5xx
240
+ * / network outage). A flaky refresh channel must not crash the
241
+ * install path, and we never reach the second `attempt()`.
242
+ * 4. **Other 4xx (403/400/…) → clear the cache and throw**: these are
243
+ * deliberate denials (RBAC revocation, malformed request), not
244
+ * transient outages. Serving stale would extend the revocation
245
+ * window beyond the cache TTL. The error propagates to the caller
246
+ * (typically `AppShell` / `TemplateRenderer`), which decides
247
+ * whether to fail the install or fall back to public npm.
248
+ * 5. **5xx or network unreachable + cached value → return cache +
249
+ * `logger.warn`**: install workflows continue against the
250
+ * last-known-good registry while we surface the outage; strictly
251
+ * better than falling back to `registry.npmjs.org` mid-deploy.
252
+ * 6. **5xx or network unreachable + no usable cache → return
253
+ * `source: "unreachable"` with `{ configured: false }`**: cold start
254
+ * or only an empty-list cache. `config.configured: false` so
255
+ * write-side consumers continue to no-op, but the distinct source
256
+ * keeps destructive cleanup paths (home-npmrc unlink) from acting on
257
+ * a transient outage.
258
+ *
259
+ * The class is intentionally a thin wrapper around a single Map cell — the
260
+ * cache key is `(organizationId)` since one client serves one org. Sharing
261
+ * one client across orgs would require per-org cache keys; we don't do
262
+ * that today because the dev-server pod is single-tenant.
62
263
  */
63
- export function getNpmRegistryConfig(env = process.env) {
64
- const url = env.SUPERBLOCKS_NPM_REGISTRY?.trim();
65
- if (!url) {
66
- // The public name `SUPERBLOCKS_REQUIRE_PRIVATE_REGISTRY` promises strict
67
- // mode; until the enforcement branch (#17) lands, surface a warning when
68
- // an operator opts in but forgot to also set the registry URL — otherwise
69
- // they silently fall back to public npm and assume they're protected.
70
- if (parseRequireFlag(env)) {
71
- getLogger().warn("[npm-registry] SUPERBLOCKS_REQUIRE_PRIVATE_REGISTRY is set but " +
72
- "SUPERBLOCKS_NPM_REGISTRY is unset; falling back to default " +
73
- "registry-resolution. Strict-mode enforcement is deferred to a " +
74
- "follow-up; set SUPERBLOCKS_NPM_REGISTRY to take effect today.");
75
- }
76
- return;
264
+ export class NpmRegistryClient {
265
+ deps;
266
+ /**
267
+ * Last successful fetch + when it landed. Source of both the TTL check
268
+ * and the last-known-good fallback. We deliberately store the entries as
269
+ * received (not the rendered `NpmRegistryConfig`) so future readers can
270
+ * apply different render policies without a re-fetch.
271
+ *
272
+ * `npmAllowInstallScripts` rides alongside because the server returns it
273
+ * on the same envelope and AppShell wants the same TTL/fallback semantics
274
+ * for both. `undefined` here means the server omitted the field (LD flag
275
+ * off on the server side, or older server); we propagate that as
276
+ * `allowInstallScripts: undefined` rather than defaulting to `true` so
277
+ * the consumer can distinguish "policy known, scripts allowed" from
278
+ * "policy unknown".
279
+ */
280
+ cache;
281
+ /**
282
+ * Inflight `fetchWithRefresh()` promise, if any. Used to deduplicate
283
+ * concurrent `getConfig()` callers during a cache miss (or stale-after-
284
+ * TTL re-fetch): rather than firing N network requests for N callers
285
+ * we share the single in-flight request. Cleared in a `.finally()` so a
286
+ * resolved or rejected fetch never sticks around.
287
+ *
288
+ * Without this, a burst of installs that all hit cold cache would race
289
+ * to the server, the first one's 401 would clear the cache for itself
290
+ * before the others observe their own 401s — and the others would
291
+ * then have no last-known-good to fall back to.
292
+ */
293
+ inflight;
294
+ /**
295
+ * Last `npmAllowInstallScripts` value observed from a successful server
296
+ * response, retained across hard-fail cache invalidations (refreshJwt
297
+ * rejection, double-401, other 4xx). Registry credentials and the
298
+ * security policy live on the same envelope but have different
299
+ * security semantics: serving stale credentials to a revoked caller
300
+ * extends a deliberate denial, but dropping a known `false` policy
301
+ * silently weakens `--ignore-scripts` enforcement — strictly the
302
+ * wrong direction. So `this.cache` is cleared on hard-fail (revoke
303
+ * stale credentials), but this field survives so the next call's
304
+ * fall-through `serveStaleOrUnconfigured` still propagates the
305
+ * org's policy to AppShell.
306
+ */
307
+ lastKnownPolicy;
308
+ constructor(deps) {
309
+ this.deps = {
310
+ isFlagEnabled: deps.isFlagEnabled,
311
+ getJwt: deps.getJwt,
312
+ refreshJwt: deps.refreshJwt,
313
+ organizationId: deps.organizationId,
314
+ baseUrl: deps.baseUrl,
315
+ fetch: deps.fetch ?? globalThis.fetch,
316
+ ttlMs: deps.ttlMs ?? DEFAULT_TTL_MS,
317
+ now: deps.now ?? Date.now,
318
+ lastKnownPolicyStore: deps.lastKnownPolicyStore,
319
+ };
77
320
  }
78
- if (hasEmbeddedNewline(url)) {
79
- getLogger().warn("[npm-registry] SUPERBLOCKS_NPM_REGISTRY contains a newline; ignoring");
80
- return;
321
+ /**
322
+ * Clears the in-memory cache so the next `getConfig()` re-fetches from
323
+ * the server. Called by AppShell install retry when
324
+ * `registry_auth_failed` indicates a stale Artifactory token.
325
+ *
326
+ * `lastKnownPolicy` survives invalidation (same as the auth-failure
327
+ * branches in `fetchWithRefresh`) so `--ignore-scripts` enforcement
328
+ * is not weakened during the re-fetch.
329
+ */
330
+ invalidateCache() {
331
+ this.cache = undefined;
81
332
  }
82
- // Validate the URL upfront so we never emit a half-broken .npmrc (e.g.
83
- // `registry=` line with no matching `_authToken=`). Also rejects userinfo
84
- // (`user:pass@host`) tokens belong on a `_authToken=` line, never in the
85
- // URL itself; URL-embedded credentials end up in HTTP request logs.
86
- if (!toNpmAuthOrigin(url)) {
87
- getLogger().warn("[npm-registry] SUPERBLOCKS_NPM_REGISTRY is not a valid registry URL " +
88
- "(must be parseable and must not contain user:pass@ userinfo); ignoring", { url: redactRegistryForLog(url) });
89
- return;
333
+ /**
334
+ * Persist the "ever configured" marker, AWAITED so the write is durable
335
+ * before the caller hands back a `configured` / `stale` resolution. Used
336
+ * on every such resolution so a subsequent server outage can fail closed
337
+ * instead of silently resolving through public npm.
338
+ *
339
+ * Awaited (not fire-and-forget) because the marker must be on disk before
340
+ * `getConfig()` returns: a crash in the gap between returning `configured`
341
+ * and an un-awaited write landing would lose the marker and silently break
342
+ * the "once configured, always fail-closed on unreachable" invariant. The
343
+ * write stays async (`fs/promises`, per the ai-service no-sync-fs rule);
344
+ * the disk store guards itself so the write happens at most once per
345
+ * process (see `createDiskRegistryPolicyStore`), keeping the awaited write
346
+ * off the hot path after the first configured resolution.
347
+ *
348
+ * Logs a warning on write failure but never propagates the error: a broken
349
+ * store is a strict superset of today's no-marker behaviour, and the
350
+ * install path must stay healthy when disk is full or the marker directory
351
+ * is unwritable.
352
+ */
353
+ async markPolicyConfigured() {
354
+ const store = this.deps.lastKnownPolicyStore;
355
+ if (!store) {
356
+ return;
357
+ }
358
+ try {
359
+ await store.markConfigured();
360
+ }
361
+ catch (err) {
362
+ getLogger().warn("[npm-registry] lastKnownPolicyStore.markConfigured() failed; ignoring", {
363
+ orgId: this.deps.organizationId,
364
+ error: err instanceof Error ? err.message : String(err),
365
+ });
366
+ }
90
367
  }
91
- let token = env.SUPERBLOCKS_NPM_REGISTRY_TOKEN?.trim() || undefined;
92
- if (token && hasEmbeddedNewline(token)) {
93
- getLogger().warn("[npm-registry] SUPERBLOCKS_NPM_REGISTRY_TOKEN contains a newline; ignoring token");
94
- token = undefined;
368
+ /**
369
+ * Read the disk-backed marker that AppShell uses to decide whether to
370
+ * fail closed on `unreachable`. Treats a read failure as `false` so a
371
+ * transient store IO error preserves current behaviour (proceed) rather
372
+ * than degrading to a fail-closed install.
373
+ */
374
+ async readEverConfigured() {
375
+ const store = this.deps.lastKnownPolicyStore;
376
+ if (!store) {
377
+ return undefined;
378
+ }
379
+ try {
380
+ return await store.hasEverBeenConfigured();
381
+ }
382
+ catch (err) {
383
+ getLogger().warn("[npm-registry] lastKnownPolicyStore.hasEverBeenConfigured() failed; treating as false", {
384
+ orgId: this.deps.organizationId,
385
+ error: err instanceof Error ? err.message : String(err),
386
+ });
387
+ return false;
388
+ }
95
389
  }
96
- const require = parseRequireFlag(env);
97
- let scopes;
98
- const scopesJson = env.SUPERBLOCKS_NPM_REGISTRY_SCOPES_JSON?.trim();
99
- if (scopesJson) {
390
+ /**
391
+ * Resolve the current npm registry configuration for the active org. See
392
+ * the class docstring for the full state machine.
393
+ */
394
+ async getConfig() {
395
+ if (!this.deps.isFlagEnabled()) {
396
+ // Short-circuit BEFORE inspecting the cache: a flag flip to "off"
397
+ // must not keep serving from a previously-fetched config. The
398
+ // ticket explicitly calls this out ("Flag off → return
399
+ // {configured: false} immediately, no server call").
400
+ return NOT_CONFIGURED;
401
+ }
402
+ const now = this.deps.now();
403
+ if (this.cache && now - this.cache.fetchedAt < this.deps.ttlMs) {
404
+ // A cached empty list (from a prior 404 or `registries: []` response)
405
+ // must surface as `not-configured` on every hit within the TTL, not
406
+ // `configured` with `config.configured: false`. The discriminator's
407
+ // "configured" branch is documented as "at least one registry row
408
+ // exists" — preserving that invariant matters for callers branching
409
+ // on `source` for observability. We still surface any cached policy
410
+ // alongside the not-configured result so AppShell can honor
411
+ // `--ignore-scripts` even when there are no registry rows.
412
+ if (this.cache.entries.length === 0) {
413
+ return notConfigured(this.cache.npmAllowInstallScripts);
414
+ }
415
+ await this.markPolicyConfigured();
416
+ return {
417
+ source: "configured",
418
+ config: registriesToConfig(this.cache.entries, this.cache.npmAllowInstallScripts),
419
+ };
420
+ }
421
+ // Deduplicate concurrent cache-miss callers onto a single network
422
+ // request so we don't multiply 401-refresh storms or lose last-known-
423
+ // good fallback for racing callers.
424
+ if (this.inflight) {
425
+ return this.inflight;
426
+ }
427
+ const pending = this.fetchWithRefresh().finally(() => {
428
+ // Always clear inflight on resolve OR reject so a transient error
429
+ // doesn't pin the slot and starve subsequent callers.
430
+ if (this.inflight === pending) {
431
+ this.inflight = undefined;
432
+ }
433
+ });
434
+ this.inflight = pending;
435
+ return pending;
436
+ }
437
+ async fetchWithRefresh() {
438
+ let response;
100
439
  try {
101
- const parsed = JSON.parse(scopesJson);
102
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
103
- scopes = {};
104
- for (const [scope, scopeUrl] of Object.entries(parsed)) {
105
- if (typeof scopeUrl !== "string") {
106
- continue;
107
- }
108
- // Validate both the scope key and the scope URL value for embedded
109
- // newlines a key containing `\n` (valid in JSON strings) would
110
- // inject an extra line into the rendered `.npmrc` (e.g.
111
- // `"@x\n_authToken=stolen"`). Mirror the existing URL-value check.
112
- if (hasEmbeddedNewline(scope)) {
113
- getLogger().warn("[npm-registry] SUPERBLOCKS_NPM_REGISTRY_SCOPES_JSON scope key contains a newline; ignoring entry");
114
- continue;
115
- }
116
- const trimmed = scopeUrl.trim();
117
- if (trimmed === "" || hasEmbeddedNewline(trimmed)) {
118
- continue;
119
- }
120
- scopes[scope] = trimmed;
440
+ response = await this.attempt();
441
+ }
442
+ catch (err) {
443
+ return this.serveStaleOrUnconfigured(err);
444
+ }
445
+ if (response.status === 401) {
446
+ // Do NOT clear `this.cache` here: if the retry fails,
447
+ // `serveStaleOrUnconfigured` still needs the last-known-good
448
+ // entries to fall back to. Concurrent callers are serialised on
449
+ // `this.inflight`, so they won't observe a stale value during the
450
+ // retry window.
451
+ //
452
+ // Await `refreshJwt` (if wired) so the retry has a fair chance
453
+ // of seeing a freshly-rotated token. A refresh-channel error
454
+ // combined with a 401 from the registry endpoint is ambiguous —
455
+ // it can be a transient outage OR a terminal revocation
456
+ // (refresh token rejected, account disabled). We cannot tell
457
+ // from out here, and serving last-known-good in the revocation
458
+ // case would let a revoked caller continue installing from a
459
+ // private registry until TTL expiry. Treat refresh failure as a
460
+ // hard fail: clear the cache and surface the error, mirroring
461
+ // the deliberate-denial 4xx branch below.
462
+ if (this.deps.refreshJwt) {
463
+ try {
464
+ await this.deps.refreshJwt();
121
465
  }
122
- if (Object.keys(scopes).length === 0) {
123
- scopes = undefined;
466
+ catch (err) {
467
+ this.cache = undefined;
468
+ const refreshFailError = new Error(`[npm-registry] refreshJwt failed after HTTP 401 from the npm registry endpoint: ${err instanceof Error ? err.message : String(err)}`);
469
+ getLogger().error(`[npm-registry] hard-fail: refreshJwt rejected after HTTP 401 (org ${this.deps.organizationId}); cache cleared`, {
470
+ error: {
471
+ kind: "NpmRegistryRefreshFail",
472
+ message: refreshFailError.message,
473
+ },
474
+ });
475
+ throw refreshFailError;
124
476
  }
125
477
  }
478
+ try {
479
+ response = await this.attempt();
480
+ }
481
+ catch (err) {
482
+ return this.serveStaleOrUnconfigured(err);
483
+ }
484
+ if (response.status === 401) {
485
+ // A second 401 after a successful refreshJwt is stronger evidence
486
+ // of revocation than either the refreshJwt-rejection branch above
487
+ // or the deliberate-denial 4xx branch below: the refresh channel
488
+ // worked, but the freshly-issued JWT is still rejected. Clear the
489
+ // cache so a subsequent 5xx/network error cannot fall back through
490
+ // `serveStaleOrUnconfigured` and serve last-known-good config to
491
+ // a potentially-revoked user until TTL expiry. We surface this
492
+ // rather than swallowing it because "silently fall back to public
493
+ // npm" is precisely the failure mode this whole feature exists
494
+ // to prevent.
495
+ this.cache = undefined;
496
+ const doubleAuthFailError = new Error("[npm-registry] re-authenticated and still received HTTP 401 from the npm registry endpoint");
497
+ getLogger().error(`[npm-registry] hard-fail: HTTP 401 persisted after refreshJwt (org ${this.deps.organizationId}); cache cleared`, {
498
+ error: {
499
+ kind: "NpmRegistryDoubleAuthFail",
500
+ message: doubleAuthFailError.message,
501
+ },
502
+ });
503
+ throw doubleAuthFailError;
504
+ }
126
505
  }
127
- catch (error) {
128
- getLogger().warn("[npm-registry] Failed to parse SUPERBLOCKS_NPM_REGISTRY_SCOPES_JSON; ignoring", { error: error instanceof Error ? error.message : String(error) });
506
+ if (response.status === 404) {
507
+ // The route exists but the org has no registry rows (or the
508
+ // sibling endpoint returned 404 for a transient reason — deploy
509
+ // window, route mis-mount, brief org-deactivation race). Treat
510
+ // the same as an empty list response — `{ configured: false }`.
511
+ // We still cache "empty" so we don't poll the 404 path on every
512
+ // install.
513
+ //
514
+ // We preserve any previously-observed `npmAllowInstallScripts`
515
+ // policy rather than overwriting it with `undefined`: a transient
516
+ // 404 against a freshly-deployed server (or an envoy mis-route)
517
+ // would otherwise silently disable an org's `--ignore-scripts`
518
+ // enforcement for the rest of the TTL window. The 404 payload
519
+ // doesn't carry a fresh policy, but a previously-observed policy
520
+ // is the best estimate we have until the next successful fetch.
521
+ //
522
+ // Fall back to `lastKnownPolicy` when `this.cache` itself was
523
+ // hard-cleared by an earlier auth-failure branch — the policy
524
+ // survives independently of the registry credentials it rode in
525
+ // with, and reading from cache alone would clobber it back to
526
+ // `undefined` here.
527
+ const preservedPolicy = this.cache?.npmAllowInstallScripts ?? this.lastKnownPolicy;
528
+ this.cache = {
529
+ entries: [],
530
+ npmAllowInstallScripts: preservedPolicy,
531
+ fetchedAt: this.deps.now(),
532
+ };
533
+ this.lastKnownPolicy = preservedPolicy;
534
+ return notConfigured(preservedPolicy);
535
+ }
536
+ if (response.status >= 400 &&
537
+ response.status < 500 &&
538
+ response.status !== 401 &&
539
+ response.status !== 404) {
540
+ // Other 4xx ("the request is bad" or "you specifically can't do
541
+ // this") are deliberate denials, not transient outages. Examples:
542
+ // - 403 after org RBAC permission revocation
543
+ // - 400 from a malformed Authorization header
544
+ // Serving stale here would let a revoked-access install run with
545
+ // a cached registry token until TTL expiry — exactly the staleness
546
+ // window we want to avoid. Clear the cache and surface the error
547
+ // so the caller decides; the inflight slot is cleared by the
548
+ // outer `.finally()` so a subsequent call can re-attempt.
549
+ this.cache = undefined;
550
+ const hardFailError = new Error(`[npm-registry] server returned HTTP ${response.status} from the npm registry endpoint`);
551
+ getLogger().error(`[npm-registry] hard-fail HTTP ${response.status} from npm registry endpoint (org ${this.deps.organizationId}); cache cleared`, {
552
+ error: {
553
+ kind: "NpmRegistryHardFail",
554
+ message: hardFailError.message,
555
+ },
556
+ });
557
+ throw hardFailError;
558
+ }
559
+ if (!response.ok) {
560
+ // 5xx is treated as a transient outage. Last-known-good if we
561
+ // have one, otherwise `not-configured`.
562
+ return this.serveStaleOrUnconfigured(new Error(`[npm-registry] server returned HTTP ${response.status} from the npm registry endpoint`));
563
+ }
564
+ // Read the body AND unwrap the standard Superblocks `ResponseDto`
565
+ // envelope (`{ data, responseMeta }`) the server wraps every JSON
566
+ // response in. The UI client uses `callServer` (which delegates to
567
+ // `unwrapResponseDto` in `@superblocksteam/shared`) for the same shape;
568
+ // here we keep raw `fetch` (preserves the DI surface the test harness
569
+ // mocks) and unwrap inline so the registry-payload schema below mirrors
570
+ // the inner shape, not the wire envelope. JSON-parse failures and
571
+ // envelope-shape failures both route to `serveStale…` — single try.
572
+ let payload;
573
+ try {
574
+ const body = await response.json();
575
+ const envelopeParse = envelopeSchema.safeParse(body);
576
+ if (!envelopeParse.success) {
577
+ const envelopeError = new Error(`[npm-registry] server returned a payload that did not match the expected ResponseDto envelope (org ${this.deps.organizationId}): ${JSON.stringify(envelopeParse.error.issues)}`);
578
+ getLogger().error(envelopeError.message, {
579
+ error: {
580
+ kind: "NpmRegistryEnvelopeParseFailed",
581
+ message: envelopeError.message,
582
+ },
583
+ });
584
+ throw envelopeError;
585
+ }
586
+ payload = envelopeParse.data.data;
587
+ }
588
+ catch (err) {
589
+ return this.serveStaleOrUnconfigured(err);
590
+ }
591
+ const parsed = registryListSchema.safeParse(payload);
592
+ if (!parsed.success) {
593
+ // Log schema issues at error level (distinct from the generic
594
+ // serveStale warn) so a malformed payload is visible in the absence
595
+ // of a network outage — most likely a server-side validation bug
596
+ // or a hostile push. The Zod issue list is folded into the message
597
+ // because the `error` logger only accepts `ErrorMeta` shape.
598
+ const schemaError = new Error(`[npm-registry] server returned a payload that did not match the expected { registries: [...] } shape (org ${this.deps.organizationId}): ${JSON.stringify(parsed.error.issues)}`);
599
+ getLogger().error(schemaError.message, {
600
+ error: {
601
+ kind: "NpmRegistrySchemaParseFailed",
602
+ message: schemaError.message,
603
+ },
604
+ });
605
+ return this.serveStaleOrUnconfigured(schemaError);
606
+ }
607
+ const entries = parsed.data.registries;
608
+ const npmAllowInstallScripts = parsed.data.npmAllowInstallScripts;
609
+ this.cache = {
610
+ entries,
611
+ npmAllowInstallScripts,
612
+ fetchedAt: this.deps.now(),
613
+ };
614
+ this.lastKnownPolicy = npmAllowInstallScripts;
615
+ if (entries.length === 0) {
616
+ return notConfigured(npmAllowInstallScripts);
617
+ }
618
+ await this.markPolicyConfigured();
619
+ return {
620
+ source: "configured",
621
+ config: registriesToConfig(entries, npmAllowInstallScripts),
622
+ };
623
+ }
624
+ /**
625
+ * One full fetch attempt with the current JWT. Throws on getJwt failure
626
+ * OR network failure so the caller's single try/catch handles both.
627
+ */
628
+ async attempt() {
629
+ const jwt = await this.deps.getJwt();
630
+ const base = this.deps.baseUrl.replace(/\/$/, "");
631
+ const url = `${base}/api/v1/organizations/${encodeURIComponent(this.deps.organizationId)}/npm-registry`;
632
+ return await this.deps.fetch(url, {
633
+ method: "GET",
634
+ headers: {
635
+ accept: "application/json",
636
+ authorization: `Bearer ${jwt}`,
637
+ },
638
+ });
639
+ }
640
+ async serveStaleOrUnconfigured(error) {
641
+ if (this.cache && this.cache.entries.length > 0) {
642
+ const ageMs = this.deps.now() - this.cache.fetchedAt;
643
+ getLogger().warn("[npm-registry] server unreachable; serving last-known-good config", {
644
+ ageMs,
645
+ error: error instanceof Error ? error.message : String(error),
646
+ });
647
+ // APPS-4370: stale serves a known-configured org, so refresh the
648
+ // disk marker too. A long outage that spans a pod recycle would
649
+ // otherwise lose the signal: the next cold-boot would see `unreachable`
650
+ // with no marker and proceed instead of failing closed.
651
+ await this.markPolicyConfigured();
652
+ return {
653
+ source: "stale",
654
+ config: registriesToConfig(this.cache.entries, this.cache.npmAllowInstallScripts),
655
+ };
129
656
  }
657
+ // No usable cache: either nothing was ever fetched successfully (cold
658
+ // start) or the only successful fetch returned an empty list. Both
659
+ // mean we don't actually know the org's current registry state, so
660
+ // we surface `unreachable` rather than `not-configured`. Consumers
661
+ // with destructive cleanup paths (e.g. the home-npmrc writer
662
+ // unlinking a previously-written `~/.npmrc`) need that distinction to
663
+ // avoid acting on a transient outage; read-only consumers branch on
664
+ // `config.configured` and continue to no-op safely either way.
665
+ //
666
+ // A cached `npmAllowInstallScripts` policy IS useful and survives the
667
+ // outage: AppShell still honors a known `--ignore-scripts` policy
668
+ // when the server is down. Falls back to `lastKnownPolicy` when
669
+ // `this.cache` itself was hard-cleared by an auth-failure branch
670
+ // above — the policy survives independently of the registry
671
+ // credentials it rode in with.
672
+ getLogger().warn("[npm-registry] server unreachable and no usable cached config; treating as unreachable", { error: error instanceof Error ? error.message : String(error) });
673
+ const base = unreachable(this.cache?.npmAllowInstallScripts ?? this.lastKnownPolicy);
674
+ // APPS-4370: consult the disk-backed marker so AppShell can decide
675
+ // whether to fail closed. Only attach the field when a store is
676
+ // wired in — `undefined` is the documented "no opinion" signal.
677
+ const everConfigured = await this.readEverConfigured();
678
+ if (everConfigured === undefined) {
679
+ return base;
680
+ }
681
+ // `base` may be the frozen UNREACHABLE singleton; build a fresh
682
+ // object so adding `everConfigured` doesn't try to mutate a frozen
683
+ // value (TypeError in strict mode).
684
+ return Object.freeze({
685
+ source: "unreachable",
686
+ config: base.config,
687
+ everConfigured,
688
+ });
130
689
  }
131
- return { url, token, scopes, require };
132
690
  }
133
691
  /**
134
692
  * Extracts the host portion of a registry URL for use in npm auth lines, e.g.
@@ -165,28 +723,68 @@ function normalizeRegistryUrl(registryUrl) {
165
723
  /**
166
724
  * Renders the npm registry config to an `.npmrc` file at `targetPath`.
167
725
  *
726
+ * Uses `ini.stringify` (the same library `@npmcli/config` uses to read and
727
+ * write `.npmrc`) so the file is guaranteed to round-trip through
728
+ * `ini.parse`. This handles value-quoting for `=`, `\r`, `\n`, leading `[`,
729
+ * leading/trailing whitespace, and escapes `;` / `#` so they're not treated
730
+ * as comments.
731
+ *
732
+ * Validation NOT covered by `ini`:
733
+ * - `noControlChars` Zod refinement: `ini.safe` JSON-quotes `\r`/`\n`
734
+ * rather than rejecting them, and accepts other C0 control bytes
735
+ * (`\x00..\x1f`) unchanged — so we reject the entire C0 range at the
736
+ * wire-schema layer instead.
737
+ * - Scope grammar (`@scope` must match npm's regex): `@npmcli/config`
738
+ * does not validate scope names.
739
+ * - https-only registry URLs: npm itself accepts http.
740
+ *
168
741
  * When `preserveScopeLines` is provided, any existing `<scope>:registry=`
169
- * lines for those scopes are preserved verbatim (so EE-baked
742
+ * keys for those scopes are preserved (so EE-baked
170
743
  * `@superblocksteam:registry=https://npm.pkg.github.com/` survives) UNLESS
171
- * the same scope is explicitly overridden in `config.scopes`.
744
+ * the same scope is explicitly overridden in `config.scopes`. The
745
+ * preserved value is read via `ini.parse` from the existing file, so
746
+ * `ini`'s own quoting rules apply (rather than our older line-grep that
747
+ * could mis-handle quoted values).
172
748
  *
173
749
  * Idempotent: rewrites the file in full each call. Writes are atomic (tmp +
174
750
  * rename) and the file mode is restricted to 0600 because the file may carry
175
751
  * an auth token.
752
+ *
753
+ * Requires `config.configured === true` OR `config.allowInstallScripts === false`
754
+ * (so the writer can emit a policy-only `.npmrc` with just `ignore-scripts=true`
755
+ * even when no registry rows are configured). A scoped-only config (no `default`
756
+ * entry) is valid — only scope `:registry=` lines are emitted, and unscoped
757
+ * packages fall through to public npm. This matches the server contract
758
+ * (the `(org, scope)` unique constraint allows scoped-only rows) and is
759
+ * the right shape for customers who only want to override one `@scope`
760
+ * without redirecting the rest of npm.
176
761
  */
177
762
  export async function writeNpmrc(targetPath, config, options = {}) {
763
+ const needsIgnoreScripts = config.allowInstallScripts === false;
764
+ if (!config.configured && !needsIgnoreScripts) {
765
+ throw new Error("[npm-registry] writeNpmrc requires a configured config");
766
+ }
767
+ const hasDefault = !!config.default;
768
+ const hasScopes = !!config.scopes && Object.keys(config.scopes).length > 0;
769
+ if (!hasDefault && !hasScopes && !needsIgnoreScripts) {
770
+ throw new Error("[npm-registry] writeNpmrc requires at least one registry entry (default or scoped)");
771
+ }
772
+ // Flat key -> value map. `.npmrc` uses no `ini` sections at the registry
773
+ // layer (all keys are top-level), so `ini.stringify` on a flat object
774
+ // produces the exact wire format npm expects.
775
+ const obj = {};
178
776
  const preserveScopes = options.preserveScopeLines ?? [];
179
- // Map of scope -> verbatim line, so we both dedup correctly and preserve the
180
- // exact bytes (whitespace, trailing comments) the operator wrote.
181
- const preservedScopeLines = new Map();
182
777
  if (preserveScopes.length > 0) {
183
- let existing = "";
778
+ let existing;
184
779
  try {
185
780
  existing = await readFile(targetPath, "utf-8");
186
781
  }
187
782
  catch (error) {
188
- // ENOENT is the expected case (no pre-existing .npmrc). EACCES / EIO /
189
- // EBUSY are not surface them so we don't silently drop a load-bearing
783
+ // ENOENT is the expected case on first write every other code
784
+ // (EACCES, EISDIR, EIO) we surface as a warning rather than throw
785
+ // because preserve-scope is a best-effort fallback and we'd rather
786
+ // emit a fresh `.npmrc` than fail the whole install path. The
787
+ // tradeoff: a transient read error could silently lose a baked
190
788
  // `@superblocksteam:registry=` line (the exact failure class APPS-2053
191
789
  // was meant to prevent).
192
790
  const code = error?.code;
@@ -199,80 +797,1121 @@ export async function writeNpmrc(targetPath, config, options = {}) {
199
797
  }
200
798
  }
201
799
  if (existing) {
202
- const lines = existing.split(/\r?\n/);
800
+ // `ini.parse` skips lines it cannot decode rather than throwing
801
+ // (CRLF artifacts, partial writes, hand-edits with stray bytes —
802
+ // see ini@6 `decode`'s regex-or-continue loop). Guard with try/catch
803
+ // for future versions that may throw, and treat any unrecognised
804
+ // result as "no preserved value" — but warn so an operator can repair
805
+ // a malformed `.npmrc` instead of silently shipping without the
806
+ // EE-baked `@superblocksteam:registry=` line.
807
+ let parsedExisting = {};
808
+ try {
809
+ parsedExisting = ini.parse(existing);
810
+ }
811
+ catch (err) {
812
+ getLogger().warn("[npm-registry] failed to parse existing .npmrc for scope preservation; proceeding without preserve", {
813
+ path: targetPath,
814
+ error: err instanceof Error ? err.message : String(err),
815
+ });
816
+ }
203
817
  for (const scope of preserveScopes) {
204
- // Skip preservation if config explicitly overrides this scope.
818
+ // Skip preservation if config explicitly overrides this scope
819
+ // the override will be written below and we don't want to emit
820
+ // two `<scope>:registry=` lines (or have the preserved value
821
+ // shadow the explicit one). The override-wins symmetry also
822
+ // applies to the auth-family lines for the old origin: dropping
823
+ // the registry line while keeping a `//old-origin/:_authToken=`
824
+ // around would leave a dangling secret pointed at no registry
825
+ // entry in the rendered file.
205
826
  if (config.scopes && config.scopes[scope]) {
206
827
  continue;
207
828
  }
208
- const prefix = `${scope}:registry=`;
209
- for (const line of lines) {
210
- const trimmed = line.trim();
211
- if (trimmed.startsWith(prefix)) {
212
- preservedScopeLines.set(scope, trimmed);
213
- break;
829
+ const key = `${scope}:registry`;
830
+ const value = parsedExisting[key];
831
+ if (typeof value === "string") {
832
+ obj[key] = value;
833
+ // Also preserve every auth-family key keyed on the same
834
+ // `//<origin>/` prefix as the preserved registry URL. Without
835
+ // this, EE-baked `.npmrc` files that ship both
836
+ // `@superblocksteam:registry=https://npm.pkg.github.com/` AND
837
+ // `//npm.pkg.github.com/:_authToken=<ghpr-token>` lose the
838
+ // token on the first `writeNpmrc` call, and every
839
+ // `@superblocksteam/*` install 401s against GHPR (APPS-4300).
840
+ //
841
+ // npm/pnpm support multiple auth shapes against the same
842
+ // origin: bearer (`_authToken`), Basic (`_password` +
843
+ // `username` + `email`), and the legacy base64 (`_auth`).
844
+ // We preserve all of them so a future EE image change that
845
+ // swaps from bearer to Basic doesn't silently regress to the
846
+ // same failure mode.
847
+ //
848
+ // `always-auth` is deliberately NOT preserved: npm removed it
849
+ // as a recognized key in npm 7+, and npm 11 (the dev-server's
850
+ // pinned version) emits `npm warn Unknown user config
851
+ // "always-auth" … This will stop working in the next major
852
+ // version of npm` on every invocation that reads it — auth
853
+ // still attaches from the sibling `_authToken`, so carrying it
854
+ // forward is a no-op that only generates stderr noise today and
855
+ // risks a hard error once we move to npm 12. Dropping it from
856
+ // the preserve set means a customer `.npmrc` that happens to
857
+ // carry `//origin/:always-auth` simply loses a dead key (APPS-4430).
858
+ //
859
+ // Each `parsedExisting[copyKey]` returns the same `unknown`
860
+ // shape as the registry line above; only string values are
861
+ // safe to round-trip back through `ini.stringify`. Non-string
862
+ // values follow the same drop+warn handling.
863
+ const preservedAuthOrigin = toNpmAuthOrigin(value);
864
+ if (preservedAuthOrigin) {
865
+ const authFamilyKeys = [
866
+ "_authToken",
867
+ "_password",
868
+ "username",
869
+ "email",
870
+ "_auth",
871
+ ];
872
+ for (const authKey of authFamilyKeys) {
873
+ const copyKey = `${preservedAuthOrigin}:${authKey}`;
874
+ const authValue = parsedExisting[copyKey];
875
+ // `ini.parse` coerces a bare `true`/`false`/numeric value to
876
+ // its JS primitive equivalent. The auth-family keys above are
877
+ // all string-valued, but coerce back to a string defensively
878
+ // so `ini.stringify` emits a valid line (it accepts strings,
879
+ // booleans, and numbers, but our internal `obj` map is typed
880
+ // `Record<string, string>` for the registry/auth lines
881
+ // elsewhere in this writer, so the cast is contained).
882
+ if (typeof authValue === "string" ||
883
+ typeof authValue === "boolean" ||
884
+ typeof authValue === "number") {
885
+ obj[copyKey] = String(authValue);
886
+ }
887
+ else if (authValue !== undefined) {
888
+ getLogger().warn("[npm-registry] preserve-scope auth-family key parsed to unsupported type; dropping", {
889
+ path: targetPath,
890
+ scope,
891
+ authKey,
892
+ valueType: typeof authValue,
893
+ });
894
+ }
895
+ }
214
896
  }
215
897
  }
898
+ else if (value !== undefined) {
899
+ // Key parsed but value is not a string — e.g. a bare key (`true`),
900
+ // a section header above it, or a quoted value whose `JSON.parse`
901
+ // failed inside `ini`. We're about to silently drop the preserve
902
+ // target, so surface so the operator can repair the file.
903
+ getLogger().warn("[npm-registry] preserve-scope key parsed to non-string; dropping", {
904
+ path: targetPath,
905
+ scope,
906
+ valueType: typeof value,
907
+ });
908
+ }
909
+ // value === undefined is the normal case when the existing .npmrc
910
+ // simply doesn't list this scope — silent on that.
216
911
  }
217
912
  }
218
913
  }
219
- const normalizedUrl = normalizeRegistryUrl(config.url);
220
- const lines = [];
221
- lines.push(`registry=${normalizedUrl}`);
222
- const authOrigin = toNpmAuthOrigin(normalizedUrl);
223
- if (config.token && authOrigin) {
224
- lines.push(`${authOrigin}:_authToken=${config.token}`);
914
+ if (config.default) {
915
+ const normalizedDefaultUrl = normalizeRegistryUrl(config.default.url);
916
+ obj["registry"] = normalizedDefaultUrl;
917
+ const defaultAuthOrigin = toNpmAuthOrigin(normalizedDefaultUrl);
918
+ if (config.default.token && defaultAuthOrigin) {
919
+ obj[`${defaultAuthOrigin}:_authToken`] = config.default.token;
920
+ }
225
921
  }
226
922
  if (config.scopes) {
227
- for (const [scope, scopeUrl] of Object.entries(config.scopes)) {
228
- lines.push(`${scope}:registry=${scopeUrl}`);
229
- // Explicit scope override wins; drop the preserved line for the same
230
- // scope so we don't emit two `<scope>:registry=` lines.
231
- preservedScopeLines.delete(scope);
923
+ for (const [scope, scopedReg] of Object.entries(config.scopes)) {
924
+ const normalizedScopeUrl = normalizeRegistryUrl(scopedReg.url);
925
+ obj[`${scope}:registry`] = normalizedScopeUrl;
926
+ const scopeAuthOrigin = toNpmAuthOrigin(normalizedScopeUrl);
927
+ if (scopedReg.token && scopeAuthOrigin) {
928
+ obj[`${scopeAuthOrigin}:_authToken`] = scopedReg.token;
929
+ }
232
930
  }
233
931
  }
234
- for (const line of preservedScopeLines.values()) {
235
- lines.push(line);
932
+ // Bake the org's ignore-scripts policy into the rendered `.npmrc` so any
933
+ // npm/pnpm invocation in this directory (or `$HOME`) honours the policy
934
+ // regardless of whether it was launched by AppShell. Covers tools like
935
+ // patch-package, husky, simple-git-hooks, and ad-hoc `bash npm install`.
936
+ if (needsIgnoreScripts) {
937
+ obj["ignore-scripts"] = "true";
938
+ }
939
+ // `ini.stringify` returns a trailing-newline-terminated string. Trim and
940
+ // re-add a single `\n` so the file is normalised regardless of the
941
+ // version of `ini`.
942
+ const body = `${ini.stringify(obj).trim()}\n`;
943
+ let content = body;
944
+ if (options.header !== undefined) {
945
+ if (/[\r\n]/.test(options.header)) {
946
+ throw new Error("[npm-registry] writeNpmrc options.header must not contain newlines");
947
+ }
948
+ content = `; ${options.header}\n${body}`;
236
949
  }
237
- const content = lines.join("\n") + "\n";
950
+ const mode = options.mode ?? 0o600;
238
951
  await mkdir(path.dirname(targetPath), { recursive: true });
239
952
  // Atomic write: render to a sibling tmp file with restrictive perms, then
240
953
  // rename. Avoids leaving a partial/empty .npmrc on crash mid-write, and
241
954
  // narrows the window where the token might be readable at the default umask.
955
+ // The atomic rename replaces the target file's inode regardless of the
956
+ // target's current mode (rename is governed by parent-dir perms), so callers
957
+ // that pass a write-stripped `mode` (e.g. 0o400) can still rewrite the file
958
+ // on subsequent syncs.
959
+ //
960
+ // We write the tmp file at 0o600 (owner-writable) first so any internal
961
+ // mode-restricting steps work, then chmod to the requested `mode` right
962
+ // before the rename so the final file lands with the requested bits set
963
+ // atomically with the contents.
242
964
  const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
243
- await writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
244
- // `mode` only applies on creation; explicit chmod handles the case where the
245
- // tmp path was preexisting (e.g. crash recovery).
246
- await chmod(tmpPath, 0o600);
247
- await rename(tmpPath, targetPath);
965
+ try {
966
+ await writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
967
+ // `mode` only applies on creation; explicit chmod handles the case where the
968
+ // tmp path was preexisting (e.g. crash recovery).
969
+ await chmod(tmpPath, mode);
970
+ await rename(tmpPath, targetPath);
971
+ }
972
+ catch (error) {
973
+ // Clean up the tmp file on any failure of write/chmod/rename so we
974
+ // don't leave `.npmrc.tmp-<pid>-<ts>` siblings accumulating across
975
+ // boots (especially in `~/` for the home writer). Best-effort: if the
976
+ // tmp file was never created (writeFile threw) or was already
977
+ // consumed by the rename, the unlink is a no-op we swallow.
978
+ await unlink(tmpPath).catch(() => undefined);
979
+ throw error;
980
+ }
981
+ }
982
+ /**
983
+ * Filename of the on-disk backup of the baked-in `.npmrc` that ships with the
984
+ * dev-server image. Captured once per pod boot by `maybeWriteNpmrcForDir`
985
+ * before the first `writeNpmrc` rewrite, then used as the restore source when
986
+ * the org transitions back to `not-configured` (e.g. the last
987
+ * `org_npm_registry` row was deleted). Lives sibling to `.npmrc` in the same
988
+ * directory writes target.
989
+ */
990
+ export const NPMRC_DEFAULT_FILENAME = ".npmrc.default";
991
+ /**
992
+ * Snapshot `targetPath` to `backupPath` exactly once if the backup does
993
+ * not already exist. Lets a subsequent `writeNpmrc`-style rewrite of
994
+ * `targetPath` happen non-destructively: a restore path has a baked-in
995
+ * source to copy back when the org transitions to `not-configured`.
996
+ *
997
+ * Uses `link(2)` rather than read + write so the snapshot is concurrency-
998
+ * safe. Two properties matter:
999
+ *
1000
+ * - The kernel serializes hardlink creation: only one of N concurrent
1001
+ * callers wins; the rest see EEXIST. No read-then-write window where
1002
+ * a slow caller could observe a `writeNpmrc`-rewritten target and
1003
+ * poison the backup with the private registry content.
1004
+ * - `writeNpmrc` rewrites the target via tmp + rename, which gives it
1005
+ * a fresh inode. The hardlink at `backupPath` keeps the original
1006
+ * inode (with the baked-in content) alive — that's the whole point.
1007
+ *
1008
+ * Silently no-ops in two cases:
1009
+ * - `backupPath` is already present (subsequent boots, or a second
1010
+ * call in the same boot after the first capture landed). Important so
1011
+ * a later `writeNpmrc`-rewritten target is never mistaken for the
1012
+ * baked-in baseline.
1013
+ * - `targetPath` does not exist (image without a baked-in `.npmrc`,
1014
+ * or a brownfield app dir that never had one). The natural "restore"
1015
+ * state is "no file", which the restore path handles by treating an
1016
+ * absent backup as "no file" too — for that case `restoreInitialNpmrc`
1017
+ * unlinks the target if asked, otherwise leaves it alone.
1018
+ *
1019
+ * Other errors degrade to a warning + no-op rather than throwing — the
1020
+ * capture is a best-effort safety net for the restore path, not a
1021
+ * precondition for the install path itself.
1022
+ *
1023
+ * Returns a `SnapshotInitialNpmrcOutcome` so a caller can tell whether a
1024
+ * usable backup now exists. This matters for the destructive restore path:
1025
+ * a silent `failed` (e.g. EXDEV) leaves no backup behind, so a later
1026
+ * `not-configured` transition must NOT mistake the absent backup for
1027
+ * "nothing to preserve" and unlink a real baked-in userconfig (APPS-4428).
1028
+ * Still never throws — the outcome is advisory, not an error channel.
1029
+ *
1030
+ * Shared with the home-npmrc writer (CLI startup, target =
1031
+ * `~/.superblocks/npmrc`) so both project-dir and home flows agree on the
1032
+ * snapshot/restore semantics.
1033
+ */
1034
+ export async function snapshotInitialNpmrc(targetPath, backupPath) {
1035
+ try {
1036
+ await link(targetPath, backupPath);
1037
+ return "created";
1038
+ }
1039
+ catch (error) {
1040
+ const code = error?.code;
1041
+ if (code === "EEXIST") {
1042
+ // Snapshot already taken (prior boot or concurrent caller): a usable
1043
+ // backup exists even though this call didn't write it.
1044
+ return "exists";
1045
+ }
1046
+ if (code === "ENOENT") {
1047
+ // No baked-in target to snapshot; the restore baseline is "no file".
1048
+ return "no-source";
1049
+ }
1050
+ getLogger().warn("[npm-registry] failed to capture .npmrc backup; restore path will be unavailable until next pod boot", {
1051
+ target: targetPath,
1052
+ backup: backupPath,
1053
+ code,
1054
+ error: error instanceof Error ? error.message : String(error),
1055
+ });
1056
+ return "failed";
1057
+ }
248
1058
  }
249
1059
  /**
250
- * Convenience: invoke `writeNpmrc` against `<dir>/.npmrc` if registry config is
251
- * present in the env. No-op when unset (preserves today's behavior for SaaS /
252
- * local dev).
1060
+ * Restore `targetPath` from `backupPath` when the org has transitioned
1061
+ * back to `not-configured` (e.g. last `org_npm_registry` row deleted).
1062
+ * Without this, a file previously rewritten by `writeNpmrc` stays in
1063
+ * place pointing at a registry the customer just removed — the exact
1064
+ * case APPS-4320 closes for project `.npmrc`, and APPS-4328 closes for
1065
+ * the home-level userconfig.
1066
+ *
1067
+ * Best-effort, never throws. The project-dir caller
1068
+ * (`maybeWriteNpmrcForDir` → `prepareForPrivateRegistry`) needs to fall
1069
+ * through to `stripResolvedFromLockfile` even when the restore can't
1070
+ * run; the home-level caller (`syncHomeNpmrc`) needs to finish dev-server
1071
+ * startup. Any error here degrades to a warn + no-op so the surrounding
1072
+ * install path stays alive.
1073
+ *
1074
+ * Skips the rewrite (without a warning) in three harmless cases:
1075
+ * - `targetPath` does not exist. The baked-in baseline for this dir is
1076
+ * "no file"; there is nothing to restore to.
1077
+ * - `backupPath` is missing AND `options.unlinkTargetWhenBackupMissing`
1078
+ * is not set. Expected on pods that never had `org_npm_registry`
1079
+ * configured (snapshot is gated on the configured branch), AND on
1080
+ * older pods whose target was rewritten before this commit landed.
1081
+ * In both cases the running file is what the pod has lived with —
1082
+ * leave it alone.
1083
+ * - `targetPath` already byte-matches `backupPath`. Avoids a no-op
1084
+ * rewrite that would bump mtime for downstream watchers (e.g. Tilt /
1085
+ * Vite file-sync).
253
1086
  *
254
- * Returns the loaded config when an `.npmrc` was written, otherwise `undefined`.
1087
+ * When `options.unlinkTargetWhenBackupMissing` is true and the backup is
1088
+ * absent but the target exists, the target is unlinked — see the option
1089
+ * comment.
255
1090
  */
256
- export async function maybeWriteNpmrcForDir(dir, options = {}, env = process.env) {
257
- const config = getNpmRegistryConfig(env);
258
- if (!config) {
1091
+ export async function restoreInitialNpmrc(targetPath, backupPath, options = {}) {
1092
+ let current;
1093
+ try {
1094
+ current = await readFile(targetPath, "utf-8");
1095
+ }
1096
+ catch (error) {
1097
+ const code = error?.code;
1098
+ if (code === "ENOENT") {
1099
+ return;
1100
+ }
1101
+ getLogger().warn("[npm-registry] failed to read target for restore comparison; skipping", {
1102
+ path: targetPath,
1103
+ code,
1104
+ error: error instanceof Error ? error.message : String(error),
1105
+ });
1106
+ return;
1107
+ }
1108
+ let baked;
1109
+ try {
1110
+ baked = await readFile(backupPath, "utf-8");
1111
+ }
1112
+ catch (error) {
1113
+ const code = error?.code;
1114
+ if (code === "ENOENT") {
1115
+ if (options.unlinkTargetWhenBackupMissing) {
1116
+ // The caller owns the target file and there is no image-baked
1117
+ // baseline to fall back to. Leaving a stale rewrite in place is
1118
+ // worse than removing the file: subsequent npm/pnpm invocations
1119
+ // pinned at this userconfig will fall through to public npm
1120
+ // rather than an old configured-org registry/token.
1121
+ try {
1122
+ await unlink(targetPath);
1123
+ getLogger().info("[npm-registry] removed Superblocks-owned userconfig after org transitioned to not-configured (no backup to restore)", { path: targetPath });
1124
+ }
1125
+ catch (unlinkError) {
1126
+ const unlinkCode = unlinkError
1127
+ ?.code;
1128
+ if (unlinkCode === "ENOENT") {
1129
+ return;
1130
+ }
1131
+ getLogger().warn("[npm-registry] failed to remove Superblocks-owned userconfig with no backup; leaving file in place", {
1132
+ path: targetPath,
1133
+ code: unlinkCode,
1134
+ error: unlinkError instanceof Error
1135
+ ? unlinkError.message
1136
+ : String(unlinkError),
1137
+ });
1138
+ }
1139
+ return;
1140
+ }
1141
+ // Two harmless overlapping cases produce this for project-dir
1142
+ // callers:
1143
+ // 1. Pod never had `org_npm_registry` rows, so capture (gated on
1144
+ // the configured branch) never fired. The target is still the
1145
+ // baked-in image content — nothing to restore.
1146
+ // 2. Pod was configured-then-deconfigured before this commit
1147
+ // landed, so the target is a stale rewrite with no companion
1148
+ // snapshot. A recycle is required to recover, but per-install
1149
+ // logging here would be noisy and not actionable.
1150
+ // Either way, no-op silently.
1151
+ return;
1152
+ }
1153
+ getLogger().warn("[npm-registry] failed to read .npmrc backup for restore; skipping", {
1154
+ path: backupPath,
1155
+ code,
1156
+ error: error instanceof Error ? error.message : String(error),
1157
+ });
1158
+ return;
1159
+ }
1160
+ if (current === baked) {
259
1161
  return;
260
1162
  }
261
- await writeNpmrc(path.join(dir, ".npmrc"), config, options);
262
- return config;
1163
+ // Atomic copy: tmp + rename, mirroring `writeNpmrc`'s strategy so a
1164
+ // crash mid-restore cannot leave a half-written file that fails
1165
+ // `npm install` with a parse error.
1166
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
1167
+ try {
1168
+ await writeFile(tmpPath, baked, { encoding: "utf-8", mode: 0o600 });
1169
+ await chmod(tmpPath, 0o600);
1170
+ await rename(tmpPath, targetPath);
1171
+ }
1172
+ catch (error) {
1173
+ await unlink(tmpPath).catch(() => { });
1174
+ // Swallow + warn rather than rethrow: the project-dir caller chains
1175
+ // into `stripResolvedFromLockfile` (APPS-4300), which is the actual
1176
+ // durable safety net for poisoned lockfiles. Propagating the restore
1177
+ // failure would skip that strip and leave the install path worse off
1178
+ // than pre-APPS-4320.
1179
+ getLogger().warn("[npm-registry] failed to restore .npmrc from backup; leaving current file in place", {
1180
+ path: targetPath,
1181
+ error: error instanceof Error ? error.message : String(error),
1182
+ });
1183
+ return;
1184
+ }
1185
+ getLogger().info("[npm-registry] restored .npmrc from backup after org transitioned to not-configured", { path: targetPath });
263
1186
  }
264
1187
  /**
265
- * Materializes the private-registry `.npmrc` (when the env contract is set)
266
- * AND unconditionally strips `resolved` URLs from any lockfile present in
267
- * `dir`, so a subsequent `npm install` is guaranteed to re-resolve through
268
- * the active registry.
1188
+ * Marker on the appended ignore block so we can detect a prior append and
1189
+ * skip the work. Read-side checks are by exact-line match, not by this
1190
+ * marker, so a customer who hand-edits the file (deletes the marker, keeps
1191
+ * the `.npmrc` line) still gets the no-op behaviour they want.
1192
+ */
1193
+ const NPMRC_GITIGNORE_MARKER = "# Superblocks: prevent committing private npm registry credentials";
1194
+ /**
1195
+ * True iff `<dir>` looks like a native-git checkout. Detects both regular
1196
+ * repos (`.git/` directory) and worktrees / submodules (`.git` file containing
1197
+ * a `gitdir:` pointer). Any stat error other than ENOENT is logged and the
1198
+ * function returns true, so a permission glitch fails closed (we will then
1199
+ * refuse to write the token-bearing `.npmrc` rather than assume "safe,
1200
+ * not a git checkout").
1201
+ *
1202
+ * Note: this only checks for `<dir>/.git` literally. `isInsideGitWorkTree`
1203
+ * is the broader check that also catches nested-repo layouts where `<dir>`
1204
+ * has no `.git` but a parent does — we need both because the parent-repo
1205
+ * case is also a `git add .` leak path (cursor[bot] review on PR #19642).
1206
+ */
1207
+ async function dirHasNativeGit(dir) {
1208
+ const gitPath = path.join(dir, ".git");
1209
+ try {
1210
+ await stat(gitPath);
1211
+ return true;
1212
+ }
1213
+ catch (error) {
1214
+ const code = error?.code;
1215
+ if (code === "ENOENT") {
1216
+ return false;
1217
+ }
1218
+ getLogger().warn("[npm-registry] failed to stat .git; assuming native-git for safety", {
1219
+ path: gitPath,
1220
+ code,
1221
+ error: error instanceof Error ? error.message : String(error),
1222
+ });
1223
+ return true;
1224
+ }
1225
+ }
1226
+ /**
1227
+ * True iff `<dir>` is inside any git working tree — either because it has
1228
+ * its own `.git` (regular repo, worktree, submodule) OR because a parent
1229
+ * directory does. The nested-repo case is a real leak path: an app dir
1230
+ * with no local `.git` but a parent that does will be staged by
1231
+ * `git add .` run from the parent, and the `.npmrc` token would land in
1232
+ * the parent's commit history.
1233
+ *
1234
+ * Implementation: `git rev-parse --is-inside-work-tree` walks up from
1235
+ * `<dir>` and exits 0 inside a work tree, non-zero (128) outside. Uses
1236
+ * the same `runGit` scrubbed-env wrapper so a caller spawned by a git
1237
+ * hook can't poison the verdict via `GIT_*` env vars. If git itself is
1238
+ * missing or the invocation times out (`runGit` returns `code: null`),
1239
+ * returns false — there is no git to stage anything with, so the
1240
+ * non-native branch handles it via the proactively-created `.gitignore`.
1241
+ */
1242
+ async function isInsideGitWorkTree(dir) {
1243
+ if (await dirHasNativeGit(dir)) {
1244
+ return true;
1245
+ }
1246
+ const result = await runGit(dir, ["rev-parse", "--is-inside-work-tree"]);
1247
+ if (result.code === 0) {
1248
+ return true;
1249
+ }
1250
+ if (result.code === 128) {
1251
+ // Canonical "not a git repository" exit. Definitively outside any
1252
+ // work tree — the non-native-git branch is correct.
1253
+ return false;
1254
+ }
1255
+ if (result.code === null) {
1256
+ // git binary missing or invocation timed out (`runGit` returns
1257
+ // `code: null` for any non-exit failure: ENOENT for `git`, ETIMEDOUT
1258
+ // after `GIT_VERIFY_TIMEOUT_MS`, signal kills, etc.).
1259
+ //
1260
+ // Returning `false` here routes us into the non-native-git branch,
1261
+ // which is the SAFER choice in every realistic scenario:
1262
+ //
1263
+ // 1. Local-`.git` case is unreachable here: `dirHasNativeGit` short-
1264
+ // circuited above when `<dir>/.git` exists, so we never hit
1265
+ // `rev-parse` for a regular repo at `<dir>`.
1266
+ //
1267
+ // 2. Nested-parent-repo case (the cursor[bot] concern): the parent
1268
+ // has `.git` but the app dir doesn't. If git is missing, the
1269
+ // customer cannot run `git add .` from the parent either — there
1270
+ // is no git binary to stage anything with. The fail-closed
1271
+ // requirement only matters when a working git is present, and a
1272
+ // working git would have returned `0` (or `128`), not `null`.
1273
+ // For the timeout case, a hung `rev-parse --is-inside-work-tree`
1274
+ // on a real repo would be unprecedented (the command only walks
1275
+ // parent directories looking for `.git`); a timeout in practice
1276
+ // means a broken environment where `git add` would also fail.
1277
+ //
1278
+ // 3. The non-native-git branch we route into still calls
1279
+ // `appendNpmrcIgnore` on `<dir>/.gitignore`. If that succeeds,
1280
+ // a future `git init` (or `git` reappearing on PATH) finds the
1281
+ // ignore entry already in place and is future-safe. If
1282
+ // `appendNpmrcIgnore` fails, `ensureNpmrcGitignored` returns
1283
+ // `{ignored:false, hasGit:false}` and the caller refuses to write
1284
+ // the credential-bearing `.npmrc`.
1285
+ //
1286
+ // Returning `true` here instead would force the per-clone exclude
1287
+ // fallback, but that fallback also calls `verifyNpmrcIgnoredByGit`
1288
+ // (same `runGit`) which would also return `code: null` and fail —
1289
+ // ending in the same fail-closed state but with an extra useless
1290
+ // attempt and a misleading "inside-repo" log line.
1291
+ return false;
1292
+ }
1293
+ // Unexpected non-zero from git. Assume inside-a-repo for safety; the
1294
+ // downstream verification will either confirm or fail closed.
1295
+ getLogger().warn("[npm-registry] git rev-parse --is-inside-work-tree returned unexpected exit code; assuming inside-repo for safety", { dir, code: result.code, stderr: result.stderr });
1296
+ return true;
1297
+ }
1298
+ /**
1299
+ * Returns true if the LAST matching rule for `.npmrc` in `text` is a
1300
+ * positive ignore (vs. a `!.npmrc` negation). Gitignore semantics are
1301
+ * "last matching pattern wins": a file containing `.npmrc` followed later
1302
+ * by `!.npmrc` re-includes the file, so a naive "does any line match
1303
+ * `.npmrc`?" check is a false positive that would let the append be
1304
+ * skipped and the credential-bearing `.npmrc` later staged on `git add .`.
1305
+ * Walk the file in order and remember the most recent verdict; return true
1306
+ * only when that verdict is a positive ignore.
1307
+ *
1308
+ * Matches npm/git's own grammar for our specific filename: a line is the
1309
+ * literal `.npmrc` (anchored anywhere) or `/.npmrc` (anchored to repo
1310
+ * root), with optional `!` negation prefix. We do NOT try to interpret
1311
+ * glob patterns like `*.rc` — false negatives just append a redundant
1312
+ * line (always safe under last-match), false positives would leak
1313
+ * credentials.
1314
+ *
1315
+ * Leading whitespace is NOT trimmed: in gitignore grammar leading spaces
1316
+ * are significant, so ` .npmrc` matches a file literally named ` .npmrc`,
1317
+ * NOT `.npmrc`. Trimming here would be a false positive (we'd skip the
1318
+ * append, but git would still stage `.npmrc`). Only two normalisations
1319
+ * happen, both to match git's own pattern parser exactly:
1320
+ *
1321
+ * - Trailing CR from CRLF line endings is stripped (CR is not part of
1322
+ * the path).
1323
+ * - UNESCAPED trailing spaces are stripped (`trim_trailing_spaces` in
1324
+ * git's `dir.c`). `.npmrc ` is the pattern `.npmrc`; `!.npmrc `
1325
+ * is the negation `!.npmrc`. This is the gpoulios-sb PR #19642
1326
+ * re-re-review case: a stray trailing space on `!.npmrc` made git
1327
+ * treat the line as a negation while a naive textual check still
1328
+ * matched the earlier `.npmrc` positive, so the helper declared the
1329
+ * file "ignored" and `maybeWriteNpmrcForDir` proceeded to write
1330
+ * credentials that a later `git init && git add .` would stage.
1331
+ * Tabs are NOT trimmed (git only trims spaces); `\<space>` escapes
1332
+ * a literal trailing space and is preserved.
1333
+ *
1334
+ * Other gitignore exotica (globs, character classes, `**`, backslash
1335
+ * escapes, leading `\!`, UTF-8 BOM, etc.) are NOT interpreted. All of
1336
+ * those cases fail the exact-match comparison below, so the textual
1337
+ * verdict is "not ignored" — the helper then appends a fresh positive
1338
+ * `.npmrc` rule and last-match-wins semantics make the file genuinely
1339
+ * ignored. False negatives are safe (one redundant line); false
1340
+ * positives leak credentials, so we err on the side of appending.
1341
+ *
1342
+ * This is the authoritative non-native-git check (gpoulios-sb PR #19642
1343
+ * re-review): the non-git branch in `ensureNpmrcGitignored` has no
1344
+ * `git check-ignore` follow-up, so the textual verdict must itself be
1345
+ * future-safe against order-sensitive negations — otherwise a later
1346
+ * `git init && git add .` in the same dir would stage a token-bearing
1347
+ * `.npmrc` that the helper had blessed.
1348
+ *
1349
+ * For native-git checkouts textual presence is still necessary but NOT
1350
+ * sufficient — `verifyNpmrcIgnoredByGit` is the authoritative check
1351
+ * because:
1352
+ *
1353
+ * - A negation in a DESCENDANT `.gitignore` (not just this file) can
1354
+ * re-include `.npmrc`; the textual check sees only the current file.
1355
+ * - An already-tracked `.npmrc` is staged on every commit regardless of
1356
+ * ignore rules; the textual check has no view of the index.
1357
+ */
1358
+ function textIgnoresNpmrc(text) {
1359
+ let lastVerdict;
1360
+ for (const raw of text.split("\n")) {
1361
+ const line = trimTrailingGitignoreSpaces(raw.replace(/\r$/, ""));
1362
+ if (line === ".npmrc" || line === "/.npmrc") {
1363
+ lastVerdict = true;
1364
+ }
1365
+ else if (line === "!.npmrc" || line === "!/.npmrc") {
1366
+ lastVerdict = false;
1367
+ }
1368
+ }
1369
+ return lastVerdict === true;
1370
+ }
1371
+ /**
1372
+ * Strip trailing unescaped spaces from a gitignore pattern, matching
1373
+ * git's `trim_trailing_spaces` (in `dir.c`) exactly.
1374
+ *
1375
+ * Algorithm (mirrors git's left-to-right walk):
1376
+ * - Track the position of the first trailing space.
1377
+ * - A `\<space>` pair is an escaped space and resets the tracker
1378
+ * (the space is part of the pattern).
1379
+ * - Any non-space character resets the tracker.
1380
+ * - Truncate at the first tracked trailing space.
1381
+ *
1382
+ * Only ASCII spaces (0x20) are trimmed. Tabs, NBSP, and other
1383
+ * whitespace are preserved — that's what git does. The CR strip happens
1384
+ * separately in `textIgnoresNpmrc`'s split loop because git treats CR as
1385
+ * a line-ending artefact, not a pattern character.
1386
+ *
1387
+ * Examples (matching git's behaviour):
1388
+ * "!.npmrc " → "!.npmrc" (trailing space trimmed)
1389
+ * "!.npmrc " → "!.npmrc" (multiple trailing spaces trimmed)
1390
+ * "!.npmrc\\ " → "!.npmrc\\ " (escaped space preserved)
1391
+ * "!.npmrc\\t" → "!.npmrc\\t" (tab preserved; not a space)
1392
+ * " .npmrc" → " .npmrc" (leading whitespace preserved)
1393
+ */
1394
+ function trimTrailingGitignoreSpaces(line) {
1395
+ let lastSpace = -1;
1396
+ for (let i = 0; i < line.length; i++) {
1397
+ const ch = line[i];
1398
+ if (ch === "\\" && line[i + 1] === " ") {
1399
+ // Escaped space: skip the pair AND reset the tracker so the
1400
+ // escaped space cannot anchor a truncation.
1401
+ i++;
1402
+ lastSpace = -1;
1403
+ }
1404
+ else if (ch === " ") {
1405
+ if (lastSpace === -1) {
1406
+ lastSpace = i;
1407
+ }
1408
+ }
1409
+ else {
1410
+ lastSpace = -1;
1411
+ }
1412
+ }
1413
+ return lastSpace === -1 ? line : line.slice(0, lastSpace);
1414
+ }
1415
+ /**
1416
+ * Timeout (ms) for the `git` invocations in `verifyNpmrcIgnoredByGit`.
1417
+ * Generous enough for any realistic working tree (these commands scan only
1418
+ * the index and ignore rules, not the working tree), short enough that a
1419
+ * hung git invocation can't stall the install path.
1420
+ */
1421
+ const GIT_VERIFY_TIMEOUT_MS = 5000;
1422
+ /**
1423
+ * Build a `process.env` copy with every `GIT_*` variable removed. Used by
1424
+ * `runGit` to insulate the helper from outer-process git state. See the
1425
+ * comment in `runGit` for the leak path this guards against.
1426
+ */
1427
+ function scrubbedGitEnv() {
1428
+ const env = {};
1429
+ for (const [key, value] of Object.entries(process.env)) {
1430
+ if (!key.startsWith("GIT_")) {
1431
+ env[key] = value;
1432
+ }
1433
+ }
1434
+ return env;
1435
+ }
1436
+ /**
1437
+ * Run `git <args>` in `cwd`, returning the exit code without throwing. Any
1438
+ * non-spawn error (timeout, ENOENT for `git`, etc.) returns `{ code: null }`
1439
+ * so the caller fails closed. We use `execFile` (no shell) so the file
1440
+ * arguments cannot be reinterpreted as shell syntax.
1441
+ */
1442
+ async function runGit(cwd, args) {
1443
+ try {
1444
+ const { stderr } = await execFileAsync("git", args, {
1445
+ cwd,
1446
+ timeout: GIT_VERIFY_TIMEOUT_MS,
1447
+ // Even if git emits a lot of stderr (unlikely for these tiny
1448
+ // commands), bound the buffer so we don't OOM the install path.
1449
+ maxBuffer: 1024 * 1024,
1450
+ // Scrub `GIT_*` env vars before invoking git. If a caller's process
1451
+ // was itself spawned by git (e.g. a `pre-commit` hook), git will
1452
+ // have exported `GIT_DIR`, `GIT_INDEX_FILE`, `GIT_WORK_TREE`,
1453
+ // `GIT_PREFIX`, etc. pointing at the OUTER repo. Those would
1454
+ // override `cwd` discovery and make `check-ignore` / `ls-files`
1455
+ // query the wrong repository, producing wrong fail-closed
1456
+ // verdicts. The npm install path is not normally invoked from a
1457
+ // git hook, but stripping these is cheap insurance.
1458
+ env: scrubbedGitEnv(),
1459
+ });
1460
+ return { code: 0, stderr };
1461
+ }
1462
+ catch (error) {
1463
+ // Promisified `execFile` throws an ExecException for both non-zero
1464
+ // exits and spawn failures. Distinguish them via `code`:
1465
+ // - number → process exited with that code
1466
+ // - string ('ENOENT', 'ETIMEDOUT', etc.) → spawn / signal failure
1467
+ const err = error;
1468
+ const code = err?.code;
1469
+ const stderr = err?.stderr ?? "";
1470
+ if (typeof code === "number") {
1471
+ return { code, stderr };
1472
+ }
1473
+ return { code: null, stderr };
1474
+ }
1475
+ }
1476
+ /**
1477
+ * Use git itself to verify that `.npmrc` is BOTH effectively ignored AND
1478
+ * not already tracked from `dir`. Closes two leak paths that a textual
1479
+ * `.gitignore` check cannot:
1480
+ *
1481
+ * 1. Negated ignore rules. `.npmrc` followed later by `!.npmrc` (or by
1482
+ * `!.npmrc` in a descendant `.gitignore`) makes the file trackable.
1483
+ * `textIgnoresNpmrc` would still report true.
1484
+ * 2. Already-tracked `.npmrc`. Ignore rules do NOT protect files that
1485
+ * are already in the index — the next commit stages the rewrite. A
1486
+ * brownfield app that committed an empty `.npmrc` before the
1487
+ * private-registry feature is the canonical case.
1488
+ *
1489
+ * Returns true ONLY when both checks positively prove safety:
1490
+ * - `git check-ignore -q .npmrc` exits 0 (file is effectively ignored)
1491
+ * - `git ls-files --error-unmatch .npmrc` exits 1 (file is NOT tracked)
1492
+ *
1493
+ * Any other outcome — file not ignored, file tracked, git missing, not a
1494
+ * real repo, timeout, unexpected exit code — returns false. The caller
1495
+ * fails closed and refuses to write the credential-bearing `.npmrc`.
1496
+ */
1497
+ async function verifyNpmrcIgnoredByGit(dir) {
1498
+ const checkIgnore = await runGit(dir, ["check-ignore", "-q", "--", ".npmrc"]);
1499
+ if (checkIgnore.code !== 0) {
1500
+ if (checkIgnore.code === 1) {
1501
+ getLogger().warn("[npm-registry] git check-ignore reports .npmrc is NOT effectively ignored (likely a `!.npmrc` negation downstream); refusing to write credential-bearing .npmrc", { dir });
1502
+ }
1503
+ else {
1504
+ getLogger().warn("[npm-registry] git check-ignore failed (not a real git repo, git missing, or timeout); refusing to write credential-bearing .npmrc", { dir, code: checkIgnore.code, stderr: checkIgnore.stderr });
1505
+ }
1506
+ return false;
1507
+ }
1508
+ const lsFiles = await runGit(dir, [
1509
+ "ls-files",
1510
+ "--error-unmatch",
1511
+ "--",
1512
+ ".npmrc",
1513
+ ]);
1514
+ if (lsFiles.code === 0) {
1515
+ // File is in the index. Even with `.gitignore` containing `.npmrc`,
1516
+ // rewriting the file with `_authToken=…` will stage on the next
1517
+ // commit because git tracks file content for already-tracked files
1518
+ // regardless of the ignore list.
1519
+ getLogger().warn("[npm-registry] .npmrc is already tracked by git; refusing to write credentials (next commit would stage the token)", { dir });
1520
+ return false;
1521
+ }
1522
+ if (lsFiles.code !== 1) {
1523
+ // 1 is the canonical "not tracked" exit (`error: pathspec '.npmrc' did
1524
+ // not match any file(s) known to git`). Other non-zero codes mean
1525
+ // something is wrong with the repo / our invocation — fail closed.
1526
+ getLogger().warn("[npm-registry] git ls-files returned an unexpected exit code; refusing to write credential-bearing .npmrc", { dir, code: lsFiles.code, stderr: lsFiles.stderr });
1527
+ return false;
1528
+ }
1529
+ return true;
1530
+ }
1531
+ /**
1532
+ * Append (or create) an ignore file at `targetPath` so that `.npmrc` is
1533
+ * excluded. Returns true on success, false on any I/O failure. `createIfMissing`
1534
+ * controls whether an absent file is created. All `ensureNpmrcGitignored`
1535
+ * call sites pass `true` (so a missing `.gitignore` or `.git/info/exclude`
1536
+ * is materialised before the credential write); the parameter is preserved
1537
+ * so future callers can opt into a strict "only append, never create"
1538
+ * behaviour without touching the body.
1539
+ */
1540
+ async function appendNpmrcIgnore(targetPath, createIfMissing) {
1541
+ // Refuse to follow a symlink at the leaf path component on BOTH the read
1542
+ // and the write. `targetPath` is `<dir>/.gitignore` or
1543
+ // `<dir>/.git/info/exclude`; a crafted workspace could point either at a
1544
+ // file outside the app root. `readFile`/`writeFile` follow symlinks, so
1545
+ // without `O_NOFOLLOW` our ~70-byte ignore fragment could be written
1546
+ // through the link to an external target (cursor[bot] MEDIUM, colinhicks
1547
+ // review on PR #19642). Opening with `O_NOFOLLOW` fails with `ELOOP` when
1548
+ // the leaf is a symlink; we treat that as fail-closed (return false) so
1549
+ // `ensureNpmrcGitignored` refuses the credential write — same boundary
1550
+ // discipline as `persisted-checklist-store.ts` / PR #19495, but TOCTOU-free
1551
+ // because the kernel checks atomically at open time. `O_NOFOLLOW` is POSIX;
1552
+ // `?? 0` degrades to a plain open where it is absent (the dev-server runs
1553
+ // on Linux, where it is always defined).
1554
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
1555
+ let existing;
1556
+ let readHandle;
1557
+ try {
1558
+ readHandle = await open(targetPath, fsConstants.O_RDONLY | noFollow);
1559
+ existing = await readHandle.readFile("utf-8");
1560
+ }
1561
+ catch (error) {
1562
+ const code = error?.code;
1563
+ if (code === "ELOOP") {
1564
+ getLogger().warn("[npm-registry] ignore file is a symlink; refusing to follow it for .npmrc enforcement", { path: targetPath });
1565
+ return false;
1566
+ }
1567
+ if (code !== "ENOENT") {
1568
+ getLogger().warn("[npm-registry] failed to read ignore file; skipping .npmrc enforcement", {
1569
+ path: targetPath,
1570
+ code,
1571
+ error: error instanceof Error ? error.message : String(error),
1572
+ });
1573
+ return false;
1574
+ }
1575
+ if (!createIfMissing) {
1576
+ return false;
1577
+ }
1578
+ existing = undefined;
1579
+ }
1580
+ finally {
1581
+ await readHandle?.close();
1582
+ }
1583
+ if (existing !== undefined && textIgnoresNpmrc(existing)) {
1584
+ return true;
1585
+ }
1586
+ // Separate the appended block from any existing content with a blank line.
1587
+ // For an empty / freshly-created file start at column 0 with no leading
1588
+ // blank line (cosmetic, colinhicks review on PR #19642). For non-empty
1589
+ // content the byte output is unchanged.
1590
+ const base = existing ?? "";
1591
+ const sep = base.length === 0 ? "" : base.endsWith("\n") ? "\n" : "\n\n";
1592
+ const appended = `${base}${sep}${NPMRC_GITIGNORE_MARKER}\n.npmrc\n`;
1593
+ let writeHandle;
1594
+ try {
1595
+ // `O_TRUNC` is safe: we re-serialise the full file (existing content +
1596
+ // appended block), so truncation never drops data. `O_NOFOLLOW` closes
1597
+ // the TOCTOU window where the leaf is swapped for a symlink between the
1598
+ // read above and this write. Mode 0o666 matches the previous `writeFile`
1599
+ // default (umask trims it to 0o644) and is ignored when the file already
1600
+ // exists.
1601
+ writeHandle = await open(targetPath, fsConstants.O_WRONLY |
1602
+ fsConstants.O_CREAT |
1603
+ fsConstants.O_TRUNC |
1604
+ noFollow, 0o666);
1605
+ await writeHandle.writeFile(appended, "utf-8");
1606
+ getLogger().info("[npm-registry] appended .npmrc to ignore file to prevent credential commit", { path: targetPath });
1607
+ return true;
1608
+ }
1609
+ catch (error) {
1610
+ const code = error?.code;
1611
+ if (code === "ELOOP") {
1612
+ getLogger().warn("[npm-registry] ignore file became a symlink before write; refusing to write through it", { path: targetPath });
1613
+ return false;
1614
+ }
1615
+ getLogger().warn("[npm-registry] failed to append .npmrc to ignore file", {
1616
+ path: targetPath,
1617
+ error: error instanceof Error ? error.message : String(error),
1618
+ });
1619
+ return false;
1620
+ }
1621
+ finally {
1622
+ await writeHandle?.close();
1623
+ }
1624
+ }
1625
+ /**
1626
+ * Ensure `.npmrc` is git-ignored from `<dir>` before any credential-bearing
1627
+ * `.npmrc` is written.
1628
+ *
1629
+ * Why this matters: `maybeWriteNpmrcForDir` writes a `.npmrc` containing
1630
+ * `//host/:_authToken=<customer token>` at the app root. The DBFS sync
1631
+ * already excludes `.npmrc` from upload (`codeModeExcludedFiles` in
1632
+ * `file-system-helpers.ts`), but the native-git path calls `git.add(".")`
1633
+ * (socket-manager `gitCommitLocal`/`gitPull`, agent `git`/`git_raw` tools)
1634
+ * which stages everything not listed in the app's on-disk ignore. Without
1635
+ * this enforcement, a customer with a configured private registry pushes
1636
+ * their `_authToken` to their external git remote on the next commit. The
1637
+ * template `.gitignore` covers new apps; this helper covers the existing
1638
+ * fleet whose `.gitignore` predates the registry feature.
1639
+ *
1640
+ * Behaviour, in three layers:
1641
+ *
1642
+ * 1. Non-native-git (`<dir>/.git` does not exist):
1643
+ * - Append `.npmrc` to `<dir>/.gitignore`, or create the file if
1644
+ * it doesn't exist. We create `.gitignore` here (not just when
1645
+ * `.git` already exists) so a subsequent `git init` in the same
1646
+ * directory is future-safe: a customer who later turns on native
1647
+ * git for an app where a credential-bearing `.npmrc` already
1648
+ * sits on disk would otherwise stage the token on their first
1649
+ * `git add .` before any install path re-runs this helper.
1650
+ * - On success return `{ ignored: true, hasGit: false }`. On a
1651
+ * read or write failure (`.gitignore` is a directory, disk full,
1652
+ * permission denied) return `{ ignored: false, hasGit: false }`.
1653
+ * The caller MUST refuse to write the credential-bearing
1654
+ * `.npmrc` in that case: even though no native git checkout
1655
+ * exists today, a later `git init` (or wiring this dir into an
1656
+ * existing repo) would find the on-disk token and stage it on
1657
+ * the first `git add .` — exactly the future-git leak path
1658
+ * gpoulios-sb flagged in code review.
1659
+ *
1660
+ * 2. Native-git (`<dir>/.git` exists), `.gitignore` path:
1661
+ * - Read or create `.gitignore`, append `.npmrc` if missing. After
1662
+ * the append, verify with git itself (`verifyNpmrcIgnoredByGit`)
1663
+ * that the file is effectively ignored AND not already tracked.
1664
+ * Only then return `ignored: true`.
1665
+ *
1666
+ * 3. Native-git, `.git/info/exclude` fallback:
1667
+ * - Only attempted when the `.gitignore` write failed AND `.git` is a
1668
+ * directory (regular repo). `.git/info/exclude` is the per-clone
1669
+ * ignore file: not synced to remotes, but exactly as effective as
1670
+ * `.gitignore` at preventing `git add` from staging the file. The
1671
+ * fallback lets us still fail-closed when the working tree is
1672
+ * read-only but the local clone metadata is writable. After the
1673
+ * exclude append, the same `verifyNpmrcIgnoredByGit` check runs.
1674
+ *
1675
+ * Why git verification, not just text: a `.gitignore` containing `.npmrc`
1676
+ * is necessary but not sufficient. Two real leak paths bypass a naive
1677
+ * "any line matches" textual check:
1678
+ * - A later `!.npmrc` re-includes the file (gitignore is order-sensitive).
1679
+ * `textIgnoresNpmrc` is already negation-aware (returns true only when
1680
+ * the LAST matching rule is positive), so for this case the textual
1681
+ * check correctly reports "not ignored" and `appendNpmrcIgnore` writes
1682
+ * a fresh positive `.npmrc` line that becomes the new last-match.
1683
+ * This is the gpoulios-sb PR #19642 re-review "always append" remedy,
1684
+ * applied uniformly to both branches (non-native-git relies on it as
1685
+ * the authoritative check; native-git double-checks with git below).
1686
+ * - An already-tracked `.npmrc` stages on every commit regardless of
1687
+ * ignore rules. No textual check sees the index, so for native-git
1688
+ * `git ls-files --error-unmatch` is the only catch.
1689
+ *
1690
+ * Returns `{ ignored, hasGit }`. The caller's fail-closed signal is
1691
+ * `ignored === false` regardless of `hasGit`: any state in which we cannot
1692
+ * guarantee `.npmrc` is ignored from `<dir>` blocks the credential write,
1693
+ * because either the current native git checkout would stage the token on
1694
+ * the next `git add .`, or a future `git init` in the same dir would find
1695
+ * the token on disk and stage it on the first `git add .`. `hasGit` is
1696
+ * retained for logging only — it lets the warning distinguish the
1697
+ * "tomorrow's risk" case from the "today's risk" case.
1698
+ */
1699
+ export async function ensureNpmrcGitignored(dir) {
1700
+ // `hasGit` covers BOTH the local-`.git` and nested-repo (parent has
1701
+ // `.git`) cases. The nested-repo case was the cursor[bot] PR #19642
1702
+ // review concern: an app dir inside a parent git repo has no local
1703
+ // `.git`, so a bare `dirHasNativeGit` check would treat it as
1704
+ // non-native-git and let credentials through; `git add .` run from the
1705
+ // parent would then stage `<dir>/.npmrc`. The local-`.git`-only check
1706
+ // is still needed for the `.git/info/exclude` fallback below (that
1707
+ // path only exists for regular repos, not nested-only layouts).
1708
+ const hasGit = await isInsideGitWorkTree(dir);
1709
+ const hasLocalDotGit = await dirHasNativeGit(dir);
1710
+ const gitignorePath = path.join(dir, ".gitignore");
1711
+ // Always create `.gitignore` if missing — even when `!hasGit` — so a
1712
+ // later `git init` in this dir finds the `.npmrc` entry already in
1713
+ // place and the first `git add .` cannot stage a credential file that
1714
+ // was written between the two events. See the layer-1 comment in this
1715
+ // function's docstring for the future-safe rationale.
1716
+ if (await appendNpmrcIgnore(gitignorePath, true)) {
1717
+ if (!hasGit) {
1718
+ return { ignored: true, hasGit: false };
1719
+ }
1720
+ if (await verifyNpmrcIgnoredByGit(dir)) {
1721
+ return { ignored: true, hasGit: true };
1722
+ }
1723
+ // Textual append succeeded but git disagrees: a downstream `!.npmrc`
1724
+ // negation, an already-tracked `.npmrc`, or `git` is unable to verify
1725
+ // (missing, broken repo, timeout). Fall through to the per-clone
1726
+ // exclude fallback — it can override `.gitignore` negations (exclude
1727
+ // rules are evaluated last) AND covers the "no git binary / bad repo"
1728
+ // case in the same way (the fallback verifies too, and fails closed).
1729
+ }
1730
+ if (!hasGit) {
1731
+ // Non-native-git, no parent repo either: the `.gitignore` append/create
1732
+ // failed (rare — disk full, permission denied, or `.gitignore` was a
1733
+ // directory). Fail-closed: the future-git leak path is still open.
1734
+ return { ignored: false, hasGit: false };
1735
+ }
1736
+ // Inside a git work tree: `.gitignore` enforcement failed (or git
1737
+ // verification rejected it). Try the per-clone exclude file as a
1738
+ // fallback. Only valid when `<dir>/.git` is a directory (regular repo
1739
+ // rooted here); for a nested-repo case (`hasGit` but no local
1740
+ // `.git`) we have no path to the parent's `.git/info/exclude` without
1741
+ // a `git rev-parse --git-dir`, and the same exclude in the parent
1742
+ // won't anchor to `<dir>` anyway. Fail closed in those cases.
1743
+ const dotGit = path.join(dir, ".git");
1744
+ let dotGitIsDir = false;
1745
+ if (hasLocalDotGit) {
1746
+ try {
1747
+ const st = await stat(dotGit);
1748
+ dotGitIsDir = st.isDirectory();
1749
+ }
1750
+ catch {
1751
+ // Already covered by `dirHasNativeGit` — if we got here, the path
1752
+ // existed at the time of that check. A subsequent disappearance is
1753
+ // odd but we still fail closed.
1754
+ }
1755
+ }
1756
+ if (!dotGitIsDir) {
1757
+ getLogger().warn("[npm-registry] native-git checkout has no writable .gitignore and .git is not a regular directory; refusing to write .npmrc", { path: gitignorePath });
1758
+ return { ignored: false, hasGit: true };
1759
+ }
1760
+ const infoDir = path.join(dotGit, "info");
1761
+ try {
1762
+ await mkdir(infoDir, { recursive: true });
1763
+ }
1764
+ catch (error) {
1765
+ getLogger().warn("[npm-registry] failed to create .git/info directory for exclude fallback", {
1766
+ path: infoDir,
1767
+ error: error instanceof Error ? error.message : String(error),
1768
+ });
1769
+ return { ignored: false, hasGit: true };
1770
+ }
1771
+ const excludePath = path.join(infoDir, "exclude");
1772
+ if (await appendNpmrcIgnore(excludePath, true)) {
1773
+ // Verify with git again. The exclude file is evaluated LAST in git's
1774
+ // ignore precedence, so it overrides a `!.npmrc` negation in
1775
+ // `.gitignore`. But it still cannot un-track an already-tracked file,
1776
+ // so `ls-files --error-unmatch` is still the gate.
1777
+ if (await verifyNpmrcIgnoredByGit(dir)) {
1778
+ return { ignored: true, hasGit: true };
1779
+ }
1780
+ }
1781
+ return { ignored: false, hasGit: true };
1782
+ }
1783
+ /**
1784
+ * Convenience: invoke `writeNpmrc` against `<dir>/.npmrc` when the client
1785
+ * resolves to a configured (or stale) registry config. No-op when:
1786
+ *
1787
+ * - `client` is undefined (test-only opt-out — production code paths
1788
+ * always pass a client constructed by AiService); or
1789
+ * - the resolved config is `source: "unreachable"` (transient cold-cache
1790
+ * outage — we leave `.npmrc` whatever it currently is and reconcile on
1791
+ * the next successful fetch, rather than clobbering a last-known-good
1792
+ * private-registry `.npmrc` back to baseline during the outage window);
1793
+ * or
1794
+ * - the resolved config is `source: "not-configured"` (deliberate unset
1795
+ * observation). If a previously-written `.npmrc` exists that no longer
1796
+ * matches the baked-in `.npmrc.default`, restore it — see APPS-4320.
1797
+ *
1798
+ * On the first call per pod boot for `<dir>`, captures `<dir>/.npmrc` to
1799
+ * `<dir>/.npmrc.default` before any rewrite so the restore path above has
1800
+ * a source to copy back from.
1801
+ *
1802
+ * Returns the resolved fetch result so callers can branch on `source` for
1803
+ * observability, or `undefined` when no client was provided.
1804
+ */
1805
+ export async function maybeWriteNpmrcForDir(dir, options = {}, client) {
1806
+ if (!client) {
1807
+ return undefined;
1808
+ }
1809
+ const result = await client.getConfig();
1810
+ if (result.source === "unreachable") {
1811
+ // Transient cold-cache outage. We do NOT know the org's current
1812
+ // registry state, so we refuse to act on this signal — neither
1813
+ // rewrite (no config to render) nor restore (would clobber a
1814
+ // last-known-good private-registry `.npmrc` back to the baked-in
1815
+ // baseline exactly during the outage window where the customer
1816
+ // needs us to fail closed). Next successful fetch will reconcile.
1817
+ // Symmetric with `syncHomeNpmrc`'s `skipped-unreachable` branch in
1818
+ // `home-npmrc.mts`.
1819
+ getLogger().warn("[npm-registry] server unreachable with no cached config; .npmrc left unchanged", { path: path.join(dir, ".npmrc") });
1820
+ return result;
1821
+ }
1822
+ const npmrcPath = path.join(dir, ".npmrc");
1823
+ const backupPath = path.join(dir, NPMRC_DEFAULT_FILENAME);
1824
+ if (!result.config.configured) {
1825
+ await restoreInitialNpmrc(npmrcPath, backupPath);
1826
+ if (result.config.allowInstallScripts === false) {
1827
+ // Even without registry rows the org policy disallows install
1828
+ // scripts. Bake `ignore-scripts=true` into the project `.npmrc`
1829
+ // so any npm/pnpm invocation (patch-package, husky, ad-hoc
1830
+ // `bash npm install`) honours the policy. `writeNpmrc` with a
1831
+ // policy-only config preserves existing scope lines (e.g. the
1832
+ // EE-baked `@superblocksteam:registry=`) via `preserveScopeLines`.
1833
+ //
1834
+ // Skip `snapshotInitialNpmrc`: on a repeated sync the target
1835
+ // already contains our policy file; snapshotting it would poison
1836
+ // the backup so a later flip to allowInstallScripts=true could
1837
+ // never restore the original baked content.
1838
+ await writeNpmrc(npmrcPath, result.config, options);
1839
+ }
1840
+ else {
1841
+ // Clean up a stale policy-only `.npmrc` left by a previous call
1842
+ // when the dir had no baked-in file to snapshot. In that case
1843
+ // `restoreInitialNpmrc` cannot restore the "no file" baseline
1844
+ // because `.npmrc.default` was never created. We detect the stale
1845
+ // file by checking that it contains only the policy line (no
1846
+ // registry entries) and remove it.
1847
+ try {
1848
+ const content = await readFile(npmrcPath, "utf-8");
1849
+ if (content.trim() === "ignore-scripts=true") {
1850
+ await unlink(npmrcPath);
1851
+ }
1852
+ }
1853
+ catch (e) {
1854
+ if (e?.code !== "ENOENT") {
1855
+ getLogger().warn("[npm-registry] failed to clean up stale policy-only .npmrc", {
1856
+ path: npmrcPath,
1857
+ error: e instanceof Error ? e.message : String(e),
1858
+ });
1859
+ }
1860
+ }
1861
+ }
1862
+ return result;
1863
+ }
1864
+ // Ensure `.npmrc` is git-ignored from `<dir>` BEFORE we write the
1865
+ // credential-bearing file. Fail-closed in BOTH the native-git and
1866
+ // non-native-git cases:
1867
+ //
1868
+ // - Native-git, no writable ignore (neither `<dir>/.gitignore` nor
1869
+ // `<dir>/.git/info/exclude`): the next `git add .` would stage the
1870
+ // token and push it to the customer's external remote.
1871
+ //
1872
+ // - Non-native-git, `<dir>/.gitignore` could not be created (rare —
1873
+ // disk full, permission denied, or the path is a directory): no
1874
+ // immediate native-git leak, but a later `git init` in the same
1875
+ // dir would find the on-disk token with no ignore rule and stage
1876
+ // it on the first `git add .`. This is the exact future-git leak
1877
+ // path called out in PR #19642 code review.
1878
+ //
1879
+ // See `ensureNpmrcGitignored` for the full decision matrix.
1880
+ const ignoreResult = await ensureNpmrcGitignored(dir);
1881
+ if (!ignoreResult.ignored) {
1882
+ getLogger().warn(ignoreResult.hasGit
1883
+ ? "[npm-registry] refusing to write .npmrc with credentials: native-git checkout has no writable ignore (neither .gitignore nor .git/info/exclude); would leak token on next `git add .`"
1884
+ : "[npm-registry] refusing to write .npmrc with credentials: could not create or update .gitignore (write failed); a later `git init` in this dir would stage the token on first `git add .`", { path: npmrcPath, dir, hasGit: ignoreResult.hasGit });
1885
+ return result;
1886
+ }
1887
+ // Snapshot the baked-in `.npmrc` before the first rewrite so the
1888
+ // not-configured branch above has a restore source. Gated on the
1889
+ // configured branch so a pod whose org was configured-then-deconfigured
1890
+ // before this commit landed (so `.npmrc` is already a rewritten value
1891
+ // with no companion `.npmrc.default`) does not snapshot the rewritten
1892
+ // file as the baked-in baseline. In that pre-existing-rewrite case
1893
+ // the restore path correctly no-ops; recovery requires a pod recycle.
1894
+ //
1895
+ // Snapshot AFTER the gitignore check (we don't need a backup if we're
1896
+ // refusing to write).
1897
+ await snapshotInitialNpmrc(npmrcPath, backupPath);
1898
+ await writeNpmrc(npmrcPath, result.config, options);
1899
+ return result;
1900
+ }
1901
+ /**
1902
+ * Materializes the private-registry `.npmrc` AND unconditionally strips
1903
+ * `resolved` URLs from any lockfile present in `dir`, so a subsequent
1904
+ * `npm install` is guaranteed to re-resolve through the active registry.
1905
+ * The `.npmrc` write is gated on whether the client resolves to a configured
1906
+ * registry; the lockfile strip runs regardless.
269
1907
  *
270
1908
  * Why the strip is unconditional (decoupled from the `.npmrc` write):
271
- * - SaaS dev-server pods (staging/prod) run without `SUPERBLOCKS_NPM_REGISTRY`
272
- * set, but their lockfiles can still be poisoned with cross-registry
273
- * `resolved` URLs via DBFS state (APPS-4300). Gating the strip on the
274
- * env meant the only safety net never fired in the exact environment
275
- * where it mattered. Always stripping closes that read path.
1909
+ * - SaaS dev-server pods (staging/prod) often run without a configured
1910
+ * org npm registry, but their lockfiles can still be poisoned with
1911
+ * cross-registry `resolved` URLs via DBFS state (APPS-4300). Gating
1912
+ * the strip on the client's configured-ness meant the only safety net
1913
+ * never fired in the exact environment where it mattered. Always
1914
+ * stripping closes that read path.
276
1915
  * - The mutation is transient: `npm install` rewrites `resolved` with
277
1916
  * URLs from the active `.npmrc` registry on the very next line, so a
278
1917
  * local dev whose lockfile pointed at npmjs.org sees the same URLs
@@ -288,13 +1927,14 @@ export async function maybeWriteNpmrcForDir(dir, options = {}, env = process.env
288
1927
  * (only configure GHPR scope for ephemeral builds) is the actual durable
289
1928
  * fix; this strip is the runtime safety net for already-poisoned DBFS state.
290
1929
  *
291
- * Returns the loaded config when an `.npmrc` was written, otherwise
292
- * `undefined`. The lockfile strip happens in both cases.
1930
+ * Returns the resolved fetch result when a client is provided (so callers
1931
+ * can branch on `source` for observability), or `undefined` when no client
1932
+ * was provided. The lockfile strip happens in both cases.
293
1933
  */
294
- export async function prepareForPrivateRegistry(dir, options = {}, env = process.env) {
295
- const config = await maybeWriteNpmrcForDir(dir, options, env);
1934
+ export async function prepareForPrivateRegistry(dir, options = {}, client) {
1935
+ const result = await maybeWriteNpmrcForDir(dir, options, client);
296
1936
  await stripResolvedFromLockfile(dir);
297
- return config;
1937
+ return result;
298
1938
  }
299
1939
  /**
300
1940
  * Removes the `resolved` field from every entry in `.packages` of an npm v2+
@@ -329,7 +1969,7 @@ export async function stripResolvedFromLockfile(dir) {
329
1969
  stripResolvedFromLockfilePath(path.join(dir, "node_modules", ".package-lock.json")),
330
1970
  ]);
331
1971
  }
332
- async function stripResolvedFromLockfilePath(lockfilePath) {
1972
+ export async function stripResolvedFromLockfilePath(lockfilePath) {
333
1973
  let raw;
334
1974
  try {
335
1975
  raw = await readFile(lockfilePath, "utf-8");
@@ -412,4 +2052,93 @@ async function stripResolvedFromLockfilePath(lockfilePath) {
412
2052
  throw error;
413
2053
  }
414
2054
  }
2055
+ /**
2056
+ * APPS-4370. Build a disk-backed `LastKnownRegistryPolicyStore` rooted at
2057
+ * `<rootDir>/.superblocks/orgs/<orgId>/.registry-was-configured`.
2058
+ *
2059
+ * The marker's PRESENCE is the semantic signal; its content is informal
2060
+ * (an ISO timestamp for forensics — "when was this org first seen with a
2061
+ * configured registry"). Writes are best-effort: a write failure (EACCES
2062
+ * on a 0o500 parent, ENOSPC) is logged and swallowed. Reads return false
2063
+ * on any error so a transient IO problem doesn't degrade today's
2064
+ * "proceed" behaviour into a fail-closed install.
2065
+ *
2066
+ * APPS-4427: `markConfigured` is AWAITED by its caller
2067
+ * (`NpmRegistryClient.markPolicyConfigured`) before a `configured` / `stale`
2068
+ * resolution is handed back, so the marker is durably on disk before that
2069
+ * result is used — closing the crash window an un-awaited write would leave
2070
+ * open. The write stays async (`fs/promises`, per the ai-service no-sync-fs
2071
+ * rule) and is guarded to run at most once per process, so awaiting it stays
2072
+ * off the install hot path after the first configured resolution.
2073
+ *
2074
+ * The `rootDir` is injected (rather than hard-coded to `os.homedir()`)
2075
+ * so tests can isolate to a tmp directory and so the dev-server can
2076
+ * point the store at the same disk it already manages
2077
+ * (`~/.superblocks/...`). The per-org path keeps multiple orgs running
2078
+ * against the same machine from contaminating each other's marker state.
2079
+ */
2080
+ export function createDiskRegistryPolicyStore(opts) {
2081
+ const orgDir = path.join(opts.rootDir, ".superblocks", "orgs", opts.organizationId);
2082
+ const markerPath = path.join(orgDir, ".registry-was-configured");
2083
+ // APPS-4427: write at most once per process. The marker's PRESENCE is the
2084
+ // signal, so re-writing on every `configured`/`stale` resolution is pure
2085
+ // overhead — and since the caller now AWAITS the write, guarding here keeps
2086
+ // it off the install hot path after the first configured resolution. Only
2087
+ // set on a SUCCESSFUL write, so a transient failure is retried next call.
2088
+ let marked = false;
2089
+ return {
2090
+ async markConfigured() {
2091
+ if (marked) {
2092
+ return;
2093
+ }
2094
+ try {
2095
+ // Async (`fs/promises`) per the ai-service no-sync-fs rule. Durability
2096
+ // comes from the caller awaiting this before handing back the
2097
+ // `configured`/`stale` resolution (see `markPolicyConfigured`), not
2098
+ // from blocking the event loop with a sync write.
2099
+ await mkdir(orgDir, { recursive: true });
2100
+ // An ISO timestamp is enough to debug "when did we first see this
2101
+ // org configured" without growing the file's semantic surface
2102
+ // (presence is the signal).
2103
+ await writeFile(markerPath, new Date().toISOString() + "\n", "utf-8");
2104
+ marked = true;
2105
+ }
2106
+ catch (err) {
2107
+ getLogger().warn("[npm-registry] failed to write registry-policy marker; ignoring", {
2108
+ path: markerPath,
2109
+ error: err instanceof Error ? err.message : String(err),
2110
+ });
2111
+ }
2112
+ },
2113
+ async hasEverBeenConfigured() {
2114
+ try {
2115
+ await stat(markerPath);
2116
+ return true;
2117
+ }
2118
+ catch (err) {
2119
+ const code = err?.code;
2120
+ if (code === "ENOENT") {
2121
+ // No marker → never been configured. The common case; not a
2122
+ // log-worthy event.
2123
+ return false;
2124
+ }
2125
+ // Anything else (EACCES on a clamped-perms dir, EIO) gets
2126
+ // surfaced at warn level so a misconfigured deployment is
2127
+ // visible, but we still resolve to false so the install path
2128
+ // preserves current behaviour.
2129
+ getLogger().warn("[npm-registry] failed to read registry-policy marker; treating as not-configured", {
2130
+ path: markerPath,
2131
+ code,
2132
+ error: err instanceof Error ? err.message : String(err),
2133
+ });
2134
+ return false;
2135
+ }
2136
+ },
2137
+ };
2138
+ }
2139
+ // Re-export the npm/pnpm install-error classifier + structured error types so
2140
+ // consumers of the `/npm-registry` entry (e.g. the CLI dev server) can classify
2141
+ // installs without reaching into ai-service internals. APPS-4450.
2142
+ export { parseNpmJsonError, parseNpmJsonDiagnostic, classifyNpmExecStderr, } from "./npm-error-parser.js";
2143
+ export { NpmInstallBlocked, NpmInstallError, scrubSecrets } from "../types.js";
415
2144
  //# sourceMappingURL=npm-registry.js.map