Skip to content

Commit 3fb53d3

Browse files
committed
improve package publisher.
1 parent eea2410 commit 3fb53d3

3 files changed

Lines changed: 245 additions & 37 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import path from 'node:path'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({
5+
npm: vi.fn<
6+
(
7+
command: string,
8+
args: string[],
9+
options: unknown,
10+
) => Promise<{ stdout: string; stderr: string }>
11+
>(),
12+
checkFailures: new Map<string, string>(),
13+
publishFailures: new Map<string, string>(),
14+
existing: new Set<string>(),
15+
missingArchives: new Set<string>(),
16+
}))
17+
18+
vi.mock('node:util', () => ({ promisify: () => mocks.npm }))
19+
vi.mock('node:fs', () => ({
20+
existsSync: (file: string) =>
21+
!mocks.missingArchives.has(
22+
file.replaceAll('\\', '/').split('/').at(-2) ?? '',
23+
),
24+
readFileSync: (file: string) => {
25+
const name = file.replaceAll('\\', '/').split('/').at(-2)
26+
return JSON.stringify({
27+
name: `@purestack/${name}`,
28+
version: '1.0.0',
29+
exports: { '.': './dist/index.js' },
30+
})
31+
},
32+
}))
33+
vi.mock('./workspaces', () => ({
34+
workspaceManifestPaths: () =>
35+
['first', 'second', 'third'].map((name) => `packages/${name}/package.json`),
36+
}))
37+
38+
import { publishNewPackages } from './publish-new-packages'
39+
40+
function npmError(message: string) {
41+
return Object.assign(new Error('npm failed'), { stdout: '', stderr: message })
42+
}
43+
44+
function publishAttempts() {
45+
return mocks.npm.mock.calls
46+
.map(([, args]) => args[args.indexOf('publish') + 1])
47+
.filter((arg) => arg?.endsWith('package.tgz'))
48+
.map((file) => path.basename(path.dirname(file)))
49+
}
50+
51+
beforeEach(() => {
52+
mocks.npm.mockReset()
53+
mocks.checkFailures.clear()
54+
mocks.publishFailures.clear()
55+
mocks.existing.clear()
56+
mocks.missingArchives.clear()
57+
vi.spyOn(console, 'log').mockImplementation(() => {})
58+
vi.spyOn(console, 'error').mockImplementation(() => {})
59+
mocks.npm.mockImplementation(async (_command, args) => {
60+
const view = args.indexOf('view')
61+
if (view !== -1) {
62+
const name = args[view + 1]
63+
const failure = mocks.checkFailures.get(name)
64+
if (failure) throw npmError(failure)
65+
if (!mocks.existing.has(name)) throw npmError('npm error code E404')
66+
}
67+
const publish = args.indexOf('publish')
68+
if (publish !== -1) {
69+
const name = path.basename(path.dirname(args[publish + 1]))
70+
const failure = mocks.publishFailures.get(name)
71+
if (failure) throw npmError(failure)
72+
}
73+
return { stdout: '"1.0.0"', stderr: '' }
74+
})
75+
})
76+
77+
afterEach(() => vi.restoreAllMocks())
78+
79+
describe('publishing missing packages', () => {
80+
it('continues publishing after an error and reports the failed package last', async () => {
81+
mocks.publishFailures.set('first', 'npm error E403: permission denied')
82+
83+
expect(await publishNewPackages(['--publish'])).toBe(1)
84+
85+
expect(publishAttempts()).toEqual(['first', 'second', 'third'])
86+
expect(console.log).toHaveBeenLastCalledWith('Published 2 new package(s).')
87+
expect(console.error).toHaveBeenCalledWith('\nFailed 1 package(s):')
88+
expect(console.error).toHaveBeenLastCalledWith(
89+
'\n- @purestack/first@1.0.0 (publish)\nnpm error E403: permission denied',
90+
)
91+
})
92+
93+
it('collects multiple publish failures without stopping later packages', async () => {
94+
mocks.publishFailures.set('first', 'Authentication failed')
95+
mocks.publishFailures.set('second', 'Upload failed')
96+
97+
expect(await publishNewPackages(['--publish'])).toBe(1)
98+
99+
expect(publishAttempts()).toEqual(['first', 'second', 'third'])
100+
expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).')
101+
expect(console.error).toHaveBeenCalledWith('\nFailed 2 package(s):')
102+
expect(console.error).toHaveBeenCalledWith(
103+
'\n- @purestack/first@1.0.0 (publish)\nAuthentication failed',
104+
)
105+
expect(console.error).toHaveBeenLastCalledWith(
106+
'\n- @purestack/second@1.0.0 (publish)\nUpload failed',
107+
)
108+
})
109+
110+
it('also continues after registry-check errors and missing archives', async () => {
111+
mocks.checkFailures.set('@purestack/first', 'Registry unavailable')
112+
mocks.missingArchives.add('second')
113+
114+
expect(await publishNewPackages(['--publish'])).toBe(1)
115+
116+
expect(publishAttempts()).toEqual(['third'])
117+
expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).')
118+
expect(console.error).toHaveBeenCalledWith('\nFailed 2 package(s):')
119+
expect(console.error).toHaveBeenCalledWith(
120+
'\n- @purestack/first@1.0.0 (registry check)\nRegistry unavailable',
121+
)
122+
expect(console.error).toHaveBeenLastCalledWith(
123+
expect.stringContaining('@purestack/second@1.0.0 (publish)'),
124+
)
125+
})
126+
127+
it('reports dry-run check failures without attempting to publish', async () => {
128+
mocks.checkFailures.set('@purestack/first', 'Registry unavailable')
129+
130+
expect(await publishNewPackages([])).toBe(1)
131+
132+
expect(publishAttempts()).toEqual([])
133+
expect(console.log).toHaveBeenLastCalledWith(
134+
'Dry run complete. 2 package version(s) would be published.',
135+
)
136+
expect(console.error).toHaveBeenLastCalledWith(
137+
'\n- @purestack/first@1.0.0 (registry check)\nRegistry unavailable',
138+
)
139+
})
140+
141+
it('keeps already-published versions as successful skips', async () => {
142+
mocks.existing.add('@purestack/second')
143+
mocks.existing.add('@purestack/second@1.0.0')
144+
mocks.publishFailures.set(
145+
'first',
146+
'cannot publish over the previously published versions',
147+
)
148+
149+
expect(await publishNewPackages(['--publish'])).toBe(0)
150+
151+
expect(publishAttempts()).toEqual(['first', 'third'])
152+
expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).')
153+
expect(console.error).not.toHaveBeenCalled()
154+
})
155+
})

‎scripts/publish-new-packages.ts‎

Lines changed: 89 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile } from 'node:child_process'
22
import { existsSync, readFileSync } from 'node:fs'
33
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
45
import { promisify } from 'node:util'
56
import { workspaceManifestPaths } from './workspaces'
67

@@ -48,10 +49,11 @@ interface PublishOptions {
4849
provenance: boolean
4950
}
5051

51-
const options = parseArgs(process.argv.slice(2))
52-
const publishablePackages = sortByWorkspaceDependencies(
53-
discoverPublishablePackages(),
54-
)
52+
interface PackageFailure {
53+
id: string
54+
stage: 'registry check' | 'publish'
55+
error: unknown
56+
}
5557

5658
async function npmJson(args: string[]) {
5759
return execFileAsync(
@@ -75,15 +77,15 @@ async function npm(args: string[]) {
7577
)
7678
}
7779

78-
async function packageExists(packageName: string) {
80+
async function packageExists(packageName: string, registry: string) {
7981
try {
8082
await npmJson([
8183
'view',
8284
packageName,
8385
'version',
8486
'--prefer-online',
8587
'--registry',
86-
options.registry,
88+
registry,
8789
])
8890
return true
8991
} catch (error) {
@@ -92,15 +94,19 @@ async function packageExists(packageName: string) {
9294
}
9395
}
9496

95-
async function packageVersionExists(packageName: string, version: string) {
97+
async function packageVersionExists(
98+
packageName: string,
99+
version: string,
100+
registry: string,
101+
) {
96102
try {
97103
await npmJson([
98104
'view',
99105
`${packageName}@${version}`,
100106
'version',
101107
'--prefer-online',
102108
'--registry',
103-
options.registry,
109+
registry,
104110
])
105111
return true
106112
} catch (error) {
@@ -127,7 +133,9 @@ function isExecError(error: unknown): error is {
127133
typeof error === 'object' &&
128134
error !== null &&
129135
'stdout' in error &&
130-
'stderr' in error
136+
typeof error.stdout === 'string' &&
137+
'stderr' in error &&
138+
typeof error.stderr === 'string'
131139
)
132140
}
133141

@@ -256,7 +264,7 @@ Options:
256264
`)
257265
}
258266

259-
async function publishPackage(pkg: PackageInfo) {
267+
async function publishPackage(pkg: PackageInfo, options: PublishOptions) {
260268
if (!existsSync(pkg.packageTarballPath)) {
261269
throw new Error(
262270
`${pkg.packageJson.name} is missing ${path.relative(projectRoot, pkg.packageTarballPath)}. Run yarn package first.`,
@@ -294,7 +302,26 @@ function isAlreadyPublished(error: unknown) {
294302
)
295303
}
296304

297-
async function main() {
305+
function reportFailures(failures: PackageFailure[]) {
306+
if (!failures.length) return 0
307+
console.error(`\nFailed ${failures.length} package(s):`)
308+
for (const { id, stage, error } of failures) {
309+
const message =
310+
isExecError(error) && error.stderr.trim()
311+
? error.stderr.trim()
312+
: error instanceof Error
313+
? error.message
314+
: String(error)
315+
console.error(`\n- ${id} (${stage})\n${message}`)
316+
}
317+
return 1
318+
}
319+
320+
export async function publishNewPackages(args = process.argv.slice(2)) {
321+
const options = parseArgs(args)
322+
const publishablePackages = sortByWorkspaceDependencies(
323+
discoverPublishablePackages(),
324+
)
298325
if (publishablePackages.length === 0) {
299326
throw new Error('No publishable PureStack packages found.')
300327
}
@@ -304,52 +331,77 @@ async function main() {
304331
)
305332

306333
const missingPackages: PackageInfo[] = []
334+
const failures: PackageFailure[] = []
307335

308336
for (const pkg of publishablePackages) {
309-
if (!pkg.packageJson.version) {
310-
throw new Error(`${pkg.packageJson.name} is missing version.`)
311-
}
312-
313-
const nameExists = await packageExists(pkg.packageJson.name)
314-
const versionExists =
315-
nameExists &&
316-
(await packageVersionExists(
337+
const id = pkg.packageJson.version
338+
? `${pkg.packageJson.name}@${pkg.packageJson.version}`
339+
: pkg.packageJson.name
340+
try {
341+
if (!pkg.packageJson.version)
342+
throw new Error(`${pkg.packageJson.name} is missing version.`)
343+
344+
const nameExists = await packageExists(
317345
pkg.packageJson.name,
318-
pkg.packageJson.version,
319-
))
320-
if (versionExists) {
321-
console.log(
322-
`skip existing ${pkg.packageJson.name}@${pkg.packageJson.version}`,
346+
options.registry,
323347
)
324-
continue
325-
}
348+
const versionExists =
349+
nameExists &&
350+
(await packageVersionExists(
351+
pkg.packageJson.name,
352+
pkg.packageJson.version,
353+
options.registry,
354+
))
355+
if (versionExists) {
356+
console.log(`skip existing ${id}`)
357+
continue
358+
}
326359

327-
missingPackages.push(pkg)
328-
console.log(`missing ${pkg.packageJson.name}@${pkg.packageJson.version}`)
360+
missingPackages.push(pkg)
361+
console.log(`missing ${id}`)
362+
} catch (error) {
363+
failures.push({ id, stage: 'registry check', error })
364+
console.error(`Registry check failed for ${id}; continuing.`)
365+
}
329366
}
330367

331368
if (!options.publish) {
332369
console.log(
333370
`Dry run complete. ${missingPackages.length} package version(s) would be published.`,
334371
)
335-
return
372+
return reportFailures(failures)
336373
}
337374

338375
let publishedCount = 0
339376
for (const pkg of missingPackages) {
340377
const id = `${pkg.packageJson.name}@${pkg.packageJson.version}`
341378
console.log(`publishing ${id}`)
342-
if (await publishPackage(pkg)) {
343-
publishedCount += 1
344-
continue
379+
try {
380+
if (await publishPackage(pkg, options)) {
381+
publishedCount += 1
382+
continue
383+
}
384+
console.log(`skip already published ${id}`)
385+
} catch (error) {
386+
failures.push({ id, stage: 'publish', error })
387+
console.error(`Publish failed for ${id}; continuing.`)
345388
}
346-
console.log(`skip already published ${id}`)
347389
}
348390

349391
console.log(`Published ${publishedCount} new package(s).`)
392+
return reportFailures(failures)
350393
}
351394

352-
main().catch((error) => {
353-
console.error('Publish-new-packages failed:', error)
354-
process.exit(1)
355-
})
395+
if (
396+
process.argv[1] &&
397+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
398+
) {
399+
publishNewPackages()
400+
.then((status) => {
401+
process.exitCode = status
402+
})
403+
.catch((error: unknown) => {
404+
console.error('Publish-new-packages failed:', error)
405+
process.exitCode = 1
406+
})
407+
}

‎vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig({
99
include: [
1010
'packages/*/src/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}',
1111
'frontend/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}',
12+
'scripts/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}',
1213
],
1314
exclude: [
1415
'packages/_ts-ssg-vscode/src/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}',

0 commit comments

Comments
 (0)