@wyxos/zephyr 0.1.1 → 0.1.3
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 +4 -3
- package/publish.mjs +222 -0
- package/src/index.mjs +180 -63
- package/tests/index.test.js +62 -5
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyxos/zephyr",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test": "vitest"
|
|
6
|
+
"test": "vitest",
|
|
7
|
+
"release": "node publish.mjs"
|
|
7
8
|
},
|
|
8
9
|
"bin": {
|
|
9
|
-
"zephyr": "
|
|
10
|
+
"zephyr": "bin/zephyr.mjs"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"chalk": "5.3.0",
|
package/publish.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
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/src/index.mjs
CHANGED
|
@@ -6,7 +6,10 @@ import chalk from 'chalk'
|
|
|
6
6
|
import inquirer from 'inquirer'
|
|
7
7
|
import { NodeSSH } from 'node-ssh'
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const PROJECT_CONFIG_DIR = '.zephyr'
|
|
10
|
+
const PROJECT_CONFIG_FILE = 'config.json'
|
|
11
|
+
const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
|
|
12
|
+
const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
|
|
10
13
|
|
|
11
14
|
const logProcessing = (message = '') => console.log(chalk.yellow(message))
|
|
12
15
|
const logSuccess = (message = '') => console.log(chalk.green(message))
|
|
@@ -245,6 +248,7 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
|
|
|
245
248
|
|
|
246
249
|
async function ensureGitignoreEntry(rootDir) {
|
|
247
250
|
const gitignorePath = path.join(rootDir, '.gitignore')
|
|
251
|
+
const targetEntry = `${PROJECT_CONFIG_DIR}/`
|
|
248
252
|
let existingContent = ''
|
|
249
253
|
|
|
250
254
|
try {
|
|
@@ -257,18 +261,18 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
257
261
|
|
|
258
262
|
const hasEntry = existingContent
|
|
259
263
|
.split(/\r?\n/)
|
|
260
|
-
.some((line) => line.trim() ===
|
|
264
|
+
.some((line) => line.trim() === targetEntry)
|
|
261
265
|
|
|
262
266
|
if (hasEntry) {
|
|
263
267
|
return
|
|
264
268
|
}
|
|
265
269
|
|
|
266
270
|
const updatedContent = existingContent
|
|
267
|
-
? `${existingContent.replace(/\s*$/, '')}\n${
|
|
268
|
-
: `${
|
|
271
|
+
? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
|
|
272
|
+
: `${targetEntry}\n`
|
|
269
273
|
|
|
270
274
|
await fs.writeFile(gitignorePath, updatedContent)
|
|
271
|
-
logSuccess('Added
|
|
275
|
+
logSuccess('Added .zephyr/ to .gitignore')
|
|
272
276
|
|
|
273
277
|
let isGitRepo = false
|
|
274
278
|
try {
|
|
@@ -287,7 +291,7 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
287
291
|
|
|
288
292
|
try {
|
|
289
293
|
await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
|
|
290
|
-
await runCommand('git', ['commit', '-m', 'chore: ignore
|
|
294
|
+
await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
|
|
291
295
|
} catch (error) {
|
|
292
296
|
if (error.exitCode === 1) {
|
|
293
297
|
logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
|
|
@@ -297,9 +301,13 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
297
301
|
}
|
|
298
302
|
}
|
|
299
303
|
|
|
300
|
-
async function
|
|
304
|
+
async function ensureDirectory(dirPath) {
|
|
305
|
+
await fs.mkdir(dirPath, { recursive: true })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function loadServers() {
|
|
301
309
|
try {
|
|
302
|
-
const raw = await fs.readFile(
|
|
310
|
+
const raw = await fs.readFile(SERVERS_FILE, 'utf8')
|
|
303
311
|
const data = JSON.parse(raw)
|
|
304
312
|
return Array.isArray(data) ? data : []
|
|
305
313
|
} catch (error) {
|
|
@@ -307,14 +315,45 @@ async function loadReleases(filePath) {
|
|
|
307
315
|
return []
|
|
308
316
|
}
|
|
309
317
|
|
|
310
|
-
logWarning('Failed to read
|
|
318
|
+
logWarning('Failed to read servers.json, starting with an empty list.')
|
|
311
319
|
return []
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
|
|
315
|
-
async function
|
|
316
|
-
|
|
317
|
-
|
|
323
|
+
async function saveServers(servers) {
|
|
324
|
+
await ensureDirectory(GLOBAL_CONFIG_DIR)
|
|
325
|
+
const payload = JSON.stringify(servers, null, 2)
|
|
326
|
+
await fs.writeFile(SERVERS_FILE, `${payload}\n`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getProjectConfigPath(rootDir) {
|
|
330
|
+
return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function loadProjectConfig(rootDir) {
|
|
334
|
+
const configPath = getProjectConfigPath(rootDir)
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const raw = await fs.readFile(configPath, 'utf8')
|
|
338
|
+
const data = JSON.parse(raw)
|
|
339
|
+
return {
|
|
340
|
+
apps: Array.isArray(data?.apps) ? data.apps : []
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error.code === 'ENOENT') {
|
|
344
|
+
return { apps: [] }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
|
|
348
|
+
return { apps: [] }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function saveProjectConfig(rootDir, config) {
|
|
353
|
+
const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
|
|
354
|
+
await ensureDirectory(configDir)
|
|
355
|
+
const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
|
|
356
|
+
await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
|
|
318
357
|
}
|
|
319
358
|
|
|
320
359
|
function defaultProjectPath(currentDir) {
|
|
@@ -719,14 +758,10 @@ async function runRemoteTasks(config) {
|
|
|
719
758
|
}
|
|
720
759
|
}
|
|
721
760
|
|
|
722
|
-
async function
|
|
723
|
-
const branches = await listGitBranches(currentDir)
|
|
724
|
-
const defaultBranch = branches.includes('master') ? 'master' : branches[0]
|
|
761
|
+
async function promptServerDetails(existingServers = []) {
|
|
725
762
|
const defaults = {
|
|
726
|
-
serverName: 'home'
|
|
727
|
-
serverIp: '1.1.1.1'
|
|
728
|
-
projectPath: defaultProjectPath(currentDir),
|
|
729
|
-
branch: defaultBranch
|
|
763
|
+
serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
|
|
764
|
+
serverIp: '1.1.1.1'
|
|
730
765
|
}
|
|
731
766
|
|
|
732
767
|
const answers = await runPrompt([
|
|
@@ -739,19 +774,77 @@ async function collectServerConfig(currentDir) {
|
|
|
739
774
|
{
|
|
740
775
|
type: 'input',
|
|
741
776
|
name: 'serverIp',
|
|
742
|
-
message: 'Server IP',
|
|
777
|
+
message: 'Server IP address',
|
|
743
778
|
default: defaults.serverIp
|
|
744
|
-
}
|
|
779
|
+
}
|
|
780
|
+
])
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
serverName: answers.serverName.trim() || defaults.serverName,
|
|
784
|
+
serverIp: answers.serverIp.trim() || defaults.serverIp
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function selectServer(servers) {
|
|
789
|
+
if (servers.length === 0) {
|
|
790
|
+
logProcessing("No servers configured. Let's create one.")
|
|
791
|
+
const server = await promptServerDetails()
|
|
792
|
+
servers.push(server)
|
|
793
|
+
await saveServers(servers)
|
|
794
|
+
logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
|
|
795
|
+
return server
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const choices = servers.map((server, index) => ({
|
|
799
|
+
name: `${server.serverName} (${server.serverIp})`,
|
|
800
|
+
value: index
|
|
801
|
+
}))
|
|
802
|
+
|
|
803
|
+
choices.push(new inquirer.Separator(), {
|
|
804
|
+
name: '➕ Register a new server',
|
|
805
|
+
value: 'create'
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
const { selection } = await runPrompt([
|
|
809
|
+
{
|
|
810
|
+
type: 'list',
|
|
811
|
+
name: 'selection',
|
|
812
|
+
message: 'Select server or register new',
|
|
813
|
+
choices,
|
|
814
|
+
default: 0
|
|
815
|
+
}
|
|
816
|
+
])
|
|
817
|
+
|
|
818
|
+
if (selection === 'create') {
|
|
819
|
+
const server = await promptServerDetails(servers)
|
|
820
|
+
servers.push(server)
|
|
821
|
+
await saveServers(servers)
|
|
822
|
+
logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
|
|
823
|
+
return server
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return servers[selection]
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function promptAppDetails(currentDir, existing = {}) {
|
|
830
|
+
const branches = await listGitBranches(currentDir)
|
|
831
|
+
const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
|
|
832
|
+
const defaults = {
|
|
833
|
+
projectPath: existing.projectPath || defaultProjectPath(currentDir),
|
|
834
|
+
branch: defaultBranch
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const answers = await runPrompt([
|
|
745
838
|
{
|
|
746
839
|
type: 'input',
|
|
747
840
|
name: 'projectPath',
|
|
748
|
-
message: '
|
|
841
|
+
message: 'Remote project path',
|
|
749
842
|
default: defaults.projectPath
|
|
750
843
|
},
|
|
751
844
|
{
|
|
752
845
|
type: 'list',
|
|
753
846
|
name: 'branchSelection',
|
|
754
|
-
message: 'Branch',
|
|
847
|
+
message: 'Branch to deploy',
|
|
755
848
|
choices: [
|
|
756
849
|
...branches.map((branch) => ({ name: branch, value: branch })),
|
|
757
850
|
new inquirer.Separator(),
|
|
@@ -764,7 +857,7 @@ async function collectServerConfig(currentDir) {
|
|
|
764
857
|
let branch = answers.branchSelection
|
|
765
858
|
|
|
766
859
|
if (branch === '__custom') {
|
|
767
|
-
const { customBranch } = await
|
|
860
|
+
const { customBranch } = await runPrompt([
|
|
768
861
|
{
|
|
769
862
|
type: 'input',
|
|
770
863
|
name: 'customBranch',
|
|
@@ -776,25 +869,41 @@ async function collectServerConfig(currentDir) {
|
|
|
776
869
|
branch = customBranch.trim() || defaults.branch
|
|
777
870
|
}
|
|
778
871
|
|
|
779
|
-
const sshDetails = await promptSshDetails(currentDir)
|
|
872
|
+
const sshDetails = await promptSshDetails(currentDir, existing)
|
|
780
873
|
|
|
781
874
|
return {
|
|
782
|
-
|
|
783
|
-
serverIp: answers.serverIp,
|
|
784
|
-
projectPath: answers.projectPath,
|
|
875
|
+
projectPath: answers.projectPath.trim() || defaults.projectPath,
|
|
785
876
|
branch,
|
|
786
877
|
...sshDetails
|
|
787
878
|
}
|
|
788
879
|
}
|
|
789
880
|
|
|
790
|
-
async function
|
|
791
|
-
const
|
|
792
|
-
|
|
881
|
+
async function selectApp(projectConfig, server, currentDir) {
|
|
882
|
+
const apps = projectConfig.apps ?? []
|
|
883
|
+
const matches = apps
|
|
884
|
+
.map((app, index) => ({ app, index }))
|
|
885
|
+
.filter(({ app }) => app.serverName === server.serverName)
|
|
886
|
+
|
|
887
|
+
if (matches.length === 0) {
|
|
888
|
+
logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
|
|
889
|
+
const appDetails = await promptAppDetails(currentDir)
|
|
890
|
+
const appConfig = {
|
|
891
|
+
serverName: server.serverName,
|
|
892
|
+
...appDetails
|
|
893
|
+
}
|
|
894
|
+
projectConfig.apps.push(appConfig)
|
|
895
|
+
await saveProjectConfig(currentDir, projectConfig)
|
|
896
|
+
logSuccess('Saved deployment configuration to .zephyr/config.json')
|
|
897
|
+
return appConfig
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const choices = matches.map(({ app, index }) => ({
|
|
901
|
+
name: `${app.projectPath} (${app.branch})`,
|
|
793
902
|
value: index
|
|
794
903
|
}))
|
|
795
904
|
|
|
796
905
|
choices.push(new inquirer.Separator(), {
|
|
797
|
-
name: '➕
|
|
906
|
+
name: '➕ Configure new application for this server',
|
|
798
907
|
value: 'create'
|
|
799
908
|
})
|
|
800
909
|
|
|
@@ -802,55 +911,58 @@ async function promptSelection(releases) {
|
|
|
802
911
|
{
|
|
803
912
|
type: 'list',
|
|
804
913
|
name: 'selection',
|
|
805
|
-
message:
|
|
914
|
+
message: `Select application for ${server.serverName}`,
|
|
806
915
|
choices,
|
|
807
916
|
default: 0
|
|
808
917
|
}
|
|
809
918
|
])
|
|
810
919
|
|
|
811
|
-
|
|
920
|
+
if (selection === 'create') {
|
|
921
|
+
const appDetails = await promptAppDetails(currentDir)
|
|
922
|
+
const appConfig = {
|
|
923
|
+
serverName: server.serverName,
|
|
924
|
+
...appDetails
|
|
925
|
+
}
|
|
926
|
+
projectConfig.apps.push(appConfig)
|
|
927
|
+
await saveProjectConfig(currentDir, projectConfig)
|
|
928
|
+
logSuccess('Appended deployment configuration to .zephyr/config.json')
|
|
929
|
+
return appConfig
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const chosen = projectConfig.apps[selection]
|
|
933
|
+
return chosen
|
|
812
934
|
}
|
|
813
935
|
|
|
814
936
|
async function main() {
|
|
815
937
|
const rootDir = process.cwd()
|
|
816
|
-
const releasePath = path.join(rootDir, RELEASE_FILE)
|
|
817
938
|
|
|
818
939
|
await ensureGitignoreEntry(rootDir)
|
|
819
940
|
|
|
820
|
-
const
|
|
941
|
+
const servers = await loadServers()
|
|
942
|
+
const server = await selectServer(servers)
|
|
943
|
+
const projectConfig = await loadProjectConfig(rootDir)
|
|
944
|
+
const appConfig = await selectApp(projectConfig, server, rootDir)
|
|
821
945
|
|
|
822
|
-
|
|
823
|
-
logProcessing("No deployment targets found. Let's create one.")
|
|
824
|
-
const config = await collectServerConfig(rootDir)
|
|
825
|
-
releases.push(config)
|
|
826
|
-
await saveReleases(releasePath, releases)
|
|
827
|
-
logSuccess('Saved deployment configuration to release.json')
|
|
828
|
-
await runRemoteTasks(config)
|
|
829
|
-
return
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const selection = await promptSelection(releases)
|
|
946
|
+
const updated = await ensureSshDetails(appConfig, rootDir)
|
|
833
947
|
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
await saveReleases(releasePath, releases)
|
|
838
|
-
logSuccess('Appended new deployment configuration to release.json')
|
|
839
|
-
await runRemoteTasks(config)
|
|
840
|
-
return
|
|
948
|
+
if (updated) {
|
|
949
|
+
await saveProjectConfig(rootDir, projectConfig)
|
|
950
|
+
logSuccess('Updated .zephyr/config.json with SSH details.')
|
|
841
951
|
}
|
|
842
952
|
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
953
|
+
const deploymentConfig = {
|
|
954
|
+
serverName: server.serverName,
|
|
955
|
+
serverIp: server.serverIp,
|
|
956
|
+
projectPath: appConfig.projectPath,
|
|
957
|
+
branch: appConfig.branch,
|
|
958
|
+
sshUser: appConfig.sshUser,
|
|
959
|
+
sshKey: appConfig.sshKey
|
|
849
960
|
}
|
|
961
|
+
|
|
850
962
|
logProcessing('\nSelected deployment target:')
|
|
851
|
-
console.log(JSON.stringify(
|
|
963
|
+
console.log(JSON.stringify(deploymentConfig, null, 2))
|
|
852
964
|
|
|
853
|
-
await runRemoteTasks(
|
|
965
|
+
await runRemoteTasks(deploymentConfig)
|
|
854
966
|
}
|
|
855
967
|
|
|
856
968
|
export {
|
|
@@ -859,9 +971,14 @@ export {
|
|
|
859
971
|
resolveRemotePath,
|
|
860
972
|
isPrivateKeyFile,
|
|
861
973
|
runRemoteTasks,
|
|
862
|
-
|
|
974
|
+
promptServerDetails,
|
|
975
|
+
selectServer,
|
|
976
|
+
promptAppDetails,
|
|
977
|
+
selectApp,
|
|
863
978
|
promptSshDetails,
|
|
864
979
|
ensureSshDetails,
|
|
865
980
|
ensureLocalRepositoryState,
|
|
981
|
+
loadServers,
|
|
982
|
+
loadProjectConfig,
|
|
866
983
|
main
|
|
867
984
|
}
|
package/tests/index.test.js
CHANGED
|
@@ -4,6 +4,7 @@ const mockReadFile = vi.fn()
|
|
|
4
4
|
const mockReaddir = vi.fn()
|
|
5
5
|
const mockAccess = vi.fn()
|
|
6
6
|
const mockWriteFile = vi.fn()
|
|
7
|
+
const mockMkdir = vi.fn()
|
|
7
8
|
const mockExecCommand = vi.fn()
|
|
8
9
|
const mockConnect = vi.fn()
|
|
9
10
|
const mockDispose = vi.fn()
|
|
@@ -14,12 +15,14 @@ vi.mock('node:fs/promises', () => ({
|
|
|
14
15
|
readFile: mockReadFile,
|
|
15
16
|
readdir: mockReaddir,
|
|
16
17
|
access: mockAccess,
|
|
17
|
-
writeFile: mockWriteFile
|
|
18
|
+
writeFile: mockWriteFile,
|
|
19
|
+
mkdir: mockMkdir
|
|
18
20
|
},
|
|
19
21
|
readFile: mockReadFile,
|
|
20
22
|
readdir: mockReaddir,
|
|
21
23
|
access: mockAccess,
|
|
22
|
-
writeFile: mockWriteFile
|
|
24
|
+
writeFile: mockWriteFile,
|
|
25
|
+
mkdir: mockMkdir
|
|
23
26
|
}))
|
|
24
27
|
|
|
25
28
|
const spawnQueue = []
|
|
@@ -90,11 +93,18 @@ vi.mock('node:child_process', () => ({
|
|
|
90
93
|
}
|
|
91
94
|
}))
|
|
92
95
|
|
|
93
|
-
vi.mock('inquirer', () =>
|
|
94
|
-
|
|
96
|
+
vi.mock('inquirer', () => {
|
|
97
|
+
class Separator {}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
default: {
|
|
101
|
+
prompt: mockPrompt,
|
|
102
|
+
Separator
|
|
103
|
+
},
|
|
104
|
+
Separator,
|
|
95
105
|
prompt: mockPrompt
|
|
96
106
|
}
|
|
97
|
-
})
|
|
107
|
+
})
|
|
98
108
|
|
|
99
109
|
vi.mock('node-ssh', () => ({
|
|
100
110
|
NodeSSH: vi.fn(() => ({
|
|
@@ -122,6 +132,7 @@ describe('zephyr deployment helpers', () => {
|
|
|
122
132
|
mockReaddir.mockReset()
|
|
123
133
|
mockAccess.mockReset()
|
|
124
134
|
mockWriteFile.mockReset()
|
|
135
|
+
mockMkdir.mockReset()
|
|
125
136
|
mockExecCommand.mockReset()
|
|
126
137
|
mockConnect.mockReset()
|
|
127
138
|
mockDispose.mockReset()
|
|
@@ -196,6 +207,52 @@ describe('zephyr deployment helpers', () => {
|
|
|
196
207
|
})
|
|
197
208
|
})
|
|
198
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
|
+
|
|
199
256
|
it('schedules Laravel tasks based on diff', async () => {
|
|
200
257
|
queueSpawnResponse({ stdout: 'main\n' })
|
|
201
258
|
queueSpawnResponse({ stdout: '' })
|