@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 +31 -3
- package/src/index.mjs +275 -6
- package/publish.mjs +0 -222
- 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.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
|
-
"
|
|
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))
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
750
|
-
|
|
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
|
|
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
|
-
})
|
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
|
-
})
|