|
| 1 | +import chalk from 'chalk'; |
| 2 | +import { readFile } from 'fs/promises'; |
| 3 | +import { resolve } from 'path'; |
| 4 | +import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; |
| 5 | +import { SshConnection } from '../../infrastructure/ssh/connection.js'; |
| 6 | +import { LoggingExecutor } from '../../infrastructure/ssh/logging-executor.js'; |
| 7 | +import { DeployService } from '../../services/deploy.service.js'; |
| 8 | +import { HealthCheckService } from '../../services/health.service.js'; |
| 9 | +import { DeployLock } from '../../domain/release/manager.js'; |
| 10 | +import { HotSync, type BuildLocation, type HotSyncResult } from '../../domain/deploy/hot-sync.js'; |
| 11 | +import { parseIgnoreFileDirs, watchProject, type ProjectWatcher } from '../../domain/deploy/watcher.js'; |
| 12 | +import { LockError } from '../../shared/errors.js'; |
| 13 | +import { ui } from '../ui.js'; |
| 14 | + |
| 15 | +/** |
| 16 | + * `shipnode deploy --watch` — the development loop. |
| 17 | + * |
| 18 | + * One full deploy establishes a coherent baseline (dependencies installed, |
| 19 | + * release health-checked, Caddy wired), then every local edit is rsynced into |
| 20 | + * that live release and the processes are reloaded. The first deploy is the |
| 21 | + * slow one; each edit after it is a hot sync. |
| 22 | + * |
| 23 | + * The session owns three things the hot sync itself does not: the deploy lock |
| 24 | + * (so a concurrent `shipnode deploy` can never interleave with a sync), |
| 25 | + * serialisation of cycles (edits arriving mid-sync are coalesced into the next |
| 26 | + * one), and reconnecting an SSH session that dropped while idle. |
| 27 | + */ |
| 28 | +export async function runDeployWatch( |
| 29 | + cwd: string, |
| 30 | + config: ShipnodeConfig, |
| 31 | + app: ShipnodeApp, |
| 32 | + options: { buildLocation: BuildLocation }, |
| 33 | +): Promise<void> { |
| 34 | + const ssh = new SshConnection(); |
| 35 | + await ssh.connect(config.ssh); |
| 36 | + |
| 37 | + const executor = new LoggingExecutor(ssh); |
| 38 | + const lock = new DeployLock(ssh, config.remotePath); |
| 39 | + |
| 40 | + let watcher: ProjectWatcher | undefined; |
| 41 | + |
| 42 | + const hotSync = new HotSync( |
| 43 | + config, |
| 44 | + app, |
| 45 | + executor, |
| 46 | + cwd, |
| 47 | + new HealthCheckService(executor, config), |
| 48 | + { |
| 49 | + buildLocation: options.buildLocation, |
| 50 | + // Deliberately late-bound: the watcher is created after the baseline |
| 51 | + // deploy, but the build that deploy runs must be suppressed too. |
| 52 | + suppressWatch: { |
| 53 | + pause: () => watcher?.pause(), |
| 54 | + resume: () => watcher?.resume(), |
| 55 | + }, |
| 56 | + }, |
| 57 | + ); |
| 58 | + |
| 59 | + let stopping = false; |
| 60 | + let lockHeld = false; |
| 61 | + |
| 62 | + /** |
| 63 | + * Release the deploy lock before exiting. |
| 64 | + * |
| 65 | + * Ctrl-C lands while a cycle may be holding the lock, and exiting there |
| 66 | + * would skip the `finally` that releases it — leaving a lock no process |
| 67 | + * owns, which blocks every later deploy until someone runs `shipnode |
| 68 | + * unlock`. A second Ctrl-C bypasses this and exits immediately, so a wedged |
| 69 | + * release can never trap the user. |
| 70 | + */ |
| 71 | + const shutdown = (): void => { |
| 72 | + if (stopping) { |
| 73 | + process.exit(130); |
| 74 | + } |
| 75 | + stopping = true; |
| 76 | + watcher?.close(); |
| 77 | + |
| 78 | + const finish = (): never => { |
| 79 | + ssh.disconnect(); |
| 80 | + ui.outro('Watch stopped.'); |
| 81 | + process.exit(0); |
| 82 | + }; |
| 83 | + |
| 84 | + if (!lockHeld) finish(); |
| 85 | + |
| 86 | + process.stdout.write(chalk.dim(' releasing deploy lock…\n')); |
| 87 | + const timeout = setTimeout(() => { |
| 88 | + ui.warn('Could not release the deploy lock. Run `shipnode unlock` before the next deploy.'); |
| 89 | + finish(); |
| 90 | + }, 5000); |
| 91 | + |
| 92 | + void lock |
| 93 | + .release() |
| 94 | + .catch(() => { |
| 95 | + ui.warn('Could not release the deploy lock. Run `shipnode unlock` before the next deploy.'); |
| 96 | + }) |
| 97 | + .finally(() => { |
| 98 | + clearTimeout(timeout); |
| 99 | + finish(); |
| 100 | + }); |
| 101 | + }; |
| 102 | + |
| 103 | + process.on('SIGINT', shutdown); |
| 104 | + process.on('SIGTERM', shutdown); |
| 105 | + |
| 106 | + try { |
| 107 | + ui.banner(); |
| 108 | + ui.step(`Watching ${chalk.bold(app.name)} → ${config.ssh.user}@${config.ssh.host}`); |
| 109 | + ui.warn( |
| 110 | + 'Watch mode patches the release that is serving traffic — no new release, ' + |
| 111 | + 'no rollback target, and reload can drop in-flight requests. Use plain ' + |
| 112 | + '`shipnode deploy` for anything you need to be able to roll back.', |
| 113 | + ); |
| 114 | + |
| 115 | + // The baseline deploy runs with skipBuild for local/none, so a local build |
| 116 | + // has to happen here or the first release ships stale build output. |
| 117 | + if (options.buildLocation === 'local') { |
| 118 | + ui.step('Building locally…'); |
| 119 | + await hotSync.buildLocally(); |
| 120 | + } |
| 121 | + |
| 122 | + ui.step(`Initial deploy… ${chalk.dim(`(build: ${options.buildLocation})`)}`); |
| 123 | + // The baseline deploy builds on the server only when the loop will too. |
| 124 | + await new DeployService(executor, config).execute(cwd, options.buildLocation !== 'remote'); |
| 125 | + |
| 126 | + let busy = false; |
| 127 | + const pending = new Set<string>(); |
| 128 | + |
| 129 | + const drain = async (): Promise<void> => { |
| 130 | + busy = true; |
| 131 | + try { |
| 132 | + while (pending.size > 0 && !stopping) { |
| 133 | + const batch = [...pending]; |
| 134 | + pending.clear(); |
| 135 | + await runCycle(batch); |
| 136 | + } |
| 137 | + } finally { |
| 138 | + busy = false; |
| 139 | + } |
| 140 | + }; |
| 141 | + |
| 142 | + const runCycle = async (batch: string[]): Promise<void> => { |
| 143 | + const label = batch.length === 1 ? batch[0] : `${batch.length} files`; |
| 144 | + process.stdout.write(`${chalk.dim('│')} ${chalk.cyan('⟳')} ${label}\n`); |
| 145 | + |
| 146 | + try { |
| 147 | + await withLock(lock, async () => { |
| 148 | + lockHeld = true; |
| 149 | + const result = await syncWithReconnect(ssh, config, hotSync, batch); |
| 150 | + reportCycle(result); |
| 151 | + }, () => { |
| 152 | + lockHeld = false; |
| 153 | + }); |
| 154 | + } catch (error) { |
| 155 | + if (error instanceof LockError) { |
| 156 | + process.stdout.write( |
| 157 | + `${chalk.dim('│')} ${chalk.yellow('⏸')} another deploy holds the lock — skipped\n`, |
| 158 | + ); |
| 159 | + return; |
| 160 | + } |
| 161 | + // A failed sync is expected during development (a type error, a crashed |
| 162 | + // boot). Report it and keep watching; the next save retries. |
| 163 | + const message = error instanceof Error ? error.message : String(error); |
| 164 | + process.stdout.write(`${chalk.dim('│')} ${chalk.red('✗')} ${message}\n`); |
| 165 | + } |
| 166 | + }; |
| 167 | + |
| 168 | + watcher = watchProject(cwd, { |
| 169 | + // Watch build output only when nothing in this process writes it — that |
| 170 | + // is, when the developer or their framework owns the build (`none`). |
| 171 | + // Under `local` *we* run the build, so watching its output would feed |
| 172 | + // our own writes back in as changes and rebuild forever. Watching is not |
| 173 | + // the same as syncing: `local` still ships the artifact, via a full-tree |
| 174 | + // rsync that detects it without the watcher's help. |
| 175 | + watchBuildOutput: options.buildLocation === 'none', |
| 176 | + ignoredDirs: await readIgnoredDirs(cwd), |
| 177 | + onBatch: (paths) => { |
| 178 | + for (const path of paths) pending.add(path); |
| 179 | + if (!busy) void drain(); |
| 180 | + }, |
| 181 | + onError: (error) => ui.warn(`watcher: ${error.message}`), |
| 182 | + }); |
| 183 | + |
| 184 | + ui.success( |
| 185 | + `Ready — editing files in ${cwd} syncs to the live release` + |
| 186 | + (watcher.mode === 'poll' ? chalk.dim(' (polling: recursive fs.watch unavailable)') : ''), |
| 187 | + ); |
| 188 | + process.stdout.write(chalk.dim(' Ctrl-C to stop.\n')); |
| 189 | + |
| 190 | + // Hold the process open; the watcher drives everything from here. |
| 191 | + await new Promise<never>(() => {}); |
| 192 | + } catch (error) { |
| 193 | + watcher?.close(); |
| 194 | + ssh.disconnect(); |
| 195 | + throw error; |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +/** |
| 200 | + * Directory names from `.shipnodeignore`, so the watcher does not wake up for |
| 201 | + * paths rsync would refuse to transfer anyway. |
| 202 | + */ |
| 203 | +async function readIgnoredDirs(cwd: string): Promise<string[]> { |
| 204 | + try { |
| 205 | + return parseIgnoreFileDirs(await readFile(resolve(cwd, '.shipnodeignore'), 'utf8')); |
| 206 | + } catch { |
| 207 | + return []; |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +/** |
| 212 | + * Acquire the deploy lock, run `body`, and always release it. |
| 213 | + * |
| 214 | + * `onReleased` runs after the lock is gone so the caller can stop tracking it — |
| 215 | + * the signal handler uses that to know whether it still has a lock to clean up. |
| 216 | + */ |
| 217 | +async function withLock( |
| 218 | + lock: DeployLock, |
| 219 | + body: () => Promise<void>, |
| 220 | + onReleased: () => void = () => {}, |
| 221 | +): Promise<void> { |
| 222 | + await lock.acquire(); |
| 223 | + try { |
| 224 | + await body(); |
| 225 | + } finally { |
| 226 | + await lock.release(); |
| 227 | + onReleased(); |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +/** |
| 232 | + * Run a hot sync, reconnecting once if the SSH session died while idle. |
| 233 | + * |
| 234 | + * A watch session sits idle for long stretches; even with keepalives a laptop |
| 235 | + * suspend or a NAT timeout can drop the connection. Reconnecting beats making |
| 236 | + * the user restart the session. |
| 237 | + */ |
| 238 | +async function syncWithReconnect( |
| 239 | + ssh: SshConnection, |
| 240 | + config: ShipnodeConfig, |
| 241 | + hotSync: HotSync, |
| 242 | + batch: string[], |
| 243 | +): Promise<HotSyncResult> { |
| 244 | + if (!ssh.isConnected()) { |
| 245 | + process.stdout.write(`${chalk.dim('│')} ${chalk.yellow('⟲')} reconnecting…\n`); |
| 246 | + await ssh.connect(config.ssh); |
| 247 | + } |
| 248 | + |
| 249 | + try { |
| 250 | + return await hotSync.run(batch); |
| 251 | + } catch (error) { |
| 252 | + if (ssh.isConnected()) throw error; |
| 253 | + process.stdout.write(`${chalk.dim('│')} ${chalk.yellow('⟲')} connection lost — reconnecting…\n`); |
| 254 | + await ssh.connect(config.ssh); |
| 255 | + return hotSync.run(batch); |
| 256 | + } |
| 257 | +} |
| 258 | + |
| 259 | +function reportCycle(result: HotSyncResult): void { |
| 260 | + const parts: string[] = [ |
| 261 | + `${result.transferredFiles} file${result.transferredFiles === 1 ? '' : 's'}`, |
| 262 | + ]; |
| 263 | + if (result.mode === 'full') parts.push('full scan'); |
| 264 | + if (result.installed) parts.push('installed'); |
| 265 | + if (result.built) parts.push('built'); |
| 266 | + if (result.reloaded) parts.push('reloaded'); |
| 267 | + |
| 268 | + const seconds = (result.durationMs / 1000).toFixed(2); |
| 269 | + const health = |
| 270 | + result.health === 'passed' |
| 271 | + ? chalk.green('healthy') |
| 272 | + : result.health === 'failed' |
| 273 | + ? chalk.red('unhealthy') |
| 274 | + : chalk.dim('no health check'); |
| 275 | + |
| 276 | + process.stdout.write( |
| 277 | + `${chalk.dim('│')} ${chalk.green('✓')} ${parts.join(', ')} · ${health} · ${chalk.bold(`${seconds}s`)}\n`, |
| 278 | + ); |
| 279 | + |
| 280 | + if (result.health === 'failed' && result.healthError) { |
| 281 | + process.stdout.write(`${chalk.dim('│')} ${chalk.dim(result.healthError.split('\n')[0])}\n`); |
| 282 | + } |
| 283 | +} |
0 commit comments