@harperfast/harper 5.2.0-alpha.5 → 5.2.0-beta.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 (311) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dataLayer/harperBridge/ResourceBridge.ts +7 -0
  28. package/dist/agent/agent.d.ts +24 -3
  29. package/dist/agent/agent.js +172 -10
  30. package/dist/agent/agent.js.map +1 -1
  31. package/dist/agent/bestPractices.d.ts +24 -0
  32. package/dist/agent/bestPractices.js +60 -0
  33. package/dist/agent/bestPractices.js.map +1 -0
  34. package/dist/agent/mcpTools.d.ts +24 -0
  35. package/dist/agent/mcpTools.js +115 -0
  36. package/dist/agent/mcpTools.js.map +1 -0
  37. package/dist/agent/operations.js +10 -1
  38. package/dist/agent/operations.js.map +1 -1
  39. package/dist/agent/registryTools.d.ts +45 -0
  40. package/dist/agent/registryTools.js +113 -0
  41. package/dist/agent/registryTools.js.map +1 -0
  42. package/dist/agent/session.js +16 -7
  43. package/dist/agent/session.js.map +1 -1
  44. package/dist/agent/tools/fsTools.js +70 -28
  45. package/dist/agent/tools/fsTools.js.map +1 -1
  46. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  47. package/dist/agent/tools/inspectorTool.js +428 -0
  48. package/dist/agent/tools/inspectorTool.js.map +1 -0
  49. package/dist/agent/toolset.d.ts +16 -6
  50. package/dist/agent/toolset.js +17 -7
  51. package/dist/agent/toolset.js.map +1 -1
  52. package/dist/agent/types.d.ts +2 -0
  53. package/dist/components/Application.d.ts +73 -18
  54. package/dist/components/Application.js +475 -64
  55. package/dist/components/Application.js.map +1 -1
  56. package/dist/components/OptionsWatcher.d.ts +1 -1
  57. package/dist/components/OptionsWatcher.js +65 -2
  58. package/dist/components/OptionsWatcher.js.map +1 -1
  59. package/dist/components/Scope.d.ts +8 -6
  60. package/dist/components/Scope.js +22 -6
  61. package/dist/components/Scope.js.map +1 -1
  62. package/dist/components/componentLoader.js +5 -1
  63. package/dist/components/componentLoader.js.map +1 -1
  64. package/dist/components/componentSecrets.d.ts +33 -7
  65. package/dist/components/componentSecrets.js +473 -59
  66. package/dist/components/componentSecrets.js.map +1 -1
  67. package/dist/components/deploymentRecorder.d.ts +2 -2
  68. package/dist/components/deploymentRecorder.js +1 -1
  69. package/dist/components/deploymentRecorder.js.map +1 -1
  70. package/dist/components/gitCredentialHelper.d.ts +1 -0
  71. package/dist/components/gitCredentialHelper.js +113 -0
  72. package/dist/components/gitCredentialHelper.js.map +1 -0
  73. package/dist/components/gitCredentialServer.d.ts +33 -0
  74. package/dist/components/gitCredentialServer.js +236 -0
  75. package/dist/components/gitCredentialServer.js.map +1 -0
  76. package/dist/components/mcp/tools/application.d.ts +19 -0
  77. package/dist/components/mcp/tools/application.js +180 -22
  78. package/dist/components/mcp/tools/application.js.map +1 -1
  79. package/dist/components/mcp/tools/operations.d.ts +13 -0
  80. package/dist/components/mcp/tools/operations.js +1 -0
  81. package/dist/components/mcp/tools/operations.js.map +1 -1
  82. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  83. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  84. package/dist/components/operations.js +23 -21
  85. package/dist/components/operations.js.map +1 -1
  86. package/dist/components/operationsValidation.js +84 -21
  87. package/dist/components/operationsValidation.js.map +1 -1
  88. package/dist/components/secretOperations.d.ts +33 -11
  89. package/dist/components/secretOperations.js +90 -26
  90. package/dist/components/secretOperations.js.map +1 -1
  91. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  92. package/dist/config/harperConfigEnvVars.js +95 -5
  93. package/dist/config/harperConfigEnvVars.js.map +1 -1
  94. package/dist/dataLayer/harperBridge/ResourceBridge.js +8 -0
  95. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  96. package/dist/index.d.ts +4 -0
  97. package/dist/index.js +14 -1
  98. package/dist/index.js.map +1 -1
  99. package/dist/resources/Resource.d.ts +19 -0
  100. package/dist/resources/Resource.js +98 -4
  101. package/dist/resources/Resource.js.map +1 -1
  102. package/dist/resources/Table.d.ts +12 -1
  103. package/dist/resources/Table.js +269 -53
  104. package/dist/resources/Table.js.map +1 -1
  105. package/dist/resources/analytics/read.js +28 -25
  106. package/dist/resources/analytics/read.js.map +1 -1
  107. package/dist/resources/databases.js +21 -0
  108. package/dist/resources/databases.js.map +1 -1
  109. package/dist/resources/defineResource.d.ts +180 -0
  110. package/dist/resources/defineResource.js +505 -0
  111. package/dist/resources/defineResource.js.map +1 -0
  112. package/dist/resources/defineTable.d.ts +221 -0
  113. package/dist/resources/defineTable.js +227 -0
  114. package/dist/resources/defineTable.js.map +1 -0
  115. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  116. package/dist/resources/jsonSchemaTypes.js +12 -4
  117. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  118. package/dist/resources/openApi.js +69 -13
  119. package/dist/resources/openApi.js.map +1 -1
  120. package/dist/resources/search.js +6 -8
  121. package/dist/resources/search.js.map +1 -1
  122. package/dist/server/graphqlQuerying.js +4 -2
  123. package/dist/server/graphqlQuerying.js.map +1 -1
  124. package/dist/server/http.d.ts +12 -0
  125. package/dist/server/http.js +35 -15
  126. package/dist/server/http.js.map +1 -1
  127. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  128. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  129. package/dist/server/serverHelpers/uwsServer.js +23 -0
  130. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  131. package/dist/sqlEngine/binder/bind.d.ts +72 -0
  132. package/dist/sqlEngine/binder/bind.js +289 -0
  133. package/dist/sqlEngine/binder/bind.js.map +1 -0
  134. package/dist/sqlEngine/config.d.ts +35 -0
  135. package/dist/sqlEngine/config.js +62 -0
  136. package/dist/sqlEngine/config.js.map +1 -0
  137. package/dist/sqlEngine/diff/differential.d.ts +23 -0
  138. package/dist/sqlEngine/diff/differential.js +90 -0
  139. package/dist/sqlEngine/diff/differential.js.map +1 -0
  140. package/dist/sqlEngine/errors.d.ts +22 -0
  141. package/dist/sqlEngine/errors.js +36 -0
  142. package/dist/sqlEngine/errors.js.map +1 -0
  143. package/dist/sqlEngine/executor/runMutation.d.ts +35 -0
  144. package/dist/sqlEngine/executor/runMutation.js +352 -0
  145. package/dist/sqlEngine/executor/runMutation.js.map +1 -0
  146. package/dist/sqlEngine/executor/runSelect.d.ts +7 -0
  147. package/dist/sqlEngine/executor/runSelect.js +14 -0
  148. package/dist/sqlEngine/executor/runSelect.js.map +1 -0
  149. package/dist/sqlEngine/expressions/compile.d.ts +24 -0
  150. package/dist/sqlEngine/expressions/compile.js +326 -0
  151. package/dist/sqlEngine/expressions/compile.js.map +1 -0
  152. package/dist/sqlEngine/functions/aggregates.d.ts +12 -0
  153. package/dist/sqlEngine/functions/aggregates.js +299 -0
  154. package/dist/sqlEngine/functions/aggregates.js.map +1 -0
  155. package/dist/sqlEngine/functions/registry.d.ts +36 -0
  156. package/dist/sqlEngine/functions/registry.js +33 -0
  157. package/dist/sqlEngine/functions/registry.js.map +1 -0
  158. package/dist/sqlEngine/functions/standard.d.ts +9 -0
  159. package/dist/sqlEngine/functions/standard.js +86 -0
  160. package/dist/sqlEngine/functions/standard.js.map +1 -0
  161. package/dist/sqlEngine/index.d.ts +29 -0
  162. package/dist/sqlEngine/index.js +62 -0
  163. package/dist/sqlEngine/index.js.map +1 -0
  164. package/dist/sqlEngine/logical/build.d.ts +20 -0
  165. package/dist/sqlEngine/logical/build.js +326 -0
  166. package/dist/sqlEngine/logical/build.js.map +1 -0
  167. package/dist/sqlEngine/logical/op.d.ts +105 -0
  168. package/dist/sqlEngine/logical/op.js +10 -0
  169. package/dist/sqlEngine/logical/op.js.map +1 -0
  170. package/dist/sqlEngine/optimizer/joinAnalysis.d.ts +37 -0
  171. package/dist/sqlEngine/optimizer/joinAnalysis.js +144 -0
  172. package/dist/sqlEngine/optimizer/joinAnalysis.js.map +1 -0
  173. package/dist/sqlEngine/optimizer/optimize.d.ts +13 -0
  174. package/dist/sqlEngine/optimizer/optimize.js +53 -0
  175. package/dist/sqlEngine/optimizer/optimize.js.map +1 -0
  176. package/dist/sqlEngine/optimizer/ruleEngine.d.ts +7 -0
  177. package/dist/sqlEngine/optimizer/ruleEngine.js +26 -0
  178. package/dist/sqlEngine/optimizer/ruleEngine.js.map +1 -0
  179. package/dist/sqlEngine/optimizer/rules/limitPushdown.d.ts +19 -0
  180. package/dist/sqlEngine/optimizer/rules/limitPushdown.js +55 -0
  181. package/dist/sqlEngine/optimizer/rules/limitPushdown.js.map +1 -0
  182. package/dist/sqlEngine/optimizer/rules/planJoins.d.ts +19 -0
  183. package/dist/sqlEngine/optimizer/rules/planJoins.js +62 -0
  184. package/dist/sqlEngine/optimizer/rules/planJoins.js.map +1 -0
  185. package/dist/sqlEngine/optimizer/rules/predicateNormalize.d.ts +18 -0
  186. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js +80 -0
  187. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js.map +1 -0
  188. package/dist/sqlEngine/optimizer/rules/predicatePushdown.d.ts +11 -0
  189. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js +48 -0
  190. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js.map +1 -0
  191. package/dist/sqlEngine/optimizer/rules/projectionPushdown.d.ts +23 -0
  192. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js +160 -0
  193. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js.map +1 -0
  194. package/dist/sqlEngine/optimizer/rules/sortFromIndex.d.ts +14 -0
  195. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js +49 -0
  196. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js.map +1 -0
  197. package/dist/sqlEngine/optimizer/rules/validateScannable.d.ts +15 -0
  198. package/dist/sqlEngine/optimizer/rules/validateScannable.js +60 -0
  199. package/dist/sqlEngine/optimizer/rules/validateScannable.js.map +1 -0
  200. package/dist/sqlEngine/optimizer/whereToConditions.d.ts +85 -0
  201. package/dist/sqlEngine/optimizer/whereToConditions.js +484 -0
  202. package/dist/sqlEngine/optimizer/whereToConditions.js.map +1 -0
  203. package/dist/sqlEngine/parser/ast.d.ts +139 -0
  204. package/dist/sqlEngine/parser/ast.js +10 -0
  205. package/dist/sqlEngine/parser/ast.js.map +1 -0
  206. package/dist/sqlEngine/parser/normalizer.d.ts +28 -0
  207. package/dist/sqlEngine/parser/normalizer.js +413 -0
  208. package/dist/sqlEngine/parser/normalizer.js.map +1 -0
  209. package/dist/sqlEngine/parser/parse.d.ts +14 -0
  210. package/dist/sqlEngine/parser/parse.js +20 -0
  211. package/dist/sqlEngine/parser/parse.js.map +1 -0
  212. package/dist/sqlEngine/physical/PhysicalDistinct.d.ts +9 -0
  213. package/dist/sqlEngine/physical/PhysicalDistinct.js +29 -0
  214. package/dist/sqlEngine/physical/PhysicalDistinct.js.map +1 -0
  215. package/dist/sqlEngine/physical/PhysicalFilter.d.ts +10 -0
  216. package/dist/sqlEngine/physical/PhysicalFilter.js +25 -0
  217. package/dist/sqlEngine/physical/PhysicalFilter.js.map +1 -0
  218. package/dist/sqlEngine/physical/PhysicalHashAggregate.d.ts +27 -0
  219. package/dist/sqlEngine/physical/PhysicalHashAggregate.js +100 -0
  220. package/dist/sqlEngine/physical/PhysicalHashAggregate.js.map +1 -0
  221. package/dist/sqlEngine/physical/PhysicalHashJoin.d.ts +29 -0
  222. package/dist/sqlEngine/physical/PhysicalHashJoin.js +93 -0
  223. package/dist/sqlEngine/physical/PhysicalHashJoin.js.map +1 -0
  224. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.d.ts +36 -0
  225. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js +78 -0
  226. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js.map +1 -0
  227. package/dist/sqlEngine/physical/PhysicalIndexScan.d.ts +22 -0
  228. package/dist/sqlEngine/physical/PhysicalIndexScan.js +77 -0
  229. package/dist/sqlEngine/physical/PhysicalIndexScan.js.map +1 -0
  230. package/dist/sqlEngine/physical/PhysicalLimit.d.ts +5 -0
  231. package/dist/sqlEngine/physical/PhysicalLimit.js +28 -0
  232. package/dist/sqlEngine/physical/PhysicalLimit.js.map +1 -0
  233. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.d.ts +20 -0
  234. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js +51 -0
  235. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js.map +1 -0
  236. package/dist/sqlEngine/physical/PhysicalProject.d.ts +18 -0
  237. package/dist/sqlEngine/physical/PhysicalProject.js +117 -0
  238. package/dist/sqlEngine/physical/PhysicalProject.js.map +1 -0
  239. package/dist/sqlEngine/physical/PhysicalQualify.d.ts +13 -0
  240. package/dist/sqlEngine/physical/PhysicalQualify.js +29 -0
  241. package/dist/sqlEngine/physical/PhysicalQualify.js.map +1 -0
  242. package/dist/sqlEngine/physical/PhysicalSort.d.ts +12 -0
  243. package/dist/sqlEngine/physical/PhysicalSort.js +59 -0
  244. package/dist/sqlEngine/physical/PhysicalSort.js.map +1 -0
  245. package/dist/sqlEngine/physical/op.d.ts +11 -0
  246. package/dist/sqlEngine/physical/op.js +9 -0
  247. package/dist/sqlEngine/physical/op.js.map +1 -0
  248. package/dist/sqlEngine/physical/plan.d.ts +23 -0
  249. package/dist/sqlEngine/physical/plan.js +162 -0
  250. package/dist/sqlEngine/physical/plan.js.map +1 -0
  251. package/dist/sqlEngine/router.d.ts +26 -0
  252. package/dist/sqlEngine/router.js +48 -0
  253. package/dist/sqlEngine/router.js.map +1 -0
  254. package/dist/sqlEngine/types.d.ts +37 -0
  255. package/dist/sqlEngine/types.js +13 -0
  256. package/dist/sqlEngine/types.js.map +1 -0
  257. package/dist/sqlTranslator/index.js +7 -1
  258. package/dist/sqlTranslator/index.js.map +1 -1
  259. package/dist/utility/errors/hdbError.d.ts +21 -0
  260. package/dist/utility/errors/hdbError.js +23 -1
  261. package/dist/utility/errors/hdbError.js.map +1 -1
  262. package/dist/validation/configValidator.js +22 -6
  263. package/dist/validation/configValidator.js.map +1 -1
  264. package/dist/validation/deleteValidator.js +10 -2
  265. package/dist/validation/deleteValidator.js.map +1 -1
  266. package/index.ts +33 -0
  267. package/npm-shrinkwrap.json +9197 -15066
  268. package/package.json +3 -1
  269. package/resources/DESIGN.md +43 -15
  270. package/resources/Resource.ts +104 -4
  271. package/resources/Table.ts +298 -74
  272. package/resources/analytics/read.ts +30 -25
  273. package/resources/databases.ts +24 -0
  274. package/resources/defineResource.ts +651 -0
  275. package/resources/defineTable.ts +407 -0
  276. package/resources/jsonSchemaTypes.ts +12 -4
  277. package/resources/openApi.ts +68 -16
  278. package/resources/search.ts +5 -8
  279. package/server/graphqlQuerying.ts +4 -2
  280. package/server/http.ts +40 -16
  281. package/server/serverHelpers/serverUtilities.ts +19 -6
  282. package/server/serverHelpers/uwsServer.ts +24 -0
  283. package/sqlTranslator/index.ts +16 -6
  284. package/studio/web/assets/{Chat-Cv_2paZE.js → Chat-BZks8dVF.js} +2 -2
  285. package/studio/web/assets/{Chat-Cv_2paZE.js.map → Chat-BZks8dVF.js.map} +1 -1
  286. package/studio/web/assets/{FloatingChat-CPyPIcVR.js → FloatingChat-Dic8paVO.js} +4 -4
  287. package/studio/web/assets/{FloatingChat-CPyPIcVR.js.map → FloatingChat-Dic8paVO.js.map} +1 -1
  288. package/studio/web/assets/{applications-C0jT2vdZ.js → applications-uOXkeUIN.js} +2 -2
  289. package/studio/web/assets/{applications-C0jT2vdZ.js.map → applications-uOXkeUIN.js.map} +1 -1
  290. package/studio/web/assets/{index-D_GKOkhn.js → index-i-2wrKhv.js} +6 -6
  291. package/studio/web/assets/{index-D_GKOkhn.js.map → index-i-2wrKhv.js.map} +1 -1
  292. package/studio/web/assets/{index.lazy-CAmCIA65.js → index.lazy-Csk8eCoB.js} +4 -4
  293. package/studio/web/assets/{index.lazy-CAmCIA65.js.map → index.lazy-Csk8eCoB.js.map} +1 -1
  294. package/studio/web/assets/{profile-CaF-4aZe.js → profile-Sb3mGDl6.js} +2 -2
  295. package/studio/web/assets/{profile-CaF-4aZe.js.map → profile-Sb3mGDl6.js.map} +1 -1
  296. package/studio/web/assets/{setComponentFile-DLW1DHCt.js → setComponentFile-BgZcaPJ2.js} +2 -2
  297. package/studio/web/assets/{setComponentFile-DLW1DHCt.js.map → setComponentFile-BgZcaPJ2.js.map} +1 -1
  298. package/studio/web/assets/{setup-TGCErIhq.js → setup-DKtlLgmT.js} +2 -2
  299. package/studio/web/assets/{setup-TGCErIhq.js.map → setup-DKtlLgmT.js.map} +1 -1
  300. package/studio/web/assets/{status-DG0Maoao.js → status-B45iLeug.js} +2 -2
  301. package/studio/web/assets/{status-DG0Maoao.js.map → status-B45iLeug.js.map} +1 -1
  302. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js → swagger-ui-react-Csu4026e.js} +2 -2
  303. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js.map → swagger-ui-react-Csu4026e.js.map} +1 -1
  304. package/studio/web/assets/{tsMode-DJ6Sl24Q.js → tsMode-DVgxUr_l.js} +2 -2
  305. package/studio/web/assets/{tsMode-DJ6Sl24Q.js.map → tsMode-DVgxUr_l.js.map} +1 -1
  306. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js → useEntityRestURL-yfDQMV1f.js} +2 -2
  307. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js.map → useEntityRestURL-yfDQMV1f.js.map} +1 -1
  308. package/studio/web/index.html +1 -1
  309. package/utility/errors/hdbError.ts +32 -0
  310. package/validation/configValidator.ts +23 -6
  311. package/validation/deleteValidator.ts +11 -2
@@ -3,6 +3,15 @@ import { getConfigObj, getConfigValue, getConfigPath } from '../config/configUti
3
3
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
4
4
  import logger from '../utility/logging/harper_logger.ts';
5
5
  import { broadcastDeployStart, broadcastDeployEnd } from './deployLifecycle.ts';
6
+ import type { CredentialReference, ResolvedCredential, ResolvedRegistryCredential } from './secretOperations.ts';
7
+ import {
8
+ GIT_CREDENTIAL_SOCKET_ENV,
9
+ startGitCredentialSession,
10
+ type GitCredentialSession,
11
+ type ResolvedGitCredential,
12
+ } from './gitCredentialServer.ts';
13
+ import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
14
+ import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
6
15
 
7
16
  import { basename, dirname, extname, join } from 'node:path';
8
17
  import {
@@ -22,13 +31,14 @@ import {
22
31
  import { spawn } from 'node:child_process';
23
32
  import { tmpdir } from 'node:os';
24
33
  import { randomUUID } from 'node:crypto';
25
- import { createReadStream, existsSync, readdirSync } from 'node:fs';
34
+ import { createReadStream, existsSync } from 'node:fs';
26
35
  import { Readable } from 'node:stream';
27
36
  import { pipeline } from 'node:stream/promises';
28
37
  import { StringDecoder } from 'node:string_decoder';
29
38
 
30
39
  import { extract } from 'tar-fs';
31
40
  import gunzip from 'gunzip-maybe';
41
+ import semver from 'semver';
32
42
 
33
43
  interface ApplicationConfig {
34
44
  // define known config properties
@@ -38,10 +48,10 @@ interface ApplicationConfig {
38
48
  timeout?: number;
39
49
  allowInstallScripts?: boolean;
40
50
  };
41
- // Private-registry auth in reference form only — each entry names an hdb_secret row, never a
42
- // token. Recorded by deploy_component so every (cold) install — reboot, new peer, rollback —
43
- // re-resolves the credential from the store rather than needing it re-supplied.
44
- registryAuth?: { registry: string; secret: string; scope?: string }[];
51
+ // Deploy credentials in reference form only — each entry names an hdb_secret row, never a token.
52
+ // Recorded by deploy_component so every (cold) install — reboot, new peer, rollback — re-resolves
53
+ // the credential from the store rather than needing it re-supplied.
54
+ credentials?: CredentialReference[];
45
55
  // an application config can have other arbitrary properties
46
56
  [key: string]: unknown;
47
57
  }
@@ -78,18 +88,19 @@ export class InvalidInstallTimeoutError extends TypeError {
78
88
  }
79
89
  }
80
90
 
81
- export class InvalidRegistryAuthPropertyError extends TypeError {
82
- constructor(applicationName: string, registryAuth: unknown) {
91
+ export class InvalidCredentialsPropertyError extends TypeError {
92
+ constructor(applicationName: string, credentials: unknown) {
83
93
  super(
84
- `Invalid 'registryAuth' property for application ${applicationName}: expected array, got ${typeof registryAuth}`
94
+ `Invalid 'credentials' property for application ${applicationName}: expected array, got ${typeof credentials}`
85
95
  );
86
96
  }
87
97
  }
88
98
 
89
- export class InvalidRegistryAuthEntryError extends TypeError {
99
+ export class InvalidCredentialEntryError extends TypeError {
90
100
  constructor(applicationName: string) {
91
101
  super(
92
- `Invalid 'registryAuth' entry for application ${applicationName}: expected { registry, secret, scope? } reference`
102
+ `Invalid 'credentials' entry for application ${applicationName}: expected a { registry, secret, scope? } ` +
103
+ `or { host, secret, username? } reference`
93
104
  );
94
105
  }
95
106
  }
@@ -131,21 +142,28 @@ export function assertApplicationConfig(
131
142
  );
132
143
  }
133
144
  }
134
- if ('registryAuth' in applicationConfig && applicationConfig.registryAuth !== undefined) {
135
- const entries = applicationConfig.registryAuth;
145
+ if ('credentials' in applicationConfig && applicationConfig.credentials !== undefined) {
146
+ const entries = applicationConfig.credentials;
136
147
  if (!Array.isArray(entries)) {
137
- throw new InvalidRegistryAuthPropertyError(applicationName, entries);
148
+ throw new InvalidCredentialsPropertyError(applicationName, entries);
138
149
  }
139
150
  for (const entry of entries) {
140
151
  // Config carries references only — a literal `token` here would mean a plaintext credential
141
- // was persisted to disk, which the deploy path is designed to prevent.
152
+ // was persisted to disk, which the deploy path is designed to prevent. An entry is npm
153
+ // registry auth (`registry`) XOR git host auth (`host`); anything else is not a credential we
154
+ // know how to resolve, so reject it rather than install without it. An entry carrying both
155
+ // discriminators, or a stray `token`, is rejected rather than coerced into a single kind.
156
+ const record = entry as any;
157
+ const hasRegistry = typeof record?.registry === 'string';
158
+ const hasHost = typeof record?.host === 'string';
142
159
  if (
143
160
  typeof entry !== 'object' ||
144
161
  entry === null ||
145
- typeof (entry as any).registry !== 'string' ||
146
- typeof (entry as any).secret !== 'string'
162
+ hasRegistry === hasHost || // neither, or both
163
+ typeof record.secret !== 'string' ||
164
+ record.token !== undefined
147
165
  ) {
148
- throw new InvalidRegistryAuthEntryError(applicationName);
166
+ throw new InvalidCredentialEntryError(applicationName);
149
167
  }
150
168
  }
151
169
  }
@@ -166,11 +184,231 @@ export function isSSHAuthFailure(stderr: string): boolean {
166
184
  );
167
185
  }
168
186
 
187
+ // Git-reference package identifier forms recognized below for the credentialed-clone path: the
188
+ // npm git-url spec forms this repo's own derivePackageIdentifier can produce for a git host
189
+ // credential. An identifier that doesn't match falls back to `npm pack --ignore-scripts` (best
190
+ // effort, same as before this fix — not a regression for a form this can't safely reclone).
191
+ const GIT_URL_PREFIX = /^git\+(ssh|https?|file):\/\//i;
192
+ const GIT_PROTOCOL_PREFIX = /^git:\/\//i;
193
+
194
+ // Hosted-git shorthand prefixes: the same table derivePackageIdentifier implicitly relies on when it
195
+ // defaults a bare `owner/repo` to `github:owner/repo`, plus the other hosts npm's own shorthand spec
196
+ // recognizes. A bare `owner/repo` never reaches parseGitReference directly — the Application
197
+ // constructor always runs packageIdentifier through derivePackageIdentifier first, which turns it
198
+ // into `github:owner/repo` — so only the prefixed forms need handling here.
199
+ const HOSTED_GIT_HOSTS: Record<string, string> = {
200
+ github: 'github.com',
201
+ gitlab: 'gitlab.com',
202
+ bitbucket: 'bitbucket.org',
203
+ gist: 'gist.github.com',
204
+ };
205
+ const HOSTED_GIT_PREFIX = /^(github|gitlab|bitbucket|gist):(.+)$/i;
206
+
207
+ interface GitReference {
208
+ cloneUrl: string;
209
+ committish?: string;
210
+ }
211
+
212
+ /**
213
+ * Parses a `git+ssh://…`/`git+https://…`/`git+http://…`/`git+file://…`/`git://…`, or hosted-git
214
+ * shorthand (`github:owner/repo`, `gitlab:owner/repo`, `bitbucket:owner/repo`, `gist:id`) package
215
+ * identifier into a plain clone URL and optional committish, without depending on npm's own git-spec
216
+ * parser (npm-package-arg/hosted-git-info aren't dependencies of this repo). Returns null for any
217
+ * other form.
218
+ */
219
+ export function parseGitReference(packageIdentifier: string): GitReference | null {
220
+ const hashIndex = packageIdentifier.indexOf('#');
221
+ const committish = hashIndex === -1 ? undefined : packageIdentifier.slice(hashIndex + 1);
222
+ const spec = hashIndex === -1 ? packageIdentifier : packageIdentifier.slice(0, hashIndex);
223
+ if (GIT_URL_PREFIX.test(spec)) return { cloneUrl: spec.slice('git+'.length), committish };
224
+ if (GIT_PROTOCOL_PREFIX.test(spec)) return { cloneUrl: spec, committish };
225
+ const hostedMatch = HOSTED_GIT_PREFIX.exec(spec);
226
+ if (hostedMatch) {
227
+ const [, prefix, path] = hostedMatch;
228
+ const host = HOSTED_GIT_HOSTS[prefix.toLowerCase()];
229
+ // A gist clone URL is keyed by id alone; an optional `owner/` in the shorthand (npm accepts
230
+ // `gist:[owner/]id`) has no place in the URL and is dropped.
231
+ const urlPath =
232
+ prefix.toLowerCase() === 'gist' && path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path;
233
+ return { cloneUrl: `https://${host}/${urlPath}.git`, committish };
234
+ }
235
+ return null;
236
+ }
237
+
238
+ const NEUTRALIZED_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepack', 'prepare'];
239
+
240
+ // npm's hosted-git-info convention (documented in #1799's own worked example, `github:my-org/my-app#semver:v1.2.3`)
241
+ // for a committish that names a semver range instead of a literal ref. `git checkout` has no notion
242
+ // of this syntax, so it must be resolved to a concrete tag before checkout.
243
+ const SEMVER_COMMITTISH_PREFIX = /^semver:/i;
244
+
245
+ // Matches npm's own git-tag-to-version extraction (@npmcli/git's lines-to-revs.js): a trailing
246
+ // `1.2.3`-shaped suffix, optionally `v`-prefixed, with anything ahead of it ignored — so a tag like
247
+ // `release-v1.2.3` resolves the same way npm's own git-dependency installer treats it.
248
+ const TAG_VERSION_SUFFIX = /v?(\d+\.\d+\.\d+(?:[-+].+)?)$/;
249
+
250
+ // A conservative safe-charset check on the FULL tag name — not just the version-shaped suffix
251
+ // TAG_VERSION_SUFFIX matches. git's own ref-name rules (`check-ref-format`) permit shell
252
+ // metacharacters like `$`, backticks, `;`, `&`, `|`, `(`, `)` in a tag name; a prefix ahead of the
253
+ // matched suffix (e.g. the `release-` in `release-v1.2.3`) is otherwise unconstrained. The resolved
254
+ // name is later checked out via `nonInteractiveSpawn`, which runs through a shell with no argument
255
+ // escaping, so a tag such as `$(id)v1.2.3` in the cloned repo would otherwise execute on checkout.
256
+ // A tag failing this check is excluded from resolution entirely rather than sanitized or escaped —
257
+ // this only has to reject shell metacharacters, not accept every ref git itself would allow.
258
+ const SAFE_TAG_NAME = /^[\w][\w.-]*$/;
259
+
260
+ /**
261
+ * Resolves a `semver:<range>` committish (e.g. `semver:v1.2.3`, `semver:^1.2.3`) against the tags of
262
+ * the given clone to a concrete, unambiguous tag ref. Returns the committish unchanged if it isn't a
263
+ * semver-range committish.
264
+ */
265
+ async function resolveCommittish(application: Application, committish: string, cloneDir: string): Promise<string> {
266
+ if (!SEMVER_COMMITTISH_PREFIX.test(committish)) return committish;
267
+ // npm's own package-arg parser URL-decodes the value after `semver:` (a range containing `^`/`~`
268
+ // can arrive percent-encoded, e.g. `#semver:%5E1.0.0`); do the same rather than evaluating it raw.
269
+ const rawRange = committish.slice('semver:'.length);
270
+ let range: string;
271
+ try {
272
+ range = decodeURIComponent(rawRange);
273
+ } catch {
274
+ range = rawRange;
275
+ }
276
+
277
+ // `git tag --list` rather than `for-each-ref --format=...`: nonInteractiveSpawn runs through a
278
+ // shell, and a `%(...)` format string is unsafe to pass through one.
279
+ const { code, stdout, stderr } = await nonInteractiveSpawn(application.name, 'git', ['tag', '--list'], cloneDir);
280
+ if (code !== 0) {
281
+ throw new Error(`Failed to list tags to resolve '${committish}' for ${application.packageIdentifier}: ${stderr}`);
282
+ }
283
+ const tags = stdout
284
+ .split('\n')
285
+ .map((tag) => tag.trim())
286
+ .filter(Boolean);
287
+
288
+ // A tag can be a bare version (`v1.2.3`) or carry a prefix ahead of one (`release-v1.2.3`); only
289
+ // the trailing version-shaped suffix is evaluated against the range, but the ORIGINAL tag name is
290
+ // what gets checked out.
291
+ const versionToTag = new Map<string, string>();
292
+ for (const tag of tags) {
293
+ if (!SAFE_TAG_NAME.test(tag)) continue;
294
+ const match = tag.match(TAG_VERSION_SUFFIX);
295
+ const version = match && semver.valid(match[1], { loose: true });
296
+ if (version) versionToTag.set(semver.clean(match[1], { loose: true }) as string, tag);
297
+ }
298
+
299
+ const resolvedVersion = semver.maxSatisfying([...versionToTag.keys()], range, { loose: true });
300
+ if (!resolvedVersion) {
301
+ throw new Error(
302
+ `Failed to resolve '${committish}' for ${application.packageIdentifier}: no tag satisfies range '${range}'. ` +
303
+ (tags.length ? `Tags found: ${tags.join(', ')}` : 'No tags were found in the repository.')
304
+ );
305
+ }
306
+ // refs/tags/<name> rather than the bare name: an unqualified `git checkout <name>` resolves to a
307
+ // same-named branch first if one exists, which would silently package the wrong commit.
308
+ return `refs/tags/${versionToTag.get(resolvedVersion)}`;
309
+ }
310
+
311
+ /**
312
+ * Clones a git reference ourselves — with the credential session's env, so the clone itself can
313
+ * still authenticate — and packs the checkout with its lifecycle scripts stripped, instead of
314
+ * letting `npm pack <git-url>` clone and pack it directly.
315
+ *
316
+ * `--ignore-scripts` is not a reliable suppression for a git source's own `prepare` script: pacote's
317
+ * DirFetcher runs it unconditionally on npm versions before 11.0.0 (the
318
+ * `if (this.opts.ignoreScripts) return` guard was only added upstream in npm 11) — which is exactly
319
+ * what Node 22's bundled npm (10.9.x) ships. A credentialed clone can't depend on which npm version
320
+ * happens to be on PATH, since a script running while the credential socket is reachable is exactly
321
+ * what this feature exists to prevent — so the script is removed from the checkout before packing,
322
+ * which works regardless of npm version.
323
+ */
324
+ async function packGitReferenceWithoutScripts(
325
+ application: Application,
326
+ gitRef: GitReference,
327
+ parentDirPath: string
328
+ ): Promise<string> {
329
+ const cloneDir = await mkdtemp(join(tmpdir(), 'harper-git-clone-'));
330
+ try {
331
+ const { code: cloneCode, stderr: cloneStderr } = await nonInteractiveSpawn(
332
+ application.name,
333
+ 'git',
334
+ ['clone', '--quiet', gitRef.cloneUrl, cloneDir],
335
+ parentDirPath,
336
+ undefined,
337
+ undefined,
338
+ undefined,
339
+ application.gitCredentialEnv
340
+ );
341
+ if (cloneCode !== 0) {
342
+ if (isSSHAuthFailure(cloneStderr)) {
343
+ throw new Error(
344
+ `Failed to deploy private repository ${application.packageIdentifier}: SSH access failed. Verify the repository URL, configure an SSH key on this Harper instance, ensure the key has access to the target repository, and confirm the host is present in the ssh/known_hosts file.`,
345
+ { cause: new Error(cloneStderr) }
346
+ );
347
+ }
348
+ throw new Error(`Failed to clone package ${application.packageIdentifier}: ${cloneStderr}`);
349
+ }
350
+
351
+ if (gitRef.committish) {
352
+ const committish = await resolveCommittish(application, gitRef.committish, cloneDir);
353
+ const { code: checkoutCode, stderr: checkoutStderr } = await nonInteractiveSpawn(
354
+ application.name,
355
+ 'git',
356
+ ['checkout', '--quiet', committish],
357
+ cloneDir
358
+ );
359
+ if (checkoutCode !== 0) {
360
+ throw new Error(`Failed to check out '${committish}' for ${application.packageIdentifier}: ${checkoutStderr}`);
361
+ }
362
+ }
363
+
364
+ // Strip the checkout's own lifecycle scripts before packing — the mechanism above only
365
+ // suppresses npm's git-clone behavior; a plain `npm pack <local-dir>` still runs `prepare`
366
+ // unless it's gone from the manifest.
367
+ const manifestPath = join(cloneDir, 'package.json');
368
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
369
+ if (manifest.scripts) {
370
+ for (const scriptName of NEUTRALIZED_LIFECYCLE_SCRIPTS) delete manifest.scripts[scriptName];
371
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
372
+ }
373
+
374
+ const { stdout, code, stderr } = await nonInteractiveSpawn(
375
+ application.name,
376
+ 'npm',
377
+ ['pack', '--json', '--ignore-scripts', cloneDir],
378
+ parentDirPath,
379
+ undefined,
380
+ undefined,
381
+ application.npmUserconfigPath
382
+ );
383
+ if (code !== 0) {
384
+ throw new Error(`Failed to pack package ${application.packageIdentifier}: ${stderr}`);
385
+ }
386
+ let packResult: Array<{ filename: string }>;
387
+ try {
388
+ packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
389
+ } catch (err) {
390
+ throw new Error(
391
+ `Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
392
+ );
393
+ }
394
+ if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
395
+ throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
396
+ }
397
+ return join(parentDirPath, packResult[0].filename);
398
+ } finally {
399
+ await rm(cloneDir, { recursive: true, force: true });
400
+ }
401
+ }
402
+
169
403
  // Hidden directory under the components root holding component versions renamed aside
170
404
  // during a deploy swap (see extractApplication). The leading dot keeps
171
405
  // loadComponentDirectories from loading its contents as components.
172
406
  export const ASIDE_STAGING_DIR = '.deploy-aside';
173
407
 
408
+ // The credential helper git executes for a private git-reference deploy. It ships alongside this
409
+ // module (both in source and in dist), holds no secret, and is inert without a live session.
410
+ export const GIT_CREDENTIAL_HELPER_PATH = join(__dirname, 'gitCredentialHelper.js');
411
+
174
412
  /**
175
413
  * Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
176
414
  *
@@ -244,39 +482,72 @@ export async function extractApplication(application: Application) {
244
482
  }
245
483
  }
246
484
  } else {
247
- // `npm pack --json` writes a JSON array describing the packed tarball(s).
248
- const { stdout, code, stderr } = await nonInteractiveSpawn(
249
- application.name,
250
- 'npm',
251
- ['pack', '--json', application.packageIdentifier],
252
- parentDirPath,
253
- undefined,
254
- undefined,
255
- application.npmUserconfigPath
256
- );
257
- if (code !== 0) {
258
- if (isSSHAuthFailure(stderr)) {
485
+ // `npm pack --json` writes a JSON array describing the packed tarball(s). This is also the
486
+ // spawn that clones a git-reference package, so it is the only one given the git credential
487
+ // environment.
488
+ //
489
+ // Packing a git reference is not just a download: npm clones the repo and, if its manifest
490
+ // has a prepare/build/install script, runs `npm install` inside the clone and then that
491
+ // script — so the repo's own code AND its dependencies' install scripts execute on this node,
492
+ // inheriting this spawn's environment. With a credential session live, that is exactly the
493
+ // reach the credential must not have (a transitive dependency's postinstall could ask the
494
+ // socket for a token granted for the top-level repository), so scripts are off for a
495
+ // credentialed clone unless the deploy explicitly opted into them.
496
+ const scriptsDisallowed = application.gitCredentialEnv && !application.install?.allowInstallScripts;
497
+ // `--ignore-scripts` alone isn't a reliable way to enforce that: pacote's DirFetcher runs a
498
+ // git source's `prepare` unconditionally on npm versions before 11.0.0 (see
499
+ // packGitReferenceWithoutScripts), which is exactly what Node 22's bundled npm ships. For a
500
+ // recognized git-reference identifier, clone and pack it ourselves with scripts stripped
501
+ // instead, sidestepping that npm code path entirely.
502
+ const gitRef = scriptsDisallowed ? parseGitReference(application.packageIdentifier) : null;
503
+
504
+ if (gitRef) {
505
+ tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
506
+ } else {
507
+ const packArgs = ['pack', '--json', application.packageIdentifier];
508
+ if (scriptsDisallowed) {
509
+ packArgs.push('--ignore-scripts');
510
+ } else if (application.gitCredentialEnv) {
511
+ application.logger.warn(
512
+ `Deploying ${application.name} from a git reference with install scripts enabled: the repository's ` +
513
+ `prepare/build scripts and its dependencies' install scripts run on this node during the clone and ` +
514
+ `can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
515
+ );
516
+ }
517
+ const { stdout, code, stderr } = await nonInteractiveSpawn(
518
+ application.name,
519
+ 'npm',
520
+ packArgs,
521
+ parentDirPath,
522
+ undefined,
523
+ undefined,
524
+ application.npmUserconfigPath,
525
+ application.gitCredentialEnv
526
+ );
527
+ if (code !== 0) {
528
+ if (isSSHAuthFailure(stderr)) {
529
+ throw new Error(
530
+ `Failed to deploy private repository ${application.packageIdentifier}: SSH access failed. Verify the repository URL, configure an SSH key on this Harper instance, ensure the key has access to the target repository, and confirm the host is present in the ssh/known_hosts file.`,
531
+ { cause: new Error(stderr) }
532
+ );
533
+ }
534
+ throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
535
+ }
536
+
537
+ let packResult: Array<{ filename: string }>;
538
+ try {
539
+ packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
540
+ } catch (err) {
259
541
  throw new Error(
260
- `Failed to deploy private repository ${application.packageIdentifier}: SSH access failed. Verify the repository URL, configure an SSH key on this Harper instance, ensure the key has access to the target repository, and confirm the host is present in the ssh/known_hosts file.`,
261
- { cause: new Error(stderr) }
542
+ `Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
262
543
  );
263
544
  }
264
- throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
265
- }
545
+ if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
546
+ throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
547
+ }
266
548
 
267
- let packResult: Array<{ filename: string }>;
268
- try {
269
- packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
270
- } catch (err) {
271
- throw new Error(
272
- `Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
273
- );
274
- }
275
- if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
276
- throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
549
+ tarballPath = join(parentDirPath, packResult[0].filename);
277
550
  }
278
-
279
- tarballPath = join(parentDirPath, packResult[0].filename);
280
551
  shouldDeleteTarball = true;
281
552
  tarball = createReadStream(tarballPath);
282
553
  }
@@ -551,7 +822,9 @@ interface ApplicationOptions {
551
822
  packageIdentifier?: string;
552
823
  install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
553
824
  onInstallLine?: OnInstallLine;
554
- registryAuth?: RegistryAuthEntry[];
825
+ // Deploy credentials already resolved to literal tokens, of any kind; partitioned by the
826
+ // constructor into the npm and git halves, which are injected by entirely different mechanisms.
827
+ credentials?: ResolvedCredential[];
555
828
  }
556
829
 
557
830
  export class Application {
@@ -563,21 +836,39 @@ export class Application {
563
836
  dirPath: string;
564
837
  logger: Logger;
565
838
  packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".
566
- // Transient registry auth provided by a deploy. The token is held only in memory and a
567
- // per-deploy `.npmrc`; it is never persisted to config, hdb_deployment, or replicated.
568
- registryAuth?: RegistryAuthEntry[];
839
+ // Transient registry credentials provided by a deploy, already resolved to literal tokens. The
840
+ // token is held only in memory and a per-deploy `.npmrc`; it is never persisted to config,
841
+ // hdb_deployment, or replicated.
842
+ registryCredentials?: ResolvedRegistryCredential[];
843
+ // Transient git-host credentials, likewise resolved to literal tokens and held only in memory:
844
+ // they are served to git over a per-deploy socket (gitCredentialServer.ts), never written anywhere.
845
+ gitCredentials?: ResolvedGitCredential[];
569
846
  // Path to the per-deploy `.npmrc`, set by writeTransientNpmrc() during prepareApplication and
570
- // passed to the spawn calls; undefined when no registry auth was provided.
847
+ // passed to the spawn calls; undefined when no registry credentials were provided.
571
848
  npmUserconfigPath?: string;
572
849
  #npmrcTempDir?: string;
850
+ #gitCredentialSession?: GitCredentialSession;
573
851
 
574
- constructor({ name, payload, packageIdentifier, install, onInstallLine, registryAuth }: ApplicationOptions) {
852
+ constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
575
853
  this.name = name;
576
854
  this.payload = payload;
577
855
  this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
578
856
  this.install = install;
579
857
  this.onInstallLine = onInstallLine;
580
- this.registryAuth = registryAuth;
858
+ // Split by kind: registry credentials go into the transient .npmrc, git credentials into the
859
+ // credential socket. An entry belongs to exactly one of them (the op schema is an xor).
860
+ // secretOperations owns the same predicate, but it is only ever imported from here lazily (it
861
+ // pulls in the datastore), so this stays a local check rather than a boot-time import.
862
+ if (credentials?.length) {
863
+ const registryCredentials = credentials.filter(
864
+ (entry): entry is ResolvedRegistryCredential => (entry as any).registry !== undefined
865
+ );
866
+ const gitCredentials = credentials.filter(
867
+ (entry): entry is ResolvedGitCredential => (entry as any).registry === undefined
868
+ );
869
+ if (registryCredentials.length) this.registryCredentials = registryCredentials;
870
+ if (gitCredentials.length) this.gitCredentials = gitCredentials;
871
+ }
581
872
  const componentsRoot = getConfigPath(CONFIG_PARAMS.COMPONENTSROOT);
582
873
  if (!componentsRoot) throw new Error('componentsRoot is not configured');
583
874
  this.dirPath = join(componentsRoot, name);
@@ -586,7 +877,8 @@ export class Application {
586
877
  }
587
878
 
588
879
  // Write the transient `.npmrc` into a fresh 0700 temp dir (file mode 0600) and record its path
589
- // so the deploy's npm spawns authenticate against the private registry. No-op without registry auth.
880
+ // so the deploy's npm spawns authenticate against the private registry. No-op without registry
881
+ // credentials.
590
882
  //
591
883
  // Because `nonInteractiveSpawn` points npm at this single file (replacing any inherited
592
884
  // npm_config_userconfig), prepend the contents of an already-configured userconfig — e.g. a
@@ -594,7 +886,7 @@ export class Application {
594
886
  // survive. The transient auth is appended last so it wins on conflict (npm honors the last
595
887
  // value for a given key).
596
888
  async writeTransientNpmrc(): Promise<void> {
597
- if (!this.registryAuth?.length) return;
889
+ if (!this.registryCredentials?.length) return;
598
890
  // Defensive: if called more than once, remove the prior temp dir first so it isn't leaked.
599
891
  if (this.#npmrcTempDir) await this.cleanupTransientNpmrc();
600
892
  this.#npmrcTempDir = await mkdtemp(join(tmpdir(), 'harper-npmrc-'));
@@ -610,11 +902,42 @@ export class Application {
610
902
  if (error?.code !== 'ENOENT') throw error;
611
903
  }
612
904
  }
613
- content += buildNpmrcContent(this.registryAuth);
905
+ content += buildNpmrcContent(this.registryCredentials);
614
906
  await writeFile(npmrcPath, content, { mode: 0o600 });
615
907
  this.npmUserconfigPath = npmrcPath;
616
908
  }
617
909
 
910
+ // Environment that lets git reach this deploy's credential socket. Applied ONLY to the spawn that
911
+ // clones the git reference (`npm pack`) — see prepareApplication.
912
+ get gitCredentialEnv(): Record<string, string> | undefined {
913
+ return this.#gitCredentialSession?.env;
914
+ }
915
+
916
+ // Start serving this deploy's git-host credentials from memory. No-op without git credentials.
917
+ async startGitCredentialSession(): Promise<void> {
918
+ if (!this.gitCredentials?.length) return;
919
+ if (this.#gitCredentialSession) await this.cleanupGitCredentialSession();
920
+ this.#gitCredentialSession = await startGitCredentialSession(this.gitCredentials, GIT_CREDENTIAL_HELPER_PATH);
921
+ }
922
+
923
+ // Tear the socket down as soon as the clone is done, so nothing later in the deploy — including
924
+ // the component's own install scripts — can still ask for the credential.
925
+ async cleanupGitCredentialSession(): Promise<void> {
926
+ const session = this.#gitCredentialSession;
927
+ if (!session) return;
928
+ this.#gitCredentialSession = undefined;
929
+ try {
930
+ await session.close();
931
+ } catch (error) {
932
+ // Called from prepareApplication's finally; a throw here would mask the deploy's own error.
933
+ this.logger.warn(`Failed to close git credential session:`, error);
934
+ } finally {
935
+ // Drop the in-memory tokens too, so they can't surface in a later heap dump or error
936
+ // serialization of this Application instance.
937
+ this.gitCredentials = undefined;
938
+ }
939
+ }
940
+
618
941
  // Remove the transient `.npmrc` (and its temp dir) once the deploy's npm work is done.
619
942
  async cleanupTransientNpmrc(): Promise<void> {
620
943
  if (!this.#npmrcTempDir) return;
@@ -629,7 +952,7 @@ export class Application {
629
952
  this.npmUserconfigPath = undefined;
630
953
  // Drop the in-memory token array too, so it can't surface in a later heap dump or error
631
954
  // serialization of this Application instance.
632
- this.registryAuth = undefined;
955
+ this.registryCredentials = undefined;
633
956
  }
634
957
  }
635
958
  }
@@ -674,7 +997,16 @@ export async function prepareApplication(application: Application) {
674
997
  // Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
675
998
  // `npm install` authenticate against the private registry; always remove it afterward.
676
999
  await application.writeTransientNpmrc();
677
- await extractApplication(application);
1000
+ try {
1001
+ // The git credential socket only has to be up for extraction — that is where npm resolves and
1002
+ // clones a git-reference package. Closing it before installApplication means the credential is
1003
+ // already gone by the time the component's dependency tree (and any install script it is
1004
+ // allowed to run) executes.
1005
+ await application.startGitCredentialSession();
1006
+ await extractApplication(application);
1007
+ } finally {
1008
+ await application.cleanupGitCredentialSession();
1009
+ }
678
1010
  await installApplication(application);
679
1011
  } finally {
680
1012
  await application.cleanupTransientNpmrc();
@@ -737,20 +1069,19 @@ export async function installApplications() {
737
1069
  // This will throw if the config is invalid
738
1070
  assertApplicationConfig(name, applicationConfig);
739
1071
 
740
- // Resolve any private-registry auth references from the store so a cold install (fresh
741
- // node, wiped components dir, new peer that never installed) can authenticate without the
742
- // token being re-supplied. Best-effort: if custody isn't available yet or a referenced
743
- // secret is missing, log and install without it (a truly private package then fails in
744
- // npm with its own error) rather than blocking boot.
745
- let resolvedRegistryAuth: RegistryAuthEntry[] | undefined;
746
- if (applicationConfig.registryAuth?.length) {
1072
+ // Resolve any credential references from the store so a cold install (fresh node, wiped
1073
+ // components dir, new peer that never installed) can authenticate without the token being
1074
+ // re-supplied. Best-effort: if custody isn't available yet or a referenced secret is
1075
+ // missing, log and install without it (a truly private package then fails in npm with its
1076
+ // own error) rather than blocking boot.
1077
+ let credentials: ResolvedCredential[] | undefined;
1078
+ if (applicationConfig.credentials?.length) {
747
1079
  try {
748
- const { resolveRegistryAuth } = await import('./secretOperations.ts');
749
- resolvedRegistryAuth = (await resolveRegistryAuth(applicationConfig.registryAuth, name)) as
750
- RegistryAuthEntry[] | undefined;
1080
+ const { resolveCredentials } = await import('./secretOperations.ts');
1081
+ credentials = await resolveCredentials(applicationConfig.credentials, name);
751
1082
  } catch (error) {
752
1083
  logger.warn?.(
753
- `Could not resolve registryAuth for application ${name} at install time: ${(error as Error).message}`
1084
+ `Could not resolve credentials for application ${name} at install time: ${(error as Error).message}`
754
1085
  );
755
1086
  }
756
1087
  }
@@ -759,7 +1090,7 @@ export async function installApplications() {
759
1090
  name,
760
1091
  packageIdentifier: applicationConfig.package,
761
1092
  install: applicationConfig.install,
762
- registryAuth: resolvedRegistryAuth,
1093
+ credentials,
763
1094
  });
764
1095
 
765
1096
  // Lock check: only install if not already installed with matching configuration
@@ -788,22 +1119,126 @@ export async function installApplications() {
788
1119
  await writeFile(harperApplicationLockPath, JSON.stringify(harperApplicationLock, null, 2), 'utf8');
789
1120
  }
790
1121
 
791
- function getGitSSHCommand() {
1122
+ /**
1123
+ * Rewrite every occurrence of `sshDir` in an ssh `config` file's contents to `tempDir`, matching
1124
+ * either slash direction per path segment (and case-insensitively on `win32`) rather than relying
1125
+ * on the two strings being byte-identical.
1126
+ *
1127
+ * A plain string substitution (`sshConfig.split(sshDir).join(tempDir)`) doesn't hold
1128
+ * cross-platform: ssh config files are frequently forward-slash even on Windows, while `sshDir`
1129
+ * (built via `path.join`) is backslash there, so the split would silently never match; Windows
1130
+ * paths are also case-insensitive. `sshDir` is split into segments on either separator *before*
1131
+ * escaping, so escaping a regex-special character within a segment (e.g. a literal paren in a
1132
+ * directory name) can't interact with the separator substitution. A trailing lookahead keeps
1133
+ * `sshDir` from partial-matching a sibling directory whose name happens to start with it (e.g.
1134
+ * `.../ssh` vs `.../sshhh`).
1135
+ *
1136
+ * Exported only so unit tests can pin the Windows-path behavior (mixed slash direction, case
1137
+ * insensitivity) directly via the `platform` override, without needing to run on Windows.
1138
+ */
1139
+ export function rewriteSshConfigPaths(
1140
+ sshConfig: string,
1141
+ sshDir: string,
1142
+ tempDir: string,
1143
+ platform: string = process.platform
1144
+ ): string {
1145
+ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1146
+ const sepClass = '[\\\\/]';
1147
+ const sshDirPattern = sshDir.split(/[\\/]/).map(escapeRegExp).join(sepClass) + `(?=${sepClass}|$)`;
1148
+ const sshDirRegex = new RegExp(sshDirPattern, platform === 'win32' ? 'gi' : 'g');
1149
+ const normalizedTempDir = platform === 'win32' ? tempDir.replace(/\\/g, '/') : tempDir;
1150
+ // Function replacer: a string replacer would treat `$` sequences in normalizedTempDir
1151
+ // (e.g. `$&`, `$1`) as replacement patterns instead of literal characters.
1152
+ return sshConfig.replace(sshDirRegex, () => normalizedTempDir);
1153
+ }
1154
+
1155
+ /**
1156
+ * Materialize the SSH deploy keys for the lifetime of a single git-over-SSH spawn.
1157
+ *
1158
+ * Keys are sealed at rest as `enc:v1:` envelopes (Harper Pro's `add_ssh_key`), but ssh needs a key
1159
+ * *file*, so each key is decrypted into a fresh 0700 temp dir as a 0600 file and the ssh config is
1160
+ * copied with its `IdentityFile` paths repointed at the transient copies. The caller removes the
1161
+ * dir as soon as the spawn settles, so the plaintext never outlives the git invocation. Legacy
1162
+ * plaintext keys (written before sealing, or on a node with no custody) are copied through
1163
+ * unchanged, so this is a no-op for them beyond the temp dir.
1164
+ *
1165
+ * A key that cannot be decrypted — no custody registered on this node, an envelope sealed under a
1166
+ * different cluster key, or a tampered envelope — is logged and skipped rather than failing the
1167
+ * spawn: a deploy that doesn't need that key is unaffected, and one that does fails with the usual
1168
+ * SSH auth error (see `isSSHAuthFailure`). No log or error message here carries key material or
1169
+ * the envelope.
1170
+ *
1171
+ * @returns The `GIT_SSH_COMMAND` to use plus its cleanup, or undefined when no keys are configured.
1172
+ */
1173
+ export async function materializeGitSSH(): Promise<{ command: string; cleanup: () => Promise<void> } | undefined> {
792
1174
  const rootDir = getConfigValue(CONFIG_PARAMS.ROOTPATH);
1175
+ if (!rootDir) return; // config not initialized (e.g. an install-time spawn) — no ssh dir to read
793
1176
  const sshDir = join(rootDir, 'ssh');
794
- if (existsSync(sshDir)) {
795
- for (const file of readdirSync(sshDir)) {
796
- if (file.includes('.key')) {
797
- return `ssh -F ${join(sshDir, 'config')} -o UserKnownHostsFile=${join(sshDir, 'known_hosts')}`;
1177
+ // `withFileTypes` so a stray subdirectory in the ssh dir (e.g. one named `foo.key`) is filtered
1178
+ // out here rather than reaching `readFile` below and throwing EISDIR, which would abort the
1179
+ // whole spawn instead of just skipping that one entry.
1180
+ const sshDirEntries = await readdir(sshDir, { withFileTypes: true }).catch(() => undefined);
1181
+ if (!sshDirEntries) return; // no ssh dir on this node
1182
+ const keyFiles = sshDirEntries
1183
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.key'))
1184
+ .map((entry) => entry.name);
1185
+ if (keyFiles.length === 0) return;
1186
+
1187
+ const tempDir = await mkdtemp(join(tmpdir(), 'harper-ssh-'));
1188
+ const cleanup = async () => {
1189
+ try {
1190
+ await rm(tempDir, { recursive: true, force: true });
1191
+ } catch (error) {
1192
+ // never mask the caller's error (this runs from a finally) — a leaked temp dir is the
1193
+ // lesser failure, and it holds only 0600 files in a 0700 dir
1194
+ logger.warn?.(`Failed to remove transient ssh dir ${tempDir}:`, error);
1195
+ }
1196
+ };
1197
+
1198
+ try {
1199
+ const decryptor = getSecretDecryptor();
1200
+ for (const keyFile of keyFiles) {
1201
+ let storedKey: string;
1202
+ try {
1203
+ storedKey = await readFile(join(sshDir, keyFile), 'utf8');
1204
+ } catch (error) {
1205
+ // Same fail-open policy as a decrypt failure below: a key that can't even be read
1206
+ // (permission drift, deleted mid-scan by a concurrent add_ssh_key/rotation, a
1207
+ // transient EIO) should drop that one key, not abort a spawn that may not need it.
1208
+ logger.error?.(`Failed to read SSH key ${keyFile}: ${(error as Error).message}; skipping it`);
1209
+ continue;
798
1210
  }
1211
+ let keyMaterial = storedKey;
1212
+ if (storedKey.startsWith(ENV_ENCRYPTED_PREFIX)) {
1213
+ if (!decryptor) {
1214
+ logger.error?.(
1215
+ `SSH key ${keyFile} is encrypted but no secret custody is registered on this node; skipping it`
1216
+ );
1217
+ continue;
1218
+ }
1219
+ try {
1220
+ keyMaterial = decryptor(storedKey);
1221
+ } catch (error) {
1222
+ logger.error?.(`Failed to decrypt SSH key ${keyFile}: ${(error as Error).message}; skipping it`);
1223
+ continue;
1224
+ }
1225
+ }
1226
+ await writeFile(join(tempDir, keyFile), keyMaterial, { mode: 0o600 });
799
1227
  }
1228
+ // The config's `IdentityFile` lines are absolute paths into the durable ssh dir; repoint
1229
+ // them at the transient copies. known_hosts holds no secrets and stays where it is.
1230
+ const sshConfig = await readFile(join(sshDir, 'config'), 'utf8').catch(() => '');
1231
+ const rewrittenConfig = rewriteSshConfigPaths(sshConfig, sshDir, tempDir);
1232
+ await writeFile(join(tempDir, 'config'), rewrittenConfig, { mode: 0o600 });
1233
+ } catch (error) {
1234
+ await cleanup();
1235
+ throw error;
800
1236
  }
801
- }
802
1237
 
803
- export interface RegistryAuthEntry {
804
- registry: string;
805
- token: string;
806
- scope?: string;
1238
+ return {
1239
+ command: `ssh -F ${join(tempDir, 'config')} -o UserKnownHostsFile=${join(sshDir, 'known_hosts')}`,
1240
+ cleanup,
1241
+ };
807
1242
  }
808
1243
 
809
1244
  // Normalize a registry to a full URL with a scheme and trailing slash, e.g.
@@ -817,16 +1252,16 @@ function normalizeRegistryUrl(registry: string): string {
817
1252
  return url;
818
1253
  }
819
1254
 
820
- // Build the contents of a transient `.npmrc` from registry auth entries: an auth-token line keyed
1255
+ // Build the contents of a transient `.npmrc` from resolved registry credentials: an auth-token line keyed
821
1256
  // by npm's registry auth key (scheme stripped, leading `//`, trailing `/`) plus a registry-routing
822
1257
  // line. A scope routes only that `@scope` to the registry (`@scope:registry=…`); without a scope
823
1258
  // the entry sets npm's default `registry=…` so an unscoped package spec (e.g. `npm:my-private-app`)
824
1259
  // or its transitive deps actually resolve against this registry rather than the public default.
825
1260
  // A scope-less entry therefore requires its registry to serve/proxy whatever npm needs to install;
826
1261
  // with multiple scope-less entries npm's last-value-wins applies to the default `registry`.
827
- export function buildNpmrcContent(registryAuth: RegistryAuthEntry[]): string {
1262
+ export function buildNpmrcContent(registryCredentials: ResolvedRegistryCredential[]): string {
828
1263
  const lines: string[] = [];
829
- for (const { registry, token, scope } of registryAuth) {
1264
+ for (const { registry, token, scope } of registryCredentials) {
830
1265
  // Enforce the no-newline invariant at the injection point so it holds for every source. The
831
1266
  // ops validator already rejects CR/LF in a literal `token`, but a token resolved from an
832
1267
  // hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
@@ -896,14 +1331,49 @@ function createLineSplitter(onLine: (line: string) => void): {
896
1331
  };
897
1332
  }
898
1333
 
899
- export function nonInteractiveSpawn(
1334
+ /**
1335
+ * Run a command with the deploy's SSH key material materialized only for the duration of the
1336
+ * spawn. The keys are decrypted to a transient 0700 dir (see `materializeGitSSH`) and removed as
1337
+ * soon as the process settles — on success, failure, and timeout alike.
1338
+ */
1339
+ export async function nonInteractiveSpawn(
900
1340
  applicationName: string,
901
1341
  command: string,
902
1342
  args: string[],
903
1343
  cwd: string,
904
1344
  timeoutMs: number = 60 * 60 * 1000,
905
1345
  onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
906
- npmUserconfigPath?: string
1346
+ npmUserconfigPath?: string,
1347
+ gitCredentialEnv?: Record<string, string>
1348
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
1349
+ const gitSSH = await materializeGitSSH();
1350
+ try {
1351
+ return await spawnWithEnv(
1352
+ applicationName,
1353
+ command,
1354
+ args,
1355
+ cwd,
1356
+ timeoutMs,
1357
+ onLine,
1358
+ npmUserconfigPath,
1359
+ gitSSH?.command,
1360
+ gitCredentialEnv
1361
+ );
1362
+ } finally {
1363
+ await gitSSH?.cleanup();
1364
+ }
1365
+ }
1366
+
1367
+ function spawnWithEnv(
1368
+ applicationName: string,
1369
+ command: string,
1370
+ args: string[],
1371
+ cwd: string,
1372
+ timeoutMs: number,
1373
+ onLine: ((stream: 'stdout' | 'stderr', line: string) => void) | undefined,
1374
+ npmUserconfigPath: string | undefined,
1375
+ gitSSHCommand: string | undefined,
1376
+ gitCredentialEnv: Record<string, string> | undefined
907
1377
  ): Promise<{ stdout: string; stderr: string; code: number }> {
908
1378
  return new Promise((resolve, reject) => {
909
1379
  logger
@@ -912,11 +1382,22 @@ export function nonInteractiveSpawn(
912
1382
 
913
1383
  const env = { ...process.env };
914
1384
 
915
- const gitSSHCommand = getGitSSHCommand();
916
1385
  if (gitSSHCommand) {
917
1386
  env.GIT_SSH_COMMAND = gitSSHCommand;
918
1387
  }
919
1388
 
1389
+ // The git credential channel is granted per spawn, and only to the one that clones the git
1390
+ // reference. Every other spawn — notably `npm install`, where a dependency's install script can
1391
+ // run — has it removed, so a transitive dependency cannot ask for a credential that was supplied
1392
+ // for the top-level repository. Only the socket variable is stripped rather than any inherited
1393
+ // GIT_ASKPASS/GIT_CONFIG_*: those may be the operator's own git auth, and without a socket to
1394
+ // reach, our helper answers nothing and is inert.
1395
+ if (gitCredentialEnv) {
1396
+ Object.assign(env, gitCredentialEnv);
1397
+ } else {
1398
+ delete env[GIT_CREDENTIAL_SOCKET_ENV];
1399
+ }
1400
+
920
1401
  // A deploy carrying transient registry auth points npm at a per-deploy `.npmrc` so
921
1402
  // `npm pack`/`install` can authenticate against a private registry without the token
922
1403
  // ever touching disk durably, the package reference, config, or hdb_deployment.