@wyxos/zephyr 0.2.12 → 0.2.13

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,670 +1,613 @@
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], { capture: true, cwd: rootDir })
120
- } catch (error) {
121
- if (error.stderr) {
122
- console.error(error.stderr)
123
- }
124
- throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
125
- }
126
- }
127
-
128
- const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
129
- capture: true,
130
- cwd: rootDir
131
- })
132
- const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
133
- capture: true,
134
- cwd: rootDir
135
- })
136
-
137
- const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
138
- const behind = Number.parseInt(behindResult.stdout || '0', 10)
139
-
140
- if (Number.isFinite(behind) && behind > 0) {
141
- if (remoteName && remoteBranch) {
142
- logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
143
-
144
- try {
145
- await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { capture: true, cwd: rootDir })
146
- } catch (error) {
147
- if (error.stderr) {
148
- console.error(error.stderr)
149
- }
150
- throw new Error(
151
- `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
152
- )
153
- }
154
-
155
- return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
156
- }
157
-
158
- throw new Error(
159
- `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
160
- )
161
- }
162
-
163
- if (Number.isFinite(ahead) && ahead > 0) {
164
- logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
165
- }
166
- }
167
-
168
- function parseArgs() {
169
- const args = process.argv.slice(2)
170
- // Filter out --type flag as it's handled by zephyr CLI
171
- const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
172
- const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
173
- const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
174
-
175
- const releaseType = positionals[0] ?? 'patch'
176
- const skipTests = flags.has('--skip-tests')
177
- const skipLint = flags.has('--skip-lint')
178
- const skipBuild = flags.has('--skip-build')
179
- const skipDeploy = flags.has('--skip-deploy')
180
-
181
- const allowedTypes = new Set([
182
- 'major',
183
- 'minor',
184
- 'patch',
185
- 'premajor',
186
- 'preminor',
187
- 'prepatch',
188
- 'prerelease'
189
- ])
190
-
191
- if (!allowedTypes.has(releaseType)) {
192
- throw new Error(
193
- `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
194
- )
195
- }
196
-
197
- return { releaseType, skipTests, skipLint, skipBuild, skipDeploy }
198
- }
199
-
200
- async function runLint(skipLint, pkg, rootDir = process.cwd()) {
201
- if (skipLint) {
202
- logWarning('Skipping lint because --skip-lint flag was provided.')
203
- return
204
- }
205
-
206
- if (!hasScript(pkg, 'lint')) {
207
- logStep('Skipping lint (no lint script found in package.json).')
208
- return
209
- }
210
-
211
- logStep('Running lint...')
212
-
213
- let dotInterval = null
214
- try {
215
- // Capture output and show dots as progress
216
- process.stdout.write(' ')
217
- dotInterval = setInterval(() => {
218
- process.stdout.write('.')
219
- }, 200)
220
-
221
- await runCommand('npm', ['run', 'lint'], { capture: true, cwd: rootDir })
222
-
223
- if (dotInterval) {
224
- clearInterval(dotInterval)
225
- dotInterval = null
226
- }
227
- process.stdout.write('\n')
228
- logSuccess('Lint passed.')
229
- } catch (error) {
230
- // Clear dots and show error output
231
- if (dotInterval) {
232
- clearInterval(dotInterval)
233
- dotInterval = null
234
- }
235
- process.stdout.write('\n')
236
- if (error.stdout) {
237
- console.error(error.stdout)
238
- }
239
- if (error.stderr) {
240
- console.error(error.stderr)
241
- }
242
- throw error
243
- }
244
- }
245
-
246
- async function runTests(skipTests, pkg, rootDir = process.cwd()) {
247
- if (skipTests) {
248
- logWarning('Skipping tests because --skip-tests flag was provided.')
249
- return
250
- }
251
-
252
- // Check for test:run or test script
253
- if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
254
- logStep('Skipping tests (no test or test:run script found in package.json).')
255
- return
256
- }
257
-
258
- logStep('Running test suite...')
259
-
260
- let dotInterval = null
261
- try {
262
- // Capture output and show dots as progress
263
- process.stdout.write(' ')
264
- dotInterval = setInterval(() => {
265
- process.stdout.write('.')
266
- }, 200)
267
-
268
- // Prefer test:run if available, otherwise use test with --run flag
269
- if (hasScript(pkg, 'test:run')) {
270
- await runCommand('npm', ['run', 'test:run'], { capture: true, cwd: rootDir })
271
- } else {
272
- // For test script, try to pass --run flag (works with vitest)
273
- await runCommand('npm', ['test', '--', '--run'], { capture: true, cwd: rootDir })
274
- }
275
-
276
- if (dotInterval) {
277
- clearInterval(dotInterval)
278
- dotInterval = null
279
- }
280
- process.stdout.write('\n')
281
- logSuccess('Tests passed.')
282
- } catch (error) {
283
- // Clear dots and show error output
284
- if (dotInterval) {
285
- clearInterval(dotInterval)
286
- dotInterval = null
287
- }
288
- process.stdout.write('\n')
289
- if (error.stdout) {
290
- console.error(error.stdout)
291
- }
292
- if (error.stderr) {
293
- console.error(error.stderr)
294
- }
295
- throw error
296
- }
297
- }
298
-
299
- async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
300
- if (skipBuild) {
301
- logWarning('Skipping build because --skip-build flag was provided.')
302
- return
303
- }
304
-
305
- if (!hasScript(pkg, 'build')) {
306
- logStep('Skipping build (no build script found in package.json).')
307
- return
308
- }
309
-
310
- logStep('Building project...')
311
-
312
- let dotInterval = null
313
- try {
314
- // Capture output and show dots as progress
315
- process.stdout.write(' ')
316
- dotInterval = setInterval(() => {
317
- process.stdout.write('.')
318
- }, 200)
319
-
320
- await runCommand('npm', ['run', 'build'], { capture: true, cwd: rootDir })
321
-
322
- if (dotInterval) {
323
- clearInterval(dotInterval)
324
- dotInterval = null
325
- }
326
- process.stdout.write('\n')
327
- logSuccess('Build completed.')
328
- } catch (error) {
329
- // Clear dots and show error output
330
- if (dotInterval) {
331
- clearInterval(dotInterval)
332
- dotInterval = null
333
- }
334
- process.stdout.write('\n')
335
- if (error.stdout) {
336
- console.error(error.stdout)
337
- }
338
- if (error.stderr) {
339
- console.error(error.stderr)
340
- }
341
- throw error
342
- }
343
- }
344
-
345
- async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
346
- if (skipBuild) {
347
- logWarning('Skipping library build because --skip-build flag was provided.')
348
- return
349
- }
350
-
351
- if (!hasScript(pkg, 'build:lib')) {
352
- logStep('Skipping library build (no build:lib script found in package.json).')
353
- return false
354
- }
355
-
356
- logStep('Building library...')
357
-
358
- let dotInterval = null
359
- try {
360
- // Capture output and show dots as progress
361
- process.stdout.write(' ')
362
- dotInterval = setInterval(() => {
363
- process.stdout.write('.')
364
- }, 200)
365
-
366
- await runCommand('npm', ['run', 'build:lib'], { capture: true, cwd: rootDir })
367
-
368
- if (dotInterval) {
369
- clearInterval(dotInterval)
370
- dotInterval = null
371
- }
372
- process.stdout.write('\n')
373
- logSuccess('Library built.')
374
- } catch (error) {
375
- // Clear dots and show error output
376
- if (dotInterval) {
377
- clearInterval(dotInterval)
378
- dotInterval = null
379
- }
380
- process.stdout.write('\n')
381
- if (error.stdout) {
382
- console.error(error.stdout)
383
- }
384
- if (error.stderr) {
385
- console.error(error.stderr)
386
- }
387
- throw error
388
- }
389
-
390
- // Check for lib changes and commit them if any
391
- const { stdout: statusAfterBuild } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
392
- const hasLibChanges = statusAfterBuild.split('\n').some(line => {
393
- const trimmed = line.trim()
394
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
395
- })
396
-
397
- if (hasLibChanges) {
398
- logStep('Committing lib build artifacts...')
399
- await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
400
- await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { capture: true, cwd: rootDir })
401
- logSuccess('Lib build artifacts committed.')
402
- }
403
-
404
- return hasLibChanges
405
- }
406
-
407
- async function ensureNpmAuth(rootDir = process.cwd()) {
408
- logStep('Confirming npm authentication...')
409
- try {
410
- const result = await runCommand('npm', ['whoami'], { capture: true, cwd: rootDir })
411
- // Only show username if we captured it, otherwise just show success
412
- if (result?.stdout) {
413
- // Silently authenticated - we don't need to show the username
414
- }
415
- logSuccess('npm authenticated.')
416
- } catch (error) {
417
- if (error.stderr) {
418
- console.error(error.stderr)
419
- }
420
- throw error
421
- }
422
- }
423
-
424
- async function bumpVersion(releaseType, rootDir = process.cwd()) {
425
- logStep(`Bumping package version...`)
426
-
427
- // Lib changes should already be committed by runLibBuild, but check anyway
428
- const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
429
- const hasLibChanges = statusBefore.split('\n').some(line => {
430
- const trimmed = line.trim()
431
- return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
432
- })
433
-
434
- if (hasLibChanges) {
435
- logStep('Stashing lib build artifacts...')
436
- await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { capture: true, cwd: rootDir })
437
- }
438
-
439
- try {
440
- // npm version will update package.json and create a commit with default message
441
- const result = await runCommand('npm', ['version', releaseType], { capture: true, cwd: rootDir })
442
- // Extract version from output (e.g., "v0.2.8" or "0.2.8")
443
- if (result?.stdout) {
444
- const versionMatch = result.stdout.match(/v?(\d+\.\d+\.\d+)/)
445
- if (versionMatch) {
446
- // Version is shown in the logSuccess message below, no need to show it here
447
- }
448
- }
449
- } finally {
450
- // Restore lib changes and ensure they're in the commit
451
- if (hasLibChanges) {
452
- logStep('Restoring lib build artifacts...')
453
- await runCommand('git', ['stash', 'pop'], { capture: true, cwd: rootDir })
454
- await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
455
- const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
456
- if (statusAfter.includes('lib/')) {
457
- await runCommand('git', ['commit', '--amend', '--no-edit'], { capture: true, cwd: rootDir })
458
- }
459
- }
460
- }
461
-
462
- const pkg = await readPackage(rootDir)
463
- const commitMessage = `chore: release ${pkg.version}`
464
-
465
- // Amend the commit message to use our custom format
466
- await runCommand('git', ['commit', '--amend', '-m', commitMessage], { capture: true, cwd: rootDir })
467
-
468
- logSuccess(`Version updated to ${pkg.version}.`)
469
- return pkg
470
- }
471
-
472
- async function pushChanges(rootDir = process.cwd()) {
473
- logStep('Pushing commits and tags to origin...')
474
- try {
475
- await runCommand('git', ['push', '--follow-tags'], { capture: true, cwd: rootDir })
476
- logSuccess('Git push completed.')
477
- } catch (error) {
478
- if (error.stdout) {
479
- console.error(error.stdout)
480
- }
481
- if (error.stderr) {
482
- console.error(error.stderr)
483
- }
484
- throw error
485
- }
486
- }
487
-
488
- async function publishPackage(pkg, rootDir = process.cwd()) {
489
- // Check if package is configured as private/restricted
490
- const isPrivate = pkg.publishConfig?.access === 'restricted'
491
-
492
- if (isPrivate) {
493
- logWarning('Skipping npm publish (package is configured as private/restricted).')
494
- logWarning('Private packages require npm paid plan. Publish manually or use GitHub Packages.')
495
- return
496
- }
497
-
498
- const publishArgs = ['publish', '--ignore-scripts'] // Skip prepublishOnly since we already built lib
499
-
500
- if (pkg.name.startsWith('@')) {
501
- // For scoped packages, determine access level from publishConfig
502
- // Default to 'public' for scoped packages if not specified (free npm accounts require public for scoped packages)
503
- const access = pkg.publishConfig?.access || 'public'
504
- publishArgs.push('--access', access)
505
- }
506
-
507
- logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
508
- try {
509
- await runCommand('npm', publishArgs, { capture: true, cwd: rootDir })
510
- logSuccess('npm publish completed.')
511
- } catch (error) {
512
- if (error.stdout) {
513
- console.error(error.stdout)
514
- }
515
- if (error.stderr) {
516
- console.error(error.stderr)
517
- }
518
- throw error
519
- }
520
- }
521
-
522
- function extractDomainFromHomepage(homepage) {
523
- if (!homepage) return null
524
- try {
525
- const url = new URL(homepage)
526
- return url.hostname
527
- } catch {
528
- // If it's not a valid URL, try to extract domain from string
529
- const match = homepage.match(/(?:https?:\/\/)?([^\/]+)/)
530
- return match ? match[1] : null
531
- }
532
- }
533
-
534
- async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
535
- if (skipDeploy) {
536
- logWarning('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
537
- return
538
- }
539
-
540
- // Check if dist directory exists (indicates build output for deployment)
541
- const distPath = path.join(rootDir, 'dist')
542
- let distExists = false
543
- try {
544
- const stats = await fs.promises.stat(distPath)
545
- distExists = stats.isDirectory()
546
- } catch {
547
- distExists = false
548
- }
549
-
550
- if (!distExists) {
551
- logStep('Skipping GitHub Pages deployment (no dist directory found).')
552
- return
553
- }
554
-
555
- logStep('Deploying to GitHub Pages...')
556
-
557
- // Write CNAME file to dist if homepage is set
558
- const cnamePath = path.join(distPath, 'CNAME')
559
-
560
- if (pkg.homepage) {
561
- const domain = extractDomainFromHomepage(pkg.homepage)
562
- if (domain) {
563
- try {
564
- await fs.promises.mkdir(distPath, { recursive: true })
565
- await fs.promises.writeFile(cnamePath, domain)
566
- } catch (error) {
567
- logWarning(`Could not write CNAME file: ${error.message}`)
568
- }
569
- }
570
- }
571
-
572
- const worktreeDir = path.resolve(rootDir, '.gh-pages')
573
-
574
- let dotInterval = null
575
- try {
576
- // Capture output and show dots as progress
577
- process.stdout.write(' ')
578
- dotInterval = setInterval(() => {
579
- process.stdout.write('.')
580
- }, 200)
581
-
582
- try {
583
- await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { capture: true, cwd: rootDir })
584
- } catch { }
585
-
586
- try {
587
- await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { capture: true, cwd: rootDir })
588
- } catch {
589
- await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], { capture: true, cwd: rootDir })
590
- }
591
-
592
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'], { capture: true })
593
- await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'], { capture: true })
594
-
595
- // Clear worktree directory
596
- for (const entry of fs.readdirSync(worktreeDir)) {
597
- if (entry === '.git') continue
598
- const target = path.join(worktreeDir, entry)
599
- fs.rmSync(target, { recursive: true, force: true })
600
- }
601
-
602
- // Copy dist to worktree
603
- fs.cpSync(distPath, worktreeDir, { recursive: true })
604
-
605
- await runCommand('git', ['-C', worktreeDir, 'add', '-A'], { capture: true })
606
- await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'], { capture: true })
607
- await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'], { capture: true })
608
-
609
- if (dotInterval) {
610
- clearInterval(dotInterval)
611
- dotInterval = null
612
- }
613
- process.stdout.write('\n')
614
- logSuccess('GitHub Pages deployment completed.')
615
- } catch (error) {
616
- // Clear dots and show error output
617
- if (dotInterval) {
618
- clearInterval(dotInterval)
619
- dotInterval = null
620
- }
621
- process.stdout.write('\n')
622
- if (error.stdout) {
623
- console.error(error.stdout)
624
- }
625
- if (error.stderr) {
626
- console.error(error.stderr)
627
- }
628
- throw error
629
- }
630
- }
631
-
632
- export async function releaseNode() {
633
- try {
634
- const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
635
- const rootDir = process.cwd()
636
-
637
- logStep('Reading package metadata...')
638
- const pkg = await readPackage(rootDir)
639
-
640
- logStep('Checking working tree status...')
641
- await ensureCleanWorkingTree(rootDir)
642
-
643
- const branch = await getCurrentBranch(rootDir)
644
- if (!branch) {
645
- throw new Error('Unable to determine current branch.')
646
- }
647
-
648
- logStep(`Current branch: ${branch}`)
649
- const upstreamRef = await getUpstreamRef(rootDir)
650
- await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
651
-
652
- await runLint(skipLint, pkg, rootDir)
653
- await runTests(skipTests, pkg, rootDir)
654
- await runBuild(skipBuild, pkg, rootDir)
655
- await runLibBuild(skipBuild, pkg, rootDir)
656
- await ensureNpmAuth(rootDir)
657
-
658
- const updatedPkg = await bumpVersion(releaseType, rootDir)
659
- await pushChanges(rootDir)
660
- await publishPackage(updatedPkg, rootDir)
661
- await deployGHPages(skipDeploy, updatedPkg, rootDir)
662
-
663
- logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
664
- } catch (error) {
665
- console.error('\nRelease failed:')
666
- console.error(error.message)
667
- throw error
668
- }
669
- }
670
-
1
+ import { spawn, exec } 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
+ import chalk from 'chalk'
9
+
10
+ const IS_WINDOWS = process.platform === 'win32'
11
+
12
+ function logStep(message) {
13
+ console.log(chalk.yellow(`→ ${message}`))
14
+ }
15
+
16
+ function logSuccess(message) {
17
+ console.log(chalk.green(`✔ ${message}`))
18
+ }
19
+
20
+ function logWarning(message) {
21
+ console.warn(chalk.yellow(`⚠ ${message}`))
22
+ }
23
+
24
+ function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
25
+ return new Promise((resolve, reject) => {
26
+ // On Windows, npm-related commands need shell to resolve npx.cmd
27
+ // Git commands work fine without shell, so we only use it when explicitly requested
28
+ const needsShell = useShell || (IS_WINDOWS && (command === 'npm' || command === 'npx'))
29
+
30
+ if (needsShell) {
31
+ // When using shell, use exec to avoid deprecation warning with spawn
32
+ // Properly escape arguments for Windows cmd.exe
33
+ const escapedArgs = args.map(arg => {
34
+ // If arg contains spaces or special chars, wrap in quotes and escape internal quotes
35
+ if (arg.includes(' ') || arg.includes('"') || arg.includes('&') || arg.includes('|')) {
36
+ return `"${arg.replace(/"/g, '\\"')}"`
37
+ }
38
+ return arg
39
+ })
40
+ const commandString = `${command} ${escapedArgs.join(' ')}`
41
+
42
+ exec(commandString, { cwd, encoding: 'utf8' }, (error, stdout, stderr) => {
43
+ if (error) {
44
+ const err = new Error(`Command failed (${error.code}): ${command} ${args.join(' ')}`)
45
+ if (capture) {
46
+ err.stdout = stdout || ''
47
+ err.stderr = stderr || ''
48
+ } else {
49
+ // When not capturing, exec still provides output, so show it
50
+ if (stdout) process.stdout.write(stdout)
51
+ if (stderr) process.stderr.write(stderr)
52
+ }
53
+ err.exitCode = error.code
54
+ reject(err)
55
+ return
56
+ }
57
+
58
+ if (capture) {
59
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() })
60
+ } else {
61
+ // When not capturing, exec still provides output, so show it
62
+ if (stdout) process.stdout.write(stdout)
63
+ if (stderr) process.stderr.write(stderr)
64
+ resolve(undefined)
65
+ }
66
+ })
67
+ } else {
68
+ // Use spawn for commands that don't need shell
69
+ const spawnOptions = {
70
+ cwd,
71
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
72
+ }
73
+
74
+ const child = spawn(command, args, spawnOptions)
75
+ let stdout = ''
76
+ let stderr = ''
77
+
78
+ if (capture) {
79
+ child.stdout.on('data', (chunk) => {
80
+ stdout += chunk
81
+ })
82
+
83
+ child.stderr.on('data', (chunk) => {
84
+ stderr += chunk
85
+ })
86
+ }
87
+
88
+ child.on('error', reject)
89
+ child.on('close', (code) => {
90
+ if (code === 0) {
91
+ resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
92
+ } else {
93
+ const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
94
+ if (capture) {
95
+ error.stdout = stdout
96
+ error.stderr = stderr
97
+ }
98
+ error.exitCode = code
99
+ reject(error)
100
+ }
101
+ })
102
+ }
103
+ })
104
+ }
105
+
106
+ async function readPackage(rootDir = process.cwd()) {
107
+ const packagePath = join(rootDir, 'package.json')
108
+ const raw = await readFile(packagePath, 'utf8')
109
+ return JSON.parse(raw)
110
+ }
111
+
112
+ function hasScript(pkg, scriptName) {
113
+ return pkg?.scripts?.[scriptName] !== undefined
114
+ }
115
+
116
+ async function ensureCleanWorkingTree(rootDir = process.cwd()) {
117
+ const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
118
+
119
+ if (stdout.length > 0) {
120
+ throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
121
+ }
122
+ }
123
+
124
+ async function getCurrentBranch(rootDir = process.cwd()) {
125
+ const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true, cwd: rootDir })
126
+ return stdout || null
127
+ }
128
+
129
+ async function getUpstreamRef(rootDir = process.cwd()) {
130
+ try {
131
+ const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
132
+ capture: true,
133
+ cwd: rootDir
134
+ })
135
+
136
+ return stdout || null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process.cwd()) {
143
+ if (!upstreamRef) {
144
+ logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
145
+ return
146
+ }
147
+
148
+ const [remoteName, ...branchParts] = upstreamRef.split('/')
149
+ const remoteBranch = branchParts.join('/')
150
+
151
+ if (remoteName && remoteBranch) {
152
+ logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
153
+ try {
154
+ await runCommand('git', ['fetch', remoteName, remoteBranch], { capture: true, cwd: rootDir })
155
+ } catch (error) {
156
+ if (error.stderr) {
157
+ console.error(error.stderr)
158
+ }
159
+ throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
160
+ }
161
+ }
162
+
163
+ const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
164
+ capture: true,
165
+ cwd: rootDir
166
+ })
167
+ const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
168
+ capture: true,
169
+ cwd: rootDir
170
+ })
171
+
172
+ const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
173
+ const behind = Number.parseInt(behindResult.stdout || '0', 10)
174
+
175
+ if (Number.isFinite(behind) && behind > 0) {
176
+ if (remoteName && remoteBranch) {
177
+ logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
178
+
179
+ try {
180
+ await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { capture: true, cwd: rootDir })
181
+ } catch (error) {
182
+ if (error.stderr) {
183
+ console.error(error.stderr)
184
+ }
185
+ throw new Error(
186
+ `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
187
+ )
188
+ }
189
+
190
+ return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
191
+ }
192
+
193
+ throw new Error(
194
+ `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
195
+ )
196
+ }
197
+
198
+ if (Number.isFinite(ahead) && ahead > 0) {
199
+ logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
200
+ }
201
+ }
202
+
203
+ function parseArgs() {
204
+ const args = process.argv.slice(2)
205
+ // Filter out --type flag as it's handled by zephyr CLI
206
+ const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
207
+ const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
208
+ const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
209
+
210
+ const releaseType = positionals[0] ?? 'patch'
211
+ const skipTests = flags.has('--skip-tests')
212
+ const skipLint = flags.has('--skip-lint')
213
+ const skipBuild = flags.has('--skip-build')
214
+ const skipDeploy = flags.has('--skip-deploy')
215
+
216
+ const allowedTypes = new Set([
217
+ 'major',
218
+ 'minor',
219
+ 'patch',
220
+ 'premajor',
221
+ 'preminor',
222
+ 'prepatch',
223
+ 'prerelease'
224
+ ])
225
+
226
+ if (!allowedTypes.has(releaseType)) {
227
+ throw new Error(
228
+ `Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
229
+ )
230
+ }
231
+
232
+ return { releaseType, skipTests, skipLint, skipBuild, skipDeploy }
233
+ }
234
+
235
+ async function runLint(skipLint, pkg, rootDir = process.cwd()) {
236
+ if (skipLint) {
237
+ logWarning('Skipping lint because --skip-lint flag was provided.')
238
+ return
239
+ }
240
+
241
+ if (!hasScript(pkg, 'lint')) {
242
+ logStep('Skipping lint (no lint script found in package.json).')
243
+ return
244
+ }
245
+
246
+ logStep('Running lint...')
247
+
248
+ try {
249
+ await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
250
+ logSuccess('Lint passed.')
251
+ } catch (error) {
252
+ if (error.stdout) {
253
+ console.error(error.stdout)
254
+ }
255
+ if (error.stderr) {
256
+ console.error(error.stderr)
257
+ }
258
+ throw error
259
+ }
260
+ }
261
+
262
+ async function runTests(skipTests, pkg, rootDir = process.cwd()) {
263
+ if (skipTests) {
264
+ logWarning('Skipping tests because --skip-tests flag was provided.')
265
+ return
266
+ }
267
+
268
+ // Check for test:run or test script
269
+ if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
270
+ logStep('Skipping tests (no test or test:run script found in package.json).')
271
+ return
272
+ }
273
+
274
+ logStep('Running test suite...')
275
+
276
+ try {
277
+ // Prefer test:run if available, otherwise use test with --run and --reporter flags
278
+ if (hasScript(pkg, 'test:run')) {
279
+ // Pass reporter flag to test:run script
280
+ await runCommand('npm', ['run', 'test:run', '--', '--reporter=verbose'], { cwd: rootDir })
281
+ } else {
282
+ // For test script, pass --run and --reporter flags (works with vitest)
283
+ await runCommand('npm', ['test', '--', '--run', '--reporter=verbose'], { cwd: rootDir })
284
+ }
285
+
286
+ logSuccess('Tests passed.')
287
+ } catch (error) {
288
+ if (error.stdout) {
289
+ console.error(error.stdout)
290
+ }
291
+ if (error.stderr) {
292
+ console.error(error.stderr)
293
+ }
294
+ throw error
295
+ }
296
+ }
297
+
298
+ async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
299
+ if (skipBuild) {
300
+ logWarning('Skipping build because --skip-build flag was provided.')
301
+ return
302
+ }
303
+
304
+ if (!hasScript(pkg, 'build')) {
305
+ logStep('Skipping build (no build script found in package.json).')
306
+ return
307
+ }
308
+
309
+ logStep('Building project...')
310
+
311
+ try {
312
+ await runCommand('npm', ['run', 'build'], { cwd: rootDir })
313
+ logSuccess('Build completed.')
314
+ } catch (error) {
315
+ if (error.stdout) {
316
+ console.error(error.stdout)
317
+ }
318
+ if (error.stderr) {
319
+ console.error(error.stderr)
320
+ }
321
+ throw error
322
+ }
323
+ }
324
+
325
+ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
326
+ if (skipBuild) {
327
+ logWarning('Skipping library build because --skip-build flag was provided.')
328
+ return
329
+ }
330
+
331
+ if (!hasScript(pkg, 'build:lib')) {
332
+ logStep('Skipping library build (no build:lib script found in package.json).')
333
+ return false
334
+ }
335
+
336
+ logStep('Building library...')
337
+
338
+ try {
339
+ await runCommand('npm', ['run', 'build:lib'], { cwd: rootDir })
340
+ logSuccess('Library built.')
341
+ } catch (error) {
342
+ if (error.stdout) {
343
+ console.error(error.stdout)
344
+ }
345
+ if (error.stderr) {
346
+ console.error(error.stderr)
347
+ }
348
+ throw error
349
+ }
350
+
351
+ // Check for lib changes and commit them if any
352
+ const { stdout: statusAfterBuild } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
353
+ const hasLibChanges = statusAfterBuild.split('\n').some(line => {
354
+ const trimmed = line.trim()
355
+ return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
356
+ })
357
+
358
+ if (hasLibChanges) {
359
+ logStep('Committing lib build artifacts...')
360
+ await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
361
+ await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { capture: true, cwd: rootDir })
362
+ logSuccess('Lib build artifacts committed.')
363
+ }
364
+
365
+ return hasLibChanges
366
+ }
367
+
368
+ async function ensureNpmAuth(rootDir = process.cwd()) {
369
+ logStep('Confirming npm authentication...')
370
+ try {
371
+ const result = await runCommand('npm', ['whoami'], { capture: true, cwd: rootDir })
372
+ // Only show username if we captured it, otherwise just show success
373
+ if (result?.stdout) {
374
+ // Silently authenticated - we don't need to show the username
375
+ }
376
+ logSuccess('npm authenticated.')
377
+ } catch (error) {
378
+ if (error.stderr) {
379
+ console.error(error.stderr)
380
+ }
381
+ throw error
382
+ }
383
+ }
384
+
385
+ async function bumpVersion(releaseType, rootDir = process.cwd()) {
386
+ logStep(`Bumping package version...`)
387
+
388
+ // Lib changes should already be committed by runLibBuild, but check anyway
389
+ const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
390
+ const hasLibChanges = statusBefore.split('\n').some(line => {
391
+ const trimmed = line.trim()
392
+ return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
393
+ })
394
+
395
+ if (hasLibChanges) {
396
+ logStep('Stashing lib build artifacts...')
397
+ await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { capture: true, cwd: rootDir })
398
+ }
399
+
400
+ try {
401
+ // npm version will update package.json and create a commit with default message
402
+ const result = await runCommand('npm', ['version', releaseType], { capture: true, cwd: rootDir })
403
+ // Extract version from output (e.g., "v0.2.8" or "0.2.8")
404
+ if (result?.stdout) {
405
+ const versionMatch = result.stdout.match(/v?(\d+\.\d+\.\d+)/)
406
+ if (versionMatch) {
407
+ // Version is shown in the logSuccess message below, no need to show it here
408
+ }
409
+ }
410
+ } finally {
411
+ // Restore lib changes and ensure they're in the commit
412
+ if (hasLibChanges) {
413
+ logStep('Restoring lib build artifacts...')
414
+ await runCommand('git', ['stash', 'pop'], { capture: true, cwd: rootDir })
415
+ await runCommand('git', ['add', 'lib/'], { capture: true, cwd: rootDir })
416
+ const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
417
+ if (statusAfter.includes('lib/')) {
418
+ await runCommand('git', ['commit', '--amend', '--no-edit'], { capture: true, cwd: rootDir })
419
+ }
420
+ }
421
+ }
422
+
423
+ const pkg = await readPackage(rootDir)
424
+ const commitMessage = `chore: release ${pkg.version}`
425
+
426
+ // Amend the commit message to use our custom format
427
+ await runCommand('git', ['commit', '--amend', '-m', commitMessage], { capture: true, cwd: rootDir })
428
+
429
+ logSuccess(`Version updated to ${pkg.version}.`)
430
+ return pkg
431
+ }
432
+
433
+ async function pushChanges(rootDir = process.cwd()) {
434
+ logStep('Pushing commits and tags to origin...')
435
+ try {
436
+ await runCommand('git', ['push', '--follow-tags'], { capture: true, cwd: rootDir })
437
+ logSuccess('Git push completed.')
438
+ } catch (error) {
439
+ if (error.stdout) {
440
+ console.error(error.stdout)
441
+ }
442
+ if (error.stderr) {
443
+ console.error(error.stderr)
444
+ }
445
+ throw error
446
+ }
447
+ }
448
+
449
+ async function publishPackage(pkg, rootDir = process.cwd()) {
450
+ // Check if package is configured as private/restricted
451
+ const isPrivate = pkg.publishConfig?.access === 'restricted'
452
+
453
+ if (isPrivate) {
454
+ logWarning('Skipping npm publish (package is configured as private/restricted).')
455
+ logWarning('Private packages require npm paid plan. Publish manually or use GitHub Packages.')
456
+ return
457
+ }
458
+
459
+ const publishArgs = ['publish', '--ignore-scripts'] // Skip prepublishOnly since we already built lib
460
+
461
+ if (pkg.name.startsWith('@')) {
462
+ // For scoped packages, determine access level from publishConfig
463
+ // Default to 'public' for scoped packages if not specified (free npm accounts require public for scoped packages)
464
+ const access = pkg.publishConfig?.access || 'public'
465
+ publishArgs.push('--access', access)
466
+ }
467
+
468
+ logStep(`Publishing ${pkg.name}@${pkg.version} to npm...`)
469
+ try {
470
+ await runCommand('npm', publishArgs, { capture: true, cwd: rootDir })
471
+ logSuccess('npm publish completed.')
472
+ } catch (error) {
473
+ if (error.stdout) {
474
+ console.error(error.stdout)
475
+ }
476
+ if (error.stderr) {
477
+ console.error(error.stderr)
478
+ }
479
+ throw error
480
+ }
481
+ }
482
+
483
+ function extractDomainFromHomepage(homepage) {
484
+ if (!homepage) return null
485
+ try {
486
+ const url = new URL(homepage)
487
+ return url.hostname
488
+ } catch {
489
+ // If it's not a valid URL, try to extract domain from string
490
+ const match = homepage.match(/(?:https?:\/\/)?([^\/]+)/)
491
+ return match ? match[1] : null
492
+ }
493
+ }
494
+
495
+ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
496
+ if (skipDeploy) {
497
+ logWarning('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
498
+ return
499
+ }
500
+
501
+ // Check if dist directory exists (indicates build output for deployment)
502
+ const distPath = path.join(rootDir, 'dist')
503
+ let distExists = false
504
+ try {
505
+ const stats = await fs.promises.stat(distPath)
506
+ distExists = stats.isDirectory()
507
+ } catch {
508
+ distExists = false
509
+ }
510
+
511
+ if (!distExists) {
512
+ logStep('Skipping GitHub Pages deployment (no dist directory found).')
513
+ return
514
+ }
515
+
516
+ logStep('Deploying to GitHub Pages...')
517
+
518
+ // Write CNAME file to dist if homepage is set
519
+ const cnamePath = path.join(distPath, 'CNAME')
520
+
521
+ if (pkg.homepage) {
522
+ const domain = extractDomainFromHomepage(pkg.homepage)
523
+ if (domain) {
524
+ try {
525
+ await fs.promises.mkdir(distPath, { recursive: true })
526
+ await fs.promises.writeFile(cnamePath, domain)
527
+ } catch (error) {
528
+ logWarning(`Could not write CNAME file: ${error.message}`)
529
+ }
530
+ }
531
+ }
532
+
533
+ const worktreeDir = path.resolve(rootDir, '.gh-pages')
534
+
535
+ try {
536
+ try {
537
+ await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], { capture: true, cwd: rootDir })
538
+ } catch { }
539
+
540
+ try {
541
+ await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], { capture: true, cwd: rootDir })
542
+ } catch {
543
+ await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], { capture: true, cwd: rootDir })
544
+ }
545
+
546
+ await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'], { capture: true })
547
+ await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'], { capture: true })
548
+
549
+ // Clear worktree directory
550
+ for (const entry of fs.readdirSync(worktreeDir)) {
551
+ if (entry === '.git') continue
552
+ const target = path.join(worktreeDir, entry)
553
+ fs.rmSync(target, { recursive: true, force: true })
554
+ }
555
+
556
+ // Copy dist to worktree
557
+ fs.cpSync(distPath, worktreeDir, { recursive: true })
558
+
559
+ await runCommand('git', ['-C', worktreeDir, 'add', '-A'], { capture: true })
560
+ await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'], { capture: true })
561
+ await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'], { capture: true })
562
+
563
+ logSuccess('GitHub Pages deployment completed.')
564
+ } catch (error) {
565
+ if (error.stdout) {
566
+ console.error(error.stdout)
567
+ }
568
+ if (error.stderr) {
569
+ console.error(error.stderr)
570
+ }
571
+ throw error
572
+ }
573
+ }
574
+
575
+ export async function releaseNode() {
576
+ try {
577
+ const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
578
+ const rootDir = process.cwd()
579
+
580
+ logStep('Reading package metadata...')
581
+ const pkg = await readPackage(rootDir)
582
+
583
+ logStep('Checking working tree status...')
584
+ await ensureCleanWorkingTree(rootDir)
585
+
586
+ const branch = await getCurrentBranch(rootDir)
587
+ if (!branch) {
588
+ throw new Error('Unable to determine current branch.')
589
+ }
590
+
591
+ logStep(`Current branch: ${branch}`)
592
+ const upstreamRef = await getUpstreamRef(rootDir)
593
+ await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
594
+
595
+ await runLint(skipLint, pkg, rootDir)
596
+ await runTests(skipTests, pkg, rootDir)
597
+ await runBuild(skipBuild, pkg, rootDir)
598
+ await runLibBuild(skipBuild, pkg, rootDir)
599
+ await ensureNpmAuth(rootDir)
600
+
601
+ const updatedPkg = await bumpVersion(releaseType, rootDir)
602
+ await pushChanges(rootDir)
603
+ await publishPackage(updatedPkg, rootDir)
604
+ await deployGHPages(skipDeploy, updatedPkg, rootDir)
605
+
606
+ logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
607
+ } catch (error) {
608
+ console.error('\nRelease failed:')
609
+ console.error(error.message)
610
+ throw error
611
+ }
612
+ }
613
+