Skip to content

Commit 3825db6

Browse files
committed
feat: add retry and retry delay options for command execution
1 parent efa806a commit 3825db6

File tree

8 files changed

+206
-49
lines changed

8 files changed

+206
-49
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"scripts": {
3939
"start": "tsx ./src/cli.ts",
4040
"build": "unbuild",
41-
"typecheck": "tsc",
41+
"typecheck": "tsc --noEmit",
4242
"test": "vitest",
4343
"lint": "eslint",
4444
"deps": "taze major -I",

src/cli.ts

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import type { CAC } from 'cac'
2-
import type { CommandOptions, MaybePromise, PublishOptions } from './types'
2+
import type { CommandOptions, PublishOptions } from './types'
33
import { existsSync } from 'node:fs'
44
import process from 'node:process'
55
import c from 'ansis'
66
import { cac } from 'cac'
77
import { execa } from 'execa'
8-
import Spinner from 'yocto-spinner'
98
import { name, version } from '../package.json'
109
import { resolveConfig } from './config'
1110
import { PLATFORM_CHOICES } from './constants'
11+
import { executeWithFeedback } from './utils'
1212

1313
try {
1414
const cli: CAC = cac('vsxpub')
1515

1616
cli
1717
.command('')
18+
.option('--cwd <dir>', 'Current working directory')
1819
.option('--repo <repo>', 'Github repo')
1920
.option('--tag <tag>', 'Github tag')
2021
.option('--name <name>', 'Extension name')
@@ -25,6 +26,8 @@ try {
2526
.option('--ovsx-pat <token>', 'Open Vsx Registry Token')
2627
.option('--include <platforms>', 'Include platforms from publishing (git, vsce, ovsx)', { default: PLATFORM_CHOICES })
2728
.option('--exclude <platforms>', 'Exclude platforms from publishing (git, vsce, ovsx)', { default: [] })
29+
.option('--retry <count>', 'Retry count', { default: 3 })
30+
.option('--retry-delay <ms>', 'Retry delay', { default: 1000 })
2831
.option('--dry', 'Dry run', { default: false })
2932
.allowUnknownOptions()
3033
.action(async (options: CommandOptions) => {
@@ -177,38 +180,3 @@ function normalizeArgs(args: string[], options: PublishOptions) {
177180

178181
return args
179182
}
180-
181-
async function executeWithFeedback(options: {
182-
config: PublishOptions
183-
message: string
184-
successMessage: string
185-
errorMessage: string
186-
fn: () => MaybePromise<void>
187-
dryFn?: () => MaybePromise<void>
188-
}): Promise<boolean> {
189-
let result = false
190-
const spinner = Spinner({ text: c.blue(options.message) }).start()
191-
192-
try {
193-
if (options.config.dry) {
194-
console.log()
195-
await options.dryFn?.()
196-
}
197-
else {
198-
await options.fn()
199-
}
200-
201-
result = true
202-
spinner.success(c.green(options.successMessage))
203-
}
204-
catch (error) {
205-
spinner.error(c.red(options.errorMessage))
206-
console.error(c.red(error instanceof Error ? error.message : String(error)))
207-
result = false
208-
}
209-
finally {
210-
console.log()
211-
}
212-
213-
return result
214-
}

src/config.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CommandOptions, Platform, PublishOptions } from './types'
22
import process from 'node:process'
3+
import c from 'ansis'
34
import { createConfigLoader } from 'unconfig'
45
import { DEFAULT_PUBLISH_OPTIONS } from './constants'
56
import { getGitHubRepo, getGitTag, getVersionByGitTag, readTokenFromGitHubCli } from './git'
@@ -22,9 +23,7 @@ export async function resolveConfig(options: Partial<CommandOptions>): Promise<P
2223
const loader = createConfigLoader<CommandOptions>({
2324
sources: [
2425
{
25-
files: [
26-
'vsxpub.config',
27-
],
26+
files: ['vsxpub.config'],
2827
},
2928
],
3029
cwd,
@@ -59,5 +58,27 @@ export async function resolveConfig(options: Partial<CommandOptions>): Promise<P
5958

6059
config.include = include.filter(p => !config.exclude.includes(p as Platform))
6160

61+
if (config.retry && typeof config.retry === 'string') {
62+
const retry = Number(config.retry)
63+
if (Number.isNaN(retry)) {
64+
console.error(c.red('Invalid retry count'))
65+
config.retry = 3
66+
}
67+
else {
68+
config.retry = retry
69+
}
70+
}
71+
72+
if (config.retryDelay && typeof config.retryDelay === 'string') {
73+
const retryDelay = Number(config.retryDelay)
74+
if (Number.isNaN(retryDelay)) {
75+
console.error(c.red('Invalid retry delay'))
76+
config.retryDelay = 1000
77+
}
78+
else {
79+
config.retryDelay = retryDelay
80+
}
81+
}
82+
6283
return config as PublishOptions
6384
}

src/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export const PLATFORM_CHOICES = ['git', 'vsce', 'ovsx'] as const
33
export const DEFAULT_PUBLISH_OPTIONS = {
44
baseUrl: 'github.com',
55
baseUrlApi: 'api.github.com',
6-
include: PLATFORM_CHOICES,
6+
include: [...PLATFORM_CHOICES],
77
exclude: [],
8+
retry: 3,
9+
retryDelay: 1000,
810
}

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ export interface CommonOptions {
99
dry?: boolean
1010
include?: Platform[]
1111
exclude?: Platform[]
12+
/**
13+
* Retry count
14+
* @default 3
15+
*/
16+
retry?: number
17+
/**
18+
* Retry delay
19+
* @default 1000ms
20+
*/
21+
retryDelay?: number
1222
}
1323

1424
export interface CommandOptions extends CommonOptions {

src/utils.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* eslint-disable no-console */
2+
import type { MaybePromise, PublishOptions } from './types'
3+
import c from 'ansis'
4+
import Spinner from 'yocto-spinner'
5+
6+
export async function executeWithFeedback(options: {
7+
config: PublishOptions
8+
message: string
9+
successMessage: string
10+
errorMessage: string
11+
fn: () => MaybePromise<void>
12+
dryFn?: () => MaybePromise<void>
13+
}): Promise<boolean> {
14+
let result = false
15+
const spinner = Spinner({ text: c.blue(options.message) }).start()
16+
17+
const maxRetries = options.config.retry ?? 3
18+
const retryDelay = options.config.retryDelay ?? 1000
19+
let currentAttempt = 0
20+
21+
while (currentAttempt <= maxRetries) {
22+
try {
23+
if (currentAttempt > 0) {
24+
console.log(c.yellow(`${options.message} (retry ${currentAttempt} of ${maxRetries})`))
25+
}
26+
27+
if (options.config.dry) {
28+
console.log()
29+
await options.dryFn?.()
30+
}
31+
else {
32+
await options.fn()
33+
}
34+
35+
result = true
36+
spinner.success(c.green(options.successMessage))
37+
break
38+
}
39+
catch (error) {
40+
currentAttempt++
41+
console.error(c.red(error instanceof Error ? error.message : String(error)))
42+
43+
if (currentAttempt <= maxRetries) {
44+
console.log(c.yellow(`Retrying in ${retryDelay}ms...`))
45+
await new Promise(resolve => setTimeout(resolve, retryDelay))
46+
}
47+
else {
48+
spinner.error(c.red(options.errorMessage))
49+
if (maxRetries > 0) {
50+
console.error(c.red(`Failed after ${maxRetries + 1} attempts`))
51+
}
52+
result = false
53+
}
54+
}
55+
}
56+
57+
console.log()
58+
return result
59+
}

test/cli.test.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

test/retry.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { PublishOptions } from '../src/types'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { DEFAULT_PUBLISH_OPTIONS } from '../src/constants'
4+
import { executeWithFeedback } from '../src/utils'
5+
6+
const publisOptions: PublishOptions = {
7+
...DEFAULT_PUBLISH_OPTIONS,
8+
cwd: '/test',
9+
dry: false,
10+
repo: 'test/repo',
11+
tag: 'v1.0.0',
12+
name: 'test-extension',
13+
version: '1.0.0',
14+
dependencies: true,
15+
githubToken: 'token',
16+
vscePat: 'vsce-pat',
17+
ovsxPat: 'ovsx-pat',
18+
}
19+
20+
describe('executeWithFeedback', () => {
21+
it('should execute function successfully', async () => {
22+
const mockFn = vi.fn().mockResolvedValue(undefined)
23+
24+
const result = await executeWithFeedback({
25+
config: publisOptions,
26+
message: 'Testing...',
27+
successMessage: 'Success!',
28+
errorMessage: 'Failed!',
29+
fn: mockFn,
30+
})
31+
32+
expect(result).toBe(true)
33+
expect(mockFn).toHaveBeenCalledTimes(1)
34+
})
35+
36+
it('should execute dryFn when in dry mode', async () => {
37+
const mockFn = vi.fn()
38+
const mockDryFn = vi.fn().mockResolvedValue(undefined)
39+
40+
const result = await executeWithFeedback({
41+
config: { ...publisOptions, dry: true },
42+
message: 'Testing...',
43+
successMessage: 'Dry Run Success!',
44+
errorMessage: 'Dry Run Failed!',
45+
fn: mockFn,
46+
dryFn: mockDryFn,
47+
})
48+
49+
expect(result).toBe(true)
50+
expect(mockFn).not.toHaveBeenCalled()
51+
expect(mockDryFn).toHaveBeenCalledTimes(1)
52+
})
53+
54+
it('should retry on failure', async () => {
55+
const mockFn = vi.fn()
56+
.mockRejectedValueOnce(new Error('First failure'))
57+
.mockRejectedValueOnce(new Error('Second failure'))
58+
.mockResolvedValueOnce(undefined)
59+
60+
const result = await executeWithFeedback({
61+
config: { ...publisOptions, retry: 2, retryDelay: 10 },
62+
message: 'Testing...',
63+
successMessage: 'Success!',
64+
errorMessage: 'Failed!',
65+
fn: mockFn,
66+
})
67+
68+
expect(result).toBe(true)
69+
expect(mockFn).toHaveBeenCalledTimes(3)
70+
})
71+
72+
it('should fail after exhausting retries', async () => {
73+
const mockFn = vi.fn()
74+
.mockRejectedValueOnce(new Error('First failure'))
75+
.mockRejectedValueOnce(new Error('Second failure'))
76+
.mockRejectedValueOnce(new Error('Third failure'))
77+
78+
const result = await executeWithFeedback({
79+
config: { ...publisOptions, retry: 2, retryDelay: 10 },
80+
message: 'Testing...',
81+
successMessage: 'Success!',
82+
errorMessage: 'Failed!',
83+
fn: mockFn,
84+
})
85+
86+
expect(result).toBe(false)
87+
expect(mockFn).toHaveBeenCalledTimes(3)
88+
})
89+
90+
it('should not retry when retryCount is not provided', async () => {
91+
const mockFn = vi.fn().mockRejectedValue(new Error('Error'))
92+
93+
const result = await executeWithFeedback({
94+
config: { ...publisOptions, retry: 0 },
95+
message: 'Testing...',
96+
successMessage: 'Success!',
97+
errorMessage: 'Failed!',
98+
fn: mockFn,
99+
})
100+
101+
expect(result).toBe(false)
102+
expect(mockFn).toHaveBeenCalledTimes(1)
103+
})
104+
})

0 commit comments

Comments
 (0)