@wyxos/zephyr 0.2.16 → 0.2.18

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.
package/README.md CHANGED
@@ -30,6 +30,12 @@ Follow the interactive prompts to configure your deployment target:
30
30
 
31
31
  Configuration is saved automatically for future deployments.
32
32
 
33
+ ## Update Checks
34
+
35
+ When run via `npx`, Zephyr can prompt to re-run itself using the latest published version.
36
+
37
+ - **Skip update check**: set `ZEPHYR_SKIP_VERSION_CHECK=1`
38
+
33
39
  ## Features
34
40
 
35
41
  - Automated Git operations (branch switching, commits, pushes)
@@ -49,11 +55,12 @@ Configuration is saved automatically for future deployments.
49
55
  Zephyr analyzes changed files and runs appropriate tasks:
50
56
 
51
57
  - **Always**: `git pull origin <branch>`
52
- - **Composer files changed**: `composer update --no-dev --no-interaction --prefer-dist`
53
- - **Migration files added**: `php artisan migrate --force`
54
- - **package.json changed**: `npm install`
55
- - **Frontend files changed**: `npm run build`
56
- - **PHP files changed**: Clear Laravel caches, restart queues
58
+ - **Composer files changed** (`composer.json` / `composer.lock`): `composer update --no-dev --no-interaction --prefer-dist`
59
+ - **Migrations changed** (`database/migrations/*.php`): `php artisan migrate --force`
60
+ - **Node dependency files changed** (`package.json` / `package-lock.json`, including nested): `npm install`
61
+ - **Frontend files changed** (`.vue/.js/.ts/.tsx/.css/.scss/.less`): `npm run build`
62
+ - Note: `npm run build` is also scheduled when `npm install` is scheduled.
63
+ - **PHP files changed**: clear caches + restart queue workers (Horizon if configured)
57
64
 
58
65
  ## Configuration
59
66
 
@@ -64,6 +71,7 @@ Servers are stored globally at `~/.config/zephyr/servers.json`:
64
71
  ```json
65
72
  [
66
73
  {
74
+ "id": "server_abc123",
67
75
  "serverName": "production",
68
76
  "serverIp": "192.168.1.100"
69
77
  }
@@ -76,8 +84,17 @@ Deployment targets are stored per-project at `.zephyr/config.json`:
76
84
 
77
85
  ```json
78
86
  {
87
+ "presets": [
88
+ {
89
+ "name": "prod-main",
90
+ "appId": "app_def456",
91
+ "branch": "main"
92
+ }
93
+ ],
79
94
  "apps": [
80
95
  {
96
+ "id": "app_def456",
97
+ "serverId": "server_abc123",
81
98
  "serverName": "production",
82
99
  "projectPath": "~/webapps/myapp",
83
100
  "branch": "main",
@@ -98,6 +115,11 @@ Zephyr creates a `.zephyr/` directory in your project with:
98
115
 
99
116
  The `.zephyr/` directory is automatically added to `.gitignore`.
100
117
 
118
+ ## Notes
119
+
120
+ - If Zephyr reports **"No upstream file changes detected"**, it means the remote repository already matches `origin/<branch>` after `git fetch`. In that case, Zephyr will only run `git pull` and skip all conditional maintenance tasks.
121
+ - If Zephyr prompts to update local file dependencies (path-based deps outside the repo), it may also prompt to commit those updates before continuing.
122
+
101
123
  ## Requirements
102
124
 
103
125
  - Node.js 16+
package/bin/zephyr.mjs CHANGED
@@ -1,24 +1,15 @@
1
- #!/usr/bin/env node
2
- import { main } from '../src/index.mjs'
3
- import { checkAndUpdateVersion } from '../src/version-checker.mjs'
4
- import inquirer from 'inquirer'
1
+ #!/usr/bin/env node
2
+ import process from 'node:process'
3
+ import { logError, main } from '../src/index.mjs'
5
4
 
6
5
  // Parse --type flag from command line arguments
7
6
  const args = process.argv.slice(2)
8
7
  const typeFlag = args.find(arg => arg.startsWith('--type='))
9
8
  const releaseType = typeFlag ? typeFlag.split('=')[1] : null
10
9
 
11
- // Check for updates and re-execute if user confirms
12
- checkAndUpdateVersion((questions) => inquirer.prompt(questions), args)
13
- .then((reExecuted) => {
14
- if (reExecuted) {
15
- // Version was updated and script re-executed, exit this process
16
- process.exit(0)
17
- }
18
- // No update or user declined, continue with normal execution
19
- return main(releaseType)
20
- })
21
- .catch((error) => {
22
- console.error(error.message)
23
- process.exit(1)
24
- })
10
+ try {
11
+ await main(releaseType)
12
+ } catch (error) {
13
+ logError(error?.message || String(error))
14
+ process.exitCode = 1
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "test": "vitest run",
16
+ "lint": "eslint .",
16
17
  "release": "node bin/zephyr.mjs --type=node"
17
18
  },
18
19
  "keywords": [
@@ -48,6 +49,9 @@
48
49
  "semver": "^7.6.3"
49
50
  },
50
51
  "devDependencies": {
52
+ "@eslint/js": "^9.39.2",
53
+ "eslint": "^9.39.2",
54
+ "globals": "^17.0.0",
51
55
  "vitest": "^2.1.8"
52
56
  }
53
57
  }
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
4
  import process from 'node:process'
5
+ import chalk from 'chalk'
5
6
 
6
7
  const IS_WINDOWS = process.platform === 'win32'
7
8
 
@@ -147,7 +148,7 @@ async function fetchLatestNpmVersion(packageName) {
147
148
  }
148
149
  const data = await response.json()
149
150
  return data.version || null
150
- } catch (error) {
151
+ } catch (_error) {
151
152
  return null
152
153
  }
153
154
  }
@@ -166,7 +167,7 @@ async function fetchLatestPackagistVersion(packageName) {
166
167
  return latest.version || null
167
168
  }
168
169
  return null
169
- } catch (error) {
170
+ } catch (_error) {
170
171
  return null
171
172
  }
172
173
  }
@@ -218,16 +219,16 @@ async function updateComposerJsonDependency(rootDir, packageName, newVersion, fi
218
219
 
219
220
  async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
220
221
  return new Promise((resolve, reject) => {
222
+ const resolvedCommand = IS_WINDOWS && (command === 'npm' || command === 'npx' || command === 'pnpm' || command === 'yarn')
223
+ ? `${command}.cmd`
224
+ : command
225
+
221
226
  const spawnOptions = {
222
227
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
223
228
  cwd
224
229
  }
225
230
 
226
- if (IS_WINDOWS && command !== 'git') {
227
- spawnOptions.shell = true
228
- }
229
-
230
- const child = spawn(command, args, spawnOptions)
231
+ const child = spawn(resolvedCommand, args, spawnOptions)
231
232
  let stdout = ''
232
233
  let stderr = ''
233
234
 
@@ -246,7 +247,7 @@ async function runCommand(command, args, { cwd = process.cwd(), capture = false
246
247
  if (code === 0) {
247
248
  resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
248
249
  } else {
249
- const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
250
+ const error = new Error(`Command failed (${code}): ${resolvedCommand} ${args.join(' ')}`)
250
251
  if (capture) {
251
252
  error.stdout = stdout
252
253
  error.stderr = stderr
@@ -262,7 +263,7 @@ async function getGitStatus(rootDir) {
262
263
  try {
263
264
  const result = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
264
265
  return result.stdout || ''
265
- } catch (error) {
266
+ } catch (_error) {
266
267
  return ''
267
268
  }
268
269
  }
@@ -280,7 +281,7 @@ function hasStagedChanges(statusOutput) {
280
281
  })
281
282
  }
282
283
 
283
- async function commitDependencyUpdates(rootDir, updatedFiles, logFn) {
284
+ async function commitDependencyUpdates(rootDir, updatedFiles, promptFn, logFn) {
284
285
  try {
285
286
  // Check if we're in a git repository
286
287
  await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { capture: true, cwd: rootDir })
@@ -289,7 +290,30 @@ async function commitDependencyUpdates(rootDir, updatedFiles, logFn) {
289
290
  return false
290
291
  }
291
292
 
292
- const status = await getGitStatus(rootDir)
293
+ const statusBefore = await getGitStatus(rootDir)
294
+
295
+ // Avoid accidentally committing unrelated staged changes
296
+ if (hasStagedChanges(statusBefore)) {
297
+ if (logFn) {
298
+ logFn('Staged changes detected. Skipping auto-commit of dependency updates.')
299
+ }
300
+ return false
301
+ }
302
+
303
+ const fileList = updatedFiles.map((f) => path.basename(f)).join(', ')
304
+
305
+ const { shouldCommit } = await promptFn([
306
+ {
307
+ type: 'confirm',
308
+ name: 'shouldCommit',
309
+ message: `Commit dependency updates now? (${fileList})`,
310
+ default: true
311
+ }
312
+ ])
313
+
314
+ if (!shouldCommit) {
315
+ return false
316
+ }
293
317
 
294
318
  // Stage the updated files
295
319
  for (const file of updatedFiles) {
@@ -306,7 +330,6 @@ async function commitDependencyUpdates(rootDir, updatedFiles, logFn) {
306
330
  }
307
331
 
308
332
  // Build commit message
309
- const fileList = updatedFiles.map(f => path.basename(f)).join(', ')
310
333
  const commitMessage = `chore: update local file dependencies to online versions (${fileList})`
311
334
 
312
335
  if (logFn) {
@@ -353,20 +376,27 @@ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
353
376
  })
354
377
  )
355
378
 
356
- // Build warning messages
379
+ // Build warning messages with colored output (danger color for package name and version)
357
380
  const messages = depsWithVersions.map((dep) => {
381
+ const packageNameColored = chalk.red(dep.packageName)
382
+ const pathColored = chalk.dim(dep.path)
358
383
  const versionInfo = dep.latestVersion
359
- ? ` Latest version available: ${dep.latestVersion}.`
384
+ ? ` Latest version available: ${chalk.red(dep.latestVersion)}.`
360
385
  : ' Latest version could not be determined.'
361
- return `Dependency '${dep.packageName}' is pointing to a local path outside the repository: ${dep.path}.${versionInfo}`
386
+ return `Dependency ${packageNameColored} is pointing to a local path outside the repository: ${pathColored}.${versionInfo}`
362
387
  })
363
388
 
389
+ // Build the prompt message with colored count (danger color)
390
+ const countColored = chalk.red(allDeps.length)
391
+ const countText = allDeps.length === 1 ? 'dependency' : 'dependencies'
392
+ const promptMessage = `Found ${countColored} local file ${countText} pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`
393
+
364
394
  // Prompt user
365
395
  const { shouldUpdate } = await promptFn([
366
396
  {
367
397
  type: 'confirm',
368
398
  name: 'shouldUpdate',
369
- message: `Found ${allDeps.length} local file dependency/dependencies pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`,
399
+ message: promptMessage,
370
400
  default: true
371
401
  }
372
402
  ])
@@ -441,7 +471,7 @@ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
441
471
 
442
472
  // Commit the changes if any files were updated
443
473
  if (updatedFiles.size > 0) {
444
- await commitDependencyUpdates(rootDir, Array.from(updatedFiles), logFn)
474
+ await commitDependencyUpdates(rootDir, Array.from(updatedFiles), promptFn, logFn)
445
475
  }
446
476
  }
447
477
 
package/src/index.mjs CHANGED
@@ -10,6 +10,7 @@ import { NodeSSH } from 'node-ssh'
10
10
  import { releaseNode } from './release-node.mjs'
11
11
  import { releasePackagist } from './release-packagist.mjs'
12
12
  import { validateLocalDependencies } from './dependency-scanner.mjs'
13
+ import { checkAndUpdateVersion } from './version-checker.mjs'
13
14
 
14
15
  const IS_WINDOWS = process.platform === 'win32'
15
16
 
@@ -22,10 +23,20 @@ const PENDING_TASKS_FILE = 'pending-tasks.json'
22
23
  const RELEASE_SCRIPT_NAME = 'release'
23
24
  const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
24
25
 
25
- const logProcessing = (message = '') => console.log(chalk.yellow(message))
26
- const logSuccess = (message = '') => console.log(chalk.green(message))
27
- const logWarning = (message = '') => console.warn(chalk.yellow(message))
28
- const logError = (message = '') => console.error(chalk.red(message))
26
+ function writeStdoutLine(message = '') {
27
+ const text = message == null ? '' : String(message)
28
+ process.stdout.write(`${text}\n`)
29
+ }
30
+
31
+ function writeStderrLine(message = '') {
32
+ const text = message == null ? '' : String(message)
33
+ process.stderr.write(`${text}\n`)
34
+ }
35
+
36
+ const logProcessing = (message = '') => writeStdoutLine(chalk.yellow(message))
37
+ const logSuccess = (message = '') => writeStdoutLine(chalk.green(message))
38
+ const logWarning = (message = '') => writeStderrLine(chalk.yellow(message))
39
+ const logError = (message = '') => writeStderrLine(chalk.red(message))
29
40
 
30
41
  let logFilePath = null
31
42
 
@@ -89,7 +100,7 @@ async function cleanupOldLogs(rootDir) {
89
100
  for (const file of filesToDelete) {
90
101
  try {
91
102
  await fs.unlink(file.path)
92
- } catch (error) {
103
+ } catch (_error) {
93
104
  // Ignore errors when deleting old logs
94
105
  }
95
106
  }
@@ -119,25 +130,23 @@ const runPrompt = async (questions) => {
119
130
 
120
131
  async function runCommand(command, args, { silent = false, cwd } = {}) {
121
132
  return new Promise((resolve, reject) => {
133
+ const resolvedCommand = IS_WINDOWS && (command === 'npm' || command === 'npx' || command === 'pnpm' || command === 'yarn')
134
+ ? `${command}.cmd`
135
+ : command
136
+
122
137
  const spawnOptions = {
123
138
  stdio: silent ? 'ignore' : 'inherit',
124
139
  cwd
125
140
  }
126
141
 
127
- // On Windows, use shell for commands that might need PATH resolution (php, composer, etc.)
128
- // Git commands work fine without shell
129
- if (IS_WINDOWS && command !== 'git') {
130
- spawnOptions.shell = true
131
- }
132
-
133
- const child = spawn(command, args, spawnOptions)
142
+ const child = spawn(resolvedCommand, args, spawnOptions)
134
143
 
135
144
  child.on('error', reject)
136
145
  child.on('close', (code) => {
137
146
  if (code === 0) {
138
147
  resolve()
139
148
  } else {
140
- const error = new Error(`${command} exited with code ${code}`)
149
+ const error = new Error(`${resolvedCommand} exited with code ${code}`)
141
150
  error.exitCode = code
142
151
  reject(error)
143
152
  }
@@ -147,6 +156,10 @@ async function runCommand(command, args, { silent = false, cwd } = {}) {
147
156
 
148
157
  async function runCommandCapture(command, args, { cwd } = {}) {
149
158
  return new Promise((resolve, reject) => {
159
+ const resolvedCommand = IS_WINDOWS && (command === 'npm' || command === 'npx' || command === 'pnpm' || command === 'yarn')
160
+ ? `${command}.cmd`
161
+ : command
162
+
150
163
  let stdout = ''
151
164
  let stderr = ''
152
165
 
@@ -155,13 +168,7 @@ async function runCommandCapture(command, args, { cwd } = {}) {
155
168
  cwd
156
169
  }
157
170
 
158
- // On Windows, use shell for commands that might need PATH resolution (php, composer, etc.)
159
- // Git commands work fine without shell
160
- if (IS_WINDOWS && command !== 'git') {
161
- spawnOptions.shell = true
162
- }
163
-
164
- const child = spawn(command, args, spawnOptions)
171
+ const child = spawn(resolvedCommand, args, spawnOptions)
165
172
 
166
173
  child.stdout.on('data', (chunk) => {
167
174
  stdout += chunk
@@ -176,7 +183,7 @@ async function runCommandCapture(command, args, { cwd } = {}) {
176
183
  if (code === 0) {
177
184
  resolve(stdout)
178
185
  } else {
179
- const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
186
+ const error = new Error(`${resolvedCommand} exited with code ${code}: ${stderr.trim()}`)
180
187
  error.exitCode = code
181
188
  reject(error)
182
189
  }
@@ -296,7 +303,9 @@ async function ensureCommittedChangesPushed(targetBranch, rootDir) {
296
303
  const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
297
304
  logProcessing(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
298
305
 
299
- await runCommand('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
306
+ // Keep terminal output clean: suppress git push progress output (it is very noisy),
307
+ // but still surface errors via the thrown exception message.
308
+ await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
300
309
  logSuccess(`Pushed committed changes to ${upstreamRef}.`)
301
310
 
302
311
  return { pushed: true, upstreamRef }
@@ -414,7 +423,7 @@ async function ensureProjectReleaseScript(rootDir) {
414
423
  let packageJson
415
424
  try {
416
425
  packageJson = JSON.parse(raw)
417
- } catch (error) {
426
+ } catch (_error) {
418
427
  logWarning('Unable to parse package.json; skipping release script injection.')
419
428
  return false
420
429
  }
@@ -453,7 +462,7 @@ async function ensureProjectReleaseScript(rootDir) {
453
462
  try {
454
463
  await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
455
464
  isGitRepo = true
456
- } catch (error) {
465
+ } catch (_error) {
457
466
  logWarning('Not a git repository; skipping commit for release script addition.')
458
467
  }
459
468
 
@@ -541,7 +550,7 @@ async function readRemoteLock(ssh, remoteCwd) {
541
550
  if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
542
551
  try {
543
552
  return JSON.parse(checkResult.stdout.trim())
544
- } catch (error) {
553
+ } catch (_error) {
545
554
  return { raw: checkResult.stdout.trim() }
546
555
  }
547
556
  }
@@ -605,7 +614,7 @@ async function acquireRemoteLock(ssh, remoteCwd, rootDir) {
605
614
  let details = {}
606
615
  try {
607
616
  details = JSON.parse(checkResult.stdout.trim())
608
- } catch (error) {
617
+ } catch (_error) {
609
618
  details = { raw: checkResult.stdout.trim() }
610
619
  }
611
620
 
@@ -620,7 +629,7 @@ async function acquireRemoteLock(ssh, remoteCwd, rootDir) {
620
629
  let details = {}
621
630
  try {
622
631
  details = JSON.parse(checkResult.stdout.trim())
623
- } catch (error) {
632
+ } catch (_error) {
624
633
  details = { raw: checkResult.stdout.trim() }
625
634
  }
626
635
 
@@ -727,7 +736,7 @@ async function ensureGitignoreEntry(rootDir) {
727
736
  cwd: rootDir
728
737
  })
729
738
  isGitRepo = true
730
- } catch (error) {
739
+ } catch (_error) {
731
740
  logWarning('Not a git repository; skipping commit for .gitignore update.')
732
741
  }
733
742
 
@@ -949,7 +958,7 @@ async function listGitBranches(currentDir) {
949
958
  .filter(Boolean)
950
959
 
951
960
  return branches.length ? branches : ['master']
952
- } catch (error) {
961
+ } catch (_error) {
953
962
  logWarning('Unable to read git branches; defaulting to master.')
954
963
  return ['master']
955
964
  }
@@ -1002,7 +1011,7 @@ async function isPrivateKeyFile(filePath) {
1002
1011
  try {
1003
1012
  const content = await fs.readFile(filePath, 'utf8')
1004
1013
  return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
1005
- } catch (error) {
1014
+ } catch (_error) {
1006
1015
  return false
1007
1016
  }
1008
1017
  }
@@ -1091,7 +1100,7 @@ async function resolveSshKeyPath(targetPath) {
1091
1100
 
1092
1101
  try {
1093
1102
  await fs.access(expanded)
1094
- } catch (error) {
1103
+ } catch (_error) {
1095
1104
  throw new Error(`SSH key not accessible at ${expanded}`)
1096
1105
  }
1097
1106
 
@@ -1247,14 +1256,14 @@ async function runRemoteTasks(config, options = {}) {
1247
1256
  }
1248
1257
 
1249
1258
  // Run tests for Laravel projects
1250
- if (isLaravel) {
1251
- logProcessing('Running Laravel tests locally...')
1252
- try {
1253
- await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
1254
- logSuccess('Local tests passed.')
1255
- } catch (error) {
1256
- throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
1257
- }
1259
+ if (isLaravel) {
1260
+ logProcessing('Running Laravel tests locally...')
1261
+ try {
1262
+ await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
1263
+ logSuccess('Local tests passed.')
1264
+ } catch (error) {
1265
+ throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
1266
+ }
1258
1267
  }
1259
1268
  } else {
1260
1269
  logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
@@ -1314,7 +1323,7 @@ async function runRemoteTasks(config, options = {}) {
1314
1323
  const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
1315
1324
 
1316
1325
  const executeRemote = async (label, command, options = {}) => {
1317
- const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
1326
+ const { cwd = remoteCwd, allowFailure = false, bootstrapEnv = true } = options
1318
1327
  logProcessing(`\n→ ${label}`)
1319
1328
 
1320
1329
  let wrappedCommand = command
@@ -1583,7 +1592,7 @@ async function runRemoteTasks(config, options = {}) {
1583
1592
  const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1584
1593
  const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1585
1594
  await compareLocksAndPrompt(rootDir, ssh, remoteCwd)
1586
- } catch (lockError) {
1595
+ } catch (_lockError) {
1587
1596
  // Ignore lock comparison errors during error handling
1588
1597
  }
1589
1598
  }
@@ -1739,9 +1748,9 @@ async function selectApp(projectConfig, server, currentDir) {
1739
1748
  if (apps.length > 0) {
1740
1749
  const availableServers = [...new Set(apps.map((app) => app.serverName).filter(Boolean))]
1741
1750
  if (availableServers.length > 0) {
1742
- logWarning(
1743
- `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
1744
- )
1751
+ logWarning(
1752
+ `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
1753
+ )
1745
1754
  }
1746
1755
  }
1747
1756
  logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
@@ -1758,7 +1767,7 @@ async function selectApp(projectConfig, server, currentDir) {
1758
1767
  return appConfig
1759
1768
  }
1760
1769
 
1761
- const choices = matches.map(({ app, index }, matchIndex) => ({
1770
+ const choices = matches.map(({ app }, matchIndex) => ({
1762
1771
  name: `${app.projectPath} (${app.branch})`,
1763
1772
  value: matchIndex
1764
1773
  }))
@@ -1796,23 +1805,6 @@ async function selectApp(projectConfig, server, currentDir) {
1796
1805
  return chosen
1797
1806
  }
1798
1807
 
1799
- async function promptPresetName() {
1800
- const { presetName } = await runPrompt([
1801
- {
1802
- type: 'input',
1803
- name: 'presetName',
1804
- message: 'Enter a name for this preset',
1805
- validate: (value) => (value && value.trim().length > 0 ? true : 'Preset name cannot be empty.')
1806
- }
1807
- ])
1808
-
1809
- return presetName.trim()
1810
- }
1811
-
1812
- function generatePresetKey(serverName, projectPath) {
1813
- return `${serverName}:${projectPath}`
1814
- }
1815
-
1816
1808
  async function selectPreset(projectConfig, servers) {
1817
1809
  const presets = projectConfig.presets ?? []
1818
1810
  const apps = projectConfig.apps ?? []
@@ -1844,7 +1836,7 @@ async function selectPreset(projectConfig, servers) {
1844
1836
 
1845
1837
  return {
1846
1838
  name: displayName,
1847
- value: index
1839
+ value: index
1848
1840
  }
1849
1841
  })
1850
1842
 
@@ -1871,6 +1863,24 @@ async function selectPreset(projectConfig, servers) {
1871
1863
  }
1872
1864
 
1873
1865
  async function main(releaseType = null) {
1866
+ // Best-effort update check (skip during tests or when explicitly disabled)
1867
+ // If an update is accepted, the process will re-execute via npx @latest and we should exit early.
1868
+ if (
1869
+ process.env.ZEPHYR_SKIP_VERSION_CHECK !== '1' &&
1870
+ process.env.NODE_ENV !== 'test' &&
1871
+ process.env.VITEST !== 'true'
1872
+ ) {
1873
+ try {
1874
+ const args = process.argv.slice(2)
1875
+ const reExecuted = await checkAndUpdateVersion(runPrompt, args)
1876
+ if (reExecuted) {
1877
+ return
1878
+ }
1879
+ } catch (_error) {
1880
+ // Never block execution due to update check issues
1881
+ }
1882
+ }
1883
+
1874
1884
  // Handle node/vue package release
1875
1885
  if (releaseType === 'node' || releaseType === 'vue') {
1876
1886
  try {
@@ -1880,7 +1890,7 @@ async function main(releaseType = null) {
1880
1890
  logError('\nRelease failed:')
1881
1891
  logError(error.message)
1882
1892
  if (error.stack) {
1883
- console.error(error.stack)
1893
+ writeStderrLine(error.stack)
1884
1894
  }
1885
1895
  process.exit(1)
1886
1896
  }
@@ -1895,7 +1905,7 @@ async function main(releaseType = null) {
1895
1905
  logError('\nRelease failed:')
1896
1906
  logError(error.message)
1897
1907
  if (error.stack) {
1898
- console.error(error.stack)
1908
+ writeStderrLine(error.stack)
1899
1909
  }
1900
1910
  process.exit(1)
1901
1911
  }
@@ -2016,7 +2026,7 @@ async function main(releaseType = null) {
2016
2026
  }
2017
2027
 
2018
2028
  logProcessing('\nSelected deployment target:')
2019
- console.log(JSON.stringify(deploymentConfig, null, 2))
2029
+ writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
2020
2030
 
2021
2031
  if (isCreatingNewPreset || !preset) {
2022
2032
  const { presetName } = await runPrompt([
@@ -2041,11 +2051,11 @@ async function main(releaseType = null) {
2041
2051
  } else {
2042
2052
  // Check if preset with this appId already exists
2043
2053
  const existingIndex = presets.findIndex((p) => p.appId === appId)
2044
- if (existingIndex >= 0) {
2054
+ if (existingIndex >= 0) {
2045
2055
  presets[existingIndex].name = trimmedName
2046
2056
  presets[existingIndex].branch = deploymentConfig.branch
2047
- } else {
2048
- presets.push({
2057
+ } else {
2058
+ presets.push({
2049
2059
  name: trimmedName,
2050
2060
  appId: appId,
2051
2061
  branch: deploymentConfig.branch
@@ -1,6 +1,5 @@
1
1
  import { spawn, exec } from 'node:child_process'
2
- import { fileURLToPath } from 'node:url'
3
- import { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
4
3
  import { readFile } from 'node:fs/promises'
5
4
  import fs from 'node:fs'
6
5
  import path from 'node:path'
@@ -11,16 +10,34 @@ import { validateLocalDependencies } from './dependency-scanner.mjs'
11
10
 
12
11
  const IS_WINDOWS = process.platform === 'win32'
13
12
 
13
+ function writeStdoutLine(message = '') {
14
+ const text = message == null ? '' : String(message)
15
+ process.stdout.write(`${text}\n`)
16
+ }
17
+
18
+ function writeStderrLine(message = '') {
19
+ const text = message == null ? '' : String(message)
20
+ process.stderr.write(`${text}\n`)
21
+ }
22
+
23
+ function writeStderr(message = '') {
24
+ const text = message == null ? '' : String(message)
25
+ process.stderr.write(text)
26
+ if (text && !text.endsWith('\n')) {
27
+ process.stderr.write('\n')
28
+ }
29
+ }
30
+
14
31
  function logStep(message) {
15
- console.log(chalk.yellow(`→ ${message}`))
32
+ writeStdoutLine(chalk.yellow(`→ ${message}`))
16
33
  }
17
34
 
18
35
  function logSuccess(message) {
19
- console.log(chalk.green(`✔ ${message}`))
36
+ writeStdoutLine(chalk.green(`✔ ${message}`))
20
37
  }
21
38
 
22
39
  function logWarning(message) {
23
- console.warn(chalk.yellow(`⚠ ${message}`))
40
+ writeStderrLine(chalk.yellow(`⚠ ${message}`))
24
41
  }
25
42
 
26
43
  function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
@@ -156,7 +173,7 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process
156
173
  await runCommand('git', ['fetch', remoteName, remoteBranch], { capture: true, cwd: rootDir })
157
174
  } catch (error) {
158
175
  if (error.stderr) {
159
- console.error(error.stderr)
176
+ writeStderr(error.stderr)
160
177
  }
161
178
  throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
162
179
  }
@@ -182,7 +199,7 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process
182
199
  await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { capture: true, cwd: rootDir })
183
200
  } catch (error) {
184
201
  if (error.stderr) {
185
- console.error(error.stderr)
202
+ writeStderr(error.stderr)
186
203
  }
187
204
  throw new Error(
188
205
  `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
@@ -252,10 +269,10 @@ async function runLint(skipLint, pkg, rootDir = process.cwd()) {
252
269
  logSuccess('Lint passed.')
253
270
  } catch (error) {
254
271
  if (error.stdout) {
255
- console.error(error.stdout)
272
+ writeStderr(error.stdout)
256
273
  }
257
274
  if (error.stderr) {
258
- console.error(error.stderr)
275
+ writeStderr(error.stderr)
259
276
  }
260
277
  throw error
261
278
  }
@@ -288,10 +305,10 @@ async function runTests(skipTests, pkg, rootDir = process.cwd()) {
288
305
  logSuccess('Tests passed.')
289
306
  } catch (error) {
290
307
  if (error.stdout) {
291
- console.error(error.stdout)
308
+ writeStderr(error.stdout)
292
309
  }
293
310
  if (error.stderr) {
294
- console.error(error.stderr)
311
+ writeStderr(error.stderr)
295
312
  }
296
313
  throw error
297
314
  }
@@ -315,10 +332,10 @@ async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
315
332
  logSuccess('Build completed.')
316
333
  } catch (error) {
317
334
  if (error.stdout) {
318
- console.error(error.stdout)
335
+ writeStderr(error.stdout)
319
336
  }
320
337
  if (error.stderr) {
321
- console.error(error.stderr)
338
+ writeStderr(error.stderr)
322
339
  }
323
340
  throw error
324
341
  }
@@ -342,10 +359,10 @@ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
342
359
  logSuccess('Library built.')
343
360
  } catch (error) {
344
361
  if (error.stdout) {
345
- console.error(error.stdout)
362
+ writeStderr(error.stdout)
346
363
  }
347
364
  if (error.stderr) {
348
- console.error(error.stderr)
365
+ writeStderr(error.stderr)
349
366
  }
350
367
  throw error
351
368
  }
@@ -378,7 +395,7 @@ async function ensureNpmAuth(rootDir = process.cwd()) {
378
395
  logSuccess('npm authenticated.')
379
396
  } catch (error) {
380
397
  if (error.stderr) {
381
- console.error(error.stderr)
398
+ writeStderr(error.stderr)
382
399
  }
383
400
  throw error
384
401
  }
@@ -439,10 +456,10 @@ async function pushChanges(rootDir = process.cwd()) {
439
456
  logSuccess('Git push completed.')
440
457
  } catch (error) {
441
458
  if (error.stdout) {
442
- console.error(error.stdout)
459
+ writeStderr(error.stdout)
443
460
  }
444
461
  if (error.stderr) {
445
- console.error(error.stderr)
462
+ writeStderr(error.stderr)
446
463
  }
447
464
  throw error
448
465
  }
@@ -473,10 +490,10 @@ async function publishPackage(pkg, rootDir = process.cwd()) {
473
490
  logSuccess('npm publish completed.')
474
491
  } catch (error) {
475
492
  if (error.stdout) {
476
- console.error(error.stdout)
493
+ writeStderr(error.stdout)
477
494
  }
478
495
  if (error.stderr) {
479
- console.error(error.stderr)
496
+ writeStderr(error.stderr)
480
497
  }
481
498
  throw error
482
499
  }
@@ -489,7 +506,7 @@ function extractDomainFromHomepage(homepage) {
489
506
  return url.hostname
490
507
  } catch {
491
508
  // If it's not a valid URL, try to extract domain from string
492
- const match = homepage.match(/(?:https?:\/\/)?([^\/]+)/)
509
+ const match = homepage.match(/(?:https?:\/\/)?([^/]+)/)
493
510
  return match ? match[1] : null
494
511
  }
495
512
  }
@@ -537,7 +554,9 @@ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
537
554
  try {
538
555
  try {
539
556
  await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { capture: true, cwd: rootDir })
540
- } catch { }
557
+ } catch (_error) {
558
+ // Ignore if worktree doesn't exist
559
+ }
541
560
 
542
561
  try {
543
562
  await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { capture: true, cwd: rootDir })
@@ -565,10 +584,10 @@ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
565
584
  logSuccess('GitHub Pages deployment completed.')
566
585
  } catch (error) {
567
586
  if (error.stdout) {
568
- console.error(error.stdout)
587
+ writeStderr(error.stdout)
569
588
  }
570
589
  if (error.stderr) {
571
- console.error(error.stderr)
590
+ writeStderr(error.stderr)
572
591
  }
573
592
  throw error
574
593
  }
@@ -610,8 +629,8 @@ export async function releaseNode() {
610
629
 
611
630
  logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
612
631
  } catch (error) {
613
- console.error('\nRelease failed:')
614
- console.error(error.message)
632
+ writeStderrLine('\nRelease failed:')
633
+ writeStderrLine(error.message)
615
634
  throw error
616
635
  }
617
636
  }
@@ -1,9 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { fileURLToPath } from 'node:url'
3
- import { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
4
3
  import { readFile, writeFile } from 'node:fs/promises'
5
4
  import fs from 'node:fs'
6
- import path from 'node:path'
7
5
  import process from 'node:process'
8
6
  import semver from 'semver'
9
7
  import inquirer from 'inquirer'
@@ -15,16 +13,34 @@ const WARN_PREFIX = '⚠'
15
13
 
16
14
  const IS_WINDOWS = process.platform === 'win32'
17
15
 
16
+ function writeStdoutLine(message = '') {
17
+ const text = message == null ? '' : String(message)
18
+ process.stdout.write(`${text}\n`)
19
+ }
20
+
21
+ function writeStderrLine(message = '') {
22
+ const text = message == null ? '' : String(message)
23
+ process.stderr.write(`${text}\n`)
24
+ }
25
+
26
+ function writeStderr(message = '') {
27
+ const text = message == null ? '' : String(message)
28
+ process.stderr.write(text)
29
+ if (text && !text.endsWith('\n')) {
30
+ process.stderr.write('\n')
31
+ }
32
+ }
33
+
18
34
  function logStep(message) {
19
- console.log(`${STEP_PREFIX} ${message}`)
35
+ writeStdoutLine(`${STEP_PREFIX} ${message}`)
20
36
  }
21
37
 
22
38
  function logSuccess(message) {
23
- console.log(`${OK_PREFIX} ${message}`)
39
+ writeStdoutLine(`${OK_PREFIX} ${message}`)
24
40
  }
25
41
 
26
42
  function logWarning(message) {
27
- console.warn(`${WARN_PREFIX} ${message}`)
43
+ writeStderrLine(`${WARN_PREFIX} ${message}`)
28
44
  }
29
45
 
30
46
  function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
@@ -271,10 +287,10 @@ async function runLint(skipLint, rootDir = process.cwd()) {
271
287
  }
272
288
  process.stdout.write('\n')
273
289
  if (error.stdout) {
274
- console.error(error.stdout)
290
+ writeStderr(error.stdout)
275
291
  }
276
292
  if (error.stderr) {
277
- console.error(error.stderr)
293
+ writeStderr(error.stderr)
278
294
  }
279
295
  throw error
280
296
  }
@@ -324,10 +340,10 @@ async function runTests(skipTests, composer, rootDir = process.cwd()) {
324
340
  }
325
341
  process.stdout.write('\n')
326
342
  if (error.stdout) {
327
- console.error(error.stdout)
343
+ writeStderr(error.stdout)
328
344
  }
329
345
  if (error.stderr) {
330
- console.error(error.stderr)
346
+ writeStderr(error.stderr)
331
347
  }
332
348
  throw error
333
349
  }
package/src/ssh-utils.mjs CHANGED
@@ -3,13 +3,24 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { NodeSSH } from 'node-ssh'
5
5
  import chalk from 'chalk'
6
+ import process from 'node:process'
6
7
 
7
8
  // Import utility functions - these need to be passed in or redefined to avoid circular dependency
8
9
  // For now, we'll redefine the simple ones and accept others as parameters
9
- const logProcessing = (message = '') => console.log(chalk.yellow(message))
10
- const logSuccess = (message = '') => console.log(chalk.green(message))
11
- const logError = (message = '') => console.error(chalk.red(message))
12
- const logWarning = (message = '') => console.warn(chalk.yellow(message))
10
+ function writeStdoutLine(message = '') {
11
+ const text = message == null ? '' : String(message)
12
+ process.stdout.write(`${text}\n`)
13
+ }
14
+
15
+ function writeStderrLine(message = '') {
16
+ const text = message == null ? '' : String(message)
17
+ process.stderr.write(`${text}\n`)
18
+ }
19
+
20
+ const logProcessing = (message = '') => writeStdoutLine(chalk.yellow(message))
21
+ const logSuccess = (message = '') => writeStdoutLine(chalk.green(message))
22
+ const logError = (message = '') => writeStderrLine(chalk.red(message))
23
+ const logWarning = (message = '') => writeStderrLine(chalk.yellow(message))
13
24
 
14
25
  function expandHomePath(targetPath) {
15
26
  if (!targetPath) {
@@ -25,7 +36,7 @@ async function resolveSshKeyPath(targetPath) {
25
36
  const expanded = expandHomePath(targetPath)
26
37
  try {
27
38
  await fs.access(expanded)
28
- } catch (error) {
39
+ } catch (_error) {
29
40
  throw new Error(`SSH key not accessible at ${expanded}`)
30
41
  }
31
42
  return expanded
@@ -64,7 +75,7 @@ const createSshClient = () => {
64
75
  * @param {string} rootDir - Local root directory for logging
65
76
  * @returns {Promise<{ssh: NodeSSH, remoteCwd: string, remoteHome: string}>}
66
77
  */
67
- export async function connectToServer(config, rootDir) {
78
+ export async function connectToServer(config, _rootDir) {
68
79
  const ssh = createSshClient()
69
80
  const sshUser = config.sshUser || os.userInfo().username
70
81
  const privateKeyPath = await resolveSshKeyPath(config.sshKey)
@@ -96,7 +107,7 @@ export async function connectToServer(config, rootDir) {
96
107
  * @returns {Promise<Object>} Command result
97
108
  */
98
109
  export async function executeRemoteCommand(ssh, label, command, options = {}) {
99
- const { cwd, allowFailure = false, printStdout = true, bootstrapEnv = true, rootDir = null, writeToLogFile = null, env = {} } = options
110
+ const { cwd, allowFailure = false, bootstrapEnv = true, rootDir = null, writeToLogFile = null, env = {} } = options
100
111
 
101
112
  logProcessing(`\n→ ${label}`)
102
113
 
@@ -3,9 +3,11 @@ import { fileURLToPath } from 'node:url'
3
3
  import path from 'node:path'
4
4
  import { spawn } from 'node:child_process'
5
5
  import process from 'node:process'
6
+ import https from 'node:https'
6
7
  import semver from 'semver'
7
8
 
8
9
  const IS_WINDOWS = process.platform === 'win32'
10
+ const ZEPHYR_SKIP_VERSION_CHECK_ENV = 'ZEPHYR_SKIP_VERSION_CHECK'
9
11
 
10
12
  async function getCurrentVersion() {
11
13
  try {
@@ -18,21 +20,57 @@ async function getCurrentVersion() {
18
20
  )
19
21
  const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
20
22
  return packageJson.version
21
- } catch (error) {
23
+ } catch (_error) {
22
24
  // If we can't read package.json, return null
23
25
  return null
24
26
  }
25
27
  }
26
28
 
29
+ function httpsGetJson(url) {
30
+ return new Promise((resolve, reject) => {
31
+ const request = https.get(
32
+ url,
33
+ {
34
+ headers: {
35
+ accept: 'application/json'
36
+ }
37
+ },
38
+ (response) => {
39
+ const { statusCode } = response
40
+ if (!statusCode || statusCode < 200 || statusCode >= 300) {
41
+ response.resume()
42
+ resolve(null)
43
+ return
44
+ }
45
+
46
+ response.setEncoding('utf8')
47
+ let raw = ''
48
+ response.on('data', (chunk) => {
49
+ raw += chunk
50
+ })
51
+ response.on('end', () => {
52
+ try {
53
+ resolve(JSON.parse(raw))
54
+ } catch (error) {
55
+ reject(error)
56
+ }
57
+ })
58
+ }
59
+ )
60
+
61
+ request.on('error', reject)
62
+ request.end()
63
+ })
64
+ }
65
+
27
66
  async function getLatestVersion() {
28
67
  try {
29
- const response = await fetch('https://registry.npmjs.org/@wyxos/zephyr/latest')
30
- if (!response.ok) {
68
+ const data = await httpsGetJson('https://registry.npmjs.org/@wyxos/zephyr/latest')
69
+ if (!data) {
31
70
  return null
32
71
  }
33
- const data = await response.json()
34
72
  return data.version || null
35
- } catch (error) {
73
+ } catch (_error) {
36
74
  return null
37
75
  }
38
76
  }
@@ -45,7 +83,7 @@ function isNewerVersionAvailable(current, latest) {
45
83
  // Use semver to properly compare versions
46
84
  try {
47
85
  return semver.gt(latest, current)
48
- } catch (error) {
86
+ } catch (_error) {
49
87
  // If semver comparison fails, fall back to simple string comparison
50
88
  return latest !== current
51
89
  }
@@ -59,7 +97,10 @@ async function reExecuteWithLatest(args) {
59
97
  return new Promise((resolve, reject) => {
60
98
  const child = spawn(command, npxArgs, {
61
99
  stdio: 'inherit',
62
- shell: IS_WINDOWS
100
+ env: {
101
+ ...process.env,
102
+ [ZEPHYR_SKIP_VERSION_CHECK_ENV]: '1'
103
+ }
63
104
  })
64
105
 
65
106
  child.on('error', reject)
@@ -75,12 +116,7 @@ async function reExecuteWithLatest(args) {
75
116
 
76
117
  export async function checkAndUpdateVersion(promptFn, args) {
77
118
  try {
78
- // Skip check if already running @latest (detected via environment or process)
79
- // When npx runs @latest, the version should already be latest
80
- const isRunningLatest = process.env.npm_config_user_config?.includes('@latest') ||
81
- process.argv.some(arg => arg.includes('@latest'))
82
-
83
- if (isRunningLatest) {
119
+ if (process.env[ZEPHYR_SKIP_VERSION_CHECK_ENV] === '1') {
84
120
  return false
85
121
  }
86
122
 
@@ -118,7 +154,7 @@ export async function checkAndUpdateVersion(promptFn, args) {
118
154
  // User confirmed, re-execute with latest version
119
155
  await reExecuteWithLatest(args)
120
156
  return true // Indicates we've re-executed, so the current process should exit
121
- } catch (error) {
157
+ } catch (_error) {
122
158
  // If version check fails, just continue with current version
123
159
  // Don't block the user from using the tool
124
160
  return false