@wyxos/zephyr 0.1.2 → 0.1.4
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/.github/copilot-instructions.md +35 -0
- package/package.json +1 -1
- package/publish.mjs +26 -0
- package/src/index.mjs +207 -63
- package/tests/index.test.js +62 -5
|
@@ -0,0 +1,35 @@
|
|
|
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/package.json
CHANGED
package/publish.mjs
CHANGED
|
@@ -97,6 +97,18 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef) {
|
|
|
97
97
|
return
|
|
98
98
|
}
|
|
99
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
|
+
|
|
100
112
|
const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
|
|
101
113
|
capture: true
|
|
102
114
|
})
|
|
@@ -108,6 +120,20 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef) {
|
|
|
108
120
|
const behind = Number.parseInt(behindResult.stdout || '0', 10)
|
|
109
121
|
|
|
110
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
|
+
|
|
111
137
|
throw new Error(
|
|
112
138
|
`Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
|
|
113
139
|
)
|
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))
|
|
@@ -194,6 +197,33 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
|
|
|
194
197
|
const initialStatus = await getGitStatus(rootDir)
|
|
195
198
|
const hasPendingChanges = initialStatus.length > 0
|
|
196
199
|
|
|
200
|
+
const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
|
|
201
|
+
cwd: rootDir
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const lines = statusReport.split(/\r?\n/)
|
|
205
|
+
const branchLine = lines[0] || ''
|
|
206
|
+
const aheadMatch = branchLine.match(/ahead (\d+)/)
|
|
207
|
+
const behindMatch = branchLine.match(/behind (\d+)/)
|
|
208
|
+
const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
|
|
209
|
+
const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
|
|
210
|
+
|
|
211
|
+
if (aheadCount > 0) {
|
|
212
|
+
logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (behindCount > 0) {
|
|
216
|
+
logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
|
|
217
|
+
try {
|
|
218
|
+
await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
|
|
219
|
+
logSuccess('Local branch fast-forwarded with upstream changes.')
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
if (currentBranch !== targetBranch) {
|
|
198
228
|
if (hasPendingChanges) {
|
|
199
229
|
throw new Error(
|
|
@@ -245,6 +275,7 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
|
|
|
245
275
|
|
|
246
276
|
async function ensureGitignoreEntry(rootDir) {
|
|
247
277
|
const gitignorePath = path.join(rootDir, '.gitignore')
|
|
278
|
+
const targetEntry = `${PROJECT_CONFIG_DIR}/`
|
|
248
279
|
let existingContent = ''
|
|
249
280
|
|
|
250
281
|
try {
|
|
@@ -257,18 +288,18 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
257
288
|
|
|
258
289
|
const hasEntry = existingContent
|
|
259
290
|
.split(/\r?\n/)
|
|
260
|
-
.some((line) => line.trim() ===
|
|
291
|
+
.some((line) => line.trim() === targetEntry)
|
|
261
292
|
|
|
262
293
|
if (hasEntry) {
|
|
263
294
|
return
|
|
264
295
|
}
|
|
265
296
|
|
|
266
297
|
const updatedContent = existingContent
|
|
267
|
-
? `${existingContent.replace(/\s*$/, '')}\n${
|
|
268
|
-
: `${
|
|
298
|
+
? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
|
|
299
|
+
: `${targetEntry}\n`
|
|
269
300
|
|
|
270
301
|
await fs.writeFile(gitignorePath, updatedContent)
|
|
271
|
-
logSuccess('Added
|
|
302
|
+
logSuccess('Added .zephyr/ to .gitignore')
|
|
272
303
|
|
|
273
304
|
let isGitRepo = false
|
|
274
305
|
try {
|
|
@@ -287,7 +318,7 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
287
318
|
|
|
288
319
|
try {
|
|
289
320
|
await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
|
|
290
|
-
await runCommand('git', ['commit', '-m', 'chore: ignore
|
|
321
|
+
await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
|
|
291
322
|
} catch (error) {
|
|
292
323
|
if (error.exitCode === 1) {
|
|
293
324
|
logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
|
|
@@ -297,9 +328,13 @@ async function ensureGitignoreEntry(rootDir) {
|
|
|
297
328
|
}
|
|
298
329
|
}
|
|
299
330
|
|
|
300
|
-
async function
|
|
331
|
+
async function ensureDirectory(dirPath) {
|
|
332
|
+
await fs.mkdir(dirPath, { recursive: true })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadServers() {
|
|
301
336
|
try {
|
|
302
|
-
const raw = await fs.readFile(
|
|
337
|
+
const raw = await fs.readFile(SERVERS_FILE, 'utf8')
|
|
303
338
|
const data = JSON.parse(raw)
|
|
304
339
|
return Array.isArray(data) ? data : []
|
|
305
340
|
} catch (error) {
|
|
@@ -307,14 +342,45 @@ async function loadReleases(filePath) {
|
|
|
307
342
|
return []
|
|
308
343
|
}
|
|
309
344
|
|
|
310
|
-
logWarning('Failed to read
|
|
345
|
+
logWarning('Failed to read servers.json, starting with an empty list.')
|
|
311
346
|
return []
|
|
312
347
|
}
|
|
313
348
|
}
|
|
314
349
|
|
|
315
|
-
async function
|
|
316
|
-
|
|
317
|
-
|
|
350
|
+
async function saveServers(servers) {
|
|
351
|
+
await ensureDirectory(GLOBAL_CONFIG_DIR)
|
|
352
|
+
const payload = JSON.stringify(servers, null, 2)
|
|
353
|
+
await fs.writeFile(SERVERS_FILE, `${payload}\n`)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getProjectConfigPath(rootDir) {
|
|
357
|
+
return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function loadProjectConfig(rootDir) {
|
|
361
|
+
const configPath = getProjectConfigPath(rootDir)
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const raw = await fs.readFile(configPath, 'utf8')
|
|
365
|
+
const data = JSON.parse(raw)
|
|
366
|
+
return {
|
|
367
|
+
apps: Array.isArray(data?.apps) ? data.apps : []
|
|
368
|
+
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code === 'ENOENT') {
|
|
371
|
+
return { apps: [] }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
|
|
375
|
+
return { apps: [] }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function saveProjectConfig(rootDir, config) {
|
|
380
|
+
const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
|
|
381
|
+
await ensureDirectory(configDir)
|
|
382
|
+
const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
|
|
383
|
+
await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
|
|
318
384
|
}
|
|
319
385
|
|
|
320
386
|
function defaultProjectPath(currentDir) {
|
|
@@ -719,14 +785,10 @@ async function runRemoteTasks(config) {
|
|
|
719
785
|
}
|
|
720
786
|
}
|
|
721
787
|
|
|
722
|
-
async function
|
|
723
|
-
const branches = await listGitBranches(currentDir)
|
|
724
|
-
const defaultBranch = branches.includes('master') ? 'master' : branches[0]
|
|
788
|
+
async function promptServerDetails(existingServers = []) {
|
|
725
789
|
const defaults = {
|
|
726
|
-
serverName: 'home'
|
|
727
|
-
serverIp: '1.1.1.1'
|
|
728
|
-
projectPath: defaultProjectPath(currentDir),
|
|
729
|
-
branch: defaultBranch
|
|
790
|
+
serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
|
|
791
|
+
serverIp: '1.1.1.1'
|
|
730
792
|
}
|
|
731
793
|
|
|
732
794
|
const answers = await runPrompt([
|
|
@@ -739,19 +801,77 @@ async function collectServerConfig(currentDir) {
|
|
|
739
801
|
{
|
|
740
802
|
type: 'input',
|
|
741
803
|
name: 'serverIp',
|
|
742
|
-
message: 'Server IP',
|
|
804
|
+
message: 'Server IP address',
|
|
743
805
|
default: defaults.serverIp
|
|
744
|
-
}
|
|
806
|
+
}
|
|
807
|
+
])
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
serverName: answers.serverName.trim() || defaults.serverName,
|
|
811
|
+
serverIp: answers.serverIp.trim() || defaults.serverIp
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function selectServer(servers) {
|
|
816
|
+
if (servers.length === 0) {
|
|
817
|
+
logProcessing("No servers configured. Let's create one.")
|
|
818
|
+
const server = await promptServerDetails()
|
|
819
|
+
servers.push(server)
|
|
820
|
+
await saveServers(servers)
|
|
821
|
+
logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
|
|
822
|
+
return server
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const choices = servers.map((server, index) => ({
|
|
826
|
+
name: `${server.serverName} (${server.serverIp})`,
|
|
827
|
+
value: index
|
|
828
|
+
}))
|
|
829
|
+
|
|
830
|
+
choices.push(new inquirer.Separator(), {
|
|
831
|
+
name: '➕ Register a new server',
|
|
832
|
+
value: 'create'
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
const { selection } = await runPrompt([
|
|
836
|
+
{
|
|
837
|
+
type: 'list',
|
|
838
|
+
name: 'selection',
|
|
839
|
+
message: 'Select server or register new',
|
|
840
|
+
choices,
|
|
841
|
+
default: 0
|
|
842
|
+
}
|
|
843
|
+
])
|
|
844
|
+
|
|
845
|
+
if (selection === 'create') {
|
|
846
|
+
const server = await promptServerDetails(servers)
|
|
847
|
+
servers.push(server)
|
|
848
|
+
await saveServers(servers)
|
|
849
|
+
logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
|
|
850
|
+
return server
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return servers[selection]
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function promptAppDetails(currentDir, existing = {}) {
|
|
857
|
+
const branches = await listGitBranches(currentDir)
|
|
858
|
+
const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
|
|
859
|
+
const defaults = {
|
|
860
|
+
projectPath: existing.projectPath || defaultProjectPath(currentDir),
|
|
861
|
+
branch: defaultBranch
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const answers = await runPrompt([
|
|
745
865
|
{
|
|
746
866
|
type: 'input',
|
|
747
867
|
name: 'projectPath',
|
|
748
|
-
message: '
|
|
868
|
+
message: 'Remote project path',
|
|
749
869
|
default: defaults.projectPath
|
|
750
870
|
},
|
|
751
871
|
{
|
|
752
872
|
type: 'list',
|
|
753
873
|
name: 'branchSelection',
|
|
754
|
-
message: 'Branch',
|
|
874
|
+
message: 'Branch to deploy',
|
|
755
875
|
choices: [
|
|
756
876
|
...branches.map((branch) => ({ name: branch, value: branch })),
|
|
757
877
|
new inquirer.Separator(),
|
|
@@ -764,7 +884,7 @@ async function collectServerConfig(currentDir) {
|
|
|
764
884
|
let branch = answers.branchSelection
|
|
765
885
|
|
|
766
886
|
if (branch === '__custom') {
|
|
767
|
-
const { customBranch } = await
|
|
887
|
+
const { customBranch } = await runPrompt([
|
|
768
888
|
{
|
|
769
889
|
type: 'input',
|
|
770
890
|
name: 'customBranch',
|
|
@@ -776,25 +896,41 @@ async function collectServerConfig(currentDir) {
|
|
|
776
896
|
branch = customBranch.trim() || defaults.branch
|
|
777
897
|
}
|
|
778
898
|
|
|
779
|
-
const sshDetails = await promptSshDetails(currentDir)
|
|
899
|
+
const sshDetails = await promptSshDetails(currentDir, existing)
|
|
780
900
|
|
|
781
901
|
return {
|
|
782
|
-
|
|
783
|
-
serverIp: answers.serverIp,
|
|
784
|
-
projectPath: answers.projectPath,
|
|
902
|
+
projectPath: answers.projectPath.trim() || defaults.projectPath,
|
|
785
903
|
branch,
|
|
786
904
|
...sshDetails
|
|
787
905
|
}
|
|
788
906
|
}
|
|
789
907
|
|
|
790
|
-
async function
|
|
791
|
-
const
|
|
792
|
-
|
|
908
|
+
async function selectApp(projectConfig, server, currentDir) {
|
|
909
|
+
const apps = projectConfig.apps ?? []
|
|
910
|
+
const matches = apps
|
|
911
|
+
.map((app, index) => ({ app, index }))
|
|
912
|
+
.filter(({ app }) => app.serverName === server.serverName)
|
|
913
|
+
|
|
914
|
+
if (matches.length === 0) {
|
|
915
|
+
logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
|
|
916
|
+
const appDetails = await promptAppDetails(currentDir)
|
|
917
|
+
const appConfig = {
|
|
918
|
+
serverName: server.serverName,
|
|
919
|
+
...appDetails
|
|
920
|
+
}
|
|
921
|
+
projectConfig.apps.push(appConfig)
|
|
922
|
+
await saveProjectConfig(currentDir, projectConfig)
|
|
923
|
+
logSuccess('Saved deployment configuration to .zephyr/config.json')
|
|
924
|
+
return appConfig
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const choices = matches.map(({ app, index }) => ({
|
|
928
|
+
name: `${app.projectPath} (${app.branch})`,
|
|
793
929
|
value: index
|
|
794
930
|
}))
|
|
795
931
|
|
|
796
932
|
choices.push(new inquirer.Separator(), {
|
|
797
|
-
name: '➕
|
|
933
|
+
name: '➕ Configure new application for this server',
|
|
798
934
|
value: 'create'
|
|
799
935
|
})
|
|
800
936
|
|
|
@@ -802,55 +938,58 @@ async function promptSelection(releases) {
|
|
|
802
938
|
{
|
|
803
939
|
type: 'list',
|
|
804
940
|
name: 'selection',
|
|
805
|
-
message:
|
|
941
|
+
message: `Select application for ${server.serverName}`,
|
|
806
942
|
choices,
|
|
807
943
|
default: 0
|
|
808
944
|
}
|
|
809
945
|
])
|
|
810
946
|
|
|
811
|
-
|
|
947
|
+
if (selection === 'create') {
|
|
948
|
+
const appDetails = await promptAppDetails(currentDir)
|
|
949
|
+
const appConfig = {
|
|
950
|
+
serverName: server.serverName,
|
|
951
|
+
...appDetails
|
|
952
|
+
}
|
|
953
|
+
projectConfig.apps.push(appConfig)
|
|
954
|
+
await saveProjectConfig(currentDir, projectConfig)
|
|
955
|
+
logSuccess('Appended deployment configuration to .zephyr/config.json')
|
|
956
|
+
return appConfig
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const chosen = projectConfig.apps[selection]
|
|
960
|
+
return chosen
|
|
812
961
|
}
|
|
813
962
|
|
|
814
963
|
async function main() {
|
|
815
964
|
const rootDir = process.cwd()
|
|
816
|
-
const releasePath = path.join(rootDir, RELEASE_FILE)
|
|
817
965
|
|
|
818
966
|
await ensureGitignoreEntry(rootDir)
|
|
819
967
|
|
|
820
|
-
const
|
|
968
|
+
const servers = await loadServers()
|
|
969
|
+
const server = await selectServer(servers)
|
|
970
|
+
const projectConfig = await loadProjectConfig(rootDir)
|
|
971
|
+
const appConfig = await selectApp(projectConfig, server, rootDir)
|
|
821
972
|
|
|
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
|
-
}
|
|
973
|
+
const updated = await ensureSshDetails(appConfig, rootDir)
|
|
831
974
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
const config = await collectServerConfig(rootDir)
|
|
836
|
-
releases.push(config)
|
|
837
|
-
await saveReleases(releasePath, releases)
|
|
838
|
-
logSuccess('Appended new deployment configuration to release.json')
|
|
839
|
-
await runRemoteTasks(config)
|
|
840
|
-
return
|
|
975
|
+
if (updated) {
|
|
976
|
+
await saveProjectConfig(rootDir, projectConfig)
|
|
977
|
+
logSuccess('Updated .zephyr/config.json with SSH details.')
|
|
841
978
|
}
|
|
842
979
|
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
980
|
+
const deploymentConfig = {
|
|
981
|
+
serverName: server.serverName,
|
|
982
|
+
serverIp: server.serverIp,
|
|
983
|
+
projectPath: appConfig.projectPath,
|
|
984
|
+
branch: appConfig.branch,
|
|
985
|
+
sshUser: appConfig.sshUser,
|
|
986
|
+
sshKey: appConfig.sshKey
|
|
849
987
|
}
|
|
988
|
+
|
|
850
989
|
logProcessing('\nSelected deployment target:')
|
|
851
|
-
console.log(JSON.stringify(
|
|
990
|
+
console.log(JSON.stringify(deploymentConfig, null, 2))
|
|
852
991
|
|
|
853
|
-
await runRemoteTasks(
|
|
992
|
+
await runRemoteTasks(deploymentConfig)
|
|
854
993
|
}
|
|
855
994
|
|
|
856
995
|
export {
|
|
@@ -859,9 +998,14 @@ export {
|
|
|
859
998
|
resolveRemotePath,
|
|
860
999
|
isPrivateKeyFile,
|
|
861
1000
|
runRemoteTasks,
|
|
862
|
-
|
|
1001
|
+
promptServerDetails,
|
|
1002
|
+
selectServer,
|
|
1003
|
+
promptAppDetails,
|
|
1004
|
+
selectApp,
|
|
863
1005
|
promptSshDetails,
|
|
864
1006
|
ensureSshDetails,
|
|
865
1007
|
ensureLocalRepositoryState,
|
|
1008
|
+
loadServers,
|
|
1009
|
+
loadProjectConfig,
|
|
866
1010
|
main
|
|
867
1011
|
}
|
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: '' })
|