Skip to content

Commit adb4128

Browse files
committed
Fix packageManager write crash and harden dist-tag check
- getSection() now validates source field is an object before using `in` operator, fixing "Cannot use 'in' operator" crash when writing packageManager updates (string field, not object) - Dist-tag check in resolve-dependency guards against non-object distTags - Adversarial tests for non-object source fields (string, number, bool, null, array) - Regression test for packageManager write flow
1 parent 9eac8d6 commit adb4128

6 files changed

Lines changed: 292 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Semver because I'm not a psychopath.
44

5-
## [1.1.1] - 2026-03-31
5+
## [1.1.2] - 2026-03-31
66

77
### Fixed
88

99
- **Dist-tag versions no longer cause false resolution errors** -- dependencies using dist-tags as their version (e.g., `npm:@fumadocs/base-ui@latest`, `next`, `canary`) are now correctly skipped instead of failing with "Failed to resolve from registry". Dist-tags resolve dynamically at install time, so there is nothing to update.
10+
- **`packageManager` write no longer crashes with `Cannot use 'in' operator`** -- the `packageManager` field is a string (e.g., `bun@1.3.10`), not an object. The write loop now guards against non-object source fields instead of blindly using the `in` operator on them.
1011

1112
## [1.1.0] - 2026-03-29
1213

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "depfresh",
3-
"version": "1.1.1",
3+
"version": "1.1.2",
44
"description": "Keep your npm dependencies fresh. Fast, correct, zero-config.",
55
"type": "module",
66
"license": "MIT",

src/io/resolve/resolve-dependency.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ export async function resolveDependency(
103103
}
104104

105105
// Skip dist-tag versions (e.g., "latest", "next") — they resolve dynamically at install time
106-
if (currentVersion in pkgData.distTags) {
106+
if (
107+
pkgData.distTags &&
108+
typeof pkgData.distTags === 'object' &&
109+
currentVersion in pkgData.distTags
110+
) {
107111
logger.debug(`Skipping ${dep.name}: version "${currentVersion}" is a dist-tag`)
108112
return null
109113
}

src/io/write/package-json.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,7 @@ function getSection(raw: Record<string, unknown>, source: string): Record<string
8282
}
8383
return current as Record<string, string> | null
8484
}
85-
return (raw[source] as Record<string, string>) ?? null
85+
const value = raw[source]
86+
if (!value || typeof value !== 'object') return null
87+
return value as Record<string, string>
8688
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import type { PackageMeta, ResolvedDepChange } from '../../types'
6+
import { writePackage } from './index'
7+
8+
function makeChange(overrides: Partial<ResolvedDepChange> = {}): ResolvedDepChange {
9+
return {
10+
name: 'test-pkg',
11+
currentVersion: '^1.0.0',
12+
source: 'dependencies',
13+
update: true,
14+
parents: [],
15+
targetVersion: '^2.0.0',
16+
diff: 'major',
17+
pkgData: { name: 'test-pkg', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } },
18+
...overrides,
19+
}
20+
}
21+
22+
function makePkg(
23+
filepath: string,
24+
raw: Record<string, unknown>,
25+
overrides: Partial<PackageMeta> = {},
26+
): PackageMeta {
27+
return {
28+
name: (raw.name as string) ?? 'test-project',
29+
type: 'package.json',
30+
filepath,
31+
deps: [],
32+
resolved: [],
33+
raw,
34+
indent: ' ',
35+
...overrides,
36+
}
37+
}
38+
39+
describe('writePackage adversarial: non-object source fields', () => {
40+
let tmpDir: string
41+
42+
beforeEach(() => {
43+
tmpDir = mkdtempSync(join(tmpdir(), 'depfresh-adv-'))
44+
})
45+
46+
afterEach(() => {
47+
rmSync(tmpDir, { recursive: true, force: true })
48+
})
49+
50+
it('does not crash when source field is a string (packageManager)', () => {
51+
const filepath = join(tmpDir, 'package.json')
52+
const raw = {
53+
name: 'test',
54+
packageManager: 'bun@1.3.10',
55+
dependencies: { foo: '^1.0.0' },
56+
}
57+
const content = `${JSON.stringify(raw, null, 2)}\n`
58+
writeFileSync(filepath, content)
59+
60+
const pkg = makePkg(filepath, raw, {
61+
packageManager: { name: 'bun', version: '1.3.10', raw: 'bun@1.3.10' },
62+
})
63+
64+
expect(() => {
65+
writePackage(
66+
pkg,
67+
[
68+
makeChange({
69+
name: 'bun',
70+
source: 'packageManager' as ResolvedDepChange['source'],
71+
currentVersion: '1.3.10',
72+
targetVersion: '1.3.11',
73+
diff: 'patch',
74+
}),
75+
],
76+
'silent',
77+
)
78+
}).not.toThrow()
79+
80+
const parsed = JSON.parse(readFileSync(filepath, 'utf-8'))
81+
expect(parsed.packageManager).toBe('bun@1.3.11')
82+
})
83+
84+
it('does not crash when source field is a number', () => {
85+
const filepath = join(tmpDir, 'package.json')
86+
const raw = { name: 'test', weirdField: 42, dependencies: { foo: '^1.0.0' } }
87+
const content = `${JSON.stringify(raw, null, 2)}\n`
88+
writeFileSync(filepath, content)
89+
90+
const pkg = makePkg(filepath, raw)
91+
92+
expect(() => {
93+
writePackage(
94+
pkg,
95+
[
96+
makeChange({
97+
name: 'something',
98+
source: 'weirdField' as ResolvedDepChange['source'],
99+
targetVersion: '2.0.0',
100+
}),
101+
],
102+
'silent',
103+
)
104+
}).not.toThrow()
105+
})
106+
107+
it('does not crash when source field is a boolean', () => {
108+
const filepath = join(tmpDir, 'package.json')
109+
const raw = { name: 'test', private: true }
110+
const content = `${JSON.stringify(raw, null, 2)}\n`
111+
writeFileSync(filepath, content)
112+
113+
const pkg = makePkg(filepath, raw)
114+
115+
expect(() => {
116+
writePackage(
117+
pkg,
118+
[
119+
makeChange({
120+
name: 'pkg',
121+
source: 'private' as ResolvedDepChange['source'],
122+
targetVersion: '1.0.0',
123+
}),
124+
],
125+
'silent',
126+
)
127+
}).not.toThrow()
128+
})
129+
130+
it('does not crash when source field is null', () => {
131+
const filepath = join(tmpDir, 'package.json')
132+
const raw = { name: 'test', nullField: null }
133+
const content = `${JSON.stringify(raw, null, 2)}\n`
134+
writeFileSync(filepath, content)
135+
136+
const pkg = makePkg(filepath, raw)
137+
138+
expect(() => {
139+
writePackage(
140+
pkg,
141+
[
142+
makeChange({
143+
name: 'pkg',
144+
source: 'nullField' as ResolvedDepChange['source'],
145+
targetVersion: '1.0.0',
146+
}),
147+
],
148+
'silent',
149+
)
150+
}).not.toThrow()
151+
})
152+
153+
it('does not crash when source field is an array', () => {
154+
const filepath = join(tmpDir, 'package.json')
155+
const raw = { name: 'test', files: ['dist', 'src'] }
156+
const content = `${JSON.stringify(raw, null, 2)}\n`
157+
writeFileSync(filepath, content)
158+
159+
const pkg = makePkg(filepath, raw)
160+
161+
expect(() => {
162+
writePackage(
163+
pkg,
164+
[
165+
makeChange({
166+
name: 'dist',
167+
source: 'files' as ResolvedDepChange['source'],
168+
targetVersion: '1.0.0',
169+
}),
170+
],
171+
'silent',
172+
)
173+
}).not.toThrow()
174+
})
175+
176+
it('packageManager with hash is preserved through write', () => {
177+
const filepath = join(tmpDir, 'package.json')
178+
const raw = {
179+
name: 'test',
180+
packageManager: 'pnpm@9.0.0+sha512.deadbeef',
181+
}
182+
const content = `${JSON.stringify(raw, null, 2)}\n`
183+
writeFileSync(filepath, content)
184+
185+
const pkg = makePkg(filepath, raw, {
186+
packageManager: {
187+
name: 'pnpm',
188+
version: '9.0.0',
189+
hash: 'sha512.deadbeef',
190+
raw: 'pnpm@9.0.0+sha512.deadbeef',
191+
},
192+
})
193+
194+
writePackage(
195+
pkg,
196+
[
197+
makeChange({
198+
name: 'pnpm',
199+
source: 'packageManager' as ResolvedDepChange['source'],
200+
currentVersion: '9.0.0',
201+
targetVersion: '10.0.0',
202+
diff: 'major',
203+
}),
204+
],
205+
'silent',
206+
)
207+
208+
const parsed = JSON.parse(readFileSync(filepath, 'utf-8'))
209+
expect(parsed.packageManager).toBe('pnpm@10.0.0+sha512.deadbeef')
210+
})
211+
212+
it('mixed packageManager + dependency changes work together', () => {
213+
const filepath = join(tmpDir, 'package.json')
214+
const raw = {
215+
name: 'test',
216+
packageManager: 'bun@1.3.10',
217+
dependencies: { lodash: '^4.17.0' },
218+
}
219+
const content = `${JSON.stringify(raw, null, 2)}\n`
220+
writeFileSync(filepath, content)
221+
222+
const pkg = makePkg(filepath, raw, {
223+
packageManager: { name: 'bun', version: '1.3.10', raw: 'bun@1.3.10' },
224+
})
225+
226+
writePackage(
227+
pkg,
228+
[
229+
makeChange({
230+
name: 'bun',
231+
source: 'packageManager' as ResolvedDepChange['source'],
232+
currentVersion: '1.3.10',
233+
targetVersion: '1.3.11',
234+
diff: 'patch',
235+
}),
236+
makeChange({
237+
name: 'lodash',
238+
source: 'dependencies',
239+
currentVersion: '^4.17.0',
240+
targetVersion: '^4.18.0',
241+
diff: 'minor',
242+
}),
243+
],
244+
'silent',
245+
)
246+
247+
const parsed = JSON.parse(readFileSync(filepath, 'utf-8'))
248+
expect(parsed.packageManager).toBe('bun@1.3.11')
249+
expect(parsed.dependencies.lodash).toBe('^4.18.0')
250+
})
251+
})

src/io/write/write.package-json.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,36 @@ describe('writePackage', () => {
222222
expect(result).toBe(content)
223223
})
224224

225+
it('does not crash when packageManager change goes through source loop', () => {
226+
const filepath = join(tmpDir, 'package.json')
227+
const raw = {
228+
name: 'test',
229+
packageManager: 'bun@1.3.10',
230+
dependencies: { foo: '^1.0.0' },
231+
}
232+
const content = `${JSON.stringify(raw, null, 2)}\n`
233+
writeFileSync(filepath, content)
234+
235+
const pkg = makePkg(filepath, raw, {
236+
packageManager: { name: 'bun', version: '1.3.10', raw: 'bun@1.3.10' },
237+
})
238+
const changes = [
239+
makeChange({
240+
name: 'bun',
241+
source: 'packageManager' as ResolvedDepChange['source'],
242+
currentVersion: '1.3.10',
243+
targetVersion: '1.3.11',
244+
diff: 'patch',
245+
}),
246+
]
247+
248+
writePackage(pkg, changes, 'silent')
249+
250+
const result = readFileSync(filepath, 'utf-8')
251+
const parsed = JSON.parse(result)
252+
expect(parsed.packageManager).toBe('bun@1.3.11')
253+
})
254+
225255
it('handles pnpm.overrides dotted source path', () => {
226256
const filepath = join(tmpDir, 'package.json')
227257
const raw = {

0 commit comments

Comments
 (0)