@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,2322 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { execa } from 'execa'
4
+ import { Logger } from './Logger.js'
5
+ import { DevToolsChecker } from './DevToolsChecker.js'
6
+ import { DevToolsManager } from './DevToolsManager.js'
7
+ import { DockerManager } from './DockerManager.js'
8
+ import { DatabaseManager } from './DatabaseManager.js'
9
+ import { PubSubManager } from './PubSubManager.js'
10
+ import { NgrokManager } from './NgrokManager.js'
11
+ import { computeProxyRewrites } from './HttpProxyRewriter.js'
12
+ import { computeWebhookRewrites } from './WebhookUrlRewriter.js'
13
+ import { generateComposeOverride, getOverridePath } from './ComposeOverrideGenerator.js'
14
+ import { ensureMcpConfig, checkClaudePermissions, addClaudePermissions } from './McpConfigManager.js'
15
+ import { ConfigManager } from './ConfigManager.js'
16
+ import { formatStartupOutput } from './UiFormatter.js'
17
+ import { SnapshotManager } from './SnapshotManager.js'
18
+ import { FunctionManager } from './FunctionManager.js'
19
+
20
+ export class ProjectCLI {
21
+ constructor (config) {
22
+ this.config = config
23
+ this.validateConfig()
24
+ }
25
+
26
+ validateConfig () {
27
+ // Only name is truly required
28
+ // database/schemaPath are only needed for PostgreSQL projects
29
+ const required = ['name']
30
+ for (const field of required) {
31
+ if (!this.config[field]) {
32
+ throw new Error(`Missing required config field: ${field}`)
33
+ }
34
+ }
35
+ // Warn if docker.composeFile is missing, default to docker-compose.yaml
36
+ if (!this.config.docker?.composeFile) {
37
+ Logger.warn('Warning: docker.composeFile not specified, defaulting to docker-compose.yaml')
38
+ }
39
+ // Validate that if database is specified, schemaPath should also be specified
40
+ if (this.config.database && !this.config.schemaPath) {
41
+ Logger.warn('Warning: database specified but schemaPath is missing')
42
+ }
43
+ // Validate services configuration matches project needs
44
+ this.validateServicesConfig()
45
+ this.validateHttpProxyConfig()
46
+ }
47
+
48
+ validateServicesConfig () {
49
+ const services = this.config.services || {}
50
+ // If project uses PostgreSQL (has database config), postgres service should be enabled
51
+ if (this.config.database && services.postgres !== true) {
52
+ Logger.error(`Error: Project uses PostgreSQL (database: ${this.config.database}) but postgres service is not enabled`)
53
+ Logger.error('Add to your .dev-tools/config.js:')
54
+ Logger.error(' services: { postgres: true }')
55
+ process.exit(1)
56
+ }
57
+ // If project has pubsub topics, pubsub should be enabled (it's on by default)
58
+ if (this.config.pubsub?.topics?.length && services.pubsub === false) {
59
+ Logger.warn('Warning: Project has Pub/Sub topics but pubsub service is disabled')
60
+ }
61
+ // Kibana requires Elasticsearch
62
+ if (services.kibana === true && services.elasticsearch !== true) {
63
+ Logger.warn('Warning: Kibana is enabled but Elasticsearch is not. Kibana requires Elasticsearch.')
64
+ }
65
+ }
66
+
67
+ validateHttpProxyConfig () {
68
+ const httpProxy = this.config.httpProxy
69
+ if (!httpProxy?.enabled) return
70
+ const hasInclude = Array.isArray(httpProxy.include) && httpProxy.include.length > 0
71
+ const hasExclude = Array.isArray(httpProxy.exclude) && httpProxy.exclude.length > 0
72
+ if (hasInclude && hasExclude) {
73
+ Logger.warn('Warning: httpProxy has both include and exclude — include takes priority, exclude is ignored')
74
+ }
75
+ if (hasInclude) {
76
+ for (const pattern of httpProxy.include) {
77
+ if (typeof pattern !== 'string' || pattern.length === 0) {
78
+ throw new Error('httpProxy.include must contain non-empty strings')
79
+ }
80
+ }
81
+ }
82
+ if (hasExclude) {
83
+ for (const pattern of httpProxy.exclude) {
84
+ if (typeof pattern !== 'string' || pattern.length === 0) {
85
+ throw new Error('httpProxy.exclude must contain non-empty strings')
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ getBaseCompose () {
92
+ return this.config.docker?.composeFile || 'docker-compose.yaml'
93
+ }
94
+
95
+ getOverrideCompose () {
96
+ return getOverridePath(process.cwd())
97
+ }
98
+
99
+ getContainerName () {
100
+ return this.config.docker?.containerName || null
101
+ }
102
+
103
+ getAppPort () {
104
+ return this.config.docker?.ports?.http || null
105
+ }
106
+
107
+ getServiceDescription (serviceName) {
108
+ // For backward compatibility (used in other places)
109
+ return this.config.serviceDescriptions?.[serviceName] || this.getDefaultServiceDescription(serviceName)
110
+ }
111
+
112
+ async promptStartDocker () {
113
+ console.log('\n🐳 Docker is not running.\n')
114
+ const inquirer = await import('inquirer')
115
+ const { startDocker } = await inquirer.default.prompt([{
116
+ type: 'confirm',
117
+ name: 'startDocker',
118
+ message: 'Start Docker Desktop now?',
119
+ default: true
120
+ }])
121
+ return startDocker
122
+ }
123
+
124
+ async promptMcpPermissions () {
125
+ const savedPreference = ConfigManager.getMcpPreference()
126
+ const { allowed: claudeSettingsExist } = checkClaudePermissions()
127
+ // Case 1: User previously answered
128
+ if (savedPreference !== null) {
129
+ if (savedPreference.allowed === true) {
130
+ // User said "yes" previously
131
+ if (claudeSettingsExist) {
132
+ // Settings exist - all good, skip prompt
133
+ return
134
+ }
135
+ // Settings missing - re-enable without prompting
136
+ console.log('')
137
+ console.log(' ⚠️ MCP permissions were previously enabled but settings are missing')
138
+ console.log(' Re-enabling...\n')
139
+ addClaudePermissions()
140
+ console.log(' ✓ Re-enabled .claude/settings.local.json\n')
141
+ return
142
+ } else {
143
+ // User said "no" previously - respect their choice, never ask again
144
+ return
145
+ }
146
+ }
147
+ // Case 2: No saved preference but Claude settings manually added
148
+ if (claudeSettingsExist) {
149
+ // Save preference so we don't ask again
150
+ ConfigManager.saveMcpPreference(true)
151
+ return
152
+ }
153
+ // Case 3: First time - prompt user
154
+ console.log('')
155
+ console.log(' 🤖 Claude Code MCP permissions')
156
+ console.log(' Allow Claude Code to use dev-tools MCP commands without prompting?')
157
+ console.log(' This adds "mcp__goki-dev-tools__*" to .claude/settings.local.json\n')
158
+ const inquirer = await import('inquirer')
159
+ const { allow } = await inquirer.default.prompt([{
160
+ type: 'confirm',
161
+ name: 'allow',
162
+ message: 'Allow all dev-tools MCP tools for Claude Code?',
163
+ default: true
164
+ }])
165
+ // Save preference (whether yes or no)
166
+ ConfigManager.saveMcpPreference(allow)
167
+ // If allowed, update Claude settings
168
+ if (allow) {
169
+ const result = addClaudePermissions()
170
+ const action = result.created ? 'Created' : 'Updated'
171
+ console.log(` ✓ ${action} .claude/settings.local.json\n`)
172
+ }
173
+ }
174
+
175
+ detectPackageManager () {
176
+ const projectDir = process.cwd()
177
+ if (fs.existsSync(path.join(projectDir, 'yarn.lock'))) {
178
+ return { type: 'yarn', command: 'yarn', args: ['install'] }
179
+ }
180
+ if (fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))) {
181
+ return { type: 'pnpm', command: 'pnpm', args: ['install'] }
182
+ }
183
+ if (fs.existsSync(path.join(projectDir, 'package-lock.json'))) {
184
+ return { type: 'npm', command: 'npm', args: ['install'] }
185
+ }
186
+ return { type: 'npm', command: 'npm', args: ['install'] }
187
+ }
188
+
189
+ areDependenciesInstalled () {
190
+ const projectDir = process.cwd()
191
+ const nodeModulesPath = path.join(projectDir, 'node_modules')
192
+ const yarnPath = path.join(projectDir, '.yarn')
193
+ const pnpCjsPath = path.join(projectDir, '.pnp.cjs')
194
+ if (fs.existsSync(nodeModulesPath)) {
195
+ return true
196
+ }
197
+ if (fs.existsSync(yarnPath) || fs.existsSync(pnpCjsPath)) {
198
+ return true
199
+ }
200
+ return false
201
+ }
202
+
203
+ async ensureDependencies () {
204
+ const projectDir = process.cwd()
205
+ const packageJsonPath = path.join(projectDir, 'package.json')
206
+ if (!fs.existsSync(packageJsonPath)) {
207
+ return
208
+ }
209
+ if (this.areDependenciesInstalled()) {
210
+ return
211
+ }
212
+ const pm = this.detectPackageManager()
213
+ const spinner = Logger.spinner(`Installing dependencies with ${pm.type}...`)
214
+ try {
215
+ await execa(pm.command, pm.args, {
216
+ cwd: projectDir,
217
+ stdio: 'pipe'
218
+ })
219
+ spinner.succeed('Dependencies installed!')
220
+ } catch (error) {
221
+ spinner.fail('Failed to install dependencies')
222
+ Logger.error(error.message)
223
+ Logger.info('\nTry running manually:')
224
+ Logger.info(` ${pm.command} ${pm.args.join(' ')}`)
225
+ process.exit(1)
226
+ }
227
+ }
228
+
229
+ async startDockerWithPrompt () {
230
+ const spinner = Logger.spinner('Starting Docker Desktop')
231
+ try {
232
+ await DockerManager.startDockerDesktop()
233
+ spinner.text = 'Waiting for Docker to be ready (this may take 10-30 seconds)...'
234
+ await DockerManager.waitForDockerReady()
235
+ spinner.succeed('Docker Desktop started!')
236
+ } catch (error) {
237
+ spinner.fail('Failed to start Docker')
238
+ Logger.error(error.message)
239
+ process.exit(1)
240
+ }
241
+ }
242
+
243
+ async checkServiceStatus (serviceName, port) {
244
+ try {
245
+ const response = await fetch(`http://localhost:${port}`, {
246
+ method: 'GET',
247
+ signal: AbortSignal.timeout(1000)
248
+ })
249
+ return response.ok || response.status < 500 ? '✓ running' : '○ stopped'
250
+ } catch {
251
+ return '○ stopped'
252
+ }
253
+ }
254
+
255
+ async promptStartDevTools () {
256
+ const services = this.config.services || {}
257
+ const enabledServices = DevToolsManager.getEnabledServices(services)
258
+ console.log('\n📦 Dev-tools is not running.\n')
259
+ console.log('This project requires the following services:\n')
260
+ // Service port mapping
261
+ const ports = {
262
+ redis: 6379,
263
+ pubsub: 8085,
264
+ postgres: 5432,
265
+ firestore: 8080,
266
+ 'redis-logs': 6380,
267
+ elasticsearch: 9200,
268
+ kibana: 5601
269
+ }
270
+ // Check status of each service
271
+ const serviceData = await Promise.all(enabledServices.map(async (name) => {
272
+ const port = ports[name] || '-'
273
+ const desc = this.config.serviceDescriptions?.[name] || this.getDefaultServiceDescription(name)
274
+ const status = await this.checkServiceStatus(name, port)
275
+ return { name, desc, port, status }
276
+ }))
277
+ // Calculate column widths
278
+ const nameWidth = Math.max(10, ...serviceData.map(s => s.name.length)) + 2
279
+ const descWidth = Math.max(20, ...serviceData.map(s => s.desc.length)) + 2
280
+ const portWidth = 8
281
+ const statusWidth = 12
282
+ // Print table header
283
+ const header = ` ${'Service'.padEnd(nameWidth)}${'Description'.padEnd(descWidth)}${'Port'.padEnd(portWidth)}${'Status'.padEnd(statusWidth)}`
284
+ const separator = ` ${'-'.repeat(nameWidth)}${'-'.repeat(descWidth)}${'-'.repeat(portWidth)}${'-'.repeat(statusWidth)}`
285
+ console.log(header)
286
+ console.log(separator)
287
+ // Print table rows
288
+ for (const svc of serviceData) {
289
+ const statusColor = svc.status.startsWith('✓') ? '\x1b[32m' : '\x1b[90m' // green for running, gray for stopped
290
+ const reset = '\x1b[0m'
291
+ console.log(` ${svc.name.padEnd(nameWidth)}${svc.desc.padEnd(descWidth)}${String(svc.port).padEnd(portWidth)}${statusColor}${svc.status}${reset}`)
292
+ }
293
+ console.log('')
294
+ const inquirer = await import('inquirer')
295
+ const { startDevTools } = await inquirer.default.prompt([{
296
+ type: 'confirm',
297
+ name: 'startDevTools',
298
+ message: 'Start dev-tools now?',
299
+ default: true
300
+ }])
301
+ return startDevTools
302
+ }
303
+
304
+ getDefaultServiceDescription (serviceName) {
305
+ const defaults = {
306
+ redis: 'cache & locks',
307
+ pubsub: 'GCP Pub/Sub emulator',
308
+ postgres: 'PostgreSQL database',
309
+ firestore: 'GCP Firestore emulator',
310
+ 'redis-logs': 'log storage LRU',
311
+ elasticsearch: 'search engine ~2GB',
312
+ kibana: 'Elasticsearch dashboard'
313
+ }
314
+ return defaults[serviceName] || ''
315
+ }
316
+
317
+ async startDevToolsWithServices () {
318
+ const services = this.config.services || {}
319
+ const enabledServices = DevToolsManager.getEnabledServices(services)
320
+ const spinner = Logger.spinner(`Starting infrastructure services (${enabledServices.join(', ')})`)
321
+ try {
322
+ const devToolsPath = this.config.devToolsPath || null
323
+ // Phase 1: Start infrastructure containers
324
+ await DevToolsManager.startServices(devToolsPath, services)
325
+ // Phase 2: Wait for infrastructure to be healthy
326
+ spinner.text = 'Waiting for infrastructure services to be healthy...'
327
+ const results = await DevToolsManager.waitForServices(services)
328
+ const failed = Object.entries(results).filter(([_, s]) => s !== 'healthy')
329
+ if (failed.length > 0) {
330
+ const failedNames = failed.map(([name]) => name).join(', ')
331
+ // Check if any critical service failed
332
+ const criticalServices = ['postgres', 'redis', 'pubsub']
333
+ const criticalFailed = failed.some(([name]) =>
334
+ services[name] && criticalServices.includes(name)
335
+ )
336
+ if (criticalFailed) {
337
+ spinner.fail(`Critical services failed to start: ${failedNames}`)
338
+ Logger.error('Fix the service issues and try again')
339
+ Logger.info('Run "docker-compose -f docker-compose.services.yml logs <service>" to debug')
340
+ process.exit(1)
341
+ } else {
342
+ spinner.warn(`Optional services not healthy: ${failedNames}`)
343
+ spinner.start()
344
+ }
345
+ }
346
+ // Give PostgreSQL extra time to fully initialize after health check passes
347
+ if (results.postgres === 'healthy') {
348
+ spinner.text = 'PostgreSQL healthy, allowing initialization to complete...'
349
+ await new Promise(resolve => setTimeout(resolve, 2000))
350
+ }
351
+ // Phase 3: Start dev-tools backend
352
+ spinner.text = 'Starting dev-tools backend...'
353
+ await DevToolsManager.startDevTools(devToolsPath, services)
354
+ spinner.text = 'Waiting for dev-tools to be ready...'
355
+ await DevToolsManager.waitForReady(90000) // 90 second timeout for first start
356
+ spinner.succeed('Dev-tools started!')
357
+ } catch (error) {
358
+ spinner.fail('Failed to start dev-tools')
359
+ Logger.error(error.message)
360
+ Logger.info('\nTry starting manually:')
361
+ Logger.info(' cd ../dev-tools')
362
+ Logger.info(' docker-compose -f docker-compose.services.yml up -d')
363
+ process.exit(1)
364
+ }
365
+ }
366
+
367
+ async configureAppGateway (spinner) {
368
+ const appGateway = this.config.appGateway
369
+ if (!appGateway) return
370
+ try {
371
+ if (appGateway.enabled) {
372
+ if (spinner) spinner.text = 'Configuring App Gateway...'
373
+ // Check current gateway status
374
+ const statusResponse = await fetch('http://localhost:9000/v1/gateway/status', {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({})
378
+ })
379
+ const statusResult = await statusResponse.json()
380
+ const isRunning = statusResult.data?.state === 'running'
381
+ const isWaiting = statusResult.data?.state === 'waitingForDependencies'
382
+ if (isRunning || isWaiting) {
383
+ if (spinner) spinner.text = `App Gateway is ${isRunning ? 'running' : 'waiting for dependencies'}`
384
+ } else {
385
+ // Start the gateway
386
+ if (spinner) spinner.text = 'Starting App Gateway...'
387
+ await fetch('http://localhost:9000/v1/gateway/start', {
388
+ method: 'POST',
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify({})
391
+ })
392
+ if (spinner) spinner.text = 'App Gateway started'
393
+ }
394
+ } else {
395
+ // Gateway disabled — stop if running
396
+ if (spinner) spinner.text = 'App Gateway disabled, ensuring stopped...'
397
+ await fetch('http://localhost:9000/v1/gateway/stop', {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify({})
401
+ })
402
+ }
403
+ } catch (error) {
404
+ // Non-fatal — gateway configuration is optional
405
+ if (spinner) spinner.text = 'App Gateway configuration skipped (dev-tools may not be ready)'
406
+ }
407
+ }
408
+
409
+ async start () {
410
+ // Ensure dependencies are installed
411
+ await this.ensureDependencies()
412
+ // Ensure MCP config before anything else (doesn't depend on Docker/services)
413
+ const mcpResult = ensureMcpConfig()
414
+ await this.promptMcpPermissions()
415
+ const spinner = Logger.spinner(`Starting ${this.config.name}`)
416
+ try {
417
+ // Step 1: Ensure Docker is running first
418
+ spinner.text = 'Checking Docker...'
419
+ const dockerRunning = await DockerManager.isRunning()
420
+ if (!dockerRunning) {
421
+ spinner.stop()
422
+ const shouldStartDocker = await this.promptStartDocker()
423
+ if (!shouldStartDocker) {
424
+ process.exit(0)
425
+ }
426
+ await this.startDockerWithPrompt()
427
+ }
428
+ // Step 2: Ensure required services are running
429
+ const services = this.config.services || {}
430
+ const enabledServices = DevToolsManager.getEnabledServices(services)
431
+ const devToolsPath = this.config.devToolsPath || null
432
+
433
+ // Always check and start services (even if dev-tools backend is already running)
434
+ if (enabledServices.length > 0) {
435
+ spinner.text = `Ensuring infrastructure services (${enabledServices.join(', ')})...`
436
+ await DevToolsManager.startServices(devToolsPath, services)
437
+ spinner.text = 'Waiting for infrastructure services to be healthy...'
438
+ const results = await DevToolsManager.waitForServices(services)
439
+ const failed = Object.entries(results).filter(([_, s]) => s !== 'healthy')
440
+ if (failed.length > 0) {
441
+ const failedNames = failed.map(([name]) => name).join(', ')
442
+ // Check if any critical service failed
443
+ const criticalServices = ['postgres', 'redis', 'pubsub']
444
+ const criticalFailed = failed.some(([name]) =>
445
+ services[name] && criticalServices.includes(name)
446
+ )
447
+ if (criticalFailed) {
448
+ spinner.fail(`Critical services failed to start: ${failedNames}`)
449
+ Logger.error('Fix the service issues and try again')
450
+ Logger.info('Run "docker-compose -f docker-compose.services.yml logs <service>" to debug')
451
+ process.exit(1)
452
+ } else {
453
+ spinner.warn(`Optional services not healthy: ${failedNames}`)
454
+ spinner.start()
455
+ }
456
+ }
457
+ // Give PostgreSQL extra time to fully initialize after health check passes
458
+ if (results.postgres === 'healthy') {
459
+ spinner.text = 'PostgreSQL healthy, allowing initialization to complete...'
460
+ await new Promise(resolve => setTimeout(resolve, 2000))
461
+ }
462
+ }
463
+
464
+ // Step 3: Ensure dev-tools backend is running (auto-start if needed)
465
+ spinner.start('Checking dev-tools status...')
466
+ const devToolsRunning = await DevToolsChecker.isRunning()
467
+ if (!devToolsRunning) {
468
+ spinner.stop()
469
+ // Ask user if they want to start dev-tools backend
470
+ const shouldStart = await this.promptStartDevTools()
471
+ if (!shouldStart) {
472
+ process.exit(0)
473
+ }
474
+ // Start dev-tools backend
475
+ spinner.start('Starting dev-tools backend...')
476
+ await DevToolsManager.startDevTools(devToolsPath, services)
477
+ await DevToolsManager.waitForReady()
478
+ }
479
+ // Step 3.5: Verify service dependencies
480
+ if (this.config.dependencies?.services) {
481
+ await this.verifyServiceDependencies(spinner)
482
+ }
483
+ // Step 4: Ensure Docker network
484
+ spinner.start('Ensuring Docker network...')
485
+ await DockerManager.ensureNetwork('goki-network')
486
+ // Step 4: Initialize database if configured (PostgreSQL only)
487
+ if (this.config.database) {
488
+ spinner.text = 'Verifying database setup...'
489
+ const dbManager = new DatabaseManager({
490
+ host: 'localhost',
491
+ port: 5432,
492
+ user: 'postgres',
493
+ password: 'postgres',
494
+ database: this.config.database
495
+ })
496
+ const dbReachable = await dbManager.isReachable()
497
+ if (!dbReachable) {
498
+ spinner.warn('PostgreSQL not reachable (ensure postgres service is enabled)')
499
+ } else {
500
+ const dbExists = await dbManager.databaseExists()
501
+ if (!dbExists) {
502
+ spinner.text = 'Creating database...'
503
+ await dbManager.createDatabase()
504
+ spinner.text = 'Running schema...'
505
+ await dbManager.runSchema(this.config.schemaPath)
506
+ spinner.text = 'Database initialized!'
507
+ } else {
508
+ spinner.text = 'Database verified'
509
+ }
510
+ }
511
+ }
512
+ // Step 5: Initialize Pub/Sub topics and subscriptions if configured
513
+ if (this.config.pubsub?.topics?.length) {
514
+ spinner.text = 'Verifying Pub/Sub setup...'
515
+ const pubsubManager = new PubSubManager(this.config.pubsub)
516
+ const pubsubReachable = await pubsubManager.isReachable()
517
+ if (pubsubReachable) {
518
+ const results = await pubsubManager.initializeAll(this.config.pubsub.topics)
519
+ const newTopics = results.topics.filter(t => t.result === 'created')
520
+ const newSubs = results.subscriptions.filter(s => s.result === 'created')
521
+ if (newTopics.length > 0 || newSubs.length > 0) {
522
+ spinner.text = `Created ${newTopics.length} topic(s), ${newSubs.length} subscription(s)`
523
+ }
524
+ // Register topics with dev-tools for auto-recovery after emulator restart
525
+ await pubsubManager.registerWithDevTools(this.config.pubsub.topics, this.config.name)
526
+ } else {
527
+ spinner.warn('Pub/Sub emulator not reachable (skipping setup)')
528
+ }
529
+ }
530
+ // Step 5.5: Register webhook routes and start ngrok if configured
531
+ let webhookResult = null
532
+ if (this.config.webhookProxy?.enabled) {
533
+ const webhooks = this.config.webhookProxy.routes || {}
534
+ webhookResult = await NgrokManager.setup(webhooks, spinner, this.config.name)
535
+ }
536
+ // Step 5.6: Register Cloud Functions if configured
537
+ if (this.config.functions?.list?.length) {
538
+ spinner.text = 'Registering Cloud Functions...'
539
+ const fnManager = new FunctionManager()
540
+ const fnReachable = await fnManager.isReachable()
541
+ if (fnReachable) {
542
+ const fnResult = await fnManager.registerFunctions(this.config.functions, process.cwd())
543
+ if (fnResult.registered.length > 0) {
544
+ spinner.text = `Registered ${fnResult.registered.length} Cloud Function(s)`
545
+ }
546
+ if (fnResult.errors.length > 0) {
547
+ for (const err of fnResult.errors) {
548
+ Logger.warn(`Function '${err.name}' failed: ${err.error}`)
549
+ }
550
+ }
551
+ } else {
552
+ spinner.warn('Dev-tools backend not reachable (skipping Cloud Functions setup)')
553
+ }
554
+ }
555
+ // Step 5.7: Compute proxy rewrites (data only)
556
+ let proxyResult = null
557
+ if (this.config.httpProxy?.enabled) {
558
+ spinner.text = 'Computing HTTP proxy rewrites...'
559
+ proxyResult = computeProxyRewrites(this.config, process.cwd())
560
+ }
561
+ // Step 5.7: Compute webhook rewrites (data only)
562
+ let webhookRewrites = null
563
+ if (webhookResult?.tunnelUrl) {
564
+ spinner.text = 'Computing webhook URL substitutions...'
565
+ const webhookRewriteResult = computeWebhookRewrites(this.config, process.cwd(), webhookResult.tunnelUrl)
566
+ webhookRewrites = webhookRewriteResult?.rewrites || null
567
+ }
568
+ // Step 5.8: Unlock secrets if config.secrets is defined
569
+ let secretsManager = null
570
+ let secretEnv = {}
571
+ if (this.config.secrets) {
572
+ try {
573
+ spinner.text = 'Unlocking secrets...'
574
+ spinner.stop()
575
+ const { SecretsManager } = await import('./secrets/SecretsManager.js')
576
+ secretsManager = await SecretsManager.create({ projectDir: process.cwd() })
577
+ await secretsManager.unlock()
578
+ secretEnv = secretsManager.getEnvMap()
579
+ spinner.start()
580
+ const keyCount = Object.keys(secretEnv).length
581
+ if (keyCount > 0) {
582
+ spinner.text = `${keyCount} secrets loaded`
583
+ }
584
+ } catch (err) {
585
+ spinner.start()
586
+ Logger.warn(`Secrets unlock skipped: ${err.message}`)
587
+ }
588
+ }
589
+ // Step 6: Generate single combined override
590
+ spinner.text = 'Generating docker-compose override...'
591
+ const overrideResult = generateComposeOverride(this.config, process.cwd(), {
592
+ proxyRewrites: proxyResult?.rewrites,
593
+ webhookRewrites,
594
+ secretKeys: Object.keys(secretEnv),
595
+ secretsManager
596
+ })
597
+ // Step 7: Start application
598
+ spinner.text = `Starting ${this.config.name} application...`
599
+ await DockerManager.composeUp(this.getBaseCompose(), this.getOverrideCompose(), {
600
+ env: Object.keys(secretEnv).length > 0 ? secretEnv : undefined
601
+ })
602
+ if (this.getContainerName()) {
603
+ spinner.text = 'Waiting for application to be ready...'
604
+ await DockerManager.waitForHealthy(this.getContainerName(), 30000)
605
+ }
606
+ // Step 8: Configure App Gateway if specified
607
+ await this.configureAppGateway(spinner)
608
+ spinner.succeed('Setup complete!')
609
+ // Display beautiful formatted output
610
+ console.log(formatStartupOutput({
611
+ projectName: this.config.name,
612
+ appPort: this.getAppPort(),
613
+ webhookResult,
614
+ webhookUrlResult: webhookRewrites ? { rewrites: webhookRewrites } : null,
615
+ proxyResult,
616
+ overrideResult,
617
+ mcpResult
618
+ }))
619
+ } catch (error) {
620
+ spinner.fail('Failed to start')
621
+ Logger.error(error.message)
622
+ process.exit(1)
623
+ }
624
+ }
625
+
626
+ async stop () {
627
+ const spinner = Logger.spinner(`Stopping ${this.config.name}`)
628
+ try {
629
+ await DockerManager.composeStop(this.getBaseCompose(), this.getOverrideCompose())
630
+ spinner.succeed(`${this.config.name} stopped successfully!`)
631
+ } catch (error) {
632
+ spinner.fail('Failed to stop')
633
+ Logger.error(error.message)
634
+ process.exit(1)
635
+ }
636
+ }
637
+
638
+ async status () {
639
+ try {
640
+ await DockerManager.composePs(this.getBaseCompose(), this.getOverrideCompose())
641
+ } catch (error) {
642
+ Logger.error(error.message)
643
+ process.exit(1)
644
+ }
645
+ }
646
+
647
+ async logs (options) {
648
+ console.clear()
649
+ console.log('┌─────────────────────────────────────────────────────────────────┐')
650
+ console.log(`│ 📋 Live Logs - ${this.config.name}`.padEnd(67) + '│')
651
+ console.log('│ Press ESC or q to return to menu'.padEnd(67) + '│')
652
+ console.log('└─────────────────────────────────────────────────────────────────┘')
653
+ console.log('')
654
+ const args = ['-f', this.getBaseCompose(), '-f', this.getOverrideCompose(), 'logs']
655
+ if (options.follow) {
656
+ args.push('-f')
657
+ } else {
658
+ args.push('--tail=100')
659
+ }
660
+ // Spawn docker-compose logs
661
+ const logsProcess = execa('docker-compose', args, {
662
+ cwd: process.cwd(),
663
+ stdio: ['ignore', 'inherit', 'inherit']
664
+ })
665
+ // Set up keyboard input
666
+ let shouldExit = false
667
+ process.stdin.setRawMode(true)
668
+ process.stdin.resume()
669
+ process.stdin.setEncoding('utf8')
670
+ const keyHandler = (key) => {
671
+ // ESC, q, or Ctrl+C to exit
672
+ if (key === '\u001b' || key === 'q' || key === '\u0003') {
673
+ shouldExit = true
674
+ logsProcess.kill('SIGTERM')
675
+ }
676
+ }
677
+ process.stdin.on('data', keyHandler)
678
+ try {
679
+ await logsProcess
680
+ } catch (error) {
681
+ // Process was killed or exited
682
+ if (!shouldExit && error.signal !== 'SIGTERM' && error.signal !== 'SIGINT') {
683
+ Logger.error(error.message)
684
+ }
685
+ } finally {
686
+ process.stdin.removeListener('data', keyHandler)
687
+ process.stdin.setRawMode(false)
688
+ process.stdin.pause()
689
+ if (shouldExit) {
690
+ console.clear()
691
+ }
692
+ }
693
+ }
694
+
695
+ requiresDatabase () {
696
+ if (!this.config.database) {
697
+ Logger.warn('This project does not use PostgreSQL (no database configured)')
698
+ Logger.info('This project may use Firestore or another database instead')
699
+ return false
700
+ }
701
+ return true
702
+ }
703
+
704
+ async dbInit () {
705
+ if (!this.requiresDatabase()) return
706
+ const spinner = Logger.spinner('Initializing database')
707
+ try {
708
+ const dbManager = new DatabaseManager({
709
+ host: 'localhost',
710
+ port: 5432,
711
+ user: 'postgres',
712
+ password: 'postgres',
713
+ database: this.config.database
714
+ })
715
+ spinner.text = 'Checking PostgreSQL connection...'
716
+ const isReachable = await dbManager.isReachable()
717
+ if (!isReachable) {
718
+ spinner.fail('PostgreSQL not reachable')
719
+ Logger.error('Make sure dev-tools is running')
720
+ process.exit(1)
721
+ }
722
+ const exists = await dbManager.databaseExists()
723
+ if (exists) {
724
+ spinner.stop()
725
+ const inquirer = await import('inquirer')
726
+ const { recreate } = await inquirer.default.prompt([{
727
+ type: 'confirm',
728
+ name: 'recreate',
729
+ message: `Database "${this.config.database}" already exists. Recreate? (all data will be lost)`,
730
+ default: false
731
+ }])
732
+ if (!recreate) {
733
+ Logger.info('Skipped')
734
+ return
735
+ }
736
+ spinner.start('Dropping existing database...')
737
+ await dbManager.dropDatabase()
738
+ }
739
+ spinner.text = 'Creating database...'
740
+ await dbManager.createDatabase()
741
+ spinner.text = `Running schema from ${this.config.schemaPath}...`
742
+ await dbManager.runSchema(this.config.schemaPath)
743
+ if (this.config.schemaVerification) {
744
+ spinner.text = 'Verifying schema...'
745
+ const verification = await dbManager.verify()
746
+ const expected = this.config.schemaVerification
747
+ if (verification.tables === expected.tables &&
748
+ verification.views === expected.views &&
749
+ verification.functions === expected.functions) {
750
+ spinner.succeed('Database initialized successfully!')
751
+ console.log('\n✅ Schema verified:')
752
+ console.log(` Tables: ${verification.tables} (expected: ${expected.tables})`)
753
+ console.log(` Views: ${verification.views} (expected: ${expected.views})`)
754
+ console.log(` Functions: ${verification.functions} (expected: ${expected.functions})\n`)
755
+ } else {
756
+ spinner.fail('Schema verification failed')
757
+ console.log('\n❌ Schema mismatch:')
758
+ console.log(` Tables: ${verification.tables} (expected: ${expected.tables})`)
759
+ console.log(` Views: ${verification.views} (expected: ${expected.views})`)
760
+ console.log(` Functions: ${verification.functions} (expected: ${expected.functions})\n`)
761
+ process.exit(1)
762
+ }
763
+ } else {
764
+ spinner.succeed('Database initialized successfully!')
765
+ }
766
+ if (this.config.pubsub?.topics?.length) {
767
+ await this.initializePubSub(spinner)
768
+ }
769
+ } catch (error) {
770
+ spinner.fail('Failed to initialize database')
771
+ Logger.error(error.message)
772
+ process.exit(1)
773
+ }
774
+ }
775
+
776
+ async initializePubSub (spinner) {
777
+ const pubsubConfig = this.config.pubsub
778
+ const pubsubManager = new PubSubManager(pubsubConfig)
779
+ if (spinner) spinner.start('Checking Pub/Sub emulator...')
780
+ const isReachable = await pubsubManager.isReachable()
781
+ if (!isReachable) {
782
+ if (spinner) spinner.warn('Pub/Sub emulator not reachable - skipping')
783
+ console.log(' Make sure the Pub/Sub emulator is running on port ' + pubsubConfig.port)
784
+ return
785
+ }
786
+ if (spinner) spinner.text = 'Creating Pub/Sub topics and subscriptions...'
787
+ const results = await pubsubManager.initializeAll(pubsubConfig.topics)
788
+ // Register topics with dev-tools for auto-recovery after emulator restart
789
+ await pubsubManager.registerWithDevTools(pubsubConfig.topics, this.config.name)
790
+ if (spinner) spinner.succeed('Pub/Sub initialized!')
791
+ console.log('\n\u2705 Pub/Sub resources:')
792
+ for (const topic of results.topics) {
793
+ console.log(` Topic: ${topic.name} (${topic.result})`)
794
+ }
795
+ for (const sub of results.subscriptions) {
796
+ console.log(` Subscription: ${sub.name} -> ${sub.topic} (${sub.result})`)
797
+ }
798
+ console.log('')
799
+ }
800
+
801
+ async pubsubInit () {
802
+ const spinner = Logger.spinner('Initializing Pub/Sub')
803
+ try {
804
+ if (!this.config.pubsub?.topics?.length) {
805
+ spinner.fail('No Pub/Sub configuration found in .dev-tools/config.js')
806
+ process.exit(1)
807
+ }
808
+ await this.initializePubSub(spinner)
809
+ } catch (error) {
810
+ spinner.fail('Failed to initialize Pub/Sub')
811
+ Logger.error(error.message)
812
+ process.exit(1)
813
+ }
814
+ }
815
+
816
+ async dbReset () {
817
+ if (!this.requiresDatabase()) return
818
+ try {
819
+ const inquirer = await import('inquirer')
820
+ const { confirm } = await inquirer.default.prompt([{
821
+ type: 'confirm',
822
+ name: 'confirm',
823
+ message: `⚠️ This will DELETE all data in "${this.config.database}". Continue?`,
824
+ default: false
825
+ }])
826
+ if (!confirm) {
827
+ Logger.info('Cancelled')
828
+ return
829
+ }
830
+ const spinner = Logger.spinner('Dropping database')
831
+ const dbManager = new DatabaseManager({
832
+ host: 'localhost',
833
+ port: 5432,
834
+ user: 'postgres',
835
+ password: 'postgres',
836
+ database: this.config.database
837
+ })
838
+ await dbManager.dropDatabase()
839
+ spinner.text = 'Creating database...'
840
+ await dbManager.createDatabase()
841
+ spinner.text = 'Running schema...'
842
+ await dbManager.runSchema(this.config.schemaPath)
843
+ spinner.succeed('Database reset successfully!')
844
+ } catch (error) {
845
+ Logger.error('Failed to reset database')
846
+ Logger.error(error.message)
847
+ process.exit(1)
848
+ }
849
+ }
850
+
851
+ async dbClear () {
852
+ if (!this.requiresDatabase()) return
853
+ try {
854
+ const inquirer = await import('inquirer')
855
+ const { confirm } = await inquirer.default.prompt([{
856
+ type: 'confirm',
857
+ name: 'confirm',
858
+ message: `⚠️ This will DELETE all data (but keep schema) in "${this.config.database}". Continue?`,
859
+ default: false
860
+ }])
861
+ if (!confirm) {
862
+ Logger.info('Cancelled')
863
+ return
864
+ }
865
+ const spinner = Logger.spinner('Clearing database')
866
+ const dbManager = new DatabaseManager({
867
+ host: 'localhost',
868
+ port: 5432,
869
+ user: 'postgres',
870
+ password: 'postgres',
871
+ database: this.config.database
872
+ })
873
+ await dbManager.clear()
874
+ spinner.succeed('Database cleared successfully!')
875
+ } catch (error) {
876
+ Logger.error('Failed to clear database')
877
+ Logger.error(error.message)
878
+ process.exit(1)
879
+ }
880
+ }
881
+
882
+ async setup () {
883
+ // Ensure dependencies are installed
884
+ await this.ensureDependencies()
885
+ // Ensure MCP config before anything else (doesn't depend on Docker/services)
886
+ const mcpResult = ensureMcpConfig()
887
+ await this.promptMcpPermissions()
888
+ const spinner = Logger.spinner(`Setting up ${this.config.name}`)
889
+ try {
890
+ // Step 1: Ensure Docker is running
891
+ spinner.text = 'Checking Docker...'
892
+ const dockerResult = await DockerManager.ensureRunning()
893
+ if (dockerResult.wasStarted) {
894
+ spinner.text = 'Docker started, initializing services...'
895
+ }
896
+ // Step 2: Ensure required services are running
897
+ const services = this.config.services || {}
898
+ const enabledServices = DevToolsManager.getEnabledServices(services)
899
+ const devToolsPath = this.config.devToolsPath || null
900
+
901
+ // Always check and start services (even if dev-tools is already running)
902
+ if (enabledServices.length > 0) {
903
+ spinner.text = `Ensuring infrastructure services (${enabledServices.join(', ')})...`
904
+ await DevToolsManager.startServices(devToolsPath, services)
905
+ spinner.text = 'Waiting for infrastructure services to be healthy...'
906
+ const results = await DevToolsManager.waitForServices(services)
907
+ const failed = Object.entries(results).filter(([_, s]) => s !== 'healthy')
908
+ if (failed.length > 0) {
909
+ const failedNames = failed.map(([name]) => name).join(', ')
910
+ // Check if any critical service failed
911
+ const criticalServices = ['postgres', 'redis', 'pubsub']
912
+ const criticalFailed = failed.some(([name]) =>
913
+ services[name] && criticalServices.includes(name)
914
+ )
915
+ if (criticalFailed) {
916
+ spinner.fail(`Critical services failed to start: ${failedNames}`)
917
+ Logger.error('Fix the service issues and try again')
918
+ Logger.info('Run "docker-compose -f docker-compose.services.yml logs <service>" to debug')
919
+ process.exit(1)
920
+ } else {
921
+ spinner.warn(`Optional services not healthy: ${failedNames}`)
922
+ spinner.start()
923
+ }
924
+ }
925
+ // Give PostgreSQL extra time to fully initialize after health check passes
926
+ if (results.postgres === 'healthy') {
927
+ spinner.text = 'PostgreSQL healthy, allowing initialization to complete...'
928
+ await new Promise(resolve => setTimeout(resolve, 2000))
929
+ }
930
+ }
931
+
932
+ // Step 3: Ensure dev-tools backend is running (auto-start if needed)
933
+ spinner.text = 'Checking dev-tools backend...'
934
+ const devToolsRunning = await DevToolsManager.isRunning()
935
+ if (!devToolsRunning) {
936
+ spinner.text = 'Starting dev-tools backend...'
937
+ await DevToolsManager.startDevTools(devToolsPath, services)
938
+ await DevToolsManager.waitForReady()
939
+ spinner.text = 'Dev-tools started!'
940
+ }
941
+ // Step 4: Ensure Docker network exists
942
+ spinner.text = 'Ensuring Docker network...'
943
+ await DockerManager.ensureNetwork('goki-network')
944
+ // Step 5: Check and initialize database if needed (only for PostgreSQL projects)
945
+ if (this.config.database) {
946
+ spinner.text = 'Checking database...'
947
+ const dbManager = new DatabaseManager({
948
+ host: 'localhost',
949
+ port: 5432,
950
+ user: 'postgres',
951
+ password: 'postgres',
952
+ database: this.config.database
953
+ })
954
+ const dbReachable = await dbManager.isReachable()
955
+ if (!dbReachable) {
956
+ spinner.fail('PostgreSQL not reachable')
957
+ Logger.error('Dev-tools started but PostgreSQL is not responding')
958
+ process.exit(1)
959
+ }
960
+ const dbExists = await dbManager.databaseExists()
961
+ if (!dbExists) {
962
+ spinner.text = 'Creating database...'
963
+ await dbManager.createDatabase()
964
+ spinner.text = 'Running schema...'
965
+ await dbManager.runSchema(this.config.schemaPath)
966
+ spinner.text = 'Database initialized!'
967
+ }
968
+ }
969
+ // Step 5: Initialize Pub/Sub if configured
970
+ if (this.config.pubsub?.topics?.length) {
971
+ spinner.text = 'Verifying Pub/Sub setup...'
972
+ const pubsubManager = new PubSubManager(this.config.pubsub)
973
+ const pubsubReachable = await pubsubManager.isReachable()
974
+ if (pubsubReachable) {
975
+ const results = await pubsubManager.initializeAll(this.config.pubsub.topics)
976
+ const newTopics = results.topics.filter(t => t.result === 'created')
977
+ const newSubs = results.subscriptions.filter(s => s.result === 'created')
978
+ if (newTopics.length > 0 || newSubs.length > 0) {
979
+ spinner.text = `Created ${newTopics.length} topic(s), ${newSubs.length} subscription(s)`
980
+ } else {
981
+ spinner.text = 'Pub/Sub setup verified (all exist)'
982
+ }
983
+ // Register topics with dev-tools for auto-recovery after emulator restart
984
+ await pubsubManager.registerWithDevTools(this.config.pubsub.topics, this.config.name)
985
+ } else {
986
+ spinner.warn('Pub/Sub emulator not reachable (skipping)')
987
+ }
988
+ }
989
+ // Step 5.5: Register webhook routes and start ngrok if configured
990
+ let webhookResult = null
991
+ if (this.config.webhookProxy?.enabled) {
992
+ const webhooks = this.config.webhookProxy.routes || {}
993
+ webhookResult = await NgrokManager.setup(webhooks, spinner, this.config.name)
994
+ }
995
+ // Step 5.6: Compute proxy rewrites (data only)
996
+ let proxyResult = null
997
+ if (this.config.httpProxy?.enabled) {
998
+ spinner.text = 'Computing HTTP proxy rewrites...'
999
+ proxyResult = computeProxyRewrites(this.config, process.cwd())
1000
+ }
1001
+ // Step 5.7: Compute webhook rewrites (data only)
1002
+ let webhookRewrites = null
1003
+ if (webhookResult?.tunnelUrl) {
1004
+ spinner.text = 'Computing webhook URL substitutions...'
1005
+ const webhookRewriteResult = computeWebhookRewrites(this.config, process.cwd(), webhookResult.tunnelUrl)
1006
+ webhookRewrites = webhookRewriteResult?.rewrites || null
1007
+ }
1008
+ // Step 6: Generate single combined override
1009
+ spinner.text = 'Generating docker-compose override...'
1010
+ const overrideResult = generateComposeOverride(this.config, process.cwd(), {
1011
+ proxyRewrites: proxyResult?.rewrites,
1012
+ webhookRewrites
1013
+ })
1014
+ // Step 7: Start the application in Docker
1015
+ spinner.text = `Starting ${this.config.name}...`
1016
+ await DockerManager.composeUp(this.getBaseCompose(), this.getOverrideCompose())
1017
+ if (this.getContainerName()) {
1018
+ spinner.text = 'Waiting for application to be ready...'
1019
+ await DockerManager.waitForHealthy(this.getContainerName(), 30000)
1020
+ } else {
1021
+ // Wait a moment for app to start
1022
+ await new Promise(resolve => setTimeout(resolve, 2000))
1023
+ }
1024
+ // Step 8: Configure App Gateway if specified
1025
+ await this.configureAppGateway(spinner)
1026
+ spinner.succeed('Setup complete!')
1027
+ // Display beautiful formatted output
1028
+ console.log(formatStartupOutput({
1029
+ projectName: this.config.name,
1030
+ appPort: this.getAppPort(),
1031
+ webhookResult,
1032
+ webhookUrlResult: webhookRewrites ? { rewrites: webhookRewrites } : null,
1033
+ proxyResult,
1034
+ overrideResult,
1035
+ mcpResult
1036
+ }))
1037
+ } catch (error) {
1038
+ spinner.fail('Setup failed')
1039
+ Logger.error(error.message)
1040
+ process.exit(1)
1041
+ }
1042
+ }
1043
+
1044
+ async shutdown (options = {}) {
1045
+ try {
1046
+ // If flags are provided, use them directly
1047
+ if (options.app) {
1048
+ return await this.stopApp()
1049
+ }
1050
+ if (options.all) {
1051
+ return await this.shutdownAll()
1052
+ }
1053
+ if (options.reset) {
1054
+ return await this.resetDatabase()
1055
+ }
1056
+ if (options.cleanup) {
1057
+ return await this.fullCleanup()
1058
+ }
1059
+ // No flags: show interactive menu
1060
+ const inquirer = await import('inquirer')
1061
+ console.log('')
1062
+ const choices = [
1063
+ { name: `Stop ${this.config.name} only`, value: 'app' }
1064
+ ]
1065
+ if (this.config.database) {
1066
+ choices.push({ name: `Stop ${this.config.name} + drop database`, value: 'app-and-db' })
1067
+ }
1068
+ choices.push(
1069
+ new inquirer.default.Separator(),
1070
+ { name: 'Stop dev-tools (affects all projects)', value: 'devtools' },
1071
+ { name: 'Stop everything + cleanup all data', value: 'all' },
1072
+ new inquirer.default.Separator(),
1073
+ { name: '← Back to menu', value: 'cancel' }
1074
+ )
1075
+ const { action } = await inquirer.default.prompt([{
1076
+ type: 'list',
1077
+ name: 'action',
1078
+ message: 'What would you like to stop?',
1079
+ choices,
1080
+ loop: false,
1081
+ pageSize: 10
1082
+ }])
1083
+ switch (action) {
1084
+ case 'app': return await this.stopApp()
1085
+ case 'app-and-db': return await this.fullCleanup()
1086
+ case 'devtools': return await this.shutdownDevToolsOnly()
1087
+ case 'all': return await this.shutdownAll()
1088
+ case 'cancel':
1089
+ Logger.info('Cancelled')
1090
+ }
1091
+ } catch (error) {
1092
+ Logger.error(error.message)
1093
+ process.exit(1)
1094
+ }
1095
+ }
1096
+
1097
+ async stopApp () {
1098
+ const spinner = Logger.spinner(`Stopping ${this.config.name}`)
1099
+ try {
1100
+ await DockerManager.composeStop(this.getBaseCompose(), this.getOverrideCompose())
1101
+ if (this.config.webhooks && Object.keys(this.config.webhooks).length > 0) {
1102
+ spinner.text = 'Stopping ngrok tunnel...'
1103
+ await NgrokManager.stop()
1104
+ }
1105
+ spinner.succeed(`${this.config.name} stopped`)
1106
+ } catch (error) {
1107
+ spinner.fail('Failed to stop')
1108
+ Logger.error(error.message)
1109
+ }
1110
+ }
1111
+
1112
+ async stopAll () {
1113
+ const spinner = Logger.spinner('Stopping project + dev-tools')
1114
+ try {
1115
+ spinner.text = `Stopping ${this.config.name}...`
1116
+ await this.stopApp()
1117
+ spinner.stop()
1118
+ console.log('\n🛑 Stopping dev-tools...\n')
1119
+ await DevToolsManager.stop(this.config.devToolsPath)
1120
+ console.log('\n✅ All services stopped\n')
1121
+ Logger.info('Note: Database was not dropped')
1122
+ } catch (error) {
1123
+ Logger.error('\n❌ Failed to stop services')
1124
+ Logger.error(error.message)
1125
+ }
1126
+ }
1127
+
1128
+ async resetDatabase () {
1129
+ if (!this.requiresDatabase()) return
1130
+ try {
1131
+ const inquirer = await import('inquirer')
1132
+ const { confirm } = await inquirer.default.prompt([{
1133
+ type: 'confirm',
1134
+ name: 'confirm',
1135
+ message: `⚠️ This will DELETE all data in "${this.config.database}". Continue?`,
1136
+ default: false
1137
+ }])
1138
+ if (!confirm) {
1139
+ Logger.info('Cancelled')
1140
+ return
1141
+ }
1142
+ const spinner = Logger.spinner('Clearing database')
1143
+ const dbManager = new DatabaseManager({
1144
+ host: 'localhost',
1145
+ port: 5432,
1146
+ user: 'postgres',
1147
+ password: 'postgres',
1148
+ database: this.config.database
1149
+ })
1150
+ await dbManager.clear()
1151
+ spinner.succeed('Database reset (data cleared, schema intact)')
1152
+ } catch (error) {
1153
+ Logger.error('Failed to reset database')
1154
+ Logger.error(error.message)
1155
+ }
1156
+ }
1157
+
1158
+ async fullCleanup () {
1159
+ try {
1160
+ const inquirer = await import('inquirer')
1161
+ const warningMsg = this.config.database
1162
+ ? `⚠️ This will STOP ${this.config.name} and DROP the database "${this.config.database}". Continue?`
1163
+ : `⚠️ This will STOP ${this.config.name} application. Continue?`
1164
+ const { confirm } = await inquirer.default.prompt([{
1165
+ type: 'confirm',
1166
+ name: 'confirm',
1167
+ message: warningMsg,
1168
+ default: false
1169
+ }])
1170
+ if (!confirm) {
1171
+ Logger.info('Cancelled')
1172
+ return
1173
+ }
1174
+ const spinner = Logger.spinner(`Stopping ${this.config.name}`)
1175
+ await this.stopApp()
1176
+ if (this.config.database) {
1177
+ spinner.text = 'Dropping database...'
1178
+ const dbManager = new DatabaseManager({
1179
+ host: 'localhost',
1180
+ port: 5432,
1181
+ user: 'postgres',
1182
+ password: 'postgres',
1183
+ database: this.config.database
1184
+ })
1185
+ try {
1186
+ await dbManager.dropDatabase()
1187
+ } catch {
1188
+ // Database might not exist
1189
+ }
1190
+ }
1191
+ spinner.succeed('Project cleanup complete')
1192
+ Logger.info('\nNote: Dev-tools is still running (shared service)')
1193
+ Logger.info('To stop dev-tools: goki-dev shutdown\n')
1194
+ } catch (error) {
1195
+ Logger.error('Failed to cleanup')
1196
+ Logger.error(error.message)
1197
+ }
1198
+ }
1199
+
1200
+ async shutdownAll () {
1201
+ try {
1202
+ const inquirer = await import('inquirer')
1203
+ const warningMsg = this.config.database
1204
+ ? `⚠️ This will STOP ${this.config.name}, DROP database, and STOP dev-tools (affects all projects). Continue?`
1205
+ : `⚠️ This will STOP ${this.config.name} and STOP dev-tools (affects all projects). Continue?`
1206
+ const { confirm } = await inquirer.default.prompt([{
1207
+ type: 'confirm',
1208
+ name: 'confirm',
1209
+ message: warningMsg,
1210
+ default: false
1211
+ }])
1212
+ if (!confirm) {
1213
+ Logger.info('Cancelled')
1214
+ return
1215
+ }
1216
+ const spinner = Logger.spinner(`Stopping ${this.config.name}`)
1217
+ await this.stopApp()
1218
+ if (this.config.database) {
1219
+ spinner.text = 'Dropping database...'
1220
+ const dbManager = new DatabaseManager({
1221
+ host: 'localhost',
1222
+ port: 5432,
1223
+ user: 'postgres',
1224
+ password: 'postgres',
1225
+ database: this.config.database
1226
+ })
1227
+ try {
1228
+ await dbManager.dropDatabase()
1229
+ } catch {
1230
+ // Database might not exist
1231
+ }
1232
+ }
1233
+ spinner.stop()
1234
+ console.log('\n🛑 Stopping dev-tools...\n')
1235
+ await DevToolsManager.stop(this.config.devToolsPath)
1236
+ console.log('\n✅ All services stopped\n')
1237
+ } catch (error) {
1238
+ Logger.error('\n❌ Failed to shutdown')
1239
+ Logger.error(error.message)
1240
+ }
1241
+ }
1242
+
1243
+ async shutdownDevToolsOnly () {
1244
+ try {
1245
+ const inquirer = await import('inquirer')
1246
+ const { confirm } = await inquirer.default.prompt([{
1247
+ type: 'confirm',
1248
+ name: 'confirm',
1249
+ message: '⚠️ This will STOP dev-tools (affects all projects using dev-tools). Continue?',
1250
+ default: false
1251
+ }])
1252
+ if (!confirm) {
1253
+ Logger.info('Cancelled')
1254
+ return
1255
+ }
1256
+ console.log('\n🛑 Stopping dev-tools...\n')
1257
+ await DevToolsManager.stop(this.config.devToolsPath)
1258
+ console.log('\n✅ Dev-tools stopped\n')
1259
+ Logger.info('Note: Your project app may still be running')
1260
+ Logger.info(`To stop ${this.config.name}: goki-dev stop\n`)
1261
+ } catch (error) {
1262
+ Logger.error('\n❌ Failed to stop dev-tools')
1263
+ Logger.error(error.message)
1264
+ }
1265
+ }
1266
+
1267
+ async menu () {
1268
+ const inquirer = await import('inquirer')
1269
+ while (true) {
1270
+ console.clear()
1271
+ console.log(`\n🔧 ${this.config.name} Development CLI\n`)
1272
+ const choices = [
1273
+ { name: '🚀 Start development environment', value: 'start' },
1274
+ { name: '🛑 Stop services', value: 'stop' },
1275
+ new inquirer.default.Separator(),
1276
+ { name: '🧪 Run e2e tests (snapshot/restore)', value: 'e2e' },
1277
+ { name: '⚡ Run tests (no snapshot)', value: 'test' },
1278
+ new inquirer.default.Separator(),
1279
+ { name: '📊 Live Dashboard (real-time status)', value: 'dashboard' },
1280
+ { name: '📋 View logs (live)', value: 'logs' },
1281
+ { name: '🔍 Check service status', value: 'status' }
1282
+ ]
1283
+ // Add database/pubsub options if configured
1284
+ if (this.config.database || this.config.pubsub?.topics?.length) {
1285
+ choices.push(new inquirer.default.Separator())
1286
+ if (this.config.database) {
1287
+ choices.push({ name: '🗄️ Database operations', value: 'database' })
1288
+ }
1289
+ if (this.config.pubsub?.topics?.length) {
1290
+ choices.push({ name: '📨 Pub/Sub operations', value: 'pubsub' })
1291
+ }
1292
+ }
1293
+ choices.push(
1294
+ new inquirer.default.Separator(),
1295
+ { name: '🔐 Secrets management', value: 'secrets' },
1296
+ new inquirer.default.Separator(),
1297
+ { name: '❌ Exit', value: 'exit' }
1298
+ )
1299
+ const { action } = await inquirer.default.prompt([{
1300
+ type: 'list',
1301
+ name: 'action',
1302
+ message: 'What would you like to do?',
1303
+ choices,
1304
+ loop: false,
1305
+ pageSize: 12
1306
+ }])
1307
+ switch (action) {
1308
+ case 'start':
1309
+ await this.setup()
1310
+ return
1311
+ case 'stop':
1312
+ await this.shutdown()
1313
+ break
1314
+ case 'e2e':
1315
+ await this.e2eMenu()
1316
+ break
1317
+ case 'test':
1318
+ await this.runTestsDirectly()
1319
+ break
1320
+ case 'dashboard':
1321
+ await this.liveDashboard()
1322
+ break
1323
+ case 'logs':
1324
+ await this.logs({ follow: true })
1325
+ return
1326
+ case 'status':
1327
+ await this.showStatus()
1328
+ console.log('')
1329
+ break
1330
+ case 'database':
1331
+ await this.databaseMenu()
1332
+ break
1333
+ case 'pubsub':
1334
+ await this.pubsubMenu()
1335
+ break
1336
+ case 'secrets':
1337
+ await this.secretsMenu()
1338
+ break
1339
+ case 'exit':
1340
+ console.log('\n👋 Goodbye!\n')
1341
+ return
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ async testsMenu () {
1347
+ const inquirer = await import('inquirer')
1348
+ const { action } = await inquirer.default.prompt([{
1349
+ type: 'list',
1350
+ name: 'action',
1351
+ message: 'Run tests:',
1352
+ choices: [
1353
+ { name: '🧪 All tests', value: 'all' },
1354
+ { name: '⚡ All tests (parallel)', value: 'parallel' },
1355
+ { name: '🌐 API tests only', value: 'api' },
1356
+ { name: '🧠 Logic tests only', value: 'logic' },
1357
+ { name: '📨 Pub/Sub tests only', value: 'pubsub' },
1358
+ new inquirer.default.Separator(),
1359
+ { name: '← Back', value: 'back' }
1360
+ ]
1361
+ }])
1362
+ if (action === 'back') return
1363
+ await this.runTests(action)
1364
+ }
1365
+
1366
+ async runTests (type) {
1367
+ const testCommands = {
1368
+ all: ['tests/component/app/**/*.js', 'tests/component/logic/**/*.js', 'tests/component/subscribers/**/*.js'],
1369
+ parallel: ['-p', 'tests/component/app/**/*.js', 'tests/component/logic/**/*.js', 'tests/component/subscribers/**/*.js'],
1370
+ api: ['tests/component/app/**/*.js'],
1371
+ logic: ['tests/component/logic/**/*.js'],
1372
+ pubsub: ['tests/component/subscribers/**/*.js']
1373
+ }
1374
+ const args = testCommands[type] || testCommands.all
1375
+ const testEnvFile = this.config.testEnvFile || 'config.test'
1376
+ console.log(`\n🧪 Running ${type} tests...\n`)
1377
+ try {
1378
+ await execa('npx', ['dotenv', '-e', testEnvFile, '--', 'mocha', ...args], {
1379
+ stdio: 'inherit'
1380
+ })
1381
+ console.log('')
1382
+ } catch (error) {
1383
+ // Test failures are shown by mocha, just add spacing
1384
+ console.log('')
1385
+ }
1386
+ }
1387
+
1388
+ async showStatus () {
1389
+ console.log('')
1390
+ const checks = []
1391
+ // Check Docker
1392
+ const dockerRunning = await DockerManager.isRunning()
1393
+ checks.push({
1394
+ name: 'Docker',
1395
+ status: dockerRunning ? '✓' : '✗',
1396
+ color: dockerRunning ? 'green' : 'red'
1397
+ })
1398
+ // Check dev-tools
1399
+ const devToolsRunning = await DevToolsManager.isRunning()
1400
+ checks.push({
1401
+ name: 'Dev-tools',
1402
+ status: devToolsRunning ? '✓' : '✗',
1403
+ color: devToolsRunning ? 'green' : 'red'
1404
+ })
1405
+ // Check PostgreSQL (only if this project uses it)
1406
+ if (this.config.database) {
1407
+ const dbManager = new DatabaseManager({
1408
+ host: 'localhost',
1409
+ port: 5432,
1410
+ user: 'postgres',
1411
+ password: 'postgres',
1412
+ database: this.config.database
1413
+ })
1414
+ const pgReachable = await dbManager.isReachable()
1415
+ checks.push({
1416
+ name: 'PostgreSQL',
1417
+ status: pgReachable ? '✓' : '✗',
1418
+ color: pgReachable ? 'green' : 'red'
1419
+ })
1420
+ // Check database exists
1421
+ if (pgReachable) {
1422
+ const dbExists = await dbManager.databaseExists()
1423
+ checks.push({
1424
+ name: `Database (${this.config.database})`,
1425
+ status: dbExists ? '✓' : '✗',
1426
+ color: dbExists ? 'green' : 'yellow'
1427
+ })
1428
+ }
1429
+ }
1430
+ // Check Pub/Sub
1431
+ if (this.config.pubsub) {
1432
+ const pubsubManager = new PubSubManager(this.config.pubsub)
1433
+ const pubsubReachable = await pubsubManager.isReachable()
1434
+ checks.push({
1435
+ name: 'Pub/Sub Emulator',
1436
+ status: pubsubReachable ? '✓' : '✗',
1437
+ color: pubsubReachable ? 'green' : 'red'
1438
+ })
1439
+ }
1440
+ // Check Firestore (if enabled in services)
1441
+ if (this.config.services?.firestore) {
1442
+ try {
1443
+ const response = await fetch('http://localhost:8080')
1444
+ checks.push({
1445
+ name: 'Firestore Emulator',
1446
+ status: response.ok ? '✓' : '✗',
1447
+ color: response.ok ? 'green' : 'red'
1448
+ })
1449
+ } catch {
1450
+ checks.push({
1451
+ name: 'Firestore Emulator',
1452
+ status: '✗',
1453
+ color: 'red'
1454
+ })
1455
+ }
1456
+ }
1457
+ // Check Redis (if enabled in services, default true)
1458
+ if (this.config.services?.redis !== false) {
1459
+ try {
1460
+ const response = await fetch('http://localhost:9000/v1/redis/connection/test', {
1461
+ method: 'POST',
1462
+ headers: { 'Content-Type': 'application/json' },
1463
+ body: JSON.stringify({})
1464
+ })
1465
+ const result = await response.json()
1466
+ checks.push({
1467
+ name: 'Redis',
1468
+ status: result.success ? '✓' : '✗',
1469
+ color: result.success ? 'green' : 'red'
1470
+ })
1471
+ } catch {
1472
+ checks.push({
1473
+ name: 'Redis',
1474
+ status: '✗',
1475
+ color: 'red'
1476
+ })
1477
+ }
1478
+ }
1479
+ // Check ngrok tunnel (if webhooks configured)
1480
+ if (this.config.webhooks && Object.keys(this.config.webhooks).length > 0) {
1481
+ const ngrokStatus = await NgrokManager.getStatus()
1482
+ checks.push({
1483
+ name: ngrokStatus.running && ngrokStatus.url ? `ngrok (${ngrokStatus.url})` : 'ngrok Tunnel',
1484
+ status: ngrokStatus.running ? '✓' : '✗',
1485
+ color: ngrokStatus.running ? 'green' : 'yellow'
1486
+ })
1487
+ }
1488
+ console.log(' 📊 Service Status:\n')
1489
+ for (const check of checks) {
1490
+ const icon = check.status === '✓' ? '✅' : (check.status === '✗' ? '❌' : '⚠️')
1491
+ const statusText = check.status === '✓' ? 'Running' : 'Stopped'
1492
+ console.log(` ${icon} ${check.name.padEnd(25)} ${statusText}`)
1493
+ }
1494
+ console.log('')
1495
+ }
1496
+
1497
+ async liveDashboard () {
1498
+ let isRunning = true
1499
+ let lastRefresh = new Date()
1500
+ // Set up keyboard input
1501
+ process.stdin.setRawMode(true)
1502
+ process.stdin.resume()
1503
+ process.stdin.setEncoding('utf8')
1504
+ const keyHandler = (key) => {
1505
+ // Ctrl+C or q or ESC to exit
1506
+ if (key === '\u0003' || key === 'q' || key === '\u001b') {
1507
+ isRunning = false
1508
+ }
1509
+ // r to refresh immediately
1510
+ if (key === 'r') {
1511
+ lastRefresh = new Date(0) // Force refresh
1512
+ }
1513
+ }
1514
+ process.stdin.on('data', keyHandler)
1515
+ const cleanup = () => {
1516
+ process.stdin.removeListener('data', keyHandler)
1517
+ process.stdin.setRawMode(false)
1518
+ process.stdin.pause()
1519
+ }
1520
+ try {
1521
+ // eslint-disable-next-line no-unmodified-loop-condition
1522
+ while (isRunning) {
1523
+ const now = new Date()
1524
+ const elapsed = now - lastRefresh
1525
+ // Refresh every 3 seconds
1526
+ if (elapsed >= 3000) {
1527
+ console.clear()
1528
+ await this.renderDashboard()
1529
+ lastRefresh = now
1530
+ }
1531
+ // Sleep for 100ms to avoid busy loop
1532
+ await new Promise(resolve => setTimeout(resolve, 100))
1533
+ }
1534
+ } finally {
1535
+ cleanup()
1536
+ console.clear()
1537
+ }
1538
+ }
1539
+
1540
+ async renderDashboard () {
1541
+ const now = new Date()
1542
+ const timeStr = now.toLocaleTimeString()
1543
+ // Header
1544
+ console.log('┌─────────────────────────────────────────────────────────────────┐')
1545
+ console.log(`│ 🔧 ${this.config.name} - Live Service Monitor`.padEnd(67) + '│')
1546
+ console.log(`│ Updated: ${timeStr} (refreshing every 3s)`.padEnd(67) + '│')
1547
+ console.log('└─────────────────────────────────────────────────────────────────┘')
1548
+ console.log('')
1549
+ // Collect service status
1550
+ const services = []
1551
+ // Docker
1552
+ const dockerRunning = await DockerManager.isRunning()
1553
+ services.push({
1554
+ name: 'Docker',
1555
+ status: dockerRunning ? 'Running' : 'Stopped',
1556
+ icon: dockerRunning ? '✅' : '❌',
1557
+ port: '-'
1558
+ })
1559
+ // Dev-tools
1560
+ const devToolsRunning = await DevToolsManager.isRunning()
1561
+ services.push({
1562
+ name: 'Dev-tools',
1563
+ status: devToolsRunning ? 'Running' : 'Stopped',
1564
+ icon: devToolsRunning ? '✅' : '❌',
1565
+ port: '9000'
1566
+ })
1567
+ // PostgreSQL (if configured)
1568
+ if (this.config.database) {
1569
+ const dbManager = new DatabaseManager({
1570
+ host: 'localhost',
1571
+ port: 5432,
1572
+ user: 'postgres',
1573
+ password: 'postgres',
1574
+ database: this.config.database
1575
+ })
1576
+ const pgReachable = await dbManager.isReachable()
1577
+ services.push({
1578
+ name: 'PostgreSQL',
1579
+ status: pgReachable ? 'Running' : 'Stopped',
1580
+ icon: pgReachable ? '✅' : '❌',
1581
+ port: '5432'
1582
+ })
1583
+ if (pgReachable) {
1584
+ const dbExists = await dbManager.databaseExists()
1585
+ services.push({
1586
+ name: `Database (${this.config.database})`,
1587
+ status: dbExists ? 'Ready' : 'Not created',
1588
+ icon: dbExists ? '✅' : '⚠️',
1589
+ port: '-'
1590
+ })
1591
+ }
1592
+ }
1593
+ // Pub/Sub (if configured)
1594
+ if (this.config.pubsub) {
1595
+ const pubsubManager = new PubSubManager(this.config.pubsub)
1596
+ const pubsubReachable = await pubsubManager.isReachable()
1597
+ services.push({
1598
+ name: 'Pub/Sub Emulator',
1599
+ status: pubsubReachable ? 'Running' : 'Stopped',
1600
+ icon: pubsubReachable ? '✅' : '❌',
1601
+ port: '8085'
1602
+ })
1603
+ }
1604
+ // Redis (if enabled)
1605
+ if (this.config.services?.redis !== false) {
1606
+ try {
1607
+ const response = await fetch('http://localhost:9000/v1/redis/connection/test', {
1608
+ method: 'POST',
1609
+ headers: { 'Content-Type': 'application/json' },
1610
+ body: JSON.stringify({})
1611
+ })
1612
+ const result = await response.json()
1613
+ services.push({
1614
+ name: 'Redis',
1615
+ status: result.success ? 'Running' : 'Stopped',
1616
+ icon: result.success ? '✅' : '❌',
1617
+ port: '6379'
1618
+ })
1619
+ } catch {
1620
+ services.push({
1621
+ name: 'Redis',
1622
+ status: 'Stopped',
1623
+ icon: '❌',
1624
+ port: '6379'
1625
+ })
1626
+ }
1627
+ }
1628
+ // Firestore (if enabled)
1629
+ if (this.config.services?.firestore) {
1630
+ try {
1631
+ const response = await fetch('http://localhost:8080')
1632
+ services.push({
1633
+ name: 'Firestore Emulator',
1634
+ status: response.ok ? 'Running' : 'Stopped',
1635
+ icon: response.ok ? '✅' : '❌',
1636
+ port: '8080'
1637
+ })
1638
+ } catch {
1639
+ services.push({
1640
+ name: 'Firestore Emulator',
1641
+ status: 'Stopped',
1642
+ icon: '❌',
1643
+ port: '8080'
1644
+ })
1645
+ }
1646
+ }
1647
+ // Application container (if configured)
1648
+ if (this.getContainerName()) {
1649
+ try {
1650
+ const { stdout } = await execa('docker', ['inspect', '--format', '{{.State.Status}}', this.getContainerName()])
1651
+ const isRunning = stdout.trim() === 'running'
1652
+ services.push({
1653
+ name: this.config.name,
1654
+ status: isRunning ? 'Running' : 'Stopped',
1655
+ icon: isRunning ? '✅' : '❌',
1656
+ port: this.getAppPort() || '-'
1657
+ })
1658
+ } catch {
1659
+ services.push({
1660
+ name: this.config.name,
1661
+ status: 'Stopped',
1662
+ icon: '❌',
1663
+ port: this.getAppPort() || '-'
1664
+ })
1665
+ }
1666
+ }
1667
+ // ngrok tunnel (if webhooks configured)
1668
+ if (this.config.webhooks && Object.keys(this.config.webhooks).length > 0) {
1669
+ const ngrokStatus = await NgrokManager.getStatus()
1670
+ services.push({
1671
+ name: ngrokStatus.running && ngrokStatus.url ? `ngrok (${ngrokStatus.url})` : 'ngrok Tunnel',
1672
+ status: ngrokStatus.running ? 'Running' : 'Stopped',
1673
+ icon: ngrokStatus.running ? '✅' : '⚠️',
1674
+ port: '4040'
1675
+ })
1676
+ }
1677
+ // Table header
1678
+ console.log('Service'.padEnd(30) + 'Status'.padEnd(15) + 'Port')
1679
+ console.log('─'.repeat(65))
1680
+ // Table rows
1681
+ for (const service of services) {
1682
+ const nameCol = `${service.icon} ${service.name}`.padEnd(30)
1683
+ const statusCol = service.status.padEnd(15)
1684
+ const portCol = String(service.port)
1685
+ console.log(nameCol + statusCol + portCol)
1686
+ }
1687
+ console.log('')
1688
+ console.log("Press 'q' or ESC to return to menu | 'r' to refresh now")
1689
+ }
1690
+
1691
+ async databaseMenu () {
1692
+ if (!this.requiresDatabase()) return
1693
+ const inquirer = await import('inquirer')
1694
+ console.log('')
1695
+ const { action } = await inquirer.default.prompt([{
1696
+ type: 'list',
1697
+ name: 'action',
1698
+ message: 'Database operations:',
1699
+ choices: [
1700
+ { name: '🏗️ Initialize database (create + run schema)', value: 'init' },
1701
+ { name: '🔄 Clear all data (keep schema)', value: 'reset' },
1702
+ { name: '💣 Drop database completely', value: 'drop' },
1703
+ new inquirer.default.Separator(),
1704
+ { name: '← Back to menu', value: 'back' }
1705
+ ],
1706
+ loop: false,
1707
+ pageSize: 8
1708
+ }])
1709
+ switch (action) {
1710
+ case 'init':
1711
+ await this.dbInit()
1712
+ break
1713
+ case 'reset':
1714
+ await this.resetDatabase()
1715
+ break
1716
+ case 'drop':
1717
+ await this.dropDatabaseInteractive()
1718
+ break
1719
+ case 'back':
1720
+ }
1721
+ }
1722
+
1723
+ async dropDatabaseInteractive () {
1724
+ if (!this.requiresDatabase()) return
1725
+ const inquirer = await import('inquirer')
1726
+ const { confirm } = await inquirer.default.prompt([{
1727
+ type: 'confirm',
1728
+ name: 'confirm',
1729
+ message: `⚠️ This will DROP the database "${this.config.database}". Continue?`,
1730
+ default: false
1731
+ }])
1732
+ if (!confirm) {
1733
+ Logger.info('Cancelled')
1734
+ return
1735
+ }
1736
+ const spinner = Logger.spinner('Dropping database')
1737
+ try {
1738
+ const dbManager = new DatabaseManager({
1739
+ host: 'localhost',
1740
+ port: 5432,
1741
+ user: 'postgres',
1742
+ password: 'postgres',
1743
+ database: this.config.database
1744
+ })
1745
+ await dbManager.dropDatabase()
1746
+ spinner.succeed('Database dropped')
1747
+ } catch (error) {
1748
+ spinner.fail('Failed to drop database')
1749
+ Logger.error(error.message)
1750
+ }
1751
+ }
1752
+
1753
+ async pubsubMenu () {
1754
+ const inquirer = await import('inquirer')
1755
+ console.log('')
1756
+ const { action } = await inquirer.default.prompt([{
1757
+ type: 'list',
1758
+ name: 'action',
1759
+ message: 'Pub/Sub operations:',
1760
+ choices: [
1761
+ { name: '🏗️ Initialize topics & subscriptions', value: 'init' },
1762
+ { name: '📋 List configured topics', value: 'list' },
1763
+ new inquirer.default.Separator(),
1764
+ { name: '← Back to menu', value: 'back' }
1765
+ ],
1766
+ loop: false,
1767
+ pageSize: 8
1768
+ }])
1769
+ switch (action) {
1770
+ case 'init':
1771
+ await this.pubsubInit()
1772
+ break
1773
+ case 'list':
1774
+ this.listTopics()
1775
+ break
1776
+ case 'back':
1777
+ }
1778
+ }
1779
+
1780
+ listTopics () {
1781
+ if (!this.config.pubsub?.topics?.length) {
1782
+ console.log('\n No topics configured.\n')
1783
+ return
1784
+ }
1785
+ console.log('\n Configured topics:')
1786
+ for (const entry of this.config.pubsub.topics) {
1787
+ if (typeof entry === 'string') {
1788
+ console.log(` 📨 ${entry}`)
1789
+ } else {
1790
+ const sub = entry.subscription ? ` → ${entry.subscription}` : ''
1791
+ console.log(` 📨 ${entry.name}${sub}`)
1792
+ }
1793
+ }
1794
+ console.log('')
1795
+ }
1796
+
1797
+ async secretsMenu () {
1798
+ const inquirer = await import('inquirer')
1799
+ console.log('')
1800
+ const { action } = await inquirer.default.prompt([{
1801
+ type: 'list',
1802
+ name: 'action',
1803
+ message: 'Secrets management:',
1804
+ choices: [
1805
+ { name: '📋 List secrets', value: 'list' },
1806
+ { name: '➕ Set a secret', value: 'set' },
1807
+ { name: '🔍 Check required secrets', value: 'check' },
1808
+ { name: '🛠️ Setup missing secrets', value: 'setup' },
1809
+ { name: '🩺 Scan for plaintext credentials', value: 'doctor' },
1810
+ new inquirer.default.Separator(),
1811
+ { name: '← Back to menu', value: 'back' }
1812
+ ],
1813
+ loop: false,
1814
+ pageSize: 10
1815
+ }])
1816
+ if (action === 'back') return
1817
+ const { SecretsManager } = await import('./secrets/SecretsManager.js')
1818
+ try {
1819
+ if (action === 'doctor') {
1820
+ const { SecretsDoctor } = await import('./secrets/SecretsDoctor.js')
1821
+ const doctor = new SecretsDoctor(process.cwd())
1822
+ const findings = await doctor.scan()
1823
+ doctor.report(findings)
1824
+ return
1825
+ }
1826
+ const manager = await SecretsManager.create({ projectDir: process.cwd() })
1827
+ if (action === 'list') {
1828
+ const entries = manager.list()
1829
+ if (entries.length === 0) {
1830
+ console.log('\n No secrets found.\n')
1831
+ } else {
1832
+ console.log('')
1833
+ for (const e of entries) {
1834
+ const scope = e.scope === 'global' ? 'global' : `project: ${e.project}`
1835
+ console.log(` ${e.key.padEnd(30)} (${scope})`)
1836
+ }
1837
+ console.log('')
1838
+ }
1839
+ return
1840
+ }
1841
+ await manager.unlock()
1842
+ if (action === 'set') {
1843
+ const { key } = await inquirer.default.prompt([{ type: 'input', name: 'key', message: 'Secret key name:' }])
1844
+ const { value } = await inquirer.default.prompt([{ type: 'password', name: 'value', message: 'Value:', mask: '\u25CF' }])
1845
+ await manager.set(key, value)
1846
+ Logger.success(`set ${key}`)
1847
+ } else if (action === 'check') {
1848
+ const { SecretsConfig } = await import('./secrets/SecretsConfig.js')
1849
+ const cfg = new SecretsConfig(manager)
1850
+ await cfg.check()
1851
+ } else if (action === 'setup') {
1852
+ const { SecretsConfig } = await import('./secrets/SecretsConfig.js')
1853
+ const cfg = new SecretsConfig(manager)
1854
+ await cfg.setup()
1855
+ }
1856
+ } catch (err) {
1857
+ Logger.error(err.message)
1858
+ }
1859
+ }
1860
+
1861
+ // Testing Methods
1862
+
1863
+ async runTestsDirectly () {
1864
+ const testCommand = this.config.e2e?.testCommand || 'npm run test:e2e'
1865
+ const timeout = this.config.e2e?.timeout || 300000
1866
+ console.log(`\n🧪 Running tests: ${testCommand}\n`)
1867
+ try {
1868
+ await execa('sh', ['-c', testCommand], {
1869
+ cwd: process.cwd(),
1870
+ stdio: 'inherit',
1871
+ timeout
1872
+ })
1873
+ console.log('\n✅ Tests passed!\n')
1874
+ } catch (error) {
1875
+ console.log('\n❌ Tests failed\n')
1876
+ process.exit(1)
1877
+ }
1878
+ }
1879
+
1880
+ // E2E Testing Methods
1881
+
1882
+ async e2eMenu () {
1883
+ const inquirer = await import('inquirer')
1884
+ console.log('')
1885
+ const { action } = await inquirer.default.prompt([{
1886
+ type: 'list',
1887
+ name: 'action',
1888
+ message: 'E2E Testing (Snapshot/Restore):',
1889
+ choices: [
1890
+ { name: '🧪 Run e2e tests (snapshot → test → restore)', value: 'run' },
1891
+ { name: '📸 Create snapshot (manual)', value: 'snapshot' },
1892
+ { name: '🔄 Restore snapshot', value: 'restore' },
1893
+ { name: '📋 List snapshots', value: 'list' },
1894
+ { name: '🗑️ Cleanup snapshots', value: 'cleanup' },
1895
+ new inquirer.default.Separator(),
1896
+ { name: '← Back to menu', value: 'back' }
1897
+ ],
1898
+ loop: false,
1899
+ pageSize: 10
1900
+ }])
1901
+ switch (action) {
1902
+ case 'run':
1903
+ await this.e2eRun()
1904
+ break
1905
+ case 'snapshot':
1906
+ await this.e2eSnapshot()
1907
+ break
1908
+ case 'restore':
1909
+ await this.e2eRestoreInteractive()
1910
+ break
1911
+ case 'list':
1912
+ await this.e2eList()
1913
+ break
1914
+ case 'cleanup':
1915
+ await this.e2eCleanupInteractive()
1916
+ break
1917
+ case 'back':
1918
+ }
1919
+ }
1920
+
1921
+ async e2eRestoreInteractive () {
1922
+ const snapshotManager = new SnapshotManager(this.config)
1923
+ try {
1924
+ const result = await snapshotManager.listSnapshots()
1925
+ const snapshots = result.snapshots || []
1926
+ if (snapshots.length === 0) {
1927
+ console.log('\nNo snapshots found.\n')
1928
+ return
1929
+ }
1930
+ const inquirer = await import('inquirer')
1931
+ const choices = snapshots.map(s => ({
1932
+ name: `${s.id} - ${new Date(s.timestamp).toLocaleString()} (${(s.size / 1024 / 1024).toFixed(2)}MB)`,
1933
+ value: s.id
1934
+ }))
1935
+ choices.push(new inquirer.default.Separator(), { name: '← Cancel', value: 'cancel' })
1936
+ const { snapshotId } = await inquirer.default.prompt([{
1937
+ type: 'list',
1938
+ name: 'snapshotId',
1939
+ message: 'Select snapshot to restore:',
1940
+ choices
1941
+ }])
1942
+ if (snapshotId === 'cancel') return
1943
+ await this.e2eRestore(snapshotId)
1944
+ } catch (error) {
1945
+ Logger.error(`Failed to list snapshots: ${error.message}`)
1946
+ }
1947
+ }
1948
+
1949
+ async e2eCleanupInteractive () {
1950
+ const snapshotManager = new SnapshotManager(this.config)
1951
+ try {
1952
+ const result = await snapshotManager.listSnapshots()
1953
+ const snapshots = result.snapshots || []
1954
+ if (snapshots.length === 0) {
1955
+ console.log('\nNo snapshots found.\n')
1956
+ return
1957
+ }
1958
+ const inquirer = await import('inquirer')
1959
+ const choices = snapshots.map(s => ({
1960
+ name: `${s.id} - ${new Date(s.timestamp).toLocaleString()} (${(s.size / 1024 / 1024).toFixed(2)}MB)`,
1961
+ value: s.id
1962
+ }))
1963
+ choices.push(
1964
+ new inquirer.default.Separator(),
1965
+ { name: '🗑️ Delete ALL snapshots', value: 'all' },
1966
+ { name: '← Cancel', value: 'cancel' }
1967
+ )
1968
+ const { snapshotId } = await inquirer.default.prompt([{
1969
+ type: 'list',
1970
+ name: 'snapshotId',
1971
+ message: 'Select snapshot to delete:',
1972
+ choices
1973
+ }])
1974
+ if (snapshotId === 'cancel') return
1975
+ if (snapshotId === 'all') {
1976
+ await this.e2eCleanup(null)
1977
+ } else {
1978
+ await this.e2eCleanup(snapshotId)
1979
+ }
1980
+ } catch (error) {
1981
+ Logger.error(`Failed to cleanup: ${error.message}`)
1982
+ }
1983
+ }
1984
+
1985
+ async e2eRun (options = {}) {
1986
+ const snapshotManager = new SnapshotManager(this.config)
1987
+ const testCommand = this.config.e2e?.testCommand || 'npm run test:e2e'
1988
+ const timeout = this.config.e2e?.timeout || 300000
1989
+ const keepOnFailure = this.config.e2e?.keepSnapshotOnFailure !== false
1990
+ const keepOnSuccess = this.config.e2e?.keepSnapshotOnSuccess === true
1991
+ let snapshotId = null
1992
+ let testsPassed = false
1993
+ const spinner = Logger.spinner('Starting e2e test run')
1994
+ try {
1995
+ // Step 1: Create snapshot
1996
+ spinner.text = 'Creating snapshot of development environment...'
1997
+ const snapshotResult = await snapshotManager.createSnapshot()
1998
+ const snapshotData = snapshotResult.data || snapshotResult
1999
+ snapshotId = snapshotData.snapshotId
2000
+ const servicesInfo = Object.keys(snapshotData.services || {}).map(s => {
2001
+ const info = snapshotData.services[s]
2002
+ if (info.database) return `${s} (${info.database})`
2003
+ if (info.collections) return `${s} (${info.collections} collections, ${info.documents} docs)`
2004
+ if (info.tables) return `${s} (${info.tables} tables, ${info.records} records)`
2005
+ return s
2006
+ }).join(', ')
2007
+ spinner.succeed(`Snapshot created: ${snapshotId}`)
2008
+ console.log(` Services: ${servicesInfo}\n`)
2009
+ // Step 2: Run tests
2010
+ spinner.start('Running e2e tests...')
2011
+ spinner.text = `Running: ${testCommand}`
2012
+ try {
2013
+ const testProcess = execa('sh', ['-c', testCommand], {
2014
+ cwd: process.cwd(),
2015
+ stdio: ['ignore', 'pipe', 'pipe'],
2016
+ timeout
2017
+ })
2018
+ // Stream output
2019
+ testProcess.stdout.on('data', (data) => {
2020
+ process.stdout.write(data)
2021
+ })
2022
+ testProcess.stderr.on('data', (data) => {
2023
+ process.stderr.write(data)
2024
+ })
2025
+ await testProcess
2026
+ testsPassed = true
2027
+ spinner.succeed('Tests passed!')
2028
+ } catch (error) {
2029
+ spinner.fail('Tests failed')
2030
+ testsPassed = false
2031
+ }
2032
+ // Step 3: Restore snapshot
2033
+ spinner.start('Restoring snapshot...')
2034
+ await snapshotManager.restoreSnapshot(snapshotId, true)
2035
+ spinner.succeed('Environment restored to pre-test state')
2036
+ // Step 4: Cleanup snapshot
2037
+ const shouldKeep = (testsPassed && keepOnSuccess) || (!testsPassed && keepOnFailure) || options.keepSnapshot
2038
+ if (shouldKeep) {
2039
+ console.log(`\n📸 Snapshot kept: ${snapshotId}`)
2040
+ console.log(` To restore: goki-dev e2e restore ${snapshotId}`)
2041
+ console.log(` To cleanup: goki-dev e2e cleanup ${snapshotId}\n`)
2042
+ } else {
2043
+ spinner.start('Cleaning up snapshot...')
2044
+ await snapshotManager.cleanupSnapshot(snapshotId)
2045
+ spinner.succeed('Snapshot cleaned up')
2046
+ }
2047
+ // Summary
2048
+ console.log('')
2049
+ if (testsPassed) {
2050
+ console.log('✅ E2E tests complete')
2051
+ console.log(' Tests: ✓ Passed')
2052
+ } else {
2053
+ console.log('❌ E2E tests failed')
2054
+ console.log(' Tests: ✗ Failed')
2055
+ }
2056
+ console.log(' Environment: ✓ Restored to pre-test state\n')
2057
+ if (!testsPassed) {
2058
+ process.exit(1)
2059
+ }
2060
+ } catch (error) {
2061
+ spinner.fail('E2E test run failed')
2062
+ Logger.error(error.message)
2063
+ if (snapshotId) {
2064
+ console.log(`\nSnapshot ID: ${snapshotId}`)
2065
+ console.log(`To restore manually: goki-dev e2e restore ${snapshotId}\n`)
2066
+ }
2067
+ process.exit(1)
2068
+ }
2069
+ }
2070
+
2071
+ async e2eSnapshot () {
2072
+ const snapshotManager = new SnapshotManager(this.config)
2073
+ const spinner = Logger.spinner('Creating snapshot')
2074
+ try {
2075
+ const result = await snapshotManager.createSnapshot()
2076
+ const snapshotData = result.data || result
2077
+ spinner.succeed(`Snapshot created: ${snapshotData.snapshotId}`)
2078
+ console.log('')
2079
+ for (const [service, info] of Object.entries(snapshotData.services || {})) {
2080
+ if (info.database) {
2081
+ console.log(` - PostgreSQL: ${info.database} (${(info.size / 1024 / 1024).toFixed(2)}MB)`)
2082
+ } else if (info.collections !== undefined) {
2083
+ console.log(` - Firestore: ${info.collections} collections, ${info.documents} documents (${(info.size / 1024 / 1024).toFixed(2)}MB)`)
2084
+ } else if (info.tables !== undefined) {
2085
+ console.log(` - SQLite: ${info.tables} tables, ${info.records} records (${(info.size / 1024).toFixed(2)}KB)`)
2086
+ }
2087
+ }
2088
+ console.log('')
2089
+ console.log(`To restore: goki-dev e2e restore ${snapshotData.snapshotId}`)
2090
+ console.log('')
2091
+ } catch (error) {
2092
+ spinner.fail('Failed to create snapshot')
2093
+ Logger.error(error.message)
2094
+ process.exit(1)
2095
+ }
2096
+ }
2097
+
2098
+ async e2eRestore (snapshotId) {
2099
+ const snapshotManager = new SnapshotManager(this.config)
2100
+ try {
2101
+ const inquirer = await import('inquirer')
2102
+ const { confirm } = await inquirer.default.prompt([{
2103
+ type: 'confirm',
2104
+ name: 'confirm',
2105
+ message: '⚠️ This will OVERWRITE current development data. Continue?',
2106
+ default: false
2107
+ }])
2108
+ if (!confirm) {
2109
+ Logger.info('Cancelled')
2110
+ return
2111
+ }
2112
+ const spinner = Logger.spinner('Restoring snapshot')
2113
+ await snapshotManager.restoreSnapshot(snapshotId, true)
2114
+ spinner.succeed(`Restored snapshot: ${snapshotId}`)
2115
+ } catch (error) {
2116
+ Logger.error(`Failed to restore snapshot: ${error.message}`)
2117
+ process.exit(1)
2118
+ }
2119
+ }
2120
+
2121
+ async e2eList () {
2122
+ const snapshotManager = new SnapshotManager(this.config)
2123
+ try {
2124
+ const result = await snapshotManager.listSnapshots()
2125
+ const resultData = result.data || result
2126
+ const snapshots = resultData.snapshots || []
2127
+ if (snapshots.length === 0) {
2128
+ console.log('\nNo snapshots found.\n')
2129
+ console.log('Create a snapshot with: goki-dev e2e snapshot\n')
2130
+ return
2131
+ }
2132
+ console.log('\nAvailable snapshots:\n')
2133
+ for (const snapshot of snapshots) {
2134
+ const date = new Date(snapshot.timestamp).toLocaleString()
2135
+ const size = (snapshot.size / 1024 / 1024).toFixed(2)
2136
+ const services = snapshot.services.join(', ')
2137
+ console.log(` ${snapshot.id} ${date} ${size}MB (${services})`)
2138
+ }
2139
+ console.log('')
2140
+ console.log('To restore: goki-dev e2e restore <id>')
2141
+ console.log('To cleanup: goki-dev e2e cleanup <id>\n')
2142
+ } catch (error) {
2143
+ Logger.error(`Failed to list snapshots: ${error.message}`)
2144
+ process.exit(1)
2145
+ }
2146
+ }
2147
+
2148
+ async e2eCleanup (snapshotId = null) {
2149
+ const snapshotManager = new SnapshotManager(this.config)
2150
+ try {
2151
+ const inquirer = await import('inquirer')
2152
+ const message = snapshotId
2153
+ ? `⚠️ This will DELETE snapshot "${snapshotId}". Continue?`
2154
+ : '⚠️ This will DELETE ALL snapshots. Continue?'
2155
+ const { confirm } = await inquirer.default.prompt([{
2156
+ type: 'confirm',
2157
+ name: 'confirm',
2158
+ message,
2159
+ default: false
2160
+ }])
2161
+ if (!confirm) {
2162
+ Logger.info('Cancelled')
2163
+ return
2164
+ }
2165
+ const spinner = Logger.spinner('Deleting snapshot(s)')
2166
+ await snapshotManager.cleanupSnapshot(snapshotId)
2167
+ if (snapshotId) {
2168
+ spinner.succeed(`Snapshot deleted: ${snapshotId}`)
2169
+ } else {
2170
+ spinner.succeed('All snapshots deleted')
2171
+ }
2172
+ } catch (error) {
2173
+ Logger.error(`Failed to delete snapshot: ${error.message}`)
2174
+ process.exit(1)
2175
+ }
2176
+ }
2177
+
2178
+ async verifyServiceDependencies (spinner) {
2179
+ const { services = [] } = this.config.dependencies || {}
2180
+ if (services.length === 0) return
2181
+ for (const dep of services) {
2182
+ spinner.text = `Checking ${dep.name}...`
2183
+ const isHealthy = await this.checkServiceHealth(dep.healthEndpoint)
2184
+ if (!isHealthy) {
2185
+ if (dep.required) {
2186
+ if (dep.autoStart) {
2187
+ const started = await this.tryAutoStartDependency(dep, spinner)
2188
+ if (!started) {
2189
+ spinner.fail(`${dep.name} is required but not running`)
2190
+ Logger.error(dep.description || `${dep.name} is required for ${this.config.name}`)
2191
+ process.exit(1)
2192
+ }
2193
+ } else {
2194
+ spinner.fail(`${dep.name} is not running`)
2195
+ Logger.error(dep.description || `${dep.name} is required for ${this.config.name}`)
2196
+ Logger.info(`Start ${dep.name} first: cd ../${dep.name} && goki-dev start`)
2197
+ process.exit(1)
2198
+ }
2199
+ } else {
2200
+ spinner.warn(`${dep.name} is not healthy (optional dependency)`)
2201
+ }
2202
+ } else {
2203
+ Logger.info(`✓ ${dep.name} is healthy`)
2204
+ }
2205
+ }
2206
+ }
2207
+
2208
+ async checkServiceHealth (healthEndpoint) {
2209
+ try {
2210
+ const response = await fetch('http://localhost:9000/v1/services/health/check', {
2211
+ method: 'POST',
2212
+ headers: { 'Content-Type': 'application/json' },
2213
+ body: JSON.stringify({ healthEndpoint }),
2214
+ signal: AbortSignal.timeout(10000)
2215
+ })
2216
+ if (!response.ok) return false
2217
+ const result = await response.json()
2218
+ return result.data?.healthy || false
2219
+ } catch {
2220
+ return false
2221
+ }
2222
+ }
2223
+
2224
+ async tryAutoStartDependency (dep, spinner) {
2225
+ const depProject = await this.findDependencyProject(dep.name)
2226
+ if (!depProject.found) {
2227
+ Logger.warn(`Cannot find ${dep.name} at ${depProject.searchPath}`)
2228
+ return false
2229
+ }
2230
+ if (dep.containerName) {
2231
+ spinner.text = `Checking if ${dep.name} container is already running...`
2232
+ const isRunning = await this.checkContainerRunning(dep.containerName)
2233
+ if (isRunning) {
2234
+ Logger.info(`✓ ${dep.name} container is already running`)
2235
+ const isHealthy = await this.checkServiceHealth(dep.healthEndpoint)
2236
+ if (isHealthy) {
2237
+ Logger.info(`✓ ${dep.name} is healthy`)
2238
+ return true
2239
+ }
2240
+ Logger.warn(`${dep.name} container is running but health check failed`)
2241
+ }
2242
+ }
2243
+ spinner.stop()
2244
+ const shouldStart = await this.promptStartDependency(dep.name, dep.description)
2245
+ if (!shouldStart) {
2246
+ return false
2247
+ }
2248
+ spinner.start(`Starting ${dep.name}...`)
2249
+ try {
2250
+ const { execSync } = await import('child_process')
2251
+ execSync('goki-dev start', {
2252
+ stdio: 'inherit',
2253
+ cwd: depProject.path
2254
+ })
2255
+ spinner.text = `Waiting for ${dep.name} to be ready...`
2256
+ for (let i = 0; i < 30; i++) {
2257
+ const isHealthy = await this.checkServiceHealth(dep.healthEndpoint)
2258
+ if (isHealthy) {
2259
+ Logger.info(`✓ ${dep.name} started successfully`)
2260
+ spinner.start()
2261
+ return true
2262
+ }
2263
+ await new Promise(resolve => setTimeout(resolve, 1000))
2264
+ }
2265
+ Logger.warn(`${dep.name} started but health check timed out`)
2266
+ return false
2267
+ } catch (error) {
2268
+ Logger.error(`Failed to start ${dep.name}: ${error.message}`)
2269
+ return false
2270
+ }
2271
+ }
2272
+
2273
+ async checkContainerRunning (containerName) {
2274
+ try {
2275
+ const { execa } = await import('execa')
2276
+ const { stdout } = await execa('docker', [
2277
+ 'inspect',
2278
+ '--format={{.State.Running}}',
2279
+ containerName
2280
+ ])
2281
+ return stdout.trim() === 'true'
2282
+ } catch {
2283
+ return false
2284
+ }
2285
+ }
2286
+
2287
+ async findDependencyProject (serviceName) {
2288
+ const path = await import('path')
2289
+ const fs = await import('fs')
2290
+ const currentDir = process.cwd()
2291
+ const parentDir = path.dirname(currentDir)
2292
+ const depPath = path.join(parentDir, serviceName)
2293
+ const configPath = path.join(depPath, '.dev-tools', 'config.js')
2294
+ if (fs.existsSync(configPath)) {
2295
+ return {
2296
+ found: true,
2297
+ path: depPath,
2298
+ configPath
2299
+ }
2300
+ }
2301
+ return {
2302
+ found: false,
2303
+ searchPath: depPath
2304
+ }
2305
+ }
2306
+
2307
+ async promptStartDependency (serviceName, description) {
2308
+ const inquirer = (await import('inquirer')).default
2309
+ const message = description
2310
+ ? `${serviceName} is required (${description}). Start it now?`
2311
+ : `${serviceName} is required. Start it now?`
2312
+ const { shouldStart } = await inquirer.prompt([
2313
+ {
2314
+ type: 'confirm',
2315
+ name: 'shouldStart',
2316
+ message,
2317
+ default: true
2318
+ }
2319
+ ])
2320
+ return shouldStart
2321
+ }
2322
+ }