@wyxos/zephyr 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,14 +1,42 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
+ "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
4
5
  "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "bin": {
8
+ "zephyr": "bin/zephyr.mjs"
9
+ },
5
10
  "scripts": {
6
11
  "test": "vitest",
7
12
  "release": "node publish.mjs"
8
13
  },
9
- "bin": {
10
- "zephyr": "bin/zephyr.mjs"
14
+ "keywords": [
15
+ "deployment",
16
+ "laravel",
17
+ "ssh",
18
+ "automation",
19
+ "devops",
20
+ "git"
21
+ ],
22
+ "author": "wyxos",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/wyxos/zephyr.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/wyxos/zephyr/issues"
30
+ },
31
+ "homepage": "https://github.com/wyxos/zephyr#readme",
32
+ "engines": {
33
+ "node": ">=16.0.0"
11
34
  },
35
+ "files": [
36
+ "bin/",
37
+ "src/",
38
+ "README.md"
39
+ ],
12
40
  "dependencies": {
13
41
  "chalk": "5.3.0",
14
42
  "inquirer": "^9.2.12",
package/src/index.mjs CHANGED
@@ -10,6 +10,10 @@ const PROJECT_CONFIG_DIR = '.zephyr'
10
10
  const PROJECT_CONFIG_FILE = 'config.json'
11
11
  const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
12
12
  const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
13
+ const PROJECT_LOCK_FILE = 'deploy.lock'
14
+ const PENDING_TASKS_FILE = 'pending-tasks.json'
15
+ const RELEASE_SCRIPT_NAME = 'release'
16
+ const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@release'
13
17
 
14
18
  const logProcessing = (message = '') => console.log(chalk.yellow(message))
15
19
  const logSuccess = (message = '') => console.log(chalk.green(message))
@@ -273,6 +277,174 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
273
277
  logProcessing('Local repository is clean after committing pending changes.')
274
278
  }
275
279
 
280
+ async function ensureProjectReleaseScript(rootDir) {
281
+ const packageJsonPath = path.join(rootDir, 'package.json')
282
+
283
+ let raw
284
+ try {
285
+ raw = await fs.readFile(packageJsonPath, 'utf8')
286
+ } catch (error) {
287
+ if (error.code === 'ENOENT') {
288
+ return false
289
+ }
290
+
291
+ throw error
292
+ }
293
+
294
+ let packageJson
295
+ try {
296
+ packageJson = JSON.parse(raw)
297
+ } catch (error) {
298
+ logWarning('Unable to parse package.json; skipping release script injection.')
299
+ return false
300
+ }
301
+
302
+ const currentCommand = packageJson?.scripts?.[RELEASE_SCRIPT_NAME]
303
+
304
+ if (currentCommand && currentCommand.includes('@wyxos/zephyr')) {
305
+ return false
306
+ }
307
+
308
+ const { installReleaseScript } = await runPrompt([
309
+ {
310
+ type: 'confirm',
311
+ name: 'installReleaseScript',
312
+ message: 'Add "release" script to package.json that runs "npx @wyxos/zephyr@release"?',
313
+ default: true
314
+ }
315
+ ])
316
+
317
+ if (!installReleaseScript) {
318
+ return false
319
+ }
320
+
321
+ if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
322
+ packageJson.scripts = {}
323
+ }
324
+
325
+ packageJson.scripts[RELEASE_SCRIPT_NAME] = RELEASE_SCRIPT_COMMAND
326
+
327
+ const updatedPayload = `${JSON.stringify(packageJson, null, 2)}\n`
328
+ await fs.writeFile(packageJsonPath, updatedPayload)
329
+ logSuccess('Added release script to package.json.')
330
+
331
+ let isGitRepo = false
332
+
333
+ try {
334
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
335
+ isGitRepo = true
336
+ } catch (error) {
337
+ logWarning('Not a git repository; skipping commit for release script addition.')
338
+ }
339
+
340
+ if (isGitRepo) {
341
+ try {
342
+ await runCommand('git', ['add', 'package.json'], { cwd: rootDir, silent: true })
343
+ await runCommand('git', ['commit', '-m', 'chore: add zephyr release script'], { cwd: rootDir, silent: true })
344
+ logSuccess('Committed package.json release script addition.')
345
+ } catch (error) {
346
+ if (error.exitCode === 1) {
347
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
348
+ } else {
349
+ throw error
350
+ }
351
+ }
352
+ }
353
+
354
+ return true
355
+ }
356
+
357
+ function getProjectConfigDir(rootDir) {
358
+ return path.join(rootDir, PROJECT_CONFIG_DIR)
359
+ }
360
+
361
+ function getPendingTasksPath(rootDir) {
362
+ return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
363
+ }
364
+
365
+ function getLockFilePath(rootDir) {
366
+ return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
367
+ }
368
+
369
+ async function acquireProjectLock(rootDir) {
370
+ const lockDir = getProjectConfigDir(rootDir)
371
+ await ensureDirectory(lockDir)
372
+ const lockPath = getLockFilePath(rootDir)
373
+
374
+ try {
375
+ const existing = await fs.readFile(lockPath, 'utf8')
376
+ let details = {}
377
+ try {
378
+ details = JSON.parse(existing)
379
+ } catch (error) {
380
+ details = { raw: existing }
381
+ }
382
+
383
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
384
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
385
+ throw new Error(
386
+ `Another deployment is currently in progress (started by ${startedBy}${startedAt}). Remove ${lockPath} if you are sure it is stale.`
387
+ )
388
+ } catch (error) {
389
+ if (error.code !== 'ENOENT') {
390
+ throw error
391
+ }
392
+ }
393
+
394
+ const payload = {
395
+ user: os.userInfo().username,
396
+ pid: process.pid,
397
+ hostname: os.hostname(),
398
+ startedAt: new Date().toISOString()
399
+ }
400
+
401
+ await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`)
402
+ return lockPath
403
+ }
404
+
405
+ async function releaseProjectLock(rootDir) {
406
+ const lockPath = getLockFilePath(rootDir)
407
+ try {
408
+ await fs.unlink(lockPath)
409
+ } catch (error) {
410
+ if (error.code !== 'ENOENT') {
411
+ throw error
412
+ }
413
+ }
414
+ }
415
+
416
+ async function loadPendingTasksSnapshot(rootDir) {
417
+ const snapshotPath = getPendingTasksPath(rootDir)
418
+
419
+ try {
420
+ const raw = await fs.readFile(snapshotPath, 'utf8')
421
+ return JSON.parse(raw)
422
+ } catch (error) {
423
+ if (error.code === 'ENOENT') {
424
+ return null
425
+ }
426
+
427
+ throw error
428
+ }
429
+ }
430
+
431
+ async function savePendingTasksSnapshot(rootDir, snapshot) {
432
+ const configDir = getProjectConfigDir(rootDir)
433
+ await ensureDirectory(configDir)
434
+ const payload = `${JSON.stringify(snapshot, null, 2)}\n`
435
+ await fs.writeFile(getPendingTasksPath(rootDir), payload)
436
+ }
437
+
438
+ async function clearPendingTasksSnapshot(rootDir) {
439
+ try {
440
+ await fs.unlink(getPendingTasksPath(rootDir))
441
+ } catch (error) {
442
+ if (error.code !== 'ENOENT') {
443
+ throw error
444
+ }
445
+ }
446
+ }
447
+
276
448
  async function ensureGitignoreEntry(rootDir) {
277
449
  const gitignorePath = path.join(rootDir, '.gitignore')
278
450
  const targetEntry = `${PROJECT_CONFIG_DIR}/`
@@ -573,8 +745,10 @@ function resolveRemotePath(projectPath, remoteHome) {
573
745
  return `${sanitizedHome}/${projectPath}`
574
746
  }
575
747
 
576
- async function runRemoteTasks(config) {
577
- await ensureLocalRepositoryState(config.branch, process.cwd())
748
+ async function runRemoteTasks(config, options = {}) {
749
+ const { snapshot = null, rootDir = process.cwd() } = options
750
+
751
+ await ensureLocalRepositoryState(config.branch, rootDir)
578
752
 
579
753
  const ssh = createSshClient()
580
754
  const sshUser = config.sshUser || os.userInfo().username
@@ -596,10 +770,19 @@ async function runRemoteTasks(config) {
596
770
 
597
771
  logProcessing(`Connection established. Running deployment commands in ${remoteCwd}...`)
598
772
 
773
+ const profileBootstrap = [
774
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
775
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
776
+ 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
777
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
778
+ 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi'
779
+ ].join('; ')
780
+
599
781
  const executeRemote = async (label, command, options = {}) => {
600
- const { cwd = remoteCwd, allowFailure = false, printStdout = true } = options
782
+ const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
601
783
  logProcessing(`\n→ ${label}`)
602
- const result = await ssh.execCommand(command, { cwd })
784
+ const wrappedCommand = bootstrapEnv ? `${profileBootstrap}; ${command}` : command
785
+ const result = await ssh.execCommand(wrappedCommand, { cwd })
603
786
 
604
787
  if (printStdout && result.stdout && result.stdout.trim()) {
605
788
  console.log(result.stdout.trim())
@@ -614,6 +797,13 @@ async function runRemoteTasks(config) {
614
797
  }
615
798
 
616
799
  if (result.code !== 0 && !allowFailure) {
800
+ const stderr = result.stderr?.trim() ?? ''
801
+ if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
802
+ throw new Error(
803
+ `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
804
+ )
805
+ }
806
+
617
807
  throw new Error(`Command failed: ${command}`)
618
808
  }
619
809
 
@@ -634,7 +824,10 @@ async function runRemoteTasks(config) {
634
824
 
635
825
  let changedFiles = []
636
826
 
637
- if (isLaravel) {
827
+ if (snapshot && snapshot.changedFiles) {
828
+ changedFiles = snapshot.changedFiles
829
+ logProcessing('Resuming deployment with saved task snapshot.')
830
+ } else if (isLaravel) {
638
831
  await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
639
832
 
640
833
  const diffResult = await executeRemote(
@@ -762,6 +955,31 @@ async function runRemoteTasks(config) {
762
955
  })
763
956
  }
764
957
 
958
+ const usefulSteps = steps.length > 1
959
+
960
+ let pendingSnapshot
961
+
962
+ if (usefulSteps) {
963
+ pendingSnapshot = snapshot ?? {
964
+ serverName: config.serverName,
965
+ branch: config.branch,
966
+ projectPath: config.projectPath,
967
+ sshUser: config.sshUser,
968
+ createdAt: new Date().toISOString(),
969
+ changedFiles,
970
+ taskLabels: steps.map((step) => step.label)
971
+ }
972
+
973
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
974
+
975
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
976
+ await executeRemote(
977
+ 'Record pending deployment tasks',
978
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
979
+ { printStdout: false }
980
+ )
981
+ }
982
+
765
983
  if (steps.length === 1) {
766
984
  logProcessing('No additional maintenance tasks scheduled beyond git pull.')
767
985
  } else {
@@ -773,8 +991,23 @@ async function runRemoteTasks(config) {
773
991
  logProcessing(`Additional tasks scheduled: ${extraTasks}`)
774
992
  }
775
993
 
776
- for (const step of steps) {
777
- await executeRemote(step.label, step.command)
994
+ let completed = false
995
+
996
+ try {
997
+ for (const step of steps) {
998
+ await executeRemote(step.label, step.command)
999
+ }
1000
+
1001
+ completed = true
1002
+ } finally {
1003
+ if (usefulSteps && completed) {
1004
+ await executeRemote(
1005
+ 'Clear pending deployment snapshot',
1006
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
1007
+ { printStdout: false, allowFailure: true }
1008
+ )
1009
+ await clearPendingTasksSnapshot(rootDir)
1010
+ }
778
1011
  }
779
1012
 
780
1013
  logSuccess('\nDeployment commands completed successfully.')
@@ -964,6 +1197,7 @@ async function main() {
964
1197
  const rootDir = process.cwd()
965
1198
 
966
1199
  await ensureGitignoreEntry(rootDir)
1200
+ await ensureProjectReleaseScript(rootDir)
967
1201
 
968
1202
  const servers = await loadServers()
969
1203
  const server = await selectServer(servers)
@@ -989,11 +1223,58 @@ async function main() {
989
1223
  logProcessing('\nSelected deployment target:')
990
1224
  console.log(JSON.stringify(deploymentConfig, null, 2))
991
1225
 
992
- await runRemoteTasks(deploymentConfig)
1226
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1227
+ let snapshotToUse = null
1228
+
1229
+ if (existingSnapshot) {
1230
+ const matchesSelection =
1231
+ existingSnapshot.serverName === deploymentConfig.serverName &&
1232
+ existingSnapshot.branch === deploymentConfig.branch
1233
+
1234
+ const messageLines = [
1235
+ 'Pending deployment tasks were detected from a previous run.',
1236
+ `Server: ${existingSnapshot.serverName}`,
1237
+ `Branch: ${existingSnapshot.branch}`
1238
+ ]
1239
+
1240
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
1241
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
1242
+ }
1243
+
1244
+ const { resumePendingTasks } = await runPrompt([
1245
+ {
1246
+ type: 'confirm',
1247
+ name: 'resumePendingTasks',
1248
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
1249
+ default: matchesSelection
1250
+ }
1251
+ ])
1252
+
1253
+ if (resumePendingTasks) {
1254
+ snapshotToUse = existingSnapshot
1255
+ logProcessing('Resuming deployment using saved task snapshot...')
1256
+ } else {
1257
+ await clearPendingTasksSnapshot(rootDir)
1258
+ logWarning('Discarded pending deployment snapshot.')
1259
+ }
1260
+ }
1261
+
1262
+ let lockAcquired = false
1263
+
1264
+ try {
1265
+ await acquireProjectLock(rootDir)
1266
+ lockAcquired = true
1267
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1268
+ } finally {
1269
+ if (lockAcquired) {
1270
+ await releaseProjectLock(rootDir)
1271
+ }
1272
+ }
993
1273
  }
994
1274
 
995
1275
  export {
996
1276
  ensureGitignoreEntry,
1277
+ ensureProjectReleaseScript,
997
1278
  listSshKeys,
998
1279
  resolveRemotePath,
999
1280
  isPrivateKeyFile,
@@ -1,35 +0,0 @@
1
- # Copilot Instructions
2
-
3
- ## Project Snapshot
4
- - Command-line deployment tool (`bin/zephyr.mjs`) that delegates to `src/index.mjs` for all logic.
5
- - Node.js ESM project; keep imports as `import … from` and avoid CommonJS helpers.
6
- - Primary responsibilities: gather deployment config via prompts, ensure local git state, SSH into remote servers, run per-change maintenance tasks.
7
-
8
- ## Configuration Model
9
- - Global servers live at `~/.config/zephyr/servers.json` (array of `{ serverName, serverIp }`).
10
- - Per-project apps live at `.zephyr/config.json` (apps array with `{ serverName, projectPath, branch, sshUser, sshKey }`).
11
- - `main()` now sequences: ensure `.zephyr/` ignored, load servers, pick/create one, load project config, pick/create app, ensure SSH details, run deployment.
12
- - When adding config logic, reuse helpers: `selectServer`, `promptServerDetails`, `selectApp`, `promptAppDetails`, `ensureProjectConfig`.
13
-
14
- ## Deployment Flow Highlights
15
- - Always call `ensureLocalRepositoryState(branch)` before SSH. It:
16
- - Verifies current branch, fast-forwards with `git pull --ff-only`, warns if ahead, commits + pushes uncommitted changes when needed.
17
- - Prompts for commit message if dirty and pushes to `origin/<branch>`.
18
- - Remote execution happens via `runRemoteTasks(config)`; keep all SSH commands funneled through `executeRemote(label, command, options)` to inherit logging and error handling.
19
- - Laravel detection toggles extra tasks—Composer, migrations, npm install/build, cache clears, queue restarts—based on changed files from `git diff HEAD..origin/<branch>`.
20
-
21
- ## Release Workflow
22
- - Automated publishing script at `publish.mjs` (`npm run release`):
23
- - Checks clean working tree, fetches & fast-forwards branch, runs `npx vitest run`, bumps version via `npm version <type>`, pushes with tags, publishes (adds `--access public` for scoped packages).
24
- - `npm pkg fix` may adjust `package.json`; commit results before running the release.
25
-
26
- ## Testing & Tooling
27
- - Test suite: `npm test` (Vitest). Mocks for fs, child_process, inquirer, node-ssh are set up—extend them for new behaviors rather than shelling out.
28
- - Avoid long-running watchers in scripts; tests spawn Vitest in watch mode by default, so kill (`pkill -f vitest`) after scripted runs when necessary.
29
-
30
- ## Conventions & Style
31
- - Logging helpers (`logProcessing`, `logSuccess`, `logWarning`, `logError`) centralize colored output—use them instead of `console.log` in new deployment logic.
32
- - Use async/await with `runCommand` / `runCommandCapture` for local shell ops; never `exec` directly.
33
- - Keep new prompts routed through `runPrompt`; it supports injection for tests.
34
- - Default to ASCII in files; comments only where logic is non-obvious.
35
- - Update Vitest cases in `tests/index.test.js` when altering prompts, config structure, or deployment steps; tests expect deterministic logging text.
package/publish.mjs DELETED
@@ -1,248 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn } from 'node:child_process'
3
- import { fileURLToPath } from 'node:url'
4
- import { dirname, join } from 'node:path'
5
- import { readFile } from 'node:fs/promises'
6
-
7
- const ROOT = dirname(fileURLToPath(import.meta.url))
8
- const PACKAGE_PATH = join(ROOT, 'package.json')
9
-
10
- const STEP_PREFIX = '→'
11
- const OK_PREFIX = '✔'
12
- const WARN_PREFIX = '⚠'
13
-
14
- function logStep(message) {
15
- console.log(`${STEP_PREFIX} ${message}`)
16
- }
17
-
18
- function logSuccess(message) {
19
- console.log(`${OK_PREFIX} ${message}`)
20
- }
21
-
22
- function logWarning(message) {
23
- console.warn(`${WARN_PREFIX} ${message}`)
24
- }
25
-
26
- function runCommand(command, args, { cwd = ROOT, capture = false } = {}) {
27
- return new Promise((resolve, reject) => {
28
- const spawnOptions = {
29
- cwd,
30
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
31
- }
32
-
33
- const child = spawn(command, args, spawnOptions)
34
- let stdout = ''
35
- let stderr = ''
36
-
37
- if (capture) {
38
- child.stdout.on('data', (chunk) => {
39
- stdout += chunk
40
- })
41
-
42
- child.stderr.on('data', (chunk) => {
43
- stderr += chunk
44
- })
45
- }
46
-
47
- child.on('error', reject)
48
- child.on('close', (code) => {
49
- if (code === 0) {
50
- resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
51
- } else {
52
- const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
53
- if (capture) {
54
- error.stdout = stdout
55
- error.stderr = stderr
56
- }
57
- error.exitCode = code
58
- reject(error)
59
- }
60
- })
61
- })
62
- }
63
-
64
- async function readPackage() {
65
- const raw = await readFile(PACKAGE_PATH, 'utf8')
66
- return JSON.parse(raw)
67
- }
68
-
69
- async function ensureCleanWorkingTree() {
70
- const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true })
71
-
72
- if (stdout.length > 0) {
73
- throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
74
- }
75
- }
76
-
77
- async function getCurrentBranch() {
78
- const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true })
79
- return stdout || null
80
- }
81
-
82
- async function getUpstreamRef() {
83
- try {
84
- const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
85
- capture: true
86
- })
87
-
88
- return stdout || null
89
- } catch {
90
- return null
91
- }
92
- }
93
-
94
- async function ensureUpToDateWithUpstream(branch, upstreamRef) {
95
- if (!upstreamRef) {
96
- logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
97
- return
98
- }
99
-
100
- const [remoteName, ...branchParts] = upstreamRef.split('/')
101
- const remoteBranch = branchParts.join('/')
102
-
103
- if (remoteName && remoteBranch) {
104
- logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
105
- try {
106
- await runCommand('git', ['fetch', remoteName, remoteBranch])
107
- } catch (error) {
108
- throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
109
- }
110
- }
111
-
112
- const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
113
- capture: true
114
- })
115
- const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
116
- capture: true
117
- })
118
-
119
- const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
120
- const behind = Number.parseInt(behindResult.stdout || '0', 10)
121
-
122
- if (Number.isFinite(behind) && behind > 0) {
123
- if (remoteName && remoteBranch) {
124
- logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
125
-
126
- try {
127
- await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch])
128
- } catch (error) {
129
- throw new Error(
130
- `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
131
- )
132
- }
133
-
134
- return ensureUpToDateWithUpstream(branch, upstreamRef)
135
- }
136
-
137
- throw new Error(
138
- `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
139
- )
140
- }
141
-
142
- if (Number.isFinite(ahead) && ahead > 0) {
143
- logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
144
- }
145
- }
146
-
147
- function parseArgs() {
148
- const args = process.argv.slice(2)
149
- const positionals = args.filter((arg) => !arg.startsWith('--'))
150
- const flags = new Set(args.filter((arg) => arg.startsWith('--')))
151
-
152
- const releaseType = positionals[0] ?? 'patch'
153
- const skipTests = flags.has('--skip-tests')
154
-
155
- const allowedTypes = new Set([
156
- 'major',
157
- 'minor',
158
- 'patch',
159
- 'premajor',
160
- 'preminor',
161
- 'prepatch',
162
- 'prerelease'
163
- ])
164
-
165
- if (!allowedTypes.has(releaseType)) {
166
- throw new Error(
167
- `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
168
- )
169
- }
170
-
171
- return { releaseType, skipTests }
172
- }
173
-
174
- async function runTests(skipTests) {
175
- if (skipTests) {
176
- logWarning('Skipping tests because --skip-tests flag was provided.')
177
- return
178
- }
179
-
180
- logStep('Running test suite (vitest run)...')
181
- await runCommand('npx', ['vitest', 'run'])
182
- logSuccess('Tests passed.')
183
- }
184
-
185
- async function ensureNpmAuth() {
186
- logStep('Confirming npm authentication...')
187
- await runCommand('npm', ['whoami'])
188
- }
189
-
190
- async function bumpVersion(releaseType) {
191
- logStep(`Bumping package version with "npm version ${releaseType}"...`)
192
- await runCommand('npm', ['version', releaseType, '--message', 'chore: release %s'])
193
- const pkg = await readPackage()
194
- logSuccess(`Version updated to ${pkg.version}.`)
195
- return pkg
196
- }
197
-
198
- async function pushChanges() {
199
- logStep('Pushing commits and tags to origin...')
200
- await runCommand('git', ['push', '--follow-tags'])
201
- logSuccess('Git push completed.')
202
- }
203
-
204
- async function publishPackage(pkg) {
205
- const publishArgs = ['publish']
206
-
207
- if (pkg.name.startsWith('@')) {
208
- publishArgs.push('--access', 'public')
209
- }
210
-
211
- logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
212
- await runCommand('npm', publishArgs)
213
- logSuccess('npm publish completed.')
214
- }
215
-
216
- async function main() {
217
- const { releaseType, skipTests } = parseArgs()
218
-
219
- logStep('Reading package metadata...')
220
- const pkg = await readPackage()
221
-
222
- logStep('Checking working tree status...')
223
- await ensureCleanWorkingTree()
224
-
225
- const branch = await getCurrentBranch()
226
- if (!branch) {
227
- throw new Error('Unable to determine current branch.')
228
- }
229
-
230
- logStep(`Current branch: ${branch}`)
231
- const upstreamRef = await getUpstreamRef()
232
- await ensureUpToDateWithUpstream(branch, upstreamRef)
233
-
234
- await runTests(skipTests)
235
- await ensureNpmAuth()
236
-
237
- const updatedPkg = await bumpVersion(releaseType)
238
- await pushChanges()
239
- await publishPackage(updatedPkg)
240
-
241
- logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
242
- }
243
-
244
- main().catch((error) => {
245
- console.error('\nRelease failed:')
246
- console.error(error.message)
247
- process.exit(1)
248
- })
@@ -1,426 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
-
3
- const mockReadFile = vi.fn()
4
- const mockReaddir = vi.fn()
5
- const mockAccess = vi.fn()
6
- const mockWriteFile = vi.fn()
7
- const mockMkdir = vi.fn()
8
- const mockExecCommand = vi.fn()
9
- const mockConnect = vi.fn()
10
- const mockDispose = vi.fn()
11
- const mockPrompt = vi.fn()
12
-
13
- vi.mock('node:fs/promises', () => ({
14
- default: {
15
- readFile: mockReadFile,
16
- readdir: mockReaddir,
17
- access: mockAccess,
18
- writeFile: mockWriteFile,
19
- mkdir: mockMkdir
20
- },
21
- readFile: mockReadFile,
22
- readdir: mockReaddir,
23
- access: mockAccess,
24
- writeFile: mockWriteFile,
25
- mkdir: mockMkdir
26
- }))
27
-
28
- const spawnQueue = []
29
-
30
- const queueSpawnResponse = (response = {}) => {
31
- spawnQueue.push(response)
32
- }
33
-
34
- const mockSpawn = vi.fn((command, args) => {
35
- const { stdout = '', stderr = '', exitCode = 0, error } =
36
- spawnQueue.length > 0 ? spawnQueue.shift() : {}
37
-
38
- const stdoutHandlers = []
39
- const stderrHandlers = []
40
- const closeHandlers = []
41
- const errorHandlers = []
42
-
43
- setImmediate(() => {
44
- if (error) {
45
- errorHandlers.forEach((handler) => handler(error))
46
- return
47
- }
48
-
49
- if (stdout) {
50
- const chunk = Buffer.from(stdout)
51
- stdoutHandlers.forEach((handler) => handler(chunk))
52
- }
53
-
54
- if (stderr) {
55
- const chunk = Buffer.from(stderr)
56
- stderrHandlers.forEach((handler) => handler(chunk))
57
- }
58
-
59
- closeHandlers.forEach((handler) => handler(exitCode))
60
- })
61
-
62
- return {
63
- stdout: {
64
- on: (event, handler) => {
65
- if (event === 'data') {
66
- stdoutHandlers.push(handler)
67
- }
68
- }
69
- },
70
- stderr: {
71
- on: (event, handler) => {
72
- if (event === 'data') {
73
- stderrHandlers.push(handler)
74
- }
75
- }
76
- },
77
- on: (event, handler) => {
78
- if (event === 'close') {
79
- closeHandlers.push(handler)
80
- }
81
-
82
- if (event === 'error') {
83
- errorHandlers.push(handler)
84
- }
85
- }
86
- }
87
- })
88
-
89
- vi.mock('node:child_process', () => ({
90
- spawn: mockSpawn,
91
- default: {
92
- spawn: mockSpawn
93
- }
94
- }))
95
-
96
- vi.mock('inquirer', () => {
97
- class Separator {}
98
-
99
- return {
100
- default: {
101
- prompt: mockPrompt,
102
- Separator
103
- },
104
- Separator,
105
- prompt: mockPrompt
106
- }
107
- })
108
-
109
- vi.mock('node-ssh', () => ({
110
- NodeSSH: vi.fn(() => ({
111
- connect: mockConnect,
112
- execCommand: mockExecCommand,
113
- dispose: mockDispose
114
- }))
115
- }))
116
-
117
- vi.mock('node:os', () => ({
118
- default: {
119
- homedir: () => '/home/local',
120
- userInfo: () => ({ username: 'localuser' })
121
- },
122
- homedir: () => '/home/local',
123
- userInfo: () => ({ username: 'localuser' })
124
- }))
125
-
126
- describe('zephyr deployment helpers', () => {
127
- beforeEach(() => {
128
- vi.resetModules()
129
- spawnQueue.length = 0
130
- mockSpawn.mockClear()
131
- mockReadFile.mockReset()
132
- mockReaddir.mockReset()
133
- mockAccess.mockReset()
134
- mockWriteFile.mockReset()
135
- mockMkdir.mockReset()
136
- mockExecCommand.mockReset()
137
- mockConnect.mockReset()
138
- mockDispose.mockReset()
139
- mockPrompt.mockReset()
140
- globalThis.__zephyrSSHFactory = () => ({
141
- connect: mockConnect,
142
- execCommand: mockExecCommand,
143
- dispose: mockDispose
144
- })
145
- globalThis.__zephyrPrompt = mockPrompt
146
- })
147
-
148
- afterEach(() => {
149
- delete globalThis.__zephyrSSHFactory
150
- delete globalThis.__zephyrPrompt
151
- })
152
-
153
- it('resolves remote paths correctly', async () => {
154
- const { resolveRemotePath } = await import('../src/index.mjs')
155
-
156
- expect(resolveRemotePath('~/webapps/app', '/home/runcloud')).toBe(
157
- '/home/runcloud/webapps/app'
158
- )
159
- expect(resolveRemotePath('app', '/home/runcloud')).toBe('/home/runcloud/app')
160
- expect(resolveRemotePath('/var/www/html', '/home/runcloud')).toBe(
161
- '/var/www/html'
162
- )
163
- expect(resolveRemotePath('~', '/home/runcloud')).toBe('/home/runcloud')
164
- })
165
-
166
- it('detects private key files from contents', async () => {
167
- mockReadFile.mockResolvedValueOnce('-----BEGIN OPENSSH PRIVATE KEY-----')
168
-
169
- const { isPrivateKeyFile } = await import('../src/index.mjs')
170
-
171
- await expect(isPrivateKeyFile('/home/local/.ssh/id_rsa')).resolves.toBe(true)
172
-
173
- mockReadFile.mockResolvedValueOnce('not-a-key')
174
- await expect(isPrivateKeyFile('/home/local/.ssh/config')).resolves.toBe(false)
175
- })
176
-
177
- it('lists only valid SSH private keys', async () => {
178
- mockReaddir.mockResolvedValue([
179
- { name: 'id_rsa', isFile: () => true },
180
- { name: 'id_rsa.pub', isFile: () => true },
181
- { name: '.DS_Store', isFile: () => true },
182
- { name: 'config', isFile: () => true },
183
- { name: 'deploy_key', isFile: () => true }
184
- ])
185
-
186
- mockReadFile.mockImplementation(async (filePath) => {
187
- if (filePath.endsWith('id_rsa')) {
188
- return '-----BEGIN RSA PRIVATE KEY-----'
189
- }
190
-
191
- if (filePath.endsWith('deploy_key')) {
192
- return '-----BEGIN OPENSSH PRIVATE KEY-----'
193
- }
194
-
195
- return 'invalid'
196
- })
197
-
198
- // Import path module to ensure cross-platform path handling
199
- const path = await import('node:path')
200
- const { listSshKeys } = await import('../src/index.mjs')
201
-
202
- const result = await listSshKeys()
203
-
204
- expect(result).toEqual({
205
- sshDir: path.default.join('/home/local', '.ssh'),
206
- keys: ['id_rsa', 'deploy_key']
207
- })
208
- })
209
-
210
- describe('configuration management', () => {
211
- it('registers a new server when none exist', async () => {
212
- mockPrompt.mockResolvedValueOnce({ serverName: 'production', serverIp: '203.0.113.10' })
213
-
214
- const { selectServer } = await import('../src/index.mjs')
215
-
216
- const servers = []
217
- const server = await selectServer(servers)
218
-
219
- expect(server).toEqual({ serverName: 'production', serverIp: '203.0.113.10' })
220
- expect(servers).toHaveLength(1)
221
- expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.config/zephyr'), { recursive: true })
222
- const [writePath, payload] = mockWriteFile.mock.calls.at(-1)
223
- expect(writePath).toContain('servers.json')
224
- expect(payload).toContain('production')
225
- })
226
-
227
- it('creates a new application configuration when none exist for a server', async () => {
228
- queueSpawnResponse({ stdout: 'main\n' })
229
- mockPrompt
230
- .mockResolvedValueOnce({ projectPath: '~/webapps/demo', branchSelection: 'main' })
231
- .mockResolvedValueOnce({ sshUser: 'forge', sshKeySelection: '/home/local/.ssh/id_rsa' })
232
- mockReaddir.mockResolvedValue([])
233
-
234
- const { selectApp } = await import('../src/index.mjs')
235
-
236
- const projectConfig = { apps: [] }
237
- const server = { serverName: 'production', serverIp: '203.0.113.10' }
238
-
239
- const app = await selectApp(projectConfig, server, process.cwd())
240
-
241
- expect(app).toMatchObject({
242
- serverName: 'production',
243
- projectPath: '~/webapps/demo',
244
- branch: 'main',
245
- sshUser: 'forge',
246
- sshKey: '/home/local/.ssh/id_rsa'
247
- })
248
- expect(projectConfig.apps).toHaveLength(1)
249
- expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.zephyr'), { recursive: true })
250
- const [writePath, payload] = mockWriteFile.mock.calls.at(-1)
251
- expect(writePath).toContain('.zephyr/config.json')
252
- expect(payload).toContain('~/webapps/demo')
253
- })
254
- })
255
-
256
- it('schedules Laravel tasks based on diff', async () => {
257
- queueSpawnResponse({ stdout: 'main\n' })
258
- queueSpawnResponse({ stdout: '' })
259
-
260
- mockConnect.mockResolvedValue()
261
- mockDispose.mockResolvedValue()
262
-
263
- mockExecCommand.mockImplementation(async (command) => {
264
- const response = { stdout: '', stderr: '', code: 0 }
265
-
266
- if (command.includes('printf "%s" "$HOME"')) {
267
- return { ...response, stdout: '/home/runcloud' }
268
- }
269
-
270
- if (command.includes('grep -q "laravel/framework"')) {
271
- return { ...response, stdout: 'yes' }
272
- }
273
-
274
- if (command.startsWith('git diff')) {
275
- return {
276
- ...response,
277
- stdout:
278
- 'composer.json\n' +
279
- 'database/migrations/2025_10_21_000000_create_table.php\n' +
280
- 'resources/js/app.js\n' +
281
- 'resources/views/welcome.blade.php\n' +
282
- 'config/horizon.php\n'
283
- }
284
- }
285
-
286
- if (command.includes('config/horizon.php')) {
287
- return { ...response, stdout: 'yes' }
288
- }
289
-
290
- return response
291
- })
292
-
293
- const { runRemoteTasks } = await import('../src/index.mjs')
294
-
295
- await runRemoteTasks({
296
- serverIp: '127.0.0.1',
297
- projectPath: '~/app',
298
- branch: 'main',
299
- sshUser: 'forge',
300
- sshKey: '~/.ssh/id_rsa'
301
- })
302
-
303
- const cwdValues = mockExecCommand.mock.calls
304
- .map(([, options]) => options?.cwd)
305
- .filter(Boolean)
306
- expect(cwdValues.length).toBeGreaterThan(0)
307
- expect(cwdValues.every((cwd) => typeof cwd === 'string')).toBe(true)
308
- expect(mockExecCommand).toHaveBeenCalledWith(
309
- expect.stringContaining('git pull origin main'),
310
- expect.objectContaining({ cwd: expect.any(String) })
311
- )
312
- expect(mockExecCommand).toHaveBeenCalledWith(
313
- expect.stringContaining('composer update'),
314
- expect.objectContaining({ cwd: expect.any(String) })
315
- )
316
- expect(mockExecCommand).toHaveBeenCalledWith(
317
- expect.stringContaining('php artisan migrate'),
318
- expect.objectContaining({ cwd: expect.any(String) })
319
- )
320
- expect(mockExecCommand).toHaveBeenCalledWith(
321
- expect.stringContaining('npm run build'),
322
- expect.objectContaining({ cwd: expect.any(String) })
323
- )
324
- expect(mockExecCommand).toHaveBeenCalledWith(
325
- expect.stringContaining('cache:clear'),
326
- expect.objectContaining({ cwd: expect.any(String) })
327
- )
328
- expect(mockExecCommand).toHaveBeenCalledWith(
329
- expect.stringContaining('horizon:terminate'),
330
- expect.objectContaining({ cwd: expect.any(String) })
331
- )
332
- })
333
-
334
- it('skips Laravel tasks when framework not detected', async () => {
335
- queueSpawnResponse({ stdout: 'main\n' })
336
- queueSpawnResponse({ stdout: '' })
337
-
338
- mockConnect.mockResolvedValue()
339
- mockDispose.mockResolvedValue()
340
-
341
- mockExecCommand
342
- .mockResolvedValueOnce({ stdout: '/home/runcloud', stderr: '', code: 0 })
343
- .mockResolvedValueOnce({ stdout: 'no', stderr: '', code: 0 })
344
- .mockResolvedValue({ stdout: '', stderr: '', code: 0 })
345
-
346
- const { runRemoteTasks } = await import('../src/index.mjs')
347
-
348
- await runRemoteTasks({
349
- serverIp: '127.0.0.1',
350
- projectPath: '~/app',
351
- branch: 'main',
352
- sshUser: 'forge',
353
- sshKey: '~/.ssh/id_rsa'
354
- })
355
-
356
- expect(mockExecCommand).not.toHaveBeenCalledWith(
357
- expect.stringContaining('composer update'),
358
- expect.anything()
359
- )
360
- expect(mockExecCommand).toHaveBeenCalledWith(
361
- expect.stringContaining('git pull origin main'),
362
- expect.anything()
363
- )
364
- })
365
-
366
- describe('ensureLocalRepositoryState', () => {
367
- it('switches to the target branch when clean', async () => {
368
- queueSpawnResponse({ stdout: 'develop\n' })
369
- queueSpawnResponse({ stdout: '' })
370
- queueSpawnResponse({})
371
- queueSpawnResponse({ stdout: '' })
372
-
373
- const { ensureLocalRepositoryState } = await import('../src/index.mjs')
374
-
375
- await expect(
376
- ensureLocalRepositoryState('main', process.cwd())
377
- ).resolves.toBeUndefined()
378
-
379
- expect(
380
- mockSpawn.mock.calls.some(
381
- ([command, args]) => command === 'git' && args.includes('checkout') && args.includes('main')
382
- )
383
- ).toBe(true)
384
- })
385
-
386
- it('throws when attempting to switch branches with uncommitted changes', async () => {
387
- queueSpawnResponse({ stdout: 'develop\n' })
388
- queueSpawnResponse({ stdout: ' M file.txt\n' })
389
-
390
- const { ensureLocalRepositoryState } = await import('../src/index.mjs')
391
-
392
- await expect(
393
- ensureLocalRepositoryState('main', process.cwd())
394
- ).rejects.toThrow(/uncommitted changes/)
395
- })
396
-
397
- it('commits and pushes pending changes on the target branch', async () => {
398
- queueSpawnResponse({ stdout: 'main\n' })
399
- queueSpawnResponse({ stdout: ' M file.php\n' })
400
- queueSpawnResponse({})
401
- queueSpawnResponse({})
402
- queueSpawnResponse({})
403
- queueSpawnResponse({ stdout: '' })
404
-
405
- mockPrompt.mockResolvedValueOnce({ commitMessage: 'Prepare deployment' })
406
-
407
- const { ensureLocalRepositoryState } = await import('../src/index.mjs')
408
-
409
- await expect(
410
- ensureLocalRepositoryState('main', process.cwd())
411
- ).resolves.toBeUndefined()
412
-
413
- expect(mockPrompt).toHaveBeenCalledTimes(1)
414
- expect(
415
- mockSpawn.mock.calls.some(
416
- ([command, args]) => command === 'git' && args[0] === 'commit'
417
- )
418
- ).toBe(true)
419
- expect(
420
- mockSpawn.mock.calls.some(
421
- ([command, args]) => command === 'git' && args[0] === 'push' && args.includes('main')
422
- )
423
- ).toBe(true)
424
- })
425
- })
426
- })