@wyxos/zephyr 0.2.5 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/release-node.mjs +96 -69
- package/src/ssh-utils.mjs +42 -5
package/package.json
CHANGED
package/src/release-node.mjs
CHANGED
|
@@ -5,24 +5,14 @@ import { readFile } from 'node:fs/promises'
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import process from 'node:process'
|
|
8
|
-
|
|
9
|
-
const STEP_PREFIX = '→'
|
|
10
|
-
const OK_PREFIX = '✔'
|
|
11
|
-
const WARN_PREFIX = '⚠'
|
|
8
|
+
import chalk from 'chalk'
|
|
12
9
|
|
|
13
10
|
const IS_WINDOWS = process.platform === 'win32'
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
}
|
|
12
|
+
const logProcessing = (message = '') => console.log(chalk.yellow(message))
|
|
13
|
+
const logSuccess = (message = '') => console.log(chalk.green(message))
|
|
14
|
+
const logWarning = (message = '') => console.warn(chalk.yellow(message))
|
|
15
|
+
const logError = (message = '') => console.error(chalk.red(message))
|
|
26
16
|
|
|
27
17
|
function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
|
|
28
18
|
return new Promise((resolve, reject) => {
|
|
@@ -114,7 +104,7 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process
|
|
|
114
104
|
const remoteBranch = branchParts.join('/')
|
|
115
105
|
|
|
116
106
|
if (remoteName && remoteBranch) {
|
|
117
|
-
|
|
107
|
+
logProcessing(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
|
|
118
108
|
try {
|
|
119
109
|
await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir })
|
|
120
110
|
} catch (error) {
|
|
@@ -136,7 +126,7 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process
|
|
|
136
126
|
|
|
137
127
|
if (Number.isFinite(behind) && behind > 0) {
|
|
138
128
|
if (remoteName && remoteBranch) {
|
|
139
|
-
|
|
129
|
+
logProcessing(`Fast-forwarding ${branch} with ${upstreamRef}...`)
|
|
140
130
|
|
|
141
131
|
try {
|
|
142
132
|
await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir })
|
|
@@ -198,11 +188,11 @@ async function runLint(skipLint, pkg, rootDir = process.cwd()) {
|
|
|
198
188
|
}
|
|
199
189
|
|
|
200
190
|
if (!hasScript(pkg, 'lint')) {
|
|
201
|
-
|
|
191
|
+
logProcessing('Skipping lint (no lint script found in package.json).')
|
|
202
192
|
return
|
|
203
193
|
}
|
|
204
194
|
|
|
205
|
-
|
|
195
|
+
logProcessing('Running lint...')
|
|
206
196
|
await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
|
|
207
197
|
logSuccess('Lint passed.')
|
|
208
198
|
}
|
|
@@ -215,21 +205,49 @@ async function runTests(skipTests, pkg, rootDir = process.cwd()) {
|
|
|
215
205
|
|
|
216
206
|
// Check for test:run or test script
|
|
217
207
|
if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
|
|
218
|
-
|
|
208
|
+
logProcessing('Skipping tests (no test or test:run script found in package.json).')
|
|
219
209
|
return
|
|
220
210
|
}
|
|
221
211
|
|
|
222
|
-
|
|
212
|
+
logProcessing('Running test suite...')
|
|
223
213
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
214
|
+
let dotInterval = null
|
|
215
|
+
try {
|
|
216
|
+
// Capture output and show dots as progress
|
|
217
|
+
process.stdout.write(' ')
|
|
218
|
+
dotInterval = setInterval(() => {
|
|
219
|
+
process.stdout.write('.')
|
|
220
|
+
}, 200)
|
|
221
|
+
|
|
222
|
+
// Prefer test:run if available, otherwise use test with --run flag
|
|
223
|
+
if (hasScript(pkg, 'test:run')) {
|
|
224
|
+
await runCommand('npm', ['run', 'test:run'], { capture: true, cwd: rootDir })
|
|
225
|
+
} else {
|
|
226
|
+
// For test script, try to pass --run flag (works with vitest)
|
|
227
|
+
await runCommand('npm', ['test', '--', '--run'], { capture: true, cwd: rootDir })
|
|
228
|
+
}
|
|
231
229
|
|
|
232
|
-
|
|
230
|
+
if (dotInterval) {
|
|
231
|
+
clearInterval(dotInterval)
|
|
232
|
+
dotInterval = null
|
|
233
|
+
}
|
|
234
|
+
process.stdout.write('\n')
|
|
235
|
+
logSuccess('Tests passed.')
|
|
236
|
+
} catch (error) {
|
|
237
|
+
// Clear dots and show error output
|
|
238
|
+
if (dotInterval) {
|
|
239
|
+
clearInterval(dotInterval)
|
|
240
|
+
dotInterval = null
|
|
241
|
+
}
|
|
242
|
+
process.stdout.write('\n')
|
|
243
|
+
if (error.stdout) {
|
|
244
|
+
logError(error.stdout)
|
|
245
|
+
}
|
|
246
|
+
if (error.stderr) {
|
|
247
|
+
logError(error.stderr)
|
|
248
|
+
}
|
|
249
|
+
throw error
|
|
250
|
+
}
|
|
233
251
|
}
|
|
234
252
|
|
|
235
253
|
async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
|
|
@@ -239,11 +257,11 @@ async function runBuild(skipBuild, pkg, rootDir = process.cwd()) {
|
|
|
239
257
|
}
|
|
240
258
|
|
|
241
259
|
if (!hasScript(pkg, 'build')) {
|
|
242
|
-
|
|
260
|
+
logProcessing('Skipping build (no build script found in package.json).')
|
|
243
261
|
return
|
|
244
262
|
}
|
|
245
263
|
|
|
246
|
-
|
|
264
|
+
logProcessing('Building project...')
|
|
247
265
|
await runCommand('npm', ['run', 'build'], { cwd: rootDir })
|
|
248
266
|
logSuccess('Build completed.')
|
|
249
267
|
}
|
|
@@ -255,11 +273,11 @@ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
|
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
if (!hasScript(pkg, 'build:lib')) {
|
|
258
|
-
|
|
276
|
+
logProcessing('Skipping library build (no build:lib script found in package.json).')
|
|
259
277
|
return false
|
|
260
278
|
}
|
|
261
279
|
|
|
262
|
-
|
|
280
|
+
logProcessing('Building library...')
|
|
263
281
|
await runCommand('npm', ['run', 'build:lib'], { cwd: rootDir })
|
|
264
282
|
logSuccess('Library built.')
|
|
265
283
|
|
|
@@ -271,7 +289,7 @@ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
|
|
|
271
289
|
})
|
|
272
290
|
|
|
273
291
|
if (hasLibChanges) {
|
|
274
|
-
|
|
292
|
+
logProcessing('Committing lib build artifacts...')
|
|
275
293
|
await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
|
|
276
294
|
await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], { cwd: rootDir })
|
|
277
295
|
logSuccess('Lib build artifacts committed.')
|
|
@@ -281,13 +299,13 @@ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd()) {
|
|
|
281
299
|
}
|
|
282
300
|
|
|
283
301
|
async function ensureNpmAuth(rootDir = process.cwd()) {
|
|
284
|
-
|
|
302
|
+
logProcessing('Confirming npm authentication...')
|
|
285
303
|
await runCommand('npm', ['whoami'], { cwd: rootDir })
|
|
286
304
|
logSuccess('npm authenticated.')
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
async function bumpVersion(releaseType, rootDir = process.cwd()) {
|
|
290
|
-
|
|
308
|
+
logProcessing(`Bumping package version...`)
|
|
291
309
|
|
|
292
310
|
// Lib changes should already be committed by runLibBuild, but check anyway
|
|
293
311
|
const { stdout: statusBefore } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
|
|
@@ -297,7 +315,7 @@ async function bumpVersion(releaseType, rootDir = process.cwd()) {
|
|
|
297
315
|
})
|
|
298
316
|
|
|
299
317
|
if (hasLibChanges) {
|
|
300
|
-
|
|
318
|
+
logProcessing('Stashing lib build artifacts...')
|
|
301
319
|
await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], { cwd: rootDir })
|
|
302
320
|
}
|
|
303
321
|
|
|
@@ -307,7 +325,7 @@ async function bumpVersion(releaseType, rootDir = process.cwd()) {
|
|
|
307
325
|
} finally {
|
|
308
326
|
// Restore lib changes and ensure they're in the commit
|
|
309
327
|
if (hasLibChanges) {
|
|
310
|
-
|
|
328
|
+
logProcessing('Restoring lib build artifacts...')
|
|
311
329
|
await runCommand('git', ['stash', 'pop'], { cwd: rootDir })
|
|
312
330
|
await runCommand('git', ['add', 'lib/'], { cwd: rootDir })
|
|
313
331
|
const { stdout: statusAfter } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
|
|
@@ -328,7 +346,7 @@ async function bumpVersion(releaseType, rootDir = process.cwd()) {
|
|
|
328
346
|
}
|
|
329
347
|
|
|
330
348
|
async function pushChanges(rootDir = process.cwd()) {
|
|
331
|
-
|
|
349
|
+
logProcessing('Pushing commits and tags to origin...')
|
|
332
350
|
await runCommand('git', ['push', '--follow-tags'], { cwd: rootDir })
|
|
333
351
|
logSuccess('Git push completed.')
|
|
334
352
|
}
|
|
@@ -337,10 +355,13 @@ async function publishPackage(pkg, rootDir = process.cwd()) {
|
|
|
337
355
|
const publishArgs = ['publish', '--ignore-scripts'] // Skip prepublishOnly since we already built lib
|
|
338
356
|
|
|
339
357
|
if (pkg.name.startsWith('@')) {
|
|
340
|
-
|
|
358
|
+
// For scoped packages, determine access level from publishConfig
|
|
359
|
+
// Default to 'public' for scoped packages if not specified (free npm accounts require public for scoped packages)
|
|
360
|
+
const access = pkg.publishConfig?.access || 'public'
|
|
361
|
+
publishArgs.push('--access', access)
|
|
341
362
|
}
|
|
342
363
|
|
|
343
|
-
|
|
364
|
+
logProcessing(`Publishing ${pkg.name}@${pkg.version} to npm...`)
|
|
344
365
|
await runCommand('npm', publishArgs, { cwd: rootDir })
|
|
345
366
|
logSuccess('npm publish completed.')
|
|
346
367
|
}
|
|
@@ -374,11 +395,11 @@ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
|
|
|
374
395
|
}
|
|
375
396
|
|
|
376
397
|
if (!distExists) {
|
|
377
|
-
|
|
398
|
+
logProcessing('Skipping GitHub Pages deployment (no dist directory found).')
|
|
378
399
|
return
|
|
379
400
|
}
|
|
380
401
|
|
|
381
|
-
|
|
402
|
+
logProcessing('Deploying to GitHub Pages...')
|
|
382
403
|
|
|
383
404
|
// Write CNAME file to dist if homepage is set
|
|
384
405
|
const cnamePath = path.join(distPath, 'CNAME')
|
|
@@ -428,35 +449,41 @@ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd()) {
|
|
|
428
449
|
}
|
|
429
450
|
|
|
430
451
|
export async function releaseNode() {
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
}
|
|
452
|
+
try {
|
|
453
|
+
const { releaseType, skipTests, skipLint, skipBuild, skipDeploy } = parseArgs()
|
|
454
|
+
const rootDir = process.cwd()
|
|
444
455
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
|
|
456
|
+
logProcessing('Reading package metadata...')
|
|
457
|
+
const pkg = await readPackage(rootDir)
|
|
448
458
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
await runBuild(skipBuild, pkg, rootDir)
|
|
452
|
-
await runLibBuild(skipBuild, pkg, rootDir)
|
|
453
|
-
await ensureNpmAuth(rootDir)
|
|
459
|
+
logProcessing('Checking working tree status...')
|
|
460
|
+
await ensureCleanWorkingTree(rootDir)
|
|
454
461
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
462
|
+
const branch = await getCurrentBranch(rootDir)
|
|
463
|
+
if (!branch) {
|
|
464
|
+
throw new Error('Unable to determine current branch.')
|
|
465
|
+
}
|
|
459
466
|
|
|
460
|
-
|
|
467
|
+
logProcessing(`Current branch: ${branch}`)
|
|
468
|
+
const upstreamRef = await getUpstreamRef(rootDir)
|
|
469
|
+
await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
|
|
470
|
+
|
|
471
|
+
await runLint(skipLint, pkg, rootDir)
|
|
472
|
+
await runTests(skipTests, pkg, rootDir)
|
|
473
|
+
await runBuild(skipBuild, pkg, rootDir)
|
|
474
|
+
await runLibBuild(skipBuild, pkg, rootDir)
|
|
475
|
+
await ensureNpmAuth(rootDir)
|
|
476
|
+
|
|
477
|
+
const updatedPkg = await bumpVersion(releaseType, rootDir)
|
|
478
|
+
await pushChanges(rootDir)
|
|
479
|
+
await publishPackage(updatedPkg, rootDir)
|
|
480
|
+
await deployGHPages(skipDeploy, updatedPkg, rootDir)
|
|
481
|
+
|
|
482
|
+
logSuccess(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
|
|
483
|
+
} catch (error) {
|
|
484
|
+
logError('\nRelease failed:')
|
|
485
|
+
logError(error.message)
|
|
486
|
+
throw error
|
|
487
|
+
}
|
|
461
488
|
}
|
|
462
489
|
|
package/src/ssh-utils.mjs
CHANGED
|
@@ -92,11 +92,11 @@ export async function connectToServer(config, rootDir) {
|
|
|
92
92
|
* @param {NodeSSH} ssh - SSH client instance
|
|
93
93
|
* @param {string} label - Human-readable label for the command
|
|
94
94
|
* @param {string} command - Command to execute
|
|
95
|
-
* @param {Object} options - Options: { cwd, allowFailure, printStdout, bootstrapEnv, rootDir, writeToLogFile }
|
|
95
|
+
* @param {Object} options - Options: { cwd, allowFailure, printStdout, bootstrapEnv, rootDir, writeToLogFile, env }
|
|
96
96
|
* @returns {Promise<Object>} Command result
|
|
97
97
|
*/
|
|
98
98
|
export async function executeRemoteCommand(ssh, label, command, options = {}) {
|
|
99
|
-
const { cwd, allowFailure = false, printStdout = true, bootstrapEnv = true, rootDir = null, writeToLogFile = null } = options
|
|
99
|
+
const { cwd, allowFailure = false, printStdout = true, bootstrapEnv = true, rootDir = null, writeToLogFile = null, env = {} } = options
|
|
100
100
|
|
|
101
101
|
logProcessing(`\n→ ${label}`)
|
|
102
102
|
|
|
@@ -120,14 +120,27 @@ export async function executeRemoteCommand(ssh, label, command, options = {}) {
|
|
|
120
120
|
].join('; ')
|
|
121
121
|
|
|
122
122
|
const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
|
|
123
|
+
const escapeForSingleQuotes = (value) => value.replace(/'/g, "'\\''")
|
|
124
|
+
|
|
125
|
+
// Build environment variable exports
|
|
126
|
+
let envExports = ''
|
|
127
|
+
if (Object.keys(env).length > 0) {
|
|
128
|
+
const envPairs = Object.entries(env).map(([key, value]) => {
|
|
129
|
+
const escapedValue = escapeForSingleQuotes(String(value))
|
|
130
|
+
return `${key}='${escapedValue}'`
|
|
131
|
+
})
|
|
132
|
+
envExports = envPairs.join(' ') + ' '
|
|
133
|
+
}
|
|
123
134
|
|
|
124
135
|
let wrappedCommand = command
|
|
125
136
|
let execOptions = { cwd }
|
|
126
137
|
|
|
127
138
|
if (bootstrapEnv && cwd) {
|
|
128
139
|
const cwdForShell = escapeForDoubleQuotes(cwd)
|
|
129
|
-
wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${command}`
|
|
140
|
+
wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${envExports}${command}`
|
|
130
141
|
execOptions = {}
|
|
142
|
+
} else if (Object.keys(env).length > 0) {
|
|
143
|
+
wrappedCommand = `${envExports}${command}`
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
const result = await ssh.execCommand(wrappedCommand, execOptions)
|
|
@@ -191,7 +204,13 @@ export async function readRemoteFile(ssh, filePath, remoteCwd) {
|
|
|
191
204
|
}
|
|
192
205
|
|
|
193
206
|
/**
|
|
194
|
-
* Download file from remote server via SFTP
|
|
207
|
+
* Download file from remote server via SFTP with progress
|
|
208
|
+
*
|
|
209
|
+
* Note: Currently uses single-stream download (most reliable).
|
|
210
|
+
* Multi-streaming is technically possible with ssh2-sftp-client's fastGet,
|
|
211
|
+
* but it's unreliable on many servers and can cause data corruption.
|
|
212
|
+
* Single-stream ensures data integrity at the cost of potentially slower speeds.
|
|
213
|
+
*
|
|
195
214
|
* @param {NodeSSH} ssh - SSH client instance
|
|
196
215
|
* @param {string} remotePath - Path to file on remote server
|
|
197
216
|
* @param {string} localPath - Local path to save file
|
|
@@ -206,8 +225,26 @@ export async function downloadRemoteFile(ssh, remotePath, localPath, remoteCwd)
|
|
|
206
225
|
|
|
207
226
|
logProcessing(`Downloading ${absoluteRemotePath} to ${localPath}...`)
|
|
208
227
|
|
|
209
|
-
|
|
228
|
+
let transferred = 0
|
|
229
|
+
const startTime = Date.now()
|
|
230
|
+
|
|
231
|
+
// Single-stream download (most reliable for data integrity)
|
|
232
|
+
await ssh.getFile(localPath, absoluteRemotePath, null, {
|
|
233
|
+
step: (totalTransferred, chunk, total) => {
|
|
234
|
+
transferred = totalTransferred
|
|
235
|
+
const percent = total > 0 ? Math.round((transferred / total) * 100) : 0
|
|
236
|
+
const elapsed = (Date.now() - startTime) / 1000
|
|
237
|
+
const speed = elapsed > 0 ? (transferred / elapsed / 1024 / 1024).toFixed(2) : 0
|
|
238
|
+
const sizeMB = (transferred / 1024 / 1024).toFixed(2)
|
|
239
|
+
const totalMB = total > 0 ? (total / 1024 / 1024).toFixed(2) : '?'
|
|
240
|
+
|
|
241
|
+
// Update progress on same line
|
|
242
|
+
process.stdout.write(`\r Progress: ${percent}% (${sizeMB}MB / ${totalMB}MB) - ${speed} MB/s`)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
210
245
|
|
|
246
|
+
// Clear progress line and show completion
|
|
247
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
211
248
|
logSuccess(`Downloaded ${absoluteRemotePath} to ${localPath}`)
|
|
212
249
|
}
|
|
213
250
|
|