@wyxos/zephyr 0.2.2 → 0.2.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.
@@ -0,0 +1,336 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, join } from 'node:path'
4
+ import { readFile, writeFile } from 'node:fs/promises'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import process from 'node:process'
8
+ import semver from 'semver'
9
+
10
+ const STEP_PREFIX = '→'
11
+ const OK_PREFIX = '✔'
12
+ const WARN_PREFIX = '⚠'
13
+
14
+ const IS_WINDOWS = process.platform === 'win32'
15
+
16
+ function logStep(message) {
17
+ console.log(`${STEP_PREFIX} ${message}`)
18
+ }
19
+
20
+ function logSuccess(message) {
21
+ console.log(`${OK_PREFIX} ${message}`)
22
+ }
23
+
24
+ function logWarning(message) {
25
+ console.warn(`${WARN_PREFIX} ${message}`)
26
+ }
27
+
28
+ function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
29
+ return new Promise((resolve, reject) => {
30
+ const spawnOptions = {
31
+ cwd,
32
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
33
+ }
34
+
35
+ if (useShell || (IS_WINDOWS && (command === 'php' || command === 'composer'))) {
36
+ spawnOptions.shell = true
37
+ }
38
+
39
+ const child = spawn(command, args, spawnOptions)
40
+ let stdout = ''
41
+ let stderr = ''
42
+
43
+ if (capture) {
44
+ child.stdout.on('data', (chunk) => {
45
+ stdout += chunk
46
+ })
47
+
48
+ child.stderr.on('data', (chunk) => {
49
+ stderr += chunk
50
+ })
51
+ }
52
+
53
+ child.on('error', reject)
54
+ child.on('close', (code) => {
55
+ if (code === 0) {
56
+ resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
57
+ } else {
58
+ const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
59
+ if (capture) {
60
+ error.stdout = stdout
61
+ error.stderr = stderr
62
+ }
63
+ error.exitCode = code
64
+ reject(error)
65
+ }
66
+ })
67
+ })
68
+ }
69
+
70
+ async function readComposer(rootDir = process.cwd()) {
71
+ const composerPath = join(rootDir, 'composer.json')
72
+ const raw = await readFile(composerPath, 'utf8')
73
+ return JSON.parse(raw)
74
+ }
75
+
76
+ async function writeComposer(rootDir, composer, composerPath = null) {
77
+ const pathToUse = composerPath || join(rootDir, 'composer.json')
78
+ const content = JSON.stringify(composer, null, 2) + '\n'
79
+ await writeFile(pathToUse, content, 'utf8')
80
+ }
81
+
82
+ function hasComposerScript(composer, scriptName) {
83
+ return composer?.scripts?.[scriptName] !== undefined
84
+ }
85
+
86
+ async function hasLaravelPint(rootDir = process.cwd()) {
87
+ const pintPath = join(rootDir, 'vendor', 'bin', 'pint')
88
+ try {
89
+ await fs.promises.access(pintPath)
90
+ const stats = await fs.promises.stat(pintPath)
91
+ return stats.isFile()
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ async function hasArtisan(rootDir = process.cwd()) {
98
+ const artisanPath = join(rootDir, 'artisan')
99
+ try {
100
+ await fs.promises.access(artisanPath)
101
+ const stats = await fs.promises.stat(artisanPath)
102
+ return stats.isFile()
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ async function ensureCleanWorkingTree(rootDir = process.cwd()) {
109
+ const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
110
+
111
+ if (stdout.length > 0) {
112
+ throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
113
+ }
114
+ }
115
+
116
+ async function getCurrentBranch(rootDir = process.cwd()) {
117
+ const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true, cwd: rootDir })
118
+ return stdout || null
119
+ }
120
+
121
+ async function getUpstreamRef(rootDir = process.cwd()) {
122
+ try {
123
+ const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
124
+ capture: true,
125
+ cwd: rootDir
126
+ })
127
+
128
+ return stdout || null
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process.cwd()) {
135
+ if (!upstreamRef) {
136
+ logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
137
+ return
138
+ }
139
+
140
+ const [remoteName, ...branchParts] = upstreamRef.split('/')
141
+ const remoteBranch = branchParts.join('/')
142
+
143
+ if (remoteName && remoteBranch) {
144
+ logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
145
+ try {
146
+ await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir })
147
+ } catch (error) {
148
+ throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
149
+ }
150
+ }
151
+
152
+ const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
153
+ capture: true,
154
+ cwd: rootDir
155
+ })
156
+ const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
157
+ capture: true,
158
+ cwd: rootDir
159
+ })
160
+
161
+ const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
162
+ const behind = Number.parseInt(behindResult.stdout || '0', 10)
163
+
164
+ if (Number.isFinite(behind) && behind > 0) {
165
+ if (remoteName && remoteBranch) {
166
+ logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
167
+
168
+ try {
169
+ await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir })
170
+ } catch (error) {
171
+ throw new Error(
172
+ `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
173
+ )
174
+ }
175
+
176
+ return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
177
+ }
178
+
179
+ throw new Error(
180
+ `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
181
+ )
182
+ }
183
+
184
+ if (Number.isFinite(ahead) && ahead > 0) {
185
+ logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
186
+ }
187
+ }
188
+
189
+ function parseArgs() {
190
+ const args = process.argv.slice(2)
191
+ // Filter out --type flag as it's handled by zephyr CLI
192
+ const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
193
+ const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
194
+ const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
195
+
196
+ const releaseType = positionals[0] ?? 'patch'
197
+ const skipTests = flags.has('--skip-tests')
198
+ const skipLint = flags.has('--skip-lint')
199
+
200
+ const allowedTypes = new Set([
201
+ 'major',
202
+ 'minor',
203
+ 'patch',
204
+ 'premajor',
205
+ 'preminor',
206
+ 'prepatch',
207
+ 'prerelease'
208
+ ])
209
+
210
+ if (!allowedTypes.has(releaseType)) {
211
+ throw new Error(
212
+ `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
213
+ )
214
+ }
215
+
216
+ return { releaseType, skipTests, skipLint }
217
+ }
218
+
219
+ async function runLint(skipLint, rootDir = process.cwd()) {
220
+ if (skipLint) {
221
+ logWarning('Skipping lint because --skip-lint flag was provided.')
222
+ return
223
+ }
224
+
225
+ const hasPint = await hasLaravelPint(rootDir)
226
+ if (!hasPint) {
227
+ logStep('Skipping lint (Laravel Pint not found).')
228
+ return
229
+ }
230
+
231
+ logStep('Running Laravel Pint...')
232
+ const pintPath = IS_WINDOWS ? 'vendor\\bin\\pint' : 'vendor/bin/pint'
233
+ await runCommand('php', [pintPath], { cwd: rootDir })
234
+ logSuccess('Lint passed.')
235
+ }
236
+
237
+ async function runTests(skipTests, composer, rootDir = process.cwd()) {
238
+ if (skipTests) {
239
+ logWarning('Skipping tests because --skip-tests flag was provided.')
240
+ return
241
+ }
242
+
243
+ const hasArtisanFile = await hasArtisan(rootDir)
244
+ const hasTestScript = hasComposerScript(composer, 'test')
245
+
246
+ if (!hasArtisanFile && !hasTestScript) {
247
+ logStep('Skipping tests (no artisan file or test script found).')
248
+ return
249
+ }
250
+
251
+ logStep('Running test suite...')
252
+
253
+ if (hasArtisanFile) {
254
+ await runCommand('php', ['artisan', 'test'], { cwd: rootDir })
255
+ } else if (hasTestScript) {
256
+ await runCommand('composer', ['test'], { cwd: rootDir })
257
+ }
258
+
259
+ logSuccess('Tests passed.')
260
+ }
261
+
262
+ async function bumpVersion(releaseType, rootDir = process.cwd()) {
263
+ logStep(`Bumping composer version...`)
264
+
265
+ const composer = await readComposer(rootDir)
266
+ const currentVersion = composer.version || '0.0.0'
267
+
268
+ if (!semver.valid(currentVersion)) {
269
+ throw new Error(`Invalid current version "${currentVersion}" in composer.json. Must be a valid semver.`)
270
+ }
271
+
272
+ const newVersion = semver.inc(currentVersion, releaseType)
273
+ if (!newVersion) {
274
+ throw new Error(`Failed to calculate next ${releaseType} version from ${currentVersion}`)
275
+ }
276
+
277
+ composer.version = newVersion
278
+ await writeComposer(rootDir, composer)
279
+
280
+ logStep('Staging composer.json...')
281
+ await runCommand('git', ['add', 'composer.json'], { cwd: rootDir })
282
+
283
+ const commitMessage = `chore: release ${newVersion}`
284
+ logStep('Committing version bump...')
285
+ await runCommand('git', ['commit', '-m', commitMessage], { cwd: rootDir })
286
+
287
+ logStep('Creating git tag...')
288
+ await runCommand('git', ['tag', `v${newVersion}`], { cwd: rootDir })
289
+
290
+ logSuccess(`Version updated to ${newVersion}.`)
291
+ return { ...composer, version: newVersion }
292
+ }
293
+
294
+ async function pushChanges(rootDir = process.cwd()) {
295
+ logStep('Pushing commits to origin...')
296
+ await runCommand('git', ['push'], { cwd: rootDir })
297
+
298
+ logStep('Pushing tags to origin...')
299
+ await runCommand('git', ['push', 'origin', '--tags'], { cwd: rootDir })
300
+
301
+ logSuccess('Git push completed.')
302
+ }
303
+
304
+ export async function releasePackagist() {
305
+ const { releaseType, skipTests, skipLint } = parseArgs()
306
+ const rootDir = process.cwd()
307
+
308
+ logStep('Reading composer metadata...')
309
+ const composer = await readComposer(rootDir)
310
+
311
+ if (!composer.version) {
312
+ throw new Error('composer.json does not have a version field. Add "version": "0.0.0" to composer.json.')
313
+ }
314
+
315
+ logStep('Checking working tree status...')
316
+ await ensureCleanWorkingTree(rootDir)
317
+
318
+ const branch = await getCurrentBranch(rootDir)
319
+ if (!branch) {
320
+ throw new Error('Unable to determine current branch.')
321
+ }
322
+
323
+ logStep(`Current branch: ${branch}`)
324
+ const upstreamRef = await getUpstreamRef(rootDir)
325
+ await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
326
+
327
+ await runLint(skipLint, rootDir)
328
+ await runTests(skipTests, composer, rootDir)
329
+
330
+ const updatedComposer = await bumpVersion(releaseType, rootDir)
331
+ await pushChanges(rootDir)
332
+
333
+ logSuccess(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
334
+ logStep('Note: Packagist will automatically detect the new git tag and update the package.')
335
+ }
336
+