@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.
- package/README.md +478 -0
- package/bin/goki-dev.js +452 -0
- package/bin/mcp-server.js +16 -0
- package/bin/secrets-cli.js +302 -0
- package/cli/ComposeOverrideGenerator.js +226 -0
- package/cli/ComposeParser.js +73 -0
- package/cli/ConfigGenerator.js +304 -0
- package/cli/ConfigManager.js +46 -0
- package/cli/DatabaseManager.js +94 -0
- package/cli/DevToolsChecker.js +21 -0
- package/cli/DevToolsDir.js +66 -0
- package/cli/DevToolsManager.js +451 -0
- package/cli/DockerManager.js +138 -0
- package/cli/FunctionManager.js +95 -0
- package/cli/HttpProxyRewriter.js +91 -0
- package/cli/Logger.js +10 -0
- package/cli/McpConfigManager.js +123 -0
- package/cli/NgrokManager.js +431 -0
- package/cli/ProjectCLI.js +2322 -0
- package/cli/PubSubManager.js +129 -0
- package/cli/SnapshotManager.js +88 -0
- package/cli/UiFormatter.js +292 -0
- package/cli/WebhookUrlRewriter.js +32 -0
- package/cli/secrets/BiometricAuth.js +125 -0
- package/cli/secrets/SecretInjector.js +47 -0
- package/cli/secrets/SecretsConfig.js +141 -0
- package/cli/secrets/SecretsDoctor.js +384 -0
- package/cli/secrets/SecretsManager.js +255 -0
- package/client/dist/client.d.ts +332 -0
- package/client/dist/client.js +507 -0
- package/client/dist/helpers.d.ts +62 -0
- package/client/dist/helpers.js +122 -0
- package/client/dist/index.d.ts +59 -0
- package/client/dist/index.js +78 -0
- package/client/dist/package.json +1 -0
- package/client/dist/types.d.ts +280 -0
- package/client/dist/types.js +7 -0
- package/config.development +46 -0
- package/config.test +18 -0
- package/guidelines/CodingStyleGuideline.md +148 -0
- package/guidelines/CommentingGuideline.md +10 -0
- package/guidelines/HttpApiImplementationGuideline.md +137 -0
- package/guidelines/NamingGuideline.md +182 -0
- package/package.json +138 -0
- package/patterns/api/[collectionName]/Controllers.md +62 -0
- package/patterns/api/[collectionName]/Logic.md +154 -0
- package/patterns/api/[collectionName]/Permissions.md +81 -0
- package/patterns/api/[collectionName]/Router.md +83 -0
- package/patterns/api/[collectionName]/Schemas.md +197 -0
- package/patterns/configs/Patterns.md +7 -0
- package/patterns/enums/Patterns.md +24 -0
- package/patterns/errorHandling/Patterns.md +185 -0
- package/patterns/testing/Patterns.md +232 -0
- package/src/Server.js +238 -0
- package/src/api/dashboard/Controllers.js +9 -0
- package/src/api/dashboard/Logic.js +76 -0
- package/src/api/dashboard/Router.js +11 -0
- package/src/api/dashboard/Schemas.js +47 -0
- package/src/api/data/Controllers.js +26 -0
- package/src/api/data/Logic.js +188 -0
- package/src/api/data/Router.js +16 -0
- package/src/api/docker/Controllers.js +33 -0
- package/src/api/docker/Logic.js +268 -0
- package/src/api/docker/Router.js +15 -0
- package/src/api/docker/Schemas.js +80 -0
- package/src/api/docs/Controllers.js +15 -0
- package/src/api/docs/Logic.js +85 -0
- package/src/api/docs/Router.js +12 -0
- package/src/api/export/Controllers.js +30 -0
- package/src/api/export/Logic.js +143 -0
- package/src/api/export/Router.js +18 -0
- package/src/api/export/Schemas.js +104 -0
- package/src/api/firestore/Controllers.js +152 -0
- package/src/api/firestore/Logic.js +474 -0
- package/src/api/firestore/Router.js +23 -0
- package/src/api/functions/Controllers.js +261 -0
- package/src/api/functions/Logic.js +710 -0
- package/src/api/functions/Router.js +50 -0
- package/src/api/functions/Schemas.js +193 -0
- package/src/api/gateway/Controllers.js +72 -0
- package/src/api/gateway/Logic.js +74 -0
- package/src/api/gateway/Router.js +10 -0
- package/src/api/gateway/Schemas.js +19 -0
- package/src/api/health/Controllers.js +14 -0
- package/src/api/health/Logic.js +24 -0
- package/src/api/health/Router.js +12 -0
- package/src/api/httpTraffic/Controllers.js +29 -0
- package/src/api/httpTraffic/Logic.js +33 -0
- package/src/api/httpTraffic/Router.js +9 -0
- package/src/api/httpTraffic/Schemas.js +23 -0
- package/src/api/logging/Controllers.js +80 -0
- package/src/api/logging/Logic.js +461 -0
- package/src/api/logging/Router.js +24 -0
- package/src/api/logging/Schemas.js +43 -0
- package/src/api/mqtt/Controllers.js +17 -0
- package/src/api/mqtt/Logic.js +66 -0
- package/src/api/mqtt/Router.js +12 -0
- package/src/api/postgres/Controllers.js +97 -0
- package/src/api/postgres/Logic.js +221 -0
- package/src/api/postgres/Router.js +21 -0
- package/src/api/pubsub/Controllers.js +236 -0
- package/src/api/pubsub/Logic.js +732 -0
- package/src/api/pubsub/Router.js +41 -0
- package/src/api/pubsub/Schemas.js +355 -0
- package/src/api/redis/Controllers.js +63 -0
- package/src/api/redis/Logic.js +239 -0
- package/src/api/redis/Router.js +21 -0
- package/src/api/scheduler/Controllers.js +27 -0
- package/src/api/scheduler/Logic.js +49 -0
- package/src/api/scheduler/Router.js +16 -0
- package/src/api/services/Controllers.js +26 -0
- package/src/api/services/Logic.js +205 -0
- package/src/api/services/Router.js +14 -0
- package/src/api/services/Schemas.js +66 -0
- package/src/api/snapshots/Controllers.js +37 -0
- package/src/api/snapshots/Logic.js +797 -0
- package/src/api/snapshots/Router.js +15 -0
- package/src/api/snapshots/Schemas.js +23 -0
- package/src/api/webhooks/Controllers.js +49 -0
- package/src/api/webhooks/Logic.js +137 -0
- package/src/api/webhooks/Router.js +12 -0
- package/src/api/webhooks/Schemas.js +31 -0
- package/src/configs/Application.js +147 -0
- package/src/configs/Default.js +13 -0
- package/src/consumers/BlackboxLogsConsumer.js +235 -0
- package/src/consumers/DockerLogsConsumer.js +687 -0
- package/src/db/Tables.js +66 -0
- package/src/db/schemas/firestore.js +18 -0
- package/src/db/schemas/functions.js +65 -0
- package/src/db/schemas/httpTraffic.js +43 -0
- package/src/db/schemas/logging.js +74 -0
- package/src/db/schemas/migrations.js +64 -0
- package/src/db/schemas/mqtt.js +56 -0
- package/src/db/schemas/pubsub.js +90 -0
- package/src/db/schemas/pubsubRegistry.js +22 -0
- package/src/db/schemas/webhooks.js +28 -0
- package/src/emulation/awsiot/Controllers.js +91 -0
- package/src/emulation/awsiot/Logic.js +70 -0
- package/src/emulation/awsiot/Router.js +19 -0
- package/src/emulation/awsiot/Server.js +100 -0
- package/src/emulation/firestore/Server.js +136 -0
- package/src/emulation/logging/Controllers.js +212 -0
- package/src/emulation/logging/Logic.js +416 -0
- package/src/emulation/logging/Router.js +36 -0
- package/src/emulation/logging/Schemas.js +82 -0
- package/src/emulation/logging/Server.js +108 -0
- package/src/emulation/pubsub/Controllers.js +279 -0
- package/src/emulation/pubsub/DefaultTopics.js +162 -0
- package/src/emulation/pubsub/Logic.js +427 -0
- package/src/emulation/pubsub/README.md +309 -0
- package/src/emulation/pubsub/Router.js +33 -0
- package/src/emulation/pubsub/Server.js +104 -0
- package/src/emulation/pubsub/ShadowPoller.js +276 -0
- package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
- package/src/enums/ContainerNames.js +106 -0
- package/src/enums/ErrorReason.js +28 -0
- package/src/enums/FunctionStatuses.js +15 -0
- package/src/enums/FunctionTriggerTypes.js +15 -0
- package/src/enums/GatewayState.js +7 -0
- package/src/enums/ServiceNames.js +68 -0
- package/src/jobs/DatabaseMaintenance.js +184 -0
- package/src/jobs/MessageHistoryCleanup.js +152 -0
- package/src/mcp/ApiClient.js +25 -0
- package/src/mcp/Server.js +52 -0
- package/src/mcp/prompts/debugging.js +104 -0
- package/src/mcp/resources/platform.js +118 -0
- package/src/mcp/tools/data.js +84 -0
- package/src/mcp/tools/docker.js +166 -0
- package/src/mcp/tools/firestore.js +162 -0
- package/src/mcp/tools/functions.js +380 -0
- package/src/mcp/tools/httpTraffic.js +69 -0
- package/src/mcp/tools/logging.js +174 -0
- package/src/mcp/tools/mqtt.js +37 -0
- package/src/mcp/tools/postgres.js +130 -0
- package/src/mcp/tools/pubsub.js +316 -0
- package/src/mcp/tools/redis.js +146 -0
- package/src/mcp/tools/services.js +169 -0
- package/src/mcp/tools/snapshots.js +88 -0
- package/src/mcp/tools/webhooks.js +115 -0
- package/src/middleware/DevProxy.js +67 -0
- package/src/middleware/ErrorCatcher.js +35 -0
- package/src/middleware/HttpProxy.js +215 -0
- package/src/middleware/Reply.js +24 -0
- package/src/middleware/TraceId.js +9 -0
- package/src/middleware/WebhookProxy.js +234 -0
- package/src/protocols/mqtt/Broker.js +92 -0
- package/src/protocols/mqtt/Handlers.js +175 -0
- package/src/protocols/mqtt/PubSubBridge.js +162 -0
- package/src/protocols/mqtt/Server.js +116 -0
- package/src/runtime/FunctionRunner.js +179 -0
- package/src/services/AppGatewayService.js +582 -0
- package/src/singletons/FirestoreBroadcaster.js +367 -0
- package/src/singletons/FunctionTriggerDispatcher.js +456 -0
- package/src/singletons/FunctionsService.js +418 -0
- package/src/singletons/HttpProxy.js +224 -0
- package/src/singletons/LogBroadcaster.js +159 -0
- package/src/singletons/Logger.js +49 -0
- package/src/singletons/MemoryJsonStore.js +175 -0
- package/src/singletons/MessageBroadcaster.js +190 -0
- package/src/singletons/PostgresBroadcaster.js +367 -0
- package/src/singletons/PostgresClient.js +180 -0
- package/src/singletons/RedisClient.js +184 -0
- package/src/singletons/SqliteStore.js +480 -0
- package/src/singletons/TickService.js +151 -0
- 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
|
+
}
|