@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.
@@ -1,462 +1,462 @@
1
- import { spawn } from 'node:child_process'
2
- import { fileURLToPath } from 'node:url'
3
- import { dirname, join } from 'node:path'
4
- import { readFile } from 'node:fs/promises'
5
- import fs from 'node:fs'
6
- import path from 'node:path'
7
- import process from 'node:process'
8
-
9
- const STEP_PREFIX = '→'
10
- const OK_PREFIX = '✔'
11
- const WARN_PREFIX = '⚠'
12
-
13
- const IS_WINDOWS = process.platform === 'win32'
14
-
15
- function logStep(message) {
16
- console.log(`${STEP_PREFIX} ${message}`)
17
- }
18
-
19
- function logSuccess(message) {
20
- console.log(`${OK_PREFIX} ${message}`)
21
- }
22
-
23
- function logWarning(message) {
24
- console.warn(`${WARN_PREFIX} ${message}`)
25
- }
26
-
27
- function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
28
- return new Promise((resolve, reject) => {
29
- // On Windows, npm-related commands need shell: true to resolve npx.cmd
30
- // Git commands work fine without shell, so we only use it when explicitly requested
31
- const spawnOptions = {
32
- cwd,
33
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
34
- }
35
-
36
- if (useShell || (IS_WINDOWS && (command === 'npm' || command === 'npx'))) {
37
- spawnOptions.shell = true
38
- }
39
-
40
- const child = spawn(command, args, spawnOptions)
41
- let stdout = ''
42
- let stderr = ''
43
-
44
- if (capture) {
45
- child.stdout.on('data', (chunk) => {
46
- stdout += chunk
47
- })
48
-
49
- child.stderr.on('data', (chunk) => {
50
- stderr += chunk
51
- })
52
- }
53
-
54
- child.on('error', reject)
55
- child.on('close', (code) => {
56
- if (code === 0) {
57
- resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
58
- } else {
59
- const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
60
- if (capture) {
61
- error.stdout = stdout
62
- error.stderr = stderr
63
- }
64
- error.exitCode = code
65
- reject(error)
66
- }
67
- })
68
- })
69
- }
70
-
71
- async function readPackage(rootDir = process.cwd()) {
72
- const packagePath = join(rootDir, 'package.json')
73
- const raw = await readFile(packagePath, 'utf8')
74
- return JSON.parse(raw)
75
- }
76
-
77
- function hasScript(pkg, scriptName) {
78
- return pkg?.scripts?.[scriptName] !== undefined
79
- }
80
-
81
- async function ensureCleanWorkingTree(rootDir = process.cwd()) {
82
- const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
83
-
84
- if (stdout.length > 0) {
85
- throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
86
- }
87
- }
88
-
89
- async function getCurrentBranch(rootDir = process.cwd()) {
90
- const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true, cwd: rootDir })
91
- return stdout || null
92
- }
93
-
94
- async function getUpstreamRef(rootDir = process.cwd()) {
95
- try {
96
- const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
97
- capture: true,
98
- cwd: rootDir
99
- })
100
-
101
- return stdout || null
102
- } catch {
103
- return null
104
- }
105
- }
106
-
107
- async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process.cwd()) {
108
- if (!upstreamRef) {
109
- logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
110
- return
111
- }
112
-
113
- const [remoteName, ...branchParts] = upstreamRef.split('/')
114
- const remoteBranch = branchParts.join('/')
115
-
116
- if (remoteName && remoteBranch) {
117
- logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
118
- try {
119
- await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir })
120
- } catch (error) {
121
- throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
122
- }
123
- }
124
-
125
- const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
126
- capture: true,
127
- cwd: rootDir
128
- })
129
- const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
130
- capture: true,
131
- cwd: rootDir
132
- })
133
-
134
- const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
135
- const behind = Number.parseInt(behindResult.stdout || '0', 10)
136
-
137
- if (Number.isFinite(behind) && behind > 0) {
138
- if (remoteName && remoteBranch) {
139
- logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
140
-
141
- try {
142
- await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir })
143
- } catch (error) {
144
- throw new Error(
145
- `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
146
- )
147
- }
148
-
149
- return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
150
- }
151
-
152
- throw new Error(
153
- `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
154
- )
155
- }
156
-
157
- if (Number.isFinite(ahead) && ahead > 0) {
158
- logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
159
- }
160
- }
161
-
162
- function parseArgs() {
163
- const args = process.argv.slice(2)
164
- // Filter out --type flag as it's handled by zephyr CLI
165
- const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
166
- const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
167
- const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
168
-
169
- const releaseType = positionals[0] ?? 'patch'
170
- const skipTests = flags.has('--skip-tests')
171
- const skipLint = flags.has('--skip-lint')
172
- const skipBuild = flags.has('--skip-build')
173
- const skipDeploy = flags.has('--skip-deploy')
174
-
175
- const allowedTypes = new Set([
176
- 'major',
177
- 'minor',
178
- 'patch',
179
- 'premajor',
180
- 'preminor',
181
- 'prepatch',
182
- 'prerelease'
183
- ])
184
-
185
- if (!allowedTypes.has(releaseType)) {
186
- throw new Error(
187
- `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
188
- )
189
- }
190
-
191
- return { releaseType, skipTests, skipLint, skipBuild, skipDeploy }
192
- }
193
-
194
- async function runLint(skipLint, pkg, rootDir = process.cwd()) {
195
- if (skipLint) {
196
- logWarning('Skipping lint because --skip-lint flag was provided.')
197
- return
198
- }
199
-
200
- if (!hasScript(pkg, 'lint')) {
201
- logStep('Skipping lint (no lint script found in package.json).')
202
- return
203
- }
204
-
205
- logStep('Running lint...')
206
- await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
207
- logSuccess('Lint passed.')
208
- }
209
-
210
- async function runTests(skipTests, pkg, rootDir = process.cwd()) {
211
- if (skipTests) {
212
- logWarning('Skipping tests because --skip-tests flag was provided.')
213
- return
214
- }
215
-
216
- // Check for test:run or test script
217
- if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
218
- logStep('Skipping tests (no test or test:run script found in package.json).')
219
- return
220
- }
221
-
222
- logStep('Running test suite...')
223
-
224
- // Prefer test:run if available, otherwise use test with --run flag
225
- if (hasScript(pkg, 'test:run')) {
226
- await runCommand('npm', ['run', 'test:run'], { cwd: rootDir })
227
- } else {
228
- // For test script, try to pass --run flag (works with vitest)
229
- await runCommand('npm', ['test', '--', '--run'], { cwd: rootDir })
230
- }
231
-
232
- logSuccess('Tests passed.')
233
- }
234
-
235
- async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
236
- if (skipBuild) {
237
- logWarning('Skipping build because --skip-build flag was provided.')
238
- return
239
- }
240
-
241
- if (!hasScript(pkg, 'build')) {
242
- logStep('Skipping build (no build script found in package.json).')
243
- return
244
- }
245
-
246
- logStep('Building project...')
247
- await runCommand('npm', ['run', 'build'], { cwd: rootDir })
248
- logSuccess('Build completed.')
249
- }
250
-
251
- async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
252
- if (skipBuild) {
253
- logWarning('Skipping library build because --skip-build flag was provided.')
254
- return
255
- }
256
-
257
- if (!hasScript(pkg, 'build:lib')) {
258
- logStep('Skipping library build (no build:lib script found in package.json).')
259
- return false
260
- }
261
-
262
- logStep('Building library...')
263
- await runCommand('npm', ['run', 'build:lib'], { cwd: rootDir })
264
- logSuccess('Library built.')
265
-
266
- // Check for lib changes and commit them if any
267
- const { stdout: statusAfterBuild } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
268
- const hasLibChanges = statusAfterBuild.split('\n').some(line => {
269
- const trimmed = line.trim()
270
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
271
- })
272
-
273
- if (hasLibChanges) {
274
- logStep('Committing lib build artifacts...')
275
- await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
276
- await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { cwd: rootDir })
277
- logSuccess('Lib build artifacts committed.')
278
- }
279
-
280
- return hasLibChanges
281
- }
282
-
283
- async function ensureNpmAuth(rootDir = process.cwd()) {
284
- logStep('Confirming npm authentication...')
285
- await runCommand('npm', ['whoami'], { cwd: rootDir })
286
- logSuccess('npm authenticated.')
287
- }
288
-
289
- async function bumpVersion(releaseType, rootDir = process.cwd()) {
290
- logStep(`Bumping package version...`)
291
-
292
- // Lib changes should already be committed by runLibBuild, but check anyway
293
- const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
294
- const hasLibChanges = statusBefore.split('\n').some(line => {
295
- const trimmed = line.trim()
296
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
297
- })
298
-
299
- if (hasLibChanges) {
300
- logStep('Stashing lib build artifacts...')
301
- await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { cwd: rootDir })
302
- }
303
-
304
- try {
305
- // npm version will update package.json and create a commit with default message
306
- await runCommand('npm', ['version', releaseType], { cwd: rootDir })
307
- } finally {
308
- // Restore lib changes and ensure they're in the commit
309
- if (hasLibChanges) {
310
- logStep('Restoring lib build artifacts...')
311
- await runCommand('git', ['stash', 'pop'], { cwd: rootDir })
312
- await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
313
- const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
314
- if (statusAfter.includes('lib/')) {
315
- await runCommand('git', ['commit', '--amend', '--no-edit'], { cwd: rootDir })
316
- }
317
- }
318
- }
319
-
320
- const pkg = await readPackage(rootDir)
321
- const commitMessage = `chore: release ${pkg.version}`
322
-
323
- // Amend the commit message to use our custom format
324
- await runCommand('git', ['commit', '--amend', '-m', commitMessage], { cwd: rootDir })
325
-
326
- logSuccess(`Version updated to ${pkg.version}.`)
327
- return pkg
328
- }
329
-
330
- async function pushChanges(rootDir = process.cwd()) {
331
- logStep('Pushing commits and tags to origin...')
332
- await runCommand('git', ['push', '--follow-tags'], { cwd: rootDir })
333
- logSuccess('Git push completed.')
334
- }
335
-
336
- async function publishPackage(pkg, rootDir = process.cwd()) {
337
- const publishArgs = ['publish', '--ignore-scripts'] // Skip prepublishOnly since we already built lib
338
-
339
- if (pkg.name.startsWith('@')) {
340
- publishArgs.push('--access', 'public')
341
- }
342
-
343
- logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
344
- await runCommand('npm', publishArgs, { cwd: rootDir })
345
- logSuccess('npm publish completed.')
346
- }
347
-
348
- function extractDomainFromHomepage(homepage) {
349
- if (!homepage) return null
350
- try {
351
- const url = new URL(homepage)
352
- return url.hostname
353
- } catch {
354
- // If it's not a valid URL, try to extract domain from string
355
- const match = homepage.match(/(?:https?:\/\/)?([^\/]+)/)
356
- return match ? match[1] : null
357
- }
358
- }
359
-
360
- async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
361
- if (skipDeploy) {
362
- logWarning('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
363
- return
364
- }
365
-
366
- // Check if dist directory exists (indicates build output for deployment)
367
- const distPath = path.join(rootDir, 'dist')
368
- let distExists = false
369
- try {
370
- const stats = await fs.promises.stat(distPath)
371
- distExists = stats.isDirectory()
372
- } catch {
373
- distExists = false
374
- }
375
-
376
- if (!distExists) {
377
- logStep('Skipping GitHub Pages deployment (no dist directory found).')
378
- return
379
- }
380
-
381
- logStep('Deploying to GitHub Pages...')
382
-
383
- // Write CNAME file to dist if homepage is set
384
- const cnamePath = path.join(distPath, 'CNAME')
385
-
386
- if (pkg.homepage) {
387
- const domain = extractDomainFromHomepage(pkg.homepage)
388
- if (domain) {
389
- try {
390
- await fs.promises.mkdir(distPath, { recursive: true })
391
- await fs.promises.writeFile(cnamePath, domain)
392
- } catch (error) {
393
- logWarning(`Could not write CNAME file: ${error.message}`)
394
- }
395
- }
396
- }
397
-
398
- const worktreeDir = path.resolve(rootDir, '.gh-pages')
399
-
400
- try {
401
- await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { cwd: rootDir })
402
- } catch { }
403
-
404
- try {
405
- await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { cwd: rootDir })
406
- } catch {
407
- await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], { cwd: rootDir })
408
- }
409
-
410
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'])
411
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'])
412
-
413
- // Clear worktree directory
414
- for (const entry of fs.readdirSync(worktreeDir)) {
415
- if (entry === '.git') continue
416
- const target = path.join(worktreeDir, entry)
417
- fs.rmSync(target, { recursive: true, force: true })
418
- }
419
-
420
- // Copy dist to worktree
421
- fs.cpSync(distPath, worktreeDir, { recursive: true })
422
-
423
- await runCommand('git', ['-C', worktreeDir, 'add', '-A'])
424
- await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'])
425
- await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'])
426
-
427
- logSuccess('GitHub Pages deployment completed.')
428
- }
429
-
430
- export async function releaseNode() {
431
- const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
432
- const rootDir = process.cwd()
433
-
434
- logStep('Reading package metadata...')
435
- const pkg = await readPackage(rootDir)
436
-
437
- logStep('Checking working tree status...')
438
- await ensureCleanWorkingTree(rootDir)
439
-
440
- const branch = await getCurrentBranch(rootDir)
441
- if (!branch) {
442
- throw new Error('Unable to determine current branch.')
443
- }
444
-
445
- logStep(`Current branch: ${branch}`)
446
- const upstreamRef = await getUpstreamRef(rootDir)
447
- await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
448
-
449
- await runLint(skipLint, pkg, rootDir)
450
- await runTests(skipTests, pkg, rootDir)
451
- await runBuild(skipBuild, pkg, rootDir)
452
- await runLibBuild(skipBuild, pkg, rootDir)
453
- await ensureNpmAuth(rootDir)
454
-
455
- const updatedPkg = await bumpVersion(releaseType, rootDir)
456
- await pushChanges(rootDir)
457
- await publishPackage(updatedPkg, rootDir)
458
- await deployGHPages(skipDeploy, updatedPkg, rootDir)
459
-
460
- logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
461
- }
462
-
1
+ import { spawn } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, join } from 'node:path'
4
+ import { readFile } from 'node:fs/promises'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import process from 'node:process'
8
+
9
+ const STEP_PREFIX = '→'
10
+ const OK_PREFIX = '✔'
11
+ const WARN_PREFIX = '⚠'
12
+
13
+ const IS_WINDOWS = process.platform === 'win32'
14
+
15
+ function logStep(message) {
16
+ console.log(`${STEP_PREFIX} ${message}`)
17
+ }
18
+
19
+ function logSuccess(message) {
20
+ console.log(`${OK_PREFIX} ${message}`)
21
+ }
22
+
23
+ function logWarning(message) {
24
+ console.warn(`${WARN_PREFIX} ${message}`)
25
+ }
26
+
27
+ function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ // On Windows, npm-related commands need shell: true to resolve npx.cmd
30
+ // Git commands work fine without shell, so we only use it when explicitly requested
31
+ const spawnOptions = {
32
+ cwd,
33
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
34
+ }
35
+
36
+ if (useShell || (IS_WINDOWS && (command === 'npm' || command === 'npx'))) {
37
+ spawnOptions.shell = true
38
+ }
39
+
40
+ const child = spawn(command, args, spawnOptions)
41
+ let stdout = ''
42
+ let stderr = ''
43
+
44
+ if (capture) {
45
+ child.stdout.on('data', (chunk) => {
46
+ stdout += chunk
47
+ })
48
+
49
+ child.stderr.on('data', (chunk) => {
50
+ stderr += chunk
51
+ })
52
+ }
53
+
54
+ child.on('error', reject)
55
+ child.on('close', (code) => {
56
+ if (code === 0) {
57
+ resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
58
+ } else {
59
+ const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
60
+ if (capture) {
61
+ error.stdout = stdout
62
+ error.stderr = stderr
63
+ }
64
+ error.exitCode = code
65
+ reject(error)
66
+ }
67
+ })
68
+ })
69
+ }
70
+
71
+ async function readPackage(rootDir = process.cwd()) {
72
+ const packagePath = join(rootDir, 'package.json')
73
+ const raw = await readFile(packagePath, 'utf8')
74
+ return JSON.parse(raw)
75
+ }
76
+
77
+ function hasScript(pkg, scriptName) {
78
+ return pkg?.scripts?.[scriptName] !== undefined
79
+ }
80
+
81
+ async function ensureCleanWorkingTree(rootDir = process.cwd()) {
82
+ const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
83
+
84
+ if (stdout.length > 0) {
85
+ throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
86
+ }
87
+ }
88
+
89
+ async function getCurrentBranch(rootDir = process.cwd()) {
90
+ const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true, cwd: rootDir })
91
+ return stdout || null
92
+ }
93
+
94
+ async function getUpstreamRef(rootDir = process.cwd()) {
95
+ try {
96
+ const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
97
+ capture: true,
98
+ cwd: rootDir
99
+ })
100
+
101
+ return stdout || null
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process.cwd()) {
108
+ if (!upstreamRef) {
109
+ logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
110
+ return
111
+ }
112
+
113
+ const [remoteName, ...branchParts] = upstreamRef.split('/')
114
+ const remoteBranch = branchParts.join('/')
115
+
116
+ if (remoteName && remoteBranch) {
117
+ logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
118
+ try {
119
+ await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir })
120
+ } catch (error) {
121
+ throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
122
+ }
123
+ }
124
+
125
+ const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
126
+ capture: true,
127
+ cwd: rootDir
128
+ })
129
+ const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
130
+ capture: true,
131
+ cwd: rootDir
132
+ })
133
+
134
+ const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
135
+ const behind = Number.parseInt(behindResult.stdout || '0', 10)
136
+
137
+ if (Number.isFinite(behind) && behind > 0) {
138
+ if (remoteName && remoteBranch) {
139
+ logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
140
+
141
+ try {
142
+ await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir })
143
+ } catch (error) {
144
+ throw new Error(
145
+ `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
146
+ )
147
+ }
148
+
149
+ return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
150
+ }
151
+
152
+ throw new Error(
153
+ `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
154
+ )
155
+ }
156
+
157
+ if (Number.isFinite(ahead) && ahead > 0) {
158
+ logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
159
+ }
160
+ }
161
+
162
+ function parseArgs() {
163
+ const args = process.argv.slice(2)
164
+ // Filter out --type flag as it's handled by zephyr CLI
165
+ const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
166
+ const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
167
+ const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
168
+
169
+ const releaseType = positionals[0] ?? 'patch'
170
+ const skipTests = flags.has('--skip-tests')
171
+ const skipLint = flags.has('--skip-lint')
172
+ const skipBuild = flags.has('--skip-build')
173
+ const skipDeploy = flags.has('--skip-deploy')
174
+
175
+ const allowedTypes = new Set([
176
+ 'major',
177
+ 'minor',
178
+ 'patch',
179
+ 'premajor',
180
+ 'preminor',
181
+ 'prepatch',
182
+ 'prerelease'
183
+ ])
184
+
185
+ if (!allowedTypes.has(releaseType)) {
186
+ throw new Error(
187
+ `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
188
+ )
189
+ }
190
+
191
+ return { releaseType, skipTests, skipLint, skipBuild, skipDeploy }
192
+ }
193
+
194
+ async function runLint(skipLint, pkg, rootDir = process.cwd()) {
195
+ if (skipLint) {
196
+ logWarning('Skipping lint because --skip-lint flag was provided.')
197
+ return
198
+ }
199
+
200
+ if (!hasScript(pkg, 'lint')) {
201
+ logStep('Skipping lint (no lint script found in package.json).')
202
+ return
203
+ }
204
+
205
+ logStep('Running lint...')
206
+ await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
207
+ logSuccess('Lint passed.')
208
+ }
209
+
210
+ async function runTests(skipTests, pkg, rootDir = process.cwd()) {
211
+ if (skipTests) {
212
+ logWarning('Skipping tests because --skip-tests flag was provided.')
213
+ return
214
+ }
215
+
216
+ // Check for test:run or test script
217
+ if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
218
+ logStep('Skipping tests (no test or test:run script found in package.json).')
219
+ return
220
+ }
221
+
222
+ logStep('Running test suite...')
223
+
224
+ // Prefer test:run if available, otherwise use test with --run flag
225
+ if (hasScript(pkg, 'test:run')) {
226
+ await runCommand('npm', ['run', 'test:run'], { cwd: rootDir })
227
+ } else {
228
+ // For test script, try to pass --run flag (works with vitest)
229
+ await runCommand('npm', ['test', '--', '--run'], { cwd: rootDir })
230
+ }
231
+
232
+ logSuccess('Tests passed.')
233
+ }
234
+
235
+ async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
236
+ if (skipBuild) {
237
+ logWarning('Skipping build because --skip-build flag was provided.')
238
+ return
239
+ }
240
+
241
+ if (!hasScript(pkg, 'build')) {
242
+ logStep('Skipping build (no build script found in package.json).')
243
+ return
244
+ }
245
+
246
+ logStep('Building project...')
247
+ await runCommand('npm', ['run', 'build'], { cwd: rootDir })
248
+ logSuccess('Build completed.')
249
+ }
250
+
251
+ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
252
+ if (skipBuild) {
253
+ logWarning('Skipping library build because --skip-build flag was provided.')
254
+ return
255
+ }
256
+
257
+ if (!hasScript(pkg, 'build:lib')) {
258
+ logStep('Skipping library build (no build:lib script found in package.json).')
259
+ return false
260
+ }
261
+
262
+ logStep('Building library...')
263
+ await runCommand('npm', ['run', 'build:lib'], { cwd: rootDir })
264
+ logSuccess('Library built.')
265
+
266
+ // Check for lib changes and commit them if any
267
+ const { stdout: statusAfterBuild } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
268
+ const hasLibChanges = statusAfterBuild.split('\n').some(line => {
269
+ const trimmed = line.trim()
270
+ return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
271
+ })
272
+
273
+ if (hasLibChanges) {
274
+ logStep('Committing lib build artifacts...')
275
+ await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
276
+ await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { cwd: rootDir })
277
+ logSuccess('Lib build artifacts committed.')
278
+ }
279
+
280
+ return hasLibChanges
281
+ }
282
+
283
+ async function ensureNpmAuth(rootDir = process.cwd()) {
284
+ logStep('Confirming npm authentication...')
285
+ await runCommand('npm', ['whoami'], { cwd: rootDir })
286
+ logSuccess('npm authenticated.')
287
+ }
288
+
289
+ async function bumpVersion(releaseType, rootDir = process.cwd()) {
290
+ logStep(`Bumping package version...`)
291
+
292
+ // Lib changes should already be committed by runLibBuild, but check anyway
293
+ const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
294
+ const hasLibChanges = statusBefore.split('\n').some(line => {
295
+ const trimmed = line.trim()
296
+ return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
297
+ })
298
+
299
+ if (hasLibChanges) {
300
+ logStep('Stashing lib build artifacts...')
301
+ await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { cwd: rootDir })
302
+ }
303
+
304
+ try {
305
+ // npm version will update package.json and create a commit with default message
306
+ await runCommand('npm', ['version', releaseType], { cwd: rootDir })
307
+ } finally {
308
+ // Restore lib changes and ensure they're in the commit
309
+ if (hasLibChanges) {
310
+ logStep('Restoring lib build artifacts...')
311
+ await runCommand('git', ['stash', 'pop'], { cwd: rootDir })
312
+ await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
313
+ const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
314
+ if (statusAfter.includes('lib/')) {
315
+ await runCommand('git', ['commit', '--amend', '--no-edit'], { cwd: rootDir })
316
+ }
317
+ }
318
+ }
319
+
320
+ const pkg = await readPackage(rootDir)
321
+ const commitMessage = `chore: release ${pkg.version}`
322
+
323
+ // Amend the commit message to use our custom format
324
+ await runCommand('git', ['commit', '--amend', '-m', commitMessage], { cwd: rootDir })
325
+
326
+ logSuccess(`Version updated to ${pkg.version}.`)
327
+ return pkg
328
+ }
329
+
330
+ async function pushChanges(rootDir = process.cwd()) {
331
+ logStep('Pushing commits and tags to origin...')
332
+ await runCommand('git', ['push', '--follow-tags'], { cwd: rootDir })
333
+ logSuccess('Git push completed.')
334
+ }
335
+
336
+ async function publishPackage(pkg, rootDir = process.cwd()) {
337
+ const publishArgs = ['publish', '--ignore-scripts'] // Skip prepublishOnly since we already built lib
338
+
339
+ if (pkg.name.startsWith('@')) {
340
+ publishArgs.push('--access', 'public')
341
+ }
342
+
343
+ logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
344
+ await runCommand('npm', publishArgs, { cwd: rootDir })
345
+ logSuccess('npm publish completed.')
346
+ }
347
+
348
+ function extractDomainFromHomepage(homepage) {
349
+ if (!homepage) return null
350
+ try {
351
+ const url = new URL(homepage)
352
+ return url.hostname
353
+ } catch {
354
+ // If it's not a valid URL, try to extract domain from string
355
+ const match = homepage.match(/(?:https?:\/\/)?([^\/]+)/)
356
+ return match ? match[1] : null
357
+ }
358
+ }
359
+
360
+ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
361
+ if (skipDeploy) {
362
+ logWarning('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
363
+ return
364
+ }
365
+
366
+ // Check if dist directory exists (indicates build output for deployment)
367
+ const distPath = path.join(rootDir, 'dist')
368
+ let distExists = false
369
+ try {
370
+ const stats = await fs.promises.stat(distPath)
371
+ distExists = stats.isDirectory()
372
+ } catch {
373
+ distExists = false
374
+ }
375
+
376
+ if (!distExists) {
377
+ logStep('Skipping GitHub Pages deployment (no dist directory found).')
378
+ return
379
+ }
380
+
381
+ logStep('Deploying to GitHub Pages...')
382
+
383
+ // Write CNAME file to dist if homepage is set
384
+ const cnamePath = path.join(distPath, 'CNAME')
385
+
386
+ if (pkg.homepage) {
387
+ const domain = extractDomainFromHomepage(pkg.homepage)
388
+ if (domain) {
389
+ try {
390
+ await fs.promises.mkdir(distPath, { recursive: true })
391
+ await fs.promises.writeFile(cnamePath, domain)
392
+ } catch (error) {
393
+ logWarning(`Could not write CNAME file: ${error.message}`)
394
+ }
395
+ }
396
+ }
397
+
398
+ const worktreeDir = path.resolve(rootDir, '.gh-pages')
399
+
400
+ try {
401
+ await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { cwd: rootDir })
402
+ } catch { }
403
+
404
+ try {
405
+ await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { cwd: rootDir })
406
+ } catch {
407
+ await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], { cwd: rootDir })
408
+ }
409
+
410
+ await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'])
411
+ await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'])
412
+
413
+ // Clear worktree directory
414
+ for (const entry of fs.readdirSync(worktreeDir)) {
415
+ if (entry === '.git') continue
416
+ const target = path.join(worktreeDir, entry)
417
+ fs.rmSync(target, { recursive: true, force: true })
418
+ }
419
+
420
+ // Copy dist to worktree
421
+ fs.cpSync(distPath, worktreeDir, { recursive: true })
422
+
423
+ await runCommand('git', ['-C', worktreeDir, 'add', '-A'])
424
+ await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'])
425
+ await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'])
426
+
427
+ logSuccess('GitHub Pages deployment completed.')
428
+ }
429
+
430
+ export async function releaseNode() {
431
+ const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
432
+ const rootDir = process.cwd()
433
+
434
+ logStep('Reading package metadata...')
435
+ const pkg = await readPackage(rootDir)
436
+
437
+ logStep('Checking working tree status...')
438
+ await ensureCleanWorkingTree(rootDir)
439
+
440
+ const branch = await getCurrentBranch(rootDir)
441
+ if (!branch) {
442
+ throw new Error('Unable to determine current branch.')
443
+ }
444
+
445
+ logStep(`Current branch: ${branch}`)
446
+ const upstreamRef = await getUpstreamRef(rootDir)
447
+ await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
448
+
449
+ await runLint(skipLint, pkg, rootDir)
450
+ await runTests(skipTests, pkg, rootDir)
451
+ await runBuild(skipBuild, pkg, rootDir)
452
+ await runLibBuild(skipBuild, pkg, rootDir)
453
+ await ensureNpmAuth(rootDir)
454
+
455
+ const updatedPkg = await bumpVersion(releaseType, rootDir)
456
+ await pushChanges(rootDir)
457
+ await publishPackage(updatedPkg, rootDir)
458
+ await deployGHPages(skipDeploy, updatedPkg, rootDir)
459
+
460
+ logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
461
+ }
462
+