@wyxos/zephyr 0.1.4 → 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.4",
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))
@@ -273,6 +277,151 @@ 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
+ 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
+
276
425
  async function ensureGitignoreEntry(rootDir) {
277
426
  const gitignorePath = path.join(rootDir, '.gitignore')
278
427
  const targetEntry = `${PROJECT_CONFIG_DIR}/`
@@ -573,8 +722,10 @@ function resolveRemotePath(projectPath, remoteHome) {
573
722
  return `${sanitizedHome}/${projectPath}`
574
723
  }
575
724
 
576
- async function runRemoteTasks(config) {
577
- 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)
578
729
 
579
730
  const ssh = createSshClient()
580
731
  const sshUser = config.sshUser || os.userInfo().username
@@ -634,7 +785,10 @@ async function runRemoteTasks(config) {
634
785
 
635
786
  let changedFiles = []
636
787
 
637
- if (isLaravel) {
788
+ if (snapshot && snapshot.changedFiles) {
789
+ changedFiles = snapshot.changedFiles
790
+ logProcessing('Resuming deployment with saved task snapshot.')
791
+ } else if (isLaravel) {
638
792
  await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
639
793
 
640
794
  const diffResult = await executeRemote(
@@ -762,6 +916,31 @@ async function runRemoteTasks(config) {
762
916
  })
763
917
  }
764
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
+
765
944
  if (steps.length === 1) {
766
945
  logProcessing('No additional maintenance tasks scheduled beyond git pull.')
767
946
  } else {
@@ -773,8 +952,23 @@ async function runRemoteTasks(config) {
773
952
  logProcessing(`Additional tasks scheduled: ${extraTasks}`)
774
953
  }
775
954
 
776
- for (const step of steps) {
777
- 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
+ }
778
972
  }
779
973
 
780
974
  logSuccess('\nDeployment commands completed successfully.')
@@ -964,6 +1158,7 @@ async function main() {
964
1158
  const rootDir = process.cwd()
965
1159
 
966
1160
  await ensureGitignoreEntry(rootDir)
1161
+ await ensureProjectReleaseScript(rootDir)
967
1162
 
968
1163
  const servers = await loadServers()
969
1164
  const server = await selectServer(servers)
@@ -989,11 +1184,58 @@ async function main() {
989
1184
  logProcessing('\nSelected deployment target:')
990
1185
  console.log(JSON.stringify(deploymentConfig, null, 2))
991
1186
 
992
- 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
+ }
993
1234
  }
994
1235
 
995
1236
  export {
996
1237
  ensureGitignoreEntry,
1238
+ ensureProjectReleaseScript,
997
1239
  listSshKeys,
998
1240
  resolveRemotePath,
999
1241
  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
- })