@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 +31 -3
- package/src/index.mjs +289 -8
- package/.github/copilot-instructions.md +0 -35
- package/publish.mjs +0 -248
- package/tests/index.test.js +0 -426
package/package.json
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyxos/zephyr",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
10
|
-
"
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
777
|
-
|
|
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
|
|
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
|
-
})
|
package/tests/index.test.js
DELETED
|
@@ -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
|
-
})
|