11import chalk from 'chalk' ;
2+ import { Result , type Result as ResultType } from 'better-result' ;
23import { loadConfig } from '../../config/loader.js' ;
34import { DeployService } from '../../services/deploy.service.js' ;
45import { LoggingExecutor } from '../../infrastructure/ssh/logging-executor.js' ;
56import { runRemoteCommandForTargets } from '../runner.js' ;
67import { ui } from '../ui.js' ;
7- import type { ShipnodeConfig , ShipnodeApp } from '../../shared/types.js' ;
8+ import type { AccessoryConfig , ShipnodeConfig , ShipnodeApp } from '../../shared/types.js' ;
89import { getPm2Name } from '../../domain/pm2/apps.js' ;
9- import { configForAppResult , configForServer , getServerTargets , resolveServerName } from '../../domain/servers.js' ;
10+ import { configForAppResult , configForServer , getServerTargets , resolveServerName , resolveServerNameResult } from '../../domain/servers.js' ;
1011import { generateBackendCaddyfile , generateFrontendCaddyfile } from '../../services/caddy.service.js' ;
12+ import { type ServerTargetError } from '../../shared/result-errors.js' ;
1113
1214export async function cmdDeploy ( cwd : string , options : { dryRun ?: boolean ; skipBuild ?: boolean ; app ?: string ; config ?: string } ) : Promise < void > {
1315 let config : ShipnodeConfig ;
@@ -54,12 +56,14 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
5456 const deployer = new DeployService ( new LoggingExecutor ( executor ) , deployConfig ) ;
5557 await deployer . execute ( cwd , options . skipBuild ?? false ) ;
5658
57- const lines = [
59+ const lines : string [ ] = [
5860 `host ${ config . ssh . user } @${ config . ssh . host } ` ,
59- ...deployConfig . apps . filter ( ( a ) => a . domain ) . map ( ( a ) => `url https://${ a . domain } ` ) ,
60- ] . filter ( Boolean ) . join ( '\n' ) ;
61+ ] ;
62+ for ( const deployedApp of deployConfig . apps ) {
63+ if ( deployedApp . domain ) lines . push ( `url https://${ deployedApp . domain } ` ) ;
64+ }
6165
62- ui . note ( lines , 'Done' ) ;
66+ ui . note ( lines . join ( '\n' ) , 'Done' ) ;
6367 ui . outro ( 'Run shipnode status to check your app.' ) ;
6468 } ,
6569 { configPath : options . config } ,
@@ -96,6 +100,9 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
96100 }
97101
98102 if ( app . domain ) serverRows . push ( [ 'Domain' , app . domain ] ) ;
103+ if ( app . dependsOn ?. length ) serverRows . push ( [ 'Depends on' , app . dependsOn . join ( ', ' ) ] ) ;
104+
105+ const dependencyWarnings = renderDependencyWarnings ( config , app ) ;
99106
100107 const caddyPreview = renderCaddyPreview ( config , app ) ;
101108
@@ -109,22 +116,21 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
109116 buildRows . push ( [ '' , chalk . dim ( 'runs on remote server' ) ] ) ;
110117 }
111118
112- const steps = [
119+ const steps : string [ ] = [
113120 'Acquire deploy lock' ,
114121 'Create release directory' ,
115122 'Rsync files' ,
116123 'Install dependencies' ,
117- app . hooks ?. preDeploy ? 'Run preDeploy hook' : '' ,
118- 'Switch symlink (atomic)' ,
119- app . appType === 'backend' ? 'Reload PM2' : '' ,
120- app . healthCheck . enabled ? `Health check ${ app . healthCheck . path } ` : '' ,
121- 'Record release' ,
122- 'Clean old releases' ,
123- app . hooks ?. postDeploy ? 'Run postDeploy hook' : '' ,
124- 'Release lock' ,
125- ] . filter ( Boolean ) ;
126-
127- const flowRows : [ string , string ] [ ] = steps . map ( ( s , i ) => [ `${ i + 1 } .` , s as string ] ) ;
124+ ] ;
125+ if ( app . hooks ?. preDeploy ) steps . push ( 'Run preDeploy hook' ) ;
126+ steps . push ( 'Switch symlink (atomic)' ) ;
127+ if ( app . appType === 'backend' ) steps . push ( 'Reload PM2' ) ;
128+ if ( app . healthCheck . enabled ) steps . push ( `Health check ${ app . healthCheck . path } ` ) ;
129+ steps . push ( 'Record release' , 'Clean old releases' ) ;
130+ if ( app . hooks ?. postDeploy ) steps . push ( 'Run postDeploy hook' ) ;
131+ steps . push ( 'Release lock' ) ;
132+
133+ const flowRows : [ string , string ] [ ] = steps . map ( ( step , i ) => [ `${ i + 1 } .` , step ] ) ;
128134
129135 return [
130136 chalk . bold ( `App: ${ app . name } ` ) ,
@@ -135,10 +141,28 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
135141 '' ,
136142 chalk . bold ( ' Deploy flow' ) ,
137143 ...flowRows . map ( ( [ k , v ] ) => ` ${ chalk . dim ( k . padEnd ( 4 ) ) } ${ v } ` ) ,
144+ ...( dependencyWarnings . length ? [ '' , chalk . bold ( ' Dependency hints' ) , ...dependencyWarnings . map ( ( line ) => ` ${ line } ` ) ] : [ ] ) ,
138145 ...( caddyPreview ? [ '' , chalk . bold ( ' Caddy' ) , caddyPreview . split ( '\n' ) . map ( ( line ) => ` ${ line } ` ) . join ( '\n' ) ] : [ ] ) ,
139146 ] . join ( '\n' ) ;
140147}
141148
149+ function renderDependencyWarnings ( config : ShipnodeConfig , app : ShipnodeApp ) : string [ ] {
150+ const dependencies = app . dependsOn ?? [ ] ;
151+ if ( dependencies . length === 0 ) return [ ] ;
152+
153+ const appServer = resolveServerName ( config , app . on ) ;
154+ const warnings : string [ ] = [ ] ;
155+ for ( const name of dependencies ) {
156+ const accessory = config . accessories ?. [ name ] ;
157+ if ( ! accessory ) continue ;
158+ const accessoryServer = resolveServerName ( config , accessory . on ) ;
159+ if ( appServer !== accessoryServer ) {
160+ warnings . push ( `${ name } runs on ${ accessoryServer } ; ${ app . name } runs on ${ appServer } . Confirm reachable networking.` ) ;
161+ }
162+ }
163+ return warnings ;
164+ }
165+
142166function renderCaddyPreview ( config : ShipnodeConfig , app : ShipnodeApp ) : string | null {
143167 if ( ! app . domain ) return null ;
144168 if ( app . appType === 'frontend' ) {
@@ -166,5 +190,95 @@ export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
166190 . flatMap ( ( targetConfig ) => targetConfig . apps . map ( ( app ) => renderAppPlan ( config , app , skipBuild ) ) )
167191 . join ( '\n\n' ) ;
168192
169- ui . note ( [ header , '' , perApp ] . join ( '\n' ) , 'Dry run — no changes will be made' ) ;
193+ const accessories = renderAccessoriesPlan ( config ) ;
194+ if ( accessories . isErr ( ) ) {
195+ ui . error ( accessories . error . message ) ;
196+ process . exit ( 1 ) ;
197+ return ;
198+ }
199+
200+ const output = [ header , '' ] ;
201+ if ( perApp ) output . push ( perApp ) ;
202+ if ( accessories . value ) output . push ( accessories . value ) ;
203+ ui . note ( output . join ( '\n' ) , 'Dry run — no changes will be made' ) ;
204+ }
205+
206+ function renderAccessoriesPlan ( config : ShipnodeConfig ) : ResultType < string , ServerTargetError > {
207+ const accessories = Object . entries ( config . accessories ?? { } ) ;
208+ if ( accessories . length === 0 ) return Result . ok ( '' ) ;
209+
210+ const rendered : string [ ] = [ chalk . bold ( 'Accessories' ) ] ;
211+ for ( const [ name , accessory ] of accessories ) {
212+ const server = resolveAccessoryServer ( config , accessory ) ;
213+ if ( server . isErr ( ) ) return Result . err ( server . error ) ;
214+
215+ rendered . push ( renderAccessoryPlan ( name , accessory , server . value , config ) ) ;
216+ }
217+
218+ return Result . ok ( rendered . join ( '\n\n' ) ) ;
219+ }
220+
221+ function resolveAccessoryServer (
222+ config : ShipnodeConfig ,
223+ accessory : AccessoryConfig ,
224+ ) : ResultType < string , ServerTargetError > {
225+ return resolveServerNameResult ( config , accessory . on ) ;
226+ }
227+
228+ function renderAccessoryPlan (
229+ name : string ,
230+ accessory : AccessoryConfig ,
231+ serverName : string ,
232+ config : ShipnodeConfig ,
233+ ) : string {
234+ const registry = accessory . registry ?? config . registry ;
235+ const ports = Array . isArray ( accessory . port ) ? accessory . port : accessory . port ? [ accessory . port ] : [ ] ;
236+ const rows : [ string , string ] [ ] = [
237+ [ 'Server' , serverName ] ,
238+ [ 'Image' , accessory . image ] ,
239+ ] ;
240+
241+ if ( ports . length > 0 ) rows . push ( [ 'Ports' , ports . join ( ', ' ) ] ) ;
242+ if ( accessory . directories ?. length ) rows . push ( [ 'Volumes' , accessory . directories . join ( ', ' ) ] ) ;
243+ if ( accessory . networks ?. length ) rows . push ( [ 'Networks' , accessory . networks . join ( ', ' ) ] ) ;
244+ if ( accessory . command ) rows . push ( [ 'Command' , Array . isArray ( accessory . command ) ? accessory . command . join ( ' ' ) : accessory . command ] ) ;
245+ if ( accessory . labels && Object . keys ( accessory . labels ) . length > 0 ) rows . push ( [ 'Labels' , Object . entries ( accessory . labels ) . map ( ( [ key , value ] ) => `${ key } =${ value } ` ) . join ( ', ' ) ] ) ;
246+ if ( accessory . restart ) rows . push ( [ 'Restart' , accessory . restart ] ) ;
247+ if ( accessory . resources ) rows . push ( [ 'Resources' , renderAccessoryResources ( accessory . resources ) ] ) ;
248+ if ( accessory . stopTimeout !== undefined ) rows . push ( [ 'Stop timeout' , `${ accessory . stopTimeout } s` ] ) ;
249+ if ( registry ) rows . push ( [ 'Registry' , `${ registry . server } (${ registry . passwordEnv } )` ] ) ;
250+ if ( accessory . healthCheck ) rows . push ( [ 'Health check' , accessory . healthCheck . command ] ) ;
251+
252+ const hasVolumeSetup = ( accessory . directories ?? [ ] ) . some ( ( mount ) => {
253+ const [ source ] = mount . split ( ':' ) ;
254+ return source !== undefined && source !== '' && ! source . startsWith ( '/' ) && ! source . startsWith ( '.' ) && ! source . startsWith ( '~' ) ;
255+ } ) ;
256+ const hasNetworkSetup = ( accessory . networks ?? [ ] ) . length > 0 ;
257+ const setupSteps = [
258+ ...( hasVolumeSetup ? [ 'Inspect/create named Docker volumes' ] : [ ] ) ,
259+ ...( hasNetworkSetup ? [ 'Inspect/create Docker networks' ] : [ ] ) ,
260+ ] ;
261+ const dockerSteps = [
262+ registry ? `Login to ${ registry . server } using $${ registry . passwordEnv } ` : 'Skip registry login' ,
263+ ...setupSteps ,
264+ `Pull ${ accessory . image } ` ,
265+ `Recreate container shipnode-${ name } ` ,
266+ ...( accessory . healthCheck ? [ 'Run health check' ] : [ ] ) ,
267+ ] ;
268+
269+ return [
270+ chalk . bold ( `Accessory: ${ name } ` ) ,
271+ ...rows . map ( ( [ key , value ] ) => ` ${ chalk . dim ( key . padEnd ( 14 ) ) } ${ value } ` ) ,
272+ '' ,
273+ chalk . bold ( ' Docker flow' ) ,
274+ ...dockerSteps . map ( ( step , index ) => ` ${ chalk . dim ( `${ index + 1 } .` . padEnd ( 4 ) ) } ${ step } ` ) ,
275+ ] . join ( '\n' ) ;
276+ }
277+
278+ function renderAccessoryResources ( resources : NonNullable < AccessoryConfig [ 'resources' ] > ) : string {
279+ const parts : string [ ] = [ ] ;
280+ if ( resources . memory ) parts . push ( `memory=${ resources . memory } ` ) ;
281+ if ( resources . memoryReservation ) parts . push ( `memoryReservation=${ resources . memoryReservation } ` ) ;
282+ if ( resources . cpus ) parts . push ( `cpus=${ resources . cpus } ` ) ;
283+ return parts . join ( ', ' ) ;
170284}
0 commit comments