@gokiteam/goki-dev 0.2.0

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 (205) hide show
  1. package/README.md +478 -0
  2. package/bin/goki-dev.js +452 -0
  3. package/bin/mcp-server.js +16 -0
  4. package/bin/secrets-cli.js +302 -0
  5. package/cli/ComposeOverrideGenerator.js +226 -0
  6. package/cli/ComposeParser.js +73 -0
  7. package/cli/ConfigGenerator.js +304 -0
  8. package/cli/ConfigManager.js +46 -0
  9. package/cli/DatabaseManager.js +94 -0
  10. package/cli/DevToolsChecker.js +21 -0
  11. package/cli/DevToolsDir.js +66 -0
  12. package/cli/DevToolsManager.js +451 -0
  13. package/cli/DockerManager.js +138 -0
  14. package/cli/FunctionManager.js +95 -0
  15. package/cli/HttpProxyRewriter.js +91 -0
  16. package/cli/Logger.js +10 -0
  17. package/cli/McpConfigManager.js +123 -0
  18. package/cli/NgrokManager.js +431 -0
  19. package/cli/ProjectCLI.js +2322 -0
  20. package/cli/PubSubManager.js +129 -0
  21. package/cli/SnapshotManager.js +88 -0
  22. package/cli/UiFormatter.js +292 -0
  23. package/cli/WebhookUrlRewriter.js +32 -0
  24. package/cli/secrets/BiometricAuth.js +125 -0
  25. package/cli/secrets/SecretInjector.js +47 -0
  26. package/cli/secrets/SecretsConfig.js +141 -0
  27. package/cli/secrets/SecretsDoctor.js +384 -0
  28. package/cli/secrets/SecretsManager.js +255 -0
  29. package/client/dist/client.d.ts +332 -0
  30. package/client/dist/client.js +507 -0
  31. package/client/dist/helpers.d.ts +62 -0
  32. package/client/dist/helpers.js +122 -0
  33. package/client/dist/index.d.ts +59 -0
  34. package/client/dist/index.js +78 -0
  35. package/client/dist/package.json +1 -0
  36. package/client/dist/types.d.ts +280 -0
  37. package/client/dist/types.js +7 -0
  38. package/config.development +46 -0
  39. package/config.test +18 -0
  40. package/guidelines/CodingStyleGuideline.md +148 -0
  41. package/guidelines/CommentingGuideline.md +10 -0
  42. package/guidelines/HttpApiImplementationGuideline.md +137 -0
  43. package/guidelines/NamingGuideline.md +182 -0
  44. package/package.json +138 -0
  45. package/patterns/api/[collectionName]/Controllers.md +62 -0
  46. package/patterns/api/[collectionName]/Logic.md +154 -0
  47. package/patterns/api/[collectionName]/Permissions.md +81 -0
  48. package/patterns/api/[collectionName]/Router.md +83 -0
  49. package/patterns/api/[collectionName]/Schemas.md +197 -0
  50. package/patterns/configs/Patterns.md +7 -0
  51. package/patterns/enums/Patterns.md +24 -0
  52. package/patterns/errorHandling/Patterns.md +185 -0
  53. package/patterns/testing/Patterns.md +232 -0
  54. package/src/Server.js +238 -0
  55. package/src/api/dashboard/Controllers.js +9 -0
  56. package/src/api/dashboard/Logic.js +76 -0
  57. package/src/api/dashboard/Router.js +11 -0
  58. package/src/api/dashboard/Schemas.js +47 -0
  59. package/src/api/data/Controllers.js +26 -0
  60. package/src/api/data/Logic.js +188 -0
  61. package/src/api/data/Router.js +16 -0
  62. package/src/api/docker/Controllers.js +33 -0
  63. package/src/api/docker/Logic.js +268 -0
  64. package/src/api/docker/Router.js +15 -0
  65. package/src/api/docker/Schemas.js +80 -0
  66. package/src/api/docs/Controllers.js +15 -0
  67. package/src/api/docs/Logic.js +85 -0
  68. package/src/api/docs/Router.js +12 -0
  69. package/src/api/export/Controllers.js +30 -0
  70. package/src/api/export/Logic.js +143 -0
  71. package/src/api/export/Router.js +18 -0
  72. package/src/api/export/Schemas.js +104 -0
  73. package/src/api/firestore/Controllers.js +152 -0
  74. package/src/api/firestore/Logic.js +474 -0
  75. package/src/api/firestore/Router.js +23 -0
  76. package/src/api/functions/Controllers.js +261 -0
  77. package/src/api/functions/Logic.js +710 -0
  78. package/src/api/functions/Router.js +50 -0
  79. package/src/api/functions/Schemas.js +193 -0
  80. package/src/api/gateway/Controllers.js +72 -0
  81. package/src/api/gateway/Logic.js +74 -0
  82. package/src/api/gateway/Router.js +10 -0
  83. package/src/api/gateway/Schemas.js +19 -0
  84. package/src/api/health/Controllers.js +14 -0
  85. package/src/api/health/Logic.js +24 -0
  86. package/src/api/health/Router.js +12 -0
  87. package/src/api/httpTraffic/Controllers.js +29 -0
  88. package/src/api/httpTraffic/Logic.js +33 -0
  89. package/src/api/httpTraffic/Router.js +9 -0
  90. package/src/api/httpTraffic/Schemas.js +23 -0
  91. package/src/api/logging/Controllers.js +80 -0
  92. package/src/api/logging/Logic.js +461 -0
  93. package/src/api/logging/Router.js +24 -0
  94. package/src/api/logging/Schemas.js +43 -0
  95. package/src/api/mqtt/Controllers.js +17 -0
  96. package/src/api/mqtt/Logic.js +66 -0
  97. package/src/api/mqtt/Router.js +12 -0
  98. package/src/api/postgres/Controllers.js +97 -0
  99. package/src/api/postgres/Logic.js +221 -0
  100. package/src/api/postgres/Router.js +21 -0
  101. package/src/api/pubsub/Controllers.js +236 -0
  102. package/src/api/pubsub/Logic.js +732 -0
  103. package/src/api/pubsub/Router.js +41 -0
  104. package/src/api/pubsub/Schemas.js +355 -0
  105. package/src/api/redis/Controllers.js +63 -0
  106. package/src/api/redis/Logic.js +239 -0
  107. package/src/api/redis/Router.js +21 -0
  108. package/src/api/scheduler/Controllers.js +27 -0
  109. package/src/api/scheduler/Logic.js +49 -0
  110. package/src/api/scheduler/Router.js +16 -0
  111. package/src/api/services/Controllers.js +26 -0
  112. package/src/api/services/Logic.js +205 -0
  113. package/src/api/services/Router.js +14 -0
  114. package/src/api/services/Schemas.js +66 -0
  115. package/src/api/snapshots/Controllers.js +37 -0
  116. package/src/api/snapshots/Logic.js +797 -0
  117. package/src/api/snapshots/Router.js +15 -0
  118. package/src/api/snapshots/Schemas.js +23 -0
  119. package/src/api/webhooks/Controllers.js +49 -0
  120. package/src/api/webhooks/Logic.js +137 -0
  121. package/src/api/webhooks/Router.js +12 -0
  122. package/src/api/webhooks/Schemas.js +31 -0
  123. package/src/configs/Application.js +147 -0
  124. package/src/configs/Default.js +13 -0
  125. package/src/consumers/BlackboxLogsConsumer.js +235 -0
  126. package/src/consumers/DockerLogsConsumer.js +687 -0
  127. package/src/db/Tables.js +66 -0
  128. package/src/db/schemas/firestore.js +18 -0
  129. package/src/db/schemas/functions.js +65 -0
  130. package/src/db/schemas/httpTraffic.js +43 -0
  131. package/src/db/schemas/logging.js +74 -0
  132. package/src/db/schemas/migrations.js +64 -0
  133. package/src/db/schemas/mqtt.js +56 -0
  134. package/src/db/schemas/pubsub.js +90 -0
  135. package/src/db/schemas/pubsubRegistry.js +22 -0
  136. package/src/db/schemas/webhooks.js +28 -0
  137. package/src/emulation/awsiot/Controllers.js +91 -0
  138. package/src/emulation/awsiot/Logic.js +70 -0
  139. package/src/emulation/awsiot/Router.js +19 -0
  140. package/src/emulation/awsiot/Server.js +100 -0
  141. package/src/emulation/firestore/Server.js +136 -0
  142. package/src/emulation/logging/Controllers.js +212 -0
  143. package/src/emulation/logging/Logic.js +416 -0
  144. package/src/emulation/logging/Router.js +36 -0
  145. package/src/emulation/logging/Schemas.js +82 -0
  146. package/src/emulation/logging/Server.js +108 -0
  147. package/src/emulation/pubsub/Controllers.js +279 -0
  148. package/src/emulation/pubsub/DefaultTopics.js +162 -0
  149. package/src/emulation/pubsub/Logic.js +427 -0
  150. package/src/emulation/pubsub/README.md +309 -0
  151. package/src/emulation/pubsub/Router.js +33 -0
  152. package/src/emulation/pubsub/Server.js +104 -0
  153. package/src/emulation/pubsub/ShadowPoller.js +276 -0
  154. package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
  155. package/src/enums/ContainerNames.js +106 -0
  156. package/src/enums/ErrorReason.js +28 -0
  157. package/src/enums/FunctionStatuses.js +15 -0
  158. package/src/enums/FunctionTriggerTypes.js +15 -0
  159. package/src/enums/GatewayState.js +7 -0
  160. package/src/enums/ServiceNames.js +68 -0
  161. package/src/jobs/DatabaseMaintenance.js +184 -0
  162. package/src/jobs/MessageHistoryCleanup.js +152 -0
  163. package/src/mcp/ApiClient.js +25 -0
  164. package/src/mcp/Server.js +52 -0
  165. package/src/mcp/prompts/debugging.js +104 -0
  166. package/src/mcp/resources/platform.js +118 -0
  167. package/src/mcp/tools/data.js +84 -0
  168. package/src/mcp/tools/docker.js +166 -0
  169. package/src/mcp/tools/firestore.js +162 -0
  170. package/src/mcp/tools/functions.js +380 -0
  171. package/src/mcp/tools/httpTraffic.js +69 -0
  172. package/src/mcp/tools/logging.js +174 -0
  173. package/src/mcp/tools/mqtt.js +37 -0
  174. package/src/mcp/tools/postgres.js +130 -0
  175. package/src/mcp/tools/pubsub.js +316 -0
  176. package/src/mcp/tools/redis.js +146 -0
  177. package/src/mcp/tools/services.js +169 -0
  178. package/src/mcp/tools/snapshots.js +88 -0
  179. package/src/mcp/tools/webhooks.js +115 -0
  180. package/src/middleware/DevProxy.js +67 -0
  181. package/src/middleware/ErrorCatcher.js +35 -0
  182. package/src/middleware/HttpProxy.js +215 -0
  183. package/src/middleware/Reply.js +24 -0
  184. package/src/middleware/TraceId.js +9 -0
  185. package/src/middleware/WebhookProxy.js +234 -0
  186. package/src/protocols/mqtt/Broker.js +92 -0
  187. package/src/protocols/mqtt/Handlers.js +175 -0
  188. package/src/protocols/mqtt/PubSubBridge.js +162 -0
  189. package/src/protocols/mqtt/Server.js +116 -0
  190. package/src/runtime/FunctionRunner.js +179 -0
  191. package/src/services/AppGatewayService.js +582 -0
  192. package/src/singletons/FirestoreBroadcaster.js +367 -0
  193. package/src/singletons/FunctionTriggerDispatcher.js +456 -0
  194. package/src/singletons/FunctionsService.js +418 -0
  195. package/src/singletons/HttpProxy.js +224 -0
  196. package/src/singletons/LogBroadcaster.js +159 -0
  197. package/src/singletons/Logger.js +49 -0
  198. package/src/singletons/MemoryJsonStore.js +175 -0
  199. package/src/singletons/MessageBroadcaster.js +190 -0
  200. package/src/singletons/PostgresBroadcaster.js +367 -0
  201. package/src/singletons/PostgresClient.js +180 -0
  202. package/src/singletons/RedisClient.js +184 -0
  203. package/src/singletons/SqliteStore.js +480 -0
  204. package/src/singletons/TickService.js +151 -0
  205. package/src/singletons/WebhookProxy.js +223 -0
@@ -0,0 +1,451 @@
1
+ import { execa } from 'execa'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import { DockerManager } from './DockerManager.js'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ // Default service configuration
9
+ // Services without profiles (redis, pubsub) always start
10
+ // Services with profiles need to be explicitly enabled
11
+ const DEFAULT_SERVICES = {
12
+ redis: true, // Always on (no profile)
13
+ pubsub: true, // Always on (no profile)
14
+ postgres: false, // Has profile, opt-in
15
+ firestore: false, // Has profile, opt-in
16
+ 'redis-logs': false, // Has profile, opt-in
17
+ elasticsearch: false, // Has profile, opt-in
18
+ kibana: false // Has profile, opt-in
19
+ }
20
+
21
+ // Mapping from service config keys to Docker container names
22
+ const SERVICE_CONTAINERS = {
23
+ redis: 'goki-redis',
24
+ pubsub: 'goki-pubsub-emulator',
25
+ postgres: 'goki-postgres',
26
+ firestore: 'goki-firestore-emulator',
27
+ 'redis-logs': 'goki-redis-logs',
28
+ elasticsearch: 'goki-elasticsearch',
29
+ kibana: 'goki-kibana'
30
+ }
31
+
32
+ export class DevToolsManager {
33
+ // Package root is one level up from cli/
34
+ static DEV_TOOLS_PATH = path.resolve(__dirname, '..')
35
+ static HEALTH_URL = 'http://localhost:9000/v1/health/readiness'
36
+ static SERVICES_COMPOSE_FILE = 'docker-compose.services.yml'
37
+ static DEV_COMPOSE_FILE = 'docker-compose.dev.yml'
38
+
39
+ static async isRunning () {
40
+ try {
41
+ const response = await fetch(this.HEALTH_URL)
42
+ return response.ok
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Build profile arguments based on services configuration
50
+ * @param {Object} services - Service configuration from cli.config.js
51
+ * @returns {string[]} Array of --profile arguments
52
+ */
53
+ static buildProfileArgs (services = {}) {
54
+ const merged = { ...DEFAULT_SERVICES, ...services }
55
+ const profiles = []
56
+ // Only add profile args for services that have profiles and are enabled
57
+ const profiledServices = ['postgres', 'firestore', 'redis-logs', 'elasticsearch', 'kibana']
58
+ for (const service of profiledServices) {
59
+ if (merged[service]) {
60
+ profiles.push('--profile', service)
61
+ }
62
+ }
63
+ return profiles
64
+ }
65
+
66
+ /**
67
+ * Get list of enabled services for display
68
+ * @param {Object} services - Service configuration
69
+ * @returns {string[]} List of enabled service names
70
+ */
71
+ static getEnabledServices (services = {}) {
72
+ const merged = { ...DEFAULT_SERVICES, ...services }
73
+ return Object.entries(merged)
74
+ .filter(([_, enabled]) => enabled)
75
+ .map(([name]) => name)
76
+ }
77
+
78
+ /**
79
+ * Wait for all enabled infrastructure services to become healthy.
80
+ * Uses Docker healthcheck status via docker inspect for each container.
81
+ * Each service has its own timeout based on typical startup time.
82
+ * @param {Object} services - Service configuration from cli.config.js
83
+ * @returns {Promise<Object>} Results per service { serviceName: 'healthy'|'timeout' }
84
+ */
85
+ static async waitForServices (services = {}) {
86
+ const SERVICE_TIMEOUTS = {
87
+ postgres: 60000, // 60s for PostgreSQL (slow initial startup)
88
+ elasticsearch: 90000, // 90s for Elasticsearch (very slow)
89
+ firestore: 45000, // 45s for Firestore
90
+ redis: 15000, // 15s for Redis (fast)
91
+ pubsub: 15000, // 15s for Pub/Sub
92
+ kibana: 60000, // 60s for Kibana (waits for ES)
93
+ 'redis-logs': 15000 // 15s for Redis
94
+ }
95
+ const merged = { ...DEFAULT_SERVICES, ...services }
96
+ const enabled = Object.entries(merged)
97
+ .filter(([_, isEnabled]) => isEnabled)
98
+ .map(([name]) => name)
99
+ if (enabled.length === 0) return {}
100
+ const results = {}
101
+ await Promise.all(enabled.map(async (serviceName) => {
102
+ const containerName = SERVICE_CONTAINERS[serviceName]
103
+ if (!containerName) {
104
+ results[serviceName] = 'unknown'
105
+ return
106
+ }
107
+ try {
108
+ const timeout = SERVICE_TIMEOUTS[serviceName] || 30000
109
+ await DockerManager.waitForHealthy(containerName, timeout)
110
+ results[serviceName] = 'healthy'
111
+ } catch {
112
+ results[serviceName] = 'timeout'
113
+ }
114
+ }))
115
+ return results
116
+ }
117
+
118
+ /**
119
+ * Parse orphan container names from docker-compose error output
120
+ * @param {string} output - Error output from docker-compose
121
+ * @returns {string[]} Array of orphan container names
122
+ */
123
+ static parseOrphanContainers (output) {
124
+ const orphanMatch = output.match(/Found orphan containers \(([^)]+)\)/)
125
+ if (orphanMatch) {
126
+ return orphanMatch[1]
127
+ .replace(/[[\]]/g, '')
128
+ .split(',')
129
+ .map(name => name.trim())
130
+ .filter(name => name)
131
+ }
132
+ return []
133
+ }
134
+
135
+ /**
136
+ * Remove orphaned containers
137
+ * @param {string} composeFile - Path to compose file
138
+ * @param {string[]} profileArgs - Profile arguments
139
+ * @param {string} targetPath - Working directory
140
+ */
141
+ static async removeOrphans (composeFile, profileArgs, targetPath) {
142
+ const args = ['-f', composeFile, ...profileArgs, 'down', '--remove-orphans']
143
+ await execa('docker-compose', args, {
144
+ cwd: targetPath,
145
+ stdio: 'inherit'
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Handle orphaned containers with user confirmation
151
+ * @param {string[]} orphanNames - Names of orphaned containers
152
+ * @param {string} composeFile - Path to compose file
153
+ * @param {string[]} profileArgs - Profile arguments
154
+ * @param {string} targetPath - Working directory
155
+ * @returns {Promise<boolean>} True if user wants to continue
156
+ */
157
+ static async handleOrphanedContainers (orphanNames, composeFile, profileArgs, targetPath) {
158
+ console.log('\n⚠️ Found orphaned containers from previous runs:\n')
159
+ for (const name of orphanNames) {
160
+ console.log(` - ${name}`)
161
+ }
162
+ console.log('\nThese containers may conflict with the new setup.')
163
+ const inquirer = await import('inquirer')
164
+ const { action } = await inquirer.default.prompt([{
165
+ type: 'list',
166
+ name: 'action',
167
+ message: 'What would you like to do?',
168
+ choices: [
169
+ { name: 'Remove orphaned containers and continue', value: 'remove' },
170
+ { name: 'Cancel startup', value: 'cancel' }
171
+ ],
172
+ default: 'remove'
173
+ }])
174
+ if (action === 'cancel') {
175
+ return false
176
+ }
177
+ console.log('\n🧹 Removing orphaned containers...')
178
+ await this.removeOrphans(composeFile, profileArgs, targetPath)
179
+ console.log('✓ Orphaned containers removed\n')
180
+ return true
181
+ }
182
+
183
+ static async startServices (devToolsPath = null, services = {}) {
184
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
185
+ const composeFile = path.join(targetPath, this.SERVICES_COMPOSE_FILE)
186
+ const profileArgs = this.buildProfileArgs(services)
187
+ const args = ['-f', composeFile, ...profileArgs, 'up', '-d']
188
+ try {
189
+ await execa('docker-compose', args, {
190
+ cwd: targetPath,
191
+ stdio: 'pipe',
192
+ all: true
193
+ })
194
+ } catch (error) {
195
+ const output = error.all || error.stderr || error.message
196
+ // Check for orphan containers warning
197
+ if (output.includes('orphan containers')) {
198
+ const orphanNames = this.parseOrphanContainers(output)
199
+ if (orphanNames.length > 0) {
200
+ const shouldContinue = await this.handleOrphanedContainers(
201
+ orphanNames,
202
+ composeFile,
203
+ profileArgs,
204
+ targetPath
205
+ )
206
+ if (!shouldContinue) {
207
+ throw new Error('Startup cancelled by user')
208
+ }
209
+ // Retry after cleanup
210
+ await execa('docker-compose', args, {
211
+ cwd: targetPath,
212
+ stdio: 'pipe'
213
+ })
214
+ return
215
+ }
216
+ }
217
+ // Check for container name conflict
218
+ if (output.includes('is already in use')) {
219
+ const containerMatch = output.match(/name "\/([^"]+)"/)
220
+ const conflictingName = containerMatch ? containerMatch[1] : 'unknown'
221
+ console.log(`\n⚠️ Container name conflict detected: ${conflictingName}\n`)
222
+ const inquirer = await import('inquirer')
223
+ const { action } = await inquirer.default.prompt([{
224
+ type: 'list',
225
+ name: 'action',
226
+ message: 'What would you like to do?',
227
+ choices: [
228
+ { name: 'Remove conflicting containers and continue', value: 'remove' },
229
+ { name: 'Cancel startup', value: 'cancel' }
230
+ ],
231
+ default: 'remove'
232
+ }])
233
+ if (action === 'cancel') {
234
+ throw new Error('Startup cancelled by user')
235
+ }
236
+ console.log('\n🧹 Removing conflicting containers...')
237
+ await this.removeOrphans(composeFile, profileArgs, targetPath)
238
+ console.log('✓ Conflicting containers removed\n')
239
+ // Retry after cleanup
240
+ await execa('docker-compose', args, {
241
+ cwd: targetPath,
242
+ stdio: 'pipe'
243
+ })
244
+ return
245
+ }
246
+ // Other error, rethrow
247
+ throw error
248
+ }
249
+ }
250
+
251
+ static async startDevTools (devToolsPath = null, services = {}) {
252
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
253
+ const composeFile = path.join(targetPath, this.DEV_COMPOSE_FILE)
254
+ const profileArgs = this.buildProfileArgs(services)
255
+ const args = ['-f', composeFile, ...profileArgs, 'up', '-d']
256
+ try {
257
+ await execa('docker-compose', args, {
258
+ cwd: targetPath,
259
+ stdio: 'pipe',
260
+ all: true
261
+ })
262
+ } catch (error) {
263
+ const output = error.all || error.stderr || error.message
264
+ // Check for orphan containers warning
265
+ if (output.includes('orphan containers')) {
266
+ const orphanNames = this.parseOrphanContainers(output)
267
+ if (orphanNames.length > 0) {
268
+ const shouldContinue = await this.handleOrphanedContainers(
269
+ orphanNames,
270
+ composeFile,
271
+ profileArgs,
272
+ targetPath
273
+ )
274
+ if (!shouldContinue) {
275
+ throw new Error('Startup cancelled by user')
276
+ }
277
+ // Retry after cleanup
278
+ await execa('docker-compose', args, {
279
+ cwd: targetPath,
280
+ stdio: 'pipe'
281
+ })
282
+ return
283
+ }
284
+ }
285
+ // Check for container name conflict
286
+ if (output.includes('is already in use')) {
287
+ const containerMatch = output.match(/name "\/([^"]+)"/)
288
+ const conflictingName = containerMatch ? containerMatch[1] : 'unknown'
289
+ console.log(`\n⚠️ Container name conflict detected: ${conflictingName}\n`)
290
+ const inquirer = await import('inquirer')
291
+ const { action } = await inquirer.default.prompt([{
292
+ type: 'list',
293
+ name: 'action',
294
+ message: 'What would you like to do?',
295
+ choices: [
296
+ { name: 'Remove conflicting containers and continue', value: 'remove' },
297
+ { name: 'Cancel startup', value: 'cancel' }
298
+ ],
299
+ default: 'remove'
300
+ }])
301
+ if (action === 'cancel') {
302
+ throw new Error('Startup cancelled by user')
303
+ }
304
+ console.log('\n🧹 Removing conflicting containers...')
305
+ await this.removeOrphans(composeFile, profileArgs, targetPath)
306
+ console.log('✓ Conflicting containers removed\n')
307
+ // Retry after cleanup
308
+ await execa('docker-compose', args, {
309
+ cwd: targetPath,
310
+ stdio: 'pipe'
311
+ })
312
+ return
313
+ }
314
+ // Other error, rethrow
315
+ throw error
316
+ }
317
+ }
318
+
319
+ static async start (devToolsPath = null, services = {}) {
320
+ await this.startServices(devToolsPath, services)
321
+ await this.waitForServices(services)
322
+ await this.startDevTools(devToolsPath, services)
323
+ }
324
+
325
+ static async stopServices (devToolsPath = null) {
326
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
327
+ const composeFile = path.join(targetPath, this.SERVICES_COMPOSE_FILE)
328
+ // Use all profiles when stopping to ensure all containers are stopped
329
+ const allProfiles = ['--profile', 'postgres', '--profile', 'firestore', '--profile', 'redis-logs', '--profile', 'elasticsearch', '--profile', 'kibana']
330
+ // Get list of running containers
331
+ const containers = await this.getRunningContainers(composeFile, allProfiles, targetPath)
332
+ if (containers.length > 0) {
333
+ console.log(` Stopping ${containers.length} service container(s)...`)
334
+ for (const container of containers) {
335
+ console.log(` Stopping ${container}...`)
336
+ }
337
+ }
338
+ await execa('docker-compose', ['-f', composeFile, ...allProfiles, 'stop'], {
339
+ cwd: targetPath,
340
+ stdio: 'inherit'
341
+ })
342
+ }
343
+
344
+ static async stopDevTools (devToolsPath = null) {
345
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
346
+ const composeFile = path.join(targetPath, this.DEV_COMPOSE_FILE)
347
+ const allProfiles = ['--profile', 'postgres', '--profile', 'firestore', '--profile', 'elasticsearch', '--profile', 'kibana']
348
+ // Get list of running containers
349
+ const containers = await this.getRunningContainers(composeFile, allProfiles, targetPath)
350
+ if (containers.length > 0) {
351
+ console.log(` Stopping ${containers.length} dev-tools container(s)...`)
352
+ for (const container of containers) {
353
+ console.log(` Stopping ${container}...`)
354
+ }
355
+ }
356
+ await execa('docker-compose', ['-f', composeFile, ...allProfiles, 'stop'], {
357
+ cwd: targetPath,
358
+ stdio: 'inherit'
359
+ })
360
+ }
361
+
362
+ /**
363
+ * Get list of running containers for a compose file
364
+ * @param {string} composeFile - Path to compose file
365
+ * @param {string[]} profiles - Profile arguments
366
+ * @param {string} cwd - Working directory
367
+ * @returns {Promise<string[]>} Array of container names
368
+ */
369
+ static async getRunningContainers (composeFile, profiles, cwd) {
370
+ try {
371
+ const args = ['-f', composeFile, ...profiles, 'ps', '-q']
372
+ const result = await execa('docker-compose', args, { cwd })
373
+ const containerIds = result.stdout.split('\n').filter(id => id.trim())
374
+ if (containerIds.length === 0) return []
375
+ // Get container names from IDs
376
+ const names = []
377
+ for (const id of containerIds) {
378
+ const inspectResult = await execa('docker', ['inspect', '--format', '{{.Name}}', id])
379
+ names.push(inspectResult.stdout.replace(/^\//, ''))
380
+ }
381
+ return names
382
+ } catch {
383
+ return []
384
+ }
385
+ }
386
+
387
+ static async stop (devToolsPath = null) {
388
+ await this.stopDevTools(devToolsPath)
389
+ await this.stopServices(devToolsPath)
390
+ }
391
+
392
+ static async waitForReady (timeout = 60000) {
393
+ const startTime = Date.now()
394
+ while (Date.now() - startTime < timeout) {
395
+ if (await this.isRunning()) {
396
+ return true
397
+ }
398
+ await new Promise(resolve => setTimeout(resolve, 1000))
399
+ }
400
+ throw new Error(`Dev-tools did not become ready within ${timeout / 1000}s`)
401
+ }
402
+
403
+ static async ensureRunning (devToolsPath = null, services = {}) {
404
+ if (await this.isRunning()) {
405
+ return { started: false, message: 'Dev-tools already running' }
406
+ }
407
+ await this.start(devToolsPath, services)
408
+ await this.waitForReady()
409
+ return { started: true, message: 'Dev-tools started successfully' }
410
+ }
411
+
412
+ /**
413
+ * Start a specific service by name (for individual service control)
414
+ * @param {string} serviceName - Name of the service to start
415
+ * @param {string} devToolsPath - Path to dev-tools
416
+ */
417
+ static async startService (serviceName, devToolsPath = null) {
418
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
419
+ const composeFile = path.join(targetPath, this.SERVICES_COMPOSE_FILE)
420
+ const profiledServices = ['postgres', 'firestore', 'redis-logs', 'elasticsearch', 'kibana']
421
+ const args = ['-f', composeFile]
422
+ if (profiledServices.includes(serviceName)) {
423
+ args.push('--profile', serviceName)
424
+ }
425
+ args.push('up', '-d', serviceName === 'pubsub' ? 'pubsub-emulator' : serviceName === 'firestore' ? 'firestore-emulator' : serviceName)
426
+ await execa('docker-compose', args, {
427
+ cwd: targetPath,
428
+ stdio: 'pipe'
429
+ })
430
+ }
431
+
432
+ /**
433
+ * Stop a specific service by name
434
+ * @param {string} serviceName - Name of the service to stop
435
+ * @param {string} devToolsPath - Path to dev-tools
436
+ */
437
+ static async stopService (serviceName, devToolsPath = null) {
438
+ const targetPath = devToolsPath || this.DEV_TOOLS_PATH
439
+ const composeFile = path.join(targetPath, this.SERVICES_COMPOSE_FILE)
440
+ const profiledServices = ['postgres', 'firestore', 'redis-logs', 'elasticsearch', 'kibana']
441
+ const args = ['-f', composeFile]
442
+ if (profiledServices.includes(serviceName)) {
443
+ args.push('--profile', serviceName)
444
+ }
445
+ args.push('stop', serviceName === 'pubsub' ? 'pubsub-emulator' : serviceName === 'firestore' ? 'firestore-emulator' : serviceName)
446
+ await execa('docker-compose', args, {
447
+ cwd: targetPath,
448
+ stdio: 'pipe'
449
+ })
450
+ }
451
+ }
@@ -0,0 +1,138 @@
1
+ import { execa } from 'execa'
2
+ import { Logger } from './Logger.js'
3
+ import os from 'os'
4
+
5
+ export class DockerManager {
6
+ static async isRunning () {
7
+ try {
8
+ await execa('docker', ['info'], { timeout: 5000 })
9
+ return true
10
+ } catch {
11
+ return false
12
+ }
13
+ }
14
+
15
+ static async startDockerDesktop () {
16
+ const platform = os.platform()
17
+ if (platform === 'darwin') {
18
+ await execa('open', ['-a', 'Docker'])
19
+ } else if (platform === 'win32') {
20
+ await execa('powershell', ['-Command', 'Start-Process', 'Docker Desktop'])
21
+ } else {
22
+ throw new Error('Auto-starting Docker is only supported on macOS and Windows')
23
+ }
24
+ }
25
+
26
+ static async waitForDockerReady (timeout = 60000) {
27
+ const startTime = Date.now()
28
+ while (Date.now() - startTime < timeout) {
29
+ if (await this.isRunning()) {
30
+ return true
31
+ }
32
+ await new Promise(resolve => setTimeout(resolve, 2000))
33
+ }
34
+ throw new Error(`Docker did not start within ${timeout / 1000}s`)
35
+ }
36
+
37
+ static async ensureRunning () {
38
+ if (await this.isRunning()) {
39
+ return { wasStarted: false }
40
+ }
41
+ Logger.info('Docker is not running. Starting Docker Desktop...')
42
+ try {
43
+ await this.startDockerDesktop()
44
+ Logger.info('Waiting for Docker to be ready (this may take 10-30 seconds)...')
45
+ await this.waitForDockerReady()
46
+ Logger.success('Docker Desktop started successfully!')
47
+ return { wasStarted: true }
48
+ } catch (error) {
49
+ if (error.message.includes('Auto-starting Docker is only supported')) {
50
+ throw new Error('Docker is not running. Please start Docker Desktop manually.')
51
+ }
52
+ throw error
53
+ }
54
+ }
55
+
56
+ static async ensureNetwork (name) {
57
+ try {
58
+ await execa('docker', ['network', 'inspect', name])
59
+ } catch {
60
+ await execa('docker', ['network', 'create', name])
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Build the -f args array for docker-compose commands
66
+ * Always includes baseFile, adds overrideFile if provided
67
+ */
68
+ static buildComposeFileArgs (baseFile, overrideFile) {
69
+ const args = ['-f', baseFile]
70
+ if (overrideFile) {
71
+ args.push('-f', overrideFile)
72
+ }
73
+ return args
74
+ }
75
+
76
+ static async composeUp (baseFile, overrideFile, options = {}) {
77
+ const args = this.buildComposeFileArgs(baseFile, overrideFile)
78
+ args.push('up', '-d', '--remove-orphans')
79
+ const execaOpts = { stdio: 'inherit' }
80
+ if (options.env) {
81
+ execaOpts.env = { ...process.env, ...options.env }
82
+ }
83
+ await execa('docker-compose', args, execaOpts)
84
+ }
85
+
86
+ static async composeStop (baseFile, overrideFile) {
87
+ const args = this.buildComposeFileArgs(baseFile, overrideFile)
88
+ args.push('stop')
89
+ await execa('docker-compose', args, {
90
+ stdio: 'inherit'
91
+ })
92
+ }
93
+
94
+ static async composeDown (baseFile, overrideFile) {
95
+ const args = this.buildComposeFileArgs(baseFile, overrideFile)
96
+ args.push('down')
97
+ await execa('docker-compose', args, {
98
+ stdio: 'inherit'
99
+ })
100
+ }
101
+
102
+ static async composePs (baseFile, overrideFile) {
103
+ const args = this.buildComposeFileArgs(baseFile, overrideFile)
104
+ args.push('ps')
105
+ await execa('docker-compose', args, {
106
+ stdio: 'inherit'
107
+ })
108
+ }
109
+
110
+ static async composeLogs (baseFile, overrideFile, follow = false) {
111
+ const args = this.buildComposeFileArgs(baseFile, overrideFile)
112
+ args.push('logs')
113
+ if (follow) args.push('-f')
114
+ await execa('docker-compose', args, { stdio: 'inherit' })
115
+ }
116
+
117
+ static async waitForHealthy (containerName, timeout = 30000) {
118
+ const startTime = Date.now()
119
+ while (Date.now() - startTime < timeout) {
120
+ try {
121
+ const { stdout } = await execa('docker', [
122
+ 'inspect',
123
+ '--format={{.State.Status}}||{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}',
124
+ containerName
125
+ ])
126
+ const [status, health] = stdout.trim().split('||')
127
+ // No health check defined — running is good enough
128
+ if (health === 'none' && status === 'running') return
129
+ // Health check defined — wait for healthy
130
+ if (health === 'healthy') return
131
+ } catch {
132
+ // Container might not exist yet
133
+ }
134
+ await new Promise(resolve => setTimeout(resolve, 1000))
135
+ }
136
+ throw new Error(`Container ${containerName} did not become healthy within ${timeout}ms`)
137
+ }
138
+ }
@@ -0,0 +1,95 @@
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+
4
+ export class FunctionManager {
5
+ constructor (devToolsUrl = 'http://localhost:9000') {
6
+ this.devToolsUrl = devToolsUrl
7
+ }
8
+
9
+ async isReachable () {
10
+ try {
11
+ const response = await fetch(`${this.devToolsUrl}/v1/functions/list`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({}),
15
+ signal: AbortSignal.timeout(3000)
16
+ })
17
+ return response.ok
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ async registerFunctions (functionsConfig, projectDir) {
24
+ const { sourcePath, list } = functionsConfig
25
+ if (!list || list.length === 0) return { registered: [], errors: [] }
26
+ const resolvedSourcePath = sourcePath
27
+ ? path.resolve(projectDir, sourcePath)
28
+ : path.resolve(projectDir, '.dev-tools', 'functions')
29
+ // Check if source path exists
30
+ if (!fs.existsSync(resolvedSourcePath)) {
31
+ return {
32
+ registered: [],
33
+ errors: [{ message: `Functions source path not found: ${resolvedSourcePath}` }]
34
+ }
35
+ }
36
+ // Install dependencies if needed
37
+ await this.installDependencies(resolvedSourcePath)
38
+ const registered = []
39
+ const errors = []
40
+ for (const fn of list) {
41
+ try {
42
+ const result = await this.registerFunction(fn, resolvedSourcePath)
43
+ registered.push({ name: fn.name, ...result })
44
+ } catch (error) {
45
+ errors.push({ name: fn.name, error: error.message })
46
+ }
47
+ }
48
+ return { registered, errors }
49
+ }
50
+
51
+ async registerFunction (fn, sourcePath) {
52
+ const body = {
53
+ name: fn.name,
54
+ source: fn.source,
55
+ sourcePath,
56
+ entryPoint: fn.entryPoint || fn.name,
57
+ triggerType: fn.triggerType,
58
+ triggerConfig: fn.triggerConfig || {},
59
+ signatureType: fn.signatureType || 'cloudevent',
60
+ runtime: fn.runtime || 'nodejs20',
61
+ timeoutSeconds: fn.timeoutSeconds || 60,
62
+ description: fn.description || 'Auto-registered from config'
63
+ }
64
+ if (fn.env) {
65
+ body.environmentVariables = fn.env
66
+ }
67
+ const response = await fetch(`${this.devToolsUrl}/v1/functions/create`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify(body),
71
+ signal: AbortSignal.timeout(30000)
72
+ })
73
+ const result = await response.json()
74
+ if (!result.success && result.success !== undefined) {
75
+ throw new Error(result.message || 'Failed to register function')
76
+ }
77
+ return result
78
+ }
79
+
80
+ async installDependencies (sourcePath) {
81
+ const packageJsonPath = path.join(sourcePath, 'package.json')
82
+ const nodeModulesPath = path.join(sourcePath, 'node_modules')
83
+ if (fs.existsSync(packageJsonPath) && !fs.existsSync(nodeModulesPath)) {
84
+ const { execa } = await import('execa')
85
+ try {
86
+ await execa('npm', ['install'], { cwd: sourcePath, timeout: 120000 })
87
+ return true
88
+ } catch (error) {
89
+ // Non-fatal — warn but continue
90
+ return false
91
+ }
92
+ }
93
+ return true
94
+ }
95
+ }