@akash-chowdhury-24/deployhub 2.0.21 → 2.0.23

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.
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
- import { loadConfig, loadEnv } from '../core/config.js';
2
+ import { loadEnv } from '../core/config.js';
3
+ import { loadConfigOrExit } from '../core/load-config-or-exit.js';
3
4
  import { resolveEnvTargets } from '../core/environments.js';
4
5
  import {
5
6
  anyEnvHasResolvableHealthCheckUrl,
@@ -86,7 +87,7 @@ export function registerVerifyCommand(program) {
86
87
  )
87
88
  .action(async (opts) => {
88
89
  loadEnv();
89
- const config = await loadConfig();
90
+ const config = await loadConfigOrExit();
90
91
 
91
92
  let outcome;
92
93
  try {
@@ -0,0 +1,37 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig } from './config.js';
3
+
4
+ /**
5
+ * @param {unknown} err
6
+ * @returns {boolean}
7
+ */
8
+ export function isConfigMissingError(err) {
9
+ return err instanceof Error && /Config not found/i.test(err.message);
10
+ }
11
+
12
+ /**
13
+ * User-facing missing-config message (no stack, no snapshot paths).
14
+ */
15
+ export function printMissingConfigError() {
16
+ console.error(chalk.red('✗ No deployhub.config.json found in this directory.'));
17
+ console.error(chalk.red(" Run 'deployhub init' first to set up your project."));
18
+ }
19
+
20
+ /**
21
+ * Load config or print a clean error and exit (never throws to the caller).
22
+ * @param {string} [cwd]
23
+ * @returns {Promise<import('./config.js').DeployHubConfig>}
24
+ */
25
+ export async function loadConfigOrExit(cwd = process.cwd()) {
26
+ try {
27
+ return await loadConfig(cwd);
28
+ } catch (err) {
29
+ if (isConfigMissingError(err)) {
30
+ printMissingConfigError();
31
+ } else {
32
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
33
+ }
34
+ process.exit(1);
35
+ throw err;
36
+ }
37
+ }
@@ -144,6 +144,8 @@ export function buildPipelineStages(config, cwd, state) {
144
144
  return;
145
145
  }
146
146
 
147
+ // Adapters themselves null-guard buildCommand; this stage always
148
+ // invokes build() so interpreted backends can log the skip cleanly.
147
149
  const adapter = getAdapter(ctx.config.framework, ctx.config, ctx.cwd);
148
150
  await adapter.build();
149
151
  },
@@ -24,6 +24,32 @@ export const SERVER_DEPLOY_TYPES = [
24
24
 
25
25
  const SSH_BASED = ['ssh', 'ec2', 'azure-vm', 'gcp-vm'];
26
26
 
27
+ const NODE_PM2_FRAMEWORKS = new Set([
28
+ 'express',
29
+ 'nestjs',
30
+ 'fastify',
31
+ 'koa',
32
+ 'nextjs',
33
+ 'node',
34
+ ]);
35
+
36
+ /**
37
+ * User-facing label for the backend process identity field (`appName`).
38
+ * Node backends use PM2; other languages use DEPLOYHUB_APP + PID files.
39
+ *
40
+ * @param {string|undefined|null} framework
41
+ * @param {string} projectName
42
+ * @param {'frontend'|'backend'|'both'} projectType
43
+ * @returns {string}
44
+ */
45
+ export function backendProcessNamePromptMessage(framework, projectName, projectType) {
46
+ const backendFramework = String(framework || '').toLowerCase();
47
+ const usesPm2 = !backendFramework || NODE_PM2_FRAMEWORKS.has(backendFramework);
48
+ const example = projectType === 'both' ? `${projectName}-api` : projectName;
49
+ return usesPm2
50
+ ? `PM2 process name for your backend (e.g. ${example}):`
51
+ : `Process name for your backend (identifies this app's process on the server, e.g. ${example}):`;
52
+ }
27
53
  /**
28
54
  * Prompt for one environment's deployment method + method-specific config.
29
55
  * Shared by `deployhub init` and `deployhub env add`.
@@ -427,7 +453,11 @@ async function promptSshBasedDeployment(base, projectName, projectType, backendC
427
453
  questions.push({
428
454
  type: 'input',
429
455
  name: 'appName',
430
- message: `PM2 process name for your backend (e.g. ${projectName}-api):`,
456
+ message: backendProcessNamePromptMessage(
457
+ /** @type {string|undefined} */ (backendConfig?.framework),
458
+ projectName,
459
+ projectType
460
+ ),
431
461
  default: projectType === 'both' ? `${projectName}-api` : projectName,
432
462
  });
433
463
  }
@@ -12,6 +12,7 @@ import {
12
12
  } from '../../utils/nginx.js';
13
13
  import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
14
14
  import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
15
+ import { extractGunicornTarget } from '../../utils/python-app-target.js';
15
16
 
16
17
  /** @type {Set<string>} */
17
18
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
@@ -54,6 +55,14 @@ export function createSshProvider(config, envName, env = process.env) {
54
55
 
55
56
  const log = createLogger('ssh');
56
57
 
58
+ // Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
59
+ // Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms). Backend start/stop uses a shorter bound.
60
+ const defaultExecTimeoutMs = Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) || 120_000;
61
+ const startStopTimeoutMs = Math.min(
62
+ defaultExecTimeoutMs,
63
+ Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS) || 60_000
64
+ );
65
+
57
66
  async function connect() {
58
67
  if (!host || !user) {
59
68
  throw new Error(
@@ -94,10 +103,33 @@ export function createSshProvider(config, envName, env = process.env) {
94
103
  /**
95
104
  * @param {import('node-ssh').NodeSSH} ssh
96
105
  * @param {string} command
106
+ * @param {{ timeoutMs?: number }} [opts]
97
107
  */
98
- async function exec(ssh, command) {
108
+ async function exec(ssh, command, opts = {}) {
109
+ const timeoutMs = opts.timeoutMs ?? defaultExecTimeoutMs;
99
110
  log.info(`$ ${command}`);
100
- const result = await ssh.execCommand(command);
111
+
112
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
113
+ let timer;
114
+ const timeoutPromise = new Promise((_, reject) => {
115
+ timer = setTimeout(() => {
116
+ reject(
117
+ new Error(
118
+ `SSH command timed out after ${timeoutMs}ms on ${user}@${host}. ` +
119
+ `The remote command may still be running — check the server. ` +
120
+ `Command: ${command.length > 240 ? `${command.slice(0, 240)}…` : command}`
121
+ )
122
+ );
123
+ }, timeoutMs);
124
+ });
125
+
126
+ let result;
127
+ try {
128
+ result = await Promise.race([ssh.execCommand(command), timeoutPromise]);
129
+ } finally {
130
+ if (timer) clearTimeout(timer);
131
+ }
132
+
101
133
  if (result.code !== 0 && result.code !== null) {
102
134
  const message = formatRemoteCommandFailure(
103
135
  command,
@@ -111,6 +143,47 @@ export function createSshProvider(config, envName, env = process.env) {
111
143
  return result;
112
144
  }
113
145
 
146
+ /**
147
+ * Exact marker match in a null-delimited /proc file (cmdline or environ).
148
+ * Uses grep -xF so DEPLOYHUB_APP=myapi does not match DEPLOYHUB_APP=myapi-staging.
149
+ *
150
+ * @param {string} procFileExpr — e.g. `/proc/$pid/environ` or `$proc/cmdline`
151
+ * @param {string} marker — already shell-quoted
152
+ */
153
+ function procHasExactMarker(procFileExpr, marker) {
154
+ return (
155
+ `tr '\\0' '\\n' < ${procFileExpr} 2>/dev/null | grep -qxF ${marker}`
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Kill every process whose environ or cmdline contains our exact env/JVM marker.
161
+ * Replaces `pkill -f DEPLOYHUB_APP=…` which only searches cmdline — and
162
+ * `VAR=value nohup cmd` puts the marker in environ only, so orphans from
163
+ * interrupted deploys (no pidfile) were never found.
164
+ *
165
+ * Safe against PID reuse: an unrelated process will not carry our marker.
166
+ *
167
+ * @param {string} markerEnvQ — shell-quoted `DEPLOYHUB_APP=…`
168
+ * @param {string} markerJvmQ — shell-quoted `deployhub.app=…`
169
+ * @param {string} markerJvmFlagQ — shell-quoted `-Ddeployhub.app=…`
170
+ */
171
+ function killByExactMarkersCmd(markerEnvQ, markerJvmQ, markerJvmFlagQ) {
172
+ return (
173
+ `for proc in /proc/[0-9]*; do ` +
174
+ `pid="\${proc##*/}"; ` +
175
+ `matched=0; ` +
176
+ `if [ -r "$proc/environ" ] && ${procHasExactMarker('"$proc/environ"', markerEnvQ)}; then matched=1; fi; ` +
177
+ `if [ "$matched" -eq 0 ] && [ -r "$proc/cmdline" ]; then ` +
178
+ `if ${procHasExactMarker('"$proc/cmdline"', markerEnvQ)} ` +
179
+ `|| ${procHasExactMarker('"$proc/cmdline"', markerJvmFlagQ)} ` +
180
+ `|| ${procHasExactMarker('"$proc/cmdline"', markerJvmQ)}; then matched=1; fi; ` +
181
+ `fi; ` +
182
+ `if [ "$matched" -eq 1 ]; then kill "$pid" 2>/dev/null || true; fi; ` +
183
+ `done`
184
+ );
185
+ }
186
+
114
187
  /**
115
188
  * @param {import('node-ssh').NodeSSH} ssh
116
189
  */
@@ -140,7 +213,9 @@ export function createSshProvider(config, envName, env = process.env) {
140
213
  * PID-file kill is gated: we only signal a PID if /proc shows our
141
214
  * DEPLOYHUB_APP / deployhub.app marker in cmdline or environ. A stale PID
142
215
  * reused by an unrelated process is left alone (file still removed).
143
- * Marker-based pkill remains the primary stop for live processes.
216
+ *
217
+ * Fallback scans /proc/[pid]/environ (and cmdline) for the exact marker -
218
+ * `pkill -f` cannot see env-only markers from `VAR=value cmd` starts.
144
219
  *
145
220
  * @param {import('node-ssh').NodeSSH} ssh
146
221
  * @param {string} targetPath
@@ -149,30 +224,75 @@ export function createSshProvider(config, envName, env = process.env) {
149
224
  const pidFile = `${targetPath}/.deployhub.pid`;
150
225
  const markerEnv = `DEPLOYHUB_APP=${appName}`;
151
226
  const markerJvm = `deployhub.app=${appName}`;
152
- // Verify-then-kill: only signal a PID if /proc shows our marker in cmdline
153
- // or environ. A stale PID reused by an unrelated process is left alone
154
- // (the pidfile is still removed so the next start writes a fresh one).
227
+ const markerEnvQ = sh(markerEnv);
228
+ const markerJvmQ = sh(markerJvm);
229
+ const markerJvmFlagQ = sh(`-D${markerJvm}`);
230
+
231
+ // Verify-then-kill: only signal a PID if /proc shows our exact marker.
232
+ // Exact (-xF) match so DEPLOYHUB_APP=myapi does not hit myapi-staging.
155
233
  await exec(
156
234
  ssh,
157
235
  `if [ -f ${sh(pidFile)} ]; then ` +
158
236
  `pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
159
- `if [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ]; then ` +
160
- `if tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerEnv)} ` +
161
- `|| tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerJvm)} ` +
162
- `|| { [ -r "/proc/$pid/environ" ] && tr '\\0' ' ' < "/proc/$pid/environ" 2>/dev/null | grep -q -F ${sh(markerEnv)}; }; then ` +
163
- `kill "$pid" 2>/dev/null || true; ` +
237
+ `if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then ` +
238
+ `matched=0; ` +
239
+ `if [ -r "/proc/$pid/environ" ] && ${procHasExactMarker(`"/proc/$pid/environ"`, markerEnvQ)}; then matched=1; fi; ` +
240
+ `if [ "$matched" -eq 0 ] && [ -r "/proc/$pid/cmdline" ]; then ` +
241
+ `if ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerEnvQ)} ` +
242
+ `|| ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerJvmFlagQ)} ` +
243
+ `|| ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerJvmQ)}; then matched=1; fi; ` +
164
244
  `fi; ` +
245
+ `if [ "$matched" -eq 1 ]; then kill "$pid" 2>/dev/null || true; fi; ` +
165
246
  `fi; ` +
166
247
  `rm -f ${sh(pidFile)}; ` +
167
- `fi`
248
+ `fi`,
249
+ { timeoutMs: startStopTimeoutMs }
168
250
  );
169
- // Marker match — covers lost PID files / processes without a readable pidfile.
170
- await exec(ssh, `pkill -f ${sh(markerEnv)} || true`);
171
- await exec(ssh, `pkill -f ${sh(markerJvm)} || true`);
251
+
252
+ // Orphan fallback: no/stale pidfile find by exact marker in environ or cmdline.
253
+ await exec(ssh, killByExactMarkersCmd(markerEnvQ, markerJvmQ, markerJvmFlagQ), {
254
+ timeoutMs: startStopTimeoutMs,
255
+ });
256
+ }
257
+
258
+ /**
259
+ * After starting a backend, wait briefly and confirm the PID file's process
260
+ * is still alive. Not a health check — only catches immediate crash.
261
+ * `port` is closed over from createSshProvider (settings.port / config.port).
262
+ *
263
+ * @param {import('node-ssh').NodeSSH} ssh
264
+ * @param {string} targetPath
265
+ * @param {string} [logFile] — defaults to targetPath/app.log
266
+ */
267
+ async function assertPidAliveAfterStart(ssh, targetPath, logFile) {
268
+ const pidFile = `${targetPath}/.deployhub.pid`;
269
+ const log = logFile || `${targetPath}/app.log`;
270
+ const verifyCmd =
271
+ `sleep 2; ` +
272
+ `pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
273
+ `if [ -z "$pid" ] || [ ! -d "/proc/$pid" ]; then ` +
274
+ `echo "DEPLOYHUB_PROCESS_DIED: process exited immediately after start (pidfile=${sh(pidFile)}). Last lines of ${sh(log)}:"; ` +
275
+ `tail -n 40 ${sh(log)} 2>/dev/null || echo "(no app.log)"; ` +
276
+ `exit 1; ` +
277
+ `fi`;
278
+
279
+ try {
280
+ await exec(ssh, verifyCmd, { timeoutMs: startStopTimeoutMs });
281
+ } catch (err) {
282
+ const detail = err instanceof Error ? err.message : String(err);
283
+ throw new Error(
284
+ `Backend process for "${appName}" died immediately after start at ${targetPath}. ` +
285
+ `Check dependencies, entrypoint, and port ${port}.\n${detail}`
286
+ );
287
+ }
172
288
  }
173
289
 
174
290
  /**
175
291
  * Start a nohup process with DEPLOYHUB_APP marker and write PID file.
292
+ * Marker is set in environ AND embedded as argv0 via `bash exec -a` so it
293
+ * appears in /proc/cmdline (pkill -f / cmdline scans can see it). Plain
294
+ * `VAR=value cmd` alone only puts the marker in environ.
295
+ *
176
296
  * @param {import('node-ssh').NodeSSH} ssh
177
297
  * @param {string} targetPath
178
298
  * @param {string} command — command body after `nohup` (no trailing &)
@@ -180,10 +300,15 @@ export function createSshProvider(config, envName, env = process.env) {
180
300
  async function startScopedNohup(ssh, targetPath, command) {
181
301
  const pidFile = `${targetPath}/.deployhub.pid`;
182
302
  const dir = sh(targetPath);
303
+ const markerArg = sh(`DEPLOYHUB_APP=${appName}`);
304
+ // stdin from /dev/null + redirects: avoid SSH waiting on leftover FDs.
305
+ // bash exec -a puts DEPLOYHUB_APP=… in argv0 of the real process after exec.
183
306
  await exec(
184
307
  ssh,
185
- `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup ${command} > app.log 2>&1 & echo $! > ${sh(pidFile)}`
308
+ `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup bash -c 'exec -a "$0" "$@"' ${markerArg} ${command} > app.log 2>&1 </dev/null & echo $! > ${sh(pidFile)}`,
309
+ { timeoutMs: startStopTimeoutMs }
186
310
  );
311
+ await assertPidAliveAfterStart(ssh, targetPath);
187
312
  }
188
313
 
189
314
  /**
@@ -234,13 +359,22 @@ export function createSshProvider(config, envName, env = process.env) {
234
359
  `uvicorn main:app --host 0.0.0.0 --port ${port}`
235
360
  );
236
361
  } else {
237
- // gunicorn --daemon writes its own PID; still set DEPLOYHUB_APP for pkill fallback.
238
- const appTarget =
362
+ // gunicorn --daemon writes the master PID to --pid (same .deployhub.pid).
363
+ // --error-logfile + --capture-output give us a log to surface on immediate death
364
+ // (daemonized stdout/stderr otherwise vanish).
365
+ const logFile = `${targetPath}/app.log`;
366
+ const fallbackTarget =
239
367
  framework === 'django' ? 'config.wsgi:application' : 'app:app';
368
+ const appTarget =
369
+ extractGunicornTarget(startCommand) || fallbackTarget;
240
370
  await exec(
241
371
  ssh,
242
- `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} --name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} --pid ${sh(pidFile)} --daemon`
372
+ `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} ` +
373
+ `--name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} ` +
374
+ `--pid ${sh(pidFile)} --error-logfile ${sh(logFile)} --capture-output --daemon`,
375
+ { timeoutMs: startStopTimeoutMs }
243
376
  );
377
+ await assertPidAliveAfterStart(ssh, targetPath, logFile);
244
378
  }
245
379
  return;
246
380
  }
@@ -261,7 +395,7 @@ export function createSshProvider(config, envName, env = process.env) {
261
395
  if (framework === 'spring' || framework === 'java') {
262
396
  await stopScopedBackendProcess(ssh, targetPath);
263
397
  // -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
264
- // so pkill -f DEPLOYHUB_APP=… and the PID file both target only this env.
398
+ // so cmdline marker scans and the PID file both target only this env.
265
399
  await startScopedNohup(
266
400
  ssh,
267
401
  targetPath,
@@ -1,5 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import {
4
+ detectDjangoWsgiTarget,
5
+ detectFlaskAppTarget,
6
+ } from '../utils/python-app-target.js';
3
7
 
4
8
  /**
5
9
  * @typedef {Object} BackendDetectorResult
@@ -76,6 +80,7 @@ const FRAMEWORKS = {
76
80
  defaults: {
77
81
  language: 'python',
78
82
  buildCommand: null,
83
+ // Fallback only — getBackendInfo overrides via detectDjangoWsgiTarget(cwd)
79
84
  startCommand: 'gunicorn config.wsgi:application --bind 0.0.0.0:8000',
80
85
  buildOutput: '.',
81
86
  testCommand: 'python manage.py test',
@@ -87,6 +92,7 @@ const FRAMEWORKS = {
87
92
  defaults: {
88
93
  language: 'python',
89
94
  buildCommand: null,
95
+ // Fallback only — getBackendInfo overrides via detectFlaskAppTarget(cwd)
90
96
  startCommand: 'gunicorn app:app --bind 0.0.0.0:5000',
91
97
  buildOutput: '.',
92
98
  testCommand: 'pytest',
@@ -261,6 +267,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
261
267
  let buildCommand = def.defaults.buildCommand;
262
268
  let startCommand = def.defaults.startCommand;
263
269
  let testCommand = def.defaults.testCommand;
270
+ const port = def.defaults.port;
264
271
 
265
272
  if (def.defaults.language === 'node') {
266
273
  if (scripts.build) buildCommand = 'npm run build';
@@ -268,6 +275,14 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
268
275
  if (scripts.test) testCommand = 'npm test';
269
276
  }
270
277
 
278
+ if (framework === 'django') {
279
+ const target = detectDjangoWsgiTarget(cwd);
280
+ startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
281
+ } else if (framework === 'flask') {
282
+ const target = detectFlaskAppTarget(cwd);
283
+ startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
284
+ }
285
+
271
286
  return {
272
287
  projectType: 'backend',
273
288
  framework,
@@ -277,7 +292,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
277
292
  buildOutput: def.defaults.buildOutput,
278
293
  testCommand,
279
294
  hasDocker,
280
- port: def.defaults.port,
295
+ port,
281
296
  };
282
297
  }
283
298
 
@@ -6,10 +6,11 @@ function detect(cwd = process.cwd()) {
6
6
  }
7
7
 
8
8
  function getInfo(cwd = process.cwd()) {
9
+ // Composer install belongs in the install stage, not a compile/build step.
9
10
  return {
10
11
  framework: 'php',
11
- buildCommand: 'composer install --no-dev --optimize-autoloader',
12
- buildOutput: 'public',
12
+ buildCommand: null,
13
+ buildOutput: '.',
13
14
  hasDocker: fs.existsSync(path.join(cwd, 'Dockerfile')),
14
15
  };
15
16
  }
@@ -11,10 +11,11 @@ function detect(cwd = process.cwd()) {
11
11
 
12
12
  function getInfo(cwd = process.cwd()) {
13
13
  const hasDocker = fs.existsSync(path.join(cwd, 'Dockerfile'));
14
+ // Deps install belongs in the install stage (pip), not a compile/build step.
14
15
  return {
15
16
  framework: 'python',
16
- buildCommand: 'pip install -r requirements.txt',
17
- buildOutput: 'dist',
17
+ buildCommand: null,
18
+ buildOutput: '.',
18
19
  hasDocker,
19
20
  };
20
21
  }
@@ -319,9 +319,17 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
319
319
 
320
320
  let reused = false;
321
321
  if (!options.skipImageReuse) {
322
+ // Normal deploy: prefer pipeline image (exact tag, then :latest retag).
322
323
  reused = await ensureImageFromPipeline(imageRef);
324
+ } else if (await imageExistsLocally(imageRef)) {
325
+ // Rollback: never retag :latest onto an older buildId, but DO use the
326
+ // exact restored buildId image if it is already present locally.
327
+ log.info(`Using restored image ${imageRef} (skipImageReuse — no :latest retag)`);
328
+ reused = true;
323
329
  } else {
324
- log.info(`Skipping local image reuse — rebuilding ${imageRef} from artifact`);
330
+ log.info(
331
+ `Target image ${imageRef} not found locally — attempting rebuild from artifact`
332
+ );
325
333
  }
326
334
 
327
335
  let ranCompose = false;