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