threadforge 0.1.1 → 0.2.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 (358) hide show
  1. package/README.md +52 -20
  2. package/bin/forge.js +2 -1058
  3. package/bin/host-commands.d.ts +2 -0
  4. package/bin/host-commands.d.ts.map +1 -0
  5. package/bin/host-commands.js +7 -8
  6. package/bin/platform-commands.d.ts +2 -0
  7. package/bin/platform-commands.d.ts.map +1 -0
  8. package/bin/platform-commands.js +118 -36
  9. package/dist/cli/base-command.d.ts +12 -0
  10. package/dist/cli/base-command.d.ts.map +1 -0
  11. package/dist/cli/base-command.js +25 -0
  12. package/dist/cli/base-command.js.map +1 -0
  13. package/dist/cli/commands/build.d.ts +10 -0
  14. package/dist/cli/commands/build.d.ts.map +1 -0
  15. package/dist/cli/commands/build.js +110 -0
  16. package/dist/cli/commands/build.js.map +1 -0
  17. package/dist/cli/commands/deploy.d.ts +12 -0
  18. package/dist/cli/commands/deploy.d.ts.map +1 -0
  19. package/dist/cli/commands/deploy.js +143 -0
  20. package/dist/cli/commands/deploy.js.map +1 -0
  21. package/dist/cli/commands/dev.d.ts +10 -0
  22. package/dist/cli/commands/dev.d.ts.map +1 -0
  23. package/dist/cli/commands/dev.js +138 -0
  24. package/dist/cli/commands/dev.js.map +1 -0
  25. package/dist/cli/commands/generate.d.ts +10 -0
  26. package/dist/cli/commands/generate.d.ts.map +1 -0
  27. package/dist/cli/commands/generate.js +76 -0
  28. package/dist/cli/commands/generate.js.map +1 -0
  29. package/dist/cli/commands/host.d.ts +8 -0
  30. package/dist/cli/commands/host.d.ts.map +1 -0
  31. package/dist/cli/commands/host.js +20 -0
  32. package/dist/cli/commands/host.js.map +1 -0
  33. package/dist/cli/commands/init.d.ts +16 -0
  34. package/dist/cli/commands/init.d.ts.map +1 -0
  35. package/dist/cli/commands/init.js +246 -0
  36. package/dist/cli/commands/init.js.map +1 -0
  37. package/dist/cli/commands/platform.d.ts +8 -0
  38. package/dist/cli/commands/platform.d.ts.map +1 -0
  39. package/dist/cli/commands/platform.js +20 -0
  40. package/dist/cli/commands/platform.js.map +1 -0
  41. package/dist/cli/commands/restart.d.ts +8 -0
  42. package/dist/cli/commands/restart.d.ts.map +1 -0
  43. package/dist/cli/commands/restart.js +13 -0
  44. package/dist/cli/commands/restart.js.map +1 -0
  45. package/dist/cli/commands/scaffold/frontend.d.ts +10 -0
  46. package/dist/cli/commands/scaffold/frontend.d.ts.map +1 -0
  47. package/dist/cli/commands/scaffold/frontend.js +130 -0
  48. package/dist/cli/commands/scaffold/frontend.js.map +1 -0
  49. package/dist/cli/commands/scaffold/react.d.ts +7 -0
  50. package/dist/cli/commands/scaffold/react.d.ts.map +1 -0
  51. package/dist/cli/commands/scaffold/react.js +12 -0
  52. package/dist/cli/commands/scaffold/react.js.map +1 -0
  53. package/dist/cli/commands/scale.d.ts +8 -0
  54. package/dist/cli/commands/scale.d.ts.map +1 -0
  55. package/dist/cli/commands/scale.js +13 -0
  56. package/dist/cli/commands/scale.js.map +1 -0
  57. package/dist/cli/commands/start.d.ts +10 -0
  58. package/dist/cli/commands/start.d.ts.map +1 -0
  59. package/dist/cli/commands/start.js +71 -0
  60. package/dist/cli/commands/start.js.map +1 -0
  61. package/dist/cli/commands/status.d.ts +11 -0
  62. package/dist/cli/commands/status.d.ts.map +1 -0
  63. package/dist/cli/commands/status.js +60 -0
  64. package/dist/cli/commands/status.js.map +1 -0
  65. package/dist/cli/commands/stop.d.ts +10 -0
  66. package/dist/cli/commands/stop.d.ts.map +1 -0
  67. package/dist/cli/commands/stop.js +89 -0
  68. package/dist/cli/commands/stop.js.map +1 -0
  69. package/dist/cli/util/config-discovery.d.ts +8 -0
  70. package/dist/cli/util/config-discovery.d.ts.map +1 -0
  71. package/dist/cli/util/config-discovery.js +70 -0
  72. package/dist/cli/util/config-discovery.js.map +1 -0
  73. package/dist/cli/util/config-patcher.d.ts +17 -0
  74. package/dist/cli/util/config-patcher.d.ts.map +1 -0
  75. package/dist/cli/util/config-patcher.js +439 -0
  76. package/dist/cli/util/config-patcher.js.map +1 -0
  77. package/dist/cli/util/frontend-dev.d.ts +8 -0
  78. package/dist/cli/util/frontend-dev.d.ts.map +1 -0
  79. package/dist/cli/util/frontend-dev.js +117 -0
  80. package/dist/cli/util/frontend-dev.js.map +1 -0
  81. package/dist/cli/util/process.d.ts +5 -0
  82. package/dist/cli/util/process.d.ts.map +1 -0
  83. package/dist/cli/util/process.js +17 -0
  84. package/dist/cli/util/process.js.map +1 -0
  85. package/dist/cli/util/templates.d.ts +10 -0
  86. package/dist/cli/util/templates.d.ts.map +1 -0
  87. package/dist/cli/util/templates.js +157 -0
  88. package/dist/cli/util/templates.js.map +1 -0
  89. package/dist/core/AlertSink.d.ts +83 -0
  90. package/dist/core/AlertSink.d.ts.map +1 -0
  91. package/dist/core/AlertSink.js +126 -0
  92. package/dist/core/AlertSink.js.map +1 -0
  93. package/dist/core/DirectMessageBus.d.ts +88 -0
  94. package/dist/core/DirectMessageBus.d.ts.map +1 -0
  95. package/dist/core/DirectMessageBus.js +352 -0
  96. package/dist/core/DirectMessageBus.js.map +1 -0
  97. package/dist/core/EndpointResolver.d.ts +111 -0
  98. package/dist/core/EndpointResolver.d.ts.map +1 -0
  99. package/dist/core/EndpointResolver.js +336 -0
  100. package/dist/core/EndpointResolver.js.map +1 -0
  101. package/dist/core/ForgeContext.d.ts +221 -0
  102. package/dist/core/ForgeContext.d.ts.map +1 -0
  103. package/dist/core/ForgeContext.js +1169 -0
  104. package/dist/core/ForgeContext.js.map +1 -0
  105. package/dist/core/ForgeEndpoints.d.ts +71 -0
  106. package/dist/core/ForgeEndpoints.d.ts.map +1 -0
  107. package/dist/core/ForgeEndpoints.js +442 -0
  108. package/dist/core/ForgeEndpoints.js.map +1 -0
  109. package/dist/core/ForgeHost.d.ts +82 -0
  110. package/dist/core/ForgeHost.d.ts.map +1 -0
  111. package/dist/core/ForgeHost.js +107 -0
  112. package/dist/core/ForgeHost.js.map +1 -0
  113. package/dist/core/ForgePlatform.d.ts +96 -0
  114. package/dist/core/ForgePlatform.d.ts.map +1 -0
  115. package/dist/core/ForgePlatform.js +136 -0
  116. package/dist/core/ForgePlatform.js.map +1 -0
  117. package/dist/core/ForgeWebSocket.d.ts +56 -0
  118. package/dist/core/ForgeWebSocket.d.ts.map +1 -0
  119. package/dist/core/ForgeWebSocket.js +415 -0
  120. package/dist/core/ForgeWebSocket.js.map +1 -0
  121. package/dist/core/Ingress.d.ts +329 -0
  122. package/dist/core/Ingress.d.ts.map +1 -0
  123. package/dist/core/Ingress.js +694 -0
  124. package/dist/core/Ingress.js.map +1 -0
  125. package/dist/core/Interceptors.d.ts +134 -0
  126. package/dist/core/Interceptors.d.ts.map +1 -0
  127. package/dist/core/Interceptors.js +416 -0
  128. package/dist/core/Interceptors.js.map +1 -0
  129. package/dist/core/Logger.d.ts +20 -0
  130. package/dist/core/Logger.d.ts.map +1 -0
  131. package/dist/core/Logger.js +77 -0
  132. package/dist/core/Logger.js.map +1 -0
  133. package/dist/core/MessageBus.d.ts +15 -0
  134. package/dist/core/MessageBus.d.ts.map +1 -0
  135. package/dist/core/MessageBus.js +18 -0
  136. package/dist/core/MessageBus.js.map +1 -0
  137. package/dist/core/Prometheus.d.ts +80 -0
  138. package/dist/core/Prometheus.d.ts.map +1 -0
  139. package/dist/core/Prometheus.js +332 -0
  140. package/dist/core/Prometheus.js.map +1 -0
  141. package/dist/core/RequestContext.d.ts +214 -0
  142. package/dist/core/RequestContext.d.ts.map +1 -0
  143. package/dist/core/RequestContext.js +556 -0
  144. package/dist/core/RequestContext.js.map +1 -0
  145. package/dist/core/Router.d.ts +45 -0
  146. package/dist/core/Router.d.ts.map +1 -0
  147. package/dist/core/Router.js +285 -0
  148. package/dist/core/Router.js.map +1 -0
  149. package/dist/core/RoutingStrategy.d.ts +116 -0
  150. package/dist/core/RoutingStrategy.d.ts.map +1 -0
  151. package/dist/core/RoutingStrategy.js +306 -0
  152. package/dist/core/RoutingStrategy.js.map +1 -0
  153. package/dist/core/RpcConfig.d.ts +72 -0
  154. package/dist/core/RpcConfig.d.ts.map +1 -0
  155. package/dist/core/RpcConfig.js +127 -0
  156. package/dist/core/RpcConfig.js.map +1 -0
  157. package/dist/core/SignatureCache.d.ts +81 -0
  158. package/dist/core/SignatureCache.d.ts.map +1 -0
  159. package/dist/core/SignatureCache.js +172 -0
  160. package/dist/core/SignatureCache.js.map +1 -0
  161. package/dist/core/StaticFileServer.d.ts +34 -0
  162. package/dist/core/StaticFileServer.d.ts.map +1 -0
  163. package/dist/core/StaticFileServer.js +497 -0
  164. package/dist/core/StaticFileServer.js.map +1 -0
  165. package/dist/core/Supervisor.d.ts +198 -0
  166. package/dist/core/Supervisor.d.ts.map +1 -0
  167. package/dist/core/Supervisor.js +1418 -0
  168. package/dist/core/Supervisor.js.map +1 -0
  169. package/dist/core/ThreadAllocator.d.ts +52 -0
  170. package/dist/core/ThreadAllocator.d.ts.map +1 -0
  171. package/dist/core/ThreadAllocator.js +174 -0
  172. package/dist/core/ThreadAllocator.js.map +1 -0
  173. package/dist/core/WorkerChannelManager.d.ts +130 -0
  174. package/dist/core/WorkerChannelManager.d.ts.map +1 -0
  175. package/dist/core/WorkerChannelManager.js +956 -0
  176. package/dist/core/WorkerChannelManager.js.map +1 -0
  177. package/dist/core/config-enums.d.ts +41 -0
  178. package/dist/core/config-enums.d.ts.map +1 -0
  179. package/dist/core/config-enums.js +59 -0
  180. package/dist/core/config-enums.js.map +1 -0
  181. package/dist/core/config.d.ts +159 -0
  182. package/dist/core/config.d.ts.map +1 -0
  183. package/dist/core/config.js +694 -0
  184. package/dist/core/config.js.map +1 -0
  185. package/dist/core/host-config.d.ts +146 -0
  186. package/dist/core/host-config.d.ts.map +1 -0
  187. package/dist/core/host-config.js +312 -0
  188. package/dist/core/host-config.js.map +1 -0
  189. package/dist/core/ipc-errors.d.ts +27 -0
  190. package/dist/core/ipc-errors.d.ts.map +1 -0
  191. package/dist/core/ipc-errors.js +36 -0
  192. package/dist/core/ipc-errors.js.map +1 -0
  193. package/dist/core/network-utils.d.ts +35 -0
  194. package/dist/core/network-utils.d.ts.map +1 -0
  195. package/dist/core/network-utils.js +145 -0
  196. package/dist/core/network-utils.js.map +1 -0
  197. package/dist/core/platform-config.d.ts +142 -0
  198. package/dist/core/platform-config.d.ts.map +1 -0
  199. package/dist/core/platform-config.js +299 -0
  200. package/dist/core/platform-config.js.map +1 -0
  201. package/dist/decorators/ServiceProxy.d.ts +175 -0
  202. package/dist/decorators/ServiceProxy.d.ts.map +1 -0
  203. package/dist/decorators/ServiceProxy.js +969 -0
  204. package/dist/decorators/ServiceProxy.js.map +1 -0
  205. package/dist/decorators/index.d.ts +146 -0
  206. package/dist/decorators/index.d.ts.map +1 -0
  207. package/dist/decorators/index.js +545 -0
  208. package/dist/decorators/index.js.map +1 -0
  209. package/dist/deploy/NginxGenerator.d.ts +165 -0
  210. package/dist/deploy/NginxGenerator.d.ts.map +1 -0
  211. package/dist/deploy/NginxGenerator.js +781 -0
  212. package/dist/deploy/NginxGenerator.js.map +1 -0
  213. package/dist/deploy/PlatformManifestGenerator.d.ts +43 -0
  214. package/dist/deploy/PlatformManifestGenerator.d.ts.map +1 -0
  215. package/dist/deploy/PlatformManifestGenerator.js +80 -0
  216. package/dist/deploy/PlatformManifestGenerator.js.map +1 -0
  217. package/dist/deploy/RouteManifestGenerator.d.ts +42 -0
  218. package/dist/deploy/RouteManifestGenerator.d.ts.map +1 -0
  219. package/dist/deploy/RouteManifestGenerator.js +105 -0
  220. package/dist/deploy/RouteManifestGenerator.js.map +1 -0
  221. package/dist/deploy/index.d.ts +210 -0
  222. package/dist/deploy/index.d.ts.map +1 -0
  223. package/dist/deploy/index.js +918 -0
  224. package/dist/deploy/index.js.map +1 -0
  225. package/dist/frontend/FrontendDevLifecycle.d.ts +26 -0
  226. package/dist/frontend/FrontendDevLifecycle.d.ts.map +1 -0
  227. package/dist/frontend/FrontendDevLifecycle.js +60 -0
  228. package/dist/frontend/FrontendDevLifecycle.js.map +1 -0
  229. package/dist/frontend/FrontendPluginOrchestrator.d.ts +64 -0
  230. package/dist/frontend/FrontendPluginOrchestrator.d.ts.map +1 -0
  231. package/dist/frontend/FrontendPluginOrchestrator.js +167 -0
  232. package/dist/frontend/FrontendPluginOrchestrator.js.map +1 -0
  233. package/dist/frontend/SiteResolver.d.ts +33 -0
  234. package/dist/frontend/SiteResolver.d.ts.map +1 -0
  235. package/dist/frontend/SiteResolver.js +53 -0
  236. package/dist/frontend/SiteResolver.js.map +1 -0
  237. package/dist/frontend/StaticMountRegistry.d.ts +36 -0
  238. package/dist/frontend/StaticMountRegistry.d.ts.map +1 -0
  239. package/dist/frontend/StaticMountRegistry.js +94 -0
  240. package/dist/frontend/StaticMountRegistry.js.map +1 -0
  241. package/dist/frontend/index.d.ts +7 -0
  242. package/dist/frontend/index.d.ts.map +1 -0
  243. package/{src → dist}/frontend/index.js +4 -2
  244. package/dist/frontend/index.js.map +1 -0
  245. package/dist/frontend/pathUtils.d.ts +8 -0
  246. package/dist/frontend/pathUtils.d.ts.map +1 -0
  247. package/dist/frontend/pathUtils.js +17 -0
  248. package/dist/frontend/pathUtils.js.map +1 -0
  249. package/dist/frontend/plugins/index.d.ts +2 -0
  250. package/dist/frontend/plugins/index.d.ts.map +1 -0
  251. package/{src → dist}/frontend/plugins/index.js +1 -1
  252. package/dist/frontend/plugins/index.js.map +1 -0
  253. package/dist/frontend/plugins/viteFrontend.d.ts +51 -0
  254. package/dist/frontend/plugins/viteFrontend.d.ts.map +1 -0
  255. package/dist/frontend/plugins/viteFrontend.js +134 -0
  256. package/dist/frontend/plugins/viteFrontend.js.map +1 -0
  257. package/dist/frontend/types.d.ts +25 -0
  258. package/dist/frontend/types.d.ts.map +1 -0
  259. package/dist/frontend/types.js +2 -0
  260. package/dist/frontend/types.js.map +1 -0
  261. package/dist/index.d.ts +17 -0
  262. package/dist/index.d.ts.map +1 -0
  263. package/dist/index.js +32 -0
  264. package/dist/index.js.map +1 -0
  265. package/dist/internals.d.ts +21 -0
  266. package/dist/internals.d.ts.map +1 -0
  267. package/{src → dist}/internals.js +12 -14
  268. package/dist/internals.js.map +1 -0
  269. package/dist/plugins/PluginManager.d.ts +209 -0
  270. package/dist/plugins/PluginManager.d.ts.map +1 -0
  271. package/dist/plugins/PluginManager.js +365 -0
  272. package/dist/plugins/PluginManager.js.map +1 -0
  273. package/dist/plugins/ScopedPostgres.d.ts +78 -0
  274. package/dist/plugins/ScopedPostgres.d.ts.map +1 -0
  275. package/dist/plugins/ScopedPostgres.js +190 -0
  276. package/dist/plugins/ScopedPostgres.js.map +1 -0
  277. package/dist/plugins/ScopedRedis.d.ts +88 -0
  278. package/dist/plugins/ScopedRedis.d.ts.map +1 -0
  279. package/dist/plugins/ScopedRedis.js +169 -0
  280. package/dist/plugins/ScopedRedis.js.map +1 -0
  281. package/dist/plugins/index.d.ts +289 -0
  282. package/dist/plugins/index.d.ts.map +1 -0
  283. package/dist/plugins/index.js +1942 -0
  284. package/dist/plugins/index.js.map +1 -0
  285. package/dist/plugins/types.d.ts +59 -0
  286. package/dist/plugins/types.d.ts.map +1 -0
  287. package/dist/plugins/types.js +2 -0
  288. package/dist/plugins/types.js.map +1 -0
  289. package/dist/registry/ServiceRegistry.d.ts +305 -0
  290. package/dist/registry/ServiceRegistry.d.ts.map +1 -0
  291. package/dist/registry/ServiceRegistry.js +735 -0
  292. package/dist/registry/ServiceRegistry.js.map +1 -0
  293. package/dist/scaling/ScaleAdvisor.d.ts +214 -0
  294. package/dist/scaling/ScaleAdvisor.d.ts.map +1 -0
  295. package/dist/scaling/ScaleAdvisor.js +526 -0
  296. package/dist/scaling/ScaleAdvisor.js.map +1 -0
  297. package/dist/services/Service.d.ts +164 -0
  298. package/dist/services/Service.d.ts.map +1 -0
  299. package/dist/services/Service.js +106 -0
  300. package/dist/services/Service.js.map +1 -0
  301. package/dist/services/worker-bootstrap.d.ts +15 -0
  302. package/dist/services/worker-bootstrap.d.ts.map +1 -0
  303. package/dist/services/worker-bootstrap.js +744 -0
  304. package/dist/services/worker-bootstrap.js.map +1 -0
  305. package/dist/templates/auth-service.d.ts +42 -0
  306. package/dist/templates/auth-service.d.ts.map +1 -0
  307. package/dist/templates/auth-service.js +54 -0
  308. package/dist/templates/auth-service.js.map +1 -0
  309. package/dist/templates/identity-service.d.ts +50 -0
  310. package/dist/templates/identity-service.d.ts.map +1 -0
  311. package/dist/templates/identity-service.js +62 -0
  312. package/dist/templates/identity-service.js.map +1 -0
  313. package/dist/types/contract.d.ts +120 -0
  314. package/dist/types/contract.d.ts.map +1 -0
  315. package/dist/types/contract.js +69 -0
  316. package/dist/types/contract.js.map +1 -0
  317. package/package.json +78 -20
  318. package/src/core/DirectMessageBus.js +0 -364
  319. package/src/core/EndpointResolver.js +0 -259
  320. package/src/core/ForgeContext.js +0 -2236
  321. package/src/core/ForgeHost.js +0 -122
  322. package/src/core/ForgePlatform.js +0 -145
  323. package/src/core/Ingress.js +0 -768
  324. package/src/core/Interceptors.js +0 -420
  325. package/src/core/MessageBus.js +0 -321
  326. package/src/core/Prometheus.js +0 -305
  327. package/src/core/RequestContext.js +0 -413
  328. package/src/core/RoutingStrategy.js +0 -330
  329. package/src/core/Supervisor.js +0 -1349
  330. package/src/core/ThreadAllocator.js +0 -196
  331. package/src/core/WorkerChannelManager.js +0 -879
  332. package/src/core/config.js +0 -637
  333. package/src/core/host-config.js +0 -311
  334. package/src/core/network-utils.js +0 -166
  335. package/src/core/platform-config.js +0 -308
  336. package/src/decorators/ServiceProxy.js +0 -904
  337. package/src/decorators/index.js +0 -571
  338. package/src/deploy/NginxGenerator.js +0 -865
  339. package/src/deploy/PlatformManifestGenerator.js +0 -96
  340. package/src/deploy/RouteManifestGenerator.js +0 -112
  341. package/src/deploy/index.js +0 -984
  342. package/src/frontend/FrontendDevLifecycle.js +0 -65
  343. package/src/frontend/FrontendPluginOrchestrator.js +0 -187
  344. package/src/frontend/SiteResolver.js +0 -63
  345. package/src/frontend/StaticMountRegistry.js +0 -90
  346. package/src/frontend/plugins/viteFrontend.js +0 -79
  347. package/src/frontend/types.js +0 -35
  348. package/src/index.js +0 -58
  349. package/src/plugins/PluginManager.js +0 -537
  350. package/src/plugins/ScopedPostgres.js +0 -192
  351. package/src/plugins/ScopedRedis.js +0 -142
  352. package/src/plugins/index.js +0 -1756
  353. package/src/registry/ServiceRegistry.js +0 -797
  354. package/src/scaling/ScaleAdvisor.js +0 -442
  355. package/src/services/Service.js +0 -195
  356. package/src/services/worker-bootstrap.js +0 -679
  357. package/src/templates/auth-service.js +0 -65
  358. package/src/templates/identity-service.js +0 -75
@@ -0,0 +1,918 @@
1
+ /**
2
+ * Deployment System
3
+ *
4
+ * The problem: you have 8 services and 3 machines. You need to
5
+ * decide what runs where, generate the right configs for each
6
+ * machine, ensure discovery works, handle rolling deploys, and
7
+ * do it all without the developer manually editing addresses.
8
+ *
9
+ * From a manifest, `forge deploy` generates:
10
+ *
11
+ * 1. Per-node forge.config.js files
12
+ * 2. Docker Compose for local dev
13
+ * 3. Systemd unit files for production
14
+ * 4. HTTP-based inter-service communication configs
15
+ * 5. Deployment scripts (rsync + restart)
16
+ */
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { RegistryMode, ServiceType } from "../core/config-enums.js";
21
+ import { resolveAppDomains } from "../core/platform-config.js";
22
+ import { buildNginxServiceMap, buildNginxWebSocketRoutes, generateNginxConfig, generatePlatformNginxConfig, } from "./NginxGenerator.js";
23
+ import { generatePlatformManifest } from "./PlatformManifestGenerator.js";
24
+ // ── Secrets validation ────────────────────────────────────────
25
+ /**
26
+ * Required environment variables for production deployments.
27
+ * `forge deploy --check-secrets` verifies these are set and non-empty.
28
+ */
29
+ const REQUIRED_SECRETS = [
30
+ "DATABASE_URL",
31
+ "REDIS_URL",
32
+ "JWT_SECRET",
33
+ "FORGE_CLUSTER_SECRET",
34
+ ];
35
+ /**
36
+ * Validate that required secrets are set in the environment.
37
+ * Returns a result object with missing and warning lists.
38
+ */
39
+ export function checkSecrets(env = process.env) {
40
+ const missing = [];
41
+ const warnings = [];
42
+ for (const key of REQUIRED_SECRETS) {
43
+ const value = env[key];
44
+ if (!value || value.trim() === "") {
45
+ missing.push(key);
46
+ }
47
+ }
48
+ // Warn about insecure default values
49
+ if (env.JWT_SECRET && (env.JWT_SECRET === "CHANGE_ME_BEFORE_DEPLOY" || env.JWT_SECRET === "secret")) {
50
+ warnings.push("JWT_SECRET is set to an insecure default value");
51
+ }
52
+ if (env.NODE_ENV !== "production") {
53
+ warnings.push("NODE_ENV is not set to 'production'");
54
+ }
55
+ return {
56
+ ok: missing.length === 0,
57
+ missing,
58
+ warnings,
59
+ };
60
+ }
61
+ // ── Port assignment helper ────────────────────────────────────
62
+ /**
63
+ * Assign HTTP ports to all services across all nodes.
64
+ *
65
+ * Edge services keep their configured port; internal/background services
66
+ * get sequential ports starting from httpBasePort, skipping any already
67
+ * claimed by edge services on that node.
68
+ */
69
+ function _assignPorts(manifest, serviceConfigs, httpBasePort) {
70
+ const servicePorts = {};
71
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
72
+ const usedPorts = new Set();
73
+ for (const svcName of node.services) {
74
+ const existingPort = serviceConfigs[svcName]?.port;
75
+ if (existingPort)
76
+ usedPorts.add(existingPort);
77
+ }
78
+ let nextPort = httpBasePort;
79
+ for (const svcName of node.services) {
80
+ const existingPort = serviceConfigs[svcName]?.port;
81
+ if (existingPort) {
82
+ if (!servicePorts[svcName])
83
+ servicePorts[svcName] = [];
84
+ servicePorts[svcName].push({ host: node.host, port: existingPort });
85
+ }
86
+ else {
87
+ while (usedPorts.has(nextPort))
88
+ nextPort++;
89
+ if (nextPort > 65535) {
90
+ throw new Error(`Port exhaustion on node "${nodeName}": cannot allocate ports beyond 65535`);
91
+ }
92
+ const port = nextPort++;
93
+ usedPorts.add(port);
94
+ if (!servicePorts[svcName])
95
+ servicePorts[svcName] = [];
96
+ servicePorts[svcName].push({ host: node.host, port });
97
+ }
98
+ }
99
+ }
100
+ return servicePorts;
101
+ }
102
+ // ── Input validation for deploy safety ───────────────────────
103
+ const NODE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
104
+ const HOSTNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
105
+ const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
106
+ const IPV6_RE = /^[0-9a-fA-F:]+$/;
107
+ const SHELL_METACHAR_RE = /[`$;|&<>(){}[\]\\'"!\s]/;
108
+ function validateNodeName(name) {
109
+ if (!NODE_NAME_RE.test(name)) {
110
+ throw new Error(`Invalid node name "${name}": must start with alphanumeric and contain only [a-zA-Z0-9_-]`);
111
+ }
112
+ }
113
+ function validateHostname(host, nodeName) {
114
+ // H-DEPLOY-2: Reject hosts with shell metacharacters first
115
+ if (SHELL_METACHAR_RE.test(host)) {
116
+ throw new Error(`Invalid host "${host}" for node "${nodeName}": contains shell metacharacters`);
117
+ }
118
+ if (HOSTNAME_RE.test(host) || IPV4_RE.test(host) || IPV6_RE.test(host)) {
119
+ return;
120
+ }
121
+ throw new Error(`Invalid host "${host}" for node "${nodeName}": must be a valid hostname, IPv4, or IPv6 address`);
122
+ }
123
+ function validateForgeProxyConfig(forgeProxy) {
124
+ if (forgeProxy === undefined || forgeProxy === null || forgeProxy === true)
125
+ return;
126
+ if (forgeProxy === false) {
127
+ throw new Error("forgeProxy cannot be false in deploy mode");
128
+ }
129
+ if (typeof forgeProxy !== "object" || Array.isArray(forgeProxy)) {
130
+ throw new Error("forgeProxy must be true or an object in deploy mode");
131
+ }
132
+ if (forgeProxy.enabled === false) {
133
+ throw new Error("forgeProxy.enabled cannot be false in deploy mode");
134
+ }
135
+ if (forgeProxy.enabled !== undefined && forgeProxy.enabled !== true) {
136
+ throw new Error("forgeProxy.enabled must be true when provided");
137
+ }
138
+ if (forgeProxy.host !== undefined) {
139
+ if (typeof forgeProxy.host !== "string" || forgeProxy.host.trim() === "") {
140
+ throw new Error("forgeProxy.host must be a non-empty string when provided");
141
+ }
142
+ }
143
+ if (forgeProxy.port !== undefined) {
144
+ if (!Number.isInteger(forgeProxy.port) || forgeProxy.port < 1 || forgeProxy.port > 65535) {
145
+ throw new Error("forgeProxy.port must be an integer between 1 and 65535");
146
+ }
147
+ }
148
+ if (forgeProxy.keepalive !== undefined) {
149
+ if (!Number.isInteger(forgeProxy.keepalive) || forgeProxy.keepalive < 1) {
150
+ throw new Error("forgeProxy.keepalive must be a positive integer");
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Load and validate a deployment manifest.
156
+ */
157
+ export function loadManifest(manifest, serviceConfigs) {
158
+ validateForgeProxyConfig(manifest?.forgeProxy);
159
+ if (!manifest.nodes || Object.keys(manifest.nodes).length === 0) {
160
+ throw new Error("Deployment manifest must define at least one node");
161
+ }
162
+ // DEP-M3: Validate httpBasePort range
163
+ if (manifest.httpBasePort !== undefined) {
164
+ if (!Number.isInteger(manifest.httpBasePort) || manifest.httpBasePort < 1024 || manifest.httpBasePort > 65000) {
165
+ throw new Error(`httpBasePort must be an integer between 1024 and 65000, got ${manifest.httpBasePort}`);
166
+ }
167
+ }
168
+ // Validate nodes
169
+ const allAssigned = new Set();
170
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
171
+ validateNodeName(nodeName);
172
+ if (!node.host) {
173
+ throw new Error(`Node "${nodeName}" is missing a host address`);
174
+ }
175
+ validateHostname(node.host, nodeName);
176
+ if (!node.services || node.services.length === 0) {
177
+ throw new Error(`Node "${nodeName}" has no services assigned`);
178
+ }
179
+ for (const svc of node.services) {
180
+ allAssigned.add(svc);
181
+ }
182
+ }
183
+ // Check for unassigned services
184
+ for (const svcName of Object.keys(serviceConfigs)) {
185
+ if (!allAssigned.has(svcName)) {
186
+ throw new Error(`Service "${svcName}" from forge.config.js is not assigned to any node. ` +
187
+ `Add it to a node in forge.deploy.js.`);
188
+ }
189
+ }
190
+ // Build the map: service -> [nodes]
191
+ const serviceToNodes = {};
192
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
193
+ for (const svc of node.services) {
194
+ if (!serviceToNodes[svc])
195
+ serviceToNodes[svc] = [];
196
+ serviceToNodes[svc].push({ node: nodeName, host: node.host });
197
+ }
198
+ }
199
+ return {
200
+ ...manifest,
201
+ serviceToNode: serviceToNodes, // now an array
202
+ allServices: Object.keys(serviceConfigs),
203
+ };
204
+ }
205
+ function serviceTypeToEnumExpr(type) {
206
+ switch (type ?? ServiceType.INTERNAL) {
207
+ case ServiceType.EDGE:
208
+ return "ServiceType.EDGE";
209
+ case ServiceType.BACKGROUND:
210
+ return "ServiceType.BACKGROUND";
211
+ case ServiceType.REMOTE:
212
+ return "ServiceType.REMOTE";
213
+ case ServiceType.INTERNAL:
214
+ default:
215
+ return "ServiceType.INTERNAL";
216
+ }
217
+ }
218
+ function registryModeToEnumExpr(mode) {
219
+ switch (mode ?? RegistryMode.MULTICAST) {
220
+ case RegistryMode.EMBEDDED:
221
+ return "RegistryMode.EMBEDDED";
222
+ case RegistryMode.EXTERNAL:
223
+ return "RegistryMode.EXTERNAL";
224
+ case RegistryMode.MULTICAST:
225
+ default:
226
+ return "RegistryMode.MULTICAST";
227
+ }
228
+ }
229
+ /**
230
+ * Generate per-node forge.config.js files.
231
+ *
232
+ * Each node gets a config where:
233
+ * - Services it runs are defined with their full config (entry, type, etc.)
234
+ * - Services on OTHER nodes are defined as type: 'remote' with HTTP addresses
235
+ */
236
+ export function generateNodeConfigs(manifest, serviceConfigs, _options = {}) {
237
+ const configs = new Map();
238
+ const httpBasePort = manifest.httpBasePort ?? 4000;
239
+ const registryMode = manifest.registry ?? RegistryMode.MULTICAST;
240
+ // Global port collision check BEFORE assignment
241
+ const globalPorts = new Map(); // "nodeName:port" -> service name
242
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
243
+ for (const svcName of node.services) {
244
+ const port = serviceConfigs[svcName]?.port;
245
+ if (port) {
246
+ const key = `${nodeName}:${port}`;
247
+ const existing = globalPorts.get(key);
248
+ if (existing) {
249
+ throw new Error(`Port ${port} collision on node "${nodeName}": services "${existing}" and "${svcName}" both claim port ${port}`);
250
+ }
251
+ globalPorts.set(key, svcName);
252
+ }
253
+ }
254
+ }
255
+ const servicePorts = _assignPorts(manifest, serviceConfigs, httpBasePort);
256
+ // Final duplicate check per node
257
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
258
+ const nodePorts = node.services
259
+ .map((s) => servicePorts[s]?.find((e) => e.host === node.host)?.port)
260
+ .filter(Boolean);
261
+ const uniquePorts = new Set(nodePorts);
262
+ if (uniquePorts.size !== nodePorts.length) {
263
+ // M-DEPLOY-1: Include both service names and node name in collision message
264
+ const portToServices = {};
265
+ for (const svc of node.services) {
266
+ const port = servicePorts[svc]?.find((e) => e.host === node.host)?.port;
267
+ if (port) {
268
+ if (!portToServices[port])
269
+ portToServices[port] = [];
270
+ portToServices[port].push(svc);
271
+ }
272
+ }
273
+ const dupeDescs = Object.entries(portToServices)
274
+ .filter(([, svcs]) => svcs.length > 1)
275
+ .map(([p, svcs]) => `port ${p} (services: ${svcs.join(", ")})`);
276
+ throw new Error(`Port collision on node "${nodeName}": ${dupeDescs.join("; ")}. Note: ports can be reused across different nodes.`);
277
+ }
278
+ }
279
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
280
+ const overrides = manifest.overrides?.[nodeName] ?? {};
281
+ let config = `// forge.config.js — Auto-generated for node: ${nodeName}
282
+ // Host: ${node.host} | Role: ${node.role ?? "general"}
283
+ // Generated by: forge deploy
284
+ // Do not edit manually — regenerate with: forge deploy --generate
285
+
286
+ import { defineServices, RegistryMode, ServiceType } from 'threadforge';
287
+
288
+ export default defineServices({
289
+
290
+ // ── Local services (this node runs these) ──
291
+ `;
292
+ // Local services
293
+ for (const svcName of node.services) {
294
+ const svc = serviceConfigs[svcName];
295
+ if (!svc) {
296
+ throw new Error(`Node "${nodeName}" references service "${svcName}" which has no configuration`);
297
+ }
298
+ const override = overrides[svcName] ?? {};
299
+ const merged = { ...svc, ...override };
300
+ const assignedPort = servicePorts[svcName]?.find((e) => e.host === node.host)?.port;
301
+ config += `\n ${svcName}: {\n`;
302
+ config += ` entry: '${merged.entry}',\n`;
303
+ config += ` type: ${serviceTypeToEnumExpr(merged.type)},\n`;
304
+ // All services get an HTTP port in multi-machine mode
305
+ config += ` port: ${assignedPort},\n`;
306
+ if (merged.threads) {
307
+ config += ` threads: ${typeof merged.threads === "string" ? `'${merged.threads}'` : merged.threads},\n`;
308
+ }
309
+ if (merged.weight && merged.weight > 1) {
310
+ config += ` weight: ${merged.weight},\n`;
311
+ }
312
+ if (merged.connects && merged.connects.length > 0) {
313
+ config += ` connects: [${merged.connects.map((c) => `'${c}'`).join(", ")}],\n`;
314
+ }
315
+ if (merged.group) {
316
+ config += ` group: '${merged.group}',\n`;
317
+ }
318
+ if (merged.websocket) {
319
+ config += ` websocket: ${JSON.stringify(merged.websocket)},\n`;
320
+ }
321
+ if (merged.routing && merged.routing.strategy !== "round-robin") {
322
+ config += ` routing: ${JSON.stringify(merged.routing)},\n`;
323
+ }
324
+ config += ` },\n`;
325
+ }
326
+ // Remote services
327
+ const remoteServices = manifest.allServices.filter((s) => !node.services.includes(s));
328
+ if (remoteServices.length > 0) {
329
+ config += `\n // ── Remote services (on other machines, via HTTP) ──\n`;
330
+ for (const svcName of remoteServices) {
331
+ const remote = servicePorts[svcName]?.[0]; // Use first endpoint as primary
332
+ if (!remote)
333
+ continue;
334
+ const targetNodes = manifest.serviceToNode[svcName];
335
+ const targetLabel = targetNodes ? targetNodes.map((n) => n.node).join(", ") : "unknown";
336
+ config += `\n ${svcName}: {\n`;
337
+ config += ` type: ${serviceTypeToEnumExpr(ServiceType.REMOTE)},\n`;
338
+ config += ` address: 'http://${remote.host}:${remote.port}',`;
339
+ config += ` // ${targetLabel}\n`;
340
+ if (servicePorts[svcName]?.length > 1) {
341
+ config += ` endpoints: ${JSON.stringify(servicePorts[svcName])},\n`;
342
+ }
343
+ config += ` },\n`;
344
+ }
345
+ }
346
+ config += `\n}, {\n`;
347
+ config += ` registryMode: ${registryModeToEnumExpr(registryMode)},\n`;
348
+ config += ` host: '${node.host}',\n`;
349
+ config += ` metricsPort: ${manifest.metricsPort ?? 9090},\n`;
350
+ config += `});\n`;
351
+ configs.set(nodeName, config);
352
+ }
353
+ return configs;
354
+ }
355
+ /**
356
+ * Generate Docker Compose for local development that simulates
357
+ * the multi-machine topology.
358
+ */
359
+ export function generateDockerCompose(manifest, serviceConfigs) {
360
+ let compose = `# docker-compose.yml — Auto-generated by forge deploy
361
+ # Simulates multi-machine topology for local development
362
+
363
+ services:
364
+ `;
365
+ // Compute HTTP ports using the shared helper
366
+ const httpBasePort = manifest.httpBasePort ?? 4000;
367
+ const portMap = _assignPorts(manifest, serviceConfigs, httpBasePort);
368
+ // Build a flat nodeName:svc -> port lookup for Docker Compose (keyed per-node)
369
+ const servicePorts = {};
370
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
371
+ for (const svc of node.services) {
372
+ const entry = portMap[svc]?.find((e) => e.host === node.host);
373
+ if (entry)
374
+ servicePorts[`${nodeName}:${svc}`] = entry.port;
375
+ }
376
+ }
377
+ // Build a service->endpoint map using Docker hostnames instead of IPs
378
+ const serviceEndpointMap = {};
379
+ for (const [nn, nd] of Object.entries(manifest.nodes)) {
380
+ for (const svc of nd.services) {
381
+ const port = servicePorts[`${nn}:${svc}`];
382
+ if (port) {
383
+ if (!serviceEndpointMap[svc])
384
+ serviceEndpointMap[svc] = [];
385
+ serviceEndpointMap[svc].push({ host: nn, port: Number(port), remote: true });
386
+ }
387
+ }
388
+ }
389
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
390
+ // Build FORGE_SERVICE_ENDPOINTS using Docker hostnames for this node (JSON format)
391
+ const endpointMap = {};
392
+ for (const [svc, endpoints] of Object.entries(serviceEndpointMap)) {
393
+ if (!node.services.includes(svc)) {
394
+ endpointMap[svc] = endpoints;
395
+ }
396
+ }
397
+ const endpointsEnv = Object.keys(endpointMap).length > 0 ? JSON.stringify(endpointMap) : "";
398
+ // YAML single-quoted strings escape ' as '' (per YAML spec 7.3.2).
399
+ // The value is JSON (double-quoted keys/values) so single quotes are
400
+ // unlikely, but we handle them defensively for correctness.
401
+ const yamlSafeEndpoints = endpointsEnv.replace(/'/g, "''");
402
+ compose += `
403
+ ${nodeName}:
404
+ build: .
405
+ hostname: ${nodeName}
406
+ restart: on-failure
407
+ env_file: ./.env
408
+ environment:
409
+ - FORGE_NODE=${nodeName}
410
+ - FORGE_HOST=${nodeName}
411
+ - FORGE_REGION=${manifest.region ?? "local"}${yamlSafeEndpoints ? `\n - FORGE_SERVICE_ENDPOINTS='${yamlSafeEndpoints}'` : ""}
412
+ volumes:
413
+ - ./deploy/${nodeName}/forge.config.js:/app/deploy/${nodeName}/forge.config.js:ro
414
+ working_dir: /app
415
+ command: node bin/forge.js start --config ./deploy/${nodeName}/forge.config.js
416
+ stop_grace_period: 35s
417
+ healthcheck:
418
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/health"]
419
+ interval: 10s
420
+ timeout: 3s
421
+ retries: 3
422
+ start_period: 30s
423
+ logging:
424
+ driver: json-file
425
+ options:
426
+ max-size: "10m"
427
+ max-file: "3"
428
+ deploy:
429
+ resources:
430
+ limits:
431
+ cpus: "${node.resources?.cpus ?? "2"}"
432
+ memory: ${node.resources?.memory ?? "2G"}
433
+ `;
434
+ // Expose HTTP ports for all services (edge + internal with assigned ports)
435
+ const ports = node.services
436
+ .map((svc) => ({
437
+ svc,
438
+ port: servicePorts[`${nodeName}:${svc}`],
439
+ type: serviceConfigs[svc]?.type ?? ServiceType.INTERNAL,
440
+ }))
441
+ .filter((p) => p.port);
442
+ if (ports.length > 0) {
443
+ const edgePorts = ports.filter((p) => p.type === ServiceType.EDGE);
444
+ const internalPorts = ports.filter((p) => p.type !== ServiceType.EDGE);
445
+ if (edgePorts.length > 0) {
446
+ compose += ` ports:\n`;
447
+ for (const { svc, port } of edgePorts) {
448
+ compose += ` - "${port}:${port}" # ${svc} HTTP\n`;
449
+ }
450
+ }
451
+ if (internalPorts.length > 0) {
452
+ compose += ` expose:\n`;
453
+ for (const { svc, port } of internalPorts) {
454
+ compose += ` - "${port}" # ${svc} HTTP (internal)\n`;
455
+ }
456
+ }
457
+ }
458
+ compose += ` networks:\n`;
459
+ compose += ` - forge-net\n`;
460
+ }
461
+ compose += `
462
+ networks:
463
+ forge-net:
464
+ driver: bridge
465
+ `;
466
+ return compose;
467
+ }
468
+ /**
469
+ * Generate a systemd service unit file for a node.
470
+ */
471
+ export function generateSystemdUnit(nodeName, node, manifest) {
472
+ const endpoints = node.endpoints || {};
473
+ const envFilePath = `/etc/threadforge/${nodeName}.env`;
474
+ const _envFileContent = `FORGE_SERVICE_ENDPOINTS=${JSON.stringify(endpoints)}`;
475
+ // DEP-M1: Configurable resource limits per node
476
+ const nodeMemory = node.resources?.memory ?? "2G";
477
+ return `[Unit]
478
+ Description=ThreadForge - ${nodeName}
479
+ After=network-online.target
480
+ Wants=network-online.target
481
+ StartLimitIntervalSec=300
482
+ StartLimitBurst=5
483
+
484
+ [Service]
485
+ Type=simple
486
+ User=forge
487
+ Group=forge
488
+ WorkingDirectory=/opt/forge
489
+ ExecStart=/usr/bin/node bin/forge.js start --config ./deploy/${nodeName}/forge.config.js
490
+ ExecStop=/bin/kill -SIGTERM $MAINPID
491
+ Restart=always
492
+ RestartSec=5
493
+ TimeoutStopSec=30
494
+ Environment=NODE_ENV=production
495
+ Environment=FORGE_NODE=${nodeName}
496
+ Environment=FORGE_HOST=${node.host}
497
+ Environment=FORGE_REGION=${manifest.region ?? "production"}
498
+ EnvironmentFile=${envFilePath}
499
+ EnvironmentFile=-/etc/threadforge/secrets/${nodeName}.env
500
+
501
+ # Security hardening
502
+ NoNewPrivileges=true
503
+ PrivateTmp=true
504
+ ProtectSystem=strict
505
+ ReadWritePaths=/opt/forge
506
+
507
+ # Resource limits
508
+ LimitNOFILE=65536
509
+ MemoryMax=${nodeMemory}
510
+
511
+ [Install]
512
+ WantedBy=multi-user.target
513
+ `;
514
+ }
515
+ /**
516
+ * Generate the environment file content for a systemd unit.
517
+ * Write this to /etc/threadforge/<nodeName>.env
518
+ */
519
+ export function generateSystemdEnvFile(nodeName, node) {
520
+ const endpoints = node.endpoints || {};
521
+ return {
522
+ path: `/etc/threadforge/${nodeName}.env`,
523
+ content: `FORGE_SERVICE_ENDPOINTS='${JSON.stringify(endpoints).replace(/'/g, "'\\''")}'\n`,
524
+ };
525
+ }
526
+ /**
527
+ * Generate a deployment script that syncs code and restarts services.
528
+ */
529
+ export function generateDeployScript(manifest) {
530
+ let script = `#!/bin/bash
531
+ # deploy.sh — Auto-generated by forge deploy
532
+ # Usage: ./deploy.sh [node-name] Deploy to a specific node
533
+ # ./deploy.sh Deploy to all nodes
534
+ # ./deploy.sh --rolling Rolling deploy (one at a time)
535
+ # ./deploy.sh --rollback <node> Rollback a node to latest backup
536
+
537
+ set -euo pipefail
538
+
539
+ DEPLOY_DIR="/opt/forge"
540
+ PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
541
+ BACKUP_BASE="/opt/forge/backups"
542
+
543
+ # ── Rollback helper ──────────────────────────────────────────
544
+ rollback() {
545
+ local node_host="$1"
546
+ local node_name="$2"
547
+ echo "Rolling back \${node_name} (\${node_host})..."
548
+ local latest
549
+ latest=$(ssh "forge@\${node_host}" "ls -1t \\"\${BACKUP_BASE}\\" 2>/dev/null | head -1")
550
+ if [ -z "$latest" ]; then
551
+ echo " No backup found for \${node_name}"
552
+ return 1
553
+ fi
554
+ ssh "forge@\${node_host}" "rsync -a \\"\${BACKUP_BASE}/\${latest}/\\" \\"\${DEPLOY_DIR}/\\""
555
+ ssh "forge@\${node_host}" "sudo systemctl restart forge-\${node_name}"
556
+ echo " Rolled back to backup: \${latest}"
557
+ }
558
+
559
+ `;
560
+ // Validate node names to prevent shell injection
561
+ for (const nodeName of Object.keys(manifest.nodes)) {
562
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(nodeName)) {
563
+ throw new Error(`Invalid node name "${nodeName}": must contain only alphanumeric characters, hyphens, and underscores`);
564
+ }
565
+ }
566
+ // Node deployment functions
567
+ for (const [nodeName, node] of Object.entries(manifest.nodes)) {
568
+ script += `deploy_${nodeName.replace(/-/g, "_")}() {
569
+ echo "Deploying to ${nodeName} (${node.host})..."
570
+
571
+ # Create timestamped backup of current deployment
572
+ local BACKUP_DIR="\${BACKUP_BASE}/$(date +%Y%m%d-%H%M%S)"
573
+ ssh "forge@${node.host}" "mkdir -p \\"\${BACKUP_DIR}\\" && rsync -a \\"\${DEPLOY_DIR}/\\" \\"\${BACKUP_DIR}/\\" 2>/dev/null || true"
574
+
575
+ # Sync code (excluding node_modules, .git)
576
+ rsync -azP --delete \\
577
+ --exclude 'node_modules' \\
578
+ --exclude '.git' \\
579
+ --exclude 'deploy/*/node_modules' \\
580
+ "\${PROJECT_DIR}/" "forge@${node.host}:\${DEPLOY_DIR}/"
581
+
582
+ # Install dependencies on remote
583
+ ssh "forge@${node.host}" "cd \\"\${DEPLOY_DIR}\\" && npm ci --omit=dev"
584
+
585
+ # Generate route manifests if needed
586
+ ssh "forge@${node.host}" "cd \\"\${DEPLOY_DIR}\\" && node bin/forge.js generate --config ./deploy/${nodeName}/forge.config.js" 2>/dev/null || true
587
+
588
+ # Restart service
589
+ ssh "forge@${node.host}" "sudo systemctl restart forge-${nodeName}"
590
+
591
+ # Wait for health check
592
+ echo -n " Waiting for health..."
593
+ for i in $(seq 1 30); do
594
+ if ssh "forge@${node.host}" "curl -sf http://localhost:9090/health" >/dev/null 2>&1; then
595
+ echo " \u2713 healthy"
596
+ return 0
597
+ fi
598
+ echo -n "."
599
+ sleep 1
600
+ done
601
+ echo " \u2717 FAILED — rolling back..."
602
+ rollback "${node.host}" "${nodeName}"
603
+ return 1
604
+ }
605
+
606
+ `;
607
+ }
608
+ // Main dispatch
609
+ const nodeNames = Object.keys(manifest.nodes);
610
+ const funcNames = nodeNames.map((n) => `deploy_${n.replace(/-/g, "_")}`);
611
+ // Build a node->host map for rollback dispatch
612
+ const nodeHostEntries = Object.entries(manifest.nodes).map(([n, nd]) => ` "${n}") rollback "${nd.host}" "${n}" ;;`);
613
+ script += `# \u2500\u2500 Main \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
614
+
615
+ if [ "\${1:-}" = "--rollback" ]; then
616
+ if [ -z "\${2:-}" ]; then
617
+ echo "Usage: ./deploy.sh --rollback <node-name>"
618
+ echo "Available: ${nodeNames.join(", ")}"
619
+ exit 1
620
+ fi
621
+ [[ "$2" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "Invalid node name: $2"; exit 1; }
622
+ case "$2" in
623
+ ${nodeHostEntries.join("\n")}
624
+ *) echo "Unknown node: $2"; echo "Available: ${nodeNames.join(", ")}"; exit 1 ;;
625
+ esac
626
+ elif [ "\${1:-}" = "--rolling" ]; then
627
+ echo "Rolling deploy to ${nodeNames.length} nodes..."
628
+ for fn in ${funcNames.join(" ")}; do
629
+ $fn
630
+ echo " Draining for 10s..."
631
+ sleep 10
632
+ done
633
+ elif [ -n "\${1:-}" ]; then
634
+ # Validate node name to prevent command injection
635
+ [[ "$1" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "Invalid node name: $1"; exit 1; }
636
+ fn="deploy_\${1//-/_}"
637
+ if type "$fn" &>/dev/null; then
638
+ $fn
639
+ else
640
+ echo "Unknown node: $1"
641
+ echo "Available: ${nodeNames.join(", ")}"
642
+ exit 1
643
+ fi
644
+ else
645
+ echo "Deploying to all ${nodeNames.length} nodes in parallel..."
646
+ PIDS=()
647
+ ${funcNames.map((fn) => `${fn} & PIDS+=($!)`).join("\n ")}
648
+ FAIL=0
649
+ for pid in "\${PIDS[@]}"; do
650
+ wait $pid || FAIL=1
651
+ done
652
+ if [ $FAIL -ne 0 ]; then
653
+ echo "ERROR: One or more deployments failed"
654
+ exit 1
655
+ fi
656
+ fi
657
+
658
+ echo "Deploy complete."
659
+ `;
660
+ return script;
661
+ }
662
+ /**
663
+ * Generate a Dockerfile for the deployment.
664
+ */
665
+ export function generateDockerfile(manifest, serviceConfigs) {
666
+ // Find the first edge service port, or fall back to httpBasePort
667
+ let httpPort = manifest.httpBasePort ?? 4000;
668
+ for (const svc of Object.values(serviceConfigs)) {
669
+ if (svc.type === ServiceType.EDGE && svc.port) {
670
+ httpPort = svc.port;
671
+ break;
672
+ }
673
+ }
674
+ return `FROM node:20.11-slim AS base
675
+ RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
676
+ WORKDIR /app
677
+ COPY package*.json ./
678
+ RUN npm ci --omit=dev
679
+ COPY . .
680
+ ENV NODE_ENV=production
681
+ EXPOSE ${httpPort}
682
+ ENTRYPOINT ["/usr/bin/tini", "--"]
683
+ CMD ["node", "bin/forge.js", "start"]
684
+ `;
685
+ }
686
+ /**
687
+ * Generate a .dockerignore file.
688
+ */
689
+ export function generateDockerignore() {
690
+ return `.git
691
+ node_modules
692
+ forgeproxy/target
693
+ .env
694
+ secrets/
695
+ *.log
696
+ deploy/
697
+ `;
698
+ }
699
+ /**
700
+ * Generate all deployment artifacts from a manifest.
701
+ */
702
+ export function generateAll(manifest, serviceConfigs, outputDir) {
703
+ const validated = loadManifest(manifest, serviceConfigs);
704
+ // DEP-M4: Write to a temp directory first, then copy on success
705
+ // to avoid partial output on failure
706
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-deploy-"));
707
+ try {
708
+ // Per-node configs
709
+ const nodeConfigs = generateNodeConfigs(validated, serviceConfigs);
710
+ for (const [nodeName, config] of nodeConfigs) {
711
+ const dir = path.join(tmpDir, nodeName);
712
+ fs.mkdirSync(dir, { recursive: true });
713
+ fs.writeFileSync(path.join(dir, "forge.config.js"), config);
714
+ }
715
+ // Docker Compose
716
+ const compose = generateDockerCompose(validated, serviceConfigs);
717
+ fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), compose);
718
+ // Compute endpoint map for systemd env files using shared helper
719
+ const httpBasePort = validated.httpBasePort ?? 4000;
720
+ const servicePorts = _assignPorts(validated, serviceConfigs, httpBasePort);
721
+ // Systemd units + env files
722
+ for (const [nodeName, node] of Object.entries(validated.nodes)) {
723
+ // Build endpoint map for this node: remote services -> host:port
724
+ const endpointMap = {};
725
+ for (const [svcName, endpoints] of Object.entries(servicePorts)) {
726
+ if (!node.services.includes(svcName)) {
727
+ endpointMap[svcName] = endpoints.map((e) => ({ host: e.host, port: Number(e.port), remote: true }));
728
+ }
729
+ }
730
+ const nodeWithEndpoints = { ...node, endpoints: endpointMap };
731
+ const unit = generateSystemdUnit(nodeName, nodeWithEndpoints, validated);
732
+ fs.writeFileSync(path.join(tmpDir, `forge-${nodeName}.service`), unit);
733
+ const envFile = generateSystemdEnvFile(nodeName, nodeWithEndpoints);
734
+ fs.writeFileSync(path.join(tmpDir, `${nodeName}.env`), envFile.content);
735
+ }
736
+ // Deploy script
737
+ const deployScript = generateDeployScript(validated);
738
+ const scriptPath = path.join(tmpDir, "deploy.sh");
739
+ fs.writeFileSync(scriptPath, deployScript);
740
+ fs.chmodSync(scriptPath, "755");
741
+ // Nginx config — routes ingress through ForgeProxy
742
+ const nginxServices = buildNginxServiceMap(validated, serviceConfigs);
743
+ const websocketRoutes = buildNginxWebSocketRoutes(serviceConfigs, nginxServices, {
744
+ websockets: manifest.websockets ?? false,
745
+ websocketPaths: manifest.websocketPaths ?? ["/ws"],
746
+ });
747
+ const nginxConfig = generateNginxConfig({
748
+ domain: manifest.domain ?? "localhost",
749
+ services: nginxServices,
750
+ ssl: manifest.ssl,
751
+ rateLimits: manifest.rateLimits ?? {},
752
+ staticDir: manifest.staticDir,
753
+ forgeProxy: manifest.forgeProxy,
754
+ websocketRoutes,
755
+ // Backward compatibility for older manifests that don't define service.websocket
756
+ websockets: manifest.websockets ?? false,
757
+ websocketPaths: manifest.websocketPaths ?? ["/ws"],
758
+ maxBodySize: manifest.maxBodySize ?? 10,
759
+ });
760
+ fs.writeFileSync(path.join(tmpDir, "nginx.conf"), nginxConfig);
761
+ // C1: Generate Dockerfile
762
+ const dockerfile = generateDockerfile(manifest, serviceConfigs);
763
+ fs.writeFileSync(path.join(tmpDir, "Dockerfile"), dockerfile);
764
+ // H4: Generate .dockerignore
765
+ const dockerignore = generateDockerignore();
766
+ fs.writeFileSync(path.join(tmpDir, ".dockerignore"), dockerignore);
767
+ // C-DEPLOY-1: Generate .env.example template for secrets management
768
+ const envExample = `# ThreadForge Deployment Secrets — DO NOT COMMIT TO VERSION CONTROL
769
+ # Copy to .env and fill in values
770
+ DATABASE_URL=
771
+ REDIS_URL=
772
+ JWT_SECRET=CHANGE_ME_BEFORE_DEPLOY
773
+ NODE_ENV=production
774
+ FORGE_METRICS_PORT=9090
775
+ FORGE_METRICS_BIND=127.0.0.1
776
+ FORGE_CLUSTER_SECRET=
777
+ FORGE_LOG_LEVEL=info
778
+ `;
779
+ fs.writeFileSync(path.join(tmpDir, ".env.example"), envExample);
780
+ // Generate .gitignore to protect .env from accidental commits
781
+ const gitignore = `# Secrets — never commit
782
+ .env
783
+ `;
784
+ fs.writeFileSync(path.join(tmpDir, ".gitignore"), gitignore);
785
+ // All files generated successfully — copy to final output directory
786
+ fs.mkdirSync(outputDir, { recursive: true });
787
+ for (const entry of fs.readdirSync(tmpDir)) {
788
+ const src = path.join(tmpDir, entry);
789
+ const dest = path.join(outputDir, entry);
790
+ fs.cpSync(src, dest, { recursive: true });
791
+ }
792
+ return {
793
+ nodes: [...nodeConfigs.keys()],
794
+ files: [
795
+ ...[...nodeConfigs.keys()].map((n) => `${n}/forge.config.js`),
796
+ "docker-compose.yml",
797
+ "nginx.conf",
798
+ ...Object.keys(validated.nodes).map((n) => `forge-${n}.service`),
799
+ ...Object.keys(validated.nodes).map((n) => `${n}.env`),
800
+ "deploy.sh",
801
+ "Dockerfile",
802
+ ".dockerignore",
803
+ ".env.example",
804
+ ".gitignore",
805
+ ],
806
+ };
807
+ }
808
+ finally {
809
+ // Clean up temp directory
810
+ fs.rmSync(tmpDir, { recursive: true, force: true });
811
+ }
812
+ }
813
+ /**
814
+ * Generate all platform deployment artifacts.
815
+ */
816
+ export function generatePlatformAll(platformConfig, services, hostMeta, outputDir, options = {}) {
817
+ // DEP-M4: Write to a temp directory first, then copy on success
818
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-platform-"));
819
+ const deployDir = path.join(tmpDir, "deploy");
820
+ const routesDir = path.join(tmpDir, "routes");
821
+ fs.mkdirSync(deployDir, { recursive: true });
822
+ fs.mkdirSync(routesDir, { recursive: true });
823
+ try {
824
+ const files = [];
825
+ const sites = options.sites ?? {};
826
+ const builtOutDirBySite = {};
827
+ if (Array.isArray(options.frontendManifest?.sites)) {
828
+ for (const site of options.frontendManifest.sites) {
829
+ if (site?.siteId && site?.outDir) {
830
+ builtOutDirBySite[site.siteId] = site.outDir;
831
+ }
832
+ }
833
+ }
834
+ const mountsFromManifest = {};
835
+ if (options.frontendManifest?.mounts && typeof options.frontendManifest.mounts === "object") {
836
+ for (const [siteId, mounts] of Object.entries(options.frontendManifest.mounts)) {
837
+ if (Array.isArray(mounts))
838
+ mountsFromManifest[siteId] = mounts;
839
+ }
840
+ }
841
+ const apps = {};
842
+ for (const [appId, app] of Object.entries(platformConfig.apps ?? {})) {
843
+ const site = sites?.[appId];
844
+ const frontend = site?.frontend
845
+ ? { ...site.frontend }
846
+ : app.frontend
847
+ ? { ...app.frontend }
848
+ : null;
849
+ if (frontend && builtOutDirBySite[appId]) {
850
+ frontend.outDir = builtOutDirBySite[appId];
851
+ }
852
+ const staticMounts = mountsFromManifest[appId]
853
+ ? mountsFromManifest[appId]
854
+ : (() => {
855
+ if (frontend) {
856
+ return [
857
+ {
858
+ siteId: appId,
859
+ domains: site?.domains ?? resolveAppDomains(app),
860
+ basePath: frontend.basePath ?? "/",
861
+ dir: frontend.outDir ?? "",
862
+ spaFallback: frontend.spaFallback ?? true,
863
+ cachePolicy: "short",
864
+ },
865
+ ];
866
+ }
867
+ const legacyStatic = site?.staticDir ?? app.static ?? null;
868
+ if (!legacyStatic)
869
+ return [];
870
+ return [
871
+ {
872
+ siteId: appId,
873
+ domains: site?.domains ?? resolveAppDomains(app),
874
+ basePath: "/static",
875
+ dir: legacyStatic,
876
+ spaFallback: false,
877
+ cachePolicy: "short",
878
+ },
879
+ ];
880
+ })();
881
+ apps[appId] = {
882
+ domains: resolveAppDomains(app),
883
+ ssl: app.ssl ?? null,
884
+ static: site?.staticDir ?? app.static ?? null,
885
+ frontend,
886
+ staticMounts,
887
+ services: app.services ?? [],
888
+ };
889
+ }
890
+ // Generate platform nginx config
891
+ const nginxConfig = generatePlatformNginxConfig({
892
+ apps,
893
+ services,
894
+ hostMeta,
895
+ defaultSsl: platformConfig.ssl ?? null,
896
+ maxBodySize: platformConfig.maxBodySize ?? 10,
897
+ forgeProxy: platformConfig.forgeProxy,
898
+ });
899
+ fs.writeFileSync(path.join(deployDir, "nginx.conf"), nginxConfig);
900
+ files.push("deploy/nginx.conf");
901
+ // Generate platform.yaml manifest
902
+ const manifest = generatePlatformManifest(platformConfig, hostMeta, sites);
903
+ fs.writeFileSync(path.join(routesDir, "platform.yaml"), manifest);
904
+ files.push("routes/platform.yaml");
905
+ // All files generated successfully — copy to final output directory
906
+ fs.mkdirSync(outputDir, { recursive: true });
907
+ for (const entry of fs.readdirSync(tmpDir)) {
908
+ const src = path.join(tmpDir, entry);
909
+ const dest = path.join(outputDir, entry);
910
+ fs.cpSync(src, dest, { recursive: true });
911
+ }
912
+ return { files };
913
+ }
914
+ finally {
915
+ fs.rmSync(tmpDir, { recursive: true, force: true });
916
+ }
917
+ }
918
+ //# sourceMappingURL=index.js.map