@wyxos/zephyr 0.1.3 → 0.1.5

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.3",
3
+ "version": "0.1.5",
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))
@@ -197,6 +201,33 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
197
201
  const initialStatus = await getGitStatus(rootDir)
198
202
  const hasPendingChanges = initialStatus.length > 0
199
203
 
204
+ const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
205
+ cwd: rootDir
206
+ })
207
+
208
+ const lines = statusReport.split(/\r?\n/)
209
+ const branchLine = lines[0] || ''
210
+ const aheadMatch = branchLine.match(/ahead (\d+)/)
211
+ const behindMatch = branchLine.match(/behind (\d+)/)
212
+ const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
213
+ const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
214
+
215
+ if (aheadCount > 0) {
216
+ logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
217
+ }
218
+
219
+ if (behindCount > 0) {
220
+ logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
221
+ try {
222
+ await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
223
+ logSuccess('Local branch fast-forwarded with upstream changes.')
224
+ } catch (error) {
225
+ throw new Error(
226
+ `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
227
+ )
228
+ }
229
+ }
230
+
200
231
  if (currentBranch !== targetBranch) {
201
232
  if (hasPendingChanges) {
202
233
  throw new Error(
@@ -246,6 +277,151 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
246
277
  logProcessing('Local repository is clean after committing pending changes.')
247
278
  }
248
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
+ return true
332
+ }
333
+
334
+ function getProjectConfigDir(rootDir) {
335
+ return path.join(rootDir, PROJECT_CONFIG_DIR)
336
+ }
337
+
338
+ function getPendingTasksPath(rootDir) {
339
+ return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
340
+ }
341
+
342
+ function getLockFilePath(rootDir) {
343
+ return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
344
+ }
345
+
346
+ async function acquireProjectLock(rootDir) {
347
+ const lockDir = getProjectConfigDir(rootDir)
348
+ await ensureDirectory(lockDir)
349
+ const lockPath = getLockFilePath(rootDir)
350
+
351
+ try {
352
+ const existing = await fs.readFile(lockPath, 'utf8')
353
+ let details = {}
354
+ try {
355
+ details = JSON.parse(existing)
356
+ } catch (error) {
357
+ details = { raw: existing }
358
+ }
359
+
360
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
361
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
362
+ throw new Error(
363
+ `Another deployment is currently in progress (started by ${startedBy}${startedAt}). Remove ${lockPath} if you are sure it is stale.`
364
+ )
365
+ } catch (error) {
366
+ if (error.code !== 'ENOENT') {
367
+ throw error
368
+ }
369
+ }
370
+
371
+ const payload = {
372
+ user: os.userInfo().username,
373
+ pid: process.pid,
374
+ hostname: os.hostname(),
375
+ startedAt: new Date().toISOString()
376
+ }
377
+
378
+ await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`)
379
+ return lockPath
380
+ }
381
+
382
+ async function releaseProjectLock(rootDir) {
383
+ const lockPath = getLockFilePath(rootDir)
384
+ try {
385
+ await fs.unlink(lockPath)
386
+ } catch (error) {
387
+ if (error.code !== 'ENOENT') {
388
+ throw error
389
+ }
390
+ }
391
+ }
392
+
393
+ async function loadPendingTasksSnapshot(rootDir) {
394
+ const snapshotPath = getPendingTasksPath(rootDir)
395
+
396
+ try {
397
+ const raw = await fs.readFile(snapshotPath, 'utf8')
398
+ return JSON.parse(raw)
399
+ } catch (error) {
400
+ if (error.code === 'ENOENT') {
401
+ return null
402
+ }
403
+
404
+ throw error
405
+ }
406
+ }
407
+
408
+ async function savePendingTasksSnapshot(rootDir, snapshot) {
409
+ const configDir = getProjectConfigDir(rootDir)
410
+ await ensureDirectory(configDir)
411
+ const payload = `${JSON.stringify(snapshot, null, 2)}\n`
412
+ await fs.writeFile(getPendingTasksPath(rootDir), payload)
413
+ }
414
+
415
+ async function clearPendingTasksSnapshot(rootDir) {
416
+ try {
417
+ await fs.unlink(getPendingTasksPath(rootDir))
418
+ } catch (error) {
419
+ if (error.code !== 'ENOENT') {
420
+ throw error
421
+ }
422
+ }
423
+ }
424
+
249
425
  async function ensureGitignoreEntry(rootDir) {
250
426
  const gitignorePath = path.join(rootDir, '.gitignore')
251
427
  const targetEntry = `${PROJECT_CONFIG_DIR}/`
@@ -546,8 +722,10 @@ function resolveRemotePath(projectPath, remoteHome) {
546
722
  return `${sanitizedHome}/${projectPath}`
547
723
  }
548
724
 
549
- async function runRemoteTasks(config) {
550
- await ensureLocalRepositoryState(config.branch, process.cwd())
725
+ async function runRemoteTasks(config, options = {}) {
726
+ const { snapshot = null, rootDir = process.cwd() } = options
727
+
728
+ await ensureLocalRepositoryState(config.branch, rootDir)
551
729
 
552
730
  const ssh = createSshClient()
553
731
  const sshUser = config.sshUser || os.userInfo().username
@@ -607,7 +785,10 @@ async function runRemoteTasks(config) {
607
785
 
608
786
  let changedFiles = []
609
787
 
610
- if (isLaravel) {
788
+ if (snapshot && snapshot.changedFiles) {
789
+ changedFiles = snapshot.changedFiles
790
+ logProcessing('Resuming deployment with saved task snapshot.')
791
+ } else if (isLaravel) {
611
792
  await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
612
793
 
613
794
  const diffResult = await executeRemote(
@@ -735,6 +916,31 @@ async function runRemoteTasks(config) {
735
916
  })
736
917
  }
737
918
 
919
+ const usefulSteps = steps.length > 1
920
+
921
+ let pendingSnapshot
922
+
923
+ if (usefulSteps) {
924
+ pendingSnapshot = snapshot ?? {
925
+ serverName: config.serverName,
926
+ branch: config.branch,
927
+ projectPath: config.projectPath,
928
+ sshUser: config.sshUser,
929
+ createdAt: new Date().toISOString(),
930
+ changedFiles,
931
+ taskLabels: steps.map((step) => step.label)
932
+ }
933
+
934
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
935
+
936
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
937
+ await executeRemote(
938
+ 'Record pending deployment tasks',
939
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
940
+ { printStdout: false }
941
+ )
942
+ }
943
+
738
944
  if (steps.length === 1) {
739
945
  logProcessing('No additional maintenance tasks scheduled beyond git pull.')
740
946
  } else {
@@ -746,8 +952,23 @@ async function runRemoteTasks(config) {
746
952
  logProcessing(`Additional tasks scheduled: ${extraTasks}`)
747
953
  }
748
954
 
749
- for (const step of steps) {
750
- await executeRemote(step.label, step.command)
955
+ let completed = false
956
+
957
+ try {
958
+ for (const step of steps) {
959
+ await executeRemote(step.label, step.command)
960
+ }
961
+
962
+ completed = true
963
+ } finally {
964
+ if (usefulSteps && completed) {
965
+ await executeRemote(
966
+ 'Clear pending deployment snapshot',
967
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
968
+ { printStdout: false, allowFailure: true }
969
+ )
970
+ await clearPendingTasksSnapshot(rootDir)
971
+ }
751
972
  }
752
973
 
753
974
  logSuccess('\nDeployment commands completed successfully.')
@@ -937,6 +1158,7 @@ async function main() {
937
1158
  const rootDir = process.cwd()
938
1159
 
939
1160
  await ensureGitignoreEntry(rootDir)
1161
+ await ensureProjectReleaseScript(rootDir)
940
1162
 
941
1163
  const servers = await loadServers()
942
1164
  const server = await selectServer(servers)
@@ -962,11 +1184,58 @@ async function main() {
962
1184
  logProcessing('\nSelected deployment target:')
963
1185
  console.log(JSON.stringify(deploymentConfig, null, 2))
964
1186
 
965
- await runRemoteTasks(deploymentConfig)
1187
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1188
+ let snapshotToUse = null
1189
+
1190
+ if (existingSnapshot) {
1191
+ const matchesSelection =
1192
+ existingSnapshot.serverName === deploymentConfig.serverName &&
1193
+ existingSnapshot.branch === deploymentConfig.branch
1194
+
1195
+ const messageLines = [
1196
+ 'Pending deployment tasks were detected from a previous run.',
1197
+ `Server: ${existingSnapshot.serverName}`,
1198
+ `Branch: ${existingSnapshot.branch}`
1199
+ ]
1200
+
1201
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
1202
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
1203
+ }
1204
+
1205
+ const { resumePendingTasks } = await runPrompt([
1206
+ {
1207
+ type: 'confirm',
1208
+ name: 'resumePendingTasks',
1209
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
1210
+ default: matchesSelection
1211
+ }
1212
+ ])
1213
+
1214
+ if (resumePendingTasks) {
1215
+ snapshotToUse = existingSnapshot
1216
+ logProcessing('Resuming deployment using saved task snapshot...')
1217
+ } else {
1218
+ await clearPendingTasksSnapshot(rootDir)
1219
+ logWarning('Discarded pending deployment snapshot.')
1220
+ }
1221
+ }
1222
+
1223
+ let lockAcquired = false
1224
+
1225
+ try {
1226
+ await acquireProjectLock(rootDir)
1227
+ lockAcquired = true
1228
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1229
+ } finally {
1230
+ if (lockAcquired) {
1231
+ await releaseProjectLock(rootDir)
1232
+ }
1233
+ }
966
1234
  }
967
1235
 
968
1236
  export {
969
1237
  ensureGitignoreEntry,
1238
+ ensureProjectReleaseScript,
970
1239
  listSshKeys,
971
1240
  resolveRemotePath,
972
1241
  isPrivateKeyFile,
package/publish.mjs DELETED
@@ -1,222 +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 aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
101
- capture: true
102
- })
103
- const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
104
- capture: true
105
- })
106
-
107
- const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
108
- const behind = Number.parseInt(behindResult.stdout || '0', 10)
109
-
110
- if (Number.isFinite(behind) && behind > 0) {
111
- throw new Error(
112
- `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
113
- )
114
- }
115
-
116
- if (Number.isFinite(ahead) && ahead > 0) {
117
- logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
118
- }
119
- }
120
-
121
- function parseArgs() {
122
- const args = process.argv.slice(2)
123
- const positionals = args.filter((arg) => !arg.startsWith('--'))
124
- const flags = new Set(args.filter((arg) => arg.startsWith('--')))
125
-
126
- const releaseType = positionals[0] ?? 'patch'
127
- const skipTests = flags.has('--skip-tests')
128
-
129
- const allowedTypes = new Set([
130
- 'major',
131
- 'minor',
132
- 'patch',
133
- 'premajor',
134
- 'preminor',
135
- 'prepatch',
136
- 'prerelease'
137
- ])
138
-
139
- if (!allowedTypes.has(releaseType)) {
140
- throw new Error(
141
- `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
142
- )
143
- }
144
-
145
- return { releaseType, skipTests }
146
- }
147
-
148
- async function runTests(skipTests) {
149
- if (skipTests) {
150
- logWarning('Skipping tests because --skip-tests flag was provided.')
151
- return
152
- }
153
-
154
- logStep('Running test suite (vitest run)...')
155
- await runCommand('npx', ['vitest', 'run'])
156
- logSuccess('Tests passed.')
157
- }
158
-
159
- async function ensureNpmAuth() {
160
- logStep('Confirming npm authentication...')
161
- await runCommand('npm', ['whoami'])
162
- }
163
-
164
- async function bumpVersion(releaseType) {
165
- logStep(`Bumping package version with "npm version ${releaseType}"...`)
166
- await runCommand('npm', ['version', releaseType, '--message', 'chore: release %s'])
167
- const pkg = await readPackage()
168
- logSuccess(`Version updated to ${pkg.version}.`)
169
- return pkg
170
- }
171
-
172
- async function pushChanges() {
173
- logStep('Pushing commits and tags to origin...')
174
- await runCommand('git', ['push', '--follow-tags'])
175
- logSuccess('Git push completed.')
176
- }
177
-
178
- async function publishPackage(pkg) {
179
- const publishArgs = ['publish']
180
-
181
- if (pkg.name.startsWith('@')) {
182
- publishArgs.push('--access', 'public')
183
- }
184
-
185
- logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
186
- await runCommand('npm', publishArgs)
187
- logSuccess('npm publish completed.')
188
- }
189
-
190
- async function main() {
191
- const { releaseType, skipTests } = parseArgs()
192
-
193
- logStep('Reading package metadata...')
194
- const pkg = await readPackage()
195
-
196
- logStep('Checking working tree status...')
197
- await ensureCleanWorkingTree()
198
-
199
- const branch = await getCurrentBranch()
200
- if (!branch) {
201
- throw new Error('Unable to determine current branch.')
202
- }
203
-
204
- logStep(`Current branch: ${branch}`)
205
- const upstreamRef = await getUpstreamRef()
206
- await ensureUpToDateWithUpstream(branch, upstreamRef)
207
-
208
- await runTests(skipTests)
209
- await ensureNpmAuth()
210
-
211
- const updatedPkg = await bumpVersion(releaseType)
212
- await pushChanges()
213
- await publishPackage(updatedPkg)
214
-
215
- logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
216
- }
217
-
218
- main().catch((error) => {
219
- console.error('\nRelease failed:')
220
- console.error(error.message)
221
- process.exit(1)
222
- })
@@ -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
- })