@@ -26,6 +26,17 @@ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checku
2626import { getCheckupEntry } from "../lib/checkup-dictionary" ;
2727import { createCheckupReport , uploadCheckupReportJson , convertCheckupReportJsonToMarkdown , RpcError , formatRpcErrorForDisplay , withRetry } from "../lib/checkup-api" ;
2828import { generateCheckSummary } from "../lib/checkup-summary" ;
29+ import {
30+ type Instance ,
31+ InstancesParseError ,
32+ loadInstances ,
33+ buildInstance ,
34+ addInstanceToFile ,
35+ removeInstanceFromFile ,
36+ buildClientConfig ,
37+ sslOptionFromConnString ,
38+ warnIfLaxSslmode ,
39+ } from "../lib/instances" ;
2940
3041// Node.js version check - require Node 18+
3142// Node 14 reached EOL in April 2023, Node 16 in September 2023.
@@ -419,19 +430,6 @@ interface ConfigResult {
419430 apiKey : string ;
420431}
421432
422- /**
423- * Instance configuration
424- */
425- interface Instance {
426- name : string ;
427- conn_str ?: string ;
428- preset_metrics ?: string ;
429- custom_metrics ?: any ;
430- is_enabled ?: boolean ;
431- group ?: string ;
432- custom_tags ?: Record < string , any > ;
433- }
434-
435433/**
436434 * Path resolution result
437435 */
@@ -2302,6 +2300,18 @@ const mon = program.command("mon").description("monitoring services management")
23022300mon
23032301 . command ( "local-install" )
23042302 . description ( "install local monitoring stack (generate config, start services)" )
2303+ . addHelpText (
2304+ "after" ,
2305+ [
2306+ "" ,
2307+ "Networking:" ,
2308+ " Compose enables IPv6 on the project's default network so containers can" ,
2309+ " reach IPv6-only databases (e.g. Supabase free-tier db.<ref>.supabase.co)." ,
2310+ " Override on hosts whose Docker daemon cannot create an IPv6 network:" ,
2311+ " PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)" ,
2312+ "" ,
2313+ ] . join ( "\n" ) ,
2314+ )
23052315 . option ( "--demo" , "demo mode with sample database" , false )
23062316 . option ( "--api-key <key>" , "Postgres AI API key for automated report uploads" )
23072317 . option ( "--db-url <url>" , "PostgreSQL connection URL to monitor" )
@@ -2487,16 +2497,16 @@ mon
24872497 const db = m [ 5 ] ;
24882498 const instanceName = `${ host } -${ db } ` . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "-" ) ;
24892499
2490- const body = `- name: ${ instanceName } \n conn_str: ${ connStr } \n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${ instanceName } \n sink_type: ~sink_type~\n` ;
2491- fs . appendFileSync ( instancesPath , body , "utf8" ) ;
2500+ addInstanceToFile ( instancesPath , buildInstance ( instanceName , connStr ) ) ;
24922501 console . log ( `✓ Monitoring target '${ instanceName } ' added\n` ) ;
24932502
24942503 // Test connection
24952504 console . log ( "Testing connection to the added instance..." ) ;
24962505 {
24972506 let testClient : InstanceType < typeof Client > | null = null ;
24982507 try {
2499- testClient = new Client ( { connectionString : connStr , connectionTimeoutMillis : 10000 } ) ;
2508+ warnIfLaxSslmode ( connStr ) ;
2509+ testClient = new Client ( buildClientConfig ( connStr , { connectionTimeoutMillis : 10000 } ) ) ;
25002510 await testClient . connect ( ) ;
25012511 const result = await testClient . query ( "select version();" ) ;
25022512 console . log ( "✓ Connection successful" ) ;
@@ -2535,16 +2545,16 @@ mon
25352545 const db = m [ 5 ] ;
25362546 const instanceName = `${ host } -${ db } ` . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "-" ) ;
25372547
2538- const body = `- name: ${ instanceName } \n conn_str: ${ connStr } \n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${ instanceName } \n sink_type: ~sink_type~\n` ;
2539- fs . appendFileSync ( instancesPath , body , "utf8" ) ;
2548+ addInstanceToFile ( instancesPath , buildInstance ( instanceName , connStr ) ) ;
25402549 console . log ( `✓ Monitoring target '${ instanceName } ' added\n` ) ;
25412550
25422551 // Test connection
25432552 console . log ( "Testing connection to the added instance..." ) ;
25442553 {
25452554 let testClient : InstanceType < typeof Client > | null = null ;
25462555 try {
2547- testClient = new Client ( { connectionString : connStr , connectionTimeoutMillis : 10000 } ) ;
2556+ warnIfLaxSslmode ( connStr ) ;
2557+ testClient = new Client ( buildClientConfig ( connStr , { connectionTimeoutMillis : 10000 } ) ) ;
25482558 await testClient . connect ( ) ;
25492559 const result = await testClient . query ( "select version();" ) ;
25502560 console . log ( "✓ Connection successful" ) ;
@@ -3173,42 +3183,32 @@ targets
31733183 return ;
31743184 }
31753185
3186+ let instances : Instance [ ] ;
31763187 try {
3177- const content = fs . readFileSync ( instancesPath , "utf8" ) ;
3178- const instances = yaml . load ( content ) as Instance [ ] | null ;
3179-
3180- if ( ! instances || ! Array . isArray ( instances ) || instances . length === 0 ) {
3181- console . log ( "No monitoring targets configured" ) ;
3182- console . log ( "" ) ;
3183- console . log ( "To add a monitoring target:" ) ;
3184- console . log ( " postgres-ai mon targets add <connection-string> <name>" ) ;
3185- console . log ( "" ) ;
3186- console . log ( "Example:" ) ;
3187- console . log ( " postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db" ) ;
3188- return ;
3189- }
3190-
3191- // Filter out disabled instances (e.g., demo placeholders)
3192- const filtered = instances . filter ( ( inst ) => inst . name && inst . is_enabled !== false ) ;
3193-
3194- if ( filtered . length === 0 ) {
3195- console . log ( "No monitoring targets configured" ) ;
3196- console . log ( "" ) ;
3197- console . log ( "To add a monitoring target:" ) ;
3198- console . log ( " postgres-ai mon targets add <connection-string> <name>" ) ;
3199- console . log ( "" ) ;
3200- console . log ( "Example:" ) ;
3201- console . log ( " postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db" ) ;
3202- return ;
3203- }
3204-
3205- for ( const inst of filtered ) {
3206- console . log ( `Target: ${ inst . name } ` ) ;
3207- }
3188+ instances = loadInstances ( instancesPath ) ;
32083189 } catch ( err ) {
32093190 const message = err instanceof Error ? err . message : String ( err ) ;
32103191 console . error ( `Error parsing instances.yml: ${ message } ` ) ;
32113192 process . exitCode = 1 ;
3193+ return ;
3194+ }
3195+
3196+ // Filter out disabled instances (e.g., demo placeholders)
3197+ const filtered = instances . filter ( ( inst ) => inst . name && inst . is_enabled !== false ) ;
3198+
3199+ if ( filtered . length === 0 ) {
3200+ console . log ( "No monitoring targets configured" ) ;
3201+ console . log ( "" ) ;
3202+ console . log ( "To add a monitoring target:" ) ;
3203+ console . log ( " postgres-ai mon targets add <connection-string> <name>" ) ;
3204+ console . log ( "" ) ;
3205+ console . log ( "Example:" ) ;
3206+ console . log ( " postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db" ) ;
3207+ return ;
3208+ }
3209+
3210+ for ( const inst of filtered ) {
3211+ console . log ( `Target: ${ inst . name } ` ) ;
32123212 }
32133213 } ) ;
32143214targets
@@ -3231,40 +3231,17 @@ targets
32313231 const db = m [ 5 ] ;
32323232 const instanceName = name && name . trim ( ) ? name . trim ( ) : `${ host } -${ db } ` . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "-" ) ;
32333233
3234- // Check if instance already exists
32353234 try {
3236- if ( fs . existsSync ( file ) && ! fs . lstatSync ( file ) . isDirectory ( ) ) {
3237- const content = fs . readFileSync ( file , "utf8" ) ;
3238- const instances = yaml . load ( content ) as Instance [ ] | null || [ ] ;
3239- if ( Array . isArray ( instances ) ) {
3240- const exists = instances . some ( ( inst ) => inst . name === instanceName ) ;
3241- if ( exists ) {
3242- console . error ( `Monitoring target '${ instanceName } ' already exists` ) ;
3243- process . exitCode = 1 ;
3244- return ;
3245- }
3246- }
3247- }
3235+ addInstanceToFile ( file , buildInstance ( instanceName , connStr ) ) ;
3236+ console . log ( `Monitoring target '${ instanceName } ' added` ) ;
32483237 } catch ( err ) {
3249- // If YAML parsing fails, fall back to simple check
3250- const isFile = fs . existsSync ( file ) && ! fs . lstatSync ( file ) . isDirectory ( ) ;
3251- const content = isFile ? fs . readFileSync ( file , "utf8" ) : "" ;
3252- const escapedName = instanceName . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
3253- if ( new RegExp ( `^- name: ${ escapedName } $` , "m" ) . test ( content ) ) {
3254- console . error ( `Monitoring target '${ instanceName } ' already exists` ) ;
3255- process . exitCode = 1 ;
3256- return ;
3257- }
3258- }
3259-
3260- // Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3261- if ( fs . existsSync ( file ) && fs . lstatSync ( file ) . isDirectory ( ) ) {
3262- fs . rmSync ( file , { recursive : true , force : true } ) ;
3238+ // Surface InstancesParseError as-is so we don't silently overwrite a
3239+ // corrupted file (which could discard several targets, including the
3240+ // credentials in their conn_str values).
3241+ const message = err instanceof Error ? err . message : String ( err ) ;
3242+ console . error ( message ) ;
3243+ process . exitCode = 1 ;
32633244 }
3264- const body = `- name: ${ instanceName } \n conn_str: ${ connStr } \n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${ instanceName } \n sink_type: ~sink_type~\n` ;
3265- const content = fs . existsSync ( file ) ? fs . readFileSync ( file , "utf8" ) : "" ;
3266- fs . appendFileSync ( file , ( content && ! / \n $ / . test ( content ) ? "\n" : "" ) + body , "utf8" ) ;
3267- console . log ( `Monitoring target '${ instanceName } ' added` ) ;
32683245 } ) ;
32693246targets
32703247 . command ( "remove <name>" )
@@ -3278,24 +3255,12 @@ targets
32783255 }
32793256
32803257 try {
3281- const content = fs . readFileSync ( file , "utf8" ) ;
3282- const instances = yaml . load ( content ) as Instance [ ] | null ;
3283-
3284- if ( ! instances || ! Array . isArray ( instances ) ) {
3285- console . error ( "Invalid instances.yml format" ) ;
3286- process . exitCode = 1 ;
3287- return ;
3288- }
3289-
3290- const filtered = instances . filter ( ( inst ) => inst . name !== name ) ;
3291-
3292- if ( filtered . length === instances . length ) {
3258+ const removed = removeInstanceFromFile ( file , name ) ;
3259+ if ( ! removed ) {
32933260 console . error ( `Monitoring target '${ name } ' not found` ) ;
32943261 process . exitCode = 1 ;
32953262 return ;
32963263 }
3297-
3298- fs . writeFileSync ( file , yaml . dump ( filtered ) , "utf8" ) ;
32993264 console . log ( `Monitoring target '${ name } ' removed` ) ;
33003265 } catch ( err ) {
33013266 const message = err instanceof Error ? err . message : String ( err ) ;
@@ -3314,35 +3279,35 @@ targets
33143279 return ;
33153280 }
33163281
3282+ let instances : Instance [ ] ;
33173283 try {
3318- const content = fs . readFileSync ( instancesPath , "utf8" ) ;
3319- const instances = yaml . load ( content ) as Instance [ ] | null ;
3320-
3321- if ( ! instances || ! Array . isArray ( instances ) ) {
3322- console . error ( "Invalid instances.yml format" ) ;
3323- process . exitCode = 1 ;
3324- return ;
3325- }
3326-
3327- const instance = instances . find ( ( inst ) => inst . name === name ) ;
3284+ instances = loadInstances ( instancesPath ) ;
3285+ } catch ( err ) {
3286+ const message = err instanceof Error ? err . message : String ( err ) ;
3287+ console . error ( `Error parsing instances.yml: ${ message } ` ) ;
3288+ process . exitCode = 1 ;
3289+ return ;
3290+ }
3291+ const instance = instances . find ( ( inst ) => inst . name === name ) ;
33283292
3329- if ( ! instance ) {
3330- console . error ( `Monitoring target '${ name } ' not found` ) ;
3331- process . exitCode = 1 ;
3332- return ;
3333- }
3293+ if ( ! instance ) {
3294+ console . error ( `Monitoring target '${ name } ' not found` ) ;
3295+ process . exitCode = 1 ;
3296+ return ;
3297+ }
33343298
3335- if ( ! instance . conn_str ) {
3336- console . error ( `Connection string not found for monitoring target '${ name } '` ) ;
3337- process . exitCode = 1 ;
3338- return ;
3339- }
3299+ if ( ! instance . conn_str ) {
3300+ console . error ( `Connection string not found for monitoring target '${ name } '` ) ;
3301+ process . exitCode = 1 ;
3302+ return ;
3303+ }
33403304
3341- console . log ( `Testing connection to monitoring target '${ name } '...` ) ;
3305+ console . log ( `Testing connection to monitoring target '${ name } '...` ) ;
33423306
3343- // Use native pg client instead of requiring psql to be installed
3344- const client = new Client ( { connectionString : instance . conn_str , connectionTimeoutMillis : 10000 } ) ;
3307+ warnIfLaxSslmode ( instance . conn_str ) ;
3308+ const client = new Client ( buildClientConfig ( instance . conn_str , { connectionTimeoutMillis : 10000 } ) ) ;
33453309
3310+ try {
33463311 try {
33473312 await client . connect ( ) ;
33483313 const result = await client . query ( 'select version();' ) ;
@@ -3376,17 +3341,17 @@ auth
33763341 process . exitCode = 1 ;
33773342 return ;
33783343 }
3379-
3344+
33803345 // Read existing config to check for defaultProject before updating
33813346 const existingConfig = config . readConfig ( ) ;
33823347 const existingProject = existingConfig . defaultProject ;
3383-
3348+
33843349 config . writeConfig ( { apiKey : trimmedKey } ) ;
33853350 // When API key is set directly, only clear orgId (org selection may differ).
33863351 // Preserve defaultProject to avoid orphaning historical reports.
33873352 // If the new key lacks access to the project, upload will fail with a clear error.
33883353 config . deleteConfigKeys ( [ "orgId" ] ) ;
3389-
3354+
33903355 console . log ( `API key saved to ${ config . getConfigPath ( ) } ` ) ;
33913356 if ( existingProject ) {
33923357 console . log ( `Note: Your default project "${ existingProject } " has been preserved.` ) ;
@@ -3570,13 +3535,13 @@ auth
35703535 const existingOrgId = existingConfig . orgId ;
35713536 const existingProject = existingConfig . defaultProject ;
35723537 const orgChanged = existingOrgId && existingOrgId !== orgId ;
3573-
3538+
35743539 config . writeConfig ( {
35753540 apiKey : apiToken ,
35763541 baseUrl : apiBaseUrl ,
35773542 orgId : orgId ,
35783543 } ) ;
3579-
3544+
35803545 // Only clear defaultProject if org actually changed
35813546 if ( orgChanged && existingProject ) {
35823547 config . deleteConfigKeys ( [ "defaultProject" ] ) ;
@@ -4971,4 +4936,3 @@ mcp
49714936program . parseAsync ( process . argv ) . finally ( ( ) => {
49724937 closeReadline ( ) ;
49734938} ) ;
4974-
0 commit comments