@wyxos/zephyr 0.2.31 → 0.3.0

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.
Files changed (33) hide show
  1. package/README.md +55 -2
  2. package/bin/zephyr.mjs +3 -1
  3. package/package.json +7 -2
  4. package/src/application/configuration/app-details.mjs +89 -0
  5. package/src/application/configuration/app-selection.mjs +87 -0
  6. package/src/application/configuration/preset-selection.mjs +59 -0
  7. package/src/application/configuration/select-deployment-target.mjs +165 -0
  8. package/src/application/configuration/server-selection.mjs +87 -0
  9. package/src/application/configuration/service.mjs +109 -0
  10. package/src/application/deploy/build-remote-deployment-plan.mjs +174 -0
  11. package/src/application/deploy/bump-local-package-version.mjs +81 -0
  12. package/src/application/deploy/execute-remote-deployment-plan.mjs +61 -0
  13. package/src/{utils/task-planner.mjs → application/deploy/plan-laravel-deployment-tasks.mjs} +5 -4
  14. package/src/application/deploy/prepare-local-deployment.mjs +52 -0
  15. package/src/application/deploy/resolve-local-deployment-context.mjs +17 -0
  16. package/src/application/deploy/resolve-pending-snapshot.mjs +45 -0
  17. package/src/application/deploy/run-deployment.mjs +147 -0
  18. package/src/application/deploy/run-local-deployment-checks.mjs +80 -0
  19. package/src/application/release/release-node-package.mjs +340 -0
  20. package/src/application/release/release-packagist-package.mjs +223 -0
  21. package/src/config/project.mjs +13 -0
  22. package/src/deploy/local-repo.mjs +187 -67
  23. package/src/deploy/remote-exec.mjs +2 -3
  24. package/src/index.mjs +27 -85
  25. package/src/main.mjs +78 -641
  26. package/src/release/shared.mjs +104 -0
  27. package/src/release-node.mjs +20 -424
  28. package/src/release-packagist.mjs +20 -291
  29. package/src/runtime/app-context.mjs +36 -0
  30. package/src/targets/index.mjs +24 -0
  31. package/src/utils/output.mjs +41 -16
  32. package/src/utils/config-flow.mjs +0 -284
  33. /package/src/{utils/php-version.mjs → infrastructure/php/version.mjs} +0 -0
@@ -0,0 +1,104 @@
1
+ import inquirer from 'inquirer'
2
+ import process from 'node:process'
3
+
4
+ import { validateLocalDependencies } from '../dependency-scanner.mjs'
5
+ import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from '../utils/command.mjs'
6
+ import {
7
+ ensureUpToDateWithUpstream,
8
+ getCurrentBranch,
9
+ getUpstreamRef
10
+ } from '../utils/git.mjs'
11
+
12
+ const RELEASE_TYPES = new Set([
13
+ 'major',
14
+ 'minor',
15
+ 'patch',
16
+ 'premajor',
17
+ 'preminor',
18
+ 'prepatch',
19
+ 'prerelease'
20
+ ])
21
+
22
+ function flagToKey(flag) {
23
+ return flag
24
+ .replace(/^--/, '')
25
+ .replace(/-([a-z])/g, (_match, character) => character.toUpperCase())
26
+ }
27
+
28
+ export function parseReleaseArgs({
29
+ args = process.argv.slice(2),
30
+ booleanFlags = []
31
+ } = {}) {
32
+ const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
33
+ const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
34
+ const presentFlags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
35
+ const releaseType = positionals[0] ?? 'patch'
36
+
37
+ if (!RELEASE_TYPES.has(releaseType)) {
38
+ throw new Error(
39
+ `Invalid release type "${releaseType}". Use one of: ${Array.from(RELEASE_TYPES).join(', ')}.`
40
+ )
41
+ }
42
+
43
+ const parsedFlags = Object.fromEntries(
44
+ booleanFlags.map((flag) => [flagToKey(flag), presentFlags.has(flag)])
45
+ )
46
+
47
+ return { releaseType, ...parsedFlags }
48
+ }
49
+
50
+ export async function runReleaseCommand(command, args, {
51
+ cwd = process.cwd(),
52
+ capture = false
53
+ } = {}) {
54
+ if (capture) {
55
+ const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
56
+ return { stdout: stdout.trim(), stderr: stderr.trim() }
57
+ }
58
+
59
+ await runCommandBase(command, args, { cwd })
60
+ return undefined
61
+ }
62
+
63
+ export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
64
+ runCommand = runReleaseCommand
65
+ } = {}) {
66
+ const { stdout } = await runCommand('git', ['status', '--porcelain'], {
67
+ capture: true,
68
+ cwd: rootDir
69
+ })
70
+
71
+ if (stdout.length > 0) {
72
+ throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
73
+ }
74
+ }
75
+
76
+ export async function validateReleaseDependencies(rootDir = process.cwd(), {
77
+ prompt = (questions) => inquirer.prompt(questions),
78
+ logSuccess
79
+ } = {}) {
80
+ await validateLocalDependencies(rootDir, prompt, logSuccess)
81
+ }
82
+
83
+ export async function ensureReleaseBranchReady({
84
+ rootDir = process.cwd(),
85
+ branchMethod = 'show-current',
86
+ getCurrentBranchImpl = getCurrentBranch,
87
+ getUpstreamRefImpl = getUpstreamRef,
88
+ ensureUpToDateWithUpstreamImpl = ensureUpToDateWithUpstream,
89
+ logStep,
90
+ logWarning
91
+ } = {}) {
92
+ const branch = await getCurrentBranchImpl(rootDir, { method: branchMethod })
93
+
94
+ if (!branch) {
95
+ throw new Error('Unable to determine current branch.')
96
+ }
97
+
98
+ logStep?.(`Current branch: ${branch}`)
99
+
100
+ const upstreamRef = await getUpstreamRefImpl(rootDir)
101
+ await ensureUpToDateWithUpstreamImpl({ branch, upstreamRef, rootDir, logStep, logWarning })
102
+
103
+ return { branch, upstreamRef }
104
+ }
@@ -1,431 +1,27 @@
1
- import { join } from 'node:path'
2
- import { readFile } from 'node:fs/promises'
3
- import fs from 'node:fs'
4
- import path from 'node:path'
5
1
  import process from 'node:process'
6
2
  import chalk from 'chalk'
7
- import inquirer from 'inquirer'
8
- import { validateLocalDependencies } from './dependency-scanner.mjs'
9
- import { writeStderr, writeStderrLine, writeStdoutLine } from './utils/output.mjs'
10
- import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
11
- import { ensureUpToDateWithUpstream, getCurrentBranch, getUpstreamRef } from './utils/git.mjs'
3
+ import {createChalkLogger} from './utils/output.mjs'
4
+ import {
5
+ parseReleaseArgs,
6
+ } from './release/shared.mjs'
7
+ import {releaseNodePackage} from './application/release/release-node-package.mjs'
12
8
 
13
- function logStep(message) {
14
- writeStdoutLine(chalk.yellow(`→ ${message}`))
15
- }
16
-
17
- function logSuccess(message) {
18
- writeStdoutLine(chalk.green(`✔ ${message}`))
19
- }
20
-
21
- function logWarning(message) {
22
- writeStderrLine(chalk.yellow(`⚠ ${message}`))
23
- }
24
-
25
- async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
26
- if (capture) {
27
- const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
28
- return { stdout: stdout.trim(), stderr: stderr.trim() }
29
- }
30
-
31
- await runCommandBase(command, args, { cwd })
32
- return undefined
33
- }
34
-
35
- async function readPackage(rootDir = process.cwd()) {
36
- const packagePath = join(rootDir, 'package.json')
37
- const raw = await readFile(packagePath, 'utf8')
38
- return JSON.parse(raw)
39
- }
40
-
41
- function hasScript(pkg, scriptName) {
42
- return pkg?.scripts?.[scriptName] !== undefined
43
- }
44
-
45
- async function ensureCleanWorkingTree(rootDir = process.cwd()) {
46
- const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
47
-
48
- if (stdout.length > 0) {
49
- throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
50
- }
51
- }
52
-
53
- // Git helpers imported from src/utils/git.mjs
54
-
55
- function parseArgs() {
56
- const args = process.argv.slice(2)
57
- // Filter out --type flag as it's handled by zephyr CLI
58
- const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
59
- const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
60
- const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
61
-
62
- const releaseType = positionals[0] ?? 'patch'
63
- const skipTests = flags.has('--skip-tests')
64
- const skipLint = flags.has('--skip-lint')
65
- const skipBuild = flags.has('--skip-build')
66
- const skipDeploy = flags.has('--skip-deploy')
67
-
68
- const allowedTypes = new Set([
69
- 'major',
70
- 'minor',
71
- 'patch',
72
- 'premajor',
73
- 'preminor',
74
- 'prepatch',
75
- 'prerelease'
76
- ])
77
-
78
- if (!allowedTypes.has(releaseType)) {
79
- throw new Error(
80
- `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
81
- )
82
- }
83
-
84
- return { releaseType, skipTests, skipLint, skipBuild, skipDeploy }
85
- }
86
-
87
- async function runLint(skipLint, pkg, rootDir = process.cwd()) {
88
- if (skipLint) {
89
- logWarning('Skipping lint because --skip-lint flag was provided.')
90
- return
91
- }
92
-
93
- if (!hasScript(pkg, 'lint')) {
94
- logStep('Skipping lint (no lint script found in package.json).')
95
- return
96
- }
97
-
98
- logStep('Running lint...')
99
-
100
- try {
101
- await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
102
- logSuccess('Lint passed.')
103
- } catch (error) {
104
- if (error.stdout) {
105
- writeStderr(error.stdout)
106
- }
107
- if (error.stderr) {
108
- writeStderr(error.stderr)
109
- }
110
- throw error
111
- }
112
- }
113
-
114
- async function runTests(skipTests, pkg, rootDir = process.cwd()) {
115
- if (skipTests) {
116
- logWarning('Skipping tests because --skip-tests flag was provided.')
117
- return
118
- }
119
-
120
- // Check for test:run or test script
121
- if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
122
- logStep('Skipping tests (no test or test:run script found in package.json).')
123
- return
124
- }
125
-
126
- logStep('Running test suite...')
127
-
128
- try {
129
- const testRunScript = pkg?.scripts?.['test:run'] ?? ''
130
- const testScript = pkg?.scripts?.test ?? ''
131
- const usesNodeTest = (script) => /\bnode\b.*\s--test\b/.test(script)
132
-
133
- // Prefer test:run if available, otherwise use test with --run and --reporter flags
134
- if (hasScript(pkg, 'test:run')) {
135
- if (usesNodeTest(testRunScript)) {
136
- await runCommand('npm', ['run', 'test:run'], { cwd: rootDir })
137
- } else {
138
- // Pass reporter flag to test:run script
139
- await runCommand('npm', ['run', 'test:run', '--', '--reporter=dot'], { cwd: rootDir })
140
- }
141
- } else {
142
- if (usesNodeTest(testScript)) {
143
- await runCommand('npm', ['test'], { cwd: rootDir })
144
- } else {
145
- // For test script, pass --run and --reporter flags (works with vitest)
146
- await runCommand('npm', ['test', '--', '--run', '--reporter=dot'], { cwd: rootDir })
147
- }
148
- }
149
-
150
- logSuccess('Tests passed.')
151
- } catch (error) {
152
- if (error.stdout) {
153
- writeStderr(error.stdout)
154
- }
155
- if (error.stderr) {
156
- writeStderr(error.stderr)
157
- }
158
- throw error
159
- }
160
- }
161
-
162
- async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
163
- if (skipBuild) {
164
- logWarning('Skipping build because --skip-build flag was provided.')
165
- return
166
- }
167
-
168
- if (!hasScript(pkg, 'build')) {
169
- logStep('Skipping build (no build script found in package.json).')
170
- return
171
- }
172
-
173
- logStep('Building project...')
174
-
175
- try {
176
- await runCommand('npm', ['run', 'build'], { cwd: rootDir })
177
- logSuccess('Build completed.')
178
- } catch (error) {
179
- if (error.stdout) {
180
- writeStderr(error.stdout)
181
- }
182
- if (error.stderr) {
183
- writeStderr(error.stderr)
184
- }
185
- throw error
186
- }
187
- }
188
-
189
- async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
190
- if (skipBuild) {
191
- logWarning('Skipping library build because --skip-build flag was provided.')
192
- return
193
- }
194
-
195
- if (!hasScript(pkg, 'build:lib')) {
196
- logStep('Skipping library build (no build:lib script found in package.json).')
197
- return false
198
- }
199
-
200
- logStep('Building library...')
201
-
202
- try {
203
- await runCommand('npm', ['run', 'build:lib'], { cwd: rootDir })
204
- logSuccess('Library built.')
205
- } catch (error) {
206
- if (error.stdout) {
207
- writeStderr(error.stdout)
208
- }
209
- if (error.stderr) {
210
- writeStderr(error.stderr)
211
- }
212
- throw error
213
- }
214
-
215
- // Check for lib changes and commit them if any
216
- const { stdout: statusAfterBuild } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
217
- const hasLibChanges = statusAfterBuild.split('\n').some(line => {
218
- const trimmed = line.trim()
219
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
220
- })
221
-
222
- if (hasLibChanges) {
223
- logStep('Committing lib build artifacts...')
224
- await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
225
- await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { capture: true, cwd: rootDir })
226
- logSuccess('Lib build artifacts committed.')
227
- }
228
-
229
- return hasLibChanges
230
- }
231
-
232
- async function bumpVersion(releaseType, rootDir = process.cwd()) {
233
- logStep(`Bumping package version...`)
234
-
235
- // Lib changes should already be committed by runLibBuild, but check anyway
236
- const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
237
- const hasLibChanges = statusBefore.split('\n').some(line => {
238
- const trimmed = line.trim()
239
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
240
- })
241
-
242
- if (hasLibChanges) {
243
- logStep('Stashing lib build artifacts...')
244
- await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { capture: true, cwd: rootDir })
245
- }
246
-
247
- try {
248
- // npm version will update package.json and create a commit with default message
249
- const result = await runCommand('npm', ['version', releaseType], { capture: true, cwd: rootDir })
250
- // Extract version from output (e.g., "v0.2.8" or "0.2.8")
251
- if (result?.stdout) {
252
- const versionMatch = result.stdout.match(/v?(\d+\.\d+\.\d+)/)
253
- if (versionMatch) {
254
- // Version is shown in the logSuccess message below, no need to show it here
255
- }
256
- }
257
- } finally {
258
- // Restore lib changes and ensure they're in the commit
259
- if (hasLibChanges) {
260
- logStep('Restoring lib build artifacts...')
261
- await runCommand('git', ['stash', 'pop'], { capture: true, cwd: rootDir })
262
- await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
263
- const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
264
- if (statusAfter.includes('lib/')) {
265
- await runCommand('git', ['commit', '--amend', '--no-edit'], { capture: true, cwd: rootDir })
266
- }
267
- }
268
- }
269
-
270
- const pkg = await readPackage(rootDir)
271
- const commitMessage = `chore: release ${pkg.version}`
272
-
273
- // Amend the commit message to use our custom format
274
- await runCommand('git', ['commit', '--amend', '-m', commitMessage], { capture: true, cwd: rootDir })
275
-
276
- logSuccess(`Version updated to ${pkg.version}.`)
277
- return pkg
278
- }
279
-
280
- async function pushChanges(rootDir = process.cwd()) {
281
- logStep('Pushing commits and tags to origin...')
282
- try {
283
- await runCommand('git', ['push', '--follow-tags'], { capture: true, cwd: rootDir })
284
- logSuccess('Git push completed.')
285
- } catch (error) {
286
- if (error.stdout) {
287
- writeStderr(error.stdout)
288
- }
289
- if (error.stderr) {
290
- writeStderr(error.stderr)
291
- }
292
- throw error
293
- }
294
- }
295
-
296
- function extractDomainFromHomepage(homepage) {
297
- if (!homepage) return null
298
- try {
299
- const url = new URL(homepage)
300
- return url.hostname
301
- } catch {
302
- // If it's not a valid URL, try to extract domain from string
303
- const match = homepage.match(/(?:https?:\/\/)?([^/]+)/)
304
- return match ? match[1] : null
305
- }
306
- }
307
-
308
- async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
309
- if (skipDeploy) {
310
- logWarning('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
311
- return
312
- }
313
-
314
- // Check if dist directory exists (indicates build output for deployment)
315
- const distPath = path.join(rootDir, 'dist')
316
- let distExists = false
317
- try {
318
- const stats = await fs.promises.stat(distPath)
319
- distExists = stats.isDirectory()
320
- } catch {
321
- distExists = false
322
- }
323
-
324
- if (!distExists) {
325
- logStep('Skipping GitHub Pages deployment (no dist directory found).')
326
- return
327
- }
328
-
329
- logStep('Deploying to GitHub Pages...')
330
-
331
- // Write CNAME file to dist if homepage is set
332
- const cnamePath = path.join(distPath, 'CNAME')
333
-
334
- if (pkg.homepage) {
335
- const domain = extractDomainFromHomepage(pkg.homepage)
336
- if (domain) {
337
- try {
338
- await fs.promises.mkdir(distPath, { recursive: true })
339
- await fs.promises.writeFile(cnamePath, domain)
340
- } catch (error) {
341
- logWarning(`Could not write CNAME file: ${error.message}`)
342
- }
343
- }
344
- }
345
-
346
- const worktreeDir = path.resolve(rootDir, '.gh-pages')
347
-
348
- try {
349
- try {
350
- await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { capture: true, cwd: rootDir })
351
- } catch (_error) {
352
- // Ignore if worktree doesn't exist
353
- }
354
-
355
- try {
356
- await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { capture: true, cwd: rootDir })
357
- } catch {
358
- await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], { capture: true, cwd: rootDir })
359
- }
360
-
361
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'], { capture: true })
362
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'], { capture: true })
363
-
364
- // Clear worktree directory
365
- for (const entry of fs.readdirSync(worktreeDir)) {
366
- if (entry === '.git') continue
367
- const target = path.join(worktreeDir, entry)
368
- fs.rmSync(target, { recursive: true, force: true })
369
- }
370
-
371
- // Copy dist to worktree
372
- fs.cpSync(distPath, worktreeDir, { recursive: true })
373
-
374
- await runCommand('git', ['-C', worktreeDir, 'add', '-A'], { capture: true })
375
- await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'], { capture: true })
376
- await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'], { capture: true })
377
-
378
- logSuccess('GitHub Pages deployment completed.')
379
- } catch (error) {
380
- if (error.stdout) {
381
- writeStderr(error.stdout)
382
- }
383
- if (error.stderr) {
384
- writeStderr(error.stderr)
385
- }
386
- throw error
387
- }
388
- }
9
+ const {logProcessing: logStep, logSuccess, logWarning} = createChalkLogger(chalk)
389
10
 
390
11
  export async function releaseNode() {
391
- try {
392
- const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
12
+ const {releaseType, skipTests, skipLint, skipBuild, skipDeploy} = parseReleaseArgs({
13
+ booleanFlags: ['--skip-tests', '--skip-lint', '--skip-build', '--skip-deploy']
14
+ })
393
15
  const rootDir = process.cwd()
394
-
395
- logStep('Reading package metadata...')
396
- const pkg = await readPackage(rootDir)
397
-
398
- logStep('Validating dependencies...')
399
- await validateLocalDependencies(rootDir, (questions) => inquirer.prompt(questions), logSuccess)
400
-
401
- logStep('Checking working tree status...')
402
- await ensureCleanWorkingTree(rootDir)
403
-
404
- const branch = await getCurrentBranch(rootDir, { method: 'show-current' })
405
- if (!branch) {
406
- throw new Error('Unable to determine current branch.')
407
- }
408
-
409
- logStep(`Current branch: ${branch}`)
410
- const upstreamRef = await getUpstreamRef(rootDir)
411
- await ensureUpToDateWithUpstream({ branch, upstreamRef, rootDir, logStep, logWarning })
412
-
413
- await runLint(skipLint, pkg, rootDir)
414
- await runTests(skipTests, pkg, rootDir)
415
- await runLibBuild(skipBuild, pkg, rootDir)
416
-
417
- const updatedPkg = await bumpVersion(releaseType, rootDir)
418
- await runBuild(skipBuild, updatedPkg, rootDir)
419
- await pushChanges(rootDir)
420
- await deployGHPages(skipDeploy, updatedPkg, rootDir)
421
-
422
- logStep('Publishing will be handled by GitHub Actions via trusted publishing.')
423
-
424
- logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
425
- } catch (error) {
426
- writeStderrLine('\nRelease failed:')
427
- writeStderrLine(error.message)
428
- throw error
429
- }
16
+ await releaseNodePackage({
17
+ releaseType,
18
+ skipTests,
19
+ skipLint,
20
+ skipBuild,
21
+ skipDeploy,
22
+ rootDir,
23
+ logStep,
24
+ logSuccess,
25
+ logWarning
26
+ })
430
27
  }
431
-