|
| 1 | +import { Command } from 'commander'; |
| 2 | +import path from 'path'; |
| 3 | +import fs from 'fs-extra'; |
| 4 | +import axios from 'axios'; |
| 5 | +import { logger } from '../core/logger.js'; |
| 6 | +import { readConfig } from '../core/config.js'; |
| 7 | +import { isRunning } from '../core/proxy.js'; |
| 8 | +import { checkGcloudInstalled, getActiveAccount, checkAdc, listInstances } from '../core/gcloud.js'; |
| 9 | +import { checkEnvironmentDetailed } from '../system/env.js'; |
| 10 | +import { isServiceInstalled, isServiceRunning } from '../system/service.js'; |
| 11 | +import { getEnvVar, runPs } from '../system/powershell.js'; |
| 12 | +import { PATHS, PATHS_REASON, PATHS_SOURCE, ENV_VARS } from '../system/paths.js'; |
| 13 | + |
| 14 | +function formatTimestamp(): string { |
| 15 | + return new Date().toISOString().replace(/[:.]/g, '-'); |
| 16 | +} |
| 17 | + |
| 18 | +function buildPathsReport(): string { |
| 19 | + const lines = [ |
| 20 | + 'CloudSQLCTL Paths', |
| 21 | + `Home: ${PATHS.HOME}`, |
| 22 | + `Bin: ${PATHS.BIN}`, |
| 23 | + `Logs: ${PATHS.LOGS}`, |
| 24 | + `Config: ${PATHS.CONFIG_FILE}`, |
| 25 | + `Proxy: ${PATHS.PROXY_EXE}`, |
| 26 | + `Secrets: ${PATHS.SECRETS}`, |
| 27 | + '', |
| 28 | + `Resolution Source: ${PATHS_SOURCE}`, |
| 29 | + `Reason: ${PATHS_REASON}`, |
| 30 | + ]; |
| 31 | + return `${lines.join('\n')}\n`; |
| 32 | +} |
| 33 | + |
| 34 | +async function buildStatusReport(): Promise<string> { |
| 35 | + const processRunning = await isRunning(); |
| 36 | + const serviceInstalled = await isServiceInstalled(); |
| 37 | + const serviceRunning = serviceInstalled ? await isServiceRunning() : false; |
| 38 | + const config = await readConfig(); |
| 39 | + |
| 40 | + const lines = [ |
| 41 | + 'CloudSQLCTL Status', |
| 42 | + `Service: ${serviceInstalled ? (serviceRunning ? 'RUNNING' : 'STOPPED') : 'NOT INSTALLED'}`, |
| 43 | + `Process: ${processRunning ? 'RUNNING' : 'STOPPED'}`, |
| 44 | + `Instance: ${config.selectedInstance || 'Unknown'}`, |
| 45 | + `Port: ${config.proxyPort || 5432}`, |
| 46 | + ]; |
| 47 | + |
| 48 | + return `${lines.join('\n')}\n`; |
| 49 | +} |
| 50 | + |
| 51 | +async function buildDoctorReport(): Promise<string> { |
| 52 | + const lines: string[] = []; |
| 53 | + lines.push('CloudSQLCTL Diagnostics'); |
| 54 | + |
| 55 | + const gcloudInstalled = await checkGcloudInstalled(); |
| 56 | + lines.push(`gcloud: ${gcloudInstalled ? 'OK' : 'FAIL'}`); |
| 57 | + |
| 58 | + const account = await getActiveAccount(); |
| 59 | + lines.push(`gcloud account: ${account || 'none'}`); |
| 60 | + |
| 61 | + const adc = await checkAdc(); |
| 62 | + lines.push(`ADC: ${adc ? 'OK' : 'WARN'}`); |
| 63 | + |
| 64 | + try { |
| 65 | + await listInstances(); |
| 66 | + lines.push('list instances: OK'); |
| 67 | + } catch (error) { |
| 68 | + const message = error instanceof Error ? error.message : String(error); |
| 69 | + lines.push(`list instances: FAIL (${message})`); |
| 70 | + } |
| 71 | + |
| 72 | + const machineEnv = await checkEnvironmentDetailed('Machine'); |
| 73 | + if (machineEnv.ok) { |
| 74 | + lines.push('env (machine): OK'); |
| 75 | + } else { |
| 76 | + lines.push('env (machine): WARN'); |
| 77 | + machineEnv.problems.forEach(p => lines.push(` - ${p}`)); |
| 78 | + } |
| 79 | + |
| 80 | + const userEnv = await checkEnvironmentDetailed('User'); |
| 81 | + if (userEnv.ok) { |
| 82 | + lines.push('env (user): OK'); |
| 83 | + } else { |
| 84 | + lines.push('env (user): WARN'); |
| 85 | + userEnv.problems.forEach(p => lines.push(` - ${p}`)); |
| 86 | + } |
| 87 | + |
| 88 | + const proxyExists = await fs.pathExists(PATHS.PROXY_EXE); |
| 89 | + lines.push(`proxy binary: ${proxyExists ? 'OK' : 'FAIL'}`); |
| 90 | + |
| 91 | + const serviceInstalled = await isServiceInstalled(); |
| 92 | + lines.push(`service installed: ${serviceInstalled ? 'yes' : 'no'}`); |
| 93 | + |
| 94 | + if (serviceInstalled) { |
| 95 | + const serviceCreds = await getEnvVar(ENV_VARS.GOOGLE_CREDS, 'Machine'); |
| 96 | + lines.push(`service creds: ${serviceCreds ? 'set' : 'not set'}`); |
| 97 | + } |
| 98 | + |
| 99 | + try { |
| 100 | + await axios.get('https://api.github.com', { timeout: 5000 }); |
| 101 | + lines.push('github api: OK'); |
| 102 | + } catch { |
| 103 | + lines.push('github api: FAIL'); |
| 104 | + } |
| 105 | + |
| 106 | + return `${lines.join('\n')}\n`; |
| 107 | +} |
| 108 | + |
| 109 | +export const supportCommand = new Command('support') |
| 110 | + .description('Support utilities'); |
| 111 | + |
| 112 | +supportCommand |
| 113 | + .command('bundle') |
| 114 | + .description('Create a support bundle zip with logs, config, doctor, paths, and status') |
| 115 | + .option('--output <path>', 'Output zip path') |
| 116 | + .option('--keep', 'Keep staging directory after bundling') |
| 117 | + .action(async (options) => { |
| 118 | + try { |
| 119 | + const timestamp = formatTimestamp(); |
| 120 | + const stagingDir = path.join(PATHS.TEMP, `support-bundle-${timestamp}`); |
| 121 | + const outputPath = options.output |
| 122 | + ? path.resolve(options.output) |
| 123 | + : path.join(PATHS.TEMP, `cloudsqlctl-support-${timestamp}.zip`); |
| 124 | + |
| 125 | + await fs.ensureDir(stagingDir); |
| 126 | + await fs.ensureDir(path.dirname(outputPath)); |
| 127 | + |
| 128 | + await fs.writeFile(path.join(stagingDir, 'paths.txt'), buildPathsReport()); |
| 129 | + await fs.writeFile(path.join(stagingDir, 'status.txt'), await buildStatusReport()); |
| 130 | + await fs.writeFile(path.join(stagingDir, 'doctor.txt'), await buildDoctorReport()); |
| 131 | + |
| 132 | + if (await fs.pathExists(PATHS.CONFIG_FILE)) { |
| 133 | + await fs.copy(PATHS.CONFIG_FILE, path.join(stagingDir, 'config.json')); |
| 134 | + } else { |
| 135 | + await fs.writeFile(path.join(stagingDir, 'config-missing.txt'), 'config.json not found'); |
| 136 | + } |
| 137 | + |
| 138 | + if (await fs.pathExists(PATHS.LOGS)) { |
| 139 | + await fs.copy(PATHS.LOGS, path.join(stagingDir, 'logs')); |
| 140 | + } else { |
| 141 | + await fs.writeFile(path.join(stagingDir, 'logs-missing.txt'), 'logs directory not found'); |
| 142 | + } |
| 143 | + |
| 144 | + await runPs('& { Compress-Archive -Path $args[0] -DestinationPath $args[1] -Force }', [ |
| 145 | + path.join(stagingDir, '*'), |
| 146 | + outputPath |
| 147 | + ]); |
| 148 | + |
| 149 | + if (!options.keep) { |
| 150 | + await fs.remove(stagingDir); |
| 151 | + } |
| 152 | + |
| 153 | + logger.info(`Support bundle created: ${outputPath}`); |
| 154 | + } catch (error) { |
| 155 | + logger.error('Failed to create support bundle', error); |
| 156 | + process.exit(1); |
| 157 | + } |
| 158 | + }); |
0 commit comments